首页 / 驱动算法 / EtherCAT专题

跨协议统一:CANopen 与 EtherCAT 共用一套对象字典

为什么"对象字典"这件事两家早就统一了(CiA 301/402 同源、CoE 直接复用,连 0x1600/0x1A00 映射对象和 little-endian 字节序都完全一致);真正要设计的是软件层面——抽一个协议无关的对象字典核心,让 CANopen 栈和 EtherCAT(CoE) 栈共用同一份 OD、同一张 PDO 映射表,只有访问通道不同。本文给统一架构(应用层 → OD 核心 → 双协议适配层 → 物理层)、OD 项字段设计、统一访问接口 od_get/od_set、状态机三层分离、锁与双缓冲一致性保护,配双协议帧级访问交互演示和可编译 C 代码。

1. 为什么两者天然统一:CoE 的出身

先把结论说透:对象字典本来就是 CANopen 的,EtherCAT 的 CoE 是"把 CANopen 搬到 EtherCAT 上"。

  • CiA 301(应用层/对象字典规范)定义 OD 结构:index+subindex 寻址、数据类型(uint8/16/32、int8/16/32、visible string、octet string)、通信对象区 0x1000~0x1FFF;
  • CiA 402(驱动规范)定义 0x6000 系列运动对象:6040h 控制字、6041h 状态字、6060h 模式、607Ah 目标位置……CANopen 板块第 10 篇讲的就是它;
  • EtherCAT CoE(ETG.1000.6)直接复用这套字典:对象编号、subindex、数据类型、甚至 PDO 映射对象 0x1600/0x1A00 都与 CANopen 完全一致。

这意味着:同一个 6040h=0x000F 命令、同一个 607Ah 目标位置、同一张 0x1600 映射表,在两种总线上语义完全相同。标准层面没有"统一"问题,问题只在软件实现——怎么让一套固件、一份应用代码同时服务两条总线。

2. 统一要吸收的差异:一张对照表

语义相同,但访问通道、传输机制、同步与监控不同。适配层要吸收的差异全在这里:

维度CANopenEtherCAT(CoE)统一策略
对象字典定义CiA 301 + CiA 402CoE 复用同一字典同一份 OD 表
配置通道SDO(COB-ID 0x580/0x600 + 节点号)CoE SDO over 邮箱(0x1000 系列)适配层收口成 od_get/set
过程数据PDO(COB-ID 0x180+节点 / 8 字节 CAN 帧)PDO over SM2/SM3 + FMMU + LRW同一张映射表(编号一致)
同步机制SYNC + 同步 PDO(1ms 级)DC Sync0(ns 级抖动)适配层各自实现,应用无感
网络状态机NMT(Op/PreOp/Stopped)AL 状态机(Init→PreOp→SafeOp→Op)各自维护,只暴露"网通"信号
节点监控Heartbeat / LifeGuard看门狗 WD(帧超时 + PDI WD)各自实现
参数保存0x1010 存 / 0x1011 恢复同左(主站还可管 ESI)统一 0x1010/0x1011 实现
字节序little-endianlittle-endian天然一致,无需处理
帧长限制PDO ≤ 8 字节/帧映射总长无硬限OD 按 bit 宽定义,截断由适配层处理

注意最后一行:统一的是字典和访问语义,不是实时机制。DC 是 EtherCAT 独有,多轴龙门/机器人强同步只能走 EtherCAT;CANopen 的优势在成本(无 ESC+PHY),单轴/IO 类 1ms 周期足够。产品线布局通常是:单轴/IO 用 CANopen,多轴联动用 EtherCAT,固件共用七成以上。

3. 统一架构总览:OD 核心 + 双协议适配层

核心思路一句话:应用层和协议栈之间隔一层"协议无关的对象字典核心"。应用只认识 index.subindex;CANopen 栈和 EtherCAT 栈都把自己收到的请求翻译成对这个核心的读写。

// 逻辑分层(从顶到底)*/ 应用层 : CiA402 伺服状态机 / 运动规划 / IO 逻辑(不感知总线类型) 对象字典核心 : 一份 OD 表 + od_get/od_set/od_subscribe 统一接口 协议适配层 : CANopen 栈(SDO/PDO/NMT) | EtherCAT 栈(CoE SDO/过程数据/AL/DC) 物理层 : CAN 收发器 → CAN_H/L | ESC(ET1100/LAN9252)+PHY → 网线

这套分层的直接收益:

  • 应用一次编写、双协议复用——运动控制逻辑只读 OD,切换总线不动应用;
  • 固件维护一份 OD 文档——新增对象只改核心表,两种栈自动可见;
  • 诊断/一致性检查一套——0x1010/0x1011 参数保存、写保护、上限校验只写一次。

工程印证:汇川 SV660N(CANopen)与 SV660E(EtherCAT)同平台固件换协议板卡;从站侧一颗 MCU 同时挂 CAN 收发器和 SPI 接 LAN9252,配置字选协议启动哪边栈,OD 完全一份。

4. 对象字典核心设计:一项一表项

两种协议栈各自都带一张字典表(CANopenNode 的 OD、SSC 生成的 CoE OD),结构大同小异。统一的核心是把它们抽成公共表项,并补齐 CANopen 栈一般没有的三个字段:

// OD 表项:一份定义,双栈可见 */ typedef struct { uint16_t index; // 对象索引,如 0x6040 */ uint8_t subindex; // 子索引(数组/记录对象用)*/ uint8_t type; // OD_T_U16 / OD_T_I32 / OD_T_STRING ... */ void *data; // 数据指针(或内嵌存储)*/ uint8_t access; // RO / RW / WO */ uint8_t pdo_map; // ★ PDO 映射位:该对象能否进过程数据 */ void (*on_write)(od_key_t); // ★ 变更回调:SDO 写入 6060h 通知模式切换 */ int (*check)(od_key_t, const void*); // ★ 一致性检查:写 6081h 校验上限 */ } od_entry_t;

三个字段的价值:

  • pdo_map 映射位——决定"哪些对象能进 0x1600/0x1A00"。没有它,映射逻辑得硬编码或按协议分开维护;有了它,映射表统一生成;
  • on_write 变更回调——CANopen 和 CoE 的 SDO 都能写 6060h,改模式的动作(停轴、清目标)必须两边都触发——把动作挂在 OD 项上,天然双栈复用;
  • check 一致性检查——上限/单位校验只写一处,SDO 和本地配置(厂商工具)走同一条校验链。

5. 统一访问接口:od_get / od_set / od_subscribe

两个栈的 SDO 处理都收口到三个薄接口:

// 统一访问接口(协议无关)*/ int od_get(od_key_t k, void *out, uint16_t *len); // 读:栈内查表 + 类型转换 */ int od_set(od_key_t k, const void *in, uint16_t len); // 写:权限→check→写入→on_write */ int od_subscribe(od_key_t k, void (*cb)(od_key_t)); // 订阅变更:本地逻辑也可监听 */ // CANopen 栈内(SDO 服务端)*/ uint8_t sdo_handler(uint16_t idx, uint8_t sub, int dir, void *buf, uint16_t *len) { od_key_t k = { idx, sub }; return dir ? od_set(k, buf, *len) : od_get(k, buf, len); } // EtherCAT 栈内(CoE SDO over 邮箱,0x1000 系列)*/ int coe_sdo_rx(const uint8_t *m) { /* 解析邮箱 SDO 头 → 同样调 od_get/od_set */ od_key_t k = { be16(m+4), m[6] }; return (m[2] & 0x40) ? od_set(k, m+8, m[2]&0x0F) : od_get(k, m+8, &len); }

要点:接口里不许出现"哪个协议"的痕迹。调用方(应用)和实现方(栈)都只认 od_key_t;协议特有的东西(COB-ID、邮箱头)全部关在适配层内部。这样双栈并存时,新增第三种协议(如 Modbus RTU 做配置通道)只是再加一个 handler。

6. 映射表统一:同一张 0x1600 / 0x1A00

前文说过:CoE 的 PDO 映射对象编号和 CANopen 完全相同,映射项格式也一致——三元组(index.subindex.bitlen)。所以映射表可以是一份,差异只在"怎么把映射的数据搬到总线":

// 一份映射表:RxPDO = 6040h(16bit) + 607Ah(32bit) = 6 字节 */ static const od_map_t rx_map[] = { { 0x6040, 0x00, 16 }, { 0x607A, 0x00, 32 }, }; /* CANopen 侧:按 COB-ID 组帧,8 字节上限,剩余补 0 帧: 0x200+node | 0F 00 | A0 86 01 00 | 00 00 (6 字节有效) */ /* EtherCAT 侧:主站经 SM2 写入 + FMMU 落到应用区,LRW 帧携带 IOmap 偏移 0: 6040h(2B) 偏移 2: 607Ah(4B) —— 与 SOEM 篇完全一致 */

工程含义:

  • 映射配置逻辑复用——0x1600/0x1A00 的 SDO 改写(先清项数→逐项写→收尾,第 13 篇第 5 节)两种栈代码几乎一样;
  • 映射缓冲区双份的时机——应用写"影子区",在 SYNC(CANopen)或 Sync0(EtherCAT)时刻原子切到"发送区",见第 8 节;
  • 8 字节边界由适配层吸收——CANopen 侧组帧截断到 8 字节(超过的拆多帧或报错),EtherCAT 侧无此限制。

7. 状态机三层分离:网络 / CiA402 / 电机控制

双协议产品最常见的架构病是把网络状态机当驱动状态机用。正确姿势是三层各管各的:

状态机由谁维护决定什么典型状态
网络层协议栈(NMT / AL 状态机)过程数据通没通CANopen: Op/PreOp/Stopped;EtherCAT: Init→PreOp→SafeOp→Op
驱动层应用(CiA402)电机能不能动disabled→Ready→Switched on→Op enabled
电机控制层FOC/伺服内部闭环怎么跑脱机/开环/闭环/故障

三个常见错误:

  • NMT Op 当"已使能"——CANopen 上节点进 Op 只表示 PDO 通了,电机没使能;EtherCAT 进 OP 同理,还得走 6040h 命令(第 13 篇使能时序);
  • 网络断线直接清 CiA402 状态——应该置"网通=0"让应用按策略处理(保持使能 or 快停),而不是粗暴重置状态机;
  • EtherCAT AL 状态机被当 NMT 用——AL 的 SafeOp/Op 是"数据分级",不是"节点开关",映射到应用层就是两个信号:net_ready(配置完成)和 data_valid(过程数据有效)。

统一做法:网络层就绪 → 置"网通"标志;应用层只看这个标志 + CiA402 状态机,不感知具体协议。

8. 数据一致性:锁与双缓冲

最容易被忽略的一节。进 OP 后同时存在两类访问:SDO(邮箱)低频写(主站改 6060h 模式、读故障)和过程数据高频读写(每周期 6040h/607Ah 下发、6041h/6064h 回报)。同一变量两条通道并发访问,不保护就撕裂。

  • 映射缓冲区双份:应用写影子区,SYNC/Sync0 时刻原子切到发送区;接收侧同样,应用从"已就位区"读,避免周期中途读到半个值;
  • SDO 写走锁:od_set 内部对目标对象加锁(关中断或自旋),写完解锁——CANopen 和 CoE 的 SDO handler 都走同一条 od_set,锁只有一处;
  • 周期对象与配置对象分离:6040h/607Ah 这类"每周期变的"标记为周期对象(SDO 仍可写但应用按周期值优先);6060h 这类"低频变"的标记为配置对象(SDO 写后必须确认生效)。
典型撕裂场景

主站一边周期下发目标位置(PDO),一边邮箱改 6060h 从 CSP 切 CSV。若 OD 无锁:模式切换回调读到的是半新半旧的目标值 → 从站可能用 CSV 突然吃掉一个位置值。双缓冲 + 锁 + "切换后清目标"(第 13 篇第 3 节)三道一起才能兜住。

9. 字节序、位宽与 8 字节边界

三个小问题,一次说清:

  • 字节序:CANopen 与 EtherCAT 都是 little-endian(低字节在前),多字节对象(607Ah int32)两边读法一样——天然一致,无需转换。这是统一的最大红利之一;
  • 位宽:OD 项按 bit 宽定义(16/32/8),CANopen 侧组帧时不满 8 字节补 0(如 RxPDO 6 字节有效 + 2 字节填充),EtherCAT 侧按映射总长原样走——截断/填充逻辑收在适配层;
  • 对齐:EtherCAT 从站 FMMU/SM 一般按 4 字节对齐分组,映射项若出现奇数位宽(bitlen=12 等)适配层要处理——CANopen 没这问题,但为统一,映射项建议按 2/4 字节对齐设计。

10. 交互演示:同一 OD,双协议帧级访问

同一个对象字典,两种总线怎么访问——选协议、选对象、写值,看帧一级的差异:SDO 走配置通道(CAN 8 字节帧 / CoE 邮箱),PDO 走过程数据通道(CAN PDO 帧 / LRW 帧 SM2 区),最终落到同一份数据。

双协议 OD 访问模拟器(从站节点号 3)
协议: 对象:
对象字典核心(一份)
总线帧日志
  • ① 协议切换:CANopen 的 SDO/PDO 走 CAN 帧(COB-ID + 8 字节);EtherCAT 的 CoE SDO 走邮箱(0x1000 头)、过程数据走 LRW 帧 SM2 区;
  • ② 同一 OD 值:SDO 写 6040h=0x000F,两种协议的帧内容不同、落到字典的同一个表项;
  • ③ 对比 PDO:CANopen PDO 帧固定 8 字节(有效 6 字节补 0),EtherCAT 只带映射总长 6 字节——帧效率差异一眼可见。

11. 可编译 C 代码 + 避坑清单

下面是 OD 核心 + 双栈适配骨架(可直接编译测试),覆盖:表项定义、od_get/od_set 实现、CANopen SDO handler 与 CoE SDO handler 收口、映射缓冲区双份示意。

/* od_core.c —— 协议无关对象字典核心 + 双栈适配骨架 编译: gcc -O2 -o od_core od_core.c 说明: 独立可跑的最小实现;接入 CANopenNode / SSC 时替换各自的 SDO handler 调用即可 */ #include <stdint.h> #include <string.h> #include <stdio.h> typedef struct { uint16_t index; uint8_t sub; } od_key_t; #define OD_RO 0 #define OD_RW 1 #define OD_U16 0 #define OD_I32 1 typedef struct { od_key_t k; uint8_t type, access, pdo_map; uint32_t val; /* 简单实现直接存值 */ void (*on_write)(od_key_t); } od_entry_t; static od_entry_t od_tab[] = { { {0x6040,0}, OD_U16, OD_RW, 1, 0, NULL }, /* 控制字 */ { {0x6041,0}, OD_U16, OD_RO, 1, 0, NULL }, /* 状态字 */ { {0x607A,0}, OD_I32, OD_RW, 1, 0, NULL }, /* 目标位置 */ { {0x6064,0}, OD_I32, OD_RO, 1, 0, NULL }, /* 实际位置 */ }; #define OD_N (sizeof(od_tab)/sizeof(od_tab[0])) static od_entry_t *find(od_key_t k) { for (int i=0;i<OD_N;i++) if (od_tab[i].k.index==k.index && od_tab[i].k.sub==k.sub) return &od_tab[i]; return NULL; } int od_get(od_key_t k, void *out, uint16_t *len) { od_entry_t *e = find(k); if (!e) return -1; if (e->type==OD_U16) { *(uint16_t*)out = (uint16_t)e->val; *len=2; } else { *(int32_t*)out = (int32_t)e->val; *len=4; } return 0; } int od_set(od_key_t k, const void *in, uint16_t len) { od_entry_t *e = find(k); if (!e || e->access!=OD_RW) return -1; /* 权限检查 */ if (e->type==OD_U16) e->val = *(const uint16_t*)in; else e->val = *(const int32_t*)in; if (e->on_write) e->on_write(k); /* 变更回调 */ return 0; } /* ===== CANopen 栈侧:SDO 服务端收口(COB-ID 0x580/0x600 + 节点号)===== */ uint8_t can_sdo_handle(uint16_t idx, uint8_t sub, int dir, void *buf, uint16_t *len) { od_key_t k = { idx, sub }; return dir ? (uint8_t)od_set(k, buf, *len) : (uint8_t)od_get(k, buf, len); } /* ===== EtherCAT 栈侧:CoE SDO over 邮箱收口(0x1000 系列邮箱)===== */ int coe_sdo_handle(const uint8_t *m, uint8_t *rsp) { uint16_t idx = (uint16_t)((m[4]<<8) | m[5]); uint8_t sub = m[6]; od_key_t k = { idx, sub }; uint16_t len = 0; return (m[2] & 0x40) ? od_set(k, m+8, 4) : od_get(k, rsp+8, &len); } /* ===== 映射缓冲区双份(示意):应用写影子,Sync/Sync0 原子切换 ===== */ static uint8_t tx_buf[2][6], rx_buf[2][6]; volatile int cur = 0; void sync_tick(void) { cur ^= 1; } /* SYNC 帧 / Sync0 中断里调用 */ void app_set_target(int32_t pos) { int w = cur ^ 1; /* 写影子区 */ memcpy(tx_buf[w], &pos, 4); } /* 主循环:以 od_get 从"已就位区"读实际位置,避免读半个值 */ int main(void) { od_key_t k = { 0x6064, 0 }; int32_t act = 0; uint16_t len = 0; /* 模拟:CANopen SDO 写 6040h=0x000F */ uint16_t cw = 0x000F; can_sdo_handle(0x6040, 0, 1, &cw, 2); printf("6040h = 0x%04X\n", *(uint16_t*)&od_tab[0].val); /* 模拟:CoE SDO 读 6064h */ od_get(k, &act, &len); printf("6064h = %d (%dB)\n", act, len); return 0; }

避坑清单:

坑现象对策
OD 各自维护两份加一个对象改两处,漏改则双协议行为不一致抽公共 OD 核心,双栈只调 od_get/set(第 4~5 节)
NMT/AL 状态当 CiA402 用"进 Op 就以为使能了",电机不动还查半天三层状态机分离,网络层只置"网通"标志(第 7 节)
SDO 与 PDO 并发写同一变量值撕裂、模式切换吃进半新目标双缓冲 + od_set 锁 + 切换后清目标(第 8 节)
忽略字节序多字节对象在两种总线读出不同两边都是 little-endian,天然一致;别加多余 swap(第 9 节)
映射按协议分开写0x1600 逻辑两份,改映射漏一处同一张映射表(编号/三元组格式一致),适配层只差搬运方式(第 6 节)
CAN 8 字节截断处理在各处散落映射超过 8 字节后各协议行为不一截断/填充收口在适配层,OD 层无感(第 6/9 节)

一句话收尾:对象字典是同源的(CiA 301/402 + CoE),标准层面不存在"统一"问题;软件层面把"OD 核心"抽出来当中间层,应用一次编写、双协议复用,差异全部收在适配层——这正是汇川等厂商"一套固件换协议板卡"的架构。第 13 篇的使能时序、本专题的 PDO/SM·FMMU/SOEM 篇,都是这套架构下具体协议的落地。