简介:一份基于STC12C2052单片机实现脉宽调制调光的完整示例工程,演示如何通过修改占空比让发光二极管从暗到亮平滑变化,适合电子爱好者和嵌入式初学者参考学习。压缩包共55个文件,主要包含多份C语言工程源码、可直接烧写的HEX固件、Keil工程备份与编译过程文件,整体体积仅44KB,结构轻量且便于学习。目前已有814人学习/下载。资源价值在于:不仅从原理上讲解PWM控制亮度的方法,还给出了覆盖按键输入、独立显示等多个功能的实际代码;读者可以借助配套工程文件直接编译、烧录到开发板,验证实际的调光效果。同时,通过对比不同例程中的占空比和周期配置,能够更直观地理解PWM参数与亮度变化之间的关系,快速掌握单片机调光开发的基础思路。
1. 用PWM调节LED灯控制:放弃调压方案,亮度线性度和效率都靠它
网上很多教程教你把LED串个可调电阻或者直接可调电压,结果亮度一调低就发黄,调光过程还严重发热。真正做产品的人都知道,LED灯的亮度调节基本不用线性调压,而是用PWM(脉冲宽度调制)——靠快速开关LED来改变单位时间内的平均电流,从而控制人眼感知的亮度。这个方案不发热、不偏色、效率高,而且从几块钱的MCU到专用驱动芯片都支持。这篇笔记我会从PWM控制LED的原理讲起,给出能直接抄的代码和硬件接法,再把调试过程中最容易翻车的几个坑列出来。适合刚开始接触单片机PWM模块、或者想把LED调光从"能亮"做到"调得顺滑"的嵌入式开发者。
2. PWM调光的底层逻辑:占空比、频率与亮度感知曲线
2.1 占空比只决定"开多久",不是"给多大电压"
PWM的全称是Pulse Width Modulation,核心思想是用一个固定频率的方波,通过改变高电平持续的时间比例来控制平均功率。这个比例就是占空比(Duty Cycle)。比如频率是1kHz、周期1ms,高电平0.5ms,占空比就是50%。LED实际看到的不是稳定的2V或3.3V,而是一串高低电平。但人眼有视觉暂留效应,只要开关频率足够高,看起来就是"半亮"。
这里要纠正一个常见误区:PWM调光不是改变LED两端电压。LED的电压-电流关系是非线性的,电压稍微变一点,电流可能翻倍,亮度根本控不住。而PWM是通过快速通断,让LED要么完全导通、要么完全关断,导通过程始终工作在额定电流下。这样LED始终处于效率最高点,色温也不会因为电流改变而漂移。
从MCU角度看,产生PWM有两种方式:一种是纯软件用定时器中断去翻转IO口,另一种是用芯片自带的硬件PWM外设。硬件PWM几乎不占CPU,适合多路调光;软件PWM灵活,不受引脚限制,但频率和能力范围有限。下面这段代码演示了最纯粹的PWM思路——用GPIO翻转模拟一个简易PWM:
// 伪代码:用两个变量模拟PWM #define PERIOD 1000 // 周期计数上限,对应1ms@1MHz计数 volatile uint16_t duty = 300; // 占空比计数,300/1000 = 30% volatile uint16_t counter = 0; void pwm_handler(void) { // 假设以1kHz调用 counter++; if (counter < duty) { GPIO_SetPin(HIGH); } else { GPIO_SetPin(LOW); } if (counter >= PERIOD) { counter = 0; } }这段代码里,PERIOD决定PWM周期,duty决定高电平时间。改变duty就能改变平均电流,从而调节LED亮度。注意这里的GPIO_SetPin是在中断里被频繁调用的,实际工程中我不会在中断里直接操作GPIO输出寄存器,而是用影子寄存器或直接赋值端口寄存器,避免多线程竞争。这个逻辑是所有PWM的雏形,理解了它,后面看硬件PWM寄存器就是换了个形式。
2.2 频率选多少:1kHz只是起点,20kHz进入无感区
频率是PWM调光里最影响体验的参数。频率太低,比如100Hz,人眼虽然勉强看不出闪烁,但余光扫过或者快速移动视线时,会觉得LED在抖。更麻烦的是手机摄像头,CMOS传感器的卷帘快门会捕捉到明暗条纹,拍出来就是一道一道的黑影。
我一般把PWM频率分为三档:
| 频率范围 | 观感与应用 | 典型场景 |
|---|---|---|
| 200Hz ~ 1kHz | 肉眼基本不闪,但高速移动或拍摄会频闪 | 低成本指示灯、低速调光 |
| 1kHz ~ 5kHz | 大多数照明场景安全,摄像头仍可能感知 | 普通LED台灯、氛围灯 |
| 10kHz以上 | 进入人眼和拍摄的无感区 | 摄影补光灯、医疗照明、高速检测设备 |
实际选频还要考虑LED驱动方式。用简单的三极管或MOS管开关,频率越高,开关损耗越大。对普通LED照明,2kHz到5kHz是比较折中的区间。如果你用的是恒流驱动IC,比如常见的一些降压恒流芯片,它们内部有最小导通时间限制,频率太高会导致占空比调不下去,最低亮度受限。
另外注意,PWM频率和GPIO翻转速度不是一回事。软件PWM用定时器中断模拟,如果中断周期是1kHz,那么主循环里任何一段耗时操作都会造成PWM抖动。硬件PWM模块没有这个问题,因为计数器独立运行,输出引脚由硬件自动翻转。
2.3 人眼不是线性传感器:Gamma校正让渐变更自然
这个坑藏得很深。你把占空比从0%按10%步进加到100%,屏幕上看LED亮度不是均匀变亮的。人眼对暗部的变化更敏感,对亮部的变化迟钝。比如占空比从5%加到10%,你会觉得"亮了不少";但从90%加到95%,几乎看不出变化。这就是感知的非线性,类似显示器的Gamma曲线。
如果直接线性调占空比,呼吸灯的渐变会显得头重脚轻,暗部一下就过去了,亮部磨蹭半天。要解决就得做Gamma校正。公式一般是:
# 生成256级Palmma校正表,gamma=2.2 gamma = 2.2 max_duty = 1000 # 硬件PWM满占空比 table = [] for i in range(256): normalized = i / 255.0 duty = int(max_duty * (normalized ** gamma)) table.append(duty) print(table[:10])这里gamma=2.2是常见的LCD显示标准,对LED照明也可以直接用。i对应你希望呈现的线性亮度等级,table[i]就是实际要写入PWM寄存器的占空比数值。查表法比实时计算快,适合在定时器中断里快速更新。Gamma表可以预生成放到常量数组里,省去每帧计算的耗时。后面第5章我会给出完整的查表实现。
2.4 驱动电路选型:三极管、MOS管还是直接用引脚
很多新手直接把LED串个电阻怼在GPIO上,然后开始调占空比。这样做对小功率LED(十几毫安)没问题,但一旦LED电流超过单片机的引脚驱动能力,就会出现电平被拉低、芯片发热、甚至引脚烧毁。而且LED的正向导通特性决定了它吸收电流不是线性的,占空比设置50%,实际电流可能不是50%。
我常用的驱动方案就是NPN三极管或者N-MOS管做低端开关。LED接电源正极,限流电阻串在LED和开关管集电极(或漏极)之间,控制信号接基极(或栅极)。用N-MOS管时注意选逻辑电平型号,阈值电压要低于MCU输出高电平,比如3.3V的MCU要选Vgs(th)在1V~2V的管子,否则栅极电压不够管子打不开。
对于大电流LED灯珠,比如1W以上,感性或容性负载特性会更明显,最好用专用的恒流LED驱动芯片,芯片上的PWM调光引脚直接接MCU。这类芯片通常引脚标着PWM或EN,输入一个方波就能调光。此时不要把PWM信号直接接在LED回路里,而是接在芯片的调光引脚上,接反了会烧芯片。
3. 从定时器到寄存器:三段能直接抄的PWM实现代码
3.1 方案一:用定时器中断手动翻转引脚,适用任何MCU
如果你的芯片没有硬件PWM,或者PWM引脚被占用了,用定时器中断模拟是最通用的办法。下面这段代码以10kHz中断为例,动态改变占空比实现呼吸灯效果:
// 假设定时器每100us触发一次中断,10kHz #define PWM_PERIOD 100 // 100个中断周期 = 1ms,PWM频率1kHz static uint16_t pwm_counter = 0; static uint16_t pwm_target = 50; // 目标占空比 50% void TIMER0_IRQHandler(void) { // 清除中断标志,然后进入PWM输出逻辑 if (++pwm_counter >= PWM_PERIOD) { pwm_counter = 0; } if (pwm_counter < pwm_target) { LED_PORT |= (1 << LED_PIN); // 拉高 } else { LED_PORT &= ~(1 << LED_PIN); // 拉低 } }这段代码的逻辑是:每进一次中断,计数器加1。当计数值小于pwm_target时输出高电平,否则输出低电平。当计数器满一个周期归零。pwm_target在中断外修改,比如在主循环里根据呼吸灯的相位递增或递减。
参数上有两个关键点。第一,定时器周期与PWM_PERIOD的关系:定时器中断周期×PWM_PERIOD= PWM完整周期。比如中断周期100us、PWM_PERIOD=100,那PWM周期就是10ms,频率100Hz——这个频率就有点低了。我一般把中断周期设到50us,PWM_PERIOD=100,得到2kHz PWM。注意中断频率越高,CPU占用越大,呼吸灯这种简单任务还好,如果还要处理其他实时逻辑,就得考虑硬件PWM了。
第二,pwm_target是16位变量,在中断里读取它时,主循环可能正在修改它。对单核MCU,如果目标值变化不频繁,直接读也问题不大,但严谨起见,可以在原子操作里先关中断再改值,或者用volatile关键字加影子拷贝。
3.2 方案二:硬件PWM模块,改三个寄存器就完事
大部分32位MCU和部分8位MCU都有硬件PWM输出引脚。以常见的PWM模块为例,配置步骤通常固定:选时钟源、设周期、设占空比、开输出。我把配置代码写成下面这样:
void pwm_init(void) { // 1. 使能外设时钟,配置引脚为复用功能AF1 RCC->AHBENR |= RCC_AHBENR_GPIOAEN; GPIOA->MODER = (GPIOA->MODER & ~GPIO_MODER_MODER8_MASK) | (2 << GPIO_MODER_MODER8_POS); // AF模式 GPIOA->AFR[1] &= ~(0xF << 0); GPIOA->AFR[1] |= (1 << 0); // AF1 // 2. 配置定时器时钟与计数器 RCC->APB1ENR |= RCC_APB1ENR_TIM2EN; TIM2->PSC = 71; // 72MHz/(71+1)=1MHz计数 TIM2->ARR = 999; // 周期1000,PWM频率1kHz TIM2->CCR1 = 500; // 占空比50% // 3. 配置输出比较模式与使能通道 TIM2->CCMR1 = 0x60; // PWM模式1,向上计数 TIM2->CCER |= TIM_CCER_CC1E; // 使能通道1输出 TIM2->CR1 |= TIM_CR1_CEN; // 启动定时器 } void pwm_set_duty(uint16_t duty) { if (duty > 999) duty = 999; TIM2->CCR1 = duty; }这段代码里,PSC是预分频器,把72MHz的系统时钟降为1MHz计数频率;ARR是自动重载值,计数从0加到999然后归零,所以PWM周期是1000/1MHz = 1ms,对应1kHz。CCR1是通道1的比较值,当计数器小于CCR1时输出高电平,这就是占空比。
后面你只要调用pwm_set_duty()就能实时改亮度。注意ARR和CCR1的位数,有些芯片是16位,最大只能到65535,频率太低时周期计数会溢出。另外寄存器的位域和模式号在不同芯片上不一样,抄代码前一定要翻对应的参考手册,别搜到个例子就直接拷。
3.3 方案三:多路呼吸灯,让三路LED独立渐变
实际项目中往往不止控制一个灯,比如红绿蓝三色灯混色、或者多个氛围灯轮流呼吸。多路PWM如果共用同一个定时器,可以用不同通道的CCR来控制不同引脚。下面以三路为例:
typedef struct { uint16_t current; // 当前占空比 uint16_t target; // 目标占空比 int8_t dir; // 渐变方向: +1递增, -1递减 uint16_t step; // 每步增量 } channel_t; channel_t ch[3] = { {0, 100, 1, 1}, {0, 200, 1, 2}, {0, 300, 1, 3} }; void breather_update(void) { for (int i = 0; i < 3; i++) { if (ch[i].current < ch[i].target) { ch[i].current += ch[i].step; pwm_set_duty(i, ch[i].current); } else if (ch[i].current > 0) { // 到达目标后反向递减,模拟呼吸 static int phase_reverse[3] = {0}; if (phase_reverse[i] == 0) { ch[i].target = 0; phase_reverse[i] = 1; } ch[i].current -= ch[i].step; pwm_set_duty(i, ch[i].current); if (ch[i].current <= 0) { phase_reverse[i] = 0; ch[i].target = (i + 1) * 100; } } } }这段代码的问题在于phase_reverse是静态变量,逻辑写复杂了容易乱。我后来在实际项目里会把它改成基于状态机的写法,每一步只做两件事:判断是否到达目标,到达就切换目标;然后以固定步长逼近目标。上面的代码更能演示步长的作用:step越大,渐变越快,三路用不同步长,三盏灯就有层次感。
多路PWM的坑在于驱动电流。三路LED不能直接并联在一个GPIO上,需要每路独立驱动管。如果共用一个定时器的不同通道,不同引脚之间的相位差是固定的,不会互相干扰,但要注意同一时刻所有通道的开关动作会集中在一个计数点附近,瞬间电流可能翻倍,电源要留余量。
3.4 三个方案的选型对比与参数配置表
选型不光是看芯片有没有PWM外设,还要算清楚频率、分辨率、引脚资源和CPU占用。下面这张表是我做调光时反复用的:
| 维度 | 定时器中断模拟 | 硬件PWM | 专用恒流驱动IC |
|---|---|---|---|
| 频率精度 | 受中断负载影响,抖动较大 | 硬件计数,稳定 | 由IC内部振荡器决定 |
| 占空比分辨率 | 由周期计数值决定,可做很高 | 受ARR寄存器位数限制 | 通常8位~12位 |
| 多路扩展 | 单路会阻塞CPU,多路吃力 | 一路一个通道,轻松多路 | 按芯片通道数 |
| 调光范围 | 0~100%连续,但低占空比受翻转速度限制 | 最小非零占空比受限 | 部分IC有最小导通时间 |
| 适用芯片 | 任何有定时器的MCU | 需要PWM/OC输出引脚 | 需要外购驱动芯片 |
我自己的经验是:如果只是做一个演示级的呼吸灯,用定时器中断最简单,代码可移植性强;如果要做产品,一定要用硬件PWM,省CPU而且不会让你在跑复杂算法时发现灯光在抖。至于驱动IC,那是做照明产品的必经路,别省。
4. 避坑与排查:LED频闪、暗亮、失控的五个血泪教训
4.1 现象:手机摄像头下LED疯狂闪烁
白天肉眼看LED非常稳定,但把手机相机凑近,屏幕上出现黑色横条纹来回滚动,亮度也是在跳的。这说明PWM频率太低,和摄像头采样频率之间产生了差频。频率越低,条纹越粗、滚动得越快。
原因:CMOS相机用逐行曝光方式采样,当PWM周期与曝光时间不同步时,某些行的曝光覆盖到高电平、另一些行覆盖到低电平,就会形成明暗条纹。解决:把PWM频率提到2kHz以上,或者10kHz彻底进入无感区。不过要注意,升频之后驱动管的开关损耗会增加,如果用的是低端三极管,波形上升沿变缓,还可能引入额外发热,最好用示波器看一下波形边沿是否陡峭。
4.2 现象:亮度调低时LED明显偏色
同一个LED在占空比10%和90%时,光的颜色不一样,暗的时候发蓝、亮的时候发白,或者暗的时候明显偏黄。原因是LED色温本身和电流有关,电流越低色温越偏。PWM调光因为始终保持额定电流脉冲,本应该不偏色,但如果你用的是调电压方式的低成本方案,或者LED驱动电路里串联了电阻导致低占空比时电流不足,就会产生偏色。
解决:确认驱动电路是真正的恒流脉冲,而不是"PWM输出直接拉低限流电阻"。对于大功率灯珠,建议使用恒流驱动IC,IC的PWM调光引脚直接接收MCU信号。另外,如果LED本身一致性差,同一批次不同灯珠在低占空比下色温偏差明显,那就要做分bin筛选了。
4.3 现象:PWM引脚发热甚至烧毁
MCU引脚直接接LED,占空比看起来正常,但引脚温度迅速上升,时间长了IO口烧坏。原因是引脚承受的电流超过了数据手册里的最大额定值,通常MCU GPIO的灌电流在几毫安到二十毫安之间,大功率LED直接接上去属于超限使用。
正确的做法是用外部开关管,或者至少用一个限流电阻把电流压到引脚能接受的范围。我见过一个案例,某开发者把12V的LED灯带直接用MCU引脚驱动,引脚瞬间击穿,连累整片芯片报废。排查时先看原理图,确认LED回路里有没有独立的开关器件,别只盯着代码里的占空比。
4.4 现象:调节占空比没反应,LED全亮或全灭
程序逻辑看起来没问题,占空比变量在变,但LED就是完全亮或者完全灭。最常见的原因就是引脚复用冲突。很多芯片的PWM输出引脚和SWD调试口、外部晶振、下载引脚共用。比如某开发板的PA13/PA14是SWD引脚,如果你强行把它们配置成PWM输出,调试器直接断开,代码下载都成问题,更别说调光了。
另一个原因是极性设置反了。有些PWM模块的输出极性可以配置,默认高电平有效,但有的芯片配置成低电平有效,你写入50%占空比,实际上高电平时间只有50%不行吗?不,如果你设置的是"反相PWM模式",那50%占空比对应的实际输出是50%低电平,看起来就是常亮。检查极性寄存器,确认是PWM模式1还是模式2。
4.5 现象:呼吸灯渐变卡顿,亮度一跳一跳
呼吸灯渐变过程不是平滑的,而是每几十毫秒跳一大格,甚至肉眼能看到明显的阶梯。原因通常是占空比更新频率太低,或者更新步长太大。比如你在主循环里每10ms把占空比加10,亮度变化就是台阶式的。另一个隐藏原因是中断优先级问题:如果PWM更新的中断优先级太低,被其他更高优先级的中断抢占,占空比更新就会延迟,导致忽快忽慢。
解决:把占空比更新放到定时器中断里,步长调到1或2,更新周期控制在1ms以内。如果是硬件PWM,直接把目标占空比写入CCR寄存器即可,不需要额外中断。别忘了开启编译优化,有些编译器默认O0,循环里代码执行慢,也会影响渐变的顺滑度。
5. 进阶技巧:用Gamma查表法把亮度渐变调顺滑
前面提到人眼对亮度感知是非线性的,直接线性调占空比会让渐变看起来不均匀。这里给出一个完整可用的Gamma查表方案。先在上位机用Python生成一张256级的表,然后把表放到C代码里作为常量数组。
gamma = 2.2 max_duty = 255 # 8位PWM with open('gamma_table.h', 'w') as f: f.write('const uint8_t gamma_table[256] = {\n') for i in range(256): v = int(round(((i / 255.0) ** gamma) * max_duty)) f.write(str(v) + (',\n' if i % 16 == 15 else ', ')) f.write('};\n')在C代码里,查表转换只在目标亮度变化时做一次,不占中断开销:
extern const uint8_t gamma_table[256]; uint8_t linear_level = 128; // 你想设置的"线性亮度",范围0~255 // 将线性值映射为实际占空比 uint16_t duty = gamma_table[linear_level]; pwm_set_duty(duty);关键地方在于:linear_level是逻辑亮度,gamma_table[linear_level]是物理占空比。比如linear_level=128,查表得到约170,而不是128。这会让中间亮度的LED看起来比直调更亮一些,因为人眼对暗部敏感,我们故意把中低亮度提亮,整体渐变就趋近线性了。
我自己的习惯是:先做一个最小呼吸Demo,把呼吸周期设为2秒,用手机慢动作视频记录,然后用Gamma表替换后再录一遍,对比两次的亮度曲线。从那以后,我每次调LED亮度都强制走一遍Gamma查表,哪怕只是做个指示灯。这套代码配一个简单的驱动电路就能跑通,工程文件我放在下载包里了,直接打开就能试。希望帮到你。
本文还有配套的精品资源,点击获取