GPIO:寄存器控制的开关阵(以 Cortex-M4 为例)
你照着教程点亮了 LED,信心满满地把灯换成按键:把引脚配成”输入”,按下想让灯亮。结果灯的表现像在闹鬼——有时不亮,有时乱闪,有时按下去根本没反应。你反复对照教程,每一行都对。你怀疑是硬件坏了,换了一块板子,还是这样。
问题不在代码,也不在板子,而在一条你从没写过、也从没人告诉你的默认值里:STM32 上电复位后,所有 GPIO 引脚默认是”浮空输入“——既不输出,也不读,既不拉高,也不拉低,像一根悬空的天线,引脚上的电平全看电路板上的电磁噪声。你的程序去读它,就是在一堆噪声里猜电平。
这篇文章以应用最广的 32 位内核之一 Cortex-M4(以 STM32F4 为例)为背景,分三层把 GPIO 拆开:先让输入和输出都跑起来,再看每个引脚背后那排由寄存器控制的电子开关,最后想明白——为什么 GPIO 非得是这副”开关阵”的模样。
L1:先让它跑起来——一个按键控制一盏灯
你在第一层。目标:用最小代码让”输入”和”输出”都跑起来,对 GPIO 的配置长什么样子有个地图感。
本系列第一篇 《Cortex-M4 与 MCU 开发》 点过一盏灯:开时钟、配输出模式、往 ODR 写值。现在给它加个交互——一个按键按下时灯亮,松开时灯灭。假设 LED 接在 PD12,按键接在 PD1 和 GND 之间(开发板自带的用户按键在 PA0,这里为了只碰一个端口 GPIOD,用面包板随便接一根到 PD1 就行):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#define RCC_AHB1ENR (*(volatile unsigned int*)0x40023830)
#define GPIOD_MODER (*(volatile unsigned int*)0x40020C00)
#define GPIOD_PUPDR (*(volatile unsigned int*)0x40020C0C)
#define GPIOD_IDR (*(volatile unsigned int*)0x40020C10)
#define GPIOD_BSRR (*(volatile unsigned int*)0x40020C18)
int main(void)
{
RCC_AHB1ENR |= 1u << 3; /* 打开 GPIOD 的时钟 */
GPIOD_MODER |= 1u << 24; /* PD12 → 输出(01):灯 */
GPIOD_PUPDR |= 1u << 2; /* PD1 → 上拉(01):按键 */
while (1) {
if (!(GPIOD_IDR & (1u << 1))) /* PD1 = 0:按下了 */
GPIOD_BSRR = 1u << 12; /* 置位 PD12,灯亮 */
else /* PD1 = 1:松开 */
GPIOD_BSRR = 1u << 28; /* 复位 PD12,灯灭 */
}
}
五六个地址,程序就活了。先挑几个”看着眼生”的地方钉一下:
GPIOD_MODER |= 1u << 24:MODER 是模式寄存器,每个引脚占 2 位,PD12 占用第 24、25 位。默认全是 0,所以只置低一位(01)就够,写成”输出”。GPIOD_PUPDR |= 1u << 2:PUPDR 是上下拉寄存器,每个引脚也是 2 位。PD1 默认是”浮空”(00),我们置成”上拉”(01)。PD1 的 MODER 不用写——默认的00恰好就是”输入”。GPIOD_IDR & (1u << 1):IDR 是输入数据寄存器,读它得到引脚当前的电平。配上拉之后:松开按键,内部电阻把引脚拽到 VDD,读到 1;按下按键,引脚被 GND 短路,读到 0。这就是”按下”的判断依据。GPIOD_BSRR:BSRR 是”置位/复位”寄存器。写 1 到第 12 位 = 把 PD12 置成高电平;写 1 到第 28 位(12+16)= 把 PD12 拉回低电平。不是 ODR——为什么后面有专门一节讲它,先记着这个感觉。
(还有两个默认值顺手交代:GPIO 的时钟忘了开,读这些寄存器全读到 0 或直接总线错误——外设第一课,永远是先开时钟;按键按下瞬间电平会”抖”几十毫秒,真实项目要消抖,那是定时器的活,这篇先不碰。)
到这里你已经会让一个引脚”工作”了——会配输出、会读输入。但你是对着手册抄了五个地址,还欠着一屁股解释:这些魔法数字背后,引脚到底接了什么电路?为什么”输入”的引脚还要先”上拉”?为什么写输出推荐 BSRR 而不是 ODR?
L2:懂原理——每个引脚背后,是一排受控开关
到这一层,问题变了:不再问”怎么写”,而是问”底下发生了什么”。
引脚的解剖:四样东西
剥开一颗引脚,从外面往里看,有四样东西(先看右边的表,再读下面的字):
| 部件 | 干什么的 | 被谁控制 |
|---|---|---|
| 输出驱动级(两个 MOS 管) | 把引脚使劲拉向 VDD 或 GND | MODER(是否输出)+ OTYPER(推挽/开漏)+ ODR(输出什么值) |
| 输入缓冲(施密特触发器) | 把引脚上的模拟电压比成干净的 0/1 | 除模拟模式外一直在工作,IDR 随时可读 |
| 上/下拉电阻(内部弱电阻) | 给悬空的引脚一个默认电平 | PUPDR |
| ESD 保护二极管 | 电压过高/过低时钳住引脚,保护芯片 | 硬件固有,不用你管 |
这几个部件里,”输出驱动级”是主角。它本质是两个电子开关(MOS 管):一个接 VDD,负责把引脚拉高;一个接 GND,负责把引脚拉低。你写 ODR,就是决定这两个开关谁接通。合起来看,这就是一张小图:
1
2
3
4
5
6
7
8
9
VDD
│
[PMOS] ← 负责拉高的开关(推挽模式才用)
│
ODR ──┼─┬──────────●──────── 引脚
│ │ │
[NMOS] [输入缓冲]──▶ IDR(读到 0 或 1)
│ │
GND [PUPDR 电阻]──▶ 内部 VDD 或 GND
现在,那个闹鬼的”浮空输入”有了解释:输入模式在电气上意味着”两个输出开关全断开”——PMOS、NMOS 都关断,引脚既不连 VDD 也不连 GND,处于高阻(tri-state)状态,谁都不管它。这时引脚上的电压由外部决定:外部没接东西,它就是悬空的,电平在噪声里漂;接了上拉/下拉电阻,才有一个确定的方向。所以”配输入”从来不是终点,”给输入一个确定电平”才是。
上拉与下拉:为什么要给悬空一个方向
把上拉/下拉做成软件可配的内部电阻,是 GPIO 和”裸导线”最大的区别。PUPDR 每个引脚 2 位:00 浮空、01 上拉(内部电阻接到 VDD)、10 下拉(内部电阻接到 GND)、11 保留。
- 按键接 GND → 配上拉:平时读到 1,按下读到 0。
- 按键接 VDD → 配下拉:平时读到 0,按下读到 1。
但内部电阻很弱——STM32F4 典型值约 40kΩ,撑不动什么负载,只能”给悬空一个温柔的默认方向”。外部真有强驱动(比如别的芯片在拉电平),内部上拉会乖乖让路。对抗强干扰、长导线时,40kΩ 靠不住,得在外部放一个几千欧的硬电阻——这是 L3 会聊的一个设计权衡。
推挽 vs 开漏:两个开关都用,还是只用半个
OTYPER 每个引脚 1 位:0 推挽(push-pull),1 开漏(open-drain)。
- 推挽:拉高时接通 PMOS,拉低时接通 NMOS——两个开关轮流出力,能强拉高也能强拉低。点灯、驱动 LED、输出普通信号,用它。
- 开漏:只保留接 GND 的 NMOS 那半个开关,拉高的事不干了。想输出高电平?只能靠外部接一个上拉电阻,电阻的另一头接 VDD(还可以接别的电压)。它只会”用力往下拽”,松手后靠外部弹簧弹回高位。
一个生活化的类比:推挽输出像门口有两个人——一个负责把门推开(拉高),一个负责把门拽上(拉低),进出自如;开漏输出像只剩一个”只往下拽”的人,门要打开,只能靠外面装的弹簧(外部上拉电阻)自己弹开。想用开漏,就得在外面配弹簧,这是它的铁律。
那开漏图什么?两个经典用处:
- 多设备共享一根线(I2C 的”线与”):两个芯片都把线配成开漏,谁都只能拉低、谁也不许硬拉高。任何一方想拉低就拉低,想让它为高就都松手、让外部上拉统一弹高。如果两边都是推挽,A 想拉高、B 想拉低,两个强驱动顶牛,线就废了,严重的还会烧管子。这就是为什么 I2C 规范强制开漏。
- 电平转换:外部上拉接 3.3V,输出高就是 3.3V;接 5V,输出高就是 5V——NMOS 只管拉低、从不碰高电平,电平就这么”翻译”过去(前提是选数据手册里标 FT 的 5V 耐压引脚)。
复用功能(AF):一根引脚,多个老板
芯片引脚就那么几十根,可片内外设一大堆:UART、SPI、I2C、定时器输出、USB……引脚根本不够分。Cortex-M 的方案是”选线器”:MODER 配成 10(复用,Alternate Function),再用 AFRH/AFRL 寄存器(每个引脚 4 位,AF0–AF15)选具体让哪个外设接管这个引脚。
“我的 UART 怎么收不到数据”这类问题的第一嫌疑,就是引脚没从 GPIO 切成 AF,或者 AF 编号选错。调串口前,记得 MODER=10 + AFR 选对号。
还有两个低调的寄存器值得混个眼熟:OSPEEDR(输出速度,每引脚 2 位)控制输出翻转的快慢,STM32F4 分 2/25/50/100MHz 四档——翻转快、边沿陡,产生的电磁噪声也大,LED、按键这种慢信号用默认低档就好,别贪快;LCKR(锁定)能把这组引脚的配置锁死,直到下次复位都改不了,适合防止固件被意外改掉关键配置的场景。
为什么写输出用 BSRR,而不是 ODR
ODR 也在,凭什么多造一个 BSRR?看汇编就知道了。往 ODR 的某个位写 1,你写的是 GPIOD_ODR |= 1u << 12——这是”读旧值、改这一位、写回去”三条指令(下一节的动手环节会在电脑上亲眼看)。危险在于中间那个缝隙:如果主循环和中断同时都在改同一个端口的不同引脚,中断可能插在这三条指令中间,导致一次更新被覆盖——这正是 《中断》 那篇讲过的”主循环与 ISR 共享变量”的老问题,搬到寄存器上照样犯。
BSRR 则把”置位”和”复位”拆成两个独立功能:写 1 到 0–15 位 = 置位对应引脚,写 1 到 16–31 位 = 复位对应引脚。一次内存写,硬件自己完成,中途谁也插不进脚——天然原子。想改哪个引脚就改哪个引脚,还不会误动别的位。这还没完,它也顺便解决了”用 = 整字写 ODR 会清掉其他引脚”的麻烦。所以工程上”置位一个输出”几乎都走 BSRR。
通往中断的路:EXTI
GPIO 不止能”被读”,还能”主动敲门”。上一篇说过外部中断(EXTI):引脚电平变化时,硬件直接打断 CPU。GPIO 侧只做两件事:把 SYSCFG_EXTICR 里的映射选好(16 根 EXTI 线是共享的——EXTI0 对应 PA0、PB0、PC0……从 0 到 15,同编号的引脚只有一个能占住这根线),再使能对应引脚的中断。机制本身在中断那篇,这里只记住:按键要”不等、立刻响应”,就把引脚从轮询换成 EXTI——配置还是这一篇的东西。
到这里你已经知道引脚里有什么了。但有个更根本的问题一直压着:为什么非得是这副模样?为什么不做成更”直观”的方案?
L3:想得透——为什么 GPIO 非要是这副”开关阵”
到这一层,问题变成:这些设计是理所当然的吗?换掉行不行?
本质:GPIO 到底是什么
去掉所有术语,GPIO 是这样一件事:
每个引脚背后,都排着一组由寄存器控制的电子开关,可以把它接通到电源、接通到地、交给一个电压比较器去读,或者让给某个片内外设——你写寄存器,就是在摆这些开关;你读引脚,就是在看开关那一头的电压。
给同事的一句话:GPIO 就是芯片把”这根引脚接哪”这件原本焊死在硬件里的事,改成了软件随手可拨的开关阵。你”配一个引脚”,本质是告诉硬件这一组开关该怎么摆;你”读一个引脚”,本质是看输入缓冲那头量出来的电平。点灯、按键、串口、ADC,全部只是这组开关的某一种摆法。
四个”被否决的方案”
本质立住了,还得追问一层:为什么是这个方案,而不是显然的替代方案?
被否决的方案一:统一用推挽,不做开漏。 推挽既能拉高又能拉低,看起来什么都覆盖了。失败场景就是 I2C:一根 SDA 线上挂着主控、传感器、Flash 三个设备,谁都能拉低、谁都想拉高,推挽对推挽就是顶牛,轻则数据错误,重则烧端口。开漏 + 外部上拉把”拉高”这个动作从芯片手里拿掉、交给一个谁也不会打架的电阻,才换来”线与”的自由。统一推挽的失败场景,正是开漏存在的理由。
被否决的方案二:内部上拉做强一点(比如 1kΩ),省得外接。 强上拉看起来一劳永逸。失败场景有二:一是功耗——1kΩ 一直有电流,电池设备根本扛不住,40kΩ 的微弱电流才勉强可忽略;二是”打架”——如果引脚被外部强驱动拉低,内部 1kΩ 还在拼命拉高,长时间顶牛会发热甚至损伤引脚。弱上拉的定位是”只给悬空一个温柔的方向,真有人要来驱动电平,我认输让路”。代价也清楚:抗干扰差,所以长导线、强噪声环境必须外接强上拉——这是”用功耗换来的弱”和”用钱换来的强”的取舍。
被否决的方案三:复位后默认输出,或者默认上拉。 默认输出看起来很”好用”:上电就能驱动。失败场景是最可怕的——上电的一瞬间,你的程序还没跑,引脚已经按默认值把电平输出了。如果外部接着继电器、电机驱动、点火电路,一次”意外的默认输出”就是一次误动作,甚至事故。默认浮空 = 谁都不碰、谁也不驱动,把选择权留给你,是最安全的默认值。这就是文首那个坑的另一面:这个让新手抓狂的默认值,恰恰是保护老手电路的安全设计。
被否决的方案四:每个外设独占引脚,不做复用。 最”清晰”的方案,代价是物理现实:一个 64 脚的芯片封完电源、晶振、调试口,能用的 GPIO 就五十来根,UART+SPI+I2C+四路定时器+ADC 各自独占,要么引脚不够,要么芯片和封装变大变贵。AF 复用用寄存器换引脚数:一根引脚经选线器接到多个外设,代价是配置复杂——每次调外设都要想”这根引脚现在归谁”。“我的串口为什么不工作”,八成是忘了做这次交接。
一段顺手的历史
“开漏(open-drain)”这个名字里就藏着历史。上世纪 TTL 时代,可编程 I/O 想共享一根线,用的是集电极开路(open-collector)——三极管的集电极裸露在外,同样”只能拉低、靠外部上拉弹回高位”。等到 CMOS 流行,”集电极开路”演化成了”漏极开路”(MOS 管的漏极),只换了个名字,思路一模一样。而 I2C 这个总线,从 1982 年发明至今,一直要求开漏——四十多年前的设计决策,今天还钉在你每天用的每一块开发板上。
一个反直觉收尾
最后戳破一个直觉:“输入”不是”接通”,而是”全断开”。你多半以为配成输入就是把引脚”接进 CPU 里让 CPU 读”,实际上输入模式的电气含义是两个输出开关全部关断,引脚悬在高阻,读它靠的是内部施密特触发器拿引脚电压跟阈值比大小。顺带还有个小彩蛋:即使在输出模式,IDR 依然能读——它读的是引脚实际电平,不是你想写的值。如果一个输出引脚被外部短路拉低了,你”写的”是 1、”读的”却是 0——这个差异能帮你在线检测短路和负载故障。
动手环节:在 PC 上亲眼看”原子”和”浮空”
下面最重要的实验能在电脑上直接跑,因为你只要用浏览器打开 Compiler Explorer,选编译器 ARM GCC,编译选项填 -mcpu=cortex-m4 -O2。
把这段贴进去:
1
2
3
4
5
#define GPIOD_ODR (*(volatile unsigned int*)0x40020C14)
#define GPIOD_BSRR (*(volatile unsigned int*)0x40020C18)
void led_odr(void) { GPIOD_ODR |= 1u << 12; } /* 用 ODR 置位 PD12 */
void led_bsrr(void) { GPIOD_BSRR = 1u << 12; } /* 用 BSRR 置位 PD12 */
先预测再看答案。 编译之前先猜:两个函数各被翻译成几条指令?BSRR 版本少了什么?
答案(ARM GCC,-O2;寄存器名随编译器版本略有出入):
1
2
3
4
5
6
7
8
9
10
led_odr: ; 用 ODR:读-改-写
ldr r3, =0x40020C14
ldr r2, [r3] ; ① 读回旧值
orr r2, r2, #0x1000 ; ② 置位第 12 位
str r2, [r3] ; ③ 写回去
led_bsrr: ; 用 BSRR:不读旧值,直接写
ldr r3, =0x40020C18
movw r2, #0x1000 ; 装好要写的常量:置位第 12 位
str r2, [r3] ; 一次内存写,完事
两个版本指令数差不多,真正的差别是 ODR 版本多了一步读:读-改-写三步之间,任何中断都能插进来,一次更新可能被覆盖;BSRR 版本没有读,一条 str 一次写完,谁也插不进脚。这就是 L2 说”BSRR 天然原子”的铁证——你亲眼看到了编译器把它们编成了什么。
再做一个纸面实验,测你对”浮空”的理解。一个按键接 PD1 和 GND 之间,但忘了配 PUPDR(保持默认的浮空):
- 按下时,引脚被 GND 短路——读 0,这是确定的。
- 松开时呢?先预测:读 0 还是 1?
答案:不知道。浮空 = 没有上拉也没下拉,松开后引脚悬空,读到 1 还是 0 全看板子上的耦合噪声——可能稳定在某个值,也可能在你手指靠近时乱跳。这就是文首那个闹鬼的按键的真身:你没错,是没人给引脚一个确定的电平。配了内部上拉之后,松开稳定读 1,一切恢复正常。
三个自测问题,能答上说明这三层你真爬过了:
- 复位后所有 GPIO 引脚默认是什么模式?读它会读到什么?(答案在 L1/L2:浮空输入,电平不确定,像悬空的天线。)
- 推挽和开漏的本质区别是什么?为什么 I2C 必须用开漏?(答案在 L2/L3:推挽两个 MOS 都能出力,开漏只拉低、高电平靠外部上拉;多设备共享一根线,推挽会顶牛。)
- 同样”置位一个输出引脚”,为什么推荐 BSRR 而不是
ODR |=?(答案在 L2 + 动手环节:ODR 是读-改-写三条指令,中断可能插在中间丢更新;BSRR 一次写,天然原子。)
收尾地图
把整篇文章收进一张速查表:
| 你想干什么 | 怎么配 | 一句话理由 |
|---|---|---|
| 点灯 / 驱动负载 | MODER=输出,OTYPER=推挽 | 要强拉高,也要强拉低 |
| 读按键 / 开关 | MODER=输入(默认就是),PUPDR=上或下拉 | 给悬空一个确定的默认电平 |
| 接 ADC 采样 | MODER=模拟(11) | 绕过数字缓冲,模拟电压直接进采样器 |
| 用串口 / SPI / 定时器输出 | MODER=复用(10),AFRH/AFRL 选号 | 引脚让给片内外设,靠选线器交接 |
| 多个设备共享一根线 | OTYPER=开漏 + 外部上拉 | 线与:谁也硬拉高,谁也不顶牛 |
| 改输出、又怕被打断 | 用 BSRR,别用 ODR |= | 一次写,原子 |
| 长导线 / 强噪声 | 外接强上拉,别指望内部 40kΩ | 内部弱上拉只够定方向,不够抗干扰 |
| 按键要”立刻响应” | 换成 EXTI 外部中断 | 见《中断》,配置还是这篇的东西 |
下一站,三条路任选。往中断里走:把按键从轮询换成 EXTI,配置全在这篇,打断与回头在 《中断:CPU 的分心与回头》;配完记得按键要消抖,那是往定时器走的引子。往串口走:调 UART 之前先把引脚配成 AF 复用——AFR 就是你会卡住的那一页,调通后你会对”复用选线器”会心一笑。往模拟走:配 ADC 通道时把 MODER 设成 11(模拟),否则读到的永远是 0 或乱值。等把 GPIO、中断、定时器都用熟了,再回头看 《FreeRTOS 核心原理》 里的任务切换——你会发现,连操作系统在 MCU 上落脚的每一步,都踩在这片开关阵上。