第5章 让数字显示出来

第4章介绍了发光二极管的概念,并且举了几个实例,最后用发光二极管连接了一个数字一样的图形,那是否有一种电子元器件直接就可以显示数字呢?答案是肯定的,而且在我们的身边用得也很多,本章我们就重点介绍该器件:数码管。

5.1 引入数码管的概念

5.1.1 数码管介绍

在介绍数码管的一些知识之前,我们先直观地看看它们是什么样子的,如图5-1所示。

图5-1 数码管外形

数码管按段数分为7段数码管和8段数码管,8段数码管比7段数码管多一个发光二极管单元(多一个小数点显示单元),图5-1中的前三个都是8段数码管;按能显示多少个“8”可分为1位(单位)、2位(双位)、4位等数码管;另外就是可显示更多数字段的“米”字数码管,这里重点介绍8段数码管。下面先看数码管的结构,如图5-2所示。

图5-2 数码管的内部结构图

发光二极管按单元连接方式分为共阳极数码管和共阴极数码管。共阳极数码管就是将所有发光二极管的阳极接到一起形成公共阳极(COM)的数码管,如图5-2(c)所示。我们在应用共阳极数码管时应将公共极COM接到+3.8V电源上,这时当某一字段发光二极管的引脚为低电平时,相应字段就点亮;当某一字段的引脚为高电平时,相应字段就不亮。同样道理,共阴极数码管就是将所有发光二极管的阴极接到一起形成公共阴极(COM)的数码管,如图5-2(b)所示。我们在应用共阴极数码管时应将公共极COM接到地线GND上,这时当某一字段发光二极管的引脚为高电平时,相应字段就点亮;当某一字段的引脚为低电平时,相应字段就不亮。

仔细观察一下图5-2中共阳极数码管的连接图,是不是和我们第4章最后一个实例的电路图很相似呢?理解了这一点,也就不难学习后面的内容了。

5.1.2 写段程序让它亮起来

下面就设计一个电路,来使用数码管。和前面一样,要先在Proteus中画出电路图,如图5-3所示。

图5-3 共阳极数码管电路

我们在图5-3中用的是共阳极的7段数码管电路,在添加元器件时输入7SEG-COM可以看到有AN(Anode的缩写)的、有CAT(Cathode的缩写)的。其中Anode的是共阳极的,Cathode的是共阴极的,千万不要选错了,根据前面介绍的它们的工作原理,选错了就不能正常显示了。

下面要考虑程序如何设计。首先根据电路和共阳极数码管的工作原理,要想让哪个灯亮就要让其对应的引脚拉低。以数字0为例,要显示数字0,就要使a、b、c、d、e、f引脚都拉低,而g引脚要拉高,这样对应到单片机PortC端口是PC6输出1而其他引脚输出0,也就是PORTC=0x40。

根据上面的分析想一想,如果要显示0~9的10个数字,该如何设计程序呢?

/******************************************************
* 文件名:main.c
* 说 明:实现共阳极数码管0~9的数字显示
* 目 标:ATmega16
* 晶 振:1.0000MHz
******************************************************/
#include <iom16v.h>
#include "main.h"
#include "delay.h"
/* 共阳极数码管显示码 */
uchar const DIS_CODE[10] = {0x40,0x79,0x24,0x30,0x19,0x12,0x02,0x78,
0x00,0x10};
/* 循环显示0~9十个数字 */
void main(void)
{
    uchar i = 0;
    DDRC = 0xFF; //端口初始化,PortC口作为输出口使用
    PORTC = 0x00; //初始化端口输出低电平
    while(1)
    {
        PORTC = DIS_CODE[i];
        delay_ms(800);
        i = (i+1)%10;
    }
}

设计完程序就可以编译、仿真了。这里有几点要注意,首先是该工程包含4个文件main.c、main.h、delay.c、delay.h。除了main.c文件,其余3个文件与第4章中的内容一样。其次是调试时可以先给PORTC赋一个固定值,如PORTC = 0x40来调试,看显示的是否是0,如果是0则电路没什么问题,如果后面的数字不能正常显示,修改程序的DIS_CODE值就好了;如果开始连0都不能正常显示,那就是电路有问题了,要检查电路;最后就是电阻的取值,我用的是RES,Proteus默认的值是10k1,不修改该值不能点亮数码管,要修改为2202左右的值。

做到以上几点,你的仿真应该就可以成功了。

做完实验,我们分析一下程序,这段代码重点有两行要分析。首先看uchar const DIS_CODE这一行,这是C语言里无符号字符数组的定义方式,有一个关键字const,怎么理解呢?这里的意思是将这个数组定义在程序存储区(即内部ROM区),而非内部RAM区。为什么要这样做呢?因为单片机的内部存储器资源比较紧张,ATmega16只有1024B的RAM区,因而我们要尽量节约使用,特别是在编写比较复杂的程序时。

另外还有一句值得我们注意,i=(i+1)%10,其目的是使i的值永远在0~9这十个数字中变化。但有几种修改方式值得我们去思考:i=(i++)%10或i=(++i)%10。这两个语句有什么不同呢?你可以思考一下,然后修改程序仿真验证你的想法。

实例中数码管的这种显示方式为静态显示,那是不是还有动态显示呢?下面我们就来补充点数码显示方式的知识。

5.1.3 数码管的驱动方式

根据数码管驱动方式的不同,可以分为静态方式和动态方式两类。

1.静态显示驱动

静态驱动也称为直流驱动,是指每个数码管的每一个段码都由单片机的一个I/O引脚进行驱动,或者使用译码驱动器(如BCD码二-十进制译码器)进行驱动,图5-4就是静态驱动两个数码管的电路。

图5-4 数码管的静态驱动方式

静态驱动的优点是单片机编程简单、数码管显示亮度高;缺点是占用单片机的I/O口多,如要驱动4个8段数码管静态显示需要448=32个I/O口来驱动,而一个ATmega16单片机可用的I/O口只有32个,这样除去数码管显示的工作,单片机就不用再做其他控制外设的工作了。因而在实际应用时必须增加译码驱动器进行驱动,这样就增加了硬件电路的复杂性。

2.动态显示驱动

数码管的动态驱动是用一种快速扫描的方法驱动数码管的方式,也是在单片机数码管控制过程中使用最为广泛的一种显示方式。动态驱动电路的连接是将所有数码管的8个显示引脚(a、b、c、d、e、f、g、dp)按照同名引脚分别连接到单片机的I/O口上,另外将每个数码管的公共极COM引脚通过选通控制电路连接到单片机的I/O上(也可能经过译码电路后连接到单片机),图5-5就是通过动态驱动的方式驱动两个两位数码管的电路。

图5-5 数码管的动态驱动方式

动态驱动方式在显示数字时,单片机要先通过选通控制电路选中要显示数字的数码管位,然后再输出字形码,这时所有数码管都接收到相同的字形码,但只有单片机选通COM那位数码管才显示数字。所以通过分时轮流控制各个数码管的COM端,输出相应的字形码,就能使各个数码管轮流受控显示,这就是动态驱动。在轮流显示过程中,每位数码管的点亮时间为1~2ms,由于人的视觉暂留现象及发光二极管的余辉效应,尽管实际上各位数码管并非同时点亮,但只要扫描的速度足够快,给人的印象就是一组稳定的显示数据,这个理论我们在第4章的4.3.1节有个LED的实验验证了,还记得吗?动态显示能够节省单片机的I/O端口,而且功耗更低;但是动态显示需要比较复杂的程序控制,而且如果延时不当就会给人闪烁的感觉。

5.2 多显示几个数字看看

上面实现了单位数码管的显示,那么要用到多个数码管显示时,是不是就要用多个单位数码管实现呢?其实不用,我们来看下一节的内容。

5.2.1 电路实现

上面的实例是显示一位数字的情形,如果要显示两位或多位数字怎么办呢?下面我们就先画一个显示4位数字的电路,如图5-6所示。

画这个电路图时用到了两个新的元器件7SEG-MPX4-CA(共阳极4位数码管)和RX8(8个电阻的排阻)。使用这两个元器件可以简化很多电路,如果数码管不使用这种连接方式,而是使用静态连接4个单位数码管的方式(每8个引脚连接一个数码管),那么ATmega16的4个I/O口也仅仅能连接4个数码管。

图5-6 4位数码管电路

图5-6中还用到了Proteus画图时的一个技巧,观察电路图会发现数码管右下角的4根引脚并没有连接到ATmega16的PortA口上,而是做了一些标注。这也是画电路原理图时常使用的一个技巧,这样画图可以使图中的连线减少,看起来更清晰。画图的方法就是在引脚上连一根短线,然后选择该导线修改其名称。修改导线名称时先选择左侧绘图工具栏上的Wire Label Mode按钮,然后在图形编辑窗口选择要命名的导线进行标注。应注意的是标注导线两边的名称要保持一致,否则就不能正确连接。例如,ATmega16的PA0引脚标注为D1,要将其与数码管的“1”引脚连接起来,就要将数码管的“1”引脚也标注为D1。我们可以将电路图放大一下看看,如图5-7所示。

图5-7 放大显示数码管电路

5.2.2 程序实现

在写程序前,先介绍一下4位数码管的知识。数码管左侧的8根引脚是用来控制数码管的字段的,和上例中单个数码管的引脚功能相同,只是多了一个GD引脚,是用来控制小数点的显示的;而右侧的4根引脚是用来控制数码管的位的,电路中用的是共阳极数码管,也就是在4根引脚中哪根引脚输入高电平就是要点亮哪位数码管。

我们先写段代码,来验证一下4位数码管的引脚是不是这样的。

/****************************************************
* 文件名:main.c
* 说明:控制4位数码管的显示
* 目标:ATmega16
* 晶振:1.0000MHz
*****************************************************/
#include <iom16v.h>
#include "main.h"
#include "delay.h"
/* 共阳极数码管显示码 */
uchar const DIS_CODE[10] = {0x40, 0x79,0x24,0x30,0x19,0x12,0x02,0x78,
0x00,0x10};
void main(void)
{
    uchar i = 0;
    DDRC = 0xFF;  //初始化,作为输出I/O口
    PORTC = 0x00;  //初始化状态输出低电平
    DDRA = 0xFF;  //初始化,作为输出I/O口
    PORTA = 0x00;  //初始化状态输出低电平
    hile(1)
    {
        for(i=0; i<10; i++)
        {
            PORTA = 0x0F;
            PORTC = DIS_CODE[i];
            delay_ms(1000);
            PORTA = 0x00;
            PORTC = DIS_CODE[i];
            delay_ms(1000);
        }
    }
}

新建一个工程,编写main.c如上,然后复制main.h、delay.c、delay.h到该工程,将这4个文件添加到工程中,设置工程的属性,然后编译工程;最后到Proteus中设置ATmega16的仿真程序为刚刚编译的文件,进行仿真。

观察仿真结果可以发现,当PortA口输出的是0x0F,也就是引脚输出为高电平时,可以将数码管点亮显示数字,而当PortA口输出低电平时,4位数码管都灭了。根据这个结果,可以控制PortA口的输出来让数码管显示我们想要的数字,这就用到了第4章的一个实例—跑马灯,还记得吗?

还有一点不知读者注意没有,上面的例子用我们前面用的数字DIS_CODE仿真时,4位数码管的小数点都是亮的。那么考虑一下,如何修改可以不让小数点亮呢?

5.2.3 你的眼睛欺骗了你

根据上面的提示,我们可以修改程序了,我们要用跑马灯的原理来控制PortA的引脚实现控制4位数码管某个位的目的,同时修改DIS_CODE的值来控制小数点的显示。

/******************************************************
* 文件名:main.c
* 说 明:数码管动态显示原理的实现
* 目 标:ATmega16
* 晶 振:1.0000MHz
*******************************************************/
#include <iom16v.h>
#include "main.h"
#include "delay.h"
/* 共阳极数码管显示码 */
uchar const DIS_CODE[] = {0xC0,0xF9,0xA4,0xB0,0x99,0x92,0x82,0xF8,
0x80,0x90};
/*数码管动态显示原理*/
void main(void)
{
    uchar i = 0;
    DDRC = 0xFF;  //初始化,作为输出I/O口
    PORTC = 0x00;  //初始化状态输出低电平
    DDRA = 0xFF;  //初始化,作为输出I/O口
    PORTA = 0x00;  //初始化状态输出低电平
    while(1)
    {
        for(i=0; i<4; i++)
        {
            PORTA = (1<<i);
            PORTC = DIS_CODE[i];
            delay_ms(1000);
        }
    }
}

程序的编译、仿真就不再重复了。我们看看程序,和上一个程序比较过DIS_CODE的值会发现,每个值刚好比上一个实例中的值多了0x80,为什么会这样呢?

我想你应该能够想到吧,看看电路你会发现,数码管的DP引脚是ATmega16的PC7引脚控制的,我们要在程序中将该引脚置高位,所以在DIS_CODE的设定值中就要加上0x80。

【修改程序】

接下来要玩点小动作,我们修改main函数中的delay_ms语句,先修改为delay_ms(100);,然后编译、仿真,看看仿真效果。

仿真后再修改一次,修改为delay_ms(10);或更小的值,再次编译、仿真,看看结果。

最后你会发现,当delay_ms的参数小到一定的时候,我们就看不到数码管灭的过程了,如图5-8所示。这就是前面介绍的数码管动态显示的实现方法,这种显示原理在第4章讲解发光二极管的时候也做过一个类似的实例,还记得吗?

图5-8 数码管的动态显示

5.3 仿真万年历

通过5.2.3节的实例,我们已经理解数码管的动态显示是怎么一会事了。本节就为大家做一个仿真实例:万年历日期的显示。

5.3.1 电路实现

我们要用的电路并不是很复杂,只是显示的数字比较多,所以要多用几个数码管。为了能让大家看清楚图的原理,这里我用了两幅图来表示,图5-9是原理图的整体图,图5-10是原理图的局部图。

图5-9 万年历日期显示

图5-10 万年历局部电路图

这幅原理图比图5-6仅仅多了两个共阳极2位数码管(7SEG-MPX2-CA),在前面实例中用过共阳极4位数码管(7SEG-MPX4-CA),我想这个也不在话下吧。

其他要注意的就是总线连线方式,绘制总线时选择左侧绘图工具栏上的Buses Mode按钮,然后就可以在图形编辑窗口中画总线了。画完总线后,把要走总线的器件都连接到总线上,然后用左侧绘图工具栏上的Wire Label Mode按钮,在图形编辑窗口标注连接到总线的每根线。标注导线时要保持两边一致,这一点和5.2.1节画图的方法是一样的。

5.3.2 程序实现

完成了电路的设计,下面就要考虑程序怎么实现了。其实有了5.2.3节数码管动态显示的实例,这个程序实现起来也简单。如图5-9中所示的那样,只要将自己想显示的数字“2011、09、19”用数码管动态显示的方法显示出来就可以了。

/******************************************************
* 文件名:main.c
* 说 明:仿真万年历日期的显示
* 目 标:ATmega16
* 晶 振:1.0000MHz
******************************************************/
#include <iom16v.h>
#include "main.h"
#include "delay.h"
/* 共阳极数码管显示码 */
uchar const DIS_CODE[] = {0xC0,0xF9,0xA4,0xB0,0x99,0x92,0x82,0xF8,
0x80,0x90,0xFF};
/* 用数码管动态显示年、月、日 */
void main(void)
{
    DDRA = 0xFF;
    PORTA = 0x00;
    DDRC = 0xFF;
    PORTC = 0x00;
    while(1)
    {
        PORTA = 0x01;
        PORTC = DIS_CODE[2]; //年
        delay_ms(5);
        PORTA = 0x02;
        PORTC = DIS_CODE[0];
        delay_ms(5);
        PORTA = 0x04;
        PORTC = DIS_CODE[1];
        delay_ms(5);
        PORTA = 0x08;
        PORTC = DIS_CODE[1];
        delay_ms(5);
        PORTA = 0x10;
        PORTC = DIS_CODE[0]; //月
        delay_ms(5);
        PORTA = 0x20;
        PORTC = DIS_CODE[9];
        delay_ms(5);
        PORTA = 0x40;
        PORTC = DIS_CODE[1]; //日
        delay_ms(5);
        PORTA = 0x80;
        PORTC = DIS_CODE[9];
        delay_ms(5);
    }
}

我们在ICC AVR编译器中新建工程,配置工程,将上面的代码编辑、保存为Main.c文件,然后复制main.h、delay.c、delay.h 3个文件到工程文件中,最后添加这4个文件到工程中,就可以编译、仿真了。

观察代码会发现,本例的代码和5.2.3节main函数中的代码并没有实质的区别。无非前面的实例用for语句循环了4次显示,而本节用8个数码管,就用了8个重复的语句显示。

原理确实如此,举此例主要是想告诉大家数码管的动态显示在实际应用中的使用。下面从代码结构的方面对程序做一个优化。

5.3.3 优化程序

我们要优化的是数码管的显示部分,前面是将代码写在主函数的while语句中,其实可以将其单独写成一个显示函数。

/*****************************************************
* 文件名:main.c
* 说 明:优化万年历日期显示程序
* 目 标:ATmega16
* 晶 振:1.0000MHz
*****************************************************/
#include <iom16v.h>
#include "main.h"
#include "delay.h"
/* 数码管显示码 */
uchar const DIS_CODE[] = {0xC0,0xF9,0xA4,0xB0,0x99,0x92,0x82,0xF8,
0x80,0x90,0xFF};
/******************************************************
* 函 数:显示日期函数
* 参 数:year要显示的年份、无符号整型,month要显示的月份、无符号字符型,day要
显示的日期、无符号字符型
* 返回值:无
******************************************************/
void show_date(uint year, uchar month, uchar day)
{
    uint tmp;
    /*年份显示*/
    tmp = year/1000;
    PORTA = 0x01;
    PORTC = DIS_CODE[tmp];
    delay_ms(5);
    tmp = year%1000;
    tmp = tmp/100;
    PORTA = 0x02;
    PORTC = DIS_CODE[tmp];
    delay_ms(5);
    tmp = year%100;
    tmp = tmp/10;
    PORTA = 0x04;
    PORTC = DIS_CODE[tmp];
    delay_ms(5);
    tmp = year%10;
    PORTA = 0x08;
    PORTC = DIS_CODE[tmp];
    delay_ms(5);
    /*月份显示*/
    tmp = month/10;
    PORTA = 0x10;
    PORTC = DIS_CODE[tmp];
    delay_ms(5);
    tmp = month%10;
    PORTA = 0x20;
    PORTC = DIS_CODE[tmp];
    delay_ms(5);
    /*日期显示*/
    tmp = day/10;
    PORTA = 0x40;
    PORTC = DIS_CODE[tmp];
    delay_ms(5);
    tmp = day%10;
    PORTA = 0x80;
    PORTC = DIS_CODE[tmp];
    delay_ms(5);
}
/******************************************************
* 函 数:端口初始化,将PortA、PortC口初始化为输出口
* 参 数:空
* 返回值:无
******************************************************/
void port_init(void)
{
    DDRA = 0xFF;
    PORTA = 0x00;
    DDRC = 0xFF;
    PORTC = 0x00;
}
/******************************************************
* 函 数:仿真万年历日期显示,经过代码优化,主函数清晰、明了
* 参 数:空
* 返回值:无
******************************************************/
void main(void)
{
    port_init();   //端口初始化
    while(1)
    {
        show_date(2011, 9, 19); //显示函数调用
    }
}

这个程序的优化,主要是将显示日期部分、端口初始化部分单独提出来做一个函数,并在日期显示函数内部做些年、月、日的显示处理。这样在以后使用同类功能函数时就可以直接调用了,就像主函数调用那样。

5.4 补充内容

5.4.1 排阻

画图时我们用到了排阻,下面就看看排阻是什么样的,如图5-11所示。

图5-11 排阻

排阻分为插件排阻和贴片排阻,插件排阻就是若干个参数完全相同的电阻,它们的一个引脚连到一起作为公共引脚,其余引脚正常引出。所以如果一个排阻是由n个电阻构成的,那么它就有n+1只引脚,一般来说最左边的那只是公共引脚,它在排阻上用一个色点标出来(如图5-11中最左侧的排阻)。

贴片排阻的外观就像n个电阻贴在一起一样,这样焊接起来可以节省空间,也方便操作。贴片排阻在内存、主板等设备上使用的比较多。

排阻比较适合多个电阻阻值相同,并且其中一个引脚都连接到电路的同一位置的场合(如接地)。

5.4.2 数码管的应用

数码管是一类显示屏,通过对其不同的引脚输入相对的电流使其发亮,从而显示出数字,它能够显示时间、日期、温度等所有可用数字表示的参数。

数码管的价格便宜、使用简单,在电器特别是家电领域的应用极为广泛。例如,空调遥控器、电视遥控器、热水器、洗衣机、电磁炉等家电设备上都用到了数码管,如图5-12所示。

图5-12 数码管的应用

观察你的身边,会找到更多数码管应用的地方,它和发光二极管一样,在我们身边随处可见。因此不要因为它的简单而不重视它,越是这种基本的东西,我们越要注意其应用。

5.4.3 比较PC口和PA口

本章的实例是用ATmega16的PC口设计的,下面我们就来看看数据手册上是如何介绍PC口的。

首先通过数据手册上的书签“综述”→“引脚说明”→“端口C(PC7…PC0)”找到对应的章节,文档中是这样描述的:

端口C为8位双向I/O口,具有可编程的内部上拉电阻。其输出缓冲器具有对称的驱动特性,可以输出和吸收大电流。作为输入使用时,若内部上拉电阻使能,端口被外部电路拉低时将输出电流。在复位过程中,即使系统时钟还未起振,端口C也处于高阻状态。如果JTAG接口使能,即使复位出现,引脚PC5(TDI)、PC3(TMS)与PC2(TCK)的上拉电阻仍被激活。

我们可以将端口C的描述和端口A的描述简单比较一下,比较后会发现,其实当端口C和端口A作为普通I/O口时,其性能是相同的,只是两个端口的第二功能不同罢了。在对端口C的描述中,最后提到的JTAG接口其实就是端口C的第二功能。

有关端口C第二功能的具体描述,可以通过数据手册中的“I/O端口”→“端口的第二功能”→“端口C的第二功能”一节来了解。

另外,端口C作为通用的数字I/O口时,与其相关的寄存器也是3个:PORTC、DDRC、PINC,可以通过“I/O端口”→“I/O端口寄存器的说明”一节来了解这些寄存器。

有关I/O端口初始状态的配置,用做输入、输出口,是否使能内部上拉电阻等,在数据手册的“I/O端口”→“作为通用数字I/O的端口”→“配置引脚”一节有比较详细的描述,在第4章讲解PA口时已经介绍过,这里就不多说了。

另外,数据手册中的“I/O端口”→“作为通用数字I/O的端口”→“未连接引脚的处理”一节提到对未处理引脚的处理一事也要注意一下:

如果有引脚未被使用,建议给这些引脚赋予一个确定电平。虽然如上文所述,在深层休眠模式下大多数数字输入被禁止,但还是需要避免因引脚没有确定的电平而造成悬空引脚在其他数字输入使能模式(复位、工作模式、空闲模式)消耗电流。

最简单的保证未用引脚具有确定电平的方法是使能内部上拉电阻。但要注意的是复位时上拉电阻将被禁用。如果复位时的功耗也有严格要求则建议使用外部上拉或下拉电阻,不推荐直接将未用引脚与VCC或GND连接,因为这样可能会在引脚偶然作为输出时出现冲击电流。

其实这段话就是对端口初始化函数port_init的一个建议,也就是说在电路中,如果没有使用PA口和PC口,最好要初始化该端口,而不是将其省略不管,而且要初始化为输入口、使能内部上拉。具体的端口作为输入口的使用会在第6章讲述。

void port_init(void)
{
    DDRA = 0x00;
    PORTA = 0xFF;
    DDRC = 0x00;
    PORTC = 0xFF;
}

通过这两章的练习,我想读者对端口作为通用数字I/O口的输出功能已经很熟悉了。那么如果将端口用做通用数字I/O口的输入功能又该如何操作呢?这个问题我们会在第6章给出答案。