Lely CANopen coapp::Node:CAN 网络、NMT 服务与 C++ 事件回调的装配机制

摘要:从源码追踪 coapp::Node 如何组合 CAN I/O、对象字典、NMT 服务、定时队列与 C++ 回调,并解释构造、复位和事件分发的完整流程。

@[toc]
在这里插入图片描述

1. Node 不是协议重写,而是装配中心

阅读 lely-core/include/lely/coapp/node.hpplely-core/src/coapp/node.cpp 时,最重要的判断不是“Node 实现了多少 CANopen 服务”,而是:

lely::canopen::Node 把 CAN I/O、时间源、本地对象字典、NMT 总控和 C++ 应用回调装配成一个可运行节点;具体 PDO、SYNC、TIME、EMCY、LSS 等协议状态机仍由 C 层服务对象实现。

这一判断可以直接从继承关系和内部对象得到:

1
2
3
4
5
class Node : public io::CanNet, public Device {
// ...
struct Impl_;
std::unique_ptr<Impl_> impl_;
};

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
flowchart LR
EXEC["Executor"] --> CNET["io::CanNet<br/>CAN I/O 与时间接入"]
TIMER["TimerBase / Timer Queue"] --> CNET
CHAN["CanChannelBase"] --> CNET

DCF["EDS / DCF"] --> DEV["Device / co_dev_t<br/>对象字典与 Node-ID"]
SDEV["co_sdev 静态设备描述"] --> DEV

NODE["coapp::Node<br/>装配与适配中心"] --> CNET
NODE --> DEV
NODE --> IMPL["Node::Impl_"]
IMPL --> NMT["co_nmt_t<br/>NMT 总控"]
NMT --> SVC["RPDO / TPDO / SYNC / TIME<br/>EMCY / LSS / SDO 等服务"]

SVC --> BRIDGE["C indication 回调"]
BRIDGE --> APP["虚函数与 std::function<br/>C++ 应用逻辑"]

图中的 Node → CanNetNode → Device 表示继承关系;Node → Impl_ → co_nmt_t 表示内部所有权关系。Node 并不把这些层合并成一套新协议实现,而是在生命周期、锁和回调层面把它们连起来。

2. 类结构、所有权与构造输入

2.1 三种设备描述入口

Node 的公开构造函数支持三类设备描述来源:

  1. 已存在的 co_dev_t*
  2. EDS/DCF 文本及可选 concise DCF 二进制;
  3. 编译进程序的 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
2
3
4
5
6
7
8
Node::Node(ev_exec_t* exec, io::TimerBase& timer,
io::CanChannelBase& chan, co_dev_t* dev, uint8_t id)
: io::CanNet(exec, timer, chan, 0, 0),
Device(dev, id, this),
tpdo_event_mutex(*this),
impl_(new Impl_(this, net(), Device::dev())) {
start();
}

这里有四个关键关系。

第一,CanNet 必须先建立,因为 Impl_ 创建 co_nmt_t 时需要 can_net_t*

第二,Device(dev, id, this)Node 自身作为 Device 使用的 BasicLockable。这表示对象字典访问和 CAN 网络事件共享 Node/CanNet 的互斥边界,而不是各自维护互不相关的锁。

第三,Impl_ 同时取得:

1
2
3
Node*       -> 回调到 C++ 对象
can_net_t* -> 连接协议服务与 CAN 网络
co_dev_t* -> 提供对象字典和通信参数

第四,构造函数末尾调用的是 CanNet::start(),注释也明确写为“Start processing CAN frames”。它只表示开始处理 CAN I/O,不等价于 CANopen 节点已经完成 boot-up。

2.3 co_nmt_t 的 RAII 管理

Impl_ 用自定义删除器持有 NMT 对象:

1
2
3
4
5
6
7
struct NmtDeleter {
void operator()(co_nmt_t* nmt) const noexcept {
co_nmt_destroy(nmt);
}
};

std::unique_ptr<co_nmt_t, NmtDeleter> nmt;

这层封装解决了 C/C++ 所有权边界:

  • C API 负责 co_nmt_create() / co_nmt_destroy()
  • C++ unique_ptr 负责异常路径和析构路径上的自动释放;
  • Node 禁止复制,避免多个实例共同拥有同一 NMT 服务及网络资源。

官方接口还要求传入的 TimerBaseCanChannelBase 不再被其他用途复用。这不是普通的“引用生命周期”提醒,而是因为 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
2
3
4
5
6
void Node::Reset() {
std::lock_guard<util::BasicLockable> lock(*this);
SetTime();
if (co_nmt_cs_ind(nmt(), CO_NMT_CS_RESET_NODE) == -1)
util::throw_errc("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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
sequenceDiagram
participant APP as Application
participant NODE as coapp::Node
participant CNET as io::CanNet
participant DEV as Device
participant IMPL as Node::Impl_
participant NMT as co_nmt_t

APP->>NODE: 构造 Node
NODE->>CNET: 构造 CAN 网络适配
NODE->>DEV: 构造对象字典封装<br/>mutex = this
NODE->>IMPL: new Impl_(this, net, dev)
IMPL->>NMT: co_nmt_create(net, dev)
IMPL->>NMT: 注册 NMT/HB/State/SYNC 总控回调
NODE->>CNET: start()
Note over CNET,NMT: CAN 帧处理已启动<br/>NMT 仍为 Initialisation

APP->>NODE: Reset()
NODE->>NODE: 加锁
NODE->>CNET: SetTime()
NODE->>NMT: co_nmt_cs_ind(RESET_NODE)
NMT-->>IMPL: OnCsInd(状态阶段命令)
IMPL->>NMT: 获取已创建服务并安装细分回调
IMPL->>DEV: 更新 RPDO/TPDO 反向映射
IMPL->>NODE: 虚函数 OnCommand()
IMPL-->>APP: 解锁后调用注册函数

Reset() 会进一步触发 OnCommand()。官方 API 明确要求不得从 OnCommand() 中再次调用 Reset(),否则会在同一命令处理链中递归注入 reset node,破坏状态机和锁语义。

4. Impl_ 如何把 C NMT 对象接入 C++

4.1 构造时只注册“总控级”回调

Impl_ 构造函数创建 co_nmt_t 后,立即注册四类入口:

1
2
3
4
co_nmt_set_cs_ind()    -> NMT command indication
co_nmt_set_hb_ind() -> heartbeat error-control indication
co_nmt_set_st_ind() -> remote NMT state indication
co_nmt_set_sync_ind() -> SYNC indication(功能启用时)

它们有一致的 C 回调桥接形态:

1
2
3
4
5
6
co_nmt_set_cs_ind(
nmt.get(),
[](co_nmt_t* nmt, uint8_t cs, void* data) noexcept {
static_cast<Impl_*>(data)->OnCsInd(nmt, cs);
},
this);

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 时:

  1. 通过 co_nmt_get_lss() 获取 LSS 服务;
  2. 通过 co_lss_set_rate_ind() 注册码率激活回调;
  3. 通过 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 STARTENTER_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
2
co_nmt_get_time() -> co_time_set_ind()
co_nmt_get_emcy() -> co_emcy_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
2
self->UpdateRpdoMapping();
self->UpdateTpdoMapping();

这两个函数来自 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 对大多数事件采用相同分发框架:

  1. 必要时先执行 C 层默认行为;
  2. 调用可被派生类覆盖的虚函数;
  3. 复制当前注册的 std::function
  4. UnlockGuard 临时释放 Node 锁,再执行外部函数对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
sequenceDiagram
participant CAN as CAN / Timer Event
participant CSVC as C Service Callback
participant IMPL as Node::Impl_
participant DEF as C Default Handler
participant VIRT as Node Virtual Hook
participant GUARD as UnlockGuard
participant FUNC as Registered std::function

CAN->>CSVC: 帧、超时或服务完成事件
CSVC->>IMPL: static lambda + void* data
opt 该事件需要 C 层默认行为
IMPL->>DEF: co_nmt_on_hb / co_nmt_on_st
end
IMPL->>VIRT: OnXxx(),此时 Node 锁仍持有
IMPL->>IMPL: 复制函数对象
IMPL->>GUARD: 临时 unlock(*self)
GUARD->>FUNC: 调用应用注册函数
FUNC-->>GUARD: 返回
GUARD->>IMPL: 作用域结束后重新加锁

6.1 为什么先复制函数对象

典型代码如下:

1
2
3
4
5
if (on_emcy) {
auto f = on_emcy;
util::UnlockGuard<util::BasicLockable> unlock(*self);
f(id, ec, er, msef);
}

复制发生在解锁前,因此读取 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
2
if (id == co_dev_get_id(co_nmt_get_dev(nmt)))
return;

本节点状态变化不通过 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
2
进入 Error Passive       -> Error(0x8120, 0x10)
从 Bus-off 状态恢复 -> Error(0x8140, 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
2
3
4
5
6
stop
-> AsyncWait(delay)
-> continuation 返回 AsyncWait(delay)
-> set_bitrate
-> continuation 返回 AsyncWait(delay)
-> restart

对应时序如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
sequenceDiagram
participant APP as OnSwitchBitrate / Application
participant NODE as Node
participant CTRL as CanControllerBase
participant TQ as Timer Queue
participant FUT as Future Continuations

APP->>NODE: AsyncSwitchBitrate(ctrl, bitrate, delay)
NODE->>CTRL: stop()
NODE->>TQ: AsyncWait(delay) #1
TQ-->>FUT: wait #1 ready
FUT->>TQ: AsyncWait(delay) #2
TQ-->>FUT: wait #2 ready
FUT->>CTRL: set_bitrate(bitrate)
FUT->>TQ: AsyncWait(delay) #3
TQ-->>FUT: wait #3 ready
FUT->>CTRL: restart()
FUT-->>APP: Future ready or exception

10. TpdoEventMutex 不是普通线程互斥锁

Node 的受保护成员:

1
TpdoEventMutex tpdo_event_mutex;

实现只有两个关键调用:

1
2
3
4
5
6
7
void TpdoEventMutex::lock() {
co_nmt_on_tpdo_event_lock(node->nmt());
}

void TpdoEventMutex::unlock() {
co_nmt_on_tpdo_event_unlock(node->nmt());
}

它实现 BasicLockable 接口,但锁住的不是 C++ 临界区,而是 NMT 的异步 TPDO event 触发路径。持锁期间:

  • co_nmt_on_tpdo_event() 触发的 acyclic/event-driven TPDO 被推迟;
  • 嵌套锁按递归计数匹配;
  • 最外层 unlock() 后,NMT 才可能发送已积累的 TPDO 事件。

它适合在派生类中成组更新多个会共同影响同一 TPDO 的对象。例如:

1
2
3
4
5
6
{
std::lock_guard<TpdoEventMutex> hold(tpdo_event_mutex);
// 更新多个相关 OD 值
// 期间触发的 event-driven TPDO 被延后
}
// 最外层解锁后再处理 TPDO event

这与 Device(dev, id, this) 使用的 Node/CanNet 线程互斥量必须分开理解:

保护对象 目的
Node / CanNetBasicLockable 对象字典、网络事件和回调共享状态 防止并发数据竞争,规定回调重入边界
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_* 宏。

这种条件编译主要改变三处:

  1. 是否包含对应 C service 头文件;
  2. Impl_ 是否保存对应 std::function 和 handler;
  3. 注册接口是否真正保存回调,或退化为 (void)callback 的空操作。

例如,当 LELY_NO_CO_TIME 为真时,Node::OnTime(std::function<...>) 这一 C++ 成员仍可出现在公共头文件中,但实现不会保存回调;StartTime()/StopTime() 的实际可用性也受对应编译分支约束。

这意味着仅凭应用源码“成功调用了注册函数”不能证明该服务已启用。能力判断至少要同时看:

1
2
3
构建宏是否启用
+ 对象字典是否配置
+ 当前 NMT 阶段是否创建服务

三个条件缺一,都可能得到“接口存在但没有运行事件”的结果。

12. 最终运行模型

把前面的源码关系压缩后,coapp::Node 可以用四条路径理解。

12.1 输入路径

1
2
3
4
CanChannelBase
-> io::CanNet
-> can_net_t
-> co_nmt_t / 具体 CANopen service

CanNet 把真实 CAN I/O 和系统时间送入被动的 C 协议栈。

12.2 状态路径

1
2
3
4
5
6
Node::Reset() 或 NMT command
-> co_nmt_cs_ind()
-> NMT 状态迁移
-> 创建/销毁协议服务
-> Impl_::OnCsInd()
-> 安装当前阶段可用的 service callback

服务生命周期归 co_nmt_t 管理,Node 只在合适阶段接入 C++ 回调。

12.3 应用路径

1
2
3
4
5
6
C service indication
-> static lambda + void* data
-> Node::Impl_::OnXxx()
-> C 默认处理(按需)
-> virtual Node::OnXxx()(锁定)
-> std::function(临时解锁)

这条路径同时解决 C ABI、C++ 多态、动态回调注册和重入控制。

12.4 时间路径

1
2
3
4
5
TimerBase / io_tqueue
-> CANopen timeout
-> Node::AsyncWait()
-> Future continuation
-> LSS bitrate switch 等异步流程

Timer Queue 既支撑 CANopen 内部定时,也为应用层串联无阻塞动作提供统一时间域。

因此,Node 最准确的定位不是“CANopen 协议全集”,而是:

1
2
3
4
5
Node = CanNet 的 I/O/时间能力
+ Device 的对象字典能力
+ co_nmt_t 的服务生命周期
+ C 到 C++ 的事件适配
+ 明确的锁与异步执行语义

掌握这一模型后,再阅读 MasterSlaveDriver 或某个 PDO/SDO service 时,就能先判断代码位于哪一层:是协议状态机、网络适配、对象字典访问,还是应用回调装配,而不会把所有行为都归到 Node 本身。

参考资料与版本说明