在 Rust 裸机内核中实现架构计时器抽象:rust-raspberrypi-OS-tutorials 教程 07(时间戳)源码解析
【免费下载链接】rust-raspberrypi-OS-tutorials:books: Learn to write an embedded OS in Rust :crab:项目地址: https://gitcode.com/gh_mirrors/ru/rust-raspberrypi-OS-tutorials
导读
本教程(07_timestamps)为 rust-raspberrypi-OS-tutorials 系列的第 7 步,核心任务是为裸机内核引入计时器硬件抽象层:在通用层src/time.rs定义TimeManager接口,并在架构层src/_arch/aarch64/time.rs基于 ARM 通用计时器(Generic Timer)完成实现。随后,这套计时器能力被用于两处关键改造——为 UART 打印输出添加启动时间戳(info!/warn!宏),以及替换 GPIO 驱动中基于 CPU 周期数的粗糙延迟,使延迟精度从"猜测的周期数"提升到"精确到微秒"。读完本文,你将掌握 ARMv8-A 架构计时器的寄存器读取、频率获取、无符号运算转换等底层细节,并理解如何在内核中设计"通用接口 + 架构实现"的抽象模式。
一、为什么需要计时器抽象:从周期延迟到时间戳
在上一教程(06_uart_chainloader)中,内核的计时手段非常原始:GPIO 驱动在配置 PL011 UART 引脚上下拉电阻时,依赖cpu::spin_for_cycles(n)这种基于 NOP 指令数量的循环延迟。开发者不得不根据 CPU 主频"猜"一个周期数,代码注释里甚至写着"educated guess"(有根据的猜测),且延迟值随 CPU 频率变化而漂移,精度无法保证。
本教程引入的计时器抽象一次性解决了两个问题:
- 精确延迟:用
time::time_manager().spin_for(Duration::from_micros(1))替代"猜测 2000 个周期约等于 1 微秒"的写法,延迟值与 CPU 频率解耦,在任何主频下都精确。 - 时间戳:让所有内核日志输出自动携带
[ 秒.微秒]前缀,例如[ 0.143123] mingo version 0.7.0,这对调试启动流程、测量各阶段耗时极具价值。
这一改造的完整 diff 可见英文版文档 README.md(中文版文档明确说明英文版本为最新 diff 来源)。
二、架构总览:通用接口 + 架构实现的两层设计
本项目采用一致的模块组织原则(在 src/main.rs 的模块文档中有详细描述):通用子系统代码放在src顶层,架构相关代码放在src/_arch/aarch64/,通过 Rust 的path属性按编译目标条件引入。
计时器子系统正是这一模式的典型示例:
src/time.rs # 通用计时器接口(TimeManager) src/_arch/aarch64/time.rs # ARM 架构计时器实现(以 crate::time::arch_time 模块路径被引入)在 src/time.rs 中可以看到引入方式:
#[cfg(target_arch = "aarch64")] #[path = "_arch/aarch64/time.rs"] mod arch_time;随后,TimeManager的方法全部转发到arch_time模块,调用方无需关心底层是哪个架构:
pub fn resolution(&self) -> Duration { arch_time::resolution() } pub fn uptime(&self) -> Duration { arch_time::uptime() } pub fn spin_for(&self, duration: Duration) { arch_time::spin_for(duration) }TimeManager是一个零大小(ZST)类型,通过static TIME_MANAGER: TimeManager = TimeManager::new();构造全局单例,并用time_manager()返回其引用(见 src/time.rs)。这是本项目后续章节反复使用的全局实例模式。
从命名空间上看,整个计时器子系统对外暴露为crate::time::*,内核其他模块只需调用crate::time::time_manager()即可,无需关心架构细节。
三、ARM 架构计时器实现原理(src/_arch/aarch64/time.rs)
3.1 核心寄存器与数据结构
ARMv8-A 架构提供了系统级计时器(Generic Timer),本实现使用两个关键寄存器:
| 寄存器 | 作用 |
|---|---|
CNTFRQ_EL0 | 计时器计数频率(Hz),由固件/硬件配置,例如 Raspberry Pi 3 上典型值为 19.2 MHz(19200000 Hz) |
CNTPCT_EL0 | 物理计数器当前值,自设备上电(更准确地说自计数器使能)以来单调递增 |
代码中定义了一个包装类型GenericTimerCounterValue(u64)来表示原始计数器值(src/_arch/aarch64/time.rs),并为其实现了:
Add:使用wrapping_add实现加法,保证计数器回绕时也不产生 panic(L58-L64);From<GenericTimerCounterValue> for Duration:把原始计数值换算成标准库Duration;TryFrom<Duration> for GenericTimerCounterValue:反向换算,用于把"要延迟的时长"转换成"需要等待的计数器增量"。
3.2 计数值与 Duration 的双向换算
计数值 → Duration(From实现,L66-L89):
let secs = counter_value.0.div(frequency); let sub_second_counter_value = counter_value.0 % frequency; let nanos = unsafe { sub_second_counter_value.unchecked_mul(u64::from(NANOSEC_PER_SEC)) } .div(frequency) as u32; Duration::new(secs, nanos)其中NANOSEC_PER_SEC = 1_000_000_000。整秒部分由计数值 / 频率得到;亚秒部分(纳秒)先取余数再乘以 10 亿后除以频率。注释中论证了这里使用unchecked_mul的安全性:因为频率最大不超过u32::MAX,余数必然小于u32::MAX,因此余数 × 10⁹不会溢出u64;且最终除法结果一定小于 10 亿,可以安全地as u32转换。
Duration → 计数值(TryFrom实现,L95-L118):
if duration < resolution() { return Ok(GenericTimerCounterValue(0)); } if duration > max_duration() { return Err("Conversion error. Duration too big"); } let counter_value = unsafe { duration.unchecked_mul(frequency) }.div(NonZeroU128::from(NANOSEC_PER_SEC));两个边界检查值得注意:
- 下限:若请求的时长小于计时器分辨率(即一个计数周期),直接返回 0——这会导致
spin_for变为"立即返回"(实际上调用方会先收到一个warn!,见下文); - 上限:若时长超过
GenericTimerCounterValue::MAX(u64::MAX个计数周期)对应的时长,返回错误"Conversion error. Duration too big"。
换算在u128空间中进行以避免溢出:时长(纳秒)× 频率 ≤ Duration::MAX.as_nanos() × u32::MAX < u128::MAX,同样论证了安全性。
3.3 计时器分辨率
pub fn resolution() -> Duration { Duration::from(GenericTimerCounterValue(1)) }即一个计数器 tick 对应的时间。在启动日志中打印的Architectural timer resolution: 52 ns正是这一数值(19.2 MHz 频率下,1/19200000 秒 ≈ 52.08 ns)。
3.4 读取计数器:ISB 屏障防止乱序
#[inline(always)] fn read_cntpct() -> GenericTimerCounterValue { // Prevent that the counter is read ahead of time due to out-of-order execution. barrier::isb(barrier::SY); let cnt = CNTPCT_EL0.get(); GenericTimerCounterValue(cnt) }read_cntpct在读取前执行一条全同步的 ISB(Instruction Synchronization Barrier)指令,防止处理器乱序执行导致计数器被"提前"读取,从而保证读取时刻的准确性。isb(barrier::SY)来自aarch64_cpucrate(依赖声明见 Cargo.toml)。
3.5 spin_for:忙等实现
pub fn spin_for(duration: Duration) { let curr_counter_value = read_cntpct(); let counter_value_delta: GenericTimerCounterValue = match duration.try_into() { Err(msg) => { warn!("spin_for: {}. Skipping", msg); return; } Ok(val) => val, }; let counter_value_target = curr_counter_value + counter_value_delta; // Busy wait. // Read CNTPCT_EL0 directly to avoid the ISB that is part of [`read_cntpct`]. while GenericTimerCounterValue(CNTPCT_EL0.get()) < counter_value_target {} }逻辑清晰:记录当前计数值 → 把时长换算成计数器增量 → 计算目标值 → 忙等直到CNTPCT_EL0超过目标。两个细节:
- 转换失败处理:当请求时长小于分辨率或过大时,
try_into()返回错误,spin_for会调用新增的warn!宏输出警告并跳过延迟。启动日志中的[W 0.145469] Spin duration smaller than architecturally supported, skipping正是内核在kernel_main里刻意测试spin_for(Duration::from_nanos(1))(1 纳秒远小于 52 ns 分辨率)的产物(见 src/main.rs)。 - 忙等循环直接读寄存器:为避免每次循环都执行 ISB 带来的开销,循环体内直接调用
CNTPCT_EL0.get(),绕开read_cntpct中的屏障。
四、计数器频率的获取:汇编启动代码写入全局变量
一个关键问题是:arch_time里的换算依赖CNTFRQ_EL0的频率值,但这个值如何进入 Rust 侧?答案在启动汇编代码 src/_arch/aarch64/cpu/boot.s 中:
// Read the CPU's timer counter frequency and store it in ARCH_TIMER_COUNTER_FREQUENCY. // Abort if the frequency read back as 0. ADR_REL x1, ARCH_TIMER_COUNTER_FREQUENCY // provided by aarch64/time.rs mrs x2, CNTFRQ_EL0 cmp x2, xzr b.eq .L_parking_loop str w2, [x1]流程为:_start完成 BSS 清零、栈指针设置后,读取CNTFRQ_EL0,若为 0(异常情况)则进入停车循环(wfe挂起),否则把频率值写入 Rust 侧声明的全局变量:
#[no_mangle] static ARCH_TIMER_COUNTER_FREQUENCY: NonZeroU32 = NonZeroU32::MIN;Rust 侧通过core::ptr::read_volatile读取该变量(src/_arch/aarch64/time.rs),并注释说明使用 volatile 读取是为了防止编译器把这个"从未被 Rust 代码写入"的静态变量优化掉。同时,本教程的启动代码还从绝对寻址(ADR_ABS)全面切换为 PC 相对寻址(ADR_REL),并删除了上一教程中的二进制重定位逻辑——这意味着07_timestamps的镜像可以像教程 05 之前那样直接从固件默认加载地址运行,这正是它能作为 chainboot 目标二进制被直接加载的前提。
五、带时间戳的打印宏:info! 与新增的 warn!
5.1 宏实现剖析
本教程在 src/print.rs 中新增了两个带时间戳的打印宏info!与warn!。以info!为例:
#[macro_export] macro_rules! info { ($string:expr) => ({ let timestamp = $crate::time::time_manager().uptime(); $crate::print::_print(format_args_nl!( concat!("[ {:>3}.{:06}] ", $string), timestamp.as_secs(), timestamp.subsec_micros(), )); }); ($format_string:expr, $($arg:tt)*) => ({ let timestamp = $crate::time::time_manager().uptime(); // ... 带格式化参数的版本,追加 $($arg)* }) }关键点:
- 宏先调用
time_manager().uptime()获取启动以来(含固件与 bootloader 消耗的时间)的时长; - 使用
concat!拼接格式前缀"[ {:>3}.{:06}] ":{:>3}右对齐 3 位秒数,{:06}补齐 6 位微秒,从而得到[ 0.143123]这样的输出; - 通过
$crate::time::...路径调用,保证宏在 crate 外部展开时也能正确定位模块。
warn!宏与info!结构完全相同,只是前缀改为[W {:>3}.{:06}],用W标识警告级别。
5.2 输出效果
启动日志完美展示了两种宏的效果(来自 README 的 chainboot 运行输出):
[ 0.143123] mingo version 0.7.0 [ 0.143323] Booting on: Raspberry Pi 3 [ 0.143778] Architectural timer resolution: 52 ns [ 0.144352] Drivers loaded: [ 0.144688] 1. BCM PL011 UART [ 0.145110] 2. BCM GPIO [W 0.145469] Spin duration smaller than architecturally supported, skipping [ 0.146313] Spinning for 1 second [ 1.146715] Spinning for 1 second [ 2.146938] Spinning for 1 second可以清晰看到info!输出以[开头,warn!以[W开头;kernel_main的主循环每隔 1 秒打印一次Spinning for 1 second,时间戳严格递增约 1 秒,直观验证了计时器的准确性(src/main.rs)。
panic_wait.rs中的 panic 输出也同样加上了时间戳前缀(见英文版 README 的 diff),确保内核崩溃时也能定位到发生时刻。
六、GPIO 驱动的精度改进
本教程消除了上一教程中基于周期数的延迟。对比 src/bsp/device_driver/bcm/bcm2xxx_gpio.rs 的disable_pud_14_15_bcm2837:
之前的做法(教程 06):根据 Wikipedia 上 RPi 主频"猜测"延迟为 2000 个周期,注释坦承这是 "educated guess",并假设 CPU 以 2 GHz 运行时约等于 1 微秒——主频变化就会失准。
本教程的做法:
// The Linux 2837 GPIO driver waits 1 µs between the steps. const DELAY: Duration = Duration::from_micros(1); self.registers.GPPUD.write(GPPUD::PUD::Off); time::time_manager().spin_for(DELAY); self.registers .GPPUDCLK0 .write(GPPUDCLK0::PUDCLK15::AssertClock + GPPUDCLK0::PUDCLK14::AssertClock); time::time_manager().spin_for(DELAY);现在延迟直接声明为1 µs,参考 Linux BCM2837 GPIO 驱动的步进间隔,由硬件计时器精确保证,与 CPU 频率无关——这正是 README 中所说的 "boosts accuracy"(提升准确性)。同时,旧的cpu::spin_for_cycles函数及其在 src/_arch/aarch64/cpu.rs 与 src/cpu.rs 中的 re-export 被一并删除。
此外,UART 驱动bcm2xxx_pl011_uart.rs中read_char更名为read_char_converting,新增了回车符(\r)到换行符(\n)的转换,配合 chainboot 串口终端使用。
七、运行与验证
7.1 在真实硬件上测试(chainboot)
本教程沿用了上一教程引入的 chainboot 流程,通过minipush将编译好的内核镜像直接推送到树莓派并执行(无需 SD 卡):
$ make chainbootMakefile(Makefile)中chainboot目标定义为:
chainboot: $(KERNEL_BIN) @$(DOCKER_CHAINBOOT) $(EXEC_MINIPUSH) $(DEV_SERIAL) $(KERNEL_BIN)默认BSP = rpi3、DEV_SERIAL = /dev/ttyUSB0,可通过命令行覆盖(如BSP=rpi4 make chainboot)。整个交互流程为:Minipush 等待串口设备 → 提示给目标板上电 → 请求并推送二进制(日志显示Pushing 12 KiB)→ 目标加载执行 → 串口输出带时间戳的内核日志(即第五节展示的内容)。
7.2 自动化启动测试
本教程同时加入了自动化启动测试。测试期望字符串定义在 tests/boot_test_string.rb:
EXPECTED_PRINT = 'Spinning for 1 second'即验证内核成功启动并进入 1 秒循环。运行方式:
$ make test # 等价于 make test_boottest_boot目标通过../common/tests/dispatch.rb调度脚本启动 QEMU(-M raspi3)并匹配输出(见 Makefile)。注意该 Makefile 相比上一教程引入了两个变化:测试调度统一走common/tests/dispatch.rb,并且chainboot现在直接推送编译出的kernel8.img而非预制的 demo payload。若选择rpi4,则QEMU_MACHINE_TYPE为空,make test会提示 "This board is not yet supported for QEMU."。
7.3 版本与依赖
本教程的 crate 版本升级到0.7.0(见 Cargo.toml),并新增了若干实验性 feature 声明:const_option、nonzero_min_max、unchecked_math(NonZeroU32::MIN、unchecked_mul等依赖这些 nightly feature,见 src/main.rs)。目标平台为aarch64-unknown-none-softfloat,CPU 为cortex-a53(rpi3)或cortex-a72(rpi4)。
八、小结
教程 07 为内核建立了可复用的时间子系统:
- 抽象层:
TimeManager通用接口(src/time.rs)+ ARM Generic Timer 实现(src/_arch/aarch64/time.rs),通过CNTPCT_EL0计数值与Duration的精确双向换算提供uptime()、resolution()、spin_for()三个能力; - 频率来源:启动汇编(src/_arch/aarch64/cpu/boot.s)在跳转 Rust 前把
CNTFRQ_EL0写入 Rust 静态变量; - 日志升级:
info!/warn!宏为所有内核输出添加[ 秒.微秒]时间戳(src/print.rs); - 精度提升:GPIO 驱动的微秒级延迟不再依赖 CPU 频率猜测(src/bsp/device_driver/bcm/bcm2xxx_gpio.rs)。
这套"通用接口 + 架构实现 + 全局单例"的模式将在后续教程中被反复使用——从异常处理、MMU 到时钟回调(timer callbacks),计时器子系统始终是内核基础设施的核心组成部分。你可以通过make qemu在 QEMU 中快速观察带时间戳的启动日志,或通过make chainboot在真实树莓派上体验。
【免费下载链接】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),仅供参考