Chương 21. volatile và Memory-Mapped I/O

Mục tiêu

  • Hiểu chính xác volatile bá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 volatile thay vì địa chỉ rời rạc.
  • Phân biệt volatile với std::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 volatile so với nhau (không sắp xếp lại việc đọc/ghi hai biến volatile khá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++ (volatile khô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.
  • -O2 có thể sắp xếp lại các truy cập không phải volatile; 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 volatile trê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 volatile cho 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áo const volatile.

Bài tập

  1. Thêm thanh ghi pupdr (pull-up/pull-down) vào GpioRegs đú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.
  2. Đánh dấu idr là const volatile std::uint32_t (chỉ đọc) và thử ghi vào nó — đọc thông báo lỗi biên dịch.
  3. 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 -O0 và -O2 có sinh assembly khác nhau cho vòng lặp chờ hay không nếu bỏ volatile.
  4. 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òn volatile 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

  • volatile buộc đọc/ghi thật và giữ thứ tự tương đối giữa các truy cập volatile; 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ùng std::atomic.