메모리 누수 디버깅
Computing 2007. 10. 18. 21:57
원문 보기
일반적으로 가장 잡기 힘든 버그의 하나로서 메모리 누수, 메모리 Overwrite등을 꼽을 수 있다. 이런 문제점을 해결하기 위해 CRT(C Runtime library)에서는 여러가지 다양한 메모리 관련 디버그 함수들을 제공한다. 그러나 이것들이 디폴트로 사용하기 힘들게 꺼져 있기 때문에 대부분의 프로그래머들은 이 사실을 알지 못하는 경우가 많다. 그래서 이 글에서는 CRT의 디버그 관련 함수들에 대해 알아보고 어떻게 사용하는 것이 좋은지에 대해 논해 보려고 한다.
John Robbins(필자가 가장 좋아하는 프로그래머 중의 한명)가 지은 Debugging Applications 이라는 책에도 좋은 내용들이 있으니 참고하기 바란다. 그러나 여기 나온 팁은 그 책에는 나와 있지 않은 것이다. Numega Bounds Checker나 Rational Purify등의 툴이 비싸서 엄두도 못내는 분들께는 좋은 내용이 되리라 믿는다
메모리 누수
개요
CRT가 출력해 주는 메모리 누수 리포트 결과를 간혹 본적이 있는 사람도 있을 것이다. 만약 MFC를 주로 사용한다면 이와 같은 메시지를 자주 볼 수 있다. 왜냐하면 MFC에서는 기본적으로 CRT 디버그 기능을 켜기 때문이다. 메시지 내용은 다음과 같다
Dumping objects ->
{65} normal block at 0x003748B0, 48 bytes long.
Data: <@H7 @H7 @H7 > 40 48 37 00 40 48 37 00 40 48 37 00 CD CD CD CD
{64} normal block at 0x00374840, 48 bytes long.
Data: < H7 H7 H7 > B0 48 37 00 B0 48 37 00 B0 48 37 00 CD CD CD CD
Object dump complete.
이 메시지의 내용은 두개의 블록이 할당된다음 해제되지 않았다는 것을 의미한다. 그렇다면 우선 이 메시지들에 대해 대략 알아보도록 하자
우선 {64}, {65}와 같은 것은 메모리에 할당된 순서로서, 각각 64번째, 65번째 할당된 메모리가 해제되지 않았음을 나타낸다. Normal block이라 함은 사용자에 의해 일반적인 new, delete, malloc등으로 할당된 메모리라는 뜻이다. 그리고 그 메모리 포인터 주소를 알려주고 있으며, 몇 바이트짜리 메모리 블록인지도 나와 있다
Data:… 라인은 그 메모리의 선두번지로부터 실제 메모리의 내용이 어떤 값을 포함하고 있는지를 나타내는 것이다
그러나 위의 메시지를 보았듯이 실제로는 아무 도움이 되지 않는다는 것을 금방 알수 있을것이다. 왜냐하면 메시지의 내용이 너무 암호와같이 복잡하다는데 문제가 있다. 몇줄짜리 프로그램이라면 또 모르겠으나, 거대한 프로그램인 경우 실제로 64,65번째 할당된 메모리를 순서대로 추적하는 것은 너무나도 어려우며, 어떤 포인터에 어느 번지의 메모리가 할당되었는지를 확인하는 것도 불가능에 가깝다고 할 수 있다. 또 자신이 만든 클래스나 구조체가 몇 바이트짜리인지를 일일이 확인한다는 것도 불가능하다.
그렇다면, 이 문제를 어떻게 해결해야 할 것인가? 실제로 CRT에는 여러가지 도우미 함수들을 포함하고 있어서 이 문제를 좀 더 쉽게 해결할 수 있도록 해 준다. 그러나 제대로 알고 사용하지 않는다면 결국에는 또다른 암호 코드만을 추가로 더 얻게 될 뿐이라는 것도 알아야 한다
샘플코드
#include "stdafx.h"
int main()
{
int *p = new int;
return 0;
}
위의 코드는 int *를 할당하고 해제하지 않았으므로 명백한 메모리 누수이다. 그러나 이 프로그램을 빌드하고 디버그해봐도 프로그램 종료시 아무런 메시지를 남기지 않는다. 즉 메모리가 샌것인지, 혹은 Overwrite가 일어났는지 등을 확인할 길이 전혀 없다. 그러면 프로그램 일부를 수정해 보도록 한다
#include "stdafx.h"
#include
int main()
{
int *p = new int;
_CrtMemDumpAllObjectsSince(0);
return 0;
}
_CrtMemBumpAllObjectsSince를 사용하기 위해 crtdbg.h를 인클루드 하고, 프로그램 종료 직전에 함수를 호출했다 디버그를 시작하고 프로그램을 종료하면 출력결과는 다음과 같다
Dumping objects ->
{64} normal block at 0x00374838, 4 bytes long.
Data: < > CD CD CD CD
Object dump complete.
The thread 0x65C has exited with code 0 (0x0).
The program 'test.exe' has exited with code 0 (0x0).
위에서 설명한것과 비슷한 종류의 메시지가 포함되어 있는 것을 알 수 있을 것이다. 이 메모리는 64번째 할당된 일반 메모리이며 0x00374838번지에 4바이트 할당되었음을 알 수 있다. 또 데이터 내용은 16진수로 CD CD CD CD이다 이 정보만으로도 많은 것을 알 수 있다. 예를들어 데이터가 CD CD CD CD라는 것은 할당만 해놓고 전혀 초기화를 하지 않았다는 의미이다. 단순한 위의 프로그램 만으로도 사용자가 처음 할당한 메모리가 64번째만에 할당되었다. 이유가 무엇일까? 이유는 간단하다. main함수가 호출되기 이전에 이미 많은 메모리 할당 요청이 있었고, 그것은 프로그램을 실행시키기 위해 운영체제나, CRT가 이미 사용했기 때문이다. 위의 프로그램은 단순하기 때문에 어디서 메모리가 샜는지 한눈에 척 알 수 있다. 그러나 메모리를 수십~수백번씩 할당했다 해제하는 일반 애플리케이션에서는 어떻게 정확히 64번째 할당된 메모리를 찾아낼 수 있을까? CRT에서 내준 정보는 CRT를 이용해 분석 가능하다 main함수가 처음 시작되기 전에 다음의 함수를 사용하도록 하자
…
_CrtSetBreakAlloc(64);
int *p = new int;
…
이 함수는 64번째 메모리 할당이 발생할 경우 프로그램 실행을 중지하고 디버거로 제어를 넘기라는 의미이다. 실제로 프로그램을 실행시켜 보면, “crtdbg.exe의 0x00411cb7에 처리되지 않은 예외가 있습니다. 사용자 중단점”과 같은 메시지를 출력하면서 프로그램 실행을 중지하게 된다. 브레이크 포인트가 가리키는 위치는 CRT의 메모리 할당 함수 내부이며, Call Stack을 따라가 보면 어느곳에서 할당이 일어났는지 바로 알수 있게된다.
전역 변수와 CRT 디버깅
다음과 같은 프로그램을 보도록 하자
#include "stdafx.h"
#include "crtdbg.h"
#include
class A
{
public:
A() { p = new int; }
private:
int *p;
};
A a;
int APIENTRY _tWinMain(HINSTANCE hInstance,
HINSTANCE hPrevInstance,
LPTSTR lpCmdLine,
int nCmdShow)
{
_CrtSetBreakAlloc(64);
_CrtMemDumpAllObjectsSince(0);
return 0;
}
이 프로그램을 디버그 해 보면 똑 같은 메모리 누수를 보고하긴 하지만, 그러나 64번째 메모리 할당에서 정지하지 않는다. 이유가 무엇일까?
이유는 간단하다. A라는 클래스의 인스턴스인 a가 전역변수로 선언되었기 때문에 main함수가 호출되기 이전에 생성되기 때문이라는 것이다. a가 이미 생성되고 난 다음에 브레이크 포인트를 설정하니, 브레이크 포인트가 먹힐리가 없다. 그럼 방법이 없는 것일까?
답은 간단하다. 모든 할당이 일어나기 직전에 프로그램을 정지시켜놓고, 64번째 메모리 할당이 일어날 때 브레이크 하라는 명령을 주면 된다. 그럼 어떻게 하면 될까? 다음과 같이 하도록 한다.
CRT소스에서 WinMainCRTStartup()함수를 찾아낸다. 이 함수는 실질적인 main함수이며, 프로그램이 로드되기 전에 가장먼저 실행된다. 이 함수 내부에서 여러분이 정의한 main 또는 WinMain함수를 호출하게 된다. 이 함수는 파일의 찾기 기능을 이용하거나, 또는 crt0.c파일을 바로 열어서 찾아도 된다. 그러나 더 간단한 방법은 main함수에 BP를 찍어놓고, 한번 실행시킨다음 call stack을 거슬러 올라가는 방법이다.
WinMinCRTStartup함수의 시작부분에 BP를 찍어놓고 다시 디버거를 시작시킨다
Watch창을 열어 _crtBreakAlloc 변수를 확인해 본다. 아마 -1일 것이다.
이 변수값을 원하는 메모리 할당 번지(위의 경우64)로 바꾼다.
다시 실행시키면 64번째 메모리 할당을 하기 전에 정지한다.
이 기술은 코드를 재 컴파일 하지 않아도 디버거 상에서 바로 브레이크 포인트를 수정할 수 있다는 장점이 있다. 현재 이 방법 보다 간단하게 할 수 있는 방법은 현재 연구중에 있다. 좋은 결과가 나오는대로 다시 여러분들에게 알려드리도록 하겠다.
이상 몇가지 메모리 누수를 찾아내는 방법을 살펴보았다. 그러나 주의할 것은 반드시 crtdbg.h를 같이 인클루드 해야 한다는 것이며 _DEBUG매크로가 정의되어 있을때에만 제대로 동작한다는 것이다.
CRT Debug 기능 사용법 2
요즘 CRT의 디버그 기능을 연구하기 시작하면서, 그동안 정말 좋은 기능들을 여럿 묵혀놓았다는 느낌을 지울수가 없습니다. 어렵게 메모리 관련 디버깅 루틴을 만들지 않아도, 너무나도 정확히 메모리 관련 에러를 잡아주니 STL을 처음 쓸 때 만큼이나 편리하게 느껴지더군요. 그럼 그동안 제가 연구한 것에 대해 보고드리도록 하겠습니다
지난 내용의 메모리 누수가 아닌 것 까지 모두 보고하는 문제
지난번 예제에서 약간만 더 수정해 보자
#include "stdafx.h"
#include
#include
using std::list;
typedef list
typedef IntList::iterator IntListIter;
IntList myList;
int main()
{
_CrtMemDumpAllObjectsSince(0);
return 0;
}
위의 프로그램은 메모리 누수를 한 개 보고한다. 위치는 myList의 생성자이다. 그러나 정말 그것이 샌것일까? 그렇다면 STL은 항상 메모리 누수가 있다는 말인가.. 이것저것 고민하던 중에, 진짜 Main함수는 {{{WinMainCRTStartup()}}}라는 사실이 생각났고, 디버그 상에서 {{{WinMainCRTStartup()}}} 메소드의 끝까지 따라가 보았다. 그랬더니 다음과 같은 루틴을 찾을 수 있었다. (처음에는 숨겨진 함수를 찾았다고 생각했으나, 알고봤더니 MSDN에 이미 문서화 되어있는 함수였다. 역시 소스보다는 문서를 찾아보는 것이 우선이다 -.-)
{{{
/* Dump all memory leaks */
if (!fExit && _CrtSetDbgFlag(_CRTDBG_REPORT_FLAG) & _CRTDBG_LEAK_CHECK_DF)
{
fExit = 1;
_CrtDumpMemoryLeaks();
}
_CrtSetDbgFlag이라는 함수는 CRT디버거의 전역 플랙을 셋팅하는 함수이다. 위에서 보다 시피 이미 CRT의 메인함수가 종료할 때 메모리 누수를 검사하고 있었던 것이다. 다만 디폴트로 꺼져 있었으며 열쇠는 _CrtSetDbgFlag이라는 함수가 쥐고 있다. MSDN에서 찾아본 결과 다음과 같다. _CrtSetDbgFlag함수는 다섯개의 Flag이 있다.
_CRTDBG_ALLOC_MEM_DF : 디폴트로 켜져 있으며, 디버그 버전에서 모든 메모리 할당이 일어날 때마다 추적 가능하도록 특별한 기능을 추가해 둔다. 이 플랙이 켜져 있어야 메모리 누수를 안전하게 검사 할 수 있다.
_CRTDBG_DELAY_FREE_MEM_DF : delete, free등으로 삭제되어도 바로 삭제되지 않고, CRT의 메모리 관리소에 남아 있다가 프로그램 종료시에 완전히 삭제된다.
_CRTDBG_CHECK_ALWAYS_DF : 모든 메모리관련 연산에서 _CrtCheckMemory를 호출한다. 이 메소드는 이후에 다시 살펴볼 것임
_CRTDBG_CHECK_CRT_DF : CRT가 내부적으로 할당한 블록도 메모리를 체크할 때 포함한다. 일반적으로는 CRT가 할당한 블록은 메모리 체크에서 제외된다. 일반적으로 사용하지 않는다
_CRTDBG_LEAK_CHECK_DF : 프로그램이 완전히 종료되기 직전에 아직 해제되지 않은 메모리가 있는지 검사한다. 프로그램의 종료 포인트가 여러군데 있는 경우에 사용하면 일일이 _CrtDumpMemoryLeaks 메소드를 호출하지 않아도 자동적으로 메모리 누수를 검사할 수 있게된다.
이 중에서 첫번째 플랙만을 제외하고는 모두 디폴트로 꺼져있다. 그러므로 다음과 같이 메모리 검사 기능을 켜도록 한다
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
위의 프로그램에서는 다음을 삭제한다
_CrtMemDumpAllObjectsSince(0);
_CrtMemDumpAllObjectsSince 함수는 실제로는 특정 지점에서 지점간에 할당되어 있는 메모리들을 보고해 주는 함수이다. 인자로 0을 넘겨주면 처음부터 지금까지 할당되어 있는 메모리들을 보고해 준다. CRT가 아직 전역으로 할당된 메모리를 완전히 삭제하기도 전에 호출했기 때문에 STL의 메모리가 샌 것 처럼 보인것이다.
CRT 메모리 블럭
다음으로 넘어가기 전에 다음을 짚어보고 넘어가기로 하자. 디버그 버전에서는 메모리가 할당되거나 사용되기 직전에 특정한 값들로 할당된 메모리가 채워진다는 것을 알고 계실것이다. 의미는 다음과 같다
0xFD : 메모리 블록 양 끝의 버퍼에 생성된다
0xCD : 새로 생성된 버퍼에 저장되는 기본값이다
0xCC : 스택에 변수가 생성되면 우선 이값으로 채워진다
0xDD : 삭제된 메모리 블록은 이 값으로 채워진다
다음 예제를 보자
int main()
{ // 함수 시작지점
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
int *p = new int [10]; // 메모리가 할당되는 지점
printf("%d", *p);
delete [] p; // 메모리가 삭제되는 지점
return 0;
}
함수 시작지점에서 포인터 p는 초기값으로 0xCCCCCCCC를 갖게 된다. 디스어셈블 해보면 모든 지역변수들을 0xCC로 채워넣는 것을 볼 수 있다. 이후 메모리가 할당되는 지점에서 아마도 sizeof(int) * 10 바이트의 메모리가 할당될것이다. Sizeof(int)가 4바이트라면(32비트 운영체제에서) 40바이트가 생성되는 것이다.(디버그 버전이라는 가정하에) 이렇게 생성된 40바이트에 0xCD값이 채워진다. 만약 어떤 포인터의 값을 읽었을 때 값이 0xCDCDCDCD와 같은 형식이거든 초기화안된 메모리를 읽었다고 생각하면 된다.
그런데, 위의 할당지점에서 실은 48바이트가 생성된다. 40바이트 할당 명령을 주었는데 왜 48바이트의 메모리가 할당되었는가 이유는 위의 0xFD값에 있다. 40바이트의 블록을 생성시키고 그 블록의 앞과 뒤에 각각 0xFDFDFDFD를 삽입시켰기 때문이다. 이 값이 채워지는 이유는 다음과 같이 접근하는 경우
p[-1] = 10; // underwrite
p[11] = 10; // overwrite
0xFD로 채워진 메모리의 일부분이 깨질 것이고, 나중에 사용할 메모리 체크 명령에 의해 overwrite/underwrite가 발생했다는 사실을 알 수 있게된다.
마지막으로 메모리가 삭제되는 지점에서는 이론 대로라면 p값이 0xDD로 채워질 것이다. 그러나 실제로 필자의 컴퓨터에서는 0xFEEE가 채워졌다. 왜 그런지는 좀더 연구해 보고 알려드리도록 하겠다. -.- 오늘은 일단 여기까지 접고 연구결과가 더 나오는대로 여러분께 보고하는 시간을 갖도록 하려한다.
CRT Debug 기능 사용법 3
지난번에 이어 이번에는 메모리에 어떤 문제가 있었는지를 체크해주는 _CrtCheckMemory함수에 대해 연구해 보도록 하겠습니다.
강좌를 진행하기 전에 지난번 강좌 마지막 부분에서 메모리가 삭제될 때 0xDD값 대신에 0xFEEE값이 채워지는 이유를 찾아본 결과 CRT에서는 0xDD값을 정확히 채워 넣는다는 것을 확인하였다. _CRTDBG_DELAY_FREE_MEM_DF 플랙이 디폴트로 꺼져 있기 때문에 삭제했다는 표시를 하고 난 다음 바로 운영체제에서 삭제(진짜 삭제) 해버렸기 때문이다. 이부분은 뒤에서 다시 알아보도록 하겠다.
_CrtCheckMemory()
지난번에 알아보기를 CRT에서 메모리를 할당하려 할 때 몇가지 정보블럭들을 설정한다는 것을 알았을것이다. 자 이제는 설정된 정보블럭들을 검사할 차례이다. 문제는 일일이 디버거의 Watch창이나 Memory창을 이용해 블록이나 스택이 깨졌는지를 확인해야 한다는 것이다. 이런 기법은 아마도 잘 알고 있을 것이다. 특정 메모리 주소를 가르키게 해놓고 의심되는 코드를 실행시켰을 때 Memory창의 내용이 빨간색으로 변하는 모양을 살펴서, 엉뚱한 부분까지 쓰거나, 원치않은 결과를 얻는지를 확인하는 것이다. 문제는 이것이 너무나도 수동적이기 때문에 CRT에서는 _CrtCheckMemory라는 도우미 함수를 곁들였다. 이 함수는 몇가지 경우에 있어서는 아주 쓸만하다. 사용법을 보자
int _tmain(int argc, _TCHAR* argv[])
{
if(_CrtCheckMemory() == 0)
return -1;
return 0;
}
그냥 메모리 체크를 하기 원하는 위치에 _CrtCheckMemory함수를 삽입하기만 하면된다. 만약 0을 리턴한다면 할당된 메모리에 어떤 문제가 발생했다는 것을 의미한다. 그러나 위의 코드는 문제가 하나 있는데 바로 모든 CRT의 디버그 함수들은 DEBUG버전에서만 의미를 가지기 때문에 RELEASE버전에서는 아무 의미없는 코드가 된다는 것이다. 일단 보기에도 두 줄에 걸쳐 표기되어 있으므로 흉하다. 그러므로 다음과 같이 코딩하도록 한다.
_ASSERTE( _CrtCheckMemory( ) );
_ASSERTE는 CRT에서 제공해주는 매크로이다. 또는 assert.h의 assert함수를 이용해도 좋다. MFC등을 사용한다면 ASSERT등을 사용해도 좋고 Robbins의 SUPER_ASSERT를 사용해도 좋다. 각각 약간씩의 차이점이 있기는 결과는 거의 같다. 그러니 여기서는 CRT를 사용한다는 일관성을 유지하기 위해 _ASSERTE를 사용하도록 하겠다.
단순히 위와같이 의심갈때마다 호출해 주기만 한다면, CRT는 지금까지 등록된 모든 할당된 메모리들을 검사해 문제가 발생했는지를 확인한다. 그럼 어떤 종류의 에러들을 잡아주는지 다음의 예제들을 통해 알아보도록 하자
예제1. 경계를 넘어서서 쓰는 경우
int _tmain(int argc, _TCHAR* argv[])
{
int *p = new int[10];
p[-1] = 100; // 둘다 모두 오류이다
p[10] = 100;
_ASSERTE( _CrtCheckMemory( ) );
return 0;
}
위의 예제에서는 정수 타입 10개짜리 배열을 할당하고 11번째 멤버에 쓰기를 시도하였다. 이부분에는 지난 강좌에서 알려 드렸듯이 0xfd값이 채워져 있는 영역이다. 주어진 메모리 체크 함수는 0xfd값이 있어야 할 자리에 다른 값이 있는 경우 0을 리턴한다.
예제2. 삭제된 포인터에 접근을 시도하는 경우
int _tmain(int argc, _TCHAR* argv[])
{
// _CRTDBG_DELAY_FREE_MEM_DF 플랙을 반드시 켜야된다
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF | _CRTDBG_DELAY_FREE_MEM_DF);
int *p = new int[10];
delete [] p;
p[0] = 10;
_ASSERTE( _CrtCheckMemory( ) );
return 0;
}
위 예제에서는 이미 삭제된 포인터를 다시 접근하고 있다. 이것은 디폴트로 비활성화된 플랙을 사용하므로 위의 예제에서처럼 프로그램 시작전에 _CRTDBG_DELAY_FREE_MEM_DF 플랙을 켜줘야한다. 이 플랙이 켜지게 되면 CRT는 삭제 명령(free함수)이 호출되는 경우 바로 삭제처리하지 않는다. 대신 삭제 처리했다는 표시(0xdd값)만을 남겨둔다음 필요할 때 이값이 깨졌는지 검사한다. 디버그 버전이라면 이 플랙은 반드시 켜두도록 한다. 물론 메모리 부하가 약간 더 있겠지만 심각한 오류를 검출하는데는 꼭 필요한 플랙이다. 어차피 디버그 버전은 디버깅이 최고의 목표이니까..
이 두가지 기능만해도 일반적인 프로그래머들이 겪는 대부분의 메모리 문제는 해결된다. 아! 필자가 지금까지 봐온 대부분의 메모리 관련 문제들은 거의 60%이상이 초기화가 되지 않았거나 쓰레기값이 들어있는 포인터 접근 문제였다. 이문제는 위의 함수가 잡아주지 않는다. 그럼 쓰레기값이 들어있는 포인터 접근 문제는 어떻게 해결하겠는가? 아시는 분들은 다 아실것이다 0xC0000005 오류가 바로 정답이다. 또 대부분의 컴파일러의 경우 컴파일하는 도중에 이미 초기화 안된 변수를 사용했다고 여러분들에게 알려줄 것이다.
다음에는 구간과 구간 사이에서 메모리 문제를 발견하는 방법에 대해 다룰 것이다. 전체 프로그램에서 문제를 발견했다면 그 범위를 점차 좁혀나가는 것이 중요하다. 아쉽지만 필자 개인적인 사정으로 인해 조금 늦어질지도 모르겠다
'Computing' 카테고리의 다른 글
[수퍼개발자의 길 ①] 가슴의 꿈을 현실로 만드는 기술 (0) | 2008.08.18 |
---|---|
Filezilla Server 한글문제 해결 (0) | 2008.05.27 |
메모리 할당한 부분 찾기 (0) | 2007.10.18 |
SATA2 하드디스크의 NCQ 기능 사용하기 (0) | 2007.10.16 |
Microsoft Robotics Studio에서 XInputController (0) | 2007.10.10 |