Skip to content

启动模块

启动模块是内核执行的起点,负责从硬件启动过渡到内核正常运行。理解启动流程是理解整个内核如何协作的关键。

启动的本质问题

在内核运行之前,系统处于什么状态?

  1. 内存是空的:没有内核代码和数据
  2. CPU 在 M-Mode:最高特权级
  3. 没有栈:无法调用函数
  4. 没有页表:虚拟内存未启用

启动模块的任务是将系统从这种"原始状态"引导到内核可以正常运行的状态。

启动的三个阶段

┌─────────────────────────────────────────────┐
│  阶段 1: 硬件复位 + OpenSBI                  │
│  M-Mode,硬件初始化                          │
└─────────────────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────────┐
│  阶段 2: 内核入口 (_entry)                   │
│  S-Mode,设置栈,进入 C                      │
└─────────────────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────────┐
│  阶段 3: main 函数                           │
│  S-Mode,初始化各子系统                      │
└─────────────────────────────────────────────┘

为什么需要 OpenSBI?

直接从硬件启动内核非常困难:

  1. 需要初始化 DDR 控制器
  2. 需要配置中断控制器
  3. 需要设置异常委托
  4. 需要从 M-Mode 切换到 S-Mode

OpenSBI 是 M-Mode 固件,完成这些底层工作,让内核运行在 S-Mode。

OpenSBI 做了什么?

  1. 硬件初始化:配置 PLIC、UART 等
  2. 异常委托:将中断和异常委托给 S-Mode
  3. 加载内核:从指定地址加载内核镜像
  4. 模式切换:从 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_bsse_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 保持简单,手动清零。

思考题

  1. 如果忘记清零 BSS 段,会出现什么问题?如何调试?
  2. 多核启动时,CPU 0 如何唤醒其他 CPU?
  3. 为什么初始化顺序很重要?如果顺序错误会发生什么?
  4. idle 进程为什么要执行 sched_yield

下一步