☰
STM32F103 Bootloader从Flash分区到YMODEM串口升级实战
2026/10/15 11:52:07 网站建设 项目流程

简介:面向STM32F103微控制器的Bootloader源码与编译产物,为嵌入式开发者及初学者提供了一套可运行的固件升级、启动引导实现范例。资源围绕串口与USB等通信接口的固件接收、校验、闪存写入及启动跳转流程展开,覆盖启动模式选择、异常恢复、通信协议等关键设计,读者可从系统启动流程入手,逐步理解引导程序如何接管硬件、与上位机交互并完成固件安全更新。压缩包共247个文件,大小约4.53MB,以C源文件与头文件为核心,辅以编译中间文件、链接脚本及hex、bin可执行文件,另有少量界面与说明文档,便于对照源码理解整体工程,目前已有3586人学习使用。通过阅读源码可掌握固件更新协议、Flash分区管理及应用程序跳转细节;结合附带的bin/hex文件,还能用于实际开发调试,并为自研Bootloader提供可裁剪、可扩展的参考框架。

1. 先搞清楚一件事:bootloader 到底在解决谁的痛

做 STM32F103 产品的人早晚会撞上同一个问题:样品阶段一根 ST-Link 全搞定,一旦到了产线和售后,每改一次固件就要拆壳、插仿真器、按复位键,这个流程又慢又贵。所谓 STM32F103 系列 Bootloader,本质是出厂前烧进芯片最前面的那一小段程序——它上电先接管 CPU,检查要不要升级,要升级就通过串口收新固件、写进 Flash,不要升级就直接跳进 App。于是后续所有固件更新都变成“用户拿一根串口线、一个升级工具就能就地刷”,仿真器只用在出厂第一次烧录。这篇文章写给三类人:正在做 F103 产品的嵌入式工程师、给产线搭升级工装的人、以及被“App 跑飞整机瘫痪”坑过的售后。看完你能独立规划分区、写出跳转代码、搞定 YMODEM 传输,并且知道哪些坑值得花半天去避。

2. 先卡 Flash 边界:分区、中断向量与跳转前置条件

2.1 Flash 分区怎么划才够用:从 0x08000000 开始留多少给 Boot

F103 的 Flash 起始地址固定在 0x08000000,整个存储空间从首地址开始线性排布。Bootloader 本质上是“占着低地址区间的常驻程序”,App 必须往高地址偏移。常见做法是 Boot 占 16KB 或 32KB,我把两种规划都列出来。

分区起始地址大小存放内容
Boot0x0800000016KB / 32KBBootloader 固件、升级逻辑
App0x08004000 / 0x08008000剩余容量应用固件
参数区(可选)App 末尾对齐页1~2 页固件版本、升级标志、回滚备份

选择 16KB 还是 32KB,取决于你的升级协议复杂度。如果只用串口 YMODEM,Flash 驱动加跳转逻辑压缩到 8KB 以内都很轻松,16KB 富余。如果还要加固件加密、双备份、参数存储,那 32KB 更稳妥。F103C8T6 只有 64KB Flash,App 实际可用 32KB,小项目勉强够,所以选型时别把 Flash 算得太满——Bootloader 占掉的空间是永久性开销。分区表定下来后,Boot 和 App 各用各的链接脚本,App 的偏移量在工程里写死,这个约定贯穿整个开发周期,后面改一次就是连锁反应,所以第一版就要把边界卡死。

2.2 中断向量表为什么必须重定向:SCB->VTOR 一票否决

芯片上电后默认从 0x08000000 开始执行,中断向量表也默认在 0x08000000 处取。Boot 跳进 App 之后,App 里的外设中断照样触发,如果向量表还指向 Boot 的向量区,CPU 就会跳到 Boot 的中断服务函数里去,而 Boot 里根本没使能这些外设,表现就是中断进了死循环或者直接跑飞。

F103 属于 Cortex-M3 内核,有 VTOR 寄存器(地址 0xE000ED08),软件可以直接把向量表基地址改到 App 的起始地址。跳转前后要做的事按顺序来,缺一步后面就是玄学问题。

对于不带 VTOR 的老 ARM 核,只能靠启动文件里手动拷贝向量表,好在 F103 全系支持。注意 VTOR 要求 0x200 字节对齐,因为 Cortex-M 的向量表固定占 512 字节(F103 有 60 多个中断,加上系统异常正好 0x200),所以 App 起始地址必须落在 0x200 的整数倍上。这也是为什么 App 偏移量一般是 0x08004000 或 0x08008000 这种值。

2.3 跳转 App 的最小代码:三个前置条件一个都不能少

跳转前要满足三件事:关闭全局中断并清掉挂起的中断、重新设置向量表偏移、把主栈指针 MSP 改成 App 的栈顶值。代码我一般这样写:

typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t stack_addr = *(volatile uint32_t *)app_addr; // 1. 取 App 栈顶值 pFunction app_entry = (pFunction)(*(volatile uint32_t *)(app_addr + 4)); // 2. 取复位向量 if ((stack_addr & 0xFFF00000) != 0x20000000) { // 3. 栈顶必须在 SRAM 范围 return; } __disable_irq(); // 关闭全局中断 NVIC->ICER[0] = 0xFFFFFFFF; // 清所有中断使能 NVIC->ICPR[0] = 0xFFFFFFFF; // 清所有挂起中断 SCB->VTOR = app_addr; // 重定向向量表 __set_MSP(stack_addr); // 设置主栈指针 app_entry(); // 跳转 }

这里有个关键点:跳转前关闭全局中断是标配,但很多人忘记关外设自身的状态。比如串口正在接收、定时器还在跑,跳到 App 后这些外设寄存器保持原样,App 的初始化函数会重新配置,理论上没问题。麻烦的是 DMA 和中断嵌套,所以我在跳转前把所有用过的外设都 DeInit。另外app_entry()是函数指针调用,编译器不会返回到原处,跳转成功后 Boot 就“死了”,想再回来只能复位。

3. 串口 YMODEM 升级落地:收包、校验、擦写与复位

3.1 为什么选 YMODEM 而不是 XMODEM 或 USB DFU

F103 的升级通道我推荐串口 YMODEM,理由很现实:串口是 F103 大部分产品的标配外设,不依赖 USB 线缆枚举,板子再简陋也能拉出三个引脚。XMODEM 一次只传 128 字节,没文件名和长度信息,第二个包才传数据,对 Bootloader 来说可用的信息太少。YMODEM 第一个包就带文件名和文件大小,Bootloader 能提前判断固件合不合理,也便于做进度条。

USB DFU 的问题是 F103 需要把 USB 模块配置成 DFU 模式,Boot 代码体积和复杂度都会涨,而且不是每块板子都引出了 USB。YMODEM 的代价是协议状态机要自己写,核心就两块:解析 0 号包和 1 号包,以及 CRC16 校验。

3.2 YMODEM 首个包与数据包解析:文件名、长度与 CRC16

YMODEM 会话走向一般是:Boot 发C(0x43)请求,上位机发 0 号包,接着发 1 号包数据,传完发 EOT,Boot 回 ACK,再发C请求下一个文件,上位机发空包结束。0 号包格式很固定:SOH 00 FF <128字节数据> CRC高字节 CRC低字节,数据区里是文件名\0 文件大小 空格 时间 空格 日期\0。

我给出一个实用的包解析函数:

#define PACKET_HEAD_LEN 3 #define PACKET_CRC_LEN 2 #define PACKET_128_LEN 128 #define PACKET_1K_LEN 1024 typedef struct { uint8_t cmd; // SOH=128字节包,STX=1024字节包 uint8_t seq; // 包序号,从0开始循环 uint8_t seq_inv; // 序号取反 } ymodem_pkt_header_t; uint16_t crc16_update(uint16_t crc, uint8_t data) { crc ^= (uint16_t)data << 8; for (int i = 0; i < 8; i++) { if (crc & 0x8000) crc = (crc << 1) ^ 0x1021; else crc <<= 1; } return crc & 0xFFFF; }

YMODEM 用的是 CRC-CCITT(多项式 0x1021,初值 0x0000),不是 Modbus 的 CRC16,第一天写这块的人十个有八个在这翻车。收到包后做法是先按包头长度收 3 字节,判断是 SOH(128)还是 STX(1024),再收对应数据区和 2 字节 CRC,把数据区逐字节喂给crc16_update,比对收到的 CRC。匹配后看包序号,0 号包的数据区解析出文件名和大小,非 0 号包直接写 Flash。

3.3 写 Flash 的时序:擦除、编程与进度上报

F103 的 Flash 必须先擦后写,擦除以页为单位,一页 2KB 或 1KB(小容量型号是 1KB)。写 Flash 时 CPU 不能同时从 Flash 取指,这是 IAP 最容易出问题的地方——你的擦写函数本身放在 Flash 里。

void program_app(uint32_t dest_addr, uint8_t *buf, uint32_t len) { FLASH_Unlock(); // 解锁 Flash FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); uint32_t page_size = 0x800; // 2KB 每页 uint32_t remaining = len; uint32_t offset = 0; while (remaining > 0) { if (FLASH_ErasePage((dest_addr + offset) & ~(page_size - 1)) != FLASH_COMPLETE) break; uint32_t chunk = (remaining >= page_size) ? page_size : remaining; for (uint32_t i = 0; i < chunk; i += 2) { uint16_t half_word = buf[offset + i] | (buf[offset + i + 1] << 8); if (FLASH_ProgramHalfWord(dest_addr + offset + i, half_word) != FLASH_COMPLETE) break; } offset += chunk; remaining -= chunk; report_progress(offset, len); // 回传进度给上位机 } FLASH_Lock(); }

每次写一个半字(16 位),经典型号都对这块有要求,一次写 8 位虽然编程函数也能过,但后面校验容易翻车。擦除和写入之间要关中断,不然 Flash 操作被打断可能让 PGSERR 标志位拉高。进度上报用百分比还是固定包间隔看协议设计,我习惯每写 4KB 回一个C,上位机读到进度心里有数。

4. 让 App 工程配合 Bootloader:链接脚本、向量偏移与初始化策略

4.1 链接脚本改两个数字:IROM1 起点和长度

App 工程要做的改动集中在链接脚本。Keil MDK 在 Options for Target → Target → IROM1 里改,GCC 的 .ld 文件里改FLASH (rx) : ORIGIN = 0x08004000, LENGTH = 0x1C000这两处。改之前心里要有数:Boot 用掉 0x08000000 到 0x08004000 这 16KB,App 就只能从 0x08004000 开始。

LENGTH 的计算方式是芯片总 Flash 减去 Boot 区间,比如 C8T6 是 64KB Flash,Boot 占 16KB,App 就是 0x10000 - 0x4000 = 0xC000(48KB)。如果非要塞满 64KB,写链接脚本时顺手把越界检查加上:

/* stm32f103c8t6_flash.ld */ MEMORY { FLASH (rx) : ORIGIN = 0x08004000, LENGTH = 0xC000 RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 0x5000 }

这里的0x5000是 20KB SRAM,C8T6 的 RAM 大小。就算你换到 ZET6(512KB Flash, 64KB RAM),也只是把ORIGIN和LENGTH重新算一遍。这个数值必须和 Boot 里定义的app_addr完全一致,两个工程各写各的,一旦不一致,跳转进去就是 HardFault 的命。

还有一个容易漏的改动是启动文件里的__initial_sp。GCC 工程里,启动汇编会把栈顶地址生成在 Flash 最后,所以 App 链接脚本改了 Flash 起始地址后,向量表也会自动落到新地址。Keil 方式更直观,它会在启动文件里生成一个跳转表,默认从 0x08000000 开始,App 工程里如果没改 IROM1,Boot 跳过去后首条指令读的就是 Boot 的程序入口,死因极其隐蔽。

4.2 App 启动的时钟与外设初始化:别在 Boot 里关外设

App 的SystemInit()会重新配置时钟树,这是标准流程,但你得确保 Boot 跳转前没有把某个外设的时钟关掉——准确说是没有关掉 Flash 必需的时钟。F103 的 Flash 接口时钟挂在 APB1 上,你跳转前如果把 APB1 关了,App 里第一次访问 Flash 就挂。所以跳转函数里我只对用到的外设做 DeInit,不动 RCC 时钟树,省得画蛇添足。

App 里有两个初始化顺序是抢回来的:先把 NVIC 优先级分组设好,再初始化外设中断。Boot 里可能已经设过分组,比如NVIC_PriorityGroup_2,App 如果不重新设,中断优先级就沿用 Boot 的规则,一般还好,但若 Boot 和 App 设置不一致,外设初始化时中断使能可能出现不可预期的抢占关系。习惯是在 App 的main()第一行就把分组写死。初始化中断向量表偏移也不需要额外操作,如果你用了像 RT-Thread 这类会自己重设向量表的系统,跳转前 SCB->VTOR 已经被改好了。

4.3 用编译宏和固件头保证 Boot 与 App 版本一致

版本信息放哪是个看似小实则天天踩的问题。最简单的方案是在 App 链接脚本里定义一个固定地址的段,放一个结构体:

#define APP_VERSION_ADDR 0x08004000 // 紧贴向量表之前 typedef struct { uint32_t magic; // 0xAA55AA55 uint32_t version; // 0x00000100 = 1.0 uint32_t length; // App 固件实际长度 uint32_t crc32; // 固件 CRC32 } app_header_t;

这个结构体放在 Flash 的 0x08004000 处,和向量表起始地址重叠了,所以要么放向量表之后空出 0x200 字节的位置,要么用宏将链接脚本的__app_header_addr定义到0x08004200。我的做法是放在 0x08004000 + 0x200 处,因为向量表已经占用了 0x200 字节。APP 编译的时候通过宏把版本号传进代码,Boot 跳转前读这个结构体,先核对 magic 和 crc32,不合法直接不跳转。这样产线升级时即使拖了个损坏的 bin 文件,Boot 也能安全拦下来。

5. 避坑:F103 IAP 最容易翻车的 5 个时刻

5.1 跳转后立刻 HardFault,复位后又能跑

现象:Boot 打印“跳转成功”,但 App 秒挂,复位后不进 Boot 反而能直接跑 App,此时用调试器看 PC 指向了 0x08004000 附近。

原因:多半是跳转前没有清理挂起中断 (pending interrupt),或者外设中断在跳转瞬间还在触发。NVIC->ICER 清使能后,某些外设的中断标志没有清,比如 UART 的 ORE 标志,进入 App 后初始化到该外设时马上产生新的中断请求,向量表又没完全配好。

解决:跳转前把外设中断标志全部清零,对用过的每个外设调USART_ClearITPendingBit()、TIM_ClearITPendingBit()。更省事的办法是跳转前NVIC->ICPR[0] = 0xFFFFFFFF,这个已经在示例代码里写了,但注意别忘了用__DMB()做内存屏障,防止编译器把写寄存器的顺序重排。

5.2 App 能刷进去但永远不在预期状态,像“还在跑 Boot”

现象:App 里HAL_GetTick()返回值异常,或者系统节拍慢了一半,升级进去的功能全都对不上。

原因:F103 的 SysTick 由系统时钟分频而来,Boot 里把时钟配置成了 8MHz 外部晶振,App 的 SystemInit 没有重新初始化时钟源。跳转后 App 直接用了 Boot 的时钟配置,可 App 代码按 72MHz 主频计算延时,时间全偏。

解决:在 App 的SystemInit()里强制走一套完整 RCC 配置,不要依赖 Boot 留下的状态。F1 的 SystemInit 默认是这样做的,只要别在工程里把它裁掉。跳转前不要再手设 RCC 寄存器,让 App 自己来。

5.3 YMODEM 传完以后 CRC 校验总失败

现象:用 SecureCRT 或某上位机传 32KB 固件,进度 100% 后 Boot 报 CRC mismatch,擦了写、写了擦,始终过不了。

原因:多半是 1024 字节包和 128 字节包混用导致的解析错位。上位机发送时检测到 Boot 的C请求后自动用 1K 包(STX),而 Boot 的接收缓冲区只有 256 字节,收 1024 字节直接截断覆盖。

解决:接收缓冲区长度至少 1024 + 包头 + CRC 尾部,也就是 1029 字节。还要注意 YMODEM 规定 128 字节包用 SOH,1024 字节包用 STX,解析包头时必须按 cmd 区分包长度,不能按固定 128 字节收。早期我图省事只收 128 字节,升级工具自动切成 1K 包后数据错位,排查了一整天才发现是 Boot 的接收长度写死了。

5.4 擦写 Flash 死在 FLASH_ErasePage 里,程序不走了

现象:升级进行到一半,无论点多少次重传,Boot 就像死了一样,只有复位能救。

原因:擦写 Flash 期间产生了中断,而中断服务代码在 Flash 中执行,CPU 需要读 Flash 但 Flash 正在擦除,直接卡死。这是 IAP 最凶残的坑,中断优先级再高都救不了。

解决:擦写 Flash 前必须关全局中断,擦写完再开。但很多 Boot 是跑完整个升级流程的,中间有很长时间不允许中断,这就要求 Boot 里的串口接收不能完全靠中断,升级模式下要改成轮询模式。我一般做法是:进入升级状态后把所有中断优先级都调低,但唯独 Flash 操作函数入口关全局中断,出来再开。如果你有看门狗,关全局中断的时间不能超过喂狗周期,否则什么都不干就被狗咬复位。

5.5 升级中途断电,板子变砖,Boot 也进不去

现象:升级到 70% 的时候用户把电源拔了,重新上电后 Boot 运行了,但跳转后 App 区域是不完整的固件,直接 HardFault,想再升级又进不了 Boot 的接收状态。

原因:App 区被写了一半,没有任何标记说明“这是个坏固件”。Boot 上电时只检查了 magic,发现是 0xAA55AA55 就直接跳转了,根本没有校验固件长度和 CRC。

解决:上电时不要见 magic 就跳,要把固件头里的 length 和 Flash 实际可读到的长度比对一遍,做全量 CRC 校验。稳妥的方案是规划两块 App 区交替存放,或者保留一小块出厂固件区做兜底。小容量 F103 做不了双备份,那就必须在跳转前校验长度和 CRC,这俩能挡住 99% 的断电损坏。真要做到断电安全,需要把“当前固件有效”标记放到一个单独页,固件写完后先写有效标记,再擦掉旧标记,这样即使半路断电,上电也能知道哪个标记有效。

6. 进阶验证:把升级过程改成“校验通过再切换”的状态机

到了这一步,你已经能收包、写 Flash、跳转了。要交付给产线或者集成到量产工具里,我建议把升级流程抽象成显式状态机,不要靠一堆 if flag 拼。状态机的好处是每个状态能测,出了问题看当前状态就知道死在哪。我用一个 4 状态模型:

状态事件动作下一状态
IDLE上位机请求升级清缓冲区、发CREADY
READY收到 0 号包解析文件名/长度,校验固件头RECVING
RECVING收到数据包写 Flash、CRC 累加RECVING / DONE
DONE收完 EOT全量校验 CRC,置有效标记JUMP / ROLLBACK

在这个基础上给固件包加上头部校验,升级工具侧用 Python 一句话就能完成:

python3 pack_fw.py app.bin out.bin --version 1.2 --dev-id prod_01

打包脚本要做的事是:读 bin 文件,附加头部(magic、version、length、crc32),输出.bin。产线升级时上位机直接发out.bin,Boot 只认头部结构,长度和 CRC 有一项不符就停在 READY 状态并回复错误码。这个流程也是我最常拿来验证硬件可靠性的手段:连续 100 次升级、中途随机断电、损坏固件包注入测试,全过才敢发产线。

最后分享一个我栽过的跟头:早期为了图快,直接在 Boot 里“顺带”把 App 区的有效标记擦掉再写,结果升级到一半掉电,新标记没写完,旧标记也丢了,整批货卡死在无法恢复的状态。后来才改成先写完整包、再切标记的流程,一刀下去成本高得多,但再也没出过“救不回来”的返修。经验就是:升级协议里所有“先破坏、后重建”的步骤都换成“先建新区、再切换”,哪怕只是多占一个页。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询