임베디드 학습 노트 목차

비트 조작 — 레지스터를 정확히 건드리는 법

임베디드 코드에서 가장 많이 보이는 것이 |=, &= ~, << 다. 레지스터 하나에 여러 설정이 비트 단위로 욱여넣어져 있기 때문에, 원하는 자리만 골라 건드리는 기술이 곧 기본기다.

여기서 실수하면 다른 설정이 조용히 지워진다. 컴파일도 되고 경고도 없다.


네 가지 기본 동작

#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_08100b10대체기능
|= (0b01 << 10) 0x0000_0C100b11아날로그로 오염 ← 버그
&= ~(0b11 << 10)0x0000_00100b00자리를 비웠다
|= (0b01 << 10)0x0000_04100b01출력 — 의도대로

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번 비트? 조용히 사라진다

1int 라 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비트를 원하면 다시 잘라야 한다

~aa 를 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) 으로 직접 따라가면 원리가 보인다.

결과이진수
x0xB01011 0000
x - 10xAF1010 1111최하위 1 이 0 이 되고 그 아래가 전부 1 로 바뀐다
x & (x-1)0xA01010 0000그 최하위 1 만 지워진다
x & -x0x100001 00002의 보수(-x = ~x+1)라 최하위 1 만 살아남는다
x | (x-1)0xBF1011 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 로 올라간다. ~a0xFFFFFF0F 가 되는 이유. 대입에서는 잘려 우연히 맞고 비교·시프트에서 드러난다 → 결과를 명시적으로 캐스팅 (부호 섞임·비교 뒤집힘 등 정수 승격의 나머지 사례는 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(시프트 범위)

메모리 맵 — 주변장치가 주소인 이유임베디드 C 함정 — 컴파일은 되는데 틀린 것들