Mở đầu: con bug đắt nhất nghề firmware
Có một loại bug mà dân nhúng kỳ cựu sợ hơn mọi HardFault: nó không hiện ra ngay khi bạn nạp firmware, không lặp lại khi bạn bấm reset, và có thể chạy ngon lành suốt ba ngày liên tục trước khi âm thầm sai đúng một lần. Không có breakpoint nào bắt được nó vì bấm dừng CPU để xem là chính hành động đó đã phá vỡ điều kiện gây ra bug. Đó là race condition — hai "người" cùng chạm vào một biến, và thứ tự chạm đúng khoảnh khắc quyết định kết quả đúng hay sai.
Bài 7 vừa cho ngắt (ISR) "chen ngang" main loom bất cứ lúc nào — sức mạnh đó có mặt trái: nếu main và ISR
cùng đụng vào một biến chia sẻ, việc chen ngang có thể xảy ra giữa chừng một phép toán tưởng
chừng đơn giản như counter++. Bài này mổ xẻ chính xác race condition xảy ra ở đâu, tại sao
volatile KHÔNG cứu được bạn, và giới thiệu 2 công cụ giải quyết triệt để: critical section
cho biến đơn, và ring buffer SPSC — cấu trúc dữ liệu quan trọng nhất của cả series — cho luồng dữ liệu.
1. Race condition đầu tiên của bạn
Nhìn một dòng C tưởng chừng vô hại: counter++;. Với CPU, đây KHÔNG phải một thao tác — nó là
ba lệnh máy riêng biệt:
-
LDR — nạp giá trị hiện tại của
countertừ RAM vào một thanh ghi CPU. - ADD — cộng 1 vào thanh ghi đó.
- STR — ghi giá trị thanh ghi trở lại RAM.
Giờ tưởng tượng một ISR CŨNG tăng counter, và nó chen vào đúng giữa LDR và STR của main: main
đã đọc giá trị cũ (giả sử 5) vào thanh ghi; ISR chạy trọn vẹn, tăng RAM lên 6; main tiếp tục ADD (5+1=6
trong thanh ghi, không hề biết RAM đã đổi) rồi STR — ghi đè RAM thành 6. Công tăng của ISR
biến mất hoàn toàn, dù cả hai bên đều "đã chạy". Đây gọi là lost update.
Điều khiến bug này đắt giá: nó không xảy ra MỌI lần. Muốn ISR chen đúng vào cửa sổ 1 lệnh máy giữa LDR và STR cần đúng thời điểm — hiếm, nhưng không phải zero. VMCU tái hiện kịch bản XẤU NHẤT (ISR luôn chen đúng lúc, mọi vòng) để bạn thấy hậu quả tối đa một cách tất định, thay vì chờ may rủi:
volatile uint32_t counter = 0;
// ISR co the chen vao GIUA 3 lenh may cua dong duoi day
void TIM1_UP_IRQHandler(void) {
counter++; // ISR cung tang - cung la LDR/ADD/STR
}
void main_loop(void) {
counter++; // 3 lenh may: LDR (doc cu) - ADD (+1) - STR (ghi lai)
// Neu ISR chen dung giua LDR va STR: cong tang cua ISR bi STR nay GHI DE mat
}
2. volatile KHÔNG phải khoá
Hiểu nhầm phổ biến nhất khi phỏng vấn nhúng: "thêm volatile là hết race". Sai.
volatile (Bài 2) chỉ ép compiler luôn đọc/ghi bộ nhớ thật thay vì giữ giá
trị cũ trong thanh ghi CPU qua nhiều dòng code — nó ngăn compiler tối ưu sai, KHÔNG ngăn CPU thực thi các
lệnh máy đó bị ISR chen vào giữa chừng. Chuỗi LDR-ADD-STR vẫn là ba lệnh riêng biệt dù biến có
volatile hay không.
volatile uint32_t counter; vẫn bị mất update y hệt uint32_t counter; thường —
verify trực tiếp ở demo Mục 6: bật/tắt bảo vệ không liên quan gì tới từ khoá volatile, chỉ
liên quan tới việc ISR có được phép chen vào giữa LDR và STR hay không. volatile giải quyết
đúng một vấn đề khác (compiler cache giá trị cũ trong thanh ghi qua nhiều dòng) — không giải quyết vấn
đề "hai bên cùng đọc-sửa-ghi không nguyên tử" của bài này.
3. Critical section — tắt ngắt có kỷ luật
Giải pháp thật của phần cứng ARM Cortex-M: tạm thời tắt hết ngắt đúng trong lúc thực hiện
chuỗi thao tác nhạy cảm, bằng thanh ghi
PRIMASK
và 2 chỉ thị CPU __disable_irq()/__enable_irq(). Trong khoảng đó, NVIC (Bài 7)
không phục vụ bất kỳ ISR nào — main hoàn tất trọn vẹn LDR-ADD-STR rồi mới "mở cửa" lại cho ngắt.
volatile uint32_t counter = 0;
void main_loop_safe(void) {
__disable_irq(); // PRIMASK = 1 - tam thoi KHONG ISR nao chen duoc
counter++; // LDR-ADD-STR chay TRON VEN, khong ai xen vao
__enable_irq(); // PRIMASK = 0 - mo cua lai cho ngat
}
Quy tắc vàng: critical section phải NGẮN nhất có thể, và cái giá phải trả đo được chính
xác — mọi ngắt (kể cả ưu tiên cao nhất) bị trễ đúng bằng độ dài khoảng tắt ngắt đó. Tắt ngắt 2 vi giây để
bảo vệ 1 dòng counter++ gần như miễn phí; tắt ngắt 50ms để "cho chắc" bảo vệ cả một hàm dài
là thảm hoạ — verified ở demo Mục 6.
Khi nào cần: mọi read-modify-write hoặc truy cập một struct nhiều trường mà cả main lẫn ISR cùng đụng vào. Khi nào KHÔNG cần: đọc một biến đơn giản một chiều (chỉ ISR ghi, main chỉ đọc) trên kiến trúc 32-bit — một lệnh LDR/STR 32-bit là nguyên tử tự nhiên trên Cortex-M, không cần khoá gì thêm.
4. Đọc "xé đôi" (torn read)
Race condition không chỉ mất cập nhật — nó còn có thể tạo ra dữ liệu chưa từng tồn tại.
Tưởng tượng một cặp trường liên quan, ví dụ đồng hồ giờ/phút, mà ISR cập nhật CẢ HAI cùng lúc (giờ:phút
chuyển từ 23:59 sang 00:00). Nếu main đọc hour trước, ISR chen vào đổi cả hai trường, rồi
main mới đọc minute — kết quả main thấy là giờ MỚI ghép với phút CŨ (hoặc
ngược lại), một tổ hợp không hề tồn tại ở bất kỳ thời điểm thật nào.
hour/minute riêng lẻ cho ra
23:00 — một thời điểm KHÔNG BAO GIỜ xảy ra thật trên đồng hồ này. Giải pháp giống hệt
Mục 3: bọc cả hai lần đọc trong 1 critical section để ISR không thể chen vào giữa.
5. Ring buffer SPSC — chia sẻ mà không cần khoá
Critical section tốt cho một biến đơn, nhưng khi cần chuyển cả một luồng dữ liệu giữa ISR
và main (ví dụ byte nhận UART ở Bài 9), tắt ngắt liên tục sẽ quá tốn. Lời giải kinh điển:
ring buffer SPSC (single-producer single-consumer) — một mảng vòng tròn với 2 con trỏ
head (vị trí ghi tiếp theo) và tail (vị trí đọc tiếp theo).
Điểm mấu chốt làm cấu trúc này AN TOÀN mà không cần khoá gì cả: ISR (producer) CHỈ bao
giờ đọc/ghi head; main (consumer) CHỈ bao giờ đọc/ghi tail. Mỗi bên chỉ sở hữu
đúng một biến của riêng mình — không ai ghi vào biến của bên kia, nên không có read-modify-write nào bị
chia sẻ để mà race. Điều kiện nền: việc ghi một chỉ số (biến kiểu uint8_t hoặc
uint16_t căn chỉnh đúng) là nguyên tử tự nhiên trên Cortex-M — không cần
volatile làm gì thêm ngoài việc ngăn compiler cache sai.
#define RB_CAP 8
volatile uint8_t rb_buf[RB_CAP];
volatile uint8_t rb_head = 0; // CHI ISR (producer) dung vao
volatile uint8_t rb_tail = 0; // CHI main (consumer) dung vao
// Goi tu ISR - khong bao gio khoa gi ca
void rb_push(uint8_t b) {
uint8_t next = (rb_head + 1) % RB_CAP;
if (next == rb_tail) return; // day - mat du lieu, khong duoc "cho"
rb_buf[rb_head] = b;
rb_head = next;
}
// Goi tu main - khong bao gio khoa gi ca
int rb_pop(uint8_t *out) {
if (rb_head == rb_tail) return 0; // rong
*out = rb_buf[rb_tail];
rb_tail = (rb_tail + 1) % RB_CAP;
return 1;
}
Đây là cấu trúc dữ liệu quan trọng nhất toàn series — Bài 9 (UART) dùng nguyên xi cho cả TX lẫn RX.
6. Thực hành: demo race + ring buffer trên VMCU thật
Demo dưới đây chạy đúng logic vừa học trên VMCU thật — không giả lập bằng số cố định. Bật/tắt "Bảo vệ (critical section)" rồi chạy 10.000 vòng để tận mắt thấy số đếm sai lệch, và thử push/pop ring buffer để xem 2 con trỏ head/tail đuổi nhau:
1. Race condition (counter++)
2. Ring buffer SPSC (capacity 8)
Gợi ý: bật "🔒 Có bảo vệ" rồi chạy lại 10.000 vòng — số đếm phải đủ 20.000 (không mất update nào). Với ring buffer: push liên tục tới khi đầy (head đuổi kịp gần tail) rồi thử push tiếp — bị từ chối có kiểm soát, không mất dữ liệu ngoài ý muốn.
// Xem code day du o Muc 1 (race_concept.c), Muc 3 (critical_section.c),
// va Muc 5 (ring_buffer_spsc.c) - demo nay chay dung 3 doan do, khong gia lap.
import { raceDemo, RingBufferSPSC } from './vmcu.js';
// Khong bao ve: moi vong chi net +1 (mat dung 1 update/vong)
const noProtect = raceDemo(10000, false);
console.log(noProtect); // { finalValue: 10000, expected: 20000, lost: 10000 }
// Co critical section: moi vong net +2 (khong mat gi)
const protectedRun = raceDemo(10000, true);
console.log(protectedRun); // { finalValue: 20000, expected: 20000, lost: 0 }
// Ring buffer SPSC
const rb = new RingBufferSPSC(8);
rb.push(0x10); // goi tu "ISR"
const byte = rb.pop(); // goi tu "main" - 0x10, dung thu tu FIFO
Tóm lược
-
✅
counter++là BA lệnh máy (LDR/ADD/STR) — ISR chen đúng giữa chừng làm mất cập nhật (verified: 10.000 vòng không bảo vệ mất sạch 10.000 update). -
✅
volatileKHÔNG phải khoá — chỉ ngăn compiler cache sai, không ngăn ISR chen vào giữa một chuỗi đọc-sửa-ghi. -
✅ Critical section (
__disable_irq()/__enable_irq()) bảo vệ biến đơn — verified bật lên thì 10.000 vòng đủ đúng 20.000, không mất gì; cái giá là mọi ngắt bị trễ đúng bằng độ dài khoảng tắt ngắt. - ✅ Torn read: cặp trường liên quan (giờ/phút) bị ISR đổi giữa 2 lần đọc → tổ hợp CHƯA BAO GIỜ tồn tại thật (verified: 23:00 — không phải giờ thật nào cả).
-
✅ Ring buffer SPSC — ISR chỉ đụng
head, main chỉ đụngtail— chia sẻ luồng dữ liệu mà KHÔNG cần khoá gì cả. Bài 9 dùng nguyên xi.
Trắc nghiệm ôn tập
Câu 1
Vì sao counter++; trong C không an toàn khi có ISR cùng đụng vào biến đó?
Câu 2
Thêm từ khoá volatile vào biến chia sẻ giữa ISR và main có giải quyết được race condition
(lost update) không?
Câu 3
Ring buffer SPSC (ISR chỉ đụng head, main chỉ đụng tail) an toàn mà KHÔNG cần
tắt ngắt hay khoá gì cả. Vì sao?
Câu 4
Demo torn read: main đọc cặp giờ/phút khi ISR đang đổi từ 23:59 sang 00:00 giữa hai lần đọc, và thấy kết quả 23:00. Vì sao đây là một "tổ hợp không có thật"?
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 8 vừa thêm
RacyCounter/raceDemo (race condition tất định),
TornReadPair (torn read) và RingBufferSPSC, 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