51 비트를 다루는 법 — 관용구와 함정
먼저 알아야 할 것
돌아보기
29장에서 「비트 연산은 부호 없는 타입에서 한다」는 수칙을 배웠다. 그런데 왜 하필 부호가 문제인가 — 비트는 그냥 비트가 아닌가?
답. 비트는 그냥 비트지만, C 는 비트 뭉치를 언제나 어떤 타입의 값으로 본다. 그 타입이 부호를 가지면 맨 윗자리 비트가 「부호」라는 특별한 뜻을 얻고, 그때부터 시프트와 나눗셈이 다르게 굴고(6장), 몇몇 조합은 아예 계약 밖이 된다(50장). 무부호를 고르는 것은 그 특별한 뜻을 없애 비트를 진짜 비트로 만드는 일이다. 이 장은 그 위에서 무엇을 할 수 있는지의 이야기다.
이 장의 필요성과 맥락
이 장이 끝나면
<stdbit.h>)을 만난다. 그다음 원칙 일곱과 이 언저리에서 자주 나는 사고들, 그리고 「손으로 최적화하기 전에 컴파일러가 이미 아는 것」을 기계어로 확인한다.이 장에서 답할 질문
- 비트 필드(49장)를 쓰면 이 모든 시프트와 마스크를 안 적어도 되지 않는가?
51.1 다섯 가지 기본 동작#
비트를 다루는 일은 결국 다섯 가지뿐이다. 나머지는 전부 이 다섯의 조합이다.
표를 읽기 전에 글자부터 정해 둔다. 다섯 줄 모두 v 를 고치는 이야기다.
| 글자 | 무엇 |
|---|---|
v | 고치려는 값 자체. 시작 값이자 결과가 담기는 곳이다 |
n | 건드릴 비트의 자리 번호. 맨 오른쪽이 0 번이다 |
m | 마스크 — 「어느 자리를 건드릴 것인가」를 1 로 표시해 둔 값 |
s | 필드가 시작하는 자리 번호(마스크 m 이 1 로 시작하는 자리) |
x | 그 필드에 새로 넣을 값. 아직 밀지 않은, 0 자리에 놓인 상태다 |
표 51.1 — 아래 표에서 쓰는 글자
1u << n 이 무엇인지부터 분명히 해 둔다. 1u 는 맨 오른쪽 한 자리만 켜진 값이고, << n 은 그것을 왼쪽으로 n 칸 미는 일이다. 그래서 1u << n 은 오른쪽 끝에서 세어 n 번째 자리 하나만 켜진 값이 된다 — 값으로는 이다. 자리를 세는 시작이 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 << n | OR 은 한쪽이 1 이면 1 — 다른 자리는 0 과 OR 되어 그대로다 | 여러 번 해도 같다 |
| 지우기 | v &= ~(1u << n) | AND 는 한쪽이 0 이면 0 — 마스크가 그 자리에만 0 이다 | ~ 를 빠뜨리는 실수가 흔하다 |
| 뒤집기 | v ^= 1u << n | XOR 은 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 다. 식을 두 조각으로 나눠 읽으면 이렇다.
v & ~m— 자리를 비운다.~m은 그 여덟 자리만 0 이므로, AND 하면 나머지는 그대로 두고 그 자리만 0 이 된다.(x << s) & m— 넣을 값을 그 자리로 옮긴다.x를s만큼 밀어 자리를 맞추고, 혹시x가 필드보다 크더라도 넘치지 않도록m으로 한 번 더 자른다.
둘을 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 << n | n 은 0 <= n < 폭 이어야 한다 |
아래 n 개 | (1u << n) - 1u | ★ n 이 폭과 같으면 계약 밖이다 |
자리 s 부터 w 개 | ((1u << w) - 1u) << s | s + 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)) == 0 | 1 비트가 정확히 하나라는 뜻이다 |
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_ones | 1 인 비트의 수 | while (x) { x &= x-1; n++; } |
stdc_count_zeros | 0 인 비트의 수 | 폭에서 위를 뺀다 |
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_bit | 2 의 거듭제곱인가 | 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=undefined | 1u << 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이 아니다. 나눗셈을 뜻했으면 나눗셈으로 적는다. - 컴파일러는 회전 관용구도, 커니핸 세기 루프도 알아본다. 손으로 요령을 부리기 전에 한 번 재 본다.
- 배치를 바깥과 약속한 자리에는 비트 필드 대신 손으로 적은 시프트와 마스크를 쓴다.
비트는 값의 가장 낮은 층이었다. 다음 장은 그 반대편 — 같은 비트 뭉치를 실수로 읽는 세계다. 근사와 오차, 그리고 「같다」가 무엇을 뜻하는지를 본다.