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를 찾고, 필요하면 설명하고, 수정하게 한 뒤 사람이 결과를 확인하는 방식이 가능함
  • 다만 결과를 검증하려면 전문가가 필요하고, 전문가는 보통 다른 일로 바쁨
  • 이 작업은 청소 작업처럼 보이지만, 전통적으로 그런 일을 맡아온 주니어 프로그래머에게 맡기기에는 너무 미묘함

관련 자료

Read Entire Article