前言
我们常说CPU只认识0和1,操作系统运行程序的过程就是在读取程序包中的机器码并执行的过程。可执行程序的文件确实主要由机器码组成,但是这些保存在磁盘中的的0、1机器码并不是杂乱的随机分布的,而是按照固定的规则来进行排列分布的。
这其实也不难理解,操作系统要执行程序,就需要从磁盘中读取程序文件,但是假如程序和数据都是杂乱的,那么操作系统将无法理解所读取的机器码的含义,也不知道该从哪里执行。所以操作系统会规定可执行文件的存储格式,比如文件开头的固定部分内容代表什么,包含什么信息。有了这种约定,操作系统不管面对什么程序,都可以先尝试从程序的头部读取固定部分的信息从而了解整个程序的情况,进而执行程序。
每个操作系统都会定义自己平台的可执行文件的格式,比如windows平台的PE(Portable Executable)和Linux的ELF(Executable Linkable Format),本质上他俩是类似的,而且不光是可执行文件按照这种格式,其引用的动态链接库(windows的dll,linux的.so)静态链接库(windows 的.lib,linux的.a)也同样按照这种格式组织文件。
今天我们就以linux平台为例,解析一下ELF文件。
ELF文件的基本格式
文件头
操作系统中,大多数类型的文件都有文件头,文件头一般用于标识该文件的一些重要信息,ELF文件也不例外,ELF的header部分提供了该文件的重要结构表的地址以及程序入口地址,为此linux定义了header的数据结构:
arduino
typedef uint64_t Elf64_Addr;
typedef uint64_t Elf64_Off;
#define EI_NIDENT 16
typedef struct {
unsigned char e_ident[EI_NIDENT]; // 最开头是16个字节的e_ident, 其中包含用以表示ELF文件的字符,以及其他一些与机器无关的信息。开头的4个字节值固定不变,为0x7f和ELF三个字符。
uint16_t e_type; // 该文件的类型 2字节
uint16_t e_machine; // 该程序需要的体系架构 2字节
uint32_t e_version; // 文件的版本 4字节
Elf64_Addr e_entry; // 程序的入口地址 8字节
Elf64_Off e_phoff; // Program header table 在文件中的偏移量 8字节
Elf64_Off e_shoff; // Section header table 在文件中的偏移量 8字节
uint32_t e_flags; // 对IA32而言,此项为0。 4字节
uint16_t e_ehsize; // 表示ELF header大小 2字节
uint16_t e_phentsize; // 表示Program header table中每一个条目的大小 2字节
uint16_t e_phnum; // 表示Program header table中有多少个条目 2字节
uint16_t e_shentsize; // 表示Section header table中的每一个条目的大小 2字节
uint16_t e_shnum; // 表示Section header table中有多少个条目 2字节
uint16_t e_shstrndx; // 包含节名称的字符串是第几个节 2字节
} Elf64_Ehdr;
下面我们以一段代码为例
arduino
// main.c
#include<stdio.h>
void sayWords(){
printf("hello owrld from C \n");
}
int main(){
sayWords();
return 0;
}
把它编译为linux系统下可执行文件
less
$ gcc -o main.o main.c
// 然后执行mian.o
$ ./main.o
接着我们使用readelf工具来读取这个可执行文件
less
readelf -h main.o
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x1060 # 程序入口地址
Start of program headers: 64 (bytes into file) #PHT的地址
Start of section headers: 14008 (bytes into file) # SHT的地址
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes) # 对应的尺寸
Number of program headers: 13
Size of section headers: 64 (bytes)
Number of section headers: 31
Section header string table index: 30 # shstrtab段的所在sht的数组下标
我们参照上文的header的数据结构可以知道开头的这16个字节对应e_ident这个字符数组,这其中的前四个字节称之为ELF文件的魔数,接下来的一个字节表示ELF文件的是32位还是64位,第六个字节表示当前文件的字节序,是大端还是小端,第七个字节表示ELF的版本,后面的字节暂时没有定义,用0填充
类似的示意图
其余比较重要的的信息有程序的入口地址(也就是代码段的地址),以及SHT(Section header table)和PHT(Program Header Table),他们分别表示程序在编译链接后的程序的组织方式以及操作系统在内存中运行程序时的组织方式。
Section header table
section header table是程序编译链接之后按照功能逻辑组织程序代码数据的方式。
我们知道c/cpp源代码代码再被编译之后生成中间文件,中间文件会分成不同的部分,有一些属于代码逻辑,有些是数据,有些是符号....等等这些不同性质的数据一般会分门别类的放置,因此出现代码段,数据段,符号段,
而且c/cpp的代码都是文件单独编译,然后通过链接的方式把一个个编译后的中间文件链接成一个可执行程序。那么这个链接的过程很重要的一步就是把不同的中间文件里的数据合并,而合并的方式就是把他们内部的同类型的段进行合并。
而为了能够在一个地方很方便的找到这些段,定义一个section header table也是很合理的事情。
同样的,我们使用readelf工具的命令也可以查看sht的结构
less
$ readelf -S main.o
There are 31 section headers, starting at offset 0x36b8:
Section Headers:
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
[ 0] NULL 0000000000000000 000000 000000 00 0 0 0
[ 1] .interp PROGBITS 0000000000000318 000318 00001c 00 A 0 0 1
[ 2] .note.gnu.property NOTE 0000000000000338 000338 000030 00 A 0 0 8
[ 3] .note.gnu.build-id NOTE 0000000000000368 000368 000024 00 A 0 0 4
[ 4] .note.ABI-tag NOTE 000000000000038c 00038c 000020 00 A 0 0 4
[ 5] .gnu.hash GNU_HASH 00000000000003b0 0003b0 000024 00 A 6 0 8
[ 6] .dynsym DYNSYM 00000000000003d8 0003d8 0000a8 18 A 7 1 8
[ 7] .dynstr STRTAB 0000000000000480 000480 00008d 00 A 0 0 1
[ 8] .gnu.version VERSYM 000000000000050e 00050e 00000e 02 A 6 0 2
[ 9] .gnu.version_r VERNEED 0000000000000520 000520 000030 00 A 7 1 8
[10] .rela.dyn RELA 0000000000000550 000550 0000c0 18 A 6 0 8
[11] .rela.plt RELA 0000000000000610 000610 000018 18 AI 6 24 8
[12] .init PROGBITS 0000000000001000 001000 00001b 00 AX 0 0 4
[13] .plt PROGBITS 0000000000001020 001020 000020 10 AX 0 0 16
[14] .plt.got PROGBITS 0000000000001040 001040 000010 10 AX 0 0 16
[15] .plt.sec PROGBITS 0000000000001050 001050 000010 10 AX 0 0 16
[16] .text PROGBITS 0000000000001060 001060 00011c 00 AX 0 0 16
[17] .fini PROGBITS 000000000000117c 00117c 00000d 00 AX 0 0 4
[18] .rodata PROGBITS 0000000000002000 002000 000018 00 A 0 0 4
[19] .eh_frame_hdr PROGBITS 0000000000002018 002018 00003c 00 A 0 0 4
[20] .eh_frame PROGBITS 0000000000002058 002058 0000cc 00 A 0 0 8
[21] .init_array INIT_ARRAY 0000000000003db8 002db8 000008 08 WA 0 0 8
[22] .fini_array FINI_ARRAY 0000000000003dc0 002dc0 000008 08 WA 0 0 8
[23] .dynamic DYNAMIC 0000000000003dc8 002dc8 0001f0 10 WA 7 0 8
[24] .got PROGBITS 0000000000003fb8 002fb8 000048 08 WA 0 0 8
[25] .data PROGBITS 0000000000004000 003000 000010 00 WA 0 0 8
[26] .bss NOBITS 0000000000004010 003010 000008 00 WA 0 0 1
[27] .comment PROGBITS 0000000000000000 003010 00002b 01 MS 0 0 1
[28] .symtab SYMTAB 0000000000000000 003040 000378 18 29 18 8
[29] .strtab STRTAB 0000000000000000 0033b8 0001e3 00 0 0 1
[30] .shstrtab STRTAB 0000000000000000 00359b 00011a 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
R (retain), l (large), p (processor specific)
sht是一个大小为31的数组,数组的每个元素都是一个固定大小的结构体(结构体你可以理解为一个对象),结构体的定义如下
ini
typedef struct elf64_shdr {
Elf64_Word sh_name; #段名
Elf64_Word sh_type; #段的类型
Elf64_Xword sh_flags; #段的标志位 W:该段可写 A:该段需要申请内存空间 X:该段可被执行
Elf64_Addr sh_addr; #段的地址
Elf64_Off sh_offset; #内存中的虚拟地址
Elf64_Xword sh_size; #文件中的偏移
Elf64_Word sh_link; #段链接信息
Elf64_Word sh_info;
Elf64_Xword sh_addralign; # 段地址对齐
Elf64_Xword sh_entsize; #段大小(针对固定大小)
} Elf64_Shdr;
从这个数组组成的段表中我们也可以看到ELF头信息中的程序入口地址就是代码段的地址,而Start of section headers也确实指向对应的sht的地址。
需要解释一下的是,程序在运行时往往是基于某一个基地址开始运行,然后后续的执行和跳转的地址都是在基地址的基础上加上偏移(offset),而在编译链接之后,编译器链接器并不知道程序未来会在哪里运行,因此一般会把基地址假设为0,那么此时的偏移就可以被认为是当前地址。
Program Header Table
操作系统通过内存映射的方式读入可执行程序,为了节约内存空间往往不是把整个程序读入到内存中,而是通过内存分页(把整块的内存划分了一个个细小的块),一页一页的载入程序的部分内容。假设单页的大小是4kb,那么载入程序部分内容时,比如载入代码段,往往希望代码段的大小能正好时页大小的整数倍,这样能达到最好的内存使用效率,但现实很难做到,假设载入的代码段时4100byte(4kb多4个字节),因为超过了一个页的大小,所以系统必须给程序分两个页,也就是8kb,这就产生了几乎整页的浪费。假设此时又载入了一个init段,大小是512byte,则又需要分一个页,这就导致不到5kb的内容占据了3个页(12kb)的大小。
于是自然就产生了一个合理的设计,那就是尽可能的把程序的section都归纳到一起载入,于是就需要对之前的section重新分类,section是根据功能属性来区分的,这次则是根据section的权限来区分:把读写权限相同的段视作一个新的部分(我们称作segment)。
于是像我们举例的text段和init段,可以被视作一个段(segment)载入内存的话,那么他们的大小是4612byte,只需要占用两个页的大小即可。
我们可以使用如下命令查看PHT
python
$ readelf -l main.o
Elf file type is DYN (Shared object file)
Entry point 0x1060
There are 13 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000040 0x0000000000000040 0x0000000000000040 0x0002d8 0x0002d8 R 0x8
INTERP 0x000318 0x0000000000000318 0x0000000000000318 0x00001c 0x00001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x000628 0x000628 R 0x1000
LOAD 0x001000 0x0000000000001000 0x0000000000001000 0x000189 0x000189 R E 0x1000
LOAD 0x002000 0x0000000000002000 0x0000000000002000 0x000124 0x000124 R 0x1000
LOAD 0x002db8 0x0000000000003db8 0x0000000000003db8 0x000258 0x000260 RW 0x1000
DYNAMIC 0x002dc8 0x0000000000003dc8 0x0000000000003dc8 0x0001f0 0x0001f0 RW 0x8
NOTE 0x000338 0x0000000000000338 0x0000000000000338 0x000030 0x000030 R 0x8
NOTE 0x000368 0x0000000000000368 0x0000000000000368 0x000044 0x000044 R 0x4
GNU_PROPERTY 0x000338 0x0000000000000338 0x0000000000000338 0x000030 0x000030 R 0x8
GNU_EH_FRAME 0x002018 0x0000000000002018 0x0000000000002018 0x00003c 0x00003c R 0x4
GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10
GNU_RELRO 0x002db8 0x0000000000003db8 0x0000000000003db8 0x000248 0x000248 R 0x1
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt
03 .init .plt .plt.got .plt.sec .text .fini
04 .rodata .eh_frame_hdr .eh_frame
05 .init_array .fini_array .dynamic .got .data .bss
06 .dynamic
07 .note.gnu.property
08 .note.gnu.build-id .note.ABI-tag
09 .note.gnu.property
10 .eh_frame_hdr
11
12 .init_array .fini_array .dynamic .got
None .comment .symtab .strtab .shstrtab
PHT也是一个数组结构,而且ELF中也有一个专门的数据结构表示PHT,
arduino
typedef struct {
Elf64_Word p_type; // segment的类型 LOAD 表示会被映射到内存中; INTERP表示链接器类型 用于载入动态连接器
Elf64_Word p_flags; // segment的权限属性 R 读 W写 E执行
Elf64_Off p_offset; // 偏移
Elf64_Addr p_vaddr; // segment在内存的虚拟地址空间的的地址
Elf64_Addr p_paddr; // 物理装载地址
Elf64_Xword p_filesz; // segment在elf文件中占据的空间长度
Elf64_Xword p_memsz; // segment在虚拟地址空间中占据的长度
Elf64_Xword p_align; // 对齐属性 对齐2的p_align次方
} Elf64_Phdr;
PHT的存在,使得操作系统加载程序时尽可能少的产生内存碎片,尽可能的提高内存的使用效率。
如果我们仔细观察,会发现PHT的starting at offset=64,和ELF Header的大小一致,也就是说PHT其实紧跟在ELF Header之后存放。
小结
通过ELF文件头,SHT,PHT这三个数据结构,操作系统已经可以从总体上获得了这个程序文件的使用说明书了。
- 通过读取ELF-Header,可以获得程序的执行入口,SHT的地址,PHT的地址。
- 通过PHT可以获得程序的最佳载入方式
- 通过SHT,可以在程序执行过程中定位到不同功能的段(.symtab/.data/.init)进而获得详细的信息。