简介:面向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) us | H 与 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 | 默认准双向口,可换推挽模式 |
| 按键 ×4 | P2.4 ~ P2.7 | 低有效,未按下时为高电平 |
| 数码管段码 | P0 口 | P0 是开漏结构,需要外接 10k 上拉排阻 |
| 数码管位选 | P2.0 ~ P2.3 | 共阴数码管,位选高有效 |
| 故障输入 | P3.2 | INT0 外部中断,第 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 单片机的电平可以直接打开,不需要额外加栅极驱动芯片。
| 器件参数 | 推荐取值 | 作用 |
|---|---|---|
| 栅极串联电阻 RG | 10Ω ~ 100Ω | 抑制高频振铃 |
| 栅源下拉电阻 RGS | 10kΩ | 防止上电瞬间误开通 |
| 续流二极管 | 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 几个容易误判的排错点
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 频率整体偏低 | 晶振配置不是 12MHz | Proteus 里检查单片机属性 |
| 按下按键占空比不变 | 主循环没调用 RebuildPwmByDuty | 在按键分支里加函数调用 |
| 数码管一位特别亮 | 动态扫描顺序反了 | 确认先关位选再送段码 |
| 示波器波形边缘振铃 | 栅极电阻过大或飞线过长 | 减小 RG,缩短驱动线 |
| 上电瞬间电机转一下 | 栅极没有下拉电阻 | 在栅源之间并联 10kΩ |
| 频率高时波形严重畸变 | 中断软件占用时间太长 | 降低 PWM 频率或换硬件 PWM 芯片 |
可调 PWM 发生器在 51 上的调试技巧,归根结底就是拿着示波器,在“改参数”和“看波形”之间来回走。代码上的分层理清了,按键、显示、输出三个环节各管各的,剩下的问题基本都出在晶振配置和接线顺序上,而这两样在 Proteus 仿真里最容易暴露出来。
本文还有配套的精品资源,点击获取