找回密码
 立即注册

QQ登录

只需一步,快速开始

搜索
查看: 17897|回复: 11
打印 上一主题 下一主题
收起左侧

第12章 指针的基础与1602液晶的初步认识

  [复制链接]
跳转到指定楼层
楼主
ID:1 发表于 2013-9-28 15:03 | 只看该作者 |只看大图 回帖奖励 |倒序浏览 |阅读模式
郑重声明:
本教材现以连载的方式由网络发布,并将于2014年由清华大学出版社出版最终完整版,版权归作者和清华大学出版社所有。本着开源、分享的理念,本教材可以自由传播及学习使用,但是务必请注明出处来自www.kingst.org,未经作者同意不得用于任何商业目的。最终解释权归金沙滩工作室所有.

我们在上C语言课的时候,学到指针,每一位教C语言的老师都会告诉我们一句:指针是C语言的灵魂。由此可见,指针是否学会是判断一个人是否真正学会C语言的重要指标之一,但是很多同学只知道其重要性,却没学会其灵活性。
简单的程序,100来行代码,不需要指针我们可以轻松搞定,但是当代码写到几千上万行甚至更多的时候,利用指针就可以直接而快速的处理内存中的各种数据结构中的数据,特别是数组、字符串和内存的动态分配等,它为函数之间各类数据传递提供了简洁便利的方法。说了这么多作用估计大家没用过指针也体会不到,但这里就是表达这样一个意思,指针很重要,必须要学会。
指针相对其他知识点来说比较难讲,主要在于例子不好举。简单的程序用指针去做会把简单的程序搞复杂,复杂的程序用指针去写牵扯的知识太多可能又不好理解。从一个角度讲,没学会指针就等于没学会C语言,所以再难也不是我们学不好的理由。这节课我就从我对指针的理解尽可能的把指针形象的介绍给大家,帮大家啃下这块硬骨头,同学们学习这节课内容也要打起十二分精神,集中注意力认真去学,争取拿下指针。
12.1 指针的基本概念和指针变量的声明12.1.1 变量的地址
要研究指针,我们得先来深入理解内存地址这个概念。打个比方:整个内存就相当于一个拥有很多房间的大楼,每个房间都有房间号,比如从101102103直到NNN,我们可以说这些房间号就是房间的地址,相应的内存中的每个单元也都有自己的编号,比如从0x000x010x02直到0xNN,我们同样可以说这些编号就是内存单元的地址。房间里可以住人,对应的内存单元里就可以“住进”变量了:假如一位名字叫A的人住在101房间,我们可以说A的住址就是101,或者101就是A的住址;对应的,假如一个名为x的变量住在编号为0x00的这个内存单元中,那么我们可以说变量x的内存地址就是0x00,或者0x00就是变量x的地址。
基本的内存单元是字节,英文单词为Byte,我们所使用的STC89C52RC单片机共有512字节的RAM,就是我们所谓的内存,但它分为内部256字节和外部256字节,我们仅以内部的256字节为例,很明显其地址的编号从0开始就是0x000xFF。我们用C语言定义的各种变量就存在0x000xFF的地址范围内,而不同类型的变量会占用不同数量的内存单元,即字节,可以结合前面讲过的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
    变量abcd之间的变量类型不同,因此在内存中所占的存储单元也不一样,ab都占一个字节,c占了2个字节,而d占了4个字节。那么,a的地址就是0x00b的地址就是0x01c的地址就是0x02d的地址就是0x04,它们的地址的表达方式可以写成:&a&b&c&d。这样就代表了相应变量的地址,C语言中变量前加一个&表示取这个变量的地址,&这个符号就叫做“取址符”。
讲到这里,有一点延伸内容,大家可以了解下:比如变量cunsigned int类型的,占了2个字节,存储在了0x020x03这两个内存地址上,那么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、比较运算。比较的前提是两个指针指向同种类型的对象,比如两个指针变量pq他们指向了具有同种数据类型的数组,那他们可以进行<>>=<===等关系运算。如果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],有一个地方要注意,既然数组名代表了数组元素的首地址,那么也就是说pnumber都是指数组的首地址。对二维数组来说,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 常量和符号常量
在程序运行过程中,其值不能被改变的量称之为常量。常量分为不同的类型,有整型常量如123100等;浮点型常量3.140.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、普通字符就是那些我们可以直接书写直接看到的有形的字符,比如阿拉伯数字09,英文字符Az,以及标点符号等。它们都是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";
在串口调试助手下,发送十六进制的1234,使用字符形式显示的话,会分别往电脑上送这4个数组中对应的那个数组。我们只是在起始位置做了区分,其他均没做区分。大家可以比较一下效果。
此外还要说明一点,数组1和数组4,数组1我们是发完整的字符串,而数组4我们仅仅发送数组中的字符,没有发结束符号。串口调试助手用字符形式显示是没有区别的,但是大家如果改用十六进制显示,大家会发现数组1比数组4多了一个字节’\0’的ASCII00

#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.5V5.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
背光源负极
液晶的电源12脚以及背光电源1516脚,不用多说,正常接就可以了。
3脚叫做液晶显示偏压信号,大家注意到小黑块没有,当我们要显示一个字符的时候,有的黑点显示,有的黑点就不能显示,这样就可以实现我们想要的字符了。我们这个3脚就是用来调整显示的黑点和不显示的之间的对比度,调整好了对比度,就可以让我们的显示更加清晰一些。在进行电路设计实验的时候,通常的办法是在这个引脚上接个电位器,也就是我们初中学过的滑动变阻器,是一个东西。通过调整电位器的分压值,来调整3脚的电压。而当产品批量生产的时候,我们可以把我们调整好的这个值直接用简单电路来实现,就如同在我们板子上,我们直接使用的是一个18欧的下拉电阻,市面上有的1602的下拉电阻大概11.5k也是比较合适的值。
4脚是数据命令选择端。我们的液晶,有时候我们要发送一些命令,让他实现我们想要的一些状态,有时候我们要发给他一些数据,让他显示出来,液晶就通过这个引脚来判断接收到的是数据还是命令,这个引脚我们接到了ADDR0上,通过跳线帽和P1.0连接在一起。大家注意学会读手册,看到我们这个引脚描述里:数据/命令选择端,而后跟了括号(H/L),他的意思就是当这个引脚是H(High)高电平的时候,是数据,当这个引脚是L(Low)低电平的时候,是命令。
5脚和4脚用法类似,只是功能是读写选择端。我们既可以写给液晶数据或者命令,也可以读取液晶内部的数据,就是控制这个引脚。因为液晶本身内部有RAM,实际上我们送给液晶的命令或者数据,液晶需要先保存在缓存里,然后再写到内部的寄存器或者RAM中,这个就需要一定的时间。所以我们再进行读写操作之前,首先要读一下液晶当前状态,是不是在“忙”,如果不忙,我们可以读写数据,如果在“忙”,我们需要等待液晶忙完了,再进行操作。读状态是常用的,不过读液晶数据我接触的场合没怎么用过,大家了解这个功能即可。这个引脚我们接到了ADDR1上,通过跳线帽和P1.1连接在一起。
6脚是使能信号,很关键,液晶的读写命令和数据,都要靠它才能正常读写,我们后边详细讲这个引脚怎么用。这个引脚我们通过跳线帽接到了ENLCD上,这个位置的跳线帽是为了和我们将来以后另外一个12864液晶的切换使用的。
714引脚就是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结构图
第一行的地址是00H27H,第二行的地址从40H67H,其中第一行00H0FH是和液晶上第一行16个字符显示位置相互对应,第二行40H4FH是和第二行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=LR/W=HE=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=HR/W=LE=H。这个逻辑也很简单,但是读数据不常用,大家了解一下就可以了,这里就不详细解释了。
3、写指令:RS=LR/W=LD0~D7=指令码,E=高脉冲。
        这个在逻辑上没什么难的,只是E=高脉冲这个问题要解释一下。这个指令一共有4条语句,其中前三条语句顺序无所谓,但是E=高脉冲这一句很关键。实际上流程是这样的:因为我们现在是写数据,所以我们首先要保证我们的E引脚是低电平状态,而前三句不管我们怎么写,1602液晶只要没有接收到E引脚的使能控制,它都不会来读总线上的信号的。当通过前三句准备好数据之后,E使能引脚从低电平到高电平变化,然后E使能引脚再从高电平到低电平出现一个下降沿,1602液晶内部一旦检测到这个下降沿后,并且检测到RS=LR/W=L,就马上来读取D0~D7的数据,完成单片机写1602指令过程。归纳总结我们写了个E=高脉冲,就是E使能引脚先从低拉高,再从高拉低,形成一个高脉冲,就是这个意思。
4、写数据:RS=HR/W=LD0~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所示。

12-3 液晶下拉电阻
    如果不加这个下拉电阻,刚开始讲点亮LED小灯的时候,我们就得写一句:LCD1602_E=0,可能很多初学者容易弄不明白,所以我们才加了这样一个电路。但是在实际开发过程中,就不必要这样了。如果这是个实际产品,能用软件去处理的,我们就不会用硬件去实现,所以大家在做实际产品的时候,这块电路可以直接去掉,只需要在程序中最开始多加一条语句即可。


12.4.3 1602液晶的指令介绍
[size=14.0000pt]
和单片机寄存器的用法类似,1602液晶在使用的时候,我们首先要进行初始化的功能配置,1602液晶有以下几个指令需要了解。
1、显示模式设置。
写指令0x38,设置16x2显示,5x7点阵,8位数据接口。这条指令对我们这个液晶来说是固定的,必须写0x38,大家仔细看会发现我们的液晶实际上内部点阵是5x8的,还有一些1602液晶还兼容串行通信,用2IO口即可,但是速度慢,我们这个液晶固定的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,光标自动加1N=0表示读或者写一个字符后指针自动减1,光标自动减1S=1表示写一个字符后,整屏显示左移(N=1)或右移(N=0),以达到光标不移动而屏幕移动的效果,如同我们的计算器输入一样的效果,而S=0表示写一个字符后,整屏显示不移动。
3、清屏指令。
固定的,写入01H表示显示清屏,其中包含了数据指针清零,所有的显示清零。写入02H后,仅仅是数据指针清零,显示不清零。
4RAM地址设置指令
该指令码的最高位为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=0y=0,往右边是x+偏移,下边是y+偏移。
第三,第一次接触多个参数传递的函数,稍微注意熟悉一下。
第四,读写数据和指令程序,每次都必须进行“忙”判断。
第五,领略一下指针在这个地方的巧妙用法,你可以尝试不用指针改写程序试试,感受一下指针的优势。
12.5 作业
1、把本节课的指针相关内容,反复学习35遍,彻底弄懂了指针是怎么回事,不知道如何用没关系,即使是背,也得把这部分背下来,等到后边我们用的时候,就可以实现顿悟。学会指针,就是突破了C语言的一道重要的堡垒。
2、把1602所有的指令功能都应用一遍,能够灵活使用1602液晶显示任意字符串。
[size=12.0000pt]3、尝试通过串口调试助手下发字符在1602液晶上显示出来。

评分

参与人数 1黑币 +5 收起 理由
秋葉原48 + 5

查看全部评分

分享到:  QQ好友和群QQ好友和群 QQ空间QQ空间 腾讯微博腾讯微博 腾讯朋友腾讯朋友
收藏收藏8 分享淘帖 顶2 踩
回复

使用道具 举报

沙发
ID:69833 发表于 2014-12-16 18:58 来自手机 | 只看该作者
挺好挺好,学会了不少东西
回复

使用道具 举报

板凳
ID:74433 发表于 2015-3-24 21:18 | 只看该作者
讲的很详细透彻
回复

使用道具 举报

地板
ID:74245 发表于 2015-11-19 08:11 | 只看该作者
真的写得很好,很适合初学者。
回复

使用道具 举报

5#
ID:225339 发表于 2017-8-27 14:30 | 只看该作者
仔细看了这篇教程 我也在开发板上试了 是存在问题的  就是 字符的 重复显示 我改了一下  现在 编译通过且成功 特地分享
/*问题 1602重复显示*/
#include<reg52.h>
#define LCD1602_DB  P0                //1602 IO口
sbit LCD1602_RD=P1^2;        // 数据命令选择端  H DATA  L CMD
sbit LCD1602_RW=P1^3;   //  读写端    H  READ  L WRITE                                                                                                                     
sbit LCD1602_E=P1^4;        //   使能
void readbusy();      //判忙 1忙 0空
void write_data(unsigned char dat);
void write_cmd(unsigned char cmd);
void LCD1602_INIT();
unsigned char code word1[]={"cjb study 1602"};
void LCDShowstr(unsigned char addr_start,unsigned char *p);   //x  y表示坐标 p表示指针变量 即数组word的首地址
void main()
{

        LCD1602_INIT();
        LCDShowstr(0x80,word1);
        while(1){}
}
void LCD1602_INIT()                  //1602INIT
{
        write_cmd(0x38);           //显示模式设置  1602 固定命令
        write_cmd(0x0c);                //开显示器 关闭光标   0000 1DCB   D=1开显示 C=1显示光标 B=1 光标闪烁
        write_cmd(0x06);                 //文字不动  地址自动加1   0000 01NS N=1 写入一个字符后 地址自动加1 S=0 写入一个字符 整屏不移动
        write_cmd(0x01);                //清屏
}
/*1602每次的写入都要确保其处于不忙的状态  状态字的最高位D7代表其是否忙碌1 禁止写入 0 应许写入*/
void readbusy()
{
        unsigned char sta;
        LCD1602_DB=0XFF;    //P0置位,判断D7是不是1602拉低的
        LCD1602_RD=0;
        LCD1602_RW=1;    //读D7状态           RD  RW 不能写错
        do{
        LCD1602_E=1;      //使能
        sta=LCD1602_DB;
        }while(sta&0x80);                //等到sta 首位为0 时 退出循环  此时1602处于空闲状态
        LCD1602_E=0;                  //读完了关闭使能  液晶输出数据对总线的干扰
}
void write_data(unsigned char dat)                 //写数据
{
    readbusy();
        LCD1602_RD=1;                           //参考时序图
        LCD1602_RW=0;
        LCD1602_DB=dat;
        LCD1602_E=1;
        LCD1602_E=0;
}
void write_cmd(unsigned char cmd)        //写命令
{
    readbusy();
        LCD1602_RD=0;                                   //参考时序图
        LCD1602_RW=0;
    LCD1602_DB=cmd;
        LCD1602_E=1;
        LCD1602_E=0;
}
void LCDShowstr(unsigned char addr_start,unsigned char *p)
{
        write_cmd(addr_start);
        while(*p!='\0')
        {
                write_data(*p++);
        }
}
至于问题出在哪里 我认为 是坐标哪里有问题  希望大神求解
回复

使用道具 举报

6#
ID:95509 发表于 2017-10-12 16:32 | 只看该作者
耐心学习!谢谢楼主
回复

使用道具 举报

7#
ID:155764 发表于 2017-11-1 10:36 | 只看该作者
绝世好贴!
回复

使用道具 举报

8#
ID:220927 发表于 2018-1-4 12:09 | 只看该作者
指针的使用最为灵活也最麻烦,这降解真心好,理解好的画用的非常好
回复

使用道具 举报

9#
ID:262591 发表于 2018-1-16 11:59 来自手机 | 只看该作者
最近在学指针,非常棒的文章。因为读的比较仔细,所以看到流水灯例程时发现个小错误,应该是P1=~buf
回复

使用道具 举报

10#
ID:281447 发表于 2018-3-20 16:23 | 只看该作者
液晶屏“读数据”操作,应该是R/W = H吧?
回复

使用道具 举报

11#
ID:93625 发表于 2018-7-12 13:21 | 只看该作者
学习内容越来越难了,努力中
回复

使用道具 举报

12#
ID:339500 发表于 2018-8-2 02:23 | 只看该作者
为什么不直接出个新版讲解单片机视频呢,主页推荐的郭天祥的视频第一模糊,第二视频播放速度和声音速度延迟8S左右,虽然我可以通过下载视频通过软件吧延迟拟补回来但是真的太麻烦了,而且郭视频也需要新一代更新更新呀
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

手机版|小黑屋|51黑电子论坛 |51黑电子论坛6群 QQ 管理员QQ:125739409;技术交流QQ群281945664

Powered by 单片机教程网

快速回复 返回顶部 返回列表