嵌入式学习——LED
输出速度:合理配置,高速=高功耗+强电磁干扰+快信号变化时间,led用低速就行了。
网课:Alice_西风&爱上半导体
从点灯开始:通过GPIO引脚输出点亮或者熄灭。
什么是GPIO?
GPIO 的全称是通用输入输出端口(General Purpose Input Output)。你可以把它想象成 STM32 芯片的 “手脚”。用户可自定义的引脚
作为 “手”:它能向外输出高电平或者低电平信号,就像手去控制一些东西一样,比如控制一个小灯的亮灭,高电平就是让灯亮,低电平就是让灯灭。
作为 “脚”:它能接收外部的信号,比如接收一个按钮有没有被按下的信号。当按钮按下时,GPIO 引脚就会变检测到一个电平化,就好像脚感觉到了有东西踩上去一样,然后告诉芯片发生了什么事情。
可以有一下几种模式:
输入模式:将引脚配置为输入模式,用于读取外部信号。
适用场景:读取按钮状态、传感器信号等
输出模式:将引脚配置为输出模式,用于驱动外部设备或信号。
适用场景:LED控制、电机驱动等
模拟(信号)模式:将引脚配置为模拟模式,用于模拟信号输入或输出。
适用场景:ADC读取、DAC输出等
复用功能:将引脚配置为某个外设的备用功能,如UART、SPI或I2C等。(一个引脚可够配置多个功能,但只能同时选择一个功能)
适用场景:通信接口、定时器输出等
输出模式:
对输出模式的详细介绍:BV1Pr4y1n74J
推挽输出模式 (Push-Pull):
驱动高电平和低电平

就好像一个有两个“小助手”的电路控制模式。 在这种模式下,当要输出高电平时,其中一个“小助手”(上拉晶体管)会把引脚连接到电源,让引脚输出高电平;而当要输出低电平时,另一个“小助手”(下拉晶体管)会把引脚连接到地,让引脚输出低电平。这两个“小助手”相互配合,一个负责拉高电平,一个负责拉低电平,就像两个人在推和拉一个东西一样,所以叫推挽输出模式。这种模式能很好地控制引脚输出稳定的高低电平,并且具有较强的驱动能力。用于控制LED,继电器,开关,信号输出等。
开漏输出模式 (Open-Drain)
可以把它想象成一个只有 “下拉助手” 的特殊电路模式。常用于I2C总线
在开漏输出模式下,当要输出低电平时,“下拉助手”(下拉晶体管)会把引脚连接到地,让引脚输出低电平,这和推挽输出模式中的下拉操作类似。但是,当要输出高电平时,就不一样了,这时没有 “上拉助手” 把引脚连接到电源来拉高电平,而是引脚处于一种悬空的状态,需要外部电路接上一个上拉电阻才能将引脚拉高到高电平。就好像一个人只能把东西往下拉,要让东西上去得靠别人帮忙拉一把。(如下图,因为当输出高电平时,晶体管不上拉也不下拉,引脚相当于接上无穷大的电阻,此时所输出的电压来源于电阻电压)
开漏输出模式常用于一些需要多个设备共享总线的情况,比如 I2C 总线。多个设备的开漏输出引脚连接在一起,通过外部上拉电阻实现电平的拉高,这样可以避免多个设备同时输出高电平时产生冲突。
为什么能避免冲突?
开漏输出就像一群人在拉一根绳子(电平),每个人都只能把绳子往下拉(拉低电平),要让绳子升上去(拉高电平)得靠一个共同的外力(外部上拉电阻)。当大家都不往下拉时,外力把绳子拉高。只要是有一个人把绳子往下拉(接地),其他人就算 “放手”(处于高阻态)也不影响绳子被拉低(拉低电平),这样就不会出现大家一起用力拉绳子,方向不一致产生冲突的情况啦。
个人理解:就好像只要有一个人饿(接地),就不会做饭(引脚不会拉高电平),只有当所有人都不饿(都不接地),才会做饭(引脚拉高电平),ps:最出生的一集。饿就是肚子空空,接入地线,一点电子都没有。

同时,它还可以实现""线与""功能,即只要有一个设备输出低电平,总线就为低电平,只有所有设备都不输出低电平时,总线才在外部上拉电阻的作用下为高电平。

当全部为高电平,则引脚为高电平。否则为低电平

总结:

输出速度:合理配置,高速=高功耗+强电磁干扰+快信号变化时间,led用低速就行了。

cubeMX引脚配置及其使用
引脚结合原理图来看
模块—功能原件对应引脚(看原件接地还是接电源:决定输入1还是0)—杜邦线连到芯片IO口—CuBeMX中设置对应引脚为输出模式( 对于LED控制,通常选择Output Mode。如果需要通过PWM控制LED亮度,则选择Alternate Function并连接到定时器。)


如图,配置对应的接口

其中要配置每个引脚的 PB12 Configuration
依次为:输出电平,输出模式(回顾上文),上下拉电阻(led不需要),输出速度,标签(对引脚的别名)
配置好引脚就可以生成文件
keil配置
主函数中对hal库初始化外设和时钟,

其中GPIO.c文件解释如下

首先在app文件下创建app.c.h,以及mydefine.h文件,在app.h文件中引用mydefine.c文件(作为其他头文件引用过度文件),mydefine中引用,注意写成预定义形式防止交叉引用现象
什么是预定义形式:
预定义(Predefined)通常指的是在编程语言中已经定义好的一些特定的变量、常量或函数。这些预定义的实体可以直接在程序中使用,无需重新定义或声明。预定义的实体通常是由编程语言标准或标准库所提供的,可以在程序中直接引用。
在C语言中,有一些常见的预定义宏,如`__LINE__`表示当前所在代码行号,`__FILE__`表示当前所在文件名,`__DATE__`表示当前编译日期等。这些预定义宏可以帮助程序员获取一些编译时的信息,方便调试和记录日志。
另外,在C语言标准库中也存在一些预定义的函数,如`printf`、`scanf`等,在使用这些函数时无需进行额外的定义,只需要包含对应的头文件即可直接调用。
否则会出现一下错误

解决方案:

这样的预定义是怎么防止冲突的呢?
预处理指令可以通过条件编译来避免头文件的重复引用。常用的方式是使用`#ifndef`、`#define`、`#endif`来包围头文件的内容。
具体来说,可以在头文件的开头和结尾添加如下代码:
这段代码的作用是:
- `#ifndef LED_APP_H`检查是否定义了`LED_APP_H`宏,如果未定义,则继续编译;如果已定义,则跳过后续代码。
- `#define LED_APP_H`定义了`LED_APP_H`宏,防止重复引用。
- `#endif`标志着条件编译的结束。通过这种方式,即使在多个地方引用同一个头文件,也可以确保头文件只被包含一次,避免了重复引用的问题。
点灯的流程:

首先定义ucled数组,创建led底层函数。用新老变量去记录LED状态。for循环去遍历led状态,然后直接写入,然后新老变量相等。
创建led显示处理函数(主函数调用),在其中调用led_disp函数更新led状态
在主函数中初始化调度器函数,同时引用调度器.h文件
while循环中执行调度器“scheduler_run”
大概就是这样:(led_app.c)
#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状态
}
学会用debug找代码问题,然后去找硬件问题,最后找软件问题
作业:led呼吸灯
简单来说就是和51单片机点亮灯一样,该变led在一个循环周期内的高电平占比,实现不同亮度等级。
新思路:将内部时间变化与led亮度变化绑定。频率:LED 开关(高低电平)的速度占空比:LED 开启时间占整个周期的比例
占空比越大,LED 看起来越亮;占空比越小,LED 看起来越暗。
首先引用相关头文件以及定义led灯
#include <math.h> // 同样需要数学库 #include <stdint.h> // 引入标准整数类型 extern uint8_t ucLed[6]; // 假设有6个LED extern void led_disp(uint8_t *ucLed);接着在led处理函数中定义局部变量,包括led周期,呼吸计时器,PWM计时器,PWM精度,LED亮度(static变量只会在程序第一次运行到这里时初始化一次。之后每次调用
led_proc,它们都会记住上次的值。)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精度和频率)接着
breathCounter = (breathCounter + 1) % breathPeriod;//让计时器在0~2000内循环 brightness = (uint8_t)((sinf((2.0f * 3.14159f * breathCounter) / breathPeriod) + 1.0f) * pwmMax / 2.0f);//使得亮度等级随光滑正弦曲线变化其中sinf((2.0f * 3.14159f * breathCounter) / breathPeriod)将亮度变为正弦函数。范围为-1~1
+1.0f,取值范围为[0~2](数字后面+f是因为带小数点的默认为double双精度数字,占资源较高,改为flout)。* pwmMax / 2.0f是将最终亮度值范围调到[0~10(pwmMax所处最大值)]
然后
pwmCounter = (pwmCounter + 1) % pwmMax;//用于在 pwmMax 的周期内比较亮度 ucLed[0] = (pwmCounter < brightness) ? 1 : 0; // 控制第一个LED (ucLed[0])呼吸例子帮助理解:
当
brightness为 0 时,pwmCounter永远不小于 0,LED 始终灭当
brightness为 5 时,pwmCounter在 0-4 时 LED 亮,5-9 时 LED 灭,占空比 50%当
brightness为 10 时,pwmCounter始终小于 10,LED 始终亮最后调用led_proc(ucled);
问题
调用频率的影响: 这个 led_proc 函数应该放在哪里调用?如果在主循环 `while(1)` 里直接调用,并且CPU很快,那么 breathCounter 和 pwmCounter 会增加得非常快。breathPeriod = 2000 可能代表2000次循环而不是2000毫秒。如何才能让它按毫秒计时?(提示:定时器中断、HAL_Delay()? 哪个更好?)
答:在while(1)里直接调用,然后去定时器或HAL_Delay()延时,更推荐定时器中断,不会占用太多资源,
1. 使用
HAL_Delay()的优缺点优点
- 简单易用:无需额外配置定时器,直接调用函数即可。
- 代码简洁:适合快速实现简单功能。
缺点
- 阻塞式延时:在延时期间,CPU 无法执行其他任务,可能导致系统响应变慢。
- 精度较低:
HAL_Delay()的精度受系统时钟和中断影响,难以实现高精度延时。- 资源浪费:如果呼吸灯不是系统的核心功能,长时间占用 CPU 会影响其他任务。
适用场景
- 系统功能简单,没有其他紧急任务需要处理。
- 对呼吸灯效果的精度要求不高。
- 快速原型开发或测试阶段。
2. 使用定时器中断的优缺点
优点
- 非阻塞:定时器在后台运行,不影响主循环执行其他任务。
- 高精度:可以精确控制中断触发时间,实现更稳定的呼吸效果。
- 资源高效:CPU 可以在定时器中断间隔期间处理其他任务,提高系统利用率。
- 可扩展性:适合多任务系统,多个外设可以共享定时器资源。
缺点
- 配置复杂:需要额外配置定时器、中断向量表等,代码量增加。
- 调试难度高:中断处理函数可能与主程序产生竞争条件,需要考虑线程安全。
适用场景
- 系统需要同时处理多个任务(如传感器数据采集、通信等)。
- 对呼吸灯效果的稳定性和精度要求较高(如作为状态指示)。
- 需要低功耗设计(定时器中断可以配合睡眠模式使用)。
`pwmMax` 的影响: 如果把 pwmMax 改成100,亮度变化会更平滑吗?PWM频率会变吗?(提示:PWM频率 = led_proc 调用频率 / pwmMax)。如果PWM频率太低(比如低于50Hz),你会看到什么现象?(提示:闪烁感)
答:会,应为亮度等级在[0~pwmMax]中,如果
pwmMax改成100,就有101个等级层次更加明显。
PWM频率 =
led_proc调用频率 /pwmMax当 PWM 频率低于人眼的视觉暂留阈值(约 50Hz)时,LED 会出现明显闪烁。这是因为:
- 人眼无法 "平滑" 整合过快的亮灭变化,而是能直接感知到 LED 的开关状态
- 低频率的亮灭交替会触发人眼的闪烁感知,尤其在亮度较低时更明显
- 所以要提高pwmMax时也要提高调用频率
`breathPeriod` 的影响: 把 breathPeriod 改成4000会怎么样?改成500呢?(提示:呼吸快慢)
答:灯由暗——亮——暗时间会变长
亮度曲线: 除了正弦函数,还能用什么函数来计算亮度?比如三角波?或者指数函数?效果会有什么不同?(提示:呼吸的“感觉“会不一样)

硬件PWM: STM32有专门的硬件定时器可以产生PWM波,而且精度高、不占用CPU。用硬件PWM实现呼吸灯会比软件模拟好在哪里?(提示:CPU占用、精度、功耗)
?
超高频调用 (us级别): 如果 led_proc 在微秒(us)级别被调用,breathPeriod 和 pwmMax 的值需要如何调整才能保持相同的呼吸速度和PWM频率?(提示:数值会变得非常大)。这样做有什么潜在好处或坏处?(提示:PWM频率可以很高,闪烁感完全消失;但CPU负担可能极大,uint32_t 可能不够用)。
多个LED呼吸流水灯
是多个呼吸灯+每个呼吸灯之间有相位的结果
定义和引用同上
其中,关键在于相位(描述周期性现象在一个周期内所处位置的角度)+增强效果(这里采用对亮度值开方+)
相位差: static const float phaseShift = 3.14159f / 3.0f;//pi分为3部分,一个周期为2pi,六个灯,六个相位差因为要实现六个灯的亮灭,故用for循环遍历六个led灯
for(uint8_t i = 0; i < 6; i++) // 遍历所有6个LED { // 计算当前LED的相位角 (angle) // (2.0f * 3.14159f * breathCounter) / breathPeriod 是基础角度,随时间变化 // - i * phaseShift 是为第 i 个LED引入的相位延迟 float angle = (2.0f * 3.14159f * breathCounter) / breathPeriod - i * phaseShift; // 计算原始的正弦值 (-1.0 到 1.0) float sinValue = sinf(angle); // 增强对比度并调整曲线 (让亮灭更分明,全亮时间更短) // powf(x, 0.5f) 相当于 sqrt(x)(开方),对于正数,它会把小的数拉高,大的数相对压低一点 (但这里主要用在 >0 的部分) // 对于负数,我们取绝对值再开根号,然后加回负号,保持形状对称 // 这使得亮度从暗到亮的过程变快,从亮到暗的过程也变快,中间亮的时间缩短 float enhancedValue = sinValue > 0 ? powf(sinValue, 0.5f) : -powf(-sinValue, 0.5f); // 进一步压缩亮度曲线,使得只有在接近峰值时才真正达到高亮度 // 如果增强后的值大于0.7 (接近峰值),则保持不变;否则,乘以0.6,让它更暗 // 目的是让“光波“显得更窄,流动感更强 enhancedValue = enhancedValue > 0.7f ? enhancedValue : enhancedValue * 0.6f; 仅为增强效果 // 将处理后的 enhancedValue (-1 到 1 之间) 映射到 0 到 pwmMax 的亮度值 uint8_t brightness = (uint8_t)((enhancedValue + 1.0f) * pwmMax / 2.0f); // 根据计算出的该LED的亮度,使用PWM逻辑设置其状态 ucLed[i] = (pwmCounter < brightness) ? 1 : 0; }
发散性思维与拓展
基于流水灯,我们可以思考更多玩法:
`phaseShift` 的影响: 如果把 phaseShift 改成 PI / 6.0f 会怎么样?改成 PI / 1.0f 呢?(提示:改变流水的"波长"或同时亮的LED数量)
变为在一个周期里有12个相位差/2个相位差,流水灯波长变长/短,同时亮的数量变少/多
`breathPeriod` 的影响: 改变 breathPeriod 会影响单个LED的呼吸快慢,还是整个流水的速度?(提示:两者都会影响)
曲线调整: 尝试修改 powf 的指数(比如改成2.0f,即平方)或者调整 enhancedValue > 0.7f 这个阈值,观察流水效果的变化。有没有办法让光波更窄或者更宽?
pof指数增大(中间亮的时间缩短),0.7f增大,光波变窄,流动感变强(反之亦然)
反向流水: 如何修改代码让流水方向反过来?(提示:修改相位计算中的 i 或 phaseShift 的符号?)
for(uint8_t i = 6; i > 0; i--)
OR
ucLed[i] = (pwmCounter > brightness) ? 1 : 0;
双向流水/其他模式: 能不能实现从中间向两边扩散的流水效果?或者其他更有创意的模式?(提示:可能需要更复杂的角度或亮度计算逻辑)
for循环分为两段
性能考量: 这个流水灯代码在 `for` 循环里做了很多浮点运算 (sinf, powf)。如果CPU性能不足,或者 led_proc 调用频率很高,可能会有效率问题。有什么优化方法吗?(提示:查找表(Look-up Table, LUT)预计算正弦值?定点数运算代替浮点运算?)
us级调用: 如果 led_proc 在us级被调用,浮点运算会成为瓶颈吗?breathPeriod = 4000 可能需要变成多少才能维持几秒的周期?(提示:4,000,000)。使用查找表或硬件PWM会是更好的选择吗?
openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。
更多推荐




所有评论(0)