从内核空间到用户空间

Linux 操作系统加载

PC上电后,处理器跳转到BIOS,开始执行BIOS。BIOS首先进行加电自检,初始化相关硬件,然后加载MBR中的程序到内存0x7c00处并跳转到该地址处,接着由MBR中的程序完成操作系统的加载工作。通常MBR中的程序也被称为Bootloader。这一节,我们以一个具体的Bootloader-GRUB为例,探讨操作系统的加载过程。

grup映像构成

对于仅有512字节大小的MBR,又要留给分区表64字节,在这么小的一个空间,已经很难容纳加载一个现代操作系统的代码。于是GRUB残躯了分阶段的策略,MBR中仅存放GRUP的第一阶段的代码,MBR中的代码复制把GRUP的其余部分载入内存。

GRUP将映像Fenwick三个部分:MBR中的boot.img、嵌入空闲扇区的core.img已经存储在文件系统中的模块。boot.img以及core.img分别以读写磁盘扇区的方式访问,它们不属于任何一个硬盘分区。第三阶段的这些模块时存储在文件系统上的。

MBR映像

boot.img主要供是将core.img中的第一个扇区载入内存,core.img其余部分的加载留给core.img的第一个山区的代码去考虑。

GRUB 核心镜像

core.img包括多个映像和模块。

disakboot.img 占据core.img中的第一个扇区,它就是boot.img加载的core.img的所谓的第一个扇区。diskboot.img用来加载core.img中除diskboot.img外的其余部分。

安装grub

使用grub-install安装grub:

$ grub-install /dev/sda

创建镜像

grub-install 首先调用grub-mkimage创建core.img.

安装镜像

创建完core.img映像后,grub-install将调用grub-setup将core.img安装到硬盘。

grub启动过程

在PC启动时,BIOS会将MBR中的程序加载到内存的0x7c00处,并跳转到哪里开始执行。

  1. boot.img加载diskboot.img.boot.img使用BIOS中断号位0x13的基于扇区的磁盘读写服务加载diskboot.img。GRUB使用从0x70000开始处的以段内存作为BIOS读缓存,所以BIOS首先将diskboot.img读到内存0x70000处,然后boot.img再将其移动到内存0x8000处。条抓到diskboot中的第一条指令处继续执行。
  2. diskboot.img加载core.img。与boot.img类似,diskboot.img使用BIOS中断号位0x13的基于扇区的磁盘读写服务加载core.img。BIOS将lore.img读到缓存0x700000处,然后diskboot.img将其移动到0x8200处,最后跳转到0x8200处开始执行lzma_decompress.img。
  3. core.img自解压。core.img解压完成后,lzm_docempress.img将跳转到解压的core.img处继续执行。
  4. kernel.img将自己复制回0x9000,因为linux内核和initramfs可能被加载到内存从1MB开始的任何地方。并跳转到移动后的位置,继续执行。

加载内核和initramfs

normal模块读取并解析GRUP配置文件grub.cfg,然后根据grub.cfg中的具体命令,加载相应的模块。

  1. 引导协议。Bootloader复制加载内核,显然Bootloader和内核之间需要分享一些数据。因此,内核和引导程序之间需要有个阅读,这个约定称为引导协议。随着新的BIOS标准的出现,Bootloader取代内核中实模式部分的功能。而且Bootloader会将CPU切换为保护模式。
    1. 内核向Bootloader传递信息
    2. Bootloader向内核传递信息。
  2. 加载内核及initramfs。模块Linux初始化时注册了两个命令:linux加载Linux内核;initrd加载Initramfs。
    1. 在32位引导协议下,GRUB只需将内核的保护模式加载到内存即可。
      1. 确定内核希望加载的地址,如果内核支持重定位,则从引导协议中读取的perf_address作为内核假爱的位置。否则,内核加载到位置0x100000。
      2. 位内核映像分配内存。
      3. 记录内核加载的物理地址。
      4. 从setup.bin中读取实模式部分的尺寸。
      5. 将内核镜像文件定位到保护模式开始的地方。
      6. 确定保护模式部分的尺寸。
      7. 加载内核。
    2. 加载initramfs
      1. 确定initramfs加载的位置。
      2. 找一个合适的位置。
      3. 加载initramfs。
      4. 将initramfs信息记录到引导参数中,供内核寻找initramfs时使用。
  3. 将控制器交给内核。在启动内核前,GRUB在传统的地段内存中申请了一块区域,将引导参数放置到传统的实模式占据的位置。最最终,在跳转到内核之前,将记录到寄存器esi中,内核启动后,从机器存期esi记录的整个地址复制引导参数。

解压内核

移动内核映像

  1. 确定源地址;
  2. 确定目的地址;
    1. 内核被编译为可重定位的
    2. 内核不支持重定位
  3. 移动内核映像

解压

重定位

内核初始化

初始化虚拟内存

通过虚拟内存机制,多个进程之间就可以和平共享物理内存。每个进程都有了自己独立的虚拟地址空间,感觉就像自己独占物理内存一样。

对于x86架构来说,虚拟内存向物理内存的转换需要经过两个阶段:

  1. 逻辑地址转换为线形地址。CPU将逻辑地址发送给MMU。逻辑地址分为两部分:16位的段选择子和32位的段内偏移。当把这48位地址传给MMU时,MMU中的分段单元根据16位段选择子,从GDT表中获取对应段,取出段基址,再加上楼及地址中的32位的偏移,就形成了线性地址。
  2. 线形地址转换位物理地址。分段单元将线形地址发送给分页单元,分页单元通过页表,将线性地址转换位物理地址。

通过虚拟内存,同一个虚拟地址可以映射到不同的物理内存。这也是多个进程共享同一个物理内存的理论基础。

为了支持MMU进行地址转换,操作系统需要位MMU准备GDT以及页表:

  1. 创建GDT, IA构建提出了平坦内存模型,所有段的基址均为0,段长位线性地址空间的整个长度,在编译时,链接器通过段内偏移控制各个段的内容不会彼此覆盖。内核定义了内核代码段、内核数据段、用户代码段和用户数据段。链接器通过偏移地址控制内核和用户程序占据的地址空间。在Linux系统上,约定内核占用3GB-4GB的空间,而应用使用0-3GB。
  2. 创建内核页表,操作系统只有一个内核,理论上,进程的页表只映射用户空间就可以了,切入内核空间时,CR3寄存器指向内核页表,但是这个操作代价高,所以每个进程的页表中都包含了相同的内核空间映射部分。内核初始化页表分为两个阶段:准备基本的运行环境;建立完整的页表。内核创建的页目录和页表被称为主内核页目录和页表,它们也作为进程的也目录和页表的模板。每当进程创建也表示,将从竹内和创建的这部分页目录和页表复制目录项和页表项。当内核的内存映射发生变化时,内核将更新主内核页目录和页表,同时,也同步进程的页目录和页表中映射内核的页目录项和页表项。
    1. 页目录和页表的存储位置。
    2. 建立页目录和页表。
      1. 填充页目录项;
      2. 填充页表项。
    3. 启动分页机制。

初始化进程0

POSIX标准ugid,符号POSIX标准的奥做系统采用复制的方式创建进程,一次,内核静态的创建了一个原始进程,被称为进程0,进程0不仅作为一个模板,在没有其他就绪任务时,进程0将投入运行,所以其又称为idle进程。

  1. 创建任务结构,变量init_task所在的位置就是进程0的任务结构。
  2. 进程0的内核栈,进程0不会切换到用户空间,所以无需用户空间的栈,只需为其安排好内核空间的栈即可。
  3. 宏current与进程内核栈。

初始化进程1

进程1是通过复制进程0而来的,复制了进程后,将执行kernel_init。创建进程1后,内核调用函数sechedule让进程1投入运行。

内核采用模块化的方法,将任务分成四类,优先级从高到地分别是停止类、实时类、公平了和空闲类。实时类中记录的是实时任务,一般的任务都归类在公平类中,而停止类和空闲类记录的是两个特殊的任务。

没有其他任务就绪时,CPU将运行空闲类中的任务,该任务将CPU置于停机状态,指导又中断将其唤醒。而停止类中的任务时用于负载均衡或者进行CPU热插拔时使用的任务,其目的是为了停止正在运行的CPU,以进行任务迁移或者拔插CPU。每个CPU分别只有一个停止任务和空闲任务。

实时类和公平类分别有一个就绪队列,维护者可以投入运行的任务。每个就绪队列又自己的排队算法。

这几个类组成了一个链条,其中最高优先级的停止类作为表头。每个CPU有一个就绪队列,通过该队列,可以访问实时队列、公平队列以及停止任务和空闲任务。

进程加载

根据POSIX标准的规定,操作系统创建一个新进程的方式是进程调用操作系统的fork服务,复制当前进程作为一个新的紫禁城,然后紫禁城使用操作系统的服务exec运行新的程序。

内核以及静态创建了一个原始进程,进程1复制这个原始进程,然后加载了用户空间的可执行文件。

用户进程的加载大致上包括如下几个步骤:

  1. 内核从磁盘加载可执行程序,建立进程地址空间;
  2. 如果可执行程序是动态链接的,那么加载动态链接器,并将控制权转交到动态链接器。
  3. 动态链接器重定位自身;
  4. 动态链接器加载动态库到进程地址空间;
  5. 动态链接器重定位动态库、可执行程序,然后跳到可执行程序的入口处继续执行。

加载可执行程序

一个进程的所有指令和数据并不一定全部要用到,内核初始加载可执行程序时,并不将指令和数据真正的加载进内存,而仅仅将指令和数据的“地址”加载进内存,称为映射。

内核首先将磁盘上ELF文件的地址映射进来。

除了代码段和数据段外,进程运行时还需要创建保存局部变量的栈段以及动态分配的内存的队段,这些段不对应任何具体的文件,所以也被称为匿名映射段。对于一个动态链接的程序,还会依赖其他动态库,在进程空间中也需要为这些动态库预留空间。

ELF文件及作为链接过程的输出,也作为装载过程的输入。ELF文件是由若干Section组成的。而为了配合进程的加载,ELF文件中又引入了Segment的概念,每个Segment保护一个或者多个Section。相应于Section有一个Section Header Table,ELF文件中也有一个Program Header Table 描述Segment的信息。

Program Header Table 中有多个不同类型的Segment,但是如何仔细观察,我们会发现,两个类型为LOAD的Segment基本涵盖了整个ELF文件,而一些Section,包括Section Header Table,只是链接时需要,加载时并不需要,所以没有包含到任何Segment中。基本上,这两个类型为LOAD的Segment,在映射到进程的地址空间时,一个映射为代码段,一个映射为数据段:

  • 代码段,具有读和可执行权限,但是除了保存指令的Section外,一些仅具有只读属性的Section,也包含到了这个段中。这些是程序加载和重定位时需要的信息。
  • 数据段,具有读写权限,除了典型保存数据的Section外,一些具有读写权限的Section,也包含到这个段中。

除了这两个LOAD类型的Segment外,ELF规范还规定了几个其他的Segment,它们都是辅助加载的。仔细观察Program Header Table,我们会发现,其他类型的Segment都包括在Load类型的段中。所以,在加载时,内核只需要加载LOAD类型的Segment。

除了映射ELF文件中的段到进程地址空间外,内核还创建了其他几个进程运行时不可少的段

  • 栈段,起初,内核将栈安排在用户空间的最顶端,即栈底在0xc00000000.后来为了安全期间,Linux使用了ASLR,一种针对缓冲区溢出的安全保护技术,在进程的地址空间中,堆、栈、内存映射等段不再分配固定的地址,而是在每次进程启动时,在原理的位置上加上一个随机的偏移,增加攻击者确定这些段的额位置的难度。
  • BSS段,BSS段保存的是未初始化的数据,所以BSS段并不需要从文件中读取数据,BSS也并不需要映射到文件。
  • 堆段,堆段映射的内存是进程运行时动态分配的,所以在建立进程的地址空间时,只需确定堆段的其实位置即可。初始时,堆的其实位置和结束位置都指向BSS段的结束位置。在进程运行时,根据动态申请内存情况动态的调整堆的大小。
  • 内存映射趋于,进程空间中还专门留有一个区域用于内存映射,比如文件映射、共享内存等,动态库据映射在这个趋于。

进程的投入运行

进程最终稿一定要切换到用户空间的.在内核创建进程1时,进程0是当前进程,因此,进程1要回到用户空间,需要经过两个步骤:

  1. 要将进程0赶出CPU。也就是在内核空间,进程1要恢复为当前进程,这是进程1返回用户空间的前提条件。
  2. 进程1从内核空间回到用户空间。

从进程0恢复到进程1需要进程1在内核空间的线程;进程1从内核空间回到用户空间需要进程1的用户空间的线程。

  1. 用户现场的保护,一个进程从用户空间切换到内核空间是如何保护用户现场的。
    1. 从用户栈切换到内核栈。当一个进程正在用户空间运行时,一旦发生中断,那么进程将从用户空间切换到内核空间运行。进程在内核空间运行时,CPU各个寄存器同样将被使用,因此,为了在处理完中断后,程序可以在用户空间的中断处得以继续执行,需要在穿越的一块保护这些寄存器的值,以免被覆盖,即所谓的保护现场。因此,在中断时,CPU做的第一件事就是将栈从用户栈切换到内核栈。
    2. 保存用户空间的现场,切换玩栈后,CPU在进程的内核栈中保存了进程在用户哦那估计执行的线程幸喜。在进程推出内核空间时,中断处理函数最后会调用x86的指令将CPU压入的这几个值恢复到对应的寄存器。
    3. 穿越中断门。
  2. 内核线程保护,当进程在内核空间运行时,在发生进程切换时,一人需要保护切换走的进程的欸小拿出,这是其下次运行的起点。
  3. 伪造现场。其实伪造内核线程只需要伪造三个关键地方:进程恢复运行时的地址;进程的内核栈的指针;准备内核栈的栈底。

按需载入指令和数据

在建立进程的地址空间时,内核仅仅是将地址映射进来,没有加载任何实际智力高和数据到欸村中。在实际需要这些指令和数据时,内核才会通过却也中断处理函数将指令和数据从文件按需加载进内存。

  1. 获取引起却也异常的地址。
  2. 更新页表。在复制紫禁城时,紫禁城也需要复制或者共享父进程的页表,紫禁城都需要创建新的页表。
  3. 从文件载入指令和数据。

加载动态链接器

在现代操作系统中,绝大部分程序都是动态链接的。对于动态链接的程序,处理加载可执行程序外,其依赖的动态库也要加载。对于动态链接的程序和库,编译时并不能确定引用的外部符号的地址,因此在加载后,还要进行符号重定位。

重定位动态库

重定位可执行程序

重定位动态链接器

段relro

results matching ""

    No results matching ""