기술 가이드

MCU 버튼 디바운싱: 안정 시간과 상태 머신으로 중복 입력 막기

눌림·해제 바운스를 이벤트 하나로 바꾸는 C 예제입니다. 샘플링 공백·부팅 상태·중복 입력을 구분하고 4,096가지 가상 입력으로 검증합니다.

목차

버튼을 한 번 눌렀는데 메뉴가 두 칸 이동한다면 GPIO의 상승·하강 에지를 곧바로 사용자 입력으로 세고 있지 않은지 확인해야 합니다. 기계식 접점은 눌림과 해제 과정에서 여러 번 바뀔 수 있습니다. 이 글은 관측한 입력이 일정 시간 유지된 뒤 상태를 확정하고, 확정된 변화마다 이벤트 하나를 내는 MCU용 디바운서를 구현합니다.

대상은 GPIO와 주기 실행 함수를 사용할 수 있는 MCU 펌웨어 개발자입니다. 예제는 특정 보드에 종속되지 않는 C11 코드이며, PC에서 가상 입력으로 검증할 수 있습니다.

1. 원시 입력·후보·확정 상태를 나누기

풀업 저항과 스위치를 GND에 연결한 active-low 회로라면 핀 LOW를 pressed=true로 정규화합니다. 아래 알고리즘에 전기적 레벨을 그대로 전달하지 말고, 눌렸으면 true라는 한 가지 의미로 전달하세요. 핀 전압·풀업 설정·입력 임계값은 사용하는 MCU 데이터시트를 따릅니다.

상태는 세 가지입니다. 매번 읽는 원시 입력, 안정화 시간을 측정 중인 후보 입력, 애플리케이션에 이미 알린 확정 입력입니다. 원시 입력이 후보와 달라질 때마다 후보와 시작 시각을 갱신합니다. 같은 후보를 20ms 이상 관측한 뒤 확정 상태가 달라졌을 때만 눌림 또는 해제 이벤트를 만듭니다.

Arduino 공식 Debounce 예제도 입력 변화 때 시간을 다시 기록하는 흐름을 보여 줍니다. 여기서는 눌림과 해제를 모두 보고하고, 샘플링이 오래 멈추면 관측 시간을 다시 시작하도록 직접 작성했습니다.

2. 20ms는 실습값이며 관측 주기를 함께 정해야 한다

이 예제의 실행 주기는 1ms, 안정화 시간은 20ms, 허용하는 최대 샘플 간격은 5ms입니다. 스케줄러가 밀려 두 관측의 간격이 5ms를 초과하면 그 사이의 입력을 알 수 없으므로 후보의 시작 시각을 현재로 옮깁니다. 따라서 90ms 동안 멈췄다가 돌아온 루프가 즉시 눌림을 확정하지 않습니다.

이 방식은 연속된 아날로그 파형의 안정성을 증명하지 않습니다. 샘플 사이에서 발생하고 사라진 펄스는 보이지 않습니다. 펄스 폭, 접점 바운스, 요구 응답 시간에 맞춰 관측 주기와 필터 시간을 정하고 오실로스코프나 로직 애널라이저로 실제 보드를 측정해야 합니다.

Zephyr gpio-keys 공식 바인딩debounce-interval-ms를 제공하고 기본값을 30ms로 정의합니다. 라이브러리의 기본값과 제품에 필요한 값은 같다고 가정하지 마세요. 이 글의 20ms 및 최대 간격 규칙은 아래 실습의 설계값이며 Zephyr 드라이버를 재현한 것이 아닙니다.

3. 복사하여 실행할 수 있는 C 예제

아래 전체 코드를 debounce.c로 저장합니다. button_init()은 부팅 당시 값을 초기 상태로 채택하며 이벤트를 발생시키지 않습니다. 따라서 전원을 켤 때 이미 눌린 버튼은 새로운 눌림으로 보고하지 않습니다. 부팅 시 눌림도 동작해야 하는 제품은 별도의 시작 정책을 정의해야 합니다.

/* Original example: JDevCompany, 2026. CC0-1.0. */
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>

enum { QUIET_MS = 20, MAX_GAP_MS = 5 };
typedef struct {
bool stable, candidate;
uint32_t since, last;
} Button;

static Button button_init(bool pressed, uint32_t now)
{
return (Button){pressed, pressed, now, now};
}

/* Return +1: press, -1: release, 0: no event. */
static int button_sample(Button *b, bool pressed, uint32_t now)
{
uint32_t gap = (uint32_t)(now - b->last);
b->last = now;
if (gap > MAX_GAP_MS || pressed != b->candidate) {
b->candidate = pressed;
b->since = now;
return 0;
}
if (pressed != b->stable &&
(uint32_t)(now - b->since) >= QUIET_MS) {
b->stable = pressed;
return pressed ? 1 : -1;
}
return 0;
}

static bool bouncing_input(uint32_t t)
{
return t == 10 || t == 12 || t == 13 ||
(t >= 15 && t < 70) || t == 71;
}

static void check_trace(uint32_t origin)
{
Button b = button_init(false, origin);
int presses = 0, releases = 0;
for (uint32_t t = 1; t <= 120; ++t) {
int event = button_sample(&b, bouncing_input(t), origin + t);
if (event == 1) { assert(t == 35); ++presses; }
if (event == -1) { assert(t == 92); ++releases; }
}
assert(presses == 1 && releases == 1 && !b.stable);
}

int main(void)
{
check_trace(0);
check_trace(UINT32_MAX - 30);

Button b = button_init(false, 0);
for (uint32_t t = 1; t <= 10; ++t)
assert(button_sample(&b, true, t) == 0);
assert(button_sample(&b, true, 100) == 0);
for (uint32_t t = 101; t < 120; ++t)
assert(button_sample(&b, true, t) == 0);
assert(button_sample(&b, true, 120) == 1);
assert(button_sample(&b, true, 121) == 0);

b = button_init(true, 0);
for (uint32_t t = 1; t <= 30; ++t)
assert(button_sample(&b, true, t) == 0);
puts("PASS: bounce, release, wrap, gap, boot, no repeat");
return 0;
}

GCC가 준비된 환경에서 gcc -std=c11 -Wall -Wextra -Werror debounce.c -o debounce로 빌드하세요. Linux에서는 ./debounce, Windows에서는 gcc -std=c11 -Wall -Wextra -Werror debounce.c -o debounce.exe로 만든 뒤 PowerShell에서 .\debounce.exe를 실행합니다. assert 검사에 의존하므로 검증할 때 -DNDEBUG는 사용하지 않습니다.

정상 출력은 PASS: bounce, release, wrap, gap, boot, no repeat입니다. 정상 빌드·실행 종료 코드는 모두 0이어야 합니다.

4. 예제 파형을 읽는 법

가상 입력은 10ms부터 흔들리다가 15ms에 눌림 후보로 유지됩니다. 35ms에 한 번만 눌림을 보고하고, 계속 누르는 동안 추가 이벤트는 없습니다. 70ms부터 해제 바운스가 시작되고 72ms부터 해제 후보가 유지되어 92ms에 해제를 보고합니다. 확정 전 19ms와 20ms의 경계도 이 반복문에서 검사합니다.

같은 파형을 32비트 시각의 끝 근처에서도 실행합니다. 시각은 밀리초 단위 uint32_t이며 unsigned 차이로 경과 시간을 계산합니다. 관측 사이에 32비트 전체 주기인 약 49.7일이 지나지 않아야 합니다. 시계가 리셋되거나 저전력 복귀에서 시간 기준이 바뀌면 새 GPIO 값과 시각으로 재초기화하세요.

긴 관측 공백 테스트는 10ms 이후 100ms까지 샘플이 없을 때 100ms에 바로 확정하지 않고, 다시 20ms를 관측한 120ms에 확정하는지 검사합니다. 이는 태스크 지연을 디바운싱 시간으로 잘못 계산하는 상황을 드러냅니다.

5. 펌웨어에 연결하기

실제 보드에서는 초기 GPIO 설정 뒤 button_init(현재_눌림, 현재_ms)로 상태를 만들고, 1ms 주기 작업에서 button_sample()을 한 번 호출합니다. 반환값 1은 눌림, -1은 해제입니다. 메뉴 이동은 1일 때 수행하고, 길게 누르기나 자동 반복은 확정 상태와 별도 타이머로 설계합니다.

버튼마다 Button 구조체를 하나씩 둡니다. 구조체를 갱신하는 실행 문맥도 하나로 정하세요. GPIO ISR과 태스크가 동시에 같은 구조체를 변경하면 이 코드는 동기화를 제공하지 않습니다. ISR에서는 필요한 신호만 남기고 주기 태스크가 상태를 소유하도록 구성할 수 있습니다. 이벤트를 큐로 전달한다면 큐가 가득 찼을 때의 정책도 별도로 정해야 합니다.

바운스를 기다린다는 이유로 ISR 안에서 20ms 지연하거나 바쁜 루프를 돌리지 마세요. 통신 수신과 다른 인터럽트의 응답 시간을 늘릴 수 있습니다. RC 필터나 슈미트 입력 등 하드웨어 대책이 필요한 긴 배선·전기적 잡음 문제도 소프트웨어만으로 해결했다고 판단하면 안 됩니다.

6. 증상별 점검과 검증 범위

  • 입력이 전혀 없다: active-low 변환, 공통 GND, 풀업·풀다운 설정, 실제 주기 실행 여부를 확인합니다. 매번 5ms보다 늦게 호출하면 후보 시간이 계속 초기화됩니다.
  • 길게 누르면 반복 동작한다: 원시 GPIO나 확정 상태 자체를 매 루프 이벤트로 세는지 확인합니다. 예제의 반환값은 변화 순간에만 사용합니다.
  • 해제 때 두 번 동작한다: 눌림과 해제 모두 메뉴 이동에 연결했는지 확인합니다. 반환값이 1일 때만 눌림 동작을 수행하세요.
  • 빠른 클릭이 사라진다: 후보가 20ms 유지되지 않으면 걸러지는 것이 이 설계의 동작입니다. 실제 접점 파형과 최소 클릭 시간을 측정하고 값을 조정합니다.

2026-09-20에 Windows의 GCC로 이 예제를 빌드·실행했고, 별도 검사에서 12개 연속 입력의 모든 4,096가지 조합 뒤에 안정 구간을 붙여 잘못된 조기 이벤트와 중복 이벤트가 없는지 확인했습니다. 이는 가상 입력에 대한 상태 머신 검증입니다. 실제 GPIO 전압, 인터럽트 지연, RTOS 스케줄링, 특정 스위치의 바운스는 보드에서 추가 측정해야 합니다.

참고한 공식 문서는 Arduino Debounce와 위에 연결한 공개 예제, Zephyr 바인딩입니다. 본문의 C 예제는 JDevCompany가 이 실습을 위해 작성했으며 CC0-1.0으로 제공합니다.

관련 글

처리 중입니다...

잠시만 기다려주세요.