简介:基于51单片机与C语言实现的占空比可调方波(PWM)生成程序,面向电子设计、单片机入门及嵌入式开发学习者。程序通过配置片内定时器/计数器产生中断,在中断服务中切换引脚电平,输出频率与占空比可控的PWM波形,可作为电机调速、LED调光等控制场景的参考实现。资源共13个文件,压缩包约80KB,内容涵盖C源文件、Keil工程配置、hex烧录文件、编译中间文件、备份文件及电路图PDF,文件结构清晰,便于理解工程组成并直接烧录验证。已有2431人学习下载,适合课程设计或电子竞赛中需要快速掌握定时器PWM原理的读者。借助该资源可学习51单片机定时器初值计算、中断服务编写与占空比动态调节思路,在此基础上修改参数即可灵活适配不同应用需求。无论是入门学习还是实际项目参考,都具备较强的实用性。
1. 51单片机占空比可调PWM:先搞清要不要靠delay
大家做舵机或电机调速时,最顺手想到的写法是“高电平delay一下,低电平delay一下”。这串代码在Proteus里能转,真放到舵机上要么抖成筛子,要么一转就卡死,原因很简单:CPU在执行delay的那几百纳秒里管不了按键和显示,把整个程序串成了一个人工定时的木偶。51单片机里做占空比可调的方波,正向路子是用定时器溢出中断,让硬件去计时,主循环只负责改一个“比较值”,这正是本标题要拆的东西:用51单片机的定时器,在C语言里生成频率稳定、占空比能随时改的PWM。它需要你懂一点定时器初值、一点中断开销概念,不依赖增强型51的硬件PWM模块,换到89C52、STC89C51甚至老旧的AT89S51都成立。
PWM的“占空比可调”在代码里只是一个0到100的数字,难的是让这个数字精确映射到引脚电平时长上。下面先从定时器数学模型入手,再给一套可抄的代码,最后用按键现场改占空比,并告诉你怎么用LED亮度判断波形对不对。
2. 定时器中断如何生成PWM:一个计数器变量吃遍周期与占空比
2.0.1 51单片机没有硬件PWM时,输出方波靠“托底定时”
基础型51(80C51内核)内部没有独立的PWM外设,这不是缺陷,而是设计上把时序控制交给定时器/计数器。要产生占空比可调方波,最自然的方案是:让定时器以固定的间隔产生中断,比如每50µs一次;每进一次中断,一个软件计数器累加1;这个计数器的取值空间代表整个PWM周期,比如从0加到199,周期就是50µs × 200 = 10ms,即100Hz。引脚在计数值小于阈值时输出高电平,大于等于阈值时输出低电平——这个阈值就是“占空比设定值”。整个过程不需要delay,CPU被中断打断的时间只有几十条指令,主循环还能腾出手去扫描按键、刷新数码管。
2.0.2 占空比调的比较值,决定“高电平保持多久”
占空比的数学定义是高电平时间占总周期时间的百分比。在上述模型里,设周期计数上限为PERIOD,比较值为duty,那么高电平时间 =duty × 中断间隔,占空比 =duty / PERIOD。注意这里调的是“中断次数”,不是直接调延时毫秒数。这样的好处是:如果你要改变PWM频率,只改中断间隔或者改PERIOD,占空比不会跑偏;反过来你改duty时,频率始终被定时器锁死,不会像delay版本那样一边调占空比一边改变了周期。两步分离,这就是软件PWM被广泛用在51上的原因。
2.0.3 定时器初值的计算公式与误差来源
以12MHz晶振、机器周期=12个时钟周期为例,定时器每计数一次耗时1µs。要让定时器每50µs中断一次,就要求计数50次。采用方式1(16位定时器),初值 = 65536 - 50 = 65486 = 0xFFCE:
TH0 = (65536 - 50) >> 8; // 高8位:0xFF TL0 = (65536 - 50) & 0xFF; // 低8位:0xCE如果晶振是11.0592MHz,一个机器周期约1.085µs,50µs对应的机器周期数不是整数,初值会有截断误差,实测频率会偏移百分之零点几,驱动舵机和LED完全够用。下表给出两种晶振下常用中断间隔的初值,帮助你快速选用:
| 晶振频率 | 中断间隔(µs) | 定时器初值(十进制) | 实测PWM频率误差(% ,按PERIOD=200) |
|---|---|---|---|
| 12MHz | 50 | 65486 | 0 |
| 12MHz | 100 | 65436 | 0 |
| 11.0592MHz | 50 | 65512(截断) | 约0.5 |
| 11.0592MHz | 100 | 65482(截断) | 约0.5 |
提示:中断服务函数里要重新赋初值,否则定时器会从0开始继续跑,周期时间变成65536个机器周期,波形频率和计算值完全对不上。
这个模型的关键在于中断间隔决定了PWM的时间分辨率。间隔越小、计数周期越大,占空比的步进就越精细,但中断占用的CPU时间也越多。50µs间隔下,100Hz的PWM有200个细分档位,舵机控制、呼吸灯、直流电机调速都足够。下面进入能直接烧录的代码环节。
3. 一套能直接用的占空比可调PWM的C51核心程序
3.0.1 定时器0初始化函数:只定周期,不管电平
用一个定时器实现全部PWM逻辑,代码量可以控制在30行内。下面这套代码基于STC89C52RC、12MHz晶振,输出引脚为P1.0,中断间隔50µs,周期计数上限200,初始占空比50%:
#include <reg52.h> sbit PWM_OUT = P1^0; // PWM输出引脚 #define PWM_PERIOD 200 // 周期计数上限:200 × 50µs = 10ms unsigned char cnt = 0; // 软件计数器 unsigned char duty = 100; // 占空比设定值,范围0~200 void Timer0_Init(void) { TMOD &= 0xF0; // 低四位清零,保留定时器1设置 TMOD |= 0x01; // 定时器0,方式1(16位) TH0 = (65536 - 50) >> 8; TL0 = (65536 - 50) & 0xFF; ET0 = 1; // 允许定时器0中断 EA = 1; // 总中断开 TR0 = 1; // 启动定时器0 } void Timer0_ISR(void) interrupt 1 { TH0 = (65536 - 50) >> 8; // 重装初值,保证间隔稳定 TL0 = (65536 - 50) & 0xFF; cnt++; if (cnt < duty) { PWM_OUT = 1; // 计数值小于占空比设定值,输出高 } else { PWM_OUT = 0; } if (cnt >= PWM_PERIOD) { cnt = 0; // 计数到周期末,回0,开始新周期 } } void main(void) { Timer0_Init(); while (1) { // 主循环可以扫描按键或刷新显示,PWM由中断自行输出 } }这段代码里,duty初值设成100,即占空比50%(100/200)。你可以观察中断函数:它没有用乘除法,没有计算百分比,只是拿计数器和设定值比较,这个设计决定中断函数能多快跑完。51在12MHz下执行一条指令约1~2µs,中断函数即使在最坏情况下,也就是几微秒,远小于50µs的中断间隔,主循环不会感觉到明显的卡顿。
3.0.2 占空比参数怎么改:0~200和百分比的换算
上面的duty值范围是0到PWM_PERIOD(200),而不是0到100。如果上位机下发的是百分比(例如60%),换算关系是:duty = 百分比 × 2。这种取值的用意是提高占空比分辨率:每改变1个计数值,占空比变化0.5%。如果你想要1%的步进,可以把中断间隔改成100µs,周期计数上限设为100,此时duty直接用百分比数字,省去换算。
3.0.3 中断里做乘除会引发占空比抖动
新手最常见的改动是写“百分比转比较值”时直接在中断里做乘法:
PWM_OUT = (cnt < duty * 2) ? 1 : 0; // 不推荐一旦duty较大,duty * 2这一步会占用多条指令周期,导致中断执行时间拉长,输出波形的上升沿位置产生几微秒的随机抖动。更好的做法是在主循环里预计算好duty值,中断里只做一次比较。程序结构因此遵循一个原则:中断越短越好,计算东西移到主循环。这也是软件PWM和硬件PWM的本质差异——软件PWM占CPU,硬件PWM完全不占,但51基础型号没有这个硬件,所以我们要用代码换时间。
上述代码跑起来后,用示波器能看到一个10ms周期、高电平宽度稳定的方波。接下来把它接到按键上,实现真正“可调”的交互。
4. 按键调占空比实战:消抖、步进、边界处理
4.0.1 按键直接改全局变量,中断自动响应
PWM的优势在这里表现得最明显:主循环扫描按键,发现按下就把duty加/减一个步进值,中断服务函数下一次进入时比较值已经变了,输出波形随即跟上。不需要停掉定时器、不需要重新初始化,也没有任何过渡期毛刺。关键代码片段:
sbit KEY_UP = P3^0; // 增加占空比按键,低电平有效 sbit KEY_DOWN = P3^1; // 减少占空比按键 #define STEP 4 // 每次按键改变4,即2%占空比 void Key_Scan(void) { if (KEY_UP == 0) { // 检测到按下 DelayMs(10); // 简单延时消抖 if (KEY_UP == 0) { // 再次确认 if (duty < PWM_PERIOD - STEP) { duty += STEP; // 防止增量超出周期上限 } else { duty = PWM_PERIOD; } while (KEY_UP == 0); // 等待释放,避免连续触发 } } if (KEY_DOWN == 0) { DelayMs(10); if (KEY_DOWN == 0) { if (duty > STEP) { duty -= STEP; } else { duty = 0; } while (KEY_DOWN == 0); } } }这段代码里,duty < PWM_PERIOD - STEP和duty > STEP这两个边界判断卡死了占空比的上下限,防止加到溢出。STEP取4意味着每次按键占空比变化2%,100Hz下肉眼可见地变亮变暗。如果你希望调到100%后“封顶”,上面第二分支置成PWM_PERIOD即可,此时引脚一直输出高电平。
4.0.2 消抖延时放主循环,不放中断里
上面用了DelayMs(10),这个函数要占用主循环10ms左右。由于PWM在中断里输出,主循环卡10ms并不会让波形停摆,只是按键扫描变慢。这个特性使得软件PWM在交互场景中比delay那种“堵死”结构从容得多。注意一点:while (KEY_UP == 0)等待释放的写法是阻塞式的,如果用户一直按住按键,主循环会卡在这一行,但PWM照常输出。有些工程师偏好“按下一次、步进一次、按住连发”,那就要加定时器扫描来判断长按,这与PWM无关,按下不表。
4.0.3 完整连接与验证流程
连接关系很简单:P1.0接LED限流电阻(典型220Ω)到GND,P3.0和P3.1各接一个按键,按键另一端接GND,I/O口内部上拉或外部上拉电阻到VCC。烧录程序后,先记录LED亮度,按几下增强键,亮度明显上升,这就是占空比升高的直观表现。如果你手头有示波器,把探头夹在P1.0,会看到高电平宽度随按键增加而变宽,下图为“按一次增加2%”时波形预计变化:
| 按键次数 | duty值 | 高电平时间(ms) | LED亮度感受 |
|---|---|---|---|
| 0 | 100 | 5.0 | 明显亮 |
| 1 | 104 | 5.2 | 略有提升 |
| 5 | 120 | 6.0 | 明显变亮 |
| 25 | 200 | 10.0 | 全亮(常亮) |
从LED亮度反推占空比有一个麻烦:人眼感知亮度不是线性的,占空比从50%升到100%,视觉变化远没有示波器上明显。所以验证占空比是否精准,逻辑分析仪比人眼靠谱。如果你手里只有LED,建议在10%、50%、90%三档间切换,体会“微亮、半亮、几乎全亮”的区别,基本能判断程序正确。
按键调完占空比,离“能交付”还差一步——在真实负载(舵机、直流电机)上跑,有几个容易踩的坑要在下章说清楚。
5. 验证占空比可靠的三个技巧,顺带看看硬件PWM
用一个数值实例来验证占空比配置是否精确:设duty=150、周期上限200、中断间隔50µs,理论占空比 = 150 / 200 = 75%,高电平时间 = 150 × 50µs = 7.5ms。实测示波器上高电平宽度偏差应该在±20µs以内。
关于重装初值的时间误差,可以采用“提前重装”法:在进入中断后先不重装,而是在中断服务函数的靠近末尾、且与下次触发临界位置较近时重装。我的建议是维持第3章的写法——进入中断立即重装。虽然这会让重装指令距离下次定时器溢出点相差几微秒,但由于每次都是固定的偏差,最终展现为频率略微偏高,不影响占空比精度。
最后提一个进阶方向:如果你用的芯片是STC89C52RC、STC15系列,内部其实带了可编程计数器阵列(PCA/CCP模块),它可以做到“硬件PWM”而不占中断:
// 使用STC89C52RC的PCA模块配置PWM(示意) CCON = 0x00; CMOD = 0x02; // PCA时钟源为定时器0溢出 CCAPM0 = 0x42; // 允许PWM输出 CCAP0H = 100; // 比较值决定占空比 CCAP0L = 100;这种模式下一旦配置好,单片机完全不需要额外的中断开销,占空比由比较寄存器硬件控制,精度更高、可用的PWM通道也更多(通常有2~4路)。不过寄存器配置随芯片型号差异较大,代码不像定时器模拟那样通吃。学习占空比可调PWM的原理时,先用定时器模拟理解“比较值决定高电平时间”这个本质,再迁移到硬件PWM,后续调舵机、调电机、矩阵键盘配合显示时才不会一头雾水。
本文还有配套的精品资源,点击获取