Bootloader复刻(B站超子物联网,使用F103瑞士军刀)

整体流程图

通过该工程你可以学习到什么?

把这个 Bootloader 工程 吃透,代码量不大,但知识点极其密集学完就能直接写进简历。

启动流程与向量表重定位

  • 知识点:上电顺序、MSP、Reset_Handler、VTOR
  • 代码:LOAD_A() + SCB->VTOR = 0x08007000
  • 迁移:可以根据需要“双程序”场景(OTA、双备份、A/B 分区)去改编该工程,适配不同的情景

链接脚本与分散加载

  • 知识点:.bin 必须和 IROM1 起始地址一致,否则跳转瞬间 HardFault
  • 代码:Keil 里 0x08007000 + --first=__Vectors
  • 迁移:GD32、HK32、MM32 等同内核 MCU 通用

中断管理的“三把锁”

  • 知识点:关总中断 __disable_irq()关外设中断源 USART1->CR1 &= ~RXNEIE清 NVIC 挂起 NVIC_DisableIRQ(USART1_IRQn)
  • 代码:Bootloader_Clear()
  • 迁移:USB、CAN、以太网 Bootloader 同样适用,少一步就跳飞

裸机状态机框架

  • 知识点:不用 OS,也能把“接收-校验-写入”做成可阻塞可超时的状态机
  • 代码:BootStaFlag 位图 + switch-case 多级嵌套(工程代码没实现,后面自己可以改一下哈哈)
  • 迁移:Modbus、SLIP、自定义串口协议直接复用

串口流控与 XMODEM-CRC16 实战----CRC 还是比较常见的,虽然 AI 也可以写,还是了解一下这个原理吧

  • 知识点:128 B 数据包、SOH/EOT、CRC16 查表法、ACK/NAK 重传
  • 代码:Xmodem_CRC16() + BootLoader_Event()
  • 迁移:产线批量烧录、无线升级、蓝牙 SPP 升级都能用

Flash 擦写对齐与寿命管理

  • 知识点:STM32F1 页 1 KB、必须先擦后写、按字写入
  • 代码:ST32F1_EraseFlash() + ST32F1_WriteFlash()
  • 迁移:EEPROM 模拟、参数存储、日志环形缓冲同理

I²C 外挂 EEPROM 抽象层

  • 知识点:AT24C02 页写延时、地址自增、CRC 校验元数据
  • 代码:AT24C02_WriteOTAInfo() + 结构体封装
  • 迁移:温湿度校准表、PID 参数、加密密钥存储通用

SPI 外挂 Flash 文件系统雏形

  • 知识点:W25Q64 64 KB 块擦、256 B 页写、跨页连续写封装
  • 代码:W25Q64_PageWrite() + 块号换算
  • 迁移:LittleFS、FATFS、日志缓存、语音/图片资源缓存

看门狗与容错设计(没加,自己可以加一个看门狗,在下载程序的时候去进行一个喂狗的操作)

  • 知识点:下载过程喂狗、异常不复位而是回退到菜单
  • 代码:driver_timer.c 里喂狗 + 超时判断
  • 迁移:蜂窝模组、NB-IoT、4G 升级容错必须加

串口命令行解析

  • 知识点:单字节命令 + 超时重试 + 回显抑制
  • 代码:BootLoader_Event() 里 switch(data[0])
  • 迁移:产线测试指令、调试控制台、AT 指令集

跨平台构建思想

  • 知识点:.c/.h 严格分层、HAL 层可替换、寄存器操作可插拔
  • 代码:fmc.h 里纯函数声明,fmc.c 里才出现 FLASH->CR
  • 迁移:移植到 GD32、CH32、NRF52、ESP32 时只换驱动层

清晰的接口设计

  • 头文件定义:清晰的函数声明和类型定义
  • 模块职责分离:BOOT、存储、通信等模块分离
  • 错误处理机制:完善的错误检测和处理

这个 BOOT 程序是学习嵌入式 C 语言的优秀范例,它展示了:

  1. 结构体在复杂数据管理中的应用
  2. 函数指针在程序跳转中的关键作用
  3. 指针操作在底层编程中的重要性
  4. 状态机在协议实现中的应用
  5. 位运算在标志位管理中的使用
  6. 模块化设计思想在实际项目中的体现

前置知识学习

什么是 bootloader?实现什么功能?

Bootloader 是芯片上电后第一个运行的程序,它在你定义的 B 区(0x08000000)执行,主要任务是初始化硬件并决定启动哪个程序。就像电脑开机时先进入 BIOS,再加载 Windows。

为什么需要 Bootloader?

没有 Bootloader 的困境:

  • 你的主程序在 A 区(0x08007000)这个可以自己修改哈
  • 如果主程序有 bug 导致无法启动,无法通过 UART 更新程序(因为 UART 驱动在 A 区)
  • 必须用 ST-Link 烧录器才能救砖,现场维护成本高

有 Bootloader 的优势:

  • Bootloader 在 B 区,拥有最高启动优先级
  • 即使 A 区程序崩溃,Bootloader 仍可运行→可通过 UART 接收新程序→写入 A 区→重启
  • 实现"无烧录器"远程升级
  • 上电复位后去检查有没有 OTA 升级事件,如果有就更新程序然后跳转到 A 区,如果没有就直接跳转到 A 区。

    这是最基础的,那么我们这部分可以自己再优化添加一些额外的功能:

    1.比如通过串口更新应用程序下载到 A 区。

    2.设置 OTA 的版本号。

    3.利用外部 Flash 存放多个程序,随时更应应用程序到 A 区。

    Flash 占用分析

    在使用 keil 编译完之后会有这么一栏提示信息:

    Program Size: Code=26580 RO-data=660 RW-data=92 ZI-data=8108

    总 Flash 占用 = Code + RO-data + RW-data = 26580 + 660 + 92 = 27330 字节 ≈ 27.4KB

    各部分含义:

  • Code (26.6KB):你的代码指令,这是主要部分。对于 STM32F103C8T6(64KB Flash)来说,这个量级属于正常应用范围
  • RO-data (660B):只读数据,如 const 修饰的常量、字符串字面量等
  • RW-data (92B):已初始化变量的初始值,会存 Flash+RAM 两份
  • ZI-data (8.2KB):未初始化变量,只占用 RAM,不占 Flash
  • 如果想要编译出来的代码更小一些可以去提高一下编译的优化等级。
  • 关于编译器优化

    1. -O0(无优化)

    • 作用:不进行任何优化。
    • 特点:生成的代码与源代码的结构非常接近,便于调试。编译速度最快。生成的代码通常较大且运行效率较低。
    • 适用场景:主要用于调试阶段,因为生成的代码易于理解和跟踪。

    2. -O1(轻度优化)

    • 作用:进行基本的优化,平衡编译时间和生成代码的性能。
    • 特点:优化程度适中,会进行一些基本的优化,如常量传播、死代码删除等。编译速度较快。生成的代码性能和大小都有一定的提升,但不会像更高优化级别那样显著。
    • 适用场景:适用于开发阶段,可以在调试和性能之间取得平衡。

    3. -O2(中度优化)

    • 作用:进行更深入的优化,生成更高效的代码。
    • 特点:会进行更多的优化,如指令调度、循环展开、函数内联等。编译速度比-O1慢,但仍然相对较快。生成的代码性能和大小都有显著提升。
    • 适用场景:适用于发布版本,可以在性能和编译时间之间取得较好的平衡。

    4. -O3(高度优化)

  • 作用:进行最深入的优化,生成尽可能高效的代码。
  • 特点:会进行非常深入的优化,如更多的循环展开、函数内联、自动向量化等。编译速度最慢,因为编译器会花费更多时间来优化代码。生成的代码性能最高,但可能会导致生成的代码大小增加。
  • 适用场景:适用于对性能要求极高的场景,如高性能计算、嵌入式系统等。

bootloader 和 APP 怎么区分?

仔细想一下为啥我们要把 BOOT 区放在前面?这个地方需要掌握 MCU 的启动流程,我就不赘述了,大家记得学习一下!

区域

扇区编号

起始地址

大小

用途推测

B 区(BOOT 区)

0 ~ 27

0x08000000

28KB

Bootloader(启动程序)

A 区

28 ~ 63

0x08007000

36KB

主应用程序

这个地方 0x08000000+28*1024 正好是 0x08007000,因为刚开始我的 boot 编译出来的代码有点大,所以我给 boot 分的空间是 28K,至于大家自己的 boot 可以视情况而定。如果你的 boot 代码编译出来是 20k 完全可以把 A 区增大一点

当然了,你的 A 区也可以放在 30-63,这些都无所谓,只要你能保证你的程序编译出来的 bin 文件大小能符合就可以。

以本工程为例子:

Keil 怎么编译 bin 文件?

fromelf --bin -o "$L@L.bin" "#L"

自己的 APP 编译出 bin 文件之前一定要去修改自己的偏移!!!

OTA-Flag 是干嘛的?存在哪里?

OTA-Flag 是一个非易失性标志位,存储在 Flash 中(你要是存储在 RAM 里面,掉电就会丢失了呀)本文使用 EEPROM(AT24C02--淘宝买一个模块 2.7 元一个包邮,IIC 读写)模拟,用于在 Bootloader 和主程序(App)之间传递升级状态。它的核心作用是:告诉 Bootloader 下次启动时该做什么。

一定是外部 Flash 下载完成之后再由❌变✅!

然后 reset 运行 boot 去检查 OTA-flag。

如果检测到升级,那么 bootloader 的代码会将 A 区的程序擦除,然后将需要升级的代码下载到 A 区,然后再去置位 OTA-Flag,然后重启,重启之后就会检查 OTA-Flag,然后跳转。

这个跳转的话需要掌握一下函数指针的概念!

什么是空闲中断?

空闲中断 = DMA 持续收数据 + 硬件自动检测帧结束 + 中断通知 CPU 处理。

空闲(IDLE)的本质:从最后一个停止位开始计时,如果在一个字节的传输时间内(注意:不是"一个字符")没有检测到下一个起始位,则触发中断。

单独使用空闲中断是无意义的,因为它只告诉你"帧结束了",但不告诉你"收到了什么"。所以必须配合:

  • DMA 接收:DMA 在后台默默搬运数据到内存,不占用 CPU
  • 空闲中断:帧结束时,告诉 CPU"DMA 可以停了,数据处理吧"
  • 具体的学习顺序

    第一步:使用串口实现 DMA+空闲中断,这个地方可以去好好学习一下,串口最高效的实现了

    因为我用的 HAL 库,所以直接在 HAL 库的回调函数实现的,每触发一次空闲中断后会进入这个函数,然后在函数里面更新数据块。

    void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
    {
        /* 过滤非USART1的接收事件,确保只处理USART1的数据 */
        if (huart->Instance != USART1) return;
        /* 当接收到有效数据时进行处理(Size > 0表示有数据到达) */
        if (Size > 0)
        {
            /* 更新总接收字节计数器,记录累计接收的数据量 */
            U0CB.URxCounter += Size;  
            
            /* 记录当前数据块的结束位置指针 * end指针指向接收到的最后一个字节位置 */
            U0CB.URxDataIN->end = &U0_RxBuff[U0CB.URxCounter-1];        
            
            /* 移动输入指针到下一个数据块位置* 准备为下一次接收分配新的数据块 */
            U0CB.URxDataIN++;
            
            /* 处理环形缓冲区回卷:当输入指针到达缓冲区末尾时回到起始位置 */
            if (U0CB.URxDataIN == U0CB.URxDataEND){
                U0CB.URxDataIN = &U0CB.URxDataPtr[0];
            }
            
            /* 为下一个数据块预分配起始指针位置
             * 根据剩余缓冲区空间决定分配策略 */
            if(U0_RX_SIZE - U0CB.URxCounter >= U0_RX_MAX){
                /* 缓冲区剩余空间充足,继续在当前位置分配 */
                U0CB.URxDataIN->start = &U0_RxBuff[U0CB.URxCounter]; 
            }else{
                /* 缓冲区空间不足,回卷到起始位置重新开始 */
                U0CB.URxDataIN->start = U0_RxBuff;
                U0CB.URxCounter = 0;
            }  
            
            /* 处理缓冲区溢出保护:当输入指针追上输出指针时
             * 强制移动输出指针,丢弃最旧的数据块 */
            if (U0CB.URxDataIN == U0CB.URxDataOUT)
            {
                U0CB.URxDataOUT++;
                if (U0CB.URxDataOUT > U0CB.URxDataEND)
                    U0CB.URxDataOUT = &U0CB.URxDataPtr[0];
            }
        }
        /* 重新启动DMA接收,准备接收下一批数据
         * 使用当前输入指针的起始位置作为DMA目标地址
         * 接收长度设置为U0_RX_MAX+1,确保能触发空闲中断 */
        HAL_UARTEx_ReceiveToIdle_DMA(&huart1, U0CB.URxDataIN->start, U0_RX_MAX);
    }
    

    第二步:实现软件 IIC 驱动 AT24C02,这个应该是和 M24C02 平替的,2K 的容量用来存放我们的标志位和版本号还有其他的东西(当然了,如果你本身 flash 就比较大,你完全可以不用外加的模块)

    我是用的这个模块去替代的

    然后呢,在这个工程里用的是:引脚为 PB6(SCL)和 PB7(SDA)

    我建议先去实现 IIC 的起始、停止、读取、发送、应答的函数,然后再根据 AT24C02 的数据手册去实现对应的读取和写入的函数。

    第三步:使用硬件 SPI,先实现 SPI 去读写一个字节的函数,然后进阶去 或者读写多个字节,然后去读写 W25Q64

    PA5 ------> SPI1_SCK

    PA6 ------> SPI1_MISO

    PA7 ------> SPI1_MOSI

    PBP-------->CS

    #define CS_ENABLE HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_RESET)

    #define CS_DISABLE HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_SET)

    这个的话就是用的个 W25Q64 模块,当然了,你使用别的外部 Flash 模块是一样的,只不过你一定要去理解他的物理结构!!!

    因为我们的 STM32F1 的 Flash 是 64k 的,所以我们可以一次写入 1K,正好 W25Q64 是有 64KB,每次可以编程 256 字节,然后四个 256 不就是 1K 啦,所以在程序里如果是正好 1k 的,我们就用 for 循环 4 次 256 对应 1K。

    然后具体的读写指令就去看手册就可以了。

    第四步:实现内部 Flash 的擦除和写入

    这个地方当时遇到的坑是这个 PER 位需要手动去清除,后来发现另外一个函数可以自动清除。

    关于 Flash 还是很有必要去了解一下的。有的地方用 Flash 去模拟 EEP,不过由于咱们的 Flash 比较小,所以就只设置一个 APP 区域(就是前面说的 A 区),如果你的 Flash 足够大,完全可以去实现三个分区,boot 区,APP1 区,APP2 区,然后把备用的区域放在 APP2 或者 APP1,然后收到指令或者发生需要升级的事件之后去搬运然后升级,这个放在后面去实现哈!

    void ST32F1_EraseFlash(uint16_t start, uint16_t num) {
        uint16_t i;
        HAL_FLASH_Unlock();                     /* 解锁Flash,允许进行擦除操作 */
         __disable_irq();  
        for(i = 0; i < num; i++) {
            /* 计算当前扇区的物理地址并执行擦除 */
            FLASH_PageErase((0x08000000 + start * 1024) + (1024 * i));
            
            while(__HAL_FLASH_GET_FLAG(FLASH_FLAG_BSY)) {}; /* 等待擦除操作完成 */          
            FLASH->CR &= ~FLASH_CR_PER;         /* 清除页擦除位PER,为下一次擦除做准备 */
        }
        __enable_irq();
        HAL_FLASH_Lock();                       /* 锁定Flash,保护Flash不被意外修改 */        
    }
    
    
    
    //可以自动擦除
    void ST32F1_EraseFlash(uint16_t start, uint16_t num) 
    {
    
       FLASH_EraseInitTypeDef EraseInitStruct;
       uint32_t PageError = 0;
       
       // 以下是稳定代码,无需修改
       HAL_FLASH_Unlock();
       
       EraseInitStruct.TypeErase = FLASH_TYPEERASE_PAGES;
       EraseInitStruct.PageAddress = 0x08000000 + start * FLASH_PAGE_SIZE;
       EraseInitStruct.NbPages = num;
         __disable_irq();  
       if(HAL_FLASHEx_Erase(&EraseInitStruct, &PageError) != HAL_OK) {
           // 错误处理(留给你实现)
            printf("error\r\n");
       }
       __enable_irq();
       HAL_FLASH_Lock();
    }
    //第二种实现
    void ST32F1_EraseFlash(uint16_t start, uint16_t num) {
        uint16_t i;
        HAL_FLASH_Unlock();                     /* 解锁Flash,允许进行擦除操作 */
         __disable_irq();  
        for(i = 0; i < num; i++) {
            /* 计算当前扇区的物理地址并执行擦除 */
            FLASH_PageErase((0x08000000 + start * 1024) + (1024 * i));
            
            while(__HAL_FLASH_GET_FLAG(FLASH_FLAG_BSY)) {}; /* 等待擦除操作完成 */          
            FLASH->CR &= ~FLASH_CR_PER;         /* 清除页擦除位PER,为下一次擦除做准备 */
        }
        __enable_irq();
        HAL_FLASH_Lock();                       /* 锁定Flash,保护Flash不被意外修改 */        
    }
    
    
    
    //可以自动擦除
    void ST32F1_EraseFlash(uint16_t start, uint16_t num) 
    {
    
       FLASH_EraseInitTypeDef EraseInitStruct;
       uint32_t PageError = 0;
       
       // 以下是稳定代码,无需修改
       HAL_FLASH_Unlock();
       
       EraseInitStruct.TypeErase = FLASH_TYPEERASE_PAGES;
       EraseInitStruct.PageAddress = 0x08000000 + start * FLASH_PAGE_SIZE;
       EraseInitStruct.NbPages = num;
         __disable_irq();  
       if(HAL_FLASHEx_Erase(&EraseInitStruct, &PageError) != HAL_OK) {
           // 错误处理(留给你实现)
            printf("error\r\n");
       }
       __enable_irq();
       HAL_FLASH_Lock();
    }
    

    第五步:实现基础的 BOOT(检测标志位然后判断是要更新还是跳转)

    我们可以实现一个最简单的 BOOT,就是在 main 去手动改写一个标志位

    这个结构体是我们用来保存 OTA 的一些信息的,这个 OTA_flag 就是用来判断是更新还是跳转的。因为是保存在 AT24C02 里面,所以,在这之前要熟悉 AT24C02 的读写!可以去看看这两个函数:void AT24C02_WriteOTAInfo(void)和 void AT24C02_ReadOTAInfo(void)。

    typedef struct{
        uint32_t OTA_flag;          // OTA升级标志位,用于控制升级流程
        uint32_t Firelen[11];       // 预留OTA文件大小数组,Firelen[0]存储实际文件长度
        uint8_t OTA_ver[32];        // OTA版本信息字符串
    }OTA_InfoCB;                    // OTA信息控制块类型定义
    
    void BootLoader_Brance(void)
    {
        /* 
         * BootLoader_Enter(20):等待用户输入,参数20表示等待2秒(20×100ms)
         * 如果用户在2秒内输入小写字母'w',函数返回1(进入Bootloader命令行)
         * 否则返回0(继续执行后续逻辑)
         */
        if(BootLoader_Enter(20) == 0){
            /* 检查OTA标志是否被设置 */
            if(OTA_Info.OTA_flag == OTA_SET_FLAG){
                /* OTA标志已设置,准备进行OTA更新 */
                printf("OTA更新\r\n");
            }
            else{
                /* OTA标志未设置,跳转到应用程序A区 */
                printf("OTA跳转\r\n");
                //LOAD_A(ST32F1_A_START_ADDR);        /* 跳转到应用程序起始地址 */
            }
        } 
        /* 
         * 以下代码在两种情况下执行:
         * 1. 用户在2秒内输入了'w'
         * 2. 应用程序跳转失败
         */
        printf("进入Bootloader命令行\r\n");
        BootLoader_Info();     /* 显示Bootloader命令菜单 */       
    }
    

    第六步:实现了最简单的跳转就可以再去增加新的功能了,这时候就可以添加命令行了

    串口解析数据然后判断指令,本工程是 6 个指令,先实现最简单的两个,一个是擦除 A 区,一个是重启。

    这两个就不赘述了,擦除的话就是第四步的 Flash 的内容,至于重启,每个 ARM 的内核 M3 呀,M4 呀都有自己的复位函数, NVIC_SystemReset(); 这个是 M3 的。

    然后再去增加新的命令,比如 2 命令,通过 Xmodem 去下载程序然后更新到 A 区,那么在这之前你是不是应该先去擦除 A 区?所以把指令 1 的代码复制过来即可,至于 Xmodem 的协议大家可以去看一下超子的视频解析

    BootStaFlag

    这个标志位是用来判断是否下载用的,重点就去看 Xmodem 下载的这个地方,这个也是一个坑,这个问题我觉得大家完全可以准备一下,当做面试的时候:

    面试官:你在做这个 OTA 升级的时候有没有遇到什么问题?

    你:通过 Xmodem 去下载数据包的时候遇到了沾包的问题……

    然后是怎么个事呢?

    else if(BootStaFlag & IAP_XMODEMD_FLAG){
            /* 处理Xmodem数据包(数据标志为0x01) */
            if((len == 133)&&(data[0] == 0x01)){
                BootStaFlag &=~ IAP_XMODEMC_FLAG;  // 清除Xmodem请求标志
                UpdataA.XmodemCRC = Xmodem_CRC16(&data[3], 128);  // 计算接收到的数据的CRC校验值    
                /* 检查CRC校验是否正确 */
                if(UpdataA.XmodemCRC == data[131] * 256 + data[132

剩余60%内容,订阅专栏后可继续查看/也可单篇购买

泻湖花园嵌入式Offer指南 文章被收录于专栏

从入门到上岸,一站式搞定求职! 本硕纯机械,无竞赛无论文,后转行嵌入式软件开发(因为课题组师哥转嵌入式拿到30Woffer之后狠狠心动),秋招最终收获35W+offer可以为27届或者28届的的UU们提供参考,可以关注一下!!!

全部评论
可以作为零基础入门嵌入式软件的一个小demo嗷
点赞 回复 分享
发布于 03-03 22:36 山东

相关推荐

最喜欢秋天的火龙果很...:第一份工作一定要往大的去,工资低点没事。后面换工作会更好找,即使你去小公司,你也不可能不会换工作的。所以找大的去
点赞 评论 收藏
分享
02-28 13:25
已编辑
门头沟学院 Java
点赞 评论 收藏
分享
评论
3
1
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务