Ch05 程序的链接与加载执行
编译、汇编和静态链接
链接的概念早在高级编程语言出现之前就已存在。
高级编程语言出现之后,程序功能愈加复杂,规模愈加庞大,需要多人开发不同模块。
在每个程序模块中,包含一些变量和子程序(函数)的定义,其地址就属于符号定义。
子程序的调用或在表达式中使用变量进行计算就是符号引用。
某一个模块中定义的符号可能被另一个模块引用,因而最终必须通过链接将程序包含的所有模块合并起来,合并时须在符号引用出填入定义时的地址。
编译和汇编
此内容在 Ch01、Ch03 均提及过,将高级语言源程序文件转换为可执行目标文件分为预处理、编译、汇编、链接等过程。
前三步用来对各模块(即源程序文件)生成「可重定位目标文件」(relocatable object file),GCC 生成的文件名后缀为 .o,VS 输出的为 .obj. 最后一步是链接,用来将若干可重定位目标文件(包括若干标准库函数目标模块)组合起来,生成一个「可执行目标文件」(executable object file)。
两种目标文件为行文方便,下文分别简称为「可重定位文件」和「可执行文件」。
预处理
用于 C 语言编译器对各种预处理命令(# 开头命令)进行处理,包括头文件的包含、宏定义的扩展、条件编译的选择等,可使用 gcc -E main.c -o main.i 或 cpp main.c -o main.i 实现,预处理后的文件是可显示的文本文件。
编译
使用命令 gcc -S main.i -o main.s 或 ccl main.i -o main.s 对 main.i 编译并生成汇编代码文件 main.s.
汇编
使用命令 gcc -c main.s -o main.o 或 as main.s -o main.o 生成可重定位文件。
可执行文件的生成
可重定位文件和可执行文件都是机器语言目标文件,前者,代码总是从 0 开始;后者,代码在 ABI 规范规定的「虚拟地址空间」中产生。
链接器在将多个可重定位文件组合称一个可执行文件时,主要完成「符号解析」和「重定位」两个任务。
符号解析
符号解析的目的是将每个符号的引用与一个确定的符号定义建立关联。符号包括「全局变量名」、「静态变量名」和「函数名」,非静态局部变量名不是符号。
编译器将所有符号存放在可重定位文件的「符号表」(symbol table)中。
重定位
可重定位文件中的代码区和数据区都从地址 0 开始,链接器将不同模块中相同的节合并生成新的单独的节,并将合并后的代码区和数据区按照 ABI 规范确定的「虚拟地址空间划分」(也称「存储器映像」)重新确定位置。
例如,对于 IA32 + Linux 系统,其只读代码段总是从地址 0x8048000 开始;而可读写数据段总是在只读代码段后的第一个 4KB 对齐的地址处开始。
因而链接器需要重新确定每条指令和每个数据的地址,并且在指令中需要明确给定所引用符号的地址,这种确定代码和数据的地址并更新指令中被引用符号地址的工作称为「重定位」(relocation)。
目标文件格式
「目标代码」(object code)是指编译器或汇编器处理源代码后所生成的机器语言目标代码。
「目标文件」(object file)是指存放目标代码的文件。
通常目标文件有三类:可重定位目标文件、可执行目标文件和共享库目标文件。「共享库目标文件」是特殊的可重定位目标文件,能在装入或运行时加载到内存并自动被链接,也称为「共享库文件」。
ELF 目标文件格式
目标文件中包含可直接被 CPU 执行的机器代码以及代码在运行时使用的数据,还包含重定位信息和调试信息等,不过目标文件中唯一与运行时相关的要素是机器代码及其使用的数据,例如,用于嵌入式系统的目标文件可能仅仅含有机器代码及其使用数据。
目标文件格式有许多不同的种类,早期计算机都拥有自身独特的格式,随着 UNIX 和其他可移植操作系统的问世,人们定义了一些标准目标文件格式,并在不同的系统上使用它们。
System V UNIX 的早期版本使用的是「通用目标文件格式」(COFF, common object file format),Windows 使用的是 COFF 的一个变种,称为「可移植可执行」(PE, portable executable)格式。现代 UNIX 操作系统,如 Linux、BSD Unix 等,主要使用「可执行可链接格式」(ELF, executable and linkable format),本章采用 ELF 标准二进制文件格式进行说明。
【这里有一张图,图 5.3】
图中展示了「链接视图」和「执行视图」。
链接视图主要由不同的「节」(section)构成,节是 ELF 文件中具有相同特征的最小可处理信息单位,不同的节描述了目标文件中不同类型的信息及其特征,例如「代码节」(.text)、「只读数据节」(.rodata)、「已初始化全局数据节」(.data)、「未初始化全局数据节」(.bss)等。从上到下分别为 ELF 头、程序头表(可选)、节、节头表。
执行视图主要由不同的「段」(segment)构成,描述了目标文件中的节如何映射到存储空间的段中,可以将多个节合并后又映射到同一个段,例如可以合并 .data 和 .bss 的内容,并将内容映射到一个「可读写数据段」中。从上到下分别为 ELF 头、程序头表、段、节头表(可选)。
显然可重定位文件和可执行文件对应的 ELF 视图不同,前者对应链接视图,后者对应执行视图。
可重定位文件格式
【这里有一张图,图 5.4】
从上到小分别为 ELF 头、.text 节、.rodata 节、.data 节、.bss 节、.symtab 节、.rel.text 节、.rel.data 节、.debug 节、.line 节、.strtab 节、节头表。
ELF 头
ELF 头位于目标文件的起始位置,包含文件结构说明信息,其数据结构分为 32 位系统对应的数据结构和 64 位系统对应的。
【书中此处展示了 32 位系统对应的数据结构,共占 52 字节。】
#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;
文件开头若干字节称为「魔数」,用于确定文件的类型或格式。在加载或读取文件时,可用魔数确认文件类型是否正确。
在 32 位 ELF 头数据结构中:
- 字段
e_ident是长为 16 的字节序列,最开始 4 字节为魔数,用于标识是否为 ELF 文件,如果是,则四个字节分别为 0x7F、'E'、'L'、'F',随后的 12 字节包含一些表示信息,例如是 32 位还是 64 位格式、小端还是大端方式,ELF 头的版本号等。 - 字段
e_type用于说明目标文件类型是可重定位文件、可执行文件还是共享库文件。 - 字段
e_machine指定机器结构类型,如 IA-32、SPARC V9、AMD64 等。 - 字段
e_version标识目标文件版本。 - 字段
e_entry指定系统将控制权转移到的起始虚拟地址(入口点),可重定位文件中为 0. - 字段
e_ehsize说明 ELF 头大小。 - 字段
e_shoff指出节头表在文件中的偏移量。 - 字段
e_shentsize表示节头表中一个表项的大小,所有表项大小相同。 - 字段
e_shnum表示节头表中的项数。和e_shentsize共同指定节头表大小。 - 偏移量和各字段大小都以字节为单位。
ELF 头在文件中总是最开始的位置,其他部分的位置由 ELF 头和节头表指出,不具有固定顺序。
可以使用 readelf -h main.o 对 main.o 进行解析。
节
「节」(section) 是 ELF 文件中的主体信息,典型的 ELF 可重定位文件包含以下节:
- .text:目标代码部分。
- .rodata:只读数据。
- .data:已初始化且初值不为零的全局变量和静态变量。
- .bss:所有未初始化或初始化为零的全局变量和静态变量。目标文件区分是否初始化是为了提高磁盘利用率,未初始化的在运行时再为这些变量分配空间,之前都仅作为占位符。
- .symtab:「符号表」(symbol table)。
- .rel.text:.text 节相关的可重定位信息。
- .rel.data:.data 节相关的可重定位信息。
- .debug:调试用符号表,有些表项对定义的局部变量和类型定义进行说明,有些表项对定义和引用的全局静态变量进行说明。
- .line:C 程序中行号和 ,text 节中指令之间的映射。
- .strtab:字符串表,包括 .symtab 节和 .debug 节中的符号和节头表中的节名。字符串表就是以 NULL 结尾的字符串序列。
节头表
节头表由若干表项组成,每个表项描述某个节的节名、在文件中的偏移、大小、访问属性、对齐方式等信息,目标文件中每个节都有一个表项与之对应。
可使用 readelf -S test.o 对 test.o 进行解析。
根据每个节,可画出 test.o 的结构。
可执行文件格式
链接器将相互关联的可重定位文件中相同的代码和数据节合并,以形成可执行文件中对应的节,因为相同的代码和数据节合并后,在可执行文件中各指令和数据的位置就可确定,所以定义的函数和变量的起始位置就可确定,即每个符号的定义就可确定,从而在符号的引用出可以根据确定的符号定义进行重定位。
【这里有一张图,图5.6】
ELF 可执行文件从上到下分别为「只读代码段」、「可读写代码段」、「无须映射到存储空间的符号表和调试信息」,其中只读代码段从上到下为:「ELF 头」、「程序头表」、「.init 节、.fini 节」、「.text 节」、「.rodata 节」;可读写代码段从上到下分别为「.data 节」、「.bss 节」;无须映射...信息从上到下分别为「.symtab 节」、「.debug 节」、「.line 节」、「.strtab 节」、「节头表」。
可执行文件格式与可重定位文件格式类似,在这两种格式中,ELF 头的数据结构一样,.text 节、.rodata 节、.data 节除了有些和重定位地址不同外,其余大部分都相同。与可重定位文件格式相比,可执行文件主要有如下不同点:
- ELF 头字段
e_entry给出程序执行入口地址,可重定位文件中为 0. - 通常会有 .init 节和 .fini 节。其中 .init 节定义
_init函数,用于可执行文件开始执行时的初始化工作,当程序开始运行时,系统会在进程进入主函数main之前,先执行这个节中的指令代码;.fini 节包含进程终止时要执行的指令代码,当程序退出时,系统会执行这个节中的指令代码。 - 少了 .rel.text 和 .rel.data 等重定位信息节,因为可执行文件中的指令和数据已被重定位,所以它们不需要了。
- 多了一个「程序头表」,也称「段头表」(segment header table),它是一个结构体数组。
可执行文件中所有代码位置连续,所有只读数据位置连续,所有可读可写数据位置连续,如前图所示,它们构成了「只读代码段」(read-only code segment)和「可读写数据段」(read/write data segment)。显然,在可执行文件启动运行时,这两个段必须被装入内存并分配存储空间,因而称为「可装入段」。
为了在可执行文件执行时能够在内存中访问到代码和数据,必须将可执行文件中这些连续的具有相同访问属性的代码和数据段映射到存储空间(通常是虚拟地址空间中)。程序头表就用于描述这种映射关系,每个表项对应一个连续的「存储段」或「特殊节」。程序头表的表项大小和表项数分别由 ELF 头中的字段 e_phentsize 和 e_phnum 指定。
32 位系统的程序头表中每个表项具有以下数据结构:
typedef struct {
Elf32_Word p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
Elf32_Word p_filesz;
Elf32_Word p_memsz;
ELf32_Word p_flags;
Elf32_Word p_align;
} Elf32_Phdr;
64 位系统对应数据结构为 Elf64_Phdr,其中描述的成员与 Elf32_Phdr 类似,只是为了对齐考虑,将 p_flags 移到了 p_offset 之前。下面是详细说明:
p_type描述存储段的类型或特殊节的类型,如是否为「可装入段」(PT_LOAD)、是否是特殊的「动态节」(PT_DYNAMIC)、是否是特殊的「解释程序节」(PT_INTERP)。p_offset指出本段首字节在文件中的偏移地址。p_vaddr指出本段首字节的虚拟地址。p_paddr指出本段首字节的物理地址,但因物理地址由操作系统根据情况动态确定,所以该信息基本上无效。p_filesz指出本段在文件中所占的字节数,可以为 0.p_memsz指出本段在存储器中所占的字节数,可以为 0.p_flags指出存取权限。p_align指出对齐方式,值为 2 的正整数幂。例如可分配段的页大小为 \(4\pu{KB}=2^{12}\pu{Bytes}\),则为 0x1000=2^{12}D.
可使用 readelf -l main 命令显示可执行文件 main 的程序头表中的部分信息。
可执行文件的存储器映像
特定系统中每个可执行文件都采用统一的存储器映像,映射到一个统一的「虚拟地址空间」。对于特定系统,可执行文件与虚拟地址空间之间的「存储器映像」(memory mapping)由 ABI 规范定义。
例如,对于 IA32 + Linux 系统,i386 System V ABI 规范规定:
- 只读代码段总是映射到从虚拟地址为 0x8048000 开始的一段区域;
- 可读写数据段映射到只读代码段后 4KB 对齐的高地址上,其中 .bss 节所在存储区在运行时被初始化为 0.
- 「运行时堆」(run-time heap)则在可读写数据段后 4KB 对齐的高地址处,通过
malloc库函数动态向高地址分配空间。 - 运行时「用户栈」(user stack)则从用户空间的最大地址向低地址方向增长。
- 堆区和栈区中间有一块空间保留给共享库目标代码。
- 用户栈区以上的高地址区是操作系统内核的虚拟存储区。
【这里有一张图,图 5.8】
当启动可执行文件时,首先会通过某种方式调出常驻内存的一个称为「加载器」的操作系统程序。例如 Linux 程序的加载执行都通过调用 execve 系统调用函数来启动加载器。
加载器根据可执行文件中的程序头表,将可执行文件中的相关内容与虚拟地址空间中的只读代码段和可读写代码段建立映射,然后启动可执行文件中的第一条指令并执行。
特定系统平台中每个可执行文件映射到一个统一的虚拟地址空间,使得链接器在重定位时可以按照统一的虚拟存储空间来确定每个符号的地址,而不用管其数据和代码将来存放在主存或磁盘的何处。因此,引入统一的虚拟地址空间简化了链接器的设计和实现。
同样,引入虚拟地址空间也简化了程序加载过程,因为统一的虚拟地址空间映像使得每个可执行文件的只读代码段都映射到 0x8048000 开始的一块连续区域,而可读写数据段也映射到虚拟地址空间中的一块连续区域,所以加载器可以非常容易地对这些连续区域进行分页,并初始化相应页表项的内容。因 IA32 中页大小通常为 4KB,所以这里的可装入段都按 2^12B = 4KB 对齐。
程序在加载过程中,实际上并没有真正从硬盘上加载代码和数据到主存,而是仅仅创建了只读代码段和可读写数据段对应的页表项。只有在执行代码过程中发生了缺页异常,才会真正从硬盘加载代码和数据到主存。相关内容可参见 Ch07.
符号表和符号解析
符号和符号表
链接器在生成可执行文件时,必须完成符号解析,而要进行符号解析,则需要用到符号表,表中包含了在程序模块中定义的所有符号的相关信息。对于某个模块 m 来说,包含在其符号表中的有以下三种不同类型:
- 在 m 中定义并被其他模块引用的「全局符号」(global symbol)。这类符号包括非静态的函数名和全局变量名。
- 由其他模块定义并被 m 引用的全局符号,称为模块 m 的「外部符号」(external symbol),包括 m 所引用的在被其他模块定义的外部函数名和函数变量名。
- 在 m 中定义并在 m 中引用的「本地符号」(local symbol)。这类符号包括带 static 属性的函数名和静态变量吗。虽然在一个过程内部定义的带 static 属性的静态局部变量的作用域局限在函数内部,但因为其生存期在整个程序运行过程中,所以这种变量并不分配在栈中,而是分配在静态数据区。
如果在模块 m 内有两个不同的函数使用了同名 static 局部变量,则需要为这两个变量都分配空间,即不可视作同一个符号。
ELF 文件中包含的符号表中的每个表项具有如下数据结构:
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;
64 位系统对应的数据结构为 Elf64_Sym,其中成员的描述与 Elf32_Sym 类似。各字段含义如下:
-
st_name给出符号在字符串表中的索引(字节偏移量),指向在「字符串表」(.strtab 节)中一个以 NULL 结尾的字符串,即符号名。 -
st_value给出符号的值,在可重定位文件中,是指符号所在位置相对于所在节起始位置的字节偏移量;在可执行文件和共享目标文件中,则是符号所在的虚拟地址。 -
st_size给出符号所表示对象的字节数。若符号无大小或大小未知则标为 0. -
st_info指出符号的类型(type)和绑定属性(bind),符号类型占低 4 位,符号绑定占高 4 位:
#define ELF32_ST_BIND(info) ((info)>>4)
#define ELF32_ST_TYPE(info) ((info)&0xf)
#define ELF32_ST_INFO(bind,type) (((bind)<<4)+((type)&0xf))
符号类型可以是「未指定」(NOTYPE)、「变量」(OBJECT)、「函数」(FUNC)、「节」(SECTION)等,当符号类型位节时,其表项主要用于重定位。
绑定属性可以是「本地」(LOCAL)、「全局」(GLOBAL)、「弱」(WEAK)等。
其中,本地符号指本模块内定义和引用的带 static 属性的符号,外部模块不可见,名称相同的本地符号可存在于多个文件中而不会相互干扰;
全局符号对于所有被合并的目标文件都可见;
弱符号是通过 GCC 扩展的属性指定符 __attribute__((weak)) 指定的符号,它与全局符号一样,对于所有被合并的目标文件都可见。
-
st_other指出符号的可见性。通常在可重定位文件中指定可见性,它定义了当符号成为可执行文件或共享目标库的一部分后访问该符号的方式。 -
st_shndx指出符号所在节在节头表中的索引,有些符号属于三种特殊「伪节」(pseudosection)之一,伪节在节头表中没有相应的表项,无法表示其索引值,因而用以下特殊的索引值表示:
ABS 表示该符号不会被重定位;UNDEF 表示未定义符号,即在本模块引用而在其他模块定义的外部符号;COMMON 表示未被分配位置的未初始化全局变量,称为「COMMON 符号」,对应 st_value 字段给出其对齐要求,st_size 字段给出其最小长度。
- 可通过 GNU READELF 工具显示符号表,例如使用命令
readelf -s main.o查看main.o的符号表。
当汇编器对汇编代码文件进行进一步处理时,汇编器根据其中的「汇编指示符」(以「.」开头的行)对符号的属性进行解释,以生成可重定位文件中的符号表。
符号解析
「符号解析」的目的是将每个模块中引用的符号于某个目标模块中的定义符号建立关联。每个定义符号在代码段或数据段中都被分配了存储空间,因此将引用符号与相对应的定义符号建立关联后,就可以在重定位时将引用符号的地址重定位为相关联的定义符号的地址。
对于在同一个模块中定义且被引用的本地符号(特指在可重定位文件的符号表中绑定属性为 LOCAL 的符号)的符号解析比较容易,因为编译器会检查每个模块中的本地符号是否具有唯一的定义,所以只要找到第一个本地定义符号与之关联即可。
对于跨模块的全局符号,因为在多个和模块中可能会出现对同名全局符号进行多重定义的情况,所以链接器需要确认以那个定义为准来进行符号解析。
全局符号的解析规则
一个全局符号可能是「函数」、「.data 节中具有特定初始值的全局变量」、「.bss 中被初始化为 0 的全局变量」、「COMMON 符号(说明为 COMMON 伪节的未初始化全局变量)」、「绑定属性为 WEAK 的弱符号」,其中为行文方便,将前三类统称为强符号。
在 Linux 系统中 GCC 链接器根据以下规则处理多重定义的同名全局符号:
- 规则一:强符号不能多次定义,否则出现链接错误。
- 规则二:若出现一次强符号定义和多次 COMMON 符号或若符号定义,则以强符号定义为准。
- 规则三:若同时出现 COMMON 符号定义和若符号定义,则以 COMMON 符号定义为准。
- 规则四:若一个 COMMON 符号出现多次定义,则以其中占空间最大的一个为准。
- 规则五:若使用编译选项
-fno-common,则不考虑 COMMON 符号,而将 COMMON 符号当作强符号处理。
其中规则四是因为,符号表中仅记录 COMMON 符号的最小长度,而不记录变量的类型,所以在链接器确定多重 COMMON 符号的唯一定义时,以最小长度中的最大值为准进行符号解析,能够保证满足所有同名 COMMON 符号的空间要求。
由于多重定义变量引起的值的而改变往往是在没有任何警告的情况下发生的,而且通常在程序执行了一段时间后才表现出来且远离错误发生源,故最好使用相应的选项命令 -fno-common,以在遇到多重定义的全局符号时,触发一个错误,或使用 -Werror 选项命令将所有警告变为错误。
解决这个问题的办法是,尽量避免使用全局变量,一定要用的话,可以定义为 static 属性的静态变量。此外要尽量给全局变量赋初值使其变为强符号,而外部全局变量尽量使用 extern.
符号解析过程
编译系统通常会提供一种将多个目标模块打包称一个单独的库文件的机制,如「静态库」(static library)文件。在构建可执行文件时只需指定库文件名,链接器会自动到库文件中寻找应用程序用到的目标模块,并且只把用到的模块从库中拷贝出来。
为行文方便,将定义处的符号和引用处的符号分别称为「定义符号」和「引用符号」。
链接器按照所有可重定位文件和静态库文件出现在命令行中的顺序从左至右依次扫描它们,在此期间链接器需要维护多个集合:
- 集合 E 是指将被合并到一起组成可执行文件的所有可重定位文件集合。
- 集合 U 是指未解析符号的集合。「未解析符号」为还未与定义符号关联的引用符号。
- 集合 D 是指当且位置已被加入 E 的所有可重定位文件中定义符号的集合。、
符号解析开始时,三个集合皆为空,按如下算法进行符号解析:
- 对命令行中的每一个输入文件 f,链接器确定它是可重定位文件还是库文件:若为可重定位文件,就将其加入 E,根据 f 中未解析符号和定义符号分别更新集合 U、D.
- 如果 f 是一个库文件,链接器会尝试将 U 中的所有为解析符号与 f 中各目标模块定义的符号进行匹配。如果某个目标模块 m 定义了一个 U 中的未解析符号 x。那么就把 m 加入 E 中,并将 x 从 U 移入 D 中。不断地对 f 中的所有目标模块重复这个过程,知道 U、D 不再变化位置。那些未加入 E 中的 f 里的目标模块就被丢弃,链接器继续处理下一输入文件。
- 若处理过程中往 D 中加入一个已存在的符号(出现双重定义符号),或者当扫描完所有输入文件时 U 非空,则链接器报错并停止。否则,链接器把 E 中所有的可重定位文件进行重定位后合并在一起,以生成可执行文件。
与静态库的链接
在类 UNIX 系统中,静态库文件采用一种称为「存档档案」(archive)的特殊文件格式,其使用 .a 作为后缀。
用户也可以自定义一个静态库文件,使用 AR 工具生成静态库。
【这里有一张图,图 5.16】
关于静态库的链接顺序问题,通常的准则是将静态库文件放在命令行文件列表的后面。若有多个静态库文件,则根据这些静态库文件的目标模块中的符号是否有引用关系来确定顺序。如果两个静态库的目标有相互引用关系,则在命令行中可以重复静态库文件名。
重定位
「重定位」的目的是在符号解析的基础上将所有关联的目标模块合并,并确定运行时每个定义符号在虚拟地址空间中的地址,在定义符号的引用处重定位引用的地址。
具体来说,重定位包含以下两方面的工作:
- 节和定义符号的重定位:链接器将相互关联的所有可重定位文件中相同类型的节合并,生成一个同一类型的新节(例如所有模块的 .data 节合并为一个大的 .data 节,从而形成生成的可执行文件中的 .data 节)。然后链接器根据每个新节在虚拟地址空间中的位置以及新节中每个定义符号的位置,为新节中的每个定义符号确定地址。
- 引用符号的重定位:链接器对合并后新代码节(.text)和新数据节(.data)中的引用符号进行重定位,使其指向对应的定义符号起始处。为了实现该操作,链接器需要「重定位信息」,其存放在「重定位节」(包括 .rel.text 和 .rel.data)中
重定位信息
在 IA32 可重定位文件的 .rel.text 节和 .rel.data 节中,存放着每个需要重定位的符号的重定位信息。.rel.text 节和 .rel.data 节采用的数据类型是结构数组,每个数组元素是一个表项,每个表项对应一个需要重定位的符号,表项的数据结构如下:
typedef struct {
Elf32_Addr r_offset;
Elf32_Word r_info;
} Elf32_Rel;
各字段介绍如下:
r_offset指出当前需要重定位的位置相对于所在节的字节偏移量,变量所在节为 .data、函数所在节为 .text.r_info指出当前重定位所引用的符号在符号表中的索引值以及相应的重定位类型,符号索引(r_sym)是r_info的高 24 位,重定位类型(r_type)是其低 8 位:
#define ELF32_R_SYM(info) ((info)>>8)
#define ELF32_R_TYPE(info) ((unsigned char)(info))
#define ELF32_R_INFO(sym, type) (((sym)<<8)+(unsigned char)(type))
重定位类型与特定的处理器有关,具体由 ABI 规范定义,IA32 处理器的重定位类型由多种,最基本的是以下两种:
- R_386_PC32:指明引用处采用 PC 相对寻址方式:有效地址为 PC 内容加重定位后的 32 位地址,PC 的内容是下一条指令地址。
- R_386_32:指明引用处采用绝对地址方式,即有效地址就是重定位后的 32 位地址。
可用命令 readelf -r main.o 来显示 main.o 中的重定位表项。
为行文方便,以下把重定位后的 32 位地址简称为「重定位值」。
重定位过程
重定位过程对 .text 节和 .data 节中由相应重定位节的重定位表项所指的每一处按顺序进行。
R_386_PC32 方式的重定位
IA32 中跳转目标地址计算公式为:跳转目标地址 = PC + 偏移地址(重定位值),也就是重定位值 = 跳转目标地址 - PC,更具体地,也就是:重定位值 = ADDR(r_sym) - ((ADDR(.text) + r_offset) - init).
其中 ADDR(r_sym) 为符号 r_sym 在运行时的存储地址,ADDR(.text) 为 .text 节在运行时的起始地址,r_offset 为偏移值,init 为初值。若指令为 call 则因 call 的机器码 E8 FC FF FF FF,得知 init 为 0xFFFF FFFC(IA32 为小端)即 -4.
R_386_32 方式的重定位
此处可看书。
动态链接
还有一类目标文件是「共享目标文件」(shared object file),也称为「共享库文件」。
它是一种特殊的可重定位目标文件,能在可执行文件加载或运行时被动态地装入到内存中并自动被链接,这个过程称为「动态链接」(dynamic link),由一个称为「动态链接器」(dynamic linker)的程序来完成。
类 UNIX 系统中共享库文件采用 .so 作为后缀,Windows 则称其为「动态链接库」(DLLs, dynamic link libraries),使用 .dll 后缀。
动态链接的机制
asd
程序加载时的动态链接
asd
程序运行时的动态链接
asd
位置无关代码
asd
模块内过程调用和跳转
asd
模块内数据引用
asd
模块间数据引用
asd
模块间过程调用和跳转
asd
库打桩机制*
编译时打桩*
链接时打桩*
运行时打桩*
可执行文件的加载与执行
经过预处理、编译、汇编、链接生成的可执行文件可被直接加载执行,但在指令和数据被取到 CPU 中处理之前,需要先将其从硬盘的可执行文件加载到内存中。
可执行文件的加载
在 Linux 系统的 shell 命令行提示符下输入可执行文件名以及相应的参数就可启动可执行文件的加载执行,例如对于可执行文件 test,若不实现库打桩功能,只需执行 ./test 命令即可加载运行 test.
命令行解释程序 shell 接受到输入的 ./test 命令后,首先检查 test 是否为内置 shell 命令,否,于是通过执行 execve() 函数调用驻留在内存中的「加载器」(loader)执行。
加载器是操作系统内核代码,它将可执行文件中的只读代码段和可读写数据段从硬盘“拷贝”到内存,然后跳转到可执行文件第一条指令处执行,此处由 ELF 头中的「入口点地址」(entry point address)e_entry 字段指定。
这个过程称作可执行文件的加载。
此处「拷贝」打引号,正如前文所说,加载过程中实际上并没有真正将代码和数据从硬盘上读到内存,而仅仅创建了只读代码段和可读写数据段对应的初始页表项。只有在执行代码过程中发生了缺页异常,才会真正加载代码和数据到主存。在生成可执行文件的过程中,链接器会按照 ABI 规范规定的存储器映像来确定所有指令和数据的地址,在可执行文件的程序头表中,对只读代码段和可读写数据段在文件地址与虚拟空间区段之间建立映射关系。因此在加载其对可执行文件进行加载处理的过程中,可以利用 ELF 文件程序头表中的映射关系构建可执行文件对应进程的初始描述星系,并生成对应进程的初始页表,以完成实质上的「拷贝」。此处进程描述信息通常称为「进程控制块」。
当加载器完成「拷贝」后,加载器跳转到程序入口地址处执行,该地址对应全局符号 _start 的取值,因此 _start() 函数是可执行文件调用的第一个函数,在启动例程 ctrl.o 中定义(函数位于 .text 节),每个 C 程序都是如此。
可执行文件 _start 处定义的启动代码主要通过一系列过程调用初始化「运行时环境」,在动态链接方式下首先调用「系统启动函数」__libc_start_main(),该函数在 libc.so 中定义,对应符号定义位于可执行文件的 .plt 节。
在静态链接方式下会依次调用 __libc_init_first 和 _init 两个初始化过程,随后通过调用 atexit() 过程登记注册程序正常结束时需要调用的函数,这个函数称为「终止处理函数」,由 exit() 函数自动调用执行。
然后再调用可执行目标中的主函数 main(),最后调用 exit() 过程,结束进程的执行。
程序和指令的执行过程
CPU 取出并执行一条指令的时间称为「指令周期」,不同指令的指令周期可能不同。
【这里有一张图,图 5.28】
CPU 执行一条指令的过程如上图所示,大致分为「取指令」、「指令译码」、「计算源操作数地址并取操作数」、「执行数据操作」、「计算目的操作数地址并存结果」、「计算下一条指令地址」这几个步骤。
【这个 DLCO 讲过了,想看也可以再看一遍书。】
根据对上述指令执行过程的分析,每条指令的功能总是通过对以下 4 种基本操作进行组合实现的:
- 读取指定存储地址中的内容,并将其装入寄存器。
- 把一个数据从某个寄存器存储到给定的存储地址中。
- 把一个数据从某个寄存器传送到另一寄存器或 ALU 中。
- 在 ALU 中进行某种算术运算或逻辑运算,结果送某寄存器。
CPU 的基本功能和基本组成
CPU 的基本职能是周而复始地执行指令,指令执行过程中的全部操作由 CPU 中的控制器控制执行。
随着超大规模集成电路技术的发展,更多的功能逻辑被集成到 CPU 芯片中,包括 cache、MMU、浮点运算逻辑、异常和中断处理等,因而 CPU 的内部组成愈加复杂,甚至可以在一个 CPU 芯片中集成许多处理器核。
但无论如何,CPU 的最基本部件还是「数据通路」(data path)和「控制器」(control unit)。控制器根据每条指令功能的不同,生成对数据通路的控制信号,并正确控制指令的执行。
【这里有一张图,图 5.29】
【这个 DLCO 讲过了,想看也可以再看一遍书。】
打断程序正常执行的事件
从开机后 CPU 被加电开始到断电为止,CPU 自始至终一直重复做一件事情:读出 PC 所致存储单元的指令并执行它。正常情况下,CPU 按部就班地按照程序规定的顺序执行一条接一条指令,要么顺序,要么因跳转指令跳转到目标指令执行。其都正常。但有时 CPU 会遇到一些特殊情况而无法继续执行当前程序,包括如下事件:
- 「非法操作码」:对指令操作码进行译码时,发现操作码不符合规定情况,即非法操作码,CPU 不知道如何执行而无法继续执行。
- 「段错误」或「缺页」:在访问指令或数据时访问不到。CPU 没有获得正确的指令和数据而无法继续执行。
- 「运算失败」:ALU 运算结果发生溢出,或除数为零。CPU 发现运算结果不正确而无法继续执行。
- 「外部事件」:在执行过程中,CPU 外部发生了「采样计时时间到」、「网络数据包到达网络适配器」、「磁盘完成数据读写」等外部事件,要求 CPU 中止当前程序的执行,转而执行专门的外部事件处理程序。
因此 CPU 除了正常执行,还须具有程序的正常执行被打断的处理机制,其称为「异常处理机制」或「中断处理机制」,CPU 中相应的异常和中断处理逻辑被称为「中断机构」。
所有这些打断程序正常执行的事件都被分为两大类:「内部异常」和「外部中断」,参见 Ch08 & Ch09.