4、GPIO基础与LED控制:GPIO工作原理、数字输出控制LED、PWM调光原理、呼吸灯实现
各位同学,欢迎来到第四章。从这一章开始,我们终于要跟硬件“真刀真枪”地打交道了。
前面几章我们搭建好了开发环境,也认识了芯片的基本架构。但芯片再厉害,也得跟外界沟通对吧?怎么沟通?靠的就是GPIO——通用输入输出引脚。说白了,它就是芯片伸出来的“手脚”,用来感知外部世界,或者对外部设备发号施令。
今天我们就拿LED灯开刀,一步步把GPIO玩明白。从最基础的亮灭控制,到呼吸灯效果,全给你安排上。
4.1 GPIO到底是什么?
GPIO,全称General Purpose Input Output。名字很直白——通用目的的输入输出引脚。
每个GPIO引脚,内部其实是一堆MOS管和寄存器。你可以把它想象成一个“电子开关”。
- 输出模式:芯片内部决定引脚是高电平(3.3V)还是低电平(0V)。
- 输入模式:芯片读取引脚上的电压,判断外部是高还是低。
嗯,听起来很简单对吧?但实际项目中,坑可不少。
核心要点:GPIO的本质,就是通过寄存器配置,让引脚在“读”和“写”两种状态间切换。
我个人习惯把GPIO分成三类来看:
- 数字输出:控制LED、蜂鸣器、继电器。
- 数字输入:读取按键、传感器信号。
- 复用功能:比如I2C、SPI、UART这些通信协议,也是通过GPIO引脚实现的。
你想想看,一个芯片少则几十个引脚,多则上百个。每个引脚都能独立配置,这就是嵌入式开发的“基本功”。
4.2 数字输出控制LED——最简单的Hello World
每个嵌入式工程师的“Hello World”,就是点亮一个LED。我也不例外。
我记得第一次点亮LED时,心里还挺激动的。虽然只是让一个小灯亮起来,但那一刻,代码真的控制了物理世界。
4.2.1 硬件连接
先看电路。LED灯串联一个限流电阻,接到GPIO引脚和GND之间。
为什么需要电阻?因为LED是电流型器件。直接接3.3V,电流会很大,LED会烧掉。一般LED工作电流在5-20mA,我们取10mA左右。
电阻值怎么算?很简单:
R = (Vcc - Vf) / I
Vcc是3.3V,Vf是LED正向压降(红色LED约1.8V,蓝色约3.0V),I取10mA。
红色LED:R = (3.3 - 1.8) / 0.01 = 150Ω,取220Ω标准值。
| LED颜色 | 正向压降(Vf) | 推荐电阻(10mA) |
|---|---|---|
| 红色 | 1.8 - 2.0 | 150 - 220Ω |
| 绿色 | 2.0 - 2.4 | 120 - 180Ω |
| 蓝色 | 3.0 - 3.2 | 10 - 33Ω |
| 白色 | 3.0 - 3.4 | 10 - 33Ω |
注意:蓝色和白色LED的Vf接近3.3V,电阻太小容易烧引脚。我建议用33Ω以上,或者用低亮度的LED。
4.2.2 代码实现——点亮LED
以STM32为例,我们操作GPIO需要三步:
- 使能时钟:GPIO模块需要时钟才能工作。
- 配置模式:设置引脚为推挽输出。
- 写数据:设置引脚高或低电平。
// 点亮LED(假设LED接在PA0)
#include "stm32f1xx_hal.h"
void LED_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
// 1. 使能GPIOA时钟
__HAL_RCC_GPIOA_CLK_ENABLE();
// 2. 配置PA0为推挽输出
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(GPIOA, &GPIO_InitStruct);
}
int main(void)
{
HAL_Init();
LED_Init();
while(1)
{
// 3. 输出高电平,点亮LED
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET);
HAL_Delay(1000); // 延时1秒
// 输出低电平,熄灭LED
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET);
HAL_Delay(1000);
}
}
代码很简单,对吧?但这里有个细节——推挽输出是什么意思?
推挽输出,就是引脚内部有两个MOS管,一个负责拉高(推),一个负责拉低(挽)。输出高电平时,上管导通,下管截止;输出低电平时反过来。这样输出能力强,驱动LED完全没问题。
小技巧:如果你用开漏输出,需要外部上拉电阻才能输出高电平。推挽输出不需要,直接用。我一般控制LED都用推挽。
4.3 PWM调光原理——让LED“说话”
亮灭控制太简单了。接下来我们让LED实现调光——从最亮到最暗,平滑变化。
怎么调?改变电压?不行,LED是非线性器件,电压微调亮度变化很大。而且模拟调光效率低。
正确的做法是——PWM(脉冲宽度调制)。
说白了,就是让LED以极高的频率快速开关。人眼有视觉暂留效应,只要频率够高(一般100Hz以上),我们就感觉不到闪烁,只感觉到平均亮度。
占空比越高,平均亮度越高。占空比0%就是全灭,100%就是全亮。
| 占空比 | 效果 |
|---|---|
| 0% | 全灭 |
| 25% | 微亮 |
| 50% | 半亮 |
| 75% | 较亮 |
| 100% | 全亮 |
为什么会这样?因为人眼对亮度的感知是积分平均的。你想想看,一个快速闪烁的光源,人眼看到的就是它的平均亮度。
4.3.1 用定时器产生PWM
用软件延时做PWM?可以,但CPU会被占死。实际项目中,我们使用硬件定时器来自动产生PWM波形。
以STM32的定时器为例:
// 使用TIM2的通道1产生PWM,输出到PA0
void PWM_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
TIM_OC_InitTypeDef sConfigOC = {0};
TIM_HandleTypeDef htim2;
// 1. 使能时钟
__HAL_RCC_TIM2_CLK_ENABLE();
__HAL_RCC_GPIOA_CLK_ENABLE();
// 2. 配置PA0为复用功能(TIM2_CH1)
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; // 复用推挽输出
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
// 3. 配置定时器
htim2.Instance = TIM2;
htim2.Init.Prescaler = 72 - 1; // 72MHz / 72 = 1MHz
htim2.Init.Period = 1000 - 1; // 1MHz / 1000 = 1kHz PWM频率
htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
HAL_TIM_PWM_Init(&htim2);
// 4. 配置PWM通道
sConfigOC.OCMode = TIM_OCMODE_PWM1;
sConfigOC.Pulse = 500; // 初始占空比50%
sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1);
// 5. 启动PWM
HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);
}
// 设置占空比,范围0-1000
void SetDuty(uint16_t duty)
{
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, duty);
}
这里的关键参数:
- Prescaler:分频系数,把72MHz降到1MHz。
- Period:自动重装值,决定PWM频率。1MHz/1000 = 1kHz。
- Pulse:比较值,决定占空比。Pulse/Period就是占空比。
经验之谈:PWM频率选多少合适?控制LED,1kHz足够了。如果频率太低(比如50Hz),你会看到LED闪烁。频率太高(比如100kHz),开关损耗会增加。我一般用1kHz-10kHz。
4.4 呼吸灯实现——从亮到暗,平滑过渡
呼吸灯,就是LED像呼吸一样,从暗到亮,再从亮到暗,循环往复。
实现思路很简单:定时改变PWM的占空比,从0逐渐增加到最大值,再从最大值逐渐减小到0。
但这里有个坑——人眼对亮度的感知不是线性的。如果占空比线性变化,你会觉得亮的部分变化很快,暗的部分变化很慢。
正确的做法是使用指数曲线或对数曲线。不过初学者可以先从线性开始,感受一下效果。
// 呼吸灯实现
void BreathLight(void)
{
uint16_t duty;
while(1)
{
// 从暗到亮
for(duty = 0; duty < 1000; duty++)
{
SetDuty(duty);
HAL_Delay(2); // 每步延时2ms
}
// 从亮到暗
for(duty = 1000; duty > 0; duty--)
{
SetDuty(duty);
HAL_Delay(2);
}
}
}
运行一下,你会发现效果还行,但不够“丝滑”。
为什么?因为线性变化时,人眼在低亮度区域感觉变化更明显。我建议用指数曲线:
// 指数呼吸灯,效果更自然
#include <math.h>
void BreathLightExp(void)
{
uint16_t duty;
float t;
while(1)
{
// 从暗到亮,使用指数曲线
for(t = 0; t < 1.0; t += 0.001)
{
// duty = 1000 * (e^t - 1) / (e - 1)
duty = (uint16_t)(1000 * (exp(t) - 1) / (exp(1) - 1));
SetDuty(duty);
HAL_Delay(2);
}
// 从亮到暗
for(t = 1.0; t > 0; t -= 0.001)
{
duty = (uint16_t)(1000 * (exp(t) - 1) / (exp(1) - 1));
SetDuty(duty);
HAL_Delay(2);
}
}
}
避坑指南:我曾经在项目里直接用线性调光,客户反馈说“呼吸不均匀”。后来改成指数曲线,效果立马好了。人眼对亮度的感知是对数的,所以控制量要用指数来补偿。
4.5 本章小结
这一章我们干了三件事:
- 搞懂了GPIO的工作原理——其实就是寄存器控制的电子开关。
- 用数字输出点亮了LED——嵌入式世界的Hello World。
- 用PWM实现了调光和呼吸灯——让LED有了“生命”。
这些基础操作,后续所有外设控制都会用到。GPIO是嵌入式开发的“手脚”,PWM是“肌肉”,两者结合,能做的事情就多了。
下一章,我们会深入按键输入和中断。到时候你会发现,让芯片“听”到外部信号,比让它“说”话更有意思。
嗯,今天就到这里。动手试试吧,把呼吸灯调到你最喜欢的节奏。