C/C++ - 윈도우 운영체제에서의 file descriptor와 HANDLE
Windows 운영체제의 핸들(HANDLE) 상속은 지난 글에 이미 설명했습니다.
Win32/C# - 자식 프로세스로 HANDLE 상속
; https://www.sysnet.pe.kr/2/0/13724
C/C++로 만든 프로그램 역시, 결국은 하부에서 Win32 API로 번역되기 때문에 저 원칙을 따릅니다. 그렇다면, 예를 들어
fopen 함수는 파일을 상속 가능한 핸들로 열까요? 아니면 C#처럼 불가능으로 열까요?
WIN03-C. Understand HANDLE inheritance
; https://wiki.sei.cmu.edu/confluence/display/c/WIN03-C.+Understand+HANDLE+inheritance
위의 글에도 설명하고 있지만, fopen은 윈도우의 HANDLE 자원을 상속 가능으로 생성합니다. (이것은, *NIX 시스템에서 file descriptor를 기본 상속하는 것과 유사합니다.)
#include <iostream>
#include <fstream>
int main()
{
FILE* fs;
fopen_s(&fs, "test.dat", "a+"); // inherit handle
getchar(); // 이 시점에 Process Explorer로 확인해 보면 inherit 속성이 설정된 것을 볼 수 있습니다.
fclose(fs);
}
C#의 경우 자식 프로세스에 상속하고 싶은 경우 명시적으로 Win32 API를 호출해야 했지만, C/C++의 경우에는 반대가 된 것입니다. 따라서 상속을 원치 않는다면, 각각의 파일 열기 함수에서 그것을 위한 옵션을 제공해야 합니다.
fopen_s(&fs, "test.dat", "a+N"); // "N"은 상속하지 않음을 의미
fopen_s가 첫 번째 인자를 통해 반환하는 것은 FILE* 타입, 즉 파일에 대한 스트림을 반환하는데요, 사실 이것은 open + fdopen 2가지 동작을 함께 처리해 주고 있는 것이므로 다음과 같이 나눠서 코딩할 수 있습니다.
// #include <io.h>
// #include <fcntl.h>
{
int fd = 0;
// 파일을 여는 시점에, 상속 여부를 _O_NOINHERIT 옵션으로 결정
_sopen_s(&fd, "test.dat", _O_APPEND | _O_NOINHERIT, _SH_DENYNO, _S_IREAD | _S_IWRITE);
FILE* fs = _fdopen(fd, "a+"); // 파일에 대한 스트림 반환
fclose(fs); // fs가 유효한 경우, 스트림을 닫으면 파일(file descriptor)도 닫힘.
}
여기서 FILE 구조체 정의가 재미있는데요,
// C:\Program Files (x86)\Windows Kits\10\Include\10.0.22621.0\ucrt\corecrt_wstdio.h
typedef struct _iobuf
{
void* _Placeholder;
} FILE;
아무 정보도 없습니다. ^^; 이것은 의도적으로 내부 구현을 노출시키지 않기 위해 전면에 내세운 타입이라서 그런 것이고, 실제 동작에서는 __crt_stdio_stream_data 타입이 사용됩니다. (그래서인지 Visual C++ 내부 소스에서는 저 FILE* 변수를 종종 public_stream이라고 작명합니다.)
// C:\Program Files (x86)\Windows Kits\10\Source\10.0.22621.0\ucrt\inc\corecrt_internal_stdio.h
struct __crt_stdio_stream_data
{
union
{
FILE _public_file;
char* _ptr;
};
char* _base;
int _cnt;
long _flags;
long _file; // file descriptor
int _charbuf;
int _bufsiz;
char* _tmpfname;
CRITICAL_SECTION _lock;
};
class __crt_stdio_stream
{
public:
// ...[생략]...
explicit __crt_stdio_stream(FILE* const stream) throw()
: _stream(reinterpret_cast<__crt_stdio_stream_data*>(stream))
{
}
// ...[생략]...
private:
__crt_stdio_stream_data* _stream;
};
이렇게 숨겨진 FILE 구조체의 값을 접근하려면 함수로 공개된 것이 있어야 합니다. 아래는 FILE 구조체의 _file 필드를 반환해 주는 _fileno의 사용 예입니다.
{
int fd = 0;
_sopen_s(&fd, "test.dat", _O_APPEND | _O_NOINHERIT, _SH_DENYNO, _S_IREAD | _S_IWRITE);
FILE* fs = _fdopen(fd, "a+");
// C:\Program Files (x86)\Windows Kits\10\Source\10.0.22621.0\ucrt\stdio\fileno.cpp
int fd2 = _fileno(fs);
_ASSERT(fd == fd2);
_close(fd);
}
엄밀히 말해서, file descriptor는 윈도우 운영체제에서는 필요 없는 개념입니다. *NIX에서는 운영체제로부터 open한 결과가 file descriptor라서 꼭 필요하지만, 윈도우의 경우 kernel로부터 반환받는 값은 HANDLE이기 때문입니다.
문제는 C 런타임 라이브러리가 근본적으로 *NIX 운영체제하에서 개발되었고, file descriptor가 HANDLE과 의미는 유사하다고 하지만 실제 동작 방식은 다르기 때문에 호환이 안 됩니다. 가령, file descriptor가 0은 STDIN, 1은 STDOUT, 2는 STDERR를 가리키는데, 이것을 그냥 HANDLE로 바꿔버리면 문제가 발생하게 됩니다. (HANDLE은 0을 쓰지 않고, 다음 HANDLE에 대한 증가는 4단위로 이뤄집니다.)
이 외에도, "
리눅스 API의 모든 것, 기초 리눅스 API" 책에 나오듯이,
https://broman.dev/download/The%20Linux%20Programming%20Interface.pdf#page=117
SUSv3 specifies that if open() succeeds, it is guaranteed to use the lowest-numbered
unused file descriptor for the process. We can use this feature to ensure that a file
is opened using a particular file descriptor. For example, the following sequence
ensures that a file is opened using standard input (file descriptor 0)
윈도우 환경의 CRT에서도 저 규칙을 지켜 file descriptor 번호가 할당됩니다.
결국 리눅스에서의 file 관련 함수들과 윈도우의 HANDLE 처리의 간극을 해결하기 위해 윈도우는 file descriptor와 HANDLE의 매핑 테이블을 관리합니다. 그래서 open으로 파일을 열 때마다 3번부터 시작하는 fd를 할당한 다음 그것과 HANDLE 정보를 연결합니다. 물론 그러한 처리는 CRT 내부적으로 __pioinfo 포인터 배열에 fd를 인덱스로, __crt_lowio_handle_data 구조체를 참조하는 식으로 관리됩니다.
// C:\Program Files (x86)\Windows Kits\10\Source\10.0.10240.0\ucrt\inc\corecrt_internal_stdio.h
extern "C" extern __crt_stdio_stream_data** __piob;
// C:\Program Files (x86)\Windows Kits\10\Source\10.0.22621.0\ucrt\inc\corecrt_internal_lowio.h
#define IOINFO_ARRAYS 128
#define IOINFO_L2E 6
#define IOINFO_ARRAY_ELTS (1 << IOINFO_L2E)
#define _pioinfo(i) (__pioinfo[(i) >> IOINFO_L2E] + ((i) & (IOINFO_ARRAY_ELTS - 1)))
extern __crt_lowio_handle_data_array __pioinfo;
typedef __crt_lowio_handle_data* __crt_lowio_handle_data_array[IOINFO_ARRAYS];
#define _osfhnd(i) (_pioinfo(i)->osfhnd)
#define _osfile(i) (_pioinfo(i)->osfile)
struct __crt_lowio_handle_data
{
CRITICAL_SECTION lock;
intptr_t osfhnd; // underlying OS file HANDLE
__int64 startpos; // File position that matches buffer start
unsigned char osfile; // Attributes of file (e.g., open in text mode?)
__crt_lowio_text_mode textmode;
__crt_lowio_pipe_lookahead _pipe_lookahead;
uint8_t unicode : 1; // Was the file opened as unicode?
uint8_t utf8translations : 1; // Buffer contains translations other than CRLF
uint8_t dbcsBufferUsed : 1; // Is the dbcsBuffer in use?
char mbBuffer[MB_LEN_MAX]; // Buffer for the lead byte of DBCS when converting from DBCS to Unicode
// Or for the first up to 3 bytes of a UTF-8 character
};
음... 너무 깊이 들어가지 말고 ^^ 그냥 어쩔 수 없이 file descriptor와 HANDLE을 연결하는 방법이 필요했고 Visual C++의 CRT 내부에 그것이 구현돼 있다고 알면 되겠습니다.
그래도 이쯤 되면, file descriptor와 HANDLE, FILE (Stream)의 관계를 대강 이해했을 것입니다. 그리고, 이들 사이의 변환(?)도 이제 다음과 같이 정리해 볼 수 있습니다.
_fileno
입력: FILE*
출력: file descriptor (FILE 구조체 내부에 보관하고 있는 file descriptor를 반환)
_fdopen
입력: file descriptor
출력: FILE* (__piob에서 비어 있거나/할당해서 FILE Stream을 반환, __piob는 일종의 object pool 역할)
_get_osfhandle
입력: file Descriptor
출력: HANDLE (__pioinfo 테이블을 참조해서 file descriptor에 대한 HANDLE을 반환)
_open_osfhandle
입력: HANDLE
출력: file Descriptor (__pioinfo 테이블에 빈 file descriptor를 찾아 반환, __pioinfo는 일종의 object pool 역할)
저걸 연결하면, 예를 들어 FILE 스트림 개체로부터 HANDLE을 가져오는 것도 가능합니다.
#include <iostream>
#include <fstream>
int main()
{
FILE* fs;
fopen_s(&fs, "test.dat", "a+"); // inherit handle
HANDLE hFile = (HANDLE)_get_osfhandle(_fileno(fs));
fclose(fs);
}
반면, 다른 2개의 함수는 단순히 값을 가져오는 수준이 아니므로 주의를 요합니다. 가령, 동일한 FILE 스트림을 2개 열었을 때 그것을 fclose로 2번 닫으면,
{
int fd = 0;
_sopen_s(&fd, "test.dat", _O_APPEND | _O_NOINHERIT, _SH_DENYNO, _S_IREAD | _S_IWRITE);
FILE* fs = _fdopen(fd, "a+");
FILE* fs2 = _fdopen(fd, "r");
_ASSERT(fs != fs2);
fclose(fs);
fclose(fs2); // ASSERT
}
2번째 fclose에서 ASSERT가 발생합니다.
---------------------------
Microsoft Visual C++ Runtime Library
---------------------------
Debug Assertion Failed!
Program: ...\ConsoleApplication1\x64\Debug\ConsoleApplication1.exe
File: minkernel\crts\ucrt\src\appcrt\lowio\close.cpp
Line: 56
Expression: (_osfile(fh) & FOPEN)
For information on how your program can cause an assertion
failure, see the Visual C++ documentation on asserts.
(Press Retry to debug the application)
왜냐하면, _fdopen이 열은 FILE 스트림은 동일한 HANDLE에 대해 다른 Stream을 생성하는 경우이기 때문입니다. 따라서, 만약 같은 파일에 대해 FILE 스트림을 여러 개 생성하고 싶다면, open 또는
dup을 해야 합니다. (dup은 원본 file descriptor의 HANDLE이 가진 Inherit 속성을 무시하고 무조건 상속 가능 유형의 HANDLE을 생성합니다.)
또한, _open_osfhandle도 (이름에서 get이 아닌 open인 것처럼) 단순히 기존 값을 가져오는 것이 아닌, HANDLE에 대해 빈 file descriptor를 매번 받아오는 식입니다.
int fd = 0;
_sopen_s(&fd, "test.dat", _O_APPEND | _O_NOINHERIT, _SH_DENYNO, _S_IREAD | _S_IWRITE);
FILE* fs = _fdopen(fd, "a+");
int fd2 = _fileno(fs);
_ASSERT(fd == fd2);
{
HANDLE hHandle = (HANDLE)_get_osfhandle(fd);
int fd3 = _open_osfhandle((intptr_t)hHandle, 0);
_ASSERT(fd2 + 1 == fd3);
int fd4 = _open_osfhandle((intptr_t)hHandle, 0);
_ASSERT(fd3 + 1 == fd4);
}
따라서, 저런 경우 _close(fd3), _close(fd4)로 닫으면 2번째 닫기에서 (이미 HANDLE을 닫았으므로) 예외가 발생합니다.
저 함수의 진정한 의도는,
CreateFile로 열은 파일에 대해 file descriptor를 최초 구할 때만 호출하라고 있는 것입니다. (만약 2번 이상 호출하면 __pioinfo에
닫히지 않을 수밖에 없는 닫히지 않은 file descriptor가 쌓이게 됩니다.)
결국, _fileno, _get_osfhandle 함수만 Pool과 관련돼 있지 않아 안심하고 어느 상황에서든 호출할 수 있습니다.
마지막으로, C++ 파일 클래스의 상속에 관해 살펴보면,
#include <iostream>
#include <fstream>
int main()
{
std::ofstream outFile("filename.txt"); // inherit handle
std::cout << "Hello World!\n";
getchar();
}
기본적으로 상속 가능한 핸들로 열리는 것을 확인할 수 있습니다. 한 가지 재미있는 점은, C++부터는 OS 종속적인 면을 제거하기 위해 file descriptor / HANDLE과의 연관성을 숨겼다는 특징이 있습니다.
심지어, Visual C++의 경우 예전에는 ofstream/ifstream에서 HANDLE을 반환하는 함수를 제공했지만 근래에는 그것마저 없앴다는 기록도 보입니다. 이것을 달리 말하면 ofstream/ifstream에 대해서는 상속을 제어할 방법이 없다는 것입니다. 단지, 꼭 이것을 원한다면 fstream을 FILE*을 거쳐 생성하는 식으로 처리해야 합니다.
{
FILE* fs;
fopen_s(&fs, "filename_o.txt", "a+N"); // 상속 불가능으로 stream을 열고,
std::ofstream outFile(fs); // 그것을 ofstream에 전달
outFile.close();
}
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]