"미정의 동작"은 "예측할 수 없음"을 뜻하는 전문 용어처럼 들립니다. 그보다 강합니다. C 표준이 어떤 구성이 미정의 동작이라고 말할 때, 그것은 프로그램이 무엇을 하는지에 대해 표준이 어떤 요구도 하지 않는다는 뜻입니다. 값에 대해서도, 문장에 대해서도, 프로그램에 대해서도요.
사람들을 놀라게 하는 부분이 이것입니다. 미정의 동작은 문제의 줄에 국한되지 않습니다. 컴파일러는 그런 일이 절대 없다고 가정하고 그 가정 위에서 주변 코드를 다시 쓸 수 있습니다. 그 결과, 여러분이 분명히 쓴 검사가 바이너리에는 존재하지 않는 프로그램이 나올 수 있습니다.
계약 모델
표준을 여러분과 컴파일러 사이의 계약이라고 생각해 보세요. 여러분은 어떤 일들을 하지 않겠다고 약속하고, 그 대가로 컴파일러는 여러분의 프로그램이 쓰인 대로의 뜻을 갖도록 약속합니다.
배열 바깥을 인덱싱하지 말 것. 부호 있는 정수를 넘치게 하지 말 것. 초기화되지 않은 값을 읽지 말 것. 해제한 뒤 포인터를 쓰지 말 것. 한 식 안에서 시퀀스 포인트 없이 같은 객체를 두 번 수정하지 말 것.
조항 하나를 깨면 거래가 무효가 됩니다. 그 줄만이 아니라 프로그램 전체에 대해서요. "합리적인 대체 동작" 같은 것은 없고 죽어야 할 의무도 없습니다.
구분할 만한 관련 용어가 셋 있습니다.
- 미정의 동작(undefined) - 무슨 일이든 일어날 수 있습니다. 범위를 벗어난 접근, 부호 있는 오버플로, 해제 후 사용.
- 미지정 동작(unspecified) - 유효한 결과 여럿 중 하나이고, 컴파일러가 어느 쪽인지 알려 줄 필요가 없습니다. 예를 들어 함수 인자가 평가되는 순서입니다.
- 구현 정의 동작(implementation-defined) - 구현이 고르고 그 선택을 문서화해야 합니다.
char가 부호 있는지,int가 얼마나 큰지 같은 것이죠.
"최적화기가 내 코드를 지웠다"는 의미에서 위험한 것은 첫 번째뿐입니다.
큰 원천들
부호 있는 정수 오버플로
부호 없는 산술은 감기고, 표준이 그렇게 말합니다. 부호 있는 산술은 그렇지 않습니다. 범위를 넘어서는 것은 미정의입니다.
if (a > INT_MAX - b) 검사는 전부 유효 범위 안에서 돌고, 그것이 올바른 오버플로 검사인 이유입니다. if (a + b < 0)이라고 쓰면 오버플로를 먼저 일으킨 뒤 결과에 대해 묻는 것이고, 오버플로가 없었다고 가정할 자격이 있는 컴파일러가 그 검사를 없앨 수 있습니다.
범위를 벗어난 접근
배열 바깥을 읽거나 쓰는 것은 죽든 안 죽든 미정의입니다.
int arr[5] = {1, 2, 3, 4, 5};
int x = arr[5]; /* UB: 인덱스 5는 존재하지 않는다 */
arr[-1] = 0; /* UB */
int *p = arr + 10; /* UB: 이 포인터를 계산하는 것조차 */
마지막 줄을 보세요. 끝에서 한 칸을 넘어선 포인터를 만드는 것은 역참조하지 않더라도 미정의입니다. 표준은 arr + 5(반복문 종료를 위한 끝 다음 한 칸)는 허용하지만 arr + 6은 허용하지 않습니다.
작은 초과가 위험한 쪽입니다. 보통 세그폴트를 내지 않고 이웃 변수를 조용히 덮어써서, 틀린 답이 상관없어 보이는 곳에서 나타납니다.
초기화되지 않은 읽기
int x;
printf("%d\n", x); /* UB: 불확정 값을 읽는다 */
int *p;
*p = 42; /* UB: 불확정 포인터를 역참조한다 */
"그냥 쓰레기 값이 들어 있겠지"라고 생각하기 쉽지만, 표준이 말하는 바는 그것이 아니고 컴파일러가 그 차이를 이용합니다. GCC가 대입 전에 읽힌 변수가 마음대로 어떤 값이든 가질 수 있다고 결론짓는 것으로 알려져 있는데, 분기를 접어 없애기 좋은 값도 포함해서입니다.
댕글링 포인터
int *p = malloc(sizeof *p);
free(p);
*p = 42; /* UB: 해제 후 사용 */
free(p); /* UB: 이중 해제 */
int *q;
{
int local = 10;
q = &local;
}
printf("%d\n", *q); /* UB: 객체의 수명이 끝났다 */
런타임 결과는 세그멘테이션 폴트에서 다룹니다. 여기서의 요점은 크래시가 운 좋은 결말이라는 것입니다.
잘못된 printf 지정자
printf("%d\n", 3.14); /* UB: double에 %d */
printf("%s\n", 42); /* UB: int에 %s - 보통 죽는다 */
printf("%d %d\n", 1); /* UB: 지정자보다 인자가 적다 */
long n = 5;
printf("%d\n", n); /* long이 int보다 넓은 시스템에서 UB */
printf는 가변 인자 함수입니다. 형식 문자열에 따라 인자를 읽을 뿐 그것을 검사할 수 없습니다. 짝이 맞지 않으면 엉뚱한 자리에서 엉뚱한 바이트 수를 읽게 됩니다. -Wall로 컴파일하면 컴파일러가 형식 문자열을 대신 검사해 줍니다. 그 묶음에서 가치가 가장 높은 경고 중 하나입니다.
한 식에서 객체를 두 번 수정하기
int i = 0;
i = i++ + ++i; /* UB */
arr[i] = i++; /* UB */
printf("%d %d\n", i++, i); /* UB */
이것들은 단지 "컴파일러에 따라 다름"이 아니라 미정의입니다. i = i++ + ++i가 무엇으로 평가되느냐고 묻는 교과서 퀴즈에는 정답이 없습니다.
엄격한 앨리어싱
호환되지 않는 타입의 포인터를 통해 객체에 접근하는 것은 미정의이고, 이것은 숙련된 프로그래머들도 놀라게 합니다.
float f = 1.0f;
int *p = (int *)&f;
printf("%d\n", *p); /* UB: float를 int *로 읽는다 */
컴파일러는 int *와 float *가 절대 같은 메모리를 가리키지 않는다고 가정하고 그에 맞춰 순서를 바꿉니다. 바이트를 재해석하는 정의된 방법은 memcpy(같은 명령어로 최적화됩니다)이거나 공용체입니다.
char *는 예외입니다. unsigned char *로는 언제나 어떤 객체의 바이트든 들여다볼 수 있습니다.
"제 컴퓨터에서는 되는데요"가 아무것도 증명하지 못하는 이유
미정의 동작은 종종 잘 되는 것처럼 보이고, 그것이 위험한 이유입니다. 개발과 테스트 내내 프로그램이 제대로 돌다가, 전혀 상관없는 무언가가 바뀔 때 깨집니다.
- 더 똑똑한 최적화기를 가진 새 컴파일러 버전.
- 릴리스 빌드를 위해
-O0에서-O2로 바꾸기. - 무관한 함수를 추가해 스택 배치가 밀리면서, 초과 쓰기가 이제 중요한 것에 떨어지는 것.
- 다른 기계, 다른 libc, 다른 운영체제.
잘 되어 보이는 것은 정확성의 증거가 아닙니다. 표준이 아무것도 약속한 적이 없으니까요. 잠자고 있는 버그이고, 방아쇠는 보통 릴리스 빌드입니다.
최적화기가 그것을 이용하는 방법
논쟁을 대개 끝내는 예제가 여기 있습니다. 어떤 프로그래머가 널 검사를 씁니다.
void process(int *p) {
int value = *p; /* 역참조 */
if (p == NULL) { /* 그다음 널 검사 */
return;
}
printf("%d\n", value * 2);
}
순서가 틀렸습니다. 검사가 역참조 뒤에 옵니다. 그래도 검사는 실행되겠죠?
그럴 필요가 없습니다. 컴파일러는 이렇게 추론합니다. *p가 역참조되었으므로 p는 NULL일 수 없고(NULL 역참조는 미정의이므로, 동작이 정의된 어떤 프로그램에서도 NULL이 아닙니다), 따라서 p == NULL은 항상 거짓이고, 따라서 if 본문 전체가 죽은 코드라 지울 수 있습니다.
컴파일된 함수에는 널 검사가 아예 없습니다. 리눅스 커널에서 이 패턴의 실제 사례가 CVE-2009-1897이 되었는데, GCC가 정확히 그런 검사를 제거해 무해해 보이던 순서 실수를 악용 가능한 취약점으로 바꿨습니다.
두 번째, 더 작은 예제입니다.
/* 동작하지 않는 오버플로 검사 */
int safe_add(int a, int b) {
int sum = a + b;
if (sum < a) { /* "감겼나?" */
return -1;
}
return sum;
}
부호 있는 타입에 대해 컴파일러는 a + b가 넘치지 않았다고 가정할 수 있고, 그렇다면 sum < a는 b < 0일 때만 가능합니다. 그 가정 아래에서 검사는 저자가 뜻한 것과 다른 것을 검사하게 되고, b >= 0이면 통째로 최적화되어 사라질 수 있습니다. 동작하는 판은 미리 검사합니다.
두 검사 모두 표현 가능한 범위 안에 머물러 오버플로가 일어나지 않고, 최적화기가 없애 버릴 가정거리도 없습니다. (GCC와 clang은 이 일을 명령어 하나로 하는 __builtin_add_overflow도 제공합니다.)
탐지하기
정적 경고가 먼저입니다. 공짜니까요.
gcc -Wall -Wextra -Wpedantic program.c -o program
이것이 형식 문자열 불일치, 일부 초기화되지 않은 읽기, 수상한 비교, 닿을 수 없는 코드를 잡습니다.
그다음은 프로그램을 계측해 위반 순간에 보고하는 새니타이저입니다.
gcc -g -fsanitize=undefined program.c -o program
./program
program.c:8:15: runtime error: signed integer overflow:
2147483647 + 1 cannot be represented in type 'int'
메모리 쪽을 위해 AddressSanitizer와 결합하세요.
gcc -g -Wall -Wextra -fsanitize=address,undefined program.c -o program
둘을 함께 쓰면 범위를 벗어난 접근, 해제 후 사용, 이중 해제, 부호 있는 오버플로, 잘못된 시프트, 정렬되지 않은 포인터, 널 역참조를 잡아 주며, 각각 파일과 줄과 스택 추적이 따라옵니다. 대략 2배 느려지는데, 개발 중에는 아무것도 아닙니다.
valgrind ./program은 다시 컴파일할 필요가 없고 초기화되지 않은 읽기와 메모리 오류를 잡지만, 산술 UB는 잡지 못합니다. clang --analyze와 gcc -fanalyzer는 프로그램을 실행하지 않고도 일부를 찾아냅니다.
실용적인 규칙. CI에서 새니타이저 아래로 테스트를 돌리세요. 로컬에서 잘 되어 보이는 UB야말로 그것들이 존재하는 이유입니다.
함께 살아가기
조심하는 것만으로 미정의 동작을 피할 수는 없습니다. 누구나 결국 쓰게 됩니다. 통하는 것은 그것을 요란하게 만드는 일입니다.
- 첫날부터
-Wall -Wextra로 빌드하고 모든 경고를 고치세요. -fsanitize=address,undefined아래에서 테스트를 돌리세요.- 모든 변수를 선언 자리에서 초기화하고, 모든 포인터를
NULL로 두세요. - 배열 인덱스를 길이와 비교해 확인하고,
i < n으로 반복하세요. <limits.h>를 써서 산술 전에 오버플로를 검사하세요.- 해제한 뒤에는 포인터를
NULL로 설정하세요. - 감기는 것이 의도된 동작인 곳에는
unsigned타입을 쓰세요. 거기서는 정의되어 있습니다. - 바이트를 재해석할 때는 포인터 캐스팅보다
memcpy를 선호하세요.
C의 속도는 여러분이 계약을 지켰다고 컴파일러가 가정할 수 있는 데서 나옵니다. 그것은 설계 결함이 아니라 진짜 거래이고, 위의 도구들은 개발 시간의 비용 없이 안전의 대부분을 되돌려 줍니다.
이 규칙들의 런타임 얼굴은 세그멘테이션 폴트를, 그보다 먼저 오는 컴파일 타임 실수들은 흔한 오류를 보세요.
자주 묻는 질문
C에서 미정의 동작이란 무엇인가요?
C 표준이 아무런 요구도 하지 않는 코드입니다. 컴파일러는 무엇이든 만들어 낼 수 있습니다. 크래시, 틀린 답, 잘 되는 것처럼 보이는 코드, 또는 문제의 분기가 통째로 제거된 코드까지요. "구현 정의"도 "무작위"도 아닙니다. 여러분이 깨뜨린 계약이고, 그 뒤로는 아무것도 약속되지 않습니다.
C는 왜 전부 정의하지 않고 미정의 동작을 두나요?
속도와 이식성 때문입니다. 배열 접근마다 경계 검사를 요구하면 C가 치르지 않기로 설계된 성능 비용이 들고, 부호 있는 오버플로를 감싸기로 정의하면 대신 트랩을 내는 하드웨어에서 추가 명령어를 강요하게 됩니다. 그 경우들을 미정의로 두면 컴파일러가 그런 일이 없다고 가정하고 최적화할 수 있습니다.
C에서 부호 있는 정수 오버플로는 미정의 동작인가요?
네. INT_MAX + 1은 미정의입니다. INT_MIN으로 감긴다고 보장되지 않습니다. 부호 없는 오버플로는 다릅니다. 완전히 정의되어 있고 2^N으로 나눈 나머지로 감깁니다. 그래서 컴파일러가 부호 있는 x에 대해 x + 1 > x가 항상 참이라고 가정하고, 그런 식으로 쓴 오버플로 검사를 지울 수 있습니다.
제 C 프로그램의 미정의 동작을 어떻게 탐지하나요?
먼저 -Wall -Wextra로 컴파일해 컴파일러가 정적으로 볼 수 있는 것을 잡고, 그다음 -fsanitize=address,undefined로 테스트를 돌리세요. 범위를 벗어난 접근, 해제 후 사용, 부호 있는 오버플로 등을 일어나는 순간 파일과 줄과 함께 보고합니다. valgrind는 다시 컴파일하지 않고도 비슷한 것들을 잡습니다.