C 언어의 정의되지 않은 동작 줄이기

Reducing undefined behavior in the C language

lwn.net ▲ 123 댓글 153 signa11

요약

C 표준의 다음 판은 남은 정의되지 않은 동작 약 100건 가운데 45건을 없앱니다.

MRI 장비 제어용 자유 소프트웨어를 만드는 의공학 교수 마르틴 위커가 Kernel Recipes 2026에서 발표한 내용입니다. LWN의 조너선 코빗이 정리했고, 위커는 C가 이식성과 장기 안정성, 빠른 속도 덕분에 여전히 가치 있다고 봤습니다.

왜 중요한가

  • 컴파일러가 정의되지 않은 동작(UB)을 핑계로 코드를 지우거나 순서를 바꾸는 일을 표준이 직접 막기 시작했습니다.
  • C를 버리지 않고 메모리 안전성을 높이는 길을 표준과 도구 양쪽에서 찾고 있습니다.

핵심 내용

  • 작업 중인 C2y 초안은 표준에 남은 UB 약 100건 가운데 45건을 없앱니다.
  • C23은 volatile(생략·재배치 금지) 저장보다 나눗셈을 앞당기는 '시간 여행' 최적화를 금지했습니다.
  • 포인터 동등 비교는 늘 정의된 동작인데도 GCC와 Clang 모두 잘못 컴파일한 사례가 있습니다.
  • 메모리 안전성 가운데 타입과 경계 검사는 상당히 풀렸지만, 해제 후 사용 같은 시간적 안전은 아직 러스트가 앞섭니다.
  • 완전한 안전에는 비싼 실행 시간 검사가 필요하므로, 당장은 제한된 언어 부분집합과 형식 검증을 함께 쓰자고 했습니다.

HN 반응

  • UB를 경고라도 하라는 요구에, 레지스터 배치 같은 기본 최적화도 UB가 없다는 가정에 기대므로 경고가 쏟아질 것이라는 반론이 맞섰습니다.
  • Fil-C(메모리 안전한 C 구현) 개발자는 기사가 Fil-C와 CHERI(포인터 권한을 검사하는 하드웨어)를 과소평가했다고 반박했습니다.

댓글

30개 표시 · 전체 153개
  1. chasil HN

    "예를 들어 일부 하니웰 기계는 9비트 바이트를 썼다."

    OS 2200은 36비트 워드를 씁니다. 지금도 지원되는 플랫폼이고요.

    https://en.wikipedia.org/wiki/UNIVAC_1100/2200_series

    이 플랫폼이 최초의 SMP 유닉스 구현이었습니다.

    "스페리가 공급한 구성이라면 멀티프로세서 구성을 포함해 어느 것이든 유닉스 시스템을 돌릴 수 있다."

    https://www.nokia.com/bell-labs/about/dennis-m-ritchie/otherports/newp.html

  2. eru HN

    OS 2200은 36비트 워드를 씁니다. 지금도 지원되는 플랫폼이고요.

    맞습니다. 하지만 그건 '구현 정의 동작(implementation defined behaviour)'을 옹호하는 근거일 수는 있어도, '미정의 동작(undefined behaviour)'을 옹호하는 근거는 아닙니다.

  3. strenholme HN

    유니시스의 마지막 출시작인 2015년의 Dorado(정확히는 ClearPath Dorado 8300)는 Xeon 프로세서(64비트 x86_64)를 썼습니다. 그래도 논의를 위해 36비트 레지스터를 가진 유니시스 2200이 어딘가에 있고, GCC나 clang이 그 기종용 C2y 컴파일러를 만든다고 가정해 봅시다.

    그렇다면 그 컴파일러도 (u)int_8/16/32/64_t는 여전히 지원해야 합니다. 어차피 (unsigned) _BitInt(8/16/32/64)를 지원해야 하니까요. 그리고 맞습니다, C23에서는 꼭 필요하면 x86_64에서도 _BitInt(36)으로 36비트 정수를 쓸 수 있습니다. int32_t, uint32_t 같은 걸 허용하면 그 (가상의) 2200 시스템에서도 C23 이전의 오픈소스 코드를 많이 깔끔하게 컴파일할 수 있게 됩니다. uint32_t가 어셈블리 수준에서 좀 보기 흉해지겠지만, Xeon 같은 데서 _BitInt(36)이 좀 보기 흉한 것과 마찬가지입니다. 어쨌든 코드는 컴파일되고 똑같이 돌아갑니다.

    물론 요즘은 토큰을 사서 에이전트에게 필요한 포팅을 시킬 수도 있겠지만, 그래도요.

  4. pizlonator HN

    글에서 Fil-C를 언급한 건 좋은데, 그 가치를 낮춰 잡았다는 아쉬움이 있습니다. CHERI도 마찬가지고요. Fil-C는 시간적 안전성(temporal safety) 버그를 "많이 찾아내는" 정도가 아닙니다. 공간적 메모리 안전성 버그와 시간적 메모리 안전성 버그를 익스플로잇 작성자가 쓰지 못하도록 막고, 언어 전체에 엄밀한 의미론을 부여합니다. CHERI는 절충안이 조금 다르지만, 메모리 안전성 익스플로잇이 통하지 않을 만큼 엄밀한 의미론을 제공하는 건 마찬가지입니다. 둘 다 ABI 수준에서 문제를 공략하기 때문에 Rust보다 포괄적입니다. 새 언어의 안전한 부분집합으로 다시 짠 코드에만 보호가 적용된다는 문제가 없으니까요. Rust가 컴파일 타임에 잡는다는 점에서 더 낫다고 주장할 수는 있겠지만, 의미론의 명확성이나 익스플로잇 가능성이 걱정이라면 큰 차이를 만들지 못합니다.

  5. afdbcreid HN

    둘 다 할당 단위로만 출처 정보(provenance)를 붙입니다. 흔한 예가 이겁니다.

        struct User {
            char name[100];
            bool is_admin;
        };
    

    여기서는 버퍼 오버플로로 is_admin을 덮어쓸 수 있습니다.

    그리고 둘 다 모든 것을 다시 컴파일해야 합니다. CHERI는 그게 가능할지 몰라도 Fil-C는 불가능합니다. 그래서 예를 들어 윈도나 맥OS용 Fil-C 지원은 있을 수 없습니다.

  6. pizlonator HN

    누군가 그런 코드를 짰다면 보안 버그가 될 수 있는 예를 보여 주셨는데, 메모리 안전성과 보안의 관계에서는 핵심을 놓치고 계십니다.

    메모리 안전성이 그토록 중요한 이유는, C/C++에서는 name의 경계 검사가 빠지면 그 할당 안의 다른 필드뿐 아니라 힙 전체에 접근할 수 있기 때문입니다. 공격자는 경계 검사 누락을 이용해 같은 객체 안의 is_admin 필드를 찾지 않습니다. 메모리 전체를 장악하는 발판을 찾고, 거기에 RCE 익스플로잇 체인을 꽂으려는 겁니다. 그러니 공격자가 객체 내부 오버플로를 노리는 경우는 struct { char buf[100]; void* ptr; } 같은 구조일 때뿐입니다. 이쪽은 메모리 전체를 장악하는 발판으로 이어지니까요. Fil-C는 이걸 막습니다. 그 오버플로로 ptr의 capability를 망가뜨릴 수 없습니다.

    MIE, MTE, Fil-C 같은 메모리 안전성 기술이 할당 단위에 초점을 맞추는 이유가 이겁니다. 하위 객체(subobject) 단위를 지원하고 싶으면 지원할 수 있습니다. capability 모델을 크게 바꿀 일도 아니고요. Fil-C의 첫 두 capability 모델(2023년 말에서 2024년 초)은 하위 객체 단위를 지원했습니다. 지금은 지원하지 않기로 했습니다. 보안에 미치는 영향을 충분히 이해하고 있고, 그 복잡도를 감수할 가치가 없다는 걸 알기 때문입니다.

    Fil-C는 맥OS 프로젝트로 시작했습니다. 지금도 APE를 통해 윈도와 맥OS를 지원합니다. 모든 것을 다시 컴파일하라는 요구는 리눅스에서 제가 선택해서 누릴 수 있는 호사이고, 그 덕에 프로세스 전체의 메모리 안전성이 확보됩니다. Fil-C가 유용하다고 느끼고 컴파일러를 안다면, FFI를 갖춘 변형을 어렵지 않게 만들어서 윈도와 맥 API를 붙일 수 있을 겁니다. 안전성 보장은 약해지겠지만, 지금 Rust나 Java에서 얻는 수준보다 약하지는 않을 겁니다.

  7. aw1621107 HN

    제 기억이 맞다면 필립(Filip)이 Fil-C를 고쳐서 그런 오버플로도 꽤 쉽게 잡게 만들 수 있다고 했는데, 그러면 상당수 C 코드가 깨진다고 했습니다.

  8. cperciva HN

    아닙니다, CHERI는 하위 객체 capability를 지원합니다.

  9. pizlonator HN

    Fil-C도 예전에는 그랬습니다. 근본적인 한계가 아니라 정책의 문제입니다. 프로그래머가 name의 포인터를 얻은 다음 포인터 산술로 is_admin 필드까지 가면 허용할 것이냐 말 것이냐의 문제죠. 허용하면 이 가상의 예는 보안 취약점이 됩니다. 허용하지 않으면 수많은 C/C++ 프로그램이 동작하지 않게 됩니다.

    "name이 넘쳐서 is_admin을 덮어쓰는" 이 예는 실제 코드나 실제 CVE에서는 나오지 않습니다. Fil-C 관련 HN 댓글에서나 나오죠.

    CHERI는 동작하게 하려면 코드를 얼마나 고쳐야 하는지 기준으로 보면 Fil-C보다 호환성이 떨어지는데, 이런 정책 결정이 그 결과를 낳은 한 사례입니다.

  10. SleepyMyroslav HN

    C++ 쪽에서 겪은 제 개인적인 경험담입니다.

    C++가 컴파일 타임 평가(constexpr 문맥)에서는 이걸 금지하기로 한 게 흥미롭습니다.

    실제 게임 개발 프로젝트에서, 포인터 산술로 구조체 멤버에서 다른 멤버로 넘어가는 코드가 쓰레기 값을 만들어 내서 고친 사례를 이미 봤습니다. 그 코드는 20년 넘게 그렇게 멀쩡히 돌았는데, 툴체인을 업데이트하자 갑자기 깨졌습니다.

  11. afdbcreid HN

    오, 몰랐네요!

  12. dwattttt HN

    더 강한 capability(포인터)에서 더 제한된 capability를 파생하는 건 안전하고 안정적인 동작입니다. 예를 들어 아레나 할당자가 아레나 전체에 유효한 포인터를 갖고 있으면서, 요청받은 크기에만 유효한 파생 포인터를 내어주는 식입니다.

    접근 권한도 제한할 수 있는 것으로 알고 있습니다. 오래된 기억에 의존해서 하는 말이지만, RWX 포인터에서 파생한 읽기 전용 포인터를 넘겨줄 수 있을 겁니다.

  13. vbezhenar HN

    C의 UB에서 제 가장 큰 불만은 조용하다는 점입니다.

    컴파일러가 막 나가는 건 괜찮습니다. 아니, 사실 안 괜찮지만, 이 미친 세상에서 받아들일 수는 있습니다.

    하지만 경고는 크게 띄워 줬으면 합니다. 예를 들어 "WARNING: 앞에서 0으로 나누는 것이 UB이므로 이 조건 연산자는 한쪽 분기로 합쳐졌습니다" 같은 것 말입니다. 그러면 알아채고 고치거나 그 조건을 지워 버릴 수 있으니까요.

    이 코드가 매크로 전개의 결과일 수 있다는 건 압니다. 그래도 괜찮습니다. 매크로가 특정 진단을 잠시 끄는 pragma를 포함하든지, 매크로를 못 고치면 사용하는 쪽에서 그 pragma로 감싸면 됩니다. 다른 경고에서는 이미 그렇게 하고 있잖아요.

    아니면 컴파일러가 매크로 전개와 진짜 사용자 실수를 구분할 만큼 똑똑해질 수도 있겠죠. 잘은 모르겠습니다.

    제가 단순한 무한 루프를 썼더니 C++ 컴파일러가 함수 에필로그를 그냥 날려 버린 적이 있습니다. 정말 황당했습니다. 무한 루프로 들어가는 대신, 프로그램이 링크 순서상 그 아래에 있던 다른 함수를 계속 실행해 버렸습니다. 그걸 디버깅한다고 상상해 보세요. 진단 메시지는 하나도 없습니다.

  14. jakobnissen HN

    그건 완전히 불가능합니다. C 컴파일러는 UB가 일어나지 않는다는 가정을 너무나 많이 깔고 있어서, 정상적으로 잘 도는 프로그램조차 경고가 산더미처럼 쏟아질 겁니다. 또 그 경고는 끌 수도 없습니다. 정수 나눗셈의 경우를 생각해 보세요. 런타임에 0으로 나눌 때마다 경고를 내야 할까요? 그러면 C가 훨씬 훨씬 느려집니다. 아니면 컴파일 타임에 제수가 0이 아님을 증명하지 못할 때마다 경고를 내야 할까요? 그러면 컴파일러에게 0이 아님을 증명할 수 없어서 문제를 고칠 수도 없는 상황이 엄청나게 생깁니다. 0으로 나누기는 그나마 쉬운 경우입니다. 예컨대 에일리어싱 가정에 대해 경고를 내는 건 훨씬 더 어렵습니다.

  15. xphos HN

    대안은 Rust 방식이라고 봅니다. 디버그 모드에서는 항상 검사된 나눗셈과 검사된 덧셈을 하는 거죠. 이러면 디버그 모드에서 찾을 수 있는 문제가 한 부류 통째로 예방됩니다. 릴리스 모드에서는 그 UB 검사들이 빠지고요. 물론 포화(saturating)나 래핑(wrapping) 등 원하는 덧셈 종류를 지정할 수도 있습니다. 느려지긴 하지만 대안은 코드가 제대로 동작하지 않을 수도 있다는 겁니다. 리눅스가 이 점에서 재미있는데, 리눅스 커널에서는 제대로 동작하지 않을 가능성이 큰 문제라서 리누스가 C++는 안 되고 Rust는 허용하는 거라고 생각합니다. C++는 이 문제를 다루지 않고, C도 물론 마찬가지입니다. C++는 에일리어싱 문제와 객체 패턴 때문에 UB가 생길 여지가 있는 범위가 훨씬 더 넓습니다.

    어쨌든 말씀대로 비용은 있고, 그만한 값어치가 있는지는 판단하기 어렵습니다.

  16. SkiFire13 HN

    디버그 모드에서는 항상 검사된 나눗셈과 검사된 덧셈을 하는 거죠. 이러면 디버그 모드에서 찾을 수 있는 문제가 한 부류 통째로 예방됩니다. 릴리스 모드에서는 그 UB 검사들이 빠지고요

    검사된 나눗셈은 릴리스 모드에도 있습니다. 그걸 빼면 UB가 되니까요.

    다른 검사들(예: 덧셈)은 릴리스 모드에서 빠지지만, 애초에 UB를 막아 주던 검사가 아니었습니다. 그 검사들은 (C에서 UB가 되는) 검사 없는 덧셈이 아니라 래핑 덧셈을 지키고 있었으니까요.

  17. bigstrat2003 HN

    C 컴파일러는 UB가 일어나지 않는다는 가정을 너무나 많이 깔고 있어서, 정상적으로 잘 도는 프로그램조차 경고가 산더미처럼 쏟아질 겁니다.

    문제는 C 컴파일러가 UB가 일어나지 않는다고 조금이라도 가정한다는 점입니다. 경험적으로 그건 전혀 사실이 아닙니다. 그러니 컴파일러가 UB가 없다는 걸 어떻게든 증명하지 못하는 한, 그 가정을 절대 하지 못하게 해야 합니다. 그러면 최적화가 얼마나 깨지든 솔직히 상관없습니다. 정확성이 최우선입니다. 소프트웨어가 빠른 게 의미 있으려면 우선 제대로 동작해야 합니다.

  18. dgrunwald HN

    최적화가 얼마나 깨지든 솔직히 상관없습니다. 정확성이 최우선입니다.

    거의 모든 최적화가 깨집니다. 좋은 소식이 있습니다. 원하시는 걸 정확히 해 주는 컴파일러 옵션이 이미 있습니다. -O0입니다. 다른 -O 스위치로 덮어쓰지 않는 한 기본으로 켜져 있기도 하고요!

  19. ahartmetz HN

    -O0 코드는 "너무 멍청하게 굴지 말라" 수준의 최적화도 많이 빼 버리기 때문에, 실제 프로덕션 코드 대부분에서는 현실적인 선택지가 아닙니다.

  20. dgrunwald HN

    그런 최적화 가운데 놀랄 만큼 많은 것이 "미정의 동작으로부터 추론"해야만 유효합니다.

    as-is 규칙에 따르면 최적화는 프로그램의 동작을 바꾸면 안 됩니다. C 프로그램은 이론상 범위를 벗어난 포인터로 자기 스택을 훑으면서 어떤 값이 스택에 있는지 관찰할 수 있습니다. 그러니 as-is 규칙대로라면 지역 변수를 레지스터에 두는 것도 금지됩니다!

    하지만 범위를 벗어난 포인터는 미정의 동작이기 때문에, 컴파일러는 스택을 훑는 프로그램을 무시할 수 있고, 그 덕에 as-is 규칙 아래서도 레지스터 할당이 가능해집니다.

    그러니 그 흔해 빠진 레지스터 할당도 "UB는 일어나지 않는다고 가정"하는 최적화입니다! 물론 컴파일러가 실제로 "이 포인터 산술은 범위를 벗어났으니 저 변수를 레지스터에 넣어도 되겠다"라고 추론하지는 않습니다. 미정의 동작으로부터의 추론은 컴파일 타임에 일어나는 게 아니라, "레지스터 할당" 최적화를 설계할 때 이미 끝난 일입니다. 하지만 "UB는 일어나지 않는다고 가정"하는 최적화는 대부분 그렇습니다! (컴파일러가 미정의 동작을 경고하기 그토록 어려운 것도 이 때문입니다. UB를 한 번도 감지하지 않고 UB에 기대어 최적화하니까요.)

    "미정의 동작으로부터의 추론"을 없애려면 "as-is" 규칙도 다른 무언가로 바꿔야 합니다. 언어 표준에 허용되는 최적화 목록을 명시하는 식으로요?

  21. afdbcreid HN

    맞습니다. UB를 전혀 이용하지 않는 주류 C/C++ 컴파일러는 지금도 없고, 어쩌면 과거에도 없었을 겁니다. 그 성능 영향을 벤치마크하려면 사실상 GCC나 LLVM을 다시 짜야 하는데, 가정이 워낙 깊이 박혀 있어서 도저히 불가능한 일입니다.

    다만 좀 더 "합리적인" UB는 가능할지 모릅니다. 예를 들어 할당 주소가 악마적 비결정성(daemonic non-determinism)을 가진다고 가정하면, 에일리어싱 모델 없이도 스택 저장소를 레지스터로 바꿀 수 있습니다. 그 성능을 벤치마크하는 데는 같은 어려움이 따르겠지만요.

  22. ahartmetz HN

    진짜 문제는 놀라운 UB입니다. 경험 많은 C 프로그래머라면 스택 변수를 조작하려고 쓴 포인터 오프셋 꼼수가 안 먹힌다고 해서 컴파일러를 탓하지는 않는다고 생각합니다. 예전 컴파일러는 "덜 똑똑"해서 UB를 두고 너무 영리하게 굴지도 않았습니다. 지금은 영리하게 굴고, 그래서 개발자가 놀랍니다. 그게 진짜 문제입니다.

  23. gpderetta HN

    s/as-is/as-if/

    그 외에는 물론 맞는 말씀입니다.

  24. UncleMeat HN

    문제는 C 컴파일러가 UB가 일어나지 않는다고 가정한다는 점입니다.

    꼬리 재귀 함수를 반복문으로 바꾸고 싶다고 해 봅시다. 이건 UB가 없을 때만 의미가 보존됩니다!

    더 심한 경우도 있습니다. UB가 일어나지 않는다고 절대 가정할 수 없다면 모든 코드를 데이터 레이스에 대비하는 방식으로 컴파일해야 합니다. 스택의 할당 순서도 바꿀 수 없습니다. 어딘가에서 버퍼 끝을 넘어 쓰는 코드가 있을 수 있고, 그러면 스택 할당 순서가 바뀌는 순간 프로그램의 의미가 달라지니까요. 악몽입니다.

    이런 프로그램을 생각해 보세요.

        int f() {
          int x = 1;
          a(x);
          b(x);
          return x;
        }
    

    상수 전파로 명령어에서 "x"를 1로 바꿀 수 있을까요? 못 합니다. a가 스택을 박살 내고 x를 덮어쓸 수도 있으니까요. x를 쓸 때마다 그 메모리 위치를 새로 읽어야 합니다.

  25. vbezhenar HN

    컴파일러가 조건문을 한쪽 분기로 바꾸기로 하고 조건 자체와 다른 쪽 분기를 버릴 때, 컴파일 타임에 경고를 내야 합니다.

    제가 코드를 썼으면 그 코드가 바이너리에 들어 있기를 기대합니다. 컴파일러가 지워 버리라고 코드를 쓰지는 않습니다. 그 기대가 틀렸다면 컴파일러가 알려 줘야 합니다.

  26. flohofwoe HN

    제가 코드를 썼으면 그 코드가 바이너리에 들어 있기를 기대합니다.

    너무 단순화한 생각입니다. 인라이닝과 상수 폴딩을 거친 뒤에 if 조건이 항상 참이거나 거짓으로 드러나면, 저는 당연히 컴파일러가 죽은 분기를 제거하기를 기대합니다.

    이런 최적화는 그 전설적인 "제로 코스트 추상화"의 토대입니다(C++만의 얘기가 아니고, C 코드도 똑같이 여기에 의존합니다). 이 최적화를 없애면 사소하지 않은 코드베이스는 어디든 성능이 심각하게 곤두박질칠 겁니다.

  27. cesarb HN

    제가 코드를 썼으면 그 코드가 바이너리에 들어 있기를 기대합니다. 컴파일러가 지워 버리라고 코드를 쓰지는 않습니다.

    템플릿이나 인라인 함수에 코드를 써 놓고, 호출하는 쪽에서 필요 없으면 컴파일러가 지워 주기를 기대하는 건 아주 흔한 일입니다. "제로 코스트 추상화"가 런타임에 비용이 0인 이유 중 하나가 바로 그겁니다.

    예를 들어 저는 꼬리 부분(네이티브 벡터 크기보다 작게 남은 요소들)을 처리하는 코드를 따로 둔 SIMD 루틴이 있습니다. 컴파일러가 입력 크기가 항상 벡터 크기의 배수임을 증명할 수 있으면(제 용도에서는 아주 흔합니다) 그 꼬리 처리 코드를 완전히 없애 버립니다.

  28. vbezhenar HN

    그러면 그 경고를 보게 될 거고, 코드가 제거될 수 있다는 걸 알고 있으니 경고가 안 뜨도록 여분의 코드에 표시를 해 두겠죠.

  29. microtonal HN

    요점은 코드가 크리스마스 트리처럼 경고로 번쩍거리게 된다는 겁니다. 특정 문맥에서는 사실상 뭐든 제거될 수 있으니까요. 예를 들어 배열의 벡터 폭 단위 부분을 처리하는 루프도 (조건과 분기 자체가) 제거될 수 있습니다. 컴파일러가 반복 횟수가 작고 고정되어 있음을 증명할 수 있으면 그냥 루프를 펼쳐 버립니다.

  30. quietbritishjim HN

    컴파일러가 포인터 역참조를 해당 메모리 주소를 읽는 명령으로 번역하는 건 어떻습니까? 그것도 미정의 동작일 수 있는 걸 안전한 연산(모든 메모리 할당을 추적해서 포인터가 그중 하나를 제대로 가리키는지 검사하는 것)보다 단순한 것으로 "최적화"한 겁니다. 그러면 (로컬 정보만으로 안전함을 증명할 수 있는 경우를 빼고) 포인터 역참조마다 경고가 나와야 합니다.

    정말 가망 없는 길입니다.

Hacker News에서 보기 ↗