跳到主要内容

17. CANopenNode 对象字典与主循环

学习形式源码导读
硬件要求不需要
配套 Demo沿用 lab_16
本课动作不控制电机

上一课的节点能够发送 Heartbeat,但代码里出现了几个还没有细讲的名字:ODCO_tCO_process()

这一课不增加新硬件动作,只回答一个问题:CANopenNode 怎样把对象字典、通信状态和周期处理组织在一起?

先从普通 C 程序看

普通程序可以有一个变量:

// 普通变量只有本程序知道名称和地址;它没有 Index、类型和访问权限,
// 因此远程 CANopen 节点无法通过 SDO 找到它。
uint32_t counter = 0;

只有本程序的代码知道它。如果希望另一个 CANopen 节点通过 SDO 访问它,还需要给它安排对象地址、数据类型和访问权限,例如:

内容示例
Index0x2000
Sub-index0x00
数据类型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.hC 结构体、变量名称、对象索引宏和外部声明
OD.c对象条目、属性、数据长度、默认值和存储实例

应用代码通过有意义的 C 名称访问变量,例如:

// 这些名称由 OD.h 生成:应用用 C 成员名访问,远程节点仍使用 0x1017:00、0x2000:00。
OD_PERSIST_COMM.x1017_producerHeartbeatTime = 1000;
OD_RAM.x2000_testCNT = 0;

远程 CANopen 节点看到的则是 0x1017:000x2000: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 里面会根据配置保存多个功能模块的实例,例如:

成员概念用途
CANmoduleCAN 收发接口
NMTNMT 状态和 Heartbeat Producer
SDOserver让别人访问本地对象字典
SDOclient主动访问远程节点
RPDO / TPDO接收和发送过程数据

某个成员是否存在,取决于 Kconfig 和对象字典配置。不能只凭函数名假设功能已经启用。

主循环为什么必须反复调用

CANopen 协议里有很多动作依赖时间和当前状态:

  • Heartbeat 周期是否到期;
  • SDO 是否收到响应或超时;
  • NMT 状态是否改变;
  • TPDO 的事件定时器是否到期;
  • 是否需要处理复位命令。

这些工作不是初始化时一次做完的,因此需要周期处理函数。

CANopenNode 周期处理顺序

只启用 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 处理函数。

本课练习

在源码中完成三次定位:

  1. 找到 0x1017 对应的 C 变量;
  2. 找到 CO_CANopenInit() 接收对象字典的位置;
  3. 找到持续调用 CO_process() 的循环。

能说清“对象在哪里定义、协议在哪里初始化、时间在哪里推进”,这课就通过了。