简介:CANopen是在工业自动化领域应用广泛的CAN高层协议,这份移植源码以festival开源协议栈为蓝本,为STM32F103提供完整的CANopen从机实现,面向需要在CAN总线上集成从站功能的嵌入式工程师,解决协议栈裁剪、对象字典配置、PDO映射等实际问题。压缩包约3.59MB,共244个文件,h头文件与c源文件构成协议栈和驱动主体,o目标文件与hex文件为已编译产物,uvprojx工程可导入Keil MDK重新编译,sct链接脚本与map文件便于定位代码段。当前已有937人学习或下载,说明这套移植方案经过实践验证,具备较高参考价值。阅读源码可以看到从STM32 CAN外设初始化、滤波器设置,到NMT网络管理、SDO服务器、PDO收发、心跳报文及错误处理的完整实现链路,对理解CANopen从机工作机制和快速搭建工程很有帮助。开发者可基于该代码直接裁剪和扩展,有效减少从零编写协议栈的工作量。
1. 一个机柜里挂了十几个CAN节点,主站点名问“你在不在”,从站回一句“我在,准备好”,整个系统才进入工作状态。如果你拿着的是 STM32F103 和 CanFestival 的源码,要做的是把这一套从机协议栈搬进自己的工程,再挂上传感器、电机或IO,而不是从CAN帧二进制开始拼NMT和SDO。这篇文章按我实际移植的顺序讲:先说明从机的协议边界和 CanFestival 替你扛掉什么,再给出手把手裁剪和驱动适配,最后把最容易翻车的几个点逐个拆开。适合用过CAN但没碰过CANopen的硬件工程师,也适合第一次把协议栈往F103上迁的人。
2. 从协议栈的视角看从机:CanFestival 替你扛掉了什么
刚开始接触CANopen的人,最容易低估“从机”这两个字的复杂度。从机不是收到主站一帧数据就回一帧,它要维护一张对象字典,维持网络状态机,还要按CANopen的规范响应SDO、PDO、NMT、心跳这些服务。CanFestival的价值在于,它把CANopen协议里最难写的部分变成了C语言源码,让你只需要把底层的CAN收发、定时器、对象字典接到自己的工程上。
2.1 从机不是“收到什么回什么”:对象字典才是核心
对象字典就是一张有索引和子索引的表,从机所有对外可见的数据都在里面。主站要读转速,不会直接发“请告诉我转速”,而是发一条SDO请求,去读0x2000这个索引里的某个子索引。主站要写目标位置,也是往另一个索引写数据。节点的工作逻辑,本质上全围绕这张表展开。
对 CanFestival 来说,对象字典就是一组 C 语言数组和数据指针。代码里所有函数都在跟这张表打交道,包括 SDO 服务、PDO 映射、心跳配置,甚至设备名和序列号。你在移植时最忌讳的就是一上来就去调CAN中断,先把对象字典在工程里编译进去。STM32F103 的Flash放几个KB的对象字典毫无压力,而且CanFestival本身也不需要动态内存分配,静态数组就够了。
实际调板子时,我会先通过SDO去读 0x1000、0x1001 这些默认对象,能读到就说明协议栈核心已经跑起来了。如果这一步都过不了,后面PDO全是空谈。
2.2 CanFestival 的层次结构:核心和驱动要分清楚
CanFestival 的源码大致分三层。第一层是协议核心,包含对象字典访问、SDO状态机、PDO收发、NMT状态机、心跳和软件定时器管理。第二层是驱动接口,也就是 CAN 收发函数、时间基准函数,这一层每个芯片都不一样。第三层是应用层,也就是你自己写的 main、中断回调、数据采集和对象字典实例。
移植时真正要改的只有驱动接口。核心代码基本不用动,除非你要裁剪功能。最常做的事就是从工程里去掉主站才用到的LSS、动态节点分配这类文件,再把从机需要的核心文件加进编译。不同版本源码的目录名可能不一样,但功能面是固定的:CAN驱动、定时器驱动、对象字典三块。
用 CanFestival 的原因很直接:纯C、源码可读、能在 no-OS 的 MCU 上跑,不依赖RTOS,也不需要动态内存。缺点是它不像商业协议栈那样开箱即用,喂狗的细节必须自己来。你拿到手的是“协议栈”,不是“产品”。
2.3 从机最小功能集合:NMT、心跳、SDO、PDO一个都不能少
一个标准从机至少要有下面这些服务。我移植时通常会列一张表,对着逐项确认状态:
| 服务类型 | 默认 COB-ID 计算方式 | 从机最少要做什么 |
|---|---|---|
| NMT 命令 | 0x000 广播帧 | 响应启动、停止、预操作状态切换 |
| Boot-up | 0x700 + Node-ID | 上电初始化完成后发一帧 0x00 |
| 心跳 | 0x700 + Node-ID | 按 0x1017 设置周期发送 |
| SDO 请求/响应 | 0x600 + Node-ID / 0x580 + Node-ID | 支持对象字典读写 |
| TPDO | 0x180 + Node-ID 起 | 把本地数据推给主站 |
| RPDO | 0x200 + Node-ID 起 | 接收主站下发的控制数据 |
| 紧急报文 | 0x80 + Node-ID | 可选,但建议保留 |
默认 COB-ID 不是死的。0x1800 子索引1、0x1400 子索引1 这些地方可以改,但新手期不建议动,先用默认值跑通。
从机最小集合里最容易被砍掉的是SDO,因为有些场合整个生命周期都用不到SDO。但我强烈建议在调试阶段保留SDO。它是你观察对象字典唯一的窗口,等系统稳定后再关也不迟。
3. 把源码裁到 STM32F103 工程:CAN 驱动和时间基准就这两件大事
STM32F103 的CAN外设其实很简单,麻烦的是让 CanFestival 的驱动接口去适配HAL库。很多人移植失败,不是协议栈不行,而是驱动文件没有按协议栈预期的接口去实现。
3.1 最小文件集:哪些 .c 必须编译,哪些是主站才用
CanFestival 源码在工程里不需要全部编译。我一般会保留下面这些,按文件名找就行:
| 文件 | 作用 |
|---|---|
| canfestival.c | 核心初始化和上层入口 |
| nmt.c | 从机网络管理状态机 |
| sdo.c | SDO 服务器 |
| pdo.c | PDO 收发处理 |
| timer.c | 软件定时器链表 |
| objacces.c | 对象字典读写函数 |
| objdict.c / objdict.h | 你用工具生成的对象字典实例 |
主站才会用的 LSS、SDO 客户端这类内容,从机工程里可以直接不加入编译。砍文件时最怕的是漏掉某个核心文件里的弱符号关联,导致链接成功但运行时不响应。所以第一次移植时,先全量编译,确认协议栈能跑,再逐个裁剪,别一上来就删。
3.2 CAN 收发函数:从裸机帧到协议栈 Message 的适配
CanFestival 上层只认自己的 Message 结构体,不认 HAL 的 TxHeader 和 RxHeader。所以驱动层要做的就是把 HAL 收到的帧包装成 Message,再把 Message 里面的数据填回 HAL 的发送结构体。
// canopen_drv.c #include "canfestival.h" #include "can.h" CAN_HandleTypeDef hcan1; static uint8_t canopen_send(CAN_HandleTypeDef *hcan, Message *m) { CAN_TxHeaderTypeDef tx = {0}; uint32_t mailbox = 0; uint8_t data[8] = {0}; if (m->cob_id & 0x80000000) { tx.IDE = CAN_ID_EXT; tx.ExtId = m->cob_id & 0x1FFFFFFF; tx.StdId = 0; } else { tx.IDE = CAN_ID_STD; tx.StdId = m->cob_id & 0x7FF; tx.ExtId = 0; } tx.RTR = (m->rtr == 1) ? CAN_RTR_REMOTE : CAN_RTR_DATA; tx.DLC = m->len; for (uint8_t i = 0; i < m->len; i++) { data[i] = m->data[i]; } if (HAL_CAN_AddTxMessage(hcan, &tx, data, &mailbox) != HAL_OK) { return 1; } return 0; }这里要注意几点。CANopen 默认帧里大多数是标准帧,但协议栈的 Message 结构体里 cob_id 的最高位可能用来表示扩展帧标志,所以驱动里得做个判断。如果你的工程只跑CANopen标准帧,也可以直接强制走标准帧,逻辑更简单。DLC 最大就是8,超过8的帧不是CANopen该出现的东西。
接收方向同样要包一层:
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan) { CAN_RxHeaderTypeDef rx = {0}; uint8_t data[8] = {0}; Message msg; if (hcan != &hcan1) { return; } if (HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &rx, data) != HAL_OK) { return; } msg.cob_id = (rx.IDE == CAN_ID_EXT) ? (rx.ExtId | 0x80000000) : rx.StdId; msg.rtr = (rx.RTR == CAN_RTR_REMOTE) ? 1 : 0; msg.len = rx.DLC; if (msg.len > 8) { msg.len = 8; } memcpy(msg.data, data, msg.len); canDispatch(&msg); }canDispatch 就是 CanFestival 的入口,所有从CAN总线收进来的帧都要丢给它。它内部会根据 COB-ID 判断是 NMT、SDO、PDO 还是心跳,不需要你写。
还要注意CAN过滤器。从机要收的 ID 有很多,NMT 是 0x000,SDO 是 0x605,RPDO 是 0x205,广播的同步帧也可能出现。最省事的做法是把过滤器改成 mask 全0,也就是不过滤任何ID:
CAN_FilterTypeDef filter = {0}; 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);mask 全0不是偷懒,是从机通信模式的合理配置。主站不会知道从机具体在等哪个ID,总线上的广播帧一个都不能丢。
3.3 定时器驱动:让协议栈的时基跑起来
CanFestival 内部所有超时机制,包括SDO超时、心跳周期、PDO事件定时,都依赖一个毫秒级时基。没有它,从机就像没有心跳的人,状态机跑不动。
我一般会在 F103 上单独开一个 TIM2,配置成 1ms 中断。如果 SysTick 还要给别的调度用,就不要跟它抢。
// timer_drv.c volatile uint32_t g_canopen_tick = 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); g_canopen_tick++; // 协议栈的时基入口,不同版本可能叫 TimeCanFestival 或类似名字 TimeCanFestival(); } }这段代码的关键在最后一行的调用频率。1ms 中断就是 1kHz,如果你不小心把 TIM2 配成了 100us,CanFestival 会以为时间走快了,SDO 回包和心跳周期全部偏移。拿逻辑分析仪看心跳间隔,一眼就能发现问题。
优先级设置要特别小心。CAN 接收中断和 TIM2 中断会同时访问协议栈内部的定时器链表。最稳的做法是让 CAN 接收中断优先级高于 TIM2,这样可以避免在 canDispatch 执行到一半的时候被时基打断,导致链表操作竞争。
4. 把源码接到从机应用:对象字典、PDO映射和主循环的配合
协议栈在你的F103上跑通以后,真正的工作才开始。你要把自己的数据填进对象字典,要让 PDO 按主站期望的方式发出去,还要保证主循环不能卡死。
4.1 对象字典的编辑:用工具生成,不要手改数组
CanFestival 的对象字典可以用通用的对象字典编辑器去编辑,导出 C 文件后再加入工程。核心文件是 ObjDict.c、ObjDict.h 和 ObjDict_ids.h。这三个文件必须成套生成,不能只替换其中一个。
我看过太多人手改生成文件,改完编译没问题,但一上电主站去读某个对象就是不对。原因是协议栈内部对对象字典的索引访问是基于数组下标的,手加一个对象却没改总表数,越界只是时间问题。
如果你用惯了文本编辑器去改 OD 表,建议换回工具。在工具里添加一个 0x2000 的厂商自定义对象,设置好类型、子索引、读写属性,再生成C文件,所有关联表会自动同步。STM32F103 的 Flash 足够放这些数据,生成文件偏大不是问题,关键是保持一致性。
4.2 PDO映射:把数据表上的槽位接到实际变量
PDO映射是另一个容易翻车的地方。比如你想把 0x2000 子索引0这个16位变量放到 TPDO1 里发出去,映射表里就要写一条记录,格式是固定的:
#define MAP16(idx, sub) (((idx) << 16) | ((sub) << 8) | 16) const UNS32 TPDO1_Mapping[] = { MAP16(0x2000, 0), 0, 0, 0 };映射值的格式是CANopen协议规定的,高16位是索引,中间8位是子索引,低8位是位长度。0x2000 子索引0,16位,就拼成了0x20000010。这个值不是CanFestival自创的,你在任何一本CANopen手册里都能找到。
映射表只解决“发什么”,还要解决“怎么发”。TPDO默认是禁用的。要让周期发送生效,需要去看0x1800子索引1的COB-ID,如果最高位是1,PDO无效;还要把0x1800子索引2的传输类型设成1,表示跟随SYNC周期发。如果你不想依赖SYNC,可以把0x1800子索引5的事件时间设成非0,协议栈会按这个时间自动触发。
我的经验是:第一次跑通时先把0x1800子索引1改成默认的0x180加节点号,传输类型先不管,直接在主循环里显式调用 sendTPDO。这样可以先确认PDO映射对不对,再去做周期触发。
4.3 SDO读写:最直接的调试窗口
SDO是从机的“寄存器窗口”,可以用它验证整个对象字典通路。比如节点号是5,主站想读0x2000,就用下面这条命令走SDO:
# 节点号 5,SDO 请求 COB-ID = 0x600 + 5 = 0x605 cansend can0 605#40 00 20 00 00 00 00 00数据场40 00 20 00 00 00 00 00说明这是“上传请求”,索引 0x2000 按低字节在前的方式填,就是00 20,后面是子索引0。从机正常响应会在0x585上回来:
# 0x4B 表示上传响应,后面跟 0x1234 candump can0:585如果这条链路通了,说明CanFestival的核心协议栈、CAN驱动、对象字典全部正常。这比任何外部指示灯都可靠。
紧急报文也别删。CanFestival 在底层CAN错误或应用错误时,会向0x80加节点号发EMCY。做耐久测试时,主站能实时看到从机报错,很多偶发问题就是靠它发现的。
4.4 主循环与中断拆分:不要在 while 里做协议栈真正的收发
有些移植者把 canDispatch 放进主循环轮询,这在负载高的场合会丢帧。正确做法是中断收帧,立即进 canDispatch;主循环只负责刷新OD数据、调用 sendTPDO、做应用逻辑。一个最小的主循环长这样:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_CAN1_Init(); MX_TIM2_Init(); canopen_slave_init(0x05); // 设置节点号并进入 Pre_operational while (1) { od_write_u16(0x2000, 0, read_adc()); if (getState(&ObjDict_Data) == Operational) { sendTPDO(&ObjDict_Data); } HAL_Delay(1); } }这里od_write_u16是我在工程里加的一层封装,实际用的是协议栈的对象字典写函数。重要的是不要在主循环里做阻塞等待,比如等串口打印完成、等Flash写入完成。CAN中断一进来不会管你主循环在干嘛,等久了就会丢帧。
还有一个习惯我建议从一开始就养成:所有对对象字典的写操作都要放在一个明确的位置,不要散落在中断和应用代码里。中断里写OD数据、主循环也写OD数据,两个地方同时改同一个变量,到最后你会分不清是谁把数据改坏的。
5. 移植避坑指南:CanFestival 在 F103 上的5个典型翻车点
协议栈移植最常见的不是不会配置,而是“看起来都对了”但实际跑不起来。下面这5个坑,是我在调试同类从机时总结出来的,基本覆盖了大部分入门问题。
5.1 现象:上电后只发Boot-up,主站怎么给NMT都不进 Operational
原因:节点号没有在初始化时写入协议栈,或者CAN过滤器把0x000这条NMT广播帧挡住了。主站发NMT启动命令用的是COB-ID 0x000,不是0x005,如果你的过滤器只放了节点相关ID,广播帧根本进不来。
解决:先确认setNodeId之类的函数在初始化时被调用,并且参数是你烧在板子上的节点号。再用candump或逻辑分析仪看总线,确认0x000帧确实收到了。如果收到了但还是不切换状态,检查NMT数据场,低字节是命令,高字节是节点号,节点号必须和自身一致,0表示所有节点。
5.2 现象:心跳正常,SDO也能读,但PDO永远发不出去
原因:PDO默认是禁用的。没有配置0x1800子索引1的COB-ID有效性,也没有配置事件时间或传输类型,映射表也是空的。很多从机源码里TPDO映射表全为0,你填了OD数据也没用。
解决:在对象字典工具里把TPDO1的映射表填上目标对象,把0x1800子索引1的PDO有效性位置0,子索引5事件时间填100ms。重新生成C文件后编译下载。如果还是没有,先用主循环里显式调用 sendTPDO 排除周期触发问题。
5.3 现象:波特率明明一致,但总线上全是错误帧
原因:多半不是波特率的问题,而是STM32F103的CAN外设时钟算错。F103的CAN挂在APB1上,APB1通常等于系统时钟的一半。如果你抄了别的芯片配置,BS1、BS2、预分频器都会差一大截。
解决:先看RCC配置里APB1是多少,再用500Kbps的目标反推。网上很多配置例程默认APB1是36MHz,如果你的系统时钟不是72MHz,直接照抄就是错误帧。用CAN分析软件看错误状态,把预分频器、BS1、BS2按实际时钟重算一遍,百发百中。
5.4 现象:跑几小时后死机,看门狗都拉不回来
原因:定时器中断和CAN接收中断同时访问了协议栈内部的定时器链表。CanFestival 里定时器相关操作不是可重入的,一个中断正在改链表,另一个中断进来把链表头改了,系统就卡死了。
解决:把CAN接收中断优先级设得比TIM2时基中断高,保证 canDispatch 处理时不会被时基打断。如果项目里用了RTOS,则要给定时器链表加互斥保护。最简单可靠的 no-OS 做法是把时基中断里的 TimeCanFestival 调用挪到主循环里,中断里只置标志位。周期任务慢几百微秒没关系,稳定性优先。
5.5 现象:改了对象字典,程序下一个版本就起不来
原因:几乎都是手改生成文件导致的。ObjDict.c、ObjDict.h、ObjDict_ids.h 三个文件版本错位,或者某个表的条目数没同步,导致协议栈访问越界。
解决:改对象字典永远跑一次工具,重新生成全套文件,再整组替换进工程。不要在生成文件里手动加对象。如果实在要手改,至少要核对三处:索引表条目数、子索引表条目数、对象字典总条目数。我自己的教训是:手改省了10分钟,排查Data abort花了两个晚上。
6. 让从机稳定跑起来:一个最小测试脚本比一堆分析仪好用
最后一章我想说一个更具体的验证技巧。很多新手把从机移植完,插上USB-CAN卡开个官方工具看一眼心跳就觉得完事了。实际上从机能不能长期稳定地活在网络里,要用脚本反复上下电、反复切换状态来验证。
我不会依赖昂贵的CAN分析软件,更习惯用PC上的通用CAN适配器和一段Python脚本。SocketCAN 环境下可以这样写:
import can import time bus = can.Bus(channel='can0', bustype='socketcan', bitrate=500000) def set_operational(node_id): bus.send(can.Message(arbitration_id=0x000, data=[0x01, node_id], is_extended_id=False)) def read_od(node_id, index, subindex): data = [0x40, index & 0xFF, (index >> 8) & 0xFF, subindex, 0, 0, 0, 0] bus.send(can.Message(arbitration_id=0x600 + node_id, data=data, is_extended_id=False)) msg = bus.recv(timeout=0.2) if msg.arbitration_id == 0x580 + node_id: return msg.data return None for i in range(100): set_operational(5) time.sleep(0.05) print(read_od(5, 0x2000, 0)) time.sleep(0.1) print("check done")这段脚本做的事很简单:100次把节点切到Operational,每次去读一次0x2000。主站丢一两次SDO响应并不一定是从机真丢,也常见于USB-CAN适配器在SocketCAN下的调度延迟。我会连续看几百次,只有出现连续失败才认为协议栈有问题。
这个脚本我用了很久,平时调板子省了半条命。在项目里我还会加一条验证逻辑:从机运行两小时后,逐项检查心跳帧间隔是否稳定在目标值附近。能连续跑过3000次SDO读写、心跳间隔不漂移、PDO不丢帧,这个从机才算真的移植完成。希望帮到你。
本文还有配套的精品资源,点击获取