임베디드 C++의 volatile: MMIO 레지스터 접근, ISR 공유 변수, 릴리스 빌드에서 사라지는 읽기

들어가며: 릴리스 빌드에서만 사라지는 레지스터 접근

임베디드 코드에서 자주 만나는 증상은 비슷합니다. UART 상태 플래그를 기다리는 while 루프가 디버거로는 플래그가 바뀌는 게 보이는데 릴리스 빌드에서는 빠져나오지 않습니다. LED를 깜빡이려고 GPIO 레지스터에 반복해서 쓰는데 -O2에서는 전혀 반응이 없고, 주기적으로 Watchdog 레지스터에 쓰는 코드가 있는데도 시스템이 리셋됩니다. 인터럽트 서비스 루틴(ISR)에서 data_ready = true를 설정해도 메인 루프에는 영원히 false로 보이기도 합니다.

원인은 모두 같습니다. 컴파일러는 일반 메모리라고 생각한 위치에 대해 “이 루프 안에서는 값이 바뀌지 않는다”, “같은 값을 두 번 쓰는 것은 한 번과 같다”, “아무도 읽지 않는 쓰기는 필요 없다”고 판단하고, 읽은 값을 레지스터에 보관해 재사용하거나 쓰기를 합치거나 없앱니다. 하드웨어 레지스터는 프로그램 밖에서 값이 바뀌고, 쓰기 자체가 장치를 움직이는 부수 효과라서 이런 최적화가 동작을 망가뜨립니다.

이 글에서는 volatile로 이런 접근을 보존하는 방법, 메모리 맵 I/O(MMIO)로 레지스터를 다루는 패턴, ISR과 메인 루프가 데이터를 안전하게 주고받는 방법을 다룹니다.

flowchart TB
    subgraph CPU[일반 메모리 가정]
        C1[일반 변수] --> C2[컴파일러 최적화]
        C2 --> C3[접근 제거·병합·재사용]
    end
    subgraph HW[하드웨어]
        H1[메모리 맵 레지스터] --> H2[값이 외부에서 바뀜]
        H2 --> H3[쓰기 자체가 부수 효과]
    end
    subgraph Volatile[volatile]
        V1[접근 보존] --> V2[매번 실제 메모리 접근]
    end
    C1 -.->|문제| H1
    V1 -->|해결| H1

volatile

volatile 객체에 대한 읽기와 쓰기는 프로그램의 관찰 가능한 동작(observable behavior)으로 취급됩니다. 컴파일러는 소스에 적힌 횟수와 순서대로 접근을 만들어야 하므로, 루프에서 한 번 읽은 값을 재사용하거나 연속된 쓰기를 하나로 합칠 수 없습니다. 다만 이 순서 보장은 volatile 접근끼리에만 적용되고, volatile이 아닌 일반 메모리 접근은 여전히 그 앞뒤로 옮겨질 수 있습니다.

// ❌ volatile 없음: -O2에서 상태 레지스터를 한 번만 읽고 무한 루프가 될 수 있음
uint32_t* uart_status = reinterpret_cast<uint32_t*>(0x40001004);
void wait_for_data_bad() {
    while ((*uart_status & 0x01) == 0) {}
}

// ✅ volatile: 매 반복마다 레지스터를 다시 읽음
volatile uint32_t* const uart_status_v = reinterpret_cast<volatile uint32_t*>(0x40001004);
void wait_for_data_good() {
    while ((*uart_status_v & 0x01) == 0) {}
}

volatile의 한계

volatile은 스레드 동기화 도구가 아닙니다. counter++ 같은 읽기-수정-쓰기를 원자적으로 만들어 주지 않고, CPU가 메모리 접근 순서를 바꾸는 것(멀티코어의 메모리 모델)도 막지 않습니다. 용도를 나누면 다음과 같습니다.

용도volatilestd::atomic
메모리 맵 레지스터적합부적합 (하드웨어 주소에 atomic 객체를 둘 수 없음)
ISR과 메인 루프의 단순 플래그단일 코어에서 플래그 하나뿐이면 동작은 하지만 순서 보장이 약함권장 (lock-free인지 확인)
멀티스레드·멀티코어 공유 데이터부적합필수

기본 UART 레지스터 접근

#include <cstdint>

// 가상의 UART: 0x00 = DATA, 0x04 = STATUS
volatile uint32_t* const uart_data   = reinterpret_cast<volatile uint32_t*>(0x40001000);
volatile uint32_t* const uart_status = reinterpret_cast<volatile uint32_t*>(0x40001004);
constexpr uint32_t STATUS_RX_READY = 1u << 0;
constexpr uint32_t STATUS_TX_EMPTY = 1u << 5;

void uart_send_char(char c) {
    while ((*uart_status & STATUS_TX_EMPTY) == 0) {}
    *uart_data = static_cast<uint8_t>(c);
}

char uart_recv_char() {
    while ((*uart_status & STATUS_RX_READY) == 0) {}
    return static_cast<char>(*uart_data);  // 읽기 자체가 수신 FIFO를 진행시킴
}

데이터 레지스터는 읽을 때마다 수신 FIFO의 다음 바이트가 나오고, 쓰기는 송신을 시작합니다. 읽은 값을 쓰지 않더라도 읽기 자체가 의미가 있으므로 반드시 volatile로 접근해야 합니다.


메모리 맵 I/O

MMIO는 CPU가 특정 주소를 읽고 쓰면 실제로는 장치 레지스터와 통신하는 방식입니다. 데이터시트는 레지스터를 “베이스 주소 + 오프셋”으로 정의하므로, 구조체로 레이아웃을 맞추거나 주소 상수로 접근합니다.

레지스터 구조체

#include <cstddef>
#include <cstdint>

// 데이터시트: 베이스 0x40000000, 0x00=DATA, 0x04=STATUS, 0x08=CTRL
struct UartRegisters {
    volatile uint32_t data;    // 0x00
    volatile uint32_t status;  // 0x04
    volatile uint32_t ctrl;    // 0x08
};
static_assert(offsetof(UartRegisters, data)   == 0x00);
static_assert(offsetof(UartRegisters, status) == 0x04);
static_assert(offsetof(UartRegisters, ctrl)   == 0x08);
static_assert(sizeof(UartRegisters) == 0x0C);

inline UartRegisters* const uart = reinterpret_cast<UartRegisters*>(0x4000'0000);

void uart_init() { uart->ctrl = 0x03; }  // 장치에 맞는 TX/RX 활성화 비트

레지스터 사이에 예약 영역이 있거나 폭이 다른 레지스터가 섞이면, 컴파일러가 넣는 정렬 패딩 때문에 오프셋이 데이터시트와 어긋날 수 있습니다. 예약 영역은 uint32_t reserved0[2];처럼 명시적인 멤버로 채우고, static_assert로 모든 오프셋을 검증합니다. #pragma pack이나 __attribute__((packed))로 패딩을 없애는 방법은 피하는 편이 좋습니다. 정렬을 가정할 수 없게 된 컴파일러가 32비트 레지스터를 바이트 단위 접근 네 번으로 쪼개 만들 수 있는데, 많은 장치가 이런 부분 접근을 지원하지 않거나 다르게 동작하기 때문입니다. 이런 이유로 실제 MCU 벤더 헤더(CMSIS 등)도 예약 필드와 자연 정렬로 레이아웃을 맞춥니다.

읽기-수정-쓰기

volatile uint32_t* const gpio_odr = reinterpret_cast<volatile uint32_t*>(0x40020014);

void gpio_set_pin_rmw(uint32_t pin) {
    uint32_t val = *gpio_odr;   // 읽기
    val |= (1u << pin);         // 수정
    *gpio_odr = val;            // 쓰기
}

읽기와 쓰기 사이에 인터럽트 핸들러가 같은 레지스터의 다른 비트를 바꾸면, 위 코드가 오래된 값으로 덮어써 그 변경을 지워 버립니다. 단일 코어에서는 이 구간 동안 인터럽트를 막으면 안전하고, STM32의 BSRR처럼 특정 비트만 바꾸는 전용 레지스터가 있으면 그것을 쓰는 것이 가장 좋습니다. ARM의 배타적 접근 명령(LDREX/STREX)은 일반 메모리용이며, 장치 메모리 영역에서는 지원되지 않는 경우가 많으므로 레지스터 RMW의 해결책으로 기대하지 않는 편이 안전합니다.

메모리 배리어

volatile은 컴파일러의 재배치만 제한하고, CPU나 버스가 접근 순서를 바꾸는 것은 막지 않습니다. Cortex-M의 장치 메모리 영역은 같은 영역 안의 접근 순서가 유지되도록 정의되어 있어 대부분 추가 조치가 필요 없지만, “일반 메모리에 DMA 디스크립터를 쓰고 나서 DMA 시작 레지스터에 쓰기”처럼 일반 메모리와 장치 메모리 사이의 순서가 중요하면 배리어가 필요합니다.

// CMSIS 환경: DMA 디스크립터(일반 메모리)를 다 쓴 뒤 장치 레지스터에 시작 비트 쓰기
fill_descriptor(&desc);
__DMB();                 // 앞의 메모리 쓰기가 뒤의 쓰기보다 먼저 보이도록
dma->ctrl = DMA_START;

std::atomic_thread_fence는 C++ 메모리 모델의 일반 메모리 순서를 위한 것이라, 장치 메모리에 대한 효과는 플랫폼마다 다릅니다. 장치 순서가 중요한 코드는 플랫폼이 제공하는 배리어(__DMB, __DSB, 리눅스의 wmb() 등)를 쓰는 것이 명확합니다.


인터럽트 서비스 루틴 (ISR)

ISR은 인터럽트가 발생하면 실행 중이던 코드를 멈추고 호출됩니다. ISR이 길면 다른 인터럽트가 지연되므로, 데이터를 받아 버퍼에 넣고 플래그를 세우는 정도만 하고 실제 처리는 메인 루프에 맡기는 것이 원칙입니다.

sequenceDiagram
    participant HW as 하드웨어
    participant ISR as ISR
    participant Main as 메인 루프
    HW->>ISR: 인터럽트 발생
    ISR->>ISR: 데이터를 버퍼에 넣고 플래그 설정
    ISR->>Main: 복귀
    Main->>Main: 플래그 확인 후 실제 처리

ISR 안에서 피해야 할 것은 다음과 같습니다. malloc/new는 많은 구현이 내부 락이나 전역 상태를 쓰므로, 메인 루프가 할당하는 도중에 인터럽트가 들어와 ISR이 다시 할당하면 데드락이나 힙 손상이 납니다. std::mutex 같은 락은 메인 루프가 락을 쥔 상태에서 ISR이 같은 락을 기다리면 ISR이 끝나지 않으므로 데드락이 됩니다. 예외는 많은 임베디드 빌드에서 꺼져 있고, 긴 루프는 실시간성을 해칩니다.

ISR과 메인 루프가 공유하는 변수

#include <atomic>
#include <cstdint>

std::atomic<bool> data_ready{false};
uint8_t received_byte = 0;   // 플래그로 보호되는 데이터

// Cortex-M에서는 벡터 테이블에 등록된 일반 함수가 ISR이 됨
extern "C" void USART1_IRQHandler() {
    received_byte = static_cast<uint8_t>(*uart_data);
    data_ready.store(true, std::memory_order_release);
}

void main_loop() {
    while (true) {
        if (data_ready.load(std::memory_order_acquire)) {
            process(received_byte);
            data_ready.store(false, std::memory_order_relaxed);
        }
    }
}

플래그를 std::atomic으로 두고 release/acquire로 저장·읽기하면, 플래그가 true로 보일 때 그 전에 쓴 received_byte도 보인다는 것이 보장됩니다. volatile bool 플래그와 일반 변수인 데이터를 섞으면, 컴파일러가 데이터 쓰기를 플래그 쓰기 뒤로 옮기는 것을 막지 못합니다. ISR에서 쓰는 atomic은 반드시 lock-free여야 하므로(static_assert(std::atomic<bool>::is_always_lock_free)), 8비트 AVR처럼 atomic 지원이 제한된 환경에서는 인터럽트를 잠시 막는 임계 구역으로 대신합니다. 이 예제는 데이터가 한 바이트라 ISR이 다음 바이트로 덮어쓸 수 있으므로, 연속 수신에는 아래의 링 버퍼를 씁니다.


UART 에코, GPIO, 링 버퍼, 타이머, SPI 예제

UART 에코 (폴링, STM32F4 USART1 기준)

#include <cstdint>

constexpr uintptr_t USART1_BASE = 0x4001'1000;
volatile uint32_t* const USART1_SR  = reinterpret_cast<volatile uint32_t*>(USART1_BASE + 0x00);
volatile uint32_t* const USART1_DR  = reinterpret_cast<volatile uint32_t*>(USART1_BASE + 0x04);
volatile uint32_t* const USART1_CR1 = reinterpret_cast<volatile uint32_t*>(USART1_BASE + 0x0C);

constexpr uint32_t SR_RXNE = 1u << 5;   // 수신 데이터 있음
constexpr uint32_t SR_TXE  = 1u << 7;   // 송신 데이터 레지스터 비어 있음
constexpr uint32_t CR1_UE  = 1u << 13;  // USART 활성화
constexpr uint32_t CR1_TE  = 1u << 3;   // 송신 활성화
constexpr uint32_t CR1_RE  = 1u << 2;   // 수신 활성화

void uart_init() {
    // 실제로는 RCC 클럭 활성화, GPIO 대체 기능 설정, BRR(보레이트) 설정이 먼저 필요
    *USART1_CR1 = CR1_UE | CR1_TE | CR1_RE;
}

void uart_putc(char c) {
    while ((*USART1_SR & SR_TXE) == 0) {}
    *USART1_DR = static_cast<unsigned char>(c);
}

char uart_getc() {
    while ((*USART1_SR & SR_RXNE) == 0) {}
    return static_cast<char>(*USART1_DR);
}

void echo_loop() {
    uart_init();
    while (true) uart_putc(uart_getc());
}

레지스터 주소와 비트 위치는 MCU 계열마다 다르므로 실제 코드에서는 해당 칩의 레퍼런스 매뉴얼이나 벤더 헤더의 정의를 씁니다.

GPIO 제어 (STM32F4 GPIO 레이아웃)

#include <cstddef>
#include <cstdint>

struct GpioRegs {
    volatile uint32_t moder;    // 0x00 모드
    volatile uint32_t otyper;   // 0x04 출력 타입
    volatile uint32_t ospeedr;  // 0x08 속도
    volatile uint32_t pupdr;    // 0x0C 풀업/풀다운
    volatile uint32_t idr;      // 0x10 입력 데이터
    volatile uint32_t odr;      // 0x14 출력 데이터
    volatile uint32_t bsrr;     // 0x18 비트 set/reset
};
static_assert(offsetof(GpioRegs, idr)  == 0x10);
static_assert(offsetof(GpioRegs, bsrr) == 0x18);

inline GpioRegs* const gpioA = reinterpret_cast<GpioRegs*>(0x4002'0000);

// BSRR: 하위 16비트에 1을 쓰면 set, 상위 16비트에 1을 쓰면 reset
// 쓰기 한 번으로 해당 비트만 바뀌므로 읽기-수정-쓰기 경쟁이 없음
void gpio_set(uint32_t pin)   { gpioA->bsrr = (1u << pin); }
void gpio_reset(uint32_t pin) { gpioA->bsrr = (1u << (pin + 16)); }

void gpio_toggle(uint32_t pin) {
    if (gpioA->odr & (1u << pin)) gpio_reset(pin);
    else gpio_set(pin);
}

레지스터 하나(pupdr)를 빠뜨리면 그 뒤 모든 멤버의 오프셋이 4바이트씩 밀려 idr을 읽는 코드가 실제로는 다른 레지스터를 읽게 됩니다. static_assert가 이런 실수를 컴파일 시점에 잡아 줍니다.

링 버퍼 + ISR

#include <atomic>
#include <cstddef>
#include <cstdint>

constexpr std::size_t RX_BUF_SIZE = 64;

struct RingBuffer {
    uint8_t buffer[RX_BUF_SIZE];
    std::atomic<std::size_t> head{0};  // ISR만 씀
    std::atomic<std::size_t> tail{0};  // 메인 루프만 씀
};
static_assert(std::atomic<std::size_t>::is_always_lock_free);

RingBuffer rx_buf;

extern "C" void USART1_IRQHandler() {
    uint8_t byte = static_cast<uint8_t>(*USART1_DR);
    std::size_t head = rx_buf.head.load(std::memory_order_relaxed);
    std::size_t next = (head + 1) % RX_BUF_SIZE;
    if (next != rx_buf.tail.load(std::memory_order_acquire)) {  // 가득 차면 버림
        rx_buf.buffer[head] = byte;
        rx_buf.head.store(next, std::memory_order_release);     // 데이터 쓰기 후 공개
    }
}

bool uart_get_byte(uint8_t& out) {
    std::size_t tail = rx_buf.tail.load(std::memory_order_relaxed);
    if (tail == rx_buf.head.load(std::memory_order_acquire)) return false;
    out = rx_buf.buffer[tail];
    rx_buf.tail.store((tail + 1) % RX_BUF_SIZE, std::memory_order_release);
    return true;
}

ISR만 head를 쓰고 메인 루프만 tail을 쓰는 단일 생산자·단일 소비자 구조라 락이 필요 없습니다. 핵심은 순서입니다. ISR은 버퍼에 데이터를 쓴 다음 head를 release로 갱신하고, 메인 루프는 head를 acquire로 읽은 다음 데이터를 읽습니다. head와 tail을 volatile size_t로만 두면 버퍼 원소(일반 변수) 쓰기가 head 갱신 뒤로 옮겨질 수 있어, 메인 루프가 아직 쓰이지 않은 바이트를 읽을 수 있습니다.

타이머 카운터로 지연

#include <cstdint>

// Cortex-M3/M4/M7의 DWT 사이클 카운터: 32비트, 증가 방향
volatile uint32_t* const DEMCR      = reinterpret_cast<volatile uint32_t*>(0xE000'EDFC);
volatile uint32_t* const DWT_CTRL   = reinterpret_cast<volatile uint32_t*>(0xE000'1000);
volatile uint32_t* const DWT_CYCCNT = reinterpret_cast<volatile uint32_t*>(0xE000'1004);

void cycle_counter_init() {
    *DEMCR |= (1u << 24);   // TRCENA: 트레이스 블록 활성화
    *DWT_CYCCNT = 0;
    *DWT_CTRL |= 1u;        // CYCCNTENA
}

void delay_cycles(uint32_t cycles) {
    uint32_t start = *DWT_CYCCNT;
    while ((*DWT_CYCCNT - start) < cycles) {}  // 부호 없는 뺄셈이라 오버플로도 처리됨
}

카운터 값은 매 클럭 바뀌므로 volatile 없이 읽으면 루프 밖에서 한 번만 읽혀 무한 루프가 됩니다. SysTick의 현재 값 레지스터(0xE000E018)는 24비트 감소 카운터라 같은 방식으로 쓰면 계산이 틀리므로, SysTick을 쓴다면 감소 방향과 리로드 값을 고려해야 합니다. Cortex-M0/M0+에는 DWT 사이클 카운터가 없습니다.

SPI 전송 (STM32F4 SPI2 기준)

volatile uint32_t* const SPI2_SR = reinterpret_cast<volatile uint32_t*>(0x4000'3808);
volatile uint32_t* const SPI2_DR = reinterpret_cast<volatile uint32_t*>(0x4000'380C);
constexpr uint32_t SPI_SR_RXNE = 1u << 0;
constexpr uint32_t SPI_SR_TXE  = 1u << 1;

uint8_t spi_transfer(uint8_t byte) {
    while ((*SPI2_SR & SPI_SR_TXE) == 0) {}
    *SPI2_DR = byte;
    while ((*SPI2_SR & SPI_SR_RXNE) == 0) {}
    return static_cast<uint8_t>(*SPI2_DR);  // 전이중: 보낸 만큼 받음
}

SPI는 보내는 동시에 받으므로, 수신 데이터를 쓰지 않더라도 DR을 읽어 RXNE를 비워야 다음 전송에서 오버런 에러가 나지 않습니다.


자주 만나는 문제

디버그에서는 되는데 릴리스에서 안 됨

-O0은 거의 모든 메모리 접근을 소스대로 만들기 때문에 volatile이 빠져 있어도 우연히 동작합니다. -O2 이상에서는 폴링 루프의 읽기가 루프 밖으로 빠지고, 같은 주소에 대한 연속 쓰기가 하나로 합쳐집니다. MMIO 포인터는 처음부터 volatile로 선언하고, 릴리스 빌드로 테스트하는 습관이 필요합니다.

// ❌ -O2에서 폴링 읽기가 한 번으로 줄고, 두 번의 쓰기가 하나로 합쳐질 수 있음
uint32_t* status = reinterpret_cast<uint32_t*>(0x40001004);
uint32_t* gpio   = reinterpret_cast<uint32_t*>(0x40010000);
while ((*status & 1) == 0) {}
*gpio = 1; *gpio = 1;

// ✅ 모든 접근이 소스대로 일어남
volatile uint32_t* const status_v = reinterpret_cast<volatile uint32_t*>(0x40001004);
volatile uint32_t* const gpio_v   = reinterpret_cast<volatile uint32_t*>(0x40010000);
while ((*status_v & 1) == 0) {}
*gpio_v = 1; *gpio_v = 1;

Watchdog 리셋처럼 특정 값을 정해진 순서로 써야 하는 레지스터(예: 0x5555 다음 0xAAAA)는 쓰기가 하나라도 사라지면 동작하지 않으므로 특히 주의합니다.

ISR에서 락을 잡아 시스템이 멈춤

// ❌ 메인 루프가 mtx를 쥔 상태에서 인터럽트가 오면 ISR이 영원히 대기
std::mutex mtx;
extern "C" void TIM2_IRQHandler() {
    std::lock_guard<std::mutex> lock(mtx);
    shared = read_register();
}

ISR에서는 락을 쓰지 않고 앞의 atomic 플래그나 링 버퍼로 데이터만 넘깁니다. 메인 루프 쪽에서 여러 변수를 한꺼번에 일관되게 읽어야 한다면, 메인 루프가 그 구간 동안 인터럽트를 잠깐 막는 방식을 씁니다.

class IrqGuard {
    uint32_t primask_;
public:
    IrqGuard() : primask_(__get_PRIMASK()) { __disable_irq(); }   // CMSIS
    ~IrqGuard() { __set_PRIMASK(primask_); }                       // 이전 상태 복원
    IrqGuard(const IrqGuard&) = delete;
    IrqGuard& operator=(const IrqGuard&) = delete;
};

void update_shared_config() {
    IrqGuard guard;
    config.a = 1;
    config.b = 2;   // ISR이 a와 b를 반쯤 바뀐 상태로 보지 않음
}

이전 인터럽트 상태를 저장했다가 복원하므로, 이미 인터럽트가 꺼진 구간 안에서 중첩해 써도 의도치 않게 인터럽트를 켜지 않습니다.

volatile을 스레드 동기화에 사용

// ❌ volatile int의 ++는 읽기-수정-쓰기 세 단계라 다른 스레드와 경합
volatile int counter = 0;
void worker() { counter++; }

// ✅
std::atomic<int> counter_a{0};
void worker_a() { counter_a.fetch_add(1, std::memory_order_relaxed); }

C++20은 이런 오해를 줄이기 위해 volatile 변수에 대한 ++, --, 복합 대입 일부를 deprecated로 지정했습니다(비트 연산 복합 대입은 레지스터 코드에서 흔히 쓰여 C++23에서 다시 허용되었습니다). 컴파일러 경고가 나면 x = x + 1처럼 읽기와 쓰기가 두 번의 접근이라는 점을 드러내도록 고쳐 씁니다.


실무 패턴

레지스터 접근 래퍼

#include <cstdint>

template<typename T>
class MmioReg {
    volatile T* ptr_;
public:
    explicit MmioReg(std::uintptr_t addr) : ptr_(reinterpret_cast<volatile T*>(addr)) {}
    T read() const { return *ptr_; }
    void write(T val) { *ptr_ = val; }
    void set_bits(T mask) { *ptr_ = *ptr_ | mask; }    // RMW임을 이름으로 드러냄
};

void example() {
    MmioReg<uint32_t> uart_data(0x4000'1000);
    uart_data.write('A');
    uint32_t value = uart_data.read();
    (void)value;
}

인라인되면 직접 포인터를 쓰는 것과 같은 코드가 나오고, 읽기·쓰기·RMW가 이름으로 구분되어 리뷰하기 쉬워집니다. 비트 마스크는 constexpr 상수나 enum으로 이름을 붙여 매직 넘버를 없앱니다.

레지스터 맵 헤더 분리

// uart_regs.h
#pragma once
#include <cstdint>
namespace hw::usart1 {
    constexpr std::uintptr_t BASE = 0x4001'1000;
    inline volatile uint32_t& SR() { return *reinterpret_cast<volatile uint32_t*>(BASE + 0x00); }
    inline volatile uint32_t& DR() { return *reinterpret_cast<volatile uint32_t*>(BASE + 0x04); }
}

DMA와 캐시

DMA가 메모리를 직접 쓰는 동안 CPU가 같은 버퍼를 캐시에서 읽으면 오래된 값을 보게 됩니다. Cortex-M7처럼 데이터 캐시가 있는 MCU에서는 DMA 버퍼를 MPU로 캐시 불가 영역에 두거나, DMA 수신 후 해당 범위를 캐시 무효화(invalidate), DMA 송신 전에는 캐시 정리(clean)를 해야 합니다. 더블 버퍼링으로 DMA가 한쪽을 채우는 동안 다른 쪽을 처리할 때도, 버퍼 전환 시점을 알리는 플래그는 atomic이나 DMA 완료 인터럽트로 전달합니다. 캐시가 없는 Cortex-M0~M4에서는 이 문제가 없고, 장치 레지스터 영역은 기본 메모리 맵에서 캐시되지 않는 영역으로 정의되어 있습니다.

플랫폼별 참고

플랫폼참고 사항
ARM Cortex-MCMSIS 헤더가 레지스터 구조체와 __DMB/__disable_irq 같은 내장 함수를 제공. M0/M0+는 32비트 atomic RMW 명령이 없어 fetch_add 등이 라이브러리 호출이나 인터럽트 차단으로 구현될 수 있음
AVR (8비트)16비트 이상 변수 읽기가 여러 명령으로 나뉘어 ISR과 공유 시 반쯤 바뀐 값을 읽을 수 있음. ATOMIC_BLOCK 매크로로 인터럽트를 막고 접근
리눅스 커널 드라이버ioremap()으로 매핑한 뒤 readl()/writel() 사용. 이 함수들이 volatile 접근과 필요한 배리어, 엔디안 처리를 포함
RISC-V장치 영역의 순서는 fence 명령으로 제어하며, 벤더 SDK의 접근 함수를 따르는 것이 안전
sequenceDiagram
    participant CPU
    participant Bus as 시스템 버스
    participant Reg as 하드웨어 레지스터
    Note over CPU,Reg: volatile 없음
    CPU->>Bus: 루프 시작 전 한 번 읽기
    Bus->>Reg: 접근
    Reg->>CPU: 값
    loop 루프
        CPU->>CPU: CPU 레지스터에 보관한 값 재사용
    end
    Note over CPU,Reg: volatile 있음
    loop 루프
        CPU->>Bus: 매 반복 읽기
        Bus->>Reg: 접근
        Reg->>CPU: 현재 값
    end

참고 자료


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. ISR에서 왜 malloc을 쓰면 안 되나요?

A. 많은 malloc 구현이 내부적으로 락이나 전역 자료구조를 씁니다. 메인 루프가 malloc 도중에 인터럽트가 들어와 ISR이 다시 malloc을 호출하면, 같은 락을 기다리며 멈추거나(락이 있는 경우) 반쯤 수정된 힙 자료구조를 건드려 힙이 손상됩니다(락이 없는 경우). ISR에서 쓸 메모리는 미리 정적으로 할당해 둡니다.

Q. 리눅스 커널 모듈에서 MMIO는 어떻게 하나요?

A. ioremap()으로 물리 주소를 커널 가상 주소에 매핑한 뒤 readl()/writel()(32비트) 같은 접근 함수를 씁니다. 이 함수들은 내부적으로 volatile 접근과 필요한 메모리 배리어, 리틀 엔디안 변환을 포함하므로, 직접 volatile 포인터를 역참조하는 것보다 이식성이 좋습니다.

Q. C와 C++에서 volatile 동작이 다르나요?

A. 레지스터 접근이라는 기본 의미는 같습니다. C++에는 volatile 멤버 함수 같은 추가 문법이 있고, C++20에서는 volatile 변수의 ++/--와 일부 복합 대입, volatile 매개변수·반환형이 deprecated되었습니다(비트 연산 복합 대입은 C++23에서 다시 허용). C 코드를 C++로 옮길 때 이런 경고가 나면 읽기와 쓰기를 명시적으로 나눠 씁니다.


다음으로 리눅스 시스템 콜(#42-3)을 읽어 보면 좋습니다.

이전 글: 실전 도메인 #42-1: 임베디드·예외 없이

다음 글: [실전 도메인 #42-3] 리눅스 시스템 프로그래밍: 시스템 콜 호출과 커널 인터페이스 이해