MIT 6.1810 · 第 3 课

操作系统设计

操作系统设计的核心目标是隔离(isolation)。本课探讨如何实现隔离:硬件支持(用户/超级模式、虚拟内存)、宏内核与微内核的权衡、以及 xv6 系统调用的实际实现。

授课:nz (Nickolai Zeldovich) 日期:2025-01-28 难度:中等

操作系统全景

操作系统由应用程序(sh、echo 等)、系统调用接口(open、read、fork 等)、内核(实现系统调用的代码)、以及底层硬件(CPU、RAM、磁盘)组成。严格来说,"操作系统"包含内核 + 用户态服务,而"内核"是其中运行在特权模式的核心部分。

隔离:为什么需要受保护的内核?

稻草人设计:没有操作系统

如果应用程序直接与硬件交互——高效、灵活,但完全没有隔离:

问题后果
资源隔离一个程序耗尽内存、CPU 或磁盘,其他程序全部崩溃
内存隔离一个程序的 bug 写入另一个进程的内存,破坏其状态

UNIX 系统调用如何帮助隔离

隔离不仅是防 bug,更是防攻击

安全假设:

内核必须谨慎处理所有用户交互。任何内核 bug 都可能成为安全漏洞——这就是 security mindset。内核位于 可信计算基(TCB) 中。

内核如何防御恶意用户代码?

两大支柱:

  1. 硬件级控制:限制用户态指令的行为
  2. 谨慎的系统调用接口与实现:防御性编程

硬件级隔离

CPU 与内核协同设计:

机制作用
用户/超级模式超级模式可执行特权指令(访问设备、修改页表);用户模式不能
虚拟内存页表将 VA → PA,限制进程可访问的内存;页表只在超级模式下可修改

系统调用如何跨越模式?

应用程序运行在用户模式,但系统调用需要超级模式。RISC-V 提供 ecall 指令:

// 用户代码
open():
    ecall SYS_open   // 触发系统调用
    // ecall 会:
    //   1. 切换到超级模式
    //   2. 跳转到内核已知的入口点
    //   3. 内核接管执行

没有硬件隔离也能实现隔离吗?

可以!使用强类型语言(如 Singularity OS)。但用户只能使用经过批准的编译器/语言,灵活性受限。目前主流仍是硬件用户/超级模式。

宏内核 vs. 微内核

宏内核(Monolithic Kernel)

整个内核是一个大程序——xv6、Linux 都采用这种设计。

优点缺点
子系统易于协作(共享缓存、直接函数调用)复杂度高、bug 多、内部无隔离
性能高(无 IPC 开销)一个模块崩溃可能导致整个内核崩溃

微内核(Microkernel)

把大部分 O/S 功能移到用户态服务进程,内核只保留最小功能:地址空间、内存管理、线程、IPC。

期望收益设计挑战
更小 → 更少 bug → 更安全(可形式化验证,如 seL4)最小功能集是什么?能否保证安全?
更简单 → 更易优化性能(IPC 开销)能否接受?
用户态 → 更易扩展/替换/重启服务如何让程序共享磁盘/网络?

xv6 在 RISC-V 上的实现

硬件平台

xv6 运行在 SiFive HiFive Unleashed 开发板上(QEMU 模拟为 virt 机器):

什么是仿真(Emulation)?

QEMU 是一个 C 程序,忠实地模拟硬件:

// 简化模型
uint64 reg[32];
for (;;) {
    instruction = read_mem(pc);
    decode(instruction);
    execute(instruction);  // e.g., add, ld, ecall
}

xv6 内核源码结构

$ ls kernel/
  bio.c      -- buffer cache 层
  console.c  -- 控制台驱动
  exec.c     -- exec() 实现
  file.c     -- 文件描述符
  fs.c       -- 文件系统
  kalloc.c   -- 物理页分配
  kernelvec.S -- 内核中断入口
  main.c     -- 启动初始化
  pipe.c     -- 管道
  proc.c     -- 进程管理
  spinlock.c -- 自旋锁
  start.c    -- 机器模式入口
  trap.c     -- 中断/异常/系统调用
  uart.c     -- UART 驱动
  vm.c       -- 虚拟内存/页表
  ...

构建与运行 xv6

$ make              # 编译内核 + 用户程序 + 文件系统镜像
$ ls -l kernel/kernel
$ more kernel/kernel.asm
$ ls -l fs.img
$ make qemu          # QEMU 仿真运行

xv6 启动过程

  1. QEMU 加载内核到 0x80000000,跳转到第一条指令。
  2. main() 初始化内核与硬件。
  3. userinit() 创建第一个用户进程,执行 exec("/init")。
  4. init 是顶级进程,打开控制台 fd 0/1,fork/exec 启动 shell。

系统调用实现示例:exec

// user/usys.S 中的 exec
exec:
  li a7, SYS_exec   # 系统调用号
  ecall             # 进入内核
  ret

// kernel/syscall.c
void syscall(void) {
    num = p->trapframe->a7;
    syscalls[num]();  // → sys_exec()
}

// kernel/sysfile.c
uint64 sys_exec(void) {
    // 获取参数(pathname, argv)
    return kexec();   // → kernel/exec.c
}

// kernel/exec.c
int kexec(char *path, char **argv) {
    // 1. 从磁盘读取 ELF 文件
    // 2. 严格的防御性检查(防止恶意文件欺骗内核)
    // 3. 分配新内存、加载代码和数据
    // 4. 分配用户栈、写入 argv
    // 5. 设置进程为新程序
}

防御性编程的重要性

用户程序可能故意构造恶意 ELF 文件。kexec() 必须检查:文件大小、魔数、段边界、栈溢出等。内核绝不能信任用户数据!


知识检查

Q1:操作系统设计的核心目标是什么?

Q2:RISC-V 中,用户态(user mode)不能执行以下哪项操作?

Q3:宏内核(Monolithic Kernel)的主要缺点是什么?

Q4:QEMU 是什么?

Q5:微内核设计的主要目标是什么?


本课要点

隔离是核心

内核的首要目标是隔离——防 bug、防攻击。硬件提供用户/超级模式和虚拟内存作为基础。

安全思维

假设用户代码恶意,内核必须防御性编程。任何内核 bug 都可能是安全漏洞。

宏内核 vs. 微内核

宏内核性能高但复杂;微内核简洁但 IPC 开销大。现代 OS 是两种设计的混合体。

xv6 是起点

xv6 是一个小型的宏内核,代码量小但包含所有核心机制——是理解 OS 的理想案例。

延伸阅读