第4章 让你的单片机“眨眨眼睛”

介绍过工具,下面就开始学习技术。本章就引导大家开始真正的AVR单片机程序设计—用单片机控制发光二极管“眨眨眼睛”。

4.1 我们的第一个单片机程序

4.1.1 用ICC AVR新建一个工程

第3章,我们是在ICC AVR编译环境中看其自带的示例程序。在这里,我们要自己写第一个程序。下面就分步实现。

第1步:运行ICC AVR编译器

ICC AVR可以通过桌面的快捷方式运行,也可以通过菜单“开始”→“所有程序”→“ImageCraft Development Tools”→“ICCV7 for AVR”运行,具体操作过程在第3章已经详细介绍过了,这里不再重复。

第2步:新建工程

通过菜单“Project”→“New”打开新建工程对话框,为新建的工程命名、设定保存路径(保存的路径最好不要包含中文名称)。我这里将工程命名为“LED.prj“,如图4-1所示。

图4-1 新建工程

【注意】 保存工程的目录最好不要包含中文路径,否则可能会影响编译、调试;另外,建立的路径不要太深,路径太深也会影响编译、调试。我保存的目录是“D:\AVR\M16\ Chapter4\Examp01\”。

另外,在工程所在的文件夹下新建一个文件夹“Debug”,后面要用到。

第3步:建立一个C文件

通过菜单“File”→“New”或工具栏上的New File按钮新建一个文件,文件的默认名字是Untitled-0。

第4步:用代码生成器生成初始化代码

通过菜单“Tools”→“Application Builder”或工具栏上的Application Builder按钮打开ICCAVR Application Builder对话框,如图4-2所示。

在ICCAVR Application Builder的CPU属性页修改“Target CPU”为“M16”,这是我们的目标单片机;“Xtal speed (MHz)”修改为“8.0000”,这是要使用的晶振频率,设置如图4-2所示。接下来切换到Ports属性页,如图4-3所示。

图4-2 ICCAVR Application Builder对话框

图4-3 Ports属性页

Ports属性页是ATmega16的4个I/O口的状态设定,可以看到,默认的模式在Direction行都是“I”,Value行是空的,Change行是灰色状态。我们修改Port A的初始状态,先单击Direction后面的小方格,其状态会由“I”变为“O”。说明一下,这里的“I”表示In,也就是输入的意思;“O”表示Out,就是输出的意思。然后再修改Value的状态,修改后如图4-3所示。

修改完成后单击“Preview”按钮(其他属性页以后用到时再做介绍),出现Code Preview对话框,如图4-4所示。

图4-4 Code Preview对话框

这就是ICC AVR自带的代码生成器生成的端口初始化代码。我们可以先学习这些初始化代码,然后再进入编程的学习。

生成代码的第一部分是注释行,其中第1行告诉我们程序段是ICC-AVR application builder生成的,并包含生成时间;第二行说明选用目标单片机是M16;第三行是晶振频率为8.0MHz等信息。

第二部分是该初始化代码要引用的两个头文件:iom16v.h、macros.h。还记得第3章我们看示例程序时在示例程序中引用的两个头文件吗?将那两个头文件io8515v.h、macros.h和这两个头文件比较一下,你会发现,和我当时介绍的一样,macros.h是一个通用的头文件,而io****.h是针对不同的AVR单片机要引用的头文件。

再往下是两个函数port_init、init_devices函数,这里不关心init_devices函数,先看看port_init函数。

根据前面的修改,我们的PORTA、DDRA两个寄存器和别的寄存器不太一样。观察可以发现,图4-3的Direction行控制的是DDR*寄存器,而Value行控制的是PORT*寄存器,也就是说DDRA寄存器是控制PortA口作为输入口还是作为输出口的。而PORTA寄存器就更有学问了,这里先不解释,我们后面根据仿真来理解。

第5步:根据初始化代码写程序

将代码生成器中的程序复制到新建的文件中,并做些修改,如图4-5所示。

图4-5 我们的第一个程序

在注释行添加一句,对程序做个简单的说明。将port_init函数修改为main函数,然后删除其他端口的设置,这里只对PortA做设置,然后添加一个while语句(注意,while语句后面是以分号“;”结尾的,不要漏掉了)。init_devices函数我们还用不到,这里就不要了;另外,头文件macros.h这里也用不到,也删除了。

【注意】ICC AVR的编译器并不太好用,特别是在做中、英文切换输入时,有些中文字符在这里是不显示的。有时在写代码时,如果忘记将中文输入法切换回英文输入法,编译时就会遇到莫名其妙的错误,因而大家在编写程序时要注意中、英文输入法的切换。

第6步:保存程序

通过菜单“File”→“Save”或工具栏上的Save File按钮来保存程序,将程序保存到工程所在的目录。我保存的文件名是“led_test.c”,保存的路径是“D:\AVR\M16\Chapter4\ Examp01\”,如图4-6所示。

图4-6 保存程序

【注意】 保存时,我们写的是C语言程序,因而要以“.c”结尾,否则ICC AVR会以为是汇编文件。

第7步:添加程序文件到工程中

通过菜单“Projec”→“Add File(s)”添加程序文件到工程中,也可以通过界面上右侧工程列表中的Files文件夹来添加,在该文件夹上单击鼠标右键,在弹出的菜单中选择“Add File(s)”项,如图4-7所示。

图4-7 添加文件菜单

在弹出的对话框中选择刚才保存的文件“led_test.c”,然后单击“打开”按钮,将该文件添加到工程中,如图4-8所示。

图4-8 添加文件到工程中

添加完成后,在ICC AVR主界面右侧的Project列表下,Files文件夹下会增加一个文件,那就是我们刚刚添加的led_test.c。

第8步:设置工程

通过菜单“Project”→“Options….”或工具栏上的Porject Options按钮打开设置工程的对话框,如图4-9所示。

在Project属性页,要设定“AVR Studio Version(COFF)”项为“Studio 4.06 and above”。然后切换到Compiler属性页,如图4-10所示。

图4-9 设置工程属性

图4-10 Compiler属性页

设定“Output Format”为“COFF/HEX”属性,这是为了生成仿真文件和调试文件。接下来切换到Target属性页,如图4-11所示。

在该属性页主要设定目标单片机,在“Device Configuration”项选择“ATMega16”,这是我们要使用的目标单片机。最后切换到Paths属性页,如图4-12所示。

图4-11 Target属性页

图4-12 Paths属性页

Paths属性页主要设定的是工程要用的文件,在“Include Paths”行单击其后的“Add…”按钮,找到ICC AVR的安装路径,然后选择include文件夹,这是工程要引用的头文件的路径;在“Asm Include Paths”行,用同样的方法添加到libsrc.avr文件夹,这是工程要使用的汇编文件;在“Library Paths”行设定到lib文件夹,这是工程用到的库文件的路径。设定完成后如图4-12所示,然后单击“Set As Default”按钮,保存该设置为默认设置,这样以后的工程就不用每个都设置这么多了。

最后设置Paths属性页的“Output Directory”属性,这是工程编译输出的路径。还记得我们在新建工程时在工程所在的文件夹下建立了一个Debug文件夹吗?将该路径设置到此文件夹,编译后的输出文件就会汇总到那里。

最后,单击“OK”按钮,保存设置,返回主界面,准备编译工程。

第9步:编译工程

编译工程通过菜单“Project”→“Rebuild All”或工具栏上的Build Project按钮

完成,这个过程在第3章编译IIC AVR自带的示例程序时使用过,还记得吧?

编译结果如图4-13所示,如果没有编写错误,应该可以顺利通过编译。由于我们的代码大多都是从代码生成器里复制过来的,因此如果有问题,应重点检测main函数定义和while语句这两行。

图4-13 编译结果

【初学者容易犯的错误】

(1)大、小写的错误。C语言是区分大、小写的,如果将main写为Main,就会产生错误。

(2)中、英文的切换。我们使用的编译器是老外开发的,因而对中文还不是很适应,特别是在写过中文注释后很容易忘记切换到英文模式下写代码,这样就很容易在编译时出现莫名其妙的错误。更让人郁闷的是,有些中文标点符号在ICC AVR的编辑环境中是看不到的,这样我们只能根据错误提示去修改。

(3)遗漏标点符号。在语句的后面忘记分号“;”,大括号、小括号等符号的成对匹配,特别是大括号,在复杂的程序中更是容易出现不匹配的情况。另外,这些符号都是英文的,不要用中文输入法去书写。

4.1.2 画出我们要用的电路

接下来,我们先用学过的Proteus画出要用的电路图。

第1步:运行Proteus ISIS

Proteus可以通过菜单“开始”→“所有程序”→“Proteus 7 Professional”→“ISIS 7 Professional”来运行。

第2步:添加单片机ATMega16

通过Proteus的菜单“Library”→“Pick Device/Symbol…(P)”或主窗口上对象选择区中的按钮来添加要用的单片机ATMega16。

还记得添加方法吧?在“Keywords”栏输入关键字“ATMega”,然后在“Results”列表中选择“ATMEGA16”,如图4-14所示,最后单击“OK”按钮就可以画图了。

图4-14 选择芯片ATMega16

第3步:添加其他元器件

用同样的方法添加其他元器件:普通电容CAP、晶振CRYSTAL、电阻RES、发光二极管LED-RED,如图4-15所示。

图4-15 电路要用的元器件

第4步:添加电源和地

用绘图工具栏上的Terminals Mode按钮,在对象选择器窗口可以看到POWER、GROUND项,选择该项添加到图形编辑区,如图4-16所示。

图4-16 添加电源和地

第5步:用导线连接电路

通过导线将电路连接起来,画电路时可以先单击绘图工具栏上的Component Mode按钮,然后在绘图区连接电路。用导线连接后的电路如图4-17所示。

图4-17 用导线连接电路

【注意】 在连接电路时,选择元器件可以根据需要用鼠标拖动元器件到合适的位置,也可以用鼠标右键菜单旋转元器件到合适的方向,如图4-18所示。

图4-18 旋转选中的元器件

第6步:调整各个元器件的参数

修改元器件的参数,双击要设定的元器件,弹出对话框用来编辑该元器件的参数,设定完毕单击“OK”按钮保存设定,如图4-19所示是晶振CRYSTAL的属性对话框。

图4-19 设置各元器件的参数

【注意】 图4-19中,晶振X1的参数是8MHz,ATMega16的晶振频率也设定为8MHz;电容C1、C2的电容值是22pF;电阻R1的阻值是10k1,电阻R2~R9的阻值是2202。

下面简单介绍一下我们画的电路(见图4-20)。

图4-20 电路划分

如图4-20所示,电路共包括三部分:晶振电路、复位电路、发光二极管控制电路,其中晶振电路的来源可以从ATMega16的数据手册上找到。有关ATMega16的数据手册可以到ATmel的官方网站http://www.atmel.com/http://www.21ic.com网站上搜索,下面我把数据手册中的相关内容贴出来比较一下,如图4-21所示。

进一步查看数据手册可以发现,数据手册上不但给出了电路的连接方式,而且还给出了电容参数值的选择(数据手册上给出的C1、C2的取值范围是12~22pF)。

复位电路是根据ATMega16单片机内部的复位电路设计的。当ATMega16的RESET引脚上的电压低于门限电压(0.9VCC)且持续时间大于1.51s时,可以使其复位。简单一点的描述就是把RESET引脚的电压拉低一定的时间可以使单片机ATMega16复位,程序重新运行(具体描述可以参考数据手册有关RESET引脚的介绍部分);为了防止其复位,这里将RESET引脚通过电阻直接连接到电源。

图4-21 ATMega16外接晶体振荡电路

最后就是要用程序控制的发光二极管电路了,根据发光二极管的特性,将二极管的正极连接到单片机的PortA口上,负极通过电阻接至地,这样我们用程序控制PortA引脚的电平变化就可以实现发光二极管的状态改变。

其实在Proteus中晶振电路和复位电路都可以省略不画,ATMega16的晶振频率可以直接在其设置属性页中设定,因而电路可以简化为如图4-22所示电路。

图4-22 简化电路

电路画完后要注意保存,为了方便后面做仿真、调试,这里对保存的路径要求比较苛刻。一定要将电路图保存在前面ICC AVR新建工程所在文件夹下的Debug文件夹中,如图4-23所示。

图4-23 保存电路图

【附一】在Atmel公司官网下载数据手册的方法

用浏览器登录到Atmel的官方网站http://www.atmel.com,在搜索按钮处选择“Go to Document Search”,如图4-24所示。

图4-24 选择搜索项

在弹出的搜索页面中,按图4-25所示设置搜索条件。

图4-25 设置搜索条件

在“Document Type”项选择“Datasheets”,在“Product Family”项选择“Atmel AVR 8- and 32-bit Microcontrollers”,然后单击“Submit”按钮,进行文件搜索。

如图4-26所示,搜索结果中共3份ATmega16的文档,其中一份英文数据手册(Datasheet)、一份对应的中文译本,另外一份是数据手册的摘要(Summary)。本书中引用的数据手册指的就是这两份数据手册(中、英文版)。

图4-26 文件列表

【附二】在21IC中搜索数据手册的方法

进入21IC的主页http://www.21ic.com,在搜索条件中填写要搜索的元器件名称,单击“搜索”按钮,如图4-27所示。

图4-27 在21IC中搜索元器件

在如图4-28所示的搜索结果中,可以看到厂家的列表和ATMega16资料。我们也可以在这里下载ATmega16的资料,还可以在列表中看到芯片的提供厂商是ATmel,然后找到ATmel公司的官网http://www.atmel.com,到其官网下载。

图4-28 搜索结果

学会看数据手册对单片机开发者来说非常重要,因此要尝试着去看看ATmega16的数据手册。比较好的是ATmel公司在其官网上提供了中文版的数据手册,我们可以和英文版的对照起来看,这样有利于学习使用数据手册。

这里提到21IC,不得不说两句题外话,那就是21IC的论坛bbs.21ic.com和它的站长“程序匠人”。21IC的论坛是高手经常出没的地方,在这里可以学到很多书上学不到的东西。站长“程序匠人”有本书很值得大家去看—《匠人手记》,不过要有一定的开发基础才能读懂其中的精华。

4.1.3 用Proteus仿真、调试

上面我们既编写了程序也为其设计了电路,下面就在Proteus中做一下仿真,看看程序运行的结果。

1.用Proteus仿真

进入Proteus集成环境,在前面设计的电路中设置ATmega16的运行程序,如图4-29所示。

图4-29 设置运行程序

在“Program File”属性项找到保存ICC AVR工程的位置,然后选择编译后的LED.hex文件(根据你自己建的工程名找该文件,如果你是按照前面的要求将电路图保存在Debug文件夹下,那么可以直接找到LED.hex文件)。另外,要设置单片机晶振的“CKSEL Fuses”项为“(0100)Int.RC 8MHz”,具体设置如图4-29所示。

完成设置后开始仿真,仿真时要修改R2~R9的阻值为2202,其默认阻值是10k1,如果没有修改,仿真时将看不到灯亮。看看我们的仿真结果,如图4-30所示。

图4-30 仿真结果

2.用Proteus调试

看到仿真后,在Proteus中停止仿真,重新为ATmega16加载仿真文件。如果细心的话,你刚才就会发现,当我们选择LED.hex时还有一个文件可以选择,如图4-31所示。

图4-31 加载LED.cof文件

【思考】 LED.cof和LED.hex两个文件有什么不同呢?你可以在下面的仿真中找到答案。

返回Proteus的主界面,单击Play按钮重新开始仿真,在程序运行后单击Pause按钮让程序停下来,观察一下和刚才做仿真时有什么不同。

在我这里会自动弹出源代码窗口,如图4-32所示,如果没有弹出该窗口,可以通过菜单“Debug”→“3. AVR Source Code -U1”来打开。

图4-32 源程序窗口

双击“PORTA=0x0F;”一行,这样可以在该行添加一个断点。然后再单击仿真按钮中的Stop按钮将仿真停下来,重新运行仿真。

再次运行会发现,程序在刚才的断点处自己停了下来,这样我们可以通过Debug菜单或图4-32中的按钮来完成程序的调试。

在单步调试的过程中,可以通过Debug菜单来看看I/O寄存器值的变化,也可以看看CPU寄存器的变化,如图4-33所示。

图4-33 显示寄存器窗口

有关Proteus对代码的调试过程这里就先介绍这么多,如果有兴趣,可以多尝试一下Debug菜单中的子菜单项,这是软件仿真,不用担心会弄坏什么,放心大胆地去尝试吧!

4.1.4 用AVR Studio仿真调试

使用AVR Studio调试程序也比较简单,在第3章讲解AVR Studio时用示例程序演示过,还记得吧?这里再简单演示一下。

第1步:运行AVR Studio集成环境

通过菜单“开始”→“所有程序”→“Atmel AVR Tools”→“AVR Studio 4”运行AVR Studio。

第2步:打开LED.cof文件

在AVR Studio集成环境下,通过菜单“File”→“Open File…”或工具栏上的Open File(Ctrl+O)按钮,打开前面用ICC AVR编译的LED.cof文件,如图4-34所示。

图4-34 打开LED.cof文件

第3步:保存AVR Studio的工程文件

在图4-34中选择“LED.cof”文件,单击“打开”按钮后会弹出保存AVR Studio工程文件的对话框,如图4-35所示。

图4-35 保存AVR Studio工程文件

【注意】 保存AVR Studio工程时,最好将工程文件保存在ICC AVR工程文件所在的文件夹,也就是Debug文件夹的上一层文件夹中,系统会自动命名为LED_cof.aps。

第4步:选择调试环境

接下来会弹出选择调试环境和单片机的窗口,在该窗口的“Debug platform”列表中选择“AVR Simulator 2”,在“Device”列表中选择“ATmega16”,如图4-36所示。

图4-36 选择调试环境和单片机

第5步:开始调试

在调试环境选择窗口选择好调试环境和单片机ATmega16,单击“Finish”按钮,AVR Studio会自动进入调试模式,如图4-37所示。

图4-37 AVR Studio调试模式

我们可以在主窗口右侧的I/O View窗口选择PORTA端口,跟踪调试DDRA、PINA、PORTA寄存器的状态变化。

也可以用工具栏上的常用调试按钮来进行单步调试。

有关AVR Studio的虚拟环境调试过程在第3章详细介绍过,这里就简单介绍这么多,如果有兴趣,可以自己多尝试、摸索。

4.1.5 AVR Studio与Proteus联合调试

用AVR Studio与Proteus联合调试,与AVR Studio单独调试基本相同,前3步和4.1.4节中介绍的是一样的,只是第4步选择的调试环境有所不同,如图4-38所示。

图4-38 选择Proteus VSM Viewer调试环境

这里选择的调试环境是Proteus VSM Viewer,这是Proteus 7.4以后的版本为AVR Studio设计的一个叫Proteus VSM Viewer的插入式模块,该模块会自动插入到AVR Studio环境中,因而我们在安装和设置的过程中没有做什么特殊的工作(该模块要求AVR Studio的版本是4.14以上)。

接下来单击“Finish”按钮,系统会自动插入一个Proteus VSM Viewer模块,如图4-39所示。

图4-39 在AVR Studio中插入Proteus VSM Viewer模块

如果Proteus VSM Viewer窗口没有出现,可以通过菜单“View”→“Toolbars”→“Proteus VSM”来打开该窗口,如图4-40所示。

【注意】 此时,图4-39所示Proteus模块中的电路并没有加载我们画的电路图,因而要通过Proteus VSM模块中的Open Design按钮来打开电路图,如图4-41所示。

图4-40 View菜单

图4-41 Proteus VSM模块

打开电路图的方法和在Proteus ISIS集成环境中打开电路图的方法一样。通过“Load ISIS Design File”对话框选择我们前面设计的电路图;在保存时,我是要求保存在Debug文件夹下的。

如图4-42所示,这时可以通过右侧的Project列表找到led_test.c文件,双击该文件显示其源代码,然后在main函数中添加断点。另外,要确认Proteus VSM模块中的ATmega16加载的是LED.cof文件。接下来就可以使用工具栏上的调试按钮进行跟踪调试了,调试的跟踪过程如图4-43所示。

图4-42 加载ISIS Design File

在调试时,由于界面的控件比较多,因而有时会感觉电路图太小,不容易看清楚,这时可以使用鼠标拖动Proteus VSM模块到合适的位置,也可以改变模块内部电路图的显示尺寸。

图4-43 调试跟踪过程

【联合调试经验】

在联合AVR Studio和Proteus调试时,有时会出现莫名其妙的问题,这时最好将ICC AVR的源代码文件、编译结果文件、Proteus ISIS文件、AVR Studio工程文件都放在一个目录文件夹下,这样可以减少问题的出现。不过我的经验是Proteus ISIS文件和ICC AVR编译的.cof文件放在同一个目录下就可以联合调试了,我们的实例就是将两者放在Debug目录下仿真调试的。

到此为止,我们完成了自己的第一个单片机系统的设计,程序的编写、编译、仿真、调试等过程。怎么样,感觉很好玩吧!

4.1.6 分析代码

亲手做完了实验,满足了我们的好奇心,那就回头看看我们写的代码:

#include <iom16v.h>
void main(void)
{
    PORTA = 0x0F;
    DDRA = 0xFF;
    while(1);
}

代码很少,除去括号和main函数的声明,仅仅4行代码,其中3行还是从代码生成器中复制过来的。关于main函数的声明我还要说一句,为养成好的编程习惯,要在声明函数时注明返回值和参数类型,省略参数类型void会产生编译警告。

第一句 #include <iom16v.h>看起来很面熟吧,在第3章我们学习ICC AVR开发环境时,在示例程序里有一句 #include <io8515v.h>。同样,iom16v.h也在ICC AVR的安装目录C:\iccv7avr\include目录下,我们可以打开该文件看看。

如图4-44所示,从注释行可以看到,该文件是ATmega16的头文件,所以选择ATmega16单片机时代码生成器会引用该文件。该文件主要声明了一些特殊功能寄存器和特殊功能寄存器的位。例如,在代码中用的PORTA就是在这里声明的:#define PORTA (*(volatile unsigned char *) 0x3B)。这里比较陌生的应该是volatile,这也是C语言编译器的一个关键字,它只是告诉编译器后面的这个值与外界环境有关,容易被改变。这个宏定义就是告诉编译器PORTA寄存器的地址是0x3B。

图4-44 iom16v.h部分代码

根据iom16v.h中对寄存器的宏定义,再看看led_test.c中的两行代码:

PORTA = 0x0F;
DDRA = 0xFF;

也就是对两个寄存器进行赋值,回头再根据我们前面用Proteus和AVR Studio做的调试,会发现,DDRA寄存器控制的是ATmega16单片机A口的输入、输出功能,该寄存器是8位寄存器,每一位刚好对应A口的一个引脚。在A口做输出时,PORTA寄存器是控制A口输出高电平和低电平的,这一点从设定PORTA = 0x0F、只点亮了4个灯可以看出来。

最后就是while(1);语句,这个语句什么也不做,只是让单片机永远在这里循环下去。这是单片机程序的特点,我们会在后面的练习中发现,几乎每个示例程序,在main函数中都会找到while语句的身影。

4.1.7 补充一点发光二极管的知识

实验也动手做过了,程序也分析过了,接下来看看我们用到的一个主要元器件:发光二极管。

发光二极管的英文名称是Light Emitting Diode,简称LED。发光二极管是一种将电能转化为光能的半导体器件,其原理是由镓(Ga)、砷(As)、磷(P)的化合物制成半导体材料,当电子与空穴复合时能辐射出可见光。根据半导体材料的不同,发出光的颜色也不同,如砷化镓二极管发红光、磷化镓二极管发绿光、碳化硅二极管发黄光。根据发光二极管的发光特性,可将发光二极管分为普通单色发光二极管、高亮度发光二极管、超高亮度发光二极管、变色发光二极管、闪烁发光二极管、电压控制型发光二极管、红外发光二极管和激光二极管等。

下面是一些常见发光二极管的图片,如图4-45所示。

图4-45 常见发光二极管

通常情况下,发光二极管的开启电压是2V,反向击穿电压约5V,工作电压为3V左右。通常LED的正向电流为10~20mA,当电流在3~10mA时,其亮度与电流基本成正比,而当电流超过30mA后会导致LED烧坏,因此在使用时必须串联限流电阻以控制通过发光二极管的电流,这也是我们在电路中串联一个2202电阻的原因。图4-46给出了发光二极管的构造和其在电路中的符号。

图4-46 发光二极管的构造和图形符号

发光二极管的引脚有正、负之分,通常支架式发光二极管较长的一根引脚是正极,较短的一根是负极;也可以通过透明的管体观察内部的情况,通常较小一端是正极,较大一端是负极(可观察图4-46中的构造图);还可以通过万用表测电阻的10k挡来测量,具体的操作要看说明资料或加电测试方法。

最初LED用做仪器、仪表的指示灯,后来各种光色的LED在交通信号灯和大面积显示屏中得到了广泛应用,汽车信号灯也是LED光源应用的重要领域。另外,LED灯在室外红、绿、蓝全彩显示屏和匙扣式微型电筒等领域都得到了应用,图4-47就是LED在现实生活中的应用。如今在家用液晶电视、液晶显示器、LED投影仪中都有LED技术的应用。随着科学技术的发展,LED的应用会越来越广泛,我们千万不可小看这个小小的发光二极管。

图4-47 发光二极管的应用

4.2 不仅仅是让它亮起来

我们完成了上一个实例的分析,也了解了一些LED的知识,那么能不能写一段代码,让PortA引脚上的LED像晚上的霓虹灯那样不断地闪烁呢?

4.2.1 如何让发亮的灯闪烁呢

下面我们就用一段代码让前面电路中的LED闪起来,直接在刚才的工程中修改led_test.c文件:

/****************************************************************
* 文件名:led_test.c
* 说 明:实现LED灯闪烁的效果
* 目 标:ATmega16
* 晶 振:1.0000MHz
****************************************************************/
#include <iom16v.h>
//宏定义
#define uint  unsigned int
#define uchar unsigned char
/******************************************************
* 函 数:延时函数,延时timer毫秒(单片机:ATmega16,晶振频率:1.00MHz)
* 参 数:timer要延时的毫秒数
* 返回值:无
******************************************************/
void delay_ms(uint timer)
{
    uchar j = 0;
    while(timer--)
    {
        for(j = 198; j>0; j--)
        {
            ;
        }
    }
}
/******************************************************
* 函 数:主函数,实现LED的闪烁效果
* 参 数:空
* 返回值:无
******************************************************/
void main(void)
{
    DDRA = 0xFF;
    PORTA = 0xFF;
    while(1)
    {
        PORTA = 0x0F;
        delay_ms(100);
        PORTA = 0xF0;
        delay_ms(100);
    }
}

完成代码的编写工作,接下来要做的就是编译、仿真。

编译、仿真的过程就不再重复了,和前面的例子一样。由于我们的代码是在原有的工程中修改的,因而不需要对ICC AVR的工程再做设置,直接编译就可以了。在Proteus的电路中也不需要再对ATmega16做配置,直接仿真就可以看到仿真结果—我们的灯忽明忽暗地闪起来了!

在Proteus中做仿真时,修改晶振的频率观察一下,在不同的晶振频率下仿真效果有什么不同。修改晶振的方法是在图形编辑区双击ATmega16器件,弹出其属性对话框,在属性对话框中修改“CKSEL Fuses”属性,如图4-48所示。可以使用1MHz、2MHz、4MHz、8MHz的晶振,分别看看其仿真效果。

图4-48 修改ATmega16的属性

在我的计算机上的仿真结果是,当晶振设定为1MHz、2MHz时,仿真效果还比较逼真,LED闪烁的频率比较快;而晶振设定为4MHz、8MHz时,仿真速度反而慢下来了。我估计是和计算机的配置有关,因而在程序的注释里修改了晶振的频率为1.0000MHz。

4.2.2 代码分析

动手做完实验,我们来看看这段代码。首先看到的是多了两行 #define语句,这是C语言中的宏定义语句:用uint表示unsigned int,用uchar表示unsigned char。因为在单片机编程中很少用有符号类型,多数是无符号类型,为了后面书写方便,这里先声明一个宏定义。

看完宏定义,我们跳过delay_ms函数看主函数main,主函数中的变化是将PORTA = 0x0F; 放在while(1)语句的括号里,后面跟了一个delay_ms函数的调用,对PortA做了第二次赋值PORTA = 0xF0;,又一次调用了delay_ms函数。

从函数的名字上可以理解delay_ms函数:延时1毫秒。对,就是这个意思,函数的参数刚好是要延时的毫秒数。这就要求我们在以后的程序设计中,在定义函数和变量时要尽量定义些有意义的名称,以方便程序的阅读。

主函数的意思是先对PortA口的0引脚输出低电平,延时100ms后再对该引脚输出高电平,再延时100ms,周而复始地做这个工作。这样我们就看到灯亮一下再灭一下,达到了闪灯的效果。

分析完程序的工作流程,再分析delay_ms函数:

/******************************************************
* 函 数:延时函数,延时timer毫秒(单片机:ATmega16,晶振频率:1.00MHz)
* 参 数:timer要延时的毫秒数
* 返回值:无
******************************************************/
void delay_ms(uint timer)
{
    uchar j = 0;
    while(timer--)
    {
        for(j = 198; j>0; j--)
        {
            ;
        }
    }
}

该函数用了两个循环语句,外部是while(timer--)语句,内部是for(j=198; j>0;j--)语句。

我们先分析while(timer--)语句,想想这和while(--timer)是不是一样呢?

用在这里区别就比较大了。while(timer--)是先判断timer的值是否为真(是否非0),再对timer的值自减1;而while(--timer)是先对timer的值自减1,再判断timer的值是否为真。

别的值都还好说,这里有个临界值的问题,如果timer的初值是0,那区别就大了。while(timer--)判断timer的值是0后就跳出循环,也就是一次也不执行;而while(--timer)就不同了,timer是无符号整型,0-1=65535(ICC AVR编译环境中,int类型占2字节),也就是要再循环65535次,区别是不是很大呢?

接下来的for语句,内部是一个空语句,也就是要执行198次循环,做这种空操作,这是在单片机程序中经常用到的一种非精确延时的方法。但是为什么要用198这个值而不用别的值呢?别着急,下面我们通过调试分析找出答案。

4.2.3 调试分析

要分析198这个值,就要用AVR Studio的调试功能。因为是在刚才的ICC AVR工程中直接修改的,这里在AVR Studio中打开前面保存的工程LED_cof.aps就可以了(通过菜单Project”→“Open Project”打开)。

打开工程后,通过菜单“Debug”→“Select Platform and Device”修改仿真环境,这次仿真环境选择“AVR Simulator”,目标单片机还是“ATmega16”,如图4-49所示。

图4-49 选择AVR Simulator仿真ATmega16

选择仿真环境后单击“Finish”按钮,系统自动开始仿真,程序停留在main函数的第一句DDRA=0xFF处,如图4-50所示。

图4-50 仿真初始状态

在这里注意观察Processor窗口的列表中有Frequency项,是在仿真环境中系统的晶振,默认频率是4.0000MHz,而我们要用的是1.0000MHz,因而要修改一下晶振频率。通过菜单“Debug”→“AVR Simulator Options”打开配置仿真环境对话框,如图4-51所示。

图4-51 配置仿真环境对话框

【注意】 在没有开始仿真时,AVR Simulator Options菜单项是灰色的,不能使用,因而如果系统没有自动运行仿真,我们要通过工具栏的Start Debugging按钮先运行仿真,然后再去Debug菜单查找该菜单项。

完成这些设定后,还要在程序中添加两个断点,也就是让程序运行到断点的地方停下,方便我们查看关心的值。添加断点的方法还记得吧:将光标停靠在要加断点的语句上(在该行单击鼠标左键),然后单击工具栏上的Toggle Breakpoint按钮。添加断点的位置如图4-50所示。

最后,就可以通过工具栏上的Run(F5)按钮让程序直接运行到第一个断点处,这时观察Processor窗口的列表中Stop Watch项值的变化,如图4-52所示。

图4-52 调试工程

可以看到Stop Watch项的值是1070.001s,这是程序运行到此处所用的时间,单位是微秒,也就是1.07ms。我们全速运行程序()让程序接着往下走,在下一个断点停下来,再看Stop Watch的值是101293.000s,即101.293ms,下面换算一下时间差:101.293-1.07=100.223ms,两个断点中间刚好是调用函数delay_ms(100),也就是说延时函数的实际延时是100.223ms,误差是0.2%。

为了检验我们的设定值,可以在delay_ms函数中修改for语句中j的初始值,然后调试、仿真看延时时间的误差。

另外,我们再看看不同数据类型对程序运行效率的影响。修改delay_ms函数中变量j的数据类型,改为uint类型,再调试观察延时时间,是不是在别的语句都不变的情况下时间变长了呢?

一个小小的延时函数告诉我们,在写单片机程序时,如果能用char类型的变量就不要用int类型的,要尽量提高程序执行的效率,减小程序所占空间。

【与51单片机做比较】

如果你学习过51单片机,可以在Keil C51中写一个同样的延时函数,仿真一下,比较两者的差别。我仿真的结果是,在Keil C51中的延时函数是:

void delay_ms(uint timer)
{
    uchar j = 0;
    while(timer--)
    {
        for(j = 124; j>0; j--)
        { ; }
    }
}

注意,变量j的初始值是124,而51单片机(AT89C51)的晶振频率是12MHz。这也是延时1ms的一个延时函数,而ATmega16的晶振频率是1.000MHz,变量j的初始值是198。这就是AVR单片机比51单片机运行速度快的表现。

4.3 做些程序的改动

4.3.1 改动延时时间

下面做点小小的改动,看仿真结果会有什么变化。首先修改main函数的延时时间:

void main(void)
{
    DDRA = 0xFF;
    PORTA = 0xFF;
    while(1)
    {
        PORTA = 0xFF;  //将0x0F修改为0xFF
        delay_ms(500);  //修改时间100为500
        PORTA = 0x00;  //将0xF0修改为0x00
        delay_ms(100);
    }
}

修改完成后,编译、仿真,观察Proteus的仿真结果,会发现LED灯不灭了,但有点闪烁(如果不闪烁,也许和计算机的配置有关,可以调整第二个delay_ms的参数)。我们进一步修改:

void main(void)
{
    DDRA = 0xFF;
    PORTA = 0xFF;
    while(1)
    {
        PORTA = 0xFF;
        delay_ms(800);  //修改时间100为800
        PORTA = 0x00;
        delay_ms(10);  //修改时间100为10
    }
}

再次编译、仿真会发现,LED变为常亮,也不闪烁了。这就是我们的眼睛欺骗了我们,看到的不一定是真实的。其原理和放电影的原理是一样的,LED短暂的熄灭会有余辉,而我们的眼睛又有视觉暂留作用,因而我们看到的LED是常亮的。为什么要做这个实验呢?到第5章你就知道了。

4.3.2 做个众人皆知的跑马灯

学习完上面的LED简单灯闪烁的程序后,我们来学习跑马灯(也叫流水灯)程序,这是几乎所有单片机入门者都必学的一个示例程序。和上面的示例一样,还用图4-22中的电路。下面我们就写一段程序来让跑马灯跑起来吧!

/*****************************************************
* 文件名:main.c
* 说 明:实现PortA口的8个LED依次点亮,产生跑马灯的效果
* 目 标:ATmega16
* 晶 振:1.0000MHz
*****************************************************/
#include <iom16v.h>
#include "main.h"
#include "delay.h"
void main(void)
{
    uchar i = 0;  //定义局部变量
    DDRA = 0xFF;  //端口初始化,PortA口做输出口
    PORTA = 0x00;  //初始化PortA口输出低电平
    while(1)
    {
        for(i=0; i<8; i++)
        {
            PORTA = (1<<i);
            delay_ms(200);
        }
    }
}
/*****************************************************
* 文件名:main.h
*****************************************************/
#ifndef _MAIN_H
#define _MAIN_H
#define uint unsigned int
#define uchar unsigned char
#endif
/******************************************************
* 文件名:delay.c
* 说 明:延时函数
* 目 标:ATmega16
* 晶 振:1.0000MHz
******************************************************/
#include "main.h"
/******************************************************
* 函 数:延时函数,延时timer毫秒(单片机:ATmega16,晶振频率:1.00MHz)
* 参 数:timer要延时的毫秒数
* 返回值:无
******************************************************/
void delay_ms(uint timer)
{
    uchar j = 0;
    while(timer--)
    {
        for(j = 198; j>0; j--)
        {
            ;
        }
    }
}
/******************************************************
* 文件名:delay.h
******************************************************/
#ifndef _DELAY_H
#define _DELAY_H
void delay_ms(uint timer);
#endif

先在ICC AVR编译环境下创建一个工程,命名为Horse_led.prj;通过“Project”→“Options”菜单配置该工程,在配置工程的Paths属性页选择输出文件的路径为Debug文件夹(在新建工程的文件夹下建立一个新的文件夹,命名为Debug);在Target属性页选择目标单片机为ATmega16,具体操作可以参考4.1.1节(如果前面的示例程序是完全按照4.1.1节的操作过程操作的,那么这里仅仅设置输出路径就可以了)。

然后创建4个文件main.c、main.h、delay.c、delay.h,文件内容如上。4个文件编写完成后,将它们都添加到Horse_led工程中;将main.c、delay.c添加到Files文件夹,将main.h、delay.h添加到Headers文件夹(添加过程参考4.1.1节的操作过程)。

接下来就是编译该工程了。编译无误后,我们准备用Proteus进行仿真。将上一个示例程序中的电路图直接复制到本示例程序工程目录的debug文件夹中。然后在Proteus集成环境下打开该电路文件,设置ATmega16的属性,设置晶振为(0001)int.RC 1MHz,Program File为刚编译的Horse_led.hex文件。然后运行仿真,查看仿真结果。

【程序分析】

做完仿真实验,我们就要分析一下上面的程序了。这个程序段用到了以下知识点:头文件的引用、条件编译、程序模块化等。

先看看main.c文件中对头文件的引用方式。#include <iom16v.h>和 #include "main.h"是两种对头文件的引用方式,使用< >引用的头文件,编译器会先从软件安装的文件夹开始搜索,即iccv7avr\include目录;而使用""引用的头文件,编译器会先从工程所在的文件夹开始搜索,找不到该文件会自动到软件安装的目录搜索。因而,我们可以将< >的引用方式写为""的引用方式,反过来则不能代替,读者可以尝试着修改一下。但根据编程习惯,我们还是沿用引用库文件时使用< >的引用方式;引用我们自己写的头文件时,用""的引用方式。

再看看条件编译语句,每个头文件中都使用了#ifndef…#define…#endif的语句,该语句是条件编译语句,其功能是防止重复引用(在我们分析iom16v.h文件时,也有这样的宏定义语句)。

最后要说的就是程序的模块化了,这里我们不仅把程序分解为函数,而且还将函数放在不同的文件中,这样做的好处是可以复用代码。例如,在后面再次用到delay_ms函数时,直接将delay.h、delay.c复制到该工程中,然后在要用的文件中引用delay.h文件,直接调用delay_ms函数就可以了。而且也便于项目管理和代码的阅读,如果是代码量大的复杂工程,根据函数功能分为不同的文件,这样管理起来很方便,阅读时也很清晰。

在以后的程序编写过程中,我们要养成良好的习惯,多写注释语句,将程序模块化,定义函数、变量要有意义,便于自己以后阅读,也便于别人阅读维护。

4.3.3 复习LED示例工程

还记得讲解第3章ICC AVR示例程序中的LED工程吗?记得该工程的代码吗?找到该工程的LED.c文件,将要用的内容复制到main.c文件下,修改一下代码。

void LED_On(int i)
{
    PORTA = ~BIT(i); //该函数需要引用头文件<macros.h>
    delay_ms(300);  //修改Delay函数为delay_ms函数
}
void main(void)
{
    int i;
    DDRA = 0xFF;  /* 修改DDRB为DDRA */
    PORTB = 0xFF;  /* 修改PORTB为PORTA */
    while (1)
    {
        /* forward march */
        for (i = 0; i < 8; i++)
            LED_On(i);
        /* backward march */
        for (i = 8; i > 0; i--)
            LED_On(i);
        /* skip */
        for (i = 0; i < 8; i += 2)
            LED_On(i);
        for (i = 7; i > 0; i -= 2)
            LED_On(i);
    }
}

注意,我做了一些修改,这里没有使用自带的Delay函数,而是在LED_On函数中使用我们自己定义的delay_ms函数。由于在LED_On函数中使用了BIT函数,因而要在main.c中引用macros.h头文件,在#include <iom16v.h>后添加一句:#include <macros.h>。电路中LED连接的是PortA口,因而要将LED_On中的PORTB修改为PortA,将main函数中的DDRB修改为DDRA、PORTB修改为PORTA。将main.c文件中原来的main函数删除,接下来就可以编译、仿真,看看结果如何了。

由于电路是通过电阻接地的,因而仿真的结果是所有的灯都亮着,只有一个灯在做亮、灭的变换。那么有没有办法修改一下程序,让这个示例程序的显示效果和我们自己写的那个程序的效果类似呢(让亮的灯做跑马灯)?

我的办法是修改LED_On函数中的语句PORTA = BIT(i);,你也可以修改一下,想想为什么这样简单的修改之后就可以了呢?

4.4 能不能玩点花样呢

4.4.1 想想你能画什么

想想看,你能用发光二极管模拟什么呢?我想到的是连接一个数字出来,用7个LED表示数字的7个段。下面我们就根据自己的想象来画电路,如图4-53所示。

图4-53 模拟的数字

电路根据自己的想象来画,我只是给出一个建议图,也许你画出的电路比我画的更像数字显示电路。

4.4.2 用代码显示数字

电路设计完了,就考虑如何用程序去实现我们要显示的数字0、1、2、3……考虑一下,是不是很简单呢?其实就是根据数字要显示的段,将需要点亮的LED点亮,其他LED不点亮就可以了。

/*****************************************************
* 文件名:main.c
* 说 明:实现PortA口控制7个LED,显示0、1、2、3四个数字
* 目 标:ATmega16
* 晶 振:1.0000MHz
******************************************************/
#include <iom16v.h>
#include "main.h"
#include "delay.h"
void main(void)
{
    DDRA = 0xFF; //端口初始化,PortA口作为输出口
    PORTA = 0x00; //PortA输出低电平
    while(1)
    {
        PORTA = 0x04; //引脚PA2为高电平,其他引脚输出低电平
        delay_ms(900); //延时900ms
        PORTA = 0x6D;
        delay_ms(900);
        PORTA = 0x18;
        delay_ms(900);
        PORTA = 0x48;
        delay_ms(900);
    }
}

以数字0为例说明代码,要想显示数字0,就要控制电路使中间的LED不亮,而其他6个LED都点亮。根据电路的画法,中间的LED(D7)是由PA2控制的,而要控制该LED不亮就要在其引脚输出高电平,其余输出低电平,因而写为PORTA = 0x04。分析过数字0的原理,我想你也就明白1、2、3的显示原理了吧?同样的道理你还可以扩展到数字9的显示。

做完这些实验,感觉很好玩吧。其实在LED的应用上还有很多可以模拟。例如,你可以用红、绿、蓝三色LED模拟交通灯的程序,具体实现时可以从简单的入手,开始就用PA0、PA1、PA2连接三个颜色的LED灯,然后让其按不同的时间亮;要注意,其中黄灯亮的时间比较短;做完简单的实验,可以仿真丁字路口交通灯的实验,用三组三色LED灯实现,也就是9个LED灯来实现。另外,Proteus也为我们专门设计了交通灯模块,该模块是TRAFFIC LIGHTS。

【思考】 根据交通灯电路(见图4-54),设计一个丁字路口的程序。

图4-54 交通灯电路

这是交通灯电路最简单的连接方法,图4-54中用到的交通灯器件叫TRAFFIC LIGHTS。我们可以先实现该电路的程序后,再修改电路,使黄灯共用一个端口,而所有的红、绿灯分别有端口控制,考虑一下程序应该如何实现?

【补充】

在本章的所有练习中,我们使用的都是ATmega16的PortA口,那么有关PortA口有什么特性呢?还要从ATmega16的数据手册中找出答案。

有关数据手册的下载方法,我们在4.1.2节的最后介绍了两种方法,这里以从Atmel官方网站下载的数据手册为准。

图4-55是ATmega16数据手册的中文版,电路元器件的数据手册大多是以pdf的文件

图4-55 ATmega16数据手册

格式提供的,当然ATmega16的也不例外。浏览pdf文件要用专用的pdf文件浏览器,我这里使用的是Foxit Reader浏览器,读者可以根据自己的喜好选择相应的浏览器。

数据手册提供一个书签(也就是文档的索引),我们可以根据该书签跳转到感兴趣的章节进行阅读。因为整个文档有330页,我们不可能一下子读完,即使读完了,也不可能都记住里面讲些什么内容,因而可以将其作为ATmega16的字典,用到什么的时候,就去查找对应的章节。

我们通过数据手册的书签可以先浏览一下产品特性,了解一下单片机ATmega16的特性(若图中看不到书签,可以通过Foxit Reader的菜单“查看”→“书签”来打开书签显示)。看过之后会发现,这也是我们前面介绍ATmega16时主要提起的内容。然后再看其引脚配置,对其引脚有个整体认识。最后就是找本章使用的端口A的介绍部分。我们通过书签的“综述”→“引脚说明”→“端口A(PA7…PA0)”找到对应的章节,在文档的第4页,文档中是这样描述的:

端口A作为A/D转换器的模拟输入端。

端口A为8位双向I/O口,具有可编程的内部上拉电阻。其输出缓冲器具有对称的驱动特性,可以输出和吸收大电流。作为输入使用时,若内部上拉电阻使能,端口被外部电路拉低时将输出电流。在复位过程中,即使系统时钟还未起振,端口A也处于高阻状态。

这段对端口A简单的描述告诉我们这样一些信息:① PA口有两种功能,即可以作为A/D转换器的模拟输入端,也可以作为普通的I/O口;② 作为I/O口使用时是双向的,而作为输入端使用时内部有上拉电阻(有关输入口的使用,我们后面再做介绍);③ 系统复位时,端口A处于高阻状态。

为了进一步寻找PA口作为普通I/O口的使用,我们在书签中找对应的章节看看。在书签中找到“I/O端口”一节,在其内部重点看“介绍”→“作为通用数字I/O的端口”、“I/O端口寄存器的说明”→“端口A数据寄存器”、“方向寄存器”等几部分内容,具体内容如图4-56所示。

图4-56 I/O端口介绍

在“I/O端口”→“介绍”部分,有这么一句话:

“输出缓冲器具有对称的驱动能力,可以输出或吸入大电流,直接驱动LED。”

这就是本章我们在电路图中使用PA口直接驱动LED的理论依据。

在“I/O端口”→“作为通用数字I/O的端口”→“配置引脚”部分,有这样的描述:

“每个端口引脚都具有三个寄存器位:DDxn、PORTxn和PINxn,……

DDxn用来选择引脚的方向。DDxn为1时,Pxn配置为输出,否则配置为输入。

……

当引脚配置为输出时,若PORTxn为1,引脚输出高电平(1),否则输出低电平(0)”。

这些内容就是我们编写代码的理论依据,通过我们的实验也刚好验证了以上理论。

通过对数据手册的简单阅读,我想你已经认识到数据手册对我们单片机设计人员来说有多么重要了吧?因而在以后的学习工作过程中,遇到一款新的器件,我们首先要看的就是其数据手册。不过很多器件的数据手册都是英文版的,这就要求我们在学习ATmega16时要将中文版和英文版结合起来看,这样也能锻炼我们看英文数据手册的能力。