本文前置内容(必看):TSS/LDT/GATE中断详解进程的虚拟内存布局、《装载、链接与库》
本节对应代码讲解:实现用户进程-代码详解

概述

操作系统有三大核心功能:内存管理、进程管理、文件管理 。截至目前,我们已经完成了内存管理和进程管理的部分内容,对于内存管理,咋们还差内存回收机制;对于进程管理,由于线程是进程的基础,之前咋们实现了线程,所以进程也就完成了一半;文件管理将在不久后实现文件系统后再进行。

任务切换的原生方式

TSS/LDT/GATE 一文中,我们简单了解过 TSS 与 LDT 的作用,明白了 TSS 和 LDT 只是理想中的任务管理和切换的工具: Intel 建议用 TSS 来保存并恢复任务的状态,用 LDT 来保存任务的实体资源 。而考虑到效率问题,现代操作系统并未(完全)使用 TSS 和 LDT 来进行任务切换。至于为什么效率低下,看看其任务切换的具体过程便能体会到:
CPU 原生支持 的任务切换方式有两种:1)中断 + 任务门;2)call / jmp + 任务门;下面分别介绍这两种方式。

中断+任务门
既然是通过中断调用,那么调用方式只能通过中断信号或 int 指令进行,此时任务门也当然是在 IDT 中注册。
调用过程如下:

  1. 时钟中断发生,处理器自动从该任务门描述符中取出新任务的 TSS 选择子。

    之前咋们的时钟中断的向量号对应的是中断门描述符,对应时钟中断处理程序(schedule),而现在我们要使用任务门来切换任务,所以时钟中断向量号就应该指向任务门描述符:
    任务门描述符
    这里笔者有个问题,时钟中断号为 0x20,如果 0x20 指向任务门描述符,那岂不是每次发生时钟中断时,都会切换到同一个任务?那还如何实现任务调度呢?由于我们的操作系统不会使用任务门来进行任务切换(Linux也不会),所以这里就不深究了,直到答案的读者还请麻烦在评论区指点一二。

  2. 用 TSS 选择子在 GDT 中索引 TSS 描述符。

  3. 判断该 TSS 描述符的 P 位是否为 1,为 0 则表示对应的 TSS 不在内存中,这将引发异常。

  4. 从 TR 寄存器中获取旧任务(当前任务)的 TSS 位置,将当前寄存器状态保存到该 TSS 中。

  5. 将新任务 TSS 中的值加载进相应寄存器。

  6. 将新任务的 TSS 段选择子加载进 TR 寄存器,这由 CPU 自动完成。

  7. 将当前任务的 B 位置 1,原因参见TSS/LDT/GATE

  8. 将新任务标志寄存器的 NT 位置 1,原因参见中断详解

  9. 将旧任务的 TSS 选择子写入到新任务 TSS 的 上一个任务的TSS指针 字段中。

  10. 开始执行新任务。

返回过程如下:

  1. 调用 iret ,检查 NT 位,如果为 1,则应该返回旧任务而非中断返回。
  2. 将当前任务的 NT 位置 0 。
  3. 将当前任务的 TSS 描述符中的 B 位置 0 。
  4. 将寄存器现场保存到当前 TSS 中。
  5. 获取当前 TSS 中 上一任务的TSS指针 字段,将选择子加载进 TR 。
  6. 根据 TR 指向的 TSS 恢复寄存器现场。

call、jmp切换任务
任务门除了可以在 IDT 中注册,还能在 GDT 和 LDT 中注册,当在后两者中注册时,就可以通过 call、jmp 指令来切换任务。call 和 jmp 有所不同,前者有去有回,所以通过 call 调用的新任务可以认为是旧任务的分支,本质上它们算是同一个任务 ;而 jmp 则是有去无回,新旧任务之间没有关系。此二者的区别体现在是否将旧任务的 B 位置 0 ,先以指令 call 0x0018:0x1234 来看 call 的任务调用过程:

  1. 0x0018 表示在 GDT 中索引第 3 号描述符,即任务门描述符。不同于普通段描述符,任务门描述符中记录的是 TSS 选择子,所以处理器自动忽略 0x1234
  2. 检查 P 位,为 0 则表示该描述符不在内存中,抛出异常。
  3. 检测 S 和 TYPE 位,判断描述符类型,如果是任务门描述符,则检查 B 位,若为 1 则抛出异常。
  4. 特权级检查,数值上 CPL 和 TSS 选择子中的 RPL 都要小于或等于 TSS 描述符的 DPL,关于特权级检查与 RPL、CPL、DPL 的区别,详见特权级剖析
  5. 将当前任务的现场保存到 TR 寄存器所指向的 TSS 中。
  6. 将新任务的 TSS 选择子加载到 TR 寄存器中。
  7. 将新任务 TSS 中的寄存器载入到相应寄存器中,并进行特权级检查。
  8. 将新任务的 NT 位置 1,表示为任务嵌套,以便 iret 时从新任务返回到旧任务,而非从中断返回。
  9. 将旧任务的 TSS 选择子写入到新任务 TSS 中 上一任务的TSS指针 字段中,以便能够返回。
  10. 将新任务的 B 位置 1 以表示当前任务忙,但旧任务的 B 位仍然为 1,不会置零
  11. 切换完成,新任务开始。

任务返回则同上文中断调用相同。

jmp 的任务调度过程和 call 几乎相同,只是第 10 步 B 位置零有所不同:由于 jmp 有去无回,所以新旧任务不构成链式关系,因此会将旧任务的 B 位置 0
从以上过程便能看出,CPU 原生的任务调度方式很是繁杂,这降低了任务切换的效率和灵活性,因此现代操作系统都没有采用这种方式。

任务切换的现代方式

虽然咋们没有用 Intel 建议的方式来进行任务调度,但这也不是说 TSS 就一无是处了(LDT 是真的完全成孤儿了),Linux 在以下两个地方还是会用到 TSS:

  • I/O 特权级控制
    我们一直认为用户进程无法直接访问硬件,必须通过操作系统才行;但是,为了在某些场景下快速反应,TSS 中的 I/O 位图为用户程序直接控制硬件提供了途径。

    提示:我们的操作系统并不会使用 IO 位图。

  • 将进程的内核栈记录在 TSS 中的 SS0 和 ESP0 位置处
    我们通过时钟中断来进行任务调度,在中断详解一文中提到,当发生中断并引用中断门描述符时,会检查是否发生特权级转移,如果特权级改变,则会自动转移到新栈,这个新栈就从 TSS 中获取 。换句话说,一个任务分为用户部分和内核部分,从用户转移到内核时,CPU 就会 自动 切换到内核自己的栈。

同时注意,Linux 只使用一个 TSS ,任务切换时并不会随之切换 TSS,而是只修改 TSS 中的 ESP0 ,这样也会大大提高任务切换的效率。后文解析代码时,读者朋友就能清楚地看到这一过程。

进程的实现方式

Windows 对线程和进程的实现如同教科书一般标准,不仅在概念上对线程和进程有明确的区分,在 API 上也是如此:使用 CreateProgress 和 CreateThread 来分别创建进程和线程。而在 Linux 下就不存在明显的线程概念,其将所有的执行实体都称为任务(task),并由 task_struct 结构体负责管理任务(这点对于理解进程和线程的关系至关重要),这在线程基础中有代码说明每个任务都类似于单线程,具有内存空间、执行实体(寄存器)、文件资源等,但不同的任务可以选择共享同一虚拟内存空间,因此,共享了同一个内存空间的任务集合构成了一个进程

我们的操作系统是仿 Linux 系统,所以咋们实现用户进程就能够以线程为基础,具体方式如下:
先回顾线程创建的大概流程:

  1. 调用 thread_start() 来创建线程。
  2. thread_start() 中,调用 get_kernel_page() 向内核申请一页物理内存用来存放 PCB 。
  3. 接着,调用 init_thread() 来初始化线程的相关信息(task_struct),包括任务状态、优先级、内核栈指针、魔数等。
  4. 然后,调用 thread_create() ,将线程函数及其参数写入该线程的内核栈中。
  5. 最终,切换到线程栈,调用线程函数,任务开始。

要以线程为基础实现进程,就只需要将执行普通任务的线程函数替换成创建进程的新函数(即start_progress)即可 。那么,具体创建进程的流程是怎么样的呢?见下图:

以上函数具体的实现待会再说,先来看看程序是如何进入到用户进程的。我们已经知道如下几点:

  1. 用户进程运行在 3 特权级(ring3),而内核则运行在 ring0;
  2. 特权级剖析一文中我们了解到,除了返回指令(retf, iret/iretd),任何时候都不允许将控制从高特权级转移到低特权级上。
  3. 一直以来我们都在内核中运行,即执行流一直停留在 ring0 。

那么现在,要在内核中,即 ring0 下创建用户进程,并进入到 ring3 用户态,该怎么做呢?显然,只能通过中断返回,即 iret 进入用户态。所以我们采用的具体办法是:利用之前的方式创建线程,将线程里运行的函数设置为 start_progress() ;然后在 start_progress 中初始化该线程的中断栈(也就是将用户进程的寄存器现场提前写入中断栈);最后 iret 中断返回,即可 ring0 -> ring3,进入用户态

其实这种方式可以说是瞒天过海,妥妥地欺骗了 CPU。我们用 iret 指令使 CPU 以为咋们处于中断处理环境中,于是便假装从中断返回,顺利通过特权级检查并进入用户态。

用户进程的虚拟内存分布

未命名绘图34
上图是经典的 Linux 用户进程内存布局(内核2.4版本) ,下面依次对各个部分作简单阐述:

  • 3GB~4GB 虚拟内存映射为内核空间,由所有进程共享。

  • 用户内存的顶部是用户栈。一般而言,用户栈的最大空间为 8MB,详见Linux进程栈空间大小 - Tiehichi’s Blog。另外,用户栈的最高处会用来保存命令行参数和环境变量,这些数据是由 C 运行库压入的,后续从文件系统加载进程时会再提及此处。

  • 用户栈下面是共享库的内存映射区域。共享库就是动态链接库,一个共享对象(即.so文件)由所有用户进程共享。举个例子,A 进程用到了 math 库,B进程也用到了 math 库,则 math 库会被加载到物理内存中,进而被映射到各个进程的虚拟内存空间中,由此实现共享,大大节省了内存,这便是动态链接库相对于静态链接库的优势。

  • 接着是运行时堆,用于满足程序运行时的动态内存需求。

  • .bss 用于保存未初始化的数据,如未初始化的静态变量和全局变量。

  • .data 段用来保存已初始化且可读写的数据。实际上还有 .rodata 用来存放只读数据,此段并未体现在图中。

  • .text 则是代码区。IA-32 体系结构中,进程空间的代码段都从 0x08048000 开始,这与最低可用地址 0x00000000 有 128M 的间距。

    关于 0x08048000 这个值有许多解释,Linker And Loader一书给出的解释如下:
    在 386 系统上,代码的起始虚拟地址是 0x08048000 ,这允许在代码下方有一个相当大的堆栈;同时保持在地址0x08000000上方,允许大多数程序使用单个二级页表(回想一下,在 386 上,每个页目录项映射 0x00400000 大小的空间)。其他原因可参见0x08048000由来

文章作者: 极简
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 后端技术分享
自制操作系统
喜欢就支持一下吧