:Ymode協(xié)議從零搭建Bootloader)
簡介本資源面向STM32嵌入式開發(fā)者與需要實現(xiàn)固件遠(yuǎn)程升級的工程師提供一套基于Ymode協(xié)議的IAP在線升級Bootloader完整工程可直接用于STM32G431平臺的產(chǎn)品固件迭代與維護(hù)場景。壓縮包共200個文件約10.62MB以C源碼與頭文件為核心包含26個.c與69個.h文件覆蓋HAL庫外設(shè)驅(qū)動、Flash讀寫、串口通信等模塊同時附帶Keil工程文件、編譯中間產(chǎn)物、hex燒錄文件及map映射表便于直接編譯驗證與二次開發(fā)。目前已有205人學(xué)習(xí)下載。讀者可從中獲取Ymode協(xié)議解析、IAP跳轉(zhuǎn)邏輯、Flash分區(qū)規(guī)劃與串口收發(fā)等關(guān)鍵實現(xiàn)思路并借助完整工程結(jié)構(gòu)快速搭建自己的Bootloader框架減少從零調(diào)試的時間成本適合具備一定STM32基礎(chǔ)、希望深入理解固件升級機(jī)制的開發(fā)者參考。1. STM32G431 上用 Ymode 協(xié)議做 IAP為什么值得從零搭一遍手里有一塊 STM32G431 的開發(fā)板想讓它能通過串口自己給自己升級固件不用每次都把調(diào)試器插上去燒錄——這個需求在量產(chǎn)設(shè)備、現(xiàn)場部署、遠(yuǎn)程維護(hù)場景里非常常見。IAPIn-Application Programming的核心思路是芯片里同時存在兩段程序一段是 bootloader負(fù)責(zé)接收新固件并寫入 Flash另一段是用戶應(yīng)用App負(fù)責(zé)實際業(yè)務(wù)邏輯。bootloader 永遠(yuǎn)不參與升級App 可以被反復(fù)替換。那 Ymode 協(xié)議在這里扮演什么角色簡單說它是一種輕量級的串口傳輸協(xié)議用來在 PC 和單片機(jī)之間可靠地傳輸固件數(shù)據(jù)。相比自己裸寫一個「發(fā)一包、回一個 ACK」的私有協(xié)議Ymode 提供了更完整的幀結(jié)構(gòu)、校驗機(jī)制和握手流程能顯著降低傳輸過程中丟包、錯包導(dǎo)致的升級失敗概率。STM32G431 自帶 128KB Flash、32KB RAMCortex-M4 內(nèi)核跑 170MHz資源做 IAP 綽綽有余關(guān)鍵是把分區(qū)規(guī)劃、跳轉(zhuǎn)邏輯和協(xié)議解析寫對。這篇文章面向的是有基本 STM32 開發(fā)經(jīng)驗、會用 Keil 或 CubeIDE、能看懂 HAL 庫代碼的嵌入式工程師。如果你之前沒做過 IAP跟著走能跑通如果你做過但用的是自定義協(xié)議這里能看到 Ymode 的幀設(shè)計和分區(qū)方案有什么不同。下面從 Flash 分區(qū)開始一步步把 bootloader 搭起來。2. Flash 分區(qū)規(guī)劃與 Ymode 幀結(jié)構(gòu)動手前必須定下來的事2.1 STM32G431 的 Flash 頁分布與分區(qū)方案STM32G431 的 Flash 按頁擦除每頁 2KB總共 64 頁128KB。做 IAP 的第一步不是寫代碼而是畫一張分區(qū)表。分錯了后面全要返工這是血淚經(jīng)驗。我一般會這樣分區(qū)域起始地址大小用途Bootloader0x0800000020KB10頁存放 bootloader 代碼參數(shù)區(qū)0x080050002KB1頁存放升級標(biāo)志、固件長度、CRCApp 區(qū)0x08005800100KB50頁存放用戶應(yīng)用固件預(yù)留區(qū)0x0801E8006KB3頁后續(xù)擴(kuò)展或備份Bootloader 放最前面是因為 Cortex-M4 復(fù)位后從 0x08000000 取向量表這是硬件決定的。參數(shù)區(qū)單獨(dú)占一頁用來存「是否需要升級」「新固件多大」「CRC 校驗值」這幾個關(guān)鍵信息掉電不丟。App 區(qū)從 0x08005800 開始注意這個地址必須是 2KB 對齊的否則擦除時會誤傷前一頁。注意G431 的 Flash 擦除粒度是頁寫入粒度是雙字64位。寫之前必須整頁擦除不能像 EEPROM 那樣按字節(jié)改。2.2 Ymode 協(xié)議幀格式與校驗邏輯Ymode 的幀結(jié)構(gòu)不復(fù)雜但每個字段都有存在的理由。典型幀格式如下| 幀頭(1B) | 命令(1B) | 長度(2B) | 數(shù)據(jù)(NB) | CRC16(2B) |幀頭固定為 0x59Ymode 的標(biāo)識命令字段區(qū)分握手、數(shù)據(jù)、結(jié)束等操作長度字段告訴接收方后面跟多少字節(jié)數(shù)據(jù)CRC16 覆蓋命令長度數(shù)據(jù)部分。接收方收到幀后先驗幀頭再驗 CRC兩步都過了才處理。為什么用 CRC16 而不是簡單累加和因為固件傳輸數(shù)據(jù)量大累加和漏檢率太高CRC16 能檢出絕大多數(shù)隨機(jī)錯誤和突發(fā)錯誤。Ymode 的 CRC16 一般用 CCITT 多項式0x1021初始值 0xFFFF這個組合在嵌入式里很常見代碼也好找。命令字段我一般定義這幾個0x01握手請求PC 問單片機(jī)「你準(zhǔn)備好了嗎」0x02握手應(yīng)答單片機(jī)回「我準(zhǔn)備好了」0x03數(shù)據(jù)幀攜帶固件內(nèi)容0x04結(jié)束幀告訴單片機(jī)「發(fā)完了」0x05應(yīng)答 ACK接收方確認(rèn)上一幀正確0x06錯誤 NAK要求重發(fā)握手階段很重要不能省。它讓雙方確認(rèn)波特率、數(shù)據(jù)格式一致避免 PC 已經(jīng)開始發(fā)了、單片機(jī)還在初始化的情況。2.3 用代碼定義幀結(jié)構(gòu)和 CRC 計算先看幀結(jié)構(gòu)體的定義和 CRC16 的實現(xiàn)#include stdint.h #include string.h #define YMODE_HEADER 0x59 #define YMODE_CMD_HANDSHAKE 0x01 #define YMODE_CMD_HANDSHAKE_ACK 0x02 #define YMODE_CMD_DATA 0x03 #define YMODE_CMD_END 0x04 #define YMODE_CMD_ACK 0x05 #define YMODE_CMD_NAK 0x06 #define YMODE_MAX_DATA_LEN 256 typedef struct { uint8_t header; // 幀頭固定 0x59 uint8_t cmd; // 命令字 uint16_t data_len; // 數(shù)據(jù)長度小端序 uint8_t data[YMODE_MAX_DATA_LEN]; // 載荷 uint16_t crc; // CRC16 校驗值 } ymodem_frame_t; // CRC16-CCITT 查表法實現(xiàn)速度快適合 170MHz 的 G431 static const uint16_t crc16_table[256] { 0x0000, 0x1021, 0x2042, 0x3063, /* ... 完整表省略實際使用需補(bǔ)全 ... */ }; uint16_t ymodem_crc16(const uint8_t *buf, uint32_t len) { uint16_t crc 0xFFFF; // 初始值 for (uint32_t i 0; i len; i) { crc (crc 8) ^ crc16_table[((crc 8) ^ buf[i]) 0xFF]; } return crc; }這段代碼里ymodem_frame_t結(jié)構(gòu)體定義了幀的內(nèi)存布局注意data_len和crc都是小端序因為 STM32 是小端芯片PC 端打包時也要按小端來。CRC 計算覆蓋范圍是cmd到data末尾不包括幀頭本身——幀頭只用來做同步不參與校驗。參數(shù)方面YMODE_MAX_DATA_LEN設(shè) 256 是權(quán)衡結(jié)果太小則幀數(shù)多、握手開銷大太大則接收緩沖區(qū)占 RAM 多而且串口中斷處理時間變長。256 字節(jié)在 115200 波特率下大約 22ms 傳完中斷不會阻塞太久。2.4 串口接收狀態(tài)機(jī)別在中斷里做重活串口接收不能收到一個字節(jié)就處理一個字節(jié)那樣容易丟數(shù)據(jù)。常見做法是中斷里只往環(huán)形緩沖區(qū)塞主循環(huán)里再解析幀。下面是一個簡化的狀態(tài)機(jī)typedef enum { STATE_IDLE, // 等待幀頭 STATE_CMD, // 收到幀頭等命令字 STATE_LEN, // 等長度字段2字節(jié) STATE_DATA, // 收數(shù)據(jù) STATE_CRC, // 收 CRC2字節(jié) } rx_state_t; static rx_state_t rx_state STATE_IDLE; static ymodem_frame_t rx_frame; static uint16_t rx_data_idx 0; static uint8_t rx_len_bytes[2]; static uint8_t rx_len_idx 0; void ymodem_rx_byte(uint8_t byte) { switch (rx_state) { case STATE_IDLE: if (byte YMODE_HEADER) { rx_frame.header byte; rx_state STATE_CMD; } break; case STATE_CMD: rx_frame.cmd byte; rx_state STATE_LEN; rx_len_idx 0; break; case STATE_LEN: rx_len_bytes[rx_len_idx] byte; if (rx_len_idx 2) { rx_frame.data_len rx_len_bytes[0] | (rx_len_bytes[1] 8); if (rx_frame.data_len YMODE_MAX_DATA_LEN) { rx_state STATE_IDLE; // 長度非法丟棄 } else { rx_data_idx 0; rx_state (rx_frame.data_len 0) ? STATE_DATA : STATE_CRC; } } break; case STATE_DATA: rx_frame.data[rx_data_idx] byte; if (rx_data_idx rx_frame.data_len) { rx_state STATE_CRC; rx_len_idx 0; } break; case STATE_CRC: rx_len_bytes[rx_len_idx] byte; if (rx_len_idx 2) { rx_frame.crc rx_len_bytes[0] | (rx_len_bytes[1] 8); // 校驗對 cmd len data 算 CRC uint8_t tmp[YMODE_MAX_DATA_LEN 3]; tmp[0] rx_frame.cmd; tmp[1] rx_frame.data_len 0xFF; tmp[2] (rx_frame.data_len 8) 0xFF; memcpy(tmp[3], rx_frame.data, rx_frame.data_len); uint16_t calc ymodem_crc16(tmp, rx_frame.data_len 3); if (calc rx_frame.crc) { ymodem_frame_ready(rx_frame); // 交給主循環(huán)處理 } rx_state STATE_IDLE; } break; } }狀態(tài)機(jī)的邏輯是逐字節(jié)推進(jìn)只有完整收到一幀且 CRC 通過才置標(biāo)志位。ymodem_frame_ready只是把幀拷到另一個緩沖區(qū)并置一個 volatile 標(biāo)志主循環(huán)看到標(biāo)志再處理這樣中斷里不會做 Flash 擦寫這種耗時操作。注意rx_frame和rx_state如果主循環(huán)也要讀需要加 volatile 或關(guān)中斷保護(hù)否則編譯器優(yōu)化可能出玄學(xué)問題。3. Bootloader 跳轉(zhuǎn)與 App 接收從握手到寫入 Flash 的完整鏈路3.1 跳轉(zhuǎn)前的準(zhǔn)備工作關(guān)中斷、設(shè)棧頂、改向量表Bootloader 收到完整固件并寫入 App 區(qū)之后下一步是跳過去執(zhí)行。跳轉(zhuǎn)不是一句goto就完事要做三件事第一關(guān)閉所有中斷和外設(shè)。SysTick、USART、DMA 都要 DeInit不然跳過去之后中斷向量還指向 bootloader 的處理函數(shù)一觸發(fā)就 HardFault。第二設(shè)置主棧指針MSP。App 的向量表第一個字就是棧頂?shù)刂诽D(zhuǎn)前要把它讀出來賦給 MSP。第三設(shè)置向量表偏移。App 的向量表在 0x08005800而 SCB-VTOR 默認(rèn)指向 0x08000000必須改過來。typedef void (*app_entry_t)(void); void jump_to_app(uint32_t app_addr) { uint32_t stack_top *(volatile uint32_t *)app_addr; uint32_t reset_handler *(volatile uint32_t *)(app_addr 4); // 檢查棧頂是否合法在 SRAM 范圍內(nèi) if (stack_top 0x20000000 || stack_top 0x20008000) { return; // 非法棧頂不跳 } // 關(guān)中斷 __disable_irq(); // 關(guān) SysTick SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; // 反初始化 HAL 和串口 HAL_DeInit(); // 設(shè)置向量表偏移 SCB-VTOR app_addr; // 設(shè)置 MSP __set_MSP(stack_top); // 跳轉(zhuǎn) app_entry_t app (app_entry_t)reset_handler; __enable_irq(); app(); }app_addr就是 0x08005800。棧頂檢查那一步別省如果 App 區(qū)是空的全 0xFF棧頂讀出來就是 0xFFFFFFFF直接設(shè) MSP 會立刻 HardFault連調(diào)試信息都看不到。加上范圍檢查至少能安全返回。3.2 接收固件并寫入 Flash按頁擦、按雙字寫Flash 寫入流程是收到數(shù)據(jù)幀 → 攢夠一頁 → 擦除該頁 → 寫入 → 回 ACK。G431 的 HAL 庫提供了HAL_FLASH_Unlock、HAL_FLASHEx_Erase、HAL_FLASH_Program三個函數(shù)用起來不復(fù)雜但有幾個參數(shù)必須設(shè)對。#define APP_START_ADDR 0x08005800 #define FLASH_PAGE_SIZE 2048 static uint32_t write_addr APP_START_ADDR; static uint8_t page_buf[FLASH_PAGE_SIZE]; static uint16_t page_offset 0; int flash_write_page(uint32_t addr, uint8_t *data, uint32_t len) { HAL_FLASH_Unlock(); // 擦除目標(biāo)頁 FLASH_EraseInitTypeDef erase {0}; erase.TypeErase FLASH_TYPEERASE_PAGES; erase.Page (addr - 0x08000000) / FLASH_PAGE_SIZE; erase.NbPages 1; uint32_t page_error 0; if (HAL_FLASHEx_Erase(erase, page_error) ! HAL_OK) { HAL_FLASH_Lock(); return -1; } // 按雙字64位寫入 for (uint32_t i 0; i len; i 8) { uint64_t dword 0; uint32_t remain len - i; uint32_t n (remain 8) ? 8 : remain; memcpy(dword, data[i], n); if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, addr i, dword) ! HAL_OK) { HAL_FLASH_Lock(); return -2; } } HAL_FLASH_Lock(); return 0; }erase.Page的計算方式是(addr - 0x08000000) / 2048因為頁號是從 Flash 基地址開始算的。HAL_FLASH_Program的第二個參數(shù)是目標(biāo)地址第三個參數(shù)是 64 位數(shù)據(jù)注意最后一個雙字如果不滿 8 字節(jié)要補(bǔ) 0xFF 而不是補(bǔ) 0因為 Flash 擦除后的狀態(tài)就是 0xFF。數(shù)據(jù)攢頁的邏輯每收到一幀數(shù)據(jù)就往page_buf里拷page_offset累加到 2048 就調(diào)flash_write_page寫一頁然后page_offset歸零。最后一頁不滿也要寫不足部分補(bǔ) 0xFF。3.3 升級標(biāo)志與 CRC 校驗讓 bootloader 知道該不該跳Bootloader 上電后第一件事是讀參數(shù)區(qū)看有沒有升級標(biāo)志。如果有就留在 bootloader 等 PC 發(fā)固件如果沒有直接跳 App。參數(shù)區(qū)的數(shù)據(jù)結(jié)構(gòu)我一般這樣定義#define PARAM_ADDR 0x08005000 typedef struct { uint32_t magic; // 魔數(shù)0x5A5A5A5A 表示參數(shù)有效 uint32_t upgrade_flag; // 1 表示需要升級 uint32_t fw_size; // 固件字節(jié)數(shù) uint32_t fw_crc; // 固件 CRC32 } param_t;上電流程void bootloader_main(void) { param_t *param (param_t *)PARAM_ADDR; if (param-magic ! 0x5A5A5A5A || param-upgrade_flag ! 1) { // 無升級請求直接跳 App jump_to_app(APP_START_ADDR); } // 有升級請求初始化串口等 PC 發(fā)固件 uart_init(115200); ymodem_receive_firmware(); // 接收完成后校驗 CRC uint32_t crc crc32_calc((uint8_t *)APP_START_ADDR, param-fw_size); if (crc param-fw_crc) { param-upgrade_flag 0; flash_write_param(param); // 清除升級標(biāo)志 jump_to_app(APP_START_ADDR); } else { // 校驗失敗留在 bootloader 等重發(fā) uart_send_nak(); } }CRC32 用標(biāo)準(zhǔn)多項式 0x04C11DB7PC 端算好之后通過結(jié)束幀發(fā)過來bootloader 收到后對 App 區(qū)數(shù)據(jù)再算一遍兩邊一致才跳轉(zhuǎn)。這一步是最后一道防線能攔住傳輸過程中被干擾但 CRC16 沒檢出的錯誤。3.4 PC 端發(fā)送工具的關(guān)鍵參數(shù)PC 端用 Python 寫一個發(fā)送腳本核心是pyserial庫。關(guān)鍵參數(shù)import serial import struct import time ser serial.Serial( portCOM3, baudrate115200, bytesizeserial.EIGHTBITS, parityserial.PARITY_NONE, stopbitsserial.STOPBITS_ONE, timeout2 ) def send_frame(cmd, datab): length len(data) payload struct.pack(BH, cmd, length) data crc crc16_ccitt(payload) frame b\x59 payload struct.pack(H, crc) ser.write(frame) # 等 ACK resp ser.read(1) return resp b\x06 # 假設(shè) ACK 用 0x06 表示波特率 115200 是保守選擇G431 跑 170MHz 可以上到 921600 甚至更高但前提是串口時鐘配置正確、線材質(zhì)量過關(guān)。第一次調(diào)通建議先用 115200穩(wěn)定后再提速。timeout2是等 ACK 的超時太短容易誤判重發(fā)太長則升級慢。4. 避坑與排查IAP 升級中最容易翻車的五個地方4.1 跳轉(zhuǎn)后 App 跑不起來卡在 HardFault現(xiàn)象bootloader 跳轉(zhuǎn)后App 沒有任何輸出調(diào)試器顯示停在 HardFault_Handler。原因最常見的是向量表沒設(shè)對。App 編譯時鏈接地址還是 0x08000000但實際燒在 0x08005800中斷向量表里的地址全是錯的。另一個可能是跳轉(zhuǎn)前沒關(guān) SysTickSysTick 中斷觸發(fā)后跳到 bootloader 的舊向量。解決App 工程的鏈接腳本.ld 或 Keil 的 IROM 設(shè)置必須改成起始 0x08005800同時在 App 的main函數(shù)開頭加SCB-VTOR 0x08005800;。跳轉(zhuǎn)前確認(rèn)SysTick-CTRL 0已執(zhí)行。4.2 Flash 寫入返回 HAL_ERROR現(xiàn)象HAL_FLASH_Program返回 HAL_ERROR數(shù)據(jù)寫不進(jìn)去。原因三種可能——目標(biāo)頁沒擦除、寫入地址不是 8 字節(jié)對齊、Flash 沒解鎖。G431 的 Flash 寫入要求地址 8 字節(jié)對齊如果write_addr是奇數(shù)或者不是 8 的倍數(shù)直接報錯。解決檢查write_addr是否從 0x08005800 開始且每次加 8確認(rèn)HAL_FLASH_Unlock()在擦除和寫入之前都調(diào)了擦除后讀一下目標(biāo)地址應(yīng)該是 0xFF。4.3 串口丟數(shù)據(jù)升級到一半卡死現(xiàn)象升級過程中進(jìn)度條走到某個位置就不動了PC 端等 ACK 超時。原因接收中斷優(yōu)先級太低被其他中斷打斷導(dǎo)致串口溢出或者環(huán)形緩沖區(qū)太小主循環(huán)處理不及時。解決把串口中斷優(yōu)先級設(shè)到最高搶占優(yōu)先級 0環(huán)形緩沖區(qū)至少 512 字節(jié)主循環(huán)里處理幀的速度要快Flash 擦寫操作放在幀處理之后不要阻塞接收。4.4 升級完成后 App 的全局變量初值不對現(xiàn)象App 跑起來后一些初始化為非零的全局變量值是 0。原因啟動文件里的.data段拷貝邏輯依賴鏈接腳本如果鏈接腳本沒改.data段的加載地址和運(yùn)行地址對不上拷貝出來就是錯的。解決App 工程的鏈接腳本要同時改 Flash 起始地址和.data段的 LMA加載地址。用 CubeIDE 的話在STM32G431XX_FLASH.ld里把FLASH的ORIGIN改成0x08005800.data段會自動跟著調(diào)整。4.5 參數(shù)區(qū)數(shù)據(jù)掉電丟失現(xiàn)象升級標(biāo)志寫進(jìn)去了斷電再上電讀出來是 0xFFFFFFFF。原因參數(shù)區(qū)所在的頁沒有正確擦除就寫入或者寫入后沒有等 Flash 操作完成就斷電。解決寫參數(shù)前先擦除整頁寫入后調(diào)HAL_FLASH_Lock()并加一個短延時比如 10ms再斷電參數(shù)結(jié)構(gòu)體加魔數(shù)校驗?zāi)?shù)不對就認(rèn)為參數(shù)無效走默認(rèn)跳 App 流程。5. 進(jìn)階技巧雙區(qū)備份與差分升級的取舍把基礎(chǔ) IAP 跑通之后下一步自然會想如果升級過程中斷電了怎么辦App 區(qū)被擦了一半跳也跳不過去設(shè)備變磚。這就是雙區(qū)備份要解決的問題。雙區(qū)方案是在 Flash 里劃兩個 App 區(qū)比如 App-A 從 0x08005800 開始App-B 從 0x0800C800 開始各占 50KB。bootloader 里維護(hù)一個「當(dāng)前活動區(qū)」標(biāo)志升級時往非活動區(qū)寫寫完后校驗 CRC通過則切換活動區(qū)標(biāo)志并跳轉(zhuǎn)不通過則繼續(xù)用舊區(qū)。這樣即使升級中斷舊區(qū)仍然完整可用。代價是 Flash 利用率減半。G431 只有 128KB雙區(qū)之后每個 App 最多 50KB 左右稍微大一點(diǎn)的工程就放不下。所以雙區(qū)適合對可靠性要求極高、固件體積可控的場景。另一個方向是差分升級只傳新舊固件之間的差異部分減少傳輸時間。但差分算法比如 bsdiff需要額外的 RAM 和 Flash 來解壓和合并G431 的 32KB RAM 跑起來比較吃力而且 bootloader 里要集成解壓庫復(fù)雜度上升不少。我的習(xí)慣是如果串口速率能到 921600全量升級 100KB 固件也就十幾秒差分帶來的收益不值得那個復(fù)雜度。最后說一個驗證技巧每次升級完成后不要只看「跳轉(zhuǎn)成功」就完事。在 App 里加一段代碼上電后往參數(shù)區(qū)寫一個「啟動計數(shù)」如果連續(xù)啟動失敗三次自動回退到 bootloader 等待重新升級。這個機(jī)制在實驗室里可能永遠(yuǎn)用不上但在現(xiàn)場部署時能救命。我自己就遇到過一批設(shè)備因為電源紋波導(dǎo)致 App 啟動偶發(fā)失敗靠這個回退機(jī)制遠(yuǎn)程恢復(fù)了大部分剩下的才需要現(xiàn)場處理。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取