2. GPIO基础与LED驱动:从寄存器到HAL库的实战之路
各位同学,欢迎来到第二章。这一章我们聊点实在的——GPIO。说白了,GPIO就是单片机跟外界打招呼的“手”。你想点亮一个LED、读取一个按键、驱动一个蜂鸣器,全得靠它。我刚开始学STM32的时候,第一个实验就是点亮LED,那种“代码一烧,灯就亮了”的成就感,到现在还记得。
这一章我们会从GPIO的工作原理讲起,然后看看寄存器映射是怎么回事,再对比HAL库的GPIO函数,最后手把手带你点亮LED、实现流水灯。嗯,内容不少,但每一步都很扎实。
2.1 GPIO工作原理:你总得知道它怎么干活
GPIO,全称General Purpose Input Output,通用输入输出口。每个GPIO引脚都可以独立配置成输入或输出。你想想看,一个芯片有几十个引脚,每个都能独立干活,这设计是不是很灵活?
STM32的GPIO内部结构其实不复杂,但有几个关键点你得记住:
- 输出模式:推挽输出、开漏输出。推挽输出最常用,能输出高电平和低电平,驱动能力强。开漏输出需要外部上拉电阻,常用于I2C总线。
- 输入模式:浮空输入、上拉输入、下拉输入、模拟输入。浮空输入就是引脚什么都不接,电平不确定,我建议你少用。上拉/下拉输入内部有电阻,电平是确定的。
- 复用功能:引脚不当作普通GPIO用,而是给外设模块(如USART、SPI)使用。
- 输出速度:有低速、中速、高速、超高速四档。不是越快越好,速度越快功耗越高、噪声越大。我一般LED用低速,SPI用高速。
核心要点:GPIO的配置本质就是设置寄存器里的几个位。你告诉芯片:这个引脚是输入还是输出,要不要上拉,速度多快。就这么简单。
我在项目中遇到过一个问题:一个引脚配置成推挽输出,但死活输出不了高电平。查了半天,发现是忘了使能GPIO时钟。嗯,这个坑我替你踩过了,后面会细说。
2.2 寄存器映射:C语言怎么操作硬件?
你可能会问:我写一行代码,怎么就能让引脚输出高电平?答案就是寄存器映射。
STM32的每个外设都有一组寄存器,这些寄存器在内存中有固定的地址。比如GPIOA的端口输出数据寄存器(ODR)地址是0x40020014。C语言里,我们通过指针直接读写这个地址,就能控制硬件。
看个例子:
// 直接操作寄存器点亮LED(假设LED接PA0)
#define GPIOA_ODR (*(volatile uint32_t *)0x40020014)
GPIOA_ODR |= (1 << 0); // PA0输出高电平
这行代码干了什么?它告诉编译器:把0x40020014这个地址当作一个32位的整型指针,然后往这个地址写数据。硬件检测到ODR寄存器的bit0变成1,就把PA0引脚拉高。LED就亮了。
STM32的GPIO每组有7个主要寄存器:
| 寄存器 | 全称 | 作用 |
|---|---|---|
| MODER | 端口模式寄存器 | 配置输入/输出/复用/模拟 |
| OTYPER | 输出类型寄存器 | 推挽或开漏 |
| OSPEEDR | 输出速度寄存器 | 低速/中速/高速/超高速 |
| PUPDR | 上拉/下拉寄存器 | 上拉/下拉/浮空 |
| IDR | 输入数据寄存器 | 读取引脚电平 |
| ODR | 输出数据寄存器 | 设置输出电平 |
| BSRR | 位设置/复位寄存器 | 原子操作置位/复位 |
我的习惯:操作单个引脚时,我更喜欢用BSRR而不是ODR。BSRR可以一次操作置位和复位,而且是原子操作,不会被中断打断。ODR的读-改-写操作在多任务环境下可能出问题。
2.3 HAL库GPIO函数:ST帮你封装好了
直接操作寄存器虽然快,但可读性差、移植性差。ST官方提供了HAL库,把寄存器操作封装成函数。你不需要记地址,调用函数就行。
常用的HAL库GPIO函数有这几个:
HAL_GPIO_Init():初始化GPIO,配置模式、上拉、速度等。HAL_GPIO_WritePin():设置引脚输出高或低。HAL_GPIO_TogglePin():翻转引脚电平。HAL_GPIO_ReadPin():读取引脚电平。HAL_GPIO_DeInit():恢复GPIO到默认状态。
看个初始化示例:
GPIO_InitTypeDef GPIO_InitStruct = {0};
// 使能GPIOA时钟
__HAL_RCC_GPIOA_CLK_ENABLE();
// 配置PA0为推挽输出,低速,无上拉
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
这段代码干了三件事:开时钟、填结构体、调初始化函数。你想想看,如果直接操作寄存器,你得写好几行位操作。HAL库帮你省了这些麻烦。
注意:HAL_GPIO_Init()内部会先复位引脚配置,再设置新配置。如果你在中断里调用这个函数,可能会出问题。我建议初始化都在main函数开始做,不要在运行时反复调用。
2.4 点亮LED:第一个硬件实验
好了,理论讲完了,咱们动手。假设LED接在PA0引脚,正极通过电阻接VCC,负极接PA0。这种接法叫“低电平点亮”——PA0输出低电平时,电流流过LED,灯亮。
完整代码:
#include "stm32f4xx_hal.h"
void LED_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOA_CLK_ENABLE();
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
// 初始状态:LED灭(输出高电平)
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET);
}
int main(void)
{
HAL_Init();
LED_Init();
while (1)
{
// 点亮LED
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET);
HAL_Delay(500);
// 熄灭LED
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET);
HAL_Delay(500);
}
}
这段代码让LED以1秒的周期闪烁。你烧录进去,应该能看到灯一亮一灭。
我曾经犯过一个低级错误:把GPIO_PIN_SET和GPIO_PIN_RESET搞反了。结果LED死活不亮,我还以为是硬件坏了。嗯,检查了半小时才发现是逻辑反了。所以,写代码的时候一定要想清楚:你的电路是高电平点亮还是低电平点亮?
2.5 流水灯实现:让多个LED动起来
单个LED闪烁太简单了,我们来做个流水灯。假设有4个LED,分别接在PA0、PA1、PA2、PA3。流水灯的效果就是:LED1亮→LED1灭、LED2亮→LED2灭、LED3亮→LED3灭、LED4亮→LED4灭,然后循环。
实现方式有两种:
方式一:逐个操作
while (1)
{
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET);
HAL_Delay(200);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET);
HAL_Delay(200);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_RESET);
HAL_Delay(200);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_SET);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_3, GPIO_PIN_RESET);
HAL_Delay(200);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_3, GPIO_PIN_SET);
}
这种方式直观,但代码冗余。如果LED数量变成8个,代码就翻倍了。
方式二:循环+移位
while (1)
{
for (int i = 0; i < 4; i++)
{
// 先熄灭所有LED
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3, GPIO_PIN_SET);
// 点亮第i个LED
HAL_GPIO_WritePin(GPIOA, (1 << i), GPIO_PIN_RESET);
HAL_Delay(200);
}
}
这种方式更优雅。用循环变量i控制哪个引脚输出低电平。如果LED数量变了,改一下循环条件就行。
我的建议:实际项目中,流水灯很少用HAL_Delay()做延时,因为HAL_Delay()会阻塞CPU。后面学了FreeRTOS,我们会用任务延时替代。但现在先这样用,理解原理最重要。
2.6 本章知识结构图
下面这张图帮你梳理本章的核心知识点:
2.7 避坑指南
最后,我总结几个新手容易踩的坑:
- 忘了使能时钟:STM32的外设默认时钟是关闭的。你不开时钟,写寄存器没用。我刚开始学的时候,至少在这上面浪费了半小时。
- 引脚复用冲突:同一个引脚可能被多个外设使用。比如PA9既是USART1_TX,也是普通GPIO。配置前先看数据手册。
- 电平逻辑搞反:一定要确认你的电路是“高电平点亮”还是“低电平点亮”。写代码前看一眼原理图。
- HAL_Delay阻塞:HAL_Delay依赖SysTick中断,如果在中断里调用HAL_Delay,系统会死锁。我建议只在main循环里用。
重要提醒:调试LED时,先用万用表测引脚电压。如果代码烧进去灯不亮,先测引脚有没有输出。硬件问题比软件问题更难排查,先排除硬件。