第三章 程序的机器级表示
计算机执行机器代码,用字节序列编码低级的操作,包括处理数据、管理内存、读写存储设备上的数据,以及利用网络通信。编译器基于编程语言的规则、目标机器的指令集和操作系统遵循的惯例,经过一系列的阶段生成机器代码。GCC C语言编译器以汇编代码的形式产生输出,汇编代码是机器代码的文本表示,给出程序中的每一条指令。然后GCC调用汇编器和链接器,根据汇编代码生成可执行的机器代码。
3.1 程序编码
假设一个C程序,有两个文件p1.c和p2.c。用Unix命令行编译这些代码:
1 | linux> gcc -0g -o p p1.c p2.c |
- 命令
gcc指的就是GCC C编译器。因为这是Linux上默认的编译器,也可以简单地用cc来启动它。 - 编译选项
-0g告诉编译器使用会生成符合原始C代码整体结构的机器代码的优化等级。使用较高级别优化产生的代码会严重变形,以至于产生的机器代码和初始源代码之间的关系非常难以理解。 - 实际中,从得到的程序的性能考虑,较高级别的优化(例如,以选项
-01或-02指定)被认为是较好的选择。
实际上gcc命令调用了一整套的程序,将源代码转化为可执行代码:
- 首先, C预处理器扩展源代码,插入所有用
#include命令指定的文件,并扩展所有用#define声明指定的宏。 - 其次,编译器产生两个源文件的汇编代码,名字分别为
p1.s和p2.s。 - 接下来,汇编器会将汇编代码转化成二进制目标代码文件
p1.o和p2.o。目标代码是机器代码的一种形式,它包含所有指令的二进制表示,但是还没有填入全局值的地址。 - 最后,链接器将两个目标代码文件与实现库函数(例如
printf)的代码合并,并产生最终的可执行代码文件p(由命令行指示符-o p指定的)。可执行代码是我们要考虑的机器代码的第二种形式,也就是处理器执行的代码格式。
3.1.1 机器级代码
对于机器级编程来说,其中两种抽象尤为重要:
- 由指令集体系结构或指令集架构(Instruction Set Architecture, ISA)来定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。大多数ISA, 包括x86-64, 将程序的行为描述成好像每条指令都是按顺序执行的,一条指令结束后,下一条再开始。处理器的硬件远比描述的精细复杂,它们并发地执行许多指令,但是可以采取措施保证整体行为与ISA指定的顺序执行的行为完全一致。
- 机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来。
x86-64的机器代码中一些通常对C语言程序员隐藏的处理器状态都是可见的:
- 程序计数器(通常称为“PC”,在x86-64中用
%rip表示)给出将要执行的下一条指令在内存中的地址。 - 整数寄存器文件包含16个命名的位置,分别存储64位的值。这些寄存器可以存储地址(对应于C语言中的指针)或整数数据。有的寄存器被用来记录某些重要的程序状态,而其他寄存器用来保存临时数据,例如过程的参数和局部变量,以及函数的返回值。
- 条件码寄存器保存着最近执行的算术或逻辑指令的状态信息。它们用来实现控制或数据流中的条件变化,例如用来实现
if和while语句。 - 一组向量寄存器可以存放一个或多个整数或浮点数值。
机器代码只是简单地将内存看成一个很大的、按字节寻址的数组。
程序内存包含:程序的可执行机器代码,操作系统需要的一些信息,用来管理过程调用和返回的运行时栈,以及用户分配的内存块(比如说用malloc库函数分配的)。程序内存用虚拟地址来寻址。
3.1.2 代码示例
1. 编译器
C语言代码文件mstore.c中包含如下的函数定义
1 | long mult2(long, long); |
在命令行上运行linux> gcc -0g -S mstore.c产生一个汇编文件mstore.s:
1 | .file "010-mstore.c" |
所有以“.”开头的行都是指导汇编器和连接器工作的伪指令,通常可以忽略这些行。为了更清楚地说明汇编代码,用这样一种格式来表示汇编代码,它省略了大部分伪指令,但包括行号和解释性说明。对于本例该代码如下:
1 | void multstore(long x, long y, long *dest) |
汇编代码相比C语言有以下特点:
- 有许多不同类型的整型数据类型,它们存储时不区分符号和无符号
- 指针是以简单的数字的形式保存在计算机中的
- 浮点数以和整型数据完全不同的方式进行处理,并且使用完全不同的寄存器组
- 程序本身是一系列字节
- 机器级别并不存在类似数组和结构这样的基本数据类型,它们是由编译器构成的
- 汇编语言中的每条语言能做的都非常有限
- 变量所有名称在汇编代码级别完全丢失了,变成了寄存器和内存中的某个位置
2.汇编器
运行gcc -Og -c mstore.c来进行编译和汇编,会生成二进制文件mstore.o,它是对一系列指令的编码,机器直接执行这些字节序列,而对源代码一无所知。
可以通过反汇编器来将机器代码转化为类似汇编代码的格式,在Linux中,运行objdump -d mstore.o,可以得到:
1 | Disassembly of function multstore in binary file mstore.o |
最左侧一栏是对应的字节地址,中间是每个指令的编码,右侧是等价的汇编语言。其中一些关于机器代码和它的反汇编表示的特性值得注意:
- x86-64的指令长度从1到15个字节不等。常用的指令以及操作数较少的指令所需的字节数少,而那些不太常用或操作数较多的指令所需字节数较多。
- 设计指令格式的方式是,从某个给定位置开始,可以将字节唯一地解码成机器指令。例如,只有指令
pushq %rbx是以字节值53开头的。 - 反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。它不需要访问该程序的源代码或汇编代码。
- 反汇编器使用的指令命名规则与GCC 生成的汇编代码使用的有些细微的差别。在示例中,它省略了很多指令结尾的‘
q’。这些后缀是大小指示符,在大多数情况中可以省略。相反,反汇编器给call和ret指令添加了‘q’后缀,同样,省略这些后缀也没有问题。
3.链接器
生成实际可执行的代码需要对一组目标代码文件运行链接器,而这一组目标代码文件中必须含有一个main函数。假设在文件main.c中有下面这样的函数:
1 |
|
然后运行命令linux> gcc -0g -o prog main.c mstore.c将main.c和mstore.c链接起来,并添加启动和终止程序的代码,以及用来与操作系统交互的代码生成可执行代码,最终生成可执行文件prog。通过对其反汇编,可以得到如下内容:
1 | Disassembly of function sum multstore binary file prog |
可以看出,这段代码与mstore.c反汇编产生的代码的区别有:
- 左边列出的地址不同,链接器将这段代码的地址移到了一段不用的地址范围中
- 链接器填上了
callq指令调用函数mult2需要使用的地址(第5行)。链接器的任务之一就是为函数调用找到匹配的函数可执行代码的位置。 - 多了两行代码(第9和10行)。插入这些指令是为了使函数代码变为16字节,使得就存储器系统性能而言,能更好地放置下一个代码块。
3.2 数据格式

大多数GCC生成的汇编代码指令都有一个字符的后缀,表明操作数的大小。例如,数据传送指令有四个变种:movb(传送字节)、movw(传送字)、movl(传送双字)和movq(传送四字)。注意,汇编代码使用后缀‘l’来表示4字节整数和8字节双精度浮点数。这不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存器。
3.3 访问信息
一个x86-64的中央处理单元(CPU)包含一组16个存储64位值的通用目的寄存器。这些寄存器都用来存储整数数据和指针,名字都以%r开头,不过后面还跟着一些不同的命名规则的名字,这是由于指令集历史演化造成的。
当指令以寄存器作为目标时,对于生成小于8字节结果的指令,对于寄存器中剩下的字节会怎么样有两条规则:生成1字节和2字节数字的指令会保持剩下的字节不变;生成4字节数字的指令会把高位4个字节置为0。
有一组标准的编程规范控制着如何使用寄存器来管理栈、传递函数参数、从函数的返回值,以及存储局部和临时数据。
3.3.1 操作数指示符
大多数指令有一个或多个操作数,指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。源数据可以以常数形式给出,或是从寄存器或内存中读出,结果可以存放在寄存器或内存中。

各种不同的操作数的可能性被分为三种类型:
1.立即数(immediate),用来表示常数值。书写方式是‘$’后跟一个用标准C表示法的整数。不同指令允许的立即数值范围不同,汇编器会自动选择最紧凑的方式进行数值编码。
2.寄存器(register),表示某个寄存器的内容,16个寄存器的低位1字节、2字节、4字节或8字节中的一个作为操作数。用符号
3.内存(memory)引用,根据计算出来的地址(通常称为有效地址)访问某个内存位置。用符号
有多种不同的寻址模式,允许不同形式的内存引用。
3.3.2 数据传送指令
最频繁的指令是将数据从一个位置复制到另一个位置的指令。
源操作数指定的值是一个立即数,存储在寄存器中或者内存中。目的操作数指定一个位置,要么是一个寄存器或者,要么是一个内存地址。x86-64加了一条限制,传送指令的两个操作数不能都指向内存位置。将一个值从一个内存位置复制到另一个内存位置需要两条指令——第一条指令将源值加载到寄存器中,第二条将该寄存器值写入目的位置。
大多数情况中, MOV指令只会更新目的操作数指定的那些寄存器字节或内存位置。唯一的例外是movl指令以寄存器作为目的时,它会把该寄存器的高位4字节设置为0。造成这个例外的原因是x86-64采用的惯例,即任何为寄存器生成32位值的指令都会把该寄存器的高位部分置成0。
在下面的MOV指令示例中,第一个是源操作数,第二个是目的操作数。
1 | movl $0x4050,%eax Immediate--Register, 4 bytes |
movabsq是处理64位立即数数据的。常规的movq指令只能以表示32位补码数字的立即数作为源操作数,然后将其符号拓展为64位放到目的位置。而movabsq能以64位立即数为源操作数,并且只能以寄存器作为目的。
下图的数据移动指令在将较小的源值复制到较大的目的时使用。每条指令的最后两个字符都是大小指示符:第一个字符指定源的大小,第二个指明目的的大小。


3.3.3 数据传送示例
对于下面的C代码
1 | long exchange(long *xp, long y){ |
对应的汇编代码如下,其中寄存器%rdi和%rsi分别存放参数xp和y
1 | long exchange(long *xp, long y) |
关于这段汇编代码有两点值得注意:
- C语言中所谓的“指针”其实就是地址。间接引用指针就是将该指针放在一个寄存器中,然后在内存引用中使用这个寄存器。
- 像
x这样的局部变量通常是保存在寄存器中,而不是内存中。访问寄存器比访问内存要快得多。
3.4.4 压入和弹出栈数据
栈是一种数据结构,可以添加或者删除值,不过要遵循“后进先出”的原则。通过push操作把数据压入栈中,通过pop操作删除数据;它具有一个属性:弹出的值永远是最近被压入而且仍然在栈中的值。
栈可以实现为一个数组,总是从数组的一端插入和删除元素。这一端被称为栈顶。
pushq指令的功能是把数据压入到栈上,而popq指令是弹出数据。这些指令都只有一个操作数一一压入的数据源和弹出的数据目的。
将一个四字值压人栈中,首先要将栈指针减8,然后将值写到新的栈顶地址。因此,指令pushq %rbp的行为等价于下面两条指令:
1 | subq $8, %rsp Decrement stack pointer |
弹出一个四字的操作包括从栈顶位置读出数据,然后将栈指针加8。因此,指令popq %rax等价于下面两条指令:
图中前两栏给出的是,当%rsp为0x108,%rax为0x123时,执行指令pushq %rax的效果。首先%rsp会减8,得到0x100,然后会将0x123存放到内存地址0x100处。第三栏说明的是在执行完pushq后立即执行指令popq %rdx的效果。先从内存中读出值0x123,再写到寄存器%rdx中,然后,寄存器%rsp的值将增加回到0x108。如图中所示,值0x123仍然会保持在内存位置0xl00中,直到被覆盖(例如被另一条入栈操作覆盖)。无论如何,%rsp指向的地址总是栈顶。
3.4 算术和逻辑操作
下图中的指令类(除leaq外)有各种带不同大小操作数的变种。这些操作被分为四组:加载有效地址、一元操作、二元操作和移位。
3.4.1 加载有效地址
加载有效地址指令leaq实际上是movq指令的变形。它的指令形式是从内存读数据到寄存器,但实际上它根本就没有引用内存。这条指令可以为后面的内存引用产生指针,也可以简洁地描述普通的算术操作,例如:
1 | long scale(long x, long y, long z){ |
编译时,该函数的算术运算以三条leap指令实现:
1 | long scale(long x, long y, long z) |
leaq指令能执行加法和有限形式的乘法,在编译如上简单的算术表达式时,是很有用处的。
3.4.2 一元和二元操作
对于一元操作,只有一个操作数,既是源又是目的。这个操作数可以是一个寄存器,也可以是一个内存位置。例如incq(%rsp)会使栈顶的8字节元素加1。
而二元操作中,第二个操作数既是源又是目的。这种语法类似于C语言中的x-=y,不过源操作数是第一个,目的操作数是第二个。第一个操作数可以是立即数、寄存器或内存位置,第二个操作数可以是寄存器或内存位置。注意,当第二个操作数为内存地址时,处理器必须从内存中读出值,执行操作,再把结果写回内存。
例:假设下面的值存放在指定的内存地址和寄存器中:
给出下面指令的效果,说明将被更新的寄存器或内存位置,以及得到的值:
| 地址 | 目的 | 值 |
|---|---|---|
addq %rcx, (%rax) |
0x100 |
0x100 |
subq %rdx, 8(%rax) |
0x108 |
0xA8 |
imulq $16, (%rax,%rdx,8) |
0x118 |
0x110 |
incq 16(%rax) |
0x110 |
0x14 |
decq %rcx |
%rcx |
0x0 |
subq %rdx, %rax |
%rax |
0xFD |
3.4.3 移位操作
移位操作首先给出移位量,然后第二项给出的是要移位的数。可以进行算术和逻辑右移。移位量可以是一个立即数,或者放在单字节寄存器%cl中(只允许这个特定的寄存器作为操作数)。
例如,下面的C语言代码:
1 | long shift_left4_rightn(long x, long n){ |
其对应的汇编代码为:
1 | long shift_left4_rightn(long x, long n) |
3.4.4 算术操作示例
C语言代码:
1 | long arith(long x, long y, long z){ |
汇编代码:
1 | long arith(long x, long y, long z) |
3.4.5 特殊的算术操作
两个64位有符号或无符号整数相乘得到的乘积需要128位来表示,x86-64指令集对128位(16字节,8字)数的操作提供有限的支持。
imulq可以用于两个不同的乘法的操作,其中一种是从两个64位操作数产生64位乘积的双操作数指令;另一种是计算两个64位的全128位补码乘积的单操作数指令。
例如,在小端机器上运行下面的C代码:
1 |
|
GCC生成的汇编代码为:
1 | void store_uprod(uint128_t *dest, uint64_t x, uint64_t y) |
对于除法和取模操作,由单操作数除法指令提供。有符号除法指令idivl将寄存器%rdx(高64位)和%rax(低64位)中的128位数作为被除数,而除数作为指令的操作数给出。指令将商存储在寄存器%rax中,将余数存储在寄存器%rdx中。
对于更普遍的64位的被除数除法而言,被除数被存放在%rax中,%rdx的位应全设置为0(无符号运算)或者%rax的符号位(有符号运算)。后面这个操作可以用指令cqto完成,这个指令不需要操作数,它隐含读出%rax的符号位,并将它复制到%rdx的所有位。
例如下面计算两个64位有符号数的商和余数的C语言代码:
1 | void remdiv(long x, long y, long *qp, long *rp){ |
汇编代码为:
1 | void remdiv(long x, long y, long *qp, long *rp) |
在上述代码中,必须首先把参数qp保存到另一个寄存器中(第4行),因为除法操作要使用参数寄存器%rdx。接下来,第5~6行准备被除数,复制并符号扩展x。除法之后,寄存器%rax中的商被保存在qp(第8行),而寄存器%rdx中的余数被保存在rp(第9行)。
3.5 控制
机器代码提供两种基本的低级机制来实现有条件的行为:测试数据值,然后根据测试的结果来改变控制流或者数据流。
与数据相关的控制流是实现有条件行为的更一般和更常见的方法。
用jump指令可以改变一组机器代码指令的执行顺序,jump指令指定控制应该被传递到程序的某个其他部分,可能是依赖于某个测试的结果。编译器必须产生构建在这种低级机制基础之上的指令序列,来实现C语言的控制结构。
3.5.1 条件码
除了整数寄存器,CPU还维护一组单个位的条件码寄存器,最常用的条件码有:
CF:进位标志。最近的操作使最高位产生了进位。可用来检查无符号操作的溢出。ZF:零标志。最近的操作得出的结果为0。SF:符号标志。最近的操作得到的结果为负数。OF:溢出标志。最近的操作导致一个补码溢出(正溢出或负溢出)。
除了leaq之外,图3-10(3.4节首图)中列出的所有指令都会设置条件码。对于逻辑操作,进位标志和溢出标志会设置成0。对于移位操作,进位标志将设置为最后一个被移出的位,而溢出标志设置为0。INC和DEC指令会设置溢出和零标志,但不会改变进位标志。
除了上述指令,还有如下图所示的两类指令,它们只设置条件码而不改变任何其他寄存器
CMP和SUB指令、TEST和AND指令行为一样,除了只设置条件码而不更新寄存器。对于CMP指令,如果两个数一样,会将零标志设置为1;对于TEST指令的典型用法是检查数字的正负(testq %rax, %rax),或其中一个操作数是一个掩码,用来指示哪些位应该被测试。
3.5.2 访问条件码
常用的条件码使用功能有3种:
- 可以根据条件码的某种组合,将一个字节设置为0或者1
- 可以条件跳转到程序的某个其他的部分
- 可以有条件地传送数据
对于第一种情况,由下图中的SET指令实现
一个计算C语言表达式a < b的典型指令序列为:(这里a和b都是long类型)
1 | int comp(data_t a, data_t b) |
3.5.3 跳转指令
jmp指令是无条件跳转。可以是直接跳转,即跳转目标是作为指令的一部分编码的;也可以是间接跳转,即跳转目标是从寄存器或内存位置中读出的。
汇编语言中,直接跳转是给出一个编号作为跳转目标,例如jmp .L1,间接跳转是“*”后面跟一个操作数指示符,例如jmp *%rax用寄存器%rax中的值作为跳转目标,jmp *(%rax)以%rax中的值作为读地址,从内存中读出跳转目标。
其他跳转指令都是有条件的——它们根据条件码的某种组合,或者跳转,或者继续执行代码序列中下一条指令。
3.5.4 跳转指令的编码
跳转指令最常用的编码是PC相对的,它们会将目标指令的地址与紧跟在跳转指令后面那条指令的地址之差作为编码。
下面是一个PC相对寻址的例子:
1 | movq %rdi, %rax |
汇编器产生的反汇编版本为:
1 | 0: 48 89 f8 mov %rdi, %rax |
右边反汇编器产生的注释中,第2行中跳转指令的跳转目标指明为0x8,第5行中跳转指令的跳转目标是0x5(反汇编器以十六进制格式给出所有的数字)。不过,观察指令的字节编码,会看到第一条跳转指令的目标编码(在第二个字节中)为0x03。把它加上0x5,也就是下一条指令的地址,就得到跳转目标地址0x8,也就是第4行指令的地址。
类似地,第二个跳转指令的目标用单字节、补码表示编码为0xf8(-8)。将这个数加上0xd(13)即第6行指令的地址,得到0x5, 即第3行指令的地址。
当执行PC相对寻址时,程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址。
例1 下面je指令的目标是什么
1 | 4003fa: 74 02 je XXXXXX //0x4003fc + 0x02 = 0x4003fe |
例2 下面je指令的目标是什么
1 | 40042f: 74 f4 je XXXXXX //0x400431 + 0xf4 (-12) = 0x400425 |
例3 ja和pop指令的地址是多少
1 | X1: 77 02 ja 400547 //X1 + 2 = X2, X1=400543 |