비트 조작 — 레지스터를 정확히 건드리는 법
임베디드 코드에서 가장 많이 보이는 것이
|=,&= ~,<<다. 레지스터 하나에 여러 설정이 비트 단위로 욱여넣어져 있기 때문에, 원하는 자리만 골라 건드리는 기술이 곧 기본기다.여기서 실수하면 다른 설정이 조용히 지워진다. 컴파일도 되고 경고도 없다.
네 가지 기본 동작
#define BIT(n) (1UL << (n))
reg |= BIT(5); // 세트 — 5번 비트를 1 로, 나머지는 그대로
reg &= ~BIT(5); // 클리어 — 5번 비트를 0 로, 나머지는 그대로
reg ^= BIT(5); // 토글 — 5번 비트를 뒤집는다
if (reg & BIT(5)) { } // 검사 — 5번 비트가 1 인가
동작 원리는 진리표 하나로 정리된다.
OR (|) x | 0 = x 0 인 자리는 그대로 → 1 로 만들 자리만 1 을 준다
x | 1 = 1
AND (&) x & 1 = x 1 인 자리는 그대로 → 0 으로 만들 자리만 0 을 준다
x & 0 = 0
XOR (^) x ^ 0 = x 0 인 자리는 그대로 → 뒤집을 자리만 1 을 준다
x ^ 1 = ~x
"바꾸지 않을 자리는 항등원을 준다" 가 공통 원리다. OR 의 항등원은 0, AND 는 1, XOR 은 0.
여러 비트짜리 필드는 두 단계로 쓴다
레지스터 안의 설정이 항상 1비트는 아니다. 2비트·4비트 필드가 흔하다.
// STM32 GPIO MODER — 핀 하나당 2비트
// 00 입력 · 01 출력 · 10 대체기능 · 11 아날로그
// ✗ 흔한 실수 — 기존 값 위에 OR 만 하면 지워지지 않는다
GPIOA->MODER |= (0b01 << (5 * 2)); // 이전이 0b10 이었으면 0b11 이 된다
// ✓ 지우고 쓴다
GPIOA->MODER &= ~(0b11 << (5 * 2)); // ① 그 자리를 0 으로 밀고
GPIOA->MODER |= (0b01 << (5 * 2)); // ② 원하는 값을 넣는다
①을 빠뜨리는 것이 가장 흔한 버그다. 초기 상태가 마침 0 이면 잘 돌다가, 다른 설정을 하고 온 뒤에 깨진다. 재현이 어려운 이유가 이것이다.
실제 값으로 따라가 보기
핀 5 가 이미 대체기능(0b10) 으로 설정돼 있는 상태에서 출력(0b01) 으로 바꾸려 한다. MODER 에서 핀 5 의 자리는 비트 11:10 이다.
| 단계 | MODER | 핀 5 필드 | 뜻 |
|---|---|---|---|
| 초기 상태 | 0x0000_0810 | 0b10 | 대체기능 |
|= (0b01 << 10) 만 | 0x0000_0C10 | 0b11 | 아날로그로 오염 ← 버그 |
① &= ~(0b11 << 10) | 0x0000_0010 | 0b00 | 자리를 비웠다 |
② |= (0b01 << 10) | 0x0000_0410 | 0b01 | 출력 — 의도대로 |
OR 만 하면 0b10 | 0b01 = 0b11 이 되어 아날로그 모드가 된다. 핀은 죽고, 컴파일러는 아무 말도 안 한다.
초기값이 0b00 이었다면 OR 만으로도 우연히 맞아떨어져 — 그래서 "어제까지 되던 코드"가 오늘 깨진다.
함정 — 이 오염은 옆 필드로 번지지 않는다(핀 2 의
0b01은 그대로0x10에 남아 있다). 증상이 "핀 하나만 이상하다"로 나타나 레지스터 전체를 의심하지 않게 되는 것이 함정이다. 덤프를 찍어 필드 단위로 쪼개 보는 습관이 이걸 잡는다.
한 문장으로 쓰는 관용구도 있다.
#define FIELD_SET(reg, mask, shift, val) \
((reg) = ((reg) & ~((mask) << (shift))) | (((val) & (mask)) << (shift)))
FIELD_SET(GPIOA->MODER, 0b11, 5 * 2, 0b01);
(val) & (mask) 로 넘치는 값이 옆 필드를 침범하지 않게 막는 것이 요점이다. val 에 실수로 0b111 이 들어와도 마스크가 잘라 준다.
원자성 — 여기서 실제로 버그가 난다
GPIOA->ODR |= BIT(5);
이 한 줄이 기계어로는 셋이다.
ldr r0, [r1] ① 읽기
orr r0, #0x20 ② 수정
str r0, [r1] ③ 쓰기
①과 ③ 사이에 인터럽트가 끼어들어 같은 레지스터의 다른 비트를 바꾸면, ③이 옛 값을 덮어써 그 변경이 사라진다.
메인: ① 읽음 (ODR = 0x00)
인터럽트: ODR |= BIT(3) → ODR = 0x08
메인: ② 0x00 | 0x20 = 0x20
메인: ③ 씀 (ODR = 0x20) ← 인터럽트가 켠 3번 비트가 사라졌다
증상이 "가끔 LED 가 안 켜진다" 로 나타난다. 100번에 한 번이라 디버거로 잡기 어렵다.
해결 ① — 하드웨어가 원자적 레지스터를 준다
GPIOA->BSRR = BIT(5); // SET — 읽기가 필요 없다
GPIOA->BSRR = BIT(5 + 16); // RESET — 상위 16비트가 클리어용
BSRR 은 쓰기 전용이고 1 을 쓴 비트만 동작한다. 0 인 자리는 아무 일도 없으므로 기존 값을 읽을 이유가 없다. 쓰기 한 번이라 인터럽트가 끼어들 틈이 없다.
GPIO 를 다룰 때 ODR |= 대신 BSRR 을 쓰는 것이 정석이다.
해결 ② — 임계 구역으로 감싼다
BSRR 같은 것이 없는 레지스터라면 인터럽트를 잠깐 막는다.
static inline uint32_t enter_critical(void) {
uint32_t primask = __get_PRIMASK();
__disable_irq();
return primask;
}
static inline void exit_critical(uint32_t primask) {
__set_PRIMASK(primask); // 원래 상태로 되돌린다
}
uint32_t s = enter_critical();
TIM2->CR1 |= TIM_CR1_CEN;
exit_critical(s);
되돌릴 때 무조건 __enable_irq() 를 부르면 안 된다. 이미 막혀 있던 곳에서 불렸다면 남의 임계 구역을 열어 버린다. 이전 상태를 저장했다 복원하는 것이 정석이다.
임계 구역은 가능한 짧게 둔다. 그동안 인터럽트가 지연되므로, 여기가 길면 실시간성이 무너진다.
시프트에서 데는 곳
uint32_t reg;
reg |= (1 << 31); // ✗ 1 은 int(부호 있음) — 31비트 시프트는 미정의 동작
reg |= (1UL << 31); // ✓ unsigned 로 명시
uint8_t flags = 0;
flags |= (1 << 9); // ✗ 8비트에 9번 비트? 조용히 사라진다
1 은 int 라 32비트 환경에서 31번 비트로 밀면 부호 비트를 건드려 미정의 동작이다. 1UL 이나 1U 로 쓰는 습관을 들인다.
// 시프트 양이 변수일 때 범위를 넘으면 역시 미정의
int n = 40;
uint32_t v = 1UL << n; // ✗ 32 이상은 미정의 — 0 이 될지 다른 값이 될지 모른다
마스크를 만들 때의 정수 승격 — ~ 가 상위 비트까지 켠다
C 는 int 보다 작은 타입을 연산 전에 int 로 승격한다(정수 승격 — 자세한 규칙과 다른
사례는 04편). 비트 마스킹에서는 이것이 ~ 와 만날 때 특히 잘 문다.
uint8_t a = 0xF0;
printf("%X", ~a); // 0xFFFFFF0F ← 0x0F 가 아니다
printf("%X", (uint8_t)~a); // 0x0F ← 8비트를 원하면 다시 잘라야 한다
~a 는 a 를 32비트로 올린 뒤 뒤집으므로 상위 24비트가 전부 1 이 된다.
그런데 이게 늘 드러나지는 않는다는 점이 고약하다.
uint8_t flags = 0xFF;
flags &= ~0x0F; // 결과 0xF0 — 대입에서 잘려 '우연히' 맞다
if ((~a) == 0x0F) { } // ✗ 절대 참이 아니다 — 0xFFFFFF0F 와 비교하므로
if ((uint8_t)(~a) == 0x0F) { } // ✓
함정 정리 — 대입할 때는 잘려서 우연히 맞고, 비교·시프트·
printf에서는 안 잘려 틀린다. "같은 식인데 어떤 줄은 되고 어떤 줄은 안 되는" 혼란이 여기서 나온다. 좁은 폭의 결과가 필요하면 연산 결과를 명시적으로 캐스팅한다.시프트에서도 같은 일이 생긴다 —
uint8_t t = b << 8;은 32비트로 계산된 뒤 대입에서 잘려 0 이 된다. 16비트int를 쓰는 MCU 로 포팅하면 결과가 또 달라지므로, MISRA 는 연산 결과의 타입을 명시하라고 요구한다.
마스크를 만드는 관용구
#define MASK(width) ((1UL << (width)) - 1) // width 개의 1
#define MASK_AT(width, shift) (MASK(width) << (shift))
MASK(3) // 0b111
MASK_AT(3, 4) // 0b0111_0000
// 필드 읽기
uint32_t val = (reg >> shift) & MASK(width);
// 특정 비트가 모두 1 인지
if ((reg & mask) == mask) { } // ✓ 전부 켜졌나
if (reg & mask) { } // ✗ 하나라도 켜졌으면 참 — 다른 뜻이다
== mask 를 빠뜨려 "전부"와 "하나라도"를 혼동하는 것도 흔한 실수다.
자주 쓰는 비트 트릭
x & (x - 1) // 가장 낮은 1 비트를 지운다
x & -x // 가장 낮은 1 비트만 남긴다
x | (x - 1) // 가장 낮은 1 비트 아래를 전부 1 로
(x & (x - 1)) == 0 // 2의 거듭제곱인가 (0 도 참이라 주의)
x = 0xB0 (0b1011_0000) 으로 직접 따라가면 원리가 보인다.
| 식 | 결과 | 이진수 | 왜 |
|---|---|---|---|
x | 0xB0 | 1011 0000 | — |
x - 1 | 0xAF | 1010 1111 | 최하위 1 이 0 이 되고 그 아래가 전부 1 로 바뀐다 |
x & (x-1) | 0xA0 | 1010 0000 | 그 최하위 1 만 지워진다 |
x & -x | 0x10 | 0001 0000 | 2의 보수(-x = ~x+1)라 최하위 1 만 살아남는다 |
x | (x-1) | 0xBF | 1011 1111 | 최하위 1 아래가 전부 1 로 채워진다 |
핵심은 x - 1 이 "최하위 1 을 빌려 아래를 전부 1 로 만든다" 는 것 하나다. 나머지 세 트릭이
전부 여기서 파생된다. x & (x-1) 을 반복하면 1 의 개수(팝카운트) 를 1 의 수만큼만 돌고 셀 수 있다.
int popcount(uint32_t x) {
int n = 0;
while (x) { x &= x - 1; n++; } // 1 이 몇 개든 그 횟수만 돈다
return n;
}
// 2의 거듭제곱으로 정렬 올림 — 버퍼 정렬에 자주 쓴다
#define ALIGN_UP(v, a) (((v) + (a) - 1) & ~((a) - 1))
ALIGN_UP(37, 32) // 64
ALIGN_UP(64, 32) // 64 — 이미 맞으면 그대로
DMA 버퍼를 캐시 라인에 맞출 때 이 매크로가 쓰인다.
하드웨어 명령을 쓰는 편이 빠르다
__CLZ(x) // 앞쪽 0 의 개수 — Count Leading Zeros
31 - __CLZ(x) // 가장 높은 1 비트의 위치 = log2 의 정수부
__RBIT(x) // 비트 순서 뒤집기
__REV(x) // 바이트 순서 뒤집기 — 엔디안 변환
Cortex-M 은 이들을 단일 명령으로 지원한다. 루프로 세는 것보다 훨씬 빠르므로, 우선순위 큐에서 "가장 높은 우선순위 찾기" 같은 곳에 쓰인다.
// 대기 중인 최고 우선순위 태스크를 O(1) 로 찾는다
uint32_t ready_mask = 0b0000_1010;
int highest = 31 - __CLZ(ready_mask); // 3
RTOS 스케줄러가 이 수법을 쓴다 — 태스크 수와 무관하게 항상 같은 시간이 걸리므로 실시간성이 보장된다.
비트필드는 권하지 않는다
struct {
uint32_t enable : 1;
uint32_t mode : 2;
uint32_t speed : 2;
} cfg;
읽기 쉬워 보이지만 표준이 배치를 정하지 않는다.
- 비트를 어느 끝부터 채우는지 가 구현 정의다(엔디안·컴파일러마다 다름)
- 필드 사이에 패딩이 들어갈 수 있다
- 레지스터에 쓸 때 컴파일러가 읽기·수정·쓰기로 나눌 수 있어 원자성이 깨진다
레지스터 매핑이나 통신 프로토콜 파싱에는 마스크와 시프트를 쓴다. 코드가 조금 길어져도 동작이 명확하다.
한눈에 정리
- 바꾸지 않을 자리에는 항등원 — OR 은 0, AND 는 1, XOR 은 0
- 여러 비트 필드는 반드시 지우고 쓴다 —
&= ~mask를 빠뜨리면 초기값이 0 일 때만 우연히 동작한다 |=는 읽기·수정·쓰기 세 단계라 인터럽트에 깨진다. 증상은 "가끔 안 된다"- GPIO 는
BSRR— 쓰기 전용에 1 을 쓴 비트만 동작해 원자적이다 - 임계 구역은 이전 PRIMASK 를 복원한다. 무조건
__enable_irq()는 남의 구역을 연다 1 << 31은 미정의 —1UL로 쓴다. 시프트 양이 폭을 넘어도 미정의(reg & mask) == mask(전부)와reg & mask(하나라도) 는 다른 뜻이다__CLZ는 단일 명령 — RTOS 가 최고 우선순위를 O(1) 로 찾는 수법- 비트필드는 배치가 구현 정의 — 레지스터·프로토콜에는 마스크와 시프트를 쓴다
- 정수 승격 —
uint8_t도 연산 전에int로 올라간다.~a가0xFFFFFF0F가 되는 이유. 대입에서는 잘려 우연히 맞고 비교·시프트에서 드러난다 → 결과를 명시적으로 캐스팅 (부호 섞임·비교 뒤집힘 등 정수 승격의 나머지 사례는 04편에서 다룬다) x - 1이 최하위 1 을 빌려 아래를 1 로 채운다 —x&(x-1)·x&-x·x|(x-1)이 전부 여기서 나온다
꼬리질문 대비
- "
reg |= BIT(5)가 원자적이지 않은 이유는?" → LDR·ORR·STR 세 명령이라 사이에 인터럽트가 낀다- "그럼
BSRR은 왜 안전한가?" → 읽기가 없는 쓰기 한 번이고 1 을 쓴 비트만 동작한다- "
uint8_t a=0xF0; ~a의 값은?" →0xFFFFFF0F(int 승격). 8비트를 원하면(uint8_t)~a- "비트필드로 레지스터를 매핑하면 안 되는 이유 세 가지는?" → 배치가 구현 정의 · 패딩 · 컴파일러가 RMW 로 쪼개 원자성이 깨짐
출처 — ISO/IEC 9899:2018 (C17) §6.5.7 비트 시프트 연산자 · §6.7.2.1 비트필드 · Arm, CMSIS-Core (Cortex-M) Intrinsic Functions(
__CLZ·__RBIT·__REV) · STMicroelectronics, RM0090 GPIO·BSRR · MISRA C:2012 Rule 12.2(시프트 범위)