C++ - _O_WTEXT, _O_U16TEXT, _O_U8TEXT의 Unicode stream 모드
지난 글에서, text 모드가 가진 translated 특징을 알아봤는데요,
C++ - 파일에 대한 Text 모드의 "translated" 동작
; https://www.sysnet.pe.kr/2/0/13766
이번에는 _O_WTEXT, _O_U16TEXT, _O_U8TEXT 3가지 모드에서 지원하는 "Unicode mode"를 설명해 보겠습니다. (_O_TEXT는 ANSI text mode입니다.)
3가지 모드 중에, 우선 _O_U8TEXT 옵션을 먼저 예로 들겠습니다. 아래의 코드는 해당 옵션을 적용해 test_utf8_bom_auto.txt 파일을 생성하는데요,
{
int fd = 0;
_sopen_s(&fd, "test_utf8_bom_auto.txt", _O_CREAT | _O_TRUNC | _O_RDWR
| _O_U8TEXT, _SH_DENYNO, _S_IREAD | _S_IWRITE);
_close(fd); // 열기만 하고 닫았음에도 파일의 크기가 3바이트가 됩니다.
}
_sopen_s 함수의 호출 인자에 _O_U8TEXT 모드를 사용했기 때문에, 함수가 실행되자마자 파일에는 UTF-8 BOM이 기록됩니다.
c:\temp> powershell Format-Hex -Path test_utf8_bom_auto.txt
Path: C:\temp\test_utf8_bom_auto.txt
00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
00000000 EF BB BF 
이렇게 유니코드 모드로 열린 파일은 재미있는 특징이 하나 있는데요, 반드시 UTF-16 인코딩으로 데이터 I/O를 해야 한다는 제약이 있습니다. 이를 위해 사용되는 현실적인 방법은 wchar_t 타입의 데이터를 사용하는 건데요,
(_O_U8TEXT 모드의 경우) 입력 시에는 파일에 있던 utf-8 글자가 wchar_t로 변환되면서 UTF-16 인코딩이 되고, 출력 시에는 UTF-16 인코딩 상태의 wchar_t 데이터가 UTF-8로 변환돼 기록이 되는 식입니다. 예를 들어, 다음과 같이 wchar_t로 데이터를 쓰면,
{
int fd = 0;
_sopen_s(&fd, "test_utf8_bom_auto.txt", _O_CREAT | _O_TRUNC | _O_RDWR | _O_U8TEXT, _SH_DENYNO, _S_IREAD | _S_IWRITE);
const wchar_t* text = L"\xD803\xDC80test한\n"; // 0xd803, 0xdc80: U+10C80 문자의 UTF-16 인코딩 값
_write(fd, text, wcslen(text) * sizeof(wchar_t));
_close(fd);
}
파일에는 이런 식으로 바이트가 저장됩니다.
00000000 EF BB BF F0 90 B2 80 74 65 73 74 ED 95 9C 0D 0A ð²testí..
// 0xef, 0xbb, 0xbf: UTF-8 BOM (_O_U8TEXT로 인해 _sopen_s 호출 시점에 기록됨)
// 0xf0, 0x90, 0xb2, 0x80: U+10C80 문자 (UTF-8로 인코딩된 값)
// 0x74, 0x65, 0x73, 0x74: "test"
// 0xed, 0x95, 0x9c: "한" U+d55c (UTF-8로 인코딩된 값)
// 0x0d, 0x0a: CRLF (translated 모드이기 때문에 \n이 CR-LF로 변환됨)
물론, char로 데이터를 쓰는 것도 가능합니다. 단지 그런 경우에는 wchar_t에 해당하는 2바이트 내용을 그대로 맞춰줘야 합니다. 만약 그렇지 않고 char 데이터를 쓰면,
{
int fd = 0;
_sopen_s(&fd, "test_utf8_bom_auto_char.txt", _O_CREAT | _O_TRUNC | _O_RDWR| _O_U8TEXT, _SH_DENYNO, _S_IREAD | _S_IWRITE);
const char* text = "test1"; // (2바이트 정렬이 아닌) 5바이트이므로,
_write(fd, text, strlen(text)); // crash 발생
_close(fd);
}
디버그 모드로
실행 시 다음과 같은 assertion 에러가 발생합니다. (릴리스 모드로 실행하면 이벤트 로그에 crash 기록이 남습니다.)
Debug Assertion Failed!
Program: ...u8text_u16text\unicode_mode\x64\Debug\ConsoleApplication1.exe
File: minkernel\crts\ucrt\src\appcrt\lowio\write.cpp
Line: 659
Expression: buffer_size % 2 == 0
설령 저 오류를 피하기 위해 억지로 2바이트 정렬을 맞춘다고 해도,
const char* text = "test";
_write(fd, text, strlen(text)); // 4바이트, 비정상 데이터 출력
_close(fd);
출력 파일을 보면 우리가 원하는 "test"가 아닌 엉뚱한 데이터를 확인할 수 있습니다.
00000000 EF BB BF E6 95 B4 E7 91 B3 æ´ç³
// 0xef, 0xbb, 0xbf: UTF-8 BOM (_O_U8TEXT로 인해 _sopen_s 시점에 기록됨)
// 0xe6, 0x95, 0xb4: "te" (UTF-8로 인코딩된 값)
// 0xe7, 0x91, 0xb3: "st" (UTF-8로 인코딩된 값)
왜냐하면, (_O_U8TEXT로 인해) unicode 모드로 동작하는 _write는 입력 데이터를 wide character로 간주해 UTF-16LE로 인코딩된 문자라고 보기 때문입니다. 즉, "te"에 해당하는 '0x74 0x65' 2바이트 데이터를
'U+6574' 문자라고 여기고 그에 해당하는 UTF-8 인코딩 값인 '0xe6 0x95 0xb4'를 출력하는 식입니다.
파일은 생성된 이후에도
_setmode 함수를 이용해 모드를 변경할 수 있습니다. 예를 들어, 이전의 코드를 다음과 같이 변경하는 것도 가능한데요,
int fd = 0;
// 생성 시점에 BOM을 출력하지 않음
_sopen_s(&fd, "test_utf8_no_bom.txt", _O_CREAT | _O_TRUNC | _O_RDWR, _SH_DENYNO, _S_IREAD | _S_IWRITE);
_setmode(fd, _O_U8TEXT); // 이후에 바꿨다고 해서 BOM이 추가되지는 않음
const wchar_t* text = L"test";
_write(fd, text, wcslen(text) * sizeof(wchar_t));
_close(fd);
이에 대한 출력 결과를 보면 다음과 같습니다.
00000000 74 65 73 74 test
보는 바와 같이 "BOM(0xef, 0xbb, 0xbf)" 없이 곧바로 "test"에 해당하는 UTF-8 인코딩 값이 나오는데요, 만약 저런 경우에 BOM을 추가하고 싶다면 _setmode로 변경하기 전에 char로 BOM을 직접 출력해야 합니다.
int fd = 0;
_sopen_s(&fd, "test_utf8_bom_manual.txt", _O_CREAT | _O_TRUNC | _O_RDWR, _SH_DENYNO, _S_IREAD | _S_IWRITE);
char bom[] = { 0xEF, 0xBB, 0xBF };
_write(fd, bom, sizeof(bom)); // _setmode 변경 전에 BOM을 직접 출력
_setmode(fd, _O_U8TEXT);
const wchar_t* text = L"test";
_write(fd, text, wcslen(text) * sizeof(wchar_t));
_close(fd);
00000000 EF BB BF 74 65 73 74 test
만약 _setmode 후에 변경하려고 하면 어떻게 될까요? 그런 상황이라면, _setmode로 인해 uncode 모드로 바뀌었기 때문에 3바이트 BOM을 출력하는 경우 이전에 설명했던 제약으로 인해 crash가 발생합니다.
_setmode(fd, _O_U8TEXT); // 유니코드 모드로 변경
char bom[] = { 0xEF, 0xBB, 0xBF };
_write(fd, bom, sizeof(bom)); // 이 시점에 BOM을 출력하면 (2바이트 정렬이 아닌 3바이트이므로) crash 발생
그렇다고 wchar_t로 BOM을 출력하면,
_setmode(fd, _O_U8TEXT);
wchar_t bom[] = { 0xEF, 0xBB, 0xBF };
_write(fd, bom, 3 * sizeof(wchar_t));
이제는,
(0x00, 0xEF), (
0x00, 0xBB), (
0x00, 0xBF)를 한 글자로 인식해 그에 해당하는 UTF-8 인코딩을 하기 때문에 BOM이 아닌 데이터가 출력됩니다.
이 정도면 대충 설명이 끝났군요. 다행히 이런 원칙은 _O_U16TEXT에도 그대로 적용되므로 중복 설명은 생략하겠습니다. 단지, wchar_t가 이미 UTF-16 인코딩 데이터를 담고 있으므로 _O_U16TEXT 모드로 열린 파일에 대한 출력은 딱히 변환 단계를 거치지 않고 곧바로 출력이 된다는 정도만 알아두시면 되겠습니다.
참고로, _O_U8TEXT, _O_U16TEXT 옵션으로 파일을 열 때는, 단지 힌트로만 작용을 할 뿐 우선순위는 파일에 기록된 BOM이 더 높습니다. 즉, 파일의 BOM이 UTF-16LE(0xff, oxfe)라면, 그 파일을 열 때 _O_U8TEXT를 지정한다고 해도 UTF-16LE로 간주합니다.
자, 그렇다면 이제 마지막 남은 _O_WTEXT가 있군요. ^^ 문서상으로는 _O_U16TEXT와 일단 유사한 듯 보이지만,
실제로 테스트를 해보면 혼란스러운 점이 있습니다. 위의 설명에 따르면 _O_U16TEXT 옵션이 "no BOM" 텍스트 모드로 연다고 돼 있지만 실제로는 (위에서 설명했듯이) BOM을 기본적으로 추가합니다. 반면 _O_WTEXT는 출력 파일을 열었을 때 BOM을 추가하지 않습니다.
이 외에도 _O_WTEXT는 신비한(?) 면들이 있는데요, 예를 들어 BOM이 없는 일반 ascii 파일을,
00000000 74 65 73 74 test
_O_WTEXT로 열어 UTF-16 데이터를 추가/출력해 보면,
{
int fd = 0;
_sopen_s(&fd, "test_unicode_no_bom.txt", _O_WRONLY | _O_APPEND | _O_WTEXT, _SH_DENYNO, _S_IWRITE);
const wchar_t* text = L"\xD803\xDC80test한\n"; // 0xd803,0xdc80 == U+10C80
_write(fd, text, wcslen(text) * sizeof(wchar_t));
_close(fd);
}
결과가 이렇게 나옵니다.
00000000 74 65 73 74 03 D8 80 DC 74 00 65 00 73 00 74 00 test.ØÜt.e.s.t.
00000010 5C D5 0D 0A 00 \Õ...
// 74, 65, 73, 74: 기존에 저장이 돼 있던 "test"
// 03, d8, 80, dc: U+10C80에 해당하는 UTF-16 인코딩 값
// 74, 00, 65, 00, 73, 00, 74, 00: "test"의 UTF-16 인코딩 값
// 5C, d5: "한"의 UTF-16 인코딩 값
// 0d, 0a, 00: CRLF + '\0'
일단, 이전 데이터인 test가 ASCII 문자로 저장돼 있는 상태에서 UTF-16LE로 인코딩된 데이터들이 따라오고 있어 정상인 듯한데요, 문제는 마지막의 "0d 0a 00"입니다. 즉, '\n' 글자가 펼쳐진 것인데 원래 UTF-16LE 인코딩이라면 0x0d, 0x00, 0x0a, 0x00이 되어야 합니다.
또 하나 재미있는 점은, _O_WTEXT로 연 파일을 명시적으로 _O_WTEXT로 모드로 다시 변경해 보면,
{
int fd = 0;
_sopen_s(&fd, "test_unicode_no_bom.txt", _O_WRONLY | _O_APPEND | _O_WTEXT, _SH_DENYNO, _S_IWRITE);
int old_mode = _setmode(fd, _O_WTEXT);
const wchar_t* text = L"\xD803\xDC80test한\n"; // 0xd803,0xdc80 == U+10C80
_write(fd, text, wcslen(text) * sizeof(wchar_t));
_close(fd);
}
출력 결과가 다음과 같이 나옵니다.
00000000 74 65 73 74 03 D8 80 DC 74 00 65 00 73 00 74 00 test.ØÜt.e.s.t.
00000010 5C D5 0D 00 0A 00 \Õ....
보는 바와 같이 '\n' 글자가 (UTF-16 인코딩에 맞게) 0d, 00, 0a, 00 4바이트로 출력됐습니다. 위에서 또 하나 이상한 점이 있는데요, _setmode는 해당 파일의 이전 모드를 반환값으로 돌려주는데, 재미있게도 old_mode 변수에는 0x4000(_O_TEXT) 값이 들어 있습니다. 하지만, _setmode를 한 번 더 해서 살펴보면,
_sopen_s(&fd, "test_unicode_no_bom2.txt", _O_WRONLY | _O_APPEND | _O_WTEXT, _SH_DENYNO, _S_IWRITE);
int old_mode = _setmode(fd, _O_WTEXT); // old_mode == _O_TEXT (0x4000)
old_mode = _setmode(fd, _O_WTEXT); // old_mode == _O_WTEXT (0x10000)
이번엔 old_mode에 0x10000(_O_WTEXT) 값이 들어 있습니다. 정말 혼란스럽죠? ^^; 이것이 버그인지, 의도한 것인지는 알 수 없으나 암튼 저런 결과로 인해 개인적으로는 _O_WTEXT를 사용하지 않는 것을 권장합니다. (혹시, _O_WTEXT 옵션에 대해 깔끔하게 설명해 주실 분 계실까요? ^^)
정리하자면, _O_U8TEXT, _O_U16TEXT 옵션은 대상이 되는 파일의 데이터에 대한 인코딩을 나타낸다는 점을 기억하시기 바랍니다. 즉, 코드에서 wchar_t로 넘겨주는 데이터는 UTF-16LE로 인코딩된 것이고 이것을 대상 파일에 설정한 _O_U8TEXT, _O_U16TEXT 옵션에 따라 변환해 기록하는 것입니다. 반대로 읽는 동작에서는, 파일에 있는 UTF-8/UTF-16LE 데이터를 읽어서 UTF-16LE로 변환한 다음 그것을 wchar_t 변수에 담아줍니다.
이와 유사한 이야기를 전에 C#으로도 한 번 설명한 적이 있습니다.
C# 문자열의 인코딩이란?
; https://www.sysnet.pe.kr/2/0/1461
C#도 string 변수 자체에는 UTF-16LE 인코딩으로 데이터를 보관하고 있지만, 대상 파일에 쓸 때는 지정한 인코딩에 따라 변환시키는 과정을 거치고, 읽는 작업도 마찬가지로 파일의 인코딩된 데이터를 string 변수에 담을 때는 UTF-16LE로 변환된 결과를 보관하는 것입니다.
참고로, 아래의 사이트에 접속해,
Compiler Explorer
; https://compiler-explorer.com/
다음의 코드를 입력한 후,
#include <iostream>
int main(int, char*[])
{
const wchar_t wcstr[] = L"test한";
std::wcout << wcstr << std::endl;
}
"x64 msvc v19.latest" 탭에 출력된 어셈블리 코드를 보면, wchar_t 문자열이 UTF-16LE로 인코딩돼 있음을 확인할 수 있습니다.
$SG55695 DB 't', 00H, 'e', 00H, 's', 00H, 't', 00H, '\', 0d5H, 00H, 00H
...[생략]...
"한" 글자가 '\', 0d5H로 인코딩돼 있는데요, 각각 0x5c, 0xd5로 UTF-16LE 인코딩 값에 해당합니다.
(
첨부 파일은 이 글의 예제 코드를 포함합니다.)
지금까지 설명한 것이 좀 복잡한 듯한데요, 그래도 Win32 API를 직접 사용하는 예제에 비하면,
Conventional wisdom is retarded, aka What the @#%&* is _O_U16TEXT?
; https://archives.miloush.net/michkap/archive/2008/03/18/8306597.html
C/C++ 표준 라이브러리 사용이 꽤나 편리하다는 것을 알 수 있습니다. ^^
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]