48 공용체와 표현
먼저 알아야 할 것
돌아보기
13장에서 “float의 비트를 uint32의 눈으로 읽는” 옛 기법이 엄격한 앨리어싱 위반이며, 옳은 방법은 memcpy나 공용체라고 했다. 그러면 공용체란 정확히 무엇이길래 그 자리에 서는가?
답. 여러 멤버가 같은 기억을 공유하는 타입이다. 구조체가 멤버들을 나란히 놓는다면(46장), 공용체는 멤버들을 겹쳐 놓는다 — 크기는 가장 큰 멤버에 맞고, 어느 순간에든 실제로 담긴 것은 하나다. “같은 비트를 이 눈으로도 저 눈으로도 본다”는 5장의 관점이 문법이 된 것이다.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
- 공용체는 그러면 언제 쓰는 것이 정석인가?
- 그러면 모든 비트열이 값이 되는가?
(26장의 갈래에서 공용체는 파생 타입이되 집합체는 아니었다 — 한 번에 하나만 살아 있기 때문이라는 그 이유를 이 장에서 실물로 본다.)
48.1 공용체 — 겹쳐 놓기
문법은 구조체와 쌍둥이다. struct를 union으로 바꾸면 된다:
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에 통째로 쓰고 필드로 읽는 것도 된다 — 출력의 세 번째 줄이 그 확인이다.
편해 보이지만 이식성의 대가가 크다. 표준이 정해 주지 않는 것이 많기 때문이다.
- 비트가 놓이는 순서 — 낮은 자리부터 채울지 높은 자리부터 채울지는 구현 정의(implementation-defined)다. 그래서 같은 선언이 컴파일러마다 다른 배치를 낼 수 있다.
- 경계를 넘는 필드 — 저장 단위를 걸치는 필드를 허용할지도 구현이 정한다. 패딩이 얼마나 끼는지도 마찬가지다.
- 부호 — 그냥
int x : 1;은 부호 있는 1비트라 값이 0과 −1이 된다. 의도한 것이 아니라면unsigned를 명시해야 한다. - 주소를 얻을 수 없다 — 비트 필드에는
&를 쓸 수 없다. 배열로 만들 수도 없다. - 원자적이지 않다 — 같은 워드에 놓인 두 필드를 서로 다른 스레드가 건드리면 12장의 거짓 공유와 같은 부류의 사고가 난다.
흔한 오해. “비트 필드로 파일·네트워크 형식을 그대로 표현할 수 있다”
가장 흔한 오해이고, 이식성 사고의 단골이다. 파일 형식이나 프로토콜은 바이트와 비트의 배치가 규격으로 정해져 있는데, 비트 필드의 배치는 구현이 정한다. 컴파일러를 바꾸거나 다른 기계로 옮기는 순간 필드가 엉뚱한 자리에서 읽힌다 — 엔디안(앞 절)까지 겹치면 더 나빠진다. 외부 형식을 다룰 때의 정공법은 바이트 배열을 놓고 시프트와 마스크로 직접 뽑아 쓰는 것이다(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처럼 바로 쓸 수 있어서, 태그된 공용체가 훨씬 읽기 좋아 진다.
규율은 하나뿐이고, 그것이 전부다 — 태그가 말하는 멤버만 읽는다. kind가 MSG_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비트씩 셋으로 잘라 초성·중성·종성에 준다.
| 비트 | 15 | 14–10 / 9–5 / 4–0 | 뜻 |
|---|---|---|---|
| 자리 | 표시 | 초성 / 중성 / 종성 | 각 5비트 |
가 = 0x8861 | 1 | 2 / 3 / 1 | ㄱ + ㅏ + (없음) |
한 = 0xD065 | 1 | 20 / 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장).