资讯动态

深入解析目标文件与可执行文件:从编译链接到ELF结构

发布时间:2026/10/26 20:11:37 来源:尧图企业网站定制
1. 从一行编译命令说起目标文件与可执行文件到底在干什么很多人第一次接触编译脑子里只有一个模糊的印象把.c或.cpp丢给编译器然后得到一个能双击运行的程序。中间发生了什么基本靠猜。直到某天链接报出一堆undefined reference或者程序在别的机器上一跑就崩才开始回头翻资料发现绕不开两个东西——目标文件和可执行文件。这篇内容就是把这个过程彻底拆开讲清楚。目标文件Object File是编译单个源文件后产出的中间产物可执行文件Executable是链接器把若干目标文件和库拼装后的最终成品。它们都属于同一种底层格式家族在主流平台上通常叫 ELFExecutable and Linkable FormatWindows 上是 PE/COFFmacOS 上是 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_DYNe_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 加 binutilsreadelf、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是UNDhelper也是UND而main是GLOBAL且Ndx指向.text段索引。这正好印证了前面的说法main.o提供main需要shared_counter和helper。再看readelf -r main.o重定位表里会有针对shared_counter和helper的条目。helper那条通常是R_X86_64_PLT32shared_counter那条是R_X86_64_PC32如果编译时加了-fPIE默认现代 GCC 都加。每条记录的Offset就是需要回填的文件内偏移。4.3 链接后的可执行文件对比对demo执行readelf -h demoType变成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段因为初始化为 42helper落在.text段。如果我把shared_counter改成不初始化它会落到.bssnm里显示为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 manglingC 代码引用时没加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限制作用域但每个文件一份副本注意内存开销C17 之后可以用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 -Sstrip或编译加-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的段范围就能判断它落在哪个段、权限是什么。这个组合我在分析崩溃和做内存布局审查时用得最多比任何图形化工具都直接。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑