程序员自我修养阅读笔记——可执行文件的装载过程
Posted grayondream
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了程序员自我修养阅读笔记——可执行文件的装载过程相关的知识,希望对你有一定的参考价值。
1 可执行文件的装载过程
1.1 进程虚拟地址空间
一个可执行文件被装载到内存变成程序后(进程和程序的区别在于一个是静态的一个是动态的,程序就是菜谱,进程就是厨师参考菜谱做菜的过程),拥有自己独立的地址空间。该地址空间是一个虚拟的地址空间,在该进程看来该空间内包含内核和自身,32bit系统该空间的大小是4GB,64bit系统是2的64次方-1bit。也就是说,一个程序实际上能够用到的虚拟内存空间实际上是小于理论值的,因为操作系统需要占用。
1.2 装载的方式
静态装入:将程序执行时所需要的指令和数据全部载入到内存中。
动态装入:利用程序的局部性原理,仅仅将程序中需要运行的部分装入,类似虚拟页的换入换出。
动态转入的两种实现:
- 覆盖装入:覆盖装入的方法把挖掘内存潜力的任务交给了程序员,程序员在编写程序的时候必须手工将程序分割成若干块,然后编写一个小的辅助代码来管理这些模块何时应该驻留内存而何时应该被替换掉;
- 页映射:页映射是将内存和所有磁盘中的数据和指令按照页为单位划分成若干个页,以后所有的装载和操作的单位就是页,利用页面调度算法进行页面调度。
1.3 进程的创建过程
一般进程的创建需要做三件事情:
- 创建独立的虚拟内存空间。进程首次创建时只会分配一个页目录,页映射关系是在有后续发生页错误时进行设置;
- 读取可执行文件,建立虚拟地址空间和可执行文件的映射关系。系统会根据可执行文件的大小建立相关的映射关系。
- 将CPU指令寄存器设置成可执行文件的入口,开始进入进程调度循环。
1.4 进程虚拟地址空间分布
1.4.1 ELF文件链接视图和执行视图
创建进程时,由于可执行文件往往存在多个段,如果针对每个段都单独进行内存映射,这样很容易造成内存的浪费。比如数据段可能就2个字节,但是如果依然按照一个页大小分配,剩余的4k-2的内存都浪费了。如果站在操作系系统的角度看,操作系统并不关心不同段的内容,只关心段的权限(可读?可写?可执行?)。因此可以通过将相同权限的段合并统一进行映射减少内存的浪费。在ELF中多个段的合并的集合就是一个Segment,而Segment中每个段都是相同类型的Section。
- 代码段可读可执行;
- 数据段和BSS段可读可写;
- 只读数据段只读。
下面利用下面简单的程序展示如何合并
//编译命令clang -static main.c -o main.elf
#include <unistd.h>
int main()
while(1)
sleep(1000);
return 0;
使用readelf -S main.elf
查看详细的Section。
Section Headers:
[Nr] Name Type Address Offset Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0
[ 1] .note.ABI-tag NOTE 0000000000400190 00000190 0000000000000020 0000000000000000 A 0 0 4
readelf: Warning: [ 2]: Link field (0) should index a symtab section.
[ 2] .rela.plt RELA 00000000004001b0 000001b0 0000000000000228 0000000000000018 AI 0 19 8
[ 3] .init PROGBITS 00000000004003d8 000003d8 0000000000000017 0000000000000000 AX 0 0 4
[ 4] .plt PROGBITS 00000000004003f0 000003f0 00000000000000b8 0000000000000000 AX 0 0 8
[ 5] .text PROGBITS 00000000004004b0 000004b0 000000000008f3d0 0000000000000000 AX 0 0 16
[ 6] __libc_freeres_fn PROGBITS 000000000048f880 0008f880 0000000000001523 0000000000000000 AX 0 0 16
[ 7] __libc_thread_fre PROGBITS 0000000000490db0 00090db0 00000000000010eb 0000000000000000 AX 0 0 16
[ 8] .fini PROGBITS 0000000000491e9c 00091e9c 0000000000000009 0000000000000000 AX 0 0 4
[ 9] .rodata PROGBITS 0000000000491ec0 00091ec0 000000000001926c 0000000000000000 A 0 0 32
[10] .stapsdt.base PROGBITS 00000000004ab12c 000ab12c 0000000000000001 0000000000000000 A 0 0 1
[11] .eh_frame PROGBITS 00000000004ab130 000ab130 000000000000a578 0000000000000000 A 0 0 8
[12] .gcc_except_table PROGBITS 00000000004b56a8 000b56a8 000000000000008e 0000000000000000 A 0 0 1
[13] .tdata PROGBITS 00000000006b6120 000b6120 0000000000000020 0000000000000000 WAT 0 0 8
[14] .tbss NOBITS 00000000006b6140 000b6140 0000000000000040 0000000000000000 WAT 0 0 8
[15] .init_array INIT_ARRAY 00000000006b6140 000b6140 0000000000000010 0000000000000008 WA 0 0 8
[16] .fini_array FINI_ARRAY 00000000006b6150 000b6150 0000000000000010 0000000000000008 WA 0 0 8
[17] .data.rel.ro PROGBITS 00000000006b6160 000b6160 0000000000002d94 0000000000000000 WA 0 0 32
[18] .got PROGBITS 00000000006b8ef8 000b8ef8 00000000000000f8 0000000000000000 WA 0 0 8
[19] .got.plt PROGBITS 00000000006b9000 000b9000 00000000000000d0 0000000000000008 WA 0 0 8
[20] .data PROGBITS 00000000006b90e0 000b90e0 0000000000001af0 0000000000000000 WA 0 0 32
[21] __libc_subfreeres PROGBITS 00000000006babd0 000babd0 0000000000000048 0000000000000000 WA 0 0 8
[22] __libc_IO_vtables PROGBITS 00000000006bac20 000bac20 00000000000006a8 0000000000000000 WA 0 0 32
[23] __libc_atexit PROGBITS 00000000006bb2c8 000bb2c8 0000000000000008 0000000000000000 WA 0 0 8
[24] __libc_thread_sub PROGBITS 00000000006bb2d0 000bb2d0 0000000000000008 0000000000000000 WA 0 0 8
[25] .bss NOBITS 00000000006bb2e0 000bb2d8 00000000000016f8 0000000000000000 WA 0 0 32
[26] __libc_freeres_pt NOBITS 00000000006bc9d8 000bb2d8 0000000000000028 0000000000000000 WA 0 0 8
[27] .comment PROGBITS 0000000000000000 000bb2d8 000000000000005f 0000000000000001 MS 0 0 1
[28] .note.stapsdt NOTE 0000000000000000 000bb338 0000000000001638 0000000000000000 0 0 4
[29] .symtab SYMTAB 0000000000000000 000bc970 000000000000a980 0000000000000018 30 677 8
[30] .strtab STRTAB 0000000000000000 000c72f0 0000000000006916 0000000000000000 0 0 1
[31] .shstrtab STRTAB 0000000000000000 000cdc06 0000000000000163 0000000000000000 0 0 1
使用readelf -l main.elf
查看合并后的Segment。
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000 0x00000000000b5736 0x00000000000b5736 R E 0x200000
LOAD 0x00000000000b6120 0x00000000006b6120 0x00000000006b6120 0x00000000000051b8 0x00000000000068e0 RW 0x200000
NOTE 0x0000000000000190 0x0000000000400190 0x0000000000400190 0x0000000000000020 0x0000000000000020 R 0x4
TLS 0x00000000000b6120 0x00000000006b6120 0x00000000006b6120 0x0000000000000020 0x0000000000000060 R 0x8
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 RW 0x10
GNU_RELRO 0x00000000000b6120 0x00000000006b6120 0x00000000006b6120 0x0000000000002ee0 0x0000000000002ee0 R 0x1
Section to Segment mapping:
Segment Sections...
00 .note.ABI-tag .rela.plt .init .plt .text __libc_freeres_fn __libc_thread_freeres_fn .fini .rodata .stapsdt.base .eh_frame .gcc_except_table
01 .tdata .init_array .fini_array .data.rel.ro .got .got.plt .data __libc_subfreeres __libc_IO_vtables __libc_atexit __libc_thread_subfreeres .bss __libc_freeres_ptrs
02 .note.ABI-tag
03 .tdata .tbss
04
05 .tdata .init_array .fini_array .data.rel.ro .got
从上面的结果能够看出32个Section被映射成了5个Segment,而我们只关心其中涉及LOAD的Segment,可以看到分别被合并为00和01两个Segment。
对应到ELF文件中(ELF文件和动态库文件,目标文件不涉及转载不存在)都有一个程序表头来表示Segment的信息,其基本结构如下:
typedef struct elf32_phdr
Elf32_Word p_type; //段的类型,LOAD,DYNAMIC等
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;
1.4.2 堆和栈
在操作系统里面,VMA(虚拟内存区域)除了被用来映射可执行文件中的各个Segment以外,它还可以有其他的作用,操作系统通过使用VMA来对进程的地址空间进行管理。我们知道进程在执行的时候它还需要用到堆和栈等空间,事实上它们在进程的虚拟空间中的表现也是以VMA的形式存在的,很多情况下,一个进程中的堆和栈分别都有一个对应的VMA。
执行刚才编译的可执行文件,查看linux系统中的proc目录下的maps就能看到VMA的映射情况。
➜ Desktop ./main.elf &
[1] 265
➜ Desktop cat /proc/265/maps
00400000-004b5000 r-xp 00000000 00:00 207690 /mnt/c/Users/ares/Desktop/main.elf
004b5000-004b6000 r-xp 000b5000 00:00 207690 /mnt/c/Users/ares/Desktop/main.elf
006b6000-006bc000 rw-p 000b6000 00:00 207690 /mnt/c/Users/ares/Desktop/main.elf
006bc000-006bd000 rw-p 00000000 00:00 0
01d4f000-01d72000 rw-p 00000000 00:00 0 [heap]
7fffcfdf5000-7fffd05f5000 rw-p 00000000 00:00 0 [stack]
7fffd0baa000-7fffd0bab000 r-xp 00000000 00:00 0 [vdso]
能够看到栈空间大小是1Mb,堆是17Kb。
1.4.3 VMA类型
根据上面的对比,可以将VMA划分为下面几种(划分按照我的机器上的结果划分,而不是按照书上来的):
- 代码VMA:可读可执行;
- 数据VMA:可读可写;
- 堆VMA:可读可写;
- 栈VMA:可读可写。
2 Linux内核装载ELF文件过程
Linux通过bash创建进程的方式:
- 首先内核会调用
fork()
创建当前进程的复制品,当前进程也就是父进程一般是bash
; - 然后会调用系统调用
execve
系列函数装载对应的可执行文件; - 装载elf文件时会:
- 检查ELF可执行文件的有效性;
- 寻找动态链接
.interp
段,设置动态链接的路径; - 根据ELF可执行文件的程序头表的描述,对ELF文件进行映射;
- 初始化ELF进程环境;
- 将系统调用的返回地址修改为ELF可执行文件的入口点:
- 如果ELF文件为静态链接,则入口点就是ELF文件的入口点;
- 如果ELF文件为动态链接,则入口点就是动态连接器。
3 Windows PE装载
PE文件的装载跟ELF有所不同,由于PE文件中,所有段的起始地址都是页的倍数,段的长度如果不是页的整数倍,那么在映射时向上补齐到页的整数倍,我们也可以简单地认为在32位的PE文件中,段的起始地址 和长度都是4096字节的整数倍。由于这个特点,PE文件的映射过错会比ELF简单得多,因为它无需考虑如ELF里面诸多段地址对齐之类的问题,虽然这样会浪费一些磁盘和内存空间。PE可执行文件的段的数量一般很少,不像ELF中经常有十多个"Section",最后不得不使用“Segment”的概念把它们合并到一起装载,PE文件中,链接器在生产可执行文件时,往往将所有的段尽可能地合并,所以一般只有代码段、数据段、只读数据段和BSS等为数不多的几个段。
- 先读取文件中的第一个页,在这个页中,包含了DOS头、PE文件头和段表;
- 检查进程地址空间中,目标地址是否可用,如果不可用,则另外选一个装载地址。这个问题对于可执行文件来说基本不存在,因为它往往是进程第一个装入的模块,所以目标地址不太可能被占用;
- 使用段表中提供的信息,将PE文件中所有的段一一映射到地址空间中相应的位置;
- 如果装载地址不是目标地址,则进行Rebasing;
- 装载所有PE文件所需要的DLL文件;
- 对PE文件中的所有导入符号进行解析;
- 根据PE头中指定的参数,建立初始化栈和堆;
- 建立主线程并且启动进程。
以上是关于程序员自我修养阅读笔记——可执行文件的装载过程的主要内容,如果未能解决你的问题,请参考以下文章
读书笔记|《程序员的自我修养》- 04 可执行文件的装载与进程
读书笔记|《程序员的自我修养》- 04 可执行文件的装载与进程