2、GPIO基础与LED驱动:GPIO模式配置(推挽/开漏/上拉/下拉);寄存器操作与HAL库对比;LED闪烁与呼吸灯实现
2.1 GPIO到底是什么?先别急着写代码
很多新手拿到开发板,第一件事就是点灯。这没错。但我想先聊聊GPIO的本质。
GPIO,全称General Purpose Input Output,通用输入输出口。说白了,它就是芯片和外界打交道的最基本通道。你可以把它想象成一根引脚,要么输出高电平(3.3V或5V),要么输出低电平(0V),要么读取外部电平。
但事情没这么简单。同一个引脚,在不同场景下,它的“性格”完全不同。这就引出了GPIO的模式配置。
2.2 GPIO的四种“性格”:推挽、开漏、上拉、下拉
我在项目中遇到过好几次,明明代码逻辑没问题,LED就是不亮。查了半天,原来是GPIO模式配错了。嗯,这里要注意。
2.2.1 推挽输出(Push-Pull)
推挽输出,是我用得最多的模式。它内部有两个MOS管,一个负责推(输出高电平),一个负责挽(输出低电平)。
- 输出高电平时:上管导通,下管截止,引脚直接连到VCC
- 输出低电平时:上管截止,下管导通,引脚直接连到GND
说白了,推挽输出的驱动能力很强。驱动LED时,我一般首选推挽模式。但要注意,两个管子不能同时导通,否则会短路烧芯片。
2.2.2 开漏输出(Open-Drain)
开漏输出,只有下管工作,上管被拿掉了。所以它只能输出低电平,不能主动输出高电平。
那高电平怎么办?需要外部接一个上拉电阻。这就是开漏的典型用法——电平转换。比如3.3V的MCU要控制5V的设备,用开漏输出加上拉电阻到5V,就能实现。
我个人习惯在I2C总线上用开漏模式。为什么?因为多个设备可以共用一根线,谁拉低谁说话,不会打架。
2.2.3 上拉与下拉(Pull-Up / Pull-Down)
这两个不是输出模式,而是输入模式下的配置。但很多初学者容易搞混。
上拉:引脚内部接一个电阻到VCC。默认状态下,引脚是高电平。按下按键接地,引脚变低。
下拉:引脚内部接一个电阻到GND。默认状态下,引脚是低电平。按下按键接VCC,引脚变高。
我曾经犯过一个低级错误:用外部按键检测,没配置内部上拉,结果引脚悬空,电平乱跳。后来加了上拉电阻,问题解决。
2.3 寄存器操作 vs HAL库:两种思路
写驱动有两种方式:直接操作寄存器,或者用HAL库。我两种都用过,各有优劣。
2.3.1 寄存器操作:快、准、狠
直接操作寄存器,说白了就是直接读写芯片的物理地址。比如STM32F103的GPIOA,基地址是0x40010800。要配置PA0为推挽输出,可以这样写:
// 开启GPIOA时钟
RCC->APB2ENR |= (1 << 2);
// 配置PA0为推挽输出,50MHz
GPIOA->CRL &= ~(0x0F << 0); // 先清零
GPIOA->CRL |= (0x03 << 0); // 设置模式
// 输出高电平
GPIOA->BSRR = (1 << 0);
// 输出低电平
GPIOA->BRR = (1 << 0);
为什么用BSRR和BRR?因为直接写ODR寄存器会有“读-改-写”的风险,在多任务环境下可能出错。BSRR和BRR是原子操作,安全。
2.3.2 HAL库:省心、可移植、但慢
HAL库把寄存器操作封装成了函数。同样的功能,用HAL库写:
// 初始化GPIO
GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; // 推挽输出
GPIO_InitStruct.Pull = GPIO_NOPULL; // 无上下拉
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
// 输出高电平
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET);
// 输出低电平
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET);
// 翻转电平
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_0);
你看,代码更长了,但可读性更好。而且HAL库在不同芯片间移植时,只需要改底层配置,上层逻辑不用动。
2.4 LED闪烁:从简单到复杂
2.4.1 基础闪烁:延时法
最简单的LED闪烁,就是延时翻转。用HAL库写:
while (1)
{
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_0);
HAL_Delay(500); // 延时500ms
}
但这种方法有个问题:延时期间CPU啥也干不了。你想想看,如果系统还要处理按键、通信,这种写法就废了。
2.4.2 进阶闪烁:定时器法
用定时器中断,每500ms翻转一次。CPU可以干别的事。
// 定时器中断回调
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM2)
{
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_0);
}
}
这才是正经做法。我在项目中从来不用延时法,除非是测试代码。
2.5 呼吸灯:PWM的精髓
呼吸灯,说白了就是让LED的亮度从暗到亮,再从亮到暗,循环变化。这需要PWM(脉冲宽度调制)。
PWM的原理很简单:在一个周期内,高电平的时间占比(占空比)决定了LED的亮度。占空比越大,LED越亮。
用定时器的PWM模式,配置起来也不复杂:
// 初始化PWM
TIM_OC_InitTypeDef sConfigOC = {0};
sConfigOC.OCMode = TIM_OCMODE_PWM1;
sConfigOC.Pulse = 0; // 初始占空比0
sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
sConfigOC.OCFastMode = TIM_OCFAST_DISABLE;
HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1);
// 启动PWM
HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);
// 在主循环中改变占空比
while (1)
{
for (int i = 0; i <= 1000; i += 10)
{
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, i);
HAL_Delay(10);
}
for (int i = 1000; i >= 0; i -= 10)
{
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, i);
HAL_Delay(10);
}
}
这里有个细节:Pulse值从0到1000变化,对应占空比0%到100%。为什么是1000?因为定时器的ARR(自动重装载值)设成了1000。ARR越大,PWM的精度越高。
2.6 总结:GPIO驱动,细节决定成败
GPIO看似简单,但模式配置、寄存器操作、PWM实现,每一个环节都有坑。我的经验是:
- 先搞清楚引脚的功能和模式,再写代码
- 能用定时器就别用延时,除非是测试
- PWM的ARR和Pulse值要匹配,别让LED闪烁
- 寄存器操作和HAL库,选一个你熟悉的,别混用
好了,这一章就到这里。下一章我们聊聊中断和按键消抖,那又是一个容易踩坑的地方。