C++ volatile의 정확한 의미: MMIO 레지스터, ISR 공유 변수, std::atomic과의 차이
들어가며: “디버그 빌드에서는 되는데 릴리스에서는 안 돼요”
UART로 시리얼 데이터를 받는 코드에서 while 루프로 “수신 버퍼 비어 있음” 플래그를 폴링합니다. 디버그 빌드(-O0)에서는 정상 동작하는데, 릴리스 빌드(-O2)에서는 무한 루프에 빠집니다. 디버거로 보면 하드웨어 레지스터 값은 분명히 바뀌는데, 프로그램은 그것을 못 봅니다. 컴파일러가 “이 변수는 루프 안에서 수정되지 않는다”고 판단해 한 번 읽은 값을 레지스터에 캐시하며, 메모리를 다시 읽지 않습니다.
GPIO 레지스터에 쓰기를 반복해 LED를 토글하는데 -O2 빌드에서는 LED가 켜지지 않는 경우도 있습니다. 컴파일러가 “같은 주소에 같은 값을 여러 번 쓰는 건 의미 없다”고 판단해 쓰기 연산을 제거했습니다.
인터럽트 서비스 루틴(ISR)에서 data_ready = true를 설정했는데 메인 루프에서는 영원히 false로만 보이는 것도 같은 원인입니다. volatile 없이 선언했을 때, 컴파일러가 “이 스레드에서는 이 변수를 수정하지 않으니” 캐시된 값을 계속 사용했습니다.
주기적으로 Watchdog 타이머를 리셋하는 코드가 있는데 릴리스 빌드에서 시스템이 계속 리부팅된다면, Watchdog 리셋 레지스터에 쓰는 코드가 “결과를 사용하지 않으므로” 컴파일러가 쓰기 연산 자체를 제거했습니다.
반대 방향의 실수도 흔합니다. 멀티스레드에서 공유 카운터를 volatile int counter로 선언하고 counter++를 수행하면 실행할 때마다 최종 값이 달라지고 ThreadSanitizer가 data race를 보고합니다. volatile은 원자성과 메모리 순서를 보장하지 않습니다. 스레드 동기화에는 std::atomic이 필요합니다.
UART 폴링 무한 대기, 사라진 GPIO 쓰기: 최적화가 하드웨어 접근을 지울 때
시나리오 A: UART 폴링 루프 무한 대기
// ❌ 잘못된 코드: -O2에서 무한 루프
#include <cstdint>
uint32_t* const uart_status = reinterpret_cast<uint32_t*>(0x40001004);
// bit 0: 수신 데이터 있음
void wait_for_uart_data_bad() {
while ((*uart_status & 0x01) == 0) {
// 컴파일러: "uart_status가 루프 안에서 바뀌지 않으니
// 한 번만 읽고 결과를 재사용해도 된다"
// → 하드웨어가 플래그를 1로 바꿔도 루프는 영원히 돔
}
}
volatile로 포인터를 선언하면 매 반복마다 실제 메모리에서 읽습니다.
// ✅ 올바른 코드
volatile uint32_t* const uart_status = reinterpret_cast<volatile uint32_t*>(0x40001004);
void wait_for_uart_data_good() {
while ((*uart_status & 0x01) == 0) {
// 매 반복마다 레지스터에서 읽음
}
}
시나리오 B: GPIO 쓰기 제거
// ❌ 잘못된 코드: LED 토글이 제거됨
uint32_t* const gpio_odr = reinterpret_cast<uint32_t*>(0x4001080C); // volatile 없음!
void blink_led_bad() {
for (int i = 0; i < 10; ++i) {
*gpio_odr = 0x0001; // LED 켜기
delay_ms(100);
*gpio_odr = 0x0000; // LED 끄기
delay_ms(100);
}
// -O2: delay_ms가 인라인되어 이 메모리에 접근하지 않는다는 것이 보이면
// "같은 주소에 쓴 값 중 최종값만 남기면 된다"며 중간 쓰기를 제거할 수 있음
}
volatile로 선언해 쓰기가 “observable side effect”로 유지되게 합니다.
// ✅ 올바른 코드
volatile uint32_t* const gpio_odr = reinterpret_cast<volatile uint32_t*>(0x4001080C);
void blink_led_good() {
for (int i = 0; i < 10; ++i) {
*gpio_odr = 0x0001;
delay_ms(100);
*gpio_odr = 0x0000;
delay_ms(100);
}
}
시나리오 C: DMA 완료 플래그 확인 실패
// ❌ DMA 상태 레지스터 폴링이 -O2에서 실패
uint32_t* const dma_status = reinterpret_cast<uint32_t*>(0x40020010);
void wait_dma_complete_bad() {
while ((*dma_status & (1u << 1)) == 0) {
// 한 번만 읽고 재사용 → 영원히 0
}
}
UART와 마찬가지로 volatile 포인터로 매번 읽도록 합니다.
시나리오 D: 벤치마크 루프가 제거됨
// ❌ volatile 없이 공백 루프: 컴파일러가 전체 제거
void benchmark_bad() {
int sum = 0;
for (int i = 0; i < 1000000; ++i) {
sum += i; // 결과를 쓰지 않음 → dead code elimination
}
// -O2: 루프 전체가 제거되어 0초에 완료
}
더미 연산에 volatile을 쓰면 컴파일러가 제거하지 못합니다. 다만 매 반복이 실제 메모리 읽기·쓰기가 되어 측정 대상 자체가 바뀌므로, 정밀한 측정에는 Google Benchmark의 benchmark::DoNotOptimize처럼 결과만 관찰 가능하게 만드는 방법이 낫습니다. 또 C++20부터 volatile 변수에 대한 +=, ++는 deprecated입니다.
// ✅ volatile로 제거 방지
void benchmark_good() {
volatile int sum = 0;
for (int i = 0; i < 1000000; ++i) {
sum += i;
}
}
시나리오 E: 디버그 변수가 최적화로 사라짐
// ❌ 디버거에서 value를 볼 수 없음 (최적화로 제거)
void process(int x) {
int value = x * 2; // 사용되지 않음 → 제거
doSomething();
}
디버깅할 때 잠깐 volatile을 붙이면 값이 메모리에 남아 디버거에서 관찰할 수 있습니다.
// ✅ 디버거에서 관찰 가능
void process(int x) {
volatile int value = x * 2;
(void)value; // 사용하지 않아도 접근 보존
doSomething();
}
volatile이란 무엇인가
”접근을 최적화로 없애지 마라”
volatile은 해당 객체에 대한 읽기·쓰기를 “observable side effect”로 유지하라고 컴파일러에 알립니다. 따라서:
- 읽기: 루프에서 한 번 읽은 값을 레지스터에 캐시해 두고 다시 읽지 않는 최적화가 적용되지 않습니다.
- 쓰기: “결과를 사용하지 않으므로” 쓰기를 제거하는 최적화가 적용되지 않습니다.
- 순서: 한 스레드 안에서 volatile 접근끼리의 순서는 컴파일러가 바꾸지 않습니다. 하지만 volatile이 아닌 접근과의 순서, CPU 수준의 재배치, 스레드 간 메모리 순서는 보장하지 않습니다.
flowchart TB
subgraph CPU[CPU/컴파일러]
C1[일반 변수] --> C2[최적화: 접근 제거/재배치]
C2 --> C3[캐시된 값 재사용]
end
subgraph HW[하드웨어]
H1[메모리 맵 레지스터] --> H2[읽을 때마다 값 변경]
H2 --> H3[쓰기 = 부수 효과]
end
subgraph Volatile[volatile]
V1[접근 보존] --> V2[매번 메모리 접근]
V2 --> V3[최적화 방지]
end
C1 -.->|문제| H1
V1 -->|해결| H1
volatile 없을 때 vs 있을 때 (어셈블리 관점)
// 테스트용 코드
volatile int* p = reinterpret_cast<volatile int*>(0x1000);
void loop_bad() {
int* q = reinterpret_cast<int*>(0x1000);
while (*q == 0) {} // -O2: 한 번만 로드, 무한 루프 가능
}
void loop_good() {
while (*p == 0) {} // 매 반복마다 로드
}
volatile 없음: 컴파일러가 *q를 루프 밖에서 한 번만 로드하며, 그 값을 레지스터에 두고 비교만 반복할 수 있습니다.
volatile 있음: *p 접근은 제거할 수 없으므로, 매 반복마다 해당 주소에서 로드하는 명령이 생성됩니다.
volatile의 한계
| 보장 | volatile | std::atomic |
|---|---|---|
| 접근 제거 방지 | ✅ | ✅ |
| 원자성 (atomicity) | ❌ | ✅ |
| 메모리 순서 (스레드 간) | ❌ | ✅ |
| 하드웨어 레지스터 접근 | ✅ 적합 | ❌ 부적합 (장치 메모리에 원자 명령이 지원되지 않을 수 있고, 접근 횟수 보존을 보장하지 않음) |
핵심: volatile은 컴파일러 최적화만 제한합니다. CPU 재배치, 캐시 일관성, 원자적 연산은 보장하지 않습니다.
메모리 맵 I/O (MMIO)
개념
메모리 맵 I/O는 하드웨어 레지스터를 메모리 주소에 매핑해, 일반 메모리 읽기/쓰기 명령으로 디바이스를 제어하는 방식입니다. 데이터시트에 베이스 주소 + 오프셋으로 정의되어 있으므로, 포인터나 구조체로 접근합니다.
기본 레지스터 접근 패턴
#include <cstdint>
// STM32 UART1 예시 (데이터시트 기준)
constexpr uint32_t UART1_BASE = 0x40011000;
constexpr uint32_t UART_SR = UART1_BASE + 0x00; // Status
constexpr uint32_t UART_DR = UART1_BASE + 0x04; // Data
constexpr uint32_t UART_CR1 = UART1_BASE + 0x0C; // Control
#define UART_SR_RXNE (1u << 5) // Read data register not empty
#define UART_SR_TXE (1u << 7) // Transmit data register empty
// volatile 포인터로 선언
volatile uint32_t* const reg_sr = reinterpret_cast<volatile uint32_t*>(UART_SR);
volatile uint32_t* const reg_dr = reinterpret_cast<volatile uint32_t*>(UART_DR);
volatile uint32_t* const reg_cr1 = reinterpret_cast<volatile uint32_t*>(UART_CR1);
void uart_send_char(char c) {
while ((*reg_sr & UART_SR_TXE) == 0) {
// 송신 버퍼 비어 있을 때까지 대기
}
*reg_dr = static_cast<uint32_t>(static_cast<unsigned char>(c));
}
char uart_recv_char() {
while ((*reg_sr & UART_SR_RXNE) == 0) {
// 수신 데이터 있을 때까지 대기
}
return static_cast<char>(*reg_dr);
}
구조체로 레지스터 그룹 정의
#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 volatile UartRegisters* const uart =
reinterpret_cast<volatile UartRegisters*>(0x40000000);
void uart_init() {
uart->ctrl = 0x03; // TX/RX 활성화
}
void uart_putc(char c) {
while ((uart->status & (1u << 5)) == 0) {}
uart->data = static_cast<uint32_t>(static_cast<unsigned char>(c));
}
struct에 uint32_t와 uint16_t를 섞으면 컴파일러가 패딩을 넣어 오프셋이 데이터시트와 달라질 수 있습니다. 데이터시트에 빈 자리가 있으면 reserved 필드로 명시하고, 뒤에서 보는 static_assert(offsetof(...))로 오프셋을 검사합니다.
리드-수정-쓰기 (Read-Modify-Write) 위험
한 레지스터의 비트 하나만 바꿀 때, 읽기 → 수정 → 쓰기 사이에 인터럽트나 DMA가 같은 레지스터를 수정하면 그 변경이 덮어쓰여질 수 있습니다.
// ⚠️ 위험: 읽기-수정-쓰기 사이에 인터럽트 가능
volatile uint32_t* const gpio_odr = reinterpret_cast<volatile uint32_t*>(0x4001080C);
void set_bit_unsafe(uint32_t pin) {
uint32_t val = *gpio_odr; // 1. 읽기
val |= (1u << pin); // 2. 수정 (이 사이에 ISR이 odr 수정 가능)
*gpio_odr = val; // 3. 쓰기 → ISR 변경 덮어씀
}
하드웨어가 BSRR(Bit Set/Reset Register)처럼 “비트별 set/reset” 전용 레지스터를 제공하면, 원자적으로 한 비트만 바꿀 수 있습니다.
// ✅ BSRR 사용: set 비트만 쓰기, 나머지 무관
volatile uint32_t* const gpio_bsrr = reinterpret_cast<volatile uint32_t*>(0x40010810);
void set_bit_safe(uint32_t pin) {
*gpio_bsrr = (1u << pin); // set 비트만 1로, 나머지는 그대로
}
void clear_bit_safe(uint32_t pin) {
*gpio_bsrr = (1u << (pin + 16)); // reset 비트 (상위 16비트)
}
인터럽트 서비스 루틴 (ISR)
ISR의 제약
ISR은 인터럽트 발생 시 즉시 호출됩니다. 실행 시간이 길면 다른 인터럽트가 지연되므로, 짧게 작성하고 락·할당·예외는 피합니다.
sequenceDiagram
participant HW as 하드웨어
participant CPU as CPU
participant ISR as ISR
participant Main as 메인 루프
HW->>CPU: 인터럽트 발생
CPU->>ISR: ISR 호출
ISR->>ISR: 플래그 설정 / 버퍼에 데이터
ISR->>CPU: return
CPU->>Main: 메인 루프 복귀
Main->>Main: 플래그 확인, 실제 처리
ISR에서 하면 안 되는 것
| 금지 항목 | 이유 |
|---|---|
malloc/new | 재진입 불가, 할당자 내부 락으로 데드락 |
std::mutex 등 락 | 메인 루프가 락을 잡은 상태에서 ISR이 같은 락 대기 → 데드락 |
| 예외 | 많은 임베디드 환경에서 예외 비활성화 |
| 긴 루프/연산 | 다른 인터럽트 지연, 실시간성 저하 |
ISR-메인 루프 공유 변수
// ⚠️ volatile만 사용 (단일 바이트, 단순 플래그, atomic 미지원 환경)
volatile bool data_ready = false;
volatile uint8_t received_byte = 0;
// ISR (의사 코드, 플랫폼별 인터럽트 속성 필요)
void __attribute__((interrupt)) uart_isr() {
received_byte = *uart_data; // 수신 데이터 읽기
data_ready = true; // 플래그 설정
}
// 메인 루프
void main_loop() {
while (true) {
if (data_ready) {
process(received_byte);
data_ready = false;
}
}
}
여러 바이트일 때는 트랜잭션이 깨질 수 있습니다 (한 바이트만 읽었는데 ISR이 다음 바이트를 덮어쓴 경우). std::atomic이 지원되는 플랫폼에서는 atomic을 쓰는 것이 더 안전합니다.
#include <atomic>
// ✅ std::atomic 사용 (플랫폼 지원 시)
std::atomic<bool> data_ready{false};
std::atomic<uint8_t> received_byte{0};
volatile vs std::atomic
비교표
| 항목 | volatile | std::atomic |
|---|---|---|
| 목적 | 컴파일러 최적화 방지 | 원자적 연산 + 메모리 순서 |
| MMIO/하드웨어 주소 | ✅ 적합 | ❌ 부적합 |
| 스레드 동기화 | ❌ 부적합 | ✅ 필수 |
| 원자성 (counter++ 등) | ❌ 보장 안 함 | ✅ 보장 |
| 메모리 순서 | ❌ 보장 안 함 | ✅ memory_order 지정 가능 |
volatile을 스레드에 쓰면 안 되는 이유
// ❌ 잘못된 코드: data race
volatile int counter = 0;
void thread_func() {
for (int i = 0; i < 1000000; ++i) {
counter++; // 읽기-수정-쓰기가 원자적이지 않음!
}
}
int main() {
std::thread t1(thread_func);
std::thread t2(thread_func);
t1.join();
t2.join();
// counter는 2000000이 아닐 수 있음 (예: 1234567)
return 0;
}
counter++는 “읽기 → +1 → 쓰기” 세 단계입니다. volatile은 각 접근을 제거하지 못하게 할 뿐, 세 단계를 한 번에 수행하도록 보장하지 않습니다. 두 스레드가 동시에 읽고 수정하면 값이 꼬입니다.
// ✅ 올바른 코드
#include <atomic>
std::atomic<int> counter{0};
void thread_func() {
for (int i = 0; i < 1000000; ++i) {
counter.fetch_add(1); // 또는 counter++
}
}
volatile vs atomic 선택 기준
flowchart TD
A[공유 메모리 접근] --> B{하드웨어 레지스터?}
B -->|예| C[volatile 사용]
B -->|아니오| D{스레드 간 공유?}
D -->|예| E["std atomic 또는 mutex"]
D -->|아니오| F{ISR-메인 루프?}
F -->|예, atomic 지원| G["std atomic 권장"]
F -->|예, atomic 미지원| H[volatile 가능]
UART 에코, 워치독, DMA 대기, GPIO BSRR 예제
예제 1: UART 에코 서버 (폴링)
#include <cstdint>
constexpr uint32_t UART_BASE = 0x40011000;
constexpr uint32_t UART_SR = UART_BASE + 0x00;
constexpr uint32_t UART_DR = UART_BASE + 0x04;
constexpr uint32_t UART_CR1 = UART_BASE + 0x0C;
#define UART_SR_RXNE (1u << 5)
#define UART_SR_TXE (1u << 7)
volatile uint32_t* const reg_sr = reinterpret_cast<volatile uint32_t*>(UART_SR);
volatile uint32_t* const reg_dr = reinterpret_cast<volatile uint32_t*>(UART_DR);
volatile uint32_t* const reg_cr1 = reinterpret_cast<volatile uint32_t*>(UART_CR1);
void uart_init() {
*reg_cr1 = 0x200C; // UE, TE, RE 활성화
}
void uart_putc(char c) {
while ((*reg_sr & UART_SR_TXE) == 0) {}
*reg_dr = static_cast<uint32_t>(static_cast<unsigned char>(c));
}
char uart_getc() {
while ((*reg_sr & UART_SR_RXNE) == 0) {}
return static_cast<char>(*reg_dr);
}
void echo_loop() {
while (true) {
char c = uart_getc();
uart_putc(c); // 받은 문자 그대로 송신
}
}
예제 2: Watchdog 리셋
#include <cstdint>
// IWDG (Independent Watchdog) 키 레지스터
// 0x5555 쓰기: PR/RLR(분주·리로드) 레지스터 쓰기 허용
// 0xAAAA 쓰기: 카운터 리셋 (이게 없으면 타임아웃 시 리셋)
constexpr uint32_t IWDG_BASE = 0x40003000; // STM32F1 기준, KR은 오프셋 0x00
volatile uint32_t* const IWDG_KR = reinterpret_cast<volatile uint32_t*>(IWDG_BASE + 0x00);
void watchdog_feed() {
*IWDG_KR = 0xAAAA; // volatile 없으면 이 쓰기가 제거될 수 있음!
}
예제 3: DMA 전송 대기
#include <cstdint>
constexpr uint32_t DMA1_BASE = 0x40020000;
volatile uint32_t* const DMA_ISR = reinterpret_cast<volatile uint32_t*>(DMA1_BASE + 0x00);
volatile uint32_t* const DMA_IFCR = reinterpret_cast<volatile uint32_t*>(DMA1_BASE + 0x04);
#define DMA_ISR_TC1 (1u << 1) // Transfer Complete
void dma_wait_channel1() {
while ((*DMA_ISR & DMA_ISR_TC1) == 0) {
// 매 반복마다 레지스터 읽음
}
*DMA_IFCR = DMA_ISR_TC1; // 플래그 클리어
}
예제 4: GPIO 제어 (BSRR 패턴)
#include <cstdint>
struct GpioRegs {
volatile uint32_t crl;
volatile uint32_t crh;
volatile uint32_t idr;
volatile uint32_t odr;
volatile uint32_t bsrr; // Bit Set/Reset: 상위 16비트=reset, 하위 16비트=set
volatile uint32_t brr;
volatile uint32_t lckr;
};
volatile GpioRegs* const gpio_a = reinterpret_cast<volatile GpioRegs*>(0x40010800);
void gpio_set_pin(uint32_t pin) {
gpio_a->bsrr = (1u << pin); // set
}
void gpio_clear_pin(uint32_t pin) {
gpio_a->bsrr = (1u << (pin + 16)); // reset
}
void gpio_toggle_pin(uint32_t pin) {
uint32_t val = gpio_a->odr;
if (val & (1u << pin)) {
gpio_a->bsrr = (1u << (pin + 16));
} else {
gpio_a->bsrr = (1u << pin);
}
}
예제 5: 타이머 카운터 읽기 (64비트 조합)
일부 플랫폼에서는 32비트 타이머를 두 번 읽어 64비트 값을 만듭니다. 읽는 사이에 오버플로우가 발생할 수 있으므로, 상위/하위를 여러 번 읽어 일관성을 확인하는 패턴을 씁니다.
#include <cstdint>
volatile uint32_t* const tim_cnt_l = reinterpret_cast<volatile uint32_t*>(0x40000024);
volatile uint32_t* const tim_cnt_h = reinterpret_cast<volatile uint32_t*>(0x40000028);
uint64_t read_64bit_counter() {
uint32_t hi1, hi2, lo;
do {
hi1 = *tim_cnt_h;
lo = *tim_cnt_l;
hi2 = *tim_cnt_h;
} while (hi1 != hi2); // 오버플로우 없이 읽었는지 확인
return (static_cast<uint64_t>(hi1) << 32) | lo;
}
설명: volatile이 없으면 컴파일러가 hi1, lo, hi2 읽기를 최적화해 한 번만 수행할 수 있습니다. volatile로 매번 레지스터에서 읽어야 오버플로우 감지가 동작합니다.
예제 6: 메모리 맵 레지스터 접근 시퀀스
sequenceDiagram
participant CPU as CPU
participant Compiler as 컴파일러
participant HW as 하드웨어 레지스터
Note over CPU,HW: volatile 없음 (-O2)
CPU->>Compiler: *reg 읽기
Compiler->>Compiler: 한 번만 로드, 캐시
loop 루프
CPU->>Compiler: 비교 (캐시된 값)
Note over Compiler: HW 변화 무시
end
Note over CPU,HW: volatile 있음
loop 루프
CPU->>Compiler: *reg 읽기
Compiler->>HW: 매번 메모리 로드
HW-->>Compiler: 현재 값
Compiler->>CPU: 비교
end
volatile 없는 MMIO, 스레드 동기화에 volatile, RMW 비트 꼬임
문제 1: “volatile 없이 MMIO 접근”
-O2 빌드에서 폴링 루프가 끝나지 않거나 쓰기가 사라지는 경우입니다.
// ❌ 잘못된 코드
uint32_t* reg = reinterpret_cast<uint32_t*>(0x40001000);
// ✅ 올바른 코드
volatile uint32_t* const reg = reinterpret_cast<volatile uint32_t*>(0x40001000);
문제 2: “volatile을 스레드 동기화에 사용”
data race로 실행마다 결과가 다르고 ThreadSanitizer가 경고를 냅니다.
// ❌ 잘못된 코드
volatile int counter = 0;
counter++;
// ✅ 올바른 코드
std::atomic<int> counter{0};
counter++;
문제 3: “리드-수정-쓰기 시 비트 꼬임”
한 비트만 바꿨는데 다른 비트가 원래대로 돌아가는 경우입니다. BSRR 같은 전용 레지스터를 쓰거나, 읽기-수정-쓰기를 인터럽트 비활성화 구간에서 수행합니다.
// ❌ 위험
void set_bit_unsafe(uint32_t pin) {
uint32_t val = *gpio_odr;
val |= (1u << pin);
*gpio_odr = val; // 이 사이에 ISR이 odr 수정하면 덮어씀
}
// ✅ BSRR 사용
void set_bit_safe(uint32_t pin) {
gpio_a->bsrr = (1u << pin);
}
문제 4: “구조체 패딩으로 오프셋 틀어짐”
레지스터에 썼는데 다른 레지스터가 바뀌거나 하드웨어가 오동작하는 경우입니다. 예를 들어 데이터시트가 a@0x00, b@0x04(16비트), c@0x08로 정의했다면 아래 구조체는 우연히 맞지만, c가 0x06에 있다면 컴파일러가 넣은 2바이트 패딩 때문에 어긋납니다. 빈 자리는 reserved 필드로 명시하고 오프셋을 컴파일 시점에 검사합니다.
#include <cstddef>
struct Regs {
volatile uint32_t a; // 0x00
volatile uint16_t b; // 0x04
uint16_t reserved0; // 0x06 (데이터시트의 빈 자리)
volatile uint32_t c; // 0x08
};
static_assert(offsetof(Regs, b) == 0x04);
static_assert(offsetof(Regs, c) == 0x08);
문제 5: “ISR에서 락 사용 → 데드락”
메인 루프가 락을 잡은 상태에서 인터럽트가 걸리고 ISR이 같은 락을 기다리면 시스템이 멈춥니다. ISR에서는 락을 쓰지 말고 플래그·링 버퍼에 기록만 한 뒤 메인 루프에서 처리합니다.
문제 6: “volatile 포인터를 통한 구조체 접근 시 일부만 volatile”
일부 필드만 volatile이면 나머지 필드 접근은 최적화될 수 있습니다. 레지스터 구조체는 각 필드를 volatile로 선언하거나 구조체 포인터 자체를 volatile로 선언합니다.
// ✅ 구조체 필드에 volatile
struct Regs {
volatile uint32_t a;
volatile uint32_t b;
};
문제 7: “캐시 일관성 (Cache Coherence)”
DMA가 메모리에 쓴 데이터를 CPU가 오래된 캐시에서 읽는 경우입니다. volatile은 캐시를 제어하지 않습니다. DMA 버퍼는 uncached 영역에 두거나, 플랫폼별 캐시 무효화/쓰기 반영 API를 호출해야 합니다. MMIO 레지스터는 보통 uncached로 매핑됩니다.
// DMA 수신 버퍼: 플랫폼별로 캐시 무효화 필요
void dma_receive_done() {
// 예: Cortex-M7(CMSIS) — 버퍼 영역의 데이터 캐시를 무효화한 뒤 읽기
// SCB_InvalidateDCache_by_Addr(rx_buf, sizeof(rx_buf));
// __DSB()/__DMB()는 메모리 배리어일 뿐 캐시를 무효화하지 않음
// volatile만으로는 CPU 캐시 무효화 안 됨
}
문제 8: “volatile 멤버 함수 호출”
volatile 객체에서는 volatile 멤버 함수만 호출 가능합니다. 일반 멤버 함수를 호출하려면 const_cast가 필요하나, MMIO에서는 비권장입니다.
struct Reg {
volatile uint32_t value;
void write(uint32_t v) { value = v; } // 비volatile
void write(uint32_t v) volatile { value = v; } // volatile 오버로드
};
volatile Reg* r = ...;
r->write(1); // volatile write() 호출됨
const volatile 포인터, 데이터시트 레이아웃 확인, 최소 범위
MMIO 포인터는 const + volatile
// ✅ 읽기 전용 레지스터
volatile const uint32_t* const uart_sr = reinterpret_cast<volatile const uint32_t*>(0x40001004);
// ✅ 쓰기 가능 레지스터
volatile uint32_t* const uart_dr = reinterpret_cast<volatile uint32_t*>(0x40001000);
매직 넘버 대신 상수/매크로
#define UART_SR_RXNE (1u << 5)
#define UART_SR_TXE (1u << 7)
if (*reg_sr & UART_SR_RXNE) { ... }
데이터시트와 레이아웃 일치 확인
// 데이터시트: 오프셋 0, 4, 8
static_assert(offsetof(UartRegisters, data) == 0, "data offset");
static_assert(offsetof(UartRegisters, status) == 4, "status offset");
static_assert(offsetof(UartRegisters, ctrl) == 8, "ctrl offset");
리드-수정-쓰기 대신 하드웨어 원자 연산
BSRR, set/clear 전용 레지스터를 우선 사용합니다.
volatile은 하드웨어·ISR용, 스레드는 atomic
용도를 명확히 구분합니다.
volatile과 메모리 배리어
volatile은 컴파일러에게만 “접근 제거하지 마라”고 알립니다. CPU나 버스가 메모리 접근 순서를 바꿀 수 있는 아키텍처에서 MMIO 순서가 중요하면 플랫폼별 배리어가 필요합니다. std::atomic_thread_fence는 스레드 사이의 메모리 순서를 위한 것이라 장치 레지스터 접근 순서까지 보장한다고 표준이 말하지 않으므로, ARM이라면 CMSIS의 __DMB(), __DSB() 같은 명령을 씁니다. Cortex-M처럼 장치 메모리 영역(Device memory) 접근이 프로그램 순서대로 수행되는 환경에서는 대개 추가 배리어가 필요 없지만, 레지스터 쓰기가 끝난 뒤에 인터럽트 설정을 바꾸는 경우처럼 완료를 보장해야 할 때는 __DSB()를 둡니다.
// MMIO 쓰기 순서·완료가 중요한 경우 (ARM CMSIS 예)
void init_device() {
*reg_ctrl = 0; // 리셋
__DSB();
*reg_config = 0x01; // 설정
__DSB();
*reg_ctrl = 1; // 활성화
}
volatile 최소 범위
필요한 변수/포인터에만 volatile을 적용합니다. 과도한 사용은 불필요한 최적화 방지를 유발해 성능에 영향을 줄 수 있습니다.
레지스터 래퍼, 레지스터 맵 헤더, MISRA 준수
패턴 1: 레지스터 래퍼 클래스
#include <cstdint>
template <uint32_t Base, uint32_t Offset>
class MmioReg {
public:
static volatile uint32_t* ptr() {
return reinterpret_cast<volatile uint32_t*>(Base + Offset);
}
static uint32_t read() { return *ptr(); }
static void write(uint32_t v) { *ptr() = v; }
};
using UartSr = MmioReg<0x40011000, 0x00>;
using UartDr = MmioReg<0x40011000, 0x04>;
void uart_send(char c) {
while ((UartSr::read() & (1u << 7)) == 0) {}
UartDr::write(static_cast<unsigned char>(c));
}
패턴 2: 플랫폼별 volatile/atomic 선택
// 매크로로 volatile과 std::atomic을 바꾸면 `volatile<bool>`이 되어 컴파일되지 않으므로 별칭 템플릿을 씀
#if defined(NO_STDLIB_ATOMIC)
// atomic을 쓸 수 없는 단일 코어 bare-metal 환경
template <typename T> using shared_flag = volatile T;
#else
#include <atomic>
template <typename T> using shared_flag = std::atomic<T>;
#endif
shared_flag<bool> data_ready{false};
// 대입(data_ready = true)과 읽기(if (data_ready))는 두 경우 모두 같은 문법으로 동작
패턴 3: 레지스터 맵 헤더
// uart_regs.h
#pragma once
#include <cstdint>
namespace hw {
namespace uart1 {
constexpr uint32_t BASE = 0x40011000;
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);
}
}
}
패턴 4: 메모리 배리어 (필요 시)
// 앞선 일반 메모리 쓰기(예: DMA 디스크립터)가 끝난 뒤 장치를 깨워야 할 때 (ARM CMSIS)
void mmio_write_ordered(volatile uint32_t* reg, uint32_t value) {
__DMB(); // 앞선 메모리 접근이 이 쓰기보다 먼저 관찰되도록
*reg = value;
}
패턴 5: 조건부 컴파일로 디버그/릴리스 분리
#ifdef DEBUG
#define DEBUG_VAR(type, name, init) volatile type name = (init)
#else
#define DEBUG_VAR(type, name, init) ((void)0)
#endif
void process(int x) {
DEBUG_VAR(int, intermediate, x * 2);
// 디버그 빌드에서만 intermediate 관찰 가능
}
패턴 6: MISRA-C/C++ 준수 (자동차·항공 등)
일부 표준에서는 volatile 사용을 제한하거나, MMIO 접근을 매크로/함수로 감싸도록 권장합니다. 프로젝트 코딩 규칙을 확인하세요.
C++20에서 달라진 점
C++20(P1152)은 의미가 모호한 volatile 사용 일부를 deprecated로 지정했습니다. volatile 변수에 대한 ++, --, += 같은 복합 대입, volatile 매개변수와 반환형, volatile 구조적 바인딩이 해당합니다. 이런 연산은 “읽기 한 번 + 쓰기 한 번”인지 원자적인 하나의 연산인지 오해를 부르기 때문입니다. 레지스터 값을 바꿀 때는 *reg = *reg | mask;처럼 읽기와 쓰기를 명시적으로 나눠 쓰면 경고를 피하면서 의도도 분명해집니다. MMIO 접근 자체에는 여전히 volatile이 표준적인 수단입니다.
참고 자료:
같이 보면 좋은 글
- C++ atomic | Mutex 없이 스레드 안전 카운터 만드는 법 (memory_order 포함)
- C++ 직접적인 하드웨어 제어: volatile, 메모리 맵 I/O, 인터럽트 서비스 루틴 [#42-2]
- Data Race 잡는 법: Mutex와 Atomic 중 뭘 써야 할까
- C++ 시리즈 전체 보기
- C++ volatile: 하드웨어 레지스터와 시그널 핸들러용 키워드, 스레드 동기화에 못 쓰는 이유