1、ELF文件描述结构
这里插一句,对于 readelf 和 objdump 两个工具,它们的联系与区别如下:
- objdump 借助BFD(Binary File Descriptor Library),更加通用一些, 可以应付不同文件格式,它提供反汇编的功能,而readelf 并不提供反汇编功能
- objdump 是以一种可阅读的格式让你更多地了解二进制文件带有的信息的工具
- readelf 并不借助BFD,而是直接读取ELF格式文件的信息,得到的信息也略细致一些
1.1 文件头
我们可以用 readelf 命令来详细看下ELF文件,代码如下:
bash
liang@liang-virtual-machine:~/cfp$ 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: 1072 (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
c
#define EI_NIDENT 16
typedef struct {
unsigned char e_ident[EI_NIDENT];
Elf32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
Elf32_Addr e_entry;
Elf32_Off e_phoff;
Elf32_Off e_shoff;
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize;
Elf32_Half e_shnum;
Elf32_Half e_shstrndx;
} Elf32_Ehdr;
typedef struct {
unsigned char e_ident[EI_NIDENT];
Elf64_Half e_type;
Elf64_Half e_machine;
Elf64_Word e_version;
Elf64_Addr e_entry;
Elf64_Off e_phoff;
Elf64_Off e_shoff;
Elf64_Word e_flags;
Elf64_Half e_ehsize;
Elf64_Half e_phentsize;
Elf64_Half e_phnum;
Elf64_Half e_shentsize;
Elf64_Half e_shnum;
Elf64_Half e_shstrndx;
} Elf64_Ehdr;
成员 | readelf 输出结果与含义 |
---|---|
e_ident | 将文件标记为目标文件的初始字节。这些字节提供与计算机无关的数据,用于解码和解释文件的内容。ELF 标识中提供了完整说明。 |
e_type | 标识目标文件类型,如下表中所列,例如ET_REL,可重定位文件;ET_EXEC,可执行文件等 |
e_machine | 指定独立文件所需的体系结构。例如EM_386,Intel 80386;EM_AMD64,AMD64等 |
e_version | 标识目标文件(ELF)版本, 一般为常数1 |
e_entry | 入口地址,规定ELF程序的入口虚拟地址,操作系统在加载完该程序后从这个地址开始执行进程指令。可重定位文件一般没有入口地址,则这个值为0 |
e_phoff | 程序头表的文件偏移(以字节为单位)。如果文件没有程序头表,则此成员值为零。关于程序头(Program Header)的相关知识在后面"可执行文件的装载"一章中会详细讲解 |
e_shoff | 节头表(段表)的文件偏移(以字节为单位)。如果文件没有节头表,则此成员值为零 |
e_flags | 与文件关联的特定于处理器的标志。标志名称采用 EF_machine_flag 形式。对于 x86,此成员目前为零。例如EF_SPARCV9_MM,内存型号掩码 |
e_ehsize | ELF 头的大小(以字节为单位) |
e_phentsize | 文件的程序头表中某一项的大小(以字节为单位)。所有项的大小都相同。关于程序头(Program Header)的相关知识在后面"可执行文件的装载"一章中会详细讲解 |
e_phnum | 程序头表中的项数,e_phentsize 和 e_phnum 的积指定了表的大小(以字节为单位)。如果文件没有程序头表,则 e_phnum 值为零 。关于程序头(Program Header)的相关知识在后面"可执行文件的装载"一章中会详细讲解 |
e_shentsize | 段表描述符的大小(以字节为单位),一般等于sizeof( ELF32_Shdr) |
e_shnum | 段表描述符数量。这个值等于ELF文件中拥有的段的数量 |
e_shstrndx | 段表字符串表所在的段在段表中的下标 |
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
最开始的4个字节是所有ELF文件都必须相同的标识码。分别为:0x7F、0x45、0x4c、0x46,
接下来的1个字节用来标识ELF的文件类的,0x01表示是32位,0x02表示是64位;第6个是字节序,规定该ELF文件是大端还是小端,0x01表示小端,0x02表示大端。第7个字节规定ELF的主版本号,一般是1。后面9个字节ELF标准没有定义,一般填0,有些平台会使用这9个字节作为扩展标志。
1.2 段表
ELF文件中,段表是除了头文件以外最重要的结构,他描述了ELF段的基本信息的结构,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其他属性。
也就是说,ELF文件的段结构就是由段表决定的,编译器、链接器和装载器都是依靠段表来定位和访问各个段的属性。
段表在ELF文件中的位置由ELF文件头的" e_shoff"成员决定。
bash
liang@liang-virtual-machine:~/cfp$ readelf -S SimpleSection.o
There are 13 section headers, starting at offset 0x430:
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
0000000000000055 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 00000320
0000000000000078 0000000000000018 I 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
0000000000000036 0000000000000001 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 0000000000000000 000000da
0000000000000000 0000000000000000 0 0 1
[ 8] .eh_frame PROGBITS 0000000000000000 000000e0
0000000000000058 0000000000000000 A 0 0 8
[ 9] .rela.eh_frame RELA 0000000000000000 00000398
0000000000000030 0000000000000018 I 11 8 8
[10] .shstrtab STRTAB 0000000000000000 000003c8
0000000000000061 0000000000000000 0 0 1
[11] .symtab SYMTAB 0000000000000000 00000138
0000000000000180 0000000000000018 12 11 8
[12] .strtab STRTAB 0000000000000000 000002b8
0000000000000067 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)
段表的结构比较简单,他是一个以"Elf32_Shdr"结构体为元素的数组。数组元素的个数等于段的个数。"Elf32_Shdr" 又被称为段描述符。
c
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 输出结果与含义 |
---|---|
sh_name | 段名,是个字符串,它位于一个叫做".shstrtab"的字符串表。sh_name是段名字符串在 ".shstrtab"中的偏移 |
sh_type | 段的类型 |
sh_flags | 段的标志位 |
sh_addr | 段的虚拟地址;如果该段可以被加载,则sh_addr为该段被加载后在进程地址空中的虚拟地址,否则为0 |
sh_offset | 段偏移,如果该段存在于文件中,则表示该段在文件中的偏移;否则无意义。比如sh_offset对于BSS段来说就无意义 |
sh_size | 段长度 |
sh_link、sh_info | 段的链接信息 |
sh_addralign | 段地址对齐要求 |
sh_entsize | 项的长度;有些段包含了一些固定大小的项,比如符号表,它包含的每个符号所占的大小都是一样的。对于这种段,sh_entsize表示每个项的大小。如果为0,则表示该段不包含固定大小的项 |
这里介绍几个比较重要的段描述符成员;
段的类型:
常量 | 值 | 含义 |
---|---|---|
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 | 表示该节在文件中没有内容,不占用空间 |
SHT_REL | 9 | 重定位信息 |
SHT_SHLIB | 10 | 保留 |
SHT_DYNSYM | 11 | 动态链接的符号表 |
段的标志位:
常量 | 值 | 含义 |
---|---|---|
SHF_WRITE | 1 | 表示该段在进程空间可写 |
SHF_ALLOC | 2 | 表示该节在进程空间中需要分配空间,有些包含指示或者控制信息的节不需要在进程分配空间,就没有这个标志。像代码段、数据段和.bss段都会有这个标志 |
SHF_EXECINSTR | 4 | 表示该段在进程空间中可以被执行,一般指代码段 |
段的链接信息 :
节链接信息(sh_link、sh_info),如果节的类型是与链接相关的(无论是动态链接还是静态链接),如重定位表、符号表等,则sh_link、sh_info两个成员所包含的意义如下所示。其他类型的节,这两个成员没有意义。
sh_type | sh_link | sh_info |
---|---|---|
SHT_DYNAMIC | 该段所使用的字符串表在段表中的下标 | 0 |
SHT_HASH | 该段所使用的符号表在段表中的下标 | 0 |
SHT_REL | 该段所使用的相应符号表在段表中的下标 | 该重定位表所作用的段在段表中的下标 |
SHT_RELA | 该段所使用的相应符号表在段表中的下标 | 该重定位表所作用的段在段表中的下标 |
SHT_SYMTAB | 操作系统相关 | 操作系统相关 |
SHT_DYNSYM | 操作系统相关 | 操作系统相关 |
other | SHN_UNDEF | 0 |
段地址对齐 :
有些段对段地址有对齐的要求。比如我们假设有个段刚开始的位置包含了一个 double 变量,因为 Inter x86 系统要求浮点数的存储地址必须是本身的整数倍。这样对一个段来说,它的 sh_addr 必须是8的整数倍。
由于地址对齐的数量都是2的整数倍,sh_addralign 表示的是地址对齐数量中的指数。即 sh_addralign = 3表示对齐为8字节。如果 sh_addralign 为0或1,则表示该段没有对齐要求。
1.3 重定位表
链接器在处理目标文件时,需要对目标文件中某些部位进行重定位,即代码段和数据段中那些绝对地址的引用位置。这些重定位的信息都记录在ELF文件的重定位表里面。对于每个需要重定位的代码段或数据段,都会有一个相应的重定位表。
比如 SimpleSection.o 中的 ".rel.text" 就是针对就 ".text" 段的重定位表,因为 ".text" 段中至少有一个绝对地址的引用,那就是对 "printf" 函数的调用;而 ".data" 段则没有绝对地址的引用,它只包含了几个常量,所以没有针对 ".data" 段的重定位表 ".rel.data"。
关于重定位表的内部结构我们在这里就先不展开了,在下一章分析静态链接过程的时候,我们还会详细的分析重定位表的结构。
1.4 字符串表
ELF文件中用到了很多字符串,比如段名、变量名等。因为字符串的长度往往是不定的,所以用固定的结构来表示它比较困难。一种很常见的做法是把字符串集中起来存放到一个表,然后采用字符串在表中的偏移来引用字符串。比如下表:
那么偏移与它们对应的字符串表如下图所示:
通过这种方法,在ELF文件中引用字符串只需给出一个数字下标即可。一般字符串表在ELF文件中也以段的形式保存,常见段名位为".strtab"或".shstrtab"。一个是字符串表,一个是段表字符串表。
例如,在ELF文件头的 "e_shstrndx" 成员,段表字符串表所在的段在段表中的下标(Section header string table index),是10。我们再对应上段表中的下标,发现刚好对应的上。由此,我们可以得出结论,只有分析ELF文件头,就可以得到段表和段表字符串表的位置,从而解析整个ELF文件。
bash
liang@liang-virtual-machine:~/cfp$ readelf SimpleSection.o -p .shstrtab
String dump of section '.shstrtab':
[ 1] .symtab
[ 9] .strtab
[ 11] .shstrtab
[ 1b] .rela.text
[ 26] .data
[ 2c] .bss
[ 31] .rodata
[ 39] .comment
[ 42] .note.GNU-stack
[ 52] .rela.eh_frame
liang@liang-virtual-machine:~/cfp$ readelf SimpleSection.o -p .strtab
String dump of section '.strtab':
[ 1] SimpleSection.c
[ 11] static_var.1840
[ 21] static_var2.1841
[ 32] golobal_init_var
[ 43] golbal_uninit_var
[ 55] func1
[ 5b] printf
[ 62] main
2、链接的接口------符号
链接过程的本质就是要把多个不同的目标文件之间像拼图一样拼起来,这些目标文件必须有像拼图那样的凹凸部分才能够粘合。
例如,目标文件B要用到了目标文件A中的函数 "foo"。那么就称目标文件A定义(Define)了函数 "foo",目标文件B引用(Reference)了目标文件A中的函数 "foo"。在链接中,我们将函数和变量统称为符号(Symbol),函数名或变量名就是符号名(Symbol Name)
每一个目标文件都会有一个相应的符号表(Symbol Table),这个表里记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值叫做符号值(Symbol Value),对于变量和函数来说,符号值就是它们的地址。
我们将符号表中的所有符号进行分类,它们有可能是下面这些类型中的一种:
- 定义在本目标文件中的全局符号,可以被其他目标文件引用。比如SimpleSection.o里面的"func1"、"main"和"global_init_var"。
- 在本目标文件中引用的全局符号,却没有定义在本目标文件中,这一般叫做外部符号。比如SimpleSection.o的"printf"。
- 段名,这种符号往往由编译器产生,它的值就是该段的起始地址。比如SimpleSection.o的".text"等。
- 局部符号,这类符号只在编译单元内部可见。比如SimpleSection.o里面的"static_var"和"static_var2"。调试器可以用使用这些符号来分析程序或崩溃时的核心转储文件。这些局部符号对于链接过程没有作用,链接器往往忽略它们。
- 行号信息,即目标文件指令与源代码中代码行的对应关系,它也是可选的。
对于我们来说,最值得关注的就是全局符号,即上面分类中的第一、第二类。我们可以使用很多工具来查看ELF文件中的符号表,比如readelf、objdump、nm等。
bash
liang@liang-virtual-machine:~/cfp$ readelf -s SimpleSection.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 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 SECTION LOCAL DEFAULT 5
6: 0000000000000004 4 OBJECT LOCAL DEFAULT 3 static_var.1840
7: 0000000000000000 4 OBJECT LOCAL DEFAULT 4 static_var2.1841
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 golobal_init_var
12: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM golbal_uninit_var
13: 0000000000000000 34 FUNC GLOBAL DEFAULT 1 func1
14: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
15: 0000000000000022 51 FUNC GLOBAL DEFAULT 1 main
SimpleSection.c 这个符号表示编译单元的源文件名。
对于那些 STT_SECTION 类型的符号,它们表示下标为 Ndx 的段的段名。它们的符号名没有显示,其实它们的符号名即为它们的段名。
2.1 ELF 符号表结构
ELF文件中的符号表往往是文件中的一个段,段名叫做".symtab"。每个"ELF32_Sym"结构对应一个符号。这个数组的第一个元素,也就是下标0的元素为无效的"未定义"符号。Elf32_Sym的结构定义如下:
c
typedef struct{
Elf32_Word st_name; // 符号名。这个成员包含了该符号名在字符串表中的下标
Elf32_Addr st_value; // 符号相对应的值
Elf32_Word st_size; // 符号大小
unsigned char st_info; // 符号类型(低4位)和绑定信息(高28位)
unsigned char st_other; // 该成员目前为0,没用
Elf32_Half st_shndx; // 符号所在的段
}Elf32_Sym;
符号类型
宏定义名 | 值 | 说明 |
---|---|---|
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_LOCAL | 2 | 弱引用,详见后面章节 |
符号所在段(sh_shndx)如果符号定义在本目标文件中,那么这个成员表示符号所在的段在段表中的下标;如果符号不是定义在本目标文件中,或者对于有些特殊符号,sh_shndx的值有些特殊,如下表所示
宏定义名 | 值 | 说明 |
---|---|---|
SHN_ABS | 0xfff1 | 表示该符号包含了一个绝对的值。比如表示文件名的符号就属于这种类型的 |
SHN_COMMON | 0xfff2 | 表示该符号是一个COMMON块类型的符号,一般来说,未初始化的全局符号定义就是这种类型的 |
SHN_UNDEF | 0 | 表示该符号未定义。这个符号表示该符号在本目标文件被引用到,但是定义在其他目标文件中 |
符号值 :
符号值,我们前面已经介绍过了,每个符号都有一个叫对应的值,如果这个符号是一个函数或变量的定义,那么符号的值就是这个函数或变量的地址,更准确的讲应该按下面这几种情况区别对待。
- 在目标文件中,如果是符号的定义并且该符号不是 "COMMON" 块类型的,则 st_value 表示该符号在段中的偏移。即符号所对应的函数或变量位于由 st_shndx 指定的段,偏移 st_value 的位置。这也是目标文件中定义全局变量的符号最常见的情况。比如 SimpleSection.o 中的"func1"、"main"和"global_init_var"。
- 在目标文件中,如果符号是 "COMMON块" 类型的,则 st_value 表示该符号的对齐属性。比如 SimpleSection.o 中的"global_uninit_var"。
- 在可执行文件中,st_value 表示符号的虚拟地址。这个虚拟地址对于动态连接器来说十分有用。我们会在后面讲解动态链接器相关知识。
readelf 的输出格式与上面描述的 Elf32_Sym 的各个成员几乎一一对应。第一列 Num 表示符号表数组的下标;第二列 Value就是符号值,即 st_value;第三列 Size 为符号大小,即 st_size;第四列、第五列分别为符号类型和绑定信息,即对应 st_info 的低4位和高28位。第六列目前在C/C++语言中未使用,暂时忽略它;第七列 Ndx 即 st_shndx,表示该该符号所属的段;最后一列,符号名称。
例如,fun1 和 main 函数,Ndx 为1,代码段;我们反汇编代码段,结果如下。fun1 的 st_value 值为0000 0000,刚好对应反汇编 fun1 函数地址;而 main 的 st_value 值为0000 0022,刚好对应反汇编main函数地址。
bash
objdump -s -d SimpleSection.o
Disassembly of section .text:
0000000000000000 <func1>:
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 <func1+0x1f>
1f: 90 nop
20: c9 leaveq
21: c3 retq
0000000000000022 <main>:
22: 55 push %rbp
23: 48 89 e5 mov %rsp,%rbp
26: 48 83 ec 10 sub $0x10,%rsp
2a: c7 45 f8 01 00 00 00 movl $0x1,-0x8(%rbp)
31: 8b 15 00 00 00 00 mov 0x0(%rip),%edx # 37 <main+0x15>
37: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 3d <main+0x1b>
3d: 01 c2 add %eax,%edx
3f: 8b 45 f8 mov -0x8(%rbp),%eax
42: 01 c2 add %eax,%edx
44: 8b 45 fc mov -0x4(%rbp),%eax
47: 01 d0 add %edx,%eax
49: 89 c7 mov %eax,%edi
4b: e8 00 00 00 00 callq 50 <main+0x2e>
50: 8b 45 f8 mov -0x8(%rbp),%eax
53: c9 leaveq
54: c3 retq
再比如,static_var 符号,Ndx为3,数据段,st_value值为0000 0004。查看数据段,如下,可以看到,数据段偏移4字节,刚好对应0x00000055, 十进制为 85,为static_var初识化的值。
bash
Contents of section .data:
0000 54000000 55000000 T...U...
Contents of section .rodata:
0000 25640a00 %d..
其它都是如此,有兴趣的话,可以一一对比一下,加深记忆。
3、特殊符号
当我们使用 ld 作为链接器 来链接产生可执行文件时,它会为我们定义很多特殊的符号,这些符号并没有在你的程序中定义,但是你可以直接声明并且引用它,我们称之为特殊符号 。其实这些符号是被定义在 ld 链接器的链接脚本中的,我们在后面的"链接过程控制"这一节会再来回顾这个问题。几个很具有代表性的特殊符号如下:
- _executable_start:该符号为程序的起始地址,注意不是入口地址,是程序最开始的地址
- _etext:该符号为代码段结束地址,即代码段最末尾的地址
- _edata:该符号为数据段结束地址,即数据段最末尾的地址
- _end:该符号为程序结束地址
- 以上地址都为程序被装载时的虚拟地址,我们在装载这一章时再来回顾关于程序被装载后的虚拟地址。
我们可以在程序中直接使用这些符号:
c
/*
* SpecialSymbol.c
*/
#include <stdio.h>
extern char __executable_start[];
extern char etext[], _etext[], __etext[];
extern char edata[], _edata[];
extern char end[], _end[];
int main(int argc, char const *argv[])
{
printf("Executable Start %X\n", __executable_start);
printf("Text End %X %X %X\n", etext, _etext, __etext);
printf("Data End %X %X\n", edata, _edata);
printf("Executable End %X %X\n", end, _end);
return 0;
}
bash
liangjie@liangjie-virtual-machine:~/Desktop/cfp$ ./SpecialSymbol
Excutable Start 403d1000
Text End 403d2205 403d2205 403d2205
Data End 403d5010 403d5010
Executable End 403d5018 403d5018
4、强符号与弱符号
4.1 强符号与弱符号
我们在编写代码的过程中经常会遇到一种叫做符号重复定义(Multiple Definition)的错误,这是因为在多个源文件中定义了名字相同的全局变量,并且都将它们初始化了。这种符号的定义可以被称为强符号。
在C/C++语言中,编译器默认函数和初始化了的全局变量为强符号(Strong Symbol),未初始化的全局变量为弱符号(Weak Symbol)。我们可以通过GCC的 "attribute((weak))" 来定义任何一个强符号为弱符号。注意,强符号和弱符号都是针对定义来说的,不是针对符号的引用。
强符号之所以强,是因为它们拥有确切的数据,变量有值,函数有函数体;弱符号之所以弱,是因为它们还未被初始化,没有确切的数据。
c
extern int ext;
int weak1;
int strong = 100;
__attribute__((weak)) weak2 = 2;
int main(){
return 0;
}
weak1 和 weak2 是弱符号,strong 和 main 是强符号,而 ext 既非强符号也非弱符号,它是一个对外部变量的引用(使用)。
链接器会按照如下的规则处理被多次定义的强符号和弱符号:
- 不允许强符号被多次定义,也即不同的目标文件中不能有同名的强符号;如果有多个强符号,那么链接器会报符号重复定义错误。
- 如果一个符号在某个目标文件中是强符号,在其他文件中是弱符号,那么选择强符号。
- 如果一个符号在所有的目标文件中都是弱符号,那么选择其中占用空间最大的一个。比如目标文件 a.o 定义全局变量 global 为 int 类型,占用4个字节,目标文件 b.o 定义 global 为 double 类型,占用8个字节,那么被链接后,符号 global 占用8个字节。请尽量不要使用多个不同类型的弱符号,否则有时候很难发现程序错误。
需要注意的是,attribute((weak))只对链接器有效,对编译器不起作用,编译器不区分强符号和弱符号,只要在一个源文件中定义两个相同的符号,不管它们是强是弱,都会报"重复定义"错误
4.2 强引用与弱引用
我们知道在编译成可执行文件时,若源文件引用了外部目标文件的符号,在链接过程中,需要找到对应的符号定义,若未找到对应符号(未定义),链接器会报符号位未定义错误,导致编译出错。这种被称为强引用。与相对应的时弱引用(开发者可通过attribute((weakref))声明),链接器在链接符号过程中,若发现符号为弱引用,即使没有找到符号定义,链接时也不会报错,但是会将该引用默认为0;
编译器默认所有的变量和函数为强引用,同时编程者可以使用__attribute__((weakref))来声明一个函数,注意这里是声明而不是定义,既然是引用,那么就是使用其他模块中定义的实体,对于函数而言,我们可以使用这样的写法:
c
__attribute__((weakref)) void func(void);
void main(void)
{
if(func) {func();}
}
bash
liang@liang-virtual-machine:~/cfp$ gcc weakref.c
weakref.c:1:31: warning: 'weakref' attribute should be accompanied with an 'alias' attribute [-Wattributes]
警告显示:weakref 需要伴随着一个别名才能正常使用
warning的原因是:
- weakref需要伴随着一个别名,别名不需要带函数参数,如果对象函数没有定义,我们可以使用别名来实现函数的定义工作,如果不指定别名,weakref作用等于weak。
- weakref 的声明必须为静态
改成这样即可:
c
static __attribute__((weakref("test"))) void func(void);
void main(void)
{
if(func) {func();}
}
我们看一个弱引用简单的例子:
c
/* test.c */
#include <stdio.h>
static __attribute__((weakref("test"))) void weak_ref(void);
void test_func(void)
{
if(weak_ref){
weak_ref();
}
else{
printf("weak ref function is null\n");
}
}
c
/* main.c */
#include <stdio.h>
#include <stdarg.h>
#include "test.h"
void test(void)
{
printf("running custom weak ref function!\n");
}
int main()
{
test_func();
return 0;
}
bash
liang@liang-virtual-machine:~/cfp$ gcc main.c test.c -o we
liang@liang-virtual-machine:~/cfp$ ./we
running custom weak ref function!
如果在main.c中去除 weak_ref 的定义,函数的执行结果是这样的:
bash
liang@liang-virtual-machine:~/cfp$ gcc weakref.c weakref_test.c -o we
liang@liang-virtual-machine:~/cfp$ ./we
weak ref function is null
弱引用在库的使用上十分有用的。
4.4 关于 weak 和 weakref
weak 和 weakref 的使用场景可能有下面两种:
- 我们需要编写一个库,库中定义的弱符号可以被用户定义的强符号所覆盖,从而使得程序可以使用自定义版本的库函数
- 程序在未正常链接某个库时也可以正常运行,不报错。
第一个场景可以使用weak实现,只需要使用__attribute__((weak))将库中的符号设置为弱符号即可。
第二个场景可以使用weak,也可以使用weakref实现,做法就是在主程序中将引用的符号定义为弱符号,如下代码
c
static __attribute__((weakref("foo"))) void bar();
int main()
{
if (bar) {
bar();
//...
}
}
c
__attribute__((weak)) void foo();
int main()
{
if (foo) {
foo();
}
}
4.5 关于动态库强弱符号
弱强符号的覆盖规则总结:
其实只有一个: 强弱符号的区别仅仅在静态链接时有效,下面的是细分
- 只针对要被链接的目标文件集合,比如多个目标文件拥有相同的强符号,链接会报错,然而一个目标文件有强符号,其他的静态库与动态库有相同的强符号,这并不会有任何问题。
- 对于链接静态库默认规则是无任何影响的(注意这里说的是默认),也就是无论强弱符号都链接器一视同仁,当匹配到第一个符号则从静态库找到定义该符号的目标文件,后续即使有相同符号也会被忽略
- 对于标准的的动态链接器来说,强弱符号无任何影响,链接器只会保留第一个链接动态库汇总的函数,忽略后来链接动态库的同名函数。
- 然而在glibc 2.1.91版本之前确实存在强符号替换到弱符号,但其行为是不符合标准规则,标准规则规定了强弱符号的区别仅仅在静态链接时有效。
- 因而在glibc 2.2版本之后都属于标准规则。因而现在的glibc版本默认搜到到第一个符号(不关心是否是强弱)则忽略后续的,如果想恢复到之前的glibc老版本不标准的行为可以使用 LD_DYNAMIC_WEAK,但对于自定义库强符号与 glibc 的强符号是否覆盖,还是会跟链接顺序有关。
4.6 总结
经过上面的描述,我们了解到了强符号,弱符号,强引用,弱引用的概念。我认为起码有两点特性可以在我们工作中使用:
- 强符号可以替换弱符号。
- 弱引用可以避免函数未定义的错误。
强符号替换弱符号
一些库中对外接口可以声明为弱符号。比如:
在 math 库中,我们发现 add(int num1, int num2) 这个接口存在问题,那我们解决方式一般有以下几种:
- 实现一个 myadd(int num1,int num2) 接口,之后再将项目中的所有 add 替换为 myadd。这种方式可行,但是存在缺点:修改量大,并且后续人员不清楚背景,很有可能继续使用熟悉的 add 接口。
- 更新 math 库,从更本解决此问题。这种方式比较推荐。但是也并不是通用的,比如有些库并不是开源的,并且已经过了支持日期,也就不适用了。
此时,我们可以自己在项目中定义一个 add(int num1,int num2) 接口,用强符号替换库中的弱符号,这样改动是比较小的。(这种情景需要了解接口的实现内容,可给调用者较高的重构权力)
巧用弱引用提高代码的健壮性
例如,在库中,我们需要调用其他函数,而不知道这个函数库外是否需要实现,我们就可以把该函数定义成弱引用,这样就将主动权给了库外;
库外定义了函数,就引用;库外不定义该函数,也不会链接报错,提高了项目的健壮性。
5、调试信息
目标文件里面还有可能保存的是调试信息。几乎所有现代的编译器都支持源代码级别的调试。比如我们可以设置断点,监控变量的变化,可以单步行进等,前提是编译器必须提前将源代码与目标文件之间的关系做好,比如目标代码中的函数地址对应源代码中的哪一行等。
如果我们在GCC编译时加上 "-g" 参数,编译器就会产生很多目标文件里面加上调试信息,我们通过 readelf 等工具可能看到,目标文件里面多了很多 "debug" 的相关段:
bash
[ 6] .debug_info PROGBITS 0000000000000000 000000a4
00000000000000ed 0000000000000000 0 0 1
[ 7] .rela.debug_info RELA 0000000000000000 000006c0
00000000000001b0 0000000000000018 I 19 6 8
[ 8] .debug_abbrev PROGBITS 0000000000000000 00000191
0000000000000093 0000000000000000 0 0 1
[ 9] .debug_aranges PROGBITS 0000000000000000 00000224
0000000000000030 0000000000000000 0 0 1
[10] .rela.debug_arang RELA 0000000000000000 00000870
0000000000000030 0000000000000018 I 19 9 8
[11] .debug_line PROGBITS 0000000000000000 00000254
000000000000004a 0000000000000000 0 0 1
[12] .rela.debug_line RELA 0000000000000000 000008a0
0000000000000018 0000000000000018 I 19 11 8
[13] .debug_str PROGBITS 0000000000000000 0000029e
00000000000000b5 0000000000000001 MS 0 0 1
[14] .comment PROGBITS 0000000000000000 00000353
0000000000000036 0000000000000001 MS 0 0 1
[15] .note.GNU-stack PROGBITS 0000000000000000 00000389
0000000000000000 0000000000000000 0 0 1
[16] .eh_frame PROGBITS 0000000000000000 00000390
0000000000000058 0000000000000000 A 0 0 8
[17] .rela.eh_frame RELA 0000000000000000 000008b8
0000000000000030 0000000000000018 I 19 16 8
[18] .shstrtab STRTAB 0000000000000000 000008e8
00000000000000b0 0000000000000000 0 0 1
[19] .symtab SYMTAB 0000000000000000 000003e8
00000000000001f8 0000000000000018 20 16 8
这些段中保存的就是调试信息。现在的ELF文件采用一个叫做 DWARF(Debug With Arbitrary Record Format)的标准的调试信息格式。关于调试信息的具体内容我们在这里不再详细展开了,它将是另外一个独立的并且很大的话题,对我们理解整个系统软件的意义不大。
值得一提的是,调试信息在目标文件和可执行文件中占有很大的空间,往往比程序的代码和数据本身大好几倍,所以当我们开发完程序要将它发布的时候,需要把这些对于用户没有用的调试信息去掉,以节省大量的空间。在 linux 下,我们可以使用 "strip" 命令来去掉ELF文件中的调试信息。
bash
liang@liang-virtual-machine:~/cfp$ strip SimpleSection.o