深入理解计算机系统
原文地址:http://www.code-farmer.tech/archives/csapp3
第三章:程序的机器级表示
计算机执行机器代码,用字节序列编码低级的操作,包括处理数据、内存管理、读写存储设备上的数据,以及利用网络通信。编译器基于编程语言的规则、目标机器的指令集合操作系统遵循的惯例,经过一系列的阶段生成机器代码。汇编代码是机器代码的文本表示。
1、程序编码
1.1、机器级代码
计算机系统使用了多种不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节。对于机器级编程来说,其中两种抽象尤为重要。第一种是由指令集体系结构或指令集架构(Instruction Set Architecture,ISA)来定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。第二种抽象是,机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来。
x86-64的机器代码和原始的C代码差别非常大。一些通常对C语言程序员隐藏的处理器状态都是可见的:
- 程序计数器(通常称为"PC",在x86-64中用 %rip 表示)给出将要执行的下一条指令在内存中的地址。
- 整数寄存器文件包含16个命名的位置,分别存储64位的值。这些寄存器可以存储地址(对应于C语言的指针)或整数数据。有的寄存器被用来记录某些重要的程序状态,而其他的寄存器用来保存临时数据,例如过程的参数和局部变量,以及函数的返回值。
- 条件码寄存器保存着最近执行的算术或逻辑指令的状态信息。它们用来实现控制或数据流中的条件变化,比如说用来实现if和while语句。
- 一组向量寄存器可以存放一个或多个整数或浮点数值。
程序内存包含:程序的可执行机器代码,操作系统需要的一些信息,用来管理过程调用和返回的运行时栈,以及用户分配的内存块(比如说用malloc库函数分配的)。程序内存用虚拟地址来寻址。在任意给定的时刻,只有有限的一部分虚拟地址被认为是合法的。操作系统负责管理虚拟地址空间,将虚拟地址翻译成实际处理器内存中的物理地址。
一条机器指令只执行一个非常基本的操作。例如,将存放在寄存器中的两个数字相加,在存储器和寄存器之间传送数据,或是条件分支转移到新的指令地址。编译器必须产生这些指令的序列,从而实现(像算术表达式求值、循环或过程调用和返回这样的)程序结构。
1.2、代码示例
下面是一段C语言代码文件 mstore.c,包含如下的函数定义
long mult2(long,long);
void multstore(long x,long y,long *dest){
long t =mult2(x,y);
*dest=t;
}
在linux系统下使用"-S"选项,就能看到C语言编译器产生的汇编代码:
linux -Og -S mstore.c
这会使GCC运行编译器,产生一个汇编文件mstore.s。
汇编代码文件包含各种声明,包括下面几行:
multstore:
pushq %rbx
movq %rdx,%rbx
call mult2
movq %rax,(%rbx)
popq %rbx
ret
要查看机器代码文件的内容,有一类称为反汇编器(disassembler)的程序非常有用。这些程序根据机器代码产生一种类似汇编代码的格式,如下命令:
linux> objdump -d mstore.o
结果如下:
0000000000000000 <multstore>:
Offset Bytes Equivalent assembly language
0: 53 push %rbx
1: 48 89 d3 mov %rdx,%rbx
4: e8 00 00 00 00 callq 9 <multstore+0x9>
9: 48 89 03 mov %rax,(%rbx)
c: 5b pop %rbx
d: c3 retq
在左边,我们看到按照前面给出的字节顺序排列的14个16进制字节值,它们分成了若干组,每组1~5个字节。每组都是一条指令,右边是等价的汇编语言。
2、数据格式
由于是从16位体系结构扩展成32位的,Intel用术语"字(word)"表示16位数据类型。因此,称32位数为“双字(double words)”,称64位数为“四字(quad words)”。
CSAPP 3-1.png 浮点数主要有两种形式:单精度(4字节)值,对应于C语言数据类型float;双精度(8字节)值,对应于C语言数据类型double。
数据传输指令有四种:movb(传送字节)、movw(传送字)、movl(传送双字)、movq(传送四字)。
3、访问信息
一个X86-64的中央处理器单元(CPU)包含一组16个存储64位值得通用目的寄存器。这些寄存器用来存储整数数据和指针。
CSAPP 3-2.png3.1、操作数指示符
大多数指令有一个或多个操作数(operand),指示出执行一个操作中要使用的数据值,以及放置结果的目的位置。源数据值可以以常数形式给出,或是从寄存器或内存中读出。结果可以存放在寄存器或内存中。因此,各种不同的操作数可能性被分为三种类型。第一种类型是立即数(immediate),用来表示常数值。在ATT格式的汇编代码中,立即数的书写方式是'-577或$0x1F。不同的指令允许的立即数值范围不同,汇编器会自动选择最紧凑的方式进行数值编码。第二种类型是寄存器(register),它表示某个寄存器的内容,16个寄存器的低位1字节、2字节、4字节或8字节中的一个作为操作数,这些字节分别对应于8位、16位、32位或64位。第三类操作数是内存引用,它会根据计算出来的地址(通常称为有效地址)访问某个内存地址。因为将内存看成一个很大的数组,我们用符号Mb[Addr]表示对存储在内存中从地址Addr开始的b个字节值得引用。为了简便,我们通常省去下标b。
如下图所示,有多种不同的寻址模式,允许不同形式的内存引用。表中底部用语法Imm(rb,ri,s)表示的是最常见的形式。这样的引用有四个组成部分:一个立即数偏移Imm,一个基址寄存器rb,一个变址寄存器ri和一个比例因子s,这里的s必须是1、2、4或者8。基址和变址寄存器都必须是64位寄存器。有效地址被计算位Imm+R[rb]+R[ri]*s。引用数组元素时,会用到这种通用形式。其他形式都是这种通用形式的特殊情况,只是省略了某些部分。
3.2、数据传送指令
最频繁使用的指令是将数据从一个位置复制到另一个位置的指令。操作数表示的通用性使得一条简单的数据传送指令能够完成许多机器中要好几条不同指令才能完成的功能。
如下图所示的是最简单的形式的数据传送指令---MOV类。这些指令把数据从源位置复制到目的位置,不做任何变化。MOV类由四条指令组成:movb、movw、movl和movq。主要区别是操作的数据大小不同:分别是1、2、4和8字节。
3.3、数据传送示例
下面是数据交换函数和汇编代码:
long exchange(long *xp,long y){
long x = *xp;
*xp = y;
return x;
}
long exchange(long *xp,long y)
xp in %rdi , y in %rsi
exchange:
movq (%rdi),%rax
movq %rsi,(%rdi)
ret
以上函数exchange由三条指令实现:两个数据传送(movq)和一个返回函数被调用点的指令(ret)。
当过程执行时,过程参数xp和y分别存储在寄存器%rdi和%rsi中。然后,指令2从内存中读出x,把它存放到寄存器%rax中,直接实现了C程序中的操作x=*xp。稍后,用寄存器%rax从这个函数返回一个值,因而返回值就是x。指令3将y写入到寄存器%rdi中的xp指向的内存位置,直接实现了操作 *xp=y。
3.4、压入和弹出栈数据
最后两个数据传送操作可以将数据压入程序栈中,以及从程序栈中弹出数据。栈是一种数据结构,在处理过程调用中起到至关重要的作用,可以添加或者删除值,不过要遵循“后进先出”的原则。通过push操作把数据压入栈中,通过pop操作删除数据;它具有一个属性:弹出的值永远是最近被压入而且仍然在栈中的值。栈可以实现为数组,总是从数组的一端插入和删除元素。这一端被称为栈顶。
CSAPP 3-5.pngpushq指令的功能是把数据压入到栈上,而popq指令是弹出数据。这些指令只有一个操作数---压入的数据源和弹出的数据目的。
CSAPP 3-6.png4、算术和逻辑操作
大多数操作都分成了指令类,这些指令类有各种带不同大小操作操作数的变种。例如,指令类ADD由四条加法指令组成:addb、addw、addl和addq,分别是字节加法、字加法、双字加法和四字加法。这些操作被分为四组:加载有效地址、一元操作和移位。
CSAPP 3-7.png4.1、加载有效地址
加载有效地址(load effective address)指令leaq实际上是movq指令的变形。它的指令形式是从内存读数据到寄存器,但实际上它根本就没有引用内存。它的第一个操作数看上去是一个内存引用,但该指令并不是从指定的位置读入数据,而是将有效地址写入到目的操作数。编译器经常发现leaq的一些灵活用法,根本就与有效地址计算无关。目的操作数必须是一个寄存器。
4.2、一元和二元操作
第二组中的操作是一元操作,只有一个操作数,既是源又是目的。这个操作数可以是一个寄存器,也可以是一个内存位置。
第三组是二元操作,其中,第二个操作数既是源又是目的。
4.3、移位操作
最后一组是移位操作,先给出移位量,然后第二项给出的是要移位的数。可以进行算术和逻辑右移。移位量可以是一个立即数,或者放在单字节寄存器%cl中。
左移指令有两个名字:SAL和SHL。两者的效果是一样的,都是将右边填上0,右移指令不同,SAR执行算术移位(填上符号位),而SHR执行逻辑移位(填上0)。移位操作的目的操作数可以是一个寄存器或是一个内存位置。
5、控制
与数据相关的控制流是实现有条件行为的更一般和更常见的方法,通常程序都是顺序执行的。用jump指令可以改变一组机器代码指令的执行顺序,jump指令指定控制应该被传递到程序的某个其他部分,可能是依赖于某个测试的的结果。
5.1、条件码
除了整数寄存器,CPU还维护着一组单个位的条件码(condition code)寄存器,它们描述了最近的算术或逻辑操作的属性。可以检测这些寄存器来执行条件分支指令。最常见的条件码有:
- CF:进位标志。最近的操作使最高为产生了进位。可用来检查无符号操作的溢出。
- ZF:零标志。最近的操作得出的结果为0。
- SF:符号标志。最近的操作得到的结果为负数。
- OF:溢出标志。最近的操作导致一个补码溢出---正溢出和负溢出。
5.2、访问条件码
条件码通常不会直接读取。常用的使用方法有三种:
- 可以根据条件码的某种组合,将一个字节设置为0或者1,
- 可以条件跳转到程序的某个其他的部分,
- 可以有条件的传送数据。
5.3、跳转指令
正常执行的情况下,指令按照它们出现的顺序一条条执行。跳转(jump)指令会导致执行切换到程序中一个全新的位置。在汇编代码中,这些跳转的目的地通常用一个标号(label)指明。
CSAPP 3-8.png图中所示的其他跳转指令都是有条件的---它们根据条件码的某种组合,或者跳转,或者继续执行代码序列中下一条指令。
5.4、跳转指令的编码
在汇编代码中,跳转目标用符号标号书写。汇编器,以及后来的链接器,会产生跳转目标的适当编码。跳转指令有几种不同的编码,但是最常用都是PC相对的(PC-relative)。也就是,它们会将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码。这些地址偏移量可以编码为1、2或4个字节。第二种编码方法是给出“绝对地址”,用4个字节直接指定目标。汇编器和链接器会选择适当的跳转目的编码。
5.5、用条件控制来实现条件分支
将条件表达式和语句从C语言翻译成机器代码,最常用的方式是结合有条件和无条件跳转。
CSAPP 3-9.png5.6、用条件传送来实现条件分支
实现条件操作的传统方法是通过使用控制的条件转移。当条件满足是,程序沿着一条执行路径执行,而当条件不足时,就走另一条路径。这种机制简单而通用,但是现在处理器上,他可能会非常低效。
一种替代的策略是使用数据的条件转移。这种方法计算一个条件操作的两种结果,然后根据条件是否满足从中选取一个。只有在一些受限制的情况中,这种策略才可行,但是如果可行,就可以用一条简单的条件传送指令来实现它,条件传送指令更符合现代处理器的性能特性。
5.7、循环
C语言提供了多种循环结构,即do-while、while和for。汇编中没有相应的指令存在。
1、do-while
2、while
3、for循环
CSAPP 3-13-1.pngCSAPP 3-13-2.png
5.8、switch语句
switch语句可以根据一个整数索引值进行多重分支(multiway branching)。在处理具有多种可能结果的测试时,这种语句特别有用。它们不仅提高了C代码的可读性,而且通过使用跳转表(jump table)这种数据结构使得实现更加高效。跳转表是一个数组,表项i是一个代码段的地址,这个代码段实现当开关索引值等于i时程序应该采取的动作。
CSAPP 3-14.png6、过程
过程是软件中一种很重要的抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选返回实现了某个功能。然后,可以在程序中不同的地方调用这个函数。设计良好的软件过程作为抽象机制,隐藏某个行为的具体表现,同时又提供清晰简洁的接口定义,说明要计算的是哪些值,过程会对程序状态产生影响。过程形式多样:函数(function)、方法(method)、子例程(subroutine)、处理函数(handler)等等,但是它们有一些共有的特性。
要提供对过程的机器级支持,必须处理许多不同的属性。假设过程P调用过程Q,Q执行后返回到P。这些动作包括下面一个或多个机制:
- 传递控制。在过程进入Q的时候,程序计数器必须设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。
- 传递数据。P必须能够向Q提供一个或多个参数,Q必须能够向P返回一个值。
- 分配和释放空间。在开始时。Q可能需要为局部变量分配空间,而在返回前,又必须释放和这些存储空间。
6.1、运行时栈
C语言过程调用机制的一个关键性在于使用了栈数据结构提供的后进先出的内存管理原则。在过程P调用过程Q的例子中,可以看到当Q执行时,P以及所有在向上追溯到P的调用链中的过程,都是被暂时挂起的。当Q运行时,它只需要为局部变量分配新的存储空间,或者设置到另一个过程的调用。另一方面,当Q返回时,任何它所分配的局部存储空间都可以被释放。因此,程序可以用栈来管理它的过程所需要的存储空间,栈和程序寄存器存放着传递控制和数据、分配内存所需要的信息。当P调用Q时,控制和数据信息添加到栈尾。当P返回时,这些信息会释放掉。
CSAPP 3-15.png当X86-64 过程需要的存储空间超出寄存器能够存放的大小时,就会在栈上分配空间。这个部分称为栈帧(stack fram)。如上图,给出了运行时栈的调用结构,包括把它划分为栈帧。当前正在执行的过程的帧总是在栈顶。当过程P调用过程Q时,会把返回地址压入栈中,指明当Q返回时,要从P程序的哪个位置继续执行。我们把这个返回地址当做P 的栈帧的一部分,因为它存放的是与P相关的状态。Q的代码会扩展当前栈的边界,分配它的栈帧所需的空间。在这个空间中,它可以保存寄存器的值,分配局部变量空间,为它调用的过程设置参数。大多数过程的栈帧都是定长的,在过程的开始就分配好了。
6.2、转移控制
将控制从函数P转移到函数Q只需要简单地把程序计数器(PC)设置为Q的代码的起始位置。不过,当稍后从Q返回时,处理器必须记录好它需要继续P的执行的代码位置。在X86-64机器中,这个信息是用指令call Q调用过程Q来记录的。该指令会把地址A压入栈中,并将PC设置为Q的起始地址。压入的地址A被称为返回地址,是紧跟在call指令后面的那条指令的地址。对应的指令ret会从栈中弹出地址A,并把PC设置为A。
下表给出的是call和ret指令的一般形式:
call指令有一个目标,即指明被调用过程起始的指令地址。同跳转一样,调用可以是直接的,也可以是间接的。在汇编代码中,直接调用的目标是一个标号,而间接调用的目标是*后面跟一个操作数指示符。
6.3、数据传送
当调用一个过程时,除了要把控制传递给它并在过程返回时再传递回来之外,过程调用还可能包括把数据作为参数传递,而从过程返回还有可能包括返回一个值。X86-64中,大部分过程间的数据传送是通过寄存器实现的。例如,我们已经看到无数的函数示例,参数在寄存器%rdi、%rsi和其他寄存器中传递。当过程P调用过程Q时,P的代码必须首先把参数复制到适当的寄存器中。类似的,当Q返回到P时,P的代码可以访问寄存器%rax中的返回值。
6.4、栈上的局部存储
大多数过程示例都不需要超出寄存器大小的本地存储区域。但是有些数据必须存放在内存中,常见情况包括:
- 寄存器不足够存放所有的本地数据。
- 对一个局部变量使用地址运算符'&',因此必须能够为它产生一个地址。
- 某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到。
一般来说,过程通过减小栈指针在栈上分配空间。分配的结果作为栈帧的一部分,标号为“局部变量”,如下如所示:
CSAPP 3-15.png6.5、寄存器中的局部存储空间
寄存器组是唯一被所有过程共享的资源。虽然在给定时刻只有一个过程是活动的,我们仍然必须确保当一个过程调用另一个过程时,被调用者不会覆盖调用者稍后会使用的寄存器值。
6.6、递归过程
递归调用一个函数本身与调用其他函数是一样的。栈规则提供了一种机制,每次函数调用都有它自己私有的状态信息(保存的返回位置和被调用者保存寄存器的值)存储空间。如果需要,它还可以提供局部变量的存储。栈分配和释放的规则很自然地就与函数调用-返回的顺序匹配。这种实现函数调用和返回的方法甚至对更复杂的情况也适用,包括相互递归调用。
CSAPP 3-17.png7、数组分配和访问
7.1、基本原则
对于数据类型 T 和整型常数 N,声明如下:
T A[N]
起始位置表示为xA。这个声明有两个效果。首先,它在内存中分配一个L*N字节的连续区域,这里L是数据类型T的大小(单位为字节)。其次,它引入了标识符A,可以用A来作为指向数组开头的指针,这个指针的值就是xA。可以用0~N-1的整数索引来访问该数组元素。数组元素i会被存放在地址xA+L * i 的地方。
7.2、指针运算
C语言允许对指针进行运算,而计算出来的值会根据该指针引用的数据类型的大小进行缩进。也就是说,如果P是一个指向型为T的数据的指针,p的值为xp,那么表达式p+i的值为xp+ L*i,这里L是数据类型T的大小。
单操作数操作符‘&’和‘’可以产生指针和间接引用指针。也就是,对于一个表示某个对象的表达式Expr,&Expr是给出该对象地址的一个指针。对于一个表示地址的表达式AExpr,AExpr给出该地址处的值。因此,表达式Expr与 * &Expr是等价的。可以对数组和指针应用数组下标操作。
7.3、嵌套数组
当我们创建数组的数组时,数组分配和引用的一般原则也是成立的。例如,声明 int A[5][3];
要访问多维数组的元素,编译器会以数组起始为基地址,偏移量为索引,产生计算预期的元素的偏移量,然后使用某种MOV指令。通常来说,对于一个声明如下的数组:
T D[R][C];
它的数组元素D[i][j]的内存地址为
&D[i][j]=xD+L(C*i+j)
这里,L是数据类型T以字节为单位的大小。
7.4、定长数组
C语言编译器能够优化定长多维数组上的操作代码。这里我们展示优化等级设置为01时GCC采用的一些优化。
7.5、变长数组
8、异质的数据结构
C语言提供了两种不同类型的对象组合到一起创建数据类型的机制:结构(structure),用关键字struct类声明,将多个对象集合到一个单元中;联合(union),用关键字union来声明,允许用几种不同的类型来引用对象。
8.1、结构
C语言的struct声明创建一个数据类型,将可能的不同类型的对象聚合到一个对象中。类似于数组的实现,结构的所有组成部分都放在内存中的一段连续区域内,而指向结构的指针就是结构第一个字节的地址。
8.2、联合
联合提供了一种方式,能够规避C语言的类型系统,语序允许多种类型来引用一个对象。
8.3、数据对齐
许多计算机系统对基本数据类型的合法地址做出来一些限制,要求某种类型对象的地址必须是某个值K的倍数。这种对齐限制简化了形成处理器和内存系统之间接口的硬件设计。
9、在机器级程序中将控制与数据结合起来
9.1、理解指针
- 每个指针都对应一个类型。
- 每个指针都有一个值。
- 指针用‘&’运算符创建。
- (*)操作符用于间接引用指针。
- 数组与指针紧密联系。
- 将指针从一种类型强制转换成另一种类型,只能改变它的类型,而不改变它的值。
10、总结
初步学习,后续总结成自己的理解!未完待续!!!
网友评论