Chương 21. volatile và Memory-Mapped I/O
Mục tiêu
- Hiểu chính xác
volatilebáo điều gì cho trình biên dịch, và vì sao thiếu nó là lỗi im lặng. - Mô hình hoá khối thanh ghi ngoại vi (GPIO, UART...) bằng struct
volatilethay vì địa chỉ rời rạc. - Phân biệt
volatilevớistd::atomic: cái nào giải quyết vấn đề gì, và khi nào cần cả hai.
Câu chuyện: vòng lặp chờ cờ không bao giờ dừng
bool ready = false;
while (!ready) { /* cho ngat bao "xong" */ }
Trên bàn debug, biến này được ISR đặt true. Nhưng ở bản release (-O2), chương trình treo mãi. Trình biên dịch thấy ready không hề bị gán bên trong vòng lặp (theo góc nhìn của nó — nó không biết ISR chạy "song song"), nên tối ưu hoá thành if (!ready) while(true); — chỉ đọc ready một lần. Thêm volatile bool ready báo: "giá trị này có thể đổi bởi thứ nằm ngoài luồng thực thi bình thường — đọc lại thật mỗi lần, đừng cache trong thanh ghi CPU."
Kiến thức
1. volatile nói gì và không nói gì
volatile T buộc trình biên dịch:
- Đọc thật từ bộ nhớ mỗi lần truy cập (không dùng giá trị đã cache trong thanh ghi CPU hay tối ưu bỏ đọc "trùng lặp").
- Ghi thật mỗi lần gán (không gộp/loại bỏ các lần ghi liên tiếp mà nó cho là "thừa").
- Giữ thứ tự các truy cập
volatileso với nhau (không sắp xếp lại việc đọc/ghi hai biếnvolatilekhác nhau).
volatile không đảm bảo:
- Tính nguyên tử (atomicity): đọc-sửa-ghi một biến
volatile(x++) vẫn có thể bị chen ngang giữa chừng bởi ISR hay lõi khác. - Rào chắn bộ nhớ (memory barrier) đầy đủ giữa CPU đa lõi: trên hệ đa lõi, thứ tự nhìn thấy bởi lõi khác cần thêm cơ chế đồng bộ (atomic với
memory_order, hoặc lệnh rào chắn của kiến trúc). - Rằng biến đó là thread-safe theo nghĩa chuẩn C++ (
volatilekhông phải công cụ đồng bộ hoá đa luồng — dù nó vẫn cần thiết khi biến được đổi bởi ISR).
2. Memory-Mapped I/O (MMIO): thanh ghi phần cứng là địa chỉ bộ nhớ
// Mo hinh mot khoi thanh ghi ngoai vi (peripheral) bang struct co truong 'volatile', anh xa vao mot
// vung nho gia lap (tren MCU that day se la mot dia chi co dinh, vd 0x40020000 cho GPIOA tren STM32).
#include <cstdint>
#include <cstdio>
struct GpioRegs {
volatile std::uint32_t moder; // che do chan: 00 input, 01 output, ...
volatile std::uint32_t odr; // gia tri ngo ra
volatile std::uint32_t idr; // gia tri ngo vao (chi doc, phan cung ghi)
};
// Tren MCU that: GpioRegs* const GPIOA = reinterpret_cast<GpioRegs*>(0x48000000);
// Tren host: gia lap bang mot vung nho tinh dong vai tro "thanh ghi phan cung".
static GpioRegs g_fake_gpio{};
static GpioRegs* const GPIOA = &g_fake_gpio;
constexpr std::uint32_t bit(unsigned n) { return 1u << n; }
void gpio_set_output(GpioRegs* gpio, unsigned pin) {
gpio->moder |= bit(pin * 2); // set bit thap cua cap 2 bit (che do output = 01)
gpio->moder &= ~bit(pin * 2 + 1); // xoa bit cao
}
void gpio_write(GpioRegs* gpio, unsigned pin, bool high) {
if (high) gpio->odr |= bit(pin);
else gpio->odr &= ~bit(pin);
}
int main() {
gpio_set_output(GPIOA, 5);
std::printf("MODER sau khi cau hinh chan 5 lam output = 0x%08X\n", static_cast<unsigned>(GPIOA->moder));
gpio_write(GPIOA, 5, true);
std::printf("ODR sau khi bat chan 5 = 0x%08X\n", static_cast<unsigned>(GPIOA->odr));
gpio_write(GPIOA, 5, false);
std::printf("ODR sau khi tat chan 5 = 0x%08X\n", static_cast<unsigned>(GPIOA->odr));
// Gia lap phan cung ghi vao IDR (nhu the mot chan input vua doi trang thai).
GPIOA->idr = bit(3);
std::printf("IDR (phan cung bao chan 3 dang muc cao) = 0x%08X\n", static_cast<unsigned>(GPIOA->idr));
}
MODER sau khi cau hinh chan 5 lam output = 0x00000400
ODR sau khi bat chan 5 = 0x00000020
ODR sau khi tat chan 5 = 0x00000000
IDR (phan cung bao chan 3 dang muc cao) = 0x00000008
Trên hầu hết MCU, thanh ghi ngoại vi không phải RAM thật — đọc/ghi một địa chỉ cố định (ví dụ 0x48000014) sẽ được bus định tuyến tới mạch điều khiển GPIO thay vì ô nhớ. Với C++, cách sạch nhất là một struct có các trường volatile, đặt đúng thứ tự và kiểu khớp với datasheet (nhắc lại chương 11: không có đệm bất ngờ), rồi ép một con trỏ hằng tới địa chỉ cơ sở:
GpioRegs* const GPIOA = reinterpret_cast<GpioRegs*>(0x48000000);
So với cách viết #define/con trỏ rời rạc cho từng thanh ghi (chương 1), struct nhóm các thanh ghi liên quan lại, cho phép truyền GpioRegs* như một "handle" tới hàm dùng chung cho nhiều cổng GPIO (gpio_set_output(GpioRegs*, unsigned pin) dùng được cho GPIOA, GPIOB, ...) — vẫn không có chi phí runtime nào so với thao tác con trỏ tay.
Vì sao mỗi trường phải là volatile, không phải cả con trỏ? volatile std::uint32_t moder nghĩa là giá trị tại địa chỉ đó có thể đổi bất ngờ (đúng bản chất phần cứng); con trỏ GpioRegs* bản thân nó không cần volatile vì địa chỉ cơ sở không đổi.
3. volatile so với std::atomic
// So sanh 'volatile' don gian (co ban dung duoc cho co mot-nguoi-ghi/mot-nguoi-doc) voi std::atomic
// (dam bao them ve tinh nguyen tu va rao chan bo nho, can khi co doc-sua-ghi hoac nhieu loi/nhieu luong).
#include <atomic>
#include <cstdint>
#include <cstdio>
// Co bao "co du lieu moi" - chi MOT noi ghi (ISR gia lap), MOT noi doc (vong lap chinh).
// volatile la du: dam bao trinh bien dich khong cache gia tri trong thanh ghi CPU, luon doc/ghi that.
static volatile bool g_data_ready = false;
static volatile std::uint16_t g_last_sample = 0;
// Gia lap ISR: goi tu "phan cung" bat cu luc nao, khong dong bo voi vong lap chinh.
void simulated_isr(std::uint16_t sample) {
g_last_sample = sample;
g_data_ready = true; // co dat SAU khi du lieu da san sang: thu tu nay quan trong
}
// Bo dem chia se, CAN atomic vi ca ISR lan vong lap chinh cung TANG no (doc-sua-ghi, khong an toan
// voi volatile don thuan neu chay tren loi khac hoac bi ngat giua chung tren cung mot loi).
static std::atomic<std::uint32_t> g_irq_count{0};
void simulated_isr_counting() {
g_irq_count.fetch_add(1, std::memory_order_relaxed);
}
int main() {
std::puts("-- volatile: mot noi ghi, mot noi doc --");
simulated_isr(2048);
if (g_data_ready) {
std::printf("nhan mau moi: %u\n", static_cast<unsigned>(g_last_sample));
g_data_ready = false;
}
std::puts("-- atomic: doc-sua-ghi an toan hon khi co tranh chap --");
for (int i = 0; i < 5; ++i) simulated_isr_counting();
std::printf("so lan ngat da dem = %u\n", static_cast<unsigned>(g_irq_count.load()));
std::printf("sizeof(atomic<uint32_t>) = %zu (thuong bang sizeof(uint32_t), kiem tra .is_lock_free() tren toolchain that)\n",
sizeof(g_irq_count));
std::printf("atomic co lock-free tren kieu nay? %s\n", g_irq_count.is_lock_free() ? "co" : "khong (co the can khoa)");
}
-- volatile: mot noi ghi, mot noi doc --
nhan mau moi: 2048
-- atomic: doc-sua-ghi an toan hon khi co tranh chap --
so lan ngat da dem = 5
sizeof(atomic<uint32_t>) = 4 (thuong bang sizeof(uint32_t), kiem tra .is_lock_free() tren toolchain that)
atomic co lock-free tren kieu nay? co
volatile |
std::atomic |
|
|---|---|---|
| Mục đích | Nói "đừng tối ưu, đọc/ghi thật" (dùng cho MMIO, cờ đơn giản) | Đảm bảo nguyên tử + thứ tự bộ nhớ giữa các luồng/lõi |
| Đọc-sửa-ghi an toàn? | Không | Có (fetch_add, compare_exchange...) |
| Chi phí | Không thêm lệnh (chỉ ảnh hưởng tối ưu hoá) | Có thể thêm lệnh rào chắn/khoá tuỳ kiến trúc |
| Dùng cho thanh ghi phần cứng | Đúng — bản chất MMIO là "đọc/ghi thật, không cache" | Không cần thiết, đôi khi không hợp lệ với vùng MMIO |
| Dùng cho biến chia sẻ ISR ↔ main, chỉ đọc/ghi đơn giản một hướng | Đủ (một người ghi, một người đọc, kiểu đủ nhỏ để đọc/ghi nguyên tử tự nhiên trên kiến trúc đó) | Cũng đúng, và rõ ràng hơn về ý định |
| Dùng cho bộ đếm được cả ISR lẫn main cùng tăng | Không đủ (đọc-sửa-ghi không nguyên tử) | Cần |
Quy tắc thực dụng: thanh ghi phần cứng → volatile; biến chia sẻ với ISR/luồng khác mà có đọc-sửa-ghi hoặc cần đảm bảo thứ tự → std::atomic. Nhiều dự án dùng cả hai cho cùng một biến khi cần (ví dụ volatile std::atomic<uint32_t> cho thanh ghi vừa MMIO vừa có thao tác đọc-sửa-ghi từ phần mềm — hiếm, nhưng có).
Góc nhúng
- Đọc-sửa-ghi thanh ghi (
gpio->moder |= ...) không phải phép toán nguyên tử: nếu cả main loop lẫn ISR cùng sửa cùng một thanh ghi, cần tắt ngắt tạm thời (critical section) quanh thao tác đó — chương về ngắt (Phần 6) đi sâu hơn. - Trên Cortex-M, một số thanh ghi hỗ trợ bit-banding hoặc thanh ghi
BSRR(set/reset riêng) để set/clear từng bit bằng một lệnh ghi nguyên tử phần cứng, tránh hẳn nhu cầu đọc-sửa-ghi cho thao tác đơn giản — kiểm tra datasheet MCU của bạn. -O2có thể sắp xếp lại các truy cập không phảivolatile; luôn kiểm tra assembly (Compiler Explorer/objdump -d) khi nghi ngờ trình tự đọc/ghi thanh ghi không đúng như ý.
Lỗi thường gặp
- Quên
volatiletrên thanh ghi phần cứng hoặc cờ chia sẻ với ISR — lỗi chỉ lộ ra ở bản tối ưu hoá, không lộ ở-O0(nên phải test cả build release). - Dùng
volatilecho biến được nhiều luồng cùng đọc-sửa-ghi, tưởng vậy là "an toàn luồng" — vẫn có race condition. - Định nghĩa struct MMIO mà không kiểm tra đệm/thứ tự trường khớp datasheet (chương 11).
- Đọc
IDR(chỉ đọc theo phần cứng) rồi lỡ tay ghi vào nó trong code — biên dịch không báo lỗi trừ khi bạn khai báoconst volatile.
Bài tập
- Thêm thanh ghi
pupdr(pull-up/pull-down) vàoGpioRegsđúng vị trí offset của STM32 thật (tra datasheet) và viết hàm cấu hình pull-up cho một chân. - Đánh dấu
idrlàconst volatile std::uint32_t(chỉ đọc) và thử ghi vào nó — đọc thông báo lỗi biên dịch. - Viết một cờ
volatile boolđược ISR giả lập đặt và vòng lặp chính xoá; đo bằng Compiler Explorer xem-O0và-O2có sinh assembly khác nhau cho vòng lặp chờ hay không nếu bỏvolatile. - Giải thích vì sao
std::atomic<uint32_t> counter; counter++;an toàn khi cả ISR và main cùng gọi, cònvolatile uint32_t counter; counter++;thì không, dù cả hai đều "nhìn thấy" giá trị mới nhất.
Tóm tắt
volatilebuộc đọc/ghi thật và giữ thứ tự tương đối giữa các truy cậpvolatile; nó không đảm bảo tính nguyên tử hay đồng bộ đa luồng.- Mô hình hoá thanh ghi ngoại vi bằng struct có trường
volatile, ép kiểu một lần từ địa chỉ cơ sở — sạch và tái dùng được hơn định nghĩa rời rạc. - Thanh ghi phần cứng dùng
volatile; biến chia sẻ cần đọc-sửa-ghi an toàn giữa ISR/luồng dùngstd::atomic.