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.
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.
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.
#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:
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.
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.
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 |
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ả:
Ở 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ỡ".
// 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
}
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 chomillis()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) >= periodmỗ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 + periodthì 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ì):
Bình luận