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.
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:
- Đọc ô đầu vector table, nạp giá trị đó vào thanh ghi SP — stack đã có "đỉnh" để bắt đầu.
- Đọc ô thứ hai, nhảy tới đó — đây chính là
Reset_Handler, đoạn code khởi động. -
Reset_Handlerlàm 2 việc quan trọng: chép.datatừ 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ọimain().
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".
const cho bảng tra cứu lớnint 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.
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.
.bss âm thầm
.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:
1. Phân loại khai báo vào section
2. Bản đồ bộ nhớ RAM (theo tỷ lệ thật)
3. Thí nghiệm tràn stack — canary & watermark
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.
// 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
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.datatừ Flash sang RAM, xoá trắng.bss— chỉ sau đó mới gọimain(). -
✅ 4 section:
.text/.rodataở Flash,.data/.bssở RAM — verified: biến khởi tạo bằng 0 rơi vào.bsschứ không phải.data; quênconstcho 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ì):
Bình luận