Mở đầu: ai chạy trước cả main()?

Suốt 11 bài vừa qua, mọi thứ đều bắt đầu từ main() — như thể nó là điểm khởi đầu tuyệt đối của chương trình. Nhưng một câu hỏi đơn giản sẽ lật ngược giả định đó: một biến toàn cục như int volume = 7; lấy đâu ra giá trị 7 ngay khi main() vừa bắt đầu chạy, trong khi RAM mất điện là trắng xoá hoàn toàn? Câu trả lời là có cả một đoạn code chạy TRƯỚC main() mà bạn chưa từng viết — do trình biên dịch và linker tự sinh ra, âm thầm chuẩn bị sẵn cả bộ nhớ trước khi giao quyền điều khiển lại cho bạn.

Bài này mổ xẻ chính xác điều gì xảy ra giữa lúc cấp nguồn và dòng code đầu tiên của main(), bản đồ bộ nhớ chương trình chia ra sao, và một pitfall nguy hiểm bậc nhất của lập trình nhúng: không có MMU bảo vệ nghĩa là một lỗi đệ quy sâu có thể âm thầm ghi đè lên dữ liệu của bạn mà không hề có một thông báo lỗi nào.


📚 Điều kiện tiên quyết
Bắt buộc: Bài 1 (bản đồ bộ nhớ tổng quan), Bài 7 (vector table — Mục 1 bài này dùng lại đúng khái niệm đó cho vector reset). Nên biết thêm: phạm vi biến và con trỏ trong C.

1. Trước main() là gì

Nhớ lại vector table của Bài 7: một mảng con trỏ hàm nằm đầu Flash, mỗi ô ứng với một loại ngắt. Ô ĐẦU TIÊN của mảng đó (địa chỉ 0x00000000) không phải một ngắt thông thường — nó chứa giá trị khởi đầu của Stack Pointer (SP), và ô thứ hai chứa địa chỉ của Reset_Handler. Khi vừa cấp nguồn hoặc reset, CPU làm đúng 3 việc trước khi chạm vào bất kỳ dòng code nào của bạn:

  1. Đọc ô đầu vector table, nạp giá trị đó vào thanh ghi SP — stack đã có "đỉnh" để bắt đầu.
  2. Đọc ô thứ hai, nhảy tới đó — đây chính là Reset_Handler, đoạn code khởi động.
  3. Reset_Handler làm 2 việc quan trọng: chép .data từ Flash sang RAM (giá trị khởi tạo thật của các biến toàn cục ≠ 0), rồi xoá trắng .bss (đặt về 0 toàn bộ) — chỉ sau đó mới gọi main().

Câu hỏi mở đầu giờ có lời giải: volume = 7 lấy giá trị 7 từ một bản sao được lưu SẴN trong Flash (không mất khi cúp điện), và Reset_Handler chép đúng giá trị đó sang RAM trước khi main() kịp chạy dòng đầu tiên.

2. Các section — nhà của từng loại biến

Trình biên dịch và linker xếp mọi thứ trong chương trình vào 4 "khu" cố định, mỗi khu có luật riêng:

Section Chứa gì Nằm ở Ví dụ
.text Mã máy đã biên dịch Flash Mọi hàm bạn viết
.rodata Hằng số (const) Flash const int TABLE[100] = {...};
.data Biến toàn cục khởi tạo ≠ 0 RAM (bản sao chép từ Flash) int volume = 7;
.bss Biến toàn cục = 0 hoặc chưa khởi tạo RAM (chỉ cấp chỗ, không cần lưu giá trị) int counter; hoặc int flag = 0;

Một chi tiết hay bị bỏ sót: int flag = 0; ở phạm vi toàn cục thường KHÔNG rơi vào .data dù có viết = 0 tường minh — trình biên dịch đủ thông minh để nhận ra bước xoá trắng .bss (Mục 1) đã lo sẵn giá trị 0, nên xếp nó vào .bss luôn cho đỡ tốn chỗ trong ảnh .data phải nạp vào Flash. Verify: classifySection trên VMCU trả về đúng .bss cho cả hai trường hợp "khởi tạo bằng 0" và "không khởi tạo".

⚠️ Pitfall verified: quên const cho bảng tra cứu lớn
Một bảng tra cứu 100 phần tử viết int TABLE[100] = {...}; (thiếu const) rơi thẳng vào .data — chiếm cả chỗ trong Flash (bản gốc) LẪN RAM (bản sao được chép mỗi lần khởi động), dù không bao giờ bị ghi trong suốt vòng đời chương trình. Chỉ cần thêm đúng 1 từ khoá const, cùng bảng đó chuyển thẳng sang .rodata — ở lại Flash, KHÔNG tốn một byte RAM nào. Với MCU chỉ có vài chục KB RAM, đây là lỗi có thể khiến chương trình hết RAM một cách khó hiểu.

3. Linker script — bản thiết kế bộ nhớ ở mức đọc-hiểu

Linker script là file cấu hình cho linker biết bộ nhớ thật của chip trông ra sao, gồm 2 khối chính: khối MEMORY khai báo từng vùng nhớ (tên, ORIGIN — địa chỉ bắt đầu, LENGTH — dung lượng), và khối SECTIONS quy định mỗi section ở Mục 2 rơi vào vùng nào.

linker_script_minh_hoa.ld
MEMORY {
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 128K
  RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}

SECTIONS {
  .text   : { *(.text) *(.rodata) } > FLASH   /* code + hang so - o Flash */
  .data   : { *(.data) } > RAM AT > FLASH      /* RAM chay, nhung NAP tu Flash */
  .bss    : { *(.bss)  } > RAM                 /* chi can cho, khong can noi dung */
}

Cú pháp > RAM AT > FLASH cho .data chính là chìa khoá của bước chép ở Mục 1: LMA (Load Memory Address — nơi dữ liệu NẰM SẴN khi nạp firmware, tức Flash) và VMA (Virtual/run Memory Address — nơi chương trình THỰC SỰ đọc/ghi khi chạy, tức RAM) là hai địa chỉ KHÁC NHAU cho cùng một section. Đây chính xác là lý do bước copy .data tồn tại: không có nó, chương trình sẽ đọc nhầm vào Flash (đúng địa chỉ LMA) thay vì bản RAM sống động (VMA) mà nó cần ghi/đọc lúc chạy.

4. Stack & heap trong nhúng

Trong không gian RAM còn lại sau .data/.bss, hai vùng nữa cùng tồn tại nhưng mọc theo hai hướng ngược nhau: heap mọc LÊN (địa chỉ tăng dần) ngay sau .bss, còn stack mọc XUỐNG (địa chỉ giảm dần) từ đỉnh RAM. Ý tưởng: nếu cả hai không bao giờ gặp nhau, RAM được dùng hiệu quả nhất có thể mà không cần biết trước chính xác mỗi bên cần bao nhiêu.

Firmware nghiêm túc gần như luôn NÉ malloc: bộ cấp phát động gây phân mảnh theo thời gian, thời gian cấp phát không tất định (tệ cho hệ thời gian thực), và tệ nhất — hết heap thường xảy ra ở một thời điểm ngẫu nhiên sau hàng giờ/hàng ngày chạy ổn định, không phải ngay khi khởi động. Thay vào đó, firmware ưu tiên cấp phát TĨNH (biến toàn cục, mảng kích thước cố định) — biết chắc chắn ngay từ lúc biên dịch RAM có đủ hay không.

⚠️ Pitfall chí mạng: KHÔNG có MMU — stack tràn ăn mòn .bss âm thầm
Trên máy tính lớn, một chương trình đọc/ghi ra ngoài vùng nhớ được cấp sẽ bị hệ điều hành (qua MMU) chặn lại ngay lập tức bằng lỗi segfault. MCU tầm thấp KHÔNG có MMU — không có gì kiểm tra hay ngăn cản việc Stack Pointer tụt xuống dưới vùng đã cấp cho nó. Nếu điều đó xảy ra (thường do đệ quy quá sâu hoặc mảng cục bộ quá lớn), stack sẽ ghi đè trực tiếp lên bất cứ thứ gì nằm liền kề — thường là .bss — và chương trình KHÔNG BÁO LỖI GÌ CẢ, chỉ đơn giản "chạy tiếp mà dữ liệu sai". Đây là một trong những loại bug khó chẩn đoán nhất trong nghề: một biến toàn cục "tự nhiên" đổi giá trị ở một chỗ hoàn toàn không liên quan tới code vừa sửa.

Kỹ thuật phòng thủ tiêu chuẩn: canary & watermark. Trước khi chạy, "sơn" toàn bộ vùng stack bằng một byte cố định dễ nhận ra (ví dụ 0xCD). Sau một thời gian chạy, quét từ đáy vùng stack lên: đếm số byte canary CÒN NGUYÊN — phần còn lại chính là "mực nước" (watermark) sâu nhất stack từng chạm tới. Nếu watermark tiệm cận sát kích thước đã cấp, đó là tín hiệu cảnh báo sớm trước khi tràn thật sự xảy ra.

5. Thực hành: Memory Layout Visualizer

Demo dưới đây chạy trên VMCU thật: phân loại khai báo vào đúng section, xem bản đồ RAM dựng theo tỷ lệ, và tự tay kích hoạt một vụ tràn stack ăn vào biến toàn cục để thấy pitfall Mục 4 xảy ra thật:

🗺️ Memory Layout Visualizer — VMCU thật

1. Phân loại khai báo vào section

Bấm một khai báo phía trên để phân loại.

2. Bản đồ bộ nhớ RAM (theo tỷ lệ thật)

3. Thí nghiệm tràn stack — canary & watermark

Đang tải…

Stack cấp 256 byte. 10 tầng × 16 byte = 160 byte (an toàn). Kéo lên 20 tầng × 16 byte = 320 byte (vượt 256) để thấy biến "victim" ngay dưới đáy stack bị ghi đè âm thầm — không có lỗi nào được báo.

startup_reset_handler.c
// Ky hieu do LINKER tu sinh, danh dau bien duoc dinh nghia o dau (khong phai bien C thuong)
extern uint32_t _sidata; // dia chi LMA: noi anh .data nam trong Flash
extern uint32_t _sdata, _edata; // dia chi VMA: dau/cuoi .data trong RAM
extern uint32_t _sbss, _ebss;   // dau/cuoi .bss trong RAM

void Reset_Handler(void) {
    // Buoc 1: chep .data tu Flash (LMA) sang RAM (VMA)
    uint32_t *src = &_sidata;
    uint32_t *dst = &_sdata;
    while (dst < &_edata) *dst++ = *src++;

    // Buoc 2: xoa trang .bss ve 0
    dst = &_sbss;
    while (dst < &_ebss) *dst++ = 0;

    main(); // CHI SAU KHI ca 2 buoc tren xong moi goi main()
}

// Pitfall Muc 2: thieu const khien bang nay roi vao .data thay vi .rodata
int BANG_TRA_CUU[100] = { /* ... */ };          // <- SAI: .data, ton RAM
const int BANG_TRA_CUU_DUNG[100] = { /* ... */ }; // <- DUNG: .rodata, o Flash
memory_layout_demo.js (đúng logic đang chạy ở tab Xem trước)
import { classifySection, buildMemoryLayout, VMCU, MEMORY_MAP } from './vmcu.js';

console.log(classifySection({ kind: 'function' }));                              // .text
console.log(classifySection({ storage: 'global', isConst: false, initValue: 0 })); // .bss
console.log(classifySection({ storage: 'global', isConst: true, initValue: 99 })); // .rodata

const ram = MEMORY_MAP.find((r) => r.kind === 'ram');
const layout = buildMemoryLayout({
  ramBase: ram.base, ramSize: ram.size,
  dataVars: [{ name: 'g_volume', size: 4 }],
  bssVars: [{ name: 'g_buffer', size: 200 }],
  stackSizeBytes: 256,
});

// Thi nghiem tran stack that tren VMCU:
const cpu = new VMCU();
const stackTop = ram.base + ram.size;
cpu.initStackRegion(stackTop, 256);
const victimAddr = stackTop - 256 - 4; // "bien toan cuc" ngay duoi day stack
cpu.write32(victimAddr, 0xdeadbeef);
cpu.simulateDeepRecursion(20, 16); // 320 byte > 256 -> tran
console.log(cpu.read32(victimAddr).toString(16)); // KHONG con la deadbeef nua!

Tóm lược

  • ✅ Trước main(): Reset_Handler đọc SP + địa chỉ khởi động từ vector table, chép .data từ Flash sang RAM, xoá trắng .bss — chỉ sau đó mới gọi main().
  • ✅ 4 section: .text/.rodata ở Flash, .data/.bss ở RAM — verified: biến khởi tạo bằng 0 rơi vào .bss chứ không phải .data; quên const cho bảng lớn khiến nó ngốn cả Flash lẫn RAM thay vì chỉ Flash.
  • ✅ Linker script: khối MEMORY + SECTIONS; LMA (nơi nạp, Flash) vs VMA (nơi chạy, RAM) khác nhau cho .data — đúng lý do bước chép tồn tại.
  • ✅ Stack mọc xuống từ đỉnh RAM, heap mọc lên sau .bss; firmware né malloc (phân mảnh, thời gian không tất định). Pitfall chí mạng: KHÔNG MMU — tràn stack ăn .bss âm thầm, không báo lỗi. Verified: đệ quy 320 byte vượt cấp phát 256 byte ghi đè biến toàn cục thành 0 không một cảnh báo nào; canary/watermark là cách phát hiện sớm.

Trắc nghiệm ôn tập

Câu 1

Vì sao Reset_Handler phải chép .data từ Flash sang RAM TRƯỚC khi gọi main(), thay vì để biến toàn cục tự "có sẵn" giá trị khởi tạo?

Câu 2

Verified: int flag = 0; ở phạm vi toàn cục thường rơi vào .bss, KHÔNG phải .data, dù có viết tường minh = 0. Vì sao?

Câu 3

LMA (Load Memory Address) và VMA (Virtual/run Memory Address) của section .data là hai địa chỉ KHÁC NHAU. Ý nghĩa của sự khác biệt này là gì?

Câu 4

Verified: một đệ quy 20 tầng × 16 byte/tầng (320 byte) vượt quá 256 byte đã cấp cho stack đã ghi đè một biến toàn cục thành 0 mà KHÔNG có bất kỳ lỗi hay cảnh báo nào xuất hiện. Vì sao MCU không tự phát hiện và chặn lỗi này lại như một máy tính lớn vẫn làm?

Tải file code thực hành minh họa bài học

File JavaScript VMCU — engine MCU ảo dùng xuyên suốt cả 16 bài, Bài 12 vừa thêm phân loại section (classifySection), dựng bản đồ bộ nhớ (buildMemoryLayout), và mô phỏng canary/watermark chống tràn stack, kèm self-test đối chiếu đúng mọi hành vi trong bài (chạy node vmcu.js, không cần cài thêm gì):

Tải về vmcu.js

📖 Tài liệu tham khảo

Bài viết liên quan trong series

Bài 11: DMA — chuyển dữ liệu không cần CPU Bài 13: Super-loop vs Cooperative Scheduler Quay lại Lộ trình Series Hệ Thống Nhúng

Bình luận