链接、装载与库(2)——装载与内存空间分布

Posted by 皮皮潘 on 05-08,2022

程序执行

从操作系统的角度来看,一个进程最关键的特征是它拥有独立的虚拟地址空间,这使得它有别于其他进程,而一个新程序执行的过程主要划分为三大块:

  1. 创建一个进程:创建一个独立的虚拟地址空间(分配一个页目录就可以甚至不设置页映射关系)并准备对应的进程数据结构
  2. 装载相应的可执行文件:读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系(当操作系统捕获到缺页异常时,它应知道程序当前所需要的页在可执行文件中的哪一个位置,也就是虚拟空间可执行文件的映射关系,其实就是VMA(Virtual Memory Area)的映射关系,也是整个装载过程中最重要的一步)
  3. 执行:将CPU的指令寄存器设置成可执行文件的入口地址,启动运行

上面的步骤执行完以后,其实可执行文件的真正指令和数据都没有被装入内存,操作系统只是建立了虚拟空间与可执行文件之间的映射关系罢了,真正装入内存是在程序运行触发缺页异常的时候

缺页异常

当程序运行时发现在页表虚拟内存没有对应的物理内存地址与其映射时,就会触发缺页异常,这时候操作系统有专门的缺页异常处理例程来处理这种情况,它会查询在之前第二步中建立的数据结构,然后找到空页面所在的VMA并计算出相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中该虚拟页与分配物理页在页表中建立映射关系,然后把控制权再还给进程,进程从刚才缺页异常的位置重新开始执行

当然除了由于事先没有装入内存导致的缺页异常之外,也有可能是在进程所需要的内存超过可用的内存数量然后换页进而导致的缺页异常,对于这种情况,当发生内存交换时,操作系统会记录将对应的物理内存换到了磁盘的什么地方,因此可以通过该记录找到需要换回的内容然后重新载入到其他的物理内存页

Segment和Section

在装载时如果每个Section对应一个VMA那么它们在装载时都需要按照4KB对齐,因此为了节省空间(越少需要对齐的空间则越能节省空间)会将多个相同类型(可读、可写、可执行的组合相同)的Section合并成为一个Segment,并将一个Segment视作一个VMA进行映射也即同一个Segment中的不同Section映射到同一个VMA上,从而大大减少了对齐带来的空间浪费

ELF可执行文件中有一个专门的数据结构叫做程序头表,用来保存Segment的信息,因为ELF目标文件不需要被装载,所以它没有程序头表,而ELF的可执行文件和共享库文件都有

总的来说,Segment和Section是从不同的角度来划分同一个ELF文件,这个在ELF中被称为不同的视图,从Section的角度来看ELF文件就是链接视图,从Segment的角度来看就是执行视图,又由于Segment和Section的中文都译作段,因此当我们在谈ELF装载时,段专门指Segment,而在其他时候,段指的是Section

堆和栈

VMA除了被用来映射可执行文件中的各个Segment以外,操作系统还通过使用VMA来对进程的地址空间进行管理,进程在执行的时候它还需要用到栈、堆等空间,事实上它们在进程的虚拟空间中的表现也是以VMA的形式存在的,很多情况下一个进程中的栈和堆分别都有一个对应的VMA,在Linux下我们可以通过查看/proc/{processId}/map来查看进程的虚拟空间分布,一个进程基本上可以分为如下几种VMA区域:

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

1927_1.png

在具体实现中,用户空间的堆栈,都在task_struct->mm->vm_area里面描述,都是属于进程虚拟地址空间的一个区域,而内核态的栈在tsak_struct->stack里面描述,其底部是thread_info对象,thread_info可以用来快速获取task_struct对象。整个stack区域一般只有一个内存页(可配置),32位机器也就是4KB

所以说,一个进程的内核栈,也是进程私有的,只是在task_struct->stack里面获取,因此每个进程都会对应一个内核栈,从多核上的多个进程会在同一时刻发起系统调用进入内核态也可以看出不可能通过一个全局内核栈实现上述特性否则必然会产生冲突与覆盖问题,而内核态也没有专属进程堆的概念,用kmalloc()分配内存,实际上是Linux内核统一管理的,一般用slab分配器,也就是一个内存缓存池,管理所有可以kmalloc()分配的内存。所以从原理上看,在Linux内核态,kmalloc分配的所有的内存,都是可以被所有运行在Linux内核态的task访问到的,因此可以通过内核的堆来使得不同进程共享同一个结构体,进而实现进程间通信或者同步

另外当进入内核态之后 CR3 用的依然是当前进程的页表,操作系统本身其实没有页表,或者说系统调用本身又没有发生进程切换它只是权限级别切换,那么肯定是直接复用原有的CR3的

Linux内核执行ELF过程简介

最后,我们给出一个Linux运行ELF文件的过程的实例,当我们在Linux系统的bash下输入一个命令执行某个ELF程序时,具体过程如下:

  1. 在用户层面,bash进程会调用fork()系统调用创建一个新的进程
  2. 新的进程调用execve()系统调用装载并执行指定的ELF文件
  3. 原先的bash进程继续返回等待刚才启动的新进程结束
  4. 最后继续等待用户输入命令

一个简单的使用fork()和execlp()实现的minibash如下:

int main() {
    char buf[1024] = { 0 };
    pid_t pid;
    while(1) {
        printf("minibash$");
        scanf("%s", buf);
        pid = fork();
        if (pid == 0) {
            execlp(buf, 0);
        } else if (pid > 0) {
            int status;
            waitpid(pid, &status, 0);
        } else {
            printf("fork error %d\n", pid);
        }
    }
    return 0;
}

在进入execve()系统调用之后,Linux内核就开始进行真正的装载工作,它首先会进行一些参数的检查复制之后,会调用do_execve()方法,do_execve()会首先查找被执行的文件,如果找到文件,则读取文件的前128个字节,并通过对应开头的魔数来判定文件的格式(比如ELF的可执行文件的头4个字节是elf,Shell脚本的第一行往往是#!/bin/sh,#!/usr/bin/python等,这时候前两个字节#和!就构成了魔数),然后调用search_binary_handle()去搜索和匹配合适的可执行文件装载处理过程(比如ELF可执行文件的装载过程叫做log_elf_binary()),这里我们只关心ELF可执行文件的装载,它的主要步骤如下:

  1. 检查ELF可执行文件格式的有效性
  2. 寻找动态链接的.interp段,设置动态链接器路径
  3. 根据ELF可执行文件的程序头表的描述,对ELF文件进行映射,比如代码、数据、只读数据
  4. 初始化ELF进程环境
  5. 将系统调用的返回地址修改成ELF可执行文件的入口点

load_elf_binary()执行完毕,返回至do_execve()再返回至sys_execve()时,由于上面的第5步中已经把系统调用的返回地址改成了被装载的ELF程序的入口地址了,所以当系统调用返回时,CPU会执行RET指令将栈帧上的返回地址也即ELF程序入口地址放入EIP指令寄存器中进而直接跳转到ELF程序的入口地址,于是新的程序开始执行,ELF可执行文件装载完成

参考

  1. 《程序员的自我修养——链接、装载与库》