미정의 동작

컴퓨터 프로그래밍에서 프로그램은 프로그래밍 언어 사양이 특정 요구 사항을 강제하지 않는 코드를 포함하거나 실행할 때 미정의 동작(undefined behavior, UB)을 보인다. 이는 언어 사양이 결과를 규정하지 않는 미지정 동작과, 컴퓨팅 플랫폼의 다른 구성 요소(예: ABI 또는 트랜슬레이터 문서)의 문서를 따르는 구현 정의 동작과는 다르다.

C 프로그래밍 커뮤니티에서 미정의 동작은 컴파일러가 "코에서 악마가 날아가게" 하는 등 원하는 모든 것을 할 수 있도록 허용한다고 설명한 comp.std.c 게시물에 따라 유머러스하게 "코 악마"라고 불리기도 한다.

 개요 
일부 프로그래밍 언어는 프로그램 실행 중에 미정의 동작이 발생하지 않는 한, 소스 코드와 다른 방식으로 작동하거나 심지어 다른 제어 흐름을 가질 수 있도록 허용한다. 미정의 동작은 프로그램이 충족해서는 안 되는 조건 목록의 이름이다.

C의 초기 버전에서 미정의 동작의 주요 이점은 다양한 기계에 대해 성능이 좋은 컴파일러를 생성한다는 것이었다. 특정 구문은 기계별 기능에 매핑될 수 있었고, 컴파일러는 언어에 의해 부과된 의미론과 일치하도록 부작용을 조정하기 위해 런타임에 추가 코드를 생성할 필요가 없었다. 프로그램 소스 코드는 특정 컴파일러와 지원할 컴퓨팅 플랫폼에 대한 사전 지식을 가지고 작성되었다.

그러나 플랫폼의 점진적인 표준화로 인해 이러한 이점은 특히 C의 최신 버전에서 줄어들었다. 이제 미정의 동작 사례는 일반적으로 코드의 모호하지 않은 소프트웨어 버그를 나타내는데, 예를 들어 배열의 범위를 벗어나 인덱싱하는 경우이다. 정의에 따르면 런타임은 미정의 동작이 결코 발생하지 않는다고 가정할 수 있으므로, 일부 유효하지 않은 조건을 확인할 필요가 없다. 컴파일러의 경우, 이는 또한 다양한 프로그램 변환이 유효해지거나, 그 정확성 증명이 단순화된다는 것을 의미한다. 이는 프로그램 상태가 그러한 조건을 결코 충족하지 않는다는 가정에 의존하는 다양한 종류의 최적화를 가능하게 한다. 컴파일러는 프로그래머에게 알리지 않고 소스 코드에 있었을 수 있는 명시적 검사를 제거할 수도 있다. 예를 들어, 발생 여부를 테스트하여 미정의 동작을 감지하는 것은 정의상 작동이 보장되지 않는다. 이로 인해 휴대용 오류 방지 옵션을 프로그래밍하기 어렵거나 불가능하다(일부 구문의 경우 비휴대용 솔루션이 가능하다).

현재 컴파일러 개발은 일반적으로 범용 데스크톱 및 랩톱 시장(예: amd64)에서 주로 사용되는 플랫폼에서도 마이크로 최적화를 중심으로 설계된 벤치마크로 컴파일러 성능을 평가하고 비교한다. 따라서 미정의 동작은 컴파일러 성능 향상을 위한 충분한 여지를 제공하는데, 특정 소스 코드 문의 소스 코드가 런타임에 어떤 것이든 매핑될 수 있기 때문이다.

C 및 C++의 경우, 컴파일러는 이러한 경우에 컴파일 시간 진단을 제공할 수 있지만 필수는 아니다. 구현은 디지털 논리의 Don't-care term과 유사하게 이러한 경우에 무엇을 하든 올바른 것으로 간주된다. 컴파일러 구현이 이러한 경우에 진단을 발행할 수 있지만, 미정의 동작을 호출하지 않는 코드를 작성하는 것은 프로그래머의 책임이다. 오늘날 컴파일러는 이러한 진단을 활성화하는 플래그를 가지고 있다. 예를 들어, -fsanitize=undefined는 gcc 4.9 및 클랭에서 "미정의 동작 새니타이저"(UBSan)를 활성화한다. 그러나 이 플래그는 기본값이 아니며, 이를 활성화하는 것은 코드를 빌드하는 사람의 선택이다.

특정 상황에서는 미정의 동작에 대한 특정 제한이 있을 수 있다. 예를 들어, CPU의 명령어 집합 사양은 일부 명령어 형식의 동작을 미정의로 둘 수 있지만, CPU가 메모리 보호를 지원하는 경우 사양에는 사용자 접근 가능한 어떤 명령어("user-accessible instruction")도 운영체제의 보안에 구멍을 낼 수 없다는 일반적인 규칙이 포함될 것이다. 따라서 실제 CPU는 그러한 명령어에 응답하여 사용자 레지스터를 손상시키는 것이 허용되지만, 예를 들어 관리자 모드로 전환하는 것은 허용되지 않는다.

툴체인 또는 런타임이 소스 코드에서 발견되는 특정 구문이 런타임에 사용 가능한 특정 잘 정의된 메커니즘에 매핑된다고 명시적으로 문서화하는 경우, 런타임 플랫폼도 미정의 동작에 대한 일부 제한 또는 보장을 제공할 수 있다. 예를 들어, 인터프리터는 언어 사양에서 미정의된 일부 작업에 대해 특정 동작을 문서화할 수 있지만, 동일한 언어의 다른 인터프리터 또는 컴파일러는 그렇지 않을 수 있다. 컴파일러는 특정 ABI를 위한 실행 코드를 생성하여 컴파일러 버전에 따라 semantic gap을 채운다. 해당 컴파일러 버전의 문서와 ABI 사양은 미정의 동작에 대한 제한을 제공할 수 있다. 이러한 구현 세부 사항에 의존하면 소프트웨어는 휴대성이 없게 되지만, 소프트웨어가 특정 런타임 외부에서 사용되지 않아야 한다면 휴대성은 문제가 되지 않을 수 있다.

미정의 동작은 프로그램 충돌을 일으킬 수 있으며, 데이터의 침묵 손실 및 잘못된 결과 생성과 같이 감지하기 더 어렵고 프로그램이 정상적으로 작동하는 것처럼 보이게 하는 오류를 발생시킬 수도 있다.

 오류 프로그램 
프로그래밍 언어 설계에서 오류 프로그램은 의미론이 잘 정의되지 않았지만, 언어 구현이 컴파일 시간 또는 실행 시간 모두에서 오류를 알릴 의무가 없는 프로그램이다. 예를 들어 에이다에서는 다음과 같다.

바운드 오류 외에도 언어 규칙은 특정 종류의 오류가 오류 실행으로 이어진다고 정의한다. 바운드 오류와 마찬가지로, 구현은 런타임 이전 또는 도중에 그러한 오류를 감지할 필요가 없다. 바운드 오류와 달리, 오류 실행의 가능한 효과에 대한 언어 지정 바운드는 없으며, 그 효과는 일반적으로 예측할 수 없다.

조건을 "오류"로 정의하는 것은 언어 구현이 잠재적으로 비용이 많이 드는 검사(예: 전역 변수가 서브루틴 매개변수와 동일한 객체를 참조하는지 여부)를 수행할 필요는 없지만, 프로그램의 의미론을 정의할 때 조건이 참이라는 것에 의존할 수 있음을 의미한다.

 장점 
작업을 미정의 동작으로 문서화하면 컴파일러가 이 작업이 규칙을 준수하는 프로그램에서는 결코 발생하지 않을 것이라고 가정할 수 있다. 이는 컴파일러에 코드에 대한 더 많은 정보를 제공하며, 이 정보는 더 많은 최적화 기회로 이어질 수 있다.

C 언어의 예시:

int foo(unsigned char x) {
 int value = 2147483600; /* assuming 32-bit int and 8-bit char */
 value += x;
 if (value < 2147483600) {
 bar();
 }
 return value;
}

x의 값은 음수가 될 수 없으며, 부호 있는 정수 오버플로가 C에서 미정의 동작이라는 점을 고려할 때, 컴파일러는 value < 2147483600이 항상 거짓일 것이라고 가정할 수 있다. 따라서 if 문의 테스트 표현식에 부작용이 없고 조건이 결코 충족되지 않으므로, 함수 bar 호출을 포함한 if 문은 컴파일러에 의해 무시될 수 있다. 따라서 코드는 의미론적으로 다음과 동일하다.

int foo(unsigned char x) {
 int value = 2147483600;
 value += x;
 return value;
}

만약 컴파일러가 부호 있는 정수 오버플로가 래핑 동작을 가진다고 가정해야 했다면, 위의 변환은 유효하지 않았을 것이다.

코드가 더 복잡하고 인라이닝과 같은 다른 최적화가 이루어질 때, 이러한 최적화는 사람이 알아차리기 어려워진다. 예를 들어, 다른 함수가 위 함수를 호출할 수 있다.

void run_tasks(unsigned char* ptrx) {
 int z;
 z = foo(*ptrx);
 while (*ptrx > 60) {
 run_one_task(ptrx, z);
 }
}

컴파일러는 값 범위 분석을 적용하여 여기에서 while 루프를 최적화할 수 있다. foo()를 검사하여 ptrx가 가리키는 초기 값이 47을 초과할 수 없다는 것을 안다(47을 초과하면 foo()에서 미정의 동작을 유발할 수 있기 때문이다). 따라서 *ptrx > 60의 초기 검사는 규칙을 준수하는 프로그램에서 항상 거짓이 될 것이다. 더 나아가, z 결과가 이제 사용되지 않고 foo()에 부작용이 없으므로, 컴파일러는 run_tasks()를 즉시 반환하는 빈 함수로 최적화할 수 있다. foo()가 별도로 컴파일된 객체 파일에 정의되어 있다면 while 루프의 사라짐은 특히 놀라울 수 있다.

부호 있는 정수 오버플로를 미정의로 허용하는 또 다른 이점은 변수의 값을 소스 코드의 변수 크기보다 큰 프로세서 레지스터에 저장하고 조작할 수 있다는 것이다. 예를 들어, 소스 코드에 지정된 변수의 타입이 기본 레지스터 너비보다 좁은 경우(예: 64비트 머신에서 int, 일반적인 시나리오), 컴파일러는 코드의 정의된 동작을 변경하지 않고 생성하는 기계어에서 해당 변수에 대해 부호 있는 64비트 정수를 안전하게 사용할 수 있다. 프로그램이 32비트 정수 오버플로 동작에 의존했다면, 컴파일러는 64비트 머신용으로 컴파일할 때 추가 로직을 삽입해야 했을 것이다. 왜냐하면 대부분의 기계 명령어의 오버플로 동작은 레지스터 너비에 따라 달라지기 때문이다.

미정의 동작은 또한 컴파일러와 정적 프로그램 분석 모두에서 더 많은 컴파일 시간 검사를 허용한다.

 위험 
C 및 C++ 표준에는 미정의 동작의 여러 형태가 있으며, 이는 컴파일러 구현의 자유도를 높이고 컴파일 시간 검사를 가능하게 하지만, 존재할 경우 미정의 런타임 동작을 초래한다. 특히 ISO C 표준에는 미정의 동작의 일반적인 원인을 나열한 부록이 있다. 더욱이 컴파일러는 미정의 동작에 의존하는 코드를 진단할 의무가 없다. 따라서 프로그래머, 심지어 숙련된 프로그래머조차도 실수로 또는 단순히 수백 페이지에 달하는 언어 규칙에 익숙하지 않아 미정의 동작에 의존하는 경우가 흔하다. 이로 인해 다른 컴파일러 또는 다른 설정이 사용될 때 드러나는 버그가 발생할 수 있다. 클랭 새니타이저와 같은 동적 미정의 동작 검사를 활성화하여 테스트 또는 퍼징을 수행하면 컴파일러 또는 정적 분석기가 진단하지 못하는 미정의 동작을 포착하는 데 도움이 될 수 있다.

미정의 동작은 소프트웨어의 보안 취약점으로 이어질 수 있다. 예를 들어, 주요 웹 브라우저의 버퍼 오버플로 및 기타 보안 취약점은 미정의 동작으로 인한 것이다. 2008년 GCC 개발자들이 미정의 동작에 의존하는 특정 오버플로 검사를 생략하도록 컴파일러를 변경했을 때, CERT는 최신 버전의 컴파일러에 대해 경고를 발행했다. Linux Weekly News는 PathScale C, 마이크로소프트 비주얼 C++ 2005 및 여러 다른 컴파일러에서도 동일한 동작이 관찰되었다고 지적했다. 이 경고는 나중에 다양한 컴파일러에 대해 경고하도록 수정되었다.

 C 및 C++의 예시 
C에서 미정의 동작의 주요 형태는 크게 다음과 같이 분류할 수 있다. 공간 메모리 안전 위반, 시간 메모리 안전 위반, 정수 오버플로, 엄격한 별칭 위반, 정렬 위반, 순서 없는 수정, 데이터 경합, 그리고 I/O를 수행하지도 종료하지도 않는 루프.

C에서 자동 변수를 초기화하기 전에 사용하면 미정의 동작이 발생하며, 정수 0으로 나누기, 부호 있는 정수 오버플로, 정의된 범위 밖의 배열 인덱싱(버퍼 오버플로 참조) 또는 널 포인터 역참조도 마찬가지이다. 일반적으로 미정의 동작의 모든 인스턴스는 추상 실행 기계를 알 수 없는 상태로 남겨두고 전체 프로그램의 동작을 미정의 상태로 만든다.

문자열 리터럴이 일반적으로 읽기 전용 메모리에 저장되기 때문에, 이를 수정하려고 시도하면 미정의 동작이 발생한다.ISO/IEC (2003). ISO/IEC 14882:2003(E): Programming Languages – C++ §2.13.4 String literals [lex.string] para. 2

char* p = "wikipedia"; // valid C, deprecated in C++98/C++03, ill-formed as of C++11
p[0] = 'W'; // undefined behavior

정수 0으로 나누기는 미정의 동작을 발생시킨다.ISO/IEC (2003). ISO/IEC 14882:2003(E): Programming Languages – C++ §5.6 Multiplicative operators [expr.mul] para. 4

int x = 1;
return x / 0; // undefined behavior

특정 포인터 연산은 미정의 동작을 초래할 수 있다.ISO/IEC (2003). ISO/IEC 14882:2003(E): Programming Languages - C++ §5.7 Additive operators [expr.add] para. 5

int arr[4] = {0, 1, 2, 3};
int* p = arr + 5; // undefined behavior for indexing out of bounds
p = nullptr;
int a = *p; // undefined behavior for dereferencing a null pointer

C 및 C++에서 객체에 대한 포인터의 관계 비교(보다 작음 또는 보다 큼 비교)는 포인터가 동일한 객체의 멤버 또는 동일한 배열의 요소를 가리키는 경우에만 엄격하게 정의된다.ISO/IEC (2003). ISO/IEC 14882:2003(E): Programming Languages – C++ §5.9 Relational operators [expr.rel] para. 2 예시:

int main(void) {
 int a = 0;
 int b = 0;
 return &a < &b // undefined behavior
}

반환 문 없이 값을 반환하는 함수(main() 제외)의 끝에 도달하면 호출자가 함수 호출의 값을 사용하는 경우 미정의 동작이 발생한다.ISO/IEC (2007). ISO/IEC 9899:2007(E): Programming Languages – C §6.9 External definitions para. 1

int f() {}
// undefined behavior if the value of the function call is used

두 sequence point 사이에서 객체를 두 번 이상 수정하면 미정의 동작이 발생한다. C++11부터 시퀀스 포인트와 관련하여 미정의 동작을 유발하는 원인에 상당한 변화가 있다. 최신 컴파일러는 동일한 객체에 대한 여러 순서 없는 수정이 발생할 때 경고를 내보낼 수 있다. 다음 예시는 C와 C++ 모두에서 미정의 동작을 유발한다.

int f(int i) {
 // undefined behavior: two unsequenced modifications to i
 return i++ + i++;
}

두 시퀀스 포인트 사이에서 객체를 수정할 때, 저장될 값을 결정하는 것 외의 다른 목적으로 객체 값을 읽는 것도 미정의 동작이다.ISO/IEC (1999). ISO/IEC 9899:1999(E): Programming Languages – C §6.5 Expressions para. 2

a[i] = i++; // undefined behavior
printf("%d %d\n", ++n, pow(2, n)); // also undefined behavior

C/C++에서 비트 시프트를 음수이거나 해당 값의 전체 비트 수보다 크거나 같은 비트 수로 수행하면 미정의 동작이 발생한다. 가장 안전한 방법(컴파일러 공급업체와 관계없이)은 항상 시프트할 비트 수(비트 연산자 << 및 >>의 오른쪽 피연산자)를 다음 범위 내에 유지하는 것이다: [, ] (여기서 value는 왼쪽 피연산자이다).

int num = -1;
unsigned int val = 1 << num; // shifting by a negative number - undefined behavior

num = 32; // or whatever number greater than 31
val = 1 << num; // the literal '1' is typed as a 32-bit integer - in this case shifting by more than 31 bits is undefined behavior

num = 64; // or whatever number greater than 63
unsigned long long val2 = 1ULL << num; // the literal '1ULL' is typed as a 64-bit integer - in this case shifting by more than 63 bits is undefined behavior

 러스트의 예시 
안전한 러스트에서는 미정의 동작이 발생하지 않을 것으로 예상되지만, 부적절하고 안전하지 않은 코드는 여전히 사운드니스 홀로 알려진 미정의 동작을 안전한 코드에 노출할 수 있다.

예를 들어, 러스트의 많은 데이터 타입은 유용한 최적화를 가능하게 하는 불변성을 사용한다.
참조는 한 가지 예시인데, 근본적으로 원시 포인터와 동일한 표현을 가지고 있지만, 널, 정렬되지 않음, 또는 다른 방식으로 유효하지 않은 대상을 가리킬 수 없다. 따라서 이러한 불변성을 위반하는 것은 결과 참조가 어떻게 사용되든 관계없이 미정의이다.

use core::mem::zeroed;

/// Constructs a null reference.
pub const fn null_ref
() -> &T {
 unsafe { zeroed() }
}

null_ref를 호출하는 것은 모든 참조 타입에 의해 부과되는 불변성 때문에 항상 잘못 형성되는데, 함수 자체가 unsafe fn 항목이 아니고 안전한 코드에서 호출될 수 있음에도 불구하고 그렇다.

또한, 어떤 널 포인터를 역참조하는 것도 미정의이지만, 많은 호스트 시스템은 여전히 세그멘테이션 오류에서 이러한 경우를 처리하도록 설계되어 있다.

use std::ptr::null;

fn main() {
 let p: *const i32 = null();

 // SAFETY: `p` is null and may not be dereferenced.
 unsafe { *p };
}

 같이 보기 
 컴파일러
 Halt and Catch Fire
 미지정 동작

 각주 

 더 읽어보기 
 피터 반 데르 린덴, Expert C Programming. 
 UB Canaries (April 2015), 존 레거 (유타 대학교, 미국)
 Undefined Behavior in 2017 (July 2017) 파스칼 쿠오 (TrustInSoft, 프랑스) 및 존 레거 (유타 대학교, 미국)

 외부 링크 
 C99 표준의 수정된 버전. #pragma에 대한 6.10.6절 참조.

분류:C++
분류:프로그래밍 언어 구현
분류:C 프로그래밍 언어