[cpp] 동적 연결 라이브러리 동작 원리

@codemaru · June 18, 2015 · 27 min read

항공기 사고의 74%는 이륙후 3분간, 착륙전 8분간 동안 발생한다고 한다. 이를 두고 ‘마의 11분’이란 표현을 쓰기도 한다. 이는 크리티컬한 작업에 있어서 시작과 끝이 얼마나 중요하고 위험한지를 나타내는 부분이라고 볼 수 있다. 윈도우 DLL을 사용할 때에도 대부분의 오류가 항공기 사고와 유사하게 DLL을 메모리에 적재하는 시점이나 해제하는 시점에 발생한다. 이는 윈도우가 DLL 목록을 관리하는 방식의 특수성에 기인한다. 이번 시간에는 이런 특수성에 대해서 살펴보고 이륙이나 착륙 과정에서의 사고를 피하기 위해서는 어떤 부분에 신경 써야 하는지 알아본다.

로더 락(Loader Lock) 윈도우는 로드된 DLL을 PEB(Process Environment Block)의 Ldr 필드를 통해서 관리한다. Ldr 필드는 <리스트 1>에 나와 있는 _PEB_LDR_DATA32 구조체를 가리킨다. _PEB_LDR_DATA32 구조체에는 현재 로더의 동작 상태를 나타내는 플래그와 로드된 DLL의 목록을 저장하고 있는 3개의 리스트(InLoadOrderModuleList, InMemoryOrderModuleList, InInitializationOrderModuleList)로 구성되어 있다.

리스트 1 _PEB_LDR_DATA32 구조체

typedef struct _PEB_LDR_DATA32
{
    ULONG Length;
    BOOLEAN Initialized;
    PVOID32 SsHandle;
    LIST_ENTRY32 InLoadOrderModuleList;
    LIST_ENTRY32 InMemoryOrderModuleList;
    LIST_ENTRY32 InInitializationOrderModuleList;
    PVOID32 EntryInProgress;
    BOOLEAN ShutdownInProgress;
    PVOID32 ShutdownThreadId;
} PEB_LDR_DATA32, *PPEB_LDR_DATA32;

LoadLibrary와 같은 DLL을 로드하는 함수나 로더 내부적으로 DLL을 로딩하는 경우에 로더는 해당 DLL을 로딩하고 _PEB_LDR_DATA32의 리스트에 해당 DLL의 정보를 추가한다. 마찬가지로 DLL이 메모리에서 언로드되는 경우에는 리스트에서 요청한 DLL을 제거한다. GetModuleHandle과 같이 특정 DLL을 찾는 경우에는 모듈 리스트를 순회하면서 들어온 정보와 매치되는 DLL이 있는지를 찾는다.

이와 같이 DLL의 관리에는 _PEB_LDR_DATA32라는 프로세스 내부의 유일한 구조체가 사용되며, 해당 구조체의 정보는 다양한 윈도우 API에 의해서 수시로 변경된다. 이 말은 해당 구조체에 대한 접근은 직렬화될 필요가 있다는 것을 의미한다. 구조체에 대한 접근이 동기화 되지 않는다면 다중 스레드에서 구조체의 내용을 변경할 경우에 구조체 내용물이 오염되는 현상이 발생할 수 있기 때문이다. 윈도우에서는 _PEB_LDR_DATA32 구조체에 대한 접근을 직렬화 시키기 위해서 로더 락이라는 것을 사용한다. 로더 락은 PEB 내부에 존재하며 크리티컬 섹션으로 구현되어 있다. 따라서 _PEB_LDR_DATA32를 사용하는 모든 API들은 로더 락을 획득한 다음 작업을 한다고 생각하면 된다.

<리스트 2>와 <리스트 3>에는 이러한 로더 락의 특징을 보여주는 예제를 담고 있다. 두 개의 스레드를 생성해서 각각의 스레드가 a.dll과 b.dll을 로드한다. 각각의 DLL은 <리스트 3>에 나와 있는 것과 같이 DLL이 초기화 되는 부분에서 자신의 이름을 출력한다. 만약 DLL이 로드되는 것이 직렬화 되지 않는다면 메시지 박스 두 개가 동시에 화면에 출력될 것이다. 하지만 DLL이 로드되는 작업은 직렬화가 이루어지기 때문에 절대로 두 개의 메시지 박스가 동시에 화면에 나타나는 법은 없다. 항상 하나의 메시지 박스를 닫아서 작업을 완료해야 다음 DLL의 메시지 박스가 출력된다. LoadLibrary 부분을 MessageBoxW 함수로 바꿔가면서 예제를 실행해보면 그 차이를 보다 쉽게 알 수 있다.

리스트 2 실행 파일 소스 소스 코드 다운로드

#include "windows.h"

ULONG
CALLBACK
ThreadProc(PVOID param)
{
    LPCWSTR name = (LPCWSTR) param;
    LoadLibraryW(name);
    return 0;
}

int _tmain(int argc, _TCHAR* argv[])
{
    LoadLibraryW(L"user32.dll");

    HANDLE threads[2];

    threads[0] = CreateThread(NULL, 0, ThreadProc, (LPVOID) L"a.dll", 0, NULL);
    threads[1] = CreateThread(NULL, 0, ThreadProc, (LPVOID) L"b.dll", 0, NULL);

    WaitForMultipleObjects(ARRAYSIZE(threads), threads, TRUE, INFINITE);
    
    return 0;
}

리스트 3 DLL 소스 소스 코드 다운로드

#include "windows.h"

BOOL APIENTRY DllMain( HMODULE m, DWORD reason, LPVOID reserved)
{
    switch(reason)
    {
    case DLL_PROCESS_ATTACH:
        MessageBoxW(NULL, L"a.dll", L"a.dll", MB_OK);
        break;
    }

    return TRUE;
}

DllMain에서 주의할 점 앞서 프로세스 내부의 DLL 정보를 관리하기 위해서 _PEB_LDR_DATA32라는 구조체를 사용한다는 것과 해당 구조체에 대한 접근을 로더 락을 통해서 직렬화 시킨다는 것을 배웠다. 이 단순한 사실이 DllMain을 작성하는 데에는 상당한 제약 사항을 불러온다. DllMain은 로더 락을 획득한 상태에서 호출되기 때문에 사소한 함수 호출도 데드락이나 크래시로 이어질 수 있다. <표 1>에는 이러한 관점에서 DllMain에서 피해야 할 작업들의 목록이 나와 있다. 해당 작업들은 특수한 가정이 적용되는 상황이 아니라면 가급적 사용하지 않는 것이 좋다.

DllMain에서 피해야 할 작업들

  • LoadLibrary, LoadLibraryEx 호출
    해당 함수들은 의존성 루프를 생성할 수 있고 의존성 루프는 데드락이나 크래시를 유발할 수 있다.
  • 다른 스레드와 동기화
    데드락을 유발한다.
  • 로더 락을 획득하려는 코드가 가지고 있는 동기화 오브젝트를 획득하려는 시도
    데드락을 유발한다.
  • CoInitializeEx를 사용한 COM 스레드 초기화
    특정 조건이 충족될 경우 CoInitializeEx는 LoadLibraryEx를 호출한다.
  • 레지스트리 관련 함수들
    이 함수들은 advapi32.dll에 구현되어 있다. advapi32.dll이 초기화 되지 않았다면 크래시가 발생할 수 있다.
  • CreateProcess 호출
    프로세스 생성은 다른 DLL을 로드할 수 있다.
  • ExitThread 호출
    DLL_PROCESS_DETACH 과정 중에 스레드를 종료하면 로더 락을 다시 획득하도록 만들 수 있다.
  • 네임드 파이프나 네임드 오브젝트 생성
    윈도우 2000에서 네임드 오브젝트 생성은 터미널 서비스 DLL에서 구현되어 있다. 해당 DLL이 초기화 되어 있지 않다면 크래시가 발생할 수 있다.
  • CRT에 포함된 메모리 관리 함수 사용
    DLL 버전의 CRT를 사용한다면 해당 DLL이 초기화되어 있지 않을 수 있다.
  • user32.dll이나 gdi32.dll에 포함된 함수 사용
    일부 함수들은 다른 DLL을 로드할 수 있고, 이 때문에 크래시가 발생할 수 있다.

그럼 좀 더 세부적으로 왜 이런 금기 사항들이 생겼는지에 대해서 알아보도록 하자. 첫 번째 금기 사항은 LoadLibrary의 호출이다. LoadLibrary의 호출은 새로운 DLL을 로딩하는 일이기 때문에 당연히 로더 락을 획득하는 시도가 발생할 수 있다는 것을 알 수 있다. 하지만 앞서 로더 락은 크리티컬 섹션으로 구현되어 있다고 했다. 윈도우에서 크리티컬 섹션은 재귀적으로 획득하는 것이 가능하도록 설정돼 있기 때문에 현재 스레드가 로더 락을 획득한 상태에서 LoadLibrary를 호출하는 것은 일견 문제가 없어 보이기도 한다. 실제로 DllMain에서 LoadLibrary를 사용해보면 큰 문제 없이 정상 동작하는 것을 볼 수 있다.

이렇게 잘 동작하는 LoadLibrary를 왜 굳이 호출하지 않도록 권고하는 것일까? 이유는 해당 호출을 인정할 경우에 특수한 상황에서 발생하는 문제들을 잠재적으로 같이 허용하는 셈이 되기 때문이다. 대표적인 예가 의존성 루프를 생성하는 것을 들 수 있다. 예를 들면 이런 상황이다. P라는 프로그램이 A.DLL을 로드한다. A.DLL은 DllMain에서 B.DLL을 로드한다. B.DLL은 DllMain에서 A.DLL을 로드한 다음 A.DLL의 Func라는 함수를 사용한다. 이때 이 Func라는 함수는 A.DLL이 초기화가 다 완료되지 않은 상태에서 호출되기 때문에 문제가 발생할 수 있다. <리스트 4>에는 이 경우에 대한 소스 코드가 나와 있다. 해당 예제를 수행해 보면 <화면 1>에 나타난 것과 같이 Func 실행 부분에서 msg 값이 초기화가 완료되지 않아서 NULL 값이 출력되는 것을 볼 수 있다.

이 예제의 경우에는 의존성 루프가 2단계이고 모든 DLL을 우리가 만들었기 때문에 원인을 쉽게 찾을 수 있다. 하지만 이런 의존성이 여러 곳에서 긴 단계에 걸쳐서 발생하는 경우에는 찾기도 힘들뿐더러 예측하지 못했던 동작들이 발생할 수 있다. 따라서 가급적 DllMain에서는 LoadLibrary 호출을 피하는 것이 좋다.

리스트 4 의존성 루프로 인한 오류 발생 예제 소스 코드 다운로드

// P 소스 코드
#include "windows.h"

int main()
{
	LoadLibraryW(L"a.dll");
	return 0;
}

// A.DLL 소스 코드
#include "windows.h"

LPCWSTR msg;

BOOL APIENTRY DllMain(HMODULE m, DWORD reason, LPVOID reserved)
{
	switch(reason)
	{
	case DLL_PROCESS_ATTACH:
		{
			printf("a.dll DLL_PROCESS_ATTACH\n");
			HMODULE m = LoadLibraryW(L"b.dll");
			msg = L"Init Complete";
			printf("a.dll DLL_PROCESS_ATTACH 완료\n");
		}
		break;
	}

	return TRUE;
}

extern "C" __declspec(dllexport) void Func()
{
	printf("MSG => %ws\n", msg);
}


// B.DLL 소스 코드
#include "windows.h"
typedef void (*FuncT)();

BOOL APIENTRY DllMain(HMODULE m, DWORD reason, LPVOID reserved)
{
	switch(reason)
	{
	case DLL_PROCESS_ATTACH:
		{
			printf("b.dll DLL_PROCESS_ATTACH\n");

			HMODULE m = LoadLibraryW(L"a.dll");
			FuncT func = (FuncT) GetProcAddress(m, "Func");
			func();
		
			printf("b.dll DLL_PROCESS_ATTACH 완료\n");
		
		}
		break;
	}

	return TRUE;
}

화면 1 P 실행 화면 두 번째 시나리오인 다른 스레드와 동기화를 생각해보자. DllMain에서 T라는 스레드를 종료 시키기 위해서 T의 종료 이벤트를 시그널 하고 WaitForSingleObject로 T를 대기한다고 가정해보자. 이 경우에 T는 종료 이벤트가 시그널 되었기 때문에 스레드 종료를 위해서 ExitThread가 호출될 것이다. ExitThread가 호출되면 DLL_THREAD_DETACH 이벤트를 호출하기 위해서 일부 DLL에 대해서는 DllMain이 호출되어야 한다. 이는 바로 로더 락 획득을 의미한다. 그런데 a.dll의 DllMain에서 로더 락을 획득한 상태에서 T가 종료되길 대기하고 있기 때문에 T의 ExitThread는 로더 락을 획득할 수가 없다. 따라서 데드락에 빠지게 된다. <리스트 5>에는 이러한 시나리오를 통해서 데드락에 빠지는 예제가 나와 있다. 해당 DLL을 로딩하는 프로그램은 자동으로 데드락에 빠진다.

리스트 5 스레드 동기화로 인한 데드락 발생 예제 소스 코드 다운로드

#include "windows.h"
HANDLE exit_event;

ULONG
CALLBACK
ThreadProc(PVOID)
{
    WaitForSingleObject(exit_event, INFINITE);
    return 0;
}

BOOL APIENTRY DllMain( HMODULE m, DWORD reaspm, LPVOID reserved)
{
    switch (reason)
    {
    case DLL_PROCESS_ATTACH:
        {
            exit_event = CreateEvent(NULL, FALSE, FALSE, NULL);
            HANDLE thread = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);
            SetEvent(exit_event);
            WaitForSingleObject(thread, INFINITE);
        }
        break;
    }

    return TRUE;
}

간단하게 두 가지 경우에 발생하는 문제만 살펴보았지만 다른 경우의 문제점들도 동일하게 모두 로더 락을 획득한 상태라는 특수성 때문에 발생하는 문제들이다. 숙련된 윈도우 프로그래머의 경우에도 이러한 제약 사항들에 대해서 단지 정상 동작한다는 이유로 크게 신경을 쓰지 않기도 한다. 하지만 이는 바람직한 자세가 아니다. 언제든지 해당 코드들은 문제를 발생시킬 수 있기 때문이다. 특수한 가정이 덧붙여지는 상황이 아니라면 해당 코드들을 DllMain에서 사용하는 일은 반드시 피하도록 해야 한다.

초기화 지연 전략 DllMain에서 피해야할 많은 작업들에 대해서 배웠다. 그렇다면 만약 DLL의 초기화에 반드시 피해야 할 작업이 있다면 어떻게 해야 할까? 그럴 때는 DllMain에서 초기화를 시키는 것이 아닌 초기화를 지연시키는 방법을 사용해야 한다. 일반적으로 가장 좋은 방법은 <리스트 6>에 나와 있는 것과 같이 DLL 초기화, 클린업 함수를 익스포트 시키는 방법이다. DLL을 사용하는 프로그래머가 DLL 로딩 후에 해당 함수를 명시적으로 호출해서 초기화와 클린업을 수행하도록 만드는 것이다. 하지만 후킹 DLL과 같은 경우에는 이러한 방법을 사용하기가 까다로운 경우가 많다. 그런 경우에는 DllMain에서 초기화 코드를 담고 있는 스레드를 생성해서 나중에 초기화가 수행되도록 만드는 것이 좋다.

리스트 6 초기화/종료 함수를 사용한 초기화 지연

__delcspec(dllexport) HRESULT InitDLL()
{
    // 실제 DLL 초기화 작업
}

__declspec(dllexport) void CleanupDLL()
{
    // 실제 DLL 클린업 작업
}

DLL_PROCESS_DETACH에 관한 오해 DLL_PROCESS_DETACH는 DLL이 메모리에서 해제되는 시점, 그러니까 FreeLibrary가 호출되서 해당 DLL의 레퍼런스 카운트가 0이 되는 시점과 프로세스가 종료되는 시점에 호출된다. 이러한 특성 때문에 상당히 많은 윈도우 프로그래머가 DLL_PROCESS_DETACH에 대해서 도시전설 같은 믿음을 가지고 있다. 하나는 TerminateProcess로 종료되는 경우에는 DLL_PROCESS_DETACH 이벤트가 발생한다는 것이고, 다른 하나는 DLL_PROCESS_DETACH가 프로세스 종료 처리에 적합한 위치라고 생각한다는 점이다. 하지만 이는 둘 다 심각하게 잘못된 믿음이다.

프로세스 종료를 만들어내는 두 가지 API, ExitProcess와 TerminateProcess의 구현을 살펴보면 <리스트 7>에 나타난 것과 같다. 코드에 나타난 것처럼 두 함수 모두 궁극적으로는 NtTerminateProcess를 호출해서 작업을 완료한다. 둘의 차이라면 ‘shutdown process’의 진행 여부에 있다. 이 ‘shutdown process’의 진행 과정의 일환으로 호출되는 것이 DLL_PROCESS_DETACH다. 따라서 TerminateProcess로 종료될 때에는 당연히 DLL_PROCESS_DETACH 이벤트가 발생하지 않는다.

리스트 7 ExitProcess와 TerminateProcess의 구현

ExitProcess(...)  
{  
    NtTerminateProcess(NULL, ExitCode);  
    // shutdown process  
    NtTerminateProcess(GetCurrentProcess(), ExitCode);  
}
  
TerminateProcess(...)  
{  
    NtTerminateProcess(Process, ExitCode);  
}

우리가 ExitProcess의 구현에서 눈치채야 할 다른 중요한 시사점은 바로 NtTerminateProcess를 호출한 다음 ‘shutdown process’를 진행한다는 점입니다. 여기에는 굉장히 중요한 점이 내포되어 있다. 바로 우리 DLL이 FreeLibrary가 아닌 프로세스 종료 때문에 DLL_PROCESS_DETACH가 호출된다면, 그 시점에는 그 어떠한 스레드도 살아 있지 않다는 점이다. NtTerminateProcess(NULL, ExitCode)를 거치는 순간 해당 프로세스의 모든 스레드는 강제로 터미네이트(Terminate)되기 때문이다.

결론을 다시 정리해보면 이렇다. DLL_PROCESS_DETACH는 TerminateProcess로 종료되는 경우에는 이벤트가 발생하지 않으며, 모든 스레드가 강제로 종료된 다음에 호출될 수 있기 때문에 종료 작업을 하는 위치로는 적절하지 않다는 점이다.

DllMain에서의 예외 DLL_PROCESS_ATTACH 이벤트에서 DllMain이 FALSE를 리턴한다면 윈도우는 DLL_PROCESS_DETACH 이벤트를 사용해서 DllMain을 다시 호출한 다음 해당 DLL을 메모리에서 제거한다. 반면에 DLL_PROCESS_ATTACH 수행 과정 중에 DllMain에서 예외가 발생한다면 운영체제는 DLL_PROCESS_DETACH 이벤트 통보 없이 해당 DLL을 즉시 메모리에서 제거한다.

윈도우는 DllMain의 실패 처리와 관련해서 기본적으로 위와 같은 입장을 가지고 있다. 이 부분이 표면적으로는 FALSE를 리턴하면 DLL_PROCESS_DETACH가 호출되고, 예외가 발생하면 메모리에서 바로 제거된다는 내용을 담고 있는 것처럼 보여지지만 사실상 이 내용은 DllMain에서 절대로 예외를 발생시켜서는 안 된다는 이야기를 담고 있다. 그 이유는 CRT 때문이다.

CRT는 멀티스레드 환경을 지원하기 위해서 FLS(Fiber Local Storage)라는 것을 사용한다. CRT 초기화 루틴에서 FlsAlloc(_freefls)를 호출해서 FLS 공간을 할당한다. 물론 이렇게 할당된 공간은 DLL_PROCESS_DETACH 이벤트에서 CRT가 직접 제거한다. 코드로 살펴보면 <리스트 8>에 나타난 것과 같은 구조가 된다. 실제 DLL의 시작은 CrtDllMain이라는 CRT 코드에서 시작되고 해당 코드에서 각종 초기화를 한 다음 우리가 작성한 OurDllMain을 호출해 주는 것이다.

리스트 8 DllMain 호출 구조

BOOL CALLBACK CrtDllMain(HINSTANCE instance, ULONG reason, PVOID reserved)  
{  
    if(reason == DLL_PROCESS_ATTACH)  
    {  
        flsindex = FlsAlloc(_freefls);  
        return OurDllMain(instance, reason, reserved);  
    }  
    else if(reason == DLL_PROCESS_DETACH)  
    {  
        BOOL r = OurDllMain(instance, reason, reserved);  
        FlsFree(flsindex);  
        return r;  
    }  
  
    return TRUE;  
} 

그런데 이 구조에서 OurDllMain에서 예외가 발생한다면 CRT가 할당한 FLS 공간이 문제가 된다. 왜냐하면 우리가 만든 OurDllMain에서 예외가 발생하면 CRT에서는 FlsAlloc을 사용해서 FLS 공간을 할당했지만 DLL_PROCESS_DETACH가 호출되지 않아서 그 공간을 회수할 기회를 가지지 못하기 때문이다.

“몇 바이트 메모리 누수가 일어나는 것이 큰 문제일까?”라고 생각할 수 있다. 하지만 이 문제는 단순히 FLS 공간에 대한 메모리 누수만 일어나는 것으로 마무리되는 문제가 아니다. 윈도우 운영체제는 FLS에 등록된 것들을 프로세스 종료 시점에 모두 다시 회수하려고 시도한다. 이는 FlsAlloc에 지정된 콜백 함수를 호출해서 이루어진다. <리스트 8>의 코드에서는 _freefls 함수가 그것이다. 즉, 우리가 예외를 발생시켜 DLL이 메모리에서 즉시 제거되고 나면 엉뚱하게 프로세스 종료 시점에 운영체제가 사라진 메모리 번지를 가리키는 _freefls를 호출하려고 하다가 크래시가 발생할 수 있다.

DllMain에서는 절대로 예외를 발생시켜서는 안 된다. 왜냐하면 그 예외가 앞서 설명한 것과 같이 CRT의 코드에 영향을 미칠 수 있기 때문이다. <리스트 9>에 나타난 것과 같이 DllMain에서 수행하는 작업 과정에서 발생할 수 있는 모든 예외를 봉쇄하고 예외가 발생한 경우에는 그냥 FALSE를 리턴하는 것이 현명하다. 그러면 적어도 다른 프로그램에 민폐는 안 끼칠 수 있다. 그렇지 않다면 민폐를 끼치고 엉뚱한 지점에서 크래시가 발생하는 문제가 생길 수 있다.

리스트 9 예외 처리기를 통해서 DllMain에서 발생하는 예외를 차단하는 코드

// /Eha 옵션으로 컴파일해야 모든 예외를 잡을 수 있다.
BOOL APIENTRY DllMain( HMODULE m, DWORD reaspm, LPVOID reserved)
{
    try
    {
        // .. DllMain에서 처리해야 할 작업들

        return TRUE;
    }
    catch(...)
    {
    }

    return FALSE;
}

lpreserved의 용도 끝으로 DllMain의 마지막 파라미터인 lpreserved에 대해서 알아보자. DllMain의 세 번째 인자는 void 포인터 타입의 lpreserved다. 흔히 그냥 추후 사용을 위해서 예약돼 있다고 막연하게 생각할 수 있는데 이 값은 윈도우 내부적으로 사용되는 값이다. 그리고 그 의미는 다음과 같다.

DLL_PROCESS_ATTACH 시점에는 지금 로드되는 DLL이 LoadLibrary와 같은 함수를 사용해서 동적으로 로딩된다면 NULL을, 그렇지 않고 임포트 테이블을 사용해서 정적으로 로딩된다면 NULL이 아닌 값을 가진다.

DLL_PROCESS_DETACH 시점에는 FreeLibrary 호출로 인해서 메모리에서 제거되는 경우에는 NULL을, 그렇지 않고 프로세스 종료로 인해서 DLL이 제거되는 경우에는 NULL이 아닌 값을 가진다.

이런 파라미터는 DLL의 로딩을 최적화하는 용도로 사용된다. <리스트 10>의 코드는 CRT의 DLL_PROCESS_DETACH 코드의 일부를 발췌한 것이다. 이 코드를 살펴보면 lpreserved 값을 조사해서 NULL일 때만 클린업 코드를 호출하는 것을 볼 수 있다. 이유는 NULL이 아닌 경우에는 프로세스가 종료되는 시점이고 우리가 일일이 리소스를 파괴하지 않아도 운영체제가 알아서 모든 리소스를 제거할 것이기 때문이다. 해당 클린업 호출을 하지 않음으로써 보다 빠르게 DllMain이 완료될 수 있도록 만들어주는 것이다.

리스트 10 CRT의 DLL 클린업 코드

#ifndef _DEBUG  
    if ( lpreserved == NULL )  
    {  
#endif  /* _DEBUG */  
        /* 
         * The process is NOT terminating so we must clean up... 
         */  
        /* Shut down lowio */  
        _ioterm();  
  
        _mtterm();  
  
        /* This should be the last thing the C run-time does */  
        _heap_term();   /* heap is now invalid! */  
#ifndef _DEBUG  
    }  
#endif  /* _DEBUG */  
@codemaru
돌아보니 좋은 날도 있었고, 나쁜 날도 있었다. 그런 나의 모든 소소한 일상과 배움을 기록한다. 여기에 기록된 모든 내용은 한 개인의 관점이고 의견이다. 내가 속한 조직과는 1도 상관이 없다.
(C) 2001 YoungJin Shin, 0일째 운영 중