认识 ELF 文件格式(三)

认识 ELF 文件格式(三)

本篇文章我们介绍 ELF 文件中的程序头

一、程序头

程序头提供了 ELF 文件的段视图,segment 我们将其翻译成段。与节头提供的节视图不一样。节视图仅适用于静态链接。而段视图是在将 ELF 文件加载到进程并执行的时候,定位相关代码和数据,并确定加载到虚拟内存中的内容时,操作系统和动态链接器会用到段视图。

ELF 的段包含零个或多个节,实际上就是把多个节捆绑成单个段。段提供的可执行视图,只有 ELF 二进制文件才会用到他们,而非二进制文件,比如可重定位对象,则用不到他们。

如下则是程序头以及段和节之间的映射关系。

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
# readelf --wide --segments main 

Elf file type is DYN (Shared object file)
Entry point 0x400
There are 10 program headers, starting at offset 52

Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000034 0x00000034 0x00000034 0x00140 0x00140 R 0x4
INTERP 0x000174 0x00000174 0x00000174 0x00013 0x00013 R 0x1
[Requesting program interpreter: /lib/ld-linux.so.2]
LOAD 0x000000 0x00000000 0x00000000 0x0074c 0x0074c R E 0x1000
LOAD 0x000ed8 0x00001ed8 0x00001ed8 0x00130 0x00134 RW 0x1000
DYNAMIC 0x000ee0 0x00001ee0 0x00001ee0 0x000f8 0x000f8 RW 0x4
NOTE 0x000188 0x00000188 0x00000188 0x00044 0x00044 R 0x4
TLS 0x000ed8 0x00001ed8 0x00001ed8 0x00000 0x00004 R 0x4
GNU_EH_FRAME 0x000614 0x00000614 0x00000614 0x0003c 0x0003c R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x10
GNU_RELRO 0x000ed8 0x00001ed8 0x00001ed8 0x00128 0x00128 R 0x1

Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame
03 .init_array .fini_array .dynamic .got .data .bss
04 .dynamic
05 .note.ABI-tag .note.gnu.build-id
06 .tbss
07 .eh_frame_hdr
08
09 .init_array .fini_array .dynamic .got

我们随便找一个二进制文件,然后通过 readelf 查看对应的段和节之间的映射关系。如上,有 10 个程序头,并且通过 Section to Segment mapping 部分,我们发现 “段只不过是把节简单的绑定在一起” 而已。

如下是一个程序头的结构体定义,一个 ELF 文件有多个程序头,程序头如同数组一样排列,我们也将其称为程序头表。

1
2
3
4
5
6
7
8
9
10
11
typedef struct
{
Elf64_Word p_type; /* Segment type */
Elf64_Word p_flags; /* Segment flags */
Elf64_Off p_offset; /* Segment file offset */
Elf64_Addr p_vaddr; /* Segment virtual address */
Elf64_Addr p_paddr; /* Segment physical address */
Elf64_Xword p_filesz; /* Segment size in file */
Elf64_Xword p_memsz; /* Segment size in memory */
Elf64_Xword p_align; /* Segment alignment */
} Elf64_Phdr;

接下来,我们来解析每一个字段的含义。

1. p_type 字段

标识了段的类型,常见的类型包括:PT_LOAD、PT_DYNAMIC、PT_INTERP

  • PT_LOAD:此类型的段会在创建进程时加载到内存中,程序头的剩余部分描述了可加载块的大小和将其加载到的地址。通常至少有两个 PT_LOAD 类型的段,一个包含不可写数据节,另一个包含可写数据节。
  • PT_INTERP:此段通常包含 .interp 节,该节提供了加载二进制文件的解释器的名称。
  • PT_DYNAMIC:此段包含了 .dynamic 节,该节告诉解释器如何解析二进制文件用于执行。

2. p_flags 字段

指定了段在运行时的访问权限,这里有 3 种重要的标志类型:PF_X、PF_W、PF_R

  • PF_X 标志:指定该段为可执行,一般对代码段设置此标志
  • PF_W 标志:表示该段为可写,一般对可写数据段设置该位,代码段一般不设置该位。
  • PF_R 标志:表示该段为可读,该属性在代码段和数据段都是正常情况。

3. p_offset 字段、p_vaddr 字段、p_paddr 字段、p_filesz 字段和 p_memsz 字段

其中 p_offset 字段:指定该段的起始文件偏移量,p_vaddr 字段:指定了加载的虚拟地址,p_filesz 字段:指定了段的大小。p_memsz 字段:指定了段在内存中的大小。

在某些操作系统上,可以使用 p_paddr 字段:指定段在物理内存的那个地址进行加载。在 Linux 操作系统中,该字段并未被使用且设置为零,因为操作系统在虚拟内存中执行二进制文件。

为什么要用 p_filesz 和 p_memsz 来指定段在文件中的大小和在内存中大小呢?

因为我们知道某些节只表明需要在内存中分配一些字节,而实际上并没有在二进制文件中占用这些字节。比如 .bss 节包含的初始化数据,肯定为零,所以实际上无须在二进制文件中包含这些零。但是。再将包含 .bss 节的段加载虚拟内存的时候,就应该分配 .bss 里面所有的字节。因此,p_memsz 很可能会大于 p_filesz 字段。这种情况下,链接器在加载二进制文件时,就会在段的末尾添加额外的字节,并且将其初始化为零。

4. p_align 字段

指定了段所需的内存对齐方式,以字节为单位。对齐值 0 或 1 表示不需要特定的对齐方式。如果 p_align 未设置为 0 或者 1,则其值必须是 2 的指数,并且 p_vaddr 必须等于 p_offset % p_align

二、小结

程序头我们介绍完了,因为段就是多个节的映射。只是看待的角度不同而已。

再将 ELF 二进制加载到进程中并且执行的时候,定位相关代码和数据并确定加载到虚拟内存中的内容时,操作系统和动态链接器就会使用到段视图。