写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.i | main.i | 展开宏、包含头文件、处理条件编译 |
| 编译 | gcc -S main.i -o main.s | main.s | 把C代码翻译成汇编语言 |
| 汇编 | gcc -c main.s -o main.o | main.o | 把汇编翻译成机器指令,生成目标文件 |
| 链接 | gcc main.o -o main | main | 把目标文件与库文件合并,生成可执行文件 |
我平时调试时最常用的是-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文件,开头一定是这四个字节,很多工具就是靠它识别文件类型。 - Class:
ELF64说明这是64位文件,对应的是64位地址空间。32位文件这里是ELF32。 - Data:
little 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表示不占文件空间的节区(典型就是.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重点看这几列:
- Type:
FUNC是函数,OBJECT是全局变量或静态变量,NOTYPE是未知类型或外部引用,SECTION是节区自身。 - Bind:
LOCAL表示仅本文件可见(比如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位是重定位类型。
- Type:
R_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节区,类型是OBJECT,Bind是GLOBAL。global_uninit在.bss节区,Offset是0x0但Size是4。static_var也在.data,但Bind是LOCAL。printf是GLOBAL UND,puts也会出现(编译器可能把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 %rbp,48 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文件,然后用readelf和objdump把每个节区都过一遍,比看十篇理论文章都管用。我在第一次完整摸透.o文件结构之后,再回头去看链接报错,基本能一眼定位是符号问题、库缺失问题还是重复定义问题。
建议你也写一个故意带外部变量、外部函数、static变量、未初始化全局变量的demo,自己观察它们在符号表里的差异。想更深入的话,可以用objcopy抠出单个节区,再用objdump -s查看原始字节,亲手验证那些布局关系。这些工具和命令看得越多,对程序从源码到二进制运行的整体流程就越有掌控感。