前几章的"进程"其实是假的——它们直接跑在内核态,和内核同等权限,可以随意读写任何内存、操作任何硬件。

真实的操作系统里,用户程序跑在用户态(Ring 3),权限受限,不能直接操作硬件。需要内核帮忙时,必须通过系统调用这扇受控的门进入内核,做完事再回去。

这一章实现 syscall / sysret:用户态和内核态之间最快速的切换机制。


两种特权级

x86-64 有 4 个特权级(Ring 0~3),操作系统只用两个:

  • Ring 0(内核态):可以执行任何指令,访问任何地址,操作 CR3、MSR 等特权寄存器
  • Ring 3(用户态):不能执行特权指令,访问不属于自己的内存会触发 #GP 或 Page Fault

CS 段寄存器的低 2 位(CPL,Current Privilege Level)表示当前特权级。syscall 指令把 CPL 从 3 切到 0,sysret 把 CPL 从 0 切回 3。


为什么用 syscall 而不是中断

早期 Linux 用 int 0x80 触发系统调用——软件中断,要保存完整的中断栈帧,走 IDT 查表,开销大。

syscall / sysret 是专门为系统调用设计的快速路径:不走 IDT,入口地址直接写在 MSR 里,省去了大量压栈操作。现代 x86-64 系统全部用这对指令。


配置 MSR

MSR(Model Specific Register,型号特定寄存器,CPU 内部的一组控制寄存器,用 rdmsr / wrmsr 访问)控制 syscall 的行为。需要配置 4 个:

void syscall_init() {
    // 1. EFER(0xC0000080)bit0 = SCE,开启 syscall 指令
    uint64_t efer = read_msr(0xC0000080);
    write_msr(0xC0000080, efer | 1);

    // 2. STAR(0xC0000081):指定 syscall/sysret 时用哪个 CS
    //    bits[47:32] = 内核 CS(0x08),bits[63:48] = 用户 CS 基(0x10)
    uint64_t star = ((uint64_t)0x08 << 32) | ((uint64_t)0x10 << 48);
    write_msr(0xC0000081, star);

    // 3. LSTAR(0xC0000082):syscall 进来后跳到的地址
    extern void syscall_entry();
    write_msr(0xC0000082, (uint64_t)syscall_entry);

    // 4. SFMASK(0xC0000084):syscall 时自动清掉 rflags 里的哪些位
    //    bit9 = IF(中断使能),进入内核时先关中断
    write_msr(0xC0000084, 0x200);
}

STAR 的格式有点绕:syscall 进入内核时,CS 被设为 STAR[47:32](=0x08,内核代码段);sysret 回用户态时,CS 被设为 STAR[63:48] + 16(=0x10+16=0x23,用户代码段)。这是 AMD 规范里写死的偏移量。


syscall_entry:汇编入口

syscall 执行时,硬件做了这几件事:

  • rcx ← 用户态 ripsysret 用来跳回去)
  • r11 ← 用户态 rflags
  • rip ← LSTAR(我们的 syscall_entry
  • CPL 切为 0

硬件不保存 rsp——内核栈得自己换。这里先不切栈(进程还在内核态跑),只保存寄存器,调用 C 处理函数:

syscall_entry:
    push rcx        ; 用户态 rip(sysret 要用)
    push r11        ; 用户态 rflags
    push rbp
    push rbx
    push r12
    push r13
    push r14
    push r15

    ; System V 调用约定:参数依次用 rdi rsi rdx rcx r8 r9
    ; syscall 约定:nr=rax, 参数1=rdi, 参数2=rsi, 参数3=rdx
    ; 所以只需要把 rax 挪到 rdi,其余已经在位
    mov rcx, rdx        ; 第4个参数(C 函数用 rcx,但 rdx 没用到,占位)
    mov rdx, rsi
    mov rsi, rdi
    mov rdi, rax        ; 第1个参数 = 系统调用号

    call syscall_handler

    pop r15
    pop r14
    pop r13
    pop r12
    pop rbx
    pop rbp
    pop r11
    pop rcx

    sysretq             ; 返回用户态:rip=rcx, rflags=r11

sysretq 执行时,riprcx 恢复(回到用户程序 syscall 指令的下一条),rflagsr11 恢复,CPL 切回 3。


C 层分发

uint64_t syscall_handler(uint64_t nr, uint64_t a1, uint64_t a2, uint64_t a3) {
    switch (nr) {
        case SYS_WRITE:   // 1
            serial_print((const char *)a1);
            return 0;
        case SYS_EXIT:    // 2
            serial_print("[kernel] process exited\r\n");
            while (1) { __asm__ volatile ("hlt"); }
            return 0;
        default:
            serial_print("[kernel] unknown syscall\r\n");
            return (uint64_t)-1;
    }
}

这就是系统调用表的雏形。Linux 有 300+ 个系统调用,都是从这个 switch 扩展出来的。


用户程序发起 syscall

static void user_proc() {
    while (1) {
        __asm__ volatile (
            "mov $1, %%rax\n"       // nr = SYS_WRITE
            "mov %0, %%rdi\n"       // a1 = 字符串地址
            "syscall\n"
            :: "r"((uint64_t)"[user] hello via syscall!\r\n")
            : "rax", "rdi", "rcx", "r11"   // syscall 会破坏 rcx 和 r11
        );
        for (volatile int i = 0; i < 2000000; i++);
    }
}

clobber list 里写上 rcxr11——syscall 指令会用这两个寄存器,GCC 必须知道它们被破坏了,否则编译器会以为值还在,生成错误代码。

输出:

Kernel loaded!
syscall initialized
Starting...
[user] hello via syscall!
[user] hello via syscall!
[user] hello via syscall!
...

用户进程通过 syscall 打印字符串,内核处理完通过 sysret 返回,循环往复。


这一章之后

十章下来,我们从一个 512 字节的 Bootloader 走到了一个能跑多进程、有系统调用的微型内核。

回头看一下这条路:

章节 做了什么
BIOS 引导,进入实模式,第一行输出
保护模式,GDT,段描述符
长模式,四级页表,64位地址空间
加载 C 内核,链接脚本,栈初始化
中断,IDT,PIC,时钟心跳
E820 内存探测,物理页位图(PMM)
虚拟内存,map_page,TLB
堆分配器,kmalloc/kfree,空闲链表合并
进程调度,上下文切换,Round Robin
系统调用,syscall/sysret,用户态隔离

每一章都是在前一章的基础上加一块砖。真实的 Linux 内核做的事和这里一模一样,只是每一层都复杂得多——但基本结构和思路是相通的。

源码在这里:github.com/tongpengfei/learn-with-ai