Mở đầu: 2 LED, 1 CPU, và một cái bẫy ai cũng từng sập

Yêu cầu tưởng chừng đơn giản nhất trong nhúng: nhấp nháy LED thứ nhất mỗi 300ms, LED thứ hai mỗi 700ms, cùng lúc. Gần như ai học nhúng lần đầu cũng viết y hệt cách họ học trên Arduino: đèn1_bật(); delay(150); đèn1_tắt(); delay(150); lặp lại. Chạy thử: LED thứ nhất nhấp nháy đẹp — nhưng LED thứ hai đứng yên, không bao giờ sáng. Tệ hơn: nút nhấn nối vào board hoàn toàn "im lặng" trong lúc chương trình đang ở giữa một lệnh delay() — dù người dùng bấm đúng lúc đó, CPU không hề biết, vì nó đang bận đếm ngược một vòng lặp rỗng không làm gì khác.

Bài này mổ xẻ đúng nguyên nhân: delay() kiểu busy-wait chiếm dụng 100% CPU trong suốt thời gian chờ — không có khái niệm "làm việc khác trong lúc chờ". Giải pháp là SysTick, một bộ đếm giờ có sẵn trong mọi lõi ARM, kết hợp với một cách viết vòng lặp hoàn toàn khác: không chặn (non-blocking). Cuối bài, bạn sẽ tự tay đo được số lần nhấn nút bị bỏ lỡ khi dùng cách viết chặn — và chứng kiến một cạm bẫy tinh vi hơn nữa: điều gì xảy ra khi bộ đếm thời gian 32-bit tràn số sau 49,7 ngày chạy liên tục.


📚 Điều kiện tiên quyết
Bắt buộc: Bài 2 (thanh ghi, RMW) và Bài 3 (đọc nút nhấn, polling).

1. Busy-wait delay(): tại sao nó xấu

Bên trong, delay(300) kiểu cổ điển làm đúng một việc: chạy một vòng lặp đếm rỗng đủ lâu để tiêu tốn khoảng 300ms thời gian CPU, rồi trả quyền điều khiển lại. Trong suốt 300ms đó, CPU không thể làm bất kỳ việc gì khác — không đọc được nút nhấn, không cập nhật được LED khác, không phản hồi được bất cứ sự kiện nào xảy ra bên ngoài. Đây gọi là busy-wait (chờ bận): CPU vẫn đang "làm việc" (chạy vòng lặp đếm), nhưng công việc đó hoàn toàn vô ích ngoài việc tiêu tốn thời gian.

busy_wait_pitfall.c (2 LED — chỉ 1 cái nhấp nháy được)
while (1) {
    led1_toggle();
    delay(300);       // CPU dong bang 300ms - khong lam gi khac duoc
    // led2 KHONG BAO GIO duoc cham toi voi chu ky rieng cua no
    // nut nhan bam trong 300ms nay - hoan toan bi bo lo, khong ai biet
}

2. SysTick & millis(): bộ đếm không chặn CPU

Mọi lõi ARM Cortex-M có sẵn một ngoại vi ĐẶC BIỆT gọi là SysTick. Nó không nằm trong vùng địa chỉ Peripheral (đặc thù từng hãng chip) như GPIOA — nó nằm trong vùng System ở địa chỉ 0xE000E000, giống hệt trên MỌI chip Cortex-M dù là STMicro, Nordic, hay NXP sản xuất. Bật SysTick lên (thanh ghi SYST_CSR, bit 0), nó tự động đếm đều đặn theo nhịp clock, và firmware dùng nó để tăng một biến đếm mốc thời gian — quen gọi là millis() — mỗi mili-giây trôi qua, chạy NGẦM, không chiếm CPU của vòng lặp chính.

systick_enable.c
#define SYST_CSR (*(volatile uint32_t *)0xE000E010)

SYST_CSR |= (1 << 0); // bat SysTick - tu do millis() bat dau chay ngam

Với millis() chạy sẵn, mọi việc "chờ" chuyển thành một phép so sánh đơn giản thay vì một vòng lặp chiếm CPU — đây chính là nền tảng của pattern non-blocking Mục 3.

3. Pattern non-blocking: "kiểm tra elapsed" thay vì "chờ"

Thay vì hỏi "đã đủ 300ms CHƯA, cứ đứng đây chờ tới khi đủ", pattern non-blocking hỏi một câu khác hẳn: "NẾU đã đủ 300ms kể từ lần cuối, làm việc — rồi LẬP TỨC đi tiếp, không chờ gì cả". Vòng lặp chính chạy liên tục, không bao giờ dừng lại — mỗi lượt chỉ mất vài micro-giây để kiểm tra vài phép so sánh:

two_leds_non_blocking.c (ĐÚNG — cả 2 LED độc lập)
uint32_t last1 = 0, last2 = 0;
const uint32_t PERIOD1 = 300, PERIOD2 = 700;

while (1) {
    uint32_t now = millis();

    if ((uint32_t)(now - last1) >= PERIOD1) { // KHONG blocking - chi kiem tra
        led1_toggle();
        last1 = now;
    }
    if ((uint32_t)(now - last2) >= PERIOD2) { // chay DOC LAP voi LED1
        led2_toggle();
        last2 = now;
    }
    check_button(); // luon duoc goi MOI luot - khong bao gio bi bo lo
}

Chú ý phép trừ now - last1 được ép kiểu (uint32_t) tường minh — chi tiết tưởng như thừa thãi này chính là chìa khoá của cạm bẫy Mục 4.

⚠️ Cạm bẫy: tràn số 32-bit — "bug 49,7 ngày" có thật
millis() thường là số nguyên không dấu 32-bit — tối đa 0xFFFFFFFF (4.294.967.295). Với tốc độ tăng 1 mỗi mili-giây, nó TRÀN SỐ (quay vòng về 0) sau đúng 2^32 ms ≈ 49,7 ngày chạy liên tục không tắt nguồn — đây là bug có thật, từng gây sự cố trên nhiều thiết bị Arduino/nhúng chạy liên tục hàng tháng trời (máy bán hàng, hệ thống điều khiển công nghiệp). Điều bất ngờ: pattern (uint32_t)(now - last) >= PERIOD ở Mục 3 vẫn ĐÚNG ngay cả khi tràn số — nhờ số học modular của phép trừ không dấu. Nhưng nếu ai đó "tối ưu" bằng cách viết trực tiếp if (now >= last + PERIOD) (nhìn hợp lý hơn, dễ đọc hơn) — công thức đó SAI ngay tại thời điểm tràn số, kết luận nhầm là "chưa đủ thời gian" trong khi thực tế đã trôi qua rất lâu. Verify bằng số đo thật ở Mục 4.

4. VMCU: SysTick vừa "sống"

VMCU giờ có vùng System (địa chỉ 0xE000E000, đúng vị trí thật trên mọi Cortex-M) với thanh ghi SYST_CSR. Vì Bài 7 (ngắt/NVIC) chưa xuất hiện, VMCU "làm hộ" việc firmware thật sẽ làm trong ISR SysTick: hàm tick(ms) mô phỏng thời gian trôi qua, chỉ tăng millis() khi SysTick đang BẬT — tắt SysTick thì không ai tăng biến đó cả, đúng hành vi thật.

vmcu_systick.js (trích engine vmcu.js)
tick(ms = 1) {
  if (this.systickEnabled()) {
    this.millis = (this.millis + ms) >>> 0;   // >>> 0 = cuon vong dung nhu uint32_t that
  }
}

Số đo thật từ self-test (node vmcu.js): đặt millis() gần điểm tràn (0xFFFFFFF0, chỉ còn cách điểm cuộn vòng 16 đơn vị), rồi cho 25ms trôi qua — vượt qua điểm tràn số:

Đại lượng Giá trị
millis() TRƯỚC 0xFFFFFFF0 (4.294.967.280)
millis() SAU 25ms trôi qua 9 (đã cuộn vòng qua 0)
Công thức ĐÚNG: (sau - trước) >>> 0 25 — chính xác tuyệt đối, dù đã tràn số
Công thức SAI: sau >= trước + 20 false — kết luận SAI "chưa đủ 20ms", dù thực tế đã 25ms
🔬 Đào sâu: vì sao ARM chuẩn hoá địa chỉ SysTick trên MỌI hãng chip?
GPIOA nằm ở một địa chỉ do hãng sản xuất chip (STMicro, Nordic, NXP...) tự quyết định — mỗi hãng thiết kế ngoại vi khác nhau nên địa chỉ khác nhau, thậm chí khác cả giữa các dòng chip cùng hãng. Nhưng SysTick nằm trong vùng System — phần đặc tả bắt buộc của chính kiến trúc ARM Cortex-M, không phải của hãng nào. Lý do: ARM muốn phần mềm hệ thống cấp thấp (đặc biệt là RTOS — Bài 13-15 sẽ dùng chính SysTick để tạo nhịp "chuyển ngữ cảnh" giữa các task) chạy được y hệt trên MỌI chip Cortex-M mà không cần sửa một dòng nào cho địa chỉ SysTick — dù chip đó do hãng nào sản xuất. Đây là lý do các hệ điều hành nhúng như FreeRTOS, Zephyr chỉ cần viết đúng MỘT đoạn code khởi tạo SysTick, chạy được trên hàng trăm dòng chip Cortex-M khác nhau.

5. Thực hành: đo số lần nhấn nút bị bỏ lỡ

Demo dưới đây chạy trên VMCU thật — SysTick thật sự được bật, tick() thật sự được gọi mỗi nhịp. Chuyển giữa 2 chế độ và bấm nút nhiều lần để tự đo kết quả:

⏱️ Blocking vs Non-blocking — VMCU thật
LED1 (300ms)
LED2 (700ms)
Đang khởi tạo…

Ở chế độ non-blocking, cả 2 LED nhấp nháy độc lập và MỌI lần nhấn nút đều được ghi nhận. Chuyển sang blocking — LED2 đứng yên (bị "đói" vì vòng lặp kẹt trong delay() của LED1), và mọi lần nhấn nút trong lúc đó bị đếm vào cột "bỏ lỡ".

blink_two_leds.c
// CHE DO NON-BLOCKING (dung o tab Xem truoc khi bam nut xanh)
uint32_t last1 = 0, last2 = 0;
while (1) {
    uint32_t now = millis();
    if ((uint32_t)(now - last1) >= 300) { led1_toggle(); last1 = now; }
    if ((uint32_t)(now - last2) >= 700) { led2_toggle(); last2 = now; }
    if (button_pressed()) handle_press(); // KHONG BAO GIO bi bo lo
}

// CHE DO BLOCKING (dung o tab Xem truoc khi bam nut do)
while (1) {
    led1_toggle();
    delay(300);            // CPU dong bang - led2 dung yen, nut nhan bi bo lo
}
systick_demo.js (đúng logic đang chạy ở tab Xem trước)
import { VMCU, SYST_CSR_ADDR } from './vmcu.js';

const cpu = new VMCU();
cpu.write32(SYST_CSR_ADDR, 1); // bat SysTick

let last1 = 0, last2 = 0, mode = 'nonblocking';
setInterval(() => {
  cpu.tick(50); // 50ms "troi qua" moi lan goi
  const now = cpu.getMillis();
  if ((now - last1) >>> 0 >= 300) { toggleLed1(); last1 = now; }
  if (mode === 'nonblocking' && (now - last2) >>> 0 >= 700) { toggleLed2(); last2 = now; }
  // che do blocking: led2 khong bao gio duoc kiem tra - "doi" vo han
}, 50);

Tóm lược

  • Busy-wait delay() chiếm dụng 100% CPU trong lúc chờ — không làm được việc khác, không phát hiện được sự kiện xảy ra trong lúc đó.
  • SysTick là bộ đếm giờ tích hợp trong lõi ARM (vùng System, 0xE000E000, giống nhau trên mọi hãng chip) — cấp nguồn cho millis() chạy ngầm không chiếm CPU vòng lặp chính.
  • ✅ Pattern non-blocking: kiểm tra (uint32_t)(now - last) >= period mỗi lượt lặp thay vì chờ — cho phép nhiều "công việc" chạy độc lập trong cùng 1 vòng lặp.
  • Tràn số 32-bit xảy ra thật sau ~49,7 ngày — công thức trừ KHÔNG DẤU vẫn đúng xuyên qua điểm tràn (verified: 25ms), nhưng so sánh trực tiếp now >= last + period thì SAI (verified: kết luận nhầm false).
  • ✅ Demo verified: blocking mode khiến LED2 "đói" và mọi lần nhấn nút trong lúc đó bị bỏ lỡ hoàn toàn.

Trắc nghiệm ôn tập

Câu 1

Vì sao delay() kiểu busy-wait bị coi là "xấu" trong lập trình nhúng?

Câu 2

SysTick khác gì so với GPIOA (Bài 2-3) về mặt vị trí trong memory map?

Câu 3

millis() đang ở giá trị 0xFFFFFFF0, sau 25ms trôi qua nó cuộn vòng về 9. Công thức nào tính ĐÚNG thời gian đã trôi qua (25ms)?

Câu 4

Trong demo Mục 5, vì sao LED2 (chu kỳ 700ms) không nhấp nháy khi ở chế độ Blocking?

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 4 vừa thêm SysTick (bật/tắt, đếm tick, tràn số 32-bit), 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 3: GPIO input: pull-up/down & đọc nút nhấn Bài 5: Chống dội phím & Máy trạng thái (FSM) Quay lại Lộ trình Series Hệ Thống Nhúng

Bình luận