Mở đầu: nói chuyện với MCU qua đúng 2 sợi dây

Suốt 8 bài vừa qua, MCU chỉ "nói chuyện" với thế giới qua LED, nút nhấn và buzzer — mọi thông tin gửi ra ngoài đều phải tự bạn nhìn bằng mắt. Bài này cho MCU một kênh giao tiếp thật sự: gõ lệnh từ máy tính, MCU nhận và phản hồi lại bằng chữ. Đây chính xác là cách hầu hết board phát triển (Arduino, STM32 Nucleo...) in ra dòng debug đầu tiên của bạn — và là nền tảng của mọi giao thức phức tạp hơn (Wi-Fi module, GPS, cảm biến) vẫn dùng UART làm lớp vật lý bên dưới.

Thứ khiến UART đặc biệt: nó là giao tiếp nối tiếp bất đồng bộ — không có dây clock chung nào cả, hai bên phải tự thoả thuận trước tốc độ, rồi tin tưởng đồng hồ nội của nhau đủ chính xác để không lệch pha giữa chừng. Bài này verify chính xác lệch bao nhiêu phần trăm thì "niềm tin" đó sụp đổ, rồi ghép UART với ring buffer SPSC của Bài 8 để xây một giao thức dòng lệnh mini điều khiển LED thật qua terminal.


📚 Điều kiện tiên quyết
Bắt buộc: Bài 7 (ngắt, NVIC), Bài 8 (ring buffer SPSC — dùng nguyên xi cho cả TX lẫn RX ở bài này).

1. Vì sao truyền nối tiếp thắng song song

Cách "hiển nhiên" để gửi 1 byte là dùng 8 chân, mỗi chân mang đúng 1 bit, tất cả đổi giá trị đồng thời — truyền song song. Nhanh, nhưng tốn 8 chân GPIO chỉ cho 1 hướng truyền — nhớ lại Bài 3: chân là tài nguyên đắt nhất của MCU. Truyền nối tiếp (serial) đảo ngược đánh đổi: gửi từng bit một qua đúng 1 dây, chậm hơn nhưng chỉ tốn 2 chân cho giao tiếp 2 chiều trọn vẹn (TX gửi, RX nhận).

UART còn bỏ luôn cả dây clock chung (khác SPI/I2C mà series này chưa chạm tới) — hai bên tự thoả thuận trước một tốc độ gọi là baud rate, rồi mỗi bên tự đếm thời gian bằng đồng hồ nội của mình. Để hai bên "đồng bộ" được với không dây clock chung, mỗi byte được đóng gói trong một khung chuẩn — kiểu phổ biến nhất gọi là 8N1 (8 bit dữ liệu, không parity, 1 stop bit):

  1. Start bit (1 bit, luôn = 0): báo "khung mới bắt đầu ngay đây" — dây đang nghỉ ở mức 1, tụt xuống 0 là tín hiệu đồng bộ đầu tiên.
  2. 8 bit dữ liệu, gửi theo thứ tự LSB trước (bit thấp nhất đi trước, khác thứ tự viết tay quen thuộc).
  3. Parity (tuỳ chọn, "N" = None trong 8N1 nghĩa là bỏ qua): 8N1 không dùng.
  4. Stop bit (1 bit, luôn = 1): dây trở lại mức nghỉ, cho bên nhận thời gian "thở" trước byte kế tiếp.

Verify khung của ký tự 'A' (mã ASCII 0x41 = 0b01000001): bit thấp nhất trước, khung 10 bit đầy đủ là 0 1 0 0 0 0 0 1 0 1 (start · d0..d7 · stop) — đúng như demo Mục 5 vẽ ra.

2. Baud rate & ngân sách sai số

Baud rate được cấu hình qua một thanh ghi chia clock, công thức đơn giản của bài (bỏ qua hệ số oversampling 16x của thanh ghi thật):

$$BRR = \frac{f_{clk}}{baud}$$

Verify: với clock APB2 chuẩn 72MHz và baud 9600, BRR = 72{.}000{.}000 / 9600 = 7500 — chia hết tuyệt đối, baud thật khớp lại đúng 9600, không sai số. Nhưng không phải lúc nào cũng may mắn như vậy: với một MCU dùng dao động RC nội rẻ tiền ~8MHz (không cắm thạch anh ngoài), BRR = 8{.}000{.}000 / 9600 ≈ 833 (làm tròn) — baud thật tính ngược lại chỉ còn 9603,84, lệch 0,04% so với 9600 mong muốn. Sai số này tự nó vô hại, nhưng nó cộng dồn với sai số dung sai thật của chính dao động RC (thường ±1% trở lên) — và đây chính là nguồn gốc của loại bug "ma ám theo nhiệt độ" nổi tiếng trong nghề.

Câu hỏi cốt lõi: lệch baud giữa 2 bên bao nhiêu phần trăm thì khung vỡ? Bộ nhận lấy mẫu mỗi bit tại đúng giữa chu kỳ bit theo đồng hồ của NÓ; nếu đồng hồ đó nhanh/chậm hơn bên gửi, điểm lấy mẫu trôi dần xa khỏi giữa bit thật qua từng bit trong khung — 10 bit khung 8N1 nghĩa là sai số có 9,5 chu kỳ bit để tích luỹ trước khi lấy mẫu bit cuối.

⚠️ Verify: ngân sách an toàn thật sự nằm ở đâu
Với khung 8N1 của 'A': lệch baud 2% và ngay cả 5% vẫn đọc ĐÚNG trọn vẹn cả 10 bit — khớp đúng quy tắc kinh nghiệm "ngân sách an toàn ~±2%" của ngành, với biên an toàn thực tế còn rộng hơn một chút. Nhưng vượt qua ~5,3%, khung bắt đầu vỡ: bit đầu tiên bị đọc sai chính là stop bit (bit cuối cùng, nơi sai số tích luỹ nhiều nhất) — verify cụ thể: lệch 5,3% cho đúng 9/10 bit đọc đúng; lệch 8% khung vỡ rõ rệt, chỉ còn 6/10 bit đúng (4 bit sai). Tự tay thử ở demo Mục 5.

3. TX/RX bằng ngắt + ring buffer

Gửi/nhận từng byte bằng polling (main tự hỏi "gửi xong chưa? có byte mới chưa?") vừa tốn CPU vừa đúng y hệt cạm bẫy polling của Bài 7. Cách chuẩn công nghiệp: dùng 2 ngắt của USART — TXE (Transmit data register Empty — báo "sẵn sàng nhận byte kế tiếp để gửi") và RXNE (Receive data register Not Empty — báo "vừa nhận xong 1 byte") — kết hợp với đúng ring buffer SPSC của Bài 8:

  • TX: main đẩy toàn bộ chuỗi cần gửi vào TX ring buffer rồi quay lại làm việc khác ngay, không chờ. ISR TXE rút từng byte ra khỏi buffer, ghi vào DR, lặp lại tới khi buffer rỗng.
  • RX: ISR RXNE đẩy byte vừa nhận vào RX ring buffer (chỉ đụng head — đúng vai producer của Bài 8). Main thong thả rút byte ra ở tail khi rảnh, không mất byte nào dù bận việc khác lúc byte tới.
uart_isr_ringbuffer.c
// Dung nguyen xi RingBufferSPSC cua Bai 8 cho ca TX lan RX
volatile uint8_t tx_buf[16], tx_head = 0, tx_tail = 0;
volatile uint8_t rx_buf[16], rx_head = 0, rx_tail = 0;

void USART1_IRQHandler(void) {
    if (USART1_SR & (1 << 7)) {        // TXE: san sang gui byte ke tiep
        if (tx_tail != tx_head) {
            USART1_DR = tx_buf[tx_tail];
            tx_tail = (tx_tail + 1) % 16;
        } else {
            USART1_CR1 &= ~(1 << 7);    // het du lieu - tat TXEIE, khong "gui rong"
        }
    }
    if (USART1_SR & (1 << 5)) {         // RXNE: vua nhan xong 1 byte
        uint8_t next = (rx_head + 1) % 16;
        if (next != rx_tail) {          // con cho - khong ghi de neu day
            rx_buf[rx_head] = USART1_DR; // doc DR TU DONG xoa RXNE
            rx_head = next;
        }
    }
}
⚠️ Cạm bẫy tội lỗi kinh điển: printf bên trong ISR
Gọi printf (hoặc bất kỳ hàm gửi UART kiểu BLOCKING nào — chờ tới khi gửi xong mới trả về) ngay bên trong một ISR khác là lỗi runtime kinh điển: ISR đó bị "khoá cứng" chờ UART gửi xong từng byte, vi phạm thẳng quy tắc vàng "ISR càng ngắn càng tốt" của Bài 7 — mọi ngắt khác (kể cả ưu tiên cao hơn) bị trễ theo. Cách đúng: ISR chỉ đẩy dữ liệu vào ring buffer (không chờ gì cả), để ISR TXE của chính UART lo phần gửi thật.

4. Giao thức dòng lệnh mini

UART chỉ truyền một dòng byte trần trụi không có ranh giới nào giữa các "thông điệp" — bên nhận phải tự định nghĩa khi nào một lệnh kết thúc. Cách đơn giản và phổ biến nhất: framing bằng ký tự xuống dòng ('\n') — main tích luỹ byte vào một bộ đệm chuỗi, khi gặp '\n' thì coi đó là hết 1 lệnh, đem đi phân tích rồi xoá bộ đệm để bắt đầu lệnh kế tiếp.

Bộ lệnh mini của demo Mục 5: LED ON (bật LED), LED OFF (tắt LED), BLINK 500 (nháy LED với chu kỳ 500ms — số sau BLINK là tham số).

command_parser.c
char line_buf[64];
uint8_t line_len = 0;

void on_byte_received(uint8_t b) {
    if (b == '\n') {
        line_buf[line_len] = 0;         // ket thuc chuoi C
        parse_command(line_buf);        // xu ly lenh vua tich luy xong
        line_len = 0;                    // reset cho lenh ke tiep
    } else if (line_len < sizeof(line_buf) - 1) {
        line_buf[line_len++] = b;       // tich luy tung ky tu
    }
}

void parse_command(const char *cmd) {
    if (strcmp(cmd, "LED ON") == 0)      led_set(1);
    else if (strcmp(cmd, "LED OFF") == 0) led_set(0);
    else if (strncmp(cmd, "BLINK ", 6) == 0) blink_start(atoi(cmd + 6));
}

Outlook một đoạn: với đường truyền dễ nhiễu (dây dài, môi trường công nghiệp), người ta còn thêm checksum hoặc CRC vào cuối mỗi khung để phát hiện lỗi truyền — ngoài phạm vi bài này, nhưng đáng biết tên khi cần tra cứu thêm.

5. Thực hành: UART terminal + waveform explorer trên VMCU thật

Demo dưới đây chạy trên VMCU thật với USART1 vừa "sống": gõ lệnh điều khiển LED qua terminal, xem từng bit của khung truyền, và tự tay phá hoại baud rate để tái hiện đúng con số verified ở Mục 2:

📟 UART Terminal & Waveform Explorer — VMCU thật

1. Terminal — gõ LED ON / LED OFF / BLINK 500

2. Waveform Explorer & thí nghiệm phá hoại baud

Đang tải…

Kéo thanh trượt qua 5,3% xem stop bit vỡ đầu tiên (verified: 9/10 bit đúng); kéo tới 8% xem khung vỡ rõ rệt (verified: chỉ còn 6/10 bit đúng). Dưới 5% luôn an toàn tuyệt đối.

uart_demo.c
// Xem code day du: uart_isr_ringbuffer.c (Muc 3) + command_parser.c (Muc 4).
// Demo nay ghep ca 2 lai va chay dung tren VMCU, khong gia lap.
uart_demo.js (đúng logic đang chạy ở tab Xem trước)
import { VMCU, RingBufferSPSC, uartSampleWithBaudError, USART1_DR_ADDR, USART1_CR1_ADDR, USART1_CR1_RXNEIE_BIT, IRQ_USART1, NVIC_ISER1_ADDR } from './vmcu.js';

const cpu = new VMCU();
const rxRing = new RingBufferSPSC(64);

cpu.write32(NVIC_ISER1_ADDR, 1 << (IRQ_USART1 - 32));
cpu.write32(USART1_CR1_ADDR, 1 << USART1_CR1_RXNEIE_BIT);
cpu.installIrqHandler(IRQ_USART1, (c) => {
  rxRing.push(c.read8(USART1_DR_ADDR)); // doc DR tu dong xoa RXNE
});

function sendChar(ch) { cpu.uartInjectRxByte(ch.charCodeAt(0)); }
// main loop: rxRing.pop() tich luy vao line_buf toi khi gap '\n', roi parse

// Waveform / bau loi:
const result = uartSampleWithBaudError(0x41, 0.08);
console.log(result); // { bits, sampled, correctPerBit, allCorrect: false }

Tóm lược

  • ✅ Truyền nối tiếp tốn 2 chân (TX/RX) thay vì 8+ như song song — không cần dây clock chung, khung 8N1 (start=0, 8 data LSB-first, stop=1) là cơ chế đồng bộ thay thế.
  • Baud rate: $BRR = f_{clk}/baud$ — verify: 9600 baud @ 72MHz chia hết (BRR=7500, không sai số); @ 8MHz RC nội thì lệch nhỏ 0,04% do làm tròn.
  • ✅ Ngân sách sai số baud verified: 2% và 5% vẫn đọc đúng trọn khung; vượt ~5,3% stop bit vỡ đầu tiên (9/10 đúng); 8% khung vỡ rõ rệt (6/10 đúng).
  • ✅ TX/RX bằng ngắt (TXE/RXNE) + ring buffer SPSC của Bài 8 — không polling, không chặn; cạm bẫy: printf blocking bên trong ISR vi phạm quy tắc "ISR ngắn" của Bài 7.
  • ✅ Giao thức dòng lệnh mini: framing bằng '\n', parser LED ON/LED OFF/BLINK <ms>.

Trắc nghiệm ôn tập

Câu 1

Vì sao UART không cần một dây clock chung giữa 2 bên như một số giao tiếp khác?

Câu 2

Verified: lệch baud 5% giữa 2 bên vẫn đọc đúng trọn vẹn khung 8N1, nhưng vượt qua khoảng 5,3% thì bit đầu tiên bị đọc sai — và đó luôn là stop bit (bit cuối cùng của khung). Vì sao đúng bit đó vỡ trước?

Câu 3

Vì sao gọi một hàm gửi UART kiểu blocking (chờ tới khi gửi xong mới trả về, ví dụ printf) ngay bên trong một ISR là một lỗi nghiêm trọng?

Câu 4

Giao thức dòng lệnh mini của bài dùng ký tự xuống dòng ('\n') để "framing" — tách các lệnh ra khỏi dòng byte UART liên tục. Vì sao cần một cơ chế framing như vậy?

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 9 vừa thêm USART1 (SR/DR/BRR/CR1), công thức baud rate, và mô phỏng lỗi baud (uartFrameBits, uartSampleWithBaudError), 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 8: Chia sẻ dữ liệu ISR ↔ main: race condition & critical section Bài 10: ADC & thế giới tương tự Quay lại Lộ trình Series Hệ Thống Nhúng

Bình luận