본문 바로가기

비트 연산자

정수의 비트를 AND·OR·XOR·NOT과 시프트로 조작할 때 적용되는 승격, 너비, 마스크 계약을 익힙니다.

비트 연산자는 정수의 이진 표현에서 같은 위치의 비트를 결합하거나 자리를 옮깁니다. 논리 연산자 &&, ||, !처럼 피연산자를 참·거짓 하나로 줄이지 않으며, 기본 제공 &, |, ^에는 단락 평가도 없습니다.

비트 패턴 자체가 데이터 계약인 플래그, 프로토콜 필드, 장치 레지스터 같은 곳에서는 유용합니다. 일반적인 상태 모델이라면 이름 있는 필드나 자료구조가 더 읽기 쉬울 수 있습니다.


피연산자와 변환 계약

기본 제공 비트 연산자의 피연산자는 정수형 또는 범위 없는 열거형이어야 합니다. float와 double에는 적용할 수 없습니다.

  • 이항 &, |, ^는 일반 산술 변환을 수행한 공통 타입에서 각 비트를 결합합니다.
  • 단항 ~와 시프트의 피연산자에는 정수 승격이 적용됩니다.
  • unsigned char처럼 int보다 작은 타입도 연산 전에 대개 int로 승격됩니다.
  • 범위 있는 enum class는 기본 제공 정수 비트 연산에 암시적으로 참여하지 않습니다. 명시적인 표현이나 연산자 오버로드로 계약을 정의해야 합니다.

예를 들어 4비트만 적어 보면 5는 0101, 3은 0011입니다.

연산자자리별 규칙0101과 0011 예시
&둘 다 1인 자리만 10001
|하나 이상 1이면 10111
^서로 다른 자리만 10110
~승격된 피연산자의 모든 자리를 반전~0101은 실제 승격 타입의 전체 너비에서 계산

작은 정수 타입의 ~ 결과를 원래 너비로 자동 해석하면 안 됩니다.

정수 승격이 보이는 NOT
#include <cstdint>
#include <type_traits>

std::uint8_t small = 5;
auto inverted = ~small;

static_assert(std::is_same_v<decltype(inverted), int>);

// uint8_t가 제공되는 구현에서는 정확히 8비트입니다.
auto low_eight_bits = static_cast<std::uint8_t>(inverted); // 250

고정 너비 비트 패턴을 다룰 때는 std::uint32_t처럼 의도가 드러나는 부호 없는 타입을 우선 고려하세요.

같은 너비에서 읽는 비트 논리
#include <cstdint>

std::uint32_t a = 0b0101;
std::uint32_t b = 0b0011;

auto common = a & b;     // 0001
auto combined = a | b;   // 0111
auto different = a ^ b;  // 0110
std::uint32_t inverted = static_cast<std::uint32_t>(~a); // 승격 뒤 32비트로 복귀

시프트 연산자

E1 << n과 E1 >> n은 곱셈·나눗셈을 빠르게 표현한 것이 아니라, 승격된 왼쪽 피연산자의 고정 너비에서 비트 자리를 옮기는 연산입니다.

연산 전에 다음 계약을 확인합니다.

  1. 두 피연산자는 정수형 또는 범위 없는 열거형이고 정수 승격을 거칩니다.
  2. 결과 타입은 승격된 E1의 타입입니다.
  3. 시프트 횟수 n은 음수가 아니며 결과 타입의 너비 N보다 작아야 합니다. 그렇지 않으면 동작이 정의되지 않습니다.
  4. 현재 표준 규칙에서 왼쪽 시프트 값은 E1 × 2ⁿ과 2ᴺ을 법으로 합동인 결과이며 빈자리는 0으로 채웁니다.
  5. 오른쪽 시프트 값은 E1 / 2ⁿ을 음의 무한대 방향으로 반올림합니다. 부호 있는 정수의 오른쪽 시프트는 부호 확장을 수행합니다.

따라서 이 교재에서 실행하는 C++20 코드의 -5 >> 1은 -3입니다. 오래된 C++ 버전을 설명하는 자료에는 음수의 오른쪽 시프트를 구현 정의라고 적은 경우가 있으므로, 대상 언어 버전을 확인해야 합니다.

시프트 횟수와 타입을 먼저 고정하기
#include <cstdint>
#include <limits>

std::uint32_t value = 10;
constexpr unsigned count = 2;
static_assert(count < std::numeric_limits<std::uint32_t>::digits);

std::uint32_t left = value << count; // 40
std::uint32_t right = value >> 1;    // 5

작은 타입은 시프트 전에 승격될 수 있으므로, 결과를 다시 좁은 타입에 저장할 때 별도의 변환이 일어난다는 점도 확인해야 합니다.

현재 C++ 기본 제공 왼쪽과 오른쪽 시프트의 공통 사전 조건, 결과 타입, 비트와 값 규칙 비교

BUILT-IN SHIFT CONTRACT

시프트는 승격된 타입과 유효한 횟수부터 확인한다

연산 기호보다 먼저 정수 승격 뒤의 왼쪽 타입과 시프트 횟수 범위를 확인합니다.

두 연산의 공통 사전 점검

1 · 승격
두 피연산자는 정수형 또는 범위 없는 열거형이며 정수 승격을 거칩니다.
2 · 횟수
0 ≤ n < N. 음수이거나 승격된 왼쪽 타입의 너비 N 이상이면 정의되지 않습니다.
3 · 결과
결과 타입은 승격된 왼쪽 피연산자 E1의 타입입니다.

E1 << n · 왼쪽 시프트

빈 낮은 자리는 0으로 채웁니다.

값 계약
E1 × 2ⁿ과 2ᴺ을 법으로 합동인 결과를 만듭니다.
unsigned 예
10u << 2 → 40u

E1 >> n · 오른쪽 시프트

낮은 자리 비트를 버리고 현재 표준의 나눗셈 규칙을 적용합니다.

값 계약
E1 / 2ⁿ을 음의 무한대 방향으로 반올림합니다.
signed 예
-5 >> 1 → -3 · 부호 확장

E1은 n보다 먼저 평가됩니다. 시프트를 성능 요령으로 선택하지 말고, 고정 너비의 비트 이동이 의도일 때 사용합니다.


마스크와 플래그

마스크는 관심 있는 자리를 1로 표시한 값입니다. 서로 겹치지 않는 단일 플래그는 보통 1u << n 형태로 정의합니다.

권한 플래그의 설정·검사·해제·토글
#include <cstdint>

using Flags = std::uint32_t;

constexpr Flags READ  = Flags{1} << 0;
constexpr Flags WRITE = Flags{1} << 1;
constexpr Flags DEBUG = Flags{1} << 2;

Flags flags = READ | DEBUG; // 0101

flags |= WRITE;             // 설정: 0111
flags &= static_cast<Flags>(~READ); // 해제: 0110
flags ^= DEBUG;             // 토글: 0010

bool has_any = (flags & (READ | WRITE)) != 0;
bool has_all = (flags & (READ | WRITE)) == (READ | WRITE);

(flags & mask) != 0은 마스크 중 하나 이상이 켜졌는지 묻고, (flags & mask) == mask는 마스크의 모든 비트가 켜졌는지 묻습니다. 단일 비트 마스크에서는 두 질문의 결과가 같지만, 여러 비트를 합친 마스크에서는 다릅니다.

~mask도 승격 규칙의 영향을 받습니다. 좁은 부호 없는 타입은 ~ 전에 더 넓은 int로 승격될 수 있으므로, 반전 결과를 static_cast<Flags>로 되돌리면 마스크에 적용할 너비가 분명해집니다.

마스크 연산은 바꾸는 자리와 검사 조건을 분리한다

원 도표의 독립 0101 실험표와 별도로 본문 실제 코드의 누적 상태 0101→0111→0110→0010을 보여 줄 수 있다. 표의 독립 결과를 연속 상태로 오인하지 않도록 구분한다.

C++ 비트 플래그를 설정, 해제, 토글하는 식과 여러 비트를 검사할 때 any와 all 조건이 달라지는 이유를 정리한 표

FLAG MASK LEDGER

비트 변경과 any·all 판정은 별개 연산이다

마스크가 가리키는 비트를 어떻게 바꿀지와, 검사 결과를 어떤 조건으로 판정할지를 따로 읽습니다. 각 예시는 flags=0101에서 독립적으로 시작합니다.

핵심 관계설정·해제·토글은 각각 flags=0101에서 독립적으로 시작한다. 검사에서는 0101 & 0011 = 0001을 any와 all의 서로 다른 질문으로 나눈다.0101 | 0010설정: 01110101 & ~0001해제: 01000101 ^ 0100토글: 0001검사: 00010101 & 0011any: != 0trueall: == 0011false
핵심 관계설정·해제·토글은 각각 flags=0101에서 독립적으로 시작한다. 검사에서는 0101 & 0011 = 0001을 any와 all의 서로 다른 질문으로 나눈다.0101| 0010설정01110101& ~0001해제01000101^ 0100토글0001검사: 00010101 & 0011any!= 0: trueall== 0011false

설정·해제·토글은 각각 flags=0101에서 독립적으로 시작한다. 검사에서는 0101 & 0011 = 0001을 any와 all의 서로 다른 질문으로 나눈다.

관계도 해설과 예제 원자료

타입과 비트 배치 계약

flags와 mask는 필요한 너비를 가진 같은 unsigned 타입으로 둡니다.

좁은 unsigned도 ~ 전에 승격될 수 있으므로 static_cast<Flags>(~mask)로 선택한 너비에 되돌립니다.

READ=0001 · WRITE=0010 · DEBUG=0100 · 현재 flags=0101

초기값 flags=0101에서 마스크 연산을 적용한 결과
목적 식 · 마스크 의미 · flags=0101 예
설정 flags |= mask — 1인 자리를 켭니다.

예: 0101 | 0010 → 0111

해제 flags &= static_cast<Flags>(~mask) — 승격 후 반전한 값을 Flags 너비로 되돌려 해당 자리만 0으로 만듭니다.

예: 0101 & ~0001 → 0100

토글 flags ^= mask — 1인 자리의 상태를 반전합니다.

예: 0101 ^ 0100 → 0001

하나라도 확인 (flags & mask) != 0 — 겹치는 비트가 하나 이상인지 봅니다.

예: 0101 & 0011 = 0001 · 0001 != 0 → true

모두 확인 (flags & mask) == mask — 요구한 비트가 전부 켜졌는지 봅니다.

예: 0101 & 0011 = 0001 · 0001 == 0011 → false

상태 변경

|= mask
설정 · 0101 | 0010 → 0111
&= static_cast<Flags>(~mask)
해제 · 선택한 너비에서 0101 & ~0001 → 0100
^= mask
토글 · 0101 ^ 0100 → 0001

조건 검사

(flags & mask) != 0
하나라도 켜졌는지 확인 · 0101 & 0011 = 0001 · 0001 != 0 → true
(flags & mask) == mask
모두 켜졌는지 확인 · 0101 & 0011 = 0001 · 0001 == 0011 → false

단일 비트 마스크에서는 any와 all이 같아 보이지만, 결합 마스크에서는 결과가 달라집니다. 여러 실행 흐름이 같은 값을 바꾸면 별도의 동기화나 원자 연산이 필요합니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
원 도표의 제목·노드·관계·조건·주석
범위원문
본문·주석초깃값
예의 값과 변화
관찰·상황값·보충 정보변화·이유
비트 초깃값

본문의 누적 변경 예는 READ와 DEBUG가 켜진 0101에서 시작한다.

  • 초기 상태: 0101
  • 켜진 플래그: READ=0001, DEBUG=0100
  • READ와 DEBUG의 비트가 1이고 WRITE=0010의 비트는 0이다. 독립 실험표와 구분되는 누적 코드이다.
WRITE 설정·READ 해제

WRITE를 켠 뒤 READ를 끈다.

  • WRITE 설정: 0101 | 0010 = 0111
  • READ 해제: 0111 & ~0001 = 0110
  • OR는 WRITE 비트를 1로 만들고, READ 마스크의 보수와 AND하면 READ 비트만 0이 된다.
DEBUG 토글

DEBUG 비트를 XOR로 뒤집어 최종 0010을 얻는다.

  • 이전: 0110
  • 연산: ^= DEBUG(0100)
  • 최종: 0010
  • DEBUG=0100과 XOR하므로 켜져 있던 DEBUG 비트가 1에서 0으로 바뀐다. WRITE 비트는 1을 유지한다.

마스크 연산은 바꾸는 자리와 검사 조건을 분리한다

원 도표의 독립 0101 실험표와 별도로 본문 실제 코드의 누적 상태 0101→0111→0110→0010을 보여 줄 수 있다. 표의 독립 결과를 연속 상태로 오인하지 않도록 구분한다.

C++ 비트 플래그를 설정, 해제, 토글하는 식과 여러 비트를 검사할 때 any와 all 조건이 달라지는 이유를 정리한 표

FLAG MASK LEDGER

비트 변경과 any·all 판정은 별개 연산이다

마스크가 가리키는 비트를 어떻게 바꿀지와, 검사 결과를 어떤 조건으로 판정할지를 따로 읽습니다. 각 예시는 flags=0101에서 독립적으로 시작합니다.

핵심 관계설정·해제·토글은 각각 flags=0101에서 독립적으로 시작한다. 검사에서는 0101 & 0011 = 0001을 any와 all의 서로 다른 질문으로 나눈다.0101 | 0010설정: 01110101 & ~0001해제: 01000101 ^ 0100토글: 0001검사: 00010101 & 0011any: != 0trueall: == 0011false
핵심 관계설정·해제·토글은 각각 flags=0101에서 독립적으로 시작한다. 검사에서는 0101 & 0011 = 0001을 any와 all의 서로 다른 질문으로 나눈다.0101| 0010설정01110101& ~0001해제01000101^ 0100토글0001검사: 00010101 & 0011any!= 0: trueall== 0011false

설정·해제·토글은 각각 flags=0101에서 독립적으로 시작한다. 검사에서는 0101 & 0011 = 0001을 any와 all의 서로 다른 질문으로 나눈다.

관계도 해설과 예제 원자료

타입과 비트 배치 계약

flags와 mask는 필요한 너비를 가진 같은 unsigned 타입으로 둡니다.

좁은 unsigned도 ~ 전에 승격될 수 있으므로 static_cast<Flags>(~mask)로 선택한 너비에 되돌립니다.

READ=0001 · WRITE=0010 · DEBUG=0100 · 현재 flags=0101

초기값 flags=0101에서 마스크 연산을 적용한 결과
목적 식 · 마스크 의미 · flags=0101 예
설정 flags |= mask — 1인 자리를 켭니다.

예: 0101 | 0010 → 0111

해제 flags &= static_cast<Flags>(~mask) — 승격 후 반전한 값을 Flags 너비로 되돌려 해당 자리만 0으로 만듭니다.

예: 0101 & ~0001 → 0100

토글 flags ^= mask — 1인 자리의 상태를 반전합니다.

예: 0101 ^ 0100 → 0001

하나라도 확인 (flags & mask) != 0 — 겹치는 비트가 하나 이상인지 봅니다.

예: 0101 & 0011 = 0001 · 0001 != 0 → true

모두 확인 (flags & mask) == mask — 요구한 비트가 전부 켜졌는지 봅니다.

예: 0101 & 0011 = 0001 · 0001 == 0011 → false

상태 변경

|= mask
설정 · 0101 | 0010 → 0111
&= static_cast<Flags>(~mask)
해제 · 선택한 너비에서 0101 & ~0001 → 0100
^= mask
토글 · 0101 ^ 0100 → 0001

조건 검사

(flags & mask) != 0
하나라도 켜졌는지 확인 · 0101 & 0011 = 0001 · 0001 != 0 → true
(flags & mask) == mask
모두 켜졌는지 확인 · 0101 & 0011 = 0001 · 0001 == 0011 → false

단일 비트 마스크에서는 any와 all이 같아 보이지만, 결합 마스크에서는 결과가 달라집니다. 여러 실행 흐름이 같은 값을 바꾸면 별도의 동기화나 원자 연산이 필요합니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.

관계도의 보충 설명

원 도표의 제목·노드·관계·조건·주석
범위원문
본문·주석초깃값
예의 값과 변화
관찰·상황값·보충 정보변화·이유
비트 초깃값

본문의 누적 변경 예는 READ와 DEBUG가 켜진 0101에서 시작한다.

  • 초기 상태: 0101
  • 켜진 플래그: READ=0001, DEBUG=0100
  • READ와 DEBUG의 비트가 1이고 WRITE=0010의 비트는 0이다. 독립 실험표와 구분되는 누적 코드이다.
WRITE 설정·READ 해제

WRITE를 켠 뒤 READ를 끈다.

  • WRITE 설정: 0101 | 0010 = 0111
  • READ 해제: 0111 & ~0001 = 0110
  • OR는 WRITE 비트를 1로 만들고, READ 마스크의 보수와 AND하면 READ 비트만 0이 된다.
DEBUG 토글

DEBUG 비트를 XOR로 뒤집어 최종 0010을 얻는다.

  • 이전: 0110
  • 연산: ^= DEBUG(0100)
  • 최종: 0010
  • DEBUG=0100과 XOR하므로 켜져 있던 DEBUG 비트가 1에서 0으로 바뀐다. WRITE 비트는 1을 유지한다.

본문 코드는 0101→0111→0110→0010으로 같은 값을 차례로 변경합니다. 반면 비교표의 설정·해제·토글은 각각 최초 0101에서 독립적으로 시작합니다. 두 결과가 다르면 연산자가 아니라 시작 상태가 같은지 먼저 확인합니다. 이진 표기에서 오른쪽 끝은 2⁰ 자리이며, READ·WRITE·DEBUG는 서로 다른 한 자리만 켭니다.

예시

아래는 필요한 고정 너비 타입 std::uint32_t가 제공되는 구현에서 실행합니다. 먼저 출력 세 줄을 비트 상태로 예측해 보세요.

누적 비트 상태와 두 검사 질문 실행하기
#include <cstdint>
#include <iostream>

int main() {
    using Flags = std::uint32_t;
    constexpr Flags read{1};
    constexpr Flags write{2};
    constexpr Flags debug{4};
    Flags flags = read | debug;
    flags |= write;
    std::cout << flags << "\n";
    flags &= static_cast<Flags>(~read);
    std::cout << flags << "\n";
    flags ^= debug;
    std::cout << flags << " " << std::boolalpha
              << ((flags & (read | write)) != 0) << " "
              << ((flags & (read | write)) == (read | write)) << "\n";
}

출력은 7, 6, 2 true false입니다. 마지막 flags=2에는 WRITE만 켜졌으므로 둘 중 하나는 있지만 둘 다 있지는 않습니다. 플래그 비트가 있다는 사실을 실제 사용자의 권한 검증까지 끝냈다는 뜻으로 확대하지 않습니다.

독립 확인

마지막 토글 직후 flags ^= debug;를 한 번 더 수행하면 마지막 줄은 어떻게 바뀔까요? flags=write이고 mask=write인 단일 비트 검사에서 하나라도/모두의 결과는 각각 무엇인가요?

토글 재실행과 단일 비트 검사 풀이

두 번째 DEBUG 토글로 0010→0110, 즉 6이 됩니다. READ는 여전히 없으므로 6 true false입니다. 단일 WRITE 마스크에서는 같은 한 비트를 묻기 때문에 두 검사가 모두 참입니다. 여러 비트 마스크에서도 항상 같다는 결론은 나오지 않습니다.

여러 스레드가 같은 플래그 값을 동시에 바꾸는 경우에는 비트 식만으로 원자성이 생기지 않습니다. 해당 공유 상태에는 원자 연산이나 락 같은 별도의 동기화 계약이 필요합니다.


XOR의 가역성과 보안 경계

(x ^ key ^ key) == x이므로 같은 키를 두 번 XOR하면 원래 값으로 돌아옵니다.

#include <cstdint>

std::uint8_t original = 0x58;
std::uint8_t key = 0x0f;

std::uint8_t transformed = static_cast<std::uint8_t>(original ^ key);
std::uint8_t restored = static_cast<std::uint8_t>(transformed ^ key);

이 성질만으로 암호화가 되지는 않습니다. 고정 키 XOR은 패턴과 키를 쉽게 노출할 수 있으므로, 위 코드는 가역 변환을 설명하는 예제일 뿐 보안 기능으로 사용하면 안 됩니다.


우선순위와 괄호

관련 연산자의 우선순위는 높은 쪽부터 대략 다음과 같습니다.

  1. 단항 ~, !
  2. 산술 연산자
  3. 시프트 <<, >>
  4. 관계·동등 연산자
  5. 비트 AND &
  6. 비트 XOR ^
  7. 비트 OR |
  8. 논리 AND &&, 논리 OR ||
  9. 대입과 복합 대입

특히 flags & READ != 0은 원하는 검사로 묶이지 않습니다. 동등 연산자가 비트 AND보다 우선하므로 다음처럼 괄호로 마스크 계산을 먼저 드러내세요.

if ((flags & READ) != 0) {
    // READ 비트가 켜져 있음
}

우선순위는 구문이 묶이는 방법을 정하지만, 일반적인 피연산자 평가 순서를 모두 정하는 규칙은 아닙니다. 비트 연산 코드에서는 타입, 너비, 마스크 의미와 괄호를 함께 검토하세요.