51单片机可调PWM发生器设计与实现:定时器中断与占空比调节详解
2026/9/16 12:23:58 网站建设 项目流程

简介:面向51单片机入门与进阶学习者的可调PWM发生器工程资源,基于定时器中断生成PWM信号,支持按键动态调节占空比,并借助1602液晶显示当前参数,解决PWM波形生成与调节的核心问题。压缩包共30个文件,涵盖main.c、delay.c、key.c、1602.c等C语言源码,配套头文件、Keil工程文件及编译生成的hex、obj、lst等文件,既可直接烧录验证,也便于结合工程进行断点调试,整体约49KB,轻量易用。已有1900人下载学习,适合单片机课程设计或日常练习。通过该资源可掌握定时器初值计算、PWM占空比控制、中断服务程序编写、按键消抖及LCD1602显示驱动等实用技能,并能在其基础上快速扩展出电机调速、呼吸灯等典型应用场景。

1. 可调PWM发生器在51上要解决什么:无硬件PWM的约束与主要设计目标

想用一块 51 单片机做一个频率、占空比都能现场改的方波信号源,说穿了就是要把两个很简单的问题串起来:参数怎么通过按键或者电位器改,以及改完以后怎么输出一个稳定、能带负载的 PWM 波形。问题是基础型 51 单片机(AT89C52、STC89C52 这一类)片内没有硬件 PWM 外设,更没有死区插入、刹车输入这些增强功能,绝大多数工程师最后走的是同一条路:用定时器中断翻转 IO,用两个不同的计数初值分别控制高电平时间和低电平时间,拼出一个完整的 PWM 周期。

这个标题在课程设计和毕业设计里出现频率很高,因为它覆盖了“51 单片机课程设计”最常见的几个得分点:定时器、按键扫描、动态数码管、Proteus 仿真波形。同时也覆盖了一部分产品场景,比如把 PWM 输出端接一个 MOS 管去做风扇调速或者 LED 调光。文章不打算复述某一个现成源码包,而是把定时器参数怎么算、按键怎么不干扰 PWM 中断、输出级怎么接负载这三件事讲透。新手可以照着接线和抄代码,老手能从这里看到软件 PWM 的误差边界、故障保护和死区这些容易被忽略的地方。

2. 定时器中断模拟PWM:频率、占空比公式与误差边界

2.1 为什么51上做PWM首选“定时器+IO翻转”

翻 AT89C52 的数据手册,你会找到定时器、串口、外部中断,就是找不到一个叫 PWM 的定时器通道。这不是芯片缩水,是这类 8051 架构的年代设定决定的。既然片内没有现成硬件,我们只能把定时器 T0 当时基,在中断服务函数里翻转 IO。高电平期间装一个较长的定时初值,低电平期间装另一个初值,两个初值合在一起决定 PWM 周期,高电平初值单独决定占空比。

常见做法是选用定时器 0 的方式 1,也就是 16 位定时器。单次定时范围可以到 65536 个机器周期,假设晶振 12MHz、标准 12T 内核,机器周期是 1us,那么单次定时范围就是 1us 到 65536us,对应频率能覆盖 16Hz 到最高几十 kHz。对课程设计里的直流电机调速、LED 亮度调节、舵机控制来说已经足够。如果晶振换成 11.0592MHz,机器周期约 1.085us,公式里要同步替换,否则频率偏差会超过 8%,这是很多 Proteus 仿真里波形对不上示波器读数的第一原因。

#include <reg52.h> sbit PWM_OUT = P1^0; // PWM 输出引脚 unsigned int highCount = 500; // 高电平半周期计数值 unsigned int lowCount = 500; // 低电平半周期计数值 void Timer0_Init(void) { TMOD &= 0xF0; // 只改低四位,保留 T1 原有配置 TMOD |= 0x01; // T0 工作方式 1,16 位定时 TH0 = (65536 - highCount) >> 8; TL0 = (65536 - highCount) & 0xFF; ET0 = 1; // 使能 T0 中断 EA = 1; // 开总中断 TR0 = 1; // 启动定时器 } void Timer0_ISR(void) interrupt 1 { static bit isHigh = 1; // 当前输出电平状态 if (isHigh) { // 高电平结束,翻低,装载低电平时长 isHigh = 0; PWM_OUT = 0; TH0 = (65536 - lowCount) >> 8; TL0 = (65536 - lowCount) & 0xFF; } else { // 低电平结束,翻高,装载高电平时长 isHigh = 1; PWM_OUT = 1; TH0 = (65536 - highCount) >> 8; TL0 = (65536 - highCount) & 0xFF; } } void main(void) { PWM_OUT = 1; Timer0_Init(); while (1) { // 按键扫描、数码管刷新都放这里 } }

这段核心逻辑是:每次中断意味着一个半周期结束,翻转引脚后重新装入另一段定时值。高电平持续时间由 highCount 决定,低电平持续时间由 lowCount 决定。如果想让输出从 0% 平滑渐变到 100%,只要在主循环里修改这两个变量,下一次半周期翻转时新值就会生效。

需要注意的是,这种软件模拟方式做不到硬件 PWM 那么均匀。中断响应本身有 3 到 8 个机器周期的延迟,重装 TH0/TL0 时定时器也没有暂停,所以每个边沿都会有几微秒的抖动。手头如果只有普通万用表察觉不到,但示波器放到微秒级时隙时能看到波形边缘不齐。因此我一般把可调上限控制在 10kHz 到 20kHz,频率再高就换带硬件 PWM 的型号,没必要在 51 上死磕。

2.2 PWM频率、占空比计算公式与频率分辨率

在 12MHz、12T 内核、机器周期 1us 的前提下,高电平计数 H 和低电平计数 L 直接决定了输出波形参数。下面这几个式子是在 51 上调参之前必须写在本子上的:

目标参数计算公式说明
PWM 周期T = (H + L) usH 与 L 各自是计数值,不是初值
PWM 频率f = 1000000 / (H + L) Hz晶振频率不同要替换分子
占空比D = H / (H + L) × 100%只调 H 不调 L,频率一定会变
频率分辨率Δf ≈ f² / 1000000 Hz每加减 1 个计数值,频率变化量
单次计数上限H 或 L ≤ 65535超过后定时器溢出周期边长

举个例子,如果 H = 250、L = 750,那么周期是 1000us,频率 1kHz,占空比 25%。如果保持频率不变、把占空比调到 50%,应让 H = 500、L = 500。很多初学代码只写了 H 的变化,按下占空比加一后 L 不动,示波器上一看频率跟着跑了,还以为芯片坏了。

这里还藏着一个设计细节:调占空比的步进应该是多少计数值。如果软件里用一个 pwmPeriodConst 固定总周期,比如 1000,那么 1% 占空比正好是 10 个计数值,用整数运算不会丢精度。可如果用一个任意数,比如总周期等于 997,99% 占空比算出 987 个计数,百分比之间有跳变。所以我做面板时习惯把频率做成固定档位,例如 100Hz、200Hz、500Hz、1kHz、2kHz、5kHz,每档对应一个固定的总周期常数,占空比用归一化方式去算,这样的可调 PWM 在显示和实际输出之间不会有“调 50% 实际是 49.2%”的尴尬。

2.3 为什么“可调PWM”不能只调占空比:参数联动策略

如果标题里的“可调”只指占空比,那实现非常简单。但“PWM 发生器”通常意味着频率也要可调,于是问题来了:频率和占空比是两个独立变量,而中断重装只有两个寄存器初值,用户每按一次“占空比加”按键,软件必须决定是只改 H 还是同时改 H 和 L。只改 H,周期就变长,频率会漂移到别处;同时改 H 和 L,才维持了频率不变。

“频率再细调”和“占空比细调”还要处理一个手感问题:1kHz 时每档 1Hz 和 100Hz 时每档 1Hz,到底是哪种?常见做法是频率用查表而非连续加减,档位之间按整数倍或对数间隔分布,这样在低频段和高频段都能保证可用的调整精度。占空比则固定 1% 步进,配合下一章要写的按键消抖逻辑。

unsigned int pwmPeriodConst = 1000; // 目标周期计数值,12MHz 下对应 1kHz unsigned int highCount; unsigned int lowCount; void RebuildPwmByDuty(unsigned char dutyPercent) { // 限制上下限,避免 H 为 0 或等于周期导致输出停振 if (dutyPercent > 99) dutyPercent = 99; if (dutyPercent < 1) dutyPercent = 1; // 用整数乘法代替浮点百分比,避免中断外计算浮点 highCount = (unsigned int)((unsigned long)pwmPeriodConst * dutyPercent / 100UL); lowCount = pwmPeriodConst - highCount; }

这段代码没有浮点运算,是因为 C51 的浮点库很占资源,而且在快速连按按键时,浮点除法可能让主循环卡顿,影响数码管刷新。pwmPeriodConst 为 1000 时,dutyPercent 从 1 到 99 都能得到精确结果;当它不能被 100 整除时,最后一位会出现跳动,但控制在 1% 步进可接受的范围内。

实际调试时还有一点容易被忽略:如果中断正在执行,主循环恰好改了 highCount,下一次中断装载会用新值,那么当前这个半周期会被截短或者拉长。对电机、LED 来说影响可以忽略,对于要求波形连贯的场合,可以在主循环里设一个 updateFlag,由中断在翻转点去读取新值,保证参数只在边沿生效。51 上做这个很简单,加一个 if(updateFlag) 判断即可,但很多人只有等到示波器上看到毛刺才会去补这一层。

3. 可调面板与参数显示:按键、数码管和Proteus仿真联调

3.1 硬件面板设计和接线要点:四个按键加四位数码管够用

把“可调”这层意思落地,最少需要两个设置键,一个管频率、一个管占空比。但实际做起来四个独立按键(频率加、频率减、占空比加、占空比减)更符合直觉,按键扫描代码也更容易组织。显示部分用四位数码管动态扫描,前两位显示频率(单位 Hz),后两位显示占空比(0 到 99)。如果想区分当前在调哪个参数,可以加一个 LED,调频率时点亮,调占空比时熄灭。

功能建议引脚说明
PWM 输出P1.0默认准双向口,可换推挽模式
按键 ×4P2.4 ~ P2.7低有效,未按下时为高电平
数码管段码P0 口P0 是开漏结构,需要外接 10k 上拉排阻
数码管位选P2.0 ~ P2.3共阴数码管,位选高有效
故障输入P3.2INT0 外部中断,第 4 章会用到

这里把按键放在 P2 口而不是 P3 口,是为了把 P3.2 留出来给外部中断故障保护。很多课程设计里按键直接占满 P3 口,后面想加过流保护时才发现 INT0 冲突,只能飞线,很狼狈。如果你完全没有故障保护需求,按键放在 P3 口也完全可以。

数码管部分我直接用共阴四位数码管,位选通过 P2.0 到 P2.3 外接 NPN 三极管驱动。要注意的是,P0 口本身没有内部上拉,直接接共阴数码管段码时,要靠外部上拉电阻提供高电平驱动电流。如果省略上拉电阻,仿真可能看起来能亮,实物上会出现段码亮度不均甚至缺笔画。

3.2 主循环里扫按键,中断里只改计数初值

按键逻辑最忌讳的就是在定时器中断里做消抖。中断服务函数的执行时间每增加 100us,PWM 最高可用频率就下降一截,而且消抖延时会让 PWM 波形产生不可预测的缺口。正确的分工是:中断只负责翻转引脚和重装初值,按键扫描、数码管刷新全部放在主循环里。

sbit KEY_FREQ_UP = P2^4; sbit KEY_FREQ_DN = P2^5; sbit KEY_DUTY_UP = P2^6; sbit KEY_DUTY_DN = P2^7; void DelayMs(unsigned int ms) { unsigned int i, j; for (i = 0; i < ms; i++) for (j = 0; j < 120; j++); } void ScanKeys(void) { if (KEY_FREQ_UP == 0) { DelayMs(10); // 软件消抖 while (KEY_FREQ_UP == 0); // 等待松手,防止一次按下连续触发 if (freqIndex < 5) { freqIndex++; pwmPeriodConst = periodTable[freqIndex]; RebuildPwmByDuty(dutyPercent); } } if (KEY_DUTY_UP == 0) { DelayMs(10); while (KEY_DUTY_UP == 0); if (dutyPercent < 99) { dutyPercent++; RebuildPwmByDuty(dutyPercent); } } // 频率减和占空比减的代码逻辑与此一致,只是方向相反 }

消抖用 10ms 延时是惯例,这一版用松手检测,简单可靠,缺点是按着不放不会连加。对于课程设计够了。如果不想阻塞主循环,可以把“等待松手”改成边沿检测,记录上一次按键电平,只在电平从 1 变 0 时响应一次,这样主循环的数码管刷新不会被卡住。

数码管动态扫描的套路同样在 while(1) 里,每次调用只点亮一位,轮流点亮 4 位,利用人眼视觉暂留形成完整显示。下面这段用查表法,0~9 的共阴段码直接放在 code 区,省下昂贵的 RAM。

unsigned char code segTab[] = { 0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F }; void DisplayProcess(unsigned int value) { unsigned char i; unsigned char dispBuf[4]; dispBuf[0] = segTab[value / 1000 % 10]; dispBuf[1] = segTab[value / 100 % 10]; dispBuf[2] = segTab[value / 10 % 10]; dispBuf[3] = segTab[value % 10]; for (i = 0; i < 4; i++) { P2 &= 0x0F; // 关闭所有位选,消隐 P0 = dispBuf[i]; // 先送段码 P2 |= (1 << i); // 再打开第 i 位,防残影 DelayMs(1); } }

这里有个顺序问题:必须先关位选、再送段码、最后打开位选。如果先开位选再送段码,切换瞬间旧段码会在新一位上闪一下,肉眼看到的就是拖影。DelayMs(1) 让每位停留约 1ms,4 位一轮约 4ms,刷新率 250Hz,足够稳定。想提升亮度就把延时加大到 2ms,但按键响应会变慢,要自己权衡。

3.3 Proteus仿真看波形:示波器读频率和占空比的验证步骤

Proteus 仿真 51 单片机几乎是这类课程设计的标配环境。接线仿真时,除了单片机,再放一个示波器到 P1.0 上,运行后要重点确认三件事:频率对不对、占空比对不对、边沿是否干净。

操作顺序是这样的:先在 Proteus 里放置 AT89C52,双击芯片把晶振频率改成 12MHz。很多人的仿真从库里面拖出来就用,默认晶振是 11.0592MHz,而代码里按 12MHz 手写初值,结果示波器读出频率比预期偏低,去改代码折腾半天。然后 Keil 里勾选 Output 页的 Create HEX File,编译后把生成的 hex 文件烧进 Proteus 的单片机模型里。运行后示波器如果显示一条直线,先查 EA、ET0、TR0 三个位有没有打开,再查中断函数里的 interrupt 编号是不是 1。

提示:Proteus 自带的示波器自动量程下,周期数字会频繁跳动。把它固定到 1ms/div,打开光标模式,量一个完整周期的两个边沿,再量高电平宽度,比直接看屏幕左下角读数可靠得多。

频率、占空比都验证完,再回头看看按键和数码管显示。如果按下“占空比加”后示波器波形不变,大概率是中断函数里直接用旧的 highCount/lowCount,主循环改了参数但没调用 RebuildPwmByDuty。这是软件 PWM 最容易踩的坑,调参入口和参数应用点脱节。

4. 输出端才是分水岭:MOS管驱动、舵机电机负载与PWM故障保护

4.1 51的IO口带不动功率负载:用AO3400A做低边MOS开关

51 单片机准双向 IO 口的高电平驱动能力非常弱,数据手册典型值只有几十到几百微安,直接推 LED 都要限流,更别说电机和灯带。所以接了负载的输出端,通常要用一个 N 沟道 MOS 管做低边开关。PWM 引脚接栅极,源极接地,漏极接负载下端,负载上端接电源正极。这个电路里最常用的管子是 AO3400A,它是逻辑电平 MOS,栅极阈值电压很低,5V 单片机的电平可以直接打开,不需要额外加栅极驱动芯片。

器件参数推荐取值作用
栅极串联电阻 RG10Ω ~ 100Ω抑制高频振铃
栅源下拉电阻 RGS10kΩ防止上电瞬间误开通
续流二极管1N5819 或 SS34感性负载关断时续流
负载连接负载一端接正电源,一端接 D低端开关,不能用于高端驱动

栅源下拉电阻是很多人会漏掉的。单片机上电复位时 IO 口是弱上拉还是高阻不定,如果在 mos 管栅极悬空时误触发电平,负载会先转一下再停。接一个 10kΩ 下拉把栅极默认电压钳在 0V,才能保证上电瞬间输出是关闭的。感性负载例如电机或者继电器线圈,则必须在负载两端反向并联一个快恢复二极管,否则关断瞬间的尖峰电压会通过漏极反灌到 MOS 管,一次就可能击穿。

4.2 负载决定频率:舵机看脉宽、电机看噪声、LED看频闪

同一个可调 PWM 发生器,接不同的负载时频率选择完全不同。

舵机控制的重点是脉冲宽度,而不是占空比。标准 50Hz 模拟舵机,周期 20ms,0.5ms 脉宽对应 0 度,1.5ms 对应 90 度,2.5ms 对应 180 度。如果这个可调 PWM 发生器把频率调成 200Hz,占空比再对,脉宽时间也对不上,舵机会抖动、发烫甚至嗡嗡响。所以“51 单片机 + 舵机”场景里,调节对象一定要设计成脉宽而非百分占比空比。

直流电机调速则看转速稳定性和噪音。频率低于 5kHz 时电机容易听到明显的电枢啸叫和转速脉动,频率超过 20kHz 以后进入超声波范围,人耳听不到,但 MOS 管开关损耗明显增加,波形边沿也会因为栅极电容变差。我一般在小功率 12V 电机上取 10kHz 到 15kHz,这个范围内转速平稳、开关电源也不会尖啸。

LED 和 RGB 灯带调光最好不低于 1kHz。低频 PWM 调光在慢速移动的摄像头画面里会产生滚动暗条,频率提到 800Hz 以上基本看不出来。做“呼吸灯”效果时频率选 1kHz,占空比按正弦表缓慢变化,效果最顺滑。注意这里说的是单色 LED 调光,如果是 WS2811 这类灯珠,数据和 PWM 是两回事,51 只需要按协议发数据,不涉及可调 PWM 波形。

4.3 故障保护与死区:软件PWM绕不开的安全边界

搜索热词里“pwm 故障保护”出现频率很高,但课程设计代码里很少见,原因是它不属于“能点亮灯就行”的范畴。等你驱动电机或者加热丝时,堵转、过流、MOS 管发热烧掉,都是真实事故。低成本方案是给 MOS 管低边串一个采样电阻,电流变大时电阻压降升高,比较器 LM393 输出跳变,接到 INT0 外部中断。中断里立刻停掉 TR0、拉低 PWM 引脚、置故障标志,比 CPU 在主循环里轮询 ADC 快得多。

void Ext0_ISR(void) interrupt 0 { EX0 = 0; // 先关外部中断,防止持续触发 TR0 = 0; // 停掉 PWM 定时器 PWM_OUT = 0; // 输出拉到低电平,关闭功率管 faultFlag = 1; // 给主循环一个报警标志 } void main(void) { // 初始化定时器和外部中断后进入主循环 while (1) { if (faultFlag) { // 这里可以驱动蜂鸣器或 LED 报警 // 按键确认并清除故障后,重新打开中断重新启动 } else { ScanKeys(); DisplayProcess(currentValue); } } }

中断服务函数里不能做长时间等待,也不建议在这里清故障,否则主循环永远没有机会去处理其他任务。faultFlag 只是把问题抛给主循环,由主循环负责显示报警和等待复位。这样硬件保护速度足够,软件逻辑又清爽。

再来谈死区。51 做单路 PWM 不涉及死区,但如果你把标题扩展到双路 H 桥、驱动直流电机正反转,事情就变了。上下两个 MOS 管绝不能同时导通,否则电源到地直接短路。单片机的上下桥驱动波形在逻辑上还要加一个延迟:先让当前导通的管子关闭,等待几微秒后再打开另一个管子。51 没有硬件死区,这个延迟必须由软件强制插入,而且由于中断响应时间不确定,死区时间会有抖动。所以做 H 桥时最好把 PWM 频率控制在 5kHz 以内,给软件留足余量;想上更高频率、更可靠地做硬件死区,就得换带 CCU6 或高级定时器的单片机了,这也是为什么“CCU6 输出 3 相 6 路 PWM 波控制 BLDC”这类方案不会选择 51 的原因。

提示:如果这个项目只是一台纯信号发生器,不带功率负载,故障保护这一段可以直接省掉。但标题里如果带了“温控风扇”“电机控制”这类词,过流保护和栅极下拉电阻就是必须的,答辩时讲到这一层反而比单纯堆代码更出彩。

5. 让“可调”可验证:示波器校准、ADC无级调节和闭环温控扩展

5.1 用示波器做最终验收:三个参数一次量到位

设备调试的完成标准不是代码能编译、数码管能亮,而是示波器读数要和面板设定值一致。先设 100Hz、50%,测量高电平时间 5ms、周期 10ms;再设 1kHz、25%,高电平 250us、周期 1ms。如果数值对不上,优先检查晶振频率和定时器初值公式,而不是去怀疑按键。示波器测量占空比时,触发电平最好调到波形幅度的一半,否则高低电平判定阈值偏移,读数会虚高或者偏低。

启动一瞬间还要看一眼:很多 51 单片机上电默认 IO 为高电平,软件加载完成后 PWM 才启动,中间这几十毫秒输出可能存在一个短暂高电平。对于接 LED、驱动小电机没问题,但如果负载是高灵敏执行器,最好在 main 函数第一行就把 PWM_OUT 拉低,再初始化定时器,能减少绝大多数上电误动作。

5.2 ADC电位器实现无级调节,以及温控风扇这种闭环场景

按键按 1% 步进调节适合课程设计,但做“无级可调”时手感更好的方式是电位器。用 STC15 系列内置的 10 位 ADC,或外接常见的 PCF8591,把电位器电压映射成 0 到 99 的占空比。电位器阻值选 10kΩ 或者 50kΩ,太小了 ADC 源阻抗高,读数会抖动。

unsigned int adcValue; unsigned char dutyPercent; while (1) { adcValue = GetAdcValue(0); // 读取 ADC 通道 0 dutyPercent = (unsigned char)(adcValue * 99UL / 1023); RebuildPwmByDuty(dutyPercent); DelayMs(20); // 采样滤波,避免电位器毛刺 }

这段 ADC 到占空比的线性映射是最常见的。注意如果用在小风扇温控项目里,风速和温度的关系并不线性,合理做法是 NTC 热敏电阻分压经 ADC 读温度,查表得到目标 PWM 占空比,再叠加一点比例调节,让风扇转速随温度连续上升。这类“基于 51 单片机温控风扇设计”的方案,核心就是这一节的可调 PWM 前端加上一个温度采样表,闭环以后就不再需要手工按键调节了。

5.3 几个容易误判的排错点

现象常见原因处理方式
频率整体偏低晶振配置不是 12MHzProteus 里检查单片机属性
按下按键占空比不变主循环没调用 RebuildPwmByDuty在按键分支里加函数调用
数码管一位特别亮动态扫描顺序反了确认先关位选再送段码
示波器波形边缘振铃栅极电阻过大或飞线过长减小 RG,缩短驱动线
上电瞬间电机转一下栅极没有下拉电阻在栅源之间并联 10kΩ
频率高时波形严重畸变中断软件占用时间太长降低 PWM 频率或换硬件 PWM 芯片

可调 PWM 发生器在 51 上的调试技巧,归根结底就是拿着示波器,在“改参数”和“看波形”之间来回走。代码上的分层理清了,按键、显示、输出三个环节各管各的,剩下的问题基本都出在晶振配置和接线顺序上,而这两样在 Proteus 仿真里最容易暴露出来。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询