指针和lcd1602的认识

时间:2022-05-08 04:43:51
我们在上 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  变量存储方式
内存地址
存储的数据
0xFF
... ...
... ...
... ...
0x07
d
0x06
d
0x05
d
0x04
d
0x03
c
0x02
c
0x01
b
0x00
a
    变量 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  常用转义字符及含义
字符形式
含义
\n
换行
\t
横向跳格 ( 相当于 Tab)
\v
竖向跳格
\b
退格
\r
光标移到行首
\\
反斜杠字符’\’
\’
单引号字符
\”
双引号字符
\f
走纸换页
\0
空值
    表格不需要大家记住,用到了,过来查就可以了。
字符串常量是用双引号括起来的字符序列,一般我们都称之字符串。如”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 液晶主要技术参数
显示容量
16 x 2 个字符
芯片工作电压
4.5~5.5V
工作电流
2.0mA(5.0V)
模块最佳工作电压
5.0V
字符尺寸
2.95 x 4.35mm ( 宽乘高 )
1602 液晶,从它的名字我们就可以理解他的显示容量,就是可以显示 2 行,每行 16 个字符的液晶。他的工作电压 4.5V 5.5V ,这个我们设计电路的时候,直接按照 5V 系统设计,但是保证我们的 5V 系统最低不能低于 4.5V 。在 5V 工作电压下测量它的工作电流是 2mA ,大家注意,这个 2mA 仅仅是指液晶,而它的黄绿背光都是用 LED 做的,所以功耗不会太小的,一二十毫安还是有的。
1602 液晶一共 16 个引脚,每个引脚的功能,我们都可以在它的数据手册上获得。而这些基本的信息,在我们设计电路和编写代码之前,必须先看明白,如表 12-4 所示。
12-4 1602 液晶引脚功能
编号
符号
引脚说明
编号
符号
引脚说明
1
VSS
电源地
9
D2
Data  I/O
2
VDD
电源正极
10
D3
Data  I/O
3
VL
液晶显示偏压信号
11
D4
Data  I/O
4
RS
数据 / 命令选择端 (H/L)
12
D5
Data  I/O
5
R/W
/ 写选择端 (H/L)
13
D6
Data  I/O
6
E
使能信号
14
D7
Data  I/O
7
D0
Data  I/O
15
BLA
背光源正极
8
D1
Data  I/O
16
BLK
背光源负极
液晶的电源 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 所示。
指针和lcd1602的认识
图12-1 1602 液晶原理图
12.4.2 1602液晶的读写时序介绍[size=14.0000pt]
1602 液晶内部带了 80 个字节的 RAM ,用来存储我们发送的数据,他的结构如图 12-2 所示。
指针和lcd1602的认识 
图12-2 1602 内部 RAM 结构图
第一行的地址是 00H 27H ,第二行的地址从 40H 67H ,其中第一行 00H 0FH 是和液晶上第一行 16 个字符显示位置相互对应,第二行 40H 4FH 是和第二行 16 个字符显示位置相互对应的。而每行都多出来一部分,是为了显示移动字幕设置的。 1602 字符液晶是显示字符的,因此他是跟 ASCII 字符表是对应的。比如我们给 00H 这个地址写一个’a’,也就是 10 进制的 97 ,液晶的最左上方的那个小块就会显示一个 a 。此外,我们本章学过指针了,液晶内部有个数据指针,它指向哪里,我们写的那个数据就会送到相应的那个地址里。
液晶有一个状态字字节,我们可以通过了解这个状态字的内容,就可以知道 1602 液晶的一些内部情况,如表 12-5 所示。
STA0-6
当前数据的指针的值
STA7
读写操作使能
1 :禁止    0 :允许
这个状态字节的 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 所示。

指针和lcd1602的认识  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_E  = 1;
    LCD1602_E  = 0;
}
void LcdWriteDat(unsigned char dat)  // 写入数据函数
{
    LcdWaitReady();
    LCD1602_RS = 1;
    LCD1602_RW = 0;
    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+ 偏移。
第三,第一次接触多个参数传递的函数,稍微注意熟悉一下。
第四,读写数据和指令程序,每次都必须进行“忙”判断。
第五,领略一下指针在这个地方的巧妙用法,你可以尝试不用指针改写程序试试,感受一下指针的优势。