레지스터 플래그, 권한 비트, 상태 마스크처럼 정수 하나의 특정 비트만 켜고 끄는 코드는 드라이버부터 일반 애플리케이션까지 어디에나 있다. 연산 자체는 |, &, ^ 한 줄이지만, 시프트할 리터럴의 타입을 잘못 고르면 컴파일 경고 없이 엉뚱한 비트가 바뀌거나 정의되지 않은 동작(undefined behavior)이 된다.
이 글에서는 set·clear·toggle·check·assign 다섯 연산을 매크로로 정리하고, 실제로 재현한 시프트 함정 네 가지를 UBSan으로 확인한 뒤, C++20 <bit>·std::bitset과 C23 <stdbit.h>까지 살펴본다. 환경은 Ubuntu 24.04, GCC 13.3, glibc 2.39다.
기본 연산 다섯 가지
#include <stdint.h>
#include <stdio.h>
#define BIT(n) (UINT32_C(1) << (n))
#define BIT_SET(x, n) ((x) |= BIT(n))
#define BIT_CLEAR(x, n) ((x) &= ~BIT(n))
#define BIT_TOGGLE(x, n) ((x) ^= BIT(n))
#define BIT_CHECK(x, n) (((x) >> (n)) & 1u)
/* n번째 비트를 v(0/1)로 만든다 — 분기 없음 */
static inline uint32_t bit_assign(uint32_t x, unsigned n, unsigned v)
{
return (x & ~BIT(n)) | ((uint32_t)-(v & 1u) & BIT(n));
}
int main(void)
{
uint32_t x = 0x0F; /* 0000 1111 */
BIT_SET(x, 7); printf("set 7 : 0x%02X\n", x);
BIT_CLEAR(x, 0); printf("clear 0 : 0x%02X\n", x);
BIT_TOGGLE(x, 1); printf("toggle 1 : 0x%02X\n", x);
BIT_TOGGLE(x, 1); printf("toggle 1 : 0x%02X\n", x);
printf("check 7,4 : %u %u\n", BIT_CHECK(x, 7), BIT_CHECK(x, 4));
printf("assign 4=0: 0x%02X\n", bit_assign(x, 4, 0));
printf("assign 6=1: 0x%02X\n", bit_assign(x, 6, 1));
return 0;
}$ gcc -O2 -Wall -Wextra -o bits bits.c && ./bits
set 7 : 0x8F
clear 0 : 0x8E
toggle 1 : 0x8C
toggle 1 : 0x8E
check 7,4 : 1 0
assign 4=0: 0x8E
assign 6=1: 0xCE
bit_assign은 -(v & 1)이 0 또는 모든 비트가 1인 마스크가 되는 점을 이용해 분기 없이 비트를 원하는 값으로 만든다.
| 연산 | 식 | 비고 |
|---|---|---|
| set | x |= 1u << n | |
| clear | x &= ~(1u << n) | ~를 붙이는 마스크의 폭이 x와 같아야 한다 |
| toggle | x ^= 1u << n | 두 번 하면 원래대로 |
| check | (x >> n) & 1u | x & (1u << n)은 0/비0을 돌려준다 |
| assign | (x & ~m) | (-(v & 1u) & m) | m = 1u << n, 분기 없음 |
컴파일러가 만드는 코드
#include <stdint.h>
uint32_t bit_set(uint32_t x, unsigned n) { return x | (UINT32_C(1) << n); }
uint32_t bit_clear(uint32_t x, unsigned n) { return x & ~(UINT32_C(1) << n); }
uint32_t bit_toggle(uint32_t x, unsigned n) { return x ^ (UINT32_C(1) << n); }
unsigned bit_check(uint32_t x, unsigned n) { return (x >> n) & 1u; }$ gcc -O2 -c asm.c && objdump -d --no-show-raw-insn -M intel asm.o
0000000000000000 <bit_set>:
0: endbr64
4: mov eax,edi
6: bts eax,esi
9: ret
0000000000000010 <bit_clear>:
10: endbr64
14: mov eax,edi
16: btr eax,esi
19: ret
0000000000000020 <bit_toggle>:
20: endbr64
24: mov eax,edi
26: btc eax,esi
29: ret
0000000000000030 <bit_check>:
30: endbr64
34: mov ecx,esi
36: shr edi,cl
38: mov eax,edi
3a: and eax,0x1
3d: ret
x86-64에서는 세 연산이 각각 bts/btr/btc 한 명령어로 바뀌므로, 인라인 어셈블리나 비트필드로 최적화할 이유가 없다(정렬용 nop 줄은 생략).
시프트 함정 네 가지
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
int main(int argc, char **argv)
{
(void)argv;
volatile int n31 = 31, n32 = 32 + argc - 1; /* 상수 접기 방지 */
/* 1) int 리터럴 1을 31칸 시프트 → 부호 있는 오버플로 */
int a = 1 << n31;
printf("1) 1 << 31 = %d\n", a);
/* 2) 64비트 변수를 32비트 마스크로 clear */
uint64_t x = UINT64_MAX;
x &= ~(1U << 3);
printf("2) ~(1U << 3) = 0x%016" PRIX64 "\n", x);
x = UINT64_MAX;
x &= ~(UINT64_C(1) << 3);
printf(" ~(1ULL << 3) = 0x%016" PRIX64 "\n", x);
/* 3) 타입 폭 이상 시프트 */
uint32_t b = 1U << n32;
printf("3) 1U << 32 = %" PRIu32 "\n", b);
/* 4) 64비트 위치에 32비트 1을 시프트 */
uint64_t y = 0;
volatile int n40 = 40;
y |= 1U << n40;
printf("4) y |= 1U << 40 = 0x%016" PRIX64 "\n", y);
return 0;
}$ gcc -O0 -g -fsanitize=undefined -o pitfall_ub pitfall.c && stdbuf -o0 ./pitfall_ub
pitfall.c:11:15: runtime error: left shift of 1 by 31 places cannot be represented in type 'int'
1) 1 << 31 = -2147483648
2) ~(1U << 3) = 0x00000000FFFFFFF7
~(1ULL << 3) = 0xFFFFFFFFFFFFFFF7
pitfall.c:23:21: runtime error: shift exponent 32 is too large for 32-bit type 'unsigned int'
3) 1U << 32 = 1
pitfall.c:29:13: runtime error: shift exponent 40 is too large for 32-bit type 'unsigned int'
4) y |= 1U << 40 = 0x0000000000000100
2)는 UB가 아니라서 UBSan도 잡지 못하고, -O2 -Wall -Wextra로 빌드해도 경고가 없었다. 3)·4)의 결과는 x86이 시프트 횟수를 하위 5비트만 쓰기 때문에 나온 값으로, 다른 아키텍처나 최적화 수준에서는 달라질 수 있다.
| 케이스 | 원인 | 올바른 식 |
|---|---|---|
1) 1 << 31 | 부호 있는 int 오버플로(UB) | 1u << 31 |
| 2) 64비트 clear | ~(1U << 3)가 32비트라 상위 32비트까지 0이 된다 | ~(UINT64_C(1) << 3) |
3) 1U << 32 | 타입 폭 이상 시프트(UB) | 범위 검사 또는 더 넓은 타입 |
4) 64비트 변수에 1U << 40 | 시프트가 대입 전에 32비트로 계산된다(UB) | UINT64_C(1) << 40 |
C++20 <bit>와 std::bitset
#include <bit>
#include <bitset>
#include <cstdint>
#include <iostream>
int main()
{
std::bitset<8> b{0x0F};
b.set(7).reset(0).flip(1);
std::cout << "bitset : " << b << " test(7)=" << b.test(7)
<< " count=" << b.count() << '\n';
std::uint32_t x = 0b1011'0000;
std::cout << "popcount : " << std::popcount(x) << '\n'
<< "countr_0 : " << std::countr_zero(x) << '\n'
<< "bit_width: " << std::bit_width(x) << '\n'
<< "single? : " << std::has_single_bit(x) << ' '
<< std::has_single_bit(0x40u) << '\n';
try {
b.set(8);
} catch (const std::out_of_range &e) {
std::cout << "set(8) : out_of_range\n";
}
}$ g++ -std=c++20 -O2 -Wall -o modern modern.cpp && ./modern
bitset : 10001100 test(7)=1 count=3
popcount : 3
countr_0 : 4
bit_width: 8
single? : 0 1
set(8) : out_of_range
std::bitset의 set/reset/flip/test는 범위를 검사해 예외를 던지고, operator[]는 검사하지 않는다.
C23 <stdbit.h>
#include <stdbit.h>
#include <stdio.h>
int main(void)
{
unsigned x = 0xB0;
printf("count_ones=%u trailing_zeros=%u has_single_bit=%d bit_width=%u\n",
stdc_count_ones(x), stdc_trailing_zeros(x),
stdc_has_single_bit(x), stdc_bit_width(x));
return 0;
}$ gcc -std=c2x -O2 -Wall -o c23 c23.c && ./c23
count_ones=3 trailing_zeros=4 has_single_bit=0 bit_width=8
C에서도 popcount류를 컴파일러 내장 함수(__builtin_popcount) 없이 표준으로 쓸 수 있다. 이 헤더는 glibc 2.39부터 제공되므로 Ubuntu 24.04에서는 바로 쓸 수 있다.
주의사항
- 비트 마스크에는 항상 부호 없는 타입을 쓰고, 대상 변수와 같은 폭의 리터럴(
UINT32_C/UINT64_C)로 시프트한다.1UL은 Linux x86-64에서는 64비트지만 Windows(LLP64)에서는 32비트다. - 시프트 함정은 UB라서 컴파일러가 최적화 과정에서 결과를 마음대로 바꿀 수 있다. 테스트 빌드에
-fsanitize=undefined를 넣어 두면 1)·3)·4)를 런타임에 잡는다. - 매크로 인자는 괄호로 감싸고,
BIT_SET(x, i++)처럼 부작용이 있는 인자를 넘기지 않는다. 타입 안전성이 필요하면static inline함수로 바꾼다. - 비트필드(
unsigned a:1;)는 비트 배치 순서와 패딩이 구현 정의(implementation-defined)라, 하드웨어 레지스터나 네트워크 패킷 레이아웃에는 마스크 연산을 쓴다. - 여러 스레드가 같은 워드의 다른 비트를 바꾸면
|=도 read-modify-write라서 경쟁 조건이 생긴다. C11atomic_fetch_or/atomic_fetch_and나 C++std::atomic::fetch_or를 쓴다.
마무리
비트 연산 자체보다 시프트할 1의 타입이 버그의 원인이다. 대상 폭에 맞춘 부호 없는 리터럴과 UBSan 빌드 두 가지만 지켜도 위 함정은 모두 피할 수 있고, 개수 세기·최하위 비트 위치 같은 연산은 C++20 <bit>나 C23 <stdbit.h>에 맡긴다.