Devin.KR

GPIO 입력 - 버튼과 디바운싱

개발자KR 조회 5

이 장에서 배우는 것

앞 장에서는 GPIO를 출력으로 써서 LED를 켜고 껐다. 이번 장은 반대 방향이다. 외부 신호를 MCU가 읽어 들이는 GPIO 입력을 다룬다. 스마트 화분에 수동 급수 버튼을 하나 달아본다고 하자. 버튼 하나만 읽으면 되는 단순한 일처럼 보이지만, 실제로 연결해서 눌러 보면 펌프가 한 번 눌렀는데 여러 번 켜졌다 꺼졌다 하는 일이 벌어진다. 이 장은 그 원인과 해결책을 다룬다.

  • 입력 핀에 풀업(pull-up)·풀다운(pull-down) 저항이 왜 필요한지 설명할 수 있다
  • 버튼 접점이 채터링(chattering)을 일으키는 물리적 이유를 안다
  • 카운터 기반 소프트웨어 디바운싱(debouncing) 상태 기계를 직접 구현할 수 있다
  • PC 시뮬레이션 위에서 버튼 눌림을 재현해 디바운싱 결과를 눈으로 확인할 수 있다

문제 상황

스마트 화분에 "지금 바로 물 주기" 버튼을 추가했다고 하자. 버튼이 눌리면(핀이 0이 되면) 펌프 상태를 토글하는 코드를 짜고 실제 버튼을 눌러 본다. 로그를 켜 보면 손가락으로 한 번 눌렀을 뿐인데 PRESSED 이벤트가 서너 번 연달아 찍히고, 펌프는 켜졌다 꺼졌다를 반복한 끝에 예상과 다른 상태로 멈춘다.

오실로스코프로 버튼 핀 전압을 보면 원인이 보인다. 버튼을 누르는 순간과 떼는 순간, 전압이 한 번에 깔끔하게 바뀌지 않고 짧은 시간 동안 오르내림을 반복한다. MCU 입장에서는 그 흔들림 하나하나가 전부 "눌림"과 "뗌"으로 보인다. 이 흔들림을 걸러내는 일이 이번 장의 핵심이다.

풀업과 풀다운: 핀에 값이 없을 때

버튼은 누르면 두 단자를 연결하고, 놓으면 아무것도 연결하지 않는 단순한 스위치다. 놓인 상태에서 MCU 입력 핀에 아무 저항도 달려 있지 않으면 그 핀은 전위가 정해지지 않은 채 떠 있는다. 이런 상태를 플로팅(floating)이라 부르고, 주변 노이즈에 따라 읽는 값이 0과 1 사이를 무작위로 오간다.

해결책은 저항 하나로 평소 전위를 고정하는 것이다. 풀업 저항은 핀을 전원(VCC)에 묶어 평소 값을 1로 만들고, 버튼이 눌리면 접지(GND)로 연결되어 값이 0이 된다. 풀다운 저항은 그 반대로, 평소 값이 0이고 누르면 1이 된다. 요즘 MCU 대부분은 이 저항을 칩 내부에 내장하고 있어서, 외부 부품 없이 레지스터 설정만으로 켤 수 있다.

풀업은 평소 1, 누르면 0이 되고 풀다운은 그 반대로 동작한다

레지스터로 보면

이 장의 예제는 실제 보드 대신 PC에서 동작하는 hal_sim.h/.c로 레지스터를 흉내 낸다. gpio_port_t 구조체 안의 pupdr 필드가 풀업/풀다운 설정을 담는다. 핀마다 2비트를 써서 00은 없음, 01은 풀업, 10은 풀다운을 뜻하게 정했다. 실제 STM32 계열의 GPIOx_PUPDR 레지스터도 같은 방식으로 핀당 2비트를 쓴다. gpio_set_pull() 함수는 이 비트를 계산해 넣어 준다.

채터링과 디바운싱 상태 기계

버튼 안쪽은 금속 접점 두 개가 스프링 힘으로 붙었다 떨어졌다 하는 구조다. 손가락으로 누르는 순간 접점이 한 번에 딱 붙는 게 아니라, 미세하게 튕기면서 붙었다 떨어졌다를 수 밀리초(ms)에서 수십 밀리초 동안 반복한다. 이 현상이 채터링이다. 초당 수백만 번 명령을 처리하는 MCU 입장에서 수 ms는 결코 짧은 시간이 아니라서, 이 흔들림을 그대로 읽으면 눌림 하나가 여러 번의 눌림으로 보인다.

가장 손쉬운 대응은 엣지(edge)를 감지한 뒤 일정 시간 대기했다가 다시 확인하는 방식이다. 다만 이 방식은 대기하는 동안 CPU가 다른 일을 전혀 못 한다는 문제가 있다. 실제 펌웨어는 센서도 읽고 통신도 해야 하므로, 한 버튼 때문에 메인 루프를 멈추는 방식은 좋지 않다.

더 나은 방법은 매 tick(예: 1ms)마다 핀을 읽고, 카운터로 "같은 값이 몇 번 연속으로 나왔는지"를 세는 상태 기계다. 읽은 값이 바뀌면 카운터를 0으로 리셋하고, 같은 값이 나오면 카운터를 늘린다. 카운터가 임계값에 닿으면 그제야 그 값을 안정된 값으로 확정하고, 이전 안정 값과 다를 때만 눌림 또는 뗌 이벤트를 발생시킨다. 이 방식은 다른 작업을 막지 않으면서도 채터링을 걸러낸다.

채터링이 섞인 원신호도 5ms 연속 조건을 거치면 눌림과 뗌 각각 한 번의 전이로 걸러진다
디바운싱 방식 비교가 말하는 것
방식구현단점적합한 경우
블로킹 딜레이엣지 감지 후 일정 시간 대기하고 재확인대기하는 동안 다른 처리가 멈춘다버튼 하나만 다루는 아주 단순한 프로그램
카운터 상태 기계매 tick 샘플링, 연속 N회 일치 시 확정tick 주기와 임계값을 맞춰야 한다여러 입력을 동시에 다루는 실제 펌웨어

완성 코드

버튼 접점이 튀는 모습을 재현하기 위해 hal_sim에 "예약된 시각에 핀 레벨이 바뀐다"는 시나리오(bounce script)를 추가했다. 실제 버튼 대신 이 시나리오로 채터링을 만들고, 카운터 기반 상태 기계로 걸러낸다.

hal_sim.h

#ifndef HAL_SIM_H
#define HAL_SIM_H

#include <stdint.h>

typedef struct {
    uint32_t moder; /* 핀별 1비트: 0=입력, 1=출력 */
    uint32_t pupdr; /* 핀별 2비트: 00=없음, 01=풀업, 10=풀다운 */
    uint32_t idr;   /* 입력 데이터 레지스터 */
    uint32_t odr;   /* 출력 데이터 레지스터 */
} gpio_port_t;

typedef enum { GPIO_MODE_INPUT = 0, GPIO_MODE_OUTPUT = 1 } gpio_mode_t;
typedef enum { GPIO_PULL_NONE = 0, GPIO_PULL_UP = 1, GPIO_PULL_DOWN = 2 } gpio_pull_t;

#define GPIO_SIM_MAX_PINS 8u
#define GPIO_SIM_MAX_EVENTS 16u

typedef struct {
    uint32_t at_ms;
    int level;
} gpio_bounce_event_t;

extern gpio_port_t GPIOA;

void hal_sim_reset(void);
uint32_t hal_millis(void);
void hal_sim_set_millis(uint32_t ms);

void gpio_set_mode(gpio_port_t *port, unsigned pin, gpio_mode_t mode);
void gpio_set_pull(gpio_port_t *port, unsigned pin, gpio_pull_t pull);
void gpio_write(gpio_port_t *port, unsigned pin, int level);
int gpio_read(gpio_port_t *port, unsigned pin);

void hal_sim_load_bounce_script(gpio_port_t *port, unsigned pin,
                                 const gpio_bounce_event_t *events, unsigned count);

#endif

hal_sim.c

#include "hal_sim.h"
#include <string.h>

gpio_port_t GPIOA;

static uint32_t g_now_ms;
static gpio_bounce_event_t g_script[GPIO_SIM_MAX_PINS][GPIO_SIM_MAX_EVENTS];
static unsigned g_script_len[GPIO_SIM_MAX_PINS];

void hal_sim_reset(void) {
    memset(&GPIOA, 0, sizeof(GPIOA));
    memset(g_script, 0, sizeof(g_script));
    memset(g_script_len, 0, sizeof(g_script_len));
    g_now_ms = 0;
}

uint32_t hal_millis(void) { return g_now_ms; }
void hal_sim_set_millis(uint32_t ms) { g_now_ms = ms; }

void gpio_set_mode(gpio_port_t *port, unsigned pin, gpio_mode_t mode) {
    if (mode == GPIO_MODE_OUTPUT)
        port->moder |= (1u << pin);
    else
        port->moder &= ~(1u << pin);
}

void gpio_set_pull(gpio_port_t *port, unsigned pin, gpio_pull_t pull) {
    port->pupdr &= ~(0x3u << (pin * 2));
    port->pupdr |= ((uint32_t)pull << (pin * 2));
}

void gpio_write(gpio_port_t *port, unsigned pin, int level) {
    if (level)
        port->odr |= (1u << pin);
    else
        port->odr &= ~(1u << pin);
}

void hal_sim_load_bounce_script(gpio_port_t *port, unsigned pin,
                                 const gpio_bounce_event_t *events, unsigned count) {
    (void)port;
    if (pin >= GPIO_SIM_MAX_PINS || count > GPIO_SIM_MAX_EVENTS)
        return;
    memcpy(g_script[pin], events, count * sizeof(*events));
    g_script_len[pin] = count;
}

static int pull_default_level(gpio_port_t *port, unsigned pin) {
    uint32_t pull = (port->pupdr >> (pin * 2)) & 0x3u;
    if (pull == GPIO_PULL_UP)
        return 1;
    if (pull == GPIO_PULL_DOWN)
        return 0;
    return 0;
}

int gpio_read(gpio_port_t *port, unsigned pin) {
    if (port->moder & (1u << pin))
        return (int)((port->odr >> pin) & 0x1u);

    int level = pull_default_level(port, pin);
    unsigned n = g_script_len[pin];
    for (unsigned i = 0; i < n; i++) {
        if (g_script[pin][i].at_ms <= g_now_ms)
            level = g_script[pin][i].level;
        else
            break;
    }
    port->idr = (port->idr & ~(1u << pin)) | ((uint32_t)level << pin);
    return level;
}

main.c

#include <stdio.h>
#include "hal_sim.h"

#define BTN_PIN 0
#define PUMP_PIN 1
#define DEBOUNCE_MS 5u
#define SIM_END_MS 60u

enum { PRESSED = 0, RELEASED = 1 };

int main(void) {
    hal_sim_reset();

    gpio_set_mode(&GPIOA, BTN_PIN, GPIO_MODE_INPUT);
    gpio_set_pull(&GPIOA, BTN_PIN, GPIO_PULL_UP);
    gpio_set_mode(&GPIOA, PUMP_PIN, GPIO_MODE_OUTPUT);
    gpio_write(&GPIOA, PUMP_PIN, 0);

    gpio_bounce_event_t script[] = {
        {10, PRESSED}, {11, RELEASED}, {12, PRESSED}, {13, RELEASED}, {14, PRESSED},
        {40, RELEASED}, {41, PRESSED}, {42, RELEASED}, {43, PRESSED}, {44, RELEASED},
    };
    hal_sim_load_bounce_script(&GPIOA, BTN_PIN, script,
                                sizeof(script) / sizeof(script[0]));

    int raw_last = gpio_read(&GPIOA, BTN_PIN);
    int stable = raw_last;
    unsigned counter = DEBOUNCE_MS;
    int pump_on = 0;

    for (uint32_t t = 0; t <= SIM_END_MS; t++) {
        hal_sim_set_millis(t);
        int raw = gpio_read(&GPIOA, BTN_PIN);

        if (raw != raw_last) {
            raw_last = raw;
            counter = 0;
            printf("[raw   ] t=%ums pin=%d (튐)\n", (unsigned)t, raw);
        } else if (counter < DEBOUNCE_MS) {
            counter++;
        }

        if (counter == DEBOUNCE_MS && stable != raw_last) {
            stable = raw_last;
            if (stable == PRESSED) {
                pump_on = !pump_on;
                gpio_write(&GPIOA, PUMP_PIN, pump_on);
                printf("[stable] t=%ums PRESSED  -> pump %s\n",
                       (unsigned)t, pump_on ? "ON" : "OFF");
            } else {
                printf("[stable] t=%ums RELEASED\n", (unsigned)t);
            }
        }
    }

    return 0;
}

줄별 해설

hal_sim.h의 gpio_port_t는 실제 MCU의 메모리 맵 레지스터를 흉내 낸 구조체다. moder는 핀의 입출력 방향, pupdr는 풀업/풀다운 설정, idr와 odr는 각각 입력·출력 데이터를 담는다. gpio_bounce_event_t는 "몇 ms에 어떤 레벨이 되는지"를 기록하는 예약 이벤트 하나로, 실제 버튼의 채터링을 대신한다.

hal_sim.c의 gpio_set_pull()은 pupdr의 해당 핀 2비트를 지우고 새 값을 써 넣는다. gpio_read()는 핀이 출력 모드면 odr을 그대로 되읽고, 입력 모드면 풀 저항이 만드는 기본값에서 시작해 현재 시각(g_now_ms)까지 지난 이벤트들을 순서대로 적용해 실제 전압을 계산한다. hal_sim_load_bounce_script()는 배열 크기(GPIO_SIM_MAX_EVENTS)를 넘는 이벤트가 들어오면 아무 것도 하지 않고 조용히 반환하는데, 이는 테스트용 코드이기 때문에 허용한 단순화이며 실제 제품 코드라면 이렇게 조용히 넘어가서는 안 된다.

main.c는 버튼 핀(0번)을 풀업 입력으로, 펌프 핀(1번)을 출력으로 설정한다. script 배열은 누르는 순간(10~14ms)과 떼는 순간(40~44ms) 각각 5번씩 레벨이 튀는 상황을 만든다. 루프는 1ms씩 시간을 흘려보내며(hal_sim_set_millis) 매번 핀을 읽는다. raw != raw_last면 값이 바뀐 것이므로 카운터를 0으로 리셋하고, 같지 않을 때만 카운터를 늘린다. 카운터가 DEBOUNCE_MS에 닿고 그 값이 이전 안정 상태(stable)와 다를 때만 진짜 눌림/뗌으로 인정한다. 펌프는 눌림 엣지에서만 토글하고, 뗌 엣지는 로그만 남긴다.

실행 결과

$ cc -std=c11 -Wall -Wextra -o gpio_input hal_sim.c main.c
$ ./gpio_input
[raw   ] t=10ms pin=0 (튐)
[raw   ] t=11ms pin=1 (튐)
[raw   ] t=12ms pin=0 (튐)
[raw   ] t=13ms pin=1 (튐)
[raw   ] t=14ms pin=0 (튐)
[stable] t=19ms PRESSED  -> pump ON
[raw   ] t=40ms pin=1 (튐)
[raw   ] t=41ms pin=0 (튐)
[raw   ] t=42ms pin=1 (튐)
[raw   ] t=43ms pin=0 (튐)
[raw   ] t=44ms pin=1 (튐)
[stable] t=49ms RELEASED

원신호는 눌림·뗌 각각 5번씩, 총 10번 튀지만 디바운싱을 거친 뒤에는 눌림 한 번(19ms)과 뗌 한 번(49ms)만 남는다. 펌프는 정확히 한 번만 켜졌다.

실무에서 자주 틀리는 것

입력 핀에 풀 저항을 설정하지 않는다

gpio_set_mode(&GPIOA, BTN_PIN, GPIO_MODE_INPUT);
/* 풀업/풀다운 설정 없음 */
int pressed = (gpio_read(&GPIOA, BTN_PIN) == 0);

버튼을 누르지 않은 동안 핀이 플로팅 상태가 되어 노이즈에 따라 값이 흔들린다. 시뮬레이터는 기본값을 고정해 두어 문제가 드러나지 않지만, 실제 보드에서는 펌프가 제멋대로 켜진다.

gpio_set_mode(&GPIOA, BTN_PIN, GPIO_MODE_INPUT);
gpio_set_pull(&GPIOA, BTN_PIN, GPIO_PULL_UP);
int pressed = (gpio_read(&GPIOA, BTN_PIN) == 0);

딜레이로 디바운싱을 흉내내다 메인 루프를 멈춘다

if (gpio_read(&GPIOA, BTN_PIN) == 0) {
    hal_delay_ms(20); /* 이 20ms 동안 다른 일을 전혀 못 한다 */
    if (gpio_read(&GPIOA, BTN_PIN) == 0)
        toggle_pump();
}

버튼 하나 때문에 센서 읽기, 통신 처리가 20ms씩 멈춘다. 버튼이 여러 개면 이 지연이 그대로 쌓인다.

/* 매 tick마다 호출, 블로킹 없이 카운터만 갱신한다 */
button_update(gpio_read(&GPIOA, BTN_PIN));

디바운스 임계값을 실측 없이 정한다

#define DEBOUNCE_MS 1u   /* 채터링 구간보다 짧아 그대로 통과한다 */
#define DEBOUNCE_MS 200u /* 반응이 눈에 띄게 느려진다 */

임계값이 너무 짧으면 채터링이 안정 값으로 오판되고, 너무 길면 버튼 반응이 굼떠진다. 오실로스코프나 로직 애널라이저로 실제 버튼의 채터링 길이를 재고(보통 5~20ms), 그보다 여유 있는 값을 고르는 것이 안전하다.

눌림과 뗌을 구분하지 않고 매번 반응한다

if (stable != last_stable) {
    toggle_pump(); /* 눌림에서도, 뗌에서도 토글된다 */
    last_stable = stable;
}

버튼을 한 번 누르고 뗄 때마다 펌프가 두 번 토글되어 결국 원래 상태로 돌아간다.

if (stable != last_stable) {
    last_stable = stable;
    if (stable == PRESSED)
        toggle_pump(); /* 눌림 엣지에서만 반응한다 */
}

한눈에 보기

이 장에서 다룬 개념 정리
용어의미이 장 코드에서
풀업/풀다운입력 핀의 기본 전위를 고정하는 저항gpio_set_pull(&GPIOA, BTN_PIN, GPIO_PULL_UP)
채터링접점이 붙거나 떨어질 때 짧게 여러 번 튀는 현상hal_sim_load_bounce_script로 재현한 구간
디바운싱튀는 신호에서 안정된 값만 골라내는 처리raw_last/counter/stable 세 변수의 상태 기계
엣지안정된 값이 이전과 달라지는 순간PRESSED/RELEASED 판정 후 pump_on 토글
PC 시뮬레이션과 실제 보드의 차이
항목PC 시뮬레이션(hal_sim)실제 보드
입력 신호bounce script로 예약된 값실제 접점 전압, 기종마다 튀는 길이가 다르다
풀 저항 설정pupdr 필드 계산STM32는 GPIOx_PUPDR, ESP32는 gpio_pullup_en(), 아두이노는 pinMode(pin, INPUT_PULLUP)
샘플링 주기for 루프에서 t를 1씩 증가타이머 인터럽트나 SysTick으로 1ms마다 실행
임계값 결정코드에 5ms로 고정, 바로 재실행해 비교 가능버튼 기구 설계에 따라 실측 후 5~50ms 사이에서 결정

연습 문제

  1. DEBOUNCE_MS를 2로 바꾸면 출력이 어떻게 달라질지 예상한 뒤, 코드를 고쳐 실제로 확인하라.
  2. 버튼 배선이 풀다운으로 바뀌어 누르면 1, 놓으면 0이 된다고 하자. main.c에서 고쳐야 할 부분을 설명하라.
  3. 지금 코드는 RELEASED 이벤트에서 아무 동작도 하지 않는다. 눌림 시작(19ms)부터 뗌(49ms)까지의 시간을 계산해, 500ms 이상 눌려 있으면 "장시간 눌림" 로그를 남기도록 상태 기계를 확장하는 방법을 설명하라.
  4. hal_sim_load_bounce_script에 GPIO_SIM_MAX_EVENTS(16개)보다 많은 이벤트를 넣으면 어떤 일이 일어나는지 코드를 읽고 설명하고, 이 동작이 실무 코드에서도 괜찮은지 평가하라.

정답과 해설

1. 임계값을 2로 줄이면 카운터가 더 빨리 채워진다. press 구간은 14ms에 레벨이 0으로 정착하므로 15ms에 카운터 1, 16ms에 카운터 2가 되어 16ms에 PRESSED가 확정된다(원래는 19ms). release 구간도 같은 방식으로 46ms에 RELEASED가 확정된다(원래는 49ms). 반응은 빨라지지만, 실제 버튼의 채터링이 2ms보다 오래 지속되는 기종이라면 튀는 도중에 오판할 위험이 커진다.

2. gpio_set_pull을 GPIO_PULL_DOWN으로 바꾸고, enum의 PRESSED/RELEASED 값을 서로 바꿔야 한다(눌림이 1, 뗌이 0). script 배열의 레벨 값도 이 새 의미에 맞게 뒤집어야 하며, stable == PRESSED 비교식은 그대로 두어도 되는데 이는 PRESSED 상수 자체가 바뀌었기 때문이다.

3. PRESSED가 확정되는 시점의 t 값을 press_start_ms에 저장해 두고, RELEASED가 확정되는 시점에 t - press_start_ms를 계산한다. 그 값이 500 이상이면 별도로 "장시간 눌림" 메시지를 출력한다. 다만 지금 예제는 SIM_END_MS가 60이라 실제로 500ms를 채우는 시나리오는 아니므로, 이 확장을 시험하려면 script와 SIM_END_MS를 함께 늘려야 한다.

4. hal_sim_load_bounce_script는 count > GPIO_SIM_MAX_EVENTS일 때 아무 안내도 없이 그냥 반환한다. 즉 이벤트가 조용히 무시된다. 테스트 픽스처이므로 이 장에서는 크게 문제 될 게 없지만, 실제 제품 코드에서 이런 식으로 조용히 넘어가면 원인을 찾기 어려운 버그로 이어진다. 실무 코드라면 배열 크기를 넘는 순간 어서션으로 즉시 알리거나, 최소한 오류를 반환값으로 돌려주는 편이 안전하다.

댓글 0

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

댓글을 남기려면 로그인이 필요합니다.