C++ volatile: 하드웨어 레지스터와 시그널 핸들러용 키워드, 스레드 동기화에 못 쓰는 이유
이 글의 핵심
volatile은 컴파일러가 변수 값을 레지스터에 캐싱하지 못하게 막아 매번 메모리에서 읽고 쓰도록 강제하는 키워드로, 하드웨어 레지스터·시그널 핸들러·메모리 매핑 I/O에 쓰입니다. 원자성과 메모리 순서는 보장하지 않으므로 멀티스레딩에는 std::atomic을 써야 합니다.
들어가며
volatile 키워드는 컴파일러에게 변수가 외부 요인에 의해 변경될 수 있음을 알립니다. 이를 통해 컴파일러 최적화를 방지하며, 매번 메모리에서 값을 읽고 쓰도록 강제합니다.
표준이 volatile에 대해 약속하는 것은 생각보다 좁습니다. volatile 객체에 대한 접근은 프로그램의 “관찰 가능한 동작”으로 취급되므로, 컴파일러는 소스에 적힌 횟수와 순서 그대로 volatile 접근을 수행해야 하고 이를 없애거나 합치거나 volatile 접근끼리 순서를 바꿀 수 없습니다. 하지만 volatile이 아닌 일반 메모리 접근과의 순서, CPU가 실행 중에 하는 재배치, 여러 코어 사이의 가시성은 전혀 다루지 않습니다. 그래서 volatile은 “컴파일러와 하드웨어 장치 사이의 약속”이지 “스레드와 스레드 사이의 약속”이 아니라고 기억하면, 아래의 올바른 용도와 잘못된 용도가 자연스럽게 구분됩니다.
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 레지스터가 외부 장치에 의해 변경됨
첫 번째 예제에서 “다른 스레드”를 언급했지만, 스레드 사이의 플래그라면 volatile을 붙여도 여전히 올바르지 않습니다. 일반 변수를 여러 스레드가 동기화 없이 읽고 쓰는 것은 데이터 레이스라서 정의되지 않은 동작이고, volatile은 이 규칙을 바꾸지 않습니다. 대부분의 x86 컴파일러에서 volatile 플래그가 “동작하는 것처럼” 보이는 것은 우연에 가깝고, 그 경우에도 플래그 앞뒤의 다른 데이터가 보이는 순서는 보장되지 않습니다. 스레드 사이라면 std::atomic<bool>이 정답이며, 이 차이는 3절에서 자세히 다룹니다. 반대로 인터럽트 핸들러가 바꾸는 변수를 메인 루프가 폴링하는 단일 코어 임베디드 환경에서는 volatile이 고전적인 해법입니다.
하드웨어 레지스터·시그널 핸들러·메모리 매핑 I/O
하드웨어 레지스터
임베디드 시스템에서 하드웨어를 직접 제어할 때 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로 “이 메모리는 예측 불가능하게 변한다”고 알림- 매번 실제 메모리에서 읽어야 최신 값을 얻음
하드웨어 레지스터에서는 읽기와 쓰기 자체가 부작용인 경우가 많다는 점도 volatile이 필요한 이유입니다. UART 데이터 레지스터를 읽으면 수신 버퍼에서 한 바이트가 빠지고, 일부 상태 레지스터는 읽는 순간 인터럽트 플래그가 지워지며(read-to-clear), 어떤 레지스터는 같은 값을 두 번 써야 잠금이 풀립니다. 일반 변수라면 컴파일러가 “같은 값을 두 번 쓰는 것”을 한 번으로 줄이거나 “읽고 쓰지 않는 값”을 아예 읽지 않을 수 있는데, 하드웨어에게는 그 한 번의 접근이 의미를 가집니다. 반대로 *GPIO_DATA |= (1 << pin)은 읽기-수정-쓰기라서, 그 사이에 인터럽트 핸들러가 같은 레지스터의 다른 비트를 바꾸면 그 변경이 덮어써집니다. 많은 MCU가 비트 단위로 설정·해제하는 별도의 SET/CLEAR 레지스터를 제공하는 것도 이 문제를 피하기 위해서입니다. C++20부터 volatile에 대한 복합 대입이 한때 deprecated되었던 배경도 여기에 있습니다(C++23에서 |=, &=, ^=는 다시 허용).
시그널 핸들러
#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;
}
시그널 핸들러는 프로그램의 어느 명령 사이에서든 끼어들 수 있으므로, 핸들러와 메인 코드가 공유하는 변수에는 두 가지가 필요합니다. 컴파일러가 루프 조건을 레지스터에 캐싱하지 않게 하는 volatile과, 쓰기 도중에 끊기지 않는 크기의 타입인 sig_atomic_t입니다. 여기서 “원자적”은 스레드 간 원자성이 아니라 “시그널에 의해 중간에 끊기지 않는다”는 좁은 의미입니다. 핸들러 안에서 할 수 있는 일도 매우 제한적이어서, std::cout, printf, malloc처럼 내부에 락을 쓰는 함수를 호출하면 메인 코드가 같은 락을 잡은 순간 시그널이 오면 교착 상태가 됩니다. 핸들러에서는 이 예제처럼 플래그만 세우고 실제 처리는 메인 루프에서 하는 것이 원칙이며, signal() 대신 동작이 더 잘 정의된 POSIX sigaction()을 쓰는 편이 좋습니다.
메모리 매핑 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;
}
이 코드에서 volatile은 컴파일러가 레지스터 접근의 횟수와 순서를 바꾸지 못하게 해 주지만, CPU와 버스 수준의 순서는 별개의 문제입니다. 일반 메모리처럼 캐시되고 재배치될 수 있는 영역에 장치를 매핑하면, control에 쓴 값보다 data에 쓴 값이 장치에 먼저 도착하는 일이 생길 수 있습니다. 그래서 운영체제와 MCU는 장치 레지스터 영역을 캐시되지 않고 순서가 유지되는 “디바이스 메모리”로 매핑하고, 그래도 순서가 중요한 지점에는 ARM의 dmb/dsb 같은 메모리 배리어를 넣습니다. 리눅스 커널 드라이버가 volatile 포인터를 직접 쓰지 않고 readl()/writel() 같은 접근 함수를 쓰는 것도 아키텍처별로 필요한 배리어를 함께 처리하기 위해서입니다. 또 이 예제의 폴링 루프는 장치가 응답하지 않으면 영원히 돌기 때문에, 실제 드라이버에서는 반복 횟수나 시간 제한을 두어 타임아웃 오류를 반환해야 합니다.
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)
// 실제: 200000보다 작은 값이 나올 수 있음 (실행마다 다름, 경쟁 조건으로 증가 손실)
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 사용!
std::atomic이 “최적화를 막는다”고 설명하는 경우가 많지만 정확하지는 않습니다. atomic은 원자성과 지정한 메모리 순서를 보장하는 데 필요한 만큼만 최적화를 제한하며, 표준은 컴파일러가 연속된 atomic 연산을 합치는 것을 이론상 허용합니다(주요 컴파일러는 보수적으로 거의 하지 않습니다). 반대로 volatile은 원자성을 주지 않는 대신 모든 접근을 소스 그대로 수행하게 합니다. 그래서 하드웨어 레지스터처럼 “접근 한 번 한 번이 의미를 가지는” 곳에는 volatile이, 스레드 사이 데이터 공유에는 atomic이 맞고, 두 가지가 모두 필요한 드문 경우(여러 코어가 공유하는 장치 메모리 등)에는 volatile std::atomic<T>처럼 둘을 함께 쓰기도 합니다.
volatile 포인터와 포인터가 가리키는 값
포인터 vs 값
volatile을 포인터와 함께 쓸 때는 “포인터 자체가 volatile인가”와 “포인터가 가리키는 값이 volatile인가”를 구분해야 합니다. 이 둘은 서로 독립적으로 조합할 수 있고, 어느 쪽에 붙이느냐에 따라 컴파일러가 매번 메모리에 접근해야 하는 대상이 달라집니다. 규칙은 const와 똑같습니다. *의 왼쪽에 있는 volatile은 가리키는 대상에, 오른쪽에 있는 volatile은 포인터 변수 자체에 붙습니다. 오른쪽에서 왼쪽으로 읽으면 헷갈리지 않습니다(int* volatile p는 “p는 volatile 포인터이고, int를 가리킨다”).
#include <cstdint>
// 가리키는 값이 volatile (volatile int* 와 int volatile* 는 같은 의미)
volatile int* ptr1;
int volatile* ptr2;
// *ptr1 = 10; // 가리키는 값에 대한 접근이 매번 메모리에서 일어남
// 포인터 자체가 volatile (주소 값을 매번 다시 읽음, 가리키는 값은 일반 접근)
int* volatile ptr3;
// 둘 다 volatile
volatile int* volatile ptr4;
실전 예제
포인터 자체는 항상 같은 하드웨어 주소를 가리키므로 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];
}
volatile 포인터를 다룰 때 흔한 함정은 캐스팅으로 volatile을 벗겨 내는 것입니다. memcpy(buf, (void*)REGISTER_BASE, 16)처럼 volatile 대상을 일반 포인터로 바꿔 표준 함수에 넘기면, 그 함수 안에서의 접근은 volatile 규칙을 따르지 않아 바이트 단위로 쪼개 읽거나 순서를 바꿀 수 있습니다. 표준상으로도 volatile 객체를 non-volatile glvalue로 접근하는 것은 정의되지 않은 동작입니다. 레지스터 블록을 복사해야 한다면 원소 하나씩 volatile 포인터로 읽는 루프를 써야 합니다. 또 32비트 레지스터를 volatile uint8_t*로 바이트 단위 접근하면 버스가 32비트 접근만 허용하는 장치에서 오류가 날 수 있으므로, 데이터시트가 요구하는 접근 폭에 맞는 타입을 쓰는 것이 중요합니다.
멀티스레딩 오해·성능 영향·메모리 순서
멀티스레딩 오해
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을 사용하세요.
release/acquire 쌍이 이 문제를 해결하는 방식은 “release로 쓴 값을 acquire로 읽은 스레드는, release 이전에 일어난 모든 쓰기를 볼 수 있다”는 규칙입니다. 그래서 ready만 atomic이면 되고 data는 일반 변수로 두어도 안전합니다. volatile 버전이 실패하는 경로는 두 가지입니다. 컴파일러는 data = 42(일반 쓰기)를 volatile 쓰기 ready = true 뒤로 옮길 수 있고, ARM처럼 약한 메모리 모델을 가진 CPU는 컴파일러가 순서를 지켜도 다른 코어에 두 쓰기가 반대 순서로 보이게 할 수 있습니다. x86은 쓰기 순서를 비교적 엄격하게 지키기 때문에 이 버그가 x86 개발 환경에서는 거의 재현되지 않다가 ARM 서버나 모바일 기기에서 드러나는 경우가 많습니다. ThreadSanitizer(-fsanitize=thread)는 volatile 변수에 대한 이런 경쟁도 데이터 레이스로 보고하므로, 기존 코드에서 volatile 플래그를 찾아 정리할 때 유용합니다.
성능 영향
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을 사용하세요.
이 벤치마크는 결과를 해석할 때 주의가 필요합니다. sum2는 이후에 전혀 쓰이지 않으므로 최적화 빌드에서 두 번째 루프가 통째로 사라져 0ms가 나올 수 있고, 이는 “레지스터가 빠르다”가 아니라 “계산 자체를 하지 않았다”는 뜻입니다. 공정하게 비교하려면 sum2를 출력해야 합니다. 또 0부터 1000만까지의 합은 약 5×10¹³으로 int 범위를 넘으므로, 부호 있는 정수 오버플로라는 정의되지 않은 동작이 됩니다. 실제로 재 보려면 long long을 쓰세요. 이런 함정을 고치고 나면 volatile 버전은 매 반복 메모리 읽기와 쓰기를 해야 하고 벡터화도 막히므로 확연히 느린 결과가 나옵니다. 이 성질은 역으로 벤치마크에서 컴파일러가 코드를 지우지 못하게 하는 용도로 volatile을 쓰는 관행으로 이어지기도 하지만, 그 경우에도 측정하려는 계산 자체의 최적화까지 막아 버리므로 Google Benchmark의 DoNotOptimize 같은 도구가 더 정확합니다.
메모리 순서
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);
// 메모리 순서 보장
}
volatile vs atomic
| 특징 | volatile | atomic |
|---|---|---|
| 최적화 방지 | ✓ | ✓ |
| 원자성 | ✗ | ✓ |
| 메모리 순서 | ✗ | ✓ |
| 스레드 안전 | ✗ | ✓ |
| 용도 | 하드웨어, 시그널 | 멀티스레딩 |
| 비용 | 접근마다 실제 메모리 읽기/쓰기, 최적화 제한 | 메모리 순서에 따라 다름 (relaxed는 저렴, seq_cst 쓰기는 배리어 필요) |
두 키워드의 비용은 “무엇을 보장하느냐”에 비례합니다. volatile 접근은 일반 로드·스토어 명령 그대로라 명령 하나의 비용은 같지만, 값을 레지스터에 두거나 루프를 벡터화하는 최적화가 막혀 전체 코드가 느려집니다. atomic의 load/store는 x86에서 memory_order_relaxed나 acquire/release라면 일반 명령과 거의 같은 비용이지만, 기본값인 seq_cst 쓰기나 fetch_add 같은 읽기-수정-쓰기 연산은 lock 접두사나 배리어가 필요해 훨씬 비쌉니다. 그래서 “atomic이 volatile보다 빠르다”는 일반화는 성립하지 않고, 둘은 성능이 아니라 해결하는 문제가 다르다고 보는 것이 정확합니다.
UART 장치 드라이버 흉내 내기
앞서 다룬 개념들을 모아 실제 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 사용 요약
핵심 요약
- 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사용 금지
다음 단계
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. volatile int* p와 int* volatile p는 어떻게 다른가요?
A. volatile int* p는 포인터가 가리키는 값이 volatile이라는 뜻이라서, *p를 읽거나 쓸 때마다 컴파일러가 실제 메모리에 접근합니다. 하드웨어 레지스터를 다룰 때 필요한 것은 대부분 이 형태입니다. int* volatile p는 포인터 변수 자체가 volatile이라 주소 값을 매번 다시 읽지만 가리키는 값에 대한 접근은 최적화될 수 있으며, 둘 다 필요하면 volatile int* volatile p처럼 양쪽에 붙입니다.