2025. 5. 28. 00:59ㆍ개발/Windows
이 글은 윤성우 님의 '뇌를 자극하는 윈도우즈 시스템 프로그래밍'을 참고하여 공부한 내용입니다.
예외 처리는 예상하지 못한 문제가 발생하여 프로세스가 비정상적으로 종료되는 문제를 방지해 줍니다. 잘못된 메모리 접근 혹은 0으로 나누기와 같은 문제를 안전하게 처리할 수 있게 해 줍니다. 그런데 앞서 말한 문제들은 개발자가 런타임에 발생할 예외들을 사전에 방지하여 설계하기 때문에 try-catch와 같은 예외처리를 잘 사용하지 않는 것 같습니다. 오히려 예외처리를 사용하게 되면 스택 언와인딩이 발생할 수 있기 때문에 성능이 상대적으로 낮아집니다. 그렇다면 예외 처리를 언제 사용해야 할까요.
저는 HTTP서버를 설계하며 HTTP Request를 읽어 HTTP Response를 작성할 때 사용하였습니다. Request에서 문법에 맞지 않거나 자원이 존재하지 않는다면 40X와 같은 오류 페이지를 응답해줘야 하기 때문에 사용자 정의 예외를 발생시켜 오류 응답을 보냈습니다. 스택 언와인딩을 통해 오류 응답을 작성하고 보내는 코드블록으로 점프한 것입니다. 저는 코드의 흐름 제어를 하기 위해 예외처리를 사용하였습니다. 이제 와서 생각해 보면 성능이 가장 중요한 서버에는 맞지 않는 방식인 것 같습니다.
Structured Exception Handling
구조적 예외 처리는 WinAPI에서 지원하는 예외 처리 메커니즘으로 하드웨어 또는 운영체제 수준에서 발생하는 구조적 예외를 처리할 수 있게 합니다. 당연하게도 WinAPI에서 지원하는 방식이기 때문에 다른 운영체제에서는 호환되지 않습니다.
__try __except __finally
사용방법은 예외처리를 알고 있다면 간단합니다. __try __except 혹은 __finally 키워드를 사용하여 처리합니다.
#include <windows.h>
#include <stdio.h>
#include <excpt.h>
int main() {
__try {
int a = 10;
int b = 0;
int result = a / b;
printf("Result: %d\n", result);
}
__finally {
printf("__finally.\n");
}
printf("Continues to run.\n");
}
__finally.
위 예시는 0으로 나누기 예외에 종료 핸들러인 __finally를 사용한 예시를 보여줍니다. 예외가 발생하지 않아도 종료 핸들러는 실행되기 때문에 일반적으로 자원관리를 위해 사용합니다. 하지만 종료 핸들러는 예외를 처리하지 못합니다. 그래서 마지막 "Continues to run" 문자열은 출력되지 않은 것을 확인할 수 있습니다.
예외를 처리하기 위해서는 __except 예외 핸들러를 사용합니다.
#include <windows.h>
#include <stdio.h>
#include <excpt.h>
int main() {
__try {
printf("Start...\n");
int* null_ptr = NULL;
*null_ptr = 42;
printf("This code will not be executed.\n");
}
__except (EXCEPTION_EXECUTE_HANDLER) {
printf("Exception occurred.\n");
}
printf("Continues to run.\n");
}
Start...
Exception occurred.
Continues to run.
위 방식은 예외 핸들러를 사용한 예외 처리입니다. nullptr에 접근함으로 예외를 발생시켜 출력문을 건너뛰고 예외 핸들러를 실행하였습니다. 위 코드에서 __except 안에 사용된 EXCEPTION_EXECUTE_HANDLER는 예외 필터라고 합니다. 예외 필터는 총 3가지로 각각 다른 행동을 정의합니다.
- EXCEPTION_EXECUTE_HANDLER: 예외 핸들러를 실행
- EXCEPTION_CONTINUE_SEARCH: 다음 상위 예외 핸들러로 예외를 넘김
- EXCEPTION_CONTINUE_EXECUTION: 예외 발생 지점에서 다시 실행 시도
우선 EXCEPTION_CONTINUE_SEARCH를 사용한 예시를 확인해 보겠습니다.
#include <windows.h>
#include <stdio.h>
#include <excpt.h>
int main() {
__try {
printf("External __try block entry\n");
__try {
printf("Internal __try block entry\n");
// 예외 발생
int* null_ptr = NULL;
*null_ptr = 42;
}
__except (EXCEPTION_CONTINUE_SEARCH) {
printf("Internal Handler: EXCEPTION_CONTINUE_SEARCH\n");
}
printf("This code will not be executed.\n");
}
__except (EXCEPTION_EXECUTE_HANDLER) {
printf("External Handler: Handling all exceptions\n");
}
}
External __try block entry
Internal __try block entry
External Handlers: Handling All Exceptions
위 예시는 중첩 예외처리를 통해 EXCEPTION_CONTINUE_SEARCH의 동작을 보여줍니다. 출력을 보면 발생한 예외를 처리하지 않고 외부 예외 핸들러에게 전달하여 예외를 처리하였습니다. 이 동작 과정을 나중에 설명드리겠습니다.
이제 EXCEPTION_CONTINUE_EXECUTION을 사용한 예시를 확인해 보겠습니다.
#include <windows.h>
#include <stdio.h>
#include <excpt.h>
int b;
int FilterFunction()
{
printf("Exception occurred\n");
b = 2;
return EXCEPTION_CONTINUE_EXECUTION;
}
int main() {
int a = 10;
b = 0;
int result;
__try {
result = a / b;
printf("Result: %d\n", result);
}
__except (FilterFunction()) {
printf("Exception: EXCEPTION_CONTINUE_EXECUTION");
}
}
Exception occurred
Result: 5
EXCEPTION_CONTINUE_EXECUTION는 핸들러를 실행하지 않고 예외가 발생했던 result = a / b; 지점으로 가서 다시 실행합니다. 핸들러를 실행하지 않고 예외 발생 지점으로 점프하기 때문에 필터를 그대로 넣으면 무한반복 상태가 됩니다.
예외의 추가적인 정보는 GetExceptionCode, GetExceptionInformation 매크로 함수를 통해 확인할 수 있습니다.
#include <windows.h>
#include <stdio.h>
#include <excpt.h>
int FilterFunction(DWORD exceptionCode, PEXCEPTION_POINTERS exceptionInfo)
{
printf("Exception code: 0x%08X\n", exceptionCode);
printf("Exception occurrence address: %p\n", exceptionInfo->ExceptionRecord->ExceptionAddress);
printf("Exception Type: ");
switch (exceptionCode) {
case EXCEPTION_ACCESS_VIOLATION:
printf("EXCEPTION_ACCESS_VIOLATION\n");
break;
case EXCEPTION_INT_DIVIDE_BY_ZERO:
printf("EXCEPTION_INT_DIVIDE_BY_ZERO\n");
break;
case EXCEPTION_STACK_OVERFLOW:
printf("EXCEPTION_STACK_OVERFLOW\n");
break;
default:
printf("Other exceptions\n");
break;
}
return exceptionCode == EXCEPTION_INT_DIVIDE_BY_ZERO ?
EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH;
}
int main() {
__try {
int a = 10;
int b = 0;
int result = a / b;
printf("Result: %d\n", result);
}
__except (FilterFunction(GetExceptionCode(), GetExceptionInformation())) {
printf("Run exception handler.\n");
}
}
Exception code: 0xC0000094
Exception occurrence address: 00007FF702B2195E
Exception Type: EXCEPTION_INT_DIVIDE_BY_ZERO
Run exception handler.
두 함수를 통해 구체적인 예외의 타입을 확인해 보는 예제입니다. 여기서 중요한 것은 FilterFunction의 매개변수를 통해 GetExceptionCode와 GetExceptionInformation 함수를 사용한 것입니다. 왜냐하면 GetExceptionCode의 경우 예외 핸들러 필터식과 예외 핸들러 외에 사용하게 될 경우 컴파일러가 오류를 발생시킵니다. 마찬가지로 GetExceptionInformation 함수는 예외 핸들러 필터식에서만 사용가능하며 이벤트 핸들러 혹은 외부에 사용할 경우 동일하게 컴파일러가 오류를 발생시킵니다.
C2707 '_exception_code': bad context for intrinsic function
C2707 '_exception_info': bad context for intrinsic function
그렇기에 FilterFunction과 같이 함수의 매개변수를 통해 사용하는 방식이 일반적입니다. 자세한 예외 코드는 GetExceptionCode의 반환값을 확인해 보시길 바랍니다.
또한 예외 핸들러와 종료 핸들러는 동시에 사용할 수 없습니다.
__try {
// Code block
}
__finally {
// Termination handler
}
__except (EXCEPTION_EXECUTE_HANDLER) {
// Exception handler
}
위 와 같은 방식은 사용할 수 없습니다. 대신 중첩하여 사용할 수는 있습니다.
Chaining
예외 핸들러의 작동방식은 특이한데 스택 언와인딩을 하지 않고 스레드마다 예외 핸들러 체인을 통해 예외를 처리합니다. 예외 핸들러 체인은 예외 핸들러를 포함한 연결리스트로 구현되어 있으며 TEB, Thread Environment Block 구조체에 포함되어 있습니다. 예외가 처리되는 과정을 간단하게 요약하면 아래와 같습니다.
- 시스템(CPU) 혹은 사용자(RaiseException) 예외를 발생시킵니다.
- 커널 모드로 진입하여 KiDispatchException을 호출합니다.
- 세그먼트 레지스터 FS [0] 오프셋에 접근하여 TEB 내부의 예외 핸들러를 탐색합니다.
- 예외 핸들러가 있다면 해당 예외 핸들러로 점프합니다. 만약 예외 핸들러가 없다면 프로그램을 종료합니다.
아래는 예외 핸들러 체인의 구조를 간략하게 표현하였습니다.
struct EXCEPTION_REGISTRATION_RECORD {
struct EXCEPTION_REGISTRATION_RECORD* Next;
PEXCEPTION_ROUTINE Handler;
}
스택 언와인딩을 하지 않는다는 말은 스택을 정리하지 않는다는 말입니다. 즉 객체의 소멸자가 호출되지 않아 메모리 누수가 발생할 수 있습니다. 또한 __try 내부에 객체를 선언하게 될 경우 위 경우를 방지하기 위해 컴파일러가 오류를 발생시킵니다.
C2712 Cannot use __try in functions that require object unwinding
RaiseException
이 함수를 호출하면 현재 스래드에 사용자 정의 예외를 발생시킬 수 있습니다. 함수의 정의는 아래와 같습니다.
void RaiseException(
[in] DWORD dwExceptionCode,
[in] DWORD dwExceptionFlags,
[in] DWORD nNumberOfArguments,
[in] const ULONG_PTR *lpArguments
);
- dwExceptionCode: 예외 코드입니다. GetExceptionCode를 통해서도 확인할 수 있습니다.
- dwExceptionFlags: 예외 플래그입니다. 0이면 연속적인 예외를 뜻하며 EXCEPTION_NONCONTINUABLE인 경우 비연속적인 예외를 뜻합니다.
- nNumberOfArguments: lpArguments 크기입니다. lpArguments가 NULL인 겨우 무시됩니다. 이 값은 EXCEPTION_MAXIMUM_PARAMETERS = 15를 초과하면 안 됩니다.
- lpArguments: 인수 배열입니다. 예외 핸들러 필터식에 전달될 데이터입니다. NULL이 올 수 있습니다.
위 매개변수로 전달한 값들은 GetExceptionInformation 함수의 반환값이 EXCEPTION_POINTERS 구조체 포인터를 통해 확인할 수 있습니다. EXCEPTION_POINTERS 내부에는 EXCEPTION_RECORD 구조체 포인터와 CONTEXT 구조체 포인터가 있으며 이 두 개의 구조체를 통해 예외 정보와 프로세스 상태를 확인할 수 있습니다.
typedef struct _EXCEPTION_POINTERS {
PEXCEPTION_RECORD ExceptionRecord;
PCONTEXT ContextRecord;
} EXCEPTION_POINTERS, *PEXCEPTION_POINTERS;
typedef struct _EXCEPTION_RECORD {
DWORD ExceptionCode;
DWORD ExceptionFlags;
struct _EXCEPTION_RECORD *ExceptionRecord;
PVOID ExceptionAddress;
DWORD NumberParameters;
ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
} EXCEPTION_RECORD;
typedef EXCEPTION_RECORD *PEXCEPTION_RECORD;
typedef struct DECLSPEC_ALIGN(16) DECLSPEC_NOINITALL _CONTEXT {
//
// Register parameter home addresses.
//
// N.B. These fields are for convience - they could be used to extend the
// context record in the future.
//
DWORD64 P1Home;
DWORD64 P2Home;
DWORD64 P3Home;
DWORD64 P4Home;
DWORD64 P5Home;
DWORD64 P6Home;
//
// Control flags.
//
DWORD ContextFlags;
DWORD MxCsr;
//
// Segment Registers and processor flags.
//
WORD SegCs;
WORD SegDs;
WORD SegEs;
WORD SegFs;
WORD SegGs;
WORD SegSs;
DWORD EFlags;
//
// Debug registers
//
DWORD64 Dr0;
DWORD64 Dr1;
DWORD64 Dr2;
DWORD64 Dr3;
DWORD64 Dr6;
DWORD64 Dr7;
//
// Integer registers.
//
DWORD64 Rax;
DWORD64 Rcx;
DWORD64 Rdx;
DWORD64 Rbx;
DWORD64 Rsp;
DWORD64 Rbp;
DWORD64 Rsi;
DWORD64 Rdi;
DWORD64 R8;
DWORD64 R9;
DWORD64 R10;
DWORD64 R11;
DWORD64 R12;
DWORD64 R13;
DWORD64 R14;
DWORD64 R15;
//
// Program counter.
//
DWORD64 Rip;
//
// Floating point state.
//
union {
XMM_SAVE_AREA32 FltSave;
struct {
M128A Header[2];
M128A Legacy[8];
M128A Xmm0;
M128A Xmm1;
M128A Xmm2;
M128A Xmm3;
M128A Xmm4;
M128A Xmm5;
M128A Xmm6;
M128A Xmm7;
M128A Xmm8;
M128A Xmm9;
M128A Xmm10;
M128A Xmm11;
M128A Xmm12;
M128A Xmm13;
M128A Xmm14;
M128A Xmm15;
} DUMMYSTRUCTNAME;
} DUMMYUNIONNAME;
//
// Vector registers.
//
M128A VectorRegister[26];
DWORD64 VectorControl;
//
// Special debug control registers.
//
DWORD64 DebugControl;
DWORD64 LastBranchToRip;
DWORD64 LastBranchFromRip;
DWORD64 LastExceptionToRip;
DWORD64 LastExceptionFromRip;
} CONTEXT, *PCONTEXT;
간단한 예시를 통해 사용법을 알아보겠습니다.
#include <windows.h>
#include <stdio.h>
#include <excpt.h>
int main() {
FILE* file = NULL;
HANDLE mutex = NULL;
__try {
printf("Resource allocation\n");
if (!fopen_s(&file, "test.txt", "w")) {
printf("File opening successful\n");
}
mutex = CreateMutex(NULL, FALSE, L"TestMutex");
if (mutex) {
printf("Mutex generation successful\n");
}
printf("RaiseException\n");
RaiseException(0xE0000001, 0, 0, NULL);
printf("This code will not be executed.\n");
}
__finally {
printf("__finally block running\n");
if (file) {
fclose(file);
printf("File closing successful\n");
}
if (mutex) {
CloseHandle(mutex);
printf("Mutex release successful\n");
}
}
}
Resource allocation
File opening successful
Mutex generation successful
RaiseException
__finally block running
File closing successful
Mutex release successful
위 예시는 종료 핸들러를 사용한 자원관리 코드입니다. 여기서 RaiseException을 통해 강제로 예외를 발생시킨 것을 확인할 수 있습니다. 위와 같이 간단하게 사용할 수 있지만 일반적으로 예외 핸들러 필터식에서 해당 예외 정보를 확인하여 예외를 전달할지 처리할지 결정하기 때문에 정보를 담아 주는 것이 좋습니다. 그래서 아래와 같이 Wrapper 함수를 작성하여 사용합니다.
#define RAISE_CUSTOM_EXCEPTION(code, msg) \
do { \
CustomExceptionInfo info; \
info.errorCode = code; \
strncpy_s(info.message, sizeof(info.message), msg, _TRUNCATE); \
info.lineNumber = __LINE__; \
strncpy_s(info.fileName, sizeof(info.fileName), __FILE__, _TRUNCATE); \
RaiseException(code, 0, sizeof(info) / sizeof(ULONG_PTR), (ULONG_PTR*)&info); \
} while(0)
do-while 문을 사용한 이유는 여러 줄의 코드를 하나의 문장으로 만들기 위함입니다. 하나의 문장으로 사용하는 이유는 if-else와 같은 조건문에서 예상치 못한 동작을 방지하기 할 수 있기 때문입니다. 위 __LINE__과 __FILE__은 컴파일 타임에 대체될 매크로입니다. 각각의 매크로는 호출된 라인과 출력된 파일을 뜻합니다.
'개발 > Windows' 카테고리의 다른 글
Windows 캐시와 가상 메모리 (0) | 2025.05.22 |
---|---|
Windows 스레드 풀 (0) | 2025.05.19 |
Windows 스레드 동기화 (0) | 2025.05.13 |
Windows 스레드 생성과 소멸 (0) | 2025.04.29 |
Windows 스레드 (0) | 2025.04.28 |