Proven C BookEnglish GitHub

48 공용체와 표현

먼저 알아야 할 것

46장 구조체 · 구조체의 배치
5장 워드와 주소 · 표현을 바이트로 본다
13장 컴파일러 최적화 · 엄격한 앨리어싱

돌아보기

13장에서 “float의 비트를 uint32의 눈으로 읽는” 옛 기법이 엄격한 앨리어싱 위반이며, 옳은 방법은 memcpy나 공용체라고 했다. 그러면 공용체란 정확히 무엇이길래 그 자리에 서는가?

답. 여러 멤버가 같은 기억을 공유하는 타입이다. 구조체가 멤버들을 나란히 놓는다면(46장), 공용체는 멤버들을 겹쳐 놓는다 — 크기는 가장 큰 멤버에 맞고, 어느 순간에든 실제로 담긴 것은 하나다. “같은 비트를 이 눈으로도 저 눈으로도 본다”는 5장의 관점이 문법이 된 것이다.

이 장의 필요성과 맥락

8부를 공용체로 닫는 이유는 그것이 자료의 모양 가운데 마지막 하나여서가 아니다 — 표현과 추상의 분리라는 이 책의 후렴구를 여기서 마지막으로 크게 울리기 때문이다. 5장에서 예약한 엔디안 시연이 실제 코드로 실행되는 자리이기도 하다. 9부의 「깊은 구석들」로 넘어가기 직전에 놓기 알맞다.

이 장이 끝나면

같은 기억을 다른 눈으로 보는 장치 — 공용체다. 그리고 이 장은 표현의 장이기도 하다: 5장에서 예약한 엔디안 관찰 시연을 실행하고, 구조체의 숨은 빈틈(패딩)을 눈으로 확인한다. 표현과 추상의 분리라는 이 책의 후렴구가 마지막으로 크게 울리는 자리다.

이 장에서 답할 질문

  1. 공용체는 그러면 언제 쓰는 것이 정석인가?
  2. 그러면 모든 비트열이 값이 되는가?

(26장의 갈래에서 공용체는 파생 타입이되 집합체는 아니었다 — 한 번에 하나만 살아 있기 때문이라는 그 이유를 이 장에서 실물로 본다.)

48.1 공용체 — 겹쳐 놓기

문법은 구조체와 쌍둥이다. structunion으로 바꾸면 된다:

union bits32 {
    uint32_t as_int;
    float    as_float;
};

멤버 접근도 같다(u.as_int). 다른 것은 기억의 배치뿐이다 — 두 멤버가 같은 4바이트를 공유하므로, as_float에 쓰고 as_int로 읽으면 같은 비트를 다른 해석으로 보게 된다. C 표준은 이 “쓴 멤버와 다른 멤버로 읽기”(타입 퍼닝)를 공용체에 한해 허용한다 — 13장의 포인터 캐스트 방식과 달리 계약 안이라는 점이 결정적 차이다(다만 읽은 값이 그 타입의 유효한 값이 아닐 수 있다는 주의는 남는다).

48.2 표현을 눈으로 — 엔디안과 패딩

5장에서 “이 확인을 C로 실제로 해 보이는 것이 이 장의 시연”이라 예약해 두었다. 이제 갚는다. 여기서는 공용체 대신 가장 이식성 좋은 방법 — 37장에서 배운 바이트의 눈(unsigned char)과 memcpy — 을 쓴다.

examples/ch48/endian.c

#include <stdio.h>
#include <stdint.h>
#include <string.h>

int main(void)
{
    uint32_t value = 0x12345678u;
    unsigned char bytes[4];

    memcpy(bytes, &value, sizeof value);   /* 표현을 바이트로 들여다본다 */

    printf("value: 0x%08X\n", value);
    printf("memory order: %02X %02X %02X %02X\n",
           bytes[0], bytes[1], bytes[2], bytes[3]);
    printf("this machine is %s-endian\n",
           bytes[0] == 0x78 ? "little" : "big");

    struct padded {
        char  tag;      /* 1바이트 */
        int   count;    /* 4바이트 — 정렬 때문에 앞에 빈틈이 생긴다 */
    };
    printf("1 + 4 = 5, but sizeof = %zu\n", sizeof(struct padded));
    return 0;
}

실행 결과

value: 0x12345678
memory order: 78 56 34 12
this machine is little-endian
1 + 4 = 5, but sizeof = 8

첫 부분이 5장의 그림 그대로다. 0x12345678이 메모리에 78 56 34 12 순서로 놓였다 — 이 책의 검증 기계는 리틀 엔디안이라는 뜻이고, 5장의 도해가 실물로 확인된 것이다. (빅 엔디안 기계에서 이 예제를 돌리면 12 34 56 78이 찍히고 판정 문장도 바뀐다 — 코드는 그대로다.)

둘째 부분은 구조체의 숨은 사정이다. char 하나(1바이트)와 int 하나(4바이트)를 담은 구조체의 크기가 5가 아니라 8이다 — 6장의 정렬 규칙 때문에 char 뒤에 3바이트의 패딩(padding, 빈틈)이 끼워진 것이다. int 멤버가 4의 배수 자리에서 시작해야 기계가 한 움큼에 집을 수 있기 때문이다(6장). 그래서 실무 관행 하나가 나온다 — 큰 멤버부터 배치하면 빈틈이 줄어든다. 구조체 수백만 개를 다루는 코드에서는 이 배치 하나가 메모리와 캐시 효율(11장)을 좌우한다. 배치 규칙과, 빈틈을 없애거나 정렬을 강제하는 방법(pack·alignas)은 47장에서 자세히 다뤘다 — 여기서는 그 빈틈이 실제로 존재한다는 사실을 눈으로 확인하는 것이 목적이다.

실제 사례. 빈틈이 흘린 비밀 — 커널의 패딩 정보 유출

패딩은 아무도 쓰지 않는 빈칸이라 무해해 보이지만, 보안의 눈으로 보면 초기화되지 않은 기억이다. 운영체제 커널이 구조체를 사용자 프로그램 에게 통째로 복사해 줄 때 이 빈틈이 함께 건너가는데 — 멤버들은 전부 채워 넣었어도 빈틈에는 그 자리에 남아 있던 다른 데이터의 잔해가 들어 있다. 공격자는 이 부스러기를 모아 커널 메모리의 내용이나 주소 배치를 엿볼 수 있고, 그것이 다음 공격의 발판이 된다. 리눅스를 비롯한 주요 커널들이 이 부류의 정보 유출 취약점을 수십 건 고쳐 왔고, 오늘의 대응은 단순하다 — 사용자에게 넘기는 구조체는 멤버를 채우기 전에 통째로 0으로 지운다. 23장에서 “선언과 동시에 초기화한다”고 한 규칙이, 보이지 않는 빈칸에까지 적용되는 순간이다.

흔한 오해. “구조체를 그대로 파일에 쓰거나 네트워크로 보내면 된다”

솔깃한 생각이고, 실제로 많이 시도된 방법이다 — 구조체의 바이트를 통째로 저장하면 코드가 짧아지니까. 그러나 이 장이 방금 보인 두 사실이 그것을 가로막는다: 바이트 순서가 기계마다 다르고(엔디안), 빈틈의 크기와 위치도 컴파일러·플랫폼마다 다르다(패딩). 한 기계에서 쓴 파일이 다른 기계에서 깨지는 것이다 — 5장의 NUXI 사건이 파일 형식의 세계에서 재현되는 셈이다. 정답은 직렬화(serialization)다: 멤버를 하나씩, 약속된 크기와 바이트 순서로(네트워크 바이트 순서 — 5장) 적고 읽는 코드를 명시적으로 쓰는 것. 표현은 기계의 사정이고, 파일과 통신은 약속의 세계라는 것 — 두 세계를 잇는 다리는 손으로 놓아야 한다.

문. 공용체는 그러면 언제 쓰는 것이 정석인가?

답. 두 가지 자리다. 첫째, 방금 본 표현 들여다보기(타입 퍼닝) — 부동 소수점 비트를 검사하거나 하드웨어 레지스터를 여러 눈으로 보는 저수준 코드다. 둘째, 더 흔한 쓰임인 태그된 공용체(tagged union): 구조체 안에 “지금 어느 멤버가 유효한가”를 알리는 표시(태그)와 공용체를 함께 넣어, “이 값은 정수이거나, 실수이거나, 문자열이다” 같은 선택형 데이터를 표현하는 것이다. 인터프리터의 값 표현, 설정 파일 파서 같은 곳의 기본 도구이고 — 6장에서 본 태그 포인터가 비트 차원의 같은 발상이었다. 현대 언어들의 열거형(Rust의 enum, Swift의 associated value)이 이 패턴을 언어 차원으로 끌어올린 것이다.

48.3 활성 멤버와 타입 펀닝 — 같은 비트를 다른 눈으로

공용체의 계약을 한 낱말로 적으면 활성 멤버(active member)다 — 마지막으로 값을 넣은 멤버가 지금 살아 있는 멤버다. 그러면 살아 있지 않은 멤버를 읽으면 어떻게 되는가? 이 물음의 답이 C와 C++에서 갈린다.

examples/ch48/punning.c

/* 같은 비트를 다른 타입으로 보는 세 가지 방법 — 그리고 각각의 계약. */
#include <stdint.h>
#include <stdio.h>
#include <string.h>

union bits { float f; uint32_t u; };

static void show(const char *how, uint32_t u)
{
    printf("  %-28s 0x%08X  (sign %u, exponent %3u, significand 0x%06X)\n",
           how, u, u >> 31, (u >> 23) & 0xFFu, u & 0x7FFFFFu);
}

int main(void)
{
    float f = 1.5f;
    printf("reading the bits of the float %.1f as a 32-bit integer\n\n", (double)f);

    /* ① 공용체 — C 에서는 허용된다.
         마지막에 쓴 멤버가 아닌 멤버를 읽으면 '표현을 다시 해석'한다. */
    union bits b = { .f = f };
    show("through a union", b.u);

    /* ② memcpy — 어디서나 계약 안이다. 최적화하면 명령 하나로 접힌다. */
    uint32_t u;
    memcpy(&u, &f, sizeof u);
    show("through memcpy", u);

    /* ③ 포인터 캐스트 — *이것만 계약 밖이다*(엄격한 앨리어싱, 37장).
         값이 같아 보여도 컴파일러가 순서를 바꿀 권리를 갖는다.
         아래 줄은 보이기 위해 적었을 뿐, 쓰면 안 되는 형태다. */
    puts("  through a pointer cast          *(uint32_t *)&f - outside the contract, not shown");

    puts("\n[the other direction is the same]");
    union bits c = { .u = 0x40490FDBu };   /* 원주율에 가까운 비트 */
    printf("  0x40490FDB seen as a float is %.7f\n", (double)c.f);
    float g;
    memcpy(&g, &c.u, sizeof g);
    printf("  moved with memcpy it is also %.7f\n", (double)g);

    puts("\n[where C and C++ part ways]");
    puts("  C   : reading another member of a union is allowed (the representation is reinterpreted).");
    puts("  C++ : reading a member that is not the 'active' one is undefined behaviour.");
    puts("  in a header shared by both languages, memcpy is the safer spelling.");

    puts("\n[careful: not every bit pattern is a value]");
    union bits nan_bits = { .u = 0x7FC00000u };
    printf("  0x7FC00000 → %f (NaN)\n", (double)nan_bits.f);
    puts("  integers and floats are usually harmless, but some types have 'trap representations'.");
    return 0;
}

실행 결과

reading the bits of the float 1.5 as a 32-bit integer

  through a union              0x3FC00000  (sign 0, exponent 127, significand 0x400000)
  through memcpy               0x3FC00000  (sign 0, exponent 127, significand 0x400000)
  through a pointer cast          *(uint32_t *)&f - outside the contract, not shown

[the other direction is the same]
  0x40490FDB seen as a float is 3.1415927
  moved with memcpy it is also 3.1415927

[where C and C++ part ways]
  C   : reading another member of a union is allowed (the representation is reinterpreted).
  C++ : reading a member that is not the 'active' one is undefined behaviour.
  in a header shared by both languages, memcpy is the safer spelling.

[careful: not every bit pattern is a value]
  0x7FC00000 → nan (NaN)
  integers and floats are usually harmless, but some types have 'trap representations'.

C에서는 허용된다. 표준은 공용체의 다른 멤버를 읽는 것을 「저장된 표현을 그 멤버의 타입으로 다시 해석하는 것」으로 규정한다. 그래서 시연처럼 float를 넣고 uint32_t로 읽어 비트를 들여다보는 것이 계약 안이다. 이 기법을 타입 펀닝(type punning)이라 부른다.

C++에서는 정의되지 않은 동작이다. 활성 멤버가 아닌 멤버를 읽을 수 없다는 것이 그 언어의 규칙이다. 그래서 같은 코드가 두 언어에서 뜻이 다르다 — C로 짠 헤더를 C++에서 포함하는 자리에서 실제로 문제가 된다.

방법C비고
공용체로 다른 멤버 읽기계약 안C++에서는 정의되지 않은 동작
memcpy로 옮기기계약 안★ 두 언어 모두 안전. 최적화하면 명령 하나로 접힌다
포인터를 캐스트해 읽기계약 밖엄격한 앨리어싱 위반(37장)

표 48.1

가운데 줄이 정답이다. memcpy가 느릴 것 같지만, 크기가 컴파일 타임에 정해진 작은 복사는 컴파일러가 레지스터 이동 한 번으로 접는다 — 시연에서 공용체와 memcpy가 같은 값을 낸 것처럼, 만들어지는 기계어도 대개 같다.

흔한 오해. *(uint32_t *)&f 가 가장 직접적이고 빠르다”

가장 위험하다. 이것은 어떤 타입의 객체를 다른 타입으로 접근하는 것이라 엄격한 앨리어싱 규칙(37장)을 어긴다. 컴파일러는 “float를 쓴 것과 uint32_t를 읽은 것은 서로 다른 객체”라고 가정할 권리가 있고, 그 가정 위에서 두 연산의 순서를 바꾼다.

결과는 익숙한 무늬다 — -O0에서는 되고 -O2에서 틀린다. 그리고 정렬이 맞지 않는 주소라면 엄격한 기계에서 그 자리에서 죽는다.

-fno-strict-aliasing 으로 컴파일러를 달래는 코드베이스도 있지만(리눅스 커널이 그렇다), 그것은 전체 최적화를 한 단계 낮추는 대가다. 새 코드라면 memcpy를 쓴다.

문. 그러면 모든 비트열이 값이 되는가?

답. 아니다. 다시 해석한 비트가 그 타입의 유효한 표현이 아닐 수 있다. 표준은 그런 것을 트랩 표현(trap representation)이라 부르고, 그 값을 읽는 것 자체가 계약 밖이라고 말한다.

실무에서는 정수와 부동소수 사이의 펀닝이 대개 무해하다 — 오늘날의 기계에서 unsigned 정수 타입에는 트랩 표현이 없고, IEEE 754의 어떤 비트열도 값(수, 무한대, NaN)이 된다. 시연의 마지막이 그 NaN을 보인다.

조심할 자리는 다른 데 있다. 포인터를 정수로 펀닝했다가 되돌리는 것(37장의 프로버넌스), bool 에 0도 1도 아닌 비트를 넣는 것, 그리고 열거체가 그렇다.

48.3.1 공용체의 크기와 정렬, 그리고 공통 초기 시퀀스

무엇규칙
크기가장 큰 멤버를 담을 만큼. 정렬 때문에 그보다 클 수 있다
정렬멤버 정렬의 최댓값
주소모든 멤버가 같은 주소에서 시작한다 — 공용체의 주소와도 같다
초기화초기자를 하나 주면 첫 멤버가 초기화된다. 지정 초기화로 고를 수 있다

표 48.2

한 가지 규칙이 더 있는데, 태그 붙은 공용체를 쓸 때 요긴하다. 여러 구조체가 공통 초기 시퀀스(common initial sequence) — 앞쪽 멤버들의 타입이 차례로 같은 것 — 를 가지면, 그 공용체가 보이는 자리에서는 어느 멤버를 통해서든 그 공통 부분을 읽을 수 있다.

union shape {
    struct { int kind; double r; }        circle;   /* 앞이 int kind 로 같다 */
    struct { int kind; double w, h; }     rect;
};
/* s.circle.kind 로 읽어도, s.rect.kind 로 읽어도 같은 자리다 */

이 규칙 덕분에 「먼저 종류를 보고 그다음에 갈라 읽는」 무늬가 계약 안에서 성립한다. 다만 조건이 까다롭다 — 완전한 선언이 보이는 곳이어야 하고, 공통 부분의 타입이 정확히 같아야 한다. 실무에서는 아예 종류 태그를 공용체 에 두는 편(구조체 안에 kind와 공용체를 나란히)이 더 흔하고 안전하다.

48.4 비트 필드 — 한 워드를 쪼개 쓰기

구조체 멤버 뒤에 콜론과 수를 적으면 비트 필드(bit field)가 된다 — 그 멤버가 차지할 비트 수를 직접 지정하는 것이다. 여기에 공용체를 겹치면 “통째로 한 워드로 보는 눈”과 “필드로 나눠 보는 눈”을 동시에 갖게 된다.

examples/ch48/bitfield.c

#include <stdio.h>
#include <stdint.h>

/* 실무에서 흔한 모양: 장치 레지스터 한 워드를 필드로 나눠 보고,
   같은 기억을 통째로 32비트 정수로도 본다. */
union control_reg {
    uint32_t raw;                 /* 통째로 읽고 쓰는 눈 */
    struct {
        uint32_t enable   : 1;    /* 비트 1개 */
        uint32_t mode     : 3;    /* 비트 3개 */
        uint32_t priority : 4;
        uint32_t reserved : 8;
        uint32_t counter  : 16;
    } f;                          /* 필드로 보는 눈 */
};

/* 구조체 안에 공용체를 넣는 실무 무늬: 태그 + 내용 */
enum msg_kind { MSG_INT, MSG_TEXT, MSG_POINT };

struct message {
    enum msg_kind kind;           /* 어느 눈으로 볼지 알려 주는 태그 */
    unsigned      flags : 4;      /* 작은 상태 몇 개 */
    unsigned      urgent : 1;
    union {                       /* 이름 없는 공용체 (C11) */
        int  number;
        char text[16];
        struct { int x, y; } point;
    };
};

static void show(const struct message *m)
{
    printf("kind=%d flags=%u urgent=%u -> ", (int)m->kind, m->flags, m->urgent);
    switch (m->kind) {
        case MSG_INT:   printf("number %d\n", m->number); break;
        case MSG_TEXT:  printf("text \"%s\"\n", m->text); break;
        case MSG_POINT: printf("point (%d, %d)\n", m->point.x, m->point.y); break;
    }
}

int main(void)
{
    union control_reg r = { .raw = 0 };
    r.f.enable = 1;
    r.f.mode = 5;
    r.f.priority = 9;
    r.f.counter = 1000;

    printf("raw   = 0x%08x\n", r.raw);
    printf("fields: enable=%u mode=%u priority=%u counter=%u\n",
           r.f.enable, r.f.mode, r.f.priority, r.f.counter);

    /* 통째로 쓰고 필드로 읽기 — 같은 기억을 두 눈으로 본다 */
    r.raw = 0x000A0013u;
    printf("after raw write: enable=%u mode=%u priority=%u counter=%u\n",
           r.f.enable, r.f.mode, r.f.priority, r.f.counter);

    printf("sizeof(union control_reg) = %zu\n", sizeof r);

    struct message a = { .kind = MSG_INT, .flags = 3, .urgent = 1, .number = 42 };
    struct message b = { .kind = MSG_TEXT, .flags = 0, .urgent = 0 };
    for (int i = 0; i < 5; i++) b.text[i] = "hello"[i];
    b.text[5] = '\0';
    struct message c = { .kind = MSG_POINT, .flags = 8, .urgent = 0,
                         .point = { .x = 3, .y = -7 } };
    show(&a); show(&b); show(&c);
    printf("sizeof(struct message) = %zu\n", sizeof(struct message));
    return 0;
}

실행 결과

raw   = 0x03e8009b
fields: enable=1 mode=5 priority=9 counter=1000
after raw write: enable=1 mode=1 priority=1 counter=10
sizeof(union control_reg) = 4
kind=0 flags=3 urgent=1 -> number 42
kind=1 flags=0 urgent=0 -> text "hello"
kind=2 flags=8 urgent=0 -> point (3, -7)
sizeof(struct message) = 24

앞부분이 하드웨어 레지스터를 다루는 전형적인 무늬다. r.f.mode = 5처럼 필드에 쓰면 라이브러리 도움 없이도 비트 자리에 값이 들어가고, r.raw로 읽으면 그 결과가 한 워드로 보인다. 반대로 r.raw에 통째로 쓰고 필드로 읽는 것도 된다 — 출력의 세 번째 줄이 그 확인이다.

편해 보이지만 이식성의 대가가 크다. 표준이 정해 주지 않는 것이 많기 때문이다.

흔한 오해. “비트 필드로 파일·네트워크 형식을 그대로 표현할 수 있다”

가장 흔한 오해이고, 이식성 사고의 단골이다. 파일 형식이나 프로토콜은 바이트와 비트의 배치가 규격으로 정해져 있는데, 비트 필드의 배치는 구현이 정한다. 컴파일러를 바꾸거나 다른 기계로 옮기는 순간 필드가 엉뚱한 자리에서 읽힌다 — 엔디안(앞 절)까지 겹치면 더 나빠진다. 외부 형식을 다룰 때의 정공법은 바이트 배열을 놓고 시프트와 마스크로 직접 뽑아 쓰는 것이다(7장). 비트 필드는 어디까지나 한 프로그램 안에서 기억을 아끼는 용도로 본다.

오늘날 비트 필드를 권하지 않는 이유가 이것이다. 그럼에도 이 문법을 알아야 하는 이유는 분명하다 — 임베디드 SDK의 레지스터 정의, 오래된 코드베이스의 플래그 묶음, 커널 자료구조에서 여전히 만나기 때문이다. 읽을 줄은 알되, 새로 쓸 때는 한 번 더 생각한다가 실무의 감각이다.

48.4.1 비트 필드에서 구현이 정하는 것들

비트 필드는 편해 보이지만 이식성이 가장 약한 문법 중 하나다. 표준이 구현에 맡긴 자리를 모아 보면 이유가 분명하다.

무엇구현이 정한다
비트가 놓이는 순서저장 단위 안에서 낮은 쪽부터인지 높은 쪽부터인지
저장 단위의 경계필드가 단위를 걸칠 수 있는지, 아니면 다음 단위로 미는지
int 비트 필드의 부호signed인지 unsigned인지 — int x : 1;이 −1을 담을 수도 있다
허용되는 타입_Bool·signed int·unsigned int 외에는 구현에 달렸다
패딩과 정렬단위 사이에 패딩이 들어갈 수 있다

표 48.3

여기에 문법적 제약이 하나 더 있다 — 비트 필드의 주소를 얻을 수 없다. &를 붙일 수 없고, 포인터로 가리킬 수도 없다.

그래서 결론은 단호하다. 통신 규약이나 파일 형식을 비트 필드로 파싱하지 않는다. 그 자리에서는 바이트를 읽어 시프트와 마스크로 직접 꺼낸다 — 47장의 직렬화와 같은 이유이고, 같은 처방이다. 비트 필드는 한 프로그램 안에서 기억을 아끼는 용도로만 쓴다.

48.5 구조체와 공용체를 섞는 실무 무늬

예제의 뒷부분은 다른 이야기다. 구조체 안에 태그와 공용체를 함께 넣은 태그된 공용체(tagged union)로, 앞의 문답에서 이름만 나온 그 무늬다.

struct message {
    enum msg_kind kind;      /* 어느 눈으로 볼지 알려 주는 태그 */
    unsigned      flags : 4; /* 작은 상태들 — 비트 필드가 여기서 쓰인다 */
    unsigned      urgent : 1;
    union {                  /* 이름 없는 공용체 (C11) */
        int  number;
        char text[16];
        struct { int x, y; } point;
    };
};

세 가지가 겹쳐 있다. 태그, 비트 필드로 아낀 상태 플래그, 그리고 이름 없는 공용체(anonymous union, C11)다. 이름이 없으면 멤버를 한 단계 덜 거쳐 m->number처럼 바로 쓸 수 있어서, 태그된 공용체가 훨씬 읽기 좋아 진다.

규율은 하나뿐이고, 그것이 전부다 — 태그가 말하는 멤버만 읽는다. kindMSG_TEXT인데 number를 읽으면 48장 첫 절에서 본 “다른 눈으로 보기”가 되어 의미 없는 값이 나온다. 그래서 이런 자료를 다루는 코드는 거의 언제나 예제의 show처럼 태그로 분기하는 switch 하나를 통과 하도록 만든다. 접근을 한 곳에 모으면 규율을 지킬 자리도 한 곳이 된다.

sizeof(struct message)가 24바이트로 나온 것도 읽어 둘 만하다 — 태그 4바이트 + 비트 필드가 든 워드 + 16바이트 공용체에, 정렬을 맞추는 패딩(앞 절)이 더해진 결과다. 표현은 언제나 이렇게 선언한 것보다 조금 더 차지한다.

48.5.1 실물 하나 — 2바이트 조합형 한글

이 무늬가 교과서 속 이야기만은 아니다. 한글을 컴퓨터에 담던 시절, 한 워드를 셋으로 쪼개 쓰는 방식이 실제 표준이 된 적이 있다 — 조합형이다.

examples/ch48/johab.c

/* 2바이트 조합형 한글 — 한 워드를 초성·중성·종성으로 쪼갠 실제 사례.
   (유니코드 설명이 목적이므로 이 예제에는 한글이 들어간다.) */
#include <stdio.h>
#include <stdint.h>
#include <string.h>

/* 조합형 낱자 이름표. 코드값 0·1 은 채움/미사용이라 비워 둔다. */
static const char *const CHO[32] = {
    [2]="G", [3]="GG", [4]="N", [5]="D", [6]="DD", [7]="R", [8]="M",
    [9]="B", [10]="BB", [11]="S", [12]="SS", [13]="NG", [14]="J",
    [15]="JJ", [16]="C", [17]="K", [18]="T", [19]="P", [20]="H",
};
static const char *const JUNG[32] = {
    [3]="A", [4]="AE", [5]="YA", [6]="YAE", [7]="EO",
    [10]="E", [11]="YEO", [12]="YE",
    [13]="O", [14]="WA", [15]="WAE",
    [18]="OE", [19]="YO", [20]="U", [21]="WEO", [22]="WE", [23]="WI",
    [26]="YU", [27]="EU", [28]="YI", [29]="I",
};
static const char *const JONG[32] = {
    [1]="(none)", [2]="G", [5]="N", [9]="L", [17]="M", [19]="B",
    [21]="S", [23]="NG", [29]="H",
};

/* ① 시프트와 마스크로 뽑는다 — 표준이 정확히 약속하는 길 */
static void split_by_shift(uint16_t w)
{
    unsigned cho  = (w >> 10) & 0x1f;
    unsigned jung = (w >>  5) & 0x1f;
    unsigned jong =  w        & 0x1f;
    printf("  shift/mask: mark=%u lead=%2u(%-2s) vowel=%2u(%-3s) tail=%2u(%s)\n",
           (unsigned)(w >> 15), cho, CHO[cho] ? CHO[cho] : "?",
           jung, JUNG[jung] ? JUNG[jung] : "?",
           jong, JONG[jong] ? JONG[jong] : "?");
}

/* ② 비트 필드 + 공용체로 본다 — 편하지만 배치는 구현이 정한다 */
union johab {
    uint16_t raw;
    struct {
        uint16_t jong : 5;   /* 이 순서가 표준의 약속이 아니다 */
        uint16_t jung : 5;
        uint16_t cho  : 5;
        uint16_t mark : 1;
    } f;
};

int main(void)
{
    /* "가" 와 "한" 의 조합형 코드 — 실제 표에서 가져온 값이다. */
    const uint16_t GA  = 0x8861;   /* 1 00010 00011 00001 */
    const uint16_t HAN = 0xD065;   /* 1 10100 00011 00101 */

    printf("가 = 0x%04X, 한 = 0x%04X\n\n", GA, HAN);
    puts("가:");   split_by_shift(GA);
    puts("한:");   split_by_shift(HAN);

    union johab u = { .raw = GA };
    printf("\n'가' seen through bit fields: mark=%u lead=%u vowel=%u tail=%u\n",
           u.f.mark, u.f.cho, u.f.jung, u.f.jong);
    puts("  (on this compiler it came out the same as the shift/mask version.");
    puts("   That is because this implementation fills bits from the low end; the standard does not promise it.)");

    /* ③ 둘째 바이트가 ASCII 와 겹친다 — 조합형의 유명한 함정 */
    unsigned char bytes[3] = { (unsigned char)(GA >> 8),
                               (unsigned char)(GA & 0xff), 0 };
    printf("\nthe two bytes of '가': %02X %02X\n", bytes[0], bytes[1]);
    printf("  the second byte 0x%02X is ASCII '%c' - so searching for 'a' byte by byte\n",
           bytes[1], bytes[1]);
    printf("  lands inside a character: strchr result = %s\n",
           strchr((char *)bytes, 'a') ? "found (false positive)" : "not found");
    return 0;
}

실행 결과

가 = 0x8861, 한 = 0xD065

가:
  shift/mask: mark=1 lead= 2(G ) vowel= 3(A  ) tail= 1((none))
한:
  shift/mask: mark=1 lead=20(H ) vowel= 3(A  ) tail= 5(N)

'가' seen through bit fields: mark=1 lead=2 vowel=3 tail=1
  (on this compiler it came out the same as the shift/mask version.
   That is because this implementation fills bits from the low end; the standard does not promise it.)

the two bytes of '가': 88 61
  the second byte 0x61 is ASCII 'a' - so searching for 'a' byte by byte
  lands inside a character: strchr result = found (false positive)

설계는 이 장에서 배운 그대로다. 16비트를 넷으로 나눠, 맨 앞 한 비트는 “이것은 한글”이라는 표시로 쓰고 나머지 15비트를 5비트씩 셋으로 잘라 초성·중성·종성에 준다.

비트1514–10 / 9–5 / 4–0
자리표시초성 / 중성 / 종성각 5비트
= 0x886112 / 3 / 1ㄱ + ㅏ + (없음)
= 0xD065120 / 3 / 5ㅎ + ㅏ + ㄴ

표 48.4

낱자에 붙은 번호에는 규칙이 있다. 초성은 ㄱ부터 ㅎ까지 2–20으로 이어지고(0·1은 채움과 예약), 종성은 “없음”이 1이며 2부터 29까지 이어진다. 중성만 8·9, 16·17, 24·25가 비어 있는데 — 모음을 넉 자리씩 묶어 배치한 흔적이다. 예제의 이름표에서 그 빈자리가 보인다.

이 방식이 준 것은 분명했다. 낱자만 조합하면 현대 한글 11,172자를 모두 적을 수 있었다. 같은 시기의 완성형(KS C 5601-1987)은 자주 쓰는 2,350자만 표에 담았고, 그래서 “펲시”·“똠방각하” 같은 글자를 아예 적지 못하는 유명한 사고가 있었다. 조합형은 표를 늘리는 대신 규칙으로 만들어 내는 길을 택한 것이다.

실제 사례. 조합형이 남긴 세 가지 교훈

첫째, 둘째 바이트가 ASCII와 겹친다. 88 61인데 뒤 바이트 0x61은 그대로 'a'다. 그래서 바이트 단위로 'a'를 찾는 코드가 글자 한복판을 짚는다 — 예제의 마지막 줄이 그 오검출을 실제로 보여 준다. 9장에서 본 “바이트와 글자는 다르다”가 여기서 사고로 나타난다.

둘째, 배치는 표준이 정해 주지 않는다. 예제의 공용체는 이 컴파일러에서 마침 시프트/마스크와 같은 값을 냈지만, 그것은 이 구현이 비트를 낮은 자리부터 채우기 때문이다. 앞 절의 규칙 그대로 — 외부 형식은 시프트와 마스크로 다룬다.

셋째, 규칙이 표를 이긴 자리가 있다. 유니코드는 조합형과 같은 발상을 더 단정하게 가져갔다. 한글 음절의 코드는 0xAC00 + (초성 * 21 + 중성) * 28 + 종성으로 계산된다 — 비트를 자르는 대신 곱셈으로 자리를 정할 뿐, “낱자를 조합해 글자를 만든다”는 생각은 같다. 조합형은 사라졌지만(윈도우 95가 완성형 계열의 통합 코드를 택하면서 자리를 잃었다) 그 발상은 오늘의 표준 속에 남았다.

48.6 제8부를 닫으며

타입을 만드는 두 문법을 얻었다 — 나란히 놓는 구조체(46장)와 겹쳐 놓는 공용체(48장). 그리고 그 과정에서 표현의 현실(엔디안·패딩)을 눈으로 확인했다. 제2부의 배경지식이 문법으로 완전히 회수된 것이다.

다음 부는 정밀의 부다 — 8장에서 배운 근사의 수학이 C의 실수 타입으로 내려오고(50장), 33장에서 씨앗을 심은 계약의 관점이 오류 처리로 자라며 (51장), 이 책이 곳곳에서 예고해 온 “계약 밖”의 세계 — 정의되지 않은 동작 — 를 정면으로 만난다(52장).