编译是将源代码变成可执行程序的过程,这个过程可以分为以下步骤:

  • 预处理
  • 编译
  • 汇编
  • 链接

下面以gcc为例解释每个阶段

预处理阶段

预处理阶段主要是处理源代码中以’#’开始的预处理指令,将其转换后直接插入程序文本中,得到另一个C源码,通常以”.i”作为文件扩展名。

例如此时有一个文件 hello.c:

1
2
3
4
5
#include <stdio.h>
int main(void) {
printf("hello world");
return 0;
}

预处理命令:

1
$ gcc -E hello.c -o hello.i

简单来说预处理阶段的一些处理规则如下:

  • 递归处理 #include 命令,将对应文件的内容复制到该指令所在的位置
  • 删除所有的 #define 命令,并替换为对应的宏
  • 处理所有的条件预处理指令,比如” #if, #ifdef, #endif 等等
  • 删除所有注释
  • 添加行号和文件名标识

编译阶段

编译阶段会进行一系列的词法分析,语法分析,语义分析和优化,将C源码编译成汇编码。该步骤的操作对象可以是源代码hello.c,也可以是预处理后的代码hello.i,因为实际上gcc将预处理阶段合并到了编译阶段之中。

值得注意的是,实际上在编译器进行编译的过程中,不仅仅是简单地把 C 源码写成了汇编码,同时还做了很多的优化和包装,进行了很多的复杂任务。

编译阶段的指令是:

1
2
$ gcc -S hello.c -o hello.s
$ gcc -S hello.i -o hello.s -masm=intel -fno-asynchronous-unwind-tables

第二行代码中的-masm指定了生成的汇编码是intel格式,而-fno-asynchromous-unwind-tables用与生成没有cfi宏的汇编码

生成的hello.s:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  .file "hello.c"
.intel_syntax noprefix
.text
.section .rodata
.LC0:
.string "Hello world"
.text
.globl main
.type main, @function
main:
push rbp
mov rbp, rsp
lea rax, .LC0[rip]
mov rdi, rax
call puts@PLT
mov eax, 0
pop rbp
ret
.size main, .-main
.ident "GCC: (GNU) 14.2.1 20250207"
.section .note.GNU-stack,"",@progbits

此处可以看到printf编译后被替换成了puts,这是因为printf在只有一个参数的时候作用跟puts差不多,所以为了节省性能就直接替换成了puts

补充知识:cfi宏 (Call Frame Information)

cfi宏是一组用于生成调试信息的伪指令,主要用于生成栈帧调试信息,以便调试器能够还原函数调用栈,尤其是在函数发生异常、崩溃或者中断时。

常见cfi宏指令:

  • .cfi_startproc: 表示函数开始,开启帧信息记录
  • .cfi_endproc: 表示函数结束,关闭帧信息记录
  • .cfi_def_cfa reg, offset: 定义 CFA(Canonical Frame Address)为 reg + offset
  • .cfi_def_cfa_offset, offset: 修改当前 CFA 的偏移
  • .cfi_def_cfa_register reg: 修改当前 CFA 的基寄存器
  • .cfi_offset reg, offset: 表示某个寄存器保存到了栈的哪一偏移处
  • .cfi_restore reg: 恢复寄存器的状态(与 .cfi_offset 相反)
  • .cfi_adjust_cfa_offset n: CFA 偏移增加/减少 n 字节

CFA (Canonical Frame Address) 是什么

CFA 是标准帧地址,通常是一个栈帧的参考点,调试器通过它来找出:

  • 返回地址在哪里
  • 参数、局部变量、保存的寄存器在哪里

汇编阶段

在汇编阶段,汇编器根据汇编指令跟机器码的对照表进行翻译,将hello.s汇编成目标文件hello.o

汇编命令:

1
2
$ gcc -c hello.s -o hello.o
$ gcc -c hello.c -o hello.o

汇编的源码既可以是汇编码hello.s,也可以是源代码hello.c,gcc会自动先进行预处理和编译,再进行汇编。
此时产生的目标文件是一个重定位文件(Relocatable File),可以使用objdump命令查看文件内容:

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
$ objdump -sd hello.o -M intel
hello.o: file format elf64-x86-64

Contents of section .text:
0000 554889e5 488d0500 00000048 89c7e800 UH..H......H....
0010 000000b8 00000000 5dc3 ........].
Contents of section .rodata:
0000 48656c6c 6f20776f 726c6400 Hello world.
Contents of section .comment:
0000 00474343 3a202847 4e552920 31342e32 .GCC: (GNU) 14.2
0010 2e312032 30323530 32303700 .1 20250207.
Contents of section .note.gnu.property:
0000 04000000 20000000 05000000 474e5500 .... .......GNU.
0010 020001c0 04000000 01000000 00000000 ................
0020 010001c0 04000000 01000000 00000000 ................

Disassembly of section .text:

0000000000000000 <main>:
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: 48 8d 05 00 00 00 00 lea rax,[rip+0x0] # b <main+0xb>
b: 48 89 c7 mov rdi,rax
e: e8 00 00 00 00 call 13 <main+0x13>
13: b8 00 00 00 00 mov eax,0x0
18: 5d pop rbp
19: c3 ret

可以看到此处puts函数的地址被设置为了00 00 00 00,这是因为文件还没有进行链接操作,库函数的地址是未知的,所以就用0来代替

注意,前面说到,汇编结束之后产生的是一个可重定位文件,那么什么是重定位,为什么叫可重定位文件呢?

  • 刚刚汇编完的文件中,代码和全局变量是分开存储的,而全局变量的地址到底是多少,代码是不知道的,此时就需要由链接器来寻找全局变量的地址。在链接之前,代码中访问全局变量的命令所访问的地址会被填充为0,链接时链接器就会把这些待填充的地址都改为

链接阶段

链接阶段包含两种链接:动态链接和静态链接。

静态链接

静态链接的过程就是把可重定位文件所依赖的库文件全部复制进来,生成一个包含了所需依赖库的目标文件,这个目标文件可以独立运行,不再需要任何的动态库。

这个过程是由静态链接器完成的,静态链接器也被称为链接器。

之所以叫做静态链接,就是因为静态链接的过程完全是在编译的过程中完成的,跟运行时没有什么关系。

静态链接命令:

1
$ gcc hello.c -o hello --static
  • 关于pwn:

在pwn中如果一个ELF文件是静态链接的,那么它所使用的库就会全部被编译到ELF文件中,所以是可以直接找到其中的库函数地址的,如果能够利用栈溢出覆盖返回地址,就可以劫持函数调用我想调用的库函数了

动态链接

与静态链接不同,动态链接并不会