Để tiếp thu bài học này tốt nhất, bạn nên ôn lại:
- Bài 12: Cổng Logic & Mạch tổ hợp (hiểu mức điện áp logic nhị phân HIGH/LOW).
- Bài 13: Mạch tuần tự & Thiết kế bộ nhớ (một thanh ghi thực chất là nhiều Flip-Flop ghép lại — sẽ dùng lại khái niệm này).
- Series C — Bài 8: Làm chủ Con Trỏ (địa chỉ bộ nhớ và ép kiểu con trỏ — nền tảng để hiểu memory-mapped I/O bên dưới).
Từ Bài 1 đến Bài 14, mọi mạch trong series đều là mạch cứng: hành vi của nó cố định vĩnh viễn theo cách bạn hàn dây và chọn linh kiện. Muốn đổi hành vi — phải hàn lại mạch. Vi điều khiển (MCU) phá vỡ giới hạn đó: cùng một con chip vật lý, chỉ cần nạp một đoạn mã lệnh khác là toàn bộ hành vi mạch thay đổi hoàn toàn — không cần chạm vào dây dẫn hay linh kiện nào.
Bài học này mở ra nửa sau của series: thay vì thiết kế mạch tương tự (analog) hay số (digital) cố định, bạn sẽ học cách một chương trình phần mềm điều khiển trực tiếp phần cứng thông qua các thanh ghi đặc biệt, và tự tay lập trình một MCU ảo để nhấp nháy đèn LED — cellphone, máy giặt, hay chính bàn phím bạn đang gõ đều chạy trên nguyên lý giống hệt.
1. Kiến trúc tổng quan bên trong vi điều khiển
Một MCU gói gọn cả một "máy tính mini" vào một con chip duy nhất: bộ xử lý trung tâm (CPU) để thực thi lệnh, bộ nhớ Flash để lưu chương trình vĩnh viễn (giữ dữ liệu kể cả khi mất điện), và SRAM để lưu biến tạm thời trong lúc chạy (mất hết khi ngắt điện — bạn đã gặp SRAM 6-transistor ở Bài 13). Ba khối này nói chuyện với nhau qua một bus hệ thống — một "xa lộ" dây dẫn chung mang địa chỉ, dữ liệu và tín hiệu điều khiển qua lại.
Điểm khác biệt cốt lõi so với "vi xử lý" (như CPU trong máy tính bàn) là MCU tích hợp luôn Flash + SRAM + các khối ngoại vi (I/O, Timer, ADC...) vào một chip duy nhất — không cần RAM rời, ổ cứng rời. Cái giá phải trả là tài nguyên rất hạn chế: một MCU 8-bit phổ biến trong lớp học chỉ có vài KB SRAM (so với hàng GB của máy tính) — đủ cho các tác vụ điều khiển thiết bị nhưng không đủ chạy hệ điều hành đầy đủ.
1.1 Kiến trúc Harvard vs Von Neumann: vì sao Flash và SRAM là hai "thế giới" khác nhau
Sơ đồ khối ở trên vẽ Flash và SRAM là hai ô riêng biệt — đó không chỉ là cách vẽ cho dễ nhìn, mà phản ánh
đúng một lựa chọn thiết kế phần cứng thật gọi là kiến trúc Harvard: chương trình (Flash)
và dữ liệu (SRAM) nằm trên hai không gian địa chỉ hoàn toàn tách biệt, mỗi loại đi trên
một bus riêng. Địa chỉ 0x25 trong Flash (một lệnh máy) và địa chỉ 0x25 trong
SRAM (thanh ghi PORTB ở mục 3) là hai ô nhớ vật lý hoàn toàn khác nhau, dù
trùng số. Đây là lý do CPU không thể vô tình "thực thi nhầm" dữ liệu thành lệnh — một lớp bảo vệ tự nhiên
mà kiến trúc Von Neumann (máy tính bàn, và cả nhiều MCU ARM hiện đại) không có, vì ở đó
chương trình và dữ liệu chia sẻ chung một không gian địa chỉ.
| Tiêu chí | Kiến trúc Harvard | Kiến trúc Von Neumann |
|---|---|---|
| Không gian địa chỉ | Chương trình và dữ liệu tách biệt (2 bus riêng) | Chương trình và dữ liệu chung một không gian (1 bus) |
| Tốc độ | Nhanh hơn — CPU có thể đọc lệnh tiếp theo và đọc/ghi dữ liệu cùng lúc | Chậm hơn — lệnh và dữ liệu tranh nhau cùng một bus (Von Neumann bottleneck) |
| An toàn | CPU không thể thực thi nhầm vùng dữ liệu thành mã lệnh | Về lý thuyết có thể (nền tảng của một số lỗ hổng bảo mật thực thi mã trên máy tính bàn) |
| Ví dụ | Hầu hết MCU 8-bit: AVR (ATmega328P), PIC | CPU máy tính bàn (x86); nhiều MCU ARM Cortex-M dùng biến thể "Harvard cải tiến" cho vùng cache |
1.2 So sánh vài họ MCU phổ biến trong thực tế
"MCU" không phải một sản phẩm duy nhất — đây là cả một phổ rộng từ chip 8-bit giá vài nghìn đồng đến chip
32-bit đa lõi mạnh ngang điện thoại đời đầu. Độ rộng thanh ghi ($8$-bit, $16$-bit hay $32$-bit) quyết định
trực tiếp độ rộng của các thanh ghi DDRx/PORTx/PINx mà bạn sẽ thao
tác ở mục 3 — MCU 8-bit thì mỗi thanh ghi GPIO rộng đúng 8 bit (khớp 8 chân một cổng) như trong toàn bộ
bài học này; MCU 32-bit thường gộp nhiều chân hơn vào một thanh ghi rộng hơn.
| Họ MCU | Kiến trúc CPU | Xung nhịp điển hình | SRAM điển hình | Ứng dụng tiêu biểu |
|---|---|---|---|---|
| ATmega328P (AVR) | 8-bit, Harvard | $16\text{ MHz}$ | $2\text{ KB}$ | Arduino Uno — học tập, dự án nhỏ |
| PIC16F877A | 8-bit, Harvard | $20\text{ MHz}$ | $368\text{ B}$ | Công nghiệp, thiết bị nhúng đơn giản |
| STM32F103 (ARM Cortex-M3) | 32-bit | $72\text{ MHz}$ | $20\text{ KB}$ | IoT, thiết bị đo lường, robot |
| ESP32 (Xtensa, 2 lõi) | 32-bit, Harvard | $240\text{ MHz}$ | $520\text{ KB}$ | IoT có WiFi/Bluetooth tích hợp |
Series này chọn mô hình 8-bit kiểu AVR (giống Arduino Uno) làm chuẩn xuyên suốt Bài 15 & 16 vì tập lệnh đơn giản, thanh ghi 8-bit trực quan để vẽ tay từng bit — nhưng mọi nguyên lý (memory-mapped I/O, DDR/PORT/PIN, đọc-sửa-ghi bitwise) áp dụng y hệt trên MCU 32-bit, chỉ khác độ rộng thanh ghi và tên gọi thanh ghi cụ thể.
delay() tốn CPU như demo ở mục 5.
2. Memory-Mapped I/O — địa chỉ hoá phần cứng
Đây là ý tưởng quan trọng nhất của bài học: các thanh ghi điều khiển chân vật lý được đặt trong CÙNG một không gian địa chỉ với SRAM. Với CPU, ghi một byte vào ô nhớ 0x25 và ghi một byte vào "thanh ghi điều khiển chân GPIO" là cùng một thao tác — chỉ khác địa chỉ đích. Cách tiếp cận này gọi là Memory-Mapped I/O.
Nhắc lại Series C — con trỏ: một con trỏ chỉ là một địa chỉ bộ
nhớ. Khi thư viện MCU viết PORTB = 0x20;, trình biên dịch thực chất dịch nó thành:
// "PORTB" chỉ là một macro cho con trỏ trỏ tới địa chỉ cố định 0x25
#define PORTB (*(volatile uint8_t *)0x25)
// Viết PORTB = 0x20 thực chất là:
*(volatile uint8_t *)0x25 = 0x20;
// Ghi thẳng byte 0x20 vào ô nhớ 0x25 — CPU không hề biết (và không cần biết)
// ô nhớ đó là RAM hay một thanh ghi điều khiển chân vật lý.
volatile bắt buộc phải cóPINB có thể đổi bất cứ lúc nào do
tín hiệu điện bên ngoài — một sự kiện trình biên dịch không thấy được. Từ khoá
volatile ra lệnh cho trình biên dịch: "luôn đọc/ghi thật từ địa chỉ này, không được tối ưu,
không được dùng giá trị cũ trong cache". Thiếu volatile, code đọc chân nút nhấn có thể bị
"đóng băng" ở giá trị đọc lần đầu tiên mãi mãi.
3. Bộ ba thanh ghi GPIO: DDRx, PORTx, PINx
Mỗi cổng GPIO 8-chân (ví dụ cổng B) được điều khiển bởi đúng 3 thanh ghi 8-bit, mỗi bit ứng với một chân vật lý:
| Thanh ghi | Vai trò | Bit = 1 nghĩa là gì | Bit = 0 nghĩa là gì |
|---|---|---|---|
| DDRx (Data Direction Register) | Chọn chiều của chân: input hay output | Chân là OUTPUT | Chân là INPUT |
| PORTx | Nếu chân là output: điều khiển mức áp ra chân | Chân xuất ra mức HIGH (thường ~5V/3.3V) | Chân xuất ra mức LOW (0V) |
| PORTx (khi chân là input) | Bật/tắt điện trở kéo lên nội bộ (internal pull-up) | Bật pull-up (chân "nghỉ" ở mức HIGH khi không nối gì) | Thả nổi hoàn toàn (floating — dễ nhiễu, xem cạm bẫy bên dưới) |
| PINx | Chỉ để đọc — phản ánh mức điện áp thật đang có ở chân | Chân đang đo được mức HIGH | Chân đang đo được mức LOW |
Nói cách khác: DDRx quyết định "vai trò" của chân (nói hay nghe), còn PORTx và PINx là hai chiều dữ liệu khác nhau của cùng một chân — PORTx là "miệng" (ghi/xuất), PINx là "tai" (đọc/nhập). Chúng không thể dùng thay nhau.
3.1 Bảng địa chỉ thật: memory-mapped I/O không phải lý thuyết suông
Để thấy rõ "3 thanh ghi mỗi cổng" không phải là ẩn dụ mà là các ô nhớ thật với địa chỉ cụ thể, đây là trích đoạn bản đồ bộ nhớ dữ liệu (data memory map) thật của chip ATmega328P (chip trên Arduino Uno) — địa chỉ lấy từ chính datasheet của Microchip:
| Địa chỉ (hex) | Tên thanh ghi | Cổng |
|---|---|---|
0x23 |
PINB | Cổng B (PB0–PB7, gồm cả PB5 dùng trong bài này) |
0x24 |
DDRB | |
0x25 |
PORTB | |
0x26 |
PINC | Cổng C (PC0–PC6) |
0x27 |
DDRC | |
0x28 |
PORTC | |
0x29 |
PIND | Cổng D (PD0–PD7) |
0x2A |
DDRD | |
0x2B |
PORTD |
Quan sát một quy luật rất đẹp: cứ mỗi cổng chiếm đúng 3 địa chỉ liên tiếp, luôn theo thứ
tự PIN → DDR → PORT, rồi lặp lại cho cổng kế tiếp. Đây chính xác là lý do vì sao macro
PORTB ở mục 2 trỏ đúng vào 0x25 — không phải con số ngẫu nhiên, mà nhà sản xuất
chip đã "đổ khuôn" các cổng GPIO liên tiếp nhau trong không gian địa chỉ để trình biên dịch dễ tạo mã tối
ưu.
#define PB0 0
void setup(void) {
DDRB &= ~(1 << PB0); // PB0 là INPUT (mặc định, nhưng ghi rõ cho dễ đọc)
PORTB |= (1 << PB0); // Chân INPUT: bit PORT này bật điện trở pull-up nội bộ
// → chân "nghỉ" ở mức HIGH khi nút nhấn chưa được bấm
}
int docNutNhan(void) {
// Nút nhấn thường đấu một đầu xuống GND, đầu kia vào chân PB0.
// Khi CHƯA bấm: pull-up kéo chân lên HIGH → PINB bit 0 = 1.
// Khi ĐANG bấm: nút nối thẳng chân xuống GND → PINB bit 0 = 0 (logic đảo ngược!).
return (PINB & (1 << PB0)) == 0; // trả về 1 (true) khi nút ĐANG được bấm
}
Chú ý logic bị đảo ngược so với trực giác: nút nhấn đang bấm lại đọc được mức LOW, còn chưa bấm đọc được mức HIGH — vì điện trở pull-up mặc định kéo chân lên cao, nút nhấn chỉ có tác dụng "kéo tuột" chân xuống đất khi đóng mạch. Đây là mẫu đọc nút nhấn phổ biến nhất trong thực tế vì không cần thêm điện trở rời bên ngoài.
PINx tuy là thanh ghi "chỉ đọc" nhưng có một hành
vi đặc biệt ở phần cứng:
ghi giá trị 1 vào một bit của PINx sẽ đảo (toggle) đúng bit đó của PORTx — không đụng
tới các bit khác. Đây là cách nhấp nháy LED nhanh hơn và an toàn hơn viết
PORTB ^= (1 << PB5); vì nó không cần đọc giá trị cũ trước (tránh race condition khi
có ngắt xảy ra giữa chừng — khái niệm bạn sẽ gặp lại ở Bài 16).
4. Kỹ thuật đọc–sửa–ghi bằng phép toán bitwise
Vì một thanh ghi 8-bit điều khiển 8 chân cùng lúc, bạn gần như không bao giờ được phép ghi đè nguyên byte (trừ khi chủ đích muốn đổi cả 8 chân). Thay vào đó, dùng phép toán bitwise để chỉ sửa đúng bit mình cần, giữ nguyên 7 bit còn lại:
// Giả sử LED nối chân PB5 (giống hệt LED tích hợp sẵn trên board Arduino Uno)
#define PB5 5
// 1. SET một bit (đưa lên 1, giữ nguyên các bit khác) — dùng OR
PORTB |= (1 << PB5); // (1 << 5) = 0b00100000 → chỉ bật riêng bit 5
// 2. CLEAR một bit (đưa về 0, giữ nguyên các bit khác) — dùng AND với số đã đảo bit
PORTB &= ~(1 << PB5); // ~(0b00100000) = 0b11011111 → chỉ tắt riêng bit 5
// 3. TOGGLE một bit (đảo trạng thái) — dùng XOR
PORTB ^= (1 << PB5); // bit 5: 0→1 hoặc 1→0, các bit khác giữ nguyên
// 4. ĐỌC một bit (kiểm tra chân đang HIGH hay LOW) — dùng AND rồi so 0
if (PINB & (1 << PB0)) {
// Chân PB0 đang ở mức HIGH (ví dụ: nút nhấn đang được thả ra nhờ pull-up)
}
Đề bài: Board có LED nối chân PB5 và Relay nối chân PB3, các chân PB còn lại
(PB0–PB2, PB4, PB6–PB7) dùng làm input đọc cảm biến. Hãy tính giá trị nhị phân và thập lục phân cần
ghi vào DDRB để cấu hình đúng một lần duy nhất lúc khởi động.
1. Đánh số bit từ 7 (trái) xuống 0 (phải): DDRB = [b7 b6 b5 b4 b3 b2 b1 b0]. Bit
output (=1) là bit 5 (LED) và bit 3 (Relay); tất cả bit còn lại là input (=0).
2. Xếp thành chuỗi nhị phân 8-bit:
$$DDRB = 0b\underbrace{0}_{7}\underbrace{0}_{6}\underbrace{1}_{5}\underbrace{0}_{4}\underbrace{1}_{3}\underbrace{0}_{2}\underbrace{0}_{1}\underbrace{0}_{0} = 0b00101000$$
3. Đổi sang thập lục phân (nhóm 4 bit một): $0010_2 = 2_{16}$, $1000_2 = 8_{16}$
$$DDRB = 0b00101000 = 0x28 = 40_{10}$$
Kết luận: chỉ cần một dòng lệnh duy nhất DDRB = 0x28; lúc khởi động — hoặc rõ ràng
hơn về mặt ý nghĩa: DDRB = (1 << PB5) | (1 << PB3);.
PORTB |= (1 << PB5); để bật LED
nhưng LED không sáng — vì chân vẫn đang ở chế độ mặc định (thường là INPUT, DDR bit = 0). Khi DDR bit là
0, giá trị PORT bit không điều khiển điện áp ra chân nữa mà chỉ bật/tắt điện trở pull-up nội bộ — dòng
điện qua điện trở pull-up quá yếu để làm sáng LED công suất bình thường. Luôn cấu hình DDR
trước, PORT sau, không bao giờ đảo thứ tự.
// ❌ SAI #1: Quên set DDR trước — LED không bao giờ sáng
PORTB |= (1 << PB5); // Chân PB5 vẫn đang là INPUT (DDR mặc định = 0)!
// ✅ ĐÚNG #1: Luôn cấu hình chiều DDR trước khi điều khiển PORT
DDRB |= (1 << PB5); // Bước 1: khai báo PB5 là OUTPUT
PORTB |= (1 << PB5); // Bước 2: bây giờ mới bật mức HIGH cho LED
// ❌ SAI #2: Gán đè cả byte — xoá mất cấu hình PB3 (Relay) đã set trước đó
DDRB = (1 << PB5); // Ghi đè toàn bộ 8 bit! PB3 vô tình bị đưa về 0 (input)
// ✅ ĐÚNG #2: Dùng OR để chỉ bật thêm bit mới, giữ nguyên các bit đã cấu hình
DDRB |= (1 << PB5); // Chỉ bật thêm bit 5, bit 3 (Relay) vẫn nguyên vẹn
5. Ứng dụng thực tế: điều khiển nhiều chân GPIO cùng lúc
Sức mạnh thật sự của thao tác thanh ghi lộ rõ khi điều khiển
nhiều chân cùng lúc bằng một lệnh duy nhất — thay vì gọi hàm bật/tắt từng LED riêng lẻ (như các
thư viện cấp cao thường làm), bạn có thể ghi thẳng cả một mẫu bit vào PORTx để đổi trạng thái
toàn bộ 8 chân trong đúng một chu kỳ máy.
5.1 Bộ đếm nhị phân hiển thị trên 4 LED
Nối 4 LED vào 4 chân thấp nhất của cổng D (PD0–PD3). Đếm nhị phân từ $0$ đến $15$ chỉ đơn giản là gán liên
tiếp giá trị đếm vào PORTD:
void setup(void) {
DDRD |= 0b00001111; // 4 bit thấp (PD0-PD3) là OUTPUT, 4 bit cao giữ nguyên INPUT
}
void demNhiPhan(void) {
for (uint8_t dem = 0; dem <= 15; dem++) {
// Chỉ ghi đè đúng 4 bit thấp, GIỮ NGUYÊN 4 bit cao (tránh lặp lại sai lầm ở mục 4!)
PORTD = (PORTD & 0b11110000) | (dem & 0b00001111);
delay(300); // Mỗi bước đếm cách nhau 300ms để mắt kịp nhìn
}
// dem = 13 = 0b1101 → LED PD3,PD2 tắt, PD1,PD0 sáng — thử tính nhẩm các giá trị còn lại!
}
Chú ý dòng PORTD = (PORTD & 0b11110000) | (dem & 0b00001111); — đây là kỹ thuật kết
hợp AND và OR để chỉ thay 4 bit thấp, khoá cứng 4 bit cao, tổng quát hoá chính xác cạm
bẫy "ghi đè cả byte" ở mục 4 cho trường hợp nhiều bit cùng lúc thay vì chỉ 1 bit.
5.2 Hiệu ứng đèn chạy (Knight Rider / Larson Scanner)
Hiệu ứng đèn chạy qua lại kinh điển — một chấm sáng "chạy" từ trái sang phải rồi ngược lại trên dãy LED —
chỉ cần đúng một toán tử dịch bit (<</>>, khác với
&/|/^ đã học ở mục 4):
void hieuUngDenChay(void) {
uint8_t mau = 0b00000001; // Bắt đầu từ chân PD0
int8_t chieu = 1; // 1 = dịch phải→trái, -1 = dịch trái→phải
while (1) {
PORTD = mau;
delay(100);
if (chieu == 1) {
mau <<= 1; // Dịch chấm sáng sang trái 1 bit
if (mau == 0b10000000) chieu = -1; // Chạm biên phải → đổi chiều
} else {
mau >>= 1; // Dịch chấm sáng sang phải 1 bit
if (mau == 0b00000001) chieu = 1; // Chạm biên trái → đổi chiều
}
}
}
Vòng lặp while (1) chạy vĩnh viễn bằng cách "đợi bận" (busy-wait) qua delay() —
trong lúc đó CPU không làm được việc gì khác, kể cả đọc một nút nhấn. Đây chính là giới hạn bạn sẽ vượt
qua ở Bài 16: dùng ngắt Timer phần cứng để CPU vẫn rảnh xử lý việc khác trong lúc đèn vẫn
chạy đều đặn — không cần vòng lặp chờ tốn tài nguyên như ở đây.
Đề bài: Với mạch bộ đếm nhị phân ở mục 5.1 (4 LED nối PD0–PD3, PD0 là bit thấp nhất),
hãy tính giá trị nhị phân và hex cần gán cho 4 bit thấp của PORTD để hiển thị số thập
phân $13$, giả sử 4 bit cao của PORTD đang giữ nguyên giá trị 0000.
1. Đổi $13$ sang nhị phân 4-bit bằng cách trừ dần luỹ thừa của 2:
$$13 = 8 + 4 + 1 = 2^3 + 2^2 + 2^0 \Rightarrow 13_{10} = 1101_2$$
2. Gán từng bit theo đúng thứ tự PD3 PD2 PD1 PD0 (từ trái sang phải):
$$\text{PD3}=1,\ \text{PD2}=1,\ \text{PD1}=0,\ \text{PD0}=1$$
3. Vì 4 bit cao đang là 0000, toàn byte PORTD là:
$$PORTD = 0b00001101 = 0x0D = 13_{10}$$
Kết luận: LED tại PD3, PD2, PD0 sẽ sáng (giá trị bit = 1), riêng PD1 tắt (giá trị bit = 0) — đúng như mã gợi ý trong chú thích code ở mục 5.1.
6. Trình giả lập: Lập trình GPIO cho MCU ảo
Bảng dưới đây mô phỏng một MCU ảo với LED nối ở chân PB5 (giống hệt LED tích hợp sẵn trên board Arduino
Uno). Bấm ▶ Chạy từng lệnh để thực thi tuần tự đoạn mã chớp tắt LED, quan sát giá trị 3
thanh ghi DDRB/PORTB/PINB đổi theo từng dòng lệnh và đèn LED thật
sự sáng/tắt tương ứng. Nút ❌ Bỏ qua bước set DDR tái hiện chính xác cạm bẫy ở mục 4.
Chương trình (chớp tắt LED)
7. Câu hỏi trắc nghiệm ôn tập
Câu 1: Bạn ghi PORTB |= (1 << PB5); để bật LED nối chân PB5
nhưng LED hoàn toàn không sáng. Nguyên nhân nhiều khả năng nhất là gì?
Câu 2: Vì sao thanh ghi PINB (đọc trạng thái chân) thường phải khai
báo với từ khoá volatile trong C?
volatile, biến
có thể bị "đóng băng" ở giá trị đọc lần đầu, không phản ánh thay đổi thật từ phần cứng bên ngoài.
Câu 3: Bạn cần bật thêm chân PB2 làm output mà không ảnh hưởng đến PB5 (LED) đã được cấu hình output từ trước. Dòng lệnh nào đúng?
DDRB |= (1 << PB2); Toán tử OR chỉ bật thêm bit 2,
giữ nguyên bit 5 đã set trước đó. Ba đáp án còn lại đều ghi đè hoặc dùng sai toán tử, xoá mất cấu
hình PB5.