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_forkshutdown 回调;
  • fork() 前后按不同方向遍历服务;
  • 在 shutdown 时按注册顺序的逆序关闭服务;
  • 通过服务内部的 _shutdown 标志保证每个服务最多关闭一次;
  • 通过 C++ 的 move-only 包装实现单个底层 Context 的唯一所有权;
  • 允许同一进程创建多个彼此独立的 Context,并非全局单例。

本文以 Lely Core 官方 Doxygen 当前展示的 2.4.0 源码为基础,重点阅读:

  • include/lely/io2/ctx.h
  • src/io2/ctx.c
  • include/lely/io2/ctx.hpp
  • src/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
2
3
4
5
6
7
8
9
10
11
flowchart TD
APP["CANopen 应用<br/>AsyncMaster / BasicSlave / Driver"]
CO["CANopen 协议层<br/>NMT / SDO / PDO / EMCY"]
IO["Lely I/O 服务<br/>CAN Channel / CAN Net / Timer / Signal"]
CTX["io_ctx<br/>服务登记与生命周期协调"]
OS["操作系统资源<br/>fd / poll / timer / thread"]

APP --> CO
CO --> IO
IO --> CTX
IO --> OS

因此,io_ctx_shutdown() 不是直接逐个关闭 NMT、SDO 或 PDO 对象。它遍历的是注册到该 Context 的底层 io_svc。上层 CANopen 对象因为依赖这些 I/O 服务而间接受到影响。


2. io_ctx 的内部结构为什么如此简单

ctx.c 可以看到,io_ctx 的核心成员只有两类:

  1. 可选的互斥锁;
  2. 一条双向链表。

可将其抽象为:

1
2
3
4
struct io_ctx {
mutex;
service_list;
};

互斥锁用于保护服务链表的插入、删除和遍历。链表则保存所有注册到该 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
2
3
4
5
6
7
8
io_svc
├─ vptr
├─ _shutdown
└─ _node

io_svc_vtbl
├─ notify_fork
└─ shutdown

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
2
int  notify_fork(io_svc *svc, io_fork_event event);
void shutdown(io_svc *svc);

两个函数指针都允许为 NULL。这意味着:

  • 某个服务可能需要 shutdown,但不需要 fork 维护;
  • 某个服务可能同时实现两者;
  • Context 不要求所有服务做完全相同的处理。

io_ctx 只负责调度,不理解任何驱动细节。真正的资源维护逻辑留在各服务内部。

这正是该设计最重要的解耦点:

Context 知道“何时通知”,具体服务知道“如何处理”。


4. 服务如何加入和退出 Context

4.1 io_ctx_insert()

服务初始化完成后,通过 io_ctx_insert() 注册到 Context。其核心步骤是:

  1. 锁住 Context;
  2. 将服务节点追加到链表尾部;
  3. 解锁。

因此,链表顺序就是服务注册顺序。

官方接口给出了一个重要前置条件:

被插入的 io_svc 当前不得注册在任何 Context 中,包括目标 Context。

原因很直接:io_svc 内部只有一个 _node。同一个侵入式链表节点不能同时属于两条链表。

所以:

1
2
一个 io_svc 实例
└─ 同一时刻最多属于一个 io_ctx

这不等于一个 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
2
3
4
5
6
7
8
9
ctx.notify_fork(lely::io::ForkEvent::prepare);

pid_t pid = ::fork();

if (pid == 0) {
ctx.notify_fork(lely::io::ForkEvent::child);
} else if (pid > 0) {
ctx.notify_fork(lely::io::ForkEvent::parent);
}

如果程序从不调用进程级 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_PARENTIO_FORK_CHILD:从链表头部向尾部遍历。

假设服务按以下顺序创建:

1
A → B → C

则通知顺序为:

1
2
3
PREPARE: C → B → A
PARENT : A → B → C
CHILD : A → B → C

这与常见的资源栈语义一致:

  • 准备阶段按依赖的逆序拆解或冻结;
  • fork 后按原始构建顺序恢复。

该顺序并不自动证明所有服务都存在严格依赖,但它为“后创建者依赖先创建者”的常见场景提供了稳定行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
sequenceDiagram
participant U as 用户代码
participant C as io_ctx
participant A as Service A
participant B as Service B
participant D as Service C

U->>C: notify_fork(PREPARE)
C->>D: PREPARE
C->>B: PREPARE
C->>A: PREPARE

U->>U: fork()

alt 父进程
U->>C: notify_fork(PARENT)
C->>A: PARENT
C->>B: PARENT
C->>D: PARENT
else 子进程
U->>C: notify_fork(CHILD)
C->>A: CHILD
C->>B: CHILD
C->>D: CHILD
end

5.4 调用回调时为什么要释放 Context 锁

io_ctx_notify_fork() 的循环逻辑可以抽象为:

1
2
3
4
5
6
锁住 Context
取出当前服务和下一个遍历节点
解锁
调用服务 notify_fork()
重新加锁
继续遍历

这是一个重要实现细节。Context 不会在执行任意服务代码时一直持有自己的互斥锁。

基于源码可以得到两个结论:

  1. 服务回调不会被迫在 Context 互斥锁内执行;
  2. 服务集合稳定性必须由调用者额外保证。

从工程角度分析,释放锁可降低服务回调内部发生锁顺序反转或长时间占用 Context 锁的风险,但代价是调用方必须严格遵守“通知期间不得移除服务”的约束。

5.5 错误处理:记录第一个错误,但继续通知

某个服务的 notify_fork() 返回 -1 时,Context 会:

  1. 保存第一个失败服务产生的错误码;
  2. 将整体结果标记为失败;
  3. 继续通知后续服务;
  4. 最终恢复第一个错误码并返回 -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
2
3
4
5
6
7
8
9
10
flowchart LR
R["注册顺序"] --> A["Poll"]
A --> B["Timer"]
B --> C["CAN Channel"]
C --> D["CAN Net"]

S["shutdown 顺序"] --> D2["CAN Net"]
D2 --> C2["CAN Channel"]
C2 --> B2["Timer"]
B2 --> A2["Poll"]

6.2 _shutdown 让操作具备幂等性

每个 io_svc 内部都有 _shutdown 标志。

Context 在调用服务回调前会:

  1. 检查 _shutdown
  2. 已经关闭则跳过;
  3. 未关闭则先置为已关闭;
  4. 再调用服务的 shutdown 回调。

因此同一个服务无论 Context shutdown 被调用多少次,都只会执行一次回调。

这里“先置位、再回调”也意味着,即使 shutdown 回调间接导致再次调用 Context shutdown,该服务也不会递归关闭第二次。

6.3 shutdown 回调同样在 Context 锁之外执行

其模式与 fork 通知相似:

1
2
3
4
5
6
7
锁住 Context
确定当前服务和前一个节点
设置 _shutdown
解锁
调用服务 shutdown()
重新加锁
继续

因此,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
2
shutdown = 终止注册服务
destroy = 释放 Context 容器本身

更稳妥的程序退出顺序通常是:

1
2
3
4
5
6
停止产生新请求
→ ctx.shutdown()
→ 让事件循环排空或退出
→ join 工作线程
→ 销毁各具体 I/O 服务
→ 最后销毁 Context

具体项目仍需根据对象依赖关系调整。


7. 具体服务如何接入:以 io_can_net 为例

src/io2/can_net.c 展示了一个典型服务如何纳入 Context。

其服务虚表中:

  • notify_forkNULL
  • shutdown 指向 CAN 网络服务自己的关闭函数。

初始化完成后,io_can_net 调用 io_ctx_insert() 注册;销毁时先调用 io_ctx_remove() 注销,然后执行自身 shutdown 逻辑。

这说明:

  1. Context 并不要求每个服务都处理 fork;
  2. CAN 网络服务可以只参与统一 shutdown;
  3. 服务自身仍负责取消未完成操作、释放内部资源和关闭设备;
  4. Context 只提供统一入口和确定的调用顺序。

可以把接入模式概括为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static const io_svc_vtbl service_vtbl = {
.notify_fork = optional_fork_handler,
.shutdown = service_shutdown
};

service_init(...) {
initialize_internal_state();
io_ctx_insert(ctx, &service->svc);
}

service_fini(...) {
io_ctx_remove(ctx, &service->svc);
service_shutdown(&service->svc);
release_internal_state();
}

上面是结构化伪代码,并非对原文件的逐字复制。


8. C++ 包装:为什么 Context 只能移动,不能复制

ctx.hpp 将 C 接口分为两个层次。

8.1 ContextBase:非拥有型引用

ContextBase 只保存一个 io_ctx_t*,并提供:

  • insert()
  • remove()
  • notify_fork()
  • shutdown()
  • io_ctx_t* 的转换。

它只是对已有 Context 的引用包装,不负责释放底层对象。

可将其理解为:

1
2
3
ContextBase
= non-owning view
= 借用一个 io_ctx_t*

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
2
lely::io::Context ctx_a;
lely::io::Context ctx_b;

它们形成两套独立注册表:

1
2
3
4
5
6
7
ctx_a
├─ service A1
└─ service A2

ctx_b
├─ service B1
└─ service B2

但一个具体服务只能注册到其中一个 Context。

实际项目是否需要多个 Context,取决于隔离需求。例如:

  • 两套完全独立的 I/O 子系统;
  • 不同生命周期域;
  • 测试环境中的隔离实例。

对常规单套 Lely CANopen 主站或从站程序而言,一个 Context 通常已经足够,且统一 shutdown 更容易维护。


10. 一套推荐的应用生命周期

下面给出一个简化的 C++ 生命周期框架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int main() {
lely::io::IoGuard io_guard;
lely::io::Context ctx;

// 创建 Poll、Loop、Timer、CAN Channel、CANopen 节点等对象。
// 各底层 I/O 服务在初始化期间注册到 ctx。

start_event_threads();

run_application();

// 先阻止新的业务请求进入。
request_application_stop();

// 统一通知所有已注册 I/O 服务停止。
ctx.shutdown();

// 让事件线程结束,并确保不再访问已 shutdown 的服务。
join_event_threads();

// 其余对象按依赖关系析构。
}

这里要强调三个边界:

  1. ctx.shutdown() 不是线程 join;
  2. ctx.shutdown() 不是 io_ctx_destroy()
  3. ctx.shutdown() 之后不应继续把已关闭服务当作可恢复对象使用。

11. fork() 场景下的推荐流程

仅当程序确实调用 POSIX fork(),并且父子进程需要维护继承的 Lely I/O 服务时,才需要 fork 通知。

1
2
3
4
5
6
7
8
9
10
11
12
13
ctx.notify_fork(lely::io::ForkEvent::prepare);

pid_t pid = ::fork();

if (pid == -1) {
// fork 失败时,需要根据应用策略恢复或终止。
} else if (pid == 0) {
ctx.notify_fork(lely::io::ForkEvent::child);
run_child();
} else {
ctx.notify_fork(lely::io::ForkEvent::parent);
run_parent();
}

工程上还应注意:

  • fork 通知期间禁止注销服务;
  • 多线程进程调用 fork() 本身就有严格限制;
  • 子进程若立即 exec() 且不再访问继承的 Lely 对象,可采用更简单的进程模型;
  • 如果可以选择,先 fork、再分别创建各自的 Context 和 I/O 服务,通常更容易推理。

后两点属于通用 POSIX 工程建议,不是 ctx.c 自身强制的策略。


12. 这套设计解决了什么问题

如果没有 Context,每个应用都需要手工记录所有 I/O 组件,并在退出时逐个调用:

1
2
3
4
5
shutdown CAN Net
shutdown CAN Channel
shutdown Timer
shutdown Poll
...

同时,fork 前后还要分别通知不同组件。

这种写法存在几个问题:

  • 应用层需要了解所有驱动内部类型;
  • 新增服务后容易遗漏退出处理;
  • 服务关闭顺序分散在业务代码中;
  • fork 维护逻辑难以统一;
  • 重复 shutdown 容易导致二次释放或重复取消。

io_ctx 将这些横切关注点收敛为统一协议:

1
2
3
4
5
6
7
8
9
服务初始化
→ 注册 io_svc

fork/shutdown
→ Context 遍历服务
→ 调用服务虚表

服务销毁
→ 注销 io_svc

它并没有消除各驱动的复杂性,而是将“何时调用各驱动的生命周期逻辑”标准化。


13. 常见误解

误解一:Context 管理整个 CANopen 协议栈

不准确。

Context 直接管理的是注册到它的 io_svc。CANopen 上层对象通常通过底层 I/O 服务间接受到影响。

误解二:notify_fork() 会调用 fork()

错误。

它只广播 PREPAREPARENTCHILD 事件。

误解三:销毁 Context 会自动 shutdown 所有服务

错误。

io_ctx_destroy() 只释放 Context 自身资源;C++ Context 析构也只调用 destroy。

误解四:整个程序只能存在一个 Context

错误。

一个底层 Context 只有一个 C++ 所有者,但一个进程可以创建多个独立 Context。

误解五:Context 有锁,所以 shutdown 期间可以任意删除服务

错误。

官方接口明确要求,fork 通知和 shutdown 运行期间不得注销服务。

误解六:所有服务都必须实现 fork 处理

错误。

notify_forkshutdown 回调都可以为 NULL。具体服务按需求实现。


14. 总结

ctx.c 的代码量不大,但其设计边界非常清晰:

io_ctx 是 Lely I/O 层的服务注册表和生命周期协调器。

它通过一条受互斥锁保护的双向链表维护 io_svc,并通过虚表将 fork 和 shutdown 事件转发给具体服务。

其关键机制可以归纳为:

  1. 显式注册:服务初始化后加入 Context,销毁前移除;
  2. 接口统一:所有服务通过 io_svc_vtbl 暴露可选的 fork 和 shutdown 回调;
  3. 顺序确定
    • fork prepare 逆序;
    • fork parent/child 正序;
    • shutdown 逆序;
  4. 回调解锁:调用服务回调时不持有 Context 锁;
  5. 调用方同步:通知和 shutdown 期间不得注销服务;
  6. 错误保留:fork 通知继续遍历,并返回第一个失败服务的错误;
  7. 关闭幂等:每个服务的 shutdown 最多执行一次;
  8. 所有权明确:C++ Context 不可复制、可以移动;
  9. 非单例设计:一个进程可以拥有多个独立 Context;
  10. shutdown 与 destroy 分离:服务终止和 Context 内存释放是两个不同阶段。

因此,对这套机制最准确的一句话描述是:

ctx.c 维护一个 I/O 上下文,通过链表统一登记 Lely 的底层 I/O 服务;当用户主动执行进程 fork 协调或系统 shutdown 时,Context 按确定顺序遍历服务并调用各自的维护回调。每个具体 Context 实例采用唯一所有权,但程序并非只能创建一个 Context。