【stm32】GPIO

🔥铅笔小新z:个人主页

🎬博客专栏:stm32学习

💫滴水不绝,可穿石;步履不休,能至渊。


目录

  1. 本节课的目标
  2. [GPIO 宏观认识:它到底是什么](#GPIO 宏观认识:它到底是什么)
  3. [GPIO 引脚与 IO:看得见的部分](#GPIO 引脚与 IO:看得见的部分)
  4. [GPIO 端口位基本电路结构](#GPIO 端口位基本电路结构)
  5. [I/O 端口保护:钳位电路](#I/O 端口保护:钳位电路)
  6. [GPIO 的八种工作模式总览](#GPIO 的八种工作模式总览)
  7. 输出模式详解
  8. 输入模式详解
  9. [实战一:GPIO 输出点亮 LED](#实战一:GPIO 输出点亮 LED)
  10. [实战二:GPIO 输入按键检测](#实战二:GPIO 输入按键检测)
  11. 附录:必须补的基础知识

一、本节课的目标

学完这一节,你应当能回答这几个问题:

  1. 单片机内部导线上流动的信号,到底是什么?
  2. GPIO 模块在 MCU 内部是干什么的?
  3. GPIO 的八种输入输出模式分别是什么?各有什么特点?
  4. 通用推挽、通用开漏、复用推挽、复用开漏有什么区别?
  5. 上拉输入、下拉输入、浮空输入、模拟输入分别怎么用?
  6. 复用器、P-MOS/N-MOS、施密特触发器这些硬件单元是怎么工作的?
  7. 欧姆定律和上拉/下拉电阻到底是什么关系?钳位保护电路怎么保护芯片?
  8. 代码层面为什么要先开时钟?(为后续 RCC 章节做铺垫)

一句话总结本节使命:学会「指挥」芯片去「感知」外部世界发生了什么,并「操控」外部设备做出动作。


二、GPIO 宏观认识:它到底是什么

2.1 GPIO 是什么?

GPIOGeneral 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?它在单片机里扮演什么角色?

答: GPIOGeneral 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 只体现"分组",体现不了数据交换。
  • 关键细节
    1. 所有 GPIO 控制器都挂接在 APB2 桥设备上;
    2. STM32F103ZET6 一共 7 组端口:GPIOA ~ GPIOG;
    3. 每个 GPIOX 管理 0~15 共 16 个引脚;
    4. 管理方式是层状的、多叉树型结构。

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 端口位的基本电路,逻辑上分成两大部分:

  1. I/O 输入路径(上方)
  2. 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 模式?

  1. GPIO 不只是给 CPU 直接输入输出的,还会被其他片上外设复用,所以它必须够"通用";
  2. 其他外设什么时候用、怎么用完全不确定,所以 GPIO 得把输入输出功能做得多模式化,给外设更多选择;
  3. 打个比方:键盘不能只有游戏需要的 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 推挽输出的完整过程
  1. CPU 执行用户代码,向输出数据寄存器写 1;
  2. 复用器选择信号源为数据寄存器;
  3. 输出控制器把 1 解释为:给 P-MOS 施加电压,N-MOS 不做动作;
  4. P-MOS 感受到电压导通;
  5. 外部引脚接入 VDD,对外输出高电平
  6. 写 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 两个根本问题:上拉/下拉输入时,外部电平为何不被"对抗"掉?

前面抛了两个问题:

  1. 上拉输入 + 外部输入低电平,上拉电阻不会把电平拉高吗?
  2. 下拉输入 + 外部输入高电平,下拉电阻不会把电平拉低吗?

这两个问题本质是同一个问题:内部电阻和外部信号"对抗",为什么内部电阻赢不了?

要理解,需要知道两点:

  1. MCU 内部的上下拉电阻阻值很大 ,属于高阻值器件,一般高达 30kΩ ~ 50kΩ
  2. 欧姆定律 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 软件设计

首次点灯,我们用单文件实现三个效果:

  1. 单灯点亮
  2. 单灯闪烁
  3. 跑马灯效果(LED 从左到右、再从右到左循环跑)

9.4 STM32CubeMX 配置要点

  1. 开启调试(非常重要!) :开发板预留的调试接口是 SWD (Serial Wire Debug,串行线调试)。在 Project Manager 中选择 "Serial Wire"。如果不改这里,保持默认的 No Debug,第一次烧录成功,后续烧录就会失败!

    顺便分清概念:DAP Link 是调试器硬件,SWD 是调试通信协议。打个比方:DAP 是考试坐在座位上的同学本人,SWD 是传纸条的通道 + 约定好的暗号。

  2. 开启外部时钟源(了解):选择 "Crystal / Ceramic Resonator",表示用外部晶振做 HSE。

  3. 配置时钟树(了解) :开发板是 8MHz HSE 晶振 ,选通 8MHz 作为时钟源,把系统主频配到最大 72MHz

  4. 开启引脚 GPIO 输出

    • GPIO output level:引脚初始电平(也就是输出寄存器的初始值);
    • GPIO mode:Output Push Pull(推挽输出)或开漏输出;
    • GPIO Pull-up/Pull-down:是否上拉/下拉;
    • Maximum output speed:输出速度(2MHz / 10MHz / 50MHz);
    • User Label:用户标签,给引脚起个"有意义的名字"方便阅读。
  5. 项目工具链 :Tool Chain 选 MDK-ARM (就是 Keil 这一套工具)。STM32CubeMX 只负责生成工程,开发代码、编译、烧录、调试仍由 Keil 完成。

  6. 最后点击 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 需求梳理

三个由浅入深的样例:

  1. 按一下亮、不按不亮(展示接口);
  2. 按一下亮、再按一下灭,循环(演示软件消抖);
  3. 多 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 的核心知识就全部讲完了。回顾一下整条主线:

  1. GPIO 是什么:MCU 内部做输入输出高低电平的"通用"控制器,给 CPU 和所有片上外设共用;
  2. 看得见的部分:112 个引脚分成 PA~PG 七组 Port,每组 16 个 Pin;
  3. 看不见的部分:每个 Pin 都有"上 I 下 O"的端口位电路,最外层还有钳位保护;
  4. 八种模式:输出四模式(通用/复用 × 推挽/开漏)+ 输入四模式(浮空/上拉/下拉/模拟);
  5. 实战:输出点灯(配置时钟 → 配置引脚 → 写 BSRR),输入读按键(读 IDR + 消抖)。

现在,你已经有能力让一块 STM32"感知"世界(按键、传感器)并"操控"世界(LED、蜂鸣器)了。 下一步,去学习时钟树(RCC),看看"指挥"是怎么把 8MHz 变成 72MHz 的吧!

相关推荐
csdn杰哥2 小时前
瑞萨单片机开发教程[三十] IIC 触摸驱动实验
单片机·嵌入式硬件·瑞萨·ra6m5
振南的单片机世界2 小时前
高优先级“插队”,低优先级让路:中断嵌套的核心规则
arm开发·stm32·单片机·嵌入式硬件
ahccqw4 小时前
电机入门学习(电机认识,分类)
嵌入式硬件
jianqiang.xue11 小时前
系统服务层设计:沉淀通用能力,让业务层只专注业务
stm32·单片机·嵌入式硬件·mcu·物联网·esp32
无垠的广袤13 小时前
【RA-Eco-RA2T1开发板】介绍、环境搭建、工程测试
单片机·嵌入式硬件·串口通信·uart·开发环境·blink
画面无声13 小时前
STM32的SPI通信原理与练习记录
c语言·stm32·单片机·嵌入式硬件·学习
weixin_4568083814 小时前
【沁恒蓝牙开发】IAP升级流程
c语言·单片机·嵌入式硬件
czhaii15 小时前
RTL8019AS、555,DS3231等芯片的跳线方式和引脚定义
嵌入式硬件
czhaii17 小时前
基本电器元件 电阻 resistance R 电容 capacitance C 电感 inductance L
嵌入式硬件