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.cinclude/lely/co/nmt.hinclude/lely/co/nmt.hpp,可以把它概括为:

1
2
3
4
5
6
7
8
9
co_nmt_t
= 本节点 NMT 有限状态机
+ 本地 CANopen 服务生命周期管理
+ NMT 命令收发与发送抑制
+ Heartbeat / Node Guarding / Life Guarding 总控
+ 主站从节点状态表与启动策略
+ 对象字典动态配置入口
+ Boot Slave / Configuration Request 编排入口
+ SYNC、EMCY、TPDO 事件的统一转接点

1. co_nmt_t 在 Lely 全栈中的位置

下面这张图重点观察两类关系:谁向 co_nmt_t 输入事件,以及 co_nmt_t 具体管理哪些对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
flowchart TB
App["应用层或 coapp Node"] --> Nmt["co_nmt_t<br/>NMT 总控对象"]
Dev["co_dev_t<br/>对象字典与 DCF"] --> Nmt
Net["can_net_t<br/>CAN receiver 与逻辑 timer"] --> Nmt

Nmt --> Fsm["NMT 状态机"]
Nmt --> Srv["co_nmt_srv<br/>本地服务管理器"]
Nmt --> Ec["Heartbeat 与 Guarding"]
Nmt --> Slaves["slaves 1..127<br/>主站远端节点状态"]
Nmt --> Boot["co_nmt_boot_t<br/>单从站启动编排"]
Nmt --> Cfg["co_nmt_cfg_t<br/>远端配置编排"]

Srv --> Ssdo["Server-SDO"]
Srv --> Csdo["Client-SDO"]
Srv --> Pdo["RPDO 与 TPDO"]
Srv --> Aux["SYNC TIME EMCY LSS"]

Net --> Rx0["CAN-ID 0x000 NMT command"]
Net --> Rx7["CAN-ID 0x700 + Node-ID"]
Net --> Timers["CAN logical timers"]
Rx0 --> Nmt
Rx7 --> Nmt
Timers --> Nmt

这体现了 Lely CANopen 的被动架构:co_nmt_t 不创建线程、不读 SocketCAN、不直接读取操作系统时钟。外部 I/O 桥接层把 CAN 帧和时间推进交给 can_net_tcan_net_t 再调用 NMT 注册的 receiver/timer 回调。

co_nmt_t 同时依赖 co_dev_t。对象字典不是只在初始化时读取一次:0x100C0x100D0x10160x10170x1F250x1F800x1F82 的写操作会进入 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_nodedcf_comm 是复位语义的关键。Lely 在 NMT 对象初始化时先把对象字典当前值写成两份内存 concise DCF;后续 Reset Node/Reset Communication 再回放相应范围,而不是重新解析外部 EDS/DCF 文件。

2.2 本地服务生命周期

struct co_nmt_srv srv 持有或管理本地 CANopen 服务,例如:

1
2
3
4
SSDO / CSDO
RPDO / TPDO
SYNC / TIME
EMCY / LSS

NMT 状态进入函数只决定目标服务掩码,具体对象的创建、启动、停止和销毁委托给 co_nmt_srv_set()

源码定义的服务集合是:

1
2
3
4
5
6
7
8
#define CO_NMT_STOP_SRV  CO_NMT_SRV_LSS

#define CO_NMT_PREOP_SRV \
(CO_NMT_STOP_SRV | CO_NMT_SRV_SDO | CO_NMT_SRV_SYNC \
| CO_NMT_SRV_TIME | CO_NMT_SRV_EMCY)

#define CO_NMT_START_SRV \
(CO_NMT_PREOP_SRV | CO_NMT_SRV_PDO)

这比“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]。每个元素记录:

  • 0x1F81 assignment;
  • 期望状态 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
2
3
4
5
底层协议事件
-> indication callback
-> 默认 handler
-> co_nmt_on_* 策略函数
-> EMCY / reset node / 状态迁移 / 用户回调

此外,co_nmt_tco_dev_t 的 TPDO event indication 接到 co_tpdo_event();锁定期间用 bitmask 合并重复事件,解锁时再逐个触发,避免对象批量更新期间立即发送多个不完整 TPDO。


3. 状态机不是 switch 大循环,而是状态对象表

nmt.c 用不透明的 co_nmt_state_t 表示状态,每个状态对象包含:

1
2
3
4
5
6
7
struct __co_nmt_state {
co_nmt_state_t *(*on_enter)(co_nmt_t *nmt);
co_nmt_state_t *(*on_cs)(co_nmt_t *nmt, co_unsigned8_t cs);
co_nmt_state_t *(*on_boot)(co_nmt_t *nmt,
co_unsigned8_t id, co_unsigned8_t st, char es);
void (*on_leave)(co_nmt_t *nmt);
};

不同构建配置下,on_boot 可被条件编译移除。状态对象包括:

1
2
3
4
5
6
7
initializing
reset application
reset communication
boot-up
pre-operational
operational
stopped

3.1 co_nmt_enter() 支持连续自动迁移

核心代码等价于:

1
2
3
4
5
6
7
8
9
while (next) {
prev = nmt->state;
nmt->state = next;

if (prev && prev->on_leave)
prev->on_leave(nmt);

next = next->on_enter ? next->on_enter(nmt) : NULL;
}

因此,一个 entry handler 可以直接返回下一个状态。外部只触发一次 Reset Node,内部可连续执行:

1
2
3
4
5
Reset Application
-> Reset Communication
-> Boot-up
-> Pre-operational
-> 根据 0x1F80 决定是否自动 Operational

这不是线程里的轮询循环,而是在同一调用栈内按 entry handler 返回值连续推进。只有遇到必须等待外部异步事件的步骤,例如 LSS 完成或 mandatory slave boot 完成,entry handler 才返回 NULL 停住;后续由 co_nmt_lss_con()co_nmt_boot_con() 重新推进。

3.2 状态关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
stateDiagram-v2
[*] --> Initializing
Initializing --> ResetApplication: Reset Node
ResetApplication --> ResetCommunication: entry 自动返回
ResetCommunication --> Bootup: 无需等待 LSS
ResetCommunication --> ResetCommunication: Reset Communication
ResetCommunication --> ResetApplication: Reset Node
Bootup --> PreOperational: Node-ID 有效并发送 boot-up
PreOperational --> Operational: Start 或 startup 自动启动
PreOperational --> Stopped: Stop
Operational --> PreOperational: Enter Pre-operational
Operational --> Stopped: Stop
Stopped --> PreOperational: Enter Pre-operational
Stopped --> Operational: Start
PreOperational --> ResetApplication: Reset Node
Operational --> ResetApplication: Reset Node
Stopped --> ResetApplication: Reset Node
PreOperational --> ResetCommunication: Reset Communication
Operational --> ResetCommunication: Reset Communication
Stopped --> ResetCommunication: Reset Communication

内部的 Reset ApplicationReset Communication 状态值分别是 0x060x07。它们是状态机内部子状态,不是 Heartbeat 正常周期广播的运行状态。


4. Reset Node 到 Operational 的完整运行链

4.1 NMT 对象创建后为什么不立即启动

__co_nmt_init() 会完成资源装配:

  1. 保存 netdev
  2. 记录待应用 Node-ID;
  3. 生成应用参数和通信参数 concise DCF 快照;
  4. 初始化 co_nmt_srv
  5. 创建 recv_000recv_700ec_timer
  6. master 构建下创建 NMT command buffer、inhibit timer 和每个远端节点的 receiver/timer;
  7. 安装对象字典 download indication;
  8. 安装 TPDO/SAM-MPDO event indication;
  9. 进入 initializing

此时不会自动创建 SDO/PDO 等服务,也不会发送 boot-up。只有收到或本地提交 Reset Nodeco_nmt_init_on_cs() 才返回 reset application

这样设计使应用可以在网络通信开始前先安装所有回调。

4.2 Reset Application

进入 co_nmt_reset_node_on_enter() 后执行:

1
2
3
4
5
6
7
8
停止主站从节点管理
关闭所有本地 CANopen 服务
销毁 Heartbeat consumer
关闭错误控制
停止接收 NMT command
回放 dcf_node,恢复 0x2000..0x9FFF 应用参数
更新内部状态并通知回调
自动返回 Reset Communication

Reset Node 不只是 MCU 复位的抽象命令。在当前 Lely 对象内,它明确恢复初始化时保存的应用参数快照,然后继续做通信复位。

4.3 Reset Communication

进入 co_nmt_reset_comm_on_enter() 后执行:

1
2
3
4
5
6
7
8
9
10
11
再次确保 slave management、本地服务、Heartbeat、错误控制均关闭
停止 NMT command receiver
回放 dcf_comm,恢复 0x1000..0x1FFF 通信参数
若 pending Node-ID 改变:更新 co_dev_t 并重建通信 DCF 快照
读取 0x1F80
由 bit0 决定 master/slave 角色
设置 reset 标志
slave 角色重新监听 CAN-ID 0x000
临时只启用 LSS
若 master 配置了 LSS callback:等待 LSS completion
否则进入 Boot-up

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
2
3
CAN-ID = 0x700 + Node-ID
DLC = 1
Data0 = 0x00

4.5 Pre-operational 与 startup

进入 Pre-operational 后:

  1. 启用除 PDO 外的服务;
  2. 更新本节点状态为 0x7F
  3. 触发 state/command indication;
  4. 进入 co_nmt_startup()

若当前为 slave,startup 只根据 reset0x1F80 bit2 判断是否自动进入 Operational。

若当前为 master,startup 还会初始化远端从节点表、发送 Reset Communication、启动 Boot Slave 流程,并等待 mandatory slave 完成。

4.6 完整时序

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
27
sequenceDiagram
participant App as Application
participant Nmt as co_nmt_t
participant Dev as co_dev_t
participant Srv as co_nmt_srv
participant Net as can_net_t
participant Sub as Remote slaves

App->>Nmt: co_nmt_cs_ind Reset Node
Nmt->>Srv: disable all services
Nmt->>Dev: replay application DCF
Nmt->>Dev: replay communication DCF
Nmt->>Dev: read 0x1F80 and role
Nmt->>Srv: enable LSS only
Nmt->>Net: start error control and heartbeat consumers
Nmt->>Net: send boot-up 0x700 plus Node-ID
Nmt->>Srv: enable Pre-operational services

alt NMT master
Nmt->>Sub: Reset Communication
Nmt->>Sub: start Boot Slave workflows
Sub-->>Nmt: boot completion callbacks
Nmt->>Srv: enable PDO after startup policy passes
else NMT slave
Nmt->>Nmt: evaluate 0x1F80 auto-start policy
Nmt->>Srv: enable PDO when Operational
end

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
2
3
4
5
6
7
8
CAN frame
-> co_nmt_recv_000
-> co_nmt_cs_ind
-> co_nmt_emit_cs
-> current_state.on_cs
-> co_nmt_enter
-> state entry side effects
-> cs_ind callback

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() 会:

  1. 查看队首帧;
  2. 若尚未到 inhibit 截止时间,则重新启动定时器;
  3. 到期后发送;
  4. 更新目标从站的期望状态 est
  5. 计算下一次允许发送的时间;
  6. 继续处理缓冲区。
1
2
3
4
5
6
7
8
9
10
flowchart LR
Req["co_nmt_cs_req"] --> Local{"目标是本节点"}
Local -- "是" --> Ind["co_nmt_cs_ind<br/>直接状态迁移"]
Local -- "否" --> Frame["构造 CAN-ID 0x000 帧"]
Frame --> Buf["写入 can_buf"]
Buf --> Timer["co_nmt_cs_timer"]
Timer --> Check{"0x102A inhibit 已到期"}
Check -- "否" --> Wait["重新设置 CAN timer"]
Check -- "是" --> Send["can_net_send"]
Send --> Est["更新 slave expected state"]

这也解释了为什么连续调用多个 NMT request 时,函数返回成功不等于所有帧已经物理发送完成;它只表示请求已被接受并进入内部发送链路。


6. Heartbeat、Node Guarding 与 Life Guarding 如何汇入 NMT

6.1 Producer 和 Life Guarding 共用 ec_timer

co_nmt_ec_update() 根据三个配置值决定模式:

1
2
3
4
5
6
7
8
0x1017 != 0
-> Heartbeat producer

0x1017 == 0 且 0x100C * 0x100D != 0
-> Life Guarding

两者都为 0
-> 停止 ec_timer

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
2
bits 16..23 = producer Node-ID
bits 0..15 = consumer timeout ms

每个 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
2
3
4
5
6
解析 co_sdo_req
-> 校验类型、sub-index、值域和当前角色
-> 比较新旧值
-> 先更新内部缓存或流程状态
-> co_sub_dn 写入对象字典
-> 重配 receiver/timer/service 或发起异步流程

因此,对象字典写入并非简单内存赋值。通过普通 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
2
3
4
5
6
7
8
co_nmt_slaves_init
-> 读取每个 0x1F81 assignment
-> 为远端节点启动接收和状态管理
-> 按 keep-alive 策略发送 Reset Communication
-> co_nmt_slaves_boot
-> 每个节点创建 co_nmt_boot_t
-> 等待 mandatory slaves
-> 成功后按 0x1F80 策略启动从站和本节点

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
2
3
遍历所有 TPDO,调用 co_tpdo_sync
遍历所有 RPDO,调用 co_rpdo_sync
最后调用用户 sync_ind

源码注释给出的原因是:如果同一对象同时映射到同步 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
2
3
4
5
6
对象字典发生 TPDO event
-> co_dev_tpdo_event(dev, idx, subidx)
-> 查找映射了该对象的 TPDO
-> dev->tpdo_event_ind(pdo_number, data)
-> co_nmt_tpdo_event_ind(pdo_number, nmt)
-> co_nmt_on_tpdo_event(nmt, pdo_number)

因此,co_nmt_on_tpdo_event() 是对象字典事件进入 NMT/TPDO 服务的统一入口,而 lock/unlock 控制的是这个入口是否立即向下调用 co_tpdo_event()

9.3.2 谁会调用 lock/unlock

C 接口可以由应用直接成对调用:

1
2
3
co_nmt_on_tpdo_event_lock(nmt);
/* 连续更新多个会触发 TPDO event 的对象 */
co_nmt_on_tpdo_event_unlock(nmt);

nmt.hppCONMT 只是直接转发:

1
2
3
4
5
CONMT::onTPDOEventLock()
-> co_nmt_on_tpdo_event_lock(this)

CONMT::onTPDOEventUnlock()
-> co_nmt_on_tpdo_event_unlock(this)

在更高层的 coapp 中,Node::TpdoEventMutex 把这两个 C 接口包装成 BasicLockable

1
2
3
4
5
Node::TpdoEventMutex::lock()
-> co_nmt_on_tpdo_event_lock(node->nmt())

Node::TpdoEventMutex::unlock()
-> co_nmt_on_tpdo_event_unlock(node->nmt())

Node 暴露受保护成员 tpdo_event_mutex,供派生节点、主站或驱动在批量调用 TpdoWriteEvent() / WriteEvent() 时使用。BasicMaster::TpdoEventMutex 还会先取得节点本身的 BasicLockable,再进入 Node::TpdoEventMutex,把 coapp 的线程串行化与 NMT 的 TPDO event 延期机制组合起来。[10][11][12]

这里必须区分两层“锁”:

  • Node / BasicMasterBasicLockable 负责访问串行化;
  • 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
2
3
4
5
6
tpdo_event_wait == 0
-> 立即调用 co_tpdo_event(pdo)

tpdo_event_wait > 0
-> 在 tpdo_event_mask 中置位
-> 暂不调用 co_tpdo_event(pdo)

tpdo_event_mask 每个 TPDO 只占一个 bit。因此,同一 TPDO 在锁定期间被重复触发多次,会合并成一个待处理事件;不同 TPDO 则分别保留自己的 bit。参数 n == 0 表示对当前全部 TPDO 执行同样处理。

9.3.4 最后一次 unlock 才释放延期事件

co_nmt_on_tpdo_event_unlock() 先递减计数:

1
2
if (--nmt->tpdo_event_wait)
return;

只要仍有外层锁,函数立即返回。计数降到 0 后,它才遍历 tpdo_event_mask,对每个置位且仍然存在的 TPDO 调用一次 co_tpdo_event(),随后清除对应 bit。

1
2
3
4
5
6
7
8
9
10
11
12
13
flowchart TD
A["应用进入批量更新"] --> B["lock: tpdo_event_wait 加一"]
B --> C["WriteEvent 或 TpdoWriteEvent"]
C --> D["co_dev_tpdo_event"]
D --> E["co_nmt_on_tpdo_event"]
E --> F{"tpdo_event_wait 是否大于零"}
F -- "是" --> G["在 tpdo_event_mask 中置位"]
F -- "否" --> H["立即调用 co_tpdo_event"]
G --> I["unlock: tpdo_event_wait 减一"]
I --> J{"计数是否归零"}
J -- "否" --> K["继续等待外层 unlock"]
J -- "是" --> L["遍历并清除 bitmask"]
L --> M["每个延期 TPDO 调用一次 co_tpdo_event"]

完整调用关系可压缩为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
应用或 coapp RAII lock
-> co_nmt_on_tpdo_event_lock
-> tpdo_event_wait++

对象写事件
-> co_dev_tpdo_event
-> co_nmt_tpdo_event_ind
-> co_nmt_on_tpdo_event
-> tpdo_event_mask 置位

应用或 coapp RAII unlock
-> co_nmt_on_tpdo_event_unlock
-> 最外层计数归零
-> 扫描 tpdo_event_mask
-> co_tpdo_event
-> TPDO inhibit time / event transmission 逻辑继续执行

它解决的核心问题不是“防止多个线程同时写对象字典”,而是避免一次逻辑事务中多个对象逐项更新时过早发送 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 都提供三种注册方式:

  1. 原始 C 函数指针和 void*
  2. callable 对象指针;
  3. 指定成员函数的对象指针。

这种包装不复制 callable,也不拥有用户对象。传入的对象地址必须在 callback 可能发生期间保持有效。

另外,CONMT 持有 NMT C 对象,但 CANNetCODev 只以指针传入。它们的生命周期必须覆盖 CONMT,否则 co_nmt_t 内部保存的 net/dev 会悬空。


11. 三条典型调用链

11.1 从站收到 Start Remote Node

1
2
3
4
5
6
7
8
9
10
11
CAN-ID 0x000, data = 01 <local-id>
-> co_nmt_recv_000
-> co_nmt_cs_ind(CO_NMT_CS_START)
-> current state on_cs
-> co_nmt_start_state
-> co_nmt_start_on_enter
-> co_nmt_srv_set(CO_NMT_START_SRV)
-> create/start PDO services
-> update local state to 0x05
-> state indication
-> command indication

11.2 应用修改 Producer heartbeat time

1
2
3
4
5
6
7
8
SDO download 0x1017:00
-> co_1017_dn_ind
-> parse and compare value
-> update nmt->ms
-> write OD value
-> co_nmt_ec_update
-> stop/restart ec_timer
-> subsequent timer expiry sends heartbeat

11.3 主站检测 mandatory slave Heartbeat timeout

1
2
3
4
5
6
7
8
9
co_nmt_hb_t timeout
-> co_nmt_hb_ind
-> user/default heartbeat handler
-> co_nmt_on_hb
-> co_nmt_node_err_ind
-> read 0x1F81 assignment and 0x1F80 policy
-> stop all / reset all / reset individual slave
-> co_nmt_cs_req buffers NMT command
-> inhibit timer sends CAN frame

这三条链分别展示了 co_nmt_t 的三种角色:本节点状态机、对象字典控制面、主站网络策略执行器。


12. 最终心智模型

阅读 nmt.c 时,应始终把以下边界保持清楚:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
can_net_t
= 提供 CAN frame 与逻辑时间事件

co_dev_t
= 保存对象字典、DCF 和配置值

co_nmt_t
= 依据事件与配置推进 NMT 状态和网络策略

co_nmt_srv
= 按目标服务掩码装配本地协议服务

co_nmt_hb
= 单个远端节点的 Heartbeat consumer

co_nmt_boot / co_nmt_cfg
= 单个远端节点的异步 boot/config 子流程

nmt.hpp
= C++ 所有权和 callback 适配,不是另一套协议实现

最关键的一点是:

Lely 的 co_nmt_t 是 CANopen 节点运行期的总控状态机。NMT command 只是触发源之一;DCF 恢复、对象字典动态写入、Heartbeat/Guarding、通信错误、SYNC/PDO 事件和主站从节点启动结果,都会进入这一总控对象,再由它决定状态、服务集合和网络动作。


参考资料

  1. Lely CANopen, Library overview: https://opensource.lely.com/canopen/docs/overview/
  2. Lely CANopen official site, latest release information: https://opensource.lely.com/canopen/
  3. Lely CANopen v2.3.5 release note: https://opensource.lely.com/canopen/release/v2.3.5/
  4. Lely official GitLab repository: https://gitlab.com/lely_industries/lely-core
  5. src/co/nmt.c, commit 620d1858eb8520dbc3dc5e1a7314565becd54199: https://github.com/lely-industries/lely-core/blob/620d1858eb8520dbc3dc5e1a7314565becd54199/src/co/nmt.c
  6. include/lely/co/nmt.h, same commit: https://github.com/lely-industries/lely-core/blob/620d1858eb8520dbc3dc5e1a7314565becd54199/include/lely/co/nmt.h
  7. include/lely/co/nmt.hpp, same commit: https://github.com/lely-industries/lely-core/blob/620d1858eb8520dbc3dc5e1a7314565becd54199/include/lely/co/nmt.hpp
  8. CiA 301 V4.2.0, NMT and error control sections.
  9. Lely CANopen v2.1.0 release note, TPDO event tracking: https://opensource.lely.com/canopen/release/v2.1.0/
  10. src/coapp/node.cpp, same commit: https://github.com/lely-industries/lely-core/blob/620d1858eb8520dbc3dc5e1a7314565becd54199/src/coapp/node.cpp
  11. include/lely/coapp/node.hpp, same commit: https://github.com/lely-industries/lely-core/blob/620d1858eb8520dbc3dc5e1a7314565becd54199/include/lely/coapp/node.hpp
  12. src/coapp/master.cpp, same commit: https://github.com/lely-industries/lely-core/blob/620d1858eb8520dbc3dc5e1a7314565becd54199/src/coapp/master.cpp