3. STM32基础外设驱动:GPIO控制LED与按键、定时器中断、USART串口通信原理与编程

好,咱们进入第三章节。前面两章我们把开发环境搭好了,也跑通了第一个点灯程序。但说真的,那个点灯只是让你感受一下“我能控制单片机了”。这一章,我们要把嵌入式开发最核心的三个基本功练扎实:GPIO、定时器、串口。

这三个东西有多重要?我打个比方。GPIO就是单片机的“手脚”,用来跟外界交互;定时器是它的“心跳”,用来精确控制节奏;串口是它的“嘴巴”,用来跟电脑或者其他设备说话。你想想看,任何一个嵌入式系统,是不是都离不开这三样?

本章核心目标: 掌握STM32三大基础外设的驱动原理与编程方法,能够独立完成LED闪烁、按键检测、定时中断和串口收发。
STM32 基础外设知识体系 STM32 基础外设 GPIO 通用输入输出 定时器 TIM USART 串口 推挽输出 浮空输入 上拉/下拉 定时中断 PWM 输出 输入捕获 波特率配置 中断收发 DMA 传输 掌握这三个外设,你就具备了开发大多数嵌入式系统的基础能力

3.1 GPIO:从点灯到按键检测

GPIO,全称 General Purpose Input Output,通用输入输出。说白了,就是单片机上的那些引脚,你可以让它们输出高电平或低电平,也可以读取外部信号是高还是低。

STM32的GPIO有8种工作模式。但实际项目中,常用的就那么几种。我列个表给你看:

模式 用途 典型场景
推挽输出 输出高低电平 驱动LED、蜂鸣器
开漏输出 需要外部上拉 I2C总线、多设备共用
浮空输入 电平不确定 外部信号检测
上拉输入 默认高电平 按键检测(按下为低)
下拉输入 默认低电平 按键检测(按下为高)
模拟输入 读取连续电压 ADC采样
我的习惯: 做按键检测时,我一般用上拉输入。这样按键没按下时引脚读到的就是高电平,按下后接地变成低电平。逻辑清晰,不容易误判。

3.1.1 点亮LED——推挽输出实战

咱们还是用最经典的LED闪烁来练手。不过这次,我要你理解每一行代码在干什么。

// 初始化PB0为推挽输出
void LED_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    
    __HAL_RCC_GPIOB_CLK_ENABLE();   // 使能GPIOB时钟
    
    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(GPIOB, &GPIO_InitStruct);
}

// 主循环中闪烁
while (1)
{
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET);   // 亮
    HAL_Delay(500);                                        // 延时500ms
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET); // 灭
    HAL_Delay(500);
}

这里有个细节要注意:时钟使能。STM32的外设默认都是关闭时钟的,你要用哪个外设,就得先打开它的时钟。我刚开始学的时候,经常忘了这一步,结果怎么配置引脚都没反应。嗯,后来就长记性了。

3.1.2 按键检测——上拉输入实战

按键检测看起来简单,但坑不少。最典型的问题就是抖动。机械按键在按下和释放的瞬间,电平会来回跳变几次,如果不做处理,一次按下可能被误判成好几次。

我曾经踩过的坑: 有一次做产品,按键没做消抖处理,结果用户按一下,设备响应了三四次。用户投诉说“这机器疯了”。从那以后,我所有按键都老老实实做消抖。

消抖的方法有两种:硬件消抖(加RC滤波)和软件消抖(延时采样)。这里我们讲软件消抖,因为不增加成本。

// 按键初始化 - 使用上拉输入
void Key_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    
    __HAL_RCC_GPIOA_CLK_ENABLE();
    
    GPIO_InitStruct.Pin = GPIO_PIN_0;          // PA0接按键
    GPIO_InitStruct.Mode = GPIO_MODE_INPUT;    // 输入模式
    GPIO_InitStruct.Pull = GPIO_PULLUP;        // 上拉,默认高电平
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
}

// 按键扫描函数 - 带消抖
uint8_t Key_Scan(void)
{
    if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) // 检测到按下
    {
        HAL_Delay(10);  // 延时10ms,避开抖动区间
        if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) // 再次确认
        {
            while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET); // 等待释放
            return 1;  // 有效按键
        }
    }
    return 0;  // 无按键
}

你看,逻辑其实很简单:第一次检测到按下,先等10ms,再读一次。如果还是按下状态,说明是有效按键,不是抖动。然后等待按键释放再返回,这样一次按下只触发一次动作。

3.2 定时器中断:让单片机学会“准时”

前面我们用 HAL_Delay() 做延时,但这种方法有个大问题:延时期间CPU啥也干不了,就在那傻等。这叫阻塞式延时。在实际项目中,这种写法基本不能用。

那怎么办?用定时器中断。让定时器自己计数,计到指定值就触发中断,CPU在中断里处理任务。这样CPU平时可以干别的事,不用干等。

3.2.1 定时器的工作原理

STM32的定时器本质上就是一个递增计数器。它内部有一个时钟源,每来一个时钟脉冲,计数器就加1。当计数器的值等于你设置的自动重装载值(ARR)时,就会触发更新事件,计数器归零重新开始。

定时时间的计算公式:

定时时间 = (预分频值 + 1) × (自动重装载值 + 1) / 定时器时钟频率

举个例子,如果定时器时钟是72MHz,你想产生1ms的定时中断:

  • 预分频值设为 71(72分频),得到1MHz的计数频率
  • 自动重装载值设为 999,这样计数1000次就是1ms
void TIM_Init(void)
{
    TIM_HandleTypeDef htim2 = {0};
    
    __HAL_RCC_TIM2_CLK_ENABLE();  // 使能TIM2时钟
    
    htim2.Instance = TIM2;
    htim2.Init.Prescaler = 71;         // 72MHz / 72 = 1MHz
    htim2.Init.Period = 999;           // 计数1000次,1ms
    htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
    HAL_TIM_Base_Init(&htim2);
    
    HAL_NVIC_SetPriority(TIM2_IRQn, 0, 0);  // 设置中断优先级
    HAL_NVIC_EnableIRQ(TIM2_IRQn);          // 使能中断
    
    HAL_TIM_Base_Start_IT(&htim2);          // 启动定时器中断
}

// 中断回调函数
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
    if (htim->Instance == TIM2)
    {
        // 每1ms执行一次的任务
        // 比如:按键扫描、LED闪烁控制等
    }
}
关键点: 中断服务函数里不要做耗时操作。我见过有人把延时函数写在中断里,结果整个系统卡死。中断里应该只做标志位设置、数据拷贝等轻量级操作,具体处理放到主循环。

3.3 USART串口通信:单片机的“嘴巴”

串口通信,是嵌入式开发中最常用的调试手段。没有之一。你想想看,单片机跑起来之后,你怎么知道它内部发生了什么?靠LED闪烁?太原始了。靠显示屏?成本高。最方便的就是通过串口把数据发到电脑上,用串口助手一看,什么都清楚了。

3.3.1 串口通信协议

USART(Universal Synchronous/Asynchronous Receiver/Transmitter)是一种异步串行通信协议。说人话就是:一根线发数据,一根线收数据,不需要时钟线,双方约定好速度就行。

串口通信的几个关键参数:

参数 说明 常用值
波特率 每秒传输的比特数 9600、115200
数据位 一帧数据中的有效位数 8位(最常用)
停止位 一帧结束的标志位 1位
校验位 用于检错 无校验(NONE)

3.3.2 串口初始化与收发

STM32的串口配置,核心就是设置波特率和中断。我一般用115200波特率,速度够快,稳定性也好。

UART_HandleTypeDef huart1;

void UART_Init(void)
{
    __HAL_RCC_USART1_CLK_ENABLE();
    __HAL_RCC_GPIOA_CLK_ENABLE();
    
    // 配置TX(PA9)和RX(PA10)为复用功能
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    GPIO_InitStruct.Pin = GPIO_PIN_9 | GPIO_PIN_10;
    GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;      // 复用推挽输出
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    GPIO_InitStruct.Alternate = GPIO_AF1_USART1; // 复用功能映射
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
    
    // USART1配置
    huart1.Instance = USART1;
    huart1.Init.BaudRate = 115200;
    huart1.Init.WordLength = UART_WORDLENGTH_8B;
    huart1.Init.StopBits = UART_STOPBITS_1;
    huart1.Init.Parity = UART_PARITY_NONE;
    huart1.Init.Mode = UART_MODE_TX_RX;          // 同时使能收发
    huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE;
    HAL_UART_Init(&huart1);
    
    // 使能接收中断
    HAL_UART_Receive_IT(&huart1, &rx_data, 1);
}

// 发送字符串
void UART_SendString(char *str)
{
    HAL_UART_Transmit(&huart1, (uint8_t *)str, strlen(str), 1000);
}

// 接收中断回调
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1)
    {
        // 处理接收到的数据
        // 比如:回显、解析命令等
        HAL_UART_Transmit(&huart1, &rx_data, 1, 100);  // 回显
        HAL_UART_Receive_IT(&huart1, &rx_data, 1);     // 继续接收
    }
}
我的调试习惯: 每写一个模块,我都会在初始化完成后通过串口打印一条日志,比如 "LED Init OK"、"UART Init OK"。这样上电一看串口输出,就知道哪些模块初始化成功了,哪些出了问题。这个习惯帮我省了无数排查时间。

3.4 综合实战:按键控制LED,串口打印状态

好了,我们把前面学的三个知识点串起来,做一个完整的例子:按一下按键,LED状态翻转,同时通过串口打印当前LED的状态。

uint8_t led_status = 0;  // 0:灭, 1:亮

int main(void)
{
    HAL_Init();
    SystemClock_Config();  // 配置系统时钟为72MHz
    
    LED_Init();      // 初始化LED
    Key_Init();      // 初始化按键
    UART_Init();     // 初始化串口
    
    UART_SendString("System Start!\r\n");
    
    while (1)
    {
        if (Key_Scan())  // 检测到按键按下
        {
            led_status = !led_status;  // 翻转状态
            HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, 
                led_status ? GPIO_PIN_SET : GPIO_PIN_RESET);
            
            // 通过串口打印状态
            if (led_status)
                UART_SendString("LED ON\r\n");
            else
                UART_SendString("LED OFF\r\n");
        }
    }
}

这个例子虽然简单,但它包含了嵌入式开发的完整闭环:输入(按键)→ 处理(状态翻转)→ 输出(LED显示+串口打印)。你以后做的任何项目,本质上都是这个模式的扩展。

本章总结:
  • GPIO的推挽输出用于控制LED等外设,上拉输入用于按键检测
  • 按键必须做消抖处理,软件消抖10ms延时是常用方案
  • 定时器中断比HAL_Delay更高效,适合周期性任务
  • 串口是调试利器,建议每个模块初始化后都打印状态信息

说实话,这三个外设我用了十几年,每次新项目都离不开它们。你把这章的内容吃透了,后面学LoRa通信、GPS定位、数据采集这些高级功能,就会轻松很多。因为不管多复杂的系统,底层都是这些基础外设在支撑。