CSAPP第三章程序的机器级表示
CSAPP第三章程序的机器级表示
Hoshea Zhang程序编码
假设有一个C程序,有两个文件p1.c和p2.c。我们用Unix命令行编译这些代码:
Linux> gcc -Og -o p p1.c p2.c
实际上gcc命令调用了一整套的程序,将源代码转化成可执行代码:
- C预处理器扩展源代码,插入所有用#include命定指定的文件,扩展所有用#define声明指定的宏。
- 编译器产生两个源文件的汇编代码,名字分别为p1.s和p2.s。
- 汇编器将汇编代码转化成二进制目标代码文件p1.o和p2.o。
- 链接器将两个目标代码文件与实现库函数(例printf)的代码合并,并产生最终的可执行代码文件p。
机器级代码
计算机系统使用了多种不同的抽象,利用更简单的抽象模型来隐藏实现的细节:
- 由指令集体系结构或指令集架构来定义机器级程序的格式和行为,它定义了处理器状态、指令的格式、以及每条指令对状态的影响。
- 机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个很大的、按字节寻址的数组
代码示例
1 | long mult2(long, long); |
假设有一个C语言代码文件mstore.c,在命令行上使用“-S”选项,就能看到C语言编译器产生的汇编代码:
linux> gcc -Og -S mstore.c 这会使GCC运行编译器,产生一个汇编文件mstore.s。
1 | # void multstore(long x, long y, long *dest) |
如果我们使用“-c”命令行选项,GCC会编译并汇编该代码:
linux> gcc -Og -c mstore.c 这就会产生目标代码文件mstore.o,它是二进制格式的。
这就会产生目标代码文件 mstore.o,它是二进制格式的,所以无法直接查看。1368 字节的文件 mstore.o 中有一段 14 字节的序列,它的十六进制表示为:
53 48 89 d3 e8 00 00 00 00 48 89 03 5b c3
要查看机器代码文件的内容,有一类称为反汇编器的程序非常有用。在Linux系统中,带“-d”命令行标志的程序OBJDUMP可以充当这个角色:
linux> objdump -d mstore.o
访问信息
一个x86-64的中央处理器单元(CPU)包含一组16个存储64位值的通用目的寄存器。这些寄存器用来存储整数数据和指针。
指令可以对这16个寄存器的低位字节中存放的不同大小的数据进行操作。
生成1字节和2字节数字的指令会保持剩下的字节不变, 生成4字节数字的指令会把高位4个字节置为0。
操作数指示符
大多数指令有一个或多个操作数,指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。操作数分为三种类型:
- 立即数:用来表示常数值。立即数的书写方式是“后面跟一个用标准表示法表示的整数,如-577或$0x1F。
- 寄存器:表示某个寄存器的内容。
- 内存引用:它会根据计算出来的地址(通常称为有效地址)访问某个内存位置
数据传送指令
最简单形式的数据传送指令——MOV类。这些指令把数据从源位置复制到目的位置,不做任何变化。
限制:传送指令的两个操作数不能都指向内存位置。
MOV指令只会更新目的操作数制定的那些寄存器字节或内存位置。唯一的例外是movl指令以寄存器作为目的时,它会把该寄存器的高位4字节设置为0。
常规的movq指令只能以表示为32位补码数字的立即数作为源操作数。movabsq指令能以任意64位立即数值作为源操作数,并且只能以寄存器作为目的。
MOVZ和MOVS可以将较小的源值复制到较大的目的。
- MOVZ类中的指令把目的中剩余的字节填充为0。
- MOVS类中的指令通过符号拓展来填充。
压入和弹出栈数据
pushq指令的功能是把数据压到栈上,而popq指令时弹出数据。这些指令只有一个操作数——压入的数据源和弹出的数据目的。
算数与逻辑操作
加载有效地址
加载有效地址指令leaq实际上是movq指令的变形。它的指令形式是从内存读数据到寄存器,但实际上它根本没有引用内存,该指令将有效地址写入目的操作数。
一元和二元操作
一元操作只有一个操作数,这个操作数可以是一个寄存器,也可以是一个内存位置。
二元操作的第二个操作数既是源又是目的。第一个操作数可以是立即数 、寄存器或是内存位置,第二个操作数可以是寄存器或是内存位置。当第二个操作数为内存地址时,处理器必须从内存读出值,执行操作,再把结果写回内存。
移位操作
移位操作,先给出移位量,第二项给出要移位的数。移位量可以是一个立即数,或者放在单字节寄存器%cl中。(只允许以这个特定的寄存器作为操作数)
特殊的算术操作
控制
机器代码提供两种基本的低级机制来实现有条件的行为:测试数据值,然后根据测试的结果来改变控制流或者数据流。
与数据相关的控制流是实现有条件行为的更一般和更常见的方法。
条件码
CPU维护着一组单个位的条件码寄存器,他们描述了最近的算术或逻辑操作的属性。
- CF:进位标志。最近的操作使最高位产生了进位。可用来检查无符号操作数的溢出。
- ZF:零标志。最近的操作得出的结果为0。
- SF:符号标志。最近的操作得到的结果为负数。
- OF:溢出标志。最近的操作数导致一个补码溢出——正溢出或负溢出。
- leaq指令不不改变任何条件码。
下图列出的所有指令都会设置条件码。
- 对于逻辑操作,进位和溢出标志会设置成0。
- 对于移位操作,进位标志将设置为最后一个被移出的位,溢出标志设置为0。
- INC和DEC指令会设置溢出和零标志,但是不会改变进位标志。
CMP和TEST指令只设置条件码而不改变任何其他寄存器。CMP和TEST指令分别根据两个操作数之差和两个操作数取与来设置条件码。注意它们的比较顺序。
访问条件码
条件码通常不会直接读取,常用的方法如下:
- 根据条件码的某种组合,将一个字节设置为0或者1。
- 可以根据条件跳转到程序的某个其他的部分。
- 可以有条件地传送数据。
我们使用SET指令来实现第一种方法。这类指令的后缀表示不同的条件而不是操作数大小。
一条SET指令的目的操作数是低位单字节寄存器元素之一,或是一个字节的内存位置,指令会将这个字节设置为0或1。为了得到一个32位或64位结果,我们必须对高位清零(例如利用MOVZ或MOVS指令)。
跳转指令
跳转指令会导致执行切换到程序中的一个全新的位置,这些跳转的目的地通常用一个标号指明。
jmp是无条件跳转。它可以是直接跳转,即跳转目标是作为指令的一部分编码的(标号,例如“.L1”);也可以是间接跳转,即跳转目标是从寄存器或内存位置中读出的(“*”后跟一个操作数提示符)。
条件跳转指令根据条件码的某种组合,或跳转,或继续执行代码序列中下一条指令。条件跳转只能是直接跳转。
用条件控制来实现条件分支
实现条件操作的传统方法是通过使用控制的条件转移。当条件满足时,程序沿着一条执行路径执行,当条件不满足时,就走另一条路径。
C语言中的if-else语句的通用形式模版如下:
1 | if (test-expr) |
对于这种通用形式,汇编实现通常会使用下面这种形式:
1 | t = test-expr; |
用条件传送来实现条件分支
使用控制的条件转移简单而通用,但是在现代处理器上可能会非常低效。
一种替代的策略是使用数据的条件转移。这种方法计算一个条件操作的两种结果,然后再根据条件是否满足从中选取一个。只有在一些受限制的情况下这种策略才可行,但如果可行,就可以用一条简单的条件传送指令来实现它。
条件传送指令有两个操作数:源寄存器或者内存地址S,和目的寄存器R。指令的结果取决于条件码的值。
条件传送指令不支持单字节的条件传送。
循环
do-while
do-while语句的通用形式如下:
1
2
3do
body-statement
while (test-expr);这种通用形式可以被翻译为如下所示的条件和goto语句:
1
2
3
4
5loop:
body-statement
t = test-expr;
if (t)
goto loop;while循环
while语句的通用形式如下:
1
2while (test-expr)
body-statement第一种翻译方法:跳转到中间,它执行一个无条件跳转跳到循环结尾处的测试,以此来执行初始的测试。
1
2
3
4
5
6
7goto test;
loop:
body-statement
test:
t = test-expr;
if (t)
goto loop;第二种翻译方法:guarded-do,首先用条件分支,如果出事条线不成立就跳过循环,把代码变换为do-while循环。
1
2
3
4
5
6
7
8
9t = test-expr;
if (!t)
goto done;
loop:
body-statement
t = test-expr;
if (t)
goto loop;
done:for循环
for循环的通用形式如下:
1
2for (init-expr; test-expr; update-expr)
body-statement与下面使用while循环的代码的行为一样:
1
2
3
4
5init-expr
while (test-expr) {
body-statement
update-expr;
}使用跳转到中间策略会得到如下代码:
1
2
3
4
5
6
7
8
9init-expr;
goto test;
loop:
body-statement
update-expr;
test:
t = test-expr;
if (t)
goto loop;