第四讲 GPIO与Led模块
这一讲主要学习 GPIO 的几种常见模式、CubeMX/HAL 中 GPIO 的配置方法,以及如何把 LED 的硬件控制整理成一个独立模块。学习这部分内容时,主线可以抓住三步:先开时钟,再配引脚,最后输出电平控制外设。
GPIO模式配置
GPIO 就是通用输入输出口,本质上是把芯片引脚按需求配置成不同工作方式。
-
输入模式:读取外部信号,比如按键输入、电平检测。
-
输出模式:由单片机主动输出高低电平,驱动
LED、蜂鸣器、继电器等外设。 -
模拟模式:关闭数字输入输出功能,用于
ADC、模拟信号相关场景。 -
复用功能模式:把引脚交给某个片上外设使用,比如
USART、SPI、I2C、定时器通道等。
在这一讲里,最常用的是输出模式,因为控制 LED 的本质就是让某个引脚按要求输出高电平或低电平。
输出类型

51 单片机的 IO 功能相对固定,很多时候上电后就能直接当普通 IO 使用,所以初学时常常感觉“不怎么配置也能输出”。STM32 不一样,一个引脚往往兼顾普通 GPIO、通信接口、模拟输入、定时器功能等多种用途,因此使用前必须先明确配置。
STM32 的输出类型主要分为两种:
-
推挽输出:既能主动输出高电平,也能主动输出低电平,驱动能力更强,控制
LED最常用。 -
开漏输出:只能主动拉低,不能主动输出高电平;输出高电平时需要依靠外部上拉电阻,常见于
I2C等总线场景。
控制普通 LED 时,通常优先选择推挽输出,因为连接和控制都更直接。
虽然推挽输出既能主动输出高电平,也能主动输出低电平,看起来功能更“完整”,但这并不意味着所有场景都应该用推挽输出。是否选择推挽还是开漏,关键要看电路连接方式和总线工作需求。
可以先记一个结论:
-
推挽输出适合单个器件主动驱动信号的场景,比如控制
LED、蜂鸣器、普通数字输出。 -
开漏输出适合多设备共用一根线、需要外部上拉,或者需要做电平兼容的场景。
推挽输出的问题不在于“能力不够”,而在于它过于主动。因为它既能强力输出高电平,也能强力输出低电平,所以如果多个器件同时接在同一根线上,就可能出现冲突。
例如有两个器件都把自己的引脚接到同一根线上:
-
一个器件输出高电平。
-
另一个器件输出低电平。
如果它们都是推挽输出,就相当于一个在拼命往上拉,一个在拼命往下拉,这种情况叫输出打架。轻则信号错误,重则可能损坏器件。
开漏输出就不会这样。开漏输出只有两种状态:
-
主动拉低。
-
不驱动,让引脚处于“放手”状态。
当它不驱动时,线路上的高电平由外部上拉电阻提供,所以多个开漏输出挂在同一根线上时,只要有一个器件拉低,这根线就是低电平;只有所有器件都放手,线路才会在上拉电阻作用下回到高电平。
这就是为什么很多总线要用开漏输出,最典型的例子就是 I2C:
-
I2C的SCL和SDA都是多设备共享的。 -
总线上既有主机,也可能有多个从机。
-
总线通信中需要应答、仲裁,这都要求多个器件可以安全地共用一根信号线。
如果这里用推挽输出,就很容易在线路上出现冲突;而开漏加上拉的方式就更安全,也更符合总线协议要求。
开漏输出还有一个常见用途是做电平兼容。比如单片机工作在 3.3V,外部模块工作在 5V,这时可以让高电平由外部上拉到目标电压,单片机只负责把线拉低,这样连接会更灵活。
所以实际使用时可以这样理解:
-
需要明确、快速、直接驱动一个负载时,用推挽输出。
-
需要多设备共线、总线共享、外部上拉或跨电平连接时,用开漏输出。
GPIO 还可以配置输出速度。这里的“速度”不是程序执行速度,而是引脚电平翻转的快慢,也就是上升沿、下降沿变化得有多快。
-
速度越高,信号边沿越陡。
-
速度越高,通常也更容易带来功耗增加和电磁干扰增加。
-
控制普通
LED时一般选低速即可,没必要一上来就配很高速度。
GPIO配置与硬件连接

LED 前面一般都要串联一个电阻,这个电阻的核心作用是限流。
-
防止流过
LED的电流过大,烧坏LED。 -
防止
GPIO引脚输出电流过大,超过芯片允许范围。 -
让
LED工作在比较合适、稳定的亮度范围内。
如果没有限流电阻,LED 不是“更亮一点”这么简单,而是很可能直接过流损坏。
通过自定义引脚再配合杜邦线连接,本质上就是把开发板上的某个 GPIO 引出来,用它去连接外部 LED 或其他模块。实际接线时要先确认:
-
该引脚是否已经被别的外设占用。
-
LED的正负极连接方向是否正确。 -
当前硬件是高电平点亮还是低电平点亮。
GPIO常用配置项
-
GPIO Mode:引脚模式,决定它是输入、输出、模拟还是复用功能。 -
GPIO Output Type:输出类型,常见是推挽输出或开漏输出。 -
GPIO Speed:输出翻转速度。 -
GPIO Pull-up/Pull-down:内部上拉、下拉配置。 -
GPIO Label:给引脚起一个便于识别的名字。 -
GPIO Signal:配置该引脚对应的具体功能信号。

下面这段代码是 CubeMX 生成的 GPIO 初始化函数:
void MX_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
/* GPIO Ports Clock Enable */
__HAL_RCC_GPIOC_CLK_ENABLE();
__HAL_RCC_GPIOH_CLK_ENABLE();
__HAL_RCC_GPIOB_CLK_ENABLE();
__HAL_RCC_GPIOD_CLK_ENABLE();
__HAL_RCC_GPIOA_CLK_ENABLE();
/*Configure GPIO pin Output Level */
HAL_GPIO_WritePin(GPIOB, LED1_Pin|LED2_Pin|LED3_Pin|LED4_Pin, GPIO_PIN_RESET);
/*Configure GPIO pin Output Level */
HAL_GPIO_WritePin(GPIOD, LED5_Pin|LED6_Pin, GPIO_PIN_RESET);
/*Configure GPIO pins : LED1_Pin LED2_Pin LED3_Pin LED4_Pin */
GPIO_InitStruct.Pin = LED1_Pin|LED2_Pin|LED3_Pin|LED4_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);
/*Configure GPIO pins : LED5_Pin LED6_Pin */
GPIO_InitStruct.Pin = LED5_Pin|LED6_Pin;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOD, &GPIO_InitStruct);
}
这段初始化代码可以按下面顺序理解:
1. 先定义初始化结构体
GPIO_InitTypeDef GPIO_InitStruct = {0};
HAL 库用这个结构体统一描述某组引脚要怎么配置。
2. 先打开对应端口时钟
__HAL_RCC_GPIOB_CLK_ENABLE();
__HAL_RCC_GPIOD_CLK_ENABLE();
在 STM32 中,外设默认不是一直工作的。只有把某个 GPIO 端口的时钟打开后,这个端口对应的寄存器才能正常工作,所以 GPIO 配置前必须先开时钟。
如果时钟没有打开,后面的配置代码即使写了,也通常不会真正生效。
3. 先设置默认输出电平
HAL_GPIO_WritePin(GPIOB, LED1_Pin|LED2_Pin|LED3_Pin|LED4_Pin, GPIO_PIN_RESET);
GPIO_PIN_RESET 表示输出低电平,GPIO_PIN_SET 表示输出高电平。
这里先写默认电平,目的是让引脚在正式初始化完成后立刻处于预期状态,尽量避免上电瞬间出现误闪烁。
4. 再配置引脚工作参数
GPIO_InitStruct.Pin = LED1_Pin|LED2_Pin|LED3_Pin|LED4_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);
这几句的含义分别是:
-
Pin:指定要配置哪些引脚。 -
Mode = GPIO_MODE_OUTPUT_PP:配置为推挽输出。 -
Pull = GPIO_NOPULL:不上拉、不下拉。 -
Speed = GPIO_SPEED_FREQ_LOW:输出速度选低速。 -
HAL_GPIO_Init():把上面的配置真正写进硬件寄存器。
对于普通 LED 控制,这是一组非常常见的配置。
STM32 HAL库GPIO配置
1. GPIO初始化结构体
HAL 库使用 GPIO_InitTypeDef 结构体来配置 GPIO 引脚,常见成员如下:
-
Pin:要配置的引脚,比如GPIO_PIN_0、GPIO_PIN_1。 -
Mode:引脚模式,比如GPIO_MODE_OUTPUT_PP。 -
Pull:上下拉设置,比如GPIO_NOPULL、GPIO_PULLUP、GPIO_PULLDOWN。 -
Speed:输出速度,比如GPIO_SPEED_FREQ_LOW。 -
Alternate:复用功能编号,只在复用模式下使用。
2. 引脚复用机制
STM32 的很多引脚都不是“只能做一件事”,而是支持复用。
-
同一个引脚可以被配置给不同外设功能。
-
常见复用对象包括
UART、SPI、I2C、定时器通道等。 -
不同芯片型号可用的复用功能不完全一样,要以芯片手册和
CubeMX配置结果为准。
如果某个引脚要拿去做串口发送、定时器输出等功能,就不能再把它当普通 GPIO 输出使用。
3. GPIO时钟使能
时钟使能是 STM32 配置外设时绕不过去的一步。
-
使用
__HAL_RCC_GPIOx_CLK_ENABLE()打开对应端口时钟。 -
不同端口需要单独打开,比如
GPIOA、GPIOB、GPIOD要分别使能。 -
只有使能之后,这个端口的配置和读写操作才真正有效。
可以把它理解为:先给这个外设“通电”,再谈配置和使用。
4. GPIO操作函数
HAL 库中常见的 GPIO 操作函数有:
-
HAL_GPIO_WritePin():设置引脚高低电平。 -
HAL_GPIO_ReadPin():读取输入状态。 -
HAL_GPIO_TogglePin():翻转当前输出状态。 -
HAL_GPIO_LockPin():锁定配置。 -
HAL_GPIO_EXTI_IRQHandler():外部中断相关处理。
这些函数的好处是把底层寄存器细节封装起来,代码更直观,尤其适合刚开始学习时使用。
LED点亮方式

不同开发板上,LED 的连接方式可能不同,因此“输出什么电平才会亮”也不一定一样。
高电平点亮
引脚输出高电平时,电流方向满足导通条件,LED 被点亮。
低电平点亮
引脚输出低电平时,电流形成回路,LED 被点亮。
所以写 LED 控制代码前,一定要先确认开发板原理图或实际接线方式,否则程序逻辑可能会和观察到的现象相反。
LED模块代码分析
void led_disp(uint8_t *ucLed)
{
uint8_t temp = 0x00; // 用于记录当前 LED 状态的临时变量 (最低6位有效)
static uint8_t temp_old = 0xff; // 记录之前 LED 状态的变量, 用于判断是否需要更新显示
for (int i = 0; i < 6; i++) // 遍历6个LED的状态
{
// 将LED状态整合到temp变量中,方便后续比较
if (ucLed[i]) temp |= (1 << i); // 如果ucLed[i]为1, 则将temp的第i位置1
}
// 仅当当前状态与之前状态不同的时候,才更新显示
if (temp != temp_old)
{
// 使用HAL库函数根据temp的值设置对应引脚状态 (假设高电平点亮)
HAL_GPIO_WritePin(GPIOB, LED1_Pin, (temp & 0x01) ? GPIO_PIN_SET : GPIO_PIN_RESET); // LED 0 (PB12)
HAL_GPIO_WritePin(GPIOB, LED2_Pin, (temp & 0x02) ? GPIO_PIN_SET : GPIO_PIN_RESET); // LED 1 (PB13)
HAL_GPIO_WritePin(GPIOB, LED3_Pin, (temp & 0x04) ? GPIO_PIN_SET : GPIO_PIN_RESET); // LED 2 (PB14)
HAL_GPIO_WritePin(GPIOB, LED4_Pin, (temp & 0x08) ? GPIO_PIN_SET : GPIO_PIN_RESET); // LED 3 (PB15)
HAL_GPIO_WritePin(GPIOD, LED5_Pin, (temp & 0x10) ? GPIO_PIN_SET : GPIO_PIN_RESET); // LED 4 (PD8)
HAL_GPIO_WritePin(GPIOD, LED6_Pin, (temp & 0x20) ? GPIO_PIN_SET : GPIO_PIN_RESET); // LED 5 (PD9)
temp_old = temp; // 更新记录的旧状态
}
}
void led_task(void)
{
led_disp(ucLed); // 调用led_disp函数更新LED状态
}
这段代码的思路很适合学习模块化控制。
1. ucLed[] 表示逻辑状态
一般可以把 ucLed[0] ~ ucLed[5] 理解为 6 个 LED 的目标状态:
-
0表示灭。 -
非
0表示亮。
这样做的好处是,上层逻辑只管改数组,不需要每次都直接操作硬件引脚。
2. temp 用来打包6个LED状态
if (ucLed[i]) temp |= (1 << i);
这句代码表示:如果第 i 个 LED 应该亮,就把 temp 的第 i 位置为 1。
例如:
-
ucLed[0] = 1,说明第 0 个灯亮,于是temp最低位会变成1。 -
ucLed[1] = 1,说明第 1 个灯亮,于是temp的第 1 位会变成1。
最终 temp 就相当于用一个字节的不同二进制位,统一记录 6 个 LED 的状态。
3. temp_old 用来避免重复刷新
static uint8_t temp_old = 0xff;
static 表示这个变量不会随着函数结束而销毁,它会保留上一次调用后的值。
后面通过:
if (temp != temp_old)
判断本次状态和上次是否有变化。
-
如果没变化,就不重复写
GPIO。 -
如果有变化,才更新引脚电平。
这样做的好处是减少无意义的重复操作,让代码更清楚,也更接近模块驱动的写法。
4. temp & 0x01 是按位检查
(temp & 0x01)
这是按位与运算,用来检查 temp 的最低位是不是 1。
-
0x01的二进制是0000 0001,专门检查第 0 位。 -
0x02的二进制是0000 0010,专门检查第 1 位。 -
0x04的二进制是0000 0100,专门检查第 2 位。
所以:
-
temp & 0x01对应第 1 个LED -
temp & 0x02对应第 2 个LED -
temp & 0x04对应第 3 个LED
如果结果不为 0,说明对应位是 1,就输出 GPIO_PIN_SET;否则输出 GPIO_PIN_RESET。
5. led_task() 的作用
void led_task(void)
{
led_disp(ucLed);
}
led_task() 相当于对外暴露一个更清晰的任务接口。
-
其他模块不一定直接调用
led_disp()。 -
系统调度器或主循环可以定期调用
led_task()。 -
led_task()内部再统一把逻辑状态刷新到硬件上。
这样更符合“上层写逻辑、底层管驱动”的思路。
模块之间的组织方式


把 LED 单独整理成模块,是为了让代码结构更清楚。
常见做法是:
-
.h头文件里放函数声明、宏定义、extern声明。 -
.c源文件里放函数实现和变量定义。
这样拆分后有几个明显好处:
-
其他模块只需要
#include对应头文件,就知道这个模块提供了哪些接口。 -
代码职责更清晰,方便维护和复用。
-
避免把所有代码都堆在
main.c里,后期越来越乱。
需要注意的一点是:全局变量如果要跨文件使用,通常是在一个 .c 文件里定义,在头文件里用 extern 声明,而不是在多个 .c 文件里重复定义。
USER CODE区域的作用



CubeMX 自动生成代码时,通常会预留 USER CODE BEGIN 和 USER CODE END 这样的区域。
这些区域的意义是:
-
这是给用户自己补充代码的位置。
-
后续如果重新生成工程,这部分内容通常会被保留。
-
如果把自定义代码写在这些区域之外,重新生成代码时就有可能被覆盖。
因此像下面这些内容,一般都适合写在 USER CODE 区域里:
-
自己新增的函数声明。
-
自己写的模块调用语句。
-
用户补充的初始化逻辑。
-
和自动生成代码配合使用的少量自定义处理。
学习 CubeMX 时要形成一个习惯:自动生成的部分尽量少改,自己的代码尽量放进保留区域,这样后续重新配置工程时不容易丢代码。