[운영체제 아주 쉬운 세 가지 이야기 - Virtualization] 14. Memory API
이 글은 제 개인적인 공부를 위해 작성한 글입니다.
틀린 내용이 있을 수 있고, 피드백은 환영합니다.
개요
여기서는 Unix의 메모리(사용자 주소 공간) 관리 인터페이스에 대해 논의한다. Unix/C 프로그램에서 메모리를 할당하고 관리하는 방법을 이해하는 것은 안정적인 소프트웨어를 구축하는 데 중요하다. 어떻게 메모리를 할당하고 관리해야 하며, 어떤 인터페이스가 사용되고, 어떤 실수를 해서는 안되는가?
메모리 공간의 종류
C 프로그램이 실행되면, 두 가지 유형의 메모리 공간이 할당된다. 첫 번째는 스택 메모리라고 불리며 할당과 반환은 프로그래머를 위해 컴파일러에 의해 암묵적으로 이루어진다. 이러한 이유 떄문에 자동(automatic) 메모리라고 불린다.
C 프로그램에서 스택에 메모리를 선언하는 것은 쉽다. 함수 안에서 변수를 선언하면, 함수가 호출될 때 스택에 공간을 확보하고 함수에서 리턴하면 스택에서 공간을 반환한다. 함수 리턴 이후에도 유지되어야 하는 정보는 스택에 저장하지 않는 것이 좋다. 이러한 작업은 컴파일러가 수행한다.
오랫동안 값이 유지되어야 하는 변수를 위해 힙 메모리라고 불리는 두 번째 유형의 메모리가 필요하다. 모든 할당과 반환이 프로그래머에 의해 명시적으로 처리된다. 당연히 무거운 책임이 있고 많은 버그의 원인이 된다. 그렇기에 조심하기 주의를 기울이면 큰 문제 없이 올바르게 인터페이스를 사용할 수 있다.
1
2
int *x = (int *)malloc(sizeof(int));
이 코드에 대한 주의 사항이 몇 개 있다.
첫째, 한 행에 스택과 힙 할당이 모두 발생한다. 우선 컴파일러가 포인터 변수의 선언 (int *x)을 만나면 정수 포인터를 위한 공간을 할당해야 한다는 것을 안다. 프로그램이 malloc()을 호출하여 정수를 위한 공간을 힙으로부터 요구한다. malloc()은 성공한 경우 그 정수의 주소를 반환하고 실패한 경우에는 NULL을 반환한다. 이 반환된 주소는 스택에 저장되어 프로그램에 의해 사용된다.
이런 명시적 성질과 다양한 쓰임새 때문에 힙 메모리의 사용은 사용자와 시스템 모두에게 어려운 숙제이다.
malloc() 함수
malloc() 호출은 매우 간단하다. 힙에 요철할 공간의 크기를 넘겨 주면, 성공했을 경우 새로 할당된 공간에 대한 포인터를 사용자에게 반환하고 실패했을 경우 NULL을 반환한다.
malloc()을 사용하기 위해 헤더 파일 stdlib.h를 소스 코드에 포함시켜야 하는데, 사실 모든 C 프로그램에 디폴트로 링크되는 C 라이브러리는 malloc() 함수를 가지고 있다. 헤더 파일을 추가하면 malloc()을 올바르게 호출했는지 컴파일러가 검사할 수 있고, 전달된 인자의 개수가 맞는지 올바른 데이터 타입의 인자를 전달했는지 검사하게 된다.
malloc()의 인자는 size_t 타입의 변수이고 이 변수는 필요 공간의 크기를 바이트 단위로 표시한 것이다. 대부분의 프로그래머는 숫자를 10이라고 직접 입력하지 않는다.
예를 들어, double precision의 부동 소수점 값을 위한 공간을 확보하기 위해서 다음과 같이 하면 된다.
1
double *d = (double *)malloc(sizeof(double));
이 malloc() 호출에서는 정확한 크기의 공간을 요청하기 위하여 C언에서 통상 컴파일 시간 연산자인 sizeof 연산자를 사용한다. 인자의 실제 크기가 컴파일 시간에 결정된다. sizeof()는 이 경우 double의 크기은 8인 숫자로 대체되어 malloc()에 전달된다. 이러한 이유로 sizeof()는 연산자로 간주되는 게 맞으며 함수 호출이 아니다. (함수 호출은 실행 시간에 일어난다.)
데이터 타입뿐 아니라 변수의 이름도 sizeof()의 인자로 전달할 수 있지만 원하는 결과를 얻지 못할 때도 있으므로 조심해야 한다.
1
2
int *x = malloc(10 * sizeof(int));
printf("%d\n", sizeof(x));
정수형 원소 10개를 가지는 배열을 위한 공간을 선언했지만, sizeof()에서는 32비트 컴퓨터에서는 4, 64비트 컴퓨터에서는 8이 출력된다. 이 경우 sizeof()는 동적으로 할당받은 메모리의 크기가 아니라, 정수를 가리키는 포인터의 크기가 얼마인지 알려주기 때문이다.
1
2
int x[10];
printf("%d\n", sizeof(x));
이 경우에는 변수 x에 40바이트가 할당되었다는 것을 컴파일러가 알 수 있는 정적 정보가 충분하다.
문자열을 위한 공간을 선언할 때는 다음과 같은 코드를 사용한다.
malloc(strlen(s) + 1);
이 코드는 strlen() 함수로 문자열의 길이를 얻고 문자열 끝을 나타내는 문자를 위한 공간을 확보하기 위해 1바이트를 더한다. sizeof()의 사용은 여기서 문제를 일으킬 수 있다.
malloc()은 void 포인터를 반환하는데 이는 주소만 넘겨주고 해당 주소에 대한 어떤 타입의 자료를 저장할 지는 프로그래머가 결정하게 하는 전형적인 C 방식이다. 프로그래머는 타입 변환(type casting)을 이용하여 공간 활용을 결정한다. 위의 예에서 프로그래머는 malloc()이 반환한 데이터 타입을 double 형을 가리키는 포인터 타입으로 변환하였다.
free() 함수
메모리 할당은 우리가 고민하고 있는 문제들 중 쉬운 쪽이다. 할당된 메모리를 언제, 어디서 해제하고 더욱이 해제 여부를 확인하는 것이 더 어려운 문제이다. 더 이상 사용되지 않는 힙 메모리를 해제하기 위해 프로그래머는 free()를 호출한다.
1
2
int *x = malloc(10 * sizeof(int));
free(x);
한 개의 인자, malloc()에 의해 반환된 포인터를 받는다. 할당된 영역의 크기는 전달되지 않는다. 할당된 메모리의 크기는 메모리 할당 라이브러리가 알고 있어야 한다.
흔한 오류
malloc()과 free()를 사용하는 데 흔히 발생하는 오류가 많다.
1. 메모리 할당 잊어버리기
많은 루틴은 자신이 호출되기 전에 필요한 메모리가 미리 할당되었다고 가정한다. 예를 들어, strcpy(dst, src) 루틴은 소스 포인터에서 목적 포인터로 문자열을 복사한다. 그러나 주의하지 안흥면 아래와 같은 코드를 작성할 수 있다.
1
2
3
char *src = "hello";
char *dst;
strcpy(dst, src);
이 코드는 dst가 가리키는 메모리가 할당되지 않았기 때문에 세그멘테이션 폴트(segmentation fault)를 발생시킨다.
올바른 코드는 다음과 같다.
1
2
3
char *src = "hello";
char *dst = (char *)malloc(strlen(src) + 1);
strcpy(dst, src);
2. 메모리를 부족하게 할당받기
때때로 버퍼 오버플로우(buffer overflow)라고 불린다.
1
2
3
char *src = "hello";
char *dst = (char *)malloc(strlen(src));
strcpy(dst, src);
이상하지만 malloc이 구현 방식과 기타 많은 세부 사항에 따라서는 이 프로그램이 제대로 동작하는 것처럼 보이는 경우가 종종 있다. 어떤 경우에는 문자열 복사가 실행될 때 할당된 공간의 마지막을 지나쳐 한 바이트만큼 더 공간을 사용한다.
이 공간이 더 이상 사용되지 않는 변수 영역이기 때문에 덮어쓰더라도 아무 피해가 발생하지 않을 때도 있다. 다른 때에는 이러한 오버플로우가 매우 유해할 수 있고 사실 많은 시스템에서 보안 취약점이 원인이다.
어떤 경우에는 malloc 함수 라이브러리가 여분의 공간을 할당하고 프로그램은 다른 변수의 값을 덮어쓰지 않고 잘 동작한다. 또 다른 경우에는 프로그램은 고장을 일으키고 크래시된다. 프로그램이 한 번 올바르게 실행된다고 하더라도, 프로그램이 올바르다는 것을 의미하지는 않는다.
3. 할당받은 메모리 초기화하지 않기
malloc()을 제대로 호출했지만 새로 할당받은 데이터 타입에 특정 값을 넣는 것을 잊는 것이다. 초기화하지 않는다면 프로그램은 결국 초기화되지 않은 읽기(uninitialized read), 즉 힙으로부터 알 수 없는 값을 읽는 일이 생긴다.
4. 메모리 해제하지 않기
다른 일반적인 오류는 메모리 누수(memory leak)다. 장시간 실행되는 응용 프로그램이나 또는 운영체제 자체와 같은 시스템 프로그램에서는 큰 문제이다. 메모리가 천천히 누수되면 결국 메모리가 부족하게 되고 시스템을 재시작 할 수밖에 없기 떄문이다. 메모리 청크의 사용이 끝나면 반드시 해제해야 한다. 가비지 콜렉션 기능이 있는 언어도 이 문제에는 도움이 되지 않는다. 메모리 청크에 대한 참조가 존재하면, 그 어떤 가비지 콜렉터도 그 청크를 해제하지 않을 것이고, 따라서 현대적인 언어에서도 메모리 누수는 여전히 문제가 된다.
프로그램이 시작 후 거의 바로 종료되어 메모리 누수가 일어나지 않아, free()를 호출하지 않는다고 가정해보자. 그렇다 하더라고 우리의 장기적인 목표는 프로그래머로서 좋은 습관을 만드는 것이기에 한 바이트라도 명시적으로 할당받았으면 해제하는 습관을 들이도록 하자.
5. 메모리 사용이 끝나기 전에 메모리 해제하기
때때로 프로그램은 메모리 사용이 끝나기 전에 메모리를 해제한다. 이런 실수는 댕글링 포인터(dangling pointer)라고 불리며, 심각한 실수이다. 차후 그 포인터를 사용하면 프로그램을 크래시 시키거나 유효 메모리 영역을 덮어쓸 수 있다.
6. 반복적으로 메모리 해제하기
프로그램은 가끔씩 메모리를 한 번 이상 해제하며 이중 해제(double free)라고 불린다. 결과는 에측하기 어렵고, 가장 흔히 일어나는 결과는 크래시다.
7. free() 잘못 호출하기
free()는 malloc()에 의해 반환된 포인터만을 인자로 받는다. 유효하지 않은 해제(invalid free)는 매우 위험하고 당연히 피해야 한다.
운영체제의 지원
malloc()이나 free()를 이야기하면서시스템 콜에 관해서는 한 번도 언급하지 않았는데, 이들은 시스템 콜이 아니라 라이브러리 함수이기 때문이다. malloc 라이브러리가 프로세스 가상 주소 공간 안의 공간을 관리하지만 라이브러리 자체는 시스템에게 더 많은 메모리를 요구하고 반환하는 시스템 콜을 기반으로 구축된다.
그런 시스템 콜 중 하나가 brk라고 불리는 시스템 콜로서, 프로그램의 break 위치를 변경하는 데 사용된다. break는 힙의 마지막 위치를 나타낸다. brk는 새로운 break 주소를 나타내는 한 개의 인자를 받는다. 새로운 break가 현재 break보다 큰지 작은 지에 따라 힙의 크기를 증가시키거나 감소시킨다. sbrk는 증가량만을 받아들이는 것을 제외하고 비슷한 용도로 사용된다.
brk 또는 sbrk를 직접 호출해서는 안된다. 이들은 메모리 할당 라이브러리에 의해 사용되고, 직접 사용하면 감당할 수 없는 결과가 발생하므로 반드시 malloc()과 free()를 사용해야 한다.
마지막으로 mmap() 함수를 사용하여 운영체제로부터 메모리를 얻을 수도 있다. 올바른 인자를 전달하면 mmap()은 프로그램에 anonymous의 메모리 영역을 만든다. 이 익명 영역은 특정 파일과 연결되어 있지 않고 스왑 공간(swap space)에 연결된 영역을 말한다.
기타 함수들
calloc()은 메모리를 할당 영역을 0으로 채워서 반환한다.
realloc()은 이미 할당된 공간에 대해 추가의 공간이 필요할 때 사용된다. 더 큰 새로운 영역을 확보하고 옛 영역의 내용을 복사한 후에 새 영역에 대한 포인터를 반환한다.
정리
- 스택 메모리
컴파일러가 암묵적으로 자동 관리한다.
함수 호출 시 생성되고 함수 반환시 자동 소멸되고, 지역 변수들이 저장된다.
일시적으로 사용되는 변수를 저장한다.
- 힙 메모리
프로그래머가 명시적으로 관리해야 한다.
malloc()으로 할당하고 free()로 해제한다.
프로그램이 실행되는 동안 값을 유지해야 할 때 사용된다.
명시적으로 관리해야 하므로 메모리 누수나 댕글링 포인터 등 버그의 주된 원인이 될 수 있다.
- malloc(size_t size)
힙 영역에서 size 바이트만큼의 메모리 공간을 할당한다.
성공 시 할당된 메모리의 시작 주소(void *), 실패 시 NULL을 반환한다.
- free(void *ptr)
malloc() 또는 (calloc, realloc() 등)으로 할당받았던 힙 메모리 공간을 해제한다.
ptr은 할당받은 메모리의 시작 주소여야 한다.
참고