場升級實戰(zhàn):Bootloader設(shè)計、Flash分區(qū)與回滾策略)
簡介基于STM32F407的IAP現(xiàn)場升級例程完整實現(xiàn)Bootloader引導(dǎo)程序與APP版本標(biāo)記判斷邏輯。IAP通過在Flash指定地址寫入程序版本標(biāo)志APP啟動時據(jù)此決定是否需要跳轉(zhuǎn)升級適用于需要現(xiàn)場固件更新、遠(yuǎn)程維護(hù)的工業(yè)控制與物聯(lián)網(wǎng)設(shè)備。資源包含Keil MDK工程全部源碼及編譯輸出共910個文件以105個C源文件和126個頭文件為核心附帶編譯中間文件.o、.crf等及工程配置壓縮包約50.67MB。APP部分采用FreeRTOS配合CAN1/CAN2總線與IAP共同構(gòu)成完整升級方案工程結(jié)構(gòu)清晰便于學(xué)習(xí)IAP原理和實際移植。目前已有3745人學(xué)習(xí)下載適合正在開發(fā)STM32固件升級功能的嵌入式工程師參考。1. 現(xiàn)場升級不是玄學(xué)STM32F407 IAP 要解決的三個問題實驗室里用調(diào)試器下載固件很容易設(shè)備裝到機(jī)柜、戶外箱體或者產(chǎn)線末端后再開蓋接調(diào)試器就變得昂貴且危險。IAP 現(xiàn)場升級就是把這個下載動作搬到設(shè)備自己身上運(yùn)行中的 STM32F407 通過 UART、CAN 或者以太網(wǎng)接收到新固件把它寫進(jìn) Flash 的另一個區(qū)域然后在重啟后切換到新程序。圍繞 STM32F407 做 IAP核心其實只有三件事一張 Bootloader/App 的 Flash 分區(qū)表、一段安全的跳轉(zhuǎn)代碼、一套能校驗和回滾的升級協(xié)議。這里按“分區(qū) → 協(xié)議 → 實現(xiàn) → 可靠性 → 驗證”的順序展開適合已經(jīng)能驅(qū)動外設(shè)、正準(zhǔn)備給產(chǎn)品增加升級能力的工程師。2. 從啟動流程看 IAPFlash 分區(qū)、向量表偏移與跳轉(zhuǎn)2.1 STM32F407 扇區(qū)劃分與 Boot/App 分區(qū)策略STM32F407 的 1MB 版本 Flash 從 0x08000000 開始按扇區(qū)組織。前四個扇區(qū)各 16KB扇區(qū) 4 是 64KB扇區(qū) 5 到 7 各 128KB。STM32CubeProgrammer 或者 Linker Script 里看到的地址并不連續(xù)等長所以劃分 Bootloader 和 App 前先要把扇區(qū)邊界寫死在工程文檔里。| 扇區(qū) | 地址區(qū)間 | 容量 | 常見角色 | | S0 | 0x08000000 - 0x08003FFF | 16KB | Bootloader 起始 | | S1 | 0x08004000 - 0x08007FFF | 16KB | Bootloader | | S2 | 0x08008000 - 0x0800BFFF | 16KB | Bootloader | | S3 | 0x0800C000 - 0x0800FFFF | 16KB | Bootloader | | S4 | 0x08010000 - 0x0801FFFF | 64KB | App A 起始 | | S5 | 0x08020000 - 0x0803FFFF | 128KB | App A | | S6 | 0x08040000 - 0x0805FFFF | 128KB | App B 起始 | | S7 | 0x08060000 - 0x0807FFFF | 128KB | App B / 參數(shù)區(qū) |這個表是后續(xù)所有 IAP 參數(shù)的基礎(chǔ)。Bootloader 放在 S0-S3大小 64KB足夠容納 HAL、串口驅(qū)動、Flash 驅(qū)動和簡易協(xié)議棧。App A 從 S4 開始使用 S4S5 共 192KBApp B 從 S6 開始使用 S6S7 中的前 192KBS7 尾部 64KB 留給升級標(biāo)志或日志。為什么不把 Bootloader 壓到 32KB 甚至 16KB現(xiàn)場升級設(shè)備往往要兼容多種協(xié)議和故障恢復(fù)邏輯Bootloader 超過預(yù)計容量時被迫重畫分區(qū)會牽連 App 的鏈接地址所以按 Bootloader 64KB 起步比較穩(wěn)妥。2.2 向量表偏移跳轉(zhuǎn)前后最容易被忽略的環(huán)節(jié)Cortex-M4 復(fù)位后從 0x08000000 讀棧頂和復(fù)位向量隨后按向量表找中斷入口。Bootloader 運(yùn)行時向量表在 0x08000000一旦跳進(jìn) App中斷拿到的是 App 向量表。如果不重定位SCB-VTOR任何一個 UART 中斷都會跳到 Bootloader 的地址輕則功能錯亂重則 HardFault。部分工程師只在 App 的SystemInit()或main()開頭寫SCB-VTOR 0x08010000但跳轉(zhuǎn)瞬間到 App 完成該賦值之間仍有一段空窗任何掛起的中斷都可能觸發(fā)非法入口。我一般會把重定位放到 Bootloader 的跳轉(zhuǎn)函數(shù)里并在 App 側(cè)也保留一次賦值作為雙保險。STM32F407 的 VTOR 要求向量表地址按表的大小對齊實際工程里只要把 App 起始地址對齊到 0x100 即可。使用 STM32CubeIDE 時Linker Script 的FLASH (rx) : ORIGIN 0x08010000, LENGTH 192K會把中斷向量表放到新地址代碼里的宏必須與之一致。2.3 跳轉(zhuǎn)函數(shù)完整實現(xiàn)先校驗棧頂再切換 MSP 和 VTOR跳轉(zhuǎn)不是把函數(shù)指針指過去那么簡單。App 前 8 字節(jié)分別是初始棧頂 MSP 和復(fù)位向量 PCBootloader 先讀出這兩個值校驗它們落在合法的 SRAM 與 Flash 范圍再把 VTOR 切到 App 基地址最后設(shè)置 MSP 并跳轉(zhuǎn)。這樣能避免 Flash 全 0xFF 或下載半截時直接飛掉。#include stm32f4xx.h #include stdint.h #define APP_BASE 0x08010000U void jump_to_app(uint32_t app_base) { uint32_t app_sp *(volatile uint32_t *)app_base; uint32_t app_pc *(volatile uint32_t *)(app_base 4); void (*app_reset)(void) (void (*)(void))app_pc; /* 棧頂必須在 512KB SRAM 地址空間內(nèi) */ if ((app_sp 0xFFF00000U) ! 0x20000000U) { return; } /* 復(fù)位向量應(yīng)落在 Flash 主存儲區(qū) */ if ((app_pc 0xFFF00000U) ! 0x08000000U) { return; } __disable_irq(); SCB-VTOR app_base; __set_MSP(app_sp); app_reset(); }__disable_irq()把全局中斷關(guān)掉防止跳轉(zhuǎn)過程中 SysTick 或外設(shè)中斷搶先執(zhí)行。__set_MSP(app_sp)必須在進(jìn)入 App 棧結(jié)構(gòu)前完成復(fù)位函數(shù)第一條指令通常是從該地址彈棧。兩個范圍校驗覆蓋了最常見故障扇區(qū)擦了一半、固件寫錯位置、固件包格式損壞。如果校驗不通過函數(shù)直接返回Bootloader 還能繼續(xù)等待下一次升級不會讓設(shè)備變磚。切記跳轉(zhuǎn)前把 UART、DMA、定時器等已開啟的中斷源全部 DeInit全局中斷開關(guān)只是最后一道保險。3. 設(shè)計升級協(xié)議幀格式、校驗與失敗重傳3.1 為什么用 YMODEM為什么也可以自己定義YMODEM 是非常適合 STM32F407 串口升級的協(xié)議。它把固件切成長度 1024 字節(jié)的數(shù)據(jù)塊每個塊帶序號和 CRC16接收端可以確認(rèn)或請求重發(fā)Bootloader 每收到一塊就處理一塊。YMODEM 的最大優(yōu)勢是上位機(jī)工具成熟Tera Term、SecureCRT 等串口工具都內(nèi)置支持現(xiàn)場人員不需要額外開發(fā)發(fā)送端。但如果 IAP 的傳輸通道不是 UART而是 CAN、RS485 或以太網(wǎng)YMODEM 就不自然。這時用自繪私有幀反而更簡單。另外如果要約定固件版本、產(chǎn)品型號或加密信息私有協(xié)議也更容易擴(kuò)展不會受 YMODEM 頭部字段限制。3.2 私有幀的最小定義幀頭、序號、長度與 CRC32私有幀設(shè)計應(yīng)留出擴(kuò)展位而不必像 YMODEM 那樣兼容歷史。最小推薦結(jié)構(gòu)如下表它同時覆蓋開始、數(shù)據(jù)和結(jié)束三種語義Bootloader 與上位機(jī)按同一張表解包。| 偏移 | 字段 | 長度 | 說明 | | 0 | HEAD | 2 | 0xAA 0x55同步字 | | 2 | CMD | 1 | 0x01開始0x02數(shù)據(jù)0x03結(jié)束0x10ACK0x11NACK | | 3 | SEQ | 2 | 小端序號發(fā)送方從 0 遞增 | | 5 | LEN | 2 | 數(shù)據(jù)區(qū)長度最大 1024 | | 7 | DATA | LEN | Payload固件內(nèi)容或升級參數(shù) | | 7LEN | CRC32 | 4 | 從 CMD 到 DATA 末尾的校驗值 |為什么用 CRC32 而不是 CRC16STM32F407 片上帶有 CRC 計算外設(shè)計算 1KB 數(shù)據(jù)的開銷遠(yuǎn)小于軟件實現(xiàn)且 CRC32 對全 0xFF 和連續(xù)錯誤的檢測能力更好。CRC32 的初值建議固定為 0xFFFFFFFF工程上不要混用 HAL 庫默認(rèn)初值和上位機(jī)常見的zlib.crc32初值否則首字節(jié)就會校驗失敗。3.3 接收側(cè)狀態(tài)機(jī)按字節(jié)驅(qū)動不卡 Flash升級幀被串口一字節(jié)一字節(jié)地送進(jìn)來。用阻塞式HAL_UART_Receive也能做但 Bootloader 在擦除扇區(qū)時不能同時從 UART 接收長幀會被丟棄。更穩(wěn)的做法是 UART 中斷或 DMA 每收到一個字節(jié)調(diào)用一次解析函數(shù)狀態(tài)機(jī)只把完整幀放到 RAM數(shù)據(jù)校驗通過后再進(jìn)入 Flash 擦寫。下面是一個最小狀態(tài)機(jī)用于同步幀頭并收集完整幀。void iap_rx_byte(uint8_t b) { static uint8_t pkt[1034]; static uint32_t idx 0; static uint16_t data_len 0; static uint8_t state 0; if (state 0) { if (b 0xAA) { pkt[idx] b; state 1; } return; } if (state 1) { if (b 0x55) { pkt[idx] b; state 2; } else { idx 0; state 0; } return; } pkt[idx] b; /* 已收完 CMD/SEQ/LEN 共 7 字節(jié)先解長度 */ if (state 2 idx 7) { data_len pkt[5] | ((uint16_t)pkt[6] 8); if (data_len 1024 || 7 data_len 4 sizeof(pkt)) { idx 0; state 0; } else { state 3; } return; } /* 狀態(tài) 3收滿數(shù)據(jù)和 CRC 后進(jìn)入上層處理 */ if (state 3 idx 7 data_len 4) { if (crc32_buf(pkt 2, 5 data_len) *(uint32_t *)(pkt 7 data_len)) { iap_process_pkt(pkt, data_len); } idx 0; state 0; } }pkt緩存 1034 字節(jié)等于 7 字節(jié)頭加 1024 字節(jié)數(shù)據(jù)加 4 字節(jié) CRC。data_len從幀頭偏移 5、6 讀出先檢查是否超過緩沖區(qū)防止惡意幀把內(nèi)存寫穿。狀態(tài)機(jī)只在完整幀到達(dá)后才調(diào)用iap_process_pkt這一步才允許做扇區(qū)擦除和 Flash 寫入。至于*(uint32_t *)(pkt 7 data_len)這種非對齊讀取HAL 庫里沒有問題嚴(yán)謹(jǐn)一點(diǎn)可以用memcpy再解。4. 落地基于 UART 在 STM32F407 上跑通一次現(xiàn)場升級4.1 最小 Bootloader 的 main 流程IAP 的 Bootloader 不需要跑操作系統(tǒng)也不初始化完整業(yè)務(wù)外設(shè)。它通常按“檢查升級標(biāo)志 → 進(jìn)升級或跳轉(zhuǎn)”兩條路徑執(zhí)行。出廠時可以把 App 區(qū)預(yù)燒好也可以等首包強(qiáng)制升級。最小 main 如下int main(void) { HAL_Init(); SystemClock_Config(); MX_USART2_UART_Init(); if (iap_flag_read() IAP_MAGIC) { iap_flag_clear(); iap_download(); NVIC_SystemReset(); } jump_to_app(APP_BASE); while (1); }iap_flag_read讀哪個地址我一般讀取 S7 尾部單獨(dú)留出的 64KB 參數(shù)區(qū)而不是普通 RAM 變量。Bootloader 和 App 都可能在復(fù)位后重新初始化 RAM普通變量不可靠。標(biāo)志值取固定魔數(shù)0xA55A5AA5同時寫入兩次讀出來兩次一致才認(rèn)為有效避免 Flash 寫一半產(chǎn)生偽升級請求。iap_download內(nèi)部按第 3 章的協(xié)議接收幀每收到一個完整幀先校驗 CRC再寫入目標(biāo)地址。寫入完成后把 App 起始地址和長度記錄到參數(shù)區(qū)方便 Bootloader 下一次做地址校驗。4.2 App 側(cè)收到遠(yuǎn)程命令置升級標(biāo)志后軟復(fù)位App 已經(jīng)運(yùn)行在 S4-S5 區(qū)域它不能直接擦寫自己所在的 Flash 來接收新固件。即使能升級中途斷電也會把自己擦掉。規(guī)范做法是App 只驗證升級命令的合法性、寫入升級魔數(shù)然后調(diào)用NVIC_SystemReset()軟復(fù)位讓 Bootloader 完成真正的固件接收和 Flash 寫入。這樣 App 側(cè)代碼量很小也能獨(dú)立測試。void app_on_upgrade_command(uint8_t *cmd, uint16_t len) { if (!iap_validate_cmd(cmd, len)) { return; } flash_param_write(IAP_MAGIC, APP_VERSION_CURRENT); NVIC_SystemReset(); }遠(yuǎn)程命令在實際產(chǎn)品里可能來自 UART 轉(zhuǎn) 4G 模塊也可能來自 RS485 總線。關(guān)鍵點(diǎn)是 App 在收到命令到復(fù)位之間要先把當(dāng)前外設(shè)斷開、把未保存參數(shù)寫掉。復(fù)位后 Bootloader 不會初始化 App 的外設(shè)整個流程干凈利落。若升級持續(xù)失敗Bootloader 判斷超時后清除升級標(biāo)志再回到j(luò)ump_to_app設(shè)備還能繼續(xù)跑舊固件。4.3 生成升級包objcopy 與頭部 CRC工程里生成升級包一般不在 IDE 里手動做而是編譯后跑腳本。先用 objcopy 把 ELF 轉(zhuǎn)成 bin再用 Python 腳本封裝頭部否則 Bootloader 不知道固件長度和校驗值。常見做法是生成“魔法字 版本 長度 CRC32 bin”的app_pkg.binarm-none-eabi-objcopy -O binary build/app.elf build/app.bin python3 pack_fw.py build/app.bin build/app_pkg.binpack_fw.py里按與 Bootloader 一致的字段封裝import struct, sys, zlib fw open(sys.argv[1], rb).read() crc zlib.crc32(fw) 0xFFFFFFFF hdr struct.pack(HHII, 0xA55A, 0x0100, len(fw), crc) open(sys.argv[2], wb).write(hdr fw)Bootloader 的iap_download收到首幀時先解頭部magic、版本、長度、CRC然后才知道總幀數(shù)和目標(biāo)地址。注意 zlib.crc32 與 STM32 硬件 CRC 輸出字節(jié)序不同。我這里上位機(jī)直接按軟件 CRC32 發(fā)給 BootloaderBootloader 也用同一個多項式計算兩端一致即可不要混用兩套 CRC 實現(xiàn)。5. 可靠性坑擦除時序、看門狗、斷電與 IAP 回滾5.1 跳轉(zhuǎn)與復(fù)位前的現(xiàn)場清理第 2 章的跳轉(zhuǎn)函數(shù)只做了最基礎(chǔ)的中斷屏蔽實際固件里還應(yīng)在跳轉(zhuǎn)前調(diào)用HAL_UART_DeInit、HAL_RCC_DeInit并清掉 SysTick 等外設(shè)。為什么App 啟動后SystemInit很可能重新配置時鐘如果時鐘外設(shè)仍由 Bootloader 事先配置App 的 PLL 配置可能產(chǎn)生一段非預(yù)期頻率甚至觸發(fā)異常。跳到 App 前把 RCC 恢復(fù)到復(fù)位值能減少不確定因素。DMA 還在傳輸數(shù)據(jù)時跳轉(zhuǎn)也會留下懸空總線事務(wù)進(jìn)入 App 后可能觸發(fā)總線錯誤所以現(xiàn)場清理順序建議是先關(guān)中斷源再 DeInit 外設(shè)停 DMA最后關(guān)全局中斷。void enter_app(void) { HAL_UART_DeInit(huart2); HAL_DMA_DeInit(hdma_usart2_rx); HAL_RCC_DeInit(); SysTick-CTRL 0; __disable_irq(); SCB-VTOR APP_BASE; __set_MSP(*(volatile uint32_t *)APP_BASE); ((void (*)(void))(*(volatile uint32_t *)(APP_BASE 4)))(); }注意跳轉(zhuǎn)前看門狗如果已經(jīng)啟動Bootloader 也必須持續(xù)喂狗直到它把控制權(quán)交給 App 早期代碼。App 側(cè)一進(jìn) main 就要重新初始化 IWDG否則新固件會被舊看門狗復(fù)位。5.2 Flash 擦寫期間的等待與中斷STM32F407 的 Flash 擦除和編程操作執(zhí)行時Flash 不能同時被 CPU 取指。HAL 庫內(nèi)部會把操作序列寫入 FLASH 寄存器然后輪詢 BSY。如果你在升級中開著一個 1kHz 定時器中斷Flash 忙時中斷入口無法取指中斷會一直掛起直到 BSY 結(jié)束才繼續(xù)。短時間無所謂但扇區(qū)擦除最壞需要數(shù)秒實時性要求高的系統(tǒng)會出現(xiàn)明顯毛刺。常用緩解辦法有兩條升級階段關(guān)閉這類中斷或者把中斷服務(wù)程序放到 SRAM 和 CCM RAM 中讓 CPU 不訪問程序 Flash。F407 的 CCM RAM 沒有數(shù)據(jù)總線適合放緊急處理函數(shù)。擦寫代碼本身也要求不訪問 Flash常見做法是把擦寫循環(huán)放到 RAM 中執(zhí)行。標(biāo)準(zhǔn)外設(shè)庫里用FLASH_If_Write時會通過__RAM_FUNC標(biāo)識把關(guān)鍵函數(shù)放在 RAM。如果只用 HAL 庫只要 Bootloader 從 S0-S3 取指而目標(biāo)地址是 S4-S7就不會擦到自己正在執(zhí)行的代碼。升級前還要確認(rèn)FLASH_ClearError清掉上一次可能的操作錯誤否則后續(xù) Program 會一直報寫保護(hù)。5.3 雙 A/B 區(qū)回滾單 Bank 的 F407 只能軟件解決F407 沒有像 F42x/F43x 那樣的硬件雙 Bank 切換。做 IAP 回滾只能靠多留一個 App 區(qū)域并在分區(qū)表中建立 A/B 關(guān)系。第 2 章的分區(qū)方案可以落地為Bootloader 放 S0-S3AppA 放 S4-S5AppB 放 S6-S7 的前 192KBS7 尾部 64KB 用作參數(shù)和標(biāo)志。升級時 Bootloader 把新固件寫到非活躍區(qū)寫完做整體 CRC 校驗通過后修改標(biāo)志并跳到新區(qū)如果校驗失敗舊區(qū)沒有被碰過Bootloader 繼續(xù)跳舊區(qū)這就是“IAP 如何備份和回滾”的常規(guī)做法。| 回滾方案 | Flash 開銷 | 斷電安全性 | 工程復(fù)雜 | | 單 App備份區(qū) | 1 個 App 1 個備份 | 斷電時只能恢復(fù)到備份版本 | 中需在升級前先把舊固件搬到備份區(qū) | | 雙 A/B 區(qū) | 2 個 App 區(qū) | 基本不依賴備份過程 | 低標(biāo)志位驅(qū)動切換 |雙 A/B 區(qū)不是沒有代價。F407 扇區(qū)大小不均兩個區(qū)物理尺寸很難完全一致實際使用以較小的 A 區(qū) 192KB 為限。固件一旦超過這個上限只能退回“單 App備份區(qū)”方案升級前先備份舊固件再擦寫主區(qū)。6. 現(xiàn)場升級的防呆驗證版本號、硬件 CRC 與日志6.1 Bootloader 和 App 共用一套版本輸出如果升級以后設(shè)備黑屏優(yōu)先懷疑跳轉(zhuǎn)失敗而不是 App 功能錯誤。為了第一時間判斷Bootloader 和 App 都必須在啟動階段打印自己的版本號。定義版本宏時把目標(biāo) ID 和編譯時間一起放進(jìn)去日志里出現(xiàn)BOOT 1.0.3 | APP 1.2.3 2025-06-01 10:00:00說明跳轉(zhuǎn)成功并且新區(qū)被激活。如果只有 BOOT 版本大概率是 App 啟動被卡住。版本信息建議固化在固件文件頭而不是只寫在代碼打印字符串里否則日志和實際運(yùn)行固件可能錯位。6.2 用硬件 CRC 校驗新固件別只信設(shè)備正常啟動下載完成后只做一次整個 App 區(qū)域的 CRC 校驗比依賴 App 自己運(yùn)行正常更可靠。STM32F407 自帶 CRC 外設(shè)計算 192KB 的開銷遠(yuǎn)小于每次串口重傳。下列代碼示意用 CRC 外設(shè)完成區(qū)域校驗uint32_t iap_calc_crc(uint32_t addr, uint32_t len) { uint32_t crc; RCC-AHB1ENR | RCC_AHB1ENR_CRCEN; CRC-CR CRC_CR_RESET; for (uint32_t i 0; i len; i 4) { CRC-DR *(volatile uint32_t *)(addr i); } crc CRC-DR; RCC-AHB1ENR ~RCC_AHB1ENR_CRCEN; return crc; }注意讀取CRC-DR得到的是位反序后的結(jié)果上位機(jī)如果用zlib.crc32計算需要把結(jié)果按字節(jié)翻轉(zhuǎn)再寫入固件頭。實際產(chǎn)品里我通常讓兩端都先用軟件 CRC等協(xié)議穩(wěn)定后再把 Bootloader 側(cè)替換成硬件 CRC減少升級包處理耗時。CRC 校驗結(jié)果要和 A/B 切換標(biāo)志一起寫入同一個 32 位字只有在標(biāo)志與 CRC 同時匹配時才允許跳轉(zhuǎn)這是讓 IAP 現(xiàn)場升級可靠收尾的最后一個細(xì)節(jié)。本文還有配套的精品資源點(diǎn)擊獲取