2025. 3. 26. 17:50ㆍ개발/Windows
이 글은 윤성우 님의 '뇌를 자극하는 윈도우즈 시스템 프로그래밍'을 참고하여 공부한 내용입니다.
프로세스는 개별적으로 독립적인 메모리 공간을 할당받아 실행됩니다. 그래서 다른 프로세스와 데이터를 주고받기 위해서는 IPC를 사용해야 합니다. IPC란 Inter-Process Communication의 약자로 프로세스 간 통신기술을 뜻합니다. 그래서 기존에 다루었던 소켓과 파이프, Signal 등의 방법들이 모두 IPC에 해당합니다. 이번에는 Windows를 기준으로 커널 오브젝트와 핸들을 통해 IPC를 알아보겠습니다.
Kernel Object State
...
WaitForSingleObject(pi.hProcess, INFINITE);
GetExitCodeProcess(pi.hProcess, &state);
...
지난 [커널 오브젝트와 오브젝트 핸들] 포스트에서 부모 프로세스에서 자식 프로세스가 종료되었는지 확인해 볼 수 있는 코드를 작성했었습니다. 어떻게 부모 프로세스는 자식 프로세스가 종료가 되었는지 확인할 수 있었던 걸까요. 그건 부모 프로세스에서 WaitForSingleObject 함수를 사용하여 자식 프로세스의 커널 오브젝트 상태를 확인하였기 때문입니다.
커널 오브젝트의 상태는 Signaled와 Non-Signaled 상태로 나뉩니다. 프로세스가 실행 혹은 대기 상태라면 커널 오브젝트는 Non-Signaled 상태를 유지합니다. 그리고 프로세스가 종료되면 커널 오브젝트의 상태는 Signaled로 바뀝니다. 그래서 부모 클래스는 자식 프로세스의 커널 오브젝트 상태를 통해 종료되었는지를 알 수 있었던 것입니다.
추가로 프로세스와 스레드는 커널 오브젝트 상태가 Signaled이면 종료를 의미하지만, 뮤텍스와 파일 등의 커널 오브젝트에서는 의미가 다릅니다. 뮤텍스의 Signaled 상태는 누군가 뮤텍스를 반환하여 획득이 가능한 상태를 나타내며, 파일의 Signaled는 I/O 작업의 완료를 의미합니다.
그래서 WaitForSingleObject / WaitForMultipleObjects 함수는 프로세스의 종료될 때까지 대기하는 함수가 아닌, 인자로 받은 객체의 커널 오브젝트 상태를 확인하여 Signaled가 될거나 제한시간까지 대기하는 함수인 것입니다.
Pipes
파이프는 프로세스 간 양방향 혹은 단방향 데이터 통신에 사용되는 IPC 기법입니다. 단방향 통신의 경우 부모와 자식 프로세스 간 통신에 주로 사용되고 양방향 통신은 서버와 클라이언트 구조에 사용됩니다. 이때 단방향 통신에 사용되는 파이프를 Anonymous Pipe, 양방향 통신에 사용되는 파이프를 Named Pipe라고 합니다.
Anonymous Pipe
익명 파이프라고 부르며, 부모 프로세스와 자식 프로세스 간에 데이터를 전송하는 단방향 파이프입니다. 익명 파이프는 항상 로컬이며, 네트워크를 통한 원격 통신에 사용할 수는 없습니다.
ParentProcess.cpp
#include <windows.h>
#include <iostream>
int main() {
HANDLE hReadPipe, hWritePipe;
SECURITY_ATTRIBUTES sa = {
sizeof(SECURITY_ATTRIBUTES),
NULL,
TRUE };
if (!CreatePipe(&hReadPipe, &hWritePipe, &sa, 0)) {
std::cerr << "CreatePipe failed! Error: " << GetLastError() << std::endl;
return -1;
}
STARTUPINFO si = { 0, };
PROCESS_INFORMATION pi;
si.cb = sizeof(STARTUPINFO);
si.hStdInput = hReadPipe;
si.dwFlags |= STARTF_USESTDHANDLES;
TCHAR cmd[] = L"ChildProcess.exe";
if (!CreateProcess(
NULL,
cmd,
NULL, NULL,
TRUE,
CREATE_NEW_CONSOLE,
NULL, NULL,
&si, &pi))
{
std::cerr << "CreateProcess failed! Error: " << GetLastError() << std::endl;
return -1;
}
const char* message = "Hello from parent!";
DWORD bytesWritten;
WriteFile(hWritePipe, message, strlen(message) + 1, &bytesWritten, NULL);
CloseHandle(hWritePipe);
CloseHandle(hReadPipe);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return 0;
}
ChildProcess.cpp
#include <windows.h>
#include <iostream>
int main() {
char buffer[128];
DWORD bytesRead;
if (ReadFile(GetStdHandle(STD_INPUT_HANDLE), buffer, sizeof(buffer), &bytesRead, NULL)) {
std::cout << "Received from parent: " << buffer << "\n";
}
else {
std::cerr << "ReadFile failed! Error: " << GetLastError() << "\n";
}
return 0;
}
ParentProcess.exe를 통해 실행된 ChildProcess.exe
Received from parent: Hello from parent!
위 예시는 익명 파이프를 이용한 부모와 자식 프로세스 통신 코드입니다. CreatePipe 함수를 사용하여 익명 파이프를 생성하였습니다. 그다음 파이프의 읽기 부분과 쓰기 부분 중, 읽기 파이프 핸들을 STARTUPINFO 구조체를 사용하여 자식 프로세스의 표준입력에 연결하였습니다. 이후 자식 프로세스는 읽기 파이플 핸들에서 받아온 문자열을 출력합니다.
이 코드에서 중요한 부분은 파이프의 사용법이 아닌 핸들의 상속 방법입니다. 부모 프로세스에서 생성한 파이프의 핸들은 단지 정수형 식별자입니다. 이 핸들을 STARTUPINFO를 통해 전달만 하면 자식 프로세스에서는 해당 핸들을 사용할 수 없습니다. 이유는 전해준 핸들이 자식 프로세스의 핸들 테이블에 저장되지 않았기 때문입니다.
Handle Table
각 프로세스는 독립적인 핸들 테이블을 가지게 됩니다. 그리고 프로세스는 이 핸들 테이블을 사용하여 핸들에 맵핑된 커널 오브젝트에 접근할 수 있습니다. 즉, 핸들을 전해줄 뿐만 아니라 전달된 프로세스의 핸들 테이블에 등록도 해야 합니다. 이 등록하는 과정이 위 코드에서는 상속으로 이루어집니다.
BOOL CreatePipe(
[out] PHANDLE hReadPipe,
[out] PHANDLE hWritePipe,
[in, optional] LPSECURITY_ATTRIBUTES lpPipeAttributes,
[in] DWORD nSize
);
우선 핸들을 생성할 때 상속여부를 설정하여, 자식 프로세스에게 핸들을 상속시킬수 있게 준비합니다. CreatePipe 함수는 세 번째 인자인 LPSECURITY_ATTRIBUTES를 통해 상속여부를 지정할 수 있습니다.
typedef struct _SECURITY_ATTRIBUTES {
DWORD nLength;
LPVOID lpSecurityDescriptor;
BOOL bInheritHandle;
} SECURITY_ATTRIBUTES, *PSECURITY_ATTRIBUTES, *LPSECURITY_ATTRIBUTES;
이 구조체에서 중요한 부분은 세번째 인자인 bInheritHandle입니다. 이 값을 기준으로 TRUE이면 자식 프로세스에게 핸들을 상속합니다. 반대로 FALSE면 자식 프로세스는 해당 핸들을 상속받지 못합니다.
파이프 핸들의 상속여부를 지정하였으니 이제 자식 프로세스의 핸들 상속여부를 결정합니다. 이것은 CreateProcess의 5번째 인자인 bInheritHandles의 값을 TRUE로 설정하여 지정할 수 있습니다. 이 값을 설정해야 자식 프로세스의 핸들 테이블에 부모 프로세스의 상속핸들들이 등록됩니다.
또한 익명 파이프는 이름이 없기 때문에 자식 프로세스에서는 핸들을 상속받아도 사용하기 위한 핸들의 값을 모릅니다. 그래서 부모 프로세스는 자식 프로세스에게 핸들의 값을 전달해주어야 합니다. 전달해 주는 방법은 여러 가지가 있지만 위 코드에서는 STARTUPINFO 구조체의 입력 핸들로 전달하였습니다.
Named Pipe
이름이 있는 기명 파이프는 독립적인 관계의 프로세스간에 양방향 혹은 단방향 통신을 지원합니다. 이름이 있다는 뜻은 Windows 커널의 객체 관리자에 저장된다는 의미입니다. 그래서 다른 프로세스들은 객체 관리자를 통해 파이프에 접근이 가능합니다. 그리고 기명 파이프는 하나의 파이프 핸들을 통해 다중 클라이언트 요청을 처리할 수 있는 특징이 있습니다.
간단한 서버 클라이언트 모델을 사용하여 기명 파이프에 대해 알아보겠습니다.
Server.cpp
#include <windows.h>
#include <iostream>
int main() {
HANDLE hPipe;
const wchar_t* pipeName = L"\\\\.\\pipe\\MyPipe";
hPipe = CreateNamedPipe(pipeName, PIPE_ACCESS_DUPLEX,
PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
1, 1024, 1024, 0, NULL);
if (hPipe == INVALID_HANDLE_VALUE) {
std::cerr << "CreateNamedPipe failed! Error: " << GetLastError() << std::endl;
return -1;
}
std::cout << "Waiting for client connection...\n";
if (ConnectNamedPipe(hPipe, NULL)) {
std::cout << "Client connected!\n";
const char* message = "Hello from server!";
DWORD bytesWritten;
WriteFile(hPipe, message, strlen(message) + 1, &bytesWritten, NULL);
char buffer[128];
DWORD bytesRead;
ReadFile(hPipe, buffer, sizeof(buffer), &bytesRead, NULL);
std::cout << "Received from client: " << buffer << std::endl;
}
CloseHandle(hPipe);
std::cin.get();
return 0;
}
Client.cpp
#include <windows.h>
#include <iostream>
int main() {
HANDLE hPipe;
const wchar_t* pipeName = L"\\\\.\\pipe\\MyPipe";
std::cout << "Connecting to server...\n";
hPipe = CreateFile(pipeName, GENERIC_READ | GENERIC_WRITE,
0, NULL, OPEN_EXISTING, 0, NULL);
if (hPipe == INVALID_HANDLE_VALUE) {
std::cerr << "Failed to connect to pipe! Error: " << GetLastError() << std::endl;
return -1;
}
char buffer[128];
DWORD bytesRead;
ReadFile(hPipe, buffer, sizeof(buffer), &bytesRead, NULL);
std::cout << "Received from server: " << buffer << std::endl;
const char* response = "Hello from client!";
DWORD bytesWritten;
WriteFile(hPipe, response, strlen(response) + 1, &bytesWritten, NULL);
CloseHandle(hPipe);
std::cin.get();
return 0;
}
Server.exe
Waiting for client connection...
Client connected!
Received from client: Hello from client!
Client.exe
Connecting to server...
Received from server: Hello from server!
서버에서 기명 파이프를 생성하여 클라이언트의 요청을 받는 코드입니다. 여기서 기명 파이프를 만드는 함수 CreateNamedPipe는 아래와 같이 선언되어 있습니다.
HANDLE CreateNamedPipeW(
[in] LPCWSTR lpName,
[in] DWORD dwOpenMode,
[in] DWORD dwPipeMode,
[in] DWORD nMaxInstances,
[in] DWORD nOutBufferSize,
[in] DWORD nInBufferSize,
[in] DWORD nDefaultTimeOut,
[in, optional] LPSECURITY_ATTRIBUTES lpSecurityAttributes
);
간략하게 중요한 부분만 설명드리면 lpName은 객체 관리자에 등록될 이름입니다. 클라이언트는 해당 이름을 통해 파이프에 접근할 수 있습니다. 그리고 dwOpenMode는 단방향 혹은 양방향 및 비동기 같은 추가적인 설정을 지정할 수 있습니다. dwPipeMode는 사용할 데이터의 타입과 Non-Block 설정을 지정할 수 있습니다. 그리고 nMaxInstance는 생성할 수 있는 파이프 인스턴스의 최대 개수입니다. 이 값을 2 이상으로 설정하면 CreateNamedPipe 함수를 사용할 때마다 새로운 클라이언트와 연결된 파이프 인스턴스를 nMaxInstance 만큼 생성할 수 있습니다.
hPipe = CreateNamedPipe(pipeName, PIPE_ACCESS_DUPLEX,
PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
1, 1024, 1024, 0, NULL);
그래서 위 CreateNamedPipe가 생성할 파이프는 양방향 통신, 데이터 타입 문자열, Block 설정, 연결할 최대 인스턴스 1개 등의 특징을 가지고 있습니다.
이후 ConnectNamedPipe 함수를 통해 클라이언트의 요청을 기다립니다. 요청이 연결되면 파이프 핸들에 WriteFile과 ReadFile 함수를 통해 버퍼를 읽어 옵니다. 또한 버퍼를 사용하기에 데이터의 해석에 주의해야 합니다. 추가로 기명 파이프의 원격 통신은 SMB, Server Message Block 프로토콜을 사용합니다. Windows의 네트워크 드라이브가 SMB 프로토콜을 사용하는 대표적인 서비스입니다.
'개발 > Windows' 카테고리의 다른 글
스택 프레임과 레지스터 (0) | 2025.04.09 |
---|---|
커널 오브젝트와 오브젝트 핸들 (0) | 2025.03.17 |
Windows 프로세스 생성과 소멸 (0) | 2025.03.15 |
레지스터와 명령어 구조 (0) | 2025.03.14 |
Windows 32/64비트 시스템 (0) | 2025.02.26 |