程序员的自我修养--链接、装载与库笔记:目标文件里有什么

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/fengbingchun/article/details/88932028

编译器编译源代码后生成的文件叫做目标文件。目标文件从结构上讲,它是已经编译后的可执行文件格式,只是还没有经过链接的过程,其中可能有些符号或有些地址还没有被调整。其实它本身就是按照可执行文件格式存储的,只是跟真正的可执行文件在结构上稍有不同。可执行文件格式涵盖了程序的编译、链接、装载和执行的各个方面。

1. 目标文件的格式

现在PC平台流行的可执行文件格式(Executable)主要是Windows下的PE(Portable Executable)和Linux的ELF(Executable Linkable Format),它们都是COFF(Common file format)格式的变种目标文件就是源代码编译后但未进行链接的那些中间文件(Windows的.obj和Linux的.o),它跟可执行文件的内容与结构很相似,所以一般跟可执行文件格式一起采用一种格式存储。从广义上看,目标文件与可执行文件的格式其实几乎是一样的,所以我们可以广义地将目标文件与可执行文件看成是一种类型的文件,在Windows下,我们可以统称它们为PE-COFF文件格式。在Linux下,我们可以将它们统称为ELF文件。

不光是可执行文件(Windows的.exe和Linux下的ELF可执行文件)按照可执行文件格式存储。动态链接库(DLL, Dynamic Linking Library)(Windows的.dll和Linux的.so)及静态链接库(Static Linking Library)(Windows的.lib和Linux的.a)文件都按照可执行文件格式存储。它们在Windows下都按照PE-COFF格式存储,Linux下按照ELF格式存储。静态链接库稍有不同,它是把很多目标文件捆绑在一起形成一个文件,再加上一些索引,你可以简单地把它理解为一个包含很多目标文件的文件包。ELF文件标准里面把系统中采用ELF格式的文件归为以下4类,如下图:

目标文件与可执行文件格式跟操作系统和编译器密切相关,所以不同的系统平台下会有不同的格式,但这些格式又大同小异。它们都是源于同一种可执行文件格式COFF。COFF的主要贡献是在目标文件里面引入了”段”的机制,不同的目标文件可以拥有不同数量及不同类型的”段”。另外,它还定义了调试数据格式。

2. 目标文件是什么样的

目标文件中的内容至少有编译后的机器指令代码、数据,还包括了链接时所须要的一些信息,比如符号表、调试信息、字符串等。一般目标文件将这些信息按不同的属性,以”节”(Section)的形式存储,有时候也叫”段”(Segment),在一般情况下,它们都表示一个一定长度的区域,基本上不加以区别,唯一的区别是在链接视图和装载视图的时候。

程序源代码编译后的机器指令经常被放在代码段(Code Section)里,代码段常见的名字有”.code”或”.text”;全局变量和局部静态变量数据经常放在数据段(Data Section),数据段的一般名字都叫”.data”,如下图所示。

上图的可执行文件(目标文件)的格式就是ELF,从图中可以看到,ELF文件的开头是一个”文件头”,它描述了整个文件的文件属性,包括文件是否可执行、是静态链接还是动态链接及入口地址(如果是可执行文件)、目标硬件、目标操作系统等信息,文件头还包括一个段表(Section Table),段表其实是一个描述文件中各个段的数组。段表描述了文件中各个段在文件中的偏移位置及段的属性等,从段表里面可以得到每个段的所有信息。文件头后面就是各个段的内容,比如代码段保存的就是程序的指令,数据段保存的就是程序的静态变量等。一般C语言的编译后执行语句都编译成机器代码,保存在.text段;已初始化的全局变量和局部静态变量都保存在.data段;未初始化的全局变量和局部静态变量一般放在一个叫”.bss”的段里。未初始化的全局变量和局部静态变量默认值都为0,本来它们也可以被放在.data段,但是因为它们都是0,所以为它们在.data段分配空间并且存放数据0是没有必要的。程序运行的时候它们的确是要占内存空间的,并且可执行文件必须记录所有未初始化的全局变量和局部静态变量的大小总和,记为.bss段。所以.bss段只是为未初始化的全局变量和局部静态变量预留位置而已,它并没有内容,所以它在文件中也不占据空间。总体来说,程序源代码被编译以后主要分成两种段:程序指令和程序数据。代码段属于程序指令,而数据段和.bss段属于程序数据

数据和指令分段的好处

(1). 一方面是当程序被装载后,数据和指令分别被映射到两个虚存区域。由于数据区域对于程序来说是可读写的,而指令区域对于进程来说是只读的,所以这两个虚存区域的权限可以被分别设置成可读写和只读。这样可以防止程序的指令被有意或无意地改写。

(2). 另外一方面是对于现代的CPU来说,它们有着极为强大的缓存(Cache)体系。由于缓存在现代的计算机中地位非常重要,所以程序必须尽量提高缓存的命中率。指令区和数据区的分离有利于提高程序的局部性。现代CPU的缓存一般都被设计成数据缓存和指令缓存分离,所以程序的指令和数据被分开存放对CPU的缓存命中率提高有好处。

(3). 第三个原因,其实也是最重要的原因,就是当系统中运行着多个程序的副本时,它们的指令都是一样的,所以内存中只须要保存一份该程序的指令部分。对于指令这种只读的区域来说是这样,对于其它的只读数据也一样,比如很多程序里面带有的图标、图片、文本等资源也是属于可以共享的。当然每个副本进程的数据区域是不一样的,它们是进程私有的。不要小看这个共享指令的概念,它在现代的操作系统里面占据了极为重要的地位,特别是在有动态链接的系统中,可以节省大量的内存。

3. 挖掘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 ,使用binutils的工具objdump查看目标文件内部的结构,参数”-h”就是把ELF文件的各个段的基本信息打印出来,也可以使用”objdump -x”把更多的信息打印出来,但是”-x”输出的这些信息又多又复杂,执行结果如下图所示:

从执行结果得知,SimpleSection.o的段除了最基本的代码段(.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”、”eh_frame”段。

有一个专门的命令叫做”size”,它可以用来查看ELF文件的代码段、数据段和BSS段的长度(dec表示3个段长度的和的十进制,hex表示长度和的十六进制),执行结果如下图所示:

代码段:挖掘各个段的内容,离不开objdump,objdump的”-s”参数可以将所有段的内容以十六机制的方式打印出来,”-d”参数可以将所有包含指令的段反汇编,执行结果如下图所示:

“Contents of section .text”就是.text的数据以十六进制方式打印出来的内容,总共0x54字节,跟前面执行”-h”中”.text”段长度相符合,最左面一列是偏移量,中间4列是十六进制内容,最右面一列是.text段的ASCII码形式。对照下面的反汇编结果,可以明显地看到,.text段里面所包含的正是SimpleSection.c里两个函数func1()和main()的指令。.text段的第一个字节”0x55”就是”func1()”函数的第一条”push %rbp”指令,而最后一个字节”0xc3”正是main()函数的最后一条指令”retq”。

数据段和只读数据段.data段保存的是那些已经初始化了的全局静态变量和局部静态变量。SimpleSection.c代码里面一共有两个这样的变量,分别是global_init_var和static_var,这两个变量每个4个字节,一共刚好8个字节,所以”.data”这个段的大小为8个字节。

SimpleSection.c里面在调用”printf”的时候,用到了一个字符串常量”%d\n”,它是一种只读数据,所以它被放到了”.rodata”段,我们可以从输出结果看到”.rodata”这个段的4个字节刚好是这个字符串常量的ASCII字节序,最后以\0结尾。

“.rodata”段存放的是只读数据,一般是程序里面的只读变量(如const修饰的变量)和字符串常量。单独设立”.rodata”段有很多好处,不光在语义上支持了C++的const关键字,而且操作系统在加载的时候可以将”.rodata”段的属性映射成只读,这样对于这个段的任何修改操作都会作为非法操作处理,保证了程序的安全性。另外在某些嵌入式平台下,有些存储区域是采用只读存储器的,如ROM,这样将”.rodata”段放在该存储区域中就可以保证程序访问存储器的正确性。

另外值得一提的是,有时候编译器会把字符串常量放到”.data”段,而不会单独放在”.rodata”段。

“.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.

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段分配空间。

其它段:除了.text、.data、.bss这3个最常用的段之外,ELF文件也有可能包含其它的段,用来保存与程序相关的其它信息,如下图所示:

这些段的名字都是由”.”作为前缀,表示这些表的名字是系统保留的,应用程序也可以使用一些非系统保留的名字作为段名。比如我们可以在ELF文件中插入一个”music”的段,里面存放了一首MP3音乐,当ELF文件运行起来以后可以读取这个段播放这首MP3。但是应用程序自定义的段名不能使用”.”作为前缀,否则容易跟系统保留段名冲突。一个ELF文件也可以拥有几个相同段名的段,比如一个ELF文件中可能有两个或两个以上叫做”.text”的段。还有一些保留的段名是因为ELF文件历史遗留问题造成的,以前用过的一些名字如.sdata、.tdesc、.sbss、.lit4、.lit8、.reginfo、.gptab、.liblist、.conflict。可以不用理会这些段,它们已经被遗弃了。

自定义段:正常情况下,GCC编译出来的目标文件中,代码会被放到”.text”段,全局变量和静态变量会被放到”.data”和”.bss”段。但是有时候你可能希望变量或某些部分代码能够放到你所指定的段中去,以实现某些特定的功能。比如为了满足某些硬件的内存和I/O的地址布局,或者是像Linux操作系统内核中用来完成一些初始化和用户空间复制时出现页错误异常等。GCC提供了一个扩展机制,使得程序员可以指定变量所处的段:

__attribute__((section(“FOO”))) int global = 42;
__attribute__((section(“BAR”))) void foo() {}

我们在全局变量或函数之前加上”__attribute__((section(“name”)))”属性就可以把相应的变量或函数放到以”name”作为段名的段中。

4. ELF文件结构描述

ELF目标文件格式的最前部是ELF文件头(ELF Header),它包含了描述整个文件的基本属性,比如ELF文件版本、目标机器型号、程序入口地址等。紧接着是ELF文件各个段。其中ELF文件中与段有关的重要结构就是段表(Section Header Table),该表描述了ELF文件包含的所有段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其它属性

文件头:可以用readelf命令来详细查看ELF文件,如下图所示:

从输出结果可以看到,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输出的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_”开头,ET_REL:值为1,可重定位文件,一般为.o文件;ET_EXEC:值为2,可执行文件;ET_DYN:值为3,共享目标文件,一般为.so文件。

机器类型:ELF文件格式被设计成可以在多个平台下使用。这并不表示同一个ELF文件可以在不同的平台下使用,而是表示不同平台下的ELF文件都遵循同一套ELF标准。e_machine成员就表示该ELF文件的平台属性,比如3表示该ELF文件只能在Intel x86机器下使用。

段表:ELF文件中有很多各种各样的段,这个段表(Section Header Table)就是保存这些段的基本属性的结构。段表是ELF文件中除了文件头以外最重要的结构,它描述了ELF的各个段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其它属性。也就是说,ELF文件的段结构就是由段表决定的,编译器、链接器和装载器都是依靠段表来定位和访问各个段的属性的。段表在ELF文件中的位置由ELF文件头的”e_shoff”成员决定。

“objdump -h”命令只是把ELF文件中关键的段显示了出来,而省略了其它的辅助性的段,比如:符号表、字符串表、段名字符串表、重定位表等。可以使用readelf工具来查看ELF文件的段,它显示出来的结果才是真正的段表结构,如下图所示:

readelf输出的结果就是ELF文件段表的内容。段表的结构比较简单,它是一个以”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:事实上段的名字对于编译器、链接器来说是有意义的,但是对于操作系统来说并没有实质的意义,对于操作系统来说,一个段该如何处理取决于它的属性和权限,即由段的类型和段的标志位这两个成员决定。

注2:关于这些字段,涉及一些映像文件的加载的概念。

对照Elf64_Shdr和”readelf -S”的输出结果,可以很明显看到,结构体的每一个成员对应于输出结果中从第二列”Name”开始的每一列。

段的类型(sh_type):段的名字只是在链接和编译过程中有意义,但它不能真正地表示段的类型。我们也可以将一个数据段命名为”.text”,对于编译器和链接器来说,主要决定段的属性的是段的类型(sh_type)和段的标志位(sh_flags)。段的类型相关常量以SHT_开头,列举如下图所示:

段的标志位(sh_flag):表示该段在进程虚拟地址空间中的属性,比如是否可写,是否可执行等。相关常量以SHF_开头,如下图所示:

对于系统保留段,它们的属性如下图所示:

段的链接信息(sh_link、sh_info):如果段的类型是与链接相关的(不论是动态链接或静态链接),比如重定位表、符号表等,那么sh_link和sh_info这两个成员所包含的意义如下图所示对于其它类型的段,这两个成员没有意义。

重定位表:SimpleSenction.o中有一个叫做”.rela.text”(注:书中为”.rel.text”)的段,它的类型(sh_type)为”SHT_RELA”(注:SHT_RELA: Relocation entries with addends; SHT_REL: Relocation entries, no addends),也就是说它是一个重定位表(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”。

字符串表:ELF文件中用到了很多字符串,比如段名、变量名等。因为字符串的长度往往是不定的,所以用固定的结构来表示它比较困难。一种很常见的做法是把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串。通过这种方法,在ELF文件中引用字符串只需给出一个数字下标即可,不用考虑字符串长度的问题。一般字符串表在ELF文件中也以段的形式保存,常见的段名为”.strtab”或”.shstrtab”。这两个字符串表分别为字符串表(String Table)和段表字符串(Section Header String Table)。顾名思义,字符串表用来保存普通的字符串,比如符号的名字;段表字符串表用来保存段表中用到的字符串,最常见的就是段名(sh_name)。

ELF文件中”e_shstrndx”是Elf64_Ehdr的最后一个成员,它是”Section header string table indiex”的缩写。段表字符串表本身也是ELF文件中的一个普通的段,它的名字往往叫做”.shstrtab”。那么这个”e_shstrndx”就表示”.shstrtab”在段表中的下标,即段表字符串表在段表中的下标。

5. 链接的接口---符号

链接过程的本质就是要把多个不同的目标文件之间相互”粘”到一起,或者说像玩具积木一样,可以拼装形成一个整体。为了使不同目标文件之间能够相互粘合,这些目标文件之间必须有固定的规则才行,就像积木模块必须有凹凸部分才能够拼合。在链接中,目标文件之间相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。比如目标文件B要用到了目标文件A中的函数”foo”,那么我们就称目标文件A定义(Define)了函数”foo”,称目标文件B引用(Reference)了目标文件A中的函数“foo”。这两个概念也同样适用于变量。每个函数或变量都有自己独特的名字,才能避免链接过程中不同变量和函数之间的混淆。在链接中,我们将函数和变量统称为符号(Symbol),函数名或变量名就是符号名(Symbol Name)

我们可以将符号看做是链接中的粘合剂,整个链接过程正是基于符号才能够正确完成。链接过程中很关键的一部分就是符号的管理,每一个目标文件都会有一个相应的符号表(Symbol Table),这个表里面记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值,叫做符号值(Symbol Value),对应变量和函数来说,符号值就是它们的地址。除了函数和变量之外,还存在其它几种不常用到的符号。我们将符号表中所有的符号进行分类,它们有可能是下面这些类型中的一种:

(1). 定义在本目标文件的全局符号,可以被其它目标文件引用。比如SimpleSection.o里面的”func1”、”main”和”global_init_var”。

(2). 在本目标文件中引用的全局符号,却没有定义在本目标文件,这一般叫做外部符号(External Symbol),也就是我们前面所讲的符号引用。比如SimpleSection.o里面的”printf”。

(3). 段名,这种符号往往由编译器产生,它的值就是该段的起始地址。比如SimpleSection.o里面的”.text”、”.data”等。

(4). 局部符号,这类符号只在编译单元内部可见。比如SimpleSection.o里面的”static_var”和”static_var2”。调试器可以使用这些符号来分析程序或崩溃时的核心转储文件。这些局部符号对于链接过程没有作用,链接器往往也忽略它们。

(5). 行号信息,即目标文件指令与源代码中代码行的对应关系,它也是可选的。

对于我们来说,最值得关注的就是全局符号,即上面分类中的第一类和第二类。因为链接过程只关心全局符号的相互”粘合”,局部符号、段名、行号等都是次要的,它们对于其它目标文件来说是”不可见”的,在链接过程中也是无关紧要的。我们可以使用很多工具来查看ELF文件的符号表,比如readelf、objdump、nm等。

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)每个符号都有一个对应的值,如果这个符号是一个函数或变量的定义,那么符号的值就是这个函数或变量的地址,更准确地讲应该按下面这几种情况区别对待:

(1). 在目标文件中,如果是符号的定义并且该符号不是”COMMON块”类型的(即st_shndx不为SHN_COMMON),则st_value表示该符号在段中的偏移。即符号所对应的函数或变量位于由st_shndx指定的段,偏移st_value的位置。这也是目标文件中定义全局变量的符号的最常见情况,比如SimpleSection.o中的”func1”、”main”、”global_init_var”。

(2). 在目标文件中,如果符号是”COMMON块”类型的(即st_shndx为SHN_COMMON),则st_value表示该符号的对齐属性。比如SimpleSection.o中的”global_uninit_var”。

 (3). 在可执行文件中,st_value表示符号的虚拟地址。这个虚拟地址对于动态链接器来说十分有用。

SimpleSection.o中的符号如下图所示:

readelf的输出格式与上面描述的Elf64_Sym的各个成员几乎一一对应,第一列Num表示符号表数组的下标,从0开始,共16个符号;第二列Value就是符号值,即st_value;第三列Size为符号大小,即st_size;第四列和第五列分别为符号类型和绑定信息,即对应st_info的地4位和高28位;第六列Vis目前在C/C++语言中未使用,我们可以暂时忽略它;第七列Ndx即st_shndx,表示该符号所属的段;最后一列即符号名称。从上面的输出可以看到,第一个符号,即下标为0的符号,永远是一个未定义的符号。对于另外几个符号解释如下:

(1). func1和main函数都是定义在SimpleSection.c里面的,它们所在的位置都为代码段,所以Ndx为1,即SimpleSection.o里面”.text”段的下标为1。这一点可以通过readelf -a或objdump -x得到验证。它们是函数,所以类型是STT_FUNC;它们是全局可见的,所以是STB_GLOBAL;Size表示函数指令所占的字节数;Value表示函数相对于代码段起始位置的偏移量。

(2). printf这个符号,该符号在SimpleSection.o里面被引用,但是没有被定义,所以它的Ndx是SHN_UNDEF。

(3). global_init_var是已初始化的全局变量,它被定义在.data段,即下标为3.

(4). global_uninit_var是未初始化的全局变量,它是一个SHN_COMMON类型的符号,它本身并没有存在于BSS段。

(5). static_var.1752和static_var2.1753是两个静态变量,它们的绑定属性是STB_LOCAL,即只是编译单元内部可见。

(6). 对于那些STT_SECTION类型的符号,它们表示下标为Ndx的段的段名。它们的符号名没有显示,其实它们的符号名即它们的段名。比如2号符号的Ndx为1,那么它即表示.text段的段名,该符号的符号名应该就是”.text”。如果我们使用”objdump -t”就可以清楚地看到这些段名符号。

(7). “SimpleSection.o”这个符号表示编译单元的源文件名。

特殊符号:当我们使用ld作为链接器来链接生产可执行文件时,它会为我们定义很多特殊的符号,这些符号并没有在你的程序中定义,但是你可以直接声明并且引用它,我们称之为特殊符号。其实这些符号是被定义在ld链接器的链接脚本中的。链接器会在将程序最终链接成可执行文件的时候将其解析成正确的值,注意,只有使用ld链接产生最终可执行文件的时候这些符号才会存在。几个很具有代表性的特殊符号如下:

(1). __executable_start:该符号为程序起始地址,注意,不是入口地址,是程序的最开始的地址。

(2). __etext或_etext或etext:该符号为代码段结束地址,即代码段最末尾的地址。

(3). _edata或edata:该符号为数据段结束地址,即数据段最末尾的地址。

(4). _end或end:该符号为程序结束地址。

(5). 以上地址都为程序被装载时的虚拟地址。

我们可以在程序中直接使用这些符号,测试代码如下:

#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;
}

执行结果如下:

另外还有不少其它的特殊符号,它们跟ld的链接脚本有关。

符号修饰与函数签名:约在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”的工具可以用来解析被修饰过的名称

签名和名称修饰机制不光被使用到函数上,C++中的全局变量和静态变量也有同样的机制。对于全局变量来说,它跟函数一样都是一个全局可见的名称,它也遵循上面的名称修饰机制,比如一个名称空间foo中的全局变量bar,它修饰后的名字为:_ZN3foo3barE。值得注意的是,变量的类型并没有被加入到修饰后名称中,所以不论这个变量是整型还是浮点型甚至是一个全局对象,它的名称都是一样的

名称修饰机制也被用来防止静态变量的名字冲突。比如main()函数里面有一个静态变量叫foo,而func()函数里面也有一个静态变量叫foo。为了区分这两个变量,GCC会将它们的符号名分别修饰成两个不同的名字_ZZ4mainE3foo和_ZZ4funcE3foo,这样就区分了这两个变量。

不同的编译器厂商的名称修饰方法可能不同,所以不同的编译器对于同一个函数签名可能对应不同的修饰后名称

猜测Visual C++的名称修饰规则:修饰后名字由”?”开头,接着是函数名由”@”符号结尾的函数名;后面跟着由”@”结尾的类名和名称空间,再一个”@”表示函数的名称空间结束;第一个”A”表示函数调用类型为”__cdecl”,接着是函数的参数类型及返回值,由”@”结束,最后由”Z”结尾。可以看到函数名、参数的类型和名称空间都被加入了修饰后名称,这样编译器和链接器就可以区别同名但不同参数类型或名字空间的函数,而不会导致link的时候函数多重定义。Visual C++的名称修饰规则并没有对外公开。Microsoft提供了一个UnDecorateSymbolName()的API,可以将修饰后名称转换成函数签名。

由于不同的编译器采用不同的名字修饰方法,必须会导致由不同编译器编译产生的目标文件无法正常相互链接,这是导致不同编译器之间不能互操作的主要原因之一。

extern “C”:C++为了与C兼容,在符号的管理上,C++有一个用来声明或定义一个C的符号的”extern “C””关键字用法。C++编译器会将在extern “C”的大括号内部的代码当作C语言代码处理C++的宏”__cpluplus”,C++编译器会在编译C++的程序时默认定义这个宏,我们可以使用条件宏来判断当前编译单元是不是C++代码

弱符号与强符号对于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))”这个扩展关键字来声明对一个外部函数的应用为弱引用

这种弱符号和弱引用对于库来说十分有用,比如库中定义的弱符号可以被用户定义的强符号所覆盖,从而使得程序可以使用自定义版本的库函数;或者程序可以对某些扩展功能模块的引用定义为弱引用,当我们将扩展模块与程序链接在一起时,功能模块就可以正常使用;如果我们去掉了某些功能模块,那么程序也可以正常链接,只是缺少了相应的功能,这使得程序的功能更加容易裁剪和组合。

6. 调试信息

目标文件里面还有可能保存的是调试信息。几乎所有现代的编译器都支持源代码级别的调试,比如我们可以在函数里面设置断点,可以监视变量变化,可以单步行进等,前提是编译器必须提前将源代码与目标代码之间的关系等,比如目标代码中的地址对应源代码中的哪一行、函数和变量的类型、结构体的定义、字符串保存到目标文件里面。甚至有些高级的编译器和调试器支持查看STL容器的内容,即程序员在调试过程中可以直接观察STL容器中的成员的值。如果我们在GCC编译时加上”-g”参数,编译器就会在产生的目标文件里面加上调试信息。现在的ELF文件采用一个叫DWARF(Debug With Arbitrary Record Format)的标准的调试信息格式。Microsoft也有自己相应的调试信息格式标准,叫CodeView。但是值得一提的是,调试信息在目标文件和可执行文件中占用很多的空间,往往比程序的代码和数据本身大好几倍,所以当我们开发完程序并要将它发布的时候,须要把这些对于用户没有用的调试信息去掉,以节省大量的空间。在Linux下,我们可以使用”strip”命令来去掉ELF文件中的调试信息。

GitHub: https://github.com/fengbingchun/Messy_Test 

猜你喜欢

转载自blog.csdn.net/fengbingchun/article/details/88932028