教程 · 中级 · 阅读约 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
四毫秒内的时序图,展示用定时器 0 搭出的软件 PWM。底部均匀的刻度标记 TH0 取 156 时每 108.5 微秒一次的定时器 0 中断。上方方波在 0 V 与 5 V 之间切换,在每 20 个节拍的周期里前 7 个节拍为高、后 13 个节拍为低。双向箭头标出完整周期:20 个节拍等于 2.17 毫秒等于 460.8 赫兹,较短的箭头标出高电平部分:20 分之 7 等于 35%。
20 个节拍组成一个周期,占空比值决定其中几个为高。中断频率固定,变的只是比例。 下载 SVG

逃不掉的权衡

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 真正在驱动的那一个,以及驱动输入的上拉接了没有。

探索关联图谱

本教程所属的搭建

包含本教程的项目和学习路线。

延伸阅读

参考资料