
搞工控或者做嵌入式设备通信的同行应该都有这种感觉项目一旦从单机设备走向联网控制通信协议的选择就变得非常关键。早年做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这种资源不算特别宽裕的MCUCanFestival的体量和移植方式反而更直接。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.cSDO服务器从站模式实现处理主站的索引/子索引读写请求。pdo.cPDO的映射管理和报文收发逻辑负责把对象字典里的变量映射到CAN报文里。nmt.cNMT节点管理处理启动、停止、预操作等状态切换命令。lifegrd.c心跳报文和节点守护报文的处理。emcy.c紧急错误报文的生成和发送。timer.c软件定时器管理CANopen的PDO事件定时、心跳周期都依赖它。sync.cSYNC同步报文的处理做多轴同步运动控制时会用到。lss.cLSSLayer 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_LSSLSS功能开关默认是开着的。如果你的从站设备节点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 针对特定外设的BugSTM32的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从站设备可以直接拿这套代码当底板在这个基础上改应用逻辑就行。