在机器人软件里,很多时间相关故障并不是算法错误,也不是控制错误,而是时间语义错误。

典型现象包括:超时判断偶发失效、周期任务持续漂移、日志时间与内部耗时分析混在一起、传感器时间戳无法对齐。

第一阶段的目标是要先解决一个基础且重要的问题:

把 Linux 的时间轴用对,把用户态接口用对,把“时刻”和“时长”分开

本文只讨论三件事:

  1. CLOCK_REALTIME / CLOCK_MONOTONIC / CLOCK_MONOTONIC_RAW / CLOCK_BOOTTIME / CLOCK_TAI 的语义区别
  2. clock_gettime()、clock_getres()、nanosleep()、clock_nanosleep()、timerfd 这些接口与不同 clock 的关系
  3. 代码里什么时候该用绝对时间,什么时候该用单调时间

一. Linux 的时间区分

Linux 提供的是多条不同语义的时间线。时间相关代码首先要回答的是当前逻辑处理的是绝对时间,还是单调时间

两类问题必须先分开:

类别关注点用途
绝对时间现实世界现在几点日志、文件时间、事件发生时刻、对外协议时间
单调时间从某个起点到现在过去多久timeout、deadline、耗时测量、周期控制、watchdog

二. 五个clock

2.1 总览表

Clock本质语义是否可能跳变适合不适合
CLOCK_REALTIME墙上时间、日历时间会日志、记录现实时刻、对外展示timeout、耗时、周期控制
CLOCK_MONOTONIC单调递增时间轴基本不倒退超时、耗时、周期、deadline表示现实世界当前时刻
CLOCK_MONOTONIC_RAW更接近底层硬件原始节拍的单调时间不倒退漂移分析、jitter 分析、时钟行为研究普通业务代码主时钟
CLOCK_BOOTTIME包含 suspend 的开机后经过时间不倒退跨 suspend 的定时/超时日历时间表达
CLOCK_TAI连续原子时绝对时间轴通常不按 UTC 方式跳秒高精度绝对时间、未来对时体系普通应用随手乱用

2.2 CLOCK_REALTIME:墙上时间

CLOCK_REALTIME 表示现实世界中的日期与时间,例如:

  • 2026-04-19 10:30:00
  • date 命令看到的系统时间
  • 日志中给人阅读的时刻

它有两个关键特征:

  • 可被人工修改
  • 可被 NTP/PTP 等同步机制调整

这意味着它可能前跳,也可能后跳。因此:

  • 适合:日志、文件时间、事件发生时刻、对外展示
  • 不适合:超时判断、周期控制、耗时测量

CLOCK_REALTIME 解决“现在几点”,不解决“过了多久”。


2.3 CLOCK_MONOTONIC:工程计时主力

CLOCK_MONOTONIC 是一条只向前走的单调时间线。
它通常是机器人软件内部最常用、最重要的 clock。

适用场景非常集中:

  • 函数耗时统计
  • 服务调用 timeout
  • 控制周期 deadline
  • 心跳监测
  • watchdog
  • 延迟测量

这些场景关心的是时间差,不是“当前几点”。
即便系统墙上时间被修改,MONOTONIC 的时间差语义仍然可靠。

只要问题是“持续了多久”“是否超时”“周期是否到点”,首选 CLOCK_MONOTONIC。


2.4 CLOCK_MONOTONIC_RAW:更原始的单调节拍

CLOCK_MONOTONIC_RAW 也是单调递增的,但它更接近底层硬件 clock source 的原始行为,尽量少受系统时间校正影响。

它的价值主要在分析层,而不是业务层:

  • 观察底层漂移
  • 对比时钟校正前后行为
  • 研究 jitter
  • 做更底层的性能测试

RAW 不是“比 MONOTONIC 更高级”,而是“更适合研究时钟本身”。

普通工程代码通常优先使用 MONOTONIC,只有在确实需要分析原始节拍行为时,才引入 RAW。


2.5 CLOCK_BOOTTIME:把 suspend 算进去

CLOCK_BOOTTIME 可以理解为:

从开机到现在真实经过了多久,包括系统 suspend(挂起) 的那段时间。

这和 MONOTONIC 的区别在于:某些语义下,MONOTONIC 不希望把 suspend 期间算作正常运行时间;而 BOOTTIME 明确希望计算进去。

适合场景:

  • 跨 suspend 的 timeout
  • 休眠唤醒后继续生效的定时逻辑
  • 电源管理相关计时
  • 移动设备、低功耗机器人平台

它解决的是一个语义问题: suspend 期间,到底算不算“时间经过”


2.6 CLOCK_TAI:连续原子时坐标

CLOCK_TAI 对应国际原子时(TAI)。
它和通常所说的 UTC/墙上时间最关键的差异在于:TAI 是连续的,不按 UTC 的闰秒表现来跳变。

第一阶段无需深入到同步系统,只需先建立这个认识:

  • 它属于高精度绝对时间体系
  • 它对未来的 PTP、PHC、硬件时间戳、多设备时间对齐非常重要
  • 它不是普通程序默认应该随手使用的主时钟

三. 五个 clock 的速记版

  • CLOCK_REALTIME:现在几点
  • CLOCK_MONOTONIC:过了多久
  • CLOCK_MONOTONIC_RAW:更原始的底层节拍
  • CLOCK_BOOTTIME:连 suspend 一起算
  • CLOCK_TAI:连续原子时绝对时间轴

其中第一阶段最关键的只有一组:

REALTIME 管时刻,MONOTONIC 管时长。


四. 用户态接口与 clock 的对应关系

4.1 clock_gettime():读取指定时间轴当前值

clock_gettime() 从指定的 clock 读取当前时刻。

示例:

#include <time.h>
#include <stdio.h>

int main() {
	struct timespec { //定义在<time.h>中
	    time_t tv_sec;  // 秒:从纪元时间(1970-01-01 00:00:00 UTC)开始的秒数
	    long   tv_nsec; // 纳秒:0 ~ 999,999,999 之间(不足1秒的部分)
	};
	
	struct timespec ts;

    clock_gettime(CLOCK_REALTIME, &ts);
    printf("REALTIME   : %ld.%09ld\n", ts.tv_sec, ts.tv_nsec);

    clock_gettime(CLOCK_MONOTONIC, &ts);
    printf("MONOTONIC  : %ld.%09ld\n", ts.tv_sec, ts.tv_nsec);

    clock_gettime(CLOCK_MONOTONIC_RAW, &ts);
    printf("RAW        : %ld.%09ld\n", ts.tv_sec, ts.tv_nsec);

    clock_gettime(CLOCK_BOOTTIME, &ts);
    printf("BOOTTIME   : %ld.%09ld\n", ts.tv_sec, ts.tv_nsec);

    clock_gettime(CLOCK_TAI, &ts);
    printf("TAI        : %ld.%09ld\n", ts.tv_sec, ts.tv_nsec);
}

运行结果:

REALTIME   : 1741857221.312567124
MONOTONIC  : 12536.789012789
RAW        : 12536.789015678
BOOTTIME   : 12536.789018901
TAI        : 1741857258.312570123
  • 不是“系统里有一个统一时间,clock_gettime() 把它读出来”
  • 而是“系统里有多条时间线,clock_gettime() 按 clock id 读取其中一条”

4.2 clock_getres():查看分辨率,不等于调度精度

clock_getres() 用来查看某个 clock 对外报告的最小粒度。

#include <time.h>
#include <stdio.h>

int main() {
    struct timespec res;
    clock_getres(CLOCK_MONOTONIC, &res);
    printf("resolution = %ld.%09ld s\n", res.tv_sec, res.tv_nsec);
}

运行结果:最小精度是 1 纳秒

resolution = 0.000000001 s

需要明确三点:

  • 分辨率 ≠ 线程唤醒精度
  • 分辨率 ≠ 定时器触发误差
  • 分辨率 ≠ 控制周期稳定度

实际唤醒精度还受调度器、系统负载、中断延迟、定时器实现、线程优先级等因素影响。
因此 clock_getres() 更适合做平台认知,而不是拿来直接评估实时性能。


4.3 nanosleep():相对睡眠,简单但容易漂移

nanosleep() :从当前时刻开始,再睡一段相对时间。

#include <time.h>

int nanosleep(const struct timespec *req, struct timespec *rem);
参数说明
1. const struct timespec *req

输入:你想休眠多久

struct timespec {
    time_t tv_sec;   // 休眠秒数
    long   tv_nsec;  // 休眠纳秒数(0 ~ 999,999,999)
};
2. struct timespec *rem

输出:休眠被中断后,还剩多少时间没睡完

  • 传 NULL 表示不关心剩余时间
  • 如果被信号中断,rem 会保存未休眠完的时间

例如:

while (running) {
    do_work();
    nanosleep(&(struct timespec){0, 10 * 1000 * 1000}, NULL); // 10ms
}

这看起来像 100 Hz 循环,但本质上并不是严格 100 Hz。
因为每轮总周期是:

do_work() 执行时间 + nanosleep() 设定时间 + 调度延迟

所以它的问题很明确:

  • 工作耗时会叠加进总周期
  • 调度误差会持续积累
  • 长时间运行后节拍越来越漂

因此 nanosleep() 的定位应当是:

  • 简单等待可用
  • 低要求退避可用
  • 严格周期控制不推荐默认使用

4.4 clock_nanosleep():指定 clock,支持绝对睡眠

clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &next, NULL);

高精度、绝对时间、阻塞睡眠
参数说明:

  • CLOCK_MONOTONIC:使用单调时钟(最稳定)
  • TIMER_ABSTIME:表示 next 是绝对时间(不是延时长度)
  • &next:睡到这个时间点再继续
  • NULL:不接收被信号中断的剩余时间

相比 nanosleep(),clock_nanosleep() 更完整,因为它支持:

  • 明确指定基于哪条时间轴
  • 选择绝对时间睡眠(TIMER_ABSTIME)

这是周期控制中非常重要的接口。

示例:

#include <time.h>

static inline void add_ms(struct timespec* t, long ms) {
    t->tv_nsec += ms * 1000000L;  //把毫秒转成纳秒
    while (t->tv_nsec >= 1000000000L) {  //如果纳秒 ≥ 1 秒(10⁹),就需要进位到秒
        t->tv_sec += 1;
        t->tv_nsec -= 1000000000L;
    }
}

int main() {
    struct timespec next;
    clock_gettime(CLOCK_MONOTONIC, &next);

    while (1) {
        add_ms(&next, 10);   // 下一个 10ms 周期点

        // do_work();

        clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &next, NULL);
    }
}

这段写法的关键点在于:

  • 不是“从现在起再睡 10ms”
  • 而是睡到下一个绝对周期点

这样做的好处是:

  • 某一轮慢了一点,不会让后续周期无限累积漂移
  • 周期逻辑围绕 deadline,而不是围绕“当前时刻再加一个睡眠量”

因此在机器人控制、定时采样、周期发布等场景中,推荐形成固定习惯:

周期任务优先考虑 CLOCK_MONOTONIC + clock_nanosleep(TIMER_ABSTIME)。


4.5 timerfd:把定时器纳入事件循环

Linux 专用:把定时器变成一个文件描述符,高精度、无漂移,可配合 epoll 监听,是Linux最优定时器方案。
timerfd 是内核硬件定时器,内核定时器是独立在后台跑的,不受用户态代码快慢影响

头文件
#include <sys/timerfd.h>
3个核心函数
1. 创建定时器
int fd = timerfd_create(CLOCK_MONOTONIC, 0);
  • 作用:创建定时器,返回文件描述符
  • CLOCK_MONOTONIC:稳定单调时钟(首选)
2. 设置/启动定时器

计时起点 = timerfd_settime 成功返回的时刻,不是 main 入口,不是 timerfd_create。it_value 是从该起点到第一次定时器事件的等待时长

timerfd_settime(fd, 0, &new_val, NULL);
  • 配置定时器:首次触发时间 + 循环周期
  • 依赖结构体:
struct itimerspec {
    struct timespec it_value;   // 第一次触发时间
    struct timespec it_interval;// 循环间隔(0=单次触发)
};
3. 读取触发
uint64_t count;
read(fd, &count, 8); 
  • 定时器到期 → fd可读
  • 读取触发次数,否则会一直触发事件
核心特点

✅ 纳秒级精度 | 无时间漂移
✅ 支持 epoll 高并发
✅ 线程安全、稳定可靠

适合场景:

  • 单线程事件驱动架构
  • I/O 与定时器统一管理
  • 状态机程序
  • 网络轮询 + 定时检查
  • 驱动守护逻辑
10ms 循环定时示例
#include <sys/timerfd.h>
#include <stdint.h>

int main() {
    int fd = timerfd_create(CLOCK_MONOTONIC, 0);
    
    // 10ms 循环
    struct itimerspec it = {
        .it_interval = {0, 10000000},  // 周期10ms
        .it_value    = {0, 10000000}   // 首次10ms
    };
    timerfd_settime(fd, 0, &it, NULL);   //启动定时器,计时起点

    while(1) {
        uint64_t c;
        read(fd, &c, 8);   //// 阻塞等待定时器到期,读取累计溢出次数
        work(); // 定时任务
    }
}
阶段 1:初始化
  1. timerfd_create(CLOCK_MONOTONIC,0)
    • 内核创建 timerfd 上下文timerfd_ctx,kernel_cnt = 0,初始化等待队列 wq,创建匿名 inode,返回 fd。
    • ⚠️ 此时定时器还没启动,不计时
  2. 初始化struct itimerspec it结构体,填入首次延时 10ms、循环周期 10ms。
  3. timerfd_settime(fd,0,&it,NULL)
    • 内核拿到it参数,配置 hrtimer 高精度定时器。
    • 调用成功返回这一刻 = 计时起点,hrtimer 开始倒计时it_value=10ms。
    • kernel_cnt保持 0。
  4. 进入while(1)死循环。
阶段 2:第一轮循环,执行read(fd, &c,8)
  1. 用户态调用read,陷入内核,进入timerfd_read回调
  2. 内核检查timerfd_ctx->kernel_cnt → kernel_cnt = 0(还没到期)
    • 把当前用户线程加入 timerfd 的等待队列 wq
    • 线程状态置为可中断休眠,schedule()让出 CPU
    • 用户态代码停在read这里阻塞休眠,不再往下跑,不占用 CPU

线程休眠期间,内核 hrtimer 继续独立倒计时,和用户线程无关。

阶段 3:t=10ms,第一次定时器到期(硬件中断上下文)
  1. 硬件定时器触发中断,进入 hrtimer 中断处理函数
  2. kernel_cnt++ → kernel_cnt = 1
  3. 调用wake_up(&ctx->wq):扫描等待队列,发现我们的线程在休眠,唤醒该线程
  4. 判断it_interval不为 0,内核 hrtimer 自动安排下一次 10ms 到期时间,继续倒计时
  5. 中断处理结束,返回。
阶段 4:线程被唤醒,继续完成 read 系统调用
  1. 线程被调度器重新上 CPU,从 wait_event 之后继续执行timerfd_read
  2. 将kernel_cnt=1拷贝到用户态变量c
  3. 内核清零 kernel_cnt → kernel_cnt = 0
  4. read 系统调用返回,返回值 = 8,回到用户代码。
    • 用户态:c=1
阶段 5:执行定时任务 work ()
  • 运行work()业务代码。
    分两种分支:

分支 A:work () 执行很快(<10ms,理想情况)
  1. work () 快速执行完毕,回到 while 循环开头,再次执行read(fd, &c,8)
  2. 再次进入内核,检查kernel_cnt=0(下一个 10ms 还没到)
  3. 线程再次进入等待队列休眠,卡在 read,等待下一次到期。

循环重复阶段 3~ 阶段 5,每次c=1。

分支 B:work () 执行很慢,耗时 25ms(任务超过周期)

承接上面:read 拿到 c=1,开始执行 work (),需要 25ms 才能跑完

  1. t=20ms:第二次定时器到期,触发中断
    • kernel_cnt++ → kernel_cnt=1
    • 尝试唤醒线程,但线程此刻正在 CPU 上跑 work (),不在等待队列,唤醒不生效;hrtimer 继续安排下一轮 10ms 倒计时
  2. t=30ms:第三次定时器到期,触发中断
    • 线程此刻仍在 CPU 上跑 work (),唤醒不生效,kernel_cnt++ → kernel_cnt=2
    • 继续安排下一轮定时器
  3. t=35ms:work () 终于跑完,回到循环,再次调用read
    • 进入内核,kernel_cnt=2 >0
    • 拷贝数值到用户 c,c=2
    • 内核清零 kernel_cnt=0
    • read直接返回,不会阻塞
  4. 用户拿到 c=2,执行 work ()。

此时如果代码没有 for 循环补跑,只跑一次 work;如果写了 for (i< c),会连续跑两次 work,容易出现任务雪崩。
任务周期远大于设定的10ms,如果业务持续耗时 > 定时器周期,timerfd 这套模型会发生任务堆积,越欠越多,最后线程一直疯狂跑任务,永远不再阻塞,定时器失去 “周期性调度” 的效果


循环往复的规则总结
  1. hrtimer 在内核独立跑,不受用户线程 work 快慢影响,到点就中断、kernel_cnt+1
  2. read 两个作用:
    • kernel_cnt=0:线程阻塞休眠,挂等待队列
    • kernel_cnt>0:把计数拷贝到用户 c,内核 kernel_cnt 清零,read 返回
  3. 唤醒只发生在定时器中断里,wake_up 等待队列上休眠的线程。
  4. 只有 read 成功返回时,kernel_cnt 才清零;中断只会增加 kernel_cnt,不会清零。

五. 绝对时间与单调时间的使用时机

5.1 绝对时间:表示“现实世界哪个时刻”

绝对时间用于表示一个现实世界有意义的时间点,例如:

  • 某张图像是在几点几分采集的
  • 某条日志发生在什么时刻
  • 某条消息带的系统时钟时间是多少

典型对应:

  • CLOCK_REALTIME
  • CLOCK_TAI

关键词是:时刻、日期、当前几点、事件发生时间


5.2 单调时间:表示“已经过去多久”

单调时间用于表示时间差,例如:

  • 两次采样间隔了多久
  • 控制循环是否超时
  • 算法运行了多少毫秒
  • 距离 deadline 还剩多少时间

典型对应:

  • CLOCK_MONOTONIC
  • CLOCK_MONOTONIC_RAW
  • CLOCK_BOOTTIME

关键词是:耗时、超时、间隔、周期、deadline

5.3 最重要的规则

凡是“测持续时间、做超时、跑周期”,优先单调时间;凡是“表示现实世界哪个时刻发生了什么”,才使用绝对时间。


六. 机器人开发中常见的时间语义错误

场景 1:服务调用超时

  • 错误:用 CLOCK_REALTIME 记录开始时刻和当前时刻做差
  • 风险:系统校时后超时逻辑失真
  • 正解:CLOCK_MONOTONIC

场景 2:周期循环

  • 错误:每轮执行结束后 nanosleep(固定时长)
  • 风险:执行时间叠加进周期,长期漂移
  • 正解:CLOCK_MONOTONIC + TIMER_ABSTIME

场景 3:图像采集时间戳

  • “这张图是什么时候采的”是绝对时间问题
  • “这张图距离上一张图隔了多久”是单调时间问题
  • 两种语义不能混用

场景 4:算法耗时统计

  • 正解:CLOCK_MONOTONIC
  • 进一步分析底层节拍行为时,再考虑 CLOCK_MONOTONIC_RAW

场景 5:watchdog / 心跳监测

  • 关心的是“距离上次事件过去多久”
  • 本质是单调时间问题,不是现实时间问题

场景 6:suspend 后 timeout 是否继续生效

  • 如果 suspend 期间也算时间经过,考虑 CLOCK_BOOTTIME
  • 如果不算,通常使用 CLOCK_MONOTONIC

场景 7:日志时间和内部耗时共用同一套时间戳

  • 日志、展示:CLOCK_REALTIME
  • timeout、耗时、周期:CLOCK_MONOTONIC
  • 两套时间并存是正常设计,不是冗余

场景 8:代码里只有 timestamp_ns

下面这种字段设计风险很高:

uint64_t timestamp_ns;

因为它没有说明时间轴。更合理的写法是:

uint64_t realtime_ns;
uint64_t monotonic_ns;
uint64_t tai_ns;
uint64_t device_timestamp_ns;

时间戳有了但语义不明非常危险。


七. Linux时间选型的工程规则

规则 1

测耗时、做 timeout、做周期,优先 CLOCK_MONOTONIC。

规则 2

日志、文件时间、对外展示时间,优先 CLOCK_REALTIME。

规则 3

长期周期任务优先使用绝对 deadline,而不是“干完再睡固定时长”。

规则 4

代码中凡是出现 timestamp,必须明确它属于哪条时间轴。

规则 5

clock_getres() 的分辨率不能等同于线程定时精度。

规则 6

第一阶段先把 REALTIME 和 MONOTONIC 用对,再进入 RAW、TAI、时间同步。

选型速查表

需求推荐选择原因
打日志、记录现实时刻CLOCK_REALTIME + clock_gettime()需要现实世界时间
统计函数耗时CLOCK_MONOTONIC + clock_gettime()关心时间差
判断服务是否超时CLOCK_MONOTONIC避免墙钟跳变影响
周期控制CLOCK_MONOTONIC + clock_nanosleep(TIMER_ABSTIME)避免累计漂移
简单等待nanosleep()简单但不适合严谨周期
事件驱动定时器timerfd_create() + timerfd_settime()方便接入 epoll
分析漂移/jitterCLOCK_MONOTONIC_RAW更接近原始硬件节拍
跨 suspend 的超时CLOCK_BOOTTIMEsuspend 期间也计时
高精度绝对时间体系CLOCK_TAI适合后续对时体系

总结

Linux 时间系统的第一阶段,重点不是同步,而是时间语义。
只要先把下面两件事彻底分开,后续很多问题都会简单很多:

  • “这是现实世界哪个时刻”
  • “这段逻辑已经过去多久”

时刻问题用绝对时间,时长问题用单调时间;日志看 REALTIME,控制看 MONOTONIC。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐