教程 · 中级 · 阅读约 35 分钟
用 8051 定时器和软件 PWM 控制电机
8051 没有 PWM 硬件,只能用定时器 0 中断自己搭。本文讲自动重装值的计算、频率与分辨率的取舍,以及如何驱动 H 桥。
引言
Arduino 用 analogWrite(pin, 180) 就设定了电机转速。这一个调用背后,是硬件外设在持续产生波形,免费的,CPU 同时还能干别的事。
8051 没有这种外设。**芯片上没有任何东西能产生占空比可变的波形。**想让电机跑 70%,只能自己用定时器中断生成波形,并用 CPU 时间来付账。搭出它是在这颗芯片上最有启发的一件事,因为它把 analogWrite() 替你做的事原原本本地摆了出来。
这里假设你已经学过端口。如果 PWM 本身还不熟,先看 PWM 与 H 桥教程,了解为什么快速开关电机等价于改变它的电压。
你手上的定时器
经典 8051 有两个 16 位定时器/计数器,即定时器 0 和定时器 1;AT89S52 多了第三个。它们数的都是机器周期——每个十二个振荡周期——所以在 11.0592 MHz 下定时器每 1.085 µs 滴答一次。
实际上你只能白嫖其中一个。定时器 1 几乎总是被串口占着,串口波特率就从定时器 1 的溢出分频而来。于是剩下定时器 0 做通用定时器,PWM 就搭在它上面。
两个寄存器配置它们:
TMOD选模式。低四位是定时器 0,高四位是定时器 1。TCON放运行位(TR0、TR1)和溢出标志(TF0、TF1)。
定时器 0 有四种模式,但适合做周期节拍的只有一种:
| 模式 | TMOD 低四位 |
行为 |
|---|---|---|
| 0 | 0x00 |
13 位计数——兼容历史遗留 |
| 1 | 0x01 |
16 位计数,每次溢出都要手动重装 |
| 2 | 0x02 |
8 位计数,自动重装 |
| 3 | 0x03 |
把定时器 0 拆成两个 8 位定时器 |
**模式 2 就是你要的。**溢出时硬件自动把 TH0 拷回 TL0,周期永远精确。模式 1 要中断服务程序自己重装计数值,从溢出到你写入之间的每个机器周期都是漂移,会永远累积下去。
设定节拍
模式 2 下定时器从 TH0 数到 255 再溢出,所以周期就是它要走的距离:
cycles per tick = 256 − TH0
tick period = (256 − TH0) × 12 / f_osc
TH0 = 156, f_osc = 11.0592 MHz
cycles = 100
tick = 100 × 12 / 11 059 200 = 108.5 µs
使能它只要五行:
#include <reg52.h>
TMOD = (TMOD & 0xF0) | 0x02; // Timer 0 -> mode 2, leave Timer 1 alone
TH0 = 156; // auto-reload value: 100 machine cycles
TL0 = 156; // first count starts from the same place
ET0 = 1; // enable the Timer 0 interrupt
EA = 1; // global interrupt enable
TR0 = 1; // start counting
TMOD & 0xF0 很关键。TMOD 不能位寻址,直接写 TMOD = 0x02 会连定时器 1 的模式位一起抹掉——如果串口正在跑,你刚配好电机,串口输出就坏了。
用计数器做 PWM
有了周期中断,PWM 就是记账。数节拍数到周期长度 N,计数值小于占空比时输出高:
#define PWM_LEVELS 20 // ticks per PWM period
sbit ENA = P2^0; // H-bridge enable — the PWM'd pin
volatile unsigned char tick = 0;
volatile unsigned char duty_a = 0; // 0 .. PWM_LEVELS
void timer0_isr(void) interrupt 1 {
if (++tick >= PWM_LEVELS) tick = 0;
ENA = (tick < duty_a); // high for the first duty_a ticks of each period
}
interrupt 1 是定时器 0 的向量(地址 0x000B)。进入中断时硬件自动清 TF0 溢出标志,所以不像手动轮询的标志那样需要复位。
设定转速现在就是写个变量:
duty_a = 14; // 14/20 = 70% duty
逃不掉的权衡
PWM 频率、占空比分辨率和 CPU 负载,是同一个关系式的三个名字:
f_pwm = f_osc / (12 × C × N)
C = machine cycles per tick (256 − TH0)
N = ticks per PWM period (also your number of duty steps)
把 C 定在 100,只变 N,分辨率是用频率换来的:
N |
占空比分辨率 | PWM 频率 | 每秒中断数 |
|---|---|---|---|
| 10 | 10% | 921.6 Hz | 9 216 |
| 20 | 5% | 460.8 Hz | 9 216 |
| 50 | 2% | 184.3 Hz | 9 216 |
| 100 | 1% | 92.2 Hz | 9 216 |
中断频率永远不变,因为它只取决于 C。变的是走多少个节拍才重复一次——而低于约 100 Hz 时,小直流电机不再平均这些脉冲,会可闻地一步一步抖。
于是很自然地想到下一步:取 N = 100、C = 20,460.8 Hz 和 1% 分辨率全都要。算术上没错——芯片会死。C = 20 时中断每 20 个机器周期来一次,而上面那个 ISR 光保存寄存器、比较、返回就要约 25 个周期。它永远在下一次中断到来前跑不完,别的东西永远没机会执行。
**这才是真正的天花板。**ISR 花约 25 个机器周期时,C = 100 把四分之一 CPU 花在 PWM 上,C = 50 花掉一半。Arduino 的硬件 PWM 花零 CPU,频率分辨率随便挑。这一个对比胜过任何功能表。
有两个廉价办法买回余量:在 ISR 里切换到寄存器组 1(using 1),让编译器改两位而不是压八个寄存器;ISR 里只做计数和写引脚。凡是要算的东西都放 main 里。
驱动电机
H 桥每路电机要两个方向输入和一个使能。方向引脚接普通端口引脚,使能接你的 PWM 引脚:
sbit IN1 = P2^1;
sbit IN2 = P2^2;
sbit ENA = P2^0; // PWM'd by the ISR
void motor_forward(unsigned char duty) {
IN1 = 1; IN2 = 0;
duty_a = duty;
}
void motor_reverse(unsigned char duty) {
IN1 = 0; IN2 = 1;
duty_a = duty;
}
void motor_stop(void) {
duty_a = 0;
IN1 = 0; IN2 = 0;
}
第二路电机就是同一个 ISR 里多一个占空比变量加一个输出引脚——节拍计数器共用,两路自然同步:
void timer0_isr(void) interrupt 1 using 1 {
if (++tick >= PWM_LEVELS) tick = 0;
ENA = (tick < duty_a);
ENB = (tick < duty_b);
}
一个这颗芯片特有的接线细节。如 GPIO 教程所讲,8051 引脚只能拉出几十微安。给 CMOS 输入够了,但 L298N 或 L293D 是双极型输入,逻辑高时可吃掉最高 100 µA,正好卡在引脚能力的边缘。每个驱动输入经 4.7 kΩ 上拉到 +5 V,几分钱消除歧义,能把“工作台上行、装车上不行”的板子变成直接能用的板子。
出问题时
- **电机嗡嗡响但几乎不转。**PWM 频率太低,或占空比低于电机的静摩擦阈值。把频率提到约 100 Hz 以上,并接受真电机要 25–40% 才动得起来。
- **加上定时器串口就坏了。**你整体写了
TMOD,清掉了定时器 1 的模式位。加掩码:TMOD = (TMOD & 0xF0) | 0x02;。 - **PWM 周期漂移。**你在模式 1 下手动重装。换到模式 2,让硬件来做。
- **整个程序变慢。**ISR 相对节拍频率太贵了。把
TH0往 200 方向提,加上using 1,把算术搬出中断。 - 改占空比时输出毛刺。
main写duty_a的同时 ISR 在读。单字节写在这颗 CPU 上是原子的,char安全;更宽的就要在更新时关中断。 - **一路电机完全不理你。**检查使能引脚是不是 ISR 真正在驱动的那一个,以及驱动输入的上拉接了没有。
探索关联图谱
本教程所属的搭建
包含本教程的项目和学习路线。
延伸阅读