1. 从一行代码到可执行文件,到底发生了什么
我经常在群里看到有人提问:”我代码明明编译通过了,为什么运行的时候报一堆找不到符号的错?“或者”为什么我加上 -lpthread 就正常,不加就崩?“。说实话,这些问题归根结底都是对**编译(Compile)和链接(Link)**这两个阶段理解不够深。很多人把”能跑就行“当成目标,却忽略了中间那段最容易被忽视、也最容易出问题的流程。
你写下的每一行 C/C++ 代码,从编辑器里的文本变成操作系统能直接加载运行的二进制文件,中间要经过预处理、编译、汇编、链接四个阶段。前三个阶段大多数人都听过甚至能说出来,但链接这个环节,很多人在学校学《编译原理》的时候就没搞明白,工作之后又一直在”遇错查错“,从来没有系统捋过一遍。这篇博文我打算用几篇的篇幅,把编译和链接这件事彻底讲透,第一篇先解决最核心的整体流程和静态链接、动态链接的区别,后续再深入目标文件格式、链接脚本、装载与运行时细节。
这篇内容适合谁看?刚入行不久、对构建系统一知半解的开发者,被 undefined reference 折磨过的 C/C++ 程序员,以及想把《编译原理》课上学的东西跟实际工程对上号的在校生。我尽量少堆术语,多用实际案例和命令行演示,保证你看完能直接用在项目里。
2. 四个阶段的完整拆解,每个环节都在干什么
2.1 预处理:不只是“复制粘贴头文件”
很多人以为预处理就是把#include的内容原封不动塞进来,这个理解太粗糙了。预处理阶段由预处理器(如cpp)执行,它做的事情包括:
- 展开
#include包含的头文件,而且是递归展开,直到所有头文件都被完整引入; - 处理所有的
#define宏,做文本级替换; - 处理条件编译指令
#if、#ifdef、#ifndef,把不满足条件的代码块直接丢弃; - 删除所有注释,把代码变成”干净“的纯逻辑文本;
- 处理
#pragma等编译器指令。
你可以用gcc -E main.c -o main.i把预处理后的结果导出来看。我第一次看main.i的时候特别震惊,一个只有几十行的main.c,预处理之后能膨胀到上万行。你#include <iostream>一下,后面跟着的是一整个标准库头文件的展开。这也能解释为什么有些老项目把头文件写得很乱、#include很多的时候,编译会慢得离谱——因为预处理器要把所有东西都展开,哪怕你只用了其中一个小函数。
2.2 编译:从高级语言到汇编的翻译
编译阶段把预处理得到的.i文件翻译成汇编代码,输出是.s文件。这个阶段是整个流程里最复杂的一环,涉及到词法分析、语法分析、语义分析和中间代码生成、优化、目标代码生成等步骤。
我当初学《编译原理》做实验的时候写过递归下降解析器,几千行代码勉强能处理一个简化版的表达式语言。真要实现一个完整的 C++ 编译器,工程量是数以百万行计的。所以实际工程里,我们只依赖成熟的编译器工具链——GCC、Clang、MSVC 这些。
不同编译器在编译阶段的优化策略差异很大。GCC 的-O2和 Clang 的-O2对同一段代码生成的汇编可能完全不同。这也是为什么有时候换了个编译器,原本正常运行的代码出现了诡异的 bug——多半是踩到了未定义行为(UB),而不同编译器对 UB 的优化处理方式不一样。
2.3 汇编:把汇编指令变成机器码
汇编阶段把.s汇编文件翻译成机器指令,目标文件(.o或.obj)就是这一阶段的产物。汇编器做的事情相对机械,每一条汇编指令基本对应一条或几条机器指令,符号和标签会被转换成地址占位符,留到链接阶段再填充。
这里有个概念值得提一下:目标文件不是最终可执行文件。虽然它包含机器码了,但里面的很多地址还是”悬空“的,尤其是引用其他目标文件或库里的符号时。你可以用nm命令查看一个.o文件里的符号,会发现大量符号标记为U(undefined,未定义),这些就是需要链接器去别处找的。
2.4 链接:把碎片拼成完整的可执行镜像
链接阶段由链接器(如ld、lld、gold)执行,它把多个目标文件和静态库合并成一个可执行文件或动态库。这个阶段做的事情包括:
- 符号解析:把每个目标文件里的未定义符号,和别的目标文件里定义的符号对上号;
- 重定位:把所有引用符号的地方,填上符号最终的真实地址;
- 合并节区:把不同目标文件里的
.text节、.data节等合并到一起,形成最终可执行文件的段落布局; - 生成最终镜像的头部信息:包括入口点、段表等。
我经常跟人打比方:编译阶段是”写作文”,每个.c文件相当于把自己那一段话写好了,但里面的错别字和引用的名言警句(外部函数)都没有核查具体出处;链接阶段就是”校对和装订“,把你所有写好的段落收集起来,统一编号、核对引用、调整格式,最后装订成一本完整的书。
3. 静态链接:把一切都焊死在可执行文件里
3.1 目标文件里装的是什么东西
要理解链接,得先看清目标文件(.o文件)的内部结构。Linux 下用的是 ELF(Executable and Linkable Format)格式,Windows 下用 PE/COFF,macOS 用 Mach-O。不同平台的格式不同,但核心思想一致。
用readelf -S main.o可以查看 ELF 文件的节区列表,常见的几个节区:
| 节区名 | 作用 | 对应代码中的内容 |
|---|---|---|
.text | 存放机器指令 | 函数体的编译结果 |
.data | 存放已初始化的全局变量和静态变量 | int a = 42; |
.bss | 存放未初始化的全局变量 | int b;(不占用文件空间) |
.rodata | 存放只读数据 | 字符串常量、const变量 |
.symtab | 符号表 | 函数名、变量名 |
.rela.text | 重定位表 | 告诉链接器.text里哪些地方需要修正地址 |
.bss节是个很有意思的设计。它不占实际的磁盘空间,但程序加载时会分配对应的内存并清零。也就是说,你声明了int arr[1000000]这种大数组,如果没初始化,它在目标文件里不占空间,加载时才划出 4MB 内存,全部填零。这一点对嵌入式开发特别重要,很多时候固件体积卡在什么地方,.bss没算进来会让你误判。
3.2 符号解析:一段代码的“认亲大会”
符号(symbol)本质上就是函数名和全局变量的名字。编译器编译每个.c文件的时候,都是独立进行的,它只知道当前文件里定义了哪些符号、引用了哪些符号。
假如你有a.c和b.c两个文件:
// a.c void func_a() { func_b(); } // b.c void func_b() { // do something }编译的时候,a.o里的符号表大概是这样(用nm a.o查看):
0000000000000000 T func_a U func_bT表示在.text节中定义的全局符号,U表示 undefined,也就是说func_a的代码里调用了func_b,但func_b的地址在编译a.c时是未知的。链接器拿到a.o和b.o之后,发现a.o里有个U func_b,然后去b.o的符号表里找到了T func_b,于是在a.o的.rela.text重定位表里记录需要修正的地址位置,把调用指令里的地址改写成func_b在合并后的.text节里的真实偏移位置。
这个过程很像拼图。每个.o文件是一块拼图碎片,碎片上有”引脚“(需要的符号)和”插座“(提供的符号),链接器负责把所有碎片啮合起来。
3.3 重定位:补全缺失的地址信息
重定位是链接阶段最核心的机制。以 x86-64 平台为例,call func_b这条指令在编译后是 E8(相对调用),后面跟 4 个字节的偏移量,但这个偏移量在编译.o时无法计算,因为func_b还不确定最终落在哪里。所以编译器先填一个占位值,同时在.rela.text里写一条重定位记录,格式大致是:
偏移量 符号名 重定位类型 0x14 func_b R_X86_64_PLT32链接器知道func_a和func_b在最终可执行文件里分别位于什么位置之后,就能计算偏移量,然后把计算结果填回指令里。等到链接完成,整个镜像的代码段布局就固定了,除非发生重定位(比如加载地址变化),否则这些地址不会再改变。
这也能解释为什么静态链接出来的可执行文件比较大。你链接了libc,哪怕只用printf,链接器会把用到的相关代码从库里抽出来并进你的可执行文件,这些代码就”焊死“在你的二进制里了。好处是移植方便,单一文件拷到哪里都能跑;坏处是磁盘占用大、内存占用大(多个进程各自加载一份同样的代码),而且一旦库里有个安全更新,你得重新编译整个程序才生效。
4. 动态链接:代码库的”随叫随到“共享方案
4.1 为什么需要动态链接
静态链接的缺点在互联网时代变得尤其突出。一个系统里有几百个可执行文件,如果全部静态链接,光是libc那份代码就得在磁盘上重复存储几百份,加载到内存里又是几百份,白白浪费大量空间。
更重要的是安全问题。静态链接的程序出了安全漏洞,需要等程序发布新版本、用户重新下载。而动态链接的程序用的是系统的libc.so,只要系统更新libc库,所有依赖它的程序就自动获得了修复。这也是为什么 Linux 发行版的更新机制里,库的更新优先级特别高。
动态链接的核心思想是:把公共的代码抽离成独立的.so(Linux)或.dll(Windows)文件,程序只记录对库的依赖关系,运行时由动态链接器(如ld-linux.so)负责把库加载到内存里。
4.2 地址无关代码(PIC):动态库的生存前提
动态库有个特殊要求:它加载到内存的地址不是固定的。同一个.so可能被共享给多个进程,每个进程的地址空间布局不一样,库被 mmap 到各个进程的虚拟地址可能位置完全不同。
如果库里的代码引用了全局变量或函数,难道每次加载都要重新修改指令里的地址吗?那做不到,因为共享库的代码段是只读的,进程之间要共享物理内存,改了就相当于每个进程一份副本,共享的意义就没了。
解决方案就是 PIC(Position Independent Code,位置无关代码)。编译器在生成动态库的代码时,开启-fPIC选项,所有对全局符号的访问都通过 GOT(Global Offset Table,全局偏移表)间接进行。GOT 在数据段里,每个进程可以有自己的副本,加载时动态链接器填充 GOT 里的真实地址,代码段本身完全不需要修改。这就是动态库能实现进程间共享的核心机制。
如果编译动态库时不加-fPIC,编译会产生”重定位文本“类型的代码,这种库加载时的速度会变慢,而且可能根本无法共享。特别是把静态库链接进动态库时,如果静态库没开-fPIC,生成的.so会有很多文本重定位,运行时效率极差。这是很多人在嵌入式或 Android NDK 开发中踩过的坑。
4.3 动态链接器的搜索路径:库往哪里找
动态链接的另一个核心机制是库的搜索路径。运行时加载libxxx.so时,动态链接器按以下顺序查找:
- 编译时写在二进制里的
DT_RPATH(已废弃,不推荐使用); - 环境变量
LD_LIBRARY_PATH; - 二进制里的
DT_RUNPATH; - 系统默认路径
/etc/ld.so.conf里配置的目录; /lib和/usr/lib。
这个搜索顺序坑了非常多人。我见过一个经典案例:某程序今天跑得好好的,第二天突然报error while loading shared libraries: libxxx.so.1: cannot open shared object file。排查之后发现是有人升级了系统库,新版本不兼容旧版本,而原程序是在旧的系统环境上编译的。这就是动态链接的”魔法“——便利的同时也意味着依赖环境的变化会直接影响程序的运行。你花了半天时间搞清楚LD_LIBRARY_PATH的作用,才算真正开始懂得链接器的工作方式。
5. 实战常见链接错误与排查工具
5.1 未定义引用(undefined reference)为什么这么多
undefined reference to 'xxx'是 C/C++ 程序员最常见的链接错误。我总结下来主要有这几个原因:
- 只声明但没定义函数,比如头文件里写了声明,源文件里忘了实现;
- 忘记链接对应的库,比如用了
pthread_create没加-lpthread,用了数学函数sin、cos没加-lm; - 链接的库顺序不对(后面会详细讲);
- C++ 函数名修饰(name mangling)导致符号不匹配——C 和 C++ 互相调用时必须用
extern "C"包裹; - 引入了头文件但对应库的版本不兼容,
libfoo.so.1里根本没有你用的那个符号。
我建议你排查这类问题的时候,第一步不是去翻代码,而是先确认符号到底存不存在。用nm -C libfoo.so | grep "你要找的函数名"或者readelf -Ws libfoo.so | grep 符号名,直接看库里面有没有这个符号、符号名是否跟你代码里期望的一致。如果是 C++ 代码,nm -C会帮你把修饰后的名字还原成可读的原名,这样就能判断是不是 extern "C" 的问题。
5.2 链接顺序:为什么加不加 -l 不一样
静态链接器处理目标文件和静态库时采用的是单次扫描模式。链接器从左到右扫描命令行里给出的文件:如果遇到一个.o文件,所有已定义的符号都会被收集进全局符号表;如果遇到一个静态库.a,链接器会检查库里每个.o文件的符号表,只要发现库里某个目标文件能解析当前已有的未定义符号,就把该目标文件抽出来链接进去。
关键问题在于:如果前面的目标文件里有一个未定义符号func_b,而libfunc.a排在它前面被扫描了,此时链接器还不知道需要func_b,就不会抽取libfunc.a里定义func_b的目标文件。等扫描完整个库、继续处理后面的目标文件时,才暴露出func_b未定义,但此时已经不可能回头再抽库里的文件了。
解决办法只有两个:把库放在所有依赖它的目标文件之后,或者使用--start-group和--end-group将多个库包起来让其循环扫描。很多新手就是死在这个细节上:gcc main.c -lfoo -lbar和gcc main.c -lbar -lfoo结果可能完全不同,一个编译通过,一个报 undefined reference。
5.3 一手工具链:readelf、objdump、nm 怎么配合用
排查链接问题靠纯读代码太累了,得学会用工具。我平时排查用得最多的三个命令:
| 命令 | 用途 | 常用参数 |
|---|---|---|
nm | 查看符号表 | -C(还原 C++ 名字)、-u(只看 undefined 符号) |
readelf | 查看 ELF 头、节区、段、动态信息 | -h(文件头)、-S(节区)、-d(动态信息) |
objdump | 反汇编、查看重定位表 | -d(反汇编)、-r(重定位表项) |
举个例子,你链接完提示某个符号没定义,还给了错误地址。你可以先nm查看所有目标文件里的定义和引用情况,再readelf -d查看最终可执行文件的动态依赖,最后用objdump -r查看某个.o文件的重定位记录,看看链接器是不是试图修正一个不该修正的地址。这三板斧下来,绝大多数链接问题都能定位出来。
6. 一个完整的 Mini 工程演示:从源文件到可执行文件
我搭一个最简单的实际例子,完整跑一遍编译和链接的各个步骤,帮你把前面讲的概念串起来。
准备三个文件:
// main.c #include <stdio.h> void greet(const char *name); int main() { greet("world"); return 0; } // greet.c #include <stdio.h> void greet(const char *name) { printf("hello, %s!\n", name); }先分步编译:
gcc -E main.c -o main.i gcc -S main.i -o main.s gcc -c main.s -o main.o gcc -c greet.c -o greet.o看一下main.o里的符号和重定位信息:
nm main.o objdump -r main.onm main.o的输出里一定能看到U greet,这就是前面说的未定义符号。objdump -r会告诉你.text节里偏移某个位置的调用需要被重定向到greet这个符号。
最后链接:
gcc main.o greet.o -o app再看nm app,你会发现原来的U greet变成了一个明确的地址T greet。这说明链接器已经完成了符号解析和重定位,greet在最终可执行文件里的地址确定下来了。
把这个例子换成动态链接,步骤也很简单:
gcc -fPIC -shared greet.c -o libgreet.so gcc main.c -L. -lgreet -o app_dyn运行之前先设置运行时的库搜索路径:
export LD_LIBRARY_PATH=./ ./app_dyn这里有个非常容易踩的坑:-L.是告诉链接器链接时去哪里找库,但运行时动态链接器并不知道这个路径,除非你用-Wl,-rpath,.把搜索路径写入二进制,或者设置LD_LIBRARY_PATH。我见过程序在开发机上跑得好好的,部署到生产环境就报找不到库,十有八九就是吃了这个亏。解决方案很简单,编译时加-Wl,-rpath,'$ORIGIN'让程序去它自己所在目录找库,或者用LD_LIBRARY_PATH指向部署目录,但记得生产环境千万别图省事把/下面全部目录塞进LD_LIBRARY_PATH,那会带来严重的路径劫持风险。
7. 编译与链接的学习路径建议
如果你想把编译和链接这块真正吃透,我给一条自己的学习路线作为参考:
先搞懂 ELF 文件格式本身,花一个周末读readelf -a的输出,别怕看不懂,逐字段对比查阅。然后自己动手写几个.c文件、几个库、几个可执行文件,反复编译链接并用工具观察差异。这一步能把静态链接的机制彻底弄清。
接下来动手做一个小实验:写一个动态库,用-fPIC和不加-fPIC各编译一次,用objdump -d对比两者的指令差异,就能直观理解为什么 PIC 是动态库的必需品。
然后深入了解动态链接器的加载流程。推荐看一下LD_DEBUG=libs ./app的输出,这个环境变量能让你看到动态链接器实际搜索了哪些路径、加载了哪些库,对理解运行时行为有奇效。
最后如果还有精力,去读一读《程序员的自我修养:链接、装载与库》这本书,虽然出版有些年头了,但核心机制没有本质变化。配合这几年的工具链更新(LLD、lld-link 的出现让链接速度提升了好几个数量级)一起看,效果会更好。
我个人强烈建议,不管你是不是主做底层开发的,都值得花时间把静态链接、动态链接、符号解析、重定位这四件事搞清楚。因为现代软件开发里,构建系统、CI/CD、性能优化、问题排查,处处都绕不开这些概念。你早一天搞懂,就早一天在日志里看到 undefined reference 时不再心里发慌。后续的文章里,我会接着展开目标文件的详细格式、符号解析的规则边界、以及静态库和动态库混用的深层问题,咱们下一篇见。