미정의 동작(Undefined behavior)

미정의 동작(Undefined behavior)

C 프로그램의 관찰 가능한 동작은 표준이 정확히 규정하지만, 예외가 있어요. 그중 가장 유명한 것이 **미정의 동작(undefined behavior, UB)**이에요. 프로그램이 UB를 일으키면 그 이후 어떤 일이 일어나도 표준 위반이 아니에요. 값을 음수로 오버플로우하거나 배열 밖을 읽는 코드가 어떻게 컴파일러에 의해 최적화돼 버리는지 예제로 살펴볼게요.

출처: cppreference

본문

C 언어 표준은 C 프로그램의 관찰 가능한 동작을 정확히 규정하지만, 다음 범주에 속하는 것들은 예외예요.

  • 미정의 동작(undefined behavior) — 프로그램 동작에 아무 제약이 없어요. 예로는 배열 범위 밖의 메모리 접근, 부호 있는 정수 오버플로우, 널 포인터 역참조, 시퀀스 포인트 없이 한 표현식 안에서 같은 스칼라를 두 번 이상 수정하는 것, 다른 타입의 포인터를 통한 객체 접근 등이 있어요. 컴파일러는 미정의 동작을 진단할 필요가 없고(많은 단순한 상황은 진단되지만), 컴파일된 프로그램은 의미 있는 어떤 일도 할 필요가 없어요.
  • 불특정 동작(unspecified behavior) — 두 개 이상의 동작이 허용되고, 구현이 각 동작의 효과를 문서화할 필요가 없어요. 예로는 평가 순서, 동일한 문자열 리터럴이 서로 구별되는지 여부 등이 있어요. 각 불특정 동작은 유효한 결과 집합 중 하나를 만들고, 같은 프로그램에서 반복되면 다른 결과를 만들어 낼 수 있어요.
  • 구현 정의 동작(implementation-defined behavior) — 각 구현이 선택이 어떻게 이뤄지는지 문서화하는 불특정 동작이에요. 예로는 바이트의 비트 수, 부호 있는 정수 오른쪽 시프트가 산술인지 논리인지 여부가 있어요.
  • 로케일 종속 동작(locale-specific behavior) — 현재 선택된 로케일에 의존하는 구현 정의 동작이에요. 예로는 islower가 26개의 소문자 라틴 문자 외의 어떤 문자에 대해 참을 반환하는지 여부가 있어요.

(참고: 엄격히 적합한(strictly conforming) 프로그램은 어떤 불특정·미정의·구현 정의 동작에도 의존하지 않아요.)

컴파일러는 어떤 C 구문 규칙이나 의미론적 제약(semantic constraint)을 위반하는 모든 프로그램에 대해 진단 메시지(오류 또는 경고)를 내야 해요. 그 행동이 미정의나 구현 정의로 규정됐거나, 컴파일러가 그런 프로그램을 받아들이는 언어 확장을 제공하더라도 마찬가지예요. 미정의 동작에 대한 진단은 그 외에는 요구되지 않아요.

UB와 최적화

올바른 C 프로그램은 미정의 동작이 없기 때문에, 실제로 UB가 있는 프로그램을 최적화를 켜고 컴파일하면 컴파일러가 예상 밖의 결과를 만들어 낼 수 있어요.

부호 있는 오버플로우(Signed overflow)

int foo(int x)
{
    return x + 1 > x; // either true or UB due to signed overflow
}

이 코드는 다음과 같이 컴파일될 수 있어요.

foo:
        mov     eax, 1
        ret

부호 있는 정수 오버플로우는 미정의 동작이므로, 컴파일러는 x + 1 > x가 "항상 참"이라고 판단해 상수 1만 반환하게 최적화할 수 있어요.

범위 밖 접근(Access out of bounds)

int table[4] = {0};
int exists_in_table(int v)
{
    // return 1 in one of the first 4 iterations or UB due to out-of-bounds access
    for (int i = 0; i <= 4; i++)
        if (table[i] == v)
            return 1;
    return 0;
}

다음과 같이 컴파일될 수 있어요.

exists_in_table:
        mov     eax, 1
        ret

table은 원소가 4개인데 루프는 i를 4까지 돌려요. 마지막 반복에서 범위 밖에 접근하는 것이 UB지만, 컴파일러는 그 UB가 "일어나지 않는다"고 가정하고 함수가 항상 1을 반환한다고 최적화해요.

초기화되지 않은 스칼라(Uninitialized scalar)

_Bool p; // uninitialized local variable
if (p) // UB access to uninitialized scalar
    puts("p is true");
if (!p) // UB access to uninitialized scalar
    puts("p is false");

다음 출력이 나올 수 있어요 (오래된 버전의 gcc에서 관찰됨):

p is true
p is false
size_t f(int x)
{
    size_t a;
    if (x) // either x nonzero or UB
        a = 42;
    return a;
}

다음과 같이 컴파일될 수 있어요.

f:
        mov     eax, 42
        ret

초기화되지 않은 변수 a를 읽는 것은 UB예요. 컴파일러는 x가 참이면 a가 42가 되고, 거짓이면 UB(그런 경우는 없다는 가정)라고 판단해 a를 항상 42로 다시 써 버릴 수 있어요.

유효하지 않은 스칼라(Invalid scalar)

int f(void)
{
    _Bool b = 0;
    unsigned char* p = (unsigned char*)&b;
    *p = 10;
    // reading from b is now UB
    return b == 0;
}

다음과 같이 컴파일될 수 있어요.

f:
        mov     eax, 11
        ret

_Bool의 표현에 저장할 수 없는 값(10)을 바이트로 써 넣었으니 b를 읽는 게 UB가 되고, 컴파일러가 그 UB를 임의의 값(여기서는 11)으로 바꿔 버릴 수 있어요.

널 포인터 역참조(Null pointer dereference)

int foo(int* p)
{
    int x = *p;
    if (!p)
        return x; // Either UB above or this branch is never taken
    else
        return 0;
}

int bar()
{
    int* p = NULL;
    return *p;    // Unconditional UB
}

다음과 같이 컴파일될 수 있어요.

foo:
        xor     eax, eax
        ret
bar:
        ret

foo*p는 처음부터 p가 널이 아니라고 가정하므로, 널인 경우는 일어나지 않는다고 보고 0을 반환하게 돼요. bar는 무조건 널 포인터를 역참조하는 UB라서, 빈 본문처럼 최적화돼요.

realloc에 넘긴 포인터로의 접근

아래 출력을 보려면 clang을 선택하세요.

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int *p = (int*)malloc(sizeof(int));
    int *q = (int*)realloc(p, sizeof(int));
    *p = 1; // UB access to a pointer that was passed to realloc
    *q = 2;
    if (p == q) // UB access to a pointer that was passed to realloc
        printf("%d%d\n", *p, *q);
}

가능한 출력:

12

realloc은 메모리를 옮길 수 있고 원래 포인터 p를 무효화할 수 있어요. 그 후 p로 접근하는 것은 UB예요.

부수 효과 없는 무한 루프

아래 출력을 보려면 clang을 선택하세요.

#include <stdio.h>

int fermat()
{
    const int MAX = 1000;
    // Endless loop with no side effects is UB
    for (int a = 1, b = 1, c = 1; 1;)
    {
        if (((a * a * a) == ((b * b * b) + (c * c * c))))
            return 1;
        ++a;
        if (a > MAX)
        {
            a = 1;
            ++b;
        }
        if (b > MAX)
        {
            b = 1;
            ++c;
        }
        if (c > MAX)
            c = 1;
    }
    return 0;
}

int main(void)
{
    if (fermat())
        puts("Fermat's Last Theorem has been disproved.");
    else
        puts("Fermat's Last Theorem has not been disproved.");
}

가능한 출력:

Fermat's Last Theorem has been disproved.

부수 효과 없이 끝나지 않는 루프는 UB예요. 컴파일러는 종료 조건을 만족시킬 어떤 수열도 (표준적으로) "얼른" 찾아낸 것처럼 행동해서, 페르마의 마지막 정리가 반증됐다는 메시지를 출력하도록 최적화할 수 있어요.

더 알아보기

  • 미정의 동작(undefined behavior) 관련 심화 글: "What Every C Programmer Should Know About Undefined Behavior #1/3, #2/3, #3/3", "Undefined behavior can result in time travel", "Understanding Integer Overflow in C/C++", "Undefined Behavior and Fermat's Last Theorem", "Fun with NULL pointers, part 1" (보고서·블로그 목록).