启动模块¶
启动模块是内核执行的起点,负责从硬件启动过渡到内核正常运行。理解启动流程是理解整个内核如何协作的关键。
启动的本质问题¶
在内核运行之前,系统处于什么状态?
- 内存是空的:没有内核代码和数据
- CPU 在 M-Mode:最高特权级
- 没有栈:无法调用函数
- 没有页表:虚拟内存未启用
启动模块的任务是将系统从这种"原始状态"引导到内核可以正常运行的状态。
启动的三个阶段¶
┌─────────────────────────────────────────────┐
│ 阶段 1: 硬件复位 + OpenSBI │
│ M-Mode,硬件初始化 │
└─────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ 阶段 2: 内核入口 (_entry) │
│ S-Mode,设置栈,进入 C │
└─────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ 阶段 3: main 函数 │
│ S-Mode,初始化各子系统 │
└─────────────────────────────────────────────┘
为什么需要 OpenSBI?¶
直接从硬件启动内核非常困难:
- 需要初始化 DDR 控制器
- 需要配置中断控制器
- 需要设置异常委托
- 需要从 M-Mode 切换到 S-Mode
OpenSBI 是 M-Mode 固件,完成这些底层工作,让内核运行在 S-Mode。
OpenSBI 做了什么?¶
- 硬件初始化:配置 PLIC、UART 等
- 异常委托:将中断和异常委托给 S-Mode
- 加载内核:从指定地址加载内核镜像
- 模式切换:从 M-Mode 切换到 S-Mode
OpenSBI 通过 QEMU 的 -bios 参数加载。QEMU 默认使用 OpenSBI。
为什么用汇编写入口?¶
问题:C 函数需要栈¶
C 函数的调用约定需要栈: - 局部变量在栈上 - 返回地址在栈上 - 函数参数可能通过栈传递
但启动时没有栈!
解决方案:汇编设置栈¶
汇编代码可以直接操作 sp 寄存器,设置栈后再调用 C 函数:
_entry:
设置 sp = boot_stack_top
call main
为什么每个 CPU 核需要独立的栈?¶
如果多核共享一个栈:
CPU 0: 使用栈顶
CPU 1: 也使用栈顶 ← 冲突!
每个核有独立的栈区域,通过 hartid 计算偏移:
sp = boot_stack_top - hartid * STACK_SIZE
BSS 段清零¶
什么是 BSS 段?¶
BSS 段存放未初始化的全局变量。C 标准规定未初始化变量值为 0。
但加载内核时,BSS 段的内容是随机的(之前内存中的数据)。必须手动清零。
为什么在启动时清零?¶
如果不清零:
int counter; // 未初始化,应该是 0
// 实际可能是任意值:12345, -999, ...
if (counter == 0) { // 条件可能不成立
...
}
为什么不用加载器清零?¶
加载器(OpenSBI)只负责加载,不知道内核的 BSS 段位置。内核自己知道 s_bss 和 e_bss 的位置(由链接脚本定义)。
初始化顺序:为什么是这个顺序?¶
main 函数的初始化顺序¶
1. init_cpu // CPU 结构
2. pm_init // 物理内存
3. plic_init // 中断控制器
4. trap_init // 中断处理
5. timer_init // 定时器
6. kvminit // 虚拟内存
7. init_runq // 运行队列
8. blk_init // 块设备
9. bcache_init // 缓冲区缓存
10. virtio_init // VirtIO
11. vfs_init // 文件系统
12. ramfs_init // RAM 文件系统
13. vfs_mount_root // 挂载根文件系统
依赖关系决定顺序¶
trap_init 需要 plic_init(中断控制器)
timer_init 需要 trap_init(中断处理)
sched_yield 需要 init_runq(运行队列)
vfs_mount_root 需要 vfs_init(VFS 框架)
为什么虚拟内存在物理内存之后?¶
虚拟内存需要页表,页表需要物理页。所以必须先初始化物理内存管理。
为什么调度器在最后启用?¶
如果在初始化过程中启用调度:
1. 初始化内存
2. 启用调度
3. [定时器中断] 触发调度,切换进程
4. 但文件系统还没初始化!新进程可能需要文件系统
所以用 sched_enabled 标志,确保初始化完成后再调度。
多核启动:当前缺失的功能¶
单核 vs 多核启动¶
单核启动:
- 只有 CPU 0 执行初始化
- 其他 CPU 暂停(wfi)
多核启动:
- CPU 0 完成初始化
- CPU 0 唤醒其他 CPU(通过 IPI)
- 其他 CPU 设置自己的栈和 tp
- 所有 CPU 参与调度
为什么当前只支持单核?¶
多核启动需要: - 核间中断(IPI) - 共享数据结构的锁保护 - Per-CPU 变量
这些机制的实现需要更多工作。单核已经足够理解内核的基本原理。
idle 进程:最后的守护者¶
为什么需要 idle 进程?¶
当没有进程可运行时,CPU 不能停机,必须执行某些代码。idle 进程就是最后的"保底"进程。
idle 进程做什么?¶
void idle_loop(void) {
while (1) {
wfi(); // 等待中断
sched_yield(); // 尝试调度
}
}
wfi 指令让 CPU 进入低功耗状态,直到中断到来。这比空循环更节能。
idle 进程与普通进程的区别¶
| 特性 | idle 进程 | 普通进程 |
|---|---|---|
| 创建时机 | 启动时 | 运行时 |
| 入运行队列 | 否 | 是 |
| 优先级 | 最低 | 可配置 |
| 状态 | 永远是 IDLE | 多种状态 |
启动失败的常见原因¶
1. 链接地址错误¶
内核期望加载到 0x80200000,如果实际加载到其他位置:
- 绝对地址跳转错误
- 全局变量访问错误
2. 栈溢出¶
启动阶段栈较小(4KB),如果初始化函数调用层次深或局部变量大,可能溢出。
3. 初始化顺序错误¶
如果依赖的模块未初始化,会触发异常或死锁。
4. 内存不足¶
如果内核镜像 + 初始化数据超过物理内存,会启动失败。
设计权衡¶
为什么用 C 而不是全部用汇编?¶
汇编难以维护,C 更清晰。只在必须的地方(设置栈、切换模式)用汇编。
为什么不用更复杂的启动加载器?¶
U-Boot 等启动加载器提供更多功能(网络启动、设备树解析),但对于教学内核,OpenSBI 已经足够。
为什么 BSS 段不自动清零?¶
这是历史原因。现代加载器可以清零 BSS,但 NoobKernel 保持简单,手动清零。
思考题¶
- 如果忘记清零 BSS 段,会出现什么问题?如何调试?
- 多核启动时,CPU 0 如何唤醒其他 CPU?
- 为什么初始化顺序很重要?如果顺序错误会发生什么?
- idle 进程为什么要执行
sched_yield?