目标文件的格式

目标文件的格式和可执行文件的格式一样

  • 在Windows下,我们可以统称它们为PE-COFF文件格式。
  • 在Linux下,我们可以将它们统称为ELF文件

不光是可执行文件(Windows的exe和Linux下的ELF可执行文件)按照可执行文件格式存储。动态链接库(DLL Dynamic Linking Library ) (Windows的.dll和Linux的.so)及静态链接库(Static Linking Library) (Windows的lib和Linux的.a)文件都按照可执行文件格式存储。

目标文件是什么样的

一般目标文件将这些信息按不回的属性,以“节”(Section)的形式存储.有时候也叫“段”(Segment),它们都表示一个—定长度的区域。
fig0301.svg

  1. File Header 文件头:ELF文件的开头是一个“文件头”,它描述了整个文件的文件属性,包括文件是否可执行、是静态链接还是动态链接及入口地址(如果是可执行文件)、目标硬件、目标操作系统等信息.
    1. 文件头还包括一个段表(Section Table),段表其实是一个描述文件中各个段的数组。段表描述了文件中各个段在文件中的偏移位置及段的属性等,从段表里面可以得到每个段的所有信息。
  2. .text section: 程序源代码编译后的机器指令经常被放在代码段(Code Section)里,代码段常见的名字有.code.text
  3. .data section: 全局变量和局部静态变量数据经常放在数据段(Data Section), 数据段的一般名 字 都叫.data
  4. .bss section: 未初始化的全局变量和局部静态变量一般放在一个叫.bss的段里,因为未初始化,所以并未对.bss段分配空间,这是一种节约空间的做法

    BSS (Block Started by Symbol)这个词最初是UA- SAP汇编器(UnitedAircraft Symbolic Assembly Program)中的一个伪指令,用干为符号预留一块内存空间。

分段的好处

:::tips

  1. 区域的权限可以被分别设置成可读写和只读。
  2. 现代CPU的缓存一般都被设计成数据缓存和指令缓存分离,所以程序的指令和数据被分开存放对CPU的缓存命中率提高有好处。
  3. 就是当系统中运行着多个该程序的副本时,它们的指令都是一样的,所以内存中只须要保存一份改程序的指令部分。对于指令这种只读的区域来说是这样,对于其他的只读数据也一样 :::

    挖掘SimpleSection.o

    以一段程序为例子: ```c /*
  • SimpleSection. c
  • Linux:
  • gcc -c SimpleSection.c
  • Windows:
  • c1 SimpleSection. c /c /Za / 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 ; } ```
  1. 执行编译命令,得到目标文件SimpleSection.o:$ gcc -c SimpleSection.c
  2. objdump来查看object内部的结构: $ objdump -h SimpleSection.o

    文件实际结构

    ```bash SimpleSection.o: 文件格式 elf64-x86-64

节: Idx Name Size VMA LMA File off Algn 0 .text 0000005f 0000000000000000 0000000000000000 00000040 20 CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE 1 .data 00000008 0000000000000000 0000000000000000 000000a0 22 CONTENTS, ALLOC, LOAD, DATA 2 .bss 00000004 0000000000000000 0000000000000000 000000a8 22 ALLOC 3 .rodata 00000004 0000000000000000 0000000000000000 000000a8 20 CONTENTS, ALLOC, LOAD, READONLY, DATA 4 .comment 0000002c 0000000000000000 0000000000000000 000000ac 20 CONTENTS, READONLY 5 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000d8 20 CONTENTS, READONLY 6 .note.gnu.property 00000020 0000000000000000 0000000000000000 000000d8 23 CONTENTS, ALLOC, LOAD, READONLY, DATA 7 .eh_frame 00000058 0000000000000000 0000000000000000 000000f8 23 CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA

  1. 段的数量有很多,除了之前说的`.text .data .bss`之外,还有其他的段。<br />.rodata:只读数据段<br />.comment :注释信息段<br />.note.GNU-stack:堆栈提示段
  2. :::tips
  3. 如何看上述的表?
  4. - 每个段的第一行有大小(size)和偏移量(file off
  5. - 第二行表明了段的属性,其中“`CONTENTS`”表示该段在文件中存在。我们可以看到BSS段没有,表示它实际上在ELF文件中不存在内容。
  6. :::
  7. 这样看来一个obj文件的实际结构如下:<br />![fig0301.svg](https://cdn.nlark.com/yuque/0/2022/svg/1303425/1652012131909-1c81f864-238e-49b6-89f9-acad042eb608.svg#clientId=u7d4e741e-09ce-4&crop=0&crop=0&crop=1&crop=1&from=ui&height=339&id=ua4f2f70b&margin=%5Bobject%20Object%5D&name=fig0301.svg&originHeight=291&originWidth=242&originalType=binary&ratio=1&rotation=0&showTitle=false&size=16290&status=done&style=none&taskId=u310a750b-2252-4300-827e-e93664115fc&title=&width=282)<br />通过`$size SimpleSection.o`也可以直接看出文件中各个段的分布
  8. ```bash
  9. text data bss dec hex filename
  10. 219 8 4 231 e7 SimpleSection.o

代码段

$objdump - s - d SimpleSection.o

  • -s参数可以将所有段的内容以十六进制的方式打印出来
  • -d参数可以将所有包含指令的段反汇编。

  • 十六进制打印得到结果如下:

Contents of section.text就是text的数据以十六进制方式打印出来的内容,最左边是偏移量,左侧是原始数据的十六进制格式,右侧是对应的ASCII码形式

  1. SimpleSection.o 文件格式 elf64-x86-64
  2. Contents of section .text:
  3. 0000 f30f1efa 554889e5 4883ec10 897dfc8b ....UH..H....}..
  4. 0010 45fc89c6 488d3d00 000000b8 00000000 E...H.=.........
  5. 0020 e8000000 0090c9c3 f30f1efa 554889e5 ............UH..
  6. 0030 4883ec10 c745f801 0000008b 15000000 H....E..........
  7. 0040 008b0500 00000001 c28b45f8 01c28b45 ..........E....E
  8. 0050 fc01d089 c7e80000 00008b45 f8c9c3 ...........E...
  9. .......
  • 执行反汇编的结果如下:

可以很明显地看到,.text段里所包含的正是SimpleSection.c里两个函数func1()main()的指令。.text段的第一个字节0x55就是func1()函数的第一条push%ebp指令

  1. Disassembly of section .text:
  2. 0000000000000000 <funcl>:
  3. 0: f3 0f 1e fa endbr64
  4. 4: 55 push %rbp
  5. 5: 48 89 e5 mov %rsp,%rbp
  6. 8: 48 83 ec 10 sub $0x10,%rsp
  7. c: 89 7d fc mov %edi,-0x4(%rbp)
  8. f: 8b 45 fc mov -0x4(%rbp),%eax
  9. 12: 89 c6 mov %eax,%esi
  10. 14: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 1b <funcl+0x1b>
  11. 1b: b8 00 00 00 00 mov $0x0,%eax
  12. 20: e8 00 00 00 00 callq 25 <funcl+0x25>
  13. 25: 90 nop
  14. 26: c9 leaveq
  15. 27: c3 retq
  16. 0000000000000028 <main>:
  17. 28: f3 0f 1e fa endbr64
  18. 2c: 55 push %rbp
  19. 2d: 48 89 e5 mov %rsp,%rbp
  20. 30: 48 83 ec 10 sub $0x10,%rsp
  21. 34: c7 45 f8 01 00 00 00 movl $0x1,-0x8(%rbp)
  22. 3b: 8b 15 00 00 00 00 mov 0x0(%rip),%edx # 41 <main+0x19>
  23. 41: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 47 <main+0x1f>
  24. 47: 01 c2 add %eax,%edx
  25. 49: 8b 45 f8 mov -0x8(%rbp),%eax
  26. 4c: 01 c2 add %eax,%edx
  27. 4e: 8b 45 fc mov -0x4(%rbp),%eax
  28. 51: 01 d0 add %edx,%eax
  29. 53: 89 c7 mov %eax,%edi
  30. 55: e8 00 00 00 00 callq 5a <main+0x32>
  31. 5a: 8b 45 f8 mov -0x8(%rbp),%eax
  32. 5d: c9 leaveq
  33. 5e: c3 retq

数据段和只读数据段

十六进制输出.data段后发现数据如下。.data段保存的是那些已经初始化了的全局静态变量和局部静态变量

  1. Contents of section .data:
  2. 0000 54000000 55000000 T...U...

恰好对应

  1. 全局变量int global_init_var = 84;
  2. 局部静态变量static int static_var=85;

此外这种存放方式涉及CPU的字节序(ByteOrder)的问题,也就是所谓的大端(Big-endian)和小端(Little-endian)的问题。

  • printf中用到了一个字符串常量%d\n,它是一种只读数据,所以它被放到了.rodata
    1. Contents of section .rodata:
    2. 0000 25640a00 %d..

    BSS段

    .bss段存放的是未初始化的全局变量和局部静态变量,但是实际上更多的取决于编译器,有些编译器会将全局的未初始化变量存放在目标文件.bss段,有些则不存放.只是预留一个未定义的全局变量符号,等到最终链接成可执行文件的时候再在.bss段分配空间。

    Quiz变量

    1. static int x1 = 0;
    2. static int x2 = 1;
    实际上x1会被放在.bss段,因为他初始化未0,被看作未初始化,以此来节约空间

    其他段

    | 常用的段名 | 说明 | | —- | —- | | .rodata1 | Read only Data,这种段里存放的是只读数据,比如字符串常量、全局const变量.跟”.rodata”一样 | | .comment | 存放的是编译器版本信息,比如字符串:“GCC:(GNU) 4.2.0’’ | | .debug | 调试信息 | | .dynamic | 动态链接信息 | | .hash | 符号哈希表 | | .line | 调试时的行号表,即并代码行号与编译后指令的对应表 | | .note | 额外的编译器信息。比如程序的公司名、发布版本号等 | | .shstrtab | Section String Table.段名表 |

ELF文件结构描述

简化版ELF文件如图所示,省略了不必要的section:
fig0301.svg

ELF Header

ELF头文件包含了描述整个文件的基本属性,比如ELF文件版本、目标机器型号、程序入口地址等。
查看ELF头文件,可看到如下信息。

  1. $readelf -h SimpleSection.o
  2. ELF 头:
  3. Magic 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  4. 类别: ELF64
  5. 数据: 2 补码,小端序 (little endian)
  6. Version: 1 (current)
  7. OS/ABI: UNIX - System V
  8. ABI 版本: 0
  9. 类型: REL (可重定位文件)
  10. 系统架构: Advanced Micro Devices X86-64
  11. 版本: 0x1
  12. 入口点地址: 0x0
  13. 程序头起点: 0 (bytes into file)
  14. Start of section headers: 1184 (bytes into file)
  15. 标志: 0x0
  16. Size of this header: 64 (bytes)
  17. Size of program headers: 0 (bytes)
  18. Number of program headers: 0
  19. Size of section headers: 64 (bytes)
  20. Number of section headers: 14
  21. Section header string table index: 13

:::tips ELF的文件头中定义了ELF魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI版本、ELF重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置和长度及段的数量等。 ::: 实际上是通过一个文件头结构体来描述的,32位机器和64位机器有着不同的结果

  1. typedef struct{
  2. unsigned char e_ident[16];
  3. Elf32_Half e_type;
  4. Elf32_Half e_machine; //ELF文件的CPU平台属性.相关常量以EM_开头
  5. Elf32_Word e_version; //ELF版本号,一般为常数l
  6. Elf32_Addr e_entry;
  7. Elf32_Off e_phoff;
  8. Elf32_Off e_shoff; //段表在文件中的偏移
  9. Elf32_Word e_flags;
  10. Elf32_Half e_ehsize; //ELF文件头本身的大小
  11. Elf32_Half e_phentsize;
  12. Elf32_Half e_phnum;
  13. Elf32_Half e_shentsize;
  14. Elf32_Half e_shnum;
  15. Elf32_Half e_shstrndx;
  16. } Elf32_Ehdr;

段表

  • 段表是ELF文件中除了文件头以外最重要的结构,它描述了ELF的各个段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其他属性
  • 也就是说,ELF文件的段结构就是由段表决定的,编译器、链接器和装载器都是依靠段表来定位和访问各个段的属性的。
  • 段表在ELF文件中的位置由ELF文件头的e_shoff成员决定
  • 使用readelf查看段表文件后得到如下结果: ```basic readelf -S SimpleSection.o There are 14 section headers, starting at offset 0x4a0:

节头: [号] 名称 类型 地址 偏移量 大小 全体大小 旗标 链接 信息 对齐 [ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0 [ 1] .text PROGBITS 0000000000000000 00000040 000000000000005f 0000000000000000 AX 0 0 1 [ 2] .rela.text RELA 0000000000000000 00000380 0000000000000078 0000000000000018 I 11 1 8 [ 3] .data PROGBITS 0000000000000000 000000a0 0000000000000008 0000000000000000 WA 0 0 4 [ 4] .bss NOBITS 0000000000000000 000000a8 0000000000000004 0000000000000000 WA 0 0 4 [ 5] .rodata PROGBITS 0000000000000000 000000a8 0000000000000004 0000000000000000 A 0 0 1 [ 6] .comment PROGBITS 0000000000000000 000000ac 000000000000002c 0000000000000001 MS 0 0 1 [ 7] .note.GNU-stack PROGBITS 0000000000000000 000000d8 0000000000000000 0000000000000000 0 0 1 [ 8] .note.gnu.propert NOTE 0000000000000000 000000d8 0000000000000020 0000000000000000 A 0 0 8 [ 9] .eh_frame PROGBITS 0000000000000000 000000f8 0000000000000058 0000000000000000 A 0 0 8 [10] .rela.eh_frame RELA 0000000000000000 000003f8 0000000000000030 0000000000000018 I 11 9 8 [11] .symtab SYMTAB 0000000000000000 00000150 00000000000001b0 0000000000000018 12 12 8 [12] .strtab STRTAB 0000000000000000 00000300 000000000000007c 0000000000000000 0 0 1 [13] .shstrtab STRTAB 0000000000000000 00000428 0000000000000074 0000000000000000 0 0 1 Key to Flags: W (write), A (alloc), X (execute), M (merge), S (strings), I (info), L (link order), O (extra OS processing required), G (group), T (TLS), C (compressed), x (unknown), o (OS specific), E (exclude), l (large), p (processor specific)

  1. 段表 的结构比较简单,它是一个以`Elf32_Shdr`结构体为元素的数组。数组元素的个数等于段的个数,每个`Elf32_Shdr`结构体对应一个段。`Elf32_Shdr`又被称为段描述符(SectionDescriptor)。
  2. > ELF文件里面很多地方采用了这种与段表类似的数组方式保存。一般定义一个 固定长度的结构,然后依次存放。
  3. ```c
  4. typedef struct{
  5. Elf32_Word sh_name;//Section name 段名是个字符串,它位于一个叫做“shstrtab"的字符串表.
  6. //sh_name是段名字符串在“.shstrtab"中的偏移
  7. Elf32_Word sh_type;//Section type段的类型
  8. Elf32_Word sh_flags;
  9. Elf32_Addr sh_addr;
  10. Elf32_off sh_offset;
  11. Elf32_Word sh_size;
  12. Elf32_Word sh_link;
  13. Elf32_Word sh_info;
  14. Elf32_Word sh_addralign;
  15. Elf32_Word sh_entsize;
  16. }Elf32_Shdr;

段的类型(sh_type)

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

  • SHT_NULL:0, 无效段
  • SHT_PROGBITS:1, 程序段:代码段和数据段都是这样
  • SHT_SYMTAB:2, 符号表
  • SHT_STRTAB: 3, 字符串表
  • SHT_RELA:4, 重定位表
  • ……

段的标志位(sh_flag)

段的标志位表示该段在进程虚拟地址空间中的属性,比如是否可写,是否可执行等。

  • SHF_WRITE:1,表示该段在进程空间中可写
  • SHF_ALLOC:2, 表示该段在进程空间中必须要分配空间
  • SHF_EXECINSTR: 4,表示可执行,一般为代码段。

e.g.

  1. |section name|---sh_type---| sh_flag |
  2. | .bss | SHT_NOBITS |SHF_ALLOC+SHF_WRITE|
  3. | .data | SHT_PROGBITS|SHF_ALLOC+SHF_WRITE|

重定位表

链接器在处理目标文件时,须要对目标文件中某些部位进行重定位,即代码段和数据段中那些对绝对地址的引用的位置。这些重定位的信息都记录在ELF文件的重定位表里面,对于每个须要重定位的代码段或数据段,都会有一个相应的重定位表。

字符串表

ELF文件中用到了很多字符串,比如段名、变量名等。因为字符串的长度往往是不定的.所以用固定的结构来表示它比较困难。一种很常见的做法是把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串
image.png
在ELF文件中引用字符串只须给出一个数字下标即可,不用考虑字符串长度的问题。一般字符串表在ELF文件中也以段的形式保存。

  1. 常见的段名为.strtab.shstrtab
  2. 这两个字符串表分别为字符串表(String Table)和段表字符串表(Section Header String Table)。
  3. 字符串表用来保存普通的字符串,比如符号的名字;
  4. 段表字符串表用来保存段表中用到的字符串,最常见的就是段名( sh_name)

    链接的接口一一符号

    链接的本质过程就是将多个目标文件粘合在一起,实际上这种拼合是对目标文件之间的地址的引用,即对变量和函数地址的引用。
    每个函数或变量都有自己独特的名字,才能避免链接过程中不问变量和函数之间的混淆。在链接中,我们将函数和变量统称为符号(Symbol),函数名或变量名就是符号名(Symbol Name)。 :::warning 链接很大程度上是依靠符号来运作的 ::: 每一个目标文件都会有一个相应的符号表(Symbol Table),这个表记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值,叫做符号值(Symbol Value),对于变量和函数来说,符号值就是它们的地址。

    符号表结构

    ELF文件中的符号表往往是文件中的 一个段,段名—般叫“.symtab”。符号表的结构很简单,它是一个Elf32_Sym结构(32位ELF文件)的数组,每个Elf32_Sym结构对应一个符号。 :::info 这和之前的段表的构成很相似 :::
    1. typedef struct {
    2. Elf32_Word st_name; //符号名,实际是字符串表的下标
    3. Elf32_Addr st_value; //符号的值,一般是地址
    4. Elf32_Word st_size;
    5. unsigned char st_info; //符号类型和绑定信息
    6. unsigned char st_other;
    7. Elf32_Half st_shndx; //符号所在段
    8. } Elf32_Sym;

    符号类型和绑定信息

    image.png

    符号所在段

image.png
st_value的值会根据这两个常量的不同有不同的含义; 如果符号定义在本文件中,那么st_value值简单地表示所在段的下标;如果不是的话会比较复杂

  • 如果符号不是COMMON块,那么表示符号在段中的偏移
  • 如果是COMMON块,那么表示对齐属性
  • 可执行文件中,st_value表示虚拟地址

    特殊符号

    在链接的过程中,链接器会自己定义一些特殊符号,这些符号我们程序中并未定义,但是可以直接声明引用;

  • __executable_start:程序的开始的地址(并非程序入口)

  • __etext:代码段结束的地址
  • _end or end:程序代码结束地址

符号修饰与函数签名

:::info 在一开始,为了避免和库中函数名称相同的问题,C语言在编译后会自动给函数名之前加_,例如foo->_foo

  • 之后C++引入了namespace的概念来解决这些问题。
  • 此外C++还支持函数重载,即同一个函数名,有不同的输入参数。 ::: 如何在链接时区分这些同名的函数呢?

    符号修饰(Name Decoration)或符号改编(Name Mangling)机制

:::info 函数签名
函数签名包含了一个函数的信息,包括函数名、它的参数类型、它所在的类和名称空间及其他信息。函数签名用于识别不同的函数,就像签名用丁识别不同的人一样 ::: 在编译器及链接器处理符号时,它们使用某种名称修饰的方法,使得每个函数签名对应一个修饰后名称(Decorated Name)。编译器在将C++源代码编译成目标文件时,会将函数和变量的名字进行修饰,形成符号名,也就是说,C++的源代码编译后的目标文件中所使用的符号名是相应的函数和变量的修饰后名称
e.g.

函数签名 符号名
int func(int ) _Z4funci
float func(float) _Z4funcf
int C::func(int) _ZN1C4funcEi
int C::C2::func(int) _ZN1C2C24funcEi
int N::func(int) _ZN1N4funcEi

:::warning

  1. 签名和名称修饰机制不光被使用到函数上,C++中的全局变员和静态变量也有同样的机制。不同的编译器厂商的名称修饰方法可能不同
  2. 由于不同的编译器采用不同的名字修饰方法,必然会导致由不同编译器编译产生的目标文件无法正常相互链接,这是导致不同编译器之间不能互操作的主要原因之一。 :::

extern “C”

C++为了与C兼容,在符号的管理上,C++有一个用来声明或定义一个C的符号的extern "C"关键字用法

  1. extern "C"{
  2. int func(int);
  3. int var;
  4. }

C++编译器会将在extern "C"的大括号内部的代码当作C语言代码处理。所以很明显,上面的代码中,C++的名称修饰机制将不会起作用。
此外,符号修饰也是导致混用C++和C代码的一个桎梏。常用解决方式就是定义extern "C"

  1. #ifdef _cplusplus
  2. extern "C"{
  3. #endif
  4. void *memset (void *, int, size_t);
  5. #ifdef_cplusplus
  6. }
  7. #endif

弱符号与强符号

在编程中经常遇到一个符号被多次定义或者多个目标文件中含有相同名字的全局变量;
如果两个同名的全局变量,在链接时会报错,那么是强符号;否则被称为弱符号。
编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。我们也可以通过GCC的__attribute((weak))__来定义任何一个强符号为弱符号。

  1. int weak;
  2. __attribute__ ((weak)) weak2 = 2;

weakweak2就是弱符号

:::warning 链接器处理的规则:

  1. 不允许强符号被多次定义(即不同的目标文件中不能有同名的强符号):如果有多个强符号定义,则链接器报符号重复定义错误。
  2. 如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号。
  3. 如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个 :::

类似的,还有弱引用和强引用,主要针对函数名称。