- 真正的程序员对自己所写的程序的每个字节都有所把握,虽然用一些OOP语言进行开发时,我们几乎不可能做到这一点,但我们还是应该对我们程序的二进制布局有个整体把握。
- 在Linux环境中,无论是可重定位文件(.o)、共享目标文件(.so)还是可执行文件都属于ELF文件格式(executable linkable format)。因此可以说,理解了ELF的文件格式,就理解了Linux环境下的绝大多数文件的二进制布局。用file命令我们可以看到这三种文件都属于elf文件格式。

- 下面,我将参考《程序员的自我修养》逐字节分析两个"程序",一个是目标文件,一个是可执行文件。
- 代码如下,a.c中定义了函数main,并声明了外部变量shared,b.c中定义了变量shared和函数swap。
extern int shared;
void swap(int*, int*);
int main()
{
int a = 100;
swap(&a, &shared);
}
int shared = 1;
void swap(int* a, int* b)
{
*a ^= *b ^= *a ^= *b;
}
1 目标文件分析
- 目标文件是程序源码经编译、汇编后,但尚未链接的程序单元模块。
- 我们知道,在.o文件中,程序按照不同部分的作用和权限不同分段section存储,一般主要有以下几个段。
- 代码编译过后的机器指令在代码段。
- 已经初始化的全局变量和局部局部静态变量在数据段。
- 未初始化的全局变量和局部静态变量在bss段。
- 局部变量在栈上分配,动态变量在堆上分配。

- 如上所示,编译汇编生成了目标文件后,安装bless二进制查看工具,下面我将逐字节分析该目标文件。a.o和b.o的结构是类似的,我这里主要分析a.o文件。
1.1 elf文件头分析
- 目标文件最开始是一个64Bytes的elf文件头,用readelf工具我们可以看到elf头的内容。

- elf文件头是一个结构体,被定义在/usr/include/elf.h中,elf头结构体如下所示。
#define EI_NIDENT (16)
typedef struct
{
unsigned char e_ident[EI_NIDENT];
Elf32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
Elf32_Addr e_entry;
Elf32_Off e_phoff;
Elf32_Off e_shoff;
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize;
Elf32_Half e_shnum;
Elf32_Half e_shstrndx;
} Elf32_Ehdr;
typedef struct
{
unsigned char e_ident[EI_NIDENT];
Elf64_Half e_type;
Elf64_Half e_machine;
Elf64_Word e_version;
Elf64_Addr e_entry;
Elf64_Off e_phoff;
Elf64_Off e_shoff;
Elf64_Word e_flags;
Elf64_Half e_ehsize;
Elf64_Half e_phentsize;
Elf64_Half e_phnum;
Elf64_Half e_shentsize;
Elf64_Half e_shnum;
Elf64_Half e_shstrndx;
} Elf64_Ehdr;
- 目标文件的前64Bytes如下所示

- 0-15字节对应于结构体中的e_ident。
(1)0-3字节表示elf文件的魔数,7F 45 4C 46为.ELF。


(2)第4字节表示elf文件类,0x01表示32位elf文件,0x02表示64位elf文件。我的elf文件为64位的。


(3)第5字节表示elf文件字节序。0x01为小端,0x02为大端。我的elf文件对应格式为小端。


(4)第6个字节表示elf文件的主版本号,一般为1。



(5)接下来的9个字节在elf标准中没有定义,一般填0,但有些平台会用这9个字节作为拓展标志。

- 16-17字节对应于结构体中的e_type,表示elf文件类型。系统通过这个常量来判断elf文件类型,而不是通过拓展名来判断。0x0001表示可重定位文件.o,0x0002表示可执行文件,0x0003表示共享目标文件.so。我的elf文件为目标文件,所以为0x0001。


- 18-19字节对应于结构体中的e_machine。它表示该elf文件的平台属性,即只能在该平台下使用。我使用的CPU为AMD系列,故为x86-64


- 20-23字节对应于结构体中的e_version。它表示该elf文件的版本号,一般为0x00000001。

- 24-31字节对应于结构体中的e_entry。它表示elf程序的入口虚拟地址,即操作系统在加载完该程序后从这个地址开始执行指令,可重定位文件一般没有入口地址,故为0。

- 32-39字节对应于结构体中的e_phoff。它表示elf的程序头表program header在elf文件中的偏移量。程序头表描述了elf文件如何被操作系统映射到进程的虚拟地址空间。可重定位文件没有程序头表,因此为0。

- 40-47字节对应于结构体中的e_shoff。它表示elf的段表section header在elf文件中的偏移量。elf文件中有各式各样的段,段表保存了这些段的基本属性,链接器和装载器都是靠段表来定位和访问elf中的段的。这里为0x0300,即段表从elf文件的第768个字节开始。

- 48-51字节对应于结构体中的e_flags。它表示一些elf文件平台相关的属性。

- 42-53字节对应于结构体中的e_ehsize。它表示elf文件头本身的大小,在这里为64字节长。

- 54-55字节对应于结构体中的e_phentsize。它表示elf程序头表入口大小,可重定位文件没有程序头表,因此为0。

- 56-57字节对应于结构体中的e_phnum。它表示elf程序头表入口数量,可重定位文件没有程序头表,因此为0。

- 58-59字节对应于结构体中的e_shentsize。它表示段表描述符的大小,等于sizeof(Elf64_Shdr)。在这里为0x0040,即64Bytes长。

- 60-61字节对应于结构体中的e_shnum。它表示段表描述符的数量,即elf文件中段的数量,在这里为0x000C,即有12个段。

- 62-63字节对应于结构体中的e_shstrndx。它表示段表字符串表在段表中的下标,在这里为0x0009,即第9个段表为段表字符串表。

- 至此,elf文件头的64个字节分析完毕,下面开始分析elf文件中最重要的结构——段表。
1.2 elf段表section table分析
- 通过前面对elf文件头的分析,我们知道了elf文件中段的一些基本信息,比如elf文件中有12个段,每个段的段表描述符长度为64Bytes,段表描述符的起始位置在该文件偏移768Bytes处。通过分析elf文件头的这些信息,我们就可以定位并分析段表了。
- 通过readelf工具,我们可以简要查看一下elf文件中的段,我们看到确实有12个段。elf段表中第一个是无效的段描述符,类型为NULL,除此之外每个段描述符对应一个段。

- 段表描述符描述了elf中段的属性,结构体定义如下所示。
typedef struct
{
Elf32_Word sh_name;
Elf32_Word sh_type;
Elf32_Word sh_flags;
Elf32_Addr sh_addr;
Elf32_Off sh_offset;
Elf32_Word sh_size;
Elf32_Word sh_link;
Elf32_Word sh_info;
Elf32_Word sh_addralign;
Elf32_Word sh_entsize;
} Elf32_Shdr;
typedef struct
{
Elf64_Word sh_name;
Elf64_Word sh_type;
Elf64_Xword sh_flags;
Elf64_Addr sh_addr;
Elf64_Off sh_offset;
Elf64_Xword sh_size;
Elf64_Word sh_link;
Elf64_Word sh_info;
Elf64_Xword sh_addralign;
Elf64_Xword sh_entsize;
} Elf64_Shdr;
- 段表描述符Elf64_Shdr各个成员的含义如下所示
(1)sh_name长度4B,表示段名字符串在字符串表.shstrtab中的偏移。
(2)sh_type长度4B,表示段的类型。段名只是在链接和编译的过程中有意义,但不能真正表示段的类型。对于操作系统来说,真正决定段属性的是段的类型sh_type和段的标志位sh_flags。

(3)sh_flags长度8B,表示该段在进程虚拟地址空间中的属性。如是否可读可写可执行等。

(4)sh_addr长度8B,如果该段可以被加载,则其表示该段被加载后在进程地址空间中的虚拟地址,否则为0。sh_offset长度为8B,如果该段存在于文件中,则表示该段在文件中的偏移,否则无意义。sh_size长度为8B,即段的长度。
(5)sh_link长度为4B,sh_info长度为4B。如果该段的类型是与链接相关的,比如重定位表或符号表等,那么这两部分才有意义,否则无意义。
(6)sh_addralign长度为8B,表示段地址对其的要求。
(7)sh_entsize长度为8B,有些段包含了一些固定大小的项,比如符号表,它包含的每个符号所占的大小都是一样的,sh_entsize表示每个项的大小。如果为0,则该段不含有固定大小的项。 - 下面我将逐字节分析这些段表描述符,从而弄清这12个段到底在目标文件中是怎么分布的。
- 我们知道有12个段描述符,每个长为64B,一共长度为768Bytes。文件长度为1536Bytes,段表偏移量为768Bytes,因此从768Bytes开始到文件尾区域全都是段描述符。段描述符真的占用了文件好大的空间啊╰(°▽°)╯。

(1)NULL段,从第768开始的64Bytes全为0,第一个段表描述符为无效段,故全为0。跳过,不分析。

(2)接下来我们分析下一个段描述符。sh_name为0x00000020,即在段表字符串表.shstrtab中的偏移为32Bytes,通过elf文件头我们知道,段表字符串表.shstrtab的段描述符为第九个段。第九个段的段描述符位置开始于768 + 9 * 64 = 1344Bytes处。

下图为.shstrtab的段描述符,sh_offset为0x00000000000002A0,即.shstrtab段的位置在文件偏移672Bytes处,sh_size为0x0000000000000059,即该段大小为89Bytes。

即文件的672Bytes开始的89Bytes范围为段表字符串段,我们可以看到段表字符串表.shstrtab中存储的是各个段的段名。

偏移量32Bytes处表示的段名为.text,即sh_name为0x00000020代表的段名为.text,原来是代码段啊。通过这一系列操作,我们终于弄清这个段的段名了,好麻烦…

sh_type为0x00000001,类型为SHT_PROGBITS,程序段、代码段、数据段都是这种类型的。sh_flags为0x0000000000000006,即为SHF_EXECINSTR | SHF_ALLOC。表示该段在进程空间中可被执行,且要分配进程空间。
sh_addr为0x0000000000000000,目标文件还未链接,故地址为0。sh_offset为0x0000000000000040,即代码段在文件中的偏移量为64Bytes,原来它紧跟在ELF头后面。sh_size为0x000000000000004A,即代码段长度为74Bytes。

这些机器指令到底是什么意思呢?我们用objdump工具反汇编代码段看看。哈哈,结果显示,代码段真的只有74Bytes,全都对上了。为了真正搞懂这个文件的每个字节,我会在后面分析这段汇编代码。

sh_link和sh_info均为0x00000000,对于重定位表和符号表来说,这两部分才有意义。sh_addralign为0x0000000000000001,即该段没有对其要求。sh_entsize为0x0000000000000000,即不包含固定大小的项。
.text段的段描述符我们分析完了,其他的段的段描述符也类似,这里就不再分析了。 - 通过分析这12个段表,我们就清楚了该文件的二进制布局,如下所示:
(1)ELF头(0x00至0x3F,64Bytes长)

(2).text段(0x40至0x89,74Bytes长),即指令段。

(3).data段( 0x8A),存放的是数据,即数据段。存放已经初始化的全局变量和局部静态变量,本文件中没有这两种变量,因此长度为0。
(4)bss段( 0x8A),存放的是未初始化的全局变量和局部静态变量。在这里需注意,bss段的段描述符指出段类型为SHT_NOBITS,即该段在文件中无内容,因此长度也为0。
(5).comment段(0x8A至0xBF,共54Bytes),存放的主要是编译器的版本信息。

(6).rodata段,该段存放的是一些只读数据,如const常量或者一些字符串常量。本文件中没有这些常量,因此不存在这个段,因为这个段比较常见,所以在这里还是提一下。
(7).note.GNU-stack段,在这里大小为0因此不占用空间。
(8).eh_frame段(0xC0至0xF7,共56Bytes),这个段主要用于系统运行时调试使用,地址对齐属性为8,即2^8 = 256Bytes,但不知为什么这里地址从0xC0开始,地址并没有对齐,为什么?我也不知道…

(9).symtab段(0xF8至0x217,共288Bytes),这个段即符号表段,该符号表记录了目标文件中所用到的所有符号。

在链接中,函数和变量统称为符号,函数名和变量名即符号名,用readelf工具可以看到这个文件中的符号表。

符号表结构体如下所示,我们看到每个结构体大小为24Bytes,有12个这样的符号,因此.symtab段总长为12 * 24 = 288Bytes。

st_name表示符号名,即该符号在字符串表中的下标。
st_info低4bits表示符号类型,高28bits表示符号的绑定信息(即局部符号、全局符号、弱符号)。

st_shndx,如果符号定义在本目标文件中,则表示该符号所在的段在段表中的下标。若未定义在本文件中,它的值有些特殊,如为STN_UNDEF则表示符号在该文件中被引用,但定义在其他文件中。
st_value,即符号值。对于不同类型的符号,它对应的值的含义不同。对于STT_SECTION类型的符号,它们表示下标为Ndx段的段名。用objdump工具,我们能清楚地看到符号所在段的段名。

st_size,即符号大小。对于包含数据的符号,这个值为该数据类型的大小。
因此我们知道符号shared和swap并未定义在a.o文件中,所以他们是UND的,如果链接器在链接时未在其他目标文件的符号表中找到全局性的对应符号,那么链接器就会报符号未定义的错误。
(10).strtab段(0x218至0x23E,共39Bytes),这个段即字符串表段。该段用于保存普通的字符串,比如符号的名字。

(11).rela.text段(0x240至0x287,共72Bytes),这个段是.text段的重定位表。每个需要重定位的代码段或数据段都会有一个重定位表,一个重定位表同时也是elf的一个段。第0x23F字节用于对齐。

(12).rela.eh_frame段(0x288至0x29F,共24Bytes),这个段是.eh_frame段的重定位表。

(13).shstrtab段(0x2A0至0x2F8,共89Bytes),这个段即段表字符串表,用于保存段表中用到的字符串,最常见的就是段名sh_name。

(13)段表(0x300至0x5EF,共12 * 64 = 768Bytes),即存放这12个段的段表。

1.3 elf文件的二进制布局
- 至此,该elf文件就分析完毕了。通过elf文件头,我们能获知elf文件的平台属性,类型,段表的位置、数量、大小。再通过分析段表,我们能获知各段的段名、属性、位置、大小、权限。各个字符串保存在**.strtab段或.shstrtab段**,.symtab段保存了符号表的各种属性。.rela等重定位表用于链接时重定位使用。.comment段保存了编译器的一些信息。
- 这个文件的二进制布局用一张图来表示就是。

1.4 重定位相关问题
- 现在我们回过头来看看代码段的内容,用objdump工具反汇编如下所示:
extern int shared;
void swap(int*, int*);
int main()
{
int a = 100;
swap(&a, &shared);
}
- 我们看到,汇编程序中有两个地方涉及到 对外部符号的引用,我们来看看目标文件中对他们是如何处理的。

- 这里我们看到main的起始地址为0,在未进行空间分配前,目标文件中代码段中的起始地址均为0,链接完成空间分配完成后,各个函数才能确定自己在虚拟地址空间中的地址。
- 汇编代码看不太懂…我先把两个目标文件链接起来吧,然后反汇编这个可执行文件,再和a.o比较一下看看有什么区别…
- 把a.o和b.o这两个目标文件链接起来,没想到却报了如下的错误…网上搜索了发现这个符号用于进行栈检查,防止栈溢出,参见__stack_chk_fail栈检查失败

- 这在里我们不进行栈检查,所以我们在编译时加上
-fno-stack-protector,自己手动进行ld链接。如下所示,果然,加上后编译出来的目标文件里就没有__stack_chk_fail这个用于栈检查的符号了。它在默认使用gcc编译链接时会自动链接glibc里的相应函数。

- 终于可以反汇编可执行文件了,如下所示…
- 我们发现链接成可执行文件后,代码段开始于0x400e8,这是代码段将要被加载到进程地址空间的虚拟地址。我们注意到,原来shared的地址0已经被重定位成0x6001B8,swap的地址已被重定位成0x40010F。那么这些修正过后的地址是如何被确定的呢?是根据我们前面的重定位表中的内容确定的!!!

- 下面这部分就是代码段的重定位表,长度为72Bytes。每个重定位入口的结构体如下所示。
r_offset对于可重定位文件来说表示重定位入口的第一个字节相对于段起始的偏移,对可执行文件与共享对象文件来说表示重定位入口的第一个字节的虚拟地址。
r_info的低32bit表示重定位入口的类型,高32bit表示重定位入口的符号在符号表中的下标。


- 用objdump工具可以看到,a.o文件中有两个段需要重定位,a.o的.text段中有三个重定位入口。这里的a.o文件是在编译时没有加
-fno-stack-protector标记的,所以其中还有栈检查符号。3 * 24 = 72Bytes,正好对上了。

- 我们先看.text段的重定位表。

- 第一个重定位表,r_offset为0x23,即重定位入口在距离.text段起始35Bytes位置处,由上面反汇编我们看到是符号shared的重定位入口,r_info为0x000000090000000A,即符号在符号表中的下标为9。

- 第二个重定位表,r_offset为0x2B,即重定位入口在距离.text段起始43Bytes位置处,由上面反汇编我们看到是符号swap的重定位入口,r_info为0x0000000A00000002,即符号在符号表中的下标为10。

- 在链接并进行重定位时,链接器会根据重定位表对目标文件中的外部符号进行重定位。