CSAPP第三章程序的机器级表示

程序编码

假设有一个C程序,有两个文件p1.c和p2.c。我们用Unix命令行编译这些代码:

Linux> gcc -Og -o p p1.c p2.c

实际上gcc命令调用了一整套的程序,将源代码转化成可执行代码:

  1. C预处理器扩展源代码,插入所有用#include命定指定的文件,扩展所有用#define声明指定的宏。
  2. 编译器产生两个源文件的汇编代码,名字分别为p1.s和p2.s。
  3. 汇编器将汇编代码转化成二进制目标代码文件p1.o和p2.o。
  4. 链接器将两个目标代码文件与实现库函数(例printf)的代码合并,并产生最终的可执行代码文件p。

机器级代码

计算机系统使用了多种不同的抽象,利用更简单的抽象模型来隐藏实现的细节:

  1. 指令集体系结构或指令集架构来定义机器级程序的格式和行为,它定义了处理器状态、指令的格式、以及每条指令对状态的影响。
  2. 机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个很大的、按字节寻址的数组

代码示例

1
2
3
4
5
long mult2(long, long);
void multstore(long x, long y, long *dest) {
long t = mult2(x, y);
*dest = t;
}

假设有一个C语言代码文件mstore.c,在命令行上使用“-S”选项,就能看到C语言编译器产生的汇编代码:

linux> gcc -Og -S mstore.c 这会使GCC运行编译器,产生一个汇编文件mstore.s。

1
2
3
4
5
6
7
8
9
# void multstore(long x, long y, long *dest)
# x in %rdi, y in %rsi, dest in %rdx
multstore:
pushq %rbx # Save %rbx
movq %rdx, %rbx # Copy dest to %rbx
call mult2 # Call mult2(x, y)
movq %rax, (%rbx) # Store result at *dest
popq %rbx # Restore %rbx
ret # Return

如果我们使用“-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位值的通用目的寄存器。这些寄存器用来存储整数数据和指针。

v2-a4fdd18e266dafb2fa2b7b89c93b45c4_720w

指令可以对这16个寄存器的低位字节中存放的不同大小的数据进行操作。

生成1字节和2字节数字的指令会保持剩下的字节不变, 生成4字节数字的指令会把高位4个字节置为0。

操作数指示符

大多数指令有一个或多个操作数,指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。操作数分为三种类型:

  1. 立即数:用来表示常数值。立即数的书写方式是“后面跟一个用标准表示法表示的整数,如-577或$0x1F。
  2. 寄存器:表示某个寄存器的内容。
  3. 内存引用:它会根据计算出来的地址(通常称为有效地址)访问某个内存位置

v2-f9e206ce400c2d8fe60949d8d152cd0c_720w

数据传送指令

最简单形式的数据传送指令——MOV类。这些指令把数据从源位置复制到目的位置,不做任何变化。

v2-4877513aca7692221f8615f862c4e452_720w

限制:传送指令的两个操作数不能都指向内存位置。

MOV指令只会更新目的操作数制定的那些寄存器字节或内存位置。唯一的例外是movl指令以寄存器作为目的时,它会把该寄存器的高位4字节设置为0。

常规的movq指令只能以表示为32位补码数字的立即数作为源操作数。movabsq指令能以任意64位立即数值作为源操作数,并且只能以寄存器作为目的。

MOVZ和MOVS可以将较小的源值复制到较大的目的。

  • MOVZ类中的指令把目的中剩余的字节填充为0。
  • MOVS类中的指令通过符号拓展来填充。

v2-1e19219ec92acec8ad5ee7eee1e4dc36_720w

压入和弹出栈数据

pushq指令的功能是把数据压到栈上,而popq指令时弹出数据。这些指令只有一个操作数——压入的数据源和弹出的数据目的。

v2-e515b4938bda4ebcd81f379e5ab3e4e6_720w

算数与逻辑操作

v2-104351fade6344715ef28a2ab852171d_720w

加载有效地址

加载有效地址指令leaq实际上是movq指令的变形。它的指令形式是从内存读数据到寄存器,但实际上它根本没有引用内存,该指令将有效地址写入目的操作数。

一元和二元操作

一元操作只有一个操作数,这个操作数可以是一个寄存器,也可以是一个内存位置。

二元操作的第二个操作数既是源又是目的。第一个操作数可以是立即数 、寄存器或是内存位置,第二个操作数可以是寄存器或是内存位置。当第二个操作数为内存地址时,处理器必须从内存读出值,执行操作,再把结果写回内存。

移位操作

移位操作,先给出移位量,第二项给出要移位的数。移位量可以是一个立即数,或者放在单字节寄存器%cl中。(只允许以这个特定的寄存器作为操作数)

特殊的算术操作

v2-6b9dd45e5c33cfa1d314876eced58ed5_720w

控制

机器代码提供两种基本的低级机制来实现有条件的行为:测试数据值,然后根据测试的结果来改变控制流或者数据流。

与数据相关的控制流是实现有条件行为的更一般和更常见的方法。

条件码

CPU维护着一组单个位的条件码寄存器,他们描述了最近的算术或逻辑操作的属性。

  • CF:进位标志。最近的操作使最高位产生了进位。可用来检查无符号操作数的溢出。
  • ZF:零标志。最近的操作得出的结果为0。
  • SF:符号标志。最近的操作得到的结果为负数。
  • OF:溢出标志。最近的操作数导致一个补码溢出——正溢出或负溢出。
  • leaq指令不不改变任何条件码。

下图列出的所有指令都会设置条件码。

  • 对于逻辑操作,进位和溢出标志会设置成0。
  • 对于移位操作,进位标志将设置为最后一个被移出的位,溢出标志设置为0。
  • INC和DEC指令会设置溢出和零标志,但是不会改变进位标志。

CMP和TEST指令只设置条件码而不改变任何其他寄存器。CMP和TEST指令分别根据两个操作数之差和两个操作数取与来设置条件码。注意它们的比较顺序

访问条件码

条件码通常不会直接读取,常用的方法如下:

  1. 根据条件码的某种组合,将一个字节设置为0或者1。
  2. 可以根据条件跳转到程序的某个其他的部分。
  3. 可以有条件地传送数据。

我们使用SET指令来实现第一种方法。这类指令的后缀表示不同的条件而不是操作数大小。

一条SET指令的目的操作数是低位单字节寄存器元素之一,或是一个字节的内存位置,指令会将这个字节设置为0或1。为了得到一个32位或64位结果,我们必须对高位清零(例如利用MOVZ或MOVS指令)。

v2-5705d2c013a73628991e53243479a7df_720w

跳转指令

跳转指令会导致执行切换到程序中的一个全新的位置,这些跳转的目的地通常用一个标号指明。

v2-844ba7d90d91e2c21a2cfb1c46ba7251_720w

jmp是无条件跳转。它可以是直接跳转,即跳转目标是作为指令的一部分编码的(标号,例如“.L1”);也可以是间接跳转,即跳转目标是从寄存器或内存位置中读出的(“*”后跟一个操作数提示符)。

条件跳转指令根据条件码的某种组合,或跳转,或继续执行代码序列中下一条指令。条件跳转只能是直接跳转。

用条件控制来实现条件分支

实现条件操作的传统方法是通过使用控制的条件转移。当条件满足时,程序沿着一条执行路径执行,当条件不满足时,就走另一条路径。

C语言中的if-else语句的通用形式模版如下:

1
2
3
4
if (test-expr)
then-statement
else
else-statement

对于这种通用形式,汇编实现通常会使用下面这种形式:

1
2
3
4
5
6
7
8
  t = test-expr;
if (!t)
goto false;
then-statement
goto done;
false:
else-statement
done:

用条件传送来实现条件分支

使用控制的条件转移简单而通用,但是在现代处理器上可能会非常低效。

一种替代的策略是使用数据的条件转移。这种方法计算一个条件操作的两种结果,然后再根据条件是否满足从中选取一个。只有在一些受限制的情况下这种策略才可行,但如果可行,就可以用一条简单的条件传送指令来实现它。

v2-92db1c3d922e44a836957dc4f395503e_720w

条件传送指令有两个操作数:源寄存器或者内存地址S,和目的寄存器R。指令的结果取决于条件码的值。

条件传送指令不支持单字节的条件传送。

循环

  • do-while

    do-while语句的通用形式如下:

    1
    2
    3
    do
    body-statement
    while (test-expr);

    这种通用形式可以被翻译为如下所示的条件和goto语句:

    1
    2
    3
    4
    5
    loop:
    body-statement
    t = test-expr;
    if (t)
    goto loop;
  • while循环

    while语句的通用形式如下:

    1
    2
    while (test-expr)
    body-statement

    第一种翻译方法:跳转到中间,它执行一个无条件跳转跳到循环结尾处的测试,以此来执行初始的测试。

    1
    2
    3
    4
    5
    6
    7
      goto test;
    loop:
    body-statement
    test:
    t = test-expr;
    if (t)
    goto loop;

    第二种翻译方法:guarded-do,首先用条件分支,如果出事条线不成立就跳过循环,把代码变换为do-while循环。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    t = test-expr;
    if (!t)
    goto done;
    loop:
    body-statement
    t = test-expr;
    if (t)
    goto loop;
    done:
  • for循环

    for循环的通用形式如下:

    1
    2
    for (init-expr; test-expr; update-expr)
    body-statement

    与下面使用while循环的代码的行为一样:

    1
    2
    3
    4
    5
    init-expr
    while (test-expr) {
    body-statement
    update-expr;
    }

    使用跳转到中间策略会得到如下代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    init-expr;
    goto test;
    loop:
    body-statement
    update-expr;
    test:
    t = test-expr;
    if (t)
    goto loop;