comprehensive-rust 裸机实战:为 QEMU aarch64 编写 PL031 实时时钟(RTC)驱动
2026/9/10 13:13:09 网站建设 项目流程

comprehensive-rust 裸机实战:为 QEMU aarch64 编写 PL031 实时时钟(RTC)驱动

【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust

本篇技术指南以 comprehensive-rust 课程中src/exercises/bare-metal/rtc.md练习文档为核心,完整讲解如何为 QEMU aarch64 virt 虚拟机上的 PL031 实时时钟(Real-Time Clock)设备编写裸机驱动。通过本练习,你将掌握 MMIO 寄存器映射驱动的写法、基于chrono的时间格式化、利用 match 寄存器与 raw interrupt status 实现忙等待(busy-wait),以及进阶的 GIC 中断配置与wfi低功耗等待,最终在 QEMU 中运行完整的三阶段 RTC 驱动。

练习背景:QEMU virt 机器上的 PL031

Arm 的 PL031 是 PrimeCell 家族中一款成熟的实时时钟外设,广泛出现在 Arm 参考平台上。QEMU 的aarch64virt 虚拟机在固定地址0x9010000处暴露了一个 PL031 实时时钟。裸机程序中没有任何操作系统帮忙管理时间,因此要获得"当前时间"或实现定时功能,就必须直接操作这块 MMIO 设备。

本练习位于 comprehensive-rust 课程的裸机(bare-metal)章节,是下午练习(afternoon exercise)的一部分,与 RTC 练习入口文档、下午练习总览 以及 RTC 练习参考答案 相互呼应。练习要求依次完成三个递进目标:

  1. 使用驱动读取当前时间,并通过chronocrate 格式化后打印到串口控制台;
  2. 使用 match 寄存器与 raw interrupt status,忙等待到未来某个时刻(例如 3 秒后),循环内调用core::hint::spin_loop
  3. 扩展任务(可选):启用并处理 RTC 匹配产生的中断,借助arm-giccrate 配置 Arm Generic Interrupt Controller(GIC),将 RTC 中断(接到 GIC 的IntId::spi(2))开启后,用arm_gic::wfi()让核心睡眠直到中断到来。

练习工程结构:你需要哪些文件

课程的 RTC 练习采用no_std+no_main的裸机工程结构,目标平台是 aarch64。仓库中src/exercises/bare-metal/rtc/目录提供了完整的工程(包含参考答案),其中各文件职责如下:

文件职责是否需要修改
src/main.rs入口函数、UART/GIC 初始化、三阶段主流程是(练习主体)
src/exceptions.rsaarch64 异常向量表实现仅第 3 部分需要
src/logger.rs基于 PL011 UART 的log输出后端
src/pl031.rsPL031 寄存器布局与Rtc驱动封装是(编写驱动核心)
Cargo.toml工程依赖声明
build.rs构建脚本
memory.ld链接脚本(内存布局)
Makefile构建与 QEMU 运行入口
.cargo/config.tomlCargo 目标与链接器配置

注意:仓库中src/exercises/bare-metal/rtc/src/pl031.rsmain.rs已经包含完整答案(通过// ANCHOR: solution注释标记),可供对照学习;练习时你可以先自行实现,再参照仓库中的参考实现。

Cargo.toml 依赖清单

工程依赖(见 rtc/Cargo.toml)揭示了这次练习用到的全部关键库:

[package] name = "rtc" version = "0.1.0" edition = "2024" publish = false [dependencies] aarch64-paging = { version = "0.12.1", default-features = false } aarch64-rt = "0.4.3" arm-gic = "0.8.1" arm-pl011-uart = "0.5.0" bitflags = "2.11.1" chrono = { version = "0.4.44", default-features = false } log = "0.4.30" safe-mmio = "0.3.0" smccc = "0.2.3" spin = "0.12.0" zerocopy = "0.8.50"
  • aarch64-rt:提供异常向量表宏(exception_handlers!)、入口宏(entry!)以及初始页表(initial_pagetable!);
  • aarch64-paging:描述 EL1 页表属性(El1Attributes),用于建立初始恒等映射;
  • arm-gic:GICv3 驱动,负责中断控制器配置与wfi()
  • arm-pl011-uart:PL011 串口驱动,用于日志输出;
  • chrono:时间格式化(注意default-features = false,裸机环境下不依赖标准库时钟);
  • safe-mmio:提供UniqueMmioPointerReadPure/ReadPureWrite/WriteOnly等字段类型,用类型安全的方式访问 MMIO 寄存器;
  • log:日志门面,配合logger.rs输出到 UART;
  • smccc:通过 HVC(Hypervisor Call)调用 PSCI 的system_off关闭系统。

链接脚本与内存布局

memory.ld 将镜像放在 QEMU virt 机器的 RAM 起始处:

MEMORY { image : ORIGIN = 0x40080000, LENGTH = 2M }

0x40080000是 QEMU virt 机器上典型的内核加载地址,2 MiB 的可加载空间足够容纳这个小型裸机镜像。

第一步:编写 PL031 寄存器映射驱动

PL031 寄存器布局

PL031 的寄存器全部以 32 位字对齐。参考实现 pl031.rs 使用#[repr(C, align(4))]结构体精确描述寄存器布局:

#[repr(C, align(4))] pub struct Registers { /// Data register(当前 RTC 计数值) dr: ReadPure<u32>, /// Match register(匹配值,达到后产生中断) mr: ReadPureWrite<u32>, /// Load register(加载初始计数值) lr: ReadPureWrite<u32>, /// Control register(控制位) cr: ReadPureWrite<u8>, _reserved0: [u8; 3], /// Interrupt Mask Set or Clear register(中断掩码) imsc: ReadPureWrite<u8>, _reserved1: [u8; 3], /// Raw Interrupt Status(原始中断状态,不受掩码影响) ris: ReadPure<u8>, _reserved2: [u8; 3], /// Masked Interrupt Status(掩码后的中断状态) mis: ReadPure<u8>, _reserved3: [u8; 3], /// Interrupt Clear Register(写 1 清除中断) icr: WriteOnly<u8>, _reserved4: [u8; 3], }

各寄存器位 0(bit 0)是核心状态位:

寄存器访问类型位 0 含义
DR(Data)只读当前 RTC 计数值(Unix 时间戳形式)
MR(Match)读写设定匹配时刻,RTC 计数值与之相等时置位状态
IMSC(Interrupt Mask Set/Clear)读写1 使能中断,0 关闭中断
RIS(Raw Interrupt Status)只读匹配发生后置 1,无论中断是否使能
MIS(Masked Interrupt Status)只读仅当匹配发生且中断使能时为 1
ICR(Interrupt Clear)只写写 1 清除挂起的中断

safe-mmio的字段类型让每个寄存器具备编译期检查的访问语义:ReadPure只能读,ReadPureWrite可读可写,WriteOnly只能写。

Rtc 驱动 API

Rtc结构体封装了上述寄存器,对外提供如下方法(源码见 pl031.rs):

pub fn new(registers: UniqueMmioPointer<'a, Registers>) -> Self // 读取当前 RTC 计数值(Unix 秒) pub fn read(&self) -> u32 // 写入匹配值;当 RTC 计数值与之相等时会生成中断(若已使能) pub fn set_match(&mut self, value: u32) // 返回是否已匹配(读 RIS 位 0),与中断是否使能无关 pub fn matched(&self) -> bool // 返回是否有中断挂起(读 MIS 位 0),等价于 matched 且中断已使能 pub fn interrupt_pending(&self) -> bool // 设置/清除中断掩码:true 使能中断,false 关闭 pub fn enable_interrupt(&mut self, mask: bool) // 清除挂起中断(写 ICR 位 0 为 1) pub fn clear_interrupt(&mut self)

其中matched()interrupt_pending()的区别正是练习第 2、3 部分的落脚点:忙等待阶段只关心"硬件是否到点"(读RIS),而中断阶段关心"是否产生了可响应的中断"(读MIS)。

第二步:裸机入口与基础环境初始化

初始页表:设备内存映射

main.rs 顶部用initial_pagetable!建立 1 GiB 粒度的初始恒等映射:

initial_pagetable!({ let mut idmap = [0; 512]; // 1 GiB of device memory. idmap[0] = DEVICE_ATTRIBUTES.bits(); // 1 GiB of normal memory. idmap[1] = MEMORY_ATTRIBUTES.bits() | 0x40000000; // Another 1 GiB of device memory starting at 256 GiB. idmap[256] = DEVICE_ATTRIBUTES.bits() | 0x4000000000; InitialPagetable(idmap) });
  • idmap[0]:映射0x0起的 1 GiB 设备内存,覆盖 GIC(0x800_0000附近的 distributor/redistributor)、PL011 UART(0x900_0000)与 PL031 RTC(0x901_0000)等外设地址;
  • idmap[1]:映射0x40000000起的 1 GiB 普通内存(| 0x40000000表示页表项输出地址),即镜像所在的 RAM 区域;
  • idmap[256]:映射0x4000000000(256 GiB)起的另一段设备内存。

设备内存使用DEVICE_ATTRIBUTES(VALID | ATTRIBUTE_INDEX_0 | ACCESSED | UXN),普通内存使用MEMORY_ATTRIBUTES(VALID | ATTRIBUTE_INDEX_1 | INNER_SHAREABLE | ACCESSED | NON_GLOBAL)。这对 MMIO 访问至关重要——设备寄存器绝不能被缓存,必须按设备内存语义访问。

地址常量与入口

const PL011_BASE_ADDRESS: NonNull<PL011Registers> = NonNull::new(0x900_0000 as _).unwrap(); const PL031_BASE_ADDRESS: NonNull<pl031::Registers> = NonNull::new(0x901_0000 as _).unwrap(); const PL031_IRQ: IntId = IntId::spi(2); entry!(main); fn main(x0: u64, x1: u64, x2: u64, x3: u64) -> ! { let uart = unsafe { Uart::new(UniqueMmioPointer::new(PL011_BASE_ADDRESS)) }; logger::init(uart, LevelFilter::Trace).unwrap(); info!("main({:#x}, {:#x}, {:#x}, {:#x})", x0, x1, x2, x3); ... }

入口函数接收启动时来自固件的x0x3四个参数,首先初始化 PL011 UART 并挂载日志后端(见 logger.rs,其内部用SpinMutex保护 UART,实现Logtrait 后逐行输出[级别] 消息),随后打印入口参数。

注意:地址常量通过unsafeNonNull::new(...).unwrap()构造,并配合逐条// SAFETY:注释说明"该地址确为对应设备基地址,且无人同时访问该区间"——这是 unsafe 代码中安全前置条件(precondition)的规范写法。

GIC 初始化

在进行中断相关操作前,主流程先初始化 Arm Generic Interrupt Controller v3:

let mut gic = unsafe { GicV3::new( UniqueMmioPointer::new(GICD_BASE_ADDRESS), GICR_BASE_ADDRESS, 1, // 核心数 false, // 非安全模式相关配置 ) }; gic.setup(0);

其中GICD_BASE_ADDRESS = 0x800_0000(distributor)、GICR_BASE_ADDRESS = 0x80A_0000(redistributor),这两个地址同样来自 QEMU virt 机器的设备树布局。GICv3 是 aarch64 上中断路由与分发的中枢,PL031 的匹配中断最终正是经由它送达 CPU。

第三步(目标 1):读取并打印当前时间

完成初始化后,读取 RTC 计数值并用chrono格式化:

let mut rtc = unsafe { Rtc::new(UniqueMmioPointer::new(PL031_BASE_ADDRESS)) }; let timestamp = rtc.read(); let time = Utc.timestamp_opt(timestamp.into(), 0).unwrap(); info!("RTC: {time}");
  • rtc.read()从 PL031DR寄存器读出当前计数值(Unix 秒);
  • Utc.timestamp_opt(timestamp.into(), 0)将 u32 计数值转换为chronoDateTime<Utc>.into()u32转成i64);
  • timestamp_opt返回LocalResult,配合unwrap()处理边缘时刻(如闰秒歧义)。

QEMU virt 机器的 PL031 默认从虚拟机启动时刻开始计数,因此打印出的时间是从启动起的相对秒数。运行后你会在串口看到类似RTC: 2026-…的 UTC 时间行。

第四步(目标 2):用 match 寄存器忙等待 3 秒

接着,代码演示了不依赖中断的定时等待——利用MRRIS

// Wait for 3 seconds, without interrupts. let target = timestamp + 3; rtc.set_match(target); info!("Waiting for {}", Utc.timestamp_opt(target.into(), 0).unwrap()); trace!( "matched={}, interrupt_pending={}", rtc.matched(), rtc.interrupt_pending() ); while !rtc.matched() { spin_loop(); } trace!( "matched={}, interrupt_pending={}", rtc.matched(), rtc.interrupt_pending() ); info!("Finished waiting");

要点:

  • rtc.set_match(timestamp + 3)把目标时刻写入MR
  • 循环条件读取rtc.matched(),即 PL031RIS寄存器的位 0——该位不受中断掩码影响,只要 RTC 计数值与匹配值相等就会置位,因此即使中断未使能也能感知"到点";
  • 循环体内调用core::hint::spin_loop(),该指令向 CPU 提示当前处于自旋等待(spin-wait)状态,有助于降低功耗与提高多核公平性;
  • 前后两次trace!输出对比matchedinterrupt_pending状态:在未使能中断时,匹配发生后matched变为true,而interrupt_pending(读MIS)仍为false,直观验证了RISMIS的语义差异。

第五步(扩展):中断驱动等待与 wfi 睡眠

练习的第 3 部分是加分项:把忙等待升级为真正的中断驱动,让 CPU 在等待期间进入休眠,由 GIC 转发 RTC 中断唤醒核心。

配置 RTC 中断路径

GicCpuInterface::set_priority_mask(0xff); gic.set_interrupt_priority(PL031_IRQ, None, 0x80).unwrap(); gic.set_trigger(PL031_IRQ, None, Trigger::Level).unwrap(); irq_enable(); gic.enable_interrupt(PL031_IRQ, None, true).unwrap();
  • GicCpuInterface::set_priority_mask(0xff):将 CPU 接口优先级掩码设为 0xff(在 arm-gic 中数值越大优先级越低),确保任意优先级的中断都能通过;
  • set_interrupt_priority(PL031_IRQ, None, 0x80):为 SPI 2 设置优先级 0x80;
  • set_trigger(..., Trigger::Level):PL031 的中断是电平触发(level-sensitive),必须按电平触发配置;
  • irq_enable():开启当前核的 IRQ 总开关;
  • enable_interrupt(PL031_IRQ, None, true):在 GIC distributor 侧使能该中断。

其中PL031_IRQ = IntId::spi(2),对应练习文档说明的"RTC 中断接到 GIC 的 SPI 2"——SPI(Shared Peripheral Interrupt)编号来自 QEMU virt 机器的中断路由表。

等待下一个 3 秒并休眠

// Wait another 3 seconds for an interrupt. let target = timestamp + 6; info!("Waiting for {}", Utc.timestamp_opt(target.into(), 0).unwrap()); rtc.set_match(target); rtc.clear_interrupt(); rtc.enable_interrupt(true); trace!(...); while !rtc.interrupt_pending() { wfi(); } trace!(...); info!("Finished waiting");
  • set_match(target)设定新匹配时刻;
  • clear_interrupt()ICR清除前一次可能残留的挂起状态;
  • enable_interrupt(true)置位IMSC,使能 PL031 的中断输出到 GIC;
  • 循环条件改为!rtc.interrupt_pending(),即读MIS——只有匹配发生中断使能时该位才为 1;
  • 循环体内调用arm_gic::wfi()(Wait For Interrupt),核心进入低功耗睡眠,直到收到中断才被唤醒,比忙等待大幅省电。

异常向量表与中断处理

中断唤醒后由异常向量表接管。仓库中的 exceptions.rs 使用aarch64-rtexception_handlers!宏定义了完整的 EL1 异常向量表:

struct Handlers; impl ExceptionHandlers for Handlers { extern "C" fn sync_current(_state: RegisterStateRef) { ... } extern "C" fn irq_current(_state: RegisterStateRef) { trace!("irq_current"); let intid = GicCpuInterface::get_and_acknowledge_interrupt(InterruptGroup::Group1) .expect("No pending interrupt"); info!("IRQ {intid:?}"); } extern "C" fn fiq_current(_state: RegisterStateRef) { ... } ... } exception_handlers!(Handlers);

当 RTC 中断到来时,CPU 进入irq_current处理函数,通过GicCpuInterface::get_and_acknowledge_interrupt(InterruptGroup::Group1)读取并应答中断,得到IntId(应为IntId::spi(2))并打印。该函数返回Option,用expect处理"无挂起中断却进入 IRQ"的异常情况——这正是练习文档中"只应在第 3 部分修改 exceptions.rs"的原因:前两个目标根本不依赖中断向量。

其余同步异常(sync_current)、FIQ、SError 以及来自低特权级(lower EL)的异常统一打印错误后通过 PSCIsystem_off关机,保证失败可观测。

构建与运行:make qemu

工程通过 Makefile 一站式完成编译、镜像生成与 QEMU 启动(见 rtc/Makefile):

.PHONY: build qemu_minimal qemu qemu_logger all: rtc.bin build: cargo build rtc.bin: build cargo objcopy -- -O binary $@ qemu: rtc.bin qemu-system-aarch64 -machine virt,gic-version=3 -cpu max -serial mon:stdio -display none -kernel $< -s clean: cargo clean rm -f *.bin

执行make qemu即可在 QEMU 中运行:

  1. cargo build交叉编译(.cargo/config.toml已指定 aarch64 目标与链接脚本);
  2. cargo objcopy -- -O binary rtc.bin从 ELF 提取裸二进制镜像;
  3. qemu-system-aarch64-machine virt,gic-version=3(必须使用 GICv3,与代码中GicV3驱动匹配)、-cpu max-serial mon:stdio将串口接到当前终端、-kernel rtc.bin加载镜像、-s开启 1234 端口 GDB stub 方便调试。

运行成功后串口输出应依次呈现:入口参数日志 → 初始 RTC 时间 → "Waiting for …" → 忙等待结束 → 中断等待 → 两次IRQ IntId::spi(2)相关日志 →system_off关机。整个流程完整演示了"轮询(polling)→ 中断驱动(interrupt-driven)"两种外设等待模式的迁移。

小结:本练习沉淀的裸机技能

  • 类型安全的 MMIO 驱动:用#[repr(C, align(4))]结构体描述寄存器布局,配合safe-mmio的读写权限类型,让寄存器访问具备编译期检查;
  • 时间处理:裸机下用chronodefault-features = false)+Utc::timestamp_opt把硬件计数值格式化为可读时间;
  • 两种等待范式:基于RIS的忙等待(配合spin_loop)与基于 GIC +MIS+wfi的中断驱动等待,理解RAW statusMASKED status的本质区别;
  • GICv3 中断链路:从IntId::spi(2)出发,依次配置优先级、触发方式、总开关与 distributor 使能,再在异常向量表中应答中断;
  • 安全注释习惯:所有 MMIO 地址构造均使用unsafe并附// SAFETY:前置条件说明,这是 comprehensive-rust 全程强调的安全前置条件(safety preconditions)实践。

如果想挑战自己,可以先隐藏pl031.rs自行实现驱动,再对照仓库中带ANCHOR: solution的参考代码(main.rs、pl031.rs)与 solutions-afternoon.md 检查差异。更进一步的延伸可以阅读课程中 bare-metal 章节 下的 uart 驱动、mmio 访问 与 GIC 相关材料,将本练习的零散知识点串联成完整的裸机系统认知。

【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询