Flash STM32G0: Linker Script, EEPROM Emulation và Dual Bank

Từ linker script chia vùng Flash, erase/write API, tại sao single-bank freeze, đến EEPROM emulation thực tế trên STM32G0 dual bank - không dùng thư viện ST, tự viết cho gọn.

12 phút đọc
STM32 / Firmware cover

1. Bài toán quen thuộc: lưu config mà không cần chip ngoài

Trong dự án POS mà tôi đang làm, mỗi máy có một bộ config riêng: địa chỉ IP, baud rate giao tiếp với máy in, timeout, các flag bật/tắt tính năng theo yêu cầu từng khách hàng. Những thứ này phải tồn tại qua reboot, qua cắt điện đột ngột, qua firmware update.

Cách đơn giản nhất là gắn thêm chip EEPROM ngoài - I2C EEPROM như 24Cxx rất phổ biến, dễ dùng. Nhưng trên board cost-optimized, thêm một chip là thêm BOM, thêm footprint, thêm điểm hỏng tiềm năng.

STM32G0B1 có 512KB Flash on-chip. Dùng một phần nhỏ để giả lập EEPROM - không tốn thêm linh kiện, không tốn thêm chân GPIO. Cái giá phải trả là phức tạp hơn một chút ở firmware. Bài này giải thích từng bước.


2. Flash STM32 không giống RAM - những ràng buộc cần nhớ

Trước khi code, phải hiểu rõ Flash hoạt động theo cách nào để tránh bẫy.

Chỉ erase mới reset về 0xFF. Không thể ghi trực tiếp một giá trị tùy ý vào Flash - chỉ có thể flip bit từ 10. Muốn ghi lại, phải erase page đó trước (về 0xFF), rồi mới ghi.

Erase theo page, không phải byte. STM32G0 dùng page 2KB. Muốn sửa 1 byte, phải erase cả 2KB chứa byte đó.

Ghi theo double word (64-bit). Địa chỉ phải align 8 byte.

Endurance có giới hạn. STM32G0 (trừ G030/G070 cấp thấp): 10.000 chu kỳ erase/program mỗi page. Ghi liên tục vào cùng một page sẽ hỏng sau vài ngàn lần - cần wear leveling.

Đọc thêm nền tảng: Flash, EEPROM và RAM khác nhau như thế nào?


3. Vấn đề của single-bank: erase là program đứng

Đây là thứ tôi học theo cách khó nhất.

Lần đầu implement config storage trên STM32G0, tôi dùng chip single-bank (G031). Mọi thứ chạy tốt trong lab - ghi config, reboot, đọc lại đúng. Nhưng khi test với máy in đang hoạt động, thỉnh thoảng máy in mất kết nối đúng lúc save config.

Nguyên nhân: trên single-bank Flash, khi CPU đang erase hoặc program Flash, Flash bus bị chiếm hoàn toàn. CPU không fetch được instruction → toàn bộ program đứng im. Erase một page 2KB mất khoảng 20-40ms.

Single-bank: |--chạy--|--ERASE 25ms, CPU đứng--|--chạy--|
                        ^ UART interrupt bị bỏ lỡ ở đây
                        ^ FreeRTOS tick mất 25ms
                        ^ Máy in timeout

Interrupt mode (HAL_FLASHEx_Erase_IT) không giải quyết được - CPU vẫn cần fetch instruction từ Flash để chạy interrupt handler, mà Flash đang bận. Giải pháp cho single-bank là copy hàm erase vào RAM và chạy từ đó - phức tạp và tốn RAM.

Đọc thêm: Flash Operations STM32: tại sao program đứng


4. STM32G0B1 dual bank - bài toán thay đổi

STM32G0B1 có tính năng quan trọng: dual bank Flash. Khi bật, 512KB Flash chia thành Bank 1 và Bank 2, mỗi bank có bus riêng.

CPU
 ├─→ Flash Interface 1 → Bank 1 (code đang chạy)
 └─→ Flash Interface 2 → Bank 2 (đang erase/program config)

CPU đang chạy code từ Bank 1 - không bị ảnh hưởng gì khi Bank 2 đang erase. UART interrupt vẫn chạy, FreeRTOS tick vẫn đều, máy in không mất kết nối.

Đây là lý do tôi chọn G0B1 thay vì G031 cho dự án POS.

Bật dual bank bằng option byte DBANK - chi tiết trong note STM32G0 Dual Bank Flash.


5. Linker script: chia Flash thành vùng rõ ràng

Với dual bank G0B1, tôi chia Flash như sau:

Bank 1 (0x08000000 - 0x0803FFFF, 256K):
  Application code: toàn bộ Bank 1

Bank 2 (0x08040000 - 0x0807FFFF, 256K):
  EEPROM emulation: 2 page đầu (4KB)
  Phần còn lại: dự phòng hoặc OTA update slot

Linker script (STM32G0B1RETX_FLASH.ld):

MEMORY
{
  /* Bank 1: Application code */
  FLASH     (rx)  : ORIGIN = 0x08000000, LENGTH = 256K

  /* Bank 2: Data storage */
  FLASH_EE  (r)   : ORIGIN = 0x08040000, LENGTH = 4K

  RAM       (xrw) : ORIGIN = 0x20000000, LENGTH = 144K
}

SECTIONS
{
  /* Code và const - Bank 1 */
  .text :
  {
    KEEP(*(.isr_vector))
    *(.text .text.*)
    *(.rodata .rodata.*)
  } > FLASH

  /* Initialized data - LMA Flash, VMA RAM */
  .data :
  {
    _sdata = .;
    *(.data .data.*)
    _edata = .;
  } > RAM AT> FLASH

  _sidata = LOADADDR(.data);

  /* Zero-initialized data */
  .bss :
  {
    _sbss = .;
    *(.bss .bss.*)
    *(COMMON)
    _ebss = .;
  } > RAM

  /* EEPROM emulation region - Bank 2
     NOLOAD: không chiếm RAM, KEEP: linker không optimize bỏ */
  .eeprom_store (NOLOAD) :
  {
    . = ALIGN(2048);
    KEEP(*(.eeprom_store))
    . = ALIGN(2048);
  } > FLASH_EE
}

_sidata là LMA của .data - địa chỉ nguồn trong Flash mà startup code dùng để copy sang RAM. Đọc thêm về LMA/VMA: Linker Script STM32.


6. EEPROM emulation - concept và tại sao cần wear leveling

Ý tưởng đơn giản: thay vì ghi config vào một địa chỉ cố định (sẽ hỏng sau 10K lần), ta append record mới vào cuối page đang dùng. Khi page đầy, copy giá trị mới nhất của từng variable sang page kia, rồi erase page cũ.

Page A (Active):
  [Header: VALID] [var1=10] [var2=5] [var1=12] [var2=5] [var1=15] [FREE...]
  ^                ^         ^        ^ update   ^         ^ update
  page header      initial   initial  var1 lần 2          var1 lần 3

Khi Page A đầy:
  1. Copy giá trị CUỐI CÙNG của mỗi variable sang Page B
  2. Đánh dấu Page B = VALID
  3. Erase Page A
  4. Page B là Active page mới

Tại sao wear leveling hiệu quả? Vì hai page cùng chia nhau tải erase. Thay vì một page bị erase mỗi lần ghi config, hai page được erase xen kẽ nhau. Với page 2KB và mỗi record 8 bytes, một page chứa được ~255 record - nghĩa là 255 lần ghi config mới erase một lần. Tổng số lần ghi config trước khi hỏng: 10.000 × 255 × 2 page ≈ 5 triệu lần. Với POS system ghi config vài lần mỗi ngày, đây là tuổi thọ thực tế rất dài.


7. Format record trong Flash

Mỗi record tôi dùng 8 bytes (một double-word để ghi một lần):

/* 8 bytes = 1 double-word write */
typedef struct __attribute__((packed)) {
    uint16_t var_id;    /* ID của variable: 0x0001 - 0xFFFE */
    uint16_t checksum;  /* XOR đơn giản để phát hiện corruption */
    uint32_t value;     /* Giá trị - có thể cast sang kiểu cần */
} EepromRecord_t;

/* Giá trị đặc biệt */
#define VAR_ID_ERASED   0xFFFF  /* Page vừa erase, chưa có record */
#define VAR_ID_INVALID  0x0000  /* Record bị invalidate */

Page header - 8 bytes đầu của mỗi page:

typedef struct __attribute__((packed)) {
    uint16_t state;     /* Page state */
    uint16_t reserved;
    uint32_t magic;     /* 0xEE10DEAD - verify page hợp lệ */
} PageHeader_t;

#define PAGE_STATE_ERASED   0xFFFF
#define PAGE_STATE_RECEIVE  0xEEEE  /* Đang nhận data (transfer in progress) */
#define PAGE_STATE_VALID    0xAAAA  /* Active, đang dùng */
#define PAGE_STATE_ERASING  0x0000  /* Đang được erase */

Page state machine:

ERASED (0xFFFF)
    ↓ header write
RECEIVE (0xEEEE)  ← transfer đang diễn ra
    ↓ transfer xong
VALID (0xAAAA)    ← active page, đang ghi thêm record
    ↓ page đầy → bắt đầu transfer
  [page kia: ERASED → RECEIVE → VALID]

ERASING (0x0000)  → erase → ERASED

State machine này quan trọng để handle power loss mid-operation: nếu mất điện khi đang transfer sang page mới (RECEIVE), lần khởi động sau phát hiện có page ở RECEIVE state → tiếp tục hoặc rollback về page VALID cũ.


8. Implementation: khởi tạo và tìm active page

/* Địa chỉ hai page trong Bank 2 */
#define EE_PAGE_A_ADDR  0x08040000UL
#define EE_PAGE_B_ADDR  0x08040800UL
#define EE_PAGE_SIZE    2048U
#define EE_MAGIC        0xEE10DEADU

static uint32_t s_active_page = 0;  /* Địa chỉ page đang dùng */
static uint32_t s_write_offset = 0; /* Offset ghi tiếp theo trong page */

static PageState_t GetPageState(uint32_t page_addr) {
    PageHeader_t *hdr = (PageHeader_t*)page_addr;
    if (hdr->magic != EE_MAGIC) return PAGE_STATE_ERASED;
    return (PageState_t)hdr->state;
}

EepromStatus_t EEPROM_Init(void) {
    PageState_t state_a = GetPageState(EE_PAGE_A_ADDR);
    PageState_t state_b = GetPageState(EE_PAGE_B_ADDR);

    if (state_a == PAGE_STATE_VALID && state_b != PAGE_STATE_VALID) {
        s_active_page = EE_PAGE_A_ADDR;
    } else if (state_b == PAGE_STATE_VALID && state_a != PAGE_STATE_VALID) {
        s_active_page = EE_PAGE_B_ADDR;
    } else if (state_a == PAGE_STATE_ERASED && state_b == PAGE_STATE_ERASED) {
        /* Fresh start - format page A */
        return FormatPage(EE_PAGE_A_ADDR);
    } else {
        /* Ambiguous state - power loss recovery */
        return RecoverFromAmbiguousState(state_a, state_b);
    }

    /* Tìm offset ghi tiếp theo (sau record cuối) */
    s_write_offset = FindNextWriteOffset(s_active_page);
    return EEPROM_OK;
}

9. Read và Write

EepromStatus_t EEPROM_Read(uint16_t var_id, uint32_t *value) {
    if (s_active_page == 0) return EEPROM_NOT_INIT;

    /* Scan từ cuối page về đầu - lấy giá trị mới nhất */
    uint32_t offset = s_write_offset - sizeof(EepromRecord_t);

    while (offset >= sizeof(PageHeader_t)) {
        EepromRecord_t *rec = (EepromRecord_t*)(s_active_page + offset);

        if (rec->var_id == var_id) {
            /* Verify checksum */
            uint16_t expected = (uint16_t)(var_id ^ (uint16_t)rec->value
                                          ^ (uint16_t)(rec->value >> 16));
            if (rec->checksum == expected) {
                *value = rec->value;
                return EEPROM_OK;
            }
        }
        offset -= sizeof(EepromRecord_t);
    }
    return EEPROM_NOT_FOUND;
}

EepromStatus_t EEPROM_Write(uint16_t var_id, uint32_t value) {
    if (s_active_page == 0) return EEPROM_NOT_INIT;

    /* Kiểm tra còn chỗ không */
    if (s_write_offset + sizeof(EepromRecord_t) > EE_PAGE_SIZE) {
        return TransferToNewPage(var_id, value);
    }

    /* Build record */
    EepromRecord_t rec = {
        .var_id   = var_id,
        .value    = value,
        .checksum = (uint16_t)(var_id ^ (uint16_t)value
                               ^ (uint16_t)(value >> 16)),
    };

    /* Ghi vào Flash */
    uint32_t addr = s_active_page + s_write_offset;
    uint64_t dword;
    memcpy(&dword, &rec, sizeof(rec));

    HAL_FLASH_Unlock();
    HAL_StatusTypeDef status = HAL_FLASH_Program(
        FLASH_TYPEPROGRAM_DOUBLEWORD, addr, dword);
    HAL_FLASH_Lock();

    if (status != HAL_OK) return EEPROM_WRITE_ERROR;

    s_write_offset += sizeof(EepromRecord_t);
    return EEPROM_OK;
}

10. Transfer sang page mới - bước quan trọng nhất

Đây là nơi dễ sai nhất nếu có power loss:

static EepromStatus_t TransferToNewPage(uint16_t new_var_id,
                                         uint32_t new_value) {
    uint32_t new_page = (s_active_page == EE_PAGE_A_ADDR)
                        ? EE_PAGE_B_ADDR : EE_PAGE_A_ADDR;

    /* 1. Đánh dấu page mới là RECEIVE - trước khi copy */
    /*    Nếu mất điện ở đây: Init sẽ thấy RECEIVE và recover */
    WritePageHeader(new_page, PAGE_STATE_RECEIVE);

    /* 2. Copy giá trị MỚI NHẤT của mỗi variable sang page mới */
    CopyLatestValues(s_active_page, new_page);

    /* 3. Ghi variable mới (lý do trigger transfer) */
    AppendRecord(new_page, new_var_id, new_value);

    /* 4. Đánh dấu page mới là VALID */
    WritePageHeader(new_page, PAGE_STATE_VALID);

    /* 5. Đánh dấu page cũ là ERASING */
    WritePageHeader(s_active_page, PAGE_STATE_ERASING);

    /* 6. Erase page cũ - chạy từ Bank 2, code đang ở Bank 1
     *    Không cần execute-from-RAM vì dual bank! */
    uint32_t old_page = s_active_page;
    s_active_page = new_page;
    s_write_offset = FindNextWriteOffset(new_page);

    ErasePage(old_page);  /* Có thể mất 25ms, nhưng không freeze code */

    return EEPROM_OK;
}

11. Power loss recovery

Khi khởi động, nếu phát hiện trạng thái không bình thường:

static EepromStatus_t RecoverFromAmbiguousState(
    PageState_t state_a, PageState_t state_b)
{
    /* Trường hợp: mất điện trong khi transfer */
    if (state_a == PAGE_STATE_VALID && state_b == PAGE_STATE_RECEIVE) {
        /* Page B đang nhận dở - incomplete transfer
         * Page A vẫn VALID và đầy đủ → dùng Page A
         * Erase Page B để reset về trạng thái sạch */
        ErasePage(EE_PAGE_B_ADDR);
        s_active_page = EE_PAGE_A_ADDR;
        s_write_offset = FindNextWriteOffset(EE_PAGE_A_ADDR);
        return EEPROM_OK;
    }

    if (state_a == PAGE_STATE_RECEIVE && state_b == PAGE_STATE_VALID) {
        ErasePage(EE_PAGE_A_ADDR);
        s_active_page = EE_PAGE_B_ADDR;
        s_write_offset = FindNextWriteOffset(EE_PAGE_B_ADDR);
        return EEPROM_OK;
    }

    /* Trường hợp khác: format lại toàn bộ */
    ErasePage(EE_PAGE_A_ADDR);
    ErasePage(EE_PAGE_B_ADDR);
    return FormatPage(EE_PAGE_A_ADDR);
}

12. Dùng trong dự án thực tế

Với thư viện này, lưu và đọc config đơn giản:

/* Định nghĩa variable IDs */
#define CFG_BAUD_RATE     0x0001
#define CFG_IP_OCTET_3    0x0002
#define CFG_TIMEOUT_MS    0x0003
#define CFG_FEATURE_FLAGS 0x0004

/* Khởi tạo khi boot */
EEPROM_Init();

/* Đọc config - với default nếu chưa có */
uint32_t baud;
if (EEPROM_Read(CFG_BAUD_RATE, &baud) != EEPROM_OK) {
    baud = 115200;  /* Default */
}

/* Ghi config khi user thay đổi */
EEPROM_Write(CFG_BAUD_RATE, 9600);

Với POS system chạy FreeRTOS, tôi đặt EEPROM_Write() trong một task riêng có priority thấp - nhận request từ queue, ghi Flash khi hệ thống ít tải. EEPROM_Read() không tốn thời gian đáng kể (chỉ scan trong RAM-mapped Flash) nên gọi được từ bất kỳ đâu.


13. Checklist trước khi dùng production

Checklist EEPROM emulation STM32G0


14. Tóm tắt

Flash STM32G0B1 với dual bank mode giải quyết được bài toán EEPROM emulation theo cách sạch - không tốn BOM, không cần execute-from-RAM trick, không freeze firmware trong khi save config.

Kiến trúc cốt lõi:

  • Linker script chia rõ Bank 1 cho code, Bank 2 cho data
  • Hai page xen kẽ với state machine để wear leveling và power-loss safe
  • Append-only record với var_id + value + checksum
  • Transfer page là bước phức tạp nhất - phải xử lý đúng thứ tự để recovery được
  • Dual bank là thứ giúp tất cả chạy không freeze

Với POS system cần ghi config vài lần mỗi ngày, thiết kế này đủ bền cho vài chục năm sử dụng - còn lâu hơn tuổi thọ thiết bị.


Đọc tiếp

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

Bài tiếp theo 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.