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.


📚 Điều kiện tiên quyết
Bắt buộc: Bài 7 (ISR chạy thế nào, khi nào chen ngang), Bài 2 (read-modify-write không nguyên tử — bài này cho RMW một thủ phạm cụ thể: ISR).

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:

  1. LDR — nạp giá trị hiện tại của counter từ RAM vào một thanh ghi CPU.
  2. ADD — cộng 1 vào thanh ghi đó.
  3. 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:

race_concept.c
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.

⚠️ Cạm bẫy: "thêm volatile là hết race" — hiểu nhầm số một
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.

critical_section.c
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.

🕐 Ví dụ cụ thể: đồng hồ xé đôi lúc nửa đêm
Trạng thái thật chỉ có hai khả năng: 23:59 (trước khi ISR chạy) hoặc 00:00 (sau khi ISR chạy xong). Verified ở demo: đọc không bảo vệ giữa hai lần đọc 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.

ring_buffer_spsc.c
#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:

🏁 Race condition & Ring buffer — VMCU thật

1. Race condition (counter++)

Bấm "Chạy 10.000 vòng" để bắt đầu.

2. Ring buffer SPSC (capacity 8)

Đang tải…

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.

race_and_ringbuffer.c
// 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.
race_demo.js (đúng logic đang chạy ở tab Xem trước)
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++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).
  • volatile KHÔ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ỉ đụng tail — 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ì):

Tải về vmcu.js

📖 Tài liệu tham khảo

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

Bài 7: Ngắt (Interrupt) & NVIC Bài 9: UART & giao tiếp nối tiếp Quay lại Lộ trình Series Hệ Thống Nhúng

Bình luận