[ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 06. 커널 오브젝트와 오브젝트 핸들
01. 커널 오브젝트에 대한 이해
커널이란, 컴퓨터를 운영하는 데 있어서 중심이 되는 운영체제 핵심 요소를 뜻하며, 일반적으로 커널과 운영체제라는 용어는 같은 의미로 사용된다. 이와 관련하여, “커널 오브젝트”라는 단어를 앞으로 설명할 것인데, 이는 “커널에서 관리하는 중요한 정보를 담아둔 데이터 블록”을 뜻한다.
커널 오브젝트의 이해
앞서 5장에서, CreateProcess 함수 호출을 통해 프로세스를 운영체제에게 요구하였으며, Windows 운영체제가 우리의 요구에 맞게 프로세스를 생성해 주었다. 즉, 프로세스를 생성하는 실질적인 주체는 우리가 아닌, 운영체제인 것이다.
프로세스의 생성뿐만 아니라, 생성된 프로세스의 관리도 운영체제의 몫이다. 이때 관리는 프로세스의 생성과 소멸, 그리고 이 사이에 이루어지는 모든 일(프로세스의 상태변화 등)이 포함된다.
이렇게 동시에 여러 개의 프로세스를 관리하는 과정에서, 운영체제는 내부에 프로세스의 상태 정보나 우선순위 정보와 같은 정보들을 저장하고 갱신해야만 한다. 이러한 정보 저장 및 갱신을 위해 Windows 운영체제 개발자는 구조체를 만들게 되고, 이를 편의상 ‘프로세스 관리 구조체’라 부른다. 이것이 바로 커널 오브젝트(Kernel Object)에 해당된다.
그 이외의 커널 오브젝트들
위의 설명은 단지 프로세스 생성을 예로 들었을 뿐이지, 프로세스가 생성될 때에만 커널 오브젝트가 생성되는 것은 아니다. 프로세스 내에서 프로그램 흐름을 구성하는 쓰레드를 생성할 때에도, IPC(Inter Process Communication)을 위해 사용되는 메일슬롯을 생성할 때에도, 심지어 파일을 생성할 때에도 커널 오브젝트를 생성하여 필요한 정보를 채우게 된다.
Windows에서 만드는 커널 오브젝트는 위와 같이 다양하다. 하지만, 모든 커널 오브젝트가 동일한 구조체를 기반으로 생성되는 것은 아니다. 즉, 커널 오브젝트의 종류에 따라서 서로 다른 구조체를 기반으로 생성된다. 이는 대상에 따라서 관리되어야 할 정보가 다르기 때문에 커널 오브젝트의 구성도 다를 수밖에 없다.
지금까지의 내용을 정리하자면, 다음과 같다.
“Windows 운영체제는 프로세스, 쓰레드 혹은 파일과 같은 리소스들을 원활히 관리하기 위해 필요한 정보를 저장해야 한다. 이때 데이터를 저장하는 메모리 블록을 가리켜 커널 오브젝트라 한다.”
다음 그림은 커널 오브젝트 여러 개가 생성된 상태를 보여준다. 이를 통해 Windows 커널에 의해서 관리되는 리소스 수만큼 커널 오브젝트가 생성된다는 사실을 알 수 있다.
오브젝트 핸들(Handle)을 이용한 커널 오브젝트의 조작
Windows에서 관리하는 리소스 특성을 변경시키기 위해서는(프로세스의 우선순위 변경과 같은) 해당 리소스의 커널 오브젝트를 조작해야만 하는데, 이는 직접적인 조작은 불가능하지만 MS에서 제공하는 시스템 함수를 이용하여 간접적인 조작이 가능하다. 다음은 프로세스에 관련된 커널 오브젝트 조작 함수들이다.
프로세스의 우선순위(Priority) 변경
다음은 프로세스의 우선순위를 변경하기 위한 함수로, 커널 오브젝트에 저장된 우선순위 정보를 변경시킨다.
BOOL SetPriorityClass
(
HANDLE hProcess,
DWORD dwPriorityClass
);
hProcess: 우선순위를 변경할 프로세스의 핸들(Handle)을 전달한다.
dwPriority: 새롭게 적용할 우선순위 정보를 전달한다.
여기서 말하는 핸들이란, 커널 오브젝트에 할당되는 숫자이다.
커널 오브젝트에 할당되는 숫자! 핸들(Handle)
Windows는 커널 오브젝트를 생성할 때마다 핸들이라는 정수값을 하나씩 부여한다. 이는 특정 커널 오브젝트를 가리키는 데에 사용된다.
위의 그림은 앞의 그림에 Handle 개념을 추가한 것이다.
추가적으로, MS에서는 Handle의 자료형을 특별히 언급하고 있지 않지만, 핸들 정보는 숫자로 표현되고 있다.
핸들 정보는 어디서?
핸들 정보를 얻는 방법은 커널 오브젝트의 종류에 따라 다양하다. 먼저 책에서는 다음의 예제를 통해 프로세스의 핸들을 얻는 방법에 대해 언급하고 있다.
// Operation1.cpp
#include <stdio.h>
#include <tchar.h>
#include <windows.h>
int _tmain(int argc, TCHAR* argv[])
{
STARTUPINFO si = { 0, };
PROCESS_INFORMATION pi;
si.cb = sizeof(si);
TCHAR command[] = _T("Operation2.exe");
CreateProcess(
NULL, command, NULL, NULL,
TRUE, 0, NULL, NULL, &si, &pi
);
while (1)
{
for (DWORD i = 0; i < 10000; i++)
for (DWORD i = 0; i < 10000; i++)
_fputts(_T("Operation1.exe \n"), stdout);
}
return 0;
}
24, 25행 : 이중 for 루프를 통해 출력 속도를 늦추고 있다. 만약, Sleep 함수를 사용해서 프로세스의 실행을 늦춘다면, 프로세스는 Blocked 상태가 되기 때문에 다른 프로세스가 대신 실행될 것이다. 이는 우리가 원하는 결과와 다르다.
// Operation2.cpp
#include <stdio.h>
#include <tchar.h>
#include <windows.h>
int _tmain(int argc, TCHAR* argv[])
{
SetPriorityClass(GetCurrentProcess(), HIGH_PRIORITY_CLASS);
while (1)
{
for (DWORD i = 0; i < 10000; i++)
for (DWORD i = 0; i < 10000; i++)
_fputts(_T("Operation2.exe \n"), stdout);
}
return 0;
}
12행: GetCurrentProcess 함수를 통해 현재 실행되고 있는 프로세스(GetCurrentProcess 함수를 호출한 프로세스)의 핸들을 얻고 있다. 또한, SetPriorityClass의 두 번째 인자로 HIGH_PRIORITY_CLASS라는 상수값을 전달하여 프로세스 우선순위가 일반적인 프로세스에 비해 높아지도록 설정하였다.
실행 결과 1:
Operation2.exe
Operation2.exe
Operation2.exe
Operation2.exe
...
실행 결과 2:
생략....
Operation2.exe
OpeOperation2.exe
Operation2.exe
Operation2.exe
Oraperation2.exe
Otion1.eperation2.exe
...
위 예제의 실행 과정은 다음과 같다.
Operation1.exe는 Operation2.exe를 실행시키고, Operation2.exe는 자신의 우선순위를 다른 프로세스에 비해 상대적으로 높이고 있다.
위와 같이 결과는 두 가지 형태로 도출될 수 있는데, 각 결과에 따른 해석은 다음과 같다.
결과 1: Operation2.cpp의 12번째 줄에서 상수 HIGH_PRIORITY_CLASS를 전달하여 우선순위를 높였기 때문에 “Operation2.exe” 문자열이 우선적으로 출력된 뒤, “Operation1.exe” 문자열을 볼 수 있다.
결과 2: 함수 호출이 완료되기 전에 다른 프로세스에게 CPU 할당 시간을 넘겨주기 때문에 “Operation1.exe”가 부분적으로 이어서 출력된다. 이를 통해 Operation1.cpp의 27번째 줄에 있는 _fputts 함수 호출이 완료되기도 전에 다른 프로세스에게 CPU 할당 시간을 넘겨주고 있음을 알 수 있다.
따라서 이와 같은 결론을 내릴 수 있다.
“함수가 호출되어 실행되는 중간에도 CPU의 할당 시간을 다른 프로세스에게 넘겨줄 수 있다.”
02. 커널 오브젝트와 핸들의 종속 관계
커널 오브젝트의 종속 관계
“커널 오브젝트는 Windows 운영체제에 종속적이다”
책에서는 위와 같은 문장을 이해시키기 위해 도서 대여점의 이야기를 예로 들고 있다. (p.175)
위와 같은 문장이 성립하는 이유는 다음과 같이 두 가지로 설명할 수 있다.
1. 커널 오브젝트는 프로세스에 종속적인 것이 아니라, 운영체제에 종속적인 관계로 커널 오브젝트의 소멸 시점은 운영체제에 의해서 결정된다.
2. 커널 오브젝트는 프로세스에 종속적인 것이 아니라, 운영체제에 종속적인 관계로 여러 프로세스에 의해서 접근 가능하다. (함수 호출을 통한 간접 접근)
핸들의 종속 관계
위에서 설명한 것과 반대로 핸들(핸들 테이블)은 운영체제에 종속적이지 않고 프로세스에 종속적이다.
예제를 통한 종속 관계의 이해
다음 예제를 통해 핸들과 커널 오브젝트의 종속 관계를 이해할 수 있다.
커널 오브젝트의 공유 예제
이 예제의 실행 과정은 다음과 같다.
“A 프로세스가 B 프로세스를 생성한다. 그러자 B 프로세스는 자신의 우선순위를 높인다. 잠시 후, A 프로세스는 B 프로세스의 우선순위를 원래대로 돌려놓는다.”
/*
KerObjShare.cpp
프로그램 설명: 커널 오브젝트 공유 예제
*/
#include <stdio.h>
#include <tchar.h>
#include <windows.h>
int _tmain(int argc, TCHAR* argv[])
{
STARTUPINFO si = { 0, };
PROCESS_INFORMATION pi;
si.cb = sizeof(si);
TCHAR command[] = _T("Operation2.exe");
CreateProcess(
NULL, command, NULL, NULL, TRUE,
0, NULL, NULL, &si, &pi
);
DWORD timing = 0;
while (1)
{
for (DWORD i = 0; i < 10000; i++)
for (DWORD i = 0; i < 10000; i++)
_fputts(_T("Parent \n"), stdout);
timing += 1;
if (timing == 2)
SetPriorityClass(pi.hProcess, NORMAL_PRIORITY_CLASS);
}
return 0;
}
위의 예제에서 결과적으로 두 개의 프로세스가 하나의 커널 오브젝트에 순서대로 접근하였다. 한 번은 우선순위를 높이기 위해서, 한 번은 우선순위를 되돌리기 위해서, 즉 커널 오브젝트가 공유되었다.
또한, Operation2.cpp 와 KerObjShare.cpp에서 접근하는 커널 오브젝트는 동일하지만, 핸들을 얻는 방법에는 차이가 있다. 이처럼 핸들을 얻는 방법은 커널 오브젝트의 종류와 상황에 따라서 다양하다.
우리는 위의 예제를 통해서 “커널 오브젝트는 프로세스에 종속적인 것이 아니라, 운영체제에 종속적인 관계로 여러 프로세스에 의해서 접근 가능하다. (함수 호출을 통한 간접 접근)” 을 확인하였다.
PROCESS_INFORMATION 구조체
위에서 사용된 PROCESS_INFORMATION 구조체의 정의는 다음과 같다.
typedef struct _PROCESS_INFORMATION {
HANDLE hProcess; // 프로세스의 핸들
HANDLE hThread; // 쓰레드의 핸들
DWORD dwProcessId; // 프로세스의 ID
DWORD dwThreadId; // 쓰레드의 ID
} PROCESS_INFORMATION;
운영체제는 프로세스를 생성할 때마다 프로세스들을 구분 짓기 위한 ID(식별자)를 할당한다. 위 구조체의 세 번째 멤버 dwProcessId는 새로 생성되는 프로세스 ID정보로 채워진다. 이때 프로세스 핸들과 프로세스 ID는 다음과 같이 이해할 수 있다.
“프로세스 핸들은 프로세스의 커널 오브젝트를 구분 짓기 위한 것이고, 프로세스 ID는 커널 오브젝트가 아닌 프로세스 자체를 구분 짓기 위한 것이다.”
이는 이후에 핸들 테이블에 대해 공부하면 더욱 명확해질 것이다.
다음으로, 두 번째, 네 번째 멤버는 쓰레드에 관한 정보이다. Windows 운영체제는 프로세스를 생성하면, 프로세스 내부적으로 쓰레드라는 개념의 ‘가벼운 프로세스’를 생성해서 이를 통해 main 함수가 호출되게 디자인되어 있다. 즉, CreateProcess 함수를 통해 프로세스를 생성하면 쓰레드라는 시스템 리소스도 더불어 생성된다. 이러한 쓰레드의 핸들과 ID가 두 번째와 네 번째 멤버에 저장된다.
03. 커널 오브젝트와 Usage Count
앞에서 커널 오브젝트 생성 및 소멸의 주체는 운영체제라고 언급하였다. 이번에는 이와 관련된 내용을 다룰 것이다.
CloseHandle 함수에 대한 정확한 이해
A라는 이름의 프로세스가 생성되면, A 프로세스를 위한 커널 오브젝트가 생성된다. 하지만, 프로세스가 소멸된다고 커널 오브젝트가 반드시 소멸되지는 않는다. 이는 운영체제가 결정한다. 그렇다면 운영체제가 커널 오브젝트를 소멸시키는 결정하는 기준은 무엇일까? 이와 관련해서 CloseHandle 함수에 대한 이해가 필요하다.
CloseHandle 함수와 프로세스 소멸
/*
KernelObjProb.cpp
프로그램 설명: CloseHandle 함수 이해 1
*/
#include <stdio.h>
#include <tchar.h>
#include <windows.h>
int _tmain(int argc, TCHAR* argv[])
{
STARTUPINFO si = { 0, };
PROCESS_INFORMATION pi;
si.cb = sizeof(si);
TCHAR command[] = _T("KernelObjProb2.exe");
CreateProcess(
NULL, command, NULL, NULL, TRUE,
0, NULL, NULL, &si, &pi
);
CloseHandle(pi.hProcess);
return 0;
}
/*
KernelObjProb.cpp
프로그램 설명: CloseHandle 함수 이해 2
*/
#include <stdio.h>
#include <tchar.h>
#include <windows.h>
int _tmain(int argc, TCHAR* argv[])
{
DWORD n = 0;
while (n<100)
{
for (DWORD i = 0; i < 10000; i++)
for (DWORD i = 0; i < 10000; i++) //Busy Waiting!!
_fputts(_T("KernelObjProb2.exe \n"), stdout);
n++;
}
return 0;
}
실행 결과:
Press any key to continue KerenerObjProb2.exe
KerenerObjProb2.exe
KerenerObjProb2.exe
KerenerObjProb2.exe
위 예제의 실행 과정은 다음과 같다.
A 프로세스는 실행과정에서 B 프로세스를 생성하고, B 프로세스의 핸들을 이용해서 CloseHandle 함수를 호출한다. CloseHandle 함수에 프로세스를 종료하는 기능이 있다면, B 프로세스는 충분히 실행되지 못할 것이다.
결과를 통해 프로세스 A는 종료되었지만, 프로세스 B에 의한 출력은 계속됨을 확인할 수 있다. 이를 통해 CloseHandle 함수 호출에 의해 B 프로세스는 소멸되지 않았음을 확인할 수 있다.
커널 오브젝트와 Usage Count
자식 프로세스의 종료코드는 자식 프로세스의 커널 오브젝트에 저장된다. 자식 프로세스가 종료될 때 커널 오브젝트도 동시에 소멸된다면, 부모 프로세스는 종료코드를 얻을 수 없게 된다. 때문에, 프로세스가 종료되었다고 커널 오브젝트까지 동시에 소멸시키지 않는다. 이는 아래의 예제 [6-6,7]을 통해 확인할 수 있으며, 종료코드를 얻기 위해 GetExitCodeProcess 함수를 호출하고 있다.
/*
OperationStateParent.cpp
프로그램 설명: 프로그램 실행 결과에 따른 반환값 확인
*/
#include "stdio.h"
#include "tchar.h"
#include "windows.h"
int _tmain(int argc, TCHAR* argv[])
{
STARTUPINFO si = { 0, };
PROCESS_INFORMATION pi;
DWORD state;
si.cb = sizeof(si);
si.dwFlags = STARTF_USEPOSITION | STARTF_USESIZE;
si.dwX = 100;
si.dwY = 200;
si.dwXSize = 300;
si.dwYSize = 200;
si.lpTitle = _T("return & exit");
TCHAR command[] = _T("OperationStateChild.exe");
CreateProcess (NULL, command,
NULL, NULL, TRUE,
CREATE_NEW_CONSOLE,
NULL, NULL,
&si, &pi
); // CreateProcess
for (DWORD i = 0; i < 10000; i++)
for (DWORD i = 0; i < 10000; i++) //Busy Waiting!!
// WaitForSingleObject(pi.hProcess, INFINITE);
GetExitCodeProcess(pi.hProcess, &state);
if (state == STILL_ACTIVE)
_tprintf(_T("STILL_ACTIVE \n\n"));
else
_tprintf(_T("state : %d \n\n"), state);
// CloseHandle(pi.hProcess);
return 0;
}
/*
OperationStateChild.cpp
프로그램 설명: 프로그램 실행 결과에 따른 반환값
*/
#include "stdio.h"
#include "stdlib.h"
#include "tchar.h"
#include "windows.h"
int _tmain(int argc, TCHAR* argv[])
{
float num1, num2;
_fputts(_T("Return Value Test \n"), stdout);
_tscanf(_T("%f %f"), &num1, &num2);
if (num2 == 0)
{
exit(-1); // or return -1;
}
_tprintf(_T("STILL_ACTIVE \n\n"));
return 1;
}
+ GetExitCodeProcess 함수는 첫 번째 인자로 전달된 핸들이 가리키는 프로세스가 반환하는 종료코드를 얻기 위해 사용되는 함수로, 전달된 핸들에 해당하는 프로세스가 종료되지 않고 실행 중이라면, STILL_ACTIVE를 반환한다.
Windows는 이러한 정책을 기반으로 커널 오브젝트 소멸시기를 결정짓기 위해 Usage Count(참조 횟수)라는 것을 관리한다. 이 Usage Count가 0이 되는 순간, 해당 커널 오브젝트는 소멸된다.
프로세스는 생성과 동시에 커널 오브젝트의 Usage Count가 1이 된다. 만약 생성과 동시에 0으로 초기화된다면, 커널 오브젝트 소멸 원칙에 의해 생성과 동시에 소멸될 것이다.
이렇게 초기화된 이후부터는 커널 오브젝트에 접근 가능한 대상이 늘어날 때마다 Usage Count가 하나씩 증가한다. 접근 가능 대상이 늘어난다는 것은 커널 오브젝트에 접근 가능한 핸들 개수의 증가를 의미한다. 따라서, 자식 프로세스의 Usage Count는 2가 되어야 한다. 왜냐하면 부모 프로세스가 CreateProcess 함수 호출 과정에서 자식 프로세스의 핸들을 얻기 때문이다.
Usage Count와 CloseHandle
다음으로, Usage Count가 0으로 감소하여 커널 오브젝트가 완전히 소멸되는 시점을 알아보자.
위의 [예제 6-6]에서 자식 프로세스를 생성하고, 자식 프로세스가 먼저 종료되었다고 가정하자. 그렇다면, 자식 프로세스 종료 후 커널 오브젝트 상태는 다음과 같다.
비록 자식 프로세스 정보를 담고 있는 커널 오브젝트이지만, Usage Count가 1인 관계로 소멸되지 않는다.
이제 43번째 줄에서 CloseHandle 함수를 호출한다고 가정해 보자. 전달되는 인자가 자식 프로세스 핸들이므로, 자식 프로세스의 커널 오브젝트에 더 이상 접근하지 않겠다는 의미이다. 따라서 이때 Usage Count가 하나 감소한다.
이처럼 CloseHandle 함수는 핸들을 반환하면서 커널 오브젝트의 Usage Count를 하나 감소시키는 기능을 지닌다. 추가적으로 프로세스(쓰레드도 마찬가지)의 경우에는 프로세스가 종료되는 시점에서도 Usage Count가 하나 감소한다.
이를 이용하여 이전에 구현했던 Calculator.cpp의 문제점을 해결할 수 있다. Calculator.cpp에서 계산기 프로세스가 실행 및 종료되었을 때, 해당 프로세스의 커널 오브젝트는 Usage Count = 1이 되어 소멸되지 않는다. 이를 해결하기 위해 부모 프로세스에서 CreateProcess 함수 호출을 통해 계산기 프로세스 생성을 요구한 뒤, 반환받은 프로세스 핸들을 인자로 CloseHandle 호출하여 Usage Count를 하나 감소시키면 자식 프로세스(계산기)가 종료될 경우 커널 오브젝트도 함께 소멸된다.
추가적으로, 바탕화면도 일종의 프로세스이기 때문에 바탕화면에 있는 아이콘을 더블클릭하여 프로세스를 생성할 경우, 이 프로세스의 Usage Count = 2가 된다.
참고 자료:
윤성우. 『뇌를 자극하는 윈도우즈 시스템 프로그래밍』.한빛미디어, 2007.