
1. 为什么选CanFestival协议栈选型与整体思路1.1 CANopen很成熟但开源协议栈其实不多做嵌入式这几年只要产品往工业方向走几乎绕不开CANopen。伺服驱动器、IO模块、传感器、工程机械控制器、医疗设备这些设备之间要组成一套可靠的控制网络CANopen是性价比最高的方案之一。它的主站一般是PLC或者运动控制器从站是各种现场设备而我们用STM32做的往往就是从站。协议栈怎么选市面上有商业协议栈也有开源方案。商业的稳定、服务好但授权费用对于个人项目或者小公司来说不算便宜开源的方案我接触过几个最容易上手的还是CanFestival。这个项目最初是SourceForge上的开源项目后来大量开发者把它移植到STM32、ARM7等平台代码基本全是C语言结构清晰对象字典生成工具体验也还算顺手。学习资料和示例代码都相对好找GitHub上有非常多基于它的二次开发仓库遇到问题也容易搜到答案。这里我想先给一个提醒移植CanFestival不是把整个源码读一遍再动手而是把它当成一套已经能跑的逻辑框架我们只需要干好两件事给它提供稳定的时钟节拍再给它提供能收发CAN帧的硬件接口。协议栈自己会搞定NMT状态机、SDO、PDO、心跳、同步、紧急报文等一堆事情。想清楚这一点移植的难度就降了一大半。1.2 移植的边界你只需要接好水电用搬家打比方CanFestival是一套已经装修好的房子NMT是门口的保安SDO是文件柜管理员PDO是快递员对象字典是储物清单。你要做的不是重新盖楼而是把水电管线接好——水管是定时器电线是CAN收发接口。具体到代码层面CanFestival预留了几个关键函数移植就是把这些函数从空壳子填成STM32能跑的实现主要包括这几个定时器抽象层initTimer()、setTimer()、getElapsedTime()、TimeDispatch()CAN抽象层canSend()、canDispatch()不要被这些名字吓到实际实现起来量很小。先把这几个函数跑通后面再加应用逻辑比如PDO发送、对象字典读写。1.3 源码目录和关键文件说明拿到CanFestival源码后主要关注这几个部分src/目录协议栈核心包括timer.c定时器管理、can.cCAN帧分发、sdo.c、pdo.c、nmtSlave.c、nmtMaster.c、heartbeat.c、sync.c、emcy.c、objacces.c对象字典访问、lss.cLayer Setting Services可选。include/目录各种头文件canfestival.h是总入口applicfg.h是平台适配配置data.h定义数据结构。examples/目录官方示例里面有现成的OD对象字典文件新手可以直接拿来做模板。移植时一般不用改动src/里的文件只要把自己的platform相关文件写好把头文件路径、宏定义配置好整个协议栈就能编译进工程。这也是CanFestival一个很大的优点分层做得比较规整平台相关的部分被集中隔离了。2. 移植前准备环境、工具和硬件资源规划2.1 硬件平台和软件开发环境建议我的推荐配置是STM32F103C8T6或者STM32F407VET6这类自带CAN控制器的芯片。F103是入门经典资料多网上能搜到大量参考工程F407性能更强适合后续想做复杂节点或者主站的场景。开发环境用MDKKeil或者STM32CubeIDE都行我习惯用MDK编译速度快调试CAN中断也方便。硬件上必须接一颗CAN收发器最常用的是TJA10503.3V供电的MCU需要收发器支持3.3V逻辑电平TJA1050可以直接用。注意CAN总线两端一定要接120欧终端电阻不然通信会偶发错误帧排查起来极其痛苦。调试工具强烈建议准备一个USB-CAN分析仪比如周立功的USBCAN系列或者几十块钱的兼容版没有它调试CANopen几乎等于盲人摸象。2.2 对象字典工具用OD Generator从零建模型CanFestival提供了对象字典生成工具objdictgen目录下运行Python脚本就能启动。工具界面比较老但功能完整能编辑索引、子索引、数据类型、访问权限、默认值等。我的建议别一上来就空手建OD先复制官方examples目录里的一个从站OD比如TestSlave在里面改。为什么因为OD是一个极其庞大的结构体手写很容易漏字段工具生成的代码结构最规范。拿到示例后我可以删掉不需要的对象再添加自己需要的用户对象。举个实际例子我在项目里需要一组16位的控制字和一组16位的状态字就在工具里添加两个用户对象索引分别设为0x2000和0x2001类型设成INTEGER16或者UNS16看需求访问权限设为读写。然后把TPDO1的映射表指到0x2000的对应子索引RPDO1的映射指到0x2001。生成后工具会输出OD.c和OD.h两个文件直接添加到工程里用。2.3 CAN和定时器资源规划这一步要在写代码前想清楚否则后面容易返工。先说定时器。我常用的方案是用一个通用定时器TIM4产生1ms中断中断里做两件事维护一个毫秒计数器然后调用TimeDispatch()。为什么选TIM4而不是SysTick因为SysTick通常被RTOS或者延时函数占用而且通用定时器可以后续单独调整优先级。当然如果你裸机跑且确定SysTick空闲用它也可以但个人经验是单独定时器更干净、更不容易和别人的代码打架。再说CAN。CAN1和CAN2的选择如果芯片有多个CAN建议先用CAN1因为在STM32上CAN1的例程和资料最多默认引脚一般也在PA11RX、PA12TX上方便接线。中断优先级要稍微注意CAN接收中断和定时器中断的抢占优先级不要设置成完全一样否则可能出现异常。我的习惯是定时器中断优先级略低于CAN接收中断因为CAN帧丢一帧可能就影响通信时序而定时器晚几微秒执行问题不大。3. 手把手移植核心接口与驱动适配3.1 定时器移植1ms驱动TimeDispatch在CanFestival里TimeDispatch()是协议栈的心脏所有周期任务心跳、周期PDO、定时事件都由它驱动。它并不强制要求固定的1ms但1ms是最常用的选择。移植时我要在定时器中断里调用它同时实现getElapsedTime()来提供当前时基。先看代码/* platform_timer.c */ #include canfestival.h static volatile uint32_t g_tick_ms 0; static uint32_t g_last_ms 0; /* 初始化硬件定时器这里以TIM4为例 */ void initTimer(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; NVIC_InitTypeDef NVIC_InitStructure; /* 假设APB1时钟36MHz分频后得到1MHz计数频率即1us计数 */ RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE); TIM_TimeBaseStructure.TIM_Period 999; /* 1ms 中断 */ TIM_TimeBaseStructure.TIM_Prescaler 36 - 1; /* 36MHz/36 1MHz */ TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM4, TIM_TimeBaseStructure); TIM_ITConfig(TIM4, TIM_IT_Update, ENABLE); NVIC_InitStructure.NVIC_IRQChannel TIM4_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 2; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); TIM_Cmd(TIM4, ENABLE); } void TIM4_IRQHandler(void) { if (TIM_GetITStatus(TIM4, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM4, TIM_IT_Update); g_tick_ms; TimeDispatch(); /* 让协议栈处理所有定时任务 */ } } TIMEVAL getElapsedTime(void) { uint32_t now g_tick_ms; uint32_t diff now - g_last_ms; g_last_ms now; return (TIMEVAL)diff; } /* 在1ms周期中断方式下setTimer可以不做事 */ void setTimer(TIMEVAL value) { (void)value; }这里最容易被忽略的是getElapsedTime()的实现。CanFestival内部通过它计算两次调用之间的时间差从而判断各种超时和周期。我这种写法返回的是自上次调用以来的毫秒数注意用uint32_t做差可以自动处理溢出不需要额外判断这是无符号数相减的特性很多人在这里绕了弯路。3.2 CAN驱动移植canSend和接收分发CAN驱动是整个移植的关键这里要提供两个能力发送一帧报文以及把接收到的报文交回协议栈。先看CanFestival的CAN帧结构。这个结构体在canfestival.h里定义核心字段有cob_idCAN-ID、rtr远程帧标志、len数据长度、data[8]数据字段。我们发送时把结构体里的数据映射到STM32的CAN硬件发送邮箱即可。/* platform_can.c */ #include canfestival.h static CAN_TxHeaderTypeDef txHeader; static uint8_t txData[8]; unsigned char canSend(CAN_PORT notused, Message *m) { uint32_t mailbox; (void)notused; txHeader.IDE CAN_ID_STD; /* CANopen 使用标准帧 11位ID */ txHeader.RTR (m-rtr 1) ? CAN_RTR_REMOTE : CAN_RTR_DATA; txHeader.StdId m-cob_id; txHeader.DLC m-len; txHeader.TransmitGlobalTime DISABLE; for (int i 0; i 8; i) { txData[i] m-data[i]; } if (HAL_CAN_AddTxMessage(hcan1, txHeader, txData, mailbox) HAL_OK) { return 1; } return 0; }接收端稍微复杂一点我们要在CAN接收中断里抓到完整报文然后调用canDispatch()让协议栈去判断这个帧是NMT、SDO还是PDO。HAL库的接收回调长这样void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan) { CAN_RxHeaderTypeDef rxHeader; uint8_t rxData[8]; Message msg; if (hcan-Instance CAN1) { HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, rxHeader, rxData); msg.cob_id rxHeader.StdId; msg.rtr (rxHeader.RTR CAN_RTR_REMOTE) ? 1 : 0; msg.len rxHeader.DLC; for (int i 0; i 8; i) { msg.data[i] rxData[i]; } canDispatch(canOpenNode, msg); } }这里有一个可以优化的点如果SDO传输数据量比较大canDispatch()内部的处理时间可能超过几十微秒放在中断里要格外小心。我的做法是在产品化阶段改成二段式中断里只把原始CAN帧存进环形FIFO主循环里再取出来调用canDispatch()。这样做可以显著降低中断占用时间避免和周期任务打架。不过在刚移植的阶段直接在中断里调用简单直观能快速验证功能后面再改也容易。3.3 配置与初始化流程协议栈驱动写好后关键就是初始化顺序。CanFestival要求CO_Data结构体先和生成的OD绑定然后设置节点ID再做协议栈的初始化。通常我会在main.c里写一个canopen_init()函数/* main.c */ #include canfestival.h #include OD.h CO_Data canOpenNode OD_Data; /* OD_Data 由 objdictgen 生成 */ void canopen_init(void) { /* 1. 设置从站节点ID */ setNodeId(canOpenNode, 1); /* 2. 初始化NMT状态机 */ initNMT(canOpenNode); /* 3. 初始化对象字典运行时数据 */ initDict(canOpenNode); /* 4. 将节点置于预操作状态 */ setState(canOpenNode, Pre_operational); /* 5. 在main中设置心跳等参数也可以初始化完成后再设置 */ canOpenNode.heartbeatError NULL; }初始化顺序不要乱尤其是setNodeId()要放在最前面因为后续很多参数会依赖节点ID来计算默认COB-ID。如果顺序搞反可能出现心跳帧的ID正确但SDO的ID不对这种诡异问题。节点默认进入Pre_operational状态这时候能使用SDO读写对象字典但不能通过PDO交换实时数据。启动节点有两种方式一是等待主站发送NMT启动命令二是在从站代码里直接调用setState(canOpenNode, Operational)强制进入运行态。我调试的时候通常先让主站发NMT命令验证NMT通道本身没问题最后再决定要不要自动启动。3.4 对象字典挂载与自定义变量挂在CO_Data上的OD表是只读常量表但CanFestival支持在读写时触发回调从而操作真实变量。比如我想在SDO写入0x2001时执行一个动作可以在OD的生成代码里设置WriteODCallback。更简单的做法是直接把应用变量地址映射进对象字典这样SDO读写和实际变量就绑定了。以下是生成的OD里一个用户对象条目的样子{ /* 0x2001, subindex 0 */ { /* key */ 0x2001, 0x00, /* len */ 2, /* flags */ ODT_UNS16, }, (void *)var_ctrl_word /* 指向实际16位变量 */ },这意味着CANopen主站一旦通过SDO往0x2001写数据var_ctrl_word会立即更新我的应用代码只需要读这个普通变量不需要关心CAN协议细节。反过来应用代码修改了var_status_word主站用SDO去读0x2000也能看到最新值。这种映射方式对工程开发非常友好代码可读性也高。4. 完整代码一个可复现的CANopen从站例程4.1 工程文件结构为了让这个例程能直接落地我按自己的工程习惯整理了一个最小结构project/ ├── canopen/ │ ├── src/ # CanFestival源码 │ ├── include/ │ ├── OD.c # 对象字典生成文件 │ ├── OD.h ├── platform/ │ ├── platform_timer.c # 定时器驱动 │ ├── platform_timer.h │ ├── platform_can.c # CAN驱动 │ └── platform_can.h ├── application/ │ ├── main.c │ └── app_ctrl.c # 应用逻辑 └── MDK-ARM/ # 工程文件实际编译时把canopen/src下的C文件全部加入工程包含头文件路径再添加platform和application目录。OD生成文件放好后剩下的就是配置编译宏见下文。4.2 编译配置与几个关键宏CanFestival的编译选项大多数在config.h和applicfg.h里不同平台略有差异。以STM32为例我的配置是这样/* applicfg.h 或者工程预定义宏 */ #define CANOPEN_NODE /* 编译为从站节点 */ #define NO_DEBUGGER_SUPPORT /* 关闭调试器打印 */ #define DEBUG_WITH_CONSOLE /* 如果有调试串口可以保留串口调试 */ #define SDO_MAX_LENGTH_TRANSFER 8 /* SDO快速模式最长8字节 */如果使用gcc或AC6编译器可能会遇到某些源文件变量定义位置的问题。CanFestival源码风格偏老有些局部变量声明在可执行语句之后这在C99下合法在AC5和GCC下也通常合法但AC6标准较新碰到兼容问题最省事的办法是把编译器拨到AC5或规范到C99模式别和源码细节较劲。另外OD.h里大量使用UNS8、UNS16、UNS32这些CanFestival自定义类型在STM32上要和uint8_t、uint16_t、uint32_t对齐否则会有大量编译警告。我习惯在applicfg.h里直接typedef映射typedef unsigned char UNS8; typedef unsigned short UNS16; typedef unsigned long UNS32;4.3 main函数初始化和PDO收发我们的从站要实现的功能每20ms通过TPDO1发4字节状态数据同时从RPDO1接收4字节控制指令。直接看main函数#include canfestival.h #include OD.h extern CO_Data canOpenNode; extern void canopen_init(void); extern void InitCAN(void); /* CAN外设初始化包含过滤器 */ extern void initTimer(void); volatile uint16_t var_ctrl_word 0; /* RPDO1 映射目标 */ volatile uint16_t var_status_word 0; /* TPDO1 映射源 */ volatile uint32_t tick_20ms 0; void app_ctrl_init(void) { var_status_word 0x55AA; } void app_ctrl_main_loop(void) { static uint32_t last_ms 0; uint32_t now g_tick_ms; if (now - last_ms 20) { last_ms now; sendPDO(canOpenNode, 0); /* 发送TPDO1 */ } } int main(void) { HAL_Init(); SystemClock_Config(); DelayInit(); /* 或你自己的延时函数 */ InitCAN(); /* 初始化CAN1配置波特率和过滤器 */ initTimer(); /* 初始化TIM4 1ms中断 */ canopen_init(); /* 初始化CanFestival协议栈 */ app_ctrl_init(); /* 若要自动运行可以在这里直接进入Operational */ // setState(canOpenNode, Operational); HAL_CAN_Start(hcan1); HAL_CAN_ActivateNotification(hcan1, CAN_IT_RX_FIFO0_MSG_PENDING); while (1) { app_ctrl_main_loop(); // 其他应用逻辑 } }注意sendPDO()这个函数它触发的是对象字典里已经配置好的TPDO映射把映射表中的变量比如var_status_word打包成CAN帧发出去。如果OD里把TPDO1配置成事件驱动或同步模式协议栈也会在合适的时机自动发送不一定非得在主循环里手动调用。这里手动调用的好处是行为明确方便理解PDO的工作机制。RPDO的接收则更简单——协议栈收到RPDO帧后会自动根据OD映射表把数据写入var_ctrl_word应用层只需要轮询这个变量即可。5. 在线调试与常见问题排查实录5.1 用USB-CAN分析仪验证协议栈是否跑通把USB-CAN接到CAN总线上打开上位机软件波特率设置为500kbps或你代码里配置的值。上电后先观察有没有心跳报文如果节点ID是1心跳周期设为500ms那么应该能看到周期为0x701的报文数据通常是0x00或0x05等节点状态值。看到心跳说明NMT和定时器已经基本正常。然后尝试SDO读0x1000设备类型。SDO的请求帧ID是0x600 NodeID也就是0x601数据部分为索引低字节索引高字节子索引命令字数据...。读0x1000子索引0的命令帧数据为数据字节: 00 10 00 40 00 00 00 00即命令字0x40表示读请求后面四字节预留为0。正常情况下会收到0x581的响应数据类似数据字节: 00 10 00 4F 02 00 00 00其中0x4F表示成功响应并传输4字节数据0x02 0x00 0x00 0x00是这个设备的设备类型值。如果不回复优先检查节点ID、波特率、过滤器。5.2 高频报错与心得踩坑记录移植过程中我踩过不少坑整理成一张速查表现象可能原因排查思路编译报错大量变量未定义头文件路径没配全检查include路径和OD.h是否添加编译报错类型冲突CanFestival自定义类型和STM32库类型冲突在applicfg.h里统一typedef映射上电后没有心跳帧节点没初始化成功或NMT状态不对确认initNMT()和setState()已经执行SDO读不回数据节点ID、波特率、过滤器配置不对先用回环模式测试CAN驱动再用SDO读0x1000PDO不发送TPDO映射配置错误或传输类型不匹配检查OD表中TPDO映射和sendPDO()的PDO序号发送PDO后总线上没有信号CAN发送邮箱配置失败或CAN没启动在canSend()里加点gpio翻转调试通信一段时间后死机中断优先级配置不合理让TimeDispatch()相关的中断优先级不要被长时间屏蔽偶发错误帧太多缺少120欧终端电阻在总线两端并联终端电阻5.3 几个独家避坑技巧第一调试CANopen最忌讳一上来就同时调SDO和PDO。先用心跳确认节点在线再通过SDO读固定对象确认通信链路最后才启用PDO循环收发。一个环节一个环节确认问题定位飞快。第二对象字典工具生成的OD.c文件容易和工程里自己定义的大数组重复引用导致RAM用量飙升尤其是定义了大量字符串或历史记录对象时。建议把不用的对象删掉或者把对应宏关闭。第三如果你的MCU换了型号比如从F103换到F407CAN的挂载时钟会变。F103的APB1是36MHz系统主频72MHzF407的APB1是42MHz系统主频168MHz波特率预分频要重新算。我用的公式是波特率 CAN时钟频率 / (Prescaler * (1 BS1 BS2 1))以F103设500kbps为例CAN时钟36MHzPrescaler取6BS1取8BS2取3则波特率 36MHz / (6 * (1 8 3)) 500kHz实际效果很稳定。换到F407就要重新计算并修改CubeMX配置。从我的实际经验看CanFestival移植最花费时间的不是驱动代码而是理解NMT状态机和对象字典之间的关系。但一旦把这条主线摸清了后面加主站功能、LSS从站ID分配、或者基于DS402做伺服控制都只是往这套框架上叠加新的协议对象而已。我自己在后续项目中就基于这套从站代码扩展了DS402标准对象直接接上了商用伺服驱动器调试工具省了很多沟通成本。建议新手先从本例程的单从站跑通再逐步把硬件抽象层的队列优化、看门狗机制和更多协议对象加进来整个过程会非常锻炼对CAN协议栈的理解。