之前所有代码都跑在内核态(ring0)。这一章第一次让用户程序在 ring3 里运行——加载 ELF 文件,跳到用户态执行,用户程序通过 syscall 和内核通信。

这是操作系统最核心的一个边界:内核和用户程序之间的隔离。


exec 做什么

exec 的语义是"用一个新程序替换当前进程":

  1. 从文件系统读 ELF 文件
  2. 解析 ELF header,找到各个段的加载地址
  3. 创建新的用户页表,把各段加载进去
  4. 分配用户栈
  5. 设置进程的 rip = 入口地址,rsp = 栈顶,cs/ss = 用户段,放入就绪队列

调度器下次选中这个进程,就会跳到用户态开始执行。


进入用户态:iretq

第一次进入用户态不能用 sysret——没有对应的 syscall。用 iretq

static void enter_usermode(uint64_t rip, uint64_t cs,
                            uint64_t rflags, uint64_t rsp, uint64_t ss) {
    __asm__ volatile(
        "push %4\n"   // ss
        "push %3\n"   // rsp
        "push %2\n"   // rflags(IF=1,开中断)
        "push %1\n"   // cs(0x23,ring3 代码段)
        "push %0\n"   // rip(entry point)
        "iretq"
        :: "r"(rip), "r"(cs), "r"(rflags), "r"(rsp), "r"(ss)
    );
}

iretq 弹出这 5 个字段,CPU 检查 cs 的 RPL 发现是 ring3,自动完成特权级切换。


用户态 → 内核:syscall/sysret

用户程序通过 syscall 指令进内核,sysret 返回用户态。

syscall 执行时:跳到 LSTAR 指定的内核入口,RCX 保存用户返回地址,R11 保存用户 rflags,但不切换栈——RSP 还是用户栈。

所以内核入口必须用汇编手动保存用户寄存器,C 函数处理完之后再恢复,最后 o64 sysret 回用户态。

初始化时写三个 MSR:

wrmsr(0xC0000080, efer | 1);           // EFER.SCE:启用 syscall/sysret
wrmsr(0xC0000082, (uint64_t)syscall_entry);  // LSTAR:内核入口地址
wrmsr(0xC0000081, ((uint64_t)0x001B0008 << 32));  // STAR:CS/SS 选择子
wrmsr(0xC0000084, 0x200);              // SFMASK:syscall 时清 IF

用户程序

用户程序用汇编直接发 syscall,不依赖任何 C 运行时:

_start:
    mov rax, 1          ; syscall 1 = write
    lea rdi, [rel msg]  ; 字符串地址
    syscall

    mov rax, 2          ; syscall 2 = exit
    syscall

msg: db "Hello from user!", 13, 10, 0

-nostdlib 编译,链接脚本把 _start 放在 ELF 入口点,写到 ext2 镜像里,内核从磁盘加载运行。


验证

ext2 mounted!
[test] exec hello.elf
[test] exec ok, pid=0x1
Hello from user!
[kernel] process exited

用户程序在 ring3 运行,write syscall 打印字符串,exit syscall 退出,没有 Page Fault。


踩坑:NASM 里 sysretq 是标签不是指令

这一章最隐蔽的 bug。

sysretq,NASM 不报错,只输出一条 warning:

warning: label alone on a line without a colon

NASM 没有 sysretq 这个助记符,把它当成了标签定义——代码直接跌落到下一个函数 gdt_init,CPU 跑飞,Page Fault。

debug:看到 Page Fault 的 RIP 是 0x10194,反汇编发现是 gdt_init.reload_cs,才意识到 sysretq 根本没生成任何指令。

修法:改为 o64 sysret

另一个坑:syscall_entry 里绝对不能 call C 函数做调试输出。C 函数会按 ABI 破坏 caller-saved 寄存器,包括 RCX——sysret 需要它作为用户返回地址,一旦被覆盖就跳到错误地址,直接 Page Fault。


小结

机制 要点
iretq 首次进用户态,手动构造 5 字段栈帧
syscall 不切栈,RCX = 返回地址,R11 = rflags
o64 sysret 回用户态,NASM 里不能写 sysretq

下一章:wait / exit — 父进程等待子进程退出,拿到退出码。