C언어 strict aliasing과 restrict — gcc/clang 최적화를 어셈블리로 확인하기

-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가 담당한다.

참고

답글 남기기