ELF文件格式

Linux 中的可执行文件(.out), 目标文件(.o), 静态库(.a) 动态库(.so) 等都按照 ELF 文件格式进行存储, 我们可以借助 readelf 和 objudmp 来查看 ELF 文件的相关信息, 也可以利用 elf.h 头文件来对其进行解析和处理

20世纪90年代, 一些厂商联合成立了一个委员会, 起草并发布了一个 ELF 文件格式标准供公开使用, 并希望所有人都可以遵循这项标准并从中获益. 1993 年委员会发布了 ELF 文件标准, 当时参与该委员会的有来自于编译器的厂商, 比如 Watcom(Watcom C/C++ 编译器) 和 Borland(Borland Turbo Pascal 编译器); 来自 CPU 的厂商比如 IBM 和 Intel; 来自操作系统的厂商比如IBM 和 Microsoft. 1995 年委员会发布了 ELF1.2标准, 自此委员会完成了自己的使命, 不久就解散了, 所以 ELF 文件格式标准的最新版本也是最后一个版本就是 1.2

本文详细介绍一下 ELF 文件格式, 我们使用如下的一个 C 程序作为示例, 其后的分析均使用该文件的编译产物(gcc-11.3)

int printf(const char *format, ...);

int global_init_var = 84;
int global_zero_var = 0;
int global_uninit_var;

void func1(int i) {
    printf("%d\n",i);
}

int main(void) {
    static int static_var = 85;
    static int static_zero_var = 0;
    static int static_var2;
    int a = 1;
    int b;
    func1(static_var + static_var2 + a + b);
}

使用 gcc 编译器得到 .o 文件

gcc -c SimpleSection.c -o SimpleSection.o

ELF 段格式

ELF 文件的作用有两个,一是用于程序链接(为了生成程序);二是用于程序执行(为了运行程序). ELF 目标文件格式可以分为两部分

  1. (Section), 每个段有各自的作用
  1. 段表(Section Header Table), 该表描述了 ELF 包含的所有段的信息, 比如每个段的段名,段长度,文件中的偏移量,读写权限和段的其他属性, 如下图所示

20240111150728

链接的其中一个过程就是将多个目标文件中各个段合并到一起, 相同性质的段组合为一个大段, 如下所示

20240111151629

正常来说 section 应该翻译为节, segment 翻译为段, 由于下文我们讨论单个目标文件的 ELF 格式, section 统称为段

段分布

ELF 文件中有很多个段组成, 可以使用 readelf -S 可以查看一个 ELF 文件的所有段的信息, 比如查看前文提到的 SimpleSection.o, 它会依次列出所有段的信息, 如下所示

笔者在 binutils 实现了 readelf, 感兴趣的读者可自行阅读源码

(base) kamilu@LZX:~/miniCRT/notes$ readelf -S SimpleSection.o
There are 14 section headers, starting at offset 0x410:

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
       0000000000000064  0000000000000000  AX       0     0     1
  [ 2] .rela.text        RELA             0000000000000000  000002f0
       0000000000000078  0000000000000018   I      11     1     8
  [ 3] .data             PROGBITS         0000000000000000  000000a4
       0000000000000008  0000000000000000  WA       0     0     4
  [ 4] .bss              NOBITS           0000000000000000  000000ac
       0000000000000008  0000000000000000  WA       0     0     4
  [ 5] .rodata           PROGBITS         0000000000000000  000000ac
       0000000000000004  0000000000000000   A       0     0     1
  [ 6] .comment          PROGBITS         0000000000000000  000000b0
       000000000000002e  0000000000000001  MS       0     0     1
  [ 7] .note.GNU-stack   PROGBITS         0000000000000000  000000de
       0000000000000000  0000000000000000           0     0     1
  [ 8] .note.gnu.pr[...] NOTE             0000000000000000  000000e0
       0000000000000020  0000000000000000   A       0     0     8
  [ 9] .eh_frame         PROGBITS         0000000000000000  00000100
       0000000000000058  0000000000000000   A       0     0     8
  [10] .rela.eh_frame    RELA             0000000000000000  00000368
       0000000000000030  0000000000000018   I      11     9     8
  [11] .symtab           SYMTAB           0000000000000000  00000158
       0000000000000138  0000000000000018          12     8     8
  [12] .strtab           STRTAB           0000000000000000  00000290
       0000000000000060  0000000000000000           0     0     1
  [13] .shstrtab         STRTAB           0000000000000000  00000398
       0000000000000074  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  D (mbind), l (large), p (processor specific)

根据输出信息可以画出整个 ELF 文件的排布, Offset 对应每一个段的起始位置, Size 对应段的大小

20240823161739

其中第一个无名段是一个特殊的段, 它包含了描述整个文件的基本属性, 被称为ELF 文件头(ELF Header); 其余 1-13 段则各自有其作用.


我们可以计算一下所有段的大小, 最后一个段 shstrtab 的大小为 0x74, 所以段结尾的地址是 0x398 + 0x74 = 0x40c

由此可知 [0x000-0x40c] 之间分别对应各个段, [0x40c-0x410] 地址对齐. 0x410 之后是段表.

段表并不算一个段, 它是独立于段之外的, 用于记录所有段信息的一个数组. ELF Header 是一个需要单独处理的特殊块, 始终位于文件起始位置.

段表

每一个段有各自的含义和信息, ELF 并没有将这些信息分布保存在每一个段中, 而是使用 (Section Header Table)段表 来统一保存所有段的信息.

段表实际上是一个数组, 数组中每一个元素都是 Elf64_Shdr 结构体, 用于储存每一个段的信息. 其定义如下所示, 这些属性对应段的段名,段长度,文件中的偏移量,读写权限和段的其他属性

typedef struct {
    uint32_t   sh_name;      // 段名
    uint32_t   sh_type;      // 段类型
    uint64_t   sh_flags;     // 段标志位
    Elf64_Addr sh_addr;      // 段虚拟地址
    Elf64_Off  sh_offset;    // 段偏移
    uint64_t   sh_size;      // 段长度
    uint32_t   sh_link;      // 段链接信息
    uint32_t   sh_info;      // 段链接信息
    uint64_t   sh_addralign; // 段地址对齐
    uint64_t   sh_entsize;   // 段条目的长度
} Elf64_Shdr;

该结构体长度为 64 字节, 因此所有段一共 64 x 14 = 896 (0x380) 字节. 前文我们计算了所有段的长度和为 0x410, 所以可以计算得到整个 ELF 文件的总字节数为 0x410 + 0x380 = 0x790 = 1936 字节. 可以使用 du 查看该文件大小, 这也印证了我们的计算结果

(base) kamilu@LZX:~/miniCRT/notes$ du -b SimpleSection.o
1936    SimpleSection.o

ELF Header

注意到段表位于所有段的最后面, 那么如何通过偏移量找到段表的位置呢?

前面我们提到了第 0 个无名段 ELF Header 是整个 ELF 文件的文件头, 段表的起始地址和段的数量都记录在 ELF Header 当中

我们可以使用 readelf -h 参数查看 ELF 文件头信息. 其中高亮的两行, Start of section headers(段表的起始地址) 1040 对应我们之前计算的 0x410. 段数量为 14.

(base) kamilu@LZX:~/miniCRT/notes$ readelf -h SimpleSection.o
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:          1040 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           0 (bytes)
  Number of program headers:         0
  Size of section headers:           64 (bytes)
  Number of section headers:         14
  Section header string table index: 13

ELF Header 的结构体如下所示, 相关结构体元素的含义以注释的形式说明. , 其中的 e_shoff 就记录了段表在整个 ELF 文件中的偏移地址, e_shnum 记录了段的个数

typedef struct {
    unsigned char e_ident[EI_NIDENT]; // 一些信息
    uint16_t      e_type;             // 文件类型
    uint16_t      e_machine;          // CPU类型
    uint32_t      e_version;          // ELF版本号
    ElfN_Addr     e_entry;            // 入口地址
    ElfN_Off      e_phoff;            // 程序头入口
    ElfN_Off      e_shoff;            // 段表在文件中的偏移
    uint32_t      e_flags;            // 标志位
    uint16_t      e_ehsize;           // ELF文件头大小
    uint16_t      e_phentsize;        // 程序头大小
    uint16_t      e_phnum;            // 程序头个数
    uint16_t      e_shentsize;        // 段表描述符大小, 等同于 sizeof(ElfN_Ehdr)
    uint16_t      e_shnum;            // 段表描述符数量
    uint16_t      e_shstrndx;         // 段表字符串表的在段表中的索引值
} ElfN_Ehdr;

也就是说只需要读取文件开头的 ELF Header 就可以获取到该 ELF 文件的基本信息, 以及段表的位置; 再根据读取段表, 就可以得到每一个段的信息.

区分不同段的作用主要是为了将指令和数据的存放区分开, 这样的好处主要有如下三点:

  1. 易于设置读写权限: 指令区域只读, 代码区域可读写,防止程序指令被有意/无意改写
  1. 现代CPU的缓存属性: 有益于程序的局部性
  1. 多副本可共享

使用 size 查看一个 ELF 文件的代码段/数据段/BSS段的长度

这里的大小不是.o文件的大小, 只是段的大小, 可以使用 du -b SimpleSection.o 查看总大小(1936b)

$ size SimpleSection.o
   text    data     bss     dec     hex filename
    224       8       8     240      f0 SimpleSection.o

其中 text 的大小并不是指 .text 段, 而是 .text(0x64) + .rodata(0x4) + .note.GNU-stack(0) + .note.gnu.property(0x20) + .eh_frame(0x58) = 0xe0 = 224

因此找到每一个段的流程如下:

  1. 读取 ELF Header, 找到段表在文件中的偏移 e_shoff 和 段表描述符数量 e_shnum
  1. 根据段表偏移量 e_shoff 找到段表起始位置
  1. 段表是一个大数组, 一共 e_shnum 个项, 每一个项都是 Elf64_Shdr, 根据其中的 sh_offset 找到对应的段

20240111175430


上文我们看到了 ELF 中的有 14 个段, 其中 .comment .note.GNU-stack .note.gnu.property .eh_frame, 它们并不是当前的重点, 我们暂时忽略.

14 个段除去 ELF Header 以及上面暂不关心的 4 个段还有 9 个段, 他们分别是 .text, .data, .bss, .rodata, .rela.text, .rela.eh_frame, .symtab, .strtab, .shstrtab, 下面我们依次介绍一下

代码段(.text)

程序源码编译后的机器指令保存在代码段中, 这也是唯一一个可以具有可执行权限的段, 程序运行起来后的 CPU 会从代码段的起始位置开始执行

# 使用 objdump 查看反汇编
objdump -s -d SimpleSection.o

可以看到 .text 段的二进制内容对应右侧的反汇编代码

20240111213202

数据段(.data .bss .rodata)

我们将 .data .bss .rodata 统称为数据段, 但他们三者略有差别.

int printf(const char *format, ...);

int global_init_var = 84;
int global_zero_var = 0;
int global_uninit_var;

void func1(int i) {
    printf("%d\n",i);
}

int main(void) {
    static int static_var = 85;
    static int static_zero_var = 0;
    static int static_var2;
    int a = 1;
    int b;
    func1(static_var + static_var2 + a + b);
}

.data 段中保存的是 int global_init_var = 84(0x54), static int static_var = 85(0x55), 四字节小端存储

20230503220741

符号表和重定位表

符号表和重定位表在可执行文件中并不是必须的, 但是在目标文件中这两个段的信息非常重要, 它涉及到之后链接器 ld 对于地址进行重定位.

如果要找到所有的符号表和重定位表, 那么只需要遍历段表的所有项, 判断 sh_type 类型即可

可以在生成可执行文件时使用 -s 选项去掉相关信息.

对于与链接相关的重定位表(.rela)和符号表(.symtab), 段表项(Elf64_Shdr)中的 sh_linksh_info 字段有意义, 其余段的段表项这两个字段无意义.

对于重定位表 RELA, sh_link 代表该段所对应的符号表(.symbol)的下标, sh_info 表示它作用的重定位的段. 如下所示.

20240827222649

重定位的相关内容详见 静态链接

字符串表(.strtab)和段表字符串表(.shstrtab)

ELF 文件中需要保存许多字符串名, 比如符号的名字, 段的名字. 字符串的长度往往是不确定的, 那么对于一个不定长的字符串, 用固定的结构来表示它比较困难. ELF 的做法是把字符串集中起来保存在段中, 符号表的符号名字保存在字符串表中(.strtab), 所有段的名字保存在段表字符串表(.shstrtab)中

这一点和ext文件系统的inode对于文件名的管理方式有些类似

我们注意到上文中 Elf64_Shdr 结构体的字段 sh_name 的含义是段名, 但是类型是 uint32_t 而非 char*, 这是因为段本身并不记录其名字, 段的名字在 .shstrtab (段表字符串表)中统一记录, sh_name 只是一个索引值. 采用这种方式就可以固定下来 Elf64_Shdr 结构体的大小

0x410 之后是段表, 0x398 开始是 .shstrtab(段表字符串表), 记录了每一个段的名字

20230506004340

因此找到每一个段名字的方法如下, 首先找到 shstrtab, 然后利用每一个段 shdr 中的 sh_name 作为偏移量计算地址

20240112095245

程序头(program header)

ELF可执行文件中有一个专门的数据结构叫做程序头表(Program Header Table)用来保存"Segment"的信息.因为ELF目标文件不需要被装载,所以它没有程序头表,而ELF的可执行文件和共享库文件都有.跟段表结构一样,程序头表也是一个 Elf64_Phdr 结构体数组,它的结构体如下:

typedef struct {
    Elf64_Word    p_type;          /* 段类型 */
    Elf64_Word    p_flags;         /* 段标志 */
    Elf64_Off     p_offset;        /* 段在文件中的偏移量 */
    Elf64_Addr    p_vaddr;         /* 段的虚拟地址 */
    Elf64_Addr    p_paddr;         /* 段的物理地址 */
    Elf64_Xword   p_filesz;        /* 段在文件中的大小 */
    Elf64_Xword   p_memsz;         /* 段在内存中的大小 */
    Elf64_Xword   p_align;         /* 段对齐方式 */
} Elf64_Phdr;

program header 在可重定位文件中不存在, 仅存在于已经完成链接的可执行文件, 可以使用 -l 选项查看, 其中还多出来了 .init .plt .plt.got 等等一些段, 这些是与运行时和动态链接相关的段, 我们暂不做介绍

其中只有 PT_LOAD 的段需要被装载, 装载时需要根据段本身的一些属性由内核分配内存区域, 例如对齐方式, 段标志

PT_INTERP 的段为动态链接的解释器段, 可以通过读取其 p_offset 指向的字符串获取到动态链接器的路径(通常是 /lib64/ld-linux-x86-64.so.2)

其他特殊段

还可以使用 objcopy 将一张图片添加到 .o 文件中

$ objcopy -I binary -O elf64-x86-64 a.jpg SimpleSection.o
$ objdump -ht SimpleSection.o

SimpleSection.o:     file format elf64-x86-64

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .data         00009261  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, DATA
SYMBOL TABLE:
0000000000000000 g       .data  0000000000000000 _binary_a_jpg_start
0000000000009261 g       .data  0000000000000000 _binary_a_jpg_end
0000000000009261 g       *ABS*  0000000000000000 _binary_a_jpg_size

其中 _binary_a_jpg_start _binary_a_jpg_end _binary_a_jpg_size 分别表示起始地址, 结束地址, 大小, 我们可以在程序中直接声明并使用它们, 例如打印出图像数据的前10个字节:

#include <stdio.h>

extern char _binary_a_jpg_start;
extern char _binary_a_jpg_end;
extern int _binary_a_jpg_size;

int main() {
    for (int i = 0; i < 10; i++) {
        printf("%02x ", _binary_a_jpg_start[i]);
    }
    printf("\n");
    return 0;
}

这种将图片直接打入目标文件是一种方式,不过会导致文件变大

自定义段

int printf(const char *format, ...);

int global_init_var = 84;
int global_uninit_var;

__attribute__((section("FOO"))) int global_foo_var = 42;

__attribute__((section("BAR"))) void foo() {
    
}

void func1(int i) {
    printf("%d\n",i);
}

int main(void) {
    static int static_var = 85;
    static int static_var2;
    int a = 1;
    int b;
    func1(static_var + static_var2 + a + b);
}

可以看到新增了两个段

20230504002317

__attribute__((section("name"))) 是GCC和Clang编译器的扩展,它不是C标准的一部分,因此不是所有的编译器都支持它.

在MSVC编译器中,可以使用 #pragma section 指令来将变量或函数放置在自定义段中 (#pragma section指令是MSVC编译器的扩展) .例如:

#pragma section("FOO", read, write)
int global_foo_var = 42;
#pragma section("BAR", execute)
void foo() {
    // ...
}

其他说明

当然 GCC 编译器提供了关于 ELF 的众多选项

-fdata-sections-ffunction-sections 是GCC编译器的选项,用于将数据和函数放置在单独的段中.

当使用这些选项时,GCC会将每个全局变量和函数放置在单独的段中,而不是将它们放置在默认的.data和.text段中.这样做的好处是可以将不同的数据和函数放置在不同的段中,从而使得目标文件更加灵活.例如,您可以将只读数据放置在只读段中,将可写数据放置在可写段中,将可执行代码放置在可执行段中.

gcc -c -fdata-sections -ffunction-sections file.c

在将目标文件链接到可执行文件时,您需要使用 -Wl , --gc-sections 选项来删除未使用的段.这将从目标文件中删除未使用的段,从而减少可执行文件的大小.例如:

gcc -Wl,--gc-sections file.o -o program

请注意,使用 -fdata-sections-ffunction-sections 选项可能会增加目标文件的大小,并在链接时增加一些开销.然而,通过将数据和函数放置在单独的段中,可以使得目标文件更加灵活,并且可以优化可执行文件的大小和性能.

不难发现 ELF 中 .text .data 已经分开了

20230504003246

参考

zood