概述
1.目标文件简介
目标文件是源代码编译但未链接的中间文件(Windows的.obj和Linux的.o),Windows的.obj采用 PE 格式,Linux 采用 ELF 格式,两种格式均是基于通用目标文件格式(COFF,Common Object File Format)变化而来,所以二者大致相同。本文以 Linux 的 ELF 格式的目标文件为例,进行介绍。
目标文件一般包含编译后的机器指令代码、数据、调试信息,还有链接时所需要的一些信息,比如重定位信息和符号表等,而且一般目标文件会将这些不同的信息按照不同的属性,以“节(section)”也叫“段(segment)”的形式进行存储,本文统称为“段”。
首先将如下具有代表性又不会过于复杂的 C 源码通过 gcc 只编译不链接生成目标文件 test.o,然后对目标文件 test.o 进行分析。
//
//@file: test.c
//
int printf(const char* format, ...);
int gInitVar = 84;
int gUninitVar;
void foo(int i)
{
printf("%dn", i);
}
int main()
{
static int staVar = 85;
static int staVar1;
int a = 1;
int b;
foo(staVar + staVar1 + a + b);
return 0;
}
通过命令 gcc -c test.c -o test.o
编译生成目标文件 test.o。
2.ELF 目标文件的结构
通过 readelf -S
命令可以查看目标文件test.o的所有段的段头信息,实际上是读取段表的内容。
readelf -S test.o
There are 13 section headers, starting at offset 0x198:
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 0000000000000056 0000000000000000 AX 0 0 4
[ 2] .rela.text RELA 0000000000000000 000006a0 0000000000000078 0000000000000018 11 1 8
[ 3] .data PROGBITS 0000000000000000 00000098 0000000000000008 0000000000000000 WA 0 0 4
[ 4] .bss NOBITS 0000000000000000 000000a0 0000000000000004 0000000000000000 WA 0 0 4
[ 5] .rodata PROGBITS 0000000000000000 000000a0 0000000000000004 0000000000000000 A 0 0 1
[ 6] .comment PROGBITS 0000000000000000 000000a4 000000000000002d 0000000000000001 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 0000000000000000 000000d1 0000000000000000 0000000000000000 0 0 1
[ 8] .eh_frame PROGBITS 0000000000000000 000000d8 0000000000000058 0000000000000000 A 0 0 8
[ 9] .rela.eh_frame RELA 0000000000000000 00000718 0000000000000030 0000000000000018 11 8 8
[10] .shstrtab STRTAB 0000000000000000 00000130 0000000000000061 0000000000000000 0 0 1
[11] .symtab SYMTAB 0000000000000000 000004d8 0000000000000180 0000000000000018 12 11 8
[12] .strtab STRTAB 0000000000000000 00000658 0000000000000045 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), l (large), I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
从上面的输出我们可以各个段在文件中的偏移位置,可以推断出ELF目标文件的结构大致如下。
ELF Header |
---|
.text |
.data |
.bss |
.rodata |
.comment |
.shstrtab |
section header table |
.symtab |
.strtab |
.rela.text |
other sections |
从上至下主要包含:
(1)ELF Header,ELF文件头描述目标文件整体信息,包含 ELF 文件版本,目标机器型号、程序入口地址等;
(2).text,代码段存放程序的机器指令;
(3).data,初始化数据段存放已初始化的全局变量与局部静态变量;
(4).bss,未初始化数据段存放未初始化的全局变量与局部静态变量;
(5).rodata,只读数据段存放程序中只读变量,如const修饰的常量和字符串常量;
(6).comment,注释信息段存放编译器版本信息,比如字符串"GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-4)"
(7).shstrtab,段表字符串表,用于存放段的名称字符串;
(8)section header table,段表存放所有段的基本信息,表中的每一项为段头,即段的基本信息;
(9).symtab,符号表记录了目标文件中使用的所有符号,比如变量和函数名,对于变量和函数而言,符号对应的值为它们所在的地址。符号用于链接器链接时找到符号地址;
(10).strtab,字符串表用于存放目标文件中用到的字符串,比如变量名等。因为字符串的长度往往是不定的,所以用固定的结构来表示比较困难。常见的做法就是把字符串集中起来存放到一个表。然后使用字符串在表中的偏移来引用字符串;
(11).rela.text,代码段重定位表存放目标文件未定义的指令在链接时所需的重定位信息。
除了上面提到的段外,ELF文件也有可能包含其他的段,用来保存与程序相关的其他信息。
段名 | 说明 |
---|---|
.hash | 符号哈希表 |
.line | 调试时的行号表,即源代码行号与编译后指令的对应表 |
.dynamic | 动态链接信息 |
.debug | 调试信息 |
.plt和.got | 动态链接的跳转表和全局入口表 |
.init | 程序初始化代码段。该段的内容为可执行指令,是程序的初始化代码,在main函数之前被调用,比如C++全局对象的构造 |
.fini | 程序终结代码段。该段的内容为可执行指令,是程序的终止前需要执行的代码,在main函数正常退出时被调用,比如C++全局对象的析构 |
.rodata1 | Read Only Data,只读数据段,存放字符串常量,全局 const 变量,该段和 .rodata 一样 |
下面以目标文件 test.o 为例,讲解 Linux 下 ELF 目标文件的具体组成部分。
3.ELF 文件头(ELF Header)
通过命令 readelf -h
可以查看 ELF 目标文件的头信息。
readelf -h test.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: 408 (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: 13
Section header string table index: 10
通过输出结果可以看出,ELF目标文件头包含了如下信息:
(1)魔数(Magic)。前四个字节7f、45、4c、46分别对应ASCII字符的Del(删除)、字母E、L、F。这四个字节被称为ELF文件的魔数,操作系统在加载可执行文件时会确认魔数是否正确,如果不正确则拒绝加载。 第五个字节标识ELF文件是32位(01)还是64位(02)的。第六个字节表示字节序是小端(01)还是大端(02)。第七个字节指示ELF文件的版本号,一般是01。 后九个字节ELF标准未做定义。一般为00。
(2)类别(Class),为 ELF64,如果是 32 位的目标文件,则类别为 ELF32。我们可以使用编译命令 gcc -m32 -c test.c -o test32.o
生成32位的目标文件。
(3)数据存储方式(Data),为小端字节序。
(4)ELF 文件版本(Version),表示 ELF 文件版本号,一般为 1。
(5)运行平台与应用程序二进制接口(OS/ABI),为UNIX - System V。其它的还有 UNIX - Linux 与 UNIX - GNU。其中 ABI 为 GNU 和 Linux 两种是相同的,只是使用不同版本的 readelf 会现实不同的结果。而 System V 则是最古老的,也是兼容性最好的。
(6)应用程序二进制接口版本(ABI Version),为 0。
(7)类型(Type),为可重定位文件(REL,Relocatable file),包括目标文件.o与静态链接库.a。其它的还有DYN(共享目标文件,.so文件)和 EXEC(可执行文件)。
(8)硬件平台(Machine),为 Intel 80386。
(9)ELF 文件版本(Version),这个与上面的 Version 是同一个意思,一般为常数 1。
(10)入口地址(Entry point address),规定 ELF 程序的入口虚拟地址,操作系统在加载完该程序后从这个地址开始执行进程的指令。可重定位文件一般没有入口地址,这个值为 0。
(11)程序头起始地址(Start of program headers),为 0 字节。
(12)段表起始地址(Start of section headers),表示段表在文件中的偏移地址,为 408 字节。
(13)标志(Flags),ELF 文件标志位,一般为 0。
(14)文件头大小(Size of this header),为 52 字节。
(15)程序头大小( Size of program headers),为 0 字节。
(16)程序头数量( Number of program headers),为0。
(17)段头大小(Size of section headers),段表描述符的大小,即一个段头的大小,等于 sizeof(Elf64_Ehdr),这里为 64 字节。
(18)段头数量(Number of section headers),这个值等于 ELF 文件中拥有的段的数量,这里表示有13个段。
(19)段表字符串表段头索引(Section header string table index),表示段表字符串表段头在段表中的偏移为10。
ELF文件头结构及相关常数的定义在 /usr/include/elf.h 里,因为 ELF 文件有 32 位和 64 位版本,所以头文件中对应也有两种结构,分别是 Elf32_Ehdr 和 Elf64_Ehdr。其成员与上面输出的头信息对应关系如下:
typedef struct
{
unsigned char e_ident[EI_NIDENT]; //Magic,Class,Data,Version,OS/ABI,ABI Version
Elf64_Half e_type; //Type
Elf64_Half e_machine; //Machine
Elf64_Word e_version; //Version
Elf64_Addr e_entry; //Entry point address
Elf64_Off e_phoff; //Start of program headers
Elf64_Off e_shoff; //Start of section headers
Elf64_Word e_flags; //Flags
Elf64_Half e_ehsize; //Size of this header
Elf64_Half e_phentsize; //Size of program headers
Elf64_Half e_phnum; //Number of program headers
Elf64_Half e_shentsize; //Size of section headers
Elf64_Half e_shnum; //Number of section headers
Elf64_Half e_shstrndx; //Section header string table index
} Elf64_Ehdr;
4.代码段(.text)
代码段存放程序的机器指令,我们通过命令 objdump -S
可以反汇编代码段的内容。
objdump -S test.o
test.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <foo>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 83 ec 10 sub $0x10,%rsp
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 8b 45 fc mov -0x4(%rbp),%eax
e: 89 c6 mov %eax,%esi
10: bf 00 00 00 00 mov $0x0,%edi
15: b8 00 00 00 00 mov $0x0,%eax
1a: e8 00 00 00 00 callq 1f <foo+0x1f>
1f: c9 leaveq
20: c3 retq
0000000000000021 <main>:
21: 55 push %rbp
22: 48 89 e5 mov %rsp,%rbp
25: 48 83 ec 10 sub $0x10,%rsp
29: c7 45 fc 01 00 00 00 movl $0x1,-0x4(%rbp)
30: 8b 15 00 00 00 00 mov 0x0(%rip),%edx # 36 <foo+0x36>
36: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 3c <foo+0x3c>
3c: 01 c2 add %eax,%edx
3e: 8b 45 fc mov -0x4(%rbp),%eax
41: 01 c2 add %eax,%edx
43: 8b 45 f8 mov -0x8(%rbp),%eax
46: 01 d0 add %edx,%eax
48: 89 c7 mov %eax,%edi
4a: e8 00 00 00 00 callq 4f <foo+0x4f>
4f: b8 00 00 00 00 mov $0x0,%eax
54: c9 leaveq
55: c3 retq
从上面可以看到,代码段包含的是test.c中两个函数foo()与main()的指令。代码段.text的第一个字节0x55就是函数foo()的第一条"push %rbp"指令,即帧指针的压栈操作。最后一个字节0xc3是main()函数的最后一条指令"ret"。
5.初始化数据段(.data)
.data段保存了已经初始化的全局变量与局部静态变量。源码 test.c 中有初始化的全局变量 int gInitVar
和局部静态变量static int staVar
,所以这两个变量的值存放在.data段,因为是两个int变量,所以.data段的大小是4字节。使用命令 objdump -s
可以查看目标文件所有非空段的内容。
objdump -s test.o
test.o: file format elf64-x86-64
Contents of section .text:
0000 554889e5 4883ec10 897dfc8b 45fc89c6 UH..H....}..E...
0010 bf000000 00b80000 0000e800 000000c9 ................
0020 c3554889 e54883ec 10c745fc 01000000 .UH..H....E.....
0030 8b150000 00008b05 00000000 01c28b45 ...............E
0040 fc01c28b 45f801d0 89c7e800 000000b8 ....E...........
0050 00000000 c9c3 ......
Contents of section .data:
0000 54000000 55000000 T...U...
Contents of section .rodata:
0000 25640a00 %d..
Contents of section .comment:
0000 00474343 3a202847 4e552920 342e382e .GCC: (GNU) 4.8.
0010 35203230 31353036 32332028 52656420 5 20150623 (Red
0020 48617420 342e382e 352d3429 00 Hat 4.8.5-4).
Contents of section .eh_frame:
0000 14000000 00000000 017a5200 01781001 .........zR..x..
0010 1b0c0708 90010000 1c000000 1c000000 ................
0020 00000000 21000000 00410e10 8602430d ....!....A....C.
0030 065c0c07 08000000 1c000000 3c000000 ...........<...
0040 00000000 35000000 00410e10 8602430d ....5....A....C.
0050 06700c07 08000000 .p......
从输出结果可以看到,段.data的内容分别是0x54与0x55,刚好是两个初始化变量的值84 与 85。
6.未初始化数据段(.bss)
.bss 段存放的是未初始化全局变量与未初始化的局部静态变量,如 test.c 中的未初始化的全局变量 int gUninitVar
与局部静态变量 static int staVar1
,其实更准确的说是 .bss 段为它们预留了空间。
从命令 readelf -S test.o
的输出结果可以看到,.bss 段的大小是4个字节,这与 gUninitVar 和 staVar1 的8字节大小不符。其实通过符号表(Symbol Table)(下面会详细介绍)可以看到,只有 staVar1 被放在了.bss段,而 gUninitVar 并没有被放在任何段,只是一个未定义的 COMMON 符号。这其实和不同语言与编译器的实现有关,有些编译器会将全局未初始化变量放在 .bss 段,有些则不放,只是预留一个未定义的全局变量符号,等到最终链接成可执行文件的时候再在 .bss 段分配空间。
为什么编译器把未初始化的全局变量标记为一个 COMMON 符号,而不直接把它当作未初始化的局部静态变量,为其在 .bss 段分配空间呢?原因是现在的编译器和链接器支持弱符号机制,即允许同一个弱符号定义在多个目标文件中,因为未初始化的全局变量属于弱符号,编译时无法确定符号大小,所以此时无法在 .bss 段为未初始化的全局变量分配空间。
说到 COMMON 符号,需要了解一下什么是 COMMON 块机制。COMMON 块机制最早来源于 Fortran,早期的 Fortran 没有动态分配空间的机制,程序员必须事先声明它所需要的内存空间,这种空间称为 COMMON 块,当不同的目标文件需要的 COMMON 块空间大小不一致时,以最大的那块为准。
对于弱符号的处理,编译器与链接器采用了与 COMMON 块一样的机制,处理措施如下:
(1)如果目标文件中的同一个弱符号的类型不同,编译器与链接器则采用了 COMMON 块(Common Block)机制来确定最终参与链接的符号,即如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的那一个;
(2)如果一个符号在某个目标文件中是强符号,在其他目标文件中都是弱符号,最终选择强符号,此时如果弱符号大小大于强符号,链接器会报警告。
(3)总体看来,未初始化的全局变量最终还是被放在 .bss 段。
以 test.c 中的未初始全局化变量 gUninitVar 为例,使用命令 readelf -s
查看其在符号表中各个字段的取值:
readlef -s
Num: Value Size Type Bind Vis Ndx Name
...
12: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM gUninitVar
...
可以看到,它是一个全局的数据对象,类型为 COM,这是一个典型的弱符号。
GCC 的编译选项 -fno-common
也允许我们把所有未初始化的全局变量不以 COMMON 块的形式处理,或者在定义变量时使用扩展属性 __attribute__
:
int gUninitVar __attribute__((nocommon));
一旦一个未初始化的全局变量不是以 COMMON 块的形式存在,那么它相当于一个强符号,如果其他目标文件中还有同名的强符号变量,链接时会发生符号重定义错误。
7.只读数据段.rodata
.rodata段存放的是只读数据,一般是程序里面的只读变量(比如const修饰的变量)和字符串常量。比如源码文件test.c中在调用printf()时,用到了格式化字符串常量"%dn",存放在.rodata段。从命令 objdump -s
的输出结果,可以看到.rodata段的内容为0x25640a00,占用4个字节,分别表示字符%、d、n与空字符 。
单独设立.rodata段的好处有很多,比如语义上支持了C的const常量,而且操作系统在加载的时候可以将.rodata段的内容映射为只读区,这样对于这个段的任何修改都会被判为非法,保证了程序的安全性。
8.段表(Section Header Table)
ELF 文件中有各种各样的段,段表保存了这些段的基本属性。段表是 ELF 文件中除了文件头以外最重要的结构,它描述了 ELF 各个段的信息,比如每个段的段名、类型、长度、在文件中的偏移等,编译器、链接器和装载器都是通过访问段表来获取各个段的属性。段表在 ELF 文件中的位置由 ELF 文件头的 e_shoff 成员决定,比如 test.o 中,段表偏移为408字节。我们可以使用命令 readelf -S
查看段表内容,前文已经使用过并输出其结果。
段表的实际结构比较简单,它是一个以结构体 Elf32_Shdr 或 Elf64_Shdr 为元素的数组,每个元素对应一个段,数组元素个数等于段的数量。Elf32_Shdr 与 Elf64_Shdr 又被称为段描述符(Section Descriptor)。对于 test.o,段表有13个 Elf64_Shdr 元素,第一个为无效的段描述符,它的类型为 NULL,所以 test.o 共有12个有效的段。
Elf32_Shdr 或 Elf64_Shdr 被定义在 /usr/include/elf.h,以 Elf64_Shdr 为例,其定义如下:
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;
中文释义如下:
成员 | 含义 |
---|---|
sh_name | 段名,是一个字符串,它位于名叫.shstrtab的段表字符串表中,sh_name是段名字符串在.shstrtab的偏移 |
sh_type | 段的类型,详见下文“段的类型” |
sh_flags | 段的标志位,详见下文“段的标志位” |
sh_addr | 段虚拟地址。如果该段可以被加载,则sh_addr 为该段被加载后在进程地址空间中的虚拟地址,否则 sh_addr 为 0 |
sh_offset | 段的偏移,如果该段存在于文件中,则表示该段在文件中的偏移 |
sh_size | 段的长度 |
sh_link 与 sh_info | 段链接信息。详见下文“段的链接信息” |
sh_addralign | 段地址对齐。有些段要求地址对齐,比如段起始位置包含一个double变量,因为 Intel x86_64 系统要求浮点数的存储地址必须是本身的整数倍,那么该段的 sh_addr 必须是8的整数倍。由于地址对齐均是2的整数,所以 sh_addralign 为 2 的指数,比如段地址对齐是8,那么sh_addralign取值3。如果 sh_addralign 为 0,表示不需要对齐 |
sh_entsize | 有些段包含固定大小的项,比如符号表,它包含的每个符号所占的大小都是一样的,对于这种段,sh_entsize表示每个项的大小。如果为0,则表示该段不包含固定大小的项 |
从文件头中可以看到,段表元素大小等于 64B=sizeof(Elf64_Shdr),元素个数等于 13,所以段表大小等于 64*13=832B,这个数值刚好等于 .symtab 在文件中的偏移 0x4d8 减去段表的偏移 0x198。
段的类型(sh_type),段的名字只是在编译和链接过程中有意义,但它不能真正地表示段的类型。我们也可以将一个数据段命名为“.text”,对于编译器和链接器来说,主要决定段的属性的是段的类型(sh_type)和段的标志位(sh_flags)。段的类型相关常量以SHT_开头,定义在 /usr/include/elf.h,列举如下:
常量 | 值 | 含义 |
---|---|---|
SHT_NULL | 0 | 无效段 |
SHT_PROGBITS | 1 | 程序数据。代码段和数据段都是这种类型 |
SHT_SYMTAB | 2 | 符号表 |
SHT_STRTAB | 3 | 字符串表 |
SHT_RELA | 4 | 重定位表。该段包含了重定位信息 |
SHT_HASH | 5 | 符号表的哈希表 |
SHT_DYNAMIC | 6 | 动态链接信息 |
SHT_NOTE | 7 | 提示性信息 |
SHT_NOBITS | 8 | 表示该段在文件中没有内容,比如.bss段 |
SHT_REL | 9 | 该段包含了重定位信息 |
SHT_SHLIB | 10 | 保留 |
SHT_DYNSYM | 11 | 动态链接的符号表 |
段的标志位(sh_flag)表示该段在进程虚拟地址空间中的属性,比如是否可写,是否可执行等。相关常量以SHF_开头,定义在 /usr/include/elf.h,如下表:
常量 | 值 | 含义 |
---|---|---|
SHF_WRITE | (1 << 0) | 表示该段在进程空间中可写 |
SHF_ALLOC | (1 << 1) | 表示该段在进程空间中须要分配空间。有些包含指示或控制信息的段不须要在进程空间中为其分配空间,它们一般不会有这个标识。像代码段、数据段和.bss段都会有这个标志位 |
SHF_EXECINSTR | (1 << 2) | 表示该段在进程空间可以被执行,一般指代码段 |
段的链接信息(sh_link、sh_info) 如果段的类型是与链接相关的(不论是动态链接或静态链接),比如重定位表、符号表等,那么 sh_link 和 sh_info 这两个成员所包含的意义如下表所示。对于其他类型的段,这两个成员没有意义。
sh_type | sh_link | sh_info |
---|---|---|
SHT_DYNAMIC | 该段所使用的字符串表在段表中的下标 | 0 |
SHT_HASH | 该段所使用的符号表在段表中的下标 | 0 |
SHT_RELA、SHT_REL | 该段所使用的相应符号表在段表中的下标 | 该重定位表所作用的段在段表中的下标 |
SHT_SYMTAB、SHT_DYNSYM | 操作系统相关的 | 操作系统相关的 |
other | SHN_UNDEF | 0 |
9.符号表(.symtab)
9.1 符号简介
链接过程的本质就是要把多个不同的目标文件之间像拼图一样拼起来,相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。比如目标文件B用到了目标文件A中的函数foo,那么称目标文件A定义了函数foo,目标文件B引用了函数foo。定义与引用这两个概念同样适用于变量。每个函数和变量都有自己独一的名字,才能避免链接过程中不同变量和函数之间的混淆。在链接中,我们将函数和变量统称为符号(Symbol),函数或变量名就是符号名(Symbol Name)。
符号是链接的粘合剂,没有符号无法完成链接。每一个目标文件都会有一个相应的符号表(Symbol Table),这个表里记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值叫做符号值(Symbol Value),对于变量和函数来说,符号值就是它们的地址。
除了函数和变量之外,还存在其它几种不常用到的符号。符号表中所有符号可以分为如下几种:
(1)全局符号。定义在本目标文件,可以被其它目标文件引用。比如 test.o 中的 gInitVar、gUninitVar 与 foo;
(2)外部符号(External Symbol)。在本目标文件中引用的全局符号,却没有定义在本目标文件。比如 test.o 中的 printf;
(3)段名。其值为该段的起始地址。比如 test.o 的 .text、.data等;
(4)局部符号。这类符号只在编译单元内部可见,链接器往往会忽略它们,因为没用。比如 test.o 中的 staVar 与 staVar1;
(5)行号信息。即目标文件指令与源代码中代码行的对应关系,它是可选的。
对于链接而言,只关心全局符号,局部符号、段名、行号等都是次要的,它们对于其它目标文件来说是不可见的。我们可以使用很多工具查看 ELF 文件的符号表,比如 readelf、objdump 和 nm 等,比如使用 nm 查看 test.o 的结果如下:
nm test.o
0000000000000000 T foo
0000000000000000 D gInitVar
0000000000000004 C gUninitVar
0000000000000021 T main
U printf
0000000000000000 b staVar1.1731
0000000000000004 d staVar.1730
9.2 符号表结构
ELF 文件中的符号表往往是一个段,段名一般叫 .symtab。它是一个 Elf64_Sym 结构的数组,每个 Elf64_Sym 结构对应一个符号,Elf64_Sym 定义在 /usr/include/elf.h。
typedef struct
{
Elf64_Word st_name; //符号名,是一个下标值,表示该符号名在字符串表中的下标
unsigned char st_info; //符号类型与绑定信息
unsigned char st_other; //符号可见性
Elf64_Section st_shndx; //符号所在段
Elf64_Addr st_value; //符号值
Elf64_Xword st_size; //符号大小
} Elf64_Sym;
(1)符号类型和绑定信息(st_info)
该成员低 4 位表示符号的类型(Symbol Type),高 28 位表示符号绑定信息(Symbol Binding)。
符号类型有:
宏定义名 | 值 | 说明 |
---|---|---|
STT_NOTYPE | 0 | 未知类型符号 |
STT_OBJECT | 1 | 该符号是个数据对象,比如变量、数组等 |
STT_FUNC | 2 | 该符号是个函数或其他可执行代码 |
STT_SECTION | 3 | 该符号表示一个段,这种符号必须是STB_LOCAL的 |
STT_FILE | 4 | 该符号表示文件名,一般都是该目标文件所对应的源文件名,它一定是 STB_LOCAL 类型的,并且它的 st_shndx 一定是SHN_ABS |
符号绑定信息取值如下:
宏定义名 | 值 | 说明 |
---|---|---|
STB_LOCAL | 0 | 局部符号,其它目标文件不可见 |
STB_GLOBAL | 1 | 全局符号,外部可见 |
STB_WEAK | 2 | 弱引用 |
(2)符号所在段(st_shndx)
如果符号定义在本目标文件中,那么这个成员表示符号所在的段在段表中的下标。但是如果符号不是定义在本目标文件中,或者对于有些特殊符号,sh_shndx 的值有些特殊。
宏定义名 | 值 | 说明 |
---|---|---|
SHN_ABS | 0xfff1 | 表示该符号包含了一个绝对的值,比如表示文件名的符号就属于这种类型的 |
SHN_COMMON | 0xfff2 | 表示该符号是一个 COMMON 块类型的符号,一般来说,未初始化的全局符号就是这种类型的 |
SHN_UNDEF | 0 | 表示该符号未定义。这个符号表示该符号在本目标文件被引用到,但是定义在其他目标文件中 |
(3)符号值(st_value)
在目标文件中,每一个符号都有一个对应的值,不同类型的符号其值具有不同的意义。主要分为如下几种:
(a)在目标文件中,如果有符号的定义并且该符号不是 COMMON 块类型的,则 st_value 表示该符号在其所属段中的偏移。比如 test.o 中全局变量 gInitVar 在其所属 .data 段中的偏移;
(b)在目标文件中,如果符号是 COMMON 块类型的,则 st_value 表示该符号的对齐属性。比如 test.o 中全局未初始化变量 gUninitVar 的符号值;
(c)在可执行文件中,st_value 表示符号的虚拟地址,这个虚拟地址对动态链接器十分有用。
9.3 符号表实例剖析
使用 readelf -s
可以查看符号表的内容。
readelf -s test.o
Symbol table '.symtab' contains 16 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS test.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 SECTION LOCAL DEFAULT 5
6: 0000000000000004 4 OBJECT LOCAL DEFAULT 3 staVar.1730
7: 0000000000000000 4 OBJECT LOCAL DEFAULT 4 staVar1.1731
8: 0000000000000000 0 SECTION LOCAL DEFAULT 7
9: 0000000000000000 0 SECTION LOCAL DEFAULT 8
10: 0000000000000000 0 SECTION LOCAL DEFAULT 6
11: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 gInitVar
12: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM gUninitVar
13: 0000000000000000 33 FUNC GLOBAL DEFAULT 1 foo
14: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
15: 0000000000000021 53 FUNC GLOBAL DEFAULT 1 main
输出格式与 Elf64_Sym 成员基本一一对应。第一列 Num 表示符号表数组的下标,共有16个符号;第二列 Value 表示符号值,ji st_value;第三列Size为符号大小,即st_size;第四列和第五列,分别为符号类型与绑定信息,即对应 st_info 的低4位和高28位;第六列 Vis 在C/C++未使用,可忽略;第七列 Ndx 即 st_shndx,表示该符号所属段的头在段表中的偏移。最后一列为符号名称。
(1)foo 和 main 函数都是定义在 test.c 中,它们都属于代码段,所以 Ndx 为 1,即 test.o 里面,.text段头在段表中的下标是 1 ,从命令 readelf -S
的输出结果可以看出。他们是函数,所以类型是 STT_FUNC;它们是全局可见的,所以是 STB_GLOBAL;Size 表示函数指令所占的字节数;Value 表示函数相对于代码段起始位置的偏移量。
(2)printf 这个符号只在 test.c 中被引用,未被定义,所以它的 Ndx 是 SHN_UNDEF。
(3)gInitVar 是已初始化的全局变量,它被定义在 .bss 段,即下标为 3。
(4)gUninitVar 是未初始化的全局变量,它是一个 SHN_COMMON 类型的符号,它本身并没有存在于 .bss 段。
(5)staVar.1730 和 staVar1.1731 是两个局部静态变量,它们的绑定属性是 STB_LOCAL,即只是编译单元内部可见。
(6)对于类型是 STT_SECTION 类型的符号,它们表示下标为 Ndx 的段的段名。它们的符号名没有显示,其实它们的符号名就是它们的段名。比如 2 号符号的 Ndx 为 1,那么它表示 .text 段的段名,我们可以使用 objdump -t
来查看。
(7)test.c 表示编译单元的源文件名。
10.字符串表(.strtab)
ELF文件中用到了很多字符串,比如段名、变量名等。因为字符串的长度往往是不定的,所以用固定的结构来表示它比较困难。一种很常见的做法是把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串。假设有下面这个字符串表。
偏移 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|---|
0 |