C 언어의 정의되지 않은 동작 줄이기
요약
본 글은 C 언어의 '미정의 동작(Undefined Behavior, UB)' 문제를 깊이 있게 다루며, 기존 컴파일러가 제공하는 경고 시스템의 한계와 근본적인 해결책을 모색합니다. Fil-C나 CHERI 같은 메모리 안전성 기술들이 제시하지만, 이들 역시 ABI 수준에서 접근하고 전체 재컴파일이 필요하다는 한계를 지적합니다. 궁극적으로 C 표준 자체의 UB 범위를 줄이는 것이 중요하다고 주장합니다.
핵심 포인트
- C 언어의 미정의 동작(UB)은 너무 광범위하여 근본적인 개선이 필요함.
- Fil-C와 CHERI 같은 기술도 ABI 수준에서 접근하며, 전체 재컴파일이 요구됨.
- 단순히 경고를 늘리는 것보다 C 표준 자체의 UB 범위를 줄이는 것이 핵심임.
- UB 감지는 컴파일러 최적화 단계에서 이루어져야 가장 효과적일 수 있음.
그렇지만 이는 구현 정의 동작을 뒷받침하는 근거이지, 미정의 동작을 옹호하는 근거는 아닌 듯함.
Unisys가 2015년에 내놓은 마지막 Dorado 모델인 ClearPath Dorado 8300은 64비트 x86_64인 Xeon 프로세서를 사용함. 그래도 36비트 레지스터를 갖춘 Unisys 2200이 남아 있고 GCC/clang이 이 플랫폼용 C2y 컴파일러를 만든다고 가정해 보겠음.
그 컴파일러도 고정 폭 정수형을 지원해야 함. 이미 (unsigned) _BitInt(8/16/32/64) 지원이 필요하며, 반대로 x86_64에서도 C23의 _BitInt(36)으로 36비트 정수를 쓸 수 있음. int32_t와 uint32_t까지 지원하면 C23 이전의 수많은 오픈 소스 코드를 이 가상의 2200에서도 깔끔하게 컴파일할 수 있게 됨. uint32_t의 어셈블리 구현이 다소 지저분해지겠지만, Xeon에서 _BitInt(36)을 구현하는 것도 마찬가지이며 코드는 동일하게 컴파일되고 실행됨. 요즘은 토큰을 사서 에이전트에게 이식 작업을 맡길 수도 있겠지만, 그래도 이 지원은 의미가 있음.
본문에서 Fil-C와 CHERI를 소개한 것은 좋지만, 둘 다 실제 능력을 과소평가함. Fil-C는 단순히 시간적 메모리 안전성 버그를 많이 찾아내는 것이 아니라, 공간적·시간적 메모리 안전성 버그의 악용을 차단하고 언어 전체에 엄밀한 의미론을 부여함. CHERI는 다른 절충을 택하지만, 역시 메모리 안전성 취약점 악용을 막을 만큼 엄밀한 의미론을 제공함.
둘 다 ABI 수준에서 접근하므로 Rust보다 적용 범위가 포괄적임. 새 언어의 안전한 부분집합으로 다시 작성한 코드에만 보호가 적용되는 한계가 없기 때문임. Rust가 컴파일 시점에 검사한다는 장점은 있지만, 의미론의 명확성이나 악용 가능성이 관심사라면 큰 차이는 아님.
둘 다 할당 단위로만 포인터의 출처 정보를 관리함. 예를 들어 struct User { char name[100]; bool is_admin; };에서는 버퍼 오버플로로 여전히 is_admin을 덮어쓸 수 있음.
또한 둘 다 전체를 다시 컴파일해야 함. CHERI에서는 가능할 수 있지만 Fil-C에서는 불가능한 환경이 있으며, Windows나 macOS를 Fil-C로 지원할 수 없는 이유도 여기에 있음.
C의 UB에서 가장 불만인 부분은 아무 경고도 없다는 것임. 컴파일러가 황당하게 동작하는 것까지는 이 이상한 세상에서 어쩔 수 없이 받아들일 수 있지만, 적어도 명확한 경고는 원함. “앞선 0으로 나누기가 UB이므로 이 조건식이 한쪽 분기로 축약됨”이라고 알려주면 코드를 고치거나 조건을 지울 수 있음.
매크로 확장으로 생긴 코드여도 괜찮음. 다른 경고처럼 매크로 내부에서 pragma로 특정 진단을 잠시 끄거나, 수정할 수 없는 매크로라면 호출 부분을 pragma로 감싸면 됨. 컴파일러가 매크로 확장과 실제 사용자 실수를 구분할 수 있을지도 모르겠음.
단순한 무한 루프를 썼더니 C++ 컴파일러가 함수 종료 코드를 제거한 적이 있음. 무한 루프에 들어가는 대신 링크 배치상 바로 다음 함수의 코드를 실행했고, 진단은 하나도 없었으니 디버깅이 얼마나 황당했을지 상상해 보기 바람.
이는 현실적으로 불가능함. C 컴파일러는 UB가 발생하지 않는다는 가정을 워낙 많이 하므로, 정상 작동하는 프로그램까지 경고가 쏟아지게 됨.
정수 나눗셈만 보더라도 실행 중 0으로 나눌 때 검사하려면 실행 속도가 크게 느려지고, 컴파일 시점에 분모가 0이 아님을 증명하지 못할 때마다 경고하면 개발자가 그 사실을 컴파일러에 증명할 수 없어 없애지 못하는 경고가 넘쳐나게 됨. 0으로 나누기는 쉬운 편이며, 별칭 관련 가정에서는 경고 생성이 훨씬 어려워짐.
우선 UB의 종류부터 줄여야 함. C 표준의 UB는 파일 끝에 개행이 없는 경우부터 특정 코드·컴파일러 버전·옵션이 조합되어 다음 함수까지 실행이 흘러가는 경우까지 지나치게 광범위함.
내가 알기로 실행을 망가뜨리는 지점은 C 프런트엔드보다 한참 뒤인 최적화 단계여서, UB를 감지할 수 있더라도 컴파일 경고를 내기에는 너무 늦음. 주로 최적화 단계들이 예상치 못하게 상호작용하면서 문제가 생겨, 개별 단계는 자신이 코드를 망가뜨렸다는 사실조차 모를 수 있음.
물론 성능 비용을 감수하면 UBSAN으로 심각한 UB 상당수를 런타임 오류로 잡을 수 있음.
대부분의 UB는 실행 시점의 조건에 달려 있으므로, 런타임 검사를 넣으면 UB가 발생하지 않는다고 가정해 최적화하는 취지가 사라짐. 정적으로 확인 가능한 경우에는 활성화할 수 있는 경고가 종종 있으니 목록을 살펴봐야 함. 외부 검사 도구도 대안이며, clang-tidy에는 무한 루프 검사가 있지만 복잡한 경우에도 최적화기의 판단과 정확히 일치하는지는 모르겠음.
틀릴 수도 있지만 UB 경고의 큰 난점은 C보다는 C++ 템플릿에 있을 듯함. 현대 C는 대체로 눈에 보이는 코드가 실제 동작과 가깝지만, C++은 내부에서 무슨 일이 일어나는지 알기 어려움.
C는 C++보다 조금 느린 경우가 많으므로 UB 제거를 위해 약간의 속도를 희생해도 타격이 크지 않지만, 템플릿 확장 안에서 같은 일을 하면 크게 느려질 수 있음. 내가 선호하는 해결책은 C++ 사용을 그만두는 것임.
C는 시스템 프로그래밍용 고수준 어셈블리라고 보므로, 이 접근은 방향을 잘못 잡았다고 생각함. UB를 막으려고 런타임 동작을 추가하면 실행 시간이 급증하고, 정적 분석도 컴파일 시간을 크게 늘리지 않고 할 수 있는 일에는 한계가 있음.
C가 적합한 곳은 운영체제와 초당 수천 번 호출되는 코드이며, 이런 코드에는 아주 작은 런타임 검사 비용도 허용하기 어려움. 개발자는 자신이 무엇을 하는지 알아야 하며, 그렇지 않으면 손을 떼야 함.
응용 프로그램에서는 C 사용을 줄이고 Rust나 Go 같은 메모리 안전 언어로 유도해야 함. 다만 UB 측면에서 Rust가 이 경우를 해결하는지도 확신하지 못함.
C는 고수준 어셈블리가 아니며, 바로 그것이 문제임. 정말 고수준 어셈블리라면 UB도, UB를 둘 필요도 없을 것임. C에 UB가 있는 이유는 최적화가 필요하기 때문이며, 그 최적화가 없으면 운영체제에 쓰기에는 너무 느려짐. 물론 모든 UB가 꼭 필요한 것은 아닐 수 있음.
스크립트 언어도 초당 수천 번 이상 루프를 쉽게 실행함. 운영체제 내부 연산의 빈도를 설명하려면 그보다 몇 자릿수는 높은 수치를 써야 함.
고수준 어셈블리라면 생성된 기계어가 소스 코드에 밀접하게 대응하므로, 형식적으로 미정의인 동작도 해당 하드웨어에서 예상한 대로 동작할 것임. C의 문제는 현대 컴파일러가 소스와 최종 기계어 사이에서 수많은 변환을 수행해 실제 동작이 기대와 크게 달라질 수 있다는 데 있음.
프로그램 전체를 unsafe 안에 넣으면 UB 측면에서 Rust가 C보다 오히려 나쁨. 하지만 아무도 Rust를 그렇게 작성하지 않으며, Rust는 모든 UB를 unsafe 블록으로 제한함.
정적 분석의 핵심 문제는 컴파일 시간이 아니라, C가 충분하고 적절한 정보를 제공하지 않는다는 것임. 효율적이고 효과적인 분석에 필요한 정보를 추가하면 필연적으로 Rust와 비슷한 형태가 됨. 참고로 Rust의 긴 컴파일 시간은 정적 분석 부분 때문이 아님.
C는 부동소수점 최적화에 약한 것으로도 유명함. 역사적으로는 Fortran이 이 분야를 선도했으며 Numerical Recipes 책을 참고하면 됨. Julia는 더 새로운 대안이고, 둘 다 Python 객체 내부 구현에 흔히 쓰이는 것으로 알고 있음. https://numerical.recipes/에서 Fortran으로 작성된 구판인 2판을 온라인으로 무료로 읽을 수 있음.
C를 고수준 어셈블리라고 보는 것부터 근본적으로 잘못된 가정임. 현대 컴파일러에서 C는 다른 언어와 마찬가지로 고수준 언어이며, 적용되는 최적화도 다른 컴파일형 고수준 언어와 본질적으로 다르지 않음.
C 사용을 억제해야 한다는 것도 어셈블리나 다른 언어로 프로그램을 작성하지 못하게 하려는 것만큼 부당함. 궁극적으로 안전성을 보장할 책임은 신뢰할 수 없는 코드를 실행하는 샌드박스, 즉 브라우저·운영체제·VM에 있음.
Rust와 안전하지 않은 언어의 차이는 하나는 컴파일 시점에 실패하고 다른 하나는 샌드박스가 잘못된 프로세스를 종료해 실행 시점에 실패한다는 것뿐이어야 함. 신뢰할 수 없는 프로그램이 샌드박스를 탈출해 운영체제를 공격할 수 있다면, 운영체제에서 고쳐야 함.
b가 0이면 exit()를 호출하는 g()뿐 아니라, longjmp로 f를 빠져나가는 g() 도 생각해 볼 수 있음. f의 호출자가 설정한 jmp_buf 문맥으로 이동하면 추상 의미론상 b로 나누는 연산을 건너뛰게 됨.
정적 분석기가 여러 UB 패턴을 잡는 것은 봤지만, UB가 전혀 없음을 보장하려면 프로그램 전체 분석이 필요함. 그러면 컴파일 시간이 급증하고도 개발자를 압도할 만큼 많은 오탐이 발생함.
임의의 프로그램에 관해 무언가를 증명하기란 사실상 불가능하지만, 허용하는 프로그램을 제한하면 보장이 가능해짐.
단순한 예로 유효한 안전 Rust 프로그램을 C로 컴파일하면, 생성된 코드는 구성상 Rust의 빌림 규칙을 위반하지 않음을 알 수 있음. 원칙적으로는 원래 Rust 코드를 보지 않고 C 코드만으로 그 보장을 입증하려 시도할 수도 있음. 하지만 임의의 C 코드에 이런 문제가 있는지 언제나 판별하는 알고리즘은 여전히 만들 수 없음.
CompCert는 적어도 다뤄야 한다고 생각함. 자유 소프트웨어가 아니라 소스 공개형 라이선스이긴 하지만, 안전이 중요한 산업에서 대규모 C 코드베이스를 가진 기업들이 실제로 코드 검증에 사용하는 수단임. https://en.wikipedia.org/wiki/CompCert.
직접적인 관련성은 크지 않음. 잘못된 컴파일을 방지하고 최적화가 이용하는 UB도 줄여주지만, 프로그램에서 UB 자체를 없애지는 못함.
여러 문제에서 내가 만들 수 있는 가장 빠른 알고리즘은 비트 조작 기법을 사용함. x86에서는 작동하지만 엄밀히는 미정의 동작임.
그런 동작은 흔히 구현 정의 동작일 뿐이어서 충분히 안전함. 다른 환경으로 이식하려 할 때 오류를 내도록 컴파일 시점 검사를 넣으면 됨.
AI 자동 생성 콘텐츠
본 콘텐츠는 GeekNews의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기