3、硬件抽象层(HAL)与GPIO控制
好,咱们进入第三章。这一章讲的是嵌入式开发里最基础、也最绕不开的东西——GPIO控制。
你可能觉得GPIO太简单了,不就是点个灯、读个按键吗?嗯,我当年也是这么想的。直到有一次在MRI射频脉冲板调试中,因为GPIO配置不对,导致整个时序乱了套,示波器上一片毛刺……那滋味,真不好受。
所以这一章,咱们把HAL库初始化、GPIO的几种配置模式、推挽输出和开漏输出的区别,还有按键的中断与轮询,一次性讲透。
3.1 HAL库初始化:别小看这一步
很多人拿到开发板,上来就写main函数,直接调GPIO。结果发现灯不亮、按键没反应。为什么?因为HAL库还没初始化。
HAL库的初始化,说白了就是告诉芯片:「我要用哪些外设,时钟怎么配,中断怎么走。」
我个人习惯,在main函数一开始就调用 HAL_Init(),然后配系统时钟。像这样:
int main(void)
{
HAL_Init(); // 初始化HAL库
SystemClock_Config(); // 配置系统时钟
MX_GPIO_Init(); // 初始化GPIO
// 其他外设初始化...
while(1)
{
// 主循环
}
}
这里有个坑。我曾经在项目里忘了调 SystemClock_Config(),结果芯片跑在默认的16MHz内部振荡器上,时序全乱。后来查了半天才发现是时钟没配。所以,时钟配置是第一步,别跳过去。
3.2 GPIO配置模式:不只是输入输出
GPIO的模式,比你想象的多。STM32的GPIO有8种模式,但咱们常用的就几种:
| 模式 | 说明 | 典型用途 |
|---|---|---|
| 输入浮空 | 引脚高阻态,电平由外部决定 | 按键输入(外部上拉) |
| 输入上拉 | 内部上拉电阻,默认高电平 | 按键输入(省外部电阻) |
| 输入下拉 | 内部下拉电阻,默认低电平 | 某些传感器信号 |
| 推挽输出 | 可输出高/低电平,驱动能力强 | LED、继电器、普通信号 |
| 开漏输出 | 只能输出低电平,高电平靠外部上拉 | I2C总线、多路复用信号 |
| 复用功能 | 引脚由外设控制(如USART、SPI) | 串口、SPI、定时器输出 |
配置代码也很简单,用HAL库的 HAL_GPIO_Init() 就行:
GPIO_InitTypeDef GPIO_InitStruct = {0};
// 配置PA0为输入,上拉
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
// 配置PB1为推挽输出
GPIO_InitStruct.Pin = GPIO_PIN_1;
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);
你想想看,如果配错了模式,比如把推挽输出配成了开漏输出,又没有外部上拉,那输出高电平的时候,引脚就是高阻态,灯根本不会亮。这种问题,示波器一看就知道,但新手往往查半天。
3.3 推挽输出 vs 开漏输出:到底怎么选?
这个问题,面试常问,项目里也常踩坑。
推挽输出,说白了就是内部有两个MOS管,一个推高,一个拉低。输出高电平时,上管导通,直接怼到VCC;输出低电平时,下管导通,直接拉到GND。驱动能力强,速度快。
开漏输出,只有下管,没有上管。输出低电平时,下管导通,拉到GND;输出高电平时,下管断开,引脚浮空。所以必须外部接一个上拉电阻到VCC。
什么时候用开漏?我举个例子。I2C总线,多个设备共用两根线。如果都用推挽输出,两个设备同时输出不同电平,那就短路了。开漏输出就不会,因为高电平是靠上拉电阻,谁都可以拉低,但谁都不能强行拉高。这就是「线与」逻辑。
- 推挽输出:高低电平都能主动驱动,适合点灯、控制继电器
- 开漏输出:只能主动拉低,高电平靠外部上拉,适合总线共享
我在MRI射频板的设计中,用开漏输出控制几个射频开关的使能信号。因为这些开关的使能是低电平有效,而且多个控制信号需要「线与」。用开漏输出,一个拉低,全部拉低,非常安全。
3.4 按键输入:轮询还是中断?
按键处理,是嵌入式开发的入门课,也是容易出问题的地方。
轮询方式,就是在主循环里不断读引脚电平。简单,但浪费CPU。适合按键不多、实时性要求不高的场景。
while(1)
{
if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET)
{
// 按键按下
HAL_Delay(20); // 消抖
if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET)
{
// 确认按下
// 执行按键功能
}
}
}
这里有个经典问题:消抖。机械按键按下和松开的时候,会有几毫秒的抖动。如果不做消抖,一次按下可能被识别成好几次。我见过有人用10ms延时消抖,结果按键响应慢得像老年机。其实,20ms延时加两次判断,基本够用。
中断方式,就是按键按下时触发一个中断,CPU暂停当前任务去处理按键。效率高,响应快。适合实时性要求高的场景,比如MRI射频脉冲的启动按键。
// 配置PA0为外部中断,下降沿触发
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
// 使能中断
HAL_NVIC_SetPriority(EXTI0_IRQn, 0, 0);
HAL_NVIC_EnableIRQ(EXTI0_IRQn);
// 中断服务函数
void EXTI0_IRQHandler(void)
{
HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
}
// 回调函数
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if(GPIO_Pin == GPIO_PIN_0)
{
// 按键按下,执行操作
// 注意:中断里不要做耗时操作
}
}
中断里有个大忌:不要做延时、不要做复杂计算。中断服务函数应该短小精悍,只设标志位,具体处理放到主循环里。我曾经在中断里调了HAL_Delay,结果系统直接卡死。为什么?因为HAL_Delay依赖SysTick中断,而SysTick中断优先级比外部中断低,死锁了。
3.5 轮询 vs 中断:怎么选?
我个人的经验是:
- 按键数量少(1-2个),且主循环不忙:用轮询,简单可靠
- 按键数量多,或需要快速响应:用中断,但注意优先级和中断处理时间
- 系统有实时任务(如MRI脉冲时序):必须用中断,轮询会耽误大事
我记得有一次调试MRI的射频脉冲序列,按键用来触发脉冲发射。用轮询的时候,主循环里有个10ms的延时,结果按键按下去,脉冲要等10ms才出来。这在MRI里是致命的,10ms的延迟会导致图像相位错乱。后来改成中断,问题解决。
嗯,这一章的内容就这些。GPIO看似简单,但配置错了、模式选错了、中断用错了,都会出大问题。下一章咱们讲定时器,那是MRI脉冲时序的核心。
记住:硬件抽象层不是让你忽略硬件,而是让你更规范地操作硬件。