Skip to content

필사 모드: 上拉、下拉与悬空引脚:为什么一个按钮会这么难搞

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 —— 什么都没接的引脚,数值一直在变

在 Arduino 上把 D2 设为输入,什么都不接,每秒读取 10 次并通过串口输出。

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的最大电压不确定区间
Arduino Uno(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 之间,Arduino 并不会决定这个值到底算 HIGH 还是 LOW——这是数据手册完全不做保证的区域。悬空引脚大部分时间都待在这个区间里。

这种状态还有一项看不见的代价。输入引脚内部的第一级是一对互补晶体管,当输入停留在中间电压时,上下两个晶体管会同时略微导通,产生一条从电源直通 GND 的贯穿电流。每个引脚只有几百微安左右,不会引人注意——但这正是电池供电设备要把所有闲置引脚统一接成上拉的原因。

上拉和下拉电阻到底在做什么

解决方案很明确:决定好「什么都没发生时,这个引脚该是多少伏」。做这件事的元件,就是上拉或下拉电阻。

上拉是在引脚和电源之间接一个电阻。只要没有别的东西碰这个引脚,电流就会从电源经电阻流向引脚,把引脚拉到电源电压。更精确地说:因为引脚的漏电流几乎为零,电阻两端的压降也几乎为零,引脚电压因此实质上等于电源电压。用 10k 上拉、1 微安漏电流来算,电阻上损耗的电压是:

0.000001A × 10000欧姆 = 0.00001V = 0.01mV

可以忽略。所以引脚会稳定地读出 HIGH。

下拉则相反,在引脚和 GND 之间接电阻,默认值变成 LOW。

这里有个值得认真对待的问题:为什么要用电阻,而不是干脆用导线直接接到电源上?如果用导线直接接,那个引脚就永远是 HIGH,按钮什么作用都起不了。不,更糟——按下按钮、把这个引脚接到 GND 的瞬间,电源和 GND 就通过零电阻直接短路了。

电阻做的事情是「弱弱地拉一下」。没人反对的时候它说了算,一旦有人强力反对,它就输。给一个带 10k 上拉的引脚接上按钮、把它拉向 GND,按钮自身的电阻几乎为零,所以引脚会被拽向 GND。这时流过的电流是:

3.3V / 10000欧姆 = 0.00033A = 0.33mA

只有 0.33mA 从电源流向 GND。这份电流就是上拉的代价,选定电阻值时要算的正是这个数字。

上拉和下拉在逻辑上是对称的,但实务中上拉的使用频率要压倒性地高得多。原因会在接下来两节里展开。

内部上拉为什么存在,阻值又是多少

几乎所有单片机内部都自带一个可以用软件开关的上拉电阻。省下一个元件,很方便。

搞清楚它的阻值很重要。内部上拉不是精密电阻,而是半导体工艺做出来的电阻,所以偏差很大。

板子/芯片内部上拉电阻内部下拉启用方式
Arduino Uno(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千欧姆(板载固定)无法使用无法移除

有三点要说明。

第一,Arduino 的 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,也就是「没按下」。高电平有效时,导线掉了会变成悬空,可能被读成随机的「按下」。断开时会跌向安全一侧的设计,才是好设计。

所以在 Arduino 上接一个按钮的正确写法就这么短:

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之间。
# 库会打开内部上拉,LOW时is_pressed变为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 总线不能无限制挂设备的物理原因。

Arduino 上模拟开漏输出的方法也值得了解,靠的是切换引脚模式。

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 了。如果代码简单,Arduino 的 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 天会归零一次,但无符号整数的减法,即使发生了溢出,差值依然是正确的。如果写成 millis() > lastChangeMs + DEBOUNCE_MS 这种形式,恰好就会在归零那一刻产生 bug。

比较 stableStateraw 的那部分,捕捉的正是状态的跃迁。哪怕一直按着按钮不放,因为 stableState 已经是 LOW 了,计数器也不会继续增加。

50 毫秒这个数值,比表里最长的抖动时间留有充分的余量,同时又短到人感觉不出来。处理旋转编码器这类快速输入时,这个值反而会导致漏掉输入,所以会缩短到 5 毫秒左右,再配合其他技巧一起用。反过来,如果是读继电器触点,可能得拉长到 100 毫秒。这个数字不是要背下来的常数,而是从触点本身的特性推出来的值。

结语 —— 定好输入引脚的默认值,是电路的一半

这篇文章的内容浓缩成一句话就是:数字输入引脚在什么都没发生时该是什么值,这件事必须由电路来决定。

对程序员来说这个想法并不陌生,因为它和「不要读取未初始化的变量」是同一条规则。只不过在硬件里,初始化不是一条赋值语句,而是一个电阻。而且和读取未初始化变量不同的是,后者的结果只是「未定义」,而这里的结果是一个随周围环境每次都会变化的值。

再补一点,这件事就完整了。就算默认值已经定好,机械触点在数值发生变化的那一刻起,有那么几毫秒是「不老实」的。在那段时间里推迟判断的代码就是消抖;而推迟的方式必须是记录时间戳,而不是 delay,程序的其余部分才能继续活着运转。

下一篇文章会讲输入引脚接收到的电压本身就很危险的情况:把一个 5V 传感器接到 3.3V 的树莓派上时,引脚内部究竟发生了什么。

현재 단락 (1/208)

在 Arduino 上把 D2 设为输入,什么都不接,每秒读取 10 次并通过串口输出。

작성 글자: 0원문 글자: 8,847작성 단락: 0/208