C언어/C언어 심화 & 특수

[C99] VLA(Variable Length Array; 가변 길이 배열)에 관한 깊은 고찰

카루-R 2023. 10. 31. 20:17
반응형

환영합니다, Rolling Ress의 카루입니다.

C언어를 배우다보면 흔히 이런 말을 한 번쯤은 들어 보셨을 겁니다.

배열을 선언할 때, 크기를 정하는 인덱스에는 변수를 사용할 수 없다.

int len = 5;
int array[len];

쉽게 말해서 위와 같은 코드가 불가능하다는 뜻이죠. 일반적으로 상수값만 가능하다고 배우니까. 그런데 이 말은 틀렸습니다. C99부터 VLA라는 문법을 지원하기 때문이에요. C11에서 선택구현으로 바뀌어서 이게 사라졌다고 아시는 분들도 있는데, C23에서 다시 부활했습니다. 아마 C언어는 이 문법을 계속 가지고 갈 것 같아요.

* 참고로 이 코드는 gcc에서 C99 이상의 문법으로 컴파일했을 때만 작동합니다. MSVC는 지원하지 않고, C++도 지원하지 않아요. (gcc의 경우 확장 기능으로 C++에서 VLA를 지원하지만, 표준은 아닙니다)

int n;
scanf("%d", &n);
int array[n];

누가봐도 malloc / calloc을 써서 동적할당해야 할 것 같은 코드이지만, 이것도 정상적으로 동작합니다. 위에서 말씀드렸지만 Visual Studio에서는 동작하지 않습니다. 이건 Visual Studio가 표준을 지키지 않아서 발생하는 일이에요. GCC 기준으로는 잘 됩니다.

문법 얘기는 여기서 그만 하겠습니다. 그냥 배열 선언할 때 크기에 변수를 넣으면 끝이니까요. 이 글에서는 이게 왜 되는지, 기존의 배열과는 어떤 점이 다른지를 짚어보고자 합니다.

우선 아래 사이트를 참고해볼까요. C/C++ 표준안을 읽기 쉽게 위키로 정리해둔 사이트입니다. 저도 중학교 때부터 애용하고 있던 사이트입니다.

 
 

Array declaration - cppreference.com

Array is a type consisting of a contiguously allocated nonempty sequence of objects with a particular element type. The number of those objects (the array size) never changes during the array lifetime. [edit] Syntax In the declaration grammar of an array d

en.cppreference.com

하단에 보시면 예제 코드가 있습니다. 처음에 이걸 봤을 때는 머리가 띵했습니다. 어떻게 저게 되지 싶으실 거예요. 이건 VLA의 동작 방식이 다른 변수들과 많이 달라서 그렇습니다. 일단 중요한 포인트 몇 가지 먼저 짚고 가겠습니다.

1. VLA의 할당 위치

  • VLA는 "주로" 스택 영역에 할당됩니다. GCC는 스택에 할당되지만, C 표준은 VLA의 할당 위치를 정하지 않았습니다. 컴파일러에 따라 힙 영역에 할당하고, 스택에 할당된 것 '처럼' (자동 free) 작동하게 할 수도 있습니다.

 

2. VLA의 라이프타임

  • VLA의 lifetime은 스택에 선언된 배열과 거의 같으나, 그 시작과 끝이 조금 다릅니다. 글로 표현하기에는 한계가 있어 아래에서 설명을 계속하도록 하죠. 1:1로 비교하자면 스택에 선언된 일반 배열보다 lifetime이 조금 더 짧습니다. 할당 매커니즘도 다르고요.
 

출력 결과를 봅시다. 변수의 주소와 값을 출력한 결과입니다. 변수는 크게 다음 세 가지가 있습니다.

  • 스택에 선언된 배열 stack_array
  • VLA 배열 vla_array
  • 힙에 선언된 배열 heap_array

이 외에 테스트용으로 스택에 선언된 두 변수 stack_variable, stack_variable2가 있습니다.

스택은 큰 주소에서 낮은 주소로 자라죠. 즉 메모리에는 변수가 다음과 같이 존재합니다.

0x5FFE34: stack_array[2]

0x5FFE30: stack_array[1]

0x5FFE2C: stack_array[0]

0x5FFE28: stack_variable

0x5FFE24: stack_variable2

참고로 변수들의 선언 순서는 다음과 같습니다.

int stack_array[3] = {1, 2, 3};
int stack_variable;
int vla_array[stack_variable];
int stack_variable2 = 0;

일치하죠. 어? 그런데 뭔가 이상합니다. vla_array가 없습니다. 혼자만 쏙 빠졌네요. 어디에 있나요?

0x5FFE18: vla_array[2]

0x5FFE14: vla_array[1]

0x5FFE10: vla_array[0]

한참 아래에 있네요. 뭔가 이상합니다. 혹시 스택이 아니라 힙에 할당된 건 아닐까요?

0x7C64D8: heap_array[2]

0x7C64D4: heap_array[1]

0x7C64D0: heap_array[0]

그건 아니네요. 힙은 저 멀찌감치 떨어져 있습니다. 이걸 통해서 VLA는 일단 스택에 할당됨을 확인할 수 있습니다. 그런데 일반적인 stack과는 다르죠. 약간 떨어져 있으니까요.

여기서 C99의 특징을 다시 한 번 봅시다. C언에에서 변수는 무조건 블럭의 최상단에 위치해야 하고, 일반문이 실행된 다음엔 선언문이 올 수 없다고 배우신 분들도 계실 거예요. 근데 그것도 C89/90 시절의 옛날 얘기입니다. C99부터 선언문 위치에 제약이 없어집니다. (애초에 제약이 있었다면 VLA도 절대 나오지 못했을 문법입니다.)

 

auto int a; // 기본
register int a; // 레지스터 변수
static int a; // 정적
extern int a; // 어딘가에

일반적으로 변수를 선언할 때, 앞에 아무것도 붙이지 않으면 auto로 선언됩니다. (C++의 auto와는 다릅니다. 그건 타입 추론이에요) auto 변수는 자동으로 할당되고, 자동으로 해제됩니다. 프로그램이 특정 블럭에 진입할 때, 그 블럭 안에 있는 지역변수를 한꺼번에 stack에 생성합니다. 그렇기 때문에 선언문 위치가 상관이 없는 겁니다.

그런데 VLA는 달라요. 블럭에 진입하자마자 생성되는 게 아니라, VLA 선언문이 실행되고 나서야 메모리 할당이 이루어집니다. 이 때문에 다른 stack 지역변수들과 분리된 공간에서 메모리가 할당된다고 볼 수 있겠죠.

printf("####### 2. Scope를 벗어난 후 stack, vla, heap array 비교 #######\n\n");
    int *ptr; 
    goto FIRST_STACK;
    {
SECOND_STACK:
        printf("[GOTO] stack_array: %p\n", ptr);
        printf("[GOTO] stack_array[0]: %d\n", *ptr); // 정상 작동 (메모리 해제 전)
    goto EXIT_STACK;
FIRST_STACK:
        int stack_array[3] = {1, 2, 3};
        ptr = stack_array;
        goto SECOND_STACK;
    } // block scope를 벗어나면서 자동 해제
EXIT_STACK:
    printf("Address of stack_array: %p\n", ptr);
    printf("Value of stack_array[0]: %d\n\n", *ptr); // 메모리가 해제되었으나, 우연히 정상작동

    int size = 3;
    goto FIRST_VLA;
    {
SECOND_VLA: // goto 문을 이용해 선언 위로 올라오기만 해도 메모리 해제
        printf("[GOTO] vla_array: %p\n", ptr);
        printf("[GOTO] vla_array[0]: %d\n", *ptr); // ptr is dangling pointer
        goto EXIT_VLA;
FIRST_VLA:
        int vla_array[size];
        for (int i = 0; i < 3; i++) vla_array[i] = i+1;
        ptr = vla_array;
        goto SECOND_VLA;
    } // 마찬가지로 auto 변수이기에, scope를 벗어나면서 자동 해제
EXIT_VLA:
    printf("Address of vla_array: %p\n", ptr);
    printf("Value of vla_array[0]: %d\n\n", *ptr);

    {
        int *heap_array = (int *)calloc(3, sizeof(int));
        for (int i = 0; i < 3; i++) heap_array[i] = i+1;
        ptr = heap_array;
    }
    printf("Address of heap_array: %p\n", ptr);
    printf("Value of heap_array[0]: %d\n\n", *ptr);
    free(ptr);

두번째는 결과가 너무 난해해서 코드를 들고 왔습니다. 아까는 VLA가 언제 할당되는지 알아봤다면, 이번에는 언제 해제되는지 보겠습니다. 표준에서는 "블럭을 나갈 때가 아닌, 선언이 스코프를 벗어날 때" 해제가 된다고 합니다. 사실 그 말이 그 말 같죠. 어쨌든 블럭을 나가면 스코프를 벗어나는 꼴이 되니까요. goto를 쓴다면 얘기가 다릅니다. 아까 auto 변수는 블럭을 들어갈 때 할당된다고 했죠? 그러면 일단 블럭을 진입한 순간, 그 블럭 안에서 위로 가든 아래로 가든 선언된 변수는 그대로 있습니다.

VLA의 경우, 블럭 내에서 VLA가 선언된 문장 위로 올라가면 메모리가 해제되어 버립니다. 그래서 GOTO문으로 올라가기만 했는데 쓰레기 값이 출력된 걸 보셨죠. 똑같이 auto 변수지만 VLA는 추가적인 예외가 붙습니다. 할당과 해제, 그 둘이 가장 큰 차이예요.

그 외에 자잘한 차이점을 말씀드리자면 sizeof 연산자로 요소의 개수를 셀 수 있습니다. VLA는 어쨌든 배열이고, malloc으로 할당한 힙 배열은 포인터로 사용하기 때문에 차이가 발생합니다. 힙 배열을 가리키는 포인터에 아무리 sizeof 써봤자 4바이트(8바이트)만 뜨죠.

근데 VLA를 적극적으로 쓰는 코드를 거의 못 봤습니다. 일단 어디까지나 스택을 쓰는 거기 때문에, 힙보다 속도는 빠를지언정 스택 오버플로우의 위험이 따라요. 조그만 변수들 몇 개 정도라면 모르겠습니다만. 그리고 MSVC에서는 지원을 아예 안 하고 있고... 역시 이런 기능이 있다는 것만 알고, 필요에 따라 사용하는 게 최선일 듯 합니다.

반응형