Skip to content

Split View: 풀업, 풀다운, 그리고 플로팅 핀 — 버튼 하나가 이렇게 어려운 이유

✨ Learn with Quiz
|

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

들어가며 — 아무것도 연결하지 않은 핀이 계속 값을 바꿉니다

아두이노에서 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)5V3.0V1.5V1.5 ~ 3.0V
라즈베리파이 (BCM)3.3V2.31V0.99V0.99 ~ 2.31V
ESP323.3V2.475V0.825V0.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 / GPIO31.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.3mA25나노초긴 배선, 모터 근처, 산업 환경
4.7킬로옴0.70mA118나노초I2C 버스 표준값
10킬로옴0.33mA250나노초일반적인 기본 선택
47킬로옴0.07mA1.2마이크로초저전력 장치, 짧은 배선
100킬로옴 이상0.03mA2.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 ~ 5ms10ms
토글 스위치5 ~ 10ms20ms
마이크로 스위치 (리밋)1 ~ 3ms8ms
릴레이 접점5 ~ 20ms50ms
로터리 엔코더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 같은 형태로 쓰면 이 시점에 버그가 생깁니다.

stableStateraw를 비교하는 부분이 상태 전이를 잡아냅니다. 버튼을 계속 누르고 있어도 stableState가 이미 LOW이므로 카운터가 늘지 않습니다.

50밀리초라는 값은 앞의 표에서 가장 긴 바운스보다 충분히 크면서 사람이 인지하지 못할 만큼 짧은 지점입니다. 로터리 엔코더처럼 빠른 입력을 다룰 때는 이 값이 오히려 입력을 놓치게 만들므로 5밀리초 정도로 줄이고 다른 기법을 병행합니다. 반대로 릴레이 접점을 읽는다면 100밀리초까지 늘려야 할 수도 있습니다. 이 숫자는 외울 상수가 아니라 접점 특성에서 나오는 값입니다.

마치며 — 입력 핀의 기본값을 정하는 것이 회로의 절반입니다

이 글의 내용을 한 줄로 줄이면 이렇습니다. 디지털 입력 핀은 아무 일도 일어나지 않을 때 어떤 값이어야 하는지를 회로가 정해 줘야 합니다.

프로그래머에게 이 발상은 익숙합니다. 초기화하지 않은 변수를 읽지 않는 것과 같은 규칙이기 때문입니다. 다만 하드웨어에서는 초기화가 대입문이 아니라 저항 한 개입니다. 그리고 초기화하지 않았을 때의 결과가 정의되지 않은 값이 아니라 주변 환경에 따라 매번 달라지는 값이라는 점이 다릅니다.

여기에 하나만 덧붙이면 완성됩니다. 그 기본값이 정해졌더라도, 기계식 접점은 값이 바뀌는 순간 수 밀리초 동안 정직하지 않습니다. 그 시간 동안 판단을 미루는 코드가 디바운싱이고, 미루는 방식이 delay가 아니라 시각 기록이어야 나머지 프로그램이 계속 살아 있습니다.

다음 글에서는 입력 핀에 들어오는 전압 자체가 위험한 경우를 다룹니다. 5V 센서를 3.3V 라즈베리파이에 연결했을 때 핀 안쪽에서 무슨 일이 벌어지는지 보겠습니다.

Pull-Up, Pull-Down, and Floating Pins: Why a Single Button Is So Hard to Get Right

Introduction — A Pin With Nothing Connected Keeps Changing Value

Set D2 to an input on an Arduino, connect nothing to it, and read its value ten times a second over serial.

1 1 1 0 1 1 0 0 0 1 0 1 1 1 0 1 0 0 1 1

The value keeps changing. Bring your hand near the pin and the pattern shifts; flip on a fluorescent light and it shifts again. There's nothing wrong with the code.

From a software point of view, this is hard to make sense of. An unread variable holds an initial value, or at least a stable piece of garbage. But this pin gives a different value every time you read it.

The answer is simple: that pin's voltage isn't fixed. A digital input is a circuit that reads a voltage and compares it against a threshold — and if there's no voltage to compare in the first place, there's no result either. And the moment you wire up a button, this issue rises straight to the center of your circuit design.

What a Floating Input Actually Is — The Pin Is a Tiny Antenna

The inside of a digital input pin is an extremely high-impedance comparator. How high? The input leakage current listed on a datasheet tops out around 1 microamp. Convert that to a resistance at 3.3V:

3.3V / 0.000001A = 3300000 ohm = 3.3 megohm

Over 3 megohms. So this pin is, for all practical purposes, a bystander through which almost no current flows.

That's exactly where the problem starts. The voltage at a node where almost no current flows gets shaken badly by even a tiny amount of charge. The capacitance formed by the pin, the wiring, and the breadboard is roughly 20 to 30 picofarads. The relationship between voltage and charge is:

Voltage change = Charge / Capacitance

Deposit just 25 picocoulombs of charge on 25 picofarads and the voltage shifts by 1V. 25 picocoulombs is easily produced by nothing more than the capacitive coupling from bringing a finger nearby.

So a pin connected to nothing becomes an antenna. Induction from a 60Hz power line, signal transitions on a nearby wire, fluorescent-light ballast noise, switching-adapter noise — all of it shakes this pin's voltage. And when that voltage wanders around the threshold, every read gives you a different value.

It's worth knowing exactly where that threshold sits. A CMOS input has two of them.

BoardLogic voltageMinimum voltage recognized as HIGHMaximum voltage recognized as LOWUndefined band
Arduino Uno (ATmega328P)5V3.0V1.5V1.5–3.0V
Raspberry Pi (BCM)3.3V2.31V0.99V0.99–2.31V
ESP323.3V2.475V0.825V0.825–2.475V

The middle band is the problem. If the pin voltage sits between 1.5V and 3.0V, the Arduino doesn't decide whether that's HIGH or LOW — it's a region the datasheet makes no guarantee about. A floating pin spends most of its time right in that band.

There's one more invisible cost to this state. The first stage inside an input pin is a pair of complementary transistors, and when the input sits at a middle voltage, the upper and lower transistors both turn on a little at the same time. A shoot-through current flows from the supply straight to GND. It's only a few hundred microamps per pin, so you'd never notice it — but it's exactly why battery-powered devices tie every unused pin to a pull-up.

What Pull-Up and Pull-Down Resistors Actually Do

The fix is straightforward: decide what voltage the pin should sit at when nothing is happening. The component that does that job is a pull-up or pull-down resistor.

A pull-up puts a resistor between the pin and the supply. When nothing else is touching the pin, current flows from the supply through the resistor to the pin, pulling the pin up to the supply voltage. More precisely: since the pin's leakage current is nearly zero, the voltage across the resistor is also nearly zero, so the pin voltage ends up essentially equal to the supply. With a 10k pull-up and 1 microamp of leakage, the voltage dropped across the resistor is:

0.000001A × 10000 ohm = 0.00001V = 0.01mV

Negligible. So the pin reads a stable HIGH.

A pull-down does the reverse: a resistor between the pin and GND. The default becomes LOW.

Here's a question worth taking seriously: why use a resistor at all — why not just wire straight to the supply with plain wire? Wire it directly, and that pin is HIGH forever, and the button can't do anything. Actually, worse: the instant you press the button and connect that pin to GND, you've directly shorted the supply to GND through zero resistance. A dead short.

What the resistor does is "pull weakly." Nothing opposing it wins by default; anything strongly opposing it wins instead. Take a pin with a 10k pull-up, and have the button connect it to GND — the button's own resistance is nearly zero, so the pin gets dragged down toward GND. The current flowing at that moment is:

3.3V / 10000 ohm = 0.00033A = 0.33mA

Only 0.33mA flows from supply to GND. This current is the cost of the pull-up, and it's exactly what you calculate when choosing a resistor value.

Pull-up and pull-down are logically symmetric, but in practice, pull-up is used overwhelmingly more often. The reasons unfold over the next two sections.

Why Internal Pull-Ups Exist, and What Value They Actually Are

Almost every microcontroller has a pull-up resistor built right into the chip, switchable in software. It's convenient because it saves you a component.

Knowing the value matters. An internal pull-up isn't a precision resistor — it's made by the semiconductor process, so it varies a lot.

Board / chipInternal pull-up resistanceInternal pull-downHow to enable
Arduino Uno (ATmega328P)20–50 kΩNonepinMode(pin, INPUT_PULLUP)
Raspberry Pi (BCM2835 family)~50–65 kΩ~50–65 kΩgpiozero's pull_up argument
ESP32~45 kΩ~45 kΩpinMode(pin, INPUT_PULLUP)
Raspberry Pi GPIO2 / GPIO31.8 kΩ (fixed on-board)Not availableCannot be removed

Three things worth flagging.

First, the Arduino's ATmega328P has no internal pull-down. If you need a pull-down, you must add an external resistor. This asymmetry is one of the technical roots of the active-low convention.

Second, the spread is large. Between 20 and 50 kilohms is a factor of two and a half. Any circuit that needs a precise current or time constant can't rely on an internal pull-up.

Third — and this is the one that trips people up most often in practice — the Raspberry Pi's GPIO2 and GPIO3 have a physical 1.8k pull-up soldered right onto the board. It's there for the I2C bus, and it cannot be removed. Use these pins as ordinary inputs and they're always pulled strongly HIGH, so even wiring on a pull-down can't overpower it. Add a 10k pull-down, for instance, and the result is a voltage divider:

Pin voltage = 3.3V × 10000 / (1800 + 10000) = 3.3V × 0.847 = 2.80V

2.80V — nowhere near the LOW ceiling of 0.99V. It still reads HIGH. GPIO2 and GPIO3 are best left dedicated to I2C.

Why Active-Low Is the Convention

There are two ways to wire a button.

Active-high uses a pull-down resistor, with the button between the pin and the supply. Pressing it produces HIGH. In code, digitalRead(pin) == HIGH means "pressed," which is intuitive.

Active-low uses a pull-up resistor, with the button between the pin and GND. Pressing it produces LOW. In code, digitalRead(pin) == LOW means "pressed," which feels backward at first.

And yet nearly every real schematic and module out there is active-low. There are several layers of reasons.

First, as we just saw, internal pull-ups are common and internal pull-downs are rare or nonexistent. Go active-low and you don't need an external resistor at all.

Second, the wiring is safer. One leg of the button is GND. On a breadboard, the GND rail runs the full length and is easy to grab from anywhere, and accidentally touching it elsewhere doesn't cause an accident, because it's already GND. A supply rail running here and there, on the other hand, is a dead short the instant it accidentally touches GND.

Third, and this was historically decisive: a transistor's ability to sink current was stronger than its ability to source it. In early TTL logic, the sink-to-LOW capability was 16mA while the source-to-HIGH capability was only 0.4mA — a 40x difference. So it was natural to assign the meaningful signal to LOW. Modern CMOS is close to symmetric in both directions, but the convention stuck around.

Fourth, the failure mode is safer. Think about what happens if a wire comes loose. In active-low, if the wire falls off, the pull-up wins and you get HIGH — "not pressed." In active-high, if the wire falls off, the pin floats and can read as randomly pressed. A design that fails toward the safe state when something breaks is a good design.

So the correct way to wire a single button on an Arduino is this short:

const int BUTTON_PIN = 2;

void setup() {
  Serial.begin(115200);
  // Turn on the internal pull-up. No external resistor needed.
  // The button only needs to connect D2 to GND.
  pinMode(BUTTON_PIN, INPUT_PULLUP);
}

void loop() {
  // With the pull-up, it's normally HIGH, and LOW when pressed.
  bool pressed = (digitalRead(BUTTON_PIN) == LOW);
  Serial.println(pressed ? "pressed" : "released");
  delay(100);
}

Use gpiozero on a Raspberry Pi and this convention is already the default.

from gpiozero import Button
from signal import pause

# pull_up=True is the default. The button connects between GPIO17 and GND.
# The library turns on the internal pull-up, and is_pressed becomes True when LOW.
# In other words, the library handles the active-low inversion for you.
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 is an option that handles debouncing — covered later — at the library level.

Choosing an External Resistor Value — Noise Immunity Versus Current Draw

Sometimes the internal pull-up isn't enough: long wire runs, a noisy environment, needing a precise value, or several devices sharing one line. In these cases you need to choose the external resistor value yourself.

This choice pits two demands head to head.

A smaller resistor is better for noise immunity, because it recovers faster. For noise to move the pin voltage, it has to charge the pin's capacitance, and a smaller pull-up resistor undoes that charging faster. Work it out as a time constant. Taking the pin-plus-wiring capacitance as 25 picofarads:

1 kΩ  → 1000 × 0.000000000025 = 25 nanoseconds
10 kΩ → 250 nanoseconds
100 kΩ → 2.5 microseconds

A pin with a 100k pull-up takes 2.5 microseconds to recover a voltage that noise has knocked askew. During that stretch, a read can come back wrong.

A larger resistor is better for current draw. While the button is held down, current keeps flowing from the supply to GND through the pull-up resistor.

1 kΩ   → 3.3V / 1000 = 3.3mA
10 kΩ  → 0.33mA
100 kΩ → 0.033mA = 33 microamps

3.3mA is no big deal for a single button. But with a bank of sixteen switches and half of them held down, that's 26mA — and on a battery-powered device, this number decides how long the battery lasts.

Put together, here's the guideline:

Resistor valueCurrent draw while pressed (3.3V)Noise-recovery timeGood for
1 kΩ3.3mA25nsLong wiring, near motors, industrial environments
4.7 kΩ0.70mA118nsStandard I2C value
10 kΩ0.33mA250nsCommon default choice
47 kΩ0.07mA1.2μsLow-power devices, short wiring
100 kΩ and up0.03mA2.5μsNot recommended

With no particular reason to do otherwise, 10 kilohms is a good starting point. And if you see the symptom "the button occasionally presses itself," lowering the value is the first thing to try. Once wiring runs longer than 30 centimeters, dropping to 4.7k or 1k is the safer move.

If you've wired up a button or switch and want to double-check you haven't left out the pull resistor, drop the part into this site's circuit wiring validator. It flags any part that would float if left alone — like a plain push button — as needing a pull-up or pull-down, and it also warns you if you've picked a pin like the Raspberry Pi's GPIO2 or GPIO3 that already has a fixed on-board pull-up.

Open-Drain Output

Once you understand pull-ups, an output style called open-drain follows naturally, and you're guaranteed to run into it in practice when dealing with I2C or sharing an interrupt line.

A normal digital output is push-pull. Write HIGH and the upper transistor turns on, connecting the pin to the supply; write LOW and the lower transistor turns on, connecting it to GND. Both directions push actively.

That comes with a constraint. If two push-pull devices sit on the same line and one writes HIGH while the other writes LOW, the supply and GND get shorted together straight through both transistors. At 5V, if each transistor's on-resistance is 25 ohms:

5V / (25 + 25) = 0.1A = 100mA

100mA rips through both chips. One or both get damaged.

Open-drain does away with the upper transistor entirely. The output either pulls LOW, or does nothing and lets go. Making HIGH happen is the job of an external pull-up resistor.

This lets multiple devices safely share one line. If nobody's pulling it down, the pull-up wins and it's HIGH; if even one device pulls it down, it's LOW. Logically, an AND gate emerges just from the wiring. Because of this property, an open-drain bus follows the rule "if even one asserts LOW, the line is LOW," which is what makes techniques like clock stretching — where an I2C slave holds the clock line down until it's ready — possible.

The cost is speed. Going to LOW is fast because a transistor actively pulls it down, but going to HIGH depends entirely on how fast the pull-up resistor can charge the capacitance. With 4.7 kΩ and 100 picofarads:

Time constant = 4700 × 0.0000000001 = 0.00000047 second = 470 nanoseconds

The rise time is a bit more than twice the time constant, so roughly 1 microsecond. I2C standard mode's maximum rise time spec is 1 microsecond — right at that edge. Add more devices and the capacitance grows, and you blow past spec. This is the physical reason you can't just keep piling devices onto an I2C bus indefinitely.

It's also worth knowing how to fake an open-drain output on an Arduino: by switching the pin mode.

const int SHARED_LINE = 4;

void setup() {
  // Released. The external pull-up brings it HIGH.
  pinMode(SHARED_LINE, INPUT);
}

void assertLow() {
  // Actively pull it down.
  pinMode(SHARED_LINE, OUTPUT);
  digitalWrite(SHARED_LINE, LOW);
}

void release() {
  // Go back to high impedance. The key is never using HIGH.
  pinMode(SHARED_LINE, INPUT);
}

The point is to never call digitalWrite(pin, HIGH). Making HIGH happen is left to the pull-up; this side only has two states — pulling down, or letting go.

Switch Bounce and Debouncing

You've wired everything correctly and turned on the pull-up, and yet pressing the button once bumps the counter by 3. This is the last problem.

The Waveform an Oscilloscope Actually Shows

A mechanical switch's contact is a piece of metal. Press it, and two pieces of metal collide — and metal has elasticity, so it bounces. It touches, separates for a very brief instant, touches again, repeats a few times, and only then settles. Look at a tactile switch on an oscilloscope and you get roughly this waveform.

                                         ┌──────────────────
      ─────┐   ┌──┐  ┌─┐ ┌┐              │
           │   │  │  │ │ ││              │
           └───┘  └──┘ └─┘└──────────────┘
           |<------ about 1–5ms ------>|
           moment of press           settling point

Roughly the range per contact type:

Switch typeTypical bounce timeWorst case
Small tactile switch1–5ms10ms
Toggle switch5–10ms20ms
Micro switch (limit)1–3ms8ms
Relay contact5–20ms50ms
Rotary encoder1–5ms (both channels)10ms

Now it's clear why the counter jumps by 3. With simple code, an Arduino's loop cycles on a microsecond timescale, so it observes a contact bouncing for 5 milliseconds hundreds of times over. Every single transition in there looks like a genuine input.

The important thing to recognize is that this isn't a defect — it's the normal behavior of a mechanical contact. Buying a better switch doesn't make it go away. You have to handle it in the circuit or in code.

Smearing Out the Waveform With an RC Circuit

The most intuitive hardware fix is a low-pass filter built from a resistor and a capacitor. Put a capacitor between the pin and GND, and while the contact is bouncing, the capacitor absorbs the brief voltage swings.

Say you put a 1 microfarad capacitor on a 10k pull-up.

Time constant = 10000 ohm × 0.000001F = 0.01 second = 10ms

After you release the button, the time it takes the pin voltage to climb up to the HIGH threshold of 2.31V is about 1.2 times the time constant.

10ms × 1.2 = 12ms

Since the bounce finishes within 5 milliseconds, every bit of that bouncing gets absorbed by the capacitor, and the pin voltage never crosses the threshold. The bounce disappears.

There's a catch here. An RC filter slows the signal down. That means the pin voltage crawls through the undefined band we saw earlier over 12 milliseconds. During that crossing, even tiny amounts of noise can flip the input comparator back and forth several times. Trying to eliminate bounce, you end up creating a different kind of multi-trigger.

So RC debouncing only really works properly paired with a Schmitt-trigger input. A Schmitt trigger has different thresholds going up versus going down, so it doesn't chatter in the middle. The 74HC14 is a classic example of such a part. Fortunately, the ESP32's GPIO inputs are Schmitt triggers, and the ATmega328P's inputs have some hysteresis too. Still, if you want to be certain, a dedicated part is the better choice.

There's one more practical tip. Since the capacitor sits between the pin and GND, and the button also sits between the pin and GND, the moment you press the button, the capacitor's charge discharges straight through the button's contacts. Discharging 1 microfarad in an instant sends a big current through the contact and wears it out. So it's standard practice to add one more resistor, in the 100 to 220 ohm range, between the button and the capacitor.

Software Debouncing — And Why delay Is Wrong

Handling it in code without adding a component is far more common. The principle is simple: only count a value as a real change once it's stayed stable for some length of time after changing.

First, let's look at code that's widely copied but wrong.

// Do not write it like this.
void loop() {
  if (digitalRead(BUTTON_PIN) == LOW) {
    counter++;
    delay(50);   // intended to wait out the bounce
  }
}

There are three reasons this code is broken.

First, delay stops the CPU completely. For 50 milliseconds, no other button, no sensor reading, no serial receive, no motor control — everything stops. The moment you add a second button, this approach falls apart.

Second, it doesn't handle bounce on release. The code above only waits 50 milliseconds when pressing. The contact bounces the same way when you let go of the button, and at that moment there's no protection at all.

Third, holding the button down keeps incrementing the counter every 50 milliseconds. It's watching the state itself instead of the change in state.

The correct approach is non-blocking. Record the moment the value last changed, and only confirm it once enough time has passed since then.

const int BUTTON_PIN = 2;
const unsigned long DEBOUNCE_MS = 50;

// The last raw value read from the pin. The bouncing value lands here as-is.
int lastRawReading = HIGH;
// The confirmed value that has passed debouncing.
int stableState = HIGH;
// The moment the raw value last changed.
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);

  // Every time the raw value wobbles, restart the timer from scratch.
  // As long as the bouncing keeps going, we never reach the confirmation block below.
  if (raw != lastRawReading) {
    lastChangeMs = millis();
    lastRawReading = raw;
  }

  // If it's been quiet for DEBOUNCE_MS since the last change, it's a real value.
  if (millis() - lastChangeMs >= DEBOUNCE_MS) {
    if (raw != stableState) {
      stableState = raw;

      // Here we look at the transition, not the state itself.
      // With a pull-up, going from HIGH to LOW is a press.
      if (stableState == LOW) {
        pressCount++;
        Serial.print("press #");
        Serial.println(pressCount);
      }
    }
  }

  // loop keeps running freely. You can put other work in here too.
}

Let's go through how this differs from the broken code above.

The subtraction millis() - lastChangeMs doesn't stop the CPU. loop keeps running and can do other things, and if you add ten buttons, giving each its own timer lets them all get handled at the same time.

And comparing via subtraction matters. millis() wraps back to zero roughly every 49.7 days, but subtracting unsigned integers gives the correct difference even across that wraparound. Write it as something like millis() > lastChangeMs + DEBOUNCE_MS instead, and a bug appears right at that rollover point.

The part comparing stableState and raw is what catches the state transition. Even holding the button down continuously, stableState is already LOW, so the counter doesn't keep climbing.

The value of 50 milliseconds sits comfortably above the longest bounce time in the table above, while staying short enough that a person can't perceive it. When handling something fast like a rotary encoder, this value actually starts causing missed inputs, so you'd shrink it to around 5 milliseconds and combine it with other techniques. Conversely, reading relay contacts might call for stretching it to 100 milliseconds. This number isn't a constant to memorize — it's a value that comes out of the contact's own characteristics.

Conclusion — Deciding an Input Pin's Default Value Is Half the Circuit

The content of this post boils down to one line: a circuit must decide what value a digital input pin should be when nothing is happening.

This idea is familiar to programmers, because it's the same rule as never reading an uninitialized variable. The difference is that in hardware, initialization isn't an assignment statement — it's a single resistor. And unlike an uninitialized read, whose result is merely undefined, the result here is a value that changes every time depending on the surrounding environment.

There's one more thing to add to make this complete. Even once that default value is settled, a mechanical contact isn't honest for a few milliseconds the instant its value changes. The code that postpones judgment during that stretch is debouncing, and postponing it via a recorded timestamp rather than delay is what keeps the rest of the program alive.

The next post covers cases where the voltage arriving at an input pin is itself dangerous — what happens inside the pin when you connect a 5V sensor to a 3.3V Raspberry Pi.