ELF (Executable and Linkable Format)
Linux 上源码编译后的 .o
文件即目标文件,目标文件结构上和可执行文件格式很相似,通过链接器链接相应的库后得到可执行文件 .elf
。为了描述方便,文中不区分二者的存储格式。elf 存储格式涵盖了程序的编译、链接、装载和执行过程。了解目标文件的格式对认识操作系统,特别是进程加载方面大有裨益。那么目标文件包含什么东西呢?显而易见,应该包含会代码和数据,另外为了支持链接,其中还有符号表,为了支持调试还会有调试信息等等东西。
本文将以一个代码片段为例,深入分析 elf 文件中常用的各段。本文参考了 《程序员的自我修养》一书,感谢前人的付出。
Executable File /
Object File
------------------------
| File Header | <- 文件头,描述 elf 文件的属性...
------------------------
| .text section | <- 指令码
------------------------
| .data section | <- 初始化的非零全局变量和非零局部静态变量
------------------------
| .bss section | <- 未初始化或值为0的全局变量和为0的局部静态变量
------------------------
| other section | <- 其他段
------------------------
| String Tables | <- 字符串表
------------------------
| Symbol Tables | <- 符号表,用于链接
------------------------
| Section header table | <- 段头表,描述各个段的位置、大小等属性
------------------------
分段存储有很多好处:
- 安全,代码段可以设置成只读的,防止恶意篡改
- 性能,现代CPU设计上往往指令和数据缓存分开
- 省空间,代码段共享,如动态库
示例代码
后文针对 elf 文件的分析均以下面的代码为例。读者可以参照着实际体验,加深印象。
int printf(const char * format, ...);
// extern long __bss_start__[];
// extern long __bss_end__[];
int g_init_var = 0x1234; // .data
int g_uinit_var1; // .bss
int g_uinit_var2; // .bss
void func1(int i) // .text
{
printf("%x\n", i); // .text
}
int main() // .text
{
static int s_var = 0x1235; // .data
static int s_var2; // .bss
int a = 1;
int b;
func1(s_var + s_var2 + a + b);
// printf("%lx, %lx\n", __bss_start__, __bss_end__);
return 0;
}
# gcc version 7.2.1 20171011 (Linaro GCC 7.2-2017.11)
$ aarch64-linux-gnu-gcc -c simpleSection.c
# 得到可重定位文件 simpleSection.o
$ aarch64-linux-gnu-gcc simpleSection.c
# 得到可执行文件 a.out
elf 文件头 (File header)
elf
文件头描述了整个 elf
文件的属性。比如程序是 32位还是64 位的,elf 文件的大小端,目标硬件平台,elf 文件的属性(可重定位文件、可执行文件等),还包括一个段头表(Section Header Table)信息,它描述了文件中各个段在文件中的偏移以及属性。
// /usr/include/elf.h
typedef struct
{
unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */
Elf64_Half e_type; /* Object file type */
Elf64_Half e_machine; /* Architecture */
Elf64_Word e_version; /* Object file version */
Elf64_Addr e_entry; /* Entry point virtual address */
Elf64_Off e_phoff; /* Program header table file offset */
Elf64_Off e_shoff; /* Section header table file offset */
Elf64_Word e_flags; /* Processor-specific flags */
Elf64_Half e_ehsize; /* ELF header size in bytes */
Elf64_Half e_phentsize; /* Program header table entry size */
Elf64_Half e_phnum; /* Program header table entry count */
Elf64_Half e_shentsize; /* Section header table entry size */
Elf64_Half e_shnum; /* Section header table entry count */
Elf64_Half e_shstrndx; /* Section header string table index */
} Elf64_Ehdr;
elf 文件头信息如下:
这个 simpleSection.o
文件是小端的,类型是可重定位文件,目标机器是 AArch64
,段头表偏移位于文件 1112 (0x458)字节处。文件头本身占用 64 字节,每个段头占用 64 字节,共有 11 个段,各个段头名字组成的字符串形成了一个单独的段,它在所有段中的索引是 10,也就是最后一个段。
aarch64-linux-gnu-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: AArch64
Version: 0x1
Entry point address: 0x0
Start of program headers: 0 (bytes into file)
Start of section headers: 1112 (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: 11
Section header string table index: 10
段头表 (Section Header Table)
段头表描述了 ELF 文件包含的所有段的信息。每个段都是由下面的结构体所描述。
// /usr/include/elf.h
typedef struct
{
Elf64_Word sh_name; /* Section name (string tbl index) */
Elf64_Word sh_type; /* Section type */
Elf64_Xword sh_flags; /* Section flags */
Elf64_Addr sh_addr; /* Section virtual addr at execution */
Elf64_Off sh_offset; /* Section file offset */
Elf64_Xword sh_size; /* Section size in bytes */
Elf64_Word sh_link; /* Link to another section */
Elf64_Word sh_info; /* Additional section information */
Elf64_Xword sh_addralign; /* Section alignment */
Elf64_Xword sh_entsize; /* Entry size if section holds table */
} Elf64_Shdr;
通过 readelf
工具可以获取到更详细的信息。
$ aarch64-linux-gnu-readelf -S simpleSection.o
There are 11 section headers, starting at offset 0x458:
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
0000000000000074 0000000000000000 AX 0 0 4
[ 2] .rela.text RELA 0000000000000000 00000340
00000000000000c0 0000000000000018 I 8 1 8
[ 3] .data PROGBITS 0000000000000000 000000b4
0000000000000008 0000000000000000 WA 0 0 4
[ 4] .bss NOBITS 0000000000000000 000000bc
0000000000000004 0000000000000000 WA 0 0 4
[ 5] .rodata PROGBITS 0000000000000000 000000c0
0000000000000004 0000000000000000 A 0 0 8
[ 6] .comment PROGBITS 0000000000000000 000000c4
000000000000002e 0000000000000001 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 0000000000000000 000000f2
0000000000000000 0000000000000000 0 0 1
[ 8] .symtab SYMTAB 0000000000000000 000000f8
00000000000001e0 0000000000000018 9 14 8
[ 9] .strtab STRTAB 0000000000000000 000002d8
0000000000000065 0000000000000000 0 0 1
[10] .shstrtab STRTAB 0000000000000000 00000400
0000000000000052 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),
p (processor specific)
可以看到段头表从偏移 0x458
开始,这和文件头中记录的偏移是一致的。第一个段是无效的段,类型是 NULL。
示例
根据段头表的信息,我们可以把 simpleSection.o
的所有段的位置分布列出来。根据文件头信息,每个段表的长度为 64 字节,共 11 个段,因此文件长度最终为 0x458 + 11 * 64 = 0x718
,即 1816 字节,与文件的实际大小一致。注意图里是按地址偏移来示例各个段的分布,与段的下标顺序并不完全一致。
// 这里没有表达为了对齐而加的 padding
----------------- 0x00000000
| ELF Header |
----------------- 0x00000040
| .text |
----------------- 0x000000b4
| .data |
----------------- 0x000000bc
| .bss |
----------------- 0x000000c0
| .rodata |
----------------- 0x000000c4
| .comment |
----------------- 0x000000f2
|.note.GNU-stack|
----------------- 0x000000f8
| .symtab |
----------------- 0x000002d8
| .strtab |
----------------- 0x00000340
| .rela.text |
----------------- 0x00000400
| .shstrtab |
----------------- 0x00000458
| Section table |
----------------- 0x00000718
$ ls -l simpleSection.o
-rw-rw-r-- 1 xxx xxx 1816 Feb 9 10:39 simpleSection.o
这里要澄清一下,.bss
段看起来占用了4个字节,实际上就算继续增加未初始化的静态局部变量,.rodata
段的偏移还是 0x000000c0
。具体的原因下文有分析。
各段解析
# Display the full contents of all sections requested
$ aarch64-linux-gnu-objdump -s simpleSection.o
simpleSection.o: file format elf64-littleaarch64
Contents of section .text:
0000 fd7bbea9 fd030091 a01f00b9 00000090 .{..............
0010 00000091 a11f40b9 00000094 1f2003d5 ......@...... ..
0020 fd7bc2a8 c0035fd6 fd7bbea9 fd030091 .{...._..{......
0030 20008052 a01f00b9 00000090 00000091 ..R............
0040 010040b9 00000090 00000091 000040b9 ..@...........@.
0050 2100000b a01f40b9 2100000b a01b40b9 !.....@.!.....@.
0060 2000000b 00000094 00008052 fd7bc2a8 ..........R.{..
0070 c0035fd6 .._.
Contents of section .data:
0000 34120000 35120000 4...5...
Contents of section .rodata:
0000 25780a00 %x..
Contents of section .comment:
0000 00474343 3a20284c 696e6172 6f204743 .GCC: (Linaro GC
0010 4320372e 322d3230 31372e31 31292037 C 7.2-2017.11) 7
0020 2e322e31 20323031 37313031 3100 .2.1 20171011.
# Display assembler contents of executable sections
$ aarch64-linux-gnu-objdump -d simpleSection.o
...
Disassembly of section .text:
0000000000000000 <func1>:
0: a9be7bfd stp x29, x30, [sp, #-32]!
4: 910003fd mov x29, sp
8: b9001fa0 str w0, [x29, #28]
c: 90000000 adrp x0, 0 <func1>
...
# Displays the sizes of sections inside binary files
$ aarch64-linux-gnu-size simpleSection.o
text data bss dec hex filename
120 8 4 132 84 simpleSection.o
.text
中存的是指令码,长度是 0x74 个字节和段表中记录的一致。不过为什么 size
工具打出来的要多四个字节 😃。
$ aarch64-linux-gnu-size -A --common simpleSection.o
simpleSection.o :
section size addr
.text 116 0
.data 8 0
.bss 4 0
.rodata 4 0
.comment 46 0
.note.GNU-stack 0 0
*COM* 8 0
Total 186
原来是输出格式不同导致的。伯克利格式把 .rodata
的长度计入到 .text
段。
The Berkeley style output counts read only data in the “text” column, not in the “data” column, the “dec” and “hex” columns both display the sum of the “text”, “data”, and “bss” columns in decimal and hexadecimal respectively.
-A|-B --format={sysv|berkeley} Select output style (default is berkeley)
。选择 System V
的格式查看,就能对应上了。
.data
存的是初始化了的全局变量和局部静态变量 0x1234
和 0x1235
。
.rodata
存的是打印的格式化字符串 %x\n
。
.comment
中存的是编译器的信息。
.bss
为未初始化的全局变量和局部静态变量预留了空间,elf 文件中并没有存数据,只是在段表中记录了 .bss
段的信息。
$ aarch64-linux-gnu-objdump -t simpleSection.o
...
SYMBOL TABLE:
0000000000000000 l df *ABS* 0000000000000000 simpleSection.c
0000000000000000 l d .text 0000000000000000 .text
0000000000000000 l d .data 0000000000000000 .data
0000000000000000 l d .bss 0000000000000000 .bss
0000000000000000 l d .rodata 0000000000000000 .rodata
0000000000000004 l O .data 0000000000000004 s_var.3113
0000000000000000 l O .bss 0000000000000004 s_var2.3114
0000000000000000 l d .note.GNU-stack 0000000000000000 .note.GNU-stack
0000000000000000 l d .comment 0000000000000000 .comment
0000000000000000 g O .data 0000000000000004 g_init_var
0000000000000004 O *COM* 0000000000000004 g_uinit_var1
0000000000000004 O *COM* 0000000000000004 g_uinit_var2
0000000000000000 g F .text 0000000000000028 func1
0000000000000000 *UND* 0000000000000000 printf
0000000000000028 g F .text 000000000000004c main
.bss
段在 elf 文件中是不占用空间的,只在 .bss
段表中记录了变量的总大小,因为值都为 0,没必要在可执行文件中实际存储值,操作系统将在加载可执行文件的时候解析段大小的信息,然后为它分配内存。通过符号表可以看到只有局部静态变量放在了 .bss
,而全局未初始化的变量是一个未定义的 COMMON
符号。这和编程语言和编译器实现相关,最终满足放在该段条件的会在链接成可执行文件时在 .bss
段分配空间,这么做的原因是链接过程涉及到符号的强与弱 (strong & weak symbol),需要在链接阶段做裁决,假如有其他的源文件定义了 int g_uinit_var1 = 1;
。那么 g_uinit_var1
将是一个强符号,由于被初始化了将被放在 .data
,而不是 .bss
。
可以看到最终的可执行文件的段中,.bss
和 .comment
共用了一个地址,.bss
段在可执行文件中实际上是空的,未占用空间,只是在段表里记录了其大小是 0x10,共 16 个字节。
...
[23] .bss NOBITS 0000000000411038 00001038
0000000000000010 0000000000000000 WA 0 0 4
[24] .comment PROGBITS 0000000000000000 00001038
000000000000002d 0000000000000001 MS 0 0 1
...
特殊符号
我们可以在代码中通过 __bss_start__
和 __bss_end__
来引用 .bss
段的起始和结束地址,这是链接器提供的特殊符号,它们并没有在源程序中定义,它们实际上定义在链接脚本中。
# List symbols in files
$ aarch64-linux-gnu-nm a.out
U abort@@GLIBC_2.17
0000000000411048 B __bss_end__
0000000000411048 B _bss_end__
0000000000411038 B __bss_start
0000000000411038 B __bss_start__
...
extern long __bss_start__[];
extern long __bss_end__[];
段头字符串表 (Section header string table)
根据前面的描述,.shstrtab
是段头名称字符串组成的段,位于 0x400
的偏移处。而段头表位于 0x458
的偏移处。我们以 .text
段为例,它的下标是 1,即位于 0x458 + 0x40 = 0x498
偏移处。可以看到 {}
表示的即是 sh_name
,它的含义是段名在 .shstrtab
中的偏移,显然 0x420
的位置刚好就是字符串 .text
。
00000400: 00 2e 73 79 6d 74 61 62 00 2e 73 74 72 74 61 62 ..symtab..strtab
00000410: 00 2e 73 68 73 74 72 74 61 62 00 2e 72 65 6c 61 ..shstrtab..rela
00000420: 2e 74 65 78 74 00 2e 64 61 74 61 00 2e 62 73 73 .text..data..bss
00000430: 00 2e 72 6f 64 61 74 61 00 2e 63 6f 6d 6d 65 6e ..rodata..commen
00000440: 74 00 2e 6e 6f 74 65 2e 47 4e 55 2d 73 74 61 63 t..note.GNU-stac
00000450: 6b 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 k...............
00000460: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000470: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000480: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000490: 00 00 00 00 00 00 00 00 {20 00 00 00} 01 00 00 00 ........ .......
000004a0: 06 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
符号表结构 (Symbol table)
在链接的时候,函数和变量统称为符号,链接器需要符号信息来完成链接工作。除了函数和变量外,还有其他符号,如段名,行号信息等,这里不详述。前面提到了符号表,符号表也是一个段。其中记录了符号的名称、大小等信息。结构体如下:
typedef struct
{
Elf64_Word st_name; /* Symbol name (string tbl index) */
unsigned char st_info; /* Symbol type and binding */
unsigned char st_other; /* Symbol visibility */
Elf64_Section st_shndx; /* Section index */
Elf64_Addr st_value; /* Symbol value */
Elf64_Xword st_size; /* Symbol size */
} Elf64_Sym;
$ aarch64-linux-gnu-readelf -s simpleSection.o
Symbol table '.symtab' contains 20 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS simpleSection.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4
5: 0000000000000000 0 NOTYPE LOCAL DEFAULT 3 $d
6: 0000000000000000 0 SECTION LOCAL DEFAULT 5
7: 0000000000000000 0 NOTYPE LOCAL DEFAULT 5 $d
8: 0000000000000000 0 NOTYPE LOCAL DEFAULT 1 $x
9: 0000000000000004 4 OBJECT LOCAL DEFAULT 3 s_var.3113
10: 0000000000000000 4 OBJECT LOCAL DEFAULT 4 s_var2.3114
11: 0000000000000000 0 NOTYPE LOCAL DEFAULT 4 $d
12: 0000000000000000 0 SECTION LOCAL DEFAULT 7
13: 0000000000000000 0 SECTION LOCAL DEFAULT 6
14: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 g_init_var
15: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM g_uinit_var1
16: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM g_uinit_var2
17: 0000000000000000 40 FUNC GLOBAL DEFAULT 1 func1
18: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
19: 0000000000000028 76 FUNC GLOBAL DEFAULT 1 main
同段头表一样,第一个元素是无效的,也就是未定义符号。可以看到 main
和 func1
是全局可见的符号,段表下标为 1,即 .text
段。value 是函数相对于代码段起始地址的偏移。
总结
无论是目标文件、可执行文件、库甚至是核心转储(coredump),它们实际上都基于相似的格式。通过 elf 文件头,我们可以知道段头表的位置、每个段头的大小、段头字符串表的下标,根据这几个信息我们可以找到 elf 文件中所有段的信息,从而解析整个 elf 文件。