点亮第一盏 LED:STM32 的 GPIO 到底在控制什么

经典三问

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 页的注:PC13PC14PC15 由电源开关供电,而这个开关只能吸收有限的电流(3 mA),所以这三个脚作输出时有四条限制——同一时间只能有一个作输出、输出时只能工作在 2 MHz(这一篇的配置正好就是 2 MHz)、最大驱动负载 30 pF、并且不能作为电流源(手册举的例子就是驱动 LED)。把 LED 正极接 PC13、从 PC13 往外供电,正好落在最后一条上,所以这种做法可能会导致一些问题:灯偏暗、几颗一起用时亮度不匀,换一块板子表现也可能不一样。这里我不改接线——实验和照片都是这么做的,能跑通,知道有这个限制比不知道好。真要严格守手册,可以改成"LED 正极经限流电阻接 3.3 V、负极接 PC13"(低电平点亮),或者把灯挪到普通 GPIO 上。

led图

只有供电,还没有运行程序

开发板刚接上电源、尚未烧录程序时,通常只能看到板上的电源指示灯,外接 LED 不会因为“已经通电”就自己亮起来。

这张照片展示的是刚上电的状态:电源指示灯亮,外接 LED 没有亮。电源指示灯只能说明供电存在,不能说明 main 已经开始运行。要让 PC13 按照我们的意图输出高低电平,还需要先把程序下载进去。

上电后电源指示灯的图和错误实验1和2的图

先看真正执行的代码

入口是 main.c。LED 的端口和电平细节放在 led.c,主循环只调用 LED 接口:

#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:

LED_On();
LED_Off();
LED_Toggle();

下载后看见灯按照循环亮、灭、翻转,至少说明一条完整链路已经接通:程序启动、模块初始化、GPIO 输出和真实 LED 电路都产生了联系。

这就是点灯的价值。它不是一个“太简单所以随便做做”的实验,而是第一次把软件执行过程变成肉眼能抓住的反馈。

先把初始化拆成几步

led.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,把它设为推挽输出,输出边沿能力设为 2MHzGPIO_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 灭。

所以接口可以这样实现:

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 高电平状态:

正常运行图和错误3的图之PC13外接LED亮板载led灭

把 PC13 输出为低电平后,外接 LED 灭;同一时刻板载 PC13 LED 常见为亮,对应的是低电平状态:

正常运行图和错误3的图之PC13外接LED灭板载led亮

板载 LED 和外接 LED 为什么相反

开发板上的板载 PC13 LED,和面包板上的外接 LED 不是同一种接法。

外接 LED 的电流路径是:

PC13 输出高电平 -> 外接 LED -> GND

所以高电平时,电流从 PC13 流向地,外接 LED 亮。

Blue Pill 一类开发板上的板载指示灯,常见电路则是:

VCC -> 限流电阻 -> 板载 LED -> PC13

这里的电流从 VCC 一侧流向 PC13。当 PC13 输出低电平时,芯片把引脚拉低并吸收这股电流,板载 LED 两端形成电压差,于是板载 LED 亮;当 PC13 输出高电平时,两端电压差变小,板载 LED 灭。板载 LED 旁边通常还配有串联限流电阻,具体电路应以手上开发板的原理图为准。

因此,两颗 LED 对同一个 PC13 电平出现相反亮灭,并不是芯片同时输出了两种电平,而是它们的电流路径相反:外接灯由 PC13 向外“供”电流,板载灯则由 PC13 向芯片“吸”电流。高低电平只描述引脚电压,亮灭还取决于 LED 接在引脚的哪一侧。

参数改错时,现场会发生什么

先使用上面的正确版本完成一次点灯,再一次只改一个地方,观察错误如何映射成硬件现象。

错误实验一:把引脚改成 PC12

GPIO_Pin_12 是合法宏,程序通常仍能编译、烧录和运行:

gpio.GPIO_Pin = GPIO_Pin_12;

但 LED 仍然接在 PC13,所以程序实际改变的是 PC12,PC13 上的外接 LED 不会随程序改变。现象通常是程序似乎已经运行,原来的外接 LED 却一直保持原状态。

错误实验二:不开 GPIOC 时钟

删掉打开 GPIOC 时钟的语句:

/* RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); */

工程仍可能通过编译并成功烧录,但 GPIOC 没有得到正常工作的条件,PC13 通常不能可靠地按程序亮、灭或翻转。也就是说,代码可以跑起来,GPIOC 却没有形成可靠的引脚反馈。

错误实验三:保持外接 LED 接法,却输出低电平

当前外接 LED 的长脚接 PC13、短脚接 GND,高电平才会点亮它。如果把初始输出改为低电平:

GPIO_ResetBits(GPIOC, GPIO_Pin_13);

这时外接 LED 会立刻熄灭;如果同时观察开发板自带的 PC13 指示灯,常见现象是板载 LED 亮。原因是板载 LED 接在 VCC 一侧,PC13 为低电平时,芯片吸收电流,板载 LED 反而获得了点亮所需的电压差。

这些实验把“参数选错会怎样”变成了板子上的具体结果。每次只改一个地方,现象才容易和对应的配置联系起来。

2MHz 和 50MHz 到底差在哪

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 是芯片主动说话,按键则要求芯片听外面的电平;到按键这里,“按了一次”和“当前按着”会变成两件不同的事。

posted @ 2026-09-19 13:28  Zw-awa  阅读(70)  评论(0)    收藏  举报