ATMEGA16(L)单片机的主要特点与内部结构
ATMEGA16(L)单片机的内部结构如图1所示。其主要组成部分及其特点介绍如下: ● 内部程序存储器(Flash)。ATMEGA16(L)内部含有16Kb的Flash存储器,支持
ISP(在系统编程)和IAP(在应用编程)。 ● 内部数据存储器(SRAM)。ATMEGA16(L)具有1K字节的SRAM,可用于存放变量数
据。 ● EEPROM。ATMEGA16(L)内置了512字节的EEPROM存贮器,可以在系统掉电时保存
一些用户信息。
● I/O接口。ATMEGA16具有32个I/O口,分为4组(PA、PB、PC、PD),每组8
位。每个I/O口可负载40mA的电流,总电流不超过200mA。
● 定时/计数器。ATMEGA16(L)有两个可分频的8位定时/计数器和一个可分频的16
位定时/计数器,带输入捕获、比较输出功能,有四个通道的PWM(脉宽调制)。 ● 中断单元。ATMEGA16(L)有20个中断源,每个中断有独立的中断向量入口地址,
所有的中断事件都有各自的使能位,可以根据需要使能或屏蔽。 ● 定时及控制单元。此单元确定系统的时钟和复位逻辑。ATMEGA16支持多种时钟
方式,有外部晶振、外部RC振荡、内部时钟和内部RC振荡等。ATMEGA16内置上电复位电路、可编程低电压检测(BOD)复位电路和带独立振荡器的看门狗,支持上电复位、外部复位、看门狗复位和低电压检测复位等复位源。 ● 模数转换器。ATMEGA16内置8通道10位精度的逐次逼近式模数转换器(ADC).
支持单端和双端差分信号输入,内含增益可编程运算放大器。 ● SPI口。ATMEGA16的SPI口在程序下载时可以用于控制对单片机的编程,在程序
运行时是一个全双工的SPI端口,此端口可以SPI主方式运行,也可以SPI从方式运行,支持中断方式工作,比软件模拟SPI接口方便而且速度更快。
● USART。ATMEGA16的USART(通用同步/异步收发器)是一个全双工的部件,有单独
的波特率发生器,可以用较低的晶振频率产生较高的波特率,支持9位工作方式和多机通信。USART不仅可以异步工作,还可以全双工地以同步方式工作,在同步方式工作时可以有很高的通信速率。 ● 其他。如模拟比较器、二线总线(TWI)I2C等。
图1
ATMEGA16的主要管脚及其作用
ATMEG16L的管脚图(封装为双列直插DIP)如图2所示。
图2
ATMEGA16L的管脚基本上可以分为5部分,即PA、PB、PC、PD以及其它特殊用途端口(如电源VCC、地GND、复位RESET和晶振接口XTAL等)。
电源与复位电路
ATMEGA16L电源(VCC)的电压范围为2.7V~5.5V。低电压(如3.3V)下,ATMEGA16L的晶振最高为8MHz;供电电压在5V时,单片机可在16MHz的晶振下正常工作。
RESET复位引脚上的低电压引发外部复位,单片机系统恢复到初始状态。注意,该引
脚必须被拉低至少2个晶振时钟周期才能有效复位。
复位电路可以由简单的RC电路构成:
图3
该复位电路的工作原理如下: 1) 系统上电复位:在系统上电时,通过电阻R1向电容C1充电,当C1两端的电
压未达到高电平的门限电压时,Reset端输出为低电平,系统处于复位状态;当C1两端的电压达到高电平的门限电压时,Reset端输出为高电平,系统进入正常工作状态。
2) 用户手动复位:当用户按下复位键S1时,C1两端的电荷被泻放掉,Reset端
输出为低电平,系统进入复位状态,再重复以上的充电过程,系统进入正常工作状态。
两级非门电路(7404)用于去抖动和波形整形;另外,我们可以通过调整R1和C1的参数来调整复位状态的时间。
时钟电路
所谓时钟,实际上就是以一个特定频率连续不断出现的方波。单片机在每一个方波的上升沿执行指令。一条指令如果能在一个时钟周期(即方波的一个周期)内执行完,称为“单周期指令”,否则称为“多周期指令”。时钟与指令执行的关系如图4。
图4
如图所示,指令1和指令3均在一个时钟周期内指行完毕,称为“单周期指令”。指令2则占用了两个时钟周期,为“多周期指令”。
时钟源
外部晶体/陶瓷振荡电路 外部低频晶体振荡器 外部RC振荡器 内部RC振荡器 外部时钟
下面是各种时钟源的连接方法:
图5 外部晶体振荡电路作为时钟源 图6 外部RC振荡电路作为时钟源
图7 外部时钟作时钟源 图8 本书中系统采用的时钟源
I/O端口
AMEGA16总共有32个I/O口,分别是PA(8位)、PB(8位)、PC(8位)和PD(8位)。这32个I/O口都可以做为双向的输入输出端口使用。
PA、PB、PC、PD这些端口,即可以作为普通的I/O口使用,又有其附加功能。
PA口:PA口可做为8路ADC使用。这8路ADC的供电为AVCC(30管脚),地为GND(31管脚),基准电压为AREF(32管脚)。
PB口:PB7~PB4是SPI接口。PB0和PB1为计数器0和计数器1的输入端口,所谓计数器,即在规定时间内对输入信号的脉冲个数进行计算的模块。
PC口:PC0~PC1是双线串行总线接口(two-wire serial bus),亦可作为I2C接口。PC2~PC5是JTAG接口,PC6~PC7为定时器外部时钟源。
PD口:PD0~PD1为USART口。PD2~PD3为外部中断0和外部中断1的输入。PD4~PD7是定时器/计数器的比较输出及输入捕获端。
输入/输出端口的使用
AVR单片机的I/O端口为标准双向口,每个端口对应三个寄存器,即DDRX、PORTX和PINX(X为相应端口号,如对PA口来说,X为A)。各端口功能配置如下:
DDRXn PORTXn I/O 上拉 关闭 打开
备 注
三态(高阻)
提供弱上拉,低电平必须由外电路拉低,PXn脚输出电流 输出0 输出1
0 0 输入 0 1 输入
1 0 输出 1 1 输出
关闭 关闭
下面讨论如何通过对寄存器的操作来控制I/O口。(注意,在GCC中,欲对I/O口进行控制,需在程序开头部分包含“avr/io.h”的头文件,即#include (1) DDRX DDRX为端口方向寄存器(Data Direction Register of port X)。当DDRX的某一位置1时,相应管脚作为输出使用,反之,当DDRX的某一位清0时,对应的管脚作为输入使用。 例: DDRB=0xF0; 此语句将PB端口的PB0~PB3位设为输入而PB4~PB7位设为输出。 (2)PORTX PORTX为端口数据寄存器。 如果引脚设为输出时,对PORTX进行写操作即改变引脚的输出值。例如: DDRA=0xFF; // 端口A设为输出 PORTA=0xAB; // 端口A输出为10101011 如果引脚设为输入,PORTX的数据决定相应的端口的引脚是否打开上拉功能。例如: DDRA=0x00; // 端口A置为输入 PORTA=0xF0; // 端口A的0~3位不设上拉,在没有输入的情况下处于高阻态; // 端口A的4~7位设上拉,在没有输入的情况下处于高电平。 (3)PINX PINX是相应端口的输入引脚地址,如果希望读取引脚的逻辑电平值,一定要读取PINX而不是PORTX。应注意的是,PINX是只读的,不能对其赋值。 例: DDRA=0x00; // 端口A置为输入 result=PINA; // 读取A口逻辑电平值,赋给result 上面的例子均是对AVR单片机的I/O端口进行“字节操作”,即以“字节”为单位读取输入值或赋给输出值。有些时候,我们仅对某一“比特”感兴趣,这时我们可以使用GCC中“位操作”的函数(注意,以下函数仅用于I/O端口,对变量的“位操作”应通过与或逻辑运算来实现)。 (1) void sbi (uint8_t port, uint8_t bit); 此函数可以将端口寄存器PORTX中某一位置1,而其余位不变。变量类型uint8_t表示8位无符号整型变量,使用此类型需在程序开头包含“inttypes.h”头文件。 例: sbi (PORTA,3); // 将PORTA中第3位置1; (2)void cbi (uint8_t port, uint8_t bit); 此函数与sbi相反,将端口寄存器PORTX中某一位置清0,而其余位不变。 例: cbi (PORTA,5); // 将PORTA中第5位清0; (3)uint8_t bit_is_set(uint8_t port,uint8_t bit); 此函数检验PORTX中某一位置是否为 ‘1’,是则返回一个非零值,否则返回 ‘0’。 (4)uint8_t bit_is_clear(uint8_t port,uint8_t bit); 此函数检验PORTX中某一位置是否为 ‘0’,是则返回一个非零值,否则返回 ‘0’。 在实际使用中,我们常常需要不断对某一位进行查询,直至其满足一定条件后再继续执行下一步程序。GCC中提供了相应函数: (5)loop_until_bit_is_set(uint8_t pinx, uint8_t bit); 从函数名就可以很清楚知道这个函数的作用:不断进行空循环直至PINX中的某一特定位被置 ‘1’。 例: loop_until_bit_is_set(PINA, 3); PORTB=PINA; 在上例中,单片机不断检测PA3是否为 ‘1’,如果不是,则不断进行空循环直至满足条件才继续执行下一步。也就是说,如果PA3永远不为 ‘1’,则PORTB=PINA这条语句永远不会被执行。这个函数方便易用,在本书中多次出现。但读者请注意,如果条件得不到满足,程序就会进入死循环。 (6) loop_until_bit_is_clear(uint8_t pinx, uint8_t bit); 从(5)的用法很容易推导出上面这个函数的作用。 常量与变量及其占用的存储空间 ATMEGA16自带1Kbytes SRAM、16K bytes Flash、512bytes EEPROM,变量可以选择存放在SRAM或者EEPROM中,而常量可以选择存放在SRAM或者Flash中。 标准C的变量类型一般都适用于单片机的C语言,如char, float, double, unsigned int等。经常使用的整型类型在头文件 类型名 长度(单位:byte) 数值范围 int8_t 1 (8 bits) -128~127 uint8_t 1 (8 bits) 0~255 int16_t 2 (16 bits) -32768~32767 uint16_t 2 (16 bits) 0~65535 int32_t 4 (32 bits) -2147483648~2147483647 uint32_t 4 (32 bits) 0~4294967295 int64_t 8 (64 bits) -9.22*10^18~9.22*10^18 uint64_t 8 (64 bits) 0~1.844*10^19 建议使用这些整型类型名,因为这些整型类型占用多少位一目了然,比char、int、long等类型名更直观,而且其命名十分科学和有规律,容易记忆和使用。 在SRAM中定义变量和常量 如果在变量或常量的定义前不加任何参数,编译器默认将该变量或常量存放于SRAM中。 例: *在SRAM中定义一变量 uint8_t value=8; 在SRAM中创建一个1byte的变量value,并赋值为8。 *在SRAM中定义一常量 const uint16_t value=60000; 在SRAM中创建一个2 bytes的常量value,并赋值为60000。 在Flash中定义常量 AVR单片机中Flash本来是用来做程序存储空间的,但我们可以利用其存储容量大的特点,在剩余足够空间的前提下,将一些在使用中没有必要改变的数值或者字符串等存放在Flash中。当然,你也可以将其存放在SRAM中,但一来Flash比SRAM的空间大好几倍,二来这些数值或字符串在使用过程中没有必要改动,存放在Flash中会比存放在SRAM更合适。 首先,程序开头部分应包含头文件 Flash中的类型名 长度 对应于SRAM中的类型名 prog_char 8 bits uint8_t prog_int 16 bits int16_t prog_long 32 bits int32_t prog_long_long 64 bits int64_t 在Flash中定义和读取单个数值: 在Flash中创建一个常量:prog_char TEST=10; 读取该常量:int8_t result=PRG_RDB(&TEST); 在Flash中定义和读取数组: 在Flash中创建数组:prog_char TEST[10]={0,1,2,3,4,5,6,7,8,9}; 读取TEN[5]:char result=PRG_RDB(&TEST[5]); 在Flash中定义和读取字符串: 在Flash中创建字符串: char *Setence=PSTR(“Hello, world!”); 指针*Setence指向创建的字符串的开头,即字符 ‘H’的地址。 读取字符串中某个字符:char letter=PRG_RDB(Setence+4); 执行此语句后,letter为字符 ‘o’。注意,Setence本身为地址,不必向前面两例一样用&取地址。(Setence+4)表示字符串中第5个字符(Setence本身指向第1个字符,所以加4后指向第5个字符)。 在EEPROM中读写变量 在某些场合下,我们需要对用户设置的信息进行掉电保护,这时就需要借助EEPROM等非易失性存储器。ATMega16内部集成了512 bytes的EEPROM,通过GCC提供的一些函数,我们可以很方便地对其进行读写(事实上,ICC、CodeVision等AVR单片机的C语言编译器都提供类似的函数,如果你使用的不是GCC,请参阅所使用编译器的Help文件)。 往EEPROM中写入数值: 函数eeprom_write_byte(uint16_t addr, uint8_t val)可往EEPROM的addr地址中写入值为val的1 byte信息。 例:eeprom_write_byte(0x00,0x05);// 往EEPROM的0x00地址写入数值5。 从EEPROM中读入数值: 函数uint8_t eeprom_read_byte(uint16_t addr)可从EEPROM的addr地址中读取1 byte信息。 例:uint8_t i=eeprom_read_byte(0x00);// 从EEPROM的0x00地址读取数值。 检测EEPROM是否忙: 函数eeprom_is_ready()可以检测EEPROM是否已经完成了上一次的读写任务并准备好接受下一次的读写请求,未准备好则返回0,否则返回非零值。 中断 ATMEGA16的中断源 中断源是指任何引起单片机中断的事件。不同型号的AVR单片机,其中断源的数量是不同的,ATMEGA16具有20个中断源和一个复位中断。所有中断源都有独立的中断使能位,当相应的使能位和全局中断使能位都置“1”的情况下,中断才可以发生,相应的中断服务程序才会执行。 中断源 RESET INT0 INT1 TIMER2 COMP TIMER2 OVF TIMER1 CAPT TIMER1 COMPA TIMER1 COMPB TIMER1 OVF TIMER0 OVF SPI, STC USART, RXC 复位中断 中断定义 在GCC中的中断名 SIG_NAME 外部中断请求0 SIG_INTERRUPT0 外部中断请求1 SIG_INTERRUPT1 定时/计时器2比较匹配 SIG_OUTPUT_COMPARE2 定时/计时器2溢出 SIG_OVERFLOW2 定时/计时器1捕获事件 SIG_INPUT_CAPTURE1 定时/计时器1 比较匹配A SIG_OUTPUT_COMPARE1A 定时/计时器1 比较匹配B SIG_OUTPUT_COMPARE1B 定时/计时器1 溢出 SIG_OVERFLOW1 定时/计时器0 溢出 SIG_OVERFLOW0 SPI传输完成 SIG_SPI USART接收完一个字节 SIG_UART_RECV USART,UDRE USART数据寄存器空 SIG_UART_DATA USART,TXC USART发送完一个字节 SIG_UART_TRANS ADC ANA_COMP TWI INT2 TIMER0 COMP SPM_RDY 内置模/数转换器转换完成 SIG_ADC 模拟比较器 SIG_COMPARATOR 双线串行总线接口 SIG_2WIRE_SERIAL 外部中断请求2 SIG_INTERRUPT2 定时/计时器0 比较匹配 SIG_OUTPUT_COMPARE0 存储程序空间准备好 SIG_SPM_READY EE_RDY EEPROM准备好接受读写 SIG_EEPROM_READY 对中断进行操作 GCC的中断操作,是通过关键字 “SIGNAL”和“INTERRUPT”实现的(注意,两个关键字都是大写),形式如下: SIGNAL(SIG_NAME) { 中断服务程序; /*在此中断服务程序执行中,其他中断被屏蔽,即此中断不会被其他中断所打断,此中断服务程序执行完毕,其他中断才有可能生效*/ } INTERRUPT(SIG_NAME) { 中断服务程序; /*在此中断服务程序执行中,其他中断仍有效,即此中断服务程序有可能被其他中断所打断。*/ } 在使用SIGNAL和INTERRUPT这两个关键字之前,一定要记得包含 图9 /* 程序说明:如图9所示,PB口接了八个LED,PD2(外部中断0)和PD3(外部中断1)接了两个开关。此程序的作用是:按下PD2开关,LED灯亮;按下PD3开关,LED灯灭。灯的亮灭状态在开关闭合瞬间改变。*/ #include #include SIGNAL(SIG_INTERRUPT0) // 外部中断0的中断服务子程序 { PORTB=0x00; // PB输出低电平,LED上加有电压VCC,灯亮 } SIGNAL(SIG_INTERRUPT1) { PORTB=0xFF; // PB输出高电平,LED阳级和阴级的电势差接近零,灯灭 } int main(void) { PORTB=0x00; DDRB=0xFF; //初始化端口B为输出 PORTB=0x00; DDRD=0x00; GICR=((1< MCUCR中的ISC11和ISC10用来控制INT1的中断触发条件: ISC11 ISC10 说明 0 0 INT1上的低电平产生中断请求 0 1 INT1上的电平变化(从高到低或者从低到高)产生 中断请求 1 0 INT1上的下降沿产生中断请求 1 1 INT1上的上升沿产生中断请求 MCUCR中的ISC01和ISC00用来控制INT0的中断触发条件: ISC01 ISC00 说明 0 0 INT0上的低电平产生中断请求 0 1 INT0上的电平变化(从高到低或者从低到高)产生 中断请求 1 0 INT0上的下降沿产生中断请求 1 1 INT0上的上升沿产生中断请求 定时器0 (Timer0) 定时器(Timer)就像一个闹钟, 只不过它在规定时间到了以后是产生溢出信号(Overflow)而不是闹铃。 对Timer0来说,这个“定时时间”记录在寄存器TCNT0中。 TCNT0是一个8位寄存器,其寄存器值每过一个预分频时钟周期自加1,当该值达到0xFF后,如果再自加1,就会产生一个溢出信号,这个溢出信号就是寄存器TIFR中的TOV0,或者称为Timer0的溢出中断标志位。 很显然,由于溢出值是固定的(0xFF+1),我们要改变定时的长短,就只能在TCNT0的初始值上做文章。我们希望定时器在N(N<0xFF)个预分频时钟周期后溢出,就应将TCNT0设为(0xFF+1-N)。 “预分频时钟”,就是该时钟是由晶振时钟分频得到的。预分频数可以是1,8,64,256,1024。假设预分频数为8,则每8个晶振时钟周期后,TCNT0的值自加1。预分频数由寄存器TCCR0控制: TCCR0值 1 2 3 4 5 fosc /8 fosc /64 fosc /256 fosc /1024 预分频后时钟频率 fosc(fosc表示晶振时钟频率) 定时器的编程有两种方法,一是轮询方式(Polling Mode),另一种是中断方式。 轮询方式: /* 设晶振频率为1MHz,PB口接LED(接法同图2.10)。此程序使用轮询方式查询TIFR寄存器中的TOV0位是否置 ‘1’,置 ‘1’表示定时器溢出(即所定时间已到),此时PB口输出反相,即LED不断闪烁(闪烁的频率为5Hz)。*/ #include int main(void) { uint8_t state; PORTB=0x00; //初始输出低电平 DDRB=0xFF; //PB口设为输出 TCNT0=0x9E; // 初始化TCNT0,使其定时为10Hz(100ms); TCCR0=5; // 预分频为Ck/1024 for(;;) { do //此循环检测Timer0的溢出中断标志TOV0 state=TIFR&0x01; //TOV0是TIFR的bit0 while (state!=0x01); PORTB=~PORTB; // PB口输出反相,使LED闪烁 TIFR=(1< 每过1024个晶振时钟周期,TCNT0就自加1。当TCNT0的值达到0xFF后,再过1024个晶振时钟周期后,TCNT0试图再加1,这时产生溢出信号,Timer0的溢出时间中断标志TOV0被置 ‘1’。程序的do-while 循环不断检测TOV0的值,直到TOV0为 ‘1’。对TOV0写入 ‘1’ 可以使TOV0清零,清除该标志后,就可以开始下一次定时。 “轮询方式”并不常用,大部分情况下,我们都是中断方式操作定时器的。现在,我们将上面那个程序改写成中断方式。 例程 #include SIGNAL(SIG_OVERFLOW0) //Timer0溢出中断 { PORTB=~PORTB; TCNT0=0x9E; } int main(void) { PORTB=0x00; //输出值 DDRB=0xFF; TCNT0=0x9E; TCCR0=5; TIMSK=(1< TCNT0=0xFF+1− Clk Fre 其中,Clk为预分频后的时钟频率,Fre为所需定时器的频率。以上面的程序为例,晶振时钟频率为1MHz,预分频数为1024,所需定时器的频率为10Hz,则TCNT0=255+1- 1000000/1024 ≈0x9E。事实上这样的计算很麻烦,网上有AVR专用计算 10 器(AVR Calu),专门计算AVR单片机编程中可能遇到的各种计算问题。 计数器0 (Counter0) 所谓计数器,即在规定时间内对输入信号的脉冲个数进行计算的模块。PB0为计数器0的输入端口,因此,使用计数器时,必须将PB0设为输入。 计数器的使用与定时器十分相似,每次输入信号的上升沿(或下降沿)出现的时候,TCNT0就自加,当自加到0xFF以后,再次自加的时候就会溢出。与定时器一样,TCNT0溢出时TIFR中的TOV0就置 ‘1’。 输入信号是上升沿或者下降沿触发计数器,是由TCCRO决定: TCCR0值 1 2 3 4 5 6 7 fosc /8 fosc /64 fosc /256 fosc /1024 输入信号的下降沿触发计数器 输入信号的上升沿触发计数器 预分频后时钟频率 fosc (fosc表示晶振时钟频率) 与定时器一样,计数器也有轮询模式与中断模式。由于轮询模式较少用到,下面的例程是基于中断模式的。 #include SIGNAL(SIG_OVERFLOW0) //Timer0溢出中断 { PORTC=~PORTC; TCNT0=0xFF; } int main(void) { DDRB=0x00; DDRC=0xFF; TCNT0=0xFF; /* 将TCNT0初始化为0xFF,则只需输入信号出现 下降沿,TCNT0即溢出,中断服务程序被执行; 若初始化为0xFE,则出现两次下降沿TCNT0溢出一次, 执行一次中断服务程序*/ TCCR0=7; TIMSK=(1< 计数器与定时器的本质是完全一样的,区别仅在于一个是对外部信号进行计数,一个是对内部时钟进行计数。读者只要理解了定时器,就能很轻松地使用计数器了。 定时/计数器1(Timer/Counter1) Timer/Counter1是一个16位的定时/计数器,其计数值为0~65535。T/C1作为定时器和计数器时和T/C0没有多大差别,仅仅是几个寄存器的名字及计数值范围不同。 与T/C1相关的几个寄存器(只介绍与定时器和计数器相关的几个寄存器): TCCR1A T/C1的控制寄存器,用于控制比较器、PWM等功能, 如作定时器和计数器用时,赋0值即可。 TCCR1B 与TCCR0同样作用,0~5表示作定时器用,6、7分别表示外来输入信号的下降沿和上升沿触发计数器 TCNT1L 16位计数寄存器的低8位 TCNT1H 16位计数寄存器的高8位 TIFR Timer的中断标志寄存器,轮询模式下用 TIMSK Timer的中断屏蔽寄存器,作用如T/C0中所述 例程 /* 此程序与例程2.3和2.4的作用一样,只是将定时器频率设为1Hz。*/ #include SIGNAL(SIG_OVERFLOW1) //Timer1溢出中断 { PORTB=~PORTB; TCNT1L=0x7C; TCNT1H=0xE1; } int main(void) { PORTB=0x00; DDRB=0xFF; TCNT1L=0x7C; // 定时器设为1Hz TCNT1H=0xE1; TCCR1A=0; // 设为定时器功能 TCCR1B=5; // 预分频数1024 TIMSK=(1< 液晶显示器以其微功耗、体积小、使用灵活等诸多优点在袖珍式仪表和低功耗应用系统中,得到越来越广泛的应用。液晶显示器通常可分为两大类,一是点阵型,二是字符型。点阵型液晶通常面积较大,可以显示图形;而一般的字符型液晶只有两行,面积较小,只能显示字符和一些很简单的图形,简单易控制且成本低。目前市面上的字符型液晶绝大多数是基于HD44780液晶芯片的,所以控制原理是完全相同的,为HD44780写的控制程序可以很方便地应用于市面上大部分的字符型液晶。 字符型LCD通常有14条引脚线,定义如下: 管脚号 管脚名 电平 输入/输出 作用 电源地 电源(+5V) 对比调整电压 1 VSS2 VCC3 VEE 4 RS 0/1 输入 0=输入指令 1=输入数据 5 R/W 0/1 输入 0=向LCD写入指令或数据 1=从LCD读取信息 6 E 1, 1→0 输入 7 DB0 0/1 输入/输出 8 DB1 0/1 输入/输出 9 DB2 0/1 输入/输出 10 DB3 0/1 输入/输出 11 DB4 0/1 输入/输出 12 DB5 0/1 输入/输出 13 DB6 0/1 输入/输出 14 DB7 0/1 输入/输出 在本系统中,LCD的连接图如2.10所示。 使能信号,1时读取信息,1→0(下 降沿)执行指令 数据总线line 0 (最低位) 数据总线line1 数据总线line2 数据总线line3 数据总线line4 数据总线line5 数据总线line6 数据总线line7(最高位) 数据总线的8条线与PB口相接,RS接PD3,R/W接PD4,E接PD5 。读者是否觉得这样的接法占用了太多的I/O口?事实上也是如此,在某些系统中,单片机的I/O口是稀缺资源,而一个LCD就占用了11个I/O口,的确有些浪费。我们会在下一节讨论这个问题。在本节的数字钟的设计中,为使读者设计的第一个系统容易上手,我们仍采用AVRAD实验板的接法(即图2.10的接法)。 HD44780内置了192个常用字符,存于CGROM中,另外还有几个允许用户自定义的字符图形RAM称为CGRAM。图3.7说明了CGROM和CGRAM与字符的对应关系。 地址0x00-0x0F为用户自定义的字符图形RAM,0x20-0x7F为标准的ASCII码,0xA0-0xFF为日文字符和希腊文字符,其余地址(0x10-0x1F及0x80-0x9F)没有定义。 除了CGROM和CGRAM外,LCD内部还有一个DDRAM(Display Data RAM),用于存放待显示内容。LCD控制器的指令系统规定,在送待显示字符代码的指令之前,先要送DDRAM的地址,实际上是待显示的字符显示位置。16*2的字符型LCD的DDRAM地址与显示位置的对应关系如图2.11。(注意:下文中提到DDRAM的内容即是待显示的字符内容,DDRAM的地址即对应字符的位置)。 图2.12 DDRAM地址与显示位置的对应关系 用户定义的字符发生器存储器(CGRAM)并不常用。 假设我们要在第1行第2列写入字符 ‘A’,这时我们先写入第1行第2列对应的DDRAM的地址:01H(参见图2.11),然后再往DDRAM中写入0x41(参写图2.10),这样LCD的第1行第2列就会出现字符 ‘A’了。也就是说,DDRAM的内容对应于所要显示的字符地址(图2.10),而DDRAM的地址就对应于显示字符的位置(图2.11)。 那么我们如何对DDRAM的内容和地址进行操作呢?下面是HD44780的指令集及其设置说明,请读者浏览该指令表并找出对DDRAM的内容和地址进行操作的指令。 表2.16 清屏指令表 指令功能 指令编码 执行时间 RS R/W DB7 DB6 DB5 DB4 DB3 DB2 DB1 DB0 清屏 0 0 0 0 0 0 0 0 0 1 1.64ms 功能:1. 清除液晶显示器,即将DDRAM的内容全部填入“空白”的ASCII码 20H 2. 光标归位,即将光标撤回液晶显示屏的左上方 3. 将地址计数器(AC)的值设为0 表5.17 光标归位指令表 指令功能 指令编码 执行时间 RS R/W DB7 DB6 DB5 DB4 DB3 DB2 DB1 DB0 光标归位 1.64ms 0 0 0 0 0 0 0 0 1 X 功能:1. 把光标撤回到液晶显示器的左上方 2. 把地址计数器(AC)的值设置为0 3. 保持DDRAM的内容不变 表5.18 进入模式设置指令表 指令功能 指令编码 执行时间 RS R/W DB7 DB6 DB5 DB4 DB3 DB2 DB1 DB0 进入模式设置 0 0 0 0 0 0 0 1 I/D S 40us 功能:设定每次写入1位数据后光标的移位方向,并且设定每次写入的一个字符是否移动。参数设定的情况如下表所示: 位名 I/D S 设置 0=写入新数据后光标右移 1=写入新数据后光标左移 0=写入新数据后显示屏不移动 1=写入新数据后显示屏整体右移1 个字符 表2.19 显示开关控制指令表 指令功能 指令编码 执行时间 RS R/W DB7 DB6 DB5 DB4 DB3 DB2 DB1 DB0 显示开关控制 0 0 0 0 0 0 1 D C B 40us 功能:控制显示器开/关、光标显示/关闭以及光标是否闪烁。参数设定的情况如下表所示: 位名 D C B 设置 0=显示功能关 1=显示功能开 0=无光标 1=有光标 0=光标闪烁 1=光标不闪烁 表2.20 设定显示屏或光标移动方向指令表 指令功能 指令编码 执行时间 RS R/W DB7 DB6 DB5 DB4 DB3 DB2 DB1 DB0 设定显示屏或光标移动方向 功能:使光标移位或使整个显示屏幕移位。参数设定的情况如下表所示: S/C R/L 设定情况 0 0 光标左移1格,且AC值减1 0 1 光标右移1格,且AC值加1 1 0 显示器上字符全部左移一格,但光标不动 表2.21 功能设定指令表 指令功能 指令编码 执行时间 RS R/W DB7 DB6 DB5 DB4 DB3 DB2 DB1 DB0 功能设定 40us 0 0 0 0 1 DL N F X X 1 1 显示器上字符全部右移一格,但光标不动 0 0 0 0 0 1 S/C R/L X X 40us 功能:设定数据总线位数、显示的行数及字型。参数设定的情况如下表所示: 位名 DL N F 设置 0=数据总线为4位 1=数据总线为8位 0=显示1行 1=显示2行 0=5*7点阵/每字符 1=5*10点阵/每字符 表2.22 设定CGRAM地址指令表 指令功能 指令编码 执行时间 RS R/W DB7 DB6 DB5 DB4 DB3 DB2 DB1 DB0 设定CGRAM地址 功能:设定下一个要存入数据的CGRAM的地址 0 0 0 1 CGRAM的地址(6位) 40us 表2.23 设定DDRAM地址指令表 指令功能 指令编码 执行时间 RS R/W DB7 DB6 DB5 DB4 DB3 DB2 DB1 DB0 设定DDRAM地址 功能:设定下一个要存入数据的DDRAM的地址 0 0 1 DDRAM的地址(7位) 40us 表2.24 读取忙信号或AC地址指令表 指令功能 指令编码 执行时间 RS R/W DB7 DB6 DB5 DB4 DB3 DB2 DB1 DB0 读取忙碌信号或AC地址 0 1 BF AC内容(6位) 40us 功能: 1. 读取忙碌信号BF的内容,BF=1表示液晶显示器忙,暂时无法接收单片机送来的数据或指令;当BF=0时,液晶显示器可以接收单片机送来的数据或指令。 2. 读取地址计数器(AC)的内容 表2.25 数据写入DDRAM或CGRAM指令一览表 指令功能 指令编码 执行时间 RS R/W DB7 DB6 DB5 DB4 DB3 DB2 DB1 DB0 数据写入到DDRAMCGRAM中 功能:1. 将字符码写入DDRAM,以使液晶显示屏显示出相对应的字符 2. 将使用者自己设计的图形存入CGRAM 表2.26 从CGRAM或DDRAM读出数据的指令一览表 指令功能 指令编码 执行时间 或 1 0 要写入的数据D7-D0 40us RS R/W DB7 DB6 DB5 DB4 DB3 DB2 DB1 DB0 从CGRAM或DDRAM读出数据 功能:读取DDRAM或CGRAM中的内容 1 1 读出的数据(D7-D0) 40us 使能位E对执行LCD指令起着关键的作用,E有两个有效状态,高电平(’1’)和下降沿(1→0)。 当E为高电平时,如果R/W为 ‘0’则LCD从单片机读入指令或者数据;R/W为 ‘1’时,则单片机可以从LCD中读出状态字(BF忙状态)和地址。 而E的下降沿指示LCD执行其读入的指令或者显示其读入的数据。下面是HD44780的时序图: 图2.13 对初学者来说,只要记住,在将E置高电平前,先设置好RS和R/W信号,在E下降沿到来之前要准备好写入的命令字或数据。我们只需在适当的地方加上延时就可以满足时序要求了。 例程2.7: /*本程序没有main()函数,仅将LCD的几个常用操作写成相应的函数,做成”LCD.h”的头文件,下文中如遇到#include “LCD.h” 之类的命令,即包含此头文件。*/ #define LCDPORT PORTB // 数据总线接在PB口 #define LCDDDR DDRB #define LCDPIN PINB #define En_H sbi(PORTD,5) // En接PD5 #define En_L cbi(PORTD,5) #define RW_R sbi(PORTD,4) //R/W接PD4 #define RW_W cbi(PORTD,4) #define RS_H sbi(PORTD,3) // RS接PD3 #define RS_L cbi(PORTD,3) #define DelaytE Delay(10) #define Clear_Screen Write_Command(0x01) //定义清屏语句 void Delay(uint16_t time) //延时程序 { while (time>0) {time--;} } void En_Toggle(void) //产生一个使能脉冲 { En_H; //拉高使能位 DelaytE; //保持高电平一定时间 En_L; //拉低使能位,产生一个下降沿 DelaytE; //保持低电平一定时间 } /*不断检测LCD的忙标志(BF),直到其为 ‘0’,表示可以执行下一条指令*/ void Wait_Until_Ready(void) { RW_R; // 设为读状态 RS_L; // 所读为状态位 LCDDDR=0x00; //单片机设为输入,用以读取LCD的忙标志 LCDPORT=0x00; En_H; DelaytE; loop_until_bit_is_clear(LCDPIN,7); //不断循环,直至BF=’0’ En_L; } void Write_Command(uint8_t Command) //向LCD写入命令字 { RW_W; // 置为写状态 RS_L; // 写入的是命令字 LCDDDR=0xFF; LCDPORT=Command; // 写命令字 En_Toggle(); // 产生使能脉冲,在下降沿开始执行指令 Wait_Until_Ready(); // 等待指令指行完毕 } void Write_Data(uint8_t Data) // 写入数据(所需显示的字符的地址) { RW_W; RS_H; // 写入的是数据 LCDDDR=0xFF; LCDPORT=Data; En_Toggle(); Wait_Until_Ready(); } void Write_Position(uint8_t row,uint8_t colum) // 设字符位置 { uint8_t p; if (row==1) {p=0x80+colum-1; Write_Command(p); } else {p=0xC0+colum-1; Write_Command(p); } } void Write_String(uint8_t *s) // 写入字符串 { for (;*s!='\\0';s++) Write_Data(*s); } void Initialize_LCD(void) // LCD初始化 { Write_Command(0x38); // 设为8位接口模式,显示2行字符 Write_Command(0x06); // 写入新数据后光标右移 Write_Command(0x0C); // 显示功能开,不显示光标 Clear_Screen; //清屏 } 我们看看下面这个小程序 例程2.8: #include int main(void) { DDRD=0xFF; PORTD=0x00; DDRB=0xFF; PORTB=0x00; Initialize_LCD(); //初始化LCD Write_Position(1,1); // 字符位置:第1行第1列 Write_Data('a'); // 显示字符 ‘a’ Write_Data(0x62); // 在第1行第2列显示字符 ‘b’ Write_Position(2,1); // 字符位置:第2行第1列 Write_String(\"Hello world!\"); // 显示字符串 “Hello world!” for (;;) {} } 需要在LCD上显示一个字符的时候,有两种途径: (1)通过查表(图2.11)获得该字符在CGROM里的地址add,用Write_Data(add)使LCD显示该字符。(如例程2.8中显示字符’b’的方法) (2)如果我们需要LCD显示的是标准ASCII码,则可以直接采用Write_Data(‘字符’)的方法。(如果例程2.8中显示字符’a’的方法) 另外值得注意的是,当我们设定了字符显示的位置,并且写入一个字符后,DDRAM的地址自动加1,也就是说,我们在第1行第1列写入一个字符后,如果我们不对字符显示位置重新设置的话,再写入一个字符,则这个新的字符后出现在第1行第2列。 4*4矩阵式键盘的使用 单片机系统中常用的按键有直接式和矩阵式两种。 直接式按键十分简单,一端接Vcc,一端接单片机的I/O口(设为输入),当按键按下时,单片机的I/O口为高电平,通过对I/O口电平的检测就可以知道按键是否按下。其优点是简单易行,连接方便,但每个按键要占用一个I/O口,如果系统中需要很多按键时,用这种方法会占用大量的I/O口,甚至将单片机的所有I/O口都用上仍不能满足要求。 矩阵式键盘控制起来比直接式按键要麻烦得多,但其优点也是很明显的,那就是节省I/O口。设矩阵式键盘有m行n列,则键盘上有(m*n)个按键,而它只需要占用(m+n)个I/O口。当我们需要很多按键时,用矩阵式键盘显然比直接式按键要合理得多。 矩阵式键盘的接法,通常有三种(其余的接法一般都是由这三种接法稍加改变而得,基本原理是完全一样的),下面我们一一介绍。 图10 矩阵键盘接法A 行列电平值与按键的对应关系表: 列/PC3~PC0(输出) 行/PC7~PC4(输入) 按键 1000 0001 0 1000 0010 1 1000 0100 2 1000 1000 3 0100 0001 4 0100 0010 5 0100 0100 6 0100 1000 7 0010 0001 8 0010 0010 9 0010 0100 A 0010 1000 B 0001 0001 C 0001 0010 D 0001 0100 E 0001 1000 F 表2.19 因为我们无法预计什么时候有键按下,也无法预测究竟是哪一列上的键被按下,所以我们只能对键盘的列线(PC3~PC0)进行扫描,同时读取键盘的行线(PC7~PC4)的电平值。PC3~PC0按下述的4种组合依次输出,不断循环: PC3 PC2 PC1 PC0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 表2.20 图11 矩阵键盘接法B 我们再来看矩阵键盘接法B(图11)。很容易看出,此接法与接法C的原理类似,只是将下拉电阻变成上拉电阻。在没有键被按下的情况下,PC7~PC4被上拉电阻稳定在高电平。如果某一键被按下,而该键对应的列线为低电平,则对应的单片机输入口读到的电平值为 ‘0’。与接法A同理,任一时刻的PC3~PC0的输出只能有一个为 ‘0’。 我们只要将扫描信号变成下面的4种组合就行了: PC3 PC2 PC1 PC0 0 1 1 1 1 0 1 1 1 1 0 1 1 1 1 0 其行列电平值与按键的对应关系如下表: 列/PC3~PC0(输出) 行/PC7~PC4(输入) 按键 0111 1110 0 0111 1101 1 0111 1011 2 0111 0111 3 1011 1110 4 1011 1101 5 1011 1011 6 1011 0111 7 1101 1110 8 1101 1101 9 1101 1011 A 1101 0111 B 1110 1110 C 1110 1101 D 1110 1011 E 1110 0111 F 图12 矩阵键盘接法C 图12为SL-AVRAD实验板上键盘的接法, /* 此函数每次执行都产生一个扫描信号(四位,任一时刻只有一位为 ‘0’)加于列线上,同时读取行线上的电平值以判断扫描信号为 ‘0’的列上是否有键按下。如果此函数 被执行4次后,即经过一轮扫描后,没有发现按键被按下,则输出16(键盘值从0到15,因此16用以指示无效值)。另外,用LastNum存上次读到的按键值,CurrentNum存本次读到的按键值,如果两次读到的按键值同,亦输出16 。换句话说,无论用户按一个键按多久,都只返回一个有效值,当成一次按键。*/ /*注:此函数一般用于定时器中断中或主循环中,即本函数需隔一定的时间(10ms为宜)被执行一次,才能起到键盘扫描的作用。*/ uint8_t Keyboard(void) //键盘扫描函数,返回一个键值或16(无效值) { static uint8_t ScanCode=0xF7,TempNum=16,LastNum=16,CurrentNum=16,Times=0; switch(ScanCode) // 扫描信号 { case 0xF7:ScanCode=0xFB; break; //扫描信号:0111→1011 case 0xFB:ScanCode=0xFD; break; // 1011→1101 case 0xFD:ScanCode=0xFE; break; // 1101→1110 case 0xFE:ScanCode=0xF7; break; // 1110→0111 default: ScanCode=0xF7; break; } /*在main()函数里需将DDRC设成0x0F,即PC7~PC4为输入,PC3~PC0为输出。 下面PORTC=ScanCode意味着,将PC7~PC4设为带上拉功能的输入, 而PC3~PC0输出扫描信号。*/ PORTC=ScanCode; switch(PINC) // 解读行列电平值与按键值的关系 { /*TempNum记录读到的键值,由于此键值需经进一步处理, 所以是暂时数值(TempNum)。Times用以记录没有读到有效键值的次数, 如果次数达到4次(即扫描一轮后没有发现有效键值), 就表示没有键按下。*/ case 0xE7:TempNum=0;Times=0;break; case 0xD7:TempNum=1;Times=0;break; case 0xB7:TempNum=2;Times=0;break; case 0x77:TempNum=3;Times=0;break; case 0xEB:TempNum=4;Times=0;break; case 0xDB:TempNum=5;Times=0;break; case 0xBB:TempNum=6;Times=0;break; case 0x7B:TempNum=7;Times=0;break; case 0xED:TempNum=8;Times=0;break; case 0xDD:TempNum=9;Times=0;break; case 0xBD:TempNum=10;Times=0;break; case 0x7D:TempNum=11;Times=0;break; case 0xEE:TempNum=12;Times=0;break; case 0xDE:TempNum=13;Times=0;break; case 0xBE:TempNum=14;Times=0;break; case 0x7E:TempNum=15;Times=0;break; default:{ Times++; if (Times==4) { TempNum=16; Times=0; } }break; } /*对读到的键值进行处理,每个有效键值仅返回一次*/ LastNum=CurrentNum; CurrentNum=TempNum; if (CurrentNum==LastNum) return(16); else return(CurrentNum); } 下面给出一个在定时器中断中使用键盘扫描的小程序: #include #include \"Key.h\" //此文件包含上面的Keyboard()函数 SIGNAL(SIG_OVERFLOW0) { uint8_t Keynum=16; TCNT0=0xB2; Keynum=Keyboard(); //读取键盘值 Write_Position(1,1); /*如果键值在0~9之间,就在LCD第1行第1列显示出来。 (Keynum+0x30)表示取键值的ASCII码。*/ if (Keynum<10) Write_Data((Keynum+0x30)); } int main(void) { DDRC=0x0F; //输出或者输入? PORTC=0xFF; //上拉及其输出值 DDRD=0xFF; //输出 PORTD=0x00; //初值 DDRB=0xFF; PORTB=0x00; Initialize_LCD(); TCCR0=5; TCNT0=0xB2; TIMSK=(1< 本系统是在SL-AVRAD实验板上实现的。系统时钟用的是8MHz晶振,显示器为16*2字符型LCD,输入设备为4*4矩阵式键盘。 本系统分为三个基本模块:计时模块、调时模块、时制转换模块。功能模块示意图如图2.17所示。 图2.17 简易数字钟功能模块示意图 例程2.11: #include #include /*下面定义了几个全局变量。 Hour、Minute和Second分别指当前时间的时、分、秒;State表示当前所处的状态,State=0表示正常计时状态,1表示处于调时状态,2表示处于调整时制状态;T12指示当前的时制,0为24小时制,1为12小时制。*/ uint8_t Second=0,Minute=0,Hour=0; uint8_t State=0, T12=0; void Display_Time(void) //显示当前时间 { /* HourH,MinuteH,SecondH表示时、分、秒十位上的数, HourL,MinuteL,SecondL 表示时、分、秒个位上的数。*/ uint8_t HourH,HourL,MinuteH,MinuteL,SecondH,SecondL; if ((Hour>=12)&&(T12==1)) //12小时制下,中午12点以后时间的表示 { HourH=(Hour-12)/10+0x30; HourL=(Hour-12)%10+0x30; Write_Position(1,9); Write_String(\"PM\"); } else { HourH=Hour/10+0x30; HourL=Hour%10+0x30; if (T12==1) //12小时制下,中午12点以前时间的表示 { Write_Position(1,9); Write_String(\"AM\"); } } MinuteH=Minute/10+0x30; //将分钟的十位数转为标准ASCII码 MinuteL=Minute%10+0x30;//将分数的个位数转为标准ASCII码 SecondH=Second/10+0x30; SecondL=Second%10+0x30; Write_Position(1,1); Write_Data(HourH); //lcd.h Write_Data(HourL); Write_Data(':'); Write_Data(MinuteH); Write_Data(MinuteL); Write_Data(':'); Write_Data(SecondH); Write_Data(SecondL); } void Display_T12(void) //显示当前时制 { Clear_Screen(); Write_Position(1,1); if (T12==0) Write_String(\"24 H\"); else Write_String(\"12 H\"); } void Adjust_Time(uint8_t Position) //调时函数 { switch(Position) { case 0: //调小时 if (Hour==23) Hour=0; else Hour++; break; case 1: //调分钟 if (Minute==59) Minute=0; else Minute++; break; case 2: Second=0;break; //调秒(将秒数清零) default:{}break; } } SIGNAL(SIG_OVERFLOW1) //Timer1中断,中断频率为1Hz { TCNT1H=0x85; TCNT1L=0xEE; if (Second==59) // 时、分、秒的进位关系 { Second=0; if (Minute==59) { Minute=0; if (Hour==23) Hour=0; else Hour++; } else Minute++; } else Second++; } SIGNAL(SIG_OVERFLOW0) // scan keyboard { uint8_t Keynum=16; /*Position指示当前所调的参数,0:小时;1:分;2:秒;*/ static uint8_t Position=0; TCNT0=0xB2; Keynum=Keyboard(); //取键值 switch (State) { case 0: //在正常计时状态下 { if (Keynum==0) State=1; //检测到按键0,变为调时状态 Write_Command(0x0C); // 不显示光标 Display_Time(); // 显示当前时间 } break; case 1: //在调时状态下 { Display_Time(); switch(Keynum) { case 0: State=2;break; //检测到按键0,变为时制转换状态 /*按键1用来改变当前所调的参数, 按键2用来调整时间*/ case 1: if (Position==2) Position=0; // change position else Position++; break; case 2: Adjust_Time(Position);break; //++ default:{};break; } Write_Command(0x0F); //光标闪烁(详见2.6.1节指令表) switch(Position) //指示光标闪烁的位置 { case 0:Write_Position(1,2);break; // move to position case 1:Write_Position(1,5);break; case 2:Write_Position(1,8);break; default:{}break; } }break; case 2: //时制转换模块 { if (Keynum==0) State=0; //检测到按键0回到正常计时 else if (Keynum==1) // 按键1用以改变当前时制 { if (T12==0) T12=1; else T12=0; } Write_Command(0x0C); // 不显示光标 Display_T12(); //显示当前时制 }break; default:{}break; } } void Init_Device(void) //对所用端口和计数器寄存器值进行初始化 { DDRC=0x0F; PORTC=0xFF; DDRD=0xFF; PORTD=0x00; DDRB=0xFF; PORTB=0x00; Initialize_LCD(); //lcd.h TCCR1A=0; // for timer1 worked as timer TCCR1B=4; // 256 divide TCNT1H=0x85; //1s TCNT1L=0xEE; TCCR0=5; //1024 divide TCNT0=0xB2; TIMSK=(1< Init_Device(); sei(); for (;;) {} } 因篇幅问题不能全部显示,请点此查看更多更全内容