简介:本资源是面向嵌入式开发工程师与STM32进阶学习者的Keil uVision5工程级BOOTLOADER完整实现,专为STM32F103ZET6芯片设计,解决固件在线升级、多应用切换及安全启动等典型工业场景需求。压缩包共390个文件,涵盖69个.o目标文件、69个.crf编译中间文件、53个.h头文件、48个.c源码、114个.d依赖文件及8个.s汇编启动文件,辅以.axf、.bin、.hex、.map等关键输出文件,完整呈现从启动配置、外设初始化(RCC/TIM/FLASH/I2C/ADC)、存储介质访问到跳转执行的全流程代码结构,包体大小为12.4MB。已有783人下载学习,可直接导入Keil uVision5环境编译调试,无需额外配置即可复现BOOTLOADER硬件初始化、固件校验加载与向量表重定向等核心功能,特别适合理解STM32启动机制、定制安全启动流程或开展OTA升级开发实践。 从网上下载了一个名为“BOOTLOADRT_keil_keiluvision5_stm32f103zet6_BOOTLOADER程序_zip”的压缩包,解压后是一个基于Keil uVision5的STM32F103ZET6 Bootloader工程。我猜不少人拿到这类东西后第一反应是打开uvprojx直接编译,然后把hex烧进去,结果发现自己的板子毫无反应,或者升级功能永远跑不通。这篇文章就围绕这个典型的嵌入式Bootloader工程,把启动流程、Flash分区、Keil工程配置、关键代码、实测踩坑和进阶设计一次讲透。不管你手上正好是这个包,还是在做其他F103系列芯片的固件升级,都能从这里找到可以直接落地的思路。
1. 这个zip包里装的到底是什么:从工程名反推Bootloader的完整需求
1.1 一张Filelist看懂Bootloader工程结构
压缩包的名字挺长:“BOOTLOADRT_keil_keiluvision5_stm32f103zet6_BOOTLOADER程序_zip”,拆开看就是四个信息:Bootloader程序、Keil uVision5工程、目标芯片STM32F103ZET6、打包格式zip。很多初学者拿到手后容易被一堆文件吓到,其实一个标准的STM32 Bootloader工程,文件类别就那么几种:
- 启动文件:startup_stm32f10x_hd.s,F103ZET6属于高密度器件,必须用hd版本。
- 系统初始化:system_stm32f10x.c,里面包含SystemInit和时钟配置。
- 外设驱动:GPIO、USART、Flash、CRC等标准外设库或HAL库文件。
- 核心业务:bootloader.c/main.c,负责接收升级数据、擦写Flash、跳转App。
- 协议文件:ymodem.c / custom_protocol.c,不同工程差异很大。
- 分散加载文件:stm32f10x_flash.sct,由Keil根据Target配置自动生成,也可能手动改过。
- 输出文件:Bootloader.hex、Bootloader.map、Bootloader.axf等编译产物。
拿到工程后,我建议不要急着编译,先看三样东西:readme或说明文档里有没有Flash分区表;Keil的Options for Target中IROM1的起始地址和大小;App工程的起始地址是否和Bootloader预留的地址一致。后面两个不对,功能必然跑不起来。
1.2 “BootloadRT”里的RT是什么:我见过的几种叫法
工程名里的“RT”没有统一标准。有人说是RealTime,强调Bootloader要支持超时重传和实时反馈;有人说是RT-Thread的缩写,表示Bootloader里面跑了RTOS线程;还有人只是随手写的“Runtime”或者“RemoteTerminal”。就F103ZET6这颗Cortex-M3内核芯片来说,Bootloader这种场景我强烈不建议上RTOS。128KB的RAM不小但也谈不上宽裕,Bootloader只需要在启动阶段和升级阶段工作,一个裸机的状态机足够解决所有问题。引入RTOS之后,中断优先级、临界区保护、调度器切换这些问题反而会引入新的不确定性,调试难度成倍增加。所以看到“RT”,理解成“这是一个带实时反馈/超时机制的Bootloader”就够用了。
2. 上电到App运行的链路:向量表、Flash分区和Keil地址配置
2.1 STM32F103上电后第一条指令到底在哪里
很多人以为Bootloader就是“先说你好,再跳过去”,但实际上Cortex-M3的启动过程有严格顺序。STM32F103上电后,内核从地址0x08000000读初始栈顶指针(MSP),从0x08000004读复位向量,然后跳转到SystemInit和我们自己写的main函数。如果没有Bootloader,0x08000000就是App的起始地址。一旦加了Bootloader,App就不能再占0x08000000,否则上电后跑的是App,Bootloader根本没有执行机会。
正确的做法是让Bootloader占据Flash低地址区,App后移。例如把Bootloader放在0x08000000,长度64KB;App放在0x08010000,长度448KB。这是F103ZET6(512KB Flash)非常常见的一种划分。App的IROM1配置成0x08010000,size 0x70000,这样编译器生成的App代码、中断向量表都从0x08010000开始。
2.2 中断向量表偏移:Bootloader跳转App的命门
如果只改IROM1地址而不处理中断向量表,App即使能跳到main,一旦产生中断(串口、定时器、按键),程序就会跑回0x08000000附近去取中断处理函数,大概率取到的是Bootloader的异常向量,结果就是HardFault或者直接跑飞。解决方法是把Cortex-M3的向量表基地址寄存器VTOR(0xE000ED08)指向App的起始地址。
在STM32F10x标准库中,SystemInit函数默认把所有中断向量表放在0x08000000。所以App工程里必须重新设置VTOR。通常在system_stm32f10x.c里加上宏定义VECT_TAB_OFFSET,或者在main函数最前面自行设置。我习惯在SystemInit末尾做:
#if defined(VECT_TAB_OFFSET) SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET; #else SCB->VTOR = FLASH_BASE; #endif其中FLASH_BASE就是0x08000000。App工程在Keil里定义宏VECT_TAB_OFFSET=0x10000,就能把向量表定位到0x08010000。Bootloader不需要设置这个宏,因为它本身从0x08000000启动。
2.3 F103ZET6的Flash分区与MDK配置
F103ZET6的Flash不是均匀分扇区的。它的Flash组织是:前4个扇区各16KB,然后是1个64KB扇区,再往后是7个128KB扇区。如果Bootloader要从0x08000000分配64KB,正好覆盖前4个16KB扇区,而0x08010000起步就是那个64KB扇区。这个地址选得非常巧妙,App从0x08010000开始,不需要跨扇区擦除。
Keil里Bootloader工程的配置在Options for Target -> Target页面:
| 参数 | Bootloader | App |
|---|---|---|
| IROM1 Start | 0x08000000 | 0x08010000 |
| IROM1 Size | 0x10000 | 0x70000 |
| IRAM1 Start | 0x20000000 | 0x20000000 |
| IRAM1 Size | 0x20000 | 0x20000 |
| Define宏 | 无 | VECT_TAB_OFFSET=0x10000 |
如果你在Bootloader里也想使用部分RAM存放升级包,那可以考虑在IRAM1上拆一块,例如给Bootloader分配RAM区域为0x20000000大小0x18000,余下0x20018000起作为接收缓冲区。不过F103ZET6有64KB RAM,Bootloader自己用不了多少,最简单还是全部留给运行环境。
还有一个容易被忽略的点:烧录时Bootloader的编程算法必须覆盖0x08000000到0x08010000,App的编程算法要能覆盖0x08010000到0x0807FFFF。Keil里Flash Download栏目默认使用STM32F10x High-density Flash算法,它支持512KB范围,通常不用改。但如果你把App起始地址改成0x08020000,同时烧录文件选错了,有可能会出现烧录一半擦除整个芯片的情况。
3. 在Keil uVision5里把Bootloader跑起来的完整过程
3.1 工程结构:为什么我建议Bootloader和App分两个工程
有人喜欢把Bootloader和App放在同一个Keil工程里,通过宏切换。这样做省了工程管理,但坏处非常明显:当Bootloader和App使用了不同的优化等级、不同的库版本时,一次编译可能把两边都改了,升级风险更大。我做过很多个量产Bootloader,最终都回归到两个独立工程:Bootloader工程只维护升级逻辑,稳定后几乎不动;App工程专注业务功能。这样版本管理清晰,编译产物隔离,也不会出现“改了一行App代码,Bootloader却变了”的诡异问题。
如果你下载的zip包里只有一个Bootloader工程,我建议你同样新建一个App工程,按照上一节的地址配置,才能完整测试跳转和升级链路。
3.2 升级协议选择:自定义帧格式还是Ymodem
串口升级协议有很多种,最常见的两个方案是Ymodem协议和自定义帧协议。Ymodem是Xmodem的扩展,支持文件名和文件大小信息,校验用CRC16,很多串口工具(SecureCRT、Xshell)直接支持发送文件,省去写上位机的麻烦。如果Bootloader打算用“超级终端”手动升级,Ymodem是首选。
但如果你要做量产设备、上位机是自研的,或者需要通过蓝牙/4G模块空中升级,自定义帧协议会更灵活。一个最小可用的自定义协议至少要有帧头、包序号、数据长度、数据、CRC32、帧尾。比如:
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2字节 | 0xAA 0x55 |
| 命令 | 1字节 | 0x01握手,0x02数据,0x03结束 |
| 包序号 | 2字节 | 从0开始累加 |
| 数据长度 | 2字节 | 不超过1024 |
| 数据 | N字节 | 固件内容或命令参数 |
| CRC32 | 4字节 | 对数据段做CRC32 |
| 帧尾 | 2字节 | 0x0D 0x0A |
无论用哪种协议,Bootloader都必须实现超时重传和“错误包序号不写入Flash”的机制。在线升级不是把串口收到的每一个字节都直接按顺序写Flash就能完事的,网络抖动、线材干扰、静电都可能导致数据错位。
3.3 核心代码:跳转、Flash擦写、CRC校验
跳转函数是整个Bootloader的核心。一个健壮的跳转函数,不仅要设置向量表,还要在跳转前关闭外设中断、恢复默认时钟,防止App启动时遇到被Bootloader改过的外设状态。
#define APP_ADDR 0x08010000 typedef void (*pFunction)(void); void JumpToApp(void) { uint32_t sp = *(volatile uint32_t *)APP_ADDR; uint32_t pc = *(volatile uint32_t *)(APP_ADDR + 4); pFunction app_entry; // 检查栈顶指针是否落在SRAM范围内,防止跳到垃圾数据 if ((sp & 0xFFF00000) != 0x20000000) { return; } // 关闭全局中断 __disable_irq(); // 关闭可能开启的外设和复位时钟到默认状态 RCC_DeInit(); // 重定向中断向量表到App起始地址 SCB->VTOR = APP_ADDR; // 设置主栈指针,取出App复位向量并跳转 __set_MSP(sp); app_entry = (pFunction)pc; app_entry(); }Flash操作部分,用标准外设库实现比较简单。操作前要解锁Flash,擦除指定扇区,然后逐字编程。以写入1024字节为例:
#define FLASH_BASE_ADDR 0x08010000 #define PAGE_SIZE 1024 FLASH_Unlock(); FLASH_ErasePage(FLASH_BASE_ADDR); // 注意,只擦除App首扇区 for (uint32_t i = 0; i < PAGE_SIZE; i += 2) { FLASH_ProgramHalfWord(FLASH_BASE_ADDR + i, buffer[i] | (buffer[i + 1] << 8)); } FLASH_Lock();这里有个很容易犯错的地方:F103系列Flash编程宽度是16位,也就是半字。别直接用FLASH_ProgramWord,它其实在底层也是分两次操作但写两个地址,对于需要严格控制的场景容易踩坑。更安全的是按半字循环写入。擦除时也要看清扇区边界,0x08010000起始的扇区是64KB扇区,一擦就是整个64KB;如果你的App固件只有几KB,只擦首扇区就够了,但如果App超过64KB,则要依次擦除后续128KB扇区。
CRC校验可以用STM32F103内置的CRC外设,也可以自己软件算CRC32。内置CRC外设简单,但它的CRC32算法是固定的,和PC端常见的zlib CRC32不一定一致,需要统一。我建议升级文件在PC端生成一个CRC32,随固件一起发送,Bootloader收完App后先算本地CRC,再和接收到的CRC比,不一致就拒绝跳转,保留旧固件运行。
3.4 用ST-Link实测整个升级链路
把Bootloader烧进芯片后,第一次测试不要直接升级。先在Bootloader里加一个超时逻辑:上电后等待3秒,如果串口没收到握手命令,就自动跳转到App。没有这个逻辑的话,Bootloader会一直阻塞在串口接收,旧App永远跑不起来。
实测步骤大概是:
- 烧录Bootloader到0x08000000。
- 烧录App到0x08010000。
- 复位板卡,确认Bootloader在等待握手,3秒后跳转到App,App的LED或者串口日志正常。
- 进入升级模式,上位机发送握手,再发送App bin和CRC,Bootloader收到后擦除、写入、校验。
- 校验成功后复位,看是否自动运行新App。
- 尝试在App运行状态下再次触发升级,确认Bootloader和App之间的“运行标志”切换正常。
这里最常用的切换机制是在RAM里放一个标志位,或者用一个无效的备份寄存器值。比如Bootloader先查询某个备份寄存器是否为0xA5,如果是则进入升级模式,否则直接跳App。App收到升级命令后,先把备份寄存器设为0xA5,然后复位。
4. 实测中躲不过去的坑:向量表、扇区擦除、断电与zip解压
4.1 跳转后HardFault的排查链路
我最常遇到的现象是:Bootloader能打印“Jump to App”,然后就卡死了,进调试器一看停在HardFault_Handler。这种问题大多数人第一反应是“跳转地址不对”。但地址不对通常连跳都不会跳。实际上先检查这个顺序:
- 先用J-Link/ST-Link读0x08010000处的数据,确认App是否真的烧进去了。很多“跳转失败”是因为App根本没烧录到0x08010000,而是用了默认的0x08000000。
- 确认App工程里VECT_TAB_OFFSET宏是否生效。如果没有生效,擦掉App中断向量表下面几个函数试试,如果是向量表错误,往往一发生中断就死。
- 确认跳转前没有开FreeRTOS/ucOS等需要维持时钟节拍的东西。跳转前调用
RCC_DeInit()和__disable_irq()可以解决大部分中断冲突。 - 在跳转函数里临时读一下App的第一个半字,如果全0xFF,说明那个地址是空的。
排查这类问题,除了看调试器寄存器,还要善用Keil的Memory窗口。把地址跳到0x08010000,前8字节是栈指针初始值和复位向量,正常情况下后者指向0x0801xxxx且不等于0xFFFFFFFF。
4.2 Flash扇区擦除边界搞错,App区被抹了一半
F103ZET6的Flash擦除是按扇区整块擦除,不能按字节擦。有人以为从0x08010000开始擦1KB,结果调用FLASH_ErasePage(0x08010000)时没意识到它会擦除整个64KB扇区,导致升级包只有8KB,却把0x08010000到0x0801FFFF全部擦成了0xFF。如果这期间掉电,App区就是半残状态。
解决办法有几种:
- 在Bootloader里不做“整包写入前擦全Flash”的操作,而是边收边擦。每收到一个扇区大小的数据,只擦当前扇区,写完后继续。这样即使写入中途失败,最多损失一个扇区,不会把整个App区抹掉。
- 更保守的做法是擦写前先在内存中缓存一整个扇区的数据,擦后立即写回,保证掉电时旧固件还能恢复(在无备份区的情况下,这是下策)。
- 最稳的方案还是做一个备份区或A/B分区,在升级过程中旧固件始终完整保留。
4.3 栈顶指针不加校验,升级一个坏固件直接失控
Bootloader跳转前最常见的偷懒写法是:
__set_MSP(*(uint32_t *)APP_ADDR); jump = (void (*)(void))(*(uint32_t *)(APP_ADDR + 4)); jump();如果App地址根本没烧录内容,或者只有半截固件,读到的栈顶指针可能是0xFFFFFFFF,跳转后内核立刻异常。更危险的是如果栈顶指针落在合法SRAM范围内但对应内容并非有效栈,程序不会马上死,而是跑着跑着到处乱跳,比直接HardFault更难查。
所以跳转前必须做合法性校验,至少检查两点:
- 栈顶指针是否落在SRAM地址范围内:
((sp & 0xFFF00000) == 0x20000000)。 - 复位向量是否落在App地址范围内:
((pc & 0xFFF80000) == 0x08000000),更严格一点可以要求pc >= APP_ADDR。
我见过有人在量产线上因为固件烧录异常导致整批设备“升完级变砖”,最后定位到就是跳转前没做地址合法性判断,把一个0xFF当成了有效跳转入口。
4.4 升级掉电的补救:临时存储区与回滚状态机
掉电是升级场景里绕不开的问题。哪怕是插座供电的设备,也可能因为用户在升级过程中拔电而遇到“Bootloader还活着,但App已经被擦了一半”的尴尬局面。
如果你不想做A/B分区,最简单的补救方案是:在Flash里另外开辟一个临时存储区,先把整个固件包收完、校验通过,再擦写App区。F103ZET6本身有512KB Flash,Bootloader占64KB,App区占448KB,如果要再腾一个备份区就捉襟见肘。但可以在App区内部做临时缓冲,只用于保存“升级过程中被覆盖”的旧扇区数据。实现思路是:
- 把App区视为A区和B区交替,或者直接把App分成两段。
- 升级时先写B区,全部校验成功后,更新引导记录,再改为从B启动。
如果没有A/B分区,那么至少要保证“先收完整包再擦写”的流程。也就是说,接收数据和Flash写入分开,接收缓冲区可以很大,也可以使用外部串行Flash/SD卡。很多路由器、安卓设备都是先把固件写入缓存分区,再触发升级,就是这个道理。
4.5 关于zip压缩包本身的坑:解压报错和工程路径
最后补一个题外话,但很多人确实会卡在这里。标题里带了“zip”字样,说明工程是以压缩包形式发布的。从这类压缩包解压出来的Keil工程,最常见的问题有三个:
- 解压后直接打开uvprojx,提示找不到芯片或找不到PACK。这种情况通常是你的Keil MDK没有安装对应Device Family Pack。F103需要安装Keil.STM32F1xx_DFP。
- 工程路径或文件名包含中文、特殊字符,导致编译过程中编译器无法访问中间文件。Keil在这个问题上特别矫情,我一般把所有工程都放在纯英文路径下,比如
D:\work\stm32_boot\。 - 用某些在线解压工具或压缩软件解压时,丢失了文件夹结构。Bootloader工程里有些相对路径依赖特定的文件夹层级,建议用较新版本的解压软件,且不要用“预览”功能,老老实实解压到本地。
另外,zip文件本身也可能损坏。如果解压提示“file is not a zip file”或“Could not find EOCD”,先确认文件是否下载完整,再用命令行工具重新解压。很多网盘下载的zip容易断点,不用急着怀疑工程有问题。
5. 有没有更稳的玩法:A/B双分区、回滚和UDS Bootloader
5.1 A/B分区比“改改地址”复杂在哪里
网上很多关于A/B分区的介绍,一听就是“把Flash分成两个区,一个跑一个备”。但真正实现时,要处理的细节远不止“两份代码”:
- 存储健康标记。每次App启动后,要有一个“运行确认”机制,比如App在启动10秒后把某个标志置为成功,否则Bootloader认为该分区不可用,自动回滚到另一个分区。
- 升级过程可以交叉,也就是你可以在A区正常运行时升级B区,升级完成后切换启动分区。这样的话,升级期间即使断电,也不影响当前正在跑的系统。
- 切换启动分区的动作必须原子化。不能出现“标记已经写着从B启动,但B还没写完”的中间态。
F103ZET6的512KB Flash做A/B分区确实有点紧。如果每个App分区只有224KB,很多大的App放不下。但如果是移植到F407或者更高容量的芯片,这套思路就很值了。你在F103上把Bootloader和A/B状态管理写明白,换芯片时只要把Flash驱动改掉,业务逻辑完全复用。
5.2 状态机驱动的回滚设计
回滚功能不复杂,但很讲究逻辑顺序。我习惯用状态机来表示Bootloader的升级生命周期:
| 状态 | 说明 | 跳转条件 |
|---|---|---|
| IDLE | 上电正常,等待启动App或升级请求 | 收到升级命令,进入RECEIVE |
| RECEIVE | 正在接收固件包 | 收包完整且CRC通过,进入VERIFY |
| VERIFY | 校验固件合法性 | 校验通过,进入WRITE |
| WRITE | 擦写目标分区 | 写入完成,进入UPDATE_META |
| UPDATE_META | 更新启动标记 | 写标记完成,进入REBOOT |
| REBOOT | 软复位 | 复位后从新分区启动 |
| ROLLBACK | 启动失败或新分区不可用 | 从保留分区重新启动 |
在APP主动触发升级时,Bootloader不要一上来就擦除App区。先保存一个“当前分区可用”的标记,然后在另一个分区写入新固件。如果写入失败,Bootloader在下次启动时检测到当前分区不可用,就自动从保留分区启动,同时向上位机报告错误码。
5.3 从自定义协议到UDS Bootloader的迁移成本
如果你的产品最终要过车规或者和诊断工具对接,那迟早会接触UDS Bootloader。UDS(Unified Diagnostic Services)本身是应用层协议,它定义了0x10、0x27、0x34、0x36、0x37等诊断服务,通过CAN或DoIP承载。迁移到UDS Bootloader,不只是把帧协议换了:
- 需要增加会话管理和安全访问机制,比如0x27服务里的seed-key验证。
- 需要按NRC(Negative Response Code)处理各种错误,不再是简单回一个ACK。
- 对应的上位机要从自研小工具换成CANalyzer、PCAN等诊断工具。
我的建议是:如果只是做个人项目、毕业设计或小批量产品,自定义协议完全够用,先把Bootloader的核心哲学“中断、地址、校验、回滚”搞清楚。如果真的要上UDS,最好先完整读完UDS规范文档,再基于成熟的分层架构去设计,否则几千行代码写完,协议栈可能还是有兼容性问题。
F103ZET6的Bootloader本身没有太多秘密,难的是把整个升级链路想清楚:Flash怎么分、向量表怎么偏、怎么校验、怎么回滚。我在实际做量产设备时,Bootloader代码往往只有几百行,但它的每一个if、每一个宏定义背后,几乎都是从变砖经验里抠出来的。最后再提一个小习惯:不管工程量多小,都要保留Bootloader的hex文件、App的bin文件以及对应的版本号记录。否则过了几个月,你根本不知道哪一版固件才是能烧的“黄金版本”。
本文还有配套的精品资源,点击获取