换种方法学操作系统,轻松入门Linux内核

计算机已成为现代人日常工作、学习和生活中必不可少的工具。操作系统是计算机之魂,作为用户使用计算机的接口,它负责调度执行各个用户程序,使计算机完成特定的任务;作为计算机硬件资源的管理者,它负责协调计算机中各类设备高效地工作。操作系统的重要性不言而喻。

对于软件工程师,理解操作系统的工作原理和关键机制是设计高质量应用程序的前提,但要做到这一点是十分困难的。

一方面,操作系统设计涉及计算机科学与工程学科的方方面面,包括数据结构与算法、计算机组成与系统结构、计算机网络,甚至程序设计语言与编译系统等核心知识,以及并发、同步和通信等核心概念。

另一方面,作为一个复杂庞大的软件产品,理解操作系统更需要理论与实践深度结合。

操作系统的相关学习资料十分丰富。有阐述基本原理者,有剖析典型系统者,还有构造示例系统者;有面向专业理论者,亦有面向应用实践者。角度多种多样,内容简繁不一。

换种方法学操作系统,轻松入门Linux内核

​本书的最大特点在于作者结合其多年的Linux操作系统实际教学经验编撰而成。作为一位经验丰富的高级软件工程师和专业教师,本书作者基于自己学习和研究Linux的心得,创新性地以一个mykernel和MenuOS为基础实验平台进行教学和实验组织,实现了理论学习与工程实践的自然融合,达到了事半功倍的效果。

同时,书中设计了丰富的单元测试题和实验,引导读者循序渐进地掌握所学知识,并有效地促进读者深入思考和实践所学内容。

作者基于本书开设的操作系统课程,其教学形式涉及面对面的课堂教学和在线慕课教学,选课对象既包括软件工程硕士,又包括一般工程实践者,学习人数已数以万计。本书的出版体现了作者认真吸收大量的学员反馈,不断优化课程的教学内容和过程组织的成果。

本文重点介绍计算机的工作原理,具体涉及存储程序计算机工作模型、基本的汇编语言,以及C语言程序汇编出来的汇编代码如何在存储程序计算机工作模型上一步步地执行。其中重点分析了函数调用堆栈相关汇编指令,如call/ret和pushl/popl。

存储程序计算机工作模型

存储程序计算机的概念虽然简单,但在计算机发展史上具有革命性的意义,至今为止仍是计算机发展史上非常有意义的发明。一台硬件有限的计算机或智能手机能安装各种各样的软件,执行各种各样的程序,这在人们看起来都理所当然,其实背后是存储程序计算机的功劳。

存储程序计算机的主要思想是将程序存放在计算机存储器中,然后按存储器中的存储程序的首地址执行程序的第一条指令,以后就按照该程序中编写好的指令执行,直至程序执行结束。

相信很多人特别是学习计算机专业的人都听说过图灵机和冯·诺依曼机。图灵机关注计算的哲学定义,是一种虚拟的抽象机器,是对现代计算机的首次描述。只要提供合适的程序,图灵机就可以做任何运算。基于图灵机建造的计算机都是在存储器中存储数据,程序的逻辑都是嵌入在硬件中的。

与图灵机不同,冯·诺依曼机是一个实际的体系结构,我们称作冯·诺依曼体系结构,它至今仍是几乎所有计算机平台的基础。我们都知道“庖丁解牛”这个成语,比喻经过反复实践,掌握了事物的客观规律,做事得心应手,运用自如。冯·诺依曼体系结构就是各种计算机体系结构需要遵从的一个“客观规律”,了解它对于理解计算机和操作系统非常重要。下面,我们就来看看什么是冯·诺依曼体系结构。

在1944~1945年期间,冯·诺依曼指出程序和数据在逻辑上是相同的,程序也可以存储在存储器中。冯·诺依曼体系结构的要点包括:

冯·诺依曼体系结构如图1-1所示,其中运算器、存储器、控制器、输入设备和输出设备5大基本类型部件组成了计算机硬件;

换种方法学操作系统,轻松入门Linux内核

图1-1  冯·诺依曼体系结构

计算机内部采用二进制来表示指令和数据; 

将编写好的程序和数据先存入存储器中,然后启动计算机工作,这就是存储程序的基本含义。

计算机硬件的基础是CPU,它与内存和输入/输出(I/O)设备进行交互,从输入设备接收数据,向输出设备发送数据。

CPU由运算器(算术逻辑单元ALU)、控制器和一些寄存器组成。有一个非常重要的寄存器称为程序计数器,在IA32(x86-32)中是EIP,指示将要执行的下一条指令在存储器中的地址。

C/C++程序员可以将EIP看作一个指针,因为它总是指向某一条指令的地址。CPU就是从EIP指向的那个地址取过来一条指令执行,执行完后EIP会自动加一,执行下一条指令,然后再取下一条指令执行,CPU像“贪吃蛇”一样总是在内存里“吃”指令。

CPU、内存和I/O设备通过总线连接。内存中存放指令和数据。

“计算机内部采用二进制来表示指令和数据”表明,指令和数据的功能和处理是不同的,但都可以用二进制的方式存储在内存中。

上述第3个要点指出了冯·诺依曼体系结构的核心是存储程序计算机。

我们用程序员的思维来对存储程序计算机进行抽象,如图1-2所示。 

换种方法学操作系统,轻松入门Linux内核

图1-2  存储程序计算机工作原理示意图

我们可以把CPU抽象成一个for循环,因为它总是在执行next instruction(下一条指令),然后从内存里取下一条指令来执行。从这个角度来看,内存保存指令和数据,CPU负责解释和执行这些指令,它们通过总线连接起来。这里揭示了计算机可以自动化执行程序的原理。

这里存在一个问题,CPU能识别什么样的指令,我们这里需要有一个定义。学过编程的读者基本都知道API,也就是应用程序编程接口。

而对于程序员来讲,还有一个称为ABI的接口,它主要是一些指令的编码。在指令编码方面,我们不会涉及那么具体的细节,而只会涉及和汇编相关的内容。

至于这些指令是如何编码成二进制机器指令的,我们不必关心,有兴趣的读者可以查找指令编码的相关资料。此外,这些指令会涉及一些寄存器,这些寄存器有些约定,我们约定什么样的指令该用什么寄存器。

同时,我们也需要了解寄存器的布局。还有,大多数指令可以直接访问内存,对于x86-32计算机指令集来讲,这也是一个重要的概念

对于x86-32计算机,有一个EIP寄存器指向内存的某一条指令,EIP是自动加一的(不是一个字节,也不是32位,而是加一条指令),虽然x86-32中每条指令占的存储空间不一样,但是它能智能地自动加到下一条指令,它还可以被其他指令修改,如call、ret、jmp等,这些指令对应C语言中的函数调用、return和if else语句。

现在绝大多数具有计算功能的设备,小到智能手机,大到超级计算机,基本的核心部分可以用冯·诺依曼体系结构(存储程序计算机)来描述。因此,存储程序计算机是一个非常基本的概念,是我们理解计算机系统工作原理的基础。

x86-32汇编基础

Intel处理器系列也称为x86,经过不断的发展,体系结构经历了16位、32位和64位几个关键阶段。32位的体系结构称为IA32,64位体系结构称为x86-64,但为了明确区分两者,本书中把32位体系结构称作x86-32。本书与Linux内核采用的汇编格式保持一致,采用AT&T汇编格式。

1.x86-32 CPU的寄存器

为了便于读者理解,下面先来介绍16位的8086 CPU的寄存器。8086 CPU中总共有14个16位的寄存器:AX、BX、CX、DX、SP、BP、SI、DI、IP、FLAG、CS、DS、SS和ES。这14个寄存器分为通用寄存器、控制寄存器和段寄存器3种类型。

通用寄存器又分为数据寄存器、指针寄存器和变址寄存器。

AX、BX、CX和DX统称为数据寄存器。

qAX(Accumulator):累加寄存器,也称为累加器。

qBX(Base):基地址寄存器。

qCX(Count):计数器寄存器。

qDX(Data):数据寄存器。

SP和BP 统称为指针寄存器。

qSP(Stack Pointer):堆栈指针寄存器。

qBP(Base Pointer):基指针寄存器。

SI和DI统称为变址寄存器。

qSI(Source Index):源变址寄存器。

qDI(Destination Index):目的变址寄存器。

控制寄存器主要分为指令指针寄存器和标志寄存器。

qIP(Instruction Pointer):指令指针寄存器。

qFLAG:标志寄存器。

段寄存器主要有代码段寄存器、数据段寄存器、堆栈段寄存器和附加段寄存器。

qCS(Code Segment):代码段寄存器。

qDS(Data Segment):数据段寄存器。

qSS(Stack Segment):堆栈段寄存器。

qES(Extra Segment):附加段寄存器。

以上数据寄存器AX、BX、CX和DX都可以当作两个单独的8位寄存器来使用,如图1-3所示,以AX寄存器为例。

换种方法学操作系统,轻松入门Linux内核

图1-3  AX 寄存器示意图

qAX寄存器可以分为两个独立的8位的AH和AL寄存器。

qBX寄存器可以分为两个独立的8位的BH和BL寄存器。

qCX寄存器可以分为两个独立的8位的CH和CL寄存器。

qDX寄存器可以分为两个独立的8位的DH和DL寄存器。

除了上面4个数据寄存器以外,其他寄存器均不可以分为两个独立的8位寄存器。注意,每个分开的寄存器都有自己的名称,可以独立存取。程序员可以利用数据寄存器的这种“可分可合”的特性,灵活地处理字/字节的信息。

了解了16位的8086 CPU的寄存器之后,我们再来看32位的寄存器。

IA32所含有的寄存器包括:

q4个数据寄存器(EAX、EBX、ECX和EDX)。

q2个变址和指针寄存器(ESI和EDI)。

q2个指针寄存器(ESP和EBP)。

q6个段寄存器(ES、CS、SS、DS、FS和GS)。

q1个指令指针寄存器(EIP)。

q1个标志寄存器(EFlags)。

32位寄存器只是把对应的16位寄存器扩展到了32位,如图1-4所示为EAX寄存器示意图,它增加了一个E。所有开头为E的寄存器,一般是32位的。

EAX累加寄存器、EBX基址寄存器、ECX计数寄存器和EDX数据寄存器都是通用寄存器,程序员在写汇编码时可以自己定义如何使用它们。EBP是堆栈基址指针,比较重要;ESI、EDI是变址寄存器;ESP也比较重要,它是堆栈栈顶寄存器。

这里可能会涉及堆栈的概念,学过数据结构课程的读者应该知道堆栈的概念,本书后面会具体讲到push指令压栈和pop指令出栈,它是向一个堆栈里面压一个数据和从堆栈里面弹出一个数据。这些都是32位的通用寄存器。

换种方法学操作系统,轻松入门Linux内核

图1-4  EAX寄存器示意图

值得注意的是在16位CPU中,AX、BX、CX和DX不能作为基址和变址寄存器来存放存储单元的地址,但在32位CPU中,32位寄存器EAX、EBX、ECX和EDX不仅可以传送数据、暂存数据保存算术逻辑运算结果,还可以作为指针寄存器,因此这些32位寄存器更具通用性。

除了通用寄存器外,还有一些段寄存器。虽然段寄存器在本书中用得比较少,但还是要了解一下。除了CS、DS、ES和SS外,还有其他附加段寄存器FS和GS。

常用的是CS寄存器和SS寄存器。我们的指令都存储在代码段,在定位一个指令时,使用CS:EIP来准确指明它的地址。

也就是说,首先需要知道代码在哪一个代码段里,然后需要知道指令在代码段内的相对偏移地址EIP,一般用CS:EIP准确地标明一个指令的内存地址。还有堆栈段,每一个进程都有自己的堆栈段(在Linux系统里,每个进程都有一个内核态堆栈和一个用户态堆栈)。

标志寄存器的功能细节比较复杂烦琐,本书就不仔细介绍了,读者知道标志寄存器可以保存当前的一些状态就可以了。

现在主流的计算机大多都是采用64位的CPU,那么我们也需要简单了解一下x86-64的寄存器。实际上,64位和32位的寄存器差别也不大,它只是从32位扩展到了64位。前面带个“R”的都是指64位寄存器,如RAX、RBX、RCX、RDX、RBP、RSI、RSP,还有Flags改为了RFLAGS,EIP改为了RIP。

另外,还增加了更多的通用寄存器,如R8、R9等,这些增加的通用寄存器和其他通用寄存器只是名称不一样,在使用中都是遵循调用者使用规则,简单说就是随便用。

2.数据格式

在Intel的术语规范中,字表示16位数据类型;在IA32中,32位数称为双字;在x86-64中,64位数称为四字。图1-5所示为C语言中基本类型的IA32表示,其中列出的汇编代码后缀在汇编代码中会经常看到。 

换种方法学操作系统,轻松入门Linux内核

图1-5  C语言中基本类型的IA32表示

3.寻址方式和常用汇编指令

汇编指令包含操作码和操作数,其中操作数分为以下3种:

(1)立即数即常数,如$8,用$开头后面跟一个数值;

(2)寄存器数,表示某个寄存器中保存的值,如%eax;而对字节操作而言,是8个单字节寄存器中的一个,如%al(EAX寄存器中的低8位);

(3)存储器引用,根据计算出的有效地址来访问存储器的某个位置。

还有一些常见的汇编指令,我们来看它们是如何工作的最常见的汇编指令是mov指令,movl中的l是指32位,movb中的b是指8位,movw中的w是指16位,movq中的q是指64位。我们以32位为主进行介绍。

首先介绍寄存器寻址。所谓寄存器寻址就是操作的是寄存器,不和内存打交道,如%eax,其中%开头后面跟一个寄存器名称。

movl %eax,%edx

上述代码把寄存器%eax的内容放到%edx中。如果把寄存器名当作C语言代码中的变量名,它就相当于:

edx = eax;

立即寻址(immediate)是用一个$开头后面跟一个数值。例如:

movl $0x123, %edx

就是把0x123这个十六进制的数值直接放到EDX寄存器中。如果把寄存器名当作C语言代码中的变量名,它就相当于:

edx = 0x123;

立即寻址也和内存没有关系。

直接寻址(direct)是直接用一个数值,开头没有$符号。开头有$符号的数值表示这是一个立即数;没有$符号表示这是一个地址。例如:

movl 0x123, %edx

就是把十六进制的0x123内存地址所指向的那块内存里存储的数据放到EDX寄存器里,这相当于C语言代码:

edx = *(int*)0x123;

把0x123这个数值强制转化为一个32位的int型变量的指针,再用一个*取它指向的值,然后放到EDX寄存器中,这就称为直接寻址。

换句话说,就是用内存地址直接访问内存中的数据。

间接寻址就是寄存器加个小括号。举例说明,%ebx这个寄存器中存的值是一个内存地址,加个小括号表示这个内存地址所存储的数据,我们把它放到EDX寄存器中:

move (%ebx), %edx

就相当于:

edx = *(int*)ebx;

把这个EBX寄存器中存储的数值强制转化为一个32位的int型变量的指针,再用一个*取它指向的值,然后放到EDX寄存器中,这称为间接寻址。

变址寻址比间接寻址稍微复杂一点。例如:

movl 4(%ebx), %edx

读者会发现代码中“(%ebx)”前面出现了一个4,也就是在间接寻址的基础上,在原地址上加上一个立即数4,相当于: 

edx = *(int*)(ebx+4)

把这个EBX寄存器存储的数值加4,然后强制转化为一个32位的int类型的指针,再用一个*取它指向的值,然后放到EDX寄存器中,这称为变址寻址。

如上所述的CPU对寄存器和内存的操作方法,都是比较基础的知识,需要牢固掌握。

x86-32中的大多数指令都能直接访问内存,但还有一些指令能直接对内存操作,如push/pop。它们根据ESP寄存器指向的内存位置进行压栈和出栈操作,注意这是指令执行过程中默认使用了特定的寄存器。

还需要特别说明的是,本书中使用的是AT&T汇编格式,这也是Linux内核使用的汇编格式,与Intel汇编格式略有不同。

我们在搜索资料时可能会遇到Intel汇编代码,一般来说,全是大写字母的一般是Intel汇编,全是小写字母的一般是AT&T汇编。

本书中的代码用到的寄存器名称都遵守AT&T汇编格式采用全小写的方式,而正文中需要使用寄存器名称一般使用大写,因为它们是首字母缩写。

还有几个重要的指令:pushl/popl和call/ret。pushl表示32位的push,如:

pushl %eax

就是把EAX寄存器的值压到堆栈栈顶。它实际上做了这样两个动作,其中第一个动作为:

subl $4, %esp

把堆栈的栈顶ESP寄存器的值减4。因为堆栈是向下增长的,所以用减指令subl,也就是在栈顶预留出一个存储单元。第二个动作为:

movl %eax, (%esp)

把ESP寄存器加一个小括号(间接寻址),就是把EAX寄存器的值放到ESP寄存器所指向的地方,这时ESP寄存器已经指向预留出的存储单元了。

接下来介绍popl指令,如:

popl %eax

就是从堆栈的栈顶取一个存储单元(32位数值),从堆栈栈顶的位置放到EAX寄存器里,这称为出栈。出栈同样对应两个操作:

movl (%esp), %eax

addl $4, %esp

第一步是把栈顶的数值放到EAX寄存器里,然后用指令addl把栈顶加4,相当于栈向上回退了一个存储单元的位置,也就是栈在收缩。每次执行指令pushl栈都在增长,执行指令popl栈都在收缩。

call指令是函数调用,调用一个地址。例如:

call 0x12345

上述代码实际上做了两个动作,如下两条伪指令,注意,这两个动作并不存在实际对应的指令,我们用“(*)”来特别标记一下,这两个动作是由硬件一次性完成的。出于安全方面的原因,EIP寄存器不能被直接使用和修改。

pushl %eip (*)

movl $0x12345, %eip (*)

上述伪指令先是把当前的EIP寄存器压栈,把0x12345这个立即数放到EIP寄存器里,该寄存器是用来告诉CPU下一条指令的存储地址的。

把当前的EIP寄存器的值压栈就是把下一条指令的地址保存起来,然后给EIP寄存器又赋了一个新值0x12345,也就是CPU执行的下一条指令就是从0x12345位置取得的。

再看与call指令对应的指令ret,ret指令是函数返回,例如:

ret

上述代码实际上做了一个动作,如下一条伪指令,注意,这个动作并不存在实际对应的指令,我们用“(*)”来特别标记一下,这个动作是由硬件一次性完成的。出于安全方面的原因,EIP寄存器不能被直接使用和修改。

popl %eip(*)

也就是把当前堆栈栈顶的一个存储单元(一般是由call指令压栈的内容)放到EIP寄存器里。上述pushl/popl和call/ret汇编指令对应执行的动作汇总如图1-6所示。

换种方法学操作系统,轻松入门Linux内核

图1-6  pushl/popl和call/ret汇编指令

总结一下,call指令对应了C语言里我们调用一个函数,也就是call一个函数的起始地址。ret指令是把调用函数时压栈的EIP寄存器的值(即call指令的下一条指令的地址)还原到EIP寄存器里,ret指令之后的下一条指令也就回到函数调用位置的下一条指令。

换句话说就是函数调用结束了,继续执行函数调用之后的下一条指令,这和C语言中的函数调用过程是严格对应的。但是需要注意的是,带个“(*)”的指令表示这些指令都是不能被程序员直接使用的,是伪指令。

因为EIP寄存器不能被程序员直接修改,只能通过专用指令(如call、ret和jmp等)间接修改。

若程序员可以直接修改EIP寄存器,那么会有严重的安全隐患。读者可以思考一下为什么?我们就不展开讨论了。

4.汇编代码范例解析

我们已经对指令和寄存器有了大致的了解,下面做一个练习来验证我们的理解。在堆栈为空栈的情况下,执行如下汇编代码片段之后,堆栈和寄存器都发生了哪些变化?

1    push   $8

2    movl    %esp, %ebp

3    subl    $4, %esp

4    movl    $8, (%esp)

我们分析这段汇编代码每一步都做了什么动作。首先在堆栈为空栈的情况下,EBP和ESP寄存器都指向栈底。 

第1行语句是将立即数8压栈(即先把ESP寄存器的值减4,然后把立即数8放入当前堆栈栈顶位置)。

第2行语句是把ESP寄存器的值放到EBP寄存器里,就是把ESP寄存器存储的内容放到EBP寄存器中,把EBP寄存器也指向当前ESP寄存器所指向的位置。

换句话说,在堆栈中又新建了一个逻辑上的空栈,这一点理解起来并不容易,读者暂时理解不了也没有关系。本书后面会将C语言程序汇编成汇编代码来分析函数调用是如何实现的,其中会涉及函数调用堆栈框架。

第3行语句中的指令是subl,是把ESP寄存器存储的数值减4,也就是说,栈顶指针ESP寄存器向下移了一个存储单元(4个字节)。

最后一行语句是把立即数8放到ESP寄存器所指向的内存地址,也就是把立即数8通过间接寻址放到堆栈栈顶。 

本例是关于栈和寄存器的一些操作的,我们可以对照上述文字说明一步一步跟踪堆栈和寄存器的变化过程,以便更加准确地理解指令的作用。

再来看一段汇编代码,同样在堆栈为空栈的情况下,执行如下汇编代码片段之后,堆栈和寄存器都发生了哪些变化?

1  pushl  $8

2  movl   %esp, %ebp

3  pushl  $8

同样我们也分析一下这段汇编代码每一步都做了什么动作。首先在堆栈为空栈的情况下EBP和ESP寄存器都指向栈底。

第1行语句是将立即数8压栈,即堆栈多了一个存储单元并存了一个立即数8,同时也改变了ESP寄存器。

第2行语句把ESP寄存器的值放到EBP寄存器里,堆栈空间没有变化,但EBP寄存器发生了变化。

第3行语句将立即数8压栈,即堆栈多了一个存储单元并存了一个立即数8。

读者会发现,这个例子和上一个例子的实际效果是完全一样的。

小试牛刀之后,再看下面这段更加复杂一点的汇编代码:

1  pushl  $8

2  movl   %esp, %ebp

3  pushl  %esp

4  pushl  $8

5  addl   $4, %esp

6  popl   %esp

这段汇编代码同样首先在堆栈为空栈的情况下EBP和ESP寄存器都指向栈底。

第1行语句是将立即数8压栈,即堆栈多了一个存储单元并保存立即数8,同时也改变了ESP寄存器。

第2行语句是把ESP寄存器的值放到EBP寄存器里,堆栈空间没有变化,但EBP寄存器发生了变化。

第3行语句是把ESP寄存器的内容压栈到堆栈栈顶的存储单元里。需要注意的是,pushl指令本身会改变ESP寄存器。“pushl %esp”语句相当于如下两条指令:

subl $4, %esp

movl %esp, (%esp)

显然,在保存ESP寄存器的值到堆栈中之前改变了ESP寄存器,保存到栈顶的数据应该是当前ESP寄存器的值减4。ESP寄存器的值发生了变化,同时栈空间多了一个存储单元保存变化后的ESP寄存器的值。

第4行语句是将立即数8压栈,即堆栈多了一个存储单元保存立即数8,同时也改变了ESP寄存器。

第5行语句是把ESP寄存器的值加4,这相当于堆栈空间收缩了一个存储单元。 

最后一条语句相当于如下两条指令:

movl (%esp), %esp

addl $4, %esp

也就是把当前栈顶的数据放到ESP寄存器中,然后又将ESP寄存器加4。这一段代码比较复杂,因为ESP寄存器既作为操作数,又被pushl/popl指令在执行过程中使用和修改。

读者需要仔细分析和思考这段汇编代码以理解整个执行过程,本书后续内容会结合C代码的函数调用和函数返回,来进一步理解这段汇编代码中涉及的建立一个函数调用堆栈和拆除一个函数调用堆栈。

换种方法学操作系统,轻松入门Linux内核

庖丁解牛Linux内核分析

孟宁  娄嘉鹏  刘宇栋 著

本书从理解计算机硬件的核心工作机制(存储程序计算机和函数调用堆栈)和用户态程序如何通过系统调用陷入内核(中断异常)入手,通过上下两个方向双向夹击的策略,并利用实际可运行程序的反汇编代码从实践的角度理解操作系统内核,然后开始分析Linux内核源代码,从系统调用陷入内核,进程调度与进程切换,最后返回到用户态进程。

换种方法学操作系统,轻松入门Linux内核

长按二维码,可以关注我们哟

每天与你分享IT好文。

在“异步图书”后台回复“关注”,即可免费获得2000门在线视频课程

异步图书福利送不停

邀请10名好友关注10天直接获取异步图书一本(点击文字获取活动详情哦)

点击阅读原文,购买异步图书

阅读原文​​​​​​​​

上一篇:【RAC】关闭Clusterware 遇到CRS-2529,ORA-15097:


下一篇:Docker镜像的存储机制