- Published on
풀업, 풀다운, 그리고 플로팅 핀 — 버튼 하나가 이렇게 어려운 이유
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — 아무것도 연결하지 않은 핀이 계속 값을 바꿉니다
- 플로팅 입력의 정체 — 핀은 작은 안테나입니다
- 풀업과 풀다운 저항이 하는 일
- 내부 풀업은 왜 있고 값은 얼마인가
- 액티브 로우가 관례인 이유
- 외부 저항값 고르기 — 노이즈 내성과 소비 전류의 트레이드오프
- 오픈 드레인 출력
- 스위치 바운스와 디바운싱
- 마치며 — 입력 핀의 기본값을 정하는 것이 회로의 절반입니다
들어가며 — 아무것도 연결하지 않은 핀이 계속 값을 바꿉니다
아두이노에서 D2를 입력으로 설정하고, 아무것도 연결하지 않은 채 1초에 열 번씩 값을 읽어 시리얼로 출력해 봅니다.
1 1 1 0 1 1 0 0 0 1 0 1 1 1 0 1 0 0 1 1
값이 계속 바뀝니다. 손을 핀 근처에 가져가면 패턴이 달라지고, 형광등을 켜면 또 달라집니다. 코드에는 아무 잘못이 없습니다.
소프트웨어 관점에서 이 상황은 이해하기 어렵습니다. 읽지 않은 변수는 초기값을 가지거나 최소한 안정적으로 쓰레기 값을 가집니다. 그런데 이 핀은 읽을 때마다 다른 값을 줍니다.
답은 간단합니다. 그 핀의 전압이 정해져 있지 않기 때문입니다. 디지털 입력은 전압을 읽어 임계값과 비교하는 회로인데, 비교할 전압 자체가 없으면 결과도 없습니다. 그리고 버튼을 연결하는 순간 이 문제가 회로 설계의 핵심으로 올라옵니다.
플로팅 입력의 정체 — 핀은 작은 안테나입니다
디지털 입력 핀의 내부는 아주 임피던스가 높은 비교기입니다. 얼마나 높은가 하면, 데이터시트에 적힌 입력 누설 전류가 최대 1마이크로암페어 수준입니다. 3.3V에서 이 값을 저항으로 환산하면,
3.3V / 0.000001A = 3300000옴 = 3.3메가옴
3메가옴이 넘습니다. 즉 이 핀은 사실상 아무 전류도 흘리지 않는 관찰자입니다.
문제는 여기서 시작됩니다. 전류가 거의 흐르지 않는 노드의 전압은 아주 작은 전하로도 크게 흔들립니다. 핀과 배선, 브레드보드가 만드는 정전 용량은 대략 20에서 30피코패럿입니다. 전압과 전하의 관계는,
전압 변화 = 전하 / 정전 용량
25피코패럿에 전하 25피코쿨롱만 실려도 전압이 1V 바뀝니다. 25피코쿨롱은 손가락을 가까이 가져갈 때 생기는 정전 결합만으로도 충분히 만들어지는 양입니다.
그래서 아무 데도 연결되지 않은 핀은 안테나가 됩니다. 60Hz 전원선의 유도, 근처 전선의 신호 전이, 형광등 안정기, 스위칭 어댑터의 노이즈가 전부 이 핀의 전압을 흔듭니다. 그리고 그 전압이 임계값 근처를 오가면 읽을 때마다 다른 값이 나옵니다.
임계값이 정확히 어디인지도 알아 둘 필요가 있습니다. CMOS 입력에는 두 개의 문턱이 있습니다.
| 보드 | 로직 전압 | HIGH로 인정되는 최소 전압 | LOW로 인정되는 최대 전압 | 불확정 구간 |
|---|---|---|---|---|
| 아두이노 우노 (ATmega328P) | 5V | 3.0V | 1.5V | 1.5 ~ 3.0V |
| 라즈베리파이 (BCM) | 3.3V | 2.31V | 0.99V | 0.99 ~ 2.31V |
| ESP32 | 3.3V | 2.475V | 0.825V | 0.825 ~ 2.475V |
가운데 구간이 문제입니다. 핀 전압이 1.5V에서 3.0V 사이에 있으면 아두이노는 그 값을 HIGH로 볼지 LOW로 볼지 정하지 않습니다. 데이터시트가 보장하지 않는 영역입니다. 플로팅 핀은 대부분의 시간을 이 구간에서 보냅니다.
그리고 이 상태에는 눈에 안 보이는 비용이 하나 더 있습니다. 입력 핀 내부의 첫 단은 상보형 트랜지스터 쌍인데, 입력이 중간 전압에 머물면 위아래 트랜지스터가 동시에 조금씩 켜집니다. 전원에서 GND로 관통 전류가 흐릅니다. 핀 하나당 수백 마이크로암페어 수준이라 눈에 띄지는 않지만, 배터리로 동작하는 장치에서는 쓰지 않는 핀을 전부 풀업으로 묶어 두는 이유가 됩니다.
풀업과 풀다운 저항이 하는 일
해결책은 명확합니다. 아무 일도 일어나지 않을 때 핀이 어떤 전압이어야 하는지 정해 주면 됩니다. 그 일을 하는 부품이 풀업 또는 풀다운 저항입니다.
풀업은 핀과 전원 사이에 저항을 넣는 것입니다. 아무것도 핀을 건드리지 않으면 전원에서 저항을 통해 핀으로 전류가 흘러 핀이 전원 전압까지 올라갑니다. 정확히는 핀의 누설 전류가 거의 0이므로 저항 양단 전압도 거의 0이 되고, 핀 전압은 전원과 사실상 같아집니다. 10킬로옴 풀업에 1마이크로암페어의 누설이 있다면 저항에서 떨어지는 전압은,
0.000001A × 10000옴 = 0.00001V = 0.01mV
무시할 수 있는 값입니다. 그래서 핀은 안정적으로 HIGH를 읽습니다.
풀다운은 반대로 핀과 GND 사이에 저항을 넣습니다. 기본값이 LOW가 됩니다.
여기서 왜 저항을 쓰는지, 왜 그냥 전선으로 전원에 직접 연결하지 않는지가 중요한 질문입니다. 전선으로 직접 연결하면 그 핀은 영원히 HIGH이고 버튼이 아무 영향을 주지 못합니다. 아니, 더 나쁩니다. 버튼을 눌러 그 핀을 GND에 연결하는 순간 전원과 GND가 저항 없이 직결됩니다. 단락입니다.
저항이 하는 일은 "약하게 당기는 것"입니다. 아무도 반대하지 않으면 이기고, 누군가 강하게 반대하면 집니다. 10킬로옴 풀업이 있는 핀을 버튼이 GND에 연결하면 버튼의 저항은 거의 0이므로 핀은 GND 쪽으로 끌려갑니다. 이때 흐르는 전류는,
3.3V / 10000옴 = 0.00033A = 0.33mA
0.33mA만 전원에서 GND로 흐릅니다. 이 전류가 풀업의 비용이고, 저항값을 정할 때 계산하는 것이 바로 이 값입니다.
풀업과 풀다운은 논리적으로 대칭이지만 실무에서는 풀업이 압도적으로 많이 쓰입니다. 이유는 다음 두 절에 걸쳐 나옵니다.
내부 풀업은 왜 있고 값은 얼마인가
거의 모든 마이크로컨트롤러는 칩 안에 풀업 저항을 내장하고 있고, 소프트웨어로 켜고 끌 수 있습니다. 부품 하나를 아낄 수 있으므로 편리합니다.
값을 알아 두는 것이 중요합니다. 내부 풀업은 정밀 저항이 아니라 반도체 공정으로 만든 저항이라 편차가 큽니다.
| 보드 / 칩 | 내부 풀업 저항 | 내부 풀다운 | 활성화 방법 |
|---|---|---|---|
| 아두이노 우노 (ATmega328P) | 20 ~ 50킬로옴 | 없음 | pinMode(pin, INPUT_PULLUP) |
| 라즈베리파이 (BCM2835 계열) | 약 50 ~ 65킬로옴 | 약 50 ~ 65킬로옴 | gpiozero의 pull_up 인자 |
| ESP32 | 약 45킬로옴 | 약 45킬로옴 | pinMode(pin, INPUT_PULLUP) |
| 라즈베리파이 GPIO2 / GPIO3 | 1.8킬로옴 (보드에 고정 실장) | 사용 불가 | 제거 불가 |
세 가지를 짚고 넘어가겠습니다.
첫째, 아두이노의 ATmega328P에는 내부 풀다운이 없습니다. 풀다운이 필요하면 반드시 외부 저항을 달아야 합니다. 이 비대칭이 액티브 로우 관례의 기술적 뿌리 중 하나입니다.
둘째, 값의 편차가 큽니다. 20에서 50킬로옴이면 두 배 반 차이입니다. 정확한 전류나 시상수가 필요한 회로에서는 내부 풀업에 의존할 수 없습니다.
셋째, 마지막 줄이 실무에서 자주 사고를 냅니다. 라즈베리파이의 GPIO2와 GPIO3에는 보드 위에 1.8킬로옴 풀업이 물리적으로 붙어 있습니다. I2C 버스를 위한 것이고 제거할 수 없습니다. 이 핀을 일반 입력으로 쓰면 항상 강하게 HIGH로 당겨져 있어서 풀다운을 붙여도 이길 수 없습니다. 예를 들어 10킬로옴 풀다운을 달면 분압이 되어,
핀 전압 = 3.3V × 10000 / (1800 + 10000) = 3.3V × 0.847 = 2.80V
2.80V입니다. LOW로 인정되는 상한 0.99V와는 거리가 멉니다. 여전히 HIGH로 읽힙니다. GPIO2와 GPIO3은 I2C 전용으로 남겨 두는 것이 맞습니다.
액티브 로우가 관례인 이유
버튼 배선에는 두 가지 방식이 있습니다.
액티브 하이는 풀다운 저항을 쓰고, 버튼이 핀과 전원 사이에 있습니다. 누르면 HIGH가 됩니다. 코드에서 digitalRead(pin) == HIGH가 "눌림"이 되므로 직관적입니다.
액티브 로우는 풀업 저항을 쓰고, 버튼이 핀과 GND 사이에 있습니다. 누르면 LOW가 됩니다. 코드에서는 digitalRead(pin) == LOW가 "눌림"이라 처음에는 거꾸로 느껴집니다.
그런데 실제 회로도와 모듈은 거의 전부 액티브 로우입니다. 이유가 여러 겹으로 있습니다.
첫째, 앞에서 본 것처럼 내부 풀업은 흔하고 내부 풀다운은 없거나 드뭅니다. 액티브 로우를 쓰면 외부 저항이 아예 필요 없습니다.
둘째, 배선이 안전합니다. 버튼의 한쪽 다리가 GND입니다. 브레드보드에서 GND 레일은 길게 뻗어 있어 어디서든 잡기 쉽고, 실수로 다른 곳에 닿아도 GND라 사고가 나지 않습니다. 반대로 전원선이 여기저기 뻗어 있으면 실수로 GND에 닿는 순간 단락입니다.
셋째, 이것이 역사적으로 결정적이었는데, 트랜지스터가 전류를 빨아들이는 쪽이 밀어내는 쪽보다 강했습니다. 초창기 TTL 로직에서 LOW로 끌어내리는 능력은 16mA였지만 HIGH로 밀어 올리는 능력은 0.4mA였습니다. 40배 차이입니다. 그래서 의미 있는 신호를 LOW에 할당하는 것이 자연스러웠습니다. 요즘 CMOS는 양방향이 대칭에 가깝지만 관례는 남았습니다.
넷째, 고장 모드가 안전합니다. 배선이 끊어지면 어떻게 될지 생각해 보십시오. 액티브 로우에서 선이 빠지면 풀업이 이겨서 HIGH, 즉 "안 눌림"이 됩니다. 액티브 하이에서 선이 빠지면 플로팅이 되어 무작위로 눌린 것처럼 읽힐 수 있습니다. 끊어졌을 때 안전한 쪽으로 떨어지는 설계가 좋은 설계입니다.
그래서 아두이노에서 버튼 하나를 붙이는 정답은 이렇게 짧습니다.
const int BUTTON_PIN = 2;
void setup() {
Serial.begin(115200);
// 내부 풀업을 켭니다. 외부 저항이 필요 없습니다.
// 버튼은 D2와 GND 사이에만 연결하면 됩니다.
pinMode(BUTTON_PIN, INPUT_PULLUP);
}
void loop() {
// 풀업이므로 평소에는 HIGH, 눌리면 LOW입니다.
bool pressed = (digitalRead(BUTTON_PIN) == LOW);
Serial.println(pressed ? "pressed" : "released");
delay(100);
}
라즈베리파이에서 gpiozero를 쓰면 이 관례가 아예 기본값으로 들어 있습니다.
from gpiozero import Button
from signal import pause
# pull_up=True가 기본값입니다. 버튼은 GPIO17과 GND 사이에 연결합니다.
# 라이브러리가 내부 풀업을 켜고, is_pressed는 LOW일 때 True가 됩니다.
# 즉 액티브 로우의 반전을 라이브러리가 대신 처리해 줍니다.
button = Button(17, pull_up=True, bounce_time=0.05)
button.when_pressed = lambda: print("pressed")
button.when_released = lambda: print("released")
pause()
bounce_time은 뒤에서 다룰 디바운싱을 라이브러리 수준에서 처리하는 옵션입니다.
외부 저항값 고르기 — 노이즈 내성과 소비 전류의 트레이드오프
내부 풀업으로 충분하지 않은 경우가 있습니다. 선이 길거나, 노이즈가 많은 환경이거나, 값이 정확해야 하거나, 여러 장치가 한 선을 공유할 때입니다. 이때 외부 저항값을 직접 골라야 합니다.
이 선택은 두 요구가 정면으로 충돌합니다.
저항이 작을수록 좋은 이유는 노이즈에 강해지기 때문입니다. 노이즈가 핀 전압을 흔들려면 핀의 정전 용량을 충전해야 하는데, 풀업 저항이 작으면 그 충전을 빠르게 되돌립니다. 시상수로 계산하면 이렇습니다. 핀과 배선의 정전 용량을 25피코패럿으로 잡으면,
1킬로옴 → 1000 × 0.000000000025 = 25나노초
10킬로옴 → 250나노초
100킬로옴 → 2.5마이크로초
100킬로옴 풀업이 걸린 핀은 노이즈로 흔들린 전압을 되돌리는 데 2.5마이크로초가 걸립니다. 그동안 값이 잘못 읽힐 수 있습니다.
저항이 클수록 좋은 이유는 전류를 덜 쓰기 때문입니다. 버튼을 누르고 있는 동안 풀업 저항을 통해 전원에서 GND로 계속 전류가 흐릅니다.
1킬로옴 → 3.3V / 1000 = 3.3mA
10킬로옴 → 0.33mA
100킬로옴 → 0.033mA = 33마이크로암페어
버튼 하나라면 3.3mA도 큰 문제가 아닙니다. 하지만 스위치 배열이 열여섯 개고 절반이 눌려 있다면 26mA가 되고, 배터리 장치라면 이 값이 수명을 좌우합니다.
정리하면 이런 기준이 됩니다.
| 저항값 | 눌렀을 때 소비 전류 (3.3V) | 노이즈 복원 시간 | 적합한 상황 |
|---|---|---|---|
| 1킬로옴 | 3.3mA | 25나노초 | 긴 배선, 모터 근처, 산업 환경 |
| 4.7킬로옴 | 0.70mA | 118나노초 | I2C 버스 표준값 |
| 10킬로옴 | 0.33mA | 250나노초 | 일반적인 기본 선택 |
| 47킬로옴 | 0.07mA | 1.2마이크로초 | 저전력 장치, 짧은 배선 |
| 100킬로옴 이상 | 0.03mA | 2.5마이크로초 | 권장하지 않음 |
특별한 이유가 없으면 10킬로옴이 좋은 출발점입니다. 그리고 "버튼이 가끔 저절로 눌린다"는 증상이 나오면 값을 낮추는 것이 첫 번째 대응입니다. 배선이 30센티미터를 넘어가면 4.7킬로옴이나 1킬로옴으로 내리는 편이 안전합니다.
버튼이나 스위치를 회로에 넣고 풀 저항을 빠뜨리지 않았는지 확인하고 싶다면 배선 검증 도구에 부품을 올려 보면 됩니다. 푸시 버튼처럼 그냥 두면 플로팅이 되는 부품에는 풀업이나 풀다운이 필요하다는 안내가 따로 표시되고, 라즈베리파이의 GPIO2와 GPIO3처럼 보드에 고정 풀업이 실장된 핀을 골랐을 때도 경고가 나옵니다.
오픈 드레인 출력
풀업을 이해하고 나면 오픈 드레인이라는 출력 방식이 자연스럽게 따라옵니다. 실무에서 I2C를 다루거나 인터럽트 선을 공유할 때 반드시 만나게 됩니다.
보통의 디지털 출력은 푸시풀입니다. HIGH를 쓰면 위쪽 트랜지스터가 켜져 핀을 전원에 연결하고, LOW를 쓰면 아래쪽 트랜지스터가 켜져 GND에 연결합니다. 두 방향 모두 능동적으로 밀어붙입니다.
여기에는 제약이 있습니다. 두 장치가 한 선에 푸시풀로 붙어 있는데 하나가 HIGH를, 다른 하나가 LOW를 쓰면 전원과 GND가 두 트랜지스터를 통해 직결됩니다. 5V에 트랜지스터 두 개의 온 저항이 각각 25옴이라고 하면,
5V / (25 + 25) = 0.1A = 100mA
100mA가 두 칩을 관통합니다. 둘 중 하나 또는 둘 다 손상됩니다.
오픈 드레인은 위쪽 트랜지스터를 아예 없앤 방식입니다. 출력은 LOW로 끌어내리거나, 아니면 아무것도 하지 않고 놓아 버립니다. HIGH를 만드는 일은 외부 풀업 저항이 담당합니다.
이렇게 하면 여러 장치가 한 선을 안전하게 공유할 수 있습니다. 아무도 끌어내리지 않으면 풀업이 이겨서 HIGH이고, 한 장치라도 끌어내리면 LOW입니다. 논리적으로 AND 게이트가 배선만으로 만들어집니다. 이 성질 때문에 오픈 드레인 버스에서는 "한 명이라도 LOW를 주장하면 LOW"라는 규칙이 성립하고, I2C의 슬레이브가 준비될 때까지 클록을 붙잡아 두는 클록 스트레칭 같은 기법이 가능해집니다.
대가는 속도입니다. LOW로 가는 것은 트랜지스터가 빠르게 끌어내리지만 HIGH로 가는 것은 풀업 저항이 정전 용량을 충전하는 속도에 달려 있습니다. 4.7킬로옴에 100피코패럿이면,
시상수 = 4700 × 0.0000000001 = 0.00000047초 = 470나노초
상승 시간은 시상수의 두 배가 조금 넘으므로 약 1마이크로초입니다. I2C 표준 모드의 최대 상승 시간 규격이 1마이크로초인데, 딱 그 언저리입니다. 장치를 더 붙여 정전 용량이 늘면 규격을 넘게 됩니다. 이것이 I2C 버스에 장치를 무한정 붙일 수 없는 물리적 이유입니다.
아두이노에서 오픈 드레인 출력을 흉내 내는 방법도 알아 두면 좋습니다. 핀 모드를 바꾸는 것입니다.
const int SHARED_LINE = 4;
void setup() {
// 놓아 버린 상태. 외부 풀업이 HIGH로 올려 줍니다.
pinMode(SHARED_LINE, INPUT);
}
void assertLow() {
// 능동적으로 끌어내립니다.
pinMode(SHARED_LINE, OUTPUT);
digitalWrite(SHARED_LINE, LOW);
}
void release() {
// 다시 고임피던스로 돌아갑니다. HIGH를 쓰지 않는 것이 핵심입니다.
pinMode(SHARED_LINE, INPUT);
}
digitalWrite(pin, HIGH)를 절대 쓰지 않는 것이 요점입니다. HIGH를 만드는 일은 풀업에게 맡기고, 이쪽은 끌어내리거나 손을 떼는 두 상태만 가집니다.
스위치 바운스와 디바운싱
배선을 제대로 하고 풀업까지 켰는데 버튼을 한 번 눌렀는데 카운터가 3 늘어납니다. 이제 마지막 문제입니다.
스코프에 실제로 찍히는 파형
기계식 스위치의 접점은 금속 조각입니다. 누르면 두 금속이 부딪히는데, 금속은 탄성이 있어서 튑니다. 붙었다가 아주 짧게 떨어지고 다시 붙기를 몇 번 반복한 뒤에야 안정됩니다. 오실로스코프로 택트 스위치를 보면 대략 이런 파형이 나옵니다.
┌──────────────────
─────┐ ┌──┐ ┌─┐ ┌┐ │
│ │ │ │ │ ││ │
└───┘ └──┘ └─┘└──────────────┘
|<------ 약 1 ~ 5ms ------>|
누른 순간 안정된 시점
접점 종류별로 대략 이 정도입니다.
| 스위치 종류 | 전형적인 바운스 시간 | 최악의 경우 |
|---|---|---|
| 소형 택트 스위치 | 1 ~ 5ms | 10ms |
| 토글 스위치 | 5 ~ 10ms | 20ms |
| 마이크로 스위치 (리밋) | 1 ~ 3ms | 8ms |
| 릴레이 접점 | 5 ~ 20ms | 50ms |
| 로터리 엔코더 | 1 ~ 5ms (양쪽 채널) | 10ms |
이제 왜 카운터가 3 늘어나는지 명확합니다. 아두이노의 loop는 단순한 코드라면 마이크로초 단위로 돌기 때문에, 5밀리초 동안 튀는 접점을 수백 번 관찰합니다. 그 사이의 모든 전이가 진짜 입력으로 보입니다.
여기서 중요한 사실은 이것이 결함이 아니라 기계식 접점의 정상 동작이라는 점입니다. 더 좋은 스위치를 사도 없어지지 않습니다. 회로나 코드에서 처리해야 합니다.
RC 회로로 파형을 뭉개기
가장 직관적인 하드웨어 해법은 저항과 커패시터로 저역 통과 필터를 만드는 것입니다. 핀과 GND 사이에 커패시터를 달면, 접점이 튀는 짧은 시간 동안 커패시터가 전압 변화를 흡수합니다.
10킬로옴 풀업에 1마이크로패럿 커패시터를 달았다고 하겠습니다.
시상수 = 10000옴 × 0.000001F = 0.01초 = 10ms
버튼을 놓은 뒤 핀 전압이 HIGH 임계값인 2.31V까지 올라가는 데 걸리는 시간은 시상수의 약 1.2배입니다.
10ms × 1.2 = 12ms
바운스가 5밀리초 안에 끝나므로 그 사이의 튐은 전부 커패시터에 흡수되어 핀 전압이 임계값을 넘지 못합니다. 바운스가 사라집니다.
여기에 함정이 하나 있습니다. RC 필터는 신호를 느리게 만듭니다. 즉 핀 전압이 앞에서 본 불확정 구간을 12밀리초에 걸쳐 천천히 통과합니다. 그 구간을 지나는 동안 아주 작은 노이즈만 있어도 입력 비교기가 여러 번 뒤집힐 수 있습니다. 바운스를 없애려다 다른 종류의 다중 트리거를 만드는 것입니다.
그래서 RC 디바운싱은 슈미트 트리거 입력과 함께 써야 제대로 작동합니다. 슈미트 트리거는 올라갈 때와 내려갈 때의 임계값이 서로 다른 입력 회로라 중간에서 떨리지 않습니다. 74HC14가 대표적인 부품입니다. 다행히 ESP32의 GPIO 입력은 슈미트 트리거이고, ATmega328P의 입력도 어느 정도 히스테리시스를 가집니다. 그래도 확실하게 하려면 전용 부품을 쓰는 편이 낫습니다.
또 하나 실무 팁이 있습니다. 커패시터가 핀과 GND 사이에 있고 버튼도 핀과 GND 사이에 있으므로, 버튼을 누르는 순간 충전되어 있던 커패시터가 버튼 접점을 통해 직접 방전됩니다. 1마이크로패럿이 순간적으로 방전되면 접점에 큰 전류가 흘러 접점이 마모됩니다. 그래서 버튼과 커패시터 사이에 100옴에서 220옴 정도의 저항을 하나 더 넣는 것이 정석입니다.
소프트웨어 디바운싱 — 그리고 delay가 틀린 이유
부품을 추가하지 않고 코드로 처리하는 것이 훨씬 흔합니다. 원리는 간단합니다. 값이 바뀐 뒤 일정 시간 동안 안정적으로 유지될 때만 진짜 변화로 인정합니다.
먼저 널리 퍼져 있지만 잘못된 코드를 보겠습니다.
// 이렇게 쓰면 안 됩니다.
void loop() {
if (digitalRead(BUTTON_PIN) == LOW) {
counter++;
delay(50); // 바운스가 끝날 때까지 기다린다는 의도
}
}
이 코드가 문제인 이유가 세 가지 있습니다.
첫째, delay는 CPU를 완전히 멈춥니다. 50밀리초 동안 다른 버튼도, 센서 읽기도, 시리얼 수신도, 모터 제어도 전부 멈춥니다. 버튼이 두 개가 되는 순간 이 방식은 무너집니다.
둘째, 놓을 때의 바운스를 처리하지 못합니다. 위 코드는 누를 때만 50밀리초를 기다립니다. 버튼을 놓는 순간에도 접점은 똑같이 튀는데, 그때는 아무 보호가 없습니다.
셋째, 버튼을 계속 누르고 있으면 50밀리초마다 카운터가 계속 올라갑니다. 상태 변화가 아니라 상태 자체를 보고 있기 때문입니다.
올바른 방식은 비차단 구조입니다. 마지막으로 값이 변한 시각을 기록해 두고, 그 뒤로 충분한 시간이 지났을 때만 확정합니다.
const int BUTTON_PIN = 2;
const unsigned long DEBOUNCE_MS = 50;
// 마지막으로 핀에서 읽은 원시 값. 튀는 값이 그대로 들어옵니다.
int lastRawReading = HIGH;
// 디바운스를 통과해 확정된 값.
int stableState = HIGH;
// 원시 값이 마지막으로 변한 시각.
unsigned long lastChangeMs = 0;
unsigned long pressCount = 0;
void setup() {
Serial.begin(115200);
pinMode(BUTTON_PIN, INPUT_PULLUP);
}
void loop() {
int raw = digitalRead(BUTTON_PIN);
// 원시 값이 흔들릴 때마다 타이머를 처음부터 다시 시작합니다.
// 바운스가 계속되는 동안에는 아래 확정 블록에 절대 도달하지 못합니다.
if (raw != lastRawReading) {
lastChangeMs = millis();
lastRawReading = raw;
}
// 마지막 변화로부터 DEBOUNCE_MS 동안 조용했다면 진짜 값입니다.
if (millis() - lastChangeMs >= DEBOUNCE_MS) {
if (raw != stableState) {
stableState = raw;
// 여기서 상태 자체가 아니라 전이를 봅니다.
// 풀업이므로 HIGH에서 LOW로 가는 것이 누름입니다.
if (stableState == LOW) {
pressCount++;
Serial.print("press #");
Serial.println(pressCount);
}
}
}
// loop는 계속 자유롭게 돕니다. 다른 작업을 여기 넣어도 됩니다.
}
이 코드가 앞의 잘못된 코드와 다른 점을 짚어 보겠습니다.
millis() - lastChangeMs라는 뺄셈은 CPU를 멈추지 않습니다. loop는 계속 돌면서 다른 일을 할 수 있고, 버튼을 열 개 붙여도 각각의 타이머를 따로 두면 동시에 처리됩니다.
그리고 뺄셈으로 비교한다는 점이 중요합니다. millis()는 약 49.7일마다 0으로 돌아가는데, 부호 없는 정수의 뺄셈은 자리 넘침이 일어나도 차이값이 올바르게 나옵니다. millis() > lastChangeMs + DEBOUNCE_MS 같은 형태로 쓰면 이 시점에 버그가 생깁니다.
stableState와 raw를 비교하는 부분이 상태 전이를 잡아냅니다. 버튼을 계속 누르고 있어도 stableState가 이미 LOW이므로 카운터가 늘지 않습니다.
50밀리초라는 값은 앞의 표에서 가장 긴 바운스보다 충분히 크면서 사람이 인지하지 못할 만큼 짧은 지점입니다. 로터리 엔코더처럼 빠른 입력을 다룰 때는 이 값이 오히려 입력을 놓치게 만들므로 5밀리초 정도로 줄이고 다른 기법을 병행합니다. 반대로 릴레이 접점을 읽는다면 100밀리초까지 늘려야 할 수도 있습니다. 이 숫자는 외울 상수가 아니라 접점 특성에서 나오는 값입니다.
마치며 — 입력 핀의 기본값을 정하는 것이 회로의 절반입니다
이 글의 내용을 한 줄로 줄이면 이렇습니다. 디지털 입력 핀은 아무 일도 일어나지 않을 때 어떤 값이어야 하는지를 회로가 정해 줘야 합니다.
프로그래머에게 이 발상은 익숙합니다. 초기화하지 않은 변수를 읽지 않는 것과 같은 규칙이기 때문입니다. 다만 하드웨어에서는 초기화가 대입문이 아니라 저항 한 개입니다. 그리고 초기화하지 않았을 때의 결과가 정의되지 않은 값이 아니라 주변 환경에 따라 매번 달라지는 값이라는 점이 다릅니다.
여기에 하나만 덧붙이면 완성됩니다. 그 기본값이 정해졌더라도, 기계식 접점은 값이 바뀌는 순간 수 밀리초 동안 정직하지 않습니다. 그 시간 동안 판단을 미루는 코드가 디바운싱이고, 미루는 방식이 delay가 아니라 시각 기록이어야 나머지 프로그램이 계속 살아 있습니다.
다음 글에서는 입력 핀에 들어오는 전압 자체가 위험한 경우를 다룹니다. 5V 센서를 3.3V 라즈베리파이에 연결했을 때 핀 안쪽에서 무슨 일이 벌어지는지 보겠습니다.