第四讲GPIO与Led

第四讲 GPIO与Led模块

这一讲主要学习 GPIO 的几种常见模式、CubeMX/HALGPIO 的配置方法,以及如何把 LED 的硬件控制整理成一个独立模块。学习这部分内容时,主线可以抓住三步:先开时钟,再配引脚,最后输出电平控制外设。

GPIO模式配置

GPIO 就是通用输入输出口,本质上是把芯片引脚按需求配置成不同工作方式。

  • 输入模式:读取外部信号,比如按键输入、电平检测。

  • 输出模式:由单片机主动输出高低电平,驱动 LED、蜂鸣器、继电器等外设。

  • 模拟模式:关闭数字输入输出功能,用于 ADC、模拟信号相关场景。

  • 复用功能模式:把引脚交给某个片上外设使用,比如 USARTSPII2C、定时器通道等。

在这一讲里,最常用的是输出模式,因为控制 LED 的本质就是让某个引脚按要求输出高电平或低电平。

输出类型

image-20260422212511208

51 单片机的 IO 功能相对固定,很多时候上电后就能直接当普通 IO 使用,所以初学时常常感觉“不怎么配置也能输出”。STM32 不一样,一个引脚往往兼顾普通 GPIO、通信接口、模拟输入、定时器功能等多种用途,因此使用前必须先明确配置。

STM32 的输出类型主要分为两种:

  • 推挽输出:既能主动输出高电平,也能主动输出低电平,驱动能力更强,控制 LED 最常用。

  • 开漏输出:只能主动拉低,不能主动输出高电平;输出高电平时需要依靠外部上拉电阻,常见于 I2C 等总线场景。

控制普通 LED 时,通常优先选择推挽输出,因为连接和控制都更直接。

虽然推挽输出既能主动输出高电平,也能主动输出低电平,看起来功能更“完整”,但这并不意味着所有场景都应该用推挽输出。是否选择推挽还是开漏,关键要看电路连接方式和总线工作需求。

可以先记一个结论:

  • 推挽输出适合单个器件主动驱动信号的场景,比如控制 LED、蜂鸣器、普通数字输出。

  • 开漏输出适合多设备共用一根线、需要外部上拉,或者需要做电平兼容的场景。

推挽输出的问题不在于“能力不够”,而在于它过于主动。因为它既能强力输出高电平,也能强力输出低电平,所以如果多个器件同时接在同一根线上,就可能出现冲突。

例如有两个器件都把自己的引脚接到同一根线上:

  • 一个器件输出高电平。

  • 另一个器件输出低电平。

如果它们都是推挽输出,就相当于一个在拼命往上拉,一个在拼命往下拉,这种情况叫输出打架。轻则信号错误,重则可能损坏器件。

开漏输出就不会这样。开漏输出只有两种状态:

  • 主动拉低。

  • 不驱动,让引脚处于“放手”状态。

当它不驱动时,线路上的高电平由外部上拉电阻提供,所以多个开漏输出挂在同一根线上时,只要有一个器件拉低,这根线就是低电平;只有所有器件都放手,线路才会在上拉电阻作用下回到高电平。

这就是为什么很多总线要用开漏输出,最典型的例子就是 I2C

  • I2CSCLSDA 都是多设备共享的。

  • 总线上既有主机,也可能有多个从机。

  • 总线通信中需要应答、仲裁,这都要求多个器件可以安全地共用一根信号线。

如果这里用推挽输出,就很容易在线路上出现冲突;而开漏加上拉的方式就更安全,也更符合总线协议要求。

开漏输出还有一个常见用途是做电平兼容。比如单片机工作在 3.3V,外部模块工作在 5V,这时可以让高电平由外部上拉到目标电压,单片机只负责把线拉低,这样连接会更灵活。

所以实际使用时可以这样理解:

  • 需要明确、快速、直接驱动一个负载时,用推挽输出。

  • 需要多设备共线、总线共享、外部上拉或跨电平连接时,用开漏输出。

GPIO 还可以配置输出速度。这里的“速度”不是程序执行速度,而是引脚电平翻转的快慢,也就是上升沿、下降沿变化得有多快。

  • 速度越高,信号边沿越陡。

  • 速度越高,通常也更容易带来功耗增加和电磁干扰增加。

  • 控制普通 LED 时一般选低速即可,没必要一上来就配很高速度。

GPIO配置与硬件连接

image-20260422213925918

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:配置该引脚对应的具体功能信号。

image-20260422215641297

下面这段代码是 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_0GPIO_PIN_1

  • Mode:引脚模式,比如 GPIO_MODE_OUTPUT_PP

  • Pull:上下拉设置,比如 GPIO_NOPULLGPIO_PULLUPGPIO_PULLDOWN

  • Speed:输出速度,比如 GPIO_SPEED_FREQ_LOW

  • Alternate:复用功能编号,只在复用模式下使用。

2. 引脚复用机制

STM32 的很多引脚都不是“只能做一件事”,而是支持复用。

  • 同一个引脚可以被配置给不同外设功能。

  • 常见复用对象包括 UARTSPII2C、定时器通道等。

  • 不同芯片型号可用的复用功能不完全一样,要以芯片手册和 CubeMX 配置结果为准。

如果某个引脚要拿去做串口发送、定时器输出等功能,就不能再把它当普通 GPIO 输出使用。

3. GPIO时钟使能

时钟使能是 STM32 配置外设时绕不过去的一步。

  • 使用 __HAL_RCC_GPIOx_CLK_ENABLE() 打开对应端口时钟。

  • 不同端口需要单独打开,比如 GPIOAGPIOBGPIOD 要分别使能。

  • 只有使能之后,这个端口的配置和读写操作才真正有效。

可以把它理解为:先给这个外设“通电”,再谈配置和使用。

4. GPIO操作函数

HAL 库中常见的 GPIO 操作函数有:

  • HAL_GPIO_WritePin():设置引脚高低电平。

  • HAL_GPIO_ReadPin():读取输入状态。

  • HAL_GPIO_TogglePin():翻转当前输出状态。

  • HAL_GPIO_LockPin():锁定配置。

  • HAL_GPIO_EXTI_IRQHandler():外部中断相关处理。

这些函数的好处是把底层寄存器细节封装起来,代码更直观,尤其适合刚开始学习时使用。

LED点亮方式

image-20260422221537319

不同开发板上,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);

这句代码表示:如果第 iLED 应该亮,就把 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() 内部再统一把逻辑状态刷新到硬件上。

这样更符合“上层写逻辑、底层管驱动”的思路。

模块之间的组织方式

image-20260422224126311

image-20260422224138469

LED 单独整理成模块,是为了让代码结构更清楚。

常见做法是:

  • .h 头文件里放函数声明、宏定义、extern 声明。

  • .c 源文件里放函数实现和变量定义。

这样拆分后有几个明显好处:

  • 其他模块只需要 #include 对应头文件,就知道这个模块提供了哪些接口。

  • 代码职责更清晰,方便维护和复用。

  • 避免把所有代码都堆在 main.c 里,后期越来越乱。

需要注意的一点是:全局变量如果要跨文件使用,通常是在一个 .c 文件里定义,在头文件里用 extern 声明,而不是在多个 .c 文件里重复定义。

USER CODE区域的作用

image-20260422224252217

image-20260422224407971

image-20260422231508567

CubeMX 自动生成代码时,通常会预留 USER CODE BEGINUSER CODE END 这样的区域。

这些区域的意义是:

  • 这是给用户自己补充代码的位置。

  • 后续如果重新生成工程,这部分内容通常会被保留。

  • 如果把自定义代码写在这些区域之外,重新生成代码时就有可能被覆盖。

因此像下面这些内容,一般都适合写在 USER CODE 区域里:

  • 自己新增的函数声明。

  • 自己写的模块调用语句。

  • 用户补充的初始化逻辑。

  • 和自动生成代码配合使用的少量自定义处理。

学习 CubeMX 时要形成一个习惯:自动生成的部分尽量少改,自己的代码尽量放进保留区域,这样后续重新配置工程时不容易丢代码。