Rust in Everything
基于 embassy-time 0.5.1

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

Instant 和 Duration 各只有一个字段:

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_HZGCD_1Kfrom_millisGCD_1Mfrom_micros
10001000× 1 / 11000× 1 / 1000 向上取整
327688× 4096 / 125 向上取整64× 512 / 15625 向上取整
1 000 0001000× 1000 / 11 000 000× 1 / 1

1 MHz 时微秒和 tick 一一对应,换算是恒等;32768 Hz 时毫秒和微秒都除不尽,取整方向就要紧了。

❗IMPORTANT
动脑筋:32768 Hz 下 1 毫秒是几个 tick?换回来是多少微秒?

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>先向下到微秒,再向上到 tickTryFrom 实现:Self::from_micros(value.as_micros().try_into()?)

同一个数,Duration 和 Instant 取整方向相反。Duration 是「要等多久」,少等一点是 bug,所以向上;Instant 是「某个时刻」,没有天然的安全方向,跟着整数除法向下。

📝NOTE
冷知识:向上取整是 2022 年 6 月才改的

最早的 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。
📝NOTE
没有蠢问题

问: 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_HZu64::MAX tick 约合
10005.8 亿年
327681784 万年
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 都是一个 u64 tick;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_nanos 9.1 年溢出,debug panic,release 悄悄给错数;运算符溢出总是 panic。

💬 关联讨论

发起讨论
加载中...