前言:从“内核玩具”到“安全基石”
在上一章中,我们实现了多任务调度,但所有进程都运行在Ring 0(最高特权级),共享同一个地址空间。这意味着任何一个进程的空指针解引用或野指针写入都能直接摧毁内核。这种架构只能称为“多任务演示”,绝非真正的操作系统。
本章我们将完成OS开发中最具仪式感的一步:用户态隔离。通过配置TSS、设置Ring 3代码/数据段、实现iret下探和int 0x80上溯,我们将让第一个用户程序在完全隔离的环境中运行,并通过受控的系统调用接口请求内核服务。这是现代操作系统安全模型的绝对基石。
本章里程碑:
- ✅ 理解x86特权级保护机制与TSS的作用
- ✅ 创建用户态代码段/数据段(DPL=3)
- ✅ 实现
fork_user_process():构造Ring 3初始栈帧并iret下探 - ✅ 实现
int 0x80系统调用入口与参数传递约定 - ✅ 编写第一个用户态程序(Hello World via syscall)
- ✅ 验证内存隔离:用户态写内核地址触发#GPF
核心概念:特权级、TSS与双向穿越
Ring 0 ↔ Ring 3 的双向通道
x86硬件强制规定了特权级切换的唯一合法路径:
| 方向 | 指令 | 触发条件 | 硬件自动行为 |
|---|---|---|---|
| 内核→用户 | iret | 弹出CS/EIP时CPL=3 | 从TSS读取SS:ESP,加载用户栈 |
| 用户→内核 | int n/syscall | IDT门DPL≥CPL | 从TSS读取SS0:ESP0,切换到内核栈 |
⚠️致命陷阱:
iret到Ring 3时,CPU必须从TSS中获取用户态的SS和ESP。如果TSS未正确加载或内容错误,iret将立即触发#TS或#GPF。这就是为什么用户态切换前必须先配置TSS。
为什么需要TSS?
TSS(Task State Segment)是x86为特权级切换设计的专用数据结构。虽然我们不用它做硬件任务切换,但Ring 3 → Ring 0的栈切换依赖TSS中的SS0:ESP0字段。每个CPU核心需要一个TSS,其中SS0固定为内核数据段选择子,ESP0指向当前进程的内核栈顶。
系统调用的安全契约
int 0x80是用户态进入内核的唯一受控入口。内核必须在ISR中:
- 验证所有用户传入的指针是否位于用户地址空间(< 0xC0000000)
- 验证字符串长度不超过合理上限
- 在拷贝数据时使用带异常处理的
copy_from_user(本章简化为范围检查)
永远不要信任用户态传入的任何值。
实战代码
TSS定义与加载
// tss.h & tss.c #include <stdint.h> typedef struct { uint32_t prev_tss; uint32_t esp0; // Ring 0 栈指针 ← 关键! uint32_t ss0; // Ring 0 段选择子 ← 关键! uint32_t esp1, ss1; // Ring 1 (unused) uint32_t esp2, ss2; // Ring 2 (unused) uint32_t cr3; uint32_t eip, eflags; uint32_t eax, ecx, edx, ebx, esp, ebp, esi, edi; uint32_t es, cs, ss, ds, fs, gs; uint32_t ldt; uint16_t trap, iomap_base; } __attribute__((packed)) tss_entry_t; static tss_entry_t tss; void tss_init(void) { memset(&tss, 0, sizeof(tss)); tss.ss0 = 0x10; // 内核数据段选择子 tss.esp0 = 0; // 将在进程切换时动态更新 tss.iomap_base = sizeof(tss); // 无I/O位图 // 在GDT中添加TSS描述符(假设索引5,选择子0x28) // type=0x89 (Available 32-bit TSS), DPL=0 gdt_set_tss(5, (uint32_t)&tss, sizeof(tss)); // 加载TR寄存器 asm volatile("ltr %%ax" :: "a"(0x28)); } // 每次进程切换时调用,更新TSS中的内核栈指针 void tss_set_kernel_stack(uint32_t esp0) { tss.esp0 = esp0; }GDT新增用户态段
// gdt.c 补充 // 用户代码段: Base=0, Limit=4GB, DPL=3, Type=Execute/Read gdt_set_entry(3, 0, 0xFFFFFFFF, 0xFA); // Selector 0x1B (3<<3 | 3) // 用户数据段: Base=0, Limit=4GB, DPL=3, Type=Read/Write gdt_set_entry(4, 0, 0xFFFFFFFF, 0xF2); // Selector 0x23 (4<<3 | 3)| 选择子 | 段类型 | RPL | 用途 |
|---|---|---|---|
| 0x08 | 内核代码 | 0 | Ring 0 CS |
| 0x10 | 内核数据 | 0 | Ring 0 DS/SS |
| 0x1B | 用户代码 | 3 | Ring 3 CS |
| 0x23 | 用户数据 | 3 | Ring 3 DS/SS/ES |
| 0x28 | TSS | 0 | TR寄存器 |
创建用户态进程并下探
// user_process.c #include "process.h" #include "tss.h" #include "serial.h" #define USER_CS 0x1B #define USER_DS 0x23 #define USER_STACK_TOP 0xBFFFF000 // 用户栈顶(低于3GB) // 用户程序二进制(由外部链接器生成,嵌入内核镜像) extern uint8_t _binary_user_hello_start[]; extern uint8_t _binary_user_hello_end[]; process_t *fork_user_process(const char *name) { process_t *proc = process_create(NULL, name); // 复用PCB分配逻辑 if (!proc) return NULL; // 1. 拷贝用户代码到独立的用户态页面 uint32_t user_code_size = _binary_user_hello_end - _binary_user_hello_start; uint32_t user_vaddr = 0x08000000; // 用户代码起始VA // TODO: 分配物理页并映射到user_vaddr,拷贝_binary_user_hello_start内容 // (此处省略PMM+分页映射细节,参见第06章) // 2. 分配用户栈页面并映射到USER_STACK_TOP附近 // TODO: 分配1页物理内存,映射到 USER_STACK_TOP - PAGE_SIZE // 3. 构造iret下探所需的栈帧(在内核栈上!) uint32_t *kstack = (uint32_t*)&proc->kernel_stack[KERNEL_STACK_SIZE]; *(--kstack) = USER_DS; // SS *(--kstack) = USER_STACK_TOP; // ESP *(--kstack) = 0x202; // EFLAGS (IF=1, IOPL=0) *(--kstack) = USER_CS; // CS *(--kstack) = user_vaddr; // EIP → 用户代码入口 // 4. 设置context使switch_to恢复后执行iret proc->ctx.esp = (uint32_t)kstack; proc->ctx.eip = (uint32_t)do_iret_to_user; // 汇编trampoline // 5. 更新TSS以匹配此进程的内核栈 tss_set_kernel_stack((uint32_t)&proc->kernel_stack[KERNEL_STACK_SIZE]); kprintf("[USER] Forked PID=%d entry=0x%x\n", proc->pid, user_vaddr); return proc; }; user_trampoline.asm global do_iret_to_user do_iret_to_user: ; 此时栈上已有 SS/ESP/EFLAGS/CS/EIP iret ; CPU自动切换到Ring 3,加载用户SS:ESP系统调用实现
// syscall.c #include "interrupt.h" #include "serial.h" #define SYS_WRITE 1 #define SYS_EXIT 2 // int 0x80 处理函数 void syscall_handler(interrupt_frame_t *frame) { uint32_t sysnum = frame->eax; switch (sysnum) { case SYS_WRITE: { // eax=sysnum, ebx=fd, ecx=buf, edx=len const char *buf = (const char *)frame->ecx; uint32_t len = frame->edx; // 【安全检查】验证缓冲区在用户空间 if ((uint32_t)buf >= 0xC0000000 || (uint32_t)buf + len > 0xC0000000) { frame->eax = -1; // -EFAULT kprintf("[SYSCALL] REJECTED: invalid user pointer 0x%x\n", buf); break; } // 简单实现:仅支持fd=1(stdout)→串口 if (frame->ebx == 1) { for (uint32_t i = 0; i < len; i++) serial_putc(buf[i]); frame->eax = len; } else { frame->eax = -1; } break; } case SYS_EXIT: kprintf("[SYSCALL] PID %d exited with code %d\n", current_process->pid, frame->ebx); current_process->state = PROC_DEAD; schedule(); break; default: frame->eax = -1; // -ENOSYS } } // 在 interrupt_init() 中注册: // idt_set_gate(0x80, (uint32_t)isr_stub_128, 0x08, 0xEE); // 注意:flags=0xEE → DPL=3,允许Ring 3触发用户态测试程序(独立编译)
// user/hello.c - 使用 -m32 -nostdlib -static 编译 #define SYS_WRITE 1 #define SYS_EXIT 2 static inline int sys_write(int fd, const void *buf, int len) { int ret; asm volatile( "int $0x80" : "=a"(ret) : "a"(SYS_WRITE), "b"(fd), "c"(buf), "d"(len) ); return ret; } static inline void sys_exit(int code) { asm volatile("int $0x80" :: "a"(SYS_EXIT), "b"(code)); } void _start(void) { const char msg[] = "Hello from Ring 3!\n"; sys_write(1, msg, sizeof(msg) - 1); sys_exit(0); }Makefile片段(将用户程序嵌入内核):
user_hello.bin: user/hello.c $(CC) -m32 -nostdlib -static -o $@ $< -T user/linker.ld user_hello.o: user_hello.bin objcopy -I binary -O elf32-i386 -B i386 \ --rename-section .data=.rodata.user \ $< $@ kernel.bin: ... user_hello.o $(LD) -T linker.ld -o $@ $^关键细节解析
1. 为什么iret下探要在内核栈上构造栈帧?
iret指令从当前栈弹出SS/ESP/EFLAGS/CS/EIP。这个栈必须是内核栈,因为此时CPU仍处于Ring 0。弹出SS:ESP后,CPU才切换到用户栈。如果在用户栈上构造这些值,iret执行时CPU还在Ring 0却访问用户页面——若该页未映射或权限不足,直接#PF。
2. 为什么系统调用门DPL必须设为3?
IDT中中断门的DPL字段控制谁可以触发该中断。默认DPL=0意味着只有Ring 0代码可以int 0x80。用户态执行int 0x80时,CPU比较CPL(3) > DPL(0),直接触发#GPF。设为DPL=3后,Ring 3才被允许调用。
3. 用户态程序的链接地址为什么必须低于0xC0000000?
高半核映射下,0xC0000000以上是内核专属虚拟空间。用户程序的ELF头部、代码段、数据段、栈都必须位于0x00000000~0xBFFFFFFF范围内。否则要么与内核冲突,要么在用户态页表中根本不存在对应映射。
调试Checklist:特权级切换崩溃排查
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
iret触发#TS/#GPF | TSS未加载/TR无效/TSS中SS0错误 | str ax验证TR值;QEMUinfo registers检查TR;确认GDT中TSS描述符type=0x89 |
| 用户程序首条指令#PF | 用户代码页未映射/权限不含U位 | QEMUmem 0x08000000验证可读;检查PTE的User位是否置1 |
int 0x80触发#GPF | IDT 0x80门DPL≠3 / CS选择子RPL错误 | sgdt导出IDT验证0x80条目flags;确认用户CS=0x1B(RPL=3) |
| 系统调用返回后EAX未更新 | ISR中修改了局部变量而非frame->eax | 确认通过interrupt_frame_t*写回返回值;检查pusha/popaa顺序 |
| 用户态写内核地址未触发#GPF | 页表U位错误/SMEP未启用 | 检查内核页PTE的U位是否为0;考虑启用SMEP(CR4.SMEP)作为额外防护 |
🔧黄金法则:特权级切换问题几乎总是GDT/TSS/IDT配置错误。在第一次
iret到用户态之前,用QEMU监控器逐一dump GDT所有条目、TSS内容、IDT 0x80条目,与Intel SDM表格逐位比对。不要相信"应该没问题",只相信十六进制dump。
本章小结与下一步
今天我们跨越了操作系统最关键的鸿沟:
- ✅ 配置了TSS并理解了它在特权级切换中的不可替代作用
- ✅ 实现了安全的Ring 0 → Ring 3下探机制
- ✅ 建立了受控的Ring 3 → Ring 0系统调用通道
- ✅ 运行了第一个真正隔离的用户态程序
从此,你的操作系统拥有了安全边界。用户程序的崩溃不再影响内核,恶意代码无法直接访问硬件或内核数据。特权级隔离是所有现代OS安全模型、容器化、沙箱技术的根基。
下一章预告:《ELF加载器与exec:让内核学会“运行程序”》
目前用户程序是硬编码嵌入内核镜像的二进制blob。下一章将实现ELF解析器、动态内存映射、exec语义,让你的OS能够从文件系统(或initrd)加载标准ELF可执行文件,迈向真正的通用操作系统。
参考资料
- Intel SDM Vol.3 Chapter 7 (Task Management) & Chapter 5 (Protection)
- OSDev Wiki - Getting to Ring 3 / System Calls / TSS
- Linux Kernel:
arch/x86/kernel/process_32.c,entry_32.S - xv6 Source:
proc.c(allocproc),trap.c(trap) - 本系列完整代码:[你的GitHub仓库链接](Commit:
u0s9e0r)
📝作者注:这是《从零手写操作系统》系列的第09篇。用户态隔离是整个教程中"安全感"最强的章节——当你第一次看到用户程序故意写0xC0000000被#GPF杀死而内核安然无恙时,你会真正理解"保护"二字的含义。如果你卡在TSS配置上,请记住:TSS是x86历史包袱中最反直觉的部分,连Linux都曾在此踩坑无数。建议先实现一个只做
iret下探的最小demo,确认能进入用户态后再添加系统调用。下一章,我们让内核学会"读文件"!