- 引言 —— 什么都没接的引脚,数值一直在变
- 悬空输入的真相 —— 引脚是一根小天线
- 上拉和下拉电阻到底在做什么
- 内部上拉为什么存在,阻值又是多少
- 为什么低电平有效成了惯例
- 选定外部电阻值 —— 抗噪能力与耗电量的取舍
- 开漏输出
- 开关抖动与消抖
- 结语 —— 定好输入引脚的默认值,是电路的一半
引言 —— 什么都没接的引脚,数值一直在变
在 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) | 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 之间,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 / GPIO3 | 1.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.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 总线不能无限制挂设备的物理原因。
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~5ms | 10ms |
| 拨动开关 | 5~10ms | 20ms |
| 微动开关(限位) | 1~3ms | 8ms |
| 继电器触点 | 5~20ms | 50ms |
| 旋转编码器 | 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。
比较 stableState 和 raw 的那部分,捕捉的正是状态的跃迁。哪怕一直按着按钮不放,因为 stableState 已经是 LOW 了,计数器也不会继续增加。
50 毫秒这个数值,比表里最长的抖动时间留有充分的余量,同时又短到人感觉不出来。处理旋转编码器这类快速输入时,这个值反而会导致漏掉输入,所以会缩短到 5 毫秒左右,再配合其他技巧一起用。反过来,如果是读继电器触点,可能得拉长到 100 毫秒。这个数字不是要背下来的常数,而是从触点本身的特性推出来的值。
结语 —— 定好输入引脚的默认值,是电路的一半
这篇文章的内容浓缩成一句话就是:数字输入引脚在什么都没发生时该是什么值,这件事必须由电路来决定。
对程序员来说这个想法并不陌生,因为它和「不要读取未初始化的变量」是同一条规则。只不过在硬件里,初始化不是一条赋值语句,而是一个电阻。而且和读取未初始化变量不同的是,后者的结果只是「未定义」,而这里的结果是一个随周围环境每次都会变化的值。
再补一点,这件事就完整了。就算默认值已经定好,机械触点在数值发生变化的那一刻起,有那么几毫秒是「不老实」的。在那段时间里推迟判断的代码就是消抖;而推迟的方式必须是记录时间戳,而不是 delay,程序的其余部分才能继续活着运转。
下一篇文章会讲输入引脚接收到的电压本身就很危险的情况:把一个 5V 传感器接到 3.3V 的树莓派上时,引脚内部究竟发生了什么。
현재 단락 (1/208)
在 Arduino 上把 D2 设为输入,什么都不接,每秒读取 10 次并通过串口输出。