rust-raspberrypi-OS-tutorials 教程 18:在 AArch64 裸机内核中实现基于帧指针的 Backtrace
【免费下载链接】rust-raspberrypi-OS-tutorials:books: Learn to write an embedded OS in Rust :crab:项目地址: https://gitcode.com/gh_mirrors/ru/rust-raspberrypi-OS-tutorials
本教程讲解如何在自研 Rust 裸机内核中实现栈回溯(backtrace / stack trace)能力:借助 AArch64 调用约定中由x29(帧指针)与x30(链接寄存器)构建的栈帧链,实现一个能在 panic 时逐帧打印"地址 + 函数符号"调用链的模块,并通过编译器选项、build-std、异常入口汇编与启动代码的配合,让回溯链既完整又可校验。读完本文,你将掌握栈帧记录(Stack Frame Record)的数据结构设计、迭代器式的回溯遍历、地址合法性校验,以及如何把它集成进 panic handler 并编写对应的集成测试。
本文基于仓库中
18_backtrace教程目录,正文中的源码路径均以仓库根目录为基准,例如 kernel/src/backtrace.rs。前置教程17_kernel_symbols已在内核中实现符号名查询能力,这是本教程回溯打印"函数名"的前提。
tl;dr:一次 panic 中的回溯输出
在上一教程(内核符号表)的基础上,18_backtrace在内核中加入了 backtrace 支持。触发一次 panic(例如向地址空间底部地址写入 1 GiB 处)后,控制台会输出如下内容:
[ 0.002782] Writing to bottom of address space to address 1 GiB... [ 0.004623] Kernel panic! Panic location: File 'kernel/src/_arch/aarch64/exception.rs', line 59, column 5 [...] Backtrace: ---------------------------------------------------------------------------------------------- Address Function containing address ---------------------------------------------------------------------------------------------- 1. ffffffffc0005560 | libkernel::panic_wait::_panic_print 2. ffffffffc00054a0 | rust_begin_unwind 3. ffffffffc0002950 | core::panicking::panic_fmt 4. ffffffffc0004898 | current_elx_synchronous 5. ffffffffc0000a74 | __vector_current_elx_synchronous 6. ffffffffc000111c | kernel_init ----------------------------------------------------------------------------------------------可以看到:从最内层的_panic_print一路回溯到异常向量入口__vector_current_elx_synchronous,最终停在内核入口kernel_init。这正是本教程要实现的核心效果。
Introduction:为什么要加回溯
上一教程之后,内核已经能够通过symbols::lookup_symbol()把地址翻译成符号名(见 kernel/src/symbols.rs 中的lookup_symbol)。在此基础上实现有意义的回溯(stack trace)便水到渠成。回溯最主要的应用场景是在panic 期间打印调用链,这能大幅简化调试。选择在这个时间点加入该特性,是因为后续教程将涉及复杂主题与大量代码变更,调试利器自然越早准备越好。
Implementation:回溯代码的组织结构
回溯机制通常由"处理器架构的调用约定(calling convention)"决定,因此与体系结构强耦合,回溯代码的核心必然放在_arch目录中;而不同架构可以共享的,是格式化与打印部分。代码因此被组织成两部分:
- kernel/src/backtrace.rs:通用定义
BacktraceItem,并提供使用Iterator<Item = BacktraceItem>完成格式化和打印的代码。 - kernel/src/_arch/aarch64/backtrace.rs:生成实际迭代器的架构相关代码。
BacktraceItem的定义如下(摘自 kernel/src/backtrace.rs):
pub enum BacktraceItem { InvalidFramePointer(Address<Virtual>), InvalidLink(Address<Virtual>), Link(Address<Virtual>), }它包含两个错误情形(InvalidFramePointer、InvalidLink)和一个有效情形(Link)。为什么需要错误情形?看完"栈帧"与"帧指针"的本质后就会明白:回溯遍历的是内存中的指针链,而裸机内核的内存可能被破坏,任何一步都可能遇到非法地址,因此遍历必须对每一步做校验,并把错误显式暴露给打印端。
Chasing Frames:追逐栈帧链
AAPCS64 与 ARMv8-A 指南怎么说
对于 AArch64,需要参考 Procedure Call Standard for the Arm® 64-bit Architecture(AAPCS64)。其核心要求如下:
符合规范的代码应构造一个栈帧的链表。每个帧通过栈上的一个由两个 64 位值组成的帧记录(frame record,与数据模型无关)链接到其调用者的帧。最内层帧(属于最近一次例程调用)的帧记录应由帧指针寄存器(FP)指向。地址最低的双字(double-word)应指向上一个帧记录,地址最高的双字应包含函数入口时传入 LR 的值 [...]. 帧记录在栈帧内的位置不作规定。
再看 ARM Cortex-A Series Programmer’s Guide for ARMv8-A 的对应章节,链表结构更清晰:
一个 AAPCS64 栈帧如图 9-2 所示。帧指针(X29)应指向保存在栈上的上一个帧指针,保存的 LR(X30)紧随其后。链中最后一个帧指针应置为 0。栈指针必须始终按 16 字节边界对齐。
这正是栈帧链的图景:
用 Rust 表达栈帧记录
依据上述规范,可以在 kernel/src/_arch/aarch64/backtrace.rs 中定义如下栈帧记录结构体:
#[repr(C)] struct StackFrameRecord<'a> { previous_record: Option<&'a StackFrameRecord<'a>>, link: Address<Virtual>, }有趣的是previous_record成员。从上面两份文档可以得知,地址最低的双字要么是零,要么指向上一个栈帧记录。得益于 Rust 的空指针优化(null pointer optimization),这个成员可以非常自然地写成Option<&StackFrameRecord>:
- 值为
None表示已经到达回溯的终点(链尾); - 值为
Some(&frame)则继续沿链回溯。
迭代器:从帧指针出发
回溯的起点可以通过x29(帧指针寄存器,FP)直接获得。在 stack_frame_record_iterator 中,先对帧指针地址做一次有效性检查,再据此构造迭代器:
struct StackFrameRecordIterator<'a> { cur: &'a StackFrameRecord<'a>, } /// [...] fn stack_frame_record_iterator<'a>() -> Option<StackFrameRecordIterator<'a>> { let fp = Address::<Virtual>::new(FP.get() as usize); if !fp.is_valid_stack_addr() { return None; } Some(StackFrameRecordIterator { cur: unsafe { &*(fp.as_usize() as *const _) }, }) }虽然理论上编译器(以及任何手写汇编)应保证x29指向合法的栈地址,但在生成引用之前做一次检查依然有意义——内存损坏随时可能发生。迭代器自身的next()实现同样会在每次推进时做健全性检查,并且在把link地址交给调用方之前,还会确认它位于内核合法的code段内(kernel/src/_arch/aarch64/backtrace.rs):
impl<'a> Iterator for StackFrameRecordIterator<'a> { type Item = BacktraceItem; fn next(&mut self) -> Option<Self::Item> { static ABORT_FRAME: StackFrameRecord = StackFrameRecord { previous_record: None, link: Address::new(0), }; // If previous is None, this is the root frame, so iteration will stop here. let previous = self.cur.previous_record?; // Need to abort if the pointer to the previous frame record is invalid. let prev_addr = Address::<Virtual>::new(previous as *const _ as usize); if !prev_addr.is_valid_stack_addr() { // This allows to return the error and then stop on the next iteration. self.cur = &ABORT_FRAME; return Some(BacktraceItem::InvalidFramePointer(prev_addr)); } let ret = if !self.cur.link.is_valid_code_addr() { Some(BacktraceItem::InvalidLink(self.cur.link)) } else { // The link points to the instruction to be executed _after_ returning from a branch. // However, we want to show the instruction that caused the branch, so subtract by one // instruction. // // This might be called from panic!, so it must not panic itself on the subtraction. let link = if self.cur.link >= Address::new(4) { self.cur.link - 4 } else { self.cur.link }; Some(BacktraceItem::Link(link)) }; // Advance the iterator. self.cur = previous; ret } }这段代码有三个值得细读的点:
ABORT_FRAME哨兵帧:当检测到非法的上一帧指针时,把self.cur指向一个previous_record == None的静态哨兵帧,从而让"先返回一个InvalidFramePointer错误,下一次迭代自然停止"。- link 减 4:LR 保存的是分支返回后要执行的那条指令的地址,而回溯希望展示的是发起分支的那条指令,所以需要减掉一条指令的长度(AArch64 固定 4 字节)。同时该代码可能从
panic!中被调用,因此减法本身必须用checked_sub语义保护(这里表现为>= Address::new(4)判断,底层实现见 kernel/src/memory.rs 的Sub<usize>,其内部使用checked_sub,溢出时才会 panic)。 - 两种校验方法:
is_valid_stack_addr()检查地址是否落在引导核栈区域内,is_valid_code_addr()检查地址是否落在内核代码页区域内。两者的实现位于 kernel/src/memory.rs,分别委托给 kernel/src/bsp/raspberrypi/memory/mmu.rs 中的virt_boot_core_stack_region()与virt_code_region()(这两个区域原本是mmu.rs的私有函数,本教程将它们提升为pub,供内存模块复用)。
通用打印端:符号查找与格式化
架构部分到此就是核心了。在通用部分打印回溯时,BacktraceItem::Link返回的地址还会被用来查询对应符号,便于直接打印函数名(kernel/src/backtrace.rs):
match backtrace_res { // omitted BacktraceItem::Link(addr) => { fmt_res = writeln!( f, " {:>2}. {:016x} | {:<50}", i + 1, addr.as_usize(), match symbols::lookup_symbol(addr) { Some(sym) => sym.name(), _ => "Symbol not found", } ) } };打印格式为序号. 16 位十六进制地址 | 左对齐 50 字符的符号名。两个错误分支则分别输出:
ERROR! Encountered invalid frame pointer (...) during backtraceERROR! Link address (...) is not contained in kernel .text section
另外两个实现细节:
- 打印时
iter.skip(1):由于回溯打印本身就是从core::fmt::write开始的,第一帧必然是它,跳过以避免输出膨胀(见 kernel/src/backtrace.rs)。 - 若迭代器根本构造不出来(起始帧指针就不是合法栈地址),打印端输出
ERROR! No valid stack frame found。 - 打印通过
impl fmt::Display for Backtrace完成,Backtrace是一个零大小的"伪结构体",这样在println!中直接传入backtrace::Backtrace即可触发格式化(见 kernel/src/backtrace.rs)。
集成进 panic handler
最后,把回溯打印加入panic!(kernel/src/panic_wait.rs):
println!( "[ {:>3}.{:06}] Kernel panic!\n\n\ Panic location:\n File '{}', line {}, column {}\n\n\ {}\n\n\ {}", timestamp.as_secs(), timestamp.subsec_micros(), location, line, column, info.message().unwrap_or(&format_args!("")), backtrace::Backtrace );注意_panic_exit被声明为#[linkage = "weak"],集成测试可以覆盖它(测试构建时走cpu::qemu_exit_failure()退出,普通构建则cpu::wait_forever()),详见 kernel/src/panic_wait.rs。
Compiler Changes:强制生成帧记录
默认情况下,aarch64-unknown-none*目标并不保证每次函数调用都会生成栈帧记录——没有帧记录,回溯代码就无法工作。好在可以通过修改 rustc 的 codegen 选项强制生成。在Makefile中添加如下内容(见 Makefile):
ifeq ($(BSP),rpi3) # omitted RUSTC_MISC_ARGS = -C target-cpu=cortex-a53 -C force-frame-pointersrpi4 分支同理:RUSTC_MISC_ARGS = -C target-cpu=cortex-a72 -C force-frame-pointers。
但仅有这一项还不够!此前编译内核时,cargo 使用的是 rustup 添加目标时随附的预编译版 Rust core 库。这在编译速度上通常非常有利,但预编译版本并没有用-C force-frame-pointers编译。解决办法是使用 cargo 的 [build-std特性],在 Makefile 中设置它,让 cargo 用我们的编译器设置一并编译 core 库,从而让 core 库函数也获得帧记录:
# build-std can be skipped for helper commands that do not rely on correct stack frames and other # custom compiler options. This results in a huge speedup. RUSTC_CMD = cargo rustc $(COMPILER_ARGS) -Z build-std=core --manifest-path $(KERNEL_MANIFEST) DOC_CMD = cargo doc $(COMPILER_ARGS) CLIPPY_CMD = cargo clippy $(COMPILER_ARGS) TEST_CMD = cargo test $(COMPILER_ARGS) -Z build-std=core --manifest-path $(KERNEL_MANIFEST)注意:build-std目前仍是 nightly 特性,需要通过-Z标志开启;DOC_CMD与CLIPPY_CMD不依赖正确的栈帧,刻意保持原样以换取构建速度(这也是 Makefile 注释里"helper commands 可以跳过 build-std"的原因)。完整的命令构建区见 Makefile。
Supporting Changes:配套改动速览
README 没有在正文中展开、但值得浏览的配套改动包括:
- kernel/src/_arch/aarch64/exception.s:在异常入口处构建栈帧记录,文件内含大量详细注释。核心逻辑(
CALL_WITH_CONTEXT宏):- 异常上下文保存区从
16 * 17扩大到16 * 18个双字,新增的第 18 个双字用于存放栈帧记录(x29+ 返回地址,见 exception.s)。 - 若异常来自更低特权级(
is_lower_el == 1),直接存储xzr, xzr把它做成根帧,防止内核回溯进入用户空间。 - 若异常发生在当前特权级,则用
ELR_EL1(而非 LR)构建栈帧,从而可以"穿越异常"继续回溯。但ELR_EL1的语义随异常类型而变:除非执行的是"产生异常的指令"(exception generating instruction),ELR_EL1已经指向正确指令,此时回溯代码里的"减 4"会得到错误结果。为此汇编里先检查ESR_EL1.EC是否为SVC64(0x15),不是 SVC 的情况下先对ELR_EL1加 4再存入帧记录(BRK/HLT也属于"产生异常的指令",但当前不预期出现,暂未处理)。相关常量通过global_asm!从 kernel/src/_arch/aarch64/exception.rs 传入。
- 异常上下文保存区从
- kernel/src/_arch/aarch64/cpu/boot.rs:新增
prepare_backtrace_reset(),在进入 EL1 前把FP与LR都清零(并配compiler_fence(Ordering::SeqCst)防止重排),从而保证kernel_init()成为回溯链的根——即其帧记录的previous_record必然为None。 - 根目录 Cargo.toml 中设置了
debug = true,确保内核 ELF 携带最大量的调试信息。注意这不会改变内核运行时的任何行为,但它允许你在拿到回溯报告的地址后用addr2line挖得更深。对比一下debug关闭与打开时的差异:
$ # debug = false $ addr2line -p -f -s -i -e target/aarch64-unknown-none-softfloat/release/kernel+ttables+symbols 0xffffffffc0001da8 | rustfilt kernel::kernel_main at kernel.c562062a-cgu.1:?$ # debug = true $ addr2line -p -f -s -i -e target/aarch64-unknown-none-softfloat/release/kernel+ttables+symbols 0xffffffffc0001da8 | rustfilt libkernel::memory::mmu::mapping_record::MappingRecord::print at mapping_record.rs:136 (inlined by) libkernel::memory::mmu::mapping_record::kernel_print::{{closure}} at mapping_record.rs:232 (inlined by) <libkernel::synchronization::InitStateLock<T> as libkernel::synchronization::interface::ReadWriteEx>::read at synchronization.rs:139 (inlined by) libkernel::memory::mmu::mapping_record::kernel_print at mapping_record.rs:232 (inlined by) libkernel::memory::mmu::kernel_print_mappings at mmu.rs:269 (inlined by) kernel::kernel_main at main.rs:84debug = true之后,addr2line甚至能还原内联展开(inlined by)的完整调用链。命令中-e指定的kernel+ttables+symbols正是 Makefile 中KERNEL_ELF = $(KERNEL_ELF_TTABLES_SYMS)的产物(Makefile),rustfilt用于把 Rust 符号解混淆。
另外,配套的tools/translation_table_tool(tools/translation_table_tool/generic.rb)也做了小幅增强:支持带下划线十六进制字面量(如0x0123_abcd)的解析,并将分区大小输出改为可读的 MiB/KiB 格式。
Test it:验证回溯的正确性
本教程新增了三个针对回溯代码健全性的集成测试;同时,所有此前会打印 panic 的测试现在也会顺带打印回溯。例如02_exception_sync_page_fault.rs:
$ TEST=02_exception_sync_page_fault make test_integration [...] ------------------------------------------------------------------- 🦀 Testing synchronous exception handling by causing a page fault ------------------------------------------------------------------- [ 0.002782] Writing to bottom of address space to address 1 GiB... [ 0.004623] Kernel panic! Panic location: File 'kernel/src/_arch/aarch64/exception.rs', line 59, column 5 CPU Exception! ESR_EL1: 0x96000004 Exception Class (EC) : 0x25 - Data Abort, current EL Instr Specific Syndrome (ISS): 0x4 FAR_EL1: 0x0000000040000000 [...] Backtrace: ---------------------------------------------------------------------------------------------- Address Function containing address ---------------------------------------------------------------------------------------------- 1. ffffffffc0005560 | libkernel::panic_wait::_panic_print 2. ffffffffc00054a0 | rust_begin_unwind 3. ffffffffc0002950 | core::panicking::panic_fmt 4. ffffffffc0004898 | current_elx_synchronous 5. ffffffffc0000a74 | __vector_current_elx_synchronous 6. ffffffffc000111c | kernel_init ---------------------------------------------------------------------------------------------- ------------------------------------------------------------------- ✅ Success: 02_exception_sync_page_fault.rs -------------------------------------------------------------------三个新增测试(每个均有一对 Rust 测试内核 + Ruby 断言脚本,注册在 kernel/Cargo.toml 的[[test]]段中):
05_backtrace_sanity(kernel/tests/05_backtrace_sanity.rs + kernel/tests/05_backtrace_sanity.rb):nested()中直接panic!(),验证 panic 产生回溯,且回溯中依次出现| core::panicking::panic、| _05_backtrace_sanity::nested与| kernel_init,证明整条链正确。06_backtrace_invalid_frame(kernel/tests/06_backtrace_invalid_frame.rs + kernel/tests/06_backtrace_invalid_frame.rb):调用backtrace::corrupt_previous_frame_addr()(仅test_buildfeature 下导出,见 kernel/src/backtrace.rs 与 kernel/src/_arch/aarch64/backtrace.rs)把当前帧的previous_record覆写为0x123,验证输出Encountered invalid frame pointer (.*) during backtrace。07_backtrace_invalid_link(kernel/tests/07_backtrace_invalid_link.rs + kernel/tests/07_backtrace_invalid_link.rb):调用backtrace::corrupt_link()(kernel/src/_arch/aarch64/backtrace.rs)把当前帧的 link 覆写为0x456,验证输出Link address (.*) is not contained in kernel .text section。
运行方式与既有测试一致,例如:
$ TEST=05_backtrace_sanity make test_integration $ TEST=06_backtrace_invalid_frame make test_integration $ TEST=07_backtrace_invalid_link make test_integrationDiff to previous:与上一教程的改动全貌
以下是17_kernel_symbols与18_backtrace之间的核心差异(完整内容见 README 的 "Diff to previous" 一节,此处摘录要点):
diff -uNr 17_kernel_symbols/Cargo.toml 18_backtrace/Cargo.toml [profile.release] lto = true +debug = true diff -uNr 17_kernel_symbols/kernel/Cargo.toml 18_backtrace/kernel/Cargo.toml [package] name = "mingo" -version = "0.17.0" +version = "0.18.0" [[test]] name = "03_exception_restore_sanity" harness = false + +[[test]] +name = "05_backtrace_sanity" +harness = false + +[[test]] +name = "06_backtrace_invalid_frame" +harness = false + +[[test]] +name = "07_backtrace_invalid_link" +harness = false其他主要改动文件包括:
- 新增 kernel/src/backtrace.rs(通用回溯模块)与 kernel/src/_arch/aarch64/backtrace.rs(架构回溯实现),并在 kernel/src/lib.rs 中公开
pub mod backtrace。 - kernel/src/memory.rs:为
Address<ATYPE>新增Sub<usize>(内部checked_sub),为Address<Virtual>新增is_valid_stack_addr()/is_valid_code_addr()。 - kernel/src/bsp/raspberrypi/memory/mmu.rs:
virt_code_region()与virt_boot_core_stack_region()由私有改为pub。 - kernel/src/panic_wait.rs:panic 输出中加入
backtrace::Backtrace。 - kernel/src/_arch/aarch64/cpu/boot.rs:
prepare_backtrace_reset()清零 FP/LR。 - kernel/src/_arch/aarch64/exception.rs 与 kernel/src/_arch/aarch64/exception.s:
global_asm!传入ESR_EL1相关常量;CALL_WITH_CONTEXT宏扩展为带is_lower_el、is_sync参数,并在异常上下文之外构建栈帧记录。 - Makefile:
RUSTC_MISC_ARGS增加-C force-frame-pointers;RUSTC_CMD/TEST_CMD增加-Z build-std=core;gdb目标不再单独设置-C debuginfo=2(因为根 Cargo.toml 已全局开启debug = true)。 - tools/translation_table_tool/generic.rb 与 tools/translation_table_tool/bsp.rb:支持带下划线十六进制字面量解析、人类可读的分区大小输出。
小结
18_backtrace为内核补上了"调试三件套"中的最后一块拼图:符号表负责把地址翻译成名字,回溯负责把散落的地址串成调用链,debug = true则让addr2line能在链上继续深挖内联细节。实现的关键在于三处协同——-C force-frame-pointers保证每个函数都有帧记录、build-std让 core 库也满足同样约定、异常入口与启动代码则确保回溯链的两端(异常向量与kernel_init)都正确封口。配合三个针对"正常链、坏帧指针、坏 link"的集成测试,这套回溯机制既实用又可验证,为后续教程中更复杂的内核特性开发提供了可靠的调试基础。
【免费下载链接】rust-raspberrypi-OS-tutorials:books: Learn to write an embedded OS in Rust :crab:项目地址: https://gitcode.com/gh_mirrors/ru/rust-raspberrypi-OS-tutorials
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考