[CS] TIL 16. 메모리 할당과 해제, 메모리 교환, 스택, 힙


  1. 제한된 메모리를 가지고 프로그래밍을 할 때 메모리를 해제하지 않으면 어떤 문제가 발생할까?
  2. 메모리 영역을 다양하게 나누는 이유는 무엇일까?


boostcourse모두를 위한 컴퓨터 과학 (CS50 2019) 강의를 듣고 정리한 필기입니다. 😀


1. 메모리 할당과 해제

1.1 메모리 할당

#include <cs50.h>
#include <ctype.h>
#include <stdio.h>
#include <string.h>

int main(void)
{
    char *s = get_string("s: ");
    
    char *t = malloc(strlen(s) + 1);

    strcpy(t, s);

    t[0] = toupper(t[0]);

    printf("s: %s\n", s);
    printf("t: %s\n", t);
}

malloc함수는 메모리 할당 함수이다. 메모리 할당이란, 메모리 일부분을 가져와서 그곳을 가리키는 포인터를 주는 것이다. 위 코드를 보면 주소를 기억해야 하므로 변수 t에 저장하고 있다.

변수 sget_string에 의해서 사용자로부터 입력받은 문자열의 주소를 저장한다. malloc함수 또한 변수 s와 비슷한 값을 돌려준다. 예를 들어 emma를 사용자로부터 입력받았으면 get_string에의해서 총 5바이트를 차지하게 될 것이고, 변수 tmalloc에 의해서 똑같은 크기로 할당한 메모리의 첫 바이트 주소를 저장하게 될 것이다.

하지만, 이 코드에는 버그가 있다. 메모리를 할당받았지만, 해제하지는 않는다.

1.2 메모리 해제

malloc 함수를 이용하여 메모리를 할당한 후에는 free라는 함수를 이용하여 메모리를 해제해줘야 한다. free라는 함수는 할당되었던 메모리를 다시 반환한다. 또 다른 함수를 이용해서 메모리를 해제해야 하는 이유는 무엇일까?

예를 들면 A라는 프로그램의 작성자가 malloc를 계속 호출하면서 메모리 할당만 할 뿐, 해제하지 않았다고 치자. 만약 우리가 그 프로그램을 오래 사용하면, 컴퓨터는 점점 느려지면서 “메모리가 부족하다”는 에러 메세지를 띄울 것이다. 메모리 해제를 하지 않을 경우 메모리에 저장한 값은 쓰레기 값으로 남게 되어 메모리를 낭비하게 된다. 이러한 현상을 ‘메모리 누수’라고 일컫는다.

1.2.1 valgrind

이런 실수를 하지 않기 위해 디버깅 도구가 있다. valgrind 라는 프로그램인데, 이것을 사용하면 우리가 작성한 코드에서 메모리와 관련된 문제가 있는지를 쉽게 확인할 수 있다.

help50 valgrind ./filename

와 같은 명령어를 사용하면 filename 파일에 대한 valgrind의 검사 내용을 쉽게 확인할 수 있다.

title

valgrind을 실행해보면, 난해한 출력이 나오고 s를 입력하고 나온다. emma를 입력하고 엔터를 누르면, 다음과 같은 메세지가 출력된다.

title

이런저런 에러가 나오는데, 힙 메모리 요약에는 “한 블록의 5바이트가 첫 번째 손실 기록에서 손실되었습니다” 누수 요약에는 “한 블럭의 5바이트가 누수되었습니다.”라고 적혀있다. 이건 리눅스라는 업계에서 흔히 쓰이는 운영체제의 한 프로그램이다. 출력되는 내용은 너무 많은데 중요한 부분만 집중해보자. 메모리 누수는 좋지 않다. 어디에서 메모리 누수가 나는지를 알 수 있을까? help50을 사용하면 된다.

title

help50을 사용하면 valgrind의 결과를 분석하고 도움이 될만한 메세지를 노란색으로 알려준다. 프로그램에서 5바이트의 메모리가 새는데 혹시 malloc으로 할당받은 메모리를 해제하였는지를 묻는다. 그리고 copy.c의 10번째 줄을 다시 확인해보라고 한다.

title

위를 올려서 보아도 copy.cmain함수 속 10번째 줄을 다시 확인해보라고 한다.

#include <cs50.h>
#include <ctype.h>
#include <stdio.h>
#include <string.h>

int main(void)
{
    char *s = get_string("s: ");
    
    char *t = malloc(strlen(s) + 1);

    strcpy(t, s);

    t[0] = toupper(t[0]);

    printf("s: %s\n", s);
    printf("t: %s\n", t);
    
    free(t);
}

다시 코드를 확인해보면 해답은 매우 간단하다. 맨 아래에 free(t)를 적는다. free(t)이 없건 있건, 실행 결과는 똑같을 것이다. valgrind ./filename을 실행해보면 그 차이를 알 수 있다.

title

모든 게 잘 작동해도 많은 내용이 출력된다. 이번에는 메모리 누수 요약에 0개의 블럭에 0바이트라고 나온다. 또, 메모리가 새고 있다는 말도 보이지 않는다. 앞으로 이것을 이용하면 복잡한 버그를 잡는 데 유용할 것이다.

1.2.2 또 다른 코드로 알아보기

#include <stdlib.h>

void f(void)
{
    int *x = malloc(10 * sizeof(int));
    x[10] = 0;
}

int main(void)
{
    f();
    return 0;
}

f 함수의 int *x = malloc(10 * sizeof(int));를 살펴보자. malloc에 의해서 10의 정수를 위한 메모리를 할당하고, x라는 포인터에 저장한다. sixeof는 이름 그대로 ‘~의 크기’를 알려주는 함수이다. 괄호 안에 자료형을 쓰면, 자료형의 크기를 알려준다. (int는 4바이트, long은 8바이트, char은 1바이트이다. ) 정수의 크기를 10을 달라고 하였으니 4바이트 * 10이 되어 총 40바이트 메모리를 요청하게 된다. 이렇게 되면 malloc에 의해서 40바이트의 시작 주소를 저장하게 될 것이고, x는 정수를 저장하는 메모리의 배열이 된다. 그리고 main함수는 f를 실행한다.

title

이 코드를 valgrnd로 검사해보면 버퍼 오버플로우와 메모리 누수 두 가지 에러를 확인할 수 있다.

title

먼저 버퍼 오버플로우란, 메모리나 혹은 메모리 배열을 다룰 때 연속된 공간의 정수가 N개가 있는데, 그 할당된 공간을 넘어서 접근하는 상황을 말한다. 여기서 버퍼는 배열이다. 위 코드에서는 x[10] = 0;에 의해서 발생한다. 우리는 10개의 int형 배열을 만들었는데, 배열의 인덱스가 0부터 시작하는 것을 생각하면 인덱스는 0 ~ 9 밖에 없게 된다. 그런데, 해당 줄에서는 인덱스 10, 즉 11번째의 정수에 접근하겠다는 의미가 된다. 이것은 정의되어있지 않기 때문에 버퍼 오버플로우가 발생한다. 따라서 이 오류는 0에서 9 사이의 인덱스를 사용하면 해결이 가능하다.

title

메모리 누수는 x라는 포인터를 통해 할당한 메모리를 해제하기 위한 free(x)라는 코드를 추가해줌으로써 해결할 수 있다.



2. 메모리 교환, 스택, 힙

2.1 메모리 교환

어제 오늘로 도구를 배웠으니 이제 이것을 적용할 것이다. 지난 주에 버블정렬, 선택정렬을 배웠을 때 유용했던 swap 함수를 기억해보자. 꽤 간단한 방법인데, 값을 교환하면 올바른 위치에 두는 함수이다.

title

두 개의 컵이 있고, 첫번째 컵에는 파란물이, 두번째 컵에는 빨간물이 담겨있다. 여분의 컵도 없고, 섞여서도 안되고, 컵의 위치를 바꾸는 등의 꼼수도 허락되지 않는 환경에서 두 컵의 내용물을 바꿔 담아야 한다면 어떻게 해야할까? 생각을 해봐도 내용물을 바꾸는 방법은 쉽게 떠오르지 않는다.

title

하지만 우리게에 빈 컵이 주어진다면 해답을 찾기 쉬울 것이다. 빈컵에 파란 물을 담고나면 첫 번째 컵은 빈 상태가 될 것이다. 이제 첫번 째 컵에 빨간물을 담고, 비어진 두번째 컵에 파란물을 담으면 두 컵의 내용물이 바뀔 것이다.

비록 컵으로 비유했지만, 이건 두 개의변수를 교환하는 올바른 방법이다. 중요한 것은 우리에게 빈 컵이 주어졌다는 것이다. 이것을 C로 나타내면 다음과 같다.

#include <stdio.h>

void swap(int a, int b); // swap 함수를 위한 프로토타입이 있다.

int main(void)
{
    int x = 1; 
    int y = 2;
    // 마치 컵에 파란 물이랑 빨간 물이 처음부터 담겨 있는 것처럼.
    
    printf("x is %i, y is %i\n", x, y);
    swap(x, y);
    printf("x is %i, y is %i\n", x, y);
    // 코드에 무슨일이 벌어지는 지 확인하기 위해서 x와 y 값을
    // swap 함수를 실행하기 전이랑 후에 출력해본다.
}

void swap(int a, int b)
{
    int tmp = a;
    a = b;
    b = tmp;
}

위는 우리는 2개의 변수를 교환하기 위해 swap이라는 함수를 작성한 것이다. 정수 두 개 ab를 인자로 받아서 바꾸는게 목적이고, 임시 공간으로 쓸 변수 tmpa를 저장하고, ab의 값으로 바꾸고, b에다가 tmp에 있는 값을 복사한다. 해당 코드만 보면 하지만 이렇게 쉽게 동작하지는 않는다.

title

xy가 1과 2로 하드 코딩 되어있었는데, 실행해서 출력을 보면 x는 1이고, y는 2라는 문장이 두번 출력되는 것을 볼 수 있다. 우리는 이것으로 하여금, xy값이 바뀌지 않은채 그대로 출력되었다는 것을 알 수 있다.

컴파일 에러도 없음에도 swap 함수 값이 전혀 바뀌지 않은 이유는 무엇을까? 이유는 함수에 인자를 정달 할 때, 그 값을 복사해서 전달하기 때문이다.

int x = 1; 
int y = 2;

위와 같이 xy가 각각 1과 2로 초기화 되어있고, swap(x, y);에 의해서 함수의 인자로 전달하지만 함수는 xy 자체가 아니라 xy의 복사본을 전달받는다. 함수 프로토 타입에 에서 이 두 값을 ab라고 부른다. 즉, xy값이 바뀌지 않은 이유는 교환하는 대상이 x, y 그 자체가 아닌 함수 내에서 새롭게 정의된 a, b이기 때문이다. ab는 각각 xy값을 복제하여 가지게 되어, 서로 다른 메모리 주소에 저장된다. 이러한 방법으로는 버블정렬이나 선택정렬에 필요한 교환함수를 구현하지 못한다.

그냥 바꾸면 될 것을 값을 복제하고 다른 메모리에 저장하는 짓을 하는지에 대해서 알아보자.

2.2 스택, 힙

title

기본적인 개념으로 돌아가서 컴퓨터 메모리 속을 살펴보자. 그리고 메모리를 격자형태로 놓은 바이트로 생각해보자. C를 사용할 때 컴퓨터는 메모리 속 아무 공간이 사용하지 않는다. 아주 조직적인 방법으로 사용한다.

title

추상적인 커다란 네모가 있다고 하자. 이게 컴퓨터 메모리라면 맨 위에는 machine code, 즉, clang이 컴파일한 0과 1의 값이 들어간다. ./filename을 치거나 아이콘을 더블클릭하면 0과 1로 컴파일된 코드가 메모리 위쪽에 저장된다. 위와 같은 크기를 차지할 수도 있고, 더 큰 공간을 차지할 수도 있다.

title 그 아래에는 프로그램이 전역(globals)변수나 정보를 쓸 때, 컴퓨터 메모리 속 머신코드 아래 공간에 놓이게 된다. 사람들이 컴파일러를 만들 때 메모리를 어디에 둘지 미리 정해놓은 것이다.

title

그 아래에는 heap(힙)이라는 특별한 메모리 영역이 있다. 힙은 우리가 메모리를 할당 받을 수 있는 커다란 영역이다. ( valgrind를 사용할 때 본 적이 있을 것이다.) malloc 함수를 호출하면 메모리를 이 영역에서 가지고 온다.

title

malloc 함수를 홀출 할 때마다 이 영역에서 가져다 쓰는 것이다. 그리고 힙은 아래로 자라기 때문에 메모리를 사용할 수록 아래로 내려간다.

title

하지만 아래에 또 다른 용도로 할당된 메모리 영역이 있다. 프로그램에서 어떤 함수를 호출할 때마다 함수의 지역변수들은 스택이라는 메모리 제일 아래 영역에 놓인다.

title

기본 함수 main에서 한 개 이상의 인자와 지역변수가 있다면 이것들은 스택의 아래쪽에 놓인다. swap같은 다른 함수를 호출하면 그 위에 있는 메모리를 사용한다. 힙은 mallo 이 메모리를 할당하는 곳이고, 스택은 함수가 호출될 때 지역변수가 쌓인는 공간이라는 걸 기억해라.

이를 바탕으로 다시 생각해보면, 위의 코드에서 a, b, x, y, tmp 모두 스택 영역에 저장되지만 ax, by는 그 안에서도 서로 다른 위치에 저장된 변수이다. 따라서 ab를 바꾸는 것은 xy를 바꾸는 것에 아무런 영향도 미치지 않다.

#include <stdio.h>

void swap(int a, int b); // swap 함수를 위한 프로토타입이 있다.

int main(void)
{
    int x = 1; 
    int y = 2;
    // 마치 컵에 파란 물이랑 빨간 물이 처음부터 담겨 있는 것처럼.
    
    printf("x is %i, y is %i\n", x, y);
    swap(x, y);
    printf("x is %i, y is %i\n", x, y);
    // 코드에 무슨일이 벌어지는 지 확인하기 위해서 x와 y 값을
    // swap 함수를 실행하기 전이랑 후에 출력해본다.
}

void swap(int a, int b)
{
    int tmp = a;
    a = b;
    b = tmp;
}

실제 동작을 살펴보자. 스택만 생각하면 변수를 교환할 때 위 코드는 어떻게 동작하는가.

title

main 을 호출하면 메모리 멘 아래의 스택 프레임이라는 공간이 주어진다. argvargc 그리고 xy같은 지역변수를 저장하는 곳이다. main 함수 안에 있는 변수는 모두 이 메모리 영역에 저장된다.

title

main함수가 swap같은 함수를 호출하면 main 함수 위에 쌓이게 된다. 즉, swap 함수의 인자 ab, 그리고 임시 변수인 tmp가 여기에 쌓이는 것이다. xy가 바닥에 놓이고 그 위에 ab, tmp가 쌓인다.

어떻게 쌓이는 지를 보았으니 이제 어떻게 동작하는지를 살펴보자.

title

프로그램이 시작하고 main이 호출되면 xy 두 변수가 각각 1과 2로 초기화 된다. 그리고 swap 함수를 호출하면 ab, tmp를 저장하기 위해 컴퓨터가 스택에 또다른 프레임을 위한 영역을 할당한다. 이제 swap함수에서는 ab를 각각 xy에서 복사를 한 후, tmpa값을 넣고, a에는 b값을 그리고나서 b에는 tmp를 넣음으로써 교환을 끝낸다.

title

하지만 스택은 식당과도 같다. 식당에서 식판이 쌓여있는데 새로운 식판을 계속 위에 쌓고 맨 위에서부터 꺼낸다. btmp값을 넣는 것까티 마무리 되면 식판이 다 꺼내듯이 프레임이 사라지게 된다. 메모리가 사라지는 것은 아니다.

하지만 복사한 값을 사용하기 때문에 xy는 전혀 영향을 받지 않는다. 이것은 a와 b를 각각 x와 y를 가리키는 포인터로 지정함으로써 이 문제를 쉽게 해결할 수 있다.

#include <stdio.h>

void swap(int *a, int *b);

int main(void)
{
    int x = 1;
    int y = 2;

    printf("x is %i, y is %i\n", x, y);
    swap(&x, &y);
    printf("x is %i, y is %i\n", x, y);
}

void swap(int *a, int *b)
{
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

main에서 xy의 값을 swap에게 전달하지 않고,xy의 주소를 알려줘서 swap 함수가 그 주소로 가서 값을 바꾸게 하는 것이다.

title

그럼 프로그램을 실행해도 우리가 요구하는데로 출력 될 것이다.

title

그림으로 보면 위와 같다.

2.3 질문

Q1. 메모리를 해제하지 않아도 괜찮은가?

malloc을 쓰지 않았기 때문에 해제할 것이 없다. malloc이 없어도 주소를 사용할 수 있다. 마지막 프로그램의 경우 &연산자를 사용해 xy의 주소를 알아낸 것이다.

Q2. 함수 속에서 malloc을 사용하여 메모리 영역을 할당해줄 텐데 이건 어떻게 다뤄야 하는가?

▶책임은 우리에게 있으며, 어떻게 든 그 메모리 영역을 기억해서 해제해야 한다.get_string이라는 함수가 비슷한 방법을 사용한다. 짧게 말하면 get_stringmalloc으로 메모리를 할당하여, 사용자에게 입력받은 문자열을 저장한다. 그리고 cs50 라이브러리의 쓰레기 수집이라는 기능이 프로그램이 종료될 때 해제되지 않은 메모리를 해제해 준다.




© 2020. by RIVER

Powered by RIVER