ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深入拆解目标文件(.o):ELF结构、符号表与重定位实战

深入拆解目标文件(.o):ELF结构、符号表与重定位实战 写C/C的人每天都会跟编译器打交道但说起gcc -c main.c之后生成的那个main.o大部分人其实没真正打开看过。有人觉得没必要有人觉得反正链接器能搞定看它纯属浪费时间。但等我遇到几次头疼的链接报错之后才意识到这东西就像汽车发动机盖下面那堆管道——平时不用管但一旦出问题你连哪根管子漏气都猜不到。这篇就专门把目标文件.o的结构从头到尾拆一遍。不涉及特别深奥的编译器原理主要是能落地、能实操的东西ELF格式长什么样、每个节区存了什么、符号表和重定位表是干嘛的、怎么用readelf和objdump把.o文件翻个底朝天。适合正在学编译原理但被理论劝退的同学也适合写C/C有一阵子、却对“从源码到可执行文件”中间那段路还是一头雾水的朋友。1. 目标文件到底是什么从源码到.o的旅程理解.o文件之前得先把编译这条路走一遍。很多人一键gcc main.c直接出可执行文件根本不知道中间发生了什么。实际上一个.c文件到最终跑起来的程序要经过四个阶段。1.1 编译四阶段每一步都有独立产物四个阶段分别是预处理、编译、汇编、链接。每个阶段都能用gcc的某个参数单独停下来看产物阶段命令参数产物作用预处理gcc -E main.c -o main.imain.i展开宏、包含头文件、处理条件编译编译gcc -S main.i -o main.smain.s把C代码翻译成汇编语言汇编gcc -c main.s -o main.omain.o把汇编翻译成机器指令生成目标文件链接gcc main.o -o mainmain把目标文件与库文件合并生成可执行文件我平时调试时最常用的是-c参数因为它能直接把源码编译成.o文件而不链接非常适合单独检查某个编译单元的语法和符号情况。链接这步单独执行时才会暴露那些“符号找不到”之类的问题因为链接器要把多个.o文件拼在一起才能发现问题。1.2 .o文件到底是什么一个很贴切的类比.o文件在Linux下是ELFExecutable and Linkable Format格式的一种具体类型叫“可重定位文件”ET_REL。我们可以用一个很生活化的类比来理解它。假设你要组装一台电脑CPU、内存、显卡、主板各自是一个独立部件这些部件就是一个个.o文件。每个部件内部已经把该干的事做好了机器指令但部件之间还需要插槽、接口、排线来互相连接。在.o文件里这些“接口信息”就是符号表和重定位表。链接器就是那个拿着螺丝刀帮你把所有部件拼装起来的人它把每个.o文件暴露出来的入口和引用对好最终拼出一台能开机的整机——也就是可执行文件。这正是.o文件的核心价值它既包含能被CPU直接执行的机器码又包含链接阶段需要的辅助信息。所以通常说“目标文件是链接器的输入”就是这个意思。1.3 ELF三种类型别把.o和可执行文件搞混ELF格式根据用途分成三大类这一点很多人没完全搞清楚ET_REL可重定位文件也就是我们说的.o文件。它包含机器指令和符号信息但没有固定的地址所有地址都是“相对的”等着链接器来分配。ET_EXEC可执行文件。链接完成后所有符号都有了确定的虚拟地址程序可以直接被加载器加载运行。ET_DYN共享目标文件.so。和可执行文件类似但可以被多个进程共享也就是动态链接库。三种类型在文件头里用e_type字段区分。很多人打开一个.so文件以为能看到节区打开可执行文件还想用同样的方式分析结果发现内容结构不一样就是因为类型不同导致布局有差异。2. 从ELF文件头开始快速定位文件全貌拿到一个.o文件第一步不是急着看机器码而是先看ELF文件头。文件头相当于整本书的封面和扉页它告诉你这本书是什么语言写的、有多少页、目录在哪一页。2.1 ELF文件头包含了哪些关键信息执行readelf -h demo.o你会看到这样一段输出我拿一个64位Linux下的示例来说明ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2s complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: REL (Relocatable file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x0 Start of program headers: 0 (bytes into file) Start of section headers: 1344 (bytes into file) ...每个字段都有含义但日常分析时最值得关注的是这几个Magic前四个字节固定是7f 45 4c 46也就是\x7fELF。这就是ELF文件的身份证。凡是ELF文件开头一定是这四个字节很多工具就是靠它识别文件类型。ClassELF64说明这是64位文件对应的是64位地址空间。32位文件这里是ELF32。Datalittle endian表示小端字节序x86/x86_64平台都是小端。如果是ARM平台或者交叉编译给某些嵌入式设备可能看到big-endian。Type这里明确写着REL (Relocatable file)代表这是一个目标文件。如果是可执行文件这里会写EXEC。Entry point address目标文件的入口地址是0x0因为还没链接不知道该从哪里开始执行。可执行文件则会有一个具体的入口地址。2.2 文件头与节区表的关系文件头里有两个字段特别关键Start of section headers和Size of section headers。前者告诉你节区表section header table在文件中的偏移位置后者告诉你每个节区头部描述项的大小。节区表本质上就是一张目录它列出了文件里每个节区的名称、类型、偏移、大小、对齐方式等信息。readelf -S读的就是这张表。如果把文件头比作一本书的版权页节区表就是书前面的目录页而每个节区就是书里的正文章节。刚才那个例子里Start of section headers: 1344意思是文件从第1344个字节开始才是节区表的位置。2.3 .o文件没有程序头表这点和可执行文件不同在ELF文件头里有一项Start of program headers: 0。在.o文件里这个值是0因为程序头表program header table在目标文件中根本不存在。程序头表是给“加载器”用的它记录了可执行文件如何被映射到进程的虚拟地址空间。.o文件不需要被直接加载运行所以没有程序头表。只有链接完成后的可执行文件和共享库才需要程序头表。这个区别是理解为什么.o文件不能直接跑的关键——不是它没被链接而是它根本不包含加载运行所需的信息。3. 节区Section是.o文件的灵魂文件头和节区表只是框架真正存着代码和数据的是那些形形色色的节区section。理解了这些节区就理解了.o文件的躯体。3.1 用readelf -S查看节区总览执行readelf -S demo.o会列出文件中所有节区。下面是一个典型.o文件的节区表片段Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align [ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0 [ 1] .text PROGBITS 0000000000000000 00000040 0000000000000014 0000000000000000 AX 0 0 16 [ 2] .rela.text RELA 0000000000000000 00000058 0000000000000018 0000000000000018 I 8 1 8 [ 3] .data PROGBITS 0000000000000000 00000070 0000000000000008 0000000000000000 WA 0 0 8 [ 4] .bss NOBITS 0000000000000000 00000078 0000000000000004 0000000000000000 WA 0 0 4 [ 5] .rodata PROGBITS 0000000000000000 00000078 000000000000000c 0000000000000000 A 0 0 1 [ 6] .symtab SYMTAB 0000000000000000 00000088 00000000000000a8 0000000000000018 7 10 8 ...简单解释一下几个关键列Name节区名称.text、.data、.bss这些就是约定俗成的名字。Type节区类型。PROGBITS表示真正的程序内容代码或数据NOBITS表示不占文件空间的节区典型就是.bssSYMTAB是符号表RELA是重定位表。Address目标文件里这个值通常是0因为还没链接没有虚拟地址。Offset节区内容在文件中的起始偏移。Size节区大小单位是字节。Flags属性标记。A表示可分配allocatableX表示可执行W表示可写。3.2 那些常见节区到底干什么的我用一个表格把最常见的节区及其作用列清楚节区名内容典型属性说明.text机器指令AX编译后的代码段CPU要执行的就在这里.data已初始化全局变量和静态变量WA例如int g 42;.bss未初始化全局变量和静态变量WA例如int g;运行时初始化为0.rodata只读数据A字符串常量、const修饰的全局变量.symtab符号表无记录函数名、变量名等符号信息.strtab字符串表无存符号名称的字符串内容.rela.text代码段的重定位信息I链接时用于修改.text中的地址引用.rela.data数据段的重定位信息I链接时用于修改.data中的地址引用.comment编译器版本注释无例如GCC: (Ubuntu 11.4.0-1ubuntu1) 11.4.0.note.GNU-stack栈可执行性标记无安全相关现代编译都会生成印象最深的是.bss因为很多新手会把“未初始化”和“占文件空间”划等号。实际上未初始化的全局变量在程序启动时会被系统自动清零存一堆零在文件里毫无意义。所以.bss节区的类型是NOBITS它只记录大小和位置不占实际的磁盘空间。你在文件里看到Offset可能是0但Size却写着4就是这个原因。3.3 .data和.bss的区别一个例子就讲透看这段代码int global_init 100; // 进 .data int global_uninit; // 进 .bss static int static_init 5; // 进 .data局部静态变量也是global_init在编译时就知道初始值是100这个100必须写进文件里运行时要直接读出来。global_uninit没有初始值运行时自动清零所以不需要在文件里存任何东西。但要注意一点如果代码里写了int global_uninit 0;编译器很可能会把它也优化进.bss因为初始值是0和没初始化在运行时效果完全一样没必要在文件里写一个0。这种细节体现了编译器的“抠门”之处但它确实安全且省空间。3.4 .rodata和字符串常量的关系.rodata存的是只读数据最典型的就是字符串常量。比如代码里写printf(hello %d\n, 42);那个hello %d\n字符串就存在.rodata节区。为什么单独分一个只读节区一方面是安全——代码意外去写只读数据会触发段错误尽早暴露bug另一方面是优化——多个地方引用同一个字符串常量时链接器可以合并它们节省内存。4. 符号表与重定位.o文件里最精彩的部分如果说节区是.o文件的躯体那符号表和重定位表就是它的神经系统。链接器靠它们才能把不同的.o文件“对上暗号”。4.1 符号表里存了什么用readelf -s demo.o查看符号表输出会很长但核心信息就几个Symbol table .symtab contains 9 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 FILE LOCAL DEFAULT ABS demo.c 2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 3: 0000000000000000 4 OBJECT LOCAL DEFAULT 3 static_var 4: 0000000000000000 14 FUNC GLOBAL DEFAULT 1 add 5: 0000000000000000 0 SECTION LOCAL DEFAULT 5 6: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf 7: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 global_var 8: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts重点看这几列TypeFUNC是函数OBJECT是全局变量或静态变量NOTYPE是未知类型或外部引用SECTION是节区自身。BindLOCAL表示仅本文件可见比如static修饰的变量或函数GLOBAL表示外部可见。Ndx符号定义在哪个节区。UNDundefined表示未定义说明该符号引用了外部定义。Name符号名称就是函数名或变量名。有一点值得注意即使是一个static修饰的变量也会出现在符号表里只是Bind是LOCAL。而printf这种库函数在编译时只知道“我要调用它”并不知道它在哪所以是一条NOTYPE GLOBAL UND记录。这里的UND和链接时的undefined reference很容易混淆但区别很大UND只是“还没定义”链接器能去别的.o文件或库里找到undefined reference是链接器所有地方都找过了就是没有这个符号的定义。4.2 重定位编译时留的悬念链接时公布答案这是.o文件里最巧妙的部分。当编译器编译printf(hello %d\n, 42);时它根本不知道printf函数的地址在哪。那怎么办它会在.text节区里为这次调用留一个占位地址同时在.rela.text重定位表里记一条“这个位置等到链接时填入 printf 的真实地址。”重定位表项Relocation Entry大概长这样Relocation section .rela.text at offset 0x58 contains 2 entries: Offset Info Type Sym. Value Sym. Name Addend 000000000007 000800000002 R_X86_64_PC32 0000000000000000 printf - 4 00000000000e 000200000002 R_X86_64_PC32 0000000000000000 puts - 4每个字段的含义Offset需要修改的位置在节区内的偏移。比如偏移7处就是一句call指令里存放目标地址的那4个字节。Info低32位是符号在符号表中的下标高32位是重定位类型。TypeR_X86_64_PC32表示相对寻址即目标地址相对于当前指令地址的差值。还有R_X86_64_PLT32用于动态链接的调用等类型。Sym. Name要解析的符号名比如printf。Addend附加常量计算最终地址时要把这个值加进去。之所以设计成“编译时留洞、链接时填坑”核心原因是并行编译。多个.c文件可以同时编译成.o文件彼此不知道对方的地址。如果编译时就把地址定死那任何一个文件的改动都可能牵一发动全身无法并行、无法增量。4.3 用一个实例演示重定位过程我们直接看一个最小例子。源码extern int external_var; int add(int a, int b) { return a b external_var; }编译后.rela.text里会有一条针对external_var的重定位记录Relocation section .rela.text at offset 0x98 contains 1 entries: Offset Info Type Sym. Value Sym. Name Addend 000000000011 000700000002 R_X86_64_PC32 0000000000000000 external_var - 4external_var是外部变量编译时它的地址未知所以在访问它的指令处留了位置。链接时如果另一个.o文件定义了external_var链接器就把它最终的绝对地址减去当前指令的下一条地址PC相对寻址填入这个位置。这样运行时CPU取址时就能正确算出external_var的地址。5. 实操一步步解剖一个真实的.o文件理论讲再多不如动手做一遍。我拿一个简单的例子带你走一遍完整流程。整个过程不需要任何特殊工具Linux下自带的gcc、readelf、objdump就够了。5.1 准备一个含多种变量和函数的示例先写一个demo.c故意包含各种类型的符号方便观察#include stdio.h int global_init 42; int global_uninit; static int static_var 8; const char *msg hello, target file; static const int const_local 100; int add(int a, int b) { return a b; } int main(void) { printf(%s %d\n, msg, add(global_init, global_uninit)); return 0; }然后用gcc -c demo.c -o demo.o得到目标文件。注意这里的-c非常重要它只到汇编这一步不做链接。5.2 用readelf逐层查看文件结构依次执行下面几条命令观察输出readelf -h demo.o # 文件头 readelf -S demo.o # 节区表 readelf -s demo.o # 符号表 readelf -r demo.o # 重定位表你会看到这些关键信息global_init在.data节区类型是OBJECTBind是GLOBAL。global_uninit在.bss节区Offset是0x0但Size是4。static_var也在.data但Bind是LOCAL。printf是GLOBAL UNDputs也会出现编译器可能把printf优化成了puts。.rela.text里有针对printf的重定位条目。这里我踩过的一个坑是有些人拿到一个包含全局变量的.o文件想用readelf -s看变量值。结果发现Value列全是0以为变量丢了。其实Value列在.o文件里是“该符号在其所在节区内的偏移”链接完成之后才会变成真正的虚拟地址。所以看.o文件时Value为0是正常现象不是bug。5.3 用objdump查看机器码和反汇编接下来用objdump拆解.text节区。objdump -d demo.o # 反汇编显示机器指令 objdump -dr demo.o # 反汇编并显示重定位信息 objdump -x demo.o # 显示文件所有头信息简化版readelfobjdump -d的输出大概是这样的Disassembly of section .text: 0000000000000000 add: 0: f3 0f 1e fa endbr64 4: 55 push %rbp 5: 48 89 e5 mov %rsp,%rbp 9: 89 7d fc mov %edi,-0x4(%rbp) c: 89 75 f8 mov %esi,-0x8(%rbp) f: 8b 45 fc mov -0x4(%rbp),%eax 12: 03 45 f8 add -0x8(%rbp),%eax 15: 5d pop %rbp 16: c3 ret最左边是地址偏移中间是机器码十六进制字节右边是对应的汇编指令。比如55就是push %rbp48 89 e5就是mov %rsp,%rbp。这就是CPU真正要执行的原始字节。如果配合-r参数还能在反汇编里直接看到哪些机器码位置需要重定位。比如25: b8 00 00 00 00 mov $0x0,%eax 2a: e8 00 00 00 00 call 2f main0x2f 2b: R_X86_64_PC32 printf-0x4这里e8 00 00 00 00就是一条call指令后面的四个字节全是0等链接器填printf的真实相对地址。5.4 用hexdump直接看原始字节如果想更直观地感受“文件里的字节长什么样”可以用xxd demo.o | head -30看最前面的Magic字节你会清晰地看到7f 45 4c 46这就是ELF的“签名”。再往后翻到.text节区的Offset位置能看到和objdump输出一致的机器码。这一步虽然不常用但对建立“文件就是一堆字节”的直觉很有帮助。6. 常见问题与排查技巧实录了解了.o文件结构之后很多开发中的疑难杂症就能找到根因了。下面分享几个我实际工作中遇到的案例。6.1 undefined reference到底是什么问题链接时报错undefined reference to foo十有八九是符号表里找不到foo的定义。排查步骤# 查所有相关.o文件里有没有 foo 的定义 readelf -s foo.o | grep foo # 如果是GLOBAL OBJECT或GLOBAL FUNC且Ndx不是UND说明定义了 # 如果显示UND且类型是NOTYPE说明只是声明没有定义常见原因有几种忘了链接对应库文件-lm、-lpthread函数声明了但实现写在了另一个没参与链接的.c文件里因为函数是static的根本没有导出。每次碰到这类报错我都会先捧着符号表查一遍比瞎猜快得多。6.2 multiple definition重复定义的处理多个.c文件同时定义同一个全局变量链接时会出现multiple definition of xxx。这个错误从.o文件的视角看就是多个.o文件的.symtab里都有一条指向.data且Bind是GLOBAL的同名符号。链接器面对这种情况无法决定用哪一个只能报错。解决办法通常是加static限制作用域或者用-fcommon让老的GCC行为兼容但最根本的还是设计好头文件用extern声明、在单个.c文件里定义。不推荐急性子一上来就用-Wl,--allow-multiple-definition糊弄这等于告诉链接器“随便选一个”非常危险。6.3 链接脚本如何影响节的合并链接器不是简单地把所有.o文件按顺序拼接而是要遵循链接脚本linker script的布局规则。默认脚本可以用ld --verbose查看它定义了.text : { *(.text .text.*) } .data : { *(.data .data.*) }这句话的意思是把所有输入.o文件里的.text节区收拢到输出文件的一个.text段里把.data收到.data段里。同时不同节区会被合并进不同的段segment最后由程序头表描述这些段如何映射到虚拟内存。嵌入式开发时经常要定制链接脚本用来控制代码放Flash还是RAM。理解了节区和段的关系改链接脚本时才能不抓瞎。6.4 struct布局不一致导致的诡异bug这个坑我印象极其深刻。两个.c文件里对同一个struct的定义不一样比如一个加了#pragma pack(1)一个没加编译链接都很正常但运行的时候数据永远对不上。从.o文件的视角看两个文件里对应的.symtab符号相同但符号指向的.data偏移和大小不同或者说访问代码里对成员偏移量的计算结果不同。排查手段是用pahole如果有查看struct布局或者直接用objdump -s对比两个.o文件里相关数据区域的内容。如果你发现.o级别看数据没问题但运行逻辑就是不对优先怀疑“同一个结构体在多个编译单元里定义不一致”。最直观的例子一个24位位域拆成两个char存另一端按一个int读偏移对不上导致读数全错。7. 一点实操体会动手花半小时把几个简单的.c文件编译成.o文件然后用readelf和objdump把每个节区都过一遍比看十篇理论文章都管用。我在第一次完整摸透.o文件结构之后再回头去看链接报错基本能一眼定位是符号问题、库缺失问题还是重复定义问题。建议你也写一个故意带外部变量、外部函数、static变量、未初始化全局变量的demo自己观察它们在符号表里的差异。想更深入的话可以用objcopy抠出单个节区再用objdump -s查看原始字节亲手验证那些布局关系。这些工具和命令看得越多对程序从源码到二进制运行的整体流程就越有掌控感。
返回列表