x86内核切换学习笔记

Reference:


开始研究内核切换的起因,是最近在研究Synopsys ARC EM的代码,打算手撸一个用户态与内核态之间的双向穿梭。然而ARC的生态堪忧,找不到现成的代码范例,没法抄抄改改凑合了事。不得已,只好从特权级切换的根源开始学习,看看从用户态的一声 'syscall' 到内核态的 'ret' 之间,操作和CPU硬件到底怎么分的工,各自完成了哪些动作,来完成切换的过程。
在跳入知识的汪洋大海,被呛得七荤八素之前,大致知道:

  • x86 架构中著名的 int 0x80 。执行软件代码 int 0x80 产生中断后,CPU会自动进入高特权级(Ring 0);
  • ARM 架构的 smcsvc 也能达到提升 Exception Level / Privilege Level 的作用。同时 CPU 的 CPSR.M[3:0] 会体现当前运行的 EL / PL;

就这么点粗浅的了解,想手撸一个设计者拍脑袋想出的新架构 CPU 的状态切换几乎是痴人说梦。于是静下心来,好好读书,从古人x86学起。

先说结论:x86架构中,特权级切换(比如内核态和用户态)几乎完全是 OS 自己玩的独角戏。CPU的配合几乎只是标注一下当前所处的特权级,所谓的隔离和保护都是基于MMU的标识


int 0580, sysenter / sysexit 和 syscall / sysret 的爱恨情仇

INT 0x80 被用于在 Interrupt Descriptor Table (IDT) 中标注用于从 Ring 0 到 Ring 3 的系统调用的编号。只有少量的 IDT 表项拥有提升运行特权的权利,比如 INT 0x03 用于 debug。
sysentersysexit 是 Intel x86_32 指令集下的快速系统调用指令。通过多设几个寄存器,减少了系统调用是压栈/出栈的时间损耗,提高了系统调用的效率。
syscallsysret 是 AMD x86 以及 AMD64 (即通用 x86_64) 指令集下的快速系统调用指令。
详情参阅 https://www.binss.me/blog/the-analysis-of-linux-system-call/ 的『总结』。

OS 在切换中的功劳

OS 在特权级切换中的作用非常大,如前文所述,基本上特权级纯粹是软件概念。之所以设立特权级,也仅仅是为了使得操作系统的运行更加稳定可靠而已(个人理解)。
名词科普:

  • Interrupt Descriptor Table (IDT),存放 INT n 的目的地。目的地是跟随 INT n 一起传入的参数所确定的回调函数,比如 sys_open
  • Global Descriptor Table / Local Descriptor Table (GDT / LDT),两张大表,用来存放内存中系统 Segment 和 进程 Segment 的信息。这里的 Segment 和二进制文件信息(比如 ELF )中的 Segment 等价。这两个表物理上
  • Code Segment Selector (CS),从 GDT / LDT 中载入代码 Segment 的寄存器落点,是一个 CPU 中的寄存器。汇编中体现为 cs
  • Data Segment Selector (DS),从 GDT / LDT 中载入默认数据 Segment 的寄存器落点,是一组 CPU 中的寄存器。汇编中体现为 ds es fs ss 等,其中 es fs 的用法随程序而定
  • Stack Segment Selector (SS),数据 Segment Selector 中的一个,用来存放栈的 Segment 信息
  • Current Privilege Level (CPL),一个 2-bit 的值,位于 Code Segment Selector (CS) 中,用于标注当前代码执行时的特权级。CS 无法由 MOV 指令等改变,只能由 CALL 之类的调用指令被动改变。一旦调用后代码的特权级发生变化,CPL 即刻改变
  • Data Privilege Level (DPL),一个 2-bit 的值,位于 IDT 中,与 IDT 中的 Segment Selector 相对应
  • Requested Privilege Level (RPL),一个 2-bit 的值,位于数据 Segment Selector 中,可以由 MOV 指令载入数值改变

首先有一个由 x86 远古架构带来的遗产,就是 Segment ,段的概念。远古时期为了标明每一个 64K 地址范围,设计了每 64Kb ( $2^{16}$ ) 为一个 Segment,同时 Segment 的索引被存在 Global Descriptor Table / Local Descriptor Table 中。GDT 和 LDT 的关系大致可以理解为大表和缓存小表的关系。GDT 和 LDT 的每一条表项都是可以直接载入 Segment Selector ,其内容如下:


http://static.duartes.org/img/blogPosts/segmentSelector.png

在整个切换过程中,分为如下几个步骤:

32-bit

  1. Linux 初始化时把 IDT 写好,各个中断描述项应该用哪个等级的DPL都定好;
  2. 我们忽略在发生特权级切换前的软件函数压栈过程;
  3. INT 0x80 或快速系统中断等手段发生中断;
  4. CPU 去查 IDT ,发现 0x80 号中断的 DPL 是 3,也就是允许从 CS 所处的 CPL=3 来访问,于是中断开始。
  5. 先切换栈的状态。硬件自动将 TSS.esp0 加载到 esp,TSS.ss0 加载到 ss,栈切换完成。根据 Intel 的构想,在 CPU 判断特权级的(几乎)同时, 硬件逻辑 还应该自动把 每个任务 的 TSS (Task State Segment,一个内存中的数据段) 中的所有准备好的数据结构都压入内核栈。从 这里 可以看出,TSS 数据结构包含了当前用户态所有关键寄存器,比如 ss(stack segment selector) esp(stack pointer) eflags(cpu flag) cs eip(return address) 等。但由于这是 Intel 的个体设计以及寄存器太多可能影响效率,在 Linux 的真实实现上会偷工减料,仅对 每个 CPU core 设置一个 TSS 结构。
  6. 切换栈状态后,Segement Selector 从 GDT / LDT 中拿到段描述符,计算获得中断处理程序的入口地址,加载到 EIP ,段基址加载到 CS。进入中断处理程序的运行。

  7. 硬件完成自动压栈。x86 中的标准流程,将 TSS 结构中 ss / sp / eflags / cs / ip / error code 压入内核栈,TSS 中其他的内容通过软件方式一一入栈。关于为何不用 Intel 一条指令直接把 TSS 整个压栈的原因据 传言 是因为

    原有的Linux 0.11采用基于TSS和一条指令,虽然简单,但这指令的执行时间却很长,在实现任务切换时大概需要200多个时钟周期。而通过堆栈实现任务切换可能要快,而且采用堆栈的切换还可以使用指令流水的并行化优化技术,同时又使得CPU的设计变得简单。所以无论是Linux还是Windows,进程/线程的切换都没有使用Intel 提供的这种TSS切换手段,而都是通过堆栈实现的。

    所以 Linux 的做法是用一串 push 把 TSS 中其余的寄存器软件压栈。

  8. 运行中断处理程序,完事后用 IRET 返回,同时弹栈。

64-bit

64 位系统切换特权级的区别,除了由于地址偏移宽了导致一些宽度变化外,增加了 Interrupt Stack Table (IST),见 这里的描述 。除此之外,32 位系统在上面第 7 步压栈 ss 和 sp 时,会视该中断是否有特权级变化而决定压(特权级变化)还是不压(特权级不变化)。这就带来了不同中断时栈帧大小不一的问题,操作系统的解决方案是不管压不压都在栈上保留 ss 和 sp 的位置。于是,在 64 位系统中,干脆不管特权级是否变化,都将 ss 和 sp 压栈。

x86 / x86_64 硬件逻辑在切换中的作用

总结一下硬件逻辑在特权级切换中的作用:

  • 把当前寄存器状态同步在 TSS 的数据结构中
  • x86 体系硬性规定,在中断响应时,将 ss / sp / eflags / cs / ip / error code 压入内核栈
  • 在返回中断调用程序时,暂时没找到是否硬件有参与的材料

后记

  • 年少不努力,老大徒伤悲
  • 从电子管到晶体管,从分立元件到集成电路,从单任务到多任务,从字符界面到图形界面,从大型机到智能手机,每一步科技的进步都是多少人想破了头皮才演进了那么一点点。现代人闷着头在手机上看视频嘿嘿嘿的时候,有多少人能想到多少科学家、工程师在背后的努力
Comments
Write a Comment
'