1. 从一行编译命令说起:目标文件与可执行文件到底在干什么
很多人第一次接触编译,脑子里只有一个模糊的印象:把.c或.cpp丢给编译器,然后得到一个能双击运行的程序。中间发生了什么,基本靠猜。直到某天链接报出一堆undefined reference,或者程序在别的机器上一跑就崩,才开始回头翻资料,发现绕不开两个东西——目标文件和可执行文件。
这篇内容就是把这个过程彻底拆开讲清楚。目标文件(Object File)是编译单个源文件后产出的中间产物,可执行文件(Executable)是链接器把若干目标文件和库拼装后的最终成品。它们都属于同一种底层格式家族,在主流平台上通常叫 ELF(Executable and Linkable Format),Windows 上是 PE/COFF,macOS 上是 Mach-O。理解它们的结构,能让你在排查链接错误、分析程序体积、定位运行时崩溃、做二进制逆向或安全分析时,不再靠玄学。
适合谁看:写过 C/C++ 但没系统学过编译链接流程的开发者;被ld报错折磨过、想搞明白符号表的人;做嵌入式、需要控制内存布局的工程师;以及任何想从“会写代码”进阶到“知道代码怎么变成机器指令”的人。下面我会从整体设计思路讲起,再逐层拆解文件结构、实操验证方法、常见坑和排查技巧,尽量做到你照着敲命令就能复现。
2. 整体设计思路:为什么编译要分“目标”和“可执行”两步
2.1 分而治之:单文件编译与多文件链接的必然性
先想一个问题:如果一个项目有 500 个源文件,编译器能不能一次性把它们全吃进去直接吐出一个可执行文件?技术上可以,但工程上极其糟糕。原因很直接——改一行代码,整个项目重编一遍,哪怕只动了一个文件。分两步走的核心价值就是增量编译:每个源文件独立编译成目标文件,只要源文件没变,目标文件就复用;只有链接这一步需要把所有目标文件重新拼一次,而链接通常比编译快得多。
这就像盖房子。目标文件是预制好的墙板、门窗、管线,每个工人在自己的工位上独立加工;链接器是总装工人,负责把这些预制件按图纸拼成整栋楼。墙板做错了,只重做那块墙板,不用把整栋楼推倒。这个类比背后对应的是编译单元(Translation Unit)的概念——一个.c文件加上它#include进来的所有头文件,构成一个独立的编译单元,编译器对它负责到底,产出唯一一个目标文件。
2.2 目标文件里到底装了什么:段、符号、重定位
目标文件不是一堆乱码,它有严格的结构。最核心的三块是段(Section)、符号表(Symbol Table)和重定位表(Relocation Table)。
段是内容的容器,按用途分类存放。代码放.text,已初始化的全局变量和静态变量放.data,未初始化或初始化为零的全局变量放.bss,只读常量放.rodata。为什么要分这么细?因为不同段有不同的访问权限和生命周期。.text是只读可执行的,.rodata是只读的,.data可读可写。操作系统加载程序时按段设置内存页权限,代码段被标记为不可写,能有效防止程序意外改写自己的指令,这也是很多安全机制的基础。
符号表记录了这个目标文件对外“提供”了什么(定义的函数和全局变量)以及“需要”什么(引用了但没定义的符号)。重定位表则记录了“哪些位置现在填的是占位符,等链接时再改成真实地址”。这三者配合,才让分离编译成为可能。
2.3 为什么不能直接生成可执行文件:地址未定的困境
单个源文件编译时,编译器根本不知道其他文件里有什么,更不知道最终程序会被加载到内存的哪个地址。所以它只能做“相对保守”的处理:函数调用先留个坑,全局变量访问先写个偏移,等链接器来填。这就是重定位的本质。
举个具体例子。你在a.c里调用b.c中定义的foo(),编译a.c时编译器看不到foo的实现,它只能在.text段对应位置生成一条call指令,目标地址填 0,同时在重定位表里记一笔:“此处偏移 0x2a 的 4 字节,需要填入符号foo的最终地址”。链接器扫描所有目标文件,找到foo在b.o的.text段里,算出最终地址,回填进去。整个过程就是“先占位、后回填”。
3. 核心结构逐层拆解:ELF 文件长什么样
3.1 文件头与段表:读懂二进制的第一把钥匙
ELF 文件开头是 ELF Header,固定 52 字节(32 位)或 64 字节(64 位)。里面最关键的几个字段:e_ident标识文件类型和字节序,e_type区分这是可重定位文件(ET_REL,即目标文件)、可执行文件(ET_EXEC)还是共享库(ET_DYN),e_entry是程序入口地址,e_shoff指向段表(Section Header Table)的偏移。
段表是一个数组,每个元素描述一个段的名字、类型、大小、在文件中的偏移、加载到内存后的对齐要求等。用readelf -S就能把这张表打出来。我实测一个简单的hello.o,能看到.text、.data、.bss、.rodata、.symtab、.strtab、.rela.text等十几个段。新手容易忽略的是.symtab和.strtab——符号表本身也是段,字符串表存符号名,符号表存符号的属性。这两个段在最终可执行文件里通常会被 strip 掉,因为运行时不需要。
3.2 符号表与符号绑定:全局、局部、弱符号的区别
符号表里每个条目有个st_info字段,高 4 位是绑定类型(Binding),低 4 位是类型(Type)。绑定类型分三种:STB_LOCAL(局部符号,如static函数和变量,只在文件内可见)、STB_GLOBAL(全局符号,可被其他文件引用)、STB_WEAK(弱符号,允许被强符号覆盖)。
这个区分非常关键。链接器处理符号冲突的规则是:多个强符号同名直接报错multiple definition;强符号和弱符号同名,强符号胜出;多个弱符号同名,随便挑一个(通常是先遇到的)。C++ 里模板实例化、内联函数产生的重复符号,很多就是靠弱符号机制解决的。你在头文件里定义一个inline函数,多个编译单元都包含它,每个目标文件都会生成一份该函数的弱符号副本,链接器只保留一份,不会报重复定义。
用readelf -s hello.o能看到符号表全貌。注意Ndx列,它表示符号定义在哪个段。UND表示未定义(需要外部提供),ABS表示绝对地址(如常量),数字表示段索引。排查undefined reference时,第一步就是看报错符号在哪个目标文件里是UND,然后确认提供它的目标文件有没有被加进链接命令。
3.3 重定位表:链接器回填地址的施工图
重定位表(.rela.text、.rela.data等)每条记录包含:需要修改的位置(r_offset)、重定位类型(r_info的低位)、引用的符号索引(r_info的高位)、加数(r_addend)。重定位类型决定了回填时怎么算,常见的有R_X86_64_PC32(相对当前指令的 32 位偏移,用于函数调用和近距离数据访问)、R_X86_64_32(绝对 32 位地址)、R_X86_64_PLT32(通过 PLT 跳转,用于调用共享库函数)。
为什么函数调用多用 PC 相对寻址?因为现代程序普遍启用地址随机化(ASLR),代码段每次加载的基地址都不同,绝对地址在编译期根本定不下来。PC 相对偏移只依赖“调用点和目标点的距离”,这个距离在同一个模块内是固定的,不受加载基址影响。这也是位置无关代码(PIC)的核心思想,共享库必须用 PIC 编译,否则无法被多个进程在不同地址映射。
3.4 可执行文件多了什么:程序头表与加载视图
目标文件只有段表(Section Header Table),可执行文件还多了程序头表(Program Header Table)。段表是给链接器看的“链接视图”,程序头表是给操作系统加载器看的“执行视图”。程序头表把若干段合并成“段(Segment)”,每个段描述一段需要映射到内存的区域:偏移、虚拟地址、文件大小、内存大小、权限(读/写/执行)。
典型可执行文件有这几个段:LOAD段(可执行代码,权限 R-X)、LOAD段(可读写数据,权限 RW-)、INTERP段(指定动态链接器路径)、DYNAMIC段(动态链接信息)、NOTE段(版本和构建信息)。用readelf -l查看。注意.bss段在文件里不占空间(因为全是零),但程序头表里MemSiz会大于FileSiz,加载器会把多出来的部分清零。这就是为什么大数组定义成全局未初始化变量不会撑大可执行文件,但运行时会占内存。
4. 实操过程:从源码到可执行文件全程验证
4.1 环境与示例代码准备
我用一台常见的 x86_64 Linux 环境演示,工具链是 GCC 加 binutils(readelf、objdump、nm、objcopy都在里面)。准备两个文件:
// main.c #include <stdio.h> extern int shared_counter; extern void helper(void); int main(void) { printf("counter=%d\n", shared_counter); helper(); return 0; }// helper.c #include <stdio.h> int shared_counter = 42; void helper(void) { printf("helper called\n"); }先只编译不链接:gcc -c main.c helper.c,得到main.o和helper.o。这两个就是目标文件。然后链接:gcc main.o helper.o -o demo,得到可执行文件demo。
4.2 用 readelf 解剖目标文件
对main.o执行readelf -h main.o,重点看Type: REL (Relocatable file),确认它是可重定位文件。再readelf -S main.o看段表,你会看到.text大小很小(就一个 main 函数),.rela.text存在(因为有外部调用),.symtab里shared_counter和helper都是UND。
readelf -s main.o的输出里,shared_counter的Ndx是UND,helper也是UND,而main是GLOBAL且Ndx指向.text段索引。这正好印证了前面的说法:main.o提供main,需要shared_counter和helper。
再看readelf -r main.o,重定位表里会有针对shared_counter和helper的条目。helper那条通常是R_X86_64_PLT32,shared_counter那条是R_X86_64_PC32(如果编译时加了-fPIE,默认现代 GCC 都加)。每条记录的Offset就是需要回填的文件内偏移。
4.3 链接后的可执行文件对比
对demo执行readelf -h demo,Type变成DYN(现代 GCC 默认生成 PIE 可执行文件,本质是位置无关的可执行体)或EXEC(加了-no-pie)。readelf -l demo能看到LOAD段和INTERP段。readelf -s demo里shared_counter和helper不再是UND,而是有了具体段索引和地址。
用objdump -d demo反汇编,找到main函数,能看到call指令后面跟的不再是 0,而是算好的偏移。用nm demo看符号地址,shared_counter落在.data段(因为初始化为 42),helper落在.text段。如果我把shared_counter改成不初始化,它会落到.bss,nm里显示为B类型。
4.4 参数选择:优化级别与调试信息对文件的影响
编译时加-O2,目标文件的.text会变大还是变小?不一定。优化会内联小函数、消除死代码,通常让代码变小,但循环展开、向量化又会让代码变大。实测这个例子加-O2后main.o的.text从几十字节涨到一百多字节,因为printf调用被优化了参数传递方式。加-g会引入.debug_*系列段,目标文件体积可能翻几倍,但这些段在最终发布时可以用strip去掉。
strip demo之后再用readelf -s看,.symtab和.strtab没了,只剩.dynsym(动态符号表,运行时动态链接必需)。这就是为什么发布版程序体积小、也看不到函数名——符号信息被剥掉了。但注意,strip不影响程序运行,因为运行时靠的是动态符号表和重定位信息,不是静态符号表。
5. 常见问题与排查技巧实录
5.1 undefined reference 的三类根因与定位路径
这是链接阶段最高频的报错。根因无非三类:符号根本没定义(忘了写实现,或拼写不一致)、定义在某个目标文件里但没参与链接(编译命令漏了那个.o或库)、C/C++ 混编的名字修饰不匹配(C++ 编译器对函数名做了 name mangling,C 代码引用时没加extern "C")。
定位方法:先用nm -C 目标文件 | grep 符号名确认符号在哪个文件里是T(已定义)或U(未定义)。如果所有文件里都是U,说明真没定义;如果某个文件里是T但链接还报错,检查那个文件是否在链接命令里;如果是 C++ 场景,用nm看符号名是否带了一长串编码,带编码说明是 C++ 修饰名,C 侧引用需要extern "C"包裹声明。
5.2 multiple definition 与弱符号的取舍
多个目标文件定义了同名全局变量或非内联函数,链接器直接报multiple definition。常见于头文件里直接定义变量(而不是extern声明)。解决办法:头文件里只放extern声明,定义放到唯一一个.c文件里;或者用static限制作用域(但每个文件一份副本,注意内存开销);C++17 之后可以用inline变量。
弱符号是另一条路。用__attribute__((weak))修饰的定义,允许被强符号覆盖。这在做可替换的默认实现时很有用,比如库提供一个弱符号的默认回调,用户程序定义强符号就能覆盖。但弱符号要慎用,它会让“符号到底用了哪份实现”变得不直观,排查问题时容易懵。
5.3 段错误与文件结构的关系:从 core dump 反推
程序运行时崩了,报Segmentation fault,很多人只会加打印。其实结合文件结构能更快定位。如果崩溃地址落在代码段范围却报“写入错误”,说明程序试图改写只读的.text,通常是函数指针被踩或栈溢出覆盖了返回地址。如果崩溃在.bss附近,可能是未初始化指针被解引用。
用readelf -l拿到各段的虚拟地址范围,再对照 core dump 里的崩溃地址,能快速判断是哪个段出的问题。我踩过的一个坑:某次程序在嵌入式板子上跑,.data段初始化失败,原因是链接脚本里.data的加载地址(LMA)和运行地址(VMA)没配对,启动代码没把.data从 Flash 搬到 RAM。这种问题在 PC 上几乎遇不到,但在裸机环境是家常便饭。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查命令 | 解决方向 |
|---|---|---|---|
| undefined reference | 符号未定义或未参与链接 | nm -C *.o | grep 符号 | 补实现或补链接目标 |
| multiple definition | 头文件里定义了变量/函数 | nm *.o | grep 符号 | 改 extern 声明或加 static |
| 可执行文件过大 | 调试信息未剥离 | size demo、readelf -S | strip或编译加-s |
| 运行时报找不到符号 | 动态库未加载或版本不匹配 | ldd demo、readelf -d | 检查库路径和 SONAME |
| .bss 段异常大 | 全局大数组未初始化 | size demo看 bss 列 | 改动态分配或确认是否必要 |
| 崩溃地址在只读段 | 写代码段或常量区 | readelf -l对照地址 | 检查指针越界和函数指针 |
6. 进阶视角:链接脚本、位置无关与二进制体积控制
6.1 链接脚本如何决定段的最终布局
PC 上链接器用默认脚本,你基本不用管。但嵌入式或需要精细控制内存布局时,链接脚本(linker script)就是核心。它用SECTIONS命令描述每个段放到哪个地址、对齐多少、LMA 和 VMA 怎么设。比如把中断向量表固定在 Flash 起始地址,把频繁访问的变量放到速度更快的 RAM 区,把只读常量留在 Flash 省 RAM。
理解链接脚本的前提就是理解目标文件的段结构。脚本里*(.text)的意思是“把所有输入文件的.text段收集到这里”。如果你在 C 代码里用__attribute__((section(".mysec")))把变量放到自定义段,链接脚本里就得显式处理.mysec,否则它可能被丢弃或放到意外位置。这是嵌入式开发里非常常见的坑。
6.2 位置无关代码对目标文件的要求
共享库必须用-fPIC编译,生成的目标文件里全局变量访问走 GOT(全局偏移表),函数调用走 PLT(过程链接表)。GOT 和 PLT 本质是“地址的地址”,运行时动态链接器把真实地址填进 GOT,代码通过 GOT 间接访问,从而不受加载基址影响。
用readelf -r看 PIC 目标文件,会看到大量R_X86_64_GOTPCREL、R_X86_64_PLT32类型的重定位。如果误用非 PIC 的目标文件去生成共享库,链接时会报relocation R_X86_64_32 against ... can not be used when making a shared object; recompile with -fPIC。这个报错很明确,照着加-fPIC重编即可。但要注意,-fPIC会让代码略微变慢(多一次间接寻址),所以可执行文件通常用-fPIE(更轻量的位置无关),只有共享库才必须-fPIC。
6.3 控制二进制体积的几个实操手段
发布版本体积膨胀,先看size demo输出的 text、data、bss 三列。text 大通常是模板膨胀或内联过度,可以调低优化级别、关闭部分内联;data 大是初始化数据太多,考虑把大常量表移到外部文件按需加载;bss 大是全局数组太多,改动态分配。
编译选项上,-Os优先体积,-ffunction-sections -fdata-sections配合链接器--gc-sections能删掉未引用的函数和数据段,效果立竿见影。strip --strip-unneeded去掉不需要的符号。如果用了 C++,注意异常和 RTTI 会带来额外体积,不需要时用-fno-exceptions -fno-rtti关掉。这些手段组合下来,一个中等项目体积砍掉三到五成很常见。
7. 我个人的几条实操心得
第一条,遇到链接问题先别急着改代码,先nm和readelf把符号和段看清楚。九成的链接错误,答案就在符号表的U和T里,比盲目搜索快得多。
第二条,理解.bss不占文件空间这件事,能帮你解释很多“为什么可执行文件才几十 KB,跑起来却占几百 MB 内存”的疑惑。全局大数组、静态缓冲区都是.bss大户,嵌入式环境 RAM 紧张时,优先审查这些。
第三条,objdump -d反汇编配合源码行号(编译加-g)是定位“编译器到底把我的代码优化成什么样了”的利器。有次一个循环被优化没了,我以为是编译器 bug,反汇编一看是自己写的条件在编译期就能确定恒假,死代码被消除了。这种问题看源码永远看不出来。
第四条,跨平台交付时,目标文件格式不通用。Linux 的.o拿到 Windows 上用不了,因为 ELF 和 PE 是两套格式。交叉编译时工具链前缀(如arm-linux-gnueabihf-gcc)决定了产出哪种格式的目标文件,链接时也必须用对应平台的链接器,混用会报格式不识别。这个坑在搭建交叉编译环境时特别容易踩。
最后分享一个排查小技巧:当你不确定某个符号最终被链接到了哪个地址,用nm -n demo | grep 符号名,-n按地址排序,能直接看到符号在内存布局中的相对位置,配合readelf -l的段范围,就能判断它落在哪个段、权限是什么。这个组合我在分析崩溃和做内存布局审查时用得最多,比任何图形化工具都直接。