C/C++ 비트 set·clear·toggle 정리 — 시프트 UB와 타입 폭 함정 실측

레지스터 플래그, 권한 비트, 상태 마스크처럼 정수 하나의 특정 비트만 켜고 끄는 코드는 드라이버부터 일반 애플리케이션까지 어디에나 있다. 연산 자체는 |, &, ^ 한 줄이지만, 시프트할 리터럴의 타입을 잘못 고르면 컴파일 경고 없이 엉뚱한 비트가 바뀌거나 정의되지 않은 동작(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인 마스크가 되는 점을 이용해 분기 없이 비트를 원하는 값으로 만든다.

연산식비고
setx |= 1u << n
clearx &= ~(1u << n)~를 붙이는 마스크의 폭이 x와 같아야 한다
togglex ^= 1u << n두 번 하면 원래대로
check(x >> n) & 1ux & (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라서 경쟁 조건이 생긴다. C11 atomic_fetch_or/atomic_fetch_and나 C++ std::atomic::fetch_or를 쓴다.

마무리

비트 연산 자체보다 시프트할 1의 타입이 버그의 원인이다. 대상 폭에 맞춘 부호 없는 리터럴과 UBSan 빌드 두 가지만 지켜도 위 함정은 모두 피할 수 있고, 개수 세기·최하위 비트 위치 같은 연산은 C++20 <bit>나 C23 <stdbit.h>에 맡긴다.

참고

답글 남기기