最简的操作系统内核
从本章开始,我们将正式进入操作系统的实验部分. 在本章中,我们将分析实验源码,理清最小化 OS 内核和批处理系统的工作原理.
拉取我们的实验代码:
如果你对于手册某些具体内容的实现感到疑惑,我们建议你可以通过阅读 commits 来查看我们的更改.
极简的用户态执行环境
Section titled “极简的用户态执行环境”一个基础的概念是,我们在学习操作系统的过程中会大量讨论内核态与用户态. 正如你在课上所学的,执行一个程序的时候,我们就在内核态与用户态之间不断进行上下文切换. 在完成实验的过程中我们会逐步加深对其的理解. 此处我们先讨论一个最简单的用户态程序.
首先我们需要学习一下 QEMU 是什么,QEMU 是一种模拟器,拥有以下模式:
- User mode:又称作“用户模式”,在这种模式下,QEMU 运行针对不同指令编译的单个 Linux 或 MacOS 程序. 系统调用与 32/64 位接口适应. 在这种模式下,我们可以实现交叉编译 (cross-compilation) 与交叉侦错 (cross-debugging).
- System mode:“系统模式”,在这种模式下,QEMU模拟一个完整的计算机系统,包括外部设备. 它可以用于在一台计算机上提供多台虚拟计算机的虚拟主机. QEMU可以实现许多客户机 OS 的引导,比如 x86, MIPS, 32-bit ARMv7 等等.
比如我们使用 qemu-riscv64 <user_file> 就可以运行 64 位的 RISC-V 架构的 <user_file> 文件,注意这里执行的是用户程序. 同理 qemu-system-riscv64 <os_kernel> 用来启动我们的内核,执行的是内核态的程序.
在学习 C 或 C++ 语言的过程中,我们都知道 main() 函数是程序执行开始的入口,通过前面的学习你已经知道在 Rust 语言中也是如此. 但是你有没有想过不依赖 main() 函数进行执行呢?答案是肯定的.
严格来说,main 只是高级语言层面的“约定入口”. 真正被 CPU 跳转到的入口地址来自可执行文件头 (例如 ELF 的入口地址字段) 以及链接脚本中的 ENTRY 设置.
操作系统内核处于硬件与应用程序之间的底层环境 (裸机),而不是基于现有的操作系统来支撑标准库中的线程、文件 I/O、堆内存管理等抽象功能. 所以对内核这类没有标准库 no_std 程序而言,我们通常不走语言运行时 (runtime) 的标准启动流程,而是直接定义一个裸入口 _start:
- 它是链接器和启动汇编约定使用的符号.
- 它负责完成最早期初始化 (栈、
.bss清零、跳转 Rust 主逻辑等). - 它不依赖标准库,因此可用于裸机执行环境.
现在你知道了 Rust 编译器要找的入口函数是 _start() ,于是我们可以在 main.rs 中添加如下内容.
这里的 extern "C" 起到 FFI 的作用,将 Rust 的 ABI 转换为 C 的 ABI. 采用 C ABI 约定,以便于与汇编入口和链接器符号对接. 你可以理解为我们显式导出 _start 这个符号,当控制流跳转到该符号时,就会执行 _start() { ... } 中的逻辑.
注意,如果要这么写,需要添加 #![no_main],告诉编译器不要生成默认入口. #[no_mangle] 则用于禁止符号名改写,确保最终产物里确实存在名为 _start 的符号,供链接器与启动代码准确定位.
这里简单写一个死循环 loop{},我们执行一下,发现:
出错了,这是因为目前的执行环境还缺了一个退出机制. 此处我们暂时不加解释地给出解决方案,在之后的篇章中我们将更详细讨论这个问题:
即可使用.
$? 表示执行程序的退出码.
正如我们在操作系统理论课上学到的,操作系统内核支持其上的用户态程序时,本质上是通过系统调用 (syscall) 提供服务。这里我们实现的是 sys_exit.
这里也可以顺便回答一个常见疑问:return 0 与 sys_exit(0) 在“效果”上通常等价,因为语言运行时最终也会调用退出系统调用;但在裸机/无标准运行时场景中,我们往往需要自己显式完成这一步.
启动你的内核
Section titled “启动你的内核”查看 Makefile,可以看到我们执行 qemu-system-riscv64 来启动我们的 ${KERNEL}:
我们此处不打算展开所有参数细节,在早期学习过程中不必一次性掌握太多. 若你想深入,可查阅 QEMU 官方文档了解每个参数的含义.
其中最关键的是:-kernel 指定内核镜像,-machine virt 选择 QEMU 提供的 RISC-V 虚拟开发板模型,-nographic 让串口输出直接显示在当前终端.
现在你很好奇,打开 main.rs 看了一眼,但是你看到的是:
如你所见,并不是 _start,接下来我们将讨论这个看似简单的启动过程发生了什么.
链接脚本 linker.ld 配置内存布局
Section titled “链接脚本 linker.ld 配置内存布局”回忆你在计算机系统基础学习的知识,我们可以通过 链接脚本 (Linker Script) 调整链接器的行为,使得最终生成的输出文件的内存布局符合我们的预期. 我们需要这么做的原因是,平时我们都使用操作系统帮我们管理好的内存布局,所以大部分情况下我们不需要考虑 .data, .text 这些段是怎么排布的. 但我们在开发操作系统内核时就需要自己管理这些部分.
我们修改 Cargo 的配置文件来使用我们自己的链接脚本 kernel/src/linker.ld 而非使用默认的内存布局.
具体的链接脚本 kernel/src/linker.ld 如下.
我们设置了目标平台为 RISC-V,整个程序的入口点为之前定义的全局符号 _start;之后定义了一个常量 BASE_ADDRESS 为 0x80200000.
冒号前面表示最终生成的可执行文件的一个段的名字,花括号内按照放置顺序将所有输入目标文件的哪些段放在这个段中。符号 . 表示当前地址,同时也是当前链接脚本要管理的地址,链接器会从它指向的位置开始,往下放置一些内容。也就是说,设置当前的地址为 ADDRESS,从输入的目标文件中,收集来的段。我们可以对 . 进行赋值来调整接下来的段放在哪里,也可以创建一些全局符号赋值为 . 从而记录这一时刻的位置。
段 .bss.stack 被放入到可执行文件中的 .bss 段中的低地址中,在后面虽然有一个通配符 .bss.* ,但是由于链接脚本的优先匹配规则它并不会被匹配到后面去. 即地址区间 [sbss,ebss) 并不包括栈空间.
因此,最终的合并结果是,在最终可执行文件中各个常见的段 .text, .rodata .data, .bss 从低地址到高地址按顺序放置,每个段里面都包括了所有输入目标文件的同名段, 且每个段都有两个全局符号给出了它的开始和结束地址 (比如 .text 段的开始和结束地址分别是 stext 和 etext ).
ALIGN 用于实现地址对齐. 对齐的主要收益是:
- 满足硬件或 ABI 对某些数据/段边界的要求;
- 减少跨页/跨缓存行访问带来的额外开销;
- 让页表映射、段权限管理等后续机制更规整.
对链接脚本可以查询官方文档.
汇编配置栈空间布局
Section titled “汇编配置栈空间布局”除此之外我们还需要设置栈空间.
如果不先设置栈空间,函数调用、局部变量、保存返回地址等操作都无法正确工作,因为它们依赖 sp 指向一段有效内存。我们需要在启动阶段分配一段栈,并把 sp 设置到栈顶。注意,RISC-V 的栈从高地址向低地址增长.
_start 的这一段汇编代码放在 .text 段的 .entry 处(回忆之前我们的链接脚本),boot_stack_lower_bound 和 boot_stack_top 被放在 .bss 段的 .stack 处.
栈空间的栈顶地址被全局符号 boot_stack_top 标识,栈底则被全局符号 boot_stack_lower_bound 标识.
当我们使用 mod boot 时,就会触发 global_asm! 嵌入全局汇编代码.
清空 .bss 段
Section titled “清空 .bss 段”一般的应用程序的 .bss 段,在程序开始运行之前会被执环境(系统库或操作系统内核)固定初始化为零. 在 ELF 文件中,为了节省磁盘空间,只会记录 .bss 段的位置. 且应用程序的假定在它执行前,其 .bss段 的数据内容都已全是0.
在裸机内核中,这一步通常需要我们自己完成:启动后尽早把 .bss 全部清零. 否则,未初始化静态变量会带着随机值运行,导致非常隐蔽的错误.
在程序内自己进行清零的时候,我们就不用去解析 ELF,而是通过链接脚本 linker.ld 中给出的全局符号 sbss 和 ebss 来确定 .bss 段的位置.
实现 println!
Section titled “实现 println!”在前面的启动阶段,我们已经完成了入口、栈和 .bss 的初始化. 接下来要解决一个实际问题:如何在没有标准输出设备驱动的情况下打印信息.
RISC-V 定义了这三种特权模式 (实际上是四种,此处我们介绍三种),构成其安全和操作系统支持的框架:M-mode(Machine,机器模式)是最高权限,直接控制硬件;S-mode(Supervisor,监督模式)用于运行操作系统内核;U-mode(User,用户模式)用于运行应用程序.
这里需要引入 SBI (Supervisor Binary Interface). 它是 S 模式内核与更高特权级执行环境 (通常是 M 模式的 OpenSBI) 之间的二进制接口. 内核可以通过 SBI 请求底层服务,例如字符输出、关机等.
在这套分层里,应用程序通过 syscall 向内核请求服务;而内核通过 SBI 向 OpenSBI 请求更底层服务. 两者都可能使用 ecall 指令触发陷入,但语义和服务提供者不同:
- U -> S:应用程序调用内核 ABI;
- S -> M:内核调用 SBI.
本实验使用的是 QEMU 自带的 OpenSBI. OpenSBI 运行在 M 模式,内核运行在 S 模式,因此内核执行 SBI ecall 后会进入更高特权级执行,完成请求后再返回.
我们通过调用 SBI 逐步实现了 Hello, world! 的输出.
从实现路径看,println! 最终会走到 core::fmt::Write 的 write_str,而 write_str 再逐字符调用 console_putchar,后者通过 SBI 把字符送到串口设备. 也就是说,println! 依赖了我们自己搭建的格式化输出链路.