本文我们来通过实例来梳理一下编译链接的流程。
编译链接过程
程序的编译是分阶段的,主要有以下阶段:
- 预处理: Prepressing
- 编译: Compilation
- 汇编: Assembly
- 链接: Linking
整个过程图示如下:
接下来,我们以最简单的 C 文件来说明一下源码到可执行文件的过程
源码到可执行文件
在macOS 我们通常由多种方式来手动或自动执行源代码的编译,此处我们使用 clang 来进行过程演示。
为简单起见,只实现一个简单的 hello.c 程序
#include <stdio.h>
#define MAX_AGE 120
int main() {
printf("%s\n", "hello world~");
printf("%d\n", MAX_AGE); // 简单输出
return 0;
}
通常而言,可以直接调用 clang 的编译命令即可将 hello.c 源码通过编译链接输出可执行文件a.out 。
$ clang hello.c # 编译命令
$ ./a.out # 执行a.out
hello world~
120
程序的整个编译过程可以通过命令来显示
$ clang -ccc-print-phases hello.c
+- 0: input, "hello.c", c
+- 1: preprocessor, {0}, cpp-output
+- 2: compiler, {1}, ir
+- 3: backend, {2}, assembler
+- 4: assembler, {3}, object
+- 5: linker, {4}, image
6: bind-arch, "x86_64", {5}, image
如果我们不输入任何参数直接用 clang 进行编译,则会省略其中的详细过程,今天我们则一步一步来进行探究。
Clang 指令说明
由于要用到这次的主角 clang ,它支持很多指令,我们先来做一个简要的了解
通过 man clang 可以看到描述,摘录一部分,详细指令参见官方文档:《clang - the Clang C, C++, and Objective-C compiler》
$ man clang
NAME
clang - the Clang C, C++, and Objective-C compiler
DESCRIPTION
clang is a C, C++, and Objective-C compiler which encompasses preprocessing, pars-
ing, optimization, code generation, assembly, and linking. Depending on which
high-level mode setting is passed, Clang will stop before doing a full link. While
Clang is highly integrated, it is important to understand the stages of compila-
tion, to understand how to invoke it. These stages are:
...
to use the static analyzer.
可见其指令主要分为下面的这些部分
- Driver
- Preprocessing
- Parsing and Semantic Analysis
- Code Generation and Optimization
- Assembler
- Linker
- Clang Static Analyzer
而我们重点关注的是这些 Options, 这将是下面我们演示的重点参数。
OPTIONS
Stage Selection Options
-E Run the preprocessor stage.
-fsyntax-only
Run the preprocessor, parser and type checking stages.
-S Run the previous stages as well as LLVM generation and optimization stages
and target-specific code generation, producing an assembly file.
-c Run all of the above, plus the assembler, generating a target ".o" object
file.
no stage selection option
If no stage selection option is specified, all stages above are run, and the
linker is run to combine the results into an executable or shared library.
其他常用参数
-o <file> Write output to file. # 指定一个输出文件
-v Show commands to run and use verbose output. # 详细日志输出
解析来我们就开始行动吧
编译过程深入
1、预处理
预处理阶段,通常会处理代码中的 #开头 的预编译指令,比如
- 去除注释
- 删除
#define并展开宏定义 - 将
#include包含的文件插入到该指令位置等(即替换宏,删除注释,展开头文件,产生.i文件)
将上面的 hello.c 进行预处理
$ clang -E hello.c -o hello.i
输出的 hello.i 文件如下,由于文件内容较多,此处只做部分摘取
# 1 "hello.c"
// 省略 xxx 行
extern int __vsnprintf_chk (char * restrict, size_t, int, size_t,
const char * restrict, va_list);
# 408 "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/stdio.h" 2 3 4
# 2 "hello.c" 2
int main() {
printf("%s\n", "hello world~");
printf("%d\n", 120);
return 0;
}
从上面可以看到,注释没有了,我们定义的宏 MAX_AGE 被直接转换成了 120 ,但代码基本没有太多变化
2、编译
编译阶段,通常会对预编译处理过的文件(hello.i)进行以下的处理:
- 词法分析
- 将代码切成一个个
Token,比如大小括号,并且标注其位置
- 将代码切成一个个
- 语法分析
- 验证语法是否正确,将单词序列组合成短语,组成抽象语法树
AST
- 验证语法是否正确,将单词序列组合成短语,组成抽象语法树
- 生成中间代码
IR- 将上一步生成的
AST遍历翻译成LLVM IR
- 将上一步生成的
- BC 中间代码生成(可选)
- Xcode7 后开启
bitcode,苹果做进一步优化,通过优化后的IR,可产生中间代码.bc
- Xcode7 后开启
- 汇编代码生成
- 通过一个个
Pass去优化,最后生成汇编代码
- 通过一个个
构建 AST 最后输出 IR 文件,它是编译器前端生成的中间代码 ,指定编译参数 -S 可以将 .i文件直接转换为汇编语言,产生.s文件,这里会忽略中间的代码生成、优化、目标代码生成等阶段。
$ clang -fmodules -fsyntax-only -Xclang -ast-dump hello.c # 输出AST
$ clang -S -emit-llvm hello.c # 生成中间IR文件
$ clang -S hello.i -o hello.s # 直接编译生成汇编
输出汇编代码hello.s 内容如下:
.section __TEXT,__text,regular,pure_instructions
.build_version macos, 11, 0 sdk_version 11, 3
.globl _main ## -- Begin function main
.p2align 4, 0x90
_main: ## @main
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
subq $16, %rsp
movl $0, -4(%rbp)
leaq L_.str(%rip), %rdi
leaq L_.str.1(%rip), %rsi
movb $0, %al
callq _printf
leaq L_.str.2(%rip), %rdi
movl $120, %esi
movl %eax, -8(%rbp) ## 4-byte Spill
movb $0, %al
callq _printf
xorl %ecx, %ecx
movl %eax, -12(%rbp) ## 4-byte Spill
movl %ecx, %eax
addq $16, %rsp
popq %rbp
retq
.cfi_endproc
## -- End function
.section __TEXT,__cstring,cstring_literals
L_.str: ## @.str
.asciz "%s\n"
L_.str.1: ## @.str.1
.asciz "hello world~"
L_.str.2: ## @.str.2
.asciz "%d\n"
.subsections_via_symbols
3、汇编
这个阶段,将前一阶段生成的汇编代码hello.s 转换为特定平台的目标文件 ,也称为object 文件。
输出格式为: .o
$ clang -c hello.s -o hello.o
到这里,目标文件就输出了,内部部分内容如下:
cffa edfe 0700 0001 0300 0000 0100 0000
0400 0000 0802 0000 0020 0000 0000 0000
1900 0000 8801 0000 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 0000 0000
c000 0000 0000 0000 2802 0000 0000 0000
c000 0000 0000 0000 0700 0000 0700 0000
0400 0000 0000 0000 5f5f 7465 7874 0000
0000 0000 0000 0000 5f5f 5445 5854 0000
可以看到,内部是二进制代码,只是以 16 进制显示出来了,看到 cffa edfe 是不是有种眼熟的感觉😆。你猜的没错,这种 目标文件 其实是有特定结果的,就是典型的 Mach-O 文件,拖入MachOView 即可见
到这里,目标文件生成了,虽然是机器码,但是它并不能直接被执行,还得将所有资源链接才可以。
4、链接
链接阶段,通常会将目标文件链接成可执行文件。这个阶段,将多个目标文件 合并为一个可执行文件 或动态库文件,输出格式为:.out 或 .so
通常情况下,我们会进行多文件 或模块开发,以及通过库文件等形式共享代码,因此不同的目标文件之间可能有相互引用的变量或调用的函数, 链接器就是能将不同的目标文件(.o文件)链接起来的程序。如我们经常调用 Foundation 框架和 UIKit 框架中的方法和变量,但是这些框架跟我们的代码并不在一个目标文件中,这就需要链接器将它们与我们自己的代码链接起来。
还记得,上面hello.c 文件中引用了系统库函数 printf ,没有进行链接是无法使用的。
下面来尝试对 hello.o 文件进行链接
$ ld hello.o
Undefined symbols for architecture x86_64:
"_printf", referenced from:
_main in hello.o
ld: symbol(s) not found for architecture x86_64
# 可见这个是外部符号,需要我们也指定 _printf 所在的库,才能进行链接
ld hello.o /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/lib/libc.tbd
# 这个 libc.tbd 的路径可以通过命令行来获取,可以简化上方的命令
ld hello.o `xcrun --show-sdk-path`/usr/lib/libc.tbd
# 执行生成的 a.out 文件
$ ./a.out
hello world~
120
到这里,我们的目标文件就链接输出了 a.out 是一个可执行程序,其实它也是一个 MachO 文件,去掉 .out
看看吧
总结一下,在编译器对代码进行编译过程中做了两件重要的事情:
- 将源码编译为汇编
- 将符号分类汇总
下一节我们来聊符号。