"이 변수는 컴파일러가 모르는 이유로 바뀔 수 있다"고 알려 주는 한정자. 최적화를 금지하는 것이 전부다.
왜 필요한가 — 컴파일러의 합리적 착각
uint32_t *status = (uint32_t *)0x40020000; // 하드웨어 레지스터
while (*status == 0) { } // 준비될 때까지 대기
컴파일러는 이렇게 판단한다.
-
"이 루프 안에서 *status 를 바꾸는 코드가 없다.
- 그러면 매번 메모리를 읽을 필요가 없지 않은가?"
-
한 번만 읽어 레지스터에 담고, 그 값으로 비교한다
-
값이 0이면 영원히 무한 루프 ✗
컴파일러의 판단은 논리적으로 옳다. 문제는 그 값을 바꾸는 주체가 C 코드 밖(하드웨어)에 있다는 사실을 모른다는 것이다.
volatile uint32_t *status = (volatile uint32_t *)0x40020000;
while (*status == 0) { } // 매 반복마다 실제 메모리를 읽는다
어떤 최적화가 금지되나
- ① 레지스터 캐싱 금지 — — 매번 메모리에서 읽는다
- ② 접근 제거 금지 — — 결과를 안 쓰는 읽기도 실제로 수행한다
- ③ 순서 재배치 금지 — — volatile 접근끼리는 순서가 유지된다
- ④ 접근 병합 금지 — — 두 번 쓰면 두 번 쓴다
②가 중요한 경우가 있다. 어떤 레지스터는 읽는 행위 자체가 플래그를 지우는 부수 효과를 갖는다(read-to-clear). 컴파일러가 "결과를 안 쓰니 지우자"고 없애 버리면 인터럽트가 계속 발생한다.
반드시 붙여야 하는 세 가지 경우
// ① 메모리 맵 하드웨어 레지스터
#define GPIOA_IDR (*(volatile uint32_t *)0x40020010)
// ② ISR과 메인 루프가 공유하는 변수
volatile uint8_t flag = 0;
void EXTI0_IRQHandler(void) { flag = 1; }
int main(void) { while (!flag) { } } // volatile 없으면 무한 루프
// ③ setjmp/longjmp 를 넘나드는 지역 변수
②가 실무에서 가장 흔하다. "인터럽트가 분명히 걸리는데 메인 루프가 빠져나오지 않는다"는 증상의 단골 원인이다. 특히 최적화를 -O0에서 -O2로 올린 순간 동작이 깨진다 — 디버그 빌드에서는 멀쩡하다가 릴리스에서만 터진다.
가장 중요한 오해 — volatile은 원자성을 보장하지 않는다
volatile uint32_t counter;
counter++; // 여전히 읽기-수정-쓰기 3단계. 인터럽트가 끼면 깨진다
volatile이 보장하는 것은 "실제로 접근한다"뿐이다.
volatile → 최적화 금지 (컴파일러에 대한 지시)
원자성 → 인터럽트 차단 / CAS (하드웨어 수준 보호)
메모리 배리어 → CPU 재배치 방지 (멀티코어에서 필요)
셋은 다른 문제를 푸는 다른 도구다.
// 8비트 MCU에서 16비트 카운터를 안전하게 읽기
uint32_t safe_read(void) {
uint32_t v;
__disable_irq();
v = counter;
__enable_irq();
return v;
}
멀티코어에서는 부족하다
volatile은 컴파일러 최적화만 막는다. CPU가 명령 순서를 바꾸거나
캐시가 코어 간에 동기화되지 않는 것은 막지 못한다.
멀티코어 SoC나 DMA와 공유하는 버퍼에는 메모리 배리어(__DMB())나
캐시 유지 연산이 함께 필요하다.
const volatile 은 모순이 아니다
const volatile uint32_t *sensor = (const volatile uint32_t *)0x4000A000;
"내가 쓸 수는 없지만(const), 하드웨어가 바꿀 수 있다(volatile)" — 읽기 전용 상태 레지스터를 정확히 표현한다. 면접에서 종종 묻는 조합이다.