C의 모든 것은 정의되지 않은 동작이다
1 hour ago
1
정의되지 않은 동작(UB) 은 컴파일러의 악의적 최적화가 아니라, 코드가 유효하다는 전제에서 불가능한 실행 경로를 처리하지 않아도 되는 규칙임
사소하지 않은 C/C++ 코드 에는 double-free나 경계 밖 접근뿐 아니라 정렬, 캐스팅, 초기화, 타입 불일치 같은 미묘한 UB가 널리 숨어 있음
정렬되지 않은 int*나 std::atomic<int>* 접근은 플랫폼별로 SIGBUS, 커널 보정, 정상 동작처럼 보이는 결과가 갈리지만 표준상 이미 UB임
isxdigit()에 signed char를 넘기거나 float를 int로 바꾸거나 NULL과 가변 인자를 잘못 쓰는 흔한 코드도 쉽게 표준 밖으로 벗어남
기존 코드베이스를 버릴 수는 없지만, LLM 기반 UB 탐지 와 전문가 검증을 결합해 대규모로 고쳐야 하며 주니어에게 맡기기엔 너무 미묘함
C/C++의 정의되지 않은 동작은 최적화 문제가 아님
정의되지 않은 동작(UB) 은 컴파일러가 개발자의 실수를 “악용”한다는 뜻이 아니라, 프로그램이 표준상 유효하다고 가정할 수 있다는 뜻임
사람이 보기에는 의도가 분명해도, 컴파일러 단계나 모듈 사이에서 그 의도를 표현하기 어려울 수 있음
컴파일러는 “일어날 수 없는” 특수 사례를 코드 생성에서 처리할 의무가 없고, 하드웨어까지 포함한 실행 경로에서 의도와 다른 결과가 나올 수 있음
최적화를 꺼도 UB가 안전해지지는 않으며, 현재나 미래의 컴파일러·아키텍처에서 같은 동작이 유지된다는 보장도 없음
UB는 비정상적인 코드에만 있지 않음
double-free , use-after-free, 객체 경계 밖 접근, 초기화되지 않은 메모리 접근은 잘 알려진 UB지만, 산업 전반에서 계속 반복됨
더 미묘하고 비직관적인 UB도 많아, 평범해 보이는 C/C++ 코드가 쉽게 표준 밖으로 벗어남
C23 표준에는 “undefined”라는 단어가 283번 나오며, 명시되지 않아 정의되지 않는 경우까지 포함하면 범위는 더 넓어짐
사소하지 않은 C/C++ 코드에는 UB가 곳곳에 있으며, 이를 개별 프로그래머의 부주의만으로 돌리기 어려움
정렬되지 않은 객체 접근
다음처럼 int*를 역참조하는 함수는 포인터가 올바르게 정렬되지 않았을 때 UB가 됨
int foo(const int* p) {
return *p;
}
정렬(alignment) 은 보통 sizeof(int)의 배수 주소를 뜻할 수 있지만, 실제 요구사항은 플랫폼과 구현에 따라 달라질 수 있음
Linux Alpha에서는 일부 경우 커널이 트랩을 받아 소프트웨어로 의도한 접근을 흉내낼 수 있었지만, 다른 경우에는 SIGBUS로 프로그램이 죽을 수 있음
SPARC에서는 SIGBUS가 발생하고, x86/amd64에서는 대체로 문제없이 동작하거나 원자적 읽기처럼 보일 수도 있음
ARM, RISC-V, 미래 아키텍처에서는 결과를 일반화할 수 없고, 미래 아키텍처가 int*의 하위 비트를 사용하지 않는 특수 레지스터를 둘 수도 있음
컴파일러가 다른 load 명령을 사용하면, 이전에는 커널이 보정해주던 접근이 더 이상 보정되지 않을 수 있음
컴파일러는 정렬되지 않은 포인터에서도 동작하는 어셈블리를 생성할 의무가 없으며, 해당 접근 자체가 UB임
원자 타입도 정렬이 틀리면 이미 UB
다음처럼 std::atomic<int>*에 대해 store()나 load()를 호출해도, 객체가 올바르게 정렬되지 않았으면 동작은 UB임
void set_it(std::atomic<int>* p) {
p->store(123);
}
int get_it(std::atomic<int>* p) {
return p->load();
}
“정렬되지 않은 객체에서도 이 연산이 원자적인가”라는 질문은 표준 관점에서 성립하지 않음
실제 하드웨어에서는 원자성 문제가 될 수 있지만, 표준상으로는 그 전에 이미 UB임
원자적으로 읽는다고 생각한 객체가 페이지 를 걸쳐 있을 때 문제는 더 복잡해지지만, 결론은 “괜찮다”가 아니라 UB임
포인터를 만드는 행위만으로도 문제가 될 수 있음
정렬되지 않은 포인터는 역참조 전이라도 특정 타입의 포인터로 캐스팅 하는 것만으로 문제가 될 수 있음
bool parse_packet(const uint8_t* bytes) {
const int* magic_intp = (const int*)bytes; // UB!
int magic_raw = foo(magic_intp); // Probably crashes on SPARC.
int magic = ntohl(magic_raw); // this is fine, at least.
[…]
}
여기서 문제는 foo() 호출이 아니라 (const int*)bytes 캐스팅임
컴파일러가 int*의 하위 비트에 가비지 컬렉션이나 보안 태그 비트 같은 의미를 부여하는 것도 표준상 가능함
isxdigit()에 char를 넘기는 문제
다음 코드는 단순해 보이지만, char가 signed인 아키텍처에서 입력값이 0–127 범위를 벗어나면 UB가 될 수 있음
bool bar(char ch) {
return isxdigit(ch);
}
isxdigit()는 16진수 문자인지 확인하는 함수이며, EOF도 인자로 받을 수 있음
C23 7.4p1에 따르면 EOF는 int이고, unsigned char로 표현될 수 없는 값으로 추론할 수 있음
isxdigit()는 char가 아니라 int를 받으며, char에서 int로 변환은 가능해도 signed char의 음수 값이 문제가 됨
C23 6.2.5 문단 20에 따르면 char가 signed인지 여부는 구현 정의임
다음처럼 구현된 isxdigit()는 음수 인덱스로 알 수 없는 메모리를 읽을 수 있음
int isxdigit(int c) {
if (c == EOF) {
return false;
}
return some_array[c];
}
그 메모리가 I/O 매핑 영역이면 임의 값이나 크래시를 넘어 하드웨어 동작을 유발할 수도 있음
데스크톱 운영체제의 애플리케이션보다 임베디드 시스템에서 더 가능성이 높지만, 사용자 공간 네트워크 드라이버처럼 사용자 공간만으로도 보호가 충분하지 않은 경우가 있음
float에서 int로 캐스팅하는 문제
다음처럼 초 단위 float 값을 밀리초 int로 변환하는 코드는 흔하지만 UB를 포함함
int milliseconds(float seconds) {
int tmp = (int)(seconds * 1000.0); /* WRONG */
return tmp + 1; /* WRONG separately (signed overflow is UB) */
}
C23 6.3.1.4는 유한한 실수 부동소수점 값을 정수 타입으로 변환할 때, 정수부가 해당 정수 타입으로 표현될 수 없으면 동작이 정의되지 않는다고 규정함
비유한 값에 대해서도 명시가 없어 UB가 됨
float를 INT_MAX와 비교하는 일도 단순하지 않음
float를 int로 캐스팅하면 피하려던 UB가 발생할 수 있음
INT_MAX를 float로 캐스팅하면 정확히 표현되는지 알 수 없음
INT_MAX가 float로 반올림되어 int로 표현할 수 없는 값이 되면 비교가 대표성을 잃을 수 있음
안전하게 만들려면 isfinite() 검사, INT_MIN + 1000, INT_MAX - 1000 같은 여유 범위 비교, 변환 후 덧셈 전 추가 검사가 필요함
int milliseconds(float seconds) {
const float ftmp = seconds * 1000.0f;
if (!isfinite(ftmp)) {
return 0;
}
if ((float)(INT_MIN + 1000) > ftmp) {
return 0;
}
if ((float)(INT_MAX - 1000) < ftmp) {
return 0;
}
const int tmp = (int)ftmp;
if (INT_MAX == tmp) {
return 0;
}
return tmp + 1;
}
단순히 float를 int로 바꾸고 싶을 뿐인데, 안전한 코드는 훨씬 길어짐
주소 0의 객체와 null pointer
OS 커널이나 임베디드 코드에서는 주소 0에 객체를 두려는 상황이 생길 수 있음
C 표준에 맞게 실제로 주소 0에 객체를 두는 실용적 방법은 없다고 볼 수 있음
C 6.3.2.3에서 포인터로 변환 가능한 정수 상수 0과 nullptr은 “null pointer constant”이며, 여기서는 NULL로 부를 수 있음
C는 실제 NULL 포인터가 기계 주소 0을 가리킨다고 지정하지 않음
C 표준은 하드웨어가 아니라 C 추상 기계를 다루며, NULL과 0을 비교하면 같다는 것만 보장함
그 같음은 정수 0이 해당 플랫폼의 native NULL 값으로 변환되기 때문일 수 있고, 그 값이 0xffff일 수도 있음
null pointer를 역참조하는 것은 값이 무엇이든 UB이며, C 3.4.3의 대표 예시임
따라서 memset(&ptr, 0, sizeof(ptr));가 NULL 포인터를 만든다고 가정할 수 없음
구조체를 0으로 초기화하고 멤버 포인터가 NULL이라고 가정하는 방식은 대부분의 프로그래머에게도 실제 문제가 됨
역사적으로 0이 아닌 NULL 포인터를 사용한 기계 도 존재했음
주소 0에 함수가 있다고 가정하는 문제
현대 기계에서 NULL이 주소 0을 가리키고 실제로 그 주소에 객체나 함수가 있다고 해도, C 6.3.2.3은 NULL이 어떤 객체나 함수와도 같지 않다고 규정함
따라서 다음 코드는 UB임
void (*func_ptr)() = NULL;
func_ptr();
C 관점에서는 “거기에 함수가 없다”는 의미가 되며, 컴파일러 내부에 이런 의도를 표현할 방법이 없을 수 있음
단순히 모든 비트가 0인 주소로 call 명령을 내보낼 것이라고 가정할 수 없음
16비트 x86에서는 “모든 0”이 0000:0000인지 CS:0000인지도 명확하지 않음
가변 인자와 타입 불일치
execl()의 마지막 인자는 포인터여야 하므로, NULL 매크로나 정수 0을 그대로 넘기면 UB가 될 수 있음
execl("/bin/sh", "sh", "-c", "date", NULL); /* WRONG */
execl("/bin/sh", "sh", "-c", "date", 0); /* WRONG */
올바른 형태는 명시적으로 포인터 타입으로 캐스팅하는 것임
execl("/bin/sh", "sh", "-c", "date", (char*)NULL);
NULL 매크로는 정수 0으로 해석될 수 있고, 가변 인자에서는 필요한 타입 정보가 전달되지 않음
printf()에서도 포맷 지정자와 실제 인자 타입이 맞지 않으면 UB임
uint64_t blah = 123;
printf("%ld\n", blah); /* WRONG */
uint64_t 출력에는 PRIu64를 써야 함
uint64_t blah = 123;
printf("%"PRIu64"\n", blah);
uid_t를 출력하려면 uintmax_t로 캐스팅하고 PRIuMAX를 쓰는 방법이 있을 수 있지만, uid_t가 unsigned인지도 확실하지 않음
최악의 경우 -1 대신 무의미한 값이 출력될 수 있음
0으로 나누기와 보안 문제
0으로 나누기 가 UB라는 사실은 널리 알려져 있지만, 분모가 신뢰할 수 없는 입력에서 오면 보안 문제가 됨
단순한 런타임 오류가 아니라, 입력 검증 경계에서 UB가 발생할 수 있다는 점이 중요함
UB는 아니지만 정수 승격도 위험함
정수 승격 규칙은 코드를 훑는 속도로 적용하기 어렵고, 직관과 다른 결과를 만들 수 있음
다음 코드에서 overflowed는 1이 아니라 0이 됨
unsigned char a = 0xff;
unsigned char b = 1;
unsigned char zero = 0;
bool overflowed = (a + b) == zero;
// overflowed is set to zero, not one.
다음 코드에서는 모든 변수가 unsigned처럼 보여도 결과가 2147483648 (0x80000000)가 아니라 18446744071562067968 (ffffffff80000000)가 됨
unsigned char a = 0x80;
uint64_t b = a << 24; // Bonus UB(?)
UB가 아니더라도 C/C++의 정수 규칙은 직관적이지 않아 결함을 만들기 쉬움
LLM을 이용한 UB 탐지
최신 LLM은 임의의 C 코드에서 UB를 찾도록 요청하면 거의 항상 문제를 찾아내며, 대체로 맞는 결과를 냄
개인 코드에서 UB를 찾은 뒤, 성숙하고 엄격하게 작성된 OpenBSD 코드에도 같은 방식이 적용됐음
처음 떠올린 도구인 find를 대상으로 여러 문제가 발견됨
OpenBSD에는 범위 밖 쓰기 에 대한 패치와 UB가 아닌 논리 버그 에 대한 패치가 보내짐
남아 있던 많은 UB에 대해서는 패치가 보내지지 않았음
OpenBSD 프로젝트가 과거 버그 리포트에 매우 수용적이지 않았던 경험이 있었음
실제로는 괜찮을 수 있다는 판단이 있었음
OpenBSD가 코드베이스에서 UB를 제거하려면, LLM과 프로젝트 사이에서 개별 패치를 전달하는 방식보다 더 큰 프로젝트가 필요함
C/C++ 코드베이스를 다루는 현실적 방향
기존 C/C++ 코드베이스를 버릴 수는 없지만, 본질적으로 깨진 상태로 두는 것도 선택지가 아님
AI가 만든 저품질 변경을 커밋하지 않으면서도, 인간 리뷰어를 압도하지 않는 방식으로 UB를 대규모로 고쳐야 함
2026년에 LLM의 UB 감독 없이 C나 C++를 작성하는 것은 SOX 위반처럼 여겨질 수 있고, 무책임한 일로 볼 수 있음
OpenBSD 개발자들도 30년 넘게 이런 문제를 모두 찾지 못했다면, 다른 프로젝트의 가능성은 더 낮아짐
개인 프로젝트에서는 LLM에 UB를 찾고, 필요하면 설명하고, 수정하게 한 뒤 사람이 결과를 확인하는 방식이 가능함
다만 결과를 검증하려면 전문가가 필요하고, 전문가는 보통 다른 일로 바쁨
이 작업은 청소 작업처럼 보이지만, 전통적으로 그런 일을 맡아온 주니어 프로그래머에게 맡기기에는 너무 미묘함
관련 자료
Homepage
Tech blog
C의 모든 것은 정의되지 않은 동작이다
🔉 볼륨 줄이기
🔊 볼륨 키우기
🔇 음소거
⏭️ 다음 곡