Skip to content

[Bug] [libc] timer_gettime 拆秒前将总纳秒截断为 uint32_t,剩余时间查询错误 #11924

Open

Description

@dongly

RT-Thread Version

master, commit a59e72fb5c2f272761a2813fc9bd9fe18eb29d7d.

Affected area

libc / POSIX timers, components/libc/compilers/common/ctime.c, timer_gettime().

Hardware / Architecture / Toolchain

采用源码级算术隔离复现,不依赖特定开发板。该提交中的受影响实现由 RT_USING_POSIX_TIMER && RT_USING_CLOCK_TIME 启用。本报告尚未进行目标板复现。

问题描述 / Describe the bug

timer_gettime() 将总剩余纳秒存入 rt_uint32_t,之后才拆分秒和纳秒:

rt_uint32_t seconds, nanoseconds;
/* ... */
nanoseconds = ((remain_cnt - rt_clock_time_get_counter())
               * rt_clock_time_get_res_scaled()) / RT_CLOCK_TIME_RESMUL;
seconds     = nanoseconds / NANOSECOND_PER_SECOND;
nanoseconds = nanoseconds % NANOSECOND_PER_SECOND;

固定版本源码 L1337–1375

即使计数器表达式正确算出 10 秒,即 10,000,000,000 ns,赋给 32 位无符号变量时也会变成 1,410,065,408 ns。查询结果因此是 {tv_sec=1, tv_nsec=410065408},而不是 {10, 0}。

可运行的隔离复现 / Standalone arithmetic reproduction

下面明确模拟计数器换算已经正确得到总纳秒的情况,不调用宿主 libc 的 timer_gettime():

#include <assert.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    uint64_t converted_ns = UINT64_C(10000000000);
    uint32_t seconds, nanoseconds;

    nanoseconds = converted_ns;
    seconds = nanoseconds / 1000000000;
    nanoseconds %= 1000000000;

    printf("%u.%09u\n", (unsigned)seconds, (unsigned)nanoseconds);
    assert(seconds == 1 && nanoseconds == 410065408);
    return 0;
}

输出:1.410065408;预期剩余时间为 10.000000000。

此前还在本地旧版同一函数的隔离计数器桩中复现过该结果;最新上游同样保留上述窄化顺序。完整目标端验证仍待进行。

Scope / Suggested direction

  • 问题从总纳秒超过 UINT32_MAX(约 4.295 秒)起出现,不是几十年后的时间范围问题。
  • 这是剩余时间查询结果错误,不表示定时器一定提前或延后触发。
  • 后面的 (rt_int32_t)seconds 不是这里的根因,数据在拆秒之前已经丢失。
  • 建议总纳秒使用足够宽的类型,再拆分秒和纳秒;或在安全的单位下先拆秒、再换算余数。还应保留对计数器回绕及中间乘法范围的检查。

引入提交和版本 / Regression history

  • 引入提交:0eb75ced70,2023-07-17,[time]时钟框架重构 (#7794);首个包含该提交的正式 release 为 v5.0.2。
  • 此前使用 64 位 remaining 保存总纳秒,再分别赋值给秒和纳秒字段;该提交改为将总纳秒直接赋给 32 位 nanoseconds,然后再拆秒。
  • rt_uint32_t seconds, nanoseconds 的声明本身在更早实现中已经存在,但当时用于保存拆分后的字段,并非这次回归的来源。
  • 后续 743b614875 的时间子系统重构更换了计数接口名称,但保留了该窄化顺序。

其他实现参考 / Comparison

Linux 使用 64 位 ktime_t 转 timespec64;Zephyr 则先从剩余毫秒中拆秒,再将余数转换为纳秒。两种方式均避免这里的总纳秒 32 位中转。这不是对这些 OS 所有计时边界行为的保证。

Activity

  1. github-actions commented on Oct 23, 2026

    @github-actions

    如需认领并处理此 Issue,请评论 /claim。

    To claim and work on this issue, comment /claim.

  2. Yunlk commented on Oct 24, 2026

    @Yunlk
    Contributor

    在 QEMU 上实测了一下,uint32 截断这一点确认成立,但同时发现:只把宽度改成 64 位还不够,在这块 BSP 上算出来的剩余时间本身就不对。分开说。

    1. uint32 截断:确认

    ctime.c:1337 的 timer_gettime():

    rt_uint32_t seconds, nanoseconds;          /* :1340  nanoseconds 存的是总纳秒 */
    ...
    nanoseconds = ((remain_cnt - rt_clock_time_get_counter()) * rt_clock_time_get_res_scaled()) / RT_CLOCK_TIME_RESMUL;
    seconds     = nanoseconds / NANOSECOND_PER_SECOND;
    nanoseconds = nanoseconds % NANOSECOND_PER_SECOND;

    总纳秒塞进 rt_uint32_t,超过 UINT32_MAX(约 4.29 s)就截断。10 s 定时器的算术对得上你的数字:10,000,000,000 mod 2^32 = 1,410,065,408 → {1 s, 410065408 ns}。

    2. 实测:截断去掉后暴露出更深的问题

    我把总纳秒改成 rt_uint64_t 后重跑(bsp/qemu-vexpress-a9,10 s 一次性定时器,CLOCK_MONOTONIC,SIGEV_SIGNAL,create/settime/gettime 三个调用都返回 0):

    设定 修复前 修复后(总纳秒改 64 位)
    1 s — {7598 s, 0 ns}
    3 s — {4347 s, 265926290 ns}
    5 s — {1096 s, 531852580 ns}
    10 s {2 s, 640384201 ns} {2193 s, 73705161 ns}

    注意两件事:

    • 修复后的值是非单调的乱数(1s→7598s、3s→4347s、5s→1096s、10s→2193s),不是简单的比例错误
    • 修复前的 10 s 读数我这边是 {2 s, 640384201 ns},不是你说的 {1 s, 410065408 ns}——取模相位取决于读取时刻,这个差异本身正常

    这组数据说明 remain_cnt - rt_clock_time_get_counter() 这个减法在做的时候就下溢了:32 位时代乘法回绕把大数又绕回了"看起来合理"的小范围,所以截断问题掩盖了它。

    3. 想请你确认的点

    1. RT_TIMER_CTRL_GET_REMAIN_TIME 在你的实现里返回的是绝对到期计数还是剩余时长?代码这里当成绝对到期计数和 rt_clock_time_get_counter() 相减;如果它其实返回剩余时长,这个减法就没有意义。
    2. 你的"纯算术隔离复现"是脱离定时器、直接构造总纳秒来验的,还是也跑在真实定时器上?如果是前者,那它证明的是第 1 节那个缺陷,第 2 节这个问题它覆盖不到。
    3. 我这边是 bsp/qemu-vexpress-a9 默认配置(RT_USING_POSIX_TIMER / RT_USING_CLOCK_TIME 本来就开),RT_TICK_PER_SECOND 用 BSP 默认。你复现时的 BSP 和时钟配置是什么?

    4. 关于修复建议

    你的建议是"总纳秒改 64 位再拆秒,或先拆秒再换算余数"。第 1 种我试了,单独做不够(见第 2 节)。如果确认 remain_cnt 的语义就是问题所在,那修复应该同时处理这个减法——比如拿到期计数与当前计数比较前先确认二者在同一计数域。

    我没有提 PR:一是我还没搞清楚第 2 节那个下溢的来源,二是这种"改了反而更离谱"的补丁不该直接进仓库。确认之后我可以按结论出补丁 + utest(utest 里可以同时锁住"10 s 读到 10 s"和"4.29 s 边界不翻转")。

    附:utest 的写法建议

    如果要用 utest 覆盖,注意别只断言 tv_sec == 10——那样在时钟域有偏差的 BSP 上会误报。建议同时断言:

    • tv_sec 等于设定值(±1 容差)
    • 用 3 s(低于 4.29 s 阈值)做对照,确认小值本来是对的
    • 用 5 s 验证跨界后不再出现"{0 s, 7xxxxxxx ns}"这种翻转
  3. Yunlk commented on Oct 24, 2026

    @Yunlk
    Contributor

    /claim

  4. github-actions commented on Oct 24, 2026

    @github-actions

    @Yunlk has claimed this issue.

  5. Yunlk commented on Oct 24, 2026

    @Yunlk
    Contributor

    接着上一条评论继续往下查了,结论要修正:问题比"uint32 截断"更靠上游,而且我试的两种修法都没修好。把过程和失败记录放上来,避免别人重复走我这条路。

    我试了两轮,都无效

    第一轮:把总纳秒改成 64 位(rt_uint64_t total_ns),拆秒用 64 位。

    第二轮:在上一轮基础上,把减法也改成像 hrtimer 核心那样在 32 位空间做(无符号回绕 + 过半判过期):

    remain_cnt -= (unsigned long)rt_clock_time_get_counter();
    if (remain_cnt > (0xFFFFFFFFUL / 2)) { remain_cnt = 0; }
    total_ns = ((rt_uint64_t)remain_cnt * rt_clock_time_get_res_scaled()) / RT_CLOCK_TIME_RESMUL;

    两轮实测(bsp/qemu-vexpress-a9,SIGEV_SIGNAL,create/settime/gettime 全部返回 0):

    设定 master 第一轮 第二轮
    1 s — 7598 s 7597 s
    3 s — 4347 s 4347 s
    5 s — 1096 s 1096 s
    10 s 2 s(截断) 2193 s 2193 s
    60 s — — 13158 s

    第二轮和第一轮几乎完全一样,说明我改的那两处根本没碰到病根:读回来的 remain_cnt 本身就不是一个可用的"剩余量"。

    代码层面为什么

    三处关键事实:

    1. timeout_cnt 是 32 位(components/drivers/include/drivers/clock_time.h:147-148):
    struct rt_clock_hrtimer
    {
        ...
        unsigned long        delay_cnt;
        unsigned long        timeout_cnt;     /* ARM 上 4 字节 */
    };
    1. 计数器是 64 位(同文件 :165):
    rt_uint64_t rt_clock_time_get_counter(void);
    1. hrtimer 自己从不把原始计数器和 timeout_cnt 直接相减。它走 _cnt_convert()(clock_hrtimer.c:127):
    count = cnt - _clock_time_get_cnt();           /* :134 两边都是 32 位,无符号回绕 */
    if (count > (_HRTIMER_MAX_CNT / 2)) return 0;  /* :135 过半视为已过期 */
    ...
    result = ((rt_uint64_t)count * src_res) / event_res;   /* :147 还会换算到 event 分辨率 */

    注意 :147 用的是 event_res(hrtimer 事件分辨率),不是 RT_CLOCK_TIME_RESMUL。而 timer_gettime() 用的是后者。

    而 timer_gettime() 做的是(ctime.c:1358-1360):

    unsigned long remain_cnt;
    rt_clock_hrtimer_control(&timer->hrtimer, RT_TIMER_CTRL_GET_REMAIN_TIME, &remain_cnt);  /* 32 位绝对值 */
    nanoseconds = ((remain_cnt - rt_clock_time_get_counter()) * rt_clock_time_get_res_scaled()) / RT_CLOCK_TIME_RESMUL;

    它把 32 位的 timeout_cnt 和 64 位的原始计数器相减,再按 RESMUL 换算——既没有 _cnt_convert() 的回绕/过期处理,也没有用 event 分辨率。所以拿到的数必然是错的,改宽度只能改变错误的"形状"(从截断的小数变成巨大的乱数)。

    结论

    timer_gettime() 的问题不是"少一个 64 位变量",而是它自己重新实现了一遍剩余时间换算,且用的是错误的计数域和分辨率。正确的做法应该是复用一个和 _cnt_convert() 同源的接口,比如给 hrtimer 增加一个返回"剩余 tick(已换算到 event 分辨率)"的控制命令,timer_gettime 只负责把它拆成秒和纳秒。

    这需要动 clock_time.h / clock_hrtimer.c 的接口,不是 libc 单文件能解决的,所以我没有提 PR——我这两轮的补丁既然修不好,提交上去只会浪费评审时间。

    想请你确认的两点

    1. 你复现时得到的 {1 s, 410065408 ns},是在真实定时器上读出来的,还是纯算术构造?如果是前者,你那边的 res_scaled / event_res / 计数器频率是多少?我这边算出来的数差了几个数量级,怀疑是分辨率假设不同。
    2. RT_TIMER_CTRL_GET_REMAIN_TIME 的文档写的是"获取绝对到期计数值"。按上面第 3 点看,这个"绝对"是 32 位环绕空间里的绝对,直接和 64 位计数器比较是错的。你认同这个理解吗?如果认同,那这个 API 的返回语义本身也需要在文档里说清楚,否则每个调用方都会踩同一个坑。

    如果需要,我可以按"新增一个返回剩余 tick 的控制命令"的方向出一版补丁——但那要先确认第 2 点的语义,我不想再猜一次。

  6. Yunlk commented on Oct 24, 2026

    @Yunlk
    Contributor

    补最后一组诊断数据——它解释了为什么我前面两轮修改都无效,也说明问题可能不在 timer_gettime() 本身。

    实测:两个"分辨率"互相矛盾

    在 bsp/qemu-vexpress-a9 上直接读时钟子系统的公开接口:

    res_scaled       = 23281316134912     (≈2.33e13)
    counter @t0      = 165
    counter @t0+1s   = 265                (rt_thread_mdelay(1000) 之后)
    delta per 1 s    = 100
    

    换算:

    • 按 delta per 1 s = 100 推,计数器是 100 Hz,每 tick = 10 ms
    • 按 res_scaled / RT_CLOCK_TIME_RESMUL = 2.33e13 / 1e6 推,每 tick = 23.3 ms,即约 43 Hz

    两者不一致。 也就是说 rt_clock_time_get_counter() 返回的计数域,和 rt_clock_time_get_res_scaled() 描述的分辨率,在这块 BSP 上不是同一个时钟。

    对照 timer_settime() 里那条逆运算:

    timer->reload = (ns * RT_CLOCK_TIME_RESMUL) / res;    /* :1453 */

    10 秒 → reload = 1000(诊断里也算出 1000),与 100 Hz 自洽。所以 arm 用的是真实计数器频率。

    而 timer_gettime():

    nanoseconds = ((remain_cnt - rt_clock_time_get_counter()) * rt_clock_time_get_res_scaled()) / RT_CLOCK_TIME_RESMUL;

    减法得到的是按 100 Hz 计的 tick 数,乘的却是按 43 Hz 折算的 res_scaled。两边单位不匹配,结果自然不对——这也解释了为什么我把总纳秒改成 64 位(第一轮)、再把减法改成 32 位回绕安全(第二轮)都没有任何变化:都不是病根。

    10 秒定时器读出 {2 s, 600384201 ns},反推 remain_cnt ≈ 111 tick,而按 100 Hz 应该是 ~1000 tick。这个 111 与"1000 按某个比例缩放"也不吻合,说明除了分辨率不匹配,剩余值本身可能还经过了别的换算,我没有继续追。

    我现在的判断

    这不是 timer_gettime() 一个函数的问题,而是时钟接口的契约问题:

    1. timeout_cnt / delay_cnt 是 unsigned long(32 位),而 rt_clock_time_get_counter() 返回 rt_uint64_t——两者比较/相减时的域不一致(我前面评论里已经指出)。
    2. 更关键的是 rt_clock_time_get_res_scaled() 与 rt_clock_time_get_counter() 在 qemu-vexpress-a9 上描述的不是同一个时钟。任何把这两个接口混用的调用方都会算错,timer_gettime() 只是其中一个受害者。

    所以我把问题交回给你 / 维护者

    我这边已经连续两轮改错(都记录在上一条评论里),再猜下去只是在消耗评审时间。需要确认的是:

    1. rt_clock_time_get_res_scaled() 在你的 BSP 上是否也等于 2.33e13?如果是不同的值,那说明这块 BSP 的 clock_time 实现本身有问题,issue 应该改成"res_scaled 与 counter 不一致"。
    2. 如果两者本来就设计成不同域,那 timer_gettime() 应该用哪个?我倾向复用 _cnt_convert() 的逻辑(它同时处理回绕并按 event_res 换算),但那是 clock_hrtimer.c 的静态函数,需要先由维护者提供一个公开的"剩余 tick"接口。

    我不再提交补丁,除非上述语义被确认。这条 issue 的修复方向需要熟悉 clock_time 子系统的人给出契约说明,然后我可以在明确契约的前提下补丁 + utest。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions