C 언어에서 버그가 생기는 자리: 메모리 레이아웃, 포인터 aliasing, UB, 링킹
이 글의 핵심
스택·힙·데이터·텍스트 세그먼트, 포인터 연산과 strict aliasing, 대표적인 UB 패턴, 전처리→컴파일→어셈블→링크 흐름, 그리고 프로덕션에서 쓰는 방어적 C 관례까지 다룹니다.
C는 기계에 가깝고 언어가 강제하는 규칙이 적습니다. 그만큼 미정의 동작(undefined behavior, UB)과 구현 정의(implementation-defined) 동작을 얼마나 잘 피하느냐가 실제 제품의 품질을 가릅니다. 이 글은 문법을 나열하는 대신, 프로세스가 메모리를 어떻게 쓰는지, 포인터가 왜 위험한지, UB가 컴파일러 최적화와 어떻게 상호작용하는지, 빌드가 어떤 단계를 거치는지, 그리고 현장에서 어떤 습관으로 버그가 생길 여지를 줄이는지를 차례로 살펴봅니다.
메모리 레이아웃: 스택·힙·데이터·텍스트
운영체제가 ELF(리눅스 등)나 PE(Windows) 같은 실행 파일을 로드하면, 프로세스의 가상 주소 공간은 대략 텍스트(코드), 읽기 전용 데이터, 읽기/쓰기 데이터, 힙, 스택으로 나뉩니다. 정확한 배치와 이름은 플랫폼, 링커 스크립트, PIE(위치 독립 실행 파일) 여부에 따라 달라지며, 아래는 개념적인 모델입니다.
텍스트 세그먼트(코드, .text)
기계어 명령이 올라가는 영역입니다. 보통 실행 가능·읽기 전용으로 매핑되므로, 코드를 데이터처럼 덮어쓰는 실수는 OS 수준에서 막힙니다. 함수의 주소는 이 영역 안의 특정 위치를 가리킵니다.
문자열 리터럴도 보통 읽기 전용 데이터(.rodata)에 놓입니다. 그래서 char *s = "hello"; s[0] = 'H';는 컴파일은 되지만(문자열 리터럴 수정은 UB입니다) 실행하면 세그멘테이션 오류로 죽는 경우가 많습니다. 수정할 문자열이라면 char s[] = "hello";처럼 배열로 선언해 스택이나 데이터 영역에 복사본을 만들어야 합니다.
초기화된 전역/정적 데이터(.data)
int g = 42;처럼 0이 아닌 초기값이 있는 파일 범위 변수와 static 변수가 여기에 놓입니다. 초기값이 실행 파일 안에 그대로 들어 있다가, 로드될 때 메모리로 매핑됩니다.
BSS(0으로 초기화되는 전역/정적 데이터, .bss)
static int x;처럼 0으로 초기화되는 전역/정적 데이터는 실행 파일에 크기 정보만 기록되고, 로더가 프로그램 시작 시 해당 영역을 0으로 채운 메모리로 매핑합니다. 0으로 채워진 바이트를 파일에 실제로 담지 않으므로 커다란 0 초기화 배열이 있어도 실행 파일 크기가 늘지 않는다는 것이 BSS를 따로 두는 이유입니다. static char buf[1 << 20];은 파일 크기에 영향을 주지 않지만, static char buf[1 << 20] = {1};은 1MB가 통째로 .data에 들어갑니다.
스택
자동 저장 기간(automatic storage duration)을 가진 지역 변수와 호출 프레임(반환 주소, 저장된 레지스터, 지역 변수)이 쌓이는 곳입니다. 함수 호출이 깊어지거나 큰 지역 배열을 두면 스택 오버플로가 납니다. 리눅스 메인 스레드의 기본 스택 크기는 보통 8MB(ulimit -s), Windows는 1MB입니다. 그래서 리눅스에서 잘 돌던 double m[512][512];(2MB) 지역 배열이 Windows에서는 시작하자마자 죽는 일이 흔합니다. 스택은 높은 주소에서 낮은 주소로 자라는 구현이 많지만 표준이 정한 것이 아니므로, 스택 방향에 의존하는 코드는 이식성이 없습니다.
힙
malloc/calloc/realloc/free(또는 C11의 aligned_alloc 등)로 관리하는 영역입니다. 수명은 프로그래머가 할당과 해제 시점으로 관리하며, 단편화, 이중 해제(double free), 해제 후 사용(use-after-free) 같은 버그가 여기서 생깁니다.
realloc은 특히 함정이 많습니다. p = realloc(p, n);처럼 결과를 원래 포인터에 바로 대입하면, 실패해 NULL이 돌아왔을 때 원래 블록의 주소를 잃어 누수가 생깁니다. 결과를 임시 변수에 받아 NULL이 아닌지 확인한 뒤 대입해야 합니다.
수명과 저장 기간
지역 변수의 수명은 블록이 끝날 때, malloc으로 할당한 메모리는 free할 때, 전역·정적 변수는 프로그램이 끝날 때 끝납니다. 자동, 정적, 할당된 저장 기간을 혼동하면 댕글링 포인터가 생깁니다. 지역 배열의 주소를 반환하는 함수가 대표적인 예입니다.
초기화하지 않은 자동 변수의 값은 불명확(indeterminate)하며, 주소를 한 번도 취하지 않은 자동 변수를 초기화 전에 읽는 것은 UB입니다. 디버그 빌드에서는 우연히 0이 들어 있다가 릴리스 빌드에서 쓰레기 값이 나오는 전형적인 원인이므로, 지역 변수는 항상 초기화하는 것이 안전합니다.
포인터 연산과 에일리어싱(aliasing)
포인터 연산의 전제
T *p에 대해 p + n은 바이트 단위가 아니라 sizeof(T) 크기의 요소 단위로 이동합니다. 같은 배열 객체 안에서의 이동은 잘 정의되고, 배열 마지막 원소 바로 다음 위치(one-past-the-end)까지는 주소를 만들고 비교할 수 있지만 역참조하면 안 됩니다. 그보다 더 밖으로 나가는 주소는 만드는 것만으로도 UB입니다.
#include <stddef.h>
void walk_ints(int *base, size_t n) {
for (size_t i = 0; i < n; i++) {
base[i] = (int)i; /* *(base + i) 와 같은 의미 */
}
}
정렬(alignment)
char 버퍼의 임의 위치를 int *로 캐스트해 읽고 쓰면 정렬 위반으로 UB가 될 수 있습니다. x86에서는 정렬되지 않은 일반 로드·스토어가 대부분 동작하기 때문에 문제가 숨어 있다가, 정렬을 요구하는 일부 ARM 환경이나 정렬된 SIMD 명령을 쓰도록 벡터화된 코드에서 갑자기 크래시(SIGBUS 또는 SIGSEGV)로 드러납니다. alignas/aligned_alloc(C11)이나 플랫폼별 API로 요구 정렬을 만족하는 주소를 쓰고, 네트워크 패킷처럼 정렬을 보장할 수 없는 버퍼에서는 memcpy로 값을 꺼내야 합니다.
Strict aliasing 규칙
C에는 객체를 그 객체의 실제 타입(effective type)과 호환되지 않는 타입의 lvalue로 접근하면 안 된다는 aliasing 규칙이 있습니다. 예외적으로 char/unsigned char로는 어떤 객체의 바이트 표현이든 읽을 수 있습니다. 반대로 float *와 uint32_t *로 같은 주소를 오가며 비트를 재해석하는 타입 퍼닝(type punning)은 이 규칙을 어기며, 컴파일러 최적화와 충돌해 찾기 어려운 버그를 만듭니다.
#include <stdint.h>
#include <string.h>
float bits_to_float(uint32_t u) {
float f;
memcpy(&f, &u, sizeof f); /* 표준이 허용하는 재해석 방법 */
return f;
}
memcpy는 바이트를 다른 객체로 복사하는 것이므로 effective type 문제를 피하는 표준적인 관용구입니다. 함수 호출처럼 보이지만 크기가 상수라 GCC와 Clang은 최적화 시 레지스터 이동 명령 하나로 바꿔 주므로 성능 손해도 없습니다. C에서는 공용체(union)의 다른 멤버를 읽는 방법도 허용되지만, 같은 코드를 C++에서 쓰면 UB이므로 두 언어에서 공유하는 헤더라면 memcpy가 안전합니다. 반면 *(float *)&u처럼 캐스트로 해결하면 -O2부터 켜지는 -fstrict-aliasing 가정과 충돌합니다. 리눅스 커널처럼 오래된 코드베이스가 -fno-strict-aliasing으로 빌드하는 이유가 이 규칙을 지키지 않는 코드가 많기 때문입니다.
restrict(C99)
restrict 한정자는 “이 포인터로 접근하는 객체는 이 포인터를 통해서만 접근한다”는 프로그래머의 약속이며, 약속을 어기면 UB입니다. memcpy의 매개변수에는 restrict가 붙어 있고 memmove에는 없다는 점이 두 함수의 차이를 그대로 보여 줍니다.
겹치는 영역에 memcpy를 쓰는 코드는 특정 구현에서 우연히 동작하다가 라이브러리가 바뀌면 깨집니다. 2010년 glibc가 memcpy를 뒤에서부터 복사할 수 있도록 최적화했을 때, 겹치는 영역에 memcpy를 쓰던 Adobe Flash 플러그인 등에서 소리가 깨지는 문제가 드러난 것이 잘 알려진 사례입니다. “UB는 오늘 되는 코드가 내일 깨지는 것”의 전형입니다. 수치 커널에서 restrict는 벡터화를 돕지만, 약속이 거짓이면 최적화가 틀린 코드를 만듭니다.
미정의 동작(UB) 패턴: 왜 “내 PC에선 됐는데”가 나오나
UB는 표준이 어떤 동작도 요구하지 않는 상황입니다. 컴파일러는 “올바른 프로그램에는 UB가 없다”는 전제로 최적화하므로, UB가 있는 코드에서는 예상과 전혀 다른 기계어가 나올 수 있습니다. 대표적인 패턴을 살펴봅니다.
부호 있는 정수 오버플로
INT_MAX + 1 같은 연산은 부호 있는 정수에서 UB입니다. 하드웨어가 2의 보수로 감싸 준다고 기대하면 안 되는 이유는, 컴파일러가 이 전제를 이용해 for (int i = 0; i <= n; i++) 같은 루프에서 “i는 절대 음수로 돌아가지 않는다”고 보고 최적화하기 때문입니다. 반면 unsigned 연산은 모듈로 연산으로 잘 정의됩니다. 안전한 검사는 연산하기 전에 범위를 비교하는 것입니다.
#include <limits.h>
#include <stdbool.h>
bool add_checked(int a, int b, int *out) {
if ((b > 0 && a > INT_MAX - b) || (b < 0 && a < INT_MIN - b))
return false; /* 오버플로가 날 연산은 하지 않음 */
*out = a + b;
return true;
/* GCC·Clang: return !__builtin_add_overflow(a, b, out); 도 가능 */
/* C23: <stdckdint.h>의 ckd_add(out, a, b)는 오버플로 시 true 반환 */
}
널 포인터 역참조와 정렬되지 않은 접근
널 포인터를 읽고 쓰는 것은 UB이고, 정렬되지 않은 포인터로 객체를 접근하는 것도 UB입니다. 널 역참조가 “그냥 크래시”로 끝나지 않을 수 있다는 점이 중요합니다. 2009년 리눅스 커널의 TUN 드라이버에는 포인터를 먼저 역참조한 뒤 그 아래에서 NULL 검사를 하는 코드가 있었는데, GCC가 “이미 역참조했으니 NULL일 수 없다”고 판단해 검사 자체를 지웠고, 이것이 권한 상승 취약점으로 이어졌습니다. 커널이 이후 -fno-delete-null-pointer-checks로 빌드하게 된 계기입니다. 널 검사는 반드시 첫 사용보다 앞에 두어야 합니다.
댕글링 포인터와 잘못된 수명
해제된 메모리나, 스택 프레임이 끝난 뒤의 지역 변수 주소를 사용하면 UB입니다. 이런 버그는 해제된 메모리가 아직 다른 용도로 재사용되지 않은 동안에는 멀쩡히 동작하기 때문에 테스트에서 잘 드러나지 않습니다. AddressSanitizer(-fsanitize=address)를 켜면 ERROR: AddressSanitizer: heap-use-after-free on address ... 메시지와 함께 할당·해제·사용 위치의 스택 트레이스를 모두 보여 주므로, 재현이 불규칙한 크래시를 만나면 가장 먼저 켜 볼 도구입니다. free(p); p = NULL; 습관은 같은 포인터로 인한 이중 해제를 막아 주지만, 같은 블록을 가리키는 다른 포인터 사본까지 보호하지는 못합니다.
데이터 레이스(C11 스레드와 메모리 모델)
C11의 <threads.h>나 POSIX 스레드를 쓸 때, 원자 연산이나 뮤텍스 없이 같은 객체에 충돌하는 접근(최소 하나가 쓰기)을 하면 데이터 레이스이며 UB입니다. volatile은 이 문제를 해결하지 못합니다. volatile은 메모리 매핑 레지스터처럼 접근 자체를 생략하면 안 되는 경우를 위한 것이고, 원자성과 스레드 간 순서를 보장하지 않습니다. 스레드 간 공유 변수에는 <stdatomic.h>의 _Atomic 타입을 쓰고, -fsanitize=thread(TSan)로 실행 중에 레이스를 잡을 수 있습니다.
Strict aliasing 위반
앞에서 설명한 aliasing 규칙을 어기면, 컴파일러는 “타입이 다른 두 포인터는 서로 다른 메모리를 가리킨다”고 가정하고 한쪽으로 쓴 값을 다른 쪽에서 다시 읽지 않은 채 레지스터에 남은 이전 값을 그대로 쓸 수 있습니다. -O0에서는 정상이고 -O2에서만 값이 틀리는 버그라면 이 경우를 의심해 볼 만합니다.
미정의 동작과 미지정(unspecified) 동작
함수 인자의 평가 순서처럼 미지정 동작은 가능한 결과 중 하나가 나오지만 어느 것일지는 정해지지 않은 경우입니다. 반면 i = i++ + 1;이나 a[i] = i++;처럼 순서가 정해지지 않은 상태에서 같은 변수를 두 번 수정하거나 수정과 읽기를 섞는 것은 미정의 동작입니다. 둘 다 컴파일러마다 결과가 달라질 수 있으므로, 부작용이 있는 식은 문장을 나눠 쓰는 편이 안전합니다. GCC는 -Wsequence-point(-Wall에 포함)로 후자의 상당수를 경고해 줍니다.
컴파일과 링킹: 전처리부터 실행 파일까지
전처리
#include, #define, 조건부 컴파일을 처리해 컴파일러가 읽을 토큰 스트림을 만듭니다. 헤더 중복 포함 방지, 플랫폼별 분기, 매크로 API가 이 단계에서 결정됩니다. 매크로 관련 에러가 이해되지 않을 때는 gcc -E file.c로 전처리 결과를 직접 보면 원인이 금방 드러납니다.
컴파일(번역 단위)
각 .c 파일은 번역 단위(translation unit)로 컴파일되어 목적 파일(object file, .o/.obj)이 됩니다. 이 단계에서는 다른 파일에 있는 심볼 정의를 모르기 때문에, 외부 함수나 변수에 대한 참조는 미해결 상태로 남깁니다. 선언 없이 함수를 호출하는 암묵적 함수 선언은 C99에서 제거되었고, GCC 14부터는 기본적으로 경고가 아니라 에러(implicit declaration of function)가 되어 오래된 코드가 새 컴파일러에서 빌드되지 않는 일이 많아졌습니다.
링킹
링커는 목적 파일과 정적 라이브러리를 모아 하나의 실행 파일(또는 공유 라이브러리)을 만듭니다. 외부 심볼의 이름 해석, 중복 정의, 강한 심볼과 약한 심볼 문제가 여기서 드러납니다. 대표적인 에러는 undefined reference to 'foo'(선언은 있는데 정의가 링크 대상에 없음)와 multiple definition of 'g'(같은 정의가 여러 목적 파일에 있음)입니다.
두 번째 에러는 GCC 10에서 기본값이 -fno-common으로 바뀌면서 갑자기 많이 보이게 되었습니다. 헤더에 int counter;처럼 초기값 없는 전역 변수를 두고 여러 .c 파일에서 포함하면, 예전에는 “공통 심볼”로 합쳐져 조용히 링크되었지만 이제는 중복 정의 에러가 납니다. 올바른 방법은 헤더에 extern int counter; 선언만 두고, 정의는 .c 파일 하나에만 두는 것입니다. -fcommon으로 예전 동작을 되돌릴 수는 있지만 임시방편일 뿐입니다.
정적 라이브러리는 명령줄 순서도 중요합니다. GNU ld는 라이브러리를 왼쪽에서 오른쪽으로 한 번만 훑으며 그 시점까지 미해결인 심볼만 가져오기 때문에, gcc -lfoo main.o처럼 라이브러리를 목적 파일보다 앞에 쓰면 undefined reference가 납니다. 라이브러리는 그것을 쓰는 목적 파일 뒤에 둡니다.
동적 링킹
공유 라이브러리는 실행 시점에 로드됩니다. 심볼 버전, rpath/RUNPATH, Windows의 DLL 검색 순서가 배포 환경에서만 나타나는 버그로 이어질 수 있습니다. 개발 머신에서는 되는데 서버에서 error while loading shared libraries: libfoo.so.1: cannot open shared object file이 난다면, ldd ./app으로 실제로 어떤 경로의 라이브러리를 찾는지부터 확인합니다.
실무에서 자주 쓰는 진단 옵션
GCC/Clang에서는 -Wall -Wextra -Wpedantic에 더해 -Wshadow, -Wformat=2, -Wconversion 같은 추가 경고와 Sanitizer(-fsanitize=address,undefined 등)를 조합하는 경우가 많습니다. Sanitizer는 실행 속도와 메모리 사용량을 크게 늘리므로 릴리스 빌드에 켠 채로 배포하지 않고, CI나 야간 테스트에서 돌리는 방식이 흔합니다.
프로덕션 C 프로그래밍 패턴
API 계약을 명시한다
함수마다 NULL을 허용하는지, 반환된 메모리를 누가 해제하는지, 버퍼 길이가 얼마인지를 주석과 타입으로 드러냅니다. 공개 헤더에는 최소한의 선언만 두고, 내부 구현은 static 함수와 내부 헤더로 감춥니다. 구조체 정의를 헤더에 노출하지 않고 typedef struct ctx ctx;처럼 불완전 타입만 공개하면, 호출자가 내부 필드에 의존할 수 없으므로 나중에 구조체를 바꿔도 ABI가 유지됩니다.
오류 처리를 일관되게
malloc 실패, 파일 I/O, 네트워크 호출의 결과는 반드시 검사하고, 오류 코드 체계(enum, int 반환값, errno)를 팀에서 하나로 정합니다. 함수 중간에서 여러 자원을 할당한다면, 실패 시 역순으로 해제하는 goto cleanup 패턴이 C에서 가장 읽기 쉽고 누수가 적은 방식으로 널리 쓰입니다.
버퍼 길이를 항상 같이 넘긴다
strcpy 계열 대신 길이를 인자로 받는 래퍼나 snprintf를 씁니다. strncpy는 이름과 달리 “안전한 strcpy”가 아니라는 점에 주의해야 합니다. 원본이 길면 끝에 '\0'을 붙이지 않고, 짧으면 남은 공간을 전부 0으로 채웁니다. snprintf(dst, size, "%s", src)는 항상 널 종료하고, 잘리지 않았다면 썼을 길이를 반환하므로 반환값이 size 이상인지로 잘림 여부를 알 수 있습니다.
매크로와 부작용
매크로 인자를 괄호로 감싸도 인자가 여러 번 평가되는 문제는 남습니다. #define MAX(a, b) ((a) > (b) ? (a) : (b))에 MAX(i++, j)를 넘기면 i가 두 번 증가할 수 있습니다. 가능하면 static inline 함수로 대체합니다.
이식성과 고정 크기 정수
프로토콜이나 디스크 포맷은 <stdint.h>의 uint32_t 같은 고정 크기 타입으로 표현하고, 객체 크기에는 size_t, 포인터를 정수로 다룰 때는 uintptr_t를 씁니다. long은 64비트 리눅스에서 8바이트, 64비트 Windows에서 4바이트이므로, 파일 포맷이나 네트워크 메시지에 쓰면 플랫폼 간 호환이 깨집니다. 바이트 순서(엔디언)도 함께 정해 두어야 합니다.
빌드 재현성과 플래그
같은 소스라도 최적화 수준에 따라 UB의 증상이 달라집니다. 팀 전체가 같은 컴파일러와 플래그를 쓰고, 경고를 에러로 승격해 새 경고가 쌓이지 않게 합니다. 다만 -Werror는 컴파일러 버전이 올라가 새 경고가 추가되면 빌드를 깨뜨리므로, 서드파티 코드에는 적용하지 않는 식의 정책이 필요합니다.
자주 묻는 질문 (FAQ)
Q. if (x + 1 < x)로 정수 오버플로를 검사하는 코드가 최적화 후 사라지는 이유는 무엇인가요?
C에서 부호 있는 정수 오버플로는 미정의 동작이므로, 컴파일러는 오버플로가 일어나지 않는다고 가정하고 x + 1 < x를 항상 거짓으로 보아 조건문을 지울 수 있습니다. 2의 보수로 감싸질 거라고 기대하는 코드는 이런 최적화에서 깨집니다. 연산 전에 x < INT_MAX처럼 범위를 먼저 비교하거나, GCC·Clang의 __builtin_add_overflow 또는 C23의 ckd_add 같은 검사 함수를 쓰는 것이 안전합니다. unsigned 연산은 모듈로로 정의되어 있어 이 문제가 없습니다.
같이 보면 좋은 글
- C static 심화 가이드: 번역 단위·정적 초기화·TLS·inline·실무 패턴
- C++ Sanitizer로 메모리·UB 버그 잡기
- C++ 정적 초기화 순서 문제(Static Initialization Order Fiasco): 재현과 함수 내 static·constinit 해결