C 编译器制作入门

机器码与汇编语言

机器码与汇编语言

本章先建立底层执行模型:计算机大致由哪些部件组成,CPU 如何读取并执行程序,以及我们的 C 编译器最终应该输出什么样的代码。这里暂时不深入具体指令,重点是先形成正确的整体图像。

CPU 与内存

构成计算机的部件,大致可以分为 CPU 和内存。内存是保存数据的设备;CPU 则是一边读写内存,一边执行某种处理的设备。

从概念上说,对 CPU 来讲,内存就像一个可以随机访问的巨大字节数组。CPU 访问内存时,会用一个数值指定想访问内存中的第几个字节。这个数值称为“地址”。例如,“从地址 16 读取 8 字节数据”,意思就是从像字节数组一样呈现的内存的第 16 个字节开始,读取 8 个字节的数据。同样的意思也可以说成“从 16 号地址读取 8 字节数据”。

CPU 执行的程序,以及该程序读写的数据,都存放在内存中。CPU 内部保存着“当前正在执行的指令的地址”;它从该地址读出指令,执行指令中描述的操作,然后再读出下一条指令并执行。这个当前正在执行的指令地址,称为“程序计数器”(program counter,PC)或“指令指针”(instruction pointer,IP)。CPU 所执行的程序本身的格式,称为“机器码”(machine code)。

程序计数器并不一定总是线性地前进到下一条指令。使用 CPU 的“分支指令”(branch instruction)这类指令,可以把程序计数器设置为下一条指令以外的任意地址。if 语句和循环就是通过这种功能实现的。把程序计数器设置到下一条指令以外的位置,称为“跳转”或“分支”。

除了程序计数器,CPU 还有少量用于保存数据的区域。例如 Intel 和 AMD 的处理器中,有 16 个可以保存 64 位整数的区域。这些区域称为“寄存器”(register)。内存从 CPU 看来是外部设备,读写需要一定时间;寄存器则位于 CPU 内部,可以无延迟地访问。

许多机器码指令的格式,都是用两个寄存器的值进行某种运算,然后把结果写回寄存器。因此,程序的执行过程可以理解为:CPU 从内存把数据读入寄存器,在寄存器之间进行某种运算,再把结果写回内存。

某一类机器码指令的总称,叫作“指令集架构”(instruction set architecture,ISA),或者简称“指令集”。指令集并不只有一种;原则上,每种 CPU 都可以自由设计自己的指令集。不过,如果机器码层面不兼容,同一个程序就不能运行,因此现实中的指令集种类并没有那么多。在 PC 上,使用的是 Intel 以及其兼容芯片厂商 AMD 的、称为 x86-64 的指令集。x86-64 是主要指令集之一,但并不是由它独占整个市场。例如 iPhone 和 Android 使用的是 ARM 指令集。

x86-64 指令集的名称

x86-64 有时也被称为 AMD64Intel 64x64 等。同一个指令集会有这么多个名字,是有历史背景的。

x86 指令集是 Intel 在 1978 年创建的,但把它扩展到 64 位的是 AMD。大约在 2000 年前后,业界逐渐需要 64 位处理器。当时 Intel 正举全公司之力推进一种名为 Itanium 的全新指令集,因此没有主动投入会与它竞争的 64 位版 x86。AMD 抓住这个空隙,制定并公开了 64 位版 x86 的规格,这就是 x86-64。后来,可能出于品牌战略上的考虑,AMD 又把 x86-64 改名为 AMD64。

之后 Itanium 的失败变得很明显,Intel 除了制作 64 位版 x86 以外几乎没有其他选择。可那时 AMD64 的实际芯片已经出货不少,再去制定一个似是而非的扩展指令集并不现实,于是 Intel 也采用了与 AMD 兼容的指令集。据说 Microsoft 方面也施加了维持兼容性的压力。那时 Intel 把几乎与 AMD64 完全相同的指令集命名为 IA-32e 并加以采用。没有使用“64”,而是命名为 IA-32e(Intel Architecture 32 extensions),这多少能看出 Intel 对 Itanium 这个未能成功的指令集仍有留恋:在 Intel 看来,64 位 CPU 的正统核心本应是 Itanium。后来 Intel 采取了彻底放弃 Itanium 的方针,IA-32e 也被改名为更普通的 Intel 64。Microsoft 可能是嫌这个名字太长,把 x86-64 称为 x64。

由于上述原因,x86-64 有很多不同的名称。

在开源项目中,不包含特定公司名称的 x86-64 这个称呼似乎更常被偏好。本书也会一贯使用 x86-64 这个名称。

什么是汇编语言

机器码是 CPU 直接读取并执行的内容,因此它首先服务于 CPU 的编码规则,并不考虑人类是否容易处理。直接用二进制编辑器编写机器码并非绝对不可能,但会非常痛苦。为了解决这个问题,人们发明了 汇编器 。汇编语言几乎与机器码一一对应,但比机器码更适合人类阅读。

对于输出原生二进制文件的编译器,也就是不是虚拟机或解释器方式的编译器,通常目标是输出汇编语言。即使某些编译器看起来是在直接输出机器码,在常见结构中,它们也往往是先输出汇编语言,然后在后台启动汇编器。本书要制作的 C 编译器也会输出汇编语言。

把汇编代码转换为机器码,有时也称为“编译”;但为了强调输入是汇编语言,也会特别称为“汇编”。

读者以前也许在某处见过汇编语言。如果还没有见过,现在正好可以看一看。我们可以使用 objdump 命令,把一个合适的可执行文件反汇编,以汇编语言的形式显示该文件中包含的机器码。下面是对 ls 命令进行反汇编的结果。

$ objdump -d -M intel /bin/ls
/bin/ls:     file format elf64-x86-64

Disassembly of section .init:

0000000000003d58 <_init@@Base>:
  3d58:  48 83 ec 08           sub    rsp,0x8
  3d5c:  48 8b 05 7d b9 21 00  mov    rax,QWORD PTR [rip+0x21b97d]
  3d63:  48 85 c0              test   rax,rax
  3d66:  74 02                 je     366a <_init@@Base+0x12>
  3d68:  ff d0                 call   rax
  3d6a:  48 83 c4 08           add    rsp,0x8
  3d6e:  c3                    ret
...

在作者的环境中,ls 命令大约包含 2 万条机器码指令,因此反汇编结果也会接近 2 万行,非常长。这里仅列出开头的一小部分。

在汇编语言中,基本上是一条机器码对应一行。作为例子,我们来看下面这一行。

  3d58:  48 83 ec 08           sub    rsp,0x8

这一行是什么意思?3d58 是机器码所在内存的地址。也就是说,ls 命令运行时,这一行的指令会被放在内存的 0x3d58 地址;当程序计数器为 0x3d58 时,这条指令会被执行。后面跟着的 4 个十六进制数值,就是实际的机器码。CPU 会读取这些数据,并把它们作为指令执行。sub rsp,0x8 是与这条机器码指令对应的汇编语言。CPU 指令集会在后面的章节中专门说明;这里这条指令的意思是,从 RSP 这个寄存器中减去 8(subtract = 减)。

C 与对应的汇编

简单示例

为了把握 C 编译器会生成什么样的输出,我们来比较 C 代码及其对应的汇编代码。作为最简单的例子,考虑下面这个 C 程序。

int main() {
    return 42;
}

如果这个程序所在文件名为 test1.c,可以像下面这样编译它,并确认 main 确实返回了 42。

$ cc -o test1 test1.c
$ ./test1
$ echo $?
42

在 C 中,main 函数返回的值会成为整个程序的退出码。程序的退出码不会显示在屏幕上,但会被隐式地设置到 shell 的 $? 变量中。因此,在命令结束后立刻用 echo 显示 $?,就可以看到该命令的退出码。这里可以看到,程序正确返回了 42。

接下来,与这个 C 程序对应的汇编程序如下。

.intel_syntax noprefix
.globl main
main:
  mov rax, 42
  ret

这段汇编定义了一个全局标签 mainmain 函数的代码跟在这个标签后面。这里把 42 这个值设置到名为 RAX 的寄存器中,然后从 main 返回。可放入整数的寄存器包括 RAX 在内共有 16 个;但约定是:函数返回时 RAX 中的值就是函数的返回值。因此,这里要把值设置到 RAX 中。

我们实际汇编并运行这个汇编程序。汇编文件的扩展名是 .s,所以请把上面的汇编代码写入 test2.s,然后执行下面的命令。

$ cc -o test2 test2.s
$ ./test2
$ echo $?
42

和 C 的情况一样,42 成为了退出码。

粗略地说,C 编译器就是这样一种程序:读入 test1.c 这样的 C 代码,然后输出 test2.s 这样的汇编语言。

包含函数调用的示例

再来看一个稍复杂的例子:包含函数调用的代码会被转换成怎样的汇编。

函数调用不同于单纯的跳转。被调用的函数结束后,必须回到原本正在执行的位置。这个原本正在执行的位置的地址,称为“返回地址”。如果函数调用只有一层,那么返回地址只要保存在 CPU 的某个合适寄存器中即可;但函数调用可以任意加深,所以返回地址必须保存在内存中。实际中,返回地址会保存在内存上的中。

栈可以只用一个变量来实现,这个变量保存栈顶地址。保存栈顶的存储区域称为“栈指针”。为了支持使用函数的编程方式,x86-64 提供了专用于栈指针的寄存器,以及使用这个寄存器的指令。把数据放到栈上称为“压栈”(push),把栈上的数据取出称为“出栈”(pop)。

现在来看一个函数调用的实际例子。请考虑下面的 C 代码。

int plus(int x, int y) {
    return x + y;
}

int main() {
    return plus(3, 4);
}

与这段 C 代码对应的汇编如下。

.intel_syntax noprefix
.globl plus, main

plus:
  add rsi, rdi
  mov rax, rsi
  ret

main:
  mov rdi, 3
  mov rsi, 4
  call plus
  ret

第 1 行是指定汇编语法的指令。第 2 行以 .globl 开头,它告诉汇编器:plusmain 这两个函数不是只在文件作用域内可见,而是从整个程序都可以看到的函数。这里暂时可以忽略它。

首先看 main。在 C 中,main 带着实参调用了 plus。在汇编层面,有一个约定:第一个参数放入 RDI 寄存器,第二个参数放入 RSI 寄存器。所以 main 的前两行正是按这个约定设置了值。

call 是调用函数的指令。具体来说,call 会执行下面两件事。

因此,当 call 指令执行后,CPU 会开始执行 plus 函数。

接着看 plus 函数。plus 函数中有三条指令。

add 是执行加法的指令。在这个例子中,RSI 寄存器和 RDI 寄存器相加,结果写入 RSI 寄存器。x86-64 的整数运算指令通常只能接收两个寄存器,因此结果会以覆盖第一个参数寄存器的方式保存。

约定规定,函数返回值要放入 RAX。因此,我们希望把加法结果放入 RAX,需要把值从 RSI 复制到 RAX。这里使用 mov 指令完成这件事。mov 是 move 的缩写,但它实际上并不是移动数据,而只是复制数据。

plus 函数最后调用 ret,从函数返回。具体来说,ret 会执行下面两件事。

也就是说,ret 会把 call 所做的事情还原,并恢复调用者函数的执行。这样,callret 被定义为一对相互配合的指令。

plus 返回后所在的位置,是 main 中的 ret 指令。在原来的 C 代码中,plus 的返回值会直接作为 main 的返回值。这里 plus 的返回值已经放在 RAX 中,所以只要直接从 main 返回,就可以把它原样作为 main 的返回值。

本章小结

本章概要说明了计算机内部如何工作,以及 C 编译器应该做什么。

看到汇编语言和机器码时,它们看起来像是一团与 C 相去甚远的杂乱数据。但实际看下来,很多读者也许会觉得,它们其实相当直接地反映了 C 的结构。

本书还几乎没有说明具体机器码,所以 objdump 显示出的汇编代码中每条指令是什么意思,读者现在应该还不清楚。

不过,应该已经能想象到:一条条指令本身并没有做什么特别复杂的事情。在本章阶段,只要能获得这种感觉就足够了。

下面用条目总结本章要点。

在线编译器

观察 C 代码及其编译结果,是学习汇编语言的好方法。不过,反复编辑源代码、编译、再查看输出的汇编,实际做起来会出乎意料地麻烦。有一个非常好的网站可以减少这些步骤,那就是 Compiler Explorer(俗称 godbolt)。在 Compiler Explorer 中,把代码输入到画面左半部分的文本框,右半部分就会实时显示对应的汇编输出。想确认 C 代码会被转换成什么样的汇编时,可以使用这个网站。