함수 내 동적할당 해제 - hamsu nae dongjeoghaldang haeje

함수에서의 메모리 할당과 해제

C에서의 메모리 할당/해제의 개념은 설명은 간단한데 실제 적용시에는 무척이나 어렵다. 물론 뉴비시절에 책 보고 따라 코드를 써볼 때에는 이게 그다지 와 닿지 않는다. 곰곰히 생각해보니 그런 책의 예제들은 그냥 숫자값만 다루다보니 그런 것 같다.
위 글에서 이어서 함수 하나를 만들어보자. 이 함수는 문자열 상수를 받아서 이를 대문자로 변경한 문자열을 만들어준다. 문자열 상수는 변경할 수 없으니, 새로운 문자열을 생성해야 한다. 기본적인 아이디어는 다음과 같이 구현된다.

char * cloneUpper(const char *origin){
    char *temp, *r;
    int l, i;
    temp = origin;
    l = strlen(origin);
    r = (char*)malloc(l+1);
    r[l] = 0;
    i = 0;
    while(*(temp+i)!=0)
    {
        *(r+i) = toupper(*temp+i);
        i++;
    }
    return r;
}

새로운 문자열을 위해 malloc을 사용하여 적당한 길이의 메모리 공간을 할당하고, 여기에 origin의 각 문자를 대문자로 만들어서 한글자씩 집어넣는 방식을 사용해 전체 문자열을 대문자화하여 넣어준다. 결국 원래 입력받은 문자열 상수를 모두 대문자로 변경한 문자열이 완성되고, 함수는 이 새로운 문자열의 포인터를 리턴한다.
문제는 malloc을 써서 메모리를 할당했기 때문에 해제를 해줘야 한다는 점이다. 그런데 return 문을 만나면 함수가 종료되므로 free 구문은 return 문 보다 먼저 나와야 하는데, 반환하기 전에 반환해야 할 값의 메모리를 해제해버리면 기껏 만들어둔 값이 망가져버리게 된다.
여기서 다시 C에서의 메모리 관리 원칙을 살펴보자.

  1. malloc(calloc 포함)과 free는 짝이 맞아야 한다.
  2. 메모리를 할당한 측에서 일차적으로 해제의 책임을 진다. 즉 함수내에서 할당했다면 해제의 책임도 함수가 진다.
  3. 2가 어렵다면 함수를 호출한 쪽에서 책임지고 free 해야 한다.

그리고 이게 너무 삶을 빡빡하게 만든다면 다음도 참고하는 게 좋겠다.

좋은 OS라면 프로그램이 종료될 때 프로그램이 할당한 메모리를 모두 파괴하고 해제해준다. 하지만 이런 기능이 없는 OS도 있고, 좋은 습관은 아니기에 추천하지 않는다.

그렇다 이렇게 함수를 만들었다면 이를 호출한 쪽에서 해제해주면 된다. 따라서 전체 프로그램 코드는 다음과 같아야 한다.

#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>
char * cloneUpper(const char *origin);
int main(void)
{
    char *s = "abcdefg";
    char *n = cloneUpper(s); // cloneUpper 함수는 메모리를 새롭게 할당한다.
    printf("%s\n", n);
    free(n); // 이 곳에서 해제해준다.
    return 0;
}
char * cloneUpper(const char *origin){
    char *temp, *r;
    int l, i;
    temp = origin;
    l = strlen(origin);
    r = (char*)malloc(l+1);
    r[l] = 0;
    i = 0;
    while(*(temp+i)!=0) {*(r+i) = toupper(*temp+i); i++; } return r;
}

여기서 잠깐 이와 하는 일이 비슷한 기본함수를 떠올려보자. 바로 문자열을 복사해주는 strcpy 이다. 이 함수는 문자열 상수를 다른 문자열 포인터로 복사해준다. 정확히 말하면 사본을 새롭게 생성하지는 않고, 사본을 생성하는 과정을 수행하는 함수이다. 이 함수는 string.h 라는 기본 라이브러리에 정의되어 있는데 그 원형은 다음과 같다.

char* __cdecl    strcpy (char*, const char*);

문자열 상수와 더불어 이 문자열을 복사될 사본의 포인터를 받는다. 즉, 이 함수를 사용하려면 ‘사본’의 메모리 영역을 미리 만들어주어야 한다는 의미이다. 이는 함수 내에서 메모리 할당을 피함으로써 호출하는 쪽에서 메모리 할당과 해제를 모두 책임지도록 하는 방법이다. 함수를 ‘편안히’ 활용하기에는 어렵지만, 그래도 호출할 때마다 해제를 해줘야 하는 것을 기억해야 하는 강박은 덜어주는 식으로 디자인했다.
같은 방식으로 cloneUpper의 디자인을 변경하면 다음과 같다. 훨씬 간결해진다.

char * cloneUpper(char *dest, const char *origin) {
    char *temp = origin;
    int i = 0;
    while(*(temp+i)!=0) {*(r+i) = toupper(*temp+i); i++; } return r;
}

함수 내 동적할당 해제 - hamsu nae dongjeoghaldang haeje

글쓴이: innu / 작성시간: 화, 2004/02/24 - 10:26오전

너무 기초적인 질문일지 싶습니다.
말그대로입니다.

#include <stdio.h>
#include <string.h>
#include <stdclib.h>

char *re_str(char *ptr);

int main() {
   char *p;

   p="We are the world !!!";
   printf("%s\n",re_str(p));
   return 0;
}

char *re_str(char *ptr) {
   char *rev;
   int i,j;
   
   i=strlen(ptr)-1;
   j=0;

   rev=(char *)malloc(strlen(ptr));

   for ( ; i>=0; i--) {
      rev[j]=*(ptr+i);
      j++;
   }

   rev[j]='\0';

   return rev;
   free(rev);           /* 없어도 상관없지 않나요? */
}


/* 결과 */
/* !!! dlrow eht era eW */

고정된 문자열을 거꾸로 출력하는 함수입니다.
만약 re_str() 없이 main으로 들어가 버리면 free 해줘야 겠지만 함수로 모듈화시키면 함수호출이 끝남과 동시에 메모리에서 해제되니까 free 쓸필요가 없지 않나요?

    SleeveStar 2021. 5. 1. 13:43

    정적 메모리 할당의 한계

    컴파일러의 설정을 변경하지 않았다면 프로세스 안에서 지역 변수가 저장되는 기본 스택 메모리 크기는 1Mbyte이다. 따라서 함수를 호출할 때 지역 변수가 할당되는 메모리 공간은 최대 1Mbyte를 넘을 수 없다.

    예를 들어 char data[1024 * 1024]; 와 같이 배열을 선언하면 스택에 1Mbyte가 할당되기 때문에 오류가 발생한다.  

    그렇다면 스택의 크기는 어떻게 계산할까?

    함수가 호출되는 여부나 횟수를 미리 파악할 수 없기 때문에 프로그램이 사용할 전체 스택의 크기는 단순히 각 함수에 선언한 지역 변수의 크기를 합산해서 예측할 수는 없다. 이 말은 실제로 프로그램이 실행되어 함수가 호출될 때까지 스택 크기를 예측하기 어렵다는 뜻이다.

    결국 스택의 실제 크기는 프로그램이 실행될 때가 돼서야 할 수 있기 때문에 컴파일러는 자신이 컴파일한 프로그램이 스택을 얼마나 사용할지 예상할 수 없다. 그래서 단일 배열의 크기나 단일 자료형의 크기가 1Mbyte를 넘는 경우에만 오류 메시지를 출력하고 각 함수에 선언한 변수의 크기를 합산해서 1Mbyte를 넘을때는 오류로 처리되지 않는다. 하지만 컴파일에 성공한다고 해도 프로그램을 실행할 때 스택을 사용하는 크기가 1Mbyte를 넘게 되면 다음과 같이 실행 오류가 발생한다.

    "예외가 있습니다. 0xC00000FD: Stack overflow(매개변수: 0x00000000, 0x00372000)"

    따라서 프로그래머는 자신의 프로그램이 스택을 1Mbyte이상 사용하지 않도록 주의해야 한다. 그런데 프로그램에서 사용하는 데이터는 당연히 1Mbyte보다 훨씬 큰경우가 많다. 예를 들어 보고 있는 스마트폰이나 모니터에 출력된 한 점의 데이터가 보통 4바이트 크기이다. 이런 픽셀들의 모임인 Full-HD 영상 한장을 저장하려면 1920 X 1080 X 4바이트, 대략 7Mbytes 정도의 크기가 필요하다. 결국 프로세스 안의 기본 스택으로는 Full_HD 영상 한 장 조차 저장하지 못한다는 뜻이다. 따라서 더 큰 메모리를 사용할 수 있는 방법이 필요하다.

    동적 메모리 할당이란

    프로세스는 더 큰 메모리를 할당해서 사용할 수 있도록 힙(Heap)이라는 공간을 제공한다.

    힙은 스택 세그먼트 영역내에 포함되어 있다.

    기계어 명령문 문자열 상수 목록 동적 메모리 할당
    (heap)
    전역 변수
    (0으로 초기화)
    지역 변수
    (stack)
    static 전역 변수
    (초기화 안 됨)
    코드 세그먼트 데이터 세그먼트 스택 세그먼트

    스택은 '스택 프레임' 규칙을 통해 코드가 컴파일될 때 사용할 메모리 크기를 결정한다. 하지만 힙은 스택 프레임과 같은 형식이 적용되지 않으며 프로그래머가 원하는 시점에 원하는 크기만큼 메모리를 할당할 수 있다. 그리고 메모리 사용이 끝나면 언제든지 할당한 메모리 공간을 해제할 수 있다. 이런 형식의 메모리 할당을 '동적 메모리 할당'이라고 한다. 또

    힙은 Mbyte 단위가 아닌 Gbyte단위까지 할당할 수 있기 때문에 메모리를 할당할 때 크기 문제가 거의 발생하지 않는다.

    *스택 프레임이란? - 함수를 호출할 때 일어나는 스택의 변화

    malloc 함수로 동적 메모리 할당하기

    힙은 스택처럼 관리되는 공간이 아니라서 변수를 선언하는 행위로 메모리를 할당할 수 없다. 그래서 동적 메모리 할당을 지원하는 C 표준 함수인 malloc을 사용해서 메모리를 할당해야 한다. 이 하수는 같이 사용자가 size 변수에 지정한 크기만큼 힙 영역에 메모리를 할당하고 그 할당된 주소를 void* 형식으로 반환해 준다.

    *malloc은 memory allocation의 약자

    함수 원형 : void *malloc(size_t size) // size_t는 unsigned int와 같은 자료형

    함수 사용 형식 : void *p = malloc(100);

    메모리 크기를 지정할 때 size_t 자료형을 사용하는데 이 자료형은 unsigned int형과 같으며, 메모리 할당은 항상 양수로만 가능하기 때문에 음수를 고려하지 않겠다는 뜻이다.

    그런데 사용자가 malloc 함수로 100바이트 메모리를 할당하더라도 이 메모리를 2바이트 단위의 50개 데이터 그룹으로 사용 사용할지, 4바이트 단위의 25개의 그룹으로 사용할지는 예상할 수 없다. 그래서 malloc 함수가 메모리의 사용단위를 결정하지 않고 void* 형식으로 주소를 반환해 주는 것이다. 

    그런데 void*를 사용하면 사용할 때마다 형 변환(casting)을 해야하는 불편함이 있다. 그래서 다음과 같이 malloc 함수를 사용하여 주소를 받는 시점에 사용할 포이넡에 미리 형 변환을 사용하는 것이 좋다. 예를 들어 힙 영역에 할당된 100바이트의 메모리를 2바이트 단위로 사용하고 싶거나 4바이트 단위로 사용하고 싶다면 malloc 함수를 다음처럼 사용하면 된다.

    short *p = (short *)malloc(100); //100바이트를 2바이트의 50그룹으로 쪼갬

    int *p = (int *)malloc(100);  //100바이트를 4 바이트의 25그룹으로 쪼갬

    *malloc 함수가 메모리 할당에 실패하는 경우도 있다.

    - 한번에 너무 큰 크기(2Gbytes 이상)를 명시할 때

    - 계속된 메모리 할당으로 힙에 공간이 부족할 때

    이런 경우 malloc 함수는 할당된 메모리 주소 대신에 NULL을 반환한다.

    free 함수로 할당된 메모리 해제하기

    스택에 할당한 지역 변수는 함수 호출이 끝나면 스택 프레임에 의해 자동으로 해제된다. 하지만 힙에 할당한 메모리는 프로그램이 끝날 때까지 자동으로 해제되지 않는다. 사용하던 메모리가 해제되지 않으면 힙에 메모리를 할당할 공간이 부족해질 수 있다. 따라서 다음과 같이 free 함수를 사용하여 힙에 할당했던 메모리를 명시적으로 해제해주어야 한다.

    free(p); // p가 가지고 있는 주소에 할당된 메모리를 해제함

    예를 들어 malloc 함수를 사용하여 할당 받는 메모리의 주소 값을 포인터 변수 p가 가지고 있다고 하자. 그러면 위와 같이 포인터 변수 p가 가지고 있는 주소 값을 free 함수에 배개변수로 넘겨서 해당 주소에 할당된 메모리를 해제해야한다.

    malloc 함수와 free 함수의 정보가 malloc.h에 정의되어 있기 때문에 이 함수들을 사용하려면 #include<malloc.h> 전처리기를 코드에 추가해야 한다.

    동적 메모리 할당을 사용하여 이름 입력 받고 출력하기

    함수 내 동적할당 해제 - hamsu nae dongjeoghaldang haeje

    malloc 함수를 사용할 때 주의할 점

    동적으로 할당한 메모리는 malloc 함수를 사용할 때부터 free 함수를 사용할 때까지 계속 힙 영역에 할당되어있다. 따라서 다음과 같이 동적 메모리 할당을 사용하는 프로그램에서 실수로 메모리할당을 해제하는 free 함수를 사용하지 않았다면 Test 함수가 호출될 때마다 힙레 100바이트씩 추가로 메모리가 할당된다. 그래서 반복문이 완료된 시점에는 100 x 100바이트의 메모리가 힙에 할당되어 버린다.

    함수 내 동적할당 해제 - hamsu nae dongjeoghaldang haeje

    그리고 힙에 할당된 주소를 기억하고 있는 포인터 변수 p는 지역 변수이기 때문에 Test 함수의 종료와 함께 메모리에서 제거된다. 그런데 제거된 포인터 변수 p가 동적 할당된 메모리의 주소 값을 저장하고 있었기 때문에 포인터 변수 p가 제거되면 동적 할당된 메모리의 주소 값을 알 수 있는 방법이 없어서 해당 메모리를 사용할 수 없고 해제할 수도 없다. 이런 상태를 메모리가 손실되었다고 이야기 한다.

    할당되지 않은 메모리를 해제하는 경우

    동적 메모리 할당을 많이 사용하느 프로그램은 메모리 손실이 나지 않도록 free 함수를 빼놓지 않도록 신경을 많이 써야 한다. 그렇다고 해서 할당도 되지 않은 메모리를 해제하면 컴파일은 성공하더라도 실행할 때 오류가 발생한다.

    char *p;

    // p = (char *)malloc(32);

    free(p);  // p는 할당된 메모리의 주소를 가지고 있지 않아서 실핼할 때 오류 발생함

    정적으로 할당된 메모리를 해제하는 경우

    포인터가 정적으로 할당된 지역 변수의 주소를 가지고 있는데 이 주소를 사용하여 free 함수를 호출해도 실행할 때 오류가 발생한다.

    int data = 5;

    int *p = &data;  // p는 지역 변수 data의 주소를 가지게 됨

    free(p); // p는 힙에 할당된 주소가 아니기 때문에 실행할 때 오류 발생함

    할당된 메모리를 두 번 해제하는 경우

    malloc 함수를 사용해 정상적으로 할당한 주소를 free 함수로 해제하고 나서 실수로 한 번 더 해제하는 경우에도 프로그램을 실행할 때 오류가 발생한다.

    int *p = (int *)malloc(12); //12바이트 메모리를 힙에 정상적으로 할당함

    free(p);  //할당했던 메모리를 정상적으로 해제함

    free(p);  //이미 해제한 주소를 다시 해제하기 때문에 실행할 때 오류 발생함

    동적 메모리 할당의 장단점

    힙에 동적으로 할당하는 메모리는 스택에 비해 큰 크기의 메모리를 할당할 수 있으며 메모리를 할당하고 하제하는 시점도 프로그래머가 직접 정할 수 있다. 그리고 할당되는 메모리 크기도 프로그램 실행중에 변경할 수 있다. 그래서 할당되는 메모리 크기가 변경되어도 소스 코드를 다시 컴파일 하지 않아도 된다. 하지만 힙에 동적으로 메모리를 할당하고 해제하는 작업을 프로그래머가 직접 관리해야 하기 때문에 코드가 복잡해지며 작은 메모리를 할당해서 사용할 때는 오히려 비효율적일 수도 있다.

    스택에 메모리 정적 할당 힙에 메모리 동적 할당
    char data; //스택에 1바이트 사용함
    data = 5;
    //p는 지역 변수라서 스택에 정적 할당함
    char *p;
    //1바이트 메모리를 힙에 동적 할당함
    p= (char *)malloc(1);
    *p = 5;
    free(p); // 동적 할당한 메모리 해제

    정적 할당은 스택에 1바이트만 할당된다. 하지만 동적 할당은 스택 대신 힙에 1바이트를 할당하며, 할당받은 힙의 메모리 주소를 저장하기 위해 스택에 4바이트를 할당한다. 따라서 총 5바이트가 필요하다. 이런 상황에 굳이 동적할당을 고집하는것은 좋지 않다.

    동적 메모리 사용하기

    1바이트 2바이트처럼 크기가 작은 데이터 여러 개를 동적으로 할당해서 사용하는 것은 번거롭고 불편할 수 있다. 그래서 동적 할당도 메모리를 배열처럼 그룹으로 묶어서 많이 사용한다.

    ex) int *p = (int *)malloc(12);  //12바이트를 할당하며 int형은 4바이트이기 때문에 세 그룹으로 나누어 사용하는 것.

    int p[3]과 비슷하다

    포인터 문법은 포인터 변수에 저장되어 있는 주소로 연산할 수 있다. 그래서 처음 4바이트는 *p 형식으로 사용하고, 그 다음 4바이트는 *(p + 1)형식으로 사용할 수 있으며, 마지막 4바이트는  *(p + 2) 형식으로 사용할 수 있다.

    결국 이런 형식으로 메모리를 동적으로 할당하면 int형으로 그룹 지어진 메모리를 사용하는 것과 같기 때문에 아래쪽처럼 선언한 배열과 같은 목적으로 사용할 수 있다.

    int data[3];

    동적 메모리를 할당하는 또 다른 방법

    동적 메모리를 할당할 때 앞에서 본 것처럼 malloc(12)라고 호출하면 할당할 전체 메모리의 크기를 명시하는 형태이다. 그런데 sizeof 연산자를 사용하면 메모리 사용 단위까지 적을 수 있다.

    int *p = (int *)malloc(sizeof(int) * 3);   //sizeof(int) * 3 == 12

    malloc(12)라고만 적어 놓으면 12바이트를 동적 할당한다는 정도로 이해할 수 있다. malloc(sizeof(int)*3)이라고 적으면 12바이트를 할당하면서 대상 메모리를 4바이트 단위로 나누어서 사용하려는 의도까지 좀 더 쉽게 파악할 수 있다.

    그리고 같은 12바이트를 사용하더라도 다음과 같이 사용하면 메모리를 사용하는 방법이 달라진다.

    short *p = (short *)malloc(sizeof(short) * 6); //sizeof(short) * 6 == 12

    위 형태로 사용하면 포인터 p는 주소에 접근하여 2바이트(short) 단위로 메모리를 읽고 쓰기 때문에 12바이트를 6개의 항목으로 나누어 사용하게 된다.

    이 구조도 변수 6개로 이루어진 배열과 같은 목적으로 사용할수 있다. 결국 포인터와 동적 할당 문법을 사용하면 배열과 같은 목적으로 사용할 수 있는 메모리를 구성할 수 있다.

    short data[6];

    정적 메모리 할당을 사용했을 때 발생할 수 있는 문제점

    배열을 사용하면 메모리가 스택에 정적으로 할당되기 때문에 항목의 개수를 상수로만 할당할 수 있다. 따라서 다음과 같이 선언하면 오류가 발생한다.

    int data_size = 3;

    int data[data_size];  //배열의 요소 개수는 상수로만 명시할 수 있기 때문에 오류 발생함

    즉, 배열의 크기는 상수로만 적을 수 있기 때문에 자신이 사용할 데이터의 최대 개수에 반드시 신경을 써야 한다. 예를 들어 친구 관리 프로그램을 만드는데 배열을 사용하고 이 배열의 한 요소에 한 명의 친구 정보가 저장된다고 하자. 그러면 친구가 몇 명인지에 따라서 배열의 크기가 달라져야 한다. 친구가 별로 없는 프로그램 사용자는 배열 크기가 10으로도 충분하겠지만 친구가 300명인 사용자도 있을 수 있다. 그러면 프로그래머는 어쩔 수 없이 최대 크기인 300으로 배열 크기를 정해야 한다. 하지만 이 프로그램을 친구가 10명인 사람이 사용하면 290개의 배열 요소를 낭비하는 셈이다.

    이 문제는 결국 배열이 정적 메모리 할당 방식을 사용하기 때문에 배열의 크기를 상수로만 받아서 생기는 것이다. 

    정적 메모리 할당을 사용하여 숫자를 입력 받아 합산하기

    함수 내 동적할당 해제 - hamsu nae dongjeoghaldang haeje

    사용자가 10개의 숫자를 입력해야 한다고 요청하면 이 프로그램은 배열의 크기를 변경해야 하기 때문에 MAX_COUNT 값을 바꾸기 위해 프로그램 코드를 수정해야 한다. 즉 #define MAX_COUNT     10 과 같이 사용자가 요청할 때마다 최대 횟수를 변경해서 다시 컴파일 하고 재배포한다면 유지보수에 부담을 줄 수 있기 때문에 변경할 가능성이 없을 만큼 큰 숫자를 명시하기도 한다.

    malloc 함수는 메모리 할당 크기를 변수로 지정할 수 있다

    int data_size = 12;

    int *p = (int *)malloc(data_size)  //12바이트의 메모리가 동적 할당됨

    이렇게 메모리 할당 크기를 변수로도 사용할 수 있기 때문에 사용할 데이터의 개수를 제한할 필요가 없다. 

    동적 메모리 할당을 사용하여 숫자를 입력받아 합산하기

    함수 내 동적할당 해제 - hamsu nae dongjeoghaldang haeje

    배열 문법이 사용하기 편한 것은 사실이다 하지만 편한 문법의 공통점은 제약이 많다는 것이다. 위와 같이 처리하면 사용자가 직접 숫자의 개수를 지정할 수 있기 때문에 배열을 사용할 때 처럼 코드를 수정할 필요가 없다.