Rust异步与ARM通用定时器
摘要
一篇技术博客文章,探讨了在ARM架构上使用ARM通用定时器进行Rust异步编程,比较了定时器外设,并讨论了Embassy和RTIC等框架。
<p><a href="https://lobste.rs/s/eeqdfc/rust_async_arm_generic_timer">评论</a></p>
查看缓存全文
缓存时间: 2026/05/18 02:25
# JP 的网站来源:https://thejpster.org.uk/blog/blog-2026-05-17/ ### JP 的网站 (https://thejpster.org.uk/) --- ## 寻找时间(下篇)——Rust 异步与 Arm 通用定时器发布于**2026-05-17** ## 目录 - 引言 (https://thejpster.org.uk/blog/blog-2026-05-17/#intro) - 定时器 (https://thejpster.org.uk/blog/blog-2026-05-17/#timers) - Arm SYSTICK (https://thejpster.org.uk/blog/blog-2026-05-17/#arm-systick) - Arm CMSDK 定时器 (https://thejpster.org.uk/blog/blog-2026-05-17/#arm-cmsdk-timer) - Arm 通用定时器 (https://thejpster.org.uk/blog/blog-2026-05-17/#arm-generic-timer) - Embassy (https://thejpster.org.uk/blog/blog-2026-05-17/#embassy) - RTIC (https://thejpster.org.uk/blog/blog-2026-05-17/#rtic) ## 引言在上一篇文章 (https://thejpster.org.uk/blog/blog-2026-05-16/) 中,我介绍了 Arm 架构的各种版本,以及我在开发 `aarch32-rt` (https://crates.io/crates/aarch32-rt) 和 `aarch32-cpu` (https://crates.io/crates/aarch32-cpu) 库时关注的那些版本。构建一个能进入 `fn main()` 并可能触发几个中断的例子固然不错,但使用这些大型处理器的大多数人会更进一步——他们需要某种框架,能够并发运行多个任务(无论是使用抢占式任务切换还是协作式调度),并管理即将发生的事件列表(如超时,或 LED 下次闪烁时间)。在 Rust 中,许多人使用 Embassy (https://embassy.dev/) 或 RTIC (https://rtic.rs/) 来实现这一点。那么,在这些框架上运行,比如 Armv8-R AArch32 架构,到底有多难呢?好吧,在那之前,我们先讨论一些常见的定时器外设。
## 定时器我将介绍三种定时器外设,以便了解它们的异同。第一种见于所有基于 Arm Cortex-M 的设备,但正如我们将看到的,它并不太好。你应该注意,如果你有 STM32,它会有一个(或多个)ST 的定时器外设;如果你有 nRF52,它会有一个(或多个)Nordic Semi 的定时器外设。但我们还是坚持使用 Arm 的这些定时器,因为我知道我们可以在 QEMU 中模拟它们进行测试,而我们最后要看的是每个 Armv8-A/Armv8-R 系统(以及部分 Armv7-A/Armv7-R 系统)都免费附带的那种。
### Arm SYSTICKArm SYSTICK (https://developer.arm.com/documentation/dui0646/c/Cortex-M7-Peripherals/System-timer--SysTick) 是 Arm 架构 M-Profile 所有版本的标准功能。也就是说,你会在以下设备中找到相同的定时器:
- Cortex-M0
- Cortex-M0+
- Cortex-M1
- Cortex-M3
- Cortex-M4
- Cortex-M7
- Cortex-M23
- Cortex-M33
- Cortex-M55
- Cortex-M85
SYSTICK 有一个递减计数器。当它减到零时,计数器会自动重置为 *SysTick 重装载值寄存器* 中的值,并且(可选地)触发 Arm SYSTICK 异常。这个定时器似乎只有一个任务:以非常规律的间隔触发中断——比如每秒 100 次,或每秒 1000 次。这正是大多数经典 C RTOS 驱动其调度器所需要的——定时器滴答是一个抢占当前正在运行的线程并将其切换为另一个等待运行的线程的机会,从而使线程即使都在忙于工作也能表现得并发执行。如果你想要一个可以用作可编程闹钟的定时器,用于未来的某个任意时刻,那这个就不太好用了。计数器只有 24 位,并且设计为由你的 SoC 主系统时钟驱动。因此,如果你有 480 MHz 的系统时钟,那么定时器滴答之间的最大时间将是 `$\frac{{2}^{24} - 1}{480 \mathrm{MHz}} = 34.95 \mathrm{ms}$`。如果你想要 100 Hz(10 ms)的滴答,那没问题;但如果你想等待更长时间,就需要将 SYSTICK 计数器设置为最大值,当它触发时,计算出距离你感兴趣的事件还剩多少时间,然后再次设置定时器。如果事件在一个小时后,而你必须每 35 ms 左右滴答一次直到到达那个时间,这对电池寿命并不理想。幸运的是,大多数 SoC 还包括一些功能更强的定时器。
### Arm CMSDK 定时器为了帮助工程师使用 Cortex-M 处理器设计产品,Arm 提供了一些标准外设作为 *Cortex-M 系统设计套件* (CMSDK) (https://developer.arm.com/documentation/ddi0479/c/) 的一部分。Arm 在为其处理器构建评估套件时也使用这些外设,而且恰巧 QEMU 模拟了其中几个板卡——例如 Arm MPS2-AN385 (https://developer.arm.com/documentation/dai0385/d)。MPS2-AN385 上基于 FPGA 的 SoC 包含一个 Arm CMSDK APB 定时器 (https://developer.arm.com/documentation/ddi0479/c/apb-components/apb-timer),由于 QEMU 对于在没有真实硬件插入 Github CI 的情况下测试 Rust 固件非常有用,我认为这是一个值得研究的例子。CMSDK APB 定时器有一个 32 位计数器和一个 32 位重载值,没有其他功能。但即便如此,这已经是对 Arm SYSTICK 的改进,对于设置报警以在未来某个可变时间触发(而不是固定的周期性滴答间隔)要实用得多。它还有一个 `INTSTATUS` 寄存器,我们可以用来检查报警是否正在响铃(即计数器值是否达到零并重载),以及一个 `INTCLEAR` 寄存器,可以用来使报警静音(即取消中断)。APB CMSDK 定时器通常由低于主系统时钟的外设时钟驱动,但即使在 480 MHz 下,这个 32 位计数器也给了我们报警间隔最大为 `$\frac{{2}^{32} - 1}{480 \mathrm{MHz}} = 8.95 \mathrm{s}$`。虽然不是完美,但比 SYSTICK 更有利于电池寿命。而且许多 SoC 允许你降低定时器的时钟速度,从而延长其范围。
### Arm 通用定时器Arm 通用定时器 (https://developer.arm.com/documentation/ddi0406/c/System-Level-Architecture/The-Generic-Timer) 是 Armv7-A 和 Armv7-R 处理器的可选功能,而在 Armv8-A 和 Armv8-R 中是标准功能。Arm 通用定时器在 AArch64 模式下作为系统寄存器暴露,在 AArch32 模式下作为 CP15 寄存器暴露,因此不使用 MMIO,也没有内存地址。Arm 通用定时器提供以下两种:
- 一个 64 位自由运行计数器,复位时从 0 开始
- 一个 64 位比较值,当自由运行计数器超过该值时触发中断
- 一个 32 位递减定时器,当其减到零时可以触发中断
Arm 通用定时器将其两个定时器称为物理定时器和虚拟定时器,区别仅在你在 hypervisor 内运行操作系统时才会显现。物理定时器计数的是“墙上时间”,就像你在墙上的时钟上看到的时间流逝一样,而虚拟定时器只在客户机操作系统运行时计时。你可能将物理定时器用于涉及外部事物的操作(UART 上字节的超时),而将虚拟定时器用于操作系统执行的操作(例如,基准测试 CRC 算法)。想象一下,你不会希望因为客户机操作系统被换出 10ms 而导致基准测试结果混乱,但同样,被换出 10ms 并不意味着你想延长 UART 超时时间——无论客户机操作系统当时是否在运行,设备都应该已经响应。Hypervisor 处理这个问题的方法是在 `CNTVOFF` 寄存器中设置一个偏移值。然后,从这个自由运行的物理定时器计数器(`CNTPCT` 寄存器)中减去这个数量,以生成自由运行的虚拟定时器计数器(`CNTVCT` 寄存器)。如果 hypervisor 没有设置该寄存器来考虑客户机操作系统重新换入时“丢失”的时间(或者如果你没有 hypervisor),那么你基本上有两个相同的定时器可用。即使我们假设一个慷慨的 2 GHz 时钟速度,我们的 64 位计数器也给了我们报警间隔最大为 `$\frac{{2}^{64} - 1}{2 \mathrm{GHz}} = 9.22 \times 10^9 \mathrm{s}$`。9.22 吉秒超过 292 年——这当然足够让你不用担心回绕。由于我不完全理解的原因,QEMU 模拟 MPS3-AN536 板卡(及其 Cortex-R52 Armv8-R AArch32 处理器)时提供的 Arm 通用定时器实现以 62.5 GHz 运行,将我们的最大周期缩减到仅 9.3 年。奇怪,但我想我可以接受。哦,为了完整性,实际上还有第三个定时器,称为 Hyp 定时器,但它设计为由 EL2 的 hypervisor 独占使用——我猜是为了让它能够测量时间片是否用完,以及正在运行的客户机操作系统是否需要被换出。而且,如果你在一个支持安全模式的系统上,你还会拥有第四个,称为安全模式物理定时器。但它们是比较专业的用例,所以我忽略它们。
## Embassy事实证明,如果你想在 Armv8-R 上运行 Embassy,已经有很多工作完成了。`embassy-executor` (https://docs.embassy.dev/embassy-executor/0.10.0/cortex-m/index.html) crate 已经有了 `platform-cortex-ar` 特性 (https://github.com/embassy-rs/embassy/blob/796d8e6039ddf92db7bebf40506a8367b057e963/embassy-executor/Cargo.toml#L157),该特性引入了对我的 `aarch32-cpu` crate(或其早期化身 `cortex-ar-cpu`)的依赖。它只支持“现代”架构,拒绝为 ARMv4T 等早期架构构建,因为这些架构缺乏原子比较交换和其他便利功能,但这没问题。重要的是,它使用 `WFE` 指令在没有任务可运行时让处理器休眠,中断处理程序使用 `SEV` 指令确保处理器从休眠中唤醒,即使它*即将*进入休眠¹ (https://thejpster.org.uk/blog/blog-2026-05-17/#fn-1)。所以,相对容易地将我的一个用于 QEMU 可以模拟的 MPS3-AN536 Arm Cortex-R52 评估板的例子复制过来,并使用他们的 `embassy_executor::main` 属性宏启动 Embassy 线程模式异步执行器:
```rust
#[embassy_executor::main(entry = "aarch32_rt::entry")]
async fn main(_spawner: embassy_executor::Spawner) -> ! {
let p = embassy_mps3_an536_examples::Board::new().unwrap();
loop {
// 嗯,现在呢。
//
// 我没有有趣的 `async fn` 可以调用
}
}
```
是的,我需要为我的任务提供一些可以等待的东西,而且我没有任何异步外设驱动程序。所以让我们创建一个带有异步 API 的定时器。幸运的是,Embassy 有一个框架。`embassy-time` (https://docs.embassy.dev/embassy-time/0.5.1/default/index.html) 是 Embassy 上时间管理的用户接口。它允许你编写如下代码:
```rust
use embassy_time::{Duration, Timer};
async fn sleep_for_a_second() {
Timer::after(Duration::from_secs(1)).await;
}
```
为此,你必须提供一个 *Time Driver*,它实现了 `embassy-time-driver` (https://docs.embassy.dev/embassy-time-driver/0.2.2/default/index.html) 中定义的 trait。那么,我们就为 Arm 通用定时器做这个,使用 `aarch32-cpu` crate 中的 `El1VirtualTimer` (https://docs.rs/aarch32-cpu/0.3.0/aarch32_cpu/generic_timer/struct.El1VirtualTimer.html) 驱动程序。顺便提一下,它被称为 `El1VirtualTimer`,因为该 API 只暴露可以在 EL1(即内核模式)下执行的操作。还有 `El2VirtualTimer` 和 `El0VirtualTimer`,但它们触及相同的硬件,只是提供的 API 不同。
首先,我们需要一个定时器队列。它维护一个已排序的列表(或者可能是一个堆?),其中包含了计划在未来各个时间点发生的事件,最近即将发生的事件排在顶部。然后我们计算距离那个时刻还有多长时间,将其转换为若干时钟周期数,并将该值作为比较值放入通用定时器的递增计数器。当那一刻到来时,闹钟触发,处理器上引发 IRQ 异常。IRQ 处理程序将与通用中断控制器通信,发现这是一个虚拟定时器私有外设中断,然后跳转到标准的 `embassy-time-driver` (https://docs.embassy.dev/embassy-time-driver/0.2.2/default/index.html) 代码。
这是我们的全局定时器:
```rust
/// 我们的定时器队列,包含包装在基于临界区的 embassy Mutex 中的共享可变状态
struct Aarch32VirtualTimerQueue {
inner: Mutex>,
}
/// 我们的共享可变状态是一个基本的定时器队列
struct Aarch32VirtualTimerQueueInner {
queue: embassy_time_queue_utils::Queue,
}
/// 这个宏创建一个静态变量,并确保 embassy API 知道它
/// 因为系统中正好有一个 Embassy 时间实现,这就是它
embassy_time_driver::time_driver_impl!(
static DRIVER: Aarch32VirtualTimerQueue = Aarch32VirtualTimerQueue {
inner: Mutex::new(RefCell::new(
Aarch32VirtualTimerQueueInner {
queue: embassy_time_queue_utils::Queue::new(),
}
))
}
);
```
我们在 `Aarch32VirtualTimerQueue` 上实现 `embassy_time_driver::Driver` trait,以赋予它所需的功能:
```rust
impl embassy_time_driver::Driver for Aarch32VirtualTimerQueue {
/// 一个独立的函数,执行原子 64 位系统寄存器读取
fn now(&self) -> u64 {
aarch32_cpu::generic_timer::read_virtual_timer()
}
/// 锁定队列并检查是否需要调度新的闹钟
fn schedule_wake(&self, at: u64, waker: &Waker) {
critical_section::with(|cs| {
let mut inner = self.inner.borrow(cs).borrow_mut();
inner.schedule_wake(at, waker);
});
}
}
```
再给自己一个可以从中断处理程序中调用的函数:
```rust
impl Aarch32VirtualTimerQueue {
/// 当中断触发时,从中断处理程序调用此函数
fn on_irq(&self) {
critical_section::with(|cs| {
let mut inner = self.inner.borrow(cs).borrow_mut();
inner.update_alarm();
});
}
}
```
现在我们需要对这个由互斥锁保护的可变内部状态添加一些方法:
```rust
impl Aarch32VirtualTimerQueueInner {
/// 为队列中的下一项调度唤醒
fn schedule_wake(&mut self, at: u64, waker: &Waker) {
// 放入队列
if self.queue.schedule_wake(at, waker) {
// 需要更新闹钟
self.update_alarm();
}
}
/// 检查时间和队列,可能设置闹钟(或关闭它)
fn update_alarm(&mut self) {
let now = aarch32_cpu::generic_timer::read_virtual_timer();
let next = self.queue.next_expiration(now);
// SAFETY: 我们拥有此定时器驱动程序的 &mut,并且它是唯一拥有定时器的对象。
let mut vt = unsafe { generic_timer::El1VirtualTimer::new() };
if next == u64::MAX {
// 关闭定时器中断
vt.interrupt_mask(true);
} else {
// 设置闹钟 - 如果时间已经过去,会立即触发
vt.counter_compare_set(next);
vt.interrupt_mask(false);
vt.enable(true);
}
}
}
```
我选择在需要时不安全地凭空创建一个 `El1VirtualTimer`,而不是在时间结构体内携带它。因此,我有责任阅读代码库中所有其他 `unsafe` 代码,并确保没有其他人试图访问同一个定时器。你可以尝试用一个只能创建一次的神奇对象来解决这个问题,但通过搜索 `VirtualTimer` 进行代码审查可能更容易。
现在,我不是 `embassy-time` 的专家,但我认为这是正确的。它似乎确实让我能够运行几个定期打印的异步任务。如果你有 QEMU 9 或更高版本,你可以用我写的这个例子亲自尝试:
```rust
#![no_std]
#![no_main]
#[embassy_executor::main(entry = "aarch32_rt::entry")]
async fn main(_spawner: embassy_executor::Spawner) -> ! {
let _p = embassy_mps3_an536_examples::Board::new().unwrap();
arm_gic::gicv3::GicCpuInterface::set_priority_mask(0xFF);
// SAFETY: 我们不在中断保护的关键段内
```
相似文章
Rust语言的性能
本次演讲分析了Rust相较于C++的性能优势与劣势,提供了基准测试和最佳实践。附有幻灯片和阅读材料。
为3DS构建AsyncIO执行器
本文介绍了在Nintendo 3DS上进行异步编程的必要性,因为其采用协作式多任务处理,并开始解释如何为其构建一个asyncio执行器,重点讨论了Rust中的任务、未来、唤醒器和执行器这些概念。
安全 Rust 的边界
TokioConf 2026 的一篇演讲/博客文章探讨了如何通过为复杂指针结构实现追踪式垃圾回收,将安全 Rust 推向极限,并分享处理循环引用与原始指针 GC 设计的技巧。
使用:counters和:atomics模块在Erlang中快速计数
这篇技术文章解释了如何使用Erlang的:counters和:atomics模块进行高性能计数和共享可变状态,从而突破标准的进程隔离模型。内容涵盖BEAM运行时中的原子操作,如add_get、exchange和compare-and-swap(比较并交换)。
我用Rust构建了一个自托管的上下文赌博机装置,并部署在一个实时的AI交易产品上。在发现运行时错误之前,先找到了自己配置中的两个错误。
宣布两个开源Rust项目:Lycan(一种用于上下文赌博机的图执行语言)和Syntra(一个自托管的Docker设备,用于服务Lycan胶囊)。作者在自己的实时AI交易产品上自用测试,发现数据管道错误(而非算法问题)主导了适配工作。