pintos 운영체제에는 자체 내장되어 있는 테스트 프로그램이 있습니다. 

src/userprog/ 디렉토리에서 'make check' 쉘 명령어를 입력하면, 76개의 테스트를 실행하며, 마지막에 테스트의 결과를 알려줍니다.



76개의 테스트 중 (제가 봤을 때) 제일 디버깅이 어려운 테스트는 'multi-oom'테스트입니다.

multi-oom테스트의 목적은 pintos운영체제의 메모리 누수가 존재하는지를 확인합니다.



메모리 누수가 일어나는 곳을 찾아야 하는데 쉽지 않습니다. 

multi-oom 테스트를 해결해나가는 약간의 팁을 제공하자면,



1) malloc - free를 정확하게 사용해라. 

malloc함수는 힙영역에 새로운 메모리를 할당하는 함수입니다. 만약에 어떤 함수에서 메모리를 새로 할당한 이후, 그 함수가 종료되기 전까지 할당한 메모리가 사라지지 않는다면, 계속해서 쓸모없는 메모리가 남게 되어 메모리 누수가 발생합니다. 따라서, 함수 루틴 안에서 반드시 malloc을 사용한 경우에는 그에 대응하는 free를 해주기 바랍니다. 참고로, malloc을 한 이후, 그 메모리를 가리키는 포인터를 다음에도 계속 가지고 있다면, 즉시 free를 해줄 필요는 없습니다. 최종적으로 그 메모리가 필요없게 되었을 때 정확히 free를 해주는 것이 관건인 것이지요.


간단히 말하자면, 함수 내에서 임시적으로 사용하기 위해 할당한 메모리는 그 함수가 끝나는 시점에서 즉시 free로 메모리 해제를 해주어야 하고, 스레드 내에서 파일 디스크립터 테이블과 같이 스레드가 죽을 때까지 가지고 있어야 하는 정보에 대한 메모리는 스레드가 죽을 때, 메모리 해제를 해주면 되는 것이지요.



2) 1)과 같은 이유로, palloc_get_page에 대응하는 palloc_free_page를 철저하게 사용하라.

1)과 동일한 이유입니다. palloc_get_page도 메모리를 할당하는 함수이고, palloc_free_page도 메모리를 해제하는 함수입니다.



3) 문자열에 대한 메모리 누수를 정확하게 계산하라.

운영체제를 건축하는 일은 C언어의 메모리 사용을 '정확히' 알아야 합니다.

하지만, 제가 학부생으로서 다른 친구들을 보았을 때 C언어의 메모리(포인터) 사용을 정확하게 아는 사람은 정말 드물어요.

아마 4학년 졸업한 후에도 10명 중 1명이 채 안될 겁니다. 정말 세밀한 부분까지 완벽하게 이해한 사람은 거의 없습니다.

물론 저도 아직 부족한 부분이 많고, 운영체제를 건축해보면서 더더욱 부족한 부분을 알게 되고 수정해나가네요.

그런 부분에서 큰 성장을 하는 것 같아 기분이 좋습니다. :) 






| 메모리 브레이크포인트


메모리 브레이크포인트(Memory Breakpoint)는 사실 브레이크포인트가 아니다. 디버거가 메모리 브레이크포인트를 설정하면 해당 메모리 영역이나 페이지에 대한 접근 권한이 변경된다. 메모리 페이지는 운영체제가 처리하는 가장 작은 단위의 메모리 크기다. 메모리 페이지가 할당되면 그곳에 대한 접근 권한이 부여된다. 메모리 페이지에 부여되는 접근 권한은 다음과 같다.



- 페이지 실행(Page Execution) 이 권한이 할당된 메모리 페이지는 실행시킬 수 있다. 하지만 이 메모리 페이지에서 데이터를 읽거나 쓰려고 하면 접근 위반 예외가 발생한다.


- 페이지 읽기(Page Read) 프로세스는 이 권한이 할당된 메모리의 내용을 읽을 수 있따. 하지만 데이터를 쓰거나 실행시키려고 하면 접근 위반 예외가 발생한다.


- 페이지 쓰기(Page Write) 이 접근 권한은 프로세스가 해당 메모리 페이지에 데이터를 쓰는 것만 허용한다.


- 보호 페이지(Guard Page) 이 권한이 할당된 페이지에 대해 어떤 종류의 접근이라도 발생하면 예외를 발생시킨다. 예외를 발생시킨 이후에는 페이지의 원래 상태로 복귀된다.



대부분의 운영체제는 이와 같은 접근 권한들을 지원한다. 예를 들어 메모리상에는 읽고 쓰는 것이 가능한 메모리 페이지가 있을 수 있고 읽고 실행하는 것이 가능한 또 따른 메모리 페이지가 있을 수 있다. 또한 각 운영체제는 특정 메모리 페이지의 접근 권한을 질의하거나 접근 권한을 원하는 대로 변경시킬 수 있는 내장 함수를 제공한다. 



메모리의 접근 권한 중에서 우리의 관심사는 바로 보호 페이지(Guard Page) 권한이다. 이 접근 권한은 스택에서 힙을 분리해내거나 특정 메모리 영역이 어떤 범위 이상으로 커지는지 확인하는데 유용하다. 또한 특정 메모리 영역에 대한 접근이 발생할 때 프로세스를 중지시키고자 할 때 매우 유용하게 사용된다. 


예를 들어 네트워크 서버 어플리케이션을 리버스 엔지니어링할 때 어플리케이션에 전달된 패킷의 페이로드가 저장되는 메모리 영역에 메모리 브레이크포인트를 설정할 수 있다. 이렇게 하면 브레이크포인트를 설정한 메모리에 대한 접근이 발생했을 때 CPU가 보호 페이지 디버그 예외를 발생시키기 때문에 어플리케이션이 전달된 패킷의 내용을 언제, 어떻게 사용하는지 판단할 수 있게 된다. 그리고 해당 메모리 페이지에 접근하는 명령을 조사해 어플리케이션이 패킷의 내용으로 어떤 작업을 수행하는 것인지 알아낼 수 있다. 메모리 브레이크포인트는 실행되는 어떤 코드도 변경하지 않기 때문에 소프트 브레이크포인트가 갖고 있는 코드 변경으로 인한 제약을 극복할 수 있다.











▲ 위와 같이 실행 코드를 일시 중지시키는 기능의 '브레이크포인트'



실행 중인 디버깅 대상 프로세스를 멈추게 하려면 브레이크 포인트를 설정한다. 브레이크포인트에 의해 프로세스가 일시 중지되면 중지된 시점의 변수나 스택 파라미터, 특정 메모리 위치의 값들을 조사해 볼 수 있다. 프로세스를 디버깅할 때 가장 흔히 사용하는 기능이 바로 브레이크포인트다. 브레이크포인트는 세 가지 종류가 있는데, 1) 소프트 브레이크포인트, 2) 하드웨어 브레이크포인트, 3) 메모리 브레이크포인트다. 세 가지 브레이크포인트 모두 유사한 동작을 수행하지만 구현되는 방법은 완전히 다르다.


1) Soft Breakpoint (소프트 브레이크포인트)

2) Hardware Breakpoint (하드웨어 브레이크포인트)

3) Memory Breakpoint (메모리 브레이크포인트)










▲ Raspberry Pi 의 모습 (모델 B)



그림과 같이 라즈베리 파이는 싱글보드 컴퓨터인 만큼 CPU, GPU, 메모리, 입출력 장치를 모두 가지고 있는 완전한 PC다. 메인 칩으로는 700Mhz으로 동작하는 ARM11을 코어로 한 Broadcom의 BCM2835 SoC 멀티미디어 프로세서를 탑재하고 있다. 이는 CPU, GPU, RAM, 오디오, USB, GPIO 기능을 수행한다. 또한,USB허브 및 랜(LAN) 컨트롤러를 담당하는 LAN9512 칩을 사용하여 네트워크와 기타 장비를 제어하고 있다. BCM2835는 H.264 화상 압축용 프로세서와 3D 그래픽 엔진도 탑재되어 있어서, 1080p(1920x1080) Full HD 화상도의 멀티미디어 재생이 특징이다. 또한, ARM11(ARM1176JZ-F)는 RISC(Reduced Instruction Set Computer) 아키텍처를 채택하고 있기 때문에 소비전력이 매우 적은 것도 특징이다.



▼ 모델 A와 B의 외관상 차이




▼ 라즈베리파이 모델별 스펙 정리 표


 

 모델 A 

 모델 B 

 가격

 25 USD

 35 USD 

 SoC(System on Chip)

 Broadcom BCM2835 SoC

 CPU

 700Mhz Low Power ARM1176JZ-F 

 CPU

 Dual Core VIdeo Core IV 

 메모리

 256MB SDRAM

 512MB SDRAM 

 내장메모리 

 SD, MMC, SDIO 

 이더넷 

 없음 

 10/100 이더넷 RJ45 잭 

 USB 포트 

 1 개 

 2 개 

 비디오 출력 

 HDMI, 컴포지트 RCA, LCD 연결을 위한 DSI 포트 

 오디오 출력

 3.5mm 오디오 출력단자 및 HDMI 

 기타기능 

 GPIO, I2C, SPI, 카메라 연결을 위한 CSI 포트 

 운영체제 

 Linux(Raspbian, Arch Linux ARM, RISC OS 등) 

 전원

 MicroUSB 포트를 통한 5V 전원공급 

 전력

 500mA(2.5W)

 700mA(3.5W)

 크기

 8.6 x 5.4 x 1.5 cm 

 8.6 x 5.4 x 1.7 cm 






일반적으로 C 포인터로 큰 규모의 메모리를 다룰 때 메모리가 조각화되어 관리된다. 즉, 인접한 메모리를 할당하는 것이 아니라, 적당하게 힙 메모리에서 떨어진 구역의 메모리들을 가져와서 마치 하나로 연결되어 있고 인접되어 있듯이 사용하는 것이다. 


그렇다면 한 번에 인접한 메모리를 할당하려고 한다면 어떻게 해야 할까?

이 포스트에서, C언어로 다차원 배열을 할당하는 경우 메모리를 인접하게 할당하는 방법에 대해서 다룰 것이다.




| 일반적인 코딩 방법


우리는 일반적으로 다차원 배열을 동적으로 할당할 때, 다음과 같이 코딩을 한다.


 int rows = 2;
 int columns = 5;
 int i=0, j=0;

 int ** matrix = (int**) malloc(rows*sizeof(int*));

 for(i=0; i<rows; i++){
         matrix[i] = (int*)malloc(columns * sizeof(int));
 }


그런데, 이렇게 해버리면 다음과 같이 결과가 나타난다.




▲ [0][4] 원소와 [1][0] 원소 사이에 16바이트 갭이 생겼다.


첫 번째 행의 마지막 원소와 두 번째 행의 첫번쨰 원소의 주소가 int 변수의 크기인 4 byte만큼 차이가 나는 것이 아니라 16 byte가 차이난다. (위 숫자들은 전부 16진수임을 알기를 바란다. 40에서 50으로 갔음은 10바이트가 아니라 16바이트가 차이났음을 의미한다.)


이제, 해결책에 대해서 알아보자.


2차원 배열에 인접한 메모리를 할당하는 두 가지 접근 방법이 있다. 첫 번째 기법은 '바깥쪽' 배열을 먼저 할당한 후 전체 열에 대한 메모리를 할당하는 방법이고, 두 번째 기법은 모든 메모리를 한 번에 할당하는 방법이다. 






| 첫 번째 방법


첫 번째 기법을 다음 예제 코드에서 설명하고 있다. 첫 번째 malloc 함수 호출로 정수에 대한 포인터의 배열을 할당한다. 각 요소는 열에 대한 포인터를 가지게 된다. 두 번째 malloc 함수 호출은 우리가 선언할 다차원 배열의 제일 첫 번째 원소의 주소에 모든 요소에 대한 메모리를 할당하게 된다. (말이 어렵다면, 그냥 배열의 이름에다가 이후에 저장할 모든 메모리의 크기를 한 번에 할당한다고 생각하자.) 그리고 for 루프는 첫 번째 배열의 각 요소에 두 번째 malloc에서 할당한 메모리의 일부분을 지정한다.


int rows = 2;
int columns = 5;
int i=0, j=0;

int ** matrix = (int**)malloc(rows*sizeof(int*));
matrix[0] = (int*)malloc(rows*columns*sizeof(int));
for(i=1; i<rows; i++)
        matrix[i] = matrix[0] + i*columns;



엄밀히 따지면, 첫 번째 배열의 메모리는 배열의 '본체(body)' 부분과 떨어져 있을 수도 있다. 하지만 배열의 본체 부분에서는 메모리의 인접한 영역이 할당된다. 



▲ 배열의 모든 요소들이 인접하게 나열됬다.





| 두 번째 방법


다음 예제 코드에서는 두 번째 기법을 설명하고 있다. 


int * matrix = (int*)malloc(rows*columns*sizeof(int));


결과는 다음과 같다.



▲ 배열의 모든 요소들이 인접하게 나열됬다.


나중에 코드 안에서 이 배열을 참조할 때는 배열 첨자를 사용할 수 없다. 대신, 다음 코드에서 설명하는 것처럼 배열에 대한 인덱스를 수동으로 계산해야 한다. 각 배열 요소는 그 인덱스의 곱으로 초기화한다. (컴파일러가 배열 첨자를 허용할 때 필요한 배열의 형태에 대한 정보가 없으므로 배열 첨자를 사용할 수 없기 때문에 배열 첨자를 사용할 수 없는 것이다.)


int i=0, j=0;

for(i=0; i<rows;i++){
        for(j=0; j<columns; j++){
                *(matrix+(i*columns)+j) = i*j;
        }       
} 


2차원 배열에 대한 인접한 메모리를 할당하는 두 가지 일반적인 접근 방법에 대해 설명했다. 어떤 방법을 이용할 것인지는 어플리케이션에 따라 다르다. 하지만 두 번째 접근 방법이 '전체'배열에 대한 단일 메모리 블록을 생성한다.



What is a dangling pointer? - Stack Overflow

 

포인터가 여전히 해제된 메모리 영역을 가리키고 있다면, 이러한 포인터를 댕글링 포인터(Dangling Pointer)라고 한다. 댕글링 포인터가 가리키는 메모리는 더는 유효하지 않다. 댕글링 포인터는 premature free(조숙한 해제, 너무 급한 해제)라고 부르기도 한다.

 

 

 

댕글링 포인터의 사용은 아래 목록에 나열된 문제를 포함한 다양한 문제를 야기한다.

 

 

 

- 메모리 접근시 예측 불가능한 동작

 

- 메모리 접근 불가 시 Segmentation fault

 

- 잠재적인 보안 위험

 

 

 

이러한 유형의 문제는 다음과 같은 동작의 결과로 발생한다.

 

 

 

- 메모리 해제 후, 해제된 메모리에 접근

 

- 함수 호출에서 자동 변수를 가리키는 포인터의 반환

 

 

 

 

< 댕글링 포인터 예제 >

 

int * pi = (int * )malloc(sizeof(int)); *pi = 5;  printf("*pi: %d\n", *pi); free(pi);

 

 

free 함수로 메모리를 해제한 후에도 변수 pi는 여전히 메모리의 주소를 가리키고 있다. 그러나 이 메모리는 힙 관리자에 의해 재사용되거나 기존의 정수가 아닌 다른 타입으로도 사용될 수 있다. free 함수를 호출하면 원래 pi 포인터가 가리키고 있던 주소에 위치한 메모리는 해제되며 다시는 사용할 수 없다. 그러나 대부분의 런타임 시스템에서 해제 뒤에 발생하는 메모리의 접근이나 변경을 막지 않는다. 아래 코드에서 보듯이 여전히 해당 메모리에 접근하여 쓰기를 시도할 수 있으며, 이러한 시도의 결과는 예측할 수 없다.

 

 

free(pi); *pi = 10; 

 

 

하나 이상의 포인터가 같은 메모리 영역을 가리키고 그 중 하나가 해제된 경우에는 좀 더 복잡하다. 아래 코드처럼 변수 p1과 p2는 둘 다 같은 메모리 영역을 가리키고 있으며, 이러한 상황을 포인터 에일리어싱(Aliasing)이라고 한다. 그런데, p1이 해제되었다.

 

 

int * p1 = (int *)malloc(sizeof(int)); *pi = 5; ... int * p2; p2 = p1; ... free(p1); ... *p2 = 10;          // p2는 댕글링 포인터이다. 

 

 

또 다른 예제가 있다. 아래 코드와 같이 블록 구문을 사용할 때도 다른 미묘한 문제가 발생한다. 변수 pi에는 tmp의 주소가 할당되며, 변수 pi는 전역 변수이거나 로컬 변수이다. 그러나 변수 tmp는 블록 안에서 선언되고 블록 구문이 닫힐 때 스택에서 제거되며, tmp의 주소는 더는 유효하지 않다.

 

 

int *pi; ... {     int tmp = 5;     pi = &tmp; } // 이 위치에서 pi는 댕글링 포인터가 된다. foo(); 

 

 

 

 

대부분 컴파일러는 블록 구문을 스택 프레임으로 다룬다. 변수 tmp는 블록 안의 스택 프레임에 할당되며, 이어서 블록 구문이 종료되면서 스택 프레임이 제거된다. 블록의 스택 프레임으로 사용된 메모리 영역은 나중에 다른 방식으로 재사용(예제에는 foo함수가 호출되므로 foo함수에 의해 재사용)되며, 변수 pi는 여전히 그 위치를 가리키고 있게 된다.

 

 

 

 

< 댕글링 포인터 다루기 >

 

 

 

포인터가 원인인 문제들의 디버깅은 떄로 해결하기 어려울 때가 있다. 댕글링 포인터 문제를 처리하기 위한 몇 가지 접근 방법을 아래에 나열하였다.

 

 

 

- 메모리 해제 후 포인터를 NULL로 설정하여라.

 

NULL로 설정한 포인터를 그 이후에 사용하면 애플리케이션이 종료할 것이다. 그러나 해당 포인터에 대한 다수의 복사본이 존재할 경우 문제는 여전히 발생한다. 포인터에 NULL을 설정하는 일은 많은 포인터 복사본 중에 단 하나의 포인터에만 영향을 미치기 때문이다. 이와 비슷한 문제를 앞 절 "이중 해제"에서 언급한 적이 있다.

 

- free 함수를 대체할 새로운 함수를 작성하여라.

 

- 몇몇 런타임 시스템이나 디버깅 시스템은 해제된 메모리를 특별한 값으로 덮어쓴다.

 

(예를 들어, 0xDEADBEEF - Visual Studio는 해제된 메모리의 종류에 따라 0xCC, 0xCD, 0xDD 값을 사용하여 덮어쓴다). 예외가 발생하지 않은 상황이라도 프로그래머는 예상치 못한 곳에 이러한 값이 포함된 것을 보고 프로그램이 해제된 메모리에 접근한 것을 알 수 있다.

 

- 댕글링 포인터와 다른 문제들을 발견하기 위해 서드파티 도구들을 사용하라.

 

 

 

 

< 메모리 누수 탐지 기능 >

 

마이크로소프트는 동적으로 할당된 메모리를 덮어쓰는 문제와 메모리 누수 문제를 해결하기 위한 기술을 제공하며, 이 접근 방식은 프로그램의 디버그 버전에서 아래에 나열된 특별한 메모리 관리 기술을 사용한다.

 

 

 

- 힙의 무결성 검사

 

- 메모리 누수 검사

 

- 힙 메모리가 부족한 상황 재현

 

 

 

마이크로소프트는 메모리 할당을 관리하기 위한 특별한 데이터 구조체를 사용한다. 그리고 이 구조체의 사용으로 위와 같은 메모리 관리 기술을 제공한다. 이 구조체는 malloc 함수가 호출된 파일명과 줄 번호와 같은 디버그 정보를 관리한다. 게다가, 메모리를 덮어쓴느 문제를 찾기 위해 메모리 할당 전후로 버퍼가 할당된다. 이 기술에 대한 추가적인 정보는 Microsoft Developer Network(http://bit.ly/12SftWV)에서 찾을 수 있다.

 

 

 

Mudflap 라이브러리(http://bit.ly/YilPI1) 사용하면 GCC에 비슷한 기능을 사용할 수 있다. Mudflap의 런타임 라이브러리는 수많은 기능을 제공하며 그중에서도 특히 메모리 누수 탐지 기능이 제공된다. 이 메모리 누수 탐지 기능은 포인터 역참조 연산들을 계산하고 측정하는 방식으로 수행된다.

 

 

 

 

 

/// 이 글의 출처는 "Understanding and Using C Pointers(리처드 리스 지음)"입니다.

 

This post is some part from the book called "Understanding and Using C Pointers".

+ Recent posts