4、GPIO与基础外设:LED指示灯、按键输入、中断配置、定时器应用
好,咱们开始聊嵌入式开发里最基础、也最绕不开的几个东西——GPIO、按键、中断和定时器。你想想看,不管多复杂的物联网终端,最终跟物理世界打交道的,就是这些引脚和寄存器。我做了十几年嵌入式,每次拿到一块新板子,第一件事就是点个灯、读个按键,这几乎成了肌肉记忆。
4.1 LED指示灯:从硬件到驱动的第一课
LED指示灯,说白了就是GPIO输出。但这里有个坑——很多人以为直接给引脚高电平就能亮灯。其实不一定。你得看电路是“高电平点亮”还是“低电平点亮”。
关键点: 查看原理图,确认LED的阳极接VCC还是接GPIO。阳极接VCC,那就是低电平点亮;阳极接GPIO,那就是高电平点亮。
我在项目中遇到过一件事:一个同事把LED接反了,程序里写高电平点亮,结果灯死活不亮。查了半天,最后发现是硬件上LED的阴极直接接地了,阳极接的GPIO。嗯,这种低级错误其实挺常见的。
代码示例(基于STM32 HAL库):
// 初始化LED引脚
void LED_Init(void) {
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOB_CLK_ENABLE(); // 使能GPIOB时钟
GPIO_InitStruct.Pin = GPIO_PIN_0; // PB0
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);
}
// 点亮LED(假设低电平点亮)
#define LED_ON() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET)
#define LED_OFF() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET)
#define LED_TOGGLE() HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_0)
我的习惯: 用宏定义封装LED操作,而不是直接写HAL函数。这样后期换引脚或者改电平逻辑,只需要改宏定义,不用满世界找代码。
4.2 按键输入:去抖是门玄学
按键输入,看起来简单——读GPIO电平嘛。但实际用起来,你会发现按键按下和松开的时候,电平会抖动。这个抖动时间大概5-20ms,如果不处理,一次按下可能被识别成几十次。
为什么会这样?因为机械触点在接触瞬间会弹跳。你想想看,两个金属片碰在一起,不可能一下子就稳定接触。
我曾经在一个工业控制面板项目里,因为没做去抖处理,操作员按一次“启动”,机器连续启动了三次。嗯,那次差点出安全事故。从那以后,我对按键去抖格外重视。
去抖有两种常见方式:
- 硬件去抖: 加RC滤波电路,电容充放电平滑掉抖动。适合对成本不敏感的场景。
- 软件去抖: 检测到电平变化后,延时10-20ms再读一次。如果电平一致,才确认按键状态。这是最常用的方法。
代码示例(软件去抖):
#define KEY_PIN GPIO_PIN_13 // PC13
#define KEY_PORT GPIOC
uint8_t Key_Scan(void) {
if (HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN) == GPIO_PIN_RESET) {
HAL_Delay(20); // 延时去抖
if (HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN) == GPIO_PIN_RESET) {
// 等待按键释放
while (HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN) == GPIO_PIN_RESET);
return 1; // 有效按键
}
}
return 0;
}
注意: 上面的代码用了阻塞式延时(HAL_Delay),在简单应用里没问题。但在复杂系统中,阻塞延时会影响其他任务。这时候应该用定时器或状态机来做非阻塞去抖。
4.3 中断配置:别让CPU空转
轮询读按键,CPU一直在忙。如果系统里只有这一个任务还好,但工业设备通常要同时处理通信、显示、数据采集……这时候轮询就不合适了。中断就是用来解决这个问题的——让CPU在事件发生时再响应,平时该干嘛干嘛。
我个人习惯,只要可能,按键和外部事件都用中断。但要注意,中断服务函数(ISR)里不能做耗时操作。我在项目中见过有人直接在ISR里做printf、延时、甚至调用HAL_Delay,结果系统卡死。中断里只做标志位设置,具体处理放到主循环里。
配置外部中断的步骤:
- 使能GPIO时钟和SYSCFG时钟
- 配置GPIO为中断模式(上升沿、下降沿或双边沿触发)
- 配置NVIC(嵌套向量中断控制器),设置优先级
- 编写中断服务函数
代码示例:
// 配置PC13为外部中断,下降沿触发
void EXTI_Init(void) {
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOC_CLK_ENABLE();
__HAL_RCC_SYSCFG_CLK_ENABLE();
GPIO_InitStruct.Pin = GPIO_PIN_13;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING; // 下降沿中断
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOC, &GPIO_InitStruct);
HAL_NVIC_SetPriority(EXTI15_10_IRQn, 2, 0); // 设置优先级
HAL_NVIC_EnableIRQ(EXTI15_10_IRQn); // 使能中断
}
// 中断服务函数
void EXTI15_10_IRQHandler(void) {
if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_13) != RESET) {
// 设置标志位,不做具体处理
key_pressed_flag = 1;
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_13); // 清除中断标志
}
}
避坑指南: 我曾经在中断里忘了清除中断标志位,结果中断一直触发,CPU一直在处理中断,主循环根本跑不起来。所以,清除标志位这个动作,一定要写在ISR里,而且要在处理完事件之后立即执行。
4.4 定时器应用:精准的时间管理
定时器是嵌入式系统里最灵活的外设之一。你可以用它做延时、产生PWM、测量脉冲宽度、做系统心跳……我甚至用定时器做过一个简易的软件串口。
STM32的定时器分几类:
| 类型 | 特点 | 典型应用 |
|---|---|---|
| 基本定时器(TIM6/TIM7) | 只有定时功能,无外部引脚 | 产生时基、触发DAC |
| 通用定时器(TIM2-TIM5) | 有输入捕获、输出比较、PWM | 测量频率、产生PWM、编码器接口 |
| 高级定时器(TIM1/TIM8) | 功能最全,支持互补输出、刹车功能 | 电机控制、三相逆变 |
咱们先看一个最简单的应用——用定时器产生1ms中断,作为系统心跳。这样就不用HAL_Delay那种阻塞延时了。
// 配置TIM2,产生1ms中断
void TIM2_Init(void) {
TIM_HandleTypeDef htim2 = {0};
__HAL_RCC_TIM2_CLK_ENABLE();
htim2.Instance = TIM2;
htim2.Init.Prescaler = 72 - 1; // 72MHz / 72 = 1MHz
htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
htim2.Init.Period = 1000 - 1; // 1MHz / 1000 = 1kHz = 1ms
htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
HAL_TIM_Base_Init(&htim2);
HAL_NVIC_SetPriority(TIM2_IRQn, 1, 0);
HAL_NVIC_EnableIRQ(TIM2_IRQn);
HAL_TIM_Base_Start_IT(&htim2); // 启动定时器并使能中断
}
// 定时器中断服务函数
void TIM2_IRQHandler(void) {
HAL_TIM_IRQHandler(&htim2); // HAL库统一处理
}
// 回调函数(HAL库自动调用)
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
if (htim->Instance == TIM2) {
system_tick++; // 系统心跳计数
// 可以在这里处理周期性任务
}
}
核心思路: 定时器的预分频器(Prescaler)和自动重装载值(Period)共同决定了中断频率。公式很简单:中断频率 = 定时器时钟 / (Prescaler + 1) / (Period + 1)。记住这个公式,调定时器就不慌了。
你想想看,有了这个1ms的心跳,你就可以做很多事情了:
- 按键去抖:每隔10ms扫描一次按键状态
- LED闪烁:每隔500ms翻转一次LED
- 超时检测:如果某个事件超过1000ms没发生,就报错
- 任务调度:简单的非抢占式任务调度
我在一个LoRa网关项目里,就是用这个1ms定时器做所有任务的时基。LoRa模块的发送周期、传感器采集间隔、心跳包上报……全部基于这个system_tick。这样整个系统的时间基准统一,不会出现各个模块各自为政的情况。
注意: 定时器中断的优先级要合理设置。如果优先级太高,会抢占其他关键中断;如果太低,可能被其他中断频繁打断,导致定时不准。我一般把定时器中断设为中等优先级,比外部中断低,比普通任务高。
好了,这一章的内容就这些。GPIO、按键、中断、定时器,这四个东西是嵌入式开发的基石。你把这些玩熟了,后面做LoRa通信、传感器采集、协议栈移植,都会顺手很多。下一章咱们开始聊串口通信,那是物联网设备跟外界对话的嘴巴。