Proven C BookEnglish GitHub

52 정의되지 않은 동작

먼저 알아야 할 것

13장 컴파일러 최적화 · 추상 기계와 관찰 가능한 동작
51장 오류와 계약 · 계약을 어긴다는 것

돌아보기

13장에서 엄격한 앨리어싱 위반이 “컴파일러에게 사실이 아닌 것을 사실 이라고 알리는 행위”라 했다. 그러면 표준이 어떤 동작을 “정의되지 않음” 으로 두었다는 것은 — 구현에게 무엇을 허락한 것인가?

답. 아무것도 요구하지 않기로 한 것이다. 표준의 문장은 냉정하다 — 정의 되지 않은 동작에 대해 표준은 “어떤 요구도 부과하지 않는다”. 즉 그런 프로그램에는 옳은 실행 결과라는 것이 아예 없다. 흔한 오해가 “위험한 동작이지만 대개 예상대로 돌아간다”인데, 계약의 눈으로는 그 프로그램 전체가 의미를 잃는다. 이 차이가 이 장의 전부다.

이 장의 필요성과 맥락

이 책이 쉰 장 넘게 「계약 밖」이라 부르며 미뤄 온 것을 정면으로 다룬다. 이 자리까지 미룬 것은 겁주지 않기 위해서가 아니라, 실물 없이 말하면 미신이 되기 때문이다. 지금은 독자가 오버플로·널 역참조·앨리어싱을 직접 만난 뒤라, UB 가 추상이 아니라 이미 본 것들의 이름이 된다.

이 장이 끝나면

이 책이 곳곳에서 “계약 밖”이라 부르며 미뤄 온 세계를 정면으로 다룬다. UB란 정확히 무엇이고 왜 존재하며, 왜 “조금 위험함”이 아니라 “무엇이든 가능함”인지 — 그리고 실무에서 어떻게 피하고 잡아내는지까지. 제2부의 계약 서사가 여기서 완결된다.

이 장에서 답할 질문

  1. UB를 전부 외워야 하는가? 표준에 수백 가지가 있다고 들었는데.

52.1 세 가지 회색지대 — UB, 미지정, 구현 정의

혼동되는 세 낱말을 먼저 갈라 둔다. 이 책이 여기까지 만난 사례들로 정리하면 이렇다.

종류표준의 태도이 책에서 만난 예
구현 정의 (implementation-defined)구현이 정하고 문서화한다int의 크기(27장), char의 부호
미지정(unspecified)정해진 선택지 중 하나이나 문서화 의무 없음한 수식 안 부분식의 평가 순서(33장)
정의되지 않음 (undefined behavior, UB)아무 요구도 하지 않는다부호 있는 오버플로(7장), 경계 침범(38장), 널 역참조(36장)

표 52.1

앞의 둘은 “답이 여럿이지만 어쨌든 답은 있는” 세계다. UB만이 답 자체가 없는 세계다.

낱말을 다시 못박아 둔다. 위 표의 세 이름 — 구현 정의·미지정·정의되지 않은 동작 — 은 표준 문서에 그대로 적혀 있는 정식 용어다. 반면 이 장의 제목과 본문이 쓰는 「회색지대」는 12장에서 밝힌 대로 이 책이 편의상 붙인 이름이며 표준에는 없는 말이다. 남과 이야기할 때는 정식 낱말로 옮겨서 말해야 한다 — “이건 회색지대야”가 아니라 “이건 미지정이야” 또는 “이건 UB야”로.

52.2 왜 존재하는가

계약 밖을 남겨 둔 이유는 두 가지다. 첫째, 기계들이 서로 다르기 때문이다 — 7장의 폭 이상 시프트가 대표다. x86과 옛 ARM이 다르게 대응하는데 표준이 한쪽을 택하면 다른 쪽 기계는 매번 보정 코드를 넣어야 한다. 어느 편도 들지 않는 대신 “그 코드를 쓰지 말라”고 한 것이다. 둘째, 최적화의 전제를 얻기 위해서다 — 7장에서 본 대로 “부호 있는 정수는 넘치지 않는다”는 전제가 있어야 컴파일러가 루프를 분석하고 재배열할 수 있다(13장). C가 반세기 동안 속도의 언어로 남은 대가가 이 조항들이다.

52.3 “무엇이든 가능함”의 실제 얼굴

UB의 결과는 붕괴만이 아니다. 13장에서 본 무늬가 더 무섭다 — 컴파일러가 UB를 “일어날 리 없는 일”로 읽고 코드를 지워 버리는 것이다. 널 검사가 통째로 사라지거나(포인터를 이미 역참조한 뒤라면 “널일 리 없다”고 추론한다), 오버플로 검사가 사라지거나(부호 있는 넘침은 없다고 전제 하므로), 루프가 무한이 되거나 아예 없어진다. 그래서 UB의 대표적 증상은 “그 자리에서 죽는 것”이 아니라 엉뚱한 곳에서, 최적화 수준을 바꾸면 사라지는, 재현이 어려운 버그다.

실제 사례. 사라진 널 검사 — 리눅스 커널 CVE-2009-1897

이 무늬가 커널에서 실제로 터진 사건이 있다. 코드는 대략 이랬다 — 포인터 tun을 먼저 역참조해 값을 꺼내고, 그 아래에서 if (!tun) return ...;으로 널을 검사했다. 순서가 뒤집힌 실수이지만, 사람 눈에는 “그래도 검사는 있으니 널이면 걸리겠지”로 보인다. 컴파일러의 추론은 달랐다: 이미 역참조했다 → 널이었다면 그 순간 UB → UB는 일어나지 않는다고 전제 → 따라서 tun은 널이 아니다 → 아래의 널 검사는 죽은 코드다. 검사가 통째로 최적화로 제거됐고, 널 페이지를 매핑할 수 있던 당시 환경과 결합해 권한 상승 취약점이 됐다. 컴파일러는 규칙대로 일했고, 무너진 것은 계약이었다.

52.4 계산이 시작되기도 전에 — 파일 모양의 UB

정의되지 않은 동작이라 하면 대개 넘침이나 널 역참조처럼 실행 중의 사고를 떠올린다. 그런데 표준의 목록(부속서 J.2)을 앞에서부터 읽으면 놀라운 것이 나온다 — 두 번째 항목이 파일의 마지막 글자에 대한 것이다.

표준이 요구하는 것어기면
비어 있지 않은 소스 파일은 개행 문자로 끝나야 한다정의되지 않은 동작
그 개행이 역슬래시로 이어진 것이면 안 된다정의되지 않은 동작
파일이 토막 난 전처리 토큰이나 주석으로 끝나면 안 된다정의되지 않은 동작

표 52.2

마지막 줄 끝에 개행이 없는 파일은, 문법이 아무리 완벽해도 계약 밖이다. 이 규정은 C89부터 있었고 C23(ISO/IEC 9899:2024)에도 그대로 남아 있다 — 부속서 J.2의 두 번째 항목이 그것이다. 참고로 C++는 2011년에 이 조항을 버렸다(없으면 개행을 붙인 것으로 친다고 정했다). 두 언어가 갈라선 드문 자리다.

왜 이런 것이 UB인가. 57장에서 본 번역 단계를 떠올리면 답이 나온다. 전처리기는 줄 단위로 일하고, 지시문 하나는 개행으로 끝나야 완성된다. 파일이 개행 없이 끝나면 마지막 줄이 미완성인 채로 남고, 그다음에 무슨 일이 벌어질지는 구현마다 다르다. 세 번째 줄의 “토막 난 토큰”도 같은 사정이다 — 따옴표가 닫히지 않은 문자열이나 */가 없는 주석으로 파일이 끝나면 전처리기는 다음 파일을 계속 읽어야 할지 판단할 근거가 없다.

오늘의 컴파일러는 대부분 조용히 개행을 붙여 준다(옛 GCC는 warning: no newline at end of file을 냈다). 그래서 실무에서 이 조항이 문제를 일으키는 자리는 컴파일러가 아니라 그 파일을 다루는 다른 도구들 이다.

실제 사례. 개행 하나가 만든 실무의 소음 — 깃과 유닉스 도구

POSIX는 을 “개행으로 끝나는 문자열”로 정의한다. 그래서 마지막 개행이 없는 파일은 도구들 눈에 “마지막 줄이 미완성인 파일”이 되고, 다음 일들이 벌어진다.

  • 깃의 차이(diff)에 표시가 남는다 — 그 유명한 \ No newline at end of file 줄이다. 나중에 누군가 개행을 더하면 내용이 그대로인 줄이 바뀐 줄로 잡혀 차이가 지저분해지고, 여러 갈래를 합칠 때 그 자리에서 충돌이 나기 쉽다. 깃허브의 웹 화면에서 마지막 줄에 붉은 표시가 붙는 것도 같은 이유다.

    • 파일을 이어 붙이면 줄이 붙어 버린다cat a.txt b.txt에서 a의 마지막 줄과 b의 첫 줄이 한 줄이 된다. 조각 파일을 이어 붙여 소스나 설정을 만드는 빌드에서 특히 성가시다.
    • 줄 수를 세는 도구가 하나를 빠뜨린다wc -l은 개행의 개수를 세므로 미완성 마지막 줄은 세지 않는다.

    그래서 오늘의 관행은 한 줄이다 — 텍스트 파일은 개행으로 끝낸다. 편집기 설정(마지막 줄 개행 자동 추가)이나 .editorconfiginsert_final_newline, 그리고 96장에서 볼 서식 도구들이 이 일을 대신해 준다. C 표준의 조항은 그 관행의 가장 오래된 근거인 셈이다.

52.5 그 밖의 기묘한 UB들

C23 기준으로 부속서 J.2는 정의되지 않은 동작을 218가지 나열한다. 대부분은 평생 만날 일이 없는 것들이지만, 그중에는 “이런 것까지?” 싶은 항목이 여럿 있다. 실행 중의 계산과는 무관한, 글자와 이름의 세계에서 벌어지는 것들을 골라 둔다.

이런 코드무엇이 문제인가표준의 자리
파일이 개행 없이 끝남앞 절에서 본 그것5.1.1.2
주석 /*를 안 닫고 파일 끝토막 난 주석으로 끝나는 것도 같은 항목5.1.1.2
따옴표를 안 닫은 문자열로 파일 끝토막 난 전처리 토큰5.1.1.2
#include "dir\file.h"헤더 이름 안의 \는 UB — 윈도우 경로를 그대로 적으면 걸린다6.4.7
#include <a//b.h>//, /*, ', "도 마찬가지로 UB6.4.7
#define defined(x) …defined를 매크로 이름으로 쓰는 것6.10.9
#undef assert 뒤 사용표준 라이브러리 매크로를 지우고 쓰는 것7.1.3
int _Value;, int __x;예약된 이름 공간을 침범하는 것(82장)7.1.3
memcpy(p, q, 0)p가 널크기가 0이어도 널 포인터는 계약 밖(아래 참고)7.26.2
printf("%s", NULL)널을 문자열로 넘기는 것7.23.6.1
short a[10]; short *p = &a[15];역참조하지 않아도 범위 밖 포인터를 만드는 것만으로 UB6.5.7
if (p > q) — 서로 다른 객체관계 연산자로 비교하는 것(37장)6.5.9
towctrans를 다른 로케일에서wctrans를 부를 때와 LC_CTYPE이 다르면 UB7.31.3.2

표 52.3

앞의 셋은 앞 절에서 본 “파일 모양” 항목의 나머지 얼굴이다. 주석이나 문자열을 닫지 않은 채 파일이 끝나는 것도 같은 조항에 걸린다 — 어차피 컴파일이 안 될 것 같지만, 표준의 눈으로는 진단조차 요구되지 않는 자리다.

가운데 두 줄이 특히 실용적이다. 윈도우에서 #include "utils\str.h"라고 적는 것은 표준으로는 정의되지 않은 동작이다 — 실제로는 MSVC가 알아서 처리해 주지만, 이식하는 순간 문제가 된다. 표준이 보장하는 것은 /뿐이고, 다행히 윈도우 컴파일러들도 /를 받아들인다. 그래서 헤더 경로에는 언제나 /를 쓰라는 조언이 나온다.

short *p = &a[15]; 줄도 놀라는 자리다. 읽지도 쓰지도 않았는데 포인터를 만든 것만으로 계약 밖이다(배열의 끝 바로 다음 자리까지만 허용된다). “주소만 계산해 두고 안 쓰면 되겠지”가 통하지 않는 이유이고, 37장에서 포인터 산술에 경계를 그은 근거이기도 하다.

마지막 줄은 이 목록의 성격을 잘 보여 준다. 와이드 문자 변환 함수 하나가 “wctrans를 불렀을 때와 지금의 로케일이 같아야 한다”는 조건을 달고 있고, 어기면 UB다. 218가지 중 대부분이 이런 결이다 — 아주 좁고, 아주 구체적이고, 평생 만날 일이 없는.

실제 사례. UB가 줄어들기도 한다 — memcpy(NULL, NULL, 0)의 사연

표 아홉째 줄은 오랫동안 논쟁거리였다. “크기가 0이니 아무 일도 안 할 텐데 포인터가 널이든 무슨 상관인가” 싶지만, 표준은 memcpy의 두 포인터가 유효할 것을 크기와 무관하게 요구했다. 그래서 빈 배열을 다루는 코드가 아무 잘못 없이 계약 밖으로 미끄러지곤 했다.

void copy(int *dst, const int *src, size_t n) {
    memcpy(dst, src, n * sizeof *dst);   /* n == 0 이고 둘 다 널이면 UB */
}

실제 피해도 있었다. 컴파일러는 memcpy의 인자가 널이 아니라는 전제를 얻으므로, 그 뒤에 오는 널 검사를 지워 버릴 수 있다 — 13장과 이 장에서 본 그 무늬다. 새니타이저(17장)도 이것을 잡아낸다.

그런데 이 조항은 없어지는 쪽으로 정해졌다. 표준 위원회가 제안 N3322를 받아들여, 다음 판(C2y)에서는 길이가 0인 연산에 널 포인터를 주는 것이 정의된 동작이 된다 — memcpy(NULL, NULL, 0), memcmp(NULL, NULL, 0), (int *)NULL + 0, (int *)NULL - (int *)NULL이 전부 합법이 된다. 위원회는 구현자들에게 옛 표준에도 소급 적용하라고 권고까지 했다.

이 사연이 남기는 교훈이 둘이다. 첫째, UB 목록은 고정된 성경이 아니다 — 최적화에 쓸모가 없고 사람만 괴롭히는 조항은 시간이 지나며 정리된다. 둘째, 그럼에도 지금 쓰는 컴파일러가 그 변화를 반영했는지는 별개다. 당분간은 n == 0을 먼저 확인하는 코드가 여전히 옳다.

흔한 오해. “이런 것들은 이론적인 트집이지 실제로는 아무 일도 안 일어난다”

대부분의 자리에서는 정말 아무 일도 일어나지 않는다. 그러나 이 장의 주제가 바로 그 점이다 — 아무 일도 일어나지 않는 것은 보장이 아니다. 개행 없는 파일은 컴파일러에서는 조용하지만 도구 사슬에서 소음을 만들고, memcpy(NULL, NULL, 0)은 어제까지 멀쩡하다가 최적화 수준을 올린 날 널 검사가 사라지며 터진다.

실용적인 태도는 이렇다. 공짜로 지킬 수 있는 조항은 그냥 지킨다. 파일 끝에 개행을 넣고, 헤더 경로에 /를 쓰고, 이름 앞에 밑줄 두 개를 붙이지 않는 것 — 여기에 드는 비용은 0이고, 그 대가로 “이 자리에서는 의심하지 않아도 된다”는 것 하나를 얻는다.

52.6 피하는 법 — 규율, 도구, 그리고 부품

실무의 방어는 세 겹이다.

규율 — 이 책이 지나온 수칙들이 그 목록이다: 초기화하고 쓴다(23장), 경계를 지킨다(38장), 널을 확인한다(36장), 한 문장에서 한 변수는 한 번만 바꾼다(33장), 계약 밖의 지름길(포인터 캐스트, 표현 가정)을 쓰지 않는다(11·37장).

도구 — 17장에서 장만한 그물이다. 컴파일러 경고는 컴파일 시점에, UBSan·ASan은 실행 시점에 잡는다. C23이 들여온 검사 산술도 이 층의 도구다 — 넘침을 UB로 만들지 않고 값으로 알려 주는 함수들이다:

examples/ch52/checked.c

#include <stdckdint.h>
#include <stdio.h>

/* C23의 검사 산술: 넘치면 true를 돌려주고, 결과는 감아 돈 값이 담긴다. */
int main(void)
{
    int a = 2000000000;
    int b = 2000000000;
    int sum = 0;

    if (ckd_add(&sum, a, b)) {
        printf("%d + %d overflows int (we stayed inside the contract)\n", a, b);
    } else {
        printf("sum: %d\n", sum);
    }

    int small = 0;
    if (ckd_add(&small, 20, 22)) {
        printf("overflow\n");
    } else {
        printf("20 + 22 = %d\n", small);
    }
    return 0;
}

실행 결과

2000000000 + 2000000000 overflows int (we stayed inside the contract)
20 + 22 = 42

ckd_add는 “넘쳤는가”를 반환값으로 돌려준다 — 7장에서 배운 “부호 있는 넘침은 계약 밖”이라는 함정을, 51장에서 배운 “오류는 값이다”라는 규율로 다스리는 표준의 답이다.

부품 — 애초에 계약 위반이 어려운 API를 쓰는 것이다(43장의 proven이 그 층이다). 도구가 그물이라면 좋은 부품은 발판이라는 17장의 비유가 여기서 완성된다.

문. UB를 전부 외워야 하는가? 표준에 수백 가지가 있다고 들었는데.

답. 외우는 것이 목표가 아니다 — 목록은 방대하고 계속 다듬어진다. 실무에서 통하는 것은 감각이다: “이 코드가 옳다고 말하는 근거가 계약서에 있는가, 아니면 ‘내 컴퓨터에서 돌아서’인가”를 묻는 습관. 그리고 그 감각을 도구로 뒷받침하는 것 — 경고를 켜고, 새니타이저로 시험 실행 하고, 두 컴파일러로 교차 검증하는 것(17장). 이 책이 제2부부터 “추상 기계 위에서 옳은가”를 되풀이한 이유가 바로 이 감각을 심기 위해서다.

52.7 제9부를 닫으며

정밀의 부가 끝났다 — 근사를 다루는 법(50장), 실패를 다루는 법(51장), 그리고 계약 밖의 세계를 아는 법(52장). 세 장의 공통 주제는 하나다: C는 프로그래머에게 많은 것을 맡기는 언어이고, 맡겨진 것을 아는 사람이 안전한 코드를 쓴다.

마지막 부가 남았다. 지금까지의 프로그램은 파일 하나짜리였다 — 이제 여러 파일로 자라고(54장), 전처리와 번역의 층을 정면으로 보고(57장), 표준 라이브러리의 지형을 익히고(61장), proven을 정면으로 다루며(제12부), 모던 C의 관행으로 책을 닫는다(98장).

다음 부는 프로그램을 구성하는 이야기다. 그 첫 장은 지금까지 여섯 줄짜리 관례로만 써 온 자리 — main 자신이다. 세 가지 형태와, 돌려주는 값이 어디로 가는지를 본다.