2026/10/26 6:43:49

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

深入理解目标文件与可执行文件:从编译链接到ELF结构 1. 从一行编译命令说起目标文件到底藏在哪很多人写C或者C写了很久天天敲gcc main.c -o main编译通过、程序跑起来任务就算完成了。可一旦链接报错比如undefined reference to xxx或者程序运行时莫名其妙崩溃就立刻抓瞎——因为平时根本没关注过编译器在背后到底生成了什么。我见过不少同学第一次看到main.o这个文件时一脸茫然不知道它是干什么的更不知道它和最终那个可执行文件之间是什么关系。这篇内容就专门把目标文件和可执行文件这两样东西掰开揉碎讲清楚。它们不是抽象概念而是实实在在躺在你磁盘上的二进制文件有自己的内部结构、有自己的字段布局理解了它们你才能明白链接器在做什么、符号表是什么、为什么有的函数能被外部调用而有的不行、为什么全局变量和局部变量的存储位置完全不同。适合已经会写基本C程序、但对编译链接过程只有模糊印象的读者也适合想补一补底层知识的老手。我个人的经验是编译报错好查链接报错难查运行时崩溃最难查。而这三类问题的根源几乎都能在目标文件和可执行文件的内部结构里找到答案。所以别急着背命令先把这两个文件看透。2. 编译流水线的四个站点预处理、编译、汇编、链接要理解目标文件从哪来得先知道编译过程被切成了几段。很多人以为gcc是一个编译器其实它是个驱动程序背后依次调用了预处理器、编译器、汇编器和链接器四个工具。每一站产出的东西都不一样搞清楚每一站的输入输出是理解目标文件的前提。2.1 预处理阶段文本层面的替换游戏预处理阶段处理的是以#开头的行。#include把被包含文件的内容原样插进来#define做文本替换#ifdef做条件裁剪。这一阶段完全不涉及语法分析纯粹是文本操作输出仍然是一个.i文件C语言或.ii文件C本质还是可读的源代码只是所有宏都展开了、所有头文件都拼进来了。你可以用这条命令单独停在预处理阶段gcc -E main.c -o main.i打开main.i你会发现它可能有好几万行因为stdio.h这类头文件展开后非常庞大。这一步的产物对理解目标文件帮助不大但有个实际用途当你怀疑某个宏展开出了问题或者头文件包含顺序导致重复定义时看.i文件是最直接的排查手段。我调试过一个宏拼接的诡异问题最后就是在.i文件里发现展开结果和预期完全不一致。2.2 编译阶段从高级语言到汇编编译阶段把预处理后的.i文件翻译成汇编代码.s。这一步才是真正意义上的编译——词法分析、语法分析、语义分析、中间代码生成、优化、目标代码生成全在这一站完成。编译器在这一步会做大量优化比如常量折叠、死代码消除、循环展开。gcc -S main.i -o main.s生成的.s文件是汇编语言人类还能看懂。这里有个值得注意的点编译器在这一步就已经决定了每个函数的调用约定、每个变量的存储类别。比如一个static函数编译器在生成汇编时就知道它不需要暴露给外部这个信息会一路传递到目标文件的符号表里。所以很多为什么这个符号链接不到的问题根子在编译阶段就已经埋下了。2.3 汇编阶段汇编代码变成机器码汇编器把.s文件翻译成机器指令产出可重定位目标文件也就是.o文件Windows上是.obj。这个文件里已经是二进制机器码了但还不能直接执行因为里面有很多待定的地址——比如调用了外部函数那个函数的地址此时还不知道得留个坑等链接器来填。gcc -c main.s -o main.o注意这里用的是-c参数它的含义是编译并汇编但不链接。这是生成目标文件最常用的方式。.o文件就是本篇的主角之一后面会详细拆它的内部结构。2.4 链接阶段把散件拼成整机链接器把一个或多个.o文件、以及它们依赖的库文件.a静态库或.so动态库合并解析所有符号引用重定位地址最终产出可执行文件。这一步是很多人知识盲区最深的地方因为链接器的工作几乎完全不可见出错了报错信息又往往很晦涩。gcc main.o utils.o -o app链接器要干三件核心的事符号解析把每个符号引用和符号定义对上号、重定位把待定地址填成最终地址、合并段把多个目标文件的同类段拼在一起。这三件事每一件出问题都会导致链接失败而报错信息通常只告诉你某个符号未定义或重定义不会告诉你为什么。提示日常开发中养成用-c分步编译的习惯出问题时能快速定位是编译阶段还是链接阶段的问题。一步到位的gcc main.c -o app虽然方便但把四个阶段糊在一起排查成本反而更高。3. 目标文件的内部构造段、符号表与重定位表.o文件不是一团乱码它有严格的结构。理解这个结构你就能回答很多平时想不通的问题为什么全局变量和局部变量放的地方不一样为什么未初始化的全局变量不占文件空间为什么static函数不会出现在符号表里3.1 ELF格式与段的划分Linux下目标文件和可执行文件都采用ELF格式Executable and Linkable Format。ELF文件由文件头、若干段section和段头表组成。文件头记录了机器类型、入口地址、段头表位置等元信息段头表描述了每个段的名称、类型、大小、偏移。对理解链接最重要的几个段如下表段名内容是否占文件空间是否可写.text机器指令是否只读可执行.data已初始化的全局/静态变量是是.bss未初始化的全局/静态变量否只记录大小是.rodata只读数据字符串常量等是否.symtab符号表是否.rel.text代码段的重定位信息是否这里有个特别容易被忽略的点.bss段在目标文件里不占实际空间它只在段头表里记录需要多大。因为未初始化的全局变量默认就是0没必要在文件里存一堆0浪费空间加载时操作系统直接分配一块清零的内存就行。这个设计很巧妙但也导致一个常见误解——有人以为int arr[1000000];这样的全局数组会让可执行文件变大其实不会它只影响运行时的内存占用。你可以用size命令直观看到各段大小size main.o输出会列出text、data、bss三列。我经常用这个命令快速判断一个目标文件是不是异常膨胀——如果text段特别大多半是模板实例化或者内联展开过度如果data段特别大可能是某个大数组被初始化了。3.2 符号表链接器的通讯录符号表.symtab是目标文件里最关键的元数据之一。它记录了本文件定义了什么符号、引用了什么符号。每个符号表项包含符号名、符号值通常是段内偏移、符号大小、符号类型函数/对象/文件、绑定属性全局/局部/弱符号等信息。用nm命令可以查看符号表nm main.o输出里每个符号前面有个字母含义如下T/t位于.text段大写表示全局小写表示局部staticD/d位于.data段已初始化的全局/静态变量B/b位于.bss段未初始化的全局/静态变量U未定义本文件引用了但没定义等着链接器去别处找R/r位于.rodata段只读数据这张表能解释很多现象。比如你写了一个static void helper()用nm看它是小写t说明它是局部符号链接器不会把它暴露给其他目标文件所以别的文件里就算声明了同名函数也链接不到它——这正是static的本意。再比如你看到某个符号是U说明这个文件依赖它如果所有目标文件里都找不到它的定义链接就会报undefined reference。注意符号表在最终的可执行文件里通常会被剥离用strip命令因为运行时不需要它。但调试版本会保留这也是为什么带调试信息的可执行文件体积大很多。3.3 重定位表留给链接器的填空题目标文件里的机器指令中凡是涉及地址的地方在汇编阶段都无法确定最终值。比如call printf这条指令printf在哪个地址汇编器不知道因为printf在标准库里链接时才确定。于是汇编器在指令里填一个占位值同时在重定位表.rel.text或.rela.text里记一笔这条指令的第X字节处需要填入printf的地址。链接器的工作之一就是遍历所有重定位表逐条填空。填的规则取决于重定位类型有的是填绝对地址有的是填相对当前指令的偏移PC相对寻址。x86-64下常见的重定位类型有R_X86_64_PC32相对寻址、R_X86_64_PLT32过程链接表跳转等。用readelf -r main.o可以查看重定位表readelf -r main.o输出会列出每个需要重定位的位置、类型和对应的符号。这个信息在排查为什么链接后地址不对这类问题时非常有用。我遇到过一次诡异的问题某个函数指针数组初始化后调用崩溃最后用readelf -r发现是重定位类型不匹配导致的地址计算错误虽然这种情况很少见但知道有这张表存在排查时就有了方向。4. 可执行文件目标文件拼装后的最终形态可执行文件本质上也是ELF格式结构和目标文件类似但有几个关键区别它有确定的入口地址、所有符号引用都已解析、段被合并成段segment而非节section。理解这些区别才能明白程序加载时发生了什么。4.1 从节到段加载视图的转换目标文件里我们讲的是节section可执行文件里更强调段segment。节是链接视图的概念段是加载视图的概念。一个段可以包含多个节比如典型的只读代码段会把.text、.rodata、.init等合并在一起因为它们权限相同只读可执行加载时映射到同一块内存区域即可。用readelf -l app可以查看程序的段布局readelf -l app输出里会看到类似LOAD类型的段每个段有偏移、虚拟地址、文件大小、内存大小、权限标志R/E表示可读可执行RW表示可读写。这里有个细节值得注意内存大小可能大于文件大小差值就是.bss段——文件里不存加载时补零。这正是前面说的.bss不占文件空间的体现。段布局直接决定了程序运行时的内存映射。代码段只读防止程序意外修改指令数据段可读写存放全局变量栈和堆在运行时动态分配不在ELF段里。理解这个布局你就能明白为什么修改字符串常量会段错误——因为它躺在只读的.rodata里被合并进了只读段。4.2 入口地址与启动过程可执行文件的ELF头里有个e_entry字段记录程序入口地址。注意这个入口不是main函数而是_start它来自启动文件如crt1.o由C运行时库提供。_start负责初始化运行环境、准备argc和argv、调用__libc_start_main最终才调用到你写的main。这个链条解释了为什么main函数的签名是固定的、为什么main的返回值会变成进程退出码。也解释了为什么在main之前可以有全局对象的构造函数被执行——那些构造函数调用被安排在__libc_start_main里通过.init_array段里的函数指针数组来触发。用readelf -h app可以看ELF头找到入口地址用objdump -d app反汇编后可以顺着入口地址找到_start的代码。我建议每个想深入理解程序启动过程的人都亲手走一遍这个链路比看十篇文章都管用。4.3 动态链接与共享库的介入如果程序依赖动态库.so可执行文件里不会包含库代码只记录我需要哪个库、需要哪些符号。加载时由动态链接器负责把库映射进内存、解析符号。这就是为什么动态链接的程序体积小但换台机器可能因为缺库跑不起来。用ldd app可以查看依赖的动态库列表。这里有个实操经验如果ldd显示某个库not found但库文件明明存在多半是库搜索路径没配好。可以临时用LD_LIBRARY_PATH环境变量指定路径来验证确认后再改配置文件。这个排查思路比盲目重装库高效得多。动态链接还带来一个现象符号可以在运行时被解析延迟绑定所以某些符号未定义的错误在链接时不会报运行时才炸。这类问题用LD_DEBUGbindings环境变量运行程序可以看到符号绑定的详细过程是排查动态链接问题的利器。5. 实操亲手拆开一个目标文件看看光讲理论容易飘这一节带你实际动手用一个最小例子把前面讲的东西全部验证一遍。整个过程不需要特殊工具Linux下gcc、binutils自带的nm、objdump、readelf、size就够了。5.1 准备一个能暴露问题的样例写两个文件故意制造全局变量、静态变量、外部引用、未初始化数据等各种情况// utils.c int global_inited 42; // .data int global_uninited; // .bss static int static_var 7; // .data局部符号 const char *msg hello; // 指针在.data字符串在.rodata static int helper(int x) { // 局部符号.text return x * 2; } int compute(int x) { // 全局符号.text return helper(x) global_inited; }// main.c #include stdio.h extern int compute(int x); extern int global_uninited; int main(void) { global_uninited compute(10); printf(%d\n, global_uninited); return 0; }分别编译成目标文件gcc -c utils.c -o utils.o gcc -c main.c -o main.o5.2 用nm验证符号表nm utils.o预期能看到T compute全局函数、t helper静态函数小写、D global_inited、B global_uninited、d static_var、R msg指向的字符串。再执行nm main.o会看到U compute和U global_uninited以及T main、U printf。这一步验证了符号表对定义和引用的区分也验证了static符号的小写标记。5.3 用size和readelf验证段布局size utils.o main.o对比两个文件的bss列utils.o因为有global_uninited会大一些main.o的bss应该接近0。再用readelf -S utils.o查看节头表确认.text、.data、.bss、.rodata、.symtab、.rela.text都存在并记下各自的大小和偏移。然后readelf -r main.o查看重定位表应该能看到针对compute、global_uninited、printf的重定位项。这一步把哪些地址待定具象化了。5.4 链接后对比可执行文件gcc main.o utils.o -o app size app readelf -l app对比app和两个.o的size输出会发现app的text段大致等于两个.o的text之和加上启动代码bss也合并了。readelf -l能看到LOAD段注意MemSiz大于FileSiz的那个段差值就是.bss。最后用objdump -d app反汇编找到main和compute的代码观察call指令的目标地址已经是确定值——这就是重定位完成的结果。提示这套流程我建议每个学编译链接的人都完整走一遍比看任何教程都印象深刻。走完之后再回头看那些链接报错你会发现自己能直接猜到问题出在符号表的哪一类条目上。6. 链接期与运行期的典型坑从报错反推文件结构理论讲完最有价值的部分来了——实际开发中围绕目标文件和可执行文件踩过的坑。这些问题的共同特点是报错信息不直观但只要你知道文件结构就能快速定位。6.1 undefined reference符号表里那个U找不到定义这是最常见的链接错误。报错形如main.o: In function main: main.c:(.text0x15): undefined reference to compute含义很明确main.o的符号表里有个U compute但链接器在所有输入文件里都没找到compute的定义。排查顺序应该是先用nm确认提供方目标文件里确实有T compute如果没有检查是不是函数名拼错、是不是被static修饰了那样就是小写t外部不可见、是不是C里忘了加extern C导致名字被mangle了。C的名字修饰name mangling是重灾区。同一个函数在C里叫compute在C里可能变成_Z7computei两边对不上自然链接失败。用nm看C目标文件时看到一堆_Z开头的符号就是mangle后的结果。解决办法是用extern C包裹声明或者用cfilt命令还原可读名字来核对。6.2 multiple definition同一个符号被定义了两次报错形如utils.o:(.data0x0): multiple definition of global_inited main.o:(.data0x0): first defined here原因是有两个目标文件都在全局作用域定义了同名符号。常见场景是把变量定义写在了头文件里被多个源文件包含后各自生成了一份定义。正确做法是头文件里只放extern声明定义放在某一个源文件里。这里有个C语言的经典细节值得说清楚** tentative definition**。在C里写int x;不带初始化在文件作用域它既是声明也是暂定定义多个文件都这么写链接器在某些模式下会合并成一个common符号但在-fno-commonGCC 10以后默认下会直接报重复定义。所以现代写法应该明确头文件extern int x;源文件int x 0;。这个变化坑过不少人尤其是从老代码迁移过来的项目。6.3 段错误发生在只读段修改了.rodata程序运行时段错误用gdb定位到某行代码在修改一个字符串常量char *s hello; s[0] H; // 段错误原因字符串字面量存放在.rodata段链接时被合并进只读的LOAD段运行时该内存页被标记为只读写入触发保护异常。正确写法是char s[] hello;这样字符串被复制到栈上的可写数组里。这个坑的迷惑性在于编译能过、有时甚至能跑取决于平台和优化但换环境就崩。理解段权限之后这类问题一眼就能看穿。用readelf -l看段的权限标志只读段没有W标志凡是往那里写就必然出问题。6.4 静态库链接顺序导致的诡异未定义用静态库时链接器按命令行顺序扫描只从库中提取当前已引用但未定义的符号所在的成员。如果库A依赖库B命令行写成-lB -lA链接器扫B时还没有对A的引用扫A时引入了对B的引用但B已经扫过了于是报未定义。解决办法是把被依赖的库放在后面-lA -lB或者用--start-group ... --end-group让链接器反复扫描。这个问题在大型项目里非常常见报错信息完全看不出是顺序问题只能靠经验。我的习惯是依赖关系从底向上排列基础库永远放最后。6.5 符号可见性为什么动态库里导出的符号少了做动态库时默认所有全局符号都导出。但有时你发现某个函数在库里有定义外部却链接不到可能是编译时加了-fvisibilityhidden只有显式标记__attribute__((visibility(default)))的符号才导出。这个机制用于减小导出符号表、加快加载、避免符号冲突。排查方法是用nm -D libxxx.so查看动态符号表对比nm libxxx.so的完整符号表差集就是被隐藏的符号。这个技巧在对接第三方库、排查库里有函数但用不了的问题时特别管用。7. 几个能立刻用上的排查命令与经验最后整理一组我日常高频使用的命令以及围绕目标文件和可执行文件积累的几条经验。这些内容在教科书里往往分散在各处集中起来用能省不少时间。7.1 命令速查表目的命令关键看点看符号定义/引用nm file.o大小写区分全局/局部U表示未定义看动态符号nm -D lib.so对比完整符号表找被隐藏的符号看段大小size file.otext/data/bss三列快速判断膨胀看节头表readelf -S file.o各节偏移、大小、类型看重定位readelf -r file.o哪些位置待填、对应哪个符号看段布局readelf -l appLOAD段权限、MemSiz与FileSiz差值看ELF头readelf -h app入口地址、机器类型反汇编objdump -d app确认重定位后的地址看动态库依赖ldd appnot found时查搜索路径还原C符号名cfilt _Z7computei把mangle名转回可读形式7.2 三条踩坑换来的经验第一条调试链接问题时永远先看符号表再看别的。链接错误的本质几乎都是符号解析失败nm能在一秒内告诉你符号是定义了还是引用了、是全局还是局部、名字对不对。我见过太多人一上来就怀疑编译器版本、怀疑库路径绕一大圈最后发现是函数名拼写错误或者漏了extern C。第二条可执行文件体积异常时先看.bss再看.data。如果体积大但size显示bss很大而data很小说明是未初始化的大数组属于正常现象不用优化如果data很大说明有大块已初始化数据被写死在文件里可以考虑改成运行时初始化或者放到外部资源文件。这个判断能避免很多无效的体积优化。第三条跨平台移植时重点检查段权限和符号可见性。不同平台对只读段的保护严格程度不同某些在A平台能跑的修改字符串常量代码到B平台就崩动态库的默认符号导出策略也不同有的平台默认全导出有的默认隐藏。移植前用readelf把目标平台的段布局和符号表过一遍能提前发现大部分隐患。7.3 一个容易被忽视的细节调试信息与符号表是两回事很多人以为-g生成的调试信息和符号表是一回事其实不是。符号表.symtab记录符号名和地址是链接和调试都需要的调试信息.debug_*段记录变量类型、源码行号映射、函数参数等只有调试器用。strip命令剥离的是符号表和调试信息剥离后程序照常运行但gdb就没法显示函数名和行号了。这个区别的实际意义在于发布版本可以strip减小体积但保留一份带符号的版本用于崩溃分析。线上崩溃拿到地址后用带符号版本配合addr2line就能定位到源码行。这个流程在排查线上问题时非常关键而它的基础就是对目标文件结构的理解。走到这里目标文件和可执行文件对你来说应该不再是黑盒了。它们是有结构、有字段、可以被工具逐层拆开的实体。下次再遇到链接报错或者运行时崩溃别急着搜报错信息先nm一下、readelf一下答案往往就在文件结构里。