
1. C언어의 메모리 구조
프로그램을 실행하면 해당 프로그램의 실행을 위한 메모리 공간이 운영체제에 의해서 미리 마련이 된다.
그리고 바로 이 메모리 공간 내에서 변수가 선언되고, 문자열이 선언되는 것이다.
프로그램 실행 시 운영체제에 의해서 마련되는 메모리의 구조는 다음과 같이 네 개의 영역으로 구분이 된다.

코드 영역: 코드 영역은 이름 그대로 실행할 프로그램의 코드가 저장되는 메모리 공간이다. 따라서 CPU는 코드 영역에 저장된 명령문들을 하나씩 가져가서 실행을 한다.
데이터 영역: 데이터 영역에는 전역변수와 static으로 선언되는 static 변수가 할당된다. 즉, 이 영역에 할당되는 변수들은 프로그램의 시작과 동시에 메모리 공간에 할당되어 프로그램 종료 시까지 남아있게 된다는 특징이 있다.
스택 영역: 스택 영역에는 지역변수와 매개변수가 할당된다. 이렇듯 이 영역에 할당되는 변수들은 선언된 함수를 빠져나가면 소멸된다는 특징이 있다.
힙 영역: 데이터 영역에 할당되는 변수와 스택 영역에 할당되는 변수들은 생성과 소멸의 시점이 이미 결정되어 있다. 그러나 프로그램을 구현하다 보면, 이 두 영역의 변수들과는 다른 성격의 변수가 필요하기도 하다. 그래서 C언어에서는 프로그래머가 원하는 시점에 변수를 할당하고 또 소멸하도록 지원을 하는데, 바로 이러한 유형의 변수들이 할당되는 영역이 힙 영역이다. 이 힙 영역을 대상으로 하는 변수의 할당과 소멸에 대해서는 잠시 후에 별도로 설명을 하겠다.
2. 메모리의 동적 할당
언뜻 생각해보면 전역변수와 지역변수만 있으면 충분하다는 생각이 든다.
하지만 프로그램을 구현하다 보면 이 둘이 아닌 다른 유형의 변수를 필요로 하게 된다.
다음 예제에서는 프로그램 사용자로부터 입력 받은 문자열의 정보를 반환하는 함수가 정의되어 있다.
우선 이 함수의 문제점을 지적해보기 바란다.
#include <stdio.h>
char * ReadUserName(void)
{
char name[30];
gets(name);
return name;
}
int main(void)
{
char * name1;
char * name2;
name1=ReadUserName();
printf("%s", name1);
name2=ReadUserName();
printf("%s", name2);
return 0;
}
위 예제의 문제점은 무엇인가?
그것은 함수 내에 지역적으로 선언된 배열(변수)의 주소 값을 반환하는데 있다.
함수 내에서 프로그램 사용자로부터 문자열을 입력 받아서 그 결과를 반환하는 것은 좋다.
문제는 그 문자열이 저장되어 있는 배열이 지역적으로 선언되었기 때문에 함수를 빠져나오면서 소멸된다는데 있다.
그래서 실제로 실행을 해보면 정상적이지 못한 결과로 이어지는 것을 확인할 수 있다.
간혹 정상적인 결과를 보일 수도 있지만, 이는 우연이며, 결국에 가서는 문제를 일으키고 만다.
그렇다면 이 문제를 전역변수를 이용해서 해결해 보겠는가?
하지만 다음 예제에서 보이듯이 이 역시 답이 될 수 없다.
#include <stdio.h>
char name[30];
char * ReadUserName(void)
{
gets(name);
return name;
}
int main(void)
{
char * name1;
char * name2;
name1=ReadUserName();
printf("%s\n", name1);
name2=ReadUserName();
printf("%s\n", name2);
printf("%s\n", name1); // name2를 입력받을 때 덮어씌어짐
return 0;
}
위의 출력에서 보이듯이 하나의 전역변수를 이용하면, 이 전역변수를 덮어쓰게 되기 때문에, 함수호출을 통해서 얻게 된 이름정보가 유지되지 않는다.
즉, 프로그램 사용자에게서 이름정보를 입력 받아서 이를 반환하는 함수를 정의하기에는 지역변수도 전역변수도 답이 될 수 없다.
그렇다면 어떠한 성격의 변수가 필요한 것일까?
"함수가 매번 호출될 때마다 새롭게 할당되고, 함수를 빠져나가도 유지가 되는 유형의 변수"
다시 말해서, 지역변수와 같이 함수가 호출될 때마다 매번 할당이 이뤄지지만, 할당이 되면 전역변수와 마찬가지로 함수를 빠져나가도 소멸되지 않는 성격의 변수가 필요하다.
그런데 다행스럽게도 이렇듯 생성과 소멸의 시기가 지역변수나 전역변수와 다른 유형의 변수는 malloc과 free라는 이름의 함수를 통해서 힙 영역에 할당하고 소멸할 수 있다.
#include <stdlib.h>
void * malloc(size_t size);
void free(void * ptr);
힙 영역을 흔히 '프로그래머가 관리하는 메모리 공간'이라고 한다.
이유는 malloc 함수호출로 할당된 메모리 공간은 프로그래머가 직접 free 함수의 호출을 통해서 해체하지 않으면 계속 남아있기 때문이다.
즉 위의 두 함수는 다음과 같이 쌍을 이루어 호출하게 된다.
void * ptr = malloc(4);
free(ptr);
이렇듯 malloc 함수는 인자로 전달된 정수 값에 해당하는 바이트 크기의 메모리 공간을 힙 영역에 할당하고, 이 메모리 공간의 주소 값을 반환한다.
malloc 함수와 free 함수의 호출위치 및 시점에는 제한이 없다.
따라서 원하는 시점에 할당하고 원하는 시점에 소멸이 가능하다.
"힙에 할당된 메모리 공간은 포인터 변수를 이용해서 접근하는 방법밖에 없나요?"
좋은 질문이다.
malloc 함수는 주소 값을 반환한다.
그리고 그 주소값을 이용해서 힙에 접근을 해야 한다.
따라서 포인터를 이용해서 메모리 공간에 접근하는 수밖에 없다.
malloc 함수의 반환형은 void 포인터이다.
따라서 malloc 함수의 반환 값에 아무런 가공도 하지 않으면, 이를 이용해서는 할당된 메모리 공간에 접근이 불가능하다.
그렇다면 malloc 함수의 반환형이 void형 포인터인 이유가 무엇일까?
malloc 함수 자체가 포인터 형을 결정할 수 없다.
malloc 함수에 전달되는 것은 숫자뿐이다.
이 숫자를 가지고 int형 변수로 사용할지 float형 변수로 사용할지, 아니면 길이가 4인 char형 배열로 사용할지 모르기 때문에 void형 포인터로 반환한는 수밖에 없는 것이다.
따라서 다음과 같이 void형으로 반환되는 주소 값을 적절히 형 변환해서 할당된 메모리 공간에 접근해야 한다.
int * ptr1 = (int *)malloc(sizeof(int));
double * ptr2 = (double *)malloc(sizeof(double));
int * ptr3 = (int *)malloc(sizeof(int)*7));
double * ptr4 = (double *)malloc(sizeof(double)*9);
그럼 지금까지 설명한 내용을 바탕으로 힙 영역에 int 형 변수와 int 형 배열을 각각 하나씩 선언해서 접근하고 또 해재해 보겠다.
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int* ptr1 = (int*)malloc(sizeof(int));
int* ptr2 = (int*)malloc(sizeof(int)*7);
int i;
*ptr1 = 20;
for(i=0; i<7; i++)
ptr2[i]=i+1;
printf("%d \n", *ptr1); // 20
for(i=0; i<7; i++)
printf("%d ", ptr2[i]); // 1 2 3 4 5 6 7
free(ptr1);
free(ptr2);
return 0;
}
참고로 malloc 함수는 메모리 공간의 할당에 실패할 경우 NULL을 반환한다.
따라서 메모리 할당의 성공여부를 확인하고자 한다면 다음과 같이 코드를 작성해야 한다.
int * ptr = (int *)malloc(sizeof(int));
if(ptr==NULL)
{
...
}
그리고 malloc 함수의 호출을 통한 메모리 공간의 할당을 가리켜 '동적 할당(dynamic allocation)'이라 한다.
이유는 할당되는 메모리의 크기를 컴파일러가 결정하지 않고, 프로그램의 실행 중간에 호출되는 malloc 함수가 결정하기 때문이다.
이제 malloc 함수와 free 함수의 사용방법도 알았으니 앞서 보인 문자열 저장 문제를 해결해보자.
#include <stdio.h>
#include <stdlib.h>
char * ReadUserName(void)
{
char * name = (char*)malloc(sizeof(char)*30);
printf("이름: ");
gets(name);
return name;
}
int main(void)
{
char* name1;
char* name2;
name1=ReadUserName();
printf("%s \n", name1);
name2=ReadUserName();
printf("%s \n", name2);
printf("%s \n", name1);
printf("%s \n", name2);
free(name1);
free(name2);
return 0;
}
이렇듯 malloc 함수와 free 함수를 이용하면 메모리 공간의 할당과 소멸의 시점을 프로그래머가 직접 결정할 수 있다.
때문에 전역변수나 지역변수가 감당하지 못하는 일들을 감당할 수 있다.
힙 영역에 메모리 공간을 할당하는 함수로 추가로 calloc이라는 함수가 정의되어 있다.
아래의 함수와 malloc 함수의 유일한 차이점은 메모리 공간의 할당을 위한 인자의 전달방식에 있다.
#include <stdlib.h>
void * calloc(size_t elt_count, size_t elt_size);
위의 함수 원형에서 보여주듯이 malloc 함수와 달리 calloc 함수는 두 개의 숫자를 인자로 전달받는다.
반면 앞서 보인 malloc 함수의 전달인자는 하나였다.
즉 malloc 함수의 호출방식은 다음과 같았다.
"총 120 바이트를 힙 영역에 할당해 주세요."
반면 calloc 함수의 첫 번재 전달인자로는 할당할 블록의 갯수 정보가 전달되고, 두 번째 전달인자로는 블록 하나당 바이트 크기의 정보가 전달된다.
즉 calloc 함수의 호출방식은 다음과 같다.
"4바이트 크기의 블록(elt_size) 30개를(elt_count) 힙영역에 할당해 주세요."
120바이트를 할당해 달라는 것과, 4바이트 크기의 블록 30개를 할당해 달라는 것은 결과적으로 완전히 동일하다.
다시 말해서 calloc 함수는 malloc 함수와 인자를 전달하는 방식에서 차이를 보인다.
그런데 이것 말고도 한가지 차이점이 더 있다.
malloc 함수는 할당된 메모리 공간을 별도의 값으로 초기화 하지 않는다.
따라서 할당된 메모리 공간이 쓰레기 값으로 채워지지만, calloc 함수는 할당된 메모리 공간의 모든 비트를 0으로 초기화시킨다.
바로 이러한 특성 때문에 calloc 함수가 대신 사용되는 경우도 많다.
한번 할당된 메모리 공간은 그 크기를 확장할 수 없다.
이는 모든 영역의 메모리 공간에 해당하는 말이다.
이미 할당되어버린 배열의 길이를 늘릴 수 있는가?
어느 영역에 선언을 하건 간에 이러한 일은 불가능하다.
하지만 그 영역이 힙이라면, 그리고 realloc 함수를 사용한다면 이러한 일이 가능해진다.
#include <stdlib.h>
void * realloc(void * ptr, size_t size)
이 함수의 첫 번째 전달인자로, 확장하고자 하는 힙 메모리의 시작 주소 값을 전달한다.
그리고 두 번째 전달인자로는 확장하고자 하는 메모리의 전체 크기를 전달한다.
즉 realloc 함수는 다음을 의미한다.
"ptr이 가리키는 메모리의 크기를 size 크기로 조절해줘."
그리고 함수호출의 성공 시에는 새로 할당된 메모리의 주소 값이 반환되고, 실패 시에는 NULL이 반환된다.
즉 위의 함수는 다음의 형태로 호출이 된다.
int * arr = (int *)malloc(sizeof(int)*3);
arr = (int *)realloc(arr, sizeof(int)*5);
위 코드의 실행결과로 malloc 함수가 반환한 주소 값과 realloc 함수가 반환한 주소 값이 같은 경우와 같지 않은 경우가 있다.
전자는 기존에 할당된 메모리 공간의 뒤를 이어서, 확장할 영역이 넉넉한 경우에 발생한다.
하지만 넉넉하지 않은 경우에는 힙의 다른 위치에, 새로이 요구하는 크기의 메모리 공간을 별도로 할당해서 이전 배열에 저장된 값을 복사하기도 한다.
그리고 이러한 경우에는 후자의 경우와 같이 malloc 함수와 realloc 함수의 반환 주소 값이 같지 않다.