17. CANopenNode 对象字典与主循环
上一课的节点能够发送 Heartbeat,但代码里出现了几个还没有细讲的名字:OD、CO_t 和 CO_process()。
这一课不增加新硬件动作,只回答一个问题:CANopenNode 怎样把对象字典、通信状态和周期处理组织在一起?
先从普通 C 程序看
普通程序可以有一个变量:
// 普通变量只有本程序知道名称和地址;它没有 Index、类型和访问权限,
// 因此远程 CANopen 节点无法通过 SDO 找到它。
uint32_t counter = 0;
只有本程序的代码知道它。如果希望另一个 CANopen 节点通过 SDO 访问它,还需要给它安排对象地址、数据类型和访问权限,例如:
| 内容 | 示例 |
|---|---|
| Index | 0x2000 |
| Sub-index | 0x00 |
| 数据类型 | UNSIGNED32 |
| 权限 | 可读写 |
| 对应 C 变量 | OD_RAM.x2000_testCNT |
对象字典因此不只是“地址和值”。它还记录类型、权限、默认值、存放位置,以及是否允许映射到 PDO。
OD.h 和 OD.c 分别是什么
打开第 16 课工程:
# 进入课程统一使用的 ~/esp 工作目录,后续相对路径都从这里计算。
cd "$HOME/esp/can-canopen-course/examples/lab_16_canopennode_heartbeat"
rg "x1017|x2000" main/OD.h main/OD.c
两份文件的分工是:
| 文件 | 主要内容 |
|---|---|
OD.h | C 结构体、变量名称、对象索引宏和外部声明 |
OD.c | 对象条目、属性、数据长度、默认值和存储实例 |
应用代码通过有意义的 C 名称访问变量,例如:
// 这些名称由 OD.h 生成:应用用 C 成员名访问,远程节点仍使用 0x1017:00、0x2000:00。
OD_PERSIST_COMM.x1017_producerHeartbeatTime = 1000;
OD_RAM.x2000_testCNT = 0;
远程 CANopen 节点看到的则是 0x1017:00 和 0x2000:00。
为什么对象字典要传给初始化函数
// OD 参数把生成的对象表交给协议栈;收到 SDO 请求后,协议栈据此查找对象、
// 检查读写权限和长度,再访问对象背后的 C 存储。
CO_CANopenInit(co, NULL, NULL, OD, NULL, 0,
1000, 1000, 500, false,
0x20, &err_info);
参数中的 OD 告诉协议栈:这个节点有哪些对象。当远程节点发来 SDO 请求时,协议栈才能查找对象、检查权限,并读写对应 C 变量。
所以对象字典可以理解为:
CANopen 协议可以访问的变量目录,以及每个变量的使用规则。
Heartbeat 本身不是“读取对象字典”。Heartbeat 是节点主动发送的固定通信服务;不过它的周期可以由对象 0x1017 保存。
CO_t 是协议栈的总对象
// CO_t 不是一帧报文,而是当前节点的协议栈总对象;CAN、NMT、SDO、PDO
// 等功能模块都由它统一持有,是否存在取决于对象字典和构建配置。
CO_t *co = CO_new(&co_config, &heap_used);
CO_t 里面会根据配置保存多个功能模块的实例,例如:
| 成员概念 | 用途 |
|---|---|
CANmodule | CAN 收发接口 |
NMT | NMT 状态和 Heartbeat Producer |
SDOserver | 让别人访问本地对象字典 |
SDOclient | 主动访问远程节点 |
RPDO / TPDO | 接收和发送过程数据 |
某个成员是否存在,取决于 Kconfig 和对象字典配置。不能只凭函数名假设功能已经启用。
主循环为什么必须反复调用
CANopen 协议里有很多动作依赖时间和当前状态:
- Heartbeat 周期是否到期;
- SDO 是否收到响应或超时;
- NMT 状态是否改变;
- TPDO 的事件定时器是否到期;
- 是否需要处理复位命令。
这些工作不是初始化时一次做完的,因此需要周期处理函数。

只启用 Heartbeat 和 SDO 时,核心调用是:
// elapsed_us 是自上次调用以来真实经过的微秒数。
// CO_process 用它推进 NMT、Heartbeat 和 SDO 定时器,并返回是否需要复位通信。
reset = CO_process(co, false, elapsed_us, NULL);
启用 PDO 后,还会加入:
// RPDO 先把总线数据写入对象字典变量,TPDO 再检查本地变量和事件定时器是否需要发送。
// 两个函数都使用本轮真实时间差,不能把应用变量更新时间当成协议时间。
CO_process_RPDO(co, false, elapsed_us, NULL);
CO_process_TPDO(co, false, elapsed_us, NULL);
为什么不能写死 elapsed_us
下面这种写法看起来每次传 10 ms:
// 错误示例:vTaskDelay(10 ms) 只保证“至少”延时这些 tick,
// 若任务因调度晚了 3 ms,仍向协议栈谎报 10 ms,误差会在每轮不断累积。
CO_process(co, false, 10000, NULL);
vTaskDelay(pdMS_TO_TICKS(10));
但 FreeRTOS 调度和其他任务会带来偏差。更稳妥的写法是读取 esp_timer_get_time(),计算两次调用间真实经过的微秒数。
这不是为了追求极端精度,而是避免程序繁忙时协议时间越走越不准。
普通处理和实时处理
CANopenNode 把工作大致分为:
CO_process():NMT、Heartbeat、SDO 等普通周期任务;CO_process_RPDO()/CO_process_TPDO():PDO 过程数据;CO_process_SYNC():使用 SYNC 时的同步处理。
本课程的第 16 课只启用了普通处理。第 18 课加入 PDO 后,再实际调用 RPDO 和 TPDO 处理函数。
本课练习
在源码中完成三次定位:
- 找到
0x1017对应的 C 变量; - 找到
CO_CANopenInit()接收对象字典的位置; - 找到持续调用
CO_process()的循环。
能说清“对象在哪里定义、协议在哪里初始化、时间在哪里推进”,这课就通过了。