4、GPIO基础与LED控制:GPIO工作原理、数字输出控制LED、PWM调光原理、呼吸灯实现

各位同学,欢迎来到第四章。从这一章开始,我们终于要跟硬件“真刀真枪”地打交道了。

前面几章我们搭建好了开发环境,也认识了芯片的基本架构。但芯片再厉害,也得跟外界沟通对吧?怎么沟通?靠的就是GPIO——通用输入输出引脚。说白了,它就是芯片伸出来的“手脚”,用来感知外部世界,或者对外部设备发号施令。

今天我们就拿LED灯开刀,一步步把GPIO玩明白。从最基础的亮灭控制,到呼吸灯效果,全给你安排上。

4.1 GPIO到底是什么?

GPIO,全称General Purpose Input Output。名字很直白——通用目的的输入输出引脚。

每个GPIO引脚,内部其实是一堆MOS管和寄存器。你可以把它想象成一个“电子开关”。

  • 输出模式:芯片内部决定引脚是高电平(3.3V)还是低电平(0V)。
  • 输入模式:芯片读取引脚上的电压,判断外部是高还是低。

嗯,听起来很简单对吧?但实际项目中,坑可不少。

核心要点:GPIO的本质,就是通过寄存器配置,让引脚在“读”和“写”两种状态间切换。

我个人习惯把GPIO分成三类来看:

  1. 数字输出:控制LED、蜂鸣器、继电器。
  2. 数字输入:读取按键、传感器信号。
  3. 复用功能:比如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需要三步:

  1. 使能时钟:GPIO模块需要时钟才能工作。
  2. 配置模式:设置引脚为推挽输出。
  3. 写数据:设置引脚高或低电平。
// 点亮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 本章小结

这一章我们干了三件事:

  1. 搞懂了GPIO的工作原理——其实就是寄存器控制的电子开关。
  2. 用数字输出点亮了LED——嵌入式世界的Hello World。
  3. 用PWM实现了调光和呼吸灯——让LED有了“生命”。

这些基础操作,后续所有外设控制都会用到。GPIO是嵌入式开发的“手脚”,PWM是“肌肉”,两者结合,能做的事情就多了。

下一章,我们会深入按键输入和中断。到时候你会发现,让芯片“听”到外部信号,比让它“说”话更有意思。

嗯,今天就到这里。动手试试吧,把呼吸灯调到你最喜欢的节奏。