Proven C Book←↑→

49 공용체와 표현

먼저 알아야 할 것

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

돌아보기

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

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

이 장의 필요성과 맥락

구조체 두 장 뒤에 공용체를 두는 이유는 그것이 자료의 모양 가운데 마지막 하나여서가 아니다 — 표현과 추상의 분리라는 이 책의 후렴구를 여기서 마지막으로 크게 울리기 때문이다. 3장에서 예약한 엔디안 시연이 실제 코드로 실행되는 자리이기도 하다. 이 장이 세운 표현의 눈은 뒤의 두 장(50–51장)이 바이트열과 캐시로 이어 간다.

이 장이 끝나면

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

이 장에서 답할 질문

  1. 공용체는 그러면 언제 쓰는 것이 정석인가?
  2. 그러면 실무에서 이것을 얼마나 걱정해야 하는가?

49.1 공용체란 무엇인가 — 한 자리를 번갈아 쓰기#

구조체는 「이것 그리고 저것」을 담았다(47장). 학생에게는 이름 그리고 점수가 있다. 그런데 「이것 또는 저것」인 자료도 있다. 계산기가 읽은 낱말 하나는 수이거나 연산자이거나 괄호이고, 설정 파일의 값 하나는 정수이거나 실수이거나 문자열이다. 동시에 둘일 수는 없다.

이런 자료를 구조체에 담으면 멤버마다 자리를 잡으므로, 쓰지 않는 멤버들의 자리가 늘 비어 있다. 공용체(union)는 멤버들이 같은 자리를 함께 쓰게 한다. 자리는 가장 큰 멤버 하나만큼만 잡고, 한 번에 한 멤버만 그 자리에 산다. 표준은 공용체를 구조체와 같은 문단에서 정의하고 (47장에서 읽은 그 문단 — 「겹쳐지는 멤버 객체의 집합」), 그 뜻을 이렇게 풀어 둔다.

“ 공용체의 크기는 멤버 가운데 가장 큰 것을 담기에 충분하다. 공용체 객체에는 어느 때든 멤버 가운데 많아야 하나의 값만 저장될 수 있다. 공용체 객체를 가리키는 포인터를 알맞게 변환하면 그 멤버 각각을 가리키고, 그 반대도 성립한다.1 ”

쓰임은 크게 둘이다.

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장에서 자세히 다뤘다 — 여기서는 그 빈틈이 실제로 있다는 사실을 눈으로 확인하는 것이 목적이다.

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

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

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

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

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

답. 두 가지 자리다. 첫째, 방금 본 표현 들여다보기(타입 펀닝) — 부동소수점 비트를 검사하거나 하드웨어 레지스터를 여러 눈으로 보는 저수준 코드다. 둘째, 더 흔한 쓰임인 태그된 공용체(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에 통째로 쓰고 필드로 읽는 것도 된다 — 출력의 세 번째 줄이 그 확인이다.

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

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

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

오늘날 비트 필드를 권하지 않는 이유가 이것이다. 그럼에도 이 문법을 알아야 하는 이유는 분명하다 — 임베디드 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비트씩 셋으로 잘라 초성·중성·종성에 준다.

비트1514–10 / 9–5 / 4–0뜻
자리표시초성 / 중성 / 종성각 5비트
가 = 0x886112 / 3 / 1ㄱ + ㅏ + (없음)
한 = 0xD065120 / 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부는 그 두 장으로 닫는다.

주

  1. ISO/IEC 9899 (C23) 6.7.3.2, 문단 18. ↩