1. 从一盏灯说起:PWM调光到底在调什么
很多人第一次接触PWM,是在单片机上点亮一颗LED,然后想让它“暗一点”。最直觉的做法是串一个电阻,或者把电压降下来。但你会发现,电压降到某个值以下,LED直接不亮了,而且亮度变化和电压根本不是线性关系。这时候PWM就登场了——它不去改变电压的高低,而是控制“通电时间”的长短。
PWM的全称是脉冲宽度调制(Pulse Width Modulation),核心参数只有两个:频率和占空比。频率决定了一秒钟开关多少次,占空比决定了每次“开”的时间占整个周期的比例。占空比50%,就是一半时间通电、一半时间断电;占空比10%,就是只有十分之一的时间在通电。
你可能会问:这样一闪一闪的,灯不会抖吗?关键在于频率够高。当开关频率超过人眼能分辨的范围(大约200Hz以上),视觉上就会产生“平均亮度”的效果。这跟电影胶片一样,一秒钟24帧静态画面快速切换,你看到的就是连续动作。LED在PWM驱动下,每秒开关几百到几万次,人眼根本来不及分辨单次亮灭,只感知到整体亮度。
那“从冷光到暖光”又是怎么回事?这就涉及到双路PWM混色。一颗暖白LED和一颗冷白LED,或者RGB三色LED,各自用一路PWM控制亮度。冷光那路占空比高、暖光那路占空比低,混出来的光就偏冷;反过来就偏暖。本质上,PWM调光做的是“亮度配比”,而不是改变LED本身的色温特性。
这套逻辑在STM32上实现起来非常自然。STM32的通用定时器(比如TIM2、TIM3、TIM4)和高级定时器(TIM1、TIM8)都自带PWM输出模式,配合光敏传感器做反馈,就能搭出一套自动调光系统。下面我会从方案设计、硬件选型、代码实现到踩坑排查,把整条链路拆开讲清楚。
2. 整体方案怎么定:先想清楚你要调什么
2.1 单色亮度调节 vs 双色混光调节
动手之前先明确目标。如果你只是想让一颗LED灯“亮一点暗一点”,那单路PWM就够了,一个定时器通道输出,代码不超过二十行。但如果你要做的是“冷光到暖光”的智能切换,那就需要至少两路PWM,分别驱动冷白和暖白LED,或者用RGB三色LED做更细腻的混光。
我个人的建议是:如果目标是照明类应用,优先选双色温LED方案。原因很简单,RGB混白光的显色性通常不如专用的冷白+暖白组合,而且双色温方案的控制逻辑更直观——你只需要调节两路占空比的比例,不需要做复杂的色彩空间转换。
具体来说,假设冷白LED接在TIM3_CH1,暖白LED接在TIM3_CH2,两路PWM频率都设为1kHz。当冷光占空比80%、暖光占空比20%时,整体光色偏冷;当两者各50%时,接近中性白;当冷光20%、暖光80%时,光色明显偏暖。这个比例可以通过一个“色温滑块”来映射,也可以由光敏传感器自动调节。
2.2 开环调光还是闭环自动调光
开环就是手动设定占空比,你给多少就是多少。闭环则是加入光敏传感器,根据环境光强度自动调整亮度。热搜词里出现了“stm32光敏传感器自动调光系统”,说明这是很多人的实际需求。
闭环方案的核心思路是:光敏传感器(比如GL5528光敏电阻、BH1750数字光照传感器)采集当前环境光强度,STM32通过ADC或I2C读取数值,然后根据预设的目标亮度计算出PWM占空比的调整量。这里有一个关键点——光敏传感器的响应是非线性的,光敏电阻的阻值与光照度大致呈对数关系,所以你不能直接用ADC值线性映射到占空比,需要做对数转换或者查表校准。
我实测下来,用BH1750这类数字传感器会省事很多,它直接输出勒克斯(lux)值,线性度好,I2C接口占用引脚少。光敏电阻方案成本更低,但需要自己标定曲线,适合对精度要求不高的场景。
2.3 频率选多少才合适
PWM频率的选择是个容易被忽视但很关键的参数。频率太低,人眼会感知到闪烁;频率太高,MOS管开关损耗增加,驱动电路可能发热。
对于LED调光,1kHz到5kHz是比较稳妥的范围。1kHz以下在快速移动的摄像头画面里可能出现条纹,5kHz以上对普通MOS管驱动电路来说开关损耗开始明显。如果你用的是专用LED驱动芯片(比如PT4115、AL8860),它们内部有恒流控制,PWM频率可以设到20kHz以上,完全无频闪。
还有一个细节:频率和占空比共同决定了最小可调亮度。假设频率1kHz,周期就是1ms。如果定时器分辨率是1us,那占空比最小可以调到0.1%。但如果分辨率只有10us,最小占空比就是1%,再低就调不出来了。所以定时器的预分频器(Prescaler)和自动重装载值(ARR)要配合好,确保你有足够的调节精度。
3. STM32上的PWM配置:从时钟树到寄存器
3.1 时钟树先理清楚
STM32的PWM输出依赖定时器,定时器的时钟来源又跟系统时钟树密切相关。以STM32F103为例,假设外部晶振8MHz,经过PLL倍频到72MHz作为系统时钟。APB1总线时钟通常是36MHz,但定时器时钟有个特殊规则:当APB预分频系数不为1时,定时器时钟是APB时钟的2倍。所以TIM2、TIM3、TIM4的时钟实际是72MHz。
这个细节很重要,因为很多人算PWM频率时直接用36MHz去算,结果输出频率差了一倍。我建议你在CubeMX里配置好时钟树后,直接看它显示的定时器时钟频率,不要自己手算。
3.2 定时器参数计算
PWM频率的公式是:
PWM频率 = 定时器时钟 / [(Prescaler + 1) × (ARR + 1)]
假设定时器时钟72MHz,我想要1kHz的PWM频率,可以这样分配:Prescaler设为71,ARR设为999。计算一下:72,000,000 / (72 × 1000) = 1000Hz,正好1kHz。
占空比的公式是:
占空比 = CCR / (ARR + 1)
CCR是捕获/比较寄存器值。ARR为999时,CCR设为500就是50%占空比,CCR设为100就是10%。CCR可以在运行时动态修改,这就是调光的本质——不断改变CCR的值。
这里有个经验:ARR值不要设得太小。ARR越小,可调的占空比档位越少。ARR=99的话,你只能调0%到100%共100个档位,每档1%。ARR=999就有1000个档位,调光更细腻。但ARR太大也有问题,如果频率要求高,Prescaler就得很小,可能超出范围。一般ARR在500到2000之间比较平衡。
3.3 代码实现:HAL库和寄存器两种写法
用STM32CubeMX生成代码的话,PWM初始化部分会自动生成。核心是配置定时器的PWM模式:
// TIM3 PWM初始化(HAL库) TIM_HandleTypeDef htim3; TIM_OC_InitTypeDef sConfigOC; htim3.Instance = TIM3; htim3.Init.Prescaler = 71; htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 999; htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_PWM_Init(&htim3); sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 500; // 初始占空比50% sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_2); // 启动PWM输出 HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2);运行时修改占空比只需要一行:
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 800); // 冷光80% __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, 200); // 暖光20%如果你习惯直接操作寄存器,等效写法是:
TIM3->CCR1 = 800; TIM3->CCR2 = 200;寄存器写法效率更高,但在复杂项目里可读性差一些。我一般在中低复杂度的项目里用HAL库,只有在中断里频繁调整占空比时才切到寄存器操作。
3.4 光敏传感器接入
如果用BH1750数字光照传感器,I2C接线很简单:VCC接3.3V,GND接地,SCL和SDA分别接STM32的I2C引脚(比如PB6、PB7),再加两个4.7kΩ上拉电阻。读取光照值的代码:
uint8_t buf[2]; HAL_I2C_Master_Receive(&hi2c1, BH1750_ADDR, buf, 2, 100); uint16_t lux = (buf[0] << 8) | buf[1]; lux = lux / 1.2; // 转换为勒克斯拿到lux值之后,就可以做自动调光了。逻辑可以是:环境光越暗,PWM占空比越高;环境光越亮,占空比越低。同时根据色温偏好,分配冷光和暖光的比例。
4. 从冷到暖:混光控制的实现细节
4.1 色温映射曲线怎么定
冷光和暖光的混合比例不是线性的。人眼对色温的感知更接近对数关系,所以直接用线性映射会导致低色温区域变化太快、高色温区域变化太慢。我的做法是建一张查找表,把色温值(比如2700K到6500K)映射到冷光占空比和暖光占空比。
一个简化的映射方案:
| 色温档位 | 冷光占空比 | 暖光占空比 | 视觉效果 |
|---|---|---|---|
| 2700K | 0% | 100% | 纯暖光 |
| 3500K | 25% | 75% | 暖白 |
| 4500K | 50% | 50% | 中性白 |
| 5500K | 75% | 25% | 冷白 |
| 6500K | 100% | 0% | 纯冷光 |
这张表可以直接写在代码里,用数组存储,运行时根据目标色温查表插值。如果你想要更平滑的过渡,可以在两个档位之间做线性插值。
4.2 亮度与色温的解耦控制
实际使用中,亮度和色温是两个独立维度。用户可能想要“暖光但很亮”,也可能想要“冷光但很暗”。所以控制逻辑要分开:先根据环境光确定总亮度,再根据用户偏好或时间确定色温比例,最后把总亮度按比例分配给冷光和暖光两路PWM。
具体计算:
冷光CCR = 总亮度 × 冷光比例 暖光CCR = 总亮度 × 暖光比例
总亮度由自动调光算法给出,冷光比例由色温设定给出。这样两路PWM各自独立,互不干扰。
4.3 平滑过渡避免突变
直接修改CCR值会导致亮度突变,视觉上很突兀。解决办法是加一个渐变过程:每次调整时,不要一步到位,而是分多次小步修改,每次间隔几毫秒。比如从CCR=200调到CCR=800,可以每10ms增加20,30次完成,总耗时300ms,人眼感觉就是平滑过渡。
这个渐变逻辑可以放在定时器中断里实现,也可以用软件延时循环。我倾向于用定时器中断,不阻塞主循环:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM4) { if (current_ccr1 < target_ccr1) current_ccr1++; if (current_ccr1 > target_ccr1) current_ccr1--; if (current_ccr2 < target_ccr2) current_ccr2++; if (current_ccr2 > target_ccr2) current_ccr2--; __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, current_ccr1); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, current_ccr2); } }TIM4设成1ms中断一次,渐变速度就是每毫秒1个CCR单位。从0到1000需要1秒,这个速度比较自然。
5. 驱动电路:MOS管怎么选怎么接
5.1 为什么不能直接用STM32引脚驱动LED
STM32的GPIO引脚输出电流有限,单个引脚最大25mA,所有引脚加起来不超过150mA。一颗大功率LED动辄需要300mA到1A的电流,直接驱动会烧引脚。所以必须加驱动电路,最常见的就是MOS管。
N沟道MOS管是最常用的选择,比如AO3400、IRLZ44N、SI2302。选型时关注三个参数:导通电阻Rds(on)、栅极阈值电压Vgs(th)、最大漏极电流Id。Rds(on)越小,导通时发热越少;Vgs(th)要低于3.3V,确保STM32的3.3V逻辑电平能完全导通;Id要留足余量,至少是LED电流的2倍。
5.2 典型驱动电路
一个典型的低边驱动电路是这样的:LED正极接电源正极,负极接MOS管漏极,MOS管源极接地,栅极通过一个100Ω电阻接STM32的PWM输出引脚,栅极和源极之间再并一个10kΩ下拉电阻。
100Ω栅极电阻的作用是限制栅极充电电流,减小开关瞬间的振荡。10kΩ下拉电阻确保STM32未初始化时MOS管保持关断,避免上电瞬间LED闪一下。
如果LED功率较大,还需要考虑散热。MOS管发热主要来自导通损耗和开关损耗。导通损耗 = I² × Rds(on),开关损耗跟频率和栅极电荷有关。1kHz频率下开关损耗通常可以忽略,主要看导通损耗。比如1A电流、Rds(on)为50mΩ,导通损耗就是50mW,基本不发热。
5.3 共阳还是共阴
双色温LED通常是三引脚封装:一个公共脚,两个控制脚。公共脚接正极的叫共阳,接负极的叫共阴。共阳配置下,MOS管做低边驱动,PWM高电平点亮;共阴配置下,MOS管做高边驱动,需要P沟道MOS管或者专用高边驱动芯片,电路复杂一些。
我建议优先选共阳LED,配合N沟道MOS管低边驱动,电路最简单,成本最低。
6. 常见问题与排查实录
6.1 LED闪烁或抖动
最常见的原因是PWM频率太低。如果你能肉眼看到闪烁,说明频率低于100Hz。把Prescaler调小或者ARR调小,把频率提到1kHz以上。
另一个可能的原因是电源纹波太大。LED驱动电流较大时,电源电压波动会导致亮度不稳。在电源两端并一个100uF电解电容和一个0.1uF陶瓷电容,通常能解决。
还有一种情况是光敏传感器采样周期和PWM周期产生了拍频。如果传感器采样频率接近PWM频率的整数倍,会出现周期性亮度波动。解决办法是让采样频率远低于PWM频率,比如PWM用1kHz,传感器每100ms采样一次。
6.2 MOS管发热严重
先检查MOS管是否完全导通。如果Vgs(th)接近3.3V,STM32的3.3V可能只能让MOS管半导通,Rds(on)远大于标称值,发热自然严重。换用Vgs(th)更低的MOS管,比如SI2302的Vgs(th)典型值只有0.9V。
还要检查栅极电阻是否过大。100Ω是常用值,如果用到1kΩ,栅极充电太慢,MOS管在开关过程中会停留在放大区,产生大量热量。示波器看栅极波形,上升沿和下降沿应该在微秒级别。
6.3 调光非线性
如果你发现低占空比区域亮度变化很明显,高占空比区域变化不明显,这是正常的——人眼对亮度的感知本身就是非线性的。解决办法是做伽马校正,把线性占空比映射到感知亮度。一个简单的伽马校正公式:
实际CCR = ARR × (目标亮度)^2.2
比如目标亮度50%,实际CCR = 1000 × 0.5^2.2 ≈ 217,而不是500。这样低亮度区域的分辨率更高,调光手感更均匀。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| LED完全不亮 | PWM未启动 | 示波器测引脚有无波形 | 检查HAL_TIM_PWM_Start是否调用 |
| LED常亮不灭 | CCR值等于ARR | 读取CCR寄存器值 | 确保CCR小于ARR |
| 亮度闪烁 | PWM频率过低 | 示波器测频率 | 提高频率到1kHz以上 |
| MOS管发热 | 未完全导通 | 测Vgs电压 | 换低阈值MOS管 |
| 调光跳变 | 未做渐变处理 | 观察CCR变化 | 加定时器中断渐变 |
| 光敏无响应 | I2C地址错误 | 扫描I2C总线 | 确认BH1750地址0x23或0x5C |
| 色温偏色 | 两路LED亮度不匹配 | 分别单独点亮对比 | 校准两路最大亮度 |
7. 几个容易被忽略的实操细节
7.1 定时器通道映射要查手册
STM32的定时器通道和引脚对应关系不是随意的,必须查数据手册的复用功能表。比如TIM3_CH1默认在PA6,重映射后在PB4或PC6。如果你在CubeMX里选了引脚但没使能重映射,PWM是出不来的。我踩过这个坑,调了半天以为是代码问题,结果是引脚映射没配对。
7.2 互补输出和死区时间
如果你用的是H桥驱动或者需要互补PWM的场合,TIM1和TIM8支持互补输出和死区插入。死区时间是为了防止上下桥臂同时导通造成短路。对于普通LED调光,用不到这个功能,但如果你要做“pwm控制电机”或者“单相h桥pwm整流器”,死区配置就是必须的。
7.3 中断优先级冲突
自动调光系统里通常有多个中断:定时器PWM中断、ADC采样中断、I2C通信中断、串口中断。如果优先级配置不当,可能出现I2C读取阻塞PWM渐变的情况。我的做法是把PWM渐变中断设为最高优先级,ADC和I2C设为中优先级,串口设为低优先级。这样即使串口在大量收发数据,也不会影响调光流畅度。
7.4 低功耗场景的注意事项
如果做电池供电的调光系统,PWM频率越高,MOS管开关次数越多,功耗越大。在低功耗场景下,可以把PWM频率降到200Hz到500Hz,虽然可能有轻微频闪,但功耗能降不少。另外,在待机时把PWM输出关掉,MOS管完全关断,LED不耗电。
7.5 校准两路LED的最大亮度
冷白和暖白LED的亮度效率通常不一样,同样的占空比下,一个可能比另一个亮。如果不校准,混出来的白光会偏色。校准方法是:分别给两路100%占空比,用照度计测亮度,然后调整其中一路的CCR上限,使两者亮度一致。没有照度计的话,可以用手机摄像头对着看,或者肉眼对比白纸上的反射光。
8. 扩展思路:从调光到更多可能
这套PWM调光框架搭好之后,可以扩展的方向很多。加一个蓝牙模块,就能用手机APP调亮度和色温;加一个RTC时钟,可以根据时间段自动切换色温——早上冷光提神,晚上暖光助眠;加一个人体红外传感器,做到人来灯亮、人走灯灭。
如果你在做“基于stm32的毕业设计”,这套系统足够撑起一个完整的项目:有传感器采集、有闭环控制、有PWM输出、有驱动电路、有上位机交互。关键是把每个环节的原理讲清楚,把参数计算过程写明白,把踩坑经验记录下来,这些才是答辩时真正加分的部分。
我个人在实际操作中的体会是,PWM调光看起来简单,但要做到“看起来舒服”并不容易。频率、占空比、渐变速度、伽马校正、色温映射,每一个参数都会影响最终体验。多花时间在调试上,用示波器看波形,用眼睛感受效果,比照搬代码有意义得多。