前言
本系列学习笔记是本人跟随米醋电子工作室学习嵌入式的学习笔记,自用为主,不是教学或经验分享,若有误,大佬轻喷,同时欢迎交流学习,侵权即删。
一、GPIO基础知识
GPIO(通用输入/输出口)是微控制器上可以由用户自定义控制的引脚。
1.1. GPIO模式配置
输入模式
将引脚配置为输入模式,用于读取外部信号。
适用场景:读取按钮状态、传感器信号等
输出模式
将引脚配置为输出模式,用于驱动外部设备或信号。
适用场景:LED控制、电机驱动等
模拟模式
将引脚配置为模拟
模式,用于模拟信号输入或输出。
适用场景:ADC读取、DAC输出等
备用功能
将引脚配置为某个外设的备用功能,如UART、SPI或I2C等。
适用场景:通信接口、定时器输出等
1.2. 输出类型
推挽输出模式 (Push-Pull)
- 功能:可以驱动高电平(逻辑1)和低电平(逻辑0)
- 工作原理:有两个晶体管配置,一个用于拉高电平,一个用于拉低电平
- 优点:驱动能力强,适用于大多数数字输出应用
- 缺点:高频切换时可能导致较大功耗和信号干扰
- 应用示例:LED控制、开关控制、信号输出
开漏输出模式 (Open-Drain)
- 功能 :只能拉低电平(逻辑0),高电平由外部上拉电阻提供
- 工作原理:只有一个下拉晶体管,高电平依赖外部上拉电阻
- 优点:支持多路信号线"线与"逻辑,适合共享总线
- 缺点:无法直接驱动高电平,需要外部电阻
- 应用示例:I2C总线、外部信号线共享
推挽输出 vs 开漏输出
| 特性 | 推挽输出 | 开漏输出 |
|---|---|---|
| 高电平驱动 | ✓ 内部提供 | ✗ 需要外部上拉 |
| 低电平驱动 | ✓ 内部提供 | ✓ 内部提供 |
| 多设备共享总线 | ✗ 不适合 | ✓ 适合 |
| 电流驱动能力 | ✓ 较强 | ⚠ 取决于上拉电阻 |
| LED控制适用性 | ✓ 非常适合 | ⚠ 需要外部上拉 |
1.3. 输出速度
低速 (Low Speed)
设置引脚的驱动速度为低,适用于低频率的信号。
2-10MHz
适用场景:按键、LED控制等低频应用
中速 (Medium Speed)
设置引脚的驱动速度为中等,适用于中等频率的信号。
10-50MHz
适用场景:一般通信接口、显示驱动等
高速 (High Speed)
设置引脚的驱动速度为高,适用于高速信号。
50-100MHz
适用场景:高速总线、快速数据传输等
超高速 (Very High Speed)
设置引脚的驱动速度为超高,适用于非常高频率的信号。
100MHz+
适用场景:高速存储器接口、高速数据采集等
速度设置的影响
更高的速度会导致:
- 更多的功耗
- 更大的电磁干扰(EMI)
- 更快的信号上升和下降时间
所以对于速度的选择不是越快越好,对于LED控制这类应用,通常选择低速即可,这能降低功耗并减少EMI。
二、 STM32 HAL库GPIO配置
2.1 GPIO Mode (GPIO模式)
决定引脚的基本工作方式,是STM32CubeMX中最基础的配置项。
Input Mode (输入模式)
配置引脚为输入,用于读取外部信号
Output Mode (输出模式)
配置引脚为输出,用于控制外部设备
Analog Mode (模拟模式)
配置引脚为模拟模式,用于ADC/DAC
Alternate Function (备用功能)
连接内部外设如UART/SPI/I2C等
配置提示: 对于LED控制,通常选择Output Mode 。如果需要通过PWM控制LED亮度,则选择Alternate Function并连接到定时器。
GPIO Mode (模式)
GPIO模式是配置引脚工作方式的基础,直接决定了引脚的电气行为和与内部/外部电路的交互方式。
四种核心模式详解:
-
输入模式 (Input Mode)
配置引脚为高阻态输入,用于读取外部数字信号电平。内部通常包含施密特触发器以整形输入信号,提高抗干扰能力。
可配置上拉/下拉电阻。常用于按键检测、外部传感器信号读取、与其他设备通信的接收端。
EXTI中断 可配置在此模式下。
-
输出模式 (Output Mode)
配置引脚为输出,驱动外部电路。可以选择推挽(Push-Pull)或开漏(Open-Drain)输出类型,并可设置输出速度。
用于控制LED、驱动继电器、驱动蜂鸣器、与其他设备通信的发送端。
-
模拟模式 (Analog Mode)
配置引脚用于模拟信号处理,将引脚连接到内部模拟外设。此时,数字输入/输出缓冲器被禁用,以减少对模拟信号的干扰。
主要用于ADC(模数转换器)的输入通道或DAC(数模转换器)的输出通道。
注意:此模式下引脚呈高阻态,且上下拉电阻无效。
-
备用功能模式 (Alternate Function)
将引脚的控制权交给STM32内部集成的外设模块(如USART, SPI, I2C, TIM, CAN等)。
具体连接哪个外设的哪个信号,需要通过配置AFR寄存器(在CubeMX中通过选择Signal实现)。可以配置输出类型(推挽/开漏)和速度。
这是实现复杂外设通信和功能的关键模式。
LED 控制场景选择
-
简单开关: 选择 输出模式 (Output Mode),通常配合推挽输出。
-
亮度调节 (PWM): 选择 备用功能模式 (Alternate Function),并将Signal配置为相应的 TIMx_CHx 定时器通道。
CubeMX 配置步骤
在CubeMX图形化界面中,左键点击目标引脚,在弹出的菜单中选择所需的GPIO模式。随后,根据所选模式,右侧的"Configuration"面板会显示更详细的配置选项(如Output Type, Speed, Pull-up/Pull-down, Signal, Label等)。
2.2 GPIO Output Type (输出类型)
当GPIO配置为输出模式时,可以选择不同的输出类型。
Push-Pull (推挽输出)
可以主动输出高电平和低电平,驱动能力强。
高电平:主动拉高 低电平:主动拉低
常用场景:LED控制 数字信号输出
Open-Drain (开漏输出)
只能主动输出低电平,高电平需要外部上拉电阻。
高电平:依靠外部上拉 低电平:主动拉低
常用场景:I2C总线 多设备共享总线
配置提示: 对于一般的LED控制,选择Push-Pull 输出类型。对于需要共享总线的场景(如I2C),选择Open-Drain输出类型。
GPIO Output Type (输出类型)
当GPIO配置为输出模式或备用功能输出时,输出类型决定了引脚驱动电路的结构和电气特性。
推挽输出 (Push-Pull)
内部包含一个PMOS和一个NMOS晶体管,分别连接到VDD和GND。
-
强驱动: 可主动输出高电平(PMOS导通)和低电平(NMOS导通)。
-
速度快: 电平切换迅速。
-
源/灌电流: 既能提供电流(Source Current,输出高电平时),也能吸收电流(Sink Current,输出低电平时)。
-
常用场景: 驱动LED、数字信号输出、高速通信接口。
-
注意: 不能将多个Push-Pull输出直接连接在一起,否则可能因电平冲突导致损坏。
开漏输出 (Open-Drain)
内部只包含一个NMOS晶体管连接到GND。
-
仅拉低: 只能主动输出低电平(NMOS导通)。
-
高阻态: 输出高电平时,NMOS截止,引脚呈高阻态。
-
需上拉: 实现高电平输出必须依赖外部或内部的上拉电阻。
-
"线与"逻辑: 多个开漏输出可以连接到同一条总线上,实现"线与"功能(只要有一个输出低,总线即为低)。
-
电平转换: 可用于连接不同电压域的设备(通过上拉电阻连接到目标电压)。
-
常用场景: I2C总线 (SCL, SDA)、SMBus、需要共享总线的信号。
LED 控制应用选择
-
首选推挽: 对于大多数直接驱动LED的应用,Push-Pull 提供更强的驱动能力和简单的控制逻辑(高电平亮或低电平亮,取决于电路设计)。
-
特定场景开漏: 如果需要多个信号控制同一个LED,或者LED连接到不同电压域,可以考虑 Open-Drain 并配合外部上拉电阻。
2.3 GPIO Speed (输出速度)
决定GPIO引脚状态切换的速率和驱动能力。更高的速度设置意味着更快的电平切换速率和更强的电流驱动能力。
| 速度设置 | 最大频率 | 适用场景 |
|---|---|---|
| Low (低速) | ~2MHz | 低功耗应用、LED指示灯 |
| Medium (中速) | ~10MHz | 一般数字接口、按键控制 |
| High (高速) | ~50MHz | SPI通信、快速信号处理 |
| Very High (超高速) | ~100MHz+ | 高速通信接口、时钟信号 |
配置提示: 对于普通LED控制,选择Low速度即可。更高的速度会增加功耗和电磁干扰,除非应用需要,否则不要选择过高的速度设置。
GPIO Speed (输出速度)
GPIO速度设置主要影响输出模式下引脚电平切换的速率(Slew Rate)和驱动强度,进而影响功耗和电磁干扰(EMI)。
速度等级与特性:
| 速度等级 | 大致频率范围 | 特点与应用 |
|---|---|---|
| Low (低速) | ~2 MHz | 最低功耗,最小EMI。适用于慢速信号,如LED指示灯、按键扫描、低速UART。 |
| Medium (中速) | ~10-25 MHz | 功耗和EMI适中。适用于I2C、中速SPI、中速UART等。 |
| High (高速) | ~50-100 MHz | 功耗和EMI较高。适用于高速SPI、快速ADC接口、LCD接口等。对PCB布局和信号完整性有要求。 |
| Very High (超高速) | ~100 MHz+ | 最高功耗,最大EMI。适用于高速总线(如FSMC/FMC)、高速时钟输出、以太网接口等。需要仔细设计PCB。 |
注意:实际最高工作频率受外部负载电容、电源电压、温度等多种因素影响。以上频率仅为典型参考。
选择原则
-
满足需求即可: 选择满足应用所需最低速度等级,以降低功耗和EMI。
-
LED 控制: 通常选择 Low (低速) 即可,除非是需要极快速闪烁的特殊应用。
-
通信接口: 根据通信协议要求的最高速率选择,并留有一定余量。例如,SPI时钟速率为20MHz时,应选择支持该速率或更高速度的等级。
-
信号完整性: 速度越高,越容易产生振铃、过冲等信号完整性问题,需要合理设计PCB布线(如缩短走线、增加地平面、考虑阻抗匹配)。
2.4 GPIO Pull-up/Pull-down (上拉/下拉电阻)
决定GPIO引脚在未被驱动时的默认状态。
No Pull-up/Pull-down
不启用内部上拉/下拉电阻,引脚浮空。
模拟输入 外部已有定义电平
Pull-up (上拉)
启用内部上拉电阻,引脚默认为高电平。
按键检测 I2C总线
Pull-down (下拉)
启用内部下拉电阻,引脚默认为低电平。
上拉型开关检测 默认低电平信号
配置提示: 对于输出模式下的LED控制,通常选择No Pull-up/Pull-down 。对于输入模式下的按键检测,建议选择Pull-up以防止引脚浮空。
LED 控制与上下拉
- 推挽输出控制LED: 通常选择 No Pull-up/Pull-down,因为推挽输出本身就能强制输出高低电平。
- 开漏输出控制LED: 需要 外部 上拉电阻将引脚拉高以点亮LED(假设LED另一端接GND),此时内部上下拉通常禁用或根据具体电路分析。
- 按键输入控制LED: 如果用一个按键输入引脚的状态来间接控制LED,该输入引脚应根据按键电路配置上拉或下拉(如按键接GND,则配置上拉)。
注意:内部上下拉电阻阻值较大,提供的电流很小(微安级别),不能用于直接驱动LED等负载,仅用于确定信号电平。
2.5 GPIO Label (引脚标签)
为GPIO引脚指定一个有意义的名称,方便在代码中引用。
命名规范
- 使用有描述性的名称,如 LED_GREEN、BUTTON_USER
- 避免使用特殊字符,使用下划线分隔单词
- 保持命名一致性,便于代码维护
命名示例
| 功能 | 推荐标签名 |
|---|---|
| LED指示灯 | LED_RED, LED_GREEN, LED1, LED2 |
| 按键输入 | BUTTON_USER, KEY1, KEY2 |
| 传感器控制 | SENSOR_ENABLE, TEMP_ALERT |
配置提示: 合理命名GPIO引脚可以大大提高代码的可读性和可维护性。CubeMX会根据标签名自动生成相应的宏定义,方便在代码中使用。
2.6 GPIO Signal (引脚信号)
当GPIO配置为备用功能(Alternate Function)模式时,需要选择特定的信号类型。
常见的备用功能信号类型:
TIM1_CH1 (定时器通道)USART1_TX (串口发送)USART1_RX (串口接收)SPI1_SCK (SPI时钟)SPI1_MISO (SPI主入从出)SPI1_MOSI (SPI主出从入)I2C1_SCL (I2C时钟线)I2C1_SDA (I2C数据线)ADC1_IN0 (ADC输入通道)DAC1_OUT1 (DAC输出通道)
LED控制相关的信号类型:
- TIMx_CHx - 定时器PWM输出通道,用于控制LED亮度
- GPIO_Output - 普通GPIO输出,用于简单的LED开关控制
配置提示: 要实现LED亮度调节(呼吸灯效果),应将GPIO配置为备用功能模式,并选择相应的定时器PWM通道信号。同时,需要在定时器配置中启用相应的PWM输出通道。
LED 亮度控制 (PWM)
要通过PWM实现LED亮度控制或呼吸灯效果:
- 将目标GPIO引脚的模式设置为 Alternate Function Push-Pull (通常推挽输出效果更好)。
- 在 "Signal" 下拉列表中,选择一个可用的 TIMx_CHx (定时器通道) 信号。
- 转到对应的 TIMx 外设配置界面。
- 配置定时器的时钟源和预分频器(Prescaler)、自动重载寄存器(ARR)以设定PWM频率。
- 启用所选的 Channel x ,并将其模式设置为 PWM Generation CHx。
- 配置比较寄存器(CCR / Pulse)的值来控制PWM占空比,从而控制LED亮度。
重要提示:务必查阅STM32芯片的数据手册(查找 "Alternate function mapping" 相关表格),确认所选引脚支持你想要连接的定时器通道。并非所有引脚都支持所有定时器的所有通道。
三、 HAL库的GPIO初始化流程
STM32 HAL(Hardware Abstraction Layer,硬件抽象层)库提供了一套完整的API来配置和控制GPIO引脚。相比直接操作寄存器,HAL库更易用且可移植性更好。
1.GPIO初始化结构体
HAL库使用GPIO_InitTypeDef结构体来配置GPIO引脚:
- Pin: 要配置的引脚,如GPIO_PIN_0, GPIO_PIN_1等
- Mode: 引脚模式,如GPIO_MODE_OUTPUT_PP(推挽输出)
- Pull: 上拉/下拉设置,如GPIO_NOPULL, GPIO_PULLUP等
- Speed: 输出速度,如GPIO_SPEED_FREQ_LOW
- Alternate: 复用功能选择(仅在复用模式下使用)
2.引脚复用机制
STM32微控制器的GPIO引脚可以分配给不同的外设功能,这称为引脚复用(Alternate Function):
- 每个引脚可以有多达16个不同的复用功能(AF0-AF15)
- 例如,同一个引脚可以配置为UART发送、SPI时钟或I2C数据线
- 复用功能在芯片手册中有详细说明,不同系列和型号的STM32有所不同
- 使用
GPIO_MODE_AF_PP或GPIO_MODE_AF_OD模式并设置相应的AF值
3.GPIO时钟使能
在STM32中,使用任何外设前都必须使能其时钟:
- 使用
__HAL_RCC_GPIOx_CLK_ENABLE()宏来使能GPIO端口时钟 - 这一步骤在配置GPIO前必须完成,否则配置无效
- 不同的GPIO端口(GPIOA, GPIOB等)需要单独使能
4.GPIO操作函数
HAL库提供了多种操作GPIO的函数:
HAL_GPIO_WritePin(): 设置引脚输出高/低电平HAL_GPIO_ReadPin(): 读取引脚输入状态HAL_GPIO_TogglePin(): 翻转引脚状态HAL_GPIO_LockPin(): 锁定引脚配置HAL_GPIO_EXTI_IRQHandler(): 中断处理
这些函数屏蔽了底层寄存器操作的复杂性,使得代码更清晰易读。
STM32 HAL GPIO配置实际应用
为LED控制配置GPIO的基本流程:
1首先定义GPIO配置结构体并初始化为默认值
2使能对应GPIO端口的时钟
3配置引脚模式为推挽输出(对LED最适合)
4设置上拉/下拉配置(LED控制通常不需要)
5设置输出速度(低速即可)
6应用配置到指定的GPIO端口和引脚
四、LED控制
4.1 LED控制的基本方式
对于普通的LED控制,我们通常使用GPIO的输出模式,并且选择推挽输出类型。这种配置可以提供足够的驱动能力来点亮LED。
GPIO模式:输出模式
输出类型:推挽输出
输出速度:低速即可
上下拉通常不需要
4.2 LED连接方式
高电平点亮方式
将LED的阴极通过限流电阻连接到地(GND),阳极连接到GPIO引脚。当GPIO输出高电平时LED点亮。
- 优点:直观,容易理解(高电平=开,低电平=关)
- 缺点:需要GPIO提供源电流能力
- 注意:确保GPIO能提供足够的源电流(通常20mA左右)
低电平点亮方式
将LED的阳极连接到电源(VCC),阴极通过限流电阻连接到GPIO引脚。当GPIO输出低电平时LED点亮。
- 优点:利用GPIO的吸电流能力,通常更强
- 缺点:逻辑反向,不太直观(低电平=开,高电平=关)
- 注意:确保GPIO能吸收足够的电流(通常比源电流能力强)
4.3 STM32 HAL库中的LED控制实现
使用HAL库控制LED的常用函数:
1.HAL_GPIO_WritePin(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin, GPIO_PinState PinState)
用于设置GPIO引脚输出状态,可以点亮或熄灭LED
2.HAL_GPIO_TogglePin(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
用于切换GPIO引脚状态,实现LED闪烁效果
3.HAL_GPIO_ReadPin(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
读取GPIO引脚当前状态,可用于获取LED当前状态
4.4 LED控制代码示例
led_app.c
cpp
#include "led_app.h"
#include "gpio.h" // 确保包含了HAL库的GPIO头文件
uint8_t ucLed[6] = {1,0,1,0,1,1}; // LED 状态数组 (6个LED)
/**
* @brief 根据ucLed数组状态更新6个LED的显示
* @param ucLed Led数据储存数组 (大小为6)
*/
void led_disp(uint8_t *ucLed)
{
uint8_t temp = 0x00; // 用于记录当前 LED 状态的临时变量 (最低6位有效)
static uint8_t temp_old = 0xff; // 记录之前 LED 状态的变量, 用于判断是否需要更新显示
for (int i = 0; i < 6; i++) // 遍历6个LED的状态
{
// 将LED状态整合到temp变量中,方便后续比较
if (ucLed[i]) temp |= (1 << i); // 如果ucLed[i]为1, 则将temp的第i位置1
}
// 仅当当前状态与之前状态不同的时候,才更新显示
if (temp != temp_old)
{
// 使用HAL库函数根据temp的值设置对应引脚状态 (假设高电平点亮)
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, (temp & 0x01) ? GPIO_PIN_SET : GPIO_PIN_RESET); // LED 0 (PB12)
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_13, (temp & 0x02) ? GPIO_PIN_SET : GPIO_PIN_RESET); // LED 1 (PB13)
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_14, (temp & 0x04) ? GPIO_PIN_SET : GPIO_PIN_RESET); // LED 2 (PB14)
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_15, (temp & 0x08) ? GPIO_PIN_SET : GPIO_PIN_RESET); // LED 3 (PB15)
HAL_GPIO_WritePin(GPIOD, GPIO_PIN_8, (temp & 0x10) ? GPIO_PIN_SET : GPIO_PIN_RESET); // LED 4 (PD8)
HAL_GPIO_WritePin(GPIOD, GPIO_PIN_9, (temp & 0x20) ? GPIO_PIN_SET : GPIO_PIN_RESET); // LED 5 (PD9)
temp_old = temp; // 更新记录的旧状态
}
}
/**
* @brief LED 显示处理函数 (主循环调用)
*/
void led_proc(void)
{
led_disp(ucLed); // 调用led_disp函数更新LED状态
}
代码详细解析
头文件包含 :代码包含了 "led_app.h"(函数声明)和 "gpio.h"。后者通常由STM32CubeMX生成,包含了HAL库GPIO相关的定义和函数声明,如 HAL_GPIO_WritePin。
LED状态数组
uint8_t ucLed[6] = {1,0,1,0,1,1}; 定义了一个包含6个元素的数组,用于存储6个LED的开关状态(1为亮,0为灭),并初始化了状态。
这种方式使得从程序的其他部分可以方便地修改各个LED的状态。
状态整合 (位操作)
在 led_disp 函数中,for循环遍历 ucLed 数组。
if (ucLed[i]) temp |= (1 << i); 这行代码是核心:如果第i个LED状态为1,则通过按位或操作 |= 将 temp 变量的第i位置为1。最终,temp 的低6位组合了所有6个LED的状态。
性能优化策略
代码使用 static uint8_t temp_old = 0xff; 和 if (temp != temp_old) 来优化性能:
-
只有当组合后的LED状态
temp与上次记录的状态temp_old不同时,才执行实际的GPIO写操作。 -
这避免了在LED状态未改变时重复调用
HAL_GPIO_WritePin,减少了CPU负担和潜在的总线访问。
HAL库GPIO控制
与之前的寄存器操作不同,新代码使用了HAL库函数 HAL_GPIO_WritePin 来控制GPIO引脚电平。
例如 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, (temp & 0x01) ? GPIO_PIN_SET : GPIO_PIN_RESET);:
-
GPIOB: 目标GPIO端口。 -
GPIO_PIN_12: 目标引脚。 -
(temp & 0x01) ? GPIO_PIN_SET : GPIO_PIN_RESET: 使用按位与&检查temp的最低位(对应LED 0)。如果为1,则设置引脚为高电平 (GPIO_PIN_SET),否则为低电平 (GPIO_PIN_RESET)。假设高电平点亮LED。 -
其他LED(1到5)也使用类似逻辑,检查
temp的不同位。
这种方法提高了代码的可读性和可移植性。
引脚映射
代码明确了6个LED对应的GPIO引脚:
-
LED 0: PB12
-
LED 1: PB13
-
LED 2: PB14
-
LED 3: PB15
-
LED 4: PD8
-
LED 5: PD9
在实际应用中,需要确保这些引脚在STM32CubeMX中已正确配置为推挽输出模式。
| 函数名 | 参数 | 返回值 | 功能描述 |
|---|---|---|---|
| led_disp | uint8_t *ucLed - 指向6元素LED状态数组的指针 | void | 根据传入的LED状态数组更新6个LED的GPIO输出。仅当状态变化时执行更新。 |
| led_proc | 无 | void | LED处理函数,供主程序周期性调用,内部调用led_disp更新全局ucLed数组的状态。 |
代码移植与注意事项
将此代码用于不同项目时,需要注意:
-
引脚定义 : 确保
GPIOB,GPIOD,GPIO_PIN_12到GPIO_PIN_9的定义与你的硬件原理图和CubeMX配置一致。 -
LED数量 : 如果LED数量不是6个,需要修改
ucLed数组大小、for循环范围 (i < 6) 以及HAL_GPIO_WritePin的调用。 -
电平逻辑 : 代码假设高电平点亮LED (
GPIO_PIN_SET)。如果你的电路是低电平点亮,需要将GPIO_PIN_SET和GPIO_PIN_RESET对调,或者在检查ucLed[i]时反转逻辑。 -
包含文件: 确保包含了必要的HAL库头文件,通常是 `main.h` 或特定外设的头文件(如 `gpio.h`)。
-
初始化: 此代码只负责更新LED状态,GPIO引脚的初始化(设置为输出模式等)需要在别处完成,通常在 `MX_GPIO_Init()` 函数中(由CubeMX生成)。
五、实践环节
5.1 实践1:实现单个LED呼吸灯效果
目标:让单个LED实现明暗渐变的呼吸效果。
呼吸灯 led_proc 函数示例
cpp
#include <math.h> // 需要包含数学库以使用 sinf 函数
// ... (可能需要包含HAL库GPIO头文件,如 "gpio.h")
// ... (假设 ucLed 数组 和 led_disp 函数已在别处定义)
extern uint8_t ucLed[6];
extern void led_disp(uint8_t *ucLed);
/**
* @brief LED 显示处理函数 - 呼吸灯效果 (在主循环中周期性调用)
*/
void led_proc(void)
{
// 呼吸灯相关变量 (使用 static 确保它们在函数调用之间保持值)
static uint32_t breathCounter = 0; // 呼吸效果的内部计时器,模拟时间流逝
static uint8_t pwmCounter = 0; // 软件PWM的内部计数器,用于生成PWM波形
static uint8_t brightness = 0; // 当前计算出的LED亮度值 (0-pwmMax)
static const uint16_t breathPeriod = 2000; // 定义一个完整的呼吸周期时长 (单位:毫秒或调用次数,取决于调用频率)
static const uint8_t pwmMax = 10; // 软件PWM周期的最大计数值 (决定PWM精度和频率)
// 更新呼吸计时器:每次调用函数时加1,达到周期后归零
// 这个计数器相当于呼吸效果的时间轴
breathCounter = (breathCounter + 1) % breathPeriod;
// 核心:计算当前时刻的亮度值
// 使用正弦函数 (sinf) 来模拟平滑的亮度变化
// (2.0f * 3.14159f * breathCounter) / breathPeriod 将 breathCounter 映射到 0 到 2π 的弧度范围
// sinf(...) 的结果在 -1.0 到 1.0 之间
// (sinf(...) + 1.0f) 将范围变为 0.0 到 2.0
// * pwmMax / 2.0f 将范围缩放到 0 到 pwmMax,即我们期望的亮度范围
brightness = (uint8_t)((sinf((2.0f * 3.14159f * breathCounter) / breathPeriod) + 1.0f) * pwmMax / 2.0f);
// 更新软件PWM计数器:每次调用函数时加1,达到 pwmMax 后归零
// 这个计数器用于在 pwmMax 的周期内比较亮度,决定当前时刻LED是亮还是灭
pwmCounter = (pwmCounter + 1) % pwmMax;
// 软件PWM逻辑:
// 如果 pwmCounter 小于当前的亮度值 brightness,则LED应该亮 (ucLed[0] = 1)
// 否则,LED应该灭 (ucLed[0] = 0)
// 效果:brightness 越大,LED在一个PWM周期内亮的时间越长,看起来就越亮
// 当 brightness 为 0 时,pwmCounter 永远不小于 0,LED 始终灭
// 当 brightness 为 pwmMax 时,pwmCounter 始终小于 pwmMax,LED 始终亮
ucLed[0] = (pwmCounter < brightness) ? 1 : 0; // 控制第一个LED (ucLed[0])
// 调用之前定义的 led_disp 函数,将计算好的 ucLed 状态更新到实际的GPIO引脚
led_disp(ucLed); // 注意:led_disp内部最好也有优化,避免状态不变时重复写GPIO
}
代码详解 (呼吸灯)
关键原理:PWM(脉宽调制)
这是理解整个代码的核心!
什么是PWM?
-
PWM就是快速开关LED
-
开关频率很高(人眼看不出闪烁)
-
改变"亮"和"灭"的时间比例
-
占空比 = 亮的时间 / 总时间
| 占空比 | LED效果 |
|---|---|
| 0% | 一直灭 |
| 50% | 半亮度 |
| 100% | 一直亮 |
代码详细解析(逐行分析)
cpp
#include <math.h> // 因为要用到sinf函数
为什么需要math.h?
因为我们要用正弦函数(sin)来生成平滑的亮度变化曲线。正弦函数的特点是:平滑、连续、循环,正好适合呼吸效果。
cpp
#include <math.h> // 因为要用到sinf函数
static 关键字非常重要!
-
普通变量:每次函数调用都重新初始化
-
static变量:记住上次的值 -
想像:breathCounter就像一个不断走的钟,每次调用led_proc()就走一步
cpp
static const uint16_t breathPeriod = 2000;
呼吸周期 = 2000次函数调用完成一个完整呼吸。
-
如果每秒调用100次:2000/100 = 20秒一个呼吸周期
-
你可以通过改变这个值调整呼吸速度
cpp
static const uint8_t pwmMax = 10;
这是软件PWM的分辨率。
-
把亮度分成10个等级(0-9)
-
数值越大,亮度变化越平滑,但CPU负担越重
-
对于LED,10个等级已经足够
cpp
static const uint8_t pwmMax = 10;
这个操作让breathCounter从0数到1999,然后又回到0。
-
%是取余数操作符 -
当breathCounter=1999时,加1等于2000,2000%2000=0,重新开始
cpp
brightness = (uint8_t)((sinf((2.0f * 3.14159f * breathCounter) / breathPeriod) + 1.0f) * pwmMax / 2.0f);
这是核心计算!我们来分解:
-
(2.0f * 3.14159f * breathCounter) / breathPeriod-
把breathCounter从0-1999映射到0-2π
-
2π = 6.28318,这是正弦函数的一个完整周期
-
-
sinf(...)-
计算正弦值,结果在-1到1之间
-
随着breathCounter增加,正弦值会:0 → 1 → 0 → -1 → 0
-
-
sinf(...) + 1.0f- 把范围从(-1到1)变成(0到2)
-
(...) * pwmMax / 2.0f把(0到2)映射到(0到pwmMax),即0-9
-
(uint8_t)- 转换为整数,因为亮度需要是整数
亮度变化过程:
breathCounter: 0 → 500 → 1000 → 1500 → 1999
brightness: 0 → 9 → 0 → 0 → 0
对应正弦: 0 → π/2 → π → 3π/2 → 2π
cpp
pwmCounter = (pwmCounter + 1) % pwmMax;
pwmCounter是软件PWM的内部计数器,从0数到9,然后重复。
cpp
ucLed[0] = (pwmCounter < brightness) ? 1 : 0;
软件PWM的实现:
假设当前brightness=7,pwmMax=10:
pwmCounter变化:0 1 2 3 4 5 6 7 8 9 0 1 2 ...
比较结果: < < < < < < < = > > < < < ...
LED状态: 亮 亮 亮 亮 亮 亮 亮 灭 灭 灭 亮 亮 亮 ...
亮的时间比例: 7/10 = 70%,所以看起来是70%亮度
- 实际效果模拟
假设调用频率:每10ms调用一次led_proc()
-
breathPeriod=2000 → 完整周期=2000×10ms=20秒
-
pwmMax=10 → PWM频率=1/(10×10ms)=10Hz
发散性思维与拓展
思考以下问题,能让你更深入地理解:
1. 调用频率的影响 : 这个 led_proc 函数应该放在哪里调用?如果在主循环 `while(1)` 里直接调用,并且CPU很快,那么 breathCounter 和 pwmCounter 会增加得非常快。breathPeriod = 2000 可能代表2000次循环而不是2000毫秒。如何才能让它按毫秒计时?(提示:定时器中断、HAL_Delay()? 哪个更好?)
实际情况分析:
如果直接在主循环while(1)中调用:
cpp
while (1) {
led_proc(); // 直接调用
// 其他代码...
}
假设CPU频率为72MHz,执行一次led_proc大约需要几十微秒,那么:
-
每秒钟可能调用几万次
-
breathPeriod=2000会在0.1秒内完成一个呼吸周期 -
呼吸效果会飞快,几乎看不清楚
解决方案对比:
方案1:使用HAL_Delay()(不推荐)
cpp
while (1) {
led_proc();
HAL_Delay(10); // 延迟10ms
}
优点: 简单易用
缺点:
-
阻塞CPU:延迟期间CPU什么都不能做
-
不精确:延迟时间可能受中断影响
-
无法执行其他任务
方案2:使用系统滴答定时器(推荐)
cpp
uint32_t last_tick = 0;
while (1) {
uint32_t current_tick = HAL_GetTick(); // 获取当前时间戳(ms)
if (current_tick - last_tick >= 10) { // 每10ms执行一次
led_proc();
last_tick = current_tick;
}
// 这里可以执行其他任务
// 比如读取按键、处理通信等
}
优点:
-
非阻塞:CPU可以执行其他任务
-
精确:使用系统滴答定时器,精度高
-
灵活:可以方便调整执行频率
方案3:使用定时器中断(最精确)
cpp
// 在定时器中断中调用(例如1ms中断)
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM2) { // 假设使用TIM2
static uint32_t counter = 0;
counter++;
if (counter >= 10) { // 每10ms执行一次
led_proc();
counter = 0;
}
}
}
优点:
-
最精确:不受主循环其他代码影响
-
实时性好:定时器中断优先级最高
-
最专业:工业级应用常用
推荐:方案2(适合初学者),方案3(适合高级应用)
2. `pwmMax` 的影响 : 如果把 pwmMax 改成100,亮度变化会更平滑吗?PWM频率会变吗?(提示:PWM频率 = led_proc 调用频率 / pwmMax)。如果PWM频率太低(比如低于50Hz),你会看到什么现象?(提示:闪烁感)
当前设置分析:
pwmMax = 10
调用频率 = 100Hz(每10ms调用一次)
PWM频率 = 调用频率 / pwmMax = 100 / 10 = 10Hz
如果把pwmMax改成100:
pwmMax = 100
调用频率 = 100Hz(不变)
PWM频率 = 100 / 100 = 1Hz
具体影响:
- 亮度变化平滑度
-
pwmMax=10:亮度只有11个等级(0-10),变化有明显阶梯感
-
pwmMax=100:亮度有101个等级,变化更平滑
- PWM频率变化
-
pwmMax=10:PWM频率=10Hz,会看到明显闪烁
-
pwmMax=100:PWM频率=1Hz,闪烁非常严重
- 人眼感知
-
无闪烁阈值:通常需要>50Hz
-
舒适范围:100-500Hz
-
可见闪烁:<30Hz会看到明显闪烁
解决方法:提高调用频率
要同时获得高PWM频率和平滑亮度:
目标PWM频率 = 100Hz(无闪烁)
目标亮度等级 = 100级(平滑)
所需调用频率 = PWM频率 × pwmMax = 100 × 100 = 10,000Hz
即每0.1ms调用一次led_proc()
硬件PWM: STM32有专门的硬件定时器可以产生PWM波,而且精度高、不占用CPU。用硬件PWM实现呼吸灯会比软件模拟好在哪里?(提示:CPU占用、精度、功耗)
软件PWM(当前实现):
cpp
// 原理:通过CPU循环控制GPIO
ucLed[0] = (pwmCounter < brightness) ? 1 : 0;
优点:
-
简单,不需要额外硬件
-
可以控制任意GPIO引脚
-
软件灵活,容易修改
缺点:
-
CPU占用高:需要频繁执行判断和GPIO操作
-
精度低:受中断和其他任务影响
-
PWM频率有限:通常<1kHz
-
功耗高:CPU一直运行
-
不精确:可能会有抖动
硬件PWM实现:
cpp
// 1. CubeMX配置定时器为PWM模式
// 2. 代码中只需更新占空比
// 初始化PWM
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);
// 更新亮度(呼吸效果)
void update_breathing_pwm(void)
{
static float angle = 0;
angle += 0.01f; // 缓慢增加角度
// 计算正弦值并映射到0-1000
uint16_t duty = (uint16_t)((sinf(angle) + 1.0f) * 500.0f);
// 设置PWM占空比(硬件自动生成波形)
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, duty);
}
硬件PWM优点:
-
零CPU占用:定时器硬件自动生成波形
-
高精度:16位分辨率,0.0015%精度
-
高频率:可达72MHz(理论上)
-
低功耗:CPU可以休眠
-
无抖动:硬件保证稳定性
对比表格:
| 特性 | 软件PWM | 硬件PWM |
|---|---|---|
| CPU占用 | 高(100%忙时) | 几乎为零 |
| 精度 | 低(受中断影响) | 高(16位) |
| 最大频率 | <1kHz | >1MHz |
| 功耗 | 高 | 低 |
| GPIO限制 | 任意引脚 | 特定引脚 |
| 开发难度 | 简单 | 中等 |
| 适用场景 | 简单应用,少量LED | 复杂应用,多路PWM |