链接装载与库:第三章——目标文件里有什么(ELF文件结构)
文章目录
- 一、ELF文件的格式
- 二、ELF文件是什么样的
- 三、挖掘SimpleSection.o
- 3.1 代码段
- 3.2 数据段和只读数据段
- 3.3 BSS段
- 3.4 其他段
- 3.5 自定义段
- 四、ELF文件结构描述
- 4.1 文件头
- 4.2 段表
- 4.3 重定位表
- 4.4 字符串表
- 五、链接的接口——符号
- 5.1 ELF符号表结构
- 5.2 特殊符号
- 5.3 符号修饰与函数名
- 5.4 extern "C"
- 5.5 弱符号与强符号
一、ELF文件的格式
Linux下符合ELF格式的文件主要有四种:可重定位文件(目标文件)、可执行文件、共享目标文件、核心转储文件。
- 可重定位文件,也常叫目标文件。包含二进制代码和数据,其可以在编译时与其他可重定位目标文件合并起来,创建一个可执行文件
- 可执行文件。包含二进制代码和数据,其形式可以被直接拷贝到存储器并执行
- 共享目标文件。一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载到存储器并链接
- 核心转储文件。当进程意外终止时,系统可以将该进程的地址空间的内容及终止时的一些其他信息转储到核心转储文件。
目标文件就是源代码编译后但未进行链接的那些中间文件(Linux下的.o
文件),它跟可执行文件的内容与结构很相似,所以一般跟可执行文件格式一起采用一种格式存储。从广义上看,目标文件与可执行文件的格式其实几乎是一样的,所以我们可以广义地将目标文件与可执行文件看成是一种类型的文件。
在Linux 下使用file file-name
命令来查看相应的文件格式:
$ ls
objdump_h.log objdump_s_d.log SimpleSection.c SimpleSection.o
$ file objdump_h.log
objdump_h.log: UTF-8 Unicode text
$ file SimpleSection.o
SimpleSection.o: ELF 64-bit LSB relocatable, ARM aarch64, version 1 (SYSV), not stripped
$ file SimpleSection.c
SimpleSection.c: C source, ASCII text
二、ELF文件是什么样的
ELF 格式被用于描述目标文件、可执行文件、核心转储文件以及共享库的所有信息。无论在什么场合,使用 ELF 格式的目的只有一个,那就是把机器代码及其对应的元数据以方便链接器和加载器处理的形式保存起来。代码的元数据指的是如下的信息:
- 代码文件的大小以及转换前的源代码文件名
- 符号信息(存放在符号表)
- 重定位信息
- 调试信息
如果以程序头(program header)信息来处理,则 ELF 文件可以解释成段集合;如果以节头(section header,也就是段表)信息来处理,则可以解释成节集合。
- 节(section)是汇编器、链接器等处理 ELF 文件内容的单位。ELF 文件把不同目的的代码、数据等分割成节保存。譬如机器码统一保存到
.text
节中,全局变量的初始化数据则保存在.data
节中。 - 段(segment)则是把程序加载到内存的加载器处理 ELF 文件时的单位。段由 1 个以上的节构成。内存上不同范围有着只读、可写、可执行等不同属性,因而需要根据属性进行分段。譬如机器码如果不可执行就毫无意义,因此要统一到具有可执行属性的段中。
节和段本质上都表示一个一定长度的区域。如果是可重定位文件(目标文件),内容叫做节的集合;如果是可执行文件、共享目标文件、核心转储文件,内容叫做段的集合。后面内容都将其叫做段,注意区分。
上图就是ELF文件的结构,可以看到,ELF文件的开头是一个文件头(readelf -h elf-file
命令可查看文件头内容),它描述了整个文件的文件属性,包括文件是否可执行、是静态链接还是动态链接及入口地址(如果是可执行文件)、目标硬件、目标操作系统等信息。
文件头还包括一个段表(Section Table,也就是section header)(readelf -S elf-file
命令可查看段表结构),段表其实是一个描述文件中各个段的数组。段表描述了文件中各个段在文件中的偏移位置及段的属性等,从段表里面可以得到每个段的所有信息。
文件头后面就是各个段的内容。程序源代码编译后的机器指令经常被放在代码段(Code Section),代码段常见的名字有.code
或.text
;全局变量和局部静态变量数据经常放在数据段(Data Section),数据段的一般名字都叫.data
。
一般C语言的编译后执行语句都编译成机器代码,保存在.text
段;已初始化的全局变量和局部静态变量都保存在.data
段;未初始化的全局变量和局部静态变量一般放在一个叫.bss
的段里。未初始化的全局变量和局部静态变量默认值都为0,本来它们也可以被放在.data
段,但是因为它们都是0,所以为它们在.data
段分配空间并且存放数据0是没有必要的。程序运行的时候它们的确是要占内存空间的,并且可执行文件必须记录所有未初始化的全局变量和局部静态变量的大小总和,记为.bss
段。所以.bss
段只是为未初始化的全局变量和局部静态变量预留位置而已,它并没有内容,所以它在文件中也不占据空间。
总体来说,程序源代码被编译以后主要分成两种段:程序指令和程序数据。代码段属于程序指令,而数据段和.bss
段属于程序数据。 让数据和指令分段主要有以下好处:
- 一方面是当程序被装载后,数据和指令分别被映射到两个虚存区域。由于数据区域对于程序来说是可读写的,而指令区域对于进程来说是只读的,所以这两个虚存区域的权限可以被分别设置成可读写和只读。这样可以防止程序的指令被有意或无意地改写。
- 另外一方面是对于现代的CPU来说,它们有着极为强大的缓存(Cache)体系。由于缓存在现代的计算机中地位非常重要,所以程序必须尽量提高缓存的命中率。指令区和数据区的分离有利于提高程序的局部性。现代CPU的缓存一般都被设计成数据缓存和指令缓存分离,所以程序的指令和数据被分开存放对CPU的缓存命中率提高有好处。
- 第三个原因,其实也是最重要的原因,就是当系统中运行着多个程序的副本时,它们的指令都是一样的,所以内存中只须要保存一份该程序的指令部分。对于指令这种只读的区域来说是这样,对于其它的只读数据也一样,比如很多程序里面带有的图标、图片、文本等资源也是属于可以共享的。当然每个副本进程的数据区域是不一样的,它们是进程私有的。不要小看这个共享指令的概念,它在现代的操作系统里面占据了极为重要的地位,特别是在有动态链接的系统中,可以节省大量的内存。
三、挖掘SimpleSection.o
SimpleSection.c文件内容:
int printf(const char* format, ...);int global_init_var = 84;
int global_uninit_var;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);return a;
}
执行$ gcc -c SimpleSection.c
会生成目标文件SimpleSection.o
,执行objdump -h SimpleSection.o
,打印SimpleSection.o
文件的各个段信息:
$ objdump -h SimpleSection.oSimpleSection.o: 文件格式 elf64-littleaarch64节:
Idx Name Size VMA LMA File off Algn0 .text 00000074 0000000000000000 0000000000000000 00000040 2**2CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE1 .data 00000008 0000000000000000 0000000000000000 000000b4 2**2CONTENTS, ALLOC, LOAD, DATA2 .bss 00000004 0000000000000000 0000000000000000 000000bc 2**2ALLOC3 .rodata 00000004 0000000000000000 0000000000000000 000000c0 2**3CONTENTS, ALLOC, LOAD, READONLY, DATA4 .comment 00000012 0000000000000000 0000000000000000 000000c4 2**0CONTENTS, READONLY5 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000d6 2**0CONTENTS, READONLY6 .eh_frame 00000060 0000000000000000 0000000000000000 000000d8 2**3CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
打印的段中除了最基本的代码段(.text
)、数据段(.data
)和BSS段(.bss
)以外,还有只读数据段(.rodata
)、注释信息段(.comment
)、堆栈提示段(.note.GNU-stack
)、异常处理段(.eh_frame
)。
Size为段的长度,File off为段所在的位置;CONTENTS、ALLOC等表示段的各种属性;CONTENTS表示该段在文件中存在。BSS段没有CONTENTS,表示它实际上在ELF文件中不存在内容。.note.GNU-stack
段虽然有CONTENTS,但它的长度为0,是个很古怪的段,暂且认为它在ELF文件中也不存在。那么ELF文件中实际存在的是.text
、.data
、.rodata
、.comment
、.note.gnu.property
、.eh_frame
段。
下图是SimpleSection.o
文件各个段的基本布局:
3.1 代码段
objdump的-s
参数可以将所有段的内容以十六机制的方式打印出来,-d
参数可以将所有包含指令的段反汇编。以下是AArch64架构下objdump -s -d SimpleSection.o
指令的部分输出:
Idx Name Size VMA LMA File off Algn0 .text 00000074 0000000000000000 0000000000000000 00000040 2**2CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODEContents 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 a01f40b9 fd7bc2a8 .........@..{..0070 c0035fd6 .._.
...
0000000000000000 <func1>:0: a9be7bfd stp x29, x30, [sp, #-32]!4: 910003fd mov x29, sp8: b9001fa0 str w0, [x29, #28]...0000000000000028 <main>:28: a9be7bfd stp x29, x30, [sp, #-32]!2c: 910003fd mov x29, sp30: 52800020 mov w0, #0x1 // #134: b9001fa0 str w0, [x29, #28]...
Contents of section .text就是.text
的数据以十六进制方式打印出来的内容,总共0x74
字节,跟前面执行-h
中.text
段长度相符合,最左面一列是偏移量,中间4列是十六进制内容,最右面一列是.text
段的ASCII码形式。对照下面的反汇编结果,可以明显地看到,.text
段里面所包含的正是SimpleSection.c里两个函数func1()
和main()
的指令。.text
段的前四个字节0xfd7bbea9
就是func1()
函数的第一条stp x29, x30, [sp, #-32]!
指令,而最后四个字节0xc0035fd6
正是main()
函数的最后一条指令ret
(注意这里是小端排列)。
3.2 数据段和只读数据段
.data
段保存的是那些已经初始化了的全局静态变量和局部静态变量。SimpleSection.c代码里面一共有两个这样的变量,分别是global_init_var
和static_var
,这两个变量每个4个字节,一共刚好8个字节,所以.data
这个段的大小为8个字节。
.data
段里的前4个字节,从低到高分别为0x54,0x00,0x00,0x00
。这个值刚好是global_init_var
,即十进制的84。global_init_var是个4字节长度的int类型,为什么存放的次序是0x54,0x00,0x00,0x00
而不是0x00,0x00,0x00,0x54
?这涉及CPU的字节序(Byte Order)的问题,也就是所谓的大端(Big-endian)和小端(Little-endian)的问题。而最后4个字节刚好是static_var
的值,即85.
Idx Name Size VMA LMA File off Algn1 .data 00000008 0000000000000000 0000000000000000 000000b4 2**2CONTENTS, ALLOC, LOAD, DATAContents of section .data:0000 54000000 55000000 T...U...
SimpleSection.c里面在调用printf
的时候,用到了一个字符串常量%d\n
,它是一种只读数据,所以它被放到了.rodata
段,我们可以从输出结果看到.rodata
这个段的4个字节刚好是这个字符串常量的ASCII字节序,最后以"\0
"结尾。
.rodata
段存放的是只读数据,一般是程序里面的只读变量(如const修饰的变量)和字符串常量。单独设立.rodata
段有很多好处,不光在语义上支持了C++的const关键字,而且操作系统在加载的时候可以将.rodata
段的属性映射成只读,这样对于这个段的任何修改操作都会作为非法操作处理,保证了程序的安全性。另外在某些嵌入式平台下,有些存储区域是采用只读存储器的,如ROM,这样将.rodata
段放在该存储区域中就可以保证程序访问存储器的正确性。
Idx Name Size VMA LMA File off Algn3 .rodata 00000004 0000000000000000 0000000000000000 000000c0 2**3CONTENTS, ALLOC, LOAD, READONLY, DATAContents of section .rodata:0000 25640a00 %d..
另外值得一提的是,有时候编译器会把字符串常量放到.data
段,而不会单独放在.rodata
段。
3.3 BSS段
.bss
段存放的是未初始化的全局变量和局部静态变量。global_uninit_var
和static_var2
就是存放在.bss
段,其实更准确的说法是.bss
段为它们预留了空间。但是我们可以看到该段的大小只有4个字节,这与global_uninit_var
和static_var2
的大小的8个字节不符。其实我们可以通过符号表(Symbol Table)看到,只有static_var2
被存放了.bss段,而global_uninit_var
却没有被存放在任何段,只是一个未定义的COMMON符号。这其实是跟不同的语言与不同的编译器实现有关,有些编译器会将全局的未初始化变量存放在目标文件.bss
段,有些则不存放,只是预留一个未定义的全局变量符号,等到最终链接成可执行文件的时候再在.bss
段分配空间。
Idx Name Size VMA LMA File off Algn2 .bss 00000004 0000000000000000 0000000000000000 000000bc 2**2ALLOC
3.4 其他段
除了.text
、.data
、.bss
这3个最常用的段之外,ELF文件也有可能包含其它的段,用来保存与程序相关的其它信息,如下图所示:
这些段的名字都是由.
作为前缀,表示这些表的名字是系统保留的,应用程序也可以使用一些非系统保留的名字作为段名。比如我们可以在ELF文件中插入一个music的段,里面存放了一首MP3音乐,当ELF文件运行起来以后可以读取这个段播放这首MP3。但是应用程序自定义的段名不能使用.作为前缀,否则容易跟系统保留段名冲突。一个ELF文件也可以拥有几个相同段名的段,比如一个ELF文件中可能有两个或两个以上叫做.text的段。还有一些保留的段名是因为ELF文件历史遗留问题造成的,以前用过的一些名字如.sdata
、.tdesc
、.sbss
、.lit4
、.lit8
、.reginfo
、.gptab
、.liblist
、.conflict
。可以不用理会这些段,它们已经被遗弃了。
使用objcopy工具给ELF文件添加段:
- 添加一个自定义的段到ELF文件,从而产生一个新的ELF文件,段的内容由一个文件指定:
objcopy --add-section section_name=file_name elf_file new_elf_file
- 将ELF文件中指定的段拷贝出来,存放到一个文件中:
objcopy --only-section=section_name elf_file copy_file
- 在ELF文件中删除一个指定名称的段:
objcopy -R section_name elf_file new_elf_file
3.5 自定义段
正常情况下,GCC编译出来的目标文件中,代码会被放到.text
段,全局变量和静态变量会被放到.data
和.bss
段。但是有时候你可能希望变量或某些部分代码能够放到你所指定的段中去,以实现某些特定的功能。比如为了满足某些硬件的内存和I/O的地址布局,或者是像Linux操作系统内核中用来完成一些初始化和用户空间复制时出现页错误异常等。GCC提供了一个扩展机制,使得程序员可以指定变量所处的段:
__attribute__((section(FOO))) int global = 42;
__attribute__((section(BAR))) void foo() {}
我们在全局变量或函数之前加上__attribute__((section(name)))
属性就可以把相应的变量或函数放到以name
作为段名的段中。
四、ELF文件结构描述
ELF目标文件格式的最前部是ELF文件头(ELF Header),它包含了描述整个文件的基本属性,比如ELF文件版本、目标机器型号、程序入口地址等。紧接着是ELF文件各个段。其中ELF文件中与段有关的重要结构就是段表(Section Header Table),该表描述了ELF文件包含的所有段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其它属性。
4.1 文件头
可以使用readelf -h elf_file
查看ELF文件头,如下图所示(这里是Arm64,同下文中的Intel 80386部分内容对不上,但不影响理解):
从输出结果可以看到,ELF的文件头中定义了ELF魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI版本、ELF重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置和长度及段的数量等。
ELF文件头结构及相关常数被定义在/usr/include/elf.h
里,因为ELF文件在各种平台下通用,ELF文件有32位版本和64位版本。它的文件头结构也有这两种版本,分别叫做Elf32_Ehdr
和Elf64_Ehdr
。32位版本与64位版本的ELF文件的文件头内容是一样的,只不过有些成员的大小不一样。为了对每个成员的大小做出明确的规定以便于在不同的编译环境下都拥有相同的字段长度,elf.h
使用typedef
定义了一套自己的变量体系,如下图所示:
64位版本的文件头结构Elf64_Ehdr
定义如下:
#define EI_NIDENT (16)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文件头结构跟前面readelf -h SimpleSection.o
输出的ELF文件头信息相比照,可以看到输出的信息与ELF文件头中的结构很多都一一对应。有点例外的是Elf64_Ehdr
中的e_ident
这个成员对应了readelf输出结果中的类别(Class)
、数据(Data)
、Version
、OS/ABI
和ABI Version
这5个参数。剩下的参数与Elf64_Ehdr
中的成员基本一一对应。下图中是ELF文件头中各个成员的含义与readelf输出结果的对照表:
ELF魔数:可以从前面readelf的输出看到,最前面的Magic的16个字节刚好对应Elf64_Ehdr
的e_ident
这个成员。这16个字节被ELF标准规定用来标识ELF文件的平台属性,比如这个ELF字长(32位/64位)、字节序、ELF文件版本。最开始的4个字节是所有ELF文件都必须相同的标识码,分别为0x7F、0x45、0x4c、0x46,第一个字节对应ASCII字符里面的DEL控制符,后面3个字节刚好是ELF这3个字母的ASCII码。这4个字节又被称为ELF文件的魔数,几乎所有的可执行文件格式的最开始的几个字节都是魔数。比如a.out格式最开始两个字节为0x01、0x07;PE/COFF文件最开始两个字节为0x4d、0x5a,即ASCII字符MZ。这种魔数用来确认文件的类型,操作系统在加载可执行文件的时候会确认魔数是否正确,如果不正确会拒绝加载。接下来的一个字节是用来标识ELF的文件类的,0x01表示是32位的,0x02表示是64位的;第6个字节是字节序,规定该ELF文件是大端的还是小端的。第7个字节规定ELF文件的主版本号,一般是1,因为ELF标准自1.2版以后就再也没有更新了。后面的9个字节ELF标准没有定义,一般填0,有些平台会使用这9个字节作为扩展标志。
文件类型:e_type
成员表示ELF文件类型,有3种ELF文件类型,每个文件类型对应一个常量。系统通过这个常量来判断ELF的真正文件类型,而不是通过文件的扩展名。相关常量以ET_
开头,如下图所示:
机器类型:ELF文件格式被设计成可以在多个平台下使用。这并不表示同一个ELF文件可以在不同的平台下使用,而是表示不同平台下的ELF文件都遵循同一套ELF标准。e_machine
成员就表示该ELF文件的平台属性,相关常量以EM_
开头,如下图所示:
4.2 段表
ELF文件中有很多各种各样的段,这个段表(Section Header Table)就是保存这些段的基本属性的结构。段表是ELF文件中除了文件头以外最重要的结构,它描述了ELF的各个段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其它属性。也就是说,ELF文件的段结构就是由段表决定的,编译器、链接器和装载器都是依靠段表来定位和访问各个段的属性的。段表在ELF文件中的位置由ELF文件头的e_shoff
成员决定。
objdump -h
命令只是把ELF文件中关键的段显示了出来,而省略了其它的辅助性的段,比如:符号表、字符串表、段名字符串表、重定位表等。可以使用readelf -S
工具来查看ELF文件的段,它显示出来的结果才是真正的段表结构,如下图所示:
readelf输出的结果就是ELF文件段表的内容,starting at offset 0x4f8
与readelf -h
中的Start of section headers:1272
相对应。段表的结构比较简单,它是一个以Elf64_Shdr
结构体为元素的数组。数组元素的个数等于段的个数,每个Elf64_Shdr
结构体对应一个段。Elf64_Shdr
又被称为段描述符(Section Descriptor)。对于SimpleSection.o来说,段表就是有13个元素的数组。ELF段表的这个数组的第一个元素是无效的段描述符,它的类型为NULL,除此之外每个段描述符都对应一个段。也就是说SimpleSection.o共有12个有效的段。
ELF文件里面很多地方采用了这种与段表类似的数组方式保存。一般定义一个固定长度的结构,然后依次存放。这样我们就可以使用下标来引用某个结构。Elf64_Shdr
被定义在/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;
结构体Elf64_Shdr
的各个成员的含义如下图所示:
【注1】事实上段的名字对于编译器、链接器来说是有意义的,但是对于操作系统来说并没有实质的意义,对于操作系统来说,一个段该如何处理取决于它的属性和权限,即由段的类型和段的标志位这两个成员决定。
段的类型(sh_type)
段的名字只是在链接和编译过程中有意义,但它不能真正地表示段的类型。我们也可以将一个数据段命名为.text
,对于编译器和链接器来说,主要决定段的属性的是段的类型(sh_type)和段的标志位(sh_flags)。段的类型相关常量以SHT_
开头,列举如下图所示:
段的标志位(sh_flag)
表示该段在进程虚拟地址空间中的属性,比如是否可写,是否可执行等。相关常量以SHF_
开头,如下图所示:
对于系统保留段,下图列举了他们的属性:
段的链接信息(sh_link、sh_info)
如果段的类型是与链接相关的(不论是动态链接或静态链接),比如重定位表、符号表等,那么sh_link
和sh_info
这两个成员所包含的意义如下图所示。对于其它类型的段,这两个成员没有意义。
4.3 重定位表
SimpleSenction.o中有一个叫做.rela.text
(注:书中为.rel.text
)的段,它的类型(sh_type)为SHT_RELA,也就是说它是一个重定位表(Relocation Table)。链接器在处理目标文件时,须要对目标文件中某些部位进行重定位,即代码段和数据段中那些对绝对地址的引用的位置。这些重定位的信息都记录在ELF文件的重定位表里面,对于每个须要重定位的代码段或数据段,都会有一个相应的重定位表。比如SimpleSection.o中的.rela.text
就是针对.text
段的重定位表,因为.text
段中至少有一个绝对地址的引用,那就是对printf
函数的调用;而.data
段则没有对绝对地址的引用,它只包含了几个常量,所以SimpleSection.o中没有针对.data
段的重定位表.rela.data
。一个重定位表同时也是ELF的一个段,那么这个段的类型(sh_type)就是SHT_RELA类型的,它的sh_link表示符号表的下标,它的sh_info
表示它作用于哪个段。比如.rela.text
作用于.text
段,而.text
段的下标为1,那么.rela.text
的sh_info
为1。
4.4 字符串表
ELF文件中用到了很多字符串,比如段名、变量名等。因为字符串的长度往往是不定的,所以用固定的结构来表示它比较困难。一种很常见的做法是把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串。
通过这种方法,在ELF文件中引用字符串只需给出一个数字下标即可,不用考虑字符串长度的问题。一般字符串表在ELF文件中也以段的形式保存,常见的段名为.strtab
或.shstrtab
。这两个字符串表分别为字符串表(String Table)和段表字符串(Section Header String Table)。顾名思义,字符串表用来保存普通的字符串,比如符号的名字;段表字符串表用来保存段表中用到的字符串,最常见的就是段名(sh_name)。
现在再看ELF文件头中的e_shstrndx
,e_shstrndx
是Elf64_Ehdr
的最后一个成员,它是Section header string table indiex的缩写。段表字符串表本身也是ELF文件中的一个普通的段,它的名字往往叫做.shstrtab
。那么这个e_shstrndx
就表示.shstrtab
在段表中的下标,即段表字符串表在段表中的下标。
五、链接的接口——符号
链接过程的本质就是要把多个不同的目标文件之间相互粘到一起,或者说像玩具积木一样,可以拼装形成一个整体。为了使不同目标文件之间能够相互粘合,这些目标文件之间必须有固定的规则才行,就像积木模块必须有凹凸部分才能够拼合。在链接中,目标文件之间相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。比如目标文件B要用到了目标文件A中的函数foo
,那么我们就称目标文件A定义(Define)了函数foo
,称目标文件B引用(Reference)了目标文件A中的函数foo
。这两个概念也同样适用于变量。每个函数或变量都有自己独特的名字,才能避免链接过程中不同变量和函数之间的混淆。在链接中,我们将函数和变量统称为符号(Symbol),函数名或变量名就是符号名(Symbol Name)。
我们可以将符号看做是链接中的粘合剂,整个链接过程正是基于符号才能够正确完成。链接过程中很关键的一部分就是符号的管理,每一个目标文件都会有一个相应的符号表(Symbol Table),这个表里面记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值,叫做符号值(Symbol Value),对应变量和函数来说,符号值就是它们的地址。除了函数和变量之外,还存在其它几种不常用到的符号。我们将符号表中所有的符号进行分类,它们有可能是下面这些类型中的一种:
- 定义在本目标文件的全局符号,可以被其它目标文件引用。比如SimpleSection.o里面的
func1
、main
和global_init_var
。 - 在本目标文件中引用的全局符号,却没有定义在本目标文件,这一般叫做外部符号(External Symbol),也就是我们前面所讲的符号引用。比如SimpleSection.o里面的
printf
。 - 段名,这种符号往往由编译器产生,它的值就是该段的起始地址。比如SimpleSection.o里面的
.text
、.data
等。 - 局部符号,这类符号只在编译单元内部可见。比如SimpleSection.o里面的
static_var
和static_var2
。调试器可以使用这些符号来分析程序或崩溃时的核心转储文件。这些局部符号对于链接过程没有作用,链接器往往也忽略它们。 - 行号信息,即目标文件指令与源代码中代码行的对应关系,它也是可选的。
对于我们来说,最值得关注的就是全局符号,即上面分类中的第一类和第二类。因为链接过程只关心全局符号的相互粘合,局部符号、段名、行号等都是次要的,它们对于其它目标文件来说是不可见的,在链接过程中也是无关紧要的。我们可以使用很多工具来查看ELF文件的符号表,比如readelf -s SimpleSection.o
、objdump -t SimpleSection.o
、nm SimpleSection.o
等。
5.1 ELF符号表结构
ELF文件中的符号表往往是文件中的一个段,段名一般叫.symtab
。符号表的结构很简单,它是一个Elf64_Sym
结构(64位ELF文件)的数组,每个Elf64_Sym
结构对应一个符号。这个数组的第一个元素,也就是下标 0 的元素为无效的未定义符号。Elf64_Sym
的结构定义如下:
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;
这几个成员的定义如下表所示:
符号类型和绑定信息(st_info):该成员低4位表示符号的类型(Symbol Type),高28位表示符号绑定信息(Symbol Binding),如下图所示:
符号所在段(st_shndx):如果符号定义在本目标文件中,那么这个成员表示符号所在的段在段表中的下标;但是如果符号不是定义在本目标文件中,或者对于有些特殊符号,sh_shndx
的值有些特殊,如下图所示:
符号值(st_value):每个符号都有一个对应的值,如果这个符号是一个函数或变量的定义,那么符号的值就是这个函数或变量的地址,更准确地讲应该按下面这几种情况区别对待:
- 在目标文件中,如果是符号的定义并且该符号不是
COMMON
块类型的(即st_shndx
不为SHN_COMMON
),则st_value
表示该符号在段中的偏移。即符号所对应的函数或变量位于由st_shndx
指定的段,偏移st_value
的位置。这也是目标文件中定义全局变量的符号的最常见情况,比如SimpleSection.o中的func1
、main
、global_init_var
。 - 在目标文件中,如果符号是
COMMON
块类型的(即st_shndx
为SHN_COMMON
),则st_value
表示该符号的对齐属性。比如SimpleSection.o中的global_uninit_var
。 - 在可执行文件中,
st_value
表示符号的虚拟地址。这个虚拟地址对于动态链接器来说十分有用。
SimpleSection.o中的符号如下图所示:
[ARM64-01 cpp-file]$ readelf -s SimpleSection.oSymbol table '.symtab' contains 21 entries:Num: Value Size Type Bind Vis Ndx Name0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 FILE LOCAL DEFAULT ABS SimpleSection.c2: 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 $d6: 0000000000000000 0 SECTION LOCAL DEFAULT 5 7: 0000000000000000 0 NOTYPE LOCAL DEFAULT 5 $d8: 0000000000000000 0 NOTYPE LOCAL DEFAULT 1 $x9: 0000000000000004 4 OBJECT LOCAL DEFAULT 3 static_var.311310: 0000000000000000 4 OBJECT LOCAL DEFAULT 4 static_var2.311411: 0000000000000000 0 NOTYPE LOCAL DEFAULT 4 $d12: 0000000000000000 0 SECTION LOCAL DEFAULT 7 13: 0000000000000014 0 NOTYPE LOCAL DEFAULT 8 $d14: 0000000000000000 0 SECTION LOCAL DEFAULT 8 15: 0000000000000000 0 SECTION LOCAL DEFAULT 6 16: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 global_init_var17: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM global_uninit_var18: 0000000000000000 40 FUNC GLOBAL DEFAULT 1 func119: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf20: 0000000000000028 76 FUNC GLOBAL DEFAULT 1 main
readelf的输出格式与上面描述的Elf64_Sym
的各个成员几乎一一对应,第一列Num表示符号表数组的下标,从0开始,共20个符号;第二列Value就是符号值,即st_value
;第三列Size为符号大小,即st_size
;第四列和第五列分别为符号类型和绑定信息,即对应st_info
的地4位和高28位;第六列Vis目前在C/C++语言中未使用,我们可以暂时忽略它;第七列Ndx即st_shndx
,表示该符号所属的段;最后一列即符号名称。从上面的输出可以看到,第一个符号,即下标为0的符号,永远是一个未定义的符号。对于另外几个符号解释如下:
func1
和main
函数都是定义在SimpleSection.c里面的,它们所在的位置都为代码段,所以Ndx为1,即SimpleSection.o里面.text
段的下标为1。这一点可以通过readelf -a
或objdump -x
得到验证。它们是函数,所以类型是STT_FUNC;它们是全局可见的,所以是STB_GLOBAL;Size表示函数指令所占的字节数;Value表示函数相对于代码段起始位置的偏移量。printf
这个符号,该符号在SimpleSection.o里面被引用,但是没有被定义,所以它的Ndx是SHN_UNDEF。global_init_var
是已初始化的全局变量,它被定义在.data
段,即下标为3.global_uninit_var
是未初始化的全局变量,它是一个SHN_COMMON类型的符号,它本身并没有存在于BSS段。static_var.1752
和static_var2.1753
是两个静态变量,它们的绑定属性是STB_LOCAL,即只是编译单元内部可见。- 对于那些STT_SECTION类型的符号,它们表示下标为Ndx的段的段名。它们的符号名没有显示,其实它们的符号名即它们的段名。比如2号符号的Ndx为1,那么它即表示
.text
段的段名,该符号的符号名应该就是.text
。如果我们使用objdump -t
就可以清楚地看到这些段名符号。 SimpleSection.o
这个符号表示编译单元的源文件名。
5.2 特殊符号
当我们使用ld作为链接器来链接生产可执行文件时,它会为我们定义很多特殊的符号,这些符号并没有在你的程序中定义,但是你可以直接声明并且引用它,我们称之为特殊符号。其实这些符号是被定义在ld链接器的链接脚本中的。链接器会在将程序最终链接成可执行文件的时候将其解析成正确的值,注意,只有使用ld链接产生最终可执行文件的时候这些符号才会存在。几个很具有代表性的特殊符号如下:
__executable_start
:该符号为程序起始地址,注意,不是入口地址,是程序的最开始的地址。__etext
或_etext
或etext
:该符号为代码段结束地址,即代码段最末尾的地址。_edata
或edata
:该符号为数据段结束地址,即数据段最末尾的地址。_end
或end
:该符号为程序结束地址。- 以上地址都为程序被装载时的虚拟地址。
我们可以在程序中直接使用这些符号,测试代码如下:
#include <stdio.h>extern char __executable_start[];
extern char etext[], _etext[], __etext[];
extern char edata[], _edata[];
extern char end[], _end[];int main()
{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;
}
执行结果如下:
[@ARM64-01 cpp-file]$ gcc SpecialSymbol.c -o SpecialSymbol
[@ARM64-01 cpp-file]$ ./SpecialSymbol
Executable Start 400000
Text End 40073C 40073C 40073C
Data End 420030 420030
Executable End 420038 420038
5.3 符号修饰与函数名
约在20世纪70年代以前,编译器编译源代码产生目标文件时,符号名与相应的变量和函数的名字是一样的。比如一个汇编源代码里面包含了一个函数foo,那么汇编器将它编译成目标文件以后,foo在目标文件中的相对应的符号名也是foo。当后来UNIX平台和C语言发明时,已经存在了相当多的使用汇编编写的库和目标文件。这样就产生了一个问题,那就是如果一个C程序要使用这些库的话,C语言中不可以使用这些库中定义的函数和变量的名字作为符号名,否则将会跟现有的目标文件冲突。为了防止类似的符号名冲突,UNIX下的C语言就规定,C语言源代码文件中的所有全局的变量和函数经过编译以后,相对应的符号名前加上下划线_
。而Fortran语言的源代码经过编译以后,所有的符号名前加上_
,后面也加上_
。比如一个C语言函数foo
,那么它编译后的符号名就是_foo
;如果是Fortran语言,就是_foo_
。
这种简单而原始的方法的确能够暂时减少多种语言目标文件之间的符号冲突的概率,但还是没有从根本上解决符号冲突的问题。比如同一种语言编写的目标文件还有可能会产生符号冲突,当程序很大时,不同的模块由多个部门(个人)开发,它们之间的命名规范如果不严格,则有可能导致冲突。于是像C++这样的后来设计的语言增加了名称空间(Namespace)的方法解决多模块的符号冲突问题。
在现在的Linux下的GCC编译器中,默认情况下已经去掉了在C语言符号前加_
的这种方式;但是Windows平台下的编译器还保持的这样的传统,比如Visucal C++编译器就会在C语言符号前加_
,GCC在Windows平台下的版本(Cygwin, mingw)也会加_
。GCC编译器也可以通过参数选项-fleading-underscore
或-fno-leading-underscrore
来打开和关闭是否在C语言符号前加上下划线。
C++符号修饰
函数签名(Function Signature):包含了一个函数的信息,包括函数名、它的参数类型、它所在的类和名称空间及其它信息。函数签名用于识别不同的函数,函数的名字只是函数签名的一部分。在编译器及链接器处理符号时,它们使用某种名称修饰的方法,使得每个函数签名 对应一个修饰后名称(Decorated Name)。编译器在将C++源代码编译成目标文件时,会将函数和变量的名字进行修饰,形成符号名,也就是说,C++的源代码编译后的目标文件中所使用的符号名是相应的函数和变量的修饰后名称。C++编译器和链接器都使用符号来识别和处理函数和变量,所以对于不同函数签名的函数,即使函数名相同,编译器和链接器都认为它们是不同的函数。
GCC的基本C++名称修饰方法如下:所有的符号都以_Z
开头,对于嵌套的名字(在名称空间或在类里面的),后面紧跟N
,然后是各个名称空间和类的名字,每个名字前是名字字符串长度,再以E结尾。对于一个函数来说,它的参数列表紧跟在E后面,对于int类型来说,就是字母i。binutils里面提供了一个叫c++filt的工具可以用来解析被修饰过的名称。
5.4 extern “C”
C++为了与C兼容(因为有些函数是用C的风格编译的),在符号的管理上,C++有一个用来声明或定义一个C的符号的extern C
关键字用法。C++编译器会将在extern C
的大括号内部的代码当作C语言代码处理。
#ifdef __cplusplus
extern "C" {#endif
void cfuncall();
#ifdef __cplusplus
}
#endif
5.5 弱符号与强符号
链接器如何解析多重定义的全局符号中也有对这块内容做解释。
对于C/C++语言来说,编译器默认函数和初始化了的全局变量为强符号(Strong Symbol),未初始化的全局变量为弱符号(Weak Symbol)。我们也可以通过GCC的__attribute__((weak))
来定义任何一个强符号为弱符号。注意:强符号和弱符号都是针对定义来说的,不是针对符号的引用。
针对强弱符号的概念,链接器就会按如下规则处理与选择被多次定义的全局符号:
- 规则1:不允许强符号被多次定义(即不同的目标文件中不能有同名的强符号);如果有多个强符号定义,则链接器报符号重复定义错误。
- 规则2:如果一个符号在某个目标文件中是强符号,在其它文件中都是弱符号,那么选择强符号。
- 规则3:如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个。比如目标文件A定义全局变量
global
为int
型,占4个字节;目标文件B定义global
为double
型,占8个字节,那么目标文件A和B链接后,符号global
占8个字节(尽量不用使用多个不同类型的弱符号,否则容易导致很难发现的程序错误)。
弱引用(Weak Reference)和强引用(Strong Reference)
对外部目标文件的符号引用在目标文件被最终链接成可执行文件时,它们须要被正确决议,如果没有找到该符号的定义,链接器就会报符号未定义错误,这种被称为强引用。与之相对应还有一种弱引用,在处理弱引用时,如果该符号有定义,则链接器将该符号的引用决议;如果该符号未被定义,则链接器对于该引用不报错。链接器处理强引用和弱引用的过程几乎一样,只是对于未定义的弱引用,链接器不认为它是一个错误。一般对于未定义的弱引用,链接器默认其为0,或者是一个特殊的值,以便于程序代码能够识别。弱引用和弱符号主要用于库的链接过程。在GCC中,我们可以通过使用__attribute__((weakref))
这个扩展关键字来声明对一个外部函数的应用为弱引用。
这种弱符号和弱引用对于库来说十分有用,比如库中定义的弱符号可以被用户定义的强符号所覆盖,从而使得程序可以使用自定义版本的库函数;或者程序可以对某些扩展功能模块的引用定义为弱引用,当我们将扩展模块与程序链接在一起时,功能模块就可以正常使用;如果我们去掉了某些功能模块,那么程序也可以正常链接,只是缺少了相应的功能,这使得程序的功能更加容易裁剪和组合。
链接装载与库:第三章——目标文件里有什么(ELF文件结构)相关推荐
- 《程序员的自我修养-链接-装载与库》第三章 目标文件里有什么(1)
目录 0.引言 1.目标文件的格式 1.1 目标文件的格式及ELF文件格式的文件的分类 1.2 目标文件与可执行文件格式的小历史 2.目标文件是什么样的 2.1 程序与目标文件简介 2.2 BSS历史 ...
- 《程序员的自我修养》第3章---目标文件里有什么
第3章 目标文件里有什么 3.1 目标文件的格式: 编译器编译源代码后生成的文件叫做 "目标文件". 目标文件从结构上讲,它是已经编译后的可执行文件格式,只是还没有经过链接的过程, ...
- 程序员的自我修养--链接、装载与库笔记:目标文件里有什么
编译器编译源代码后生成的文件叫做目标文件.目标文件从结构上讲,它是已经编译后的可执行文件格式,只是还没有经过链接的过程,其中可能有些符号或有些地址还没有被调整.其实它本身就是按照可执行文件格式存储的, ...
- 【《程序员的自我修养---链接装载于库》读书笔记】可执行文件的装载与进程
系列文章目录 [<程序员的自我修养-链接装载于库>读书笔记]初探ELF [<程序员的自我修养-链接装载于库>读书笔记]windows PE/COFF [<程序员的自我修养 ...
- 计算机二级第三章查询,全国计算机二级题库第三章
全国计算机二级题库 第三章 具有多媒体功能的微型计算机系统中,常用的CD-ROM是(只读型光盘) 在计算机术语中经常用RAM表示(随机存取存储器) 在微型计算机内存储器中,不能用指令修改其存储内容的部 ...
- oracle体系三大文件,oracle 体系_第三章控制文件
第三章 控制文件 1.功能特点 记录数据库当前的物理状态 维护数据库的一致性,是一个二进制的小文件 在mount阶段被读取,记录rman备份的元数据 查看database控制文件的位置 Show pa ...
- 【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递临时对象构建栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端
文章目录 前言 介绍 内存 内存布局 栈与调用惯例 堆与内存管理 运行库 入口函数和程序初始化 C/C++运行库 运行库与多线程 C++全局构造与析构 fread 实现 系统调用与API 系统调用介绍 ...
- 程序员的自我修养三目标文件里有什么
编译器编译源代码后生成的文件叫做目标文件. 目标文件从结构上讲,它是已经编译后的可执行文件格式,只是没有经过链接的过程. 3.1目标文件的格式 现在PC平台流行的是可执行文件格式,主要是win下的PE ...
- C语言 目标文件和可执行文件(ELF文件)
转自:https://www.jianshu.com/p/7c609b70acbd 1.C语言创建程序 1.1C语言创建(分为4个步骤) 编辑 编译 链接 执行 编辑:就是创建和修改C程序的源代码-我 ...
最新文章
- Oracle Sql技巧 - Upsert, Multitable Insert, Undrop
- Python(2.7.6) copy - 浅拷贝与深拷贝
- 动态规划--重拾我的“背包”
- Java小青蛙跳台街,算法-青蛙跳台阶详解
- 2021,前方路艰,与君共勉
- python 包含汉字的文件读写之每行末尾加上特定字符
- 浅谈PHP数字字符串比较
- JSP 中 pageEncoding 和 charset 区别以及中文乱码解决方案
- 计量经济学 pdf_计量经济学笔记(十六)
- 【libjpeg-turbo】安装指南[mac版]
- 用arcgis批量裁剪栅格(tiff)数据的矩形区域
- html3d建模,数百个 HTML5 例子学习 HT 图形组件 – 3D 建模篇
- H3C华三链路聚合的原理及配置
- 项目管理的5大过程/ 9大知识领域/44个子过程
- python如何识别特殊字符_Python怎么判断过滤特殊字符
- ASR项目实战-决策点
- MacOS Catalina Beta使用体验
- 联想笔记本键盘亮屏幕不亮_联想笔记本电脑开机键亮但是黑屏,联想笔记本电脑键盘失灵怎么办...
- FFmpeg的HEVC解码器源代码简单分析:CTU解码(CTU Decode)部分-TU
- 恰当的活法――有感于《杜拉拉升职记》
热门文章
- Linux中断子系统(四)之中断申请注册
- 整蛊病毒,让你的计算机无法进行操作
- python获取登录按钮_Python:Selenium模拟Chrome浏览器抓取淘宝商品信息
- 【python】sympy
- 图像处理基本方法-c语言调用opencv实现人脸检测功能
- [转载]UNICODE,GBK,UTF-8区别
- Charmve Coding | Integer V lies strictly between integers U and W
- 上市首日大涨15%,投出优步的私募巨头TPG迎来开门红?
- e-Japen:看日本如何打造电子政府(转)
- Arbitrary Style Transfer in Real-time with Adaptive Instance Normalization论文阅读笔记