显示器件应用分析精粹:从芯片架构到驱动程序设计
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

第2章 51单片机驱动开发

虽然搭建纯硬件电路可以驱动LED,但使用单片机会带来更大的灵活性,更何况,使用纯硬件电路驱动比较复杂的显示器件是不太现实的,所以提前介绍一些单片机开发知识很有必要。

我们以决定选择51单片机,是因为虽然它看起来好像有些过时,但本书并不是专门介绍单片机开发的,所以主要考虑平台应用的广泛性以及学习资料的丰富性,这对于读者学习与理解都非常有利,而51单片机在这两方面绝对独占鳌头。另外,使用C51语言编写的程序非常接近硬件底层,而且我们只会用到顺序编程(尽管下一章会讨论中断编程,但主要还是为了介绍这种思路,实际编写显示驱动时并没有使用到)。也就是说,即便在阅读本书前从未使用过51单片机,也不妨碍理解显示器件的驱动原理,只要对各类显示器件的应用知识感兴趣,后续内容一定是非常有价值的。

首先宏观了解一下单片机。现阶段的我们不需要想得很复杂,只需要认为它是一个包含CPU(Central Processing Unit,中央处理器)、ROM(Read-Only Memory,只读存储器)、RAM(Random Access Memory,随机存取存储器)三个部分的芯片(虽然细节上复杂得多,但我们无需理会,等入门后可进一步自行了解),如图2.1所示。

图2.1 单片机的基本单元

其中,CPU具有一定运算功能(例如加、减、乘、除等);ROM用来存储数据(一般是指令程序,所以也称为程序存储器),并且CPU只能从ROM中读取而不能写入(修改)数据;RAM也是存储数据的单元,通常被称为数据存储器,CPU不仅可以从中读取数据,还可以把数据写入进去。整个单片机的运行过程即:CPU按顺序从ROM中提取并执行相应的指令,而RAM用来存储执行指令期间可能需要的一些临时数据。

那RAM到底存储什么样的临时数据呢?例如,需要使用计算器得到5+(6×8)的计算结果,首先得计算出临时值6×8=48,然后再计算出5+48=53。在这个过程中,可能会将临时值先记在纸上(或心里),而RAM的作用就相当于那张纸,它允许将一些临时的数据记录在上面以供后续使用。这么一个简单的运算就存在一个临时数据,可想而知,运算越复杂则需要的临时数据也会越多,也就需要更大空间的RAM。

单片机的运行过程与人的活动非常相似,人就相当于CPU,而事先做好的计划就相当于ROM中存储的指令,在计划执行的过程中临时需要处理的事情就相当于RAM存储的数据,如图2.2所示。

图2.2 人的活动

现在的任务是:使用51单片机控制LED实现闪烁功能。Proteus软件平台搭建的硬件电路如图2.3所示。

图2.3 硬件电路

我们选择Proteus软件平台中引脚相对较少的一款51单片机,其型号为AT89C1051(本书涉及的51单片机是指Intel公司推出的8位MCS-51单片机,通常称为“51内核”。Atmel公司获得51内核授权后,在其基础上设计了一系列应用单片机,AT89C1051就是其中一款应用单片机的型号,如果后续提到的是“51单片机”,表示所述内容对于所有基于“51内核”的应用单片机都是适用的),它可供控制的输入或输出(Input/Output,IO)引脚有15个,51单片机将它们分为P1、P3两组,每一组包含8个引脚,而每个引脚通过“组名+点+数字”的方式加以区分。例如,P1.7、P3.2(AT89C1051没有P3.6引脚)。

XTAL1与XTAL2两个引脚外围连接了一颗12MHz的晶体与两个30pF的匹配电容,它们与单片机内部电路配合可产生12MHz的时钟(如果想接外部时钟源,可以从XTAL1接入,XTAL2悬空即可),CPU就会以时钟作为基准,依次从ROM中读取指令并执行。另外,我们还增加了一个RC复位电路,这样可以确保单片机上电后从ROM的首地址开始执行。

需要注意的是,使用Proteus软件平台进行仿真时,时钟与复位相关的器件都不是必须的,没有它们也可以运行出相同的结果,所以后续为了简化电路,这些器件可能不会再添加。另外,Proteus软件平台中大多数芯片的原理图符号没有显示电源与地引脚。

现在需要通过单片机循环点亮与熄灭LED(实现LED闪烁功能),也就是以一定的时间间隔让P1.7引脚循环输出低电平与高电平,应该怎么做呢?CPU与外界沟通时只做一件事:通过地址读取或写入数据。当它从ROM中读取指令时,是通过把程序计数器(Program Counter,PC)给出的地址赋给ROM再顺序读取指令,当它往(从)RAM写入(读取)临时数据时,也是先将地址赋给RAM再进行操作。对于IO引脚也是完全一样,只不过51单片机为它们分配了一个特殊寄存器(Special Function Registers,SFR)。AT89C1051的特殊寄存器地址与复位值如图2.4所示。

图2.4 特殊寄存器地址与复位值

51单片机中的特殊寄存器地址的范围为80H~FFH(后缀“H”或“h”表示十六进制,“D”或“d”表示十进制,“B”或“b”表示二进制),每个地址对应一个8位寄存器,并且对使用到的寄存器均赋给了一个名称,同时还给出了复位时的初始值。例如,地址为E0H对应的寄存器名称为ACC,复位值为全0。但是正如图2.4中展示的,AT89C1051单片机中很多特殊寄存器地址都是空白的(表示该单片机型号没有使用到),它们可以预留给其他型号的单片机,现阶段的你无需理会。

现在需要对LED进行控制,而LED与单片机相连接的引脚为P1.7,所以必须找到P1的地址才能对其进行控制。从图2.4可以看到,P1的地址为90H,其复位值为全1。P1每一个引脚与90H地址所在的8位寄存器P1的对应关系如图2.5所示。

图2.5 P1特殊寄存器与引脚的对应关系

从图中可以看到,P1寄存器中的每一位都对应单片机一个控制引脚(它们分别为P1.7、P1.6、P1.5、P1.4、P1.3、P1.2、P1.1、P1.0),寄存器最高位对应P1.7,最低位对应P1.0。单片机上电后会进行复位,其值为全1。也就是说,对于图2.3所示电路,由于P1.7为高电平,所以LED是不亮的。那么为了将LED其点亮,我们应该将P1.7设置为低电平,也就是给P1寄存器所在地址写入7FH(二进制01111111B)。

我们来看看相应的C51驱动源代码,如清单2.1所示。

清单2.1 LED闪烁功能

在源代码的最开始,首先使用关键字sfr定义了一个标识符P1(也可以取其他的名字),它的值就是前面从特殊寄存器区域找到的P1地址:0x90(前缀0x表示十六进制,不加前缀且以1~9开头的数字表示十进制,这是C语言约定的数字进制表达方式,与前述后缀H、D是对应的,但是在进行C语言编程时只能使用前缀方式表达数字进制。另外,我们进一步约定前缀0b表示二进制以方便后续行文,因为C语言并不支持二进制整数的形式)。

有人可能会问:为什么不使用unsigned char来定义P1呢?问得好!在C51编程语言中,使用关键字sfr就相当于告诉编译软件:我现在定义的是一个特殊功能寄存器,而不是一个变量。就相当于给地址为0x90的特殊功能寄存器取了一个别名,这样以后访问它就不用总是记着0x90这个数字地址,使用起来会很方便,这同时也是一个很好的习惯。如果使用unsigned char定义一个P1,只能表示定义的是一个变量,后续对该变量的操作也不能控制相应的特殊寄存器P1。简单地说,变量的定义并没有建立它与特殊功能寄存器之间的映射关系。

main主函数是整个代码的执行入口,我们首先声明了两个无符号整形变量(ij)用于后续需要的延时操作。接下来while语句判断小括号内的表达式,如果为0,则不执行大括号{}中的语句,如果为非0,则执行花括号中的语句。为了使代码更为紧凑,本书采用传统的K&R风格来书写大括号,这种风格把左大括号留在前一行的末尾,而不是另起并占据一行。

while语句后小括号内的表达式设置为1,它是非0的,表示无限循环执行大括号中语句。具体操作是这样的:首先给P1赋给0x7F,也就能够让P1.7为低电平而点亮LED。然后再用两个嵌套的for语句延时约1s(不需要很精确),这是非常必要的,因为在不做延时的情况下,单片机的运行速度非常快,肉眼将无法观察到LED闪烁状态。紧接着再将P1赋为0xFF,也就能够让P1.7为高电平而使LED熄灭,最后延时约1s后回到循环的开始,又将P1赋为0x7F……如此循环运行下去,LED就会一直不停地闪烁起来。

终于可以正常工作了,这实在是一件美好的事情。接下来我们对刚刚编写好的源代码进行一些优化,相应的源代码如清单2.2所示。

清单2.2 优化后的源代码

优化后的源代码中并没有再使用关键字sfr定义标识符P1,但是main函数内部仍然使用了P1标识符,这是为什么呢?注意到我们在源代码开头包含了一个名为reg51.h的头文件,打开看一下里面是什么内容,如清单2.3所示(部分)。

清单2.3 头文件reg51.h(部分)

可以看到,头文件reg51.h里面使用关键字sfr定义了很多标识符,我们之前定义的P1就在里面。reg51.h是厂家已经定义好的通用头文件,51单片机中所有特殊功能寄存器的定义都包含在里面,这意味着如果需要对某个特殊功能寄存器进行访问,只需要从中查看相应的标识符并对其进行操作即可。例如,标识符P3定义的地址为0xB0,这与图2.4所示的地址B0H是完全对应的。

然后我们进一步使用while语句重写了延时代码并包装成了delay_us与delay_ms两个函数,它们分别表示以微秒与毫秒为单位进行延时。为什么要重新包装函数呢?因为从清单2.1可以看到,两处延时约1s的代码是完全一样的,包装成函数就可以方便我们在代码中进行多次调用。需要特别注意的是,此处包装的delay_us函数的延时并不是精确的微秒数,因为C51代码中while语句编译成汇编语言后并不仅仅只是一条语句,从Keil软件平台获取的汇编指令如图2.6所示。

图2.6中第一行是C51语句,下面是对应的汇编指令。最左侧第1列以“C:”开头的十六进制数字为程序存储器中存储指令的地址,第2列为指令对应的二进制数字(也称为“机器码”),程序存储器中存储的就是它,第3、4列为相应的汇编指令。

可以看到,delay_us函数被分解成为11条汇编指令,在单片机运行在12MHz频率时钟的前提下,除JNC、JNZ、RET指令需耗时2μs(微秒)外,其他指令均耗时1μs,再加上调用该函数也需要用到LCALL指令(耗时2μs),所以总共消耗的时间约为2μs(LCALL指令)+12μs(地址范围0x0036~0x0043之间的指令)+2μs(RET指令)=16μs。换句话说,即便给delay_us函数传递的延时参数为0,它也将消耗不少时间。也正因为如此,我们在delay_ms函数中调用delay_us函数时传递的延时参数值并不是1000,而是60。因为1ms/16μs=62.5,再考虑到delay_ms函数本身也会消耗一些时间,所以取了个小一点的时间值。当然,我们这里对延时要求并不高,只要大约为1s就可以了,但这种延时计算的方法在一些对延时精度要求很高的场合将会非常有用,不熟悉汇编语言的读者了解一下即可,并不影响后续的程序设计。对于需要精确延时几微秒(例如1μs、2μs)的场合,我们会在合适的场合讨论具体实现方法。

图2.6 毫秒汇编指令

另外,在点亮LED的语句中,我们将P1与0x7F进行“与”运算,由于“有0为0,全1为1”的运算特性,所以P1的最高位P1.7将被置0,而其他位对应的引脚状态将不变,这样可以避免之前给P1直接赋值0x7F的“野蛮”方式对其他位带来影响(把低7位都置1了)。在这个简单例子中,P1口的其他引脚并没有使用到,然而一旦其他引脚也被用来作为控制使用,这种考虑是必须的。在熄灭LED代码中,将P1与0x80进行“或”运算,由于“有1为1,全0为0”的运算特性,所以P1的最高位P1.7将被置1,而其他位将不变,同样可以避免影响P1口其他位的现有状态。

代码总是会有可以优化的空间,再一次修改的代码如清单2.4所示。

清单2.4 再次优化的代码

首先使用define预编译指令把前面代码中的数字定义为有意义的宏标识符,这样能使源代码的可读性更强,而且一旦外部电路的行为进行了更改(例如,修改后LED要求的驱动电平是反的,或者闪烁的速度更快),只需要修改几个宏定义即可,而没有必要花费心思去源代码内部对应位置进行修改。是不是很方便?

接下来使用关键字typedef将unsigned int类型定义了个别名uint,后续在需要定义unsigned int类型变量时可以直接使用uint,这样代码会更加简洁。然后使用关键字sbit(与关键字sfr所起的作用是完全一样的,只不过sbit是给特殊功能寄存器的某一位定义别名)将控制发光二极管的引脚P1.7也定义了一个别名LED,这样在代码阅读时就很容易理解赋值的具体含义。另外,语句“LED ^=1”相当于“LED=LED ^ 1”,其中符号“^”表示“异或”运算,由于它有“相同为0,不同为1”的运算特性,也就可以将LED驱动电平取反。