컴퓨터/프로그래밍

C언어 함수 내 배열은 어디에 생성될까?

2019. 11. 19. 05:31

C언어의 배열 생성 위치에 대한 오해

C언어에 대해 어느 정도 자신이 생겼는데도 확실히 알지 못한 것이 있었습니다. 함수 내부에 생성되는 배열 변수는 어디에 생성될까에 대한 호기심입니다. char, short, int 변수는 그 크기가 작아서 스택에 생성되는 것에 대해서 의심하지 않았지만, 배열은 매우 클 수 있는데, 이 배열도 과연 스택에 생성될까 하는 것이죠. 이 문제에 대해 시원하게 설명해 주는 책이 없었고 인터넷이 없던 시절이라서 주위에 C언어라면 방귀 좀 뀐다는 프로그래머에게 물어보면 사람마다 의견이 다르고 서로 언쟁을 올리기도 했습니다.

지금이야 메모리가 8GB·16GB로 매우 크지만, 당시에는 2MB, 돈 좀 쓰면 4MB라서 프로그래머는 메모리 운영에 매우 조심해야 했습니다. 잘 실행되는 프로그램이 며칠 지나면 멈추는 경우를 보면 주로 메모리 관리를 잘못했거나 스택 오버 플로우였죠.

메모리가 작으니 스택도 당연히 작을 텐데 다른 auto 변수처럼 배열도 스택에 생길 거라고는 생각하기 힘들었습니다.

그래서 배열 크기에 따라 작은 것은 스택에 생기고, 크면 malloc() 함수를 호출하듯 heap 메모리에 생성된다고 생각하는 분도 있었습니다. 그러나 만일 그렇다고 한다면 큰 배열은 사용해서는 안 됩니다. malloc() 함수는 메모리 확보 여부를 리턴 값으로 확인할 수 있지만, 배열 변수는 확인할 방법이 없기 때문입니다. 한마디로 러시안룰렛 게임이 되는 것이죠.

배열 변수가 heap 영역에 생긴다는 주장 이유

그런데도 함수 내부의 배열이 heap 메모리에 생긴다는 주장은 아마도 아래의 방법으로 확인했던 것 같습니다. -오래 전의 일이라 테스트 코드가 다를 수 있고, 컴파일러의 발전으로 예전과 다른 결과일 수 있지만, 올려 봅니다.-

void func_a( char val){

    char    buff_a4096];

    buff_a[0] = val;
    printf( "func(%c) buff_a addr=%p\n", buff_a[0] , buff_a);
}

void func_b( char val){

    char    buff_b[4096];

    buff[0] = val;
    func_a( 'a');
    printf( "func(%c) buff_b addr=%p\n", buff_b[0] , buff_b);
}

int   main  ( void )
{
    func_a( 'a');
    func_b( 'b');

    return 0;
}

▲ 자 실행하면 어떻게 될까요? func_a()를 바로 호출했을 때의 buff_a 배열의 주소가 func_b()를 거쳐서 func_a()를 호출했을 때의 buff_a 배열 주소가 다를까요? 다른 지역변수처럼 배열도 스택에 생성된다면 buff_a 배열 주소가 달라야 합니다.

$ ./app_test
func(a) buff_a addr=0x7ffdf8116f00
func(a) buff_a addr=0x7ffdf8116f00
func(b) buff_b addr=0x7ffdf8115f00
$

▲ 그러나 실행해 보면 둘 다 주소 값이 같습니다. 이상하지요? 배열이 스택에 생성된다면 func_a()의 buff_a 배열 주소의 시작 주소는 달라야 합니다.

main() 함수에서 func_a()를 호출하면 다시 돌아오는 복귀 포인터와 인수 val 변수 값을 스택에 저장하고 func_a()로 이동합니다. 그리고 스택에 buff_a 배열을 생성합니다.

main() 함수에서 func_b()를 호출하면 스택에 복귀 포인터와 val 값을 넣고 func_b()로 이동한 다음, 다시 스택에 복귀 포인터와 val 값을 추가한 후에 func_a()로 이동하고 스택에 buff_a 배열을 생성합니다. 직접 func_a()를 호출했을 때와 func_b()에서 한 단계 거친 후에 호출했을 때와는 스택 포인터가 다릅니다. 그럼 buff_a 배열의 생성 위치가 달라야 정상이 아닐까요? 더욱이 func_b()에서도 배열 변수 buff_b를 정의하고 사용하기까지 했습니다. 스택 포인터가 밀려도 한참 밀려야 하지요. 이상하지 않나요?

배열 생성 위치를 따지는 이유

배열 생성 위치를 따지는 이유는 당연히 메모리 관리 때문입니다. 요즘 메모리 용량이 매우 풍족해졌고 OS의 발달로 메모리 관리자 기능도 크게 발전했습니다. 그래서 메모리 생성 위치에 대해 그다지 신경을 쓰지 않는 코드를 자주 봅니다. malloc()으로 얻은 메모리를 정확히 free()해 주기만 하면 오케이인 것이죠.

malloc()으로 생성한 메모리를 정확히 free()로 반환해야 하는 것이 귀찮아서 큰 배열도 함수 내부에 선언해서 사용하기도 하는데요, 그렇다면 과연 배열은 어디에 생성될까요? heap 영역이면 그나마 안심이 되는데, 스택이면 불안하지 않을 수 없습니다.

위키책에 설명된 배열 변수

이런 궁금증에 대한 시원한 설명이 위키책에 올라와 있네요.

역시 배열 크기에 관계없이 스택 영역에 생성됩니다. 걱정했던 대로 함수 내에 큰 배열을 잡을 경우의 문제점도 자세히 설명되어 있습니다. 하드웨어 스펙이나 시스템 설정에 따라 스택 오버플로우를 발생할 수 있다는 얘기입니다. 당연한 것이죠. 그렇다면 배열의 크기가 작다 크다의 기준이 뭘까요? 그 기준이야 하드웨어 스펙이나 시스템 설정에 따라 다를 것입니다. 애매하지요. 배열의 크기에 기준을 잡는 것보다는 불안한 요소를 제거하는 쪽이 안전한 코드가 될 것입니다.

배열은 전역이나 static 사용

void func( int cnt){

    char    buff[4096];

    if ( 0 < cnt){
        printf( "%d %p\n", cnt, buff);
        func( cnt-1);
    }
}

int   main  ( void )
{
    func( 10);

    return 0;
}

▲ 이러한 이유는 배열 크기가 작더라도 문제가 발생할 수 있습니다. 너무 극단적인 예이지만, 재귀호출만으로도 배열 변수 사용이 조심스럽습니다. 배열이 스택에 생기든 heap 영역에 생기든 auto 변수로 함수를 호출할 때마다 생성되고 복귀할 때 제거되는 것이 반복된다면 차라리 프로그램이 실행할 때 미리 확보하는 것이 안전하지 않을까 하는 생각에 배열은 전역 변수로 선언합니다.

그러나 전역 변수는 편리한 만큼 관리를 잘못하면 보이지 않는 버그를 만들 수 있습니다. 프로그램의 흐름을 망칠 수도 있고요. 그래서 전역 변수를 과용하지 않는 것이 좋은데, 이런 생각에 배열을 내부 변수로 선언하되 static 키워드로 한 번 메모리에 확보하면 이후로 다시 확보해야 하는 부담을 줄입니다.

static이라도 지역변수라서 함수가 호출이 되어야 그때 메모리를 확보하지만, 자주 호출되는 함수라면 프로그램 실행과 함께 문제 여부를 빠르게 확인할 수 있습니다. 며칠 지나야 문제 점을 확인하는 경우가 없습니다.

함수 내부 변수가 안전하지만, 그렇다고 자주 호출되지 않는 함수까지 static으로 배열을 생성해서 메모리를 낭비하기는 아깝습니다. 이럴 때는 malloc()으로 메모리 확보를 확인하고 사용합니다.

void func( int cnt){

    static char buff[4096];

    if ( 0 < cnt){
        printf( "%d %p\n", cnt, buff);
        func( cnt-1);
    }
}

int   main( void )
{
    func( 10);

    return 0;
}

▲ 재귀호출이라도 실행할 때 문제가 없다면 즉, 메모리를 확보했다면 이후 실행에서 메모리 때문에 걱정할 필요가 없습니다.

$ ./app_test
10 0x6dcce0
9 0x6dcce0
8 0x6dcce0
7 0x6dcce0
6 0x6dcce0
5 0x6dcce0
4 0x6dcce0
3 0x6dcce0
2 0x6dcce0
1 0x6dcce0
$

▲ 너무 소심한 것이 아닌가 하는 말씀을 들을 수 있겠습니다만, 예전에 메모리가 부족하던 시절의 습관과 조심성이 사라지지 않네요. 불안해서요.

지역 배열 변수의 생성 위치를 아려 주는 책이나 알고 있는 분이 없어서 정확히 몰랐는데, 어셈블리 코드로 확인하는 방법이 있었네요. 아하~ 그때 이렇게 확인했으면 좋았을 것을. 시간이 많이 흘러도 프로그래밍은 계속 학습해야 하는 분야이네요. 아래 글은 변수 생성 위치를 어셈블리 코드로 직접 확인합니다. 멋지네요.