34. 엄격한 앨리어싱(strict aliasing)과 타입 퍼닝(type punning)
난이도 상내 리뷰 · C++
해설 · C++
해설 — 엄격한 앨리어싱(strict aliasing)과 타입 퍼닝(type punning)
난이도: 상
요약
컴파일러는 "서로 다른(호환되지 않는) 타입의 lvalue는 같은 메모리를 가리키지 않는다"고
가정할 수 있다(엄격한 앨리어싱 규칙, [basic.lval]). 이 가정 덕에 재적재(reload)를
생략하는 최적화가 가능하지만, reinterpret_cast로 타입을 속여 접근하면 미정의
동작(UB) 이 되어 최적화 레벨에 따라 결과가 달라진다. 비트 재해석의 표준 정답은
std::memcpy(항상 정의됨) 또는 std::bit_cast(C++20)다.
핵심 개념: 엄격한 앨리어싱 규칙
객체의 저장된 값에 접근할 때, 허용되는 lvalue 타입은 제한적이다: 객체의 실제 타입,
그 cv-한정 버전, 부호만 다른 정수 타입, 그리고 char/unsigned char/std::byte
(이들은 무엇이든 별칭 가능). int를 float*로 읽거나 그 반대는 이 목록에 없으므로
UB다. 컴파일러는 "int*와 float*는 절대 겹치지 않는다"고 믿고 코드를 생성한다.
단계별 내부 동작
세 가지 퍼닝 함수 (main 출력)
reinterpret = 0x3F800000
memcpy = 0x3F800000
union = 0x3F800000
- 값 자체는 셋 다
1.0f의 IEEE-754 비트0x3F800000으로 보이지만, 표준 준수도는 전혀 다르다:reinterpretPun:float객체를uint32_tlvalue로 접근 → UB. 지금은 "우연히" 맞는 값이 나오지만, 최적화·타깃·컴파일러 버전에 따라 깨질 수 있다.memcpyPun: 항상 정의됨.memcpy는 바이트 단위 복사로 규정되어 앨리어싱 규칙을 우회한다. 최적화 시 실제 복사 없이 레지스터 이동으로 접힌다(비용 0에 수렴).unionPun: C++에서 "마지막에 쓴 멤버가 아닌 다른 멤버 읽기"는 표준상 미정의로 분류되지만, GCC/Clang은 확장으로 허용(C 방식). 이식성은 memcpy보다 낮다.
aliasingDemo — 최적화 레벨에 따라 달라지는 값
-O0: aliasingDemo returns 1065353216 // = 0x3F800000, 즉 *pi 를 다시 읽음
-O2: aliasingDemo returns 42 // *pf 쓰기를 무시하고 42 를 그대로 반환
- 함수는
int* pi와float* pf를 받는데, main에서 같은 주소(&storage)를 넘긴다. 표준상 이는 UB(int 객체를 float lvalue로 씀). - -O0: 최적화가 없어
return *pi가 메모리를 다시 읽는다.*pf = 1.0f가 같은 메모리를 덮었으므로1.0f의 비트(0x3F800000= 1065353216)가 읽힌다. - -O2: 컴파일러는 엄격한 앨리어싱으로 "
pf(float)는pi(int)와 안 겹친다"고 가정한다. 따라서*pf = 1.0f가*pi를 바꿀 리 없다고 보고,*pi를 재적재하지 않고 방금 쓴42를 레지스터에서 그대로 반환한다. - 같은 소스가 최적화 레벨에 따라 다른 답을 낸다는 것 자체가 UB의 증거다.
-fno-strict-aliasing을 주면 -O2에서도 42가 아닌 재적재 결과가 나오지만, 이는 "규칙을 끄는" 것이지 코드가 올바른 것은 아니다.
네트워크 버퍼를 구조체로 안전하게 해석하기
// 나쁨: UB (버퍼 바이트를 Packet lvalue 로 접근)
Packet* p = reinterpret_cast<Packet*>(buf); auto id = p->id; // 위험
// 좋음: memcpy 로 값 복사 (정렬·앨리어싱 모두 안전)
Packet p;
std::memcpy(&p, buf, sizeof(Packet));
// C++20: std::bit_cast (컴파일타임 가능, trivially copyable 한정)
auto p2 = std::bit_cast<Packet>(bufAsArray);
char*로 바이트를 순회하며 필드를 조립하는 것도 안전하다(char는 만능 별칭).
흔한 오해·함정
- "
reinterpret_cast로 캐스팅해서 잘 돌아가는데?" → 저 최적화 레벨/그 컴파일러에서 우연히 맞은 것.-O2, LTO, 다른 아키텍처에서 조용히 깨진다. - 정렬(alignment) 문제 별개: 버퍼가
Packet의 정렬 요구를 못 맞추면reinterpret_cast접근은 앨리어싱과 별도로 정렬 UB(일부 아키텍처에서 SIGBUS).memcpy는 정렬 문제도 함께 회피한다. - "union이면 안전하다" → C에서는 그렇지만 C++ 표준상으론 미정의(컴파일러 확장에 의존). 이식 코드는 memcpy/bit_cast.
char/std::byte는 무엇이든 별칭 가능하지만 역방향은 아니다(임의 버퍼를 T로 읽는 것은 여전히 위험). 안전한 건 T→바이트 방향의 검사·복사.
면접 포인트
- "엄격한 앨리어싱이 뭐고 왜 있나?" → 컴파일러가 서로 다른 타입 포인터의 비앨리어싱을 가정해 재적재/벡터화 최적화를 하기 위함. 이를 어기면 UB.
- "float 비트를 int로 꺼내는 이식적 방법?" →
std::memcpy또는 C++20std::bit_cast.reinterpret_cast역참조와 union 읽기는 표준상 UB/미정의. - "왜 -O2에서만 버그가?" → 최적화가 앨리어싱 가정을 실제로 활용하기 때문. UB는 "최적화가 켜지면 드러나는" 전형적 클래스다.