C 언어 시리즈 #05 — 포인터 연산·엄격 별칭(strict aliasing)·유효성
이 글의 핵심
포인터 덧셈이 의미하는 바이트·요소 단위, one-past-the-end, float*와 int*가 같은 메모리를 가리킬 때 왜 UB가 되는지, restrict가 최적화에 주는 힌트를 정리합니다.
시리즈 안내
#05 | 📋 전체 목차 | 이전: #04 함수·ABI · 다음: #06 배열·문자열
이번 글은 포인터를 “그냥 주소 숫자”로만 보지 말고, 타입이랑 수명, 별칭(aliasing) 계약까지 같이 묶어서 읽자는 취지야. 근데 순서는 좀 미친 듯이 바꿨어. 일단 망할 수 있는 것부터 보고, 그다음에 차분하게 기초로 내려갈게.
포인터 잘못 써서 서버 전체가 한번 흔들렸던 날
포인터 잘못 써서 서버 전체가 잠깐이었지만 거의 난리 난 적이 있었어. 프로덕션에서만 터지는 스타일이었지. 개발기에선 멀쩡하다가, 트래픽 올라가고 최적화 빌드 깔리고 나서 간헐적으로 터지는 그거. 로그는 애매하고, 코어 덤프는 운 좋을 때만 잡히고, 팀 채널은 “재현 돼요?”만 수십 번 돌고.
원인은 결국 이미 free한 메모리를 가리키는 포인터를 한 스레드가 건드린 거였는데, 타이밍이 맞을 때만 레이스가 터져서 며칠 동안 “환경 문제 아님?” 소리가 나왔어. 포인터 하나가 느슨하면 시스템 전체가 아니어도 서비스 한 덩어리는 충분히 갈 수 있다는 걸 그때 실감했지. 우아한 추상화랑 상관없이, 밑바닥은 주소랑 수명이야.
valgrind랑 사흘을 보낸 썰
그 전에 다른 프로젝트에서 Valgrind memcheck만 붙잡고 사흘을 꼬박 보낸 적이 있어. 프로덕션에선 한 달에 한 번 정도만 터지는 냄새나는 버그였는데, 재현 스크립트 짜고, 로그 싸질러도 “Invalid read” 한 줄이 찍힐까 말까였지. 결국 리눅스 박스 하나 띄워서 valgrind —leak-check=full —show-leak-kinds=all 걸고 바이너리 돌렸더니, 첫날엔 엄청 느려서 CI에서 쓰던 데이터로는 타이밍이 안 맞았어. 둘째 날엔 부하를 조금씩 올려가며 결국 use-after-free 스택이 찍혔고, 셋째 날은 그 경로가 우리 코드가 아니라 플러그인 쪽 콜백이랑 엮인 걸 추적해서 고쳤지. 그때 느낀 건 간단해. 포인터 버그는 재현이 지옥이고, 도구 없이는 시간이 갈린다는 것. GDB로 bt만 찍고 있으면 끝이 없을 때가 많아서, 힙이면 Valgrind나 ASan 계열을 먼저 생각하게 됐어.
먼저 볼 것: 포인터가 왜 이렇게 위험한지
널·미초기화·범위 밖
NULL을 역참조하면 보통 UB고, 일부 환경에선 바로 SIGSEGV로 죽어. 더 악질인 건 초기화 안 된 지역 포인터야. 쓰레기 값이 우연히 “그럴듯한 주소”처럼 보이면, 당장 안 죽고 며칠 뒤에 터질 수 있어. 범위 밖이면 one-past-the-end 말고 역참조하는 순간 OOB인데, 이건 AddressSanitizer가 꽤 잘 잡아줘.
댕글링 포인터
수명 끝난 메모리를 가리키는 거야. free 뒤에 같은 p를 쓰거나, 스택 변수 주소를 함수 밖으로 들고 나가면 대표적이지. 겉으로는 돌아가는 것처럼 보이다가 Valgrind에서만 “Invalid read”로 터지는 패턴이야.
int *bad(void) {
int x = 0;
return &x; /* x의 수명은 함수 종료와 함께 끝(일반) — UB */
}
Double free / Use-after-free
UAF는 free 다음에 읽기·쓰기, 더블 프리는 같은 블록을 두 번 free. 보안 이슈랑도 직결돼서, free 직후 p = NULL로 한 번 막는 건 완벽하진 않아도 실수 한 번은 줄여줘.
메모리 누수
realloc 경로나 에러 때 early return, 플러그인 생명주기 빠뜨리면 RSS만 천천히 올라가. 장기 프로세스면 모니터링이랑 도구로 같이 봐야 해.
-O0에선 되는데 -O2에서만 터지는 그런 것 (strict aliasing)
C는 타입 안 맞는 포인터끼리 같은 저장을 동시에 건드리면 최적화 가정이 깨질 수 있어서 UB로 가는 길이 있어. 컴파일러는 “별칭 없다”고 레지스터에 값 캐시해두는데, int*로 쓰고 float*로 읽는 식으로 겹치면 모델이 무너지거든. char/unsigned char 바이트 접근 쪽은 또 이야기가 다르고.
void bad_alias(int *i, float *f) {
*i = 1;
*f = 2.0f; /* *같은 주소*를 가리키면 UB 가능(일반) */
}
restrict
restrict는 “이 경로로만 이 영역에 접근한다, 겹침 없다”는 약속이야. 거짓말이면 UB. 멀티스레드에서 버퍼 겹치면 대표적으로 꼬여.
void add_vec(int * restrict a,
const int * restrict b,
const int * restrict c, size_t n) {
for (size_t i = 0; i < n; i++) a[i] = b[i] + c[i];
}
-fno-strict-aliasing은 타입 룰을 느슨하게 해서 숨길 수는 있어도 성능이 나빠질 수 있고, 장기적으로는 memcpy나 맞는 union 패턴으로 고치는 편이 낫다는 쪽에 나는 서 있어.
Effective type랑 memcpy
malloc으로 받은 덩어리에 처음으로 의미 있게 값을 쓸 때 effective type이 잡히는 흐름이 있어서, 바이트만 fread로 채운 뒤 T로 그냥 통째로 읽는 건 위험할 수 있어. 네트워크 패킷을 int32_t로 읽고 싶으면 memcpy로 int32_t 변수에 복사하거나, 비트 연산으로 명시적으로 decode하는 쪽이 안전 궤도야.
#include <stdint.h>
#include <string.h>
int32_t read_i32(const unsigned char b[4]) {
int32_t v;
memcpy(&v, b, sizeof v);
return v;
}
C23 쪽은 포인터 provenance를 표준 문맥에서 더 명시하려는 흐름이 있고, 포인터를 그냥 정수랑 동일하다고만 보면 안 되는 경우가 있다는 점만 기억해둬.
uint32_t be32_load(const unsigned char p[4]) {
return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) |
((uint32_t)p[2] << 8) | (uint32_t)p[3];
}
도구 쪽: Valgrind, ASan, GDB (짧게)
Valgrind는 리눅스에서 느리지만 memcheck가 누수랑 invalid read/write를 잘 찍어줘. 위에 썰처럼 며칠 걸려도 결국 원인 찍어주는 경우가 많고.
AddressSanitizer는 -g -O1 정도에 -fsanitize=address 넣으면 OOB·상황에 따라 UAF까지 런타임에 잡아줘서 CI에도 자주 얹어. UBSan(-fsanitize=undefined)은 strict aliasing 같은 건 완벽하진 않아도 개발할 땐 신호가 되고.
GDB는 터질 때 bt로 스택 보는 정도가 기본이고, ASan 붙은 바이너리는 첫 위반에서 멈추면서 할당 경로를 잘 보여주는 경우도 많아. 코어 덤프는 재현성 낮은 버그에 특히 도움이 되는데, PIE·ASLR이면 심볼이랑 빌드 ID 맞추는 건 팀 정책으로 가져가야 해.
이제 기초: 포인터가 뭔데
포인터는 어떤 객체의 주소를 담는 객체야. T *p는 “T를 가리키는 포인터”고, p 안에 들어 있는 건 그 객체의 주소 값이지. 주소 비트 너비는 sizeof(void *)로 확인하면 돼.
선언 읽는 법
int *a, b는 함정이야. *는 선언자에 붙으니까 a만 int 포인터고 b는 그냥 int. 둘 다 포인터면 int *a, *b;처럼 써야 하고, 팀마다 int* a처럼 붙이기도 하지만 한 줄에 여러 개 선언 자체를 금지하는 스타일도 많아.
#include <stdio.h>
int main(void) {
int x = 42;
int *p = &x;
printf("x = %d\n", x);
printf("p가 가리키는 곳: %d\n", *p);
return 0;
}
&x는 주소, *p는 역참조라서 lvalue가 돼서 *p = 0; 같은 대입도 가능해.
널이랑 초기화
대상 없으면 널 포인터 NULL로 두는 게 보통이고, 지역 T *p;만 선언한 채로 *p 하면 거의 바로 UB야. C23부터는 nullptr 키워드도 생겼어.
int *p = NULL;
if (p == NULL) {
/* 아직 가리킬 대상 없음 */
}
const·volatile
const T *p는 읽기만 하겠다는 의도고, T * const p는 포인터 자체가 불변이라 다른 객체로 못 바꿔. volatile T *p는 MMIO 같은 데서 컴파일러가 접근을 생략하지 못하게 하는 힌트인데, 멀티스레딩 동기화의 기본 답은 아니야. 동시성은 stdatomic 쪽으로 가는 게 안전하고.
메모리 주소랑 역참조 (바이트 관점)
포인터 값은 주소에 타입(그리고 strict aliasing 문맥에서의 접근 의미)이 붙어 있어. 객체는 연속된 바이트고, unsigned char 관점은 객체 표현에 손대는 표준적인 길 중 하나야.
int x = 0x12345678;가 있으면 엔디안은 플랫폼마다 다르고, 메모리는 연속 바이트 슬롯으로 그리기 쉽지. int *p = &x;면 p는 그 int 객체의 대표 주소를 담는다고 보면 돼.
*p는 p가 가리키는 int 객체고, p[0]은 배열이 아니어도 포인터 연산 의미로 *(p+0)과 같아 (아래 포인터 연산과 연결).
스택에 int *p가 지역 x를 가리킬 때, 그 주소로 *p 하는 건 함수가 살아 있는 동안은 대개 안전한데, return 뒤에 그 주소 쓰는 건 수명 위반이야.
uintptr_t로 주소를 정수로 옮겨 로그·해시에 쓸 수는 있는데, 그 정수에 막 산술해서 다시 포인터 만들면 정렬·수명·provenance 문제로 UB로 갈 수 있어서 경로 전체를 봐야 해. printf("%p", (void *)p);나 PRIxPTR로 찍는 팀도 많고, 공개 로그에선 ASLR이랑 같이 주의해야 해.
포인터 연산: 스케일링이랑 배열
T *p에 대해 p + n은 n개의 T 객체만큼 이동이야. 바이트 n이 아니라 n * sizeof(T) 바이트로 가는 느낌.
배열 T a[N]에서 a + N은 one-past-the-end인데, 비교에는 쓸 수 있어도 그 주소를 역참조하면 UB야. 루프에서 end에 도달하는 것과 읽는 것은 다르다는 점이 핵심.
#include <stddef.h>
void walk(const int *a, size_t n) {
const int *end = a + n;
for (const int *p = a; p != end; p++) {
(void)*p;
}
}
p - q는 같은 배열(또는 one-past) 안에서의 차이고 타입은 ptrdiff_t야. 아무 관계 없는 주소끼리 빼면 제약이 커.
char buf[16]에 막 T * 캐스팅해서 쓰면 정렬 위반이랑 effective type 문제로 UB 갈 수 있어서, 네트워크 버퍼는 memcpy나 alignas 쪽을 생각해.
p[i]랑 i[p]는 교육용으로 가끔 나오는데 실무에선 p[i]로 통일하자.
const int *const first = a;
const int *const last = a + n;
for (const int *p = first; p < last; ++p) { /* *p */ }
다중 포인터: **랑 main의 argv
T**는 T*를 가리키는 거고, main(int argc, char **argv)가 대표적이지. 호출자 쪽 포인터 자체를 바꾸려면 T **가 필요할 때가 있어.
#include <stdlib.h>
void reseat(int **pp, int *newp) {
*pp = newp;
}
int main(void) {
int a = 1, b = 2;
int *p = &a;
reseat(&p, &b);
return p == &b ? 0 : 1;
}
다중 포인터는 소유권이랑 수명이 한꺼번에 꼬이기 쉬워서 typedef로 단계 드러내는 것도 방법이야.
typedef int *IntPtr;
typedef IntPtr *IntPtrPtr;
배열이냐 포인터냐: T (*p)[N] vs T *a[N]
T (*p)[N]은 길이 N인 T 배열 전체를 가리키는 포인터고, T *a[N]은 포인터 N개 담은 배열이야. char *argv[] 같은 게 후자 쪽 느낌.
void print_row3(int (*row)[3]) {
for (int i = 0; i < 3; i++) {
printf("%d ", (*row)[i]);
}
printf("\n");
}
int main(void) {
int m[2][3] = {{1,2,3},{4,5,6}};
print_row3(&m[1]);
return 0;
}
int a = 1, b = 2, c = 3;
int *arr[3] = {&a, &b, &c};
int a[10];에서 대부분 문맥에선 a는 int *로 디케이한다는 얘기는 시리즈 #06에서 더 팔게. &a[0]이랑 a는 값은 같게 보이는 경우가 많은데 타입은 &a는 int (*)[10]로 달라져서, +1이 뭘 건너뛰는지가 갈려.
함수 포인터
함수 포인터는 함수 식별자가 포인터 문맥으로 가는 그거야. f랑 &f가 문맥에 따라 비슷하게 취급되는 흐름 흔하지.
#include <stdio.h>
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int main(void) {
int (*op)(int, int) = add;
printf("%d\n", op(2, 3));
op = sub;
printf("%d\n", op(2, 3));
return 0;
}
typedef int (*binop_t)(int, int);
binop_t pick(char op) { return op == '+' ? add : sub; }
qsort 비교 함수가 const void*인 이유는, 넘기는 쪽에서 T*로 캐스팅해서 읽기 때문이야.
#include <stdlib.h>
int icmp(const void *a, const void *b) {
int x = *(const int *)a, y = *(const int *)b;
return (x > y) - (x < y);
}
int main(void) {
int a[] = {3,1,2};
qsort(a, 3, sizeof a[0], icmp);
return 0;
}
동적 메모리: malloc, calloc, realloc, free
힙은 malloc 계열로 받고 free랑 짝 맞춰야 해. 이중 free나 엉뚱한 포인터 free는 UB.
malloc은 초기화 안 된 바이트를 주고, calloc은 보통 0으로 잡아주는 쪽을 기대하지만 malloc(0) 같은 경계는 구현마다 다르니 방어적으로 가는 게 좋아.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *p = (int *)malloc(3 * sizeof *p);
if (!p) { return 1; }
p[0] = 1; p[1] = 2; p[2] = 3;
free(p);
return 0;
}
realloc은 블록을 옮길 수 있어서, 이전 포인터는 반환값 확인 전에 만지지 말고 새 포인터로 가는 게 안전해.
p = realloc(p, new_size);
if (!p && new_size != 0) {
return 1;
}
malloc(a * b) 곱이 size_t에서 넘치면 조용히 잘못된 작은 할당이 될 수 있어서, 실서비스는 안전한 곱 유틸이랑 상한을 같이 거는 경우가 많아.
calloc이랑 realloc로 가변 버퍼 짤 때도 실패 시 기존 블록이 살아 있는지는 구현 문서 보면서 처리해야 하고.
구조체랑 ->, .
s.field는 값 struct S s일 때, p->field는 struct S *p일 때. (*p).field랑 같고.
#include <stdio.h>
typedef struct {
int x;
int y;
} Point;
int main(void) {
Point p = {1, 2};
Point *pp = &p;
printf("%d\n", pp->x);
return 0;
}
offsetof로 필드 위치 묻는 건 패딩이랑 ABI 바뀔 수 있으니까 테스트·문서랑 같이 봐.
#include <stddef.h>
#include <stdio.h>
typedef struct {
char tag;
int value;
} Msg;
int main(void) {
printf("offsetof(Msg, value)=%zu\n", offsetof(Msg, value));
return 0;
}
void*
void *는 임의 객체를 가리킬 수 있게 설계됐고, C에선 T*랑 제약 있게 상호 변환되지만 자동 역참조는 안 돼. 꼭 T*로 바꾼 다음 *나 []로.
int x = 42;
void *vp = &x;
int *ip = (int *)vp;
*ip = 0;
제네릭 핸들은 void*에 길이는 size_t로 짝짓는 패턴이 흔하고, 직렬화·네트워크 쪽은 위에서 말한 memcpy·명시 decode로 가져가.
실전 예: 리스트랑 문자열
단일 연결 리스트 (교육용)
#include <stdio.h>
#include <stdlib.h>
typedef struct Node {
int value;
struct Node *next;
} Node;
static Node *node_new(int v) {
Node *n = (Node *)malloc(sizeof *n);
if (!n) return NULL;
n->value = v; n->next = NULL;
return n;
}
static void list_free(Node *head) {
while (head) {
Node *nxt = head->next;
free(head);
head = nxt;
}
}
int main(void) {
Node *h = node_new(1);
if (!h) return 1;
h->next = node_new(2);
if (!h->next) { list_free(h); return 1; }
for (Node *p = h; p; p = p->next) {
printf("%d\n", p->value);
}
list_free(h);
return 0;
}
힙에 Node a -> Node b -> NULL 같이 잡혀 있으면, 순회는 for (Node *p = head; p; p = p->next)가 포인터 루프랑 딱 맞닿는 구조고, 누가 next를 갱신하냐가 버그·누수를 갈라.
list_prepend 같이 head를 바꾸려면 Node **가 필요하다는 건 위에서 reseat이랑 같은 이야기야.
void list_prepend(Node **head, Node *n) {
n->next = *head;
*head = n;
}
문자열 쪽
#include <string.h>
#include <stdio.h>
int main(void) {
const char *s = "a=1&b=2";
const char *eq = strchr(s, '=');
if (eq) {
/* eq+1 이후 파싱은 범위 체크랑 같이 */
}
return 0;
}
strtok는 내부 static이라 스레드나 재입력에 애매할 수 있어서 strtok_r 계열이 있으면 그걸 쓰는 쪽이 낫고.
길이 모를 때 strcpy는 OOB 위험이니까 snprintf나 팀 래퍼로 막자.
void copy_name(char *dst, size_t dsz, const char *src) {
if (dsz == 0) return;
(void)snprintf(dst, dsz, "%s", src);
}
strstr로 : 찾은 뒤 p+1로 넘어가는 패턴은 char가 1바이트 요소라 포인터 +1이 “다음 문자”로 가는 전형적인 켰고, 전체 범위 체크는 여전히 필요해.
최적화랑 “값이냐 포인터냐”
작은 struct 몇 word는 값으로 넘겨도 복사가 싸다 싶을 수 있고, 큰 건 const T*로 넘기는 식이 흔해. 포인터가 항상 더 빠른 건 아니고, 캐시라인이랑 별칭, ABI가 갈라져.
typedef struct { double x, y, z, w; } V4;
void by_ptr(const V4 *v) { (void)v->x; }
V4 by_value(V4 v) { return v; }
-O2에 -fstrict-aliasing 켜진 상태에선 타입 안 맞는 캐스팅이 간헐적 버그로 나올 수 있어서, “디버그에선 됐는데 릴리스만 터진다”면 UB·별칭 의심해봐. Sanitizer랑 리뷰를 같이 가져가.
전역 T* 같은 거
static int *g;가 파일 스코프에 있든, 가리키는 쪽이 free되면 UAF. 전역·TLS 핸들은 락이랑 소유권 문서 없으면 수명 잡기 어렵다.
static int *g;
void set_bad(int *p) { g = p; }
sizeof(T*)는 포인터 객체 크기지 가리키는 배열 전체 길이가 아니고, 동적 길이는 size_t로 따로 넘기는 식이야. 힙 블록 정렬은 malloc이 구현/표준이 약속하는 쪽에 기대되지만, 민감하면 플랫폼 API까지 확인.
체크리스트 느낌으로 (표 말고 그냥 말로)
캐스팅으로 타입 뜯는 것보다 memcpy·decode·검증된 union 쪽. size_t 오프셋이랑 곱은 오버플로 날 수 있으니 경로마다 상한. Sanitizer로 OOB랑 UBSan 시나리오는 선제로 잡기. CI에서 경고는 -Wall -Wextra 쪽 올리고, 가능하면 ASan+UBSan은 개발·스테이징에 얹는 팀 많아. -Wformat 계열로 printf랑 포인터 실수도 같이 잡을 수 있고.
이전에 한 줄로 정리하자면
T *p에 대해 p+n은 요소 단위 이동이고, a+N one-past는 역참조 금지가 핵심이야. strict aliasing는 겹쳐 읽고 쓰는 경로에 제약이 있고, restrict는 거짓이면 UB. malloc·memcpy·Sanitizer·ABI는 이 교차로에서 자주 잡혀.
요약
포인터는 주소+타입+수명이 묶인 값이야. 배열·함수·구조체·힙 API는 전부 포인터로 말이 나가고, UB는 “가끔 되는 것처럼” 보일 수 있어서 strict aliasing·OOB·UAF·double free는 프로덕션에서 진짜 아프다.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나?
A. 힙/스택/정적 영역에 걸친 소유권, 네트워크·디스크에서 바이트 → 구조체 해석, 대용량 memcpy·SIMD까지 포인터는 계층마다 밑받침이야. strict aliasing·restrict·malloc·Sanitizer·ABI는 그 교차로에서 자주 터지는 주제고.
Q. 선행으로 읽으면 좋은 글은?
A. C 언어 시리즈 목차에서 #02 타입, #04 함수·ABI, 이어 #06 배열, #07 구조체 정도를 같이 보면 흐름이 이어져. relatedPosts에도 비슷하게 걸어뒀어.
Q. 더 깊이 공부하려면?
A. C 표준이랑 cppreference C 항목, Compiler Explorer로 -O2 IR 비교(별칭·벡터화), ASan/Valgrind로 실측 같이 가는 쪽이 체감이 제일 커.
같이 보면 좋은 글 (내부 링크)
- C 언어 시리즈 #06 — 배열·디케이(decay)·문자열 리터럴·VLA
- C 언어 시리즈 #07 — 구조체·공용체·비트필드·패딩과 ABI
- C 언어 시리즈 #02 — 타입·승격(usual arithmetic)·정수 표현과 패딩
이 글에서 다루는 키워드 (관련 검색어)
C, 포인터, strict aliasing, restrict, 미정의 동작, malloc/realloc/free, void*, valgrind, AddressSanitizer, one-past-the-end, restrict, memcpy, effective type 등.