搞工控或者做嵌入式设备通信的同行,应该都有这种感觉:项目一旦从单机设备走向联网控制,通信协议的选择就变得非常关键。早年做RS485串口设备,Modbus协议凭借实现简单、资料多,几乎是默认选项。但到了多节点、实时性要求高的场景,比如伺服驱动器、IO模块、传感器网关集群,Modbus的轮询机制和主从架构就有点吃力了。CANopen这种基于CAN总线的应用层协议,凭借事件驱动、节点间直接通信、完善的网络管理机制,在工业现场的地位一直很稳。
这次想聊的就是在STM32平台上移植CanFestival协议栈这件事。CanFestival是一个开源的CANopen协议栈实现,源码结构清晰,支持从站和主站功能,网上资料也不少,但真正动手移植时,对象字典怎么配置、底层CAN接口怎么对接、定时器怎么处理,这些坑一踩就是半天。这篇文章会把整个移植过程完整走一遍,从源码结构、工程搭建、驱动适配到通信测试,把关键代码和注意事项都写清楚,给准备做CANopen从站设备的朋友做个参考。
1. 移植之前的功课:CanFestival到底是个什么结构
1.1 为什么选CanFestival而不是CANopenNode
目前开源的CANopen协议栈里,比较常见的就是CanFestival和CANopenNode这两家。CANopenNode代码比较现代,在Linux和独立MCU上都有移植案例,架构划分也更清晰。但如果你用的是STM32这种资源不算特别宽裕的MCU,CanFestival的体量和移植方式反而更直接。
CanFestival的核心代码是用C语言写的,不依赖操作系统,也不需要动态内存分配,对象字典可以静态声明。它的源码里带着三个完整示例,对应不同平台(stm32、lpc、avr等),虽然版本老一点,但代码逻辑非常直白,适合用来做二次开发。另外一个让我选它的理由是,CanFestival文档和网上讨论的存量很大,遇到问题搜得到答案,这点在项目排期紧张的时候非常重要。
CanFestival整个协议栈分为两层:上层是协议逻辑,包括对象字典管理(objacces)、SDO服务器(sdo)、PDO收发(pdo)、NMT节点管理(nmt)、心跳和节点守护(lifegrd)、紧急报文(emcy)这些模块;下层是驱动适配层,需要你根据MCU的CAN外设去实现几个具体函数。这个分层设计是它移植方便的根本原因,把标准逻辑和硬件操作拆开了,你只需要关心驱动层的接口怎么填。
1.2 源码目录和核心文件:移植前先把地图看明白
拿到CanFestival源码后,先别急着往工程里拖文件,花半小时把目录结构摸清楚,后面能少走很多弯路。核心源码在 src 和 include 目录下,里面每个C文件对应一个功能模块:
- objacces.c:对象字典读写接口,SDO和PDO处理都会回调到这里,是整个协议栈的数据中心。
- sdo.c:SDO服务器(从站模式)实现,处理主站的索引/子索引读写请求。
- pdo.c:PDO的映射管理和报文收发逻辑,负责把对象字典里的变量映射到CAN报文里。
- nmt.c:NMT节点管理,处理启动、停止、预操作等状态切换命令。
- lifegrd.c:心跳报文和节点守护报文的处理。
- emcy.c:紧急错误报文的生成和发送。
- timer.c:软件定时器管理,CANopen的PDO事件定时、心跳周期都依赖它。
- sync.c:SYNC同步报文的处理,做多轴同步运动控制时会用到。
- lss.c:LSS(Layer Setting Services)服务,用于节点ID和波特率动态配置,一般用不到时可以关掉。
除了这些核心模块,源码里还有两个必须关注的头文件:canfestival.h是全局配置头文件,编译开关都在这里;ObjDict.h和ObjDict.c则是对象字典文件,由对象字典编辑器生成或者手动编写,对应你自己的设备描述。
移植的时候并不需要把所有模块都编译进工程。比如你做一个简单从站,没有动态节点配置需求,就可以把LSS模块去掉,通过canfestival.h里的宏来裁剪代码,减小Flash占用。
2. 搭建移植环境与工程框架
2.1 工程结构规划:哪些文件直接复制,哪些必须自己写
我比较习惯的工程组织方式是这样的:把CanFestival源码单独放在一个目录,不跟应用代码混在一起,这样后续升级协议栈版本或者换平台的时候,改动范围是可控的。
例如,把源码放在:
Project/ ├── Core/ # STM32标准外设库或HAL库代码 ├── canfestival/ │ ├── include/ # 协议栈头文件 │ ├── src/ # 协议栈核心C文件 │ └── drivers/ │ ├── can_interface.c/h # 自己写的CAN驱动适配 │ ├── timer_interface.c/h # 自己写的定时器驱动适配 │ └── objdict.c/h # 对象字典文件 ├── App/ │ ├── main.c │ └── canopen_app.c/h └── MDK-ARM/ # Keil工程文件直接复制到工程里编译的协议栈文件包括:objacces.c、sdo.c、pdo.c、nmt.c、lifegrd.c、emcy.c、timer.c、sync.c(按需)。需要自己编写的只有两个接口文件:can_interface.c 和 timer_interface.c。这样算下来,整个移植工作的核心就是写清楚这两组接口。
在 Keil 工程配置里,记得把 canfestival/include 和 canfestival/drivers 目录加到头文件搜索路径,然后把上面列出的C文件添加进工程。编译选项里 C99 模式要打开,因为 CanFestival 源码里用了少量C99特性,比如声明与语句混合。
2.2 对象字典配置:用编辑器生成还是手写
对象字典是CANopen设备的核心,一个设备能对外提供什么数据、能接收什么配置,全都由它决定。CanFestival官方提供了对象字典编辑器(ObjectDictEditor),基于Python,可以可视化地添加索引、子索引,然后自动生成ObjDict.h和ObjDict.c。但这个工具依赖关系有点复杂,运行起来偶尔会闹脾气。
如果只是做一个简单设备,我建议了解一下对象字典的结构,直接手写其实也不难。下面是一个最精简的对象字典定义示例:
#include "objdictdef.h" const indextable ObjDict_objdict[] = { { 0x1000, /* 设备类型索引 */ 0, /* 子索引0 */ {0, 0, 0}, {0, 0, 0}, 0, 0 }, { 0x1001, /* 错误寄存器索引 */ 0, {0, 0, 0}, {0, 0, 0}, 0, 0 }, /* ... 其他索引 ... */ { 0, 0, 0, 0, 0, 0 } // 结束标志 };真正做项目时,我建议用固定的模板。打开官方给的stm32示例里的ObjDict.c,在这个基础上增删索引,比从零手写稳定得多。对象字典里每个索引都有对应的类型和访问权限,比如0x1000是设备类型,0x1017是心跳生产时间,0x1018是设备标识,0x2000往上通常是厂商自定义的应用对象。这些标准对象在CiA 301和CiA 302规范里有明确定义,需要对照着来,不能随便改含义。
2.3 关键宏配置:编译开关决定功能裁剪
canfestival.h里有一组宏定义,控制着协议栈的编译行为。移植时这些宏一定要核对清楚:
- CAN_BITTING_xxx:波特率相关配置,其实就是调用驱动里的初始化函数时用到的参数,跟STM32的CAN外设配置是分开的。
- CANOPEN_NODE_ID:默认节点ID,范围1~127。如果打算通过拨码开关或上位机配置节点ID,这里定义一个默认值即可,运行时再调用setNodeId改。
- FEATURE_SDO / FEATURE_PDO / FEATURE_EMCY:功能裁剪开关。如果确定设备用不到PDO,可以把PDO功能关掉,省一点RAM。
- ENABLE_LSS:LSS功能开关,默认是开着的。如果你的从站设备节点ID是固定的或者通过硬件拨码设置的,可以把这个功能关掉,减少代码量。
- MULTI_NMT:多节点支持,从站模式下通常关掉。
这部分配置就是在给协议栈做体检,把用不到的模块裁掉,编译出来的固件体积更小,运行时的调度开销也更低。
3. 底层驱动适配:CAN和定时器是协议栈的两条腿
3.1 实现canSend:发送路径要避开哪些坑
CanFestival要往总线上发包时,会调用一个函数:UNS8 canSend(CAN_PORT notused, Message *m)。这个函数需要把CanFestival的Message结构体转换成STM32的CAN消息格式,然后填充到外设发送邮箱。
Message结构体定义大概是这样的:
typedef struct { UNS16 cob_id; // CAN报文ID(含功能码) UNS8 rtr; // 远程帧标志 UNS8 len; // 数据长度(0~8) UNS8 data[8]; // 数据 } Message;而STM32 HAL库的CAN发送接口需要两个结构体:CAN_TxHeaderTypeDef 和 一个8字节数据数组。转换关系如下:
#include "can_interface.h" #include "main.h" extern CAN_HandleTypeDef hcan1; UNS8 canSend(CAN_PORT notused, Message *m) { CAN_TxHeaderTypeDef txHeader; uint8_t txData[8]; uint32_t txMailbox; uint8_t i; txHeader.ExtId = 0; txHeader.IDE = CAN_ID_STD; txHeader.RTR = m->rtr ? CAN_RTR_REMOTE : CAN_RTR_DATA; txHeader.DLC = m->len; txHeader.StdId = m->cob_id; /* 将CanFestival的报文数据拷贝到HAL库的数据数组 */ for (i = 0; i < m->len; i++) { txData[i] = m->data[i]; } for (i = m->len; i < 8; i++) { txData[i] = 0; } if (HAL_CAN_AddTxMessage(&hcan1, &txHeader, txData, &txMailbox) != HAL_OK) { return 1; /* 非0表示发送失败 */ } return 0; }这块有个小坑,CAN外设的发送邮箱数量是有限的,如果调用很频繁而邮箱还没释放,HAL_CAN_AddTxMessage会返回HAL_ERROR。CanFestival的事件表机制不擅长处理发送失败的重试,所以实际项目中我通常会在驱动层面做一次简单的排队发送:如果返回错误,就把消息缓存到一个环形缓冲区,等CAN发送完成中断里再补发。这个逻辑不复杂,但对通信稳定性提升很明显。
3.2 实现canReceive:中断接收的完整处理
接收方向,CanFestival用一个 dispatch 函数来分发报文:canDispatch(CAN_HANDLE fd, Message *m)。在STM32上,标准做法是在CAN接收中断里读取数据,转成Message结构体后调用canDispatch。
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan) { CAN_RxHeaderTypeDef rxHeader; uint8_t rxData[8]; Message m; uint8_t i; if (hcan->Instance == CAN1) { HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &rxHeader, rxData); m.cob_id = rxHeader.StdId; m.rtr = (rxHeader.RTR == CAN_RTR_REMOTE) ? 1 : 0; m.len = rxHeader.DLC; for (i = 0; i < m.len; i++) { m.data[i] = rxData[i]; } /* 交给协议栈处理 */ canDispatch(CAN_PORT0, &m); } }这里需要提前在main函数里开启CAN接收中断:
HAL_CAN_ActivateNotification(&hcan1, CAN_IT_RX_FIFO0_MSG_PENDING);如果用的是STM32的标准外设库而不是HAL库,处理方式也差不多,核心都是把中断收到的数据组帧后交给canDispatch。注意,如果MCU的Flash比较紧张,可以把中断优先级设得高一点,但不要在中断里做耗时操作。canDispatch本身处理速度很快,但如果你的SDO请求比较复杂,涉及到对象字典回调函数,建议把canDispatch放进主循环里轮询,中断只负责把消息存到缓冲区,这样更稳。
3.3 定时器接口:TimeCAN的玄机
CanFestival的定时器体系由三个函数组成,它们共同协作,为协议栈提供了时间基准:
- void setTimer(TIME_VALUE value):设置一个定时器,value单位是毫秒,表示从现在开始value毫秒后触发。协议栈内部会维护一个事件表,到时间后执行对应的回调。
- TIME_VALUE getElapsedTime(void):返回自上次调用以来经过的时间(毫秒)。
- TIME_VALUE TimeCAN(void):返回当前时间戳(毫秒)。
这三个函数的核心其实就是一个单调递增的毫秒计数。我的实现思路是用STM32的一个基本定时器产生1ms中断,在中断里累加一个全局变量:
static volatile uint32_t timertick = 0; /* 在定时器中断服务函数中调用 */ void TimerTick_Handler(void) { timertick++; }然后基于这个tick实现三个定时器函数:
void setTimer(TIME_VALUE value) { /* 清除定时器计数,让getElapsedTime从0开始计时 */ timer_alarm_target = value; timer_alarm_start = timertick; } TIME_VALUE getElapsedTime(void) { return (TIME_VALUE)(timertick - timer_alarm_start); } TIME_VALUE TimeCAN(void) { return (TIME_VALUE)timertick; }你可能要问了,为什么需要两个函数 setTimer 和 getElapsedTime 配合,而不是直接给一个绝对时间?这是CanFestival事件表的机制决定的,协议栈内部会在一个循环里先setTimer设置一个相对超时时间,然后不停轮询getElapsedTime检查是否超时。如果timeout到了,协议栈就会触发超时处理并执行下一个事件。
因此timer_interface.c里的实现不需要真正创建操作系统定时器,只要保证毫秒计数准确就行。用SysTick也可以实现,但注意不要在SysTick_Handler里调用协议栈函数,容易造成重入问题。
4. 启动流程与心跳:连上主站之前别裸奔
4.1 初始化顺序:先底层后协议栈
移植完成后,主程序里的初始化顺序是有讲究的。顺序错了,通信就可能莫名其妙失败。
推荐的启动流程如下:
int main(void) { /* 1. 初始化底层硬件 */ HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_CAN1_Init(); /* CAN外设初始化,配置波特率等 */ MX_TIM_Init(); /* 定时器初始化,用于协议栈时基 */ CAN_Filter_Config(); /* 配置CAN接收过滤器 */ /* 2. 启动定时器,开始提供毫秒Tick */ HAL_TIM_Base_Start_IT(&htimx); /* 3. 启动CAN外设 */ HAL_CAN_Start(&hcan1); HAL_CAN_ActivateNotification(&hcan1, CAN_IT_RX_FIFO0_MSG_PENDING); /* 4. 初始化CanFestival协议栈 */ __init(&ObjDict_Data, CANOPEN_NODE_ID, 0, 0); /* 5. 设置节点ID(如果支持运行时修改) */ setNodeId(&ObjDict_Data, CANOPEN_NODE_ID); /* 6. 设置状态为操作状态 */ setState(&ObjDict_Data, Operational); /* 7. 配置心跳并启动 */ setHeartbeatTime(&ObjDict_Data, 100); /* 100ms一次心跳 */ while (1) { /* 主循环:驱动心跳、定时器事件处理 */ CanFestival_Process(); } }这个顺序的逻辑是:必须先让底层收发能力就绪,再初始化协议栈。__init函数会清空对象字典并初始化状态机,此时如果CAN还没准备好,后续收发就会失败。setState命令让节点进入Operational状态,这样主站就可以直接进行SDO访问和PDO交互了。
如果主站是先发送NMT启动命令让节点进入Operational模式,那么也可以在Initialisation状态等待NMT命令。不过自己在程序里直接置Operational也是常见的做法,取决于整个系统的启动策略。
4.2 心跳发送与节点守护
CANopen网络管理里有一个重要的概念:主站通过心跳报文监控从站是否在线。从站必须周期性发送心跳报文,主站如果在一段时间内收不到,就会判定该节点离线。
CanFestival里控制心跳周期的核心对象字典索引是0x1017,单位是毫秒。可以通过SDO命令动态修改,也可以在程序里用setHeartbeatTime接口直接设置。我的建议是最小值和默认值都由对象字典描述文件定义好,但程序里在启动后主动设置一次,确保使用预期值。
还有一个相关功能是节点守护(Node Guarding),它是基于请求-响应的模式,主站发远程帧请求,从站响应。这个机制现在用得越来越少了,心跳模式更主流。CanFestival两个都支持,具体用哪个取决于你的主站配置。如果用的是PLC从站组态,通常心跳模式更省心。
4.3 用PC主站工具做第一次通信
移植完成后,最好先做个简单的上位机联调,确认协议栈工作正常。常用的工具是BusMaster和CANopenMagic,国产的USBCAN分析仪基本都自带上位机,也支持CANopen协议。如果没有这些工具,用串口转CAN分析仪加一个Linux下的candeladump/scandump来抓CAN帧也可以,但调试效率会低不少。
联调步骤很简单:
- 把STM32开发板接上CAN收发器(比如TJA1050),连到USBCAN分析仪。
- 上位机配置波特率,必须跟STM32的CAN波特率一致(常见的是250kbps或500kbps,我用250k比较多,传输距离长一点)。
- 让节点进入Operational状态(如果程序里已经自动进入,跳过这一步)。
- 用上位机发送SDO读取命令,读对象字典0x1000设备类型,看返回值是否正常。
- 再读0x1017心跳周期,确认返回你设置的值。
- 观察CAN总线上是否有周期性心跳报文,检查COB-ID是否正确(心跳COB-ID = 0x700 + 节点ID)。
第一次通信成功,说明移植的核心链路已经跑通了。
5. 常见问题与调试技巧实录
5.1 收不到任何报文:先查这四件事
这个问题出现的频率最高,90%的情况躲不开这四类原因:
第一,CAN收发器电路问题。最常见的是缺少终端电阻或者接反了CANH/CANL。CAN总线至少需要两端各接一个120欧姆的终端电阻,如果节点数很少又忘了接电阻,波形信号反射严重,通信就起不来。
第二,波特率不一致。这算老生常谈,但每次都能碰到几个。检查STM32的CAN外设初始化代码里的分频和同步跳转宽度配置,以及主站的波特率设置,确保两边一致。用示波器看CAN_H和CAN_L的位宽,能直接确认实际波特率。
第三,过滤器配置错误。STM32的CAN外设支持硬件过滤器,如果过滤器把所有报文都屏蔽了,协议栈自然收不到任何东西。新手容易在配置过滤器时把屏蔽位设成全1,导致只接收ID完全等于设定值的报文,而CANopen的COB-ID种类很多,这样会把大部分报文过滤掉。
建议过滤器配置为接收所有标准帧,编码方式如下:
void CAN_Filter_Config(void) { CAN_FilterTypeDef filter; filter.FilterBank = 0; filter.FilterMode = CAN_FILTERMODE_IDMASK; filter.FilterScale = CAN_FILTERSCALE_32BIT; filter.FilterIdHigh = 0x0000; filter.FilterIdLow = 0x0000; filter.FilterMaskIdHigh = 0x0000; filter.FilterMaskIdLow = 0x0000; filter.FilterFIFOAssignment = CAN_RX_FIFO0; filter.FilterActivation = ENABLE; HAL_CAN_ConfigFilter(&hcan1, &filter); }第四,中断没开启或者优先级配置有问题。检查NVIC里CAN接收中断是否使能,以及HAL_CAN_ActivateNotification是否调用。如果中断被其它高频中断饿死了,也会出现收不到报文的情况。
5.2 SDO读写失败:对象字典索引核对
如果心跳正常、NMT状态也正常,但SDO读写就是失败,通常问题出在对象字典的定义上。
常见原因包括:
- 对象字典里根本没有定义目标索引。用对象字典编辑器生成时,如果漏了某个索引,主站访问时协议栈会回复SDO abort报文,错误码通常是0x06020000(对象不存在)。
- 访问权限不对。有些索引是只读的,主站尝试写就会失败。需要检查对象字典中该索引的ACL(访问控制列表)标志位。
- 子索引范围不对。SDO请求里如果子索引超出范围,也会返回abort。像0x1000这种没有子索引的对象,子索引固定是0。
- 跨字节数据大小端问题。CANopen在传输16位、32位数据时使用Little-Endian字节序。如果上位机主站配置成Big-Endian,读出来的值就会被打乱。这个不是协议栈的bug,但要心里有数。
调试SDO时,用USBCAN工具的PDO/SDO调试窗口,直接发送SDO请求帧,收到响应后看数据是否符合预期,排查速度比在代码里加打印快得多。
5.3 运行一段时间后通信卡死
通信刚开始正常,跑几个小时或者高负载下突然就卡死的案例也有。这种情况基本是资源管理问题,排查思路分几步。
第一,检查发送失败重试逻辑有没有处理。前面提到,STM32的CAN发送邮箱只有3个,如果某一段时间内PDO事件密集、心跳、同步、紧急报文都在抢邮箱,就会发生发送失败。如果没有在HAL_CAN_TxMailboxCompleteCallback里补做发送队列,报文就会悄悄丢,协议栈状态也会受影响。
第二,查看是否开启了总线关闭(Bus-Off)恢复。在强干扰环境下,CAN控制器会出现总线关闭错误。HAL库提供了HAL_CAN_ErrorCallback,可以在里面做总线恢复处理。我一般会在错误回调里重新HAL_CAN_Start,让总线快速恢复工作。
第三,检查是在中断里直接调用了协议栈函数导致重入问题。尤其在你使用了外部事件处理回调,比如SDO的写入回调函数里又去调用协议栈发送接口时,有可能把核心状态机搞乱。推荐的做法是:中断里只做标志位置位和数据缓存,协议栈的后续处理全部放到主循环。
5.4 针对特定外设的Bug:STM32的BSRR误解与CAN_FIFO溢出
有人可能会在实现canSend时把CAN_TxHeaderTypeDef里IDE位配置成CAN_ID_EXT,而报文ID却是标准帧。这会导致所有发出的报文全部被主站丢弃。这个问题不大,但排查起来很无语,因为总线分析仪上能看到报文一直在发,就是没有任何响应。
另外,CAN的FIFO溢出也是一个容易忽略的地方。如果CAN接收FIFO满了,后续报文会被硬件丢弃。可以开启FIFO满中断,并及时清空FIFO,或者在HAL_CAN_RxFifo0MsgPendingCallback里尽快把数据读走。需要留意的是,如果调试串口中断优先级比CAN接收中断高很多,且打印频率很高,会拖延CAN FIFO的释放。
6. 移植完成后还能做什么
基础从站通信打通以后,CanFestival这套协议栈的潜力还有很多可以挖掘的地方。
如果你的设备需要和上位机动态交互大量数据,可以把对象字典里的应用对象扩展出来,每个应用对象对应一个SDO索引或者PDO映射通道。比如把ADC采样值映射到TPDO1,主站配置好PDO参数后,采样数据会周期自动上传,不需要每次都发SDO请求,效率会高很多。
如果项目里有多个电机,需要同步运动,可以用SYNC报文配合PDO来实现周期同步。CanFestival的SYNC模块在收到主站SYNC帧后,会立即触发对应PDO的发送,这样各节点的采样和输出能在同一时刻对齐。
如果后续要把协议栈往别的平台迁移,比如从STM32F1迁到STM32H7或者GD32,只需要重新实现can_interface.c和timer_interface.c这两个文件,协议栈的其它部分几乎不用动。把这两个接口的代码写规范一点,注释写清楚,将来切换平台会非常省事。
根据我实际使用的经验,CanFestival虽然看着有些年头了,但稳定性并不差,在工业设备上跑起来很可靠。关键是把对象字典设计好,驱动层不要偷懒,该做的发送排队和总线恢复机制都做上。这样设备挂在复杂的CANopen网络里,才能真正扛得住生产环境的考验。
这次移植的完整代码已经整理好,CAN驱动适配、定时器实现、对象字典配置这些都有。如果你正在折腾CANopen从站设备,可以直接拿这套代码当底板,在这个基础上改应用逻辑就行。