介绍
这节首先描述程序头的格式和运行程序相关的ELF文件结构,然后是程序的装载过程。程序头可以算是程序结构的一个总纲,它指明了文件中各个段的位置,还包含一些用于创建内存镜像的必要内容。准备一个程序的内存镜像,可以大体上分为装载和连接两个步骤。前者把目标文件装载入内存,后者解析目标文件中的符号引用。一个已装载完成的进程空间会包含多个不同的“段(segment)”,比如代码段(text segment),数据段(data segment),堆栈段(stacksegment)等。
程序头结构
一个可执行文件或共享目标文件的程序头表(program header table)是一个数组,数组中的每一个元素称为“程序头(program header)”,每一个程序头描述了一个“段(segment)”或者一块用于准备执行程序的信息。一个目标文件中的“段(segment)”包含一个或者多个“节(section)”。程序头只对可执行文件或共享目标文件有意义,对于其它类型的目标文件,该信息可以忽略。在目标文件的文件头(elf header)中,e_phentsize 和 e_phnum 成员指定了程序头的大小。
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;
p_type: 此数据成员说明了本程序头所描述的段的类型,除非有特别要求,否则所有程序头的段类型域 p_type 都是可选项,不是必须存在的。在所有程序头都不指定段类型的情况下,程序头表中所有的表项都不代表任何特别的类型,而只是作为一种索引,表明其相应的段的大小和位置。
名字 | 值 | 解释说明 |
PT_NULL | 0 | 此表明本程序头是未使用的,本程序头内的其它成员值均无意义。具有此种类型的程序头应该被忽略。 |
PT_LOAD | 1 | 此类型表明本程序头指向一个可装载的段。段的内容会被从文件中拷贝到内存中。如前所述,段在文件中的大小是 p_filesz,在内存中的大小是p_memsz。如果 p_memsz 大于 p_filesz,在内存中多出的存储空间应填 0 补充,也就是说,段在内存中可以比在文件中占用空间更大;而相反,p_filesz永远不应该比 p_memsz 大,因为这样的话,内存中就将无法完整地映射段的内容。在程序头表中,所有 PT_LOAD 类型的程序头按照 p_vaddr 的值做升序排列。 |
PT_DYNAMIC | 2 | 此类型表明本段指明了动态连接的信息。 |
PT_INTERP | 3 | 本段指向了一个以”null”结尾的字符串,这个字符串是一个 ELF 解析器的路径。这种段类型只对可执行程序有意义,当它出现在共享目标文件中时,是一个无意义的多余项。在一个 ELF 文件中它最多只能出现一次,而且必须出现在其它可装载段的表项之前。 |
PT_NOTE | 4 | 本段指向了一个以”null”结尾的字符串,这个字符串包含一些附加的信息。 |
PT_SHLIB | 5 | 该段类型是保留的,而且未定义语法。UNIX System V 系统上的应用程序不会包含这种表项。 |
PT_PHDR | 6 | 此类型的程序头如果存在的话,它表明的是其自身所在的程序头表在文件或内存中的位置和大小。这样的段在文件中可以不存在,只有当所在程序头表所覆盖的段只是整个程序的一部分时,才会出现一次这种表项,而且这种表项一定出现在其它可装载段的表项之前。 |
PT_LOPROC | 0x70000000 | 类型值在这个区间的程序头是为特定处理器保留的。 |
PT_HIPROC | 0x7fffffff |
p_offset :此数据成员给出本段内容在文件中的位置,即段内容的开始位置相对于文件开头的偏移量。
p_vaddr:此数据成员给出本段内容的开始位置在进程空间中的虚拟地址。
p_paddr:此数据成员给出本段内容的开始位置在进程空间中的物理地址。此成员多数情况下保留不用,或改作它用。
p_filesz:此数据成员给出本段内容在文件中的大小,单位是字节,可以是 0。
p_memsz:此数据成员给出本段内容在内存镜像中的大小,单位是字节,可以是 0。
p_flags:本段内容的属性,当为可加载段创建内存镜像时,系统会按照 p_flags 的指示给段赋予一定的权限。
名字 | 值 | 含义 | 总结 |
PF_X | 0x1 | 可执行 |
|
PF_W | 0x2 | 只写 | |
PF_R | 0x4 | 只读 | |
PF_MASKPROC | 0xf0000000 | 为特殊处理器保留 |
p_align:对于可装载的段来说,其 p_vaddr 和 p_offset 的值至少要向内存页面大小对齐。此数据成员指明本段内容如何在内存和文件中对齐。如果该值为 0 或 1,表明没有对齐要求;否则,p_align 应该是一个正整数,并且是 2 的幂次数。p_vaddr 和p_offset 在对 p_align 取模后应该相等。
基地址
程序头中出现的虚拟地址不能代表其相应的数据在进程内存空间中的虚拟地址。可执行文件中需要含有绝对的地址,比如变量地址,函数地址等,为了让程序正确地执行,“段”中出现的虚拟地址必须在创建可执行程序时被重新计算。另一方面,出于 ELF 通用性的求,目标文件的段中又不能出现绝对地址,其代码是不应依赖于具体存储位置的,即同一个段在被加载到两个不同的进程中时,它的地址可能不同,但它的行为不能表现出不一样。
在被加载到进程空间里时,尽管“段”会被分配到一个不确定的地址,但是不同的段之间会有确定的“相对位置(relative position)”。也就是说,在目标文件中存储的两个段,它们的位置之间有多少偏移,当它们被加载到内存中时,这两个段的位置之间仍然保持这么大的偏移(距离)。一个段在内存中的虚拟地址与其在目标文件中的地址一般是不相等的,它们之间会有一个偏移量,这个偏移量被称为“基地址(base address)”,基地址的作用之一就是在动态连接过程中为程序重定位内存镜像。
一个可执行文件或共享目标文件的基地址是在运行期间由以下三个值计算出来的:内存加载地址,最大页面大小,程序可装载段的最低地址。为计算基地址,首先找出类型为 PT_LOAD(即可加载)而且 p_vaddr(段地址)最低的那个段,把这个段在内存中的地址与最大页面大小相除,得到一个段地址的余数;再把p_vaddr 与最大页面大小相除,得到一个 p_vaddr 的余数。基地址就是段地址的余数与 p_vaddr 的余数之差。
段内容
ELF文件中的一个“段”由若干个“节”组成,不过程序头并不关心“节”,一个段中包含多少个节也与程序装载没有多大关系。如代码段(.text)包含的是只读的指令和数据,数据段(data segment)包含可写的数据和指令,一般情况下会包含以下这些节,一个实际的更复杂的代码段可能包含更多的节。
代码段可能包含的节 | 数据段可能包含的节 |
.text | .data |
.rodata | .dynamic |
.hash | .got |
.dynsym | .bss |
.dynstr |
|
.plt |
|
.rel.got |
|
注释段
类型为 PT_NOTE 的段往往会包含类型为 SHT_NOTE 的节,SHT_NOTE 节可以为目标文件提供一些特别的信息,用于给其它的程序检查目标文件的一致性和兼容性。这些信息我们称为“注释信息”,这样的节称为“注释节(note section)”,所在的段即为“注释段(note segment)”。注释信息可以包含任意数量的“注释项”,每一个注释项是一个数组,数组的每一个成员大小为 4 字节,格式依目标处理器而定。下图解释了注释信息是如何组织的,但这仅是一种参考,不是规范的一部分。
程序装载
程序装载就是操作系统创建或扩充进程镜像的过程。当系统创建或者扩充一个进程镜像时,逻辑上,它要把文件中的段复制成为虚
拟内存中的一个段。但是系统不一定立刻真正地去读文件,什么时候读,还要依赖于程序的行为、系统负载等等。进程在加载完成之后,很多文件的内容其实并没有真正地映射到内存中;在运行过程中,进程只有在真正需要去访问一个内存页面的时候,才会去映射它。为了达到这种效果,可执行文件和共享目标文件中段的镜像在文件中的偏移量或者内存虚拟地址必须是向页面大小对齐的。
可执行文件示例 | 程序头段 | ||
---|---|---|---|
成员 | 代码 | 数据 | |
p_type | PT_LOAD | PT_LOAD | |
p_offset | 0x100 | 0x2bf00 | |
p_vaddr | 0x8048100 | 0x8074f00 | |
p_paddr | unspecified | unspecified | |
p_filesz | 0x2be00 | 0x4e00 | |
p_memsz | 0x2be00 | 0x5e24 | |
p_flags | PF_R+PF_X | PF_R+PF_W+PF_X | |
p_align | 0x1000 | 0x1000 |
尽管示例文件内代码和数据段的偏移量和虚拟地址都是向 4KB 对齐的,但是文件内有 4 个页面包含不纯的代码和数据。
- 第一个代码(text)页包含 ELF 头,程序头表和其它信息。
- 最后一个代码页含有数据段开始处的拷贝。
- 第一个数据(data)页含有代码段结尾处的拷贝。
- 最后一个数据页可能含有与运行过程无关的信息。
逻辑上说,不同的段应该截然分开。根据所在的段不同,不同的页面也应该被分配不同的权限。在这个示例中,同时包含代码段结尾部分和数据段开头部分的这个区域需要被映射到内存两次,一次是映射到代码段,另一次是到数据段,当然,两次的虚拟内存地址是不同的。
当装载进内存后,未初始化的全局变量会被放在紧跟在数据段的后面,系统一般会把这种数据置为 0。所以,如果文件的最后一个数据页中包含一些不在逻辑内存页中的信息,这些附加的信息将无法被保留下来,也会被置为 0。
这个程序的内存镜像如下图所示:
对于可执行文件和共享目标文件来说,段的装载是不同的。
一方面,在可执行文件中,理所当然地要包含绝对地址。为了让程序能够正确地执行,运行期间的段的虚拟地址必须与构建可执行文件时该段的地址相同,因此系统使用 p_vaddr 来记录不变的虚拟地址。
另一方面,在共享目标文件中,理所当然地要使用地址无关的代码,不能依赖于绝对地址。给不同的程序使用时,共享目标文件中的段被加载的地址也不同,但是不能因为地址的不同而改变了执行行为。尽管在两个不同的进程中,系统会给共享目标文件的段选择不同的地址,但是各个段之间的相对地址是不变的。与位置无关的代码正是使用段间的相对位置来互相访问的,在进程的内存中,两段的虚拟地址之差与它们在文件中的位置偏移量之差相等。
下表展示了共享目标在几个不同进程中被指定的虚拟地址,解释了在不同进程中,不同段之间的相对位置是不变的。这张表也解释了基地址的计算。
共享目标段地址示例 | |||
来源 | 代码 | 数据 | 基地址 |
File | 0x200 | 0x2a400 | 0x0 |
Process | 1 | 0x80000200 | 0x8002a400 |
Process | 2 | 0x80081200 | 0x800ab400 |
Process | 3 | 0x900c0200 | 0x900ea400 |
Process | 4 | 0x900c6200 | 0x900f0400 |