Menci's Blog
念念不忘,必有回响
在 x86 中实现用户态与系统调用
  1. 1. 问题
    1. 1.1. 进程的生命周期
    2. 1.2. 用户栈与内核栈
    3. 1.3. 系统调用
  2. 2. x86 实现
    1. 2.1. 特权级与描述符表
    2. 2.2. 中断与 TSS
    3. 2.3. 系统调用
    4. 2.4. 页权限

在现代操作系统中,应用程序的代码与内核代码的运行环境是隔离的,这被称为用户态与内核态。一般来说,用户态应用程序只能在进程内执行算法逻辑,无法直接访问内核态的数据,也无法执行操作硬件所需的特权指令,而是需要通过系统调用来与内核态交互,来执行各种对系统或硬件的操作。这样的设计保证操作系统可以对应用程序的行为有完全的控制,保障了系统的安全性与稳定性。

问题

进程的生命周期

在开始之前,我们先梳理一下,在不考虑特权级切换时,每个用户态进程的生命周期:

  • 在被创建时,为其分配虚拟地址空间(页目录与页表),为其堆/栈内存,并将参数写入;
  • 在第一次被调度时,执行其主函数;
  • 若进程运行时发生中断(或异常,下略),由 CPU 跳转回内核,内核保存状态并处理中断;
    • 如果中断中没有发生重调度,则完成中断处理并返回到用户态;
    • 如果中断中发生了重调度(常发生在时钟中断),则保存状态并进行上下文切换,在下次切换回该进程时从中断返回到用户代码;
  • 进程运行时,调用内核来完成各种任务,并从内核返回结果;
    • 同样地,进程在运行完成后,调用内核来结束自身。

所以,从内核态进入用户态的情况有:首次调度进程时处理中断完成后返回时系统调用返回时
相对地,从用户态进入内核态的情况有:发生中断时进行系统调用时

用户栈与内核栈

在 x86 平台的通常约定中,我们使用 SP 来保存指向栈顶的指针,在入栈/出栈时修改 SP 并写入/读取其所指向的内存。使用栈的代码需要自行保证栈指针的正确维护,当 SP 指向无效内存时,对栈的操作将产生异常。

在引入特权级之前,每个进程有自己的一块栈空间,由于应用程序和内核的代码时单纯的函数调用的关系,所以它们都使用同样的栈空间。这就引入了一些问题:

  • 如果应用程序代码设置 SP 指向无效内存地址,则会导致内核代码运行时产生异常。
  • 如果应用程序代码设置 SP 指向存放其他数据的内存地址,则可能导致内核信息泄露或关键数据被覆写。
  • 如果应用程序在系统调用/中断处理完成后,读取比栈顶更低地址处的数据,则会导致内核信息泄露。

引入特权级之后,为了让用户态代码能够正常使用栈,栈空间必须设为用户态可访问,所以以上影响内核安全性与稳定性的问题仍然存在。为了解决这些问题,我们引入用户栈内核栈的隔离。

  • 每个进程有两个栈,内核栈仅内核态可访问,用户栈在用户态也可访问。
  • 在每个进程中执行用户态代码时,使用该进程的用户栈;执行内核态代码时,使用该进程的内核栈。

在用户态下,当进行系统调用发生中断时,即需要从用户态切换到内核态时,我们将 SP 指针切换为当前进程内核栈的栈底(最高地址),并保存切换前的 SP 指针,以便在返回用户态时恢复。

:在内核态下发生中断时,因为我们已经在使用内核栈,所以不能也不需要切换栈。

为什么每个进程有独立的内核栈(而不是整个系统使用同一个内核栈)?

每个进程在上下文切换到另一进程前,即被挂起前,均处于内核态(发生中断或 sleep / wait 等系统调用),所以每个被挂起的进程都在占用内核栈,所以一个内核栈无法满足多个进程的需要,必须为每个进程独立分配一个内核栈。

系统调用

因为从用户态陷入内核态的方式是有限的,且内核态被调用的位置是固定的(为安全起见,由内核态实现设为固定值),所以我们往往在内核态中实现一个分发系统调用的函数,并通过寄存器或栈将调用行为(被调用的系统调用 ID、参数数量、参数列表、返回值传递地址)从用户态传递到该函数中,由该函数去调用目标的系统调用处理函数。

为了系统调用能够顺利返回,我们还需要告知内核,返回的位置和返回后的栈指针 SP 值(因为系统调用会切换到内核栈)。

为了防止信息泄露,我们需要在从内核态返回用户态之前将寄存器中的状态清除,所以我们需要在处理系统调用之前(在进入内核之前/之后均可)保存现场,在完成系统调用处理后恢复。

在每个进程首次进入用户态时,我们可以用与从系统调用返回相同的方式来实现内核态到用户态的切换,只需要「返回」到进程入口函数即可。

x86 实现

特权级与描述符表

在 x86 架构的保护模式下,我们有四个可以使用的特权级:从 Ring 0 到 Ring 3。其中 Ring 0 为最高特权级,我们在 Ring 0 上运行内核代码,并在最低的 Ring 3 上运行用户代码。其余的两个 Ring 是不需要的。

我们需要关注以下三个不同的特权级概念:

  • CPLCS 段寄存器,即代码段寄存器的低 2 位,描述了 CPL(Current Privilege Level,当前特权级),即正在哪个特权级上执行当前的指令
    • CS 不能作为 MOV 的目标,所以不能通过 MOV 改变特权级
    • 当发生跳转改变 CS 时,CPL 不会改变,即特权级不会随着跳转类指令(JMPCALLRET 等)改变
  • RPL:每个数据段寄存器(如 SSDSFS 等)的低 2 位,描述了使用该段寄存器访问内存时的 RPL(Requested Privilege Level,请求特权级),即在高特权级下使用低特权级的权限去访问内存
    • RPL 可以通过使用 MOV 指令改变段寄存器的方式来改变
    • 一般保持 RPL 与 CPL 相等即可,即总是使用当前的特权级来访问内存
  • DPL:描述符表中的每个段拥有一个 DPL(Descriptor Privilege Level,描述符特权级)属性,代表访问这个段所需的特权级
    • 如果是代码段,则表示读或执行该段所需的特权级;如果是数据段,则表示读或写该段所需的特权级
    • 所需的特权级是指,CPL 与 RPL 中的较小值(低特权级请求高特权级没有意义),必须小于(更高权限)目标段的 DPL

然而在平坦内存模型中,我们并不需要利用如此复杂的机制:

  • 忽略 DPL:为内核态和用户态分别设置两个段(代码段和数据段),均指向整个内存
  • 忽略 RPL:在内核态与用户态中直接使用 CPL(或者直接使用 0)作为 RPL

所以,在 GDT(Global Descriptor Table,全局描述符表)中,我们把 1、2 号描述符作为内核代码/数据段,3、4 号描述符作为用户代码/数据段。

中断与 TSS

我们刚刚提到,当用户态下发生中断时,我们需要切换栈到内核态。而在 x86 中,中断由 CPU 以一种特殊函数调用的形式通知到内核,所以需要进入中断处理函数前切换到内核栈,所以在 x86 中这个切换的过程只能由 CPU 来完成。

x86 使用 TSS(Task Status Segment,任务状态段)来描述当前正在运行的任务的信息,并基于此提供了硬件多任务(上下文切换)的功能。我们并不需要硬件多任务,只需要关注 TSS 中的 ESP0 属性(意为 ESP for Ring 0,即用于内核态的栈指针)即可。当在非 0 特权级发生中断时,CPU 会从当前任务的 TSS 中读取 ESP0 属性,并赋值给 ESP,随后调用中断处理函数。

由于不需要硬件多任务,我们只需要一个 TSS 实例。将 TSS 存储到共享内存区域(在每个进程的地址空间中均被映射)中,并在每次上下文切换前将它的 ESP0 更新为新进程的内核栈地址即可。

使用 TSS 的方式是,在 GDT 中加入一个描述符,指向 TSS 结构,并使用 LTR 指令加载这个段,即可设置当前 CPU 所使用的 TSS。

由于中断是一种特殊的函数调用,所以通过 IRET 返回时,将由 CPU 来负责返回后继续执行之前的任务,即切换回 Ring 3,切换回用户栈,并回到发生中断前的 EIP

系统调用

在 x86 中从用户态(Ring 0)进入内核态(Ring 3)有多种方式,如软中断、异常、调用门和 SYSENTER/SYSCALL 指令等,其中后两种也可以用于从内核态进入用户态。在此我们使用 SYSENTER 指令(仅 x86 可用,x64 中由 SYSCALL 指令取代)。

在 Pentium II 引入 SYSENTER 之前,触发无效指令异常曾是 80386 上最快进入内核态的方式,这在当时被用于 Windows 中

SYSENTER 要求我们在内核态中提前告知 CPU,返回内核时需要设置的 CSEIPESP 值(考虑到唯一需要手动从用户态进入内核态的场景是进行系统调用,这里所需的即是系统调用分发函数的入口与当前进程内核栈的栈底),在执行时仅做特权级切换、栈切换和跳转三件事,具体地:

  • 在进入用户态前,内核需要将参数写入以下 MSR(Model Specific Register,模型特定寄存器)
    • IA32_SYSENTER_CS = 0x174: 内核态 CS 寄存器的值
    • IA32_SYSENTER_ESP = 0x175: 进入内核态时要设置的 ESP 寄存器的值(即系统调用分发函数的入口地址)
    • IA32_SYSENTER_EIP = 0x176: 进入内核态时要设置的 ESP 寄存器的值(即内核栈的地址)
  • 当在用户态执行 SYSENTER 指令时,CPU 会
    • CS 设为 IA32_SYSENTER_CS 的值
    • SS 设为 IA32_SYSENTER_CS 的值加 8(也就是说,期望内核数据段内核代码段的下一个)
    • 不会从 GDT 中加载对应段的信息,而是默认此时 CSSS 段是遵从扁平内存模型的代码段(可读可执行)与数据段(可读可写),且 DPL 为 0(无需关心)
    • ESP 设为 IA32_SYSENTER_ESP 的值
    • EIP 设为 IA32_SYSENTER_EIP 的值
    • 寄存器状态被保留
    • 切换到 Ring 0,从设置后的 CS:EIP 开始执行
  • 当在内核态执行 SYSEXIT 指令时,CPU 会
    • CS 设为 IA32_SYSENTER_CS 的值加 16(也就是说,期望用户代码段内核数据段的下一个)
    • SS 设为 IA32_SYSENTER_CS 的值加 24(也就是说,期望用户数据段用户代码段的下一个)
    • 不会从 GDT 中加载对应段的信息,而是默认此时 CSSS 段是遵从扁平内存模型的代码段(可读可执行)与数据段(可读可写),且 DPL 为 0(无需关心)
    • ESP 设为 ECX 的值(应在系统调用时由用户态告知)
    • EIP 设为 EDX 的值(应在系统调用时由用户态告知)
    • 寄存器状态被保留
    • 切换到 Ring 3,从设置后的 CS:EIP 开始执行

所以,SYSENTERSYSEXIT 所需要的段排列顺序为:

  1. 内核代码段
  2. 内核数据段
  3. 用户代码段
  4. 用户数据段

我们只需要以上四个段,将 GDT 的第 5 项设为上文中提到的 TSS 即可。

由于系统调用进入内核态后会进行函数调用,会破坏上下文,所以建议在进入内核态前保存上下文。

由于 ECXEDX 将用于为 SYSEXIT 系统调用传参,所以不能使用这两个寄存器来存放返回值。

页权限

页目录项与页表项中有一个 User/Supervisor 位,将该字段被设为 1 时,表示对应的页面(或范围内的页面)可在用户态(非 Ring 0)访问,否则当该字段被设为 0 时,仅可在内核态(Ring 0)访问

当页表项与其所属页表的页目录项中 User/Supervisor 位的值不同时,取较严格的一项限制,即仅当两处设置均为 1 时,该页可在用户态访问。