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!"。有了输出,调试就不再是盲人摸象了。