前几章的"进程"其实是假的——它们直接跑在内核态,和内核同等权限,可以随意读写任何内存、操作任何硬件。
真实的操作系统里,用户程序跑在用户态(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← 用户态rip(sysret用来跳回去)r11← 用户态rflagsrip← 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 执行时,rip 从 rcx 恢复(回到用户程序 syscall 指令的下一条),rflags 从 r11 恢复,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 里写上 rcx 和 r11——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 内核做的事和这里一模一样,只是每一层都复杂得多——但基本结构和思路是相通的。