简介:CANopen是运行在控制器局域网络上的高层通信协议,它以对象字典为核心,通过过程数据对象、服务数据对象与网络管理对象等机制,为不同制造商的CAN设备提供统一的数据交互与网络管理规范,能够有效提升系统扩展性与设备互操作性。这份资源面向STM32F1系列单片机开发者,基于开源CANfestival协议栈,从CAN外设参数配置、协议库移植到应用层回调编写,完整呈现一个CANopen从站节点的开发流程。压缩包共包含931个文件,主体为C语言源文件与头文件,覆盖对象字典定义、PDO与SDO处理、NMT状态机及CAN驱动等关键源码;同时附有编译链接脚本、Keil工程配置、批处理工具及目标文件,便于直接导入环境或分析内部细节,整个包大小约28.8MB。从工程组织上看,源码、配置与工具脚本分离,对象字典、应用回调和驱动接口均独立成模块,代码注释较为详细,便于二次开发与功能裁剪。已有392人学习下载,适合希望系统掌握CANopen协议实现,或正在从事工业控制、设备组网与嵌入式软件开发的工程师参考使用。
1. 直接上 STM32F1 跑 CANopen,第一个坑往往是对象字典
很多人第一次接触 CANopen,会下意识把 CAN 总线当成一根“多主串口”,自定义一帧数据带设备地址、命令字、校验位直接发出去。设备少时确实能跑,但只要 PLC 或工控机上挂的伺服、变频器一多,就会暴露帧格式不统一、优先级没法仲裁、在线状态没人管理这三个问题。CANopen 的价值在于它把“数据在总线上怎么排”变成了“对象字典里谁在哪”,通信帧只管索引和子索引,业务数据全部落在对象字典里。STM32F1 的 bxCAN 外设只负责收发帧,CANopen 协议栈用 CANfestival 补齐后,从站代码量可以控制在十几 KB 级别,一块 F103 完全可以同时做采集、控制和协议处理。这篇整理的目标是把 CANfestival 在 STM32F1 上的移植路径、对象字典的配置方法以及调试手段讲完整,适合准备把设备接入工业 CANopen 网络的嵌入式工程师。
2. CANopen 协议详解:对象字典、层级结构与 CANfestival 的选型理由
2.1 对象字典是 CANopen 的层级结构核心
CANopen 的层级结构可以从两层来看:底层是 ISO 11898-1 标准的 CAN 帧,上层是 CiA 301 定义的应用层。应用层全部围绕一个叫对象字典(Object Dictionary,简称 OD)的表格展开。每个对象用 16 位索引加 8 位子索引定位,例如 0x1000 是设备类型,0x1005 是同步 COB-ID,0x1017 是心跳时间。通信相关条目集中在 0x1000~0x1FFF,制造商自定义区域在 0x2000~0x5FFF,标准设备子协议(如 CiA 402 驱动、CiA 404 传感器)使用 0x6000 以上的索引。STM32F1 做从站时,最常用的是 0x2000 区域放自定义的温度、状态、控制字,再把它们映射到 PDO 里去传输。
对象字典不只是一个数据结构,它还是主站访问从站的统一视图。主站用 SDO 读 0x1000 就能判断总线上挂的是什么设备;读 0x1017 能知道从站多久报一次心跳。CANfestival 把对象字典定义成一个CO_Data结构体,里面挂着全部条目,每次收到标准帧就按 COB-ID 查表处理。理解了这个层级结构,后面配置对象映射、调 PDO 传输才不会乱。实际写应用代码时,宁可多花半小时把字典里的条目设计清楚,也不要边写业务边临时往里塞索引,否则后面 SDO 调试时索引对不上最难受。
2.2 NMT、PDO、SDO、心跳在工作时各管一摊
CANopen 在同一根总线上同时存在四种主要的通信对象,它们的分工是协议设计上最值得学习的地方:
- NMT(网络管理):主站给从站发命令,控制从站在初始化、预操作态、操作态、停止态之间切换。一个 CANopen 网络要求只能有一个 NMT 主站,从站收到“进入操作态”命令后才允许发 PDO。
- PDO(过程数据):用于周期或事件触发的实时数据,无应答、单帧最多传 8 字节,适合温度、转速、状态字这类高频数据。
- SDO(服务数据):用于配置参数和读写对象字典,有应答、支持分段传大于 8 字节的数据,适合电机运行参数、校准表这类低频大块数据。
- 心跳(Heartbeat):从站按 0x1017 规定的时间周期性发 0x700+节点ID 的报文,主站靠它判断从站是否掉线。
| 通信对象 | 方向 | COB-ID 范围 | 特点 | 典型用途 |
|---|---|---|---|---|
| NMT | 主站到从站 | 0x000 | 无应答,广播 | 状态切换 |
| PDO | 生产者到消费者 | 0x180+nodeId / 0x200+nodeId | 无应答,最多 8 字节 | 实时过程数据 |
| SDO | 客户端/服务器 | 0x600+nodeId / 0x580+nodeId | 有应答,支持分段 | 读写对象字典 |
| 心跳 | 从站到主站 | 0x700+nodeId | 周期发送 | 在线监控 |
从这四个对象的区别能得出一个使用原则:跑数据用 PDO,调参数用 SDO,管状态用 NMT,看节点活没活着用心跳。CANfestival 源码里的nmt.c、pdo.c、sdo.c、lifegrd.c就是按这个分工组织的,移植时不需要改它们本身的逻辑,只要保证底层驱动把帧送进canDispatch,协议栈就会自动分发到对应模块。
2.3 CANopen 节点上线与离开的时序要求
从站上电后先进入初始化状态,完成自检后自动切到预操作态。预操作态下允许 SDO 通信和心跳,但不允许发 PDO。只有 NMT 主站发出“启动节点进入操作态”命令后,PDO 才开始传输,这个过程就是常说的节点上线。反过来,节点下电或心跳超时,主站应把该从站标记为离线,驱动层对错误帧做清理,避免总线上留下一堆没人接收的 PDO 数据。
从站实现进入和离开逻辑时,需要特别注意两点:一是 NMT 命令是广播帧,COB-ID 固定为 0x000,从站必须检查命令字和节点 ID,只有 0x01 或 0x81 两种命令字匹配自己时才执行状态切换;二是从站在收到“停止节点”命令后,要把自己维护的 PDO 发送定时器清掉。CANfestival 中这些动作分别在nmt.c的状态机里处理,APP 层只需要在节点 ID 匹配时调setState,不需要手动干预通信对象。
2.4 STM32F1 选 CANfestival,而不是自己写协议或换 CanOpenNode
自研帧协议的问题不是难写,而是难收尾:帧格式、优先级分配、断线检测、多主站竞争能力这些都要重新设计,而且和现成 PLC、伺服系统对接时对方根本不认你的私有帧。CanOpenNode 社区活跃度更高,代码更新,但它的工具链相对现代,生成对象字典需要自己搭环境;CANfestival 虽然看起来旧,却有 Objdictgen 这个可视化的对象字典生成器,拖拽就能定义索引和 PDO 映射,对 STM32F1 这种小资源单片机的适配资料也足够多。唯一要忍耐的是它的工具链还停在 Python2 时代,但只在生成字典时才用到,生成出的.c/.h文件是标准 C,复制进 Keil 或 GCC 工程就能编。综合从站资源占用、上手难度、资料丰富度,F103 上选 CANfestival 是性价比最高的方案。
3. STM32F1 上移植 CANfestival:驱动接口、时基与最小工程
3.1 先把源码按模块拆清楚,再谈移植
CANfestival 的源码可以大体分成三块:协议核心、不同平台的驱动样例、以及生成对象字典的工具。移植时不需要把全部文件拖进工程,只需要固定几只文件:
| 文件 | 作用 | 需要改动? |
|---|---|---|
| objdictdef.h | 定义 CO_Data、对象条目的数据结构 | 否 |
| nmt.c | 从站状态机与 NMT 命令解析 | 否 |
| pdo.c | PDO 的发送与接收逻辑 | 否 |
| sdo.c | SDO 服务端处理 | 否 |
| lifegrd.c | 心跳与节点保护 | 否 |
| can.c | CAN 驱动入口,适配 STM32F1 | 是 |
| timers.c | 协议栈的毫秒定时器 | 是 |
can.c里实际要实现的接口就两个:canSend(Message *m)负责把协议栈构造好的帧发出去,canDispatch(Message *m)是收到物理帧后交给协议栈的入口;timers.c里要提供一个让TimeDispatch_Update()周期运行的时基。协议栈其余部分不需要动。把模块边界划清楚之后,编译报错会少很多,因为大部分错误都集中在数据结构定义不匹配或函数指针没接上。
3.2 用 STM32F1 的 bxCAN 实现 canSend 发送接口
STM32F1 的 bxCAN 支持 3 个发送邮箱,发送前要检查邮箱状态。CANfestival 传给canSend的 Message 结构体里已经有cob_id、data、len、rtr四个字段,直接映射到CanTxMsg即可。
UNS8 canSend(Message *m) { CanTxMsg TxMsg; TxMsg.ExtId = 0; TxMsg.IDE = CAN_Id_Standard; // CANopen 默认标准帧 TxMsg.RTR = m->rtr ? CAN_RTR_REMOTE : CAN_RTR_DATA; TxMsg.StdId = m->cob_id; TxMsg.DLC = m->len; memcpy(TxMsg.Data, m->data, m->len); if (CAN_Transmit(CAN1, &TxMsg) == CAN_TxStatus_Failed) { return 1; } return 0; }这个实现里有两个容易被忽略的点。第一,CANopen 的这些通信对象地址都落在标准帧的 11 位 ID 范围内,用标准帧就够了,不要开扩展帧;第二,CAN_Transmit返回值只判断失败状态,协议栈只看返回值是 0 还是非零,零表示成功。实际工程里我会在返回失败前加一个错误计数,发送失败次数持续增长时,多半是总线被占满或没有终端电阻。
3.3 接收中断与 canDispatch 入口
从站收到 CAN 帧后,应尽快把帧内容取走并解析,否则 FIFO 溢出会丢帧,导致 SDO 超时或者 PDO 数据跳变。典型写法如下:
void USB_LP_CAN1_RX0_IRQHandler(void) { CanRxMsg rx; CAN_Receive(CAN1, CAN_FIFO0, &rx); Message m; m.cob_id = rx.StdId; m.rtr = (rx.RTR == CAN_RTR_REMOTE) ? 1 : 0; m.len = rx.DLC; memcpy(m.data, rx.Data, 8); canDispatch(&m); }这里把CAN_Receive放在中断里直接执行,是因为 bxCAN 接收 FIFO 只有 3 个槽位,处理太慢必有丢帧。canDispatch内部会先按 COB-ID 判断帧类型,NMT、SDO、PDO、心跳分别走不同处理函数,因此中断函数里不要做任何业务判断,把完整的 Message 交进去即可。需要注意memcpy虽然只拷 8 字节,但如果应用里还开了其他高优先级中断,要在 NVIC 里把 CAN 接收中断优先级安排合理。
3.4 时基:让协议栈的定时链表跑起来
CANfestival 的 PDO 周期发送、心跳周期、SDO 超时都依赖一个非阻塞的毫秒时基。我一般用 TIM3 定时器,配置成 1ms 或 10ms 中断,每进一次中断调一次TimeDispatch_Update()。这个函数会遍历协议栈内部的定时链表,把到期的定时器逐个执行。
void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); TimeDispatch_Update(); } }TimeDispatch_Update()的参数不绑定具体硬件,定时器中断配成 1ms 就按 1ms 的时间基准驱动协议栈。不要在中断里做浮点运算或长耗时操作,否则定时器抖动会让心跳周期不稳定,主站可能误判从站离线。更不要用HAL_Delay去驱动它,因为协议栈内部 SDO 分段传输的超时计算会被阻塞打断。
3.5 上电初始化:从 CAN 外设到 CANopen 协议栈
初始化顺序影响节点上线后的行为,我的常见做法是:
int main(void) { CAN_Init(500); // 波特率 500kbit/s Timer_Init(); initTimer(); canOpen(5, 0, 0, 0, &objdict_Data); // 节点 ID = 5 setNodeId(5); setState(Operational); // 调试阶段先强制进操作态 while (1) { // 主循环只做采集与业务逻辑 } }canOpen的第一个参数是节点 ID,节点 ID 不能为 0,也不能超过 127;CANopen 的 COB-ID 计算都以它为基础,比如节点 5 的生产 PDO 就是 0x185。调试时先setState(Operational)能立刻看到心跳和 PDO,正式接入别人的主站网络时,通常要等主站下发 NMT 启动命令,此时不要自己去抢占主站职责。初始化里还要记得把 CAN_H、CAN_L 之间接 120 欧姆终端电阻,总线两头各一个,没有终端电阻时 SDO 访问经常时好时坏。
4. 用 Objdictgen 配置对象字典,在 STM32F1 上跑通 PDO 与 SDO
4.1 Objdictgen 生成字典文件的工作流
Objdictgen 是一个图形化工具,操作流程是固定的:先新建一个从站字典文件,设置节点 ID、波特率,然后在 0x2000 开始的制造商区域添加自定义对象,类型可以选 uint8、int16、uint32 等;再把需要周期传输的对象映射到一个或多个 TPDO 条目里,保存为.dcf文件,最后导出 C 文件。生成的objdict_xxx.c与objdict_xxx.h直接加入 STM32F1 工程,替换默认字典文件即可。
导出字典时有两点会直接影响后续调试。第一,PDO 映射的默认传输类型如果要设成周期型,要在对象 0x1800/0x1A00 里配置好,生成后再手工改容易和 Objdictgen 对不上;第二,字典里定义的变量名(例如objdict_Data)要和工程 include 的一致,换字典文件时最容易错的就是头文件里的宏定义不一致,编译报错会指向结构体字段找不到。
4.2 把 DHT11 温湿度采集结果写进对象字典
前面说过,应用层的数据最终都落在对象字典里。这里用一个常见的场景:STM32F1 读取 DHT11 温湿度传感器,把温度湿度写入 0x2000 和 0x2001,然后通过 PDO 发出去。DHT11 是单总线器件,读取时序由 STM32F1 的 GPIO 模拟,读到的温度是整数,湿度是整数百分比,直接用 int16 存。
#include "objdict_xxx.h" extern CO_Data objdict_Data; void dht_task(void) { if (dht11_read(&temp, &hum) == 0) { INTEGER16 t = temp; UNS8 h = hum; setODentry(&objdict_Data, 0x2000, 0x01, &t, sizeof(t)); setODentry(&objdict_Data, 0x2001, 0x01, &h, sizeof(h)); } }setODentry是写字典的标准入口,参数依次是协议栈数据指针、索引、子索引、值指针、值的长度。之所以要先取出来再写字典,而不直接写objdict_Data的成员数组,是因为对象字典内部可能存在字节序修正,走setODentry可以保证 SDO 客户端读到的数据和写入时一致。反过来,读取对象字典用getODentry拿到数据,不要在业务线程里直接访问结构体成员,否则换字典工具重新生成后这里很容易编译失败。
4.3 PDO 发送:周期上报温湿度
对象字典写好了,还需要让 PDO 周期地把数据送出去。CANfestival 的pdo.c会在TimeDispatch_Update()被调用时检查 PDO 定时器,并在传输类型为周期型时自动发送。如果配置里没有自动发送,也可以在应用代码里手动触发:
void send_temp_hum_pdo(void) { checkPDOEvent(&objdict_Data); }checkPDOEvent会遍历所有 PDO 配置,检查映射的对象是否有更新,然后构造 CANopen 标准 PDO 帧并交给canSend发送。发送 COB-ID 由 0x1800 通信参数和节点 ID 计算而来,例如节点 5 的 TPDO1 就是 0x185,不需要在应用代码里拼 COB-ID。需要说明的是,PDO 单帧最多 8 字节,所以一个 PDO 最多映射 4 个 16 位数据,如果要传 32 位转速值,一个 PDO 就只能映射两个。
4.4 PDO 接收:电机控制字从总线到 PWM 输出
如果说 PDO 发送是把设备状态推出去,PDO 接收就是消费来自主站的控制数据。下面以控制一个直流电机为例:主站通过 RPDO 下发目标转速和控制字,STM32F1 的定时器 PWM 输出驱动 MOS 桥,实现调速。接收到的 PDO 数据会自动写入对应的对象字典。
UNS16 target_speed; UNS8 ctrl_word; void motor_control_task(void) { getODentry(&objdict_Data, 0x2002, 0x01, &target_speed, sizeof(target_speed)); getODentry(&objdict_Data, 0x2003, 0x01, &ctrl_word, sizeof(ctrl_word)); if (ctrl_word & 0x01) { TIM_SetCompare1(TIM1, target_speed); } }CANfestival 的 PDO 接收解析在协议栈内部完成,消费 PDO 数据自动写入对象字典,应用层只需要用getODentry读取。这里要提醒的是,RPDO 没有应答机制,主站发完即不管,因此从站单方面通信故障时不会立刻知道。需要可靠传输的启停类命令,建议走 SDO;功率类参数用 PDO 即可,这和电机驱动场景是匹配的。
4.5 用 SDO 读写从站参数,配置心跳时间
主站需要修改心跳时间或 PDO 映射时,使用 SDO 写入对应对象。以修改从站心跳周期为 200ms 为例,在 CAN 调试工具上发送:
发送 COB-ID 0x605,数据 2B 17 10 00 C8 00 00 00这串数据的含义是:0x605 是 SDO 客户端请求 COB-ID(0x600+节点 5),2B表示写 16 位长度的快速写入命令,17 10是小端索引 0x1017,00是子索引 0,C8 00是 200 的 little-endian 表示。从站回复 COB-ID 0x585,数据60 17 10 00 00 00 00 00表示写入成功。理解这个字节布局对排查 SDO 问题非常关键,很多线上故障其实是命令字或字节序写错了。
5. 快速验证 CANopen 从站:测试主站、SDO 探针与排错技巧
5.1 用测试主站脚本快速验证协议栈是否通了
CANfestival 源码里自带的测试主站脚本是一个现成的验证入口。常见做法是在 PC 上接一个 CAN 卡,用 Python 脚本或通用 CAN 调试工具配合收发。把 STM32F1 从站的节点 ID 设为 5,上电后先发一条 NMT 启动命令,然后读从站对象字典 0x1000。能读到设备类型就说明协议栈核心链路是通的,再读 0x2000 的数据,就能确认对象字典映射是否配置错。整个验证过程不需要写应用层代码,比对着示波器看波形更快。
5.2 一个被踩得最多的坑:主站没发启动,从站 PDO 先飞了
CANopen 从站在预操作态下是不允许发送 PDO 的,但很多移植代码为了图验收方便,把setState(Operational)写在初始化函数里,这让从站上电就自动运行。坏处在多主站或者主站做安全控制时会直接破坏网络管理:主站明明没有启动它,它的 PDO 却一直在总线上占用带宽。正确做法是让从站停在预操作态,等收到 0x000 的启动命令后再进入操作态,例如在主循环里检查getState(&objdict_Data) != Operational时,仅允许应用层处理心跳和 SDO。
5.3 给对象字典加一个调试入口,验证 PDO 链路
如果从站在总线上能发心跳但 PDO 不刷新,问题大概率不在协议栈,而在 PDO 映射参数。可以临时在字典里加一个 0x2004 的调试计数器,每发送一次 PDO 就通过setODentry累加,然后用 SDO 实时读它。计数器不涨,说明 PDO 压根没触发;计数器涨而主站收不到,才是 CAN 驱动发送侧的问题。把调试计数器映射到 0x2004,用 SDO 循环读取,主站读到的计数值会跟 PDO 发送次数严格一致,不一致时再看 CAN 控制器 TEC/REC 错误寄存器,链路问题基本能定位。
本文还有配套的精品资源,点击获取