Lely CANopen :I/O 服务 io_ctx
@[toc]
摘要
在 Lely CANopen 应用中,lely::io::Context 往往是最先创建、最后销毁的对象之一。它看起来只是一个很薄的 C++ 包装类,但其底层 io_ctx_t 承担了一个关键职责:
维护一组已注册的 Lely I/O 服务,并在进程
fork()或系统退出时,按确定顺序向这些服务分发维护事件。
ctx.c 本身并不执行 CANopen 协议,也不负责运行事件循环。它更像一个轻量级的“服务生命周期协调器”:
- 通过双向链表登记
Timer、CAN 网络接口及其他底层 I/O 服务; - 通过服务虚表调用各组件自己的
notify_fork和shutdown回调; - 在
fork()前后按不同方向遍历服务; - 在 shutdown 时按注册顺序的逆序关闭服务;
- 通过服务内部的
_shutdown标志保证每个服务最多关闭一次; - 通过 C++ 的 move-only 包装实现单个底层 Context 的唯一所有权;
- 允许同一进程创建多个彼此独立的 Context,并非全局单例。
本文以 Lely Core 官方 Doxygen 当前展示的 2.4.0 源码为基础,重点阅读:
include/lely/io2/ctx.hsrc/io2/ctx.cinclude/lely/io2/ctx.hppsrc/io2/can_net.c
1. 先明确:io_ctx 属于哪一层
Lely Core 并不是一个单一的 CANopen 协议库,而是由多个层次组成,其中包括:
liblely-ev:任务、执行器和事件循环;liblely-io2:异步 I/O 抽象;liblely-co:CANopen 协议实现;liblely-coapp:面向 C++ 应用的 CANopen 封装。
io_ctx 位于 liblely-io2。因此它不是 CiA 301 规定的对象字典、PDO、SDO 或 NMT 状态机的一部分,而是 Lely 在操作系统和异步 I/O 层提供的基础设施。
更准确的依赖关系是:
1 | flowchart TD |
因此,io_ctx_shutdown() 不是直接逐个关闭 NMT、SDO 或 PDO 对象。它遍历的是注册到该 Context 的底层 io_svc。上层 CANopen 对象因为依赖这些 I/O 服务而间接受到影响。
2. io_ctx 的内部结构为什么如此简单
从 ctx.c 可以看到,io_ctx 的核心成员只有两类:
- 可选的互斥锁;
- 一条双向链表。
可将其抽象为:
1 | struct io_ctx { |
互斥锁用于保护服务链表的插入、删除和遍历。链表则保存所有注册到该 Context 的 io_svc。
这说明 io_ctx 本身不保存:
- CAN 帧;
- 定时器超时时间;
- 事件循环任务;
- CANopen 节点状态;
- 文件描述符;
- 线程对象。
这些状态都属于具体服务。Context 只掌握服务节点以及调用服务回调所需的统一接口。
从设计模式上看,它接近以下组合:
- Registry:登记服务;
- Observer/Event Dispatcher:广播 fork 事件;
- Lifecycle Coordinator:统一 shutdown;
- Intrusive List:链表节点嵌入服务对象自身。
3. io_svc:所有可被 Context 管理的服务都要遵守的协议
ctx.h 定义了两个关键结构:
1 | io_svc |
3.1 io_svc
每个可注册服务都内嵌一个 io_svc:
vptr指向服务虚表;_shutdown记录该服务是否已被 Context 关闭;_node是挂入 Context 双向链表的节点。
这是一种典型的 C 语言“基类”写法。具体服务对象通常包含一个 io_svc 成员,再通过 structof() 或类似 container-of 机制由 io_svc* 反推出外层服务对象。
3.2 io_svc_vtbl
服务虚表只规定两种生命周期回调:
1 | int notify_fork(io_svc *svc, io_fork_event event); |
两个函数指针都允许为 NULL。这意味着:
- 某个服务可能需要 shutdown,但不需要 fork 维护;
- 某个服务可能同时实现两者;
- Context 不要求所有服务做完全相同的处理。
io_ctx 只负责调度,不理解任何驱动细节。真正的资源维护逻辑留在各服务内部。
这正是该设计最重要的解耦点:
Context 知道“何时通知”,具体服务知道“如何处理”。
4. 服务如何加入和退出 Context
4.1 io_ctx_insert()
服务初始化完成后,通过 io_ctx_insert() 注册到 Context。其核心步骤是:
- 锁住 Context;
- 将服务节点追加到链表尾部;
- 解锁。
因此,链表顺序就是服务注册顺序。
官方接口给出了一个重要前置条件:
被插入的
io_svc当前不得注册在任何 Context 中,包括目标 Context。
原因很直接:io_svc 内部只有一个 _node。同一个侵入式链表节点不能同时属于两条链表。
所以:
1 | 一个 io_svc 实例 |
这不等于一个 Context 只能管理一个服务,也不等于一个进程只能创建一个 Context。
4.2 io_ctx_remove()
服务销毁前,通过 io_ctx_remove() 从链表中删除。
官方文档明确限制:
io_ctx_notify_fork()或io_ctx_shutdown()正在运行时,不得注销服务。
虽然 ctx.c 在调用具体服务回调前会暂时释放 Context 互斥锁,但遍历逻辑已经保存了前一个或后一个链表节点。如果另一个线程此时移除服务,迭代游标可能失效。因此,Context 的内部锁并不能替代上层生命周期同步。
换句话说,线程安全边界是:
- Context 负责保护链表的基本读写;
- 调用者负责保证整次 fork 通知或 shutdown 期间,服务集合保持稳定。
5. io_ctx_notify_fork() 的调度机制
5.1 Context 不会替用户调用 fork()
io_ctx_notify_fork() 只是事件广播函数。真正的 fork() 仍由应用调用。
标准流程是:
1 | ctx.notify_fork(lely::io::ForkEvent::prepare); |
如果程序从不调用进程级 fork(),就不需要调用该接口。创建 std::thread 与它无关。
5.2 三种 fork 事件
ctx.h 定义三种事件:
| 事件 | 调用时机 | 作用 |
|---|---|---|
IO_FORK_PREPARE |
fork() 前 |
让服务进入可安全复制进程状态 |
IO_FORK_PARENT |
fork() 后的父进程 |
恢复父进程服务状态 |
IO_FORK_CHILD |
fork() 后的子进程 |
修复或重建子进程继承的服务状态 |
具体要暂停什么、重建什么、重新注册什么,完全由服务自己的 notify_fork 回调决定。
5.3 为什么 PREPARE 是逆序,而 PARENT/CHILD 是正序
源码中的遍历规则是:
IO_FORK_PREPARE:从链表尾部向头部遍历;IO_FORK_PARENT、IO_FORK_CHILD:从链表头部向尾部遍历。
假设服务按以下顺序创建:
1 | A → B → C |
则通知顺序为:
1 | PREPARE: C → B → A |
这与常见的资源栈语义一致:
- 准备阶段按依赖的逆序拆解或冻结;
- fork 后按原始构建顺序恢复。
该顺序并不自动证明所有服务都存在严格依赖,但它为“后创建者依赖先创建者”的常见场景提供了稳定行为。
1 | sequenceDiagram |
5.4 调用回调时为什么要释放 Context 锁
io_ctx_notify_fork() 的循环逻辑可以抽象为:
1 | 锁住 Context |
这是一个重要实现细节。Context 不会在执行任意服务代码时一直持有自己的互斥锁。
基于源码可以得到两个结论:
- 服务回调不会被迫在 Context 互斥锁内执行;
- 服务集合稳定性必须由调用者额外保证。
从工程角度分析,释放锁可降低服务回调内部发生锁顺序反转或长时间占用 Context 锁的风险,但代价是调用方必须严格遵守“通知期间不得移除服务”的约束。
5.5 错误处理:记录第一个错误,但继续通知
某个服务的 notify_fork() 返回 -1 时,Context 会:
- 保存第一个失败服务产生的错误码;
- 将整体结果标记为失败;
- 继续通知后续服务;
- 最终恢复第一个错误码并返回
-1。
这种策略比“遇到第一个错误立即停止”更适合生命周期广播。即使某个服务准备失败,其他服务仍有机会接收到同一阶段事件,避免系统停在更不一致的中间状态。
但需要注意:该函数只报告第一个错误,不提供所有失败服务的聚合列表。
6. io_ctx_shutdown() 的调度机制
6.1 shutdown 按注册逆序执行
io_ctx_shutdown() 从链表尾部开始,逐个调用服务的 shutdown 回调。
如果注册顺序为:
1 | Poll → Timer → CAN Channel → CAN Net |
那么 shutdown 顺序为:
1 | CAN Net → CAN Channel → Timer → Poll |
这种顺序类似 C++ 局部对象的逆序析构,适用于“后创建的高层组件依赖先创建的底层组件”的情况。
1 | flowchart LR |
6.2 _shutdown 让操作具备幂等性
每个 io_svc 内部都有 _shutdown 标志。
Context 在调用服务回调前会:
- 检查
_shutdown; - 已经关闭则跳过;
- 未关闭则先置为已关闭;
- 再调用服务的 shutdown 回调。
因此同一个服务无论 Context shutdown 被调用多少次,都只会执行一次回调。
这里“先置位、再回调”也意味着,即使 shutdown 回调间接导致再次调用 Context shutdown,该服务也不会递归关闭第二次。
6.3 shutdown 回调同样在 Context 锁之外执行
其模式与 fork 通知相似:
1 | 锁住 Context |
因此,Context 的互斥锁不保护服务内部 shutdown 过程。调用方仍必须保证:
- shutdown 期间不注销服务;
- 不再启动依赖这些服务的新异步操作;
- 工作线程和事件循环的停止顺序与服务依赖一致。
6.4 shutdown 不等于 destroy
这是最容易混淆的一点。
io_ctx_shutdown()
作用是:
- 遍历注册服务;
- 调用服务 shutdown 回调;
- 让底层 I/O 进入终止状态。
io_ctx_destroy()
作用是:
- 销毁 Context 自身的互斥锁;
- 释放 Context 内存。
从 ctx.c 可以看到,io_ctx_destroy() 不会自动调用 io_ctx_shutdown()。
因此不能将两者等同:
1 | shutdown = 终止注册服务 |
更稳妥的程序退出顺序通常是:
1 | 停止产生新请求 |
具体项目仍需根据对象依赖关系调整。
7. 具体服务如何接入:以 io_can_net 为例
src/io2/can_net.c 展示了一个典型服务如何纳入 Context。
其服务虚表中:
notify_fork为NULL;shutdown指向 CAN 网络服务自己的关闭函数。
初始化完成后,io_can_net 调用 io_ctx_insert() 注册;销毁时先调用 io_ctx_remove() 注销,然后执行自身 shutdown 逻辑。
这说明:
- Context 并不要求每个服务都处理 fork;
- CAN 网络服务可以只参与统一 shutdown;
- 服务自身仍负责取消未完成操作、释放内部资源和关闭设备;
- Context 只提供统一入口和确定的调用顺序。
可以把接入模式概括为:
1 | static const io_svc_vtbl service_vtbl = { |
上面是结构化伪代码,并非对原文件的逐字复制。
8. C++ 包装:为什么 Context 只能移动,不能复制
ctx.hpp 将 C 接口分为两个层次。
8.1 ContextBase:非拥有型引用
ContextBase 只保存一个 io_ctx_t*,并提供:
insert();remove();notify_fork();shutdown();- 到
io_ctx_t*的转换。
它只是对已有 Context 的引用包装,不负责释放底层对象。
可将其理解为:
1 | ContextBase |
8.2 Context:拥有型 RAII 对象
Context 构造时调用 io_ctx_create(),析构时调用 io_ctx_destroy()。
它删除了:
- 拷贝构造;
- 拷贝赋值。
同时支持:
- 移动构造;
- 移动赋值。
原因是底层 io_ctx_t* 必须只有一个负责销毁它的 C++ 所有者。若允许默认复制,两个 Context 最终会对同一指针执行两次 io_ctx_destroy()。
因此,准确表述是:
每个具体底层
io_ctx_t采用唯一所有权,而不是整个程序只能存在一个 Context。
8.3 移动构造与移动赋值的细节
移动构造会把原对象指针交给新对象,并把原对象设为空。
移动赋值则使用 swap() 交换两个指针。因此移动赋值后:
- 目标对象获得源对象原来的 Context;
- 源对象获得目标对象原来的 Context;
- 两个底层 Context 仍分别只有一个所有者。
这与 std::unique_ptr 的常见移动赋值结果略有不同:这里被移动对象不一定变成空对象,因为实现采用的是交换。
8.4 析构函数不会自动 shutdown
Context::~Context() 只调用 io_ctx_destroy(),不会先调用 shutdown()。
这进一步说明:
- RAII 负责 Context 内存所有权;
- I/O 服务终止时机仍由应用生命周期决定;
- 用户不能假设
Context离开作用域时,会自动统一关闭所有注册服务。
9. Context 不是单例:程序可以创建多个实例
io_ctx_create() 每次都会独立分配并初始化一个新的 Context。源码中不存在全局唯一实例或单例检查。
下面的代码在语义上是允许的:
1 | lely::io::Context ctx_a; |
它们形成两套独立注册表:
1 | ctx_a |
但一个具体服务只能注册到其中一个 Context。
实际项目是否需要多个 Context,取决于隔离需求。例如:
- 两套完全独立的 I/O 子系统;
- 不同生命周期域;
- 测试环境中的隔离实例。
对常规单套 Lely CANopen 主站或从站程序而言,一个 Context 通常已经足够,且统一 shutdown 更容易维护。
10. 一套推荐的应用生命周期
下面给出一个简化的 C++ 生命周期框架:
1 | int main() { |
这里要强调三个边界:
ctx.shutdown()不是线程 join;ctx.shutdown()不是io_ctx_destroy();ctx.shutdown()之后不应继续把已关闭服务当作可恢复对象使用。
11. fork() 场景下的推荐流程
仅当程序确实调用 POSIX fork(),并且父子进程需要维护继承的 Lely I/O 服务时,才需要 fork 通知。
1 | ctx.notify_fork(lely::io::ForkEvent::prepare); |
工程上还应注意:
- fork 通知期间禁止注销服务;
- 多线程进程调用
fork()本身就有严格限制; - 子进程若立即
exec()且不再访问继承的 Lely 对象,可采用更简单的进程模型; - 如果可以选择,先 fork、再分别创建各自的 Context 和 I/O 服务,通常更容易推理。
后两点属于通用 POSIX 工程建议,不是 ctx.c 自身强制的策略。
12. 这套设计解决了什么问题
如果没有 Context,每个应用都需要手工记录所有 I/O 组件,并在退出时逐个调用:
1 | shutdown CAN Net |
同时,fork 前后还要分别通知不同组件。
这种写法存在几个问题:
- 应用层需要了解所有驱动内部类型;
- 新增服务后容易遗漏退出处理;
- 服务关闭顺序分散在业务代码中;
- fork 维护逻辑难以统一;
- 重复 shutdown 容易导致二次释放或重复取消。
io_ctx 将这些横切关注点收敛为统一协议:
1 | 服务初始化 |
它并没有消除各驱动的复杂性,而是将“何时调用各驱动的生命周期逻辑”标准化。
13. 常见误解
误解一:Context 管理整个 CANopen 协议栈
不准确。
Context 直接管理的是注册到它的 io_svc。CANopen 上层对象通常通过底层 I/O 服务间接受到影响。
误解二:notify_fork() 会调用 fork()
错误。
它只广播 PREPARE、PARENT 和 CHILD 事件。
误解三:销毁 Context 会自动 shutdown 所有服务
错误。
io_ctx_destroy() 只释放 Context 自身资源;C++ Context 析构也只调用 destroy。
误解四:整个程序只能存在一个 Context
错误。
一个底层 Context 只有一个 C++ 所有者,但一个进程可以创建多个独立 Context。
误解五:Context 有锁,所以 shutdown 期间可以任意删除服务
错误。
官方接口明确要求,fork 通知和 shutdown 运行期间不得注销服务。
误解六:所有服务都必须实现 fork 处理
错误。
notify_fork 和 shutdown 回调都可以为 NULL。具体服务按需求实现。
14. 总结
ctx.c 的代码量不大,但其设计边界非常清晰:
io_ctx是 Lely I/O 层的服务注册表和生命周期协调器。
它通过一条受互斥锁保护的双向链表维护 io_svc,并通过虚表将 fork 和 shutdown 事件转发给具体服务。
其关键机制可以归纳为:
- 显式注册:服务初始化后加入 Context,销毁前移除;
- 接口统一:所有服务通过
io_svc_vtbl暴露可选的 fork 和 shutdown 回调; - 顺序确定:
- fork prepare 逆序;
- fork parent/child 正序;
- shutdown 逆序;
- 回调解锁:调用服务回调时不持有 Context 锁;
- 调用方同步:通知和 shutdown 期间不得注销服务;
- 错误保留:fork 通知继续遍历,并返回第一个失败服务的错误;
- 关闭幂等:每个服务的 shutdown 最多执行一次;
- 所有权明确:C++
Context不可复制、可以移动; - 非单例设计:一个进程可以拥有多个独立 Context;
- shutdown 与 destroy 分离:服务终止和 Context 内存释放是两个不同阶段。
因此,对这套机制最准确的一句话描述是:
ctx.c维护一个 I/O 上下文,通过链表统一登记 Lely 的底层 I/O 服务;当用户主动执行进程 fork 协调或系统 shutdown 时,Context 按确定顺序遍历服务并调用各自的维护回调。每个具体 Context 实例采用唯一所有权,但程序并非只能创建一个 Context。









