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 本章知识结构图

下面这张图帮你梳理本章的核心知识点:

GPIO基础与LED驱动知识结构 GPIO核心知识 工作原理 推挽/开漏输出 上拉/下拉/浮空输入 复用功能、输出速度 寄存器映射 MODER/OTYPER/OSPEEDR PUPDR/IDR/ODR/BSRR C语言指针操作硬件地址 HAL库函数 HAL_GPIO_Init/WritePin HAL_GPIO_TogglePin/ReadPin 结构体配置、时钟使能 LED驱动实战 单个LED点亮/熄灭 流水灯:逐个操作 流水灯:循环+移位 从寄存器到HAL库,从理论到实战

2.7 避坑指南

最后,我总结几个新手容易踩的坑:

  • 忘了使能时钟:STM32的外设默认时钟是关闭的。你不开时钟,写寄存器没用。我刚开始学的时候,至少在这上面浪费了半小时。
  • 引脚复用冲突:同一个引脚可能被多个外设使用。比如PA9既是USART1_TX,也是普通GPIO。配置前先看数据手册。
  • 电平逻辑搞反:一定要确认你的电路是“高电平点亮”还是“低电平点亮”。写代码前看一眼原理图。
  • HAL_Delay阻塞:HAL_Delay依赖SysTick中断,如果在中断里调用HAL_Delay,系统会死锁。我建议只在main循环里用。

重要提醒:调试LED时,先用万用表测引脚电压。如果代码烧进去灯不亮,先测引脚有没有输出。硬件问题比软件问题更难排查,先排除硬件。