1. 为什么我们需要一个CAN总线Bootloader?
大家好,我是老张,在嵌入式这行摸爬滚打十几年了,从早期的51单片机玩到现在的各种ARM核MCU,踩过的坑比写过的代码都多。今天想和大家聊聊一个在工业控制和汽车电子领域特别实用的技术:给STM32F103做一个CAN总线的Bootloader。
你可能要问,STM32不是有自带的串口IAP(在应用编程)例程吗,为啥还要折腾CAN总线?我拿亲身经历来说吧。几年前我参与一个分布式电机控制项目,十几个控制器装在设备的不同角落,外壳都是密封防尘防水的。每次软件有小改动,工程师都得抱着电脑和下载器,一个个拆外壳、接调试口,折腾一下午,效率低不说,还容易把接头搞坏。后来我们下定决心,必须上远程升级。现场总线里,CAN以其高可靠性、多主结构和优秀的抗干扰能力,成了不二之选。有了CAN Bootloader,工程师在总控室点几下鼠标,就能给整个网络里的设备批量升级固件,那感觉,就像给一群机器人做“空中手术”,既高效又优雅。
所以,这个Bootloader到底是干嘛的?简单说,它是一段预先烧录在MCU Flash最前端的小程序。芯片上电后,先运行它。它的任务就是“侦查”:检查有没有来自CAN总线的升级指令。如果有,它就变身“数据搬运工”和“Flash烧录员”,通过CAN总线接收新的应用程序固件包,并安全地写入到Flash的指定位置。如果没有升级指令,或者升级完成了,它就一个“跳跃”,把CPU的执行权交给后面真正的用户应用程序。这样一来,你的产品出厂后,无需拆机,就能通过CAN网络进行功能更新、bug修复,产品的生命周期和维护便捷性大大提升。
这篇文章,我就带你从零开始,手把手实现一个基于STM32F103C8T6的CAN Bootloader。我会把硬件连接、CAN驱动配置、Bootloader核心逻辑、应用程序的适配,以及那些容易踩坑的细节,掰开揉碎了讲清楚。只要你有一点STM32和C语言的基础,跟着做,一定能搞定。
2. 动手前的准备:硬件与软件平台搭建
工欲善其事,必先利其器。咱们先看看需要准备些什么。我这里用的核心芯片是STM32F103C8T6,也就是常说的“蓝莓派”最小系统板上的那种,性价比极高,资源也足够。
2.1 硬件清单
- STM32F103核心板:任何搭载STM32F103C8T6的开发板或你自己设计的功能板都可以。注意,它必须引出CAN接口(通常是PA11和PA12,对应CAN_RX和CAN_TX)和一个USB转串口用于打印调试信息。
- CAN总线收发器:STM32内部的CAN控制器是逻辑电平的,需要一颗TJA1050或SN65HVD230这类芯片转换成标准的CAN差分信号(CAN_H, CAN_L)。这是硬件连接的关键,接线错了通信肯定不通。
- CAN分析仪:这是我们的“上位机”。你可以用周立功的USBCAN卡,也可以用更亲民的CANable、PCAN-USB,或者淘宝上几十块的USB转CAN模块。它们的作用是把电脑变成一个CAN节点,用来发送升级命令和固件数据。
- ST-LINK调试器:用于第一次烧录Bootloader程序以及后续的调试。
- 120欧姆终端电阻:CAN总线两端需要各接一个120欧姆的终端电阻,用于阻抗匹配,消除信号反射。如果你的网络只有两个节点(开发板和CAN分析仪),在总线两端各接一个就行。
硬件连接示意图:
[你的电脑] --(USB)--> [CAN分析仪] --(CAN_H/CAN_L)--> [TJA1050] --(PA11/PA12)--> [STM32F103] |--[120Ω电阻]--|务必确保CAN_H和CAN_L没有接反,并且总线布线规范,远离干扰源。
2.2 软件环境
- 集成开发环境:我强烈推荐使用STM32CubeIDE。它是ST官方推出的免费IDE,集成了STM32CubeMX配置工具和基于Eclipse的编译调试环境,一站式解决,非常方便。本文的所有代码和配置都将基于STM32CubeIDE。
- STM32CubeMX:虽然CubeIDE里内置了,但单独提一下。它是图形化引脚和外设配置工具,能自动生成初始化代码,是我们快速搭建项目框架的神器。
- 上位机软件:你需要一个能通过CAN分析仪发送数据的工具。如果你用的CAN分析仪有官方软件(如周立功的ZCANPRO),可以直接用。也可以自己用Python(
python-can库)或C#等语言写一个简单的发送程序,更灵活。为了简化,我会先介绍用现成软件发送标准数据帧的方法。
准备好这些,我们的“手术台”就搭好了。接下来,进入最核心的环节:规划Flash这座“房子”怎么住。
3. 核心规划:给Bootloader和App分好“家”
Flash空间就像一块有限的地皮,Bootloader和用户应用程序(App)两“家人”都得住进去,还得互不干扰。规划不合理,要么Bootloader太小住不下,要么App地址跑飞了。STM32F103C8T6有64KB的Flash,我们得精打细算。
3.1 Flash地址空间分配
STM32上电后,固定从地址0x0800 0000开始执行代码。我们的Bootloader必须放在这里。剩下的空间留给App。一个典型的分区方案如下:
| 区域 | 起始地址 | 结束地址 | 大小 | 用途说明 |
|---|---|---|---|---|
| Bootloader | 0x0800 0000 | 0x0800 3FFF | 16 KB | 存放Bootloader程序。必须包含中断向量表。 |
| App 区域 | 0x0800 4000 | 0x0800 FFFF | 48 KB | 存放用户应用程序。其中断向量表需要偏移。 |
| (可选)参数区 | 0x0800 3800 | 0x0800 3FFF | 2 KB | 可用于存放升级标志、App版本号等参数。 |
为什么是16KB?对于一个功能精简的CAN Bootloader(包含CAN驱动、Flash擦写、简单协议解析),16KB通常绰绰有余。你可以通过优化代码,尝试压缩到8KB甚至更小,为App腾出更多空间。我建议初期预留大一点,调试成功后再优化缩小。
如何在CubeIDE中设置?这是关键步骤,设置错了程序无法正确跳转。
- 对于Bootloader工程:
- 打开工程的属性(Properties)。
- 找到
C/C++ Build->Settings->Tool Settings->MCU Settings。 - 将
Flash base address设置为0x08000000,Flash size设置为0x4000(16KB)。这告诉链接器,代码从0x08000000开始存放,大小不超过16KB。
- 对于App工程:
- 同样位置,将
Flash base address设置为0x08004000,Flash size设置为0xC000(48KB)。 - 更关键的一步:在
C/C++ Build->Settings->Tool Settings->MCU Linker->Managed Linker Script下,需要修改链接脚本,或者直接在工程配置中定义宏:-D VECT_TAB_OFFSET=0x4000。这个操作是为了将App的中断向量表偏移到它自己的起始地址。你还需要在App的main()函数最开头,通过SCB->VTOR = 0x08004000;这条语句,设置向量表偏移寄存器。这两步缺一不可,否则App的中断无法响应。
- 同样位置,将
3.2 启动流程与跳转逻辑
规划好住址,接下来是设计“行动路线图”。
- 上电:CPU从0x08000000启动,执行Bootloader。
- Bootloader初始化:初始化基本系统时钟、GPIO、CAN,可能还有串口用于调试打印。
- 升级判断:这是Bootloader的决策点。我常用的方法有两种:
- 引脚电平检测:比如,将一个特定的GPIO(如PB0)通过上拉电阻拉到高电平,同时引出到一个测试点。需要升级时,用跳线帽将该测试点接到地(低电平)。Bootloader启动后读取这个引脚,如果是低电平,则进入升级模式;如果是高电平,则尝试跳转到App。
- CAN指令触发:Bootloader启动后,在短时间内(如500ms)监听CAN总线。如果收到特定的“进入升级模式”指令帧(例如,CAN ID为0x101,数据为0x5A),则进入升级模式;否则超时后跳转到App。这种方式更灵活,无需硬件操作。
- 执行分支:
- 跳转至App:如果判断不需要升级,Bootloader需要“打扫干净屋子”。关闭自己的外设中断(尤其是CAN和定时器),将堆栈指针(MSP)设置为App向量表的第一个字(即0x08004000地址的内容),然后跳转到App向量表的第二个字(即复位中断地址,0x08004004)所指向的地址。这个跳转通常用一个函数指针实现:
((void (*)(void))(*((uint32_t *)(APP_ADDRESS + 4))))();。 - 进入升级模式:如果判断需要升级,则开启CAN接收中断,等待上位机连接并传输固件数据。
- 跳转至App:如果判断不需要升级,Bootloader需要“打扫干净屋子”。关闭自己的外设中断(尤其是CAN和定时器),将堆栈指针(MSP)设置为App向量表的第一个字(即0x08004000地址的内容),然后跳转到App向量表的第二个字(即复位中断地址,0x08004004)所指向的地址。这个跳转通常用一个函数指针实现:
这个流程设计,确保了Bootloader和App之间清晰、安全的交接。
4. CAN驱动与通信协议:让数据可靠地跑起来
Bootloader的“耳朵”和“嘴巴”就是CAN。配置好CAN驱动,定义好通信协议,数据才能准确无误地传输。
4.1 使用CubeMX配置CAN外设
在STM32CubeMX里配置CAN非常直观:
- 在
Pinout & Configuration视图,找到Connectivity->CAN1。 - 将工作模式设置为
Normal。 - 参数配置是重点,尤其是波特率。工业上常用500kbps。计算公式是:
波特率 = APB1时钟 / (Prescaler * (TimeSeg1 + TimeSeg2 + 1))。- 我们的系统时钟通常是72MHz,APB1时钟是36MHz。
- 在CubeMX界面,设置
Prescaler为6,Time Segment 1为13,Time Segment 2为2,Synchronization Jump Width为1。这样计算下来:36M / (6 * (13+2+1)) = 36M / 96 = 375kbps。如果要精确的500kbps,需要调整分频和时段,例如APB1时钟为48M时更好配置。其实,只要通信双方(Bootloader和上位机)使用相同的参数,375kbps或500kbps都能稳定工作。我这里为了兼容性,常设为250kbps或500kbps。
- 滤波器配置:为了只接收升级相关的帧,我们可以设置CAN过滤器。例如,设置一个掩码模式滤波器,只接收标准ID为
0x100~0x10F的帧。在Bootloader中,这能有效减少无关总线消息的干扰。 - 别忘了开启CAN中断(
CAN1_RX0_IRQn),并在NVIC中设置优先级。
生成代码后,CubeMX会帮我们生成MX_CAN1_Init()函数。我们还需要自己封装发送函数和中断处理函数。
4.2 设计一个简单实用的通信协议
光有CAN收发能力还不够,我们需要一个协议来组织固件数据。固件文件(通常是.bin或.hex)可能很大,需要拆分成很多个CAN数据帧来发送。我设计过一个简单可靠的协议,你可以参考:
帧格式定义:每一帧数据用一个**标准CAN数据帧(8字节数据场)**来传输。
- CAN ID:用于区分帧类型。例如:
0x101:命令帧。上位机发送,用于握手、开始传输、结束传输、复位等。数据场可定义具体命令码。0x102:数据帧。上位机发送,承载固件数据。0x201:响应帧。Bootloader发送,回复上位机的命令或数据,包含成功/失败状态。
- 数据场:对于数据帧,可以这样设计:
data[0]: 包序列号高字节data[1]: 包序列号低字节(共16位,支持65536个包)data[2]: 本包数据长度(通常为6,因为后面6个字节是实际数据)data[3]~data[7]: 固件数据(6字节)。为什么不发满8字节?留出两个字节给序列号和长度信息,方便校验和组装。
通信流程(类似XMODEM协议):
- 握手:上位机发送
0x101命令帧(命令码:0x01)。Bootloader回复0x201响应帧(状态:就绪)。 - 数据传输:
- 上位机按顺序发送数据帧(ID:0x102),每帧包含序列号和6字节数据。
- Bootloader每收到一帧,校验序列号是否正确,然后回复一个带当前序列号的ACK响应帧(ID:0x201)。
- 上位机收到ACK后,发送下一帧。如果Bootloader发现序列号不连续或校验失败,回复NAK,上位机应重发上一帧。
- 结束传输:数据发完后,上位机发送结束命令帧(0x101,命令码:0x04)。Bootloader进行整体校验(如CRC32),校验通过后回复成功,并将接收到的数据写入Flash;失败则回复错误。
- 跳转执行:上位机发送复位命令帧(0x101,命令码:0xFF),Bootloader软件复位或直接跳转到App。
这个协议加入了序列号、ACK/NAK应答和整体校验,虽然比单纯“发-收”复杂一点,但能极大提高在可能有干扰的CAN总线上的升级成功率。实测下来,在复杂的工业设备环境中也非常稳。
5. Bootloader核心代码实现
协议定好了,我们来写Bootloader的核心代码。我会挑几个最关键的函数来讲。
5.1 跳转到App的函数
这是Bootloader的“临门一脚”,必须确保干净利落。
#define APP_ADDRESS 0x08004000 typedef void (*pFunction)(void); void JumpToApp(void) { pFunction Jump_To_Application; uint32_t JumpAddress; // 1. 关闭所有开启的中断(非常重要!) HAL_CAN_DeInit(&hcan1); HAL_NVIC_DisableIRQ(CAN1_RX0_IRQn); // ... 关闭其他可能开启的中断,如SysTick SysTick->CTRL = 0; // 禁用SysTick中断 // 2. 设置堆栈指针(MSP)为App栈顶 __set_MSP(*(__IO uint32_t*)APP_ADDRESS); // 3. 获取App的复位中断服务程序地址 JumpAddress = *(__IO uint32_t*)(APP_ADDRESS + 4); Jump_To_Application = (pFunction)JumpAddress; // 4. 跳转! Jump_To_Application(); }注意:跳转前务必禁用所有中断,否则跳转到App后,Bootloader的中断服务程序可能被错误触发,导致系统崩溃。
5.2 CAN接收中断与协议解析
在CAN接收中断服务函数里,我们不仅要收数据,还要快速解析。
// 定义接收缓冲区和解包状态机 typedef enum { STATE_IDLE, STATE_RECEIVING_DATA, STATE_WAIT_FOR_END } Bootloader_State_t; Bootloader_State_t bl_state = STATE_IDLE; uint32_t current_packet_num = 0; uint8_t rx_data_buffer[APP_MAX_SIZE]; // 一个大缓冲区,存放所有收到的固件数据 uint32_t data_index = 0; void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan) { CAN_RxHeaderTypeDef RxHeader; uint8_t RxData[8]; HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &RxHeader, RxData); switch(RxHeader.StdId) { case 0x101: // 命令帧 handle_command(RxData[0]); // 处理命令码 break; case 0x102: // 数据帧 if(bl_state == STATE_RECEIVING_DATA) { uint16_t pkt_num = (RxData[0] << 8) | RxData[1]; if(pkt_num == current_packet_num) { // 序列号正确,存入缓冲区 memcpy(&rx_data_buffer[data_index], &RxData[2], RxData[2]); data_index += RxData[2]; current_packet_num++; send_ack_response(pkt_num, STATUS_OK); // 发送ACK } else { send_ack_response(current_packet_num, STATUS_NACK); // 发送NAK,请求重发 } } break; default: // 忽略其他ID的帧 break; } }这里用了一个简单的状态机来管理升级流程,并用current_packet_num来确保数据包的顺序。在实际项目中,你可能需要更复杂的缓冲区管理,比如分块接收、边收边写Flash,以减少对RAM的占用。
5.3 Flash的擦写操作
这是最后一步,也是风险最高的一步。写错了可能让芯片“变砖”。
#include "stm32f1xx_hal_flash.h" int erase_app_flash(void) { FLASH_EraseInitTypeDef EraseInitStruct; uint32_t SectorError = 0; // 1. 解锁Flash HAL_FLASH_Unlock(); // 2. 清除所有Flash操作标志位 __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); // 3. 配置擦除参数:从第16扇区开始(0x08004000),擦除N个扇区 EraseInitStruct.TypeErase = FLASH_TYPEERASE_PAGES; EraseInitStruct.PageAddress = APP_ADDRESS; EraseInitStruct.NbPages = (APP_SIZE + FLASH_PAGE_SIZE - 1) / FLASH_PAGE_SIZE; // 计算需要的页数 // 4. 执行擦除 if (HAL_FLASHEx_Erase(&EraseInitStruct, &SectorError) != HAL_OK) { HAL_FLASH_Lock(); return -1; // 擦除失败 } HAL_FLASH_Lock(); return 0; // 成功 } int write_data_to_flash(uint32_t address, uint8_t *data, uint32_t len) { uint32_t i; uint16_t *pData = (uint16_t*)data; // Flash按半字(16位)编程 HAL_FLASH_Unlock(); for(i = 0; i < len; i += 2) { // 使用HAL库函数编程半字 if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, address + i, *pData) != HAL_OK) { HAL_FLASH_Lock(); return -1; } pData++; } HAL_FLASH_Lock(); return 0; }致命细节:
- 对齐:Flash写入地址必须是半字(2字节)对齐的。你的固件数据最好也是2的倍数。
- 擦除:Flash只能从1写0,不能从0写1。所以写入前必须先擦除,擦除会把整个扇区变成0xFF。
- 锁保护:操作完一定要
HAL_FLASH_Lock(),防止程序跑飞意外修改Flash。 - 超时:擦除和写入操作需要时间,HAL库函数内部有超时机制,但如果你的系统时钟配置异常,可能导致超时失败。
6. 应用程序的适配与联调
Bootloader写好了,别忘了App也需要做一些改动才能“认识”这个新家。
6.1 修改App的链接脚本和向量表
如前所述,在App工程的CubeIDE配置中,设置Flash base address为0x08004000,并添加-D VECT_TAB_OFFSET=0x4000的宏定义。然后在main()函数开头添加:
int main(void) { // 重新设置中断向量表偏移地址 SCB->VTOR = FLASH_BASE | 0x4000; // ... 其他初始化代码 while (1) { // 你的应用代码 } }这样,App产生的中断,CPU才能正确找到它的中断服务程序。
6.2 生成和转换固件文件
在STM32CubeIDE中编译App工程,会生成.elf文件。我们需要的是纯二进制数据文件(.bin)或Intel Hex文件(.hex)。
- .bin文件:最直接,就是App代码的二进制映像,从
0x08004000开始。可以用arm-none-eabi-objcopy工具从.elf转换:arm-none-eabi-objcopy -O binary app.elf app.bin。 - .hex文件:包含地址信息,很多上位机工具可以直接解析。
重要:你的上位机软件需要能读取这个二进制文件,并按照你定义的CAN协议(如前文的6字节一帧)将其拆分发送。
6.3 完整的升级流程联调测试
这是最激动人心也最容易出错的环节。建议按步骤测试:
- 单独测试Bootloader:烧录Bootloader后,通过串口打印信息,测试GPIO触发进入升级模式是否正常,CAN初始化是否成功。
- 测试CAN通信:用CAN分析仪软件,手动发送握手命令帧(0x101),看Bootloader能否正确回复响应帧(0x201)。
- 测试App跳转:先烧录一个简单的App(比如一个LED闪烁程序)到
0x08004000地址(可以使用调试器强制烧录到该地址)。然后让Bootloader跳转,观察LED是否闪烁,验证跳转成功。 - 模拟升级:编写一个简单的上位机脚本,将一个非常小的.bin文件(比如1KB)按照协议拆包发送。在Bootloader端,可以先不真正写Flash,而是将接收到的数据通过串口打印出来,与源文件对比,验证协议解析和数据接收的正确性。
- 完整流程实战:最后,进行端到端的完整升级。擦除原有App,通过CAN发送新的App固件,Bootloader接收并写入Flash,最后跳转执行。
我踩过的一个坑是,Bootloader跳转后,App的时钟初始化又配置了一遍CAN引脚,导致CAN分析仪瞬间掉线。后来在App里,对于Bootloader已经初始化过且App不用的外设,我选择保持原状或不重复初始化。
整个过程虽然繁琐,但当你第一次不借助调试器,仅凭一根CAN线就成功给设备更新了程序时,那种成就感是非常棒的。这个自制的CAN Bootloader,也成为了我们后续很多工控和车载项目的标准基础组件,大大提升了开发效率和产品可维护性。希望我的这些经验,能帮你少走些弯路。