经典三问
Q: GPIO 是什么?
A: GPIO 是可以由程序配置为输入或输出的通用数字引脚,芯片通过它读取或驱动外部电平。
Q: 为什么需要先学习 GPIO?
A: LED、按键、传感器使能和电机方向等大量外设,最终都要通过 GPIO 完成最基本的输入输出。
Q: GPIO 常见使用场景?
A: 点灯、读取按键、选择通信模式、控制驱动器使能,以及连接数字传感器。
第一次把程序下载进 STM32,最想看到的是板子给你一个回应。
电脑上的 C 程序可以在终端里打印 hello world。嵌入式程序没有这个窗口,程序下载成功也不等于你已经看见它运行了。对我来说,第一次让面包板上的 LED 亮起来,就是嵌入式版本的 hello world。
先把外接 LED 接到 PC13:长脚接 PC13,短脚接 GND。当前只观察这一颗灯,后面再把同样的思路扩展到其他引脚。
接线前先认识一下手里的 LED。普通直插 LED 通常是长脚为正极、短脚为负极。透明外壳里面,较大的金属片连接短脚,是负极;较小的金属片连接长脚,是正极。也就是说,本实验把正极接到 PC13,负极接到 GND。
写完之后我才发现 :这个实验我做过很多次,接线、点灯都没有问题。后来翻数据手册才看到第 20 页的注:PC13、PC14、PC15 由电源开关供电,而这个开关只能吸收有限的电流(3 mA),所以这三个脚作输出时有四条限制------同一时间只能有一个作输出 、输出时只能工作在 2 MHz (这一篇的配置正好就是 2 MHz)、最大驱动负载 30 pF、并且不能作为电流源(手册举的例子就是驱动 LED) 。把 LED 正极接 PC13、从 PC13 往外供电,正好落在最后一条上,所以这种做法可能会导致一些问题:灯偏暗、几颗一起用时亮度不匀,换一块板子表现也可能不一样。这里我不改接线 ------实验和照片都是这么做的,能跑通,知道有这个限制比不知道好。真要严格守手册,可以改成"LED 正极经限流电阻接 3.3 V、负极接 PC13"(低电平点亮),或者把灯挪到普通 GPIO 上。
只有供电,还没有运行程序
开发板刚接上电源、尚未烧录程序时,通常只能看到板上的电源指示灯,外接 LED 不会因为"已经通电"就自己亮起来。
这张照片展示的是刚上电的状态:电源指示灯亮,外接 LED 没有亮。电源指示灯只能说明供电存在,不能说明 main 已经开始运行。要让 PC13 按照我们的意图输出高低电平,还需要先把程序下载进去。
先看真正执行的代码
入口是 main.c。LED 的端口和电平细节放在 led.c,主循环只调用 LED 接口:
c
#include "stm32f10x.h"
#include "led.h"
int main(void)
{
LED_Init();
while (1)
{
LED_On();
for (volatile uint32_t delay = 0; delay < 720000; delay++)
{
}
LED_Off();
for (volatile uint32_t delay = 0; delay < 720000; delay++)
{
}
LED_Toggle();
for (volatile uint32_t delay = 0; delay < 720000; delay++)
{
}
}
}
while 里面除了三个 LED 操作,中间各放了一个空的 for 循环。循环体虽然是空的,但程序仍会在这里原地重复计数。这样做的目的是让 LED 亮一段时间、灭一段时间,再翻转一次,亮灭变化才不会快到肉眼看不见。
用 ST-Link 把程序下载到开发板。看到灯按照程序亮、灭、翻转,说明程序已经启动,LED_Init() 执行了,后面的输出函数也确实改变了某个 GPIO。灯没有按预期变化时,再沿着初始化和接线一步一步检查。
一颗灯为什么能当作 hello world
普通 C 程序用 printf 把结果送到终端;STM32 先没有终端,最直接的反馈就是面包板上的 LED:
c
LED_On();
LED_Off();
LED_Toggle();
下载后看见灯按照循环亮、灭、翻转,至少说明一条完整链路已经接通:程序启动、模块初始化、GPIO 输出和真实 LED 电路都产生了联系。
这就是点灯的价值。它不是一个"太简单所以随便做做"的实验,而是第一次把软件执行过程变成肉眼能抓住的反馈。
先把初始化拆成几步
led.c 里的初始化是:
c
#include "stm32f10x.h"
#include "led.h"
void LED_Init(void)
{
GPIO_InitTypeDef gpio;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE);
gpio.GPIO_Pin = GPIO_Pin_13;
gpio.GPIO_Mode = GPIO_Mode_Out_PP;
gpio.GPIO_Speed = GPIO_Speed_2MHz;
GPIO_Init(GPIOC, &gpio);
LED_Off();
}
第一句打开 GPIOC 的外设时钟。没有它,GPIOC 还没有准备好工作,后面的配置和输出就不能可靠地作用到引脚上。
接着是配置单:选择 PC13,把它设为推挽输出,输出边沿能力设为 2MHz。GPIO_Init() 接过这张配置单后,PC13 才有了明确的工作方式。
最后调用 LED_Off(),让初始化结束时的输出状态确定下来。这里的"关灯"不是函数名天然保证的,它要看 LED 实际接在引脚的哪一侧。
GPIO_Mode 这一格能填哪几个值
前面把 GPIO_Mode 填成了 GPIO_Mode_Out_PP,也就是"推挽输出"。它是这一格能填的四个枚举常量之一:
GPIO_Mode |
名字 | 引脚在做什么 | 什么时候用 |
|---|---|---|---|
GPIO_Mode_Out_PP |
推挽输出 | 输出高电平时主动往外送电流,输出低电平时主动往里吸,两个方向都有驱动能力 | 点灯、电机方向脚、模块使能脚,先默认选它 |
GPIO_Mode_Out_OD |
开漏输出 | 只主动把引脚拉到低;要输出高电平,得靠外面的上拉电阻 | 多个设备共用一根线、谁拉低谁说话 |
GPIO_Mode_AF_PP |
复用推挽 | 输出不再由程序决定,而是交给片内的某个外设 | 把引脚交给定时器、串口这类外设 |
GPIO_Mode_AF_OD |
复用开漏 | 同样交给外设,但只主动拉低 | 外设需要开漏时 |
后两个名字里的"复用",说的是这根引脚不再当普通 GPIO 用,而是被片内的外设接管。现在只要记住有这么回事,不用管怎么配。
这一篇用的是 GPIO_Mode_Out_PP:灯只需要芯片主动给高、主动给低,推挽正好够。
高电平点亮,还是低电平点亮
本实验的外接 LED 是长脚接 PC13、短脚接 GND。因此:
PC13输出高电平时,外接 LED 两端形成电压差,电流从PC13流过 LED 到GND,外接 LED 亮;PC13输出低电平时,LED 两端几乎没有电压差,外接 LED 灭。
所以接口可以这样实现:
c
void LED_On(void)
{
GPIO_SetBits(GPIOC, GPIO_Pin_13);
}
void LED_Off(void)
{
GPIO_ResetBits(GPIOC, GPIO_Pin_13);
}
void LED_Toggle(void)
{
if (GPIO_ReadOutputDataBit(GPIOC, GPIO_Pin_13) == Bit_SET)
{
LED_Off();
}
else
{
LED_On();
}
}
SetBits 说的是"输出高电平",ResetBits 说的是"输出低电平",它们并不直接等于所有电路中的"开灯"和"关灯"。LED_On() 和 LED_Off() 是我们根据当前外接电路做的一层翻译。
LED_Toggle() 要做的是"翻转":先读出这根引脚当前的输出电平 ,是高的就改低,是低的就改高。读它用的是 GPIO_ReadOutputDataBit(),读的是芯片自己记着的输出状态(注意不是输入,是当前的输出);它返回的 Bit_SET 就是 1,Bit_RESET 就是 0。
正常运行时,外接 LED 亮、开发板上常见的板载 PC13 LED 灭,对应的是 PC13 高电平状态:
把 PC13 输出为低电平后,外接 LED 灭;同一时刻板载 PC13 LED 常见为亮,对应的是低电平状态:
板载 LED 和外接 LED 为什么相反
开发板上的板载 PC13 LED,和面包板上的外接 LED 不是同一种接法。
外接 LED 的电流路径是:
text
PC13 输出高电平 -> 外接 LED -> GND
所以高电平时,电流从 PC13 流向地,外接 LED 亮。
Blue Pill 一类开发板上的板载指示灯,常见电路则是:
text
VCC -> 限流电阻 -> 板载 LED -> PC13
这里的电流从 VCC 一侧流向 PC13。当 PC13 输出低电平时,芯片把引脚拉低并吸收这股电流,板载 LED 两端形成电压差,于是板载 LED 亮;当 PC13 输出高电平时,两端电压差变小,板载 LED 灭。板载 LED 旁边通常还配有串联限流电阻,具体电路应以手上开发板的原理图为准。
因此,两颗 LED 对同一个 PC13 电平出现相反亮灭,并不是芯片同时输出了两种电平,而是它们的电流路径相反:外接灯由 PC13 向外"供"电流,板载灯则由 PC13 向芯片"吸"电流。高低电平只描述引脚电压,亮灭还取决于 LED 接在引脚的哪一侧。
参数改错时,现场会发生什么
先使用上面的正确版本完成一次点灯,再一次只改一个地方,观察错误如何映射成硬件现象。
错误实验一:把引脚改成 PC12
GPIO_Pin_12 是合法宏,程序通常仍能编译、烧录和运行:
c
gpio.GPIO_Pin = GPIO_Pin_12;
但 LED 仍然接在 PC13,所以程序实际改变的是 PC12,PC13 上的外接 LED 不会随程序改变。现象通常是程序似乎已经运行,原来的外接 LED 却一直保持原状态。
错误实验二:不开 GPIOC 时钟
删掉打开 GPIOC 时钟的语句:
c
/* RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); */
工程仍可能通过编译并成功烧录,但 GPIOC 没有得到正常工作的条件,PC13 通常不能可靠地按程序亮、灭或翻转。也就是说,代码可以跑起来,GPIOC 却没有形成可靠的引脚反馈。
错误实验三:保持外接 LED 接法,却输出低电平
当前外接 LED 的长脚接 PC13、短脚接 GND,高电平才会点亮它。如果把初始输出改为低电平:
c
GPIO_ResetBits(GPIOC, GPIO_Pin_13);
这时外接 LED 会立刻熄灭;如果同时观察开发板自带的 PC13 指示灯,常见现象是板载 LED 亮。原因是板载 LED 接在 VCC 一侧,PC13 为低电平时,芯片吸收电流,板载 LED 反而获得了点亮所需的电压差。
这些实验把"参数选错会怎样"变成了板子上的具体结果。每次只改一个地方,现象才容易和对应的配置联系起来。
2MHz 和 50MHz 到底差在哪
c
gpio.GPIO_Speed = GPIO_Speed_2MHz;
这里的速度是 GPIO 输出边沿能力,不是 CPU 主频,也不是 LED 每秒闪两百万次。把它改成 GPIO_Speed_50MHz,灯不会因此变亮。点灯只需要让电平稳定地变高、变低,2MHz 已经够用;高速外设或较快的波形才有理由选择更快的边沿。
如果只是想让 LED 闪得更快,应该调整循环中的计数,而不是把 GPIO 速度从 2MHz 换成 50MHz。一个参数描述引脚边沿,一个参数影响程序多久切换一次状态,它们不是一回事。
最后橘猫说
你确认的还不是"我已经学会 GPIO 了",而是一条可验证的硬件链路:
- 程序能从启动文件走到
main; - 工程里的标准库和自写模块能够被正确编译、链接;
- GPIOC 时钟、PC13 配置和 LED 电路能够接上;
- 写入高低电平后,硬件给出了可见反馈;
- 同一个 GPIO 电平经过不同的电流路径,会让外接 LED 和板载 LED 呈现相反的亮灭状态。
这盏灯就是嵌入式的第一个 hello world。先看到反馈,再去追问高电平为什么灭、低电平为什么亮;先让模块跑起来,再慢慢拆开里面的函数和配置。
再往输入方向走一步:LED 是芯片主动说话,按键则要求芯片听外面的电平;到按键这里,"按了一次"和"当前按着"会变成两件不同的事。