时间驱动
主线位置:定时器主线的「
Timer把到期时刻和 waker 交给时间驱动 → 设置硬件闹钟 → 闹钟中断 → 唤醒任务」。本页讲驱动这一层:接口长什么样、怎么和embassy-time接上、几个现成的驱动怎么实现;驱动内部的队列在定时器队列。
sequenceDiagram participant T as Timer poll participant D as RtcDriver participant Q as Queue participant H as TIM 硬件 T->>D: schedule_wake at waker D->>Q: schedule_wake Q-->>D: true 需要重设闹钟 D->>Q: next_expiration now D->>H: CCR2 写入 at 打开中断 H-->>D: CC2 中断 on_interrupt D->>Q: next_expiration now Q->>T: waker.wake
两个函数
Driver 只有两个方法:
pub trait Driver: Send + Sync + 'static {
/// Return the current timestamp in ticks.
///
/// Implementations MUST ensure that:
/// - This is guaranteed to be monotonic, ...
/// - It "never" overflows. It must not overflow in a sufficiently long time frame, say
/// in 10_000 years ...
/// - It never fails, including any kind of access fault, even if the underlying
/// hardware has not yet been initialized. ...
fn now(&self) -> u64;
/// Schedules a waker to be awoken at moment `at`.
/// If this moment is in the past, the waker might be awoken immediately.
fn schedule_wake(&self, at: u64, waker: &Waker);
}
now 的三条要求,后面每个驱动都要回答:单调不倒退;一万年不溢出,硬件只有 16 位、32 位计数器就必须在软件里扩展到 64 位;硬件没初始化时也不能出错,返回一个默认值。schedule_wake 只承诺「到 at 时叫醒」,过去的时刻可以立刻叫醒。它没有返回值、没有错误路径:驱动做不到时,唯一的退路是提前叫醒,这一点在通用队列满时会用到。
驱动怎么记住「谁在什么时候要醒」,接口不管。现在的约定是驱动自己持有一个定时器队列(下一页的 embassy_time_queue_utils::Queue),硬件上只用一个闹钟,永远设成队列里最早的那个时刻。
embassy-time-driver 0.1.0 的 Driver 有四个方法:now、allocate_alarm(返回一个 AlarmHandle)、set_alarm_callback(alarm, callback: fn(*mut ()), ctx)、set_alarm(alarm, timestamp) -> bool。驱动只提供几个硬件闹钟,定时器队列在别处:开了 integrated-timers 的执行器各自分配一个闹钟、在任务头里维护队列,否则由 embassy-time 的通用队列占一个闹钟。#3593(「Rework time-driver contract.」)把这些合并了,其中一个提交的标题就是「Merge time-driver and time-queue-driver traits, make HALs own and handle the queue.」:闹钟分配和回调全部删掉,换成 schedule_wake(at, waker),队列由 HAL 持有;同一个 PR 里「Make integrated-timers the default, remove Cargo feature.」把执行器的 integrated-timers feature 也去掉了:执行器不再自己维护队列,只在任务头里给驱动的队列留一块内存。随 embassy-time-driver 0.2.0、embassy-time 0.4.0、embassy-executor 0.7.0 一起在 2025-01-02 发布。
链接期符号:没有泛型参数
Timer 不带驱动的类型参数,Instant::now() 也不需要谁传进来一个驱动。embassy-time-driver 声明两个外部函数,由谁定义留给链接器:
unsafe extern "Rust" {
fn _embassy_time_now() -> u64;
fn _embassy_time_schedule_wake(at: u64, waker: &Waker);
}
/// See [`Driver::now`]
#[inline]
pub fn now() -> u64 {
unsafe { _embassy_time_now() }
}
驱动用 time_driver_impl! 定义它们:
macro_rules! time_driver_impl {
(static $name:ident: $t: ty = $val:expr) => {
static $name: $t = $val;
#[unsafe(no_mangle)]
#[inline]
fn _embassy_time_now() -> u64 {
<$t as $crate::Driver>::now(&$name)
}
#[unsafe(no_mangle)]
#[inline]
fn _embassy_time_schedule_wake(at: u64, waker: &core::task::Waker) {
<$t as $crate::Driver>::schedule_wake(&$name, at, waker);
}
};
}
模块文档给了两条理由:库可以直接用时间,不用把驱动类型一路传下去;全程序只有一个驱动,任意两个 Instant 比较才有意义。两头的 #[inline] 是 0.2.1 加的(#3840,CHANGELOG:「Allow inlining on time driver boundary」),让编译器有机会把驱动的 now() 内联到调用处。
代价是错误推迟到链接时。一个只调 Instant::now()、没有驱动的 std 程序(M2 Max、rustc 1.99.0,Mach-O 符号名前面多一个下划线):
= note: Undefined symbols for architecture arm64:
"__embassy_time_now", referenced from:
__RNvCs4QyoBtEmjrB_19embassy_time_driver3nowCsbLvIQ9Pi7Qd_13time_nodriver in time_nodriver-b44d07e8b282199e.6pak1aiwlh9sm41v6kc2xa594.1jgbfpu.rcgu.o
ld: symbol(s) not found for architecture arm64
两个驱动(embassy-time 同时开 std 和 mock-driver)则在编译 embassy-time 时就报,因为两个定义在同一个 crate 里:
error: symbol `_embassy_time_now` is already defined
--> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/embassy-time-0.5.1/src/driver_std.rs:20:1
|
20 | / embassy_time_driver::time_driver_impl!(static DRIVER: TimeDriver = TimeDriver {
两个定义分在两个 crate 里(比如 HAL 的 time-driver-* 加一个自己写的),结果取决于链接器。thumbv7em 上 embassy-stm32 开 time-driver-any、再加模板的 SysTick 驱动,rust-lld 报错:
= note: rust-lld: error: duplicate symbol: _embassy_time_now
>>> defined at lib.rs:168 (~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/embassy-time-driver-0.2.2/src/lib.rs:168)
>>> ~/exp-embassy/target-time-qemu/thumbv7em-none-eabihf/release/deps/q_dup-f40f6542ce07c317.q_dup.5d0c0467d7d93bf3-cgu.0.rcgu.o:(_embassy_time_now)
>>> defined at lib.rs:168 (~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/embassy-time-driver-0.2.2/src/lib.rs:168)
>>> embassy_stm32-0c67ec75b754013f.embassy_stm32.fd2545e22b51d66f-cgu.2.rcgu.o:(.text._embassy_time_now+0x1) in archive ~/exp-embassy/target-time-qemu/thumbv7em-none-eabihf/release/deps/libembassy_stm32-0c67ec75b754013f.rlib
macOS 上不一定。一个 std 程序开 embassy-time 的 std,自己再用 time_driver_impl! 定义一个 now() 恒为 0 的驱动:debug 构建只有一条警告,release 构建什么也不说,两种构建都能运行,Instant::now() 都打印 0 ticks,用的是二进制 crate 里那个:
warning: linker stderr: ld: duplicate symbol '__embassy_time_now' in:
~/exp-embassy/target-time-std/debug/deps/time_dup-dae997501625b2d0.ckzz2bx5tp2e61jvhw1xx4j39.0va4dfb.rcgu.o
~/exp-embassy/target-time-std/debug/deps/libembassy_time-37093cb091fae193.rlib[4](embassy_time-37093cb091fae193.embassy_time.6329b00ed89fe955-cgu.0.rcgu.o)
两种构建的二进制里都找不到 std 驱动的代码(llvm-nm -C 里没有 driver_std 的符号)。rlib 是静态库归档,链接器只在需要某个符号时才取出其中的目标文件;debug 下被取出来的那个目标文件(警告里的 cgu.0)同时含有 std 驱动的定义,于是有一条警告,链接器用了先出现的那个;release 下没有警告。模块文档说「If there is none or multiple drivers in the crate tree, linking will fail.」,后半句在这种情况下不成立。TICK_HZ 也走同一套思路,只是它是常量,用 feature 选,见 Instant 与 Duration。
参考写法:队列加一个闹钟
embassy-time-driver 的模块文档给了带队列的驱动骨架,embassy-stm32 的 TIM 驱动和 wasm 驱动都是这个形状:
fn schedule_wake(&self, at: u64, waker: &Waker) {
critical_section::with(|cs| {
let mut queue = self.queue.borrow(cs).borrow_mut();
if queue.schedule_wake(at, waker) {
let mut next = queue.next_expiration(self.now());
while !self.set_alarm(&cs, next) {
next = queue.next_expiration(self.now());
}
}
});
}
queue.schedule_wake 返回 true 表示队列的最早时刻可能变了。next_expiration(now) 把已经到期的项全部唤醒并移出,返回剩下最早的时刻(空了就是 u64::MAX)。set_alarm 返回 false 表示这个时刻已经过去、闹钟不会响,于是再跑一轮 next_expiration,把刚过期的那个也唤醒。循环一直到闹钟真正设在将来为止。闹钟中断里做同样的循环。
std 驱动:一个闹钟线程
driver_std.rs 的 TimeDriver 把队列放在 std::sync::Mutex 里。第一次调 now() 或 schedule_wake() 时,Inner::init 记下零点并起一个线程:
fn alarm_thread() {
let zero = DRIVER.inner.lock().unwrap().zero_instant.unwrap();
loop {
let now = DRIVER.now();
let next_alarm = DRIVER.inner.lock().unwrap().queue.next_expiration(now);
// Ensure we don't overflow
let until = zero
.checked_add(StdDuration::from_micros(next_alarm))
.unwrap_or_else(|| StdInstant::now() + StdDuration::from_secs(1));
DRIVER.signaler.wait_until(until);
}
}
now() 是零点到现在的微秒数(tick-hz-1_000_000),每次都要拿一次锁。schedule_wake 改了队列就 signal(),让闹钟线程从条件变量上醒来重算。waker 在闹钟线程里被调用,执行器线程被它的 pender 叫醒(见各平台的执行器)。
队列空时 next_alarm 是 u64::MAX,checked_add 失败,于是退回「一秒后」:没有定时器的程序里,这个线程每秒醒一次。
精度取决于宿主。M2 Max、macOS 27 上跑 std 实验时负载约 30,Ticker 每 100 ms 的 tick 打印在 105、205、305 ms 左右(见 Timer、Ticker 与超时),每次唤醒大约晚 5 ms。
mock 驱动:手动推进的时间
MockDriver 的「现在」是一个存在 critical_section::Mutex 里的 Instant,只有调用 advance 才往前走:
pub fn advance(&self, duration: Duration) {
critical_section::with(|cs| {
let inner = &mut *self.0.borrow_ref_mut(cs);
inner.now += duration;
// wake expired tasks.
inner.queue.next_expiration(inner.now.as_ticks());
})
}
schedule_wake 入队后马上 next_expiration 一次,过去的时刻当场叫醒。没有闹钟,也没有线程,时间和唤醒完全由测试控制。
它不能和 std 同时开(前面那个重复符号的编译错误),所以单独建一个 crate:embassy-time 只开 mock-driver 和 generic-queue-8,critical-section 开 std。选通用队列是因为测试里要用自己做的 waker 去 poll future,集成队列只认 embassy 任务的 waker(见下一页)。两个测试:一个 Ticker::every(100ms) 推进 350 ms 后连续 poll,一个 with_timeout(100ms, pending()) 推进到 99.999 ms 和 100 ms 各看一次:
running 2 tests
after 350 ms: 3 ticks ready at once, wakes so far 0
after 399 ms: wakes 0
after 400 ms: wakes 1
.at 99.999 ms: wakes 0, poll Pending
at 100.000 ms: wakes 1, poll Ready(Err(TimeoutError))
now = 100000 ticks
.
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
(cargo test -- --test-threads=1 --nocapture,debug。)每一项都精确到 tick:差 1 µs 不唤醒,到点唤醒一次。
MockDriver 是全局的,同一个测试二进制里的测试共用一个时钟和一个队列。不加 --test-threads=1,cargo 默认多线程并行跑测试,同一个测试二进制连跑 200 次,19 次失败,其中一次的输出:
---- tests::ticker_catches_up stdout ----
after 350 ms: 4 ticks ready at once, wakes so far 0
...
assertion `left == right` failed
left: 4
right: 3
另一个测试在中间 advance 了 100 ms。串行跑 200 次,0 次失败。上游自己的测试用 serial_test 的 #[serial] 把它们排成串行。
wasm 驱动
driver_wasm.rs 用浏览器的 setTimeout 做闹钟,结构和参考写法一样。set_alarm 里有一处截断:
let timeout = (timestamp - now) as u32;
let closure = self.closure.get_or_insert_with(|| Closure::new(dispatch));
self.alarm.token = Some(setTimeout(closure, timeout / 1000));
timestamp - now 是微秒,转成 u32 后最多约 4295 秒(71.6 分钟)。更远的定时器,setTimeout 会被设成一个取模后的时长,提前回调;dispatch 里 next_expiration 发现还没到,再设一次。结果是多几次空醒,不会提前唤醒任务。这是读源码的结论,本机没有跑 wasm。
embassy-stm32 的 TIM 驱动
embassy-stm32 用一个通用定时器(TIM)当时间驱动,代码在 src/time_driver/:mod.rs 按 feature 选 gp16.rs(TIM)或 lptim.rs(LPTIM)。本节读 gp16.rs。
选哪个定时器
time-driver-tim2、time-driver-any 这类 feature 在 build.rs 的 time_driver_singleton 里换成一个 cfg(time_driver_tim15)。time-driver-any 按一个固定顺序挑芯片上有的第一个:先 2 通道的(TIM22、TIM21、TIM12、TIM9),再 TIM15,再 16 位通用(TIM19、TIM4、TIM3),再 32 位(TIM24、TIM23、TIM5、TIM2),最后高级定时器。被选中的定时器从 Peripherals 里去掉(.filter(|s| *s != &time_driver_singleton...)),用户拿不到它;它的 CC、UP(或 GLOBAL)中断由 build.rs 生成处理函数,直接调 on_interrupt()。外设单例怎么生成见外设单例,init 什么时候调 time_driver::init 见 init 与时钟。
STM32L475 上 time-driver-any 选中 TIM15,QEMU 的日志可以作证(见本节最后)。
初始化:预分频必须放得进 16 位
init_timer 把计数器配成 TICK_HZ 一跳:
let timer_freq = T::frequency();
r.cr1().modify(|w| w.set_cen(false));
r.cnt().write(|w| w.set_cnt(0));
let psc = timer_freq.0 / TICK_HZ as u32 - 1;
let psc: u16 = match psc.try_into() {
Err(_) => panic!("psc division overflow: {}", psc),
Ok(n) => n,
};
r.psc().write_value(psc);
r.arr().write(|w| w.set_arr(u16::MAX));
// ...
// Mid-way point
r.ccr(0).write(|w| w.set_ccr(0x8000));
// Enable overflow and half-overflow interrupts
r.dier().write(|w| {
w.set_uie(true);
w.set_ccie(0, true);
});
两个后果:
频率太低会 panic。 预分频寄存器 16 位,
timer_freq / TICK_HZ超过 65536 就进panic!。QEMU 上 L475 复位后跑 4 MHz 的 MSI,选tick-hz-32,embassy_stm32::init就停在这里(QEMU 11.1.2,embassy-stm32 打了一行 PWR 补丁,见下):这是纯算术,和 QEMU 有没有 TIM 模型无关:真芯片上定时器时钟同样是 4 MHz 时,会在同一行 panic。before init, TICK_HZ 32 panicked at ~/exp-embassy/qemu-patched/embassy-stm32/src/time_driver/gp16.rs:127:23: psc division overflow: 124999除不尽就不准。 整数除法向下取整。若定时器时钟是 80 MHz、
TICK_HZ是 32768,80 000 000 / 32768 = 2441.4,psc = 2440,实际分频 2441,tick 频率 32 773.45 Hz,比标称快 0.017%,一天快约 14 秒。驱动不检查也不报告这个误差。选一个能整除定时器时钟的TICK_HZ(比如 1 MHz)就没有这个误差,但 1 MHz 有下一小节说的 4.46 年回绕。
用 16 位计数器拼出时间
计数器只有 16 位,每 65536 tick 回绕一次。驱动另记一个 period: AtomicU32,计数器每过半圈加一:回绕(UIF,计数器到 0)加一,到 0x8000(比较通道 1 的匹配,CC1IF)也加一。于是 period 是偶数时计数器应在 0..0x7FFF,奇数时应在 0x8000..0xFFFF。calc_now:
fn calc_now(period: u32, counter: u16) -> u64 {
((period as u64) << 15) + ((counter as u32 ^ ((period & 1) << 15)) as u64)
}
now() 先读 period、再读计数器,中间一个 compiler_fence,不关中断。读到的计数器和 period 的奇偶对不上,说明刚跨过半圈、中断还没来得及更新 period;异或把计数器的最高位翻过来,结果正好等于「period + 1 加上计数器在新半圈里的位置」。例:period = 2(偶数),计数器读到 0x8003,(2 << 15) + (0x8003 ^ 0) = 0x18003,正是 period = 3 时的 (3 << 15) + 3。
这个设计容忍中断晚到,但只容忍半圈。把 calc_now 原样抄进一个 std 程序,模拟真实 tick、硬件标志(每个标志只锁存一次)和中断处理,从计数器 0x7000 开始屏蔽中断若干 tick,记录 now() 减真实 tick 的最小值和恢复中断后的误差(M2 Max、rustc 1.99.0,release):
masked 1000 ticks: now()-true min 0 max 0, after recovery 0
masked 32767 ticks: now()-true min 0 max 0, after recovery 0
masked 36864 ticks: now()-true min 0 max 0, after recovery 0
masked 36865 ticks: now()-true min -65536 max 0, after recovery 0
masked 65536 ticks: now()-true min -65536 max 0, after recovery 0
masked 100000 ticks: now()-true min -65536 max 0, after recovery 0
masked 102400 ticks: now()-true min -65536 max 0, after recovery -65536
masked 102401 ticks: now()-true min -131072 max 0, after recovery -65536
masked 200000 ticks: now()-true min -196608 max 0, after recovery -131072
0x8000 那次中断最晚可以拖到 0x10000,即半圈 32768 tick;再晚,屏蔽期间读到的 now() 倒退 65536 tick,违反单调性;中断恢复后两个标志一起处理,误差消失。屏蔽得更久,同一个标志被锁存两次,少记的半圈就永远补不回来。32768 Hz 下半圈是 1 秒,1 MHz 下是 32.8 ms。
period 是 u32,源码注释算的是「2^32 × 2^15 / 32768 秒,136 年」。这只在 32768 Hz 下成立:时间一共是 32 + 15 = 47 位,TICK_HZ 取默认的 1 MHz 时 2^47 µs 约 4.46 年就回绕(period + 1 在 release 里回到 0,时间跳回开机),达不到 Driver::now 文档要求的一万年。仓库里的 stm32 例子几乎都选 32768 Hz(见 Instant 与 Duration),驱动本身不拦别的选择。
闹钟:比较通道 2
闹钟用比较通道 2(代码里的 ccr(n + 1),n = 0)。set_alarm:
let t = self.now();
if timestamp <= t {
// If alarm timestamp has passed the alarm will not fire.
// Disarm the alarm and return `false` to indicate that.
r.dier().modify(|w| w.set_ccie(n + 1, false));
self.alarm.borrow(cs).timestamp.set(u64::MAX);
return false;
}
// Write the CCR value regardless of whether we're going to enable it now or not.
// This way, when we enable it later, the right value is already set.
r.ccr(n + 1).write(|w| w.set_ccr(timestamp as u16));
// Enable it if it'll happen soon. Otherwise, `next_period` will enable it.
let diff = timestamp - t;
r.dier().modify(|w| w.set_ccie(n + 1, diff < 0xc000));
// Reevaluate if the alarm timestamp is still in the future
let t = self.now();
if timestamp <= t {
// ...
r.dier().modify(|w| w.set_ccie(n + 1, false));
self.alarm.borrow(cs).timestamp.set(u64::MAX);
return false;
}
比较寄存器只有 16 位,写进去的是 timestamp 的低 16 位,计数器每圈都会匹配一次。所以只在目标不到 0xC000 tick 远时打开中断;更远的由 next_period 在每个半圈检查一次,at < t + 0xc000 时再打开。写完再读一次 now():写寄存器的这几个周期里目标时刻可能刚好过去,比较不会再匹配,必须返回 false 让调用方当场唤醒。
on_interrupt 读一次 SR,写回它的按位取反来清标志(注释:SR 是写 0 清除,直接写 0 或读改写都可能丢掉刚置位的中断),然后按标志分别调 next_period 和 trigger_alarm。
一个闹钟、一个队列,闹钟永远设成队列里最早的时刻:除了半圈中断,CPU 只在有任务该醒时被叫醒。
低功耗
开 low-power 时驱动多实现一个 LPTimeDriver trait。低功耗执行器空闲时调 configure_pwr,里面 pause_time:离下一个闹钟不少于 min_stop_pause 时,启动 RTC 的唤醒闹钟、把 TIM 停掉(CEN = 0),然后进 STOP 模式;醒来后 resume_time 从 RTC 的 stop_wakeup_alarm() 拿到现在的 Instant,set_time 把 period 和计数器直接拨到这个时刻,再重设闹钟。停机期间的时间由 RTC 记。选 time-driver-lptim* 时编译的是 lptim.rs,另有一套暂停和恢复逻辑。低功耗执行器本身见各平台的执行器。本机没有板子,这一小节只读了源码。
在 QEMU 里
QEMU 的 L475 没有 TIM 模型(读 0、写忽略),所以模板没法用这个驱动。打开 time-driver-any 照样编、照样跑一遍,看它对寄存器做了什么(QEMU 11.1.2 b-l475e-iot01a;embassy-stm32 用模板里打过补丁的副本,和 0.6.0 只差 rcc/bd.rs 里注释掉的 while !cr.read().dbp() {} 一行,否则 init 会卡在 PWR 上;tick-hz-1_000):
before init, TICK_HZ 1000
init done, now 0 ticks
after busy loop, now 0 ticks
awaiting Timer::after_millis(10)
[runq: killed after 10s]
-d unimp 的日志里 TIM15 的访问依次是:CR1 写 0、CNT 写 0、PSC 写 0x0f9f(3999,4 MHz / 1000 − 1)、ARR 写 0xffff、CR1 写 4(URS)、EGR 写 1、CR1 写 0、CCR1 写 0x8000、DIER 写 3(UIE | CC1IE)、CR1 写 1;之后 Timer::after_millis(10) 读 CNT(得 0)、CCR2 写 0xa、读改写 DIER(读回 0,写成 4,只剩 CC2IE)。计数器读出来永远是 0,now() 停在 0,闹钟不会响,任务一直挂着。
对照:模板里的 SysTick 驱动少了什么
QEMU 实验用的是模板 base-qemu 里手写的驱动(本卷的 QEMU 实验都用它):
impl embassy_time_driver::Driver for SysTickDriver {
fn now(&self) -> u64 {
critical_section::with(|cs| self.ticks.borrow(cs).get())
}
fn schedule_wake(&self, at: u64, waker: &Waker) {
critical_section::with(|cs| {
self.queue.borrow(cs).borrow_mut().schedule_wake(at, waker);
});
}
}
#[exception]
fn SysTick() {
critical_section::with(|cs| {
let t = DRIVER.ticks.borrow(cs).get() + 1;
DRIVER.ticks.borrow(cs).set(t);
DRIVER.queue.borrow(cs).borrow_mut().next_expiration(t);
});
}
SysTick 每 1 ms 中断一次,中断里 tick 加一并处理一遍队列。它没有闹钟:schedule_wake 的返回值直接丢掉,因为下一个毫秒反正会检查。tick-hz-1_000。在 QEMU 上量三件事(QEMU 11.1.2 b-l475e-iot01a,同样的 PWR 补丁,-icount shift=4 让虚拟时间按执行的指令数走;下面的「微秒」是 SysTick 的 4000 个计数折成 1000,是 QEMU 的虚拟时间,不是硬件时间;每个程序跑两次,下面是第一次。精度实验第二次的数和第一次差 1 到 11 个单位(269 对 274、2522 对 2533),中断次数和关中断那一组两次完全相同):
started near end of tick : Timer::after_micros(1000) waited 269 us
started near start of tick: Timer::after_micros(1000) waited 1264 us
started near end of tick : Timer::after_micros(1500) waited 1521 us
started near start of tick: Timer::after_micros(1500) waited 2522 us
started near end of tick : Timer::after_micros(1) waited 266 us
one Timer::after_millis(1000): 1000 SysTick interrupts
spin(200000): interrupts on 9 ticks, inside critical_section 1 ticks
spin(2000000): interrupts on 96 ticks, inside critical_section 1 ticks
前五行:在一个 tick 快结束时开始等 1000 µs,只等到下一个 tick 边界,约 269(其中二百多是中断、唤醒、poll 和测量本身在 QEMU 里的开销);在 tick 刚开始时开始,等满一整个 tick 再加开销。after_micros(1) 和 after_micros(1000) 等得一样久:1 tick 就是最小单位。第六行:一个 1 秒的定时器期间 SysTick 中断了 1000 次,每一次都把 CPU 从 WFE 里拉出来。最后两行:同一段忙循环,开着中断走了 96 tick,放进 critical_section::with 只走了 1 tick,SysTick 的挂起位只能记一次,其余 95 次中断丢了,时间永久慢 95 ms。
| 模板 SysTick 驱动 | embassy-stm32 TIM 驱动 | |
|---|---|---|
| 时间来源 | 软件 u64 计数,中断里加一 | 硬件计数器 + 半圈中断维护的 period |
| 关中断时 | tick 丢失,永久变慢(实测 96 → 1) | 硬件照走;超过半圈 now() 暂时倒退,再久才永久丢 |
| 回绕 | u64,1 kHz 下 5.8 亿年 | 47 位:32768 Hz 下 136 年,1 MHz 下 4.46 年 |
| 闹钟 | 没有,每 tick 扫一遍队列 | 比较通道 2,精确到 tick |
| 中断次数 | 每秒 1000 次,不管有没有定时器 | 每 32768 tick 一次半圈中断,加上真正到期的闹钟 |
| 精度 | 1 ms | 1 / TICK_HZ |
| 低功耗 | 无暂停、恢复逻辑 | low-power 下由 RTC(或 LPTIM 版驱动)接手,进 STOP |
同一个 QEMU 程序(SysTick 驱动,tick-hz-1_000),先 block_for(Duration::from_millis(5)),打印用了几个 tick;再在 critical_section::with(|_| block_for(Duration::from_millis(5))) 里做一遍,打印「returned」。第二段会打印什么?换成 TIM 驱动呢?答案在本页末尾。
动脑筋答案
QEMU 11.1.2 b-l475e-iot01a,PWR 补丁,release,用 runq 10 限时:
block_for(5ms) outside critical section: 5 ticks
block_for(5ms) inside critical section ...
[runq: killed after 10s]
第二段永远不返回。block_for 是 while Instant::now() < expires_at {};SysTick 驱动的 now() 只在中断里前进,关着中断它一直停在进入临界区时的值,循环条件永远成立,10 秒后被 runq 杀掉。
TIM 驱动的 now() 读的是硬件计数器,关着中断也在走。5 ms 在 1 MHz 下是 5000 tick、在 32768 Hz 下是 164 tick,都远小于半圈,block_for 会正常返回。但如果关中断等得比半圈还久,now() 会倒退 65536 tick(上面的模拟),block_for 就可能多等一圈。这一段是按源码推的,QEMU 上没有 TIM 可跑。
小结
- 驱动接口在 0.2.0(#3593)收成
now和schedule_wake两个函数,队列由驱动自己持有;schedule_wake没有错误路径,做不到只能提前唤醒。 time_driver_impl!定义_embassy_time_now/_embassy_time_schedule_wake两个符号;没有驱动是链接错误,两个驱动在同一 crate 是编译错误,分在两个 crate 时 rust-lld 报错,macOS 的 ld 却可能只警告甚至一声不吭地选一个。- std 驱动用闹钟线程加条件变量,队列空时每秒醒一次;mock 驱动只在
advance时前进,测试要串行跑。 - stm32 TIM 驱动:
period每半圈加一,calc_now用奇偶修正读竞争,中断可以晚半圈;总共 47 位,32768 Hz 下 136 年,1 MHz 下 4.46 年;预分频放不进 16 位就 panic,除不尽就不准;闹钟用比较通道 2,远于 0xC000 tick 时延后打开。 - 模板的 SysTick 驱动每毫秒中断一次、没有闹钟、关中断就丢 tick,在临界区里
block_for会死等。