STM32 Bootloader: Execute from RAM, UART Update và Jump to Application

Xây dựng bootloader thực tế trên STM32G0: execute from RAM để erase flash không freeze, nhận firmware qua UART, jump đúng cách vào application với VTOR và MSP.

12 phút đọc
STM32 / Firmware cover

1. Bootloader là gì và tại sao cần thiết?

Trong POS system, một trong những yêu cầu hay gặp nhất là: cập nhật firmware từ xa mà không cần kỹ thuật viên ra tận nơi cắm ST-Link. Máy đặt ở cửa hàng, cách xa vài trăm kilomét - khi có lỗi cần fix hoặc tính năng mới cần đưa vào, cách duy nhất là update qua kết nối có sẵn: UART đến máy tính thu ngân, hoặc sau này là qua mạng.

Đây là lý do bootloader tồn tại.

Bootloader là một chương trình firmware nhỏ chạy đầu tiên khi chip reset. Nhiệm vụ của nó:

  1. Kiểm tra có firmware mới không (qua UART, UART-to-WiFi, SD card, …)
  2. Nếu có → nhận và ghi vào Flash
  3. Nếu không → jump vào application chính

Bootloader và application là hai chương trình riêng biệt, mỗi cái có linker script riêng, nằm ở vùng Flash khác nhau.

Flash 512K (STM32G0B1 dual bank):

Bank 1:
  0x08000000 ┬─ Bootloader (64K)
  0x08010000 └─ Application (192K)

Bank 2:
  0x08040000 ┬─ OTA download slot (240K)
  0x0807C000 └─ Config/EEPROM (16K)

2. Vấn đề đặc trưng của single-bank: erase flash là đứng chương trình

Bài trước đã nói về single-bank vs dual-bank trong context EEPROM emulation. Với bootloader, vấn đề còn rõ ràng hơn.

Bootloader cần làm hai việc đồng thời:

  • Nhận firmware mới qua UART - cần UART interrupt chạy liên tục
  • Erase và ghi Flash - nếu single-bank, đây là thao tác block toàn bộ CPU

Trên single-bank chip (G031, G041…): khi erase Flash, CPU không fetch được instruction. UART interrupt không chạy được. Dữ liệu đến trong UART Rx buffer bị overflow → mất data → firmware update bị hỏng.

Giải pháp cho single-bank: execute from RAM - copy hàm erase vào RAM và chạy từ đó. CPU fetch instruction từ RAM, Flash bus được giải phóng cho erase, UART interrupt chạy bình thường.

Trên dual-bank G0B1: nếu bootloader đang ở Bank 1 và erase Bank 2 (OTA slot) → không cần execute-from-RAM. Nếu bootloader cần erase Bank 1 (chính nó đang chạy từ đó) → vẫn cần execute-from-RAM.

Bài này cover cả hai scenario.


3. Execute from RAM - setup linker script

Thêm section .RamFunc vào linker script của bootloader:

/* Trong bootloader linker script */
MEMORY
{
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 64K
  RAM   (xrw) : ORIGIN = 0x20000000, LENGTH = 144K
}

SECTIONS
{
  .text   : { ... } > FLASH
  .data   : { ... } > RAM AT> FLASH
  .bss    : { ... } > RAM

  /* Functions execute from RAM */
  .RamFunc :
  {
    . = ALIGN(4);
    _sRamFunc = .;
    *(.RamFunc)
    *(.RamFunc.*)
    . = ALIGN(4);
    _eRamFunc = .;
  } > RAM AT> FLASH

  _siRamFunc = LOADADDR(.RamFunc);
}

Startup code cần copy .RamFunc vào RAM, tương tự copy .data. Nếu dùng CubeMX startup file, có thể thêm vào Reset_Handler hoặc thêm section vào .data region để startup tự copy.

Cách đơn giản hơn: merge vào .data copy bằng cách đặt section trong cùng vùng AT> FLASH:

/* Hoặc đơn giản hơn: đặt .RamFunc ngay sau .data */
.data :
{
  _sdata = .;
  *(.data .data.*)
  *(.RamFunc)         /* Merge vào đây, startup sẽ copy luôn */
  *(.RamFunc.*)
  _edata = .;
} > RAM AT> FLASH

4. Viết hàm erase chạy từ RAM

/**
 * Erase một page Flash.
 * Attribute section(".RamFunc") → linker đặt hàm này vào RAM section.
 * noinline → đảm bảo hàm không bị inline vào caller (vẫn ở Flash).
 * used     → ngăn linker optimize bỏ hàm này nếu không thấy reference.
 */
__attribute__((section(".RamFunc"), noinline, used))
HAL_StatusTypeDef Flash_ErasePage(uint32_t bank, uint32_t page)
{
    FLASH_EraseInitTypeDef cfg = {
        .TypeErase = FLASH_TYPEERASE_PAGES,
        .Banks     = bank,
        .Page      = page,
        .NbPages   = 1,
    };
    uint32_t page_error = 0;

    HAL_FLASH_Unlock();
    HAL_StatusTypeDef status = HAL_FLASHEx_Erase(&cfg, &page_error);
    HAL_FLASH_Lock();

    return status;
}

__attribute__((section(".RamFunc"), noinline, used))
HAL_StatusTypeDef Flash_WriteDoubleWord(uint32_t address, uint64_t data)
{
    HAL_FLASH_Unlock();
    HAL_StatusTypeDef status = HAL_FLASH_Program(
        FLASH_TYPEPROGRAM_DOUBLEWORD, address, data);
    HAL_FLASH_Lock();
    return status;
}

5. Bootloader flow tổng thể

Power on / Reset

Bootloader init (clock, UART, GPIO)

Kiểm tra update trigger:
  - GPIO pin giữ khi reset? (nút UPDATE)
  - UART có gửi magic byte trong 2 giây?
  - Flag trong EEPROM/backup register?

[Có update] ──→ Firmware Update Flow ──→ Reset

[Không có update]

Validate application:
  - MSP value tại APP_BASE có hợp lệ?

[Valid] ──→ Jump to Application

[Invalid]
  Application chưa flash → blink LED, chờ update

6. Trigger update - cách quyết định vào update mode

Có nhiều cách trigger bootloader mode:

Cách 1: GPIO pin - đơn giản nhất, giữ một nút khi reset.

/* Pull-up nội, active LOW */
if (HAL_GPIO_ReadPin(UPDATE_GPIO_Port, UPDATE_Pin) == GPIO_PIN_RESET) {
    EnterUpdateMode();
}

Cách 2: Magic flag trong Backup Register - application ghi flag vào RTC Backup Register trước khi reset, bootloader đọc.

/* Application muốn update: */
HAL_PWR_EnableBkUpAccess();
HAL_RTCEx_BKUPWrite(&hrtc, RTC_BKP_DR0, 0xDEADBEEF);
HAL_NVIC_SystemReset();

/* Bootloader kiểm tra: */
HAL_PWR_EnableBkUpAccess();
uint32_t flag = HAL_RTCEx_BKUPRead(&hrtc, RTC_BKP_DR0);
if (flag == 0xDEADBEEF) {
    HAL_RTCEx_BKUPWrite(&hrtc, RTC_BKP_DR0, 0x00000000); /* Clear */
    EnterUpdateMode();
}

Cách này tiện vì application có thể trigger update từ xa qua lệnh từ host - không cần người tới nhấn nút.

Cách 3: UART magic byte timeout - bootloader chờ trong 2 giây, nếu nhận được magic byte thì vào update mode, nếu không thì jump thẳng vào application.

uint8_t rx_byte = 0;
uint32_t start = HAL_GetTick();

while (HAL_GetTick() - start < 2000) {
    if (HAL_UART_Receive(&huart1, &rx_byte, 1, 100) == HAL_OK) {
        if (rx_byte == BOOT_MAGIC) {
            EnterUpdateMode();
            return;
        }
    }
}
JumpToApplication(APP_BASE_ADDRESS);

Cho POS system, tôi dùng kết hợp cách 2 và 3: application trigger bằng backup register khi nhận lệnh update từ server, bootloader chờ UART. Không cần kỹ thuật viên ra tay.


7. Nhận firmware qua UART - protocol đơn giản

Protocol không cần phức tạp. Tôi dùng frame-based với CRC:

Frame layout:
┌────────┬────────┬────────┬─────────────────┬───────┐
│ START  │  CMD   │  LEN   │    PAYLOAD      │  CRC  │
│ 1 byte │ 1 byte │ 2 byte │  0-256 bytes    │ 2 byte│
└────────┴────────┴────────┴─────────────────┴───────┘

START  = 0x7E (fixed)
CMD:
  0x01 = START_UPDATE  (payload: firmware size 4 bytes)
  0x02 = DATA_CHUNK    (payload: address 4 bytes + data N bytes)
  0x03 = END_UPDATE    (payload: total CRC 4 bytes)
  0x04 = RESET         (no payload)
CRC    = CRC-16 CCITT của CMD+LEN+PAYLOAD

Response từ bootloader:

0x06 = ACK
0x15 = NACK + 1 byte error code
typedef enum {
    BL_CMD_START  = 0x01,
    BL_CMD_DATA   = 0x02,
    BL_CMD_END    = 0x03,
    BL_CMD_RESET  = 0x04,
} BlCommand_t;

typedef enum {
    BL_ERR_OK       = 0x00,
    BL_ERR_CRC      = 0x01,
    BL_ERR_ADDR     = 0x02,
    BL_ERR_FLASH    = 0x03,
    BL_ERR_SIZE     = 0x04,
} BlError_t;

8. Ghi firmware vào Flash - thực tế

Khi nhận được frame DATA_CHUNK:

static BlError_t HandleDataChunk(uint32_t address,
                                  const uint8_t *data,
                                  uint16_t len)
{
    /* Validate địa chỉ - chỉ cho phép ghi vào OTA slot */
    if (address < OTA_SLOT_BASE ||
        address + len > OTA_SLOT_BASE + OTA_SLOT_SIZE) {
        return BL_ERR_ADDR;
    }

    /* Align address và len lên 8 bytes */
    if ((address % 8) != 0 || (len % 8) != 0) {
        return BL_ERR_ADDR;
    }

    /* Erase page nếu đây là byte đầu tiên của page mới */
    if ((address % FLASH_PAGE_SIZE) == 0) {
        uint32_t page = (address - FLASH_BASE) / FLASH_PAGE_SIZE;
        uint32_t bank = (address < BANK2_BASE) ?
                         FLASH_BANK_1 : FLASH_BANK_2;

        /* Flash_ErasePage chạy từ RAM - không freeze UART interrupt */
        if (Flash_ErasePage(bank, page) != HAL_OK) {
            return BL_ERR_FLASH;
        }
    }

    /* Ghi từng double-word */
    for (uint16_t i = 0; i < len; i += 8) {
        uint64_t dword;
        memcpy(&dword, data + i, 8);
        if (Flash_WriteDoubleWord(address + i, dword) != HAL_OK) {
            return BL_ERR_FLASH;
        }
    }

    return BL_ERR_OK;
}

9. Verify firmware sau khi nhận xong

Trước khi commit (cho phép boot vào firmware mới), verify bằng CRC:

static bool VerifyFirmware(uint32_t base, uint32_t size,
                            uint32_t expected_crc)
{
    /* Dùng STM32 hardware CRC cho nhanh */
    __HAL_RCC_CRC_CLK_ENABLE();
    CRC->CR = CRC_CR_RESET;

    uint32_t *ptr = (uint32_t*)base;
    uint32_t words = size / 4;
    for (uint32_t i = 0; i < words; i++) {
        CRC->DR = ptr[i];
    }
    uint32_t crc = CRC->DR;

    return (crc == expected_crc);
}

Chỉ sau khi verify pass, mới ghi flag báo “firmware mới hợp lệ”:

/* Ghi magic word vào đầu OTA slot để báo valid */
/* (hoặc dùng EEPROM emulation để lưu trạng thái update) */
uint64_t valid_flag = FIRMWARE_VALID_MAGIC;
Flash_WriteDoubleWord(OTA_STATUS_ADDR, valid_flag);

10. Jump to Application - đúng thứ tự

Sau khi xác nhận application hợp lệ, jump:

#define APP_BASE_ADDRESS  0x08010000UL

void JumpToApplication(void)
{
    uint32_t app_base = APP_BASE_ADDRESS;

    /* 1. Validate - MSP phải trỏ vào RAM */
    uint32_t msp = *(uint32_t*)app_base;
    if ((msp & 0xFF000000) != 0x20000000) {
        /* Không có application hợp lệ */
        Error_Handler();
        return;
    }

    /* 2. Deinit peripheral bootloader đã dùng */
    HAL_UART_DeInit(&huart1);
    HAL_RCC_DeInit();
    SysTick->CTRL = 0;
    SysTick->LOAD = 0;
    SysTick->VAL  = 0;

    /* 3. Disable tất cả interrupt, clear pending */
    __disable_irq();
    for (int i = 0; i < 8; i++) {
        NVIC->ICER[i] = 0xFFFFFFFF;
        NVIC->ICPR[i] = 0xFFFFFFFF;
    }

    /* 4. Set VTOR */
    SCB->VTOR = app_base;

    /* 5. Set MSP */
    __set_MSP(msp);

    /* 6. Jump */
    typedef void (*FuncPtr)(void);
    FuncPtr app_reset = (FuncPtr)(*(uint32_t*)(app_base + 4));
    __enable_irq();
    app_reset();

    /* Không bao giờ đến đây */
    while (1) {}
}

11. Phía application: confirm VTOR

Application được build với FLASH_ORIGIN = 0x08010000. Trong system_stm32g0xx.c:

/* Thêm vào đầu SystemInit() */
void SystemInit(void)
{
    /* Relocate vector table về địa chỉ của application */
#if defined(USER_VECT_TAB_ADDRESS)
    SCB->VTOR = VECT_TAB_BASE_ADDRESS | VECT_TAB_OFFSET;
#else
    SCB->VTOR = 0x08010000UL;  /* Hardcode nếu cần */
#endif
    /* ... phần còn lại của SystemInit ... */
}

Hoặc trong CubeMX, set VECT_TAB_OFFSET = 0x10000 (offset của application so với Flash base).


12. Dual bank cho OTA: Bank 2 làm download slot

Với G0B1 dual bank, strategy tôi dùng cho POS:

Normal operation:
  Boot từ Bank 1 (bootloader + application)
  Bank 2 = OTA download slot (trống hoặc backup)

Update flow:
  1. Server gửi lệnh update qua UART
  2. Application set flag trong Backup Register, reset
  3. Bootloader nhận firmware mới qua UART → ghi vào Bank 2
  4. Verify CRC
  5. Ghi metadata "Bank 2 valid" vào EEPROM
  6. Reset - bootloader đọc metadata → copy Bank 2 → Bank 1
     (hoặc set BFB2 để boot từ Bank 2, tùy strategy)
  7. Application mới chạy

Bước 6 là phần tinh tế nhất. Có hai strategy:

Strategy A: Copy Bank 2 → Bank 1 - application luôn chạy từ địa chỉ cố định 0x08010000. Đơn giản cho linker script, không cần lo BFB2. Nhược điểm: copy mất thời gian, nếu mất điện trong khi copy thì cả hai bank đều hỏng.

Strategy B: Bank swap qua BFB2 - bootloader set BFB2 để boot từ Bank 2 trực tiếp. Application cần được build để chạy được từ cả Bank 1 lẫn Bank 2 (position independent hoặc hai build khác nhau). Phức tạp hơn nhưng không có risk mất điện khi copy.

Cho POS system, tôi dùng Strategy A vì đơn giản hơn và thời gian copy (~192KB tại 64MHz Flash) chỉ mất vài giây - chấp nhận được khi đang update.


13. Checklist bootloader production

Checklist trước khi deploy bootloader lên POS


14. Tóm tắt

Bootloader STM32 không phức tạp về concept, nhưng có nhiều chi tiết nhỏ mà sai một cái là crash không rõ nguyên nhân:

  • Execute from RAM cho single-bank chip để UART interrupt không bị block khi erase Flash
  • Dual bank G0B1 giải quyết vấn đề này ở hardware level - erase Bank 2 không block Bank 1
  • Jump sequence phải đúng thứ tự: DeInit peripheral → disable IRQ → set VTOR → set MSP → jump
  • Validate MSP trước khi jump - tránh nhảy vào Flash chưa có firmware
  • HAL_RCC_DeInit() trước khi jump - tránh clock mismatch gây lỗi giao tiếp
  • CRC verify trước khi commit firmware mới - tránh boot firmware bị corrupt

Đọc tiếp

Notes nền tảng cho bài này:

Bài trước trong series:


Tài liệu tham khảo

Thấy nội dung này hữu ích?

Lưu lại hoặc chia sẻ cho người cũng đang học firmware, BIOS/UEFI và embedded systems.

Nội dung liên quan

Một số bài viết, ghi chú hoặc project có liên quan đến nội dung bạn vừa đọc.

Đọc thêm về BIOS/UEFI

Khám phá các bài viết về BIOS/UEFI, embedded firmware, debugging và system-level thinking.