简介:面向STM32F103的CANopen从机移植源码包,适用于工业自动化、汽车电子等需要快速搭建从站节点的嵌入式开发场景,完整涵盖NMT节点管理、SDO非周期配置、PDO实时传输与映射、心跳状态监控和错误处理等核心机制。包内共244个文件、约3.59MB,以C/H源码头文件、Keil工程文件(uvprojx/uvoptx)及编译生成的hex/axf/o等为主,另有map/lst链接映射与列表文件可用于核对函数地址和编译布局;从机侧的对象字典、SDO服务器、收发PDO配置均已按STM32F103的CAN外设落实,可直接在此基础上裁剪复用,并根据实际节点功能调整映射关系。源码结构与注释帮助开发者快速定位从站初始化、协议处理和应用回调接口,已有937人学习下载,适合具备一定嵌入式基础、希望深入理解CANopen从站协议并完成项目移植的工程师。
1. 一个 CANopen 从机,为什么值得你花三天自己移植
某条产线上有台老设备要并入新主站,主站只认 CANopen。设备厂家给的协议栈代码报价贵,还绑死在他们自家芯片上,于是我们花三天时间把社区里最常见的开源 CANopen 从机协议栈移植到 STM32F103 上,用它做了一个从站节点:心跳起来、SDO 能读写、PDO 按周期往外发。这篇笔记就是把那三天的路数拆开讲:CANopen 从机协议栈在 F103 上到底由哪几个部件组成、移植时哪些文件必须动、哪些参数必须调、哪些坑我踩过。适合手里有 F103 板子、能调通 CAN 收发、想自己掌握从机源码而不是拿黑匣子集成的工程师;如果你连 CAN 总线都没跑通过,建议先把手头的 CAN 回环例程点亮再往下看。
2. 移植前心里要有数:从机协议栈的四个骨架
移植不是把文件拖进工程就能跑的事。F103 的 bxCAN 硬件本身只负责收发 CAN 报文,CANopen 从机是在这层之上把报文组织成“可以被主站调度的数据”。动手之前,先搞清楚四个骨架:对象字典、NMT 状态机、SDO/PDO、心跳机制。这四个东西搞明白了,后面所有代码都是往骨架上贴肉。
2.1 对象字典:从机的数据仓库,不是随便一张表
每个 CANopen 从机都有一张对象字典(Object Dictionary,OD),它描述了这个从机“有什么数据、能干什么”。OD 按索引组织:0x1000~0x1FFF 是通信参数区,0x2000~0x5FFF 是厂家特定区,0x6000~0x9FFF 是标准设备行规区。主站所有 SDO 读写请求,本质都是“按索引去查你这张表”。
我要强调一个新手最容易忽略的点:你的应用数据不管在哪个全局变量里,想让主站能通过标准协议读写它,必须把它登记到 OD 表上。我习惯把自定义数据挂在 0x2000~0x5FFF 这一段,比如 0x2000 子索引 1 放状态字,0x2001 子索引 1 放控制字节。OD 表里每个条目至少要有索引、子索引、类型、数据指针,有的协议栈还会定义读写权限和存储属性。
OD 表实现上就是一个大数组,每个元素描述一个条目。在 F103 这种内存不宽裕的芯片上,OD 表要尽量精简,只挂实际用到的条目。别把示例工程里一整套标准对象全搬过来,搬过来占 Flash 也占 RAM,还容易在编译时暴露出“内存不足”的问题。
2.2 NMT 状态机:从机的四个运行状态,先明白它再写轮询
NMT(Network Management)状态机是 CANopen 从机的心脏。一个标准从机有四个状态:初始化(Initialization)、预操作(Pre-operational)、操作(Operational)、停止(Stopped)。上电后从机自动跑完初始化,进入预操作状态。
预操作状态下,从机只响应 SDO 和心跳,不发送 PDO。只有主站通过 NMT 命令(COB-ID 0x000)下发启动指令(命令字 0x01)后,从机才切到操作状态,PDO 才开始传输。主站也可以下发停止(0x02)、进入预操作(0x80)、复位节点(0x81)、复位通信(0x82)四条命令。
这个状态机看起来简单,但调试时 90% 的“PDO 为什么不发”都能归结到状态没切对。我自己的排查流程是:先看心跳报文有没有,心跳在预操作就有;再看 NMT 命令有没有收到,用分析仪抓 0x000 帧;最后才去翻 PDO 映射配置。
2.3 SDO 与 PDO:一个管配置,一个管实时
SDO 是邮箱式的确认传输,客户端发请求,服务端回响应,每一帧都要应答。它适合传配置参数:对象字典里某个值是多少、改成多少、改完读回确认。SDO 一帧最多 8 字节,超过 4 字节的数据还会分段传输,速度慢但可靠。
PDO 是生产/消费式单向广播,不带确认,发出去就不管了。它适合传实时数据:状态字、控制字、速度、位置这类周期性变化的值。每个从机至少要有 1 个 RPDO(收命令)和 1 个 TPDO(发状态)。
把“某个 OD 对象塞进 PDO 的 8 字节里”这个动作叫做映射。PDO 映射配置在 0x1600~0x1603(RPDO)和 0x1A00~0x1A03(TPDO)这些索引里。注意一点:PDO 映射不是随便写个索引就能生效,很多协议栈要求先进入预操作状态再改映射,改完复位通信才能让新配置生效。
2.4 心跳与节点保护:主站靠它判断你是否在线
主站要知道从机是不是还活着,靠两样东西:心跳报文和节点保护。现在多数系统用心跳,它的 COB-ID 是 0x700 + 节点 ID,周期由 OD 0x1017 设定,单位毫秒。我一般设 100ms 或 1000ms,根据系统实时性要求定。
节点保护是更老的方式:主站周期性地发请求,从机必须在规定时间内应答,否则主站判定节点离线。两种机制选一种就行,不要同时开,同时开会让总线上多出一倍的无用帧。
下表是 CANopen 从机最常用的 COB-ID 分配,移植后核对数据时用得着:
| 报文类型 | COB-ID | 方向 | 用途 |
|---|---|---|---|
| NMT | 0x000 | 主站→从机 | 状态切换命令 |
| SYNC | 0x080 | 主站→所有节点 | 同步触发 PDO |
| 紧急报文 EMCY | 0x080 + 节点ID | 从机→主站 | 上报错误状态 |
| 心跳 | 0x700 + 节点ID | 从机→主站 | 节点在线状态 |
| SDO 请求 | 0x600 + 节点ID | 主站→从机 | 读写对象字典 |
| SDO 响应 | 0x580 + 节点ID | 从机→主站 | SDO 应答 |
| TPDO1 | 0x180 + 节点ID | 从机→主站 | 实时数据输出 |
| RPDO1 | 0x200 + 节点ID | 主站→从机 | 实时命令输入 |
这套骨架理清了,再回头看源码就不会迷路。下一步我们把它落到 F103 的工程里。
3. 用 STM32F103 跑通从机:最小移植的五个落地步骤
下面的步骤对应一个“最小从机”:能发心跳、能响应 SDO 读写、能发一个 TPDO。这个目标跑通了,再往里面加业务逻辑。
3.1 准备 HAL 工程:CAN 引脚、时钟和波特率
先确认硬件连接:F103 的 CAN1 使用 PA11(RX)和 PA12(TX),外接 CAN 收发器后接入总线。调试时强烈建议在总线上挂一个 USB-CAN 分析仪,后面所有验证都靠它。
接下来初始化 CAN。我这里以 HAL 库为例,时钟配置为主频 72MHz,APB1 预分频 2(即 PCLK1 = 36MHz)。CAN 波特率目标 500kbps,看初始化代码:
CAN_HandleTypeDef hcan1; hcan1.Instance = CAN1; hcan1.Init.Prescaler = 8; /* 分频系数 8,CAN 时钟 36M/8 = 4.5MHz */ hcan1.Init.Mode = CAN_MODE_NORMAL; /* 正常模式,非回环 */ hcan1.Init.SyncJumpWidth = CAN_SJW_1TQ; /* 同步跳转宽度 1 个时间片 */ hcan1.Init.TimeSeg1 = CAN_BS1_7TQ; /* BS1 = 7 个时间片 */ hcan1.Init.TimeSeg2 = CAN_BS2_1TQ; /* BS2 = 1 个时间片 */ hcan1.Init.TimeTriggeredMode = DISABLE; hcan1.Init.AutoBusOff = ENABLE; /* 总线离线后硬件自动恢复,很重要 */ hcan1.Init.AutoWakeUp = DISABLE; hcan1.Init.AutoRetryTransmission = ENABLE;/* 发送失败硬件自动重传 */ hcan1.Init.ReceiveFifoLocked = DISABLE; hcan1.Init.TransmitFifoPriority = DISABLE; HAL_CAN_Init(&hcan1);这段代码里最重要的是波特率计算。HAL 库的 TimeSeg1、TimeSeg2 参数写的是“几个时间片”,不是寄存器原始值。实际比特率公式是:
波特率 = PCLK1 / (Prescaler × (TimeSeg1 + TimeSeg2 + 1))
代入上面数值:36MHz / (8 × (7 + 1 + 1)) = 36MHz / 72 = 500kHz,正好 500kbps。要注意,这个公式里 Prescaler 也不是寄存器值,HAL 层帮你做了减一操作,别自己再减一次,否则波特率会翻倍。
AutoBusOff = ENABLE建议一开始就打开。F103 的 CAN 控制器在错误计数器 TEC 超过 255 后会进入 BusOff 状态,如果不开自动恢复,节点会一直离线,必须重新初始化才能回来。这个坑在后面避坑章还会详细讲。
然后配置过滤器。从机最简单的做法是不过滤任何报文,全部收进 FIFO0,交给协议栈自己按 COB-ID 分发:
CAN_FilterTypeDef filter; filter.FilterActivation = ENABLE; filter.FilterBank = 0; filter.FilterMode = CAN_FILTERMODE_IDMASK; filter.FilterScale = CAN_FILTERSCALE_32BIT; filter.FilterId = 0; filter.FilterMaskId = 0; /* Mask 全 0 = 不过滤任何 ID */ filter.FilterFIFOAssignment = CAN_RX_FIFO0; HAL_CAN_ConfigFilter(&hcan1, &filter); HAL_CAN_Start(&hcan1); HAL_CAN_ActivateNotification(&hcan1, CAN_IT_RX_FIFO0_MSG_PENDING);FilterMaskId = 0配合FilterId = 0表示所有报文都进 FIFO0。从机本身报文量不大,过滤全部交给协议栈处理最简单,也少一层排查问题。如果以后总线很忙想减少中断次数,再按 COB-ID 范围做硬件过滤不迟。
这里踩过一个小坑:HAL_CAN_ActivateNotification必须在HAL_CAN_Start之后调用,顺序反了会导致中断不触发。项目里查了半天,最后发现是两行代码顺序的问题。
3.2 源码放进工程:哪些文件必须动、哪些别碰
开源 CANopen 从机协议栈的源码文件大致分三层:核心层、驱动层、应用层。核心层是协议逻辑,比如 NMT 状态机、SDO 服务器、PDO 收发、心跳定时、紧急报文这些,文件多,但原则上不需要改。驱动层是协议栈和硬件之间的桥,需要你自己写:CAN 收发驱动、1ms 定时器驱动、非易失存储驱动,这层是移植工作的主战场。应用层是你自己的业务代码,比如把某个 ADC 采样值塞进 TPDO。
我一般把驱动层收敛到一个文件里,命名类似canopen_port.c,配套一个canopen_port.h。这样做的好处是:以后换芯片或者换协议栈版本,只动这一个文件,核心层和应用层都不用翻。文件清单大致如下:
| 层次 | 文件说明 | 移植时处理 |
|---|---|---|
| 核心层 | 协议栈的 CO_ 系列源码文件 | 只编译,不修改 |
| 驱动层 | CAN 收发、1ms 定时器、Flash 存储 | 全部自己写,按协议栈接口对接 |
| 应用层 | 业务逻辑、对象字典数据源 | 自己维护,挂在目标板上 |
核心层文件“不要改”是一条铁律。协议栈内部的状态机、超时处理都是精心调试过的,改了之后问题非常隐蔽,而且升级协议栈版本时你的改动全部作废。如果发现某个行为不合预期,先怀疑自己的驱动层是不是少调了某个函数,而不是急着改核心层。
3.3 驱动层第 1 步:接好 CAN 接收与发送
F103 的 CAN 接收中断频率可能很高,协议栈的解析逻辑又不能用中断里跑太久。常见做法是中断里只把报文搬进一个小的环形队列,主循环里再取出来喂给协议栈。我用一个 8 深的队列:
#define CAN_RX_QUEUE_SIZE 8 typedef struct { uint32_t id; /* 标准帧 ID 或扩展帧 ID */ uint8_t data[8]; uint8_t len; /* DLC */ } can_rx_msg_t; volatile can_rx_msg_t can_rx_q[CAN_RX_QUEUE_SIZE]; volatile uint8_t can_rx_head = 0; volatile uint8_t can_rx_tail = 0; void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan) { CAN_RxHeaderTypeDef rx; uint8_t buf[8]; uint8_t next; if (hcan->Instance != CAN1) { return; } if (HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &rx, buf) != HAL_OK) { return; } next = (can_rx_head + 1) % CAN_RX_QUEUE_SIZE; if (next == can_rx_tail) { /* 队列满,丢弃新帧,保留旧帧 */ return; } can_rx_q[can_rx_head].id = (rx.IDE == CAN_ID_EXT) ? rx.ExtId : rx.StdId; can_rx_q[can_rx_head].len = rx.DLC; memcpy(can_rx_q[can_rx_head].data, buf, 8); can_rx_head = next; }中断里只做搬运,不解析。队列满时我选择丢新帧而不是覆盖旧帧,因为对从机来说,旧帧可能是主站 NMT 命令或 SDO 请求的一部分,丢了会导致状态不一致;新一帧数据丢了,下一轮心跳里还能补回来。
主循环里取出报文交给协议栈:
void canopen_port_poll(void) { while (can_rx_tail != can_rx_head) { can_rx_msg_t msg; uint8_t next; msg = can_rx_q[can_rx_tail]; next = (can_rx_tail + 1) % CAN_RX_QUEUE_SIZE; can_rx_tail = next; /* 交给协议栈接收处理,函数名按实际适配层为准 */ canopen_on_can_receive(msg.id, msg.data, msg.len); } }发送侧直接调用 HAL 接口:
uint8_t canopen_port_send(uint32_t id, uint8_t *data, uint8_t len, uint8_t ext) { CAN_TxHeaderTypeDef tx; uint32_t mailbox; tx.IDE = ext ? CAN_ID_EXT : CAN_ID_STD; tx.RTR = CAN_RTR_DATA; tx.DLC = len; if (ext) { tx.ExtId = id; } else { tx.StdId = id; } if (HAL_CAN_AddTxMessage(&hcan1, &tx, data, &mailbox) != HAL_OK) { return 0; /* 三个发送邮箱都满,返回失败 */ } return 1; }F103 只有 3 个发送邮箱,总线繁忙时AddTxMessage会失败。发送函数里不要死等空邮箱,那会卡住主循环,心跳也跟着丢。正确做法是把失败结果如实返回上层,让协议栈决定重试还是丢弃。
3.4 驱动层第 2 步:1ms定时器怎么跟协议栈咬合
CANopen 从机的心跳周期、PDO 事件定时、SDO 超时都依赖一个 1ms 的时基。F103 上我习惯用 TIM2 做这个时基,因为它是 16 位定时器里最容易空出来的一个(TIM1 常用于 PWM)。
这里有个新手常踩的坑:APB1 预分频为 2 时,F103 的定时器时钟不是 36MHz 而是 72MHz。这是芯片设计的一个细节:APB1 分频后,定时器时钟会自动翻倍。所以初始化 TIM2 时要按 72MHz 算:
TIM_HandleTypeDef htim2; htim2.Instance = TIM2; htim2.Init.Prescaler = 71; /* 72 分频:72MHz / 72 = 1MHz */ htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 999; /* 计 1000 次 = 1ms */ htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(&htim2); HAL_TIM_Base_Start_IT(&htim2);在中断回调里调用协议栈的 1ms 步进函数:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { canopen_timer_1ms_tick(); /* 通知协议栈走一个毫秒 */ } }这里要给canopen_port.h里声明一个canopen_timer_1ms_tick(),在适配层里调用协议栈的毫秒处理入口。我之前在一个项目里把定时器时钟算错了一倍,结果心跳周期变成 200ms,主站一直报节点超时,排查半天才发现是这里的时钟源问题。
中断优先级也值得提前定好:TIM2 的优先级要高于 CAN 接收中断。道理很简单,时基丢一个毫秒,影响的是所有跟时间有关的逻辑;CAN 帧丢一帧,下一帧还能补。优先级设成 TIM2 = 1,CAN RX = 2,SysTick 保持 3 左右比较稳妥。
3.5 主循环集成与最小冒烟测试
主循环的结构保持简单,别把业务逻辑写得太复杂:
while (1) { canopen_port_poll(); /* 取 CAN 接收队列,喂给协议栈 */ canopen_process_1ms(); /* 处理定时事件,如心跳、PDO 事件定时 */ canopen_nmt_process(); /* 处理 NMT 状态切换 */ app_task(); /* 你自己的业务逻辑 */ }注意canopen_port_poll()要放主循环,不要放在定时器中断里。协议栈的数据结构不是为中断安全设计的,在中断里调用解析函数,遇到临界区保护和嵌套中断很容易跑飞。
冒烟测试很简单:先不接主站,从机单独上电,用 USB-CAN 分析仪挂到总线上抓帧。如果节点 ID 配置为 1,应该能周期性地看到 COB-ID 为 0x701 的心跳帧,数据字段里第一位是状态字(0x7F 表示预操作,0x05 表示操作)。看到心跳,说明 CAN 驱动、定时器、协议栈核心层已经串起来了。
4. 参数与对象字典实操:6 个必须改的地方
最小工程跑通心跳之后,就要开始配置参数了。如果你拿到的示例工程是基于某个网卡或者另一款单片机移植的,以下 6 个地方是每次移植新板子都必须过一遍的。
4.1 节点 ID:拨码开关读取还是写死
节点 ID 决定所有 COB-ID。我建议不是写死,而是从拨码开关读取,这样现场调试时不用重新刷固件。F103 的 GPIO 读取很简单,以下是一个用 PA4~PA7 读 4 位拨码的例子:
#define NODE_ID_PORT GPIOA #define NODE_ID_PINS (GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7) uint8_t node_id_get(void) { uint8_t id = 0; id |= (HAL_GPIO_ReadPin(NODE_ID_PORT, GPIO_PIN_4) ? 1 : 0); id |= (HAL_GPIO_ReadPin(NODE_ID_PORT, GPIO_PIN_5) ? 2 : 0); id |= (HAL_GPIO_ReadPin(NODE_ID_PORT, GPIO_PIN_6) ? 4 : 0); id |= (HAL_GPIO_ReadPin(NODE_ID_PORT, GPIO_PIN_7) ? 8 : 0); if (id == 0 || id > 127) { id = 1; /* 0 和 127 属于保留地址,不能用作实际节点 */ } return id; }读取的时机很关键:要在协议栈初始化之前把节点 ID 拿到,再传给对象字典里的 0x1018 子索引 1(厂商 ID 可以自己定义)。如果协议栈初始化之后再去改节点 ID,需要做一次复位通信才能让全部 COB-ID 刷新。
GPIO 外部要接上拉电阻,拨码闭合时读到低电平或者高电平取决于你的硬件设计,代码里按实际极性调整? 1 : 0的逻辑即可。别让引脚悬空,悬空时读到的值随机,现场会出现“这次是节点 3,下次是节点 7”的诡异现象。
4.2 波特率:在 F103 的 bxCAN 上如何换算
波特率必须和总线上其他节点完全一致。以下表格给出 APB1 = 36MHz 时几组常用配置(HAL 参数直接可填):
| 目标波特率 | Prescaler | TimeSeg1 | TimeSeg2 | 实际波特率 |
|---|---|---|---|---|
| 1Mbps | 4 | 7 | 1 | 36M / (4 × 9) = 1M |
| 500kbps | 8 | 7 | 1 | 36M / (8 × 9) = 500k |
| 250kbps | 16 | 7 | 1 | 36M / (16 × 9) = 250k |
| 125kbps | 32 | 7 | 1 | 36M / (32 × 9) = 125k |
这组配置统一采用 BS1=7、BS2=1,采样点都在 88.9% 左右,对于常用电缆长度和终端电阻来说都在合理范围。如果总线长度超过几十米,建议把采样点往 80% 附近调,也就是把 BS1 调小、BS2 调大,具体要通过示波器看眼图决定。
一个技巧:F103 的 CAN 波特率不要跟主站“想当然”一致。多数主站默认 500k,但也有人把网络配成 250k 甚至 1M。上电后如果从机心跳出不来,先用分析仪抓一帧,看实际波特率,别急着怀疑协议栈。
4.3 心跳周期与节点保护:OD 0x1017 别设成 0
对象字典里 0x1017 是心跳周期,单位毫秒。它最常见的错误是初始化为 0,0 表示“关闭心跳”。很多主站软件把“没有心跳”等同于“节点离线”,所以这个值一定要在初始化时写正确:
uint16_t od_heartbeat_time = 100; /* 心跳周期 100ms */ /* 对象字典条目,0x1017 子索引 0 */ {0x1017, 0, OD_TYPE_U16, &od_heartbeat_time},取值建议:100ms 适合快速检测节点掉线的系统;1000ms 适合对实时性要求不高的传感器节点。这两个值对 CPU 占用都极小,F103 完全扛得住。
另外注意 0x1017 是动态值,主站可以通过 SDO 在线修改它,但断电后是否保持,取决于你是否实现了参数保存到 Flash 的功能。这也就是 0x1010 的作用,后面会讲。
4.4 PDO 映射:把应用变量挂到 TPDO 上
PDO 映射是重头戏。先定义应用变量,再把它登记到 OD 表的厂家特定区:
uint16_t app_status = 0; /* 应用状态字,比如整机状态 */ uint8_t app_ctrl = 0; /* 应用控制字节,比如故障码 */ /* 0x2000 子索引 1:状态字 */ {0x2000, 1, OD_TYPE_U16, &app_status}, /* 0x2000 子索引 2:控制字节 */ {0x2000, 2, OD_TYPE_U8, &app_ctrl},然后配置 TPDO1 的映射参数。TPDO1 的通信参数在 0x1800,映射参数在 0x1A00。映射条目是一个 32 位值,高 16 位是对象索引,中间 8 位是子索引,低 8 位是数据位长:
uint8_t od_tpdo1_map_count = 2; /* 映射 2 个对象 */ uint32_t od_tpdo1_map[2] = { (0x2000u << 16) | (1u << 8) | 16u, /* 0x2000 子索引 1,16 位 */ (0x2000u << 16) | (2u << 8) | 8u /* 0x2000 子索引 2,8 位 */ }; /* 0x1A00 子索引 0:映射对象个数 */ {0x1A00, 0, OD_TYPE_U8, &od_tpdo1_map_count}, /* 0x1A00 子索引 1 和 2:映射条目内容 */ {0x1A00, 1, OD_TYPE_U32, &od_tpdo1_map[0]}, {0x1A00, 2, OD_TYPE_U32, &od_tpdo1_map[1]},TPDO1 共 0x1800 里还有传输类型、事件时间等参数。传输类型在 0x1800 子索引 2:
uint8_t od_tpdo1_type = 0xFF; /* 事件触发:变量变化立即发送 */ {0x1800, 2, OD_TYPE_U8, &od_tpdo1_type},传输类型取值要分清:1 表示在收到 SYNC 后发送,2 表示每收到 2 个 SYNC 发一次;0xFF 表示事件触发,即应用变量一变就发。实际项目中我建议调试阶段先用 0xFF,跑通后再改成周期同步型,减少总线负载。
4.5 参数保存到 Flash:0x1010 与 0x1011 的正确姿势
F103 没有 EEPROM,参数保存要用内部 Flash 模拟。CANopen 标准里 0x1010 子索引 1 是“保存所有参数”,0x1011 子索引 1 是“恢复出厂默认值”。协议栈的 OD 表里要挂这两个信号值:
uint32_t od_save_signal = 0; /* 0x1010 子索引 1 */ uint32_t od_restore_signal = 0; /* 0x1011 子索引 1 */ {0x1010, 1, OD_TYPE_U32, &od_save_signal}, {0x1011, 1, OD_TYPE_U32, &od_restore_signal},当主站向 0x1010 子索引 1 写入0x65766173(ASCII 字符串 "save")时,协议栈会触发保存回调,你要在这里执行 Flash 写入。写入前要先擦除整个扇区,再按顺序写。F103 内部 Flash 的擦除操作会阻塞 Flash 读写总线,擦除期间代码执行会暂停,所以这里有个隐含要求:保存回调执行时间不能太长,否则可能会触发硬件看门狗。
我通常的做法是:把所有需要掉电保存的 OD 数据集中到一个结构体里,在 Flash 里单独划出一页,页大小按芯片型号确认(F103 中低密度是 1KB/页,高密度是 2KB/页)。保存时先关中断,擦除整页,把结构体写入,再开中断,最后读回校验一遍。恢复默认值的逻辑类似,把结构体重新初始化即可。
另一个坑是 0x1011 的恢复默认值。恢复动作会把心跳周期、PDO 映射全部重置为编译时的初值,如果你之前通过 SDO 在线改过参数,恢复后一切都回到出厂状态。现场恢复完发现“PDO 不发了”,先看看是不是 0x1011 被触发过。
4.6 硬件相关:CAN 收发器供电和终端电阻
这个不在源码里,但移植过程中十有八九会遇到。F103 的 CAN 控制器输出是 TTL 电平,必须经过 CAN 收发器转成差分信号才能上总线。收发器供电不稳,或者总线两端没有 120Ω 终端电阻,会出现“单板回环正常、挂上总线就丢帧”的问题。
我在一个裸板项目里遇到过:收发器电源用的 LDO 输出有高频纹波,导致总线错误率偏高,节点频繁 BusOff。后来在收发器电源脚并了一个 10μF 电容加一个 0.1μF 高频电容,问题消失。这种问题用示波器看 CAN_H 和 CAN_L 的差分波形一眼就能认出来,波形上升沿不够陡,或者总线空闲电平不是 2.5V 左右,先查硬件再查软件。
5. CANopen 从机移植避坑:五个最常见的翻车现场
这一章是我自己走过和帮别人排查过的典型问题,每条都按现象、原因、解决三个步骤写清楚。可以说,这五个问题覆盖了从机移植 80% 的调试时间。
5.1 从机不上电不发心跳,主站扫描列表为空
现象:程序下载完成后,主站扫描节点列表为空,分析仪抓总线只有零星帧或者完全没有 0x700+ID 的心跳帧。
原因:最常犯的是对象字典里 0x1017 初始化成了 0,协议栈按标准行为关闭了心跳。另一个原因是在协议栈初始化之后,NMT 状态机没有按预期进入预操作状态,某些移植者在初始化流程里改了状态机入口,或者漏掉了 NMT 初始化函数的调用。
解决:第一步,确认 0x1017 的初值,在断点处查看od_heartbeat_time是否为 100 之类的非零值;第二步,在协议栈初始化完成后手动检查 NMT 状态,如果状态值看起来不对,回到适配层看 NMT 初始化有没有被调用;第三步,用分析仪确认物理层真的在发帧。我遇到过一种情况:CAN 初始化时HAL_CAN_Start没调用,代码不报错,但总线上一帧都没有,这类问题往往藏在“看起来完整”的初始化顺序里。
5.2 SDO 读写正常,PDO 报文永远是一个值
现象:用主站软件通过 SDO 修改应用变量,变量确实变了,但 TPDO 发出去的报文内容始终不变。
原因:PDO 映射指向的对象和 SDO 修改的对象不是同一个内存地址。常见场景是 OD 表里映射条目写的是 0x2001,但应用变量挂在 0x2000 上,SDO 改的是 0x2000,PDO 映射的是 0x2001,两边各说各话。还有一种原因:传输类型配置成了 SYNC 同步,但主站根本没有发 SYNC 帧,TPDO 一直等待触发。
解决:第一步,把 0x1800 子索引 2 临时改成 0xFF(事件触发),这样变量一变就发;第二步,用一个主站软件同时读 0x2000 和 PDO 映射条目,确认映射条目的索引、子索引、位长和你写入的 OD 条目完全一致;第三步,如果确认映射没问题但 PDO 还是不动,检查应用变量的地址是否被优化掉了——局部变量做映射源是大忌,映射必须指向稳定的全局变量或静态变量。
5.3 SDO 写 0x1010 保存参数,断电重启丢失
现象:主站写 0x1010 子索引 1 为0x65766173,SDO 返回成功,但断电重新上电后,之前改的节点 ID 或心跳周期全部回到默认值。
原因:Flash 写入地址没有对齐页首。F103 的 Flash 擦除是以页为单位的,写地址必须落在页边界上,否则擦除会失败或者擦错页。另一个常见原因是保存函数只写了数据没有擦除,Flash 在写 0 的位可以成功,但把 1 写成 0 之外的改变需要先擦除。
解决:检查保存回调里的地址计算,确保 Flash 目标地址是页对齐的。保存流程固定为:备份需要保留的其他数据(如果有),关中断,擦除整页,写入所有参数,开中断,读回校验。我在另一块板子上遇到的问题是:保存函数里用了HAL_FLASH_Program写 32 位数据,但它要求地址按 32 位对齐,我把结构体里的一个 uint16_t 字段放在了奇数偏移上,导致写失败返回错误,SDO 却已经回了成功。
5.4 总线一忙节点就掉线,进入 BusOff 后不再恢复
现象:正常通信时一切正常,一旦多个节点同时发 PDO,或者主站批量写 SDO,从机突然停止响应,分析仪上看到从机不再发任何帧。
原因:F103 只有 3 个发送邮箱,高负载时HAL_CAN_AddTxMessage返回失败,适配层发送函数如果反复重试,错误计数器会快速累积到 255,CAN 控制器进入 BusOff。如果初始化时AutoBusOff = DISABLE,控制器不会自动恢复,节点就“死”了。
解决:第一,打开AutoBusOff = ENABLE;第二,适配层发送失败时直接返回失败,不要死循环重试;第三,给 TPDO 设置事件时间(0x1800 子索引 5),比如设 10ms,意思是 10ms 内最多发一帧,把突发负载摊平。这个参数对 F103 尤其重要,三个邮箱真的不够用,硬件层面无法扩容,只能靠软件限流。
5.5 中断里调协议栈函数,程序跑飞进 HardFault
现象:单独测试 CAN 收发正常,协议栈一跑起来,偶发进入 HardFault,而且大概率发生在总线繁忙的时候。
原因:在 CAN 接收中断里直接调用了协议栈的解析函数。多数协议栈内部不是中断安全的,它有自己的缓冲区管理和临界区处理,中断嵌套场景下缓冲区可能被重复写入,指针被破坏,最终访问非法地址。F103 中断优先级配置不合理也会加剧这个问题,两个中断互相抢占,临界区保护形同虚设。
解决:中断里只做数据搬运,解析放主循环。NVIC 优先级建议为:TIM2 = 1、CAN RX = 2、SysTick = 3。数字越小优先级越高。CAN 接收中断里除了HAL_CAN_GetRxMessage和队列写入之外,不调用任何业务函数。这个原则坚持住了,HardFault 的概率会低一个数量级。
6. 进阶验证技巧:一台分析仪把从机底细看清楚
从机移植完成后,我习惯先不接真实主站,用一台 USB-CAN 分析仪配合电脑端工具做三个验证,确认协议栈行为完全符合标准再上产线。
第一个验证是抓心跳。把节点 ID 设为 3,分析仪过滤出 COB-ID 0x703 的帧,看它是否按设定周期(比如 100ms)稳定出现。心跳数据的第一字节是 NMT 状态:上电后应为 0x7F(预操作),主站下发启动命令后应变为 0x05(操作)。如果状态没变,说明 NMT 命令没有被正确处理,直接查核心层的 NMT 接收分支。
第二个验证是 SDO 读写。用主站软件发起一个 SDO 读请求,读 0x1000 设备类型和 0x1018 子索引 1 的厂商 ID,确认返回值和 OD 表里挂的数据一致。再试一次 SDO 写,往 0x2000 子索引 1 写入一个已知值,再用 SDO 读回来。读写都能对上,说明 SDO 服务器通路完整,对象字典的数据指针没有挂错。
第三个验证是触发 TPDO。以 0xFF 事件触发为例,修改应用变量,观察分析仪是否马上抓到 COB-ID 0x183 的 TPDO1 帧,并且帧数据里能看到修改后的值。这一步跑通,映射链路就完全确认了。
这三个验证通过之后,再考虑更进阶的 LSS。LSS 用于在线修改节点 ID:主站通过 COB-ID 0x7E5 发送 LSS 请求,从站在 0x7E4 响应。F103 上实现 LSS 快扫描后,现场改节点 ID 不用拆壳不用重新烧录,量产和售后都省事。我当时给模拟项目 X 加 LSS 支持,就是把 OD 里的节点 ID 变量暴露给 LSS 驱动,主站逐位扫描比较时从机响应匹配结果,流程不复杂,但能省下大量现场维护时间。
我自己的习惯是:把一个“心跳 + SDO + 1 个 TPDO”的最小从机例程永久保留在工程目录里。每次换新板子、换协议栈版本、改编译选项,都先烧这个最小例程验证一遍移植环境,通过了才往里加业务。这比在完整项目里排查环境问题快得多,也让我避开了很多“改了一行代码,不知道哪里带崩了”的玄学场面。希望帮到你。
本文还有配套的精品资源,点击获取