널 포인터에 관한 흔한 오해들을 기초부터 특이한 사례까지 짚어 보며, 최적화·플랫폼 차이·신호/예외 처리·포인터 출처·주소 0 매핑·WebAssembly·CHERI 등과 얽힌 함정과 예외를 설명한다. C를 “휴대용 어셈블리”로 오해하지 말고 추상 기계의 관점에서 안전하고 이식성 있게 다루는 방법을 제안한다.
2025년 1월 30일 Hacker News Reddit
2월 1일 추가: 이 글은 UB가 무엇이고 왜 유발하면 안 되는지, CPU의 아주 기초적인 동작, 그리고 문맥을 정확히 고려해 구체적인 사실을 과도하게 일반화하지 않는 능력을 전제로 합니다. 여기서 다루는 오해들은 “언제나 틀리다”가 아니라 “전역적으로 성립하지 않는다”는 뜻에서 오해입니다. 이 전제들이 당신에게 문제가 된다면, 이 글을 읽는 것은 오히려 소프트웨어 엔지니어링 역량에 해가 될 수 있으니 읽지 않기를 권합니다. 그럼에도 시도할 경우 어떤 일이 벌어지는지는 Reddit 댓글을 참고하세요.
널 포인터는 표면적으로 단순해 보이기에 더 위험합니다. 컴파일러 최적화, 직관적이지만 잘못된 단순화, 플랫폼 특이점들이 겹치면서 잘못된 가정을 하기 쉬워졌고, 그 결과 버그와 취약점이 퍼졌습니다.
이 글은 많은 프로그래머들이 갖는 널 포인터에 대한 흔한 오해들을, 단순한 것부터 시작해 가장 괴상한 경우까지 살펴봅니다. 초보자에게 새로운 사실도 있겠지만, 전문가에게도 꼼꼼한 사실 검증이 필요하도록 만드는 내용이 있을 것입니다. 그럼 시작합니다.
널 포인터 역참조는 즉시 프로그램을 크래시시킨다.
C, C++, Rust에서 널 포인터를 처음 역참조해 보면 STATUS_ACCESS_VIOLATION이나 공포의 Segmentation fault (core dumped) 메시지를 보게 되니, 이런 오해가 그럴듯해 보입니다. 하지만 상위 수준 언어나 Crashpad 같은 라이브러리는 오류를 가로채 예쁜 메시지와 백트레이스를 출력한 뒤 종료할 수 있습니다. 이는 Windows에서는 벡터 예외 처리기, 유닉스 계열에서는 시그널 핸들러를 설치해 구현합니다.
널 포인터를 역참조하면 결국 프로그램이 종료된다.
널 포인터 역참조가 나쁜 일인 건 맞지만, 반드시 복구 불가능한 것은 아닙니다. 벡터 예외 처리기나 시그널 핸들러는 프로세스를 죽이는 대신 프로그램을(필요하면 다른 코드 위치에서) 계속 실행시킬 수 있습니다. 예를 들어, Go는 nil 포인터 역참조를 panic으로 변환하는데, 이는 사용자 코드에서 recover로 잡을 수 있습니다. Java는 NullPointerException으로 변환하며, 역시 일반 예외처럼 사용자 코드에서 잡을 수 있습니다.
두 경우 모두 “허가를 구하는 것(역참조 전에 널인지 확인)”보다 “용서를 구하는 것(널 포인터를 역참조한 뒤 복구)”이 더 빠른 최적화가 됩니다. 모든 포인터를 널과 비교하면, 대다수 케이스(포인터가 널이 아닐 때)에 실행이 느려집니다. 반면 시그널 처리는 시그널이 실제 발생할 때까지 비용이 0이며, 잘 작성된 프로그램에서는 극히 드뭅니다.
널 포인터 역참조는 항상 시그널이나 예외를 일으키거나, 하드웨어에서 거부된다.
당분간 UB는 제쳐 놓고, 역참조가 최적화로 제거되지 않았다고 가정합시다.
가상 메모리 이전에는 거의 모든 메모리에 접근할 수 있었습니다. 예를 들어, 실모드의 x86은 인터럽트 테이블을 주소 0부터 1024까지 배치했습니다. 하드웨어 관점에서 널 포인터 역참조는 다른 포인터 역참조와 다르지 않았고, 그저 주소 0의 메모리에 접근했을 뿐입니다.
이는 지금도 많은 임베디드 플랫폼에서 사실입니다. 널 포인터 역참조는 여전히 UB이므로, 어떤 이유로든 주소 0에 접근해야 한다면 크게 두 가지 방법이 있습니다.
0x80000000(또는 유사한 값)을 통해 접근할 수 있습니다.현대의 일반적인 플랫폼에서는, 널 포인터 역참조가 항상 시그널이나 예외를 일으키거나 하드웨어에서 거부된다.
Linux에는 MMAP_PAGE_ZERO라는 personality 플래그가 있어 System V용으로 개발된 프로그램과의 호환성을 지원합니다. setarch -Z로 프로그램을 실행하면 주소 0부터 4096(또는 페이지 크기만큼)까지를 0으로 채워진 페이지에 매핑합니다. 또는 mmap으로 직접 주소 0에 메모리를 배치할 수도 있습니다. 오래전 Wine은 이 트릭(그리고 LDT 패칭 같은 다른 기법들)으로 DOSBox 없이 DOS 애플리케이션을 실행하곤 했습니다.
이제는 보안상의 이유로 기본적으로 작동하지 않습니다. 누군가에겐 보물, 누군가에겐 쓰레기죠. 커널이 실수로 널 포인터를 역참조했는데 주소 0에 매핑된 메모리가 있으면, 사용자 제공 데이터를 커널 데이터 구조로 해석할 수 있어 익스플로잇이 쉬워집니다. 하지만 sudo sysctl vm.mmap_min_addr=0로 명시적으로 다시 활성화할 수는 있습니다.
그럼에도 주소 0에 메모리를 매핑하는 아주 현대적이고 흔한 플랫폼이 하나 있습니다. 바로 WebAssembly입니다. wasm 컨테이너 내부에서는 격리가 불필요하므로 보안 익스플로잇을 쉽게 만들지 않으며, 그 결과 여기서는 널 포인터 역참조가 여전히 동작합니다.
널 포인터 역참조는 항상 “UB”를 트리거한다.
이건 까다롭습니다. 표준에는 이것이 Undefined Behavior(UB)를 유발한다고 쓰여 있지만, 이 문구의 _의미_는 시간에 따라 크게 바뀌었습니다.
옛날에는 C 표준이 규칙집이라기보다 가이드라인으로 여겨졌고, _undefined behavior_는 지금처럼 ‘검은 마법’이라기보다 _implementation-defined behavior_에 가까웠으며, 최적화기들도 그 차이를 무의미하게 만들 만큼 똑똑하지 않았습니다. 대다수 플랫폼에서 널 포인터 역참조는 주소 0의 값을 역참조하는 것과 똑같이 컴파일되고 동작했습니다.
사실상 오늘날 우리가 이해하는, 멀리 떨어진 곳까지 알 수 없는 영향을 끼치는 UB는 존재하지 않았습니다.
예를 들어, HP-UX C 컴파일러에는 주소 0에 0 페이지를 매핑하는 CLI 옵션이 있어 *(int*)NULL이 0을 반환하게 할 수 있었습니다. 일부 프로그램은 이 동작에 의존했기에 최신 OS에서 제대로 실행하려면 패치가 필요했거나, personality 플래그로 실행해야 했습니다.
이제부터는 저주받은 영역으로 들어갑니다.
널 포인터의 주소는 0이다.
C 표준은 널 포인터의 주소가 0이어야 한다고 요구하지 않습니다. 오직 (void*)x가 널 포인터가 되어야 한다고만 요구하는데, 여기서 x는 컴파일 타임 상수 0 이어야 합니다. 이런 패턴은 컴파일 타임에 쉽게 매칭되므로, 널 포인터의 실제 주소는 0이 아닐 수 있습니다. 비슷하게, 포인터를 불리언으로 캐스트할 때(if (p), !p 등)도 0 포인터가 아니라 널 포인터에 대해 false가 되어야 한다고만 요구합니다.
이건 가정이 아닙니다. 실제 아키텍처들과 C 인터프리터 중에는 0이 아닌 널 포인터를 사용하는 경우가 있습니다. fullptr은 농담만은 아닙니다.
궁금하다면, Rust나 다른 현대 언어들은 보통 이런 경우를 지원하지 않습니다.
현대 플랫폼에서는 널 포인터의 주소가 0이다.
AMD GCN이나 NVIDIA Fermi 같은 GPU 아키텍처에서는 0이 접근 가능한 메모리를 가리킵니다. 적어도 AMD GCN에서는 널 포인터가 -1로 표현됩니다. (Fermi에서도 그런지는 확신할 수 없지만, 그랬다면 타당합니다.)
(void*)0이 널 포인터이므로, int x = 0; (void*)x도 널 포인터다.
int x = 0; (void*)x에서 x는 상수 표현식이 아니므로, 표준은 이것이 널 포인터를 만들어야 한다고 요구하지 않습니다. 런타임의 정수→포인터 캐스트는 종종 no-op이므로, 모든 캐스트에 if (x == 0) x = ACTUAL_NULL_POINTER_ADDRESS;를 넣으면 매우 비효율적입니다. 최적화가 런타임 값을 꿰뚫어볼 때만 조건부로 널 포인터를 생성하는 것은 불필요하게 일관성이 떨어집니다.
당연히 void *p; memset(&p, 0, sizeof(p)); p 역시 널 포인터를 만든다고 보장되지 않습니다.
널 포인터의 주소가 0인 플랫폼에서는, C 객체를 주소 0에 배치할 수 없다.
객체를 가리키는 포인터는, 비트 표현이 같더라도, 널 포인터가 아닙니다.
포인터 출처(provenance)에 익숙하다면, 비트 표현이 같은 포인터가 다르게 동작한다는 사실은 놀랍지 않을 것입니다:
int x[1];
int y = 0;
int *p = x + 1;
// 이건 true가 될 수 있음
if (p == &y) {
// 하지만 p와 &y가 같더라도, 이는 UB가 됨
*p;
}
마찬가지로, 런타임에서는 NULL과 구분이 안 되더라도 객체를 주소 0에 배치할 수 있습니다:
int tmp = 123; // 이 변수는 주소 0에 놓일 수 있음
int *p = &tmp; // 단지 0을 가리키는 포인터일 뿐, 상수 0에서 유래하지 않음
int *q = NULL; // 상수 0에서 유래했으므로 널 포인터
// p와 q는 비트 표현이 같겠지만...
int x = *p; // 123을 산출
int y = *q; // UB
널 포인터의 주소가 0인 플랫폼에서는, int x = 0; (void*)x가 널 포인터다.
정수→포인터 변환의 결과는 구현 정의입니다. 널 포인터가 그럴듯한 후보이긴 하지만, 무효 포인터를 만들 수도 있고, 심지어 주소 0의 객체를 가리키는 역참조 가능한 포인터를 만들 수도 있습니다. 일부 컴파일러는 주소 0의 메모리에 안전하게 접근하기 위해 이 패턴을 권장하기도 했습니다:
int *p = (void*)0; // 반드시 NULL 포인터를 만들어야 함
int x = *p; // UB
int zero = 0;
int *q = (void*)zero; // 어떤 컴파일러에서는 역참조 가능한 포인터를 만들 수 있음
int y = *q; // 반드시 UB는 아님
이는 대체로 C의 유산입니다. 대부분의 언어는 런타임과 컴파일 타임의 정수→포인터 캐스트를 구분하지 않으며, 일관된 동작을 보입니다.
널 포인터의 주소가 0인 플랫폼에서는, int x = 0; (void*)x가 NULL과 같게 비교된다.
C에서는, 객체를 가리키는 포인터는 그 객체가 주소 0에 있더라도 NULL과 같지 않다고 문서화되어 있습니다. 다시 말해, 포인터의 주소만 알아서는 비교를 결정할 수 없습니다. 이는 UB를 일으키지 않으면서도 출처(provenance)가 프로그램 실행에 영향을 미치는 드문 사례입니다.
다음 단언들은 참입니다:
extern int tmp; // 이 변수가 주소 0에 있다고 하자
int *p = &tmp;
assert(p != NULL); // 객체 포인터는 NULL과 같지 않음
int *q = (void*)(uintptr_t)p;
assert(p == q); // 정수 왕복으로 Possibly-invalid지만 동등한 포인터가 됨
assert(q != NULL); // 추이성에 의해
int x = 0;
int *r = (void*)x; // 여전히 왕복으로 간주되며, p에 대한 데이터 의존성의 부재는 무관함
assert(r != NULL);
출처는 런타임에서 접근할 수 없으므로, 이런 비교는 컴파일 타임에만 해석할 수 있습니다. 따라서 객체 포인터가 FFI 경계를 넘거나 복잡한 코드로 전달될 수 있다면, 그 객체를 주소 0에 배치하는 것은 현실적으로 불가능합니다.
주소 0에 객체가 없더라도, int x = 0; (void*)x는 여전히 구현 정의 변환에 따라 NULL과 같지 않게 비교되는 포인터를 만들어도 됩니다.
Rust에서는 객체를 주소 0에 배치하는 것이 명시적으로 금지됩니다.
널 포인터의 주소가 0인 플랫폼에서는, 널 포인터가 0으로 저장된다.
정수 캐스트로 드러난 포인터의 “주소”와 포인터의 비트 표현은 일치할 필요가 없습니다. 정수를 부동소수점으로 캐스트한다고 비트가 보존되지 않는 것과 같습니다.
세그먼트 주소 체계는 흔한 예이고, 더 현대적인 예로는 포인터 인증(pointer authentication)이 있습니다. ARM에서는 포인터 상위 바이트를 암호학적 서명을 저장하는 용도로 구성할 수 있고, 역참조 시 이를 검증합니다. __ptr_auth 영역 안의 포인터는 주소 외에 서명도 함께 저장합니다. Apple은 널 포인터에 서명을 붙이지 않기로 했는데, 컴파일 타임에 값이 예측 불가능해지기 때문입니다. 하지만 이는 표준이 강제한 게 아니라 의도적인 결정입니다.
CHERI는 더 기괴합니다. CHERI 포인터는 우리가 익숙한 64비트 주소 외에 128비트 capability를 함께 저장해 UAF(use-after-free)와 OOB(out-of-bounds) 접근을 방지합니다. 주소가 0인 포인터는 모두 널 포인터로 간주되므로, 사실상 2의 128승 개에 가까운 서로 다른 널 포인터가 존재하며, 그중 오직 하나만이 올-제로입니다. (따라서 포인터 동등 비교 결과가 이진 표현의 동등 비교 결과와 다를 수 있습니다.)
클래스 멤버 포인터까지 정의를 확장하면, 이건 더 현실적입니다. 멤버 포인터는 사실상 필드까지의 오프셋(메서드를 제외하면)이고, 0은 유효한 오프셋이므로 (int Class::*)nullptr는 보통 -1로 저장됩니다.
널 포인터는 일반 포인터보다도 더 저주받은 존재이며, 출처(provenance)만으로도 이미 포인터는 꽤 복잡합니다. 이런 엣지 케이스들을 알고 있는 것은, 의도치 않게 이식성이 없는 코드를 만들지 않기 위해서, 그리고 다른 사람의 코드를 올바르게 해석하기 위해서 중요합니다.
하지만 이런 걸 매번 전부 기억하라는 말로 들린다면, 요점을 놓치고 있는 겁니다. 더 많은 플랫폼이 등장하고 최적화 컴파일러가 똑똑해질수록, 규칙과 프로그램을 새 환경에 맞춰 다듬어 온 게 바로 이 지경에 이르게 한 이유입니다.
많은 이들이 C를 “휴대용 어셈블러”라고 부릅니다. 이는 강하게 사실이 아닙니다. C는 하드웨어에 가까워 보이지만, 실제로는 자체 추상 기계와 운영 의미론을 갖고 있습니다. 최적화 패스, 코드 생성 백엔드, 라이브러리는 함께 동작하기 위해 플랫폼 독립적인 언어로 소통해야 하며, 그 언어는 “하드웨어가 하는 아무거나”가 아닙니다. 하드웨어에 시키고 싶은 일을 C로 글자 그대로 번역하려 하지 말고, C를 상위 수준 언어로 대하세요. 실제로 그렇게 되어 있으니까요.
Python이 끔찍한 메모리 안전 버그와 이식성 문제를 겪지 않는 이유는 인터프리터 언어여서만이 아니라, 소프트웨어 엔지니어들이 컴파일러나 런타임을 억지로 이겨 먹으려 들지 않기 때문이기도 합니다. C에도 같은 접근을 시도해 보세요.
memset이 필요한가요, 아니면 = {0}이면 충분한가요?size_t로 캐스팅하나요? 대신 uintptr_t를 사용하세요.void*를 사용하세요.(void*)((uintptr_t)p * flag) 같은 분기 없는 코드를 짜기보다, 컴파일러가 flag ? p : NULL을 알아서 최적화하게 두세요.(uintptr_t)p | flags 대신 (char*)p + flags처럼 더하는 방식으로 삽입할 수는 없나요?거미 감각이 울리면 C 표준을 찾아보고, 그다음에는 컴파일러 문서를 확인하고, 그래도 불확실하면 컴파일러 개발자에게 물어보세요. 동작에 장기적인 변화 계획이 없다고 가정하지 말고, 상식에 기대지 마세요.
다른 방법이 없다면 차선책을 하세요. 가정들을 문서화하는 것입니다. 그러면 사용자가 소프트웨어의 한계를 이해하기 쉽고, 개발자가 새 플랫폼으로 이식하기 쉬우며, 당신도 뜻밖의 문제를 디버깅하기 쉬워집니다.
다음 편: 메모리 주소를 IEEE-754 부동소수점에 저장하는 아키텍처.