Elf文件的组织

ELF(Executable and Linking Format files),可执行可链接格式文件,是Unix下标准的文件格式。一般我们可以将其分为可重定位目标文件下的ELF(也即链接视图下的ELF)和可执行目标文件下的ELF(也即执行视角下的ELF)

根据elf在man上的手册,ELF头可以分为32位和64位,表示为ElfN_Ehdr,这里的N取32或64;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#define EI_NIDENT 16

typedef struct {
unsigned char e_ident[EI_NIDENT];
uint16_t e_type;
uint16_t e_machine;
uint32_t e_version;
ElfN_Addr e_entry;
ElfN_Off e_phoff;
ElfN_Off 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;
} ElfN_Ehdr;

上述结构有如下的意义:

1
2
3
4
5
6
7
8
e_ident 
类型为unsigned char,在模拟一个字节数组,指明了如何具体翻译一个文件;独立于预处理器或者文件剩余的内容。在这个数组内部,所有内容都以宏的方式命名了;这个字节数组大小是16个字节,其中:
前四个字节应该对应着ELF文件的魔数,也即0x7f, E, L, F; 用宏表示对应就是EI_MAG0, EI_MAG1, ....
第五个字节,可以用EI_CLASS来表示,表示这个二进制文件所指明的架构,如ELFCLASS32,这个值就说明是32位架构;

第六个字节,可以用EI_DATA表示,表示这个文件的编码方式,有ELFDATA2LSB这种(说明是二进制补码,且小端方式)

剩余内容比较少用,可手动man elf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
e_type 指明了对象文件的类型,如果是ET_REL表示是可重定位目标文件,而如果是ET_EXEC则表明是一个可执行文件,ET_DYN表示是动态链接里面用到的共享库文件
e_machine 指明了独立文件所需的架构,如EM_MIPS, EM_386, EM_X86_64

e_version 指定文件版本
e_entry 这个元素比较重要,指明了系统第一次将控制权限交给程序的虚拟地址,这样程序就可以开始运行了。或者说,这个地址就是程序执行第一条指令所在的虚拟地址;如果没有程序的entry_point,这个值就是0;

e_phoff 这个元素表示了程序头表在文件中偏移的字节数,如果文件没有程序头表,值为0
e_shoff 这个元素指明了段头表在文件中偏移的字节数,如果文件没有段头表,值为0
e_flags 表示进程相关的文件的标识符号
e_ehsize elf header size in bytes,表示具体的elf头的大小
e_phentsize 这个内容表示,在文件的程序头表的一条entry所占的大小,具体的,程序头表中所有的entry大小都是一样的;注意,entry的概念可以类比cache的entry,实际上就是表格中的“一项”,而表格由多项组成;
e_phnum 这个成员表示程序头表中entries的数目,显然e_phentisize * e_phnum得到的记过就是这个程序头表的大小;

e_shentsize 表示节头表中一个项的大小,用字节表示,一个节头表会由多个节的entry组成,所有的entry都是一样的大小;
e_shnum 表示节头表中所有成员的数目,e_shentsize和e_shnum相乘就能得到节头表的大小;

程序头表和节头表之间的区别与联系;

Program header table程序头表位于可执行目标文件的ELF头的后面,本质是在展示运行时各个segment的使用情况,这是运行时的视角,只在可执行目标文件中存在;程序头表是为了执行。有的时候,程序头表也被称为段头部表,Segment Header Table。

Section header table节头表的本质是可重定位目标文件的最后,用来描述和列出目标文件的节;不同节的位置和大小是由节头部表描述的,其中目标文件的每个节都有一个固定大小的条目;节头表是为了链接;

程序头表的结构与解析:

可执行或共享的对象文件中,程序头表是一系列内容,将描述系统要执行该程序所需要的段或者其他的信息。一个对象文件段就会包含一个或者更多的节,程序头表支队可执行或者可共享的对象文件是有效的。文件刻画它本身的程序头表的大小,是通过elf头的e_phentsize 和e_phnum来实现的。ELF程序头表会被Elf32_Phdr和Elf64_Phdr结果来形容,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef struct {
uint32_t p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
uint32_t p_filesz;
uint32_t p_memsz;
uint32_t p_flags;
uint32_t p_align;
} Elf32_Phdr;

typedef struct {
uint32_t p_type;
uint32_t p_flags;
Elf64_Off p_offset;
Elf64_Addr p_vaddr;
Elf64_Addr p_paddr;
uint64_t p_filesz;
uint64_t p_memsz;
uint64_t p_align;
} Elf64_Phdr;

32位和64位的程序头表之间的不同主要在于p_flags在全部结构中的位置;

1
2
3
4
5
6
7
8
p_type 这个成员会指示当前所对应的程序段的具体类型,以及如何翻译这个数组的元素信息;有如下的宏可以参与解读:
PT_NULL 表示这个数组元素是没有被使用的,其他成员的数值也是未定义的,这会使得程序头表忽略extries;
PT_LOAD 表示数组元素在指定一个需要被载入的代码段,可以被p_filesz和p_memsz型刻画,文件的字节内容会被映射到一个内存段的开头。如果段的内存大小p_memsz比文件该段的大小p_filesz大,那么多出来的字节就会被定义来保持一个数值0,而且取遵循代码段的初始化数据;文件大小或许不会比内存大小要大,需要加载的段在程序头表中会按照p_vaddr的升序排列;

p_offset 指示从文件开头直到这个段第一个字节开始之间的偏移量
p_vaddr 指示在内存中这个段开头的第一个字节所在的虚拟地址
p_filesz 指明了在文件镜像中,该段的按照字节统计的大小总数,可能是0
p_memsz 指明了在内存镜像中,该部分内容所需要占的字节总数,可能是0

PA3中实现loader的过程,就需要往nemu的虚拟内存中根据程序头表的信息加载内容;

节头表的结构与解析:

一个文件的节头表可以定位这个文件的所有节,节头表是Elf32_Shdr或Elf64_Shdr的数组结构。elf头的e_shoff指明了节头表从文件开头的字节偏移量;

以下是节头表具体的结构和内容组织:32位和64位之间没有必然的区分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
typedef struct {
uint32_t sh_name;
uint32_t sh_type;
uint32_t sh_flags;
Elf32_Addr sh_addr;
Elf32_Off sh_offset;
uint32_t sh_size;
uint32_t sh_link;
uint32_t sh_info;
uint32_t sh_addralign;
uint32_t sh_entsize;
} Elf32_Shdr;

typedef struct {
uint32_t sh_name;
uint32_t sh_type;
uint64_t sh_flags;
Elf64_Addr sh_addr;
Elf64_Off sh_offset;
uint64_t sh_size;
uint32_t sh_link;
uint32_t sh_info;
uint64_t sh_addralign;
uint64_t sh_entsize;
} Elf64_Shdr;
1
2
3
4
5
6
7
8
9
sh_name 这个成员指明了节的名字,它的值是在节头表的字符串表部分中,其名字在这个字符串表中的偏移量,会指明一个以null结尾的字符串作为名字;
sh_type 表示节的类型和语法规则
SHT_SYMTAB 这个类型表示这个节是符号表,符号表可以为链接的编辑提供符号
SHT_STRTAB 这个类型表示节是字符串表,每个目标文件可能有多个字符串表的节;
SHT_DYNAMIC 表示是动态链接所需的节
sh_addr 如果节在进程的内存镜像中出现了,那么addr就会指向其第一个字节所在的地址,否则为0
sh_offset 这个成员的值表示从文件开头到第一个节的字节之间便宜的字节数
sh_size 表示这个节的大小有多少个字节

字符串节和符号表

字符串表维护以null结尾的字符串序列,目标文件会将这些字符串转化为符号和节名,其中一个引用就是字符串会作为index维护在字符串表节中。第一个字节,index=0,是被定义来维护’\ 0’的。类似的,字符串表的最后一个字节也会被定义成一个null byte,这样可以确保对于所有字符串都是以null结尾的。

一个对象文件的符号表会维护一系列用来定位和重定位程序的符号定义、引用的信息,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct {
uint32_t st_name;
Elf32_Addr st_value;
uint32_t st_size;
unsigned char st_info;
unsigned char st_other;
uint16_t st_shndx;
} Elf32_Sym;

typedef struct {
uint32_t st_name;
unsigned char st_info;
unsigned char st_other;
uint16_t st_shndx;
Elf64_Addr st_value;
uint64_t st_size;
} Elf64_Sym;
1
2
3
4
5
6
st_name 类似代表对象文件的符号字串表,可以维护符号名称的字符表示,如果其值为非0,他就代表字符串表中对应符号名称的下标。否则就是无名;

st_info 定义了一系列宏来指定符号的类型
STT_NOTYPE 这个符号的类型没有被定义
STT_FUNC 这个符号说明了和其关联的是一个函数或其他可执行的代码
...

ld:链接器的学习与使用

命令语言为连接过程提供了控制,并允许我们完成输入文件和输出可执行程序之间的映射刻画。一般,我们会提供一个命令文件(也即常见的链接器脚本)给链接器,通过’-T’选项。

链接脚本的撰写:

最基础的命令时SECTIONS命令,每一个有意义的命令脚本必须有一个SECTIONS命令:它刻画了一个输出文件的格式,并在细节上改变了刻画的尺度;没有其他的命令在这种情况是需要的。

MEMORY指令会补充SECTIONS指令,通过描述在目标架构可用的内存。这个命令是可选的,如果你没有使用MEMORY命令,那么ld就会假设有充足的内存在一个连续的块中,并以此来维护所有的输出。

在链接脚本中撰写注释是和c十分类似的,可以以/* 和 */来作为注释的分隔符。注释在语法上自然与空格等价;

表达式

许多有用的命令会引起算数表达式计算,其语法与C表达式是等价的。以下是ld脚本中表达式计算的规则:

  • All expressions evaluated as integers and are of “long” or “unsigned long” type.
  • All constants are integers.
  • All of the C arithmetic operators are provided.
  • You may reference, define, and create global variables.
  • You may call special purpose built-in functions.

整数计算

每种整数的表示,O八进制,不加十进制,0x十六进制,-负数

符号名称

一般定义的符号名称都以字母、下划线(underscore)、或者点在开头,并且包裹在双引号内部;

定位计数器

在链接中一个很特殊的变量符号”点“.总是会包含当前输出位置的计数器;因为.总是会找到在输出段中的一个位置,所以它必须经常出现在SECTIONS命令包裹的内部,它可能出现在任何普通的符号允许的地方,但是它的赋值也会导致副作用。对.赋值可能会导致位置计数器被移除,这可能用来在输出段中创建空洞,定位计数器永远不会向后移动。

分号

分号在以下的内容中都是需要的,在所有其他的地方,他们也可以出现,但是是作为艺术原因,实际上会被忽略掉。

  1. 在赋值场景中,分号必须出现在赋值表达式的末尾;
  2. PHDRS指令,分号必须出现在每个PHDRS语句的末尾,

指定输出的段:Specifying Output Sections

SECTIONS命令会准确控制输入段,使得输入端被放置到对应的输出段,以及控制他们在输出文件中的排列顺序。

在每个连接脚本中最多可以使用一个SECTIONS指令,但是我们可以有尽可能多的描述语句,在SECTIONS中的描述语句可以是下面三种形式的任意一个:

  1. 定义一个程序的入口
  2. 对某个符号做赋值
  3. 形容某个被命名的输出端的放置方式,以及将什么输入内容填充到这个段中。

我们也可以将前两个操作,定义程序入口和定义符号放到SECTIONS命令外面来完成。他们可以方便阅读ld脚本,这样符号和入口点就可以被更清晰地理解。

节(Section)的定义:

最经常用的SECTIONS指令,会确定一系列输出可执行文件各个节的性质。如位置、对齐方式、填充内容和模式,以及目标内存区域;许多特性都是可选的,最简单的一个SECTIONS的定义如下:

1
2
3
4
5
6
7
SECTIONS {...
secname : {
contents
}

...
}

注意,这里用到的sections是在链接视角下的,节头表所控制的内容!我们这里刻画的,本质上是在用链接视角下的节来组件可执行文件。这里的secname不是segment,也即并不是phdr里面的segment。(我们在PHDRS里定义的name,本质上定义的是段的名字,而不是节的名字!)所以,这里的secname和PHDRS里面说的name是不一样的。secname最后括号结束,可以用: name的方式来实现节和段(程序头表中的定义的段)的映射关系。

secname是输出的节的名称,其内容可以根据输入的文件或者输入文件的节来填充。注意secname后需要一个空格,这样不会导致名字的冲突;同时冒号后面还需要一个空格。

节的放置

在节的定义中,我们需要指出每个定义的输出节中的内容通过列出一系列

ELF程序头表的构造

ELF对象文件的格式要求使用程序头表,这样他们就能被系统loader加载,并且描述这些程序应该如何被加载到内存中去。这些程序头表必须被正确设置,这样就能够在一个naive的ELF系统里面运行。链接器将会默认构造一个合法的ELF程序头表,但是在一些情况下,将程序头表设置地更加精确是有必要的。所以就引入了命令PHDRS。

PHDRS指令被使用的时候,linker链接器就不会自动生成任何程序头表;

PHDRS命令只对生成ELF输出文件的时候有效,在其他情况是被忽略的,想要具体地观察ELF文件的程序头表是如何被打印的,就需要在objdump指令加入-p选项。

以下是PHDRS的语法:

1
2
3
4
5
PHDRS
{
name type [ FILEHDR ] [ PHDRS ] [ AT ( address ) ]
[ FLAGS ( flags ) ] ;
}

注意,这里的name会在SECTIONS命令中被引用。这些程序头表类型描述的内存段,会通过系统加载器从文件中加载进来;在连接脚本中,这些段的内容会通过直接在输出段中申请空间并放置来实现。为了完成这种做法,刻画输出段内容的操作需要通过在SECTIONS指令中使用:name来完成,这里的name就是在PHDRS中定义的name。

以下是一个链接的实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
PHDRS
{
headers PT_PHDR PHDRS ;
interp PT_INTERP ;
text PT_LOAD FILEHDR PHDRS ;
data PT_LOAD ;
dynamic PT_DYNAMIC ;
}

SECTIONS
{
. = SIZEOF_HEADERS; /* 这里是为ELF头和程序头表留空*/
.interp : { *(.interp) } :text :interp /* 这部分内容会出现在text段和interp段,后面要跟:text */
.text : { *(.text) } :text //同理
.rodata : { *(.rodata) } /* defaults to :text */
...
. = . + 0x1000; /* move to a new page in memory */
.data : { *(.data) } :data
.dynamic : { *(.dynamic) } :data :dynamic
...
}

程序的入口点:The Entry Point

链接器的命令语句包含了,确定输出的可执行文件的第一条可执行指令的位置的命令。可执行文件第一条执行指令的起点,或者被称为程序的入口点(The Entry Point),它的参数如下所示:

1
ENTRY(symbol)

正如符号赋值,ENTRY命令也会在脚本中被放置成一个独立的命令,或者在所有段的定义中,即SECTIONS命令一同描述。只要能够更好理解我们安排的程序的结构即可。

ENTRY入口时唯一一个在几种方式中选择入口点的,还有几种其他方式。注意,这些方式会按照优先级进行排序:

  1. 通过命令行设置-e参数,后面跟程序的开始点
  2. 通过在连接脚本中实现ENTRY(symbol)的命令
  3. 通过设置符号start的值
  4. 第一个.text节的地址
  5. 0地址

PA中的AM里面的链接器linker.ld

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
ENTRY(_start)
PHDRS { text PT_LOAD; data PT_LOAD; }

SECTIONS {
/* _pmem_start and _entry_offset are defined in LDFLAGS */
. = _pmem_start + _entry_offset;
.text : {
*(entry)
*(.text*)
} : text
etext = .;
_etext = .;
.rodata : {
*(.rodata*)
}
.data : {
*(.data)
} : data
edata = .;
_data = .;
.bss : {
_bss_start = .;
*(.bss*)
*(.sbss*)
*(.scommon)
}
_stack_top = ALIGN(0x1000); /* 这里将栈顶按照页大小进行对齐 */
. = _stack_top + 0x8000; /* 这里留出0x8000,也即2^15 B = 2^5 KB = 32KB,这是应用程序栈的大小 */
_stack_pointer = .; /* 栈底在最高处,栈自上向下生长。 _stack_pointer指向栈底; */
end = .;
_end = .;
_heap_start = ALIGN(0x1000);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28


--------

heap

-------- <- sp(end)

stack(32KB)

-------- <- stack_top
.bss
--------
.data
--------
.rodata
--------
.text
(.text)
(.entry)
-------- <- _pmem_start + _entry_offset