装载方式
回顾一下操作系统的知识,程序执行的时候需要的指令和数级都必须在内存中时,程序才能正常运行,最简单的方式就是将指令和数级全部加载到内存中,这样肯定可以顺利执行,但这样的方式对内存大小来说是一个考验。因此,装载方式必须尽可能的有效利用内存。
目前主流的装载方式是分页加载,它跟随着虚拟内存的发明而诞生。分页加载的方式不是一口气将所有数据和指令都加载到内存中,而是通过将磁盘中的指令和数据按照固定大小划分成多个页(一般情况下页的默认大小是4096字节),在执行过程中将需要被用到的指令和对应的数据所在的页加载到内存中即可。
一个页被加载到内存之后,会被内存管理单元所管理,当内存不足的时候,内存管理单元会选择一个页先移除出内存,然后装载新的页面。至于选择哪个页面则有多种算法控制,比如FIFO、LRU、LFU等。
进程的建立
通常情况下,进程的建立包含以下三个部分
创立独立的虚拟空间
读取可执行文件的信息,建立虚拟空间与可执行文件的映射关系
设置CPU的指令寄存器为可执行文件的入口地址
创建虚拟空间
虚拟空间实际上是由一组页映射构成,所以创建空间就是创建页映射所需要的数据结构。在i386的Linux下,创建虚拟空间只需要分配一个页目录(Page Directory)即可,其中的虚拟页到对应的物理空间的关系等到需要被访问到的时候才会被设置。
读取可执行文件信息
Segment
在创建完虚拟空间后,空间内的页还没加载。当在执行过程中,碰到这些缺少的页,那么操作系统应该从磁盘中读取缺少的页,然后分配一块对应大小的物理内存给它。在这整个过程中,最重要的一点就是操作系统应该知道怎么从磁盘找那个找到缺少的页。
操作系统在读取ELF信息进行映射的时候是以页作为单位的,如果在装载的时候,按section进行映射的话,每个section需要占用的内存都是页的整数倍,一些没占满一页的section会导致对应页出现内存碎片。而一般情况下,可执行文件中会具有十几个section,内存碎片所占用的空间就会非常的多。
为了解决内存碎片的问题,ELF在可执行文件中引入了一个Segment的概念,Segment包含多个属性类似的section。Segment的数据结构如下:
typedef struct
{
Elf32_Word p_type; /* Segment type */
Elf32_Off p_offset; /* Segment file offset */
Elf32_Addr p_vaddr; /* Segment virtual address */
Elf32_Addr p_paddr; /* Segment physical address */
Elf32_Word p_filesz; /* Segment size in file */
Elf32_Word p_memsz; /* Segment size in memory */
Elf32_Word p_flags; /* Segment flags */
Elf32_Word p_align; /* Segment alignment */
} Elf32_Phdr;
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;
p_type: Segment的类型,目前只需要关注PT_LOAD(1),其他类型如PT_DYNAMIC(2)、PT_INTERP(3)的类型会在动态链接中碰到
p_offset: Segment在文件中的偏移
p_vaddr: Segment的第一个字节在虚拟空间中的问题。
p_paddr: Segment的物理装载地址,实际上就是之前有碰到的LMA,一般情况下p_paddr 和 p_vaddr的值时相同的。
p_filesz: Segment在文件中的长度
p_memse: Segment在虚拟空间的长度
p_flags:Segment的权限属性
p_align: Segment的对齐属性。实际上存储的是2的幂,也就是说当p_align 等于10的时候,对齐字节为1024.
以下面的代码为例子
// a.cpp
#include<unistd.h>
int main(){
while(1){
sleep(1);
}
return 0;
}
使用gcc -static a.cpp -o main编译成可执行文件,通过readelf 可以看到这个程序具有二十多个section。
>>>> readelf main -S
There are 29 section headers, starting at offset 0xb8070:
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 0000000000400200 00000200
0000000000000020 0000000000000000 A 0 0 4
[ 2] .note.gnu.build-i NOTE 0000000000400220 00000220
0000000000000024 0000000000000000 A 0 0 4
readelf: Warning: [ 3]: Link field (0) should index a symtab section.
[ 3] .rela.plt RELA 0000000000400248 00000248
0000000000000228 0000000000000018 AI 0 18 8
[ 4] .init PROGBITS 0000000000401000 00001000
0000000000000017 0000000000000000 AX 0 0 4
[ 5] .plt PROGBITS 0000000000401018 00001018
00000000000000b8 0000000000000000 AX 0 0 8
[ 6] .text PROGBITS 00000000004010d0 000010d0
000000000007a510 0000000000000000 AX 0 0 16
..........
[26] .symtab SYMTAB 0000000000000000 000a62f0
000000000000aec0 0000000000000018 27 742 8
[27] .strtab STRTAB 0000000000000000 000b11b0
0000000000006d93 0000000000000000 0 0 1
[28] .shstrtab STRTAB 0000000000000000 000b7f43
0000000000000128 0000000000000000 0 0 1
而通过readelf -l 查看Segment Header的信息可以看到Segment信息并不多
>>>>> readelf -l main
Elf file type is EXEC (Executable file)
Entry point 0x401a30
There are 8 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000
0x0000000000000470 0x0000000000000470 R 0x1000
LOAD 0x0000000000001000 0x0000000000401000 0x0000000000401000
0x000000000007b071 0x000000000007b071 R E 0x1000
LOAD 0x000000000007d000 0x000000000047d000 0x000000000047d000
0x00000000000237ac 0x00000000000237ac R 0x1000
LOAD 0x00000000000a10e0 0x00000000004a20e0 0x00000000004a20e0
0x00000000000051f0 0x0000000000006940 RW 0x1000
NOTE 0x0000000000000200 0x0000000000400200 0x0000000000400200
0x0000000000000044 0x0000000000000044 R 0x4
TLS 0x00000000000a10e0 0x00000000004a20e0 0x00000000004a20e0
0x0000000000000020 0x0000000000000060 R 0x8
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
GNU_RELRO 0x00000000000a10e0 0x00000000004a20e0 0x00000000004a20e0
0x0000000000002f20 0x0000000000002f20 R 0x1
Section to Segment mapping:
Segment Sections...
00 .note.ABI-tag .note.gnu.build-id .rela.plt
01 .init .plt .text __libc_freeres_fn .fini
02 .rodata .eh_frame .gcc_except_table
03 .tdata .init_array .fini_array .data.rel.ro .got .got.plt .data __libc_subfreeres __libc_IO_vtables __libc_atexit .bss __libc_freeres_ptrs
04 .note.ABI-tag .note.gnu.build-id
05 .tdata .tbss
06
07 .tdata .init_array .fini_array .data.rel.ro .got
目前只关心LOAD的Segment,其他类型的段都是在装载过程中起辅助作用。通过上面的Segment信息,可以看到.init、.text等具有可读、可执行属性的Section被分到了同一个Segment中;.tdata、 .tbss等可读可写的Section被分到了同一个Segment中。
Segment和Section实际上是从不同角度来划分一个ELF文件,从Section来看ELF文件是链接视图(Linking View);从Segment角度来看就是执行试图(Execution View)
linux将虚拟空间的一个Segment称之为VMA(Virtual Memory Adress).
在操作系统里面,VMA除了被用来映射各个Segment之外,它还需要被使用管理进程的地址空间,比如大家熟知的堆(Heap)、栈(Stack)就是以VMA的形式存在的。通过查看/proc来看进程空间的分布。
>>> ./main &
[1] 12697
>>> cat /proc/12697/maps
00400000-00401000 r--p 00000000 fe:21 805333484 ./main
00401000-0047d000 r-xp 00001000 fe:21 805333484 ./main
0047d000-004a1000 r--p 0007d000 fe:21 805333484 ./main
004a2000-004a8000 rw-p 000a1000 fe:21 805333484 ./main
004a8000-004a9000 rw-p 00000000 00:00 0
00d3a000-00d5d000 rw-p 00000000 00:00 0 [heap]
7ffd4bf62000-7ffd4bf83000 rw-p 00000000 00:00 0 [stack]
7ffd4bfc1000-7ffd4bfc4000 r--p 00000000 00:00 0 [vvar]
7ffd4bfc4000-7ffd4bfc6000 r-xp 00000000 00:00 0 [vdso]
第一列是VMA的地址范围
第二列是VMA的权限,r是可读,w是可写,x是可执行,p是私有。
第三列是VMA对应的Segment在文件中的偏移
第四列是VMA对应的主设备号和次设备号
第五列是映像文件的节点号
第六列是文件路径
像上面没有设备号的VMA被成为匿名虚拟内存空间,像stack、heap都属于这种VMA。
段地址对齐
引用书上的例子,考虑有以下几个Segment需要加载
段 | 长度 | 偏移 |
SEG0 | 127 | 34 |
SEG1 | 9899 | 164 |
SEG2 | 1988 |
ELF可执行文件的起始虚拟地址是0X08048000,对于每个Segment不是页的整数倍,假设按向上取整进行分配的话,则分配结果如下
段 | 起始虚拟地址 | 大小 | 有效字节 | 假设加载的物理地址 | 在文件中的偏移 |
SEG0 | 0X8048000 | 0X1000 | 127 | 0X00000 - 0X00FFF | 34 |
SEG1 | 0X8049000 | 0X3000 | 9899 | 0X01000 - 0X03FFF | 164 |
SEG2 | 0X804C000 | 0X1000 | 1988 | 0X04000 - 0X04FFF |
3个Segment总长度12014字节,按这种对齐方式 会分配5个物理页面,共20480字节,空间使用效率只有58.6%。
为了解决这个内存碎片的问题,诞生了一种取巧的方式,即让部分物理页面引射两次。SEG1 和 SEG0可以一个物理页面,然后系统将这个物理地址映射成两个虚拟地址。
段 | 起始虚拟地址 | 大小 | 有效字节 | 假设加载的物理地址 | 在文件中的偏移 |
SEG0 | 0X8048022 (0X804800 + 34(0X22)) | 0X1000 | 127 | 0X00022 - 0X000A0 | 34(0x22) |
SEG1 | 0X80490A4 (0x8048022 + 127(0x7F) + 3(字节对齐) + 0X1000(逻辑页面)) | 0X3000 | 9899 | 0X000A4 - 0X0274E | 164(0xa4) |
SEG2 | 0X804C74F (0X80490A4 + 9899(26AB) + 0X1000(逻辑页面)) | 0X1000 | 1988 | 0X0274F - 0X02F12 |
看到各个地址的运算,大概就能看得出来,物理地址从5个页面的分配被压缩到了3个页面的分配,实际物理的内存利用率是得到了提升,注意逻辑页面还是分配了5个,但逻辑页面是可以被替换出内存的,所以还是优先考虑物理内存的使用。
这边还有一个规律,那就是任何一个可装载的Segment, 它的p_vaddr % align == p_offset % align。
https://stackoverflow.com/questions/72414574/why-elfs-vaddr-is-not-page-aligned这篇帖子提供了原因。
设置程序执行入口地址
这一步的逻辑比较简单,操作系统设置CPU寄存器,将控制权转交给进程。虽然看上去是一句话的事情,但实际上操作系统所做的事情会比较复杂,涉及内核态与用户态的切换等。这个程序执行的入口地址存储在ELF文件头中,就是Entry point address。
总结
这边讨论了进程创建的一个大概的流程,主要是通过实验介绍了进程在创建的时候,是如何使用读取ELF文件的Segment信息的。还通过对《程序员自我修养》一书中的例子进行分析,详细分解了进程在加载Segment的时候是如何对物理页面的使用进行优化的。