目录
一、单片机启动过程
上电复位:系统初始化(供电、时钟配置等)。复位后 PC 寄存器指向向量表的复位向量。
启动代码:配置堆栈指针(SP)。初始化中断向量表(Vector Table)。设置系统时钟。初始化全局变量和静态变量。调用主函数:跳转到 main() 函数。用户代码执行:应用逻辑运行。
在许多单片机中,程序的启动代码通常存储在内部闪存(FLASH)中。对于32等单片机,内部闪存的起始地址通常是 0x08000000
。
中断向量表的作用
当程序启动时,单片机会首先根据“中断向量表”来响应中断。中断向量表是一个存储了所有中断服务函数地址的数据结构,每个中断类型(如复位中断、硬件中断等)都对应一个函数入口。单片机内部有一个固定的中断向量表,该表的起始地址通常是 0x08000000
之后的地址。在STM32、GD32等ARM架构的单片机中,这个地址通常是 0x08000004
。
二、Bootloader 介绍
在嵌入式系统中,Bootloader 作为系统启动的第一阶段,负责引导用户程序执行,并提供固件更新功能。
Bootloader 是什么?
Bootloader(引导加载程序)是嵌入式系统启动时运行的第一段代码,负责初始化硬件,并引导主应用程序或操作系统。它通常存储在 Flash 存储器的固定区域,并在设备上电复位后最先执行。
简而言之:在用户代码前面加入一个引导代码,决定是进行系统升级还是直接加入用户代码。
目的是为了在产品发布后可以方便地通过预留的通信口对产品中的固件程序进行更新升级。
加入bootloader后单片机启动流程将会变化成如下:
Bootloader 的核心任务包括:
硬件初始化:设置时钟、存储器、外设等,确保系统稳定运行。
固件完整性检查:校验主应用程序是否损坏,如 CRC 校验。
引导主程序:将 CPU 的执行流从 Bootloader 转移到应用程序。
提供固件升级能力:支持通过串口、USB、OTA(无线升级)等方式更新固件。
有了 Bootloader,主应用程序可以专注于业务逻辑,而不必关心底层硬件初始化、固件升级等问题,使得系统架构更加清晰,开发更加高效。
Bootloader 广泛应用于 单片机(MCU)、嵌入式 Linux 设备、物联网设备、消费电子、工业控制 等领域,帮助开发者高效管理固件和系统启动流程。
总结来说,Bootloader 作为系统的引导程序,不仅承担着系统启动的任务,还提供固件管理、安全性保障等功能,是嵌入式系统中不可或缺的一部分。
三、bootloader框架:
在嵌入式系统中,Bootloader 的设计可以根据功能需求进行不同的划分。通常,我们可以将 Flash 分为以下三个主要区域:
1. Boot 区(引导区)
作用:存放 Bootloader 代码,负责系统启动、固件升级和应用程序引导。
特点:
该区域一般固定在 Flash 的起始地址(如 0x08000000
)。
负责硬件初始化、固件校验和应用程序加载。
一般不会频繁更新,以保证系统稳定性。
2. APP 区(应用程序区)
作用:存放用户应用程序的代码和数据,系统运行时执行的主要程序。
特点:
典型的应用程序起始地址位于 Boot 区之后(如 0x08020000
)。
Bootloader 需要校验并跳转到该区域执行用户代码。
可以设计单 APP 或双 APP 结构,以支持系统升级和回滚。
3. Setting 区(配置区)
作用:存储系统配置、固件版本信息、升级状态标志、CRC 校验等信息。
特点:
用于记录固件升级状态,防止因升级中断导致系统无法启动。
可以存放 Boot 选项,如当前运行的是 APP1 还是 APP2。
可用于存储系统运行日志、故障记录等。
双 Boot 方案
概念:在 Flash 中存储两个 Bootloader,分别用于不同的引导方式或安全机制。
优点:
- 提高系统可靠性:当主 Bootloader 出现异常时,可以通过备份 Bootloader 进行恢复,防止设备变砖。
- 支持冗余设计:两个 Bootloader 可分别支持不同的升级方式,如一个用于串口升级,另一个用于 OTA 升级。
- 增强安全性:一个 Bootloader 负责安全性检查,另一个 Bootloader 负责执行不同权限的代码。
实现方式:
- Boot1 负责基础启动,在 Flash 起始地址,主要完成硬件初始化和引导 Boot2 或 APP。
- Boot2 负责高级功能,如固件校验、OTA 处理,成功后跳转到应用程序。
多APP 方案
概念:在 Flash 中存储两个应用程序(APP1 和 APP2),通过 Bootloader 控制哪个 APP 运行。
优点:
- 防止升级失败:如果新固件(APP2)升级失败,系统可以回滚到旧版本(APP1),保证设备可用性。
- 支持 A/B 版本切换:可动态切换两个 APP 运行,以支持测试版本或不同功能的程序。
- OTA 友好:在远程升级时,先将新固件写入备用 APP 区(如 APP2),升级成功后再切换执行,避免升级过程中设备不可用。
实现方式:
- Bootloader 读取 Setting 区的标志位,决定执行 APP1 还是 APP2。
- 固件升级时不覆盖当前 APP,而是写入另一区域,确保升级失败时仍可回滚。
- 在 Bootloader 中设置跳转逻辑,根据校验结果执行正确的 APP 版本。
在嵌入式系统中,为了存储 Bootloader 和多个应用程序,通常会对 Flash 存储区域进行划分。每个区域都有特定的用途,下面是一个典型的存储区域划分方案:
区域 | 起始地址 | 结束地址 | 大小 |
---|---|---|---|
Bootloader | 0x08000000 | 0x0801FFFF | 128 KB |
APP1 | 0x08020000 | 0x0803FFFF | 128 KB |
APP2 | 0x08040000 | 0x0805FFFF | 128 KB |
设置区域 | 0x08060000 | 0x0807FFFF | 128 KB |
在这里,Bootloader区域通常存储启动程序,而APP1和APP2区域分别存储两个独立的应用程序。设置区域可以用于存储一些应用的配置信息或状态数据。
由于 Flash 存储是按扇区擦除的(通常是16KB、64KB、128KB等大小的整块擦除),我们需要合理地分配 Flash 区域,确保每个应用程序、Bootloader 和配置数据占用独立且不重叠的扇区。这意味着我们需要根据每个程序的大小和 Flash 扇区的大小来进行分配。
如何进行扇区分配
假设我们有以下几个区域需要存储:
- Bootloader:通常较小
- 应用程序:每个应用程序可能有不同的大小,可以分配 flash的一半或更多。
- 设置区域:存储配置数据等
我们需要依据它们的大小而合理分配
怎么查看代码的大小呢?
在keil中加入下面指令便可生成工程的BIN文件,这不经可以直接看到工程大小在后面固件更新中我们要写入单片机的便也是这个BIN文件
$K\ARM\ARMCLANG\bin\fromelf.exe --bin --output=@L.bin !L
由于 Flash 存储的操作只能按扇区进行擦除,因此在分配时需要确保每个区域的起始和结束地址都适合在 Flash 扇区上对齐。这样可以避免因为擦除不完整的扇区而导致程序崩溃或数据丢失。
双 Boot + 双 APP 方案(完整冗余设计)
在关键任务设备(如医疗、工业控制)中,可采用“双 Boot + 双 APP” 方案,即 Flash 结构如下:
区域 | 起始地址 | 结束地址 | 说明 |
---|---|---|---|
Boot1 | 0x08000000 | 0x0800FFFF | 主 Bootloader |
Boot2 | 0x08010000 | 0x0801FFFF | 备用 Bootloader |
APP1 | 0x08020000 | 0x0803FFFF | 旧版本固件 |
APP2 | 0x08040000 | 0x0805FFFF | 新版本固件 |
Setting | 0x08060000 | 0x0807FFFF | 版本标志、校验信息 |
这样,即使 Boot1 或 APP1 运行失败,仍然可以通过 Boot2 或 APP2 进行恢复,保证设备安全性和稳定性。
四、 Bootloader 代码实现
本博客以GD32F407为例,提供完整的bootloader与app代码(完整代码链接)
1、bootloader代码:
功能:
- 通过串口接收用户程序数据。
- 擦除 Flash 并写入新的用户程序。
- 解析并校验用户程序。
- 在按键触发或接收到指令后跳转到 Bootloader 进行固件升级。
- 在无固件更新时,自动跳转到用户程序执行。
串口接收:
提供串口0来接收固件,并指定存储开始位置为(0X20001000):
uint8_t rx_buffer[RXBUFFERSIZE]; // 接收缓冲区
uint8_t usart_rx_buf[USART_REC_LEN] __attribute__ ((at(0X20001000)));//接收缓冲,最大USART_REC_LEN个字节,起始地址为0X20001000. ; // 接收数据缓冲区
uint32_t usart_rx_cnt = 0;/* 接收的字节数 */
void usart_init(uint32_t b)
{
/* 使能GPIO和USART0时钟 */
rcu_periph_clock_enable(RCU_GPIOA); // USART0的TX(PA9)和RX(PA10)使用GPIOA
rcu_periph_clock_enable(RCU_USART0);
/* 配置USART0的TX(PA9)和RX(PA10)引脚 */
gpio_af_set(GPIOA, GPIO_AF_7, GPIO_PIN_9 | GPIO_PIN_10); // 复用功能为USART0
gpio_mode_set(GPIOA, GPIO_MODE_AF, GPIO_PUPD_PULLUP, GPIO_PIN_9 | GPIO_PIN_10); // 复用模式
gpio_output_options_set(GPIOA, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_9 | GPIO_PIN_10); // 推挽输出
/* 配置USART0 */
usart_deinit(USART0); // 复位USART0
usart_baudrate_set(USART0, b); // 波特率b
usart_word_length_set(USART0, USART_WL_8BIT); // 8位数据位
usart_stop_bit_set(USART0, USART_STB_1BIT); // 1位停止位
usart_parity_config(USART0, USART_PM_NONE); // 无校验位
usart_hardware_flow_rts_config(USART0, USART_RTS_DISABLE); // 禁用RTS
usart_hardware_flow_cts_config(USART0, USART_CTS_DISABLE); // 禁用CTS
usart_receive_config(USART0, USART_RECEIVE_ENABLE); // 使能接收
usart_transmit_config(USART0, USART_TRANSMIT_ENABLE); // 使能发送
usart_enable(USART0); // 使能USART0
/* 配置USART0接收中断 */
usart_interrupt_enable(USART0, USART_INT_RBNE); // 使能接收缓冲区非空中断
nvic_irq_enable(USART0_IRQn, 0, 0); // 使能USART0中断,优先级配置为0
}
// 发送一个字节
void uart_send_byte(uint8_t data) {
usart_data_transmit(USART0, data);
while (RESET == usart_flag_get(USART0, USART_FLAG_TBE));
}
// 发送字符串
void uart_send_string(char *str) {
while (*str) {
uart_send_byte(*str++);
}
}
/**
* @brief 串口0中断接收回调函数
* @param 无
* @retval 无
*/
void USART0_IRQHandler(void)
{
if (RESET != usart_interrupt_flag_get(USART0, USART_INT_FLAG_RBNE)) // 检查接收中断标志
{
/* 读取接收到的数据 */
rx_buffer[0] = usart_data_receive(USART0);
/* 将数据存入接收缓冲区 */
if (usart_rx_cnt < USART_REC_LEN)
{
usart_rx_buf[usart_rx_cnt] = rx_buffer[0];
usart_rx_cnt++;
}
/* 重新使能接收中断 */
usart_interrupt_enable(USART0, USART_INT_RBNE);
}
}
FLASH相关代码
主要对flash操作时加入保护操作
:
#include "gd32flash.h"
/**
* @brief 从指定地址读取一个字
* @param faddr: 读取地址
* @return 读取到的数据
*/
uint32_t gd32flash_read_word(uint32_t faddr) {
return *(__IO uint32_t *)faddr;
}
/**
* @brief 从指定地址开始写入指定长度的数据
* @param waddr: 写入地址
* @param pbuf: 数据缓冲区
* @param length: 数据长度(以字为单位)
*/
void gd32flash_write(uint32_t waddr, uint32_t *pbuf, uint32_t length) {
fmc_unlock(); // 解锁 Flash
for (uint32_t i = 0; i < length; i++) {
fmc_word_program(waddr + i * 4, pbuf[i]); // 写入一个字
while (fmc_flag_get(FMC_FLAG_BUSY) != RESET); // 等待写入完成
}
fmc_lock(); // 锁定 Flash
}
/**
* @brief 从指定地址开始读取指定长度的数据
* @param raddr: 读取地址
* @param pbuf: 数据缓冲区
* @param length: 数据长度(以字为单位)
*/
void gd32flash_read(uint32_t raddr, uint32_t *pbuf, uint32_t length) {
for (uint32_t i = 0; i < length; i++) {
pbuf[i] = gd32flash_read_word(raddr + i * 4); // 读取一个字
}
}
/**
* @brief 测试写入函数
* @param waddr: 写入地址
* @param wdata: 写入数据
*/
void test_write(uint32_t waddr, uint32_t wdata) {
uint32_t data = wdata;
gd32flash_write(waddr, &data, 1); // 写入一个字
}
void erase_flash_sectors(uint32_t fmc_sector)
{
/* 解锁FLASH */
fmc_unlock();
/* 擦除扇区 */
fmc_sector_erase(fmc_sector);
while (fmc_state_get() == FMC_BUSY); /* 等待擦除完成 */
/* 锁定FLASH */
fmc_lock();
}
写入应用程序 BIN 文件到 Flash,并添加 CRC 校验:
/**
* @brief 计算 CRC-32 校验值
* @param data: 数据指针
* @param length: 数据长度(字节)
* @return CRC-32 校验值
*/
uint32_t crc32(const uint8_t *data, uint32_t length) {
uint32_t crc = 0xFFFFFFFF; // 初始值
for (uint32_t i = 0; i < length; i++) {
crc ^= data[i]; // 异或当前字节
for (uint8_t j = 0; j < 8; j++) {
if (crc & 1) {
crc = (crc >> 1) ^ CRC32_POLYNOMIAL; // 右移并异或多项式
} else {
crc >>= 1;
}
}
}
return ~crc; // 取反得到最终 CRC 值
}
/**
* @brief 写入应用程序 BIN 文件到 Flash,并添加 CRC 校验
* @param appxaddr: 应用程序的起始地址
* @param appbuf: 应用程序代码数据
* @param appsize: 应用程序大小(字节)
* @retval 无
*/
uint8_t iap_write_appbin(uint32_t appxaddr, uint8_t *appbuf, uint32_t appsize) {
uint32_t t;
uint16_t i = 0;
uint32_t temp;
uint32_t fwaddr = appxaddr; /* 当前写入的地址 */
uint8_t *dfu = appbuf;
/* 计算原始数据的 CRC 值 */
uint32_t original_crc = crc32(appbuf, appsize);
// printf("原始数据的 CRC 值: 0x%08X\r\n", original_crc);
for (t = 0; t < appsize; t += 4) {
temp = (uint32_t)dfu[3] << 24;
temp |= (uint32_t)dfu[2] << 16;
temp |= (uint32_t)dfu[1] << 8;
temp |= (uint32_t)dfu[0];
dfu += 4; /* 偏移4个字节 */
g_iapbuf[i++] = temp;
if (i == 512) {
i = 0;
gd32flash_write(fwaddr, g_iapbuf, 512); /* 使用GD32的FLASH写入函数 */
fwaddr += 2048; /* 偏移2048字节 */
}
}
if (i) {
gd32flash_write(fwaddr, g_iapbuf, i); /* 将最后的一些内容字节写进去 */
}
/* 验证 Flash 中的数据 */
uint32_t flash_crc = crc32((uint8_t *)appxaddr, appsize);
// printf("Flash 数据的 CRC 值: 0x%08X\r\n", flash_crc);
if (flash_crc == original_crc)
return 1;
else
return 0;
}
跳转到应用程序段:
注意:/* 设置应用程序的中断向量表偏移 */
SCB->VTOR = FLASH_BASE | 0x20000;这个中断向量表偏移很重要,0x20000是app偏离FLASH_BASE的距离,依据具体代码进行更改。
/**
* @brief 跳转到应用程序段(执行APP)
* @param appxaddr : 应用程序的起始地址
* @retval 无
*/
void iap_load_app(uint32_t appxaddr)
{
/* 检查栈顶地址是否合法. GD32F407VET6的SRAM为192KB(0x20000000~0x2002FFFF) */
if (((*(volatile uint32_t *)appxaddr) & 0x2FFE0000) == 0x20000000)
{
/* 用户代码区第二个字为程序开始地址(复位地址) */
jump2app = (iapfun) * (volatile uint32_t *)(appxaddr + 4);
/* 初始化APP堆栈指针(用户代码区的第一个字用于存放栈顶地址) */
__set_MSP(*(volatile uint32_t *)appxaddr);
/* 设置应用程序的中断向量表偏移 */
SCB->VTOR = FLASH_BASE | 0x20000;
/* 跳转到APP */
jump2app();
}
}
Bootloader 的主循环主要负责检测是否需要进行固件更新,如果没有新固件,则跳转到用户程序执行。
int main(void)
{
uint32_t applenth = 0;
systick_config();
gd_eval_led_init(LED2);
usart_init(115200);
printf("boot program start\r\n");
while (1)
{
if (usart_rx_cnt)
{
applenth = usart_rx_cnt;
usart_rx_cnt = 0;
printf("用户程序接收完成! 代码长度: %dBytes\r\n", applenth);
}
key = key_scan(0);
if (key == KEY0_PRES && applenth)
{
printf("开始更新固件...\r\n");
erase_flash_sectors(CTL_SECTOR_NUMBER_5);
if (iap_write_appbin(FLASH_APP1_ADDR, usart_rx_buf, applenth))
printf("固件更新完成!\r\n");
else
printf("CRC 校验失败!\r\n");
}
else if (key == KEY1_PRES)
{
if (((*(volatile uint32_t *)(FLASH_APP1_ADDR + 4)) & 0xFF000000) == 0x08000000)
{
printf("开始执行用户代码!!\r\n");
delay_1ms(10);
iap_load_app(FLASH_APP1_ADDR);
}
else
{
printf("没有可以运行的固件!\r\n");
}
}
}
}
APP代码:
跳转到 Bootloader当用户程序运行时,如果希望进入 Bootloader 更新固件,可以通过串口发送指令实现:
void jump_to_bootloader(void)
{
void (*bootloader_entry)(void) = (void (*)(void))(*((uint32_t *)FLASH_BASE+4));
__set_MSP(*((uint32_t *)FLASH_BASE));
SCB->VTOR = FLASH_BASE;
bootloader_entry();
}
当串口接收到 "updates" 指令时,跳转到 Bootloader 进行更新:
#define USART_REC_LEN 128 // 接收缓冲区大小
#define BOOTLOADER_CMD "updates" // 进入 Bootloader 的指令
uint8_t usart_rx_buf[USART_REC_LEN]; // 串口接收缓冲区
uint8_t usart_rx_flag = 0; // 接收到指令的标志位
void USART0_IRQHandler(void)
{
static uint8_t rx_len = 0;
if (usart_interrupt_flag_get(USART0, USART_INT_FLAG_RBNE) != RESET)
{
usart_rx_buf[rx_len++] = usart_data_receive(USART0);
if (rx_len >= strlen(BOOTLOADER_CMD))
{
usart_rx_buf[rx_len] = '\0';
if (strcmp((char *)usart_rx_buf, BOOTLOADER_CMD) == 0)
{
usart_rx_flag = 1;
}
rx_len = 0;
}
}
}
APP的主函数:
int main(void)
{
systick_config();
gd_eval_led_init(LED2);
usart_ini(115200);
printf("APP1 program start\r\n");
while(1)
{
printf("task running\r\n");
gd_eval_led_toggle(LED2);
delay_1ms(500);
/* 检查是否接收到进入 Bootloader 的指令 */
if (usart_rx_flag)
{
usart_rx_flag = 0; // 清除标志位
printf("接收到指令: updates,进入 Bootloader...\r\n\r\n");
jump_to_bootloader(); // 跳转到 Bootloader
}
}
}
注意事项
- 跳转地址必须是有效的 Flash 地址,否则会导致 HardFault。
- 确保 NVIC 向量表正确重定位,否则中断无法正常工作。
- 应用程序需包含正确的起始向量地址(栈指针地址和复位向量)。
- Flash 擦除时要小心,防止误擦除 Bootloader 区域。
- 建议使用 CRC 校验,防止固件传输过程中损坏。
使用过程:
上电(LED闪烁)、按下KEY0、KEY1如下:
点击开的文件,选择APP的BIN文件:
点击发送文件,按下KEY0、KEY1,app开始工作:
在串口发送“updates”,将会由APP转到Bootloader:
五、总结
Bootloader 是嵌入式系统启动和管理的重要组成部分,它负责将系统从硬件初始化到主应用程序的加载。通过合理的 Flash 存储分配和代码实现,可以确保系统的稳定性、可维护性和灵活性。理解 Bootloader 的工作原理和设计思路,对于嵌入式开发者来说是非常重要的,这有助于优化系统启动时间、增强固件更新功能,并提高系统的可靠性。