装载

事实上,从操作系统的角度来看,一个进程最关键的特征是它拥有独立的虚拟地址空间,这使得它有别于其他进程.很多时候一个程序被执行同时都伴随着一个新的进程的创建

我们来看看这种最通常的情形:创建一个进程,然后装载相应的可执行文件并且执行.

在有虚拟存储的情况下,上述过程最开始只需要做三件事情:

加载

前文我们提到分段主要是为了将指令和数据的存放区分开, 但是当我们站在操作系统装载可执行文件的角度看问题时,可以发现它实际上并不关心可执行文件各个段所包含的实际内容,操作系统只关心一些跟装载相关的问题,最主要的是段的权限(可读/可写/可执行).ELF文件中,段的权限往往只有为数不多的几种组合:

  1. 以代码段为代表的权限为可读可执行的段 RE
  1. 以数据段和BSS段为代表的权限为可读可写的段 RW
  1. 以只读数据段为代表的权限为只读的段 R

那么我们可以找到一个很简单的方案就是:对于相同权限的段,把它们合并到一起当作一个段进行映射. 比如有两个段分别叫".text"和"init",它们包含的分别是程序的可执行代码和初始化代码,并且它们的权限相同,都是可读并且可执行的.假设.text为4097字节,.init为512字节,这两个段分别映射的话就要占用三个页面,但是,如果将它们合并成一起映射的话只须占用两个页

ELF可执行文件引入了一个概念叫做"Segment",一个"Segment"包含一个或多个属性类似的"Section".

20240112161635

正如我们上面的例子中看到的,如果将".text"段和"init"段合并在一起看作是一个"Segment",那么装载的时候就可以将它们看作一个整体一起映射,也就是说映射以后在进程虚存空间中只有一个相对应的VMA,而不是两个,这样做的好处是可以很明显地减少页面内部碎片,从而节省了内存空间

"Segment"和"Section"主要是从不同的角度定义的, 其中文含义段/节并无太大差别

我们可以使用 readelf -l 来查看 ELF 的 segment

readelf -l SectionMapping.elf

从装载的角度看,我们目前只关心"LOAD"类型的Segment,因为只有它是需要被映射的,其他的诸如"NOTE""TLS"/"GNU_STACK"都是在装载时起辅助作用的,我们在这里不详细展开. Program Header 中的分段方式就是按照 section 的读写执行权限进行划分的, 每一个 segment 合并的 section 都列在下方的 segment mapping 中:

其中可以看到有两个只读 R 段, 第一个(00)只读 LOAD 段保存着诸如 .note.gnu.property, ABI-tag 之类的信息, 第三个(02)只读 LOAD 保存 rodata 段的只读数据

20240112163329

对于"LOAD"类型的"Segment"来说,MemSiz(p_memsz) 的值通常是等于 FileSiz(p_filesz)的. 这表示该段的文件大小和操作系统装载时应当分配的内存空间是相同的.

而如果 p_memsz 大于 p_filesz, 如下图中最后一个 RW 段, 表示该"Segment"在内存中所分配的空间大小超过文件中实际的大小,这部分"多余"的部分则全部填充为"0".这样做的好处是,我们在构造ELF可执行文件时不需要再额外设立BSS的"Segment"了,可以把数据"Segment"的 p_memsz 扩大,那些额外的部分就是BSS.因为数据段和BSS的唯一区别就是:数据段从文件中初始化内容,而BSS段的内容全都初始化为0

20240112164418

堆和栈

linux 内核将空间中的一片连续内存区域称为虚拟内存区域(VMA,Virtual Memory Area), 操作系统通过使用VMA来对进程的地址空间进行管理. VMA除了被用来映射可执行文件中的各个"Segment"以外,它还可以有其他的作用,.

进程在执行的时候它还需要用到栈(Stack)/堆(Heap)等空间,事实上它们在进程的虚拟空间中的表现也是以VMA的形式存在的,很多情况下,一个进程中的栈和堆分别都有一个对应的VMA.在Linux下,我们可以通过查看"/proc"来查看进程的虚拟空间分布:

$ ./SectionMapping.elf &
$ cat /proc/[pid]/maps

20240112165843

上图中可以发现进程中有 10 个 VMA, 前 5 个是映射到可执行文件中的 4 个 Segment. 另外三个段的文件所在设备主设备号和次设备号及文件节点号都是0,则表示它们没有映射到文件中,这种VMA叫做匿名虚拟内存区域(Anonymous Virtual Memory Area).

其中第三个只读段被分成了两个 VMA 进行映射

我们可以看到下面有两个区域分别是堆(Heap)和栈(Stack), 这两个VMA几乎在所有的进程中存在, 我们在C语言程序里面最常用的malloc()内存分配函数就是从堆里面分配的,堆由系统库管理.

栈一般也叫做堆栈, 每个线程都有属于自己的堆栈,对于单线程的程序来讲,这个VMA堆栈就全都归它使用.另外有一个很特殊的VMA叫做"vdso", vdso 是 virtual dynamic shared object 的缩写, 表示这段mapping实际包含的是一个ELF共享目标文件, vsdo 用于加速某些不需要陷入内核态的系统调用, 详见 vsdo

Linux在装载ELF文件时实现了一种"Hack"的做法,因为Linux的进程虚拟空间管理的VMA的概念并非与"Segment"完全对应,Linux规定一个VMA可以映射到某个文件的一个区域,或者是没有映射到任何文件;

这里的 data segment 的要求是,前面部分映射到文件中,而后面一部分不映射到任何文件,直接为0,也就是说前面的从".tdata"段到".data"段部分要建立从虚拟空间到文件的映射,而".bss"和"_libcfreeres_ptrs"部分不要映射到文件.这样这两个概念就不完全相同了,所以Linux实际上采用了一种取巧的办法来处理 BSS 段, 把最后一个页面的剩余部分清0, 然后调用内核中的do_brk(),把".bss"和"_libcfreeres_ptrs"的剩余部分放到堆段中.

段地址对齐

没看懂

进程栈初始化

进程刚开始启动的时候, 需要知道一些进程运行的环境,最基本的就是系统环境变量和进程的运行参数.很常见的一种做法是操作系统在进程启动前将这些信息提前保存到进程的虚拟空间的栈中(也就是VMA中的Stack VMA)

操作系统需要按照 sysv-abi 的规则将这些信息放置在栈对应的位置, 如下图所示

20240115233154

然后再由 libc 负责将这些信息取出, 赋值给 argc argv envp 等 main 函数参数, 以供程序使用

比如我们在 shell 中执行的指令是 ./main 123 abc, ./main 123 abc 以及其他环境变量 HOME PATH SHELL 等都会保存在栈底, 然后是所有的 envp 指针, 所有的 argv 指针, 以及 argc 的值, 以 \0 分割. stack 部分的信息如下图所示,

20240828161843

其中 argv 和 envp 的指针指向后面的存储字符串的对应的地址

20240828162545

参考

zood