4. 最小内核:编写入口汇编代码、设置栈指针、跳转到Rust入口点

好,咱们现在正式开始动手写内核了。

上一章我们搭好了交叉编译环境,也配置好了链接脚本。但说实话,那些都是准备工作。真正的内核代码,从这一章才开始。

我个人习惯,写内核的第一步永远是汇编入口。为什么?因为CPU上电后,C语言环境还没建立。没有栈指针,没有BSS段清零,你连一个全局变量都不敢碰。这时候,只有汇编能救你。

4.1 为什么第一行代码必须是汇编

你想想看,Rust也好,C也好,它们都依赖一个基本的运行时环境。至少需要:

  • 栈指针(SP)指向可用的内存区域
  • BSS段被清零(全局未初始化变量)
  • 异常向量表被设置

这些事,高级语言做不了。或者说,在它们能运行之前,这些事必须已经做完。

核心原则:汇编代码负责"造环境",Rust代码负责"干正事"。

我在项目中遇到过好几次这样的情况:有人把初始化逻辑全写在Rust里,结果一运行就崩。查了半天,发现是全局变量没初始化,因为BSS段还没清零。嗯,这种坑踩过一次就记住了。

4.2 入口汇编代码的结构

我们以ARMv8-A架构(64位)为例。这是目前嵌入式领域和服务器领域都很常见的架构。我们的微内核跑在Raspberry Pi 3上,用的就是它。

先看完整代码,我再逐行解释:

// boot.S - 内核入口汇编代码
.section ".text.boot"

.globl _start
_start:
    // 1. 读取当前CPU的ID
    mrs     x0, mpidr_el1
    and     x0, x0, #0xFF
    cbz     x0, primary_cpu

    // 2. 非主CPU进入休眠
secondary_halt:
    wfe
    b       secondary_halt

primary_cpu:
    // 3. 设置栈指针
    ldr     x0, =_stack_top
    mov     sp, x0

    // 4. 清零BSS段
    ldr     x0, =_bss_start
    ldr     x1, =_bss_end
    sub     x1, x1, x0
    cbz     x1, bss_done

bss_loop:
    str     xzr, [x0], #8
    subs    x1, x1, #8
    b.ne    bss_loop

bss_done:
    // 5. 跳转到Rust入口
    bl      rust_main

    // 6. 如果返回,则死循环
halt:
    wfe
    b       halt

这段代码不长,但每一行都是关键。我们来拆解一下。

4.3 多核处理器的启动策略

现在的CPU基本都是多核的。上电时,所有核心都会从同一个入口开始执行。但你不能让所有核心都去干同一件事——那不乱套了?

所以第一步,就是区分主核和从核。

mrs     x0, mpidr_el1
and     x0, x0, #0xFF
cbz     x0, primary_cpu

mpidr_el1 寄存器保存了当前CPU的ID。我们取低8位,如果为0,就是主核(primary CPU),否则就是从核。

从核怎么办?让它们进入WFI(Wait For Interrupt)状态,原地待命。等主核初始化完内存、中断控制器之后,再通过核间中断唤醒它们。

小技巧:我习惯在从核的休眠循环里加一个内存屏障指令,防止编译器优化掉这个循环。虽然这里没写,但实际项目中我会加上 dmb sy。

4.4 设置栈指针——最不能出错的一步

栈指针的设置,说白了就是告诉CPU:"你的临时工作区在这里。"

如果栈指针指向了不存在的内存地址,或者指向了代码段,那函数调用、局部变量、异常处理全都会崩。而且崩得毫无征兆。

ldr     x0, =_stack_top
mov     sp, x0

这里的 _stack_top 是在链接脚本里定义的。我们上一章配置的链接脚本中,把栈放在了内存的高地址区域,向下生长。

为什么向下生长?因为ARM的栈是满递减栈(Full Descending)。压栈时SP减小,出栈时SP增大。所以栈顶要放在高地址,栈底在低地址。

警告:千万不要把栈指针设置在BSS段或数据段附近。栈向下生长时,可能会覆盖掉你的全局变量。我曾经见过一个案例,栈溢出把内核的页表给覆盖了,系统跑着跑着就随机崩溃。查了三天才找到原因。

4.5 清零BSS段——Rust能安全运行的前提

Rust要求所有变量在使用前必须初始化。但全局变量和静态变量,在编译时会被放到BSS段(未初始化)或数据段(已初始化)。

BSS段在二进制文件中不占空间,但加载到内存后,必须清零。否则那些"未初始化"的全局变量,里面全是随机值。

ldr     x0, =_bss_start
ldr     x1, =_bss_end
sub     x1, x1, x0
cbz     x1, bss_done

bss_loop:
    str     xzr, [x0], #8
    subs    x1, x1, #8
    b.ne    bss_loop

这段代码的逻辑很简单:从 _bss_start 到 _bss_end,每8字节写一个0。用 xzr 寄存器(ARM64的零寄存器)来写,效率最高。

这里有个细节:str xzr, [x0], #8 是后变址寻址。先把 x0 指向的地址写入0,然后 x0 自增8。一条指令搞定读写和指针移动。

4.6 跳转到Rust入口点

环境准备好了,终于可以进入Rust的世界了。

bl      rust_main

bl 是"Branch with Link",会跳转到 rust_main 函数,同时把返回地址保存在 x30(链接寄存器)中。

如果 rust_main 返回了,说明内核执行完毕。但微内核不应该退出,所以我们在后面加了一个死循环:

halt:
    wfe
    b       halt

这个循环会让CPU进入低功耗状态,同时保持响应中断的能力。

4.7 Rust端的入口函数

汇编跳转过来后,Rust这边需要做什么?

// main.rs
#![no_std]
#![no_main]

// 从汇编中调用的入口函数
#[no_mangle]
pub extern "C" fn rust_main() -> ! {
    // 初始化串口(用于打印)
    // 初始化内存管理
    // 初始化中断控制器
    // ... 后续章节会逐步实现

    loop {
        // 内核主循环
    }
}

注意几个关键点:

  • #![no_std]:不使用标准库,因为标准库依赖操作系统
  • #![no_main]:不使用默认的main函数入口
  • #[no_mangle]:防止Rust对函数名进行修饰,保证汇编能正确链接
  • pub extern "C":使用C调用约定,与汇编的 bl 指令兼容
  • 返回类型 -> !:发散类型,表示这个函数永不返回

个人经验:我习惯在 rust_main 的第一行就初始化串口。这样后续的调试信息就能通过串口打印出来。没有串口输出,调试内核就像蒙着眼睛走路。后面我们会专门讲串口驱动的实现。

4.8 编译与链接验证

写完了代码,怎么验证它能不能跑?

先编译成目标文件:

aarch64-none-elf-gcc -c boot.S -o boot.o
cargo build --target aarch64-unknown-none-elf

然后链接:

aarch64-none-elf-ld -T link.ld boot.o target/aarch64-unknown-none-elf/debug/mykernel -o kernel.elf

最后检查生成的elf文件:

aarch64-none-elf-objdump -d kernel.elf

你应该能看到 _start 函数在0x80000地址(我们链接脚本里指定的起始地址),并且第一条指令就是 mrs x0, mpidr_el1。

4.9 常见陷阱与避坑指南

陷阱 后果 解决方法
栈指针未对齐 访问未对齐地址导致异常 确保SP是16字节对齐的
BSS段未清零 全局变量初始值随机 在跳转到Rust前完成清零
链接脚本中栈地址错误 栈覆盖代码或数据段 检查链接脚本的内存布局
忘记加 #[no_mangle] 链接时找不到 rust_main Rust函数名必须与汇编一致

我曾经犯过一个低级错误:在链接脚本里把栈顶地址写在了BSS段中间。结果BSS清零的时候,把栈也给清了。一调用函数就崩,查了两天才发现是链接脚本的问题。嗯,从那以后我每次都会用 objdump 检查内存布局。

4.10 本章小结

这一章我们完成了微内核的"最小骨架":

  • 用汇编处理了多核启动
  • 设置了栈指针
  • 清空了BSS段
  • 成功跳转到了Rust世界

虽然只有几十行代码,但这是整个内核的基石。后面的所有功能——内存管理、进程调度、中断处理——都建立在这个基础上。

下一章,我们会让串口输出"Hello, World!"。有了输出,调试就不再是盲人摸象了。