[C언어 50강] 44강. 비트 연산 &, |, ^, ~, 쉬프트, 플래그 설계

[C언어 50강] 44강. 비트 연산 &, |, ^, ~, 쉬프트, 플래그 설계

C언어에서 비트 연산은 "저수준 트릭"이 아니라, 데이터를 가장 작은 단위(비트)로 정확하게 다루는 기본 기술입니다. 임베디드, 네트워크 프로토콜, 권한 플래그, 성능 최적화, 바이너리 파일 파싱까지 모두 비트 단위 사고가 필요합니다. 이번 강의에서는 연산자 기호를 외우는 데서 멈추지 않고, 값이 메모리에서 어떤 비트 패턴으로 바뀌는지 중심으로 이해해보겠습니다.


핵심 개념

  • 비트 연산자는 정수의 각 비트 위치를 대상으로 동작한다.
  • &, |, ^, ~, <<, >>는 모두 "숫자 계산"이 아니라 "비트 패턴 변환"으로 이해해야 한다.
  • 플래그 설계의 핵심은 "한 비트 = 한 상태" 원칙과 마스크(mask) 연산 조합이다.

개념 먼저 이해하기

우리가 평소 쓰는 +, -, *, /는 숫자의 크기를 바꾸는 산술 연산입니다. 반면 비트 연산은 숫자의 의미보다 비트 배열 자체를 조작합니다. 예를 들어 13은 10진수로는 그냥 십삼이지만, 2진수로 보면 00001101(8비트 기준)입니다. 비트 연산은 이 자리값들(1, 2, 4, 8, ...)의 ON/OFF를 직접 다루는 방식입니다. 그래서 같은 값이라도 "몇 번째 비트를 켜고 끄느냐"에 따라 전혀 다른 용도로 쓰입니다.

&는 겹치는 1만 남깁니다. 그래서 특정 비트가 켜져 있는지 검사할 때 사용합니다. |는 하나라도 1이면 1로 만듭니다. 그래서 특정 비트를 강제로 켤 때 적합합니다. ^는 서로 다를 때만 1이 되므로 토글(toggle, 반전)에 유용합니다. ~는 모든 비트를 뒤집는데, 여기서 초보자가 가장 많이 헷갈립니다. "양수를 뒤집으면 음수가 되는" 현상은 2의 보수(two's complement) 표현 때문입니다. 즉, ~는 수학적 부호 반전 연산이 아니라 순수한 비트 반전입니다.

쉬프트 연산은 비트를 좌우로 밀어내는 동작입니다. x << n은 일반적으로 x * 2^n처럼 보일 수 있지만, 오버플로나 자료형 폭을 무시하면 위험합니다. 특히 signed 값에서 왼쪽 쉬프트는 구현/정의 관점에서 주의가 필요합니다. 반대로 x >> nx / 2^n과 비슷하게 보이지만, 음수일 때 부호 비트를 유지할지(산술 쉬프트) 0으로 채울지(논리 쉬프트)는 타입과 구현에 따라 차이가 생길 수 있습니다. 실무에서 안전하게 가려면 비트 조작 대상은 unsigned 정수형으로 제한하는 습관이 좋습니다.

플래그 설계는 비트 연산이 가장 빛나는 영역입니다. 예를 들어 사용자 권한을 읽기, 쓰기, 삭제, 관리자로 표현할 때, bool 변수 4개를 두는 대신 정수 하나에 4비트를 할당할 수 있습니다. 이렇게 하면 저장공간뿐 아니라 연산도 단순해집니다. "권한 추가"는 OR, "권한 제거"는 AND + NOT, "권한 토글"은 XOR, "권한 확인"은 AND 비교로 통일됩니다. 설계 원칙은 간단합니다. (1) 비트 의미를 상수로 이름 붙이고, (2) 매직넘버를 코드에 직접 쓰지 말고, (3) 연산 후 상태를 사람이 읽을 수 있는 형태로 출력/검증 루틴을 같이 둡니다.

결국 비트 연산은 난해한 문법이 아니라, "상태를 조밀하고 빠르게 관리하는 모델"입니다. 이 모델에 익숙해지면 enum과 함께 상태머신을 만들 때, 네트워크 헤더를 해석할 때, 하드웨어 레지스터를 건드릴 때 코드 품질이 크게 올라갑니다. 중요한 건 연산자 자체보다 **의도(검사/설정/해제/토글)**를 분명히 표현하는 것입니다.

기본 사용

예제 1) 비트 마스크로 플래그 켜기/끄기/확인

#include <stdio.h>
#include <stdint.h>

enum Permission {
    PERM_READ   = 1u << 0, // 0001
    PERM_WRITE  = 1u << 1, // 0010
    PERM_DELETE = 1u << 2, // 0100
    PERM_ADMIN  = 1u << 3  // 1000
};

int main(void) {
    uint32_t perm = 0;

    perm |= PERM_READ;   // 읽기 권한 추가
    perm |= PERM_WRITE;  // 쓰기 권한 추가

    if (perm & PERM_WRITE) {
        printf("WRITE 권한 있음\n");
    }

    perm &= ~PERM_WRITE; // 쓰기 권한 제거

    if ((perm & PERM_WRITE) == 0) {
        printf("WRITE 권한 제거됨\n");
    }

    return 0;
}

설명:

  • 1u << n으로 각 비트의 의미를 상수화하면 코드 의도가 선명해집니다.
  • perm |= FLAG는 설정(set), perm &= ~FLAG는 해제(clear), perm & FLAG는 검사(test) 패턴입니다.
  • uint32_t를 사용해 부호 관련 함정을 줄였습니다.

예제 2) XOR로 상태 토글 + 비트 출력 디버깅

#include <stdio.h>
#include <stdint.h>

static void print_bits8(uint8_t x) {
    for (int i = 7; i >= 0; --i) {
        putchar((x & (1u << i)) ? '1' : '0');
    }
    putchar('\n');
}

int main(void) {
    uint8_t flags = 0b00000101; // 0번, 2번 비트 ON
    uint8_t mask  = 0b00000100; // 2번 비트 토글 대상

    print_bits8(flags);  // 00000101
    flags ^= mask;       // 2번 비트 반전 -> OFF
    print_bits8(flags);  // 00000001
    flags ^= mask;       // 다시 반전 -> ON
    print_bits8(flags);  // 00000101

    return 0;
}

설명:

  • XOR(^)은 같은 비트면 0, 다르면 1이라 특정 비트만 뒤집는 데 최적입니다.
  • 디버깅 시 10진수 출력만 보면 실수를 놓치기 쉽습니다. 비트 패턴 출력 함수를 습관화하면 원인 추적이 빨라집니다.
  • 토글은 UI 상태, 장치 모드 전환, 게임 옵션 비트 등에서 자주 쓰입니다.

예제 3) 쉬프트로 패킹/언패킹

#include <stdio.h>
#include <stdint.h>

int main(void) {
    // 상위 4비트: version(0~15), 하위 4비트: type(0~15)
    uint8_t version = 3;
    uint8_t type = 12;

    uint8_t header = (uint8_t)((version << 4) | (type & 0x0F));

    uint8_t parsed_version = (header >> 4) & 0x0F;
    uint8_t parsed_type    = header & 0x0F;

    printf("header = 0x%02X\n", header);
    printf("version=%u, type=%u\n", parsed_version, parsed_type);

    return 0;
}

설명:

  • 여러 작은 값을 하나의 바이트/워드에 담는 것을 패킹이라고 합니다.
  • 추출(언패킹)할 때는 쉬프트 후 마스크를 적용해 필요한 비트만 남깁니다.
  • 네트워크/파일 포맷/임베디드 레지스터 해석에서 가장 흔한 패턴입니다.

자주 하는 실수

실수 1) 매직넘버를 직접 쓰기

  • 원인: 빠르게 작성하려다 비트 의미를 상수로 정의하지 않음.
  • 해결: #define 또는 enum으로 플래그를 이름 붙이고, 숫자 리터럴 대신 의미 이름을 사용.

실수 2) signed 정수에서 쉬프트 남용

  • 원인: 음수 우측 쉬프트 결과를 항상 동일하게 가정함.
  • 해결: 비트 조작은 uint8_t/uint16_t/uint32_t 같은 unsigned 계열로 통일.

실수 3) 우선순위 착각 (if (flags & MASK == 0))

  • 원인: ==&보다 먼저 평가되어 의도와 다르게 동작.
  • 해결: 항상 괄호 사용 if ((flags & MASK) == 0).

실수 4) ~MASK 사용 시 타입 폭 미고려

  • 원인: MASK가 작은 타입인데 정수 승격이 일어나 상위 비트까지 1로 채워짐.
  • 해결: 대상 타입으로 명시 캐스팅하거나, 동일 폭의 상수 사용(uint8_t, 0xFFu 등).

실무 패턴

  • 플래그 연산 API를 함수/매크로로 감싸 팀 내 표현을 통일합니다.
    • set_flag(&x, FLAG)
    • clear_flag(&x, FLAG)
    • has_flag(x, FLAG)
  • 상태값은 로그에서 10진수와 16진수를 함께 출력합니다. (%u, %#x)
  • 비트 위치 정의 문서를 코드 옆에 둡니다. (주석, README, 프로토콜 표)
  • 테스트는 경계 조합을 우선합니다.
    • 전부 0
    • 전부 1
    • 단일 비트 ON
    • 상위 비트만 ON
  • 하드웨어/프로토콜 코드에서는 "읽기-수정-쓰기"(RMW) 순서를 엄격히 관리합니다. 같은 레지스터를 여러 스레드/인터럽트가 만지는 경우 원자성 보장 전략까지 설계해야 합니다.

오늘의 결론

한 줄 요약: 비트 연산의 본질은 "숫자 계산"이 아니라 "상태 비트의 의도적 조작"이며, 안전한 실무 코드는 unsigned 타입 + 이름 있는 마스크 + 괄호 습관으로 만들어진다.

연습문제

  1. READ(1<<0), WRITE(1<<1), EXEC(1<<2) 권한을 갖는 uint8_t perms를 만들고, WRITE를 추가/제거/검사하는 함수를 각각 작성하세요.
  2. 16비트 값에서 상위 5비트를 a, 그다음 3비트를 b, 하위 8비트를 c로 분해하는 코드를 작성하세요.
  3. flags & MASK == 0 버그가 실제로 어떻게 오동작하는지 재현 코드를 만들고, 올바른 코드와 출력 차이를 설명하세요.

이전 강의 정답

43강(분할 컴파일/Makefile 기초) 연습문제 핵심 정리:

  • 여러 .c 파일을 각각 오브젝트로 컴파일한 뒤 링크해야 합니다.
    • clang -c main.c math.c io.c
    • clang -o app main.o math.o io.o
  • 헤더 파일에는 선언, 소스 파일에는 정의를 두고 include guard를 넣어 중복 포함 문제를 막습니다.
  • Makefile 기본 형태:
    • 타깃(app), 의존성(main.o math.o io.o), 레시피(링크 명령)
    • clean 타깃으로 산출물 정리
  • 핵심은 "변경된 파일만 다시 빌드"되게 의존성을 올바르게 표현하는 것입니다.

실습 환경/재현 정보

  • 컴파일러: Apple clang version 17.x 이상(또는 gcc 12+)
  • 컴파일 옵션: -std=c11 -Wall -Wextra -Werror -O0
  • 실행 환경: macOS(arm64) / Linux(x86_64) 공통 재현 가능
  • 재현 체크:
    • 예제 1: 권한 추가/제거 메시지 확인
    • 예제 2: 토글 전후 비트 패턴 3줄 비교
    • 예제 3: header의 16진수와 파싱 결과 일치 확인