启动流程¶
启动流程是从硬件上电到内核正常运行的过程。理解启动流程是理解内核如何一步步建立运行环境的关键。
启动的本质挑战¶
硬件启动时的状态¶
当 QEMU 启动 RISC-V virt 平台时:
- 内存是空的:没有内核代码
- CPU 在 M-Mode:最高特权级
- 没有栈:无法调用函数
- 没有中断:中断系统未初始化
- 没有页表:虚拟内存未启用
内核启动的任务是将系统从这种"原始状态"引导到"可运行状态"。
需要解决的问题¶
- 如何加载内核:内核代码从哪里来?
- 如何切换特权级:从 M-Mode 到 S-Mode
- 如何设置栈:让 C 代码可以运行
- 如何初始化硬件:中断控制器、定时器等
- 如何初始化内存:页表、分配器
第一阶段:硬件启动与 OpenSBI¶
QEMU 的启动过程¶
1. QEMU 初始化虚拟硬件
2. 加载 OpenSBI 固件到 0x80000000
3. 设置 CPU 的 PC = 0x80000000
4. CPU 开始执行 OpenSBI(M-Mode)
OpenSBI 的作用¶
OpenSBI 是 RISC-V 的 M-Mode 固件,负责:
硬件初始化: - 配置中断控制器(PLIC) - 设置定时器 - 初始化串口
异常委托:
- RISC-V 允许 M-Mode 将异常委托给 S-Mode
- OpenSBI 设置 medeleg 和 mideleg 寄存器
- 这样内核(S-Mode)可以直接处理中断
加载内核:
- OpenSBI 从 QEMU 的 -kernel 参数读取内核路径
- 将内核加载到 0x80200000
切换到 S-Mode:
1. 设置 mepc = 0x80200000(内核入口)
2. 设置 mstatus.mpp = S-Mode
3. 设置 a0 = hartid, a1 = fdt 地址
4. 执行 mret 指令
mret 会:
- 跳转到 mepc 指向的地址
- 切换到 mstatus.mpp 指定的特权级
为什么需要 OpenSBI?¶
直接在 M-Mode 运行内核是可行的,但:
- M-Mode 代码难以调试
- 需要处理更多硬件细节
- 不符合 RISC-V 的设计哲学
OpenSBI 提供了标准的 M-Mode 服务,让内核专注于 S-Mode 功能。
第二阶段:内核入口(汇编)¶
为什么用汇编?¶
C 函数的隐含需求: - 需要栈(局部变量、返回地址) - 需要初始化的全局变量(.data 段) - 需要清零的 BSS 段
但在启动时: - 没有设置栈指针 - BSS 段未清零
所以必须用汇编完成这些准备工作。
_entry 的工作¶
1. 计算栈位置
sp = boot_stack_top - hartid * STACK_SIZE
2. 调用 main 函数
call main
关键设计:每个 CPU 核有独立的栈。
为什么?如果多核共享栈: - CPU 0 开始执行,使用栈顶 - CPU 1 也开始执行,也使用栈顶 - 冲突!数据互相覆盖
通过 hartid 计算偏移,每个核有独立的栈区域。
为什么先设置栈再清 BSS?¶
因为:
- clear_bss 是 C 函数,需要栈
- 必须先设置栈,才能调用 C 函数
第三阶段:main 函数初始化¶
初始化的依赖关系¶
初始化顺序由依赖关系决定:
init_cpu
│ 初始化 CPU 结构,设置 tp 寄存器
│
pm_init
│ 初始化物理内存管理
│ 后续的 kmalloc 才能工作
│
plic_init
│ 初始化中断控制器
│ 后续才能处理外部中断
│
trap_init
│ 设置中断向量
│ 后续中断才能被处理
│
timer_init
│ 初始化定时器
│ 设置第一次定时器中断
│
kvminit
│ 创建内核页表
│ 开启虚拟内存
│
init_runq
│ 初始化运行队列
│ 后续才能调度
│
blk_init / bcache_init / virtio_init
│ 初始化块设备和缓存
│ 后续才能访问磁盘
│
vfs_init / ramfs_init
│ 初始化文件系统
│ 后续才能操作文件
为什么是这个顺序?¶
例子 1:为什么 pm_init 在 kmalloc 之前?
因为 kmalloc 需要分配器工作,分配器由 pm_init 初始化。
例子 2:为什么 trap_init 在 timer_init 之前?
因为定时器会触发中断,中断需要 trap_init 设置的处理函数。
例子 3:为什么 sched_yield 在最后?
因为调度需要:
- 运行队列(init_runq)
- 当前进程状态(init_cpu)
- 中断处理(trap_init)
重要的标志:sched_enabled¶
volatile bool sched_enabled = false;
这个标志控制是否允许调度。在初始化完成前为 false,避免:
- 定时器中断触发过早的调度
- 未初始化的子系统被访问
初始化完成后设为 true,调度器开始工作。
启动的关键设计¶
汇编与 C 的分界¶
汇编负责: - 设置栈指针 - 调用 C 入口函数
C 负责: - 所有初始化逻辑 - 硬件配置 - 数据结构初始化
这个分界最小化了汇编代码,提高了可维护性。
单核启动策略¶
当前只让 CPU 0 执行初始化,其他 CPU 等待:
if (hartid == 0) {
// 初始化
} else {
while (1) { wfi(); }
}
为什么?简化设计。多核启动需要: - 核间同步 - 共享数据保护 - 更复杂的初始化逻辑
单核足以理解内核原理。
idle 进程的作用¶
每个 CPU 有一个内置的 idle 进程。当没有其他进程可运行时,CPU 执行 idle:
void idle_loop(void) {
while (1) {
wfi(); // 等待中断,省电
sched_yield(); // 尝试调度
}
}
wfi 让 CPU 进入低功耗状态,直到中断到来。这比空循环更节能。
BSS 段清零的必要性¶
C 标准规定未初始化的全局变量值为 0。但内存加载后内容是随机的,必须手动清零。
不清零的后果:
int counter; // 应该是 0,实际是随机值
if (counter == 0) { /* 可能不执行 */ }
启动时的内存状态¶
启动前¶
0x80000000: OpenSBI 代码
0x80200000: 内核镜像(刚加载)
0x80XXXXXX: 随机数据(BSS 段未清零)
启动后¶
0x80000000: OpenSBI 代码
0x80200000: 内核镜像
.text: 代码
.rodata: 只读数据
.data: 已初始化数据
.bss: 已清零
0x80XXXXXX: 页表、内核栈、分配器
谁设置了什么?¶
- OpenSBI:加载内核镜像
- 内核汇编:设置栈
- 内核 C:清 BSS,初始化内存管理
多核启动的挑战¶
当前缺失的功能¶
多核启动需要:
核间中断(IPI):
- CPU 0 完成初始化后唤醒其他核
- 通过 SBI 的 sbi_send_ipi
Per-CPU 变量: - 每个核有自己的变量副本 - 避免锁竞争
同步机制: - 确保多核访问共享数据的正确性
多核启动流程(概念)¶
1. CPU 0 启动,其他核等待
2. CPU 0 完成初始化
3. CPU 0 发送 IPI 唤醒其他核
4. 其他核设置自己的栈和 tp
5. 其他核初始化自己的中断
6. 所有核参与调度
常见启动问题¶
1. 内核未加载¶
症状:QEMU 启动后无输出
原因: - 内核路径错误 - 内核格式错误
调试:检查 -kernel 参数和 kernel.asm
2. 栈溢出¶
症状:随机崩溃
原因:初始化函数调用层次深,或局部变量大
解决:增大 BOOT_STACK_SIZE
3. 初始化顺序错误¶
症状:访问空指针或死锁
原因:使用未初始化的模块
调试:在初始化函数中添加日志
4. 中断过早触发¶
症状:在 trap_init 之前崩溃
原因:硬件中断(如定时器)过早触发
解决:确保 trap_init 尽早执行
设计权衡¶
为什么用 OpenSBI 而不是自己写 M-Mode 代码?¶
OpenSBI 优点: - 成熟稳定 - 标准接口 - 支持多平台
自己实现的代价: - 工作量大 - 难以调试 - 不通用
为什么单核而不是多核?¶
教学目的:单核足以理解核心概念
多核复杂度: - 并发问题 - 缓存一致性 - 调试困难
为什么用简单的 FIFO 调度?¶
简单:易于理解和实现
公平:每个进程平等获得 CPU
代价:无法区分优先级
思考题¶
- 如果忘记清零 BSS 段,会出现什么问题?如何调试?
- 为什么 OpenSBI 需要设置异常委托?
- 如果初始化顺序错误,会发生什么?
- 多核启动时,CPU 0 如何通知其他核初始化完成?