
🔥铅笔小新z:个人主页
🎬博客专栏:stm32学习
💫滴水不绝,可穿石;步履不休,能至渊。

目录
- 本节课的目标
- [GPIO 宏观认识:它到底是什么](#GPIO 宏观认识:它到底是什么)
- [GPIO 引脚与 IO:看得见的部分](#GPIO 引脚与 IO:看得见的部分)
- [GPIO 端口位基本电路结构](#GPIO 端口位基本电路结构)
- [I/O 端口保护:钳位电路](#I/O 端口保护:钳位电路)
- [GPIO 的八种工作模式总览](#GPIO 的八种工作模式总览)
- 输出模式详解
- 输入模式详解
- [实战一:GPIO 输出点亮 LED](#实战一:GPIO 输出点亮 LED)
- [实战二:GPIO 输入按键检测](#实战二:GPIO 输入按键检测)
- 附录:必须补的基础知识
一、本节课的目标
学完这一节,你应当能回答这几个问题:
- 单片机内部导线上流动的信号,到底是什么?
- GPIO 模块在 MCU 内部是干什么的?
- GPIO 的八种输入输出模式分别是什么?各有什么特点?
- 通用推挽、通用开漏、复用推挽、复用开漏有什么区别?
- 上拉输入、下拉输入、浮空输入、模拟输入分别怎么用?
- 复用器、P-MOS/N-MOS、施密特触发器这些硬件单元是怎么工作的?
- 欧姆定律和上拉/下拉电阻到底是什么关系?钳位保护电路怎么保护芯片?
- 代码层面为什么要先开时钟?(为后续 RCC 章节做铺垫)
一句话总结本节使命:学会「指挥」芯片去「感知」外部世界发生了什么,并「操控」外部设备做出动作。
二、GPIO 宏观认识:它到底是什么

2.1 GPIO 是什么?
GPIO 是 General Purpose Input/Output 的简写,中文叫通用输入/输出。
字面理解:它是 MCU(单片机)内部一个专门负责输入输出 的控制器电路。所谓"输入输出",本质就一件事------输入输出高低电平。


2.2 输入输出的本质
我们分两半来看:
为什么要输出?
输出的本质是「控制」。MCU 把高低电平送到引脚上,外部设备按电压不同做出不同反应:
- 输出高电平,LED 会把它理解成「要求点亮我」;
- 输出高电平,电机驱动电路会把它理解成「电机正转」;
- 输出高电平,串口接收方会把它理解成「收到了一个 1」。
反过来,输出低电平,含义就反过来了。
为什么要输入?
单片机的核心作用是控制 ,而控制的前提是获取信息。MCU 需要知道:
- 开关有没有被按下?
- 传感器感受到数据变化了吗?
- 设备现在是休眠还是唤醒?
然后 MCU 根据读到的状态,再通过输出去控制外设、或者把状态报告给用户。
所以一句话:
输入输出的手段是「高低电平」,目的是「获取外设信息、控制外设」。
2.3 为什么叫"通用"输入输出?

"通用" = 一个东西发明出来,是给大家用的。
英语是世界"通用"语言------英国人用、美国人用、中国人也用。GPIO 也是这个道理:
MCU 内部以后会集成很多外设控制器(串口、I2C、SPI、定时器......)。这些外设控制器也要对外输出高低电平、控制外部设备。难道给每个外设控制器都单独焊一套「输入输出高低电平」的硬件电路吗?那复杂度、功耗、成本都会爆炸。
于是聪明的工程师想:GPIO 反正已经把输入输出高低电平这件事做完了,其他外设控制器直接用 GPIO 的电路不就行了?
这就是"通用"二字的来历:
GPIO 是所有片上外设共享的「输入输出通道」。
我们把「其他外设控制器直接使用 GPIO 硬件电路进行输入输出」的做法,叫做 复用 GPIO(后面的章节会细讲)。
2.4 MCU 与 CPU 的关系
这里先埋一个非常重要的观念:
MCU ≠ CPU
MCU(单片机)内部 = ARM-CPU 核心 + 一堆片上外设控制器。
GPIO 不是只给 CPU 用的,而是给所有片上设备提供 IO 能力的。这也是为什么 STM32 芯片上大部分引脚都是 GPIO 引脚,也是为什么要优先学习 GPIO。
💡 知识小结
问:什么是 GPIO?它在单片机里扮演什么角色?
答: GPIO 是 General Purpose Input/Output 的缩写,即"通用输入/输出",是 MCU 内部专门负责输入输出高低电平的控制器电路。它的本质工作是输入输出高低电平:输出高电平可以让 LED 点亮、电机正转;输入则用来感知外部状态,比如按键有没有按下、传感器有没有变化。所谓"通用",是指它并不只给 CPU 使用,而是给 MCU 内部所有片上外设控制器共享的 IO 通道------外设不需要自己重复造输入输出电路,直接复用 GPIO 即可,这样能大幅降低芯片的复杂度、功耗和成本。换句话说,GPIO 是 MCU 与外部世界交换信息的"通用接口"。
三、GPIO 引脚与 IO:看得见的部分
3.1 GPIO 的外部引脚
对 GPIO 最直观的认识,就是芯片外面那一圈金属针脚。
STM32F103ZET6 的引脚分布中,标为 PAx ~ PGx 的就是 GPIO 管脚(P = Port 端口,A~G 是端口名,x 是 0~15 的引脚号)。
几个概念先分清(其实是一个意思):
- 针脚 / 引脚 / 管脚:都是指 MCU 上那圈金属针脚,三个词等价。
- 可编程引脚 :STM32F103ZET6 一共有 112 个 可编程 GPIO 引脚,分为 PA、PB、PC、PD、PE、PF、PG 共 7 组,每组 16 个(0~15)。
3.2 MCU 外部视角:引脚怎么和外设关联?
引脚对外一般有两种工作方式。
方式一:输入(Input)------ 感知外部世界
例如外接一个按键:
- 按键没按下时,引脚接 VDD,GPIO 读到高电平;
- 按键按下时,引脚接通 GND,GPIO 读到低电平。
GPIO 就是靠引脚上电压的高低,来"感知"外部状态的变化。
方式二:输出(Output)------ 控制外部世界
例如外接一个小灯泡(LED):
- GPIO 输出低电平,灯泡左右产生压差、形成电流,灯亮;
- GPIO 输出高电平,灯泡两端电压一样、没有压差就没有电流,灯灭。
⚠️ 避免思维定式:不是所有外设都是"高电平有效"。比如上面这个接法,就是"低电平点亮",输出高电平反而点不亮。所以高电平不一定代表"有效",一切以电路为准。
一句话总结 GPIO 和外设的关系:
MCU 引脚外接各种外设(LED、按键、蜂鸣器、EEPROM......),MCU 内部有对应的输入/输出寄存器。向寄存器写数据,就能从引脚输出高低电平;从寄存器读数据,就能获取引脚当前的高低电平。
3.3 MCU 内部视角:GPIO 的拓扑结构
从芯片内部看,GPIO 是层状、多叉树型的组织结构:
ARM-CPU 核心
│ 数据总线
▼
GPIO 控制器(挂接在 APB2 桥上)
│
▼
GPIO Port(一组一组,如 GPIOA~GPIOG)
│
▼
GPIO Pin(每一个引脚,如 PA0、PB3)
│
▼
外部真实管脚(芯片针脚)
几个名词必须弄懂:
- Pin(引脚):单个引脚,如 PA0、PB3。
- Port(端口):一组引脚。为什么叫 Port 而不叫 Group?因为 Port(港口)在计算机领域本身就有"数据交换入口/出口"的含义,能体现"数据进出"的概念,而 Group 只体现"分组",体现不了数据交换。
- 关键细节 :
- 所有 GPIO 控制器都挂接在 APB2 桥设备上;
- STM32F103ZET6 一共 7 组端口:GPIOA ~ GPIOG;
- 每个 GPIOX 管理 0~15 共 16 个引脚;
- 管理方式是层状的、多叉树型结构。
3.4 端口位的概念
GPIO 端口位 = 端口中单个引脚的控制单元,例如 PA0、PB3。
⚠️ 最重要的一句话:
每一个引脚在 MCU 内部都对应一套完整的「端口位电路」,每个引脚都能独立进行输入输出。搞懂了一个引脚是怎么输入输出的,就搞懂了所有引脚。
💡 知识小结
问:GPIO 的 Pin 和 Port 是什么关系?GPIO 在 MCU 内部是怎么组织的?
答: GPIO 的 Pin 指单个引脚(如 PA0),Port 指一组引脚组成的端口(如 GPIOA)。STM32F103ZET6 共有 GPIOA~GPIOG 七组端口,每组管理 16 个引脚,全部挂接在 APB2 总线上。内部是"CPU 核心 → 数据总线 → GPIO 控制器 → GPIO Port → GPIO Pin → 外部管脚"的层状多叉树结构。每个引脚在芯片内部都有独立的端口位电路,可独立完成输入输出。之所以叫 Port(端口)而不是 Group,是因为 Port 在计算机领域有"数据交换出入口"的含义,能体现数据交换的本质。
四、GPIO 端口位基本电路结构

前面看的是"外形",现在钻进芯片内部,看一个引脚到底怎么完成输入输出。
STM32F103ZET6 每个 GPIO 端口位的基本电路,逻辑上分成两大部分:
- I/O 输入路径(上方)
- I/O 输出路径(下方)
👓 速记口诀:上 I 下 O(上面是 Input 输入,下面是 Output 输出)。
在深挖之前,先想清楚两个宏观问题:
- A. 输入的数据给谁了? → 给 MCU 内部所有想读它的单元。输入寄存器只是先存起来,谁需要谁读。
- B. 输出是谁在输出? → 不一定是 CPU!可能是 CPU,也可能是其他片上外设(通过复用器选择)。
这就引出 GPIO 设计的核心思想:
GPIO 不是只让 ARM-CPU 做 IO 的,而是让所有片上外设都能做 IO。这是 STM32 大部分引脚都是 GPIO 引脚的根本原因,也是我们要先学 GPIO 的原因------它为所有片上外设提供了 IO 能力。
好处:其他片上外设不用单独设计 IO 接口电路,直接复用 GPIO。硬件电路简单了 → 功耗低了 → 芯片便宜了 → 应用场景多了。
💡 知识小结
问:GPIO 端口位电路大致由哪几部分构成?为什么 GPIO 要设计成"通用"给所有外设用?
答: GPIO 端口位电路(每个引脚一套)逻辑上分为两部分:上方的输入路径和下方的输出路径,所以叫"上 I 下 O"。输入路径负责把外部引脚电平经过触发器整形后存进输入寄存器;输出路径负责把内部逻辑电平经过驱动电路后从引脚送出去。GPIO 设计成"通用"的原因在于:MCU 内部集成的大量片上外设(串口、I2C、SPI 等)都需要对外做输入输出,让每个外设都单独设计 IO 硬件电路会显著增加复杂度、功耗和成本,所以统一复用 GPIO 这条通道,既简化了芯片设计,又降低了成本。
五、I/O 端口保护:钳位电路
5.1 端口保护在哪里
每个引脚的最外面,贴着芯片边界的位置,有一个 I/O 保护电路 ,本质上是一个双二极管钳位电路。
5.2 二极管是什么
二极管是一种单向导电的电子元件:只允许电流从正极(阳极)流向负极(阴极),反向则阻断。
- 电压差形成电流,电流驱动二极管导通;
- 二极管导通压降一般在 0.6V ~ 0.7V 左右(二极管自身有电阻,会承担一部分电压)。
5.3 端口钳位保护原理
假设:VDD = 3.3V,VSS = 0V,二极管导通压降 = 0.7V。
保护电路的作用:
- 当输入电压 ≥ VDD + 0.7V 时,上路二极管导通,引脚电压被钳位在 VDD + 0.7V 左右,不再继续升高;
- 当输入电压 ≤ VSS − 0.7V 时,下路二极管导通,引脚电压被钳位在 VSS − 0.7V 左右,不再继续下降。
思考:数值具体是多少不重要,重要的是------
因为有了钳位保护二极管,就能有效防止不合理电压灌进引脚、形成大电流,从而击穿 MCU 内部元器件。这本质是一种保护电路。
什么时候会出现不符合预期的电压?最常见的就是静电。
5.4 静电与经验之谈
- 用手触碰 MCU 引脚,可能因静电导致引脚电压过大,此时保护电路就非常重要;
- 静电电压范围大约 ±2kV ~ ±15kV;
- 不要轻易用手去摸 MCU 针脚;
- 偶尔的超范围电压不一定击穿元器件,最怕的是持续的超范围电压;
- 电压的正负其实只是人为规定,真正重要的是压差。
💡 知识小结
问:GPIO 引脚上的钳位保护电路是什么?为什么需要它?
答: 引脚入口处有一个双二极管钳位保护电路。它由上下两个二极管构成:当输入电压高于 VDD 加二极管导通压降时,上二极管导通,把引脚电压钳位在高位;当输入电压低于 VSS 减导通压降时,下二极管导通,把引脚电压钳位在低位。它的作用是在静电、误接等异常电压进入引脚时,把电压限制在安全范围,避免形成大电流击穿芯片内部电路,本质上是一种硬件保护机制。
六、GPIO 的八种工作模式总览
GPIO 负责输入输出,而输入输出有多种工作模式:
输出模式(4 种)
| 推挽 | 开漏 | |
|---|---|---|
| 通用 | 通用推挽输出 | 通用开漏输出 |
| 复用 | 复用推挽输出 | 复用开漏输出 |
输入模式(4 种)
- 浮空输入
- 上拉输入
- 下拉输入
- 模拟输入
为什么 GPIO 需要这么多 I/O 模式?
- GPIO 不只是给 CPU 直接输入输出的,还会被其他片上外设复用,所以它必须够"通用";
- 其他外设什么时候用、怎么用完全不确定,所以 GPIO 得把输入输出功能做得多模式化,给外设更多选择;
- 打个比方:键盘不能只有游戏需要的 WASD 几个键,因为键盘还要给人编程、写文档、聊天用,键必须足够多才能覆盖各种场景。
一句话总结:GPIO 有这么多 I/O 模式,是为了满足不同片上外设在不同场景下的 IO 需求。
💡 知识小结
问:GPIO 有哪八种工作模式?为什么需要这么多模式?
答: GPIO 有四种输出模式和四种输入模式。输出模式包括通用推挽、通用开漏、复用推挽、复用开漏;输入模式包括浮空输入、上拉输入、下拉输入、模拟输入。之所以需要这么多模式,是因为 GPIO 是"大家"的------它不仅要给 CPU 做简单 IO,还要被各种片上外设在不同场景下复用;不同外设的驱动方式、电平需求、抗干扰要求各不相同,模式多,才能灵活匹配各种应用场景,这也是"通用"二字的体现。
七、输出模式详解
7.1 输出部分在哪里?
输出模式下,我们关注的是端口位电路中的输出路径(下 I/O 部分),也就是如何通过引脚把电平送出去。
7.2 通用 vs 复用
先弄清楚 4 种输出模式的命名含义:
- 通用(普通/通常) :指通常使用,即由 ARM-CPU 直接控制 GPIO 输出。
- 复用(重复使用) :指 MCU 内部的片上外设复用 GPIO 进行输入输出。
7.3 理解输出原理 ------ 三个关键硬件单元
为了讲清楚,我们把输出电路简化成一个 1 位版本(寄存器本来是多位的,这里为了好理解,先只画一位)。

简化为:

7.3.1 复用器(Multiplexer)
复用器是什么? 从多组输入信号中选择一个作为有效输入。
为什么要有复用器? GPIO 到底是被 CPU 直接使用,还是被其他片上外设复用,就由它说了算。它决定了"信号源"是谁。
7.3.2 输出控制电路
接收内部逻辑电平,直接驱动输出级 MOS 管的栅极,控制其通断,从而把弱信号转换为具有足够驱动能力的外部输出电平。
7.3.3 P-MOS 和 N-MOS(重点!)
MOS:金属氧化物半导体场效应晶体管(Metal-Oxide-Semiconductor Field-Effect Transistor,缩写 MOSFET,再简称 MOS)。
- P-MOS:P 沟道金属氧化物半导体。P = positive(积极的、正向的)。
- N-MOS:N 沟道金属氧化物半导体。N = negative(消极的、负面的、反向的)。

MOS 管的本质:一个由电压控制通断的开关。
MOS 管有三个极(记三个字母就够了):
| 符号 | 全称 | 含义 |
|---|---|---|
| S | Source(源极) | 载流子(电子/空穴)的来源 |
| D | Drain(漏极) | 载流子最终从这里流出器件 |
| G | Gate(栅极) | "门",开关的开启与关闭由它控制 |
未来接线方式:S 和 D 接 VCC 或 GND,G 接控制电路。真正的电流是在 S 和 D 之间流动的。

第一层理解(看电流方向命名):
- 被控制电流方向 S → D ,就是 P 型 MOS(positive,顺势而为);
- 被控制电流方向 D → S ,就是 N 型 MOS(negative,逆水行舟)。
注意:上面的图默认都是没有导通的,导通需要给 G 极施加电压(门槛电压 Vth,通常 0.3V~1.0V)。
第二层理解(看施加电压的"态度"):
-
P-MOS 导通:给 G 极施加的电压方向与电流方向"同向",是顺势而为,所以叫 P;

-
N-MOS 导通:给 G 极施加的电压方向与电流方向"相反",是逆流而上,所以叫 N。

结论(记住这两个):
P-MOS 和 N-MOS 的本质是开关。
为什么要用 MOS 管?通过小电压、控制开关的手段,达到控制大电流的目的。
GPIO 输出电路中的接法(重要):

- 上方是 P-MOS (S 接 VDD),下方是 N-MOS(S 接地);
- 两者的 D 极相接,共同连到外部引脚。
导通条件(了解即可,不必记):
- 对任意 MOS:导通与否取决于 S 与 G 之间的电压差是否超过阈值电压 Vth,与 D 无关;
- P-MOS:
Vs − Vg > Vth时导通; - N-MOS:
Vg − Vs > Vth时导通; - MOS 开关速度非常快,达 ns 级。
7.4 通用推挽输出(Push-Pull Output)
7.4.1 原理
核心思想:交替闭合或断开 P-MOS 和 N-MOS。
- 向输出数据寄存器写 1 :闭合 P-MOS、断开 N-MOS → 引脚接 VDD → 输出高电平;
- 向输出数据寄存器写 0 :断开 P-MOS、闭合 N-MOS → 引脚接地 → 输出低电平。
为什么叫"推挽"?
使用一对互补的晶体管(CMOS 工艺的 P-MOS 和 N-MOS)来输出:
- 输出高电平:P-MOS 导通,"推"(提供电流);
- 输出低电平:N-MOS 导通,"挽"(吸收/拉取电流)。
本质就是交替闭合两个开关,让外部引脚接入 VDD 或接地,对外呈现高低电压,形成输出电流(推)和吸收电流(挽)的效果。
⚠️ 致命注意 :如果 P-MOS 和 N-MOS 同时导通 ,VDD 会和 GND 直接接通,短路烧毁电路 !因此"输出控制"单元必须保证两个 MOS 管永远不能同时导通。
7.4.2 推挽输出的完整过程
- CPU 执行用户代码,向输出数据寄存器写 1;
- 复用器选择信号源为数据寄存器;
- 输出控制器把 1 解释为:给 P-MOS 施加电压,N-MOS 不做动作;
- P-MOS 感受到电压导通;
- 外部引脚接入 VDD,对外输出高电平;
- 写 0 同理,最终 N-MOS 导通,引脚接地,输出低电平。
写 1/0 的本质:是让输出控制器选择打开 P-MOS 还是 N-MOS,从而决定引脚输出高电平还是低电平。
7.4.3 推挽输出的特点
- 引脚要么接 VDD 要么接地,状态确定,驱动能力强;
- 可以同时提供(推)和吸收(挽)电流;
- 大多数数字输出场景的首选。
💡 知识小结
问:什么是通用推挽输出?它的工作原理是什么?
答: 通用推挽输出是 GPIO 的一种输出模式,由 CPU 直接控制。电路上由上方接 VDD 的 P-MOS 和下方接地的 N-MOS 构成,两者漏极相连后接到引脚。原理是交替开关两个 MOS 管:向输出数据寄存器写 1 时闭合 P-MOS、断开 N-MOS,引脚接 VDD 输出高电平,由 P-MOS"推"出电流;写 0 时断开 P-MOS、闭合 N-MOS,引脚接地输出低电平,由 N-MOS"挽"入电流。所以叫"推挽"。关键在于两个 MOS 管不能同时导通,否则会短路烧毁电路,因此输出控制单元必须保证它们互斥导通。推挽输出电平状态确定、驱动能力强,是最常用的输出模式。
7.5 通用开漏输出(Open-Drain Output)
7.5.1 原理
核心思想:去掉 P-MOS,让 P-MOS 永远关闭。
开漏模式下,MCU 对特定引脚的输出,本质就是该引脚对应 N-MOS 的漏极输出。
结论(直接记):
- 向输出数据寄存器写 1 → 表示 N-MOS 断开;
- 向输出数据寄存器写 0 → 表示 N-MOS 闭合。
于是有两种状态:
- 写 0,N-MOS 闭合 → 引脚接地 → 输出低电平;
- 写 1,N-MOS 断开 → 引脚与内部电路"断开" → 输出高阻抗(高阻态)。
什么叫高阻抗?
MCU 和外部引脚之间彻底断开,相当于开路,或者说 N-MOS 的 D 极和外部引脚互相"悬空"。从引脚看进去,MCU 相当于一个阻值无穷大的设备,这种情况就叫输出高阻抗。
开漏模式只可能输出低电平和高阻抗,本身无法输出高电平。
为什么叫"开漏"?
开漏的全称是漏极开路输出。因为没有 P-MOS,N-MOS 的 D 漏极相当于什么都没接("开路"了),所以叫"漏极开路",简称"开漏"。
理解要点:
- 不要把引脚当设备,要把引脚当成 D 漏极这条"线"的延伸;
- 推挽输出下 P-MOS 和 N-MOS 互为对方 D 极接入的设备,所以不属于开漏。
7.5.2 开漏模式如何输出高电平?
开漏只能输出低电平和高阻态,那想要高电平怎么办?
通过外部上拉电阻把引脚拉高。

因为 MCU 内部电路已经设计好了,这个上拉的电压通常需要开发板设计者在 MCU 外部根据自己的需求接上去。
加上上拉电阻之后:
- 写 0,N-MOS 闭合 → 引脚接地 → 输出低电平;
- 写 1,N-MOS 断开 → 引脚高阻 → 在外部上拉电阻作用下 → 输出高电平。
7.5.3 为什么要上拉电阻?
开漏输出不仅考虑 N-MOS 断开的高阻抗问题,还要考虑 N-MOS 闭合接地的情况:
- 没有上拉电阻:N-MOS 闭合时引脚接地没问题,但 N-MOS 断开时引脚悬空,电平不确定;
- 设置上拉电阻:断开时引脚被拉高,闭合时引脚被拉低,两个状态都确定。
所以上拉电阻保证了开漏输出的两个状态都"确定"。
7.5.4 为什么叫"上拉电阻"?
在开漏输出 + 外部上拉电阻的情况下:
- N-MOS 断开,MCU 输出高阻抗,引脚输出外部上拉电压 3.3V → 高电平;
- N-MOS 闭合,引脚接地 → 低电平;
- 开漏模式下 MCU 默认只能输出低电平和高阻抗;
- 外部接上电阻 + 高电压,在保证电路安全的前提下,让开漏成功输出高低电平。
因为高电平是被外部电阻 + 高电压"拉"起来的,这个电阻就叫上拉电阻。
上拉电阻阻值一般取 4.7kΩ ~ 10kΩ。
💡 知识小结
问:什么是开漏输出?为什么开漏输出接上拉电阻后才能输出高电平?
答: 开漏输出又叫漏极开路输出,它去掉了 P-MOS,只保留接地的 N-MOS。向输出寄存器写 0 时 N-MOS 闭合,引脚接地输出低电平;写 1 时 N-MOS 断开,引脚呈高阻抗(相当于内部电路与引脚断开,阻值无穷大),此时无法输出高电平。为了让引脚在高阻态时有一个确定的高电平,需要在引脚外部接一个上拉电阻接到电源:当 N-MOS 断开时,回路电流近似为 0,上拉电阻几乎不承担压差,根据欧姆定律引脚电压就等于上拉电源电压(如 3.3V),从而输出高电平。开漏输出的意义在于:可以利用外部上拉电压灵活匹配不同电平的外设(如 5V 设备),也常用于"线与"等多设备共享场景。
7.6 复用推挽输出 & 复用开漏输出
- 复用推挽输出:片上外设(如串口 TX)通过复用器选中信号源,使用推挽结构输出;
- 复用开漏输出:片上外设通过复用器选中信号源,使用开漏结构输出。
秒懂:唯一区别是------
普通模式:信号来自输出数据寄存器 (CPU 控制);
复用模式:信号来自片上外设(复用器切换信号源)。
具体用哪种复用模式,等学到串口、I2C、SPI 等外设时再逐步理解即可。
💡 知识小结
问:通用模式和复用模式有什么区别?
答: 通用模式指信号由 ARM-CPU 通过输出数据寄存器直接控制,也就是 CPU 直接写寄存器来输出高低电平,适用于普通的开关控制;复用模式指 GPIO 的输出信号源被切换为某个片上外设控制器(如串口、I2C、SPI 等),由外设驱动 GPIO 输出。这两种模式都可以配合推挽或开漏两种电路结构,组合出通用推挽、通用开漏、复用推挽、复用开漏四种输出模式。选择哪种,取决于该引脚当前由谁使用、需要什么驱动方式。
八、输入模式详解
8.1 输入部分在哪里?
输入模式下,我们关注端口位电路中的输入路径(上方 I/O 部分)。
注意:中文参考手册里叫"肖特基触发器",英文其实叫施密特触发器(Schmitt Trigger),这是中文手册的翻译错误,我们以"施密特触发器"为准。
8.2 输入原理的三个关键单元

8.2.1 上拉电阻与下拉电阻
- 上拉电阻 :为特定电路提供缺省高电压;
- 下拉电阻 :为特定电路提供缺省低电压。
⚠️ 注意位置区别:
- 输入这里的上拉/下拉电阻是 MCU 内部、GPIO 模块自带的;
- 输出开漏那里的上拉电阻是外部硬件工程师设计的;
- 位置不同、开发者不同,但功能类似。
8.2.2 TTL 施密特触发器
现实世界的信息分两类:
- 数字信息:状态有限的、可量化的信息。如电梯门有没有关好、安全带系没系、门铃有没有被按下。
- 模拟信息:连续可变的、可取无穷多个值的信息。如洗澡水冷热、手机剩余电量、音量大小、风力大小。
计算机/单片机要为人服务,就必须能处理这两类信息(这就是大学《数字电路》和《模拟电路》两门课的由来)。模拟信息暂时不讨论,本节专注数字信息。
施密特触发器是处理输入信号的一个硬件单元,它的核心作用:
抗噪整形:把抖动、毛刺、斜坡等"不可靠"的输入波形,整形成规则的方波。
方波在硬件上是方波,在软件上就是 0/1,本质是数字信息。
为什么需要它?
外部输入信号可能不干净(有抖动、毛刺)。如果没有施密特触发器,输入信息可能会在 0 和 1 之间反复"横跳",导致读到错误的电平。
它怎么工作?
施密特触发器内部有整形模块,规定两个阈值:
- 正向阈值电压 VT+:输入必须超过它,才触发"高电平";
- 负向阈值电压 VT−:输入必须低于它,才返回"低电平";
- VT+ 和 VT− 之间构成一个迟滞区域。
迟滞区域的作用:要求 0/1 跳变必须"穿越整个区域"才能发生,而不是在临界值附近来回跳,从而防止微小波动导致输出频繁跳变,增强系统稳定性。
如果输入就在阈值附近来回跳呢?
- 高于 VT+:判定高电平;
- 低于 VT−:判定低电平;
- 在迟滞区域内跳变:输出保持上次状态不变,避免误跳变。
结论:如果输入在阈值附近来回跳,施密特触发器会维持最近状态不变,从而剔除毛刺。这一切都是在触发器内部自动完成的,使用者不需要关心。
8.3 上拉输入(Pull-up Input)
8.3.1 上拉输入是什么?
上拉输入就是 MCU 内部配置了上拉电阻。本质是:
闭合上拉电阻的开关,断开下拉电阻的开关。
(这些开关在 MCU 内部,通过配置寄存器来选择闭合或断开。)

8.3.2 上拉输入的过程

- 引脚输入高电平 → GPIO 收到高电平 → 输入数据寄存器读到 1;
- 引脚输入低电平 → GPIO 收到低电平 → 输入数据寄存器读到 0;
- 引脚悬空(没接任何设备) → 在上拉电阻拉动下 → 输入数据寄存器默认读到 1。
为什么上拉输入需要上拉电阻?
提供输入确定性,让缺省输入是高电平(1)。
因为有了缺省值,无论引脚是什么情况,输入数据寄存器里的数据都是确定的。
❓ 一个灵魂问题:上拉输入的时候,如果外部输入低电平,上拉电阻不会把电平拉高吗?
答:不会。本质是"谁更"强"的问题"。
8.4 下拉输入(Pull-down Input)
8.4.1 下拉输入是什么?
下拉输入就是 MCU 内部配置了下拉电阻。本质是:
闭合下拉电阻的开关,断开上拉电阻的开关。

8.4.2 下拉输入的过程

- 引脚输入高电平 → 输入数据寄存器读到 1;
- 引脚输入低电平 → 输入数据寄存器读到 0;
- 引脚悬空 → 在下拉电阻拉动下 → 输入数据寄存器默认读到 0。
为什么下拉输入需要下拉电阻?
提供输入确定性,让缺省输入是低电平(0)。
❓ 灵魂问题:下拉输入的时候,如果外部输入高电平,下拉电阻不会把电平拉低吗?
答:不会。本质同样是"谁更强"的问题。
8.5 输入模式对照表
| 输入模式 | 引脚高电平 | 引脚低电平 | 引脚悬空(默认输入值) |
|---|---|---|---|
| 上拉输入 | 1 | 0 | 1 |
| 下拉输入 | 1 | 0 | 0 |
| 浮空输入 | 1 | 0 | 不确定 |
8.6 两个根本问题:上拉/下拉输入时,外部电平为何不被"对抗"掉?
前面抛了两个问题:
- 上拉输入 + 外部输入低电平,上拉电阻不会把电平拉高吗?
- 下拉输入 + 外部输入高电平,下拉电阻不会把电平拉低吗?
这两个问题本质是同一个问题:内部电阻和外部信号"对抗",为什么内部电阻赢不了?
要理解,需要知道两点:
- MCU 内部的上下拉电阻阻值很大 ,属于高阻值器件,一般高达 30kΩ ~ 50kΩ;
- 欧姆定律 V = I × R 算的是设备承担的电压差(分压),不是绝对电压。
解决问题一:上拉输入 + 外部输入低电平
"输入低电平"在内部可以理解为:外部把引脚直接接到地(GND)。于是内部上拉电阻和外部接地形成一个通路:
- 引脚是导线,电阻≈0,不承压;
- 这个回路中唯一需要承压的器件就是上拉电阻;
- 上拉电阻两端压差就是 VDD,整个 VDD 都压在上拉电阻上;
- 所以引脚处(接地点)电压 = 0V ,是低电平,不受上拉电阻影响。
解决问题二:下拉输入 + 外部输入高电平
"输入高电平"在内部可以理解为:外部把引脚直接接到 VDD。于是内部下拉电阻和外部电源形成通路:
- 整个 VDD 都压在下拉电阻上;
- 引脚处(接电源处)电压 = VDD ,是高电平,不受下拉电阻影响。
本质:外部信号是"强"的(直接接电源或地,内阻很小),内部上下拉电阻是"弱"的(几十 kΩ 高阻值),所以外部信号说了算。
8.7 弱上拉弱下拉
MCU 内部的上下拉电阻为什么叫"弱"?
因为阻值较大(通常几十 kΩ),提供的电流非常小,对外部信号的控制力比较"弱"------它只负责给悬空引脚一个缺省电平,一旦外部有强信号接入,就由外部信号决定电平。
8.8 浮空输入(Floating Input)
8.8.1 浮空输入是什么?
浮空输入就是 MCU 内部上下拉电阻全部断开。本质是:
断开下拉电阻开关,也断开上拉电阻开关。

8.8.2 浮空输入的过程(三种情况)
- 引脚输入高电平 → 读到 1;
- 引脚输入低电平 → 读到 0;
- 引脚悬空 → 引脚像一根"天线",接收外部电磁干扰 → 读到的高低电平不确定。
悬空时为什么电平不确定?
电源线、人体、手机、电机、开关电源、甚至手靠近导线,都会产生交变电场;附近的 WiFi、蓝牙、对讲机、电机、继电器会辐射高频电磁波。浮空输入时引脚断开,就像一根接收天线,把这些干扰信号"接收"进来,经过触发器后变成随机的高低电平。
所以:浮空输入只有在外部信号始终确定的情况下才安全,悬空时电平不确定,容易受干扰。
8.9 模拟输入(Analog Input)
8.9.1 模拟输入是什么?

看输入电路结构图,模拟输入的物理电路等效于一根导线:
- 它没有用施密特触发器,不做信号整形,要的就是原汁原味;
- 引脚外部是什么,模拟输入就获取什么。
模拟输入理解第一层:引脚外部是什么,模拟输入就获取什么------它不关心 0/1,它把电压原样传给内部。
8.9.2 模拟信号与处理模块
- 模拟信号:在时间与幅度上均连续变化的物理量,物理世界的本质是连续的。
- 数字信号:在时间与幅度上均离散化的信号,把连续物理量量化成离散数据。
模拟输入获取的连续电压,最终会交给 MCU 内部专门处理模拟信号的片上外设,最常见的就是:
- ADC(模数转换器):把连续电压转成数字量;
- DAC(数模转换器):把数字量转成连续电压。
(ADC、DAC 会在后面的章节展开学习,这里先有个印象。)
💡 知识小结(输入模式合集)
问:GPIO 的四种输入模式分别是什么?各自的缺省值是什么?
答: 四种输入模式是浮空输入、上拉输入、下拉输入和模拟输入。上拉输入内部接上拉电阻,断开下拉电阻,引脚悬空时默认读到高电平 1;下拉输入内部接下拉电阻,断开上拉电阻,引脚悬空时默认读到低电平 0;浮空输入上下拉电阻都断开,引脚悬空时电平不确定,容易受电磁干扰;模拟输入不使用施密特触发器,引脚电压原样送入内部,交给 ADC/DAC 等模拟外设处理。上拉、下拉输入的主要价值在于给引脚提供确定的缺省电平(输入确定性),浮空输入必须保证外部信号确定时才安全。
问:上拉输入时外部输入低电平、下拉输入时外部输入高电平,为什么内部电阻不会把电平"带跑"?
答: 因为内部上下拉电阻是高阻值器件(通常 30kΩ~50kΩ),提供的电流非常小,属于"弱"上拉/下拉。当外部输入低电平时,等效于外部直接把引脚接到地,整个 VDD 压差都承担在内部上拉电阻上,引脚处电压就是 0V;当外部输入高电平时,等效于外部直接把引脚接到 VDD,整个 VDD 都压在下拉电阻上,引脚处电压就是 VDD。也就是说,外部强信号(直接接电源或地)与内部弱电阻形成回路后,引脚电压由外部信号决定,内部电阻只是给悬空状态提供缺省电平。
问:施密特触发器在 GPIO 输入中的作用是什么?
答: 施密特触发器是输入路径上的整形单元,核心作用是抗噪整形:把外部抖动、毛刺、斜坡等不干净的输入波形整形成规则的方波(软件上即稳定的 0/1)。它内部规定了正向阈值 VT+ 和负向阈值 VT−,两者之间构成迟滞区域:输入必须越过 VT+ 才触发高电平、低于 VT− 才回到低电平,在迟滞区域内跳变时输出保持上次状态不变。这样就把数字信息的 0/1 判定"锁死",防止微小波动造成误判。模拟输入不使用施密特触发器,以保留原始模拟信号。
九、实战一:GPIO 输出点亮 LED
9.1 GPIO 的寄存器清单
GPIOx 除了输出寄存器,还有其他寄存器。通过配置这些寄存器,就能完成对 GPIOx 的配置------就像给洗衣机、空调设定参数,电器就按预设参数工作。
GPIOx 相关的寄存器(HAL 结构体定义):
c
typedef struct
{
__IO uint32_t CRL; /* 配置寄存器低位:配置 PIN0~PIN7 的模式/速度 */
__IO uint32_t CRH; /* 配置寄存器高位:配置 PIN8~PIN15 的模式/速度 */
__IO uint32_t IDR; /* 输入数据寄存器:读引脚当前电平(只读) */
__IO uint32_t ODR; /* 输出数据寄存器:写引脚输出电平 */
__IO uint32_t BSRR; /* 位设置/清除寄存器:一次性设置或清除指定引脚 */
__IO uint32_t BRR; /* 位清除寄存器:清除(清零)指定引脚 */
__IO uint32_t LCKR; /* 端口配置锁定寄存器:锁定配置防止被意外修改 */
} GPIO_TypeDef;
9.2 LED 硬件设计
开发板上 STM32 芯片与 LED 的连接关系:
- LED 在 PF8、PF9、PF10、PF11 上;
- 由于板上外接电压的设计,可以使用通用开漏输出 模式,也可以使用通用推挽输出模式。
(开漏模式下,输出 0 时 N-MOS 闭合形成回路点亮 LED;默认输出 1 时灯熄灭。)
9.3 软件设计
首次点灯,我们用单文件实现三个效果:
- 单灯点亮
- 单灯闪烁
- 跑马灯效果(LED 从左到右、再从右到左循环跑)
9.4 STM32CubeMX 配置要点
-
开启调试(非常重要!) :开发板预留的调试接口是 SWD (Serial Wire Debug,串行线调试)。在 Project Manager 中选择 "Serial Wire"。如果不改这里,保持默认的 No Debug,第一次烧录成功,后续烧录就会失败!
顺便分清概念:DAP Link 是调试器硬件,SWD 是调试通信协议。打个比方:DAP 是考试坐在座位上的同学本人,SWD 是传纸条的通道 + 约定好的暗号。
-
开启外部时钟源(了解):选择 "Crystal / Ceramic Resonator",表示用外部晶振做 HSE。
-
配置时钟树(了解) :开发板是 8MHz HSE 晶振 ,选通 8MHz 作为时钟源,把系统主频配到最大 72MHz。
-
开启引脚 GPIO 输出:
- GPIO output level:引脚初始电平(也就是输出寄存器的初始值);
- GPIO mode:Output Push Pull(推挽输出)或开漏输出;
- GPIO Pull-up/Pull-down:是否上拉/下拉;
- Maximum output speed:输出速度(2MHz / 10MHz / 50MHz);
- User Label:用户标签,给引脚起个"有意义的名字"方便阅读。
-
项目工具链 :Tool Chain 选 MDK-ARM (就是 Keil 这一套工具)。STM32CubeMX 只负责生成工程,开发代码、编译、烧录、调试仍由 Keil 完成。
-
最后点击 Generate Code 生成完整工程。
9.5 认识工程目录结构
CubeMX 生成的工程主要包含:
Application/MDK-ARM/:启动文件startup_stm32f103xe.s(汇编实现,已自动生成,不用手动拷贝);Application/User/Core/:main.c(入口)、stm32f1xx_it.c(中断处理)、stm32f1xx_hal_msp.c(硬件层初始化);Drivers/STM32F1xx_HAL_Driver/:HAL 库源码。CubeMX 会根据当前配置的外设,自动拷贝对应源码,未用到的不会参与编译,控制程序体积;Drivers/CMSIS/:CMSIS 源码。HAL 库会调用 CMSIS 完成对 CPU 核心的操作。
9.6 跑马灯代码实现(带注释)
c
#include "main.h"
/* ===== 用户私有变量区 ===== */
uint16_t leds[] = {GPIO_PIN_8, GPIO_PIN_9, GPIO_PIN_10, GPIO_PIN_11}; // 4 个 LED 对应的引脚号
#define LED_NUM (sizeof(leds) / sizeof(leds[0])) // 计算 LED 数量,自动得出 4
/* ===== 主函数 ===== */
int main(void)
{
HAL_Init(); // 复位所有外设,初始化 Flash 接口和 SysTick(系统滴答定时器)
SystemClock_Config();// 配置系统时钟(8MHz HSE 倍频到 72MHz)
MX_GPIO_Init(); // 初始化 GPIO(CubeMX 生成的配置代码)
// 先手动把所有 LED 熄灭
// 开漏模式下:GPIO_PIN_SET(1) 让 N-MOS 断开 → 灯灭;GPIO_PIN_RESET(0) 让 N-MOS 闭合 → 灯亮
for (int i = 0; i < LED_NUM; i++)
{
HAL_GPIO_WritePin(GPIOF, leds[i], GPIO_PIN_SET); // 写 1,熄灭 LED
}
while (1) // 主循环:单片机裸机程序永远在这里循环运行
{
// 第一轮:从左向右点亮,模拟"从左跑到右"
for (int i = 0; i < LED_NUM; i++)
{
HAL_GPIO_WritePin(GPIOF, leds[i], GPIO_PIN_RESET); // 写 0,点亮当前 LED
HAL_Delay(200); // 等待 200ms,让效果肉眼可见
HAL_GPIO_WritePin(GPIOF, leds[i], GPIO_PIN_SET); // 写 1,熄灭当前 LED
}
// 第二轮:从右向左点亮,模拟"从右跑到左"
for (int i = LED_NUM - 1; i >= 0; i--)
{
HAL_GPIO_WritePin(GPIOF, leds[i], GPIO_PIN_RESET); // 写 0,点亮当前 LED
HAL_Delay(200); // 等待 200ms
HAL_GPIO_WritePin(GPIOF, leds[i], GPIO_PIN_SET); // 写 1,熄灭当前 LED
}
}
}
⚠️ CubeMX 代码保护区的使用习惯 :生成的代码里有很多形如
USER CODE BEGIN ...和USER CODE END ...的注释标记。你自己写的代码一定要放在 BEGIN 和 END 之间,否则下次重新生成工程,写在注释区之外的代码会被直接覆盖丢失!
9.7 GPIO 源码分析:地址与寄存器
为什么 GPIOF 能当普通变量用?------统一编址。
ARM 体系采用统一编址:外设寄存器和内存共用一套地址空间,GPIO 寄存器被映射到固定物理地址,CPU 可以像访问内存一样直接读写这些地址。
c
#define PERIPH_BASE 0x40000000UL /* 外设基地址(别名区) */
#define APB2PERIPH_BASE (PERIPH_BASE + 0x00010000UL) /* APB2 总线外设基地址 */
#define GPIOF_BASE (APB2PERIPH_BASE + 0x00001C00UL) /* GPIOF 的基地址 */
#define GPIOF ((GPIO_TypeDef *)GPIOF_BASE) /* 把地址强转成结构体指针 */
所以 GPIOF->ODR 本质上就是直接访问 GPIOF 这个外设内部偏移某处的物理地址。访问 GPIOx 就是通过物理地址直接访问它内部的寄存器。
9.8 GPIO 引脚号的本质:位图
c
#define GPIO_PIN_0 ((uint16_t)0x0001) /* 第 0 位为 1:0000 0000 0000 0001 */
#define GPIO_PIN_1 ((uint16_t)0x0002) /* 第 1 位为 1:0000 0000 0000 0010 */
#define GPIO_PIN_8 ((uint16_t)0x0100) /* 第 8 位为 1:0000 0001 0000 0000 */
#define GPIO_PIN_9 ((uint16_t)0x0200)
#define GPIO_PIN_10 ((uint16_t)0x0400)
#define GPIO_PIN_11 ((uint16_t)0x0800)
#define GPIO_PIN_All ((uint16_t)0xFFFF) /* 所有引脚 */
引脚号的本质是"只有一个比特位是 1"的位图------第几个比特位,就表示几号引脚。
因为每一位只代表一个引脚,所以可以用按位或(|)一次性选中多个引脚:
c
// 同时选中 PF8、PF9、PF10、PF11 这 4 个引脚
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10 | GPIO_PIN_11, GPIO_PIN_RESET);
9.9 GPIO 初始化源码:设置控制寄存器
CubeMX 生成的 MX_GPIO_Init() 本质就是设置 CRL/CRH 等控制寄存器:
c
static void MX_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0}; // 定义一个 GPIO 配置结构体并清零
/* 1. 打开 GPIO 端口的时钟(重要!先开时钟才能用外设) */
__HAL_RCC_GPIOF_CLK_ENABLE();
__HAL_RCC_GPIOA_CLK_ENABLE();
/* 2. 设置引脚初始输出电平:先输出低电平(开漏模式下 = 点亮 LED) */
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10 | GPIO_PIN_11, GPIO_PIN_RESET);
/* 3. 配置引脚模式:PF8~PF11 开漏输出、无上下拉、低速 */
GPIO_InitStruct.Pin = GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10 | GPIO_PIN_11; // 要配置哪些引脚
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; // 通用开漏输出
GPIO_InitStruct.Pull = GPIO_NOPULL; // 不使能上拉/下拉
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; // 输出速度:低速(2MHz)
HAL_GPIO_Init(GPIOF, &GPIO_InitStruct); // 应用配置到寄存器
}
GPIO_InitTypeDef 结构体就是用来描述"怎么配"的:
c
typedef struct
{
uint32_t Pin; /* 要配置的引脚(位图,可按位或组合) */
uint32_t Mode; /* 工作模式:输入/输出/复用/模拟/上下拉等 */
uint32_t Pull; /* 上拉/下拉选择:NOPULL / PULLUP / PULLDOWN */
uint32_t Speed; /* 输出速度:LOW / MEDIUM / HIGH */
} GPIO_InitTypeDef;
在 HAL_GPIO_Init() 内部,核心是读-改-写 CRL/CRH 寄存器,把对应引脚的 CNF(配置)和 MODE(模式)位设置好:
c
/* 读-改-写宏定义:先清掉指定比特位,再设置新值 */
#define WRITE_REG(REG, VAL) ((REG) = (VAL))
#define READ_REG(REG) ((REG))
#define MODIFY_REG(REG, CLEARMASK, SETMASK) \
WRITE_REG((REG), (((READ_REG(REG)) & (~(CLEARMASK))) | (SETMASK)))
一句话:初始化 GPIO = 打开时钟 + 设置 CRL/CRH 控制寄存器(模式/速度)+ 设置初始电平。
9.10 GPIO 写入输出:为什么用 BSRR 而不是直接改 ODR?
c
void HAL_GPIO_WritePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin, GPIO_PinState PinState)
{
if (PinState != GPIO_PIN_RESET) // 要输出高电平(置位)
{
GPIOx->BSRR = GPIO_Pin; // 写入 BSRR 的低 16 位:对应位为 1 则置位该引脚
}
else // 要输出低电平(清零)
{
GPIOx->BSRR = (uint32_t)GPIO_Pin << 16u; // 写入 BSRR 的高 16 位:对应位为 1 则清零该引脚
}
}
BSRR 寄存器:Bit Set/Reset Register,位设置/清除寄存器。它有 32 位:
- 低 16 位(bit0~bit15):写 1 置位对应引脚,写 0 忽略;
- 高 16 位(bit16~bit31):写 1 清零对应引脚,写 0 忽略。
为什么用 BSRR 而不是直接改 ODR?核心理由:
直接写 ODR 是"读-改-写"流程,在多引脚操作时可能把其他引脚的状态搞乱(并发不安全);而 BSRR 是"硬件原子操作",想置位就把 1 写到低 16 位对应位,想清零就把 1 写到高 16 位对应位,只影响目标引脚,不影响其他引脚,而且不用先读再写,更快更安全。
9.11 GPIO 输出速度是什么?
一个问题:GPIO 输出要么 0V 要么 3.3V,切换不就一瞬间的事吗?为什么还要配置速度(2/10/50MHz)?
结论(理解层面即可):
- 电平变化是需要时间的,不是陡峭的瞬间跳变,波形是有倾斜角度的;
- 极端情况下速度配置不当,波形可能变成"尖峰"形状,导致接收端获取信息失败,所以速度不能无限提升;
- 设置速度等级,是在保证正确输出信息的前提下,用不同等级匹配不同应用场景(普通 LED 用低速即可,高速通信才需要高速)。
💡 知识小结
问:如何用 HAL 库点亮一个 LED?配置 GPIO 输出的步骤是什么?
答: 点亮 LED 的步骤是:先在 CubeMX 里使能调试接口(SWD)、配置时钟(8MHz 外部晶振,系统主频 72MHz)、把 LED 引脚配置为推挽或开漏输出并设定初始电平,然后生成工程。在代码层面,初始化 GPIO 的三要素是:打开对应端口的时钟(__HAL_RCC_GPIOx_CLK_ENABLE())、配置引脚的模式/速度/上下拉(HAL_GPIO_Init)、设置初始输出电平(HAL_GPIO_WritePin)。之后在 main 的 while 循环里反复调用 HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8, GPIO_PIN_RESET/SET) 并配合 HAL_Delay 就能实现点亮、熄灭、闪烁和跑马灯效果。
问:为什么 GPIO 初始化之前要先打开时钟(使能 RCC)?
答: 因为 STM32 的绝大多数外设时钟默认是关闭的,处于"不供电、不工作"的省电状态。外设要想工作,必须先通过 RCC(复位和时钟控制)模块把对应外设的时钟使能,外设的寄存器才能真正被读写。如果不先开时钟就直接操作 GPIO 寄存器,寄存器根本无效。所以"开时钟"是使用任何片上外设的第一步。
问:HAL_GPIO_WritePin 为什么通过 BSRR 寄存器写引脚,而不是直接操作 ODR?
答: 因为 BSRR 是"位设置/清除寄存器",向低 16 位对应位写 1 会置位该引脚,向高 16 位对应位写 1 会清零该引脚,写 0 的位不受影响。这种"只影响目标引脚、不影响其他引脚"的写入是硬件原子操作,不需要先读后写。而直接修改 ODR 需要读-改-写流程,如果同时操作多个引脚,可能因读旧值覆盖新值而影响其他引脚的状态,且效率更低。所以用 BSRR 更安全、更快。
十、实战二:GPIO 输入按键检测
10.1 按键硬件设计(以灵犀版为例)
开发板上有 4 个按键:SYS_SW1、SYS_SW2、SYS_SW3、SYS_SW4(SW = Switch,开关、按键),型号 TS-1187A-B-A-B,是贴片轻触开关。
连接方式(4 个按键完全一样):
- 按键没按下 时:按键断开,引脚被外部上拉到高电平 → GPIO 读到高电平;
- 按键按下 时:按键导通,引脚连接到地 → GPIO 读到低电平。
因此,只要判断按键检测引脚的输入电平高低,就能判断按键状态:读到低电平 = 按下,读到高电平 = 松开。
10.2 按键消抖
10.2.1 问题:毛刺
理想情况下,按键波形是干净的高低电平跳变。但实际情况是,按下 和松开的瞬间都会产生"毛刺":
- 由于手的抖动、按键触点的接触抖动,会产生抖动"毛刺";
- 按键抖动时间一般是 5ms~20ms,常见 5~10ms,工程上一般按 20ms 的"最坏情况"设计消抖。
问:不是有施密特触发器吗?为什么还要消抖?
施密特触发器解决的是"信号不干净"的问题(抗噪整形),而不是"信号乱跳"的问题。按键抖动可能穿越施密特触发器的迟滞区,MCU 内部的施密特触发器解决不了按键抖动,需要额外消抖。
10.2.2 方案一:硬件消抖(了解)
每个按键并联一个小电容 。可以把电容想象成一块海绵:
- 松开按键时:相当于海绵自动吸水。电荷通过电阻慢慢流进海绵,电容两端电压从 0 开始平缓增加,松开瞬间的弹跳毛刺被抹平;
- 按下按键时:相当于用力挤压海绵。电荷通过按键流到地上,电容两端电压从高点平缓减小,按下瞬间的抖动毛刺也被抹平。
结果:不管松开还是按下,电容都在"拖慢"电压的变化速度,把瞬间跳变的信号变成平缓的斜坡,软件读到的就是干净、稳定的按键状态。
10.2.3 方案二:软件消抖
在极高可靠场景,还可以再叠加软件消抖:
原理很简单:多次判断按键状态是否一致 。例如判断 2 次都是按下才认为真的按下------在检测到按下时,延时
KEY_DEBOUNCE_MS(5ms~20ms)后再判断一次,仍然是按下才确认。
KEY_DEBOUNCE_MS 值越大,要求按键按下的稳定时间越长。
10.3 输入模式的选择:按键硬件接法决定输入模式
MCU 外部接按键通常有 4 种硬件连接方式,每种对应的 GPIO 输入模式不同。
方式 1:按键按下接 GND,外部只有"下拉到低",没有"上拉到高"能力
- 必须由 MCU 内部提供上拉电阻,按键松开时引脚才能回到确定的高电平;
- 用 0/1 区分按键状态;
- → GPIO 配置为「上拉输入」模式。
方式 2:按键按下接 VDD,外部只有"上拉到高",没有"下拉到低"能力
- 必须由 MCU 内部提供下拉电阻,按键松开时引脚才能回到确定的低电平;
- → GPIO 配置为「下拉输入」模式。
方式 3:外部自己提供了上拉电阻
- 按键按下 → 引脚接 GND → 读到低电平(0);
- 按键断开 → 引脚接外部 VDD → 读到高电平(1);
- 外部既有"上拉到高"能力又有"下拉到低"能力(由按键通断决定);
- → GPIO 配置为「浮空输入」模式。
👓 细节:没错,上拉能力 MCU 内部虽然提供,但硬件工程师可以不用。MCU 提供上拉/下拉,本质是提供"缺省"的上拉/下拉能力;外部上拉电阻阻值可以自由设置,比 MCU 内部弱上拉灵活得多,所以很多硬件工程师愿意用外部上拉。
方式 4:外部自己提供了下拉电阻
- 按键按下 → 引脚接 VDD → 读到高电平(1);
- 按键断开 → 引脚经外部下拉电阻接地 → 读到低电平(0);
- → GPIO 配置为「浮空输入」模式。
灵犀版用的是方式 3:外部提供上拉,输入配置为浮空模式。
10.4 需求梳理
三个由浅入深的样例:
- 按一下亮、不按不亮(展示接口);
- 按一下亮、再按一下灭,循环(演示软件消抖);
- 多 LED、多按键(侧重工程化封装)。
10.5 代码实现
10.5.1 Version 1:按一下亮、不按不亮(展示接口)
c
while (1)
{
// 读按键:按下时引脚接地,读到 RESET(0);松开时读到 SET(1)
if (HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_10) == GPIO_PIN_RESET)
{
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8, GPIO_PIN_RESET); // 按下 → 点亮 LED
}
else
{
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8, GPIO_PIN_SET); // 松开 → 熄灭 LED
}
}
10.5.2 Version 2:按一下亮、再按一下灭(无消抖,结果不确定)
c
bool led_status = false; // false: 灭 true: 亮
while (1)
{
if (HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_10) == GPIO_PIN_RESET) // 检测到按下
{
led_status = !led_status; // 翻转 LED 状态:灭→亮 或 亮→灭
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8,
led_status ? GPIO_PIN_RESET : GPIO_PIN_SET); // 亮则输出0,灭则输出1
}
}
⚠️ 这段代码实际运行结果不确定!因为按键抖动可能让一次"物理按下"被识别成多次"逻辑按下",状态被翻转多次。
10.5.3 Version 3:软件消抖后的稳定版本
c
#define KEY_DEBOUNCE_MS 20 // 消抖延时:按下后等待 20ms 再二次确认
bool led_status = false; // false: 灭 true: 亮
while (1)
{
// 第一次检测到按键按下
if (HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_10) == GPIO_PIN_RESET)
{
HAL_Delay(KEY_DEBOUNCE_MS); // 延时消抖:让机械抖动过去
// 二次确认:延时后仍然是按下,才认为"真的按下"了
if (HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_10) == GPIO_PIN_RESET)
{
led_status = !led_status; // 翻转 LED 状态
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8,
led_status ? GPIO_PIN_RESET : GPIO_PIN_SET);
}
// 不仅要考虑"按下",还要考虑"松开":
// 如果用户一直按着,引脚一直低电平,就卡在这里等待,直到用户松开变成高电平
while (HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_10) == GPIO_PIN_RESET);
HAL_Delay(KEY_DEBOUNCE_MS); // 保险起见,对"松开"也做一次消抖
}
}
10.5.4 Version 4:多按键多 LED(工程化封装)
为了把应用程序与硬件完全解耦,用枚举给用户提供直观的操作接口,用户不需要关心按键/LED 具体在哪个引脚。
c
#include <stdbool.h> // 引入 bool 类型
/* ===== 用户层枚举:给应用代码提供直观的名字 ===== */
typedef enum { LED1 = 1, LED2, LED3, LED4 } LED_t; // 4 个 LED 的代号
typedef enum { KEY1 = 1, KEY2, KEY3, KEY4 } KEY_t; // 4 个按键的代号
/* ===== 函数声明 ===== */
void BSP_Led_Control(LED_t led, bool flag); // 控制某个 LED:flag=true 亮,false 灭
void BSP_Led_Off(LED_t led); // 熄灭指定 LED
void BSP_Led_On(LED_t led); // 点亮指定 LED
bool BSP_Key_IsPressed(KEY_t key); // 判断指定按键是否按下
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
while (1)
{
// 应用层:KEY1 按下则点亮 LED1,否则熄灭;其余同理
BSP_Key_IsPressed(KEY1) ? BSP_Led_On(LED1) : BSP_Led_Off(LED1);
BSP_Key_IsPressed(KEY2) ? BSP_Led_On(LED2) : BSP_Led_Off(LED2);
BSP_Key_IsPressed(KEY3) ? BSP_Led_On(LED3) : BSP_Led_Off(LED3);
BSP_Key_IsPressed(KEY4) ? BSP_Led_On(LED4) : BSP_Led_Off(LED4);
}
}
/* ===== LED 底层实现(负责"硬件映射") ===== */
void BSP_Led_Control(LED_t led, bool flag)
{
// 开漏模式:GPIO_PIN_RESET(0) 表示点亮,GPIO_PIN_SET(1) 表示熄灭
GPIO_PinState state = flag ? GPIO_PIN_RESET : GPIO_PIN_SET;
switch (led) // 把枚举代号翻译成具体引脚
{
case LED1: HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8, state); break;
case LED2: HAL_GPIO_WritePin(GPIOF, GPIO_PIN_9, state); break;
case LED3: HAL_GPIO_WritePin(GPIOF, GPIO_PIN_10, state); break;
case LED4: HAL_GPIO_WritePin(GPIOF, GPIO_PIN_11, state); break;
default: break;
}
}
void BSP_Led_Off(LED_t led) { BSP_Led_Control(led, false); } // 熄灭 = 控制 flag=false
void BSP_Led_On(LED_t led) { BSP_Led_Control(led, true); } // 点亮 = 控制 flag=true
/* ===== 按键底层实现(BSP = Board Support Package,板级支持包) ===== */
bool BSP_Key_IsPressed(KEY_t key)
{
bool ret = false;
switch (key) // 把按键代号翻译成具体引脚,读到 RESET(0) 说明按下
{
case KEY1: ret = (HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_10) == GPIO_PIN_RESET); break;
case KEY2: ret = (HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_6) == GPIO_PIN_RESET); break;
case KEY3: ret = (HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_8) == GPIO_PIN_RESET); break;
case KEY4: ret = (HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_7) == GPIO_PIN_RESET); break;
default: break;
}
return ret;
}
软件消抖版(在 BSP_Key_IsPressed 内加入延时二次确认):
c
#define KEY_DEBOUNCE_MS 20 // 消抖延时
bool BSP_Key_IsPressed(KEY_t key)
{
bool ret = false;
switch (key)
{
case KEY1:
{
// 第一次检测到按下
if (HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_10) == GPIO_PIN_RESET)
{
HAL_Delay(KEY_DEBOUNCE_MS); // 延时消抖,过滤机械抖动毛刺
if (HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_10) == GPIO_PIN_RESET) // 二次确认
{
ret = true; // 两次都是按下,才认为是真的按下
}
}
}
break;
// KEY2、KEY3、KEY4 逻辑完全相同,只是引脚不同(GPIOG 的 PIN_6/PIN_8/PIN_7)
default: break;
}
return ret;
}
10.6 源码解析:HAL_GPIO_ReadPin 是怎么读的?
c
GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin)
{
GPIO_PinState bitstatus;
// 核心:读 IDR(输入数据寄存器),用位图 GPIO_Pin 做与运算,只看目标那一位
if ((GPIOx->IDR & GPIO_Pin) != (uint32_t)GPIO_PIN_RESET)
{
bitstatus = GPIO_PIN_SET; // 该位为 1 → 引脚高电平
}
else
{
bitstatus = GPIO_PIN_RESET; // 该位为 0 → 引脚低电平
}
return bitstatus;
}
读取的本质:
读输入的本质就是读 IDR(输入数据寄存器) 。IDR 是只读寄存器,每一位对应一个引脚当前经过施密特触发器整形后的电平(1 = 高电平,0 = 低电平)。
HAL_GPIO_ReadPin用引脚位图和 IDR 做&运算,只看目标引脚那一位,从而判断引脚电平。
10.7 下载验证
编译下载并复位后:
- 按下按键 1 → LED1 点亮;松开按键 1 → LED1 熄灭;
- 按下按键 2 → LED2 点亮;松开按键 2 → LED2 熄灭;
- 依次类推。
💡 知识小结
问:按键检测为什么要消抖?软件消抖怎么做?
答: 因为机械按键在按下和松开的瞬间,触点接触会产生抖动,形成持续几毫秒到二十毫秒的毛刺,如果直接读取,一次物理按下可能被误判成多次逻辑按下。施密特触发器解决的是信号整形问题,解决不了这种"乱跳",所以必须消抖。软件消抖的核心思想是"多次判断一致才确认":第一次读到按下后,延时 5~20ms(常用 20ms),再读一次,如果仍然是按下才认为真的按下;同样松开也做对称处理。这样能过滤机械抖动毛刺,保证按键状态判断的确定性。
问:按键硬件怎么接,输入模式怎么选?
答: 选择输入模式取决于外部电路是否自带"上拉到高/下拉到低"的能力。如果按键按下接 GND、松开时引脚悬空,需要 MCU 内部上拉电阻提供缺省高电平,就配置为上拉输入;如果按键按下接 VDD、松开时引脚悬空,需要内部下拉电阻提供缺省低电平,就配置为下拉输入;如果外部电路已经自己接好了上拉或下拉电阻(同时具备确定高低电平的能力),就配置为浮空输入。原则是:保证按键松开和按下时引脚电平都确定,且与外部电路配合正确。
问:GPIO 输入读取的本质是什么?
答: GPIO 输入读取的本质是读输入数据寄存器 IDR。引脚外部电平经过施密特触发器整形后,以一位的形式存放在 IDR 中:高电平对应 1,低电平对应 0。HAL 库的 HAL_GPIO_ReadPin 用引脚位图与 IDR 做位与运算,只取目标引脚那一位,即可得到该引脚当前的电平状态。整个过程是硬件寄存器直读,不需要配置额外模式之外的动作。
十一、附录:必须补的基础知识
11.1 欧姆定律与分压(本节最重要的数学基础)
欧姆定律:V = I × R(电压 = 电流 × 电阻)
关键理解:
欧姆定律计算出来的电压,是"某个设备在整个电路中承担的电压差(分压)",即经过该设备电压下降了多少,而不是设备某端的绝对电压值。
- 一个电路回路中,电压会被各个元器件承接,电阻值越高,承压越高;
- 求某节点真实电压 = 总电压 − 该节点之前所有元器件承担压差之和;
- 导线不承担电压(近似 0Ω)。
这就是开漏输出高阻态、上拉/下拉缺省电平这些"神奇现象"背后的统一数学原理。
11.2 常见电子元件(原理图阅读入门)
- 电阻:限流、分压。原理图中的常见元件。
- 二极管:单向导电,电流只能从正极(阳极)流向负极(阴极)。
- 发光二极管(LED) :导通时发光,各种电器电源指示灯就是它。直插式 LED 引脚一长一短,长的就是正极(阳极)。
- 电容:可以理解成"海绵"或"水池",能缓冲电压的突变(按键消抖就靠它)。
11.3 原理图网络标签
- 原理图内容多,通常分多个模块绘制,但物理上模块之间是通过导线连在一起的;
- 为了表示这种连接关系,引入了网络标签:一个字符串(字母+数字的组合);
- 相同名称的网络标签在电气上是连通的,即使它们在图纸上相隔很远;
- 网络标签是原理图上的符号,不是真实的电子元件。
11.4 RCC:复位和时钟控制
- RCC 全称 Reset and Clock Control,即"复位和时钟控制";
- 复位:整个设备从头开始执行程序,相当于"重启";
- 时钟:一组周期性的高低电平信号。
打个比方: 如果把 STM32 理解成一支"交响乐团",时钟就是其中的"指挥"。指挥按一定节奏挥舞指挥棒,乐团各个角色按指挥棒的节奏演奏,整个乐队才能整齐划一。没有时钟,MCU 内部各个部件就无法协调工作。
所以使用任何外设之前,都要先通过 RCC 把它的时钟打开 ------这就是 GPIO 初始化代码里
__HAL_RCC_GPIOx_CLK_ENABLE()的由来。
写在最后
到这里,GPIO 的核心知识就全部讲完了。回顾一下整条主线:
- GPIO 是什么:MCU 内部做输入输出高低电平的"通用"控制器,给 CPU 和所有片上外设共用;
- 看得见的部分:112 个引脚分成 PA~PG 七组 Port,每组 16 个 Pin;
- 看不见的部分:每个 Pin 都有"上 I 下 O"的端口位电路,最外层还有钳位保护;
- 八种模式:输出四模式(通用/复用 × 推挽/开漏)+ 输入四模式(浮空/上拉/下拉/模拟);
- 实战:输出点灯(配置时钟 → 配置引脚 → 写 BSRR),输入读按键(读 IDR + 消抖)。
现在,你已经有能力让一块 STM32"感知"世界(按键、传感器)并"操控"世界(LED、蜂鸣器)了。 下一步,去学习时钟树(RCC),看看"指挥"是怎么把 8MHz 变成 72MHz 的吧!