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() 等接口,而是建立下面这条主线:
1 | 本地主站 Node 启动 |
1. 先建立整体心智模型
BasicMaster 同时承担四类职责:
- 本地 CANopen 节点:继承
Node,拥有本地对象字典、NMT、PDO、SYNC、TIME、EMCY、CAN channel、timer 和 executor 等能力。 - 远端节点编排器:通过 NMT master 的 boot slave 流程识别、检查、配置并启动从站。
- Driver 注册表:按 node-ID 保存一个
DriverBase*,把属于某个远端节点的事件交给对应 Driver。 - Client-SDO 仲裁器:决定应用层 SDO 当前是否可用,以及由普通请求、NMT boot 还是配置阶段占用。
AsyncMaster 没有重新实现一套协议状态机。它继承 BasicMaster,主要改变“事件如何进入用户 Driver”:
BasicMaster在协议事件处理路径中直接调用 Driver 回调,但调用前暂时释放 Master 锁;AsyncMaster把回调封装为 task,投递到该 Driver 的 executor,当前协议回调随后返回。
官方教程也用这个差异解释 AsyncMaster:用户定义的 CANopen 事件回调会作为事件循环任务执行,而不是直接嵌在协议栈事件处理调用栈中。[S3]
1 | flowchart LR |
这张图中最重要的关系是:Master 本身仍然是一个 CANopen Node;Driver 才是远端设备的应用层接口。
2. master.hpp 定义了什么抽象
2.1 继承关系不是装饰,而是职责组合
master.hpp 中的核心声明可以概括为:
1 | class BasicMaster : public Node, |
这意味着:
- 从
Node继承本地主站所需的协议服务和对象字典访问能力; - 从受保护的
std::map继承 Driver 容器行为; - 派生类可以按关联容器方式遍历 Driver,但外部应用不能随意破坏注册表;
AsyncMaster只需覆盖事件入口,即可改变整个 Driver 回调的执行上下文。[S1][S4]
2.2 BasicMaster::Impl_ 保存真正的主站协调状态
master.cpp:45-68 中的私有实现包含以下数据:
| 成员 | 作用 |
|---|---|
self |
回指所属 BasicMaster |
on_node_guarding |
可注册的 node guarding 通知函数 |
on_boot |
可注册的 boot 完成通知函数 |
ready[CO_NUM_NODES] |
每个 node-ID 的 boot-ready 标志 |
config[CO_NUM_NODES] |
每个 node-ID 是否处于 update configuration 阶段 |
sdos |
node-ID -> Sdo,保存默认或配置阶段 Client-SDO 队列 |
这里没有保存一份“远端完整对象字典”。远端设备类型、业务行为和回调由 Driver 表达;Master 只保存完成编排所需的最小状态。
2.3 三组访问接口对应三种不同的数据路径
BasicMaster 对外暴露的对象访问接口容易混淆,实际上分别对应:
| 接口形态 | 操作对象 | 是否产生远端 SDO 报文 |
|---|---|---|
master[idx][subidx] |
主站本地对象字典 | 否 |
master.RpdoMapped(id)[idx][subidx] |
主站 RPDO 代理中缓存的远端数据 | 否 |
master.TpdoMapped(id)[idx][subidx] |
主站 TPDO 代理中准备发送给远端的数据 | 否 |
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 支持三种设备描述来源:
- 已创建的
co_dev_t*; - 文本 EDS/DCF 与可选 concise DCF;
- 静态设备描述
co_sdev*。
三个构造函数都遵循同一结构:
1 | 构造 Node |
官方 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 初始化细节:
1 | lely::canopen::AsyncMaster master( |
这里的顺序有实际含义:
- Master 必须先存在,Driver 才能注册到它;
- Driver 应在远端节点事件到来前完成注册;
Reset()只是启动本地主站 NMT,具体是否自动 boot 某个从站还受主站 DCF 中 CiA 302 网络管理配置影响。
1 | sequenceDiagram |
4. Driver 注册表如何把多从站拆成独立接口
4.1 一个 node-ID 只能注册一个 Driver
BasicMaster::Insert() 在加锁后检查:
- node-ID 必须位于 1…127;
- 不能等于 Master 自己的 node-ID;
- 同一 node-ID 不能重复注册。
通过检查后,注册表保存:
1 | node-ID -> DriverBase* |
Erase() 只删除与当前指针完全匹配的 Driver,并先取消该节点的 SDO 队列。这使该节点的排队或进行中 SDO 与 Driver 注册生命周期一并结束。[S2]
4.2 事件分为“广播型”和“单节点型”
| 事件 | 分发范围 | 典型 Driver 回调 |
|---|---|---|
| CAN state 变化 | 所有 Driver | OnCanState() |
| CAN error | 所有 Driver | OnCanError() |
| Master 本地 NMT command | 所有 Driver | OnCommand() |
| SYNC | 所有 Driver | OnSync() |
| SYNC 长度错误 | 所有 Driver | OnSyncError() |
| TIME | 所有 Driver | OnTime() |
| 某节点 PDO 数据写入 | 指定 node-ID | OnRpdoWrite() |
| heartbeat 发生/恢复 | 指定 node-ID | OnHeartbeat() |
| 远端 NMT state 变化 | 指定 node-ID | OnState() |
| EMCY | 指定 node-ID | OnEmcy() |
| node guarding 超时/恢复 | 指定 node-ID | OnNodeGuarding() |
| boot slave 完成 | 指定 node-ID | OnBoot() |
| update configuration | 指定 node-ID | OnConfig() |
这种划分使主站可以同时管理不同类型的从站:通用总线事件广播给所有 Driver,设备相关事件只进入对应 node-ID 的 Driver。
5. Boot(id):单节点启动流程的编排入口
5.1 Boot() 不是发送一帧 NMT 命令
BasicMaster::Boot(id) 请求的是完整 NMT boot slave 过程,而不是简单调用一次 Command(START, id)。其等价逻辑如下:
1 | 校验 node-ID |
为什么先取消 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++ 编排和应用桥接层”。
1 | sequenceDiagram |
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]
需要特别区分:
1 | IsReady(id) == true |
不等价于:
1 | 远端设备此刻已经完成应用内部初始化,并稳定处于 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:
| 条件 | 结果 | 原因 |
|---|---|---|
| node-ID 非法 | 抛出范围异常 | 请求本身无效 |
| Master 不在 Pre-operational 或 Operational | 返回空 | CSDO 服务在当前 NMT 状态不可用 |
sdos[id] 已存在 |
返回现有队列 | 可能是普通默认 SDO,也可能是配置阶段临时包装 |
| 该节点正在 boot slave | 返回空 | NMT master 正在占用默认 Client-SDO |
| 其余情况 | 延迟创建并返回默认 Sdo |
应用可提交普通远端 SDO 请求 |
这里的判断顺序很重要:配置阶段的 sdos[id] 检查位于 booting 检查之前。 这使得节点虽然仍处于 boot slave 流程,Driver 在 OnConfig() 内却可以合法使用 NMT 服务临时移交的 Client-SDO。
1 | flowchart TD |
6.2 所有 SDO API 最终都经过同一闸门
SubmitRead()、SubmitWrite()、block 传输、future 版本以及 DCF 下载接口,虽然模板重载很多,主干基本一致:
1 | 获取 Master 锁 |
因此,看到 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() 执行三件事:
- 用该
co_csdo_t*构造一个 C++Sdo包装对象并放入sdos[id]; - 将
config[id - 1]设为true; - 调用
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]
1 | sequenceDiagram |
7.3 done 是继续 NMT 状态机的必要条件
官方 API 说明,NMT boot slave 会停在 update configuration 阶段,直到应用通过 ConfigResult() 报告结果;Driver 层对应的就是 OnConfig(done) 中的完成函数。[S4]
因此 OnConfig() 的正确语义是:
1 | 执行设备相关配置 |
它不是普通通知回调。若遗漏完成函数,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 状态机继续运行。
所以这不是“配置线程结束”这么简单,而是一次明确的资源交还:
1 | NMT master 持有 CSDO |
8. BasicMaster 与 AsyncMaster 的执行时序差异
8.1 BasicMaster:同步调用,但先释放锁
以 OnCanState() 为例,BasicMaster 遍历 Driver,并用 UnlockGuard 暂时释放 Master 锁后调用 Driver::OnCanState()。单节点事件则先查注册表,再以相同方式调用对应 Driver。
这解决两个问题:
- Driver 回调可以再次调用需要 Master 锁的 API,不会直接自锁;
- 用户代码的执行时间不会把 Master 锁长期占住。
但回调仍发生在当前协议事件处理调用链中。若 Driver 执行阻塞操作,当前事件循环仍可能被拖延。
8.2 AsyncMaster:只负责投递,Driver 稍后执行
AsyncMaster 的覆盖函数通常执行:
1 | 找到 Driver |
例如:
- 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]
1 | sequenceDiagram |
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) 在非配置阶段执行:
1 | ready[id] = false |
因为新的 boot-up 表示远端通信状态已重置,之前的“已完成 boot”结论以及应用 SDO 会话都不能继续沿用;NMT master 也可能马上重新进入 boot slave 流程并接管 Client-SDO。[S2]
1 | stateDiagram-v2 |
这张图描述的是 BasicMaster 暴露的应用状态,不是 CiA 302 内部 boot state machine 的完整替代。
10. AsyncDeconfig():与 boot 相反的 Driver 生命周期入口
AsyncDeconfig(id) 查找对应 Driver,随后由 Impl_::AsyncDeconfig():
- 先把该节点标记为 not ready;
- 创建 promise/future;
- 向 Driver executor 投递
OnDeconfig(done); - Driver 完成后设置 promise;
- 调用者通过 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 锁。这样能维持两层同步关系:
1 | Master 状态与对象访问同步 |
典型意图是把多个 TpdoMapped() 写入放入同一个临界区,避免更新到一半时触发 TPDO:
1 | { |
是否立即产生总线帧仍取决于 TPDO transmission type、event timer、SYNC 和 mapping 配置;mutex 只负责推迟事件触发,不改变 PDO 通信参数。[S1][S2]
12. 把完整流程串起来
下面以一个 AsyncMaster + Driver 管理 node 2 的典型启动过程为例:
1 | sequenceDiagram |
从这个流程可以得到三个工程结论:
OnState(BOOTUP)不是执行普通应用 SDO 的稳定窗口,因为 NMT boot 可能已经准备接管默认 Client-SDO。OnConfig()是 boot 期间由 NMT 明确授权给 Driver 的 SDO 窗口,必须以完成回调结束。OnBoot()表示 boot slave 流程完成,但对依赖 PDO 的业务逻辑,仍应结合远端 NMT state 判断设备是否真正进入可运行阶段。
13. 条件编译决定哪些路径真实存在
master.hpp/master.cpp 中的重要能力受编译配置控制:
| 条件 | 影响 |
|---|---|
LELY_NO_COAPP_MASTER |
整个 C++ Master 实现不编译 |
LELY_NO_CO_DCF |
文本 DCF 构造路径不可用 |
LELY_NO_CO_SDEV |
静态设备描述构造路径不可用 |
LELY_NO_CO_NMT_BOOT |
Boot() 与 boot indication 相关路径不可用 |
LELY_NO_CO_NMT_CFG |
update configuration、config[] 和配置 CSDO 交接不可用 |
LELY_NO_CO_NG |
node guarding indication 不注册 |
因此,对具体构建产物分析时,不能只看头文件里是否存在某个 API,还要确认目标库的 feature macros。本文描述的是这些功能启用时的主流程。[S1][S2]
14. 如何理解 BasicMaster、AsyncMaster 与 Driver 的边界
可以把三者压缩成下面的职责模型:
| 对象 | 负责什么 | 不负责什么 |
|---|---|---|
Node |
本地 CANopen 节点、对象字典与协议服务 | 不表达多个远端设备的业务差异 |
BasicMaster |
NMT 主站编排、Driver 注册、ready/config、SDO 仲裁、同步事件分发 | 不为每种从站实现设备业务逻辑 |
AsyncMaster |
在 BasicMaster 基础上把 Driver 事件投递到 executor |
不自动创建线程,也不改变 CANopen 协议语义 |
DriverBase/派生 Driver |
单个远端节点的配置、状态处理和业务行为 | 不管理全网 NMT 主状态机 |
Sdo |
一条 Client-SDO 队列及传输请求 | 不决定自己何时可与 NMT boot 并发使用 |
最终可以用一句话概括 coapp::Master:
它是本地 CANopen Node 与远端设备 Driver 之间的协调层,以 NMT 生命周期为主轴,对 Client-SDO 所有权、节点 ready/config 状态和协议事件执行上下文进行统一管理。
参考资料
- [S1] Lely core development Doxygen:
master.hppsource - [S2] Lely core development Doxygen:
master.cppsource - [S3] Lely CANopen C++ tutorial
- [S4] Lely Doxygen:
lely::canopen::BasicMasterclass reference - [S5] Lely issue #76: SDO availability during early initialization and boot
- [S6] Lely issue #92: relationship between
OnBoot(), Operational state and PDO events










