编译与链接全解析:从源码到可执行文件的完整流程
发布时间:2026/10/5 8:31:27 作者:尧图编辑部 阅读量:1,286

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文件里的符号会发现大量符号标记为Uundefined未定义这些就是需要链接器去别处找的。2.4 链接把碎片拼成完整的可执行镜像链接阶段由链接器如ld、lld、gold执行它把多个目标文件和静态库合并成一个可执行文件或动态库。这个阶段做的事情包括符号解析把每个目标文件里的未定义符号和别的目标文件里定义的符号对上号重定位把所有引用符号的地方填上符号最终的真实地址合并节区把不同目标文件里的.text节、.data节等合并到一起形成最终可执行文件的段落布局生成最终镜像的头部信息包括入口点、段表等。我经常跟人打比方编译阶段是”写作文”每个.c文件相当于把自己那一段话写好了但里面的错别字和引用的名言警句外部函数都没有核查具体出处链接阶段就是”校对和装订“把你所有写好的段落收集起来统一编号、核对引用、调整格式最后装订成一本完整的书。3. 静态链接把一切都焊死在可执行文件里3.1 目标文件里装的是什么东西要理解链接得先看清目标文件.o文件的内部结构。Linux 下用的是 ELFExecutable and Linkable Format格式Windows 下用 PE/COFFmacOS 用 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 发行版的更新机制里库的更新优先级特别高。动态链接的核心思想是把公共的代码抽离成独立的.soLinux或.dllWindows文件程序只记录对库的依赖关系运行时由动态链接器如ld-linux.so负责把库加载到内存里。4.2 地址无关代码PIC动态库的生存前提动态库有个特殊要求它加载到内存的地址不是固定的。同一个.so可能被共享给多个进程每个进程的地址空间布局不一样库被 mmap 到各个进程的虚拟地址可能位置完全不同。如果库里的代码引用了全局变量或函数难道每次加载都要重新修改指令里的地址吗那做不到因为共享库的代码段是只读的进程之间要共享物理内存改了就相当于每个进程一份副本共享的意义就没了。解决方案就是 PICPosition Independent Code位置无关代码。编译器在生成动态库的代码时开启-fPIC选项所有对全局符号的访问都通过 GOTGlobal 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_DEBUGlibs ./app的输出这个环境变量能让你看到动态链接器实际搜索了哪些路径、加载了哪些库对理解运行时行为有奇效。最后如果还有精力去读一读《程序员的自我修养链接、装载与库》这本书虽然出版有些年头了但核心机制没有本质变化。配合这几年的工具链更新LLD、lld-link 的出现让链接速度提升了好几个数量级一起看效果会更好。我个人强烈建议不管你是不是主做底层开发的都值得花时间把静态链接、动态链接、符号解析、重定位这四件事搞清楚。因为现代软件开发里构建系统、CI/CD、性能优化、问题排查处处都绕不开这些概念。你早一天搞懂就早一天在日志里看到 undefined reference 时不再心里发慌。后续的文章里我会接着展开目标文件的详细格式、符号解析的规则边界、以及静态库和动态库混用的深层问题咱们下一篇见。