← Embedded C 강의 목록으로
🔌
응용
응용 · 선수: 20강 · 응용 · 선수: 21강

21–22. 디바운싱·유한 상태 머신 패턴 · 링버퍼와 인터럽트 기반 UART 수신

주변장치 사용법을 익혔으니 이제 펌웨어를 짜는 패턴으로 넘어갑니다. 거의 모든 프로젝트에 등장하는 두 가지 — 디바운싱과 유한 상태 머신(FSM)입니다. 기계식 버튼은 누르는 순간 접점이 수 ms 튀어(채터링) 한 번이 여러 번으로 읽힙니다. 디바운싱은 떨림을 걸러 진짜 입력만 남기고, FSM은 "상태+입력→다음 상태" 규칙으로 동작을 깔끔하게 표현합니다. 하드웨어 비의존 순수 로직이라 gcc로 정확히 검증합니다. UART로 데이터가 쏟아지면 수신 인터럽트가 매 바이트마다 걸립니다. ISR에서 다 처리하려 들면 ISR이 길어지고, 메인이 느리면 다음 바이트가 이전 바이트를 덮어써 유실됩니다. 해법은 ISR은 버퍼에 넣기만 하고 처리는 한가한 메인이 맡는 분업입니다. 그 핵심 자료구조가 링버퍼(원형 버퍼)입니다. 생산자(ISR)와 소비자(메인)가 서로 다른 인덱스만 건드리면 인터럽트를 끄지 않고도 안전합니다.

디바운싱채터링FSM상태머신적분기모듈링버퍼FIFOUART인터럽트SPSCvolatile
소요 시간
⏱ 약 1.5시간 + 약 1.5~2시간
난이도
📊 중급
선수 조건
🎯 20강
결과물
적분기(연속 샘플 카운트) 디바운서를 모듈로 만들고, 디바운스된 확정 에지로 enum+switch FSM을 구동해 입력 정제와 동작 결정을 분리할 수 있습니다. / head/tail FIFO 링버퍼를 2의 거듭제곱 마스킹으로 구현하고, 단일 생산자/소비자(SPSC) + volatile로 UART RX 인터럽트와 안전하게 결합할 수 있습니다.

이 강의에서 배우는 것

  • 1버튼 채터링(바운스)의 원인과 증상을 설명한다
  • 2적분기(연속 샘플 카운트) 방식의 디바운서를 구현한다
  • 3디바운서를 모듈로 분리해 재사용·테스트 가능하게 만든다
  • 4유한 상태 머신의 구성요소(상태·입력·전이)를 이해한다
  • 5디바운스된 확정 에지로 FSM을 구동한다
  • 6인터럽트 수신에서 데이터 유실이 왜 생기는지 설명한다
  • 7링버퍼의 head/tail FIFO 구조를 이해한다
  • 8빈/가득 판정과 2의 거듭제곱 마스킹을 구현한다
  • 9단일 생산자/소비자(SPSC) 환경의 안전성과 volatile을 이해한다
  • 10링버퍼를 UART RX 인터럽트와 결합하는 패턴을 안다

21. 디바운싱·유한 상태 머신(FSM) 패턴

주변장치 사용법을 익혔으니 이제 펌웨어를 짜는 패턴으로 넘어갑니다. 거의 모든 프로젝트에 등장하는 두 가지 — 디바운싱과 유한 상태 머신(FSM)입니다. 기계식 버튼은 누르는 순간 접점이 수 ms 튀어(채터링) 한 번이 여러 번으로 읽힙니다. 디바운싱은 떨림을 걸러 진짜 입력만 남기고, FSM은 "상태+입력→다음 상태" 규칙으로 동작을 깔끔하게 표현합니다. 하드웨어 비의존 순수 로직이라 gcc로 정확히 검증합니다.

소개

기계식 버튼의 금속 접점은 닿는 순간 미세하게 튕겨, 한 번 누름이 전기적으로 0→1→0→1…처럼 수 ms간 진동합니다. 이 신호를 그대로 읽으면 여러 번으로 카운트됩니다. 이번 편은 하드웨어 비의존 순수 로직 — 디바운서를 모듈(debounce.c/.h)로 분리하고 그 위에 LED 모드 FSM을 얹습니다.

핵심 개념

1) 적분기 디바운서

일정 주기(예: SysTick 1~5ms)로 핀을 샘플링하고, 안정 상태와 다른 값이 THRESHOLD번 연속될 때만 상태를 바꿉니다. 중간에 한 번이라도 원래 값으로 돌아오면 카운터가 0으로 리셋되어 글리치가 걸러집니다(논블로킹·견고).

c
uint8_t debounce_update(debounce_t *db, uint8_t raw) {
    uint8_t r = raw ? 1u : 0u;
    if (r == db->stable) { db->count = 0u; return 0u; }  /* 떨림 없음 */
    if (++db->count >= DEBOUNCE_THRESHOLD) {             /* N번 연속 */
        db->stable = r; db->count = 0u; return 1u;       /* 확정 변경 */
    }
    return 0u;
}

2) 유한 상태 머신(FSM)

현재 상태입력(눌림 확정)다음 상태
OFFpressSLOW
SLOWpressFAST
FASTpressOFF

C에서는 enum + switch로 구현합니다. 상태가 명시적이라 디버깅·확장이 쉽습니다.

💡

FSM은 디바운서가 확정한 "눌림 에지"만 입력으로 받습니다. 입력 정제(디바운스)와 동작 결정(FSM)을 분리하면 코드가 깔끔합니다. 보드에서는 SysTick 같은 주기 인터럽트에서 raw = (GPIOA->IDR >> pin) & 1 을 읽어 넣기만 하면 됩니다.

핵심 예제

c
/* 노이즈 낀 raw 스트림 → 디바운스 → 눌림 확정 에지마다 OFF→SLOW→FAST 순환 */
typedef enum { MODE_OFF=0, MODE_SLOW=1, MODE_FAST=2 } led_mode_t;
for (i = 0; i < n; i++) {
    if (debounce_update(&db, raw[i]) && debounce_state(&db) == 1u) {
        mode = (led_mode_t)((mode + 1) % 3);   /* 확정 에지에서만 전진 */
    }
}

검증: 짧은 글리치(1샘플)는 무시되고 진짜 누름(THRESHOLD 연속)에서만 모드가 바뀝니다. 빌드: gcc pc_test.c debounce.c -o pc_test.

자주 하는 실수

Q. 디바운스했는데도 가끔 두 번 눌린 것처럼 동작해요.

A. THRESHOLD가 너무 작거나 샘플 주기가 너무 빠릅니다. 보통 샘플 주기 × THRESHOLD가 10~30ms가 되도록 잡습니다(예: 5ms × 3 = 15ms).

Q. 버튼을 누르고 있는 동안 모드가 계속 바뀌어요.

A. "눌림 상태"가 아니라 "눌림으로 바뀐 에지"에서만 동작해야 합니다. debounce_update의 반환값(변경됨)과 새 상태가 1인지를 함께 확인하세요.

Q. FSM을 if-else 더미로 짰더니 상태가 꼬여요.

A. 상태를 enum으로 명시하고 switch로 전이를 한곳에 모으세요. 상태가 코드 곳곳에 흩어지면 추적이 어렵습니다.

정리

  • 채터링은 기계 접점 떨림으로 한 번 누름이 여러 번으로 읽힌다
  • 적분기 디바운서는 같은 값이 N번 연속될 때만 상태를 확정해 글리치를 거른다
  • 디바운서를 모듈로 분리하면 어느 칩에서도 재사용·테스트할 수 있다
  • FSM은 상태·입력·전이로 동작을 표현하며 enum+switch로 구현한다
  • 입력 정제(디바운스)와 동작 결정(FSM)을 분리하면 코드가 깔끔하다

과제

  1. THRESHOLD를 2/5로 바꿔 가며 글리치 거름이 어떻게 달라지는지 관찰
  2. 길게 누르면(long press) 별도 동작을 하는 상태를 FSM에 추가
  3. 두 버튼을 각각 디바운스해 독립적으로 FSM을 구동

22. 링버퍼와 인터럽트 기반 UART 수신

UART로 데이터가 쏟아지면 수신 인터럽트가 매 바이트마다 걸립니다. ISR에서 다 처리하려 들면 ISR이 길어지고, 메인이 느리면 다음 바이트가 이전 바이트를 덮어써 유실됩니다. 해법은 ISR은 버퍼에 넣기만 하고 처리는 한가한 메인이 맡는 분업입니다. 그 핵심 자료구조가 링버퍼(원형 버퍼)입니다. 생산자(ISR)와 소비자(메인)가 서로 다른 인덱스만 건드리면 인터럽트를 끄지 않고도 안전합니다.

소개

RX 인터럽트는 바이트마다 발생합니다. ISR이 길거나 메인이 제때 안 가져가면 다음 바이트가 수신 레지스터를 덮어써 사라집니다. "빨리 받아 어딘가 쌓아두고, 처리는 나중에"가 필요합니다.

핵심 개념

1) 링버퍼 구조와 빈/가득 판정

고정 배열을 원형으로 씁니다. head(쓰기)·tail(읽기)이 끝에 도달하면 0으로 감싸 돕니다(FIFO). 빈 상태는 head==tail, 가득 상태는 ((head+1)&MASK)==tail. 가득 판정을 위해 슬롯 하나를 비우므로 크기 N이면 용량은 N-1.

c
#define RB_SIZE 8u
#define RB_MASK (RB_SIZE - 1u)
next = (head + 1) & RB_MASK;   /* 2의 거듭제곱이면 & 로 빠르게 wrap */

2) SPSC 안전성과 volatile

생산자(ISR)는 head만, 소비자(메인)는 tail만 씁니다. 서로 다른 변수를 갱신하므로 단일 생산자/단일 소비자(SPSC)에서는 락이나 인터럽트 차단 없이도 안전합니다. 단, head/tail은 ISR·메인 공유라 volatile 로 선언해야 합니다.

3) UART RX 인터럽트와 결합

c
void USART1_IRQHandler(void) {
    if (USART1->SR & USART_SR_RXNE) {
        uint8_t b = (uint8_t)USART1->DR;   /* 읽으면 RXNE 클리어 */
        rb_push(&rx_rb, b);                /* 생산자: head 만 */
    }
}
/* 메인: if (rb_pop(&rx_rb, &c)) { ...느긋하게 처리... }  (소비자: tail 만) */
ℹ️

USART1->CR1 에 USART_CR1_RXNEIE(수신 인터럽트)를 켜고 NVIC_EnableIRQ(USART1_IRQn)를 호출하면 RX 인터럽트가 동작합니다. 처리는 메인이 합니다.

핵심 예제

c
uint8_t rb_push(ringbuffer_t *rb, uint8_t data) {
    uint16_t next = (uint16_t)((rb->head + 1u) & RB_MASK);
    if (next == rb->tail) return 0u;          /* 가득 — 거절(유실 방지) */
    rb->buf[rb->head] = data; rb->head = next; return 1u;
}
uint8_t rb_pop(ringbuffer_t *rb, uint8_t *out) {
    if (rb->head == rb->tail) return 0u;       /* 비었음 */
    *out = rb->buf[rb->tail];
    rb->tail = (uint16_t)((rb->tail + 1u) & RB_MASK); return 1u;
}

검증: 크기 8(용량 7)에 8개째 push는 FULL로 거절, 꺼내면 들어간 순서대로(FIFO) 나옵니다. 빌드: gcc pc_test.c ringbuffer.c -o pc_test.

자주 하는 실수

Q. head/tail에 volatile을 안 붙여도 PC에선 잘 돼요.

A. PC(단일 스레드)에선 안 보일 수 있지만, ISR·메인이 공유하는 임베디드에선 컴파일러가 캐싱해 갱신을 못 봅니다. 반드시 volatile 을 붙이세요.

Q. 크기를 10으로 했더니 wrap이 이상해요.

A. & (N-1) 마스킹은 N이 2의 거듭제곱일 때만 맞습니다. 10이면 % 10을 쓰거나 8·16처럼 2의 거듭제곱으로 잡으세요.

Q. 버퍼가 가득 찼는데 push가 덮어써요.

A. push에서 ((head+1)&MASK)==tail이면 거절(0 반환)해야 합니다. 안 그러면 tail을 추월해 아직 안 읽은 데이터를 덮어씁니다.

정리

  • 인터럽트 수신은 ISR이 버퍼에 넣고 메인이 꺼내 처리하는 분업이 안전하다
  • 링버퍼는 head/tail로 도는 FIFO이며 빈/가득을 인덱스로 판정한다
  • 크기를 2의 거듭제곱으로 잡으면 & MASK로 빠르게 wrap한다(용량 N-1)
  • SPSC에서는 head/tail 분리 + volatile로 락 없이 안전하다
  • UART RX ISR은 rb_push만, 메인은 rb_pop 후 처리

과제

  1. rb_count() 가 래핑 케이스에서도 올바른 개수를 주는지 확인
  2. 버퍼 크기를 16으로 늘리고 MASK를 맞게 수정
  3. 줄바꿈(\n)까지 모아 한 줄을 조립하는 line_assemble() 추가
예제 코드 / 강의 자료

전체 강의 자료와 예제 코드(과제·정답 포함)는 GitHub에서 자유롭게 받아볼 수 있습니다.

GitHub에서 보기 ↗

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 주세요.

댓글은 회원만 쓸 수 있습니다.

광고·욕설·비방·개인정보·불법 내용이 담긴 댓글은 숨기거나 지웁니다. 댓글은 닉네임과 함께 공개됩니다. 개인정보처리방침