CANopenNode协议栈之对象字典概念
对象字典(OD)是赋予 CANopenNode 身份与行为的核心数据结构。它由 CiA 301 标准定义,是影响应用对象、通信对象以及设备状态机行为的所有数据项的集合。它作为通信栈与应用层之间的唯一接口——每一次 SDO 读取、每一次 PDO 传输以及每一次心跳交换,最终都会触及某个 OD 变量。
字典中的每个对象均通过16 位索引和8 位子索引进行寻址,从而构成一张二维查找表,将每个数据项映射至已知位置。理解了对象字典,也就理解了所有 CANopen 设备的核心骨干。
对象字典的结构
种对象类型
CANopenNode 在字典中定义了三种基本对象类型,每种类型都有对应的 C 结构体表示:
| 对象类型 | CiA 301 代码 | C 结构体 | 描述 |
|---|---|---|---|
| VAR | 7 | OD_obj_var_t | 任意数据类型的单一变量,位于子索引 0 |
| ARRAY | 8 | OD_obj_array_t | 相同类型变量组成的数组;子索引 0 保存元素数量 |
| RECORD | 9 | OD_obj_record_t[] | 由不同类型子元素组成的结构体,每个元素拥有各自的属性 |
OD_obj_var_t结构体最为简单——它包含一个指向实际数据的指针(dataOrig)、一个属性位掩码(attribute)以及以字节为单位的数据长度(dataLength)。OD_obj_array_t在此基础上进行了扩展,为子索引 0 和数组元素分别提供了独立的指针和属性,并增加了用于指针运算的dataElementSizeof。OD_obj_record_t是一个由类OD_obj_var_t条目组成的数组,每个条目都标有各自的subIndex。
OD 条目与 OD 根
在更高一层的抽象中,每个 OD 对象由一个OD_entry_t表示——这是一个目录条目,包含 16 位索引、子条目数量、对象类型标识符、指向特定类型对象数据的指针(odObject),以及指向可选应用扩展的指针(extension)。这些条目被收集到一个有序数组中,而OD_t结构体则使用一个大小字段对该数组进行了封装:
typedef struct { uint16_t index; // 例如 0x1000 uint8_t subEntriesCount; // 包含子索引 0 在内的子条目总数 uint8_t odObjectType; // ODT_VAR, ODT_ARR 或 ODT_REC CO_PROGMEM void* odObject; // 指向特定类型对象数据的指针 OD_extension_t* extension; // 可选的应用层扩展(默认为 NULL) } OD_entry_t; typedef struct { uint16_t size; // 条目数量,不包含空白终止符 OD_entry_t* list; // OD 条目的有序数组 } OD_t;下图展示了这些结构体之间的关系:
OD 条目列表必须按索引升序排列。这是一项硬性要求——OD_find()使用复杂度为 O(log N) 的二分查找算法,如果条目顺序混乱,查找操作将发生静默失败。
存储组:按生命周期组织数据
OD 变量并非以扁平数据块的形式存储,而是被组织成多个存储组,这些组是 C 结构体,其名称体现了持久化语义。在示例 OD 中,定义了两个组:
| 存储组 | 用途 | 持久化至 NVM? |
|---|---|---|
| PERSIST_COMM | 跨越电源周期的通信参数(设备类型、COB-ID、心跳时间、PDO 参数) | 是 |
| RAM | 易失性运行时数据(错误寄存器、错误历史、SDO 服务器参数) | 否 |
生成的头文件将每个组声明为 typedef 结构体和 extern 全局变量。例如,OD_PERSIST_COMM_t包含所有持久化通信参数,而OD_RAM_t存放易失性运行时状态。这种分组机制使得存储子系统能够选择性地将特定组保存至非易失性存储器或从中恢复。
访问 OD 变量:接口层
通用访问模式
CANopenNode 通过 OD 接口提供了一种简洁且与类型无关的访问模式。无需直接访问底层 C 结构体。其操作流程始终如下:
使用
OD_find()通过索引查找OD 条目——返回指向OD_entry_t的指针使用
OD_getSub()通过子索引获取子对象——填充OD_IO_t结构体通过
OD_IO_t中的函数指针进行读或写——基于流操作,类似于 POSIX 的read()/write()
OD_IO_t结构体包含一个OD_stream_t(持有dataOrig、dataLength、dataOffset、attribute、index、subIndex)以及函数指针read和write。当未配置扩展时,这些指针默认指向OD_readOriginal()和OD_writeOriginal()——这是简单的memcpy操作,通过dataOffset处理部分数据传输。
辅助 Getter 与 Setter 函数
对于简单的单次访问,CANopenNode 在OD_get_value()和OD_set_value()的基础上提供了类型化的便捷封装。这些是static inline函数,可自动处理类型大小:
| 函数 | C 类型 | CANopen 类型 |
|---|---|---|
OD_get_i8/OD_set_i8 | int8_t | INTEGER8 |
OD_get_u16/OD_set_u16 | uint16_t | UNSIGNED16 |
OD_get_u32/OD_set_u32 | uint32_t | UNSIGNED32 |
OD_get_f32/OD_set_f32 | float32_t | REAL32 |
OD_get_u64/OD_set_u64 | uint64_t | UNSIGNED64 |
OD_getPtr() | void* | 直接指针访问 |
辅助函数方式更为简单,但在频繁访问时效率较低,因为每次调用都会重新执行完整的OD_find()→OD_getSub()链。对于热点路径变量,建议在初始化后缓存OD_IO_t并直接调用io.read()。
直接全局访问
对于简单应用,生成的头文件还会暴露直接的 C 全局变量和条目快捷方式:
#include "OD.h" /* 直接变量访问(无锁,无查找) */ uint32_t devType = OD_PERSIST_COMM.x1000_deviceType; OD_PERSIST_COMM.x1018_identity.serialNumber = 0x12345678; /* 直接条目指针访问 */ OD_entry_t *entry = OD_ENTRY_H1001_errorRegister;
如果已通过OD_extension_init()在某条目上注册了 OD 扩展,直接变量访问将绕过该扩展,从而读取到陈旧或无意义的数据。在扩展处于活动状态时,请务必使用 IO 接口进行访问。
属性:定义访问权限
每个 OD 子对象都带有一个 8 位属性位掩码(OD_attr_t),用于控制谁可以访问它以及如何访问。这些标志是 CANopen 访问模型的强制执行机制:
| 属性 | 数值 | 含义 |
|---|---|---|
ODA_SDO_R | 0x01 | SDO 服务器可读 |
ODA_SDO_W | 0x02 | SDO 服务器可写 |
ODA_TPDO | 0x04 | 可映射至 TPDO(发送) |
ODA_RPDO | 0x08 | 可映射至 RPDO(接收) |
ODA_TSRDO | 0x10 | 可映射至发送 SRDO |
ODA_RSRDO | 0x20 | 可映射至接收 SRDO |
ODA_MB | 0x40 | 多字节变量(int16 及以上——需处理大小端) |
ODA_STR | 0x80 | 字符串类型——较短的写入操作将以零填充剩余部分 |
属性通过按位或运算组合。例如,地址 0x1001 处的错误寄存器定义为ODA_SDO_R | ODA_TPDO(可通过 SDO 读取,可映射至 TPDO),而地址 0x1017 处的生产者心跳时间则使用ODA_SDO_RW | ODA_MB(可通过 SDO 读写,多字节用于大小端转换)。内联函数OD_mappable()通过测试ODA_TRPDO | ODA_TRSRDO位来检查变量是否可以映射至任何 PDO 或 SRDO。
OD 扩展:自定义读/写行为
OD_extension_t机制是 CANopenNode OD 接口最强大的功能。它允许应用使用自定义函数覆盖默认基于memcpy的读/写操作,从而有效地将任何 OD 条目转换为按需计算其值的虚拟变量。
当对某条目调用OD_extension_init()时,extension指针即被设置。随后,OD_getSub()会检查该指针:如果非 NULL,它会将io.read和io.write指向扩展的函数指针(如果指针为 NULL,则指向OD_readDisabled/OD_writeDisabled)。该扩展还携带一个可选的object指针,该指针会通过stream->object传递给自定义函数,此外还有一个用于事件驱动 TPDO 触发的flagsPDO位域。
OD_readOriginal()和OD_writeOriginal()函数可在自定义扩展函数内部使用。这允许扩展在增强行为(例如验证取值范围)的同时,仍将实际的内存操作委托给默认实现处理。
返回码与 SDO 终止映射
每个 OD 访问函数都会返回一个ODR_t枚举值。这些代码与 CiA 301 SDO 终止码一一对应,可通过OD_getSDOabCode()函数进行转换。这种设计意味着 SDO 服务器可以直接将 OD 返回码传递给网络,而无需单独的错误映射层:
| ODR_t 值 | SDO 终止码 | 含义 |
|---|---|---|
ODR_OK(0) | 0x00000000 | 成功 |
ODR_PARTIAL(-1) | — | 部分传输,需再次调用 |
ODR_IDX_NOT_EXIST(5) | 0x06020000 | 索引不在字典中 |
ODR_SUB_NOT_EXIST(14) | 0x06090011 | 未找到子索引 |
ODR_READONLY(4) | 0x06010002 | 尝试写入只读对象 |
ODR_TYPE_MISMATCH(11) | 0x06070010 | 数据类型/长度不匹配 |
ODR_INVALID_VALUE(15) | 0x06090030 | 值超出有效范围 |
CANopen 数据类型
CANopenNode 使用映射至 CiA 301 / IEC 61131-3 数据类型的标准 C 类型。下表展示了整个 OD 中使用的核心映射关系:
| CANopenNode C 类型 | IEC 61131-3 | CANopen 名称 | dataType 代码 |
|---|---|---|---|
bool_t | BOOL | BOOLEAN | 0x01 |
int8_t | SINT | INTEGER8 | 0x02 |
int16_t | INT | INTEGER16 | 0x03 |
int32_t | DINT | INTEGER32 | 0x04 |
uint8_t | USINT | UNSIGNED8 | 0x05 |
uint16_t | UINT | UNSIGNED16 | 0x06 |
uint32_t | UDINT | UNSIGNED32 | 0x07 |
uint64_t | ULINT | UNSIGNED64 | 0x1B |
float32_t | REAL | REAL32 | 0x08 |
char[] | STRING | VISIBLE_STRING | 0x09 |
uint8_t[] | BITSTRING | OCTET_STRING | 0x0A |
完整的 OD 文件对
一个真正的对象字典由生成的文件对OD.h和OD.c进行定义,它们是由 CANopenEditor 工具根据 XML 设备描述生成的。其结构遵循一致的模式:
| 文件部分 | 内容 | 示例 |
|---|---|---|
| 计数器 | 用于对象计数的#define宏 | OD_CNT_RPDO 4,OD_CNT_TPDO 4 |
| 存储组结构体 | 按生命周期分组的 C typedef | OD_PERSIST_COMM_t,OD_RAM_t |
| Extern 声明 | 全局变量和 OD 指针声明 | extern OD_PERSIST_COMM_t OD_PERSIST_COMM; |
| 条目快捷方式 | 用于直接条目访问的#define宏 | #define OD_ENTRY_H1001 &OD->list[1] |
| 数据初始化 | 所有存储组的默认值 | .x1000_deviceType = 0x00000000 |
| OD 对象 | 常量类型描述符 (VAR/ARRAY/RECORD) | OD_obj_var_t,OD_obj_record_t[] |
| OD 条目列表 | 带有终止符的OD_entry_t有序数组 | {0x1000, 0x01, ODT_VAR, ...}, ... |
| OD 根 | 封装条目列表的OD_t结构体 | OD_t *OD = &_OD; |
生成文件中的警告十分明确:“请勿手动编辑此文件”。手动修改会破坏与 XML 源文件的同步,且极易引发错误。整个 OD 定义应通过编辑器工具及其 XML 项目文件(遵循 CiA 311 标准的.xpd/.xdd格式)进行管理。
线程安全考量
OD 变量会在多个执行上下文中被访问。CANopenNode 通常至少运行在两个线程中:一个快速实时线程(处理 PDO、SYNC)和一个慢速主线程(处理 SDO 服务器、心跳)。两者可能会并发读写同一个 OD 变量。
协议栈提供了CO_LOCK_OD()和CO_UNLOCK_OD()宏用于临界区保护。在主线程中访问可映射至 PDO 的变量时,必须使用这些宏。然而,在实时线程中绝对不可使用加锁操作(因为这会阻塞 ISR),在自定义的read()/write()扩展函数内部也绝对不可使用加锁操作(因为这会导致死锁)。在初始化阶段,即线程启动之前,则无需加锁。