第一次遇到 Hello World 已经是九年前的课堂上。九年后,除了下面这段代码,一切都变了。

1
2
3
4
5
#include <stdio.h>
int main() {
puts("Hello world!");
return 0;
}

系统环境是 macOS 10.12.5,假定上面这段代码存成了文件 hello-world.c, 下面是看一看到底代码运行的时候都做了一些什么事情。
先把这段代码编译成 Intel 风格的汇编码

1
$ gcc -masm=intel -Os -S hello-world.c

其中 gcc是一个编译器,-Os是进行不扩大程序的小的优化,-S是生成汇编代码,运行这行命令以后会在同目录生成hello-world.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
	.section	__TEXT,__text,regular,pure_instructions
.macosx_version_min 10, 12
.intel_syntax noprefix
.globl _main
_main: ## @main
.cfi_startproc
## BB#0:
push rbp
Ltmp0:
.cfi_def_cfa_offset 16
Ltmp1:
.cfi_offset rbp, -16
mov rbp, rsp
Ltmp2:
.cfi_def_cfa_register rbp
lea rdi, [rip + L_.str]
call _puts
xor eax, eax
pop rbp
ret
.cfi_endproc

.section __TEXT,__cstring,cstring_literals
L_.str: ## @.str
.asciz "Hello world!"


.subsections_via_symbols

所有以.开头的都是 assembler directives,我们这里暂时先不管这些指令,从_main开始逐行阅读。

第一句指令

1
push rbp

把当前的基底指标暂存器(Base Pointer Register)压栈 push是汇编指令的一类助记码(mnemonic),本质上它是一个单个可执行的机器指令的缩写,push后面接的是操作数, 操作数可以是寄存器、可以是一个内存地址、也可以是一个中间变量(intermidate)。因此push就是将操作数中定义的元素进行压栈操作。rbp 是其中一种通用寄存器,不同的通用寄存器请参考这里. push指令将对 rbp 指向的内存地址进行自减操作,此时 rbp 将会指向新的一个内存地址,这里 rbp 可以立即成是所有本地变量的存储空间的开始,任何新的本地变量都会在 rbp 之上继续压栈。

第二句指令

1
mov rbp, rsp

将堆叠指标暂存器(Stack Poinger Register)指向的内容切换到 rbp 指向的内容
mov是非常常用的一个操作。这里堆叠指标暂存器实际上与 rbp 一起划定了当前这个程序的所有本地变量的寄存空间,这里我们没有本地变量,从而 rsp 一定跟 rbp 相等,多数情况下,rsp 将会是 rbp 减去一定数量后的结果,比如减掉 16 Byte.
1
2
mov rbp, rsp
sub rsp, 16


第三句指令

1
lea	rdi, [rip + L_.str]

将标签 L_.str 定义的 ASCII 字符串加载到目的索引暂存器(Destination Index Register),这里实际上是准备好调用 puts 所需要的第一个参数
lea指令的全称是 Load Effective Address, 与mov指令不同,前者主要是计算出实际地址,并存到某个暂存器中,而后者是将某个地址的数据加载到寄存器中。这里使用了 rip+L_.str 计算出字符串 Hello World 所在的地址,并将这个地址记载到 rdi 中以供 _puts 调用。

L_.str 是一个标签,它的数据是一个 ASCII 字符串,程序可以通过标签定位到这个数据,而rip是一个指令指针(Instruction Pointer), 指向当前执行的指令的地址,每一次取这个指针都会指向下一个执行的指令的地址,在这里,rip 指的总是当前 main 执行的地址,因此通过相对地址定位,就能计算出L_.str的数据的内存地址。

第四句指令

1
call _puts

调用 puts, 在控制台的标准输出中打印字符串Hello world!字符串
根据x86 ABI, 函数调用时,将会取 rdi 作为第一个参数,puts 命令传入参数后,在控制台输出了 Hello world! 字符串。

第五句指令

1
xor	eax, eax

将 eax 置 0,以准备 main 函数的返回值
xor是异或运算,对两个相同的暂存器取异或是一个快速的暂存器置0的运算,它与mov eax, 0等价,但前者生成的机器码更短。

第六句指令

1
pop rbp

将之前保存的rbp暂存器出栈
pop是出栈命令,恢复 push rbp 对rbp做的自减操作, 此时 rbp 指向地址将自增。

第七条指令

1
ret

将程序控制返回到该程序被调用的位置,也就是此时的栈顶

Assembler Directives

现在我们再回过头来看编译器指令

1
2
3
4
.section	__TEXT,__text,regular,pure_instructions
.macosx_version_min 10, 12
.intel_syntax noprefix
.globl _main

在程序最前面声明的这些指令描述了程序的元信息。

.section 表明告诉 assembler 将下面的代码归入到 segname 为 __TEXT, sectname 为__text的 section 中。regular 表示该小节可能包含任意数据,无需特殊处理,是默认的 section 类型。pure_instructions 是补充信息,意思是这个小节只包含机器指令。

.globl 是标志下面的符号是全局的,在这里我们把_main符号设置成全局的,从而让 linker 知道_main符号是全局的,如果在多个文件中出现了这个符号,将会报错。

.macosx_version_min 标志着运行此程序的最低 macOS 系统版本

.intel_syntax 告诉编译器这里使用 Intel Syntax,并且暂存器不需要%作为前缀。

CFI Directives

所有以.cfi开头的编译器指令都是 CFI Directives. CFI 的全称是 Call frame information, 一个 GNU AS 扩展。它可以用来提供向 debugger 提供调用栈信息或者向 linker 提供 exception tables. cfi_startproccfi_endproc 分别标志着程序开始与结束。

.cfi_def_cfa_offset 16 定义了 Call Frame Address 计算时候的偏移规则,这里意味着 CFA 一定会偏移 16bit.

.cfi_offset rbp -16 将 rbp 之前的值储存在 CFA 偏移 -16bit 的位置。

.cfi_def_cfa_register rbp 意味着使用新的 rbp 来计算 CFA.

最后看到

1
2
L_.str:                                 ## @.str
.asciz "Hello world!"

这里.asciz代表后面的数据是一个 ASCII 字符串,同时字符串结尾加上\0,这也是 C 字符串的要求.

更多 Assembler Directives 的介绍可以参考这里