我们知道每个程序被运行起来以后,它将拥有自己独立的虚拟地址空间(Virtual Address Space),

装载方式

程序执行时,需要把指令和数据装入内存。很多情况下,程序所需的内存数量大于物理内存的数量,一种方法是添加物理内存。另一种方式是利用程序局部性原理,采用动态装入。主要分为两类:覆盖装入,页映射。

从操作系统角度看可执行文件的装载

进程建立

从操作系统的角度来看,一个进程最关键的特征是它拥有独立的虚拟地址空间,这使得它有别于其他进程。装载可执行文件并执行,主要分为三步~。

  1. 创建一个独立的虚拟地址空间。
  2. 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。
  3. 将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。

首先是创建虚拟地址空间。回忆第1章的页映射机制,我们知道一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,那么创建一个虚拟空间实际上并不是创建空间而是创建映射函数所需要的相应的数据结构,在i386 的Linux下,创建虚拟地址空间实际上只是分配一个页目录(Page Directory)就可以了(mm_struct 吗?),甚至不设置页映射关系,这些映射关系等到后面程序发生页错误的时候再进行设置。

读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。上面那一步的页映射关系函数是虚拟空间到物理内存的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。我们知道,当程序执行发生页错误时,操作系统将从物理内存中分配一个物理页,然后将该“缺页”从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才得以正常运行。但是很明显的一点是,当操作系统捕获到缺页错误时,它应知道程序当前所需要的页在可执行文件中的哪一个位置。这就是虚拟空间与可执行文件之间的映射关系。从某种角度来看,这一步是整个装载过程中最重要的一步,也是传统意义上“装载”的过程。

将CPU指令寄存器设置成可执行文件入口,启动运行。第三步其实也是最简单的一步,操作系统通过设置CPU的指令寄存器将控制权转交给进程,由此进程开始执行。这一步看似简单,实际上在操作系统层面上比较复杂,它涉及内核堆栈和用户堆栈的切换、CPU运行权限的切换。不过从进程的角度看这一步可以简单地认为操作系统执行了一条跳转指令,直接跳转到可执行文件的入口地址。还记得ELF文件头中保存有入口地址吗?没错,就是这个地址。

页错误

太完美了:

进程虚拟空间分布

当我们站在操作系统装载可执行文件的角度看问题时,可以发现它实际上并不关心可执行文件各个段所包含的实际内容,操作系统只关心一些跟装载相关的问题,最主要的是段的权限(可读、可写、可执行)。ELF文件中,段的权限往往只有为数不多的几种组合,基本上是三种:以代码段为代表的权限为可读可执行的段。以数据段和BSS段为代表的权限为可读可写的段。以只读数据段为代表的权限为只读的段。那么我们可以找到一个很简单的方案就是:对于相同权限的段,把它们合并到一起当作一个段进行映射。

到这里,也可以回答在C/C++中的一些问题了

  • 字符串可以写吗?肯定是不能的,字符串会被编译器放到.rodata段,被链接器放到一个属性是只读的段中,操作系统也会把该段加载进内存后将该段内存设置为只读属性,所以是没法更改的。
  • 全局变量会被初始化为0,而局部变量编译器不保证值为多少,为什么?这是因为编译器会把全局为初始化变量放到.bss段,初始化为0的变量也在这个段,当加载到内存时,会把这些.bss段的内容都置为0。

内存中每个 vm_area_struct 结构体对应一个内存区域(VMA),

所有相同属性的“Section”被归类到一个“Segment”,并且映射到同一个VMA。每个 VMA 不一定只包含一个页哦

program header 也有相应的结构体:

堆和栈在虚拟空间中的表示也是以 VMA 的形式存在的。

关于虚拟地址空间详细内容可以查看下文:深入理解 Linux 虚拟内存管理

让我们小结关于进程虚拟地址空间的概念:

操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间;基本原则是将相同权限属性的、有相同映像文件的映射成一个VMA;一个进程基本上可以分为如下几种VMA区域:

  1. 代码VMA,权限只读、可执行;有映像文件。
  2. 数据VMA,权限可读写、可执行;有映像文件。
  3. 堆VMA,权限可读写、可执行;无映像文件,匿名,可向上扩展。
  4. 栈VMA,权限可读写、不可执行;无映像文件,匿名,可向下扩展。

减少碎片

个人理解:主要就是更改了段的起始虚拟地址,可以让两个虚拟页同时映射到同一个物理页,当然,映射区域不能有交集。这样既可以提高物理页的利用率,又可以保证对每个虚拟页的权限控制(可读可写可执行),缺点就是起始虚拟地址变了,可能在链接时就需要更改虚拟地址。

对齐部分详见 程序员的自我修养 p170页

进程栈初始化

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

我们假设系统中有两个环境变量: HOME=/home/user,PATH=/usr/bin
比如我们运行该程序的命令行是:$ prog 123
并且我们假设堆栈段底部地址为0xBF802000,那么进程初始化后的堆栈就如图6-12所示。

内核装载 ELF 文件过程

minibash:

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
#include "stdio.h"
#include "sys/types.h"
#include "unistd.h"

int main(void){
char buf[100];
pid_t pid;
char str[] = "ttjbash\n";
while(1){
printf("ttjbash$:");
scanf("%s", buf);
if(buf[0] == 'q')
break;
pid = fork();
if(pid == 0){
if(execlp(buf, str, 0) < 0){
printf("exec error\n");
break;
}
}
else if(pid > 0){
int status;
waitpid(pid, &status, 0);
}
else{
printf("fork error %d\n", pid);
break;
}
}
return 0;
}

具体装载过程:execve 调用 sys_execve , 它进行一些参数检查复制后,调用 do_execve()。do_execve() 会首先查找被执行的文件,如果找到文件,则读取文件的前128个字节。当 do_execve() 读取了这128个字节的文件头部之后,然后调用search_binary_handle() 去搜索和匹配合适的可执行文件装载处理过程。load_elf_binary 用于 装载 elf。它的主要步骤包括:

  1. 检查ELF可执行文件格式的有效性,比如魔数、程序头表中段(Segment)的数量。
  2. 寻找动态链接的“.interp”段,设置动态链接器路径(与动态链接有关,具体请参考第9章)。
  3. 根据ELF可执行文件的程序头表的描述,对ELF文件进行映射,比如代码、数据、只读数据。
  4. 初始化ELF进程环境,比如进程启动时EDX寄存器的地址应该是DT_FINI的地址(参照动态链接)。
  5. 将系统调用的返回地址修改成ELF可执行文件的入口点,这个入口点取决于程序的链接方式,对于静态链接的ELF可执行文件,这个程序入口就是ELF文件的文件头中 e_entry 所指的地址;对于动态链接的ELF可执行文件,程序入口点是动态链接器。以便运行时可以加载共享目标文件。

当 load_elf_binary() 执行完毕,返回至 do_execve() 再返回至sys_execve() 时,上面的第5步中已经把系统调用的返回地址改成了被装载的ELF程序的入口地址了。所以当 sys_execve() 系统调用从内核态返回到用户态时,EIP寄存器直接跳转到了ELF程序的入口地址,于是新的程序开始执行,ELF可执行文件装载完成。