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里用expeditedsegmented两种模式来处理。嗯,这里要注意,分段传输时一定要处理好序列号,否则数据会乱。

警告: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映射表告诉设备哪些数据要实时发送

举个例子,设备上电后:

  1. 进入初始化状态,对象字典加载默认值
  2. 进入预操作状态,主机通过SDO配置PDO映射表
  3. 主机发送NMT命令,设备进入操作状态
  4. 设备根据PDO映射表,定时发送实时数据

这个流程我建议你画个时序图,贴在工位上。我当年就是靠这张图,把整个CANopenNode的代码逻辑理清楚的。

好了,这一章的内容就到这。下一章咱们开始动手写代码,从零搭建一个CANopenNode工程。到时候你会看到,今天讲的这四个数据结构,会一一出现在你的代码里。