Lely CANopen co_nmt_t:主从 NMT 总控机制、状态机与运行流程
@[toc]
co_nmt_t 不能只理解成“接收 CAN-ID 0x000 后切换 NMT 状态”的对象。Lely 官方库概览直接把它定位为 CANopen 设备的核心服务管理器:它维护本节点 NMT 状态,并按状态创建、启动、停止或销毁 SDO、PDO、SYNC、TIME、EMCY、LSS 等服务。新建对象初始停留在 Initialisation,不立即通信,应用可以先安装回调,再通过 Reset Node 启动完整 boot-up 链路。[1]
结合 src/co/nmt.c、include/lely/co/nmt.h 与 include/lely/co/nmt.hpp,可以把它概括为:
1 | co_nmt_t |
1. co_nmt_t 在 Lely 全栈中的位置
下面这张图重点观察两类关系:谁向 co_nmt_t 输入事件,以及 co_nmt_t 具体管理哪些对象。
1 | flowchart TB |
这体现了 Lely CANopen 的被动架构:co_nmt_t 不创建线程、不读 SocketCAN、不直接读取操作系统时钟。外部 I/O 桥接层把 CAN 帧和时间推进交给 can_net_t,can_net_t 再调用 NMT 注册的 receiver/timer 回调。
co_nmt_t 同时依赖 co_dev_t。对象字典不是只在初始化时读取一次:0x100C、0x100D、0x1016、0x1017、0x1F25、0x1F80、0x1F82 的写操作会进入 NMT 安装的 download indication,从而实时改变内部 receiver、timer、角色或流程。
2. struct __co_nmt 如何划分职责
struct __co_nmt 字段很多,但可以按职责分成五组。
2.1 核心状态与配置快照
| 字段 | 作用 |
|---|---|
net |
CAN 逻辑网络,提供 receiver、timer 和发送入口 |
dev |
本地设备对象字典 |
id |
待在 Reset Communication 阶段应用的 Node-ID |
dcf_node |
应用参数 0x2000..0x9FFF 的 concise DCF 快照 |
dcf_comm |
通信参数 0x1000..0x1FFF 的 concise DCF 快照 |
state |
当前内部状态对象指针 |
startup |
对象 0x1F80 NMT startup 缓存 |
master |
当前是否以 NMT master 运行 |
reset |
当前启动流程是否由 Reset Communication 触发 |
dcf_node 和 dcf_comm 是复位语义的关键。Lely 在 NMT 对象初始化时先把对象字典当前值写成两份内存 concise DCF;后续 Reset Node/Reset Communication 再回放相应范围,而不是重新解析外部 EDS/DCF 文件。
2.2 本地服务生命周期
struct co_nmt_srv srv 持有或管理本地 CANopen 服务,例如:
1 | SSDO / CSDO |
NMT 状态进入函数只决定目标服务掩码,具体对象的创建、启动、停止和销毁委托给 co_nmt_srv_set()。
源码定义的服务集合是:
1 |
这比“Operational 才能通信”更精确:
| NMT 状态 | 本地服务集合 |
|---|---|
| Reset Application / Reset Communication | 全部关闭;Reset Communication 后临时只启用 LSS |
| Pre-operational | LSS、SDO、SYNC、TIME、EMCY;不启用 PDO |
| Operational | Pre-operational 全部服务,再加 PDO |
| Stopped | 只保留 LSS |
2.3 NMT command 与错误控制
| 字段 | 作用 |
|---|---|
recv_000 |
接收 CAN-ID 0x000 的 NMT command |
recv_700 |
本节点侧 Node Guarding RTR receiver;master 的远端 boot-up/guarding 使用每个 slave->recv,两者共用 co_nmt_recv_700() 回调 |
ec_timer |
Heartbeat producer 或 Life Guarding 定时器 |
st |
当前 NMT 状态,最高位还可携带 toggle bit |
gt / ltf |
Guard time 与 Life time factor |
ms |
Producer heartbeat time |
hbs / nhb |
每个 0x1016 子项对应的 Heartbeat consumer |
Heartbeat producer 和 Life Guarding 共用 ec_timer。当 0x1017 非零时,Heartbeat producer 优先,Life Guarding 被抑制。
2.4 主站侧远端节点管理
启用 master 功能时,co_nmt_t 内含 slaves[CO_NUM_NODES]。每个元素记录:
0x1F81assignment;- 期望状态
est与接收状态rst; - boot-up 是否收到;
- Boot Slave 是否进行/完成及错误字母;
- Configuration Request 是否进行;
- Node Guarding 周期、生命因子和超时计数;
- 本节点对应的 receiver、timer、boot/cfg 子对象。
这说明 co_nmt_t 既是本节点状态机,也是主站网络级状态表。
2.5 回调与事件桥接
NMT 对象保存 command、heartbeat、guarding、state、boot、configuration、SDO progress、SYNC 等回调。其默认回调通常再调用 co_nmt_on_*(),形成:
1 | 底层协议事件 |
此外,co_nmt_t 把 co_dev_t 的 TPDO event indication 接到 co_tpdo_event();锁定期间用 bitmask 合并重复事件,解锁时再逐个触发,避免对象批量更新期间立即发送多个不完整 TPDO。
3. 状态机不是 switch 大循环,而是状态对象表
nmt.c 用不透明的 co_nmt_state_t 表示状态,每个状态对象包含:
1 | struct __co_nmt_state { |
不同构建配置下,on_boot 可被条件编译移除。状态对象包括:
1 | initializing |
3.1 co_nmt_enter() 支持连续自动迁移
核心代码等价于:
1 | while (next) { |
因此,一个 entry handler 可以直接返回下一个状态。外部只触发一次 Reset Node,内部可连续执行:
1 | Reset Application |
这不是线程里的轮询循环,而是在同一调用栈内按 entry handler 返回值连续推进。只有遇到必须等待外部异步事件的步骤,例如 LSS 完成或 mandatory slave boot 完成,entry handler 才返回 NULL 停住;后续由 co_nmt_lss_con() 或 co_nmt_boot_con() 重新推进。
3.2 状态关系
1 | stateDiagram-v2 |
内部的 Reset Application 和 Reset Communication 状态值分别是 0x06、0x07。它们是状态机内部子状态,不是 Heartbeat 正常周期广播的运行状态。
4. Reset Node 到 Operational 的完整运行链
4.1 NMT 对象创建后为什么不立即启动
__co_nmt_init() 会完成资源装配:
- 保存
net和dev; - 记录待应用 Node-ID;
- 生成应用参数和通信参数 concise DCF 快照;
- 初始化
co_nmt_srv; - 创建
recv_000、recv_700、ec_timer; - master 构建下创建 NMT command buffer、inhibit timer 和每个远端节点的 receiver/timer;
- 安装对象字典 download indication;
- 安装 TPDO/SAM-MPDO event indication;
- 进入
initializing。
此时不会自动创建 SDO/PDO 等服务,也不会发送 boot-up。只有收到或本地提交 Reset Node,co_nmt_init_on_cs() 才返回 reset application。
这样设计使应用可以在网络通信开始前先安装所有回调。
4.2 Reset Application
进入 co_nmt_reset_node_on_enter() 后执行:
1 | 停止主站从节点管理 |
Reset Node 不只是 MCU 复位的抽象命令。在当前 Lely 对象内,它明确恢复初始化时保存的应用参数快照,然后继续做通信复位。
4.3 Reset Communication
进入 co_nmt_reset_comm_on_enter() 后执行:
1 | 再次确保 slave management、本地服务、Heartbeat、错误控制均关闭 |
Node-ID 修改没有立即改写运行中的所有服务,而是在 Reset Communication 阶段集中应用。这样可避免 receiver、COB-ID、对象字典相对 Node-ID 值和服务实例处于半更新状态。
4.4 Boot-up
co_nmt_bootup_on_enter() 首先检查 Node-ID:
- Node-ID 为
0xFF:认为尚未配置,停留在当前阶段,不进入 Pre-operational; - Node-ID 有效:初始化错误控制和 Heartbeat consumer,发送一帧 boot-up,再返回 Pre-operational。
Boot-up 帧:
1 | CAN-ID = 0x700 + Node-ID |
4.5 Pre-operational 与 startup
进入 Pre-operational 后:
- 启用除 PDO 外的服务;
- 更新本节点状态为
0x7F; - 触发 state/command indication;
- 进入
co_nmt_startup()。
若当前为 slave,startup 只根据 reset 与 0x1F80 bit2 判断是否自动进入 Operational。
若当前为 master,startup 还会初始化远端从节点表、发送 Reset Communication、启动 Boot Slave 流程,并等待 mandatory slave 完成。
4.6 完整时序
1 | sequenceDiagram |
5. NMT command 的接收与发送不是对称实现
5.1 从站接收:co_nmt_recv_000()
Reset Communication 阶段,只有 slave 角色启动 recv_000,监听 CAN-ID 0x000。收到报文后会检查:
- 帧是否为预期格式;
- DLC 是否至少为 2;
data[1]是否为0或本节点 Node-ID;- command specifier 是否受支持。
有效命令进入 co_nmt_cs_ind(),再由当前状态的 on_cs() 决定下一状态。
处理顺序为:
1 | CAN frame |
nmt.h 明确说明,command indication 在状态切换之后调用,而且回调仍处于 NMT 状态迁移内部;不能在该回调中再次对本节点调用 co_nmt_cs_ind() 或 co_nmt_cs_req(),否则会形成重入状态迁移。
5.2 主站发送:co_nmt_cs_req()
只有 master 为真时才能调用:
1 | co_nmt_cs_req(nmt, command, target_id); |
处理分为两条路径:
- 目标是本节点:不发 CAN 帧,直接调用
co_nmt_cs_ind(); - 目标是远端或广播:生成
0x000、2 字节 NMT 帧,写入can_buf,再由cs_timer发送。
主站发送不是每次调用都立即 can_net_send()。对象 0x102A NMT inhibit time 用于限制相邻 NMT command 的最小间隔,co_nmt_cs_timer() 会:
- 查看队首帧;
- 若尚未到
inhibit截止时间,则重新启动定时器; - 到期后发送;
- 更新目标从站的期望状态
est; - 计算下一次允许发送的时间;
- 继续处理缓冲区。
1 | flowchart LR |
这也解释了为什么连续调用多个 NMT request 时,函数返回成功不等于所有帧已经物理发送完成;它只表示请求已被接受并进入内部发送链路。
6. Heartbeat、Node Guarding 与 Life Guarding 如何汇入 NMT
6.1 Producer 和 Life Guarding 共用 ec_timer
co_nmt_ec_update() 根据三个配置值决定模式:
1 | 0x1017 != 0 |
Heartbeat production 优先于 Life Guarding。master 角色还会禁止自身 Life Guarding。
co_nmt_ec_timer() 到期时:
- Heartbeat 模式:发送当前状态,去掉 toggle bit;
- Life Guarding 模式:报告 life guarding error。
6.2 Heartbeat consumer 是一项配置一个对象
co_nmt_hb_init() 读取 0x1016:00 的子项数量,为每个子项创建 co_nmt_hb_t,再解析:
1 | bits 16..23 = producer Node-ID |
每个 co_nmt_hb_t 独立注册 receiver 和 timer。它检测到 timeout 或 state change 后回调 co_nmt_hb_ind();NMT 再调用用户 heartbeat indication,并在状态变化时继续发出统一 state indication。
动态写 0x1016 时,co_1016_dn_ind() 还会:
- 禁止写 sub-index 0;
- 检查 sub-index 范围;
- 禁止两个启用的 consumer 使用相同 Node-ID;
- boot/config 期间临时把实际监控时间设为 0;
- 更新对象字典后立即重配对应
co_nmt_hb_t。
6.3 主站的每节点 receiver 共用 co_nmt_recv_700() 处理 boot-up 和 guarding
主站接收 0x700 + id 后:
data[0] == 0x00:记录 boot-up,预期下一状态为 Pre-operational,并通知 state callback;- boot/config 进行中:忽略普通 guarding 状态处理;
- Node Guarding 启用:校验 toggle bit、清零未响应 RTR 次数、检测 timeout 恢复与异常状态变化。
默认 heartbeat/node guarding handler 会进一步调用 co_nmt_node_err_ind()。对 mandatory slave,0x1F80 的策略位可触发全网 Stop、全网 Reset Node,或只复位故障节点。
7. 对象字典不是静态配置文件,而是运行期控制面
NMT 初始化时会把 download indication 安装到以下对象:
| 对象 | 运行期作用 |
|---|---|
0x100C |
更新 Guard time,并重算错误控制模式 |
0x100D |
更新 Life time factor,并重算错误控制模式 |
0x1016 |
重配某个 Heartbeat consumer |
0x1017 |
更新 Producer heartbeat time |
0x1F25 |
发起 Configuration Request |
0x1F80 |
更新 NMT startup 策略缓存 |
0x1F82 |
将对象写操作转换为 NMT command request |
这些 callback 的共同模式是:
1 | 解析 co_sdo_req |
因此,对象字典写入并非简单内存赋值。通过普通 SDO 下载这些对象时,会立即影响 NMT 运行状态;通过绕过 indication 的直接 setter 修改值,则可能只改变 OD 存储,不会同步重配 NMT 内部资源。
7.1 0x1F82 Request NMT
master 侧写 0x1F82:<node-id> 可以转换成相应 NMT request。对象字典因此成为 NMT 控制面的一个标准化入口,而不是额外复制一套状态。
7.2 0x1F25 Configuration request
向对应子项写入特定控制值后,NMT 校验节点是否在 0x1F81 网络列表中,并检查 0x1F20/0x1F22 是否提供配置来源,再启动 co_nmt_cfg_req()。
在配置期间,对应 Heartbeat consumer 被临时禁用;流程完成后再按 0x1016 原值恢复,以避免从站在 reset、下载 DCF 或重启过程中被误判 timeout。
8. 主站 startup 是网络级编排,不只是本节点进 Operational
0x1F80 NMT startup 决定主站角色和自动启动策略,0x1F81 为每个从站提供 assignment。进入 Pre-operational 后,master 执行:
1 | co_nmt_slaves_init |
co_nmt_boot_t 负责单节点身份检查、SDO 访问、配置、程序下载和错误控制启动;co_nmt_t 负责:
- 何时创建它;
- 哪些节点必须等待;
- 是否因 mandatory slave 失败而暂停 startup;
- boot 完成后是否发送 Start;
- 是否进入本节点 Operational;
- 如何向应用报告结果。
co_nmt_preop_on_boot() 只在所有 mandatory slave 不再 boot 且没有 halt 时继续 co_nmt_startup_slave()。这意味着 optional slave 未完成不一定阻塞主站进入下一阶段,最终行为由 assignment 和 startup bit 共同决定。
9. SYNC、EMCY 与 PDO 为什么也由 NMT 暴露入口
9.1 SYNC:先 TPDO,后 RPDO
co_nmt_on_sync() 的顺序是:
1 | 遍历所有 TPDO,调用 co_tpdo_sync |
源码注释给出的原因是:如果同一对象同时映射到同步 TPDO 和 RPDO,应先发送上一同步窗口的值,再用当前收到的 RPDO 更新对象,避免时序竞争。
所以应用层收到 NMT 的 SYNC callback 时,同步 PDO 已经处理完成。
9.2 EMCY:NMT 负责把通信错误连接到状态策略
co_nmt_on_err() 先把错误压入 EMCY 服务;若错误码属于 0x81xx 通信错误,再调用 co_nmt_comm_err_ind(),读取 0x1029:01 决定:
- 保持当前状态;
- Operational 降到 Pre-operational;
- 进入 Stopped。
这说明 EMCY 上报与 NMT 状态策略是两层:EMCY 记录/发送错误,NMT 根据 Error behavior 对象决定通信状态变化。
9.3 co_nmt_on_tpdo_event_lock() / unlock() 的实际调用链
官方在 v2.1.0 的 TPDO event tracking 说明中明确指出:应用可以在批量修改对象字典时暂缓 TPDO event;这个“锁”允许嵌套,只有最后一次解锁才真正触发累计的 TPDO。[9]
这套机制不是从 co_nmt_on_tpdo_event_lock() 单独开始的。完整链路包含“事件桥注册、应用加锁、对象事件进入 NMT、最后一次解锁统一释放”四个阶段。
9.3.1 NMT 初始化时先接管对象字典的 TPDO event
__co_nmt_init() 会把 NMT 的静态回调安装到 co_dev_t:
1 | co_dev_set_tpdo_event_ind(nmt->dev, &co_nmt_tpdo_event_ind, nmt); |
之后应用调用 co_dev_tpdo_event(dev, idx, subidx),或 C++ Device::TpdoWriteEvent() / WriteEvent() 时,co_dev_t 会检查该子对象的 TPDO 映射,并对命中的 PDO 编号调用已注册的 indication:
1 | 对象字典发生 TPDO event |
因此,co_nmt_on_tpdo_event() 是对象字典事件进入 NMT/TPDO 服务的统一入口,而 lock/unlock 控制的是这个入口是否立即向下调用 co_tpdo_event()。
9.3.2 谁会调用 lock/unlock
C 接口可以由应用直接成对调用:
1 | co_nmt_on_tpdo_event_lock(nmt); |
nmt.hpp 的 CONMT 只是直接转发:
1 | CONMT::onTPDOEventLock() |
在更高层的 coapp 中,Node::TpdoEventMutex 把这两个 C 接口包装成 BasicLockable:
1 | Node::TpdoEventMutex::lock() |
Node 暴露受保护成员 tpdo_event_mutex,供派生节点、主站或驱动在批量调用 TpdoWriteEvent() / WriteEvent() 时使用。BasicMaster::TpdoEventMutex 还会先取得节点本身的 BasicLockable,再进入 Node::TpdoEventMutex,把 coapp 的线程串行化与 NMT 的 TPDO event 延期机制组合起来。[10][11][12]
这里必须区分两层“锁”:
Node/BasicMaster的BasicLockable负责访问串行化;co_nmt_on_tpdo_event_lock()只增加延期计数,不是线程互斥原语。
9.3.3 加锁期间如何积累事件
co_nmt_on_tpdo_event_lock() 的实现只有一个核心动作:
1 | nmt->tpdo_event_wait++; |
当 co_nmt_on_tpdo_event() 收到 PDO 编号时:
1 | tpdo_event_wait == 0 |
tpdo_event_mask 每个 TPDO 只占一个 bit。因此,同一 TPDO 在锁定期间被重复触发多次,会合并成一个待处理事件;不同 TPDO 则分别保留自己的 bit。参数 n == 0 表示对当前全部 TPDO 执行同样处理。
9.3.4 最后一次 unlock 才释放延期事件
co_nmt_on_tpdo_event_unlock() 先递减计数:
1 | if (--nmt->tpdo_event_wait) |
只要仍有外层锁,函数立即返回。计数降到 0 后,它才遍历 tpdo_event_mask,对每个置位且仍然存在的 TPDO 调用一次 co_tpdo_event(),随后清除对应 bit。
1 | flowchart TD |
完整调用关系可压缩为:
1 | 应用或 coapp RAII lock |
它解决的核心问题不是“防止多个线程同时写对象字典”,而是避免一次逻辑事务中多个对象逐项更新时过早发送 TPDO,使同一个或多个事件驱动 TPDO 在批量更新结束后再统一进入发送流程。
10. C++ CONMT 的接口设计与生命周期边界
CONMT 继承 incomplete_c_type<__co_nmt>,构造函数接收 CANNet* 和 CODev*。其 C++ 方法基本是一对一转发:
| C++ 方法 | C API |
|---|---|
getNet() |
co_nmt_get_net() |
getSt() |
co_nmt_get_st() |
csReq() |
co_nmt_cs_req() |
bootReq() |
co_nmt_boot_req() |
cfgReq() |
co_nmt_cfg_req() |
ngReq() |
co_nmt_ng_req() |
onSync() |
co_nmt_on_sync() |
onErr() |
co_nmt_on_err() |
onTPDOEvent() |
co_nmt_on_tpdo_event() |
onTPDOEventLock() |
co_nmt_on_tpdo_event_lock() |
onTPDOEventUnlock() |
co_nmt_on_tpdo_event_unlock() |
每类 callback 都提供三种注册方式:
- 原始 C 函数指针和
void*; - callable 对象指针;
- 指定成员函数的对象指针。
这种包装不复制 callable,也不拥有用户对象。传入的对象地址必须在 callback 可能发生期间保持有效。
另外,CONMT 持有 NMT C 对象,但 CANNet 与 CODev 只以指针传入。它们的生命周期必须覆盖 CONMT,否则 co_nmt_t 内部保存的 net/dev 会悬空。
11. 三条典型调用链
11.1 从站收到 Start Remote Node
1 | CAN-ID 0x000, data = 01 <local-id> |
11.2 应用修改 Producer heartbeat time
1 | SDO download 0x1017:00 |
11.3 主站检测 mandatory slave Heartbeat timeout
1 | co_nmt_hb_t timeout |
这三条链分别展示了 co_nmt_t 的三种角色:本节点状态机、对象字典控制面、主站网络策略执行器。
12. 最终心智模型
阅读 nmt.c 时,应始终把以下边界保持清楚:
1 | can_net_t |
最关键的一点是:
Lely 的
co_nmt_t是 CANopen 节点运行期的总控状态机。NMT command 只是触发源之一;DCF 恢复、对象字典动态写入、Heartbeat/Guarding、通信错误、SYNC/PDO 事件和主站从节点启动结果,都会进入这一总控对象,再由它决定状态、服务集合和网络动作。
参考资料
- Lely CANopen, Library overview: https://opensource.lely.com/canopen/docs/overview/
- Lely CANopen official site, latest release information: https://opensource.lely.com/canopen/
- Lely CANopen v2.3.5 release note: https://opensource.lely.com/canopen/release/v2.3.5/
- Lely official GitLab repository: https://gitlab.com/lely_industries/lely-core
src/co/nmt.c, commit620d1858eb8520dbc3dc5e1a7314565becd54199: https://github.com/lely-industries/lely-core/blob/620d1858eb8520dbc3dc5e1a7314565becd54199/src/co/nmt.cinclude/lely/co/nmt.h, same commit: https://github.com/lely-industries/lely-core/blob/620d1858eb8520dbc3dc5e1a7314565becd54199/include/lely/co/nmt.hinclude/lely/co/nmt.hpp, same commit: https://github.com/lely-industries/lely-core/blob/620d1858eb8520dbc3dc5e1a7314565becd54199/include/lely/co/nmt.hpp- CiA 301 V4.2.0, NMT and error control sections.
- Lely CANopen v2.1.0 release note, TPDO event tracking: https://opensource.lely.com/canopen/release/v2.1.0/
src/coapp/node.cpp, same commit: https://github.com/lely-industries/lely-core/blob/620d1858eb8520dbc3dc5e1a7314565becd54199/src/coapp/node.cppinclude/lely/coapp/node.hpp, same commit: https://github.com/lely-industries/lely-core/blob/620d1858eb8520dbc3dc5e1a7314565becd54199/include/lely/coapp/node.hppsrc/coapp/master.cpp, same commit: https://github.com/lely-industries/lely-core/blob/620d1858eb8520dbc3dc5e1a7314565becd54199/src/coapp/master.cpp









