C++ volatile Keyword | 'volatile 키워드' 가이드
이 글의 핵심
volatile은 컴파일러가 변수 값을 레지스터에 캐싱하지 못하게 막아 매번 메모리에서 읽고 쓰도록 강제하는 키워드로, 하드웨어 레지스터·시그널 핸들러·메모리 매핑 I/O에 쓰입니다. 원자성과 메모리 순서는 보장하지 않으므로 멀티스레딩에는 std::atomic을 써야 합니다.
들어가며
volatile 키워드는 컴파일러에게 변수가 외부 요인에 의해 변경될 수 있음을 알립니다. 이를 통해 컴파일러 최적화를 방지하며, 매번 메모리에서 값을 읽고 쓰도록 강제합니다.
1. volatile 기본
최적화 방지
컴파일러는 성능 향상을 위해 변수를 레지스터에 캐싱하는 최적화를 수행합니다. 하지만 외부에서 변수가 변경될 수 있는 경우, 이 최적화가 문제를 일으킵니다.
#include <iostream>
// ❌ 최적화로 무한 루프
int flag = 0;
void wait() {
// 컴파일러 최적화 동작:
// 1. 컴파일러가 "flag는 이 함수 안에서 변경되지 않는다"고 판단
// 2. flag 값을 레지스터에 캐싱 (메모리 접근 비용 절약)
// 3. while 조건 검사 시 레지스터 값만 확인 (메모리 값 무시)
// 4. 다른 스레드나 인터럽트가 flag를 변경해도 감지 못함
while (flag == 0) {
// 무한 루프에 빠짐!
// 레지스터에 캐시된 0만 계속 확인
}
}
// ✅ volatile 사용
volatile int flag = 0;
void wait() {
// volatile 효과:
// 1. 컴파일러에게 "이 변수는 외부에서 변경될 수 있다"고 알림
// 2. 레지스터 캐싱 금지
// 3. while 조건 검사 시마다 메모리에서 직접 읽음
// 4. 다른 스레드나 인터럽트가 flag를 1로 변경하면 즉시 감지
while (flag == 0) {
// flag가 1로 변경되면 루프 탈출
}
}
핵심 개념:
- volatile: 컴파일러 최적화 방지 키워드
- 매번 메모리 접근: 레지스터 캐싱을 하지 않고 항상 메모리에서 읽고 씀
- 외부 변경 감지: 하드웨어 레지스터, 시그널 핸들러, 인터럽트 등이 변수를 변경할 수 있을 때 사용
실제 시나리오
- 임베디드 시스템에서 GPIO 핀 상태가 하드웨어에 의해 변경됨
- 시그널 핸들러가 변수를 변경 (Ctrl+C 등)
- 메모리 매핑된 I/O 레지스터가 외부 장치에 의해 변경됨
2. 사용 사례
하드웨어 레지스터
임베디드 시스템에서 하드웨어를 직접 제어할 때 volatile이 필수적입니다:
#include <cstdint>
#include <iostream>
// 임베디드 시스템: GPIO 레지스터
// 0x40020000: 하드웨어 메모리 주소 (데이터시트에 명시)
// volatile: 하드웨어가 이 메모리를 직접 변경할 수 있음을 컴파일러에게 알림
// const: 포인터 자체는 변경 불가 (항상 같은 주소를 가리킴)
volatile uint32_t* const GPIO_DATA =
reinterpret_cast<volatile uint32_t*>(0x40020000);
volatile uint32_t* const GPIO_DIR =
reinterpret_cast<volatile uint32_t*>(0x40020004);
void setPin(int pin) {
// 핀을 출력 모드로 설정
// |= : 비트 OR 연산으로 특정 비트만 1로 설정
// (1 << pin): pin번째 비트를 1로 만듦 (예: pin=3 → 0b1000)
*GPIO_DIR |= (1 << pin); // 출력 모드 설정
// 핀을 HIGH(1)로 설정
*GPIO_DATA |= (1 << pin); // HIGH 출력
// volatile이 없다면:
// 컴파일러가 두 줄을 하나로 합치거나 순서를 바꿀 수 있음
// 하드웨어는 정확한 순서대로 레지스터 접근을 기대하므로 문제 발생
}
void clearPin(int pin) {
// 핀을 LOW(0)로 설정
// &= : 비트 AND 연산
// ~(1 << pin): pin번째 비트만 0, 나머지는 1 (예: pin=3 → 0b11110111)
*GPIO_DATA &= ~(1 << pin); // LOW 출력
}
bool readPin(int pin) {
// 핀의 현재 상태 읽기
// & : 비트 AND로 특정 비트만 추출
// != 0 : 해당 비트가 1인지 확인
return (*GPIO_DATA & (1 << pin)) != 0;
// volatile이 없다면:
// 컴파일러가 GPIO_DATA를 한 번만 읽고 캐시
// 하드웨어가 값을 변경해도 감지 못함
}
왜 volatile이 필요한가:
- 하드웨어가 레지스터 값을 직접 변경할 수 있음 (예: 버튼 입력)
- 컴파일러는 이를 모르고 최적화하려 함
volatile로 “이 메모리는 예측 불가능하게 변한다”고 알림- 매번 실제 메모리에서 읽어야 최신 값을 얻음
시그널 핸들러
#include <signal.h>
#include <iostream>
#include <unistd.h>
// sig_atomic_t는 원자적 타입
volatile sig_atomic_t signalReceived = 0;
void signalHandler(int sig) {
signalReceived = 1;
}
int main() {
signal(SIGINT, signalHandler);
std::cout << "Ctrl+C를 누르세요..." << std::endl;
while (!signalReceived) {
sleep(1);
std::cout << "대기 중..." << std::endl;
}
std::cout << "시그널 받음, 종료합니다" << std::endl;
return 0;
}
메모리 매핑 I/O
#include <cstdint>
#include <iostream>
struct DeviceRegisters {
volatile uint32_t control; // 제어 레지스터
volatile uint32_t status; // 상태 레지스터
volatile uint32_t data; // 데이터 레지스터
volatile uint32_t interrupt; // 인터럽트 레지스터
};
DeviceRegisters* device =
reinterpret_cast<DeviceRegisters*>(0x40000000);
void writeDevice(uint32_t value) {
// 1. 장치 시작
device->control = 0x01;
// 2. 데이터 쓰기
device->data = value;
// 3. 완료 대기 (상태 레지스터 폴링)
while (!(device->status & 0x01)) {
// 매번 메모리에서 status 읽기
}
std::cout << "쓰기 완료" << std::endl;
}
uint32_t readDevice() {
// 1. 읽기 시작
device->control = 0x02;
// 2. 완료 대기
while (!(device->status & 0x02)) {
// 폴링
}
// 3. 데이터 읽기
return device->data;
}
3. volatile과 멀티스레딩
volatile은 스레드 안전하지 않음
많은 개발자가 volatile을 멀티스레딩에 사용하려 하지만, 이는 잘못된 접근입니다. volatile이 보장하는 것은 “매번 메모리에서 읽고 쓴다”는 것뿐이고, “여러 스레드가 동시에 접근해도 안전하다”는 것은 전혀 다른 문제입니다. 아래 예제는 counter++라는 한 줄이 실제로는 읽기·수정·쓰기라는 세 단계로 나뉘어 실행된다는 점을 보여주는데, volatile은 이 세 단계 사이에 다른 스레드가 끼어드는 것을 막아주지 못합니다.
#include <thread>
#include <iostream>
// ❌ volatile은 원자성 보장 안함
volatile int counter = 0;
void increment() {
for (int i = 0; i < 100000; ++i) {
counter++; // 이 한 줄이 실제로는 3단계 연산!
// CPU 레벨에서 실제 동작:
// 1. counter 값을 메모리에서 레지스터로 읽기 (read)
// 2. 레지스터 값을 1 증가 (modify)
// 3. 레지스터 값을 메모리에 쓰기 (write)
// 문제: 두 스레드가 동시에 실행하면
// Thread 1: read(0) → modify(1) → [인터럽트]
// Thread 2: read(0) → modify(1) → write(1)
// Thread 1: write(1)
// 결과: 2가 되어야 하는데 1이 됨!
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "counter: " << counter << std::endl;
// 예상: 200000 (100000 + 100000)
// 실제: 150000 정도 (경쟁 조건으로 일부 증가 손실)
return 0;
}
#include <atomic>
#include <thread>
#include <iostream>
// ✅ atomic 사용
std::atomic<int> counter{0};
void increment() {
for (int i = 0; i < 100000; ++i) {
counter++; // 원자적 연산!
// std::atomic의 동작:
// 1. CPU의 원자적 명령어 사용 (예: x86의 LOCK ADD)
// 2. read-modify-write가 하나의 원자적 연산으로 실행
// 3. 다른 스레드가 중간에 끼어들 수 없음
// 4. 메모리 순서도 보장 (다른 스레드가 변경사항을 즉시 볼 수 있음)
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "counter: " << counter << std::endl;
// 200000 (정확) - 경쟁 조건 없음
return 0;
}
volatile vs atomic 비교:
| 특성 | volatile | atomic |
|---|---|---|
| 최적화 방지 | ✓ | ✓ |
| 원자성 (atomicity) | ✗ | ✓ |
| 메모리 순서 (memory ordering) | ✗ | ✓ |
| 경쟁 조건 방지 | ✗ | ✓ |
| 용도 | 하드웨어 레지스터, 시그널 | 멀티스레딩 |
핵심 교훈: 멀티스레딩에는 절대 volatile 사용하지 말고 std::atomic 사용!
4. volatile 포인터
포인터 vs 값
volatile을 포인터와 함께 쓸 때는 “포인터 자체가 volatile인가”와 “포인터가 가리키는 값이 volatile인가”를 구분해야 합니다. 이 둘은 서로 독립적으로 조합할 수 있고, 어느 쪽에 붙이느냐에 따라 컴파일러가 매번 메모리에 접근해야 하는 대상이 달라집니다.
#include <cstdint>
// 포인터가 volatile (포인터 자체가 변경될 수 있음)
volatile int* ptr1;
ptr1 = nullptr; // 매번 메모리 접근
// 값이 volatile (가리키는 값이 변경될 수 있음)
int volatile* ptr2;
*ptr2 = 10; // 매번 메모리 접근
// 둘 다 volatile
volatile int* volatile ptr3;
실전 예제
포인터 자체는 항상 같은 하드웨어 주소를 가리키므로 const로 고정하고, 그 주소가 가리키는 레지스터 값은 하드웨어가 임의로 바꿀 수 있으므로 volatile을 붙이는 조합이 실무에서 가장 흔하게 쓰입니다.
#include <cstdint>
#include <iostream>
// 하드웨어 레지스터 배열
volatile uint32_t* const REGISTER_BASE =
reinterpret_cast<volatile uint32_t*>(0x40000000);
void writeRegister(int index, uint32_t value) {
// REGISTER_BASE는 const (변경 불가)
// 가리키는 값은 volatile (매번 메모리 접근)
REGISTER_BASE[index] = value;
}
uint32_t readRegister(int index) {
return REGISTER_BASE[index];
}
5. 자주 발생하는 문제
문제 1: 멀티스레딩 오해
volatile이 “매번 메모리를 읽는다”는 것과 “다른 스레드가 쓴 값을 즉시, 그리고 올바른 순서로 본다”는 것은 다른 이야기입니다. CPU와 컴파일러는 성능을 위해 명령어 순서를 재배치할 수 있는데, volatile은 이 재배치를 막아주지 않으므로 아래 코드처럼 data를 먼저 쓰고 ready를 나중에 쓰는 순서가 다른 스레드 입장에서 뒤바뀌어 보일 수 있습니다.
#include <thread>
#include <iostream>
// ❌ volatile은 동기화 안함
volatile bool ready = false;
int data = 0;
void producer() {
data = 42;
ready = true; // volatile이지만 메모리 순서 보장 안함
}
void consumer() {
while (!ready) {}
std::cout << data << std::endl; // 경쟁 조건! (0 또는 42)
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
return 0;
}
#include <atomic>
#include <thread>
#include <iostream>
// ✅ atomic 사용
std::atomic<bool> ready{false};
int data = 0;
void producer() {
data = 42;
ready.store(true, std::memory_order_release); // 메모리 순서 보장
}
void consumer() {
while (!ready.load(std::memory_order_acquire)) {}
std::cout << data << std::endl; // 42 (안전)
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
return 0;
}
해결책: 멀티스레딩에는 std::atomic을 사용하세요.
문제 2: 성능 영향
volatile은 컴파일러가 값을 레지스터에 캐싱하지 못하게 강제하므로, 반복문 안에서 값을 계속 읽고 쓰는 코드라면 매번 실제 메모리에 접근하는 비용이 그대로 드러납니다. 아래 벤치마크는 같은 누적 계산을 volatile 변수와 일반 변수로 각각 수행해 그 차이를 직접 측정합니다.
#include <iostream>
#include <chrono>
int main() {
// volatile: 최적화 방지 (느림)
volatile int sum1 = 0;
auto start1 = std::chrono::high_resolution_clock::now();
for (int i = 0; i < 10000000; ++i) {
sum1 += i; // 매번 메모리 접근
}
auto end1 = std::chrono::high_resolution_clock::now();
// 일반 변수: 레지스터 사용 (빠름)
int sum2 = 0;
auto start2 = std::chrono::high_resolution_clock::now();
for (int i = 0; i < 10000000; ++i) {
sum2 += i; // 레지스터 사용
}
auto end2 = std::chrono::high_resolution_clock::now();
auto duration1 = std::chrono::duration_cast<std::chrono::milliseconds>(end1 - start1).count();
auto duration2 = std::chrono::duration_cast<std::chrono::milliseconds>(end2 - start2).count();
std::cout << "volatile: " << duration1 << " ms" << std::endl;
std::cout << "일반: " << duration2 << " ms" << std::endl;
return 0;
}
해결책: 필요한 경우에만 volatile을 사용하세요.
문제 3: 메모리 순서
volatile 변수 두 개를 순서대로 대입해도 그 순서가 다른 스레드나 CPU 코어에도 그대로 보인다는 보장은 없습니다. std::atomic의 memory_order_release/memory_order_acquire처럼 명시적인 메모리 순서 지정이 있어야 “이 값을 본 시점에는 저 값도 이미 반영되어 있다”는 것을 보장할 수 있습니다.
// ❌ volatile은 순서 보장 안함
volatile int a = 0;
volatile int b = 0;
void func() {
a = 1;
b = 2;
// 컴파일러나 CPU가 순서를 바꿀 수 있음
}
// ✅ atomic으로 순서 보장
std::atomic<int> a{0};
std::atomic<int> b{0};
void func() {
a.store(1, std::memory_order_release);
b.store(2, std::memory_order_release);
// 메모리 순서 보장
}
6. volatile vs atomic
| 특징 | volatile | atomic |
|---|---|---|
| 최적화 방지 | ✓ | ✓ |
| 원자성 | ✗ | ✓ |
| 메모리 순서 | ✗ | ✓ |
| 스레드 안전 | ✗ | ✓ |
| 용도 | 하드웨어, 시그널 | 멀티스레딩 |
| 성능 | 느림 | 빠름 (lock-free) |
7. 실전 예제: 장치 드라이버
앞서 다룬 개념들을 모아 실제 UART(시리얼 통신) 장치 드라이버를 흉내 낸 예제입니다. 각 레지스터를 volatile로 선언해 두었기 때문에, sendByte/receiveByte의 폴링 루프가 캐시된 옛날 값이 아니라 하드웨어가 실제로 갱신한 상태 레지스터 값을 매번 확인합니다.
#include <cstdint>
#include <iostream>
#include <thread>
#include <chrono>
// UART 장치 레지스터
struct UARTRegisters {
volatile uint32_t data; // 데이터 레지스터
volatile uint32_t status; // 상태 레지스터
volatile uint32_t control; // 제어 레지스터
volatile uint32_t baudrate; // 보드레이트 레지스터
};
class UARTDriver {
UARTRegisters* uart;
static constexpr uint32_t STATUS_TX_READY = 0x01;
static constexpr uint32_t STATUS_RX_READY = 0x02;
public:
UARTDriver(uintptr_t baseAddress)
: uart(reinterpret_cast<UARTRegisters*>(baseAddress)) {}
void init(uint32_t baudrate) {
uart->baudrate = baudrate;
uart->control = 0x03; // TX/RX 활성화
}
void sendByte(uint8_t byte) {
// TX 준비 대기
while (!(uart->status & STATUS_TX_READY)) {
// 폴링 (volatile이므로 매번 메모리 읽기)
}
// 데이터 전송
uart->data = byte;
}
uint8_t receiveByte() {
// RX 준비 대기
while (!(uart->status & STATUS_RX_READY)) {
// 폴링
}
// 데이터 수신
return static_cast<uint8_t>(uart->data);
}
void sendString(const std::string& str) {
for (char c : str) {
sendByte(static_cast<uint8_t>(c));
}
}
};
int main() {
// 실제 하드웨어 주소 (예시)
UARTDriver uart(0x40004000);
uart.init(9600);
uart.sendString("Hello, UART!");
return 0;
}
정리
핵심 요약
- volatile: 컴파일러 최적화 방지
- 매번 메모리 접근: 레지스터 캐싱 금지
- 용도: 하드웨어, 시그널, MMIO
- 멀티스레딩:
volatile대신std::atomic - 원자성:
volatile은 보장 안함 - 메모리 순서:
volatile은 보장 안함
volatile vs atomic
| 상황 | 권장 | 이유 |
|---|---|---|
| 하드웨어 레지스터 | volatile | 최적화 방지 필요 |
| 시그널 핸들러 | volatile sig_atomic_t | 원자적 타입 |
| 멀티스레딩 | std::atomic | 원자성, 동기화 |
| 일반 변수 | 일반 타입 | 불필요한 volatile 금지 |
실전 팁
사용 원칙:
- 하드웨어 레지스터:
volatile필수 - 시그널 핸들러:
volatile sig_atomic_t - 멀티스레딩:
std::atomic사용 - 일반 변수:
volatile불필요
성능:
volatile은 최적화 방지로 느림- 필요한 변수에만 사용
- 멀티스레딩은
atomic이 더 빠름
주의사항:
volatile은 원자성 보장 안함volatile은 메모리 순서 보장 안함- 멀티스레딩에
volatile사용 금지
다음 단계
- C++ Atomic
- C++ Memory Order
- C++ Cache Optimization
관련 글
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 임베디드 시스템에서 하드웨어 레지스터를 직접 다루거나, 시그널 핸들러에서 변경되는 플래그를 확인하거나, 메모리 매핑 I/O를 폴링할 때 volatile이 필요합니다. 반대로 멀티스레드 환경에서 변수를 공유할 때는 volatile 대신 std::atomic을 써야 합니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 메모리 모델 | ‘동시성’ 메모리 모델 가이드
- Python 성능 최적화 실전 사례 | 데이터 처리 속도 100배 개선기
- C++ Allocator | ‘메모리 할당자’ 커스터마이징 가이드
이 글에서 다루는 키워드 (관련 검색어)
C++, volatile, optimization, hardware, keyword 등으로 검색하시면 이 글이 도움이 됩니다.