본문 바로가기

진리는어디에/C#

[C#] MiniDump 남기기

들어가며

추리 영화에서 살인 피해자들이 항상 다잉 메시지를 남기듯 우리의 어플리케이션도 어떤 이유로 죽게 되었는지 알릴 수 있는 '코어 덤프'라는 것을 남길 수 있다.

하지만 코어 덤프를 남기기 위해서는 최소한의 작업을 개발자가 해줘야만 한다.이번 포스트에서는 윈도우 플랫폼에서 C#으로 작성된 프로그램에서 코어 덤프를 남길 수 있는 방법에 대해 살펴 보도록 하겠다. 참고로 리눅스에서 코어 덤프를 남기는 방법은 [여기]를 참고하면 된다.

처리 되지 않은 예외

본격적인 내용에 앞서 간단한 개념 정도는 알고 넘어 가도록하자. 굳이 몰라도 코어 덤프를 남기는 데는 문제 없는 내용이니 궁금하지 않다면 다음 섹션으로 바로 넘어가도 괜찮다(바쁘신 분들은 [여기]의 전체 코드를 복붙해서 사용해도 상관 없다).

'예외(exception)'라는 것은 이미 알려진 오류들을 말한다. 예를 들어 C#에서는 널(null) 참조를 하게 되면  어플리케이션에서 NullReferenceException 예외를 발생 시킨다. 이렇게 전달 되는 예외를 프로그래머가 try ~ catch 문을 이용해 처리해주지 않는 경우를 처리되지 않은 예외라고 하며, 이는 어플리케이션의 갑작스런 종료를 야기한다.

처리 되지 않은 예외(unhandled excpetion)는 잠재적인 예외를 처리하지 않을때 발생한다.

아래 예제에서는 예외를 강제적으로 발생 시키고 아무런 처리를 하지 않고 있다. 만일 우리가 작성하는 프로그램 어딘가에서 아래 함수를 호출하게 된다면 프로그램은 즉각 종료 되고 주석과 같은 에러 메시지를 출력한다.

static void ThrowUnhandledException()
{
    int localInt = 1234567890;
    string localStr = "HelloWorld";
    
    string exeName = AppDomain.CurrentDomain.FriendlyName;
    throw new Exception("An Unhanled exception has been detected in the application " + exeName);
}

static void Main(string[] args)
{
    ThrowUnhandledException();
}

/*
Unhandled exception. System.Exception: An Unhanled exception has been detected in the application helloworld_cs
   at Program.ThrowUnhandledException() in D:\helloworld_cs\Program.cs:line 18
   at Program.Main(String[] args) in D:\helloworld_cs\Program.cs:line 25
*/

MiniDumpWriteDump

앞서 우리는 처리되지 않은 예외에 대해 어플리케이션이 강제 종료 되는 것을 살펴 보았다. 위 예제 코드는 간단해서 원인을 쉽게 파악할 수 있지만 프로그램이 조금이라도 복잡해지면 어디에서 어떠한 예외를 처리해서 프로그램이 종료 되었는지 파악하기 힘들다. 이 때 유용하게 사용 될 수 있는 것이 프로그램이 종료 되는 시점의 모든 정보를 담고 있는 코어 덤프를 남기는 것이다.

C++의 경우 윈도우 플랫폼에서 코어 덤프를 남기기 위해서는 간단히 MiniDumpWriteDump 함수를 호출하면 되지만 C#에는 그런 함수를 찾을 수 없었다.

하지만 걱정 말라. 방법이 없다면 이 포스트는 시작도 못 했다. C#에서 C++로 작성된 dll의 함수를 사용할 수 있는 간단한 방법이 있다. 자세한 내용은 잘 정리되어 있는 다른 포스트가 있으니 궁금하신 분들은 [여기]를 참고하도록 하고 본 포스트에서는 코어 덤프를 남기는 것에 집중하도록 하겠다.

가장 먼저 C/C++ 라이브러리의 API를 호출하기 위해선 DllImport 어트리뷰트를 이용해 dll을 임포트하고 함수 시그니쳐에 맞는 static 함수를 선언해줘야 한다.

[DllImport("Dbghelp.dll")]
static extern bool MiniDumpWriteDump(IntPtr hProcess, uint ProcessId, IntPtr hFile, int DumpType, ref MINIDUMP_EXCEPTION_INFORMATION ExceptionParam, IntPtr UserStreamParam, IntPtr CallbackParam);

[DllImport("kernel32.dll")]
static extern IntPtr GetCurrentProcess();

[DllImport("kernel32.dll")]
static extern uint GetCurrentProcessId();

[DllImport("kernel32.dll")]
static extern uint GetCurrentThreadId();

[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct MINIDUMP_EXCEPTION_INFORMATION
{
    public uint ThreadId;
    public IntPtr ExceptionPointers;
    public int ClientPointers;
}

14라인에 StructLayout이라는 어트리뷰트는 MiniDumpWriteDump 함수의 인자로 C/C++ 구조체가 필요해서 C#에서 C/C++과 호환 되도록 구조체의 레이아웃을 정의해준 것이다.

위와 같은 간단한 선언 만으로 우리는 C/C++ 라이브러리의 MiniDumpWriteDump 함수를 C#에서도 호출할 수 있는 준비 작업이 완료 되었다. 다음은 C# 어플리케이션에서 처리 되지 않은 예외가 발생했을 때 호출될 콜백 함수를 작성하고 그 안에서 MiniDumpWriteDump 함수를 호출하는 것이다.

const int MiniDumpNormal = 0x00000000; // 최소한의 스택 정보만 남기는 플래그
const int MiniDumpWithFullMemory = 0x00000002; // 모든 스택 정보와, 스레드, 메모리 상태 정보를 남기는 플래그
    
public static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
{
    // 덤프 파일 이름(각자 원하는대로 변경)
    string dirPath = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location);
    string exeName = AppDomain.CurrentDomain.FriendlyName;
    string dateTime = DateTime.Now.ToString("[yyyy-MM-dd][HH-mm-ss-fff]");

    MINIDUMP_EXCEPTION_INFORMATION info = new MINIDUMP_EXCEPTION_INFORMATION();
    info.ClientPointers = 1;
    info.ExceptionPointers = Marshal.GetExceptionPointers();
    info.ThreadId = GetCurrentThreadId();
    
    {   // 최소한의 스택 정보만 가진 코어 덤프 파일 생성
        string dumpFileFullName = dirPath + "/[" + exeName + "_mini]" + dateTime + ".dmp";
        FileStream file = new FileStream(dumpFileFullName, FileMode.Create);
        MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), file.SafeFileHandle.DangerousGetHandle(), MiniDumpNormal, ref info, IntPtr.Zero, IntPtr.Zero);
        file.Close();
    }

    {   // 스택 정보 뿐 아니라 스레드, 메모리 상태 등 남길 수 있는 모든 정보를 가진 코어 덤프 생성
        string dumpFileFullName = dirPath + "/[" + exeName + "]" + dateTime+ ".dmp";
        FileStream file = new FileStream(dumpFileFullName, FileMode.Create);
        MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), file.SafeFileHandle.DangerousGetHandle(), MiniDumpWithFullMemory, ref info, IntPtr.Zero, IntPtr.Zero);
        file.Close();
    }
}

위 19라인과 26라인에서 동일한 MiniDumpWriteDump 함수를 각각 호출하고 있다. 유일한 차이점은

  • const int MiniDumpNormal = 0x00000000;
  • const int MiniDumpWithFullMemory = 0x00000002;

인자로 넘겨지는 두 플래그 값인데 MiniDumpNormal(0)의 경우 콜스택 정보만을 가진 상대적으로 작은 사이즈의 코어 덤프 파일을 만들기 위해 사용되고 MiniDumpWithFullMemory(2)는 콜스택 정보뿐 아니라 변수, 메모리 상태, 스레드 상태 등등 남길 수 있는 모든 정보를 가진 코어 덤프 파일을 생성하기 위한 플래그다. 외에도 다른 많은 옵션 플래그들이 있으며 그에 대한 자세한 내용은 [여기]를 참고 하도록 하자.

각 타입에 따른 코어 덤프의 차이는 아래에서 계속 살펴 보도록 하겠다.

싱글 스레드 어플리케이션에서 코어 덤프 남기기

마지막 단계는 어플리케이션에서 처리 되지 않은 예외가 발생하는 경우 어플리케이션을 종료하는 대신 우리가 위에서 작성한 콜백 함수를 호출하도록 변경하는 것이다.

C#은 AppDomain.CurrentDomain.UnhandledException 을 이용해 처리 되지 않은 예외가 발생하는 경우 호출 될 콜백 함수를 등록할 수 있다. 등록될 콜백함수는 다음과 같은 시그니쳐를 가져야 한다.

 public static void UnhandledException(object sender, UnhandledExceptionEventArgs e)

앞에서 우리가 작성했던 CurrentDomain_UnhandledException 함수가 위 시그니쳐를 따르고 있다.

static void Main(string[] args)
{
    // 처리 되지 않은 예외가 발생 했을때 호출될 콜백 등록
    AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(CurrentDomain_UnhandledException);

    ThrowUnhandledException();
}

맨 앞에서 우리가 살펴 보았던 예제의 Main 함수의 4라인에  AppDomain.CurrentDomain.UnhandledException 를 이용해 처리 되지 않은 예외가 발생했을 때 호출 될 콜백을 등록하는 코드가 추가 되었다.

프로그램을 빌드하고 실행해보면 프로그램이 실행 되었던 위치에 코어 덤프 파일이 생성 된 것을 확인 할 수 있을 것이다.

빨간 박스로 표시된 확장자가 .dmp인 파일이 예외가 발생했을 당시의 상태 스냅샷을 저장한 덤프 파일이다. 녹색 박스로 표시된 파일의 크기를 보면 파일 이름에 _mini로 표시되어 있는 노멀 미니 덤프 파일이 풀 덤프 파일 보다 훨씬 작은 파일  크기를 가지고 있는 것을 확인할 수 있다.

두 덤프 파일의 차이는 다음 섹션에서 계속 알아가 보도록 하겠다.

MiniDumpNormal 옵션

먼저 상대적으로 작은 사이즈의 노멀 미니 덤프 파일을 더블 클릭 해보자. 비주얼 스튜디오가 구동되며 아래와 같은 화면이 보일 것이다.

'우측 상단의 '관리 전용(으)로 디버그'를 선택하게 되면 코어 덤프 파일을 기반으로 디버깅을 시작한다.

디버거가 시작 되면 프로그램의 어느 위치에서 예외가 발생했는지 콜스택 정보를 이용해 알려준다.

최소한의 정보만을 저장하고 있는 덤프 파일이므로 로컬 변수 localInt, localStr의 값들은 확인할 수 없다. MiniDumpNormal 옵션은 가볍고 작은 대신 최소한의 콜스택 정보만을 가지고 있기 대략적인 예외 발생 위치를 파악하는것 외에 정확한 예외 상황에 대한 정보를 얻기는 힘들다.

MiniDumpWithFullMemory 옵션

그렇다면 이제 풀 덤프를 확인해 보도록 하자. 방법은 동일하다. 덤프 파일을 더블 클릭하고 미니 덤프 파일 요약 화면에서 관리 전용(으)로 디버그를 실행 한다.

기본적인 내용은 동일하지만 왼쪽 하단 로컬 조사식 창에서 이제는 변수들에 들어 있는 값까지 확인 가능하다.

멀티 스레드 어플리케이션에서 코어 덤프 남기기

앞에서 AppDomain.CurrentDomain.UnhandledException를 이용해 메인 스레드에서 발생하는 처리 되지 않은 예외에 대한 콜백을 등록하는 것을 살펴 보았다. 이번에는 멀티 스레드에서 발생하는 예외에 대해 코어 덤프를 남기는 방법에 대해 살펴 보도록 하자.

static void Main(string[] args)
{
    AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(CurrentDomain_UnhandledException);
    
    //ThrowUnhandledException();
    
    Task t = Task.Run(() => {
        ThrowUnhandledException();
    });
    t.Wait();
}

앞의 예제에서 Main함수에서 직접 예외를 발생 시키는 함수를 호출하는 것을 Task를 이용해 스레드를 생성하고, 스레드 내에서 ThrowUnhandledException 함수를 실행 시키도록 변경했다.

Task에 대한 자세한 내용은 [여기]를 참조한다.

실행 시키면 아래와 같은 결과를 얻을 수 있다.

디버거는 ThrowUnhandledExcption 함수의 내부 상태를 보여주는 것이 아니라 Task를 실행한 메인 스레드의 스택까지만을 보여주고 있다. 스레드는 별도의 스택을 가지기 때문에 위와 같은 코드로는 스레드 내부에서 발생한 예외에 대해서는 확인하지 못 한다. WinForm 기반 어플리케이션에서는 Application.ThreadException를 이용해 메인 UI 스레드가 아닌 스레드로 부터 발생하는 예외에 대한 처리를 할 수 있지만 일반 콘솔 C#에서는 저 API가 지원되지 않는다.

그래서 메인 스레드가 아니 곳에서 우리가 원하는 정보를 담은 코어 덤프를 남기기 위해서는 스레드 내부에서 try ~ catch를 이용해 발생하는 예외를 가로채는 처리가 필요하다.

static void Main(string[] args)
{
    AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(CurrentDomain_UnhandledException);
    
    //ThrowUnhandledException();
    
    Task t = Task.Run(() => {
        try
        {
            ThrowUnhandledException();
        }
        catch (Exception e)
        {
            CurrentDomain_UnhandledException(null, null);
            throw e;
        }
    });
    t.Wait();
}

14라인을 보면 별도의 콜백을 등록하는 과정없이 예외가 발생하면 코어 덤프 파일을 바로 생성하고 예외를 그대로 전달하도록 처리한것을 확인할 수 있다.

실행 시키면 위와 같이 예외가 발생했을 당시의 스냅샷 정보를 스레드 스택에서도 잘 보여주고 있는 것을 확인 할 수 있다.

.pdb(Program Database)

덤프 파일을 이용해 디버깅을 하기 위해서는 꼭 있어야 하는 필수적인 파일이 있다. 바로 여러분이 비주얼 스튜디오를 통해 빌드 할 때마다 자동으로 생성되는 .pdb 파일이 그것이다.

pdb 파일은 프로그램 데이터베이스 파일이라는 것으로써 간단히 프로젝트에 있는 소스 코드를 컴파일된 어플리케이션에 매핑하는 정보를 담고 있다고 생각하면 된다. 코어 덤프 파일을 이용해 디버깅 할 때는 항상 이 pdb 파일이 dmp 파일과 같은 위치에 있어야 하며 더욱 중요한 것은 같은 빌드에서 나온 pdb 와 dmp 여야 한다는 것이다.
(사실 pdb 경로를 별도로 설정하면 되므로 pdb와 dmp가 꼭 같은 위치에 있어야 할 필요는 없다. 하지만 같이 놓고 쓰는게 정신 건강과 빠른 퇴근에 많은 도움이 된다)

쉽게 설명하면 여러분이 빌드하면 exe와 pdb가 생성 된다. 이 exe를 구동하다 처리 되지 않은 예외가 발생되면 dmp를 생성하게 된다. 이 버전이 모두 동일해야지 과거의 언젠가 쓰던 pdb를 이용해 현재의 dmp를 디버깅하려고 하면 심볼을 찾을 수 없다는 에러를 만나게 될 것이다.

pdb에 대한 보다 자세한 설명은 [여기]를 참고 하도록 하자.

마치며

이상 C# 윈도우 플랫폼에서 코어 덤프를 생성하는 방법을 알아 보았다. 특별한 자료구조나 알고리즘이 필요하지 않은 간단히 MiniDumpWriteDump만을 호출하는 방법을 설명한 것이다 어려운 포스트는 아니었을 것이라 생각한다. 다만 아쉬운 점이 있다면 스레드에서 발생한 예외를 처리하는 콜백이 분명히 있을것 같은데 찾지 못해 스레드 내부에서 덤프 생성 함수를 직 호출했다. 혹시 누가 스레드 예외 처리 방법에 대해 잘 알고 계신분이 있다면 댓글로 부탁 드린다.

부록 1. 전체 코드

using System;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading.Tasks;

class Program
{
    static int staticInt = 1234567890;
    static string staticStr = "HelloWorld";

    static void ThrowUnhandledException()
    {
        int localInt = 1234567890;
        string localStr = "HelloWorld";

        string exeName = AppDomain.CurrentDomain.FriendlyName;
        throw new Exception("An Unhanled exception has been detected in the application " + exeName);
    }

    static void Main(string[] args)
    {
        // 싱글 스레드 용
        // AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(CurrentDomain_UnhandledException);

        // ThrowUnhandledException();

        // 멀티 스레드 용
        Task t = Task.Run(() => {
            try
            {
                ThrowUnhandledException();
            }
            catch (Exception e)
            {
                CurrentDomain_UnhandledException(null, null);
                throw e;
            }
        });
        t.Wait();
    }

    // https://docs.microsoft.com/en-us/windows/win32/api/minidumpapiset/nf-minidumpapiset-minidumpwritedump
    [DllImport("Dbghelp.dll")]
    static extern bool MiniDumpWriteDump(IntPtr hProcess, uint ProcessId, IntPtr hFile, int DumpType, ref MINIDUMP_EXCEPTION_INFORMATION ExceptionParam, IntPtr UserStreamParam, IntPtr CallbackParam);

    [DllImport("kernel32.dll")]
    static extern IntPtr GetCurrentProcess();

    [DllImport("kernel32.dll")]
    static extern uint GetCurrentProcessId();

    [DllImport("kernel32.dll")]
    static extern uint GetCurrentThreadId();

    // https://docs.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_exception_information
    [StructLayout(LayoutKind.Sequential, Pack = 4)]
    public struct MINIDUMP_EXCEPTION_INFORMATION
    {
        public uint ThreadId;
        public IntPtr ExceptionPointers;
        public int ClientPointers;
    }

    // https://docs.microsoft.com/en-us/windows/win32/api/minidumpapiset/ne-minidumpapiset-minidump_type
    const int MiniDumpNormal = 0x00000000;
    const int MiniDumpWithFullMemory = 0x00000002;

    /// 처리 되지 않은 예외가 발생 했을 때 호출 되는 콜백 함수.
    public static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
    {
        string dirPath = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location);
        string exeName = AppDomain.CurrentDomain.FriendlyName;
        string dateTime = DateTime.Now.ToString("[yyyy-MM-dd][HH-mm-ss-fff]");

        MINIDUMP_EXCEPTION_INFORMATION info = new MINIDUMP_EXCEPTION_INFORMATION();
        info.ClientPointers = 1;
        info.ExceptionPointers = Marshal.GetExceptionPointers();
        info.ThreadId = GetCurrentThreadId();

        {
            string dumpFileFullName = dirPath + "/[" + exeName + "_mini]" + dateTime + ".dmp";
            FileStream file = new FileStream(dumpFileFullName, FileMode.Create);
            MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), file.SafeFileHandle.DangerousGetHandle(), MiniDumpNormal, ref info, IntPtr.Zero, IntPtr.Zero);
            file.Close();
        }

        {
            string dumpFileFullName = dirPath + "/[" + exeName + "]" + dateTime+ ".dmp";
            FileStream file = new FileStream(dumpFileFullName, FileMode.Create);
            MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), file.SafeFileHandle.DangerousGetHandle(), MiniDumpWithFullMemory, ref info, IntPtr.Zero, IntPtr.Zero);
            file.Close();
        }
    }
}

부록 2. 같이 읽으면 좋은 글

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!