-O0에서 잘 돌던 코드가 -O2로 올리면 값이 달라진다. 디버거로 들여다보면 메모리에는 올바른 값이 들어 있는데 함수가 엉뚱한 값을 반환한다. 대개 범인은 두 가지다. 서로 다른 타입의 포인터로 같은 메모리를 건드리는 strict aliasing 위반이거나, restrict로 “겹치지 않는다”고 약속한 포인터에 겹친 주소를 넘긴 경우다. 둘 다 미정의 동작(undefined behavior)이라 컴파일러는 경고 없이 더 빠른 코드를 내놓을 권리를 행사한다. 이 글에서는 gcc 13.3과 clang 18이 실제로 어떤 어셈블리를 내는지 비교해 두 규칙이 최적화에 어떻게 쓰이는지 정리한다.
$ gcc --version | head -1
gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0
$ clang --version | head -1
Ubuntu clang version 18.1.3 (1ubuntu1)
strict aliasing 규칙
C 표준은 객체에 접근할 때 쓸 수 있는 lvalue 타입을 제한한다. 선언된 타입과 호환되지 않는 타입으로 읽으면 미정의 동작이고, 컴파일러는 “타입이 다른 두 포인터는 같은 메모리를 가리키지 않는다”고 가정해 로드를 생략할 수 있다.
| 허용되는 접근 | 비고 |
|---|---|
| 선언된 타입과 호환되는 타입 | 기본 |
부호만 다른 정수 타입 (int ↔ unsigned int) | 허용 |
const/volatile만 다른 타입 | 허용 |
char, signed char, unsigned char | 모든 타입에 접근 가능한 예외 |
| 공용체(union)의 멤버 | 같은 공용체 안의 다른 멤버로 읽기 허용 |
float ↔ int 같은 무관한 타입 | 미정의 동작 |
타입 퍼닝 세 가지 방법
float의 비트 패턴을 정수로 보는 세 가지 구현을 비교해보면 어느 쪽을 써야 하는지 바로 드러난다.
#include <stdio.h>
#include <string.h>
/* 타입 퍼닝: float 비트를 정수로 보기 */
unsigned bits_cast(float f) { return *(unsigned *)&f; } /* UB */
unsigned bits_memcpy(float f){ unsigned u; memcpy(&u,&f,4); return u; }
unsigned bits_union(float f) { union { float f; unsigned u; } v = {.f=f}; return v.u; }
int main(void)
{
float x = 1.0f;
printf("cast : 0x%08X\n", bits_cast(x));
printf("memcpy: 0x%08X\n", bits_memcpy(x));
printf("union : 0x%08X\n", bits_union(x));
return 0;
}gcc는 캐스트 방식에만 경고를 낸다.
$ gcc -O2 -Wall -Wextra -Wstrict-aliasing=2 -o punning punning.c
punning.c: In function ‘bits_cast’:
punning.c:5:52: warning: dereferencing type-punned pointer will break strict-aliasing rules [-Wstrict-aliasing]
5 | unsigned bits_cast(float f) { return *(unsigned *)&f; } /* UB */
| ^~
$ ./punning
cast : 0x3F800000
memcpy: 0x3F800000
union : 0x3F800000
같은 옵션으로 clang은 아무 경고도 내지 않는다. 경고가 없다는 것이 코드가 합법이라는 뜻은 아니다.
$ clang -O2 -Wall -Wextra -Wstrict-aliasing=2 -o p punning.c
(출력 없음)
$ ./p
cast : 0x3F800000
memcpy: 0x3F800000
union : 0x3F800000
중요한 것은 -O2에서 세 함수의 기계어가 완전히 같다는 점이다. memcpy는 비용이 0이므로 UB를 감수할 이유가 없다.
$ gcc -O2 -S -o - punning.c # 각 함수 본문만 추림
bits_cast:
endbr64
movd %xmm0, %eax
ret
bits_memcpy:
endbr64
movd %xmm0, %eax
ret
bits_union:
endbr64
movd %xmm0, %eax
ret
규칙을 어기면 실제로 값이 달라진다
같은 주소를 int *와 float *로 동시에 넘기면 컴파일러의 가정이 눈에 보이는 차이를 만든다.
#include <stdio.h>
/* ip와 fp가 같은 주소를 가리키면? 표준상 UB다. */
int reload(int *ip, float *fp)
{
*ip = 1;
*fp = 0.0f;
return *ip; /* 1을 재사용해도 되는가? */
}
int main(void)
{
int storage = 0;
int r = reload(&storage, (float *)&storage);
printf("reload() 반환 = %d, 메모리 실제 값(hex) = 0x%08X\n", r, (unsigned)storage);
return 0;
}$ gcc -O0 -o ab alias_break.c && ./ab
reload() 반환 = 0, 메모리 실제 값(hex) = 0x00000000
$ gcc -O2 -o ab alias_break.c && ./ab
reload() 반환 = 1, 메모리 실제 값(hex) = 0x00000000
$ gcc -O2 -fno-strict-aliasing -o ab alias_break.c && ./ab
reload() 반환 = 0, 메모리 실제 값(hex) = 0x00000000
메모리에는 0이 들어 있는데 -O2는 1을 반환한다. 어셈블리를 보면 이유가 분명하다.
# gcc -O2
reload:
endbr64
movl $1, (%rdi)
movl $1, %eax <- *fp 저장 전에 반환값을 1로 상수 접기
movl $0x00000000, (%rsi)
ret
# gcc -O2 -fno-strict-aliasing
reload:
endbr64
movl $1, (%rdi)
movl $0x00000000, (%rsi)
movl (%rdi), %eax <- 저장 후 다시 로드
ret
clang 18도 같은 결론을 낸다. 컴파일러를 바꿔 피할 수 있는 문제가 아니다.
clang -O0 reload() 반환 = 0, 메모리 실제 값(hex) = 0x00000000
clang -O2 reload() 반환 = 1, 메모리 실제 값(hex) = 0x00000000
clang -O2 -fno-strict-aliasing reload() 반환 = 0, 메모리 실제 값(hex) = 0x00000000
char 경유는 합법이다
char 계열 포인터는 예외라서 바이트 단위로 들여다보고 수정하는 것은 최적화 수준과 무관하게 정의된 동작이다.
#include <stdio.h>
/* char 와 unsigned char 포인터는 어떤 타입에도 접근할 수 있는 예외다 */
int via_char(int *p)
{
*p = 0x41424344;
unsigned char *c = (unsigned char *)p;
c[0] = 0x58; /* char 경유 수정은 합법 */
return *p;
}
int main(void){ int v = 0; printf("via_char = 0x%08X\n", (unsigned)via_char(&v)); return 0; }$ gcc -O0 -Wall -Wstrict-aliasing=2 -o ca charalias.c && ./ca
via_char = 0x41424358
$ gcc -O2 -Wall -Wstrict-aliasing=2 -o ca charalias.c && ./ca
via_char = 0x41424358
리틀 엔디언이라 최하위 바이트 0x44가 0x58로 바뀐다. 두 최적화 수준에서 결과가 같다.
restrict가 만드는 차이
restrict는 “이 포인터로 접근하는 객체를 다른 포인터로는 접근하지 않는다”는 약속이다. 컴파일러는 이 약속을 받고 로드를 생략하거나 루프를 벡터화한다.
같은 값을 두 번 더하기
void twice_plain(int *a, int *b) { *a += *b; *a += *b; }
void twice_restrict(int *restrict a, int *restrict b) { *a += *b; *a += *b; }a == b일 수 있으면 두 번째 *b를 다시 읽어야 하므로 저장도 두 번 해야 한다.
$ gcc -O2 -S -o - restrict1.c
twice_plain:
endbr64
movl (%rsi), %eax
addl (%rdi), %eax
movl %eax, (%rdi)
addl (%rsi), %eax <- *b 재로드
movl %eax, (%rdi) <- 저장 2회
ret
twice_restrict:
endbr64
movl (%rsi), %eax
addl %eax, %eax <- *b * 2
addl %eax, (%rdi) <- 저장 1회
ret
clang 18도 바이트 단위로 같은 코드를 만든다.
# clang -O2
twice_plain:
movl (%rdi), %eax
addl (%rsi), %eax
movl %eax, (%rdi)
addl (%rsi), %eax
movl %eax, (%rdi)
retq
twice_restrict:
movl (%rsi), %eax
addl %eax, %eax
addl %eax, (%rdi)
retq
루프 벡터화와 겹침 검사
#include <stddef.h>
void vadd_plain(float *a, const float *b, const float *c, size_t n)
{ for (size_t i = 0; i < n; i++) a[i] = b[i] + c[i]; }
void vadd_restrict(float *restrict a, const float *restrict b,
const float *restrict c, size_t n)
{ for (size_t i = 0; i < n; i++) a[i] = b[i] + c[i]; }-O3에서 두 함수 모두 벡터화되지만, restrict가 없는 쪽은 런타임 겹침 검사와 스칼라 폴백 경로를 함께 들고 있어 코드가 1.6배 길다.
$ gcc -O3 -S -o rl.s restrict2.c
vadd_plain 총 명령줄= 60 SIMD(addps/vaddps)= 2 스칼라 addss= 2
vadd_restrict 총 명령줄= 38 SIMD(addps/vaddps)= 2 스칼라 addss= 1
vadd_plain에만 포인터 거리를 재는 비교와 분기가 들어 있다.
$ awk '/^vadd_plain:/{p=1} p{print} p&&/\.cfi_endproc/{exit}' rl.s | grep -E 'cmp|sub.*%r|ja|jb|lea'
cmpq $1, %rcx
leaq 4(%rsi), %rcx
subq %rcx, %rax
cmpq $8, %rax
jbe .L12 <- 겹치면 스칼라 경로로
leaq 4(%r8), %rcx
subq %rcx, %rax
cmpq $8, %rax
약속을 어기면 결과가 틀린다
restrict는 컴파일러가 검사해주지 않는다. 겹친 포인터를 넘기면 조용히 다른 값이 나온다.
#include <stdio.h>
/* restrict로 "겹치지 않는다"고 약속했는데 겹친 포인터를 넘기면? */
void copy_restrict(int *restrict dst, const int *restrict src, int n)
{ for (int i = 0; i < n; i++) dst[i] = src[i] + 1; }
void copy_plain(int *dst, const int *src, int n)
{ for (int i = 0; i < n; i++) dst[i] = src[i] + 1; }
static void show(const char *tag, int *a, int n)
{ printf("%-16s", tag); for (int i=0;i<n;i++) printf(" %d", a[i]); puts(""); }
int main(void)
{
int a[6] = {0}, b[6] = {0};
copy_plain(b+1, b, 5); /* 겹침: 한 칸 밀며 누적 -> 0 1 2 3 4 5 */
show("plain(겹침)", b, 6);
copy_restrict(a+1, a, 5); /* 같은 겹침인데 restrict로 약속함 */
show("restrict(겹침)", a, 6);
return 0;
}$ gcc -O0 -o rlie restrict_lie.c && ./rlie
plain(겹침) 0 1 2 3 4 5
restrict(겹침) 0 1 2 3 4 5
$ gcc -O2 -o rlie restrict_lie.c && ./rlie
plain(겹침) 0 1 2 3 4 5
restrict(겹침) 0 1 2 3 4 5
$ gcc -O3 -o rlie restrict_lie.c && ./rlie
plain(겹침) 0 1 2 3 4 5
restrict(겹침) 0 1 1 1 1 1 <- 틀린 결과
-O3에서 벡터화가 걸리면서 누적이 깨졌다. 더 고약한 것은 clang 18에서는 -O3까지 올려도 같은 코드가 올바른 결과를 낸다는 점이다.
# clang
--- clang -O0 --- restrict(겹침) 0 1 2 3 4 5
--- clang -O2 --- restrict(겹침) 0 1 2 3 4 5
--- clang -O3 --- restrict(겹침) 0 1 2 3 4 5
한 컴파일러에서 테스트가 통과했다고 UB가 없는 것이 아니다. 최적화 수준이나 컴파일러를 바꾸는 순간 드러난다.
주의사항
| 상황 | 증상 | 대응 |
|---|---|---|
*(T2 *)&x로 타입 퍼닝 | -O2 이상에서 로드가 생략돼 값이 틀어진다 | memcpy나 공용체를 쓴다. -O2에서 비용은 같다 |
| clang에서 경고가 없어 안심 | clang은 -Wstrict-aliasing을 사실상 구현하지 않는다 | gcc로 한 번 더 빌드해 경고를 확인한다 |
-fno-strict-aliasing으로 덮기 | 동작은 맞지만 해당 번역 단위 전체의 최적화를 포기한다 | 원인을 고치는 쪽이 낫다. 커널처럼 이미 이 옵션을 쓰는 코드베이스는 예외 |
restrict를 “최적화 힌트”로 남발 | 겹친 인자가 들어오면 조용히 틀린 결과가 나온다 | 호출자가 겹침을 보장할 수 있는 경계(공개 API 내부 구현 등)에서만 쓴다 |
| 한 컴파일러·한 최적화 수준만 테스트 | clang -O3에서는 통과하는 restrict 위반이 gcc -O3에서 깨진다 | -O0/-O2/-O3과 gcc·clang 양쪽으로 돌린다 |
| UB를 런타임에 잡고 싶음 | 컴파일 경고로는 포인터 겹침을 못 잡는다 | -fsanitize=undefined로 실행 시점 검사를 켠다 |
| 구조체 첫 멤버로 캐스트 | “공통 초기 시퀀스” 규칙은 공용체 안에서만 보장된다 | 공용체로 감싸거나 memcpy로 복사한다 |
마무리
- 타입 퍼닝은
memcpy나 공용체로 하면 되고,-O2에서 캐스트와 기계어가 동일하므로 UB를 감수할 이유가 없다. char·unsigned char경유 접근은 예외로 허용된다.restrict의 이득은 재로드 제거(5개 명령 → 3개 명령)와 겹침 검사 제거(60줄 → 38줄)로 어셈블리에 바로 나타난다.- 약속을 어긴
restrict는 gcc-O3에서 틀린 값을 내고 clang-O3에서는 안 낸다. 통과했다는 것이 안전하다는 근거가 못 된다. - 경고는 gcc가 더 많이 내주고, 런타임 검사는
-fsanitize=undefined가 담당한다.