Lely CANopen coapp::Node:CAN 网络、NMT 服务与 C++ 事件回调的装配机制
摘要:从源码追踪 coapp::Node 如何组合 CAN I/O、对象字典、NMT 服务、定时队列与 C++ 回调,并解释构造、复位和事件分发的完整流程。
@[toc]
1. Node 不是协议重写,而是装配中心
阅读 lely-core/include/lely/coapp/node.hpp 和 lely-core/src/coapp/node.cpp 时,最重要的判断不是“Node 实现了多少 CANopen 服务”,而是:
lely::canopen::Node把 CAN I/O、时间源、本地对象字典、NMT 总控和 C++ 应用回调装配成一个可运行节点;具体 PDO、SYNC、TIME、EMCY、LSS 等协议状态机仍由 C 层服务对象实现。
这一判断可以直接从继承关系和内部对象得到:
1 | class Node : public io::CanNet, public Device { |
Node 同时拥有三种能力:
| 层次 | 主要对象 | 在 Node 中的职责 |
|---|---|---|
| I/O 与时间 | io::CanNet |
接收和发送 CAN 帧,连接 Timer Queue、Executor 与系统时钟 |
| 设备描述 | Device / co_dev_t |
保存对象字典、Node-ID、通信参数及本地 OD 访问能力 |
| CANopen 总控 | co_nmt_t |
管理 NMT 状态,并按状态创建、销毁或访问其他协议服务 |
| C++ 应用接口 | 虚函数与 std::function |
把 C 层 indication 转换成可继承、可注册的 C++ 事件接口 |
Lely 的底层 liblely-co 是被动协议栈:它自身不创建线程、不直接访问总线,也不主动读取系统时钟。CAN 帧和时间必须由外部网络对象注入。io::CanNet 正好承担这一适配,而 co_nmt_t 再在其上管理 CANopen 服务。
下面的关系图只画出本文需要的边界,不展开每个协议状态机内部实现。
1 | flowchart LR |
图中的 Node → CanNet 和 Node → Device 表示继承关系;Node → Impl_ → co_nmt_t 表示内部所有权关系。Node 并不把这些层合并成一套新协议实现,而是在生命周期、锁和回调层面把它们连起来。
2. 类结构、所有权与构造输入
2.1 三种设备描述入口
Node 的公开构造函数支持三类设备描述来源:
- 已存在的
co_dev_t*; - EDS/DCF 文本及可选 concise DCF 二进制;
- 编译进程序的
co_sdev静态设备描述。
对应重载是否存在受条件编译控制:
LELY_NO_CO_DCF会移除 DCF/EDS 构造路径;LELY_NO_CO_SDEV会移除静态设备描述构造路径。
三条路径最终都得到一个 Device,并把同一份内部 co_dev_t 交给 co_nmt_create()。因此,设备描述来源不同,但进入 NMT 总控后的运行模型相同。
2.2 构造顺序透露了对象依赖
以接收 co_dev_t* 的构造函数为例,初始化顺序可以压缩为:
1 | Node::Node(ev_exec_t* exec, io::TimerBase& timer, |
这里有四个关键关系。
第一,CanNet 必须先建立,因为 Impl_ 创建 co_nmt_t 时需要 can_net_t*。
第二,Device(dev, id, this) 将 Node 自身作为 Device 使用的 BasicLockable。这表示对象字典访问和 CAN 网络事件共享 Node/CanNet 的互斥边界,而不是各自维护互不相关的锁。
第三,Impl_ 同时取得:
1 | Node* -> 回调到 C++ 对象 |
第四,构造函数末尾调用的是 CanNet::start(),注释也明确写为“Start processing CAN frames”。它只表示开始处理 CAN I/O,不等价于 CANopen 节点已经完成 boot-up。
2.3 co_nmt_t 的 RAII 管理
Impl_ 用自定义删除器持有 NMT 对象:
1 | struct NmtDeleter { |
这层封装解决了 C/C++ 所有权边界:
- C API 负责
co_nmt_create()/co_nmt_destroy(); - C++
unique_ptr负责异常路径和析构路径上的自动释放; Node禁止复制,避免多个实例共同拥有同一 NMT 服务及网络资源。
官方接口还要求传入的 TimerBase 与 CanChannelBase 不再被其他用途复用。这不是普通的“引用生命周期”提醒,而是因为 CanNet 会在其上安装异步读写、定时和状态处理,复用可能破坏事件所有权及执行顺序。
3. 构造完成后为什么仍然必须调用 Reset()
3.1 两个“启动”属于不同层
Node 构造结束时发生了:
1 | CanNet::start() |
它使 CAN 通道开始向 CanNet 提交帧和状态事件。但新建的 co_nmt_t 仍处于 NMT Initialisation 状态。按照 Lely 的 NMT 设计,此时不会创建完整协议服务,也不会执行正常 CANopen 通信。
应用需要显式调用:
1 | node.Reset(); |
Reset() 的语义不是重新启动事件循环,而是模拟收到 NMT reset node 命令:
1 | void Node::Reset() { |
所以两者应严格区分:
| 动作 | 所属层 | 直接效果 |
|---|---|---|
构造函数中的 start() |
CAN I/O 层 | 开始处理 CAN 帧、时间和 CAN 状态事件 |
Node::Reset() |
CANopen NMT 层 | 注入 RESET_NODE,启动 NMT 复位和 boot-up 流程 |
3.2 为什么复位前先更新时间
Reset() 在 co_nmt_cs_ind() 之前调用 SetTime(),而 SetTime() 只是转调 CanNet::set_time()。
源码注释给出的目的很具体:如果该节点作为主站运行,先更新 CAN 网络时间可以避免 SDO timeout 从过旧时间基准开始计算,导致超时过早发生。
这里的“网络时间”是 can_net_t 的单调定时基准,不是 CANopen TIME 对象 0x1012/0x1013 表示的绝对时间。两者名称相似,但用途完全不同:
CanNet::set_time():推进协议定时器;- TIME service:收发 CANopen 时间戳报文。
3.3 完整构造与复位时序
1 | sequenceDiagram |
Reset() 会进一步触发 OnCommand()。官方 API 明确要求不得从 OnCommand() 中再次调用 Reset(),否则会在同一命令处理链中递归注入 reset node,破坏状态机和锁语义。
4. Impl_ 如何把 C NMT 对象接入 C++
4.1 构造时只注册“总控级”回调
Impl_ 构造函数创建 co_nmt_t 后,立即注册四类入口:
1 | co_nmt_set_cs_ind() -> NMT command indication |
它们有一致的 C 回调桥接形态:
1 | co_nmt_set_cs_ind( |
C API 只认识函数指针和 void* data。静态 lambda 不捕获对象,能够转换为 C 函数指针;data 保存 Impl_*,再把事件转回成员函数。
4.2 为什么不能在构造时注册全部服务回调
co_nmt_t 不是简单持有一组永久存在的服务对象。它会依据 NMT 状态和对象字典配置创建、配置或销毁 RPDO、TPDO、SYNC、TIME、EMCY、LSS 等服务。
因此,在 Impl_ 构造时:
co_nmt_t已经存在;- 具体服务未必存在;
- 即使某服务未来会出现,此刻也可能无法通过
co_nmt_get_*()获得。
这解释了源码为什么把细分服务的回调安装集中放到 Impl_::OnCsInd():只有状态迁移已经推进,NMT 总控完成相应服务装配后,Node 才能取得服务指针并设置 indication。
5. NMT 状态迁移中的动态服务装配
OnCsInd() 是理解 Node 的核心函数。它并不实现 NMT 状态机,而是在 NMT 状态机发出 command indication 后,对新出现的服务安装 C++ 桥接回调。
5.1 RESET_COMM:接入 LSS 配置动作
当命令为 CO_NMT_CS_RESET_COMM,并且未定义 LELY_NO_CO_LSS 时:
- 通过
co_nmt_get_lss()获取 LSS 服务; - 通过
co_lss_set_rate_ind()注册码率激活回调; - 通过
co_lss_set_store_ind()注册 Node-ID/码率存储回调。
LSS 回调中的码率单位会从 C 层的 kbit/s 转为 C++ 接口使用的 bit/s:
1 | self->OnSwitchBitrate(rate * 1000, std::chrono::milliseconds(delay)); |
存储回调 OnStoreInd() 捕获所有异常并返回 -1,因为 C 回调边界不能传播 C++ 异常。
5.2 START 或 ENTER_PREOP:接入 SYNC 错误处理
当节点进入 Operational 或 Pre-operational 后,源码尝试取得 co_sync_t,并设置 co_sync_set_err()。
SYNC indication 本身在 Impl_ 构造时通过 co_nmt_set_sync_ind() 接入;SYNC service 的长度错误等细分错误回调,则要等服务创建后再注册。这体现了两级回调:
- NMT 总控可直接转发的通用事件;
- 具体 service 对象才能提供的细分事件。
5.3 TIME 与 EMCY:服务存在即接入
OnCsInd() 每次运行时都会尝试:
1 | co_nmt_get_time() -> co_time_set_ind() |
代码仍先检查返回指针。对象字典配置、构建选项或当前 NMT 阶段都可能导致服务不存在,Node 不把“编译了该模块”等同于“当前节点一定创建了该服务”。
5.4 START:遍历运行中的 PDO
进入 CO_NMT_CS_START 时,源码遍历编号 1..512:
- 对每个存在的 RPDO 设置完成 indication 和 error indication;
- 对每个存在的 TPDO 设置发送完成 indication。
这段遍历不是创建 512 个 PDO。co_nmt_get_rpdo() / co_nmt_get_tpdo() 只返回对象字典配置后实际存在的服务,空槽位会被跳过。
5.5 非 reset 阶段:刷新 PDO 反向映射
当命令既不是 RESET_NODE 也不是 RESET_COMM 时,Node 调用:
1 | self->UpdateRpdoMapping(); |
这两个函数来自 Device,用于更新本地代理对象与远程 PDO 地址之间的映射关系。它们不是重建 C 层 PDO 状态机,而是让 Device::RpdoRead()、Device::TpdoWrite() 等高层接口看到当前通信配置。
5.6 动态装配表
| NMT command indication | Node 层主要动作 |
前提 |
|---|---|---|
RESET_COMM |
接入 LSS bitrate/store 回调 | LSS 编译启用且服务存在 |
START / ENTER_PREOP |
接入 SYNC error 回调 | SYNC 编译启用且服务存在 |
| 多个阶段 | 接入 TIME、EMCY indication | 对应服务存在 |
START |
遍历并接入 RPDO/TPDO 回调 | PDO 服务已经由 NMT 创建 |
| 非 reset command | 更新 RPDO/TPDO 反向映射 | 对应模块编译启用 |
| 所有 command | 调用虚函数和注册的 OnCommand |
注册函数可为空 |
由此可以得到一个稳定的源码阅读原则:
在
Node中看到co_nmt_get_*(),通常不是在“使用一个永久成员”,而是在当前 NMT 阶段查询该服务是否已经由总控创建。
6. 从 C 回调到 C++ 应用:固定的四段式分发
Node 对大多数事件采用相同分发框架:
- 必要时先执行 C 层默认行为;
- 调用可被派生类覆盖的虚函数;
- 复制当前注册的
std::function; - 用
UnlockGuard临时释放Node锁,再执行外部函数对象。
1 | sequenceDiagram |
6.1 为什么先复制函数对象
典型代码如下:
1 | if (on_emcy) { |
复制发生在解锁前,因此读取 on_emcy 受当前锁保护。解锁后调用副本,即使应用回调内部重新注册另一个回调,也不会使正在执行的 callable 引用失效。
6.2 虚函数与注册函数的锁语义不同
官方类说明指出:
- 调用公开成员函数时,调用方必须保证
Node互斥量当前未被自己持有; - 进入虚函数回调时,
Node锁处于持有状态; - 动态注册的
std::function在虚函数完成后执行,并由实现临时解锁。
这形成了两种扩展方式:
| 扩展方式 | 执行时锁状态 | 更适合的工作 |
|---|---|---|
派生类重写 virtual OnXxx() |
锁定 | 快速更新派生类内部状态,调用要求锁已持有的受保护操作 |
Node::OnXxx(std::function) |
解锁 | 调用公开 API、跨对象通知、提交新异步任务 |
如果在虚函数回调中直接调用会再次锁定 Node 的公开成员,存在自锁风险。源码没有用递归线程锁来掩盖这个约束,而是通过接口文档和两阶段回调明确区分使用场景。
6.3 Heartbeat:默认处理、超时筛选和应用通知
OnHbInd() 首先执行:
1 | co_nmt_on_hb(nmt, id, state, reason); |
这是 NMT 默认 heartbeat error-control 行为。随后源码只处理 CO_NMT_EC_TIMEOUT:
state == CO_NMT_EC_OCCURRED转换为occurred = true;- 超时恢复转换为
occurred = false; - 其他 heartbeat 状态变化由
OnStInd()路径负责。
所以 OnHeartbeat(id, occurred) 表达的是“heartbeat timeout 故障发生或解除”,不是每次心跳帧到达通知。
6.4 NMT state:忽略本节点,只报告远端状态
OnStInd() 同样先调用 co_nmt_on_st(),再比较事件 Node-ID 与当前设备 Node-ID:
1 | if (id == co_dev_get_id(co_nmt_get_dev(nmt))) |
本节点状态变化不通过 OnState() 继续通知;该接口针对 heartbeat 协议检测到的远端节点 boot-up 或状态变化。当前节点自身的命令阶段通过 OnCommand() 路径表达。
7. 各协议事件如何汇入统一应用模型
Node 没有强行把所有协议事件压成同一种参数,而是保留各服务最有价值的信息,同时统一回调生命周期和锁策略。
7.1 RPDO 与 TPDO
RPDO 完成回调取得:
1 | PDO 编号 + SDO abort/status code + 原始映射数据指针 + 长度 |
源码通过 co_rpdo_get_num() 获取编号,并把 uint32_t ac 转为 SdoErrc/std::error_code 语义。对于启用 MPDO 的构建,OnRpdoInd() 还会识别 SAM-MPDO,并调用 RpdoWrite() 更新本地远程 PDO 代理映射。
RPDO 错误回调传递 emergency error code 和 error register。默认虚函数实现调用:
1 | Error(eec, er); |
TPDO 完成回调结构相似,通过 co_tpdo_get_num() 获得编号。它表示“TPDO 已发送或处理发生错误”,不是“对象字典任意值被写入”的通用通知。
7.2 SYNC
OnSyncInd() 在事件到达时读取:
1 | auto t = self->GetClock().gettime(); |
随后把 SYNC counter 和这一单调时钟时间点传给应用。因此参数中的 time_point 是本地收到或发送 SYNC 时的网络时钟观测点,不是 CANopen TIME 报文中的绝对时间。
SYNC 错误默认调用 OnSyncError(),其基类实现再进入 Error(eec, er),即交给 NMT 的统一通信错误处理。
7.3 TIME
TIME service 给出 timespec 后,Node 转换为:
1 | std::chrono::system_clock::time_point |
TIME 使用系统绝对时间语义,而 SYNC 使用 TimerBase::time_point。这种类型区分避免把周期同步时刻和日历时间混为一谈。
7.4 EMCY
EMCY indication 直接保留:
- 远端 Node-ID;
- emergency error code;
- error register;
- 5 字节 manufacturer-specific error field。
Node 不在这一层解释厂商字节,也不自动改写远端错误;具体含义仍由设备规范和 EDS/DCF 决定。
7.5 LSS
LSS 码率切换 indication 提供新码率和延时。Node 负责单位转换和 C++ 回调分发,但不会自动选择具体 CAN controller。应用可以在 OnSwitchBitrate() 中调用 AsyncSwitchBitrate(ctrl, bitrate, delay) 完成本地控制器切换。
7.6 CAN 控制器状态如何进入 EMCY/NMT 错误处理
Node::OnCanState() 对两个状态变化执行默认处理:
1 | 进入 Error Passive -> Error(0x8120, 0x10) |
Error() 继续调用 co_nmt_on_err(),由 NMT 默认错误处理生成 EMCY,并按照对象 0x1029:01 等 Error behavior 配置执行状态行为。
需要注意,源码中仍有 TODO:进入 error active 后清除相关 EMCY 尚未在该函数中实现。文章只能确认当前代码的上报路径,不能把 TODO 描述成已完成的自动清除能力。
8. Timer Queue 与 Future:为什么 Node 也提供异步等待
Node 继承的 CanNet 本身就是 io_tqueue_t 的使用者,因此它把同一计时基础暴露为:
SubmitWait();AsyncWait();CancelWait();AbortWait()。
8.1 相对时间最终转换为绝对时间
相对等待不会直接把 duration 传给底层,而是:
1 | AsyncWait(exec, GetClock().gettime() + d, pwait); |
这样所有等待都在同一个 TimerBase 时间域中排序,避免各异步调用分别维护相对倒计时。
8.2 默认 Executor
当 exec == nullptr 时:
1 | exec = GetExecutor(); |
这使等待完成任务默认回到 Node 所在的执行器。调用方仍可显式提供其他 executor,从而改变 continuation 的执行上下文,但底层过期时间仍来自同一 Timer Queue。
8.3 错误从整数转换为异常通道
io_tqueue_async_wait() 返回 Future<void, int>。Node 再追加 continuation,把非零 errc 转成异常,使公开返回值成为:
1 | ev::Future<void, std::exception_ptr> |
因此,上层 Future 链可以统一用 get().value() 传播失败,而不必每层手工判断 C 整数错误码。
8.4 CancelWait() 与 AbortWait() 不是同义词
| 操作 | pending wait 的处理 | completion task |
|---|---|---|
CancelWait() |
取消等待 | 仍提交,以 operation_canceled 完成 |
AbortWait() |
中止等待 | 不再提交完成任务 |
前者适合让 Future 链感知取消,后者适合彻底丢弃尚未执行的完成路径。选择错误会直接影响资源释放、continuation 是否运行及上层状态机是否收到结束信号。
9. AsyncSwitchBitrate():
1 | stop |
对应时序如下。
1 | sequenceDiagram |
10. TpdoEventMutex 不是普通线程互斥锁
Node 的受保护成员:
1 | TpdoEventMutex tpdo_event_mutex; |
实现只有两个关键调用:
1 | void TpdoEventMutex::lock() { |
它实现 BasicLockable 接口,但锁住的不是 C++ 临界区,而是 NMT 的异步 TPDO event 触发路径。持锁期间:
- 由
co_nmt_on_tpdo_event()触发的 acyclic/event-driven TPDO 被推迟; - 嵌套锁按递归计数匹配;
- 最外层
unlock()后,NMT 才可能发送已积累的 TPDO 事件。
它适合在派生类中成组更新多个会共同影响同一 TPDO 的对象。例如:
1 | { |
这与 Device(dev, id, this) 使用的 Node/CanNet 线程互斥量必须分开理解:
| 锁 | 保护对象 | 目的 |
|---|---|---|
Node / CanNet 的 BasicLockable |
对象字典、网络事件和回调共享状态 | 防止并发数据竞争,规定回调重入边界 |
TpdoEventMutex |
NMT 的 TPDO event 触发计数 | 暂缓异步 TPDO,避免中间数据组合被发送 |
TpdoEvent() 则是显式向 NMT 报告某个 TPDO 发生事件:
1 | co_nmt_on_tpdo_event(nmt(), num); |
num == 0 的具体广播或选择语义由底层 NMT API 负责,Node 不重新实现事件调度。
11. 条件编译如何改变能力,而不改变总体结构
node.cpp 对 RPDO、TPDO、MPDO、SYNC、TIME、EMCY、LSS 等模块使用 LELY_NO_CO_* 宏。
这种条件编译主要改变三处:
- 是否包含对应 C service 头文件;
Impl_是否保存对应std::function和 handler;- 注册接口是否真正保存回调,或退化为
(void)callback的空操作。
例如,当 LELY_NO_CO_TIME 为真时,Node::OnTime(std::function<...>) 这一 C++ 成员仍可出现在公共头文件中,但实现不会保存回调;StartTime()/StopTime() 的实际可用性也受对应编译分支约束。
这意味着仅凭应用源码“成功调用了注册函数”不能证明该服务已启用。能力判断至少要同时看:
1 | 构建宏是否启用 |
三个条件缺一,都可能得到“接口存在但没有运行事件”的结果。
12. 最终运行模型
把前面的源码关系压缩后,coapp::Node 可以用四条路径理解。
12.1 输入路径
1 | CanChannelBase |
CanNet 把真实 CAN I/O 和系统时间送入被动的 C 协议栈。
12.2 状态路径
1 | Node::Reset() 或 NMT command |
服务生命周期归 co_nmt_t 管理,Node 只在合适阶段接入 C++ 回调。
12.3 应用路径
1 | C service indication |
这条路径同时解决 C ABI、C++ 多态、动态回调注册和重入控制。
12.4 时间路径
1 | TimerBase / io_tqueue |
Timer Queue 既支撑 CANopen 内部定时,也为应用层串联无阻塞动作提供统一时间域。
因此,Node 最准确的定位不是“CANopen 协议全集”,而是:
1 | Node = CanNet 的 I/O/时间能力 |
掌握这一模型后,再阅读 Master、Slave、Driver 或某个 PDO/SDO service 时,就能先判断代码位于哪一层:是协议状态机、网络适配、对象字典访问,还是应用回调装配,而不会把所有行为都归到 Node 本身。
参考资料与版本说明
- Lely core GitLab:https://gitlab.com/lely_industries/lely-core/-/tree/master?ref_type=heads
node.hpp源码:https://lely_industries.gitlab.io/lely-core/doxygen/node_8hpp_source.htmlnode.cpp源码:https://lely_industries.gitlab.io/lely-core/doxygen/node_8cpp_source.htmllely::canopen::NodeAPI:https://lely_industries.gitlab.io/lely-core/doxygen/classlely_1_1canopen_1_1Node.html- Lely CANopen Library overview:https://opensource.lely.com/canopen/docs/overview/








