[C 시리즈 18편] 비트 연산으로 저수준 감각 키우기

English version

함수 포인터로 동작을 전달하는 감각을 익혔다면, 이번에는 데이터를 가장 작은 단위인 비트 수준에서 조작하는 연습을 해 봅니다. 비트 연산을 이해하면 메모리와 하드웨어 레지스터를 효율적으로 다루고, 여러 상태를 하나의 정수에 압축해 표현할 수 있습니다.

이번 글에서 새로 나오는 용어

  1. 비트 (Bit): 0 또는 1 중 하나의 값을 표현하는 최소 단위
  2. 비트 마스크 (Bit Mask): 특정 비트를 골라내거나 조작하는 데 사용하는 값
  3. 플래그 (Flag): 상태를 나타내기 위해 켜고 끄는 비트
  4. 시프트 연산 (Shift): 비트를 왼쪽 또는 오른쪽으로 이동시키는 연산

핵심 개념

학습 메모

  • 소요 시간: 60분 내외
  • 준비물: 정수 자료형 크기, 논리 연산자 이해, 2진수 표기법 감각
  • 학습 목표: 비트 연산 종류별 의미 이해, 플래그·마스크 설계, 시프트로 빠른 곱셈·나눗셈 구현

C의 비트 연산자는 &, |, ^, ~, <<, >> 여섯 가지입니다. 여기서 &는 비트 단위 AND, |는 OR, ^는 XOR, ~는 NOT, <<는 왼쪽 시프트, >>는 오른쪽 시프트입니다. 정수형에서만 사용하며, 각 비트 자리에 대해 논리를 수행합니다. 입문 단계에서는 부호 있는 정수보다 unsigned 값을 기준으로 예제를 보는 편이 더 안전합니다.

이번 글에서는 다음 순서로 살펴봅니다.

  • 비트 연산자별 동작과 2진수 표기를 직접 써 가며 확인하기
  • 플래그 값을 정의하고 켜기·끄기·토글하기
  • 센서 상태나 권한을 한 정수에 묶어 관리하기
  • 시프트로 빠른 곱셈/나눗셈과 데이터 패킹 구현하기

코드로 따라하기

기본 비트 연산

#include <stdio.h>

void print_bits(unsigned int value) {
    int i;
    for (i = 7; i >= 0; i--) {
        unsigned int mask = 1u << i;
        printf("%u", (value & mask) ? 1u : 0u);
    }
    printf("\n");
}

int main(void) {
    unsigned int a = 0xAA; // 10101010
    unsigned int b = 0xF0; // 11110000

    printf("a & b = ");
    print_bits(a & b);

    printf("a | b = ");
    print_bits(a | b);

    printf("a ^ b = ");
    print_bits(a ^ b);

    printf("~a   = ");
    print_bits((unsigned char)(~a));

    return 0;
}

위 예제는 C11/C17 환경에서도 바로 따라 할 수 있도록 16진수 0xAA, 0xF0를 사용했습니다. print_bits 함수는 상위 8비트만 출력하도록 만들어 비트 패턴을 눈으로 확인할 수 있게 도와줍니다. ~ 연산은 정수 승격 뒤에 수행되므로, 보고 싶은 비트 폭만 남기려면 예제처럼 캐스팅이나 마스크를 함께 사용하는 습관이 도움이 됩니다.

플래그 정의와 조작

#include <stdio.h>

enum {
    FLAG_READ  = 1 << 0, // 0001
    FLAG_WRITE = 1 << 1, // 0010
    FLAG_EXEC  = 1 << 2, // 0100
    FLAG_SYNC  = 1 << 3  // 1000
};

void print_status(unsigned int flags) {
    printf("권한: R=%d W=%d X=%d S=%d\n",
           (flags & FLAG_READ) != 0,
           (flags & FLAG_WRITE) != 0,
           (flags & FLAG_EXEC) != 0,
           (flags & FLAG_SYNC) != 0);
}

int main(void) {
    unsigned int flags = 0;

    flags |= FLAG_READ;
    flags |= FLAG_WRITE;
    print_status(flags);

    flags &= ~FLAG_WRITE;
    flags |= FLAG_SYNC;
    print_status(flags);

    flags ^= FLAG_EXEC;
    print_status(flags);

    return 0;
}

|=는 플래그를 켜고, &= ~는 특정 플래그를 끕니다. ^=는 토글(toggle) 동작입니다. 이런 패턴은 파일 권한, 이벤트 상태 등 다양한 곳에서 사용됩니다.

센서 상태를 비트로 묶기

#include <stdio.h>

#define SENSOR_TEMP   (1u << 0)
#define SENSOR_HUMID  (1u << 1)
#define SENSOR_LIGHT  (1u << 2)
#define SENSOR_MOTION (1u << 3)

unsigned int read_sensors(int temperature, int humidity, int light, int motion) {
    unsigned int status = 0;

    if (temperature > 30) {
        status |= SENSOR_TEMP;
    }
    if (humidity < 30) {
        status |= SENSOR_HUMID;
    }
    if (light < 100) {
        status |= SENSOR_LIGHT;
    }
    if (motion) {
        status |= SENSOR_MOTION;
    }

    return status;
}

void handle_status(unsigned int status) {
    if (status & SENSOR_TEMP) {
        printf("온도 경고\n");
    }
    if (status & SENSOR_HUMID) {
        printf("습도 경고\n");
    }
    if (status & SENSOR_LIGHT) {
        printf("조도 경고\n");
    }
    if (status & SENSOR_MOTION) {
        printf("움직임 감지\n");
    }
}

int main(void) {
    unsigned int status = read_sensors(32, 25, 90, 1);
    handle_status(status);
    return 0;
}

여러 상태를 정수 하나에 담으면 함수 인자도 간단해지고, 조건을 겹쳐 검사하기 쉬워집니다.

시프트 연산으로 빠른 계산

#include <stdio.h>

int main(void) {
    unsigned int value = 5;

    printf("value << 1 = %u\n", value << 1); // 5 * 2
    printf("value << 3 = %u\n", value << 3); // 5 * 8

    unsigned int packed = (0x0A << 4) | 0x03; // 상위 4비트 + 하위 4비트
    printf("packed = 0x%X\n", packed);

    unsigned int high = (packed >> 4) & 0x0F;
    unsigned int low = packed & 0x0F;
    printf("high=%u, low=%u\n", high, low);

    return 0;
}

시프트는 단순 곱셈뿐 아니라 데이터를 여러 조각으로 나누거나 합치는 데도 쓰입니다. 임베디드 장비에서는 레지스터의 특정 비트만 수정해야 하는 경우가 많으므로 시프트와 마스크 조합이 필수입니다. 다만 부호 있는 정수를 오른쪽 시프트할 때의 결과는 환경에 따라 다르게 보일 수 있으므로, 입문 단계에서는 unsigned 값으로 먼저 연습하는 편이 안전합니다.

왜 중요한가

  • 비트 연산을 이해하면 메모리 사용량을 줄이고, 상태를 압축해 전달할 수 있습니다.
  • 하드웨어 레지스터, 파일 포맷, 네트워크 프로토콜을 읽을 때 각 비트 위치의 의미를 파악할 수 있습니다.
  • 시프트는 특정 플랫폼에서 빠른 곱셈/나눗셈 대체 연산으로 사용됩니다.
  • 플래그와 마스크 설계 능력은 시스템 프로그래밍과 임베디드에서 기본 역량입니다.

CodeSandbox로 이어서 실습하기

아래 샌드박스는 CodeSandbox의 Universal starter입니다. C는 터미널에서 직접 컴파일하고 다시 실행하는 흐름이 중요하니, 이번 글의 코드를 파일로 만들고 빌드-실행 사이클을 다시 따라가 보세요.

Live Practice

C Practice Sandbox

CodeSandbox

Run the starter project in CodeSandbox, compare it with the lesson code, and keep experimenting.

Universal starterCterminal
  1. starter를 fork한 뒤 hello.c 같은 실습 파일을 만든다
  2. 본문 코드를 붙여 넣고 clang 또는 gcc가 있으면 직접 컴파일한다
  3. 코드를 고친 뒤 다시 빌드하고 실행 결과를 비교한다

C는 브라우저 미리보기보다 터미널 빌드 흐름이 핵심입니다. Universal starter의 컴파일러 구성이 환경에 따라 다를 수 있으니, 먼저 clang이나 gcc 사용 가능 여부부터 확인하세요.

실습

  • 따라 하기: print_bits 함수를 확장해 16비트까지 출력하도록 수정하고, ~ 연산을 적용했을 때 어떤 비트가 뒤집히는지 확인합니다.
  • 확장하기: 센서 예제에 SENSOR_ERROR 플래그를 추가하고, 특정 조합에서만 에러를 표시하도록 조건문을 설계합니다.
  • 디버깅: 32비트 정수에서 시프트 후 마스크를 잘못 적용했을 때 왜 잘못된 값이 나오는지 추적해 봅니다.
  • 완료 기준: 비트 연산자 각각의 의미를 설명하고, 두 개 이상의 상태를 플래그로 묶어 처리하는 함수를 작성할 수 있으면 됩니다.

마무리

비트 연산은 겉으로 보기에는 기호 몇 개뿐이지만, 실제로는 메모리와 하드웨어를 직접 제어하는 데 필요한 언어입니다. 다음 편에서는 이런 저수준 감각을 프로젝트 구성으로 연결하며 빌드 자동화와 다중 파일 관리 방법을 다룹니다.

💬 댓글

이 글에 대한 의견을 남겨주세요