版权信息
warning
本文章为博主原创文章。遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
在日常的嵌入式开发中,我们习惯了点击 IDE 里的“Download”按钮,看着进度条走完,然后串口终端里跳出熟悉的日志。这个过程显得理所当然。但当我们脱离开发板,将设备部署到荒郊野外的节点,或者封闭的工业网关中时,靠一根数据线去烧录代码就成了奢望。而OTA(Over-The-Air)升级便显得尤为重要。
本文我们就从MCU的烧录和启动流程开始,引申到OTA升级。整体是以STM32-MCU为例讲解。
1. 烧录的本质
所谓的“烧录”,本质上都是上位机通过特定的通信协议,去操纵 MCU 内部的一个特定的硬件外设——Flash 控制器(Flash Memory Controller / FMC)。通过flash控制器,去写入或擦除flash的指定扇区或页。
以常见的STM32为例,我们最常见的两种烧录方式:
-
仿真器烧录
电脑 (USB) ST-Link SWD 协议 MCU 内部的 DAP(调试接入端口) MCU 的内部硬件总线(AHB) Flash 控制器。
-
串口烧录
电脑 (USB转串口) UART 协议 MCU 的串口外设 MCU 的 CPU(正在运行 ROM 里的原厂 Bootloader) CPU 执行指令操作内部硬件总线 Flash 控制器。
那么,是什么指导着上位机对flash的进行正确操作呢?
——.hex 或 .bin 文件中的地址映射表。上位机将这些二进制数据按图索骥,精准地写入到 MCU 内部 Flash 的指定扇区(Sector)或页(Page)中。
2. 链接器脚本
链接器脚本(Linker Script),在 GCC 工具链里通常是 .ld 文件,在 Keil 里叫分散加载文件(.sct)。它就是那个地址的 “规划师”。
我们都知道编译器只负责把你的 C 代码翻译成机器指令,但这些指令最终应该放在 Flash 的哪个绝对物理地址?全局变量应该放在 RAM 的哪个位置?这全是链接器(Linker)看着链接脚本来决定的。
例如最经典的代码分段:
-
.text段:存放可执行的机器指令和常量,存放在 Flash 中。 -
.data段:存放已初始化的全局变量和静态变量。它们的初始值也存放在 Flash 中。 -
.bss段:存放未初始化的全局变量,它们不需要在 Flash 中占地方,只需要记录长度即可。
3. STM32的boot引脚概述
3.1. boot 引脚简介
Boot 引脚( BOOT0 和 BOOT1 引脚)本质上是硬件级的引导模式选择开关。可以把它理解为电脑主板上的 BIOS 启动项选择(是从硬盘启动,还是从 U 盘启动)。
我们引用STM32f411的参考手册里对boot引脚的讲解:
The values on the BOOT pins are latched on the 4th rising edge of SYSCLK after a reset. It is up to the user to set the BOOT1 and BOOT0 pins after reset to select the required boot mode.
BOOT0 is a dedicated pin while BOOT1 is shared with a GPIO pin. Once BOOT1 has been sampled, the corresponding GPIO pin is free and can be used for other purposes.
The BOOT pins are also resampled when the device exits the Standby mode. Consequently,they must be kept in the required Boot mode configuration when the device is in the Standby mode. After this startup delay is over, the CPU fetches the top-of-stack value from address 0x0000 0000, then starts code execution from the boot memory starting from 0x0000 0004.
大致意思是:
复位后,BOOT引脚上的值会在SYSCLK的第4个上升沿被锁定。硬件会自动读取这些 Boot 引脚的电平状态。根据读取到的组合,MCU 会在内部进行内存地址重映射,也就是把不同的物理存储介质映射到 CPU 默认的启动地址(通常是 0x00000000)。
文档还指出,BOOT0是一个专用引脚,而BOOT1则与一个GPIO引脚共享。一旦对BOOT1完成采样,相应的GPIO引脚就空闲了,可用于其他目的。
在CubeMX中,并没有对BOOT1引脚做标识,还需要通过查参考手册列出的GPIO功能描述才找到,以F411为例,它的BOOT1引脚是PB2。
3.2. 常见的 Boot 引脚电平组合
-
从主 Flash 启动(最常见)
-
配置:
BOOT0 = 0(通常接地),BOOT1 = 任意 -
行为: MCU 将内部的主 Flash(物理地址通常是
0x08000000)映射到0x00000000。 -
应用: 设备正常运行你烧录进去的代码。
-
-
从系统存储器启动(System Memory / ROM)
-
配置:
BOOT0 = 1(接高电平),BOOT1 = 0(接地) -
行为: MCU 将内部的一块只读 ROM(物理地址通常在
0x1FFFF000附近)映射到0x00000000。这块 ROM 里固化了 ST 出厂前写死的一段 Bootloader 代码。 -
应用: 俗称 ISP(In-System Programming)下载模式。当你没有仿真器时,可以通过这根线,利用串口甚至 USB,把新的固件直接下载到主 Flash 中。开发板上的一键下载电路(如 CH340 配合 DTR/RTS 引脚)就是利用这个机制自动控制 BOOT0 和 RESET 引脚来实现串口烧录的。
-
-
从内置 SRAM 启动
-
配置:
BOOT0 = 1,BOOT1 = 1 -
行为: 将内部 RAM(物理地址
0x20000000)映射到起始地址。 -
应用: 主要用于极端的调试场景。因为 Flash 的擦写寿命有限且擦写速度慢,在进行某些底层算法的高频次调试时,可以将代码直接下载到 RAM 中运行。缺点是掉电即丢失。这个我了解比较少,用得也比较少,就不讲了
-
“映射”,在我看来也有一点“依赖倒置”的思想在里面,上层CPU只管调用逻辑地址(0x00000000),逻辑地址相当于“接口”,逻辑地址对应的物理地址则由底层的总线矩阵根据不同情况重定向,也就是“接口的底层实现”。哈哈哈。
4. STM32启动流程概述
4.1. 从主flash启动
以常见的 ARM Cortex-M 为例。当系统上电或按下 Reset 按键时,硬件电路产生复位信号,开始进行“开机准备”。这个过程完全由硬件主导,不需要任何软件干预:
-
取栈顶指针(MSP):CPU读取逻辑地址
0x00000000(已被映射到物理地址0x08000000,下同理) 的第一个 4 字节数据,将其直接赋值给主堆栈指针寄存器(MSP)。这是 C 语言运行的基石——没有栈,就没有函数调用和局部变量。 -
取复位向量(PC):接着,内核读取相邻的下一个 4 字节数据(即
0x00000004),将其装载到程序计数器(PC)中。这个地址,就是Reset_Handler(复位中断服务函数)的入口点。 -
软件接管:PC 指针跳转到
Reset_Handler后,软件拿到了控制权。启动代码(由汇编编写的.s文件)开始执行:-
初始化系统时钟树,以及其他的必需硬件。
-
将
.data段的数据从 Flash 搬运到 RAM 中。 -
将
.bss段所在的 RAM 区域全部清零。 -
最后,执行一条跳转指令:
BL main。
-
至此,系统正式进入到我们熟悉的 main() 函数。
根据我们前面讲的地址映射,可以轻松推断出——在MCU中,text段的内容通常不会被加载到ram中,而是一直在主flash中,CPU可以从flash中直接取指,就地执行。
在整个过程中我们需要注意两点,
-
在整个过程中首地址(
0x00000000)及其下一个地址(0x00000004)有着绝对固定的意义,是绝对的核心。 -
第一个软件程序通过复位中断服务函数得到执行。
4.2. 从ROM启动
与从主flash启动的流程基本相似。只不过通过复位中断函数执行的代码变成了ROM中固化好的bootloader代码。
这段bootloader程序会初始化芯片上的串口(比如 STM32 默认是 USART1)。然后进入一个死循环,安静地监听串口,等待上位机(如STM32CubeProgrammer)发来特定的握手指令。一旦握手成功,上位机通过串口把你的 .bin 文件发过来,这段 ROM 里的程序就会接收这些数据,并帮你把它们写入到主 Flash 中。然后你再重新从主flash启动。
有些时候我们不小心把 SWD/JTAG 调试引脚给复用成了普通 GPIO,导致仿真器连不上,在这样的情况下,我们就无法下载程序到flash里了,也就是芯片被锁住了,或者叫”变砖了“,此时我们就可以靠从ROM启动,重新烧录代码,实现救砖。
5. OTA升级的本质
OTA升级的本质就是,从网络上下载固件到flash的指定位置,并把旧固件替换为新固件的过程。
这之中有两个问题——
- 位置如何指定?怎么保证我不会访问到不该访问的地方,比如存放text段的地方?
- 把旧固件替换成新固件的过程怎么实现?正在运行的旧固件总不可能自己把自己给替换了吧?就好像你不可能自己给自己做换脑手术一样。
解决第一个问题,我们引入了flash分区机制,把flash划分为多个区,并给每个区指定合适的大小,这样它们的地址也就固定了。哪个区,做什么事,也就明明白白,且不会越界访问。一种极简且稳妥的划分方式是:
-
Bootloader 区:存放在 Flash 的最起始位置(例如前 32KB)。
-
App 区(运行区):存放当前正在执行的主程序。
-
Download 区(下载区/备份区):用于暂存从网络接收到的新固件。
-
Data 区:存放升级标志位、设备配置等掉电不可丢失的信息。
这个bootloader,就是为了解决第二个问题而存在的,既然你自己不能给自己做换脑手术,那就找个手术医生来帮你做。我们在前面也提到了在ROM里的bootloader,但它实现不了我们想要的逻辑,也不能编程。
6. 如何使用自定义的bootloader启动?
bootloader实际上可以看作是一个独立的工程,拥有一套完整的底层启动代码,自己的程序段全部集中在bootloader区里。
也就是这样的:
-
当建立 Bootloader 工程时,工程里包含了一个启动文件(如
startup_stm32f411xe.s),里面定义了一个Reset_Handler。 -
当建立 App 工程时,工程里同样包含了一个启动文件,里面也有一个
Reset_Handler。
所以我们的MCU里运行了两套工程,我们先运行bootloader工程,它的作用是检查与跳转。 它会读取 Data 区的标志位,判断 Download 区是否有一份校验通过(CRC 或数字签名验证)的新固件。如果有,它就负责擦除 App 区,把新固件搬运过去。如果没有,或者搬运完成,它就要启动 App工程。
想让MCU上电后启动bootloader工程,很简单,正常把bootloader工程的hex文件烧录进flash不就行了吗。
那APP工程咋办呢,通过前面说的链接脚本重新在flash中指定一个位置烧录进去啊。如下:
对于 Bootloader 工程,它占据的是系统上电后的默认位置。它的链接脚本几乎不需要怎么修改,和普通裸机程序一样:
/* Bootloader_Flash.ld 的 MEMORY 区域定义 */
MEMORY
{
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
/* Bootloader 独占 Flash 的最前面,假设分配 32KB 的空间 */
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 32K
}
对于 App 工程,我们就必须修改链接脚本,让它“挪个窝”。假设我们把 App 放在偏移 32KB(0x08008000)的位置:
/* App_Flash.ld 的 MEMORY 区域定义 */
MEMORY
{
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
/* App 的 Flash 起始地址必须向后偏移,让出前 32KB */
FLASH (rx) : ORIGIN = 0x08008000, LENGTH = 256K /* 假设 App 区大小为 256KB */
}
由于bootloader鸠占鹊巢把APP的位置霸占了,APP就不能按照正常流程启动,唤醒APP的任务只能落到bootloader身上了。 仿照第四节的主flash启动原理,我们使用软件模拟出硬件逻辑就好了。
-
关闭一切干扰,清理现场
在跳转前,必须全局屏蔽中断(
__disable_irq()),关闭所有 Bootloader 中使用过的外设(定时器、串口等),并清除挂起的中断标志。绝对不能让 Bootloader 的中断请求在 App 运行时突然触发,那将是灾难性的 HardFault。 -
重设堆栈指针
从 App 在 Flash 中的起始地址(例如
APP_ADDRESS)读取出它的栈顶指针,并写入 MSP 寄存器。__set_MSP(*(__IO uint32_t*) APP_ADDRESS); -
重定位中断向量表 (VTOR)
这是重中之重。默认情况下,中断向量表的起始基地址是
0x08000000(没错,就是存放MSP的位置,中断向量表又不在基地址放任何东西,所以两者并不冲突,对吧?)。中断发生时,内核会使用起始基地址0x08000000+偏移的方式寻找中断服务函数地址,但0x08000000是 Bootloader 的地盘。我们必须修改系统控制块(SCB)中的向量表偏移寄存器(VTOR),指定新的中断向量表的起始基地址。SCB->VTOR = APP_ADDRESS; -
启动App工程
读取
APP_ADDRESS + 4的内容(即 App 的Reset_Handler地址),将其转换为函数指针,然后调用它。uint32_t app_entry = *(__IO uint32_t*) (APP_ADDRESS + 4); void (*app_reset_handler)(void) = (void (*)(void))app_entry; app_reset_handler(); // 魔丸降世
7. 实现一个简单的OTA升级项目
本文我们简单概述了OTA的实现原理,至于OTA的具体实现和开发流程,请听下回分解。