第 15 篇已经能让任务等待、唤醒和接受时钟抢占,但所有任务仍在 ring 0,共享内核页表与执行权限。
一个错误指针可以改写内核数据,一条 CLI 可以阻止普通中断进入。
独立任务栈与轮转调度都不能代替访问权限。

本篇为两个用户任务建立独立地址空间,经 IRETD 进入 ring 3,让它们在相同虚拟地址递增各自的计数。
三轮实验分别让其中一个任务写内核数据、执行 CLI、执行 OUT,要求错误任务停止,而另一个继续增长。
用户代码仍是内嵌的手写机器指令;本篇没有 ELF 加载器、系统调用或用户自行退出接口,有限实验由内核监督收尾。

CPU 权限和页面权限分别限制什么

保护模式的当前权限级 CPL 由当前代码段选择子的低两位反映。
此前内核使用 CS=0x08、SS=0x10,运行在 CPL0;用户代码将使用 CS=0x1b、SS=0x23,运行在 CPL3。
这些数值同时包含 GDT 项索引和请求权限级,不能把低两位当作可以随意改写的普通配置开关。

分页的 U/S 位决定页面是否允许用户访问。
即使代码运行在 ring 3,若页目录和页表错误地把内核区域都标成 user,它仍可能通过普通访存破坏内核。
反过来,仅有不同 CR3 而仍运行在 ring 0,也不能阻止任务执行特权指令。

本篇同时建立 CPL3 执行环境、user 页面和共享的 supervisor 内核映射。
Intel SDM Vol. 3A分别描述分段权限、分页访问检查与特权指令规则,这些约束共同作用。
实验只覆盖当前已实现的页故障和一般保护异常退出路径,不声称所有用户异常都能隔离。

六张私有页描述一个用户地址空间

每个用户任务分配六张私有页,再单独申请一张内核栈页,总计七页。
struct user_space 中的数组记录全部六张页的所有权,供构造失败和任务结束时回收:

1
struct user_space { uint32_t pages[6]; };
数组位置 用途 所处地址关系
pages[0] 页目录 物理地址写入 CR3
pages[1]、pages[2] 用户代码/数据页表、用户栈页表 分别由 PDE 256、511 引用
pages[3] 代码页 用户 VA 0x40000000,只读
pages[4] 数据页 用户 VA 0x40001000,可读写
pages[5] 用户栈页 用户 VA 0x7ffff000,可读写

这里每张页都独立分配,不要求七张页物理连续。
用户栈顶设为 0x80000000,栈向下增长,当前映射只覆盖栈顶以下一页。
没有自动扩栈、按需分配或栈页保护区管理;超出实际映射仍会触发相应访问检查。

全部私有页都位于此前低 64 MiB 恒等映射可访问的物理范围。
内核构造页表、复制代码和读取用户计数时,可以通过这些物理页的 supervisor 恒等别名操作。
这个别名属于内核权限,不能据此给用户同样的访问能力。

只共享低 64 MiB 内核映射

新页目录先整页清零,再复制内核页目录前 16 个 PDE。
每个 PDE 覆盖 4 MiB,16 项共同保留低 64 MiB 映射,页表本身继续共享。
这些项保持 supervisor,用户任务在自己的 CR3 下也不能访问其中的内核数据和页表。

1
2
3
4
uint32_t *pd = (uint32_t *)space->pages[0];
memcpy(pd, (void *)user_kernel_cr3, 16 * sizeof(uint32_t));
pd[USER_CODE >> 22] = space->pages[1] | 7;
pd[(USER_STACK - PAGE_BYTES) >> 22] = space->pages[2] | 7;

PDE 256 对应用户代码起点,PDE 511 对应用户栈所在区间。
第 11 篇曾在 PDE 256 建立实验别名,本篇不能整张复制旧页目录,再把这条旧别名误当用户地址空间的一部分。
这里只继承明确需要的 16 个内核 PDE,用户项由各自私有页表重新建立。

低地址共享还保证加载新 CR3 时,正在执行的内核代码、旧内核栈、新内核栈、GDT、TSS 与任务表都保持可访问。
如果新页表没有映射当前代码或栈,CR3 加载成功后的下一次取指或栈访问就可能失败,根本到不了后续切换指令。
恒等映射在这里是跨地址空间切换得以继续执行的前提。

两个用户任务则在相同 VA 下使用不同的物理页:

1
2
3
4
5
                  任务 A 的 CR3                  任务 B 的 CR3
低 64 MiB → 共享 supervisor 映射 ← 相同内核映射
0x40000000 → A 的代码物理页 B 的代码物理页
0x40001000 → A 的数据物理页 B 的数据物理页
0x7ffff000 → A 的用户栈物理页 B 的用户栈物理页

因此 0x40001000 的含义取决于当前 CR3。
一张地址截图不足以证明隔离,必须连同页表路径、物理页归属和正在运行的任务一起判断。

PDE 与 PTE 都要满足用户访问要求

本篇标志值 7 表示 present、writable、user,5 表示 present、user 而不 writable。
代码和数据共享同一张用户页表,但使用不同 PTE 权限:

1
2
3
code_pt[0] = space->pages[3] | 5;
code_pt[1] = space->pages[4] | 7;
stack_pt[1023] = space->pages[5] | 7;

允许 CPL3 访问需要 PDE 和 PTE 的 user 条件同时满足。
只修改叶子 PTE 而上级 PDE 仍为 supervisor,用户访问仍会失败;把 PDE 设为 user 也不会自动让其中每个 PTE 都可访问。
代码 PDE 可写是为了容纳同页表中的数据页,代码 PTE 自己仍保持只读。

当前为 IA-32 非 PAE 分页,没有 NX 位。
只读代码页禁止用户写入,但数据页和栈页不因此获得硬件不可执行属性,不能宣称已经实现完整 W^X。
本次运行检查页表中的只读位,坏写实验针对 supervisor 内核数据,不把它当作“写用户代码页”的额外实测。

内核复制代码时使用 pages[3] 的低地址恒等别名。
这条 supervisor 映射允许内核写入,与用户代码 VA 的只读映射分别受各自 PTE 约束。
用户不能通过猜出这个物理地址绕过权限,因为低 64 MiB 的页目录项仍是 supervisor。

GDT 加入用户段和一个 32 位 TSS

新 GDT 保留内核代码/数据段的原索引和属性,再加入 DPL3 代码段、DPL3 数据段与 TSS 描述符。
索引 3、4 加上 RPL3 后得到用户选择子 0x1b、0x23,TSS 使用索引 5 的选择子 0x28。
用户代码和数据段沿用平坦地址空间,真正的每任务内存归属主要由分页决定。

TSS 不是本篇的任务切换保存区。
软件仍然使用第 13 篇的 switch_context,TSS 只提供 CPU 从用户态进入 ring 0 时需要的内核栈,以及 I/O 权限相关配置。
每个 CPU 一个 TSS 足够当前单 CPU 环境使用,每次调度更新其中的 ESP0。

1
2
3
4
struct tss32 {
uint32_t words[25];
uint16_t trap, iomap;
} __attribute__((packed));

静态断言要求 TSS 为 104 字节,I/O map base 字段偏移 102。
words[1] 对应 ESP0,words[2] 对应 SS0,后者设为内核数据选择子 0x10。
描述符 limit 为 103,即结构最后一个合法字节;GDT 载入后用 LTR 加载任务寄存器。

I/O map base 设为 104,位于 TSS limit 之外,当前没有给用户开放任何有效的 I/O 许可位图。
配合用户初始 IOPL=0,用户执行受检 OUT 会产生一般保护异常。
这不是“用户自行选择不访问端口”的约定,权限由处理器检查。

切换任务时同时更新 CR3 和 ESP0

调度器选中 next 后、执行普通栈切换前调用 user_switch()
用户任务传入私有页目录,普通内核任务回退到保存的 user_kernel_cr3
ESP0 则更新为将要运行任务的内核栈顶;bootstrap 使用既有启动栈顶。

1
2
3
4
user_tss.words[1] = stack_top > PAGE_BYTES
? stack_top : (uint32_t)&__stack_top;
if (!pd) pd = user_kernel_cr3;
__asm__ volatile("mov %0,%%cr3" : : "r"(pd) : "memory");

CR3 决定用户 VA 的映射,ESP0 决定下一次从用户态进入内核时 CPU 选择哪张栈。
漏改 CR3 可能让 A 访问 B 的数据;漏改 ESP0 可能让 B 的下一次中断帧覆盖 A 的内核栈。
只核对其中一项,不能证明用户任务上下文切换完整。

这段操作处在 IF=0 的调度临界区,加载新 CR3 与切换 ESP 之间不会插入普通 IRQ。
两者之间仍短暂使用旧内核栈,而新地址空间保留低 64 MiB 恒等映射,所以旧栈继续有效。
本篇没有实现 PCID、跨 CPU TLB 同步或用户页动态解除映射。

用 IRETD 建立第一次用户执行

首次用户任务仍先通过普通 trampoline 到达 task_start(),随后调用 enter_user(USER_CODE, USER_STACK)
该汇编入口读取参数、切换数据段选择子,并构造一次返回到更低权限的硬件返回帧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cli
mov ecx, [esp+4]
mov edx, [esp+8]
mov ax, 0x23
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
push dword 0x23
push edx
push dword 0x202
push dword 0x1b
push ecx
iretd

压栈顺序是用户 SS、用户 ESP、EFLAGS、CS、EIP,IRETD 从低地址开始逆序消费。
EFLAGS=0x202 开启 IF,并保持 IOPL、NT、VM 为零;这样用户计算可被 PIT 打断,又不能获得 ring 0 的 I/O 权限。
这里不会用普通 RET 改变 CPL,也没有让用户代码自行修改 CS 完成降权。

1
2
3
4
5
当前内核 ESP → EIP = 0x40000000
CS = 0x1b
EFLAGS = 0x202
用户 ESP = 0x80000000
用户 SS = 0x23

用户入口是手写汇编字节,不是普通 C 函数。
它没有 CALL 建立的返回地址,也不依赖宿主 libc;用户栈虽然已经映射,当前计数代码不靠 C 局部变量验证深层用户调用栈。
将来运行用户 C 程序时,还需要独立的 _start 与调用对齐协议,不能把这个 IRETD 入口直接当成 main。

用户中断在内核栈上多保存八字节

用户代码被 IRQ0 打断时,CPU 根据 TSS.SS0/ESP0 切到当前任务的内核栈,再保存用户 SS、ESP 和返回状态。
汇编共用入口继续补齐 vector/error、通用寄存器和段寄存器。
前 68 字节仍与第 07、14 篇的公共 frame 相同,尾部多出用户 ESP 和 SS,总长 76 字节。

1
2
3
frame +  0 ... +64  原来的 68 字节公共现场
+ 68 用户 ESP
+ 72 用户 SS

只有 (frame->cs & 3) == 3 时才读取这两个尾字段。
同 CPL 内核中断没有该尾部,按 76 字节固定读取会把其他栈内容误当 SS/ESP。
task_user_trap() 还要求完整尾部落在当前任务的内核栈页内,并核对 CR3 与任务记录相同。

IRQ0 路径记录用户 frame 后仍返回普通时钟 handler,继续 tick、EOI 和最外层调度。
恢复时保留第 14 篇两层返回:RET 回内核调用续点,最后 IRETD 回 CPL3,并消费用户 SS/ESP。
这次硬件返回会更换到用户栈,不能只按同 CPL 的 EIP/CS/EFLAGS 三项解释。

GDB 在真实 task_user_trap 入口读取 19 个四字节字段。
脚本要求现场 CS=0x1b、用户 SS=0x23、用户 ESP=0x80000000,当前处理器已经是 CS=0x08、SS=0x10。
还要核对 frame 尾部恰好到达任务内核栈顶、TSS.ESP0 等于该栈顶、CR3 指向该任务私有页目录。

本次 IRQ0 样本属于任务 1:frame 位于 0x127fb4,加 76 字节恰好到栈顶 0x128000,TSS.ESP0 也为 0x128000,CR3 为 0x121000
保存的用户 CS/SS 为 0x1b/0x23,处理中 CPU 的 CS/SS 为 0x08/0x10;用户 ESP 为 0x80000000
这项检查证明真实权限切换已经发生,而不只是代码写入了一个准备以后使用的 GDT 或 TSS。

相同虚拟地址的两个计数都增长

内嵌用户程序持续递增 0x40001000,每轮使用同一组绝对虚拟地址。
数据页同时包含实验模式、就绪标志、观察释放门、内核哨兵地址和已经发生用户 IRQ 的标记。
这些字段构成本篇专用实验协议,没有扩展成通用进程 ABI。

用户计数达到阈值后仍需等待释放门和真实用户 IRQ 标记,才进入故障动作。
检查器临时将两份物理数据页里的释放门清零,使它们都留在计算循环,避免坏任务过早退出。
调试器控制故障何时放行,不替用户调用 yield,也不代替时钟产生任务切换。

脚本执行两次 0.3 秒自由运行采样,要求 A、B 都增长。
然后在用户地址 0x40000000 设置临时硬件断点,实际停在 CPL3 指令,核对当前 CR3 与通过该 CR3 读取的 0x40001000 等于当前任务私有物理数据。
仅从内核恒等别名读出两个整数还不够,这一步把物理归属与实际用户访问连接起来。

本次两次自由运行样本中,A 从 27709051 增至 51195437,B 从 21856827 增至 46478524。
随后用户指令断点读到 CPL3 和当前私有 CR3,用户数据 VA 的值与该任务物理数据页一致。
宿主窗口受 TCG、调试停顿与宿主调度影响,数据用于证明进展,不是吞吐或公平性基准。
完成观察后释放两道门,用户代码才继续本轮受控故障。

只把来自用户态的 PF 和 GP 转为任务退出

异常分派先根据保存的 CS 判断来源。
用户来源进入 task_user_trap(),当前只对向量 13 和 14 记录错误并请求结束当前任务;其他向量继续原有分派。
内核来源继续第 07、11 篇的诊断或特定页故障修复路径,不因当前恰好有用户任务而被伪装成用户错误。

1
2
3
4
5
6
7
if (frame->vector != 13 && frame->vector != 14) return 0;
t->user_fault = frame->vector;
t->user_error = frame->error;
t->user_eip = frame->eip;
t->user_address = address;
t->user_stop = 1;
return 1;

退出请求没有在 handler 中立刻释放内存。
统一 trap 出口先把深度减到零,再检查 user_stop,调用 task_exit() 标 DEAD 并切走。
该异常现场不再 IRETD 回到出错指令,因此不会不断重复同一条非法写或特权操作。

CR2 仅在页故障解释中表示本次出错线性地址。
通用记录函数收到 CR2,不代表 GP 的 CR2 也有对应含义;GP 实验应核对向量、错误码和 EIP。
未实现用户非法指令、除零等异常的通用退出策略,也没有把 NMI 或双重故障纳入可恢复范围。

三轮故障分别检查页面与指令权限

每轮重新创建 A、B,A 保持普通计数,B 使用不同模式。
第一轮 B 将 0xbadbad00 写向内核哨兵地址,该页面 present 但属于 supervisor,预期得到 #PF,错误码 7。
低三位分别表示保护违规、写访问、用户访问;同时 CR2 应等于哨兵地址,哨兵保持 0x51a7cafe

第二轮 B 执行 CLI。
用户 CPL3 高于 IOPL0,预期产生 #GP(0),不能真正关闭整个内核的普通中断。
第三轮 B 执行 out 0x80, al,当前 IOPL 与 TSS I/O 设置不允许该端口访问,同样预期 #GP(0)。

脚本不只检查向量数组为 14,13,13 和错误码 7,0,0
内核根据内嵌汇编中三个故障标签相对 user_blob_start 的偏移,计算实际用户 EIP,逐轮核对异常确实发生在预定指令。
这样可以排除程序尚未跑到测试动作,就因其他入口错误提前失败的情形。

每轮 B 结束以后,再观察至少四个 tick,要求 A 的计数继续增长。
只看到 B 变为 DEAD 不能证明其他任务幸存;若页表回收或错误的栈切换破坏了 A,后续进展检查会失败。
本次三轮实测分别为:

错误操作 向量 / 错误码 用户 EIP B 退出后 A 增量
写内核哨兵 #PF / 7 0x4000004a 2042584
CLI #GP / 0 0x40000052 2135467
OUT 0x80, AL #GP / 0 0x40000057 2244561

内核哨兵保持 0x51a7cafe,第一轮故障后的 bootstrap 已恢复内核 CR3。上述增量只用于确认幸存任务继续运行。

三个用户故障被隔离,幸存计数继续增长并完成回收的客体画面

没有 exit 系统调用时由内核监督收尾

A 的用户程序没有主动返回内核的退出接口,正常模式只继续计数。
监督代码确认它在 B 出错后仍增长,就设置 A 的 user_stop,等待下一次符合协议的 trap 出口使其退出。
这是一段有限实验的内核控制,不是用户程序已经成功调用 exit。

bootstrap 在本篇继续参加轮转,才能定期检查两个用户任务的状态。
等两者都 DEAD 后关闭实验时钟并回到内核 CR3,再从 bootstrap 栈回收它们的私有页和内核栈。
销毁函数明确断言待释放页目录不等于当前 CR3,避免释放仍被硬件使用的页目录。

共享的低地址页表不在 space.pages[] 所有权数组里,因此销毁只释放六张私有页。
另外释放对应内核栈,合计每任务七页、每轮十四页。
三轮先后创建和回收六个任务,累计归还 42 张私有分配;不是同一时刻持有 42 页。

内核栈回收仍检查 canary、trap_depth、preempt_count 和填充值修改范围。
76 字节用户 trap 与后续 C 调用会占用内核栈,不能沿用同 CPL 版本的栈扫描结果。
填充与 canary 依然只是有限诊断,不构成 guard page 或最坏深度保证。

七处分配失败都不能发布半成品任务

地址空间构造从清零所有权数组开始,每成功分配一页就保存地址。
六张页中任何一步失败,销毁函数只归还已经取得的页并清零记录,然后返回失败。
第七次分配是内核栈;若此处失败,调用者继续销毁已经完整建立但尚未发布的用户地址空间。

任务对象先以 DEAD 状态填充,地址空间和栈都准备好后才改成 RUNNABLE。
否则 PIT 可能选中一份尚无有效 CR3 或入口栈的对象。
当前构造调用在既定内核协议下执行,没有异步用户加载或并行创建路径。

user_fail_after 从零到六依次触发七个失败点。
每次要求创建返回失败、空闲页数等于初始值、任务槽仍 DEAD、内核栈和私有页目录记录为空。
最后恢复正常分配路径,真正创建两个任务并确认页数减少十四。

这覆盖创建过程的部分成功回滚,不等于本篇再次实际耗尽所有物理内存。
底层页分配耗尽属于此前章节的独立检查,故障注入用于稳定覆盖每一个不同回滚位置。
本次七个分配失败点全部通过回滚检查,三轮回收六个任务后空闲页恢复为 16063,最后两张内核栈的填充扫描均为 228 字节。
所有已退出任务的六张私有页记录和内核栈字段均清零,共享内核映射继续保留。

当前内核文件为 37140 字节,镜像头记录内存占用 61008 字节,仍在 65536 字节装载上限内。
新增 user.c 单独使用 -Os,页表继续运行时按页分配,没有通过扩大装载边界或放置 BSS 大页表数组容纳增量。

本篇结束以后继续进入第 15 篇的持久键盘任务路径。
用户实验的私有分配已经归还,键盘任务仍会申请并长期持有自己的内核栈页,不能把交互态描述成零任务页占用。

下载与复现

下载第 16 篇完整源码
冻结源码提交为 802fb4a7a368b5caf949474a4a8d90b61a29ebbd;仓库实现目录为 examples/build-an-os,附件将目录内容直接放在 os-day-16 内。
继续使用既有工具链镜像,顺序运行:

1
2
3
4
unzip os-day-16.zip
cd os-day-16
podman run --rm -v "$PWD:/work" -w /work localhost/build-an-os:day01 make build check-user
podman run --rm -v "$PWD:/work" -w /work localhost/build-an-os:day01 make check

检查脚本为 tools/check-user.sh,三轮故障都在正常镜像 build/mbr.img 内受控执行。
串口、GDB、QEMU 输出分别保存在 build/user-serial.txtbuild/user-gdb.txtbuild/user-qemu.txt,截图为 build/day16-screen.png
各检查共用容器 loopback 的 GDB 1234 端口,应顺序运行。

本轮定向 check-user 与最终累计 make check screenshot help 均退出 0,包含三个实际故障 EIP 核对和此前章节回归。
源码附件独立解压验证为 make check 退出 0,包含用户态隔离与全部累计检查,实现边界与运行数据见源码内 docs/day16-evidence.md

用户代码现在不能直接写 supervisor 页面或执行受检特权操作,但也还不能主动请求内核输出数据。
第 17 篇将增加受控系统调用入口,让用户程序在参数检查之后使用内核能力。

练习

  1. 手算 0x40000000、0x40001000、0x7ffff000 的 PDE/PTE 索引,解释代码和数据为什么可以共享页表但保持不同写权限。
  2. 分别画出 ring 0 IRQ 与 ring 3 IRQ 的 68/76 字节现场,说明 CPU 从用户态进入中断时为什么必须使用 TSS.ESP0。
  3. 对照第七处分配失败与正常任务退出,指出两条路径由谁回收六张地址空间页,以及为什么都不能销毁当前正在使用的 CR3。

上一篇:15 - 等待与唤醒

下一篇:17 - 系统调用:在受控入口里使用内核能力。

参考资料