Proven C Book←↑→

51 비트를 다루는 법 — 관용구와 함정

먼저 알아야 할 것

6장 정수의 표현 · 두 보수와 시프트의 의미
29장 정수의 연산 · 비트 연산자와 「무부호에서 하라」는 수칙
50장 수식과 연산자 · 우선순위, 그리고 연산자별 계약

돌아보기

29장에서 「비트 연산은 부호 없는 타입에서 한다」는 수칙을 배웠다. 그런데 왜 하필 부호가 문제인가 — 비트는 그냥 비트가 아닌가?

답. 비트는 그냥 비트지만, C 는 비트 뭉치를 언제나 어떤 타입의 값으로 본다. 그 타입이 부호를 가지면 맨 윗자리 비트가 「부호」라는 특별한 뜻을 얻고, 그때부터 시프트와 나눗셈이 다르게 굴고(6장), 몇몇 조합은 아예 계약 밖이 된다(50장). 무부호를 고르는 것은 그 특별한 뜻을 없애 비트를 진짜 비트로 만드는 일이다. 이 장은 그 위에서 무엇을 할 수 있는지의 이야기다.

이 장의 필요성과 맥락

책은 비트를 세 번 지나왔다 — 6장에서 뜻을, 29장에서 연산자를, 50장에서 계약을 배웠다. 그런데 정작 「그래서 어떻게 쓰는가」를 한자리에 모은 적이 없다. 플래그를 세우고, 마스크를 만들고, 자리를 올림하고, 비트맵을 다루는 일은 임베디드·그래픽·압축·자료구조 어디에나 있는데도 그렇다. 9부가 「이미 배운 것을 다시, 끝까지」의 부이므로 여기가 그 자리다.

이 장이 끝나면

다섯 가지 기본 동작(세우기·지우기·뒤집기·읽기·바꿔 넣기)에서 시작해 마스크를 만드는 법과 그 벼랑을 본다. 자주 쓰는 관용구 열 가지를 왜 그렇게 되는지와 함께 익히고, C23 이 그 관용구들에 붙인 이름(<stdbit.h>)을 만난다. 그다음 원칙 일곱과 이 언저리에서 자주 나는 사고들, 그리고 「손으로 최적화하기 전에 컴파일러가 이미 아는 것」을 기계어로 확인한다.

이 장에서 답할 질문

  1. 비트 필드(49장)를 쓰면 이 모든 시프트와 마스크를 안 적어도 되지 않는가?

51.1 다섯 가지 기본 동작#

비트를 다루는 일은 결국 다섯 가지뿐이다. 나머지는 전부 이 다섯의 조합이다.

표를 읽기 전에 글자부터 정해 둔다. 다섯 줄 모두 v 를 고치는 이야기다.

글자무엇
v고치려는 값 자체. 시작 값이자 결과가 담기는 곳이다
n건드릴 비트의 자리 번호. 맨 오른쪽이 0 번이다
m마스크 — 「어느 자리를 건드릴 것인가」를 1 로 표시해 둔 값
s필드가 시작하는 자리 번호(마스크 m 이 1 로 시작하는 자리)
x그 필드에 새로 넣을 값. 아직 밀지 않은, 0 자리에 놓인 상태다

표 51.1 — 아래 표에서 쓰는 글자

1u << n 이 무엇인지부터 분명히 해 둔다. 1u 는 맨 오른쪽 한 자리만 켜진 값이고, << n 은 그것을 왼쪽으로 n 칸 미는 일이다. 그래서 1u << n 은 오른쪽 끝에서 세어 n 번째 자리 하나만 켜진 값이 된다 — 값으로는 2𝑛 이다. 자리를 세는 시작이 1 이 아니라 0 이라는 점이 중요하다. 1u << 0 은 맨 오른쪽 비트(값 1)이고, 1u << 3 은 오른쪽에서 넷째 비트(값 8)다.

자리 번호   7  6  5  4  3  2  1  0     ← 자리는 오른쪽부터 0 번
1u          0  0  0  0  0  0  0  1     값 1
1u << 3     0  0  0  0  1  0  0  0     값 8   (1 을 왼쪽으로 세 칸)
1u << 7     1  0  0  0  0  0  0  0     값 128

그래서 「n 번 비트」라고 하면 오른쪽에서 n 칸 왼쪽에 있는 자리를 뜻한다. 1u << n 은 그 자리 하나만 켠 값이고, 이것이 아래 다섯 동작의 재료가 된다.

하는 일적는 법왜 그렇게 되는가기억할 것
세우기v |= 1u << nOR 은 한쪽이 1 이면 1 — 다른 자리는 0 과 OR 되어 그대로다여러 번 해도 같다
지우기v &= ~(1u << n)AND 는 한쪽이 0 이면 0 — 마스크가 그 자리에만 0 이다~ 를 빠뜨리는 실수가 흔하다
뒤집기v ^= 1u << nXOR 은 1 인 자리만 뒤집는다두 번 하면 제자리
읽기v & (1u << n)그 자리만 남는다★ 결과는 1 이 아니라 그 비트의 값이다
바꿔 넣기v = (v & ~m) | ((x << s) & m)m 이 가리키는 자리를 지우고, 그 자리로 옮긴 x 를 얹는다순서를 지킨다 — 지우고, 넣는다

표 51.2 — 비트 하나를 다루는 다섯 가지

examples/bitwise/bitops.c

// 비트를 다루는 다섯 가지 기본 동작.
// 모두 부호 없는 타입 위에서, 폭을 이름에 적은 타입으로 한다.
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>

static void show(const char *label, uint32_t v)
{
    printf("  %-28s 0x%08" PRIX32 "\n", label, v);
}

int main(void)
{
    uint32_t v = 0x00F0'0000;          // C23 의 자릿수 구분자
    show("start", v);

    // ① 세우기 --- 그 자리에 1 을 넣는다
    v |= UINT32_C(1) << 3;
    show("set bit 3        (|=)", v);

    // ② 지우기 --- 그 자리에만 0 인 마스크와 AND
    v &= ~(UINT32_C(1) << 20);
    show("clear bit 20     (&= ~)", v);

    // ③ 읽기 --- 결과는 0 이거나 「그 비트의 값」이지 1 이 아니다
    printf("  is bit 3 on?  %s\n", (v & (UINT32_C(1) << 3)) ? "yes" : "no");
    printf("  the value of v & (1<<3) is %" PRIu32 ", not 1\n",
           v & (UINT32_C(1) << 3));

    // ④ 뒤집기 --- XOR 은 마스크가 1 인 자리만 뒤집는다
    v ^= UINT32_C(0xF0);
    show("flip bits 4..7   (^=)", v);

    // ⑤ 필드 바꿔 넣기 --- 지우고, 밀어 넣는다
    // 비트 8~15 를 하나의 8비트 필드로 본다.
    uint32_t field = 0xAB;
    uint32_t mask  = UINT32_C(0xFF) << 8;
    v = (v & ~mask) | ((field << 8) & mask);
    show("put 0xAB into bits 8..15", v);

    // 꺼내는 것은 반대 순서다 --- 내리고, 남긴다
    printf("  reading it back gives 0x%02" PRIX32 "\n", (v >> 8) & 0xFF);
    return 0;
}

실행 결과

  start                        0x00F00000
  set bit 3        (|=)        0x00F00008
  clear bit 20     (&= ~)      0x00E00008
  is bit 3 on?  yes
  the value of v & (1<<3) is 8, not 1
  flip bits 4..7   (^=)        0x00E000F8
  put 0xAB into bits 8..15     0x00E0ABF8
  reading it back gives 0xAB

마지막 줄만 글자가 넷이라 낯설 텐데, 시연의 마지막 대목이 바로 그것이다. 거기서는 v 의 8 15번 자리를 하나의 필드로 보고 0xAB 를 넣었다 — m 은 그 여덟 자리만 1 인 마스크(0xFF << 8), s 는 필드가 시작하는 자리 번호 8, x 는 넣을 값 0xAB 다. 식을 두 조각으로 나눠 읽으면 이렇다.

둘을 OR 로 합치면 「그 필드만 새 값이고 나머지는 건드리지 않은」 값이 된다. 꺼낼 때는 반대로 하면 된다 — (v >> s) & (m >> s), 시연에서는 (v >> 8) & 0xFF 다.

넷째 줄이 초보자가 가장 자주 걸리는 자리다. v & (1u << 3) 의 값은 1 이 아니라 8 이다. 조건문에 그대로 넣으면(참·거짓만 보므로) 잘 돌지만, 그 값을 저장하거나 견주면 어긋난다.

반례. 비트 검사 결과를 1 과 견주기

if ((flags & FLAG_READY) == 1) { … }   /* 거의 언제나 거짓이다 */

FLAG_READY 가 1u << 5 라면 검사 결과는 32 이지 1 이 아니다. 고치는 길은 둘이다 — 참·거짓만 필요하면 견주지 말고 if (flags & FLAG_READY) 로 쓰고, 꼭 0/1 이 필요하면 !!(flags & FLAG_READY) 또는 (flags & FLAG_READY) != 0 으로 적는다.

51.1.1 마스크 만들기, 그리고 벼랑#

마스크는 「어느 자리를 볼 것인가」를 적어 둔 값이다. 만드는 법은 셋이면 족하다.

무엇적는 법주의
비트 하나1u << nn 은 0 <= n < 폭 이어야 한다
아래 n 개(1u << n) - 1u★ n 이 폭과 같으면 계약 밖이다
자리 s 부터 w 개((1u << w) - 1u) << ss + w 가 폭을 넘지 않아야 한다

표 51.3 — 마스크 세 가지

둘째 줄의 벼랑을 조심해야 한다. uint32_t 에서 아래 32개를 다 켜려고 (1u << 32) - 1 이라 적으면, 시프트 횟수가 폭과 같아 계약 밖이다 (50장). 폭 전체가 필요하면 UINT32_MAX 나 ~UINT32_C(0) 처럼 시프트를 쓰지 않는 표현을 고른다. 굳이 일반화해야 한다면 한 번 더 나누어 민다 — w == 32 ? ~UINT32_C(0) : ((UINT32_C(1) << w) - 1).

51.2 플래그 집합 — 가장 흔한 쓰임#

여러 개의 참·거짓을 한 정수에 담는 것이 비트 연산의 첫째 용도다.

enum {
    OPT_READ    = 1u << 0,
    OPT_WRITE   = 1u << 1,
    OPT_APPEND  = 1u << 2,
    OPT_BINARY  = 1u << 3,
};

unsigned opts = OPT_READ | OPT_BINARY;      /* 두 개를 켠 채로 시작 */

opts |= OPT_WRITE;                          /* 하나 더 켜기 */
opts &= ~OPT_BINARY;                        /* 하나 끄기 */

if (opts & OPT_WRITE)          { /* 쓰기가 켜져 있다 */ }
if ((opts & (OPT_READ | OPT_WRITE)) == (OPT_READ | OPT_WRITE))
                               { /* 둘 *다* 켜져 있다 */ }
if (opts & (OPT_READ | OPT_WRITE))
                               { /* 둘 중 *하나라도* 켜져 있다 */ }

세 검사의 차이를 눈여겨보라. 하나라도는 그냥 AND 하면 되지만, 전부는 「마스크와 같은가」를 물어야 한다. 이 둘을 섞는 것이 실무에서 아주 흔한 버그다.

★ 값에 이름을 붙이는 자리에는 enum 이 좋다 — 디버거가 이름을 보여 주고, 값이 한자리에 모인다. 다만 enum 상수의 타입은 int 이므로(C23 에서도 기본은 그렇다), 1u << 31 처럼 부호 있는 폭을 넘는 자리는 매크로나 unsigned 상수로 적는다.

51.3 관용구 — 그리고 왜 그렇게 되는가#

아래는 반세기 동안 다듬어진 표현들이다. 외우기보다 왜 그런지를 한 번 따라가 두면 필요할 때 다시 만들어 낼 수 있다.

무엇을 얻는가적는 법왜 그런가
짝수인가(x & 1u) == 0맨 아랫자리가 곧 1 의 자리다
가장 낮은 1 비트만x & (0u - x)0u - x 는 ~x + 1 — 그 비트 아래는 그대로, 위는 전부 뒤집힌다
가장 낮은 1 비트 지우기x & (x - 1u)x - 1 은 그 비트를 0 으로, 아래를 전부 1 로 만든다
2 의 거듭제곱인가x != 0 && (x & (x - 1u)) == 01 비트가 정확히 하나라는 뜻이다
a(2의 거듭제곱) 배수로 올림(x + a - 1u) & ~(a - 1u)먼저 넘치게 더하고, 아랫자리를 잘라 낸다
a 배수로 내림x & ~(a - 1u)아랫자리를 그냥 버린다
나머지(무부호, a가 2의 거듭제곱)x & (a - 1u)아랫자리만 남긴다
비트 세기while (x) { x &= x - 1; n++; }「가장 낮은 1 비트 지우기」를 되풀이한다 — 켜진 수만큼만 돈다
왼쪽 회전(x << (n & 31)) | (x >> ((32 - n) & 31))밀려 나간 비트를 반대쪽으로 넣는다. & 31 이 n == 0 의 벼랑을 막는다
분기 없이 고르기(x & -(uint32_t)c) | (y & ~-(uint32_t)c)c 가 0/1 일 때 마스크가 전부 0 이거나 전부 1 이 된다

표 51.4 — 자주 쓰는 관용구

examples/bitwise/idioms.c

// 자주 쓰는 비트 관용구들 --- 그리고 왜 그렇게 되는지.
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>

// x 에서 가장 낮은 1 비트만 남긴다.
// 무부호에서 0u - x 는 (~x + 1) 과 같다. 가장 낮은 1 비트 *아래*는 그대로이고
// 그 위는 전부 뒤집히므로, 둘을 AND 하면 그 한 비트만 살아남는다.
static uint32_t lowest_one(uint32_t x) { return x & (0u - x); }

// 가장 낮은 1 비트를 지운다. x-1 은 그 비트를 0 으로 만들고 아래를 전부 1 로 만든다.
static uint32_t clear_lowest_one(uint32_t x) { return x & (x - 1u); }

// 2 의 거듭제곱인가 --- 1 비트가 정확히 하나인가와 같은 말이다.
static int is_power_of_two(uint32_t x) { return x != 0 && (x & (x - 1u)) == 0; }

// a(2의 거듭제곱)의 배수로 올림한다.
static uint32_t align_up(uint32_t x, uint32_t a) { return (x + a - 1u) & ~(a - 1u); }

// 왼쪽으로 n 만큼 회전. n == 0 일 때도 안전하다 --- 아래를 보라.
static uint32_t rotate_left(uint32_t x, unsigned n)
{
    return (x << (n & 31)) | (x >> ((32u - n) & 31));
}

// 1 비트의 개수 --- 켜진 비트 수만큼만 돈다(커니핸 방식).
static unsigned count_ones(uint32_t x)
{
    unsigned n = 0;
    while (x) { x &= x - 1u; n++; }
    return n;
}

int main(void)
{
    uint32_t x = 0x0000'0B40;          // …1011 0100 0000
    printf("x = 0x%08" PRIX32 "\n", x);
    printf("  lowest set bit  (x & -x)     = 0x%08" PRIX32 "\n", lowest_one(x));
    printf("  clear lowest    (x & (x-1))  = 0x%08" PRIX32 "\n", clear_lowest_one(x));
    printf("  number of 1 bits             = %u\n", count_ones(x));

    puts("powers of two:");
    for (uint32_t v = 0; v <= 5; v++)
        printf("  %" PRIu32 " -> %s\n", v, is_power_of_two(v) ? "yes" : "no");
    printf("  1024 -> %s, 1000 -> %s\n",
           is_power_of_two(1024) ? "yes" : "no", is_power_of_two(1000) ? "yes" : "no");

    puts("rounding up to a multiple of 16:");
    for (uint32_t v = 0; v <= 33; v += 16)
        printf("  align_up(%2" PRIu32 ", 16) = %" PRIu32 "\n", v, align_up(v, 16));
    printf("  align_up(17, 16) = %" PRIu32 "\n", align_up(17, 16));

    puts("rotation keeps every bit --- nothing falls off the end:");
    printf("  rotate_left(0x80000001, 1) = 0x%08" PRIX32 "\n", rotate_left(0x8000'0001u, 1));
    printf("  rotate_left(0x80000001, 0) = 0x%08" PRIX32 "  (n = 0 is safe here)\n",
           rotate_left(0x8000'0001u, 0));
    return 0;
}

실행 결과

x = 0x00000B40
  lowest set bit  (x & -x)     = 0x00000040
  clear lowest    (x & (x-1))  = 0x00000B00
  number of 1 bits             = 4
powers of two:
  0 -> no
  1 -> yes
  2 -> yes
  3 -> no
  4 -> yes
  5 -> no
  1024 -> yes, 1000 -> no
rounding up to a multiple of 16:
  align_up( 0, 16) = 0
  align_up(16, 16) = 16
  align_up(32, 16) = 32
  align_up(17, 16) = 32
rotation keeps every bit --- nothing falls off the end:
  rotate_left(0x80000001, 1) = 0x00000003
  rotate_left(0x80000001, 0) = 0x80000001  (n = 0 is safe here)

회전을 눈여겨보라. 흔히 보는 형태는 (x << n) | (x >> (32 - n)) 인데, n 이 0 이면 오른쪽이 x >> 32 가 되어 계약 밖이다. 위 시연이 쓴 & 31 한 쌍이 그 벼랑을 막는다. 그리고 이렇게 적어도 손해가 없다 — 뒤에서 보듯 컴파일러가 이 모양을 알아보고 회전 명령 하나로 접는다.

흔한 오해. XOR 로 임시 변수 없이 맞바꾸기가 빠르다

*a ^= *b;  *b ^= *a;  *a ^= *b;      /* 쓰지 않는다 */

두 가지가 틀렸다. 첫째, 느리다. 이 기계의 gcc 가 -O2 로 낸 기계어를 세어 보면 임시 변수를 쓴 맞바꿈은 네 명령(적재 두 번, 저장 두 번)인데 XOR 판은 여섯 명령이고, 게다가 셋이 앞의 결과를 기다리는 의존 사슬이라 나란히 돌 수도 없다. 레지스터가 모자라던 시절의 요령이 지금은 손해다.

둘째, 틀린 답을 낸다. 두 인자가 같은 자리를 가리키면(swap(&v, &v)) 첫 줄에서 그 자리가 0 이 되어 값이 사라진다. 임시 변수를 쓰는 쪽은 그런 일이 없다.

51.4 C23 — 관용구가 이름을 얻다#

위 관용구 가운데 상당수는 사실 기계 명령 하나로 되는 일이다. 문제는 그 명령을 부르는 표준적인 방법이 오랫동안 없었다는 것이다. 그래서 컴파일러마다 __builtin_popcount·_BitScanForward 같은 각자의 이름을 두었고, 이식성 있는 코드는 위와 같은 관용구를 손으로 적었다. C23 이 이 자리를 정리했다 — <stdbit.h> 다.

함수무엇을 주는가손으로 적으면
stdc_count_ones1 인 비트의 수while (x) { x &= x-1; n++; }
stdc_count_zeros0 인 비트의 수폭에서 위를 뺀다
stdc_leading_zeros맨 위부터 이어지는 0 의 수—
stdc_trailing_zeros맨 아래부터 이어지는 0 의 수—
stdc_first_leading_one맨 위에서 센 첫 1 의 자리(1 부터, 없으면 0)—
stdc_first_trailing_one맨 아래에서 센 첫 1 의 자리—
stdc_bit_width그 값을 담는 데 필요한 비트 수while (x) { x >>= 1; n++; }
stdc_bit_floor · stdc_bit_ceil그 값 이하·이상인 가장 가까운 2의 거듭제곱—
stdc_has_single_bit2 의 거듭제곱인가x && !(x & (x-1))

표 51.5 — <stdbit.h> 가 이름을 준 것들 (uc·us·ui·ul·ull 꼬리가 붙은 판도 있다)

examples/bitwise/stdbit.c

// C23 이 관용구에 이름을 붙였다 --- <stdbit.h>.
// 손으로 짠 것과 표준 함수를 나란히 놓고 답이 같은지 확인한다.
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>
#include <stdbit.h>

static unsigned count_by_hand(uint32_t x)
{
    unsigned n = 0;
    while (x) { x &= x - 1u; n++; }
    return n;
}

static unsigned width_by_hand(uint32_t x)          // 담는 데 필요한 비트 수
{
    unsigned n = 0;
    while (x) { x >>= 1; n++; }
    return n;
}

int main(void)
{
    printf("__STDC_VERSION_STDBIT_H__ = %ld\n", (long)__STDC_VERSION_STDBIT_H__);
    printf("byte order is %s\n",
           __STDC_ENDIAN_NATIVE__ == __STDC_ENDIAN_LITTLE__ ? "little endian"
           : __STDC_ENDIAN_NATIVE__ == __STDC_ENDIAN_BIG__  ? "big endian"
                                                            : "neither");

    uint32_t samples[] = { 0u, 1u, 0x0000'0B40u, 300u, 0xFFFF'FFFFu };
    puts("value       ones(hand/std)  width(hand/std)  bit_ceil   leading zeros");
    for (size_t i = 0; i < sizeof samples / sizeof samples[0]; i++) {
        uint32_t x = samples[i];
        printf("0x%08" PRIX32 "   %2u / %-2u        %2u / %-2u         %-10" PRIu32 " %u\n",
               x,
               count_by_hand(x), (unsigned)stdc_count_ones(x),
               width_by_hand(x), (unsigned)stdc_bit_width(x),
               (uint32_t)stdc_bit_ceil(x),
               (unsigned)stdc_leading_zeros(x));
    }

    // 자리 번호를 세는 함수는 *1 부터* 세고, 0 은 「없다」는 뜻이다.
    printf("first_leading_one(0x00F0) = %u  (counted from the top, 1-based)\n",
           (unsigned)stdc_first_leading_one(UINT32_C(0x00F0)));
    printf("first_trailing_one(0x00F0) = %u  (counted from the bottom)\n",
           (unsigned)stdc_first_trailing_one(UINT32_C(0x00F0)));
    printf("first_trailing_one(0) = %u  (zero means 'there is none')\n",
           (unsigned)stdc_first_trailing_one(UINT32_C(0)));

    // 2 의 거듭제곱 판정도 이름을 얻었다.
    printf("has_single_bit(1024) = %d, has_single_bit(1000) = %d\n",
           (int)stdc_has_single_bit(UINT32_C(1024)),
           (int)stdc_has_single_bit(UINT32_C(1000)));
    return 0;
}

실행 결과

__STDC_VERSION_STDBIT_H__ = 202311
byte order is little endian
value       ones(hand/std)  width(hand/std)  bit_ceil   leading zeros
0x00000000    0 / 0          0 / 0          1          32
0x00000001    1 / 1          1 / 1          1          31
0x00000B40    4 / 4         12 / 12         4096       20
0x0000012C    4 / 4          9 / 9          512        23
0xFFFFFFFF   32 / 32        32 / 32         0          0
first_leading_one(0x00F0) = 25  (counted from the top, 1-based)
first_trailing_one(0x00F0) = 5  (counted from the bottom)
first_trailing_one(0) = 0  (zero means 'there is none')
has_single_bit(1024) = 1, has_single_bit(1000) = 0

세 가지를 짚어 둔다.

첫째, 자리를 세는 함수는 1 부터 센다. stdc_first_trailing_one 이 5 를 돌려주면 「아래에서 다섯째 비트」, 곧 1u << 4 자리다. 0 은 「그런 비트가 없다」는 뜻이라서, 값이 0 일 때도 안전하게 답을 준다 — 손으로 적은 관용구 대부분이 0 에서 무너지는 것과 대조된다.

둘째, stdc_bit_ceil 은 넘칠 수 있다. 결과가 그 타입에 담기지 않으면 계약 밖이다. 시연에서 0xFFFFFFFF 를 넣었을 때 0 이 나온 것은 이 기계가 그렇게 했을 뿐이지 표준의 약속이 아니다.

셋째, 이 헤더는 바이트 순서도 알려 준다. __STDC_ENDIAN_NATIVE__ 를 __STDC_ENDIAN_LITTLE__·__STDC_ENDIAN_BIG__ 과 견주면 된다(3장). 표준 C 로 엔디안을 묻는 방법이 처음 생긴 것이다.

플랫폼 노트. 아직 쓸 수 없는 자리가 있다

<stdbit.h> 는 C23 헤더라 컴파일러가 받쳐 주어야 쓸 수 있다. 이 책의 검증 기계에서 gcc 14 는 갖추고 있고 __STDC_VERSION_STDBIT_H__ 가 202311 을 준다. 오래된 도구를 함께 지원해야 한다면 #if __has_include(<stdbit.h>) 로 갈라 두고, 없는 쪽에는 앞 절의 관용구를 두는 것이 실무의 절충이다.

그리고 표준 함수라고 언제나 기계 명령 하나가 되는 것은 아니다. 이 기계에서 stdc_count_ones 는 기본 설정으로 빌드하면 라이브러리 함수 호출이 되고, -mpopcnt 처럼 「이 기계에 그 명령이 있다」고 알려 주어야 비로소 popcnt 한 명령으로 접힌다. 표준은 이름을 통일한 것이지 속도를 약속한 것이 아니다.

51.5 원칙 일곱#

수칙왜
무부호에서 한다부호 비트라는 특별한 뜻을 없앤다. 음수의 좌시프트는 계약 밖, 우시프트는 구현 정의다(29·50장)
폭을 이름에 적는다unsigned 의 폭은 기계마다 다르다. uint32_t 는 32 라고 적혀 있다
상수에 접미사를 붙인다1 << 31 은 int 라 계약 밖, 1u << 31 은 괜찮다. 64비트 자리에는 UINT64_C(1)
승격을 잊지 않는다uint8_t 끼리의 연산도 int 로 넓혀져 일어난다 — 뒤집기와 비교에서 놀란다
시프트 횟수를 확인한다0 <= n < 폭. 폭은 sizeof(x) * CHAR_BIT
괄호를 넉넉히 친다&·|·^ 는 비교보다 약하게 묶인다(50장)
이름을 붙인다(v >> 8) & 0xFF 보다 FIELD_GET(v, KIND) 가 여섯 달 뒤에 읽힌다

표 51.6 — 비트를 다룰 때의 수칙

넷째 수칙이 눈에 잘 안 보이는 자리다. 실물로 본다.

examples/bitwise/promote.c

// 비트 연산에서 가장 자주 데이는 자리 --- 정수 승격.
// int 보다 좁은 타입은 연산 전에 int 로 넓혀진다. 그래서 「8비트를 뒤집었다」고
// 생각한 결과가 32비트짜리로 나온다.
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>

int main(void)
{
    uint8_t c = 0x0F;

    // ~c 의 타입은 uint8_t 가 아니라 int 다.
    printf("c             = 0x%02X\n", c);
    printf("~c            = 0x%08X   <- not eight bits\n", (unsigned)~c);
    printf("(uint8_t)~c   = 0x%02X         <- narrow it back yourself\n",
           (unsigned)(uint8_t)~c);
    // 여기서 (~c == 0xF0) 이라 적으면 gcc 가 -Wsign-compare 로 막아 준다.
    printf("((uint8_t)~c == 0xF0) is %s\n", ((uint8_t)~c == 0xF0) ? "true" : "false");

    // char 가 부호 있는 기계에서 0x80 이상인 바이트를 int 로 넓히면 음수가 된다.
    // 그래서 바이트를 다룰 때는 unsigned char 로 받는다.
    char signed_byte = (char)0x80;
    unsigned char plain_byte = 0x80;
    printf("a char holding 0x80, widened  : %d\n", (int)signed_byte);
    printf("an unsigned char holding 0x80 : %d\n", (int)plain_byte);
    printf("masking with 0xFF fixes it    : %d\n", (int)(signed_byte & 0xFF));

    // 마스크의 폭도 타입을 따라간다.
    uint64_t wide = 0xFFFF'FFFF'FFFF'FFFFu;
    printf("wide & ~0u          = 0x%016" PRIX64 "   <- ~0u is 32 bits wide\n",
           wide & ~0u);
    printf("wide & ~UINT64_C(0) = 0x%016" PRIX64 "\n", wide & ~UINT64_C(0));
    return 0;
}

실행 결과

c             = 0x0F
~c            = 0xFFFFFFF0   <- not eight bits
(uint8_t)~c   = 0xF0         <- narrow it back yourself
((uint8_t)~c == 0xF0) is true
a char holding 0x80, widened  : -128
an unsigned char holding 0x80 : 128
masking with 0xFF fixes it    : 128
wide & ~0u          = 0x00000000FFFFFFFF   <- ~0u is 32 bits wide
wide & ~UINT64_C(0) = 0xFFFFFFFFFFFFFFFF

uint8_t 에 ~ 를 붙였는데 결과가 0xFFFFFFF0 이다. ~c 를 계산하기 전에 c 가 int 로 넓혀지기 때문이다(30장). 8비트로 돌아오려면 다시 좁혀야 한다. 같은 이유로 ~c == 0xF0 은 거짓인데, 다행히 gcc 가 이 자리를 짚어 준다 — comparison of promoted bitwise complement of an unsigned value with constant.

마지막 두 줄도 같은 뿌리다. ~0u 는 「전부 1」이 아니라 「unsigned 폭만큼 전부 1」 이라, 64비트 값과 AND 하면 위쪽 절반이 날아간다. 마스크에도 폭이 있다.

51.6 여기서 잘 나는 사고들#

사고증상누가 잡아 주는가고치는 법
flags & MASK == 0늘 같은 답 — == 가 먼저 묶인다-Wparentheses(flags & MASK) == 0
1 << 31 을 int 에서계약 밖 — 최적화에 따라 답이 바뀐다-fsanitize=undefined1u << 31
x >> 32 (폭과 같거나 큼)계약 밖 — 기계마다 0 이거나 x 그대로상수면 컴파일러가 경고횟수를 검사하거나 나누어 민다
n == 0 인 회전x >> 32 가 되어 계약 밖아무도& 31 을 넣는다
좁은 타입의 ~8비트인 줄 알았는데 32비트때때로 -Wsign-compare(uint8_t)~c 로 좁힌다
char 로 바이트 다루기0x80 이상이 음수가 된다아무도unsigned char 로 받거나 & 0xFF
부호 있는 수에 >> 1 을 나눗셈 대신음수에서 답이 다르다아무도나눗셈은 / 로 적는다
~0u 를 64비트 마스크로위쪽 절반이 날아간다아무도~UINT64_C(0)

표 51.7 — 비트 연산 언저리의 사고

일곱째 줄은 「시프트는 빠른 나눗셈」이라는 오래된 요령이 남긴 함정이다. 무부호에서는 맞는 말이지만 부호 있는 수에서는 답이 다르다.

실제 사례. −7 을 둘로 나누면 −3 인가 −4 인가

C 의 나눗셈은 0 쪽으로 버리고(29장), 산술 우시프트는 아래쪽으로 버린다. 그래서 -7 / 2 는 -3 이고 -7 >> 1 은 -4 다. 나머지도 마찬가지다 — -7 % 8 은 -7 인데 -7 & 7 은 1 이다.

이 차이는 실제로 값이 다르게 나오는 자리라서 조용히 지나간다. 배열 첨자를 계산하다가, 좌표를 격자에 맞추다가, 해시를 버킷에 넣다가 — 음수가 한 번도 들어오지 않는 동안에는 아무 일도 없다.

그리고 이 요령은 이제 값도 없다. 컴파일러는 x / 2 를 이미 시프트로 바꾸되 음수를 위한 보정까지 넣어 옳은 답을 낸다. 나눗셈을 뜻했으면 나눗셈으로 적는다.

51.7 손으로 최적화하기 전에#

비트 요령의 상당수는 「컴파일러가 못 하니까 사람이 한다」는 전제에서 태어났다. 그 전제가 아직 참인지 확인해 보는 편이 좋다. 이 기계의 gcc 가 -O2 로 낸 기계어다.

소스에 적은 것나온 기계어뜻
(x << n) | (x >> ((32 - n) & 31))rol 명령 하나회전 관용구를 알아본다
x & (0u - x)neg + and 둘. BMI1 을 켜면 blsi 하나명령이 있으면 쓴다
while (x) { x &= x-1; n++; }-mpopcnt 를 주면 popcnt 하나★ 루프 전체를 알아보고 접는다
stdc_count_ones(x)기본은 라이브러리 호출, -mpopcnt 면 popcnt 하나이름이 표준이라고 명령이 되는 것은 아니다
XOR 맞바꾸기여섯 명령 + 의존 사슬 (임시 변수는 네 명령)옛 요령이 손해가 된 자리

표 51.8 — 컴파일러가 이미 아는 것 — 이 기계에서 확인

셋째 줄이 이 표의 핵심이다. 커니핸 방식 세기 루프는 사람이 「영리하게」 적은 코드인데, 컴파일러가 그 의도를 알아보고 명령 하나로 바꾼다. 요령을 쓰든 안 쓰든 같은 기계어가 나온다면, 남는 차이는 읽기 쉬움뿐이다.

★ 그러니 순서는 이렇다. ①뜻이 그대로 드러나게 적는다 → ②재 본다(13장) → ③그래도 모자라면 그때 요령을 쓰고, 왜 이렇게 적었는지 주석으로 남긴다.

51.8 실물 — 비트는 어디에 쓰이는가#

자리무엇을 하는가이 책의 어디
하드웨어 레지스터한 워드 안의 여러 필드를 읽고 쓴다88·104장
플래그 집합옵션 여럿을 한 정수에이 장
문자 인코딩UTF-8 의 바이트를 조립하고 해체한다8장
색·픽셀한 워드에 R·G·B·A 를 담는다—
비트맵(비트 집합)참·거짓 백만 개를 워드 몇 개로이 장
해시·난수XOR 과 시프트로 비트를 섞는다97장
압축·부호화비트 단위로 읽고 쓴다—

표 51.9 — 비트 연산이 실제로 쓰이는 자리

UTF-8 이 좋은 본보기다. 코드 포인트 하나를 바이트 여럿으로 나누는 일은 결국 시프트와 마스크다(8장).

/* U+0800 ~ U+FFFF 는 세 바이트가 된다 --- 1110xxxx 10xxxxxx 10xxxxxx */
out[0] = (unsigned char)(0xE0u | (cp >> 12));          /* 위 4비트 */
out[1] = (unsigned char)(0x80u | ((cp >> 6) & 0x3Fu)); /* 가운데 6비트 */
out[2] = (unsigned char)(0x80u | (cp & 0x3Fu));        /* 아래 6비트 */

0x3F 는 「아래 여섯 비트만」이라는 뜻의 마스크이고, 0x80 과 0xE0 은 「이 바이트가 어떤 자리인지」를 알리는 표시다. 앞 절의 다섯 동작이 그대로 쓰였다.

그리고 실무에서 가장 자주 만나는 모습은 비트맵이다.

examples/bitwise/bitset.c

// 비트맵 --- 비트 연산이 실무에서 가장 자주 쓰이는 모습.
// 참·거짓 백만 개를 담는 데 바이트 백만 개를 쓸 이유가 없다.
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdbit.h>

#define BITS_PER_WORD 64
#define WORDS(n)      (((n) + BITS_PER_WORD - 1) / BITS_PER_WORD)

#define N 1000
static uint64_t set[WORDS(N)];

// 번호 i 는 몇 번째 워드의 몇 번째 비트인가.
// 워드 크기가 2 의 거듭제곱이므로 나눗셈과 나머지를 시프트와 마스크로 적을 수 있다.
static size_t   word_of(size_t i) { return i >> 6; }        // i / 64
static unsigned bit_of (size_t i) { return i & 63u; }       // i % 64

static void bit_set   (size_t i) { set[word_of(i)] |=  UINT64_C(1) << bit_of(i); }
static void bit_clear (size_t i) { set[word_of(i)] &= ~(UINT64_C(1) << bit_of(i)); }
static int  bit_test  (size_t i) { return (set[word_of(i)] >> bit_of(i)) & 1u; }

static size_t bit_count(void)
{
    size_t total = 0;
    for (size_t w = 0; w < WORDS(N); w++)
        total += stdc_count_ones(set[w]);          // 워드 하나를 한 번에
    return total;
}

int main(void)
{
    printf("%d flags need %zu bytes as a bitmap, %d bytes as one char each\n",
           N, sizeof set, N);

    // 에라토스테네스의 체 --- 비트맵의 교과서적 쓰임
    memset(set, 0, sizeof set);
    for (size_t i = 2; i < N; i++)
        bit_set(i);                                // 일단 전부 「소수일 수 있다」
    for (size_t p = 2; p * p < N; p++)
        if (bit_test(p))
            for (size_t m = p * p; m < N; m += p)
                bit_clear(m);

    // 워드 끝의 남는 비트는 세지 않도록 지운다 --- 잊기 쉬운 자리다.
    for (size_t i = N; i < WORDS(N) * BITS_PER_WORD; i++)
        bit_clear(i);

    printf("primes below %d: %zu\n", N, bit_count());
    printf("  is 997 prime? %s\n", bit_test(997) ? "yes" : "no");
    printf("  is 999 prime? %s\n", bit_test(999) ? "yes" : "no");

    printf("the first ten:");
    size_t shown = 0;
    for (size_t i = 0; i < N && shown < 10; i++)
        if (bit_test(i)) { printf(" %zu", i); shown++; }
    puts("");
    return 0;
}

실행 결과

1000 flags need 128 bytes as a bitmap, 1000 bytes as one char each
primes below 1000: 168
  is 997 prime? yes
  is 999 prime? no
the first ten: 2 3 5 7 11 13 17 19 23 29

세 가지가 이 시연의 요점이다. 첫째, 번호를 워드와 비트로 나누는 일이 시프트와 마스크다 — 워드 크기가 2 의 거듭제곱이라서 i / 64 를 i >> 6 으로, i % 64 를 i & 63 으로 적을 수 있다. 둘째, 마지막 워드의 남는 비트를 잊지 않는다 — 세다가 있지도 않은 원소를 세게 된다. 셋째, 워드 단위로 한꺼번에 처리한다 — 개수를 셀 때 stdc_count_ones 를 워드마다 한 번씩 부르면 비트 하나씩 도는 것보다 예순네 배 적게 돈다.

문. 비트 필드(49장)를 쓰면 이 모든 시프트와 마스크를 안 적어도 되지 않는가?

답. 문법은 편해지지만 약속이 줄어든다. 비트 필드는 어느 쪽 끝부터 채우는지, 워드 경계를 넘을 수 있는지, 어떤 타입을 쓸 수 있는지가 구현 정의라서(49장), 하드웨어 레지스터나 파일 형식처럼 배치가 정해져 있는 것에는 쓰기 어렵다. 같은 구조체가 다른 컴파일러에서 다른 배치를 가질 수 있다.

그래서 실무의 관행은 이렇게 갈린다 — 배치가 내 마음대로여도 되는 자리(내부 자료구조의 작은 플래그)에는 비트 필드가 편하고, 바깥과 약속한 배치에는 손으로 적은 시프트와 마스크가 안전하다. 리눅스 커널이나 여러 장치 드라이버가 후자를 택하는 이유다.

51.9 되짚기#

복습 정리

  • 비트를 다루는 일은 다섯 가지뿐이다 — 세우기·지우기·뒤집기·읽기·바꿔 넣기. 나머지는 조합이다.
  • 읽기의 결과는 1 이 아니라 그 비트의 값이다. 1 과 견주지 않는다.
  • 마스크에도 폭이 있다. ~0u 는 64비트 마스크가 아니고, 1u << 32 는 계약 밖이다.
  • 관용구는 외우기보다 왜 그런지를 잡아 둔다 — x & (x-1) 이 왜 가장 낮은 1 비트를 지우는지 알면 나머지가 따라온다.
  • C23 의 <stdbit.h> 가 그 관용구들에 표준 이름을 주었다. 자리를 세는 함수는 1 부터 세고, 0 은 「없다」는 뜻이다.
  • 부호 있는 수에서 >> 1 은 / 2 가 아니고 & 7 은 % 8 이 아니다. 나눗셈을 뜻했으면 나눗셈으로 적는다.
  • 컴파일러는 회전 관용구도, 커니핸 세기 루프도 알아본다. 손으로 요령을 부리기 전에 한 번 재 본다.
  • 배치를 바깥과 약속한 자리에는 비트 필드 대신 손으로 적은 시프트와 마스크를 쓴다.

비트는 값의 가장 낮은 층이었다. 다음 장은 그 반대편 — 같은 비트 뭉치를 실수로 읽는 세계다. 근사와 오차, 그리고 「같다」가 무엇을 뜻하는지를 본다.