一、Linux操作系统加载
PC 上电或复位后,处理器跳转到 BIOS ,开始执行 BIOS。BIOS 首先进行加电自检,初始化相关硬件,然后加载 MBR 中的程序到内存 0x7c00 处并跳转到该地址处,接着由 MBR 中的程序完成操作系统的加载工作。通常,MBR 中的程序也被称为 Bootloader 。当然,鉴于现代操作系统的复杂性,Bootloader 已远远不止一个扇区大小。这一节,我们就以一个具体的 Bootloader —— GRUB 为例,探讨操作系统的加载过程。为简单起见,我们只讨论典型的从硬盘加载操作系统的过程,所以后续的讨论全部是针对从硬盘启动的情况。
PC 上硬盘的传统分区方式是 MBR 分区方案。但是 MBR 最大能表示的分区大小为 2TB。因此,随着硬盘容量的不断扩大,为了突破 MBR 分区方式的一些限制,20 世纪 90 年代 Intel 提出了 GPT 分区方案。对于不同的分区方式,加载操作系统的方式还是有些许不同的。也是为了简单起见,我们结合现在依然广泛使用的传统的 MBR 分区方案进行讨论。
1、GRUB映像构成
对于仅有 512 字节大小的 MBR,又要留给分区表 64 字节,在这么小的一个空间,已经很难容纳加载一个现代操作系统的代码。于是 GRUB 采取了分阶段的策略,MBR 中仅存放 GRUB 的第一阶段的代码,MBR 中的代码负责把 GRUB 的其余部分载入内存。
但是 GRUB 分成几段合适呢?要回答这个问题我们还得从 DOS 谈起。
DOS 的系统映像是不能跨柱面存放的,所以在 DOS 时代,磁盘的第一个分区索性并没有紧接在 MBR 的后面,而是直接从下一个柱面的边界开始。而且,按照柱面对齐,对系统的性能有很大好处,这对于现代操作系统同样适用。于是,在 MBR 与第一个分区之间,就出现了一块空闲区域。从那时起,这种分区方式成为了一个约定俗成,基本上所有的分区工具都把这种分区方式保留了下来。如果硬盘是 MBR 分区方案,用分区工具 fdisk 就可以看到这一点。
根据 fdisk 的输出可见,每个磁道划分为 63 个扇区。硬盘的第一个分区起始于第 63 个扇区(从 0 开始计数)。也就是说,对于第 0 个磁道,除了 MBR 占据的一个分区,其余 62 个分区是空闲的。
于是,GRUB 的开发人员就打算把 GRUB “嵌入”到这个空闲区域,这样做的好处就是相对来说比较安全。因为某些文件系统的一些特性或者一些修复文件系统的操作,有可能导致文件系统中的文件所在的扇区发生改变。因此,单纯依靠扇区定位文件是有一定的风险的。而对于 GRUB 来说,在其初始阶段,由于尚未加载文件系统的驱动,因此,它恰恰需要通过 BIOS 以扇区的方式访问 GRUB 的后续的阶段。但是,一旦 GRUB 嵌入到这个不属于任何分区的特殊区域,则将不再受文件系统的影响。当然将 GRUB 嵌入到这个区域也不是必须的,但是因为这个相对安全的原因,GRUB 的开发人员推荐将 GRUB 嵌入到这个区域。
但是这个区域的大小是有限的,通常,一个扇区 512 字节,一个柱面最多包含 63 个扇区。因此,除去 MBR ,这个区域的大小是 62 个扇区,即 31KB。因此,嵌入到这里的 GRUB 的映像最大不能超过 31KB 。为了控制嵌入到这个区域中的映像的尺寸不超过 31KB,GRUB 采用了模块化的设计方案。
GRUB 在嵌入的映像中包含硬件及文件系统的驱动,因此,一旦嵌入的映像载入内存,GRUB 即可访问文件系统。其他模块完全可以存储在文件系统上,通过文件系统的接口访问这些模块,避开了因为如修复文件系统而引起文件所在扇区的变化而带来的风险。另外也可以很好地控制嵌入到空闲扇区的映像的尺寸。
由上述内容可知,GRUB 将映像分为三个部分:MBR 中的 boot.img、嵌入空闲扇区的 core.img 以及存储在文件系统中的模块。这三个部分也对应着 GRUB 执行的三个阶段。在 MBR 分区模式下,以嵌入方式安装的 GRUB 的各个部分在硬盘上的分布如图5-1所示。
core.img 包括多个映像和模块,以从硬盘启动为例,core.img 包含的内容如图 5-2 所示。
2、安装GRUB
通常,在安装操作系统的最后,操作系统安装程序将会为用户安装 GRUB 。当然,有时我们也会手动安装 GRUB 。但是都是通过 GRUB 提供的工具,执行的命令如下:
grub-install /dev/sda
事实上,在这个安装命令的背后,GRUB 的安装过程分为两个阶段:第一阶段是创建 core.img ,GRUB 为此提供的工具是 grub-mkimage ;第二阶段是安装 boot.img 及 core.img 到硬盘,GRUB 提供的工具是 grub-setup 。为了方便,GRUB 将这两个过程封装到脚本 grub-install 中。
二、解压内核
根据构建内核时的分析,我们知道,内核的保护模式部分包括非压缩部分以及压缩部分,压缩部分才是内核正常运转时的部分,而非压缩部分只是一个过客,其主要作用是解压内核的压缩部分,解压完成后,非压缩部分也将退出历史舞台。
内核的解压缩过程几经演进,现在的解压过程不再是首先将内核解压到另外的位置,然后再合并到最终的目的地址。而是采用了所谓的就地解压(in-place decompression)方法,内核解压时并不需要解压到另外的位置,从而避免覆盖其他部分的数据。
以不可重定位的内核的解压过程为例,其解压过程如图 5-7 所示。
- 移动内核映像
- 解压
- 重定位
三、内核初始化
虽然操作系统的功能包括进程管理、内存管理、设备管理等,但是操作系统的终极目标是创造一个环境,承载进程。但是由于进程运行时,可能需要和各种外设打交道,因此,操作系统初始化时,也会将这些外设等子系统进行初始化,这也导致内核初始化过程异常复杂。虽然这些过程很重要,但是忽略它们并不妨碍理解操作系统的本质。本节我们并不关心这些子系统的初始化,比如 USB 系统是如何初始化的,我们只围绕进程来讨论内核相关部分的初始化。
1、初始化虚拟内存
2、初始化进程0
POSIX 标准规定,符合 POSIX 标准的操作系统采用复制的方式创建进程,但是内核总得想办法创建第一个原始的进程,否则其他进程复制谁呢?因此,内核静态的创建了一个原始进程,因为这个进程是内核的第一个进程,Linux 为其分配的进程号为 0,所以也被称为进程 0。进程 0 不仅作为一个模板,在没有其他就绪任务时进程 0 将投入运行,所以其又称为 idle 进程。下面我们就看看内核是如何为进程 0 分配任务结构和内核栈这两个关键数据结构的。
// linux-3.7.4/init/init_task.c: struct task_struct init_task = INIT_TASK(init_task); // linux-3.7.4/include/linux/init_task.h: #define INIT_TASK(task) { \ .state = 0, \ .stack = &init_thread info, \ .usage = ATOMIC_INIT(2) , \ ... }
3、创建进程1
// linux-3.7.4/init/main.c: static noinline void __init_refok rest_init (void) { ... kernel_thread(kernel_init, NULL, CLONE_FS | CLONE_SIGH AND); ... } // linux-3.7.4/kernel/fork.c pid_t kernel_thread(int (*fn) (void *), void *arg, ...) { return do fork (flags| CLONE VM CLONE UNTRACED, (unsigned long)fn, NULL, (unsigned long) arg, NULL, NULL); }
四、进程加载
根据 POSIX 标准的规定,操作系统创建一个新进程的方式是进程调用操作系统的 fork 服务,复制当前进程作为一个新的子进程,然后子进程使用操作系统的服务 exec 运行新的程序。前面,我们看到内核已经静态地创建了一个原始进程,进程 1 复制这个原始进程,然后加载了用户空间的可执行文件。这一节,我们就来探讨用户进程的加载过程,大致上整个加载过程包括如下几个步骤:
- 内核从磁盘加载可执行程序,建立进程地址空间;
- 如果可执行程序是动态链接的,那么加载动态链接器,并将控制权转交到动态链接器;
- 动态链接器重定位自身;
- 动态链接器加载动态库到进程地址空间;
- 动态链接器重定位动态库、可执行程序,然后跳转到可执行程序的入口处继续执行。
1、加载可执行程序
一个进程的所有指令和数据并不一定全部要用到,比如某些处理错误的代码。某些错误可能根本不会发生,如果也将这些错误代码加载进内存,就是白白占据内存资源。而且对于某些特别大的程序,如果启动时全部加载进内存,也会使启动时间延长,让用户难以忍受。因此,内核初始加载可执行程序(包括动态库)时,并不将指令和数据真正的加载进内存,而仅仅将指令和数据的 “地址” 加载进内存,通常我们也将这个过程形象地称为映射。
对于一个程序来说,虽然其可以寻址的空间是整个地址空间,但是这只是个范围而已,就比如某个楼层的房间编号可能是 4 位的,但是并不意味着这个楼层 0000~9999 号房间都可用。对于某个进程而言,一般也仅仅使用了地址空间的一部分。那么一个进程如何知道自己使用了哪些虚拟地址呢?这个问题就转化为是谁为进程分配的运行时地址呢?没错,是链接器分配的,那么当然从 ELF 程序中获取了。所以内核首先将磁盘上 ELF 文件的地址映射进来。
除了代码段和数据段外,进程运行时还需要创建保存局部变量的栈段(Stack Segment)以及动态分配的内存的堆段(Heap Segment),这些段不对应任何具体的文件,所以也被称为匿名映射段(anonymous map)。对于一个动态链接的程序,还会依赖其他动态库,在进程空间中也需要为这些动态库预留空间。
通过上述的讨论可见,进程的地址空间并不是铁板一块,而是根据不同的功能、权限划分为不同的段。某些地址根本没有对应任何有意义的指令或者数据,所以从程序实现的角度看,内核并没有设计一个数据结构来代表整个地址空间,而是抽象了一个结构体 vm_area_struct 。进程空间中每个段对应一个 vm_area_struct 的对象(或者叫实例),这些对象组成了 “有效” 的进程地址空间。进程运行时,首先需要将这个有效地址空间建立起来。
内核支持多种不同的文件格式,每种不同格式的加载都实现为一个模块。比如,加载 ELF 格式的模块是 binfmt_elf ,加载脚本的模块是 binfmt_script ,它们都在内核的 fs 目录下。对于每个要加载的文件,内核都读入其文件头部的一部分信息,然后依次调用这些模块提供的函数 load_binary 根据文件头的信息判断其是否可以加载。前面,initramfs 中的 init 程序是使用 shell 脚本写的,显然,它是由内核中负责加载脚本的模块 binfmt_script 加载。模块 binfmt_script 中的函数指针 load_binary 指向的具体函数是 load_script,代码如下:
// fs/binfmt_script.c static int load_script(struct linux_binprm *bprm,struct pt_regs *regs) { ... if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!') || (bprm->recursion_depth > BINPRM_MAX_RECURSION)) return -ENOEXEC; ... for (cp = bprm->buf+2; (*cp == ' ') || (*cp == '\t'); cp++); ... file = open_exec(interp); ... bprm->file = file; ... return search_binary_handler(bprm,regs); }
linux_binprm 是内核设计的一个在加载程序时,临时用来保存一些信息的结构体。其中,buf 中保存的就是内核读入的要加载程序的头部。函数 load_script 首先判断 buf,也就是文件的前两个字符是否是 “#!” 。这就是脚本必须以 “#!” 开头的原因。
如果要加载的程序是一个脚本,则 load_script 从字符 “#!” 后的字符串中解析出解释程序的名字,然后重新组织 bprm,以解释程序为目标再次调用函数 search_binary_handler,开始寻找加载解释程序的加载器。而脚本文件的名字将被当作解释程序的参数压入栈中。
对于 initramfs 中的 init 程序,其是使用 shell 脚本编写的,所以加载 init 的过程转变为加载解释程序 “/bin/bash” 的过程,而 init 脚本则作为 bash 程序的一个参数。
可见,脚本的加载,归根结底还是 ELF 可执行程序的加载。
ELF 文件“一人分饰二角”,既作为链接过程的输出,也作为装载过程的输入。在第 2 章中,我们从链接的角度讨论了 ELF 文件格式,当时我们看到 ELF 文件是由若干 Section 组成的。而为了配合进程的加载,ELF 文件中又引入了 Segment 的概念,每个 Segment 包含一个或者多个 Section 。相应于 Section 有一个 Section Header Table,ELF 文件中也有一个 Program Header Table 描述 Segment 的信息,如图5-21所示。
Program Header Table 中有多个不同类型的 Segment ,但是如果仔细观察图 5-21,我们会发现,两个类型为 LOAD 的 Segment 基本涵盖了整个 ELF 文件,而一些 Section,如 “.comment”、“.symtab” 等,包括 Section Header Table,只是链接时需要,加载时并不需要,所以没有包含到任何 Segment 中。基本上,这两个类型为 LOAD 的 Segment ,在映射到进程地址空间时,一个映射为代码段,一个映射为数据段:
代码段(code segment)具有读和可执行权限,但是除了保存指令的 Section 外,一些仅具有只读属性的 Section ,比如记录解释器名字的 “.interp” ,动态符号表 “.dynsym” ,以及重定位表 “.rel.dyn”、“.rel.plt” ,甚至是 ELF Header、Program Header Table,也包含到了这个段中。这些是程序加载和重定位时需要的信息,随着讨论的深入,我们慢慢就会理解它们的作用。
数据段(data segment)具有读写权限,除了典型保存数据的 Section 外,一些具有读写权限的 Section,如 GOT 表,也包含到这个段中。
除了这两个 LOAD 类型的 Segment 外,ELF 规范还规定了几个其他的 Segment ,它们都是辅助加载的。仔细观察 Program Header Table,我们会发现,其他类型的 Segment 都包括在 LOAD 类型的段中。所以,在加载时,内核只需要加载 LOAD 类型的 Segment 。
2、进程的投入运行
1. 用户现场的保护
(1)从用户栈切换到内核栈
当一个进程正在用户空间运行时,一旦发生中断,那么进程将从用户空间切换到内核空间运行。进程在内核空间运行时,CPU 各个寄存器同样将被使用,因此,为了在处理完中断后,程序可以在用户空间的中断处得以继续执行,需要在穿越的一刻保护这些寄存器的值,以免被覆盖,即所谓的保护现场。Linux 使用进程的内核栈保存进程的用户现场。因此,在中断时,CPU 做的第一件事就是将栈从用户栈切换到内核栈。
(2)保存用户空间的现场
切换完栈后,CPU 在进程的内核栈中保存了进程在用户空间执行时的现场信息,包括 eflags、cs、eip、ss 和 esp,如图 5-24 所示。
(3)穿越中断门
接下来,进程就将进行最后的穿越了,当然,内核在初始化时就已经为 CPU 初始化了中断相关的部分。
2. 内核现场的保护
当进程在内核空间运行时,在发生进程切换时,依然需要保护切换走的进程的现场,这是其下次运行的起点。那么进程的内核现场保存在哪里合适呢?前面我们看到进程的用户现场保存在进程的内核栈,那么进程的内核现场当然也可以保存在进程的内核栈。
但是,当调度函数准备切换到下一个进程时,下一个进程的内核栈的栈指针从何而来?在前面讨论进程从用户空间切换到内核空间时,我们看到,CPU 从进程的 TSS 段中获取内核栈的栈指针。那么当在内核空间发生切换时,调度函数如何找到准备切入进程的内核栈的栈指针?
除了进程的内核栈外,进程在内核中始终存在另外一个数据结构 —— 进程的户口,即任务结构。因此,进程的内核栈的栈指针可以保存在进程的任务结构中。在任务结构中,特意抽象了一个结构体 thread_struct 来保存进程的内核栈的栈指针、返回地址等关键信息。
调度函数使用宏 switch_to 切换进程,我们来仔细观察以下这段代码,为了看起来更清晰,删除了代码中的注释:
在每次进程切换时,调度函数将准备切出的进程的寄存器 esp 中的值保存在其任务结构中,见第 5 行代码。然后从下一个投入运行的进程的任务结构中恢复 esp,见第 6 行代码。除了栈指针外,程序下一次恢复运行时的地址也有一点点复杂,不仅仅是简单的保存 eip 中的值,有一些复杂情况需要考虑,比如稍后我们会看到对于新创建的进程,其恢复运行的地址的设置。所以调度函数也将 eip 保存到了任务结构中,第 7 行代码就是保存被切出进程下次恢复时的运行地址。第 8 行代码和第 10 行的 jmp ,以及函数 __switch_to 最后的 ret 指令联手将投入运行的进程的地址,即 next->thread.ip,恢复到寄存器 eip 中。
除了 eip、esp 外,宏 switch_to 将其他寄存器如 eflags、ebp 等保存到了进程内核栈中。
每次中断时,CPU 会从 TSS 段中取出当前进程的内核栈的栈指针,因此,当发生任务切换时,TSS 段中的 esp0 的值也要更新为下一个投入运行任务的内核栈的栈指针。
3、按需载入指令和数据
在建立进程的地址空间时,我们看到,内核仅仅是将地址映射进来,没有加载任何实际指令和数据到内存中。这主要还是出于效率的考虑,一个进程的所有指令和数据并不一定全部要用到,比如某些处理错误的代码。某些错误可能根本不会发生,如果也将这些错误代码加载进内存,就是白白占据内存资源。而且特别对于某些大型程序,如果启动时全部加载进内存,也会使启动时间延长,让用户难以忍受。所以,在实际需要这些指令和数据时,内核才会通过缺页中断处理函数将指令和数据从文件按需加载进内存。这一节,我们就来具体讨论这一过程。
获取引起缺页异常的地址
IA32 架构的缺页中断的处理函数 do_page_fault 调用函数 __do_page_fault 处理缺页中断。
更新页表
在复制子进程时,子进程也需要复制或者共享父进程的页表。如果没有页表,子进程寸步难行,指令或者数据的地址根本没有办法映射到物理地址,更不用提从物理内存读取指令了。当子进程替换(exec)为一个新的程序时,无论子进程是共享或者复制了父进程的页表,子进程都需要创建新的页表。
从文件载入指令和数据
页表准备就绪后,handle_mm_fault 最后准备载入指令和数据了。
深度探索Linux操作系统 —— 从内核空间到用户空间2: https://developer.aliyun.com/article/1598086