Lely CANopen: Timer 的实现原理与运行流程
@[toc]
一句话结论
Lely 的 lely::io::Timer 不是一个独立线程,也不是一个直接执行回调的传统软件定时器。它本质上是一个异步事件适配器:
1 | Linux timerfd 负责计时 |
创建 Timer、设置到期时间和提交等待操作是三个不同动作:
1 | 创建 Timer:建立 timerfd 并注册到 io_poll |
从 CANopen 主站视角看,还要再增加两层调度:
1 | BasicMaster / Node |
因此,Lely CANopen 的核心设计不是“每个协议功能创建一个 Linux timerfd”,而是:
一个 CANopen Node 使用一个专用系统 Timer;
io_tqueue和can_net的两级最小堆把大量逻辑超时复用到这个 Timer 上。
1. 从 C++ 创建语句开始
上层代码:
1 | impl_->timer.reset( |
构造函数:
1 | Timer(io_poll_t* poll, ev_exec_t* exec, clockid_t clockid) |
可以确认的创建链路为:
1 | lely::io::Timer::Timer() |
这里完成的是“创建 I/O 定时器基础设施”,还没有指定具体的到期时刻。
1.1 CLOCK_MONOTONIC 的作用
Timer 使用:
1 | CLOCK_MONOTONIC |
表示定时器基于单调时钟运行。它适合协议超时和周期调度,因为系统日历时间被人工修改时,单调时钟不会发生向前或向后的跳变。
对 CANopen 而言,心跳超时、SDO 超时、PDO 事件定时、SYNC 周期等都属于持续时间或截止时间问题,而不是日历时间问题,因此使用单调时钟符合这类任务的需求。
2. io_timer_impl:Timer 的真实内部对象
Linux 实现中的核心结构体为:
1 | struct io_timer_impl { |
关键字段如下:
| 字段 | 职责 |
|---|---|
poll |
监听 timerfd 是否出现可读事件 |
exec |
执行内部任务和等待完成任务 |
clockid |
指定 CLOCK_MONOTONIC 或 CLOCK_REALTIME |
tfd |
Linux timerfd 文件描述符 |
watch |
注册到 io_poll 的监听对象 |
wait_task |
timerfd 就绪后投递给 executor 的内部处理任务 |
wait_queue |
保存等待下一次 Timer 完成的 io_timer_wait |
wait_posted |
防止同一到期事件重复投递内部任务 |
shutdown |
标记 Timer 已进入关闭流程 |
overrun |
保存额外到期次数 |
mtx |
保护等待队列和状态位 |
2.1 一个对象同时暴露多个接口
io_timer_impl 同时包含:
1 | dev_vptr → io_dev_t 接口 |
Lely 使用类似 container_of() 的 structof() 机制,由成员地址反推出完整对象地址:
1 | static inline struct io_timer_impl * |
这也是为什么 io_timer_alloc() 返回的不是 impl,而是:
1 | return &impl->timer_vptr; |
3. 打开 Timer:timerfd_create() 与 io_poll_watch()
真正创建 Linux 定时器的代码位于:
1 | static int |
3.1 TFD_NONBLOCK
TFD_NONBLOCK 让:
1 | read(impl->tfd, ...) |
在当前没有到期计数可读时立即返回 EAGAIN 或 EWOULDBLOCK,而不是阻塞 executor 线程。
这是异步事件循环中的关键要求。若内部处理任务在 read() 上阻塞,使用同一个 executor 的其他 CANopen 任务也可能被阻塞。
3.2 TFD_CLOEXEC
TFD_CLOEXEC 表示进程执行 exec() 加载新程序时,内核自动关闭该文件描述符,避免子程序意外继承 Timer 资源。
它只影响 exec(),不阻止 fork() 继承。fork() 的处理由后面的 io_timer_impl_svc_notify_fork() 完成。
3.3 io_poll_watch(..., IO_EVENT_IN, ...)
该调用的语义是:
1 | 请 io_poll 监听 impl->tfd; |
IO_EVENT_IN 对应“可读事件”。对 timerfd 而言,可读意味着至少发生过一次定时到期,内核中存在可读取的 64 位到期计数。
这条 if 判断的是“注册监听是否失败”,不是判断 Timer 是否已经到期。
4. 设置时间:io_timer_impl_settime()
Timer 的虚函数表包含:
1 | static const struct io_timer_vtbl io_timer_impl_vtbl = { |
其中设置时间的实现为:
1 | static int |
4.1 flags_ |= TFD_TIMER_ABSTIME 是什么
这句把 Lely 通用定时接口中的:
1 | TIMER_ABSTIME |
转换为 Linux timerfd 使用的:
1 | TFD_TIMER_ABSTIME |
两种模式的区别:
| 模式 | it_value 的含义 |
|---|---|
未设置 TFD_TIMER_ABSTIME |
从现在开始再等待多长时间 |
设置 TFD_TIMER_ABSTIME |
当指定 clock 到达哪个绝对时间点时到期 |
假设当前 CLOCK_MONOTONIC 为 100 秒,it_value 为 10 秒:
1 | 相对模式:在 110 秒时到期 |
绝对时间模式常用于避免周期调度的累计漂移。上层可以始终维护固定截止时间轴,而不是在每次任务执行完后再重新增加一个相对周期。
需要注意:TFD_TIMER_ABSTIME 主要改变首次到期值 it_value 的解释方式;周期字段 it_interval 仍表示每次到期后的相对间隔。
4.2 TimerBase 的完整 C++ API 已可确认
官方 include/lely/io2/timer.hpp 显示,TimerBase 不是只有一个模糊的“异步等待接口”,而是提供了完整的时间和等待操作封装:
| C++ 接口 | 下层 C 接口 | 语义 |
|---|---|---|
get_clock() |
io_timer_get_clock() |
获取 Timer 使用的时钟 |
getoverrun() |
io_timer_getoverrun() |
获取最近一次额外到期次数 |
gettime() |
io_timer_gettime() |
获取剩余时间和周期 |
settime(duration, period) |
io_timer_settime(..., 0, ...) |
按相对时间设置 |
settime(time_point, period) |
io_timer_settime(..., TIMER_ABSTIME, ...) |
按绝对时间设置 |
submit_wait(io_timer_wait&) |
io_timer_submit_wait() |
提交已有等待对象 |
submit_wait(F) |
make_timer_wait_wrapper() |
包装回调并提交;完成后自动释放 wrapper |
cancel_wait() |
io_timer_cancel_wait() |
取消并以错误结果完成 |
abort_wait() |
io_timer_abort_wait() |
移除任务,不走正常完成路径 |
async_wait() |
io_timer_async_wait() |
返回 ev::Future<int, int> |
其中,回调包装器最终执行的函数签名为:
1 | void(int overrun, std::error_code ec) |
overrun == -1 时,ec 由 wait->r.errc 构造;正常到期时,overrun 为额外到期次数。make_timer_wait_wrapper() 创建的对象在任务执行完成后会 delete self,因此提交以后调用方不能再手动释放它。
5. 提交等待:io_timer_impl_submit_wait()
实现如下:
1 | static void |
5.1 它提交的不是“立即执行任务”
更准确的语义是:
将一个一次性的异步等待操作登记到 Timer;当 Timer 下一次到期、出错或被取消时,再把该等待对象中的任务投递给 executor。
因此:
1 | io_timer_impl_submit_wait() |
5.2 默认 executor
如果调用者没有为 wait->task 指定 executor:
1 | if (!task->exec) |
就使用创建 Timer 时传入的默认 executor。
5.3 ev_exec_on_task_init()
这不是执行任务,而是通知 executor:存在一个尚未完成的异步任务。它与完成时的:
1 | ev_exec_on_task_fini(exec); |
形成生命周期配对。
5.4 shutdown 后提交
如果 Timer 已关闭,新提交的 wait 不会进入队列,而是以:
1 | result = -1 |
完成,并被投递给 executor。这样调用者不会得到一个永远无法结束的悬空异步操作。
5.5 同一个 wait 不能重复挂入
队列保存的是:
1 | &task->_node |
同一个链表节点在完成或取消前不能重复加入同一个或其他队列,否则可能破坏链表结构。
6. Timer 到期后发生什么
核心流程分成两个阶段:
1 | poll 阶段:发现 fd 就绪,投递内部任务 |
6.1 第一阶段:io_timer_impl_watch_func()
创建时已经绑定:
1 | impl->watch = IO_POLL_WATCH_INIT( |
因此 timerfd 可读时,io_poll 调用:
1 | static void |
它不读取 timerfd,也不直接执行用户回调,只做两件事:
- 用
wait_posted防止重复投递; - 把内部
wait_task投递给 executor。
这样可避免在 I/O poll 回调上下文中执行较重的处理逻辑。
6.2 为什么需要 wait_posted
如果 poll 在内部处理任务完成前多次报告同一 fd 可读,而每次都投递一个读取任务,就可能出现多个任务竞争读取同一个 timerfd。
wait_posted 保证任意时刻最多有一个内部读取任务待执行:
1 | 第一次事件:wait_posted 0 → 1,投递 wait_task |
7. 第二阶段:io_timer_impl_wait_task_func()
内部任务由 executor 执行:
1 | static void |
该函数是整个 Timer 完成路径的核心。
7.1 读取 timerfd 到期计数
1 | uintmax_t value = 0; |
一次成功读取返回 8 字节计数,表示自上次成功读取后累计发生了多少次到期。
例如周期为 10 ms,但 executor 35 ms 后才处理,该值可能为 3。
代码循环读取,直到返回 EAGAIN:
1 | 持续读取所有当前积压的到期计数 |
因为创建时设置了 TFD_NONBLOCK,读空时不会阻塞。
7.2 overrun 的含义
变量初始为:
1 | int overrun = -1; |
每读取到 value 后执行累计。结果语义为:
1 | overrun = 总到期次数 - 1 |
示例:
| 到期计数 | overrun |
含义 |
|---|---|---|
| 1 | 0 | 正常到期一次 |
| 2 | 1 | 额外积压一次 |
| 3 | 2 | 额外积压两次 |
代码使用饱和处理避免超过 INT_MAX。
7.3 将内部 wait 队列整体转移到局部队列
1 | struct sllist queue; |
这里不是复制,而是将当前 impl->wait_queue 中的等待对象整体移入局部 queue。
效果可以理解为:
1 | 转移前:impl->wait_queue = [A, B, C] |
在解锁后新提交的 wait 会进入新的 impl->wait_queue,不会被本次到期事件完成。
7.4 Linux 后端确定使用 epoll + EPOLLONESHOT
官方 src/io2/linux/poll.c 已经直接证明 Linux 后端的行为:
1 |
事件映射关系为:
1 | IO_EVENT_IN → EPOLLIN | EPOLLRDHUP |
io_poll_watch() 使用 epoll_ctl() 完成:
1 | 第一次监听 → EPOLL_CTL_ADD |
轮询线程通过 epoll_pwait() 获取事件,io_poll_process() 在调用 watch 回调之前先执行:
1 | watch->_events = 0; |
这意味着一次事件发生后,该 watch 在 Lely 的内部状态中也被标记为未激活。Timer 在读空 timerfd、得到 EAGAIN 后执行:
1 | events |= IO_EVENT_IN; |
不是保守性重复注册,而是对 EPOLLONESHOT 的必要 rearm。完整路径已经可以确定为:
1 | epoll_pwait() |
8. io_timer_wait_queue_post() 会调用所有回调吗
内部队列完成函数:
1 | static inline size_t |
答案是:
它会处理传入局部
queue中的所有io_timer_wait,但不是在当前调用栈中直接同步调用所有用户函数,而是逐个把每个 wait 的ev_task投递到对应 executor。
io_timer_wait_post() 做的是:
1 | wait->r.result = result; |
所以实际链路为:
1 | io_timer_wait_queue_post() |
8.1 “所有”的边界
这里的“所有”只包括:
1 | 本次被转移到局部 queue 的等待对象 |
不包括:
- 其他 Timer 的 wait;
- 已完成、已取消或已 abort 的 wait;
- 队列转移后才提交的新 wait;
- 系统中所有历史注册函数;
- 未包装为
io_timer_wait的普通函数。
8.2 wait 是一次性的
队列使用:
1 | sllist_pop_front(queue) |
每个 wait 完成后都会离开等待队列。因此即使底层 timerfd 是周期性的,一次 submit_wait() 也只完成一次。若上层需要持续接收每个周期,必须重新提交 wait,或者由更高层封装自动重新提交。
9. 正常到期的完整时序
1 | sequenceDiagram |
用一句话概括:
1 | 内核产生到期事件,poll 发现事件,executor 读取事件,再由 executor 执行用户等待任务。 |
10. 取消、abort 与 shutdown
10.1 cancel
1 | io_timer_impl_dev_cancel() |
将指定 wait 或全部 wait 从队列中取出,然后:
1 | io_timer_wait_queue_post(&queue, -1, ECANCELED); |
调用者仍会收到完成通知,只是结果为取消。
10.2 abort
1 | io_timer_impl_dev_abort() |
将任务移出队列并调用:
1 | ev_task_queue_abort(&queue); |
语义上,abort 不走正常完成回调路径。
10.3 shutdown
1 | static void |
关闭流程:
1 | shutdown = 1 |
之后 io_timer_fini() 会关闭 timerfd 并销毁互斥锁。
11. io_timer_impl_svc_notify_fork() 为什么存在
实现:
1 | static int |
11.1 Timer 不会主动 fork
这段函数并不意味着 Timer 会创建子进程。它只是作为 io_svc 回调注册到 io_ctx:
1 | impl->svc = IO_SVC_INIT(&io_timer_impl_svc_vtbl); |
只有宿主程序或依赖库确实执行了 fork(),并且 io_ctx 收到相应通知时,该回调才会被调用。
11.2 为什么子进程要重建 timerfd
fork() 后,子进程继承父进程的文件描述符,父子进程中的 fd 指向同一个底层 timerfd 对象。若双方都读取到期计数,可能互相消费事件。
因此子进程执行:
1 | timerfd_gettime() 保存剩余时间和周期 |
父进程继续使用原 timerfd,子进程使用新 timerfd,双方不再共享到期计数。
11.3 与 TFD_CLOEXEC 的区别
| 场景 | 处理方式 |
|---|---|
fork() 后仍继续运行 Lely |
io_timer_impl_svc_notify_fork() 重建 timerfd |
随后 exec() 加载新程序 |
TFD_CLOEXEC 自动关闭 timerfd |
12. 从 BasicMaster 到 Linux Timer:官方源码中的真实所有权
此前不能确认的“完整 CANopen 主站工程调用关系”,在官方 coapp、io2 和 can 源码中可以完整闭环。
12.1 BasicMaster 不创建 Timer,而是接收外部 Timer
BasicMaster 的构造函数接收:
1 | io::TimerBase& timer |
然后直接传给父类 Node:
1 | BasicMaster::BasicMaster(..., io::TimerBase& timer, ...) |
Node 的接口文档明确要求:
传入的 Timer 用于 CANopen 事件,且不得用于其他目的。
Node 构造时继续把 Timer 传给 io::CanNet:
1 | Node::Node(..., io::TimerBase& timer, ...) |
所以在你的工程中:
1 | impl_->timer.reset(new lely::io::Timer(...)); |
impl_->timer 是 Timer 的所有者;Lely 的 BasicMaster/Node 只接收引用并使用它,不负责创建该系统 Timer。
12.2 真正直接保存 io_timer_t* 的是 io_can_net
io::CanNet 将 Timer 传给:
1 | io_can_net_create(exec, timer, chan, ...) |
struct io_can_net 中直接保存:
1 | io_timer_t *timer; |
初始化时:
1 | net->timer = timer; |
因此系统 Timer 的直接使用者是 I/O CAN 网络适配层 io_can_net,而不是 NMT、SDO、PDO 模块。
12.3 io_tqueue 如何用一个系统 Timer 管理多个绝对截止时间
struct io_tqueue 包含:
1 | io_timer_t *timer; // 唯一底层 Timer |
当上层提交一个 io_tqueue_wait 时,io_tqueue_submit_wait():
将等待对象按绝对时间插入
pheap;如果它成为最早截止时间,调用:
1
io_timer_settime(tq->timer, TIMER_ABSTIME, &value, NULL);
若底层尚未提交等待,则只提交一个:
1
io_timer_submit_wait(tq->timer, &tq->wait);
底层 Timer 到期后,io_tqueue_wait_func():
- 获取当前时刻;
- 从堆中弹出所有
value <= now的等待对象; - 将底层 Timer 重新设置为下一个最早截止时间;
- 如果堆仍非空,再次提交同一个
tq->wait; - 将所有已到期的
io_tqueue_wait投递给各自 executor。
这证明 io_tqueue 是第一层逻辑定时复用器。
12.4 io_can_net 中 TimerQueue 的两个直接用途
io_can_net 在同一 io_tqueue 中至少维护两个等待操作:
| 等待对象 | 目的 |
|---|---|
wait_next |
等待下一个 CAN/CANopen 逻辑定时器到期 |
wait_confirm |
等待 CAN 帧发送确认超时 |
io_can_net_next_func() 收到 can_net 更新后的最早时间后,会设置:
1 | net->wait_next.value = *tp; |
而发送 CAN 帧时,io_can_net_do_write() 会为写确认计算绝对截止时间并提交 wait_confirm。因此同一个系统 Timer 不仅承载 CANopen 协议定时器,也承载 CAN 通道发送确认超时。
13. CANopen 协议定时器如何映射到同一个 timerfd
13.1 can_net 是第二层逻辑定时复用器
struct __can_net 保存:
1 | struct pheap timer_heap; |
每个 can_timer_t 保存:
1 | struct timespec start; |
can_timer_start() 将逻辑 Timer 插入 can_net.timer_heap,再调用 can_net_set_next()。后者取堆顶,也就是最早截止时间,并调用注册的 next_func。在 io_can_net 中,这个回调就是 io_can_net_next_func()。
13.2 一次协议 Timer 到期的完整、可验证调用链
1 | NMT / Heartbeat / SDO / PDO / SYNC 模块 |
这里有一个重要设计点:can_net_set_time() 在调用周期 Timer 的协议回调之前,先把该 Timer 的 start 增加 interval 并重新插回堆中。这可以降低回调内部修改或停止 Timer 时的状态复杂度。
13.3 哪些 CANopen 模块持有逻辑 can_timer_t
协议模块不直接持有 lely::io::Timer 或 Linux timerfd,而是持有注册在 can_net.timer_heap 中的 can_timer_t。
| 模块 | 源码中的逻辑 Timer | 用途 |
|---|---|---|
| NMT 主/从服务 | ec_timer |
life guarding 或 heartbeat 生产 |
| NMT Master | cs_timer |
发送缓冲的 NMT 命令 |
| 每个 NMT slave 状态 | timer |
node guarding |
| Heartbeat consumer | timer |
消费者心跳超时,每个 consumer 一个 |
| Client-SDO | timer |
SDO 请求/分段/块传输超时 |
| TPDO | timer_event |
event timer、事件驱动 TPDO |
| TPDO | timer_swnd |
同步窗口截止时间 |
| RPDO | timer_event |
deadline monitoring |
| RPDO | timer_swnd |
同步窗口截止时间 |
| SYNC | timer |
SYNC producer 周期 |
对应官方源码:
- NMT:
src/co/nmt.c、src/co/nmt.c - Heartbeat consumer:
src/co/nmt_hb.c - Client-SDO:
src/co/csdo.c - TPDO:
src/co/tpdo.c - RPDO:
src/co/rpdo.c - SYNC:
src/co/sync.c
13.4 “哪个模块直接持有 Timer”的准确答案
可以分三层回答:
| 层级 | 直接持有对象 | 说明 |
|---|---|---|
| 应用/运行时 | std::unique_ptr<lely::io::Timer> |
你的 impl_->timer 负责所有权 |
| I/O CAN 网络层 | io_timer_t *timer、io_tqueue_t *tq |
io_can_net 直接使用系统 Timer |
| CANopen 协议层 | can_timer_t * |
NMT、Heartbeat、SDO、PDO、SYNC 等只持有逻辑 Timer |
因此不能说“SDO 直接调用 Linux timerfd”,也不能说“每个 NMT/SDO/PDO 都有独立 Timer”。准确关系是:
1 | 协议模块 can_timer_t |
14. 完整生命周期
1 | flowchart TD |
15. 调试一次真实到期流程
推荐断点顺序:
1 | lely::io::Timer::Timer |
重点观察:
1 | impl->tfd |
15.1 查看 timerfd 状态
1 | ls -l /proc/<pid>/fd |
15.2 使用 strace
1 | strace -f -tt \ |
预期可观察到类似链路:
1 | timerfd_create(..., TFD_CLOEXEC|TFD_NONBLOCK) = 7 |
15.3 确认程序是否 fork
1 | strace -ff -e trace=process ./your_program |
GDB 可设置:
1 | break fork |
若 io_timer_impl_svc_notify_fork() 从未命中,说明该兼容路径没有参与当前程序运行。
16. 常见误区
误区 1:创建 Timer 就开始计时
错误。创建阶段只建立 timerfd 和 poll 监听,真正计时由 timerfd_settime() 启动或更新。
误区 2:submit_wait() 会立即执行任务
错误。它只把 wait 放入 wait_queue。只有到期、错误或取消后,wait 才会被投递给 executor。
误区 3:io_timer_wait_queue_post() 直接调用所有用户函数
不准确。它逐个调用 ev_exec_post(),由 executor 决定具体何时、在哪个执行上下文运行任务。
误区 4:周期 timerfd 会自动反复执行同一个 wait
错误。io_timer_wait 是一次性等待操作。完成后已从队列移除,下一周期需要重新提交。
误区 5:Timer 内部会主动 fork
错误。Timer 只注册 fork 通知回调;是否 fork 由宿主程序或依赖库决定。
误区 6:TFD_TIMER_ABSTIME 表示周期时间是绝对值
错误。它改变首次到期值 it_value 的解释;it_interval 仍是相对周期。
17. 最终心智模型
阅读完整源码时,应把它拆成八个角色:
1 | 1. can_timer_t |
完整运行公式:
1 | can_timer_start 决定“协议逻辑何时到期” |
最终应形成的核心心智模型是:
Lely CANopen 用两级最小堆将大量协议定时事件压缩为一个绝对截止时间,再由一个
timerfd唤醒事件循环。到期后时间从底层向上回灌,最终由can_net_set_time()批量执行所有已到期的协议回调。
参考资料
官方 Lely 源码基线
- 仓库:
lely-industries/lely-core - 固定提交:
620d1858eb8520dbc3dc5e1a7314565becd54199 TimerBaseC++ API:include/lely/io2/timer.hpp- Linux Timer:
src/io2/linux/timer.c - Linux epoll 后端:
src/io2/linux/poll.c - TimerQueue:
src/io2/tqueue.c - I/O CAN 网络适配:
src/io2/can_net.c - CAN 网络逻辑 Timer:
src/can/net.c - CANopen Node:
include/lely/coapp/node.hpp、src/coapp/node.cpp - CANopen Master:
include/lely/coapp/master.hpp、src/coapp/master.cpp
制规定的实现方式。








