对于静态编译型语言,比如 C 语言和 Go 语言,编译器后端的任务就是生成汇编代码,然后再由汇编器生成机器码,生成的文件叫目标文件,最后再使用链接器就能生成可执行文件或库文件了。
image.png
就算像 JavaScript 这样的解释执行的语言,也要在运行时利用类似的机制生成机器码,以便调高执行的速度。Java 的字节码,在运行时通常也会通过 JIT 机制编译成机器码。而汇编语言是完成这些工作的基础。对你来说,掌握汇编语言是十分有益的,因为哪怕掌握一小点儿汇编技能,就能应用到某项工作中,比如,在 C 语言里嵌入汇编,实现某个特殊功能;或者读懂某些底层类库或驱动程序的代码,因为它可能是用汇编写的。本节课,我先带你了解一下汇编语言,来个破冰之旅。然后在接下来的课程中再带你基于 AST 手工生成汇编代码,破除你对汇编代码的恐惧,了解编译期后端生成汇编代码的原理。

了解汇编语言

机器语言都是 0101 的二进制的数据,不适合我们阅读。而汇编语言,简单来说,是可读性更好的机器语言,基本上每条指令都可以直接翻译成一条机器码。跟你日常使用的高级语言相比,汇编语言的语法特别简单,但它要跟硬件(CPU 和内存)打交道,我们来体会一下。计算机的处理器有很多不同的架构,比如 x86-64、ARM、Power 等,每种处理器的指令集都不相同,那也就意味着汇编语言不同。我们目前用的电脑,CPU 一般是 x86-64 架构,是 64 位机。(如不做特别说明,本课程都是以 x86-64 架构作为例子的)。说了半天,汇编代码长什么样子呢?我用 C 语言写的例子来生成一下汇编代码。

  1. #include <stdio.h>
  2. int main(int argc, char* argv[]){
  3. printf("Hello %s!\n", "Richard");
  4. return 0;
  5. }

在 macOS 中输入下面的命令,其中的 -S 参数就是告诉编译器把源代码编译成汇编代码,而 -O2 参数告诉编译器进行 2 级优化,这样生成的汇编代码会短一些:

  1. clang -S -O2 hello.c -o hello.s
  2. 或者:
  3. gcc -S -O2 hello.c -o hello.s

生成的汇编代码是下面的样子:

  1. .section __TEXT,__text,regular,pure_instructions
  2. .build_version macos, 10, 14 sdk_version 10, 14
  3. .globl _main ## -- Begin function main
  4. .p2align 4, 0x90
  5. _main: ## @main
  6. .cfi_startproc
  7. ## %bb.0:
  8. pushq %rbp
  9. .cfi_def_cfa_offset 16
  10. .cfi_offset %rbp, -16
  11. movq %rsp, %rbp
  12. .cfi_def_cfa_register %rbp
  13. leaq L_.str(%rip), %rdi
  14. leaq L_.str.1(%rip), %rsi
  15. xorl %eax, %eax
  16. callq _printf
  17. xorl %eax, %eax
  18. popq %rbp
  19. retq
  20. .cfi_endproc
  21. ## -- End function
  22. .section __TEXT,__cstring,cstring_literals
  23. L_.str: ## @.str
  24. .asciz "Hello %s!\n"
  25. L_.str.1: ## @.str.1
  26. .asciz "Richard"
  27. .subsections_via_symbols

你如果再打下面的命令,就会把这段汇编代码编译成可执行文件(在 macOS 或 Linux 执行 as 命令,就是调用了 GNU 的汇编器):

  1. as hello.s -o hello.o //用汇编器编译成目标文件
  2. gcc hello.o -o hello //链接成可执行文件
  3. ./hello //运行程序

以上面的代码为例,来看一下汇编语言的组成元素。这是汇编语言入门的基础,也是重点内容,在阅读时,你不需要死记硬背,而是要灵活掌握,比如 CPU 的指令特别多,我们记住常用的就行了,不太常用的可以去查手册。1. 汇编语言的组成元素这段代码里有指令、伪指令、标签和注释四种元素,每个元素单独占一行。指令(instruction)是直接由 CPU 进行处理的命令,例如:

  1. pushq %rbp
  2. movq %rsp, %rbp

其中,开头的一个单词是助记符(mnemonic),后面跟着的是操作数(operand),有多个操作数时以逗号分隔。第二行代码的意思是把数据从这里(源)拷贝到那里(目的),这跟“请倒杯咖啡给我”这样的自然语句是一样的,先是动词(倒),然后是动词的作用对象(咖啡),再就是目的地(给我)。
伪指令以“.”开头,末尾没有冒号“:”。

  1. .section __TEXT,__text,regular,pure_instructions
  2. .globl _main
  3. .asciz "Hello %s!\n"

伪指令是是辅助性的,汇编器在生成目标文件时会用到这些信息,但伪指令不是真正的 CPU 指令,就是写给汇编器的。每种汇编器的伪指令也不同,要查阅相应的手册。标签以冒号“:”结尾,用于对伪指令生成的数据或指令做标记。例如 L_.str: 标签是对一个字符串做了标记。其他代码可以访问标签,例如跳转到这个标签所标记的指令。

  1. L_.str: ## @.str
  2. .asciz "Hello %s!\n"

标签很有用,它可以代表一段代码或者常量的地址(也就是在代码区或静态数据区中的位置)。可一开始,我们没法知道这个地址的具体值,必须生成目标文件后,才能算出来。所以,标签会简化汇编代码的编写。

第四种元素,注释,以“#”号开头,这跟 C 语言中以 // 表示注释语句是一样的。
因为指令是汇编代码的主要部分,所以我们再把与指令有关的知识点展开讲解一下。

2. 详细了解指令这个元素
在代码中,助记符“movq”“xorl”中的“mov”和“xor”是指令,而“q”和“l”叫做后缀,表示操作数的位数。后缀一共有 b, w, l, q 四种,分别代表 8 位、16 位、32 位和 64 位。
image.png
比如,movq 中的 q 代表操作数是 8 个字节,也就是 64 位的。movq 就是把 8 字节从一个地方拷贝到另一个地方,而 movl 则是拷贝 4 个字节。
而在指令中使用操作数,可以使用四种格式,它们分别是:立即数、寄存器、直接内存访问和间接内存访问。
立即数以 $ 开头, 比如 $40。(下面这行代码是把 40 这个数字拷贝到 %eax 寄存器)。

  1. movl $40, %eax

除此之外,我们在指令中最常见到的就是对寄存器的访问,GNU 的汇编器规定寄存器一定要以 % 开头。
直接内存访问:当我们在代码中看到操作数是一个数字时,它其实指的是内存地址。不要误以为它是一个数字,因为数字立即数必须以 $ 开头。另外,汇编代码里的标签,也会被翻译成直接内存访问的地址。比如“callq _printf”中的“_printf”是一个函数入口的地址。汇编器帮我们计算出程序装载在内存时,每个字面量和过程的地址。
间接内存访问:带有括号,比如(%rbp),它是指 %rbp 寄存器的值所指向的地址。
间接内存访问的完整形式是:

偏移量(基址,索引值,字节数)这样的格式。

其地址是:

基址 + 索引值 * 字节数 + 偏移量

举例来说:

8(%rbp),是比 %rbp 寄存器的值加 8。 -8(%rbp),是比 %rbp 寄存器的值减 8。 (%rbp, %eax, 4)的值,等于 %rbp + %eax*4。这个地址格式相当于访问 C 语言中的数组中的元素,数组元素是 32 位的整数,其索引值是 %eax,而数组的起始位置是 %rbp。其中字节数只能取 1,2,4,8 四个值。

现在应该对指令的格式有所了解了,接下来,我们再学几个常用的指令:

mov 指令

  1. mov 寄存器|内存|立即数, 寄存器|内存

这个指令最常用到,用于在寄存器或内存之间传递数据,或者把立即数加载到内存或寄存器。mov 指令的第一个参数是源,可以是寄存器、内存或立即数。第二个参数是目的地,可以是寄存器或内存。
lea 指令,lea 是“load effective address”的意思,装载有效地址。
比如前面例子代码中的 leaq 指令,是把字符串的地址加载到 %rdi 寄存器。

  1. lea 源,目的
  2. leaq L_.str(%rip), %rdi

add 指令是做加法运算,它可以采取下面的格式:

  1. add 立即数, 寄存器
  2. add 寄存器, 寄存器
  3. add 内存, 寄存器
  4. add 立即数, 内存
  5. add 寄存器, 内存

比如,典型的 c=a+b 这样一个算术运算可能是这样的:

  1. movl -4(%rbp), %eax #把%rbp-4的值拷贝到%eax
  2. addl -8(%rbp), %eax #把%rbp-8地址的值加到%eax
  3. movl %eax, -12(%rbp) #把%eax的值写到内存地址%rbp-12

这三行代码,分别是操作 a、b、c 三个变量的地址。它们的地址分别比 %rbp 的值减 4、减 8、减 12,因此 a、b、c 三个变量每个都是 4 个字节长,也就是 32 位,它们是紧挨着存放的,并且是从高地址向低地址延伸的,这是栈的特征。

除了 add 以外,其他算术运算的指令:

image.png

与栈有关的操作:

image.png

跳转类:
image.png

过程调用:
image.png

比较操作: