伴随 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 单片机最小系统由电源 、晶振 、复位 电路三个要素构成,参考如下电路图所示:
电路图当中,连线上放置的字符称为网络标号,相同名称的网络标号表示此处相互连接。
电源
目前主流单片机的电源主要分为5V
和3.3V
两个标准,STC89C52RC 属于5V
单片机,当前电路使用计算机
USB
接口输出的5V
直流进行供电,供电电路分别连接至单片机的40脚
(VCC ,接+5V
代表电源正极)与20脚
(GND ,接地代表电源负极)。
晶振
晶振的全称叫做晶体振荡器,作用是为单片机系统提供基准时钟信号,STC89C52RC 内部所有工作都以该信号作为基准步调。STC89C52RC 的18脚
和19脚
是外接晶振引脚,这里使用了频率为11.0592MHz
的晶振(每秒振荡11059200
次),并且外加两个20pF
电容协助晶振起振以及维持振荡信号稳定。晶振通常可分为无源晶振和有源晶振两种类型,有源晶振 是一套利用石英晶体压电效应起振的完整谐振振荡器,供电后即可产生高精度的振荡频率。无源晶振 需要芯片内置的振荡电路协同工作才能产生振荡信号,两侧通常还需放置两枚10pF~40pF
电容(通常选取典型值20pF
)。
上图是深圳扬兴科技 生产的YXC 品牌晶振,最左侧的是一个无源晶振,拥有
VCC
、GND
、信号输出
、悬空/使能
4
个引脚,使用时只需要将信号输出
引脚连接到单片机的晶振信号输入引脚XTAL1
即可。右侧的三个都是无源晶振,通常拥有
2
个不区分正负极性的引脚,使用时分别连接到单片机的XTAL1
和XTAL2
两个晶振信号引脚上面。
注意:无源晶振有时也存在 3
个引脚的情况,其中间引脚连接晶振外壳后接入GND
。
复位电路
左侧复位电路连接至单片机的9脚
,即RST (Reset)复位引脚。单片机复位通常可分为上电复位(每次上电都从一固定的相同的状态开始工作)、手动复位(通过复位按键让程序重新初始化运行)、程序自动复位(程序失去响应时看门狗自动重启并复位单片机)三种情况。
当上面这个复位电路处于稳定工作状态时,电容C11 起到了隔离5V
直流的作用,由于左侧复位按键处于弹起状态,下半部分电路接地后电压为0V
,STC89C52RC 单片机属于高电平复位,低电平正常工作 ,因而此时单片机就处于正常工作状态。接下来的内容,重点讨论一下上电复位 和手动复位 。
上电复位 发生在上电一瞬间:电容C11 上方电路电压为5V
下方电路电压为0V
,伴随电容逐步开始充电,所有电压都加在电阻R31 上面,此时RST 端口位置的电压为5V
。伴随电容充电量逐步增多,电流将会越来越小,此时由于RST 端口上的电压等于电流乘以R31 的阻值,所以电压将会越来越小,直至电容完全充满之后,RST 端口与GND 电位相同,两端电压差为0V
所以不再产生电流。换而言之,单片机上电之后,RST 引脚会先保持一小段时间(不少于2
个机器周期时间 )高电平然后变为低电平,也就是经历了一个完整的上电复位过程。
每种单片机的复位电压各不相同,STC89C52RC 通常按照0.7 × VCC
作为复位电压值,而复位时间的计算过程较为复杂,这里只需要记住一个结论:t = 1.2 × R × C
,其中R 是4700
欧,C 是0.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脚
的VCC 和4脚
的GND ,其中1脚
通过F1
自恢复保险丝 连接至右侧电路,正常工作时保险丝可以视为导线,但当后级电路发生短路故障时,保险丝会自动切断电路,故障恢复以后再重新恢复导通。
电路图右侧的两条支路,第一条在VCC 与GND 之间连接了一个470uF
的C16 电容,由于电容是隔离直流的,所以这条支路上没有电流通过,此处电容起到的仅是缓冲电源电流避免上电瞬间电流过大的作用。第二条支路串联了一颗用作电源指示灯的发光二极管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 后级还并接了一个100uF
的C19 电容以及一个0.1uF
的C10 电容,电容C19 主要用于稳定后级电路电流电压,避免某个元件突然工作时造成的瞬时电流电压的下降;而容值较小的0.1uF
电容C10 ,主要用于滤除高频信号干扰,该电容的取值是结合干扰频率、电容参数得到的一个经验值,数字电路设计时,电源位置的高频去耦电容 可以直接选取0.1uF
容值。
注意:电路中大功率元件附近都可以放置一个较大容值的电容,从而起到稳定电流电压的作用。此外,所有IC 元件的VCC 与GND 之间,都会放置一个0.1uF
的高频去耦电容,特别是在
PCB Layout
的时候,该电容在位置上要尽可能靠近IC 元件。
数字电路中三极管的应用
三极管拥有饱和 、截止 、放大
3
种工作状态,其中放大状态 主要用于模拟电路,用法较为复杂。数字电路主要使用到三极管的开关特性,即饱和状态 与截止状态 。电路图中箭头朝内的是PNP 三极管,箭头朝外的是NPN 三极管。三极管拥有基极 (B ase)、发射极 (E mitter)、集电极 (C ollector)三个引脚,下图横向左侧的是基极 ,元件图中间的箭头一头连接基极另外一头连接的是发射极 ,最顶部的那个引脚是集电极 。
三极管使用的关键在于基极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 来作为单片机的电流缓冲器。
网络标号为19
的OE 是输出使能引脚,该引脚在电路图当中上标有上划线,表示低电平有效。而网络标号为1
的DIR 是方向引脚,当其为高电平1
时,右侧标号B 的引脚等于左侧标号A 引脚上的电压;当其为低电平0
时,左侧标号A 的引脚等于右侧标号B 引脚上的电压;
74HC138 三八译码器
74HC138 可以将 3 种输入状态转换为 8
种输出状态,该逻辑芯片左侧的E1 、E2 、E3 是使能引脚,A0 、A1 、A2 是输入引脚,Y0 至Y7 是输出引脚。当E1 、E2 、E3 引脚的电平状态分别为0
、0
、1
的时候,就可以通过A0 、A1 、A2 的输入状态来控制Y0 至Y7 的电平输出状态。
注意观察上图的真值表,任意的输出引脚都只有一位是低电平0
,而其它的七位都是高电平1
。
LED 闪烁实验
下面电路图当中,LED2 至LED9 八个 LED
发光二极管的总开关是三极管Q16 的基极LEDS6 ,即74HC138 三八译码器的Y6 引脚,该引脚输出低电平0
就可以导通Q16 三极管的集电极和发射极,由此可以推导出74HC138 的A2 、A1 、A0 的输入状态应该为110
。
另外,74HC138 三八译码器的E1 和E2 并联至单片机的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 ; 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 ; ADDR2 = 1 ; ADDR1 = 1 ; ADDR0 = 0 ; while (1 ) { LED = 0 ; for (i = 0 ; i < 30000 ; i++); LED = 1 ; for (i = 0 ; i < 30000 ; i++); } }
软件延时
延时 是单片机开发工作当中的常见操作,例如上一小节内容在
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 ; ADDR2 = 1 ; ADDR1 = 1 ; ADDR0 = 0 ; while (1 ) { LED = 0 ; delay(25000 ); LED = 1 ; 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); for (i = 0 ; i < 20000 ; i++); cnt++; if (cnt >= 8 ) { cnt = 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 ; ENLED = 0 ; ADDR3 = 1 ; ADDR2 = 1 ; ADDR1 = 1 ; ADDR0 = 0 ; while (1 ) { P0 = ~shift; for (i = 0 ; i < 20000 ; i++); if (dir == 0 ) { shift = shift << 1 ; if (shift == 0x80 ) { dir = 1 ; } } else { shift = shift >> 1 ; if (shift == 0x01 ) { dir = 0 ; } } } }
静态数码管 & 定时器
定时器概念
前面小节的内容当中,我们通过Keil
uVision 的【Debug】模式得到一个非精确的延时时间。而日常项目开发当中,通常会使用更为精确的STC89C52RC 单片机内置定时/计数器功能,通过配置单片机特殊功能寄存器,能够分别实现定时 和计数 的功能,相对而言定时器 功能更加常用。标准
51
架构单片机内部拥有T0 和T1 两个定时器,这里的T 就是单词Timer 的缩写。除此之外,STC89C52RC 还扩展了一个额外的定时器T2 。在开始着手进一步相关的试验之前,需要了解如下基本概念:
时钟周期 :时序的最小 时间单位,其值为\(\frac{1}{晶振时钟频率}\) ,当前晶振频率为11.0592MHz
,即开发板的时钟周期为1/11059200
秒。
机器周期 :完成一个基本汇编操作的执行时间,\(1个机器周期 =
12个时钟周期\) ,那么开发板的机器周期就是12/11059200
秒。
指令周期 :时序的最大 时间单位,指取出汇编指令并分析执行的时间,\(指令周期 =
机器周期\) ,换算成小数约等于0.0000011
秒。
STC89C52RC 单片机内部拥有 4
个定时值存储寄存器 (用于T0 的TH0/TL0 ,以及用于T1 的TH1/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 ,该寄存器可以进行位寻址 ,下表当中的IE0
、IT0
、IE1
、IT1
位与外部中断功能相关,本小节只需重点了解TF0
、TR0
、TF1
、TR1
四个位。
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
次,如此周而复始直至TH1
和TL1
累加至255
次,这里TL1
和TH1
组成的十进制整数是65535
。此时如果定时值再增加1
次就会发生溢出,TL1
和TH1
同时自动清零0
,与此同时定时器溢出标志位TF1
被自动置1
,标识定时器发生了溢出。
前面提到的定时器T0
、T1
的工作模式,则是由定时器模式寄存器
TMOD 进行控制,需要注意该寄存器不可位寻址 。
序号
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 :工作模式选择位 ,可以有如下选项:
模式 0 (0
0
):13
位定时器 ,由THn
的 8 位和TLn
的 5 位组成一个
13 位定时器,用于兼容 8048 单片机的 13 位定时器。
模式 1 (0
1
):16
位定时器 ,由THn
和TLn
组成一个 16
位定时器,计数范围位于0 ~ 65535
,溢出后如果不对THn
和TLn
重新赋值,则从0
开始计数。
模式 2 (1
0
):8
位自动重装模式 ,TLn
参予累加计数,计数范围位于0 ~ 255
,发生溢出时会将THn
的值自动重装至TLn
,主要用于产生串口波特率。
模式 3 (1
1
):定时器
T1 无效 ,即停止计数。定时器 T0 双 8
位定时器 ,TL0
作为一个 8 位定时器由 T0
的控制位进行控制,TH0
作为另一个 8 位定时器由 T1
的控制位进行控制。
注意 :单片机的寄存器可位寻址 表示程序能够直接对寄存器的每个位进行操作,而不可位寻址 表示程序只能对寄存器整个字节进行操作。
上面列表中的【模式
0】是出于兼容性而设计,日常开发基本不会使用到。【模式
3】的功能可以被【模式 2】取代,因此全文内容将会重点介绍【模式
1】与【模式 2】的使用。下面的流程图展示了定时器 T0 在【模式
1】下的配置过程,图中的SYSclk
表示的是系统时钟频率:
TR0
与其下方的或门电路 进行与 运算,因此如果要让定时器工作,TR0
必须置1
;与此同时,下方的或门 也必须为1
。
当GATE
位等于1
时,经过非门 变成0
。或门 电路结果想要为1
,则INT0
必须为1
定时器才会工作,如果INT0
等于0
则定时器不工作。
当GATE
位等于0
时,经过一个非门 变为1
,此时无论INT0
引脚处于何种电平状态,经过或门 电路以后都肯定为1
,定时器就会正常工作。
当开关处于C/T = 0
状态时候,一个机器周期就会累加一次,此时处于定时器功能。
当开关处于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
位,监听定时器溢出。
注意 :上面列表中TFn
、TRn
、TLn
、THn
里n
的取值可以是0
(代表定时器
0 )或1
(代表定时器 1 )。
如前所述,已知当前单片机晶振电路的机器周期为0.000001
秒,如果需要精确的定时1ms
毫秒,那么就需要经历\(\frac{0.001}{0.000001}=1000\) 个机器周期。已知16
位模式 定时器的溢出值为2^{16} = 65536
,如果赋予TLn
与THn
一个初始值,使其经过1000
个机器周期之后刚好达到65536
溢出(通过检查TFn
获得),这里只需要进行一个简单的减法运算即可得知该初始值为\(65536 - 1000 =
64536\) ,转换为十六进制数值就是0xFC18
,那么THn
的值为0xFC
,TLn
的值为0x18
。这里将之前使用while
非精确延时函数的例子,修改为使用片内定时/计数器的精确延时:
如前所述,当前电路使用的晶振是11.0592MHz
,因此时钟周期等于1 / 11059200
,机器周期等于12/11059200
。如果定时20ms
毫秒即0.02s
秒,假设需要经过X
个机器周期,那么根据X × (12 / 11059200) = 0.02
从而得到X = 18432
。由于
16
位定时器的溢出值是65536
(65535
需要加1
才会溢出 ),那么可以先为TH0
与TL0
赋一个初值,让其经过18432
个机器周期后达到65536
溢出,此时可以通过检测TF0
的值来判断溢出状态。这个TH0
与TL0
的初值应为65536 - 18432 = 47104 = 0xB800
,即TH0 = 0xB8
、TL0 = 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 ; ENLED = 0 ; ADDR3 = 1 ; ADDR2 = 1 ; ADDR1 = 1 ; ADDR0 = 0 ; TMOD = 0x01 ; TH0 = 0xB8 ; TL0 = 0x00 ; TR0 = 1 ; while (1 ) { if (TF0 == 1 ) { TF0 = 0 ; TH0 = 0xB8 ; TL0 = 0x00 ; cnt++; if (cnt >= 50 ) { cnt = 0 ; LED = ~LED; } } } }
静态数码管显示
数码管是单片机开发当中的常用显示器件,每个数码管都拥有a
、b
、c
、d
、e
、f
、g
、dp
八个
LED 段,下面是数码管的内部结构示意图:
由于并联电路电流之和等于总电流,数码管公共端的 2
个引脚可以起到分流的作用,从而降低单条支路所承受的电流。
LED
数码管分为共阳 和共阴 两种,共阴数码管 所有
LED 段的阴极连接在一起作为公共端,由阳极控制每个 LED
段的亮灭。共阳数码管 所有 LED
段的阳极连接在一起作为公共端,由阴极控制每个 LED 段的亮灭。
从下面电路图能够看出,电路当中使用了 6
个共阳数码管,每个数码管的公共端都连接至5V
正极,其段选 端(控制某段
LED
亮灭状态的引脚 )同样由P0 管脚经过74HC245 进行驱动,并由网络标号为U3 的74HC138 使用三极管来进行控制。
LED
数码管通常用于显示数值和字母,下面的表格总结了共阴/共阳极数码管所能够显示字符的编码表:
共阳 极数码管
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 ; 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 ; unsigned char sec = 0 ; ENLED = 0 ; ADDR0 = 0 ; ADDR1 = 0 ; ADDR2 = 0 ; ADDR3 = 1 ; TMOD = 0x01 ; TH0 = 0xB8 ; TL0 = 0x00 ; TR0 = 1 ; while (1 ) { if (TF0 == 1 ) { TF0 = 0 ; TH0 = 0xB8 ; TL0 = 0x00 ; cnt++; if (cnt >= 50 ) { cnt = 0 ; P0 = LedChar[sec]; sec++; 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 }; void main () { unsigned char i = 0 ; unsigned int cnt = 0 ; unsigned long sec = 0 ; ENLED = 0 ; ADDR3 = 1 ; TMOD = 0x01 ; TH0 = 0xFC ; TL0 = 0x67 ; TR0 = 1 ; while (1 ) { if (TF0 == 1 ) { TF0 = 0 ; TH0 = 0xFC ; TL0 = 0x67 ; cnt++; if (cnt >= 1000 ) { cnt = 0 ; 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
子句里的ADDRn
和P0
完成赋值之后,再执行一个ENLED=0
语句,最后完成break
操作。
除了残影问题之外,上面的数码管秒表程序还存在显示抖动 的问题,即每秒数值变化的时候,不参予变化的数码管会发生一次抖动。造成这个现象的原因在于程序定时到1s
秒时,会执行秒数加1
并转换为数码管显示字符 的操作。由于unsigned long sec
是一个
32
位的整型数据,当在switch()
语句进行除法运算时会消耗大量时间,导致每次定时到1s
秒时程序都需要多运行一段时间,从而造成某些数码管点亮时间较长,并最终影响到视觉效果。
接下来的内容里,将会引入STC89C52RC 单片机内置的中断机制,来解决上述的残影 与抖动 的问题。
中断机制
标准 8051
架构单片机涉及中断的寄存器主要有:中断使能寄存器 (可位寻址)、中断优先级寄存器 (可位寻址),这里首先来了解前者的相关相信:
IE
EA
-
ET2
ES
ET1
EX1
ET0
EX0
复位值
0
-
0
0
0
0
0
0
EA :总中断使能 ,EA=1
允许中断,EA=0
屏蔽所有中断。
ET2 :定时/计数器 T2
中断使能 ,ET2=1
允许中断,ET2=0
禁止中断。
ES :串行口中断使能 ,ES=1
允许串口中断,ES=0
禁止串口中断。
ET1 :定时/计数器 T1
中断使能 ,ET1=1
允许中断,ET1=0
禁止中断。
EX1 :外部中断 1
使能 ,EX1=1
允许中断,EX1=0
禁止中断。
ET0 :定时/计数器 T0
中断使能 ,ET0=1
允许中断,ET0=0
禁止中断。
EX0 :外部中断 0
使能 ,EX0=1
允许中断,EX0=0
禁止中断。
上面的表格当中,ET2
、ES
、ET1
、EX1
、ET0
、EX0
这六个位分别控制着四种中断方式的使能,而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 ; unsigned char flag = 0 ; void main () { unsigned long sec = 0 ; ENLED = 0 ; ADDR3 = 1 ; TMOD = 0x01 ; TH0 = 0xFC ; TL0 = 0x67 ; EA = 1 ; ET0 = 1 ; TR0 = 1 ; while (1 ) { if (flag == 1 ) { flag = 0 ; 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 ]; } } } void InterruptTimer0 () interrupt 1 { TH0 = 0xFC ; TL0 = 0x67 ; cnt++; if (cnt >= 1000 ) { cnt = 0 ; flag = 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
使能该中断,然后当其对应的中断标志位TF0
置1
时就会触发
T0
中断,此时单片机将会根据中断向量地址执行该中断函数,这里的中断向量是基于interrupt
关键字后的中断函数编号 得出,具体计算方法是中断函数号 × 8 + 3
。当中断条件满足之后,就会自动触发中断并调用相应的中断函数。
前面动态数码管显示的示例代码当中,就是通过中断机制来确保数码管动态扫描的间隔时间固定为1ms
毫秒,从而避免了由于程序代码逐行执行而导致的数码管显示抖动。
中断优先级与中断嵌套
STC89C52RC 单片机存在着默认优先级 和抢占式优先级 两种概念,这里先来介绍一下抢占式优先级。中断优先级寄存器
IP 可以进行位寻址,其每一位都代表着其对应中断 的抢占式优先级,其复位值都是0
,置1
后该位对应的优先级将高于其它位。
-
-
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
端连接至网络标号为U4 的74HC138 三八译码器,并最终与单片机的P1.0 ~ P1.3
引脚连接,以此作为
LED 点阵的阳极。
当 LED 点阵第 9
脚设置为高电平1
,第13
脚设置为低电平0
,就可以点亮顶部左侧的第
1 枚
LED。同理,通过对P0 整体赋值,并将74HC138 的Y1 引脚拉至低电平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 ; ADDR3 = 0 ; ADDR2 = 0 ; ADDR1 = 0 ; ADDR0 = 1 ; P0 = 0x00 ; 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 ; ADDR3 = 0 ; EA = 1 ; TMOD = 0x01 ; TH0 = 0xFC ; TL0 = 0x67 ; ET0 = 1 ; TR0 = 1 ; while (1 ); } 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 松开之后,这条通路被断开,从而没有电流通过,此时KeyIn1 和5V
是相等电位,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> sbit ADDR0 = P1 ^ 0 ; sbit ADDR1 = P1 ^ 1 ; sbit ADDR2 = P1 ^ 2 ; sbit ADDR3 = P1 ^ 3 ; sbit ENLED = P1 ^ 4 ; 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 () { ENLED = 0 ; ADDR0 = 0 ; ADDR1 = 1 ; ADDR2 = 1 ; ADDR3 = 1 ; P2 = 0xF7 ; while (1 ) { LED9 = KEY1; LED8 = KEY2; LED7 = KEY3; LED6 = KEY4; } }
上面代码让KeyOut1
输出低电平0
,KeyOut2 ~ 4
则保持高电平1
,相当于将矩阵按键第
1 行的Key1 ~ Key4 作为 4 个独立按键处理,然后将这 4
个按键的电平状态分别传递给LED6 ~ LED9
这 4 个
LED。当按键按下时,KEYn
和LEDn
的电平状态都为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 ; ENLED = 0 ; ADDR0 = 0 ; ADDR1 = 0 ; ADDR2 = 0 ; ADDR3 = 1 ; P2 = 0xF7 ; P0 = LedChar[cnt]; while (1 ) { if (KEY4 != backup) { if (backup == 0 ) { cnt++; if (cnt >= 10 ) { cnt = 0 ; } P0 = LedChar[cnt]; } backup = KEY4; } } }
上面的程序当中,按一次按键,就会产生【按下】与【弹起】两个状态,这里选择的是在【弹起】时对数码管进行加1
操作。程序实际运行时,会发现有些时候K4 按键按下一次,但是数码管显示的数字却累加了不止
1 次,这主要由于按键抖动所引起的,接下来的小节将会重点探讨这个问题。
上面代码所使用的bit
关键字是 8051
架构特有的一种数据类型,仅占用 1
个位的存储空间,且只能保存0
和1
两个值,通常用来表达按键的按下/弹起、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 }; void delay () { unsigned int i = 1000 ; while (i--); } void main () { bit keybuf = 1 ; bit backup = 1 ; unsigned char cnt = 0 ; ENLED = 0 ; ADDR3 = 1 ; ADDR2 = 0 ; ADDR1 = 0 ; ADDR0 = 0 ; P2 = 0xF7 ; P0 = LedChar[cnt]; while (1 ) { keybuf = KEY4; if (keybuf != backup) { delay(); if (keybuf == KEY4) { if (backup == 0 ) { cnt++; if (cnt >= 10 ) { cnt = 0 ; } P0 = LedChar[cnt]; } backup = keybuf; } } } }
由于延时函数delay()
极有可能会造成main()
函数执行流程的死锁,进而影响其它任务的调度,因此可以引入单片机的中断机制,每2ms
进入一次定时中断,扫描一次按键状态后缓存下来,连续扫描
8 次以后,比较这 8
次的按键(共计16ms
)状态是否一致,如果保持一致就可以确定按键已经处于稳定状态。
上面示意图当中,左边是起始的0
时间,每经过2ms
左移一次,每移动一次,就判断当前连续的
8
次按键状态是否全为0
或者全为1
,如果全为1
就表示按键弹起,全为0
则表示按键按下,如果出现0
与1
交错的情况,就认为按键发生了抖动。这种方式可以有效避免延时消抖函数占用单片机执行时间,影响其它功能的执行,这里继续对上面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 ; ENLED = 0 ; ADDR3 = 1 ; ADDR2 = 0 ; ADDR1 = 0 ; ADDR0 = 0 ; TMOD = 0x01 ; TH0 = 0xF8 ; TL0 = 0xCD ; ET0 = 1 ; TR0 = 1 ; P2 = 0xF7 ; P0 = LedChar[cnt]; while (1 ) { if (KeySta != backup) { if (backup == 0 ) { cnt++; if (cnt >= 10 ) { cnt = 0 ; } P0 = LedChar[cnt]; } backup = KeySta; } } } void InterruptTimer0 () interrupt 1 { static unsigned char keybuf = 0xFF ; TH0 = 0xF8 ; TL0 = 0xCD ; keybuf = (keybuf << 1 ) | KEY4; if (keybuf == 0x00 ) { KeySta = 0 ; } else if (keybuf == 0xFF ) { KeySta = 1 ; } else { } }
矩阵键盘
由于独立按键会占用大量的单片机 IO
资源,接下来将介绍更加常用的矩阵式按键设计。下面电路图当中,使用 8
个单片机 IO 管脚就可以控制由 16 个按键组成的矩阵按键(共分为 4
组每组各 4
个独立按键 ),通过矩阵按键的行线 和列线 就可以检测到当前按下的是哪个按键。
前面小节当中介绍过,按键按下状态通常会保持100ms
以上,如果在按键扫描中断服务函数当中,每次都让矩阵按键的一个KeyOut 输出低电平0
,其它三个引脚KeyOut2 、KeyOut3 、KeyOut4 输出高电平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 ; ENLED = 0 ; ADDR3 = 1 ; ADDR2 = 0 ; ADDR1 = 0 ; ADDR0 = 0 ; TMOD = 0x01 ; TH0 = 0xFC ; TL0 = 0x67 ; ET0 = 1 ; TR0 = 1 ; P0 = LedChar[0 ]; while (1 ) { 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]; } } } } } 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 ; 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; for (i = 0 ; i < 4 ; i++) { if ((keybuf[keyout][i] & 0x0F ) == 0x00 ) { KeySta[keyout][i] = 0 ; } else if ((keybuf[keyout][i] & 0x0F ) == 0x0F ) { KeySta[keyout][i] = 1 ; } } keyout++; keyout = keyout & 0x03 ; 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 后归零
这条语句,其实质是确保keyout 在0 ~ 3
范围以内变化,加至4
以后就自动归零。这里并未采用if()
语句进行判断,而是另辟蹊径使用了与运算符&
来完成。由于数值0
、1
、2
、3
正好占据
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 }, {0x34 , 0x35 , 0x36 , 0x25 }, {0x37 , 0x38 , 0x39 , 0x28 }, {0x30 , 0x1B , 0x0D , 0x27 } }; void KeyDriver () ; void main () { EA = 1 ; ENLED = 0 ; ADDR3 = 1 ; TMOD = 0x01 ; TH0 = 0xFC ; TL0 = 0x67 ; ET0 = 1 ; TR0 = 1 ; LedBuff[0 ] = LedChar[0 ]; while (1 ) { KeyDriver(); } } void ShowNumber (unsigned long num) { signed char i; unsigned char buf[6 ]; for (i = 0 ; i < 6 ; i++) { buf[i] = num % 10 ; num = num / 10 ; } for (i = 5 ; i >= 1 ; i--) { if (buf[i] == 0 ) LedBuff[i] = 0xFF ; else break ; } for (; i >= 0 ; i--) { LedBuff[i] = LedChar[buf[i]]; } } void KeyAction (unsigned char keycode) { static unsigned long result = 0 ; static unsigned long addend = 0 ; 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); } 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 } }; 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]; } } } } 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 } }; 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; for (i = 0 ; i < 4 ; i++) { if ((keybuf[keyout][i] & 0x0F ) == 0x00 ) { KeySta[keyout][i] = 0 ; } else if ((keybuf[keyout][i] & 0x0F ) == 0x0F ) { KeySta[keyout][i] = 1 ; } } keyout++; keyout = keyout & 0x03 ; 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 ; } } void InterruptTimer0 () interrupt 1 { TH0 = 0xFC ; TL0 = 0x67 ; 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
电源,橙 、黄 、粉 、蓝 色导线则分别对应A 、B 、C 、D 四相,其八拍模式绕组控制顺序表可以总结如下:
红 (公共端,接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
);如要让A 、B 相同时导通,那么就需要三极管Q2 、Q3 导通,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> unsigned char code BeatCode[8 ] = {0xE , 0xC , 0xD , 0x9 , 0xB , 0x3 , 0x7 , 0x6 };void delay () { unsigned int i = 200 ; while (i--); } void main () { unsigned char tmp; unsigned char index = 0 ; while (1 ) { tmp = P1; tmp = tmp & 0xF0 ; tmp = tmp | BeatCode[index]; P1 = tmp; index++; index = index & 0x07 ; delay(); } }
八拍模式下,步进电机转子转动一圈需要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> void delay () { unsigned int i = 200 ; while (i--); } void TurnMotor (unsigned long angle) { unsigned char tmp; unsigned char index = 0 ; unsigned long beats = 0 ; unsigned char code BeatCode[8 ] = {0xE , 0xC , 0xD , 0x9 , 0xB , 0x3 , 0x7 , 0x6 }; beats = (angle * 4096 ) / 360 ; while (beats--) { tmp = P1; tmp = tmp & 0xF0 ; tmp = tmp | BeatCode[index]; P1 = tmp; index++; index = index & 0x07 ; delay(); } P1 = P1 | 0x0F ; } void main () { TurnMotor(360 * 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 ; void StartMotor (unsigned long angle) { EA = 0 ; beats = (angle * 4076 ) / 360 ; EA = 1 ; } void main () { EA = 1 ; TMOD = 0x01 ; TH0 = 0xF8 ; TL0 = 0xCD ; ET0 = 1 ; TR0 = 1 ; StartMotor(360 * 2 + 180 ); while (1 ); } void InterruptTimer0 () interrupt 1 { unsigned char tmp; static unsigned char index = 0 ; unsigned char code BeatCode[8 ] = {0xE , 0xC , 0xD , 0x9 , 0xB , 0x3 , 0x7 , 0x6 }; TH0 = 0xF8 ; TL0 = 0xCD ; if (beats != 0 ) { tmp = P1; tmp = tmp & 0xF0 ; tmp = tmp | BeatCode[index]; P1 = tmp; index++; index = index & 0x07 ; beats--; } 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 }, {0x34 , 0x35 , 0x36 , 0x25 }, {0x37 , 0x38 , 0x39 , 0x28 }, {0x30 , 0x1B , 0x0D , 0x27 } }; 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 ; TH0 = 0xFC ; TL0 = 0x67 ; ET0 = 1 ; TR0 = 1 ; while (1 ) { KeyDriver(); } } void StartMotor (signed long angle) { EA = 0 ; beats = (angle * 4076 ) / 360 ; EA = 1 ; } void StopMotor () { EA = 0 ; beats = 0 ; EA = 1 ; } void KeyAction (unsigned char keycode) { static bit dirMotor = 0 ; 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 ; } else if (keycode == 0x25 ) { StartMotor(90 ); } else if (keycode == 0x27 ) { StartMotor(-90 ); } 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 }}; 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]; } } } } 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 }}; 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; for (i = 0 ; i < 4 ; i++) { if ((keybuf[keyout][i] & 0x0F ) == 0x00 ) { KeySta[keyout][i] = 0 ; } else if ((keybuf[keyout][i] & 0x0F ) == 0x0F ) { KeySta[keyout][i] = 1 ; } } keyout++; keyout = keyout & 0x03 ; 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 ; unsigned char code BeatCode[8 ] = {0xE , 0xC , 0xD , 0x9 , 0xB , 0x3 , 0x7 , 0x6 }; if (beats != 0 ) { if (beats > 0 ) { index++; index = index & 0x07 ; beats--; } else { index--; index = index & 0x07 ; beats++; } tmp = P1; tmp = tmp & 0xF0 ; tmp = tmp | BeatCode[index]; P1 = tmp; } else { P1 = P1 | 0x0F ; } } void InterruptTimer0 () interrupt 1 { static bit div = 0 ; TH0 = 0xFC ; TL0 = 0x67 ; KeyScan(); 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
间隔的效果;如果需要3ms
、4ms
...等更长的间隔,就可以考虑将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 ; unsigned char T0RL = 0 ; void OpenBuzz (unsigned int frequ) { unsigned int reload; reload = 65536 - (11059200 / 12 ) / (frequ * 2 ); T0RH = (unsigned char )(reload >> 8 ); T0RL = (unsigned char )reload; TH0 = 0xFF ; TL0 = 0xFE ; ET0 = 1 ; TR0 = 1 ; } void StopBuzz () { ET0 = 0 ; TR0 = 0 ; } void main () { unsigned int i; TMOD = 0x01 ; EA = 1 ; while (1 ) { OpenBuzz(4000 ); for (i = 0 ; i < 40000 ; i++); StopBuzz(); for (i = 0 ; i < 40000 ; i++); OpenBuzz(1000 ); for (i = 0 ; i < 40000 ; i++); StopBuzz(); for (i = 0 ; i < 40000 ; i++); } } void InterruptTimer0 () interrupt 1 { TH0 = T0RH; TL0 = T0RL; BUZZ = ~BUZZ; }
高精度数字秒表
定时器中断的精度补偿
单片机从正常运行状态进入中断,通常需要耗费几个机器周期时间,去完成一些场景保存方面的工作;进入中断以后,重新为定时值存储寄存器TH
、TL
赋值,同样需要花费几个机器周期时间;此外,硬件问题也会影响到单片机系统的时钟精度,比如晶振的精度会随着温度的变化而发生【温漂】现象,这样就造成了一些不可避免的误差,需要进行相应的补偿。
使用【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
分支的结构都是相同的,即首先来修改ADDR2
、ADDR1
、ADDR0
这三个74HC138 译码器的输入端,然后让索引变量i
自增1
,最后将缓冲区的数据写入P0
。仔细分析代码可以发现,case
后的选择条件常量与ADDRx
以及LedBuff
的下标相等,因此可以考虑直接将条件常量赋值给它们,而不必再使用冗长的switch()
语句。而对于索引变量i
,一共进行了五次自增和一次归零运算,因此可以使用自增运算符++
以及if
判断语句来实现。由于ADDR2
、ADDR1
、ADDR0
端通过跳线帽与单片机的P1.2
、P1.1
、P1.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 ; unsigned char T0RL = 0 ; void ConfigTimer0 (unsigned int ms) ;void StopwatchDisplay () ;void KeyDriver () ;void main () { EA = 1 ; ENLED = 0 ; ADDR3 = 1 ; P2 = 0xFE ; ConfigTimer0(2 ); while (1 ) { if (StopwatchRefresh) { StopwatchRefresh = 0 ; StopwatchDisplay(); } KeyDriver(); } } 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 ; TMOD |= 0x01 ; TH0 = T0RH; TL0 = T0RL; ET0 = 1 ; TR0 = 1 ; } void StopwatchDisplay () { signed char i; unsigned char buf[4 ]; LedBuff[0 ] = LedChar[DecimalPart % 10 ]; LedBuff[1 ] = LedChar[DecimalPart / 10 ]; buf[0 ] = IntegerPart % 10 ; buf[1 ] = (IntegerPart / 10 ) % 10 ; buf[2 ] = (IntegerPart / 100 ) % 10 ; buf[3 ] = (IntegerPart / 1000 ) % 10 ; 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 }; for (i = 0 ; i < 4 ; i++) { if (backup[i] != KeySta[i]) { if (backup[i] != 0 ) { 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++) { if (keybuf[i] == 0x00 ) { KeySta[i] = 0 ; } else if (keybuf[i] == 0xFF ) { KeySta[i] = 1 ; } } } void LedScan () { static unsigned char i = 0 ; P0 = 0xFF ; P1 = (P1 & 0xF8 ) | i; P0 = LedBuff[i]; if (i < 5 ) i++; else i = 0 ; } void StopwatchCount () { if (StopwatchRunning) { DecimalPart++; if (DecimalPart >= 100 ) { DecimalPart = 0 ; IntegerPart++; if (IntegerPart >= 10000 ) { IntegerPart = 0 ; } } StopwatchRefresh = 1 ; } } void InterruptTimer0 () interrupt 1 { static unsigned char tmr10ms = 0 ; TH0 = T0RH; TL0 = T0RL; LedScan(); KeyScan(); 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 ;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 ; TMOD |= 0x01 ; TH0 = HighRH; TL0 = HighRL; ET0 = 1 ; TR0 = 1 ; PWMOUT = 1 ; } void ClosePWM () { TR0 = 0 ; ET0 = 0 ; PWMOUT = 1 ; } void main () { unsigned int i; EA = 1 ; ENLED = 0 ; ADDR3 = 1 ; ADDR2 = 1 ; ADDR1 = 1 ; ADDR0 = 0 ; while (1 ) { ConfigPWM(100 , 10 ); for (i = 0 ; i < 40000 ; i++); ClosePWM(); ConfigPWM(100 , 40 ); for (i = 0 ; i < 40000 ; i++); ClosePWM(); ConfigPWM(100 , 90 ); for (i = 0 ; i < 40000 ; i++); ClosePWM(); for (i = 0 ; i < 40000 ; i++); } } 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 ; unsigned char HighRH = 0 ;unsigned char HighRL = 0 ;unsigned char LowRH = 0 ;unsigned char LowRL = 0 ;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 ; ADDR3 = 1 ; ADDR2 = 1 ; ADDR1 = 1 ; ADDR0 = 0 ; ConfigPWM(100 , 10 ); ConfigTimer1(50 ); while (1 ) ; } 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 ; TMOD |= 0x10 ; TH1 = T1RH; TL1 = T1RL; ET1 = 1 ; TR1 = 1 ; } 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 ; TMOD |= 0x01 ; TH0 = HighRH; TL0 = HighRL; ET0 = 1 ; TR0 = 1 ; 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; } void InterruptTimer0 () interrupt 1 { if (PWMOUT == 1 ) { TH0 = LowRH; TL0 = LowRL; PWMOUT = 0 ; } else { TH0 = HighRH; TL0 = HighRL; PWMOUT = 1 ; } } 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; AdjustDutyCycle(table[index]); 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
。
从上面列表可以看出,片内的data
是idata
一部分,而片外的pdata
是xdata
一部分。在
8051 C 语言当中,声明的变量默认是data
类型,RAM
的data
区域在汇编语言中使用直接寻址进行访问,执行速度较快。如果显式定义为idata
,不仅可以访问data
区域,还可以访问0x80H ~ 0xFF
范围的存储空间,此时汇编语言中使用的是通用寄存器间接寻址,速度相对data
慢一些。由于0x80H ~ 0xFF
区域通常用于中断与函数调用堆栈,大多数情况下,使用内部
RAM 时只用到data
区域就可以了。
外部 RAM 当中,使用pdata
可以将变量存储到外部 RAM
的0x00 ~ 0xFF
地址范围,这块地址与idata
一样都采用通用寄存器间接寻址,而如果定义为xdata
,则可以访问全部64KByte
存储空间,但这里需要额外使用
2
个字节的寄存器DPTRH
和DPTRL
来进行间接寻址,访问速度最慢。
片内和片外的区分来自早期 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 }, {0x34 , 0x35 , 0x36 , 0x25 }, {0x37 , 0x38 , 0x39 , 0x28 }, {0x30 , 0x1B , 0x0D , 0x27 } }; 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 ; bit flagStart = 0 ; unsigned char T0RH = 0 ; unsigned char T0RL = 0 ; unsigned int CountDown = 0 ; void ConfigTimer0 (unsigned int ms) ;void ShowNumber (unsigned long num) ;void KeyDriver () ;void main () { EA = 1 ; ENLED = 0 ; ADDR3 = 1 ; ConfigTimer0(1 ); ShowNumber(0 ); while (1 ) { KeyDriver(); if (flagStart && flag1s) { flag1s = 0 ; if (CountDown > 0 ) { CountDown--; ShowNumber(CountDown); if (CountDown == 0 ) { enBuzz = 1 ; LedBuff[6 ] = 0x00 ; } } } } } 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 ; TMOD |= 0x01 ; TH0 = T0RH; TL0 = T0RL; ET0 = 1 ; TR0 = 1 ; } void ShowNumber (unsigned long num) { signed char i; unsigned char buf[6 ]; for (i = 0 ; i < 6 ; i++) { buf[i] = num % 10 ; num = num / 10 ; } for (i = 5 ; i >= 1 ; i--) { if (buf[i] == 0 ) LedBuff[i] = 0xFF ; else break ; } for (; i >= 0 ; i--) { LedBuff[i] = LedChar[buf[i]]; } } void KeyAction (unsigned char keycode) { if (keycode == 0x26 ) { if (CountDown < 9999 ) { CountDown++; ShowNumber(CountDown); } } else if (keycode == 0x28 ) { if (CountDown > 1 ) { CountDown--; ShowNumber(CountDown); } } else if (keycode == 0x0D ) { flagStart = 1 ; } else if (keycode == 0x1B ) { enBuzz = 0 ; LedBuff[6 ] = 0xFF ; 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 }}; 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 ; } } else { TimeThr[i][j] = 1000 ; } } } } 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 }}; 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; for (i = 0 ; i < 4 ; i++) { if ((keybuf[keyout][i] & 0x0F ) == 0x00 ) { KeySta[keyout][i] = 0 ; KeyDownTime[keyout][i] += 4 ; } else if ((keybuf[keyout][i] & 0x0F ) == 0x0F ) { KeySta[keyout][i] = 1 ; KeyDownTime[keyout][i] = 0 ; } } keyout++; keyout &= 0x03 ; 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; P0 = LedBuff[i]; if (i < 6 ) i++; else i = 0 ; } void InterruptTimer0 () interrupt 1 { static unsigned int tmr1s = 0 ; TH0 = T0RH; TL0 = T0RL; if (enBuzz) BUZZ = ~BUZZ; else BUZZ = 1 ; LedScan(); KeyScan(); if (flagStart) { tmr1s++; if (tmr1s >= 1000 ) { tmr1s = 0 ; flag1s = 1 ; } } else { tmr1s = 0 ; } }
串行通信 UART 与 RS232
UART 串行通信(通用异步收发器 ,Universal
Asynchronous
Receiver/Transmitter)是微控制器设备之间的常用通信技术,STC89C52RC 的P3.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
与单片机系统进行通信时,只需要关注其中的RXD 、TXD 、GND 三个引脚即可,各针脚的功能具体定义如下:
载波检测 DCD;
接收数据 RXD;
发送数据 TXD;
数据终端准备好 DTR;
信号地线 GND;
数据准备好 DSR;
请求发送 RTS;
清除发送 CTS;
振铃提示 RI。
由于 RS-232
标准采用了负逻辑 (-3V ~ -15V
电压代表1
,+3 ~ +15V
电压代表0
,即低电平代表1
高电平代表0
),因此需要通过一块电平转换芯片MAX232 与单片机设备进行连接。虽然
RS-232 与 UART
两者都采用了相同的串行通信协议,但使用的电平标准并不相同,而 MAX232
这块芯片可以将计算机输出的 RS-232
电平转换为STC89C52RC 采用的0V ~ 5.5V
标准
UART 电平,从而确保两者的正常通信。
为了更加清晰的理解 UART
串行通信的原理,接下来将会利用STC89C52RC 的P3.0/RxD 和P3.1/TxD 引脚来模拟串行通信的过程,即通过
STC ISP
提供的【串口调试助手】发送一个数值,单片机接收到该数值以后加上1
再自动返回。注意波特率需要根据单片机程序的设定来选择,下面实验程序中一个数据位的电平持续时间为1/9600
秒,因而此处波特率就选择了9600
,具体设置见下面截图:
串口调试助手【发送/接收缓冲区】中的【文本模式】是将数据以 ASCII
编码进行显示,而【HEX 模式】则是将数据按照十六进制格式进行展示。
接下来的实验代码当中,我们将使用定时器
T0 的模式
2 来配置波特率,这里的TH0
和TL0
不再分别代表高低
8
位,仅仅TL0
进行计数,TL0
发生溢出之后,对TF0
置1
的同时,还会将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 ; bit RxdEnd = 0 ; bit TxdEnd = 0 ; unsigned char RxdBuf = 0 ; unsigned char TxdBuf = 0 ; void ConfigUART (unsigned int baud) { TMOD &= 0xF0 ; TMOD |= 0x02 ; TH0 = 256 - (11059200 / 12 ) / baud; } void StartTXD (unsigned char dat) { TxdBuf = dat; TL0 = TH0; ET0 = 1 ; TR0 = 1 ; PIN_TXD = 0 ; TxdEnd = 0 ; RxdOrTxd = 1 ; } void StartRXD () { TL0 = 256 - ((256 - TH0) >> 1 ); ET0 = 1 ; TR0 = 1 ; RxdEnd = 0 ; RxdOrTxd = 0 ; } void main () { EA = 1 ; ConfigUART(9600 ); while (1 ) { while (PIN_RXD); StartRXD(); while (!RxdEnd); StartTXD(RxdBuf + 1 ); while (!TxdEnd); } } void InterruptTimer0 () interrupt 1 { static unsigned char cnt = 0 ; if (RxdOrTxd) { cnt++; if (cnt <= 8 ) { PIN_TXD = TxdBuf & 0x01 ; TxdBuf >>= 1 ; } else if (cnt == 9 ) { PIN_TXD = 1 ; } else { cnt = 0 ; TR0 = 0 ; TxdEnd = 1 ; } } else { if (cnt == 0 ) { if (!PIN_RXD) { RxdBuf = 0 ; cnt++; } else { TR0 = 0 ; } } else if (cnt <= 8 ) { RxdBuf >>= 1 ; if (PIN_RXD) { RxdBuf |= 0x80 ; } cnt++; } else { cnt = 0 ; TR0 = 0 ; if (PIN_RXD) { RxdEnd = 1 ; } } } }
根据串行通信低电平触发 的原理,上面代码接收数据时,会首先使用while (PIN_RXD)
进行低电平检测,未检测到就说明此时没有数据,而一旦检测到低电平就会调用接收函数StartRXD()
。接收函数经历半个波特率周期以后(即信号较稳定的中间位置)会进行数据的读取。一旦读到起始低电平0
,就将当前状态设置为接收并打开定时器中断。经过半个周期进入中断服务函数以后,会再次对起始位进行判断,以确认其处于低电平状态,而非一个干扰信号。然后每经过1/9600
秒进入一次中断,将单片机引脚接收到的电平状态读取至RxdBuf
变量。接收完毕之后,将RxdBuf
变量的值加1
以后再通过TXD
引脚发回。同样需要先发送
1 位起始位,再发送 8 个数据位,最后再发送 1
位结束位。完成这一系列操作之后,代码重新循环至while (PIN_RXD)
,开始准备下一轮信号的收发。
通信技术按照传输方向可分为单工通信、半双工通信、全双工通信 3
种类型:
单工通信 只允许一方向另外一方传送信息,而另一方不能回传信息;
半双工通信 的数据可以在双方之间相互传播,但是同一时刻只能由其中一方发给另外一方;
全双工通信 数据的发送与接收能够同步进行。
上述代码通过单片机 IO
接口来模拟串口通信,由于程序需要不间断扫描单片机 IO
接口收到的数据,所以大量占用了单片机运行时间与资源。实际上,STC89C52RC 单片机已经内置了一个
UART
硬件模块,能够自动接收数据并在接收完成以后通知单片机,开发人员只需配置相关的串行接口控制寄存器
SCON 即可,该寄存器可以进行位寻址,其具体定义请见下表所示:
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
的溢出值;晶振频率为当前电路中使用的11059200
;12
是指一
个机器周期等于 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> void ConfigUART (unsigned int baud) { SCON = 0x50 ; TMOD &= 0x0F ; TMOD |= 0x20 ; TH1 = 256 - (11059200 / 12 / 32 ) / baud; TL1 = TH1; ET1 = 0 ; TR1 = 1 ; } void main () { ConfigUART(9600 ); while (1 ) { while (!RI); RI = 0 ; SBUF = SBUF + 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 ); while (1 ); } void ConfigUART (unsigned int baud) { SCON = 0x50 ; TMOD &= 0x0F ; TMOD |= 0x20 ; TH1 = 256 - (11059200 / 12 / 32 ) / baud; TL1 = TH1; ET1 = 0 ; ES = 1 ; TR1 = 1 ; } void InterruptUART () interrupt 4 { if (RI) { RI = 0 ; SBUF = SBUF + 1 ; } if (TI) { TI = 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 ; unsigned char T0RL = 0 ; unsigned char RxdByte = 0 ; void ConfigTimer0 (unsigned int ms) ;void ConfigUART (unsigned int baud) ;void main () { EA = 1 ; ENLED = 0 ; ADDR3 = 1 ; ConfigTimer0(1 ); ConfigUART(9600 ); while (1 ) { LedBuff[0 ] = LedChar[RxdByte & 0x0F ]; LedBuff[1 ] = LedChar[RxdByte >> 4 ]; } } 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 ; TMOD |= 0x01 ; TH0 = T0RH; TL0 = T0RL; ET0 = 1 ; TR0 = 1 ; } void ConfigUART (unsigned int baud) { SCON = 0x50 ; TMOD &= 0x0F ; TMOD |= 0x20 ; TH1 = 256 - (11059200 / 12 / 32 ) / baud; TL1 = TH1; ET1 = 0 ; ES = 1 ; TR1 = 1 ; } void LedScan () { static unsigned char i = 0 ; P0 = 0xFF ; P1 = (P1 & 0xF8 ) | i; P0 = LedBuff[i]; if (i < 6 ) i++; else i = 0 ; } void InterruptTimer0 () interrupt 1 { TH0 = T0RH; TL0 = T0RL; LedScan(); } 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 的不足,它采用了差分信号传输,可以抑制共模干扰提高通信可靠性,两根通信线通常使用A
、B
或者
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
引脚是用于通信的A 、B 两个引脚(在它们之间并接了一个阻值为1kΩ
的电阻R5 以提升抗干扰能力),第
1 和 4
引脚分别连接至单片机的RXD 与TXD 引脚,第
2 脚(低电平使能接收)和第 3
脚(高电平使能输出)是方向引脚,电路当中将这组引脚连接在一起,不发送数据时保持为低电平处于接收状态,发送数据时就将这组引脚上拉至高电平,发送完毕之后再下拉为低电平。
接下来的RS485 实验当中,将MAX485 的通信引脚连接至单片机的P3.0 和P3.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 #include <intrins.h> #include <reg52.h> sbit RS485_DIR = P1 ^ 7 ; bit flagFrame = 0 ; bit flagTxd = 0 ; unsigned char cntRxd = 0 ; unsigned char pdata bufRxd[64 ]; extern void UartAction (unsigned char *buf, unsigned char len) ;void ConfigUART (unsigned int baud) { RS485_DIR = 0 ; SCON = 0x50 ; TMOD &= 0x0F ; TMOD |= 0x20 ; TH1 = 256 - (11059200 / 12 / 32 ) / baud; TL1 = TH1; ET1 = 0 ; ES = 1 ; TR1 = 1 ; } void DelayX10us (unsigned char t) { do { _nop_(); _nop_(); _nop_(); _nop_(); _nop_(); _nop_(); _nop_(); _nop_(); } while (--t); } void UartWrite (unsigned char *buf, unsigned char len) { RS485_DIR = 1 ; while (len--) { flagTxd = 0 ; SBUF = *buf++; while (!flagTxd) ; } DelayX10us(5 ); RS485_DIR = 0 ; } 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; } 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 { if (idletmr < 30 ) { idletmr += ms; 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 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 #include <reg52.h> unsigned char T0RH = 0 ; unsigned char T0RL = 0 ; 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 ); ConfigUART(9600 ); while (1 ) { UartDriver(); } } void UartAction (unsigned char *buf, unsigned char len) { buf[len++] = '\r' ; buf[len++] = '\n' ; UartWrite(buf, len); } 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 ; TMOD |= 0x01 ; TH0 = T0RH; TL0 = T0RL; ET0 = 1 ; TR0 = 1 ; } void InterruptTimer0 () interrupt 1 { TH0 = T0RH; TL0 = T0RL; UartRxMonitor(1 ); }
程序当中MAX485 正常情况下为接收状态,只有在数据发送时才会置为发送状态,因此UartWrite()
函数开头将MAX485 电平转换芯片的方向引脚上拉为高电平,函数执行完成之前再下拉至低电平。
另外还有一个值得注意的细节,单片机发送和接收中断发生的时刻都处于停止位中间位置,即每当停止位传输至一半时,RI
或TI
就会置位并且进入中断服务函数,这种方式在接收的时候一切正常,但是在发送的时候,会紧接着向SBUF 寄存器写入
1 个字节数据;单片机 UART
会在上一个停止位发送完成之后,再开始新的字节发送,如果此时不继续发送下一个字节,而是处于已经发送完毕的状态,这种情况下停止发送并将MAX485 方向引脚拉低以使MAX485 重新处于接收状态的逻辑就存在问题,因为最后的停止位此时只被发送了一半。
正因为如此,上面代码里通过在UartWrite()
内部执行DelayX10us(5)
函数,手动增加了50us
延时(延时时间为波特率周期的一半 ),以便让剩下的一半停止位有足够的时间完成发送,从而有效避免了问题的发生。
Modbus 通信协议
Modbus 是由施耐德电气于 1979
年提出的应用层的串行通信协议,目前已经成为一种常用的工业领域通信标准。Modbus
属于主从架构协议 ,只有一个节点作为主设备(所有通信都由它发起),其它参与通信的节点为从设备(最大可支持
247
个从设备);每个从设备都拥有唯一地址,每条命令也都会包含需要执行该命令的目标设备地址,主设备发出命令之后所有从设备都会收到该命令,但只有指定地址的从设备能够执行并响应。此外,每条
Modbus 命令还包含有校验码,以确保命令的完整性。由于 Modbus
协议涉及内容较多,这里只重点介绍数据的帧结构 以及通信控制方式 。
Modbus
协议包含ASCII 、RTU 、TCP 等传输方式,其中
ASCII 模式每个字节仅由 7 个 bit 位组成,标准 8051
架构单片机无法实现并且实际应用较少;而 TCP 与 RTU 极为类似,只需去除 RTU
的两个字节校验码,然后在协议开头添加五个0
和一个6
,最后通过
TCP/IP
网络协议发出即可。因此,这里将会重点介绍RTU 模式,一条典型的
RTU 数据帧如下表所示:
间隔 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 02
和00 03
的寄存器内部的数据,而FA 33
就是
CRC 校验了。
由于当前使用的开发板不具备 Modbus
协议功能码定义的诸多功能,因此在程序中通过数组regGroup[5]
定义了
5 个模拟的寄存器,以及 1 个用于控制蜂鸣器的寄存器,Modbus
调试精灵可以通过下发不同指令改变STC89C52RC 上寄存器的数据或者调整蜂鸣器的开关状态,即将单片机作为从机解析串口接收到的数据并执行相应操作。
Modbus 协议中寄存器的地址和数值都为 16 位(即 2
个字节),这里默认高字节是0x00
低字节为数组regGroup[5]
的值,其中地址0x0000
到0x0004
对应的是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 unsigned int GetCRC16 (unsigned char *ptr, unsigned char len) { unsigned int index; unsigned char crch = 0xFF ; unsigned char crcl = 0xFF ; unsigned char code TabH[] = {0x00 , 0xC1 , 0x81 , ..., 0xC1 , 0x81 , 0x40 }; unsigned char code TabL[] = {0x00 , 0xC0 , 0xC1 , ..., 0x81 , 0x80 , 0x40 }; 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 #include <reg52.h> sbit BUZZ = P1 ^ 6 ; bit flagBuzzOn = 0 ; unsigned char T0RH = 0 ; unsigned char T0RL = 0 ; unsigned char regGroup[5 ]; 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 ); ConfigUART(9600 ); InitLcd1602(); while (1 ) { UartDriver(); } } 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; if (buf[0 ] != 0x01 ) { return ; } crc = GetCRC16(buf, len - 2 ); crch = crc >> 8 ; crcl = crc & 0xFF ; if ((buf[len - 2 ] != crch) || (buf[len - 1 ] != crcl)) { return ; } switch (buf[1 ]) { case 0x03 : if ((buf[2 ] == 0x00 ) && (buf[3 ] <= 0x05 )) { if (buf[3 ] <= 0x04 ) { i = buf[3 ]; cnt = buf[5 ]; buf[2 ] = cnt * 2 ; len = 3 ; while (cnt--) { buf[len++] = 0x00 ; buf[len++] = regGroup[i++]; } } else { buf[2 ] = 2 ; buf[3 ] = 0x00 ; buf[4 ] = flagBuzzOn; len = 5 ; } break ; } else { buf[1 ] = 0x83 ; buf[2 ] = 0x02 ; len = 3 ; break ; } case 0x06 : if ((buf[2 ] == 0x00 ) && (buf[3 ] <= 0x05 )) { if (buf[3 ] <= 0x04 ) { i = buf[3 ]; regGroup[i] = buf[5 ]; cnt = regGroup[i] >> 4 ; 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); } else { flagBuzzOn = (bit)buf[5 ]; } len -= 2 ; break ; } else { buf[1 ] = 0x86 ; buf[2 ] = 0x02 ; len = 3 ; break ; } default : buf[1 ] |= 0x80 ; buf[2 ] = 0x01 ; len = 3 ; break ; } crc = GetCRC16(buf, len); buf[len++] = crc >> 8 ; buf[len++] = crc & 0xFF ; UartWrite(buf, len); } 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 ; TMOD |= 0x01 ; TH0 = T0RH; TL0 = T0RL; ET0 = 1 ; TR0 = 1 ; } 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 GND 3
背光 LED 负极 0V
1602 液晶的第 4、5
管脚分别通过跳线插座ADDR0 、ADDR1 与单片机的P1.0 、P1.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 ;
读状态 (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 );
读数据 (RS = H, R/W = H, E = H
):不常用,这里不作详细介绍。
写指令 (RS = L,R/W = L,D0 ~ D7 = 指令码,E = 高脉冲
):这里E = 高脉冲
是指将引脚E 从低电平拉高,再从高电平拉低,从而形成高脉冲。
写数据 (RS = H,R/W = L,D0~D7 = 数据,E = 高脉冲
):与上面的写指令类似,需要将RS
改成H
,再把总线修改为数据即可。
如前所述,由于 1602
液晶使能引脚E 属于高电平有效,为了不影响其它外设的工作,需要在不使用液晶时在代码顶部声明一句LCD1602_E = 0
,上述程序未添加该语句,是由于电路在该引脚上增加了一个
15 KΩ
下拉电阻R72 ,从硬件上保证了该引脚上电后默认为低电平状态。
设置指令
1602
液晶使用的时候,需要通过一些特定指令来进行相应功能的配置和初始化:
显示模式设置指令 :0x38
,设置 1602
液晶的工作模式为16 x 2 显示,5 x 7 点阵,8
位数据接口 。
显示开/关以及光标设置指令 :这里涉及两条指令,第一条指令高
5 位是固定的0b00001
,低 3
位分别采用DCB
(D=1/0
打开关闭显示,C=1/0
显示或隐藏光标;B=1/0
光标闪烁或者不闪烁)从高到低进行表示。第二条指令高
6 位为固定的0b000001
,低 2
位分别用NS
(N = 1/0
表示读或写一个字符后指针自动加减1
,S = 1/0
表示写入一个字符以后整屏显示左右移动或者不移动)从高到低进行表示。
清屏指令 :0x01
表示显示清屏,包含数据指针以及所有的显示清零;0x02
则仅仅清零数据指针,显示则不进行清零。
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 ); } void LcdWriteCmd (unsigned char cmd) { LcdWaitReady(); LCD1602_RS = 0 ; LCD1602_RW = 0 ; LCD1602_DB = cmd; LCD1602_E = 1 ; LCD1602_E = 0 ; } void LcdWriteDat (unsigned char dat) { LcdWaitReady(); LCD1602_RS = 1 ; LCD1602_RW = 0 ; LCD1602_DB = dat; LCD1602_E = 1 ; LCD1602_E = 0 ; } void LcdSetCursor (unsigned char x, unsigned char y) { unsigned char addr; if (y == 0 ) addr = 0x00 + x; else addr = 0x40 + x; LcdWriteCmd(addr | 0x80 ); } void LcdShowStr (unsigned char x, unsigned char y, unsigned char *str) { LcdSetCursor(x, y); while (*str != '\0' ) { LcdWriteDat(*str++); } } void InitLcd1602 () { LcdWriteCmd(0x38 ); LcdWriteCmd(0x0C ); LcdWriteCmd(0x06 ); 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 引脚有可能是高电平也可能是低电平,注意下图中的表示方法。
当RS 与R/W 变化之后再经历Tsp1
长度的时间,使能引脚E 才会发生正跳变。而使能引脚E 拉高持续tD 长度时间以后,引脚DB 才会输出有效数据,读取完成之后,再将使能引脚E 下拉为低电平,一段时间以后RS 、R/W 、DB 就可以准备下一次读写了。
下面的写操作时序图与读操作的区别,在于写操作是由单片机来改变DB 的状态,因此需要在使能引脚E 变化之前进行操作。
上述两张时序图上存在着诸多时序参数标签,例如使能引脚E 的上升时间tR
下降时间时间tF
,从一个上升沿至下一个上升沿之间的长度周期tC
;以及使能引脚E 下降沿之后,R/W 和RS 变化的时间间隔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 纳秒
--
RS 和R/W 引脚使能之后至少保持30 ns
,使能引脚E 才会变为高电平,该条件依然满足。
tHD1
地址保持时间
10 纳秒
--
使能引脚E 下拉为低电平以后至少保持10ns
以上,RS 与R/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 ); } void LcdWriteCmd (unsigned char cmd) { LcdWaitReady(); LCD1602_RS = 0 ; LCD1602_RW = 0 ; LCD1602_DB = cmd; LCD1602_E = 1 ; LCD1602_E = 0 ; } void LcdWriteDat (unsigned char dat) { LcdWaitReady(); LCD1602_RS = 1 ; LCD1602_RW = 0 ; LCD1602_DB = dat; LCD1602_E = 1 ; LCD1602_E = 0 ; } void LcdSetCursor (unsigned char x, unsigned char y) { unsigned char addr; if (y == 0 ) addr = 0x00 + x; else addr = 0x40 + x; LcdWriteCmd(addr | 0x80 ); } void LcdShowStr (unsigned char x, unsigned char y, unsigned char *str, unsigned char len) { LcdSetCursor(x, y); while (len--) { LcdWriteDat(*str++); } } void InitLcd1602 () { LcdWriteCmd(0x38 ); LcdWriteCmd(0x0C ); LcdWriteCmd(0x06 ); 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 ; unsigned char T0RH = 0 ; unsigned char T0RL = 0 ; unsigned char code str1[] = "Hello Hank..." ; unsigned char code str2[] = "Hello Abel..." ; 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 ]; unsigned char pdata bufMove2[16 + sizeof (str2) + 16 ]; EA = 1 ; ConfigTimer0(10 ); 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 ) { 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 ; } } } } 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 ; TMOD |= 0x01 ; TH0 = T0RH; TL0 = T0RL; ET0 = 1 ; TR0 = 1 ; } 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 ); } void LcdWriteCmd (unsigned char cmd) { LcdWaitReady(); LCD1602_RS = 0 ; LCD1602_RW = 0 ; LCD1602_DB = cmd; LCD1602_E = 1 ; LCD1602_E = 0 ; } void LcdWriteDat (unsigned char dat) { LcdWaitReady(); LCD1602_RS = 1 ; LCD1602_RW = 0 ; LCD1602_DB = dat; LCD1602_E = 1 ; LCD1602_E = 0 ; } void LcdSetCursor (unsigned char x, unsigned char y) { unsigned char addr; if (y == 0 ) addr = 0x00 + x; else addr = 0x40 + x; LcdWriteCmd(addr | 0x80 ); } void LcdShowStr (unsigned char x, unsigned char y, unsigned char *str, unsigned char len) { LcdSetCursor(x, y); while (*str != '\0' ) { LcdWriteDat(*str++); } } void LcdAreaClear (unsigned char x, unsigned char y, unsigned char len) { LcdSetCursor(x, y); while (len--) { LcdWriteDat(' ' ); } } void LcdFullClear () { LcdWriteCmd(0x01 ); } void InitLcd1602 () { LcdWriteCmd(0x38 ); LcdWriteCmd(0x0C ); LcdWriteCmd(0x06 ); 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 }, {'4' , '5' , '6' , 0x25 }, {'7' , '8' , '9' , 0x28 }, {'0' , 0x1B , 0x0D , 0x27 } }; 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 }}; 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]; } } } } 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 }}; 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; for (i = 0 ; i < 4 ; i++) { if ((keybuf[keyout][i] & 0x0F ) == 0x00 ) { KeySta[keyout][i] = 0 ; } else if ((keybuf[keyout][i] & 0x0F ) == 0x0F ) { KeySta[keyout][i] = 1 ; } } keyout++; keyout &= 0x03 ; 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 ; signed long num2 = 0 ; signed long result = 0 ; unsigned char T0RH = 0 ; unsigned char T0RL = 0 ; 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 ); InitLcd1602(); LcdShowStr(15 , 1 , "0" ); while (1 ) { KeyDriver(); } } 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; while (i-- > 0 ) { *str++ = buf[i] + '0' ; } *str = '\0' ; return len; } void ShowOprt (unsigned char y, unsigned char type) { switch (type) { case 0 : LcdShowStr(0 , y, "+" ); break ; case 1 : LcdShowStr(0 , y, "-" ); break ; case 2 : LcdShowStr(0 , y, "*" ); break ; case 3 : LcdShowStr(0 , y, "/" ); break ; default : break ; } } void Reset () { num1 = 0 ; num2 = 0 ; step = 0 ; LcdFullClear(); } void NumKeyAction (unsigned char n) { unsigned char len; unsigned char str[12 ]; if (step > 1 ) { Reset(); } if (step == 0 ) { num1 = num1 * 10 + n; len = LongToString(str, num1); LcdShowStr(16 - len, 1 , str); } else { num2 = num2 * 10 + n; len = LongToString(str, num2); LcdShowStr(16 - len, 1 , str); } } void OprtKeyAction (unsigned char type) { unsigned char len; unsigned char str[12 ]; if (step == 0 ) { len = LongToString(str, num1); LcdAreaClear(0 , 0 , 16 - len); LcdShowStr(16 - len, 0 , str); ShowOprt(1 , type); LcdAreaClear(1 , 1 , 14 ); LcdShowStr(15 , 1 , "0" ); oprt = type; step = 1 ; } } void GetResult () { unsigned char len; unsigned char str[12 ]; 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 ; } len = LongToString(str, num2); ShowOprt(0 , oprt); LcdAreaClear(1 , 0 , 16 - 1 - len); LcdShowStr(16 - len, 0 , str); len = LongToString(str, result); LcdShowStr(0 , 1 , "=" ); LcdAreaClear(1 , 1 , 16 - 1 - len); LcdShowStr(16 - len, 1 , str); } } 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(); LcdShowStr(15 , 1 , "0" ); } } 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 ; TMOD |= 0x01 ; TH0 = T0RH; TL0 = T0RL; ET0 = 1 ; TR0 = 1 ; } void InterruptTimer0 () interrupt 1 { TH0 = T0RH; TL0 = T0RL; KeyScan(); }
1602 液晶与串口通信综合试验
实际工作当中,单片机经常需要通过串口与电脑上安装的上位机软件进行交互,从而执行不同的功能。本小节试验会通过电脑上的串口调试助手发送
3
条指令:buzz on
打开蜂鸣器、buzz off
关闭蜂鸣器、showstr
将命令后面的字符串显示到
1602 液晶,单片机接收到这些命令以后会将其原样返回。
发送一帧包含多个字节的数据时,这些数据会逐个字节连续不断进行发送,中间没有间隔或者间隔时间极短,当该帧数据发送完毕之后,将会间隔相对较长的一段时间不再发送数据,通信总线也就会空闲一段较长时间,因此可以在代码当中设置一个总线空闲定时器 ,该定时器在有数据传输时清零,而在总线空闲时累加,当累加至30 ms
毫秒时间之后,就认为一帧数据已经传输完毕,其它程序可以开始进行数据的处理。本次数据处理完毕后就恢复到初始状态,并开始准备下一轮接收。这里用于判定每帧结束的空闲时间并无一个固定值,开发时需要综合考虑如下两个原则:
该时间必须大于波特率周期 ,这是由于单片机接收中断产生于一个字节数据接收完毕之后,程序无法了解其具体接收过程,因而在至少一个波特率周期内,不能认为达到了每帧结束的空闲时间。
需要考虑到发送者的系统延时 ,发送者并不总是能保证数据严格无间隔发送,因此需要再附加几十毫秒处理时间,本实验选取的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 #include <reg52.h> bit flagFrame = 0 ; bit flagTxd = 0 ; unsigned char cntRxd = 0 ; unsigned char pdata bufRxd[64 ]; extern void UartAction (unsigned char *buf, unsigned char len) ;void ConfigUART (unsigned int baud) { SCON = 0x50 ; TMOD &= 0x0F ; TMOD |= 0x20 ; TH1 = 256 - (11059200 / 12 / 32 ) / baud; TL1 = TH1; ET1 = 0 ; ES = 1 ; TR1 = 1 ; } void UartWrite (unsigned char *buf, unsigned char len) { while (len--) { flagTxd = 0 ; SBUF = *buf++; while (!flagTxd); } } 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; } 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 { if (idletmr < 30 ) { idletmr += ms; 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 ; } }
上面的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 #include <reg52.h> sbit BUZZ = P1 ^ 6 ; bit flagBuzzOn = 0 ; unsigned char T0RH = 0 ; unsigned char T0RL = 0 ; 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 ); ConfigUART(9600 ); InitLcd1602(); while (1 ) { UartDriver(); } } bit CmpMemory (unsigned char *ptr1, unsigned char *ptr2, unsigned char len) { while (len--) { if (*ptr1++ != *ptr2++) { return 0 ; } } return 1 ; } 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 " ; 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 ; } } } 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 ]; 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); } 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 ; TMOD |= 0x01 ; TH0 = T0RH; TL0 = T0RL; ET0 = 1 ; TR0 = 1 ; } 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 #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 ); } void LcdWriteCmd (unsigned char cmd) { LcdWaitReady(); LCD1602_RS = 0 ; LCD1602_RW = 0 ; LCD1602_DB = cmd; LCD1602_E = 1 ; LCD1602_E = 0 ; } void LcdWriteDat (unsigned char dat) { LcdWaitReady(); LCD1602_RS = 1 ; LCD1602_RW = 0 ; LCD1602_DB = dat; LCD1602_E = 1 ; LCD1602_E = 0 ; } void LcdSetCursor (unsigned char x, unsigned char y) { unsigned char addr; if (y == 0 ) addr = 0x00 + x; else addr = 0x40 + x; LcdWriteCmd(addr | 0x80 ); } void LcdShowStr (unsigned char x, unsigned char y, unsigned char *str, unsigned char len) { LcdSetCursor(x, y); while (*str != '\0' ) { LcdWriteDat(*str++); } } void LcdAreaClear (unsigned char x, unsigned char y, unsigned char len) { LcdSetCursor(x, y); while (len--) { LcdWriteDat(' ' ); } } void InitLcd1602 () { LcdWriteCmd(0x38 ); LcdWriteCmd(0x0C ); LcdWriteCmd(0x06 ); 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
总线属于开漏引脚并联结构,因此外部需要添加上拉电阻R63 和R64 ,从而在总线上构成一个【线与】关系,即所有接入总线的器件保持高电平,总线才是高电平,而任何一个器件输出低电平,则总线就会保持低电平。换而言之,总线上的任何器件都可以拉低电平作为主设备。
通常情况下,I²C
总线上都是由单片机微控制器作为主机,总线上挂载的诸多设备都拥有各自的唯一地址,信息传输时将会通过这个地址识别属于各自设备的信息,当前的实验电路上已经挂载了24C02 和PCF8591 两个
I²C 总线设备。与 UART 串行通信类似,I²C
总线时序也分为起始信号、数据传输信号、停止信号,如下图所示:
UART 传输的每个字节都有 1 个起始位、8 个数据位、1 个停止位,而 I²C
分为起始信号、数据传输部分、停止信号,其中数据传输部分可以一次传输多个字节,而每个字节数据的最后也会跟着一个应答位(用ACK 表示)。此外,虽然
UART
也使用了TXD 和RXD 两条通信线路,但是实际上每次通信只需要通过一条线来完成,采用两条线只是为了区分接收 和发送 ;而
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
;如果不存在,则没有设备回应ACK ,SDA 将会持续保持高电平状态1
。
接下来编写一个程序,通过 I²C 访问一下实验电路上的 EEPROM
地址,另外再访问一个不存在的地址设备,观察是否能够返回ACK 。实验电路中采用的EEPROM 型号为24C02 ,其
7 位地址中高 4 位固定为0b1010
,而低 3
位地址取决于具体电路设计,由芯片上A2 、A1 、A0 三个引脚的电平状态确定,下面是24C02 的电路图:
上图中,A2 、A1 、A0 全都连接到了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 #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 ); } void LcdWriteCmd (unsigned char cmd) { LcdWaitReady(); LCD1602_RS = 0 ; LCD1602_RW = 0 ; LCD1602_DB = cmd; LCD1602_E = 1 ; LCD1602_E = 0 ; } void LcdWriteDat (unsigned char dat) { LcdWaitReady(); LCD1602_RS = 1 ; LCD1602_RW = 0 ; LCD1602_DB = dat; LCD1602_E = 1 ; LCD1602_E = 0 ; } void LcdSetCursor (unsigned char x, unsigned char y) { unsigned char addr; if (y == 0 ) addr = 0x00 + x; else addr = 0x40 + x; LcdWriteCmd(addr | 0x80 ); } void LcdShowStr (unsigned char x, unsigned char y, unsigned char *str) { LcdSetCursor(x, y); while (*str != '\0' ) { LcdWriteDat(*str++); } } void InitLcd1602 () { LcdWriteCmd(0x38 ); LcdWriteCmd(0x0C ); LcdWriteCmd(0x06 ); 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 #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(); ack = I2CAddressing(0x50 ); str[0 ] = '5' ; str[1 ] = '0' ; str[2 ] = ':' ; str[3 ] = (unsigned char )ack + '0' ; str[4 ] = '\0' ; LcdShowStr(0 , 0 , str); ack = I2CAddressing(0x62 ); str[0 ] = '6' ; str[1 ] = '2' ; str[2 ] = ':' ; str[3 ] = (unsigned char )ack + '0' ; str[4 ] = '\0' ; LcdShowStr(8 , 0 , str); while (1 ); } void I2CStart () { I2C_SDA = 1 ; I2C_SCL = 1 ; I2CDelay(); I2C_SDA = 0 ; I2CDelay(); I2C_SCL = 0 ; } void I2CStop () { I2C_SCL = 0 ; I2C_SDA = 0 ; I2CDelay(); I2C_SCL = 1 ; I2CDelay(); I2C_SDA = 1 ; I2CDelay(); } bit I2CWrite (unsigned char dat) { bit ack; unsigned char mask; for (mask = 0x80 ; mask != 0 ; mask >>= 1 ) { if ((mask & dat) == 0 ) I2C_SDA = 0 ; else I2C_SDA = 1 ; I2CDelay(); I2C_SCL = 1 ; I2CDelay(); I2C_SCL = 0 ; } I2C_SDA = 1 ; I2CDelay(); I2C_SCL = 1 ; ack = I2C_SDA; I2CDelay(); I2C_SCL = 0 ; return ack; } 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
通信从高位开始发送数据,所以先从最高位开始,0x80
和dat
进行按位与运算,从而得知dat
的第
7 位是0
还是1
,然后右移 1
位变为0x40
和dat
的按位与运算,进而得知第 6
位是0
还是1
,如此循环直至第0
位结束,最终通过if
语句将dat
的
8 位数据依次发送出去。
EEPROM 24C02
保存在单片机RAM 内的数据掉电后就会丢失,而保存在单片机FLASH 内的数据又不能用于记录变化的数值,而实际开发场景当中又经常需要记录下一些需要经常进行修改的数据,并且在掉电之后还不会丢失。本小节将要介绍的
EEPROM 就是能够满足这一特性的存储器,当前实验电路中选用的是 ATMEL
公司型号为24C02 的EEPPROM ,其容量大小为256 Byte
,并且基于
I²C 通信协议。
EEPROM 单字节读写时序
EEPROM 写数据流程
I²C 起始信号和设备地址,并且读写方向上选择为【写】操作。
发送数据的存储地址,24C02 拥有 256
字节存储空间,地址从0x00 ~ 0xFF
,需要将数据存储在哪个位置就填写哪个地址。
发送待存储数据的第 1、2、...个字节,注意 EEPROM
每个字节都会回应一个应答位0
,用于通知写数据成功,如果未返回应答位,则说明写入不成功。
写数据过程中,每成功写入 1 个字节,EEPROM
存储空间地址就会自增1
,当加至0xFF
以后再进行写入,地址就溢出为0x00
。
EEPROM 读数据流程
I²C
起始信号和设备地址,读写方向上依然选择【写】操作,之所以这里仍然选择写,是为了通知
EEPROM 当前需要读取数据位于哪个地址。
发送待读取数据的地址,注意这里是地址而非存储在 EEPROM
中的数据本身。
重新发送 I²C 起始信号与器件地址,并且读写方向位选择【读】操作。
读取 EEPROM 响应的数据,1
个字节读取完成之后,如果需要继续读取下个字节,应答位ACK 发送0
;如果不需要再读取,则发送1
通知
EEPROM 不再进行读取数据。
前 3 个步操作当中,每个字节本质上都处于【写】操作,因此 EEPROM
每个字节的应答位都是0
。
与上面的写数据流程类似,每读取一个字节,地址就会自动加1
,如果需要继续读取,就向
EEPROM
发送一个ACK 低电平0
,并且再继续给SCL 提供完整的时序,此时
EEPROM 会继续往外发送数据。如果无需再进行读取,就直接向 EEPROM
发送一个NAK 高电平1
,通知 EEPROM
不再读取数据,下面再梳理一下此处的逻辑顺序:
假如STC89C52RC 单片机是主设备,24C02 是从设备;
无论读还是写,SCL 始终由主设备单片机控制;
写的时候应答信号由从设备24C02 提供,表示从设备是否正确接收了数据;
读的时候应答信号由主设备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 #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 ; I2C_SCL = 1 ; I2CDelay(); I2C_SDA = 0 ; I2CDelay(); I2C_SCL = 0 ; } void I2CStop () { I2C_SCL = 0 ; I2C_SDA = 0 ; I2CDelay(); I2C_SCL = 1 ; I2CDelay(); I2C_SDA = 1 ; I2CDelay(); } bit I2CWrite (unsigned char dat) { bit ack; unsigned char mask; for (mask = 0x80 ; mask != 0 ; mask >>= 1 ) { if ((mask & dat) == 0 ) I2C_SDA = 0 ; else I2C_SDA = 1 ; I2CDelay(); I2C_SCL = 1 ; I2CDelay(); I2C_SCL = 0 ; } I2C_SDA = 1 ; I2CDelay(); I2C_SCL = 1 ; ack = I2C_SDA; I2CDelay(); I2C_SCL = 0 ; return (~ack); } unsigned char I2CReadNAK () { unsigned char mask; unsigned char dat; I2C_SDA = 1 ; for (mask = 0x80 ; mask != 0 ; mask >>= 1 ) { I2CDelay(); I2C_SCL = 1 ; if (I2C_SDA == 0 ) dat &= ~mask; else dat |= mask; I2CDelay(); I2C_SCL = 0 ; } I2C_SDA = 1 ; I2CDelay(); I2C_SCL = 1 ; I2CDelay(); I2C_SCL = 0 ; return dat; } unsigned char I2CReadACK () { unsigned char mask; unsigned char dat; I2C_SDA = 1 ; for (mask = 0x80 ; mask != 0 ; mask >>= 1 ) { I2CDelay(); I2C_SCL = 1 ; if (I2C_SDA == 0 ) dat &= ~mask; else dat |= mask; I2CDelay(); I2C_SCL = 0 ; } I2C_SDA = 0 ; I2CDelay(); I2C_SCL = 1 ; I2CDelay(); I2C_SCL = 0 ; 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 #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 ); str[0 ] = (dat / 100 ) + '0' ; str[1 ] = (dat / 10 % 10 ) + '0' ; str[2 ] = (dat % 10 ) + '0' ; str[3 ] = '\0' ; LcdShowStr(0 , 0 , str); dat++; E2WriteByte(0x02 , dat); while (1 ); } unsigned char E2ReadByte (unsigned char addr) { unsigned char dat; I2CStart(); I2CWrite(0x50 << 1 ); I2CWrite(addr); I2CStart(); I2CWrite((0x50 << 1 ) | 0x01 ); dat = I2CReadNAK(); I2CStop(); return dat; } void E2WriteByte (unsigned char addr, unsigned char dat) { I2CStart(); I2CWrite(0x50 << 1 ); I2CWrite(addr); I2CWrite(dat); 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 #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); for (i = 0 ; i < sizeof (buf); i++) { buf[i] = buf[i] + 1 + i; } E2Write(buf, 0x90 , sizeof (buf)); while (1 ); } void MemToStr (unsigned char *str, unsigned char *src, unsigned char len) { unsigned char tmp; while (len--) { tmp = *src >> 4 ; if (tmp <= 9 ) *str++ = tmp + '0' ; else *str++ = tmp - 10 + 'A' ; tmp = *src & 0x0F ; if (tmp <= 9 ) *str++ = tmp + '0' ; else *str++ = tmp - 10 + 'A' ; *str++ = ' ' ; src++; } *str = '\0' ; } 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 ); while (len > 1 ) { *buf++ = I2CReadACK(); len--; } *buf = I2CReadNAK(); I2CStop(); } 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++); 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 #include <reg52.h> 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) { do { I2CStart(); if (I2CWrite(0x50 << 1 )) { break ; } I2CStop(); } while (1 ); I2CWrite(addr); I2CStart(); I2CWrite((0x50 << 1 ) | 0x01 ); while (len > 1 ) { *buf++ = I2CReadACK(); len--; } *buf = I2CReadNAK(); I2CStop(); } 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++; 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 #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)); MemToStr(str, buf, sizeof (buf)); LcdShowStr(0 , 0 , str); for (i = 0 ; i < sizeof (buf); i++) { buf[i] = buf[i] + 1 + i; } E2Write(buf, 0x8E , sizeof (buf)); while (1 ); } void MemToStr (unsigned char *str, unsigned char *src, unsigned char len) { unsigned char tmp; while (len--) { tmp = *src >> 4 ; if (tmp <= 9 ) *str++ = tmp + '0' ; else *str++ = tmp - 10 + 'A' ; tmp = *src & 0x0F ; if (tmp <= 9 ) *str++ = tmp + '0' ; else *str++ = tmp - 10 + 'A' ; *str++ = ' ' ; 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 #include <reg52.h> unsigned char T0RH = 0 ; unsigned char T0RL = 0 ; 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 ); ConfigUART(9600 ); InitLcd1602(); InitShowStr(); while (1 ) { UartDriver(); } } void InitShowStr () { unsigned char str[17 ]; str[16 ] = '\0' ; E2Read(str, 0x20 , 16 ); LcdShowStr(0 , 0 , str); E2Read(str, 0x40 , 16 ); LcdShowStr(0 , 1 , str); } bit CmpMemory (unsigned char *ptr1, unsigned char *ptr2, unsigned char len) { while (len--) { if (*ptr1++ != *ptr2++) { return 0 ; } } return 1 ; } void TrimString16 (unsigned char *out, unsigned char *in) { unsigned char i = 0 ; while (*in != '\0' ) { *out++ = *in++; i++; if (i >= 16 ) { break ; } } for (; i < 16 ; i++) { *out++ = ' ' ; } *out = '\0' ; } void UartAction (unsigned char *buf, unsigned char len) { unsigned char i; unsigned char str[17 ]; unsigned char code cmd0[] = "showstr1 " ; unsigned char code cmd1[] = "showstr2 " ; 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 ]); LcdShowStr(0 , 0 , str); E2Write(str, 0x20 , sizeof (str)); break ; case 1 : buf[len] = '\0' ; TrimString16(str, buf + cmdLen[1 ]); LcdShowStr(0 , 1 , str); E2Write(str, 0x40 , sizeof (str)); break ; default : UartWrite("bad command.\r\n" , sizeof ("bad command.\r\n" ) - 1 ); return ; } buf[len++] = '\r' ; buf[len++] = '\n' ; UartWrite(buf, len); } 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 ; TMOD |= 0x01 ; TH0 = T0RH; TL0 = T0RL; ET0 = 1 ; TR0 = 1 ; } 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
个时钟沿的时候,MOSI 与MISO 均会发生变化,同时在SCK 第
2
个时钟沿时数据保持稳定,此刻适合进行数据采样,即在该时钟周期的后沿锁存读取数据,即CPHA = 1
。最隐蔽的是SSEL 片选,该引脚通常用于确定需要进行通信的主从设备。剩余的三种模式的时序图如下所示,为了简化将MOSI 和MISO 合并在了一起。
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
主电源引脚,当Vcc2
比Vcc1
高出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 ,用于防止电源对芯片造成冲击,其它的R9 、R26 、R32 都是上拉电阻。
上面实时时钟的核心是一枚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
位取值的十六进制命令0x80
、0x81
...,详细如下图表格所示:
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
,不存在0b1010
、0b1011
、0b1100
、0b1101
、0b1110
、0b1111
六个数字。如果
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
操作时序的比较如下图:
上图当中,两种时序的CE 和SSEL 使能控制相反,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 #include <reg52.h> sbit DS1302_CE = P1 ^ 7 ; sbit DS1302_CK = P3 ^ 5 ; sbit DS1302_IO = P3 ^ 4 ; bit flag200ms = 0 ; unsigned char T0RH = 0 ; unsigned char T0RL = 0 ; 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 ; unsigned char time[8 ]; unsigned char str[12 ]; EA = 1 ; ConfigTimer0(1 ); InitDS1302(); InitLcd1602(); while (1 ) { if (flag200ms) { flag200ms = 0 ; for (i = 0 ; i < 7 ; i++) { time[i] = DS1302SingleRead(i); } if (psec != time[0 ]) { str[0 ] = '2' ; str[1 ] = '0' ; str[2 ] = (time[6 ] >> 4 ) + '0' ; str[3 ] = (time[6 ] & 0x0F ) + '0' ; str[4 ] = '-' ; str[5 ] = (time[4 ] >> 4 ) + '0' ; str[6 ] = (time[4 ] & 0x0F ) + '0' ; str[7 ] = '-' ; str[8 ] = (time[3 ] >> 4 ) + '0' ; str[9 ] = (time[3 ] & 0x0F ) + '0' ; str[10 ] = '\0' ; LcdShowStr(0 , 0 , str); str[0 ] = (time[5 ] & 0x0F ) + '0' ; str[1 ] = '\0' ; LcdShowStr(11 , 0 , "week" ); LcdShowStr(15 , 0 , str); str[0 ] = (time[2 ] >> 4 ) + '0' ; str[1 ] = (time[2 ] & 0x0F ) + '0' ; str[2 ] = ':' ; str[3 ] = (time[1 ] >> 4 ) + '0' ; str[4 ] = (time[1 ] & 0x0F ) + '0' ; str[5 ] = ':' ; str[6 ] = (time[0 ] >> 4 ) + '0' ; str[7 ] = (time[0 ] & 0x0F ) + '0' ; str[8 ] = '\0' ; LcdShowStr(4 , 1 , str); psec = time[0 ]; } } } } 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 ; } DS1302_IO = 1 ; } unsigned char DS1302ByteRead () { unsigned char mask; unsigned char dat = 0 ; for (mask = 0x01 ; mask != 0 ; mask <<= 1 ) { if (DS1302_IO != 0 ) { dat |= mask; } DS1302_CK = 1 ; DS1302_CK = 0 ; } return dat; } void DS1302SingleWrite (unsigned char reg, unsigned char dat) { DS1302_CE = 1 ; DS1302ByteWrite((reg << 1 ) | 0x80 ); DS1302ByteWrite(dat); DS1302_CE = 0 ; } unsigned char DS1302SingleRead (unsigned char reg) { unsigned char dat; DS1302_CE = 1 ; DS1302ByteWrite((reg << 1 ) | 0x81 ); dat = DS1302ByteRead(); DS1302_CE = 0 ; return dat; } void InitDS1302 () { unsigned char i; unsigned char code InitTime[] = {0x00 , 0x30 , 0x12 , 0x08 , 0x10 , 0x02 , 0x13 }; DS1302_CE = 0 ; DS1302_CK = 0 ; i = DS1302SingleRead(0 ); if ((i & 0x80 ) != 0 ) { DS1302SingleWrite(7 , 0x00 ); for (i = 0 ; i < 7 ; i++) { DS1302SingleWrite(i, InitTime[i]); } } } 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 ; TMOD |= 0x01 ; TH0 = T0RH; TL0 = T0RL; ET0 = 1 ; TR0 = 1 ; } void InterruptTimer0 () interrupt 1 { static unsigned char tmr200ms = 0 ; TH0 = T0RH; TL0 = T0RL; tmr200ms++; 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 #include <reg52.h> sbit DS1302_CE = P1 ^ 7 ; sbit DS1302_CK = P3 ^ 5 ; sbit DS1302_IO = P3 ^ 4 ; bit flag200ms = 0 ; unsigned char T0RH = 0 ; unsigned char T0RL = 0 ; 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 ; unsigned char time[8 ]; unsigned char str[12 ]; EA = 1 ; ConfigTimer0(1 ); InitDS1302(); InitLcd1602(); while (1 ) { if (flag200ms) { flag200ms = 0 ; DS1302BurstRead(time); if (psec != time[0 ]) { str[0 ] = '2' ; str[1 ] = '0' ; str[2 ] = (time[6 ] >> 4 ) + '0' ; str[3 ] = (time[6 ] & 0x0F ) + '0' ; str[4 ] = '-' ; str[5 ] = (time[4 ] >> 4 ) + '0' ; str[6 ] = (time[4 ] & 0x0F ) + '0' ; str[7 ] = '-' ; str[8 ] = (time[3 ] >> 4 ) + '0' ; str[9 ] = (time[3 ] & 0x0F ) + '0' ; str[10 ] = '\0' ; LcdShowStr(0 , 0 , str); str[0 ] = (time[5 ] & 0x0F ) + '0' ; str[1 ] = '\0' ; LcdShowStr(11 , 0 , "week" ); LcdShowStr(15 , 0 , str); str[0 ] = (time[2 ] >> 4 ) + '0' ; str[1 ] = (time[2 ] & 0x0F ) + '0' ; str[2 ] = ':' ; str[3 ] = (time[1 ] >> 4 ) + '0' ; str[4 ] = (time[1 ] & 0x0F ) + '0' ; str[5 ] = ':' ; str[6 ] = (time[0 ] >> 4 ) + '0' ; str[7 ] = (time[0 ] & 0x0F ) + '0' ; str[8 ] = '\0' ; LcdShowStr(4 , 1 , str); psec = time[0 ]; } } } } 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 ; } DS1302_IO = 1 ; } unsigned char DS1302ByteRead () { unsigned char mask; unsigned char dat = 0 ; for (mask = 0x01 ; mask != 0 ; mask <<= 1 ) { if (DS1302_IO != 0 ) { dat |= mask; } DS1302_CK = 1 ; DS1302_CK = 0 ; } return dat; } void DS1302SingleWrite (unsigned char reg, unsigned char dat) { DS1302_CE = 1 ; DS1302ByteWrite((reg << 1 ) | 0x80 ); DS1302ByteWrite(dat); DS1302_CE = 0 ; } unsigned char DS1302SingleRead (unsigned char reg) { unsigned char dat; DS1302_CE = 1 ; DS1302ByteWrite((reg << 1 ) | 0x81 ); dat = DS1302ByteRead(); DS1302_CE = 0 ; return dat; } void DS1302BurstWrite (unsigned char *dat) { unsigned char i; DS1302_CE = 1 ; DS1302ByteWrite(0xBE ); for (i = 0 ; i < 8 ; i++) { DS1302ByteWrite(dat[i]); } DS1302_CE = 0 ; } void DS1302BurstRead (unsigned char *dat) { unsigned char i; DS1302_CE = 1 ; DS1302ByteWrite(0xBF ); for (i = 0 ; i < 8 ; i++) { dat[i] = DS1302ByteRead(); } DS1302_CE = 0 ; } void InitDS1302 () { unsigned char dat; unsigned char code InitTime[] = {0x00 , 0x30 , 0x12 , 0x08 , 0x10 , 0x02 , 0x13 , 0x00 }; DS1302_CE = 0 ; DS1302_CK = 0 ; dat = DS1302SingleRead(0 ); if ((dat & 0x80 ) != 0 ) { DS1302SingleWrite(7 , 0x00 ); DS1302BurstWrite(InitTime); } } 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 ; TMOD |= 0x01 ; TH0 = T0RH; TL0 = T0RL; ET0 = 1 ; TR0 = 1 ; } void InterruptTimer0 () interrupt 1 { static unsigned char tmr200ms = 0 ; TH0 = T0RH; TL0 = T0RL; tmr200ms++; if (tmr200ms >= 200 ) { tmr200ms = 0 ; flag200ms = 1 ; } }
注意: 无论读写,只要使用了突发模式,就必须一次性读写
8 个寄存器,即对时钟寄存器完全读取或者完全写入。
电子时钟实例
本实验将会实现一个加入了按键调时的简易万年历,通过上、下、左、右、回车、ESC
六个按键调整时间(忽略了星期数),下面列出了代码实现当中的一些要点:
将 DS1302
底层操作封装为一个DS1302.c
文件,对上层应用提供基本实时时间操作函数。
定义结构体类型sTime
来封装日期时间的各个元素,又使用该结构体定义了一个时间缓冲区变量bufTime
来暂存从
DS1302 读取的时间以及时间的设定值;
定义一个setIndex
变量,用于判断当前是否处于时间设置状态以及设置的是时间的哪一位,该值为0
表示正常运行,1 ~ 12
分别代表可以修改日期时间的十二个位;
由于本实验需要进行时间调整,所以需要使用到 1602
液晶的光标功能,改变哪一位数字就在液晶屏对应的位置上闪烁光标,因此Lcd1602.c
文件需要添加了
2 个光标控制函数;
时间的显示、增减、设置移位等功能都放在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 #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; }; 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 ; } DS1302_IO = 1 ; } unsigned char DS1302ByteRead () { unsigned char mask; unsigned char dat = 0 ; for (mask = 0x01 ; mask != 0 ; mask <<= 1 ) { if (DS1302_IO != 0 ) { dat |= mask; } DS1302_CK = 1 ; DS1302_CK = 0 ; } return dat; } void DS1302SingleWrite (unsigned char reg, unsigned char dat) { DS1302_CE = 1 ; DS1302ByteWrite((reg << 1 ) | 0x80 ); DS1302ByteWrite(dat); DS1302_CE = 0 ; } unsigned char DS1302SingleRead (unsigned char reg) { unsigned char dat; DS1302_CE = 1 ; DS1302ByteWrite((reg << 1 ) | 0x81 ); dat = DS1302ByteRead(); DS1302_CE = 0 ; return dat; } void DS1302BurstWrite (unsigned char *dat) { unsigned char i; DS1302_CE = 1 ; DS1302ByteWrite(0xBE ); for (i = 0 ; i < 8 ; i++) { DS1302ByteWrite(dat[i]); } DS1302_CE = 0 ; } void DS1302BurstRead (unsigned char *dat) { unsigned char i; DS1302_CE = 1 ; DS1302ByteWrite(0xBF ); for (i = 0 ; i < 8 ; i++) { dat[i] = DS1302ByteRead(); } DS1302_CE = 0 ; } 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 ]; } 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); } void InitDS1302 () { unsigned char dat; unsigned char code InitTime[] = {0x2013 , 0x10 , 0x08 , 0x12 , 0x30 , 0x00 , 0x02 }; DS1302_CE = 0 ; DS1302_CK = 0 ; dat = DS1302SingleRead(0 ); if ((dat & 0x80 ) != 0 ) { DS1302SingleWrite(7 , 0x00 ); DS1302BurstWrite(InitTime); } }
上面的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 #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 ); } void LcdWriteCmd (unsigned char cmd) { LcdWaitReady(); LCD1602_RS = 0 ; LCD1602_RW = 0 ; LCD1602_DB = cmd; LCD1602_E = 1 ; LCD1602_E = 0 ; } void LcdWriteDat (unsigned char dat) { LcdWaitReady(); LCD1602_RS = 1 ; LCD1602_RW = 0 ; LCD1602_DB = dat; LCD1602_E = 1 ; LCD1602_E = 0 ; } void LcdSetCursor (unsigned char x, unsigned char y) { unsigned char addr; if (y == 0 ) addr = 0x00 + x; else addr = 0x40 + x; LcdWriteCmd(addr | 0x80 ); } void LcdShowStr (unsigned char x, unsigned char y, unsigned char *str) { LcdSetCursor(x, y); while (*str != '\0' ) { LcdWriteDat(*str++); } } void LcdOpenCursor () { LcdWriteCmd(0x0F ); } void LcdCloseCursor () { LcdWriteCmd(0x0C ); } void InitLcd1602 () { LcdWriteCmd(0x38 ); LcdWriteCmd(0x0C ); LcdWriteCmd(0x06 ); 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 #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 ; struct sTime bufTime ; unsigned char setIndex = 0 ; unsigned char T0RH = 0 ; unsigned char T0RL = 0 ; 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 ; EA = 1 ; ConfigTimer0(1 ); InitDS1302(); InitLcd1602(); LcdShowStr(3 , 0 , "20 - - " ); LcdShowStr(4 , 1 , " : : " ); while (1 ) { KeyDriver(); if (flag200ms && (setIndex == 0 )) { flag200ms = 0 ; GetRealTime(&bufTime); if (psec != bufTime.sec) { RefreshTimeShow(); psec = bufTime.sec; } } } } 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 ; } } unsigned char IncBcdHigh (unsigned char bcd) { if ((bcd & 0xF0 ) < 0x90 ) bcd += 0x10 ; else bcd &= 0x0F ; return bcd; } unsigned char IncBcdLow (unsigned char bcd) { if ((bcd & 0x0F ) < 0x09 ) bcd += 0x01 ; else bcd &= 0xF0 ; return bcd; } unsigned char DecBcdHigh (unsigned char bcd) { if ((bcd & 0xF0 ) > 0x00 ) bcd -= 0x10 ; else bcd |= 0x90 ; return 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 ; LeftShiftTimeSet(); LcdOpenCursor(); } void ExitTimeSet (bit save) { setIndex = 0 ; if (save) { SetRealTime(&bufTime); } LcdCloseCursor(); } 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 ); } } else if (keycode == 0x1B ) { ExitTimeSet(0 ); } } 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 ; TMOD |= 0x01 ; TH0 = T0RH; TL0 = T0RL; ET0 = 1 ; TR0 = 1 ; } void InterruptTimer0 () interrupt 1 { static unsigned char tmr200ms = 0 ; TH0 = T0RH; TL0 = T0RL; KeyScan(); tmr200ms++; if (tmr200ms >= 200 ) { tmr200ms = 0 ; flag200ms = 1 ; } }
main.c
文件负责所有应用层功能的实现,虽然代码较长显得较为繁琐,但是实现原理方面并不存在难度。
红外通信与 NEC 协议
红外线是波长介于微波与可见光之间的电磁波(波长760 纳米 ~ 1 毫米
),物体温度只要高于绝对零度-273
就会由于分子、原子的无序运动而不停的辐射红外线。单片机开发电路当中,红外发射管 类似于发光二极管,红外线的发射强度会随着电流的增大而增强;红外接收管 是可以接收红外线波长的光敏二极管,内部是一个具有红外敏感特征的
PN
节;没有红外线时接收管不导通,有红外线时接收管导通,并在一定范围内电流随着红外线强度的增加而增大。
红外发射/接收管也用于避障、循迹小车等单片机实验,有兴趣的同学可以参考下面的原理图来搭建电路:
上图中的发射控制 端与接收检测 端都连接到单片机
IO
引脚:发射部分 在发射控制端输出高电平时,三极管Q1 不导通,红外发射管L1 不会发射红外信号;当发射控制输出低电平时,三极管Q1 导通L1 开始发射红外信号。
接收部分 通过电位器R4 向LM393 的
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/2
或1/3
。
生产环境下,接收端还需要对信号进行检测、放大、滤波、解调等处理,然后输出基带信号。但是实验电路采用的一体化红外接收头HS0038B 已经集成了这些电路,因此电路得到了大幅的简化:
红外线接收头内置放大器的增益较大容易引起干扰,因此在接收头供电引脚上添加了10uF
的滤波电容,并在
HS0038B
供电引脚VDD 与5V
电源之间串联了一个100 Ω
的电阻R69 ,进一步降低干扰。
前面两幅电路图,分别代表了红外线通信实验中的发送 与接收 方,当
HS0038B
检测到38 KHz
红外信号时,OUT 引脚就会输出低电平,如果未检测到,OUT 引脚就会输出高电平。由于OUT 引脚输出的是解调之后的基带信号,所以将其连接至单片机
IO 引脚,即可以获取红外线发送过来的信号。
NEC 红外通信协议
红外通信的硬件成本明显低于其它无线通信方式,因此在家电遥控器当中始终占有一席之地。红外遥控器的基带通信协议有几十种,常用的就有
ITT、NEC、Sharp、Philips RC-5、Sony SIRC 协议等等,其中应用较多的是 NEC
协议,因此实验电路配套的遥控器直接采用了 NEC 协议。
NEC
协议的数据格式包括引导码 、用户码 、用户码 (或用户码反码)、按键键码 、键码反码 、停止位 ,其中数据编码有4 Byte
共32 bit
;其中,第
1 个字节是用户码,第 2
个字节即可能是用户码也可能是用户码反码(具体由生产商决定),第 3
个字节是当前按键的键码,第 4 个字节是键码的反码,主要用于数据纠错。NEC
协议每一位数据本身都需要编码,然后再进行载波调制:
引导码:9ms
载波加上4.5ms
空闲;
比特值0
:560us
载波加上560us
空闲;
比特值1
:560us
载波加上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 两个外部中断,实验电路将红外接收引脚连接到STC89C52RC 的P3.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 #include <reg52.h> sbit IR_INPUT = P3 ^ 3 ; bit irflag = 0 ; unsigned char ircode[4 ]; void InitInfrared () { IR_INPUT = 1 ; TMOD &= 0x0F ; TMOD |= 0x10 ; TR1 = 0 ; ET1 = 0 ; IT1 = 1 ; EX1 = 1 ; } unsigned int GetHighTime () { TH1 = 0 ; TL1 = 0 ; TR1 = 1 ; while (IR_INPUT) { if (TH1 >= 0x40 ) { break ; } } TR1 = 0 ; return (TH1 * 256 + TL1); } unsigned int GetLowTime () { TH1 = 0 ; TL1 = 0 ; TR1 = 1 ; while (!IR_INPUT) { if (TH1 >= 0x40 ) { break ; } } TR1 = 0 ; return (TH1 * 256 + TL1); } void EXINT1_ISR () interrupt 2 { unsigned char i, j; unsigned char byt; unsigned int time; time = GetLowTime(); if ((time < 7833 ) || (time > 8755 )) { IE1 = 0 ; return ; } time = GetHighTime(); if ((time < 3686 ) || (time > 4608 )) { IE1 = 0 ; return ; } for (i = 0 ; i < 4 ; i++) { for (j = 0 ; j < 8 ; j++) { time = GetLowTime(); if ((time < 313 ) || (time > 718 )) { IE1 = 0 ; return ; } time = GetHighTime(); if ((time > 313 ) && (time < 718 )) { byt >>= 1 ; } else if ((time > 1345 ) && (time < 1751 )) { byt >>= 1 ; byt |= 0x80 ; } else { IE1 = 0 ; return ; } } ircode[i] = byt; } irflag = 1 ; IE1 = 0 ; }
上面代码在获取高低电平时间的时候,使用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 #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 }; unsigned char T0RH = 0 ; unsigned char T0RL = 0 ; extern bit irflag;extern unsigned char ircode[4 ];extern void InitInfrared () ;void ConfigTimer0 (unsigned int ms) ;void main () { EA = 1 ; ENLED = 0 ; ADDR3 = 1 ; InitInfrared(); ConfigTimer0(1 ); PT0 = 1 ; 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 ]; } } } 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 ; TMOD |= 0x01 ; TH0 = T0RH; TL0 = T0RL; ET0 = 1 ; TR0 = 1 ; } void LedScan () { static unsigned char i = 0 ; P0 = 0xFF ; P1 = (P1 & 0xF8 ) | i; P0 = LedBuff[i]; if (i < sizeof (LedBuff) - 1 ) i++; else i = 0 ; } 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
位都是2ˣ
的指数形式,用于表示温度,字母S 用于标识符号位。DS18B20
的温度测量范围在-55℃ ~ +125℃
之间,因此温度数据有正负之分,寄存器每个数值如同卡尺刻度一样分布,请参考下面的温度数据关系表:
二进制数值最低位增减1
就代表温度增减了0.0625℃
,0℃
时对应的十六进制为0x0000
,125℃
时对应的十六进制为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 ; DelayX10us(50 ); IO_18B20 = 1 ; DelayX10us(6 ); 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
可以选用12 、11 、10 、9 位四种格式来呈现温度,位数越高精度就越高,9
位模式下最低位变化 1 个数字,虽然温度只会变化
0.5℃
,但是与此同时其转换速度相对更快,具体请参考下表所示:
上述表格中的寄存器R1 和R0 决定了其转换位数,出厂时默认值为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 ; for (mask = 0x01 ; mask != 0 ; mask <<= 1 ) { IO_18B20 = 0 ; _nop_(); _nop_(); if ((mask & dat) == 0 ) IO_18B20 = 0 ; else IO_18B20 = 1 ; DelayX10us(6 ); 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 ; for (mask = 0x01 ; mask != 0 ; mask <<= 1 ) { IO_18B20 = 0 ; _nop_(); _nop_(); IO_18B20 = 1 ; _nop_(); _nop_(); if (!IO_18B20) dat &= ~mask; else dat |= mask; DelayX10us(6 ); } 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 #include <intrins.h> #include <reg52.h> sbit IO_18B20 = P3 ^ 2 ; void DelayX10us (unsigned char t) { do { _nop_(); _nop_(); _nop_(); _nop_(); _nop_(); _nop_(); _nop_(); _nop_(); } while (--t); } bit Get18B20Ack () { bit ack; EA = 0 ; IO_18B20 = 0 ; DelayX10us(50 ); IO_18B20 = 1 ; DelayX10us(6 ); ack = IO_18B20; while (!IO_18B20); EA = 1 ; return ack; } void Write18B20 (unsigned char dat) { unsigned char mask; EA = 0 ; for (mask = 0x01 ; mask != 0 ; mask <<= 1 ) { IO_18B20 = 0 ; _nop_(); _nop_(); if ((mask & dat) == 0 ) IO_18B20 = 0 ; else IO_18B20 = 1 ; DelayX10us(6 ); IO_18B20 = 1 ; } EA = 1 ; } unsigned char Read18B20 () { unsigned char dat; unsigned char mask; EA = 0 ; for (mask = 0x01 ; mask != 0 ; mask <<= 1 ) { IO_18B20 = 0 ; _nop_(); _nop_(); IO_18B20 = 1 ; _nop_(); _nop_(); if (!IO_18B20) dat &= ~mask; else dat |= mask; DelayX10us(6 ); } EA = 1 ; return dat; } bit Start18B20 () { bit ack; ack = Get18B20Ack(); if (ack == 0 ) { Write18B20(0xCC ); Write18B20(0x44 ); } return ~ack; } bit Get18B20Temp (int *temp) { bit ack; unsigned char LSB, MSB; ack = Get18B20Ack(); if (ack == 0 ) { Write18B20(0xCC ); Write18B20(0xBE ); LSB = Read18B20(); MSB = Read18B20(); *temp = ((int )MSB << 8 ) + LSB; } return ~ack; }
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 #include <reg52.h> bit flag1s = 0 ; unsigned char T0RH = 0 ; unsigned char T0RL = 0 ; 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 ); Start18B20(); InitLcd1602(); while (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' ; while (len < 6 ) { str[len++] = ' ' ; } str[len] = '\0' ; LcdShowStr(0 , 0 , str); } else { LcdShowStr(0 , 0 , "error!" ); } Start18B20(); } } } 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; while (i-- > 0 ) { *str++ = buf[i] + '0' ; } *str = '\0' ; return len; } 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 ; TMOD |= 0x01 ; TH0 = T0RH;TL0 = T0RL; ET0 = 1 ; TR0 = 1 ; } void InterruptTimer0 () interrupt 1 { static unsigned char tmr1s = 0 ; TH0 = T0RH; TL0 = T0RL; tmr1s++; 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
分为积分型 、逐次逼近型 、并行/串行比较型 、Σ-Δ
型 等多种类型,但通常都会涉及到如下技术指标:
ADC 的位数 :ADC 有n
位就表示该 ADC
拥有2ⁿ
个刻度,例如 8 位 ADC
可以输出2⁸ = 256
个数字量(数据刻度)。
基准源 :也称为基准电压,要想准确测量输入的 ADC
信号,基准源必须要准确,例如基准源应为5.10V
,但实际只提供4.5V
,那么就会造成较大的偏差。
分辨率 :是数字量变化 1
个最小刻度时,模拟信号的变化量,计算方法为满刻度量程 / (2n-1)
,例如5.10V
的电压使用
8 位 ADC 测量,相当于采用 256 个刻度将5.10V
平均分为 255
等份,那么分辨率就等于5.10V / (256 - 1) = 0.02V
。
转换速率 :指 ADC
每秒能够进行采样转换的最大次数,其单位为sa/s
、s/s
或sps
(即
Samples Per Second 缩写),其与 ADC 完成 1
次模数转换所需的时间互为倒数。积分型 ADC 转换时间为毫秒级的,属于低速
ADC;逐次逼近型 ADC 转换时间为微秒级,属于中速 ADC;并行/串行 ADC
转换时间可达到纳秒级,属于高速 ADC。
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
类似,三个地址引脚A0 、A1 、A2 分别用于硬件地址编程,可以允许
8 个设备连接至 I²C
总线而无需额外的片选电路,器件的地址、控制、数据都通过 I²C
进行传输,下图是 PCF8591 的电路原理图:
上图当中,第 1、2、3、4 号引脚为四路模拟输入,第 5、6、7 号引脚为 I²C
设备地址,第 8 脚是接地GND ,第 9、10 脚是 I²C
总线的SDA 与SCL ,第 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 实际测量到的就是电位器分压值,AIN1 和AIN2 测的是
GND
的值,AIN3 测的是+5V
的值。需要注意:AIN3 虽然测量的是+5V
,但是对于
AD 而言,只要输入信号超过Vref
基准源 ,得到的始终都是最大值255
,换而言之,实际上
AD
无法测量超过其Vref 的电压信号。此外,所有输入信号的电压值都不能超过+5V
的VCC ,否则可能会导致
ADC 芯片损坏。
由于 PCF8591 采用了 I²C 总线与单片机通信,单片机发送 3
个字节就可以完成对 PCF8591 的初始化:第 1 个字节与 EEPROM
通信时类似,是器件的地址字节,其中 7 位代表地址(高 4
位固定为0b1001
,低 3
位A2 ,A1 ,A0 全部接GND 取值为0b000
)
1 位代表读写方向,具体如下图所示:
单片机通过 I²C 总线发送到PCF8591 的第 2
个字节用于控制 PCF8591 的各种功能,其中第 0 和第 1
位是通道选择位,00
、01
、10
、11
分别代表从
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 功能可以不发送该字节。
接下来着手编写一个实验程序,将模拟输入通道AIN0 、AIN1 、AIN3 测得的电压值显示到
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