第2章:STM32 GPIO控制——从寄存器到HAL库的实战

各位同学,欢迎来到第二章。上一章我们搭建好了开发环境,今天咱们来点实在的——直接操作GPIO。GPIO是嵌入式开发中最基础也最常用的外设,说白了就是芯片的“手脚”,用来跟外界打交道。

我个人习惯把GPIO的学习分成三个层次:理解模式配置 → 掌握寄存器操作 → 熟练使用HAL库。今天我们就按这个思路来。

2.1 GPIO模式配置:推挽、开漏、上拉下拉

先说说GPIO的几种工作模式。很多初学者一开始就被这堆模式搞晕了,其实没那么复杂。

2.1.1 推挽输出(Push-Pull Output)

推挽输出,你可以想象成两个开关:一个接VCC,一个接GND。输出高电平时,上面的开关闭合,下面的断开;输出低电平时反过来。这样输出能力强,驱动LED、蜂鸣器这类器件很合适。

我的经验:做LED流水灯时,我几乎都用推挽输出。驱动电流够大,而且电平切换速度快。但要注意,两个输出端不能直接短接,否则会烧管子——我在初学时犯过这个错。

2.1.2 开漏输出(Open-Drain Output)

开漏输出就只有一个下拉开关,高电平时靠外部上拉电阻拉高。这种模式常用于I2C总线、电平转换场景。

为什么要有开漏?你想想看,如果多个设备共用一根信号线,推挽输出会打架——一个输出高一个输出低,直接短路。开漏就不存在这个问题,谁想拉低就拉低,想输出高就靠上拉电阻。

避坑指南:我曾经在一个项目里用开漏输出驱动LED,结果发现LED亮度不够。后来才意识到,开漏输出高电平是靠上拉电阻的,电流有限。驱动LED还是用推挽靠谱。

2.1.3 上拉与下拉(Pull-Up / Pull-Down)

上拉下拉是给输入模式用的。芯片内部有弱上拉(约40kΩ)和弱下拉电阻,可以配置是否启用。

配置 引脚空闲状态 典型应用
上拉输入 高电平 按键检测(按下为低)
下拉输入 低电平 按键检测(按下为高)
浮空输入 不确定 外部已有上拉/下拉

嗯,这里要注意:浮空输入状态下,引脚电平是不确定的,容易受外界干扰。我建议除非外部电路已经处理好了,否则尽量用内部上拉或下拉。

2.2 寄存器版LED流水灯实现

直接操作寄存器,是理解芯片底层最好的方式。STM32F103的GPIO挂在APB2总线上,控制寄存器主要有这几个:

  • CRL / CRH:配置模式(输入/输出/复用)和速度
  • ODR:输出数据寄存器,写1输出高,写0输出低
  • BSRR:位设置/复位寄存器,原子操作,推荐使用
  • IDR:输入数据寄存器,读取引脚电平

下面是一个用寄存器操作的流水灯示例,控制PB0、PB1、PB2三个引脚:

// 寄存器版LED流水灯
#define GPIOB_BASE    0x40010C00
#define GPIOB_CRL     *(volatile uint32_t *)(GPIOB_BASE + 0x00)
#define GPIOB_ODR     *(volatile uint32_t *)(GPIOB_BASE + 0x0C)
#define GPIOB_BSRR    *(volatile uint32_t *)(GPIOB_BASE + 0x10)

void GPIO_Init(void)
{
    // 开启GPIOB时钟(RCC_APB2ENR寄存器)
    *(volatile uint32_t *)0x40021018 |= (1 << 3);
    
    // 配置PB0、PB1、PB2为推挽输出,50MHz
    // CRL寄存器每4位控制一个引脚
    GPIOB_CRL &= ~(0xF << 0);   // 清除PB0配置
    GPIOB_CRL |= (0x3 << 0);    // 设置为推挽输出,50MHz
    GPIOB_CRL &= ~(0xF << 4);   // 清除PB1配置
    GPIOB_CRL |= (0x3 << 4);    // 设置为推挽输出,50MHz
    GPIOB_CRL &= ~(0xF << 8);   // 清除PB2配置
    GPIOB_CRL |= (0x3 << 8);    // 设置为推挽输出,50MHz
}

void LED_Flow(void)
{
    uint8_t i;
    uint16_t led_pins[] = {0, 1, 2};  // PB0, PB1, PB2
    
    while(1)
    {
        for(i = 0; i < 3; i++)
        {
            // 使用BSRR寄存器:低16位置位,高16位复位
            GPIOB_BSRR = (1 << led_pins[i]);          // 点亮当前LED
            GPIOB_BSRR = (1 << (led_pins[i] + 16));  // 熄灭上一个LED
            
            // 简单延时
            for(uint32_t j = 0; j < 500000; j++);
        }
    }
}

注意:操作CRL/CRH寄存器时,一定要先清除再设置。否则旧配置和新配置会叠加,产生意想不到的结果。我见过有人直接赋值,结果引脚模式全乱了。

2.3 HAL库版LED流水灯实现

用寄存器操作虽然底层,但可读性差、移植困难。HAL库封装了这些操作,让代码更清晰。同样的功能,用HAL库写是这样的:

// HAL库版LED流水灯
#include "stm32f1xx_hal.h"

void LED_Flow_HAL(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    
    // 使能GPIOB时钟
    __HAL_RCC_GPIOB_CLK_ENABLE();
    
    // 配置PB0、PB1、PB2为推挽输出
    GPIO_InitStruct.Pin = GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
    
    uint8_t led_pins[] = {GPIO_PIN_0, GPIO_PIN_1, GPIO_PIN_2};
    uint8_t i;
    
    while(1)
    {
        for(i = 0; i < 3; i++)
        {
            HAL_GPIO_WritePin(GPIOB, led_pins[i], GPIO_PIN_SET);
            HAL_GPIO_WritePin(GPIOB, led_pins[(i+2) % 3], GPIO_PIN_RESET);
            HAL_Delay(200);
        }
    }
}

看到区别了吗?HAL库把寄存器操作封装成了结构体和函数,代码更易读。但代价是多了些函数调用开销。不过对于大多数应用来说,这点性能损失可以忽略。

2.4 按键中断与消抖处理

按键处理是嵌入式开发中的经典问题。硬件上简单,但软件处理不好就会出各种问题。

2.4.1 按键消抖原理

机械按键在按下和释放的瞬间,会产生抖动——电平在几毫秒内反复跳变。如果不处理,一次按键可能被误判成多次。

消抖方法有两种:

  • 硬件消抖:加RC滤波电路或施密特触发器
  • 软件消抖:延时采样法(检测到电平变化后,延时10-20ms再读一次)

我个人更倾向软件消抖,成本低、灵活。除非是工业级高可靠性场景,才考虑硬件消抖。

2.4.2 中断方式实现按键检测

用轮询方式检测按键会浪费CPU资源。中断方式更高效——按键按下时才触发处理。

// HAL库版按键中断 + 软件消抖
#define KEY_PIN     GPIO_PIN_13   // PC13,开发板上的按键
#define KEY_PORT    GPIOC

// 按键中断回调函数
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    static uint32_t last_tick = 0;
    uint32_t current_tick = HAL_GetTick();
    
    if(GPIO_Pin == KEY_PIN)
    {
        // 软件消抖:判断两次中断间隔是否大于20ms
        if((current_tick - last_tick) > 20)
        {
            // 确认按键状态(低电平表示按下)
            if(HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN) == GPIO_PIN_RESET)
            {
                // 按键有效,执行操作
                // 比如切换LED状态
                HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_0);
            }
        }
        last_tick = current_tick;
    }
}

// 初始化按键中断
void Key_Interrupt_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    
    __HAL_RCC_GPIOC_CLK_ENABLE();
    __HAL_RCC_AFIO_CLK_ENABLE();  // 复用功能时钟
    
    GPIO_InitStruct.Pin = KEY_PIN;
    GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;  // 下降沿触发
    GPIO_InitStruct.Pull = GPIO_PULLUP;           // 内部上拉
    HAL_GPIO_Init(KEY_PORT, &GPIO_InitStruct);
    
    // 配置中断优先级并使能
    HAL_NVIC_SetPriority(EXTI15_10_IRQn, 2, 0);
    HAL_NVIC_EnableIRQ(EXTI15_10_IRQn);
}

我的经验:消抖时间不是固定的。我遇到过一种按键,抖动时间长达30ms。所以建议在实际项目中,用示波器看一下按键波形,再确定消抖延时。别盲目用20ms。

2.4.3 中断服务函数写法

HAL库的中断处理流程是这样的:中断触发 → 进入中断服务函数 → 调用HAL库的通用处理函数 → 回调用户函数。所以我们需要在stm32f1xx_it.c中写:

// stm32f1xx_it.c
void EXTI15_10_IRQHandler(void)
{
    HAL_GPIO_EXTI_IRQHandler(KEY_PIN);
}

然后HAL库会自动调用我们上面写的HAL_GPIO_EXTI_Callback函数。这种设计把底层中断处理和用户逻辑分开了,挺好的。

注意:中断服务函数里不要做耗时操作。比如延时、打印、复杂计算等。我曾经在回调里加了串口打印,结果按键响应变得很慢,还影响了其他中断。中断里只做标志位设置或简单状态切换,具体处理放到主循环里。

2.5 本章小结

这一章我们覆盖了GPIO的核心内容:

  • 推挽输出适合驱动器件,开漏输出适合总线共享
  • 上拉下拉让输入引脚有确定状态
  • 寄存器操作让你理解底层,HAL库让你高效开发
  • 按键消抖是基本功,中断方式更高效

下一章我们会深入定时器,那是嵌入式控制的灵魂。到时候我会分享一个我踩过的坑——定时器溢出中断没处理好,导致电机控制出了大问题。敬请期待。

课后练习:试着用寄存器方式实现按键中断,并对比HAL库方式的代码量。你会发现,HAL库虽然代码行数多,但可读性和可维护性确实好很多。