비트 단위로 값을 읽고 쓰고 검사하는 기법. 하드웨어 레지스터 조작의 기본이다.
네 가지 기본 연산
#define BIT(n) (1UL << (n))
reg |= BIT(3); // 세트 : 3번 비트를 1로 (나머지는 유지)
reg &= ~BIT(3); // 클리어 : 3번 비트를 0으로
reg ^= BIT(3); // 토글 : 뒤집기
if (reg & BIT(3)) { … } // 검사 : 3번 비트가 1인가
왜 이렇게 되는지
세트 x | 1 = 1, x | 0 = x → 1을 OR 하면 켜지고, 0인 자리는 그대로
클리어 x & 0 = 0, x & 1 = x → 0을 AND 하면 꺼지고, 1인 자리는 그대로
토글 x ^ 1 = ~x, x ^ 0 = x → 1을 XOR 하면 뒤집히고, 0인 자리는 그대로
~BIT(3)은 1111...0111이므로 3번만 0이고 나머지는 1이다.
그래서 AND 하면 3번만 꺼지고 나머지는 보존된다.
여러 비트 필드 — 마스크 후 시프트
// 비트 4~7 에 값 0b1010 을 넣기
#define FIELD_MASK (0xFUL << 4)
#define FIELD_SET(v) (((v) & 0xFUL) << 4)
reg = (reg & ~FIELD_MASK) | FIELD_SET(0xA);
// ↑ 기존 필드를 지우고 ↑ 새 값을 넣는다
"지우고 넣기"를 한 문장으로 하는 것이 중요하다. 두 문장으로 나누면 그 사이에 인터럽트가 끼어들 수 있고, 중간 상태가 하드웨어에 반영된다.
// 읽을 때
uint8_t v = (reg & FIELD_MASK) >> 4;
반드시 지켜야 할 규칙
① 1UL을 쓴다
uint32_t reg;
reg |= (1 << 31); // ✗ 1은 int(32비트 부호 있음) → 부호 비트 오버플로
// 미정의 동작
reg |= (1UL << 31); // ✓
② read-modify-write는 원자적이 아니다
GPIOA->ODR |= BIT(5);
이 한 줄은 읽기 → OR → 쓰기 3단계다. 중간에 인터럽트가 다른 비트를 바꾸면 그 변경이 사라진다.
메인: ODR 읽기 (0x00)
ISR: ODR |= BIT(3) → 0x08
메인: 0x00 | BIT(5) = 0x20 을 쓴다 ← ISR의 BIT(3)이 사라졌다
해결 — 하드웨어의 원자적 레지스터를 쓴다
GPIOA->BSRR = BIT(5); // 세트 (STM32) — 한 번의 쓰기. 원자적
GPIOA->BSRR = BIT(5) << 16; // 클리어
많은 MCU가 이런 set/clear 전용 레지스터를 제공한다. 있으면 반드시 쓴다. 없으면 임계 구역으로 감싼다.
③ volatile을 빠뜨리지 않는다
레지스터 포인터에 volatile이 없으면 컴파일러가 읽기를 생략한다.
유용한 관용구
x & (x - 1) // 최하위 1비트를 지운다
x & (-x) // 최하위 1비트만 남긴다
(x & (x - 1)) == 0 // 2의 거듭제곱인가 (0 제외)
x >> 1 // 2로 나누기 (부호 없는 경우)
x & (N - 1) // N이 2의 거듭제곱일 때 x % N 과 동일 (훨씬 빠름)
마지막이 특히 유용하다 — 나눗셈 명령이 없는 MCU에서 링 버퍼 인덱스를
idx = (idx + 1) & (SIZE - 1) 로 처리하면 나머지 연산 비용이 사라진다.
단 SIZE가 2의 거듭제곱이어야 한다.
비트 필드 구조체보다 나은 이유
struct { uint8_t en : 1; uint8_t mode : 3; } reg; // 이식성 없음
비트 필드는 배치 순서·패딩·엔디안이 구현 정의라 컴파일러를 바꾸면 레지스터 매핑이 어긋난다. 하드웨어 제어에는 마스크와 시프트를 명시적으로 쓰는 것이 안전하고, MISRA C도 이 방향을 권한다.
가독성 확보
// ✗ 매직 넘버
TIM2->CR1 |= 0x0001;
// ✓ 의미가 드러난다
#define TIM_CR1_CEN (1UL << 0) // Counter Enable
TIM2->CR1 |= TIM_CR1_CEN;
벤더 헤더(CMSIS)가 이런 매크로를 제공하므로 직접 숫자를 쓰지 말고 제공된 매크로를 쓴다.