Published on

MIT 6.S081 Lecture 2. OS organization and system calls

Authors
  • avatar
    Name
    Vegetog
    Twitter

课堂笔记

每个进程有自己独立的页表,因而只能访问出现在页表中的物理地址,实现了进程之间的物理地址隔离

关于某个系统调用是否合法,这一步主要在内核态进行检验,例如检查write的参数是否合法

如何理解进程是对CPU的一种抽象

**物理层面:**CPU 的程序计数器(PC,Program Counter)在一个时钟周期内只能指向一条指令

并发执行:由操作系统的时间片轮转(Time Slicing)和抢占机制实现了并发执行

**虚拟化:**通过虚拟化(Virtualization)CPU,让进程认为自己在独占CPU

**快照机制:**当 OS 决定停止执行进程 A 改为执行进程 B 时,它会将当前 CPU 的所有寄存器值保存到进程 A 的 PCB(Process Control Block,进程控制块) 中,再次运行A时,就可以回到之前的状态

**复用方式:**具体的,复用CPU的方式有波分复用、时分复用、码分复用等

Q&A

xv6的启动过程

有点复杂,这里分两个版本来讲

简单来说

可以把这个过程简化为 4 个核心步骤

1. 找个“落脚点” (汇编阶段)

  • 文件entry.S
  • 动作:给每个 CPU 分配一小块内存作为栈 (Stack)
  • 原因:CPU 刚启动时只有寄存器,没有栈。没有栈就没法运行 C 语言代码(因为函数调用、局部变量都需要栈)。
  • 结果:设置好栈指针 sp 后,赶紧跳到 C 语言环境的 start 函数。

2. 身份“降级” (机器模式)

  • 文件start.c
  • 动作:CPU 默认在最高权限(Machine Mode)运行。start() 函数会做一系列配置,然后通过执行 mret 指令,把权限降低到监管者模式 (Supervisor Mode),并跳入 main() 函数。
  • 原因:就像公司老板不能事无巨细都管,内核通常运行在权限稍低一点的 S 模式,把最核心的权限留给硬件配置。
  • 配置:顺便关掉内存分页(让地址直接对应物理内存)、把时钟中断委托给内核处理。

3. 大管家“装修” (内核初始化)

  • 文件main.c
  • 动作
    • 主 CPU (Hart 0):负责所有的“装修”工作,包括初始化物理内存分配器、创建内核页表、初始化磁盘驱动、进程表等。
    • 其他 CPU:在旁边“摸鱼”等待,直到主 CPU 把活干完。
  • 关键任务:调用 userinit() 准备好第一个用户进程(就像 Windows 启动后的桌面程序一样)。

4. 开启“正式营业” (调度循环)

  • 文件main.c
  • 动作:所有 CPU 都会进入 scheduler()(调度器)函数。
  • 结果:调度器会在进程表里找刚才准备好的第一个进程。一旦找到,就跳到那个程序去运行。至此,内核启动完成,系统开始运行用户程序。

总结一下

阶段核心任务状态变化
第一步 (entry.S)分配栈空间硬件状态 \rightarrow 环境就绪
第二步 (start.c)切换到内核权限Machine Mode \rightarrow Supervisor Mode
第三步 (main.c)初始化内存和设备只有代码 \rightarrow 完整系统架构
第四步 (main.c)运行第一个程序内核初始化 \rightarrow 用户可用

完整过程

结合xv6的代码来讲

第一阶段:硬件加载与入口 (entry.S)

当 QEMU 模拟器启动时,它会将内核加载到内存地址 0x80000000 处。此时,所有的 CPU 核心(在 RISC-V 中称为 hart,即 Hardware Thread)都会从这个地址开始执行。

  1. 设置 C 语言运行环境:由于 C 语言的函数调用依赖于栈(Stack),而硬件复位时栈指针 sp 是未定义的,所以 _entry 的首要任务是为每个 CPU 分配一个独立的栈。

  2. 计算栈地址:内核在 start.c 中预定义了一个 4096 字节倍数的数组 stack0。entry.S 通过以下公式为每个核心设置 sp:

    sp = stack0 + (hartid + 1) * 4096。

  3. 跳转到 C 代码:设置好栈后,核心会通过 call start 进入 start.c

第二阶段:特权模式切换 (start.c)

RISC-V 硬件在复位后默认处于最高特权级——机器模式(Machine Mode)。为了系统的安全和稳定,内核通常运行在监管者模式(Supervisor Mode),而用户程序运行在用户模式(User Mode)start() 函数的核心任务就是将特权级从 Machine Mode 降低到 Supervisor Mode。

  • 准备切换(mret 机制):RISC-V 无法直接通过一条指令简单地“修改”当前模式,而是需要模拟一次“中断返回”。
    • 通过设置 mstatus 寄存器的 MPP 位为 S (Supervisor),告诉硬件下次执行返回指令时进入 S 模式。
    • 通过 w_mepc((uint64)main)main 函数的地址写入异常程序计数器,作为返回后的跳转目标。
  • 配置权限
    • PMP (Physical Memory Protection):配置物理内存保护,允许 S 模式访问所有的物理内存。
    • 中断委托:将所有的中断和异常处理交给 S 模式处理。
  • 最后一步:执行 asm volatile("mret")。这条指令会根据之前的设置,让 CPU 切换到 Supervisor Mode 并跳转到 main() 执行。

第三阶段:内核初始化 (main.c)

当代码进入 main() 时,系统已经运行在 Supervisor Mode。为了避免多个 CPU 同时初始化全局资源造成冲突,xv6 采用了“主核负责,从核等待”的策略:

  1. 主核(Hart 0)初始化全局资源
    • consoleinit() / printfinit():初始化控制台,这样我们才能在屏幕上看到输出。
    • kinit():初始化物理内存分配器。
    • kvminit():创建内核页表。
    • userinit()最关键的一步,创建第一个用户进程(通常是执行 initcode.S)。
    • 同步信号:主核完成上述任务后,将全局变量 started 置为 1,并执行内存屏障 __sync_synchronize() 确保所有核心都能看到这个变化。
  2. 从核(Other Harts)等待与局部初始化
    • 非 0 号核心会在 while(started == 0) 循环中自旋等待。
    • 一旦 started 变为 1,它们会执行自己核心相关的局部初始化,例如 kvminithart()(开启本核心的分页机制)和 trapinithart()(设置本核心的中断向量表)。

第四阶段:进入调度器

最后,所有的核心(包括主核和从核)都会调用 scheduler()

  • 此时内核初始化宣告完成。
  • scheduler() 是一个死循环,它会不断寻找可运行(RUNNABLE)的进程。
  • 主核之前通过 userinit() 创建的进程将被第一个选出并运行,最终引导出 Shell。

如何从用户态(User Mode)转换到内核态(Kernel Mode)

在操作系统中,从用户态(User Mode)转换到内核态(Kernel Mode)是保证系统安全与稳定的核心机制。对于正在学习 MIT 6.S081 并准备 C++ 面试的你来说,这不仅是一个操作系统概念,更是理解高性能编程(如减少上下文切换开销)的基础。 主要有三种途径可以触发这种转换:

  1. 系统调用 (System Call) 这是最常见的一种方式,是用户程序主动申请内核服务。
  • 场景: 当你需要读写文件(read/write)、申请内存(brk/mmap)或创建进程(fork)时。
  • 机制: 用户程序通过执行特定的汇编指令(如 x86 上的 syscall 或 int 0x80)来触发。这会引发一个特殊的陷阱(Trap),让 CPU 跳转到预定义的内核入口点。
  1. 异常 (Exceptions) 这是由 CPU 在执行指令时检测到的被动转换。
  • 场景: 典型的例子是缺页异常 (Page Fault)、除以零、非法指令等。
  • 机制: 当发生这些情况时,当前指令无法继续执行,CPU 会自动跳转到内核定义的异常处理程序中。
  1. 外设中断 (Hardware Interrupts) 这是由外部设备触发的异步事件。
  • 场景: 硬盘读写完成、网卡接收到数据包、时钟中断(用于进程调度)。
  • 机制: 硬件向 CPU 发出信号,CPU 在执行完当前指令后,会根据**中断向量表(Interrupt Vector Table)**跳转到对应的内核驱动程序。 转换过程的底层步骤 无论哪种途径,转换的核心逻辑是一致的,目的是安全地切换“上下文”:
  • 保存现场 (Save Context): CPU 会将用户态的寄存器(如 PC、SP 以及通用寄存器)压入该进程对应的**内核栈(Kernel Stack)**中。
  • 切换栈指针: 将堆栈指针从用户栈切换到内核栈。
  • 提升特权级: 将 CPU 的运行级别从 Ring\ 3(用户态)切换到 Ring\ 0(内核态)。
  • 执行处理程序: 根据陷阱号(Trap Number)或向量号,跳转到对应的内核处理函数。
  • 恢复与返回: 处理完成后,通过执行 iret 或 sysret 指令,还原寄存器,切换回用户栈,并将特权级降回。

对于课程中提到qemu的作用的部分,补充一些计算机组成原理的知识

  1. 核心架构:冯·诺依曼模型 (Von Neumann Architecture) 计算机的基础是“存储程序”概念。它主要由五部分组成:
  • 运算器 (ALU): 负责数学运算(加减乘除)和逻辑运算(与或非)。
  • 控制器 (CU): 负责指挥全局,指挥各部件协调工作。
  • 存储器 (Memory): 即内存,存放指令和数据。
  • 输入设备 (Input): 键盘、鼠标。
  • 输出设备 (Output): 显示器。

面试点: 在 C++ 中,程序的执行实际上就是将编译好的机器码从硬盘加载到内存,再由 CPU 依次读取。

  1. 寄存器 (Registers):CPU 的“贴身口袋” 寄存器是 CPU 内部存储速度最快的存储单元。由于内存(RAM)距离 CPU 相对较远且速度慢,CPU 需要寄存器来暂存正在处理的数据。 常见的寄存器包括:
  • 程序计数器 (PC, Program Counter): 存储下一条要执行的指令在内存中的地址。它是指挥官,决定了程序运行到哪了。
  • 指令寄存器 (IR, Instruction Register): 存放当前正在执行的指令内容。
  • 通用寄存器 (GPRs): 如 rax, rbx 等(X86 架构),用于存放运算的操作数或中间结果。
  • 栈指针寄存器 (SP, Stack Pointer): 永远指向当前函数调用栈的栈顶。
  1. 指令执行周期 (Instruction Cycle) 一条指令从产生到完成,通常经历以下四个阶段: 第一步:取指令 (Fetch) CPU 根据 PC 寄存器 中的地址,去内存里找到对应的指令,并将其读入到 IR (指令寄存器) 中。取完后,PC 会自动递增,指向下一条指令。 第二步:译码 (Decode) 控制器 (CU) 对 IR 中的指令进行“翻译”。它需要搞清楚:
  • 这是什么操作?(加法还是跳转?)
  • 操作数在哪里?(在寄存器里还是在内存里?) 第三步:执行 (Execute) 根据译码结果,运算器 (ALU) 开始干活。如果是 a + b,它就把两个数加起来。 第四步:访存与写回 (Memory Access & Write Back) 如果结果需要保存到内存,就执行访存操作。最后,将计算结果写回到指定的寄存器或内存地址。