呼吸灯在 C51 和 STM32 上的实现
前言
上学期在学习 51 单片机的时候,曾经写了一个叫做 『 LED Show 』的程序,它的效果是这样的:
很弱智哈哈哈,原理也很直接:选好音乐,给音乐中每个小节设计一个灯光效果,控制好节拍(试出最合适的延时使其与音乐节拍吻合)就行。不过我想说的是,那个时候我想过要在 51 上面实现呼吸灯的效果,但一番查阅资料,又是中断(是的当时连中断是啥都不晓得🤷♂️)又是啥 PWM ,无奈放弃了。最近翻回去查了查资料,发现实现呼吸灯的基本原理已经差不多理解了,不过,51 相关的知识已经忘得差不多了,正好借这个机会,好好复习一下。
我所使用的 51 开发板来自清翔电子,芯片型号为 STC89C52。
中断和定时计数器的复习
呼吸灯的实现原理其实就是控制 LED 灯的亮灭时间比例,正好中断的知识已经忘得差不多了,那不如用定时器中断来实现,所以首先简单复习一下 STC89C52 的中断系统。
STC89C52 提供了包括 4 个外部中断、3 个定时器中断以及 1 个串口中断在内的 8 个中断请求源。具体在程序中的的控制方法实在记不起来了,查阅了芯片手册,才勉强回忆起来,在此做一个总结:
-
CPU 总中断控制位为
EA
; -
外部中断
INT0 ~ INT3
的中断允许控制位分别是EX0 ~ EX3
,触发方式由ITx
控制,ITx = 1
代表下降沿触发而 ITx = 0 代表低电平触发。清翔的开发板给每个外部中断的引脚接到的不同的外部硬件,如按键或红外接收器,可以查阅开发板原理图找到每个外部中断对应的可用的硬件模块; -
定时器中断
Timer0 ~ Timer2
的中断允许控制位分别是ET0 ~ ET2
,其运行控制位分别为TR0 ~ TR2
,3个定时器中断中的Timer0
和Timer1
共用一个工作模式寄存器TMOD
,低四位控制Timer0
而高四位控制Timer1
。Timer2
有一个单独的控制寄存器T2CON
用来实现更丰富的功能。 -
寄存器TMOD
TMOD.7 TMOD.6 TMOD.5 TMOD.4 TMOD.3 TMOD.2 TMOD.1 TMOD.0 GATE C/T' M1 M0 GATE C/T' M1 M0 GATE
控制定时器:GATE = 0
时用TRx
来控制定时器的启动,GATE = 1
时则用外部中断来控制。
C/T'
控制定时器用作定时器还是计数器:C/T'
= 0 表示定时器模式;C/T'
= 1 表示计数器模式,定时器的外部引脚下降沿作为脉冲。
M1
和M0
控制定时器的模式:分别配置为0 0
(模式 0)为 13 位定时器,THx
仅低五位参与分频;分别配置为0 1
(模式 1)为 16 位定时器,数据的低 8 位和高 8 位分别在THx
和TLx
寄存器中;分别配置为1 0
(模式 2)为 8 位自动重装定时器,当TLx
溢出时自动将THx
数据重装入TLx
(对于模式 0 和模式 2,往往是在中断程序中手动装载计数值来实现周期定时);而当M1 M0
设置为1 1
(模式 3)时,对定时器 1 是停止计数,效果与将TR1
设置为0
相同,而对于定时器 0 而言,此时TH0
和TL0
将作为两个独立的 8 位计数器,且定时器 0 的部分控制位将被TL0
占用,因为这一方式的逻辑结构较为复杂,故不在此处阐述,读者可以查阅芯片手册的 7.1.2.4 模式 3 ( 两个 8 位计数器 ) 章节。
接下来是最重要的定时器中断的时间间隔控制方法,因为定时器在不同的模式下位数不同,因此在不同的工作模式下计算方法也略有不同。
对于模式 1(16 位定时器):
对于模式 2(8 位定时器):
对应到我所使用的开发板(晶振频率 11.0592 MHz,1 机器周期 = 12时钟周期),就是:
这样就可以写出定时器中断的代码了:
/**
* 在我的开发板上,
* 8位定时器最多实现 256 × 1.085 ≈ 277us 的中断时间间隔,
* 13位定时器最多实现 8192 × 1.085 ≈ 8888us 的中断时间间隔,
* 16位定时器最多实现 65536 × 1.085 ≈ 71ms 的中断时间间隔,
* 这里用了 200us 做演示,因此选择模式 2。
*/
void Timer0_Init(void) {
EA = 1; // 打开 CPU 中断总开关
ET0 = 1; // 打开 Timer0 的中断允许控制位
TMOD = 0x02; // 设置 Timer0 为 TR0 控制、定时器模式、8 位定时器
TR0 = 1; // 打开 Timer0
TH0 = (256 - 184);
TL0 = (256 - 184); // 时间间隔 200us
}
void timer0() interrupt 1 { // Timer0 的中断号为 1
/* code */
}
思路和实现
前面提到,控制 LED 亮灭的时间的比例变化就可以实现呼吸灯,我先用了最简单的办法实现:
用变量 flag
来划分 0
到 max
,i
属于 flag
之前时亮灯,i
属于 flag
之后时灭灯,i
走完 0
到 max
后,flag
移动一位(改变亮灭时间比例)。
#include <reg52.h>
unsigned int max = 100, flag = 0, i = 0;
char step = 1; // 不能用 unsigned
void timer0() interrupt 1 { // Timer0 的中断号为 1
if(i < flag) P1 = 0x00;
if(i >= flag && i <= max) P1 = 0xff;
if(++i == max){
i = 0;
if(flag == max) step = -1;
if(flag == 0) step = 1; // 改变flag移动方向,实现由灭到亮再到亮
flag += step;
}
}
void Timer0_Init(void) {...} // 此处代码与上文相同,故省略
void main() {
Timer0_Init();
while(1);
}
优化:代码精简
这样一来,开发板上已经可以看到不错的呼吸效果了,不过我们仔细看看代码,现在每次中断都要把 LED 的亮灭状态设置一次,而我更想让它只在变化的时候设置一次。那也不难发现,i
从 0
递增到 max
,加到 max
后回到 0
,而我们刚才说了,i
属于 flag
之前时亮灯,i
属于 flag
之后时灭灯,也就可以简化成:i == 0 || i == flag
时变换亮灭状态,这样就可以把前两句 if 变成一句了。不过实现过程中,仍然发现了需要注意的点:flag
的范围不能再是 0 ~ max
,因为当 flag
和 i
都等于 0
时,那一次 i
从 0
到 max
的循环 LED 只会变化一次,具体代码如下:
unsigned int max = 100, flag = 2, i = 0;
char step = 1;
void timer0() interrupt 1 {
if(i == 0 || i == flag) P1 = ~P1;
if(++i > max){
i = 0;
/**
* 下面这一句也可以改,原理近似,但注意点还是挺多的:
* 首先 flag 的初值不能与下面的比较值一样,避免第一次就改变 step;
* 其次 flag 的比较值不可以与 i 的初值一样,这样 i 在一次循环中才可以改变一次亮灭,P1 = ~ P1 才可以按照正常的规律进行,
* 上一句要写 ++i > max 而不是 >= 或 ==,也是这个原因。
* 这里的逻辑还是挺有意思的,读者可以自己尝试不同的初值、比较值,看看呼吸灯是怎样的效果。
*/
if(flag == max || flag == 1) step = -step;
flag += step;
}
}
一些简单的概念
上述的方法中我们通过改变数字信号的占空比,从而实现了近似于输出模拟信号的功能,这就叫做 PWM(脉冲宽度调制),在上文案例中,每 100 次进入定时器中断完成一次从暗到亮的变化,所以我们的 PWM 周期就是 100 × 200us = 2s,我们可以改变定时器周期和 max
变量来配合改变 PWM 周期和亮暗变化的细腻程度,比如将上面代码中的 max
改为 20
就可以得到近似于 Disco 灯球的动感;再比如将定时器的 TH0
TL0
都设置为 0xEC
, max
设置为 200
,就可以得到变化细腻且在低亮度下也不易观察到频闪的呼吸灯效。读者可以随意尝试看看不一样的效果,而在电机控制等应用场景中,对于 PWM 周期会有更为严格的要求(比如不合适的周期脉冲可能会让电机产生异响等),如果读者是初学者,应该对这些要有一个概念。