Chương 24. UART, ring buffer và ngắt (ISR) an toàn
Mục tiêu
- Viết ring buffer không khoá (lock-free) an toàn cho đúng một ISR ghi, một vòng lặp chính đọc.
- Biết các ràng buộc bắt buộc của code chạy trong ISR: ngắn, không heap, không block, không ném ngoại lệ.
- Nối một ISR (yêu cầu C linkage, tên cố định) với driver viết bằng C++.
Câu chuyện: mất byte UART khi tải cao
Driver UART ban đầu chỉ lưu byte cuối cùng nhận được vào một biến volatile. Khi tốc độ baud tăng và vòng lặp chính bận xử lý việc khác, các byte đến dồn dập bị ghi đè lên nhau trước khi vòng lặp chính kịp đọc — mất dữ liệu mà không có cảnh báo nào. Giải pháp chuẩn: một hàng đợi vòng (ring buffer) giữa ISR (người sản xuất) và vòng lặp chính (người tiêu thụ), đủ lớn để hấp thụ độ trễ xử lý.
Kiến thức
1. Ring buffer lock-free cho một ISR, một vòng lặp chính
// Ring buffer khong khoa (lock-free) cho MOT nguoi ghi (ISR) va MOT nguoi doc (vong lap chinh).
// Voi dung mot writer + dung mot reader, atomic voi memory_order phu hop la du, khong can mutex/khoa ngat.
#include <array>
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <cstdio>
template <typename T, std::size_t N>
class RingBuffer {
static_assert((N & (N - 1)) == 0, "N nen la luy thua cua 2 de phep '&' thay the '%' (nhanh hon tren MCU)");
public:
// Goi TU ISR: khong bao gio block, khong cap phat, that bai am tham neu day (mat byte co kiem soat
// - tot hon la treo he thong; driver thuc te nen dem so byte bi mat de chan doan).
bool push(const T& value) {
const auto head = head_.load(std::memory_order_relaxed);
const auto next = static_cast<std::size_t>(head + 1) & (N - 1);
if (next == tail_.load(std::memory_order_acquire)) return false; // day
buf_[head] = value;
head_.store(next, std::memory_order_release);
return true;
}
// Goi TU vong lap chinh (khong phai ISR): lay ra mot phan tu neu co.
bool pop(T& out) {
const auto tail = tail_.load(std::memory_order_relaxed);
if (tail == head_.load(std::memory_order_acquire)) return false; // rong
out = buf_[tail];
tail_.store(static_cast<std::size_t>(tail + 1) & (N - 1), std::memory_order_release);
return true;
}
std::size_t size() const {
const auto h = head_.load(std::memory_order_acquire);
const auto t = tail_.load(std::memory_order_acquire);
return static_cast<std::size_t>(h - t) & (N - 1);
}
private:
std::array<T, N> buf_{};
std::atomic<std::size_t> head_{0};
std::atomic<std::size_t> tail_{0};
};
static RingBuffer<std::uint8_t, 8> g_rx_buffer; // N=8: luy thua cua 2
// Gia lap ISR nhan UART: phan cung "day" tung byte vao. Trong firmware that, ham nay
// (hoac ham no goi toi) la than cua USART1_IRQHandler (xem vi du extern_c_isr.cpp).
void simulated_uart_rx_isr(std::uint8_t byte) {
if (!g_rx_buffer.push(byte)) {
std::puts("CANH BAO: ring buffer day, mat 1 byte (can xu ly vong lap chinh nhanh hon hoac tang N)");
}
}
int main() {
const std::uint8_t frame[] = {'H', 'E', 'L', 'L', 'O'};
for (std::uint8_t b : frame) simulated_uart_rx_isr(b);
std::printf("ring buffer dang giu %zu byte\n", g_rx_buffer.size());
std::uint8_t byte;
std::printf("doc ra: ");
while (g_rx_buffer.pop(byte)) std::printf("%c", static_cast<char>(byte));
std::printf("\n");
// Lap day buffer de minh hoa canh bao mat du lieu.
for (int i = 0; i < 10; ++i) simulated_uart_rx_isr('X');
}
ring buffer dang giu 5 byte
doc ra: HELLO
CANH BAO: ring buffer day, mat 1 byte (can xu ly vong lap chinh nhanh hon hoac tang N)
CANH BAO: ring buffer day, mat 1 byte (can xu ly vong lap chinh nhanh hon hoac tang N)
CANH BAO: ring buffer day, mat 1 byte (can xu ly vong lap chinh nhanh hon hoac tang N)
Với đúng một người ghi (push, gọi từ ISR) và một người đọc (pop, gọi từ vòng lặp chính), không cần mutex hay tắt ngắt: hai chỉ số head/tail độc lập, mỗi bên chỉ sửa chỉ số của mình và chỉ đọc chỉ số của bên kia.
head_.store(..., memory_order_release)sau khi ghi dữ liệu vàobuf_, vàhead_.load(..., memory_order_acquire)trước khi đọc dữ liệu ở bên đọc: cặp release/acquire đảm bảo bên đọc thấy được dữ liệu đã ghi, không chỉ thấy chỉ số đã cập nhật (nếu không, trình biên dịch hoặc CPU có thể sắp xếp lại, khiến bên đọc thấyheadmới nhưng dữ liệu cũ).Nlà luỹ thừa của 2 để dùng& (N-1)thay cho% N— phép AND rẻ hơn chia lấy dư trên hầu hết MCU,static_assertbắt lỗi ngay nếu ai đó đổiNthành số không phải luỹ thừa 2.- Buffer đầy:
pushtrảfalsethay vì ghi đè hay chờ — mất byte có kiểm soát (với cảnh báo) luôn tốt hơn treo ISR hoặc ghi đè dữ liệu chưa đọc.
2. Quy tắc bắt buộc trong ISR
- Ngắn nhất có thể: ISR càng dài, càng trễ các ngắt khác (hoặc chính nó nếu ngắt lồng bị cấm). Đẩy dữ liệu vào ring buffer rồi thoát ngay; xử lý nặng để lại cho vòng lặp chính.
- Không cấp phát heap:
new/mallockhông xác định thời gian và có thể không an toàn khi bị ngắt giữa chừng bởi ISR khác (chương 20, 22). - Không ném ngoại lệ: nhiều toolchain nhúng build
-fno-exceptions; kể cả khi bật, unwind ngoại lệ trong ISR thường không được hỗ trợ đúng. - Biến chia sẻ phải
volatile/atomicđúng như chương 21 — thiếu, tối ưu hoá có thể khiến vòng lặp chính không bao giờ thấy dữ liệu mới. - Không gọi hàm chặn (chờ semaphore kiểu blocking,
printfra UART tốc độ thấp...) — ISR không có "thời gian chờ", nó phải trả quyền điều khiển lại cho phần cứng.
3. Nối ISR (C linkage) với driver C++
// Mau lien ket ISR (C linkage, ten ham co dinh do startup file/vector table doi hoi) voi driver viet
// bang C++. Tren MCU that, vector ngat trong startup.s tro toi dung TEN ham nay (vd "USART1_IRQHandler"),
// nen ham do PHAI co C linkage (extern "C") de ten khong bi bien doi (name mangling - xem chuong 28).
#include <cstdint>
#include <cstdio>
class UartDriver {
public:
void on_rx_isr(std::uint8_t byte) {
++rx_count_;
last_byte_ = byte;
}
std::uint32_t rx_count() const { return rx_count_; }
std::uint8_t last_byte() const { return last_byte_; }
private:
std::uint32_t rx_count_ = 0;
std::uint8_t last_byte_ = 0;
};
// Doi tuong driver: static o pham vi file, song suot chuong trinh - ISR can mot noi co dinh de goi toi,
// khong the tao doi tuong "cuc bo" cho moi lan ngat.
static UartDriver g_uart1;
// C linkage: ten "USART1_IRQHandler" giu nguyen (khong bi C++ doi ten), khop voi ten startup file mong doi.
// Than ham la C++, duoc phep goi phuong thuc cua doi tuong C++ binh thuong.
extern "C" void USART1_IRQHandler() {
// Tren phan cung that: doc thanh ghi DR/RDR cua USART1 de lay byte va xoa co ngat.
// O day gia lap bang mot bien duoc "phan cung" ghi san truoc khi goi ham nay.
static std::uint8_t simulated_hw_byte = 0;
g_uart1.on_rx_isr(simulated_hw_byte++);
}
int main() {
// Gia lap 3 lan ngat lien tiep (nhu the phan cung goi USART1_IRQHandler 3 lan).
USART1_IRQHandler();
USART1_IRQHandler();
USART1_IRQHandler();
std::printf("so byte da nhan = %u, byte cuoi = %u\n",
static_cast<unsigned>(g_uart1.rx_count()), static_cast<unsigned>(g_uart1.last_byte()));
std::puts("Luu y: day la mo phong tren host. Tren MCU that, ten ham ISR phai dung CHINH XAC");
std::puts("ten ma vector ngat cua startup file/CMSIS mong doi - kiem tra tai lieu vendor SDK.");
}
so byte da nhan = 3, byte cuoi = 2
Luu y: day la mo phong tren host. Tren MCU that, ten ham ISR phai dung CHINH XAC
ten ma vector ngat cua startup file/CMSIS mong doi - kiem tra tai lieu vendor SDK.
Startup file của vendor (thường viết bằng assembly hoặc C, sinh bởi CMSIS/CubeMX) chứa bảng vector ngắt trỏ tới các hàm theo tên cố định (ví dụ USART1_IRQHandler). C++ đổi tên hàm khi biên dịch (name mangling, chi tiết ở chương 28) để hỗ trợ nạp chồng — nếu khai báo ISR như một hàm C++ bình thường, tên sau biên dịch sẽ không khớp với tên trong bảng vector, và ngắt sẽ không bao giờ được gọi (hoặc rơi vào handler mặc định) mà trình biên dịch không báo lỗi gì. extern "C" buộc giữ nguyên tên, trong khi thân hàm vẫn là C++ bình thường — được phép gọi phương thức của đối tượng C++ (như g_uart1.on_rx_isr(...)).
Đối tượng driver (g_uart1) phải có thời gian sống toàn cục (static/global): ISR không có ngữ cảnh "gọi hàm với tham số driver" — nó được phần cứng gọi thẳng, không tham số, nên phải biết trước đối tượng nào để thao tác.
Góc nhúng
- Đo thời gian thực thi ISR bằng cách bật một chân GPIO (chương 23) ở đầu ISR và tắt ở cuối, quan sát bằng oscilloscope/logic analyzer — cách đo thực tế thay vì đoán.
- Kích thước ring buffer đánh đổi RAM lấy khả năng chịu trễ xử lý; tính toán dựa trên tốc độ baud và thời gian xử lý tệ nhất của vòng lặp chính, không chọn số tuỳ tiện.
- Tên hàm ISR chính xác (
USART1_IRQHandler,EXTI0_IRQHandler...) nằm trong file khởi động của vendor SDK (CMSIS) — luôn tra đúng tên cho MCU cụ thể, sách này không thể liệt kê hết cho mọi dòng chip.
Lỗi thường gặp
- Quên
extern "C"trên hàm ISR — trình biên dịch build thành công (nó chỉ là một hàm C++ bình thường "mồ côi", không ai gọi), nhưng ngắt không bao giờ chạy, và lỗi này khó phát hiện vì không có thông báo nào. - Gọi hàm có thể block (chờ mutex, gửi UART đồng bộ tốc độ thấp) bên trong ISR.
- Dùng ring buffer nhiều-người-ghi hoặc nhiều-người-đọc mà không thêm khoá — thiết kế trong chương này chỉ đúng cho một ghi/một đọc.
- Không kiểm tra giá trị trả về của
push, âm thầm mất dữ liệu mà không log/đếm để chẩn đoán sau này.
Bài tập
- Thêm bộ đếm
dropped_countvàoRingBuffer, tăng mỗi khipushthất bại; in ra trongmain. - Viết phiên bản
pop_allđọc hết dữ liệu hiện có vào mộtstd::array(dùng cho vòng lặp chính xử lý theo lô thay vì từng byte). - Giải thích vì sao
RingBuffer<T, N>trong chương này không an toàn nếu có hai ISR khác nhau cùng gọipush(ví dụ UART1 và UART2 dùng chung một buffer) — và nêu cách sửa tối thiểu. - Tìm trong tài liệu CMSIS/reference manual của một MCU bất kỳ (STM32, nRF52...) tên chính xác của ISR nhận dữ liệu UART, đối chiếu với ví dụ trong chương.
Tóm tắt
- Ring buffer một-ghi/một-đọc với
std::atomic+memory_orderđúng cặp là mẫu chuẩn để trao dữ liệu giữa ISR và vòng lặp chính mà không cần khoá. - ISR phải ngắn, không heap, không ngoại lệ, không block; xử lý nặng luôn để lại cho vòng lặp chính.
- Hàm ISR cần
extern "C"để giữ đúng tên mà bảng vector ngắt của startup file mong đợi.