调试档案 022 · 第一次跳进 Ring 3 的两个坑
从
document/notes/022/001_usermode_three_bugs.md、002_sfmask_qemu_msr.md提炼,配套 022 · 第一次跳进 Ring 3:用户态与特权隔离。022 把内核从「只有 Ring 0」推到「能真正进 Ring 3、并被特权隔离弹回来」。过程里两个坑最典型:一个是「进不去 Ring 3,串口炸成乱码」——其实是三个 bug 叠在一起互相掩盖,缺一不可地全修才进得去;一个是「SFMASK 写进去读回来是 0」——看着像 bug,其实是 QEMU 对这只 MSR 模拟不完整,得在测试层把"模拟器限制"和"真 bug"分清。两条都值得记成档案。
案例一:SYSRET 之后串口吐一串 [,根本进不了 Ring 3
症状:
launch_first_user()执行后,串口没打出预期的#GP from user mode,反而吐出一坨乱码,预期应该是 SYSRET 进入 Ring 3 → 用户代码第一条cli触发 #GP → 打寄存器转储并 halt:text[USER] Setting up first user-mode program... [[[[[[[[[[[[[[[[[VMM] Demand-paged 0x00000000FD08F000 -> phys 0x0000000001089000 V[[[[[[[[[[[[[注意两个细节:一是满屏
[重复——这正好是[USER]、[VMM]那个方括号被反复打印;二是中间夹了Demand-paged ...——这是handle_pf里的kprintf。两个细节合起来指向同一个方向:console 的写路径本身在崩。根因:三个独立 bug 叠加,任何一个单独修都不够。下面逐个拆。
Bug A —— framebuffer 的 identity mapping 在用户地址空间里消失了。 022 之前,内核的 framebuffer/MMIO 靠
map_mmio()用 1 GB 大页做 identity mapping(物理地址直接当虚拟地址,挂在PDPT[3])。而AddressSpace构造时只复制 PML4 的高半区(PML4[256..511],内核共享部分),低半区全清零。于是launch_first_user里user_space.activate()一切 CR3,PDPT[3]那个 1 GB 大页就没了。后果是连环的:kprintf往 console 写 → 写到那个虚拟地址触发 #PF →handle_pf走 demand-page,给它随便分配了一个普通 RAM 页并 map 上去 → 写进的是个无关地址,console 当然不亮;更要命的是handle_pf内部又调kprintf打Demand-paged ...这条日志,而 console 还是坏的,于是 #PF handler 自己又 #PF,重入。串口那串[就是这么来的:kprintf想打[USER],每次写到坏地址就 #PF,demand-page 又想打[VMM],如此循环,每条日志的第一个[被吐出来,后面就崩了。根子不在 console,在「切 CR3 把 identity mapping 切丢了」。Bug B —— 写 STAR MSR 用了
shlq $32而不是shlq $16。usermode_init_asm要把内核 CS 选择子0x08塞进 STAR 的[63:48](SYSRET 取这个字段算 user CS)。代码先movq $0x08, %rdx再shlq $32, %rdx,想把0x08移到 RDX 的高 32 位。问题在于:wrmsr只读EDX:EAX——也就是只读 RDX 的低 32 位,高 32 位被直接丢弃。shlq $32把0x08移到了 RDX 的 bit 32 以上,wrmsr根本看不到:text预期:EDX = 0x00080008 → STAR[63:48] = 0x08 → SYSRET 后 CS = 0x1B(用户代码段) 实际:EDX = 0x00000008 → STAR[63:48] = 0x00 → SYSRET 后 CS = 0x13(内核数据段 + RPL3)0x13是数据段选择子,SYSRET 之后 CPU 拿着数据段当代码段去取指,必然出事。为什么一开始没发现?因为 Bug A 和 Bug C 先爆了 #PF,把 Bug B 盖住了——等到 A、C 都修好,SYSRET 真的能跳了,Bug B 才以CS=0x13 的 #PF的形式暴露。Bug C —— 四级页表中间层缺
FLAG_USER。VMM::map会在用户页上带FLAG_USER,但walk_level内部分配新的 PDPT/PD/PT 页时,只写了FLAG_PRESENT | FLAG_WRITABLE,没带 user 位。x86-64 的权限检查要遍历全部四级页表:哪怕最终的 PT 项带了FLAG_USER,只要 PML4/PDPT/PD 里有一级的中间项缺 user 位,Ring 3 访问就会被拒。表现是用户代码页刚一取指就 #PF,错误码0x05(P=1、W/R=0 即读、U/S=1 即用户态——页在,但权限不足)。cpp// 旧(错):分配中间页表时不带 user 位 entry.raw = new_page | FLAG_PRESENT | FLAG_WRITABLE;
定位:这案的关键是「别被乱码带偏」。串口乱码第一眼像 console/serial 驱动坏了,但
[这个字符反复出现、且夹着Demand-paged日志,指向的是「console 写路径触发 #PF,而 #PF handler 自己又打日志又 #PF」的重入。顺这条线查handle_pf的 demand-page 分支,确认它在用户 CR3 下把一个普通 RAM 页映到了 framebuffer 的虚拟地址——这就把矛头指向 identity mapping 在切 CR3 后丢失。Bug C 的error_code = 0x05是另一条独立线索:U/S=1说明是用户态访问被拒,且P=1说明页存在,那就是权限位问题,直接查walk_level有没有把 user 位传到每一级。Bug B 最隐蔽,要等 A、C 修好、SYSRET 真的跳了,看到CS=0x13才能抓住——所以这三 bug 的定位顺序通常是 A→C→B。修复:三处分别对症下药,合起来才进得了 Ring 3。
Bug A:在
launch_first_user的activate()之前,把内核 PDPT 里已经存在的 identity-mapping 条目复制到用户 PDPT,只复制用户 PDPT 里还缺的那些项(避免覆盖已经建好的用户映射):cppauto* kern_pdpt = reinterpret_cast<const uint64_t*>( (kern_pml4[0] & ADDR_MASK) + KERNEL_VMA); auto* user_pdpt = reinterpret_cast<uint64_t*>( (user_pml4[0] & ADDR_MASK) + KERNEL_VMA); for (uint32_t i = 0; i < PT_ENTRIES; i++) { if ((kern_pdpt[i] & FLAG_PRESENT) && !(user_pdpt[i] & FLAG_PRESENT)) user_pdpt[i] = kern_pdpt[i]; }这样切了 CR3 之后,console 用的 1 GB 大页还在,
kprintf不再 #PF,重入循环断掉。Bug B:
shlq $32改成shlq $16,让0x08落在EDX[31:16](也就是 STAR 的[63:48]),wrmsr这次真能读到。当前 tag 的usermode.S里已经是shlq $16, %rdx。Bug C:给
walk_level加一个user_flag参数(默认 0),VMM::map从传入的 flags 里抠出FLAG_USER,一路传到 PDPT/PD/PT 的分配:cppuint64_t user_flag = flags & FLAG_USER; auto* pdpt = walk_level(pml4_table, PML4_INDEX(virt), true, user_flag); auto* pd = walk_level(pdpt, PDPT_INDEX(virt), true, user_flag); auto* pt = walk_level(pd, PD_INDEX(virt), true, user_flag);walk_level内部新建页表项时一律| user_flag。
防复发:三条经验,都该钉进 muscle memory。一是
wrmsr只认 32 位的 EDX:EAX——对 64 位寄存器做移位再喂给wrmsr,一定想清楚目标到底是 RDX 还是 EDX;往 STAR 这种[63:48]/[47:32]位域塞值,要么用shlq $16配orq在 EDX 内拼,要么干脆用 C++ 算好 64 位常量再拆高低写。二是 x86-64 的 user 位是逐级检查的,四级页表任何一级缺 user 位,Ring 3 都进不去——所以页表分配的内部函数必须把 user 标志当参数传下去,不能只在最末级 PT 项上设。三是 identity mapping 和地址空间切换天然不合:「物理地址直当虚拟地址」的方案,在 CR3 一换的瞬间就会断裂。凡是切了地址空间还要继续用的 MMIO/console 映射,要么在激活前显式继承,要么一开始就别用 identity mapping、改用高半区映射。这三条任意一条漏了,都会以「乱码」「#PF(0x05)」「CS 不对」这类看似不相干的症状出现,排查时先从这三类根因入手。
案例二:SFMASK 写进去读回来是 0,但 wrmsr 又不报 #GP
症状:QEMU 机内测试
test_sfmask_if_bit一上来就挂:text[RUN] test_msr::test_sfmask_if_bit [FAIL] (sfmask & 0x200) == true at kernel/test/test_usermode.cpp:125usermode_init_asm里明明用wrmsr给IA32_FMASK(0xC0000084)写了0x200(屏蔽 IF 位),rdmsr读回来却是0。而同一段汇编里 STAR(0xC0000081)和 EFER(0xC0000080)的写/读都正常。也就是说,唯独 SFMASK 这一只写丢了,且不报错。根因:这不是代码 bug,是 QEMU 对
IA32_FMASK这只 MSR 的写入持久化模拟不完整。验证下来,QEMU 既不是不认识这只 MSR、也不是乱报错,而是「认得、会做合法性检查、但把合法写入静默丢弃」:wrmsr写合法值(如0x200):不触发 #GP,但值被丢,rdmsr读回 0;wrmsr写非法值(如全1):照常触发 #GP;- STAR、EFER 等其他 SYSCALL/SYSRET 相关 MSR 写入正常;
- KVM 后端和 TCG 软件模拟后端行为一致,所以不是 KVM 的锅,是 QEMU 模拟层本身。
好在它对本 milestone 无功能影响:
IA32_FMASK只在执行 SYSCALL 时用来决定清掉 RFLAGS 的哪些位;022 只用 SYSRET 单向进 Ring 3,SYSRET 是从 R11 恢复 RFLAGS、不读 SFMASK,所以这只 MSR 写没写进去,都不影响进 Ring 3 和特权隔离的验证。定位:这案的排查链很典型,值得记下顺序,因为它示范了「怎么把模拟器限制和真 bug 区分开」。
- 先反汇编
usermode_init_asm,确认movabs $0xc0000084,%rcx / xor %rdx,%rdx / mov $0x200,%rax / wrmsr这段指令序列本身没写错——指令是对的。 - 把 SFMASK 的写入挪到 EFER 之后(最后执行),排除「EFER 的
wrmsr覆盖了 SFMASK」——挪完仍然失败,排除顺序依赖。 - 改用 C++ inline asm 在
usermode_init()里直接写 SFMASK,排除「.S 汇编文件链接/符号问题」——仍然失败。 - 在测试函数体内「写完立即读回」,排除时序——读回仍是 0。
- 关键一步:写全
1(非法值)进去,结果正常触发 #GP。这一步把案子翻过来了:非法值会 #GP、合法值不 #GP 但不持久,说明 QEMU 认识这只 MSR 并且做了合法性检查,只是丢掉了合法写入。如果指令编码错了,合法值也应该 #GP;现在合法值不 #GP,就反证了「指令没错,锅在模拟器」。 - 最后用
-accel tcg -cpu max -vga std跑一遍,排除 KVM 虚拟化层——TCG 也复现,坐实是 QEMU 本身的模拟行为。
- 先反汇编
修复:既然是模拟器限制而不是代码错,修法不是改内核,而是把测试从「硬断言读回值」改成「验证写合法值不触发 #GP」。
test_sfmask_if_bit现在只做wrmsr 0x200,只要没 #GP 就算通过——能走到测试函数末尾,就说明指令编码正确、CPU 接受了这个合法值:cppvoid test_sfmask_if_bit() { // 写 0x200 不应触发 #GP;非法值(如 0xFFFFFFFF)才会。 __asm__ volatile( "movl $0xC0000084, %%ecx\n\t" "xorl %%edx, %%edx\n\t" "movl $0x200, %%eax\n\t" "wrmsr\n\t" ::: "rax", "rcx", "rdx" ); // 走到这儿说明 wrmsr 接受了 0x200,指令编码无误。 }在真实硬件上,这只 MSR 的写入会正常持久化,
rdmsr应读回0x200;真要在真机上加严校验,可以套一层#ifndef __QEMU__守卫做读回断言,但本 tag 不做。防复发:两条。一是 测试结果和代码正确性矛盾时,先在模拟器层面排除干扰——不是所有
wrmsr不报错就代表写入生效,QEMU 对部分 MSR(尤其是冷门的IA32_FMASK)的模拟并不完整。判断「指令对不对」的一个好招是写一个明知非法的值看会不会 #GP:非法值 #GP、合法值不 #GP,就反证了指令编码正确,锅在模拟器。二是 测试断言要对齐这只 MSR 在当前设计里的真实语义:IA32_FMASK只管 SYSCALL 方向,SYSRET 从 R11 恢复 RFLAGS 不读它;022 是 SYSRET-only 的演示,SFMASK 写没写进去对功能没影响,所以测试断「不 #GP」是恰当的,既覆盖了「指令编码正确」这个唯一需要验证的点,又不被模拟器的已知缺陷卡住。
附:几个当下不发作、迟早要踩的隐形坑
下面这几条在本 tag 的 demo 里不会爆(因为用户程序就四字节 cli;hlt;jmp .-2,触发 #GP 后直接 fatal_halt),但一旦后续真上系统调用、真跑多任务、真做异常返回,就会要命,先记在这里。
launch_first_user把TSS.RSP0设成了当前rsp。 022 里GDT::tss_set_rsp0(kernel_rsp0)用的kernel_rsp0就是movq %rsp, ...取到的当前内核栈。这只在「用户态触发异常后直接 halt、不返回 Ring 3」的 demo 里够用。等 023 接上系统调用、需要从 Ring 3 进内核再回 Ring 3 时,RSP0 必须指向一个稳定、专属、栈顶干净的内核栈,不能再随手拿当前 rsp——否则异常/中断在烂栈上再炸一次就是 #DF。launch_first_user不返回,main.cpp里它后面的键盘 poll loop 不可达。 用户代码cli一触发 #GP,handle_gp走fatal_halt()永久cli;hlt。所以main.cpp里Returned from user mode launch (unexpected)和后面的键盘轮询循环,在本 tag 是死代码,别以为「用户态跑完会回到 main 继续」。(顺带:main.cpp头注释里还写着17. Scheduler init, create tasks,但 Step 17 的 Scheduler init 在本 tag 的 diff 里已经被删了,实际只剩usermode_init+launch_first_user;那条注释是没擦干净的遗留,别当成"调度器和用户态并存"的证据。)#PF的 demand-page 在用户态访问内核地址时会乱映。 Bug A 暴露的是一个更深的问题:handle_pf的 demand-page 分支对任何「页不存在」的 #PF 都一视同仁地g_vmm.map一页普通 RAM 上去,不区分这个地址该不该被映、是用户态访问还是内核态访问。在本 demo 里它只是把 console 写歪了;等真正跑用户程序时,用户态访问一个本不该存在的地址,demand-page 却默默给它建了个映射,等于隔离被偷偷打穿。这条在后续做系统调用/真用户程序时必须收紧(demand-page 只该服务合法的、用户地址空间内的缺页)。
一句话总结
022 的两个坑,一个是「三个 bug 互相掩盖、得全修才进得了 Ring 3」——framebuffer identity mapping 切 CR3 后丢失引发 #PF 重入、STAR 用 shlq $32 写错位导致 CS=0x13、walk_level 中间页表缺 user 位导致 #PF(0x05),分别修在「激活前复制 identity mapping」「shlq $16」「user 位逐级下传」;一个是「QEMU 对 SFMASK 写入静默丢弃」的模拟器限制——用「写全 1 触发 #GP」反证指令没错,再把测试从硬断言读回值改成「不 #GP 即通过」。前者是「x86-64 权限/地址空间切换」的必修课,后者是「测试要分清模拟器限制和真 bug」的典型样本。