21–22. 디바운싱·유한 상태 머신 패턴 · 링버퍼와 인터럽트 기반 UART 수신
주변장치 사용법을 익혔으니 이제 펌웨어를 짜는 패턴으로 넘어갑니다. 거의 모든 프로젝트에 등장하는 두 가지 — 디바운싱과 유한 상태 머신(FSM)입니다. 기계식 버튼은 누르는 순간 접점이 수 ms 튀어(채터링) 한 번이 여러 번으로 읽힙니다. 디바운싱은 떨림을 걸러 진짜 입력만 남기고, FSM은 "상태+입력→다음 상태" 규칙으로 동작을 깔끔하게 표현합니다. 하드웨어 비의존 순수 로직이라 gcc로 정확히 검증합니다. UART로 데이터가 쏟아지면 수신 인터럽트가 매 바이트마다 걸립니다. ISR에서 다 처리하려 들면 ISR이 길어지고, 메인이 느리면 다음 바이트가 이전 바이트를 덮어써 유실됩니다. 해법은 ISR은 버퍼에 넣기만 하고 처리는 한가한 메인이 맡는 분업입니다. 그 핵심 자료구조가 링버퍼(원형 버퍼)입니다. 생산자(ISR)와 소비자(메인)가 서로 다른 인덱스만 건드리면 인터럽트를 끄지 않고도 안전합니다.
이 강의에서 배우는 것
- 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으로 리셋되어 글리치가 걸러집니다(논블로킹·견고).
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)
| 현재 상태 | 입력(눌림 확정) | 다음 상태 |
|---|---|---|
| OFF | press | SLOW |
| SLOW | press | FAST |
| FAST | press | OFF |
C에서는 enum + switch로 구현합니다. 상태가 명시적이라 디버깅·확장이 쉽습니다.
FSM은 디바운서가 확정한 "눌림 에지"만 입력으로 받습니다. 입력 정제(디바운스)와 동작 결정(FSM)을 분리하면 코드가 깔끔합니다. 보드에서는 SysTick 같은 주기 인터럽트에서 raw = (GPIOA->IDR >> pin) & 1 을 읽어 넣기만 하면 됩니다.
핵심 예제
/* 노이즈 낀 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)을 분리하면 코드가 깔끔하다
과제
- THRESHOLD를 2/5로 바꿔 가며 글리치 거름이 어떻게 달라지는지 관찰
- 길게 누르면(long press) 별도 동작을 하는 상태를 FSM에 추가
- 두 버튼을 각각 디바운스해 독립적으로 FSM을 구동
22. 링버퍼와 인터럽트 기반 UART 수신
UART로 데이터가 쏟아지면 수신 인터럽트가 매 바이트마다 걸립니다. ISR에서 다 처리하려 들면 ISR이 길어지고, 메인이 느리면 다음 바이트가 이전 바이트를 덮어써 유실됩니다. 해법은 ISR은 버퍼에 넣기만 하고 처리는 한가한 메인이 맡는 분업입니다. 그 핵심 자료구조가 링버퍼(원형 버퍼)입니다. 생산자(ISR)와 소비자(메인)가 서로 다른 인덱스만 건드리면 인터럽트를 끄지 않고도 안전합니다.
소개
RX 인터럽트는 바이트마다 발생합니다. ISR이 길거나 메인이 제때 안 가져가면 다음 바이트가 수신 레지스터를 덮어써 사라집니다. "빨리 받아 어딘가 쌓아두고, 처리는 나중에"가 필요합니다.
핵심 개념
1) 링버퍼 구조와 빈/가득 판정
고정 배열을 원형으로 씁니다. head(쓰기)·tail(읽기)이 끝에 도달하면 0으로 감싸 돕니다(FIFO). 빈 상태는 head==tail, 가득 상태는 ((head+1)&MASK)==tail. 가득 판정을 위해 슬롯 하나를 비우므로 크기 N이면 용량은 N-1.
#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 인터럽트와 결합
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 인터럽트가 동작합니다. 처리는 메인이 합니다.
핵심 예제
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 후 처리
과제
- rb_count() 가 래핑 케이스에서도 올바른 개수를 주는지 확인
- 버퍼 크기를 16으로 늘리고 MASK를 맞게 수정
- 줄바꿈(\n)까지 모아 한 줄을 조립하는 line_assemble() 추가
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 주세요.
댓글은 회원만 쓸 수 있습니다.
광고·욕설·비방·개인정보·불법 내용이 담긴 댓글은 숨기거나 지웁니다. 댓글은 닉네임과 함께 공개됩니다. 개인정보처리방침