从源码到进程:02.目标文件的生成

前言

在之前的文章中,我们初步讲解了从源码到进程这个主题的预处理部分,在这篇文章中,我们将讲解目标文件(.o)的生成过程。

通过了解 .o文件,可以帮助我们:

  • 定位 undefined reference / multiple definition 等链接错误的根源
  • 理解重定位失败与对齐问题导致的运行时崩溃
  • 掌控编译器、汇编器与链接器之间的契约(ABI / ELF / linker script)
  • 为什么很多编码规范中,不允许在头文件中定义或者声明全局变量

核心理论

什么是目标文件?

目标文件(.o)是编译器将源码转化为机器码的中间产物,遵循 ELF(Executable and Linkable Format)格式。它包含代码、数据和元信息(如符号表、重定位表),但还不是可执行程序,需要链接器进一步处理。
生成目标文件是编译过程的第三步,位于以下流程中:

  • 预处理:展开宏和头文件,生成 .i 文件。
  • 编译:将源码转为汇编代码(.s 文件),并优化。
  • 汇编:将汇编代码转为机器码,打包为 .o 文件。
  • 链接:合并多个 .o 文件和库,生成可执行文件。

ELF文件与 .o 文件的核心结构

在 Linux 上,.o 文件通常遵循 ELF规范。一个典型的 ELF 可重定位文件包含:

  • ELF Header:文件起始,包含魔数、位宽(ELF32/ELF64)、文件类型(REL 表示 relocatable)等。
  • Section Header Table:节区(section)的目录,每个节区都有名字、类型、大小、对齐等属性。
  • Sections(节区):实际的数据或元数据,常见节区有:
    • .text:机器指令(函数体)
    • .rodata:只读常量(字符串字面量、常量数组)
    • .data:已初始化全局/静态变量
    • .bss:未初始化全局/静态变量(NOBITS)
    • .symtab / .strtab:符号表与字符串表
    • .rela.* / .rel.*:重定位表(记录需要在链接或加载时修正的地址)
    • .debug_*:DWARF 调试信息(可选)

这种分节存储将代码、可读数据、可写数据和元信息解耦,使链接器可以按节合并、重定位,并允许调试器与性能分析工具独立读取所需信息。

符号表(Symbol Table)与名字修饰

符号表记录每个符号(函数、变量、文件名)在目标文件中的属性:名称、值(在可重定位文件中通常为节内偏移或 0)、大小、类型(FUNC/OBJECT)、绑定(LOCAL/GLOBAL/WEAK)与所属节(Ndx)。

  • LOCAL 符号仅在当前翻译单元可见。
  • GLOBAL 符号可被链接器解析并跨文件引用。
  • WEAK 符号允许被其它强符号覆盖(常用于库或可选实现)。

C++ 采用名字修饰(name mangling)将类型信息编码到符号名中,链接器基于这些 mangled names 做匹配(比如 _Z3foov)。

排障技巧:使用 nm, readelf -s, objdump -t 查看符号属性;注意 UND(未定义)、ABS(绝对符号)、T(text 段)等标志。

重定位(Relocation)机制

.o 文件不是可执行镜像,许多地址(函数调用、全局变量地址、常量地址等)在编译后仍然是相对或占位的。重定位表记录哪些位置需要在链接(静态链接)或加载(动态加载/内核模块)时修正。

重定位包含三要素:

  • 偏移(offset)
  • 符号(symbol)
  • 类型(relocation type)

不同架构有不同的重定位类型(如 x86_64 的 R_X86_64_PC32 / R_X86_64_64 等),决定链接器如何将符号地址写入目标位置(绝对/相对/高/低半字等)。

重定位错误会导致页错误、未知指令或控制流错误;对于位置无关代码(PIC / PIE)与内核模块,正确的重定位处理尤其关键。

就像之前一直说的,生成源码的四个步骤中。汇编器负责把汇编代码的标签变为节内偏移并产生重定位条目(如果引用外部符号则不填地址)。这正是 .o 成为“部分实现 + 重定位数据”的原因。

实操演示

// object.cpp
int g_var_init = 10;

int g_var_uninit;

static int l_var_init = 15;

static int l_var_uninit;

extern void extern_func();

int main()
{
  extern_func();
  return 0;
}

通过如下命令生成目标文件

g++ -c object.cpp -o object.o

通过如下命令可以看到elf的头部

readelf -h object.o

o_h
观察输出信息可以发现,存在一些魔数,标记文件的真正类型;文件的类型是可重定位文件,因为目标文件不是真正的可执行程序,需要链接器将多个目标文件链接在一起之后,为每个符号找到真正的地址才是可以运行
通过如下命令查看节区表

readelf -S object.o

o_S
其中 .data .bss 段分别存储着初始化和未初始化的 全局变量与静态变量,大小分别都是8个字节,因为都是两个int变量。
可以看到 .bss.comment的偏移量,也就是在文件中的实际读取位置都是0000005c。 是因为.bss 段在文件中是不占大小的,只是运行时在内存中占有大小,并且初始化为0;
此外各个段的地址全是 0,因为是可重定位文件,此时分配没有意义。
通过如下命令查看符号表

readelf -s object.o

o_s
可以看见,_ZL10l_var_init,_ZL12l_var_uninit,g_var_init,g_var_uninit,_Z11extern_funcv 这样的几个符号。
其中_ZL10l_var_init,g_var_init的Ndx都是3,因为他们都是在 .data 段, Value值分别是0和4,是因为他们在.data段的位置偏移分别是0和4。
同理_ZL12l_var_uninit,g_var_uninit的Ndx都是4,Value值分别是0和4,因为他们在 .bss 段,偏移0个字节和4个字节之后,分别读取4个字节的大小就是对应点的变量的值。
_Z11extern_funcv 这个符号的 UND是因为,这是个外部符号,需要再链接确定。
同时,看到 _ZL10l_var_initg_var_init的Bind类型分别是 local与global,是因为static这个标识符限定了这个符号本身是局部的,只在当前目标文件可用,链接器是访问不了的。
通过如下命令查看重定位表

readelf -r object.o

o_r
_Z11extern_funcv 这个符号,链接器会在连接时候修正,实现call extern_func这个函数的操作

常用命令

readelf -h file.o        # 查看 ELF 头
readelf -S file.o        # 列出节区
readelf -s file.o        # 符号表
readelf -r file.o        # 重定位表
nm file.o                # 简易符号表(T/U/D 等)
objdump -d file.o        # 反汇编
objcopy --strip-debug in.o out.o   # 去除调试段
strip   file.o           # 去符号表与调试信息
addr2line -e vmlinux <addr>  # 将地址转为 file:line(对 vmlinux)

总结

  • .o 文件是 可重定位文件,遵循 ELF 格式。

  • 它包含 节区(代码/数据/元信息)符号表重定位表

  • 通过符号表和重定位机制,编译器和链接器能够协同工作,把多个 .o 文件合并为一个可执行程序。

  • 理解 .o 文件能帮助我们解决:

    • 链接错误(undefined reference / multiple definition);
    • 符号可见性问题(static / global / weak);
    • 调试信息与 strip 的作用;
    • 为什么不应该在头文件中定义全局变量。

一句话:

目标文件 (.o) 是源码到可执行程序的关键中间形态,它承载了机器码、符号与重定位信息,是编译器与链接器之间的桥梁。

posted @ 2025-09-26 07:22  ToBrightmoon  阅读(149)  评论(0)    收藏  举报

© ToBrightmoon. All Rights Reserved.

Powered by Cnblogs & Designed with ❤️ by Gemini.

湘ICP备XXXXXXXX号-X