ARTICLE · 1094452
从源代码到可执行文件,中间到底经历了什么?
当你写下这样一个简单的程序:
#include<stdio.h>intmain(void){printf("Hello, world!\n");return0;}你可能会使用下面的命令编译它:
gcc main.c -o main几秒钟之后,你得到了一个可执行文件。
很容易让人认为,Compiler 只是简单地把源代码转换成机器代码。
实际上,中间发生了很多事情。
从源代码到可执行文件的过程通常包括 预处理(preprocessing)、编译(compilation)、汇编(assembly)和链接(linking)。当程序最终启动时,操作系统和 runtime linker 还需要执行额外的工作,然后 CPU 才开始执行程序。
理解这个过程之后,很多我们熟悉的东西——header files、object files、libraries、linker errors 和 executable formats——都会更容易理解。
1. 整体流程
一个简化的编译流程如下:
Source Code | vPreprocessor | vPreprocessed Source | vCompiler | vAssembly Code | vAssembler | vObject File | vLinker | vExecutable | vLoader / Runtime Linker | vRunning ProcessGCC 将 preprocessing、compilation、assembly 和 linking 作为生成可执行文件的主要阶段。
重要的一点是,Compiler 只是整个 toolchain 的一部分。
可执行文件是由多个工具共同完成的。
2. 第一步:Preprocessing
考虑下面这一行:
#include<stdio.h>Compiler 通常不会直接接收你写下的原始源文件。
在编译之前,Preprocessor 会处理这样的 directives:
#include#define#ifdef#ifndef#endif例如:
#define SIZE 100int buffer[SIZE];宏 SIZE 可以在 preprocessing 过程中被展开。
类似地,#include directive 会让被包含的 header 内容对正在处理的源代码可用。
完成 preprocessing 之后,Compiler 会接收到一份大得多的 C source code。
你可以使用 GCC 看到结果:
gcc -E main.c -o main.i.i 文件包含 preprocessed source。
此时,程序仍然是 C code。
还没有生成 machine code。
3. 第二步:Compilation
下一阶段是真正的 Compiler。
Compiler 分析 source code,并通过更低层的 intermediate representations(IR) 进行转换,最终生成 assembly language,作为一种可以观察到的输出阶段。
例如:
intadd(int a, int b){return a + b;}最终可能变成类似这样的 assembly:
add: mov eax, edi add eax, esi ret具体的 assembly 取决于 target architecture、Compiler、optimization level、ABI 以及其他选项。
重要的是,Compiler 理解 programming language 的语义。
它会执行这样的任务:
解析 source code
检查类型
分析表达式
优化程序
为 target architecture 生成 code
现代 Compiler 在生成 assembly 之前,可能会通过多个 intermediate representations 执行许多复杂的转换。
你可以让 GCC 在这个阶段停止:
gcc -S main.c -o main.s结果是一个 assembly source file。
这里有一点需要注意:
Assembly language 仍然不是最终的 machine-code executable。
后面还有一个步骤。
4. 第三步:Assembly
Assembler 接收 assembly language,并将其转换成 machine code。
例如:
mov eax, ediadd eax, esiret会被转换成 target CPU architecture 能够理解的 binary instructions。
结果通常是一个 object file。
在很多 Unix-like systems 中,这个文件使用 .o 扩展名:
main.o你可以使用下面的命令生成它:
gcc -c main.c -o main.o此时,我们已经有了 machine code,但仍然不一定拥有一个完整的 executable。
为什么?
因为一个 source file 很少包含程序所需要的全部内容。
5. Object File 里面有什么?
Object file 不只是 machine instructions 的集合。
它可以包含几种重要类型的信息,包括:
machine code
data
symbol information
relocation information
section information
debugging information(如果要求生成)
考虑:
intadd(int a, int b){return a + b;}生成的 object file 包含 add 的 machine code。
它也包含其他程序部分可能需要的 symbol 信息。
现在假设有第二个 source file:
intadd(int, int);intmain(void){return add(1, 2);}当这个 source file 被编译时,Compiler 通过下面的 declaration 知道 add 存在:
intadd(int, int);但 add 的实现可能位于另一个 object file 中。
因此,Compiler 不能简单地把 add 的最终地址直接放进 machine code。
这就是 Linker 发挥作用的地方。
6. 多个 Source Files 需要被组合
一个典型的程序可能包含:
main.cmath.cnetwork.cdatabase.c每个 source file 都可以独立编译:
main.c -> main.omath.c -> math.onetwork.c -> network.odatabase.c -> database.o这是 object files 存在的重要原因之一。
每个 source file 不需要每次都和其他所有文件一起重新编译成一个巨大的文件。
相反,每个 translation unit 都可以单独编译。
之后,再把这些 object files 组合起来。
例如:
gcc -c main.cgcc -c math.cgcc main.o math.o -o program最后一条命令通过 GCC 调用 Linker。
7. Linker 到底做什么?
Linker 将 object files 和 libraries 组合成最终的 executable,或者其他可以继续进行 linking 的输出。
它最重要的工作之一是 symbol resolution。
假设 main.o 中包含对下面 symbol 的引用:
add而 math.o 中包含 add 的实际定义:
addLinker 会把这个引用和定义连接起来。
概念上可以表示为:
main.o | | references add vLinker | | finds definition of add vmath.oLinker 还会执行 relocation。
machine code 中可能包含一些地址,这些地址必须等到 Linker 知道程序不同部分在最终 image 中的位置之后才能确定。
Object files 包含 relocation information,让 Linker 可以调整这些位置。
这也是为什么 object file 可以包含 machine code,却还不是一个完整 executable。
8. Libraries 也参与其中
大多数真正的程序都依赖 libraries。
我们的简单示例使用了:
printf("Hello, world!\n");但 printf 的实现并不包含在我们的 source file 中。
它来自一个 library。
Libraries 可以使用不同的方式进行 linking。
对于 static library,相关 code 可以在 linking 时被复制到最终 executable 中。
对于 shared library,executable 会包含对由 shared library 在 runtime 提供的 code 的引用。dynamic linker 会执行必要的 library loading 和 runtime symbol resolution,根据配置的不同,这其中可能包括 lazy binding。
这种区别会对 executable size、deployment、updates 和 runtime behavior 产生重要影响。
它也解释了为什么程序有时可以成功编译,却在 linking 时失败;或者成功 linking,却因为运行时无法加载所需的 shared library 而失败。
例如,Compiler 可能完全理解一个 function declaration,但 Linker 找不到对应的 definition。
这会产生一个 linker error,例如:
undefined reference to ...Compiler、Linker 和 runtime linker 解决的是不同的问题。
9. Executable 是一种结构化文件
完成 linking 后,我们最终得到了一个 executable。
在 Linux 上,一种常见的 executable format 是 ELF。
在 Windows 上,可执行程序通常使用 PE。
Executable 并不只是:
machine instructionsmachine instructionsmachine instructions...它具有定义好的 file format,其中包含操作系统和 loader 加载它所需要的信息。
根据 format 和 build configuration 的不同,一个 executable 可以包含:
executable code
initialized data
uninitialized data (BSS)
read-only data
symbol information
relocation information
dynamic linking information
program entry point information
具体的组织方式取决于 executable format 和 platform。
所以,当我们说 Linker “创建了一个 executable”时,它实际上创建的是一个结构化的 binary image,操作系统知道如何加载它。
10. Linking 和 Loading 并不是一回事
这里还有一个重要的区别。
Linking 发生在构建程序时。
Loading 发生在运行程序时。
这是两个不同的阶段。
假设你运行:
./program在现代 Linux system 中,kernel 首先检查 ELF executable,并将需要的部分映射到 process 的 address space 中。如果 executable 通过 .interp segment 指定了 dynamic linker,kernel 就会加载这个 interpreter,并将控制权交给它。
dynamic linker,例如典型 x86-64 Linux system 上的 /lib64/ld-linux-x86-64.so.2,随后会执行诸如映射所需 shared libraries 和解析 dynamic symbols 等任务。
所以,一个简化的流程是:
Operating System Kernel | vMap Executable | vDynamic Linker | vLoad Shared Libraries | vResolve Dynamic Symbols | vProgram Startup具体细节会根据 operating systems 和 executable formats 的不同而有所变化。
11. main() 是从哪里来的?
还有一个经常让人感到意外的细节。
CPU 通常不会直接调用:
main();程序启动过程中还存在 runtime startup code。
在一个典型的使用 glibc 的 Linux system 中,executable 的 entry point 通常是 _start,它由 C runtime startup object,例如 crt1.o 提供。
一个简化的 startup path 如下:
Operating System | v_start | v__libc_start_main | vmain()startup code 和 C runtime 会在调用 main() 之前执行必要的 initialization。
完成这些准备之后,程序才会进入 main() function。
具体的 startup sequence 会根据 operating systems、executable formats、C libraries 和 toolchains 的不同而变化。
12. 完整流程
现在我们可以把所有内容放在一起。
假设我们从:
main.c开始。
整个过程大致如下:
main.c | | preprocessing vmain.i | | compilation vmain.s | | assembly vmain.o | | linking vprogram | | loading / dynamic linking vRunning Process对于一个更大的项目:
main.c -> main.omath.c -> math.onetwork.c -> network.odatabase.c -> database.o | v Linker | v executable | v OS + Dynamic Linker | v Running Process而整个过程通常可以通过一条命令触发:
gcc main.c math.c -o programGCC 会自动执行或调用所需要的各个阶段。它还提供了 -E、-S 和 -c 等选项,可以让过程在中间阶段停止。
13. 为什么需要这么多阶段?
看起来这似乎有些复杂。
为什么不能简单地这样做?
source code | vexecutable这些阶段之所以分开,是有实际原因的。
首先,不同阶段解决不同的问题。
Preprocessor 负责处理 preprocessing directives。
Compiler 理解 programming language 并生成 target code。
Assembler 将 assembly language 转换成 object code。
Linker 将独立编译的各个部分组合起来。
Loader 和 dynamic linker 为 executable 及其依赖准备执行环境。
其次,独立编译让大型项目变得更容易管理。
如果一个项目包含数千个 source files,那么修改其中一个 source file 不应该要求重新编译所有内容。
Build systems 可以重新编译发生变化的文件,然后再次链接得到的 object files。
第三,这种分离让不同的工具和语言成为可能。
Linker 不需要理解完整的 C 或 C++ language。
它主要需要理解 object files、symbols、relocations、libraries 和 target executable format。
这种分离是现代 software toolchains 的基础之一。
14. 当你看到 Compiler Error 时发生了什么?
理解这个流程之后,也会更容易对错误进行分类。
例如:
syntax error或者:
unknown type name通常与 source analysis 和 compilation 有关。
类似下面的错误:
undefined reference to 'foo'通常是一个 linker problem。
而类似:
No such file or directory这样的错误,如果是在启动一个依赖缺失 shared library 的程序时出现,那么可能发生在 runtime loading 阶段,而不是 compilation 阶段。
这些错误信息实际上是在告诉你,哪个阶段发生了问题。
在排查 build problems 时,这一点非常有用。
15. Compiler 只是整个过程的一部分
当程序员说:
“Compiler 把 source code 转换成 executable。”
这是一个有用的简化说法。
但完整的过程更加有意思:
Source Code | vPreprocessor | vCompiler + Intermediate Representations | vAssembly | vAssembler | vObject File | vLinker + Libraries | vExecutable | vOS + Dynamic Linker + Runtime | vProcess | vCPU我们编写的 source code 与 CPU 实际执行的 instructions 之间隔着多个 abstraction layers。
在这两者之间,程序会经过多种 representation,以及多个专门的工具。
下一次当你运行:
gcc main.c -o main可以记住,这条命令隐藏着一段相当长的过程。
对程序员来说看起来只有一步的事情,实际上是一个经过精心组织的 pipeline,它将人类可读的 source code 转换成一种 binary program,使操作系统能够加载它,并让 CPU 能够执行它。