笔记03.2 - Lab 1:ELF

以下内容摘抄自《系统的启动和初始化》一书

ELF = “Executable and Linkable Format”
在 JOS 操作系统实验中,内核的可执行程序实际上是一个 ELF 文件。 ELF 文件可以分为这样几个部分: ELF 文件头(定长)、程序头表(program header table)(不定长)、节头表(section header table)和文件内容。而其中文件内容部分又可以分为这样的几个节: .text 节、 .rodata 节、 .stab节、 .stabstr 节、 .data 节、 .bss 节、 .comment 节。程序头表实际上是将文件的内容分成了好几个段,而每个表项就代表了一个段,有可能就是同时几个节包含在同一个段里。

加载 ELF 文件到内存中是先加载文件头信息,通过 ELF 文件头获取所有的程序头表项数据,然后按段的形式加载文件内容的,下面从段的角度介绍一下 ELF 文件结构。

我们先来看看 ELF 文件头的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct Elf {
uint32_t e_magic; // 标识文件是否是 ELF 文件
uint8_t e_elf[12]; // 魔数和相关信息
uint16_t e_type; // 文件类型
uint16_t e_machine;// 针对体系结构
uint32_t e_version; // 版本信息
uint32_t e_entry; // Entry point 程序入口点,是虚拟的链接地址
uint32_t e_phoff; // 程序头表偏移量,是程序头表的第一项相对于 ELF 文件的开始位置的偏移
uint32_t e_shoff; // 节头表偏移量
uint32_t e_flags; // 处理器特定标志
uint16_t e_ehsize; // 文件头长度
uint16_t e_phentsize;// 程序头部长度
uint16_t e_phnum; // 程序头部表项个数
uint16_t e_shentsize;// 节头部长度
uint16_t e_shnum; // 节头部个数
uint16_t e_shstrndx; // 节头部字符索引
};

可以用“ objdump -f 可执行文件”这样的命令来查看硬盘中 ELF 文件的文件头信息,如“ objdump -f obj/kern/kernel ”:

再看看程序头表项的数据结构:

1
2
3
4
5
6
7
8
9
10
struct Proghdr {
uint32_t p_type; // 段类型
uint32_t p_offset; // 段位置相对于 ELF 文件开始处的偏移量,标识出了磁盘位置(相对的位置,这里没有包括 ELF 文件之前的磁盘)
uint32_t p_va; // 段放置在内存中的地址(虚拟的链接地址)
uint32_t p_pa; // 段的物理地址
uint32_t p_filesz; // 段在文件中的长度
uint32_t p_memsz; // 段在内存中的长度
uint32_t p_flags; // 段标志
uint32_t p_align; // 段在内存中的对齐标志
};

用 ELF 文件头与程序头表项如何找到 ELF 文件的第 i 段:

从段的角度介绍完 ELF 文件结构后,下面从节的角度介绍一下 ELF 文件结构:

1
2
3
4
5
6
7
.text 节:可执行指令的部分。
.rodata 节:只读全局变量部分,如 C 编译器产生的 ASCII string 常量。
.stab 节:符号表部分,这一部分的功能是程序报错时可以提供错误信息。
.stabstr 节:符号表字符串部分。
.data 节:可读可写的全局变量部分,保存已初始化的数据,如已初始化的全局变量 int x = 5。
.bss 节:未初始化的全局变量部分,如 int x,这一部分不会在磁盘有存储空间,因为这些变量并没有被初始化,全部默认为 0。内核代码编译时,链接器为未初始化全局变量保留了.bss 空间,并计算出.bss 的地址和大小。在将这节装入到内存的时候程序需要为其分配相应大小的初始值为 0 的内存空间。
.comment 节:注释部分,这一部分不会被加载到内存。

可以用“ objdump –h 可执行文件”这样的命令来查看硬盘中 ELF 文件的每个节的信息(不是内存布局),如“ objdump -h obj/kern/kernel ”:

可以发现.bss 节与.comment 节在文件中的偏移是一样的,这就说明.bss 在硬盘中式不占用空间的,仅仅只是记载了它的长度。但是.bss 节在装入内存后是占据一定的空间的。
另一方面,“ VMA ”表示链接地址 link address ,“ LMA ”表示加载地址 load address ,节的 LMA 表示该节会被加载到内存中的 LMA 地址上。

可以用“ objdump –x 可执行文件”这样的命令来查看硬盘中 ELF 文件的文件头、段、节、符号表等信息(不是内存布局),如“ objdump -x obj/kern/kernel ”:

从 start address 可以看出 kernel 的入口地址是 0x0010000c。
段信息部分,vaddr是线性地址,paddr是物理地址,可以看出,第一段在文件中的偏移是 0x1000,而在内存中占据的字节数是 0x717b,链接地址 0xf0100000,物理地址 0x100000,包括了.text 、.rodata 、.stab 、.stabstr 节;第二段在文件中的偏移是 0x9000,而在内存中占据的字节数是 0xa944,链接地址 0xf0108000,物理地址 0x108000,包含了.data 节以及在硬盘上不占用空间但在内存中占据 644 字节的.bss 节。在这里程序头表的第二项会用p_filesz 成员变量标注该段在文件占用的字节数并且同时用 p_memsz 标注在内存中占用的字节数,这样 Boot Loader 便会在从硬盘读入第二段的同时为.bss 节在内存中分配空间。我们可以看到,.comment 节没有被包含在任意一段中,这表明它没有被装入内存。
注意到:链接地址是高地址,而加载地址是低地址。这表示内核代码告诉 Boot Loader 在加载它时要加载到内存低地址(1M),而它将从内存高地址执行。

stab 节在 ELF 文件结构为符号表部分,stabstr 是符号表的字符串部分。可以通过 objdump -G obj/kern/kernel 查看 stab 节的内容,jos 中解析 stab 节的数据结构见于 inc/stab.h。其中,n_type 有几种类型,SO 表示主函数的文件名,SOL 表示包含进的文件名,SLINE 表示代码段的行号,FUN 表示函数的名称。结合 grep SO、 grep FUN 等可以对结果进行分类。objdump -G 命令还可以看到每个文件在编译后在 ELF 文件中的链接地址,从小到大依次排列。objdump -G obj/kern/kernel | grep FUN 可以发现函数也是按照它们属于各自文件的顺序 ,依次排列在链接地址空间里。

kern/kdebug.c 中有二分查找函数 stab_binsearch,实现了在 stab 中查找 addr 对应表项的过程(根据链接地址查找)。根据 %eip 读取当前指令所在文件、所在行、所在函数的实现过程则见于 kern/kdebug.c 的 debuginfo_eip 函数。其中,stab 节的位置 __STAB_BEGIN__ 是链接器在链接时得到的,在 kern/kernel.ld 中可以看到用于初始化 stabs 和 stab_end 两个变量的部分。调用 stab_binsearch 的两个参数 int region_left, int region_right 是表项序号,不是内存地址,使用 stab_end - stabs 即可,因为同类型指针相减会自动除去该类型的 size 的,即这里可以获取相隔的元素个数之差。

最后,我们介绍一下节头表。
节头表的功能是让程序能够找到特定的某一节,通过 ELF 文件头与节头表找到文件的某一节的方式和之前所说的找到某一段的方式是类似的。我们来看看节头表项的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
struct Secthdr {
uint32_t sh_name; // 节名称
uint32_t sh_type; // 节类型
uint32_t sh_flags; // 节标志
uint32_t sh_addr; // 节在内存中的线性地址
uint32_t sh_offset; // 相对于文件首部的偏移
uint32_t sh_size; // 节大小(字节数)
uint32_t sh_link; // 与其它节的关系
uint32_t sh_info; // 其它信息
uint32_t sh_addralign; // 字节对齐标志
uint32_t sh_entsize; // 表项大小
};

显示 Gitment 评论