3. GPIO基础与LED控制:从寄存器到呼吸灯
GPIO,说白了就是芯片的“手脚”。你要让STM32跟外界打交道,第一步就是学会怎么用这些引脚。我记得刚入行那会儿,总觉得GPIO太简单,不就是输出高电平、低电平嘛。直到有一次项目里LED死活不亮,查了两天才发现是模式配错了……嗯,从那以后我再也不敢小看GPIO配置了。
3.1 GPIO模式:不只是输入输出
STM32的GPIO比你想的复杂。它一共有8种工作模式,我整理了一张表,你一看就明白:
| 模式 | 用途 | 典型场景 |
|---|---|---|
| 推挽输出 | 强驱动高低电平 | LED、蜂鸣器 |
| 开漏输出 | 需要外部上拉 | I2C总线 |
| 浮空输入 | 电平不确定 | 按键检测(需外接上拉) |
| 上拉输入 | 默认高电平 | 按键检测(省外部电阻) |
| 下拉输入 | 默认低电平 | 特定传感器 |
| 模拟输入 | ADC采样 | 光敏电阻、电位器 |
| 复用推挽 | 外设驱动引脚 | USART TX、SPI MOSI |
| 复用开漏 | 外设开漏驱动 | I2C SCL/SDA |
3.2 寄存器操作:直接跟硬件对话
为什么还要学寄存器?HAL库不是封装好了吗?说实话,HAL库确实方便,但出了问题你还是要看寄存器。我在项目里遇到过HAL_GPIO_WritePin莫名其妙不生效,最后发现是时钟没开——这种问题不看寄存器根本查不出来。
每个GPIO端口有7个关键寄存器。我挑最常用的几个说:
- GPIOx_MODER(模式寄存器):每2位控制一个引脚,00=输入,01=输出,10=复用,11=模拟
- GPIOx_OTYPER(输出类型):0=推挽,1=开漏
- GPIOx_OSPEEDR(输出速度):00=低速,01=中速,10=高速,11=超高速
- GPIOx_PUPDR(上拉/下拉):00=无,01=上拉,10=下拉
- GPIOx_ODR(输出数据寄存器):写1输出高,写0输出低
- GPIOx_BSRR(位设置/复位寄存器):低16位置位,高16位复位,原子操作
核心技巧:操作ODR和BSRR的区别很大。ODR是“读-改-写”,多线程或中断里容易出问题。BSRR是原子操作,写1就置位,写1就复位,不会被打断。我建议你养成用BSRR的习惯。
来个实际例子。配置PA5为推挽输出,速度50MHz:
// 开启GPIOA时钟
RCC->AHB1ENR |= (1 << 0);
// 配置PA5为输出模式(MODER[11:10] = 01)
GPIOA->MODER &= ~(0x3 << 10); // 先清零
GPIOA->MODER |= (0x1 << 10); // 设置为输出
// 推挽输出(默认就是推挽,这步可省略)
GPIOA->OTYPER &= ~(1 << 5);
// 速度50MHz(OSPEEDR[11:10] = 10)
GPIOA->OSPEEDR &= ~(0x3 << 10);
GPIOA->OSPEEDR |= (0x2 << 10);
// 无上拉下拉
GPIOA->PUPDR &= ~(0x3 << 10);
// 输出高电平(用BSRR)
GPIOA->BSRR = (1 << 5); // 置位
// 输出低电平
GPIOA->BSRR = (1 << (5+16)); // 复位
3.3 HAL库函数:封装好的便利
寄存器操作虽然直接,但项目里我还是推荐用HAL库。原因很简单:可读性好,移植方便。你想想看,如果团队里每个人写寄存器的方式都不一样,代码维护起来得多痛苦。
HAL库GPIO的核心函数就这几个:
// 初始化GPIO
HAL_GPIO_Init(GPIO_TypeDef *GPIOx, GPIO_InitTypeDef *GPIO_Init);
// 写引脚电平
HAL_GPIO_WritePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin, GPIO_PinState PinState);
// 切换引脚电平
HAL_GPIO_TogglePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin);
// 读取引脚电平
GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin);
用HAL库配置PA5点亮LED,代码长这样:
GPIO_InitTypeDef GPIO_InitStruct = {0};
// 开启时钟
__HAL_RCC_GPIOA_CLK_ENABLE();
// 配置结构体
GPIO_InitStruct.Pin = GPIO_PIN_5;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; // 推挽输出
GPIO_InitStruct.Pull = GPIO_NOPULL; // 无上下拉
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; // 高速
// 初始化
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
// 点亮LED
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);
// 熄灭LED
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET);
// 切换状态
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);
3.4 LED闪烁:第一个实战
闪烁的原理很简单:延时-亮-延时-灭,循环。但这里有个关键——延时函数怎么写?
最简单的办法是用HAL_Delay():
while (1)
{
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET); // 亮
HAL_Delay(500); // 延时500ms
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET); // 灭
HAL_Delay(500); // 延时500ms
}
但HAL_Delay有个问题——它是阻塞的。延时期间CPU啥也干不了。如果你要做多任务,就得用定时器或者SysTick。这个后面章节会细讲,这里先留个印象。
避坑指南:HAL_Delay依赖SysTick中断。如果你在中断服务函数里调HAL_Delay,系统会死锁。我曾经在外部中断里加了个延时做消抖,结果程序直接卡死……后来改用定时器才解决。
3.5 呼吸灯:PWM的入门
呼吸灯比闪烁高级一点。它不是简单的亮灭,而是亮度渐变。怎么实现?靠PWM(脉冲宽度调制)。
PWM的原理说白了就是:快速切换高低电平,通过调整高电平占的比例(占空比)来控制亮度。人眼有视觉暂留效应,只要频率够高(一般100Hz以上),看到的就是平均亮度。
用软件模拟PWM,代码也不复杂:
// 软件PWM实现呼吸灯
// 周期20ms(50Hz),分100级亮度
#define PWM_PERIOD 100 // 一个周期分100份
#define PWM_FREQ 5 // 每级延时5ms
void Breath_LED(void)
{
static uint8_t brightness = 0;
static int8_t direction = 1;
// 输出当前占空比
for (uint8_t i = 0; i < PWM_PERIOD; i++)
{
if (i < brightness)
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);
else
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET);
HAL_Delay(1); // 每级1ms,周期100ms
}
// 更新亮度
brightness += direction;
if (brightness >= PWM_PERIOD || brightness == 0)
direction = -direction;
}
这个代码有个问题——CPU占用率太高。实际项目中我推荐用硬件PWM(定时器),效率高得多。但软件PWM作为理解原理的手段,还是很有价值的。
3.6 知识体系总览
这一章的内容比较多,我画了张图帮你理清思路:
3.7 本章小结
这一章我们走了从寄存器到HAL库、从闪烁到呼吸灯的完整路径。你可能会觉得内容有点多,但这些都是基本功。GPIO用好了,后面I2C、SPI、UART这些协议栈配置才能顺手。
最后送你一句话:别怕寄存器,也别死磕寄存器。理解原理用HAL,出了问题看寄存器——这才是嵌入式工程师该有的姿态。