GPIO 与 LED
说明:课件示例主要用 STM32 HAL 和 CubeMX 讲解 GPIO 与 LED。实际做题时,如果板卡不是同一型号,具体引脚、高低电平逻辑、LED 数量都要以原理图和你自己的工程配置为准。
这节课在讲什么
这节课在讲一件很基础但非常重要的事:怎样把单片机的 GPIO 引脚正确配置成 LED 可用的输出口,并在 CubeMX 和 HAL 库里完成“配置引脚、点亮 LED、翻转 LED、扩展到多个 LED”的完整流程。
放到整个学习顺序里看,上一节先搭好了工程模板,这一节就是在模板基础上继续加外设。重点不只是把灯点亮,而是学会下面这条链路:
-
先看原理图,确认 LED 接到了哪个引脚
-
再在 CubeMX 里把这个引脚配置成正确模式
-
再看懂
MX_GPIO_Init()是怎样完成初始化的 -
最后把 LED 控制写成独立模块,方便后续移植、扩展和调试
先把 GPIO 说清楚
一句话结论:GPIO 是单片机上可以由用户自己定义用途和电平状态的通用输入输出引脚。
它虽然表现为“一个脚”,本质上仍然是 MCU 内部的一个外设模块,所以不能想当然地直接用,而是要先使能时钟,再做模式配置。
GPIO 常见四种模式
| 模式 | 作用 | 常见场景 | 本节课中的用途 |
|---|---|---|---|
| 输入模式 | 读取外部数字电平 | 按键、数字传感器 | 暂时不是重点,后续按键课会大量使用 |
| 输出模式 | 主动输出高低电平 | LED、蜂鸣器、继电器 | 本节课最核心 |
| 模拟模式 | 接入模拟外设 | ADC、DAC | 与 LED 点灯无关 |
| 复用模式 | 把引脚控制权交给片上外设 | UART、SPI、I2C、TIM | 做 PWM 调光时会用到 |
为什么说 GPIO 也算外设
因为 GPIO 不是“天然可用”的裸引脚,而是 MCU 内部带寄存器、带时钟、带配置项的功能模块。你在 CubeMX 里看到的模式、输出类型、速度、上下拉,本质上都是在配置这个外设模块。
输出类型和输出速度
推挽输出和开漏输出
一句话结论:直接控制 LED 时优先选推挽输出,开漏输出更常见于 I2C 这类共享总线。
| 对比项 | 推挽输出 Push-Pull |
开漏输出 Open-Drain |
|---|---|---|
| 高电平输出 | 能主动输出高电平 | 不能主动输出高电平,需要上拉电阻 |
| 低电平输出 | 能主动输出低电平 | 能主动拉低 |
| 驱动能力 | 强,适合直接驱动数字负载 | 取决于上拉方式 |
| 典型场景 | LED、普通数字输出 | I2C、共享总线、线与逻辑 |
| LED 控制 | 最常用 | 一般不作为普通点灯首选 |
可以这样理解:
-
推挽输出内部相当于既能往上拉,也能往下拉,所以最适合普通点灯
-
开漏输出内部只负责下拉,想得到高电平必须依赖外部或内部上拉
-
后面学 I2C 时会更明显感受到开漏的意义,因为多个设备要共享同一根总线
为什么 GPIO 速度不是越快越好
一句话结论:GPIO 的输出速度本质上是电平翻转速度,普通 LED 控制选低速就够了,不需要盲目追求高速。
| 速度等级 | 适用理解 | LED 场景建议 |
|---|---|---|
| Low | 切换慢一些,功耗和 EMI 更低 | 最常用 |
| Medium | 中等速度 | 一般通信口可用 |
| High | 切换更快,干扰和功耗更大 | 普通 LED 不需要 |
| Very High | 高速接口才需要 | 与普通点灯无关 |
速度开得越高,边沿越陡,副作用也越明显:
-
功耗更高
-
更容易带来电磁干扰
-
更容易出现信号完整性问题
所以 LED 只是亮灭指示,不是高速总线,通常配 Low Speed 就可以。
LED 的硬件连接和电平逻辑
一句话结论:写 LED 代码前先搞清楚它是高电平点亮还是低电平点亮,否则代码逻辑可能完全写反。
两种常见接法
| 接法 | 连接方式 | 点亮条件 | 特点 |
|---|---|---|---|
| 高电平点亮 | LED 阳极接 GPIO,阴极经电阻接 GND | GPIO 输出高电平时点亮 | 逻辑直观,写 SET 就亮 |
| 低电平点亮 | LED 阳极接 VCC,阴极经电阻接 GPIO | GPIO 输出低电平时点亮 | 逻辑相反,但很多板子常这样接 |
如果你发现“代码里写 1,灯却灭;写 0,灯却亮”,大概率不是代码错了,而是这个 LED 本来就是低电平点亮。
为什么一定要串限流电阻
一句话结论:限流电阻不是装饰,而是保护 LED 和 GPIO 的。
没有限流电阻时,GPIO 直接给 LED 供电流,容易导致:
-
LED 过流损坏
-
GPIO 引脚输出能力超限
-
板子工作不稳定
常见计算思路是:
R = (Vcc - Vf) / If
其中:
-
Vcc是供电电压 -
Vf是 LED 正向压降 -
If是希望工作的电流
CubeMX 里 GPIO 应该怎么配
一句话结论:普通 LED 开关控制,最常见的配置组合是 GPIO_Output + Push-Pull + Low Speed + No Pull。
CubeMX 中几个核心配置项
| 配置项 | 作用 | 普通 LED 推荐 | 说明 |
|---|---|---|---|
GPIO Mode |
决定引脚工作方式 | Output |
普通点灯用输出模式 |
GPIO Output Type |
决定推挽还是开漏 | Push-Pull |
直接驱动 LED 最常用 |
GPIO Speed |
决定翻转速度 | Low |
足够用且干扰更小 |
GPIO Pull-up/Pull-down |
决定悬空时默认电平 | No Pull |
普通 LED 输出通常不需要 |
GPIO Label |
生成可读的宏名 | LED1、LED_GREEN |
方便后续写代码 |
GPIO Signal |
复用模式下选具体信号 | 普通点灯选 GPIO_Output |
做 PWM 时改为 TIMx_CHx |
GPIO Signal 怎么理解
Signal 只有在复用功能场景里最关键。
-
如果只是把灯点亮、熄灭,选普通 GPIO 输出就行
-
如果是想调亮度,做呼吸灯,就不能再停留在普通输出,而要把引脚切到定时器通道,比如
TIMx_CHx
也就是说:
-
简单开关灯:
Output + GPIO_Output -
调亮度做呼吸灯:
Alternate Function + TIMx_CHx
Label 为什么值得认真写
一句话结论:Label 不是可有可无的注释,而是让代码可读性立刻提升的关键。
如果你在 CubeMX 里给引脚打上 LED1 标签,生成代码后通常会得到类似这样的宏:
#define LED1_Pin GPIO_PIN_12
#define LED1_GPIO_Port GPIOB
这样后面写代码时就不必硬编码端口和引脚号,而是可以直接写:
HAL_GPIO_TogglePin(LED1_GPIO_Port, LED1_Pin);
这比直接写 GPIOB, GPIO_PIN_12 更容易读,也更方便后续换板移植。
上下拉在 LED 场景里怎么处理
一句话结论:普通推挽输出控制 LED 时,通常不需要上下拉;上下拉更多是给输入引脚用的。
可以先记住这三个结论:
-
GPIO_NOPULL:最常见,普通 LED 输出一般就选它 -
GPIO_PULLUP:常见于按键输入、I2C 等需要默认高电平的场景 -
GPIO_PULLDOWN:常见于需要默认低电平的输入场景
内部上下拉的作用是“给未驱动引脚一个默认状态”,不是拿来直接带负载的。它只能管逻辑电平,不能代替 LED 的限流电阻。
配置前先看原理图
这一点在原笔记里提醒得很对:GPIO 课不是背配置菜单,而是学会从原理图找到正确引脚。
真正的操作顺序应该是:
-
先在原理图里找到 LED 对应的网络和 MCU 引脚
-
再在 CubeMX 里找到这个引脚
-
按 LED 的实际电路关系配置模式和默认电平
-
如果板子换了,就重新确认,不要照着别人的引脚号硬抄
课件代码示例里 6 个 LED 的映射关系是:
| LED | 引脚 |
|---|---|
LED0 |
PB12 |
LED1 |
PB13 |
LED2 |
PB14 |
LED3 |
PB15 |
LED4 |
PD8 |
LED5 |
PD9 |
这个映射只能说明“这个示例工程是这么接的”,不能说明“所有板子都这么接”。
HAL 是怎么完成 GPIO 初始化的
一句话结论:CubeMX 生成的 MX_GPIO_Init() 本质上只做了三件事,先开时钟,再填配置结构体,最后调用 HAL_GPIO_Init()。
一个典型的 GPIO 初始化写法如下:
static void MX_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOB_CLK_ENABLE();
HAL_GPIO_WritePin(GPIOB, LED1_Pin, GPIO_PIN_RESET);
GPIO_InitStruct.Pin = LED1_Pin;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
}
这段代码可以拆成三层理解:
-
__HAL_RCC_GPIOB_CLK_ENABLE():先给 GPIOB 开时钟,不开时钟就谈不上配置 -
GPIO_InitStruct:把当前引脚的模式、上下拉、速度等参数集中写进去 -
HAL_GPIO_Init(GPIOB, &GPIO_InitStruct):把配置真正下发到硬件
GPIO_InitTypeDef 里最常看的字段
| 字段 | 含义 | 本节课最常见取值 |
|---|---|---|
Pin |
配哪个引脚 | GPIO_PIN_x |
Mode |
引脚模式 | GPIO_MODE_OUTPUT_PP |
Pull |
上下拉 | GPIO_NOPULL |
Speed |
输出速度 | GPIO_SPEED_FREQ_LOW |
Alternate |
复用功能编号 | 只有 AF 模式下才关心 |
HAL_Init() 和 MX_GPIO_Init() 不是一回事
一句话结论:HAL_Init() 是 HAL 库的全局初始化,MX_GPIO_Init() 才是 GPIO 的具体初始化。
通常主函数初始化顺序是:
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
可以这样理解:
-
HAL_Init()先把 HAL 的基础运行环境准备好 -
SystemClock_Config()把系统时钟配好 -
MX_GPIO_Init()再去初始化具体外设
HAL 里最常用的 LED 控制函数
| 函数 | 作用 | 典型用途 |
|---|---|---|
HAL_GPIO_WritePin() |
直接写高低电平 | 点亮、熄灭 LED |
HAL_GPIO_TogglePin() |
翻转当前电平 | 做闪烁效果最方便 |
HAL_GPIO_ReadPin() |
读取当前引脚电平 | 用于调试、输入检测、状态确认 |
这三个函数要先会熟:
HAL_GPIO_WritePin(LED1_GPIO_Port, LED1_Pin, GPIO_PIN_SET);
HAL_GPIO_TogglePin(LED1_GPIO_Port, LED1_Pin);
HAL_GPIO_ReadPin(LED1_GPIO_Port, LED1_Pin);
注意,如果你的 LED 是低电平点亮,那么 GPIO_PIN_SET 不一定代表“灯亮”,它只代表“引脚输出高电平”。
从代码看 LED 模块怎么写
一句话结论:把 LED 状态和 GPIO 操作分开,是一种很实用的模块化写法。
课件示例里有一个很典型的设计:
-
用
ucLed[]数组保存每个 LED 的目标状态 -
用
led_disp()把数组状态统一刷新到 GPIO -
用
led_task()或led_proc()在主循环或调度器里周期调用
ucLed[] 的意义
uint8_t ucLed[6] = {1,0,1,0,1,1};
这个数组的含义不是“GPIO 电平”,而是“应用层希望每个 LED 呈现什么状态”。
这样做好处很大:
-
上层逻辑只管改数组,不必直接碰 GPIO
-
底层显示函数统一处理输出
-
以后要换引脚、换板子、改电平逻辑时,只改底层就行
if (ucLed[i]) temp |= (1 << i); 到底在干什么
这一句是原笔记里专门提出来的问题,必须搞懂。
if (ucLed[i]) temp |= (1 << i);
它的作用是把多个 LED 的开关状态压缩到一个字节里。
可以这样拆开理解:
-
1 << i:把数字1左移到第i位 -
temp |= ...:如果第i个 LED 应该亮,就把temp的第i位置成1
例如:
-
ucLed[0] = 1,就把temp的第 0 位设为 1 -
ucLed[3] = 1,就把temp的第 3 位设为 1
最后 temp 的低 6 位,就打包了 6 个 LED 的目标状态。
为什么还要有 temp_old
一句话结论:temp_old 是为了减少重复写 GPIO,提高效率。
典型写法是:
if (temp != temp_old)
{
// 只有状态变化时才真正写 GPIO
temp_old = temp;
}
这样做的好处是:
-
LED 状态没变时,不重复调用
HAL_GPIO_WritePin() -
CPU 负担更小
-
总线访问更少,逻辑也更清楚
示例代码的核心刷新逻辑
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, (temp & 0x01) ? GPIO_PIN_SET : GPIO_PIN_RESET);
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_13, (temp & 0x02) ? GPIO_PIN_SET : GPIO_PIN_RESET);
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_14, (temp & 0x04) ? GPIO_PIN_SET : GPIO_PIN_RESET);
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_15, (temp & 0x08) ? GPIO_PIN_SET : GPIO_PIN_RESET);
HAL_GPIO_WritePin(GPIOD, GPIO_PIN_8, (temp & 0x10) ? GPIO_PIN_SET : GPIO_PIN_RESET);
HAL_GPIO_WritePin(GPIOD, GPIO_PIN_9, (temp & 0x20) ? GPIO_PIN_SET : GPIO_PIN_RESET);
这段代码的意思就是:
-
用按位与判断
temp的某一位是 1 还是 0 -
是 1 就把对应引脚输出到某个有效电平
-
是 0 就输出到相反电平
如果你的板子是低电平点亮,那这里的 SET/RESET 很可能要整体反过来理解。
在主函数里怎样接入 LED 模块
GPIO 配好以后,主函数至少要满足“初始化完成后,周期调用 LED 处理逻辑”。
最基础的写法可以是:
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
while (1)
{
led_task();
}
}
如果你已经有调度器,那么就像原笔记那样,把 LED 任务交给调度器统一调度。核心思想不是“必须写在 while 里”,而是“GPIO 初始化后,LED 模块要被稳定地周期执行”。
另外还要保留一个习惯:
-
用户代码尽量写在
USER CODE BEGIN / END区域 -
重新生成工程前先保存
-
不要把业务代码和 CubeMX 自动生成代码混写得太乱
HAL、标准库和寄存器怎么理解
一句话结论:当前阶段先把 HAL 用熟最重要,标准库和寄存器是后续继续往下钻时要补的能力。
| 维度 | HAL 库 | 标准库 / 寄存器方式 |
|---|---|---|
| 上手难度 | 低 | 更高 |
| 可读性 | 更好 | 相对更底层 |
| 开发效率 | 高 | 低一些 |
| 执行效率 | 足够用 | 理论上更高 |
| 适合当前阶段 | 非常适合 | 适合后续深入理解 |
你现在先记住:
-
HAL 适合快速搭功能、看 CubeMX 生成代码、做比赛和实验课
-
寄存器方式适合后面深入理解底层机制
-
标准库也是一种封装,但很多新工程和新课程更常围绕 HAL 展开
所以现在重点不是纠结“哪种最强”,而是先把 GPIO 和 LED 这条基本链路打通。
调试和排错怎么做
一句话结论:LED 不亮时,先查硬件连接和配置,再查代码逻辑,不要一上来就怀疑编译器或芯片坏了。
LED 不亮时的排查顺序
-
先看原理图,确认 LED 到底接在哪个引脚,是高电平点亮还是低电平点亮
-
再看 CubeMX,确认这个引脚是不是配成了
GPIO_Output -
检查输出类型是不是
Push-Pull,速度是不是Low,普通点灯场景下通常不需要上下拉 -
确认
MX_GPIO_Init()是否真正执行到了,GPIO 对应端口时钟是否已经使能 -
在调试器里观察
ucLed[]、temp、temp_old的值,看逻辑有没有走到 -
用最直接的
HAL_GPIO_WritePin()强制拉高或拉低,先验证硬件链路
一个很实用的调试习惯
原笔记里提到“把 ucLed 数组放到 watch 里观察”,这个习惯非常好。
因为它能帮你快速判断问题在哪一层:
-
如果
ucLed[]都没变,问题在上层逻辑 -
如果
ucLed[]变了但 GPIO 没反应,问题在led_disp()或底层配置 -
如果强制写 GPIO 也不亮,问题大概率在引脚映射、电平逻辑或硬件连接
为什么改了配置后现象和课件不一样
因为课件只是示例,不是你的板卡原理图。
最常见的差异来源有这些:
-
板子型号不同
-
LED 接的引脚不同
-
LED 是高电平点亮还是低电平点亮不同
-
引脚已经被别的外设复用占用了
所以真正有效的做法不是“照抄课件配置”,而是“用课件教的方法去验证自己板子的配置”。
和课件配套的延伸理解
为什么普通点灯不用 PWM,而呼吸灯要用 PWM
一句话结论:普通点灯只关心亮或灭,PWM 亮度控制才关心占空比。
-
普通点灯:GPIO 只要输出稳定高低电平就行
-
呼吸灯:要让亮度连续变化,就要改变一个周期内“亮的时间占比”
课件里给了两种思路:
-
硬件 PWM:用定时器通道
TIMx_CHx直接输出 PWM,效率更高 -
软件 PWM:用周期任务加计数器模拟 PWM,思路清楚但更占 CPU
当前阶段先把普通 LED 控制学扎实,后面再自然过渡到定时器 PWM。
单灯呼吸和流水灯的核心思路
即使不背代码,也要记住思想:
-
呼吸灯本质上是“亮度随时间平滑变化”
-
多个呼吸灯流水,本质上是“每个 LED 的亮度曲线有相位差”
后面真正写到时,只是在这两个概念上加了计数器、正弦函数和 PWM 比较。
本节课结论
-
GPIO 是可配置用途的通用输入输出引脚,但本质上仍然是要开时钟、要配置模式的外设模块。
-
普通 LED 控制最常见的 CubeMX 组合是
Output + Push-Pull + Low Speed + No Pull。 -
LED 代码能不能写对,关键不在会不会调 HAL 函数,而在有没有先看懂原理图,搞清楚引脚和高低电平逻辑。
-
MX_GPIO_Init()的核心就是开时钟、填GPIO_InitTypeDef、调用HAL_GPIO_Init()。 -
HAL_GPIO_WritePin()、HAL_GPIO_TogglePin()、HAL_GPIO_ReadPin()是这一节必须熟悉的三个函数。 -
把 LED 状态存在数组里,再统一刷新 GPIO,是一种很实用的模块化写法。
-
if (ucLed[i]) temp |= (1 << i);的本质是位操作打包状态,不要把它看成魔法代码。 -
调试 LED 时,先排硬件和配置,再排逻辑,最后再怀疑更复杂的问题。
后续学习建议
-
下一步把按键输入和上下拉电阻真正串起来理解,因为这两个知识点是配套出现的。
-
用当前工程把“单个 LED 闪烁”和“多个 LED 依次点亮”各写一遍,先把 GPIO 控制练熟。
-
再往后把普通 GPIO 输出升级到
TIMx_CHx,用硬件 PWM 实现呼吸灯。 -
每次换板子、换引脚、换 LED 接法时,都先回到原理图确认,而不是直接照抄旧工程。