☰
STC15F104W无硬件PWM?软件模拟PWM完整实现指南
2026/10/6 19:27:58 网站建设 项目流程

如果你在选型阶段搜过 STC15F104W,大概率会看到这样的评价:这是一颗便宜、封装极小、外围极简的 8 脚单片机,缺点是没有硬件 PWM。前段时间我做一个小控制板,尺寸被卡在指甲盖大小,成本也抠得死,最后选了 STC15F104W,然后同样的问题就摆到了面前——要做 PWM 脉冲输出,但芯片没有硬件 PWM 模块。这篇就是把当时从查资料、写代码到上示波器验证的完整过程整理出来,主要包括软件模拟 PWM 的原理、计数器比较法和动态重载法两套思路,以及可以直接抄的 C51 工程代码,并用 LED 呼吸灯和舵机控制两个实战场景做了验证。看完之后你可以直接照搬,也能按自己的项目需求改出多路 PWM。

1. 先摸清STC15F104W这颗芯片的底细

1.1 这颗8脚芯片的硬件家底

STC15F104W 是 STC 15 系列里非常典型的低引脚型号,SOP8 封装,满打满算 8 个引脚,除掉电源和地,真正能用的 IO 只有 6 个。但是它的内在配置并不差:

  • 内核:1T 增强型 8051,指令集兼容传统 8051,执行速度比老 51 快 8~12 倍;
  • Flash:4KB,做小型逻辑控制、PWM 输出这种程序绰绰有余;
  • SRAM:256B,足够跑软件 PWM 的状态变量;
  • 定时器:3 个 16 位定时器(T0/T1/T2),这是软件 PWM 的核心资源;
  • 时钟:内置高精度 RC 振荡器,频率范围 5MHz~35MHz,下载程序时用 STC-ISP 软件设置即可,默认常用 12MHz 或 11.0592MHz;
  • 串口:1 路 UART,可以用 P3.0/P3.1 下载程序;
  • 外设短板:没有 ADC,没有 PCA/CCP,也就是没有任何硬件 PWM 模块。

注意最后一条,这直接决定了你在这颗芯片上要得到 PWM 信号,只能靠软件模拟。

1.2 没有硬件PWM,为什么还能输出PWM脉冲

硬件 PWM 的原理是定时器计数器溢出或比较匹配时,由内部逻辑电路自动翻转引脚电平,整个过程不需要 CPU 干预。软件 PWM 的思路更直白:定时器产生中断,在中断服务程序里手动翻转 IO 引脚。

打个比方,硬件 PWM 像自动挡汽车,挂上 D 挡踩油门就行;软件 PWM 像手动挡,每个换挡动作都得你亲手操作。手动挡操作得好,也能开出自动挡的效果,代价是司机(CPU)得一直盯着路况(中断)。

所以这里的关键结论是:STC15F104W 能不能输出 PWM,取决于你的中断服务程序写得快不快、中断频率选得合不合理。跟硬件 PWM 相比,软件 PWM 的 CPU 占用率必然更高,输出频率也有限制,但胜在引脚随意、分辨率可变、程序可改,对大量低频控制场景完全够用。

2. 软件PWM的两条路线,对应完全不同的应用

网上搜"软件 PWM",方案五花八门,但真正实际项目中常用的只有两种:计数器比较法和动态重载法。选错方案,轻则 CPU 忙死,重则 PWM 频率根本达不到。

2.1 计数器比较法:多路输出和可变分辨率首选

计数器比较法的思路是让定时器以一个固定周期持续中断,每进一次中断,软件计数器加 1,然后拿这个计数器和设定的占空比阈值比较,决定 IO 输出高还是低。

公式非常简洁:

PWM 频率 = 中断频率 / 分辨率

比如说,我想要 2kHz 的 PWM,希望占空比调节精度到 1%,也就是分辨率 100 级,那么:

中断频率 = 2kHz × 100 = 200kHz

每次中断间隔 = 1 / 200kHz = 5us

在 12MHz 的 1T 模式下,5us 对应 60 个时钟周期,定时器初值就是 65536 - 60 = 65476。

这个方法的优点很突出:

  • 多路 PWM 可以共享同一个定时器中断,每个通道只多一次比较赋值,代码量增加极少;
  • 分辨率随时可以改,只要改 PWM_PERIOD 宏;
  • 占空比的调节粒度均匀,一个周期内高电平和低电平分布稳定。

缺点就是中断频率偏高,像上面 2kHz/100 级直接干到 200kHz,对 CPU 压力不小。所以计数器比较法更适合中断频率控制在 100kHz 以内的场景,比如 LED 调光、小电机调速、加热器功率控制这些不需要超高 PWM 频率的场合。

2.2 动态重载法:大周期低频PWM的省心方案

动态重载法的思路完全不同。它不搞固定周期中断,而是让定时器在高电平时定时高电平时长,到点后翻转为低电平,再重新装载低电平时长的初值,到点后再翻转为高电平,周而复始。一个周期内中断只进两次:一次是高点转低,一次是低点转高。

这种方法下,每次中断只需翻转一次 IO 并重新装载初值,中断频率极低。比如 50Hz 的舵机控制信号,周期 20ms,一周期只进 2 次中断,中断频率才 100Hz,CPU 占用几乎可以忽略。

但动态重载法有个致命细节很容易忽略:16 位定时器单次最长定时时间有限。

在 12MHz 系统时钟、1T 模式下,定时器每个时钟周期计数一次,计满 65536 需要 65536 / 12MHz ≈ 5.46ms。也就是说,如果舵机周期 20ms、低电平 18.5ms,单靠一次定时器溢出根本定不了这么长时间。解决办法有两个:把定时器切成 12T 模式,计数频率变成 1MHz,这样 20ms 只需要 20000 个计数,轻松装进 16 位定时器;或者用软件计数器配合定时器溢出做多级分频,但代码复杂度明显上升,没必要。

实际项目里舵机控制我强烈建议动态重载法,因为 12T 模式下初值计算非常舒服:12MHz 晶振 12 分频后正好是 1MHz,1us 计 1 个数,1ms 初值就是 65536 - 1000 = 64536,直接心算都能算出来。

2.3 两种方法怎么选

需求场景推荐方法原因
LED 调光/呼吸灯,PWM 频率 500Hz~2kHz计数器比较法分辨率可控,多路扩展方便
直流电机调速,PWM 频率 100Hz~1kHz计数器比较法占空比调节均匀,电机噪声可控
舵机控制,50Hz 周期 20ms动态重载法周期太大,定时器初值放不下,中断频率低
项目对 CPU 占用要求苛刻动态重载法一周期只进 2 次中断
需要多路 PWM 同步输出计数器比较法所有通道共享一个计数器相位一致

记住一点:中断频率超过 100kHz 之后,8051 内核的 ISR 执行压力会明显增大,能避则避。

3. 计数器比较法完整工程:寄存器、初值、代码一次性给全

3.1 先搭最小系统,少走弯路

STC15F104W 之所以受欢迎,很大程度因为最小系统实在太简单:

  • VCC 接 5V(2.4V~5.5V 宽压都能跑);
  • VCC 与 GND 之间并联一个 0.1uF 陶瓷电容,放在电源引脚旁边;
  • 不需要外部晶振;
  • 不需要外部复位电路。

有一个引脚要特别处理:P3.0。这颗芯片的 P3.0 默认是复位脚 RST,高电平复位。如果不做任何设置直接拿来当普通 IO,你会碰到各种奇怪问题,比如程序偶尔重启。常规稳定做法是给 P3.0 外接一个 10K 下拉电阻到 GND,让它在默认状态下保持低电平。

如果你确实需要把 P3.0 当 IO 用,那么在 STC-ISP 下载软件的烧录选项里勾选"复位脚用作 IO 口(P3.0)",然后重新下载程序。勾选之后 P3.0 就不再具备复位功能,可以作为普通 IO 使用。不过下载程序仍然通过 P3.0/P3.1 串口进行,冷启动下载不受影响。

3.2 IO口模式:PWM输出必须配推挽

STC15F104W 的 IO 默认是准双向口,输出高电平能力弱,直接驱动 LED 或者后级 MOS 管时可能压降明显。软件 PWM 输出引脚建议配置为推挽输出。

IO 模式由 P3M1 和 P3M0 两个寄存器控制,对应关系如下:

P3M1 位P3M0 位IO 模式
00准双向口(默认)
01推挽输出
10高阻输入
11开漏输出

我习惯用 P3.3 作为 PWM 输出,配置代码就是:

P3M0 |= 0x08; // P3.3 = 1,推挽 P3M1 &= ~0x08; // P3.3 = 0

3.3 定时器初值计算的完整推导

STM32 或者 Arduino 用户习惯了 HAL 库直接写频率,回到 51 系必须自己算初值。这里把计算过程完整写一遍,方便你举一反三。

目标参数:

  • PWM 频率 500Hz;
  • 分辨率 100 级,也就是占空比最小步进 1%;
  • 系统时钟 12MHz,定时器 T0 用 1T 模式。

第一步,算中断频率:

中断频率 = 500Hz × 100 = 50kHz

第二步,算中断周期:

中断周期 = 1 / 50kHz = 20us

第三步,算一个周期有多少个时钟脉冲:

20us × 12MHz = 240 个时钟周期

第四步,算定时器初值:

初值 = 65536 - 240 = 65296 = 0xFF10

所以:

TH0 = 0xFF; TL0 = 0x10;

这里有个寄存器模式的坑必须说清楚。STC15 系列和传统 8051 的定时器模式定义不完全一样,传统 8051 的方式 0 是 13 位定时器,STC15 的 T0 方式 0 是"16 位自动重载",也就是溢出后硬件自动重新装载初值,中断里完全不用手动再赋值。如果沿用老 51 的习惯写 TMOD = 0x01,那对应的是"16 位不自动重载",每次中断都得手动把 TH0/TL0 再写一遍,多几条指令不说,忘记写就会产生频率漂移。所以这里用方式 0:

TMOD &= 0xF0; // 清掉 T0 相关位 TMOD |= 0x00; // T0 方式 0:16 位自动重载

3.4 完整C51工程代码

下面代码基于 Keil C51,头文件用 STC 官方提供的 STC15F104W.H,引脚定义兼容。

#include "STC15F104W.H" sbit PWM_PIN = P3^3; // PWM 输出引脚 #define PWM_PERIOD 100 // 分辨率:100 级 #define PWM_FREQ_HZ 500 // PWM 频率:500Hz #define SYS_CLK_MHZ 12 // 系统时钟:12MHz // 中断频率 = 500Hz * 100 = 50kHz,中断周期 = 20us // 20us 在 12MHz 1T 下 = 240 个时钟周期 // 定时器初值 = 65536 - 240 = 65296 = 0xFF10 #define TH0_INIT 0xFF #define TL0_INIT 0x10 unsigned char pwm_cnt = 0; // 计数器 unsigned char pwm_high = 30; // 占空比阈值,30/100 = 30% void Timer0_Init(void) { AUXR |= 0x80; // T0x12 = 1,T0 工作在 1T 模式 TMOD &= 0xF0; // 清 T0 模式位 TMOD |= 0x00; // T0 方式 0:16 位自动重载 TH0 = TH0_INIT; TL0 = TL0_INIT; ET0 = 1; // 使能 T0 中断 TR0 = 1; // 启动 T0 } void main(void) { P3M0 |= 0x08; // P3.3 推挽输出 P3M1 &= ~0x08; Timer0_Init(); EA = 1; // 开总中断 while (1) { // 主循环可以处理按键、通信等任务 } } void Timer0_ISR(void) interrupt 1 { if (++pwm_cnt >= PWM_PERIOD) pwm_cnt = 0; PWM_PIN = (pwm_cnt < pwm_high) ? 1 : 0; }

这个工程跑起来,P3.3 引脚输出频率 500Hz、占空比 30% 的 PWM 波形,实际用示波器测量误差很小,主要来自内部 RC 振荡器的精度,通常 ±1% 左右,对大多数应用完全够用。

3.5 换频率、换分辨率、换引脚要改哪里

这是我最常被问的问题,直接列个对应关系:

  • 换 PWM 频率:重新算中断频率,改 TH0_INIT 和 TL0_INIT;
  • 换分辨率:改 PWM_PERIOD,同时重新算中断频率和初值;
  • 换引脚:把 sbit 定义换掉,同时把 IO 推挽配置的位运算改成对应引脚;
  • 占空比调节:运行时改 pwm_high 变量,范围 0~PWM_PERIOD。

比如要把频率改成 1kHz,分辨率还是 100 级:

中断频率 = 100kHz,中断周期 = 10us,时钟周期数 = 10 × 12 = 120

初值 = 65536 - 120 = 65416 = 0xFF88

改两行:

#define TH0_INIT 0xFF #define TL0_INIT 0x88

就这么简单。核心永远是那三步:频率乘分辨率得到中断频率,中断频率倒数得到中断周期,中断周期乘系统时钟得到计数个数。

4. 实战验证:LED呼吸灯和舵机控制

4.1 呼吸灯:验证占空比连续变化

第一个实战我建议从呼吸灯开始,因为它不需要任何外部硬件,一个 LED 串个 220Ω 电阻接在 P3.3 和 GND 之间就能看到效果,还能直观验证占空比变化时亮度是否线性。

基于前面 3.4 节的工程,呼吸灯效果需要在主循环里周期性调整 pwm_high 的值。我采用的是 10ms 步进,从 0 增到 100 再减回 0,一个完整呼吸周期 2 秒:

void delay_10ms(void) { unsigned char i, j; i = 18; j = 235; do { while (--j); } while (--i); } void main(void) { unsigned char dir = 1; P3M0 |= 0x08; P3M1 &= ~0x08; Timer0_Init(); EA = 1; while (1) { if (dir) { if (++pwm_high >= PWM_PERIOD) { pwm_high = PWM_PERIOD - 1; dir = 0; } } else { if (--pwm_high == 0) { dir = 1; } } delay_10ms(); } }

我实测下来,500Hz 的 PWM 频率下 LED 没有任何肉眼可见的闪烁,亮度从暗到亮再到暗的过程非常平滑。如果你对电流声音敏感,把 PWM 频率提到 1kHz 以上也不会增加多少 CPU 负担,公式照搬上节就行。

4.2 舵机控制:动态重载法完整实现

呼吸灯验证完计数器比较法,舵机控制正好拿来验证动态重载法。常见舵机(SG90、MG996R 等)的控制信号是 50Hz,周期 20ms,高电平时间 0.5ms~2.5ms,其中 1ms 对应 0 度,1.5ms 对应 90 度,2ms 对应 180 度。

如果还用计数器比较法,要做到 1us 级的高电平精度,分辨率需要 20000 级,中断频率 = 50Hz × 20000 = 1MHz,这在 51 上根本跑不动。所以舵机控制是动态重载法的主场。

前面提过,在 12MHz 系统时钟下,1T 模式 16 位定时器最多只能定时 5.46ms,覆盖不了 20ms 周期。这里把 T0 切换到 12T 模式:计数频率 = 12MHz / 12 = 1MHz,1 个计数正好 1us,20ms 只需要 20000 个计数,完全放得下。代码:

#include "STC15F104W.H" sbit SG_PIN = P3^2; // 舵机信号线接到 P3.2 unsigned int high_us = 1500; // 高电平时间,单位 us,1ms~2ms 对应 0~180 度 void load_timer(unsigned int us) { unsigned int val = 65536 - us; // 12T 模式,12MHz 下 1us 计 1 个数 TL0 = val & 0xFF; TH0 = val >> 8; } void Timer0_Init(void) { AUXR &= ~0x80; // T0x12 = 0,T0 工作在 12T 模式 TMOD |= 0x01; // T0 方式 1:16 位不自动重载,每次中断都要重装初值 load_timer(high_us); IP |= 0x02; // 提高 T0 中断优先级,防止被其他中断干扰 ET0 = 1; TR0 = 1; } void Timer0_ISR(void) interrupt 1 { if (SG_PIN) { SG_PIN = 0; // 高电平结束,输出低电平 load_timer(20000 - high_us); // 低电平时间 = 周期 - 高电平时间 } else { SG_PIN = 1; // 低电平结束,输出高电平 load_timer(high_us); } } void main(void) { P3M0 |= 0x04; // P3.2 推挽输出 P3M1 &= ~0x04; Timer0_Init(); EA = 1; while (1) { // 这里可以定时修改 high_us,控制舵机角度 // 比如 high_us = 1000; // 0 度 // high_us = 1500; // 90 度 // high_us = 2000; // 180 度 } }

这套代码跑起来后,实测一个完整周期 20ms,高电平时间可以通过改 high_us 变量精确控制,舵机角度稳定,没有抖动。动态重载法的中断频率极低,CPU 占用几乎可以忽略,哪怕主循环里做复杂的传感器轮询也不影响舵机输出。

有个细节要注意:更新 high_us 的时机。如果程序在主循环里直接修改 high_us,恰好遇上中断正在执行,可能出现一个周期高电平用新值、低电平用旧值的错乱情况,表现为舵机偶尔卡一下。稳妥做法是在低电平阶段更新,也就是等到 SG_PIN == 0 时再改 high_us,或者简单粗暴地关中断更新然后立即打开。舵机本身有惯性,对这种偶发抖动不敏感,但对精度要求高的场景还是建议处理一下。

4.3 示波器验证波形,别只靠感觉

写完代码别急着接负载,先上示波器看一眼波形再决定能不能用。

  • 用 CH1 探头夹在 PWM 输出脚,接地夹子接 GND;
  • 示波器时间轴先放到 1ms/div,看周期是不是符合预期;
  • 再切换到 20us/div,看上升沿和下降沿是否陡峭,有没有明显振铃;
  • 重点观察一个完整周期的占空比稳定性,软件 PWM 如果出现抖动,说明中断里可能有其他更高优先级的中断在捣乱。

我第一版呼吸灯程序就是在没接示波器的情况下调试,结果亮度变化明显不均匀,后来才发现是中断里多了几条乘除运算导致 ISR 执行时间超过中断周期,PWM 周期被拉长了。规范化代码后,用 500Hz/100 级参数测量,周期误差小于 1%,完全满足需求。

5. 实际调PWM踩过的坑和优化建议

5.1 P3.0默认是复位脚,不是普通IO

这个坑第一次用 STC15F104W 的人基本都会踩。P3.0 上电默认作为 RST 使用,高电平复位。如果你不管它,直接把它接到按键或者 LED 上,大概率会出现以下现象:程序上电后跑一会儿突然重启,或者按下某个按键后整个系统复位。

解决方式前面提过两种:外接 10K 下拉电阻,或者到 STC-ISP 里勾选"复位脚用作 IO 口"重新烧录。

但要注意,勾选复位脚作为 IO 后,芯片就没有外部复位引脚了,程序跑飞的恢复只能靠内部看门狗。STC15F104W 内置看门狗,建议在初始化时打开看门狗,设置合适的溢出时间,程序正常运行期间定期喂狗,一旦跑飞自动复位。做产品时别省这个功能。

5.2 中断频率并不是越高越好

很多人刚接触软件 PWM 时容易犯一个错:总想追求更高的 PWM 频率和更高的分辨率,结果中断频率飙到几百 kHz,ISR 没跑完下一次中断就到了,PWM 波形反而乱掉。

STC15F104W 虽然是 1T 内核,12MHz 下执行一条指令通常只要 1~4 个时钟周期,但中断现场保存、恢复、跳转这些开销省不掉。一个最简单的 ISR,从我 3.4 节的代码看,大概 20~40 条指令,按平均 2 个时钟周期算,需要 40~80 个时钟周期,也就是 3.3us~6.7us。如果中断周期比这个时间短,CPU 就会一直陷在中断处理里,主循环完全卡死。

根据我的经验,软件 PWM 中断频率控制在 100kHz 以内比较稳妥,50kHz 最舒服。对 8051 内核来说,在中断频率和业务处理之间找到平衡点,比一味追求参数更重要。这也是我在呼吸灯例子里用 500Hz/100 级而不是 2kHz/100 级的原因——50kHz 的中断频率让 CPU 有充足的余量处理主循环逻辑。

5.3 占空比更新时的毛刺和抖动

计数器比较法里,ISR 每次中断都读取 pwm_high 变量。如果主循环在某个时刻修改了 pwm_high,而 ISR 中正在执行一半的判断,可能出现本周期占空比异常,产生一个毛刺。

对 LED 呼吸灯来说影响微乎其微,眼睛根本看不出来。但如果你用 PWM 控制步进电机或者伺服系统,这类毛刺会让执行器突然跳动,就有问题了。最直接的解决办法是更新时关中断:

EA = 0; pwm_high = new_value; EA = 1;

还有一种更优雅的双缓冲方式:主循环里先写 shadow 变量,ISR 在 PWM 周期边界处把 shadow 同步给实际使用的变量,保证一个周期内的 PWM 完全一致。代码量也不大,适合要求严格的场景。

5.4 多路PWM扩展和H桥死区提醒

计数器比较法扩展多路 PWM 非常自然。因为所有通道共享同一个 pwm_cnt 计数器,只要每路设定自己的阈值即可,而且相位天然同步:

sbit PWM_CH0 = P3^2; sbit PWM_CH1 = P3^3; unsigned char pwm_high0 = 20; unsigned char pwm_high1 = 70; void Timer0_ISR(void) interrupt 1 { if (++pwm_cnt >= PWM_PERIOD) pwm_cnt = 0; PWM_CH0 = (pwm_cnt < pwm_high0) ? 1 : 0; PWM_CH1 = (pwm_cnt < pwm_high1) ? 1 : 0; }

三路、四路也同理,唯一代价是中断里多了几次比较赋值,开销可以接受。

最后啰嗦一个安全性问题:如果你把 STC15F104W 的软件 PWM 用于 H 桥驱动直流电机,必须考虑死区时间。H 桥同一桥臂上下两个 MOS 管绝对不能同时导通,否则电源直接短路,轻则发烫,重则炸管。硬件 PWM 通常自带死区发生器,软件 PWM 没有。两个思路可以解决:一是两路 PWM 的占空比之和限制在 95% 以内,人为留出死区;二是在程序里做状态机,保证切换上下管时先关闭另一个管子并延时几个微秒。用软件做死区控制逻辑不复杂,但每一条时序都要经过逻辑分析仪或者示波器验证,别拿自己的板子开玩笑。

写到这里,关于 STC15F104W 的软件 PWM 实践基本覆盖了我踩过的所有关键点。这颗芯片真正用顺手之后,你会觉得没有硬件 PWM 并不是什么遗憾,反而让你对定时器、中断和时序的理解深了一层。最后再分享一个实际项目里的小技巧:如果只想要固定频率方波,不需要调占空比,可以把定时器溢出信号直接映射到引脚输出(P3.4/T0 引脚的 CLKOUT 功能),连中断都不用开,CPU 零负担拿到一路方波。这才是把 STC15F104W 的资源彻底榨干的做法。

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

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

立即咨询