UTF-8 UTF-16 문자열 표현 방법

Posted on By frozenrainyoo

UTF-8

UTF-8 문자열은 8bit 기반으로 다음과 같은 4가지 패턴으로 표현된다.

0xxxxxxx                            // 000000-00007F
110xxxxx 10xxxxxx                   // 000080-0007FF
1110xxxx 10xxxxxx 10xxxxxx          // 000800-00FFFF
11110zzz 10zzxxxx 10xxxxxx 10xxxxxx // 010000-10FFFF

0xxxxxxx

ASCII와 동일한 범위.
맨 앞 바이트가 0으로 시작하면 한 바이트짜리 ASCII문자이다.

110xxxxx 10xxxxxx

첫 바이트는 110으로 시작하고, 10으로 시작하는 한 바이트가 따라온다.

1110xxxx 10xxxxxx 10xxxxxx

첫 바이트는 1110으로 시작하고, 10으로 시작하는 두 바이트가 따라온다.

11110zzz 10zzxxxx 10xxxxxx 10xxxxxx

첫 바이트는 11110으로 시작하고, 뒤에 10으로 시작하는 세 바이트가 따라온다.

아래는 utf-8 문자인지 유효성 체크하는 코드이다.

// 0b10xxxxxx
bool isTrailing(unsigned char b) {
    return (b & 0xC0) == 0x80; // 0xC0=0b11000000, 0x80=0b10000000
}

// 0b0xxxxxxx
bool isLeading1(unsigned char b) {
    return (b & 0x80) == 0;
}

// 0b110xxxxx
bool isLeading2(unsigned char b) {
    return (b & 0xE0) == 0xC0; // 0xE0=0b11100000
}

// 0b1110xxxx
bool isLeading3(unsigned char b) {
    return (b & 0xF0) == 0xE0; // 0xF0=0b11110000
}

// 0b11110xxx
bool isLeading4(unsigned char b) {
    return (b & 0xF8) == 0xF0; // 0xF8=0b11111000
}

bool validateUTF8(const unsigned char* buffer, size_t len) {
    int expected = 0;
    for (size_t i = 0; i < len; ++i) {
        unsigned char b = buffer[i];
        if (isTrailing(b)) {
            if (expected-- > 0)
                continue;
            return false;
        } else if (expected > 0) {
            return false;
        }

        if (isLeading1(b)) {
            expected = 0;
        } else if (isLeading2(b)) {
            expected = 1;
        } else if (isLeading3(b)) {
            expected = 2;
        } else if (isLeading4(b)) {
            expected = 3;
        } else {
            return false;
        }

        return (expected == 0);
    }
}

UTF-16

아래는 UTF-16BE 기준으로 이진수로 표현한 것이다.
UTF-8은 8bit 기반으로 UTF-16의 경우 16bit 기반이다.

// UTF-8과 코드포인트에 따른 비교
00000000 0xxxxxxx                   // 000000-00007F
00000xxx xxxxxxxx                   // 000080-0007FF
xxxxxxxx xxxxxxxx                   // 000800-00FFFF
110110zz zzxxxxxx 110111xx xxxxxxxx // 010000-10FFFF

U' = yyyyyyyyyyxxxxxxxxxx  // U - 0x10000
W1 = 110110yyyyyyyyyy      // 0xD800 + yyyyyyyyyy
W2 = 110111xxxxxxxxxx      // 0xDC00 + xxxxxxxxxx

U+0000~U+FFFF

BMP(Basic Multilingual Plane), 가장 많이 쓰는 문자들이 이 영역에 있다.
단, U+D800~U+DFFF surrogate 영역은 사용되지 않는다. (해당 영역은 서러게이트 문자 영역이다.)

00000000|000zzzzz|xxxxxxyy|yyyyyyyy

16bit로 표현할 수 없는 문자들은 서러게이트 문자 영역에 해당하는 두 개의 16bit 문자로 변환되어 32bit 한쌍으로 010000-10FFFF를 코드 포이트를 표현할 수 있다.

High-Surrogate (U+D800 … U+DBFF)

110110ZZ|ZZxxxxxx

상위 서러게이트는 U+D800 에서 U+DBFF 까지의 값을 갖는다. 즉 최상위비트 6개의 값이 위를 보듯이 110110 으로 일정하다.

Low-Surrogate (U+DC00 … U+DFFF)

110111yy|yyyyyyyy
하위 서러게이트는 U+DC00 에서 U+DFFF 까지의 값을 가지며 최상위비트 6개의 값은 110111 이 된다.

각 서러게이트 문자는 하위 10비트씩의 자유도를 갖는다. 따라서 주어진 문자를 10비트씩 두조각을 내서 상위 서러게이트와 하위 서러게이트에 배정한 것이다.

이 두 개의 서러게이트 문자는 상위 서러게이트, 하위 서러게이트로서 전송이 된다. 이 방법으로 U+10FFFF 까지의 문자를 인코딩 할 수 있다.

Byte Order

시스템에 따라 바이트 오더가 다르게 저장되므로 읽을 때 Byte Order를 확인해야한다 Byte Order에는 Big-Endian, Little-Endian 이 있다.

  • LSB(Least-Significant Byte) 최하위 바이트
  • MSB(Most-Significant Byte) 최상위 바이트
  • 0x5A6C 에서 6C가 LSB, 5A가 MSB이다.
  • 빅 엔디언 머신에서는 MSB가 가장 낮은 주소를 차지하고, 리틀 엔디언 머신에서는 LSB가 가장 낮은 주소를 차지한다.

코드로 현재 시스템의 바이트 오더를 확인하려면 두 가지 방법이 있을 수 있다.

// 첫 번째, 매크로를 확인하여 바이트 오더를 확인할 수 있다.
#include <endian.h>
#if __BYTE_ORDER == __BIG_ENDIAN
// 빅 엔디안
#else
// 리틀 엔디안
#endif

// 두 번째, 시스템 헤더에 __BYTE_ORDER가 정의되어 있지 않을
// 경우도 있을 수 있겠다. 함수를 구현하여 확인할 수 있다.
// union 형의 기능을 활용한다. 모든 멤버가 메모리의 같은 
// 위치에서 시작하여 할당된다.
// 4 byte인 int형으로 1을 설정한 뒤 char형 1 byte로 
// 읽어들여 1이 나오면 little-endian 0이 나오면 bit-endian이 된다.
bool isLittleEndian() {
    union {
        int theInteger;
        char signleByte;
    } endianTest;

    endianTest.theInteger = 1;
    return endianTest.signleByte;
}

𐐷(U+10437)를 표현할 때 Byte Order에 따라 아래와 같이 표현된다.

  • UTF-16BE
    2진수 : 1101 1000 0000 0001 1101 1100 0011 0111
    16진수 : D8 01 DC 37

  • UTF-16LE
    2진수 : 0000 0001 1101 1000 0011 0111 1101 1100
    16진수 : 01 D8 37 DC

  • BOM(Byte Order Mark)
    문서 앞에 Big-Endian인지 Little-Endian인지 체크할 수 있도록 BOM을 추가할 수 있습니다.
    U+FEFF문자를 맨 앞에 붙혀서 순서를 짐작할 수 있도록 한다.
    0xFE 0xFF : Big-Endian
    0xFF 0xFE : Little-Endian

C/C++

utf-8은 char로 표현가능하고 utf-16의 경우 wchar_t로 표현가능하다. 하지만 wchar_t의 경우 리눅스와 윈도우즈의 크기가 다르다. 리눅스계열에서는 wchar_t 크기가 32bit이지만 윈도우즈에서는 16bit이다. 그래서 서로 호환되는 소스에서는 리눅스에서 -fshort-wchar 옵션을 이용하여 wchar_t를 16bit로 빌드하면 서로 쉽게 호환된다. 그래도 디버깅의 어려움 등등의 단점은 존재한다.

추가적으로 c++11에서 char16_t char32_t 타입이 추가되었습니다. 그리고 u8 u U prefix를 붙여 리터럴 문자를 선언할 수 있게 되었다.

const char* nomalChar = "hello"; // const char*
const char* utf8 = u8"hello"; // const char*, encoded as UTF-8
const wchar_t* wideChar =  L"hello"; // const wchar_t*
const char16_t* utf16 =  u"hello"; // const char16_t*, encoded as UTF-16
const char32_t* utf32 =  U"hello"; // const char32_t*, encoded as UTF-32

결론

UTF-8, UTF-16 용량을 생각해야한다면 둘 중에 선택을 해야 한다. 문서에 영문자로 작성된 문서라면 1 byte로 표현가능한 UTF-8로 표현하는게 좋을 것이고, 한글로 작성된 문서라면 UTF-8의 경우 3 bytes, UTF-16의 경우에는 2 bytes로 표현하므로 UTF-16으로 표현하는게 좋을 것이다.

References

https://ko.wikipedia.org/wiki/UTF-8
http://en.wikipedia.org/wiki/UTF-16
Programming Interviews Exposed: Coding Your Way Through the Interview
https://docs.microsoft.com/ko-kr/cpp/cpp/string-and-character-literals-cpp?view=vs-2019