Lely CANopen coapp::Master:NMT 总控、SDO 仲裁与 Driver 事件分发机制
摘要:从 master.hpp 与 master.cpp 追踪 BasicMaster/AsyncMaster 如何组织 NMT 启动、SDO 所有权、配置握手及 Driver 事件分发。

@[toc]lely::canopen::BasicMaster 和 lely::canopen::AsyncMaster 是 Lely CANopen C++ 应用层中负责主站编排的核心对象。它们并不是简单地把 NMT、SDO、PDO API 包装成 C++ 成员函数,而是在 Node 的本地 CANopen 节点能力之上,进一步维护远端节点 Driver、节点启动状态、配置阶段状态以及 Client-SDO 使用权。
理解这两个类的关键,不是逐个记住 SubmitRead()、Command()、OnBoot() 等接口,而是建立下面这条主线:
本地主站 Node 启动
-> NMT master 发起或接管远端节点 boot slave 流程
-> boot 过程中独占或临时移交 Client-SDO
-> Driver 完成设备相关配置
-> Master 汇总状态并分发 NMT/PDO/EMCY/SYNC/TIME 事件1. 先建立整体心智模型
BasicMaster 同时承担四类职责:
1. 本地 CANopen 节点:继承 Node,拥有本地对象字典、NMT、PDO、SYNC、TIME、EMCY、CAN channel、timer 和 executor 等能力。2. 远端节点编排器:通过 NMT master 的 boot slave 流程识别、检查、配置并启动从站。 3. Driver 注册表:按 node-ID 保存一个 DriverBase*,把属于某个远端节点的事件交给对应 Driver。4. Client-SDO 仲裁器:决定应用层 SDO 当前是否可用,以及由普通请求、NMT boot 还是配置阶段占用。
AsyncMaster 没有重新实现一套协议状态机。它继承 BasicMaster,主要改变“事件如何进入用户 Driver”:
• BasicMaster在协议事件处理路径中直接调用 Driver 回调,但调用前暂时释放 Master 锁;• AsyncMaster把回调封装为 task,投递到该 Driver 的 executor,当前协议回调随后返回。
官方教程也用这个差异解释 AsyncMaster:用户定义的 CANopen 事件回调会作为事件循环任务执行,而不是直接嵌在协议栈事件处理调用栈中。[S3]
这张图中最重要的关系是:Master 本身仍然是一个 CANopen Node;Driver 才是远端设备的应用层接口。
2. master.hpp 定义了什么抽象
2.1 继承关系不是装饰,而是职责组合
master.hpp 中的核心声明可以概括为:
classBasicMaster : public Node,
protected std::map<uint8_t, DriverBase*>;
classAsyncMaster : public BasicMaster;这意味着:
• 从 Node继承本地主站所需的协议服务和对象字典访问能力;• 从受保护的 std::map继承 Driver 容器行为;• 派生类可以按关联容器方式遍历 Driver,但外部应用不能随意破坏注册表; • AsyncMaster只需覆盖事件入口,即可改变整个 Driver 回调的执行上下文。[S1][S4]
2.2 BasicMaster::Impl_ 保存真正的主站协调状态
master.cpp:45-68 中的私有实现包含以下数据:
self | BasicMaster |
on_node_guarding | |
on_boot | |
ready[CO_NUM_NODES] | |
config[CO_NUM_NODES] | |
sdos | node-ID -> Sdo |
这里没有保存一份“远端完整对象字典”。远端设备类型、业务行为和回调由 Driver 表达;Master 只保存完成编排所需的最小状态。
2.3 三组访问接口对应三种不同的数据路径
BasicMaster 对外暴露的对象访问接口容易混淆,实际上分别对应:
master[idx][subidx] | ||
master.RpdoMapped(id)[idx][subidx] | ||
master.TpdoMapped(id)[idx][subidx] | ||
SubmitRead/AsyncRead | ||
SubmitWrite/AsyncWrite |
从总线方向理解更直观:
• RpdoMapped(id)是只读入口,读取主站已经通过 RPDO 接收的数据;这些数据来源于远端节点的 TPDO。• TpdoMapped(id)是读写入口,修改主站本地 TPDO 代理;随后数据可通过主站 TPDO 发往远端节点的 RPDO。• 只有 SDO API 才会按 index/sub-index 对远端对象字典发起请求。
因此,PDO 映射访问是“本地代理对象访问”,SDO 访问才是“远端对象字典事务”。[S1][S3]
3. 从构造到 Reset():Master 何时真正开始工作
3.1 构造函数只完成装配
BasicMaster 支持三种设备描述来源:
1. 已创建的 co_dev_t*;2. 文本 EDS/DCF 与可选 concise DCF; 3. 静态设备描述 co_sdev*。
三个构造函数都遵循同一结构:
构造 Node
-> 构造 BasicMaster::TpdoEventMutex
-> 创建 Impl_
-> 将 Node 内部的 co_nmt_t* 交给 Impl_
-> 注册 NMT boot/config/node-guarding indication官方 API 明确说明:构造完成后 Master 仍处于 NMT Initialisation 状态,不会立即创建全部服务或进行通信;调用 Reset() 后才启动本地 NMT boot-up。[S2][S4]
3.2 dcf_txt 与 dcf_bin 的角色
构造参数中的:
• dcf_txt描述主站本地对象字典和 NMT 配置;• dcf_bin是加载到主站本地对象字典的 concise DCF;• 对远端从站的自动配置通常由主站 DCF 中的网络管理对象引用相应配置内容,再由 NMT boot slave 流程执行。
官方 C++ 教程使用 dcfgen 生成 master.dcf,并说明某些配置还会生成 master.bin;Master 构造后调用 Reset(),由本地 NMT 服务开始运行。[S3]
下面是用于说明装配关系的最小化代码,省略 I/O 初始化细节:
lely::canopen::AsyncMaster master(
executor, timer, channel, "master.dcf", "master.bin", 1);
MyDriver node2_driver(driver_executor, master, 2);
MyDriver node3_driver(driver_executor, master, 3);
master.Reset();这里的顺序有实际含义:
• Master 必须先存在,Driver 才能注册到它; • Driver 应在远端节点事件到来前完成注册; • Reset()只是启动本地主站 NMT,具体是否自动 boot 某个从站还受主站 DCF 中 CiA 302 网络管理配置影响。
4. Driver 注册表如何把多从站拆成独立接口
4.1 一个 node-ID 只能注册一个 Driver
BasicMaster::Insert() 在加锁后检查:
• node-ID 必须位于 1…127; • 不能等于 Master 自己的 node-ID; • 同一 node-ID 不能重复注册。
通过检查后,注册表保存:
node-ID -> DriverBase*Erase() 只删除与当前指针完全匹配的 Driver,并先取消该节点的 SDO 队列。这使该节点的排队或进行中 SDO 与 Driver 注册生命周期一并结束。[S2]
4.2 事件分为“广播型”和“单节点型”
OnCanState() | ||
OnCanError() | ||
OnCommand() | ||
OnSync() | ||
OnSyncError() | ||
OnTime() | ||
OnRpdoWrite() | ||
OnHeartbeat() | ||
OnState() | ||
OnEmcy() | ||
OnNodeGuarding() | ||
OnBoot() | ||
OnConfig() |
这种划分使主站可以同时管理不同类型的从站:通用总线事件广播给所有 Driver,设备相关事件只进入对应 node-ID 的 Driver。
5. Boot(id):单节点启动流程的编排入口
5.1 Boot() 不是发送一帧 NMT 命令
BasicMaster::Boot(id) 请求的是完整 NMT boot slave 过程,而不是简单调用一次 Command(START, id)。其等价逻辑如下:
校验 node-ID
-> 获取 Master 锁
-> 若该节点已经 booting,返回 false
-> 取消该节点正在执行或排队的应用 SDO
-> 暂时清除 ready 标志
-> co_nmt_boot_req(...)
-> 请求提交失败时恢复原 ready 状态并报错为什么先取消 SDO?源码注释直接给出原因:NMT master 在 boot slave 过程中可能需要使用同一个默认 Client-SDO 服务,例如读取设备类型、身份对象或下发配置。[S2]
5.2 boot slave 的核心阶段
master.cpp 并未重新实现 CiA 302 boot 状态机;它调用 C 层 NMT 服务,并通过 indication 接收关键节点:
• co_nmt_set_boot_ind():boot slave 完成;• co_nmt_set_cfg_ind():进入 update configuration 阶段;• co_nmt_set_ng_ind():node guarding 事件。
因此 BasicMaster 的作用更接近“C 层 NMT 状态机的 C++ 编排和应用桥接层”。
5.3 ready 的准确语义
官方 API 对 IsReady(id) 的定义是:
• 该节点的 boot slave 流程已经成功完成; • 此后没有再次收到该节点的 boot-up 事件; • 调用 AsyncDeconfig()也会将其标记为 not ready。[S4]
Impl_::OnBootInd() 在无错误,或错误状态字符为 L 时,把 ready[id - 1] 置为 true。L 表示从站最初已处于 Operational,NMT master 可以继续处理其他节点。[S2][S4]
需要特别区分:
IsReady(id) == true不等价于:
远端设备此刻已经完成应用内部初始化,并稳定处于 Operational维护者在 issue #92 中解释过:NMT start 命令没有确认机制,OnBoot() 可能在从站真正进入 Operational 前先被调用;判断 PDO 是否已经可靠可用,应同时观察 Master 和从站的 NMT state。[S6]
6. Client-SDO 为什么必须由 Master 仲裁
这是 master.cpp 中最关键、也最容易在实际项目中触发异常的设计。
6.1 默认 SDO 不是永久可用资源
GetSdo(id) 按以下顺序决定是否返回 Client-SDO:
sdos[id] | ||
Sdo |
这里的判断顺序很重要:配置阶段的 sdos[id] 检查位于 booting 检查之前。 这使得节点虽然仍处于 boot slave 流程,Driver 在 OnConfig() 内却可以合法使用 NMT 服务临时移交的 Client-SDO。
6.2 所有 SDO API 最终都经过同一闸门
SubmitRead()、SubmitWrite()、block 传输、future 版本以及 DCF 下载接口,虽然模板重载很多,主干基本一致:
获取 Master 锁
-> GetSdo(id)
-> 更新 CAN network time
-> 向 Sdo 队列提交请求
-> 不可用时返回或抛出 SdoErrc::NO_SDO因此,看到 060A0023 Resource not available: SDO connection 时,不能直接判断为从站 SDO server 故障。它可能只是:
• Master 本地 NMT 状态不允许创建 Client-SDO; • NMT boot slave 正在使用默认 Client-SDO; • boot-up 事件触发后旧队列已被主动取消; • NMT command 使 Master 离开 Pre-operational/Operational,全部应用 SDO 被清理。
Lely 维护者在 issue #76 中明确说明:默认 SDO client 只有在 NMT master 不使用它时才可用;通常应用应在 OnBoot() 后使用,或在 OnConfig() 的配置窗口内使用。[S5]
6.3 CancelSdo() 是生命周期切换的一部分
源码在以下位置主动清理 SDO:
• Boot(id)开始前;• Driver 从注册表移除时; • Master 收到使本地状态离开 Pre-operational/Operational 的 NMT command 时; • 远端节点出现新的 boot-up,且当前不在配置阶段时; • 配置阶段结束并把 Client-SDO 重新交还 NMT boot 流程时。
这说明 Sdo 队列的生命周期服从 NMT 生命周期,而不是应用对象的生命周期。应用不能保存一个 Sdo*,然后假设它在 reset、boot-up 或重新配置后仍然有效。
7. OnConfig():NMT 与 Driver 之间的配置握手
7.1 C 层把正在使用的 Client-SDO 临时交给 C++ 层
Impl_ 构造时通过 co_nmt_set_cfg_ind() 注册配置 indication。当 NMT boot slave 进入 update configuration 阶段时,C 层回调提供一个 co_csdo_t*。
Impl_::OnCfgInd() 执行三件事:
1. 用该 co_csdo_t*构造一个 C++Sdo包装对象并放入sdos[id];2. 将 config[id - 1]设为true;3. 调用 self->OnConfig(id)。
此时 GetSdo(id) 会优先返回这个已存在的队列,所以 Driver 可以在 boot slave 尚未结束时提交配置 SDO。
7.2 无 Driver 与有 Driver 的处理不同
BasicMaster::OnConfig(id):
• 没有为该 node-ID 注册 Driver:直接向 NMT 报告配置成功; • 有 Driver:释放 Master 锁,调用 Driver 的 OnConfig(done);• Driver 完成后调用 done(ec);• 完成函数重新获取 Master 锁并进入 ConfigResult()。
AsyncMaster::OnConfig(id) 的业务逻辑相同,但它先把 Driver 配置任务投递到 driver->GetExecutor(),避免在 NMT indication 调用栈内执行用户代码。[S2]
7.3 done 是继续 NMT 状态机的必要条件
官方 API 说明,NMT boot slave 会停在 update configuration 阶段,直到应用通过 ConfigResult() 报告结果;Driver 层对应的就是 OnConfig(done) 中的完成函数。[S4]
因此 OnConfig() 的正确语义是:
执行设备相关配置
-> 将所有异步结果归并为一个 error_code
-> 调用 done(error_code)
-> 允许 NMT boot slave 继续它不是普通通知回调。若遗漏完成函数,boot slave 会保持在配置阶段,IsConfig(id) 持续为真,最终也不会进入正常的 boot 完成路径。
7.4 ConfigResult() 完成所有权回收
ConfigResult():
• 清除 config标志;• 若节点仍处于 booting,删除 sdos[id]包装对象,因为 CSDO 将重新由 NMT master 接管;• 把 std::error_code转换为 SDO abort code;• 调用 co_nmt_cfg_res()让 C 层 NMT 状态机继续运行。
所以这不是“配置线程结束”这么简单,而是一次明确的资源交还:
NMT master 持有 CSDO
-> OnCfgInd 临时交给 Driver
-> Driver 调用 done
-> ConfigResult 归还 NMT master8. BasicMaster 与 AsyncMaster 的执行时序差异
8.1 BasicMaster:同步调用,但先释放锁
以 OnCanState() 为例,BasicMaster 遍历 Driver,并用 UnlockGuard 暂时释放 Master 锁后调用 Driver::OnCanState()。单节点事件则先查注册表,再以相同方式调用对应 Driver。
这解决两个问题:
• Driver 回调可以再次调用需要 Master 锁的 API,不会直接自锁; • 用户代码的执行时间不会把 Master 锁长期占住。
但回调仍发生在当前协议事件处理调用链中。若 Driver 执行阻塞操作,当前事件循环仍可能被拖延。
8.2 AsyncMaster:只负责投递,Driver 稍后执行
AsyncMaster 的覆盖函数通常执行:
找到 Driver
-> 复制回调参数
-> driver->GetExecutor().post(task)
-> 当前 Master 事件入口返回例如:
• CAN state/error、NMT command、SYNC/TIME 会给每个 Driver 投递 task; • heartbeat、state、EMCY、boot 等只投递给对应 node-ID; • OnConfig()也在 Driver executor 上运行。
OnEmcy() 还有一个值得注意的细节:源码先把 5 字节 manufacturer-specific error field 复制到 std::array,再捕获进异步 task。原因是原始 uint8_t msef[5] 指针的生命周期只覆盖当前回调,不能直接跨越异步投递边界。[S2]
8.3 异步不等于并行,也不自动保证全局顺序
从这两个文件能确认的是“任务被投递到各 Driver 的 executor”。以下行为取决于应用选择的 executor、strand、fiber 或独立线程模型:
• 同一 Driver 内多个任务是否串行; • 不同 Driver 回调是否并行; • 回调相对其他应用 task 的排队顺序; • 长耗时回调是否阻塞共享 event loop。
因此,AsyncMaster 保证的是调用栈解耦,不应被直接解释为“每个节点都有独立线程”。官方教程进一步提供 FiberDriver 和 LoopDriver 等不同执行模型;它们的阻塞语义和死锁风险并不相同。[S3]
9. NMT command、远端状态与 SDO 清理如何联动
9.1 Command() 只是发起 NMT command
BasicMaster::Command(cs, id) 获取锁后调用 C 层 co_nmt_cs_req()。node-ID 为 0 时可表示广播,具体帧和状态行为由 NMT 服务执行。[S2]
9.2 OnCommand() 处理的是 Master 自身状态迁移
当 Master 本地 NMT 收到 command 时,OnCommand(cs) 会检查即将进入的状态:
• 若不是 Pre-operational 或 Operational,取消全部应用 SDO; • 再将 command 事件通知各 Driver。
这与 GetSdo() 的可用条件一致:离开可提供 Client-SDO 的 NMT 状态后,旧请求队列必须失效。
9.3 新 boot-up 事件会使旧 ready 与 SDO 状态失效
远端节点发送 boot-up 后,OnState(id, BOOTUP) 在非配置阶段执行:
ready[id] = false
CancelSdo(id)因为新的 boot-up 表示远端通信状态已重置,之前的“已完成 boot”结论以及应用 SDO 会话都不能继续沿用;NMT master 也可能马上重新进入 boot slave 流程并接管 Client-SDO。[S2]
这张图描述的是 BasicMaster 暴露的应用状态,不是 CiA 302 内部 boot state machine 的完整替代。
10. AsyncDeconfig():与 boot 相反的 Driver 生命周期入口
AsyncDeconfig(id) 查找对应 Driver,随后由 Impl_::AsyncDeconfig():
1. 先把该节点标记为 not ready; 2. 创建 promise/future; 3. 向 Driver executor 投递 OnDeconfig(done);4. Driver 完成后设置 promise; 5. 调用者通过 future 获得完成或错误结果。
无参数版本会为全部 Driver 创建 future,再通过 when_all 聚合。[S2]
这一接口主要表达应用层设备资源的解除过程。它不会自动等同于 NMT stop、reset communication 或物理掉线;具体设备资源、业务状态和 I/O 停止动作由 Driver 的 OnDeconfig() 实现。
11. tpdo_event_mutex 为什么在 Master 中重新包装
Node::TpdoEventMutex 用于延迟事件驱动或异步 TPDO 的发送,使应用可以批量修改多个映射值后再统一触发。
BasicMaster::TpdoEventMutex::lock() 和 unlock() 在调用基类实现前,先获取 Master 锁。这样能维持两层同步关系:
Master 状态与对象访问同步
+
Node 的 TPDO event 延迟机制典型意图是把多个 TpdoMapped() 写入放入同一个临界区,避免更新到一半时触发 TPDO:
{
std::lock_guard<lely::canopen::BasicMaster::TpdoEventMutex> guard(
master.tpdo_event_mutex);
master.TpdoMapped(2)[0x6040][0] = uint16_t{0x0006};
master.TpdoMapped(2)[0x607A][0] = int32_t{100000};
}是否立即产生总线帧仍取决于 TPDO transmission type、event timer、SYNC 和 mapping 配置;mutex 只负责推迟事件触发,不改变 PDO 通信参数。[S1][S2]
12. 把完整流程串起来
下面以一个 AsyncMaster + Driver 管理 node 2 的典型启动过程为例:
从这个流程可以得到三个工程结论:
1. OnState(BOOTUP)不是执行普通应用 SDO 的稳定窗口,因为 NMT boot 可能已经准备接管默认 Client-SDO。2. OnConfig()是 boot 期间由 NMT 明确授权给 Driver 的 SDO 窗口,必须以完成回调结束。3. OnBoot()表示 boot slave 流程完成,但对依赖 PDO 的业务逻辑,仍应结合远端 NMT state 判断设备是否真正进入可运行阶段。
13. 条件编译决定哪些路径真实存在
master.hpp/master.cpp 中的重要能力受编译配置控制:
LELY_NO_COAPP_MASTER | |
LELY_NO_CO_DCF | |
LELY_NO_CO_SDEV | |
LELY_NO_CO_NMT_BOOT | Boot() |
LELY_NO_CO_NMT_CFG | config[] 和配置 CSDO 交接不可用 |
LELY_NO_CO_NG |
因此,对具体构建产物分析时,不能只看头文件里是否存在某个 API,还要确认目标库的 feature macros。本文描述的是这些功能启用时的主流程。[S1][S2]
14. 如何理解 BasicMaster、AsyncMaster 与 Driver 的边界
可以把三者压缩成下面的职责模型:
Node | ||
BasicMaster | ||
AsyncMaster | BasicMaster 基础上把 Driver 事件投递到 executor | |
DriverBase | ||
Sdo |
最终可以用一句话概括 coapp::Master:
它是本地 CANopen Node 与远端设备 Driver 之间的协调层,以 NMT 生命周期为主轴,对 Client-SDO 所有权、节点 ready/config 状态和协议事件执行上下文进行统一管理。
参考资料
• [S1] Lely core development Doxygen: master.hppsource[1]• [S2] Lely core development Doxygen: master.cppsource[2]• [S3] Lely CANopen C++ tutorial[3] • [S4] Lely Doxygen: lely::canopen::BasicMasterclass reference[4]• [S5] Lely issue #76: SDO availability during early initialization and boot[5] • [S6] Lely issue #92: relationship between OnBoot(), Operational state and PDO events[6]
引用链接
[1] Lely core development Doxygen: `master.hpp` source: https://lely_industries.gitlab.io/lely-core/doxygen/master_8hpp_source.html[2] Lely core development Doxygen: `master.cpp` source: https://lely_industries.gitlab.io/lely-core/doxygen/master_8cpp_source.html[3] Lely CANopen C++ tutorial: https://opensource.lely.com/canopen/docs/cpp-tutorial/[4] Lely Doxygen: `lely::canopen::BasicMaster` class reference: https://lely_industries.gitlab.io/lely-core/doxygen/classlely_1_1canopen_1_1BasicMaster.html[5] Lely issue #76: SDO availability during early initialization and boot: https://gitlab.com/lely_industries/lely-core/-/issues/76[6] Lely issue #92: relationship between `OnBoot()`, Operational state and PDO events: https://gitlab.com/lely_industries/lely-core/-/issues/92
夜雨聆风