历史上出现过很多知名的指令集架构,比如Alpha, SPARC,PowerPC,MIPS等,而在今天最流行的则是x86(-64),ARM,RISC-V。这一章以x86-64为重点。

指令集体系结构或指令集架构(nstruetion Set Arehiteeture, ISA),定义机器级程序的
格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。大多数ISA, 包括 x86-64, 将程序的行为描述成好像每条指令都是按顺序执行的。

虽然我们日常使用的编程语言多种多样,但对于计算机来说,其唯一能理解的无非就是二进制,即0和1而已。 CPU的工作流程基本可以看作为控制器从计数器(PC)取出下一条指令并执行,同时更新程序计数器的值。下面是一些基本概念:

  • Instructure Set Architecture: 指令集架构 (包括指令规格,寄存器等),简称ISA,它是软硬件之间的“合同”
  • Mircoarchitecture: 指令集架构的具体实现方式 (比如流水线级数,缓存大小等),它是可变的
  • Machine Code: 机器码,也就是机器可以直接执行的二进制指令
  • Assembly Code: 汇编码,也就是机器码的文本形式 (主要是给人类阅读)

程序编码

C编译过程

C程序的编译过程: 源代码 -> 编译 -> 汇编 -> 链接 -> 可执行文件 -> 装载 -> 执行

更具体的是:

  1. 预处理器(preprocessor) 把诸如 #include#define#if#else#elif#ifdef#endif 等预编译指令替换掉
  2. 编译器(compiler)把.c源文件编译成.s的汇编代码文件
  3. 汇编器(assembler)把汇编代码文件转换成相应的二进制目标文件.o,目标文件已经是机器码了,只是没有填入全局变量的地址
  4. 链接器(linker),把多目标文件和库函数链接在一起,形成可执行文件

在整个编译过程中,编译器会完成大部分工作,将把用 C 语言提供的相对比较抽象的执行模型表示的程序转化成处理器执行的非常基本的指令。汇编代码表示非常接近于机器代码。与机器代码的二进制格式相比,汇编代码的主要特点是它用可读性更好的文本格式表示。

  • 程序计数器(“PC”),在 x86-64 中用 %rip 表示,给出将要执行的下一条指令在内存中的地址;
  • 整数寄存器文件包含16个命名的位置,分别存储64位的值,这些寄存器可以存储地址或整数数据,有的寄存器被用来记录某些重要的程序状态,而其他的寄存器用来保存临时数据,例如过程的参数和局部变量,以及函数的返回值;
  • 条件码寄存器保存着最近执行的算术或逻辑指令的状态信息,可以用来实现条件控制代码比如 ifwhile
  • 向量寄存器可以存放一个或多个整数或浮点数值。

以下并不严格按照CSAPP的内容

例如有如下 C 代码 mstore.c

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

在命令行使用 gcc -Og -S mstore.c , -S 选项会使GCC运行编译器,产生一个汇编文件 mstore.s , 但是不做进一步工作

查看该汇编文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
	.file	"mstore.c"
.text
.globl multstore
.type multstore, @function
multstore:
.LFB0:
.cfi_startproc
endbr64
pushq %rbx
.cfi_def_cfa_offset 16
.cfi_offset 3, -16
movq %rdx, %rbx
call mult2@PLT
movq %rax, (%rbx)
popq %rbx
.cfi_def_cfa_offset 8
ret
.cfi_endproc
.LFE0:
.size multstore, .-multstore
.ident "GCC: (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:

我们其实只需要关注其中的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
multstore:
.LFB0:
.cfi_startproc
endbr64
pushq %rbx
.cfi_def_cfa_offset 16
.cfi_offset 3, -16
movq %rdx, %rbx
call mult2@PLT
movq %rax, (%rbx)
popq %rbx
.cfi_def_cfa_offset 8
ret
.cfi_endproc

寄存器文件(x86-64)

16个64位的寄存器,构成了寄存器文件。

16个寄存器文件,但是有64个命名,这是因为字节数量的不同。比如 %rax ,存储 char 类型的变量时只会用到低八位,即 al ,存储 short 类型的变量则需要用到低16位,即 %ax ,而 int 类型的变量需要用到32位,即 %eax

在 x86-64 中存在着栈空间,它存储着临时变量,担任过程调用的中转站,即保留返回地址、参数等。栈遵循先进后出原则(LIFO)。栈空间有一个栈顶指针,也就是十六个寄存器里的 %rsp ,在 x86-64 中,程序栈存放在内存中某个区域,栈向下增长,这样可以使得栈顶元素的地址是所有栈中元素地址最低的。栈指针 %rsp 保存着栈顶元素的地址,如下图

栈有两个操作:

  • 压入栈(push): 将栈顶指针往下移动若干个字节,使得栈的容量增大,再把新的数据填入栈顶;
  • 弹出栈(pop): 将栈顶指针上移若干字节,随着栈顶指针的上移,原来的栈顶已经被排除在栈的范围之外。

PC寄存器

PC(Program counter)寄存器,也就是前面说的程序计数器,用 %rip 表示,它并不是通用寄存器的一员,它存储着下一条要执行的指令的地址。此外,PC寄存器并不会显式地出现在汇编代码中,其值的变化都是暗地里进行的。

汇编语言(AT&T)

指令

汇编代码是由多条指令组成的序列,指令序列存在内存里,PC指向下一条指令的地址,一个指令可以完成一个CPU操作。一条指令由操作码和 $0\sim 2$ 个操作数组成,操作码指定了当前指令要执行的操作,例如两数相加,操作数则是操作码的作用对象。由此可见,指令的长度并不固定。

操作数

操作数可以是立即数、寄存器、内存地址,以下是三种操作数的表示方法

举例子对上图进行说明:

  1. $5 是立即数,它的值为 5;
  2. %rax 是寄存器,它的值是寄存器 %rax 中的值;
  3. 0x07 是内存地址,它的值是内存中地址为 0x07 的某种类型的值;
  4. (%rax) 是内存地址,该地址保留在寄存器 %rax 中;
  5. 0xf7(%rax, %rbp, 4) 是内存地址,所有的内存寻址方式都可以写成这种类型。

最后一种表示一个基址寻址,寻址方式是: displacement(base register, index register, scale factor) ,在上面个例子中:

  • displacement(偏移)是 0xf7,即十六进制的偏移值F7
  • base register(基址寄存器)是 %rax
  • index register(索引寄存器)是 %rbp
  • scale factor(倍数因子)是 4

也就是说,该表达式表示的内存地址是: 0xf7 + (%rax) + 4 * (%rbp)

操作码

操作码分为算数逻辑类、数据传输类、控制类等。

  1. 算术和逻辑指令操作码
    1
    addq $3, %rdi
    add 表示相加,第一个操作数是源操作数,第二个操作数是目标操作数,表示将立即数 3 加到寄存器 %rdi 中。而 add 的后缀表示操作数的大小,分别为:
  • b —— 字节(byte, 8bit);
  • w —— 字(word, 16bit);
  • l —— 双字(doubleword, 32bit);
  • q —— 四字(quadword, 64bit)

算数和逻辑类指令操作码被分为四组: 加载有效地址、一元操作、二元操作和移位,二元操作有两个操作数,而一元操作有一个操作数。如下图所示

  1. 数据传输指令操作码
    1
    movb %bl, %al
    %bl 寄存器中的值赋给%al 寄存器。
1
pushq %rbp

表示将 %rbp 的值压入栈中,即先使栈顶指针寄存器%rsp的值减少8个字节,再将%rbp的值赋值给%rsp所指的内存单元。

1
popq %rsi

将栈顶的8个字节的值弹出,并赋给 %rsi

除此之外,还有诸如控制类、比较和测试类操作码。

条件码

除了整数寄存器,CPU还维护了一组单个位的条件码(condition code)寄存器,它们
描述了最近的算术或逻辑操作的属性。可以检测这些寄存器来执行条件分支指令。最常用
的条件码有:

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

比如说,当使用一条 add 指令来完成等价于 C 表达式 t = a + b 的功能后,这里的变量都是整型的。然后由下面的 C 表达式来设置条件码

  • CF —— (unsigned) t < (unsigned) a —— 无符号溢出
  • ZF —— (t == 0) —— 零
  • SF —— (t < 0) —— 负数
  • OF —— (a < 0 == b < 0) && (t < 0 != a < 0) —— 有符号溢出

实例

准备一个 test.c 文件,写入如下代码

1
2
3
void foo(){
return;
}

使用 gcc -Og -S test.c 命令可以得到 *.s 的汇编语言文件,加入 -Og 的目的是使得到的汇编代码与源代码尽可能的对应。更好的办法是:

首先编译源代码得到目标文件test.o

1
gcc -c -Og test.c

然后用反汇编命令

1
objdump -d test.o

得到如下:

首先 0000000000000000 <main> ,前面的 16 个 0 是十六进制下的 0,也就是二进制下的 64 个 0, 表示该函数所在的虚拟地址,而 <main> 则是函数名。接下来是函数体,0、4、8、f…是各指令的地址,也是十六进制,因为第一条指令有4个字节(f3、0f、1e、fa)所以第二条指令的地址和第一条指令地址相差 4。