目标文件(.o)格式解析——符号表、重定位与链接报错解析

0.简介

上一篇文章中我们了解了编译的具体流程,本文我们将开始去对编译生成的目标文件进行说明,介绍其中的核心结构,揭示其中各个部分的作用以及一些典型的问题。

1.整体结构介绍

目标文件一般和可执行文件采用一种格式存储,在linux中一般使用ELF(Executable Linkable Format)格式存储;Windows中一般采用PE(Portable Executable)格式存储,这两种格式都来源于COFF文件格式,本文主要探讨ELF格式文件,但原理都是类似的。ELF文件在Linux中有四种:可重定位文件(.o 我们主要介绍的内容)、可执行文件(直接可执行程序)、共享目标文件(.so)、核心转储文件(core dump)。

可重定位文件中整体结构可分为下面几个部分:

本文例子依旧使用test.c,只不过为了让test.c可以覆盖到我们的主要段,对其代码进行一些改写。

// test.c
#include <stdio.h>
int nInitData = 10;
int nUninitData;
void func(int nTmp)
{
    printf("%d", nTmp);
}
int main() 
{
    static int nStaticInit = 111;
    static int nStaticUninit;
    int a = 99;
    int b;
    func(nInitData + nUninitData + nStaticInit + nStaticUninit + a + b);
    return 0;
}

2.ELF文件头

ELF的文件头包含的内容如下,以32位为例:

typedef struct elf32_hdr{
  unsigned char e_ident[EI_NIDENT]; // ELF文件标识字节,固定为0x7F 'E' 'L' 'F'
                                     // [0-3]: 魔数(Magic Number),固定为0x7F, 0x45, 0x4C, 0x46
                                     // [4]:   版本号(1=ELF32, 2=ELF64)
                                     // [5]:   数据编码(1=小端, 2=大端)
                                     // [6]:   文件格式版本(通常为1)
                                     // [7-15]: 未使用或保留字段
  Elf32_Half  e_type;  // 文件类型(2字节无符号整数)
                       // 0: 未知类型
                       // 1: 可重定位文件(.o)
                       // 2: 可执行文件
                       // 3: 共享目标文件(.so)
                       // 4: 核心转储文件(core dump)
  Elf32_Half  e_machine;  // 目标机器架构(2字节)
                          // 3: i386架构
                          // 8: MIPS架构
                          // 0x28: ARM架构
                          // 0x3E: x86-64架构
                          // 0xB7: AArch64(ARM64)
  Elf32_Word  e_version;  // ELF格式版本号(4字节)
                          // 1: 当前版本
  Elf32_Addr  e_entry;  // 程序入口点虚拟地址(4字节)
                        // 可执行文件: 程序启动的第一条指令地址
                        // 可重定位文件: 通常为0
  Elf32_Off e_phoff;    // 程序头表(Program Header Table)的文件偏移(4字节)
                        // 可执行文件/共享库: 非零值
                        // 可重定位文件: 通常为0
  Elf32_Off e_shoff;    // 节头表(Section Header Table)的文件偏移(4字节)
                        // 无节头表时为0
  Elf32_Word  e_flags;  // 与特定架构相关的标志位(4字节)
                        // i386: 0x00000000
                        // ARM: 0x05000000表示硬件浮点支持
  Elf32_Half  e_ehsize;  // ELF文件头大小(2字节)
                         // 对于ELF32: 固定为52字节(0x34)
  Elf32_Half  e_phentsize;  // 程序头表中每个条目的大小(2字节)
                            // ELF32: 固定为32字节(0x20)
  Elf32_Half  e_phnum;  // 程序头表中的条目数量(2字节)
                        // 0表示无程序头表
  Elf32_Half  e_shentsize;  // 节头表中每个条目的大小(2字节)
                            // ELF32: 固定为40字节(0x28)
  Elf32_Half  e_shnum;  // 节头表中的条目数量(2字节)
                        // 0表示无节头表
  Elf32_Half  e_shstrndx;  // 节名称字符串表所在的节索引(2字节)
                           // SHN_UNDEF(0): 无节名称表
                           // 其他值: 指向包含节名称的字符串表节
} Elf32_Ehdr;

我们使用gcc -c test.c来进行.o文件生成,然后使用readelf -h test.o查看其头部信息。

3.段表信息

接下来我们来看一下段表的信息,使用objdump -h test.o可以得到如下内容(主要的段):

段表的结构在内核代码中定义如下(32位为例):

typedef struct {
    Elf32_Word  p_type;    // 段类型(PT_LOAD等)
    Elf32_Off   p_offset;  // 段在文件中的偏移
    Elf32_Addr  p_vaddr;   // 段在内存的虚拟地址
    Elf32_Addr  p_paddr;   // 物理地址(通常等同vaddr)
    Elf32_Word  p_filesz;  // 段在文件中的大小
    Elf32_Word  p_memsz;   // 段在内存中的大小
    Elf32_Word  p_flags;   // 权限标志(PF_R/W/X)
    Elf32_Word  p_align;   // 内存对齐要求
} Elf32_Phdr;

4.各个段信息

了解了elf文件头和段表,我们来看一下各个段的内容和作用,还是使用objdump命令,本次使用objdump -s -d,-s打印所有段,-d按照反汇编打印,我们逐个段去看。

4.1 代码段(text)

代码段即可执行程序中的指令内容,其内容如下:

4.2 数据段(data)以及只读数据段(rodata)未初始化变量(bss)

数据段(data)存放初始化了的各全局变量或者局部静态变量的值,可以发现,两个值分别对应nInitData和nStaticInit:

只读数据段(rodata)存放的是只读数据(比如const修饰的变量或者常量字符,这个地方是%d:

未初始化变量段(bss)存放的是未初始化的全局变量和局部静态变量,对应上面代码中的应该是nUninitData和nStaticUninit。但是查看bss大小为4不是8。

此时去查看符号对应的段可以发现,nUninitData对应的Ndx是Com也就是comment段。

4.3 其他段

其他常见的段有debug段(存放调试信息)、dynamic段(动态链接信息)、strtab(字符串表)等,当然我们也可以自定义段,通过使用如下方式就可以自定义段,自定义段用处很多,比如将代码固定到某个内存区域、隔离敏感数据(设置内存保护属性)等。

__attribute__((section(".mysection"))) 
int my_var = 42;

4.4 查看程序所有段的方式

使用readelf -S test.o查看即可

5.链接相关内容介绍

链接相关的段主要有符号表、重定位表、字符串表、动态链接符号表和动态链接字符串表。

5.1 符号表

符号表用以记录文件中的定义和引用的符号(变量,函数),是链接器需要的核心结构,使用readelf -s test.o可以查看其符号表的信息,其具体字段含义如下。

1)名称(Name):符号的标识符(如 main、global_var)。

2)值(Value):符号的地址(在 .o 文件中通常是相对段的偏移量,链接后变为绝对地址)。

3)类型(Type):如 STT_FUNC(函数)、STT_OBJECT(变量)、STT_FILE(文件名)。

4)绑定(Binding):符号类型。

STB_GLOBAL:全局符号,可被其他文件引用(如非静态函数)。
STB_LOCAL:局部符号,仅在当前文件可见(如静态函数或变量)。
STB_WEAK:弱符号(如未定义的全局变量),优先级低于强符号。

5)所在段(Section Index):符号所属的段(如 .text、.data),未定义符号为 SHN_UNDEF。

其在内核中的表示结构为(以32位为例):

typedef struct elf32_sym {
  // 符号名在字符串表中的索引(字节偏移量)
  Elf32_Word st_name;		
  // 符号值(地址或常量值)
  Elf32_Addr st_value;		
  // 符号大小(如函数长度、变量占用字节数)
  Elf32_Word st_size;		
  // 符号类型和绑定属性(低4位为类型,高4位为绑定)
  unsigned char st_info;	
  // 保留字段(未使用,通常为0)
  unsigned char st_other;	
  // 符号关联的节索引(SHN_*常量或节区表头索引)
  Elf32_Half st_shndx;		
} Elf32_Sym;

5.2 重定位表

可以使用readelf -r test.o查看重定位表,其主要字段如下:

1)偏移量(Offset):需要修正的地址在段内的位置。

2)重定位类型(Type):指示如何修正地址,例如:

R_X86_64_PC32:32 位相对地址修正(如 PC 相对寻址的函数调用)。
R_X86_64_64:64 位绝对地址修正(如全局变量引用)。

其在内核中定义如下(64位为例):

typedef struct {
    Elf64_Addr  r_offset; // 需要修改的位置(段内偏移)
    Elf64_Xword r_info;   // 符号索引和重定位类型
    Elf64_Sxword r_addend;// 附加常数
} Elf64_Rela;

5.3 其他相关段

其他段作用会在静态链接以及动态链接实际使用文章中详细说明。

6.典型链接问题分析

在上面介绍中我们可以看到未定义的符号以及各种符号类型(强弱,全局、本地),处理这些符号就是链接的主要作用之一。大部分链接错误的原因也就是这些符号的处理有问题。

6.1 "undefined reference" 错误

其可能原因如下:

1)缺少目标文件或库文件。

2)符号名拼写错误。

3)函数声明与定义不匹配(如参数类型不一致)。

4)静态库 (.a) 或动态库 (.so) 未正确链接。

我们可以使用nm -u命令查看未定义的符号,然后去查看是否正确链接,然后使用readelf查看对应的库是否导出了该符号,看是否是版本问题。

nm -u a.o          # 查看未定义符号
readelf -Ws lib.so # 检查库是否导出该符号

6.2 重复定义错误

在上面介绍符号表时提到了符号的类型(强符号,弱符号等)两个强符号会报冲突,可能是多地定义,也可能是头文件没有使用#ifndef保护。

6.3 动态库找不到

可以在LD_LIBRARY_PATH环境变量中配置,也可以在编译命令中指定。

6.4 通用查看方法

可以使用如下命令生成链接的符号地址和段布局文件进行分析。

gcc -o test test.c -Wl,-Map=test.map  # 生成链接地图文件

7.总结

本文介绍了目标文件的主要内容,同时简要提及了链接的一些问题。但静态链接究竟做了什么?为什么链接后程序会变大?静态库到底以什么形式存在?些将在下一篇文章(静态链接)中进行说明。

原文链接:,转发请注明来源!