- Linux系统调用过程分析
- 一位貌似国外读研/博,鹅厂员工
- CPU的运行环、特权级与保护
- 中文版翻译,原文见下一条引用
- CPU Rings, Privilege, and Protection
- 妈耶,08年的文章,我19年当宝一样读...
- 在Linux-0.11中实现基于内核栈切换的进程切换
- 当学生时间真是多,悔恨...
- Intel Software Development Manual Vol.1
- Intel Software Development Manual Vol.2A
开始研究内核切换的起因,是最近在研究Synopsys ARC EM的代码,打算手撸一个用户态与内核态之间的双向穿梭。然而ARC的生态堪忧,找不到现成的代码范例,没法抄抄改改凑合了事。不得已,只好从特权级切换的根源开始学习,看看从用户态的一声 'syscall' 到内核态的 'ret' 之间,操作和CPU硬件到底怎么分的工,各自完成了哪些动作,来完成切换的过程。
在跳入知识的汪洋大海,被呛得七荤八素之前,大致知道:
- x86 架构中著名的
int 0x80
。执行软件代码int 0x80
产生中断后,CPU会自动进入高特权级(Ring 0); - ARM 架构的
smc
svc
也能达到提升 Exception Level / Privilege Level 的作用。同时 CPU 的 CPSR.M[3:0] 会体现当前运行的 EL / PL;
就这么点粗浅的了解,想手撸一个设计者拍脑袋想出的新架构 CPU 的状态切换几乎是痴人说梦。于是静下心来,好好读书,从古人x86学起。
int 0580, sysenter / sysexit 和 syscall / sysret 的爱恨情仇
INT 0x80
被用于在 Interrupt Descriptor Table (IDT) 中标注用于从 Ring 0 到 Ring 3 的系统调用的编号。只有少量的 IDT 表项拥有提升运行特权的权利,比如 INT 0x03 用于 debug。
sysenter
和 sysexit
是 Intel x86_32 指令集下的快速系统调用指令。通过多设几个寄存器,减少了系统调用是压栈/出栈的时间损耗,提高了系统调用的效率。
syscall
和 sysret
是 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 ,其内容如下:
在整个切换过程中,分为如下几个步骤:
32-bit
- Linux 初始化时把 IDT 写好,各个中断描述项应该用哪个等级的DPL都定好;
- 我们忽略在发生特权级切换前的软件函数压栈过程;
- 用
INT 0x80
或快速系统中断等手段发生中断; - CPU 去查 IDT ,发现 0x80 号中断的 DPL 是 3,也就是允许从 CS 所处的 CPL=3 来访问,于是中断开始。
- 先切换栈的状态。硬件自动将 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 结构。 -
切换栈状态后,Segement Selector 从 GDT / LDT 中拿到段描述符,计算获得中断处理程序的入口地址,加载到 EIP ,段基址加载到 CS。进入中断处理程序的运行。
- 硬件完成自动压栈。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 中其余的寄存器软件压栈。
- 运行中断处理程序,完事后用 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 压入内核栈
- 在返回中断调用程序时,
暂时没找到是否硬件有参与的材料
后记
- 年少不努力,老大徒伤悲
- 从电子管到晶体管,从分立元件到集成电路,从单任务到多任务,从字符界面到图形界面,从大型机到智能手机,每一步科技的进步都是多少人想破了头皮才演进了那么一点点。现代人闷着头在手机上看视频嘿嘿嘿的时候,有多少人能想到多少科学家、工程师在背后的努力