Mở đầu: dòng code không hề sai, nhưng LED không bao giờ sáng
Một sinh viên thực tập viết đúng 3 dòng code: cấu hình một chân làm output, rồi ghi 1 vào đúng bit của chân đó để bật LED. Biên dịch sạch, không cảnh báo. Nạp vào board — LED không sáng. Không có lỗi cú pháp, không có lỗi logic theo nghĩa thông thường: trình biên dịch, để "tối ưu cho nhanh", nhận thấy dòng code ghi vào một con trỏ rồi không bao giờ đọc lại nó trong hàm này nên đã âm thầm xoá luôn phép ghi đó khỏi bản build cuối cùng. Đây không phải chuyện hiếm — đây là cạm bẫy đầu tiên và phổ biến nhất mà gần như mọi lập trình viên nhúng mới vào nghề đều dính ít nhất một lần.
Bài 1 đã dạy ý tưởng nền: mọi ngoại vi đều là một địa chỉ bộ nhớ. Bài này biến ý tưởng đó thành công cụ
dùng được thật — cách đọc và ghi thanh ghi AN TOÀN bằng con trỏ volatile, cách sửa đúng 1 bit
mà không đụng đến 15 bit anh em, và một cạm bẫy tinh vi hơn nữa (đọc-sửa-ghi không nguyên tử) mà bạn sẽ
còn gặp lại xuyên suốt cả series. Cuối bài, VMCU có thêm ngoại vi ĐẦU TIÊN — GPIOA — và bạn sẽ tự tay bật
một LED thật bằng đúng cơ chế vừa học.
1. volatile: nói với trình biên dịch "đừng tự ý bịa"
Trình biên dịch C được thiết kế để tối ưu tối đa: nếu nó thấy một biến được ghi rồi không hề được đọc lại trước khi hàm kết thúc, nó có quyền coi phép ghi đó là "vô nghĩa" và loại bỏ hoàn toàn — hợp lý với biến thường trong RAM, nhưng thảm hoạ với một thanh ghi ngoại vi, vì phép ghi vào đó có tác dụng phụ (bật LED, khởi động timer...) nằm hoàn toàn ngoài tầm nhìn của trình biên dịch.
// KHONG co volatile - loi ngam, khong bao gio bi bao warning/error
uint32_t *odr = (uint32_t *)0x40010800;
*odr = 0x01; // trinh bien dich thay bien nay khong doc lai...
// ...co quyen XOA luon dong tren khoi ban build toi uu!
// CO volatile - dung
volatile uint32_t *odr_v = (volatile uint32_t *)0x40010800;
*odr_v = 0x01; // "volatile" = "gia tri co the doi bat cu luc nao vi
// ly do NGOAI code nay (phan cung) - luon doc/ghi
// THAT vao dia chi, khong duoc cache lai, khong duoc
// sap xep lai thu tu, khong duoc xoa"
Ba điều volatile ép trình biên dịch phải tuân thủ, mỗi điều đều từng gây ra một lớp bug nhúng
kinh điển nếu thiếu: (1) không được cache giá trị trong một thanh ghi CPU rồi dùng lại
giá trị cũ (nếu không, vòng lặp while (FLAG_REG == 0); chờ một cờ phần cứng đổi có thể chạy
MÃI MÃI vì trình biên dịch chỉ đọc bộ nhớ đúng 1 lần rồi coi như hằng số);
(2) không được sắp xếp lại thứ tự các phép đọc/ghi so với code xung quanh;
(3) không được loại bỏ phép đọc/ghi "trông có vẻ thừa". Registers ngoại vi luôn cần con
trỏ volatile — biến thường trong SRAM thì không.
2. Đọc-sửa-ghi (RMW): sửa đúng 1 bit mà không đụng 15 bit còn lại
Một thanh ghi như
ODR của
GPIOA giữ trạng thái của 16 chân cùng lúc — mỗi chân 1 bit. Muốn bật LED ở chân 0 nhưng
lại ghi thẳng ODR = 1 sẽ vô tình đặt luôn 15 bit còn lại về 0 — tắt hết mọi chân khác đang
bật! Đây là lỗi runtime cực kỳ phổ biến ở người mới, và không hề bị bất kỳ trình biên dịch nào cảnh báo vì
cú pháp hoàn toàn hợp lệ.
// Gia su chan 3 dang BAT (LED sang), muon bat them chan 0
GPIOA_ODR = (1 << 0); // SAI: ghi DE toan bo thanh ghi
// -> chan 0 bat DUNG, nhung chan 3 (va moi chan
// khac) bi dat ve 0 - LED chan 3 tat theo, du
// khong ai "bao" no lam vay ca
Cách đúng là đọc-sửa-ghi (Read-Modify-Write, viết tắt RMW): đọc giá trị hiện tại của cả thanh ghi, chỉ sửa đúng bit cần, rồi ghi lại toàn bộ. Ba thao tác kinh điển dùng đại số bit — mỗi thao tác chỉ đúng 1 dòng, và mọi lập trình viên nhúng đều gõ chúng hàng nghìn lần trong sự nghiệp:
| Thao tác | Công thức | Vì sao đúng |
|---|---|---|
| Set bit (bật) | REG |= (1 << n) |
OR với 1 luôn ra 1 (bật đúng bit n), OR với 0 giữ nguyên (mọi bit khác không đổi) |
| Clear bit (tắt) | REG &= ~(1 << n) |
AND với 0 luôn ra 0 (tắt đúng bit n), AND với 1 giữ nguyên (mọi bit khác không đổi) |
| Toggle bit (đảo) | REG ^= (1 << n) |
XOR với 1 luôn đảo bit n, XOR với 0 giữ nguyên mọi bit khác |
volatile uint32_t *odr = (volatile uint32_t *)0x4001080C;
*odr |= (1 << 0); // set: bat chan 0, GIU NGUYEN 15 chan con lai
*odr &= ~(1 << 0); // clear: tat chan 0, GIU NGUYEN 15 chan con lai
*odr ^= (1 << 0); // toggle: dao chan 0, GIU NGUYEN 15 chan con lai
// Nhac lai tu Series C (phep Bitwise): AND/OR/XOR/NOT lam viec TUNG BIT
// doc lap - day chinh la ly do chung dung tuyet doi cho bai toan nay.
3. Cấu hình MODER: một chân phải "được cho phép" mới xuất ra ngoài
ODR quyết định mức logic muốn xuất, nhưng bản thân chân đó phải được cấu hình đúng chế độ trước — nếu không, việc ghi ODR vẫn "thành công" về mặt thanh ghi (bit lưu lại được), nhưng không có LED hay chân vật lý nào phản hồi theo nó cả. Thanh ghi MODER dùng 2 bit cho mỗi chân để chọn chế độ:
| Giá trị 2-bit | Chế độ | Ghi chú |
|---|---|---|
00 |
Input | Giá trị RESET mặc định — an toàn vì không vô tình đẩy dòng ra một chân đang nối với thứ khác |
01 |
Output push-pull | Dùng trong bài này — chân chủ động kéo lên/xuống mức logic |
10 / 11 |
Alternate function / Analog | Dành cho các bài sau (timer/PWM Bài 6, ADC Bài 10) |
00 (Input) luôn là trạng thái MẶC ĐỊNH khi MCU vừa cấp nguồn,
không phải ngẫu nhiên. Một chân Output chủ động kéo dòng ra — nếu chân đó vô tình đang được nối với một
chân Output KHÁC trên mạch (hoặc với nguồn/đất qua một linh kiện), hai bên "kéo co" nhau có thể gây ra
dòng ngắn mạch thực sự, làm nóng chip hoặc hỏng linh kiện ngay khi vừa cấp điện — trước cả khi firmware
kịp chạy dòng code đầu tiên. Input thì an toàn tuyệt đối: nó chỉ "lắng nghe", không bao giờ chủ động đẩy
dòng ra. Đây là lý do mọi MCU thật đều thiết kế trạng thái reset "an toàn trước, hiệu quả sau" — một
nguyên tắc sẽ lặp lại nhiều lần trong series này (ví dụ ngắt cũng mặc định bị vô hiệu hoá cho tới khi
được bật tay ở Bài 7).
ODR |= (1<<3) — vừa ĐỌC xong giá trị cũ của ODR — thì một
ngắt (interrupt, sẽ học ở Bài 7) xen ngang, tự nó cũng làm RMW để tắt
chân 5 (ODR &= ~(1<<5)), rồi trả quyền lại cho main loop. Main loop tiếp tục dùng
đúng giá trị ODR nó đã đọc TRƯỚC khi ngắt xảy ra (đã lỗi thời!) để ghi lại — vô tình
xoá mất thay đổi mà ngắt vừa thực hiện, dù không dòng code nào "sai" cả. Đây là một
dạng race condition kinh điển của lập trình nhúng, và Bài 8 sẽ dạy cách giải quyết bằng
critical section. Bây giờ, chỉ cần nhớ: RMW không nguyên tử — demo Mục 5 cho bạn tự tay
"chạy lại" đúng kịch bản mất-cập-nhật này theo từng bước.
4. VMCU: GPIOA vừa được "nối dây"
VMCU giờ có ngoại vi đầu tiên — GPIOA, đặt đúng theo địa chỉ APB2 thật của dòng STM32F1
(0x40010800) để chuyển sang board thật không bỡ ngỡ. Hai thanh ghi vừa được nối:
| Thanh ghi | Địa chỉ | Vai trò |
|---|---|---|
| MODER | 0x40010800 |
2 bit/chân — chọn input (00, mặc định) hay output (01) |
| ODR | 0x4001080C |
1 bit/chân — mức logic muốn xuất (chỉ có tác dụng thật khi MODER = output) |
// LED co that su SANG hay khong - can DU 2 dieu kien:
gpioaLedOn(pin) {
return this.gpioaPinMode(pin) === GPIO_MODE_OUTPUT
&& ((this.gpioaOdr >>> pin) & 1) === 1;
}
// Che do hien tai cua 1 chan (0=input, 1=output) - doc 2 bit tai vi tri pin*2
gpioaPinMode(pin) {
return (this.gpioaModer >>> (pin * 2)) & 0b11;
}
5. Thực hành: Register Inspector — bật LED thật bằng RMW
Demo dưới đây chạy trên VMCU thật — không phải hoạt ảnh giả lập. Đặt chế độ cho từng chân PA0–PA7 (input/output), rồi bật/tắt/đảo LED bằng đúng 3 thao tác bit Mục 2. Thử tắt LED khác trong lúc một LED đang bật — bạn sẽ thấy các chân khác không hề bị ảnh hưởng, đúng như đại số bit hứa hẹn.
Mỗi cột PA0–PA7: nút trên đổi MODE (Input/Output), nút dưới SET/CLR/TGL làm RMW thật trên ODR (chỉ có tác dụng — LED sáng — khi mode đang là Output). Thử bật LED PA0 rồi đổi mode PA0 về Input: bit ODR vẫn còn đó, nhưng LED tắt ngay — đúng cạm bẫy Mục 3.
#include <stdint.h>
#define GPIOA_MODER (*(volatile uint32_t *)0x40010800)
#define GPIOA_ODR (*(volatile uint32_t *)0x4001080C)
int main(void) {
// Cau hinh PA0 lam output push-pull (MODER bit[1:0] = 01)
GPIOA_MODER = (GPIOA_MODER & ~(0b11 << (0 * 2))) | (0b01 << (0 * 2));
GPIOA_ODR |= (1 << 0); // bat LED PA0 (set, RMW)
GPIOA_ODR &= ~(1 << 0); // tat LED PA0 (clear, RMW)
GPIOA_ODR ^= (1 << 0); // dao LED PA0 (toggle, RMW)
while (1) { /* Bai 4 se thay bang SysTick khong-blocking */ }
}
import { VMCU, GPIOA_MODER_ADDR, GPIOA_ODR_ADDR, GPIO_MODE_OUTPUT } from './vmcu.js';
const cpu = new VMCU();
const pin = 0;
// Cau hinh PA0 lam output (RMW tren MODER, 2 bit/chan)
const moder = cpu.read32(GPIOA_MODER_ADDR);
cpu.write32(GPIOA_MODER_ADDR, (moder & ~(0b11 << (pin * 2))) | (GPIO_MODE_OUTPUT << (pin * 2)));
cpu.write32(GPIOA_ODR_ADDR, cpu.read32(GPIOA_ODR_ADDR) | (1 << pin)); // set
console.log(cpu.gpioaLedOn(pin)); // true
cpu.write32(GPIOA_ODR_ADDR, cpu.read32(GPIOA_ODR_ADDR) & ~(1 << pin)); // clear
console.log(cpu.gpioaLedOn(pin)); // false
6. Thực hành: chứng kiến RMW mất-cập-nhật
Demo dưới đây "diễn lại" từng bước đúng kịch bản của cạm bẫy Mục 3 — không phải hoạt ảnh, mà là 3 lệnh
read32/write32 THẬT gọi tuần tự trên cùng một VMCU, theo đúng thứ tự một
interrupt xen ngang giữa main loop sẽ tạo ra. Bấm lần lượt 3 bước và quan sát bit PA5:
Tóm lược
-
✅
volatileép trình biên dịch KHÔNG cache, KHÔNG sắp xếp lại, KHÔNG loại bỏ phép đọc/ghi thanh ghi — thiếu nó là cạm bẫy phổ biến nhất khi mới học nhúng. -
✅ RMW (đọc-sửa-ghi) qua
|=/&=~/^=sửa đúng 1 bit mà không đụng các bit khác — ghi đè cả thanh ghi (REG = ...) là lỗi runtime kinh điển không trình biên dịch nào cảnh báo. - ✅ MODER phải cấu hình đúng OUTPUT thì bit ODR mới có tác dụng điện thật — ghi ODR cho một chân đang INPUT vẫn "thành công" nhưng không LED nào sáng.
- ✅ RMW không nguyên tử: một ngắt xen ngang giữa đọc và ghi có thể khiến thay đổi của nó bị main loop ghi đè mất — verified bằng demo 3 bước; giải pháp (critical section) hẹn ở Bài 8.
- ✅ VMCU vừa có ngoại vi đầu tiên: GPIOA (MODER/ODR) tại địa chỉ APB2 thật.
Trắc nghiệm ôn tập
Câu 1
Vì sao con trỏ trỏ tới thanh ghi ngoại vi luôn cần khai báo volatile?
Câu 2
Chân PA3 đang BẬT (LED sáng). Dòng code nào sau đây bật thêm PA0 mà KHÔNG làm tắt PA3?
Câu 3
Bạn ghi GPIOA_ODR |= (1 << 5) nhưng chân PA5 vẫn đang ở chế độ MODER = Input. Điều gì
xảy ra?
Câu 4
Vì sao RMW (đọc-sửa-ghi) được gọi là "không nguyên 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 2 vừa thêm GPIOA
(MODER/ODR), 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