宏晶 STC89C52RC 微控制器实践小书

伴随 NB-IOT、LoRa、5G 等无线物联网通信技术的快速成熟,已经诞生近四十余年的 8051 系列微处理,在功耗、性能、开发难易程度方面,已然全面落后于 ARM Cortex-M3 等主流嵌入式微控制器方案。但是由于其技术架构较为经典,寄存器配置相对简洁,在一些低成本场景中依然有所沿用。笔者当前使用的开发板基于宏晶STC89C52RC嵌入式微控制器方案,虽然购置于六年以前,但是依然集成有各类常用的 UART、I²C、SPI 总线模块。

笔者日常开发工作当中,经常需要使用到此类嵌入式总线通信协议,因此参考了官方文档以及相关技术资料,逐步将本文涉及的各类模块驱动移植至当前开发板,便于用作与其它嵌入式设备联调测试之用。近几年,意法半导体的STM32F103C8T6量产价格不断下探,已经逐步接近宏晶的STC8051系列产品,可以预见后者将会逐渐面临市场淘汰,作为一款极为经典的 8 位微控制器,用作测试和实验目的依然是不错的选择。

STC89C52RC 简介

STC89C52RC工作电压在5.5V ~ 3.4V之间,属于5V单片机(工作电压范围在2.7V ~ 3.6V的称为3.3V单片机)。STC89C52RC提供给开发者使用的片上存储空间主要划分为以下三类:

SFR特殊功能寄存器,Special Function Register):特殊功能寄存器,用于配置单片机内部的各种功能。 RAM随机存取存储器,Random Access Memory):数据存储空间,用于存储程序运行过程中产生和需要的数据,读写速度较快断电后丢失。 Flash闪存,Flash Memory):程序存储空间,用于存储单片机需要运行的程序,可重复擦写且断电后不丢失。

STC89C52RC单片机最小系统由电源晶振复位电路三个要素构成,参考如下电路图所示:

电路图当中,连线上放置的字符称为网络标号,相同名称的网络标号表示此处相互连接。

电源

目前主流单片机的电源主要分为5V3.3V两个标准,STC89C52RC属于5V单片机,当前电路使用计算机 USB 接口输出的5V直流进行供电,供电电路分别连接至单片机的40脚VCC,接+5V代表电源正极)与20脚GND,接地代表电源负极)。

晶振

晶振的全称叫做晶体振荡器,作用是为单片机系统提供基准时钟信号,STC89C52RC内部所有工作都以该信号作为基准步调。STC89C52RC18脚19脚是外接晶振引脚,这里使用了频率为11.0592MHz的晶振(每秒振荡11059200次),并且外加两个20pF电容协助晶振起振以及维持振荡信号稳定。晶振通常可分为无源晶振和有源晶振两种类型,有源晶振是一套利用石英晶体压电效应起振的完整谐振振荡器,供电后即可产生高精度的振荡频率。无源晶振需要芯片内置的振荡电路协同工作才能产生振荡信号,两侧通常还需放置两枚10pF~40pF电容(通常选取典型值20pF)。

上图是深圳扬兴科技生产的YXC品牌晶振,最左侧的是一个无源晶振,拥有 VCCGND信号输出悬空/使能 4 个引脚,使用时只需要将信号输出引脚连接到单片机的晶振信号输入引脚XTAL1即可。右侧的三个都是无源晶振,通常拥有 2 个不区分正负极性的引脚,使用时分别连接到单片机的XTAL1XTAL2两个晶振信号引脚上面。

注意:无源晶振有时也存在 3 个引脚的情况,其中间引脚连接晶振外壳后接入GND

复位电路

左侧复位电路连接至单片机的9脚,即RST(Reset)复位引脚。单片机复位通常可分为上电复位(每次上电都从一固定的相同的状态开始工作)、手动复位(通过复位按键让程序重新初始化运行)、程序自动复位(程序失去响应时看门狗自动重启并复位单片机)三种情况。

当上面这个复位电路处于稳定工作状态时,电容C11起到了隔离5V直流的作用,由于左侧复位按键处于弹起状态,下半部分电路接地后电压为0VSTC89C52RC单片机属于高电平复位,低电平正常工作,因而此时单片机就处于正常工作状态。接下来的内容,重点讨论一下上电复位手动复位

上电复位发生在上电一瞬间:电容C11上方电路电压为5V下方电路电压为0V,伴随电容逐步开始充电,所有电压都加在电阻R31上面,此时RST端口位置的电压为5V。伴随电容充电量逐步增多,电流将会越来越小,此时由于RST端口上的电压等于电流乘以R31的阻值,所以电压将会越来越小,直至电容完全充满之后,RST端口与GND电位相同,两端电压差为0V所以不再产生电流。换而言之,单片机上电之后,RST引脚会先保持一小段时间(不少于2个机器周期时间)高电平然后变为低电平,也就是经历了一个完整的上电复位过程。

每种单片机的复位电压各不相同,STC89C52RC通常按照0.7 × VCC 作为复位电压值,而复位时间的计算过程较为复杂,这里只需要记住一个结论:t = 1.2 × R × C,其中R4700欧,C0.0000001法,那么t的值是为0.000564秒,即564us左右,远大于 2 个机器周期约2us的时间。

按键手动复位需要经历 2 个过程:微动开关按下之前,RST电压为0V,开关按下之后电路导通,电容会在瞬间放电,RST电压值变化为4700 × VCC/(4700 + 18),此时处于高电平复位状态。开关松开之后经历的过程与上电复位类似,即首先电容充电,然后电流逐渐减小直至RST端电压变为0V。按下微动开关的时间通常都会维持几百毫秒,完全满足复位的时间要求。

按下微动开关的一瞬间,电容C11两端的5V电压会瞬间遭受较大电流的冲击,并引起局部范围内的电磁干扰。因此,为了抑制大电流引发的干扰,电路图当中串联了一个18Ω欧电阻R60进行限流。

配置 Keil uVision 5

在进行接下来的工作之前,需要对Keil uVision进行一些初始化配置。首先,右键选中【Options for Target】打开目标设置,然后打开【Target】选项卡设置当前使用的Xtal晶振频率为板载的11.0592MHz

然后,勾选【Output】选项卡当中的Create HEX File生成可供 ISP 工具烧写的十六进制文件。

最后,确保【debug】选项卡下的Use Simulator处于默认的选中状态。

发光二极管 LED

电源/开关指示 LED

开发板使用的是是普通贴片发光二极管,正向导通电压在1.8V ~ 2.2V之间,工作电流在1mA ~ 20mA 之间,导通电流越大 LED 亮度越高,如果超过限定电流则有可能烧坏元件。

开发板上的USB 接口电路可同时用于供电、程序下载、串口通信,USB 插座USB-B一共拥 6 个引脚,其中2脚3脚是数据通信引脚,1脚4脚是电源引脚,5脚6脚通过 USB 外壳连接到GND上。注意1脚VCC4脚GND,其中1脚通过F1 自恢复保险丝连接至右侧电路,正常工作时保险丝可以视为导线,但当后级电路发生短路故障时,保险丝会自动切断电路,故障恢复以后再重新恢复导通。

电路图右侧的两条支路,第一条在VCCGND之间连接了一个470uFC16电容,由于电容是隔离直流的,所以这条支路上没有电流通过,此处电容起到的仅是缓冲电源电流避免上电瞬间电流过大的作用。第二条支路串联了一颗用作电源指示灯的发光二极管LED1以及一枚电阻R34,注意发光二极管与普通二极管一样使用时需要区分正负极。由于VCC电压是5V,发光二极管自身压降约2V,那么R34电阻承受的电压为5V - 2V = 3V;现在已知 LED 正常点亮的电流范围是1~20mA,那根据欧姆定律电阻R = 电压U / 电流I,电阻R34取值的下限为3V / 0.02A = 150Ω,上限在3V / 0.001A = 3000Ω;由于这枚电阻能够限制整条通路上的电流大小,因此通常被称作限流电阻

同样的原理,在上面电路后级的电源开关电路当中,还有一颗标号为LED10的发光二极管用作开关指示灯。注意上面电路图中的开关是两路的,并联的两路开关可以有效确保后级电路供电的稳定性。开关Power后级还并接了一个100uFC19电容以及一个0.1uFC10电容,电容C19主要用于稳定后级电路电流电压,避免某个元件突然工作时造成的瞬时电流电压的下降;而容值较小的0.1uF电容C10,主要用于滤除高频信号干扰,该电容的取值是结合干扰频率、电容参数得到的一个经验值,数字电路设计时,电源位置的高频去耦电容可以直接选取0.1uF容值。

注意:电路中大功率元件附近都可以放置一个较大容值的电容,从而起到稳定电流电压的作用。此外,所有IC元件的VCCGND之间,都会放置一个0.1uF的高频去耦电容,特别是在 PCB Layout 的时候,该电容在位置上要尽可能靠近IC元件。

数字电路中三极管的应用

三极管拥有饱和截止放大 3 种工作状态,其中放大状态主要用于模拟电路,用法较为复杂。数字电路主要使用到三极管的开关特性,即饱和状态截止状态。电路图中箭头朝内的是PNP三极管,箭头朝外的是NPN三极管。三极管拥有基极(Base)、发射极Emitter)、集电极(Collector)三个引脚,下图横向左侧的是基极,元件图中间的箭头一头连接基极另外一头连接的是发射极,最顶部的那个引脚是集电极

三极管使用的关键在于基极B发射极E之间的电压情况,对于 PNP 型三极管,发射极E端电压高于基极B端电压0.7V以上,发射极E集电极C之间就可以导通,即控制端在基极B发射极E之间,被控制端在发射极E集电极C之间。同样的道理,对于 NPN 型三极管,基极B端比发射极E端高0.7V以上,就可以导通发射极E集电极C

首先来介绍一下三极管的电压导通用法:上面的样例电路图当中,通过单片机引脚与三极管的配合来控制一个 LED 亮灭。三极管Q16的基极通过10KΩ电阻R47连接至单片机 IO 引脚,发射极E连接至5V电源,集电极C串接了一枚发光二极管LED2以及一颗1KΩ的限流电阻R41,并最终连接到电源负极GND。如果单片机 IO 接口输出高电平1,三极管Q16基极B发射极E都是5V,此时不会产生任何压降,发射极E集电极C之间不会导通,发光二极管LED2也就无法点亮。当单片机 IO 接口输出低电平0,由于此时发射极E依然是5V集电极B发射极E之间产生压差,发射极E集电极C被导通。

三极管集电极B发射极E之间,其自身会产生0.7V左右压降,此时电阻R47上承受的电压为5V - 0.7V = 4.3V。三极管发射极E集电极C之间,其自带的0.2V压降可以忽略不计,而后面的发光二极管LED2自身带有2V压降,此时限流电阻R41上的压降应为5V - 2V = 3V,根据欧姆定理可以推算出该条支路的电流约为3V/1000Ω = 0.003A = 3mA,可以满足LED2的工作电流并且正常点亮。

然后再介绍一下三极管的电流控制用法:三极管有截止放大饱和三种状态,其中截止是指集电极B发射极E之间不导通,这里暂不作讨论;而要让三极管处于饱和状态必须要满足一个条件:集电极B的电流必须大于发射极E集电极C之间的电流值除以三极管放大倍数β,常用三极管放大倍数约为100,接下来计算一下R47的阻值。

发射极E集电极C之间的电流为3mA,那么基极B的电流最小就是3mA / 100 = 30uA。由于基极电阻承受的电压为4.3V,那么基极电阻最大取值应为4.3V / 30uA ≈ 143kΩ,电阻取值只需要比这个值更小即可,但是也不能太小,否则电流通过电流过大会烧坏单片机或三极管。STC89C52RC的 IO 引脚的理论最大输入电流在25mA左右,但是实际推荐最好不要超过6mA,那么基极的R47电阻取值必须大于4.3V / 6mA ≈ 717Ω,即R47的阻值应该介于717Ω14.3kΩ之间,上面电路图中实际选取的阻值为10KΩ

综上所述,数字电路当中,三极管开关特性主要在于控制应用和驱动应用两个方面:

  • 控制应用:通过单片机控制三极管基极B,从而间接控制发射极E集电极C的导通状态,并进一步控制更高工作电压的外围元器件。
  • 驱动应用:单片机 IO 接口的电流输出能力通常在微安uA级别,而利用三极管的电流放大作用,可以增强单片机 IO 接口的电流输出能力至毫安mA级别。

74HC245 双向缓冲器

虽然通过单片机 IO 接口低电平可以直接点亮少量的 LED,但是当八个 LED 发光二极管同时并联的时候,总的驱动电流将会达到8mA ~ 160mA区间,而STC89C52RC的输入电流不建议超过6mA。如果这里通过限流电阻来解决问题,又有可能导致后级电路上连接的数码管供电不足,因此这种降低电流的方法并不可取。面对这种情况,我们可以考虑选用诸如74HC245(可以稳定工作于70mA左右电流)这样的驱动 IC 来作为单片机的电流缓冲器。

网络标号为19OE是输出使能引脚,该引脚在电路图当中上标有上划线,表示低电平有效。而网络标号为1DIR是方向引脚,当其为高电平1时,右侧标号B的引脚等于左侧标号A引脚上的电压;当其为低电平0时,左侧标号A的引脚等于右侧标号B引脚上的电压;

74HC138 三八译码器

74HC138可以将 3 种输入状态转换为 8 种输出状态,该逻辑芯片左侧的E1E2E3是使能引脚,A0A1A2是输入引脚,Y0Y7是输出引脚。当E1E2E3引脚的电平状态分别为001的时候,就可以通过A0A1A2的输入状态来控制Y0Y7的电平输出状态。

注意观察上图的真值表,任意的输出引脚都只有一位是低电平0,而其它的七位都是高电平1

LED 闪烁实验

下面电路图当中,LED2LED9八个 LED 发光二极管的总开关是三极管Q16的基极LEDS6,即74HC138三八译码器的Y6引脚,该引脚输出低电平0就可以导通Q16三极管的集电极和发射极,由此可以推导出74HC138A2A1A0的输入状态应该为110

另外,74HC138三八译码器的E1E2并联至单片机的P1.4引脚ENLED,而E3引脚则通过ADDR3连接到了单片机的P1.3引脚,因此当P1.4 = ENLED = 0; P1.3 = ADDR3 = 1;的时候,就可以使能74HC138。然后根据前面的分析,P1^2 = ADDR2 = 1; P1^1 = ADDR1 = 1; P1^0 = ADDR0 = 0;就能够保证三极管Q16顺利导通5V的电源。思路整理完毕,接下来开始编写让发光二极管LED2反复闪烁的程序代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <reg52.h>

sbit LED = P0 ^ 0; // 通过 DB0 点亮 LED2
sbit ADDR0 = P1 ^ 0;
sbit ADDR1 = P1 ^ 1;
sbit ADDR2 = P1 ^ 2;
sbit ADDR3 = P1 ^ 3;
sbit ENLED = P1 ^ 4;

void main() {
unsigned int i = 0;

ENLED = 0; ADDR3 = 1; // 使能 74HC138 译码器
ADDR2 = 1; ADDR1 = 1; ADDR0 = 0; // 导通三极管 Q16 的集电极与发射极

while (1) {
LED = 0; // 点亮 LED
for (i = 0; i < 30000; i++); // for 循环延时
LED = 1; // 熄灭 LED
for (i = 0; i < 30000; i++); // for 循环延时
}
}

软件延时

延时是单片机开发工作当中的常见操作,例如上一小节内容在 LED 点亮和熄灭状态之间加入了延时操作,从而能够让 LED 呈现出闪烁效果,STC89C52RC+单片机开发当中主要存在四种延时方式:

上图中的非精确延时,虽然无法精确控制程序执行的间隔时间,但是可以通过Keil uVision提供的【Debug】模式,在延时函数以及后一条语句各设置一个断点,然后将两条语句的执行时间相减,即可得到一个较为接近的延时时间值。

这里通过while()循环方式来编写一个非精确的延时函数delay(),将前一小节编写的 LED 代码进行修改,使用延时函数替换掉之前的for()循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <reg52.h>

sbit LED = P0 ^ 0;
sbit ADDR0 = P1 ^ 0;
sbit ADDR1 = P1 ^ 1;
sbit ADDR2 = P1 ^ 2;
sbit ADDR3 = P1 ^ 3;
sbit ENLED = P1 ^ 4;

/** 延时函数 */
void delay(unsigned long count) {
while (count--);
}

void main() {
unsigned int i = 0;

ENLED = 0; ADDR3 = 1; // 使能 74HC138
ADDR2 = 1; ADDR1 = 1; ADDR0 = 0; // 导通三极管 Q16 的集电极与发射极
while (1) {
LED = 0; // 点亮 LED
delay(25000); // 调用延时函数
LED = 1; // 熄灭 LED
delay(25000); // 调用延时函数
}
}

将上面代码放入Keil uVision进行调试,执行至第一个delay()断点语句的时间为0.00038900秒,到后一条断点语句P0 = 0xFF所消耗的时间为1.00044500 - 0.00038900 = 1.000056秒,也就是说 LED 亮灭状态的切换会在延时1秒后执行,即 LED 每间隔1秒反复闪烁。

LED 流水灯实验

完成 LED 闪烁实验之后,现在来进行一个 LED 流水灯试验,即将八个 LED 依次循环进行点亮,从而呈现出流水的效果。实验当中,需要通过P0的全部 8 个 IO 管脚来控制 8 枚 LED 的亮灭,此时就需要借助 C 语言提供的按位左右移运算符<<>>,以及按位取反运算符~来进行相应的控制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <reg52.h>

sbit ADDR0 = P1 ^ 0;
sbit ADDR1 = P1 ^ 1;
sbit ADDR2 = P1 ^ 2;
sbit ADDR3 = P1 ^ 3;
sbit ENLED = P1 ^ 4;

void main() {
unsigned int i = 0; // 定义循环变量,用于软件延时
unsigned char cnt = 0; // 定义移位计数变量,用于移位控制

ENLED = 0;
ADDR3 = 1;
ADDR2 = 1;
ADDR1 = 1;
ADDR0 = 0;

while (1) {
P0 = ~(0x01 << cnt); // P0 等于 1 左移 cnt 位,控制 8 个LED
for (i = 0; i < 20000; i++); // 软件延时
cnt++; // 移位计数变量自增 1

if (cnt >= 8) {
cnt = 0; // 移位计数变量超过 7,则重新从 0 开始计数
}
}
}

接下来,基于上面的代码,再来完成一个左移完接着右移,右移完再左移的往复式流水灯程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <reg52.h>

sbit ADDR0 = P1 ^ 0;
sbit ADDR1 = P1 ^ 1;
sbit ADDR2 = P1 ^ 2;
sbit ADDR3 = P1 ^ 3;
sbit ENLED = P1 ^ 4;

void main() {
unsigned int i = 0; //定义循环变量,用于软件延时
unsigned char dir = 0; //定义移位方向变量,用于控制移位的方向
unsigned char shift = 0x01; //定义循环移位变量,并赋初值为 0x01

ENLED = 0;
ADDR3 = 1;
ADDR2 = 1;
ADDR1 = 1;
ADDR0 = 0;

while (1) {
P0 = ~shift; // 循环移位变量取反,通过 P0 控制 8 枚LED
for (i = 0; i < 20000; i++); // 软件延时

/* 左移,当移位方向变量等于 0 */
if (dir == 0) {
shift = shift << 1; //循环移位变量左移 1 位
if (shift == 0x80) {
dir = 1; //左移到最左端后,改变移位方向
}
}
/* 右移,当移位方向变量不等于 0 */
else {
shift = shift >> 1; //循环移位变量右移 1 位
if (shift == 0x01) {
dir = 0; //右移到最右端后,改变移位方向
}
}
}
}

静态数码管 & 定时器

定时器概念

前面小节的内容当中,我们通过Keil uVision的【Debug】模式得到一个非精确的延时时间。而日常项目开发当中,通常会使用更为精确的STC89C52RC单片机内置定时/计数器功能,通过配置单片机特殊功能寄存器,能够分别实现定时计数的功能,相对而言定时器功能更加常用。标准 51 架构单片机内部拥有T0T1两个定时器,这里的T就是单词Timer的缩写。除此之外,STC89C52RC还扩展了一个额外的定时器T2。在开始着手进一步相关的试验之前,需要了解如下基本概念:

时钟周期:时序的最小时间单位,其值为\(\frac{1}{晶振时钟频率}\),当前晶振频率为11.0592MHz,即开发板的时钟周期为1/11059200秒。 机器周期:完成一个基本汇编操作的执行时间,\(1个机器周期 = 12个时钟周期\),那么开发板的机器周期就是12/11059200秒。 指令周期:时序的最大时间单位,指取出汇编指令并分析执行的时间,\(指令周期 = 机器周期\),换算成小数约等于0.0000011秒。

STC89C52RC单片机内部拥有 4 个定时值存储寄存器(用于T0TH0/TL0,以及用于T1TH1/TL1),当定时器开始计数后,定时值寄存器的值每经过一个机器周期时间(12 / 11059200 ≈ 0.000001秒)就累加1,在这里机器周期可以理解为定时器的计数周期。 对于 16 位定时器工作模式,16 bit = 2 Byte能够保存的最大十进制数值为65535,再加1定时器就会发生溢出,此时定时值存储寄存器将会归0

符号 描述 地址 复位值
TL0 定时器** T0 低**位 8AH 0000 0000B
TH0 定时器** T0 高**位 8BH 0000 0000B
TL1 定时器** T1 低**位 8CH 0000 0000B
TH1 定时器** T1 高**位 8DH 0000 0000B

另一个与定时器工作相关的寄存器是定时器控制寄存器 TCON,该寄存器可以进行位寻址,下表当中的IE0IT0IE1IT1位与外部中断功能相关,本小节只需重点了解TF0TR0TF1TR1四个位。

定时器控制寄存器 B7 B6 B5 B4 B3 B2 B1 B0
TCON TF1 TR1 TF0 TR0 IE1 IT1 IE0 IT0
复位值 0 0 0 0 0 0 0 0
  • TF1:定时器** T1 溢出标志位,当定时器 T1**发生溢出时由硬件置1,或者通过软件清零或者进入定时器中断时由硬件清零。
  • TR1:定时器** T1 运行控制位**,通过软件置位/清零来启动/停止寄存器。
  • TF0:定时器** T0 溢出标志位,当定时器 T0**发生溢出时由硬件置1,或者通过软件清零或者进入定时器中断时由硬件清零。
  • TR0:定时器** T0 运行控制位**,通过软件置位/清零来启动/停止寄存器。

当定时器运行控制位TR1 = 1的时候,定时器值每经过一个机器周期就自动累加1;当TR1 = 0的时候,定时器就会停止加1保持不变。当定时器设置为 16 位工作模式时,每经过一个机器周期TL1就自增1次,当定时值存储寄存器的低位TL1累加至2⁸ = 255次以后,再加1变为0。此时定时值存储寄存器高位TH1会累加1次,如此周而复始直至TH1TL1累加至255次,这里TL1TH1组成的十进制整数是65535。此时如果定时值再增加1次就会发生溢出,TL1TH1同时自动清零0,与此同时定时器溢出标志位TF1被自动置1,标识定时器发生了溢出。

前面提到的定时器T0T1的工作模式,则是由定时器模式寄存器 TMOD进行控制,需要注意该寄存器不可位寻址

定时器工作模式寄存器 定时器 T1 定时器 T1 定时器 T1 定时器 T1 定时器 T0 定时器 T0 定时器 T0 定时器 T0
序号 7 6 5 4 3 2 1 0
TMOD GATE C/T M1 M0 GATE C/T M1 M0
复位值 0 0 0 0 0 0 0 0
  • GATE:置1时为门控位,仅当INTx引脚为高电平且TRx控制位被置1时使能相应定时器开始计时。当该位被清0的时候,只需TRx位置1,相应的定时器就使能开始计时,并不受INTx引脚外部信号的干扰。该功能常用来测量外部信号脉冲宽度,本节内容暂不做介绍。
  • C/T定时器/计数器功能选择位,该位为0时作为定时器(内部系统时钟),该位为1时作为计数器(外部脉冲计数)。
  • M1/M0工作模式选择位,可以有如下选项:
    • 模式 00 0):13 位定时器,由THn的 8 位和TLn的 5 位组成一个 13 位定时器,用于兼容 8048 单片机的 13 位定时器。
    • 模式 10 1):16 位定时器,由THnTLn组成一个 16 位定时器,计数范围位于0 ~ 65535,溢出后如果不对THnTLn重新赋值,则从0开始计数。
    • 模式 21 0):8 位自动重装模式TLn参予累加计数,计数范围位于0 ~ 255,发生溢出时会将THn的值自动重装至TLn,主要用于产生串口波特率。
    • 模式 31 1):定时器 T1 无效,即停止计数。定时器 T0 双 8 位定时器TL0作为一个 8 位定时器由 T0 的控制位进行控制,TH0作为另一个 8 位定时器由 T1 的控制位进行控制。

注意:单片机的寄存器可位寻址表示程序能够直接对寄存器的每个位进行操作,而不可位寻址表示程序只能对寄存器整个字节进行操作。

上面列表中的【模式 0】是出于兼容性而设计,日常开发基本不会使用到。【模式 3】的功能可以被【模式 2】取代,因此全文内容将会重点介绍【模式 1】与【模式 2】的使用。下面的流程图展示了定时器 T0 在【模式 1】下的配置过程,图中的SYSclk表示的是系统时钟频率:

  1. TR0与其下方的或门电路进行运算,因此如果要让定时器工作,TR0必须置1;与此同时,下方的或门也必须为1
  2. GATE位等于1时,经过非门变成0或门电路结果想要为1,则INT0必须为1定时器才会工作,如果INT0等于0则定时器不工作。
  3. GATE位等于0时,经过一个非门变为1,此时无论INT0引脚处于何种电平状态,经过或门电路以后都肯定为1,定时器就会正常工作。
  4. 当开关处于C/T = 0状态时候,一个机器周期就会累加一次,此时处于定时器功能。
  5. 当开关处于C/T = 1状态时候,T0 引脚接收到一个脉冲就会累加一次,此时处于计数器功能。

STC89C52RC系列单片机的定时器有两种计数速率:一种是12T 模式,即每12个时钟周期累加1,兼容传统 8051 架构。另外一种是6T 模式,即每6个时钟周期累加1,速度是传统单片机的 2 倍;具体方式可以在 STC-ISP 编程器中进行设置。

定时器应用

STC89C52RC单片机的定时器/计数器 T0 与 T1的使用步骤大致可以总结如下:

  • 【第 1 步】:设置定时器工作模式寄存器TMOD,选择定时(计数脉冲由系统时钟输入)还是计数(计数脉冲从T0/P3.4引脚输入),以及选择定时值存储寄存器的工作模式。
  • 【第 2 步】:设置定时值存储寄存器低位TLn与高位THn的初值。
  • 【第 3 步】:设置定时器控制寄存器TCON,将其TRn位置1使定时器开始计数。
  • 【第 4 步】:判断TCON里的TFn位,监听定时器溢出。

注意:上面列表中TFnTRnTLnTHnn的取值可以是0(代表定时器 0)或1(代表定时器 1)。

如前所述,已知当前单片机晶振电路的机器周期为0.000001秒,如果需要精确的定时1ms毫秒,那么就需要经历\(\frac{0.001}{0.000001}=1000\)个机器周期。已知16 位模式定时器的溢出值为2^{16} = 65536,如果赋予TLnTHn一个初始值,使其经过1000个机器周期之后刚好达到65536溢出(通过检查TFn获得),这里只需要进行一个简单的减法运算即可得知该初始值为\(65536 - 1000 = 64536\),转换为十六进制数值就是0xFC18,那么THn的值为0xFCTLn的值为0x18。这里将之前使用while非精确延时函数的例子,修改为使用片内定时/计数器的精确延时:

如前所述,当前电路使用的晶振是11.0592MHz,因此时钟周期等于1 / 11059200,机器周期等于12/11059200。如果定时20ms毫秒即0.02s秒,假设需要经过X个机器周期,那么根据X × (12 / 11059200) = 0.02从而得到X = 18432。由于 16 位定时器的溢出值是6553665535需要加1才会溢出),那么可以先为TH0TL0赋一个初值,让其经过18432个机器周期后达到65536溢出,此时可以通过检测TF0的值来判断溢出状态。这个TH0TL0的初值应为65536 - 18432 = 47104 = 0xB800,即TH0 = 0xB8TL0 = 0x00,溢出50次就可以定时1s秒钟,接下来,编写一份关于定时器的代码,让 LED 点亮一秒然后熄灭一秒,不断进行闪烁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <reg52.h>

sbit LED = P0 ^ 0;
sbit ADDR0 = P1 ^ 0;
sbit ADDR1 = P1 ^ 1;
sbit ADDR2 = P1 ^ 2;
sbit ADDR3 = P1 ^ 3;
sbit ENLED = P1 ^ 4;

void main() {
unsigned char cnt = 0; // 溢出次数计数变量,用于记录定时器 T0 的溢出次数
ENLED = 0; // 使能网络标号为 U3 的 74HC138
ADDR3 = 1; ADDR2 = 1; ADDR1 = 1; ADDR0 = 0; // 选择需要控制的 LED

TMOD = 0x01; // 设置 T0 为工作模式 1
TH0 = 0xB8; TL0 = 0x00; // 定时值存储寄存器赋初值 0xB800
TR0 = 1; // 启动定时器 T0

while (1) {
/* 判断 T0 是否溢出*/
if (TF0 == 1) {
TF0 = 0; // T0 溢出后,清零中断标志
TH0 = 0xB8; TL0 = 0x00; // 定时值存储寄存器重新赋初值
cnt++; // 计数值自加 1

/* 判断 T0 溢出是否达到50次 */
if (cnt >= 50) {
cnt = 0; // 清零计数值
LED = ~LED; // LED状态取反
}
}
}
}

静态数码管显示

数码管是单片机开发当中的常用显示器件,每个数码管都拥有abcdefgdp八个 LED 段,下面是数码管的内部结构示意图:

由于并联电路电流之和等于总电流,数码管公共端的 2 个引脚可以起到分流的作用,从而降低单条支路所承受的电流。

LED 数码管分为共阳共阴两种,共阴数码管所有 LED 段的阴极连接在一起作为公共端,由阳极控制每个 LED 段的亮灭。共阳数码管所有 LED 段的阳极连接在一起作为公共端,由阴极控制每个 LED 段的亮灭。

从下面电路图能够看出,电路当中使用了 6 个共阳数码管,每个数码管的公共端都连接至5V正极,其段选端(控制某段 LED 亮灭状态的引脚)同样由P0管脚经过74HC245进行驱动,并由网络标号为U374HC138使用三极管来进行控制。

LED 数码管通常用于显示数值和字母,下面的表格总结了共阴/共阳极数码管所能够显示字符的编码表:

显示字符 0 1 2 3 4 5 6 7 8 9 A B C D E F
极数码管 0xFF 0xC0 0xF9 0xA4 0xB0 0x99 0x92 0x82 0xF8 0x80 0x90 0x88 0x83 0xC6 0xA1 0x86 0x8E
极数码管 0x00 0x3F 0x06 0x5B 0x4F 0x66 0x6D 0x7D 0x07 0x7F 0x6F 0x77 0x7C 0x39 0x5E 0x79 0x71

前一小节内容当中有介绍过,74HC138同一时刻只能输出一个低电平,参照上面数码管电路图,也就是说同一时刻只会使能一个数码管,换而言之74HC138输出信号将会作为数码管的位选端(选择多个数码管中具体哪个数码管被点亮)。配合控制段选的单片机P0引脚,就能通过一个数码管显示上述编码表当中的字符,也就是一个数码管的静态显示

正常声明的变量默认存放在单片机 RAM (数据存储空间)当中,程序中可以随意进行修改。但是有些不需要在程序使用过程中进行修改的变量,可以使用 8051 C 语言提供的code关键字进行声明,使其存储至 Flash(程序存储空间)从而节省单片机相对有限的 RAM 存储空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <reg52.h>

sbit ENLED = P1 ^ 4;
sbit ADDR0 = P1 ^ 0;
sbit ADDR1 = P1 ^ 1;
sbit ADDR2 = P1 ^ 2;
sbit ADDR3 = P1 ^ 3;

/* 使用 code 数组来存储数码管的编码表 */
unsigned char code LedChar[] = {0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8,
0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E};

void main() {
unsigned char cnt = 0; // 定时器 T0 的中断次数
unsigned char sec = 0; // 消耗的秒数

ENLED = 0; // 使能网络标号为 U3 的 74HC138
ADDR0 = 0; ADDR1 = 0; ADDR2 = 0; ADDR3 = 1; // 位选,选中数码管 DS1

TMOD = 0x01; // 设置 T0 为工作模式 1
TH0 = 0xB8; // 为 T0 赋初值 0xB800
TL0 = 0x00;
TR0 = 1; // 启动 T0

while (1) {
/* 判断 T0 是否溢出*/
if (TF0 == 1) {
TF0 = 0; // T0 溢出后,清零中断标志位
TH0 = 0xB8; TL0 = 0x00; // 定时值存储寄存器重新赋初值
cnt++; // 计数值自加 1

/* 判断 T0 溢出是否达到 50 次 */
if (cnt >= 50) {
cnt = 0; // 清零计数值
P0 = LedChar[sec]; // 段选,传递当前秒数对应的编码
sec++; // 秒数记录自增 1

/* 当秒数超过15(0x0F)以后,重新从 0 开始计数 */
if (sec >= 16) {
sec = 0;
}
}
}
}
}

动态数码管 & 中断

数码管的静态显示在同一时刻只能导通一位数码管,而数码管的动态显示,则是利用人眼的余晖效应(小于10ms)同时动态扫描刷新多个数码管。这里将利用 6 位数码管实现一个可以计时到999999的秒表功能。这里需要注意,对于多位数码管上每一位字符编码显示,可以通过除法运算/和取余运算%获取,例如要显示数字123456,个位数字6可以通过直接对10进行取余操作获得,十位数字5则需要先除以10然后再与10进行取余操作获取,以此类推就可以显示出全部数字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include <reg52.h>

sbit ADDR0 = P1 ^ 0;
sbit ADDR1 = P1 ^ 1;
sbit ADDR2 = P1 ^ 2;
sbit ADDR3 = P1 ^ 3;
sbit ENLED = P1 ^ 4;

/* 数码管编码表 */
unsigned char code LedChar[] = {0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8,
0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E};

/* 动态数码管显示缓冲区*/
unsigned char LedBuff[6] = {
0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF // 将初始值设置为 0xFF,确保启动时数码管处于熄灭状态
};

void main() {
unsigned char i = 0; // 动态扫描索引
unsigned int cnt = 0; // 定时器 T0 中断次数
unsigned long sec = 0; // 记录经过的秒数

ENLED = 0; // 使能网络标号为 U3 的 74HC138
ADDR3 = 1; // 因为需要动态修改ADDR 0、1、2的值,所以这里无须再初始化
TMOD = 0x01; // 设置 T0 为模式 1
TH0 = 0xFC; TL0 = 0x67; // 设置定时值存储寄存器,定时 1ms
TR0 = 1; // 启动 T0

while (1) {
/* 判断 T0 是否溢出*/
if (TF0 == 1) {
TF0 = 0; // T0 溢出后,清零中断标志位
TH0 = 0xFC;
TL0 = 0x67; // 定时值存储寄存器重新赋初值
cnt++; // 中断次数计数值自增 1

/* 判断 T0 溢出是否达到 1000 次 */
if (cnt >= 1000) {
cnt = 0; // 清零计数值
sec++; // 秒数记录自增 1

/* 将 sec 按照十进制由低至高,提取为数码管将要显示字符的编码 */
LedBuff[0] = LedChar[sec % 10];
LedBuff[1] = LedChar[sec / 10 % 10];
LedBuff[2] = LedChar[sec / 100 % 10];
LedBuff[3] = LedChar[sec / 1000 % 10];
LedBuff[4] = LedChar[sec / 10000 % 10];
LedBuff[5] = LedChar[sec / 100000 % 10];
}

/* 数码管动态扫描刷新 */
switch (i) {
case 0: ADDR2 = 0; ADDR1 = 0; ADDR0 = 0; i++; P0 = LedBuff[0]; break;
case 1: ADDR2 = 0; ADDR1 = 0; ADDR0 = 1; i++; P0 = LedBuff[1]; break;
case 2: ADDR2 = 0; ADDR1 = 1; ADDR0 = 0; i++; P0 = LedBuff[2]; break;
case 3: ADDR2 = 0; ADDR1 = 1; ADDR0 = 1; i++; P0 = LedBuff[3]; break;
case 4: ADDR2 = 1; ADDR1 = 0; ADDR0 = 0; i++; P0 = LedBuff[4]; break;
case 5: ADDR2 = 1; ADDR1 = 0; ADDR0 = 1; i = 0; P0 = LedBuff[5]; break;
default: break;
}
}
}
}

数码管显示残影与抖动

上述实验程序运行之后,数码管不应该亮起的 LED 段微微发亮,这种现象称为残影,主要是由于 C 语言逐语句执行时,位选和段选时进行瞬间状态切换而造成。以上面数码管秒表试验的代码为例,当代码执行流程从case 5切换至case 0时,case 5位选信号为ADDR2=1; ADDR1=0; ADDR0=1;,假如此刻最高位数码管case 5对应的显示值是0。需要切换到的case 0数码管位选为ADDR2=0; ADDR1=0; ADDR0=0;,假如其对应的数码管显示值是1

由于 C 语言程序逐句顺序执行,每条语句的执行都会占用一个短暂的时间,在将ADDR0=1修改成ADDR0=0的时候,出现了一个瞬时的中间状态ADDR2=1; ADDR1=0; ADDR0=0;,从而让case 4对应的数码管DS5瞬间显示为0。当完成正确的赋值ADDR2=0; ADDR1=0; ADDR0=0;之后,由于P0还保持着之前的值,又会瞬间使case 0对应的数码管DS1显示为0。直至将case 0后面的所有语句执行完成,整个数码管的刷新流程才正式结束。整个刷新过程当中发生了两次错误的赋值,虽然点亮时间极短,但是依然能够被肉眼察觉。而解决数码管动态显示的残影问题,只需要避开这两个瞬间的错误赋值即可。即在位选切换期间,避免一切数码管赋值,主要存在以下两种方式:

  • 关闭段选:数码管刷新之前关闭所有段选,位选完成之后,再打开段选;即在switch(i)语句之前,添加P0=0xFF;语句强制关闭数码管所有 LED 段,待完成ADDRn的赋值以后,再对P0进行赋值。
  • 关闭位选:关闭数码管位选,赋值过程完成以后再重新打开;即在switch(i)语句之前添加ENLED=1,直至case子句里的ADDRnP0完成赋值之后,再执行一个ENLED=0语句,最后完成break操作。

除了残影问题之外,上面的数码管秒表程序还存在显示抖动的问题,即每秒数值变化的时候,不参予变化的数码管会发生一次抖动。造成这个现象的原因在于程序定时到1s秒时,会执行秒数加1并转换为数码管显示字符的操作。由于unsigned long sec是一个 32 位的整型数据,当在switch()语句进行除法运算时会消耗大量时间,导致每次定时到1s秒时程序都需要多运行一段时间,从而造成某些数码管点亮时间较长,并最终影响到视觉效果。

接下来的内容里,将会引入STC89C52RC单片机内置的中断机制,来解决上述的残影抖动的问题。

中断机制

标准 8051 架构单片机涉及中断的寄存器主要有:中断使能寄存器(可位寻址)、中断优先级寄存器(可位寻址),这里首先来了解前者的相关相信:

中断允许寄存器 B7 B6 B5 B4 B3 B2 B1 B0
IE EA - ET2 ES ET1 EX1 ET0 EX0
复位值 0 - 0 0 0 0 0 0
  1. EA总中断使能EA=1允许中断,EA=0屏蔽所有中断。
  2. ET2定时/计数器 T2 中断使能ET2=1允许中断,ET2=0禁止中断。
  3. ES串行口中断使能ES=1允许串口中断,ES=0禁止串口中断。
  4. ET1定时/计数器 T1 中断使能ET1=1允许中断,ET1=0禁止中断。
  5. EX1外部中断 1 使能EX1=1允许中断,EX1=0禁止中断。
  6. ET0定时/计数器 T0 中断使能ET0=1允许中断,ET0=0禁止中断。
  7. EX0外部中断 0 使能EX0=1允许中断,EX0=0禁止中断。

上面的表格当中,ET2ESET1EX1ET0EX0这六个位分别控制着四种中断方式的使能,而EA则是作为所有中断的总使能开关,STC89C52RC使用任意中断方式之前,首先都需要通过EA = 1打开总中断使能,然后再开启相应的中断方式使能即可。

接下来,将会在之前的数码管秒表程序当中加入中断机制,以求完美处理掉残影抖动问题。由于中断程序的加入,执行流程将会被分割为两部分:数码管显示字符转换相关的代码继续留在主循环,动态扫描和定时1s秒功能则移动至中断函数,请参考下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#include <reg52.h>

sbit ADDR0 = P1 ^ 0;
sbit ADDR1 = P1 ^ 1;
sbit ADDR2 = P1 ^ 2;
sbit ADDR3 = P1 ^ 3;
sbit ENLED = P1 ^ 4;

/* 数码管编码表 */
unsigned char code LedChar[] = {0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8,
0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E};

/* 动态数码管显示缓冲区*/
unsigned char LedBuff[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};

unsigned char i = 0; // 动态扫描索引
unsigned int cnt = 0; // 定时器 T0 中断次数
unsigned char flag = 0; // 定时 1 秒标志

void main() {
unsigned long sec = 0; // 消耗的秒数

ENLED = 0; // 使能网络标号为 U3 的 74HC138
ADDR3 = 1; // 因为需要动态修改ADDR 0、1、2的值,所以这里无须再初始化
TMOD = 0x01; // 设置 T0 为模式 1
TH0 = 0xFC; TL0 = 0x67; // 设置定时值存储寄存器,定时 1ms

EA = 1; // 使能总中断
ET0 = 1; // 使能 T0 中断
TR0 = 1; // 启动 T0

while (1) {
/* 判断 1 秒定时标志 */
if (flag == 1) {
flag = 0; // 定时 1 秒标志清零
sec++; // 秒计数自增 1

/* 将 sec 按照十进制由低至高,提取为数码管将要显示字符的编码 */
LedBuff[0] = LedChar[sec % 10];
LedBuff[1] = LedChar[sec / 10 % 10];
LedBuff[2] = LedChar[sec / 100 % 10];
LedBuff[3] = LedChar[sec / 1000 % 10];
LedBuff[4] = LedChar[sec / 10000 % 10];
LedBuff[5] = LedChar[sec / 100000 % 10];
}
}
}

/* 定时器 T0 中断服务函数 */
void InterruptTimer0() interrupt 1 {
TH0 = 0xFC; TL0 = 0x67; // 定时值存储寄存器重新赋初值
cnt++; // 中断次数计数值自增 1

/* 判断 T0 溢出是否达到 1000 次 */
if (cnt >= 1000) {
cnt = 0; // 清零计数值
flag = 1; // 定时 1 秒标志置 1
}

P0 = 0xFF; // 消除残影

/* 数码管动态扫描刷新 */
switch (i) {
case 0: ADDR2 = 0; ADDR1 = 0; ADDR0 = 0; i++; P0 = LedBuff[0]; break;
case 1: ADDR2 = 0; ADDR1 = 0; ADDR0 = 1; i++; P0 = LedBuff[1]; break;
case 2: ADDR2 = 0; ADDR1 = 1; ADDR0 = 0; i++; P0 = LedBuff[2]; break;
case 3: ADDR2 = 0; ADDR1 = 1; ADDR0 = 1; i++; P0 = LedBuff[3]; break;
case 4: ADDR2 = 1; ADDR1 = 0; ADDR0 = 0; i++; P0 = LedBuff[4]; break;
case 5: ADDR2 = 1; ADDR1 = 0; ADDR0 = 1; i = 0; P0 = LedBuff[5]; break;
default: break;
}
}

上面的代码当中,程序的执行流程被分割为主函数、中断服务函数两部分,中断服务函数必须使用 8051C 提供的interrupt关键字进行声明,紧接在后面的数字1表示的是中断查询顺序号,STC89C52RC常用的中断查询顺序以及中断向量(中断服务程序入口地址)如下表所示:

中断函数 中断名称 中断标志位 中断使能位 中断向量地址 优先级
void service() interrupt 0 外部中断 0 IE0 EX0 0x0003 ▲ 高
void service() interrupt 1 定时器 T0 中断 TF0 ET0 0x000B -
void service() interrupt 2 外部中断 1 IE1 EX1 0x0013 -
void service() interrupt 3 定时器 T1 中断 TF1 ET1 0x001B -
void service() interrupt 4 UART 中断 TI/RI ES 0x0023 -
void service() interrupt 5 定时器 T2 中断 TF2/EXF2 ET2 0x002B ▼ 低

仔细分析上面的表格,对于前面代码中使用到的定时器 T0 中断,可以先通过ET0 = 1使能该中断,然后当其对应的中断标志位TF01时就会触发 T0 中断,此时单片机将会根据中断向量地址执行该中断函数,这里的中断向量是基于interrupt关键字后的中断函数编号得出,具体计算方法是中断函数号 × 8 + 3。当中断条件满足之后,就会自动触发中断并调用相应的中断函数。

前面动态数码管显示的示例代码当中,就是通过中断机制来确保数码管动态扫描的间隔时间固定为1ms毫秒,从而避免了由于程序代码逐行执行而导致的数码管显示抖动。

中断优先级与中断嵌套

STC89C52RC单片机存在着默认优先级抢占式优先级两种概念,这里先来介绍一下抢占式优先级。中断优先级寄存器 IP可以进行位寻址,其每一位都代表着其对应中断的抢占式优先级,其复位值都是0,置1后该位对应的优先级将高于其它位。

B7 B6 B5 B4 B3 B2 B1 B0
- - PT2 PS PT1 PX1 PT0 PX0
保留 保留 定时器 T2 中断优先级控制位 串口中断优先级控制位 定时器 T1 中断优先级控制位 外部中断 T1 中断优先级控制位 定时器 T0 中断优先级控制位 外部中断 T0 中断优先级控制位
- - 0 0 0 0 0 0

例如PT0 = 1的时候,如果定时器 T0 发生中断,代码执行流程将会立即转入定时器 T0 的中断服务程序。如果此时发生了其它优先级较低的中断,也必须等待当前高优先级中断服务程序执行完成,才会进入低优先级中断对应的服务程序。当流程进入低优先级中断对应服务程序的时候,如果又发生了更高优先级的中断,此时执行流程就会立刻转入高优先级中断服务函数执行,处理完成之后再返回刚才的低优先级中断,这一系列过程称为中断嵌套,也就是上面提到的抢占,即高优先级中断可以打断低优先级中断的执行,从而形成中断嵌套;反过来,低优先级中断并不会打断高优先级中断的执行;标准 8051 架构单片机最多可以实现两级中断嵌套。

默认优先级就是单片机中断机制当中,各种中断源默认的优先级顺序(中断查询顺序编号数值越小优先级越高),标准 8051 架构单片机一共拥有 6 个默认优先级中断源,它们分别是:外部中断 0外部中断 1定时器 T0 中断定时器 T1 中断定时器 T2 中断UART 中断,中断源默认优先级与抢占式优先级最大的不同点在于:即使低优先级中断执行过程中又发生了高优先级中断,高优先级中断也只能等待低优先级中断执行完后才会得到响应。默认优先级主要用于处理多个中断源同时发生的仲裁,例如代码中暂时通过EA = 0关闭了总中断,然后在此期间有多个中断源产生了中断,由于总中断处于关闭状态,这些中断迟迟得不到响应。然后当程序中EA = 0重新使能总中断时,这些中断源就会同时申请中断,此时单片机就会按照各个中断源默认的优先级顺序逐个进行处理。

LED 点阵

函数中的局部变量(未添加static关键字修饰)属于自动变量,自动分配存储空间,函数调用完成后自动释放,自动变量也可以通过auto关键字显式进行声明。函数外的全局变量都属于静态变量,但是使用static关键字声明的局部变量被称为静态局部变量,可以用来缓存函数上一次的执行结果。因此,可以尝试将前面动态数码管显示所使用的索引变量i、定时器中断次数cnt定义为静态局部变量。

点阵 LED 是一种可任意分割组装的显示技术,本质上是由多个 LED 发光二极管组成的矩阵,点亮原理较为简单。下面电路图当中,LED 点阵LD1顶部的DB0 ~ DB7通过74HC245连接到单片机P0引脚,以此作为 LED 点阵的阴极;左侧引脚经过八枚9012 二极管之后,由LEDC0 ~ LEDCC7端连接至网络标号为U474HC138三八译码器,并最终与单片机的P1.0 ~ P1.3引脚连接,以此作为 LED 点阵的阳极。

当 LED 点阵第 9 脚设置为高电平1,第13脚设置为低电平0,就可以点亮顶部左侧的第 1 枚 LED。同理,通过对P0整体赋值,并将74HC138Y1引脚拉至低电平0就可以点亮第 2 行的全部八枚 LED,具体可以参考下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <reg52.h>

sbit ADDR0 = P1 ^ 0;
sbit ADDR1 = P1 ^ 1;
sbit ADDR2 = P1 ^ 2;
sbit ADDR3 = P1 ^ 3;
sbit ENLED = P1 ^ 4;

void main() {
ENLED = 0; // 同时使能 U3 和 U4 两片 74HC138 三八译码器
ADDR3 = 0; // 使能 U4 输出
ADDR2 = 0; // 经过 Y1 导通三极管 Q11
ADDR1 = 0;
ADDR0 = 1;

P0 = 0x00; // 将 P0 引脚全部置为低电平

while (1); // 让程序运行停止在该位置
}

LED 点阵由 64 枚发光二极管组成,可以考虑将 LED 点阵理解为一个 8 位的数码管(每个数码管由 8 段 LED 组成)。之前已经进行过 6 位数码管同时显示的实验,接下来就将同样利用定时器中断和数码管动态显示的原理来将 LED 点阵全部点亮。注意:之前代码中的动态扫描索引i,已经被移至中断服务函数当中,并被static关键字声明为了一个静态局部变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <reg52.h>

sbit ADDR0 = P1 ^ 0;
sbit ADDR1 = P1 ^ 1;
sbit ADDR2 = P1 ^ 2;
sbit ADDR3 = P1 ^ 3;
sbit ENLED = P1 ^ 4;

void main() {
ENLED = 0; // 同时使能 U3 和 U4 两片 74HC138 三八译码器
ADDR3 = 0; // 使能 U4 输出,由于要动态改变 ADDR0/1/2 的值,所以这里不再初始化

EA = 1; // 使能总中断
TMOD = 0x01; // 设置 T0 为模式 1
TH0 = 0xFC; TL0 = 0x67; // 设置定时值存储寄存器,定时 1ms
ET0 = 1; // 使能 T0 中断
TR0 = 1; // 启动 T0

while (1); // 程序停止在此处,等待定时器 T0 中断
}

/* 定时器 T0 中断服务函数 */
void InterruptTimer0() interrupt 1 {
static unsigned char i = 0; // 声明为静态局部变量的动态扫描索引
TH0 = 0xFC; TL0 = 0x67; // 定时值存储寄存器重新赋初值
P0 = 0xFF; // 消除残影

/* 数码管动态扫描刷新 */
switch (i) {
case 0: ADDR2 = 0; ADDR1 = 0; ADDR0 = 0; i++; P0 = 0x00; break;
case 1: ADDR2 = 0; ADDR1 = 0; ADDR0 = 1; i++; P0 = 0x00; break;
case 2: ADDR2 = 0; ADDR1 = 1; ADDR0 = 0; i++; P0 = 0x00; break;
case 3: ADDR2 = 0; ADDR1 = 1; ADDR0 = 1; i++; P0 = 0x00; break;
case 4: ADDR2 = 1; ADDR1 = 0; ADDR0 = 0; i++; P0 = 0x00; break;
case 5: ADDR2 = 1; ADDR1 = 0; ADDR0 = 1; i++; P0 = 0x00; break;
case 6: ADDR2 = 1; ADDR1 = 1; ADDR0 = 0; i++; P0 = 0x00; break;
case 7: ADDR2 = 1; ADDR1 = 1; ADDR0 = 1; i = 0; P0 = 0x00; break;
default: break;
}
}

独立按键

独立式和矩阵式是两种比较常用的按键电路形式,其中独立式按键的电路非常简单,每个微动按键分别与单片机的 IO 管脚进行连接。

上面的电路图当中,四枚按键分别连接至单片机 IO 口,当按键K1按下,5V电压经过电阻R1按键 K1以后进入GND形成通路,该线路上所有电压都将施加到这个R1电阻,单片机KeyIn1引脚此时表现为低电平0。当按键K1松开之后,这条通路被断开,从而没有电流通过,此时KeyIn15V是相等电位,KeyIn1引脚呈现高电平1。综上所述,我们就可以通过单片机KeyIn1引脚的电平状态来判断按键是否按下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <reg52.h>

/* 通过 74HC138 选中需要控制的 LED */
sbit ADDR0 = P1 ^ 0;
sbit ADDR1 = P1 ^ 1;
sbit ADDR2 = P1 ^ 2;
sbit ADDR3 = P1 ^ 3;
sbit ENLED = P1 ^ 4;

/* LED 控制引脚 */
sbit LED6 = P0 ^ 4;
sbit LED7 = P0 ^ 5;
sbit LED8 = P0 ^ 6;
sbit LED9 = P0 ^ 7;

/* 独立按键控制引脚 */
sbit KEY1 = P2 ^ 4;
sbit KEY2 = P2 ^ 5;
sbit KEY3 = P2 ^ 6;
sbit KEY4 = P2 ^ 7;

void main() {
/* 将矩阵按键首行的 K1~K4 作为独立按键处理 */
ENLED = 0;
ADDR0 = 0;
ADDR1 = 1;
ADDR2 = 1;
ADDR3 = 1;

P2 = 0xF7; // 1111 0111,设置 P2.3 引脚对应的 KeyOut1 为低电平 0

while (1) {
/* 将按键对应引脚上的电平状态传递给 LED,按键按下时电平状态为 0 对应 LED 被点亮 */
LED9 = KEY1;
LED8 = KEY2;
LED7 = KEY3;
LED6 = KEY4;
}
}

上面代码让KeyOut1输出低电平0KeyOut2 ~ 4则保持高电平1,相当于将矩阵按键第 1 行的Key1 ~ Key4作为 4 个独立按键处理,然后将这 4 个按键的电平状态分别传递给LED6 ~ LED9 这 4 个 LED。当按键按下时,KEYnLEDn的电平状态都为0,此时 LED 正常点亮。

通常情况下,按键并不需要检测一个固定的电平值,而是需要检测电平值的按下弹起两种变化状态,即模拟自锁定开关的效果。因此,可以将每次扫描到的按键状态进行缓存,每次扫描按键状态时都与前一次的状态进行比较,如果状态不一致,就说明按键产生过动作。接下来,以按键K4为例编写如下示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <reg52.h>

sbit ADDR0 = P1 ^ 0;
sbit ADDR1 = P1 ^ 1;
sbit ADDR2 = P1 ^ 2;
sbit ADDR3 = P1 ^ 3;
sbit ENLED = P1 ^ 4;

sbit KEY1 = P2 ^ 4;
sbit KEY2 = P2 ^ 5;
sbit KEY3 = P2 ^ 6;
sbit KEY4 = P2 ^ 7;

/* 数码管显示字符编码表 */
unsigned char code LedChar[] = {0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8,
0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E};

void main() {
bit backup = 1; // 位变量,保存前次扫描的按键值
unsigned char cnt = 0; // 记录按键按下的次数

/* 通过 74HC138 选中数码管DS1 */
ENLED = 0;
ADDR0 = 0;
ADDR1 = 0;
ADDR2 = 0;
ADDR3 = 1;

P2 = 0xF7; // KeyOut1 输出低电平
P0 = LedChar[cnt]; // 显示按键次数的初始值

while (1) {
/* 当前值与前次值不相等说明此时按键有动作 */
if (KEY4 != backup) {

/* 如果前次值为0,则说明当前是由0变1,即按键弹起 */
if (backup == 0) {
cnt++; // 按键次数自增 1

/* 由于参与显示的只有 1 位数码管,所以按键次数自增到 10 就清零并重新计数 */
if (cnt >= 10) {
cnt = 0;
}
P0 = LedChar[cnt]; // 让数码管显示计数值
}
backup = KEY4; // 更新缓存值为当前值,便于下次进行比较
}
}
}

上面的程序当中,按一次按键,就会产生【按下】与【弹起】两个状态,这里选择的是在【弹起】时对数码管进行加1操作。程序实际运行时,会发现有些时候K4按键按下一次,但是数码管显示的数字却累加了不止 1 次,这主要由于按键抖动所引起的,接下来的小节将会重点探讨这个问题。

上面代码所使用的bit关键字是 8051 架构特有的一种数据类型,仅占用 1 个位的存储空间,且只能保存01两个值,通常用来表达按键的按下/弹起、LED 的亮/灭、三极管的导通/关断等状态。

按键消抖

按键抖动是由微动开关的机械触点在闭合/断开时未能稳定接通所造成的,抖动发生的时间通常不会超过10ms。按键消抖的基本原理,是在检查出按键状态发生变化时,延迟一段时间,等待触点闭合/断开状态稳定之后再进行相应处理。

按键消抖的处理方式大致可以分为硬件消抖和软件消抖两类,硬件消抖会在按键上并联一个电容,从而利用电容的充放电特性对抖动过程中产生的电压杂波进行平滑处理,进而实现消抖功能。

例如上面的按键消抖电路当中,微动开关下方就并联了一只容值为0.1uF的电容。实际开发环境里,如果按键数量较多,这种方式会显著增加电路成本,因此较少被使用到。相对而言,软件消抖才是工程实践当中较多采纳的消抖方式,当程序检测到按键状态变化以后,先延时10ms等待抖动消失以后,再检测一次按键状态,如果与之前检测的状态相同,就确认按键已经稳定的闭合/断开,这里对前面独立按键K4的实验程序进行修改,加入防抖处理的相关代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include <reg52.h>

sbit ADDR0 = P1 ^ 0;
sbit ADDR1 = P1 ^ 1;
sbit ADDR2 = P1 ^ 2;
sbit ADDR3 = P1 ^ 3;
sbit ENLED = P1 ^ 4;

sbit KEY1 = P2 ^ 4;
sbit KEY2 = P2 ^ 5;
sbit KEY3 = P2 ^ 6;
sbit KEY4 = P2 ^ 7;

/* 数码管显示字符编码表 */
unsigned char code LedChar[] = {0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8,
0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E};

/* 延时函数,延时约 10ms */
void delay() {
unsigned int i = 1000;
while (i--);
}

void main() {
bit keybuf = 1; // 按键值暂存,临时保存按键的扫描值
bit backup = 1; // 按键值备份,保存前一次按键扫描值
unsigned char cnt = 0; // 按键计数,记录按键被按下的次数

/* 通过 74HC138 选中数码管DS1 */
ENLED = 0;
ADDR3 = 1;
ADDR2 = 0;
ADDR1 = 0;
ADDR0 = 0;

P2 = 0xF7; // KeyOut1 端输出低电平
P0 = LedChar[cnt]; // 显示按键次数的初始值

while (1) {
keybuf = KEY4; // 保存当前的按键扫描值

/* 当前值与前次值不相等说明此时按键有动作 */
if (keybuf != backup) {
delay(); // 消抖,调用延时函数,延时约 10ms

/* 判断扫描值是否发生变化,即按键是否还在抖动 */
if (keybuf == KEY4) {

/* 如果前次值为0,说明当前按键弹起 */
if (backup == 0) {
cnt++; // 按键次数自增 1

/* 由于参与显示的仅 1 位数码管,所以按键次数自增到 10 就清零重新计数 */
if (cnt >= 10) {
cnt = 0;
}
P0 = LedChar[cnt]; // 让数码管显示计数值
}

backup = keybuf; // 更新缓存值为当前值,便于下次进行比较
}
}
}
}

由于延时函数delay()极有可能会造成main()函数执行流程的死锁,进而影响其它任务的调度,因此可以引入单片机的中断机制,每2ms进入一次定时中断,扫描一次按键状态后缓存下来,连续扫描 8 次以后,比较这 8 次的按键(共计16ms)状态是否一致,如果保持一致就可以确定按键已经处于稳定状态。

上面示意图当中,左边是起始的0时间,每经过2ms左移一次,每移动一次,就判断当前连续的 8 次按键状态是否全为0或者全为1,如果全为1就表示按键弹起,全为0则表示按键按下,如果出现01交错的情况,就认为按键发生了抖动。这种方式可以有效避免延时消抖函数占用单片机执行时间,影响其它功能的执行,这里继续对上面K4独立按键的例子进行修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <reg52.h>

sbit ADDR0 = P1 ^ 0;
sbit ADDR1 = P1 ^ 1;
sbit ADDR2 = P1 ^ 2;
sbit ADDR3 = P1 ^ 3;
sbit ENLED = P1 ^ 4;

sbit KEY1 = P2 ^ 4;
sbit KEY2 = P2 ^ 5;
sbit KEY3 = P2 ^ 6;
sbit KEY4 = P2 ^ 7;

/* 数码管显示字符编码表 */
unsigned char code LedChar[] = {0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8,
0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E};

bit KeySta = 1; // 当前按键的状态

void main() {
bit backup = 1; // 按键值备份,保存前一次按键扫描值
unsigned char cnt = 0; // 按键计数,记录按键按下的次数
EA = 1; // 使能总中断

/* 通过 74HC138 选中数码管DS1 */
ENLED = 0;
ADDR3 = 1;
ADDR2 = 0;
ADDR1 = 0;
ADDR0 = 0;

TMOD = 0x01; // 设置 T0 为模式1
TH0 = 0xF8; TL0 = 0xCD; // 定时器 T0 赋初值 0xF8CD,表示定时 2ms
ET0 = 1; // 定时器 T0 中断使能
TR0 = 1; // 启动定时器 T0
P2 = 0xF7; // KeyOut1 端输出低电平
P0 = LedChar[cnt]; // 显示按键次数的初始值

while (1) {
/* 当前值与前次值不相等说明此时按键有动作 */
if (KeySta != backup) {

/* 如果前次值为0,说明当前按键弹起 */
if (backup == 0) {
cnt++; // 按键次数自增 1

/* 由于参与显示的仅 1 位数码管,所以按键次数自增到 10 就清零重新计数 */
if (cnt >= 10) {
cnt = 0;
}
P0 = LedChar[cnt]; // 让数码管显示计数值
}

backup = KeySta; // 更新缓存值为当前值,便于下次进行比较
}
}
}

/* 定时器 T0 中断服务函数,用于按键状态扫描与消抖 */
void InterruptTimer0() interrupt 1 {
static unsigned char keybuf = 0xFF; // 扫描按键状态缓冲区,保存一段时间内的扫描值
TH0 = 0xF8; TL0 = 0xCD; // 定时器 T0 重新赋初值
keybuf = (keybuf << 1) | KEY4; // 缓冲区左移一位,将当前扫描值移入最低位

/* 连续 8 次扫描值都为 0 ,即 16ms 内都只检测到按下状态,就认为按键已经稳定按下 */
if (keybuf == 0x00) {
KeySta = 0;
}
/* 连续 8 次扫描值都为 1 ,即 16ms 内都只检测到弹起状态,就认为按键已经稳定弹起 */
else if (keybuf == 0xFF) {
KeySta = 1;
} else {
// 其它情况说明按键状态尚未稳定,不需要更新 KeySta
}
}

矩阵键盘

由于独立按键会占用大量的单片机 IO 资源,接下来将介绍更加常用的矩阵式按键设计。下面电路图当中,使用 8 个单片机 IO 管脚就可以控制由 16 个按键组成的矩阵按键(共分为 4 组每组各 4 个独立按键),通过矩阵按键的行线列线就可以检测到当前按下的是哪个按键。

前面小节当中介绍过,按键按下状态通常会保持100ms以上,如果在按键扫描中断服务函数当中,每次都让矩阵按键的一个KeyOut输出低电平0,其它三个引脚KeyOut2KeyOut3KeyOut4输出高电平1,然后判断所有KeyIn的状态,通过快速的中断不停循环进行判断,就可以最终确定当前按下的按键。

至于扫描间隔时间和消抖时间,由于目前拥有 4 路KeyOut输出,需要中断 4 次才能完成一次全部按键的扫描,继续采用2ms中断来判断 8 次扫描值的方式,消耗的时间(2毫秒 × 4路 × 8次 = 64毫秒)将会过长,无法正确实现消抖处理。因此,这里可以改用1ms中断并且判断 4 次的方式作为消抖时间(1毫秒 × 4路 × 4次 = 16毫秒)。接下来编写程序,循环扫描电路图当中的K1 ~ K16共 16 个矩阵按键,并将当前按下按键的编号(使用0~F表示,显示值等于按键编号减去1)显示在一位数码管上面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
#include <reg52.h>

sbit ADDR0 = P1 ^ 0;
sbit ADDR1 = P1 ^ 1;
sbit ADDR2 = P1 ^ 2;
sbit ADDR3 = P1 ^ 3;
sbit ENLED = P1 ^ 4;

sbit KEY_IN_1 = P2 ^ 4;
sbit KEY_IN_2 = P2 ^ 5;
sbit KEY_IN_3 = P2 ^ 6;
sbit KEY_IN_4 = P2 ^ 7;

sbit KEY_OUT_1 = P2 ^ 3;
sbit KEY_OUT_2 = P2 ^ 2;
sbit KEY_OUT_3 = P2 ^ 1;
sbit KEY_OUT_4 = P2 ^ 0;

/* 数码管显示字符编码表 */
unsigned char code LedChar[] = {0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8,
0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E};

/* 矩阵按键的全部状态 */
unsigned char KeySta[4][4] = {{1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}};

void main() {
unsigned char i, j;
/* 键值备份,保存前一次的矩阵按键状态 */
unsigned char backup[4][4] = {{1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}};
EA = 1; //使能总中断

/* 通过 74HC138 选中数码管DS1 */
ENLED = 0;
ADDR3 = 1;
ADDR2 = 0;
ADDR1 = 0;
ADDR0 = 0;

TMOD = 0x01; // 设置 T0 为模式1
TH0 = 0xFC; TL0 = 0x67; // 定时器 T0 赋初值 0xFC67,定时 1ms
ET0 = 1; // 定时器 T0 中断使能
TR0 = 1; // 启动定时器 T0
P0 = LedChar[0]; // 数码管显示默认值 0

while (1) {
/* 循环扫描 4*4 矩阵按键 */
for (i = 0; i < 4; i++) {
for (j = 0; j < 4; j++) {

/* 检测按键动作 */
if (backup[i][j] != KeySta[i][j]) {
/* 按键按下时需要执行的任务 */
if (backup[i][j] != 0) {
P0 = LedChar[i * 4 + j]; // 将按键编号显示至数码管
}
backup[i][j] = KeySta[i][j]; // 更新前一次的备份值
}
}
}
}
}

/* 定时器 T0 中断服务函数,用于按键状态扫描与消抖 */
void InterruptTimer0() interrupt 1 {
unsigned char i;
static unsigned char keyout = 0; // 矩阵按键扫描输出索引

/* 矩阵按键扫描缓冲区 */
static unsigned char keybuf[4][4] = {{0xFF, 0xFF, 0xFF, 0xFF}, {0xFF, 0xFF, 0xFF, 0xFF}, {0xFF, 0xFF, 0xFF, 0xFF}, {0xFF, 0xFF, 0xFF, 0xFF}};

TH0 = 0xFC; TL0 = 0x67; // 定时器 T0 重新赋初值

/* 将一行上 4 个按键的值移入缓冲区 */
keybuf[keyout][0] = (keybuf[keyout][0] << 1) | KEY_IN_1;
keybuf[keyout][1] = (keybuf[keyout][1] << 1) | KEY_IN_2;
keybuf[keyout][2] = (keybuf[keyout][2] << 1) | KEY_IN_3;
keybuf[keyout][3] = (keybuf[keyout][3] << 1) | KEY_IN_4;

/* 完成消抖之后更新按键的状态,因为每行 4 个按键,所以要循环 4 次 */
for (i = 0; i < 4; i++) {

/* 连续 4 次扫描值都为 0 ,即 16ms 内都只检测到按下状态,就认为按键已经稳定按下 */
if ((keybuf[keyout][i] & 0x0F) == 0x00) {
KeySta[keyout][i] = 0;
}
/* 连续 4 次扫描值都为 1 ,即 16ms 内都只检测到弹起状态,就认为按键已经稳定弹起 */
else if ((keybuf[keyout][i] & 0x0F) == 0x0F) {
KeySta[keyout][i] = 1;
}
}

/* 执行下一次扫描输出 */
keyout++; // 输出索引自增
keyout = keyout & 0x03; // 索引值自增到 4 以后归零

/* 根据索引值释放当前输出引脚,并拉低下次的输出引脚 */
switch (keyout) {
case 0: KEY_OUT_4 = 1; KEY_OUT_1 = 0; break;
case 1: KEY_OUT_1 = 1; KEY_OUT_2 = 0; break;
case 2: KEY_OUT_2 = 1; KEY_OUT_3 = 0; break;
case 3: KEY_OUT_3 = 1; KEY_OUT_4 = 0; break;
default: break;
}
}

上面代码当中,中断函数中扫描KeyIn输入与切换KeyOut输出的顺序与前面顺序不同,代码中首先对所有KeyIn输入进行扫描与消抖处理,然后才切换到下一次KeyOut输出,即每次中断扫描的实质是上次输出所选择的那行按键。这是由于信号从输出到稳定需要一段时间,这里颠倒输入输出顺序就是为了让输出信号拥有足够时间(一次定时器中断间隔)保持稳定,从而确保程序代码的健壮性。

注意:上面代码中的keyout = keyout & 0x03; // 索引值自增到 4 后归零这条语句,其实质是确保keyout0 ~ 3范围以内变化,加至4以后就自动归零。这里并未采用if()语句进行判断,而是另辟蹊径使用了与运算符&来完成。由于数值0123正好占据 2 个二进制位(bit),如果对 1 个字节(Byte)的高 6 位一直进行清零,那么该字节存储的值就自然呈现出一种满 4 归零的效果。

加法计算器试验

完成前面数码管与键盘的学习之后,本节内容将着手实现一个简易的加法计算器:使用开发板上标有【0 ~ 9】的按键作为数字输入,这些数字会实时显示到数码管;然后采用标有向上箭头的按键作为【+】加号,按下以后可以再行输入一串被加数字;最后,按下回车键就可以得到加法计算的结果,并将其显示在数码管。本程序会将各个子功能划分为独立的函数,提高代码可读性的同时也便于程序的维护。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
#include <reg52.h>

sbit ADDR0 = P1 ^ 0;
sbit ADDR1 = P1 ^ 1;
sbit ADDR2 = P1 ^ 2;
sbit ADDR3 = P1 ^ 3;
sbit ENLED = P1 ^ 4;

sbit KEY_IN_1 = P2 ^ 4;
sbit KEY_IN_2 = P2 ^ 5;
sbit KEY_IN_3 = P2 ^ 6;
sbit KEY_IN_4 = P2 ^ 7;

sbit KEY_OUT_1 = P2 ^ 3;
sbit KEY_OUT_2 = P2 ^ 2;
sbit KEY_OUT_3 = P2 ^ 1;
sbit KEY_OUT_4 = P2 ^ 0;

/* 数码管显示字符编码表 */
unsigned char code LedChar[] = {0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8,
0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E};

/* 矩阵按键的全部状态 */
unsigned char KeySta[4][4] = {
{1, 1, 1, 1},
{1, 1, 1, 1},
{1, 1, 1, 1},
{1, 1, 1, 1}
};

/* 数码管显示缓冲区 */
unsigned char LedBuff[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};

/* 矩阵按键编号和标准键盘键码的映射表 */
unsigned char code KeyCodeMap[4][4] = {
{0x31, 0x32, 0x33, 0x26}, // 数字键 1、数字键 2、数字键 3、向上键
{0x34, 0x35, 0x36, 0x25}, // 数字键 4、数字键 5、数字键 6、向左键
{0x37, 0x38, 0x39, 0x28}, // 数字键 7、数字键 8、数字键 9、向下键
{0x30, 0x1B, 0x0D, 0x27} // 数字键 0、ESC 键、回车键、向右键
};

void KeyDriver(); // 按键驱动函数声明

void main() {
EA = 1; // 总中断使能
ENLED = 0;
ADDR3 = 1;

TMOD = 0x01; // 设置 T0 为模式 1
TH0 = 0xFC; TL0 = 0x67; // 定时器 T0 赋初值 0xFC67,定时 1ms
ET0 = 1; // 定时器 T0 中断使能
TR0 = 1; // 启动定时器 T0
LedBuff[0] = LedChar[0]; // 上电以后数码管显示初始值 0

while (1) {
KeyDriver(); // 按键驱动函数调用
}
}

/* 数码管上显示一个无符号长整型数值,参数 num 表示要显示的数字 */
void ShowNumber(unsigned long num) {
signed char i;
unsigned char buf[6];

/* 将长整型数值转换为 6 位十进制数组 */
for (i = 0; i < 6; i++) {
buf[i] = num % 10;
num = num / 10;
}
/* 从最高位开始,如果遇到 0 就转换为空格,非 0 则退出循环 */
for (i = 5; i >= 1; i--) {
if (buf[i] == 0)
LedBuff[i] = 0xFF;
else
break;
}
/* 剩下的低位原样转换为数码管要显示的字符 */
for (; i >= 0; i--) {
LedBuff[i] = LedChar[buf[i]];
}
}

/* 根据键码执行相应操作,参数 keycode 是按键键码 */
void KeyAction(unsigned char keycode) {
static unsigned long result = 0; // 运算结果
static unsigned long addend = 0; // 输入的被加数

/* 如果输入的是 0 ~ 9 的数字 */
if ((keycode >= 0x30) && (keycode <= 0x39)) {
addend = (addend * 10) + (keycode - 0x30); // 整体十进制左移,新数字进入个位
ShowNumber(addend); // 将运算结果显示到数码管
}
/* 向上键用作为加号,执行加法或者连加运算 */
else if (keycode == 0x26) {
result += addend; // 进行加法运算
addend = 0;
ShowNumber(result); // 将运算结果显示到数码管
}
/* 按下回车键,然后执行加法运算 */
else if (keycode == 0x0D) {
result += addend; // 进行加法运算
addend = 0;
ShowNumber(result); // 将运算结果显示到数码管
}
/* 按下 Esc 键,清零计算结果 */
else if (keycode == 0x1B) {
addend = 0;
result = 0;
ShowNumber(addend);
}
}

/* 按键驱动函数,检测按键动作,调度相应动作函数,需在主循环中调用 */
void KeyDriver() {
unsigned char i, j;

/* 键值备份,保存前一次的矩阵按键状态 */
static unsigned char backup[4][4] = {
{1, 1, 1, 1},
{1, 1, 1, 1},
{1, 1, 1, 1},
{1, 1, 1, 1}
};

/* 循环扫描 4*4 矩阵按键 */
for (i = 0; i < 4; i++) {
for (j = 0; j < 4; j++) {

/* 检测按键动作 */
if (backup[i][j] != KeySta[i][j]) {
/* 按键按下时需要执行的任务 */
if (backup[i][j] != 0) {
KeyAction(KeyCodeMap[i][j]); // 调用按键动作函数
}
backup[i][j] = KeySta[i][j]; // 更新前一次备份的值
}
}
}
}

/* 按键扫描函数,需在定时中断中调用,推荐调用间隔1ms */
void KeyScan() {
unsigned char i;
static unsigned char keyout = 0; // 矩阵按键扫描输出索引

/* 矩阵按键扫描缓冲区 */
static unsigned char keybuf[4][4] = {
{0xFF, 0xFF, 0xFF, 0xFF},
{0xFF, 0xFF, 0xFF, 0xFF},
{0xFF, 0xFF, 0xFF, 0xFF},
{0xFF, 0xFF, 0xFF, 0xFF}
};

/* 将一行上 4 个按键的值移入缓冲区 */
keybuf[keyout][0] = (keybuf[keyout][0] << 1) | KEY_IN_1;
keybuf[keyout][1] = (keybuf[keyout][1] << 1) | KEY_IN_2;
keybuf[keyout][2] = (keybuf[keyout][2] << 1) | KEY_IN_3;
keybuf[keyout][3] = (keybuf[keyout][3] << 1) | KEY_IN_4;

/* 完成消抖之后更新按键的状态,因为每行 4 个按键,所以要循环 4 次 */
for (i = 0; i < 4; i++) {
/* 连续 4 次扫描值都为 0 ,即 16ms 内都只检测到按下状态,就认为按键已经稳定按下 */
if ((keybuf[keyout][i] & 0x0F) == 0x00) {
KeySta[keyout][i] = 0;
}
/* 连续 4 次扫描值都为 1 ,即 16ms 内都只检测到弹起状态,就认为按键已经稳定弹起 */
else if ((keybuf[keyout][i] & 0x0F) == 0x0F) {
KeySta[keyout][i] = 1;
}
}

/* 执行下一次扫描输出 */
keyout++; // 输出索引自增
keyout = keyout & 0x03; // 索引值自增到 4 以后归零

/* 根据索引值释放当前输出引脚,并拉低下次的输出引脚 */
switch (keyout) {
case 0: KEY_OUT_4 = 1; KEY_OUT_1 = 0; break;
case 1: KEY_OUT_1 = 1; KEY_OUT_2 = 0; break;
case 2: KEY_OUT_2 = 1; KEY_OUT_3 = 0; break;
case 3: KEY_OUT_3 = 1; KEY_OUT_4 = 0; break;
default: break;
}
}

/* 数码管动态扫描刷新函数,定时中断内调用 */
void LedScan() {
static unsigned char i = 0; // 动态扫描索引
P0 = 0xFF; // 数码管显示消隐

switch (i) {
case 0: ADDR2 = 0; ADDR1 = 0; ADDR0 = 0; i++; P0 = LedBuff[0]; break;
case 1: ADDR2 = 0; ADDR1 = 0; ADDR0 = 1; i++; P0 = LedBuff[1]; break;
case 2: ADDR2 = 0; ADDR1 = 1; ADDR0 = 0; i++; P0 = LedBuff[2]; break;
case 3: ADDR2 = 0; ADDR1 = 1; ADDR0 = 1; i++; P0 = LedBuff[3]; break;
case 4: ADDR2 = 1; ADDR1 = 0; ADDR0 = 0; i++; P0 = LedBuff[4]; break;
case 5: ADDR2 = 1; ADDR1 = 0; ADDR0 = 1; i = 0; P0 = LedBuff[5]; break;
default: break;
}
}

/* 定时器 T0 中断服务函数,用于按键状态扫描与消抖 */
void InterruptTimer0() interrupt 1 {
TH0 = 0xFC; TL0 = 0x67; // 定时器 T0 重新赋初值
LedScan(); // 调用数码管显示扫描函数
KeyScan(); // 调用按键扫描函数
}

步进电机

步进电机属于控制类电机,用来将脉冲信号转换成一个转动角度。当前使用的28BYJ-48是四相八拍的永磁式减速步进电机,这里的减速是指步进电机转子通过内置减速齿轮对外输出动力,28BYJ-48的减速比为1 : 64,也就是转子需要旋转64圈,外部的传动轴才会旋转1圈。

type-matrix

下面结构图当中,里圈由永磁体组成的 6 个齿(标注为0~5)称为转子,外圈缠有线圈绕组的 8 个齿并且保持不动的是定子,其正对的两个齿上的绕组相互串联,总是同时导通或关断,从而形成四相(标注为A、B、C、D)。通过循环导通A、B、C、D绕组实现转子的逆时针转动,每个四节拍转子将会转过一个定子齿的角度,八个四节拍转子就可以转动一圈,其中单个节拍使转子转过的角度\(\frac{360度}{8次\times4拍}=11.25度\)称为步进角度,上述这种工作模式就是步进电机的单相绕组通电四节拍模式,简称单四拍模式

如果在单四拍的两个节拍之间插入一个双绕组导通的中间节拍,则可以构成扭矩更大精度更高的八节拍模式,其步进角度为\(\frac{360度}{8次\times8拍}=5.625度\)。如果舍弃八拍模式中单绕组通电的四拍,而只保留双绕组通电的四拍,就可以构成双绕组通电四节拍,其步进角度虽与单四拍模式相同,但由于两个绕组同时导通,扭矩相比单四拍模式更大。生产环境当中,八节拍模式能够最大限度发挥电机的扭矩和精度,是四相步进电机的最佳工作模式。

五线四相步进电机一共拥有五条导线,其中红色导线是公共端连接至5V电源,色导线则分别对应ABCD四相,其八拍模式绕组控制顺序表可以总结如下:

导线颜色 1 拍 2 拍 3 拍 4 拍 5 拍 6 拍 7 拍 8 拍
(公共端,接5V电源) VCC VCC VCC VCC VCC VCC VCC VCC
A 相 GND GND - - - - - GND
B 相 - GND GND GND - - - -
C 相 - - - GND GND GND - -
D 相 - - - - - GND GND GND

注意:每个节拍的持续时间由步进电机的启动频率来决定,开发板使用的28BYJ-48启动频率为≥550,即单片机每秒输出大于550个步进脉冲就可以正常启动,换算成节拍持续时间就是\(\frac{1s}{550}=1.8ms\),也就是说每个节拍之后都需要延时这段时间才能保证步进电机正常工作。

当前电路当中,步进电机控制模块与 LED 控制模块的74HC138译码器共同复用单片机的P1.0 ~ P1.3引脚,通过调整跳线帽位置可以切换P1.0 ~ P1.3去控制步进电机的四个绕组。

type-matrix

另外,由于单片机 IO 接口输出电流能力较弱,所以每相控制线上都添加了 9012 三极管提高驱动能力。结合上面的电路图和八拍模式绕组控制顺序表,如果要让A相绕组导通,则三极管Q2必须导通,此时A 相对应的橙色线相当于接地,单片机P1引脚低 4 位应输出0b1110(即0xE);如要让AB相同时导通,那么就需要三极管Q2Q3导通,P1引脚低 4 位应输出0b1100(即0xC),依此类推就可以得出八拍模式下的单片机 IO 控制码数组:

1
unsigned char code BeatCode[8] = { 0xE, 0xC, 0xD, 0x9, 0xB, 0x3, 0x7, 0x6 };

接下来,开始着手编写一个五线四相步进电机在八节拍工作模式下的测试程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <reg52.h>

/* 八拍模式下的单片机 IO 控制码 */
unsigned char code BeatCode[8] = {0xE, 0xC, 0xD, 0x9, 0xB, 0x3, 0x7, 0x6};

/* 延时函数,延时约2ms */
void delay() {
unsigned int i = 200;
while (i--);
}

void main() {
unsigned char tmp; // 临时变量
unsigned char index = 0; // 节拍输出索引

while (1) {
tmp = P1; // 缓存P1引脚当前状态
tmp = tmp & 0xF0; // 采用位运算 & 清零低 4 位
tmp = tmp | BeatCode[index]; // 采用位运算 | 将节拍代码写入低4位
P1 = tmp; // 将低 4 位节拍代码与高 4 位原值发送给 P1
index++; // 节拍输出索引自增 1
index = index & 0x07; // 索引值自增到 8 以后归零
delay(); // 延时 2ms,即每 2ms 执行一拍
}
}

八拍模式下,步进电机转子转动一圈需要64个节拍,而其减速比为1:64,即转子转动64圈输出轴才会转动1圈,即步进电机输出轴转动一圈需要64 × 64 = 4096拍,其中每个节拍的步进角度为360 ÷ 4096 ≈ 0.09。步进电机的特点是可以精确控制转动幅度,因此可以让步进电机旋转多圈以后,检查其转轴是否停留在原来位置,从而确定其具体的转动精度。这里修改一下上面的程序,便于控制步进电机转动任意圈数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <reg52.h>

/* 延时函数,延时约2ms */
void delay() {
unsigned int i = 200;
while (i--);
}

/* 步进电机转动控制函数,参数 angle 表示需要转动的角度 */
void TurnMotor(unsigned long angle) {
unsigned char tmp; // 临时变量
unsigned char index = 0; // 节拍输出索引
unsigned long beats = 0; // 所需节拍总数

/* 八拍模式下的单片机 IO 控制码 */
unsigned char code BeatCode[8] = {0xE, 0xC, 0xD, 0x9, 0xB, 0x3, 0x7, 0x6};

beats = (angle * 4096) / 360; // 计算所需节拍总数,64×64=4096 拍对应一圈

/* 判断 beats 不为 0 时执行循环,然后自减 1 */
while (beats--) {
tmp = P1; // 缓存P1引脚当前状态
tmp = tmp & 0xF0; // 采用位运算 & 清零低 4 位
tmp = tmp | BeatCode[index]; // 采用位运算 | 将节拍代码写入低4位
P1 = tmp; // 将低 4 位节拍代码与高 4 位原值发送给 P1
index++; // 节拍输出索引自增 1
index = index & 0x07; // 索引值自增到 8 以后归零
delay(); // 延时 2ms,即每 2ms 执行一拍
}

P1 = P1 | 0x0F; // 关闭步进电机所有相
}

void main() {
TurnMotor(360 * 25); // 转动 25 圈
while (1);
}

上述程序执行完成之后,会发现输出轴最后停下的位置存在一定误差,经计算后实际减速比约为1 : 63.684,因此其转动一圈所需的节拍数应为64 × 63.684 ≈ 4076,如果将上面电机控制函数TurnMotor()里的4096修改为4076,就可以有效的校正这个误差。

注意:造成误差的原因在于28BYJ-48最初是设计用于控制空调扇叶,转动范围通常不超过 180 度,厂商给出近似的整数减速比1 : 64,实质上相对于这类应用场景已经足够精确。

精度问题讨论清楚以后,再将目光放回到电机控制程序。上述步进电机示例程序由于存在大段延时,从而阻塞单片机其它任务的执行,生产环境下通常会改用定时中断来进行节拍的刷新,进一步对上面的示例程序进行修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <reg52.h>

unsigned long beats = 0; // 步进电机转动总节拍数

/* 步进电机启动函数,参数 angle 表示需要转动的角度 */
void StartMotor(unsigned long angle) {
EA = 0; // 节拍计算之前先关闭中断,避免打断计算过程发生错误
beats = (angle * 4076) / 360; // 校正误差,每 4076 拍转动一圈
EA = 1; // 节拍计算完成以后再打开中断
}

void main() {
EA = 1; // 总中断使能
TMOD = 0x01; // 设置 T0 为模式 1
TH0 = 0xF8;
TL0 = 0xCD; // 定时器 T0 赋初值定时 2ms
ET0 = 1; // 定时器 T0 中断使能
TR0 = 1; // 启动定时器 T0

StartMotor(360 * 2 + 180); // 控制电机转动 2.5 圈
while (1);
}

/* T0中断服务函数,用于驱动步进电机旋转 */
void InterruptTimer0() interrupt 1 {
unsigned char tmp; // 临时变量
static unsigned char index = 0; // 节拍输出索引

/* 八拍模式下的单片机 IO 控制码 */
unsigned char code BeatCode[8] = {0xE, 0xC, 0xD, 0x9, 0xB, 0x3, 0x7, 0x6};
TH0 = 0xF8; TL0 = 0xCD; // 定时器 T0 重新赋初值

/* 如果节拍数不为 0 就产生一个驱动节拍 */
if (beats != 0) {
tmp = P1; // 缓存P1引脚当前状态
tmp = tmp & 0xF0; // 采用位运算 & 清零低 4 位
tmp = tmp | BeatCode[index]; // 采用位运算 | 将节拍代码写入低4位
P1 = tmp; // 将低 4 位节拍代码与高 4 位原值发送给 P1
index++; // 节拍输出索引自增 1
index = index & 0x07; // 索引值自增到 8 以后归零
beats--; // 总节拍数自减 1
}
/* 如果节拍数为 0 就关闭所有相 */
else {
P1 = P1 | 0x0F;
}
}

步进电机启动函数StartMotor()只负责计算总节拍数beats,然后在中断函数InterruptTimer0()当中检查该变量,如果不为0就执行节拍操作,同时对其执行自减1的操作,直至减到0为止。

需要特别说明的是StartMotor()函数里对于总中断使能EA的两次操作,在计算beats之前首先关闭总中断使能,让单片机在计算过程中不响应中断事件,待计算完成之后再重新打开。即使这个过程中定时器发生了溢出,也只能等到EA重新置1使能之后,中断服务函数InterruptTimer0()才会开始响应。这样做的原因在于,STC89C52RC单片机操作数据都是按照 8 个位来进行,处理多个字节数据(变量beats就是占用 4 字节存储空间的unsigned long类型)的时候则需要分批执行,如果这个过程中恰好发生了中断,中断服务函数InterruptTimer0()将被自动调用,而该函数会对变量beats进行自减1操作,此时自减1的结果将不会是预期的值,最终就会造成错误的发生。如果这里的beats使用char或者bit数据类型,单片机一次就可以操作完成,即使不关闭总中断也不会发生错误。

按键控制步进电机实验

本节内容的最后,结合步进电机和按键程序来完成一个综合试验:【数字键】控制电机转动1 ~ 9圈,【上下键】改变电机转动的方向,【左右键】分别正反转 90 度,【Esc 键】停止转动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
#include <reg52.h>

sbit KEY_IN_1 = P2 ^ 4;
sbit KEY_IN_2 = P2 ^ 5;
sbit KEY_IN_3 = P2 ^ 6;
sbit KEY_IN_4 = P2 ^ 7;

sbit KEY_OUT_1 = P2 ^ 3;
sbit KEY_OUT_2 = P2 ^ 2;
sbit KEY_OUT_3 = P2 ^ 1;
sbit KEY_OUT_4 = P2 ^ 0;

/* 矩阵按键编号和标准键盘键码的映射表 */
unsigned char code KeyCodeMap[4][4] = {
{0x31, 0x32, 0x33, 0x26}, // 数字键 1、数字键 2、数字键 3、向上键
{0x34, 0x35, 0x36, 0x25}, // 数字键 4、数字键 5、数字键 6、向左键
{0x37, 0x38, 0x39, 0x28}, // 数字键 7、数字键 8、数字键 9、向下键
{0x30, 0x1B, 0x0D, 0x27} // 数字键 0、ESC 键、回车键、向右键
};

/* 矩阵按键的全部状态 */
unsigned char KeySta[4][4] = {{1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}};

signed long beats = 0; // 所需的节拍总数
void KeyDriver();

void main() {
EA = 1; // 总中断使能
TMOD = 0x01; // 设置 T0 为模式 1
TH0 = 0xFC; TL0 = 0x67; // 定时器 T0 赋初值 0xFC67,定时 1ms
ET0 = 1; // 定时器 T0 中断使能
TR0 = 1; // 启动定时器 T0

while (1) {
KeyDriver(); // 按键驱动函数调用
}
}

/* 步进电机启动函数,angle-需转过的角度 */
void StartMotor(signed long angle) {
// 在计算前关闭中断,完成后再打开,以避免中断打断计算过程而造成错误
EA = 0;
beats = (angle * 4076) / 360; //实测为4076拍转动一圈
EA = 1;
}

/* 步进电机停止函数 */
void StopMotor() {
EA = 0;
beats = 0;
EA = 1;
}

/* 根据键码执行相应操作,参数 keycode 是按键键码 */
void KeyAction(unsigned char keycode) {
static bit dirMotor = 0; //电机转动方向

/* 数字键,控制电机转动 1~9 圈 */
if ((keycode >= 0x30) && (keycode <= 0x39)) {
if (dirMotor == 0)
StartMotor(360 * (keycode - 0x30));
else
StartMotor(-360 * (keycode - 0x30));
}
/* 向上键,控制转动方向为正转 */
else if (keycode == 0x26) {
dirMotor = 0;
}
/* 向下键,控制转动方向为反转 */
else if (keycode == 0x28) {
dirMotor = 1;
}
/* 向左键,正转90度 */
else if (keycode == 0x25) {
StartMotor(90);
}
/* 向右键,反转90度 */
else if (keycode == 0x27) {
StartMotor(-90);
}
/* Esc键,停止转动 */
else if (keycode == 0x1B) {
StopMotor();
}
}

/* 按键驱动函数,检测按键动作并执行相应任务,需要在主函数中循环调用 */
void KeyDriver() {
unsigned char i, j;

/* 键值备份,保存前一次的矩阵按键状态 */
static unsigned char backup[4][4] = {{1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}};

/* 循环扫描 4*4 矩阵按键 */
for (i = 0; i < 4; i++) {
for (j = 0; j < 4; j++) {

/* 检测按键动作 */
if (backup[i][j] != KeySta[i][j]) {
/* 按键按下时需要执行的任务 */
if (backup[i][j] != 0) {
KeyAction(KeyCodeMap[i][j]); // 调用按键动作函数
}
backup[i][j] = KeySta[i][j]; // 更新前一次备份的值
}
}
}
}

/* 按键扫描函数,需要在定时中断函数中调用,推荐调用间隔 1ms */
void KeyScan() {
unsigned char i;
static unsigned char keyout = 0; // 矩阵按键扫描输出索引

/* 矩阵按键扫描缓冲区 */
static unsigned char keybuf[4][4] = {{0xFF, 0xFF, 0xFF, 0xFF}, {0xFF, 0xFF, 0xFF, 0xFF}, {0xFF, 0xFF, 0xFF, 0xFF}, {0xFF, 0xFF, 0xFF, 0xFF}};

/* 将一行上 4 个按键的值移入缓冲区 */
keybuf[keyout][0] = (keybuf[keyout][0] << 1) | KEY_IN_1;
keybuf[keyout][1] = (keybuf[keyout][1] << 1) | KEY_IN_2;
keybuf[keyout][2] = (keybuf[keyout][2] << 1) | KEY_IN_3;
keybuf[keyout][3] = (keybuf[keyout][3] << 1) | KEY_IN_4;

/* 完成消抖之后更新按键的状态,因为每行 4 个按键,所以要循环 4 次 */
for (i = 0; i < 4; i++) {
/* 连续 4 次扫描值都为 0 ,即 16ms 内都只检测到按下状态,就认为按键已经稳定按下 */
if ((keybuf[keyout][i] & 0x0F) == 0x00) {
KeySta[keyout][i] = 0;
}
/* 连续 4 次扫描值都为 1 ,即 16ms 内都只检测到弹起状态,就认为按键已经稳定弹起 */
else if ((keybuf[keyout][i] & 0x0F) == 0x0F) {
KeySta[keyout][i] = 1;
}
}

/* 执行下一次扫描输出 */
keyout++; // 输出索引自增
keyout = keyout & 0x03; // 索引值自增到 4 以后归零

/* 根据索引值释放当前输出引脚,并拉低下次的输出引脚 */
switch (keyout) {
case 0: KEY_OUT_4 = 1; KEY_OUT_1 = 0; break;
case 1: KEY_OUT_1 = 1; KEY_OUT_2 = 0; break;
case 2: KEY_OUT_2 = 1; KEY_OUT_3 = 0; break;
case 3: KEY_OUT_3 = 1; KEY_OUT_4 = 0; break;
default: break;
}
}

/* 电机转动控制函数 */
void TurnMotor() {
unsigned char tmp; // 临时变量
static unsigned char index = 0; // 节拍输出索引

/* 八拍模式下的单片机 IO 控制码 */
unsigned char code BeatCode[8] = {0xE, 0xC, 0xD, 0x9, 0xB, 0x3, 0x7, 0x6};

/* 如果节拍数不为 0 就产生一个驱动节拍 */
if (beats != 0) {

/* 节拍数大于 0 时正转 */
if (beats > 0) {
index++; // 正转时节拍输出索引递增
index = index & 0x07; // 索引值自增到 8 以后归零
beats--; // 正转时节拍计数递减
}
/* 节拍数小于 0 时反转 */
else {
index--; // 反转时节拍输出索引递减
index = index & 0x07; // 索引值自增到 -1 以后置为 7
beats++; // 反转时节拍计数递增
}

tmp = P1; // 缓存P1引脚当前状态
tmp = tmp & 0xF0; // 采用位运算 & 清零低 4 位
tmp = tmp | BeatCode[index]; // 采用位运算 | 将节拍代码写入低4位
P1 = tmp; // 将低 4 位节拍代码与高 4 位原值发送给 P1
}
/* 如果节拍数为 0 就关闭步进电机所有相 */
else {
P1 = P1 | 0x0F;
}
}

/* T0中断服务函数,用于按键扫描与电机转动控制 */
void InterruptTimer0() interrupt 1 {
static bit div = 0; // 定义一个静态的 bit 类型变量

TH0 = 0xFC; TL0 = 0x67; // 定时器 T0 重新赋初值

KeyScan(); // 调用按键扫描函数

/* 使用变量 div 实现二分频,即 2ms 的定时,用于控制步进电机 */
div = ~div;
if (div == 1) {
TurnMotor(); // 调用电机驱动函数
}
}

上面代码中,电机的正转和反转,并没有通过建立不同的函数来区分,而是通过将步进电机启动函数void StartMotor(signed long angle)中形式参数angle的数据类型从unsigned long调整为signed long来进行区分,即通过有符号数据类型固有的正负特性来区分正反转,正数表示正转angle度,负数表示反转angle度,这样处理起来简单明了。

另外,由于中断函数中需要处理按键扫描电机驱动两件事情,为避免中断函数编写过于复杂,上面代码中将这两个功能分离为两个独立的函数。这里还有一个值得注意的问题,按键扫描采用的定时时间是1ms,而本实验之前代码中步进电机节拍的持续时间都是2ms。显然采用1ms的定时可以得到2ms的间隔,而采用2ms定时却不能得到准确的1ms间隔;因此上面代码中,定时器选择定时1ms,然后使用一个bit类型的变量做为标志,每1ms改变一次它的值,但只选择当其值为1时执行一次操作,这样就可以得到2ms间隔的效果;如果需要3ms4ms...等更长的间隔,就可以考虑将bit更换为char或者int类型,然后再对其进行递增操作。

蜂鸣器

蜂鸣器常用于电子设备发出提示音,按照驱动方式可以分为有源无源两种,这里的并非指电源,而是指振荡源。有源蜂鸣器内部自带振荡源,通电使能以后就会发出震荡源对应的声响。无源蜂鸣器本身不带振荡源,需要向其施加500Hz ~ 4.5KHz之间的脉冲频率进行驱动才会发出声音。

上面电路图当中,依然采用了 9012 三极管来驱动蜂鸣器,并添加了一枚100ΩR7电阻作为限流电阻。此外还使用了一枚型号为 4148 的二极管D4作为续流二极管。如果三极管导通蜂鸣器上电,电流就会经过蜂鸣器,电感的电流不能突变,导通时电流逐渐加大,可以正常工作。但是在关断时,电源 - 三极管 - 蜂鸣器 - 接地这条回路被截断,导致电流无法通过,储存的电流就经过这个续流二极管D4和蜂鸣器自身的回路消耗掉了,从而避免关断时由于电感的电流造成反向冲击,接续关断时的电流,这就是续流二极管称谓的由来。下面将通过编写程序,来完成一个4 kHZ以及1 kHZ频率下的无源蜂鸣器的发声实验。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <reg52.h>

sbit BUZZ = P1 ^ 6; // 蜂鸣器的单片机控制引脚
unsigned char T0RH = 0; // 定时器 T0 重载值高字节
unsigned char T0RL = 0; // 定时器 T0 重载值低字节

/* 蜂鸣器启动函数,参数 frequ 表示工作频率 */
void OpenBuzz(unsigned int frequ) {
unsigned int reload; // 计算所需的定时器重载值
reload = 65536 - (11059200 / 12) / (frequ * 2); // 由给定的频率计算出定时器的重载值

/* 将 16 位的重载值分解为高、低两个字节 */
T0RH = (unsigned char)(reload >> 8);
T0RL = (unsigned char)reload;

TH0 = 0xFF;
TL0 = 0xFE; // 设置一个接近溢出的初始值,让定时器马上开始工作
ET0 = 1; // 使能定时器 T0 中断
TR0 = 1; // 启动定时器 T0
}

/* 蜂鸣器停止函数 */
void StopBuzz() {
ET0 = 0; // 禁用定时器 T0 中断
TR0 = 0; // 停止定时器 T0 运行
}

void main() {
unsigned int i;

TMOD = 0x01; // 配置T0工作在模式1,但先不启动
EA = 1; // 使能全局中断

while (1) {
OpenBuzz(4000); // 以 4KHz 频率驱动蜂鸣器
for (i = 0; i < 40000; i++);
StopBuzz(); // 停止蜂鸣器
for (i = 0; i < 40000; i++);
OpenBuzz(1000); // 以 1KHz 频率驱动蜂鸣器
for (i = 0; i < 40000; i++);
StopBuzz(); // 停止蜂鸣器
for (i = 0; i < 40000; i++);
}
}

/* 定时器 T0 中断服务函数,用于控制蜂鸣器发声 */
void InterruptTimer0() interrupt 1 {
TH0 = T0RH;
TL0 = T0RL; // 重新加载重载值
BUZZ = ~BUZZ; // 反转蜂鸣器控制电平状态
}

高精度数字秒表

定时器中断的精度补偿

单片机从正常运行状态进入中断,通常需要耗费几个机器周期时间,去完成一些场景保存方面的工作;进入中断以后,重新为定时值存储寄存器THTL赋值,同样需要花费几个机器周期时间;此外,硬件问题也会影响到单片机系统的时钟精度,比如晶振的精度会随着温度的变化而发生【温漂】现象,这样就造成了一些不可避免的误差,需要进行相应的补偿。

  • 使用【Debug】模式计算补偿值:进入Keil uVision提供的【Debug】模式计算两次进入中断的时间间隔,观察与实际定时相差的机器周期时间,然后在定时器赋初值时补偿相应的机器周期时间。
  • 通过累计误差进行计算:让时钟运行一段相对比较长的时间,观察最终时间与实际时间的误差,然后计算进入定时器中断的次数,将误差的时间平均分配到每次定时器中断,从而完成误差的校正。

精确是一个相对的概念,因此只能在一定程度上提高精度,但是永远不能使误差为零。后续小节将要介绍的DS1302实时时钟芯片,其计时精度相对单片机内置的定时器更高。

不可位寻址寄存器的位操作

另外,对于诸如TMOD这样的不支持位寻址的寄存器,如果需要对指定的位进行赋值,而又不想影响其它位的状态,这种情况下可以考虑采用位运算&|来完成赋值。无论该位的初始值是0还是1,跟0进行与运算&得到的结果都是0,跟1进行与运算&得到的结果是初始值本身。与之相对应,无论该位的初始值是0还是1,跟1进行或运算|得到的结果都是1,跟0进行或运算|得到的结果是初始值本身。

例如现在要设置TMOD使定时器 T0工作在模式 1,而又不希望对同一寄存器上的定时器 T1配置造成干扰,那么可以通过TMOD = TMOD & 0xF0; TMOD = TMOD | 0x01;语句达成目的。这段代码中,首先和0xF0进行与运算&,高四位不变低四位被清零,得到的结果为0bxxxx0000。然后再同0x01进行或运算|,此时高七位不变最低一位变成1,得到的结果为0bxxxx0001,这样就达成了将低四位值修改为0b0001,而高四位保持原值不变0bxxxx的目的,即只对定时器 T0进行配置,而不会影响定时器 T1

改进数码管扫描函数

前面小节的内容当中,数码管的动态扫描函数采用了下面的switch()语句来完成:

1
2
3
4
5
6
7
8
9
10
11
P0 = 0xFF;

switch (i) {
case 0: ADDR2=0; ADDR1=0; ADDR0=0; i++; P0=LedBuff[0]; break;
case 1: ADDR2=0; ADDR1=0; ADDR0=1; i++; P0=LedBuff[1]; break;
case 2: ADDR2=0; ADDR1=1; ADDR0=0; i++; P0=LedBuff[2]; break;
case 3: ADDR2=0; ADDR1=1; ADDR0=1; i++; P0=LedBuff[3]; break;
case 4: ADDR2=1; ADDR1=0; ADDR0=0; i++; P0=LedBuff[4]; break;
case 5: ADDR2=1; ADDR1=0; ADDR0=1; i=0; P0=LedBuff[5]; break;
default: break;
}

上面代码里每个case分支的结构都是相同的,即首先来修改ADDR2ADDR1ADDR0这三个74HC138译码器的输入端,然后让索引变量i自增1,最后将缓冲区的数据写入P0。仔细分析代码可以发现,case后的选择条件常量与ADDRx以及LedBuff的下标相等,因此可以考虑直接将条件常量赋值给它们,而不必再使用冗长的switch()语句。而对于索引变量i,一共进行了五次自增和一次归零运算,因此可以使用自增运算符++以及if判断语句来实现。由于ADDR2ADDR1ADDR0端通过跳线帽与单片机的P1.2P1.1P1.0引脚相连,改进后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
P0 = 0xFF;
P1 = (P1 & 0xF8) | i;
P0 = LedBuff[i];

if (i < 5) {
i++;
}
else {
i = 0;
}

编写秒表实验程序

接下来,结合上述的改进内容,综合应用定时器、数码管、中断、按键,完成一个实用的秒表程序,在计数保留到小数点后两位(10ms完成一次计数)的同时,对定时器中断延时所造成的误差进行补偿。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
#include <reg52.h>

sbit ADDR3 = P1 ^ 3;
sbit ENLED = P1 ^ 4;
sbit KEY1 = P2 ^ 4;
sbit KEY2 = P2 ^ 5;
sbit KEY3 = P2 ^ 6;
sbit KEY4 = P2 ^ 7;

/* 数码管显示字符编码表 */
unsigned char code LedChar[] = {0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8,
0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E};

/* 数码管显示缓冲区 */
unsigned char LedBuff[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};

/* 矩阵按键的全部状态 */
unsigned char KeySta[4] = {1, 1, 1, 1};

bit StopwatchRunning = 0; // 秒表运行标志
bit StopwatchRefresh = 1; // 秒表刷新标志
unsigned char DecimalPart = 0; // 秒表小数部分
unsigned int IntegerPart = 0; // 秒表整数部分
unsigned char T0RH = 0; // 定时器 T0 重载值高字节
unsigned char T0RL = 0; // 定时器 T0 重载值低字节

void ConfigTimer0(unsigned int ms);
void StopwatchDisplay();
void KeyDriver();

void main() {
EA = 1; // 总中断使能
ENLED = 0;
ADDR3 = 1;
P2 = 0xFE; // 选择第 4 行按键作为独立按键
ConfigTimer0(2); // 配置定时器 T0 去定时 2ms

while (1) {
/* 如果秒表当前没有刷新,就调用秒表计数显示函数*/
if (StopwatchRefresh) {
StopwatchRefresh = 0;
StopwatchDisplay();
}
KeyDriver(); // 调用按键驱动函数
}
}

/* 配置并启动定时器 T0,参数 ms 表示需要定时的时间 */
void ConfigTimer0(unsigned int ms) {
unsigned long tmp; // 临时变量

tmp = 11059200 / 12; // 定时器计数频率
tmp = (tmp * ms) / 1000; // 计算所需计数值
tmp = 65536 - tmp; // 计算定时器重载值
tmp = tmp + 18; // 补偿中断响应延时造成的误差
T0RH = (unsigned char)(tmp >> 8); // 定时器重载值拆分为高低字节
T0RL = (unsigned char)tmp;

TMOD &= 0xF0; // 清零定时器 T0 控制位
TMOD |= 0x01; // 配置定时器 T0 为工作模式 1
TH0 = T0RH; TL0 = T0RL; // 加载定时器 T0 重载值
ET0 = 1; // 使能定时器 T0 中断
TR0 = 1; // 启动定时器 T0
}

/* 秒表计数显示函数 */
void StopwatchDisplay() {
signed char i;
unsigned char buf[4]; // 数据转换缓冲区

/* 小数部分转换到低 2 位 */
LedBuff[0] = LedChar[DecimalPart % 10];
LedBuff[1] = LedChar[DecimalPart / 10];

/* 整数部分转换到高 4 位 */
buf[0] = IntegerPart % 10;
buf[1] = (IntegerPart / 10) % 10;
buf[2] = (IntegerPart / 100) % 10;
buf[3] = (IntegerPart / 1000) % 10;

/* 将整数部分高位的 0 转换为空字符 */
for (i = 3; i >= 1; i--) {
if (buf[i] == 0)
LedBuff[i + 2] = 0xFF;
else
break;
}

/* 将有效数字位转换为数码管要显示的字符 */
for (; i >= 0; i--) {
LedBuff[i + 2] = LedChar[buf[i]];
}

LedBuff[2] &= 0x7F; // 点亮数码管的小数点
}

/* 秒表启停函数,如果启动就停止,如果停止就启动 */
void StopwatchAction() {
if (StopwatchRunning)
StopwatchRunning = 0;
else
StopwatchRunning = 1;
}

/* 秒表复位函数 */
void StopwatchReset() {
StopwatchRunning = 0; // 停止秒表
DecimalPart = 0; // 清零计数值
IntegerPart = 0;
StopwatchRefresh = 1; // 重置刷新标志位
}

/* 按键驱动函数,用于检测按键动作并调用相应的动作函数 */
void KeyDriver() {
unsigned char i;
static unsigned char backup[4] = {1, 1, 1, 1};

/* 循环检测 4 个按键 */
for (i = 0; i < 4; i++) {
if (backup[i] != KeySta[i]) {
/* 如果按键已经按下 */
if (backup[i] != 0) {
/* Esc键复位秒表 */
if (i == 1)
StopwatchReset();
/* 回车键启停秒表 */
else if (i == 2)
StopwatchAction();
}
backup[i] = KeySta[i]; // 刷新前一次的备份值
}
}
}

/* 按键扫描函数,需在定时中断中调用 */
void KeyScan() {
unsigned char i;
static unsigned char keybuf[4] = {0xFF, 0xFF, 0xFF, 0xFF}; //按键扫描缓冲区

/* 将按键值移入缓冲区 */
keybuf[0] = (keybuf[0] << 1) | KEY1;
keybuf[1] = (keybuf[1] << 1) | KEY2;
keybuf[2] = (keybuf[2] << 1) | KEY3;
keybuf[3] = (keybuf[3] << 1) | KEY4;

/* 按键消抖处理以后更新状态 */
for (i = 0; i < 4; i++) {
/* 连续 8 次扫描值都为 0 ,即 16ms 内都处于按下状态时,就认为按键已经稳定的按下 */
if (keybuf[i] == 0x00) {
KeySta[i] = 0;
}
/* 连续 8 次扫描值都为 1 ,即 16ms 内都处于弹起状态时,就认为按键已经稳定的弹起 */
else if (keybuf[i] == 0xFF) {
KeySta[i] = 1;
}
}
}

/* 数码管动态扫描刷新函数,定时中断内调用 */
void LedScan() {
static unsigned char i = 0; // 动态扫描索引

P0 = 0xFF; // 关闭所有数码管段选进行消隐
P1 = (P1 & 0xF8) | i; // 将位选索引值赋值给 P1 的低 3 位
P0 = LedBuff[i]; // 将缓冲区指定索引位置的数据赋值给 P0

/* 遍历整个缓冲区 */
if (i < 5)
i++;
else
i = 0;
}

/* 秒表计数函数,每隔 10ms 调用一次,进行秒表计数累加 */
void StopwatchCount() {
/* 处于运行状态时递增计数值 */
if (StopwatchRunning) {
DecimalPart++; // 小数部分自增 1

/* 小数部分满 100 进位到整数部分 */
if (DecimalPart >= 100) {
DecimalPart = 0;
IntegerPart++; // 整数部分自增 1

/* 整数部分满 10000 进行归零 */
if (IntegerPart >= 10000) {
IntegerPart = 0;
}
}
StopwatchRefresh = 1; // 重置秒表计数刷新标志位
}
}

/* T0中断服务函数,用于执行数码管显示、按键扫描、秒表计数 */
void InterruptTimer0() interrupt 1 {
static unsigned char tmr10ms = 0;

TH0 = T0RH; TL0 = T0RL; // 定时值存储寄存器重新赋初值
LedScan(); // 数码管扫描
KeyScan(); // 按键扫描

/* 定时10ms进行一次秒表计数 */
tmr10ms++;
if (tmr10ms >= 5) {
tmr10ms = 0;
StopwatchCount(); // 调用秒表计数函数
}
}

注意上面代码中,将定时器相关的配置抽象为了一个可复用的函数,后面再遇到类似需要定时指定毫秒数的场景,就可以直接以毫秒数作为参数调用该函数即可。由于秒表需要的按键数量不多,所以代码中没有使用到矩阵按键,而是将矩阵按键第 4 行复用为了独立按键,尽量简化问题的处理。

脉冲宽度调制 PWM

PWM脉冲宽度调制(Pulse Width Modulation)的英文缩写,意思是通过改变单片机输出脉冲的宽度来实现特定的功能,比如使用数字信号来控制模拟电路。

上图表达的是一个周期为10ms频率为100Hz的波形,但是每个周期内的高低电平脉冲宽度并不相同,这正是PWM的本质。这里要注意一个占空比的概念,所谓占空比就是指高电平时间占据整个周期的比例。例如上图第一部分波形的占空比为4ms ÷ (4ms + 6ms) = 40%,第二部分波形的占空比是6ms ÷ (6ms + 4ms) = 60%,第三部分波形的占空比是8ms ÷ (8ms + 2ms) = 80%

第 3 小节点亮 LED 的程序当中,单片机输出低电平 LED 就会长亮,反之输出高电平 LED 就会熄灭。如果调整 LED 亮灭状态切换的间隔时间到肉眼无法分辨(大于100Hz)的程度,就可以基于 PWM 的原理完成对 LED 亮度的控制。接下来通过定时器 T0 改变P0.0引脚的输出,从而实现对 LED 的 PWM 控制。注意每个周期都需要重载两次定时器初值,从而控制高低电平的不同持续时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
#include <reg52.h>

sbit PWMOUT = P0 ^ 0;
sbit ADDR0 = P1 ^ 0;
sbit ADDR1 = P1 ^ 1;
sbit ADDR2 = P1 ^ 2;
sbit ADDR3 = P1 ^ 3;
sbit ENLED = P1 ^ 4;

/* 高电平定时值高低字节 */
unsigned char HighRH = 0;
unsigned char HighRL = 0;

/* 低电平定时值高低字节 */
unsigned char LowRH = 0;
unsigned char LowRL = 0;

/* 配置并启动 PWM,参数 fr 是频率,参数 dc 是占空比 */
void ConfigPWM(unsigned int fr, unsigned char dc) {
unsigned int high, low;
unsigned long tmp;
tmp = (11059200 / 12) / fr; // 计算一个机器周期所需要的计数值

high = (tmp * dc) / 100; // 计算高电平所需要的计数值
low = tmp - high; // 计算低电平所需要的计数值
high = 65536 - high + 12; // 计算高电平的重载值并补偿中断延时
low = 65536 - low + 12; // 计算低电平的重载值并补偿中断延时

/* 高电平重载值拆分为高低字节 */
HighRH = (unsigned char)(high >> 8);
HighRL = (unsigned char)high;

/* 低电平重载值拆分为高低字节 */
LowRH = (unsigned char)(low >> 8);
LowRL = (unsigned char)low;

TMOD &= 0xF0; // 清零定时器 T0 控制位
TMOD |= 0x01; // 设置 T0 为工作模式 1
TH0 = HighRH; TL0 = HighRL; // 赋予定时器 T0 定时值
ET0 = 1; // 定时器 T0 中断使能
TR0 = 1; // 启动定时器 T0
PWMOUT = 1; // 输出高电平
}

/* 关闭PWM */
void ClosePWM() {
TR0 = 0; // 停止定时器
ET0 = 0; // 禁止中断
PWMOUT = 1; // 输出高电平
}

void main() {
unsigned int i;

EA = 1; // 使能总中断
ENLED = 0; // 使能 LED
ADDR3 = 1;
ADDR2 = 1;
ADDR1 = 1;
ADDR0 = 0;

while (1) {
ConfigPWM(100, 10); // 频率100Hz,占空比10%
for (i = 0; i < 40000; i++);
ClosePWM();

ConfigPWM(100, 40); // 频率100Hz,占空比40%
for (i = 0; i < 40000; i++);
ClosePWM();

ConfigPWM(100, 90); // 频率100Hz,占空比90%
for (i = 0; i < 40000; i++);
ClosePWM(); // 关闭 PWM,即占空比100%

for (i = 0; i < 40000; i++);
}
}

/* 定时器 T0 中断服务函数,用于产生 PWM 输出 */
void InterruptTimer0() interrupt 1 {
/* 当前输出为高电平时,赋予低电平定时值并输出低电平 */
if (PWMOUT == 1) {
TH0 = LowRH; TL0 = LowRL;
PWMOUT = 0;
}
/* 当前输出为低电平时,赋予高电平定时值并输出高电平 */
else {
TH0 = HighRH; TL0 = HighRL;
PWMOUT = 1;
}
}

注意:STC89C52RC单片机内部没有集成 PWM 模块,所以上面代码采用定时器中断的方式来产生 PWM,现代单片机大部份已经集成了硬件 PWM 模块,因此仅仅需要计算周期计数值、占空比计数值,然后配置到相关特殊功能寄存器当中即可,这样既大幅度简化了程序,又消除了中断延时的影响,确保了 PWM 信号的输出品质。

将上面程序编译并下载到单片机实验电路以后,会观察到 LED 被分为 4 个亮度等级。如果这样的亮度等级更加丰富并且发光连续起来,就可以产生一个 LED 亮度渐变的呼吸灯效果,接下来的代码里将会同时使用到 2 个定时器中断来进行如下实验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
#include <reg52.h>

sbit PWMOUT = P0 ^ 0;
sbit ADDR0 = P1 ^ 0;
sbit ADDR1 = P1 ^ 1;
sbit ADDR2 = P1 ^ 2;
sbit ADDR3 = P1 ^ 3;
sbit ENLED = P1 ^ 4;

unsigned long PeriodCnt = 0; // PWM周期计数值

/* 高电平定时值高低字节 */
unsigned char HighRH = 0;
unsigned char HighRL = 0;

/* 低电平定时值高低字节 */
unsigned char LowRH = 0;
unsigned char LowRL = 0;

/* 定时器 T1 的定时值高低字节 */
unsigned char T1RH = 0;
unsigned char T1RL = 0;

void ConfigTimer1(unsigned int ms);
void ConfigPWM(unsigned int fr, unsigned char dc);

void main() {
EA = 1; // 使能总中断
ENLED = 0; // 使能 LED
ADDR3 = 1;
ADDR2 = 1;
ADDR1 = 1;
ADDR0 = 0;

ConfigPWM(100, 10); // 配置并启动PWM
ConfigTimer1(50); // 调用定时器 T1 调整占空比
while (1)
;
}

/* 配置并启动定时器 T1,参数 ms 是定时时间 */
void ConfigTimer1(unsigned int ms) {
unsigned long tmp; //临时变量

tmp = 11059200 / 12; // 定时器计数频率
tmp = (tmp * ms) / 1000; // 计算需要的计数值
tmp = 65536 - tmp; // 计算定时器重载值
tmp = tmp + 12; // 补偿中断响应延时造成的误差

T1RH = (unsigned char)(tmp >> 8); // 定时器重载值拆分为高低字节
T1RL = (unsigned char)tmp;

TMOD &= 0x0F; // 清零定时器 T1 控制位
TMOD |= 0x10; // 配置定时器 T1 为工作模式 1
TH1 = T1RH; TL1 = T1RL; // 定时器 T1 重新赋初值
ET1 = 1; // 使能定时器 T1 中断
TR1 = 1; // 启动定时器 T1
}

/* 配置并启动PWM,参数 fr 表示频率,参数 dc 表示占空比 */
void ConfigPWM(unsigned int fr, unsigned char dc) {
unsigned int high, low;
PeriodCnt = (11059200 / 12) / fr; // 计算一个周期所需的计数值

high = (PeriodCnt * dc) / 100; // 计算高电平所需的计数值
low = PeriodCnt - high; // 计算低电平所需的计数值
high = 65536 - high + 12; // 计算高电平的定时器重载值并补偿中断延时
low = 65536 - low + 12; // 计算低电平的定时器重载值并补偿中断延时

/* 高电平重载值拆分为高低字节 */
HighRH = (unsigned char)(high >> 8);
HighRL = (unsigned char)high;

/* 低电平重载值拆分为高低字节 */
LowRH = (unsigned char)(low >> 8);
LowRL = (unsigned char)low;

TMOD &= 0xF0; // 清零定时器 T0 控制位
TMOD |= 0x01; // 配置定时器 T0 为工作模式 1
TH0 = HighRH; TL0 = HighRL; // 赋予定时器 T0 定时值
ET0 = 1; // 定时器 T0 中断使能
TR0 = 1; // 启动定时器 T0
PWMOUT = 1; // 输出高电平
}

/* 占空比调整函数,频率不变只调整占空比 */
void AdjustDutyCycle(unsigned char dc) {
unsigned int high, low;

high = (PeriodCnt * dc) / 100; // 计算高电平所需要的计数值
low = PeriodCnt - high; // 计算低电平所需要的计数值
high = 65536 - high + 12; // 计算高电平的重载值并补偿中断延时
low = 65536 - low + 12; // 计算低电平的重载值并补偿中断延时

/* 高电平重载值拆分为高低字节 */
HighRH = (unsigned char)(high >> 8);
HighRL = (unsigned char)high;

/* 低电平重载值拆分为高低字节 */
LowRH = (unsigned char)(low >> 8);
LowRL = (unsigned char)low;
}

/* T0中断服务函数,用于产生PWM输出 */
void InterruptTimer0() interrupt 1 {
/* 当前输出为高电平时,装载低电平值并输出低电平 */
if (PWMOUT == 1) {
TH0 = LowRH; TL0 = LowRL;
PWMOUT = 0;
}
/* 当前输出为低电平时,装载高电平值并输出高电平 */
else {
TH0 = HighRH; TL0 = HighRL;
PWMOUT = 1;
}
}

/* 定时器 T1 中断服务函数,动态定时调整占空比 */
void InterruptTimer1() interrupt 3 {
static bit dir = 0;
static unsigned char index = 0;

/* 占空比调整表 */
unsigned char code table[13] = {5, 18, 30, 41, 51, 60, 68, 75, 81, 86, 90, 93, 95};

TH1 = T1RH; TL1 = T1RL; // 定时器 T1 重新赋初值

AdjustDutyCycle(table[index]); // 调整 PWM 占空比

/* 逐步增加占空比 */
if (dir == 0) {
index++;
if (index >= 12) {
dir = 1;
}
}
/* 逐步缩小占空比 */
else {
index--;
if (index == 0) {
dir = 0;
}
}
}

RAM 与长短按键

数据存储空间 RAM 的划分

STC89C52RC的 512 字节 RAM 用来保存数据,程序中定义的变量都保存在 RAM 当中,标准 51 架构单片机的 RAM 是分块的,物理结构和使用方法上有一定区别。STC89C52RC将 RAM 存储空间划分为片内256Byte)和片外256Byte)两部分,标准 51 架构片内 RAM 地址从0x00 ~ 0x7F总共128Byte,而STC89C52RC将片内 RAM 从0x00 ~ 0xFF扩展为 256 个字节。而片外 RAM 则最大可以扩展至0x0000 ~ 0xFFFF共计64KByte。下面是 8051 C 语言当中的几个关键字,用于将声明的变量划分到不同的 RAM 数据存储区域:

  • data片内 RAM是从0x00~0x7F
  • idata片内 RAM是从0x00~0xFF
  • pdata片外 RAM是从0x00~0xFF
  • xdata片外 RAM是从0x0000~0xFFFF

从上面列表可以看出,片内的dataidata一部分,而片外的pdataxdata一部分。在 8051 C 语言当中,声明的变量默认是data类型,RAM 的data区域在汇编语言中使用直接寻址进行访问,执行速度较快。如果显式定义为idata,不仅可以访问data区域,还可以访问0x80H ~ 0xFF范围的存储空间,此时汇编语言中使用的是通用寄存器间接寻址,速度相对data慢一些。由于0x80H ~ 0xFF区域通常用于中断与函数调用堆栈,大多数情况下,使用内部 RAM 时只用到data区域就可以了。

外部 RAM 当中,使用pdata可以将变量存储到外部 RAM 的0x00 ~ 0xFF地址范围,这块地址与idata一样都采用通用寄存器间接寻址,而如果定义为xdata,则可以访问全部64KByte存储空间,但这里需要额外使用 2 个字节的寄存器DPTRHDPTRL来进行间接寻址,访问速度最慢。

片内和片外的区分来自早期 51 架构单片机,现在的 51 芯片已经将两者都集成到了芯片内部。

长短按键试验

之前的按键相关实验当中,按下一次按键就可以执行加1或者减1操作,如果想连续执行同样动作,这样的操作就显得极为不便。如果能一直按住按键不松开,就能完成数值的持续增加或者减小,这样操作就显得更加便捷,这也就是本小节内容将要介绍的长短按键实验。其原理是当检测到按键产生按下的动作之后,立刻执行一次对应操作,同时在程序当中将按键按下的持续时间缓存起来,当这个时间超过1s秒以后(用于区分长/短按两个动作,短按持续时间通常会达到几百毫秒),每间隔200ms毫秒(如果想更快可以选择更短时间)就再自动执行一次该按键对应的操作。

基于上述原理完成这样的实验:单片机上电以后数码管显示数字0,按向上键数字加1,按向下键数字减1,长按向上键1s秒后数字持续增加,长按向下键1s秒后数字持续减小。数字设定完毕按下回车按键就会开始进行倒计时,倒计时归0以后,蜂鸣器会持续发声,并且 8 枚 LED 将会全部点亮。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
#include <reg52.h>

sbit BUZZ = P1 ^ 6;
sbit ADDR3 = P1 ^ 3;
sbit ENLED = P1 ^ 4;

sbit KEY_IN_1 = P2 ^ 4;
sbit KEY_IN_2 = P2 ^ 5;
sbit KEY_IN_3 = P2 ^ 6;
sbit KEY_IN_4 = P2 ^ 7;

sbit KEY_OUT_1 = P2 ^ 3;
sbit KEY_OUT_2 = P2 ^ 2;
sbit KEY_OUT_3 = P2 ^ 1;
sbit KEY_OUT_4 = P2 ^ 0;

/* 数码管显示字符编码表 */
unsigned char code LedChar[] = {0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8, 0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E};

/* 数码管显示缓冲区 */
unsigned char LedBuff[7] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};

/* 矩阵按键编号和标准键盘键码的映射表 */
unsigned char code KeyCodeMap[4][4] = {
{0x31, 0x32, 0x33, 0x26}, // 数字键 1、数字键 2、数字键 3、向上键
{0x34, 0x35, 0x36, 0x25}, // 数字键 4、数字键 5、数字键 6、向左键
{0x37, 0x38, 0x39, 0x28}, // 数字键 7、数字键 8、数字键 9、向下键
{0x30, 0x1B, 0x0D, 0x27} // 数字键 0、ESC 键、回车键、向右键
};

/* 矩阵按键的全部状态 */
unsigned char KeySta[4][4] = {{1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}};

/* 按键按下的持续时间,单位为毫秒 */
unsigned long pdata KeyDownTime[4][4] = {{0, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}};

bit enBuzz = 0; // 蜂鸣器使能
bit flag1s = 0; // 定时 1 秒标志位
bit flagStart = 0; // 倒计时启动标志位
unsigned char T0RH = 0; // 定时器 T0 重载值高字节
unsigned char T0RL = 0; // 定时器 T0 重载值低字节
unsigned int CountDown = 0; // 倒计时计数

/* 函数声明 */
void ConfigTimer0(unsigned int ms);
void ShowNumber(unsigned long num);
void KeyDriver();

void main() {
EA = 1; // 使能总中断
ENLED = 0; // 选中LED与数码管
ADDR3 = 1;

ConfigTimer0(1); // 定时器 T0 定时 1 毫秒
ShowNumber(0); // 上电以后数码管默认显示 0

while (1) {
KeyDriver(); // 调用按键驱动函数

/* 如果倒计时启动,并且经历 1 秒定时时间之后 */
if (flagStart && flag1s) {
flag1s = 0;
/* 如果倒计时还未归 0 */
if (CountDown > 0) {
CountDown--;
ShowNumber(CountDown); // 数码管显示倒计时计数
/* 如果倒计时已经归 0 */
if (CountDown == 0) {
enBuzz = 1; // 蜂鸣器发声
LedBuff[6] = 0x00; // 点亮 LED
}
}
}
}
}

/* 配置并启动定时器 T0,参数 ms 表示定时时间 */
void ConfigTimer0(unsigned int ms) {
unsigned long tmp; //临时变量

tmp = 11059200 / 12; // 定时器计数频率
tmp = (tmp * ms) / 1000; // 计算所需计数值
tmp = 65536 - tmp; // 计算定时器重载值
tmp = tmp + 28; // 补偿中断响应延时造成的误差
T0RH = (unsigned char)(tmp >> 8); // 定时器重载值拆分为高低字节
T0RL = (unsigned char)tmp;

TMOD &= 0xF0; // 清零定时器 T0 控制位
TMOD |= 0x01; // 配置定时器 T0 为工作模式 1
TH0 = T0RH; TL0 = T0RL; // 加载定时器 T0 重载值
ET0 = 1; // 使能定时器 T0 中断
TR0 = 1; // 启动定时器 T0
}

/* 数码管上显示一个无符号长整型数值,参数 num 表示要显示的数字 */
void ShowNumber(unsigned long num) {
signed char i;
unsigned char buf[6];

/* 将长整型数值转换为 6 位十进制数组 */
for (i = 0; i < 6; i++) {
buf[i] = num % 10;
num = num / 10;
}

/* 从最高位开始,如果遇到 0 就转换为空格,非 0 则退出循环 */
for (i = 5; i >= 1; i--) {
if (buf[i] == 0)
LedBuff[i] = 0xFF;
else
break;
}

/* 剩下的低位原样转换为数码管要显示的字符 */
for (; i >= 0; i--) {
LedBuff[i] = LedChar[buf[i]];
}
}

/* 根据键码执行相应操作,参数 keycode 是按键键码 */
void KeyAction(unsigned char keycode) {
/* 向上键,倒计时设定值递增 */
if (keycode == 0x26) {
/* 最大计时时间 9999 秒 */
if (CountDown < 9999) {
CountDown++;
ShowNumber(CountDown);
}
}
/* 向下键,倒计时设定值递减 */
else if (keycode == 0x28) {
//最小计时1秒
if (CountDown > 1) {
CountDown--;
ShowNumber(CountDown);
}
}
/* 回车键,启动倒计时 */
else if (keycode == 0x0D) {
flagStart = 1;
}
/* Esc键,取消倒计时 */
else if (keycode == 0x1B) {
enBuzz = 0; // 关闭蜂鸣器
LedBuff[6] = 0xFF; // 关闭 LED
flagStart = 0; // 停止倒计时
CountDown = 0; // 数码管倒计时显示归零
ShowNumber(CountDown);
}
}

/* 按键驱动函数 */
void KeyDriver() {
unsigned char i, j;

/* 缓存按键前一次的值 */
static unsigned char pdata backup[4][4] = {{1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}};

/* 快速输入执行的时间阈值 */
static unsigned long pdata TimeThr[4][4] = {{1000, 1000, 1000, 1000}, {1000, 1000, 1000, 1000}, {1000, 1000, 1000, 1000}, {1000, 1000, 1000, 1000}};

/* 循环扫描 4*4 矩阵按键 */
for (i = 0; i < 4; i++) {
for (j = 0; j < 4; j++) {

/* 检测按键动作 */
if (backup[i][j] != KeySta[i][j]) {
/* 当按键按下时 */
if (backup[i][j] != 0) {
KeyAction(KeyCodeMap[i][j]); // 调用按键动作函数
}
backup[i][j] = KeySta[i][j]; // 更新前一次备份的值
}

/* 检测执行快速输入 */
if (KeyDownTime[i][j] > 0) {
/* 达到阈值时执行一次动作 */
if (KeyDownTime[i][j] >= TimeThr[i][j]) {
KeyAction(KeyCodeMap[i][j]); // 调用按键动作函数
TimeThr[i][j] += 200; // 时间阈值增加 200ms,便于下次执行
}
}
/* 按键弹起时复位阈值时间 */
else {
TimeThr[i][j] = 1000; // 恢复 1s 初始阈值时间
}
}
}
}

/* 按键扫描函数,需在定时中断中调用 */
void KeyScan() {
unsigned char i;
static unsigned char keyout = 0; // 矩阵按键扫描输出索引

/* 矩阵按键扫描缓冲区 */
static unsigned char keybuf[4][4] = {{0xFF, 0xFF, 0xFF, 0xFF}, {0xFF, 0xFF, 0xFF, 0xFF}, {0xFF, 0xFF, 0xFF, 0xFF}, {0xFF, 0xFF, 0xFF, 0xFF}};

/* 将一行上 4 个按键的值移入缓冲区 */
keybuf[keyout][0] = (keybuf[keyout][0] << 1) | KEY_IN_1;
keybuf[keyout][1] = (keybuf[keyout][1] << 1) | KEY_IN_2;
keybuf[keyout][2] = (keybuf[keyout][2] << 1) | KEY_IN_3;
keybuf[keyout][3] = (keybuf[keyout][3] << 1) | KEY_IN_4;

/* 完成消抖之后更新按键的状态,因为每行 4 个按键,所以要循环 4 次 */
for (i = 0; i < 4; i++) {
/* 连续 4 次扫描值都为 0 ,即 16ms 内都只检测到按下状态,就认为按键已经稳定按下 */
if ((keybuf[keyout][i] & 0x0F) == 0x00) {
KeySta[keyout][i] = 0;
KeyDownTime[keyout][i] += 4; // 累加按下持续时间
}
/* 连续 4 次扫描值都为 1 ,即 16ms 内都只检测到弹起状态,就认为按键已经稳定弹起 */
else if ((keybuf[keyout][i] & 0x0F) == 0x0F) {
KeySta[keyout][i] = 1;
KeyDownTime[keyout][i] = 0; // 清零按下持续时间
}
}

/* 执行下一次扫描输出 */
keyout++; // 输出索引自增
keyout &= 0x03; // 索引值自增到 4 以后归零

/* 根据索引值释放当前输出引脚,并拉低下次的输出引脚 */
switch (keyout) {
case 0: KEY_OUT_4 = 1; KEY_OUT_1 = 0; break;
case 1: KEY_OUT_1 = 1; KEY_OUT_2 = 0; break;
case 2: KEY_OUT_2 = 1; KEY_OUT_3 = 0; break;
case 3: KEY_OUT_3 = 1; KEY_OUT_4 = 0; break;
default: break;
}
}

/* 数码管动态扫描刷新函数,定时中断内调用 */
void LedScan() {
static unsigned char i = 0; // 动态扫描索引

P0 = 0xFF; // 数码管显示消隐
P1 = (P1 & 0xF8) | i; // 将位选索引值赋值给 P1 的低 3 位
P0 = LedBuff[i]; // 将缓冲区指定索引位置的数据赋值给 P0

/* 遍历整个缓冲区 */
if (i < 6)
i++;
else
i = 0;
}

/* T0中断服务函数,用于执行数码管显示、按键扫描、秒表计数 */
void InterruptTimer0() interrupt 1 {
static unsigned int tmr1s = 0; // 1秒定时器
TH0 = T0RH; TL0 = T0RL; // 定时值存储寄存器重新赋初值

if (enBuzz)
BUZZ = ~BUZZ; // 驱动蜂鸣器发声
else
BUZZ = 1; // 关闭蜂鸣器

LedScan(); // LED扫描
KeyScan(); // 按键扫描

/* 倒计时启动时处理1秒定时 */
if (flagStart) {
tmr1s++;
if (tmr1s >= 1000) {
tmr1s = 0;
flag1s = 1;
}
}
/* 倒计时未启动时1秒定时器始终归零 */
else {
tmr1s = 0;
}
}

串行通信 UART 与 RS232

UART串行通信(通用异步收发器,Universal Asynchronous Receiver/Transmitter)是微控制器设备之间的常用通信技术,STC89C52RCP3.0/RxD(接收)和P3.1/TxD(发送)引脚可用作串行通信接口,具体接线方式请参见下面的示意图:

1
2
3
4
5伏设备A       5伏设备B
TxD ------> TxD
RxD <------ RxD
GND ------- GND

TxD引脚和RxD引脚交叉进行连接,以此作为数据通道。而设备间的GND连接在一起,则是为了保持相同的电源基准;UART 通信过程当中,遵循从低位到高位的发送顺序(先发低位再发高位),即如果要发送0b11100001这个数据,则将会先发送一个高电平1,再发送一个低电平0,以此类推。而每个二进制数据位的传输速率称为波特率(Baud rate),即 1 位二进制数据传输的持续时间等于1 / 波特率,单片机设备之间进行通信时双方的波特率必须保持一致。

UART 通信时一个字节为 8 位,没有通讯信号时线路保持高电平1,发送数据时则需要首先发送一个0表示起始位,然后再遵循由低位到高位的原则发送 8 位有效数据位,数据位发送完成以后会再发一位高电平1表示停止位,即总共发送了 10 个二进制位。而作为数据接收方,没有信号时一直保持高电平,一旦检测到一位低电平就会开始准备接收数据,8 位数据位接收完成之后,如果检测到停止位,就会继续准备接收下一位数据,具体可以参考下面的示意图:

RS232 标准接口

计算机上常用的串行通信接口是RS-232,该接口主要有 9 个引脚的DB-9以及 25 个引脚的DB-25两种类型,计算机上普遍采用的是 9 针的DB-9规格, 当计算机通过 RS232 与单片机系统进行通信时,只需要关注其中的RXDTXDGND三个引脚即可,各针脚的功能具体定义如下:

  1. 载波检测 DCD;
  2. 接收数据 RXD;
  3. 发送数据 TXD;
  4. 数据终端准备好 DTR;
  5. 信号地线 GND;
  6. 数据准备好 DSR;
  7. 请求发送 RTS;
  8. 清除发送 CTS;
  9. 振铃提示 RI。

由于 RS-232 标准采用了负逻辑-3V ~ -15V电压代表1+3 ~ +15V电压代表0,即低电平代表1高电平代表0),因此需要通过一块电平转换芯片MAX232与单片机设备进行连接。虽然 RS-232 与 UART 两者都采用了相同的串行通信协议,但使用的电平标准并不相同,而 MAX232 这块芯片可以将计算机输出的 RS-232 电平转换为STC89C52RC采用的0V ~ 5.5V标准 UART 电平,从而确保两者的正常通信。

为了更加清晰的理解 UART 串行通信的原理,接下来将会利用STC89C52RCP3.0/RxDP3.1/TxD引脚来模拟串行通信的过程,即通过 STC ISP 提供的【串口调试助手】发送一个数值,单片机接收到该数值以后加上1再自动返回。注意波特率需要根据单片机程序的设定来选择,下面实验程序中一个数据位的电平持续时间为1/9600秒,因而此处波特率就选择了9600,具体设置见下面截图:

串口调试助手【发送/接收缓冲区】中的【文本模式】是将数据以 ASCII 编码进行显示,而【HEX 模式】则是将数据按照十六进制格式进行展示。

接下来的实验代码当中,我们将使用定时器 T0模式 2来配置波特率,这里的TH0TL0不再分别代表高低 8 位,仅仅TL0进行计数,TL0发生溢出之后,对TF01的同时,还会将TH0当中的内容自动重装到TL0。这样就可以将所需的定时器初值提前存放于TH0,当TL0溢出以后,就自动将TH0中保存的初始值赋值给TL0,从而代码中无需再对TL0进行赋值。波特率设置完成以后就打开中断,等待串口调试助手下发数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
#include <reg52.h>

sbit PIN_RXD = P3 ^ 0; // 接收引脚
sbit PIN_TXD = P3 ^ 1; // 发送引脚

bit RxdOrTxd = 0; // 标识当前通信状态是接收 or 发送
bit RxdEnd = 0; // 接收结束标志
bit TxdEnd = 0; // 发送结束标志

unsigned char RxdBuf = 0; // 接收缓存
unsigned char TxdBuf = 0; // 发送缓存

/* 串行配置,参数 baud 表示波特率 */
void ConfigUART(unsigned int baud) {
TMOD &= 0xF0; // 清零定时器 T0 控制位
TMOD |= 0x02; // 配置定时器 T0 为工作模式 2
TH0 = 256 - (11059200 / 12) / baud; // 计算定时器 T0 的重载值
}

/* 串行发送,参数 dat 表示等待发送的数据 */
void StartTXD(unsigned char dat) {
TxdBuf = dat; // 将等待发送的数据保存至发送缓存
TL0 = TH0; // 设置定时器 T0 的初值为重载值
ET0 = 1; // 定时器 T0 中断使能
TR0 = 1; // 启动定时器 T0
PIN_TXD = 0; // 发送起始位
TxdEnd = 0; // 发送结束标志置 0
RxdOrTxd = 1; // 设置当前通信状态为发送
}

/* 串行接收 */
void StartRXD() {
TL0 = 256 - ((256 - TH0) >> 1); // 接收启动时的 T0 定时为半个波特率周期
ET0 = 1; // 使能定时器 T0 中断
TR0 = 1; // 启动定时器 T0
RxdEnd = 0; // 接收结束标志置 0
RxdOrTxd = 0; // 设置当前通信状态为接收
}

void main() {
EA = 1; // 总中断使能
ConfigUART(9600); // 设置波特率为 9600

while (1) {
while (PIN_RXD); // 接收引脚等待串行通信起始位低电平的出现
StartRXD(); // 开始接收
while (!RxdEnd); // 等待接收完成
StartTXD(RxdBuf + 1); // 将接收到的数据自增 1 以后发回
while (!TxdEnd); // 等待发送完成
}
}

/* 定时器 T0 中断服务函数,用于处理串行通信收发 */
void InterruptTimer0() interrupt 1 {
static unsigned char cnt = 0; // 位收发计数

/* 处理发送 */
if (RxdOrTxd) {
cnt++;

/* 从低位至高位依次发送 8 bit 数据 */
if (cnt <= 8) {
PIN_TXD = TxdBuf & 0x01;
TxdBuf >>= 1;
}
/* 发送停止 */
else if (cnt == 9) {
PIN_TXD = 1;
}
/* 发送结束 */
else {
cnt = 0; // 将位收发计数置 0
TR0 = 0; // 关闭定时器 T0
TxdEnd = 1; // 发送结束标志置 0
}
}
/* 处理接收 */
else {
/* 处理起始位 */
if (cnt == 0) {
/* 起始位为 0 时,先将接收缓存置 0,然后让位收发计数自增 1 */
if (!PIN_RXD) {
RxdBuf = 0;
cnt++;
}
/* 起始位不为 0 时,关闭定时器 T0 中止接收 */
else {
TR0 = 0;
}
}
/* 处理 8 位数据位 */
else if (cnt <= 8) {
RxdBuf >>= 1; // 低位在前,所以让接收缓存右移 1 位

/* 接收引脚为高电平 1 时,将接收缓存的最高位置 1,而为 0 时则不进行任何处理 */
if (PIN_RXD) {
RxdBuf |= 0x80;
}
cnt++;
}
/* 停止位处理 */
else {
cnt = 0; // 位收发计数置 0
TR0 = 0; // 关闭定时器 T0

/* 当停止位为 1 时才认为数据有效 */
if (PIN_RXD) {
RxdEnd = 1; // 接收结束标志置 1
}
}
}
}

根据串行通信低电平触发的原理,上面代码接收数据时,会首先使用while (PIN_RXD)进行低电平检测,未检测到就说明此时没有数据,而一旦检测到低电平就会调用接收函数StartRXD()。接收函数经历半个波特率周期以后(即信号较稳定的中间位置)会进行数据的读取。一旦读到起始低电平0,就将当前状态设置为接收并打开定时器中断。经过半个周期进入中断服务函数以后,会再次对起始位进行判断,以确认其处于低电平状态,而非一个干扰信号。然后每经过1/9600秒进入一次中断,将单片机引脚接收到的电平状态读取至RxdBuf变量。接收完毕之后,将RxdBuf变量的值加1以后再通过TXD引脚发回。同样需要先发送 1 位起始位,再发送 8 个数据位,最后再发送 1 位结束位。完成这一系列操作之后,代码重新循环至while (PIN_RXD),开始准备下一轮信号的收发。

通信技术按照传输方向可分为单工通信、半双工通信、全双工通信 3 种类型:

  1. 单工通信只允许一方向另外一方传送信息,而另一方不能回传信息;
  2. 半双工通信的数据可以在双方之间相互传播,但是同一时刻只能由其中一方发给另外一方;
  3. 全双工通信数据的发送与接收能够同步进行。

上述代码通过单片机 IO 接口来模拟串口通信,由于程序需要不间断扫描单片机 IO 接口收到的数据,所以大量占用了单片机运行时间与资源。实际上,STC89C52RC单片机已经内置了一个 UART 硬件模块,能够自动接收数据并在接收完成以后通知单片机,开发人员只需配置相关的串行接口控制寄存器 SCON即可,该寄存器可以进行位寻址,其具体定义请见下表所示:

串行控制寄存器 B7 B6 B5 B4 B3 B2 B1 B0
SCON SM0 SM1 SM2 REN TB8 RB8 TI RI
复位值 0 0 0 0 0 0 0 0
  • SM0/SM1:两位共同决定串口的通信模式 0~3,其中 1 位起始位、8 位数据位、1 位停止位的模式 1 最为常用,即SM0 = 0; SM1 = 1;
  • SM2多机通信控制位(极少使用),模式 1 时直接置0
  • REN串行接收控制位,置1时允许启用RxD进行串行接收,置0时则禁止接收;
  • TB8: 工作模式 2 或者 3 当中,要发送的第 9 位数据。
  • RB8: 工作模式 2 或者 3 当中,接收到的第 9 位数据。
  • TI发送中断请求标志位,工作模式 0 时,串行数据第 8 位发送结束时由硬件置1并请求中断,中断响应完成后必须代码手动置0复位;其它工作方式会在接收到停止位中间位置时由硬件自动置1,然后必须通过程序手动置0复位;
  • RI接收中断请求标志位,工作模式 0 时,串行数据第 8 位接收结束时由硬件置1并请求中断,中断响应完成后必须代码手动置0复位;其它工作方式会在接收到停止位中间位置时由硬件自动置1,然后必须通过程序手动置0复位。

前面使用单片机 IO 接口模拟串口通信的程序当中,波特率是通过定时器 T0 中断来实现的;实际上,STC89C52RC内置的 UART 模块已经提供了一个波特率发生器(只能由定时器 T1 和 T2 产生),用于控制数据的收发速率。由于定时器 T2 需要配置额外的寄存器,这里默认使用定时器 T1 作为波特率发生器,而处于工作方式 1 的波特率发生器必须采用定时器 T1 的模式 2 自动重装模式,那么定时值存储寄存器的初始值的计算公式应为:

1
TH1 = TL1 = 256 - (晶振频率 ÷ 12 ÷ 2 ÷ 16 ÷ 波特率)

值得注意的是,电源管理寄存器 PCON的最高位可以将波特率提升一倍,即当PCON |= 0x80的时候,定时值存储寄存器的计算公式应修改为:

1
TH1 = TL1 = 256 - (晶振频率 ÷ 12 ÷ 16 ÷ 波特率)

上面公式当中的256是 8 位定时器的溢出值,也就是TL1的溢出值;晶振频率为当前电路中使用的1105920012是指一 个机器周期等于 12 个时钟周期;而数值16是指将一位信号采集 16 次,如果其中的第 7、8、9 次其中两次为高电平1,那么就认为该位处于高电平状态1,如果两次是低电平就认定该位是低电平状态0,这样即便受到意外干扰读错一次数据,也依然能够保证程序的正确性。

电路中的晶振之所以选用11.0592 MHz,就是由于该值可以在上面的公式中被除尽。

STC89C52RC单片机的 UART 串口通信电路,在发送和接收两端分别都采用了 2 个同名称同地址(0x99)的SBUF 寄存器,一个用于发送缓冲,另一个用于接收缓冲,从而实现全双工的 UART 通信。但是程序代码当中不会区分收发,而仅仅只需要对 SBUF 寄存器进行操作,单片机会自动选择当前应使用接收 SBUF还是发送 SBUF

UART 串口行通信实验

编写一个串行通信相关的程序通常需要经历如下 4 个基本步骤:

1、配置串口为工作模式 1,即1 位起始位、8 位数据位、1 位停止位。 2、配置定时器 T1 为工作模式 2,即自动重装模式。 3、根据波特率计算定时值存储寄存器 TH1 和 TL1 的值,如有需要可使用电源管理寄存器 PCON 加倍波特率。 4、打开定时器控制寄存器 TR1,使定时器 T1 开始工作。

注意:当使用定时器 T1 做为波特率发生器的时候,绝对不可以再使能定时器 T1 的中断。

现在对上面单片机 IO 接口模拟串口通信的程序进行修改,改用单片机内置的 UART 模块来进行实验,由于大部份工作都已经自动化完成,因而程序代码将得到较大幅度的简化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <reg52.h>

/* 串口配置函数,baud-通信波特率 */
void ConfigUART(unsigned int baud) {
SCON = 0x50; // 配置串口为工作方式 1
TMOD &= 0x0F; // 清零定时器 T1 控制位
TMOD |= 0x20; // 配置定时器 T1 为工作模式 2
TH1 = 256 - (11059200 / 12 / 32) / baud; // 计算定时器 T1 的定时值
TL1 = TH1; // 将高位的定时值作为低位的初值
ET1 = 0; // 禁用定时器 T1 中断
TR1 = 1; // 启动定时器 T1
}

void main() {
ConfigUART(9600); // 配置波特率 9600

while (1) {
while (!RI); // 等待接收完成
RI = 0; // 清零接收中断标志位
SBUF = SBUF + 1; // 将接收到的数据加 1 以后返回
while (!TI); // 等待发送完成
TI = 0; // 清零发送中断标志位
}
}

上面代码依然在while主循环里判断接收/发送中断标志位,实际工程开发当中则会直接使用串口中断,但是要需要注意:由于接收和发送触发的是相同的串口中断,所以中断服务函数内必须首先判断当前属于哪种类型中断,然后再进行相应的处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <reg52.h>

void ConfigUART(unsigned int baud);

void main() {
EA = 1; // 使能总中断
ConfigUART(9600); // 配置波特率为9600
while (1);
}
/* 串口配置函数,baud-通信波特率 */
void ConfigUART(unsigned int baud) {
SCON = 0x50; // 设置串口为工作模式1
TMOD &= 0x0F; // 清零定时器 T1 控制位
TMOD |= 0x20; // 设置定时器 T1 为工作方式 2
TH1 = 256 - (11059200 / 12 / 32) / baud; // 设置定时器 T1 的定时值存储寄存器
TL1 = TH1; // 将高位定时值作为低位初值
ET1 = 0; // 禁止定时器 T1 中断
ES = 1; // 使能串口中断
TR1 = 1; // 启动定时器 T1
}

/* UART中断服务函数 */
void InterruptUART() interrupt 4 {
/* 接收到字节 */
if (RI) {
RI = 0; // 接收中断标志位置 0
SBUF = SBUF + 1; // 将接收到的数据加 1 以后返回
}
/* 字节发送完毕 */
if (TI) {
TI = 0; // 发送中断标志位置 0
}
}

ASCII 编码的串行传输

串口通信经常用于不同设备之间的数据交互,比如可以通过计算机控制单片机功能,也可以将单片机相关的日志信息发送给计算机。本小节将会完成这样一个简单示例:将计算机上串口调试助手发送的数据,在单片机电路的数码管上进行显示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
#include <reg52.h>

sbit ADDR3 = P1 ^ 3;
sbit ENLED = P1 ^ 4;

/* 数码管显示字符编码表 */
unsigned char code LedChar[] = {0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8,
0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E};

/* 数码管显示缓冲区 */
unsigned char LedBuff[7] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};

unsigned char T0RH = 0; // 定时器 T0 存储寄存器高字节
unsigned char T0RL = 0; // 定时器 T0 存储寄存器低字节
unsigned char RxdByte = 0; // 串口接收到的字节数据

void ConfigTimer0(unsigned int ms);
void ConfigUART(unsigned int baud);

void main() {
EA = 1; // 总中断使能
ENLED = 0; // 使能数码管和LED
ADDR3 = 1;

ConfigTimer0(1); // 设置定时器 T0 定时 1ms
ConfigUART(9600); // 设置波特率 9600

/* 将接收到的字节数据在数码管上以十六进制格式显示 */
while (1) {
LedBuff[0] = LedChar[RxdByte & 0x0F];
LedBuff[1] = LedChar[RxdByte >> 4];
}
}

/* 配置并启动定时器 T0,参数 ms 表示定时时间 */
void ConfigTimer0(unsigned int ms) {
unsigned long tmp; // 临时变量

tmp = 11059200 / 12; // 定时器计数频率
tmp = (tmp * ms) / 1000; // 计算计数值
tmp = 65536 - tmp; // 计算定时器初始值
tmp = tmp + 13; // 中断响应延迟误差补偿

T0RH = (unsigned char)(tmp >> 8); // 定时器重载值拆分为高低字节
T0RL = (unsigned char)tmp;

TMOD &= 0xF0; // 清零定时器 T0 控制位
TMOD |= 0x01; // 设置定时器 T0 为工作模式 1
TH0 = T0RH; TL0 = T0RL; // 加载定时器 T0 初始值
ET0 = 1; // 使能定时器 T0 中断
TR0 = 1; // 启动定时器 T0
}

/* 串口配置函数,baud-通信波特率 */
void ConfigUART(unsigned int baud) {
SCON = 0x50; // 设置串口为工作模式 1
TMOD &= 0x0F; // 定时器 T1 控制位清零
TMOD |= 0x20; // 设置定时器 T1 为工作方式 2
TH1 = 256 - (11059200 / 12 / 32) / baud; // 计算定时器 T1 初始值
TL1 = TH1; // 将高位定时值作为低位的初值
ET1 = 0; // 禁用定时器 T1 中断
ES = 1; // 串口中断使能
TR1 = 1; // 启动定时器 T1
}

/* 数码管动态扫描刷新函数,定时中断内调用 */
void LedScan() {
static unsigned char i = 0; // 动态扫描索引

P0 = 0xFF; // 关闭所有数码管段选进行消隐
P1 = (P1 & 0xF8) | i; // 位选索引值赋值给 P1 引脚的低 3 位
P0 = LedBuff[i]; // 将缓冲区中索引位置的数据发送至单片机 P0 接口
if (i < 6) // 索引递增循环,遍历缓冲区
i++;
else
i = 0;
}

/* 定时器 T0 中断服务函数,用于完成数码管扫描 */
void InterruptTimer0() interrupt 1 {
TH0 = T0RH;
TL0 = T0RL; // 重新加载定时值
LedScan(); // LED 扫描显示
}

/* UART中断服务函数 */
void InterruptUART() interrupt 4 {
/* 接收到字节 */
if (RI) {
RI = 0; // 接收中断标志位置零
RxdByte = SBUF; // 将接收到的数据保存至接收字节变量
SBUF = RxdByte; // 返回接收到的数据,以此提示用户输入的信息是否已经正确接收
}
/* 字节发送完毕 */
if (TI) {
TI = 0; // 发送中断标志位置零
}
}

这里需要注意:由于STC89C52RC是通过 UART 串口进行程序下载,当下载完成程序在单片机上开始运行以后,ISP 下载软件还会向串口发送一些额外数据,造成程序下载完成后并非显示00,遇到这种情况只需要将电路重新上电即可恢复正常状态。

RS485 与 Modbus 协议

RS232标准诞生于RS485之前,电平达到十几伏容易损坏芯片,并且不兼容 TTL 电平标准,传输速率极限值仅为100 ~ 200Kb/s;使用信号线、GND与其它设备形成共地模式通信,容易受到干扰且抗干扰能力较弱;传输距离最多仅几十米,并且只能完成两点之间的通信,不能采用多机联网通信。

RS485的出现弥补了RS232的不足,它采用了差分信号传输,可以抑制共模干扰提高通信可靠性,两根通信线通常使用AB或者 D+D-表示,两条通信线之间的电压差为+(0.2 ~ 6)V时表示高电平1,电压差为-(0.2 ~ 6)V时表示低电平0,属于典型的差分通信;RS485最大传输速率可以达到10 Mb/s以上,传输距离最远可以达到 1200 米左右(距离较远将会降低传输速度);内部采用平衡驱动器与差分接收器组合,有效提高了抗干扰能力;可以在总线上进行多机联网通信,能够支持32 ~ 256个设备。RS485接口通过MAX485电平转换芯片,就可以方便的与单片机 UART 串口进行连接通信;但是由于RS485采用的是差分通信,因此数据的收发不能同时进行,属于半双工通信

MAX485 电平转换芯片的 5 和 8 引脚是电源引脚,6 和 7 引脚是用于通信的AB两个引脚(在它们之间并接了一个阻值为1kΩ的电阻R5以提升抗干扰能力),第 1 和 4 引脚分别连接至单片机的RXDTXD引脚,第 2 脚(低电平使能接收)和第 3 脚(高电平使能输出)是方向引脚,电路当中将这组引脚连接在一起,不发送数据时保持为低电平处于接收状态,发送数据时就将这组引脚上拉至高电平,发送完毕之后再下拉为低电平。

接下来的RS485实验当中,将MAX485的通信引脚连接至单片机的P3.0P3.1,方向控制引脚连接至单片机的P1.7引脚。接下来,基于前面小节RS232串口通信实验的思路,通过电脑上的串口调试助手发送任意个数字符,单片机接收到到后在末尾添加【回车+换行】符以后返回,并在调试助手上显示出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
/** RS485.c */
#include <intrins.h>
#include <reg52.h>

sbit RS485_DIR = P1 ^ 7; // RS485方向选择引脚

bit flagFrame = 0; // 数据帧接收完成标志位,表示接收到了一帧新数据
bit flagTxd = 0; // 单字节发送完成标志位,用来替代 TXD 中断标志位

unsigned char cntRxd = 0; // 接收字节计数器
unsigned char pdata bufRxd[64]; // 接收字节缓冲区

extern void UartAction(unsigned char *buf, unsigned char len);

/* 串口配置函数,baud-通信波特率 */
void ConfigUART(unsigned int baud) {
RS485_DIR = 0; // 将 RS485 设置为接收

SCON = 0x50; // 配置串口为工作方式 1
TMOD &= 0x0F; // 清零定时器 T1 控制位
TMOD |= 0x20; // 配置定时器 T1 为工作模式 2
TH1 = 256 - (11059200 / 12 / 32) / baud; // 计算定时器 T1 初始值
TL1 = TH1; // 将高位定时值作为低位的初值
ET1 = 0; // 禁用定时器 T1 中断
ES = 1; // 串口中断使能
TR1 = 1; // 启动定时器 T1
}

/* 软件延时函数,延时 t * 10 微秒的时间 */
void DelayX10us(unsigned char t) {
do {
_nop_();
_nop_();
_nop_();
_nop_();
_nop_();
_nop_();
_nop_();
_nop_();
} while (--t);
}

/* 串口数据写入函数,参数 buf 是等待发送数据的指针,参数 len 是发送的长度 */
void UartWrite(unsigned char *buf, unsigned char len) {
RS485_DIR = 1; // 将 RS485 设置为发送

/* 循环发送所有的字节 */
while (len--) {
flagTxd = 0; // 发送完成标志位清零
SBUF = *buf++; // 发送 1 个字节的数据
while (!flagTxd) ; // 等待该字节发送完成
}

DelayX10us(5); // 等待最后的停止位完成,延时时间由波特率决定
RS485_DIR = 0; // 将 RS485 设置为接收
}

/* 串口数据读取函数,参数 buf 表示接收指针的指针,参数 len 表示读取的长度,返回值是实际读取到的长度 */
unsigned char UartRead(unsigned char *buf, unsigned char len) {
unsigned char i;
/* 当指定的读取长度大于实际接收到的数据长度时,将读取长度设置为实际接收到的数据长度 */
if (len > cntRxd) {
len = cntRxd;
}

/* 将接收到的数据拷贝至接收指针 */
for (i = 0; i < len; i++) {
*buf++ = bufRxd[i];
}

cntRxd = 0; // 接收计数器清零
return len; // 返回实际读取的长度
}

/* 串口接收监控函数,基于空闲时间来判定帧结束,在定时中断中调用,参数 ms 表示定时间隔 */
void UartRxMonitor(unsigned char ms) {
static unsigned char cntbkp = 0; // 接收计数变量
static unsigned char idletmr = 0; // 总线空闲计时变量

/* 当接收计数变量大于零时,监听总线的空闲时间 */
if (cntRxd > 0) {
/* 如果接收计数变量发生改变(即刚接收到数据时),空闲计时变量清零 */
if (cntbkp != cntRxd) {
cntbkp = cntRxd;
idletmr = 0;
}
/* 如果接收计数变量没有改变(即总线保持空闲时),将空闲时间进行累加 */
else {
/* 总线空闲计时小于 30ms 持续累加 */
if (idletmr < 30) {
idletmr += ms;
/* 总线空闲计时达到 30ms,就认为一帧数据接收完毕 */
if (idletmr >= 30) {
flagFrame = 1; // 设置数据帧接收完成标志位
}
}
}
} else {
cntbkp = 0;
}
}

/* 串口驱动函数,用于监听数据帧的接收并调用相应功能,主函数循环中进行调用 */
void UartDriver() {
unsigned char len;
unsigned char pdata buf[40];
/* 当数据帧到达时,读取并处理该命令 */
if (flagFrame) {
flagFrame = 0;

len = UartRead(buf, sizeof(buf) - 2); // 将接收到的命令读取至缓冲区
UartAction(buf, len); // 传递数据帧,调用动作执行函数
}
}

/* 串口中断服务函数 */
void InterruptUART() interrupt 4 {
/* 接收到新的字节数据 */
if (RI) {
RI = 0; // 接收中断标志位清零

/* 接收缓冲区尚未用完时,保存接收字节,并递增计数器 */
if (cntRxd < sizeof(bufRxd)) {
bufRxd[cntRxd++] = SBUF;
}
}
/* 字节发送完毕 */
if (TI) {
TI = 0; // 发送中断标志位清零
flagTxd = 1; // 发送完成标志位置 1
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/** main.c */
#include <reg52.h>

unsigned char T0RH = 0; // 定时器 T0 重载值的高字节
unsigned char T0RL = 0; // 定时器 T0 重载值的低字节

void ConfigTimer0(unsigned int ms);
extern void UartDriver();
extern void ConfigUART(unsigned int baud);
extern void UartRxMonitor(unsigned char ms);
extern void UartWrite(unsigned char *buf, unsigned char len);

void main() {
EA = 1; // 总中断使能
ConfigTimer0(1); // 配置定时器 T0 定时 1ms
ConfigUART(9600); // 设置波特率 9600

while (1) {
UartDriver(); // 调用串口驱动程序
}
}

/* 串口动作函数,参数 buf 是接收到的命令帧指针,参数 len 是命令帧的长度 */
void UartAction(unsigned char *buf, unsigned char len) {
buf[len++] = '\r'; // 在接收到的数据帧后添加回车符
buf[len++] = '\n'; // 在接收到的数据帧后添加换行符

UartWrite(buf, len);
}

/* 配置并启动定时器 T0,参数 ms 是定时时间 */
void ConfigTimer0(unsigned int ms) {
unsigned long tmp; // 临时变量

tmp = 11059200 / 12; // 定时器计数频率
tmp = (tmp * ms) / 1000; // 计算计数值
tmp = 65536 - tmp; // 计算定时器初始值
tmp = tmp + 33; // 补偿由于中断响应延迟造成的误差

T0RH = (unsigned char)(tmp >> 8); // 拆分定时值为高低位
T0RL = (unsigned char)tmp;
TMOD &= 0xF0; // 定时器 T0 控制位置 0
TMOD |= 0x01; // 配置定时器 T0 为工作模式 1
TH0 = T0RH;
TL0 = T0RL; // 定时值存储寄存器赋初值
ET0 = 1; // 定时器 T0 中断使能
TR0 = 1; // 启动定时器 T0
}

/* 定时器 T0 中断服务函数 */
void InterruptTimer0() interrupt 1 {
TH0 = T0RH;
TL0 = T0RL; // 定时值存储寄存器重新赋初值
UartRxMonitor(1); // 调用串口接收监控函数
}

程序当中MAX485正常情况下为接收状态,只有在数据发送时才会置为发送状态,因此UartWrite()函数开头将MAX485电平转换芯片的方向引脚上拉为高电平,函数执行完成之前再下拉至低电平。

另外还有一个值得注意的细节,单片机发送和接收中断发生的时刻都处于停止位中间位置,即每当停止位传输至一半时,RITI就会置位并且进入中断服务函数,这种方式在接收的时候一切正常,但是在发送的时候,会紧接着向SBUF寄存器写入 1 个字节数据;单片机 UART 会在上一个停止位发送完成之后,再开始新的字节发送,如果此时不继续发送下一个字节,而是处于已经发送完毕的状态,这种情况下停止发送并将MAX485方向引脚拉低以使MAX485重新处于接收状态的逻辑就存在问题,因为最后的停止位此时只被发送了一半。

正因为如此,上面代码里通过在UartWrite()内部执行DelayX10us(5)函数,手动增加了50us延时(延时时间为波特率周期的一半),以便让剩下的一半停止位有足够的时间完成发送,从而有效避免了问题的发生。

Modbus 通信协议

Modbus 是由施耐德电气于 1979 年提出的应用层的串行通信协议,目前已经成为一种常用的工业领域通信标准。Modbus 属于主从架构协议,只有一个节点作为主设备(所有通信都由它发起),其它参与通信的节点为从设备(最大可支持 247 个从设备);每个从设备都拥有唯一地址,每条命令也都会包含需要执行该命令的目标设备地址,主设备发出命令之后所有从设备都会收到该命令,但只有指定地址的从设备能够执行并响应。此外,每条 Modbus 命令还包含有校验码,以确保命令的完整性。由于 Modbus 协议涉及内容较多,这里只重点介绍数据的帧结构以及通信控制方式

Modbus 协议包含ASCIIRTUTCP等传输方式,其中 ASCII 模式每个字节仅由 7 个 bit 位组成,标准 8051 架构单片机无法实现并且实际应用较少;而 TCP 与 RTU 极为类似,只需去除 RTU 的两个字节校验码,然后在协议开头添加五个0和一个6,最后通过 TCP/IP 网络协议发出即可。因此,这里将会重点介绍RTU模式,一条典型的 RTU 数据帧如下表所示:

起始位 设备地址 功能码 数据 CRC 校验 结束符
间隔 3.5 Byte 通信时间 8 bit 8 bit n × 8 bit 16 bit 间隔 3.5 Byte 通信时间

RTU 模式规定每个数据帧前后都至少需要间隔 3.5 个字节的通信时间,如果超过这个时间,接收设备就会认为是一个新的数据帧。每个数据帧都会包含一个目标从设备的地址,如果地址为0x00就会认为这是一个所有从机设备都要执行的广播命令。功能码部分由 Modbus 协议进行约定,设备将会根据功能码来执行相应的动作,常用的功能码如下表所示:

功能码 名称 描述
01 读线圈状态 取得一组逻辑线圈的当前状态,ON 或者 OFF
02 读离散输入状态 取得一组开关输入的当前状态,ON 或者 OFF
03 读保持寄存器 从一个或多个保持寄存器当中获取二进制值
04 读输入寄存器 从一个或多个输入寄存器当中获取二进制值
05 写单个线圈 强制一个逻辑线圈的通断状态
06 写单个保持寄存器 将二进值写入一个保持寄存器
7 ~ 14 其它功能 - - -
15 写多个线圈 强制一串连续逻辑线圈的通断
16 写多个保持寄存器 将二进制值写入一串连续的保持寄存器
17 ~ 21 其它功能 - - -
22 ~ 64 保留,作为协议扩展备用 - - -
65 ~ 72 保留,作为用户扩展备用 - - -
73 ~ 119 非法功能 - - -
120 ~ 127 保留,作为内部作用 - - -
128 ~ 25 保留,用于异常应答 - - -

紧跟在功能码后面的 8 bit 数据具体个数由功能码来确定,例如功能码为0x03(参考上表,即读保持寄存器),那么主机发送的数据如下表所示:

功能码 起始地址 寄存器数量
1 个字节功能码 2 个字节寄存器起始地址 2 个字节寄存器数量
0x03 0x0000 ~ 0xFFFF 1 ~ 125

从机接收到上述命令之后,响应数据的结构如下表所示:

功能码 字节数 寄存器值
1 个字节功能码 1 个字节 2 × 寄存器数量个字节
0x03 2 × 寄存器数量 - - -

最后的 CRC 校验是将前面的所有字节数据进行计算,并生成一个16 bit位的数据作为校验码添加在每帧数据最后。接收方接收到该帧数据以后会进行同样的 CRC 计算,并且将计算结果与接收到的 CRC 校验位进行比较,从而完成每帧数据的完整性校验。

RTU模式下,每个字节由 1 个起始位、8 个数据位(由低至高进行发送)、1 个奇偶校验位(可选)、1 位停止位(有校验位时)或 2 个停止位(无校验位时)组成。

Modbus 多机通信示例

主机通过给从机下发不同指令,然后从机去执行指令对应的操作。与前面的串口实验类似,基于 Modbus 多机通信只需增加一个设备地址判断,接下来就使用计算机的 Modbus 调试精灵作为主机,STC89C52RC单片机作为从机,并通过 USB 转 RS485 模块进行通信实验。先设置 Modbus 调试精灵:波特率9600无校验位8位数据位,1位停止位,设备地址为1

写寄存器时,如果需要将01写到地址为0000的寄存器地址,Modbus 调试精灵就会自动生成指令01 06 00 00 00 01 48 0A,其中01是设备地址,06是写寄存器功能码,00 00表示要写入的寄存器地址,00 01为待写入的数据,48 0A是自动计算出的 CRC 校验码。根据 Modbus 协议,从机完成写寄存器指令操作以后,会直接返回主机发送的指令,此时调试精灵应接收到的数据帧为:01 06 00 00 00 01 48 0A

如果要从地址0002开始读取寄存器,并且读取的数量为2个,就会发送指令01 03 00 02 00 02 65 CB,其中01是设备地址,03是读寄存器功能码,00 02是读寄存器的起始地址,00 02是要读取两个寄存器的数值,65 CB是 CRC 校验。此时调试精灵应接收到的返回数据帧为01 03 04 00 00 00 00 FA 33,其中01是设备地址,03是功能码,04代表的是后边读到的数据字节数是 4 个,00 00 00 00分别是地址为00 0200 03的寄存器内部的数据,而FA 33就是 CRC 校验了。

由于当前使用的开发板不具备 Modbus 协议功能码定义的诸多功能,因此在程序中通过数组regGroup[5]定义了 5 个模拟的寄存器,以及 1 个用于控制蜂鸣器的寄存器,Modbus 调试精灵可以通过下发不同指令改变STC89C52RC上寄存器的数据或者调整蜂鸣器的开关状态,即将单片机作为从机解析串口接收到的数据并执行相应操作。

Modbus 协议中寄存器的地址和数值都为 16 位(即 2 个字节),这里默认高字节是0x00低字节为数组regGroup[5]的值,其中地址0x00000x0004对应的是regGroup[5]数组的元素,完成写入以后会将数字显示到 1602 液晶,而对于地址0x0005,如果写入0x00蜂鸣器不会鸣叫,写入其它值就会报警。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/** Lcd1602.c ... 此处省略,代码内容请参考后续小节 */

/** RS485.C ... 此处省略,代码内容请参考之前小节 */

/** CRC16.C */
/* CRC16计算函数,参数 ptr 表示数据指针,len 表示数据长度,返回计算的 CRC16 结果 */
unsigned int GetCRC16(unsigned char *ptr, unsigned char len) {
unsigned int index;
unsigned char crch = 0xFF; // CRC 高字节
unsigned char crcl = 0xFF; // CRC 低字节

/* CRC 高位字节值列表 */
unsigned char code TabH[] = {0x00, 0xC1, 0x81, ..., 0xC1, 0x81, 0x40};
/* CRC 低位字节值列表 */
unsigned char code TabL[] = {0x00, 0xC0, 0xC1, ..., 0x81, 0x80, 0x40};

/* 计算指定长度的 CRC */
while (len--) {
index = crch ^ *ptr++;
crch = crcl ^ TabH[index];
crcl = TabL[index];
}

return ((crch << 8) | crcl);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
/** main.c */
#include <reg52.h>

sbit BUZZ = P1 ^ 6;
bit flagBuzzOn = 0; //蜂鸣器启动标志

unsigned char T0RH = 0; // 定时器 T0 定时值的高字节
unsigned char T0RL = 0; // 定时器 T0 定时值的低字节
unsigned char regGroup[5]; // Modbus 寄存器组,地址范围 0x00~0x04

void ConfigTimer0(unsigned int ms);

extern void UartDriver();
extern void ConfigUART(unsigned int baud);
extern void UartRxMonitor(unsigned char ms);
extern void UartWrite(unsigned char *buf, unsigned char len);
extern unsigned int GetCRC16(unsigned char *ptr, unsigned char len);
extern void InitLcd1602();
extern void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str);

void main() {
EA = 1; // 总中断使能
ConfigTimer0(1); // 定时器 T0 定时 1ms
ConfigUART(9600); // 设置波特率 9600
InitLcd1602(); // 初始化 1602 液晶

while (1) {
UartDriver(); // 调用串口驱动
}
}

/* 串口动作函数,根据接收到的命令帧执行相应动作,参数 buf
* 表示接收到的命令帧指针,len 表示命令帧的长度 */
void UartAction(unsigned char *buf, unsigned char len) {
unsigned char i;
unsigned char cnt;
unsigned char str[4];
unsigned int crc;
unsigned char crch, crcl;

/* 例子中从机地址设置为 0x01,如数据帧中地址与本机不符,则不做任何处理 */
if (buf[0] != 0x01) {
return;
}

/* 如果地址相符,则对本帧数据进行校验 */
crc = GetCRC16(buf, len - 2); // 计算 CRC 校验值
crch = crc >> 8;
crcl = crc & 0xFF;

if ((buf[len - 2] != crch) || (buf[len - 1] != crcl)) {
return; // 如果 CRC 校验错误直接退出
}

/* 地址和 CRC 校验都正确时,解析功能码并执行相应操作 */
switch (buf[1]) {
/* 读取一个连续的寄存器 */
case 0x03:
/* 仅支持 0x0000 ~ 0x0005 地址范围 */
if ((buf[2] == 0x00) && (buf[3] <= 0x05)) {
if (buf[3] <= 0x04) {
i = buf[3]; // 获取寄存器地址
cnt = buf[5]; // 获取待读取的寄存器数量
buf[2] = cnt * 2; // 读取数据字节数,即寄存器数量乘以 2
len = 3; // 数据帧已经拥有地址、功能码、字节数一共 3 个字节

while (cnt--) {
buf[len++] = 0x00; // 寄存器高字节补 0
buf[len++] = regGroup[i++]; // 寄存器低字节
}
}
/* 地址 0x05 为蜂鸣器状态 */
else {
buf[2] = 2; // 读取字节数
buf[3] = 0x00;
buf[4] = flagBuzzOn;
len = 5;
}
break;
}
/* 如果寄存器地址不被支持 */
else {
buf[1] = 0x83; // 功能码最高位置 1
buf[2] = 0x02; // 设置异常码为 02,即无效地址
len = 3;
break;
}
/* 写入单个寄存器 */
case 0x06:
/* 仅支持 0x0000 ~ 0x0005 地址范围 */
if ((buf[2] == 0x00) && (buf[3] <= 0x05)) {
if (buf[3] <= 0x04) {
i = buf[3]; // 获取寄存器地址
regGroup[i] = buf[5]; // 保存寄存器数据
cnt = regGroup[i] >> 4; // 显示至 1602 液晶

if (cnt >= 0xA)
str[0] = cnt - 0xA + 'A';
else
str[0] = cnt + '0';
cnt = regGroup[i] & 0x0F;
if (cnt >= 0xA)
str[1] = cnt - 0xA + 'A';
else
str[1] = cnt + '0';

str[2] = '\0';
LcdShowStr(i * 3, 0, str);
}
/* 地址0x05为蜂鸣器状态 */
else {
flagBuzzOn = (bit)buf[5]; // 寄存器值转为蜂鸣器的开关
}
len -= 2; // 长度减去 2 ,重新计算 CRC 并且返回原始帧
break;
}
/* 如果寄存器地址不被支持 */
else {
buf[1] = 0x86; // 功能码最高位置 1
buf[2] = 0x02; // 异常码设置为 02,即无效地址
len = 3;
break;
}
/* 其它不被支持的功能码 */
default:
buf[1] |= 0x80; // 功能码最高位置1
buf[2] = 0x01; // 异常码设置为 01,即无效功能
len = 3;
break;
}

crc = GetCRC16(buf, len); // 计算返回帧的 CRC 校验值
buf[len++] = crc >> 8; // CRC 高字节
buf[len++] = crc & 0xFF; // CRC 低字节
UartWrite(buf, len); // 发送返回帧
}

/* 配置并启动定时器 T0,参数 ms 表示定时时间 */
void ConfigTimer0(unsigned int ms) {
unsigned long tmp; // 临时变量

tmp = 11059200 / 12; // 定时器计数频率
tmp = (tmp * ms) / 1000; // 计算所需要的计数值
tmp = 65536 - tmp; // 计算定时器的定时值
tmp = tmp + 33; // 补偿中断响应延迟造成的误差

T0RH = (unsigned char)(tmp >> 8); // 定时器初始值拆分为高低字节
T0RL = (unsigned char)tmp;
TMOD &= 0xF0; // 清零定时器 T0 控制位
TMOD |= 0x01; // 配置定时器 T0 为模式 1
TH0 = T0RH;
TL0 = T0RL; // 加载定时器 T0 重载值
ET0 = 1; // 使能定时器 T0 中断
TR0 = 1; // 启动定时器 T0
}

/* 定时器 T0 中断服务函数,监听串口接收并驱动蜂鸣器 */
void InterruptTimer0() interrupt 1 {
TH0 = T0RH;
TL0 = T0RL; // 重新加载定时值

/* 蜂鸣器控制 */
if (flagBuzzOn)
BUZZ = ~BUZZ;
else
BUZZ = 1;

UartRxMonitor(1); // 监听串口接收
}

1602 液晶

1602 液晶可以显示 2 行信息(每行 16 个字符),驱动电压在3.0V ~ 5.0V,但逻辑电压为4.8V ~ 5.2V,液晶工作电流最大为1.7mA,加上背光以后工作电流可达24.0mA。液晶模块一共拥有 16 个引脚,每个引脚具体功能定义如下:

引脚号 符号 描述 引脚号 符号 描述
1 GND 电源负极 0V 9 DB2 数据 2
2 VCC 电源正极 5V 10 DB3 数据 3
3 VO LCD 显示偏压输入( 可以通过输入电压调整显示对比度) 11 DB4 数据 4
4 RS 指令/数据的选择端(高电平1表示命令,低电平0表示数据) 12 DB5 数据 5
5 WR 读/写的选择端(选择读取液晶的数据、状态还是写入数据、命令) 13 DB6 数据 6
6 E 使能信号 14 DB7 数据 7
7 DB0 数据 0 15 BG VCC 背光 LED 正极 5V
8 DB1 数据 1 16 BG GND3 背光 LED 负极 0V

1602 液晶的第 4、5 管脚分别通过跳线插座ADDR0ADDR1与单片机的P1.0P1.1引脚连接,第 6 管脚则通过LCD_CS跳线座接到至单片机P1.5引脚,下面是具体的电路连接原理图:

1602 液晶内部带有80 Byte字节的 RAM 显示缓冲区,用以存储需要发送的数据,其具体结构如下图所示:

上图当中,第 1 行地址范围0x00 ~ 0x0F与液晶第 1 行的 16 个字符显示位置对应,第 2 行地址范围0x40 ~ 0x4F与液晶第 2 行的 16 个字符显示位置对应,每行中多出来的部分可用于显示移动字幕。1602 液晶显示的字符与 ASCII 字符码表对应,例如向地址0x00写入十进制数97,液晶左上方小块就会显示出字母a。此外,1602 液晶内部还拥有一个数据指针,用于指向数据将要发送到的地址。以及一个 8 位的状态字节,用于获取 1602 液晶模块内部的一些运行情况,其第0 ~ 6位表示的是当前数据的指针值,第7位表示读写操作使能(0允许读写/1禁止读写)。

操作时序

1602 液晶一共拥有 4 个基本操作时序(采用摩托罗拉的 6800 时序),这里先将需要使用到的接口和引脚进行声明:

1
2
3
4
5
#define LCD1602_DB = P0

sbit LCD1602_RS = P1^0;
sbit LCD1602_RW = P1^1;
sbit LCD1602_E = P1^5;
  1. 读状态RS = L, R/W = H, E = H):编写具体代码时,可以考虑将液晶的状态字读取到一个sta变量,通过判断其最高位来查询液晶的忙闲状态,以及查询数据指针的位置。如果读取到当前液晶处于【空闲】状态,那么程序就可以进行相应的读写操作;如果读取到的状态为【正忙】,就要继续等待并再重新判断液晶状态;另外,由于电路中的流水灯、数码管、LED 点阵、1602 液晶共用了单片机P0引脚,为了不干扰其它外设的工作,需要在读取液晶状态之后,在do while循环中将引脚电平拉低,避免引起不必要的干扰。
1
2
3
4
5
6
7
8
9
LCD1602_DB = 0xFF;
LCD1602_RS = 0;
LCD1602_RW = 1;

do {
LCD1602_E = 1;
sta = LCD1602_DB; // 读取液晶状态字
LCD1602_E = 0; // 液晶状态读取完毕之后拉低电平,避免干扰其它外设
} while (sta & 0x80); // 如果第 7 位等于 1 表示液晶正忙,需要循环进行检测直至等于 0 为止
  1. 读数据RS = H, R/W = H, E = H):不常用,这里不作详细介绍。
  2. 写指令RS = L,R/W = L,D0 ~ D7 = 指令码,E = 高脉冲):这里E = 高脉冲是指将引脚E从低电平拉高,再从高电平拉低,从而形成高脉冲。
  3. 写数据RS = H,R/W = L,D0~D7 = 数据,E = 高脉冲):与上面的写指令类似,需要将RS改成H,再把总线修改为数据即可。

如前所述,由于 1602 液晶使能引脚E属于高电平有效,为了不影响其它外设的工作,需要在不使用液晶时在代码顶部声明一句LCD1602_E = 0,上述程序未添加该语句,是由于电路在该引脚上增加了一个 15 KΩ下拉电阻R72,从硬件上保证了该引脚上电后默认为低电平状态。

设置指令

1602 液晶使用的时候,需要通过一些特定指令来进行相应功能的配置和初始化:

  1. 显示模式设置指令0x38,设置 1602 液晶的工作模式为16 x 2 显示,5 x 7 点阵,8 位数据接口
  2. 显示开/关以及光标设置指令:这里涉及两条指令,第一条指令高 5 位是固定的0b00001,低 3 位分别采用DCBD=1/0打开关闭显示,C=1/0显示或隐藏光标;B=1/0光标闪烁或者不闪烁)从高到低进行表示。第二条指令高 6 位为固定的0b000001,低 2 位分别用NSN = 1/0表示读或写一个字符后指针自动加减1S = 1/0表示写入一个字符以后整屏显示左右移动或者不移动)从高到低进行表示。
  3. 清屏指令0x01表示显示清屏,包含数据指针以及所有的显示清零;0x02 则仅仅清零数据指针,显示则不进行清零。
  4. RAM 地址设置指令:该指令最高位为1,低 7 位为 RAM 地址,RAM 地址与液晶显示字符的映射关系如前图所示。

简单实例

注意下面代码中的LcdWriteDat( *str++ )语句先将指针str指向的数据取出,然后str++自增1从而指向下一个数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
#include <reg52.h>

#define LCD1602_DB P0
sbit LCD1602_RS = P1^0;
sbit LCD1602_RW = P1^1;
sbit LCD1602_E = P1^5;

void InitLcd1602();
void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str);

void main() {
unsigned char str[] = "Hello Hank";

InitLcd1602();
LcdShowStr(2, 0, str);
LcdShowStr(0, 1, "Welcome to Chengdu");
while (1);
}

/* 等待液晶准备就绪 */
void LcdWaitReady() {
unsigned char sta;

LCD1602_DB = 0xFF;
LCD1602_RS = 0;
LCD1602_RW = 1;
do {
LCD1602_E = 1;
sta = LCD1602_DB; // 读取液晶状态字
LCD1602_E = 0;
} while (sta & 0x80); // 第 7 位等于 1 表示液晶正忙,循环检测直至等于 0 为止
}

/* 写入 1 字节的命令,参数 cmd 表示待写入的命令值 */
void LcdWriteCmd(unsigned char cmd) {
LcdWaitReady();
LCD1602_RS = 0;
LCD1602_RW = 0;
LCD1602_DB = cmd;
LCD1602_E = 1;
LCD1602_E = 0;
}

/* 写入 1 字节的数据,参数 dat 表示待写入的数据值 */
void LcdWriteDat(unsigned char dat) {
LcdWaitReady();
LCD1602_RS = 1;
LCD1602_RW = 0;
LCD1602_DB = dat;
LCD1602_E = 1;
LCD1602_E = 0;
}

/* 设置显示 RAM 的起始地址,同时也是光标的位置,参数 x, y 表示屏幕上字符的坐标 */
void LcdSetCursor(unsigned char x, unsigned char y) {
unsigned char addr;

if (y == 0) // 通过输入的屏幕坐标计算出显示 RAM 的地址
addr = 0x00 + x; // 第 1 行字符地址从 0x00 起始
else
addr = 0x40 + x; // 第 2 行字符地址从 0x40 起始

LcdWriteCmd(addr | 0x80); // 设置 RAM 地址
}

/* 在液晶上显示字符串,(x,y)-对应屏幕上的起始坐标,str-字符串指针 */
void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str) {
LcdSetCursor(x, y); //设置起始地址

/* 连续写入字符串数据,直到检测到结束符 */
while (*str != '\0') {
LcdWriteDat(*str++); // 先获取 str 指针所指向的数据,然后再自增 1
}
}

/* 初始化 1602 液晶 */
void InitLcd1602() {
LcdWriteCmd(0x38); // 16 x 2 显示,5 x 7 点阵,8 位数据接口
LcdWriteCmd(0x0C); // 打开显示,关闭光标
LcdWriteCmd(0x06); // 文字不动,地址自动加 1
LcdWriteCmd(0x01); // 清屏
}

通信时序

通信时序这个概念可以从时间顺序两个维度进行理解,所谓顺序是指通信的数据与操作必须保持一定的先后顺序,例如:UART 串口通信当中,首先 1 位起始位,然后 8 位数据位,最后 1 位停止位;虽然 1602 液晶写指令对于RS = L,R/W = L,D0 ~ D7 = 指令码的顺序没有要求,但是E = 高脉冲操作必须放置在最后。

而所谓的时间则内容相对复杂,例如 UART 通信里每一位的时间宽度为1 / 波特率,前面内容中有提到单片机读取 RXD 引脚数据时,每一位数据的传输时间都被平均分为 16 等份,如果第 7、8、9 次读到的结果有两次为高电平1就认为该位为高电平,有两次为低电平0就认为该位为低电平,如果波特率产生的误差,让第 7、8、9 次采样还能够位于停止位范围内,这样的采样率就被认为是正确可用的,请仔细观察下图:

上图使用三个箭头来表示第 7、8、9 次采样的位置,注意采样至 D7 位时,有一个采样点已经偏移出去,由于另外两次采样位置正确,因此采集到数据依然被认为是正确可信的。事实上 UART 通信的波特率允许存储一定范围的误差,波特率计算的时候,如果发现结果当中出现了小数,就需要格外留心出现误差。 实验电路中之所以采用11.0592 MHz晶振,就是由于11059200 ÷ 12 ÷ 32 ÷ 9600得到的结果是一个整数,如果改用12 MHz晶振则将会得到一个小数,设置较高波特率时将会产生错误。

接下来研究 1602 液晶的时序问题,参考数据手册提供的时序图,下面的读操作时序图当中,RS引脚和R/W引脚首先进行变化,由于是读操作,所以R/W引脚被置为高电平。又由于读取的可以是数据也可以是指令,所以RS引脚有可能是高电平也可能是低电平,注意下图中的表示方法。

RSR/W变化之后再经历Tsp1长度的时间,使能引脚E才会发生正跳变。而使能引脚E拉高持续tD长度时间以后,引脚DB才会输出有效数据,读取完成之后,再将使能引脚E下拉为低电平,一段时间以后RSR/WDB就可以准备下一次读写了。

下面的写操作时序图与读操作的区别,在于写操作是由单片机来改变DB的状态,因此需要在使能引脚E变化之前进行操作。

上述两张时序图上存在着诸多时序参数标签,例如使能引脚E的上升时间tR下降时间时间tF,从一个上升沿至下一个上升沿之间的长度周期tC;以及使能引脚E下降沿之后,R/WRS变化的时间间隔tHD1等等。根据数据手册内容,将 1602 液晶的相关时序参数总结如下表:

符号 时序参数 最小值 最大值 说明
tC E 信号周期 400 纳秒 -- 使能引脚E从本次上升沿到下次上升沿的最短时间为400 ns,每条 C 语句需要耗费一个甚至多个机器周期,每个机器周期需要耗费1 us以上,则该条件满足。
tPW E 脉冲宽度 150 纳秒 -- 使能引脚E高电平持续时间最短为150 ns,该条件也同样满足。
tR, tF E 上升沿/下降沿时间 -- 25 纳秒 使能引脚E的上升沿/下降沿时间不能超过25 ns,示波器实际测量该引脚上升下降沿时间在10 ns ~ 15 ns范围,该条件满足。
tSP1 地址建立时间 30 纳秒 -- RSR/W引脚使能之后至少保持30 ns,使能引脚E才会变为高电平,该条件依然满足。
tHD1 地址保持时间 10 纳秒 -- 使能引脚E下拉为低电平以后至少保持10ns以上,RSR/W才能进行变化,该条件满足。
tD 数据建立时间(读) -- 100 纳秒 使能引脚E变为高电平最多100 ns之后,1602 液晶模块就会将数据送出,从而能够正常去读取状态和数据。
tHD2 数据保持时间(读) 20 纳秒 -- 读操作过程当中,使能引脚E变为低电平以后至少保持20 ns,数据总线DB才可以发生变化,该条件满足。
tSP2 数据建立时间(写) 40 纳秒 -- DB数据总线准备好以后需要至少保持40 ns,使能引脚E才可以从低电平变为高电平使能,该条件完全满足。
tHD2 数据保持时间(写) 10 纳秒 -- 写操作过程当中,引脚E变为低电平以后至少保持10 ns,数据总线DB才能够变化,该条件也完全满足。

1602 液晶综合实验

字符串移动显示

这里动手编写一段代码在 1602 液晶上显示两行字符串,并实现整屏的重复左移;首先将 1602 液晶的底层功能函数LcdWaitReady()LcdWriteCmd()LcdWriteDat()LcdShowStr()LcdSetCursor()InitLcd1602()封装为一个独立的Lcd1602.c文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#include <reg52.h>

#define LCD1602_DB P0
sbit LCD1602_RS = P1 ^ 0;
sbit LCD1602_RW = P1 ^ 1;
sbit LCD1602_E = P1 ^ 5;

/* 等待液晶准备完毕 */
void LcdWaitReady() {
unsigned char sta;

LCD1602_DB = 0xFF;
LCD1602_RS = 0;
LCD1602_RW = 1;

do {
LCD1602_E = 1;
sta = LCD1602_DB; // 读取状态字
LCD1602_E = 0;
} while ( sta & 0x80); // 如果 bit7 等于 1 表示液晶正忙,循环检测直至等于表示空闲的 0 为止
}

/* 写入 1 字节命令到液晶,参数 cmd 表示待写入的命令 */
void LcdWriteCmd(unsigned char cmd) {
LcdWaitReady();
LCD1602_RS = 0;
LCD1602_RW = 0;
LCD1602_DB = cmd;
LCD1602_E = 1;
LCD1602_E = 0;
}

/* 写入 1 字节数据到液晶,参数 dat 表示待写入的数据 */
void LcdWriteDat(unsigned char dat) {
LcdWaitReady();
LCD1602_RS = 1;
LCD1602_RW = 0;
LCD1602_DB = dat;
LCD1602_E = 1;
LCD1602_E = 0;
}

/* 设置显示 RAM 的起始地址,即光标位置,参数 x 和 y 分别对应屏幕的字符坐标 */
void LcdSetCursor(unsigned char x, unsigned char y) {
unsigned char addr;

/* 根据屏幕坐标计算显示 RAM 地址 */
if (y == 0)
addr = 0x00 + x; // 第 1 行字符地址从 0x00 起始
else
addr = 0x40 + x; // 第 2 行字符地址从 0x40 起始

LcdWriteCmd(addr | 0x80); // 设置 RAM 地址
}

/* 在液晶上显示字符串,参数 x 和 y 对应屏幕上的起始坐标,参数 str 是字符串指针,参数 len 是需要显示的字符长度 */
void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str, unsigned char len) {
LcdSetCursor(x, y); // 设置起始地址

/* 连续写入 len 个字符数据 */
while (len--) {
LcdWriteDat(*str++); // 首先获得 str 指向的数据,然后 str 再自增 1
}
}

/* 初始化1602液晶 */
void InitLcd1602() {
LcdWriteCmd(0x38); // 16×2显示,5×7点阵,8位数据接口
LcdWriteCmd(0x0C); // 开启显示器,关闭光标
LcdWriteCmd(0x06); // 文字保持不动,地址自增 1
LcdWriteCmd(0x01); // 清屏
}

然后再建立一个main.c文件,通过extern关键字分别调用上面Lcd1602.c文件中用于初始化液晶的InitLcd1602()和显示内容的LcdShowStr()函数,注意代码当中for语句在数组上的灵活应用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
#include <reg52.h>

bit flag500ms = 0; // 定时标志位 500ms
unsigned char T0RH = 0; // 定时器 T0 定时值高字节
unsigned char T0RL = 0; // 定时器 T0 定时值低字节

unsigned char code str1[] = "Hello Hank..."; // 待显示第 1 行字符串
unsigned char code str2[] = "Hello Abel..."; // 待显示第 2 行字符串,需要保持与第 1 行长度相同

void ConfigTimer0(unsigned int ms);
extern void InitLcd1602();
extern void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str, unsigned char len);

void main() {
unsigned char i;
unsigned char index = 0; // 移动索引
unsigned char pdata bufMove1[16 + sizeof(str1) + 16]; // 移动显示缓冲区 1
unsigned char pdata bufMove2[16 + sizeof(str2) + 16]; // 移动显示缓冲区 2

EA = 1; // 开总中断
ConfigTimer0(10); // 配置定时器 T0 定时 10ms
InitLcd1602(); // 初始化液晶

/* 显示缓冲区头部填充空格 */
for (i = 0; i < 16; i++) {
bufMove1[i] = ' ';
bufMove2[i] = ' ';
}
/* 将待显示字符串拷贝至显示缓冲区中间位置 */
for (i = 0; i < (sizeof(str1) - 1); i++) {
bufMove1[16 + i] = str1[i];
bufMove2[16 + i] = str2[i];
}
/* 显示缓冲区尾部填充空格 */
for (i = (16 + sizeof(str1) - 1); i < sizeof(bufMove1); i++) {
bufMove1[i] = ' ';
bufMove2[i] = ' ';
}

while (1) {
/* 每间隔 500ms 移动一次屏幕 */
if (flag500ms) {
flag500ms = 0;

/* 从显示缓冲区提取一段字符显示到液晶 */
LcdShowStr(0, 0, bufMove1 + index, 16);
LcdShowStr(0, 1, bufMove2 + index, 16);
index++; // 移动索引自增,完成左移

/* 起始位置到达字符串尾部以后返回从头开始 */
if (index >= (16 + sizeof(str1) - 1)) {
index = 0;
}
}
}
}

/* 配置并启动定时器 T0,参数 ms 表示定时时间 */
void ConfigTimer0(unsigned int ms) {
unsigned long tmp; //临时变量

tmp = 11059200 / 12; // 定时器计数频率
tmp = (tmp * ms) / 1000; // 计算所需的计数值
tmp = 65536 - tmp; // 计算定时器重载值
tmp = tmp + 12; // 补偿中断响应延时造成的误差

T0RH = (unsigned char)(tmp >> 8); // 定时器重载值拆分为高低字节
T0RL = (unsigned char)tmp;
TMOD &= 0xF0; // 清零定时器 T0 的控制位
TMOD |= 0x01; // 配置定时器 T0 为模式 1
TH0 = T0RH;
TL0 = T0RL; // 加载定时器 T0 的定时值
ET0 = 1; // 使能定时器 T0中断
TR0 = 1; // 启动定时器 T0
}

/* 定时器 T0 中断服务函数 */
void InterruptTimer0() interrupt 1 {
static unsigned char tmr500ms = 0;
TH0 = T0RH;
TL0 = T0RL; // 重新加载定时值
tmr500ms++;

if (tmr500ms >= 50) {
tmr500ms = 0;
flag500ms = 1;
}
}

基于按键与液晶的计算器

接下来再编写一个相对更复杂的实验,一个由 3 个源文件组成的简易整数计算器程序。为了简化程序实现,这里暂时不考虑连加、连减、小数的情况。上下左右按键分别用来表示+ - × ÷,回车和 ESC 键则分别表示=归 0。程序共划分为用于 1602 液晶显示的Lcd1602.c,以及用于按键动作扫描的keyboard.c和主函数main.c一共 3 个源文件。首先,Lcd1602.c文件根据当前实验的需要,添加了区域清屏LcdAreaClear()和整屏清屏LcdFullClear()两个功能函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <reg52.h>

#define LCD1602_DB P0
sbit LCD1602_RS = P1 ^ 0;
sbit LCD1602_RW = P1 ^ 1;
sbit LCD1602_E = P1 ^ 5;

/* 等待液晶准备完毕 */
void LcdWaitReady() {
unsigned char sta;

LCD1602_DB = 0xFF;
LCD1602_RS = 0;
LCD1602_RW = 1;

do {
LCD1602_E = 1;
sta = LCD1602_DB; // 读取状态字
LCD1602_E = 0;
} while ( sta & 0x80); // 如果 bit7 等于 1 表示液晶正忙,循环检测直至等于表示空闲的 0 为止
}

/* 写入 1 字节命令到液晶,参数 cmd 表示待写入的命令 */
void LcdWriteCmd(unsigned char cmd) {
LcdWaitReady();
LCD1602_RS = 0;
LCD1602_RW = 0;
LCD1602_DB = cmd;
LCD1602_E = 1;
LCD1602_E = 0;
}

/* 写入 1 字节数据到液晶,参数 dat 表示待写入的数据 */
void LcdWriteDat(unsigned char dat) {
LcdWaitReady();
LCD1602_RS = 1;
LCD1602_RW = 0;
LCD1602_DB = dat;
LCD1602_E = 1;
LCD1602_E = 0;
}

/* 设置显示 RAM 的起始地址,即光标位置,参数 x 和 y 分别对应屏幕的字符坐标 */
void LcdSetCursor(unsigned char x, unsigned char y) {
unsigned char addr;

/* 根据屏幕坐标计算显示 RAM 地址 */
if (y == 0)
addr = 0x00 + x; // 第 1 行字符地址从 0x00 起始
else
addr = 0x40 + x; // 第 2 行字符地址从 0x40 起始

LcdWriteCmd(addr | 0x80); // 设置 RAM 地址
}

/* 在液晶上显示字符串,参数 x 和 y 对应屏幕上的起始坐标,参数 str 是字符串指针,参数 len 是需要显示的字符长度 */
void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str, unsigned char len) {
LcdSetCursor(x, y); // 设置起始地址

/* 连续写入字符串,直至检测到结束符 */
while (*str != '\0') {
LcdWriteDat(*str++); // 首先获得 str 指向的数据,然后 str 再自增 1
}
}

/* 区域清屏,清除 x、y 坐标起始的 len 个字符 */
void LcdAreaClear(unsigned char x, unsigned char y, unsigned char len) {
LcdSetCursor(x, y); // 设置起始地址

while (len--) {
LcdWriteDat(' '); // 循环写入空格
}
}

/* 整屏清除 */
void LcdFullClear() {
LcdWriteCmd(0x01);
}

/* 初始化1602液晶 */
void InitLcd1602() {
LcdWriteCmd(0x38); // 16×2显示,5×7点阵,8位数据接口
LcdWriteCmd(0x0C); // 开启显示器,关闭光标
LcdWriteCmd(0x06); // 文字保持不动,地址自增 1
LcdWriteCmd(0x01); // 清屏
}

然后,keyboard.c封装了前面小节当中使用的矩阵按键驱动,这个按键驱动只负责调用上层实现的按键动作函数,而每个按键的具体动作则会放置到后续的main.c文件里实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
#include <reg52.h>

sbit KEY_IN_1 = P2 ^ 4;
sbit KEY_IN_2 = P2 ^ 5;
sbit KEY_IN_3 = P2 ^ 6;
sbit KEY_IN_4 = P2 ^ 7;

sbit KEY_OUT_1 = P2 ^ 3;
sbit KEY_OUT_2 = P2 ^ 2;
sbit KEY_OUT_3 = P2 ^ 1;
sbit KEY_OUT_4 = P2 ^ 0;

/* 矩阵按键编号和标准键盘键码的映射表 */
unsigned char code KeyCodeMap[4][4] = {
{'1', '2', '3', 0x26}, // 数字键 1、数字键 2、数字键 3、向上键
{'4', '5', '6', 0x25}, // 数字键 4、数字键 5、数字键 6、向左键
{'7', '8', '9', 0x28}, // 数字键 7、数字键 8、数字键 9、向下键
{'0', 0x1B, 0x0D, 0x27} // 数字键 0、ESC 键、 回车键、 向右键
};

/* 矩阵按键的当前状态 */
unsigned char pdata KeySta[4][4] = {{1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}};

extern void KeyAction(unsigned char keycode);

/* 按键驱动函数,检测按键动作,调度相应动作函数,需在主循环中调用 */
void KeyDriver() {
unsigned char i, j;

/* 键值备份,保存前一次的矩阵按键状态 */
static unsigned char pdata backup[4][4] = {{1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}};

/* 循环扫描 4*4 矩阵按键 */
for (i = 0; i < 4; i++) {
for (j = 0; j < 4; j++) {

/* 检测按键动作 */
if (backup[i][j] != KeySta[i][j]) {
/* 按键按下时需要执行的任务 */
if (backup[i][j] != 0) {
KeyAction(KeyCodeMap[i][j]); // 调用按键动作函数
}
backup[i][j] = KeySta[i][j]; // 更新前一次备份的值
}
}
}
}

/* 按键扫描函数,需在定时中断中调用,推荐调用间隔1ms */
void KeyScan() {
unsigned char i;
static unsigned char keyout = 0; // 矩阵按键扫描输出索引

/* 矩阵按键扫描缓冲区 */
static unsigned char keybuf[4][4] = {{0xFF, 0xFF, 0xFF, 0xFF}, {0xFF, 0xFF, 0xFF, 0xFF}, {0xFF, 0xFF, 0xFF, 0xFF}, {0xFF, 0xFF, 0xFF, 0xFF}};

/* 将一行上 4 个按键的值移入缓冲区 */
keybuf[keyout][0] = (keybuf[keyout][0] << 1) | KEY_IN_1;
keybuf[keyout][1] = (keybuf[keyout][1] << 1) | KEY_IN_2;
keybuf[keyout][2] = (keybuf[keyout][2] << 1) | KEY_IN_3;
keybuf[keyout][3] = (keybuf[keyout][3] << 1) | KEY_IN_4;

/* 完成消抖之后更新按键的状态,因为每行 4 个按键,所以要循环 4 次 */
for (i = 0; i < 4; i++) {
/* 连续 4 次扫描值都为 0 ,即 16ms 内都只检测到按下状态,就认为按键已经稳定按下 */
if ((keybuf[keyout][i] & 0x0F) == 0x00) {
KeySta[keyout][i] = 0;
}
/* 连续 4 次扫描值都为 1 ,即 16ms 内都只检测到弹起状态,就认为按键已经稳定弹起 */
else if ((keybuf[keyout][i] & 0x0F) == 0x0F) {
KeySta[keyout][i] = 1;
}
}

/* 执行下一次扫描输出 */
keyout++; // 输出索引自增
keyout &= 0x03; // 索引值自增到 4 以后归零

/* 根据索引值释放当前输出引脚,并拉低下次的输出引脚 */
switch (keyout) {
case 0: KEY_OUT_4 = 1; KEY_OUT_1 = 0; break;
case 1: KEY_OUT_1 = 1; KEY_OUT_2 = 0; break;
case 2: KEY_OUT_2 = 1; KEY_OUT_3 = 0; break;
case 3: KEY_OUT_3 = 1; KEY_OUT_4 = 0; break;
default: break;
}
}

最后,main.c文件用于实现全部应用层面的功能,例如:计算信息显示、按键动作响应以及定时器中断的调度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
#include <reg52.h>

unsigned char step = 0; // 操作步骤
unsigned char oprt = 0; // 运算类型
signed long num1 = 0; // 操作数 1
signed long num2 = 0; // 操作数 2
signed long result = 0; // 运算结果
unsigned char T0RH = 0; // 定时器 T0 重载值的高字节
unsigned char T0RL = 0; // 定时器 T0 重载值的低字节

void ConfigTimer0(unsigned int ms);

extern void KeyScan();
extern void KeyDriver();
extern void InitLcd1602();
extern void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str);
extern void LcdAreaClear(unsigned char x, unsigned char y, unsigned char len);
extern void LcdFullClear();

void main() {
EA = 1; // 总中断使能
ConfigTimer0(1); // 配置定时器 T0 定时 1ms
InitLcd1602(); // 初始化 1602 液晶
LcdShowStr(15, 1, "0"); // 初始显示数字 0

while (1) {
KeyDriver(); // 调用按键驱动
}
}

/* 长整型数据转换为字符串,参数 str 是字符串指针,参数 dat
* 是待转换数值,返回字符串长度 */
unsigned char LongToString(unsigned char *str, signed long dat) {
signed char i = 0;
unsigned char len = 0;
unsigned char buf[12];

/* 如果为负数,首先取绝对值,然后向指针上添加负号 */
if (dat < 0) {
dat = -dat;
*str++ = '-';
len++;
}

/* 转换为低位在前的十进制数组 */
do {
buf[i++] = dat % 10;
dat /= 10;
} while (dat > 0);

len += i; // 局部变量 i 最后的值为有效字符个数

/* 将数组值转换为ASCII码反向拷贝到接收指针上 */
while (i-- > 0) {
*str++ = buf[i] + '0';
}
*str = '\0'; // 添加字符串结束符

return len; // 返回字符串长度
}

/* 显示运算符,参数 y 是显示位置,参数 type 是运算符类型 */
void ShowOprt(unsigned char y, unsigned char type) {
switch (type) {
case 0: LcdShowStr(0, y, "+"); break; // 0 代表 +
case 1: LcdShowStr(0, y, "-"); break; // 1 代表 -
case 2: LcdShowStr(0, y, "*"); break; // 2 代表 *
case 3: LcdShowStr(0, y, "/"); break; // 3 代表 /
default: break;
}
}

/* 计算器复位,清零变量值与屏幕显示 */
void Reset() {
num1 = 0;
num2 = 0;
step = 0;

LcdFullClear();
}

/* 数字按键动作函数,参数 n 表示按键输入数值 */
void NumKeyAction(unsigned char n) {
unsigned char len;
unsigned char str[12];

/* 如果计算已经完成,就开始新的计算 */
if (step > 1) {
Reset();
}
/* 输入第 1 个操作数 */
if (step == 0) {
num1 = num1 * 10 + n; // 输入数值累加到原来的操作数
len = LongToString(str, num1); // 新的数值转换为字符串
LcdShowStr(16 - len, 1, str); // 显示到液晶第 2 行
}
/* 输入第 2 个操作数 */
else {
num2 = num2 * 10 + n; // 输入数值累加到原来的操作数
len = LongToString(str, num2); // 新的数值转换为字符串
LcdShowStr(16 - len, 1, str); // 显示到液晶第 2 行
}
}

/* 运算符按键动作函数,运算符类型type */
void OprtKeyAction(unsigned char type) {
unsigned char len;
unsigned char str[12];

/* 第 2 个操作数尚未输入时响应,即不支持连续操作 */
if (step == 0) {
len = LongToString(str, num1); // 第 1 个操作数转换为字符串

LcdAreaClear(0, 0, 16 - len); // 清除第 1 行左边的字符位置
LcdShowStr(16 - len, 0, str); // 字符串靠右显示在第 1 行
ShowOprt(1, type); // 在第 2 行显示操作符
LcdAreaClear(1, 1, 14); // 清除第 2 行中间的字符位置
LcdShowStr(15, 1, "0"); // 在第 2 行最右端显示 0

oprt = type; // 记录操作类型
step = 1;
}
}

/* 计算结果函数 */
void GetResult() {
unsigned char len;
unsigned char str[12];

/* 第 2 个操作数输入以后才执行计算 */
if (step == 1) {
step = 2;

/* 根据运算符类型计算结果(未考虑溢出) */
switch (oprt) {
case 0: result = num1 + num2; break;
case 1: result = num1 - num2; break;
case 2: result = num1 * num2; break;
case 3: result = num1 / num2; break;
default: break;
}

/* 将之前的第 2 操作数和运算符显示至第 1 行 */
len = LongToString(str, num2);
ShowOprt(0, oprt);
LcdAreaClear(1, 0, 16 - 1 - len);
LcdShowStr(16 - len, 0, str);

/* 计算结果、等号显示到第 2 行 */
len = LongToString(str, result);
LcdShowStr(0, 1, "=");
LcdAreaClear(1, 1, 16 - 1 - len);
LcdShowStr(16 - len, 1, str);
}
}

/* 按键动作函数,根据按键码执行相应操作,参数 keycode 表示按键键码 */
void KeyAction(unsigned char keycode) {
if ((keycode >= '0') && (keycode <= '9')) {
NumKeyAction(keycode - '0');
} else if (keycode == 0x26) {
OprtKeyAction(0); // 向上键,+
} else if (keycode == 0x28) {
OprtKeyAction(1); // 向下键,-
} else if (keycode == 0x25) {
OprtKeyAction(2); // 向左键,*
} else if (keycode == 0x27) {
OprtKeyAction(3); // 向右键,÷
} else if (keycode == 0x0D) {
GetResult(); // 回车键,计算结果
} else if (keycode == 0x1B) {
Reset(); // Esc键,清除
LcdShowStr(15, 1, "0");
}
}

/* 配置并启动T0,ms-T0定时时间 */
void ConfigTimer0(unsigned int ms) {
unsigned long tmp; //临时变量

tmp = 11059200 / 12; // 定时器计数频率
tmp = (tmp * ms) / 1000; // 计算所需计数值
tmp = 65536 - tmp; // 计算定时值
tmp = tmp + 28; // 补偿中断响应延时造成的误差

T0RH = (unsigned char)(tmp >> 8); // 定时值拆分为高、低字节
T0RL = (unsigned char)tmp;

TMOD &= 0xF0; // 清零定时器 T0 控制位
TMOD |= 0x01; // 配置定时器 T0 为模式 1
TH0 = T0RH;
TL0 = T0RL; // 加载定时器 T0 初始值
ET0 = 1; // 使能定时器 T0 中断
TR0 = 1; // 启动定时器 T0
}

/* 定时器 T0 中断服务函数,执行按键扫描 */
void InterruptTimer0() interrupt 1 {
TH0 = T0RH;
TL0 = T0RL; // 重新加载定时值
KeyScan(); // 调用按键扫描
}

1602 液晶与串口通信综合试验

实际工作当中,单片机经常需要通过串口与电脑上安装的上位机软件进行交互,从而执行不同的功能。本小节试验会通过电脑上的串口调试助手发送 3 条指令:buzz on打开蜂鸣器、buzz off关闭蜂鸣器、showstr将命令后面的字符串显示到 1602 液晶,单片机接收到这些命令以后会将其原样返回。

发送一帧包含多个字节的数据时,这些数据会逐个字节连续不断进行发送,中间没有间隔或者间隔时间极短,当该帧数据发送完毕之后,将会间隔相对较长的一段时间不再发送数据,通信总线也就会空闲一段较长时间,因此可以在代码当中设置一个总线空闲定时器,该定时器在有数据传输时清零,而在总线空闲时累加,当累加至30 ms毫秒时间之后,就认为一帧数据已经传输完毕,其它程序可以开始进行数据的处理。本次数据处理完毕后就恢复到初始状态,并开始准备下一轮接收。这里用于判定每帧结束的空闲时间并无一个固定值,开发时需要综合考虑如下两个原则:

  1. 该时间必须大于波特率周期,这是由于单片机接收中断产生于一个字节数据接收完毕之后,程序无法了解其具体接收过程,因而在至少一个波特率周期内,不能认为达到了每帧结束的空闲时间。
  2. 需要考虑到发送者的系统延时,发送者并不总是能保证数据严格无间隔发送,因此需要再附加几十毫秒处理时间,本实验选取的30 ms能适应大部分波特率(大于1200)以及大部分计算机或其它单片机设备的系统延时。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
/** Uart.c 文件,基于帧模式的 UART 串口驱动程序 */
#include <reg52.h>

bit flagFrame = 0; // 帧接收完成标志,即接收到一帧新数据
bit flagTxd = 0; // 单字节发送完成标志,用来替代TXD中断标志位
unsigned char cntRxd = 0; // 接收字节计数器
unsigned char pdata bufRxd[64]; // 接收字节缓冲区

extern void UartAction(unsigned char *buf, unsigned char len);

/* 串口配置函数,baud-通信波特率 */
void ConfigUART(unsigned int baud) {
SCON = 0x50; // 配置串口为工作方式 1
TMOD &= 0x0F; // 清零定时器 T1 控制位
TMOD |= 0x20; // 配置定时器 T1 为工作模式 2
TH1 = 256 - (11059200 / 12 / 32) / baud; // 计算定时器 T1 的定时值
TL1 = TH1; // 将高位的定时值作为低位的初值
ET1 = 0; // 禁用定时器 T1 中断
ES = 1; // 使能串口中断
TR1 = 1; // 启动定时器 T1
}

/* 串口数据写入函数,参数 buf 是等待发送数据的指针,参数 len 是发送的长度 */
void UartWrite(unsigned char *buf, unsigned char len) {
/* 循环发送所有的字节 */
while (len--) {
flagTxd = 0; // 发送完成标志位清零
SBUF = *buf++; // 发送 1 个字节的数据
while (!flagTxd); // 等待该字节发送完成
}
}

/* 串口数据读取函数,参数 buf 表示接收指针的指针,参数 len 表示读取的长度,返回值是实际读取到的长度 */
unsigned char UartRead(unsigned char *buf, unsigned char len) {
unsigned char i;
/* 当指定的读取长度大于实际接收到的数据长度时,将读取长度设置为实际接收到的数据长度 */
if (len > cntRxd) {
len = cntRxd;
}

/* 将接收到的数据拷贝至接收指针 */
for (i = 0; i < len; i++) {
*buf++ = bufRxd[i];
}

cntRxd = 0; // 接收计数器清零
return len; // 返回实际读取的长度
}

/* 串口接收监控函数,基于空闲时间来判定帧结束,在定时中断中调用,参数 ms 表示定时间隔 */
void UartRxMonitor(unsigned char ms) {
static unsigned char cntbkp = 0; // 接收计数变量
static unsigned char idletmr = 0; // 总线空闲计时变量

/* 当接收计数变量大于零时,监听总线的空闲时间 */
if (cntRxd > 0) {
/* 如果接收计数变量发生改变(即刚接收到数据时),空闲计时变量清零 */
if (cntbkp != cntRxd) {
cntbkp = cntRxd;
idletmr = 0;
}
/* 如果接收计数变量没有改变(即总线保持空闲时),将空闲时间进行累加 */
else {
/* 总线空闲计时小于 30ms 持续累加 */
if (idletmr < 30) {
idletmr += ms;
/* 总线空闲计时达到 30ms,就认为一帧数据接收完毕 */
if (idletmr >= 30) {
flagFrame = 1; // 设置数据帧接收完成标志位
}
}
}
} else {
cntbkp = 0;
}
}

/* 串口驱动函数,监测数据帧的接收,调度功能函数,需在主循环中调用 */
void UartDriver() {
unsigned char len;
unsigned char pdata buf[40];
/* 当数据帧到达时,读取并处理该命令 */
if (flagFrame) {
flagFrame = 0;

len = UartRead(buf, sizeof(buf)); // 将接收到的命令读取至缓冲区
UartAction(buf, len); // 传递数据帧,调用动作执行函数
}
}

/* 串口中断服务函数 */
void InterruptUART() interrupt 4 {
/* 接收到新的字节数据 */
if (RI) {
RI = 0; // 接收中断标志位清零

/* 接收缓冲区尚未用完时,保存接收字节,并递增计数器 */
if (cntRxd < sizeof(bufRxd)) {
bufRxd[cntRxd++] = SBUF;
}
}
/* 字节发送完毕 */
if (TI) {
TI = 0; // 发送中断标志位清零
flagTxd = 1; // 发送完成标志位置 1
}
}

上面的Uart.c文件里存在两个需要注意的知识点,首先对于接收数据的处理,串口中断时会将接收到的字节保存至bufRxd缓冲区,同时在其它定时器中断内不断调用UartRxMonitor()监控一帧数据是否接收完毕(判定原则即如前所述的空闲时间);如果判断一帧数据已经接收完毕,就会设置flagFrame标志位,主循环可以通过调用UartDriver()对该标志位进行检测并处理接收到的数据;处理接收到的数据时,将会首先通过串口读取函数UartRead()将接收缓冲区bufRxd内的数据读取出来,然后再对读取到的数据进行判断处理。

代码中之所以不直接对bufRxd接收到的数据进行处理,主要是为了提高串行接口的收发效率:如果在bufRxd中处理数据,由于新接收的数据会破坏之前的数据,此时将不能再接收任何其它的数据;另外,数据处理过程可能会耗费较长时间,在这个时间里如果无法接收新的命令,可能会被发送方认为已经失去响应了。上面代码里实现的这种双缓冲机制大大改善了这些问题,由于数据拷贝所需的时间较短,只要完成拷贝以后,bufRxd就可以马上开始接收新的数据。

另外,串口数据写入函数UartWrite()会将数据指针buf指向的数据块连续发送出去,虽然串口程序启用了中断,但是此时发送功能并没有在中断里完成,而是仍然依靠查询发送中断标志位flagTxd来完成(由于中断函数内部必须清零发送中断请求标志位TI,否则中断将会重复进入执行,所以新建了flagTxd标志位来替代TI);虽然也可以采用先将发送数据拷贝至一个缓冲区,然后再在中断内将缓冲区数据发送的方式,但是这样会耗费额外的内存,并且让程序更加复杂。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
/** main.c 文件 */
#include <reg52.h>

sbit BUZZ = P1 ^ 6; // 蜂鸣器控制引脚

bit flagBuzzOn = 0; // 蜂鸣器启动标志位
unsigned char T0RH = 0; // 定时器 T0 定时值高字节
unsigned char T0RL = 0; // 定时器 T0 定时值低字节

void ConfigTimer0(unsigned int ms);

extern void UartDriver();
extern void ConfigUART(unsigned int baud);
extern void UartRxMonitor(unsigned char ms);
extern void UartWrite(unsigned char *buf, unsigned char len);
extern void InitLcd1602();
extern void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str);
extern void LcdAreaClear(unsigned char x, unsigned char y, unsigned char len);

void main() {
EA = 1; // 总中断使能
ConfigTimer0(1); // 配置定时器 T0 定时 1ms
ConfigUART(9600); // 配置波特率为 9600
InitLcd1602(); // 初始化液晶

while (1) {
UartDriver(); // 调用串口驱动程序
}
}

/* 内存比较函数,比较两个指针所指向的内存数据是否相同;参数 ptr1 是待比较指针 1,参数 ptr2 是待比较指针 2,参数 len 是待比较长度 */
bit CmpMemory(unsigned char *ptr1, unsigned char *ptr2, unsigned char len) {
while (len--) {
/* 遇到不相等数据时即刻返回0 */
if (*ptr1++ != *ptr2++) {
return 0; // 两段内存数据不相同返回 0
}
}
return 1; // 两段内存数据完全相同返回 1
}

/* 串口动作函数,根据接收到的命令帧执行相应的动作;参数 buf 是接收到的命令帧指针,参数 len 是命令帧的长度 */
void UartAction(unsigned char *buf, unsigned char len) {
unsigned char i;
unsigned char code cmd0[] = "buzz on"; // 打开蜂鸣器命令
unsigned char code cmd1[] = "buzz off"; // 关闭蜂鸣器命令
unsigned char code cmd2[] = "showstr "; // 1602 液晶字符串显示命令

/* 命令长度数组 */
unsigned char code cmdLen[] = { sizeof(cmd0) - 1, sizeof(cmd1) - 1, sizeof(cmd2) - 1 };

/* 命令指针数组 */
unsigned char code *cmdPtr[] = { &cmd0[0], &cmd1[0], &cmd2[0] };

/* 遍历命令长度数组,查找相同的命令 */
for (i = 0; i < sizeof(cmdLen); i++) {

/* 接收到的数据长度不能小于命令长度 */
if (len >= cmdLen[i]) {
if (CmpMemory(buf, cmdPtr[i], cmdLen[i])) {
break; // 比较相同时退出循环
}
}
}

/* 上面的循环退出时,i 的值就是当前命令的索引值 */
switch (i) {
case 0: flagBuzzOn = 1; break; // 开启蜂鸣器
case 1: flagBuzzOn = 0; break; // 关闭蜂鸣器
case 2:
buf[len] = '\0'; // 为接收到的字符串添加结束符
LcdShowStr(0, 0, buf + cmdLen[2]); // 显示命令后面的字符串
i = len - cmdLen[2]; // 计算有效字符个数,如果少于 16 就清除屏幕上的后续字符位
if (i < 16) {
LcdAreaClear(i, 0, 16 - i);
} break;
// 如果没有找到相符的命令,就给上位机发送【错误命令】提示
default: UartWrite("bad command.\r\n", sizeof("bad command.\r\n") - 1); return;
}

buf[len++] = '\r'; // 有效命令被执行后,在原命令帧之后添加
buf[len++] = '\n'; // 回车换行符后返回给上位机,表示已执行
UartWrite(buf, len);
}

/* 配置并启动定时器 T0,参数 ms 表示定时时间 */
void ConfigTimer0(unsigned int ms) {
unsigned long tmp; // 临时变量

tmp = 11059200 / 12; // 定时器计数频率
tmp = (tmp * ms) / 1000; // 计算计数值
tmp = 65536 - tmp; // 计算定时器初始值
tmp = tmp + 33; // 补偿由于中断响应延迟造成的误差

T0RH = (unsigned char)(tmp >> 8); // 拆分定时值为高低位
T0RL = (unsigned char)tmp;
TMOD &= 0xF0; // 定时器 T0 控制位置 0
TMOD |= 0x01; // 配置定时器 T0 为工作模式 1
TH0 = T0RH;
TL0 = T0RL; // 定时值存储寄存器赋初值
ET0 = 1; // 定时器 T0 中断使能
TR0 = 1; // 启动定时器 T0
}

/* 定时器 T0 中断服务函数,用于监听串口接收和驱动蜂鸣器 */
void InterruptTimer0() interrupt 1 {
TH0 = T0RH;
TL0 = T0RL; // 重新加载定时值
if (flagBuzzOn)
BUZZ = ~BUZZ;
else
BUZZ = 1;
UartRxMonitor(1); // 监听串口接收
}

上面代码当中,串口接收数据的解析方法具有较强普适性,需要用心体会并灵活运用。首先,CmpMemory()函数用于比较两段内存数据,函数将会接收两段数据的指针,然后通过语句if (*ptr1++ != *ptr2++)逐个字节进行比较,并在比较完成以后将两个指针都自增1。从而判断接收到的数据与程序内置命令字符串是否相同,便于后续代码检索出相应的命令。

其次,UartAction()函数会对接收到的数据进行解析与处理,即先将接收到的数据与命令字符串逐条比较,比较时需要先确保接收到的长度大于命令字符串长度,然后再通过CmpMemory()函数逐字节进行比较,如果比较相同就退出循环,不相同则继续对比下一条命令。当出现相符的命令字符串时,最终循环索引变量i就是该命令在列表中的索引位置,如果没有查询到相符命令,最后i的值将等于命令总数,那么最后就会采用switch语句,根据i的值来执行相应的具体动作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
/** Lcd1602.c */
#include <reg52.h>

#define LCD1602_DB P0
sbit LCD1602_RS = P1 ^ 0;
sbit LCD1602_RW = P1 ^ 1;
sbit LCD1602_E = P1 ^ 5;

/* 等待液晶准备完毕 */
void LcdWaitReady() {
unsigned char sta;

LCD1602_DB = 0xFF;
LCD1602_RS = 0;
LCD1602_RW = 1;

do {
LCD1602_E = 1;
sta = LCD1602_DB; // 读取状态字
LCD1602_E = 0;
} while ( sta & 0x80); // 如果 bit7 等于 1 表示液晶正忙,循环检测直至等于表示空闲的 0 为止
}

/* 写入 1 字节命令到液晶,参数 cmd 表示待写入的命令 */
void LcdWriteCmd(unsigned char cmd) {
LcdWaitReady();
LCD1602_RS = 0;
LCD1602_RW = 0;
LCD1602_DB = cmd;
LCD1602_E = 1;
LCD1602_E = 0;
}

/* 写入 1 字节数据到液晶,参数 dat 表示待写入的数据 */
void LcdWriteDat(unsigned char dat) {
LcdWaitReady();
LCD1602_RS = 1;
LCD1602_RW = 0;
LCD1602_DB = dat;
LCD1602_E = 1;
LCD1602_E = 0;
}

/* 设置显示 RAM 的起始地址,即光标位置,参数 x 和 y 分别对应屏幕的字符坐标 */
void LcdSetCursor(unsigned char x, unsigned char y) {
unsigned char addr;

/* 根据屏幕坐标计算显示 RAM 地址 */
if (y == 0)
addr = 0x00 + x; // 第 1 行字符地址从 0x00 起始
else
addr = 0x40 + x; // 第 2 行字符地址从 0x40 起始

LcdWriteCmd(addr | 0x80); // 设置 RAM 地址
}

/* 在液晶上显示字符串,参数 x 和 y 对应屏幕上的起始坐标,参数 str 是字符串指针,参数 len 是需要显示的字符长度 */
void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str, unsigned char len) {
LcdSetCursor(x, y); // 设置起始地址

/* 连续写入字符串,直至检测到结束符 */
while (*str != '\0') {
LcdWriteDat(*str++); // 首先获得 str 指向的数据,然后 str 再自增 1
}
}

/* 区域清屏,清除 x、y 坐标起始的 len 个字符 */
void LcdAreaClear(unsigned char x, unsigned char y, unsigned char len) {
LcdSetCursor(x, y); // 设置起始地址

while (len--) {
LcdWriteDat(' '); // 循环写入空格
}
}

/* 初始化1602液晶 */
void InitLcd1602() {
LcdWriteCmd(0x38); // 16×2显示,5×7点阵,8位数据接口
LcdWriteCmd(0x0C); // 开启显示器,关闭光标
LcdWriteCmd(0x06); // 文字保持不动,地址自增 1
LcdWriteCmd(0x01); // 清屏
}

I²C 总线

I²C总线是由 PHILIPS 公司开发的两线式串行总线,多用于连接微处理器与外围芯片,两条线可以挂载多个器件组成多机模式,任何一个器件都可以作为主设备(同一时刻只能有一个主设备)。

从原理角度来看,UART属于异步通信,例如前面例子中,计算机只负责将数据通过TXD发送,而接收数据是单片机自己的工作;而I²C属于同步通信,SCL时钟线负责收发双方的时钟节拍,SDA数据线负责传输数据,收发双方都是以 SCL 这个时钟节拍为基准进行数据的传输。从应用角度而言,UART通常用于板间通信,例如计算机与单片机或者单片机与单片机;而I²C多用于板内通信,例如后续将会介绍的单片机 与 EEPROM 之间。

I²C 时序

I²C 硬件上由时钟总线 SCL数据总线 SDA构成,总线上所有设备的SCL相互连接在一起,所有SDA同样相互连接在一起。I²C 总线属于开漏引脚并联结构,因此外部需要添加上拉电阻R63R64,从而在总线上构成一个【线与】关系,即所有接入总线的器件保持高电平,总线才是高电平,而任何一个器件输出低电平,则总线就会保持低电平。换而言之,总线上的任何器件都可以拉低电平作为主设备。

通常情况下,I²C 总线上都是由单片机微控制器作为主机,总线上挂载的诸多设备都拥有各自的唯一地址,信息传输时将会通过这个地址识别属于各自设备的信息,当前的实验电路上已经挂载了24C02PCF8591两个 I²C 总线设备。与 UART 串行通信类似,I²C 总线时序也分为起始信号、数据传输信号、停止信号,如下图所示:

UART 传输的每个字节都有 1 个起始位、8 个数据位、1 个停止位,而 I²C 分为起始信号、数据传输部分、停止信号,其中数据传输部分可以一次传输多个字节,而每个字节数据的最后也会跟着一个应答位(用ACK表示)。此外,虽然 UART 也使用了TXDRXD两条通信线路,但是实际上每次通信只需要通过一条线来完成,采用两条线只是为了区分接收发送;而 I²C 每次通信无论收发,两条通信线路都必须同时参加工作。为了更直观的观察每位的传输流程,这里在上面时序图基础上添加了辅助分析的分隔线:

  • 起始信号UART是将持续高电平时突然出现的低电平作为起始位;而I²C起始信号是在SCL为高电平期间,由SDA高电平向低电平跳变产生的下降沿作为起始信号,即上图中Start阶段所示。
  • 数据传输UART是低位在前高位在后;而I²C高位在前低位在后。此外,UART通信的数据位是波特率分之一的固定长度,逐位在固定时间完成发送即可;而I²C虽然没有固定波特率,但是在时序上要求SCL为低电平时,SDA允许变化,即发送方必须首先保持SCL为低电平,才能够改变SDA状态输出一位待发送的数据;当SCL为高电平时SDA的状态不能被改变,因为此时接收方需要读取SDA的电平状态,所以必需保证SDA状态的稳定,上图中每位数据的变化都是发生在SCL的低电平位置。
  • 停止信号UART的停止位固定为一位高电平信号,而I²C停止信号是在SCL为高电平期间,由SDA从低电平向高电平跳变产生的一个上升沿,即上图中Stop部分所示。

I²C 寻址模式

上一小节介绍了 I²C 位级信号的时序流程,而 I²C 在字节级依然存在固定的时序要求。I²C 起始信号Start之后,需要首先发送一个 7 位从机地址,接下来紧随其后的第 8 位是数据方向位R/W,如果为0就表示接下来的数据为接收数据(写操作),为1就表示接下来的数据为是请求数据(读操作),最后第 9 位ACK的作用是在 7 位地址位和 1 位方向位发送完毕以后,如果发送地址真实存在,那么该地址的设备将会响应一位ACK,即拉低SDA输出0;如果不存在,则没有设备回应ACKSDA将会持续保持高电平状态1

接下来编写一个程序,通过 I²C 访问一下实验电路上的 EEPROM 地址,另外再访问一个不存在的地址设备,观察是否能够返回ACK。实验电路中采用的EEPROM型号为24C02,其 7 位地址中高 4 位固定为0b1010,而低 3 位地址取决于具体电路设计,由芯片上A2A1A0三个引脚的电平状态确定,下面是24C02的电路图:

上图中,A2A1A0全都连接到了GND,三个引脚的电平状态全部为0,因此24C02的 7 位二进制地址应为0b1010000,换算成十六进制也就是0x50。下面代码将采用 I²C 协议来寻址0x50以及一个不存在的地址0x62,寻址完毕以后返回ACK的状态并显示到 1602 液晶。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
/** Lcd1602.c */
#include <reg52.h>

#define LCD1602_DB P0
sbit LCD1602_RS = P1 ^ 0;
sbit LCD1602_RW = P1 ^ 1;
sbit LCD1602_E = P1 ^ 5;

/* 等待液晶准备完毕 */
void LcdWaitReady() {
unsigned char sta;

LCD1602_DB = 0xFF;
LCD1602_RS = 0;
LCD1602_RW = 1;

do {
LCD1602_E = 1;
sta = LCD1602_DB; // 读取状态字
LCD1602_E = 0;
} while ( sta & 0x80); // 如果 bit7 等于 1 表示液晶正忙,循环检测直至等于表示空闲的 0 为止
}

/* 写入 1 字节命令到液晶,参数 cmd 表示待写入的命令 */
void LcdWriteCmd(unsigned char cmd) {
LcdWaitReady();
LCD1602_RS = 0;
LCD1602_RW = 0;
LCD1602_DB = cmd;
LCD1602_E = 1;
LCD1602_E = 0;
}

/* 写入 1 字节数据到液晶,参数 dat 表示待写入的数据 */
void LcdWriteDat(unsigned char dat) {
LcdWaitReady();
LCD1602_RS = 1;
LCD1602_RW = 0;
LCD1602_DB = dat;
LCD1602_E = 1;
LCD1602_E = 0;
}

/* 设置显示 RAM 的起始地址,即光标位置,参数 x 和 y 分别对应屏幕的字符坐标 */
void LcdSetCursor(unsigned char x, unsigned char y) {
unsigned char addr;

/* 根据屏幕坐标计算显示 RAM 地址 */
if (y == 0)
addr = 0x00 + x; // 第 1 行字符地址从 0x00 起始
else
addr = 0x40 + x; // 第 2 行字符地址从 0x40 起始

LcdWriteCmd(addr | 0x80); // 设置 RAM 地址
}

/* 在液晶上显示字符串,参数 x 和 y 对应屏幕上的起始坐标,参数 str 是字符串指针 */
void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str) {
LcdSetCursor(x, y); // 设置起始地址

/* 连续写入字符串,直至检测到结束符 */
while (*str != '\0') {
LcdWriteDat(*str++); // 首先获得 str 指向的数据,然后 str 再自增 1
}
}

/* 初始化1602液晶 */
void InitLcd1602() {
LcdWriteCmd(0x38); // 16×2 显示,5×7 点阵,8 位数据接口
LcdWriteCmd(0x0C); // 开启显示器,关闭光标
LcdWriteCmd(0x06); // 文字保持不动,地址自增 1
LcdWriteCmd(0x01); // 清屏
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
/** main.c */
#include <intrins.h>
#include <reg52.h>

#define I2CDelay() {_nop_();_nop_();_nop_();_nop_();}
sbit I2C_SCL = P3 ^ 7;
sbit I2C_SDA = P3 ^ 6;

bit I2CAddressing(unsigned char addr);
extern void InitLcd1602();
extern void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str);

void main() {
bit ack;
unsigned char str[10];
InitLcd1602(); // 初始化 1602 液晶

ack = I2CAddressing(0x50); // 查询地址为 0x50 的器件
str[0] = '5'; // 将地址和响应值转换为字符串
str[1] = '0';
str[2] = ':';
str[3] = (unsigned char)ack + '0';
str[4] = '\0';
LcdShowStr(0, 0, str); // 显示到 1602 液晶

ack = I2CAddressing(0x62); // 查询地址为 0x62 的设备
str[0] = '6'; // 将地址和响应值转换为字符串
str[1] = '2';
str[2] = ':';
str[3] = (unsigned char)ack + '0';
str[4] = '\0';
LcdShowStr(8, 0, str); // 显示到 1602 液晶

while (1);
}

/* 产生总线起始信号 */
void I2CStart() {
I2C_SDA = 1; // 确保 SDA、SCL 都是高电平
I2C_SCL = 1;
I2CDelay();
I2C_SDA = 0; // 拉低 SDA
I2CDelay();
I2C_SCL = 0; // 拉低 SCL
}

/* 产生总线停止信号 */
void I2CStop() {
I2C_SCL = 0; // 确保 SDA、SCL 都是低电平
I2C_SDA = 0;
I2CDelay();
I2C_SCL = 1; // 拉高 SCL
I2CDelay();
I2C_SDA = 1; // 拉高 SDA
I2CDelay();
}

/* I2C总线写操作,参数 dat 中待写入的字节数据,返回从设备响应位的值 */
bit I2CWrite(unsigned char dat) {
bit ack; // 用于暂存响应位的值
unsigned char mask; // 用于检测字节内某一位值的掩码

/* 从高位至低位依次执行 */
for (mask = 0x80; mask != 0; mask >>= 1) {
/* 将该位的值输出至 SDA */
if ((mask & dat) == 0)
I2C_SDA = 0;
else
I2C_SDA = 1;

I2CDelay();
I2C_SCL = 1; // 拉高 SCL
I2CDelay();
I2C_SCL = 0; // 拉低 SCL,完成一个位周期
}

I2C_SDA = 1; // 8 位数据发送完毕后,主设备释放 SDA 以检测从设备响应
I2CDelay();
I2C_SCL = 1; // 拉高 SCL
ack = I2C_SDA; // 读取此时 SDA 的值,即从设备响应值
I2CDelay();
I2C_SCL = 0; // 再次拉低 SCL 完成响应

return ack; // 返回从设备的响应值
}

/* I²C 寻址函数,addr 参数是待检测的设备地址,最后返回从设备响应值 */
bit I2CAddressing(unsigned char addr) {
bit ack;

I2CStart(); // 生成起始位,即启动一次总线操作
ack = I2CWrite(addr << 1); // 寻址命令最低位为读写位,用于表示之后操作的读写状态,因此设备地址左移一位
I2CStop(); // 直接停止本次总线操作,无需再进行后续的读写操作

return ack;
}

上述代码利用了前面提到的库函数_nop_()进行精确延时,一个_nop_()的运行时间就是一个机器周期,该库函数包含在intrins.h头文件。程序编译运行之后,主设备发送一个真实的从设备地址,从设备会回复一个应答位0;主设备如果发送一个不存在的从设备地址,由于没有从设备响应,此时应答位为1

I²C 通信分为100 kbit/s的低速模式、400 kbit/s的快速模式、3.4 Mbit/s的高速模式,由于所有 I²C 设备都支持低速模式,而未必同时支持另外两种模式,因此上面代码作为通用 I²C 程序选择了100 kbit/s的低速模式实现。换而言之,单片机实际产生的时序必须小于或等于这个速率,也就是说SCL高低电平的持续时间不得短于5 us,因此代码在时序函数中插入了总线延时函数I2CDelay()(实质就是 4 次_nop_()库函数调用),加上改变SCL值的语句本身需要消耗至少一个周期,最终就满足了低速模式下的速率限制。如果后续想要提升速度,那么减小此处的总线延时时间即可。

注意I2CWrite()函数当中for(mask=0x80; mask!=0; mask>>=1)循环语句的使用技巧,由于 I²C 通信从高位开始发送数据,所以先从最高位开始,0x80dat进行按位与运算,从而得知dat的第 7 位是0还是1,然后右移 1 位变为0x40dat的按位与运算,进而得知第 6 位是0还是1,如此循环直至第0位结束,最终通过if语句将dat的 8 位数据依次发送出去。

EEPROM 24C02

保存在单片机RAM内的数据掉电后就会丢失,而保存在单片机FLASH内的数据又不能用于记录变化的数值,而实际开发场景当中又经常需要记录下一些需要经常进行修改的数据,并且在掉电之后还不会丢失。本小节将要介绍的 EEPROM 就是能够满足这一特性的存储器,当前实验电路中选用的是 ATMEL 公司型号为24C02EEPPROM,其容量大小为256 Byte,并且基于 I²C 通信协议。

EEPROM 单字节读写时序

EEPROM 写数据流程

  1. I²C 起始信号和设备地址,并且读写方向上选择为【写】操作。
  2. 发送数据的存储地址,24C02拥有 256 字节存储空间,地址从0x00 ~ 0xFF,需要将数据存储在哪个位置就填写哪个地址。
  3. 发送待存储数据的第 1、2、...个字节,注意 EEPROM 每个字节都会回应一个应答位0,用于通知写数据成功,如果未返回应答位,则说明写入不成功。

写数据过程中,每成功写入 1 个字节,EEPROM 存储空间地址就会自增1,当加至0xFF以后再进行写入,地址就溢出为0x00

EEPROM 读数据流程

  1. I²C 起始信号和设备地址,读写方向上依然选择【写】操作,之所以这里仍然选择写,是为了通知 EEPROM 当前需要读取数据位于哪个地址。
  2. 发送待读取数据的地址,注意这里是地址而非存储在 EEPROM 中的数据本身。
  3. 重新发送 I²C 起始信号与器件地址,并且读写方向位选择【读】操作。
  4. 读取 EEPROM 响应的数据,1 个字节读取完成之后,如果需要继续读取下个字节,应答位ACK发送0;如果不需要再读取,则发送1通知 EEPROM 不再进行读取数据。

前 3 个步操作当中,每个字节本质上都处于【写】操作,因此 EEPROM 每个字节的应答位都是0

与上面的写数据流程类似,每读取一个字节,地址就会自动加1,如果需要继续读取,就向 EEPROM 发送一个ACK低电平0,并且再继续给SCL提供完整的时序,此时 EEPROM 会继续往外发送数据。如果无需再进行读取,就直接向 EEPROM 发送一个NAK高电平1,通知 EEPROM 不再读取数据,下面再梳理一下此处的逻辑顺序:

  1. 假如STC89C52RC单片机是主设备,24C02是从设备;
  2. 无论读还是写,SCL始终由主设备单片机控制;
  3. 写的时候应答信号由从设备24C02提供,表示从设备是否正确接收了数据;
  4. 读的时候应答信号由主设备STC89C52RC提供,表示是否继续进行读取。

接下来编写一段程序,读取 EEPROM 上地址为0x02上的数据,加1以后再将结果回写到该地址上。与前面将 1602 液晶显示相关的操作封装至Lcd1602.c文件一样,下面代码也会将 I²C 总线的操作函数(起始、停止、字节写、字节读和应答、字节读和非应答)封装至一个独立的I2C.c文件,便于程序代码的复用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
/** I2C.c */
#include <intrins.h>
#include <reg52.h>

#define I2CDelay() {_nop_();_nop_();_nop_();_nop_();}
sbit I2C_SCL = P3 ^ 7;
sbit I2C_SDA = P3 ^ 6;

/* 产生总线起始信号 */
void I2CStart() {
I2C_SDA = 1; // 确保 SDA、SCL 都是高电平
I2C_SCL = 1;
I2CDelay();
I2C_SDA = 0; // 拉低 SDA
I2CDelay();
I2C_SCL = 0; // 拉低 SCL
}

/* 产生总线停止信号 */
void I2CStop() {
I2C_SCL = 0; // 确保 SDA、SCL 都是低电平
I2C_SDA = 0;
I2CDelay();
I2C_SCL = 1; // 拉高 SCL
I2CDelay();
I2C_SDA = 1; // 拉高 SDA
I2CDelay();
}

/* I2C总线写操作,dat-待写入字节,返回值-从机应答位的值 */
bit I2CWrite(unsigned char dat) {
bit ack; // 用于暂存响应位的值
unsigned char mask; // 用于检测字节内某一位值的掩码

/* 从高位至低位依次执行 */
for (mask = 0x80; mask != 0; mask >>= 1) {
/* 将该位的值输出至 SDA */
if ((mask & dat) == 0)
I2C_SDA = 0;
else
I2C_SDA = 1;

I2CDelay();
I2C_SCL = 1; // 拉高 SCL
I2CDelay();
I2C_SCL = 0; // 拉低 SCL,完成一个位周期
}

I2C_SDA = 1; // 8 位数据发送完毕后,主设备释放 SDA 以检测从设备响应
I2CDelay();
I2C_SCL = 1; // 拉高 SCL
ack = I2C_SDA; // 读取此时 SDA 的值,即从设备响应值
I2CDelay();
I2C_SCL = 0; // 再次拉低 SCL 完成响应

return (~ack); // 应答值取反以符合日常逻辑,0 表示不存在/忙/写入失败,1 表示存在/空闲/写入成功
}

/* I²C 总线读操作,并且发送 NAK 非应答信号,返回读取到的字节 */
unsigned char I2CReadNAK() {
unsigned char mask;
unsigned char dat;
I2C_SDA = 1; // 确保主设备释放 SDA

/* 从高位到低位依次进行 */
for (mask = 0x80; mask != 0; mask >>= 1) {
I2CDelay();
I2C_SCL = 1; // 拉高 SCL

/* SDA 的值为 0 时,dat 对应位置清零,为 1 时对应位置 1 */
if (I2C_SDA == 0)
dat &= ~mask;
else
dat |= mask;

I2CDelay();
I2C_SCL = 0; // 拉低 SCL 让从设备发送下一位
}

I2C_SDA = 1; // 8 位数据发送完毕后拉高 SDA,发送非应答信号
I2CDelay();
I2C_SCL = 1; // 拉高 SCL
I2CDelay();
I2C_SCL = 0; // 拉低 SCL 完成非应答位

return dat;
}

/* I²C 总线读操作,并且发送 ACK 应答信号,返回读取到的字节 */
unsigned char I2CReadACK() {
unsigned char mask;
unsigned char dat;
I2C_SDA = 1; // 确保主设备释放 SDA

/* 从高位到低位依次进行 */
for (mask = 0x80; mask != 0; mask >>= 1) {
I2CDelay();
I2C_SCL = 1; // 拉高 SCL

/* SDA 的值为 0 时,dat 对应位置清零,为 1 时对应位置 1 */
if (I2C_SDA == 0)
dat &= ~mask;
else
dat |= mask;

I2CDelay();
I2C_SCL = 0; // 拉低 SCL 让从设备发送下一位
}

I2C_SDA = 0; // 8 位数据发送完毕后拉高 SDA,发送应答信号
I2CDelay();
I2C_SCL = 1; // 拉高 SCL
I2CDelay();
I2C_SCL = 0; // 拉低 SCL 完成应答位

return dat;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/** main.c,此处省略 Lcd1602.c */
#include <reg52.h>

extern void InitLcd1602();
extern void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str);
extern void I2CStart();
extern void I2CStop();
extern unsigned char I2CReadNAK();
extern bit I2CWrite(unsigned char dat);

unsigned char E2ReadByte(unsigned char addr);
void E2WriteByte(unsigned char addr, unsigned char dat);

void main() {
unsigned char dat;
unsigned char str[10];

InitLcd1602(); // 初始化液晶
dat = E2ReadByte(0x02); // 读取指定地址上的 1 个字节数据

str[0] = (dat / 100) + '0'; // 将字节数据转换为十进制字符串格式
str[1] = (dat / 10 % 10) + '0';
str[2] = (dat % 10) + '0';
str[3] = '\0';

LcdShowStr(0, 0, str); // 显示到 1602 液晶
dat++; // 将数据自增 1
E2WriteByte(0x02, dat); // 重新回写至原地址

while (1);
}

/* 读取 EEPROM 的 1 个字节,参数 addr 是字节地址 */
unsigned char E2ReadByte(unsigned char addr) {
unsigned char dat;

I2CStart();
I2CWrite(0x50 << 1); // 设备寻址,并设置后续为写操作
I2CWrite(addr); // 写入存储地址
I2CStart(); // 发送重复启动信号
I2CWrite((0x50 << 1) | 0x01); // 设备寻址,并设置后续为读操作
dat = I2CReadNAK(); // 读取 1 个字节的数据
I2CStop();

return dat;
}
/* 定数 1 个字节数据到 EEPROM,参数 addr 是字节地址 */
void E2WriteByte(unsigned char addr, unsigned char dat) {
I2CStart();
I2CWrite(0x50 << 1); // 设备寻址,并设置后续为写操作
I2CWrite(addr); // 写入存储地址
I2CWrite(dat); // 写入 1 个字节的数据
I2CStop();
}

上面程序读取 EEPROM 时候,只读取一个字节后就通知 EEPROM 不再需要读取数据,读取完成以后直接发送一个NAK,因此只调用了I2CReadNAK()函数,并未调用I2CReadACK()函数。如果遇到需要连续读取多个字节数据的场景,I2CReadACK()函数就会派上用场了。

EEPROM 多字节读写时序

读取 EEPROM 的过程较为简单,EEPROM 会根据程序发送的时序将数据送出。但是 EEPROM 的写入较为复杂,向 EEPROM 发送的数据首先保存在其缓存当中,然后 EEPROM 必须将缓存中的数据迁移至【非易失】存储区域,才能最终达到掉电不丢失的目的。但是这个【非易失】存储区域的写需要一定时间,24C02的写入时间最高不超过5 ms。将数据迁移至【非易失】存储区域的过程当中,EEPROM 不会再响应其它的访问,既接收不到任何数据也无法进行寻址,待数据迁移完成之后 EEPROM 才能够恢复正常读写。

前面写入数据的实验代码里,每次只写入一个字节数据,下次重新上电再进行写入时,时间已经远远超过5 ms。但是如果连续写入多个字节数据,就必须考虑到应答位的问题;即写入 1 个字节后,再写入下个字节之前必须等待 EEPROM 重新响应,下面代码展示了多字节读写 EEPROM 的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
/* main.c,多字节读写模式访问 EEPROM,依次累加 1, 2, 3...之后再回写到之前存储位置 */
#include <reg52.h>

extern void InitLcd1602();
extern void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str);
extern void I2CStart();
extern void I2CStop();
extern unsigned char I2CReadACK();
extern unsigned char I2CReadNAK();
extern bit I2CWrite(unsigned char dat);

void E2Read(unsigned char *buf, unsigned char addr, unsigned char len);
void E2Write(unsigned char *buf, unsigned char addr, unsigned char len);
void MemToStr(unsigned char *str, unsigned char *src, unsigned char len);

void main() {
unsigned char i;
unsigned char buf[5];
unsigned char str[20];

InitLcd1602(); // 初始化液晶
E2Read(buf, 0x90, sizeof(buf)); // 读取一段数据
MemToStr(str, buf, sizeof(buf)); // 转换为十六进制字符串
LcdShowStr(0, 0, str); // 显示至 1602 液晶

/* 数据依次累加 1, 2, 3... */
for (i = 0; i < sizeof(buf); i++) {
buf[i] = buf[i] + 1 + i;
}

E2Write(buf, 0x90, sizeof(buf)); // 将结果写回 EEPROM
while (1);
}

/* 将一段内存数据转换为十六进制字符串,参数 str 是字符串指针,参数 src 是源数据地址,参数 len 是数据长度 */
void MemToStr(unsigned char *str, unsigned char *src, unsigned char len) {
unsigned char tmp;

while (len--) {
tmp = *src >> 4; // 取出高 4 位
if (tmp <= 9) // 转换为 0-9 或 A-F
*str++ = tmp + '0';
else
*str++ = tmp - 10 + 'A';

tmp = *src & 0x0F; // 取出低 4 位
if (tmp <= 9) // 转换为 0-9 或 A-F
*str++ = tmp + '0';
else
*str++ = tmp - 10 + 'A';

*str++ = ' '; // 转换完 1 个字节就添加 1 个空格
src++;
}

*str = '\0'; // 添加字符串结束符
}

/* EEPROM 多字节读写函数,参数 buf 是数据接收指针,参数 addr 是 EEPROM 里的起始地址,参数 len 是读取的数据长度 */
void E2Read(unsigned char *buf, unsigned char addr, unsigned char len) {
/* 通过寻址方式查询当前是否能够进行读写操作 */
do {
I2CStart();
/* 应答就跳出循环,非应答就进行下次查询 */
if (I2CWrite(0x50 << 1)) {
break;
}
I2CStop();
} while (1);

I2CWrite(addr); // 写入起始地址
I2CStart(); // 发送重复启动信号
I2CWrite((0x50 << 1) | 0x01); // 设备寻址,并设置后续为读操作

/* 连续读取 len-1 个字节的数据 */
while (len > 1) {
*buf++ = I2CReadACK(); // 最后 1 个字节之前为读取操作 + 应答
len--;
}

*buf = I2CReadNAK(); // 最后 1 个字节为读取操作 + 非应答
I2CStop();
}

/* EEPROM 写入函数,参数 buf 是源数据指针,参数 addr 是 EEPROM 中的起始地址,参数 len 是待写入的长度 */
void E2Write(unsigned char *buf, unsigned char addr, unsigned char len) {
while (len--) {
/* 通过寻址方式查询当前是否能够进行读写操作 */
do {
I2CStart();
/* 应答就跳出循环,非应答就进行下次查询 */
if (I2CWrite(0x50 << 1)) {
break;
}
I2CStop();
} while (1);

I2CWrite(addr++); // 写入起始地址
I2CWrite(*buf++); // 写入 1 个字节数据
I2CStop(); // 结束写操作,并等待写入完成
}
}

MemToStr()函数:用于将一段内存数据转换成十六进制字符串格式,这是由于从 EEPROM 读取的是正常数据,而 1602 液晶接收的是 ASCII 码字符,要显示必须进行转换。方法是将每个字节数据的高 4 位与低 4 位分别与9进行比较,如果小于或等于9就直接加0转为0 ~ 9的 ASCII 码,如果大于9则先减去10再加上A即可转换为A ~ F的 ASCII 码。

E2Read()函数:读取 EEPROM 数据之前,需要首先查询当前是否能够进行读写操作,EEPROM 正常响应后才能进行。读取到最后 1 个字节之前全部设置为ACK,读取到最后一个字节以后就设置为NAK

E2Write()函数:每次进行写操作之前,都需要查询判断当前 EEPROM 是否响应,正常响应以后才能进行数据写入。

EEPROM 的页写入

EEPROM 连续写入多个字节数据时,如果每写入 1 个字节都要等待几毫秒,就会明显影响写入效率。因此 EEPROM 通常实行分页管理,当前实验电路使用的24C02拥有 256 个字节,其中每 8 个字节 1 页,总共拥有 32 页。

存储空间进行分页之后,如果在同一个页内连续写入几个字节,最后再发送停止位时序,EEPROM 检测到该停止位之后,就会一次性将整页的数据迁移至【非易失】存储区域,不需要每写 1 个字节进行 1 次检测,并且页写入时间也不会超过5 ms。如果需要写入跨页的数据,那么每写完了一页之后就要发送一个停止位,然后等待并且检测 EEPROM 空闲模式,直至将上一页数据迁移至【非易失】存储区域以后,再进行下一页的写入,这样就有效提高了数据的写入效率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/** eeprom.c */
#include <reg52.h>

extern void I2CStart();
extern void I2CStop();
extern unsigned char I2CReadACK();
extern unsigned char I2CReadNAK();
extern bit I2CWrite(unsigned char dat);

/* EEPROM 读取函数,参数 buf 是数据接收指针,参数 addr 是 EEPROM 中起始地址,参数 len 是待读取的长度 */
void E2Read(unsigned char *buf, unsigned char addr, unsigned char len) {
/* 采用寻址操作查询当前是否可以进行读写操作 */
do {
I2CStart();
/* 应答就跳出循环,非应答就进行下次查询 */
if (I2CWrite(0x50 << 1)) {
break;
}
I2CStop();
} while (1);

I2CWrite(addr); // 写入起始地址
I2CStart(); // 发送重复启动信号
I2CWrite((0x50 << 1) | 0x01); // 设备寻址,并设置后续为读操作

/* 连续读取 len-1 个字节的数据 */
while (len > 1) {
*buf++ = I2CReadACK(); // 最后 1 个字节之前为读取操作 + 应答
len--;
}

*buf = I2CReadNAK(); // 最后 1 个字节为读取操作 + 非应答
I2CStop();
}

/* EEPROM 写入函数,参数 buf 是源数据指针,参数 addr 是 EEPROM 的起始地址,参数 len 是待写入的数据长度 */
void E2Write(unsigned char *buf, unsigned char addr, unsigned char len) {
/* 等待上次写入操作完成 */
while (len > 0) {
/* 采用寻址操作查询当前是否可以进行读写操作 */
do {
I2CStart();

/* 应答就跳出循环,非应答就进行下次查询 */
if (I2CWrite(0x50 << 1)) {
break;
}

I2CStop();
} while (1);

/* 采用页模式连续的写入多个字节 */
I2CWrite(addr); // 写入起始地址

while (len > 0) {
I2CWrite(*buf++); // 写入一个字节数据
len--; // 待写入长度计数递减
addr++; // EEPROM 地址递增

/* 通过检测低 3 位是否为零来判断检查地址是否到达了页边界 */
if ((addr & 0x07) == 0) {
break; // 如果到达了页边界,就跳出循环结束本次写操作
}
}
I2CStop();
}
}

遵循模块化原则,上面代码将 EEPROM 读写函数独立为eeprom.c文件,其中E2Read()函数与上一节实验代码保持相同,因为 I²C 读操作与存储区分页无关。关键点在于E2Write()函数,写入数据时需要计算下一个待写入数据的地址,是否为一个页的起始地址,如果是就必须跳出循环,等待 EEPROM 将当前页写入至【非易失】存储区域,然后再行写入后续的存储页。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/** 用 连续读 和 分页写 模式访问 EEPROM,并且依次加 1 加 2 加 3...最后将结果回写至原地址,此处省略 Lcd1602.c 和 I2C.c */
#include <reg52.h>

extern void InitLcd1602();
extern void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str);
extern void E2Read(unsigned char *buf, unsigned char addr, unsigned char len);
extern void E2Write(unsigned char *buf, unsigned char addr, unsigned char len);
void MemToStr(unsigned char *str, unsigned char *src, unsigned char len);

void main() {
unsigned char i;
unsigned char buf[5];
unsigned char str[20];

InitLcd1602(); // 初始化液晶
E2Read(buf, 0x8E, sizeof(buf)); // 从 EEPROM 读取一段数据
MemToStr(str, buf, sizeof(buf)); // 转换为十六进制字符串
LcdShowStr(0, 0, str); // 显示到 1602 液晶

/* 数据依次累加 1, 2, 3... */
for (i = 0; i < sizeof(buf); i++) {
buf[i] = buf[i] + 1 + i;
}
E2Write(buf, 0x8E, sizeof(buf)); // 将结果写回 EEPROM
while (1);
}

/* 将一段内存数据转换为十六进制字符串,参数 str 是字符串指针,参数 src 是源数据地址,参数 len 是数据长度 */
void MemToStr(unsigned char *str, unsigned char *src, unsigned char len) {
unsigned char tmp;

while (len--) {
tmp = *src >> 4; // 取出高 4 位
if (tmp <= 9) // 转换为 0-9 或 A-F
*str++ = tmp + '0';
else
*str++ = tmp - 10 + 'A';

tmp = *src & 0x0F; // 取出低 4 位
if (tmp <= 9) // 转换为 0-9 或 A-F
*str++ = tmp + '0';
else
*str++ = tmp - 10 + 'A';

*str++ = ' '; // 转换完 1 个字节就添加 1 个空格
src++;
}

*str = '\0'; // 添加字符串结束符
}

同样写入 5 个字节的数据,逐个字节写入会消耗8.4 ms左右的时间,而采用页写入则只耗费了3.5 ms左右的时间。

I²C 和 EEPROM 综合实验

空调温度设置、电视频道记忆等场景都可能会使用到 EEPROM 存储器,其存储的数据不仅可以顺意改变,而且掉电后数据不会丢失,因此在各类电子设备上大量使用。本节的实验类似于完成一个广告屏,实验电路上电之后,1602 液晶第 1 行显示 EEPROM 从0x20地址开始的 16 个字符,第 2 行显示 EERPOM 从0x40开始的 16 个字符,并且可以通过 UART 串口通信手动修改 EEPROM 内部保存的这个数据,当然同时也会改变 1602 液晶显示的内容,实验电路下次上电后将会显示这个手动更新之后的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
/* main.c,此处省略了 Lcd1602.c、I2C.c、eeprom.c、Uart.c 源文件*/
#include <reg52.h>

unsigned char T0RH = 0; // 定时器 T0 定时值高字节
unsigned char T0RL = 0; // 定时器 T0 定时值低字节

void InitShowStr();
void ConfigTimer0(unsigned int ms);
extern void InitLcd1602();
extern void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str);
extern void E2Read(unsigned char *buf, unsigned char addr, unsigned char len);
extern void E2Write(unsigned char *buf, unsigned char addr, unsigned char len);
extern void UartDriver();
extern void ConfigUART(unsigned int baud);
extern void UartRxMonitor(unsigned char ms);
extern void UartWrite(unsigned char *buf, unsigned char len);

void main() {
EA = 1; // 总中断使能
ConfigTimer0(1); // 定时器 T0 定时 1ms
ConfigUART(9600); // 设置波特率为 9600
InitLcd1602(); // 初始化 1602 液晶
InitShowStr(); // 初始化显示内容

while (1) {
UartDriver(); // 调用串口驱动程序
}
}

/* 处理液晶屏初始显示内容 */
void InitShowStr() {
unsigned char str[17];

str[16] = '\0'; // 添加字符串结束符
E2Read(str, 0x20, 16); // 读取第 1 行字符串,EEPROM 起始地址为 0x20
LcdShowStr(0, 0, str); // 显示到 1602 液晶屏
E2Read(str, 0x40, 16); // 读取第 2 行字符串,EEPROM 起始地址为 0x40
LcdShowStr(0, 1, str); // 显示到 1602 液晶屏
}

/* 内存比较函数,比较两个指针所指向的内存数据是否相同;参数 ptr1 是待比较指针 1,参数 ptr2 是待比较指针 2,参数 len 是待比较的数据长度 */
bit CmpMemory(unsigned char *ptr1, unsigned char *ptr2, unsigned char len) {
while (len--) {
/* 遇到不相等数据时即刻返回0 */
if (*ptr1++ != *ptr2++) {
return 0; // 两段内存数据不相同返回 0
}
}
return 1; // 两段内存数据完全相同返回 1
}

/* 将字符串转换为 16 字节固定长度的字符串,不足部分直接填充空格,参数 out 是整理后的字符串指针,参数 in 是待整理字符串指针 */
void TrimString16(unsigned char *out, unsigned char *in) {
unsigned char i = 0;
/* 拷贝字符串直至结束符 */
while (*in != '\0') {
*out++ = *in++;
i++;

/* 拷贝长度达到 16 个字节就强制跳出循环 */
if (i >= 16) {
break;
}
}

/* 不足 16 个字节部分使用空格补齐 */
for (; i < 16; i++) {
*out++ = ' ';
}

*out = '\0'; // 添加结束符
}

/* 串口动作函数,根据接收到的命令帧执行相应动作,参数 buf 是接收到的命令帧指针,参数 len 是命令帧的长度 */
void UartAction(unsigned char *buf, unsigned char len) {
unsigned char i;
unsigned char str[17];
unsigned char code cmd0[] = "showstr1 "; // 1602液晶 第 1 行字符显示命令
unsigned char code cmd1[] = "showstr2 "; // 1602液晶 第 2 行字符显示命令
unsigned char code cmdLen[] = {sizeof(cmd0) - 1, sizeof(cmd1) - 1};
unsigned char code *cmdPtr[] = {&cmd0[0], &cmd1[0]};

/* 遍历命令列表,查找相同命令 */
for (i = 0; i < sizeof(cmdLen); i++) {
/* 接收到的数据长度不能小于命令长度 */
if (len >= cmdLen[i]) {
if (CmpMemory(buf, cmdPtr[i], cmdLen[i])) {
break; // 比较相同时退出循环
}
}
}

/* 根据比较结果执行相应命令 */
switch (i) {
case 0:
buf[len] = '\0'; // 添加结束符
TrimString16(str, buf + cmdLen[0]); // 整理为 16 字节固定长度字符串
LcdShowStr(0, 0, str); // 显示字符串 1
E2Write(str, 0x20, sizeof(str)); // 保存字符串 1,起始地址为 0x20
break;
case 1:
buf[len] = '\0'; // 添加结束符
TrimString16(str, buf + cmdLen[1]); // 整理为 16 字节固定长度字符串
LcdShowStr(0, 1, str); // 显示字符串 1
E2Write(str, 0x40, sizeof(str)); // 保存字符串 2,起始地址为 0x40
break;
default: // 如果未找到相符命令,向上位机发送 bad command 提示
UartWrite("bad command.\r\n", sizeof("bad command.\r\n") - 1);
return;
}

buf[len++] = '\r'; // 添加回车符
buf[len++] = '\n'; // 添加换行符
UartWrite(buf, len); // 将命令返回上位机,表示已经执行
}

/* 配置并启动定时器 T0,参数 ms 是定时器 T0 的定时时间 */
void ConfigTimer0(unsigned int ms) {
unsigned long tmp; // 临时变量

tmp = 11059200 / 12; // 定时器计数频率
tmp = (tmp * ms) / 1000; // 计算所需的计数值
tmp = 65536 - tmp; // 计算定时器定时值
tmp = tmp + 33; // 补偿中断响应延时造成的误差

T0RH = (unsigned char)(tmp >> 8); // 将定时值拆分为高低字节
T0RL = (unsigned char)tmp;
TMOD &= 0xF0; // 定时器 T0 控制位清零
TMOD |= 0x01; // 配置定时器 T0 为工作模式 1
TH0 = T0RH;
TL0 = T0RL; // 加载定时器 T0 定时值
ET0 = 1; // 定时器 T0 中断使能
TR0 = 1; // 启动定时器 T0
}

/* 定时器 T0 中断服务函数,用于监听串口接收 */
void InterruptTimer0() interrupt 1 {
TH0 = T0RH;
TL0 = T0RL; // 重新加载定时值
UartRxMonitor(1); // 串口接收监控
}

STC89C52RC内部集成了 UART 通信模块,通过简单的寄存器配置就可以实现通信功能;而STC89C52RC并没有集成 I²C 总线控制模块,因此只能通过 IO 引脚进行模拟,虽然代码较为冗长,但是有助于理解 I²C 的底层实现机制。

SPI 总线

SPI 是串行外围设备接口(Serial Peripheral Interface)的英文缩写,属于一种高速全双工的同步通信总线,常用于单片机微控制器与 EEPROM、FLASH、实时时钟、数字信号处理器等等元件的通信。SPI 通信原理相对 I²C 更加简单,采用了主从模式进行通信(一个主设备与多个从设备),标准 SPI 拥有 4 根信号线:

  • SSEL:片选,也记为SCS,传输从设备的片选使能信号,如果从设备是低电平使能,那么拉低引脚以后,该从设备就会被选中,主设备就可以与这个被选中的从设备进行通信;
  • SCLK:时钟信号,也记为SCK,由主设备产生,作用类似于 I²C 的SCL
  • MOSI:主设备输出从设备输入(Master Output/Slave Input),主设备给从设备发送指令或数据的通道;
  • MISO:主设备输入从设备输出(Master Input/Slave Output)主设备读取从设备状态或数据的通道。

根据实际生产环境需要,也可以用将 SPI 总线剪裁为 3 根或者 2 根通信线,例如主设备只给从设备发送命令,而从设备无需回复数据时 MISO 就可以省略;如果主设备只读取从设备数据,而无需给从设备发送命令就可以省略 MOSI;如果只有 1 个主设备和 1 个从设备时,从设备片选可以固定为有效的使能电平,这样 SSEL 就可以省略,如果此时主设备只需要向从设备发送数据,那么 SSEL 和 MISO 都可以省去;而如果主设备只读取从设备发送的数据,则可以同时省去 SSEL 和 MOSI。

SPI 总线的主设备通常为单片机/微控制器,读写数据的工作时序有 4 种工作模式,在进一步了解这些工作模式之前,需要首先了解如下 2 个概念:

  • CPOL: 时钟的极性(Clock Polarity),通信过程分为空闲时刻和通信时刻,如果SCLK在空闲状态为高电平,那么CPOL = 1;如果SCLK在空闲状态为低电平,那么CPOL=1
  • CPHA: 时钟的相位(Clock Phase),,主从设备交换数据时,涉及到主设备何时输出数据到 MOSI 而从设备何时采样该数据,或者从设备何时输出数据到 MISO 而主设备何时采样该数据的一系列问题。同步通信的特点在于所有数据的变化与采样都是伴随时钟沿进行,数据总是在时钟边沿附近变化或者采样,基于周期的定义,一个时钟周期必然包含 1 个上升沿与 1 个下降沿,又由于数据从产生到稳定需要一定时间,因而如果主设备在上升沿输出数据到 MOSI,从设备就只能在下降沿去采样该数据;反之,一个设备在下降沿输出数据,那么另一个设备就必须在上升沿采样它。

CPHA = 1表示数据的输出位于一个时钟周期的第 1 个上升沿或者下降沿(此时如果CPOL = 1就是下降沿,反之为上升沿),而数据的采样自然就位于第 2 个上升/下降沿。CPHA = 0表示数据采样位于一个时钟周期的第 1 个上升/下降沿(具体是上升还是下降沿依然由CPOL决定),此时数据的输出自然就位于第 2 个上升/下降沿。

当某一帧数据开始传输第 1 个 bit 位时,在第 1 个时钟沿上就会开始采样该数据,该数据何时输出分两种情况:一是SSEL使能的边沿,二是前一帧数据的最后 1 个时钟沿,有时两种情况可能会同时生效,下面以CPOL=1/CPHA=1情况下的时序图为例:

上图当中,当数据【未发送】和【发送完毕】之后,SCK都为高电平,因此CPOL = 1。在SCK第 1 个时钟沿的时候,MOSIMISO均会发生变化,同时在SCK第 2 个时钟沿时数据保持稳定,此刻适合进行数据采样,即在该时钟周期的后沿锁存读取数据,即CPHA = 1。最隐蔽的是SSEL片选,该引脚通常用于确定需要进行通信的主从设备。剩余的三种模式的时序图如下所示,为了简化将MOSIMISO合并在了一起。

SPI 的通信时序相比 I²C 要简单许多,没有了起始、停止、应答信号;UART 与 SPI 进行通信的时候,只负责通信本身而不负责通信成功与否,而 I²C 由于需要通过应答信息获取通信成功与否的状态,相对而言 UART 和 SPI 在时序上都要比 I²C 更加简单。

实时时钟 DS1302

DS1302 是美信 MAXIM 半导体出品的一款涓流充电时钟芯片,可以提供年、月、日、时、分、秒等实时时钟信息,还能够配置 24 小时或 12 小时格式。DS1302 拥有31 Byte字节的数据存储 RAM(掉电丢失数据,较少使用)。采用串行 IO 通信方式,有效节省单片机 IO 引脚资源;工作电压较宽,位于2.0V ~ 5.5V范围;功耗极低,工作电压2.0 V时工作电流小于300 nA

当前实验电路使用的 DS1302 拥有 8 个引脚,并采用 SOP 小外型封装,芯片两侧都引出 L 形引脚。供电电压为5V时兼容标准 TTL 电平标准,能够直接与 STC89C52RC 进行通信。此外 DS1302 拥有主、备两个电源输入,可以分别连接电源、电池或电容,可以在系统掉电的情况下继续走时。

1 脚VCC2是主电源正极的引脚,2 脚X1和 3 脚X2是晶振输入和输出引脚,4 脚是GND负极,5 脚CE是使能引脚(连接至单片机),6 脚I/O是数据传输引脚(连接至单片机),7 脚SCLK是通信时钟引脚(连接至单片机),8 脚VCC1是备用电源引脚,下面表格描述了 DS1302 的各引脚功能:

引脚编号 引脚名称 功能描述
1 VCC2 主电源引脚,当Vcc2Vcc1高出0.2V以上时,DS1302 由Vcc2供电,当Vcc2低于Vcc1时则由Vcc1供电。
2 X1 32.768 kHz晶振为 DS1302 提供计时基准,注意该晶振引脚负载电容必须为6pF
3 X2 同上。
4 GND 接地。
5 CE 使能输入引脚,读写 DS1302 时该引脚必须为高电平,该引脚内置了一个40kΩ的下拉电阻。
6 I/O 该引脚为双向通信引脚,即数据的读写都通过该引脚完成,该引脚同样内置了一个40kΩ下拉电阻。
7 SCLK 输入引脚,用来作为通信的时钟信号,该引脚依然内置有一个40kΩ下拉电阻。
8 VCC1 备用电源引脚。

当前实验电路第 8 脚未连接备用电池,而是连接了一枚10uF电容,可以在掉电以后仍维持 DS1302 持续工作 1 分钟左右。出于成本原因,实际应用中极少会使用充电电池作为备用电源,因而基本不会使用到 DS1302 提供的涓流充电功能。下面电路图当中,直接在VCC1主电源处并联了一枚二极管,主电源上电时为电容充电,主电源掉电时二极管可以防止电容向主电路放电,而仅仅用于维持 DS1302 的供电,确保更换电池的场景下实时时钟的运行不会停止。此外,在 DS1302 主电源引脚VCC2还串联一个1KΩ的电阻R6,用于防止电源对芯片造成冲击,其它的R9R26R32都是上拉电阻。

上面实时时钟的核心是一枚32.768 kHz晶振,而时钟的精度就主要取决于晶振精度、晶振的引脚负载电容以及晶振的温漂

DS1302 寄存器介绍

DS1302 每条指令占用 1 个字节共 8 位,其中最高位第 7 位固定为1;第 6 位用于选择 RAM 功能(1)还是 CLOCK 功能(0),前面已经提到 RAM 存储功能较少使用,所以这里固定选择 CLOCK 时钟功能;第 1 ~ 5 位决定了寄存器的五位地址(0b00000 ~ 0b00111);第 0 位为读写位,其中0表示写1表示读,指令字节的分配示意图如下所示:

DS1302 数据手册直接给出了第 0、6、7 位取值的十六进制命令0x800x81...,详细如下图表格所示:

DS1302 拥有 8 个与时钟相关的寄存器,具体请参考下面的列表:

  • 寄存器 0:最高位CH时钟停止标志位,如果时钟电路拥有备用电源,上电后需要首先检测该位状态,为0说明时钟芯片在系统掉电后可由备用电源来保持正常运行,为1说明时钟芯片在系统掉电后就不工作了。如果Vcc1悬空或者电池没电,下次重新上电时该位的状态为1,因此可以通过该位判断时钟在掉电后能否正常工作。剩下 7 位中的高 3 位是【秒数】的十位,低 4 位是【秒数】的个位,由于 DS1302 内部使用 BCD 编码来表示时间,而秒的十位最大是5,所以三个二进制位就足够表达。
  • 寄存器 1:最高位没有使用,剩下 7 位中的高 3 位是【分钟数】的十位,低 4 位是【分钟数】的个位。
  • 寄存器 2:最高位为1表示当前是 12 小时制,为0表示当前是 24 小时制;第 6 位固定为0,第 5 位在【12 小时制】里0代表上午1代表下午,在【24 小时制】里与第 4 位一起代表【小时数】的十位,低 4 位代表【小时数】的个位。
  • 寄存器 3:最高的 2 位固定为0,第 5 和第 4 位是【日期数】的十位,而低 4 位是【日期数】的个位。
  • 寄存器 4:最高的 3 位固定为0,第 4 位是【月数】的十位,低 4 位是【月数】的个位。
  • 寄存器 5:最高的 5 位固定为0,低 3 位代表【星期】。
  • 寄存器 6:最高的 4 位代表了【年】的十位,低 4 位代表了【年】的个位,注意这里的00 ~ 99指的是2000年 ~ 2099年
  • 寄存器 7:最高位为写保护位,为1表示禁止给任何其它寄存器或者 31 字节 RAM 写数据,因而在进行写数据操作之前,该位必须置为0

BCD 码(Binary-Coded Decimal)也称为二-十进制码,使用 4 位二进制数来表达 1 位0 ~ 9的十进制数,是一种采用二进制编码的十进制表达格式,可以方便的进行二进制与十进制之间的转换。0 ~ 9对应的 BCD 编码范围为0b0000 ~ 0b1001,不存在0b10100b10110b11000b11010b11100b1111六个数字。如果 BCD 码计数达到了最高的0b1001,再加1结果就变为0b00010000,相当于使用 8 位二进制数字表达了 2 位的十进制数字。

本节使用的 DS1302 时钟芯片将时间日期以 BCD 编码方式存储,当需要将其转换为 1602 液晶可直观显示的 ASCII 编码时,可以直接将 BCD 码的 4 个二进制位加上0x30即可得到 ASCII 编码的字节。

DS1302 通信时序介绍

DS1302 一共拥有CE 使能线I/O 数据线SCLK 时钟线三条链路连接到 STC89C52RC,虽然采用了 SPI 的时序,但是并未完全按照 SPI 总线的规则来进行通信,下面我们一点点解剖 DS1302 的变异 SPI 通信方式。DS1302 单字节写操作与CPOL=0/CPHA=0时 SPI 操作时序的比较如下图:

上图当中,两种时序的CESSEL使能控制相反,SPI 写数据都位于 SCK 上升沿,即从设备进行采样的时候,下降沿时则主设备发送数据;而 DS1302 时序里需要预先写一个字节指令,指定需要写入的寄存器地址以及后续操作为写操作,然后再写入一个字节数据。DS1302 单字节读操作这里不作探讨,具体参考下面的时序图:

读操作的时序里有两个需要注意的地方:首先,DS1302 时序图上的箭头都是针对 DS1302 而言,因此读操作时先写第 1 个字节指令,上升沿的时候 DS1302 锁存数据,下降沿则由单片机发送数据。到了第 2 个字数据,由于该时序过程相当于CPOL=0/CPHA=0,前沿发送数据,后沿读取数据,所以第 2 个字节就是 DS1302 下降沿输出数据,单片机在上升沿读取这些数据,因此箭头对于 DS1302 而言出现在下降沿。

其次,当前单片机没有标准 SPI 接口,与 I²C 一样需要通过单片机 IO 引脚来模拟通信过程。读 DS1302 的时候 SPI 理论上是上升沿读取,但是由于程序是通过单片机 IO 模拟,所以数据的读取和时钟沿的变化不可能同时进行,而必然存在着一个先后顺序。通过实验发现,如果先读取I/O线路上的数据,再拉高SCLK产生上升沿,那么读到的数据一定是正确的,而颠倒顺序后数据就有可能出现错误。产生这个问题的原因在于 DS1302 通信协议与标准 SPI 协议存在差异而造成,标准 SPI 的数据会一直保持到下一个周期的下降沿才会发生变化,所以读取数据与上升沿的先后顺序无关紧要;但是 DS1302 的I/O线路会在时钟上升沿之后被 DS1302 释放,即从强推挽输出变为弱下拉状态,此时在 STC89C52RC 单片机引脚内部上拉电阻的作用下,I/O线路上的实际电平会逐渐上升,导致在上升沿产生后再读取I/O数据可能会出现错误。因此这里需要先读取I/O数据,再拉高SCLK产生上升沿的顺序。

下面完成一个实验程序,使用单次读写模式,将【2013 年 10 月 8 号星期二 12 点 30 分 00 秒】这个时间写入 DS1302,让其正常运行以后再反复读取 DS1302 上的当前时间,并且显示在 1602 液晶上面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
/** main.c,文件 Lcd1602.c 省略 */
#include <reg52.h>

sbit DS1302_CE = P1 ^ 7;
sbit DS1302_CK = P3 ^ 5;
sbit DS1302_IO = P3 ^ 4;

bit flag200ms = 0; // 200ms 定时标志位
unsigned char T0RH = 0; // 定时器 T0 定时值高字节
unsigned char T0RL = 0; // 定时器 T0 定时值低字节

void ConfigTimer0(unsigned int ms);
void InitDS1302();
unsigned char DS1302SingleRead(unsigned char reg);
extern void InitLcd1602();
extern void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str);

void main() {
unsigned char i;
unsigned char psec = 0xAA; // 秒数备份,初始值 0xAA 可以确保首次读取时间以后能够刷新显示
unsigned char time[8]; // 当前时间数组
unsigned char str[12]; // 字符串转换缓冲区

EA = 1; // 使能总中断
ConfigTimer0(1); // 定时器 T0 定时 1ms
InitDS1302(); // 初始化 DS1302 实时时钟
InitLcd1602(); // 初始化 1602 液晶

while (1) {
/* 每间隔 200ms 读取一次时间 */
if (flag200ms) {
flag200ms = 0;

/* 读取 DS1302 当前时间 */
for (i = 0; i < 7; i++) {
time[i] = DS1302SingleRead(i);
}

/* 检测到时间变化时刷新显示 */
if (psec != time[0]) {
/* 添加年份的高两位 20 */
str[0] = '2';
str[1] = '0';

str[2] = (time[6] >> 4) + '0'; // 年份的高位数值转换为 ASCII 编码
str[3] = (time[6] & 0x0F) + '0'; // 年份的低位数值转换为 ASCII 编码
str[4] = '-'; // 日期分隔符

str[5] = (time[4] >> 4) + '0'; // 月份的高位数值转换为 ASCII 编码
str[6] = (time[4] & 0x0F) + '0'; // 月份的低位数值转换为 ASCII 编码
str[7] = '-'; // 日期分隔符

str[8] = (time[3] >> 4) + '0'; // 日期的高位数值转换为 ASCII 编码
str[9] = (time[3] & 0x0F) + '0'; // 日期的低位数值转换为 ASCII 编码
str[10] = '\0'; // 日期结束符
LcdShowStr(0, 0, str); // 显示到 1602 液晶的第 1 行

str[0] = (time[5] & 0x0F) + '0'; // 星期数
str[1] = '\0'; // 星期结束符
LcdShowStr(11, 0, "week");
LcdShowStr(15, 0, str); // 显示到 1602 液晶第 1 行

str[0] = (time[2] >> 4) + '0'; // 小时数的高位数值转换为 ASCII 编码
str[1] = (time[2] & 0x0F) + '0'; // 小时数的低位数值转换为 ASCII 编码
str[2] = ':'; // 时间分隔符

str[3] = (time[1] >> 4) + '0'; // 分钟数的高位数值转换为 ASCII 编码
str[4] = (time[1] & 0x0F) + '0'; // 分钟数的低位数值转换为 ASCII 编码
str[5] = ':'; // 时间分隔符

str[6] = (time[0] >> 4) + '0'; // 秒数的高位数值转换为 ASCII 编码
str[7] = (time[0] & 0x0F) + '0'; // 秒数的低位数值转换为 ASCII 编码
str[8] = '\0'; // 时间分隔符
LcdShowStr(4, 1, str); // 显示到 1602 液晶第 2 行

psec = time[0]; // 使用当前的时间值更新上次的秒数
}
}
}
}

/* 发送 1 个字节到 DS1302 通信总线 */
void DS1302ByteWrite(unsigned char dat) {
unsigned char mask;

/* 低位在前,逐位进行移出 */
for (mask = 0x01; mask != 0; mask <<= 1) {
/* 首先输出该位数据 */
if ((mask & dat) != 0)
DS1302_IO = 1;
else
DS1302_IO = 0;

DS1302_CK = 1; // 然后拉高时钟
DS1302_CK = 0; // 再拉低时钟,完成 1 个位的操作
}

DS1302_IO = 1; // 确保释放 IO 引脚
}

/* 从 DS1302 通信总线 读取 1 个字节 */
unsigned char DS1302ByteRead() {
unsigned char mask;
unsigned char dat = 0;

/* 低位在前,逐位进行移出 */
for (mask = 0x01; mask != 0; mask <<= 1) {

/* 首先读取此时 IO 引脚,并设置 dat 的对应位 */
if (DS1302_IO != 0) {
dat |= mask;
}
DS1302_CK = 1; // 然后拉高时钟
DS1302_CK = 0; // 再拉低时钟,完成 1 个位的操作
}

return dat; // 返回读取的字节数据
}

/* 采用单次写操作向某寄存器写入 1 个字节,参数 reg 是寄存器地址,参数 dat 是待写入的字节 */
void DS1302SingleWrite(unsigned char reg, unsigned char dat) {
DS1302_CE = 1; // 使能片选信号
DS1302ByteWrite((reg << 1) | 0x80); // 发送【写】寄存器指令
DS1302ByteWrite(dat); // 写入字节数据
DS1302_CE = 0; // 移除片选信号使能
}

/* 采用单次写操作向某寄存器读取 1 个字节,参数 reg 是寄存器地址,返回读取到的字节数据 */
unsigned char DS1302SingleRead(unsigned char reg) {
unsigned char dat;

DS1302_CE = 1; // 使能片选信号
DS1302ByteWrite((reg << 1) | 0x81); // 发送【读】寄存器指令
dat = DS1302ByteRead(); // 读取字节数据
DS1302_CE = 0; // 移除片选信号使能

return dat;
}

/* 初始化 DS1302,如果掉电就重新设置初始时间 */
void InitDS1302() {
unsigned char i;
unsigned char code InitTime[] = {0x00, 0x30, 0x12, 0x08, 0x10, 0x02, 0x13}; // 2013年10月8日 星期二 12:30:00

/* 初始化 DS1302 通信引脚 */
DS1302_CE = 0;
DS1302_CK = 0;

i = DS1302SingleRead(0); // 读取秒寄存器

/* 通过秒寄存器的最高位 CH 来判断 DS1302 是否停止运行 */
if ((i & 0x80) != 0) {
DS1302SingleWrite(7, 0x00); // 撤销写保护,允许写入数据

/* 设置 DS1302 为默认初始时间 */
for (i = 0; i < 7; i++) {
DS1302SingleWrite(i, InitTime[i]);
}
}
}

/* 配置并启动定时器 T0,参数 ms 是定时时间 */
void ConfigTimer0(unsigned int ms) {
unsigned long tmp; // 临时变量

tmp = 11059200 / 12; // 定时器计数频率
tmp = (tmp * ms) / 1000; // 计算所需的计数值
tmp = 65536 - tmp; // 计算定时器重载值
tmp = tmp + 12; // 补偿中断响应延时造成的误差
T0RH = (unsigned char)(tmp >> 8); // 定时器重载值拆分为高低字节
T0RL = (unsigned char)tmp;
TMOD &= 0xF0; // 清零定时器 T0 控制位
TMOD |= 0x01; // 配置定时器 T0 为模式 1

/* 加载定时器 T0 定时值 */
TH0 = T0RH;
TL0 = T0RL;
ET0 = 1; // 使能定时器 T0 中断
TR0 = 1; // 启动定时器 T0
}

/* 定时器 T0 中断服务函数,进行 200ms 定时 */
void InterruptTimer0() interrupt 1 {
static unsigned char tmr200ms = 0;

/* 加载定时器 T0 定时值 */
TH0 = T0RH;
TL0 = T0RL;
tmr200ms++;

/* 定时 200ms */
if (tmr200ms >= 200) {
tmr200ms = 0;
flag200ms = 1;
}
}

DS1302 突发模式

上面读写 DS1302 的实验程序存在一个不太严谨的问题,当STC89C52RC定时器时间达到200ms之后,就连续读取了 DS1302 时间参数的 7 个字节。但是由于读取时存在一个时间差,极端情况可能会导致这样一种情况:如果当前时间是【00:00:59】,首先读取秒数59,然后再读取分钟数,在读完秒数但还未开始读取分钟数的这段时间,时间刚好发生了进位变成【00:01:00】,此时读到的分钟数为01,导致 1602 液晶上出现一个错误的时间【00:01:59】。这个问题的出现概率极小,但是问题确确实实是存在的。

为此,DS1302 提供了突发模式(Burst Mode)来解决这个问题,突发模式分为RAM 突发模式时钟突发模式(Clock Burst Mode),前者这里暂且不表,只研究实时时钟相关的模式。

当向 DS1302 写入指令时,只需要将 5 位地址全部写为1,即读操作用0xBF,写操作用0xBE,指令发送之后 DS1302 就会自动识别出当前为突发(Burst) 模式。然后马上会将所有 8 个字节同时锁存到另外 8 个字节的寄存器缓冲区,时钟继续走时,而数据则是从另一个缓冲区内读取的。同理,如果采用突发模式写数据,同样也是先将数据写入到该缓冲区,DS1302 最终会将该缓冲区内的数据一次性发送到它的时钟寄存器。

下面采用突发读写模式重写前一小节的实验代码,访问 DS1302 并将日期时间显示到 1602 液晶:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
/** main.c,另 Lcd1602.c 文件省略 */
#include <reg52.h>

sbit DS1302_CE = P1 ^ 7;
sbit DS1302_CK = P3 ^ 5;
sbit DS1302_IO = P3 ^ 4;

bit flag200ms = 0; // 200ms 定时标志位
unsigned char T0RH = 0; // 定时器 T0 定时值高字节
unsigned char T0RL = 0; // 定时器 T0 定时值低字节

void ConfigTimer0(unsigned int ms);
void InitDS1302();
void DS1302BurstRead(unsigned char *dat);
extern void InitLcd1602();
extern void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str);

void main() {
unsigned char psec = 0xAA; // 秒数备份,初始值 0xAA 可以确保首次读取时间以后能够刷新显示
unsigned char time[8]; // 当前时间数组
unsigned char str[12]; // 字符串转换缓冲区

EA = 1; // 使能总中断
ConfigTimer0(1); // 定时器 T0 定时 1ms
InitDS1302(); // 初始化 DS1302 实时时钟
InitLcd1602(); // 初始化 1602 液晶

while (1) {
/* 每间隔 200ms 读取一次时间 */
if (flag200ms) {
flag200ms = 0;
DS1302BurstRead(time); // 读取 DS1302 当前时间

/* 检测到时间变化时刷新显示 */
if (psec != time[0]) {
/* 添加年份的高两位 20 */
str[0] = '2';
str[1] = '0';

str[2] = (time[6] >> 4) + '0'; // 年份的高位数值转换为 ASCII 编码
str[3] = (time[6] & 0x0F) + '0'; // 年份的低位数值转换为 ASCII 编码
str[4] = '-'; // 日期分隔符

str[5] = (time[4] >> 4) + '0'; // 月份的高位数值转换为 ASCII 编码
str[6] = (time[4] & 0x0F) + '0'; // 月份的低位数值转换为 ASCII 编码
str[7] = '-'; // 日期分隔符

str[8] = (time[3] >> 4) + '0'; // 日期的高位数值转换为 ASCII 编码
str[9] = (time[3] & 0x0F) + '0'; // 日期的低位数值转换为 ASCII 编码
str[10] = '\0'; // 日期结束符
LcdShowStr(0, 0, str); // 显示到 1602 液晶的第 1 行

str[0] = (time[5] & 0x0F) + '0'; // 星期数
str[1] = '\0'; // 星期结束符
LcdShowStr(11, 0, "week");
LcdShowStr(15, 0, str); // 显示到 1602 液晶第 1 行

str[0] = (time[2] >> 4) + '0'; // 小时数的高位数值转换为 ASCII 编码
str[1] = (time[2] & 0x0F) + '0'; // 小时数的低位数值转换为 ASCII 编码
str[2] = ':'; // 时间分隔符

str[3] = (time[1] >> 4) + '0'; // 分钟数的高位数值转换为 ASCII 编码
str[4] = (time[1] & 0x0F) + '0'; // 分钟数的低位数值转换为 ASCII 编码
str[5] = ':'; // 时间分隔符

str[6] = (time[0] >> 4) + '0'; // 秒数的高位数值转换为 ASCII 编码
str[7] = (time[0] & 0x0F) + '0'; // 秒数的低位数值转换为 ASCII 编码
str[8] = '\0'; // 时间分隔符
LcdShowStr(4, 1, str); // 显示到 1602 液晶第 2 行

psec = time[0]; // 使用当前的时间值更新上次的秒数
}
}
}
}

/* 发送 1 个字节到 DS1302 通信总线 */
void DS1302ByteWrite(unsigned char dat) {
unsigned char mask;

/* 低位在前,逐位进行移出 */
for (mask = 0x01; mask != 0; mask <<= 1) {
/* 首先输出该位数据 */
if ((mask & dat) != 0)
DS1302_IO = 1;
else
DS1302_IO = 0;

DS1302_CK = 1; // 然后拉高时钟
DS1302_CK = 0; // 再拉低时钟,完成 1 个位的操作
}

DS1302_IO = 1; // 确保释放 IO 引脚
}

/* 从 DS1302 通信总线 读取 1 个字节 */
unsigned char DS1302ByteRead() {
unsigned char mask;
unsigned char dat = 0;

/* 低位在前,逐位进行移出 */
for (mask = 0x01; mask != 0; mask <<= 1) {

/* 首先读取此时 IO 引脚,并设置 dat 的对应位 */
if (DS1302_IO != 0) {
dat |= mask;
}
DS1302_CK = 1; // 然后拉高时钟
DS1302_CK = 0; // 再拉低时钟,完成 1 个位的操作
}

return dat; // 返回读取的字节数据
}

/* 采用单次写操作向某寄存器写入 1 个字节,参数 reg 是寄存器地址,参数 dat 是待写入的字节 */
void DS1302SingleWrite(unsigned char reg, unsigned char dat) {
DS1302_CE = 1; // 使能片选信号
DS1302ByteWrite((reg << 1) | 0x80); // 发送【写】寄存器指令
DS1302ByteWrite(dat); // 写入字节数据
DS1302_CE = 0; // 移除片选信号使能
}

/* 采用单次写操作向某寄存器读取 1 个字节,参数 reg 是寄存器地址,返回读取到的字节数据 */
unsigned char DS1302SingleRead(unsigned char reg) {
unsigned char dat;

DS1302_CE = 1; // 使能片选信号
DS1302ByteWrite((reg << 1) | 0x81); // 发送【读】寄存器指令
dat = DS1302ByteRead(); // 读取字节数据
DS1302_CE = 0; // 移除片选信号使能

return dat;
}

/* 采用【突发模式】连续写入 8 个寄存器数据,参数 dat 是待写入的数据指针 */
void DS1302BurstWrite(unsigned char *dat) {
unsigned char i;
DS1302_CE = 1;
DS1302ByteWrite(0xBE); // 发送【突发写】寄存器指令

/* 连续写入 8 字节数据 */
for (i = 0; i < 8; i++) {
DS1302ByteWrite(dat[i]);
}
DS1302_CE = 0;
}

/* 采用【突发模式】连续读取 8 个寄存器数据,参数 dat 是读取数据的接收指针 */
void DS1302BurstRead(unsigned char *dat) {
unsigned char i;
DS1302_CE = 1;
DS1302ByteWrite(0xBF); // 发送【突发读】寄存器指令

/* 连续读取 8 字节数据 */
for (i = 0; i < 8; i++) {
dat[i] = DS1302ByteRead();
}
DS1302_CE = 0;
}

/* 初始化 DS1302,如果掉电就重新设置初始时间 */
void InitDS1302() {
unsigned char dat;
unsigned char code InitTime[] = {0x00, 0x30, 0x12, 0x08, 0x10, 0x02, 0x13, 0x00}; // 2013年10月8日 星期二 12:30:00

/* 初始化 DS1302 通信引脚 */
DS1302_CE = 0;
DS1302_CK = 0;

dat = DS1302SingleRead(0); // 读取秒寄存器

/* 通过秒寄存器的最高位 CH 来判断 DS1302 是否停止运行 */
if ((dat & 0x80) != 0) {
DS1302SingleWrite(7, 0x00); // 撤销写保护,允许写入数据
DS1302BurstWrite(InitTime); // 设置 DS1302 为默认初始时间
}
}

/* 配置并启动定时器 T0,参数 ms 是定时时间 */
void ConfigTimer0(unsigned int ms) {
unsigned long tmp; // 临时变量

tmp = 11059200 / 12; // 定时器计数频率
tmp = (tmp * ms) / 1000; // 计算所需的计数值
tmp = 65536 - tmp; // 计算定时器重载值
tmp = tmp + 12; // 补偿中断响应延时造成的误差
T0RH = (unsigned char)(tmp >> 8); // 定时器重载值拆分为高低字节
T0RL = (unsigned char)tmp;
TMOD &= 0xF0; // 清零定时器 T0 控制位
TMOD |= 0x01; // 配置定时器 T0 为模式 1

/* 加载定时器 T0 定时值 */
TH0 = T0RH;
TL0 = T0RL;
ET0 = 1; // 使能定时器 T0 中断
TR0 = 1; // 启动定时器 T0
}

/* 定时器 T0 中断服务函数,进行 200ms 定时 */
void InterruptTimer0() interrupt 1 {
static unsigned char tmr200ms = 0;

/* 加载定时器 T0 定时值 */
TH0 = T0RH;
TL0 = T0RL;
tmr200ms++;

/* 定时 200ms */
if (tmr200ms >= 200) {
tmr200ms = 0;
flag200ms = 1;
}
}

注意:无论读写,只要使用了突发模式,就必须一次性读写 8 个寄存器,即对时钟寄存器完全读取或者完全写入。

电子时钟实例

本实验将会实现一个加入了按键调时的简易万年历,通过上、下、左、右、回车、ESC 六个按键调整时间(忽略了星期数),下面列出了代码实现当中的一些要点:

  1. 将 DS1302 底层操作封装为一个DS1302.c文件,对上层应用提供基本实时时间操作函数。
  2. 定义结构体类型sTime来封装日期时间的各个元素,又使用该结构体定义了一个时间缓冲区变量bufTime来暂存从 DS1302 读取的时间以及时间的设定值;
  3. 定义一个setIndex变量,用于判断当前是否处于时间设置状态以及设置的是时间的哪一位,该值为0表示正常运行,1 ~ 12分别代表可以修改日期时间的十二个位;
  4. 由于本实验需要进行时间调整,所以需要使用到 1602 液晶的光标功能,改变哪一位数字就在液晶屏对应的位置上闪烁光标,因此Lcd1602.c文件需要添加了 2 个光标控制函数;
  5. 时间的显示、增减、设置移位等功能都放在main.c中实现,如果按键需要使用这些功能函数,可以在按键代码文件内进行外部声明,从而避免各功能函数分散在不同文件当中导致混乱。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
/** DS1302.c,实时时钟 DS1302 驱动 */
#include <reg52.h>

sbit DS1302_CE = P1 ^ 7;
sbit DS1302_CK = P3 ^ 5;
sbit DS1302_IO = P3 ^ 4;

/* 定义时间结构体 */
struct sTime {
unsigned int year; // 年
unsigned char mon; // 月
unsigned char day; // 日
unsigned char hour; // 时
unsigned char min; // 分
unsigned char sec; // 秒
unsigned char week; // 星期
};

/* 发送 1 个字节到 DS1302 通信总线 */
void DS1302ByteWrite(unsigned char dat) {
unsigned char mask;

/* 低位在前,逐位进行移出 */
for (mask = 0x01; mask != 0; mask <<= 1) {
/* 首先输出该位数据 */
if ((mask & dat) != 0)
DS1302_IO = 1;
else
DS1302_IO = 0;

DS1302_CK = 1; // 然后拉高时钟
DS1302_CK = 0; // 再拉低时钟,完成 1 个位的操作
}

DS1302_IO = 1; // 确保释放 IO 引脚
}

/* 从 DS1302 通信总线 读取 1 个字节 */
unsigned char DS1302ByteRead() {
unsigned char mask;
unsigned char dat = 0;

/* 低位在前,逐位进行移出 */
for (mask = 0x01; mask != 0; mask <<= 1) {

/* 首先读取此时 IO 引脚,并设置 dat 的对应位 */
if (DS1302_IO != 0) {
dat |= mask;
}
DS1302_CK = 1; // 然后拉高时钟
DS1302_CK = 0; // 再拉低时钟,完成 1 个位的操作
}

return dat; // 返回读取的字节数据
}

/* 采用单次写操作向某寄存器写入 1 个字节,参数 reg 是寄存器地址,参数 dat 是待写入的字节 */
void DS1302SingleWrite(unsigned char reg, unsigned char dat) {
DS1302_CE = 1; // 使能片选信号
DS1302ByteWrite((reg << 1) | 0x80); // 发送【写】寄存器指令
DS1302ByteWrite(dat); // 写入字节数据
DS1302_CE = 0; // 移除片选信号使能
}

/* 采用单次写操作向某寄存器读取 1 个字节,参数 reg 是寄存器地址,返回读取到的字节数据 */
unsigned char DS1302SingleRead(unsigned char reg) {
unsigned char dat;

DS1302_CE = 1; // 使能片选信号
DS1302ByteWrite((reg << 1) | 0x81); // 发送【读】寄存器指令
dat = DS1302ByteRead(); // 读取字节数据
DS1302_CE = 0; // 移除片选信号使能

return dat;
}

/* 采用【突发模式】连续写入 8 个寄存器数据,参数 dat 是待写入的数据指针 */
void DS1302BurstWrite(unsigned char *dat) {
unsigned char i;
DS1302_CE = 1;
DS1302ByteWrite(0xBE); // 发送【突发写】寄存器指令

/* 连续写入 8 字节数据 */
for (i = 0; i < 8; i++) {
DS1302ByteWrite(dat[i]);
}
DS1302_CE = 0;
}

/* 采用【突发模式】连续读取 8 个寄存器数据,参数 dat 是读取数据的接收指针 */
void DS1302BurstRead(unsigned char *dat) {
unsigned char i;
DS1302_CE = 1;
DS1302ByteWrite(0xBF); // 发送【突发读】寄存器指令

/* 连续读取 8 字节数据 */
for (i = 0; i < 8; i++) {
dat[i] = DS1302ByteRead();
}
DS1302_CE = 0;
}

/* 获取实时时间,读取 DS1302 当前时间并转换为时间结构体格式 */
void GetRealTime(struct sTime *time) {
unsigned char buf[8];
DS1302BurstRead(buf);

time->year = buf[6] + 0x2000;
time->mon = buf[4];
time->day = buf[3];
time->hour = buf[2];
time->min = buf[1];
time->sec = buf[0];
time->week = buf[5];
}

/* 设置实时时间,将时间结构体格式的设置时间转换为数组并写入 DS1302 */
void SetRealTime(struct sTime *time) {
unsigned char buf[8];

buf[7] = 0;
buf[6] = time->year;
buf[5] = time->week;
buf[4] = time->mon;
buf[3] = time->day;
buf[2] = time->hour;
buf[1] = time->min;
buf[0] = time->sec;

DS1302BurstWrite(buf);
}

/* 初始化 DS1302,如果掉电就重新设置初始时间 */
void InitDS1302() {
unsigned char dat;
unsigned char code InitTime[] = {0x2013, 0x10, 0x08, 0x12, 0x30, 0x00, 0x02}; // 2013年10月8日 12:30:00 星期二

/* 初始化 DS1302 通信引脚 */
DS1302_CE = 0;
DS1302_CK = 0;

dat = DS1302SingleRead(0); // 读取秒寄存器

/* 通过秒寄存器的最高位 CH 来判断 DS1302 是否停止运行 */
if ((dat & 0x80) != 0) {
DS1302SingleWrite(7, 0x00); // 撤销写保护,允许写入数据
DS1302BurstWrite(InitTime); // 设置 DS1302 为默认初始时间
}
}

上面的DS1302.c文件提供了与时钟芯片寄存器位置无关的、由时间结构类型sTime作为参数的实时时间读写函数,如果未来需要更换时钟芯片型号,只需要提供同样以sTime为参数的操作函数即可,而应用层无需进行任何调整。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
/** Lcd1602.c,1602 液晶显示驱动 */
#include <reg52.h>

#define LCD1602_DB P0
sbit LCD1602_RS = P1 ^ 0;
sbit LCD1602_RW = P1 ^ 1;
sbit LCD1602_E = P1 ^ 5;

/* 等待液晶准备完毕 */
void LcdWaitReady() {
unsigned char sta;

LCD1602_DB = 0xFF;
LCD1602_RS = 0;
LCD1602_RW = 1;

do {
LCD1602_E = 1;
sta = LCD1602_DB; // 读取状态字
LCD1602_E = 0;
} while (sta & 0x80); // 如果 bit7 等于 1 表示液晶正忙,循环检测直至等于表示空闲的 0 为止
}

/* 写入 1 字节命令到 1602 液晶,参数 cmd 表示待写入的命令 */
void LcdWriteCmd(unsigned char cmd) {
LcdWaitReady();
LCD1602_RS = 0;
LCD1602_RW = 0;
LCD1602_DB = cmd;
LCD1602_E = 1;
LCD1602_E = 0;
}

/* 写入 1 字节数据到 1602 液晶,参数 dat 表示待写入的数据 */
void LcdWriteDat(unsigned char dat) {
LcdWaitReady();
LCD1602_RS = 1;
LCD1602_RW = 0;
LCD1602_DB = dat;
LCD1602_E = 1;
LCD1602_E = 0;
}

/* 设置显示 RAM 的起始地址,即光标位置,参数 x 和 y 分别对应屏幕的字符坐标 */
void LcdSetCursor(unsigned char x, unsigned char y) {
unsigned char addr;

/* 根据屏幕坐标计算显示 RAM 地址 */
if (y == 0)
addr = 0x00 + x; // 第 1 行字符地址从 0x00 起始
else
addr = 0x40 + x; // 第 2 行字符地址从 0x40 起始

LcdWriteCmd(addr | 0x80); // 设置 RAM 地址
}

/* 在液晶上显示字符串,参数 x 和 y 对应屏幕上的起始坐标,参数 str 是字符串指针 */
void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str) {
LcdSetCursor(x, y); // 设置起始地址

/* 连续写入字符串,直至检测到结束符 */
while (*str != '\0') {
LcdWriteDat(*str++); // 首先获得 str 指向的数据,然后 str 再自增 1
}
}

/* 打开光标闪烁 */
void LcdOpenCursor() {
LcdWriteCmd(0x0F);
}

/* 关闭光标闪烁 */
void LcdCloseCursor() {
LcdWriteCmd(0x0C);
}

/* 初始化1602液晶 */
void InitLcd1602() {
LcdWriteCmd(0x38); // 16×2 显示,5×7 点阵,8 位数据接口
LcdWriteCmd(0x0C); // 开启显示器,关闭光标
LcdWriteCmd(0x06); // 文字保持不动,地址自增 1
LcdWriteCmd(0x01); // 清屏
}

Lcd1602.c在之前代码基础上添加了用于控制光标效果开LcdOpenCursor()、关LcdCloseCursor()的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
/** main.c */
#include <reg52.h>

/* 定义时间结构体 */
struct sTime {
unsigned int year;
unsigned char mon;
unsigned char day;
unsigned char hour;
unsigned char min;
unsigned char sec;
unsigned char week;
};

bit flag200ms = 1; // 200ms 定时标志位
struct sTime bufTime; // 日期时间缓冲区
unsigned char setIndex = 0; // 时间设置索引
unsigned char T0RH = 0; // 定时器 T0 定时值高字节
unsigned char T0RL = 0; // 定时器 T0 定时值低字节

void ConfigTimer0(unsigned int ms);
void RefreshTimeShow();

extern void InitDS1302();
extern void GetRealTime(struct sTime *time);
extern void SetRealTime(struct sTime *time);
extern void KeyScan();
extern void KeyDriver();
extern void InitLcd1602();
extern void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str);
extern void LcdSetCursor(unsigned char x, unsigned char y);
extern void LcdOpenCursor();
extern void LcdCloseCursor();

void main() {
unsigned char psec = 0xAA; // 秒数备份,初始值 0xAA 可以确保首次读取时间以后能够刷新显示

EA = 1; // 使能总中断
ConfigTimer0(1); // 定时器 T0 定时 1ms
InitDS1302(); // 初始化 DS1302 实时时钟
InitLcd1602(); // 初始化 1602 液晶

/* 初始化 1602 液晶屏幕上固定不变的内容 */
LcdShowStr(3, 0, "20 - - ");
LcdShowStr(4, 1, " : : ");

while (1) {
KeyDriver(); // 调用按键驱动

/* 间隔 200ms 并且没有处于设置状态的时候 */
if (flag200ms && (setIndex == 0)) {
flag200ms = 0;
GetRealTime(&bufTime); // 获取当前时间

/* 检测时间变化时刷新 1602 液晶显示 */
if (psec != bufTime.sec) {
RefreshTimeShow();
psec = bufTime.sec; // 使用当前值更新秒数
}
}
}
}
/* 将 BCD 码显示到 1602 液晶屏幕,参数 x, y 是屏幕起始坐标,参数 bcd 是待显示的 BCD 码 */
void ShowBcdByte(unsigned char x, unsigned char y, unsigned char bcd) {
unsigned char str[4];

str[0] = (bcd >> 4) + '0';
str[1] = (bcd & 0x0F) + '0';
str[2] = '\0';
LcdShowStr(x, y, str);
}

/* 刷新日期时间显示 */
void RefreshTimeShow() {
ShowBcdByte(5, 0, bufTime.year);
ShowBcdByte(8, 0, bufTime.mon);
ShowBcdByte(11, 0, bufTime.day);
ShowBcdByte(4, 1, bufTime.hour);
ShowBcdByte(7, 1, bufTime.min);
ShowBcdByte(10, 1, bufTime.sec);
}

/* 刷新当前设置位的光标 */
void RefreshSetShow() {
switch (setIndex) {
case 1: LcdSetCursor(5, 0); break;
case 2: LcdSetCursor(6, 0); break;
case 3: LcdSetCursor(8, 0); break;
case 4: LcdSetCursor(9, 0); break;
case 5: LcdSetCursor(11, 0); break;
case 6: LcdSetCursor(12, 0); break;
case 7: LcdSetCursor(4, 1); break;
case 8: LcdSetCursor(5, 1); break;
case 9: LcdSetCursor(7, 1); break;
case 10: LcdSetCursor(8, 1); break;
case 11: LcdSetCursor(10, 1); break;
case 12: LcdSetCursor(11, 1); break;
default: break;
}
}

/* 递增 BCD 码高位 */
unsigned char IncBcdHigh(unsigned char bcd) {
if ((bcd & 0xF0) < 0x90)
bcd += 0x10;
else
bcd &= 0x0F;

return bcd;
}

/* 递增 BCD 码低位 */
unsigned char IncBcdLow(unsigned char bcd) {
if ((bcd & 0x0F) < 0x09)
bcd += 0x01;
else
bcd &= 0xF0;

return bcd;
}

/* 递减 BCD 码高位 */
unsigned char DecBcdHigh(unsigned char bcd) {
if ((bcd & 0xF0) > 0x00)
bcd -= 0x10;
else
bcd |= 0x90;

return bcd;
}

/* 递减 BCD 码低位 */
unsigned char DecBcdLow(unsigned char bcd) {
if ((bcd & 0x0F) > 0x00)
bcd -= 0x01;
else
bcd |= 0x09;

return bcd;
}

/* 递增时间当前设置位的值 */
void IncSetTime() {
switch (setIndex) {
case 1: bufTime.year = IncBcdHigh(bufTime.year); break;
case 2: bufTime.year = IncBcdLow(bufTime.year); break;
case 3: bufTime.mon = IncBcdHigh(bufTime.mon); break;
case 4: bufTime.mon = IncBcdLow(bufTime.mon); break;
case 5: bufTime.day = IncBcdHigh(bufTime.day); break;
case 6: bufTime.day = IncBcdLow(bufTime.day); break;
case 7: bufTime.hour = IncBcdHigh(bufTime.hour); break;
case 8: bufTime.hour = IncBcdLow(bufTime.hour); break;
case 9: bufTime.min = IncBcdHigh(bufTime.min); break;
case 10: bufTime.min = IncBcdLow(bufTime.min); break;
case 11: bufTime.sec = IncBcdHigh(bufTime.sec); break;
case 12: bufTime.sec = IncBcdLow(bufTime.sec); break;
default: break;
}

RefreshTimeShow();
RefreshSetShow();
}

/* 递减时间当前设置位的值 */
void DecSetTime() {
switch (setIndex) {
case 1: bufTime.year = DecBcdHigh(bufTime.year); break;
case 2: bufTime.year = DecBcdLow(bufTime.year); break;
case 3: bufTime.mon = DecBcdHigh(bufTime.mon); break;
case 4: bufTime.mon = DecBcdLow(bufTime.mon); break;
case 5: bufTime.day = DecBcdHigh(bufTime.day); break;
case 6: bufTime.day = DecBcdLow(bufTime.day); break;
case 7: bufTime.hour = DecBcdHigh(bufTime.hour); break;
case 8: bufTime.hour = DecBcdLow(bufTime.hour); break;
case 9: bufTime.min = DecBcdHigh(bufTime.min); break;
case 10: bufTime.min = DecBcdLow(bufTime.min); break;
case 11: bufTime.sec = DecBcdHigh(bufTime.sec); break;
case 12: bufTime.sec = DecBcdLow(bufTime.sec); break;
default: break;
}

RefreshTimeShow();
RefreshSetShow();
}

/* 右移时间设置位 */
void RightShiftTimeSet() {
if (setIndex != 0) {
if (setIndex < 12)
setIndex++;
else
setIndex = 1;

RefreshSetShow();
}
}

/* 左移时间设置位 */
void LeftShiftTimeSet() {
if (setIndex != 0) {
if (setIndex > 1)
setIndex--;
else
setIndex = 12;

RefreshSetShow();
}
}

/* 进入时间设置 */
void EnterTimeSet() {
setIndex = 2; // 将设置索引赋值为 2 进入设置状态
LeftShiftTimeSet(); // 调用左移操作函数,移至位置 1 并完成显示刷新
LcdOpenCursor(); // 打开光标闪烁
}

/* 退出时间设置,参数 save 是否保存当前设置的时间值 */
void ExitTimeSet(bit save) {
setIndex = 0; // 设置索引为 0 退出设置状态

/* 如果需要保存,就将当前设置时间写入 DS1302 */
if (save) {
SetRealTime(&bufTime);
}

LcdCloseCursor(); // 关闭光标显示
}

/* 按键动作函数,根据键码执行相应的操作,keycode-按键键码 */
void KeyAction(unsigned char keycode) {
/* 本实验无需响应字符按键 */
if ((keycode >= '0') && (keycode <= '9')){}
/* 向上键,递增当前设置位的值 */
else if (keycode == 0x26) {
IncSetTime();
}
/* 向下键,递减当前设置位的值 */
else if (keycode == 0x28) {
DecSetTime();
}
/* 向左键,向左切换设置位 */
else if (keycode == 0x25) {
LeftShiftTimeSet();
}
/* 向右键,向右切换设置位 */
else if (keycode == 0x27) {
RightShiftTimeSet();
}
/* 回车键,进入设置模式或者确认当前设置值 */
else if (keycode == 0x0D) {
/* 如果不处于设置状态,那么就进入设置状态 */
if (setIndex == 0) {
EnterTimeSet();
}
/* 已处于设置状态时,保存时间并退出设置状态 */
else{
ExitTimeSet(1);
}
}
/* Esc键,取消当前设置 */
else if (keycode == 0x1B) {
ExitTimeSet(0);
}
}

/* 配置并启动定时器 T0,参数 ms 是需要的定时时间 */
void ConfigTimer0(unsigned int ms) {
unsigned long tmp; // 临时变量

tmp = 11059200 / 12; // 定时器计数频率
tmp = (tmp * ms) / 1000; // 计算所需的计数值
tmp = 65536 - tmp; // 计算定时器定时值
tmp = tmp + 28; // 补偿中断响应延时误差

/* 将定时值拆分为高、低字节 */
T0RH = (unsigned char)(tmp >> 8);
T0RL = (unsigned char)tmp;
TMOD &= 0xF0; // 清零定时器 T0 控制位
TMOD |= 0x01; // 配置定时器 T0 为模式 1
TH0 = T0RH;
TL0 = T0RL; // 加载定时器 T0 的定时值
ET0 = 1; // 使能定时器 T0 中断
TR0 = 1; // 启动定时器 T0
}

/* T0中断服务函数,执行按键扫描和200ms定时 */
void InterruptTimer0() interrupt 1 {
static unsigned char tmr200ms = 0;
TH0 = T0RH;
TL0 = T0RL; // 重新加载定时值
KeyScan(); // 调用按键扫描函数
tmr200ms++;

/* 定时 200ms */
if (tmr200ms >= 200) {
tmr200ms = 0;
flag200ms = 1;
}
}

main.c文件负责所有应用层功能的实现,虽然代码较长显得较为繁琐,但是实现原理方面并不存在难度。

红外通信与 NEC 协议

红外线是波长介于微波与可见光之间的电磁波(波长760 纳米 ~ 1 毫米),物体温度只要高于绝对零度-273就会由于分子、原子的无序运动而不停的辐射红外线。单片机开发电路当中,红外发射管类似于发光二极管,红外线的发射强度会随着电流的增大而增强;红外接收管是可以接收红外线波长的光敏二极管,内部是一个具有红外敏感特征的 PN 节;没有红外线时接收管不导通,有红外线时接收管导通,并在一定范围内电流随着红外线强度的增加而增大。

红外发射/接收管也用于避障、循迹小车等单片机实验,有兴趣的同学可以参考下面的原理图来搭建电路:

上图中的发射控制端与接收检测端都连接到单片机 IO 引脚:发射部分在发射控制端输出高电平时,三极管Q1不导通,红外发射管L1不会发射红外信号;当发射控制输出低电平时,三极管Q1导通L1开始发射红外信号。

接收部分通过电位器R4LM393的 2 号引脚提供一个阈值电压(该电压值可以通过调整电位器来确定),由于红外接收管L2接收红外线时会产生电流,并且随着红外线强度的增加,通过的电流也会变大。当没有红外线或者红外线较弱的时候,3 号引脚的电压值趋近于VCC,如果 3 号引脚电压高于 2 号,经过LM393比较器之后,红外接收检测端将输出一个高电平。随着红外线强度的增大,通过的电流也在变大。由于 3 号引脚的电压值等于VCC - I × R3,所以电压会越来越小,直至小于 2 号引脚电压时,红外接收检测引脚就会变为低电平。

该电路用于避障时,发射管先发送红外信号,红外信号会随着距离的加大而逐渐衰减,如果遇到障碍物就会形成反射。当反射回来的信号比较弱时,光敏二极管L2接收的红外线较弱,比较器LM393的 3 号引脚电压高于 2 号,此时接收检测引脚将输出高电平,说明障碍物当前距离较远;当反射回来的信号变强,接收检测引脚输出低电平时,说明障碍物已经距离较近了。

该电路用于小车循迹时,地面必须铺设黑色、白色轨道;当红外信号发送至黑色轨道时,由于黑色的光线吸收能力较强,被反射回的红外线信号较微弱,而白色轨道则会将大部分红外信号反射回来。循迹小车通常采用多个红外模块同时进行检测,从多个角度判断轨道,并以此调整小车行驶轨迹。

红外遥控通信原理

基带信号(Baseband)是指未经过调制的原始电信号,,信号频谱从零频附近开始并且频率较低,具有低通形式。

由于基带信号并不适合直接在信道中传输,为了便于传输、提高抗干扰能力、有效利用带宽,通常需要将信号调制为适当的频率范围(高频)进行传输,这个过程就称为信号调制。而接收端需要对接收到的调制信号进行解调,将其恢复为原始的基带信号。

家用电器中常用的红外遥控器,使用了38K左右的载波进行调制,所谓调制就是利用待传输的信号去控制某个高频信号的幅度、相位、频率等参数变化的过程,简而言之,就是使用一个信号去装载另一个信号。

上图中,原始信号是待发送的 1 位低电平0和 1 位高电平1,所谓38K 载波是指频率为38 KHz的方波信号,调制后的信号就是最终发射出去的波形。这里使用了原始信号去控制 38K 载波,当原始信号为低电平0时 38K 载波原样发送,当原始信号为高电平1时不发送任何载波信号。

38K 载波可以通过455 KHz晶振,经过12 分频后得到37.91 KHz,也可以通过时基集成电路NE555来生成,或者通过单片机的PWM来产生。当信号输出引脚输出高电平时,三极管Q2截止,无论38 KHz载波信号如何控制三极管Q1,右侧纵向的支路都不会导通,红外线发送管L1不会发送任何信息;当信号输出为低电平时,38 KHz载波会通过三极管Q1传输过来,并在L1上产生38 KHz载波信号。需要特别说明的是:大多数家用电器遥控器的38 KHz的占空比为1/21/3

生产环境下,接收端还需要对信号进行检测、放大、滤波、解调等处理,然后输出基带信号。但是实验电路采用的一体化红外接收头HS0038B已经集成了这些电路,因此电路得到了大幅的简化:

红外线接收头内置放大器的增益较大容易引起干扰,因此在接收头供电引脚上添加了10uF的滤波电容,并在 HS0038B 供电引脚VDD5V电源之间串联了一个100 Ω的电阻R69,进一步降低干扰。

前面两幅电路图,分别代表了红外线通信实验中的发送接收方,当 HS0038B 检测到38 KHz红外信号时,OUT引脚就会输出低电平,如果未检测到,OUT引脚就会输出高电平。由于OUT引脚输出的是解调之后的基带信号,所以将其连接至单片机 IO 引脚,即可以获取红外线发送过来的信号。

NEC 红外通信协议

红外通信的硬件成本明显低于其它无线通信方式,因此在家电遥控器当中始终占有一席之地。红外遥控器的基带通信协议有几十种,常用的就有 ITT、NEC、Sharp、Philips RC-5、Sony SIRC 协议等等,其中应用较多的是 NEC 协议,因此实验电路配套的遥控器直接采用了 NEC 协议。

NEC 协议的数据格式包括引导码用户码用户码(或用户码反码)、按键键码键码反码停止位,其中数据编码有4 Byte32 bit;其中,第 1 个字节是用户码,第 2 个字节即可能是用户码也可能是用户码反码(具体由生产商决定),第 3 个字节是当前按键的键码,第 4 个字节是键码的反码,主要用于数据纠错。NEC 协议每一位数据本身都需要编码,然后再进行载波调制:

  • 引导码:9ms载波加上4.5ms空闲;
  • 比特值0560us载波加上560us空闲;
  • 比特值1560us载波加上1.68ms空闲。

结合协议分析上面的 NEC 协议示意图,最前面的整块黑色是9ms载波所表达的引导码,紧接着是4.5ms空闲,接下来的数据码由多个载波和空闲交替组成,其长短具体由需要传输的数据来决定。HS0038B 接收到载波信号时会输出低电平,空闲时则会输出高电平,采用逻辑分析仪抓取一个红外遥控器按键经 HS0038B 解码后的波形图:

上图当中,首先是9ms载波加上4.5ms空闲起始码,而数据码是低位在前高位在后,数据码第 1 个字节是八组560us的载波加上560us空闲,即0x00;第 2 个字节是八组560us载波加上1.68ms空闲,即0xFF;这两个字节就是用户码用户码的反码。按键的二进制键码为0x0C,反码就是0xF3,最后再跟随了一个560us载波停止位。对于红外遥控器而言,不同按键仅仅是键码键码反码的区别,而用户码是相同的。

标准 51 单片机拥有外部中断 0外部中断 1两个外部中断,实验电路将红外接收引脚连接到STC89C52RCP3.3引脚,该引脚功能之一就是作为外部中断 1。寄存器TCON中的第 2、3 两位与外部中断 1 相关,其中IE1是外部中断标志位,当外部中断发生以后该位自动置1;而IT1用于设置外部中断类型,如果等于0则只需P3.3为低电平即可触发中断,如果为1则会在P3.3从高电平向低电平产生下降沿时才会触发中断。此外,外部中断 1 的使能位是EX1

接下来着手编写一个实验程序,使用动态数码管将红外遥控器的用户码和键码显示出来。Infrared.c文件主要用于检测红外通信,当发生外部中断之后,进入外部中断并通过定时器 1 定时,首先判断引导码,然后根据数据码逐位获取高低电平时间,进而得知每一位上是0还是1,进而最终完成解码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
/** Infrared.c,使用 NEC 编码协议的红外遥控驱动程序 */
#include <reg52.h>

sbit IR_INPUT = P3 ^ 3; // 红外接收引脚
bit irflag = 0; // 红外接收标志位,接收到一帧数据以后置 1
unsigned char ircode[4]; // 红外数据接收缓冲区

/* 初始化红外接收功能 */
void InitInfrared() {
IR_INPUT = 1; // 确保红外接收引脚被释放
TMOD &= 0x0F; // 清零定时器 T1 控制位
TMOD |= 0x10; // 设置定时器 T1 为工作模式 1
TR1 = 0; // 停止定时器 T1 计数
ET1 = 0; // 禁止定时器 T1 中断
IT1 = 1; // 设置 INT1 为下降沿触发
EX1 = 1; // 使能 INT1 外部中断
}

/* 获取当前高电平的持续时间 */
unsigned int GetHighTime() {
TH1 = 0; TL1 = 0; // 定时器 T1 定时值置 0
TR1 = 1; // 启动定时器 T1

/* 红外输入引脚为 1 时循环进行检测,变为 0 时则结束循环 */
while (IR_INPUT) {

/* 当定时器 T1 的定时值大于 0x4000,即高电平持续时间超过约 18ms 时强制退出循环 */
if (TH1 >= 0x40) {
break; // 避免信号异常致使程序跑飞
}
}

TR1 = 0; // 定时器 T1 停止
return (TH1 * 256 + TL1); // 将定时器 T1 的计数值转换为 16 位整型数据并返回
}

/* 获取当前低电平的持续时间 */
unsigned int GetLowTime() {
TH1 = 0;
TL1 = 0; // 定时器 T1 定时值置 0
TR1 = 1; // 启动定时器 T1

/* 红外输入引脚为 0 时循环进行检测,变为 1 时则结束循环 */
while (!IR_INPUT) {

/* 当定时器 T1 的定时值大于 0x4000,即低电平持续时间超过约 18ms 时强制退出循环 */
if (TH1 >= 0x40) {
break; // 避免信号异常致使程序跑飞
}
}

TR1 = 0; // 定时器 T1 停止
return (TH1 * 256 + TL1); // 将定时器 T1 的计数值转换为 16 位整型数据并返回
}

/* 外部中断 INT1 中断服务函数,用于红外信号的接收与解码 */
void EXINT1_ISR() interrupt 2 {
unsigned char i, j;
unsigned char byt;
unsigned int time;
time = GetLowTime(); // 接收并且判断引导码的 9ms 低电平

/* 时间判定范围为 8.5ms ~ 9.5ms,超过此范围则说明为错误码,直接退出 */
if ((time < 7833) || (time > 8755)) {
IE1 = 0; // 退出前清零 INT1 中断标志位
return;
}

time = GetHighTime(); // 接收并且判定引导码的4.5ms 高电平

/* 时间判定范围为 4.0ms ~ 5.0ms,超过此范围则说明为错误码,直接退出 */
if ((time < 3686) || (time > 4608)) {
IE1 = 0;
return;
}

/* 循环接收并且判断后续 4 个字节的数据 */
for (i = 0; i < 4; i++) {
/* 循环判定每个字节的 8 个位数据 */
for (j = 0; j < 8; j++) {

time = GetLowTime(); // 接收并且判断引导码的 560us 低电平

/* 时间判定范围为 340us ~ 780us,超过此范围则说明为错误码,直接退出 */
if ((time < 313) || (time > 718)) {
IE1 = 0;
return;
}

time = GetHighTime(); // 接收每位的高电平持续时间,并且判断该位的值

/* 时间判定范围为 340us ~ 780us,在此范围以内说明该位为 0 */
if ((time > 313) && (time < 718)) {
byt >>= 1; // 由于低位在前,所以数据右移,高位为 0
}
/* 时间判定范围为 1460 ~ 1900us,在此范围以内说明该位为 1 */
else if ((time > 1345) && (time < 1751)) {
byt >>= 1; // 由于低位在前,所以数据右移
byt |= 0x80; // 让高位置 1
}
/* 如果不在上述时间范围以内说明为错误码,直接退出 */
else {
IE1 = 0;
return;
}
}

ircode[i] = byt; // 接收完 1 个字节以后,将其保存至缓冲区
}

irflag = 1; // 接收完毕后设置标志位
IE1 = 0; // 清零 INT1 中断标志位
}

上面代码在获取高低电平时间的时候,使用if (TH1 >= 0x40)语句进行了一个超时判断,该判断主要是为了处理输入信号异常的情况。如果没有做超时判断,一旦输入的信号异常,由于程序等待的跳变沿无法到来,从而造成程序假死。此外,红外遥控器【单次按下按键】与【持续按下按键】所产生的信号时序不同,下面可以对比一下两者的波形:

持续按键首先会发送一个与单次按键类似的波形,经历大约40ms之后,将产生9ms载波加上2.25ms空闲,再跟随 1 个停止位波形,这被称为重复码,只要依然持续按住按键,每隔约108ms就会产生一个重复码。上面代码忽略了这个重复码的解析,这并不会影响正常按键数据的接收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
/* main.c */
#include <reg52.h>

sbit ADDR3 = P1 ^ 3;
sbit ENLED = P1 ^ 4;

/* 数码管编码表 */
unsigned char code LedChar[] = {0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8,
0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E};

/* 动态数码管显示缓冲区*/
unsigned char LedBuff[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; // 将初始值设置为 0xFF,确保启动时数码管处于熄灭状态

unsigned char T0RH = 0; // 定时器 T0 定时值高字节
unsigned char T0RL = 0; // 定时器 T0 定时值低字节

extern bit irflag;
extern unsigned char ircode[4];
extern void InitInfrared();
void ConfigTimer0(unsigned int ms);

void main() {
EA = 1; // 总中断使能
ENLED = 0; // 使能网络标号为 U3 的 74HC138 译码器
ADDR3 = 1;
InitInfrared(); // 初始化红外功能
ConfigTimer0(1); // 定时器 T0 定时 1ms
PT0 = 1; // 设置定时器 T0 中断为高优先级,可消除信号接收时的闪烁问题

while (1) {
/* 当接收到红外数据时,动态数码管刷新显示 */
if (irflag) {
irflag = 0;
LedBuff[5] = LedChar[ircode[0] >> 4]; // 显示用户码
LedBuff[4] = LedChar[ircode[0] & 0x0F];
LedBuff[1] = LedChar[ircode[2] >> 4]; // 显示按键码
LedBuff[0] = LedChar[ircode[2] & 0x0F];
}
}
}

/* 配置并启动T0,ms-T0定时时间 */
void ConfigTimer0(unsigned int ms) {
unsigned long tmp; // 临时变量

tmp = 11059200 / 12; // 计算定时器计数频率
tmp = (tmp * ms) / 1000; // 计算所需计数值
tmp = 65536 - tmp; // 计算定时器定时值
tmp = tmp + 13; // 补偿中断响应延时造成的误差
T0RH = (unsigned char)(tmp >> 8); // 定时值拆分为高、低字节
T0RL = (unsigned char)tmp;
TMOD &= 0xF0; // 清零定时器 T0 控制位
TMOD |= 0x01; // 设置定时器 T0 为模式 1
TH0 = T0RH; TL0 = T0RL; // 加载定时器 T0 定时值
ET0 = 1; // 使能定时器 T0 中断
TR0 = 1; // 启动定时器 T0
}

/* LED动态扫描刷新函数,需在定时中断中调用 */
void LedScan() {
static unsigned char i = 0; // 动态扫描索引

P0 = 0xFF; // 关闭所有段选位,数码管显示消隐
P1 = (P1 & 0xF8) | i; // 将位选索引赋值给单片机 P1 接口低 3 位
P0 = LedBuff[i]; // 将缓冲区中的索引位置发送至单片机 P0 接口

/* 索引位置自增,遍历缓冲区 */
if (i < sizeof(LedBuff) - 1)
i++;
else
i = 0;
}

/* T0中断服务函数,执行数码管扫描显示 */
void InterruptTimer0() interrupt 1 {
TH0 = T0RH; TL0 = T0RL; // 重新加载定时值
LedScan(); // 数码管显示扫描
}

main.c将获取的红外遥控器用户码、键码发传至数码管上动态显示,并通过定时器 T0 间隔1ms对数码管进行动态刷新。程序运行之后,如果按下遥控器按键,数码管显示会发生闪烁,这是由于单片机程序顺序执行时,一旦按下遥控器按键,单片机就会进入遥控器解码中断程序,而该程序执行时间较长(大约需要几十毫秒),如果数码管动态刷新间隔超过10ms,肉眼就会感觉到闪烁。

解决这个问题,需要利用到 STC89C52RC 中断的嵌套特性。上面程序中存在两个中断程序,一个是用于接收红外数据的外部中断程序,一个是负责数码管扫描的定时器中断程序,接收红外信号时如果希望不要影响到数码管的动态扫描,那么必须让定时器中断去嵌套外部中断,即将定时器中断设置为高抢占优先级。

定时器中断程序执行时间仅有几十微秒,即使打断了红外接收中断代码的执行,顶多对每位的时间测量造成这几十微秒的误差,而该误差在最短560us的时间判断内是可接受的,所以中断嵌套并不会影响红外数据的正常接收。上面main()函数当中的PT0 = 1语句就是将定时器 T0 中断设置为高抢占式优先级,从而成功解决了上述的闪烁问题。

温度传感器 DS18B20

DS18B20 是 Maxim 美信半导体推出的一款温度传感器,可以采用 1-Wire 总线协议获取温度数据。1-Wire总线的硬连接比较简单,只需将 DS18B20 的数据引脚与单片机 IO 引脚直接相连即可。但是其总线时序较为复杂,下面首先来看一下 DS18B20 的硬件原理图:

DS18B20 能够存储高达12 bit的温度值,在寄存器中是以补码形式式存储,如下图所示:

一共 2 个字节,其中LSB为低字节MSB为高字节,低 11 位都是的指数形式,用于表示温度,字母S用于标识符号位。DS18B20 的温度测量范围在-55℃ ~ +125℃之间,因此温度数据有正负之分,寄存器每个数值如同卡尺刻度一样分布,请参考下面的温度数据关系表:

二进制数值最低位增减1就代表温度增减了0.0625℃0℃时对应的十六进制为0x0000125℃时对应的十六进制为0x07D0-55℃时对应的十六进制为0xFC90。换个角度,十六进制数0x0001对应的温度就是0.0625℃

接下来的内容,将会对 DS18B20 工作协议过程进行详细的梳理:

初始化

与 I²C 总线寻址类似,1-Wire总线也会检测是否存在 DS18B20 设备。如果存在,总线会根据时序要求返回一个低电平脉冲;如果不存,总线就保持高电平不返回任何脉冲;该操作习惯上被称为存在脉冲检测。此外,存在脉冲不仅检测设备存在与否,还会通知 DS18B20 进行操作前的准备,具体请参考下面的时序图:

时序图中的【实心粗线】是由于单片机 IO 接口拉低该引脚,【虚粗线】是由于 DS18B20 拉低该引脚,【浅色细线】是单片机依靠上拉电阻将 IO 接口上拉为高电平释放总线。存在脉冲检测过程,首先由单片机拉低电平,持续约480us ~ 960us时间即可;然后,单片机输出高电平释放总线,DS18B20 等待约15us ~ 60us以后,下拉为低电平并持续60us ~ 240us;最后 DS18B20 释放总线,IO 引脚通过上拉电阻自动拉高。下面通过一段程序逐句进行解释,首先由于 DS18B20 时序要求非常严格,所以操作时序时为了防止中断干扰总线时序,需要关闭总中断;然后拉低 DS18B20 引脚并持续500us;接下来,延时60us;最后,读取存在脉冲并等待存在脉冲结束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bit Get18B20Ack() {
bit ack;

EA = 0; // 禁用总中断
IO_18B20 = 0; // 产生 500us 复位脉冲
DelayX10us(50);
IO_18B20 = 1;
DelayX10us(6); // 延时 60us
ack = IO_18B20; // 读取存在脉冲
while (!IO_18B20); // 等待存在脉冲结束
EA = 1; // 使能总中断

return ack;
}

时序图上标注 DS18B20 需要等待15us ~ 60us,程序代码中延时60us就是要确保能够读到存在脉冲。

ROM 操作指令

1-Wire 总线同样可以挂载多个设备,每个 DS18B20 内部都有一个唯一的 64 位长度序列号,该序列号保存在 DS18B20 内部 ROM 当中。前 8 位是产品类型编码(DS18B20 为0x10),接下来 48 位是每个设备的唯一序列号,最后 8 位是 CRC 校验码。

1-Wire 总线有效长度可以达到数十米,单片机通过 1-Wire 与多个 DS18B20 设备通信可以获取多组温度信息,也可以同时向所有 DS18B20 设备发送指令,这种一对多的指令相对而言较为复杂,这里只对 1-Wire 总线只连接一个设备的情况进行分析。当总线上只有一个设备的时候,可以通过0xCC指令跳过 ROM 检测。

RAM 存储器操作指令

本小节只列出 DS18B20 的两条 RAM 读取指令,更多指令可以通过查询官方数据手册来获取。

  • 0xBE:读暂存寄存器(Read Scratchpad),注意 DS18B20 提供的温度数据为 2 个字节,读取数据的时候,首先读取到的是低字节的低位,第 1 个字节读取完毕以后再读取高字节的低位,直至两个字节全部读取完毕。
  • 0x44:启用温度转换(Convert Temperature),该指令发送后 DS18B20 开始进行温度转换,从开始转换到获取温度,DS18B20 根据自身的精度需要耗费一定时间。DS18B20 可以选用1211109位四种格式来呈现温度,位数越高精度就越高,9 位模式下最低位变化 1 个数字,虽然温度只会变化 0.5℃,但是与此同时其转换速度相对更快,具体请参考下表所示:

上述表格中的寄存器R1R0决定了其转换位数,出厂时默认值为11,即采用12位格式来表达温度,最大转换时间为750ms。开始温度转换之后,至少要再等待750ms才能读取温度,否则有可能会读到错误的值。

DS18B20 位读写时序

上图上半部分是 DS18B20 的位写入时序图,当向 DS18B20 写入0时单片机将引脚拉低,持续60us ~ 120us时间即可。当单片机拉低15us以后,DS18B20 会在15us ~ 60us之间读取该位,典型时间一般是在30us左右进行读取。当向 DS18B20 写入1时单片机将引脚拉低,持续时间大于1us即可;然后马上拉高电平释放总线,持续时间大于60us即可;同样的,DS18B20 会在15us ~ 60us之间对其进行读取。

DS18B20 对时序的要求极为严格,写入过程最好不要产生中断,但是两个数据位之间的间隔时间大于1us且小于无穷,在这个时间段可以开启中断处理其它程序,下面是一个向 DS18B20 写入1Byte数据的示例程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void Write18B20(unsigned char dat) {
unsigned char mask;
EA = 0; // 禁用总中断

/* 低位在先,依次移出 8 个位 */
for (mask = 0x01; mask != 0; mask <<= 1) {
IO_18B20 = 0; // 产生 2us 低电平
_nop_();
_nop_();

/* 输出该位的值 */
if ((mask & dat) == 0)
IO_18B20 = 0;
else
IO_18B20 = 1;

DelayX10us(6); // 延时 60us
IO_18B20 = 1; // 拉高电平释放总线
}

EA = 1; // 使能总中断
}

上图的下半部分是 DS18B20 的位读取时序图,读取 DS18B20 数据时,首先单片机拉低引脚并保持至少1us时间,然后释放引脚。释放完毕以后需要尽快读取,从拉低该引脚到读取引脚状态不能超过15us。从上图可以看出,主机(MASTER SAMPLES)的采样时间必须在15us以内完成,下面是一个从 DS18B20 读取1Byte数据的示例程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
unsigned char Read18B20() {
unsigned char dat;
unsigned char mask;
EA = 0; // 禁用总中断

/* 低位在先,依次采集 8 个位 */
for (mask = 0x01; mask != 0; mask <<= 1) {
IO_18B20 = 0; // 产生 2us 低电平
_nop_();
_nop_();
IO_18B20 = 1; // 拉高电平状态,等待 18B20 输出数据
_nop_(); // 延时 2us
_nop_();

/* 读取通信引脚的值 */
if (!IO_18B20)
dat &= ~mask;
else
dat |= mask;
DelayX10us(6); // 延时 60us
}

EA = 1; // 使能总中断
return dat;
}

1602 液晶温度显示实验

DS18B20 提供的温度值包含了小数和整数两部分,带小数的数据处理方法通常有两种:第一种是定义成浮点型直接处理,第二种是定义为整型,然后将小数与整数部分进行分离,最后在合适位置添加小数点。本实验程序采用了第二种方法,将读到的温度值显示在 1602 液晶,并且保留一位小数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
/* DS18B20.c,温度传感器 DS18B20 驱动程序 */
#include <intrins.h>
#include <reg52.h>

sbit IO_18B20 = P3 ^ 2; // 声明用于 DS18B20 的单片机通信引脚

/* 软件延时函数,延时时间(t × 10)us */
void DelayX10us(unsigned char t) {
do {
_nop_();
_nop_();
_nop_();
_nop_();
_nop_();
_nop_();
_nop_();
_nop_();
} while (--t);
}

/* 存在脉冲检测,并且初始化 DS18B20 */
bit Get18B20Ack() {
bit ack;

EA = 0; // 禁用总中断
IO_18B20 = 0; // 产生 500us 复位脉冲
DelayX10us(50);
IO_18B20 = 1;
DelayX10us(6); // 延时 60us
ack = IO_18B20; // 读取存在脉冲
while (!IO_18B20); // 等待存在脉冲结束
EA = 1; // 使能总中断

return ack;
}

/* 向 DS18B20 写入 1 个字节,参数 dat 是待写入的字节 */
void Write18B20(unsigned char dat) {
unsigned char mask;
EA = 0; // 禁用总中断

/* 低位在先,依次移出 8 个位 */
for (mask = 0x01; mask != 0; mask <<= 1) {
IO_18B20 = 0; // 产生 2us 低电平
_nop_();
_nop_();

/* 输出该位的值 */
if ((mask & dat) == 0)
IO_18B20 = 0;
else
IO_18B20 = 1;

DelayX10us(6); // 延时 60us
IO_18B20 = 1; // 拉高电平释放总线
}

EA = 1; // 使能总中断
}

/* 从 DS18B20 读取 1 个字节,返回值为读取到的字节 */
unsigned char Read18B20() {
unsigned char dat;
unsigned char mask;
EA = 0; // 禁用总中断

/* 低位在先,依次采集 8 个位 */
for (mask = 0x01; mask != 0; mask <<= 1) {
IO_18B20 = 0; // 产生 2us 低电平
_nop_();
_nop_();
IO_18B20 = 1; // 拉高电平状态,等待 18B20 输出数据
_nop_();
_nop_(); // 延时 2us

/* 读取通信引脚的值 */
if (!IO_18B20)
dat &= ~mask;
else
dat |= mask;

DelayX10us(6); // 延时 60us
}

EA = 1; // 总中断使能
return dat;
}

/* 启动 1 次 DS18B20 温度转换,返回启动是否成功的状态 */
bit Start18B20() {
bit ack;
ack = Get18B20Ack(); // 总线复位,并且获取 18B20 响应

/* 如果 18B20 正确响应,则启动 1 次转换 */
if (ack == 0) {
Write18B20(0xCC); // 跳过ROM操作
Write18B20(0x44); // 启动 1 次温度转换
}

return ~ack; // 由于使用了 ack = 0 表示操作成功,所以需要对返回值进行取反
}

/* 读取 DS18B20 转换出的温度值,返回读取是否成功的状态 */
bit Get18B20Temp(int *temp) {
bit ack;
unsigned char LSB, MSB; // 声明 16 位温度值的高低字节
ack = Get18B20Ack(); // 总线复位,并且获取 18B20 响应

/* 如果 18B20 正确响应,则读取温度值 */
if (ack == 0) {
Write18B20(0xCC); // 跳过 ROM 操作
Write18B20(0xBE); // 发送读命令
LSB = Read18B20(); // 读取温度值低字节
MSB = Read18B20(); // 读取温度值高字节
*temp = ((int)MSB << 8) + LSB; // 转换为 16 位整型数据
}

return ~ack; // 由于 ack = 0 表示操作正确响应,所以需要对其进行取反
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
/** main.c,采用 DS18B20 测量温度并显示到1602 液晶,此处省略 Lcd1602.c 文件 */
#include <reg52.h>

bit flag1s = 0; // 定时 1 秒标志位
unsigned char T0RH = 0; // 定时器 T0 重载值高字节
unsigned char T0RL = 0; // 定时器 T0 重载值低字节

void ConfigTimer0(unsigned int ms);
unsigned char IntToString(unsigned char *str, int dat);
extern bit Start18B20();
extern bit Get18B20Temp(int *temp);
extern void InitLcd1602();
extern void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str);

void main() {
bit res;
int temp; // 当前读取到的温度值
int intT, decT; // 温度值的整数与小数部分
unsigned char len;
unsigned char str[12];

EA = 1; // 总中断使能
ConfigTimer0(10); // 定时器 T0 定时 10ms
Start18B20(); // 启动 DS18B20
InitLcd1602(); // 初始化 1602 液晶

while (1) {
/* 每间隔 1 秒更新一次温度显示 */
if (flag1s) {
flag1s = 0;
res = Get18B20Temp(&temp); // 读取当前温度值

/* 如果读取成功,就刷新当前温度显示 */
if (res) {
intT = temp >> 4; // 分离温度值整数部分
decT = temp & 0xF; // 分离温度值小数部分
len = IntToString(str, intT); // 整数部分转换为字符串
str[len++] = '.'; // 添加小数点
decT = (decT * 10) / 16; // 二进制小数部分转换为十进制
str[len++] = decT + '0'; // 十进制小数转换为 ASCII 字符

/* 使用空格补齐至 6 个字符长度 */
while (len < 6) {
str[len++] = ' ';
}
str[len] = '\0'; // 添加字符串结束符
LcdShowStr(0, 0, str); // 显示到 1602 液晶
}
/* 如果读取失败时,则提示错误信息 */
else {
LcdShowStr(0, 0, "error!");
}

Start18B20(); // 开始下一次温度转换
}
}
}

/* 将整型数据转换为字符串类型,参数 str 是字符串指针,参数 dat 是待转换的数据,返回值是字符串长度 */
unsigned char IntToString(unsigned char *str, int dat) {
signed char i = 0;
unsigned char len = 0;
unsigned char buf[6];

/* 如果待转换的数据为负数,那么取得绝对值后在指针上添加负号 */
if (dat < 0) {
dat = -dat;
*str++ = '-';
len++;
}

/* 将数据转换为低位在前的十进制数组 */
do {
buf[i++] = dat % 10;
dat /= 10;
} while (dat > 0);

len += i; // 变量 i 的最终值是有效字符的个数

/* 将数组值转换为 ASCII 码,并反向拷贝到接收指针上 */
while (i-- > 0) {
*str++ = buf[i] + '0';
}

*str = '\0'; // 添加字符串结束符
return len; // 返回字符串长度
}

/* 配置并启动定时器 T0,参数 ms 是定时的时间 */
void ConfigTimer0(unsigned int ms) {
unsigned long tmp; // 临时变量

tmp = 11059200 / 12; // 定时器计数频率
tmp = (tmp * ms) / 1000; // 计算所需计数值
tmp = 65536 - tmp; // 计算定时器的定时值
tmp = tmp + 12; // 补偿中断响应延时造成的误差

/* 将定时值拆分为高低字节 */
T0RH = (unsigned char)(tmp >> 8);
T0RL = (unsigned char)tmp;

TMOD &= 0xF0; // 清零定时器 T0 控制位
TMOD |= 0x01; // 配置定时器 T0 为工作模式 1
TH0 = T0RH;TL0 = T0RL; // 加载定时器 T0 的定时值
ET0 = 1; // 定时器 T0 中断使能
TR0 = 1; // 启动定时器 T0
}

/* 定时器 T0 中断服务函数,用于完成 1 秒的定时 */
void InterruptTimer0() interrupt 1 {
static unsigned char tmr1s = 0;
TH0 = T0RH; TL0 = T0RL; // 重新加载定时值
tmr1s++;

/* 定时 1 秒 */
if (tmr1s >= 100) {
tmr1s = 0;
flag1s = 1;
}
}

模数 AD / 数模 DA

单片机处理的是数字信号,而工业控制领域与消费类电子领域中大量的信号都是模拟量,比如温度、距离、压力、速度等等,这就需要对模拟量和数字量进行相应的转换,这正是本节将要探讨的内容。

A/D是模拟量 ➡ 数字量的转换,依靠的是模数转换器(ADC,Analog to Digital Converter);D/A是数字量 ➡ 模拟量的转换,依靠的是数模转换器(DAC,Digital to Analog Converter)。两者原理基本相同,区别仅在于转换方向的不同,本节内容主要以A/D为例子来进行讨论。可以将 AD 分为积分型逐次逼近型并行/串行比较型Σ-Δ 型等多种类型,但通常都会涉及到如下技术指标:

  1. ADC 的位数:ADC 有n位就表示该 ADC 拥有2ⁿ个刻度,例如 8 位 ADC 可以输出2⁸ = 256个数字量(数据刻度)。
  2. 基准源:也称为基准电压,要想准确测量输入的 ADC 信号,基准源必须要准确,例如基准源应为5.10V,但实际只提供4.5V,那么就会造成较大的偏差。
  3. 分辨率:是数字量变化 1 个最小刻度时,模拟信号的变化量,计算方法为满刻度量程 / (2n-1),例如5.10V的电压使用 8 位 ADC 测量,相当于采用 256 个刻度将5.10V平均分为 255 等份,那么分辨率就等于5.10V / (256 - 1) = 0.02V
  4. 转换速率:指 ADC 每秒能够进行采样转换的最大次数,其单位为sa/ss/ssps(即 Samples Per Second 缩写),其与 ADC 完成 1 次模数转换所需的时间互为倒数。积分型 ADC 转换时间为毫秒级的,属于低速 ADC;逐次逼近型 ADC 转换时间为微秒级,属于中速 ADC;并行/串行 ADC 转换时间可达到纳秒级,属于高速 ADC。
  5. INL(积分非线性度,Integral NonLiner)和DNL(差分非线性度,Differencial NonLiner):分辨率与精度是两个容易混淆的概念,通常认为分辨率越高精度越高,但实际上,两者之间并没有必然联系。分辨率是用来描述刻度划分,精度则主要用来描述准确程度。 INL 和 DNL 分别是衡量 ADC 精度的两个重要指标。

INL是指 ADC 在所有数值上对应的模拟值与真实值之间误差最大那个点的误差值,单位是LSB(最低有效位,Least Significant Bit),其实质对应的是 ADC 的分辨率。例如基准为5.10V的 8 位 ADC,其分辨率为0.02V,如果用其测量一个电压信号得到的结果为100,则表示其所测得的电压值为100 × 0.02V = 2V,假如其 INL 为1 LSB,就表示该电压信号真实准确值位于1.98V ~ 2.02V之间,理想情况下对应得到的数值应在99 ~ 101范围,测量误差为一个最低有效位1 LSB

DNL是指 ADC 相邻两个刻度之间的最大误差,单位同样为LSB。这是由于 ADC 两个刻度线之间并不总是准确的等于分辨率,而是存在一定的误差 DNL。例如基准为5.10V的 8 位 ADC,其 DNL 假定为0.5 LSB,那么当其转换结果从 100 增加至 101 时,理想情况下实际电压应该增加 0.02V,但 DNL 为 0.5 LSB 情况下实际电压增加值应位于0.01~0.03V范围。此外,DNL 并不一定小于1 LSB,也可能会等于或大于1 LSB,当实际电压保持不变时,ADC 得出的结果可能会在多个数值之间跳跃。

PCF8591 数据采集

PCF8591 是一个单电源低功耗的 8 位 CMOS 数据采集器件,具备 4 路模拟输入、1 路模拟输出以及 1 个用于与单片机通信的 I²C 总线接口。与之前实验电路所使用的 24C02 类似,三个地址引脚A0A1A2分别用于硬件地址编程,可以允许 8 个设备连接至 I²C 总线而无需额外的片选电路,器件的地址、控制、数据都通过 I²C 进行传输,下图是 PCF8591 的电路原理图:

上图当中,第 1、2、3、4 号引脚为四路模拟输入,第 5、6、7 号引脚为 I²C 设备地址,第 8 脚是接地GND,第 9、10 脚是 I²C 总线的SDASCL,第 12 脚是时钟选择引脚(高电平表示采用外部时钟输入,低电平表示使用内部时钟),当前实验电路将第 12 脚接 GND,所以采用的是内部时钟,第 11 脚悬空,第 13 脚是模拟地 AGND,实际开发中,对于较为复杂的模拟电路,AGND 部分在布局布线上需要特殊处理。当前实验电路并不存在复杂的模拟电路,所以将 AGND 和 GND 连接在一起;第 14 脚是基准源,第 15 脚是 DAC 模拟输出,第 16 脚是VCC供电电源。

PCF8591 的 ADC 属于逐次逼近型,虽然转换速率属于中速,但是其速度瓶颈在于 I²C 总线。由于 I²C 通信速度较慢,所以 PCF8591 的最终转换速度取决于 I²C 的通信速率。因为 I²C 通信速率的限制,所以 PCF8591 只能算作低速 AD/DA,主要用于一些转换速度要求不高、成本较低的场合,例如用于检测电池供电电压低于某值时提醒更换电池。

Vref 基准电压有两种提供方式:一是采用简易原则直接连接到VCC,但是VCC可能会受到整个实验电路上元件功耗的影响,一方面可能不是准确的5V,另一方面伴随电路负载的变动会产生波动,所以只能用于精度要求较低的场合。二是使用TL431这样的专用基准电压器件,采用其提供的高精度2.5V电压基准。

上图中的J17是双排插针,可以使用杜邦线或者跳线帽连接其它外部电路,这里直接将J17的 3 脚和 4 脚用跳线帽短接,因此当前Vref基准源为2.5V。如果分别将5 ~ 12引脚用跳线帽短接,那么AIN0实际测量到的就是电位器分压值,AIN1AIN2测的是 GND 的值,AIN3测的是+5V的值。需要注意:AIN3虽然测量的是+5V,但是对于 AD 而言,只要输入信号超过Vref 基准源,得到的始终都是最大值255,换而言之,实际上 AD 无法测量超过其Vref的电压信号。此外,所有输入信号的电压值都不能超过+5VVCC,否则可能会导致 ADC 芯片损坏。

由于 PCF8591 采用了 I²C 总线与单片机通信,单片机发送 3 个字节就可以完成对 PCF8591 的初始化:第 1 个字节与 EEPROM 通信时类似,是器件的地址字节,其中 7 位代表地址(高 4 位固定为0b1001,低 3 位A2A1A0全部接GND取值为0b000) 1 位代表读写方向,具体如下图所示:

单片机通过 I²C 总线发送到PCF8591的第 2 个字节用于控制 PCF8591 的各种功能,其中第 0 和第 1 位是通道选择位,00011011分别代表从 0 到 3 共四个通道的选择;第 2 位是自动增量控制位,自动增量是指:假如当前一共有 4 个通道,读取完通道 0 以后下次会自动读取通道 1,由于 A/D 每次读到的数据都是前一次的转换结果,所以使用自动增量时需要注意,当前读取的实质是上一通道的值(出于程序通用性的考量,后续实验代码未启用该功能);第 3 和第 7 位固定为0;第 6 位为 DA 使能位,该位置1表示DA输出引脚使能,启动模拟电压的输出;第 4 和第 5 位用于将 PCF8591 的四路模拟输入配置为单端模式差分模式;具体请参考如下示意图:

单片机通过 I²C 总线发送到PCF8591的第 3 个字节表示 D/A 模拟输出的电压值,如果仅使用 A/D 功能可以不发送该字节。

接下来着手编写一个实验程序,将模拟输入通道AIN0AIN1AIN3测得的电压值显示到 1602 液晶,当转动实验电路中的电位器时,AIN0的值会发生改变。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93