4、CANopenNode核心数据结构:OD对象字典、NMT状态机、SDO协议引擎、PDO映射表
好,咱们今天来啃硬骨头。CANopenNode里最核心的四个数据结构,说白了就是:对象字典(OD)、NMT状态机、SDO协议引擎、PDO映射表。这四个东西你搞明白了,整个CANopen协议栈你就拿下了七成。
我刚开始接触CANopenNode的时候,看着那堆结构体也是一脸懵。后来在项目里踩了几次坑,才慢慢摸清楚它们之间的关系。今天我就把这几年的心得给你捋一遍。
4.1 对象字典(OD)—— 整个系统的“户口本”
对象字典是什么?你可以把它想象成一个巨大的表格。每个设备支持哪些参数、哪些功能,全记在这个表里。CANopen协议规定,每个对象都有一个16位的索引(Index)和8位的子索引(Subindex)。
举个例子,设备类型存在0x1000索引下,设备名称存在0x1008索引下。你想想看,如果没有这个统一的“户口本”,不同厂家的设备怎么互相理解?
在CANopenNode里,对象字典是用一个结构体数组实现的。核心结构体长这样:
typedef struct {
uint16_t index; // 对象索引
uint8_t subIndex; // 子索引
OD_attr_t attribute; // 属性(读写权限)
uint32_t *data; // 数据指针
uint32_t dataLength; // 数据长度
OD_func_t *readFunc; // 读回调函数
OD_func_t *writeFunc; // 写回调函数
} OD_entry_t;
我个人习惯把对象字典的初始化放在一个单独的文件里,比如OD.c。这样主程序看起来清爽很多。每个对象条目都对应一个实际变量,比如:
// 定义实际变量
uint8_t deviceType[4] = {0x00, 0x00, 0x00, 0x00};
uint8_t vendorID[4] = {0x01, 0x02, 0x03, 0x04};
// 对象字典条目
const OD_entry_t OD_entries[] = {
{0x1000, 0x00, OD_ATTR_RO, &deviceType, 4, NULL, NULL},
{0x1018, 0x01, OD_ATTR_RO, &vendorID, 4, NULL, NULL},
// ... 更多条目
};
重点:对象字典的读写回调函数非常有用。我在项目中遇到过需要动态计算返回值的情况,比如读取设备运行时间。这时候就不能直接返回一个静态变量,而是要在读回调里实时计算。
4.2 NMT状态机 —— 设备的“生命线”
NMT(Network Management)状态机,说白了就是设备的生命周期管理。CANopen设备有四个主要状态:初始化、预操作、操作、停止。
为什么会这样设计?你想想看,设备上电后不能直接开始通信吧?得先初始化一下。初始化完了进入预操作状态,这时候可以配置参数,但不能发PDO。配置好了,再切换到操作状态,开始正常工作。
状态转换图我就不画了,咱们直接看代码实现。CANopenNode里NMT状态机核心变量就一个:
volatile uint8_t NMT_state; // 当前NMT状态
状态值定义如下:
| 状态 | 值 | 说明 |
|---|---|---|
| 初始化 | 0x00 | 上电后自动进入,完成硬件初始化 |
| 预操作 | 0x7F | 可配置参数,不能发PDO |
| 操作 | 0x05 | 正常工作,收发PDO |
| 停止 | 0x04 | 停止通信,只响应NMT命令 |
小技巧:调试的时候,我经常在NMT状态切换的地方加个打印。比如从预操作切到操作时,打印一下当前时间。这样能快速定位设备是否按预期启动了。
4.3 SDO协议引擎 —— 配置参数的“快递员”
SDO(Service Data Object)是用来读写对象字典的。它基于“请求-应答”模式,说白了就是你发一个请求,设备回一个应答。
SDO协议引擎的核心是处理SDO报文。一个SDO报文包含:命令字、索引、子索引、数据。命令字决定了你要干什么——读还是写,写几个字节。
CANopenNode里SDO引擎的实现,我建议你重点关注这个函数:
void SDO_serverProcess(CO_SDO_t *SDO, uint8_t *data) {
// 解析SDO请求
uint8_t command = data[0];
uint16_t index = (data[2] << 8) | data[1];
uint8_t subIndex = data[3];
uint8_t *sdoData = &data[4];
// 根据命令字执行操作
switch(command & 0xE0) {
case 0x40: // 读请求
// 从对象字典读取数据
break;
case 0x20: // 写请求
// 写入对象字典
break;
// ... 其他命令
}
}
我曾经遇到过一个坑:SDO传输大块数据时,如果超过4个字节,需要分段传输。CANopenNode里用expedited和segmented两种模式来处理。嗯,这里要注意,分段传输时一定要处理好序列号,否则数据会乱。
警告:SDO的应答超时时间一定要设置合理。我见过有人设成10毫秒,结果总线上设备一多就超时。建议至少设100毫秒以上,保守点设500毫秒也行。
4.4 PDO映射表 —— 实时数据的“高速公路”
PDO(Process Data Object)和SDO最大的区别是什么?PDO是“推”模式,SDO是“拉”模式。PDO不需要应答,发出去就完事了,所以速度快,适合实时控制。
PDO映射表,就是告诉设备:你要把哪些对象字典里的数据打包发出去。比如,我想把0x6000索引下的数据映射到TPDO1,那就在映射表里写一条记录。
映射表的结构体:
typedef struct {
uint16_t index; // 对象索引
uint8_t subIndex; // 子索引
uint8_t dataLength; // 数据长度(位)
} PDO_mapping_t;
举个例子,一个典型的TPDO映射配置:
// TPDO1映射:发送两个16位数据
const PDO_mapping_t TPDO1_mapping[] = {
{0x6000, 0x01, 16}, // 16位数据1
{0x6000, 0x02, 16}, // 16位数据2
};
PDO的触发方式有三种:事件触发、定时触发、远程请求触发。我个人最常用的是定时触发,比如每10毫秒发一次。事件触发容易造成总线拥堵,远程请求触发又太被动。
核心要点:PDO映射表是动态可配置的。你可以在预操作状态下,通过SDO修改映射表。但要注意,修改映射表后,需要重新启动PDO通信才能生效。这个细节我当年调试了整整一个下午才搞明白。
4.5 四个数据结构的协作关系
这四个东西不是孤立的,它们协同工作:
- 对象字典是数据仓库,所有数据都存在这里
- NMT状态机控制设备什么时候能做什么事
- SDO引擎在预操作和操作状态下读写对象字典
- PDO映射表告诉设备哪些数据要实时发送
举个例子,设备上电后:
- 进入初始化状态,对象字典加载默认值
- 进入预操作状态,主机通过SDO配置PDO映射表
- 主机发送NMT命令,设备进入操作状态
- 设备根据PDO映射表,定时发送实时数据
这个流程我建议你画个时序图,贴在工位上。我当年就是靠这张图,把整个CANopenNode的代码逻辑理清楚的。
好了,这一章的内容就到这。下一章咱们开始动手写代码,从零搭建一个CANopenNode工程。到时候你会看到,今天讲的这四个数据结构,会一一出现在你的代码里。