机器码与汇编语言
本章先建立底层执行模型:计算机大致由哪些部件组成,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 有时也被称为 AMD64、Intel 64、x64 等。同一个指令集会有这么多个名字,是有历史背景的。
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
这段汇编定义了一个全局标签 main,main 函数的代码跟在这个标签后面。这里把 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 开头,它告诉汇编器:plus 和 main 这两个函数不是只在文件作用域内可见,而是从整个程序都可以看到的函数。这里暂时可以忽略它。
首先看 main。在 C 中,main 带着实参调用了 plus。在汇编层面,有一个约定:第一个参数放入 RDI 寄存器,第二个参数放入 RSI 寄存器。所以 main 的前两行正是按这个约定设置了值。
call 是调用函数的指令。具体来说,call 会执行下面两件事。
- 把
call下一条指令的地址压入栈中;在这个例子中,下一条指令是ret - 跳转到作为
call参数给出的地址
因此,当 call 指令执行后,CPU 会开始执行 plus 函数。
接着看 plus 函数。plus 函数中有三条指令。
add 是执行加法的指令。在这个例子中,RSI 寄存器和 RDI 寄存器相加,结果写入 RSI 寄存器。x86-64 的整数运算指令通常只能接收两个寄存器,因此结果会以覆盖第一个参数寄存器的方式保存。
约定规定,函数返回值要放入 RAX。因此,我们希望把加法结果放入 RAX,需要把值从 RSI 复制到 RAX。这里使用 mov 指令完成这件事。mov 是 move 的缩写,但它实际上并不是移动数据,而只是复制数据。
plus 函数最后调用 ret,从函数返回。具体来说,ret 会执行下面两件事。
- 从栈中弹出一个地址
- 跳转到该地址
也就是说,ret 会把 call 所做的事情还原,并恢复调用者函数的执行。这样,call 和 ret 被定义为一对相互配合的指令。
从 plus 返回后所在的位置,是 main 中的 ret 指令。在原来的 C 代码中,plus 的返回值会直接作为 main 的返回值。这里 plus 的返回值已经放在 RAX 中,所以只要直接从 main 返回,就可以把它原样作为 main 的返回值。
本章小结
本章概要说明了计算机内部如何工作,以及 C 编译器应该做什么。
看到汇编语言和机器码时,它们看起来像是一团与 C 相去甚远的杂乱数据。但实际看下来,很多读者也许会觉得,它们其实相当直接地反映了 C 的结构。
本书还几乎没有说明具体机器码,所以 objdump 显示出的汇编代码中每条指令是什么意思,读者现在应该还不清楚。
不过,应该已经能想象到:一条条指令本身并没有做什么特别复杂的事情。在本章阶段,只要能获得这种感觉就足够了。
下面用条目总结本章要点。
- CPU 通过读写内存来推进程序的执行。
- CPU 执行的程序,以及该程序处理的数据,都存放在内存中。CPU 从内存中依次读取机器码指令,并执行这些指令。
- CPU 中有称为寄存器的小型存储区域,许多机器码都定义为寄存器之间的操作。
- 汇编语言是把机器码改写成人类容易阅读的语言;C 编译器通常输出汇编语言。
- C 的函数在汇编语言中也会成为函数。
- 函数调用通过栈来实现。
在线编译器
观察 C 代码及其编译结果,是学习汇编语言的好方法。不过,反复编辑源代码、编译、再查看输出的汇编,实际做起来会出乎意料地麻烦。有一个非常好的网站可以减少这些步骤,那就是 Compiler Explorer(俗称 godbolt)。在 Compiler Explorer 中,把代码输入到画面左半部分的文本框,右半部分就会实时显示对应的汇编输出。想确认 C 代码会被转换成什么样的汇编时,可以使用这个网站。