49 공용체와 표현
먼저 알아야 할 것
돌아보기
14장에서 “float의 비트를 uint32의 눈으로 읽는” 옛 기법이 엄격한 앨리어싱 위반이며, 옳은 방법은 memcpy나 공용체라고 했다. 그러면 공용체란 정확히 무엇이길래 그 자리에 서는가?
답. 여러 멤버가 같은 기억을 공유하는 타입이다. 구조체가 멤버들을 나란히 놓는다면(47장), 공용체는 멤버들을 겹쳐 놓는다 — 크기는 가장 큰 멤버에 맞고, 어느 순간에든 실제로 담긴 것은 하나다. “같은 비트를 이 눈으로도 저 눈으로도 본다”는 3장의 관점이 문법이 된 것이다.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
- 공용체는 그러면 언제 쓰는 것이 정석인가?
- 그러면 실무에서 이것을 얼마나 걱정해야 하는가?
49.1 공용체란 무엇인가 — 한 자리를 번갈아 쓰기#
구조체는 「이것 그리고 저것」을 담았다(47장). 학생에게는 이름 그리고 점수가 있다. 그런데 「이것 또는 저것」인 자료도 있다. 계산기가 읽은 낱말 하나는 수이거나 연산자이거나 괄호이고, 설정 파일의 값 하나는 정수이거나 실수이거나 문자열이다. 동시에 둘일 수는 없다.
이런 자료를 구조체에 담으면 멤버마다 자리를 잡으므로, 쓰지 않는 멤버들의 자리가 늘 비어 있다. 공용체(union)는 멤버들이 같은 자리를 함께 쓰게 한다. 자리는 가장 큰 멤버 하나만큼만 잡고, 한 번에 한 멤버만 그 자리에 산다. 표준은 공용체를 구조체와 같은 문단에서 정의하고 (47장에서 읽은 그 문단 — 「겹쳐지는 멤버 객체의 집합」), 그 뜻을 이렇게 풀어 둔다.
“ 공용체의 크기는 멤버 가운데 가장 큰 것을 담기에 충분하다. 공용체 객체에는 어느 때든 멤버 가운데 많아야 하나의 값만 저장될 수 있다. 공용체 객체를 가리키는 포인터를 알맞게 변환하면 그 멤버 각각을 가리키고, 그 반대도 성립한다.1 ”
쓰임은 크게 둘이다.
- 여러 갈래 가운데 하나인 값 — 「지금 어느 멤버가 살아 있는가」를 적은 표시와 함께 쓴다. 이 장 뒷부분의 태그된 공용체(tagged union)가 그 관용구다.
- 같은 비트를 다른 눈으로 보기 —
float로 쓴 4바이트를uint32_t로 읽어 비트를 본다. 이 장의 「활성 멤버와 타입 펀닝」 절 시연(punning.c)이 그것이고, C가 이것을 공용체에 한해 허락한다는 것이 결정적이다.
27장의 갈래에서 공용체는 파생 타입이되 집합체는 아니었다 — 한 번에 하나만 살아 있기 때문이라는 그 이유를 이 장에서 실물로 본다.
49.2 공용체 — 겹쳐 놓기#
문법은 구조체와 쌍둥이다. struct를 union으로 바꾸면 된다:
union bits32 {
uint32_t as_int;
float as_float;
};멤버 접근도 같다(u.as_int). 다른 것은 기억의 배치뿐이다 — 두 멤버가 같은 4바이트를 공유하므로, as_float에 쓰고 as_int로 읽으면 같은 비트를 다른 해석으로 보게 된다. C 표준은 이 “쓴 멤버와 다른 멤버로 읽기”(타입 펀닝)를 공용체에 한해 허용한다 — 14장의 포인터 캐스트 방식과 달리 계약 안이라는 점이 결정적 차이다(다만 읽은 값이 그 타입의 유효한 값이 아닐 수 있다는 주의는 남는다).
49.3 표현을 눈으로 — 엔디안과 패딩#
3장에서 바이트 순서(엔디안)를 확인하는 일을 두고 “이 확인을 C로 실제로 해 보이는 것이 공용체 장의 시연”이라 예약해 두었다. 이제 그 시연을 보인다. 이 장에서 표현(representation)은 값이 기억 속에 놓인 바이트와 비트의 모양을 말한다. 여기서는 공용체 대신 가장 이식성 좋은 방법 — 38장에서 배운 바이트의 눈(unsigned char)과 memcpy — 을 쓴다.
examples/unions/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
첫 부분이 3장의 그림 그대로다. 0x12345678이 메모리에 78 56 34 12 순서로 놓였다 — 이 책의 검증 기계는 리틀 엔디안이라는 뜻이고, 3장의 도해가 실물로 확인된 것이다. (빅 엔디안 기계에서 이 예제를 돌리면 12 34 56 78이 찍히고 판정 문장도 바뀐다 — 코드는 그대로다.)
둘째 부분은 구조체의 숨은 사정이다. char 하나(1바이트)와 int 하나(4바이트)를 담은 구조체의 크기가 5가 아니라 8이다 — 4장의 정렬 규칙 때문에 char 뒤에 3바이트의 패딩(padding, 빈틈)이 끼워진 것이다. int 멤버가 4의 배수 자리에서 시작해야 기계가 한 움큼에 집을 수 있기 때문이다(4장). 그래서 실무 관행 하나가 나온다 — 큰 멤버부터 배치하면 빈틈이 줄어든다. 구조체 수백만 개를 다루는 코드에서는 이 배치 하나가 메모리와 캐시 효율(12장)을 좌우한다. 배치 규칙과, 빈틈을 없애거나 정렬을 강제하는 방법(pack·alignas)은 48장에서 자세히 다뤘다 — 여기서는 그 빈틈이 실제로 있다는 사실을 눈으로 확인하는 것이 목적이다.
실제 사례. 빈틈이 흘린 비밀 — 커널의 패딩 정보 누출
흔한 오해. “구조체를 그대로 파일에 쓰거나 네트워크로 보내면 된다”
문. 공용체는 그러면 언제 쓰는 것이 정석인가?
답. 두 가지 자리다. 첫째, 방금 본 표현 들여다보기(타입 펀닝) — 부동소수점 비트를 검사하거나 하드웨어 레지스터를 여러 눈으로 보는 저수준 코드다. 둘째, 더 흔한 쓰임인 태그된 공용체(tagged union): 구조체 안에 “지금 어느 멤버가 유효한가”를 알리는 표시(태그)와 공용체를 함께 넣어, “이 값은 정수이거나, 실수이거나, 문자열이다” 같은 선택형 데이터를 표현하는 것이다. 인터프리터의 값 표현, 설정 파일 파서 같은 곳의 기본 도구이고 — 4장에서 본 태그 포인터가 비트 차원의 같은 발상이었다. 현대 언어들의 열거형(Rust의 enum, Swift의 associated value)이 이 패턴을 언어 차원으로 끌어올린 것이다.
49.4 활성 멤버와 타입 펀닝 — 같은 비트를 다른 눈으로#
공용체의 계약을 한 낱말로 적으면 활성 멤버(active member)다 — 마지막으로 값을 넣은 멤버가 지금 살아 있는 멤버다. 그러면 살아 있지 않은 멤버를 읽으면 어떻게 되는가? 이 물음의 답이 C와 C++에서 갈린다.
examples/unions/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);
/* ③ 포인터 캐스트 — *이것만 계약 밖이다*(엄격한 앨리어싱, 56장).
값이 같아 보여도 컴파일러가 순서를 바꿀 권리를 갖는다.
아래 줄은 보이기 위해 적었을 뿐, 쓰면 안 되는 형태다. */
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로 옮기기 | 계약 안 | ★ 두 언어 모두 안전. 최적화하면 명령 하나로 접힌다 |
| 포인터를 캐스트해 읽기 | 계약 밖 | 엄격한 앨리어싱 위반(38장) |
표 49.1 — 타입 펀닝의 방법과 C 에서의 판정
가운데 줄이 정답이다. memcpy가 느릴 것 같지만, 크기가 컴파일 타임에 정해진 작은 복사는 컴파일러가 레지스터 이동 한 번으로 접는다 — 시연에서 공용체와 memcpy가 같은 값을 낸 것처럼, 만들어지는 기계어도 대개 같다.
흔한 오해. “*(uint32_t *)&f 가 가장 직접적이고 빠르다”
가장 위험하다. 이것은 어떤 타입의 객체를 다른 타입으로 접근하는 것이라 엄격한 앨리어싱 규칙(38장)을 어긴다. 컴파일러는 “float를 쓴 것과 uint32_t를 읽은 것은 서로 다른 객체”라고 가정할 권리가 있고, 그 가정 위에서 두 연산의 순서를 바꾼다.
결과는 익숙한 꼴이다 — -O0에서는 되고 -O2에서 틀린다. 그리고 정렬이 맞지 않는 주소라면 엄격한 기계에서 그 자리에서 죽는다.
-fno-strict-aliasing 으로 컴파일러를 달래는 코드베이스도 있지만(리눅스 커널이 그렇다), 그것은 전체 최적화를 한 단계 낮추는 대가다. 새 코드라면 memcpy를 쓴다.
49.4.1 값이 아닌 비트열 — 비값 표현#
모든 비트열이 값이 되는 것은 아니다. 다시 해석한 비트가 그 타입의 유효한 표현이 아닐 수 있고, 표준은 그런 것에 이름을 붙여 두었다 — 비값 표현(non-value representation)이다. 정의는 한 줄이다(§3.24).
“ 객체 표현 가운데 그 객체 타입의 값을 나타내지 않는 것. ”
비값 표현은 새 이름이다. C17 까지 같은 개념의 이름은 트랩 표현(trap representation)이었다. 옛 문서·남의 코드·검색 결과에서는 지금도 그 이름으로 만나므로 둘 다 알아 두어야 한다. 왜 갈았는지도 분명하다 — 「트랩」이 필수가 아니기 때문이다. 표준은 트랩을 「프로그램의 실행을 그 자리에서 끊어 더 이상 아무 연산도 하지 않는 것」이라 정의하고(§3.25), 비값 표현을 꺼내는 일이 구현에게 트랩을 허용하되 요구하지는 않는다고 못박는다(§3.25 각주). 이름이 「트랩」이면 반드시 멈출 것처럼 읽히니, 뜻 그대로 「값이 아닌 표현」으로 고친 것이다.
| 무엇 | 내용 | 근거 |
|---|---|---|
| 정의 | 그 타입의 값을 나타내지 않는 객체 표현 | §3.24 |
| 옛 이름 | 트랩 표현(trap representation) — C17 까지 | C17 §6.2.6.1p5 |
| 읽으면 | 문자 타입이 아닌 좌변값으로 읽으면 정의되지 않은 동작 | §6.2.6.1p5 |
| 써 넣으면 | 문자 타입이 아닌 좌변값으로 그런 표현을 만들어도 정의되지 않은 동작 | §6.2.6.1p5 |
| 트랩은 | 해도 되고 안 해도 된다 — 구현의 재량 | §3.25 |
| 어디서 생기나 | 패딩 비트(예: 홀짝 검사 비트), 초기화하지 않은 객체, 타입 펀닝, 범위 밖의 bool·열거체 | §6.2.6.2 NOTE 1 |
| 생기지 않는 곳 | 값을 저장하는 일이 비값 표현을 만들어 내지는 않는다 | §6.2.6.1p8 |
표 49.2 — 비값 표현 — 어디서 생기고, 만지면 어떻게 되는가
세 가지를 눈여겨볼 만하다.
첫째, 문자 타입으로 읽고 쓰는 것은 예외다. 위 표의 「읽으면」·「써 넣으면」 두 줄이 모두 「문자 타입이 아닌 좌변값으로」라는 단서를 달고 있다. 뒤집어 말하면 unsigned char 로 들여다보는 것은 언제나 합법이라는 뜻이고, 이것이 memcpy 와 바이트 단위 검사가 안전한 근거다 (38장). 게다가 unsigned char 자체에는 비값 표현이 없다 — 표준이 이 타입을 순수 이진 표기로 못박아(§6.2.6.1p3) 모든 비트 조합이 값이 되기 때문이다.
둘째, 구조체·공용체 전체는 비값 표현이 되지 않는다. 표준이 따로 적어 둔 자리다(§6.2.6.1p6) — 어떤 멤버의 자리에 해당하는 바이트가 그 멤버에게는 비값 표현이더라도, 구조체 객체 전체의 표현은 그렇지 않다. 그래서 공용체를 통째로 복사하거나 대입하는 것은 안전하고, 위험은 어느 멤버로 읽는가에서 생긴다.
셋째, 변수에 비값 표현을 담아 두는 것과 그 변수의 값을 쓰는 것은 다르다. 표준은 각주에서 이렇게 적는다 — 자동 변수를 비값 표현으로 초기화하는 것 자체는 정의되지 않은 동작이 아니지만, 제대로 된 값을 넣기 전에는 그 변수의 값을 쓸 수 없다(각주 43). 초기화하지 않은 변수를 「아직 읽지만 않으면 괜찮다」고 말할 수 있는 근거가 여기다(24장).
문. 그러면 실무에서 이것을 얼마나 걱정해야 하는가?
답. 정수와 부동소수 사이의 펀닝은 대개 무해하다 — 오늘날의 기계에서 unsigned 정수 타입에는 비값 표현이 없고(패딩 비트가 없다), IEEE 754의 어떤 비트열도 값(수, 무한대, NaN)이 된다. 시연의 마지막이 그 NaN을 보인다.
조심할 자리는 다른 데 있다. 포인터를 정수로 펀닝했다가 되돌리는 것(38장의 프로버넌스), bool 에 0도 1도 아닌 비트를 넣는 것, 그리고 열거체에 목록에 없는 값을 넣는 것이다. 앞의 둘은 이 책의 검증 기계에서도 재현되는 자리이고, 마지막은 표준이 열거체의 값 범위를 밑바탕 타입으로 정하기 때문에 생긴다.
★ 한 줄로 줄이면 이렇다 — 바이트로 볼 때는 마음껏 보고(문자 타입), 값으로 읽을 때는 그 타입의 값인지가 보장되어야 한다.
49.4.2 공용체의 크기와 정렬, 그리고 공통 초기 시퀀스#
| 무엇 | 규칙 |
|---|---|
| 크기 | 가장 큰 멤버를 담을 만큼. 정렬 때문에 그보다 클 수 있다 |
| 정렬 | 멤버 정렬의 최댓값 |
| 주소 | 모든 멤버가 같은 주소에서 시작한다 — 공용체의 주소와도 같다 |
| 초기화 | 초기자를 하나 주면 첫 멤버가 초기화된다. 지정 초기화로 고를 수 있다 |
표 49.3 — 공용체의 크기와 정렬 규칙
한 가지 규칙이 더 있는데, 태그된 공용체를 쓸 때 요긴하다. 여러 구조체가 공통 초기 시퀀스(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와 공용체를 나란히)이 더 흔하고 안전하다.
49.5 비트 필드 — 한 워드를 쪼개 쓰기#
구조체 멤버 뒤에 콜론과 수를 적으면 비트 필드(bit field)가 된다 — 그 멤버가 차지할 비트 수를 직접 지정하는 것이다. 여기에 공용체를 겹치면 “통째로 한 워드로 보는 눈”과 “필드로 나눠 보는 눈”을 동시에 갖게 된다.
examples/unions/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;의 부호는 구현이 정한다 — GCC 와 Clang 에서는 부호 있는 1비트라 값이 0과 −1이 된다. 의도한 것이 아니라면unsigned를 명시해야 한다. - 주소를 얻을 수 없다 — 비트 필드에는
&를 쓸 수 없다. 배열로 만들 수도 없다. - 원자적이지 않다 — 같은 워드에 놓인 두 필드를 서로 다른 스레드가 건드리면 데이터 경쟁, 곧 정의되지 않은 동작이다 — 이웃한 비트 필드는 C 에서 한 메모리 위치이기 때문이다. 13장의 거짓 공유처럼 느려지기만 하는 것이 아니라 값이 틀린다.
흔한 오해. “비트 필드로 파일·네트워크 형식을 그대로 표현할 수 있다”
오늘날 비트 필드를 권하지 않는 이유가 이것이다. 그럼에도 이 문법을 알아야 하는 이유는 분명하다 — 임베디드 SDK의 레지스터 정의, 오래된 코드베이스의 플래그 묶음, 커널 자료구조에서 여전히 만나기 때문이다. 읽을 줄은 알되, 새로 쓸 때는 한 번 더 생각한다가 실무의 감각이다.
49.5.1 비트 필드에서 구현이 정하는 것들#
비트 필드는 편해 보이지만 이식성이 가장 약한 문법 중 하나다. 표준이 구현에 맡긴 자리를 모아 보면 이유가 분명하다.
| 무엇 | 구현이 정한다 |
|---|---|
| 비트가 놓이는 순서 | 저장 단위 안에서 낮은 쪽부터인지 높은 쪽부터인지 |
| 저장 단위의 경계 | 필드가 단위를 걸칠 수 있는지, 아니면 다음 단위로 미는지 |
int 비트 필드의 부호 | signed인지 unsigned인지 — int x : 1;이 −1을 담을 수도 있다 |
| 허용되는 타입 | bool·signed int·unsigned int·비트 정밀 정수(_BitInt) 외에는 구현에 달렸다 |
| 패딩과 정렬 | 단위 사이에 패딩이 들어갈 수 있다 |
표 49.4 — 비트 필드에서 구현이 정하는 것
앞 목록에서 본 문법적 제약을 다시 적어 둔다 — 비트 필드의 주소를 얻을 수 없다. &를 붙일 수 없고, 포인터로 가리킬 수도 없다.
그래서 결론은 단호하다. 통신 규약이나 파일 형식을 비트 필드로 파싱하지 않는다. 그 자리에서는 바이트를 읽어 시프트와 마스크로 직접 꺼낸다 — 48장의 직렬화와 같은 이유이고, 같은 처방이다. 비트 필드는 한 프로그램 안에서 기억을 아끼는 용도로만 쓴다.
49.6 구조체와 공용체를 섞는 실무 패턴#
비트 필드 절의 예제(bitfield.c) 뒷부분은 다른 이야기다. 구조체 안에 태그와 공용체를 함께 넣은 태그된 공용체(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를 읽으면 이 장 첫 절에서 본 “다른 눈으로 보기”가 되어 의미 없는 값이 나온다. 그래서 이런 자료를 다루는 코드는 거의 언제나 예제의 show처럼 태그로 분기하는 switch 하나를 통과하도록 만든다. 접근을 한 곳에 모으면 규율을 지킬 자리도 한 곳이 된다.
sizeof(struct message)가 24바이트로 나온 것도 읽어 둘 만하다 — 태그 4바이트 + 비트 필드가 든 워드 4바이트 + 16바이트 공용체다. 패딩은 끼지 않았지만, 비트 필드가 든 워드에는 쓰지 않는 비트가 남는다. 표현은 언제나 이렇게 선언한 것보다 조금 더 차지한다.
49.6.1 실물 하나 — 2바이트 조합형 한글#
한 워드를 비트 단위로 쪼개 쓰는 패턴이 교과서 속 이야기만은 아니다. 한글을 컴퓨터에 담던 시절, 한 워드를 쪼개 초성·중성·종성을 따로 담는 방식이 실제 표준이 된 적이 있다 — 조합형이다.
examples/unions/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 | ㅎ + ㅏ + ㄴ |
표 49.5 — 조합형 한글의 비트 배치
낱자에 붙은 번호에는 규칙이 있다. 초성은 ㄱ부터 ㅎ까지 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'를 찾는 코드가 글자 한복판을 짚는다 — 예제의 마지막 줄이 그 오검출을 실제로 보여 준다. 8장에서 본 “바이트와 글자는 다르다”가 여기서 사고로 나타난다.
둘째, 비트 필드의 배치는 C 표준이 정해 주지 않는다. 예제의 공용체는 이 컴파일러에서 마침 시프트/마스크와 같은 값을 냈지만, 그것은 이 구현이 비트를 낮은 자리부터 채우기 때문이다. 「비트 필드」 절의 규칙 그대로 — 외부 형식은 시프트와 마스크로 다룬다.
셋째, 규칙이 표를 이긴 자리가 있다. 유니코드는 조합형과 같은 발상을 더 단정하게 가져갔다. 한글 음절의 코드는 0xAC00 + (초성 * 21 + 중성) * 28 + 종성으로 계산된다 — 비트를 자르는 대신 곱셈으로 자리를 정할 뿐, “낱자를 조합해 글자를 만든다”는 생각은 같다. 조합형은 사라졌지만(윈도우 95가 완성형 계열의 통합 코드를 택하면서 자리를 잃었다) 그 발상은 오늘의 표준 속에 남았다.
49.7 다음 장으로#
공용체로 표현을 눈으로 보았으니, 남은 것은 그 표현을 다루는 두 가지 일이다. 다음 장은 기억 속 값을 바깥과 약속한 바이트로 바꾸는 직렬화를(50장), 그다음 장은 같은 자료를 안에서 어떻게 늘어놓을지를 다룬다(51장). 제8부는 그 두 장으로 닫는다.
주
- ISO/IEC 9899 (C23) 6.7.3.2, 문단 18. ↩