尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

《从零手写操作系统 (09):用户态隔离与系统调用——跨越特权级的鸿沟》

发布时间:2026/9/28 18:40:23

资讯中心
01
ARTICLE

《从零手写操作系统 (09):用户态隔离与系统调用——跨越特权级的鸿沟》

《从零手写操作系统 (09):用户态隔离与系统调用——跨越特权级的鸿沟》
前言从“内核玩具”到“安全基石”在上一章中我们实现了多任务调度但所有进程都运行在Ring 0最高特权级共享同一个地址空间。这意味着任何一个进程的空指针解引用或野指针写入都能直接摧毁内核。这种架构只能称为“多任务演示”绝非真正的操作系统。本章我们将完成OS开发中最具仪式感的一步用户态隔离。通过配置TSS、设置Ring 3代码/数据段、实现iret下探和int 0x80上溯我们将让第一个用户程序在完全隔离的环境中运行并通过受控的系统调用接口请求内核服务。这是现代操作系统安全模型的绝对基石。本章里程碑✅ 理解x86特权级保护机制与TSS的作用✅ 创建用户态代码段/数据段DPL3✅ 实现fork_user_process()构造Ring 3初始栈帧并iret下探✅ 实现int 0x80系统调用入口与参数传递约定✅ 编写第一个用户态程序Hello World via syscall✅ 验证内存隔离用户态写内核地址触发#GPF核心概念特权级、TSS与双向穿越Ring 0 ↔ Ring 3 的双向通道x86硬件强制规定了特权级切换的唯一合法路径方向指令触发条件硬件自动行为内核→用户iret弹出CS/EIP时CPL3从TSS读取SS:ESP加载用户栈用户→内核int n/syscallIDT门DPL≥CPL从TSS读取SS0:ESP0切换到内核栈⚠️致命陷阱iret到Ring 3时CPU必须从TSS中获取用户态的SS和ESP。如果TSS未正确加载或内容错误iret将立即触发#TS或#GPF。这就是为什么用户态切换前必须先配置TSS。为什么需要TSSTSSTask 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 // type0x89 (Available 32-bit TSS), DPL0 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 补充 // 用户代码段: Base0, Limit4GB, DPL3, TypeExecute/Read gdt_set_entry(3, 0, 0xFFFFFFFF, 0xFA); // Selector 0x1B (33 | 3) // 用户数据段: Base0, Limit4GB, DPL3, TypeRead/Write gdt_set_entry(4, 0, 0xFFFFFFFF, 0xF2); // Selector 0x23 (43 | 3)选择子段类型RPL用途0x08内核代码0Ring 0 CS0x10内核数据0Ring 0 DS/SS0x1B用户代码3Ring 3 CS0x23用户数据3Ring 3 DS/SS/ES0x28TSS0TR寄存器创建用户态进程并下探// 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 (IF1, IOPL0) *(--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 entry0x%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: { // eaxsysnum, ebxfd, ecxbuf, edxlen 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; } // 简单实现仅支持fd1(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); // 注意flags0xEE → DPL3允许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必须设为3IDT中中断门的DPL字段控制谁可以触发该中断。默认DPL0意味着只有Ring 0代码可以int 0x80。用户态执行int 0x80时CPU比较CPL(3) DPL(0)直接触发#GPF。设为DPL3后Ring 3才被允许调用。3. 用户态程序的链接地址为什么必须低于0xC0000000高半核映射下0xC0000000以上是内核专属虚拟空间。用户程序的ELF头部、代码段、数据段、栈都必须位于0x00000000~0xBFFFFFFF范围内。否则要么与内核冲突要么在用户态页表中根本不存在对应映射。调试Checklist特权级切换崩溃排查症状可能原因排查方法iret触发#TS/#GPFTSS未加载/TR无效/TSS中SS0错误str ax验证TR值QEMUinfo registers检查TR确认GDT中TSS描述符type0x89用户程序首条指令#PF用户代码页未映射/权限不含U位QEMUmem 0x08000000验证可读检查PTE的User位是否置1int 0x80触发#GPFIDT 0x80门DPL≠3 / CS选择子RPL错误sgdt导出IDT验证0x80条目flags确认用户CS0x1B(RPL3)系统调用返回后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 / TSSLinux Kernel:arch/x86/kernel/process_32.c,entry_32.Sxv6 Source:proc.c(allocproc),trap.c(trap)本系列完整代码[你的GitHub仓库链接]Commit:u0s9e0r作者注这是《从零手写操作系统》系列的第09篇。用户态隔离是整个教程中安全感最强的章节——当你第一次看到用户程序故意写0xC0000000被#GPF杀死而内核安然无恙时你会真正理解保护二字的含义。如果你卡在TSS配置上请记住TSS是x86历史包袱中最反直觉的部分连Linux都曾在此踩坑无数。建议先实现一个只做iret下探的最小demo确认能进入用户态后再添加系统调用。下一章我们让内核学会读文件
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。