《程序员的自我修养》-(3)目标文件里有什么

536 阅读22分钟

编译器编译代码后生成的文件叫做目标文件,那么目标文件里存放的是什么呢?或者是我们的源代码经过编译之后是怎么存储的?我们将在这一节探索目标文件本质。

目标文件的格式

目标文件就是源代码编译后但未进行链接的中间文件,它和可执行文件的内容和结构很相似,所以一般和可执行文件采用一种格式。不光是可执行文件按照可执行文件格式存储。动态链接库和静态链接库都是按照可执行文件格式存储。例如在Linux下,我们可以将他们统称为ELF 文件。下文我们将主要探究ELF 结构。ELF 格式的文件可以归为如下4类:

ELF文件类型说明实例
可重定位文件(Relocatable File这类文件包含了代码和数据,可以被链接成可执行文件或共享目标文件,静态链接库也可以归为这一类。Linux的.o Windows的.obj
可执行文件(Executable File)这类文件包含了可以直接执行的程序,它的代表就是ELF可执行文件,它们一般都没有扩展名比如bin/bash 文件 Windows的.exe
共享目标文件(Shared Object File)这种文件包含了代码和数据,可以在以下两种情况使用。一种是连接器可以使用这种文件与其他的可重定位文件和共享目标文件链接,产生新的目标文件。第二种是动态链接器可以将几个这种共享目标文件与可执行文件结合,作为进程映像的一部分来运行。Linux的.so Windows的DLL
核心转储文件(Core Dump File)当进程意外终止时,系统可以将进程的地址空间的内容及终止时的一些其他信息转储到核心转储文件Linux下的core dump

目标文件是什么样的

目标文件中的内容至少有编译后的机器指令代码、数据。还需要有链接时所需的一些信息,比如符号表、调试信息、字符串等。一般目标文件将这些信息按不同的属性,以 ”节“(Section) 的形式存储,有时候也叫 ”段“(Segment),它们都表示一个一定长度的区域,下文中默认情况下统一称为”段“。

image.png

ELF 文件的开头是一个**”文件头“** ,它描述了整个文件的属性,包括文件是否可执行,是静态链接还是动态链接及入口地址,文件头中还包含了一个 段表(Section Table),段表其实是一个描述文件中各个段的数组。段表描述了各个段在文件中的偏移位置和属性等。

  • .text段:执行语句编译后的机器代码

  • .data段:已初始化的全局变量和局部静态变量

  • .bbs段:未初始化的全局变量和局部静态变量

总体来说,程序源代码被编译后主要分成两种段:程序指令和程序数据。代码段属于程序指令,数据段和.bbs 段属于程序数据。为什么要把程序数据和程序指令分卡存放?主要有如下几个方面:

  • 数据区对于进程来说是可读写的,而指令区对于进程来说是只读的,所以这两个区域的权限被设置成可读写和只读。这样可以防止程序的指令被改写。

  • 现代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 来编译这个文件,使用objdump来查看object 内部结构

$gcc -c SimpleSection.c
$objdump -h SimpleSection.o

image 1.png

SimpleSction.o 中除了最基本的代码段、数据段和BBS段以外,还有只读数据段(.rodata)注释信息段(.comment)堆栈提示段(.note.GUN-stack),这三个我们暂时不去细究。在段属性中最容易理解的是段的长度(Size)段所在的位置(File Offset),CONTENTS 表示该段在文件中存在,我们可以看到BBS 段没有CONTENTS,表示它实际在ELF 文件中不存在内容。那么在ELF 文件中实际存在的也就是.text、.data、.rodata、.comment 这4个段了。

image 2.png

代码段

objdump 的-s 参数可以将所有段的内容以16进制打印,-d 参数可以将所有包含指令的段反汇编。

image 3.png

最左面一列是偏移量,中间4列是十六进制内容,最后一列是.text 段的ASCII 码形式。Contents of section .text 就是.text 段的数据内容,总共0x5b 个字节,可以和上文了解到的.text 段长度相符合。.text 段第一个字节0x55 就是func1() 函数的第一条push %ebp 指令,而最后一个字节0xc3 正是main() 函数的最后一条指令ret。

数据段和只读数据段

.data 段保存的是已经初始化的全局静态变量和局部静态变量。SimpleSection.c 里有两个这样的变量,分别是global_init_var 和static_var。这两个变量每个4个字节,所以.data 段大小为8字节。在我们调用printf 的时候,用到了一个字符串常量“%d\n ”,它是只读数据,所以被放到了.rodata 段。

BBS 段

.bbs 段存放的是未初始化的全局变量和局部静态变量,例如代码中的global_uninit_var 和static_var2 就是被存放在.bbs 段,其实更准确的说法是.bbs 段位它们预留了空间。但是我们可以看到该段的大小只有4个字节,这与global_uninit_var 和static_var2 的大小8个字节不符。

我们可以通过符号表看到,只有static_var2 被存放在了.bbs 段,而global_uninit_var 却没有被存放在任何段,只是一个未定义的COMMON 符号,等到最终链接成可执行文件时再在.bbs 段分配空间。这和不同语言不同编译器实现有关。

其他段

常用的段名说明
.rodata1存放只读数据,比如字符串常量、全局const 变量。跟.rodata 一样
.comment存放编译器版本信息,比如字符串:"GCC:(GNU)4.2.0"
.debug调试信息
.dynamic动态链接信息
.hash符号哈希表
.line调试时的行号表,即源代码行号与编译后指令的对照表
.note额外的编译器信息
.strtabString Table 字符串表
.symtabSymbol Table 符号表
.shstrtabSection String Table 段名表
.plt
.got
动态链接的跳转表和全局入口表
.init
.fini
程序初始化和终结代码段

自定义段

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

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

ELF 文件结构描述

下图是ELF 目标文件的总体结构,我们省去了ELF 一些复杂的结构,把重要的结构提取出来。

image 4.png

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

文件头

使用readelf 命令查看ELF 文件详细信息

image 5.png

image 6.png

ELF 头文件结构及相关常数被定义在usr/include/elf.h 里,基本上可以和readelf 输出的信息一一对应。

成员readelf 输出结果与含义
e_identMagic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1(current)
OS/ABI:
UNIX - System V
ABI Version: 0
e_typeType: REL(Relocatable file)
ELF 文件类型
e_machineMachine: Intel 80386
ELF 文件的CPU 平台属性,相关常量以EM_开头
e_versionVersion: 0x1
ELF 版本号,一般为常数1
e_entryEntry point address:0x0
入口地址,规定ELF 程序的入口虚拟地址,操作系统加载完该程序后从这个地址开始执行进程的指令,可重定位文件一般没有入口地址,则这个值为0
e_phoffStart if program headers: 0(bytes into file)
后面的文章会讲到
e_shoffStart of section headers: 280(bytes into file)
段表在文件中的偏移,上面的例子这个值是280,也就是段表从文件的第281个字节开始
e_wordFlags: 0x0
ELF 标志位,用来标识一些ELF 文件平台相关属性。相关常量的格式一般为EF_machine_flag,machine 为平台,flag 为标志
e_ehsizeSize if this header: 52(bytes)
即ELF 文件头的大小
e_phentsizeSize of program headers: 0(bytes)
后面的文章会讲到
e_phnumNumber of program header: 0
后面的文章会讲到
e_shentsizeSize of section headers: 40(bytes)
段表描述符的大小
e_shnumNumber of section header: 11
段表描述符数量,这个值等于ELF 文件中拥有的段的数量
e_shstrndxSection header string table idnex: 8
段表字符串表所在的段在段表中的下标

ELF 魔数

我们从readelf 输出可以看到最前的Magic 16个字节,这16个字节用来标识ELF 文件的平台属性。最开始的4个字节是所有ELF文件必须相同的标识码,分别为0x7f、0x45、0x4c、0x46,第一个字节对应ASCII 字符里面的DEL控制符,后面三个字节刚好是ELF 这三个字母的ASCII 码。这4个字节被称为ELF 的魔数。接下来的一个字节用来标识文件类的,0x01表示是32位的,0x02表示是64位的,第6个字节是字节序,规定该文件是大端还是小端。第7个字节规定文件的主版本号,一般是1。后面9个字节没有定义,一般填0,有些平台会同来作为扩展标识。

文件类型

e_type 表示ELF 文件类型,有3种文件类型,每个类型对应一个常量。

常量含义
ET_REL1可重定位文件
ET_EXEC2可执行文件
ET_DYN3共享目标文件

机器类型

ELF文件被设计成可以在多个平台下使用。这并不表示同一个ELF 文件可以在不同平台下使用,而是表示不同平台下的ELF 文件都遵循同一套ELF 标准。e_machine 成员就表示该ELF 文件的平台属性。

常量含义
EM_M321AT&T WE 32100
EM_SPARC2SPARC
EM_3863Intel x86
EM_68K4Motorola 68000
EM_88K5Motorola 88000
EM_8606Intel 80860

段表

段表是除了文件头外最重要的结构,段表保存了每个段的基本属性结构。编译器、连接器和装载器都是依靠段表来定位和访问各个段的属性。段表是一个数组存储着段表描述符(Section Descriptor)。下面是段表描述符成员含义:

成员含义
sh_nameSection name 段名
段名是个字符串,它位于.shstrtab 字符串表,sh_name 是段名在.shstrtab中的偏移量
sh_typeSection type 段的类型
详见下文
sh_flagsSection flag 段的标志位
详见下文
sh_addrSection Address 段虚拟地址
如果该段可以被加载,则sh_addr 为该段被加载后在进程地址空间中的虚拟地址;否则sh_addr 为0
sh_offsetSection Offset 段偏移
如果该段存在于文件中,则表示该段在文件中的偏移;否则无意义。比如sh_offset 对于BBS 段来说就没有意义
sh_sizeSection Size 段的长度
sh_link
sh_info
Section Link and Section Information 段链接信息
详见下文
sh_addralignSection Address Alignment 段地址对齐
和字节对齐类似,sh_addralign 表示对齐数量中的指数,例如sh_addralign = 3 表示8字节对齐,如果sh_addralign 为0或1,则表示该段没有对齐要求
sh_entsizeSection Entry Size 项的长度
有些段包含了固定大小的项,比如符号表每个符号所占的大小都一样,对于这种段sh_entsize 表示每项的大小。如果为0,则表示该段不包含固定大小的项。

段的类型(sh_type)

段名不能真正的表示段的类型,对于编译器和链接器来说,主要决定段的属性的是段的类型(sh_type)段标识位(sh_flags),段的类型相关常量如下。

常量含义
SHT_NULL0无效段
SHT_RPOGBITS1程序段、代码段、数据段都是这种类型的
SHT_SYMTAB2表示该段的内容为符号表
SHT_STRTAB3表示该段的内容为字符串表
SHT_RELA4重定位表,该段包含了重定位信息,详见后文
SHT__HASH5符号表的哈希表,详见后文
SHT_DYNAMIC6动态链接信息,详见后文
SHT_NOTE7提示性信息
SHT_NOBITS8表示该段在文件中没有内容,如BBS 段
SHT_REL9该段包好了重定位信息,详见后文
SHT_SHLIB10保留
SHT_DNYSYM11动态链接的符号表,详见后文

段的标识位(sh_flag)

段的标识位表示该段在进程虚拟地址空间中的属性,比如是否可写、是否可执行等。相关常量如下:

常量含义
SHF_WRITE1表示该段在进程空间中可写
SHF_ALLOC2表示该段在进程空间中需要分配空间。有些包含指示或者控制信息的段不需要在进程空间中被分配空间,它们一般不会有这个标识。像代码段、数据段和.bbs 段都会有这个标志位
SHF_EXECINSTR4表示该段在进程空间中可以被执行,一般指代码段

对于系统保留段它们的属性如下:

Namesh_typesh_flag
.bbsSHT_NOBITSSHF_ALLOC + SHF_WRITE
.commnetSHT_PROGBITSnone
.dataSHT_PROGBITSSHF_ALLOC + SHF_WRITE
.data1SHT_PROGBITSSHF_ALLOC + SHF_WRITE
.debugSHT_PROGBITSnone
.dynamicSHT_DYNAMICSHF_ALLOC + SHF_WRITE
有些系统.dynamic 段可能是只读的,没有SHF_WRITE 标志位
.hashSHT_HASHSHF_ALLOC
.lineSHT_PROGBITSnone
.noteSHT_NOTEnone
.rodataSHT_PROGBITSSHF_ALLOC
.rodata1SHT_PROGBITSSHF_ALLOC
.shstrtabSHT_STRTABnone
.strtabSHT_STRTAB如果该ELF文件中有可装载的段需要用到该字符串表,那么该字符串表也将被装载到进程空间,则有SHF_ALLOC
.symtabSHT_SYMTAB同字符串表
.textSHT_PROGBITSSHF_ALLOC + SHF_EXECINSTR

段的链接信息(sh_link、sh_info)

如果段的类型是和链接相关的,那么sh_linksh_info 这两个成员所包含的意义如下,对于其他类型的段,这两个成员没有意义。

sh_typesh_linksh_info
SHT_DYNAMIC该段使用的字符串表在段表中的下标0
SHT_HASH该段所使用的符号表在段表中的下标0
SHT_REL该段所使用的相应符号表在段表中的下标该重定位表所作用的段在段表中的下标
SHT_RELA该段所使用的相应符号表在段表中的下标该重定位表所作用的段在段表中的下标
SHT_SYMTAB操作系统相关的操作系统相关的
SHT_DYNSYM操作系统相关的操作系统相关的
otherSHN_UNDEF0

重定位表

SimpleSection.o 中有一个.rel.text 的段,它是针对.text 段的重定位表(Relocation Table)。重定位表也是ELF 的一个段,那么这个段的类型为SHT_REL。后续章节会详细介绍。

字符串表

ELF 文件中用到了很多字符串,比如段名、变量名等。由于字符串长度不固定所以用固定的结构来表示比较困难,常见的做法事把字符串集中到一个表中,用偏移来引用字符串。

偏移+0+1+2+3+4+5+6+7+8+9
+0\0helloworl
+10d\0Myvariab
+20le\0

那么偏移与对应的字符串如下:

偏移字符串
0空字符串
1helloworld
6world
12Myvariable

这样在ELF 文件中引用字符串只须给出下标即可。.strtab 字符串表用来保存普通的字符串,.shstrtab 段表字符串表用来保存段表中用到的字符串表。

链接的接口——符号

链接的本质就是要把不同的目标文件拼接成一个整体。在链接中我们将函数和变量统称为符号(Symbol),函数名或变量名就是符号名(Symbol Name)。整个链接过程基于符号才能正确完成,每个目标文件都有一个相应的符号表(Symbol Table),这个表记录了目标文件里所用到的所有符号。每个符号都有一个对应的符号值(Symbol Value),对于变量和函数来说符号值就是它们的地址。另外还有几种不常用的符号,所有的符号一般分为如下几类:

  • 定义在本目标文件里的全局符号,可以被其他目标文件引用。

  • 在本目标文件中引用的全局符号,却没有定义在本目标文件中,这一般叫做外部符号(External Symbol)

  • 段名,这种符号一般由编译器产生,它的值就是该段的起始地址。

  • 局部符号,这类符号只在编译单元内可见。这些局部符号对链接过程没有作用,链接器一般忽略。

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

我们最值得关注的就是全局符号,上面的第一类和第二类。其他符号对于其他目标文件是不可见的,在链接过程中也是无关紧要。

ELF 符号表结构

ELF 文件中的符号表一般是.symtab 段。它是一个Elf32_Sym 结构的数组。

typedef struct {
    Elf32_Word st_name;
    Elf32_Addr st_value;
    Elf32_Word st_size;
    unsigned char st_info;
    unsigned char st_other;
    Elf32_Half st_shndx;
} Elf32_Sym;

这几个成员定义如下:

成员定义
st_name符号名。该符号在字符串表中的下标
st_value符号对应的值。可能是一个绝对值,也可能是一个地址。
st_size符号大小。对于包含数据的符号,这个值应该是数据类型的大小。如果为0,则表示大小为0或者未知
st_info符号类型和绑定信息
st_other目前为0,没用
st_shndx符号所在的段

符号类型和绑定信息(st_info)

该成员低4位表示符号的类型(Symbol Type),高28位表示符号绑定信息(Symbol Binding)

符号绑定信息:

宏定义名说明
STB_LOCAL0局部符号,对于目标文件外部不可见
STB_GLOBAL1全局符号,外部可见
STB_WEAK2弱引用,详见下文

符号类型:

宏定义名说明
STT_NOTYPE0位置类型符号
STT_OBJECT1该符号是个数据对象。比如变量、数组等
STT_FUNC2该符号是个函数或其他可执行代码
STT_SECTION3该符号表示一个段,这种符号必须是STB_LOCAL 的
STT_FILE4该符号表示文件名,一般都是该目标文件所对应的源文件名,它一定是STB_LOCAL 类型的,并且它的st_shndx 一定是SHN_ABS

符号所在段(st_shndx)

如果符号定义在本目标文件中,那么这个成员表示符号所在段在段表中的下标;但如果符号不是定义在本目标文件中,或者对于有些特殊符号 sh_shndx 的值有些特殊,如下所示:

宏定义名说明
SHN_ABS0xfff1表示该符号包含了一个绝对值。比如表示文件名的符号。
SHN_COMMON0xfff2表示该符号是一个“COMMON 块”类型的符号,一般来说未初始化的全局符号定义就是这种类型,详见下文
SHN_UNDEF0表示该符号未定义,表示该符号在本目标文件被引用但是定义在其他目标文件中

符号值(st_value)

每个符号都有一个对应的值,如果这个符号是一个函数或者变量的定义,那个符号的值就是这个函数或者变量的地址,更准确的讲应该按下面几种情况区别对待。

  • 在目标文件中,符号如果不是SHN_COMMON 类型,则st_value 表示该符号在段中的偏移,根据st_shndx 找到指定的段,然后便宜st_value 的位置。

  • 在目标文件中,符号如果是SHN_COMMON 类型,st_value 表示该符号的对齐属性。

  • 在可执行文件中,st_value 表示符号的虚拟地址。

分析SimpleSection.o

image 7.png

readelf 输出基本与Elf32_Sym 成员相对应。

  • func1 和main 函数所在的位置都为.text 段,.text在段表中下标为1,所以Ndx 为1。

  • printf 这个符号在源代码里被引用,但是没有被定义。所以Ndx 为SHN_UNDEF。

  • global_init_var 是已初始化全局变量,定义在.data 段,Ndx 为3。

  • global_uninit_var 是未初始化的全局变量,它是SHN_COMMON 类型,它本身并没有存在于BBS 段,详见下文。

  • static_var.1533 和 static_var2.1534 是两个静态变量,至于名字为什么变了,详见下文。

  • 那些STT_SECTION 类型的符号,比如2号符号的Ndx 为1,那么他表示.text 段名,该符号名应该就是“.text”。

  • “SimpleSection.c” 这个符号便是编译单元的源文件名。

特殊符号

当我们使用ld 作为链接器来链接生产可执行文件时,他会为我们定义很多特殊的符号。这些符号没有在程序中定义但是可以直接生命引用。链接器会在最后链接成可执行文件时将特殊符号解析成正确的值。几个具有代表性的特殊符号如下:

  • __executable_start,该符号为程序起始地址,注意并不是入口地址,是程序最开始的地址。

  • __etext 或_etext 或etext,该符号为代码段结束地址,即代码段最末尾的地址。

  • _edata 或edata,数据段结束地址。

  • _end 或 end,程序结束地址。

符号修饰与函数签名

在很早之前如果源代码里有一个foo 函数,那么编译成目标文件之后对应的符号也是foo。这样就带来了个问题,编写代码时定义的函数和变量名会和已有的库冲突,为了防止冲突C语言源代码中函数和变量编译后,会在相对应的符号前加上下划线“_”。这种简单的方法确实减少了冲突的概率,但是没有从根本上解决问题。

C++符号修饰

C++ 强大而又复杂有很多的特性,最多简单的函数重载,两个名字相同但是单数列表不同,那么编译器和链接器在链接过程中如何区分呢?为了支持这些特性,人们发明了**符号修饰(Name Decoration)符号改编(Name Mangling)**的机制。接下来我们看看C++符号修饰机制。

int func(int);
float func(float);

class C {
    int func(int);
    
    class C2 {
        int func(int);
    };
};

namespace N {
    int func(int);
    class C {
        int func(int);
    };
}

这段代码中有6个同名函数func, 但是他们的返回类型和参数及所在命名空间不同。我们引入一个术语叫函数签名(Function Signature),函数签名包括了一个函数的函数名、参数类型、它所在的类和名称空间及其他信息。在编译器和链接器处理符号时,它们使用某种名称修饰方法,把函数签名变成修饰后的名称(Decorated Name)。上面6个函数的签名和修饰后的名称如下:

函数签名修饰后名称(符号名)
int func(int)_Z4funci
float func(float)_Z4funcf
int C::func(int)_N1C4funcEi
int C::C2::func(int)_ZN1C2C24funcEi
int N::func(int)_ZN1N4funcEi
int N::C::func(int)_ZN1N1C4funcEi

GCC 的基本C++ 名称修饰方法如下:所有的符号都以“_Z”开头,对于嵌套的名字后面紧跟“N”,然后是各个名称空间和类的名字,每个名字前是名字字符串长度。后面以E结尾,参数列表紧跟在“E”后面。int 类型来说就是字母i。全局变量和静态变量也有同样的机制。不同的编译器厂商的名称修饰方法可能不同。

弱符号与强符号

对于C/C++ 来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。我们也可以通过GCC 的“_attribute((weak))”来定义一个强符号为弱符号。针对强弱符号的概念,编译器按如下规则处理被多次定义的全局符号:

不允许强符号多次定义

如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号。

如果一个符号在所有目标文件中都是弱符号,那么选择占用空间最大的一个。

弱引用和强引用

目前我们所看到的对外部目标文件的符号引用在本目标文件被链接成可执行文件是,它们需要背正确决议,如果没有找到该符号的定义,链接器就会报符号未定义错误,这种被称为强引用(Strong Reference)。与之对应的还有一种弱引用(Weak Reference),在处理弱引用时,如果该符号有定义,则链接器将该符号的引用决议;如果该符号未被定义,则链接器对于该引用不报错。

引用

程序员的自我修养