
有次同事拉下代码make一路绿灯程序跑起来却卡在启动阶段日志只有一行symbol lookup error: ./app: undefined symbol。他第一反应是“代码明明编译过了怎么会找不到函数”。这个疑问正好戳中计算机程序的编译和链接这条链路上最容易被忽略的一段编译只负责把单个源文件翻译成机器码至于这些机器码之间怎么对上号、外部函数最终落在哪个地址、库以什么形态进入进程空间全是链接器的事。编译和链接看着只是构建脚本里的两个动词底层却是两套完全不同的模型一个是语言层面的翻译一个是二进制层面的拼接。这篇文章想干的事很具体把从.c到可执行文件这条路上的每一站都拆开看搞清目标文件里到底有什么、符号表怎么记录信息、重定位表在修正什么、静态库和动态库的取舍依据在哪、undefined reference和multiple definition这类高频报错怎么在五分钟内定位到根因。适合写 C/C 的、做嵌入式和系统软件的、被构建系统折磨过的读者也适合想搞明白“程序是怎么被装进内存跑起来”的人。基础弱一点没关系我会用生活化的类比把概念先讲顺再上命令和代码。1. 从源码到可执行文件这条链路到底有几步1.1 四个阶段的职责与产物很多人背得出“预处理、编译、汇编、链接”但未必清楚每一步到底在跟什么打交道。我用一张表把职责和产物对齐一下后面所有讨论都基于这张表展开。阶段主要动作输入输出典型命令预处理头文件展开、宏替换、条件编译、去注释.c/.h.i仍是文本gcc -E编译词法/语法/语义分析、优化、生成汇编.i.s汇编文本gcc -S汇编把汇编指令逐条翻译成机器指令生成目标文件.s.oELF 可重定位文件gcc -c/as链接符号解析、段合并、重定位、生成可执行映像多个.o 库可执行文件 /.sold/gcc关键在于边界前三步都以“单个翻译单元”为单位编译器压根不知道别的.c文件里有什么它碰到一个只声明没定义的函数只能留一个空位。这个空位的填法是链接阶段的事。理解这条边界后面九成的报错都能自己解释清楚。1.2 为什么非要把编译和链接拆成两步有人会问既然最后都是生成一个文件为什么不一次性做完。原因其实很实在。第一是增量构建。一个有八百个源文件的项目改一个文件只需要重新编译那一个再链接一次几秒钟搞定如果每次都全量翻译构建时间会长到没法用。第二是二进制分发。库的作者把代码编成.o或.a交出去使用者不需要源码也能链接这是整个软件复用的基础设施。第三是语言无关。汇编、C、C、Rust 编出来的目标文件遵循同一套格式约定链接器不认识语言只认识符号和段所以它们能被拼到一起。第四是职责分离带来的工具化。编译器可以专心做优化链接器专心做布局各自演进互不干扰。这里有个容易被忽略的推论既然编译是单文件视角那头文件里的内容就只能靠约定而不是强制。编译器无法验证你在头文件里声明的函数签名和另一个文件里实际的定义是否一致。这就是为什么会出现“编译通过、链接也通过、运行结果却是垃圾”的情况——参数类型对不上栈上的数据被按错误的宽度解读。这类问题链接器管不了它只认名字。1.3 用一条命令把四个阶段“暂停”下来新手最有效的一招是把中间产物全部留下来看一遍。GCC 提供了非常省事的开关# 一次性保留所有中间文件 gcc -save-temps main.c add.c -o app # 也可以逐段手动执行观察得更清楚 gcc -E main.c -o main.i # 预处理 gcc -S main.i -o main.s # 编译成汇编 gcc -c main.s -o main.o # 汇编成目标文件 gcc main.o add.o -o app # 链接跑完以后ls -l看一眼文件大小你会直观地发现main.i往往比main.c大几十倍——因为#include stdio.h展开就是几千行。而main.o反而很小因为注释、空行、变量名在机器码层面基本都不存在了。.s文件可以打开读虽然长但能看懂大概在干什么这对建立“编译器到底做了哪些变换”的直觉非常有帮助。注意手工分步时不要跳过-c直接gcc -S main.i -o app是行不通的-S只到汇编为止。另外.i文件已经展开过预处理再对它执行-E不会有新变化。2. 预处理与编译文本替换与语言翻译2.1 预处理器只做文本活但坑都在这儿预处理器的本质是一个文本处理器它对 C 语言的语法一无所知。它做的事就是把#include的内容原地贴进来、把宏按规则替换、把#if分支里不成立的部分删掉、把注释换成空格。所以你写的宏如果括号没加够它不会报错只会默默展开成错误表达式。举个我自己踩过的坑#define SQUARE(x) x * x int a SQUARE(1 2); // 展开成 1 2 * 1 2结果是 5不是 9正确写法是#define SQUARE(x) ((x) * (x))外层括号也不能省——2 * SQUARE(3)如果宏是(3)*(3)没问题但如果是x*x再加外层运算就完蛋。更隐蔽的是参数多次求值#define MAX(a, b) ((a) (b) ? (a) : (b)) int m MAX(i, j); // i 或 j 会被加两次涉及副作用时宏只能改成static inline函数让编译器按值语义处理。这个替换在 C99 之后完全没有性能损失编译期就能内联展开。#和##两个运算符也值得记一下#x把参数变成字符串字面量##把两个记号粘成一个。它们主要出现在日志宏、断言宏和自动生成函数名的场景里。还有一种常见写法是用do { ... } while(0)包裹多语句宏避免if (cond) MACRO(); else ...展开后语义错乱。这些都是老代码里的固定套路读别人的项目时会反复见到。2.2 编译器内部从语法树到指令选择编译器的工作可以粗略分成三段。前端负责把文本变成抽象语法树做类型检查、作用域解析生成中间表示IR。中端在 IR 上做优化比如常量折叠、死代码消除、循环不变量外提、函数内联。后端做指令选择、寄存器分配、指令调度最终吐出目标架构的汇编。分段的意义在于换一颗芯片只需要换后端前端和中端的优化逻辑全部复用。优化到底做了什么看个小例子最直观int scale(int a) { return a * 8; }-O0下生成的指令大约是mov取值、shl $3、返回。编译器把乘法换成了左移三位这是最基础的强度削弱。如果换成a * 7它会变成(a 3) - a比一条乘法指令在某些架构上更快。再复杂一点如果函数里有个循环每次都用同一个常量做判断-O2会把它提到循环外面甚至直接展开。这些变换在源码层面完全看不出来只有在汇编或反汇编里才能观察到。这里有个工程上的现实优化等级会影响可调试性。-O2下变量可能被完全优化进寄存器gdb里print一个变量会告诉你optimized out。所以线上出问题的版本最好保留一份带-g且优化等级较低的构建产物用于复现或者至少在-O2 -g的前提下接受调试信息的不完整。我一般建议开发阶段用-O0 -g压测和发布用-O2两套产物分开存放。2.3 目标文件里到底装了什么.o文件是链接的基本单位格式通常是 ELF。它不是一个“半成品程序”而是一组带元信息的段。核心的几个段.text机器指令。.data已初始化的全局变量和静态变量占用文件空间。.bss未初始化或初始化为 0的全局/静态变量不占文件空间只在加载时由系统清零。.rodata只读数据典型是字符串字面量和const全局量。.symtab/.strtab符号表和字符串表链接器靠它认人。.rela.text等重定位表告诉链接器“这个位置的地址还没填”。.comment/.debug_*编译器版本、调试信息可以裁掉。.bss不占文件空间这点很值得展开。你在代码里写static int buf[1 20];编译出来的.o文件大小几乎不变因为它只在.bss里记录“需要 4MB 空间、按零初始化”实际空间在程序加载时才分配。反过来static int buf[1 20] {1};就会让文件涨 4MB——因为它是“已初始化”的必须原样存进.data。这是个非常实际的优化点大数组如果只需要部分初始化可以考虑运行期memset。2.4 实操观察同一个变量在不同段里的位置写两段小代码编出来对比一下// bss_demo.c #include stdio.h int g_uninit; // .bss int g_zero 0; // 通常也进 .bss全零初始化 int g_init 42; // .data const char *msg hello; // 指针在 .datahello 在 .rodata static char big[1 20]; // .bss不占文件空间 int main(void) { printf(%d %d %s %zu\n, g_uninit, g_init, msg, sizeof(big)); return 0; }gcc -c bss_demo.c -o bss_demo.o size bss_demo.o # text / data / bss 三段的大小一目了然 readelf -S bss_demo.o # 看完整的段表 objdump -h bss_demo.o # 另一种视角size的输出会明确告诉你.bss有多大而ls -l bss_demo.o的大小只有几 KB。这个对比做一次.bss的概念就再也不会混淆了。想进一步验证可以用objdump -s -j .rodata bss_demo.o把只读段的内容 dump 出来能看到hello原样躺在里面。实操心得分析陌生库或崩溃现场时size是我最先敲的命令之一。如果.text异常大而.data也很大多半是有人在头文件里定义了带初始化的全局对象或者模板实例化失控了。这个信号比看编译日志快得多。3. 链接器做的两件事符号解析与重定位3.1 符号表链接器眼里的人名词典链接器不认识函数体它只认识符号。每个目标文件都贡献一张符号表每条记录至少包含名字、类型、绑定属性、所在段、大小。绑定属性分三类LOCALstatic修饰的只在本文件可见、GLOBAL普通全局符号可以跨文件引用、WEAK弱符号允许被强符号覆盖常见于默认实现的桩函数。用nm看最直白输出的第一个字母就是类型gcc -c add.c main.c nm main.o典型输出0000000000000000 T main 0000000000000000 D g U addT是.text段的全局符号有定义D是已初始化数据U是未定义——表示“我用到了这个名字但定义不在我这”。链接器的核心任务之一就是给每个U找到一个匹配的定义。找不到报undefined reference找到多个强定义报multiple definition。这里有个细节值得说C 的符号名会经过名字修饰manglingUtils::encode(std::string const)会变成类似_ZN5Utils6encodeERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE的东西。所以报错里出现一大串乱码不要慌用cfilt还原一下就行cfilt _ZN5Utils6encodeERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE # 输出Utils::encode(std::__cxx11::basic_stringchar, ... const)至于为什么 C 的函数在 C 里要加extern C根子也在这里C 不做名字修饰C 做链接器按名字匹配自然就匹配不上。这个坑在对接第三方 C 库时几乎人人都撞过一次。3.2 重定位为什么.o里的地址全是 0这是理解链接最关键的一步。编译器在生成指令时比如遇到call add它不知道add最终会落在哪个虚拟地址于是先填一个占位值通常是 0 或当前偏移同时往.rela.text里写一条记录“这里有个针对符号add的引用类型是 PC 相对调用请在链接时把它替换成真实地址”。用命令验证一下readelf -r main.o输出大致是Relocation section .rela.text at offset 0x1a8 contains 1 entry: Offset Info Type Sym. Value Sym. Name Addend 0000000000000015 000500000004 R_X86_64_PLT32 0000000000000000 add - 4这条记录的含义是在.text偏移0x15的位置有一个 32 位 PC 相对的调用引用目标是符号add附加值-4因为 x86 的相对跳转是相对“下一条指令”计算的要减掉指令本身长度。地址在.o里普遍从 0 开始是因为每个.o都假设自己独占整个地址空间从 0 开始编。等链接器把所有.o的段合并、分配好最终的虚拟地址再回头把每条重定位记录按新地址修正一遍文件才成为可执行形态。生活化一点说编译就像给每个部门写内部文档提到“找财务部小王”时只写职务不写工位号因为写的时候办公楼还没盖好链接器就是那个盖楼、分配工位、然后把每份文档里的“找小王”全部改成具体门牌号的人。这个类比里的“职务”就是符号名“门牌号”就是重定位后的地址。3.3 静态链接的完整动作序列把多个.o链成可执行文件链接器大致按这个顺序走收集输入把所有.o和静态库成员的目标文件读进来。合并段把所有.text拼成一个大.text.data拼成一个大.data.bss合并求总大小。符号解析建立全局符号表处理重复定义和未定义符号。分配地址按链接脚本规定的布局给每个段分配虚拟地址。重定位遍历所有重定位表把占位值替换成最终地址。生成程序头写出 ELF 头、程序头表告诉加载器“哪些段要映射到内存的哪个位置、用什么权限”。第 4 步的地址分配完全由链接脚本控制默认脚本可以ld --verbose看到。它决定了代码段从哪开始、数据段偏移多少、栈和堆在什么位置。做嵌入式的时候这段脚本通常是手写的因为要把代码放进 Flash、把变量放进 RAM、把某些段放到特定的内存区域。这也是为什么换个芯片型号经常要改链接脚本——内存布局不一样。3.4 实操反汇编看懂一次函数调用把前面两个文件编出来反汇编对比一下“重定位前”和“重定位后”的差别// add.c int add(int a, int b) { return a b; }// main.c extern int add(int, int); int g 42; int main(void) { return add(g, 1); }gcc -c add.c main.c objdump -d main.o # 链接前call 后面是 00 00 00 00 gcc main.o add.o -o app objdump -d app | grep -A4 main: # 链接后call 后面是真实相对偏移链接前的call指令机器码类似e8 00 00 00 00操作数全零那种“空洞感”非常直观。链接后变成e8 xx xx xx xx相对偏移算好了。这一步亲手做过一次以后看任何链接报错都不会觉得抽象。4. 静态库、动态库与库的链接顺序4.1.a的本质是一堆.o的压缩包静态库不是什么神秘格式它就是ar打包出来的一堆.ogcc -c math_util.c string_util.c ar rcs libutil.a math_util.o string_util.or插入、c创建、s生成索引。链接时写-L. -lutil链接器会在libutil.a里查找需要的成员。注意“按需”这两个字它只把真正被引用到的.o拉进来没被用到的成员完全不参与链接。这带来两个结果一个是产物精简另一个是——顺序敏感。4.2.so与位置无关代码动态库的编译方式和静态库有本质区别它必须支持加载到任意地址。做法是编译时加-fPIC生成位置无关代码所有对全局变量和外部函数的引用都走一张间接表而不是写死绝对地址。gcc -fPIC -c math_util.c -o math_util.o gcc -shared -Wl,-soname,libutil.so.1 math_util.o -o libutil.so.1.0具体机制上**GOT全局偏移表**存放变量的实际地址**PLT过程链接表**存放函数跳转桩。函数第一次被调用时桩代码会去解析真实地址并写回 GOT之后直接命中这就是所谓的延迟绑定。这套设计的收益是多个进程可以共享同一份物理内存里的库代码因为它们看到的代码是只读且位置无关的。代价也很明确每次跨库调用多一次间接跳转虽然现代 CPU 的分支预测基本能吃掉这个开销但在极端性能敏感的循环里仍然可以测出差异。这就是为什么对性能极其苛刻的场景会选择静态链接牺牲体积和共享性换确定性。4.3 库顺序为什么会崩这是新手最容易栽的地方。链接器处理-l参数是从左到右单遍扫描的读到一个库就把当前所有未解析的符号拿去和它匹配匹配上的拉进来扫描完这个库就继续往后走不会回头。所以如果libA用到了libB里的函数命令行必须写成-lA -lB。写成-lB -lA会怎样扫描libB时libA还没被读进来没人引用libB的符号于是它一个成员都不拉再扫描libA它引入了对libB的未定义符号但libB已经走过了链接器不会回头最终报一堆undefined reference。解决办法有三个# 方案一调整顺序依赖者在前 gcc main.o -lA -lB -o app # 方案二重复写适合简单场景多了很难看 gcc main.o -lA -lB -lA -o app # 方案三用 group 包住让链接器在里面反复扫描直到收敛 gcc main.o -Wl,--start-group -lA -lB -Wl,--end-group -o app方案三处理循环依赖最稳代价是链接时间会略微增加。如果你用的是构建系统很多会默认开启类似行为或者用--whole-archive把整个库强行拉进来。--whole-archive要慎用它会把库里所有成员都塞进去包括那些可能引起重复定义的符号一般只在导出插件注册表这类场景用。提示如果某个库必须被强制链接哪怕没直接引用现代工具链默认开了--as-needed会把它从依赖列表里剔除。这时可以加-Wl,--no-as-needed或者干脆写一个显式调用逼它被引用。4.4 打包分发时最容易漏的参数自己写的库要给别人用有几个参数不能省-Wl,-soname,libxxx.so.1指定运行时依赖名。升级小版本时只要soname不变老程序还能正常加载。-Wl,-rpath,$ORIGIN/../lib指定相对查找路径。$ORIGIN表示可执行文件自身所在目录这样整个包挪到别的机器上也能找到库比设置环境变量干净得多。-Wl,--no-undefined链接共享库时强制所有符号都能解析避免把问题拖到运行期。-Wl,-z,defs -Wl,-z,now前者与上一条类似后者关闭延迟绑定让符号问题在启动时就暴露。$ORIGIN那个单引号别漏容易被 shell 展开成空字符串。写成双引号也行但要转义$。这个小细节坑过不止一个人。5. 高频故障排查手册5.1undefined reference的六种典型成因这个报错几乎是 C/C 开发者的成人礼但成因可以归成有限的几类对照着查就行。现象成因验证方式处置只在某个函数上报错声明了但没实现或实现文件的.o没参与链接nm查目标文件里有没有该符号的T补上实现或加入编译列表一整片函数都报错库没链接上nm -C libxxx.a | grep 函数名补-L和-l明明加了-l还报错列表里有多个库顺序错了打乱顺序试验依赖者放前面或用--start-groupC 调 C 库报错名字修饰不一致nm -C看双方符号名C 头文件加extern C内联函数报错头文件里只有声明定义在.c里内联失败看编译选项把定义放进头文件并标inline模板或const常量报错模板定义不可见或取地址的常量缺定义检查头文件定义移入头文件排查顺序我一般是这么走的先nm确认符号到底在哪一侧缺失再确认库路径和顺序最后怀疑名字修饰。不要一上来就乱加库那通常会让问题变成“链接冲突”。# 查某个目标文件是否提供了某符号 nm -C add.o | grep T # 查某个静态库里的符号注意 -C 要带上否则 C 名看不懂 nm -C libutil.a | grep encode5.2multiple definition别急着删代码重复定义报错最常见的原因是头文件里定义了变量// bad.h int g_counter 0; // 每个包含它的 .c 都会定义一次在 GCC 10 之前C 语言有个“暂定定义”的老规则多个文件里的同名未初始化全局变量会被合并成一个所以很多老项目这么写也没炸。但GCC 10 开始默认开启了-fno-common行为变成严格报错。这就是为什么一些老代码在新编译器上一编译就炸一片。正确的做法是在头文件里写extern int g_counter;在某个.c里写int g_counter 0;。或者用 C17 的inline变量。实在没法改老代码可以临时加-fcommon兼容但这是技术债别当长期方案。第二种常见情况是函数定义在头文件里没加inline或static。C 里类内定义的成员函数自带内联属性但自由函数写在头文件里必须显式inline否则每个翻译单元都会生成一份强定义。C 语言里同理头文件里的辅助函数应该标static虽然会导致每个.o里都有一份副本但至少不会冲突。第三种是库重复链接尤其是--whole-archive加上同时链接了某个库的静态版和动态版。用ldd或readelf -d看依赖列表能发现。实操心得遇到multiple definition先看报错里两个定义分别来自哪个文件。如果是“某个头文件和某个.o”几乎必然是头文件里放了定义。如果是“两个.o”那可能是两个功能相近的模块都实现了同一个辅助函数这种情况我一般会把它下沉到一个公共模块里而不是简单改名——改名只是把重复藏起来。5.3 ABI 与版本问题最隐蔽的一类故障有时候链接完全干净程序一启动就报symbol lookup error或者运行时挂掉问题出在 ABI 层面。最著名的是_GLIBCXX_USE_CXX11_ABI。GCC 5 起std::string换了实现新旧 ABI 不兼容。你的代码用新 ABI 编译依赖的第三方预编译库用老 ABI两边的函数签名修饰名对不上链接时就会报找不到。检查方法是用nm看符号里有没有__cxx11nm -C libthird.a | grep basic_string有st11char_traitsIcESaIcEE里的cxx11就是新 ABI。解决方式是统一编译选项或者让第三方库重新出包。别想着混着用std::string的内存布局完全不一样就算链接过了也会崩。另一类是 glibc 版本。在较新系统上编译的二进制拿到老系统上跑会报GLIBC_2.34 not found。查一下依赖objdump -T app | grep GLIBC_如果最高版本高于目标环境要么在低版本环境里编译要么把依赖静态链接进去。后者的代价是某些功能比如 NSS 相关的解析在静态链接下行为会变需要额外验证。5.4 排查工具箱速查工具用来看什么典型命令file文件类型、架构、是否动态链接file appnm符号表定义/未定义/弱符号nm -C --defined-only lib.aobjdump -d反汇编看实际指令objdump -d app | lessreadelf -S/-r/-d段表 / 重定位表 / 动态依赖readelf -d appsize各段大小粗判体积来源size -A appldd运行时依赖解析结果ldd ./appcfilt还原修饰后的 C 名cfilt 符号名LD_DEBUG动态加载全过程细节LD_DEBUGlibs,symbols ./appLD_DEBUG这个尤其值得记住。LD_DEBUGlibs会打印库的搜索路径和实际命中位置LD_DEBUGsymbols,bindings会打印符号绑定过程排查“为什么加载了错误的库版本”这类问题一打一个准。它的输出很长配合grep用。6. 把构建写进工程Makefile 与依赖管理6.1 手写 Makefile 最容易错的两处手工写构建脚本时最典型的错误是漏掉头文件依赖。改了.hmake认为.c没变跳过重新编译结果链出来的还是旧逻辑调试时百思不得其解。第二种是模式规则里用错自动变量。$是目标$是第一个依赖$^是全部依赖$*是模式匹配部分。写成gcc -c $ -o $会把方向搞反编出来的东西莫名其妙。一个最小但正确的手写骨架长这样CC : gcc CFLAGS : -O2 -g -Wall -Wextra -MMD -MP SRCS : $(wildcard src/*.c) OBJS : $(SRCS:.c.o) TARGET : app $(TARGET): $(OBJS) $(CC) $^ -L./lib -lutil -Wl,-rpath,$$ORIGIN/lib -o $ %.o: %.c $(CC) $(CFLAGS) -c $ -o $ -include $(OBJS:.o.d) clean: rm -f $(OBJS) $(OBJS:.o.d) $(TARGET)6.2 增量编译-MMD -MP是怎么起效的-MMD让编译器在编译的同时生成一个.d文件内容是这条规则的所有头文件依赖。-MP额外为每个头文件生成一个空目标防止某个头文件被删掉后make报“没有规则可以创建目标”而中断。最后用-include把这些.d引进来make就自动知道“这个.o依赖哪几个.h”。这套机制的价值在于依赖是编译器自己算的不需要你手工维护也不需要额外跑工具扫描。它对#include的嵌套、条件编译都处理得很准。CMake 这类工具内部也是同样的思路。6.3 几个实测下来值得固定的习惯第一把编译和链接分开看日志。链接失败就别再翻编译警告了方向错了。第二给链接命令加-Wl,--trace-symbol符号名它会在链接时打印该符号从哪个文件被解析处理疑难符号冲突特别快。第三定期用readelf -d检查产物的依赖列表尤其是引入新库之后确认没有意外多出来的间接依赖。第四在 CI 里跑一次-Wl,--no-undefined的链接把运行期的符号缺失提前到构建期。还有一个我吃过亏的点交叉编译时-I和-L的顺序很重要。头文件和库的搜索路径是按顺序找的如果系统路径排在前面很可能编出来的是主机架构的库链接时报“文件格式不匹配”。用-v或--sysroot明确指定根目录比自己拼路径可靠得多。我个人在实际操作中的体会是编译链接这类问题之所以让人觉得玄学是因为它横跨了源码、汇编、二进制格式、加载器四层报错信息又往往只暴露最表层的现象。真正管用的方法不是背报错而是建立一条“从报错反推到具体层”的路径看到符号名就先nm定位是缺定义还是名字对不上看到加载失败就先readelf -d看依赖看到地址异常就先确认链接脚本和段布局。把这条路径走顺了绝大多数构建问题都能在几分钟内收敛到根因不用靠重启和重装。