我们在上
C
语言课的时候,学到指针,每一位教
C
语言的老师都会告诉我们一句:指针是
C
语言的灵魂。由此可见,指针是否学会是判断一个人是否真正学会
C
语言的重要指标之一,但是很多同学只知道其重要性,却没学会其灵活性。
简单的程序,
100
来行代码,不需要指针我们可以轻松搞定,但是当代码写到几千上万行甚至更多的时候,利用指针就可以直接而快速的处理内存中的各种数据结构中的数据,特别是数组、字符串和内存的动态分配等,它为函数之间各类数据传递提供了简洁便利的方法。说了这么多作用估计大家没用过指针也体会不到,但这里就是表达这样一个意思,指针很重要,必须要学会。
指针相对其他知识点来说比较难讲,主要在于例子不好举。简单的程序用指针去做会把简单的程序搞复杂,复杂的程序用指针去写牵扯的知识太多可能又不好理解。从一个角度讲,没学会指针就等于没学会
C
语言,所以再难也不是我们学不好的理由。这节课我就从我对指针的理解尽可能的把指针形象的介绍给大家,帮大家啃下这块硬骨头,同学们学习这节课内容也要打起十二分精神,集中注意力认真去学,争取拿下指针。
12.1 指针的基本概念和指针变量的声明12.1.1 变量的地址
要研究指针,我们得先来深入理解内存地址这个概念。打个比方:整个内存就相当于一个拥有很多房间的大楼,每个房间都有房间号,比如从
101
、
102
、
103
直到
NNN
,我们可以说这些房间号就是房间的地址,相应的内存中的每个单元也都有自己的编号,比如从
0x00
、
0x01
、
0x02
直到
0xNN
,我们同样可以说这些编号就是内存单元的地址。房间里可以住人,对应的内存单元里就可以“住进”变量了:假如一位名字叫
A
的人住在
101
房间,我们可以说
A
的住址就是
101
,或者
101
就是
A
的住址;对应的,假如一个名为
x
的变量住在编号为
0x00
的这个内存单元中,那么我们可以说变量
x
的内存地址就是
0x00
,或者
0x00
就是变量
x
的地址。
基本的内存单元是字节,英文单词为
Byte
,我们所使用的
STC89C52RC
单片机共有
512
字节的
RAM
,就是我们所谓的内存,但它分为内部
256
字节和外部
256
字节,我们仅以内部的
256
字节为例,很明显其地址的编号从
0
开始就是
0x00
~
0xFF
。我们用
C
语言定义的各种变量就存在
0x00
~
0xFF
的地址范围内,而不同类型的变量会占用不同数量的内存单元,即字节,可以结合前面讲过的
C
语言变量类型深入理解。现在,假如我们现在定义了
unsigned char a = 1; unsigned char b = 2; unsigned int c = 3; unsigned long d = 4;
这样
4
个变量,我们把这
4
个变量分别放到内存中,就会是表
12-1
中所列的样子,我们先来大概了解一下他们的存储方式。
表
12-1
变量存储方式
变量
a
、
b
和
c
和
d
之间的变量类型不同,因此在内存中所占的存储单元也不一样,
a
和
b
都占一个字节,
c
占了
2
个字节,而
d
占了
4
个字节。那么,
a
的地址就是
0x00
,
b
的地址就是
0x01
,
c
的地址就是
0x02
,
d
的地址就是
0x04
,它们的地址的表达方式可以写成:
&a
,
&b
,
&c
,
&d
。这样就代表了相应变量的地址,
C
语言中变量前加一个
&
表示取这个变量的地址,
&
这个符号就叫做“取址符”。
讲到这里,有一点延伸内容,大家可以了解下:比如变量
c
是
unsigned int
类型的,占了
2
个字节,存储在了
0x02
和
0x03
这两个内存地址上,那么
0x02
是他的低字节还是高字节呢?这个问题由所用的
C
编译器与
CPU
架构共同决定,单片机类型不同就有可能不同,大家知道这么回事即可。比如:在我们使用的
keil+51
单片机的环境下,
0x02
存的是高字节,
0x03
存的是低字节。这是编译底层实现上的细节问题,并不影响上层的应用,如下这两种情况在应用上就丝毫不受这个细节的影响:强制类型转换——
b = (unsigned char) c
,那么
b
的值一定是
c
的低字节;取地址——
&c
,则得到一定是
0x02
,这都是
C
语言本身所决定的规则,不因单片机编译器的不同而有所改变。
实际生活中,我们要寻找一个人有两种方式,一种方式是通过它的名字来找人,还有第二种方式就是通过它的住宅地址来找人。我们在派出所的户籍管理系统的信息输入方框内,输入小明的家庭住址,系统会自动指向小明的相关信息,输入小刚的家庭住址,系统会自动指向小刚的相关信息。这个供我们输入地址的这个方框,在户籍管理系统叫做“地址输入框”。
那么,在
C
语言中,我们要访问一个变量,同样有两种方式:一种是通过变量名来访问,另一种自然就是通过变量的地址来访问了。在
C
语言中,地址就等同于指针,变量的地址就是变量的指针。我们要把地址送到上边那个所谓的“地址输入框”内,这个“地址输入框”既可以输入
x
的指针,又可以输入
y
的指针,所以相当于一个特殊的变量——保存指针的变量,因此称之为指针变量,简称为指针,而通常我们说的指针就是指指针变量。
地址输入框输入谁的地址,指向的就是这个人的信息,而给指针变量输入哪个普通变量的地址,它自然就指向了这个变量的内容,通常的说法就是指针指向了该变量。
12.1.2 指针变量的声明
在
C
语言中,变量的地址往往都是编译系统自动分配的,对我们用户来说,我们是不知道某个变量的具体地址的。所以我们定义一个指针变量
p
,把普通变量
a
的地址直接送给指针变量
p
就是
p = &a;
这样的写法。
对于指针变量
p
的定义和初始化,一般有两种方式,这两种方式,初学者很容易混淆,因此这个地方没别的方法,就是死记硬背,记住即可。
方法
1
:定义时直接进行初始化赋值。
unsigned char a;
unsigned char *p = &a;
方法
2
:定义后再进行赋值。
unsigned char a;
unsigned char *p;
p = &a;
大家仔细看会看出来这两种写法的区别,它们都是正确的。我们在定义的指针变量前边加了个
*
,这个
*p
就代表了这个
p
是个指针变量,不是个普通的变量,它是专门用来存放变量地址的。此外,我们定义
*p
的时候,用了
unsigned char
来定义,这里表示的是这个指针指向的变量类型是
unsigned char
型的。
指针变量似乎比较好理解,大家也能很容易就听明白。但是为什么很多人弄不明白指针呢?因为在
C
语言中,有一些运算和定义,他们是有区别的,很多同学就是没弄明白他们的区别,指针就始终学不好。这里我要重点强调两个区别,只要把这两个区别弄明白了,起码指针变量这部分就不是问题了。这两个重点现在大家死记硬背,直接记住即可,靠理解有可能混淆概念。
第一个重要区别:指针变量
p
和普通变量
a
的区别。
我们定义一个变量
a
,同时也可以给变量
a
赋值
a = 1
,也可以赋值
a = 2
。
我们定义一个指针变量
p
,另外还定义了一个普通变量
a=1
,普通变量
b=2
,那么这个指针变量可以指向
a
的地址,也可以指向
b
的地址,可以写成
p = &a
,也可以写成
p = &b
,但是就不能写成
p = 1
或者
p = 2
或者
p = a
,这三种表达方式都是错的。
因此这个地方,不要看到定义
*p
的时候前边有个
unsigned char
型,就错误的赋值
p=1
,这个只是说明
p
指向的变量是这个
unsigned char
类型的,而
p
本身,是指针变量,不可以给他赋值普通的值或者变量,后边我们会直接把指针变量称之为指针,大家要注意一下这个小细节。
前边这个区别似乎比较好理解,还有第二个重要区别,一定要记清楚。
第二个重要区别:定义指针变量
*p
和取值运算
*p
的区别。
“
*
”这个符号,在我们的
C
语言有三个用法,第一个用法很简单,乘法操作就是用这个符号,这里就不讲了。
第二个用法,是定义指针变量的时候用的,比如
unsigned char *p
,这个地方使用“
*
”代表的意思是
p
是一个指针变量,而非普通的变量。
还有第三种用法,就是取值运算,和定义指针变量是完全两码事,比如:
unsigned char a = 1;
unsigned char b = 2;
unsigned char *p;
p = &a;
b = *p;
这样两步运算完了之后,
b
的值就成了
1
了。在指针这块,
&a
表示取
a
这个变量的地址,把这个地址送给
p
之后,再用
*p
运算表示的是取指针变量
p
指向的地址的变量的值,又把这个值送给了
b
,最终的结果相当于
b=a
。同样是
*p
,放在定义的位置就是定义指针变量,放在程序中就是取值运算。
这两个重要区别,大家可以反复阅读三四遍,把这两个重要区别弄明白,指针的大门就顺利的踏进去一只脚了。至于详细的用法,我们后边用得多了就会慢慢熟悉起来了。
12.1.3 指针的简易应用程序
前边我们提到了,指针的意义往往在小程序里是体现不出来的,对于简易程序来说,有时候用了指针,反而可能比没用指针还麻烦,但是为了让大家巩固一下指针的用法,我还是写了个使用指针的流水灯程序,目的是让大家从简单程序开始了解指针,当程序复杂的时候不至于手足无措。
#include <reg52.h>
sbit ADDR0 = P1^0;
sbit ADDR1 = P1^1;
sbit ADDR2 = P1^2;
sbit ADDR3 = P1^3;
sbit ENLED = P1^4;
void ShiftLeft(unsigned char *p);
void main(void)
{
unsigned int i = 0;
unsigned char buf = 0x01;
ADDR0 = 0; //
选择独立
LED
ADDR1 = 1;
ADDR2 = 1;
ADDR3 = 1;
ENLED = 0; //LED
总使能
while(1)
{
P0 = ~buf; //
缓冲值取反送到
P0
口
for (i=0; i<20000; i++); //
延时
ShiftLeft(&buf); //
缓冲值左移一位
if (buf == 0) //
如移位后为
0
则重赋初值
{
buf = 0x01;
}
}
}
void ShiftLeft(unsigned char *p)
{
*p = *p << 1; //
利用指针变量可以向函数外输出运算结果
}
这是一个使用指针实现流水灯的例子,纯粹是为了讲指针而写这样一段程序,程序中传递的是
buf
的地址,把这个地址值直接传递给函数
ShiftLeft
的形参指针变量
p
,也就是
p
指向了
buf
。对比之前的函数调用,大家是否看明白,如果是普通变量传递,只能单向的,也就是说,主函数传递给子函数的值,子函数只能使用却不能改变。而现在我们传递的是指针,不仅仅我们的子函数可以使用
buf
里边的值,而且还可以对
buf
里边的值进行改变。
此外再强调一句,只要是
*p
前边带了变量类型如
unsigned char
,就是表示定义了一个指针变量
p
,程序中的
*p
,是指
p
所指向的内容。
通过理论的学习和这样一个例程,我想大家对指针应该有概念了,至于它的灵活应用,需要我们在后边的程序中去体会,理论上就不再过多赘述了。
[size=14.0000pt]12.2 [size=14.0000pt]指向数组元素的指针
[size=14.0000pt]12.2.1 [size=14.0000pt]指向数组元素的指针的介绍和运算法则
所谓指向数组元素的指针,其本质还是变量的指针。因为我们的数组里的每个元素,其实都可以直接看成是一个变量,所以指向数组元素的指针,也就是变量的指针。
指向数组元素的指针不难,但很常用。我们用程序来解释会比较直观一些。
unsigned char number[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
unsigned char *p;
如果我们写
p = &number[0];
那么指针
p
就指向了
number
的第
0
号元素,也就是把
number[0]
的地址赋值给了
p
,同理,如果写
p =&number[1];p
就指向了数组
number
的第
1
号元素,
p=&number[x];
其中
x
的取值范围是
0<=x<=9
,表示
p
指向了数组
number
的第
x
号元素。
指针本身,也可以进行几种简单的运算,这几种运算对于数组元素的指针来说应用最多。
1
、比较运算。比较的前提是两个指针指向同种类型的对象,比如两个指针变量
p
和
q
他们指向了具有同种数据类型的数组,那他们可以进行
<
,
>
,
>=
,
<=
,
==
等关系运算。如果
p==q
为真的话,表示这两个指针指向的是同一个元素。
2
、指针和整数可以直接进行加减运算。比如还是上边我们那个指针
p
和数组
number
,如果
p = &number[0]
,那么
p+1
就指向了
number[1]
,
p+9
就指向了
number[9]
。当然了,如果
p = &number[9]
,
p-9
也就指向了
number[0]
。
3
、两个指针变量在一定条件下可以进行减法运算。如
p = &number[0]; q = &number[9];
那么
q-p
的结果就是
9
。但是这个地方大家要特别注意,这个
9
代表的是元素的个数,而不是真正的地址差值。如果我们的
number
的变量类型是
unsigned int
型,占
2
个字节,
q-p
的结果依然是
9
,他代表的是数组元素的个数。
在数组元素指针这里还有一种情况,就是我们的数组名字代表了数组元素的首地址,也就是说
p = &number[0];
p = number;
这两种表达方式是等价的,因此以下几种表达形式和内容需要大家格外注意一下。
1、根据指针的运算规则,
p+x
代表的是
number[x]
的地址,那么
number+x
代表的也是
number[x]
的地址。或者说,他们指向的都是
number
数组的第
x
号元素。
2、*(p+x)
和
*(number+x)
都表示
number[x]
。
3、指向数组元素的指针也可以表示成数组的形式,也就是说,允许指针变量带下标,即
p[ i]
和
*(p+i)
等价。但是为了避免混淆与规范起见,这里我们建议大家不要写成前者,而一律采用后者的写法。但如果看到别人那么写,也知道是怎么回事即可。
二维数组元素的指针和一维数组类似,需要介绍的内容不多。假如现在一个指针变量
p
和一个二维数组
number[3][4]
,它的地址的表达方式也就是
p=&number[0][0]
,有一个地方要注意,既然数组名代表了数组元素的首地址,那么也就是说
p
和
number
都是指数组的首地址。对二维数组来说,
number[0]
,
number[1]
,
number[2]
都可以看成是一维数组的数组名字,所以
number[0]
等价于
&number[0][0]
,
number[1]
等价于
&number[1][0]
,
number[2]
等价于
&number[2][0]
。加减运算和一维数组是类似的,不再详述。
12.2.2 指向数组元素的指针应用例程
在我们的
C
语言里边,
sizeof()
可以用来获取括号内的对象所占用的内存字节数,虽然它写作函数的形式,但它并不是一个函数,而是
C
语言的一个关键字,
sizeof()
整体在程序代码中就相当于一个常量,也就是说这个获取操作是在程序编译的时候进行的,而不是在程序运行的时候进行。这是一个实际编程中很有用的关键字,灵活运用它可以为程序带来更好可读性、易维护性和可移植性,在后续的例程学习中将会慢慢有所体会的。
sizeof()
括号中的可以是变量名,也可以是变量类型,其结果是等效的。而其更大的用处是与数组名搭配使用,这样可以获取整个数组占用的字节数,就不用自己动手计算了。
下面我们提供了一个简单的串口演示例程,可以体验一下指针和
sizeof()
的用法。例程首先接收上位机下发的命令,根据命令值分别把不同数据的数据回发给上位机,程序还用到了指针的自增运算,也就是
+1
运算,大家可以认真考虑一下指针
ptrTxd
在串口发送的过程中的指向是如何变化的。在上位机串口调试助手中分别下发
1,2,3,4
,就会得到不同的数组回发,注意这里都用十六进制发送和十六进制显示。
此外,这个程序还应用到一个小技巧,这里大家要学会使用。我们前边讲了串口发送中断标志位
TI
是硬件置位,软件清零的。通常来讲,我们想一次发送多个数据的时候,就需要把第一个字节写入
SBUF
,然后在等待发送中断,再在后续中断中在发送剩余的数据,这样我们的数据发送过程就被拆分到了两个地方——主循环内和中断服务函数内,无疑就使得程序结构变得零散了。这个时候,为了使程序结构尽量紧凑,在启动发送的时候,不是向
SBUF
中写入第一个待发的字节,而是直接让
TI=1
,注意,这时候会马上进入串口中断,因为中断标志位置
1
了,但是串口线上并没有发送任何数据,于是,我们所有的数据发送都可以在中断中进行,而不用再分为两部分了。大家可以在程序中体会一下这个技巧的好处。
#include <reg52.h>
bit cmdArrived = 0; //
命令到达标志,即接收到上位机下发的命令
unsigned char cmdIndex = 0; //
命令索引,即与上位机约定好的数组编号
unsigned char cntTxd = 0; //
串口发送计数器
unsigned char *ptrTxd = 0; //
串口发送指针
unsigned char array1[1] = {1};
unsigned char array2[2] = {1,2};
unsigned char array3[4] = {1,2,3,4};
unsigned char array4[8] = {1,2,3,4,5,6,7,8};
void ConfigUART(unsigned int baud);
void main ()
{
ConfigUART(9600); //
配置波特率为
9600
EA = 1; //
开总中断
while(1)
{
if (cmdArrived)
{
cmdArrived = 0;
switch (cmdIndex)
{
case 1:
ptrTxd = array1; //
数组
1
的首地址赋值给发送指针
cntTxd = sizeof(array1); //
数组
1
的长度赋值给发送计数器
TI = 1; //
手动方式启动发送中断,处理数据发送
break;
case 2:
ptrTxd = array2;
cntTxd = sizeof(array2);
TI = 1;
break;
case 3:
ptrTxd = array3;
cntTxd = sizeof(array3);
TI = 1;
break;
case 4:
ptrTxd = array4;
cntTxd = sizeof(array4);
TI = 1;
break;
default:
break;
}
}
}
}
void ConfigUART(unsigned int baud) //
串口配置函数,
baud
为波特率
{
SCON = 0x50; //
配置串口为模式
1
TMOD &= 0x0F; //
清零
T1
的控制位
TMOD |= 0x20; //
配置
T1
为模式
2
TH1 = 256 - (11059200/12/32) / baud; //
计算
T1
重载值
TL1 = TH1; //
初值等于重载值
ET1 = 0; //
禁止
T1
中断
ES = 1; //
使能串口中断
TR1 = 1; //
启动
T1
}
void InterruptUART() interrupt 4
{
if (RI) //
接收到字节
{
RI = 0; //
手动清零接收中断标志位
cmdIndex = SBUF; //
接收到的数据保存到命令索引中
cmdArrived = 1; //
设置命令到达标志
}
if (TI) //
字节发送完毕
{
TI = 0; //
手动清零发送中断标志位
if (cntTxd > 0) //
有待发送数据时,继续发送后续字节
{
SBUF = *ptrTxd; //
发出指针指向的数据
cntTxd--; //
发送计数器递减
ptrTxd++; //
发送指针递增
}
}
}
12.3 字符数组和字符指针12.3.1 常量和符号常量
在程序运行过程中,其值不能被改变的量称之为常量。常量分为不同的类型,有整型常量如
1
,
2
,
3
,
100
等;浮点型常量
3.14
,
0.56
,
-4.8
;字符型常量’a’,’b’,’0’;字符串常量”a”,”abc”,”1234”,”1234abcd”等。
细心的同学会发现,整型和浮点型常量我们直接写的数字,而字符型常量用单引号来表示一个字符,用双引号来表示一个字符串,尤其大家要注意’a’和”a”是不一样的,这个等会我们要详细介绍。
常量一般有
2
种表现形式。
1、直接常量:直接以值的形式表示的常量称之为直接常量。上述举例这些都是直接常量,直接写出来了。
2、符号常量:用标识符命名的常量称之为符号常量,就是为上面的直接常量再取一个名字。使用符号常量一是方便理解,提高程序可读性,更重要的是方便程序的后续维护,习惯上符号常量我们都用大写字母和下划线来命名。
比如,我们可以把
3.14
取名为
PI
(即π)。再比如,我们上节课的串口程序,我们用的波特率是
9600
,如果用符号常量来进行提前声明的话,那我们要修改成其他速率的话,就不用在程序中找
9600
修改了,直接修改声明处就可以了,两种方法举例说明。
1、用
const
声明。比如我们在程序开始位置定义一个符号常量
BAUD
。
定义形式是
const
类型 符号常量名字
=
常量值
;
如
const unsigned int BAUD = 9600; /*
注意结尾有个分号
*/
我们就可以在程序中直接把
9600
改成
BAUD
,这样我们如果要改波特率的话,直接在程序开头位置改一下这个值就可以了。
2、用预处理命令
#define
来完成,预处理命令我们先来认识
#define
。
定义形式是
#define
符号常量名 常量值
如
#define BAUD 9600 /*
注意结尾没有分号
*/
这样定义以后,只要在程序中出现
BAUD
的话,意思就是完全替代了后边的
9600
这个数字。
不知大家是否记得,我们之前在数码管真值表,用了一个
code
关键字
unsigned char code LedChar[] = { //
用数组来表示数码管真值表
0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8,
0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8e
};
我们当时说加了
code
之后,这个真值表的数据只能被使用,不能被改变,如果我们直接写LedChar[0] = 1;
这样就错了。实际上
code
这个关键字是
51
单片机特有的,如果是其他型号的单片机我们只需要写成
const unsigned char LedChar[]={}
就可以了,自动保存到
FLASH
里,而
51
单片机只用
const
而不加
code
的话,这个数组会保存到
RAM
中,而不会保存到
FLAHS
中,鉴于此,在
51
这个体系下,
const
反倒变得不那么重要了,它的作用被
code
取代了,这里大家知道这么回事即可。
我们来对各种类型的常量做进一步说明。
整型常量和浮点型常量没太多内容在里边了,之前我们应用都很熟练了,整型直接写数字就是十进制如
128
,前边
0x
开头的表示是十六进制
0x80
,浮点型直接写带小数点的数据就可以了。
字符型常量是由一对单引号括起来的单个字符。它分为两种形式,一种是普通字符,一种是转义字符。
1、普通字符就是那些我们可以直接书写直接看到的有形的字符,比如阿拉伯数字
0
~
9
,英文字符
A
~
z
,以及标点符号等。它们都是
ASCII
码表中的字符,而它们在单片机中都占用一个字节的空间,其值就是对应的
ASCII
码值。比如’a’的值是
97
,’A’的值是
65,
‘
0
’的值是
48
,如果定义一个变量
unsigned char a = ’a’,那么变量
a
的值就是
97
。
2、除了上述这些字符之外,还有一些特殊字符,它们一些是无形的,像回车符、换行符这些都是看不到的,还有一些像’、”这类字符它们已经有特殊用途了,想象一下如果写’’’你觉得编译器会怎么取解释。针对这些特殊符号,为了可以让它们正常进入到我们的程序代码中,
C
语言就规定了转义字符,它是以反斜杠
(\)
开头的特定字符序列,让它们来表示这些特殊字符,比如我们用
\n
来代表换行,这块内容我们后边有程序说明。我们用一个简单表格来说明一下常用的转义字符的意思,如表
12-2
所示。
表
12-2
常用转义字符及含义
表格不需要大家记住,用到了,过来查就可以了。
字符串常量是用双引号括起来的字符序列,一般我们都称之字符串。如”a”,”1234”,”welcome to
www.kingst.org”等都是字符串常量。字符串常量在内存中按顺序逐个存储字符串中的字符的
ASCII
码值,并且特别注意,最后还有一个字符’\0’,’0’字符的
ASCII
码值是
0
,它是字符串结束标志,在写字符串的时候,这个‘
\0’是隐藏的,我们看不到,但是实际却是存在的。所以”a”就比’a’多了一个’\0’,”a”的就占了
2
个字节,而’a’只占一个字节。
还有一个地方要注意,就是字符串中的空格,也是一个字符,比如”welcome to
www.kingst.org”一共占了
26
个字节的空间。其中
21
个字母,
2
个’.’,
2
个‘ ’(
空格字符
)
以及一个’\0’。
[size=14.0000pt]12.3.2 [size=14.0000pt]字符和字符串数组应用例程
为了对比字符串,字符数组,常量数组的区别,我们写个了简单的演示程序,定义了
4
个数组分别是:
unsigned char array1[] = "1-Hello!\r\n";
unsigned char array2[] = {'2', '-', 'H', 'e', 'l', 'l', 'o', '!', '\r', '\n'};
unsigned char array3[] = {51, 45, 72, 101, 108, 108, 111, 33, 13, 10};
unsigned char array4[] = "4-Hello!\r\n";
在串口调试助手下,发送十六进制的
1
,
2
,
3
,
4
,使用字符形式显示的话,会分别往电脑上送这
4
个数组中对应的那个数组。我们只是在起始位置做了区分,其他均没做区分。大家可以比较一下效果。
此外还要说明一点,数组
1
和数组
4
,数组
1
我们是发完整的字符串,而数组
4
我们仅仅发送数组中的字符,没有发结束符号。串口调试助手用字符形式显示是没有区别的,但是大家如果改用十六进制显示,大家会发现数组
1
比数组
4
多了一个字节’\0’的
ASCII
值
00
。
#include <reg52.h>
bit cmdArrived = 0; //
命令到达标志,即接收到上位机下发的命令
unsigned char cmdIndex = 0; //
命令索引,即与上位机约定好的数组编号
unsigned char cntTxd = 0; //
串口发送计数器
unsigned char *ptrTxd = 0; //
串口发送指针
unsigned char array1[] = "1-Hello!\r\n";
unsigned char array2[] = {'2', '-', 'H', 'e', 'l', 'l', 'o', '!', '\r', '\n'};
unsigned char array3[] = {51, 45, 72, 101, 108, 108, 111, 33, 13, 10};
unsigned char array4[] = "4-Hello!\r\n";
void ConfigUART(unsigned int baud);
void main ()
{
ConfigUART(9600); //
配置波特率为
9600
EA = 1; //
开总中断
while(1)
{
if (cmdArrived)
{
cmdArrived = 0;
switch (cmdIndex)
{
case 1:
ptrTxd = array1; //
数组
1
的首地址赋值给发送指针
cntTxd = sizeof(array1); //
数组
1
的长度赋值给发送计数器
TI = 1; //
手动方式启动发送中断,处理数据发送
break;
case 2:
ptrTxd = array2;
cntTxd = sizeof(array2);
TI = 1;
break;
case 3:
ptrTxd = array3;
cntTxd = sizeof(array3);
TI = 1;
break;
case 4:
ptrTxd = array4;
cntTxd = sizeof(array4) - 1;//
字符串长度为数组长度减
1
TI = 1;
break;
default:
break;
}
}
}
}
void ConfigUART(unsigned int baud) //
串口配置函数,
baud
为波特率
{
SCON = 0x50; //
配置串口为模式
1
TMOD &= 0x0F; //
清零
T1
的控制位
TMOD |= 0x20; //
配置
T1
为模式
2
TH1 = 256 - (11059200/12/32) / baud; //
计算
T1
重载值
TL1 = TH1; //
初值等于重载值
ET1 = 0; //
禁止
T1
中断
ES = 1; //
使能串口中断
TR1 = 1; //
启动
T1
}
void InterruptUART() interrupt 4
{
if (RI) //
接收到字节
{
RI = 0; //
手动清零接收中断标志位
cmdIndex = SBUF; //
接收到的数据保存到命令索引中
cmdArrived = 1; //
设置命令到达标志
}
if (TI) //
字节发送完毕
{
TI = 0; //
手动清零发送中断标志位
if (cntTxd > 0) //
有待发送数据时,继续发送后续字节
{
SBUF = *ptrTxd; //
发出指针指向的数据
cntTxd--; //
发送计数器递减
ptrTxd++; //
发送指针递增
}
}
}
12.4 1602液晶的认识12.4.1 1602液晶的硬件接口介绍
[size=14.0000pt]
前边我们讲的流水灯、数码管、
LED
点阵这三种都是
LED
设备,这节课我们来学习一下
LCD
显示设备——
1602
液晶。那个大大的,平时第一行显示
16
个小黑块,第二行什么都不显示的东西就是
1602
液晶,是不是早就注意到它了呢?
大家学习这些电子器件,头脑中要逐渐形成一种意识,不管是我们的单片机,还是
74HC138
,甚至我们的三极管等等,都是有数据手册的。不管是设计电路还是编写程序,器件的数据手册是我们最好的参考资料,那我们今天来学习
1602
,首先就要看它的数据手册。手册大家可以在网上找到,这里我讲的时候只挑手册的重点讲。
首先我们来看一个主要技术参数表格,如表
12-3
所示。
表
12-3 1602
液晶主要技术参数
1602
液晶,从它的名字我们就可以理解他的显示容量,就是可以显示
2
行,每行
16
个字符的液晶。他的工作电压
4.5V
到
5.5V
,这个我们设计电路的时候,直接按照
5V
系统设计,但是保证我们的
5V
系统最低不能低于
4.5V
。在
5V
工作电压下测量它的工作电流是
2mA
,大家注意,这个
2mA
仅仅是指液晶,而它的黄绿背光都是用
LED
做的,所以功耗不会太小的,一二十毫安还是有的。
1602
液晶一共
16
个引脚,每个引脚的功能,我们都可以在它的数据手册上获得。而这些基本的信息,在我们设计电路和编写代码之前,必须先看明白,如表
12-4
所示。
表
12-4 1602
液晶引脚功能
液晶的电源
1
脚
2
脚以及背光电源
15
脚
16
脚,不用多说,正常接就可以了。
3
脚叫做液晶显示偏压信号,大家注意到小黑块没有,当我们要显示一个字符的时候,有的黑点显示,有的黑点就不能显示,这样就可以实现我们想要的字符了。我们这个
3
脚就是用来调整显示的黑点和不显示的之间的对比度,调整好了对比度,就可以让我们的显示更加清晰一些。在进行电路设计实验的时候,通常的办法是在这个引脚上接个电位器,也就是我们初中学过的滑动变阻器,是一个东西。通过调整电位器的分压值,来调整
3
脚的电压。而当产品批量生产的时候,我们可以把我们调整好的这个值直接用简单电路来实现,就如同在我们板子上,我们直接使用的是一个
18
欧的下拉电阻,市面上有的
1602
的下拉电阻大概
1
到
1.5k
也是比较合适的值。
4
脚是数据命令选择端。我们的液晶,有时候我们要发送一些命令,让他实现我们想要的一些状态,有时候我们要发给他一些数据,让他显示出来,液晶就通过这个引脚来判断接收到的是数据还是命令,这个引脚我们接到了
ADDR0
上,通过跳线帽和
P1.0
连接在一起。大家注意学会读手册,看到我们这个引脚描述里:数据
/
命令选择端,而后跟了括号
(H/L)
,他的意思就是当这个引脚是
H(High)
高电平的时候,是数据,当这个引脚是
L(Low)
低电平的时候,是命令。
5
脚和
4
脚用法类似,只是功能是读写选择端。我们既可以写给液晶数据或者命令,也可以读取液晶内部的数据,就是控制这个引脚。因为液晶本身内部有
RAM
,实际上我们送给液晶的命令或者数据,液晶需要先保存在缓存里,然后再写到内部的寄存器或者
RAM
中,这个就需要一定的时间。所以我们再进行读写操作之前,首先要读一下液晶当前状态,是不是在“忙”,如果不忙,我们可以读写数据,如果在“忙”,我们需要等待液晶忙完了,再进行操作。读状态是常用的,不过读液晶数据我接触的场合没怎么用过,大家了解这个功能即可。这个引脚我们接到了
ADDR1
上,通过跳线帽和
P1.1
连接在一起。
6
脚是使能信号,很关键,液晶的读写命令和数据,都要靠它才能正常读写,我们后边详细讲这个引脚怎么用。这个引脚我们通过跳线帽接到了
ENLCD
上,这个位置的跳线帽是为了和我们将来以后另外一个
12864
液晶的切换使用的。
7
到
14
引脚就是
8
个数据引脚了,我们就是通过这
8
个引脚读写数据和命令的。我们统一接到了
P0
总线上了。我们来看一下我们开发板上的
1602
的原理图,如图
12-1
所示。
图12-1 1602
液晶原理图
12.4.2 1602液晶的读写时序介绍[size=14.0000pt]
1602
液晶内部带了
80
个字节的
RAM
,用来存储我们发送的数据,他的结构如图
12-2
所示。
图12-2 1602
内部
RAM
结构图
第一行的地址是
00H
到
27H
,第二行的地址从
40H
到
67H
,其中第一行
00H
到
0FH
是和液晶上第一行
16
个字符显示位置相互对应,第二行
40H
到
4FH
是和第二行
16
个字符显示位置相互对应的。而每行都多出来一部分,是为了显示移动字幕设置的。
1602
字符液晶是显示字符的,因此他是跟
ASCII
字符表是对应的。比如我们给
00H
这个地址写一个’a’,也就是
10
进制的
97
,液晶的最左上方的那个小块就会显示一个
a
。此外,我们本章学过指针了,液晶内部有个数据指针,它指向哪里,我们写的那个数据就会送到相应的那个地址里。
液晶有一个状态字字节,我们可以通过了解这个状态字的内容,就可以知道
1602
液晶的一些内部情况,如表
12-5
所示。
这个状态字节的
8
位,最高位表示了当前液晶是不是“忙”,如果这个状态字是
1
表示液晶正“忙”,禁止我们读写数据或者命令,如果是
0
,则可以进行读写。而低
7
位就表示了当前数据地址指针的位置。
1602
的基本操作时序,一共有
4
个,这些大家都不需要记住,但是都需要理解,因为我们现在不是为了应付考试了,所以不需要你把手册背的滚瓜烂熟,但是你写程序的时候,打开手册能看懂如何操作,还要再提醒一句,单片机读外部状态前,必须先保证自己是高电平哦。
我们这里要做
1602
液晶程序,因此我们我们的声明写成:
#define LCD1602_DB P0
sbit LCD1602_RS = P1^0;
sbit LCD1602_RW = P1^1;
sbit LCD1602_E = P1^5;
1、读状态:
RS=L
,
R/W=H
,
E=H
。这是个很简单的逻辑,就是说,我们直接写
LCD1602_DB = 0xFF;
LCD1602_RS = 0;
LCD1602_RW = 1;
LCD1602_E = 1;
sta = LCD1602_DB;
这样就把当前液晶的状态字读到了
sta
这个变量中,我们可以通过判断
sta
的最高位来了解当前液晶是否处于“忙”状态,也可以得知当前数据的指针位置。两个问题,问题一是如果我们当前读到的状态是“不忙”,那么我们程序可以进行读写操作,如果当前状态是“忙”,那么我们还得继续等待重新判断液晶的状态;问题二,大家可以看我们的原理图,我们的流水灯、数码管、点阵,
1602
液晶都用到了
P0
口总线,我们读完了液晶状态继续保持LCD1602_E 是高电平的话,
1602
液晶会继续输出它的状态值,输出的这个值会占据了
P0
总线,干扰到流水灯数码管等其他外设,所以我们读完了状态,通常要把这个引脚拉低来释放总线,这里我们用了一个
do...while
循环语句来实现。
LCD1602_DB = 0xFF;
LCD1602_RS = 0;
LCD1602_RW = 1;
do // do...while
循环语句
{
LCD1602_E = 1;
sta = LCD1602_DB; //
读取状态字
LCD1602_E = 0; //
读完了要关闭使能,防止液晶输出数据干扰总线
} while (sta & 0x80); //bit7
等于
1
表示液晶正忙,重复检测直到其等于
0
为止
2、读数据:
RS=H
,
R/W=L
,
E=H
。这个逻辑也很简单,但是读数据不常用,大家了解一下就可以了,这里就不详细解释了。
3、写指令:
RS=L
,
R/W=L
,
D0~D7=
指令码,
E=
高脉冲。
这个在逻辑上没什么难的,只是
E=
高脉冲这个问题要解释一下。这个指令一共有
4
条语句,其中前三条语句顺序无所谓,但是
E=
高脉冲这一句很关键。实际上流程是这样的:因为我们现在是写数据,所以我们首先要保证我们的
E
引脚是低电平状态,而前三句不管我们怎么写,
1602
液晶只要没有接收到
E
引脚的使能控制,它都不会来读总线上的信号的。当通过前三句准备好数据之后,
E
使能引脚从低电平到高电平变化,然后
E
使能引脚再从高电平到低电平出现一个下降沿,
1602
液晶内部一旦检测到这个下降沿后,并且检测到
RS=L
,
R/W=L
,就马上来读取
D0~D7
的数据,完成单片机写
1602
指令过程。归纳总结我们写了个
E=
高脉冲,就是
E
使能引脚先从低拉高,再从高拉低,形成一个高脉冲,就是这个意思。
4、写数据:
RS=H
,
R/W=L
,
D0~D7=
数据,
E=
高脉冲。
写数据和写指令是类似的,就是把
RS
改成
H
,把总线改成数据即可。
此外,这里要提一句,液晶
1602
所使用的通信时序是摩托罗拉公司所创立的
6800
时序,大家知道这么回事即可。
这里还要说明一个问题,就是从这 4 个时序大家可以看出来,我们的 LCD1602 的使能引脚 E ,高电平的时候是有效,低电平的时候是无效。如果这个引脚是个高电平的话,那么就可能被液晶认为是读指令或者读数据, 1602 就会占用 P0 口往外送数据。由于 P0 口同时控制了 LED 小灯、数码管、点阵 LED 以及 1602 液晶,如果 1602 往外送数据, P0 口就会干扰到其他外设。因此我们如果正常情况下,用单片机 IO 口直接连到 1602 的这个 E 引脚上,只要我们用 LED 小灯、数码管、点阵,那么我们程序上来就写一句LCD1602_E=0 ,就可以避免 1602 干扰到其他外设。我们之前的程序没有加这句,是因为我们板子在这个引脚上加了一个 15K 的下拉电阻,这个下拉电阻就可以保证这个引脚上电默认后是低电平,如图 12-3 所示。
这里还要说明一个问题,就是从这 4 个时序大家可以看出来,我们的 LCD1602 的使能引脚 E ,高电平的时候是有效,低电平的时候是无效。如果这个引脚是个高电平的话,那么就可能被液晶认为是读指令或者读数据, 1602 就会占用 P0 口往外送数据。由于 P0 口同时控制了 LED 小灯、数码管、点阵 LED 以及 1602 液晶,如果 1602 往外送数据, P0 口就会干扰到其他外设。因此我们如果正常情况下,用单片机 IO 口直接连到 1602 的这个 E 引脚上,只要我们用 LED 小灯、数码管、点阵,那么我们程序上来就写一句LCD1602_E=0 ,就可以避免 1602 干扰到其他外设。我们之前的程序没有加这句,是因为我们板子在这个引脚上加了一个 15K 的下拉电阻,这个下拉电阻就可以保证这个引脚上电默认后是低电平,如图 12-3 所示。
图12-3 液晶下拉电阻
如果不加这个下拉电阻,刚开始讲点亮LED小灯的时候,我们就得写一句:
LCD1602_E
=0,可能很多初学者容易弄不明白,所以我们才加了这样一个电路。但是在实际开发过程中,就不必要这样了。如果这是个实际产品,能用软件去处理的,我们就不会用硬件去实现,所以大家在做实际产品的时候,这块电路可以直接去掉,只需要在程序中最开始多加一条语句即可。
12.4.3 1602液晶的指令介绍
[size=14.0000pt]
和单片机寄存器的用法类似,1602
液晶在使用的时候,我们首先要进行初始化的功能配置,
1602
液晶有以下几个指令需要了解。
1、显示模式设置。
写指令
0x38
,设置
16x2
显示,
5x7
点阵,
8
位数据接口。这条指令对我们这个液晶来说是固定的,必须写
0x38
,大家仔细看会发现我们的液晶实际上内部点阵是
5x8
的,还有一些
1602
液晶还兼容串行通信,用
2
个
IO
口即可,但是速度慢,我们这个液晶固定的
0x38
的模式。
2、显示开
/
关以及光标设置指令。
这里有
2
条指令,第一条指令一个字节中
8
位,其中高
5
位是固定的
00001
,低
3
位我们假设是
DCB
从高到底表示,
D=1
表示开显示,
D=0
表示关显示;
C=1
表示显示光标,
C=0
表示不显示光标;
B=1
表示光标闪烁,
B=0
表示光标不闪烁。
第二条指令高
6
位是固定的
000001
,低
2
位我们分别用
NS
从高到底表示,其中
N=1
表示读或者写一个字符后,指针自动加
1
,光标自动加
1
,
N=0
表示读或者写一个字符后指针自动减
1
,光标自动减
1
;
S=1
表示写一个字符后,整屏显示左移
(N=1)
或右移
(N=0)
,以达到光标不移动而屏幕移动的效果,如同我们的计算器输入一样的效果,而
S=0
表示写一个字符后,整屏显示不移动。
3
、清屏指令。
固定的,写入
01H
表示显示清屏,其中包含了数据指针清零,所有的显示清零。写入
02H
后,仅仅是数据指针清零,显示不清零。
4
、
RAM
地址设置指令
该指令码的最高位为
1
,低
7
位为
RAM
的地址,
RAM
地址与液晶上字符的关系如上图
12-2
所示。通常,我们在读写数据之前都要先设置好地址,然后再进行数据的读写操作。
12.4.4 1602液晶简易程序例程[size=14.0000pt]
1602
液晶手册提供了一个初始化过程,但是它写的比较复杂,我们这边总结了一个更加简易方便的过程提供给大家,手册上描述的那个,大家仅仅作为了解就可以了,下面我把程序写出来大家看下,我们的初始化只用了
4
条语句,没有像手册介绍的那么繁琐。
#include <reg52.h>
#define LCD1602_DB P0
sbit LCD1602_RS = P1^0;
sbit LCD1602_RW = P1^1;
sbit LCD1602_E = P1^5;
void LcdInit();
void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str);
void main ()
{
unsigned char str[] = "Kingst Studio";
LcdInit();
LcdShowStr(2, 0, str);
LcdShowStr(0, 1, "Welcome to KST51");
while(1)
{}
}
void LcdWaitReady() //读取“忙”表示,等待液晶准备好
{
unsigned char sta;
LCD1602_DB = 0xFF;
LCD1602_RS = 0;
LCD1602_RW = 1;
do // do...while
循环语句
{
LCD1602_E = 1;
sta = LCD1602_DB; //
读取状态字
LCD1602_E = 0; //
读完了要关闭使能,防止液晶输出数据干扰总线
} while (sta & 0x80); //bit7
等于
1
表示液晶正忙,重复检测直到其等于
0
为止
}
void LcdWriteCmd(unsigned char cmd) //
写入命令函数
{
LcdWaitReady();
LCD1602_RS = 0;
LCD1602_RW = 0;
LCD1602_DB = cmd;
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_DB = dat;
LCD1602_E = 1;
LCD1602_E = 0;
}
void LcdInit() //
液晶初始化函数
{
LcdWriteCmd(0x38); //16*2
显示,
5*7
点阵,
8
位数据接口
LcdWriteCmd(0x0C); //
显示器开,光标关闭
LcdWriteCmd(0x06); //
文字不动,地址自动加
1
LcdWriteCmd(0x01); //
清屏
}
void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str) //
显示字符串,屏幕起始坐标
(x,y)
,字符串指针
str
{
unsigned char addr;
//
由输入的显示坐标计算显示
RAM
的地址
if (y == 0)
{
addr = 0x00 + x; //
第一行字符地址从
0x00
起始
}
else
{
addr = 0x40 + x; //
第二行字符地址从
0x40
起始
}
//
由起始显示
RAM
地址连续写入字符串
LcdWriteCmd(addr | 0x80); //
写入起始地址
while (*str != '\0') //
连续写入字符串数据,直到检测到结束符
{
LcdWriteDat(*str); //
注意
*str
就是这个指针指向的内容
str++;
}
}
程序我注释的已经很详细了,有几个地方再说两句。首先,我们把程序所有的功能都使用函数模块化了,这样非常有利于我们程序的维护,不管要写一个什么样的功能,只要调用相应的函数就可以了,大家注意学习这个技巧。
其次,我们使用液晶的习惯,也是喜欢用数学上的
X,Y
坐标来进行定位,而和数学不同的是,液晶的左上角的坐标是
x=0
,
y=0
,往右边是
x+
偏移,下边是
y+
偏移。
第三,第一次接触多个参数传递的函数,稍微注意熟悉一下。
第四,读写数据和指令程序,每次都必须进行“忙”判断。
第五,领略一下指针在这个地方的巧妙用法,你可以尝试不用指针改写程序试试,感受一下指针的优势。