Instant 与 Duration
主线位置:定时器主线的第一步。
Timer::after_millis(100)先把 100 ms 换成 tick 数,再加到Instant::now()上,得到一个到期时刻;后面交给驱动和队列的只有这个u64。
flowchart TB feat["tick-hz-* feature"] --> hz["TICK_HZ 常量"] hz --> gcd["GCD_1K 1M 1G"] gcd --> from["from_millis 等"] gcd --> as["as_millis 等"] from --> dur["Duration ticks"] drv["driver now"] --> inst["Instant ticks"] dur --> add["Instant 加 Duration"] inst --> add add --> at["到期时刻 u64"]
两个类型,一个 u64
pub struct Instant {
ticks: u64,
}
pub struct Duration {
pub(crate) ticks: u64,
}
Instant 是开机以来的 tick 数,Duration 是两个 Instant 之差,单位都是 tick,不带秒。Instant::now 直接调驱动的 now(),见时间驱动。两个类型的 Display 都打印 N ticks,要看毫秒得自己调 as_millis()。
一个 tick 有多长,整个程序只有一个答案:embassy_time::TICK_HZ,一个编译期常量。所有换算都在编译期折叠成乘除常数,运行时没有浮点,也没有查表。
TICK_HZ 从哪来
常量定义在 embassy-time-driver 里,TICK_HZ 转手取自 tick.rs。tick.rs 是 488 行的 cfg 列表:
// Generated by gen_tick.py. DO NOT EDIT.
#[cfg(feature = "tick-hz-1")]
pub const TICK_HZ: u64 = 1;
#[cfg(feature = "tick-hz-2")]
pub const TICK_HZ: u64 = 2;
// ...
#[cfg(not(any(
feature = "tick-hz-1",
// ...
feature = "tick-hz-5_242_880_000",
)))]
pub const TICK_HZ: u64 = 1_000_000;
它是「生成的」,但不是编译时生成:crate 的 build.rs 只有一行 fn main() {}。生成它的是仓库里的 gen_tick.py:开发者手动运行,按 10 的幂、2 的幂、2^i × 1000、2^i × 10000、几组 MHz 值等规则列出 161 个频率,同时改写 embassy-time-driver 和 embassy-time 两个 Cargo.toml 里 # BEGIN TICKS 和 # END TICKS 之间的 feature 列表,再重写 tick.rs,结果提交进仓库。embassy-time 的每个 tick-hz-X 只是转发到 embassy-time-driver/tick-hz-X。最低 1 Hz,最高 5 242 880 000 Hz;CHANGELOG 里 0.2.2 只有一条:加 375 kHz。
谁来选:
- 频率固定的驱动自己打开一个 feature。
embassy-time的std、mock-driver、wasm三个驱动都打开tick-hz-1_000_000。 - 频率可配的驱动不选,留给二进制 crate。embassy-stm32 就是这样:它的 TIM 驱动按
TICK_HZ算预分频值,自己不开tick-hz-*。仓库里 stm32 的例子大多选tick-hz-32_768(tag 上 47 个 stm32 例子目录,44 个的Cargo.toml选了频率:41 个 32768,2 个 1000,1 个 1 MHz)。 - 库 crate 不该选。
Cargo.toml的说明写得很直白:从库里使用 embassy-time 时不要打开任何tick-*feature。
两个 feature 同时打开,tick.rs 里会出现两条 pub const TICK_HZ。在 embassy-time 上开 tick-hz-1_000、在 embassy-time-driver 上开 tick-hz-32_768(两个 crate 各选了一个),M2 Max、rustc 1.99.0 上编译的原文:
error[E0428]: the name `TICK_HZ` is defined multiple times
--> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/embassy-time-driver-0.2.2/src/tick.rs:54:1
|
28 | pub const TICK_HZ: u64 = 1_000;
| ------------------------------- previous definition of the value `TICK_HZ` here
...
54 | pub const TICK_HZ: u64 = 32_768;
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `TICK_HZ` redefined here
|
= note: `TICK_HZ` must be defined only once in the value namespace of this module
报错在 embassy-time-driver 里,不指出是哪个 crate 开了哪个 feature。用 cargo tree -e features -i embassy-time-driver 能查到:输出里 embassy-time-driver feature "tick-hz-1_000" 和 feature "tick-hz-32_768" 两条分支各自指向开它的那个 crate 和 feature。
另一种「两个频率」是两份 embassy-time-driver:比如一个依赖从 crates.io 来、另一个从 git 来,各自编译出一个 TICK_HZ,驱动用一份、用户代码用另一份,延时就全错了。Cargo.toml 里 links = "embassy-time" 防的就是这个:cargo 规定同一个 links 值在依赖图里只能出现一次。把 0.2.2 的源码拷一份、版本号改成 0.3.0 作为第二份依赖,cargo 在解析阶段就拒绝:
package `embassy-time-driver` links to the native library `embassy-time`, but it conflicts with a previous package which links to `embassy-time` as well:
package `embassy-time-driver v0.3.0 (~/exp-embassy/time-links/vendor/embassy-time-driver)`
...
note: only one package in the dependency graph may specify the same links value to ensure that only one copy of a native library is linked in the final binary
这个 crate 并不链接任何本地库,links 在这里只是借 cargo 的唯一性检查。embassy-time-queue-utils 用同样的办法声明了 links = "embassy-time-queue"。
换算:先约分,再乘除
TICK_HZ 是常量,先在 lib.rs 里用 gcd 求出它和 1000、10^6、10^9 的最大公约数:
const fn gcd(a: u64, b: u64) -> u64 {
if b == 0 { a } else { gcd(b, a % b) }
}
pub(crate) const GCD_1K: u64 = gcd(TICK_HZ, 1_000);
pub(crate) const GCD_1M: u64 = gcd(TICK_HZ, 1_000_000);
pub(crate) const GCD_1G: u64 = gcd(TICK_HZ, 1_000_000_000);
毫秒换 tick 是 ms × TICK_HZ / 1000。直接这样算,ms × TICK_HZ 很容易溢出;先把分子分母同除以公约数,乘数和除数都变小。Duration::from_millis 和 Duration::as_millis:
pub const fn from_millis(millis: u64) -> Duration {
Duration {
ticks: div_ceil(millis * (TICK_HZ / GCD_1K), 1000 / GCD_1K),
}
}
// ...
pub const fn as_millis(&self) -> u64 {
self.ticks * (1000 / GCD_1K) / (TICK_HZ / GCD_1K)
}
代进去看三个常见频率(a / b 表示「乘 a 再除以 b」):
TICK_HZ | GCD_1K | from_millis | GCD_1M | from_micros |
|---|---|---|---|---|
| 1000 | 1000 | × 1 / 1 | 1000 | × 1 / 1000 向上取整 |
| 32768 | 8 | × 4096 / 125 向上取整 | 64 | × 512 / 15625 向上取整 |
| 1 000 000 | 1000 | × 1000 / 1 | 1 000 000 | × 1 / 1 |
1 MHz 时微秒和 tick 一一对应,换算是恒等;32768 Hz 时毫秒和微秒都除不尽,取整方向就要紧了。
M2 Max、rustc 1.99.0,embassy-time 开 tick-hz-32_768,打印 Duration::from_millis(1).as_ticks() 和它的 as_micros();再对 3 ms、10 ms 各算一次。三组数里有没有一组能原样换回来?答案在本页末尾。
取整方向
所有 from_*、as_* 的取整方向:
| 函数 | 方向 | 实现 |
|---|---|---|
Duration::from_millis / from_micros / from_nanos | 向上 | div_ceil:(num + den - 1) / den |
Duration::from_*_floor(毫秒、微秒;秒无所谓) | 向下 | 整除 |
Duration::from_secs、Instant::from_secs | 精确 | secs * TICK_HZ(from_secs 的文档写「rounding up」,实际没有可取整的余数) |
Duration::from_hz | 四舍五入 | from_hz:(TICK_HZ + hz / 2) / hz,hz >= TICK_HZ 时钳到 1 tick |
Instant::from_millis / from_micros / from_nanos | 向下 | 整除,例如 Instant::from_micros |
两个类型的 as_secs / as_millis / as_micros / as_nanos | 向下 | 整除 |
TryFrom<core::time::Duration> | 先向下到微秒,再向上到 tick | TryFrom 实现:Self::from_micros(value.as_micros().try_into()?) |
同一个数,Duration 和 Instant 取整方向相反。Duration 是「要等多久」,少等一点是 bug,所以向上;Instant 是「某个时刻」,没有天然的安全方向,跟着整数除法向下。
最早的 Duration::from_millis 是 millis * (TICKS_PER_SECOND / GCD_1K) / (1000 / GCD_1K),整数除法向下取整。#824 的提交说明是「embassy/time: round up by default in Duration::from_*. Fixes #823」:在除不尽的 tick 频率下,向下取整会让延时比要求的短。改法就是现在这样:from_millis、from_micros 换成 div_ceil,原来的行为改名为 from_secs_floor、from_millis_floor、from_micros_floor 保留下来。那时时间代码还在 embassy crate 的 src/time/ 里,embassy-time 单独发布后,第一个版本 0.1.0 就带着它。
在三个频率下各跑一遍(M2 Max、rustc 1.99.0,debug;这些行在 release 下相同;程序里放了一个什么也不做的驱动,好让它能链接,换算本身不调驱动):
TICK_HZ = 1000
1 us: Duration::from_micros= 1 ticks (as_micros 1000), from_micros_floor= 0, Instant::from_micros= 0
30 us: Duration::from_micros= 1 ticks (as_micros 1000), from_micros_floor= 0, Instant::from_micros= 0
31 us: Duration::from_micros= 1 ticks (as_micros 1000), from_micros_floor= 0, Instant::from_micros= 0
1000 us: Duration::from_micros= 1 ticks (as_micros 1000), from_micros_floor= 1, Instant::from_micros= 1
1500 us: Duration::from_micros= 2 ticks (as_micros 2000), from_micros_floor= 1, Instant::from_micros= 1
...
from_hz(3) = 333 ticks
core 1500 ns -> 1 ticks
TICK_HZ = 32768
1 us: Duration::from_micros= 1 ticks (as_micros 30), from_micros_floor= 0, Instant::from_micros= 0
30 us: Duration::from_micros= 1 ticks (as_micros 30), from_micros_floor= 0, Instant::from_micros= 0
31 us: Duration::from_micros= 2 ticks (as_micros 61), from_micros_floor= 1, Instant::from_micros= 1
1000 us: Duration::from_micros= 33 ticks (as_micros 1007), from_micros_floor= 32, Instant::from_micros= 32
1500 us: Duration::from_micros= 50 ticks (as_micros 1525), from_micros_floor= 49, Instant::from_micros= 49
...
from_hz(3) = 10923 ticks
core 1500 ns -> 1 ticks
TICK_HZ = 1000000
1 us: Duration::from_micros= 1 ticks (as_micros 1), from_micros_floor= 1, Instant::from_micros= 1
...
1500 us: Duration::from_micros=1500 ticks (as_micros 1500), from_micros_floor=1500, Instant::from_micros=1500
...
from_hz(3) = 333333 ticks
core 1500 ns -> 1 ticks
几处值得看:
- 1 kHz 下
from_micros(1)是一整毫秒,Instant::from_micros(1500)是 1 ms:同一个 1500,Duration进到 2 tick、Instant退到 1 tick。 - 32768 Hz 下一个 tick 是 30.5 µs,
as_micros打印 30(向下)。from_micros(31)进到 2 tick,换回来是 61 µs,几乎翻倍。 core::time::Duration转过来时先截到整微秒:1500 ns 在 1 MHz 下得到 1 tick,那 500 ns 没了,后面的向上取整救不回来。反方向From<Duration> for core::time::Duration也只走as_micros。
问: Duration 已经向上取整了,Timer::after_millis(1) 是不是至少等满 1 ms?
答: 不是。Timer::after 的到期时刻是 Instant::now() + duration,而 now() 是驱动当前的 tick 计数,本身就是向下取整的:开始时真实时间可能已经走到这个 tick 的末尾。到期判断是「now() >= 到期时刻」,所以真实等待落在 (d - 1 tick, d] 之间,最短可能只比 d - 1 个 tick 多一点点。1 MHz 下这差 1 µs,无所谓;1 kHz 下差 1 ms。时间驱动一页在 QEMU 11.1.2 上(embassy-stm32 打了一行 PWR 补丁,见那一页)量过:1 kHz 的 SysTick 驱动下,同一个 Timer::after_micros(1000),在一个 tick 的末尾开始,只等了 269 个微秒单位,在 tick 开头开始,等了 1264。要「至少 d」就得自己多加 1 tick。
溢出:tick 计数不会,中间结果会
u64 的 tick 计数能撑多久,取决于频率(按格里历平均年 31 556 952 秒算):
TICK_HZ | u64::MAX tick 约合 |
|---|---|
| 1000 | 5.8 亿年 |
| 32768 | 1784 万年 |
| 1 000 000(默认) | 58.5 万年 |
| 5 242 880 000(最高档) | 111 年 |
README 说默认 1 MHz「可以表示约 584558 年,大到不用担心溢出」。tick 计数本身确实如此;但换算的中间结果是先乘后除的,乘法会先溢出。Instant::as_nanos 是 ticks * (1_000_000_000 / GCD_1G) / (TICK_HZ / GCD_1G);32768 = 2^15,10^9 = 2^9 × 5^9,公约数只有 512,乘数是 1 953 125。上面的程序最后算了开机 10 年时的 as_nanos():
TICK_HZ = 32768
...
u64 ticks wrap after 17839174 years
as_nanos overflows after 9444732965739 ticks = 9.1 years
thread 'main' (4680319) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/embassy-time-0.5.1/src/instant.rs:124:9:
attempt to multiply with overflow
这是 debug。release 关掉了溢出检查,不 panic,给出一个错数:
Instant at 10 years: as_secs=315360000 as_nanos=27129623848288256
315 360 000 秒应该是 3.15 × 10^17 纳秒,打印出来的约合 0.86 年。1 kHz 和 1 MHz 下 as_nanos 的乘数分别是 10^6 和 1000,都要 584.6 年才溢出。Duration 的 from_* 也一样先乘:from_millis 在 1 MHz 下乘 1000,millis 超过约 1.8 × 10^16 就溢出;需要判断的场合用 0.5.0 起加的 try_from_*,它们用 checked_mul,溢出返回 None。
Instant + Duration、Instant - Instant、Duration * u32 这些运算符溢出时一律 panic(expect("overflow when adding duration to instant") 之类),release 也一样,因为它们走的是 checked_* 再 expect;不想 panic 用 checked_add / saturating_add。Instant::MAX 是一个常用的「永不」:Timer::at(Instant::MAX) 永远不会到期,队列对它有专门的处理,见定时器队列。
动脑筋答案
tick-hz-32_768,M2 Max、rustc 1.99.0,debug 和 release 相同:
1 ms: from_millis=33 ticks -> as_micros 1007 us
3 ms: from_millis=99 ticks -> as_micros 3021 us
10 ms: from_millis=328 ticks -> as_micros 10009 us
- 1 ms:
1 × 4096 / 125 = 32.768,向上取整到 33 tick;33 tick 换回来33 × 15625 / 512 = 1007.08,向下取整到 1007 µs。 - 3 ms:98.3 进到 99 tick,换回 3021 µs。
- 10 ms:327.68 进到 328 tick,换回 10 009 µs。
三组都换不回原值,而且都偏大:from_millis 向上进了不到 1 tick,as_micros 向下只退了不到 1 µs。32768 Hz 下毫秒数只有是 125 的倍数时才能精确表示(from_millis(125) 正好 125 × 4096 / 125 = 4096 tick)。
小结
Instant和Duration都是一个u64tick;TICK_HZ是编译期常量,默认 1 MHz。- 161 个
tick-hz-*feature 和tick.rs由gen_tick.py离线生成后提交,不是build.rs;同时开两个是E0428,两份embassy-time-driver被links挡在解析阶段。 - 换算先用 GCD 约分再乘除,全部编译期折叠。
Duration::from_*向上取整(#824),Instant::from_*和所有as_*向下取整;core::time::Duration转换先截到微秒。- 默认频率下 tick 计数本身几十万年不溢出,但先乘后除的中间结果会:32768 Hz 下
as_nanos9.1 年溢出,debug panic,release 悄悄给错数;运算符溢出总是 panic。