# ELF原理三
程序加载和动态链接(PROGRAM LOADING AND DYNAMIC LINKING)
本章将详细描述目标对象文件的具体信息和如何根据这些信息在内存中创建并运行程序的原理。本章描述的大部分信息适用于所有系统,而对于少部分特定于处理器的信息也将会一一描述。
可执行和共享目标对象文件表示了程序的静态信息: 代码 + 数据 。为了执行这样的程序,操作系统需要使用这些文件信息来创建动态程序表示,也称之为内存程序映像,也称之为进程,后文直接称之为进程。进程由代码段、数据段、堆栈段组成。本章的主要讨论以下内容:
- 程序头部(Program header)。这部分补充了前面对于程序头部的描述信息,详细介绍了与程序执行直接相关的目标对象文件结构。主要的数据结构是程序头部表,它定位文件中的段信息,并包含为程序创建程序所需的其他信息,所以该部分将是本章的主要内容
- 程序加载(Program loading)。当我们指定需要运行一个目标文件,操作系统必须将其加载到内存中,以便程序运行,这部分将描述操作系统加载程序的处理细节。
- 动态链接(Dynamic linking)。在操作系统加载程序之后,它必须通过解析组成进程的目标文件之间的符号引用来完成进程的创建,这部分将详细描述动态链接中对于符号解析的细节
注:对于具有指定处理器的特定 ELF常量 有一些命名约定,如:DT, PT,用于处理器特定的扩展,例如:DT_M32_SPECIAL。
程序头部(Program Header)
一个可执行或共享目标文件的程序头部表是一个结构数组,数组中的每个结构描述了一个程序段加载到内存中创建进程的详细信息。一个目标对象文件段由一个或多个节组成,正如我们前面描述的那样:在链接时将节信息组合,在执行时加载节信息组合而成的段信息。程序头部表只对可执行和共享的目标文件有意义。一个文件用ELF头的 e_phentsize 和e_phnum 变量来指定它自己的程序头大小(可参考前面描述的程序头(ELF Header)描述)。程序头部表如下所示。
- p_type:该变量表示当前描述的段类型。类型值及其含义如下所示:
PT_NULL:表示程序头部数组中当前项该段未使用,其他变量的值未定义,将会被操作系统加载时忽略。
PT_LOAD:表示程序头部数组中当前项一个可加载的段,由 p_filesz 和 p_memsz 变量共同描述,用于将文件中 指定偏移量的字节被映射到对应内存段中。如果段的内存大小(p_memsz)大于文件大小(p_filesz)(什么时候会大于呢?考虑下内存对齐、bss未初始化变量 ~ 很容易理解),则多余字节用0填充,并位于段初始化区域之后。文件大小不能大于内存大小。程序头表中的可加载段将按p_vaddr变量地址升序排列(也即虚拟地址的高低排布)。
PT_DYNAMIC:表示程序头部数组中当前项的动态链接信息。详细信息将在后面的 动态链接 部分描述
PT_INTERP:表示程序头部数组中当前项要作为解释器(动态链接器)调用的以空白符结尾的路径名的位置和大小。这个段类型只对可执行文件有意义(虽然它可能发生在共享对象中)它在一个文件中只能出现一次,如果存在该类型的数组项,那么它必须位于任何可加载段条目之前。详细信息将在后面的 程序解释器 部分描述
PT_NOTE:表示程序头部数组中当前项辅助信息的位置和大小。详细信息将在后面的 注释节 部分描述
PT_SHLIB:此段类型为保留类型,具有未指定的语义。包含这种类型的程序不符合ABI规范定义。
PT_PHDR:表示程序头部数组中当前项的程序头表在文件和程序内存映像中的位置和大小。该段类型在一个文件中只能出现一次,而且,只有当程序头表本身需要作为进程的一部分时才会出现,如果存在,它必须位于任何可加载段条目之前。详细信息将在后面的 程序解释器 部分描述
PT_LOPROC 到 PT_HIPROC:这个范围内的值为特定于处理器的语义保留
\2. p_offset:该变量表示段的第一个字节从文件开始的偏移量
\3. p_vaddr:该变量表示段的第一个字节在内存中的虚拟地址
4.p_paddr:在与物理寻址相关的操作系统上,该变量保留用于表示段的物理地址。由于 System V (System V, 曾经也被称为 AT&T System V,是Unix操作系统众多版本中的一支。它最初由 AT&T 开发,在1983年第一次发布。一共发行了4个 System V 的主要版本:版本1、2、3 和 4。System V Release 4,或者称为 SVR4,是最成功的版本,成为一些 UNIX 共同特性的源头,例如 ”SysV 初始化脚本“ (/etc/init.d),用来控制系统启动和关闭,System V Interface Definition (SVID) 是一个System V 如何工作的标准定义。)忽略应用程序的物理寻址,所以该变量在可执行文件和共享对象文件表示未指定的内容,通常与 p_vaddr 相同。
5.p_filesz:该变量表示段的目标文件中占用的总字节数,可能为0
6.p_memsz:该变量表示段在内存中占用的总字节数,可能为0
7.p_flags:该变量表示段相关的标志
8.p_align:正如本章后面的 程序加载 一节所描述的,可加载的进程段必须为 p_vaddr 和 p_offset 取页面大小模的一致值,也即内存对齐,这时使用该变量给出了在内存和文件中对这些段进行对齐的值。值0和1表示不需要对齐,否则,p_align应为2的正整数次幂,同时 p_vaddr 应为 p_offset 取 p_align 的模
基地址(Base Address)
可执行和共享目标文件有一个基址,它是表示程序的目标文件的内存映像(进程)的最低虚拟地址,也即进程在内存中的起始地址。基址的一个用途是在动态链接期间重新定位程序的内存映像。
在进程执行过程中,可执行或共享目标文件的基址将由三个值计算生成:内存加载地址、页大小、程序可加载段的最低虚拟地址。这里了解下即可,因为我们将在后面的 程序加载 一节中讲解:程序头中的虚拟地址可能并不代表程序内存映像的实际虚拟地址。要计算基址,需要确定与 PT_LOAD 段的最低 p_vaddr 变量值相关联的内存地址,然后通过将内存地址对齐到页大小的最近倍数来获得基址(因为操作系统操作内存的基础单元为页,一页在通用Linux操作系统中为4KB,最大在Intel为4M,可以参考Intel手册,或者混沌学堂的课程)。根据加载到内存中的目标文件类型,内存地址可能与 p_vaddr 值相同,也可能不同。
正如前面 Sections(节) 所描述的那样,.bss 节的类型为SHT_NOBITS。虽然它不占用文件中的空间,但它却在内存映像中参与分配内存(bss 节中包含了未初始化的变量类型,但是没有值,在进程中所表示的类型空间将会被分配同时初始化为0)。通常,这些未初始化的数据位于段的末尾,因此在相关的程序头数组项中 p_memsz 变量值大于 p_filesz 变量值。
注释节(Note Section)
有些系统供应商或系统构建者(比如:GNU)需要用向目标文件标记一些特殊信息,当在使用时检查文件的一致性和兼容性。SHT_NOTE类型的节和PT_NOTE类型的程序头部信息均可用于保存这些标记信息。节和程序头部中的注释信息可以保存任意数量的注释信息,每个信息都是目标处理器格式的4字节数组。下面出现的标签有助于解释注释信息的组织,但它们不是规范的一部分:
namesz 和 name:namesz为name的字节数,而name包含一个以空白符结束的字符串信息,用于表示注释的所有者或发起者。按照惯例,供应商使用他们自己的名称,例如 XYZ计算机公司 作为标识符。如果没有名称,namesz 为 0。如果有必要,可以使用0填充来确保描述符的4字节对齐。namesz中不包括这样的填充
descsz 和 desc:descsz为desc的字节数,desc用于保存其描述信息。ABI对描述符的内容没有任何约束,如果没有描述符,descsz为0。如果有必要,可以使用0填充来确保描述符的4字节对齐
type:该变量给出了描述符的解释。每个厂商使用该变量来控制自己的类型,所以可能存在对单一类型值的多种解释。因此,程序必须同时识别名称和类型才能读出描述符的含义,类型目前必须是非负的,ABI没有定义描述符的含义。
为了说明这一点,下面的注释段包含两个信息:一个只有XYZCo 为组织名,没有描述信息,一个包含组织名XYZCo 和描述信息,使用Type表示类型为:1和3。
程序加载(Program Loading)
当操作系统创建程序映像(进程)时,它将目标文件的段(使用程序头部信息)复制到虚拟内存中。操作系统什么时候读取以及是否读取目标文件,取决于当前程序的执行行为(比如:根据GOT与PLT 加载动态链接库)、系统负载等。操作系统通常使用懒加载来处理虚拟内存与物理页的映射,也即初始分配虚拟内存不占用物理内存,在执行读写操作时再执行映射,所以创建的进程不需要物理页,除非它在执行期间实际使用到了这一页的数据或者代码,进程通常不需要使用许多页便可以执行,使用这种懒加载的行为将有效节约物理内存。(pass:不理解虚拟内存与物理内存,可以参考笔者的混沌学堂描述或者Intel开发手册)。
因此,延迟对于物理页的读取操作,可以减少物理页的分配,从而提高系统性能。为了在实现中获得这种效率,可执行文件和共享目标文件必须具有文件偏移量和虚拟地址相等的段映像,且需要对齐到虚拟内存页的边界,通常为4KB。
SYSTEM V 架构的虚拟地址和文件偏移量以4KB 或 2的更大幂(比如:Intel 的 4MB)对齐。因为4 KB是最低的页面大小,所以不管物理页面大小如何,目标文件都可以适应该分页大小。我们来看一个例子:
- 第一个代码页保存 ELF头、程序头表和其他信息
- 最后一个代码页保存 数据段中部分信息的副本
- 第一个数据页保存 代码段中部分末尾信息的副本
- 最后一个数据页可能包含与正在运行的进程无关的一些文件信息
虽然示例中的文件偏移量和虚拟地址对于代码和数据都是一致的,同时以4 KB对齐,但如上所述,最多有4个文件页保存部分代码或数据(取决于页面大小和文件系统块大小)。
图:可执行文件信息
图:程序头部段信息
操作系统强制约束了内存权限,这时需要每个段都是完整和独立的,所以需要对段的地址进行调整以确保地址空间中的每个逻辑页都有一组权限信息。在上面的例子中,保存代码结尾和数据开头的文件区域页将被映射两次:一次将虚拟地址用于代码,另一次将虚拟地址用于数据。
数据段的末尾需要对未初始化的数据(也即 bss 段)进行特殊处理,操作系统将这些未初始化数据定义为0。因此,如果文件的最后一个数据页包含不在逻辑内存页中的信息,则必须将这些数据设置为零,而不是可执行文件的未知内容。其他三个页面中的 多余信息(无关信息) 在逻辑上不是程序映像(进程)的一部分,操作系统是否将其删除取决于具体实现,因此,最终这个程序的内存映像如下(假设一页为 4KB):
读者需要注意:数据填充部分、代码填充部分,均是为了保证4KB对齐,同时未初始化数据虽然在文件中不占用内存,但是在内存中需要初始化为0。
段加载在可执行文件和共享目标文件之间的在某些方面是不同的。可执行文件段通常包含绝对地址代码,为了让进程正确执行,段必须在构建可执行文件时指定其虚拟地址。因此,系统使用 p_vaddr 变量来保存该虚拟地址。另一方面,共享目标文件的段通常包含位置无关的代码(混沌学堂中,已经花大量篇幅介绍,GOT、PLT、相对偏移量),这允许段的虚拟地址在不同进程间都可以不一样,而不会使执行行为失效。由于位置无关代码在段之间使用相对寻址,因此内存中虚拟地址之间的差值必须与文件中虚拟地址之间的差值相匹配。下表显示了多个进程可能的共享对象虚拟地址分配,说明了常量的相对定位。该表还说明了基址计算。虽然系统为各个进程选择虚拟地址,但它们都保持段的相对位置。