链接、装载与库(三) 目标文件结构

预处理->编译->汇编生成的目标代码文件,也叫可重定位文件。

Windows平台的目标文件:*.obj

Linux平台的目标文件: *.o

1. 目标文件的格式

实际上,目标文件是源代码编译但未链接的中间文件(windows的.obj文件和linux的.o文件).它与可执行文件的内容与结构十分相似,从广义上二者的格式几乎一样。windows下,我们统称为PE-COFF文件格式,Linux下统称为ELF文件,其实,它们都是COFF(Common file format)的变种。

2.目标文件内容

目标文件中包含了编译后的机器指令代码、数据、以及链接时所需的信息,如符号表、调试信息、字符串等。通常这些信息会根据属性,以“节”(“段”)的形式存储。通常,机器指令放在代码段,全局变量或局部静态变量放在数据段。代码段常见名字有“.code”或“.text”,数据段一般命名“.data”。

3.从实例分析目标文件

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编译SimpleSection.c(参数 -c 表示只编译不链接),产生目标文件SimpleSection.o

gcc -c SimpleSection.c

1.SimpleSection.o目标文件结构
使用binutils的工具objdump查看目标文件内部的结构,参数”-h”就是把ELF文件的各个段的基本信息打印出来,也可以使用”objdump -x”把更多的信息打印出来

objdump -h SimpleSection.o 

在这里插入图片描述
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表示长度和的十六进制)

size SimpleSection.o

在这里插入图片描述
2.代码段
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”。

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

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

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

4. ELF文件结构描述

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

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;

文件头:可以用readelf命令来详细查看ELF文件头
在这里插入图片描述
ELF的文件头中定义了ELF魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI版本、ELF重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置和长度及段的数量等。

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

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;

“objdump -h”命令只是把ELF文件中关键的段显示了出来,而省略了其它的辅助性的段,比如:符号表、字符串表、段名字符串表、重定位表等。可以使用readelf工具来查看ELF文件的段,它显示出来的结果才是真正的段表结构,如下图所示:
在这里插入图片描述
readelf输出的结果就是ELF文件段表的内容。段表的结构比较简单,它是一个以”Elf64_Shdr”结构体为元素的数组。数组元素的个数等于段的个数,每个”Elf64_Shdr”结构体对应一个段。”Elf64_Shdr”又被称为段描述符(Section Descriptor)。对于SimpleSection.o来说,段表就是有13个元素的数组。ELF段表的这个数组的第一个元素是无效的段描述符,它的类型为”NULL”,除此之外每个段描述符都对应一个段。也就是说SimpleSection.o共有12个有效的段。

5. 链接的接口—符号

在链接中,将函数和变量统称为符号,函数名或变量名就是符号名,符号值就是它们的地址。

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_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的符号,永远是一个未定义的符号。

COMMON块:现在的编译器和链接器都支持一种叫COMMON块(Common Block)的机制。现代的链接机制在处理弱符号的时候,采用的就是与COMMON块一样的机制,当不同的目标文件需要的COMMON块空间大小不一致时,以最大的那块为准。COMMON类型的链接规则是针对符号都是弱符号的情况,如果其中有一个符号为强符号,那么最终输出结果中的符号所占空间与强符号相同。值的注意的是,如果链接过程中有弱符号大小大于强符号,那么ld链接器会报警告。未初始化的全局变量就是典型的弱符号。GCC的”-fno-common”也允许我们把所有未初始化的全局变量不以COMMON块的形式处理,或者使用”attribute”扩展,即int global attribute((nocommon)); 一旦一个未初始化的全局变量不是以COMMON块的形式存在,那么它就相当于一个强符号,如果其它目标文件中还有同一个变量的强符号定义,链接时就会发生符号重复定义错误。

对于另外几个符号解释如下:

(1). func1和main函数都是定义在SimpleSection.c里面的,它们所在的位置都为代码段,所以Ndx为1,即SimpleSection.o里面”.text”段的下标为1。这一点可以通过readelf -a或objdump -x得到验证。它们是函数,所以类型是STT_FUNC;它们是全局可见的,所以是STB_GLOBALSize表示函数指令所占的字节数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). 以上地址都为程序被装载时的虚拟地址。

符号修饰与函数签名:为了防止类似的符号名冲突,UNIX下的C语言就规定,C语言源代码文件中的所有全局的变量和函数经过编译以后,相对应的符号名前加上下划线”_”。而Fortran语言的源代码经过编译以后,所有的符号名前加上””,后面也加上””。比如一个C语言函数”foo”,那么它编译后的符号名就是”_foo”;如果是Fortran语言,就是”foo”。

在现在的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++代码。

符号重定义
强符号:编译器默认函数和初始化了的全局变量为强符号。
弱符号:未初始化的全局变量未弱符号。
我们也可以通过GCC的”attribute((weak))”来定义任何一个强符号为弱符号。

链接规则:
<1> 不允许强符号被多次定义。
<2> 如果一个符号在某目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号。
<3> 如果一个符号所在目标文件中都是弱符号,那么选择其中占用空间最大的一个。

弱引用(Weak Reference)和强引用(Strong Reference):对外部目标文件的符号引用在目标文件被最终链接成可执行文件时,它们须要被正确决议,如果没有找到该符号的定义,链接器就会报符号未定义错误,这种被称为强引用。与之相对应还有一种弱引用,在处理弱引用时,如果该符号有定义,则链接器将该符号的引用决议;如果该符号未被定义,则链接器对于该引用不报错。链接器处理强引用和弱引用的过程几乎一样,只是对于未定义的弱引用,链接器不认为它是一个错误。一般对于未定义的弱引用,链接器默认其为0,或者是一个特殊的值,以便于程序代码能够识别。弱引用和弱符号主要用于库的链接过程。在GCC中,我们可以通过使用”attribute((weakref))”这个扩展关键字来声明对一个外部函数的应用为弱引用

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

猜你喜欢

转载自blog.csdn.net/weixin_43202635/article/details/112137085