#第三章 目标文件里有什么
编译器编译源代码后生成的文件叫做目标文件,从结构上讲,它是已经编译后的可执行文件格式,只是还没有经过链接的过程。
3.1 目标文件的格式
现在PC平台流行的可执行文件格式(Executable)主要是windows下的PE(Portable Executable)和linux下的ELF(Executable Linkable Format),它们都是COFF(Common file format)格式的变种。目标文件就是源代码编译后但未进行链接的那些中间文件(windows的.obj和linux下的.o)。
除了可执行文件,动态链接库(DLL,Dynamic Linking Library)(windows下的.dll和linux下的.so)和静态链接库(Static Linking Library)(windows下的.lib和linux的.a)文件都按照可执行文件格式存储。
elf文件标准里把系统中采用elf格式的文件归为如下所示的4类:
ELF文件类型 | 说明 | 实例 |
---|---|---|
可重定位文件(Relocatable File) | 这类文件包含了代码和数据,可以用来链接成可执行文件或共享目标文件,静态链接库也可以归为这一类 | linux的.o windows的.obj |
可执行文件(Executable File) | 这类文件包含了可以直接执行的程序,它的代表就是elf可执行文件,它们一般都没有扩展名 | 比如/bin/sh文件 windows的.exe |
共享目标文件(Shared Object File) | 这种文件包含了代码和数据,一种是链接器可以使用这种文件和其他的可重定位文件和共享目标文件链接,产生新的目标文件;第二种是动态链接器可以将几个这种共享目标文件与可执行文件结合,作为进程映象的一部分来运行 | linux的.so,如/lib/glibc-2.5.so windows的DLL |
核心转储文件(Core Dump File) | 当进程意外终止时,系统可以将该进程的地址空间的内容及终止时的一些其他信息转储到核心转储文件 | linux下的core dump |
3.2 目标文件是什么样的
目标文件中除了有编译后的机器指令代码、数据,还包含链接时需要的一些信息,比如符号表、调试信息、字符串等。一般目标文件将这些信息按不同的属性,以节(Section)的形式存储,有时候也叫段(segment)。
节和段唯一的 区别是中elf的链接视图和装载视图的时候,本书中默认统一称为“段”。
程序源代码编译后的机器指令经常放在代码段(Code Section)里,代码段常见的名字有”.code”或”.text”;全局变量和局部静态变量数据经常放在数据段(Data Section)里,数据段的一般名字叫”.data”。
假设上图中格式是elf,可以看到elf文件的开头是一个”文件头”,它描述了整个文件的文件属性,包括文件是否可执行、是静态链接还是动态链接及入口地址(如果是可执行文件)、目标硬件、目标操作系统等信息,文件头还有一个用来描述文件中各个段的数组—-段表(Section Table)。
- 一般c语言编译后执行语句都编译成机器代码,保存在.text段
- 已初始化的全局变量和局部静态变量都保存在.data段
- 未初始化的全局变量和局部静态变量一般放在.bss段
bss段只是为未初始化的全局变量和局部静态变量预留位置,并没有内容,在文件中也不占据空间
总体来说,程序源代码被编译后主要分成两种段:程序指令和程序数据。代码段属于程序指令,而数据段和.bss段属于程序数据。
数据和指令分段的好处主要有:
- 可以方便的设置数据和指令的权限为可读写和只读,以防止指令被有意或无意地改写
- 现代cpu的缓存一般都被设计成数据缓存和指令缓存分离,程序的指令和数据被分开有利于cpu的缓存命中率提高
- 当系统中运行着多个该程序的副本时,内存中只需要保存一份该程序的指令部分,可以省下大量空间(除了指令,对于所有的只读数据也都一样)
3.3 挖掘SimpleSection.o
以下列代码为例
1 | //SimpleSection.c |
如不加说明,以下分析的都是32位的elf文件格式
因为我使用的是64位的系统,所以想要将其编译成32位程序需要加上 -m32 参数,同时由于gcc版本、机器平台等因素的差异,我得出来的结果可能和书中的例图略有不同
我们使用gcc来编译这个文件(参数 -c 表示只编译不链接)
1 | $ gcc -m32 -c SimpleSection.c |
参数 -h 就是把elf文件的各个段的基本信息打印出来。我们也可以使用”objdump -x”打印出更多信息。
从上面的结果来看,SimpleSection.o中除了最基本的代码段、数据段和BSS段以外,还有三个段分别是只读数据段(.rodata)、注释信息段(.comment)和堆栈提示段(.note.GNU-stack)。
书中并未出现.eh_frame段,所以下面对elf文件的解读也会忽略这个内容,我查找到的结果如下:
当gcc生成处理异常的某些代码时,它将生成可以描述如何展开堆栈的表。这些表在.eh_frame部分中找到。eh = exception handling。.eh_frame包含异常展开和源语言信息,其中,每个条目均由单个CFI表示。
我们首先看看几个重要的段的属性,最容易理解的是段的长度(Size)和段所在的位置(File Offset)
LMA: 加载地址,如加载到RAM中等,在嵌入式中,有可能是在ROM中(这时LMA!=VMA)
VMA: 虚拟地址,就是程序运行时的地址,一般就是内存地址,如要把ROM中的数据加载到RAM中运行。Algn:对齐
第二行中的”CONTENTS”、”ALLOC”等表示段的各种属性,”CONTENTS”表示该段在文件中存在,.note.GNU-stack比较奇怪,我们暂且忽略
结构如图
1 | $ size -A SimpleSection.o |
如果直接用size来查看大小,.text长度会过大。因为size默认是运行在”Berkeley compatibility mode”下。在这种模式下,会将不可执行的拥有”ALLOC”属性的只读段归到.text段下,很典型的就是.rodata段。如果你使用”size -A obj.o”,那么size会运行在”System V compatibility mode”,此时,用objdump -h和size显示的.text段大小就差不多了。
我们来看看这些段都包含了什么内容
代码段
objdump的 -s 参数可以将所有段段内容以十六进制的方式打印出来,-d 参数可以将所有包含指令的段反汇编,以下为打印出的内容(省略号表示可以忽略的无关内容
1 | $ objdump -s -d SimpleSection.o |
“Contents of section .text”就是.text的数据以十六进制方式打印出来的内容,最左边是偏移量,中间四列是十六进制内容,最右边是.text段的ASCII码形式,对比下面的反汇编结果可以很明显的看出.text的内容正是SimpleSection.c里两个函数func1()和main()的指令
数据段和只读数据段
.data段保存的是那些已经初始化了的全局静态变量和局部静态变量。前面的代码中共有两个这样的变量:global_init_varabal和static_var,每个四个字节,所以”.data”段的大小为八个字节
1 | $ objdump -s -d -x SimpleSection.o |
“.rodata”段存放的是只读数据,一般是程序里的只读变量(如const修饰的变量)和字符串常量。我们在调用”printf”的时候,用到了一个字符串常量”%d\n”,它是一种只读数据,所以它被放到了”.rodata”段
在.data段的前四个字节,从低到高分别是0x54,0x00,0x00,0x00,原因是CPU的字节序(Byte Order)。即我们所说的大端序和小端序。
BSS段
.bss段存放的是未初始化的全局变量和局部静态变量,如上述代码中的 global_uninit_var 和 static_var2 。更准确的说法是.bss段为它们预留了空间,但是我们可以看到.bss段的大小只有4个字节,这与应有的八个字节不符。
其实我们可以通过符号表(Symbol Table)(后面章节介绍)看到,只有static_var2被存放在了.bss段,而global_uninit_var没有被存放在任何段,只是一个未定义的”COMMON”符号。这和不同语言、不同编译器相关,有些编译器会将全局的未初始化变量存放在目标文件.bss段,有些则不存放,只是预留一个未定义的全局变量符号,等到最终链接成可执行文件的时候再在.bss段分配空间。
编译单元内部可见的静态变量(比如给global_uninit_var 加上 static 修饰)的确是存放在.bcc段的
当一个c或cpp文件在编译时,预处理器首先递归包含头文件,形成一个含有所有必要信息的单个源文件,这个源文件就是一个编译单元。
1 | $ objdump -s -d -x SimpleSection.o |
变量存放位置
1 | static int x1 = 0; |
x1会被存放在.bss中,x2会被放在.data中,因为x1为0,可以认为是未初始化的,所以被优化掉了可以放在.bss,这样可以节省磁盘空间。
其他段
elf中也可能包括其他段,这里放一个书中的图
应用程序也可以自定义段,但是需要注意的是不能以”.”作为前缀,否则容易和系统保留段名冲突。
如何把一个二进制文件作为目标文件中的一个段?
可以使用objcopy工具,例如我们有一个图片文件”image.png”,大小为0x3f81字节:
1 | $ objcopy -I binary -O elf32-i386 -B i386 image.png test.o |
符号”_binary_image_jpg_start”、”_binary_image_png_end”、”_binary_image_png_size” 分别表示该图片文件在内存中的起始地址、结束地址和大小,我们可以在程序里面直接声明并使用它们
自定义段
正常情况下,gcc编译出来的文件中,代码会被放到”.text”段,全局变量和静态变量会被放到”.data”和”.bss”段。
但有些情况我们可能希望变量或部分代码放到我们指定的段里面,gcc提供了一个扩展机制,使得程序员可以指定变量所处的段:
1 | __attribute__((section("FOO"))) int global = 42; |
我们在全局变量或函数之前加上”_attribute_\((section(“BAR”)))” 属性就可以把相应的变量或函数放到以”name”作为段名的段中
3.4 ELF文件结构描述
我们把elf最重要的结构提取出来,就形成了如图所示的elf文件基本结构图
elf目标文件格式的最前部是ELF文件头(ELF Header),它包含了描述整个文件的基本属性。ELF文件中与段有关的重要结构就是段表(Section Header Table),该表描述了ELF文件包含的所有段的信息。
文件头
我们可以通过readelf命令来详细查看elf文件
-h 参数即显示elf文件头
1 | $ readelf -h SimpleSection.o |
我们可以看到,elf的文件头中定义了ELF魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI版本、ELF重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置和长度及段的数量等。
elf文件头结构及相关常数被定义在”usr/include/elf.h”里。
书上其实有不少介绍,但我觉得暂时没啥用,估计也没人看,在书的p70。
elf魔数
最开始的4个字节是所有ELF文件都必须相同的标识码,分别为0x7f、0x45、0x4c、0x46,第一个字节对应ASCII字符里面的DEL控制符,后面3个字节是ELF这三个字母的ASCII码,所谓魔数,也就相当于大家约定俗成的惯例,即用这几个字节来确认文件的类型。
文件类型
系统并不是通过文件的扩展名,而是通过e_type常量来判断elf的真正文件类型,相关的常量以”ET_”开头
常量 | 值 | 含义 |
---|---|---|
ET_REL | 1 | 可重定位文件,一般为.o文件 |
ET_EXEC | 2 | 可执行文件 |
ET_DYN | 3 | 共享目标文件,一般为.so文件 |
机器类型
elf文件格式被设计成可以在多个平台下使用,但这并不表示同一个elf文件可以在不同的平台下使用,e_machine成员表示该elf文件的平台属性,相关的常量以”EM_”开头
常量 | 值 | 含义 |
---|---|---|
EM_M32 | 1 | AT&T WE 32100 |
EM_SPARC | 2 | SPARC |
EM_386 | 3 | Intel x86 |
EM_68K | 4 | Motorola 68000 |
EM_88K | 5 | Motorola 88000 |
EM_860 | 6 | Intel 80860 |
段表
段表(Section Header Table)就是保存elf文件的段的基本属性的结构
我们前文中使用objdump -h
来查看elf文件中包含的段,实际上只是看到了elf文件中关键的段,我们可以使用readelf来查看elf文件中真正的段表结构:
1 | $ readelf -S SimpleSection.o |
段表实质上是一个以”Elf32_Shdr”结构体为元素的数组,每个”Elf32_Shdr”结构体对应一个段。故”Elf32_Shdr”又被称为段描述符(Section Descriptor)。
Elf32_Shdr被定义在”/usr/include/elf.h”
1 | /* Section header. */ |
关于段的类型、段的标识位、段的链接信息,在书中的p77都列出了各个常量值的详细解释,这里就不多赘述了
重定位表
链接器在处理目标文件时,需要对目标文件中的某些部位进行重定位,即代码段和数据段中那些对绝对地址的引用的位置,这些重定位的信息都记录在elf文件的重定位表(Relocation Table)里面。
字符串表
elf文件中一般把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串。
而字符串表通常有两种,普通的字符串表(String Table)和段表字符串表(Section Header String Table)
- 字符串表用来保存普通的字符串,比如符号的名字
- 段表字符串用来保存段表中用到的字符串,最常见的就是段名(sh_name)
3.5 链接的接口——符号
链接过程的本质是要把多个不同的目标文件之间相互“粘”到一起,链接中,目标文件之间相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。例如目标文件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”。调试器可以使用这些符号来分析程序或崩溃时段核心转储文件。这些局部符号对于链接没有作用,链接器也往往忽略它们
- 行号信息,即目标文件指令与源文件中代码行等对应关系,它也是可选的
对于我们来说最需要关心的就是全局符号,其他的都是次要的,因为它们对于其他目标文件来说是“不可见”的,链接过程中也无关紧要。我们可以使用readelf、objdump、nm等工具来查看elf文件的符号表
1 | $ nm SimpleSection.o |
elf符号表结构
elf文件中的符号表往往是文件中的一个段,段名一般叫”.symtab”。符号表的结构很简单,它是一个Elf32_Sym结构(32位elf文件)的数组,每个Elf32_Sym结构对应一个符号。
1 | /* Symbol table entry. */ |
- 符号类型和绑定信息(st_info)
该成员低4位表示符号的类型(Symbol Type),高28位表示符号绑定信息(Symbol Binding) - 符号所在段(st_shndx)
如果符号定义在本目标文件中,那么这个成员表示符号所在段段在段表中的下标。如果符号不是定义在本目标文件中,或者对于有些特殊符号,这个成员的值表示符号的类型/未定义 - 符号值(st_value)
如果这个符号是一个函数或变量的定义,那么符号的值就是这个函数或变量的地址,更具体的说有以下几种情况- 目标文件中,如果是符号的定义且该符号不是”COMMON块”类型的,则st_value表示该符号值段中的偏移
- 目标文件中,如果该符号是”COMMON块”类型的,则st_value表示该符号的对齐属性
- 在可执行文件中,st_value表示符号的虚拟地址。
1 | $ readelf -s SimpleSection.o |
第一列Num表示符号表数组的下标;第二列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。它们是函数,所以类型是STT_FUNC;它们全局可见,所以是STB_GLOBAL;Size表示函数指令所占的字节数;Value表示函数相对于代码段起始位置的偏移量
- printf符号在SimpleSection.c里面被引用,但是没有被定义,所以它的Ndx是SHN_UNDEF
- global_init_var是已初始化的全局变量,它被定义在.bss段,即下标为3。
- global_uninit_var是未初始化的全局变量,它是一个SHN_COMMON类型的符号,它本身并没有存在BSS段。
- static_var.1488和static_var2.1489是两个静态变量,它们的绑定属性是STB_LOCAL,即只是编译单元内部可见。
- 对于那些STT_SECTION类型的符号,它们表示下标为Ndx的段的段名。它们的符号名没有显示,因为它们的符号名即段名。
- “SimpleSection.c”这个符号表示编译单元的源文件名
特殊符号
当我们使用ld作为链接器来链接生产可执行文件时,它会为我们定义很多特殊的符号,这些符号并没有在我们的程序中定义,但是我们可以直接声明并使用它,我们称之为特殊符号。
链接器会在将程序最终链接成可执行文件的时候将其解析成正确的值,需要注意的是,只有使用ld链接生产最终可执行文件的时候这些符号才会存在,几个具有代表性的特殊符号如下:
- __executable_start,该符号为程序起始地址,注意,不是入口地址,是程序的最开始的地址
- __etext或_etext或etext,该符号为代码段结束地址,即代码段最末尾的地址
- _edata或edata,该符号为数据段结束地址,即数据段最末尾的地址
- _end或end,该符号为程序结束地址
- 以上地址都是程序被装载时的虚拟地址
我们可以在程序中直接使用这些符号:
1 | /* |
1 | $ gcc SpecialSymbol.c -o SpecialSymbol |
符号修饰与函数签名
为了防止例如c语言库和我们编写的代码之间的符号名冲突,最开始的unix下的c语言规定,c语言yuandaim文件中的所有全局变量和函数经过编译以后,相对应的符号名前加上下划线”_”。但是这种方法简单和原始,后来例如c++增加了名称空间(namespace)的方法来解决多模块的符号冲突问题
随着时间的推移,整个环境发生了很大变化,例如linux下的gcc编译器中已经默认去掉了在c语言符号前加”_”的方式,但是windows下的编译器还保留着这样的传统。
C++符号修饰
c++拥有很多强大的特性,人们于是发明了符号修饰(Name Decoration)或符号改编(Name Mangling)机制来解决这些问题。例如下列代码:
1 | int func(int); |
这段代码中有6个同名函数叫func,只不过它们的返回类型和参数及所在的名称空间不同。我们引入一个术语叫做函数签名(Function Signature),函数签名包含了一个函数的各种信息,包括函数名、它的参数类型、它所在的类和名称空间及其他信息。
在编译器及链接器处理符号时,它们采用某种名称修饰的方法,使得每个函数签名对应一个修饰后名称(Decorated Name)。c++编译器和链接器都使用符号来识别和处理函数和变量,所以对于不同函数签名的函数,即使函数名相同,编译器和链接器也能区分它们。上面六个函数签名在gcc编译器下,相对应的修饰后名称如图:
函数签名 | 修饰后名称(符号名) |
---|---|
int func(int) | _Z4funci |
float func(float) | _Z4funcf |
int C::func(int) | _ZN1C4funcEi |
int C::C2func(int) | _ZN1C2C24funcEi |
int N::func(int) | _ZN1N4funcEi |
int N::C::func(int) | _ZN1N1C4funcEi |
不同的编译器厂商的名称修饰方法可能不同
extern “C”
c++为了和c兼容,在符号的管理上,c++有一个用来声明或定义一个c的符号的“extern“C””关键字用法:
1 | extern "C" { |
c++编译器会将在 extern “C” 的大括号内部的代码当作c语言代码处理,所以什么的代码中,c++的名称修饰机制将会不起作用。
如果单独声明某个函数或变量为c语言的符号,那么也可以使用如下格式:
1 | extern "C" int func(int); |
弱符号与强符号
对于c/c++语言来说,编译器默认函数和初始化了的全局变量为强符号(Strong Symbol),未初始化的全局变量为弱符号(Weak Symbol)。我们也可以通过GCC的"__attribute__((weak))"
来定义任何一个强符号为弱符号,需要注意的是,强符号和弱符号都是针对定义来说的,不是针对符号的引用。例如下面的程序
1 | extern int ext; |
上面这段程序中,“weak”和“weak2”为弱符号,“strong”和“main”是强符号,而“ext”既非强符号也非弱符号,因为它是一个外部变量的引用。
针对强弱符号的概念,链接器或按如下规则处理与选择被多次定义的全局符号:
- 不允许强符号被多次定义(即不同的目标文件中不能有同名的强符号):如果有多个强符号定义,则链接器报符号重复定义错误。
- 如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号。
- 如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个。比如目标文件A定义全局变量global为int型,占4个字节;目标文件B定义global为double型,占8个字节,那么目标文件A和B链接后,符号global占8个字节(尽量不要使用多个不同类型的弱符号,否则容易导致很难发现的程序错误)。
弱引用和强引用
目前我们所看到的对外部目标文件的符号引用在目标文件被最终链接成可执行文件时,它们需要被正确决议,如果没有找到该符号的定义,链接器就会报符号未定义错误,这种被称为强引用(Strong Reference),与之相对应的是弱引用(weak Reference),和强引用的区别在于对于未定义的弱引用,编译器不认为它是一个错误,一般链接器会默认其为0,或者是一个特殊的值,以便于程序代码能够识别。
在gcc中,我们可以通过使用"__attribute__((weakref))"
这个扩展关键字来声明对一个外部函数的引用为弱引用
3.6 调试信息
目标文件里面还可能保存的是调试信息,如果我们在gcc编译时加上“-g”参数,编译器就会在产生的目标文件里面加上调试信息。我们用readelf等工具可以看到,目标文件里多了很多“debug”相关的段
1 | $ readelf -S SimpleSection |
这些段中保存的就是调试信息,现在的ELF文件采用一个叫DWARF(Debug With Arbitrary Record Format)的标准的调试信息格式。
在linux下,我们可以使用“strip”命令来去掉elf文件中的调试信息
1 | $strip SimpleSection |