[CS] TIL 16. 메모리 할당과 해제, 메모리 교환, 스택, 힙
- 제한된 메모리를 가지고 프로그래밍을 할 때 메모리를 해제하지 않으면 어떤 문제가 발생할까?
- 메모리 영역을 다양하게 나누는 이유는 무엇일까?
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
에 저장하고 있다.
변수 s
는 get_string
에 의해서 사용자로부터 입력받은 문자열의 주소를 저장한다. malloc
함수 또한 변수 s
와 비슷한 값을 돌려준다. 예를 들어 emma를 사용자로부터 입력받았으면 get_string
에의해서 총 5바이트를 차지하게 될 것이고, 변수 t
도 malloc
에 의해서 똑같은 크기로 할당한 메모리의 첫 바이트 주소를 저장하게 될 것이다.
하지만, 이 코드에는 버그가 있다. 메모리를 할당받았지만, 해제하지는 않는다.
1.2 메모리 해제
malloc
함수를 이용하여 메모리를 할당한 후에는 free
라는 함수를 이용하여 메모리를 해제해줘야 한다. free
라는 함수는 할당되었던 메모리를 다시 반환한다. 또 다른 함수를 이용해서 메모리를 해제해야 하는 이유는 무엇일까?
예를 들면 A라는 프로그램의 작성자가 malloc
를 계속 호출하면서 메모리 할당만 할 뿐, 해제하지 않았다고 치자. 만약 우리가 그 프로그램을 오래 사용하면, 컴퓨터는 점점 느려지면서 “메모리가 부족하다”는 에러 메세지를 띄울 것이다. 메모리 해제를 하지 않을 경우 메모리에 저장한 값은 쓰레기 값으로 남게 되어 메모리를 낭비하게 된다. 이러한 현상을 ‘메모리 누수’라고 일컫는다.
1.2.1 valgrind
이런 실수를 하지 않기 위해 디버깅 도구가 있다. valgrind
라는 프로그램인데, 이것을 사용하면 우리가 작성한 코드에서 메모리와 관련된 문제가 있는지를 쉽게 확인할 수 있다.
help50 valgrind ./filename
와 같은 명령어를 사용하면 filename 파일에 대한 valgrind
의 검사 내용을 쉽게 확인할 수 있다.
valgrind
을 실행해보면, 난해한 출력이 나오고 s
를 입력하고 나온다. emma를 입력하고 엔터를 누르면, 다음과 같은 메세지가 출력된다.
이런저런 에러가 나오는데, 힙 메모리 요약에는 “한 블록의 5바이트가 첫 번째 손실 기록에서 손실되었습니다” 누수 요약에는 “한 블럭의 5바이트가 누수되었습니다.”라고 적혀있다. 이건 리눅스라는 업계에서 흔히 쓰이는 운영체제의 한 프로그램이다. 출력되는 내용은 너무 많은데 중요한 부분만 집중해보자. 메모리 누수는 좋지 않다. 어디에서 메모리 누수가 나는지를 알 수 있을까? help50
을 사용하면 된다.
help50
을 사용하면 valgrind
의 결과를 분석하고 도움이 될만한 메세지를 노란색으로 알려준다. 프로그램에서 5바이트의 메모리가 새는데 혹시 malloc
으로 할당받은 메모리를 해제하였는지를 묻는다. 그리고 copy.c
의 10번째 줄을 다시 확인해보라고 한다.
위를 올려서 보아도 copy.c
의 main
함수 속 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
을 실행해보면 그 차이를 알 수 있다.
모든 게 잘 작동해도 많은 내용이 출력된다. 이번에는 메모리 누수 요약에 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
를 실행한다.
이 코드를 valgrnd
로 검사해보면 버퍼 오버플로우와 메모리 누수 두 가지 에러를 확인할 수 있다.
먼저 버퍼 오버플로우란, 메모리나 혹은 메모리 배열을 다룰 때 연속된 공간의 정수가 N개가 있는데, 그 할당된 공간을 넘어서 접근하는 상황을 말한다. 여기서 버퍼는 배열이다. 위 코드에서는 x[10] = 0;
에 의해서 발생한다. 우리는 10개의 int형 배열을 만들었는데, 배열의 인덱스가 0부터 시작하는 것을 생각하면 인덱스는 0 ~ 9 밖에 없게 된다. 그런데, 해당 줄에서는 인덱스 10, 즉 11번째의 정수에 접근하겠다는 의미가 된다. 이것은 정의되어있지 않기 때문에 버퍼 오버플로우가 발생한다. 따라서 이 오류는 0에서 9 사이의 인덱스를 사용하면 해결이 가능하다.
메모리 누수는 x
라는 포인터를 통해 할당한 메모리를 해제하기 위한 free(x)
라는 코드를 추가해줌으로써 해결할 수 있다.
2. 메모리 교환, 스택, 힙
2.1 메모리 교환
어제 오늘로 도구를 배웠으니 이제 이것을 적용할 것이다. 지난 주에 버블정렬, 선택정렬을 배웠을 때 유용했던 swap
함수를 기억해보자. 꽤 간단한 방법인데, 값을 교환하면 올바른 위치에 두는 함수이다.
두 개의 컵이 있고, 첫번째 컵에는 파란물이, 두번째 컵에는 빨간물이 담겨있다. 여분의 컵도 없고, 섞여서도 안되고, 컵의 위치를 바꾸는 등의 꼼수도 허락되지 않는 환경에서 두 컵의 내용물을 바꿔 담아야 한다면 어떻게 해야할까? 생각을 해봐도 내용물을 바꾸는 방법은 쉽게 떠오르지 않는다.
하지만 우리게에 빈 컵이 주어진다면 해답을 찾기 쉬울 것이다. 빈컵에 파란 물을 담고나면 첫 번째 컵은 빈 상태가 될 것이다. 이제 첫번 째 컵에 빨간물을 담고, 비어진 두번째 컵에 파란물을 담으면 두 컵의 내용물이 바뀔 것이다.
비록 컵으로 비유했지만, 이건 두 개의변수를 교환하는 올바른 방법이다. 중요한 것은 우리에게 빈 컵이 주어졌다는 것이다. 이것을 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
이라는 함수를 작성한 것이다. 정수 두 개 a
와 b
를 인자로 받아서 바꾸는게 목적이고, 임시 공간으로 쓸 변수 tmp
에 a
를 저장하고, a
를 b
의 값으로 바꾸고, b
에다가 tmp
에 있는 값을 복사한다. 해당 코드만 보면 하지만 이렇게 쉽게 동작하지는 않는다.
x
와 y
가 1과 2로 하드 코딩 되어있었는데, 실행해서 출력을 보면 x
는 1이고, y
는 2라는 문장이 두번 출력되는 것을 볼 수 있다. 우리는 이것으로 하여금, x
와 y
값이 바뀌지 않은채 그대로 출력되었다는 것을 알 수 있다.
컴파일 에러도 없음에도 swap
함수 값이 전혀 바뀌지 않은 이유는 무엇을까? 이유는 함수에 인자를 정달 할 때, 그 값을 복사해서 전달하기 때문이다.
int x = 1;
int y = 2;
위와 같이 x
와 y
가 각각 1과 2로 초기화 되어있고, swap(x, y);
에 의해서 함수의 인자로 전달하지만 함수는 x
와 y
자체가 아니라 x
와 y
의 복사본을 전달받는다. 함수 프로토 타입에 에서 이 두 값을 a
와 b
라고 부른다. 즉, x
와 y
값이 바뀌지 않은 이유는 교환하는 대상이 x
, y
그 자체가 아닌 함수 내에서 새롭게 정의된 a
, b
이기 때문이다. a
와 b
는 각각 x
와 y
의 값을 복제하여 가지게 되어, 서로 다른 메모리 주소에 저장된다. 이러한 방법으로는 버블정렬이나 선택정렬에 필요한 교환함수를 구현하지 못한다.
그냥 바꾸면 될 것을 값을 복제하고 다른 메모리에 저장하는 짓을 하는지에 대해서 알아보자.
2.2 스택, 힙
기본적인 개념으로 돌아가서 컴퓨터 메모리 속을 살펴보자. 그리고 메모리를 격자형태로 놓은 바이트로 생각해보자. C를 사용할 때 컴퓨터는 메모리 속 아무 공간이 사용하지 않는다. 아주 조직적인 방법으로 사용한다.
추상적인 커다란 네모가 있다고 하자. 이게 컴퓨터 메모리라면 맨 위에는 machine code, 즉, clang
이 컴파일한 0과 1의 값이 들어간다. ./filename
을 치거나 아이콘을 더블클릭하면 0과 1로 컴파일된 코드가 메모리 위쪽에 저장된다. 위와 같은 크기를 차지할 수도 있고, 더 큰 공간을 차지할 수도 있다.
그 아래에는 프로그램이 전역(globals)변수나 정보를 쓸 때, 컴퓨터 메모리 속 머신코드 아래 공간에 놓이게 된다. 사람들이 컴파일러를 만들 때 메모리를 어디에 둘지 미리 정해놓은 것이다.
그 아래에는 heap(힙)이라는 특별한 메모리 영역이 있다. 힙은 우리가 메모리를 할당 받을 수 있는 커다란 영역이다. ( valgrind
를 사용할 때 본 적이 있을 것이다.) malloc
함수를 호출하면 메모리를 이 영역에서 가지고 온다.
malloc
함수를 홀출 할 때마다 이 영역에서 가져다 쓰는 것이다. 그리고 힙은 아래로 자라기 때문에 메모리를 사용할 수록 아래로 내려간다.
하지만 아래에 또 다른 용도로 할당된 메모리 영역이 있다. 프로그램에서 어떤 함수를 호출할 때마다 함수의 지역변수들은 스택이라는 메모리 제일 아래 영역에 놓인다.
기본 함수 main
에서 한 개 이상의 인자와 지역변수가 있다면 이것들은 스택의 아래쪽에 놓인다. swap
같은 다른 함수를 호출하면 그 위에 있는 메모리를 사용한다. 힙은 mallo
이 메모리를 할당하는 곳이고, 스택은 함수가 호출될 때 지역변수가 쌓인는 공간이라는 걸 기억해라.
이를 바탕으로 다시 생각해보면, 위의 코드에서 a
, b
, x
, y
, tmp
모두 스택 영역에 저장되지만 a
와 x
, b
와 y
는 그 안에서도 서로 다른 위치에 저장된 변수이다. 따라서 a
와 b
를 바꾸는 것은 x
와 y
를 바꾸는 것에 아무런 영향도 미치지 않다.
#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;
}
실제 동작을 살펴보자. 스택만 생각하면 변수를 교환할 때 위 코드는 어떻게 동작하는가.
main
을 호출하면 메모리 멘 아래의 스택 프레임이라는 공간이 주어진다. argv
나 argc
그리고 x
와 y
같은 지역변수를 저장하는 곳이다. main
함수 안에 있는 변수는 모두 이 메모리 영역에 저장된다.
main
함수가 swap
같은 함수를 호출하면 main
함수 위에 쌓이게 된다. 즉, swap
함수의 인자 a
와 b
, 그리고 임시 변수인 tmp
가 여기에 쌓이는 것이다. x
와 y
가 바닥에 놓이고 그 위에 a
와 b
, tmp
가 쌓인다.
어떻게 쌓이는 지를 보았으니 이제 어떻게 동작하는지를 살펴보자.
프로그램이 시작하고 main
이 호출되면 x
와 y
두 변수가 각각 1과 2로 초기화 된다. 그리고 swap
함수를 호출하면 a
와 b
, tmp
를 저장하기 위해 컴퓨터가 스택에 또다른 프레임을 위한 영역을 할당한다. 이제 swap
함수에서는 a
와 b
를 각각 x
와 y
에서 복사를 한 후, tmp
에 a
값을 넣고, a
에는 b
값을 그리고나서 b
에는 tmp
를 넣음으로써 교환을 끝낸다.
하지만 스택은 식당과도 같다. 식당에서 식판이 쌓여있는데 새로운 식판을 계속 위에 쌓고 맨 위에서부터 꺼낸다. b
에 tmp
값을 넣는 것까티 마무리 되면 식판이 다 꺼내듯이 프레임이 사라지게 된다. 메모리가 사라지는 것은 아니다.
하지만 복사한 값을 사용하기 때문에 x
와 y
는 전혀 영향을 받지 않는다. 이것은 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
에서 x
와 y
의 값을 swap
에게 전달하지 않고,x
와 y
의 주소를 알려줘서 swap
함수가 그 주소로 가서 값을 바꾸게 하는 것이다.
그럼 프로그램을 실행해도 우리가 요구하는데로 출력 될 것이다.
그림으로 보면 위와 같다.
2.3 질문
Q1. 메모리를 해제하지 않아도 괜찮은가?
▶ malloc
을 쓰지 않았기 때문에 해제할 것이 없다. malloc
이 없어도 주소를 사용할 수 있다. 마지막 프로그램의 경우 &
연산자를 사용해 x
와 y
의 주소를 알아낸 것이다.
Q2. 함수 속에서 malloc
을 사용하여 메모리 영역을 할당해줄 텐데 이건 어떻게 다뤄야 하는가?
▶책임은 우리에게 있으며, 어떻게 든 그 메모리 영역을 기억해서 해제해야 한다.get_string
이라는 함수가 비슷한 방법을 사용한다. 짧게 말하면 get_string
은 malloc
으로 메모리를 할당하여, 사용자에게 입력받은 문자열을 저장한다. 그리고 cs50
라이브러리의 쓰레기 수집이라는 기능이 프로그램이 종료될 때 해제되지 않은 메모리를 해제해 준다.