news 2026/9/16 20:49:26

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入拆解目标文件(.o):ELF结构、符号表与重定位实战

写C/C++的人每天都会跟编译器打交道,但说起gcc -c main.c之后生成的那个main.o,大部分人其实没真正打开看过。有人觉得没必要,有人觉得反正链接器能搞定,看它纯属浪费时间。但等我遇到几次头疼的链接报错之后,才意识到这东西就像汽车发动机盖下面那堆管道——平时不用管,但一旦出问题,你连哪根管子漏气都猜不到。

这篇就专门把目标文件(.o)的结构从头到尾拆一遍。不涉及特别深奥的编译器原理,主要是能落地、能实操的东西:ELF格式长什么样、每个节区存了什么、符号表和重定位表是干嘛的、怎么用readelfobjdump把.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下是ELF(Executable 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: 2's 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 headersSize 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表示不占文件空间的节区(典型就是.bss),SYMTAB是符号表,RELA是重定位表。
  • Address:目标文件里这个值通常是0,因为还没链接,没有虚拟地址。
  • Offset:节区内容在文件中的起始偏移。
  • Size:节区大小,单位是字节。
  • Flags:属性标记。A表示可分配(allocatable),X表示可执行,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:符号定义在哪个节区。UND(undefined)表示未定义,说明该符号引用了外部定义。
  • 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 - 4

external_var是外部变量,编译时它的地址未知,所以在访问它的指令处留了位置。链接时,如果另一个.o文件定义了external_var,链接器就把它最终的绝对地址减去当前指令的下一条地址(PC相对寻址),填入这个位置。这样运行时CPU取址时就能正确算出external_var的地址。

5. 实操:一步步解剖一个真实的.o文件

理论讲再多,不如动手做一遍。我拿一个简单的例子,带你走一遍完整流程。整个过程不需要任何特殊工具,Linux下自带的gccreadelfobjdump就够了。

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节区,类型是OBJECT,Bind是GLOBAL
  • global_uninit.bss节区,Offset是0x0但Size是4。
  • static_var也在.data,但Bind是LOCAL
  • printfGLOBAL 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 # 显示文件所有头信息(简化版readelf)

objdump -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 <main+0x2f> 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文件,然后用readelfobjdump把每个节区都过一遍,比看十篇理论文章都管用。我在第一次完整摸透.o文件结构之后,再回头去看链接报错,基本能一眼定位是符号问题、库缺失问题还是重复定义问题。

建议你也写一个故意带外部变量、外部函数、static变量、未初始化全局变量的demo,自己观察它们在符号表里的差异。想更深入的话,可以用objcopy抠出单个节区,再用objdump -s查看原始字节,亲手验证那些布局关系。这些工具和命令看得越多,对程序从源码到二进制运行的整体流程就越有掌控感。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 20:46:22

基于Neo4j与Spring Boot的化妆品知识图谱问答实践

简介&#xff1a;面向Java方向课程设计与知识图谱入门者&#xff0c;这份资源以化妆品领域为背景&#xff0c;完整覆盖知识图谱从数据采集、关系建模到智能问答的落地链路。项目图谱包含3000个节点、15000条边&#xff0c;覆盖口红与香水两类商品&#xff0c;支持图谱检索与智能…

作者头像 李华
网站建设 2026/9/16 20:46:20

Python实现个税计算器:财税与编程的完美结合

1. 项目概述&#xff1a;Python个税计算器的学习价值这个用纯Python实现的个人所得税模拟器&#xff0c;本质上是一个教学演示项目。它完整还原了国内现行个税计算规则&#xff0c;包括综合所得、专项扣除、累进税率等核心要素。对于财税专业学生和Python初学者而言&#xff0c…

作者头像 李华
网站建设 2026/9/16 20:46:12

Ubuntu apt报错Unable to locate package?根源排查与修复指南

你有没有遇到过这种情况&#xff1a;在Ubuntu里执行sudo apt-get install nginx结果终端直接甩回来一句E: Unable to locate package nginx然后你就开始怀疑人生&#xff1a;是不是系统装坏了&#xff1f;是不是没联网&#xff1f;是不是命令敲错了&#xff1f;我在帮别人排查问…

作者头像 李华
网站建设 2026/9/16 20:45:47

Regex101 里正则没匹配上?把表达式贴给走 TaoToken 的 Codex 核对

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 20:45:47

鸿蒙系统WebRTC音视频通话集成实战:选型、移植与踩坑记录

去年我们团队接到一个实时音视频通话的需求&#xff0c;要在鸿蒙系统上实现一对一视频通话功能。当时鸿蒙原生生态还不算成熟&#xff0c;社区里关于WebRTC在鸿蒙上的资料也少得可怜&#xff0c;踩了不少坑才把整个链路跑通。这篇文章把我从方案选型、环境搭建、核心链路实现到…

作者头像 李华