linquan315
发表于 2017-1-19 23:20:44
膜拜一下,特意保存了书签,以后慢慢看
linquan315
发表于 2017-1-19 23:22:46
感谢楼主的分享精神!只可惜当年没有这本书啊,自己在黑暗中摸索
吴坚鸿
发表于 2017-1-23 14:16:02
第五十四节: 从全局变量和局部变量中感悟“栈”为何物。
【54.1 本节阅读前的名词约定。】
变量可以粗略的分成两类,一类是全局变量,一类是局部变量。如果更深一步精细划分,全局变量还可以分成“普通全局变量”和“静态全局变量”,局部变量也可以分成“普通局部变量”和“静态局部变量”,也就是说,若精细划分,可以分成四类。其中“静态全局变量”和“静态局部变量”多了一个前缀“静态”,这个前缀“静态”是因为在普通的变量前面多加了一个修饰关键词“static”,这部分的内容后续章节会讲到。本节重点为了让大家理解内存模型的“栈”,暂时不考虑“静态变量”的情况,人为约定,本节所涉及的“全局变量”仅仅默认为“普通全局变量”,“局部变量”仅仅默认为“普通局部变量”。
【54.2 如何判定全局变量和局部变量?】
全局变量就是在函数外面定义的变量,局部变量就是在函数内部定义的变量,这是最直观的判定方法。下面的例子能很清晰地说明全局变量和局部变量的判定方法:
unsigned char a; //在函数外面定义的,所以是全局变量。
void main()//主函数
{
unsigned char b; //在函数内部定义的,所以是局部变量。
b=a;
while(1)
{
}
}
【54.3 全局变量和局部变量的内存模型。】
单片机内存包括ROM和RAM两部分,ROM存储的是单片机程序中的指令和一些不可更改的常量数据,而RAM存放的是可以被更改的变量数据,也就是说,全局变量和局部变量都是存放在RAM,但是,虽然都是存放在RAM,全局变量和局部变量之间的内存模型还是有明显的区别的,因此,分了两个不同的RAM区,全局变量占用的RAM区称为“全局数据区”,局部变量占用的RAM区称为“栈”,因为我后面会用宾馆来比喻“栈”,为了方便记忆,大家可以把“栈”想象成 “客栈”来记忆。它们的内存模型到底有什么本质的区别呢?“全局数据区”就像你自己家的房间,是唯一的,一个房间的地址只能你一个人住(假设你还没结婚的时候),而且是永久的,所以说每个全局变量都有唯一对应的RAM地址,不可能重复的。而“栈”就像宾馆客栈,一年下来每天晚上住的人不一样,每个人在里面居住的时间是有期限的,不是长久的,一个房间的地址一年下来每天可能住进不同的人,不是唯一的。“全局数据区”的全局变量拥有永久产权,“栈”区的局部变量只能临时居住在宾馆客栈,地址不是唯一的,有期限的。全局变量像私人区,局部变量像公共区。“栈”的这片公共区,是给程序里所有函数内部的局部变量共用的,函数被调用的时候,该函数内部的每个局部变量就会被分配对应到“栈”的某个RAM地址,函数调用结束后,该局部变量就失效,因此它对应的“栈”的RAM空间就被收回以便给下一个被调用的函数的局部变量占用。请看下面这个例子,我借用“宾馆客栈”来比喻局部变量所在的“栈”。
void HanShu(void); //子函数的声明
void HanShu(void) //子函数的定义
{
unsigned char a; //局部变量
a=1;
}
void main() //主函数
{
HanShu() ; //子函数的调用
}
分析:上述例子,单片机从主函数main往下执行,首先遇到HanShu子函数的调用,所以就跳到HanShu函数的定义那里开始执行,此时的局部变量a开始被分配在RAM的“栈区”的某个地址,相当于你入住宾馆被分配到某个房间。单片机执行完子函数HanShu后,局部变量a在RAM的“栈区”所分配的地址被收回,局部变量a消失,被收回的RAM地址可能会被系统重新分配给其它被调用的函数的局部变量,此时相当于你离开宾馆,从此你跟那个宾馆的房间没有啥关系,你原来在宾馆入住的那个房间会被宾馆老板重新分配给其他的客人入住。全局变量的作用域是永久性不受范围限制的,而局部变量的作用域就是它所在函数的内部范围。全局变量的“全局数据区”是永久的私人房子(这里的“永久”仅仅是举一个例子,别拿“70年产权”来抬杠),局部变量的“栈”是临时居住的“客栈”。重要的事情说两遍,再次总结如下:
(1)每定义一个新的全局变量,就意味着多开销一个新的RAM内存。而每定义一个局部变量,只要在函数内部所定义的局部变量总数不超过单片机的“栈”区,此时的局部变量不开销新的RAM内存,因为局部变量是临时借用“栈”区的,使用后就还给“栈”,“栈”是公共区,可以重复利用,可以服务若干个不同的函数内部的局部变量。
(2)单片机每次进入执行函数时,局部变量都会被初始化改变,而全局变量则不会被初始化,全局变量是一直保存之前最后一次更改的值。
【54.4 三个常见疑问。】
第一个疑问:
问:“全局数据区”和“栈区“是谁在幕后分配的,怎么分配的?
答:是C编译器自动分配的,至于怎么分配,谁分配多一点,谁分配少一点,C编译器会有一个默认的比例分配,我们一般都不用管。
第二个疑问:
问:“栈”区是临时借用的,子函数被调用的时候,它内部的局部变量才会“临时”被分配到“栈”区的某个地址,那么问题来了,谁在幕后主持“栈区”这些分配的工作,难道也是C编译器?C编译器不是在编译程序的时候一次性就做完了编译工作然后就退出历史舞台了吗?难道我们程序已经在单片机内部运转的时候,编译器此时还在幕后指手画脚的起作用?
答:单片机已经上电开始运行程序的时候,编译器是不可能起作用的。所以,真相只有一个,“栈区”分配给函数内部局部变量的工作,确实是C编译器做的,唯一需要注意的地方是,它不是“现炒现卖”,而是在单片机上电前,C编译器就把所有函数内部的局部变量的分配工作就规划好了,都指定了如果某个函数一旦被调用,该函数内部的哪个局部变量应该分到“栈区”的哪个地址,C编译器都是事先把这些“后事”都交代完毕了才“结束自己的生命”,后面,等单片机上电开始工作的时候,虽然C编译器此时“不在”了,但是单片机都是严格按照C编译器交代的“遗嘱”开始工作和分配“栈区”的。因此,“栈区”的“临时分配”非真正严格意义上的“临时分配”。
第三个疑问:
问:函数内部所定义的局部变量总数不超过单片机的“栈”区的RAM数量,那,万一超过了“栈”区的RAM数量,后果严重吗?
答:后果特别严重。这种情况,专业术语叫“爆栈”。程序会出现异常,而且是莫名其妙的异常。为了避免这种情况,一般在编写程序的时候,函数内部都不能定义大数组的局部变量,局部变量的数量不能定义太多太大,尤其要避免刚才所说的定义开辟大数组局部变量这种情况。大数组的定义应该定义成全局变量,或者定义成“静态的局部变量”(“静态”这部分相关的内容后面章节会讲到)。有一些C编译器,遇到“爆栈”的情况,会好心跟你提醒让你编译不过去,但是也有一些C编译器可能就不会给你提醒,所以大家以后做项目写函数的时候,要对“爆栈”心存敬畏。
【54.5 全局变量和局部变量的优先级。】
刚才说到,全局变量的作用域是永久性并且不受范围限制的,而局部变量的作用域就是它所在函数的内部范围,那么问题来,假如局部变量和全局变量的名字重名了,此时函数内部执行的变量到底是局部变量还是全局变量?这个问题就涉及到优先级。注意,当面对同名的局部变量和全局变量时,函数内部执行的变量是局部变量,也就是局部变量在函数内部要比全局变量的优先级高。为了深刻理解“全局变量和局部变量的优先级”,强烈建议大家必须仔细看完下面列举的三个练习例子。
【54.6 例程练习和分析。】
请看下面第一个例子:
/*---C语言学习区域的开始。-----------------------------------------------*/
unsigned char a=5; //此处第1个a是全局变量。
void main() //主函数
{
unsigned char a=2;//此处第2个a是局部变量。跟上面全局变量的第1个a重名了!
View(a);//把a发送到电脑端的串口助手软件上观察。
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
分析:
上述例子,有2个变量重名了!其中一个是全局变量,另外一个是局部变量。此时输出显示的结果是5还是2?正确的答案是2。因为在函数内部,函数内部的局部变量比全局变量的优先级更加高。此时View(a)是第2个局部变量的a,而不是第1个全局变量的a。虽然这里的两个a重名了,但是它们的内存模型不一样,第1个全局变量的a是分配在“全局数据区”是具有唯一的地址的,而第2个局部变量的a是被分配在临时的“栈”区的,寄生在main函数内部。
再看下面第二个例子:
/*---C语言学习区域的开始。-----------------------------------------------*/
void HanShu(void); //函数声明
unsigned char a=5; //此处第1个a是全局变量。
void HanShu(void) //函数定义
{
unsigned char a=3;//此处第2个a是局部变量。
}
void main() //主函数
{
unsigned char a=2;//此处第3个a也是局部变量。
HanShu();//子函数被调用
View(a);//把a发送到电脑端的串口助手软件上观察。
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
分析:
上述例子,有3个变量重名了!其中一个是全局变量,另外两个是局部变量。此时输出显示的结果是5还是3还是2?正确的答案是2。因为,HanShu这个子函数是被调用结束之后,才执行View(a)的,就意味HanShu函数内部的局部变量(第2个局部变量a)是在执行View(a)语句的时候就消亡不存在了,所以此时View(a)的a是第3个局部变量的a(在main函数内部定义的局部变量的a)。
再看下面第三个例子:
/*---C语言学习区域的开始。-----------------------------------------------*/
void HanShu(void); //函数声明
unsigned char a=5; //此处第1个a是全局变量。
void HanShu(void) //函数定义
{
unsigned char a=3;//此处第2个a是局部变量。
}
void main() //主函数
{
HanShu();//子函数被调用
View(a);//把a发送到电脑端的串口助手软件上观察。
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
分析:
上述例子,有2个变量重名了!其中一个是全局变量,另外一个是局部变量。此时输出显示的结果是5还是3?正确的答案是5。因为,HanShu这个子函数是被调用结束之后,才执行View(a)的,就意味HanShu函数内部的局部变量(第2个局部变量)是在执行View(a)语句的时候就消亡不存在了,同时,因为此时main函数内部也没有定义a的局部变量,所以此时View(a)的a是必然只能是第1个全局变量的a(在main函数外面定义的全局变量的a)。
【54.7 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
吴坚鸿
发表于 2017-1-24 16:58:08
第五十五节: 函数的作用和四种常见书写类型。
【55.1 函数和变量的命名规则。】
函数的名字和变量的名字一样,一般是由“字母,数字,下划线”三者组成。第1个字符不能是数字,必须是字母或者下划线“_”,后面紧跟的第2个字符开始可以是数字。在C语言中名字所用的字母是区分大小写的。可以用下划线“_”,但是不可以用横杠“-”。名字不能跟C编译系统已经征用的关键字重名,比如不能用“unsigned ”,“char”,“static”等系统关键词,跟古代时不能跟皇帝重名一样,要避尊者讳。
【55.2 函数的作用和分类。】
函数的作用。通常把一些可能反复用到的算法或者过程封装成一个函数,函数就是一个模块,给它输入特定的参数,就可以输出想要的结果,比如一个加法函数,只要输入加数和被加数,然后就会输出相加结果之和,里面具体的算法过程只要写一次就可以重复调用,极大的节省单片机程序容量,也节省程序开发人员的工作量。还有一类函数,它从封装上看无所谓“输入输出”,这类函数往往是针对某一种可能重复使用的“过程”。
函数的分类。暂时排除指针的情况下(指针的内容后续章节会讲到),从输入输出的角度来看,有四种常见的书写类型。分别是“无输出无输入,无输出有输入,有输出无输入,有输出有输入”。“输出”是看函数名的前缀,前缀如果是void表示“无输出”,否则就是“有输出”。“输入”是看函数名括号里的内容,如果是void或者是空着就表示“无输入”,否则就是“有输入”。“输出”和“输入”是比较通俗的说法,专业一点的说法是,“有输出”表示函数“有返回”,“无输出”表示函数“无返回”。“有输入”表示函数“有形参”,“无输入”表示函数“无形参”。下面举一个加法函数的例子,分别用四种不同的函数类型来实现,通过对比它们之间的差别,来体会它们在书写方面有哪些不同,又有哪些规律。
【55.3 第1类:“无输出”“无输入”的函数。】
unsigned char a;//此变量用来接收最后相加结果的和。
unsigned char g=2;
unsigned char h=3;
void HanShu(void)//“无输出”“无输入”函数的定义。
{
a=g+h;
}
main()
{
HanShu(); //函数的调用。此处括号内的形参void要省略,否则编译不通过。
}
分析:void HanShu(void),此函数名的前缀是void,括号内也是void,属于“无输出”“无输入”函数。这类函数表面看是“无输出”“无输入”,其实内部是通过全局变量来输入输出的,比如上面的例子就是靠a,g,h这三个全局变量来传递信息,只不过这类表达方式比较隐蔽,没有那么直观。
【55.4 第2类:“无输出”“有输入”的函数。】
unsigned char b;//此变量用来接收最后相加结果的和。
void HanShu(unsigned char i,unsigned char k) //“无输出”“有输入”函数的定义。
{
b=i+k;
}
main()
{
HanShu(2,3);//函数的调用。
}
分析:void HanShu(unsigned char i,unsigned char k),此函数名的前缀是void,括号内是(unsigned char i,unsigned char k),属于“无输出”“有输入”的函数。括号的两个变量i和k是函数内的局部变量,也是跟对外的桥梁接口,它们有一个专业的名称叫形参。外部要调用此函数时,只要给括号填入对应的变量或者数值,这些变量和数值就会被复制一份传递给作为函数形参的局部变量(比如本例子中的i和k),从而外部调用者跟函数内部就发生了数据信息的传递。这种书写方式的特点是把输入接口封装了出来。
【55.5 第3类:“有输出”“无输入”的函数。】
unsigned char c; //此变量用来接收最后相加结果的和。
unsigned char m=2;
unsigned char n=3;
unsigned char HanShu(void) //“有输出”“无输入”函数的定义。
{
unsigned char p;
p=m+n;
return p;
}
main()
{
c=HanShu();//函数的调用。此处括号内的形参void要省略,否则编译不通过。
}
分析:unsigned char HanShu(void),此函数名的前缀是unsigned char类型,括号内是void,属于“有输出”“无输入”的函数。函数前缀的unsigned char表示此函数最后退出时会返回一个unsigned char类型的数据给外部调用者。而且这类函数内部必须有一个return语句配套,表示立即退出当前函数并且返回某个变量或者常量的数值给外部调用者。这种书写方式的特点是把输出接口封装了出来。
【55.6 第4类:“有输出”“有输入”的函数。】
unsigned char d; //此变量用来接收最后相加结果的和。
unsigned char HanShu(unsigned char r,unsigned char s) //“有输出”“有输入”函数的定义
{
unsigned char t;
t=r+s;
return t;
}
main()
{
d=HanShu(2,3);//函数的调用。
}
分析:unsigned char HanShu(unsigned char r,unsigned char s),此函数名的前缀是unsigned char类型,括号内是(unsigned char r,unsigned char s),属于“有输出”“有输入”的函数。输入输出的特点跟前面介绍的函数一样,不多讲。这种书写方式的特点是把输出和输入接口都封装了出来。
【55.7 函数在被“调用”时需要注意的地方。】
函数的三要素是“声明,定义,调用”。函数在被“调用”的时候,对于“无输入”的函数,形参的void关键词要省略,否则编译不通过,这里仅仅是指在函数在被“调用”的时候。
【55.8 例程练习和分析。】
现在编写一个练习程序,要求编写4个不同“输入输出”封装的函数,它们每个函数所实现的功能都是一样的,都是加法的算法函数,它们之间仅仅是外观的封装接口不同而已。
/*---C语言学习区域的开始。-----------------------------------------------*/
void hanshu_1(void);
void hanshu_2(unsigned char i,unsigned char k);
unsigned char hanshu_3(void);
unsigned char hanshu_4(unsigned char r,unsigned char s);
unsigned char a; //此变量用来接收第1个函数最后相加结果的和。
unsigned char g=2;
unsigned char h=3;
unsigned char b; //此变量用来接收第2个函数最后相加结果的和。
unsigned char c; //此变量用来接收第3个函数最后相加结果的和。
unsigned char m=2;
unsigned char n=3;
unsigned char d; //此变量用来接收第4个函数最后相加结果的和。
void hanshu_1(void)//第1类:“无输出”“无输入”。
{
a=g+h;
}
void hanshu_2(unsigned char i,unsigned char k)//第2类:“无输出”“有输入”。
{
b=i+k;
}
unsigned char hanshu_3(void) //第3类:“有输出”“无输入”。
{
unsigned char p;
p=m+n;
return p;
}
unsigned char hanshu_4(unsigned char r,unsigned char s)//第4类:“有输出”“有输入”。
{
unsigned char t;
t=r+s;
return t;
}
void main() //主函数
{
hanshu_1(); //第1类:“无输出”“无输入”的函数调用。这里的形参的void要省略。
hanshu_2(2,3); //第2类:“无输出”“有输入”的函数调用。
c=hanshu_3(); //第3类:“有输出”“无输入”的函数调用。这里的形参的void要省略。
d=hanshu_4(2,3);//第4类:“有输出”“有输入”的函数调用。
View(a);//把a发送到电脑端的串口助手软件上观察。
View(b);//把b发送到电脑端的串口助手软件上观察。
View(c);//把c发送到电脑端的串口助手软件上观察。
View(d);//把d发送到电脑端的串口助手软件上观察。
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:5
十六进制:5
二进制:101
第2个数
十进制:5
十六进制:5
二进制:101
第3个数
十进制:5
十六进制:5
二进制:101
第4个数
十进制:5
十六进制:5
二进制:101
分析:
变量a为5。
变量b为5。
变量c为5。
变量d为5。
【55.9 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
吴坚鸿
发表于 2017-2-12 10:16:05
第五十六节: return在函数中的作用以及四个容易被忽略的功能。
【56.1 return深入讲解。】
return在英语单词中有“返回”的意思,上一节提到,凡是“有输出”的函数,函数内部必须有一个“return+变量或者常量”与之配套,表示返回的结果给外部调用者接收,这个知识点很容易理解,但是容易被忽略的是另外四个功能:
第一个是return语句隐含了立即退出的功能。退出哪?退出当前函数。只要执行到return语句,就马上退出当前函数。即使return语句身陷多层while或者for的循环中,它也毫不犹豫立即退出当前函数。
第二个是return语句可以出现在函数内的任何位置。可以出现在第一行代码,也可以出现在中间的某行代码,也可以出现在最后一行的代码,它的位置不受限制。很多初学者有个错觉,以为return只能出现在最后一行,这是错的。
第三个是return语句不仅仅可以用在“有输出”的函数,也可以用在“无输出”的函数,也就是可以用在前缀是void的函数里。回顾上一节,在“有输出”的函数里,return后面紧跟一个变量或者常量,表示返回的数,但是在“无输出”的函数里,因为是“无输出”,此时return后面不用跟任何变量或者常量,这种写法也是合法的,表示返回的是空的。此时return主要起到立即退出当前函数的作用。
第四个是return语句可以在一个函数里出现N多次,次数不受限制,不一定必须只能一次。不管一个函数内有多少个return语句,只要任何一个return语句被单片机执行到,就立即退出当前函数。
【56.2 中途立即退出的功能。】
下面的书写格式是合法的:
void HanShu(void)//“无输出”函数的定义。
{
语句1;
return; //立即退出当前函数。对于这类“无输出”函数,return后面没有跟任何变量或者常量。
语句2;
return; //立即退出当前函数。对于这类“无输出”函数,return后面没有跟任何变量或者常量。
语句3;
return; //立即退出当前函数。对于这类“无输出”函数,return后面没有跟任何变量或者常量。
}
分析:当HanShu此函数被调用时,单片机从“语句1”往下执行,当遇到第一个return语句后,马上退出当前函数。后面的“语句2”和“语句3”等代码永远不会被执行到。多说一句,大家仔细看看return后面跟了什么数没有?什么都没有。因为此函数的前缀是void的,是“无输出”的。
【56.3 身陷多层while或者for的循环时的惊人表现。】
下面的书写格式是合法的:
void HanShu(void)//“无输出”函数的定义。
{
语句1;
while(1)//第一个循环
{
while(1)//第二个循环中的循环
{
return; //立即退出当前函数。
}
语句2;
return; //立即退出当前函数。
}
语句3;
return; //立即退出当前函数。
}
分析:当HanShu此函数被调用时,单片机从“语句1”往下执行,先进入第一个循环,接着进入第二个循环中的循环,然后遇到第一个return语句,于是马上退出当前函数。后面的“语句2”和“语句3”等代码永远不会被执行到。此函数中,虽然表面看起来有那么多可怕的循环约束着,但是一旦碰上return语句都是浮云,立刻退出当前函数。
【56.4 在“有输出”函数里的书写格式。】
把上面例子中“无输出”改成“有输出”的函数后:
unsigned char HanShu(void)//“有输出”函数的定义。
{
unsigned char a=9;
语句1;
while(1)//第一个循环
{
while(1)//第二个循环中的循环
{
return a; //返回a变量的值,并且立即退出当前函数。
}
语句2;
return a; //返回a变量的值,并且立即退出当前函数。
}
语句3;
return a; //返回a变量的值,并且立即退出当前函数。
}
分析:因为此函数是“有输出”的函数,所以return语句后面必须配套一个变量或者常量,此例子中配套的是a变量。当HanShu函数被调用时,单片机从“语句1”往下执行,先进入第一个循环,接着进入第二个循环中的循环,然后遇到第一个“return a”语句,马上退出当前函数。而后面的“语句2”和“语句3”等代码是永远不会被执行到的。再一次说明了,return语句不仅有返回某数的功能,还有立即退出的重要功能。
【56.5 项目中往往是跟if语句搭配使用。】
前面的例子只是为了解释return语句的执行顺序和功能,实际项目中,如果中间有多个return语句,中间的return语句不可能像前面的例子那样单独使用,它往往是跟if语句一起搭配使用,否则单独用return就没有什么意义。比如:
void HanShu(void)//“无输出”函数的定义。
{
语句1;
if(某条件满足)
{
return; //立即退出当前函数。
}
语句2;
if(某条件满足)
{
return; //立即退出当前函数。
}
语句3;
}
分析:单片机从“语句1”开始往下执行,至于在哪个“return”语句处退出当前函数,就要看哪个if的条件满不满足了,如果所有的if的条件都不满足,此函数会一直执行完最后的“语句3”才退出当前函数。
【56.6 例程练习和分析。】
写一个简单的除法函数,在除法运算中,除数不能为0,如果发现除数为0,就立即退出当前函数,并且返回运算结果默认为0。
/*---C语言学习区域的开始。-----------------------------------------------*/
//函数的声明。
unsigned int ChuFa(unsigned int BeiChuShu,unsigned int ChuShu);
//变量的定义。
unsigned int a;//此变量用来接收除法的运算结果。
unsigned int b;//此变量用来接收除法的运算结果。
//函数的定义。
unsigned int ChuFa(unsigned int BeiChuShu,unsigned int ChuShu)
{
unsigned int Shang;//返回的除法运算结果:商。
if(0==ChuShu) //如果除数等于0,就立即退出当前函数,并返回0
{
return 0; // 退出当前函数并且返回0.此时后面的代码不会被执行。
}
Shang=BeiChuShu/ChuShu;//除法运算的算法
return Shang;//返回最后的运算结果:商。并且退出当前函数。
}
void main() //主函数
{
a=ChuFa(128,0);//函数调用。128除以0,把商返回给a变量。
b=ChuFa(128,2);//函数调用。128除以2,把商返回给b变量。
View(a);//把a发送到电脑端的串口助手软件上观察。
View(b);//把b发送到电脑端的串口助手软件上观察。
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:0
十六进制:0
二进制:0
第2个数
十进制:64
十六进制:40
二进制:1000000
分析:
变量a为0。
变量b为64。
【56.7 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
kingbo
发表于 2017-2-16 20:43:02
楼主威武呀,让我等在黑暗中看见了曙光!
饭牛牛
发表于 2017-2-22 08:24:26
期待继续更新。
吴坚鸿
发表于 2017-2-26 12:29:30
第五十七节: static的重要作用。
【57.1 变量前加入static后发生的“化学反应”。】
有两类变量,一类是全局变量,一类是局部变量。定义时,在任何一类变量前面加入static关键词,变量原有的特性都会发生某些变化,因此,static像化学的催化剂,具有神奇的功能。加static关键词的书写格式如下:
static unsigned char a; //这是在全局变量前加的static关键词
void HanShu(void)
{
static unsigned char i; //这是在局部变量前加的static关键词
}
【57.2 在全局变量前加static。】
static读作“静态”,全局变量前加static,称为静态全局变量。静态全局变量和普通全局变量的功能大体相同,仅在有效范围(作用域)方面有差异。假设整个工程有多个文件组成,普通全局变量的有效范围能覆盖全部文件,在任何一个文件里,以及跨文件与文件之间,在传递信息的层面上都畅通无阻。而静态全局变量只能在当前定义的那个文件里起作用,活动范围完全被限定在一个文件,彷佛被加了紧箍咒,由不得你任性,在传递信息的层面上仅仅局限于定义变量时所在的那一个文件。这部分的内容有个大致印象就可以,暂时不用深入研究,等以后学到“多文件编程”时再关注,因为我当前的程序例子只有一个源文件,还没涉及“多文件编程”。
【57.3 在局部变量前加static。】
这是本节重点。我常把局部变量比喻宾馆的客房,客人入住时被分配在哪间客房是随机临时安排的,第二天退房时宾馆会把客房收回继续分配给下一位其他的客人,是临时公共区。而加入static后的局部变量,发生了哪些变化?加入static后的局部变量,称为静态局部变量。静态局部变量就像宾馆的VIP客户,VIP客户财大气粗,把宾馆分配的客房永远包了下来,永远不许再给其它客人入住。总结了静态局部变量的两个重要特性:
第一个,静态局部变量不会在函数调用时被初始化,它只在单片机刚上电时被初始化了一次,因为它的内存模型不是分配在“栈”,而是跟全局变量一样放在“全局数据区”,拥有自己唯一的地址。因此,静态局部变量的数值跟全局变量一样,具有“记忆”功能,你每次调用某个函数,函数内部的静态局部变量的数值是维持最后一次被更改的数值,不会被“清零”的。但是跟全局变量又有差别,全局变量的有效范围(作用域)是整个工程,而静态局部变量毕竟是“局部”,在传递信息的层面仅局限于当前函数内。而普通局部变量,众所周知,每次被函数调用时,都会被重新初始化,会被“清零”的,没有“记忆”功能的。
第二个,每次函数调用时,静态局部变量比普通局部变量少开销一条潜在的“初始化语句”,原因是普通局部变量每次被函数调用时都要重新初始化,而静态局部变量不用进行这个操作。也就是说,静态局部变量比普通局部变量的效率高一点,虽然这个“点”的时间开销微不足道,但是写程序时不能忽略这个“点”。静态局部变量用到好处之时,能体现一个工程师的功力。
【57.4 静态局部变量的应用场合。】
静态局部变量适用在那些“频繁调用”的函数,比如main函数主循环while(1)里直接调用的所有函数,还有以后讲到的定时器中断函数,等等。因为静态局部变量每次被调用都不会被重新初始化,用在这类函数时就省去了每次初始化语句的时间。还有一类用途,就是那些规定不能被函数初始化的场合,比如在很多用switch搭建程序框架的函数里,这类switch程序框架俗称为状态机思路。
【57.5 能用全局变量替代静态局部变量吗?】
能用全局变量替代静态局部变量吗?能。哪怕在整个程序里全部用全局变量都可以。全局变量是一把牛刀,什么场合都用牛刀虽然也能解决问题,但是显得鲁莽没有条理。尽量把全局变量,普通局部变量,静态局部变量各自优势充分发挥出来才是编程之道。能用局部变量的尽量用局部变量,这样可以减少全局变量的使用。当局部变量帮分担一部分工作时,最后全局变量只起到一个作用,那就是在各函数之间传递信息。局部变量与全局变量的分工定位明确了,程序代码阅读起来就没有那么凌乱,思路也清晰很多。
【57.6 例程练习和分析。】
现在编写一个程序来熟悉static的性能。
/*---C语言学习区域的开始。-----------------------------------------------*/
//函数的声明。
unsigned char HanShu(void);
unsigned char HanShu_static(void);
//变量的定义。
unsigned char a; //用来接收函数返回的结果。
unsigned char b;
unsigned char c;
unsigned char d;
unsigned char e;
unsigned char f;
//函数的定义。
unsigned char HanShu(void)
{
unsigned char i=0; //普通局部变量,每次函数调用都被初始化为0.
i++;//i自加1
return i;
}
unsigned char HanShu_static(void)
{
static unsigned char i=0; //静态局部变量,只在上电是此初始化语句才起作用。
i++;//i自加1
return i;
}
void main() //主函数
{
//下面函数内的i是普通局部变量,每次调用都会被重新初始化。
a=HanShu();//函数内的i每次重新初始化为0,再自加1,所以a等于1。
b=HanShu();//函数内的i每次重新初始化为0,再自加1,所以b等于1。
c=HanShu();//函数内的i每次重新初始化为0,再自加1,所以c等于1。
//下面函数内的i是静态局部变量,第一次上电后默认为0,就不会再被初始化,
d=HanShu_static(); //d由0自加1后等于1。
e=HanShu_static(); //e由1自加1后等于2。
f=HanShu_static(); //f由2自加1后等于3。
View(a);//把第1个数a发送到电脑端的串口助手软件上观察。
View(b);//把第2个数b发送到电脑端的串口助手软件上观察。
View(c);//把第3个数c发送到电脑端的串口助手软件上观察。
View(d);//把第4个数d发送到电脑端的串口助手软件上观察。
View(e);//把第5个数e发送到电脑端的串口助手软件上观察。
View(f);//把第6个数f发送到电脑端的串口助手软件上观察。
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:1
十六进制:1
二进制:1
第2个数
十进制:1
十六进制:1
二进制:1
第3个数
十进制:1
十六进制:1
二进制:1
第4个数
十进制:1
十六进制:1
二进制:1
第5个数
十进制:2
十六进制:2
二进制:10
第6个数
十进制:3
十六进制:3
二进制:11
分析:
变量a为1。
变量b为1。
变量c为1。
变量d为1。
变量e为2。
变量f 为3。
【57.7 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
吴坚鸿
发表于 2017-2-26 12:31:50
第五十八节: const(或code)在定义数据时的作用。
【58.1 const与code的关系。】
const与code都是语法的修饰关键词,放在所定义的数据前面时有“不可更改”之意。在C语言语法中,const像普通话全国通用,是标准的语言;而code像地方的方言,仅仅适合针对51单片机的C51编译器环境。而其它大多数单片机的C编译器并不支持code,只支持const。比如PIC,stm32等单片机的C编译器都是只认const而不认code的。通常情况下,const定义的数据都是放在ROM的,但是51单片机的C51编译器是例外,它并不把const定义的数据放在ROM区,只有用code关键词时它才会把数据放在ROM区,这一点相对其它大多数的单片机来说是不一样的。因为本教程是用51单片机的C51编译器,所以用code来替代const。本节教程所提到的const,在实际编程时都用code来替代。
【58.2 const(或code)在定义数据时的终极目的。】
在数据定义分配的应用中,const的终极目的是为了节省RAM的开销。从“读”和“写”的角度分析,数据有两种:“能读能写”和“只能读”这两种。 “能读能写”的数据占用RAM内存,叫变量,C语言语法上定义此类数据时“无”const前缀。 “只能读”的数据占用ROM内存,叫常量, C语言语法上定义此类数据时“有”const前缀。单片机的ROM容量比RAM容量往往大几十倍甚至上百倍,相比之下,RAM的资源显得比较稀缺。因此,把某些只需“读”而不需“写”的数据定义成const放在ROM,就可以节省RAM的开销。
【58.3 const(或code)的应用场合。】
const可以定义单个常量,也可以定义常量数组。定义单个常量时,通常应用在某个出现在程序多处并且需要经常调整的“阀值”参数,方便“一键更改”的操作。所谓“一键更改”,就是只要改一次const所定义初始化的某个常量,整个程序多次出现的此常量就自动更改了。定义常量数组时,通常应用在某个数据转换表,把某些固定的常量预先放到常量数组,通过数组的下标来“查表”。
【58.4 const(或code)的语法格式。】
定义单个常量和常量数组时的语法是以下这个样子的:
const unsigned char x=10;//定义单个常量。加了const。
const unsigned char y={31,28,31,30,31,30,31,31,30,31,30,31}; //定义常量数组。加了const。
【58.5 const(或code)的“能读”和“不可写”概念】
所谓“读”和“写”的能力,其实就是看某数能在赋值符号“=”的“右边”还是“左边”的能力。普通的变量,既可以在赋值符号“=”的“右边”(能读),也可以在赋值符号“=”的“左边”(能写)。比如,下面的写法是合法的:
unsigned char k=1;//这是普通的变量,无const前缀。
unsigned char n=2;//这是普通的变量,无const前缀。
n=k; //k出现在赋值符号“=”的右边,表示能读。合法。
k=n; //k出现在赋值符号“=”的左边,表示能写,可更改之意。合法。
但是如果一旦在普通的变量前面加了const(或code)关键词,就会发生“化学变化”,原来的“变量”就变成了“常量”,常量只能“读”,不能“写”。比如:
const unsigned char c=1;//这是常量,有const前缀。
unsigned char n=2;//这是普通的变量,无const前缀。
n=c; //c是常量,能读,这是合法的。这行代码是语法正确的。
c=n; //c是常量,不能写,这是非法的,C编译器不通过。这行代码是语法错误的。
【58.6 const(或code)能在函数内部定义吗?】
const(或code)能在函数内部定义吗?能。语法是允许的。当在函数内部定义数据成const(或者code),在数据的存储结构上,数据也是放在ROM区的(实际上在51单片机里想把数据放在ROM只能用code而不能用const),把数据定义在函数内部,就只能在这个函数里面用,不能被其它函数调用。在作用域的问题上,const(或者code)的常量数据跟其它变量的数据是一样的。比如:
void HanShu(void)
{
const unsigned char c=1;//在函数内部定义的const常量也是放在ROM区存储。
unsigned char n=2;
n=c; //c是常量,在函数内部定义,只能在当前这个HanShu函数里调用。
}
【58.7 例程练习和分析。】
本教程使用的是51单片机的C51编译器,编写程序时为了让常量数据真正存储在ROM区,因此,本教程的程序例子都是用code替代const。
本例程讲两个例子,一个是单个常量,一个是常量数组。
(1)单个常量。举的例子是“阀值”的“一键更改”应用。根据考试的分数,分两个等级。凡是大于或者等于90分的就是“优”,串口助手输出显示“1”。凡是小于90分的就是“良”,串口助手输出显示“0”。这里的“90分”就是我所说的“阀值”概念,只要用一个const定义一个常量数据来替代“90”,当需要调整“阀值”时,只要更改一次此定义的常量数值就可以达到“一键更改”之目的。
(2)常量数组。举的例子是,查询2017年12个月的某个月的总天数,用两种思路实现,一种是switch分支语句来实现,另一种是const常量数组的“查表”思路来实现。通过对比这两种思路,你会发现const常量数组在做“转换表”这类“查表”项目时的强大优越性。
/*---C语言学习区域的开始。-----------------------------------------------*/
//函数的声明。
unsigned char HanShu_switch(unsigned char u8Month);
unsigned char HanShu_const(unsigned char u8Month);
//数据的定义。
code unsigned char Cu8Level=90;//需要调整“阀值”时,只需更改一次这里的“90”这个数值。
code unsigned char Cu8MonthBuffer= //每个月对应的天数。从数组下标0开始,0代表1月...
{31,28,31,30,31,30,31,31,30,31,30,31};
unsigned char a; //用来接收函数返回的结果。
unsigned char b;
unsigned char c;
unsigned char d;
//函数的定义。
unsigned char HanShu_switch(unsigned char u8Month)//用switch分支来实现。
{
switch(u8Month)
{
case 1: //1月份的天数
return 31;
case 2: //2月份的天数
return 28;
case 3: //3月份的天数
return 31;
case 4: //4月份的天数
return 30;
case 5: //5月份的天数
return 31;
case 6: //6月份的天数
return 30;
case 7: //7月份的天数
return 31;
case 8: //8月份的天数
return 31;
case 9: //9月份的天数
return 30;
case 10://10月份的天数
return 31;
case 11://11月份的天数
return 30;
case 12://12月份的天数
return 31;
default://万一输错了其它范围的月份,就默认返回30天。
return 30;
}
}
unsigned char HanShu_const(unsigned char u8Month) //用const常量数组的“查表”来实现。
{
unsigned char u8GetDays;
u8Month=u8Month-1;//因为数组下标是从0开始,0代表1月份,1代表2月份。所以减去1。
u8GetDays=Cu8MonthBuffer; //这就是查表,马上获取常量数组表格里固定对应的天数。
return u8GetDays;
}
void main() //主函数
{
//第(1)个例子
if(89>=Cu8Level)//大于或者等于阀值,就输出1。
{
a=1;
}
else//否则输出0。
{
a=0;
}
if(95>=Cu8Level)//大于或者等于阀值,就输出1。
{
b=1;
}
else//否则输出0。
{
b=0;
}
//第(2)个例子
c=HanShu_switch(2);//用switch分支的函数获取2月份的总天数。
d=HanShu_const(2); //用const常量数组“查表”的函数获取2月份的总天数。
View(a);//把第1个数a发送到电脑端的串口助手软件上观察。
View(b);//把第2个数b发送到电脑端的串口助手软件上观察。
View(c);//把第3个数c发送到电脑端的串口助手软件上观察。
View(d);//把第4个数d发送到电脑端的串口助手软件上观察。
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:0
十六进制:0
二进制:0
第2个数
十进制:1
十六进制:1
二进制:1
第3个数
十进制:28
十六进制:1C
二进制:11100
第4个数
十进制:28
十六进制:1C
二进制:11100
分析:
a为0。
b为1。
c为28。
d为28。
【58.8 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
kiwil
发表于 2017-3-2 18:59:57
顶一下!!!!!
dhw5qq
发表于 2017-3-2 20:45:29
理想固然丰满,现实太骨感!
吴坚鸿
发表于 2017-3-5 13:30:45
第五十九节: 全局“一键替换”功能的#define。
【59.1 #define作用和书写格式。】
上一节讲const的时候,讲到了当某个常量在程序中是属于需要频繁更改的“阀值”的时候,用const就可以提供“一键更改”的快捷服务。本节的#define也具有此功能,而且功能比const更加强大灵活,它除了可以应用在常量,还可以应用在运算式以及函数的“一键更改”中。所谓“一键更改”,其实是说,#define内含了“替换”的功能,此“替换”跟word办公软件的“替换”功能几乎是一模一样的。#define的“替换”功能,除了在某些场合起到“一键更改”的作用,还可以在某些场合,把一些在字符命名上不方便阅读理解的常量、运算式或函数先“替换”成容易理解的字符串,让程序阅读起来更加清晰更加方便维护。#define的常见三种书写格式如下:
#define字符串常量 //注意,这里后面没有分号“;”
#define字符串运算式 //注意,这里后面没有分号“;”
#define字符串函数 //注意,这里后面没有分号“;”
具体一点如下:
#defineAA1 //常量
#defineBB(a+b+c)//运算式
#defineC add() //函数
需要注意的时候,#define后面没有分号“;”,因为它是C语言中的“预处理”的语句,不是单片机运行的程序指令语句。
【59.2 #define的编译机制。】
#define是属于“预编译”的指令,所谓“预编译”就是在“编译”之前就开始的准备工作。编译器在正式编译某个源代码的时候,先进行“预编译”的准备工作,对于#define语句,编译器是直接把#define要替换的内容先在“编辑层面”进行机械化替换,这个“机械化替换”纯粹是字符串的替换,可以理解成word办公软件的“替换”编辑功能。比如以下程序:
#define A 3
#define B (2+6) //有括号
#define C2+6 //无括号
unsigned long x=3;
unsigned long a;
unsigned long b;
unsigned long c;
void main() //主函数
{
a=x*A;
b=x*B;
c=x*C;
while(1)
{
}
}
经过编译器“预编译”的“机械化替换”后,等效于以下代码:
unsigned long x=3;
unsigned long a;
unsigned long b;
unsigned long c;
void main() //主函数
{
a=x*3;
b=x*(2+6);
c=x*2+6;
while(1)
{
}
}
【59.3 #define在常量上的“一键替换”功能。】
上一节讲const(或code)的时候,举了一个“阀值”常量的例子,这个例子可以用#define来替换等效。比如,原来const(或code)的例子如下:
code unsigned char Cu8Level=90;//需要调整“阀值”时,只需更改一次这里的“90”这个数值。
unsigned char a;
unsigned char b;
void main() //主函数
{
if(89>=Cu8Level)//大于或者等于阀值,就输出1。
{
a=1;
}
else//否则输出0。
{
a=0;
}
if(95>=Cu8Level)//大于或者等于阀值,就输出1。
{
b=1;
}
else//否则输出0。
{
b=0;
}
while(1)
{
}
}
上述程序现在用#define来替换,等效如下:
#define Cu8Level 90//需要调整“阀值”时,只需更改一次这里的“90”这个数值。
unsigned char a;
unsigned char b;
void main() //主函数
{
if(89>=Cu8Level)//大于或者等于阀值,就输出1。
{
a=1;
}
else//否则输出0。
{
a=0;
}
if(95>=Cu8Level)//大于或者等于阀值,就输出1。
{
b=1;
}
else//否则输出0。
{
b=0;
}
while(1)
{
}
}
【59.4 #define在运算式上的“一键替换”功能。】
#define在运算式上应用的时候,有一个地方要特别注意,就是必须加小括号“()”,否则容易出错。因为#define的替换是很“机械呆板”的,它只管“字符编辑层面”的机械化替换,举一个例子如下:
#define B (2+6) //有括号
#define C2+6 //无括号
unsigned long x=3;
unsigned long b;
unsigned long c;
void main() //主函数
{
b=x*B;//等效于b=x*(2+6),最终运算结果b等于24。因为3乘以8(2加上6等于8)。
c=x*C;//等效于c=x*2+6,最终运算结果c等于12。因为3乘以2等于6,6再加6等于12。
while(1)
{
}
}
上述例子中,“有括号”与“没括号”的运算结果差别很大,第一个是24,第二个是12。具体的分析已经在源代码的注释了。
【59.5 #define在函数上的“一键替换”功能。】
#define的应用很广,也可以应用在函数的“替换”上。例子如下:
void add(void);//函数的声明。
void add(void) //函数的定义。
{
a++;
}
#define a_zi_jiaadd()//用字符串a_zi_jia来替代函数add()。
unsigned long a=1;
void main() //主函数
{
a_zi_jia;//这里相当于调用函数add()。
while(1)
{
}
}
【59.6 #define在常量后面添加U或者L的特殊写法。】
有些初学者今后可能在工作中遇到#define以下这种写法:
#define字符串常量U
#define字符串常量L
具体一点如下:
#defineAA6U
#defineBB6L
常量加后缀“U”或者“L”有什么含义呢?字面上理解,U表示该常量是无符号整型unsigned int;L表示该常量是长整型long。但是在实际应用中这样“多此一举”地去强调某个常量的数据类型有什么意义呢?我自己私下也做了一些测试,目前我本人暂时还没有发现这个秘密的答案。所以对于这个问题,初学者现在只要知道这种写法在语法上是合法的就可以,至于它背后有什么玄机,有待大家今后更深的发掘。
【59.7 #define省略常量的特殊写法。】
有些初学者今后在多文件编程中,在某些头文件.h中,会经常遇到以下这类代码:
#ifndef _AAA_
#define _AAA_
#endif
其中第2行代码“#define _AAA_”后面居然没有常量,这样子的写法也行,到底是什么意思?在这类写法中,当字符串“_AAA_”后面省略了常量的时候,编译器默认会给_AAA_添加一个“非0”的常量,也许是1或者其它“非0”的值,多说一句,所谓“非0”值就是“肯定不是0”。上述代码等效于:
#ifndef _AAA_
#define _AAA_ 1//编译器会在这类默认添加一个1或者其它“非0”的常量
#endif
这个知识点大家只要先有一个感性的认识即可,暂时不用深入了解。
【59.8 例程练习和分析。】
现在编一个练习程序来熟悉#define的用法。
/*---C语言学习区域的开始。-----------------------------------------------*/
//第1个:常量的例子
#define Cu8Level 90//需要调整“阀值”时,只需更改一次这里的“90”这个数值。
unsigned char a;
unsigned char b;
//第2个:运算式的例子
#define C (2+6) //有括号
#define D2+6 //无括号
unsigned char x=3;
unsigned char c;
unsigned char d;
//第3个:函数的例子
unsigned char e=1;
void add(void);
void add(void)
{
e++;
}
#define a_zi_jiaadd()//用字符串a_zi_jia来替代函数add()。
void main() //主函数
{
//第1个:常量的例子
if(89>=Cu8Level)//大于或者等于阀值,就输出1。
{
a=1;
}
else//否则输出0。
{
a=0;
}
if(95>=Cu8Level)//大于或者等于阀值,就输出1。
{
b=1;
}
else//否则输出0。
{
b=0;
}
//第2个:运算式的例子
c=x*C;//等效于c=x*(2+6),最终运算结果c等于24。因为3乘以8(2加上6等于8)。
d=x*D;//等效于d=x*2+6,最终运算结果d等于12。因为3乘以2等于6,6再加6等于12。
//第3个:函数的例子
a_zi_jia;//这里相当于调用函数add()。e从1自加到2。
a_zi_jia;//这里相当于调用函数add()。e从2自加到3。
View(a);//把第1个数a发送到电脑端的串口助手软件上观察。
View(b);//把第2个数b发送到电脑端的串口助手软件上观察。
View(c);//把第3个数c发送到电脑端的串口助手软件上观察。
View(d);//把第4个数d发送到电脑端的串口助手软件上观察。
View(e);//把第5个数e发送到电脑端的串口助手软件上观察。
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:0
十六进制:0
二进制:0
第2个数
十进制:1
十六进制:1
二进制:1
第3个数
十进制:24
十六进制:18
二进制:11000
第4个数
十进制:12
十六进制:C
二进制:1100
第5个数
十进制:3
十六进制:3
二进制:11
分析:
a为0。
b为1。
c为24。
d为12。
e为3。
【59.9 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
饭牛牛
发表于 2017-3-6 10:14:11
学习了,继续补基础。
长夜伴灯
发表于 2017-3-8 21:41:28
学习了!
吴坚鸿
发表于 2017-3-12 12:52:47
第六十节: 指针在变量(或常量)中的基础知识。
【60.1 指针与普通变量的对比。】
普通变量和指针都是变量,都要占用RAM资源。普通变量的unsigned char类型占用1个字节,unsignedint类型占用2个字节,unsigned long类型占用4个字节。但是指针不一样,指针是一种特殊的变量,unsigned char*,unsigned int*,unsigned long*这三类指针都是一样占用4个字节。指针是普通变量的载体,平时我们处理普通变量,都是可以“直接”操作普通变量本身。而学了指针之后,我们就多一种选择,可以通过指针这个载体来“间接”操作某个普通变量。“直接”不是比“间接”更好更高效吗?为什么要用“间接”?其实在某些场合,指针的“间接”操作更加灵活更加高效,这个要看具体的应用。
指针既然是普通变量的“载体”,那么普通变量就是“物”。“载体”与“物”之间可以存在一对多的关系。也就是说,一个篮子(载体),可以盛放鸡蛋(物),也可以盛放青菜(物),也可以盛放水果(物)。
但是,在这里,一个篮子在一个时间段内,只能承载一种物品,如果想承载其它物品,必须先把当前物品“卸”下来,然后再“装”其它物品”。这里有两个关键动作“装”和“卸”,就是指针在处理普通变量时的“绑定”,某个指针与某个变量发生“绑定”,就已经包含了先“卸”后“装”这两个动作在其中。
题外话多说一句,刚才提到,unsignedint类型占用2个字节,这个是在C51编译器下的情况。如果是在stm32单片机的编译器下,unsignedint类型是占用4个字节。而“凡是指针都是4个字节”,这个描述仅仅适用于32位以下的单片机编译器(包括8位的单片机),而在某些64位的PC机,指针可能是8个字节,这些内容大家只要有个大概的了解即可。
【60.2 指针的定义。】
跟普通变量一样,指针也必须先定义再使用。为了与普通变量区分开来,指针在定义的时候多加了一个星号“*”,例子如下:
unsigned char* pu8; //针对unsigned char类型变量的指针。凡是指针都是占4个字节!
unsigned int* pu16; //针对unsigned int类型变量的指针。凡是指针都是占4个字节!
unsigned long* pu32;//针对unsigned long类型变量的指针。凡是指针都是占4个字节!
既然指针都是4个字节,为什么还要区分unsigned char*,unsigned int* pu16,unsigned long* pu32这三种类型?因为指针是为普通变量(或常量)而生,所以要根据普通变量(或常量)的类型定义对应的指针。
【60.3 指针与普通变量是如何关联和操作的?】
指针在操作某个变量的时候,必须先跟某个变量关联起来,这里的关联就是“绑定”。“绑定”后,才可以通过指针这个“载体”来“间接”操作变量。指针与普通变量在“绑定”的时候,需要用到“&”这个符号。例子如下:
unsigned char* pu8; //针对unsigned char类型变量的指针。凡是指针都是占4个字节!
unsigned char a=0; //普通的变量。
pu8=&a;//指针与普通变量发生关联(或者说绑定)。
*pu8=2;//通过指针这个载体来处理a这个变量,此时a从原来的0变成了2。
【60.4 指针处理“批量数据”的基础知识。】
之所以有通过载体来“间接”操作普通变量的存在价值,其中很重要的原因是指针在处理“批量数据”时特别给力,这里的“批量数据”是有条件的,要求这些数据的地址必须挨家挨户连起来的,不能是零零散散的“散户”,比如说,数组就是由一堆在RAM空间里地址连续的变量组合而成,指针在很多时候就是为数组而生的。先看一个例子如下:
unsigned char* pu8; //针对unsigned char类型变量的指针。凡是指针都是占4个字节!
unsigned char Buffer; //普通的数组,内含3个变量,它们地址是相连的。
pu8=&Buffer;//指针与普通变量Buffer发生关联(或者说绑定)。
*pu8=1; //通过指针这个载体来处理Buffer这个变量,此时Buffer变成了1。
pu8=&Buffer;//指针与普通变量Buffer发生关联(或者说绑定)。
*pu8=2; //通过指针这个载体来处理Buffer这个变量,此时Buffer变成了2。
pu8=&Buffer;//指针与普通变量Buffer发生关联(或者说绑定)。
*pu8=3; //通过指针这个载体来处理Buffer这个变量,此时Buffer变成了3。
分析:上述例子中,并没有体现出指针的优越性,因为数组有3个元素,居然要绑定了3次,如果数组有1000个元素,难道要绑定1000次?显然这样是繁琐低效不可取的。而要发挥指针的优越性,我们现在必须深入了解一下指针的本质是什么,指针跟普通变量发生“绑定”的本质是什么。普通变量由“地址”和“地址所装的数据”构成,指针是特殊的变量,它是由什么构成呢?其实,指针是由“地址”和“地址所装的变量(或常量)的地址”组成。很明显,一个重要的区别是,普通变量装的数据,而指针装的是地址。正因为指针装的是地址,所以指针可以有两种选择,第一种可以处理“装的地址”,第二种可以处理“装的地址的所在数据”,这两种能力,就是指针的精华和本质所在,也是跟普通变量的区别所在。那么指针处理“装的地址”的语法是什么样子的?请看例子如下:
unsigned char* pu8; //针对unsigned char类型变量的指针。凡是指针都是占4个字节!
unsigned char Buffer; //普通的数组,内含3个变量,它们地址是相连的。
pu8=&Buffer;//处理“装的地址”。把 Buffer变量的地址装在指针这个载体里。
*pu8=1; //处理“装的地址的所在数据”。此时Buffer变成了1。
pu8++; //处理“装的地址”。这里是“地址”自加1,相当于指针此时装的是Buffer的地址。
*pu8=2; //处理“装的地址的所在数据”。此时Buffer变成了2。
pu8++; //处理“装的地址”。这里是“地址”自加1,相当于指针此时装的是Buffer的地址。
*pu8=3; //处理“装的地址的所在数据”。此时Buffer变成了3。
上述例子中,利用“地址”自加1的操作,省去了2条赋值式的“绑定”操作(比如像pu8=&Buffer这类语句),因此“绑定”本质其实就是更改指针所装的“变量(或常量)的地址”的操作。此例子中虽然还没体现了出指针在数组处理时的优越性,但是利用指针处理“装的地址”这项功能,在实际项目中很容易发现它的好处。
【60.5 指针与数组关联(绑定)时省略“&和下标”的写法。】
指针与数组关联的时候,通常是跟数组的第0个元素的地址关联,此时,可以把数组的“&和下标”省略,比如:
unsigned char* pu8;
unsigned char Buffer;
pu8=Buffer; //此行代码省略了“&和下标”,等效于pu8=&Buffer;
【60.6 带const关键字的常量指针。】
指针也可以跟常量关联起来,处理常量,但是常量只能“读”不能“写”,所以通过指针操作常量的时候也是只能“读”不能“写”。操作常量的指针用const关键词修饰,强调此指针只有“读”的操作。例子如下:
const unsigned char* pCu8; //常量指针
code char Cu8Buffer={5,6,7}; //常量数组
unsigned char b;
unsigned char c;
unsigned char d;
pCu8=Cu8Buffer; //此行代码省略了“&和下标”,等效于pCu8=&Cu8Buffer;
b=*pCu8; //读“装的地址的所在数据”。b等于5。
pCu8++; //所装的地址自加1,跟Cu8Buffer关联
c=*pCu8; //读“装的地址的所在数据”。c等于6。
pCu8++; //所装的地址自加1,跟Cu8Buffer关联
d=*pCu8; //读“装的地址的所在数据”。d等于7。
【60.7 例程练习和分析。】
现在编一个练习程序来熟悉指针的基础知识。
/*---C语言学习区域的开始。-----------------------------------------------*/
unsigned char* pu8; //针对unsigned char类型变量的指针。凡是指针都是占4个字节!
unsigned char a=0; //普通的变量。
unsigned char Buffer; //普通的数组,内含3个变量,它们地址是相连的。
const unsigned char* pCu8; //常量指针
code char Cu8Buffer={5,6,7}; //常量数组
unsigned char b;
unsigned char c;
unsigned char d;
void main() //主函数
{
pu8=&a;//指针与普通变量发生关联(或者说绑定)。
*pu8=2;//通过指针这个载体来处理a这个变量,此时a从原来的0变成了2。
pu8=&Buffer;//处理“装的地址”。把 Buffer变量的地址装在指针这个载体里。
*pu8=1; //处理“装的地址的所在数据”。此时Buffer变成了1。
pu8++; //处理“装的地址”。这里是“地址”自加1,相当于指针此时装的是Buffer的地址。
*pu8=2; //处理“装的地址的所在数据”。此时Buffer变成了2。
pu8++; //处理“装的地址”。这里是“地址”自加1,相当于指针此时装的是Buffer的地址。
*pu8=3; //处理“装的地址的所在数据”。此时Buffer变成了3。
pCu8=Cu8Buffer; //此行代码省略了“&和下标”,等效于pCu8=&Cu8Buffer;
b=*pCu8; //读“装的地址的所在数据”。b等于5。
pCu8++; //所装的地址自加1,跟Cu8Buffer关联
c=*pCu8; //读“装的地址的所在数据”。c等于6。
pCu8++; //所装的地址自加1,跟Cu8Buffer关联
d=*pCu8; //读“装的地址的所在数据”。d等于7。
View(a); //把第1个数a发送到电脑端的串口助手软件上观察。
View(b); //把第2个数b发送到电脑端的串口助手软件上观察。
View(c); //把第3个数c发送到电脑端的串口助手软件上观察。
View(d); //把第4个数d发送到电脑端的串口助手软件上观察。
View(Buffer);//把第5个数Buffer发送到电脑端的串口助手软件上观察。
View(Buffer);//把第6个数Buffer发送到电脑端的串口助手软件上观察。
View(Buffer);//把第7个数Buffer发送到电脑端的串口助手软件上观察。
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:2
十六进制:2
二进制:10
第2个数
十进制:5
十六进制:5
二进制:101
第3个数
十进制:6
十六进制:6
二进制:110
第4个数
十进制:7
十六进制:7
二进制:111
第5个数
十进制:1
十六进制:1
二进制:1
第6个数
十进制:2
十六进制:2
二进制:10
第7个数
十进制:3
十六进制:3
二进制:11
分析:
a为2。
b为5。
c为6。
d为7。
Buffer为1。
Buffer为2。
Buffer为3。
【60.8 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
tt98
发表于 2017-3-13 19:29:37
谢谢鸿哥,好多不会用!!
吴坚鸿
发表于 2017-3-20 10:48:25
第六十一节: 指针的中转站作用,地址自加法,地址偏移法。
【61.1 指针与批量数组的关系。】
指针和批量数据的关系,更像领导和团队的关系,领导是团队的代表,所以当需要描述某个团队的时候,为了表述方便,可以把由N个人组成的团队简化成该团队的一个领导,用一个领导来代表整个团队,此时,领导就是团队,团队就是领导。指针也一样,指针一旦跟某堆数据“绑定”了,那么指针就是这堆数据,这堆数据就是该指针,所以在很多PC上位机的项目中,往往也把指针称呼为“句柄”,字面上理解,就是一句话由N个文字组成,而“句柄”就是这句话的代表,实际上“句柄”往往是某一堆资源的代表。不管是把指针比喻成“领导”、“代表”还是“句柄”,指针在这里都有“中间站”这一层含义。
【61.2 指针在批量数据的“中转站”作用。】
指针在批量数据处理中,主要是能节省代码容量,而且是非常直观的节省代码容量。为什么能节省代码容量?是因为可以把某些重复性的具体实现的功能封装成指针来操作,请看下面的例子:
程序要求:根据一个选择变量Gu8Sec的值,要从三堆数据中选择对应的一堆数据放到数组Gu8Buffer里。当Gu8Sec等于1的时候选择第1堆,等于2的时候选择第2堆,等于3的时候选择第3堆。也就是“三选一”。
第1种实现的方法:没有用指针,最原始的处理方式。如下:
code unsigned char Cu8Memory_1={1,2,3};//第1堆数据
code unsigned char Cu8Memory_2={4,5,6};//第2堆数据
code unsigned char Cu8Memory_3={7,8,9};//第3堆数据
unsigned char Gu8Sec=2;//选择的变量
unsigned char Gu8Buffer;//根据变量来存放对应的某堆数据的数组
unsigned char i; //for循环用到的变量i
switch(Gu8Sec)//根据此选择变量来切换到对应的操作上
{
case 1://第1堆
for(i=0;i<3;i++) //第1次出现for循环,用来实现“赋值”的“搬运数据”的动作。
{
Gu8Buffer=Cu8Memory_1;
}
break;
case 2://第2堆
for(i=0;i<3;i++)//第2次出现for循环,用来实现“赋值”的“搬运数据”的动作。
{
Gu8Buffer=Cu8Memory_2;
}
break;
case 3://第3堆
for(i=0;i<3;i++) //第3次出现for循环,用来实现“赋值”的“搬运数据”的动作。
{
Gu8Buffer=Cu8Memory_3;
}
break;
}
分析:上述程序中,没有用到指针,出现了3次for循环的“赋值”的“搬运数据”的动作。
第2种实现的方法:用指针作为“中间站”。如下:
code unsigned char Cu8Memory_1={1,2,3};//第1堆数据
code unsigned char Cu8Memory_2={4,5,6};//第2堆数据
code unsigned char Cu8Memory_3={7,8,9};//第3堆数据
unsigned char Gu8Sec=2;//选择的变量
unsigned char Gu8Buffer;//根据变量来存放对应的某堆数据的数组
unsigned char i; //for循环用到的变量i
const unsigned char *pCu8; //引入一个指针作为“中间站”
switch(Gu8Sec)//根据此选择变量来切换到对应的操作上
{
case 1://第1堆
pCu8=&Cu8Memory_1;//跟第1堆数据“绑定”起来。
break;
case 2://第2堆
pCu8=&Cu8Memory_2;//跟第2堆数据“绑定”起来。
break;
case 3://第3堆
pCu8=&Cu8Memory_3;//跟第3堆数据“绑定”起来。
break;
}
for(i=0;i<3;i++)//第1次出现for循环,用来实现“赋值”的“搬运数据”的动作。
{
Gu8Buffer=*pCu8;//把“指针所存的地址的数据”赋值给数组
pCu8++;//“指针所存的地址”自加1,为下一个数据的“赋值”的“搬运”作准备。
}
分析:上述程序中,用到了指针作为中间站,只出现了1次for循环的“赋值”的“搬运数据”的动作。对比之前第1种方法,在本例子中,用了指针之后,程序代码看起来更加高效简洁清爽省容量。在实际项目中,数据量越大的时候,指针这种“优越性”就越明显。
【61.3 指针在书写上另外两种常用写法。】
刚才61.2处第2个例子中,有一段代码如下:
for(i=0;i<3;i++)//第1次出现for循环,用来实现“赋值”的“搬运数据”的动作。
{
Gu8Buffer=*pCu8;//把“指针所存的地址的数据”赋值给数组
pCu8++;//“指针所存的地址”自加1,为下一个数据的“赋值”的“搬运”作准备。
}
很多高手,喜欢把上面for循环内部的那两行代码简化成一行代码,如下:
for(i=0;i<3;i++)//第1次出现for循环,用来实现“赋值”的“搬运数据”的动作。
{
Gu8Buffer=*pCu8++;//先把“数据”赋值给数组,然后“指针所存的地址”再自加1。
}
上面这种写法也是合法的,而且在高手的代码中常见,据说也是最高效的写法。还有一种是利用“指针的偏移地址”的写法,我常用这种写法,因为感觉这种写法比较直观,而且跟数组的书写很像。如下:
for(i=0;i<3;i++)//第1次出现for循环,用来实现“赋值”的“搬运数据”的动作。
{
Gu8Buffer=pCu8;//这类是“偏移地址”的写法,i在这里相当于指针的偏移地址。
}
这种写法也是跟前面那两种写法在程序实现的功能上是一样的,是等效的,我常用这种写法。
【61.4 指针的“地址自加法”和“地址偏移法”的差别。】
刚才61.3处讲了3个例子,其中前面的两个例子都是属于“地址自加法”,而最后的那一个是属于“地址偏移法”。它们的根本差别是:“地址自加法”的时候,“指针所存的地址”是变动的;而“地址偏移法”的时候,“指针所存的地址”是不变的,“指针所存的地址”的“不变”的属性,就像某个原点,原点再加上偏移,就可以寻址到某个新的RAM地址所存的数据。例子如下:
第1种:“地址自加法”:
pCu8=&Cu8Memory_2;//假设赋值后,此时“指针所存的地址”是RAM的地址4。
for(i=0;i<3;i++)
{
Gu8Buffer=*pCu8++;//先把“数据”赋值给数组,然后“指针所存的地址”再自加1。
}
分析:上述代码,等程序执行完for循环后,指针所存的地址还是RAM地址4吗?不是。因为它是变动的,经过for循环,“指针所存的地址”自加3次后,此时“所存的RAM地址”从原来的4变成了7。
第2种:“地址偏移法”:
pCu8=&Cu8Memory_2;//假设赋值后,此时“指针所存的地址”是RAM的地址4。
for(i=0;i<3;i++)
{
Gu8Buffer=pCu8;//这类是“偏移地址”的写法,i在这里相当于指针的偏移地址。
}
分析:上述代码,等程序执行完for循环后,指针所存的地址还是RAM地址4吗?是的。因为它存的地址是不变的,变的只是偏移地址i。此时“指针所存的地址”就像“原点”一样具有“绝对地址”的“参考点”的属性。
【61.5 例程练习和分析。】
现在编一个练习程序。
/*---C语言学习区域的开始。-----------------------------------------------*/
code unsigned char Cu8Memory_1={1,2,3};//第1堆数据
code unsigned char Cu8Memory_2={4,5,6};//第2堆数据
code unsigned char Cu8Memory_3={7,8,9};//第3堆数据
unsigned char Gu8Sec=2;//选择的变量
unsigned char Gu8Buffer;//根据变量来存放对应的某堆数据的数组
unsigned char i; //for循环用到的变量i
const unsigned char *pCu8; //引入一个指针作为“中间站”
void main() //主函数
{
switch(Gu8Sec)//根据此选择变量来切换到对应的操作上
{
case 1://第1堆
pCu8=&Cu8Memory_1;//跟第1堆数据“绑定”起来。
break;
case 2://第2堆
pCu8=&Cu8Memory_2;//跟第2堆数据“绑定”起来。
break;
case 3://第3堆
pCu8=&Cu8Memory_3;//跟第3堆数据“绑定”起来。
break;
}
// for(i=0;i<3;i++)//第1次出现for循环,用来实现“赋值”的“搬运数据”的动作。
// {
// Gu8Buffer=*pCu8++; //先把“数据”赋值给数组,然后“指针所存的地址”再自加1。
// }
for(i=0;i<3;i++)//第1次出现for循环,用来实现“赋值”的“搬运数据”的动作。
{
Gu8Buffer=pCu8;//这类是“偏移地址”的写法,i在这里相当于指针的偏移地址。
}
View(Gu8Buffer);//把第1个数Gu8Buffer发送到电脑端的串口助手软件上观察。
View(Gu8Buffer);//把第2个数Gu8Buffer发送到电脑端的串口助手软件上观察。
View(Gu8Buffer);//把第3个数Gu8Buffer发送到电脑端的串口助手软件上观察。
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:4
十六进制:4
二进制:100
第2个数
十进制:5
十六进制:5
二进制:101
第3个数
十进制:6
十六进制:6
二进制:110
分析:
Gu8Buffer为4。
Gu8Buffer为5。
Gu8Buffer为6。
【61.6 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
wintelboy
发表于 2017-3-20 11:57:38
谢谢!又更新了,继续学习……
tt98
发表于 2017-3-22 15:50:31
希望鸿哥继续下去{:handshake:}
MCU开发2014
发表于 2017-3-23 14:05:59
都是精华呀,好好学学。
吴坚鸿
发表于 2017-3-26 10:58:52
第六十二节: 指针,大小端,化整为零,化零为整。
【62.1 内存的大小端。】
C51编译器的unsigned int占2字节RAM(也称为内存),unsigned long占4字节RAM,这两种数据类型所占的字节数都超过了1个字节,而RAM内存是每一个地址对应一个字节的RAM内存,那么问题就来了,比如像unsigned long这种占4个字节RAM的数据变量,它这4个字节在RAM中的地址是“连续”的“挨家挨户”的“连号”的,这4个字节所存的一个数据,它的数据高低位在地址的排列上,到底是从低到高还是从高到低,到底是“正向”的还是“反向”?这两种不同的排列顺序,在C语言里用“大端”和“小端”这两个专业术语来描述。“大端”的方式是将高位存放在低地址,“小端”的方式是将低位存放在低地址。比如:
假设有一个unsigned long变量a等于0x12345678,是存放在RAM内存中的第4,5,6,7这四个“连号”的地址里,现在看看它在“大端”和“小端”的存储方式里的差别。如下:
(1)在“大端”的方式里,将高位存放在低地址。
0x12存在第4个地址,0x34存在第5个地址,0x56存在第6个地址,0x78存在第7个地址。
(2)在“小端”的方式里,将低位存放在低地址。
0x78存在第4个地址,0x56存在第5个地址,0x34存在第6个地址,0x12存在第7个地址。
问题来了,在单片机里,内存到底是“大端”方式还是“小端”方式?答:这个跟C编译器有关。比如,在51单片机的C51编译环境里是“大端”方式,而在stm32单片机的ARM_MDK编译环境里则是“小端”方式。那么问题又来了?如何知道一个C编译器是“大端”还是“小端”?答:有两种方式,一种是看C编译器的说明书,另一种是自己编写一个小程序测试一下就知道了(这种方法最简单可靠)。那么问题又来了?讲这个 “大小端”有什么用?答:这个跟指针的使用密切相关。
【62.2 化整为零。】
在数据的存储和通信中,往往要先把数据转换成以字节为单位的数组,才能进行数据存储和通信。比如unsigned long这种类型的数据,就要先转换成4个字节,这种把某个变量转换成N个字节的过程,就是“化整为零”。“化整为零”的过程,在代码上,有两种常见的方式,一种是原始的“移位法”,另一种是极具优越性的“指针法”。比如,现在以“大端”方式为例(因为本教程是用C51编译器,C51编译器是“大端”方式),有一个unsigned long变量a等于0x12345678,要把这个变量分解成4个字节存放在一个数组Gu8BufferA中,现在跟大家分享和对比一下这两种方法。
(1)原始的“移位法”。
unsigned long a=0x12345678;
unsigned char Gu8BufferA;
Gu8BufferA=a>>24;
Gu8BufferA=a>>16;
Gu8BufferA=a>>8;
Gu8BufferA=a;
(2)极具优越性的“指针法”。
unsigned long a=0x12345678;
unsigned char Gu8BufferA;
unsigned long *pu32; //引入一个指针变量,注意,这里是unsigned long类型的指针。
pu32=(unsigned long *)&Gu8BufferA;//指针跟数组“绑定”(也称为“关联”)起来。
*pu32=a;//这里仅仅1行代码就等效于上述(1)“移位”例子中的4行代码,所以极具优越性。
多说一句,“pu32=(unsigned long *)&Gu8BufferA”这行代码中,其中小括号“(unsigned long *)”是表示数据的强制类型转换,这里表示强制转换成unsigned long的指针方式,以后这类代码写多了,就会发现这种书写方法的规律。作为语言来解读先熟悉一下它的表达方式就可以了,暂时不用深究它的含义。
【62.3 化零为整。】
从数据存储中提取数据出来,从通讯端接收到一堆数据,这里的“提取”和“接收”都是以字节为单位的数据,所以为了“还原”成原来的类型变量,就涉及“化零为整”的过程。在代码上,有两种常见的方式,一种是原始的“移位法”,另一种是极具优越性的“指针法”。比如,现在以“大端”方式为例(因为本教程是用C51编译器,C51编译器是“大端”方式),有一个数组Gu8BufferB存放了4个字节数据分别是:0x12,0x34,0x56,0x78。现在要把这4个字节数据“合并”成一个unsigned long类型的变量b,这个变量b等于0x12345678。现在跟大家分享和对比一下这两种方法。
(1)原始的“移位法”。
unsigned char Gu8BufferB={0x12,0x34,0x56,0x78};
unsigned long b;
b=Gu8BufferB;
b=b<<8;
b=b+Gu8BufferB;
b=b<<8;
b=b+Gu8BufferB;
b=b<<8;
b=b+Gu8BufferB;
(2)极具优越性的“指针法”。
unsigned char Gu8BufferB={0x12,0x34,0x56,0x78};
unsigned long b;
unsigned long *pu32; //引入一个指针变量,注意,这里是unsigned long类型的指针。
pu32=(unsigned long *)&Gu8BufferB;//指针跟数组“绑定”(也称为“关联”)起来。
b=*pu32;//这里仅仅1行代码就等效于上述(1)“移位”例子中的7行代码,所以极具优越性。
【62.4 “指针法”要注意的问题。】
“化整为零”和“化零为整”其实是一个“互逆”的过程,在使用“指针法”的时候,一定要注意“大小端”的问题。“化整为零”和“化零为整”这两个“互逆”过程要么同时为“大端”,要么同时为“小端”,否则会因字节的排列顺序问题而引起数据的严重错误。
【62.5 例程练习和分析。】
现在编一个练习程序。
/*---C语言学习区域的开始。-----------------------------------------------*/
unsigned long a=0x12345678;
unsigned char Gu8BufferA;
unsigned char Gu8BufferB={0x12,0x34,0x56,0x78};
unsigned long b;
unsigned long *pu32; //引入一个指针变量,注意,这里是unsigned long类型的指针。
void main() //主函数
{
pu32=(unsigned long *)&Gu8BufferA;//指针跟数组“绑定”(也称为“关联”)起来。
*pu32=a;//化整为零
pu32=(unsigned long *)&Gu8BufferB;//指针跟数组“绑定”(也称为“关联”)起来。
b=*pu32;//化零为整
View(Gu8BufferA);//把第1个数Gu8BufferA发送到电脑端的串口助手软件上观察。
View(Gu8BufferA);//把第2个数Gu8BufferA发送到电脑端的串口助手软件上观察。
View(Gu8BufferA);//把第3个数Gu8BufferA发送到电脑端的串口助手软件上观察。
View(Gu8BufferA);//把第4个数Gu8BufferA发送到电脑端的串口助手软件上观察。
View(b); //把第5个数b发送到电脑端的串口助手软件上观察。
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:18
十六进制:12
二进制:10010
第2个数
十进制:52
十六进制:34
二进制:110100
第3个数
十进制:86
十六进制:56
二进制:1010110
第4个数
十进制:120
十六进制:78
二进制:1111000
第5个数
十进制:305419896
十六进制:12345678
二进制:10010001101000101011001111000
分析:
Gu8BufferA为0x12。
Gu8BufferA为0x34。
Gu8BufferA为0x56。
Gu8BufferA为0x78。
b为0x12345678。
【62.6 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
吴坚鸿
发表于 2017-4-2 10:38:32
第六十三节: 指针“化整为零”和“化零为整”的“灵活”应用。
【63.1 化整为零的“灵活”应用。】
上一节讲“化整为零”的例子,指针是跟数组的首地址(下标是0)“绑定”的,这样,很多初学者就误以为指针跟数组“绑定”时,只能跟数组的“首地址”关联。其实,指针是可以跟数组的任何一个成员的地址“绑定”(只要不超过数组的长度导致越界),它不仅仅局限于首地址,指针的这个特征就是本节标题所说的“灵活”。请看下面这个例子:
有3个变量,分别是单字节unsigned char a,双字节unsigned int b,四字节unsigned long c,它们加起来一共有7个字节,要把这7个字节放到一个7字节容量的数组里。除了用传统的“移位法”,还有一种更加便捷的“指针法”,代码如下:
unsigned char a=0x01;
unsigned int b=0x0203;
unsigned long c=0x04050607;
unsigned char Gu8BufferABC; //存放3个不同长度变量的数组
unsigned char *pu8; //引入的unsigned char 类型指针
unsigned int *pu16; //引入的unsigned int 类型指针
unsigned long *pu32;//引入的unsigned long 类型指针
pu8=&Gu8BufferABC; //指针跟数组的第0个位置“绑定”起来。
*pu8=a; //把a的1个字节放在数组第0个位置。
pu16=(unsigned int *)&Gu8BufferABC; //指针跟数组的第1个位置“绑定”起来。
*pu16=b; //把b的2个字节放在数组第1、2这两个位置。
pu32=(unsigned long *)&Gu8BufferABC; //指针跟数组的第3个位置“绑定”起来。
*pu32=c; //把c的4个字节放在数组第3、4、5、6这四个位置。
【63.2 化零为整的“灵活”应用。】
刚才讲的是“化整为零”,现在讲的是“化零为整”。刚才讲的是“分解”,现在讲的是“合成”。请看下面这个例子:
有一个容量为7字节数组,第0字节存放的是unsigned char d变量,第1、2字节存放的是unsigned int e变量,第3、4、5、6字节存放的是unsigned long f变量,现在要从数组中“零散”的字节里提取并且合成为“完整”的3个变量。代码如下:
unsigned char Gu8BufferDEF={0x01,0x02,0x03,0x04,0x05,0x06,0x07}; //注意大小端的问题
unsigned char d;
unsigned int e;
unsigned long f;
unsigned char *pu8; //引入的unsigned char 类型指针
unsigned int *pu16; //引入的unsigned int 类型指针
unsigned long *pu32;//引入的unsigned long 类型指针
pu8=&Gu8BufferDEF; //指针跟数组的第0个位置“绑定”起来。
d=*pu8; //从数组第0位置提取单字节完整的d变量。
pu16=(unsigned int *)&Gu8BufferDEF; //指针跟数组的第1个位置“绑定”起来。
e=*pu16; //从数组第1,2位置提取双字节完整的e变量。
pu32=(unsigned long *)&Gu8BufferDEF; //指针跟数组的第3个位置“绑定”起来。
f=*pu32; //从数组第3,4,5,6位置提取四字节完整的f变量。
【63.3 例程练习和分析。】
现在编一个练习程序。
/*---C语言学习区域的开始。-----------------------------------------------*/
unsigned char a=0x01;
unsigned int b=0x0203;
unsigned long c=0x04050607;
unsigned char Gu8BufferABC; //存放3个不同长度变量的数组
unsigned char Gu8BufferDEF={0x01,0x02,0x03,0x04,0x05,0x06,0x07}; //注意大小端的问题
unsigned char d;
unsigned int e;
unsigned long f;
unsigned char *pu8; //引入的unsigned char 类型指针
unsigned int *pu16; //引入的unsigned int 类型指针
unsigned long *pu32;//引入的unsigned long 类型指针
void main() //主函数
{
//第1类例子:化整为零。
pu8=&Gu8BufferABC; //指针跟数组的第0个位置“绑定”起来。
*pu8=a; //把a的1个字节放在数组第0个位置。
pu16=(unsigned int *)&Gu8BufferABC; //指针跟数组的第1个位置“绑定”起来。
*pu16=b; //把b的2个字节放在数组第1、2这两个位置。
pu32=(unsigned long *)&Gu8BufferABC; //指针跟数组的第3个位置“绑定”起来。
*pu32=c; //把c的4个字节放在数组第3、4、5、6这四个位置。
//第2类例子:化零为整。
pu8=&Gu8BufferDEF; //指针跟数组的第0个位置“绑定”起来。
d=*pu8; //从数组第0位置提取单字节完整的d变量。
pu16=(unsigned int *)&Gu8BufferDEF; //指针跟数组的第1个位置“绑定”起来。
e=*pu16; //从数组第1,2位置提取双字节完整的e变量。
pu32=(unsigned long *)&Gu8BufferDEF; //指针跟数组的第3个位置“绑定”起来。
f=*pu32; //从数组第3,4,5,6位置提取四字节完整的f变量。
View(Gu8BufferABC);//把第1个数Gu8BufferABC发送到电脑端的串口助手软件上观察。
View(Gu8BufferABC);//把第2个数Gu8BufferABC发送到电脑端的串口助手软件上观察。
View(Gu8BufferABC);//把第3个数Gu8BufferABC发送到电脑端的串口助手软件上观察。
View(Gu8BufferABC);//把第4个数Gu8BufferABC发送到电脑端的串口助手软件上观察。
View(Gu8BufferABC);//把第5个数Gu8BufferABC发送到电脑端的串口助手软件上观察。
View(Gu8BufferABC);//把第6个数Gu8BufferABC发送到电脑端的串口助手软件上观察。
View(Gu8BufferABC);//把第7个数Gu8BufferABC发送到电脑端的串口助手软件上观察。
View(d); //把第8个数d发送到电脑端的串口助手软件上观察。
View(e); //把第9个数e发送到电脑端的串口助手软件上观察。
View(f); //把第10个数f发送到电脑端的串口助手软件上观察。
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:1
十六进制:1
二进制:1
第2个数
十进制:2
十六进制:2
二进制:10
第3个数
十进制:3
十六进制:3
二进制:11
第4个数
十进制:4
十六进制:4
二进制:100
第5个数
十进制:5
十六进制:5
二进制:101
第6个数
十进制:6
十六进制:6
二进制:110
第7个数
十进制:7
十六进制:7
二进制:111
第8个数
十进制:1
十六进制:1
二进制:1
第9个数
十进制:515
十六进制:203
二进制:1000000011
第:个数(这里是第10个数。本模块程序只支持显示第1到第9个,所以这里没有显示“10”)
十进制:67438087
十六进制:4050607
二进制:100000001010000011000000111
分析:
Gu8BufferABC为0x01。
Gu8BufferABC为0x02。
Gu8BufferABC为0x03。
Gu8BufferABC为0x04。
Gu8BufferABC为0x05。
Gu8BufferABC为0x06。
Gu8BufferABC为0x07。
d为0x01。
e为0x0203。
f为0x04050607。
【63.4 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
tt98
发表于 2017-4-2 11:55:34
支持鸿哥{:handshake:}
吴坚鸿
发表于 2017-4-9 18:15:32
第六十四节: 指针让函数具备了多个相当于return的输出口。
【64.1 函数的三类输出渠道。】
函数是模块,模块必须具备输入和输出的接口,从输入和输出的角度分析,函数对外部调用者传递信息主要有三类渠道,第一类是全局变量,第二类是return返回值,第三类是用指针。全局变量太隐蔽,没有那么直观,可读性稍差。return可读性强,缺点是一个函数只能有一个return,如果一个函数要输出多个结果,return就力不从心。指针作为函数的输出接口,就能随心所欲了,不但可读性强,而且输出的接口数量不受限制。
【64.2 只有一个输出接口的时候。】
现在举一个例子,要用函数实现一个加法运算,输出“一个”加法运算的和,求3加上5等于8。下面三个例子中分别使用“全局变量,return,指针”这三类输出接口。
第一类:全局变量。
unsigned char DiaoYongZhe;//调用者
unsigned char BeiJiaShu; //被加数
unsigned char JiaShu; //加数
unsigned char He; //输出的接口,加法运算的"和"。
void JiaFa(void)
{
He=BeiJiaShu+JiaShu;
}
void main()
{
BeiJiaShu=3; //填入被加数3
JiaShu=5; //填入加数5
JiaFa(); //调用一次加法运算的函数
DiaoYongZhe=He; //把加法运算的“和”赋值给调用者。
}
第二类:return。
unsigned char DiaoYongZhe; //调用者
unsigned char JiaFa(unsigned char BeiJiaShu,unsigned char JiaShu)
{
unsigned char He;
He=BeiJiaShu+JiaShu;
return He;
}
void main()
{
DiaoYongZhe=JiaFa(3,5); //把加法运算的“和”赋值给调用者,一气呵成。
}
第三类:指针。
unsigned char DiaoYongZhe; //调用者
void char JiaFa(unsigned char BeiJiaShu,unsigned char JiaShu,unsigned char *pu8He)
{
*pu8He=BeiJiaShu+JiaShu;
}
void main()
{
JiaFa(3,5,&DiaoYongZhe);//通过指针这个输出渠道,把加法运算的“和”赋值给调用者,一气呵成。
}
【64.3 有多个输出接口的时候。】
现在举一个例子,要用函数实现一个除法运算,分别输出除法运算的商和余数这“两个”数,求5除以3等于1余2。因为return只能输出一个结果,所以这里不列举return的例子,只使用“全局变量”和“指针”这两类输出接口。
第一类:全局变量。
unsigned char DiaoYongZhe_Shang; //调用者的商
unsigned char DiaoYongZhe_Yu; //调用者的余数
unsigned char BeiChuShu; //被除数
unsigned char ChuShu; //除数
unsigned char Shang; //输出的接口,除法运算的"商"。
unsigned char Yu; //输出的接口,除法运算的"余"。
void ChuFa(void)
{
Shang=BeiChuShu/ChuShu; //求商。假设除数不会为0的情况。
Yu=BeiChuShu%ChuShu; //求余数。假设除数不会为0的情况。
}
void main()
{
BeiChuShu=5; //填入被除数5
ChuShu=3; //填入除数3
ChuFa(); //调用一次除法运算的函数
DiaoYongZhe_Shang=Shang;//把除法运算的“商”赋值给调用者的商。
DiaoYongZhe_Yu=Yu; //把除法运算的“余数”赋值给调用者的余数。
}
第二类:return。
return只能输出一个结果,力不从心,所以这里不列举return的例子。
第三类:指针。
unsigned char DiaoYongZhe_Shang; //调用者的商
unsigned char DiaoYongZhe_Yu; //调用者的余数
void ChuFa(unsigned char BeiChuShu,
unsigned char ChuShu,
unsigned char *pu8Shang,
unsigned char *pu8Yu)
{
*pu8Shang=BeiChuShu/ChuShu; //求商。假设除数不会为0的情况。
*pu8Yu=BeiChuShu%ChuShu; //求余数。假设除数不会为0的情况。
}
void main()
{
ChuFa(5,3,&DiaoYongZhe_Shang,&DiaoYongZhe_Yu);//通过两个指针的输出接口,一气呵成。
}
【64.4 例程练习和分析。】
现在编一个练习程序。
/*---C语言学习区域的开始。-----------------------------------------------*/
void ChuFa(unsigned char BeiChuShu,
unsigned char ChuShu,
unsigned char *pu8Shang,
unsigned char *pu8Yu); //函数声明
unsigned char DiaoYongZhe_Shang; //调用者的商
unsigned char DiaoYongZhe_Yu; //调用者的余数
void ChuFa(unsigned char BeiChuShu,
unsigned char ChuShu,
unsigned char *pu8Shang,
unsigned char *pu8Yu) //函数定义
{
*pu8Shang=BeiChuShu/ChuShu; //求商。假设除数不会为0的情况。
*pu8Yu=BeiChuShu%ChuShu; //求余数。假设除数不会为0的情况。
}
void main() //主函数
{
ChuFa(5,3,&DiaoYongZhe_Shang,&DiaoYongZhe_Yu);//函数调用。通过两个指针的输出接口,一气呵成。
View(DiaoYongZhe_Shang); //把第1个数DiaoYongZhe_Shang发送到电脑端的串口助手软件上观察。
View(DiaoYongZhe_Yu); //把第2个数DiaoYongZhe_Yu发送到电脑端的串口助手软件上观察。
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:1
十六进制:1
二进制:1
第2个数
十进制:2
十六进制:2
二进制:10
分析:
DiaoYongZhe_Shang为1。
DiaoYongZhe_Yu为2。
【64.5 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
flywwind12
发表于 2017-4-10 07:28:20
实时系统单片机是主角,比高校教材,楼主的书很实用。
daidaide
发表于 2017-4-12 15:49:47
现在很少有人愿意花7年的时间写一本书了,都是快餐时代。支持楼主
吴坚鸿
发表于 2017-4-16 11:57:39
第六十五节: 指针作为数组在函数中的入口作用。
【65.1 函数的参数入口。】
要往函数内部传递信息,主要有两类渠道。第一类是全局变量。第二类是函数的参数入口,而参数入口可以分为“普通局部变量”和“指针”这两类。“普通局部变量”的参数入口一次只能传一个数据,如果一个数组有几十个甚至上百个数据,此时“普通局部变量”就无能为力,这时不可能也写几十个甚至上百个入口参数吧(这会累坏程序员),针对这种需要输入批量数据的场合,“指针”的参数入口就因此而生,完美解决了此问题,仅用一个“指针”参数入口就能解决一个数组N个数据的入口问题。那么,什么是函数的参数入口?例子如下:
//函数声明
unsigned long PinJunZhi(unsigned char a,unsigned char b,unsigned char c,unsigned char d);
//变量定义
unsigned char Gu8Buffer={2,6,8,4};//4个变量分别是2,6,8,4。
unsigned long Gu32PinJunZhi;//求平均值的结果
//函数定义
unsigned long PinJunZhi(unsigned char a,unsigned char b,unsigned char c,unsigned char d)
{
unsigned long u32PinJunZhi;
u32PinJunZhi=(a+b+c+d)/4;
return u32PinJunZhi;
}
void main() //主函数
{
//函数调用
Gu32PinJunZhi=PinJunZhi(Gu8Buffer,Gu8Buffer,Gu8Buffer,Gu8Buffer);
}
上面是一个求4个数据平均值的函数,在这个函数中,函数小括号的(unsigned char a,unsigned char b,unsigned char c,unsigned char d)就是4个变量的“普通局部变量”参数入口,刚才说到,如果一个数组有上百个变量,这种书写方式是很累的。如果改用“指针”入口参数的方式,例子如下:
//函数声明
unsigned long PinJunZhi(unsigned char *pu8Buffer);
//变量定义
unsigned char Gu8Buffer={2,6,8,4};//4个变量分别是2,6,8,4。
unsigned long Gu32PinJunZhi;//求平均值的结果
//函数定义
unsigned long PinJunZhi(unsigned char *pu8Buffer)
{
unsigned long u32PinJunZhi;
u32PinJunZhi=(pu8Buffer+pu8Buffer+pu8Buffer+pu8Buffer)/4;
return u32PinJunZhi;
}
void main() //主函数
{
//函数调用
Gu32PinJunZhi=PinJunZhi(&Gu8Buffer);//也等效于Gu32PinJunZhi=PinJunZhi(Gu8Buffer)
}
上面例子中,仅用一个(unsigned char *pu8Buffer)指针入口参数,就可以达到输入4个变量的目的(这4个变量要求是同在一个数组内)。
【65.2 const在指针参数“入口”中的作用。】
指针在函数的参数入口中,既可以做“入口”,也可以做“出口”,而C语言为了区分这两种情况,提供了const这个关键字来限定权限。如果指针加了const前缀,就为指针的权限加了紧箍咒,限定了此指针只能作为“入口”,而不能作为“出口”。如果没有加了const前缀,就像本节的函数例子,此时指针参数既可以作为“入口”,也可以作为“出口”。加const关键字有两个意义,一方面是方便阅读,通过const就知道此接口的“入口”和“出口”属性,另一方面,是为了代码的安全,对于只能作为“入口”的指针参数一旦加了const限定,万一我们不小心在函数内部对const限定的指针所关联的数据进行了更改(“更改”就意味着“出口”),C编译器在编译的时候就会有提醒或者报错,及时让我们发现程序的bug(程序的漏洞)。这部分的内容后续章节会讲到,大家先有个大概的了解,本节暂时不深入讲。
【65.3 例程练习和分析。】
现在编一个练习程序。
/*---C语言学习区域的开始。-----------------------------------------------*/
//函数声明
unsigned long PinJunZhi(unsigned char *pu8Buffer);
//变量定义
unsigned char Gu8Buffer={2,6,8,4};//4个变量分别是2,6,8,4。
unsigned long Gu32PinJunZhi;//求平均值的结果
//函数定义
unsigned long PinJunZhi(unsigned char *pu8Buffer)
{
unsigned long u32PinJunZhi;
u32PinJunZhi=(pu8Buffer+pu8Buffer+pu8Buffer+pu8Buffer)/4;
return u32PinJunZhi;
}
void main() //主函数
{
//函数调用
Gu32PinJunZhi=PinJunZhi(&Gu8Buffer);//也等效于Gu32PinJunZhi=PinJunZhi(Gu8Buffer)
View(Gu32PinJunZhi); //把第1个数Gu32PinJunZhi发送到电脑端的串口助手软件上观察。
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:5
十六进制:5
二进制:101
分析:
平均值变量Gu32PinJunZhi为5。
【65.4 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
吴坚鸿
发表于 2017-4-23 10:12:02
第六十六节: 指针作为数组在函数中的出口作用。
【66.1 指针作为数组在函数中的出口。】
函数对外部调用者传递信息主要有三类渠道,第一类是全局变量,第二类是return返回值,第三类是指针。之前讲指针对外传递信息的时候,只讲了单个变量的情况,现在重点讲讲数组的情况。要把一个四位数的个,十,百,千位分别提取出来成为4个数,依次存放在一个包含4个字节的数组里,代码如下:
void TiQu(unsigned int u16Data,unsigned char *pu8Buffer) //“提取”函数
{
unsigned char u8Ge; //个位
unsigned char u8Shi; //十位
unsigned char u8Bai; //百位
unsigned char u8Qian; //千位
u8Ge=u16Data/1%10; //提取个位
u8Shi=u16Data/10%10; //提取十位
u8Bai=u16Data/100%10; //提取百位
u8Qian=u16Data/1000%10;//提取千位
//最后,把所提取的数分别传输到“指针”这个“出口通道”
pu8Buffer=u8Ge;
pu8Buffer=u8Shi;
pu8Buffer=u8Bai;
pu8Buffer=u8Qian;
}
上述代码,为了突出“出口通道”,我刻意多增加了u8Ge、u8Shi、u8Bai、u8Qian这4个局部变量,其实,这4个局部变量还可以省略的,此函数简化后的等效代码如下:
void TiQu(unsigned int u16Data,unsigned char *pu8Buffer) //“提取”函数
{
pu8Buffer=u16Data/1%10; //提取个位
pu8Buffer=u16Data/10%10; //提取十位
pu8Buffer=u16Data/100%10; //提取百位
pu8Buffer=u16Data/1000%10; //提取千位
}
【66.2 例程练习和分析。】
现在编一个练习程序。
/*---C语言学习区域的开始。-----------------------------------------------*/
//函数声明
void TiQu(unsigned int u16Data,unsigned char *pu8Buffer);
//全局变量定义
unsigned char Gu8Buffer;//存放提取结果的数组
//函数定义
void TiQu(unsigned int u16Data,unsigned char *pu8Buffer) //“提取”函数
{
pu8Buffer=u16Data/1%10; //提取个位
pu8Buffer=u16Data/10%10; //提取十位
pu8Buffer=u16Data/100%10; //提取百位
pu8Buffer=u16Data/1000%10; //提取千位
}
void main() //主函数
{
TiQu(9876,&Gu8Buffer);//把9876这个四位数分别提取6、7、8、9存放在数组Gu8Buffer里
View(Gu8Buffer); //把第1个数Gu8Buffer)发送到电脑端的串口助手软件上观察
View(Gu8Buffer); //把第2个数Gu8Buffer)发送到电脑端的串口助手软件上观察
View(Gu8Buffer); //把第3个数Gu8Buffer)发送到电脑端的串口助手软件上观察
View(Gu8Buffer); //把第4个数Gu8Buffer)发送到电脑端的串口助手软件上观察
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:6
十六进制:6
二进制:110
第2个数
十进制:7
十六进制:7
二进制:111
第3个数
十进制:8
十六进制:8
二进制:1000
第4个数
十进制:9
十六进制:9
二进制:1001
分析:
Gu8Buffer为6。
Gu8Buffer为7。
Gu8Buffer为8。
Gu8Buffer为9。
【66.3 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
hzyzs
发表于 2017-4-24 19:20:36
今天注册了,就为看你的帖子。
ruzi
发表于 2017-4-24 22:57:14
来学习了!
吴坚鸿
发表于 2017-4-30 09:02:21
第六十七节: 指针作为数组在函数中既“入口”又“出口”的作用。
【67.1 指针作为数组在函数中的“入口”和“出口”。】
前面分别讲了指针的入口和出口,很多初学者误以为指针是一个“单向”的通道,其实,如果指针前面没有加const这个“紧箍咒”限定它的属性,指针是“双向”的,不是“单向”的,也就是说,指针是可以同时具备“入口”和“出口”这两种属性的。现在讲一个程序例子,求一个数组(内含4元素)的每个元素变量的整数倍的一半,所谓整数倍的一半,就是除以2,但是不带小数点,比如4的整数倍的一半是2,7的整数倍的一半是3(不是3.5),代码如下:
void Half(unsigned char *pu8Buffer) //“求一半”的函数
{
unsigned char u8Data_0; //临时中间变量
unsigned char u8Data_1; //临时中间变量
unsigned char u8Data_2; //临时中间变量
unsigned char u8Data_3; //临时中间变量
//从指针这个“入口”里获取需要“被除以2”的数据。
u8Data_0=pu8Buffer;
u8Data_1=pu8Buffer;
u8Data_2=pu8Buffer;
u8Data_3=pu8Buffer;
//求数据的整数倍的一半的算法
u8Data_0=u8Data_0/2;
u8Data_1=u8Data_1/2;
u8Data_2=u8Data_2/2;
u8Data_3=u8Data_3/2;
//最后,把计算所得的结果分别传输到指针这个“出口”
pu8Buffer=u8Data_0;
pu8Buffer=u8Data_1;
pu8Buffer=u8Data_2;
pu8Buffer=u8Data_3;
}
上述代码,为了突出“入口”和“出口”,我刻意多增加了u8Data_0,u8Data_1,u8Data_2,u8Data_3这4个临时中间变量,其实,这4个临时中间变量还可以省略的,此函数简化后的等效代码如下:
void Half(unsigned char *pu8Buffer) //“求一半”的函数
{
pu8Buffer=pu8Buffer/2;
pu8Buffer=pu8Buffer/2;
pu8Buffer=pu8Buffer/2;
pu8Buffer=pu8Buffer/2;
}
【67.2 例程练习和分析。】
现在编一个练习程序。
/*---C语言学习区域的开始。-----------------------------------------------*/
//函数声明
void Half(unsigned char *pu8Buffer);
//全局变量定义
unsigned char Gu8Buffer={4,7,16,25};//需要“被除以2”的数组
//函数定义
void Half(unsigned char *pu8Buffer) //“求一半”的函数
{
pu8Buffer=pu8Buffer/2;
pu8Buffer=pu8Buffer/2;
pu8Buffer=pu8Buffer/2;
pu8Buffer=pu8Buffer/2;
}
void main() //主函数
{
Half(&Gu8Buffer); //计算数组的整数倍的一半。这里的“入口”和“出口”是“同一个通道”。
View(Gu8Buffer); //把第1个数Gu8Buffer)发送到电脑端的串口助手软件上观察
View(Gu8Buffer); //把第2个数Gu8Buffer)发送到电脑端的串口助手软件上观察
View(Gu8Buffer); //把第3个数Gu8Buffer)发送到电脑端的串口助手软件上观察
View(Gu8Buffer); //把第4个数Gu8Buffer)发送到电脑端的串口助手软件上观察
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:2
十六进制:2
二进制:10
第2个数
十进制:3
十六进制:3
二进制:11
第3个数
十进制:8
十六进制:8
二进制:1000
第4个数
十进制:12
十六进制:C
二进制:1100
分析:
Gu8Buffer为2。
Gu8Buffer为3。
Gu8Buffer为8。
Gu8Buffer为12。
【67.3 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
czlss
发表于 2017-4-30 19:21:44
支持一下你!
吴坚鸿
发表于 2017-5-7 10:44:15
第六十八节: 为函数接口指针“定向”的const关键词。
【68.1 为函数接口指针“定向”的const关键词。】
在函数接口处的指针,是一个双向口,既可以作为“输入”也可以作为“输出”,换句话说,既能“读”也能“写”(被更改),这样一来,当你把一个数组(或者某变量)通过指针引入到函数内部的时候,当执行完此函数,这个数组的数值可能已经悄悄发生了更改(“是否被更改”取决于函数内部的具体代码),进来时是“摩托”出来后可能已变成“单车”,而实际项目上,很多时候我们只想传递数组(或者某变量)的数值,并不想数组(或者某变量)本身发生变化,这个时候,本节的主角const关键词就派上用场了。
只要在函数接口的指针前面加上const关键词,原来双向的指针就立刻变成了单向,只能输入不能输出。这个const有两个好处。第一个好处是方便阅读,通过const就知道此接口的“入口”和“出口”属性,如果你是用别人已经封装好的函数,一旦发现接口指针带了const标签,就足以说明这个指针只能作为输入接口,不用担心输入数据被意外修改。第二个好处是确保数据的安全,函数接口指针一旦加了const限定,万一你不小心在函数内部对指针所关联的数据进行了更改(“更改”就意味着“出口”),C编译器在编译的时候就会报错让你编译失败,及时让你发现程序的bug(程序的漏洞),这是编译器层面的一道防火墙。例子如下:
unsigned char ShuRu(const unsigned char *pu8Data)
{
unsigned char a;
a=*pu8Data;//这行代码是合法的,是指针所关联数据的“读”操作。
*pu8Data=a;//这行代码是非法的,是指针所关联数据的“写”操作,违背const的约束。
return a;
}
【68.2 例程练习和分析。】
在前面第65节讲函数入口的时候,用到一个求数组平均值的程序例子,这个数组是仅仅作为输入用的,不需要被更改,因此,现在借本节讲const的机会,为此函数的接口指针补上一个const关键词,让该函数更加科学规范,程序如下:
/*---C语言学习区域的开始。-----------------------------------------------*/
unsigned long PinJunZhi(const unsigned char *pu8Buffer);//指针前增加一个const关键词
unsigned char Gu8Buffer={2,6,8,4};
unsigned long Gu32PinJunZhi;
unsigned long PinJunZhi(const unsigned char *pu8Buffer) //指针前增加一个const关键词
{
unsigned long u32PinJunZhi;
u32PinJunZhi=(pu8Buffer+pu8Buffer+pu8Buffer+pu8Buffer)/4;//求平均值
return u32PinJunZhi;
}
void main() //主函数
{
Gu32PinJunZhi=PinJunZhi(&Gu8Buffer);//不用担心Gu8Buffer数组的数据被意外更改。
View(Gu32PinJunZhi); //把第1个数Gu32PinJunZhi发送到电脑端的串口助手软件上观察。
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:5
十六进制:5
二进制:101
分析:
平均值变量Gu32PinJunZhi为5。
【68.3 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
atonghua
发表于 2017-5-7 19:41:25
支持楼主!!!
吴坚鸿
发表于 2017-5-14 10:37:06
第六十九节: 宏函数sizeof()。
【69.1 宏函数sizeof()的基础知识。】
宏函数sizeof()是用来获取某个对象所占用的字节数。既然是“宏”,就说明它不是单片机执行的函数,而是单片机之外的C编译器执行的函数(像#define这类宏语句一样),也就是说,在单片机上电之前,C编译器在电脑端翻译我们的C语言程序的时候,一旦发现了这个宏函数sizeof,它就会在电脑端根据C语言程序的一些关键字符(比如“unsigned char,[,]”这类字符)来自动计算这个对象所占用的字节数,然后再把我们C语言程序里所有的sizeof字符替换等效成一个“常量数字”,1代表1个字节,5代表5个字节,1000代表1000个字节。所谓在单片机之外执行的宏函数,就是说,在“计算”这些对象所占的字节数的时候,这个“计算”的工作只占用电脑的内存(C编译器是在电脑上运行的),并不占用单片机的ROM容量和内存。而其它在单片机端执行的“非宏”函数,是占用单片机的ROM容量和内存。比如:
unsgiend char a; //变量。占用1个字节
unsgiend int b; //变量。占用2个字节
unsgiend long c; //变量。占用4个字节
code unsgiend char d; //常量。占用9个字节
unsigned int Gu16GetBytes;//这个变量用来获取字节数
Gu16GetBytes=sizeof(a); //单片机上电后,在单片机程序里等效于Gu16GetBytes=1;
Gu16GetBytes=sizeof(b); //单片机上电后,在单片机程序里等效于Gu16GetBytes=2;
Gu16GetBytes=sizeof(c); //单片机上电后,在单片机程序里等效于Gu16GetBytes=4;
Gu16GetBytes=sizeof(d); //单片机上电后,在单片机程序里等效于Gu16GetBytes=9;
上述的“sizeof字符”在进入到单片机的层面的时候,已经被编译器预先替换成对应的“常量数字”的,这个“常量数字”就代表所占用的字节数。
【69.2 宏函数sizeof()的作用。】
在项目中,通常用在两个方面:一方面是用在求一个数组的大小尺寸,另一方面是用在计算内存分配时候的偏移量。当然,sizeof并不是“刚需”,如果没有sizeof宏函数,我们也可以人工计算出一个对象所占用的字节数,只是,人工计算,一方面容易出错,另一方面代码往往“动一发而牵全身”,改一个变量往往就会涉及很多地方需要配合调整更改,没法做到“自由裁剪”的境界。下面举一个程序例子:要把3个不同长度的数组“合并”成1个数组。
第一种情况:在没有使用sizeof宏函数时,人工计算字节数和偏移量:
unsigned char a={1,2}; //占用2个字节
unsigned char b={3,4,5}; //占用3个字节
unsigned char c={6,7,8,9}; //占用4个字节
unsigned charHeBing;//合并a,b,c在一起的数组。这里的9是人工计算a,b,c容量累加所得。
unsigned char i; //循环变量i
for(i=0;i<2;i++)//这里的2,是人工计算出a占用2个字节
{
HeBing=a;//从HeBing数组的偏移量第0个地址开始存放。
}
for(i=0;i<3;i++)//这里的3,是人工计算出b占用3个字节
{
HeBing=b;//这里的2是人工计算出的偏移量。a占用了数组2个字节。
}
for(i=0;i<4;i++)//这里的4,是人工计算出c占用4个字节
{
HeBing=c;//这里的2和3是人工计算出的偏移量,a和b占用了数组2+3个字节。
}
第二种情况:在使用sizeof宏函数时,利用C编译器自动来计算字节数和偏移量:
unsigned char a={1,2}; //占用2个字节
unsigned char b={3,4,5}; //占用3个字节
unsigned char c={6,7,8,9}; //占用4个字节
unsigned charHeBing;//C编译器自动计算字节数
unsigned char i;
for(i=0;i<sizeof(a);i++)//C编译器自动计算字节数
{
HeBing=a;
}
for(i=0;i<sizeof(b);i++)//C编译器自动计算字节数
{
HeBing=b;//C编译器自动计算偏移量
}
for(i=0;i<sizeof(c);i++)//C编译器自动计算字节数
{
HeBing=c; //C编译器自动计算偏移量
}
【69.3 例程练习和分析。】
现在编写一个练习的程序:
/*---C语言学习区域的开始。-----------------------------------------------*/
unsigned char a={1,2}; //占用2个字节
unsigned char b={3,4,5}; //占用3个字节
unsigned char c={6,7,8,9}; //占用4个字节
unsigned charHeBing;//C编译器自动计算字节数
unsigned char i;
void main() //主函数
{
for(i=0;i<sizeof(a);i++)//C编译器自动计算字节数
{
HeBing=a;
}
for(i=0;i<sizeof(b);i++)//C编译器自动计算字节数
{
HeBing=b;//C编译器自动计算偏移量
}
for(i=0;i<sizeof(c);i++)//C编译器自动计算字节数
{
HeBing=c; //C编译器自动计算偏移量
}
for(i=0;i<sizeof(HeBing);i++)//利用宏sizeof计算出HeBing数组所占用的字节数
{
View(HeBing); //把HeBing所有数据挨个依次全部发送到电脑端观察
}
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:1
十六进制:1
二进制:1
第2个数
十进制:2
十六进制:2
二进制:10
第3个数
十进制:3
十六进制:3
二进制:11
第4个数
十进制:4
十六进制:4
二进制:100
第5个数
十进制:5
十六进制:5
二进制:101
第6个数
十进制:6
十六进制:6
二进制:110
第7个数
十进制:7
十六进制:7
二进制:111
第8个数
十进制:8
十六进制:8
二进制:1000
第9个数
十进制:9
十六进制:9
二进制:1001
分析:
HeBing为1。
HeBing为2。
HeBing为3。
HeBing为4。
HeBing为5。
HeBing为6。
HeBing为7。
HeBing为8。
HeBing为9。
【69.4 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
吴坚鸿
发表于 2017-5-21 10:30:15
第七十节: “万能数组”的结构体。
【70.1 结构体与数组。】
结构体是数组,但不是普通的数组,而是一种“万能数组”。普通数组,是依靠严格的数组下标(类似编号)来识别某个具体单元的(或者称“寻址”),期间,如果要往数组插入或者删除某些单元,后面所有单元的下标编号都会发生改变,牵一发而动全身,后面其它单元的下标序号自动重新排列,原来某个特定的单元的下标发生了改变,也就意味着“名字”发生了改变,这种情况在编写程序的时候,就意味着很多代码需要随着更改调整,给程序员带来很多不便。怎么办?结构体此时横空出世,扭转了这种“不便”的局面。之所以称结构体为“万能数组”,是因为结构体内部没有“下标编号”,只有名字。结构体与普通数组的本质区别是,结构体是靠“名字”来寻址的,不管你往结构体里插入或者删除某些单元,其它单元的“名字”不会发生改变,隔离效果好,左邻右舍不会受影响。除此之外,结构体内部的成员变量是允许出现不同的数据类型的,比如unsigned char,unsigned int,unsigned long这三种数据类型的变量都可以往同一个结构体里面“填充”,不受类型的局限,真正做到“万能”级。而普通数组就没有这个优越性,普通数组要么清一色都是unsigned char,要么清一色都是unsigned int,要么清一色都是unsigned long,不能像结构体这么“混合型”的。结构体的这种优越性,在大型程序的升级和维护时体现得非常明显。
【70.2 “造模”和“生成”和“调用”。】
结构体的使用,有三道标准工序“造模”和“生成”和“调用”。塑胶外壳,必须先开模具(造模),然后再用模具印出外壳(生成),再把外壳应用于日常生活中(调用)。结构体也一样,先“造”结构体的“模”(造模),再根据这个“模”来“生成”一个结构体变量(生成),然后在某函数里使用此变量(调用)。例子如下:
struct StructMould //“造模”
{
unsigned charu8Data_A;
unsigned int u16Data_B;
unsigned longu32Data_C;
};
struct StructMouldGtMould;//“生成”一个变量GtMould。
void main()
{
GtMould.u8Data_A=1; //依靠成员的“名字”来“调用”
GtMould.u16Data_B=2; //依靠成员的“名字”来“调用”
GtMould.u32Data_C=3; //依靠成员的“名字”来“调用”
while(1)
{
}
}
把上述程序转换成“普通数组”和“指针”的形式,给大家一个直观的对比,代码如下:
unsigned char Gu8MouldBuffer;//相当于结构体变量GtMould
unsigned char *pu8Data_A;
unsigned int*pu16Data_B;
unsigned long *pu32Data_C;
void main()
{
pu8Data_A=(unsigned char *)&Gu8MouldBuffer;//依靠数组的下标来“调用”
*pu8Data_A=1;
pu16Data_B=(unsigned int *)&Gu8MouldBuffer;//依靠数组的下标来“调用”
*pu16Data_B=2;
pu32Data_C=(unsigned long *)&Gu8MouldBuffer; //依靠数组的下标来“调用”
*pu32Data_C=3;
while(1)
{
}
}
分析:上述两种代码,目标都是把1,2,3这三个数字存放在一个数组里。第一种用结构体的方式,第二种用普通数组的方式。
【70.3 例程练习和分析。】
现在编写一个练习的程序:
/*---C语言学习区域的开始。-----------------------------------------------*/
struct StructMould //“造模”
{
unsigned charu8Data_A;
unsigned int u16Data_B;
unsigned longu32Data_C;
};
struct StructMouldGtMould;//“生成”一个变量GtMould。
void main() //主函数
{
GtMould.u8Data_A=1; //依靠成员的“名字”来“调用”
GtMould.u16Data_B=2; //依靠成员的“名字”来“调用”
GtMould.u32Data_C=3; //依靠成员的“名字”来“调用”
View(GtMould.u8Data_A); //把结构体成员GtMould.u8Data_A发送到电脑端观察
View(GtMould.u16Data_B); //把结构体成员GtMould.u16Data_B发送到电脑端观察
View(GtMould.u32Data_C); //把结构体成员GtMould.u32Data_C发送到电脑端观察
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:1
十六进制:1
二进制:1
第2个数
十进制:2
十六进制:2
二进制:10
第3个数
十进制:3
十六进制:3
二进制:11
分析:
GtMould.u8Data_A为1。
GtMould.u16Data_B为2。
GtMould.u32Data_C为3。
【70.4 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
吴坚鸿
发表于 2017-5-29 11:29:12
第七十一节: 结构体的内存和赋值。
【71.1 结构体的内存生效。】
上一节讲到结构体有三道标准工序“造模”和“生成”和“调用”,那么,结构体在哪道工序的时候才会开始占用内存(或者说内存生效)?答案是在第二道工序“生成”(或者说定义)的时候才产生内存开销。第一道工序仅“造模”不“生成”是不会产生内存的。什么意思呢?请看下面的例子。
第一种情况:仅“造模”不“生成”。
struct StructMould //“造模”
{
unsigned charu8Data_A;
unsigned charu8Data_B;
};
分析:这种情况是没有内存开销的,尽管你已经写下了数行代码,但是C编译器在翻译此代码的时候,它会识别到你偷工减料仅仅“造模”而不“生成”新变量,此时C编译器会把你这段代码忽略而过。
第二种情况:先“造模”再“生成”。
struct StructMould //“造模”
{
unsigned charu8Data_A;
unsigned charu8Data_B;
};
struct StructMouldGtMould_1;//“生成”一个变量GtMould_1。占用2个字节内存
struct StructMouldGtMould_2;//“生成”一个变量GtMould_2。占用2个字节内存
分析:这种情况才会占用内存。你“生成”变量越多,占用的内存就越大。像本例子,“生成”了两个变量GtMould_1和GtMould_2,一个变量占用2个字节,两个就一共占用了4个字节。结论:内存的占用是跟变量的“生成”有关。
【71.2 结构体的内存对齐。】
什么是对齐?为了确保内存的地址能整除某个“对齐倍数”(比如4)。比如以4为“对齐倍数”,在地址0存放一个变量a,因为地址0能整除“对齐倍数”4,所以符合“地址对齐”,接着往下再存放第二个变量b,紧接着的地址1不能整除“对齐倍数”4,此时,为了内存对齐,本来打算把变量b放到地址1的,现在就要更改挪到地址4才符合“地址对齐”,这就是内存对齐的含义。“对齐倍数”是什么?“对齐倍数”就是单片机的位数除以8。比如8位单片机的“对齐倍数”是1(8除以8),16位单片机是2(16除以8),32位单片机是4(32除以8)。本教材所用的单片机是8位的51内核单片机,因此“对齐倍数”是1。1是可以被任何整数整除的,因此,8位单片机在结构体的使用上被内存对齐的“干扰”是最小的。
为什么要对齐?单片机内部硬件层面一条指令处理的数据宽度是固定的,比如,因为一个字节是8位,所以,8位的单片机一次处理的数据宽度是1个字节(8除以8等于1),16位的单片机一次处理的数据宽度是2个字节(16位除以8位等于2),32位的单片机一次处理的数据宽度是4个字节(32位除以8位等于4),如果字节不对齐,本来单片机一个指令能处理的数据可能就要分解成2个指令甚至更多的指令,所以C编译器为了让单片机处于最佳状态,在某些情况就会涉及内存对齐,结构体就涉及到内存对齐。
结构体的内存对齐表现在哪里呢?请看下面两个例子:
第一个例子:8位单片机。
struct StructMould_1 //“造模”
{
unsigned charu8Data; //一个unsigned char占用1个字节。
unsigned longu32Data; //一个unsigned long占用4个字节。
};
struct StructMould_1GtMould_1;//占用多少个字节内存呢?
分析:GtMould_1这个变量占用多少个内存字节呢?假设GtMould_1的首地址是0,那么地址0就存放成员u8Data,u8Data占用1个字节,所以接下来的地址是1(0+1),问题来了,地址1能直接存放占用4个字节的成员u32Data吗?因为8位单片机的“对齐倍数”是1(8除以8),那么地址1显然是可以整除“对齐倍数”1的,因此,地址1是可以果断存储u32Data成员的。因此,GtMould_1占用的总字节数是5(1+4),也就是u8Data和u32Data两者所占字节数之和。
第二个例子:32位单片机。
struct StructMould_1 //“造模”
{
unsigned charu8Data; //一个unsigned char占用1个字节。
unsigned longu32Data; //一个unsigned long占用4个字节。
};
struct StructMould_1GtMould_1;//占用多少个字节内存呢?
分析:GtMould_1这个变量占用多少个内存字节呢?假设GtMould_1的首地址是0,那么地址0就存放成员u8Data,u8Data占用1个字节,所以接下来的地址是1(0+1),那么问题来了,地址1能直接存放占用4个字节的成员u32Data吗?不能。因为32位单片机的“对齐倍数”是4(32除以8),那么地址1显然是不可以整除“对齐倍数”4的,因此,就要把地址1更改挪到地址4这里才符合“地址对齐”,这样,就意味着多插入了3个“填充的字节”,因此,GtMould_1占用的总字节数是8(1+3+4),也就是“1个字节u8Data,3个填充字节,4个u32Data”三者所占字节数之和。那么问题又来了,如果把结构体内部成员u8Data和u32Data的位置顺序更改一下,内存容量会有所改变吗?位置顺序更改后如下。
struct StructMould_1 //“造模”
{
unsigned longu32Data; //一个unsigned long占用4个字节。
unsigned charu8Data; //一个unsigned char占用1个字节。
};
struct StructMould_1GtMould_1;//占用多少个字节内存呢?
分析:更改u8Data和u32Data的位置顺序后,u32Data在前u8Data在后,GtMould_1这个变量占用多少个内存字节呢?假设GtMould_1的首地址是0,那么地址0就存放成员u32Data,u32Data占用4个字节,所以接下来的地址是4(0+4),那么问题来了,地址4能直接存放占用1个字节的成员u8Data吗?能。因为32位单片机的“对齐倍数”是4(32除以8),那么地址4显然是可以整除“对齐倍数”4的,因此,地址4是可以果断存储u8Data的。那么,是不是GtMould_1就占用5个字节呢?不是。因为结构体的内存对齐,还包括另外一条规定,那就是“一个结构体变量所占的内存总容量必须能整除该单片机的“对齐倍数”(单片机的位数除以8),如果不能,C编译器就会擅自在最后一个成员的后面插入若干个“填充字节”来满足这个规则”,根据这条规定,计算所得的总容量5是不能整除“对齐倍数”4的,必须再额外填充3个字节补足到8,才能整除“对齐倍数”4,因此,更改顺序后,GtMould_1还是占用8个字节(4+1+3),前4个字节是u32Data,中间1个字节是u8Data,后3个字节是“填充字节”。
因为本教程采用的是8位的51内核单片机,因此,在上述这个例子中,GtMould_1所占的字节数是符合“第一个例子”的情况,也就是占用5个字节。内存对齐是遵守几条严格的规则的,我只列出其中最关键的两条给大家大致阅读一下,有一个印象即可,不强求死记硬背,只需知道“结构体因为存在内存对齐,所以实际内存容量是有可能大于内部各成员类型字节数相加之和,尤其是16位或者32位这类单片机”就可以了。
第(1)条:结构体内部某个成员相对结构体首地址的偏移地址必须能整除该单片机的“对齐倍数”(单片机的位数除以8),如果不能,C编译器就会擅自在各成员之间插入若干个“填充字节”来满足这个规则。
第(2)条:一个结构体变量所占的内存总容量必须能整除该单片机的“对齐倍数”(单片机的位数除以8),如果不能,C编译器就会擅自在最后一个成员的后面插入若干个“填充字节”来满足这个规则。
【71.3 如何获取某个结构体变量的内存容量?】
结构体存在内存对齐的问题,就说明它的内存占用情况不会像普通数组那样一目了然,那么,我们编写程序的时候怎么知道某个结构体变量占用了多少个字节数?答案是:用sizeof宏函数。比如:
struct StructMould_1
{
unsigned longu32Data;
unsigned charu8Data;
};
struct StructMould_1GtMould_1;
unsigned long a; //此变量用来获取结构体变量GtMould_1所占用的字节总数
void main() //主函数
{
a=sizeof(GtMould_1);//利用宏函数sizeof获取结构体变量所占用的字节总数
}
【71.4 结构体之间的赋值。】
结构体之间的赋值有两种,第一种是成员之间“一对一”的赋值,第二种是整个结构体之间“面对面”的整体赋值。第一种成员赋值像普通变量赋值那样,没有那么多套路和忌讳,数据传递安全可靠。第二种整个结构体之间赋值在编程体验上带有“一键操作”的快感,但是要注意避开一些“雷区”,首先,整体赋值的前提是必须保证两个结构体变量都是同一个“结构体模板”造出来的变量,不同“模板”的结构体变量之间禁止“整体赋值”,其次,哪怕是“同一个模板”的结构体变量,也并不是所有的“同模板结构体”变量都能实现整个结构体之间的直接赋值,只有在结构体内部成员比较简单的情况下才适合“整体赋值”,如果结构体内部包含有“指针”或者“字符串”或者“其它结构体中的结构体”,这类情况就比较复杂,这时建议大家绕开有“雷区”的“整体赋值”而直接选用安全可靠的“成员赋值”。什么是“成员赋值”什么是“整体赋值”?请看下面两个例子。
第一种:成员赋值。把结构体变量GtMould_2_A赋值给GtMould_2_B。
struct StructMould_2 //“造模”
{
unsigned longu32Data;
unsigned charu8Data;
};
struct StructMould_2GtMould_2_A;//生成第1个结构体变量
struct StructMould_2GtMould_2_B //生成第2个结构体变量
void main() //主函数
{
//先给GtMould_2_A赋初值。
GtMould_2_A.u32Data=1;
GtMould_2_A.u8Data=2;
//通过“成员赋值”,把结构体变量GtMould_2_A赋值给GtMould_2_B。
GtMould_2_B.u32Data=GtMould_2_A.u32Data; //成员之间“一对一”的赋值
GtMould_2_B.u8Data=GtMould_2_A.u8Data; //成员之间“一对一”的赋值
}
第二种:整体赋值。把结构体变量GtMould_2_A赋值给GtMould_2_B。
struct StructMould_2 //“造模”
{
unsigned longu32Data;
unsigned charu8Data;
};
struct StructMould_2GtMould_2_A;//生成第1个结构体变量
struct StructMould_2GtMould_2_B //生成第2个结构体变量
void main() //主函数
{
//先给GtMould_2_A赋初值。
GtMould_2_A.u32Data=1;
GtMould_2_A.u8Data=2;
//通过“整体赋值”,把结构体变量GtMould_2_A赋值给GtMould_2_B。
GtMould_2_B=GtMould_2_A; //整体之间“一次性”的赋值
}
上述例子中的整体赋值,是因为结构体内部的数据比较“简单”,没有包含“指针”或者“字符串”或者“其它结构体中的结构体”这类数据成员,如果包含这类成员,建议大家不要用整体赋值。比如遇到以下这类结构体就建议大家直接用安全可靠的“成员赋值”:
struct StructMould //“造模”
{
unsigned char u8String[]=”String”;//字符串
unsigned char*pu8Data; //指针
struct StructOtherMould GtOtherMould;//结构体中的结构体
};
【71.5 例程练习和分析。】
现在编写一个练习的程序:
/*---C语言学习区域的开始。-----------------------------------------------*/
struct StructMould_1 //“造模”
{
unsigned longu32Data; //一个unsigned long占用4个字节。
unsigned charu8Data; //一个unsigned char占用1个字节。
};
struct StructMould_2 //“造模”
{
unsigned charu8Data;
unsigned longu32Data;
};
struct StructMould_1GtMould_1;//占用多少个字节内存呢?
struct StructMould_2GtMould_2_A;
struct StructMould_2GtMould_2_B;
unsigned long a; //此变量用来获取结构体变量GtMould_1所占用的字节总数
void main() //主函数
{
a=sizeof(GtMould_1);//利用宏函数sizeof获取结构体变量GtMould_1所占用的字节总数
//先给GtMould_2_A赋初值。
GtMould_2_A.u32Data=1;
GtMould_2_A.u8Data=2;
//通过“整体赋值”,把结构体变量GtMould_2_A赋值给GtMould_2_B。
GtMould_2_B=GtMould_2_A; //整体之间“一次性”的赋值
View(a); //把a发送到电脑端观察
View(GtMould_2_B.u32Data); //把结构体成员GtMould_2_B.u32Data发送到电脑端观察
View(GtMould_2_B.u8Data); //把结构体成员GtMould_2_B.u8Data发送到电脑端观察
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:5
十六进制:5
二进制:101
第2个数
十进制:1
十六进制:1
二进制:1
第3个数
十进制:2
十六进制:2
二进制:10
分析:
GtMould_1所占的字节数a为5。
GtMould_2_B的结构体成员GtMould_2_B.u32Data为1。
GtMould_2_B的结构体成员GtMould_2_B.u8Data为2。
【71.6 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
su33691
发表于 2017-5-29 12:51:23
给吴老板点个赞!
吴坚鸿
发表于 2017-6-4 12:16:41
第七十二节: 结构体的指针。
【72.1 结构体指针的重要用途。】
结构体指针有两个重要用途,一个是结构体数据的拆分和打包,另一个是作为结构体数据在涉及函数时的参数入口。
什么是“结构体数据的拆分和打包”?结构体本质是一个数组,数组内可能包含了许多不同数据长度类型的成员,当我们直接操作某个具体的成员时,只改变某个成员的数值,不影响其它成员,这个就是“拆分”的角度。那么,什么是“打包”?当涉及整个结构体数据的存储或者传输(通信)给另外一个单片机时,这时候有两种选择,一种是一个成员一个成员的挨个处理,这种“拆分”的处理方式比较繁琐麻烦,另外一种就是把整个结构体当作一个以字节为单位的整体数组来处理,这种处理方式就是高速便捷的“打包”处理,但是关键的问题来了,我们把整个结构体数据以字节的方式“打包”传递给另外一个单片机,但是这个单片机接收到我们一组数据后,如何把这“一包”以字节为单位的数组转换成相同的结构体变量,以便在它的程序处理中也能以“拆分”的角度直接处理某个具体的成员变量,这时就涉及到结构体指针的作用。
什么是“作为结构体数据在涉及函数时的参数入口”?结构体数据一般内部包含了很多成员,当要把这一包数据传递给某个函数内部时,这个函数要给结构体数据预留参数入口,这时,如果函数以结构体成员的角度来预留入口,那么有多少个成员就要预留多少个函数的参数入口,可阅读性非常差,操作起来也麻烦。但是,如果以指针的角度来预留入口,那么不管这个结构体内部包含多少个成员,只需要预留一个指针参数入口就够用了,这就是绝大多32单片机在写库函数时都采样结构体指针作为函数的参数入口的原因。
结构体指针这两个重要用途后续章节会深入讲解,本节的重点是先让大家学会结构体指针的基础知识,为后续章节做准备。
【72.2 结构体指针的基础。】
操作结构体内部某个具体变量时,有两种方式,一种是成员调用的方式,另一种是指针调用的方式。C语言语法为了区分这两种方式,专门设计了两种不同的操作符号。成员调用方式采样小数点“.”的符号,指针调用方式采用箭头“->”的符号。例子如下:
struct StructMould_1
{
unsigned charu8Data_A;
unsigned longu32Data_B;
};
struct StructMould_1 GtMould_1;//“生成”一个变量。 //占用5个字节。
struct StructMould_1*ptMould_1;//定义一个结构体指针。 //占用3个字节。
void main() //主函数
{
GtMould_1.u8Data_A=5; //“成员调用”的方式,用小数点符号“.”
ptMould_1=&GtMould_1; //ptMould_1指针与变量GtMould_1建立关联。
ptMould_1->u8Data_A=ptMould_1->u8Data_A+5; //“指针调用”的方式,用箭头符号“->”
while(1)
{
}
}
分析:上述例子中,信息量很大,知识点有两个。
第一个知识点:为什么结构体变量GtMould_1占用5个字节,而结构体指针*ptMould_1只占用3个字节?结构体变量GtMould_1所占的内存是由结构体成员内部的数量决定的,而结构体指针*ptMould_1是由C编译器根据芯片硬件寻址范围而决定的,在一个相同的C编译器系统中,所有类型的指针所占用的字节数都是一样的,比如在本教程中所用8位单片机的C51编译器系统中,unsigned char *,unsigned int *,unsigned long *,以及本节的struct StructMould_1 *,都是占用3个字节(题外话,我前面第60节中所说的“凡是32位以下的单片机的指针都是占用4个字节”是有误的,抱歉)。32位单片机的指针往往都是4个字节,而某些64位的PC机,指针可能是8个字节,这些内容大家只要有个大概的了解即可。
第二个知识点:结构体成员GtMould_1.u8Data_A经过第一步的“成员调用”直接赋值5,紧接着经过“指针调用”的累加5操作,最后GtMould_1.u8Data_A的数值是10(5+5)。
【72.3 例程练习和分析。】
现在编写一个练习的程序:
/*---C语言学习区域的开始。-----------------------------------------------*/
struct StructMould_1
{
unsigned charu8Data_A;
unsigned longu32Data_B;
};
struct StructMould_1 GtMould_1;//“生成”一个变量。 //占用5个字节。
struct StructMould_1*ptMould_1;//定义一个结构体指针。 //占用3个字节。
void main() //主函数
{
GtMould_1.u8Data_A=5; //“成员调用”的方式,用小数点符号“.”
ptMould_1=&GtMould_1; //ptMould_1指针与变量GtMould_1建立关联。
ptMould_1->u8Data_A=ptMould_1->u8Data_A+5; //“指针调用”的方式,用箭头符号“->”
View(sizeof(GtMould_1)); //在电脑端观察变量GtMould_1占用多少个字节。
View(sizeof(ptMould_1)); //在电脑端观察指针ptMould_1占用多少个字节。
View(GtMould_1.u8Data_A); //在电脑端观察结构体成员GtMould_1.u8Data_A的最后数值。
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:5
十六进制:5
二进制:101
第2个数
十进制:3
十六进制:3
二进制:11
第3个数
十进制:10
十六进制:A
二进制:1010
分析:
变量GtMould_1占用5个字节。
指针ptMould_1占用3个字节。
结构体成员GtMould_1.u8Data_A的最后数值是10。
【72.4 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
tt98
发表于 2017-6-6 15:46:16
顶下鸿哥{:lol:}
吴坚鸿
发表于 2017-6-11 09:35:57
第七十三节: 结构体数据的传输存储和还原。
【73.1 结构体数据的传输存储和还原。】
结构体本质是一个数组,数组内可能包含了许多不同数据长度类型的成员,当整个结构体数据需要存储或者传输(通信)给另外一个单片机时,这时候有两种选择,一种是一个成员一个成员的挨个处理,这种“以成员为单位”的处理方式比较繁琐麻烦,另外一种是把整个结构体变量当作一个“以字节为单位”的普通数组来处理,但是有两个关键的问题来了,第一个问题是如何把结构体“拆分”成“以字节为单位”来进行搬动数据,第二个问题是假如我们把整个结构体数据以“字节为单位”的方式“整体打包”传递给另外一个单片机,当这个接收方的单片机接收到我们这一组数据后,如何把这“一包”以字节为单位的数组再“还原”成相同的结构体变量,以便在程序处理中也能直接按“结构体的方式”来处理某个具体的成员。其实,这两个问题都涉及到“指针的强制转换”。具体讲解的例子,请直接阅读下面73.2段落的源代码例子和注释。
【73.2 例程练习和分析。】
现在编写一个练习程序,把一个结构体变量“以字节的方式”存储到另外一个普通数组里,然后再把这个“以字节为单位”的普通数组“还原”成“结构体的方式”,以便直接操作内部某个具体的成员。
/*---C语言学习区域的开始。-----------------------------------------------*/
struct StructMould_1
{
unsigned charu8Data_A;
unsigned longu32Data_B;
unsigned int u16Data_C;
};
struct StructMould_1 GtMould_1;//“生成”一个变量。
unsigned char Gu8Buffer; //定义一个内存跟结构体变量大小一样的普通数组
unsigned char *pu8; //定义一个把结构体变量“拆分”成“以字节为单位”的指针
struct StructMould_1 *ptStruct; //定义一个结构体指针,用于“还原”普通数组为“结构体”
unsigned int i; //定义一个用于for循环的变量
void main() //主函数
{
//先把该结构体变量内部具体成员分别以“成员的方式”初始化为5,6,7
GtMould_1.u8Data_A=5;
GtMould_1.u32Data_B=6;
GtMould_1.u16Data_C=7;
pu8=(unsigned char *)&GtMould_1; //把结构体变量强制转换成“以字节为单位”的指针
for(i=0;i<sizeof(GtMould_1);i++)
{
Gu8Buffer=pu8; //把结构体变量以字节的方式搬运并且存储到普通数组里。
}
ptStruct=(struct StructMould_1*)&Gu8Buffer;//再把普通数组强制“还原”成结构体指针
ptStruct->u8Data_A=ptStruct->u8Data_A+1; //该变量从5自加1后变成6。
ptStruct->u32Data_B=ptStruct->u32Data_B+1; //该变量从6自加1后变成7。
ptStruct->u16Data_C=ptStruct->u16Data_C+1; //该变量从7自加1后变成8。
View(ptStruct->u8Data_A);//在电脑端观察结构体成员u8Data_A的数值。
View(ptStruct->u32Data_B); //在电脑端观察结构体成员u32Data_B的数值。
View(ptStruct->u16Data_C); //在电脑端观察结构体成员u16Data_C的数值。
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:6
十六进制:6
二进制:110
第2个数
十进制:7
十六进制:7
二进制:111
第3个数
十进制:8
十六进制:8
二进制:1000
分析:
结构体成员u8Data_A的数值是6。
结构体成员u32Data_B的数值是7。
结构体成员u16Data_C的数值是8。
【73.3 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
吴坚鸿
发表于 2017-6-17 18:04:55
第七十四节: 结构体指针在函数接口处的频繁应用。
【74.1 重温“函数的接口参数”。】
函数的接口参数主要起到标识的作用。比如:
一个加法函数:
unsigned char add(unsinged char a,unsigned char b)
{
return (a+b);
}
这里的a和b就是接口参数,它的作用是告诉人们,你把两个加数分别代入a和b,返回的就是你要的加法运算结果。这里的接口参数就起到入口标识的作用。注意,这句话的关键词是“标识”而不是“入口”,因为函数的“入口”不是唯一的,而是无数条路径。为什么这么说?我们把上面的例子改一下,改成全局变量,例子如下:
一个加法函数:
unsinged char a;//加数
unsigned char b; //加数
unsigned char c;//和
void add(void)
{
c=a+b;
}
上述例子中,尽管我用“两个”void(空的)关键词把原来加法函数的入口(接口参数)和出口(return返回)都堵得死死的,但是,全局变量是无法阻挡的,它进入一个函数的内部不受任何限制,也就是说,我们做项目的时候,如果把所有函数的接口参数和返回都改成void类型,所有的信息传递都改用全局变量,这样也是可以勉强把项目做完成的。但是,如果真的把所有函数的接口参数都改成void,全部靠全局变量来传递信息,那么最大的问题是函数多了之后,阅读非常不方面,你每看到一个被调用的函数,你不能马上猜出它大概跟哪些全局变量发生了关联,你必须一个一个的去查该函数的源代码才能理清楚,针对这个问题,C语言的设计者,给了函数非常丰富的接口参数,最理想的函数是:你把凡是与此函数相关的全局变量都经过接口参数的入口才进入到函数内部,尽量把接口参数的入口看作是函数的唯一合法入口(尽管不是唯一也不是必须),这样只要看函数的接口参数就知道这个函数跟哪些全局变量有关,函数的输入输出就非常清晰明了。但是问题又来了,如果有多少个全局变量就开多少个接口参数,接口参数就会变得非常多,接口参数多了,函数的门面就非常难看,无异于把本来应该“小而窄”的接口设在“宽而广”的平原上,还不如直接用原来那种全局变量强行进入呢。那么,要解决这个问题怎么办?本节的主角“结构体指针”可以解决这个问题。
【74.2 结构体指针在函数接口处的频繁应用。】
当函数的接口参数非常多的时候,可以把N个相关的全局变量“打包”成一个结构体数据,碰到函数接口的时候,可以通过“结构体指针”以“包”为单位的方式进入,这样就可以让函数的接口参数看起来非常少,这种方法,是很多32位单片机的库函数一直在用的方法,它最重要的好处是简化入口的通道数量。你想想,32位单片机有那么多寄存器,如果没有这种以“结构体指针”为接口参数的方式,它的入口可能需要几十个接口参数,那岂不是非常麻烦?库函数设计的成败与否,本来就在于接口的设计合不合理,“结构体指针作为函数接口参数”在此场合就显得特别有价值,使用了这种方法,函数与全局变量之间,它们的关联脉络再也不用隐藏起来,并且可以很清晰的表达清楚。现在举一个例子,比如有一个函数,要实现把5个全局变量“自加1”的功能,分别使用两种接口参数来实现,例子如下:
第一种方式:有多少个全局变量就开多少个接口参数。
//函数的声明
void Add_One( unsigned char *pu8Data_1,//第1个接口参数
unsigned char *pu8Data_2,//第2个接口参数
unsigned char *pu8Data_3,//第3个接口参数
unsigned char *pu8Data_4,//第4个接口参数
unsigned char *pu8Data_5);//第5个接口参数
//5个全局变量的定义
unsigned char a;
unsigned char b;
unsigned char c;
unsigned char d;
unsigned char e;
//函数的定义
void Add_One( unsigned char *pu8Data_1,//第1个接口参数
unsigned char *pu8Data_2,//第2个接口参数
unsigned char *pu8Data_3,//第3个接口参数
unsigned char *pu8Data_4,//第4个接口参数
unsigned char *pu8Data_5)//第5个接口参数
{
*pu8Data_1=(*pu8Data_1)+1; //实现自加1的功能
*pu8Data_2=(*pu8Data_2)+1;
*pu8Data_3=(*pu8Data_3)+1;
*pu8Data_4=(*pu8Data_4)+1;
*pu8Data_5=(*pu8Data_5)+1;
}
void main()
{
//5个全局变量都初始化为0
a=0;
b=0;
c=0;
d=0;
e=0;
//函数的调用,实现5个变量都“自加1”的功能。加“&”表示“传址”的方式进入函数内部。
Add_One(&a,//第1个接口参数
&b,//第2个接口参数
&c,//第3个接口参数
&d,//第4个接口参数
&e); //第5个接口参数
}
第二种方式:把N个全局变量打包成一个结构体,以“结构体指针”的方式进入函数内部。
//函数的声明
void Add_One(struct StructMould *ptMould);//只有1个结构体指针,大大减小了接口参数。
//结构体的“造模”
struct StructMould
{
unsigned char a;
unsigned char b;
unsigned char c;
unsigned char d;
unsigned char e;
};
struct StructMould GtMould;//生成一个结构体变量,内部包含了5个全局变量a,b,c,d,e。
//函数的定义
void Add_One(struct StructMould *ptMould)//只有1个结构体指针,大大减小了接口参数。
{
ptMould->a=ptMould->a+1;//实现“自加1”的功能。
ptMould->b=ptMould->b+1;
ptMould->c=ptMould->c+1;
ptMould->d=ptMould->d+1;
ptMould->e=ptMould->e+1;
}
void main()
{
//5个全局变量的结构体成员都初始化为0
GtMould.a=0;
GtMould.b=0;
GtMould.c=0;
GtMould.d=0;
GtMould.e=0;
//函数的调用,实现5个变量都“自加1”的功能。加“&”表示“传址”的方式进入函数内部。
Add_One(&GtMould);//只有1个结构体指针,大大减小了接口参数。
}
【74.3 例程练习和分析。】
现在编写一个“以结构体指针为函数接口参数”的练习程序。
/*---C语言学习区域的开始。-----------------------------------------------*/
//函数的声明
void Add_One(struct StructMould *ptMould);//只有1个结构体指针,大大减小了接口参数。
//结构体的“造模”
struct StructMould
{
unsigned char a;
unsigned char b;
unsigned char c;
unsigned char d;
unsigned char e;
};
struct StructMould GtMould;//生成一个结构体变量,内部包含了5个全局变量a,b,c,d,e。
//函数的定义
void Add_One(struct StructMould *ptMould)//只有1个结构体指针,大大减小了接口参数。
{
ptMould->a=ptMould->a+1;//实现“自加1”的功能。
ptMould->b=ptMould->b+1;
ptMould->c=ptMould->c+1;
ptMould->d=ptMould->d+1;
ptMould->e=ptMould->e+1;
}
void main() //主函数
{
//5个全局变量的结构体成员都初始化为0
GtMould.a=0;
GtMould.b=0;
GtMould.c=0;
GtMould.d=0;
GtMould.e=0;
//函数的调用,实现5个变量都“自加1”的功能。加“&”表示“传址”的方式进入函数内部。
Add_One(&GtMould);//只有1个结构体指针,大大减小了接口参数。
View(GtMould.a);//在电脑端观察结构体成员GtMould.a的数值。
View(GtMould.b);//在电脑端观察结构体成员GtMould.b的数值。
View(GtMould.c);//在电脑端观察结构体成员GtMould.c的数值。
View(GtMould.d);//在电脑端观察结构体成员GtMould.d的数值。
View(GtMould.e);//在电脑端观察结构体成员GtMould.e的数值。
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:1
十六进制:1
二进制:1
第2个数
十进制:1
十六进制:1
二进制:1
第3个数
十进制:1
十六进制:1
二进制:1
第4个数
十进制:1
十六进制:1
二进制:1
第5个数
十进制:1
十六进制:1
二进制:1
分析:
结构体成员GtMould.a的数值是1。
结构体成员GtMould.b的数值是1。
结构体成员GtMould.c的数值是1。
结构体成员GtMould.d的数值是1。
结构体成员GtMould.e的数值是1。
【74.4 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
avr_sz
发表于 2017-6-25 10:40:50
刚看到“先花4年时间写一个初稿,然后再花2年时间重写一次,最后再花1年时间整理成书,整个过程大概7年时间左右”
觉得楼主是一个严谨、有责任心的人, 先顶贴,然后再拜读
吴坚鸿
发表于 2017-6-25 11:17:24
第七十五节: 指针的名义(例:一维指针操作二维数组)。
【75.1 指针的名义。】
刚开始接触指针往往有这种感觉,指针的江湖很乱,什么“乱七八糟”的指针都能冒出来,空指针,指针的指针,函数的指针,各种名目繁多的指针,似乎都可以打着指针的名义让你招架不住,而随着我们功力的提升,会逐渐拨开云雾,发现指针的真谛不外乎三个,第一个是所有的指针所占用字节数都一样,第二个是所有指针的操作本质都是“取地址”,第三个是所有各种不同类型的指针之间的转换都可以用“小括号的类型强制转换”。
【75.2 一维指针操作二维数组。】
C语言讲究门当户对,讲究类型匹配,什么类型的指针就操作什么类型的数据,否则C编译器在翻译代码的时候,会给予报错或者警告。如果想甩开因类型不匹配而导致的报错或者警告,就只能使用“小括号的类型强制转换”,这个方法在项目中应用很频繁,也很实用。一维指针想直接操作二维数组也是必须使用“小括号的类型强制转换”。实际项目中为什么会涉及“一维指针想直接操作二维数组”?二维数组更加像一个由行与列组合而成的表格,而且每行单元的内存地址是连续的,并且上下每行与每行之间的首尾单元的内存地址也是连续的,凡是内存地址连续的都是指针的菜。我曾遇到这样一种情况,要从一个二维表格里提取某一行数据用来显示,而这个显示函数是别人封装好的一个库函数,库函数对外的接口是一维指针,这样,如何把二维表格(二维数组)跟一维指针在接口上兼容起来,就是一个要面临的问题,这时有两种思路,一种是把二维数组的某一行数据先用原始的办法提取出来存放在一个中间变量的一维数组,然后再把这个一维数组代入到一维指针接口的库函数里,另一种思路是绕开中间变量,直接把二维数组的某一行的地址强制转换成一维指针的类型,利用“类型强制转换”绕开C编译器的报错或警告,实现二维数组跟一维指针“直通”,经过实验,这种方法果然可以,从此对指针的感悟就又上了一层,原来,指针的“取地址”是不仅仅局限于某个数组的首地址,它完全可以利用类型强制转换的小括号“()”与取地址符号“&”结合起来,让指针跟一维数组或者二维数组里面任何一个单元直接关联起来。请看下面两个例子,用一维指针提取二维数组里面某一行的数据,第一个例子是在程序处理中的类型强制转换的应用,第二个例子是在函数接口中的类型强制转换的应用。
【75.3 在程序处理中的类型转换。】
unsigned char table[]=//二维数组
{
{0x00,0x01,0x02},//二维数组的第0行数据
{0x10,0x11,0x12},//二维数组的第1行数据
{0x20,0x21,0x22},//二维数组的第2行数据
};
unsigned char *pGu8; //一维指针
unsigned charGu8Buffer; //一维数组,存放从二维数组里提取出来的某一行数据
unsigned chari; // for循环的变量
void main()
{
pGu8=(unsigned char *)&table;//利用类型强制转换使得一维指针跟二维数组关联起来。
for(i=0;i<3;i++)
{
Gu8Buffer=pGu8; //提取二维数组的第2行数据,存入到一个一维数组里。
}
while(1)
{
}
}
【75.4 在函数接口中的类型转换。】
在函数接口中,也可以利用类型强制转换来实现函数接口的匹配问题,比如,下面这个写法也是合法的。
void GetRowData(unsigned char *pu8); //函数的声明
unsigned char table[]=//二维数组
{
{0x00,0x01,0x02},//二维数组的第0行数据
{0x10,0x11,0x12},//二维数组的第1行数据
{0x20,0x21,0x22},//二维数组的第2行数据
};
unsigned charGu8Buffer; //一维数组,存放从二维数组里提取出来的某一行数据
void GetRowData(unsigned char *pu8)//一维指针的函数接口
{
unsigned chari; // for循环的变量
for(i=0;i<3;i++)
{
Gu8Buffer=pu8; //提取二维数组的某行数据,存入到一个一维数组里。
}
}
void main()
{
GetRowData((unsigned char *)&table); //利用类型强制转换来兼容一维指针的函数接口
while(1)
{
}
}
【75.5 注意指针或者数组越界的问题。】
上述例子中,二维数组内部只有9个数据,如果指针操作的数据超过了这9个数据的地址范围,就会导致系统其它无辜的数据受到破坏,这个问题导致的后果是很严重的,这类指针或者数组越界的问题,大家平时做项目时必须留心注意。
【75.6 例程练习和分析。】
现在编写一个练习程序。
/*---C语言学习区域的开始。-----------------------------------------------*/
void GetRowData(unsigned char *pu8); //函数的声明
unsigned char table[]=//二维数组
{
{0x00,0x01,0x02},//二维数组的第0行数据
{0x10,0x11,0x12},//二维数组的第1行数据
{0x20,0x21,0x22},//二维数组的第2行数据
};
unsigned charGu8Buffer; //一维数组,存放从二维数组里提取出来的某一行数据
void GetRowData(unsigned char *pu8)//一维指针的函数接口
{
unsigned chari; // for循环的变量
for(i=0;i<3;i++)
{
Gu8Buffer=pu8; //提取二维数组的某行数据,存入到一个一维数组里。
}
}
void main() //主函数
{
GetRowData((unsigned char *)&table); //利用类型强制转换来兼容一维指针的函数接口
View(Gu8Buffer);//在电脑端观察存放二维数组某行数据的一维数组的内容
View(Gu8Buffer);//在电脑端观察存放二维数组某行数据的一维数组的内容
View(Gu8Buffer);//在电脑端观察存放二维数组某行数据的一维数组的内容
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:32
十六进制:20
二进制:100000
第2个数
十进制:33
十六进制:21
二进制:100001
第3个数
十进制:34
十六进制:22
二进制:100010
分析:
Gu8Buffer是十六进制的0x20,提取了二维数组第2行中的某数据。
Gu8Buffer是十六进制的0x21,提取了二维数组第2行中的某数据。
Gu8Buffer是十六进制的0x22,提取了二维数组第2行中的某数据。
【75.7 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
hzyzs
发表于 2017-6-28 13:37:46
每日学习一下
hzyzs
发表于 2017-6-30 14:35:59
看到一半了,目前都很好理解
吴坚鸿
发表于 2017-7-2 11:34:12
本帖最后由 吴坚鸿 于 2017-7-2 11:37 编辑
第七十六节: 二维数组的指针。
【76.1 二维数组指针的用途。】
前面章节讲了一维指针操作二维数组,本质是通过“类型强制转换”实现的,这种应用局限于某些特定的场合,毕竟一维有1个下标,二维有2个下标,一维和二维在队形感上是有明显差别的,强行用一维指针操作二维数组会破坏了代码原有的队形感,大多数的情况,还是用二维指针操作二维数组。
二维指针主要应用在两个方面,一方面是N个二维数组的“中转站”应用,另一方面是函数接口的应用。比如,当某项目有N个二维数组表格时,要通过某个变量来切换处理某个特定的表格,以便实现“N选一”的功能,此时,二维指针在这N个二维数组之间就起到中转站的作用。又,当某个函数接口想输入或者输出一个二维数组时,就必然要用到二维指针作为函数的接口参数。
【76.2 二维指针的“中转站”应用。】
举一个例子,有3个现有的二维数组,通过某个变量来选择切换,把某个二维数组的数据复制到指定的一个缓存数组中。
code unsigned char table_1=//第1个现有的二维数组
{
{0x00,0x01,0x02},
{0x10,0x11,0x12},
{0x20,0x21,0x22},
};
code unsigned char table_2=//第2个现有的二维数组
{
{0xA0,0xA1,0xA2},
{0xB0,0xB1,0xB2},
{0xC0,0xC1,0xC2},
};
code unsigned char table_3=//第3个现有的二维数组
{
{0xD0,0xD1,0xD2},
{0xE0,0xE1,0xE2},
{0xF0,0xF1,0xF2},
};
unsigned char SaveBuffer;//指定的一个缓存数组
unsigned char TableSec;//选择变量
const unsigned char (*pTable);//“中转站”的二维指针
unsigned char R,L;//复制数据时用到的for循环变量
void main()
{
TableSec=2; //选择第2个现有的二维数组
switch(TableSec)//根据选择变量来切换选择某个现有的二维数组
{
case 1://选择第1个现有二维数组
pTable=table_1;//二维指针pTable在这里关联指定的数组,起到中转站的作用。
break;
case 2: //选择第2个现有二维数组
pTable=table_2;//二维指针pTable在这里关联指定的数组,起到中转站的作用。
break;
case 3: //选择第3个现有二维数组
pTable=table_2;//二维指针pTable在这里关联指定的数组,起到中转站的作用。
break;
}
//通过二维指针pTable来复制数据到指定的缓存数组SaveBuffer
for(R=0;R<3;R++)//行循环
{
for(L=0;L<3;L++)//列循环
{
SaveBuffer=pTable; //这里能看到,二维指针维护了二维数组的队形感
}
}
while(1)
{
}
}
【76.3 二维指针在“函数接口”中的应用。】
把上述例子“复制过程”的代码封装成一个函数,实现的功能还是一样,有3个现有的二维数组,通过某个变量来选择切换,把某个二维数组的数据复制到指定的一个缓存数组中。
//函数声明
void CopyBuffer(const unsigned char (*pTable),unsigned char (*pSaveBuffer));
code unsigned char table_1=//第1个现有的二维数组
{
{0x00,0x01,0x02},
{0x10,0x11,0x12},
{0x20,0x21,0x22},
};
code unsigned char table_2=//第2个现有的二维数组
{
{0xA0,0xA1,0xA2},
{0xB0,0xB1,0xB2},
{0xC0,0xC1,0xC2},
};
code unsigned char table_3=//第3个现有的二维数组
{
{0xD0,0xD1,0xD2},
{0xE0,0xE1,0xE2},
{0xF0,0xF1,0xF2},
};
unsigned char SaveBuffer;//指定的一个缓存数组
unsigned char TableSec;//选择变量
//*pTable是输入接口带const修饰,*pSaveBuffer是输出结果的接口无const。
void CopyBuffer(const unsigned char (*pTable),unsigned char (*pSaveBuffer))
{
unsigned char R,L;//复制数据时用到的for循环变量
for(R=0;R<3;R++)//行循环
{
for(L=0;L<3;L++)//列循环
{
pSaveBuffer=pTable; //这里能看到,二维指针维护了二维数组的队形感
}
}
}
void main()
{
TableSec=2; //选择第2个现有的二维数组
switch(TableSec)//根据选择变量来切换选择某个现有的二维数组
{
case 1://选择第1个现有二维数组
CopyBuffer(table_1,SaveBuffer); //二维指针在这里分别体现了输入和输出接口作用
break;
case 2: //选择第2个现有二维数组
CopyBuffer(table_2,SaveBuffer); //二维指针在这里分别体现了输入和输出接口作用
break;
case 3: //选择第3个现有二维数组
CopyBuffer(table_3,SaveBuffer); //二维指针在这里分别体现了输入和输出接口作用
break;
}
while(1)
{
}
}
【76.4 二维指针“类型强制转换”的书写格式。】
unsigned char *pu8,unsigned int *pu16,unsigned int *pu32这些指针的书写定义都是很有规则感的,相比之下,二维指针的定义显得缺乏规则感,比如定义的二维指针变量unsigned char (*pTable),不规则在哪?就在于二维指针的变量pTable嵌入到了括号中去,跟符号“*”捆绑在一起,如果我要强制某个指针变量为二维指针怎么办?下面的例子已经给出了答案。
unsigned char table=//二维数组
{
{0xD0,0xD1,0xD2},
{0xE0,0xE1,0xE2},
{0xF0,0xF1,0xF2},
};
unsigned char (*pTable);
void main()
{
pTable=(unsigned char (*))table;//这里,强制类型转换用unsigned char (*)
}
总结:二维数组的强制类型转换用这种书写格式(unsigned char (*)),这里的N是代表实际项目中某数组的“列”数。
【76.5 例程练习和分析。】
现在编写一个练习程序。
/*---C语言学习区域的开始。-----------------------------------------------*/
void CopyBuffer(const unsigned char (*pTable),unsigned char (*pSaveBuffer));
code unsigned char table_1=//第1个现有的二维数组
{
{0x00,0x01,0x02},
{0x10,0x11,0x12},
{0x20,0x21,0x22},
};
code unsigned char table_2=//第2个现有的二维数组
{
{0xA0,0xA1,0xA2},
{0xB0,0xB1,0xB2},
{0xC0,0xC1,0xC2},
};
code unsigned char table_3=//第3个现有的二维数组
{
{0xD0,0xD1,0xD2},
{0xE0,0xE1,0xE2},
{0xF0,0xF1,0xF2},
};
unsigned char SaveBuffer;//指定的一个缓存数组
unsigned char TableSec;//选择变量
//*pTable是输入接口带const修饰,*pSaveBuffer是输出结果的接口无const。
void CopyBuffer(const unsigned char (*pTable),unsigned char (*pSaveBuffer))
{
unsigned char R,L;//复制数据时用到的for循环变量
for(R=0;R<3;R++)//行循环
{
for(L=0;L<3;L++)//列循环
{
pSaveBuffer=pTable; //这里能看到,二维指针维护了二维数组的队形感
}
}
}
void main() //主函数
{
TableSec=2; //选择第2个现有的二维数组
switch(TableSec)//根据选择变量来切换选择某个现有的二维数组
{
case 1://选择第1个现有二维数组
CopyBuffer(table_1,SaveBuffer); //二维指针在这里分别体现了输入和输出接口作用
break;
case 2: //选择第2个现有二维数组
CopyBuffer(table_2,SaveBuffer); //二维指针在这里分别体现了输入和输出接口作用
break;
case 3: //选择第3个现有二维数组
CopyBuffer(table_3,SaveBuffer); //二维指针在这里分别体现了输入和输出接口作用
break;
}
View(SaveBuffer);//在电脑端观察某个二维数组第0行数据第0个元素的内容
View(SaveBuffer);//在电脑端观察某个二维数组第0行数据第1个元素的内容
View(SaveBuffer);//在电脑端观察某个二维数组第0行数据第2个元素的内容
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:160
十六进制:A0
二进制:10100000
第2个数
十进制:161
十六进制:A1
二进制:10100001
第3个数
十进制:162
十六进制:A2
二进制:10100010
分析:
SaveBuffer是十六进制的0xA0,提取了第2个二维数组的第0行第0个数据。
SaveBuffer是十六进制的0xA1,提取了第2个二维数组的第0行第1个数据。
SaveBuffer是十六进制的0xA2,提取了第2个二维数组的第0行第2个数据。
【76.6 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
hzyzs
发表于 2017-7-2 15:42:54
多多支持
tt98
发表于 2017-7-2 17:45:38
距100节不远了{:titter:}
geyd2000
发表于 2017-7-5 09:58:45
学习,非常感谢!
吴坚鸿
发表于 2017-7-9 09:26:51
第七十七节: 指针唯一的“单向”输出通道return。
【77.1 指针的“单向”输出通道。】
函数的接口有两个地方,一个是函数名“后面”的小括号所包含的接口参数,另一个是函数名“前面”通过函数内部return返回出来的“return返回类型”。比如:
return返回类型 函数名(接口参数,接口参数...)
unsigned char HanShu(unsigned char a,unsigned char b) //a和b是函数名“后面”的接口参数
{
unsigned char c;
c=a+b;
return c; //函数内部返回出来的“return返回类型”
}
指针在“函数名后面小括号所包含的接口参数”的地方时,可以是一个“双向”口(输入和输出),如果在指针前面加上const关键字修饰,可以把“双向”改为只能输入的“单向”口,注意,这里所说的“单向”是指“输入的单向”,但是做不到“输出的单向”,指针如果想做到“输出的单向”,就必须通过return这个通道。return返回指针这个功能很常用,比如用32位单片机想做比较漂亮的显示界面时,大家往往喜欢用到emWIN这个界面显示系统,而emWIN提供了很多库函数,这些库函数用了很多return返回的“句柄”,“句柄”其实就是指针,比如类似以下行代码:
hItem = WM_GetDialogItem(hWin_FrameWin_GetClientWindow, ID_LISTVIEW_0); //获取某个控件的句柄
其中hItem就是“句柄”,本质就是函数内部return返回出来的指针。
所以本节内容主要是想告诉大家,return不仅可以返回普通的变量,也是可以返回指针的,而且还很常用。具体内容请看下面77.2例子中的讲解。
【77.2 例程练习和分析。】
编写一个函数,要从一个二维表格的数组中提取其中某一行的数据,用return这个返回输出的通道来接收该行数据的地址(指针),然后再通过这个指针的间接调用,把该行数据全部显示出来。
/*---C语言学习区域的开始。-----------------------------------------------*/
unsigned char *GetRowData(unsigned char (*pu8Table),unsigned char u8RowSec);//函数声明
unsigned char table[]=//二维数组
{
{0x00,0x01,0x02},//二维数组的第0行数据
{0x10,0x11,0x12},//二维数组的第1行数据
{0x20,0x21,0x22},//二维数组的第2行数据
};
//函数名前面是unsigned char *,代表内部return返回的是unsigned char *的指针。
unsigned char *GetRowData(unsigned char (*pu8Table),unsigned char u8RowSec)
{
unsigned char *pu8Row;
pu8Row=(unsigned char *)&pu8Table;//提取某一行开始的地址(指针)
return pu8Row; //经过return通道对外输出指针,pu8Row是一个指针类型的变量。
}
unsigned char *pGu8Row; //接收return输出的指针
unsigned charGu8Buffer; //一维数组,存放从二维数组里提取出来的某一行数据
unsigned chari; // for循环的变量
void main() //主函数
{
pGu8Row=GetRowData(table,0);//这里的0是表示选择二维表格的第0行数据
for(i=0;i<3;i++)
{
Gu8Buffer=pGu8Row;//通过指针pGu8Row来搬运数据到一维数组Gu8Buffer
}
View(Gu8Buffer);//在电脑端观察存放二维数组某行数据的一维数组的内容
View(Gu8Buffer);//在电脑端观察存放二维数组某行数据的一维数组的内容
View(Gu8Buffer);//在电脑端观察存放二维数组某行数据的一维数组的内容
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:0
十六进制:0
二进制:0
第2个数
十进制:1
十六进制:1
二进制:1
第3个数
十进制:2
十六进制:2
二进制:10
分析:
Gu8Buffer是0,提取了二维数组的第0行第0个数据。
Gu8Buffer是1,提取了二维数组的第0行第1个数据。
Gu8Buffer是2,提取了二维数组的第0行第2个数据。
【77.3 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
tt98
发表于 2017-7-9 21:00:37
继续跟进学习{:lol:}
hzyzs
发表于 2017-7-11 09:13:23
还望多多跟新
AVRTDK
发表于 2017-7-11 09:18:56
厉害,顶~
吴坚鸿
发表于 2017-7-16 13:02:42
第七十八节: typedef和#define和enum。
【78.1 typedef和#define和enum。】
typedef称为“类型定义”,#define称为“宏定义”,enum称为“枚举”。三者都有“一键替换”的能力,但是应用的侧重点各有不同。请看下面的例子,要写一个函数,把学生的分数分为3个等级,第1等级是“优”(范围:“优”>=90分),第2等级是“中”(范围:70分<=“中”<90分),第3等级是“差”(范围:“差”<70分),实现此算法的函数需要一个输入口和一个输出口,用来输入分数和输出判断结果,判断的结果用三个数字常量0,1,2来表示,0代表“优”,1代表“中”,2代表“差”。代码如下:
unsigned char GetGrade(unsigned char u8Score)
{
if(u8Score<70)
{
return 2;//2代表“差”
}
else if(u8Score>=70&&u8Score<90)
{
return 1;//1代表“中”
}
else
{
return 0;//0代表“优”
}
}
上述代码没有添加任何“typedef,#define,enum”,是“素颜照”级别的原始代码。现在对上述代码做一些美容,加入“typedef,#define,enum”的元素,代码如下:
#define BAD_MEDIUM 70//宏定义。用BAD_MEDIUM来表示“差”和“中”分数的分界线
#define MEDIUM_GOOD90//宏定义。用MEDIUM_GOOD来表示“中”和“优”分数的分界线
typedef unsignedchar u8;//用typedef为类型“unsigned char”增加一个名为“u8”的代言人
enum {GOOD = 0,MEDIUM,BAD}; //用enum把“0,1,2”三个常量转换为“GOOD,MEDIUM,BAD”
u8 GetGrade(u8 u8Score)
{
if(u8Score<BAD_MEDIUM) //等级分数分界线的判断
{
return BAD; //BAD就是常量2,代表“差”。
}
else if(u8Score>=BAD_MEDIUM&&u8Score<MEDIUM_GOOD)//等级分数分界线的判断
{
return MEDIUM;//MEDIUM就是常量1,代表“中”
}
else
{
return GOOD; //GOOD就是常量0,代表“优”
}
}
代码赏析:
赏析片段一:
#define BAD_MEDIUM 70//宏定义。用BAD_MEDIUM来表示“差”和“中”分数的分界线
#define MEDIUM_GOOD90//宏定义。用MEDIUM_GOOD来表示“良”和“优”分数的分界线
这里,用宏定义#define来关联分界线判断的分数,给后续代码的升级维护带来了便捷,因为用户有可能会要求把“差”“中”“优”三者的分数线进行调整,这时直接更改70和90这个数值就可以实现分数线的调整。可见,宏定义#define经常用在涉及“分界线”判断的场合。
赏析片段二:
typedef unsignedchar u8;//用typedef为类型“unsigned char”增加一个名为“u8”的代言人
用类型定义typedef为类型“unsigned char”增加一个名为“u8”的代言人,u代表unsigned的u,8代表此类型占用8位,比如unsignedchar就是占用8位的unsigned类型,所以用u8。如果是16位的unsigned类型就用u16,32位则用u32,这都是单片机界的常用命名习惯。上述代码用了类型定义,今后代码中凡是想定义一个unsigned char变量,都可以直接用u8来替代。这样有两个好处:第一个好处,u8的字符个数明显比unsigned char少,省了敲代码的力气。第二个好处,方便代码在各种不同硬件平台上的移植,因为不同的单片机不同的编译器对unsigned char,unsigned int,unsigned long翻译所得的结果是不一样的,比如,51单片机的unsigned int是占用16位的,而很多32位单片机的unsigned int是占用32位的,它们的16位则用unsigned short int类型,而不是unsigned int。
当我们用51单片机写代码的时候,可以如下类型定义:
typedef unsignedchar u8;
typedef unsignedint u16;
typedef unsignedlong u32;
当我们用32位的单片机写代码的时候,可以如下类型定义:
typedef unsigned char u8;
typedef unsigned short int u16;
typedef unsigned int u32;
这样,当我们想把51单片机的代码移到32位的单片机上时,只需要修改类型定义typedef这部分的代码,就可以快速做到代码在不同编译器平台上的类型兼容。
赏析片段三:
enum {GOOD = 0,MEDIUM,BAD}; //用enum把“0,1,2”三个常量转换为“GOOD,MEDIUM,BAD”
用枚举enum把“0,1,2”三个常量转换为“GOOD,MEDIUM,BAD”英文单词,最大的好处就是方便代码的阅读和修改。再多补充一点枚举的基础知识,上述代码中,第一个英文单词GOOD,经过“GOOD = 0”这条初始化的语句后,等效于常量0,后面的MEDIUM和BAD则C编译器自动对它们进行“累加1”排序,所以MEDIUM和BAD分别为常量1,2,这是C语言的语法规则。枚举enum的应用侧重在某些涉及到“状态”的数据类型,但是也不绝对。
【78.2 enum和typedef的相结合。】
enum一旦搭载上typedef后,可以把各自的特性发挥得淋漓尽致,产生另外一种常见的用途,那就是“人造”数据类型的用途,这里的“人造”解读为“人为制造”之意。比如上述78.1的函数u8 GetGrade(u8 u8Score),输出接口接收的是u8类型,但是内部return返回的是枚举类型的“GOOD,MEDIUM,BAD”其中之一,而u8虽然也能接收和兼容常量“GOOD,MEDIUM,BAD”,但是总是感觉有点“类型不匹配”的“不适感”,如果想消除这点“不适感”,可以用enum和typedef相结合的办法,修改后代码如下:
#define BAD_MEDIUM 70//宏定义。用BAD_MEDIUM来表示“差”和“中”分数的分界线
#define MEDIUM_GOOD90//宏定义。用MEDIUM_GOOD来表示“良”和“优”分数的分界线
typedef unsignedchar u8;//用typedef为类型“unsigned char”增加一个名为“u8”的代言人
typedef enum {
GOOD = 0,
MEDIUM,
BAD
} Grade;//通过typedef 和enum的相结合,“人造”出一个新的数据类型 Grade。
Grade GetGrade(u8 u8Score)//这里返回的类型是Grade,而“GOOD,MEDIUM,BAD”就是属于Grade
{
if(u8Score<BAD_MEDIUM) //等级分数分界线的判断
{
return BAD; //BAD就是常量2,代表“差”。
}
else if(u8Score>=BAD_MEDIUM&&u8Score<MEDIUM_GOOD)//等级分数分界线的判断
{
return MEDIUM;//MEDIUM就是常量1,代表“中”
}
else
{
return GOOD; //GOOD就是常量0,代表“优”
}
}
【78.3 例程练习和分析。】
为了熟悉typedef,#define,enum的用法,现在要写一个函数,把学生的分数分为3个等级,第1等级是“优”(范围:“优”>=90分),第2等级是“中”(范围:70分<=“中”<90分),第3等级是“差”(范围:“差”<70分),实现此算法的函数需要一个输入口和一个输出口,用来输入分数和输出判断结果,判断的结果用三个数字常量0,1,2来表示,0代表“优”,1代表“中”,2代表“差”。
/*---C语言学习区域的开始。-----------------------------------------------*/
#define BAD_MEDIUM 70//宏定义。用BAD_MEDIUM来表示“差”和“中”分数的分界线
#define MEDIUM_GOOD90//宏定义。用MEDIUM_GOOD来表示“良”和“优”分数的分界线
typedef unsignedchar u8;//用typedef为类型“unsigned char”增加一个名为“u8”的代言人
typedef enum {
GOOD = 0,
MEDIUM,
BAD
} Grade;//通过typedef 和enum的相结合,“人造”出一个新的数据类型 Grade。
Grade GetGrade(u8 u8Score);//函数声明
Grade a;//“人造”出Grade类型的变量a,用来接收函数的判断结果。
Grade b;//“人造”出Grade类型的变量b,用来接收函数的判断结果。
Grade c;//“人造”出Grade类型的变量c,用来接收函数的判断结果。
Grade GetGrade(u8 u8Score)//这里返回的类型是Grade,而“GOOD,MEDIUM,BAD”就是属于Grade
{
if(u8Score<BAD_MEDIUM) //等级分数分界线的判断
{
return BAD; //BAD就是常量2,代表“差”。
}
else if(u8Score>=BAD_MEDIUM&&u8Score<MEDIUM_GOOD)//等级分数分界线的判断
{
return MEDIUM;//MEDIUM就是常量1,代表“中”
}
else
{
return GOOD; //GOOD就是常量0,代表“优”
}
}
void main() //主函数
{
a=GetGrade(98);//输入98分,a来接收判断的结果
b=GetGrade(88);//输入88分,b来接收判断的结果
c=GetGrade(68);//输入68分,c来接收判断的结果
View(a);//在电脑端观察98分的判断结果a
View(b);//在电脑端观察88分的判断结果b
View(c);//在电脑端观察68分的判断结果c
while(1)
{
}
}
/*---C语言学习区域的结束。-----------------------------------------------*/
在电脑串口助手软件上观察到的程序执行现象如下:
开始...
第1个数
十进制:0
十六进制:0
二进制:0
第2个数
十进制:1
十六进制:1
二进制:1
第3个数
十进制:2
十六进制:2
二进制:10
分析:
98分的判断结果a为0,0代表“优”。
88分的判断结果b为1,1代表“中”。
68分的判断结果c为2,2代表“差”。
【78.4 如何在单片机上练习本章节C语言程序?】
直接复制前面章节中第十一节的模板程序,练习代码时只需要更改“C语言学习区域”的代码就可以了,其它部分的代码不要动。编译后,把程序下载进带串口的51学习板,通过电脑端的串口助手软件就可以观察到不同的变量数值,详细方法请看第十一节内容。
tt98
发表于 2017-7-16 16:04:52
再有12节到100了{:titter:}顶!!!
sctwp
发表于 2017-7-20 22:40:09
将所有的章节合并 并且增加目录
吴坚鸿
发表于 2017-7-23 10:43:26
第七十九节: 各种变量常量的命名规范。
【79.1 命名规范的必要。】
一个大型的项目程序,涉及到的变量常量非常多,各种变量常量眼花缭乱,名字不规范就无法轻松掌控全局。若能一开始就遵守特定的命名规范,则普天之下,率土之滨,都被你牢牢地掌控在手里,天下再也没有难维护的代码。本节教给大家的是我多年实践所沿用的命名规范和习惯,它不是唯一绝对的,只是给大家参考,大家今后也可以在自己的实践中慢慢总结出一套适合自己的命名规范和习惯。
【79.2 普通变量常量的命名规范和习惯。】
在C51编译器的平台下,unsigned char ,unsigned int ,unsigned long三类常用的变量代表了“无符号的8位,16位,32位”,这类型的变量前缀分别加“u8,u16,u32”来表示。但是这种类型的变量还分全局变量和局部变量,为了有所区分,就在全局变量前加“G”来表示,不带“G”的就默认是局部变量。比如:
unsigned char Gu8Number; //Gu8就代表全局的8位变量
unsigned int Gu16Number; //Gu16就代表全局的16位变量
unsigned long Gu32Number; //Gu32就代表全局的32位变量
void HanShu(unsigned char u8Data) //u8就代表局部的8位变量
{
unsigned char u8Number; //u8就代表局部的8位变量
unsigned int u16Number; //u16就代表局部的16位变量
unsigned long u32Number; //u32就代表局部的32位变量
}
全局变量和局部变量继续往下细分,还分“静态”和“非静态”,为了有所区分,就在前面增加“ES”或“S”来表示,“ES”代表全局的静态变量,“S”代表局部的静态变量。比如:
static unsigned char ESu8Number; //ESu8就代表全局的8位静态变量
static unsigned intESu16Number; //ESu16就代表全局的16位静态变量
static unsigned long ESu32Number; //ESu32就代表全局的32位静态变量
void HanShu(unsigned char u8Data) //u8就代表局部的8位变量
{
static unsigned char Su8Number; //Su8就代表局部的8位静态变量
static unsigned int Su16Number; //Su16就代表局部的16位静态变量
static unsigned long Su32Number; //Su32就代表局部的32位静态变量
}
刚才讲的只是针对“变量”,如果是“常量”,则前缀加“C”来表示,不管是全局的常量还是局部的常量,都统一用“C”来表示,不再刻意区分“全局常量”和“静态常量”,比如:
const unsigned char Cu8Number=1; //Cu8就代表8位常量,不刻意区分“全局”和“局部”
const unsigned int Cu16Number=1; //Cu16就代表16位常量,不刻意区分“全局”和“局部”
const unsigned long Cu32Number=1; //Cu32就代表32位常量,不刻意区分“全局”和“局部”
void HanShu(unsigned char u8Data) //u8就代表局部的8位变量
{
const unsigned char Cu8Number=1;//Cu8就代表8位常量,不刻意区分“全局”和“局部”
const unsigned int Cu16Number=1;//Cu16就代表16位常量,不刻意区分“全局”和“局部”
const unsigned long Cu32Number=1; //Cu32就代表32位常量,不刻意区分“全局”和“局部”
}
【79.3 循环体变量的命名规范和习惯。】
循环体变量是一个很特定场合用的变量,为了突出它的特殊,这类变量在命名上用单个字母,可以不遵守命名规范,这里的“不遵守命名规范”就是它的“命名规范”,颇有道家“无为就是有为”的韵味,它是命名界的另类。比如:
unsigned char i; //超越了规则约束的循环体变量,用单个字母来表示。
unsigned long k; //超越了规则约束的循环体变量,用单个字母来表示。
void HanShu(unsigned char u8Data) //u8就代表局部的8位变量
{
unsigned int c; //超越了规则约束的循环体变量,用单个字母来表示。
for(c=0;c<5;c++)//用在循环体的变量
{
u8Data=u8Data+1;//u8就代表局部的8位变量
}
for(i=0;i<5;i++)//用在循环体的变量
{
u8Data=u8Data+1;//u8就代表局部的8位变量
}
for(k=0;k<5;k++)//用在循环体的变量
{
u8Data=u8Data+1;//u8就代表局部的8位变量
}
}
【79.4 数组的命名规范和习惯。】
数组有四种应用场合,一种是普通数组,一种是字符串,一种是表格,一种是信息。在命名上分别加入后缀“Buffer,String,Table,Message”来区分,但是它们都是数组。比如:
unsigned intGu16NumberBuffer;//后缀是Buffer。16位的全局变量数组。用在普通数组。
unsigned char Gu8NumberString; //后缀是String。8位的全局变量数组。用在字符串。
//根据原理图得出的共阴数码管字模表
code unsigned char Cu8DigTable[]=//后缀是Table。这里的code是代表C51的常量(类似const)。
{
0x3f,//0 序号0
0x06,//1 序号1
0x5b,//2 序号2
0x4f,//3 序号3
0x66,//4 序号4
0x6d,//5 序号5
0x7d,//6 序号6
0x07,//7 序号7
0x7f,//8 序号8
0x6f,//9 序号9
0x00,//不显示序号10
};
void HanShu(unsigned char u8Data) //u8就代表局部的8位变量
{
unsigned char u8NumberMessage;//后缀是Message。8位的局部变量数组。用在信息。
}
【79.5 指针的命名规范和习惯。】
指针的前缀加“p”来区分。再往下细分,指针有全局和局部,有“静态”和“非静态”,有“8位宽度”和“16位宽度”和“32位宽度”,有变量指针和常量指针。比如:
unsigned char *pGu8NumberString;//pGu8代表全局的8位变量指针
void HanShu(const unsigned char *pCu8Data) //pCu8代表局部的8位常量指针
{
unsigned char *pu8NumberBuffer; //pu8代表局部的8位变量指针
static unsigned int *pSu16NumberBuffer; //pSu16代表局部的16位静态变量指针
static unsigned long *pSu32NumberBuffer; //pSu32代表局部的32位静态变量指针
}
【79.6 结构体的命名规范和习惯。】
结构体的前缀加“t”来区分。再往下细分,指针有全局和局部,有“静态”和“非静态”,有结构体变量和结构体指针。比如:
struct StructSignData//带符号的数
{
unsigned charu8Sign;//符号0为正数 1为负数
unsigned long u32Data;//数值
};
struct StructSignData GtNumber; //Gt代表全局的结构体变量。
void HanShu(struct StructSignData *ptData) //pt代表局部的结构体指针
{
struct StructSignData tNumber; //t代表局部的结构体变量。
static struct StructSignData StNumber; //St代表局部的静态结构体变量。
}
【79.7 宏常量的命名规范和习惯。】
所谓“宏常量”往往是指用#define语句定义的常量。宏常量的所有字符都用大写字母。比如:
#define DELAY_TIME 30//宏常量所有字符都用大写字母。DELAY_TIME代表延时的时间。
void HanShu(void)
{
delay(DELAY_TIME); //相当于delay(30),这里的delay代表某个延时函数(这里没有具体写出来)
}
【79.8 首字符用大写字母以及下划线“_”的灵活运用。】
两个以上的英文单词连在一起命名时,每个单词的首字符用大写,其余用小写,这样可以把每个单词“断句”开来,方便阅读。如果遇到两个英文单词连在一起不好“断句”的情况(比如某个英文单词全部是大写字母的专用名词),只要在两个英文单词之间插入下划线“_”就可以清晰的“断句”了。比如:
unsigned long Gu32GetFileLength; //GetFileLength寓意“获取某个文件的长度”。
unsigned char Gu8ESD_Flag; //ESD是专业用名词,代表“静电释放”的意思。用下划线“_”断句。
吴坚鸿
发表于 2017-7-23 17:49:43
特别篇:本教程的PCB文件和原理图。
吴坚鸿
发表于 2017-7-27 15:03:12
特别篇:本教程的PCB文件和原理图(2017年7月27日在PCB文件里增加了接地过孔)。
fengyunyu
发表于 2017-7-27 15:35:51
楼主厉害!
吴坚鸿
发表于 2017-7-29 14:18:10
特别篇:本教程的PCB文件和原理图V03公共版(2017年7月29日在PCB文件里改动了圆柱晶振旁边两个电容的位置)。
wjb555
发表于 2017-7-29 16:15:28
赞一个!nnn
larry.xu
发表于 2017-7-29 17:13:20
感谢楼主的连载分享!学习了!
what007
发表于 2017-7-29 19:16:57
教程更新一次做次打包啊,可以下载看
吴坚鸿
发表于 2017-7-30 11:42:25
第八十节: 单片机IO口驱动LED。
【80.1 不再依赖第11节模板程序。】
前面大量的章节主要是讲C语言本身的基础知识,因此每次的练习例程都要依赖第11节的模板程序。从本节开始,正式进入到单片机主题,如果没有特殊说明,以后的练习程序就不再需要依赖第11节模板程序,可以脱离模板单飞了。
【80.2 寄存器。】
寄存器是跨越在软件与硬件之间的桥梁,单片机的C语言想控制单片机引脚输出0V或者5V的物理电压,本质就是通过往寄存器里填数字,往哪个寄存器填数字,填什么样的数字,对应的引脚就输出什么样的电压。至于“为什么往寄存器填数字就会在引脚上输出对应的电压”这个问题,对于我们“应用级”工程师来说是一个黑匣子。我们写软件的最底层就是操作到“寄存器”这个层面,至于“寄存器与物理电压之间是如何关联如何实现”的这个问题,其实是“芯片级”半导体工程师所研究的事,因为单片机本身其实就是一个成品,我们从“芯片级”半导体工程师那里拿到这个成品,这个成品的说明书告诉了我们该成品的每个寄存器的作用,我们只能在这个基础上去做更上层的应用。该说明书其实就是大家通常所说的芯片的datasheet。
寄存器在单片机C语言层面,是一个全局变量,是一个具备特定名字的全局变量,是一个被系统征用的全局变量。寄存器的名字就像古代皇帝的名字,所有普通老百姓的变量名字都要“避尊者讳”,不能跟寄存器的名字重名,否则C编译器就编译不通过。
图80.2.1单片机的32个IO口引脚
本教程用的STC89C52单片机IO口寄存器有4个,分别是P0,P1,P2,P3这4个寄存器,每个寄存器都是一个8位的全局变量,每一位代表控制一个单片机的IO口引脚,因此,该单片机一共有32个(4乘以8)IO口引脚,每个引脚都是可以单独控制的(俗称位操作)。往该位填入0,对应的引脚就输出0V的物理电压。往该位填入1,对应的引脚就输出5V的物理电压。
【80.3 C语言操作IO口寄存器。】
C语言操作单片机IO口寄存器,以便在对应的引脚上输出对应的物理电压,有两种方式。一种是并口的方式,另外一种是位操作的方式。并口方式,一次操作8个位(8个引脚),往往用在并口数据总线上。位操作方式,一次操作1个位(1个引脚),该方式因为单独控制到某个引脚,所以应用更加灵活广泛。
并口方式。并口方式的时候,可以直接对P0,P1,P2,P3这4个寄存器赋值,就像对一个unsigned char的全局变量赋值一样。比如:
#include "REG52.H"
void main()
{
P0=0xF0; //直接对P0赋值0xF0,意味着P0口的8个引脚,高4位全部输出5V,低4位全部输出0V。
while(1)
{
}
}
“P0=0xF0”这行代码,把十六进制的0xF0分解成二进制11110000来理解,P0.7,P0.6,P0.5,P0.4这4个引脚分别输出5V物理电压,而P0.3,P0.2,P0.1,P0.0这4个引脚分别输出0V物理电压。
位操作方式。并口方式因为一次操作就绑定了8个引脚,是非常不方便的,因此,位操作就显得特别灵活实用,你可以直接操作P0,P1,P2,P3这4组引脚中(共32个)的某1个引脚,而不影响其它引脚的状态。比如,P1.4引脚是属于P1组的8个引脚中的某1个引脚,如果想直接位操作P1.4引脚,要用到特定的关键词sbit和符号“^”这个组合,sbit和符号“^”的组合类似宏定义,使用方式如下。
#include "REG52.H"
sbit P1_4=P1^4;//利用sbit和符号“^”的组合,把变量名字P1_4与P1.4引脚关联起来
void main()
{
P1_4=0;//P1.4引脚输出0V物理电压,而不影响其它P1口引脚的状态。
while(1)
{
}
}
【80.4 点亮LED。】
LED灯要有电流通过,才会发光。要有电流通过,必须要有电压的“正压差”,“压差”可以用水压来比喻。
比如在2楼的水,对于1楼来说,它就有“正压差”(2减去1等于“正1”),因此只要构成回路(有水管),2楼的水是可以往1楼流动的。
比如在2楼的水,对于3楼来说,它虽然有压差,但是有的只是“负压差”(2减去3等于“负1”),因此哪怕构成回路(有水管),2楼的水也是不可以往3楼流动的。
比如在2楼的水,对于同楼层的2楼来说,它的压差是0压差(2减去2等于“0压差”),因此哪怕构成回路(有水管),2楼的水也是不可以在2楼之间流动的。
上面三个比喻很关键,精髓在于是否有“正压差”。要点亮一个LED灯,并不是说你单片机引脚直接输出一个5V的物理电压就能点亮的,还要看它构成的整个LED灯回路,也就是实际的电路图是什么样的。在本教程的原理图中,我们点亮LED灯是采样“灌入式”的电路,也就是单片机输出5V电压的时候LED灯是熄灭的,而输出0V物理电压时LED灯反而是被点亮的。如下两个图:
图80.4.1灌入式驱动8个LED
图80.4.2灌入式驱动4个LED
现在根据这原理图,编写一个并口和位操作的练习例子,直接把程序烧录进开发板,就可以看到对应的LED灯的状态。
#include "REG52.H"
sbit P1_4=P1^4;//利用sbit和符号“^”的组合,把变量名字P1_4与P1.4引脚关联起来
void main()
{
P0=0xF0; //直接对P0赋值0xF0,意味着P0口的8个引脚,高4位全部输出5V,低4位全部输出0V。
P1_4=0;//P1.4引脚输出0V物理电压,而不影响其它P1口引脚的状态。
while(1)
{
}
}
现象分析:
“P0=0xF0”直接对P0赋值0xF0,意味着P0口的8个引脚,高4位全部输出5V(LED灯反而灭),低4位全部输出0V(LED灯反而被点亮)。
“P1_4=0”P1.4引脚输出0V物理电压(LED灯反而被点亮)。
sctwp
发表于 2017-7-30 16:23:01
将所有的章节组合在一起方便阅读
tt98
发表于 2017-7-30 17:47:56
感谢制作的PDF{:lol:}
吴坚鸿
发表于 2017-8-6 11:43:08
第八十一节: 时间和速度的起源(指令周期和晶振频率)。
【81.1 节拍。】
单片机的C语言经过C编译器后,翻译成很多条机器指令,单片机逐条执行这些指令,每执行一条指令都是按照固定的节奏进行的,两条指令之间是存在几乎固定的时间间隔(实际上不是所有指令的间隔时间都绝对一致,这里方便理解暂时看作是一致),这就是节拍,每个节拍之间的时间间隔其实就是指令周期,因此,指令周期越短,节拍就越短,单片机的运算速度就越快。指令周期是由什么决定的呢?指令周期是由“心跳速度”和“心跳个数”决定的。指令周期都是由固定的N个“心跳个数”组成的,指令周期到底由多少个“心跳个数”组成?每种单片机每类指令各不一样。我们用的51系列单片机,最短的单周期指令是由12个“心跳个数”组成,依次类推,双周期指令由24个“心跳个数”组成,4周期指令由48个“心跳个数”组成。但是光有“心跳个数”还不够,还必须搭配知道“心跳速度”才能最终计算出指令周期。这里的“心跳速度”就是晶振的频率,“心跳个数”就是累计晶振的起振次数。比如,假设我们用的51单片机是12MHz(本教程实际用的是11.0592MHz),那么每个单周期的指令执行的时间是:12x(1/12000000)秒=1微秒。这个公式左边的“12”代表“12个晶振起振的次数”,这个公式右边的“(1/12000000)”代表晶振每起振1次所需要的单位时间。二者结合,刚好就是“心跳个数”乘以“单个心跳周期”等于指令周期,而指令周期就是节拍的时间。
图81.1.1单片机的晶振
【81.2 累计节拍次数产生延时时间。】
有了这个最原始的“节拍”概念,现在开始编写一个练习程序,让一个LED灯闪烁,闪烁的本质,就是让一个LED灯先亮一会(“一会”就是延时),然后紧接着让LED灯熄灭一会(“一会”就是延时),依次循环,在视觉上看到的连贯动作就是LED闪烁。这里的关键是如何产生这个“一会”的延时,本节教程所用的就是一个for循环来执行N条空指令,每执行一条空指令就需要消耗掉1个左右的指令周期的时间(大概1微秒左右),空指令执行的循环次数越多,产生的延时时间就越长。例子如下:
图81.2.1灌入式驱动8个LED
#include "REG52.H"
sbit P0_0=P0^0;//利用sbit和符号“^”的组合,把变量名字P0_0与P0.0引脚关联起来
unsigned long i;//for循环用的累计变量
//unsigned int i; //如果把for循环的变量i改成unsigned int类型,闪烁的频率会加快。
void main()
{
while(1)
{
//第(1)步
P0_0=0;//LED灯亮。
//第(2)步
for(i=0;i<5000;i++) //累计的循环次数越大,这里的延时就越长,“亮”持续的时间就越长。
{
;//分号代表一条空指令
}
//第(3)步
P0_0=1;//LED灯灭。
//第(4)步
for(i=0;i<5000;i++) //累计的循环次数越大,这里的延时就越长,“灭”持续的时间就越长。
{
;//分号代表一条空指令
}
//第(5)步:这里已经触碰到主循环while(1)的“底线”,所以接着跳转到第(1)步继续循环
}
}
现象分析:
理论上,每执行1条指令大概1微秒左右,但是实际上,我们看到的实验现象,发现累计循环才5000次,按理论计算,应该产生0.005秒左右的延时才合理,但是实际上居然能产生类似0.5秒的闪烁效果,中间相差100倍!为什么?C语言跟机器指令之间是存在翻译的“中间商”环节,一条C指令并不代表一条机器指令,往往一条C指令翻译后产生N条机器指令,比如上面的代码,用到for循环变量i,用的是unsigned long变量,意味4个字节,即使一条C语言赋值指令估计可能也要消耗4条单周期指令,在加上for循环的判断指令,和累加指令,以及跳转指令,所以我们看到的for(i=0;i<5000;i++)并不代表是真正仅仅执行了5000个指令周期,而是有可能执行了500000条指令周期!假如我们把上述代码中的i改成unsigned int变量(2字节),是会看到闪烁的速度明显加快的,其中原因就是C编译器与机器指令之间存在翻译后的“1对N”的关系。
dtdzlujian
发表于 2017-8-7 07:00:40
感谢鸿哥,在这学到了好多知识。支持LZ,辛苦了。
吴坚鸿
发表于 2017-8-13 12:31:11
第八十二节: Delay阻塞延时控制LED闪烁。
【82.1 “阻塞”与“非阻塞”。】
做项目写程序,大框架大思路上就是在“阻塞”与“非阻塞”这两种模式下不断切换。“阻塞”可以理解成“单任务处理”模式,“非阻塞”可以理解成“多任务并行处理”模式。“阻塞”的优点是它全神贯注不分心地专注于当下这一件事,它等待某个事件的响应速度是最快的,同时省去了“来回切换、反复扫描”的额外开销,而且在编程思路上不用太费脑力只需“记流水账式”的编程即可,但是它的缺点是当下只能干一件事,其它事情无法兼顾,做不到多任务并行处理。而“非阻塞”恰恰相反,它的有优点就是“阻塞”的缺点,它的缺点就是“阻塞”的优点,对于“非阻塞”本节暂时不多讲。在实际项目中,有时候“大 阻塞”中分支了N个“小 非阻塞”,也有时候“大 非阻塞”中分支了N个“小 阻塞”。能在“阻塞”与“非阻塞”之间运用自如者,谓之神。
“阻塞等待”是指单片机在某个死循环里(比如“while(1)”这类)一直不断循环地在等待某个标志变量的状态,如果这个标志变量满足条件才会跳出这个死循环然后才能干其它的事情,否则一直在死循环里死等,给人一种全神贯注心无旁骛的感觉,
“阻塞延时”是指单片机在产生“延时时间”的时候做不了别的事,延时多久它就要被“阻塞”多久,只有延时过后它才能解脱去干别的事。比如,在编程上,常用for循环产生N个空指令来达到产生“延时时间”的目的,这种编程方式就是最常见的“阻塞延时”。
【82.2 Delay阻塞延时的一个例子。】
现在利用“Delay阻塞延时”编写一个练习程序,让一个LED灯闪烁。例子如下:
图82.2.1灌入式驱动8个LED
#include "REG52.H"
void Delay(unsigned long u32DelayTime); //函数的声明
sbit P0_0=P0^0;//利用sbit和符号“^”的组合,把变量名字P0_0与P0.0引脚关联起来
void Delay(unsigned long u32DelayTime) //产生“阻塞延时”的延时函数
{
static unsigned long i; //函数在频繁调用时,加static可以省去一条额外的初始化语句的开销。
for(i=0;i<u32DelayTime;i++);
}
void main()
{
while(1)
{
//第(1)步
P0_0=0;//LED灯亮。
//第(2)步
Delay(5000);//这里就是阻塞延时,时间就越长,“亮”持续的时间就越长。
//第(3)步
P0_0=1;//LED灯灭。
//第(4)步
Delay(5000);//这里就是阻塞延时,时间就越长,“灭”持续的时间就越长。
//第(5)步:这里已经触碰到主循环while(1)的“底线”,所以接着跳转到第(1)步继续循环
}
}
【82.3 累加型和累减型的两种Delay函数,哪家强?】
上述82.2例子中,用到一个Delay函数,该函数内部的for循环用的是“累加型”的,比如:
void Delay(unsigned long u32DelayTime)
{
static unsigned long i; //“累加型”函数内部多开销了一个变量i。
for(i=0;i<u32DelayTime;i++);//因为这里的“i++”是加法运算,所以称为“累加型”。
}
现在在跟大家分享一种“累减型”的Delay函数,例子如下:
void Delay(unsigned long u32DelayTime)
{
//“累减型”函数内部节省了一个变量i。
for(;u32DelayTime>0;u32DelayTime--);//“u32DelayTime--”意味着“累减型”。
}
仔细对比“累加型”和“累减型”,会发现在实现同样“阻塞延时”的功能下,因为“累减型”巧妙的借用了函数入口的局部变量u32DelayTime来充当for循环的变量,而省去了一个i变量。因此,“累减型”比“累加型”强一点。
【82.4 Delay函数让初学者容易犯的错误。】
初学者刚接触Delay函数,常常容易犯的错误就是忽略了for循环变量的类型,for循环变量的类型决定了你能输入的数值范围,比如上面例子中用到的是unsigned long变量,因此可以最大输入Delay(4294967295)。如果是unsigned int变量,最大可以输入Delay(65535)。如果是unsignedchar变量,最大可以输入Delay(255)。
【82.5 Delay内部的for循环嵌套可产生无穷长的时间。】
刚才讲到,如果用最大的变量类型unsigned long ,最大的输入是Delay(4294967295),那么问题来,难道Delay函数的阻塞延时的时间有最大极限?其实不存在最大极限,理论上,你要多大的延时都可以,只需要在Delay函数内部用上for循环的嵌套,就可以产生“乘法级”的无穷长的时间,例子如下:
void Delay(unsigned long u32DelayTime)
{
static unsigned long i;
static unsigned long k;
for(i=0;i<u32DelayTime;i++)
{
for(k=0;k<5000;k++); //内部嵌套的for循环,意味着乘法的关系u32DelayTime的5000倍!
}
}
【82.6 “阻塞延时”与“非阻塞延时”的各自应用范围。】
“阻塞延时”一般应用在两个地方,一个是上电初始化进入主循环之前的延时,另一个是进入主循环之后,跟外部驱动芯片通信时候产生的时钟节拍小延时,而这个类延时一般是低于1ms的小延时。
“非阻塞延时”在项目中是被大量应用的,进入主循环之后,只要大于或等于1ms的延时,大多数都采样“非阻塞延时”,因为进入“任务框架级”的层面,只有“非阻塞延时”才能保证项目可以继续“多任务并行处理”。“非阻塞延时”的方式后续章节会讲到。
综上所述,1ms是“阻塞延时”与“非阻塞延时”的一个分解线,1ms这个时间不是绝对的,只是一个经验值。
吴坚鸿
发表于 2017-8-20 09:51:41
第八十三节: 累计主循环的“非阻塞”延时控制LED闪烁。
【83.1 累计主循环的“非阻塞”。】
上一节提到,当Delay的“阻塞”时间超过1ms并且被频繁调用的时候,由于Delay做“独占式无用功”而消耗的延时太长,会影响其它任务的并行处理,整个系统给人的感觉非常卡顿不流畅。为了解决此问题,本节引入累计主循环的“非阻塞”,同时,希望通过此例子,让大家第一次感受到switch语句在多任务并行处理时候的优越性。switch的精髓在于“根据某个判断条件实现步骤之间的灵活跳转”,这个思路是以后做所有大项目的框架性思路。
为什么“累计主循环”可以兼顾到其它任务的并行处理?因为单片机进入main函数以后,在一个主循环里要扫描N个任务,从头到尾,把N个任务扫描一遍,每扫描一遍算“一次主循环”,每一次“主循环”都是要消耗一点时间,累计的“主循环”次数越多,所要消耗的时间就越长,但是跟Delay唯一的差别是,Delay做延时的时候没有办法扫描其它任务,而“累计主循环”内部本身就是在不断扫描其它任务,产生时间越长扫描其它任务的次数就越多,两者是完全相互促进而没有矛盾的。具体内容,请看下面的例子。
【83.2 累计主循环“非阻塞”的一个例子。】
现在利用“累计主循环非阻塞”编写一个练习程序,让一个LED灯闪烁。例子如下:
图83.2.1灌入式驱动8个LED
#include "REG52.H"
#defineCYCLE_SUM 5000 //累计主循环次数的设定阀值,该值决定了LED闪烁频率
sbit P0_0=P0^0;//利用sbit和符号“^”的组合,把变量名字P0_0与P0.0引脚关联起来
unsigned char Gu8CycleStep=0;//switch的跳转步骤
unsigned long Gu32CycleCnt=0;//累计主循环的计数器
void main()
{
while(1)
{
switch(Gu8CycleStep)
{
case 0:
Gu32CycleCnt++; //这里就是累计main函数内部的主循环while(1)的次数
if(Gu32CycleCnt>=CYCLE_SUM) //累计的次数达到设定值CYCLE_SUM就跳到下一步骤
{
Gu32CycleCnt=0; //及时清零计数器,为下一步骤的新一轮计数准备
P0_0=0;//LED灯亮。
Gu8CycleStep=1;//跳到下一步骤
}
break;
case 1:
Gu32CycleCnt++; //这里就是累计main函数内部的主循环while(1)的次数
if(Gu32CycleCnt>=CYCLE_SUM) //累计的次数达到设定值CYCLE_SUM就返回上一步骤
{
Gu32CycleCnt=0; //及时清零计数器,为返回上一步骤的新一轮计数准备
P0_0=1;//LED灯灭。
Gu8CycleStep=0;//返回到上一个步骤
}
break;
}
}
}
【83.3 累计主循环的不足。】
上述83.2例子中,“累计主循环次数”实现时间延时是一个不错的选择。这种方法能胜任多任务处理的程序框架,但是本身也有一个小小的不足,比如“阀值CYCLE_SUM到底应该取多少才能产生多长的时间”是没有标准的,只能依靠不断上机实验来拿到一个你所需要的数值,这种“不规范”,当程序要移植到其它单片机平台上的时候就特别麻烦,需要重新修正阀值CYCLE_SUM。除此之外,哪怕在同样的一个单片机里,随着主函数里任务量的增加,累计一次主循环所消耗的时间长度也会发生变化,意味着靠“累计主循环次数”所获得的时间也会发生变化而导致不准确,此时,为了保证延时时间的准确性,必须要做的就是再一次修正“设定累计主循环次数”的阀值CYCLE_SUM,这样显然给我们带来了一丝不便,怎么办?假设单片机没有“定时中断”这个资源,那么这种“累计主循环次数”在多任务处理中确实是不二之选,但是,因为现在几乎所有的单片机内部都有“定时中断”这个资源,所以,大家不用为这个“不足”而烦恼,我们只要用上本节的switch思路,再外加一个“定时中断”,就可以轻松解决此问题,下一节就跟大家讲“定时中断”的内容。
sankyo_feng
发表于 2017-8-25 14:17:44
受教!定个位!我要常来
sankyo_feng
发表于 2017-8-25 20:18:19
受教,学习中!七八年后不知单片机会发展成什么样子!
吴坚鸿
发表于 2017-8-27 21:57:44
第八十四节: 中断与中断函数。
【84.1 中断。】
单片机的“中断”跟日常生活的“中断”差不多,你正在做“常事”的时候,突然遇到优先级更高的“急事”,这时你必须先暂停手上的“常事”,马上去处理突如其来的“急事”,处理完“急事”再返回来继续做“常事”。要理解单片机的“中断”,有六个关键点,第一点是“配置中断”,第二点是“做常事”,第三点是“中断请求”,第四点是“保护中断现场”,第五点是“处理急事”,第六点是“返回中断现场”。举个例子如下:
第一点:你老婆随时都会打电话给你,所以你把你的手机24小时都打开处于待机的状态。(配置中断)
第二点:你正在读一本书《道德经》(做常事)。
第三点:当你读到第18页的时候,你老婆突然给你打电话,让你去幼儿园帮接一下小孩(中断请求)。
第四点:你在第18页里夹了一张书签做标记(保护中断现场)。
第五点:你放下手上的书去幼儿园接小孩(处理急事)。
第六点:接了小孩,你回来继续打开《道德经》,找到书签标记的第18页(返回中断现场),继续阅读。
上述六点,在单片机的C语言里,“配置中断”放在主函数的初始化那里,“做常事”放在主函数的主循环里(main函数内部的while(1)循环),“中断请求”单片机内部硬件检测到符合发生中断的条件,“保护中断现场”是单片机内部硬件电路自动处理的(不需要我们软件干涉),“处理急事”是单片机自动跳转到另外开辟的一个特殊中断函数处理(自动跳转是单片机的硬件自动完成不需要我们软件干涉),执行完一次中断函数后单片机再自动跳转到主函数的主循环的现场点继续从现场点开始继续做常事(返回中断现场)。在这六点中,其中第四点的“保护中断现场”与第六点的“返回中断现场”是要特别强调的,单片机从main函数的主循环while(1)准备跳转到中断函数之前,它会自动记录当前的位置(做好路标),以便处理完中断函数后再返回main函数的主循环while(1)时,能找到之前的被中断跳转前的位置,这样就可以接上原来的步骤去处理原来的“常事”,在步骤上既不提前也不滞后恰到好处,中断就不会影响到常事的完整性。代码分布图的模板描述如下:
void main()
{
配置中断;
while(1)
{
处理常事;
}
}
void 中断函数() interrupt 序号 //中断函数后缀带“interrupt 序号”特别修饰
{
急事;
}
奇怪!上述代码,为什么“main函数”与“中断函数”在软件上看不到任何关联,既不存在“main函数”调用“中断函数”,也不存在“中断函数”调用“main函数”的情况,在观感上,“main函数”与“中断函数”仿佛是隔离的毫无“物理连接”的,为什么单片机还能在“main函数”与“中断函数”两者中切换自如?没错,确实,“main函数”与“中断函数”在书写上是隔离的毫无关联的,但是它们之间之所以能相互切换,是因为背后有一只无形的手在自动操控这一切,这只手就是单片机硬件自身,这是一种特殊机制,也可以理解成一种特殊的游戏规则,我们只要遵守就好了,除了普通函数,其它凡是中断函数的,都不用跟main函数发生软件上的关联调用,它们之间的切换都是硬件自动完成的,这就是main函数与中断函数的特殊跳转机制(或者称为游戏规则也可以)。
【84.2 常用的中断函数有哪三类?】
单片机的中断有很多,但常用在项目上的有三类:
第一类是定时中断。配置中断后,使其每隔一段时间就产生一次中断,比如“1ms一次的定时中断”几乎是所有的系统里的标配,因为它对程序框架起到一个时间节拍的作用。
第二类是通讯中断。比如串口每接收完一个字节就会产生一个中断通知我们去处理。
第三类是电平变化的中断。下降沿或者上升沿的中断,常常用在采集高速的脉冲信号。
【84.3 我们如何操控中断?】
刚才84.1提到“单片机硬件自动”这个概念,但是说它“硬件自动”并不意味着它不可控。单片机本身出厂的时候内部就携带了很多种类的中断,这些中断是否开启取决于你的“配置中断”代码,你要开启或者关闭某类中断,只需编写对应的“配置中断”代码就可以,而“配置中断”的代码本质就是填写某些寄存器数值。
吴坚鸿
发表于 2017-9-4 19:40:46
第八十五节: 定时中断的寄存器配置。
【85.1 寄存器配置的本质。】
单片机内部可供我们选择的资源非常丰富,有定时器,有串口,有外部中断,等等。这些丰富的资源,就像你进入一家超市,你只需选择你所需要的东西就可以了,所以配置寄存器的关键在于选择,所谓选择就是往寄存器里面做填空题,单片机系统内部再根据你的“选择清单”,去启动对应的资源。那么我们怎么知道某个型号的单片机内部有哪些资源呢?看该型号“单片机的说明书”呀,“单片机的说明书”就是我们通常所说的“芯片的datasheet”,或者说是“芯片的数据手册”,这些资料单片机厂家会提供的。
跟单片机打交道,其实跟人打交道没什么区别,你要让单片机按照你的“意愿”走,你首先要把你的“意愿”表达清楚,这个“意愿”就是信息,信息要具备准确性和唯一性,不能模凌两可。比如,现在要让单片机“每1ms产生一次中断”,你想想你可能需要给单片机提供哪些信息?
(1)51单片机有2个定时器,一个是0号定时器,一个是1号定时器,我们要面临“二选一”的选择,本例子中用的是“0号定时器”。
(2)0号定时器内部又有4种工作方式:方式0,方式1,方式2,方式3,本例子中用的是“方式1”。
(3)定时器到底多长时间中断一次,这就涉及到填充与中断时间有关的寄存器的数值,该数值是跟时间成比例关系,本例子中配置的是1ms中断,就要填充对应的数值。
(4)默认状态下,定时器是不会被开启的,如果要开启,这里就是涉及到定时器的“开关”,本例子要开启此开关。
(5)定时器时间到了就要产生中断,中断也有“总开关”和“定时器的局部开关”,这两个开关都必须同时打开,中断才会有效。
要配置定时器“每1ms产生一次中断”,大概就上述这些信息,根据这些信息提示,下面开始讲解一下寄存器的具体内容。
【85.2定时器/计数器的模式控制寄存器TMOD。】
寄存器TMOD是一个8位的特殊变量,里面每一位都代表了不同的功能选择。根据芯片的说明书,TMOD的8位从左到右依次对应从D7到D0(左高位,右低位),定义如下:
GATE C/T M1 M0 GATE C/T M1 M0
仔细观察,发现左4位与右4位是对称的,分别都是“GATE,C/T , M1 , M0”,左4位控制的是“定时器1”,右4位控制的是“定时器0”,因为本例子用的是“定时器0”,因此“定时器1”的左4位都设置为0的默认数值,我们只需重点关注右4位的“定时器0”即可。
GATE:定时器是否受“其它外部开关”的影响的标志位。定时器的开启或者停止,受到两个开关的影响,第一个开关是“自身原配开关”,第二个开关是“其它外部开关”。GATE取1代表定时器受“其它外部开关”的影响,取0代表定时器不受“其它外部开关”的影响。本例子中,定时器只受到“自身原配开关”的影响,而不受到“其它外部开关”的影响,因此,GATE取0。
C/T:定时器有两种模式,该位取1代表“计数器模式”,取0代表“定时器模式”。本例子是“定时器模式”,因此,C/T取0。
M1与M0:工作方式的选择。M1与M0这两位的01搭配,可以有4种组合(00,01,10,11),每一种组合就代表一种工作方式。本例子选用“方式1”,因此M1与M0取“01”的组合。
综上所述,TMOD的配置代码是:TMOD=0x01;
【85.3决定时间长度的寄存器TH0与TL0。】
TH0与TL0,T代表定时器英文单词TIME的T,H代表高位,L代表低位,0代表定时器0。
TH0是一个8位宽度的寄存器,TL0也是一个8位宽度的寄存器,两者合并起来成为一个整体,实际上就是一个16位宽度的寄存器,TH0是高8位,TL0是低8位,它们合并后的数值范围是:0到65535。该16位寄存器取值越大,定时中断一次的时间反倒越小,为什么?TH0与TL0的初始值,就像一个水桶里装的水。如果这个桶是空桶(取值为0),“雨水”想把这个桶“滴满溢出”所需要的时间就很大。如果里面已经装了大半的水(取值为大于32767),“雨水”想把这个桶“滴满溢出”所需要的时间就很小。这里的关键词“滴满溢出”的“滴”与“满溢出”,“滴”的速度是由单片机晶振决定的,而“满溢出”一次就代表产生一次中断,执行完中断函数在即将返回主函数之前,我们重新装入特定容量的水(重装初值),为下一次的“滴满溢出”做准备,依次循环,从而连续不断地产生间歇的定时中断。
配置中断时间的大小是需要经验的,因为,每次定时中断的时间太长,就意味着时间的可分度太粗,而如果每次定时中断的时间太短,则会产生很频繁的中断,势必会影响主函数main()的执行效率,而且累记中断次数的时间误差也会增大。因此,配置中断时间是需要经验的,根据经验,定时中断取1ms一次,是几乎所有单片机项目的最佳选择,按我的理解,“1ms定时中断一次”已经是单片机界公认的一种“标配”。
要配置1ms定时中断,TH0与TL0如何取值?刚才提到一个形象的例子“桶,滴,满溢出”。TH0与TL0的最大取值范围是65535,可以理解成为最大65535“滴”,如果超过65535“滴”(比如加1“滴”后变成65536“滴”)就会“满溢出”,从而产生一次中断(65536是中断发生的临界值)。而“滴一次的时间”就刚好是单片机执行“一次单指令的时间”,“一次单指令的时间”等于12个晶振周期,比如12MHz的晶振,晶振周期是(1/12000000)秒,而“一次单指令的时间”就等于12乘以(1/12000000)秒,等于0.000001秒,也就是1us。1us“滴”一次,要产生1ms的时间就需要“滴”1000次。“满溢出”的前提条件是“桶里”一共需要装入65536滴才溢出,因此,在12MHz的晶振下要产生1ms的定时中断,TH0与TL0的初值应该是64536(65536减去1000等于64536),而64536变成十六进制0xfc17,再分解到高8位TH0为0xfc,低8位TL0为0x17。
刚才的例子是假如晶振在12MHz的情况下所计算出来的结果,而本教程所用的晶振是11.0592MHz,根据11.0592MHz产生1ms的定时中断,TH0与TL0应该取值多少?根据刚才的计算方式:
初值=[溢出值]-(/([晶振周期的12个]*(/[晶振频率])))
初值=65536-(0.001/(12*(1/11059200)))
初值=65536-922 (注:922是921.6的四舍五入)
初值=64614
初值=64614
初值=0xfc66
初值TH0=0xfc
初值TL0=0x66
【85.4中断的总开关EA与局部开关ET0。】
EA:中断的总开关。宽度是1位的位变量。此开关如果取0,就会强行屏蔽所有的中断,因此,只要用到中断,此开关必须取1。
ET0:专门针对定时器0中断的局部开关。宽度是1位的位变量。此开关如果取0,则会屏蔽定时器0的中断,如果取1则允许定时器0中断。如果要定时器0能产生中断,那么总开关EA与ET0必须同时都打开(都取1),两者缺一不可。
【85.5定时器0的“自身原配开关”TR0。】
TR0:定时器的“自身原配开关”。宽度是1位的位变量。很多初学者会把EA,ET0,TR0三者搞不清。定时器可以工作在“查询标志位”和“中断”这两种状态,也就是说在没有中断的情况下定时器也可以单独使用的。TR0是定时器0自身的发动引擎,要不要把这个发动引擎所产生的能量传输到中断的渠道,则取决于中断开关EA和ET0。TR0是源头开关,EA是中断总渠道开关,ET0是中断分支渠道的定时器0开关。TR0取1表示启动定时器0,取0表示关闭定时器0。
【85.6定时器0的中断函数的书写格式。】
void 函数名() interrupt 1
{
...中断程序内容;
...此处省去若干代码
...中断程序内容;
...最后面的代码,要记得重装TH0与TL0的初值;
}
函数名可以随便取,只要不是编译器已经征用的关键字。这里的1是定时器0的中断号。不同的中断号代表不同类型的中断,至于哪类中断对应哪个中断号,大家可以查找相关书籍和资料。本节用的定时器0处于工作方式1的情况下,在即将退出中断之前,需要重装TH0与TL0的初始值。
【85.7寄存器的名字来源。】
前面讲的寄存器都有固定的名字,而且这些名字都是唯一的,拼写的时候少一个字母或者多一个字母,C编译器都会报错不让你通过,因此问题来了,初学者刚接触一款单片机的时候,如何知道某个寄存器它特定的唯一的名字?有两个来源。
第一个来源,可以打开C编译器的某个头文件(.h格式)查看这些寄存器的名字。比如51单片机可以查看REG52.H这个头文件。如何打开REG52.H这个文件?在keil源代码编辑器界面下,选中上面REG52.H这几个字符,在右键弹出的菜单下点击Open ducument“REG52.H”即可。
第二个来源是直接参考一些现成的范例程序,这些范例程序网上很多,有的是原厂提供的,有的是热心网友的分享,有的是技术书籍或者学习板开发板厂家提供的。
【85.8如何快速配置寄存器。】
建议一边阅读芯片的数据手册,一边参考一些现成的范例程序,这些范例程序网上很多,有的是原厂提供的,有的是热心网友的分享,有的是技术书籍或者学习板开发板厂家提供的。
【85.9练习例程。】
现在编写一个定时中断程序,让两个LED灯闪烁,一个是在主函数里用累计主循环次数的方式实现(P0.0控制),另一个是在定时中断函数里用累计定时中断次数的方式实现(P0.1控制)。这两个闪烁的LED灯,一个在main函数,一个是在中断函数,两路任务互不干涉独立运行,并行处理的“雏形”略显出来。
图85.9.1灌入式驱动8个LED
#include "REG52.H"
#defineCYCLE_SUM 5000 //主循环的次数
#defineINTERRUPT_SUM 500 //中断的次数
sbit P0_0=P0^0;//在主循环里的LED灯
sbit P0_1=P0^1;//在定时中断里的LED灯
unsigned char Gu8CycleStep=0;
unsigned long Gu32CycleCnt=0; //累计主循环的计数器
unsigned char Gu8InterruptStep=0;
unsigned long Gu32InterruptCnt=0;//累计定时中断次数的计数器
void main()
{
TMOD=0x01;//设置定时器0为工作方式1
TH0=0xfc; //产生1ms中断的TH0初始值
TL0=0x66; //产生1ms中断的TL0初始值
EA=1; //开总中断
ET0=1; //允许定时0的中断
TR0=1; //启动定时0的中断
while(1)//主循环
{
switch(Gu8CycleStep)
{
case 0:
Gu32CycleCnt++;
if(Gu32CycleCnt>=CYCLE_SUM)
{
Gu32CycleCnt=0;
P0_0=0;//主循环的LED灯亮。
Gu8CycleStep=1;
}
break;
case 1:
Gu32CycleCnt++;
if(Gu32CycleCnt>=CYCLE_SUM)
{
Gu32CycleCnt=0;
P0_0=1; //主循环的LED灯灭。
Gu8CycleStep=0;
}
break;
}
}
}
void T0_time() interrupt 1 //定时器0的中断函数,每1ms单片机自动执行一次此函数
{
switch(Gu8InterruptStep)
{
case 0:
Gu32InterruptCnt++; //累计中断次数的次数
if(Gu32InterruptCnt>=INTERRUPT_SUM) //次数达到设定值就跳到下一步骤
{
Gu32InterruptCnt=0; //及时清零计数器,为下一步骤的新一轮计数准备
P0_1=0;//定时中断的LED灯亮。
Gu8InterruptStep=1;//跳到下一步骤
}
break;
case 1:
Gu32InterruptCnt++; //累计中断次数的次数
if(Gu32InterruptCnt>=INTERRUPT_SUM)//次数达到设定值就返回上一步骤
{
Gu32InterruptCnt=0; //及时清零计数器,为返回上一步骤的新一轮计数准备
P0_1=1;//定时中断的LED灯灭。
Gu8InterruptStep=0;//返回到上一个步骤
}
break;
}
TH0=0xfc; //重装初值,不能忘。
TL0=0x66; //重装初值,不能忘。
}
tt98
发表于 2017-9-7 14:15:17
顶鸿哥,51精典教程!!!
吴坚鸿
发表于 2017-9-10 11:03:24
第八十六节: 定时中断的“非阻塞”延时控制LED闪烁。
【86.1 定时中断应用的四大关键词。】
本节主要内容有四大个关键词:1ms,互斥量,volatile,switch。
(1)1ms。把定时中断设置为1ms中断一次,几乎是单片机界公认的“标配”。这个1 ms是系统时间的节拍来源,有了1ms“标配”意识,你的程序在不同单片机平台上移植的时候会得心应手运用自如。
(2)互斥量。“主函数”与“定时中断函数”,本质上是两个独立进程在不断切换并行运行,两个进程之间不断切换,就会涉及到数据的安全保护,数据的安全保护主要是针对多字节的变量,比如int类型(2个字节),long类型(4个字节)。但是单字节的char变量不用额外保护,因为“字节”是变量中的最小单位(在不考虑“位”的情况下),这里的“最小单位不可分”就像“原子是最小单位不可分”一样,因此也有很多前辈把“互斥量”称为“原子锁”。为什么要用互斥量?因为,在多个线程同时访问同一个全局变量的时候,如果双方都是“读操作”,则不会出现问题,但是,如果双方都是“既有写操作也有读操作”的情况下,比如,我在主函数里正在修改(写操作)一个unsigned int类型的变量,unsigned int类型的变量占用2个字节,在更改数据的时候至少需要2条指令,当我刚执行完第1条指令还没来得及执行第2指令的时候,突然来了一个定时中断,并且在定时中断函数里也对这个变量进行了修改(写操作)并且还进行了读取判断操作,这个瞬间就可能给程序带来了隐患。话说回来,互斥量到底有没有必要,其实还是有点争议的,我曾经为这个问题纠结过很久,毕竟,如果不用互斥量,这么微观的隐患到底存不存在,目前很难做一个“让故障重现”的实验去证明,最后,我是本着“宁可信其有不可信其无”的态度,把互斥量应用在了我的工作中。
(3)volatile。volatile是一个前缀的修饰关键词,也是用来保护主函数与中断函数共用的全局变量的,只不过,volatile是针对C编译器的,预防“C编译器在优化代码的时候误伤一些重要的共享数据”,就像预防杀毒软软件用力过猛把一些合法软件当作病毒而误杀。加了volatile修饰的全局变量,就能提醒C编译器不要对这类特殊变量擅作主张去优化。
(4)switch。switch是“非阻塞程序框架”的核心语句,在以switch为核心的框架下,进行不同步骤之间的程序跳转,是做大型裸机程序的常态。
【86.2主函数与定时中断函数的程序框架。】
主函数与定时中断函数之间相互配合,主函数负责做什么,中断函数负责做什么,对于初学者来说可能是一头雾水,但是对于像我这种在单片机界深耕多年即将修炼成精的工程师来说,我心中是有很清晰的模板和套路的,这种模板和套路是经过多年沉淀下来的经验。比如,定时中断函数尽量放一些精简的计时器代码,一般不调用函数,但是“输入IO口的消抖动”(按键扫描)以及“蜂鸣器鸣叫”这两类特殊函数我是喜欢破例放在定时中断函数里调用的。定时中断如何产生时间,这个时间如何跟主函数关联起来,请看下面的框架代码:
volatile unsigned char vGu8TimeFlag=0;//互斥量变量标志
volatile unsigned int vGu16TimeCnt=0;//计时器变量
void main()
{
vGu8TimeFlag=0;//在“写操作”vGu16TimeCnt全局变量之前,互斥量vGu8TimeFlag的“加锁”
vGu16TimeCnt=1000;//全局变量的赋值,就是“写操作”
vGu8TimeFlag=1; //互斥量vGu8TimeFlag的“解锁”。同时也起到“启动计时器”的开关作用
while(1)//主循环
{
if(0==vGu16TimeCnt)//时间变量为0则表示时间到了
{
...在这里执行具体的功能代码
}
}
}
void T0_time() interrupt 1 //每1ms中断一次的定时中断函数
{
if(1==vGu8TimeFlag&&vGu16TimeCnt>0) //判断vGu8TimeFlag是否等于1,就是互斥量的判断。
{
vGu16TimeCnt--;//“自减一”的操作
}
}
分析:上述代码中,vGu8TimeFlag是一箭双雕,既起到互斥量的作用,也起到了计数器vGu16TimeCnt开始计时的启动开关作用。
【86.3练习例程。】
现在根据上述程序框架,编写一个LED灯闪烁的程序。
图86.3.1灌入式驱动8个LED
#include "REG52.H"
#defineBLINK_TIME 500 //时间是500ms
sbit P0_0=P0^0;
volatile unsigned char vGu8TimeFlag=0;//互斥量变量标志
volatile unsigned int vGu16TimeCnt=0;//计时器变量
unsigned char Gu8Step=0;//switch的切换步骤
void main()
{
TMOD=0x01;//设置定时器0为工作方式1
TH0=0xfc; //产生1ms中断的TH0初始值
TL0=0x66; //产生1ms中断的TL0初始值
EA=1; //开总中断
ET0=1; //允许定时0的中断
TR0=1; //启动定时0的中断
while(1)//主循环
{
switch(Gu8Step)
{
case 0:
if(0==vGu16TimeCnt)//时间到
{
P0_0=0; //LED灯亮
vGu8TimeFlag=0;//互斥量“加锁”
vGu16TimeCnt=BLINK_TIME;//计时器的写操作。设定计时的长度
vGu8TimeFlag=1;//互斥量“解锁”,同时蕴含了计时器“启动”的动作
Gu8Step=1;//切换到case 1这个步骤
}
break;
case 1:
if(0==vGu16TimeCnt)//时间到
{
P0_0=1; //LED灯灭。
vGu8TimeFlag=0;//互斥量“加锁”
vGu16TimeCnt=BLINK_TIME;//计时器的写操作。设定计时的长度
vGu8TimeFlag=1;//互斥量“解锁”,同时蕴含了计时器“启动”的动作
Gu8Step=0;//切换到case 0这个步骤,依次循环
}
break;
}
}
}
void T0_time() interrupt 1 //定时器0的中断函数,每1ms单片机自动执行一次此函数
{
if(1==vGu8TimeFlag&&vGu16TimeCnt>0) //判断vGu8TimeFlag是否等于1,就是互斥量的判断
{
vGu16TimeCnt--;//“自减一”的操作
}
TH0=0xfc; //重装初值,不能忘
TL0=0x66; //重装初值,不能忘
}
【86.4解决闪烁出现不规则“非对称感”现象的方法。】
上述例子,实验现象应该是LED闪烁很有规则的每1s闪烁一次,但是也有一部分初学者可能会遇到闪烁出现不规则“非对称感”的现象,这个问题的解决办法如下:在keil2的project下拉菜单下,选择Options for Target选项,弹出的窗口中,切换到Target选项,在Memory Model选项中选择small:variables in Data。
图86.4.1设置窗口
jesse2012
发表于 2017-9-13 11:57:30
又要到这边帖子来打卡{:lol:},连载精彩,功夫没法速成
zxq1990
发表于 2017-9-13 12:09:38
被楼主深厚的内力折服,为楼主开源的精神点赞!
dela2000
发表于 2017-9-14 14:02:55
作者有古人遗风,穷则独善其身,达则兼济天下。祝楼主赤子之心永存,幸福美满。
sidu320
发表于 2017-9-16 14:33:44
目前还在学你写的程序框架,个人感觉真是不错的选择。
吴坚鸿
发表于 2017-9-19 10:15:48
第八十七节: 一个定时中断产生N个软件定时器。
【87.1 信手拈来的软件定时器。】
初学者会疑惑,51单片机只有2个定时器T0和T1,是不是太少了一点?2个定时器怎能满足实际项目的需要,很多项目涉及到的定时器往往十几个,怎么办?这个问题的奥秘就在本节的内容。
51单片机内置的2个定时器T0和T1,是属于硬件定时器,硬件定时器是一个母体,它可以孕育出N个软件定时器,实际项目中,我们需要多少个定时器只需要从同一个硬件定时器中断里构造出对应数量的软件定时器即可。构造N个软件定时器的框架如下:
//“软件定时器1”的相关变量
volatile unsigned char vGu8TimeFlag_1=0;
volatile unsigned int vGu16TimeCnt_1=0;
//“软件定时器2”的相关变量
volatile unsigned char vGu8TimeFlag_2=0;
volatile unsigned int vGu16TimeCnt_2=0;
//“软件定时器3”的相关变量
volatile unsigned char vGu8TimeFlag_3=0;
volatile unsigned int vGu16TimeCnt_3=0;
void main()
{
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=1000;//“软件定时器1”的定时时间是1000ms
vGu8TimeFlag_1=1;
vGu8TimeFlag_2=0;
vGu16TimeCnt_2=500;//“软件定时器2”的定时时间是500ms
vGu8TimeFlag_2=1;
vGu8TimeFlag_3=0;
vGu16TimeCnt_3=250;//“软件定时器3”的定时时间是250ms
vGu8TimeFlag_3=1;
while(1)//主循环
{
if(0==vGu16TimeCnt_1)//“软件定时器1”的时间到了
{
...在这里执行具体的功能代码
}
if(0==vGu16TimeCnt_2)//“软件定时器2”的时间到了
{
...在这里执行具体的功能代码
}
if(0==vGu16TimeCnt_3//“软件定时器3”的时间到了
{
...在这里执行具体的功能代码
}
}
}
void T0_time() interrupt 1 //每1ms中断一次的定时中断函数
{
if(1==vGu8TimeFlag_1&&vGu16TimeCnt_1>0) //在定时中断里衍生出“软件定时器1”
{
vGu16TimeCnt_1--;
}
if(1==vGu8TimeFlag_2&&vGu16TimeCnt_2>0) //在定时中断里衍生出“软件定时器2”
{
vGu16TimeCnt_2--;
}
if(1==vGu8TimeFlag_3&&vGu16TimeCnt_3>0) //在定时中断里衍生出“软件定时器3”
{
vGu16TimeCnt_3--;
}
//按上面的套路继续写,可以衍生出N个“软件定时器”,只要不超过单片机的RAM和ROM。
}
【87.2练习例程。】
现在根据上述程序框架,编写3个LED灯闪烁的程序。第1个LED灯的一闪一灭的周期是2秒,第2个LED灯的一闪一灭的周期是1秒,第3个LED灯一闪一灭的周期是0.5秒。这3个灯的闪烁频率是不一样的,因此需要3个软件定时器。该例子其实也是一个多任务并行处理的典型例子,这3个LED灯就代表3个不同的任务,它们之间是通过switch这个关键语句进行多任务并行处理的。switch的精髓在于根据某个特定条件切换到对应的步骤(或称“跳转到对应的步骤”)。
图87.2.1灌入式驱动8个LED
#include "REG52.H"
#defineBLINK_TIME_1 1000 //时间是1000ms
#defineBLINK_TIME_2 500 //时间是500ms
#defineBLINK_TIME_3 250 //时间是250ms
sbit P0_0=P0^0;
sbit P0_1=P0^1;
sbit P0_2=P0^2;
//“软件定时器1”的相关变量
volatile unsigned char vGu8TimeFlag_1=0;
volatile unsigned int vGu16TimeCnt_1=0;
//“软件定时器2”的相关变量
volatile unsigned char vGu8TimeFlag_2=0;
volatile unsigned int vGu16TimeCnt_2=0;
//“软件定时器3”的相关变量
volatile unsigned char vGu8TimeFlag_3=0;
volatile unsigned int vGu16TimeCnt_3=0;
unsigned char Gu8Step_1=0;//软件定时器1的switch切换步骤
unsigned char Gu8Step_2=0;//软件定时器2的switch切换步骤
unsigned char Gu8Step_3=0;//软件定时器3的switch切换步骤
void main()
{
TMOD=0x01;//设置定时器0为工作方式1
TH0=0xfc; //产生1ms中断的TH0初始值
TL0=0x66; //产生1ms中断的TL0初始值
EA=1; //开总中断
ET0=1; //允许定时0的中断
TR0=1; //启动定时0的中断
while(1)//主循环
{
//软件定时器1控制的LED灯闪烁
switch(Gu8Step_1)
{
case 0:
if(0==vGu16TimeCnt_1)
{
P0_0=0;
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=BLINK_TIME_1;
vGu8TimeFlag_1=1;
Gu8Step_1=1;
}
break;
case 1:
if(0==vGu16TimeCnt_1)
{
P0_0=1;
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=BLINK_TIME_1;
vGu8TimeFlag_1=1;
Gu8Step_1=0;
}
break;
}
//软件定时器2控制的LED灯闪烁
switch(Gu8Step_2)
{
case 0:
if(0==vGu16TimeCnt_2)
{
P0_1=0;
vGu8TimeFlag_2=0;
vGu16TimeCnt_2=BLINK_TIME_2;
vGu8TimeFlag_2=1;
Gu8Step_2=1;
}
break;
case 1:
if(0==vGu16TimeCnt_2)
{
P0_1=1;
vGu8TimeFlag_2=0;
vGu16TimeCnt_2=BLINK_TIME_2;
vGu8TimeFlag_2=1;
Gu8Step_2=0;
}
break;
}
//软件定时器3控制的LED灯闪烁
switch(Gu8Step_3)
{
case 0:
if(0==vGu16TimeCnt_3)
{
P0_2=0;
vGu8TimeFlag_3=0;
vGu16TimeCnt_3=BLINK_TIME_3;
vGu8TimeFlag_3=1;
Gu8Step_3=1;
}
break;
case 1:
if(0==vGu16TimeCnt_3)
{
P0_2=1;
vGu8TimeFlag_3=0;
vGu16TimeCnt_3=BLINK_TIME_3;
vGu8TimeFlag_3=1;
Gu8Step_3=0;
}
break;
}
}
}
void T0_time() interrupt 1 //定时器0的中断函数,每1ms单片机自动执行一次此函数
{
if(1==vGu8TimeFlag_1&&vGu16TimeCnt_1>0) //在定时中断里衍生出“软件定时器1”
{
vGu16TimeCnt_1--;
}
if(1==vGu8TimeFlag_2&&vGu16TimeCnt_2>0) //在定时中断里衍生出“软件定时器2”
{
vGu16TimeCnt_2--;
}
if(1==vGu8TimeFlag_3&&vGu16TimeCnt_3>0) //在定时中断里衍生出“软件定时器3”
{
vGu16TimeCnt_3--;
}
TH0=0xfc; //重装初值,不能忘
TL0=0x66; //重装初值,不能忘
}
wushifeng
发表于 2017-9-19 10:22:29
看到更新,第一时间留脚印
shotstar
发表于 2017-9-19 13:09:24
支持一下 楼主的奉献精神
PCBBOY1991
发表于 2017-9-19 13:25:20
楼主一直在更新,赞一个!
ywf85
发表于 2017-9-23 16:27:53
楼主功力高深,赞一个,希望更新快点
吴坚鸿
发表于 2017-9-24 10:30:36
第八十八节: 两大核心框架理论(四区一线,switch外加定时中断)。
【88.1 四区一线。】
提出“四区一线”理论,主要方便初学者理解单片机程序大概的“空间分区”。
“四区”代表四大主流函数,分别是:系统初始化函数,外设初始化函数,主程序的任务函数,定时中断函数。
“一线”是指“系统初始化函数”与“外设初始化函数”的“分割线”,这个“分割线”是一个delay的延时函数。
“四区一线”的布局如下:
void main()
{
SystemInitial(); //“四区一线”的“第一区”
Delay(10000); //“四区一线”的“一线”
PeripheralInitial(); //“四区一线”的“第二区”
while(1)//主循环
{
LedService(); //“四区一线”的“第三区”
KeyService(); //“四区一线”的“第三区”
UsartService(); //“四区一线”的“第三区”
... //凡是在主循环里的函数都是属于“第三区”
}
}
void T0_time() interrupt 1 //“四区一线”的“第四区”
{
}
“第一区”的函数SystemInitial(),是一个系统的初始化函数,专门用来初始化单片机自己的寄存器以及个别外围要求响应速度快的输出设备,防止刚上电之后,由于输出IO口电平状态不确定而导致外围设备误动作,比如驱动继电器的误动作等等。
“一线”的函数Delay(10000),是一个延时函数,为什么这里要插入一个延时函数?主要目的是为接下来的PeripheralInitial()做准备的。上电后先延时一段时间,再执行PeripheralInitial()函数,因为PeripheralInitial()函数专门用来初始化不要求上电立即处理的外设芯片和模块。比如液晶模块,AT24C02存储芯片,DS1302时钟芯片,等等。这些芯片在上电的瞬间,内部自身的复位需要一点时间,以及外部电压稳定也需要一点时间,只有过了这一点时间,这些芯片才处于正常的工作状态,这个时候单片机才能跟它正常通信,所以“一线”函数Delay(10000)的意义就在这里。
“第二区”的函数PeripheralInitial(),是一个外设的初始化函数。专门用来初始化不要求上电立即处理的外设芯片和模块。
“第三区”的函数LedService(),KeyService(),UsartService(),等等,是一些在主循环里不断扫描的任务函数。
“第四区”的函数void T0_time() interrupt 1,是一个定时中断函数,一个系统必须标配一个定时中断函数才算完美齐全,这个中断函数提供系统的节拍时间,以及处理扫描一些跟IO口消抖动相关的函数,以及跟蜂鸣器驱动相关的函数。
【88.2 switch外加定时中断。】
提出“switch外加定时中断”理论,主要方便初学者理解单片机程序大概的“逻辑框架”。
switch是一个万能语句,它外加while与for循环就可以做任何复杂的算法,比如,搜索算法,运动算法,提取关键词算法,等等。它外加定时中断,就可以搭建一个系统的基本框架。比如,做通信的程序框架,人机界面的程序框架,按键服务的程序框架,等等。switch的精髓在于“根据条件进行步骤的灵活切换”。具体内容请看本节的练习程序。
【88.3练习例程。】
根据上述的两大核心框架理论,编写1个LED灯闪烁的程序。
图88.3.1灌入式驱动8个LED
#include "REG52.H"
void T0_time();
void SystemInitial(void) ;
void Delay(unsigned long u32DelayTime) ;
void PeripheralInitial(void) ;
void LedService(void);
#defineBLINK_TIME_1 1000
sbit P0_0=P0^0;
volatile unsigned char vGu8TimeFlag_1=0;
volatile unsigned int vGu16TimeCnt_1=0;
void main()
{
SystemInitial(); //“四区一线”的“第一区”
Delay(10000); //“四区一线”的“一线”
PeripheralInitial(); //“四区一线”的“第二区”
while(1)//主循环
{
LedService(); //“四区一线”的“第三区”
}
}
void T0_time() interrupt 1 //“四区一线”的“第四区”
{
if(1==vGu8TimeFlag_1&&vGu16TimeCnt_1>0)
{
vGu16TimeCnt_1--;
}
TH0=0xfc;
TL0=0x66;
}
void SystemInitial(void)
{
TMOD=0x01;
TH0=0xfc;
TL0=0x66;
EA=1;
ET0=1;
TR0=1;
}
void Delay(unsigned long u32DelayTime)
{
for(;u32DelayTime>0;u32DelayTime--);
}
void PeripheralInitial(void)
{
}
void LedService(void)
{
static unsigned char Su8Step=0; //加static修饰的局部变量,每次进来都会保留上一次值。
switch(Su8Step)
{
case 0:
if(0==vGu16TimeCnt_1)//时间到
{
P0_0=0;
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=BLINK_TIME_1; //重装定时的时间
vGu8TimeFlag_1=1;
Su8Step=1;//切换到下一个步骤,精髓语句!
}
break;
case 1:
if(0==vGu16TimeCnt_1) //时间到
{
P0_0=1;
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=BLINK_TIME_1;//重装定时的时间
vGu8TimeFlag_1=1;
Su8Step=0; //返回到上一个步骤,精髓语句!
}
break;
}
}
吴坚鸿
发表于 2017-10-2 11:59:05
第八十九节: 跑马灯的三种境界。
【89.1 跑马灯的三种境界。】
跑马灯也称为流水灯,排列的几个LED依次循环的点亮和熄灭,给人“跑动起来”的感觉,故称为“跑马灯”。实现跑马灯的效果,编程上有三种思路,分别代表了跑马灯的三种境界,分别是:移位阻塞,移位非阻塞,状态切换非阻塞。
图89.1.1灌入式驱动8个LED
本节用的是8个LED灯依次挨个熄灭点亮,如上图所示。
【89.2 移位阻塞。】
移位阻塞,“移位”用的是C语言的左移或者右移语句,“阻塞”用的是delay延时。代码如下:
#include "REG52.H"
void T0_time();
void SystemInitial(void) ;
void Delay(unsigned long u32DelayTime) ;
void PeripheralInitial(void) ;
void LedTask(void);
void main()
{
SystemInitial();
Delay(10000);
PeripheralInitial();
while(1)
{
LedTask();
}
}
void T0_time() interrupt 1
{
TH0=0xfc;
TL0=0x66;
}
void SystemInitial(void)
{
TMOD=0x01;
TH0=0xfc;
TL0=0x66;
EA=1;
ET0=1;
TR0=1;
}
void Delay(unsigned long u32DelayTime)
{
for(;u32DelayTime>0;u32DelayTime--);
}
void PeripheralInitial(void)
{
}
//跑马灯的任务程序
void LedTask(void)
{
static unsigned char Su8Data=0x01; //加static修饰的局部变量,每次进来都会保留上一次值。
static unsigned char Su8Cnt=0; //加static修饰的局部变量,每次进来都会保留上一次值。
P0=Su8Data; //Su8Data的8个位代表8个LED的状态,0为点亮,1为熄灭。
Delay(10000) ; //阻塞延时
Su8Data=Su8Data<<1;//左移一位
Su8Cnt++; //计数器累加1
if(Su8Cnt>=8) //移位大于等于8次后,重新赋初值
{
Su8Cnt=0;
Su8Data=0x01;//重新赋初值,继续下一次循环移动
}
}
分析总结:这是第1种境界的跑马灯,这种思路虽然实现了跑马灯的效果,但是因为“阻塞延时”,整个程序显得僵硬机械,缺乏多任务并行的框架。
【89.3 移位非阻塞。】
移位非阻塞,“移位”用的是C语言的左移或者右移语句,“非阻塞”用的是定时中断衍生出来的软件定时器。代码如下:
#include "REG52.H"
void T0_time();
void SystemInitial(void) ;
void Delay(unsigned long u32DelayTime) ;
void PeripheralInitial(void) ;
void LedTask(void);
#defineBLINK_TIME_1 1000
volatile unsigned char vGu8TimeFlag_1=0;
volatile unsigned int vGu16TimeCnt_1=0;
void main()
{
SystemInitial();
Delay(10000);
PeripheralInitial();
while(1)
{
LedTask();
}
}
void T0_time() interrupt 1
{
if(1==vGu8TimeFlag_1&&vGu16TimeCnt_1>0) //软件定时器
{
vGu16TimeCnt_1--;
}
TH0=0xfc;
TL0=0x66;
}
void SystemInitial(void)
{
TMOD=0x01;
TH0=0xfc;
TL0=0x66;
EA=1;
ET0=1;
TR0=1;
}
void Delay(unsigned long u32DelayTime)
{
for(;u32DelayTime>0;u32DelayTime--);
}
void PeripheralInitial(void)
{
}
//跑马灯的任务程序
void LedTask(void)
{
static unsigned char Su8Data=0x01; //加static修饰的局部变量,每次进来都会保留上一次值。
static unsigned char Su8Cnt=0; //加static修饰的局部变量,每次进来都会保留上一次值。
if(0==vGu16TimeCnt_1) //时间到
{
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=BLINK_TIME_1;//重装定时的时间
vGu8TimeFlag_1=1;
P0=Su8Data; //Su8Data的8个位代表8个LED的状态,0为点亮,1为熄灭。
Su8Data=Su8Data<<1;//左移一位
Su8Cnt++; //计数器累加1
if(Su8Cnt>=8) //移位大于等于8次后,重新赋初值
{
Su8Cnt=0;
Su8Data=0x01;//重新赋初值,继续下一次循环移动
}
}
}
分析总结:这是第2种境界的跑马灯,这种思路虽然实现了跑马灯的效果,也用到了多任务并行处理的基本元素“软件定时器”,但是因为还停留在“移位”语句的阶段,此时的程序并没有超越跑马灯本身,跑马灯还是跑马灯,处于“看山还是山”的境界。
【89.4 状态切换非阻塞。】
状态切换非阻塞,“状态切换”用的是switch语句中根据特定条件进行步骤切换,“非阻塞”用的是定时中断衍生出来的软件定时器。代码如下:
#include "REG52.H"
void T0_time();
void SystemInitial(void) ;
void Delay(unsigned long u32DelayTime) ;
void PeripheralInitial(void) ;
void LedTask(void);
#defineBLINK_TIME_1 1000
sbit P0_0=P0^0;
sbit P0_1=P0^1;
sbit P0_2=P0^2;
sbit P0_3=P0^3;
sbit P0_4=P0^4;
sbit P0_5=P0^5;
sbit P0_6=P0^6;
sbit P0_7=P0^7;
volatile unsigned char vGu8TimeFlag_1=0;
volatile unsigned int vGu16TimeCnt_1=0;
void main()
{
SystemInitial();
Delay(10000);
PeripheralInitial();
while(1)
{
LedTask();
}
}
void T0_time() interrupt 1
{
if(1==vGu8TimeFlag_1&&vGu16TimeCnt_1>0) //软件定时器
{
vGu16TimeCnt_1--;
}
TH0=0xfc;
TL0=0x66;
}
void SystemInitial(void)
{
TMOD=0x01;
TH0=0xfc;
TL0=0x66;
EA=1;
ET0=1;
TR0=1;
}
void Delay(unsigned long u32DelayTime)
{
for(;u32DelayTime>0;u32DelayTime--);
}
void PeripheralInitial(void)
{
}
//跑马灯的任务程序
void LedTask(void)
{
static unsigned char Su8Step=0; //加static修饰的局部变量,每次进来都会保留上一次值。
switch(Su8Step)
{
case 0:
if(0==vGu16TimeCnt_1)//时间到
{
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=BLINK_TIME_1; //重装定时的时间
vGu8TimeFlag_1=1;
P0_0=1; //第0个灯熄灭
P0_1=0;
P0_2=0;
P0_3=0;
P0_4=0;
P0_5=0;
P0_6=0;
P0_7=0;
Su8Step=1;//切换到下一个步骤,精髓语句!
}
break;
case 1:
if(0==vGu16TimeCnt_1)//时间到
{
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=BLINK_TIME_1; //重装定时的时间
vGu8TimeFlag_1=1;
P0_0=0;
P0_1=1; //第1个灯熄灭
P0_2=0;
P0_3=0;
P0_4=0;
P0_5=0;
P0_6=0;
P0_7=0;
Su8Step=2;//切换到下一个步骤,精髓语句!
}
break;
case 2:
if(0==vGu16TimeCnt_1)//时间到
{
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=BLINK_TIME_1; //重装定时的时间
vGu8TimeFlag_1=1;
P0_0=0;
P0_1=0;
P0_2=1; //第2个灯熄灭
P0_3=0;
P0_4=0;
P0_5=0;
P0_6=0;
P0_7=0;
Su8Step=3;//切换到下一个步骤,精髓语句!
}
break;
case 3:
if(0==vGu16TimeCnt_1)//时间到
{
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=BLINK_TIME_1; //重装定时的时间
vGu8TimeFlag_1=1;
P0_0=0;
P0_1=0;
P0_2=0;
P0_3=1; //第3个灯熄灭
P0_4=0;
P0_5=0;
P0_6=0;
P0_7=0;
Su8Step=4;//切换到下一个步骤,精髓语句!
}
break;
case 4:
if(0==vGu16TimeCnt_1)//时间到
{
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=BLINK_TIME_1; //重装定时的时间
vGu8TimeFlag_1=1;
P0_0=0;
P0_1=0;
P0_2=0;
P0_3=0;
P0_4=1; //第4个灯熄灭
P0_5=0;
P0_6=0;
P0_7=0;
Su8Step=5;//切换到下一个步骤,精髓语句!
}
break;
case 5:
if(0==vGu16TimeCnt_1)//时间到
{
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=BLINK_TIME_1; //重装定时的时间
vGu8TimeFlag_1=1;
P0_0=0;
P0_1=0;
P0_2=0;
P0_3=0;
P0_4=0;
P0_5=1; //第5个灯熄灭
P0_6=0;
P0_7=0;
Su8Step=6;//切换到下一个步骤,精髓语句!
}
break;
case 6:
if(0==vGu16TimeCnt_1)//时间到
{
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=BLINK_TIME_1; //重装定时的时间
vGu8TimeFlag_1=1;
P0_0=0;
P0_1=0;
P0_2=0;
P0_3=0;
P0_4=0;
P0_5=0;
P0_6=1; //第6个灯熄灭
P0_7=0;
Su8Step=7;//切换到下一个步骤,精髓语句!
}
break;
case 7:
if(0==vGu16TimeCnt_1) //时间到
{
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=BLINK_TIME_1;//重装定时的时间
vGu8TimeFlag_1=1;
P0_0=0;
P0_1=0;
P0_2=0;
P0_3=0;
P0_4=0;
P0_5=0;
P0_6=0;
P0_7=1; //第7个灯熄灭
Su8Step=0; //返回到第0个步骤重新开始往下走,精髓语句!
}
break;
}
}
分析总结:这是第3种境界的跑马灯,很多初学者咋看此程序,表示不理解,人家一条赋值语句就解决8个LED一次性显示的问题,你非要拆分成8条按位赋值的语句,人家只用一个判断就实现了LED灯移动显示的功能,你非要整出8个步骤的切换,况且,整个程序的代码量明显增加了很多,这个程序好在哪?其实,我这么做是用心良苦呀。这个程序的代码量虽然增多了,但是仔细一看,并没有影响运行的效率。之所以把8个LED灯拆分成一个一个的LED灯单独赋值显示,是因为,在我眼里,这个8个LED灯代表的不仅仅是LED灯,而是8个输出信号!这8个输出信号未来驱动的可能是不同的继电器,气缸,电机,大炮,导弹,以及它们的各种千变万化的组合逻辑,拆分之后程序框架就有了无限可能的扩展性。之所以整出8个步骤的切换,也是同样的道理,为了增加程序框架无限可能的扩展性。这个程序虽然表面看起来繁琐,但是仔细一看它是“多而不乱”,非常富有“队形感”。因此可以这么说,这个看似繁琐的跑马灯程序,其实背后蕴藏了编程界的大智慧,它已经突破了“看山还是山”的境界。
kukudi
发表于 2017-10-3 12:09:12
讲得很好,支持。
tt98
发表于 2017-10-8 08:55:24
受三种境界了
吴坚鸿
发表于 2017-10-9 10:06:51
第九十节: 多任务并行处理两路跑马灯。
【90.1 多任务并行处理。】
两路速度不同的跑马灯,代表了两路独立运行的任务,单片机如何“并行”处理这两路任务,就涉及到“多任务并行处理的编程思路”。
上图90.1.1灌入式驱动8个LED 第1路跑马灯
上图90.1.2灌入式驱动4个LED新增加的第2路跑马灯
如上图,本节特别值得一提的是,新增加的第2路跑马灯用的是4个LED,这4个LED的驱动IO口是“散装的”,因为,前面3个是P1口的(P1.4,P1.5,P1.6),最后1个是P3口的(P3.3),这种情况下,肯定用不了“移位”的处理思路,只能用跑马灯第3种境界里所介绍的“状态切换非阻塞”思路,可见,“IO口拆分”和“switch状态切换”又一次充分体现了它们“程序框架万能扩展”的优越性。代码如下:
#include "REG52.H"
void T0_time();
void SystemInitial(void) ;
void Delay(unsigned long u32DelayTime) ;
void PeripheralInitial(void) ;
void Led_1_Task(void);
void Led_2_Task(void);
#defineBLINK_TIME_1 1000//控制第1路跑马灯的速度,数值越大“跑动”越慢。
#defineBLINK_TIME_2 200 //控制第2路跑马灯的速度,数值越大“跑动”越慢。
sbit P0_0=P0^0;
sbit P0_1=P0^1;
sbit P0_2=P0^2;
sbit P0_3=P0^3;
sbit P0_4=P0^4;
sbit P0_5=P0^5;
sbit P0_6=P0^6;
sbit P0_7=P0^7;
sbit P1_4=P1^4;
sbit P1_5=P1^5;
sbit P1_6=P1^6;
sbit P3_3=P3^3;
volatile unsigned char vGu8TimeFlag_1=0;
volatile unsigned int vGu16TimeCnt_1=0;
volatile unsigned char vGu8TimeFlag_2=0;
volatile unsigned int vGu16TimeCnt_2=0;
void main()
{
SystemInitial();
Delay(10000);
PeripheralInitial();
while(1)
{
Led_1_Task(); //第1路跑马灯
Led_2_Task(); //第2路跑马灯
}
}
void T0_time() interrupt 1
{
if(1==vGu8TimeFlag_1&&vGu16TimeCnt_1>0) //软件定时器1
{
vGu16TimeCnt_1--;
}
if(1==vGu8TimeFlag_2&&vGu16TimeCnt_2>0) //软件定时器2
{
vGu16TimeCnt_2--;
}
TH0=0xfc;
TL0=0x66;
}
void SystemInitial(void)
{
TMOD=0x01;
TH0=0xfc;
TL0=0x66;
EA=1;
ET0=1;
TR0=1;
}
void Delay(unsigned long u32DelayTime)
{
for(;u32DelayTime>0;u32DelayTime--);
}
void PeripheralInitial(void)
{
}
//第1路跑马灯
void Led_1_Task(void)
{
static unsigned char Su8Step=0; //加static修饰的局部变量,每次进来都会保留上一次值。
switch(Su8Step)
{
case 0:
if(0==vGu16TimeCnt_1)//时间到
{
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=BLINK_TIME_1; //重装定时的时间
vGu8TimeFlag_1=1;
P0_0=1; //第0个灯熄灭
P0_1=0;
P0_2=0;
P0_3=0;
P0_4=0;
P0_5=0;
P0_6=0;
P0_7=0;
Su8Step=1;//切换到下一个步骤,精髓语句!
}
break;
case 1:
if(0==vGu16TimeCnt_1)//时间到
{
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=BLINK_TIME_1; //重装定时的时间
vGu8TimeFlag_1=1;
P0_0=0;
P0_1=1; //第1个灯熄灭
P0_2=0;
P0_3=0;
P0_4=0;
P0_5=0;
P0_6=0;
P0_7=0;
Su8Step=2;//切换到下一个步骤,精髓语句!
}
break;
case 2:
if(0==vGu16TimeCnt_1)//时间到
{
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=BLINK_TIME_1; //重装定时的时间
vGu8TimeFlag_1=1;
P0_0=0;
P0_1=0;
P0_2=1; //第2个灯熄灭
P0_3=0;
P0_4=0;
P0_5=0;
P0_6=0;
P0_7=0;
Su8Step=3;//切换到下一个步骤,精髓语句!
}
break;
case 3:
if(0==vGu16TimeCnt_1)//时间到
{
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=BLINK_TIME_1; //重装定时的时间
vGu8TimeFlag_1=1;
P0_0=0;
P0_1=0;
P0_2=0;
P0_3=1; //第3个灯熄灭
P0_4=0;
P0_5=0;
P0_6=0;
P0_7=0;
Su8Step=4;//切换到下一个步骤,精髓语句!
}
break;
case 4:
if(0==vGu16TimeCnt_1)//时间到
{
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=BLINK_TIME_1; //重装定时的时间
vGu8TimeFlag_1=1;
P0_0=0;
P0_1=0;
P0_2=0;
P0_3=0;
P0_4=1; //第4个灯熄灭
P0_5=0;
P0_6=0;
P0_7=0;
Su8Step=5;//切换到下一个步骤,精髓语句!
}
break;
case 5:
if(0==vGu16TimeCnt_1)//时间到
{
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=BLINK_TIME_1; //重装定时的时间
vGu8TimeFlag_1=1;
P0_0=0;
P0_1=0;
P0_2=0;
P0_3=0;
P0_4=0;
P0_5=1; //第5个灯熄灭
P0_6=0;
P0_7=0;
Su8Step=6;//切换到下一个步骤,精髓语句!
}
break;
case 6:
if(0==vGu16TimeCnt_1)//时间到
{
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=BLINK_TIME_1; //重装定时的时间
vGu8TimeFlag_1=1;
P0_0=0;
P0_1=0;
P0_2=0;
P0_3=0;
P0_4=0;
P0_5=0;
P0_6=1; //第6个灯熄灭
P0_7=0;
Su8Step=7;//切换到下一个步骤,精髓语句!
}
break;
case 7:
if(0==vGu16TimeCnt_1) //时间到
{
vGu8TimeFlag_1=0;
vGu16TimeCnt_1=BLINK_TIME_1;//重装定时的时间
vGu8TimeFlag_1=1;
P0_0=0;
P0_1=0;
P0_2=0;
P0_3=0;
P0_4=0;
P0_5=0;
P0_6=0;
P0_7=1; //第7个灯熄灭
Su8Step=0; //返回到第0个步骤重新开始往下走,精髓语句!
}
break;
}
}
//第2路跑马灯
void Led_2_Task(void)
{
/*
疑点讲解(1):
这里第2路跑马灯的“Su8Step”与第1路跑马灯的“Su8Step”虽然同名,但是,因为它们是静态的局部变量,在两个不同的函数内部,是两个不同的变量,这两个变量所分配的RAM内存地址是不一样的,因此,它们虽然同名,但是不矛盾不冲突。
*/
static unsigned char Su8Step=0; //加static修饰的局部变量,每次进来都会保留上一次值。
switch(Su8Step)
{
case 0:
if(0==vGu16TimeCnt_2)//时间到
{
vGu8TimeFlag_2=0;
vGu16TimeCnt_2=BLINK_TIME_2; //重装定时的时间
vGu8TimeFlag_2=1;
P1_4=1; //第0个灯熄灭
P1_5=0;
P1_6=0;
P3_3=0;
Su8Step=1;//切换到下一个步骤,精髓语句!
}
break;
case 1:
if(0==vGu16TimeCnt_2)//时间到
{
vGu8TimeFlag_2=0;
vGu16TimeCnt_2=BLINK_TIME_2; //重装定时的时间
vGu8TimeFlag_2=1;
P1_4=0;
P1_5=1; //第1个灯熄灭
P1_6=0;
P3_3=0;
Su8Step=2;//切换到下一个步骤,精髓语句!
}
break;
case 2:
if(0==vGu16TimeCnt_2)//时间到
{
vGu8TimeFlag_2=0;
vGu16TimeCnt_2=BLINK_TIME_2; //重装定时的时间
vGu8TimeFlag_2=1;
P1_4=0;
P1_5=0;
P1_6=1; //第2个灯熄灭
P3_3=0;
Su8Step=3;//切换到下一个步骤,精髓语句!
}
break;
case 3:
if(0==vGu16TimeCnt_2)//时间到
{
vGu8TimeFlag_2=0;
vGu16TimeCnt_2=BLINK_TIME_2; //重装定时的时间
vGu8TimeFlag_2=1;
P1_4=0;
P1_5=0;
P1_6=0;
P3_3=1; //第3个灯熄灭
Su8Step=0; //返回到第0个步骤重新开始往下走,精髓语句!
}
break;
}
}
oaixuw
发表于 2017-10-9 15:19:01
支持一下楼主,楼主愿意花那么多精力去分享他多年以来的经验确实是很难得的,这对学习的人来说确实是好的,希望楼主的书到时候能大卖
吴坚鸿
发表于 2017-10-17 18:05:29
第九十一节: 蜂鸣器的“非阻塞”驱动。
【91.1 蜂鸣器的硬件电路简介。】
上图91.1.1PNP三极管驱动有源蜂鸣器
蜂鸣器有两种,一种是有源蜂鸣器,一种是无源蜂鸣器。有源蜂鸣器的驱动最简单,只要通电就一直响,断电就停,跟驱动LED灯一样。无源蜂鸣器则不一样,无源蜂鸣器一直断电不响,奇怪的是一直通电也不响,只有“通,关,通,关...”反复通电关电的状态,才会持续发生稳定的声音,此方式称为脉冲驱动方式,或者PWM驱动方式。本教程用的是有源蜂鸣器。
蜂鸣器的驱动电路也有两种常用的方式,一种是NPN三极管驱动,一种是PNP三极管驱动。NPN三极管驱动电路,单片机输出“1”(高电平)蜂鸣器导通,输出“0”(低电平)蜂鸣器关闭。而PNP三极管驱动电路恰恰相反,单片机输出“0”(低电平)蜂鸣器导通,输出“1”(高电平)蜂鸣器关闭。本教程所用的是PNP三极管驱动电路,如上图。
【91.2 “非阻塞”驱动程序。】
“驱动层”是相对“应用层”而言。“应用层”发号施令,“驱动层”负责执行。一个好的“驱动层”必须给“应用层”提供快捷便利的调用接口,此接口可以是函数或者全局变量。本节驱动蜂鸣器所用的是全局变量vGu16BeepTimerCnt。“应用层”只需给vGu16BeepTimerCnt赋值,就可以控制蜂鸣器发声,赋值越大,发声越长,500代表发声500ms,1000代表发声1000ms,具体细节实现,则由“驱动层”的驱动函数负责执行,驱动函数放在定时中断函数里定时扫描。为什么不把驱动函数放到main函数的循环里去?因为放在定时中断里,能保证蜂鸣器的声音长度是一致的,如果放在main循环里,声音的长度有可能在某些项目中受到某些必须一气呵成的任务干扰,得不到及时响应,影响声音长度的一致性。下面代码实现的功能是,单片机只要一上电,蜂鸣器就发出一次1000ms长度的“嘀”声音。
#include "REG52.H"
#define BEEP_TIME1000 //控制蜂鸣器发声的长度,此处是1000ms
void T0_time();
void SystemInitial(void) ;
void Delay(unsigned long u32DelayTime) ;
void PeripheralInitial(void) ;
void BeepOpen(void); //蜂鸣器发声
void BeepClose(void);//蜂鸣器关闭
void VoiceScan(void);//蜂鸣器的驱动函数,放在定时中断里
sbit P3_4=P3^4;//控制蜂鸣器的IO口。0代表发声,1代表关闭。
volatile unsigned char vGu8BeepTimerFlag=0;
volatile unsigned int vGu16BeepTimerCnt=0;//控制蜂鸣器发声长度的计时器
void main()
{
SystemInitial();
Delay(10000);
PeripheralInitial(); //此函数内部有“应用层”的赋值操作,控制上电的声音长度。
while(1)
{
;
}
}
void T0_time() interrupt 1
{
VoiceScan();//蜂鸣器的驱动函数
TH0=0xfc;
TL0=0x66;
}
void SystemInitial(void)
{
TMOD=0x01;
TH0=0xfc;
TL0=0x66;
EA=1;
ET0=1;
TR0=1;
}
void Delay(unsigned long u32DelayTime)
{
for(;u32DelayTime>0;u32DelayTime--);
}
void PeripheralInitial(void)
{
vGu8BeepTimerFlag=0;
vGu16BeepTimerCnt=BEEP_TIME;//“应用层”只需赋值,一上电,蜂鸣器发出1000ms长度的声音。
vGu8BeepTimerFlag=1;
}
//蜂鸣器发声
void BeepOpen(void)
{
P3_4=0;//0代表发声
}
//蜂鸣器关闭
void BeepClose(void)
{
P3_4=1;//1代表关闭
}
//蜂鸣器的驱动函数,放在定时中断函数里每定时1ms扫描一次。
void VoiceScan(void)
{
//Su8Lock的作用是避免BeepOpen()被重复扫描影响效率,发声时只执行一次此函数即可。
//同时,也巧妙借用else结构,实现逻辑顺序分解成“先发声,下一次再开始定时”的两个步骤。
static unsigned char Su8Lock=0;
if(1==vGu8BeepTimerFlag&&vGu16BeepTimerCnt>0)
{
if(0==Su8Lock)
{
Su8Lock=1;//进入触发声音后就自锁起来
BeepOpen(); //发声,此处封装成函数,为了今后代码的移植性。
}
else//巧妙借用else结构,实现先发声,下一次中断再开始计时的逻辑顺序。比如,
{ //如果赋值1,就能确保有1ms的计时发声。
vGu16BeepTimerCnt--; //定时器自减,控制蜂鸣器发声的时间长度
if(0==vGu16BeepTimerCnt)
{
Su8Lock=0; //关闭声音后,及时解锁,为下一次触发做准备
BeepClose();//关闭声音,此处封装成函数,为了今后代码的移植性。
}
}
}
}
lghao
发表于 2017-10-17 21:20:01
终于下载完91节了!楼主辛苦啦!!现在迫切需要提高C语言基础,零基础从硬件转软件,请问有没有推荐的资料{:handshake:}
tt98
发表于 2017-10-18 15:03:17
学习51的精华!!!
yunhuisong
发表于 2017-10-18 17:04:14
支持一下 楼主的奉献精神,能每个月都有更新。
wblqx
发表于 2017-10-18 20:03:19
很好的教学贴。
吴坚鸿
发表于 2017-10-25 11:40:39
第九十二节: 独立按键的四大要素(自锁,消抖,非阻塞,清零式滤波)。
【92.1 独立按键的硬件电路简介。】
上图92.1.1独立按键电路
按键有两种驱动方式,一种是独立按键,一种是矩阵按键。1个独立按键要占用1个IO口,IO口不能共用。而矩阵按键的IO口是分时片选复用的,用少量的IO口就可以驱动翻倍级别的按键数量。比如,用8个IO口只能驱动8个独立按键,但是却可以驱动16个矩阵按键(4x4)。因此,按键少的时候就用独立按键,按键多的时候就用矩阵按键。这两种按键的驱动本质是一样的,都是靠识别输入信号的下降沿(或上升沿)来识别按键的触发。
独立按键的硬件原理基础,如上图,P2.2这个IO口,在按键K1没有被按下的时候,P2.2口因为单片机内部自带上拉电阻把电平拉高,此时P2.2口是高电平的输入状态。当按键K1被按下的时候,按键K1左右像一根导线连接到电源的负极(GND),直接把原来P2.2口的电平拉低,此时P2.2口变成了低电平的输入状态。编写按键驱动程序,就是要识别这个电平从高到低的过程,这个过程也叫下降沿。多说一句,51单片机的P1,P2,P3口是内部自带上拉电阻的,而P0口是内部没有上拉电阻的,需要外接上拉电阻。除此之外,很多单片机内部其实都没有上拉电阻的,因此,建议大家在做独立按键电路的时候,养成一个习惯,凡是按键输入状态都外接上拉电阻。
识别按键的下降沿触发有四大要素:自锁,消抖,非阻塞,清零式滤波。
“自锁”,按键一旦进入到低电平,就要“自锁”起来,避免不断触发按键,只有当按键被松开变成高电平的时候,才及时“解锁”为下一次触发做准备。
“消抖”,按键是一个机械触点器件,在接触的瞬间必然存在微观上的机械抖动,反馈到电平的瞬间就是“高,低,高,低...”这种不稳定的电平状态是一种干扰,但是,按键一旦按下去稳定了之后,这种状态就消失,电平就一直保持稳定的低电平。消抖的本质就是滤波,要把这种接触的瞬间抖动过滤掉,避免按键的“一按多触发”。
“非阻塞”,在处理消抖的时候,必须用到延时,如果此时用阻塞的delay延时就会影响其它任务的运行效率,因此,用非阻塞的定时延时更加有优越性。
“清零式滤波”,在消抖的时候,有两种境界,第一种境界是判断两次电平的状态,中间插入“固定的时间”延时,这种方法前后一共判断了两次,第一次是识别到低电平就进入延时的状态,第二次是延时后再确认一次是否继续是低电平的状态,这种方法的不足是,“固定的时间”全凭经验值,但是不同的按键它们的抖动时间长度是不同的,除此之外,前后才判断了两次,在软件的抗干扰能力上也弱了很多,“密码等级”不够高。第二种境界就是“清零式滤波”,“清零式滤波”非常巧妙,抗扰能力超强,它能自动过滤不同按键的“抖动时间”,然后再进入一个“稳定时间”的“N次识别判断”,更加巧妙的是,在“抖动时间”和“稳定时间”两者时间内,只要发现一次是高电平的干扰,就马上自动清零计时器,重新开始计时。“稳定时间”一般取20ms到30ms之间,而“抖动时间”是隐藏的,在代码上并没有直接描写出来,但是却无形地融入了代码之中,只有慢慢体会才能发现它的存在。
具体的代码如下,实现的功能是按一次K1或者K2按键,就触发一次蜂鸣器鸣叫。
#include "REG52.H"
#define KEY_VOICE_TIME 50 //按键触发后发出的声音长度
#define KEY_FILTER_TIME25//按键滤波的“稳定时间”25ms
void T0_time();
void SystemInitial(void) ;
void Delay(unsigned long u32DelayTime) ;
void PeripheralInitial(void) ;
void BeepOpen(void);
void BeepClose(void);
void VoiceScan(void);
void KeyScan(void); //按键识别的驱动函数,放在定时中断里
void KeyTask(void); //按键任务函数,放在主函数内
sbit P3_4=P3^4;
sbit KEY_INPUT1=P2^2;//K1按键识别的输入口。
sbit KEY_INPUT2=P2^1;//K2按键识别的输入口。
volatile unsigned char vGu8BeepTimerFlag=0;
volatile unsigned int vGu16BeepTimerCnt=0;
volatile unsigned char vGu8KeySec=0;//按键的触发序号,全局变量意味着是其它函数的接口。
void main()
{
SystemInitial();
Delay(10000);
PeripheralInitial();
while(1)
{
KeyTask(); //按键任务函数
}
}
void T0_time() interrupt 1
{
VoiceScan();
KeyScan(); //按键识别的驱动函数
TH0=0xfc;
TL0=0x66;
}
void SystemInitial(void)
{
TMOD=0x01;
TH0=0xfc;
TL0=0x66;
EA=1;
ET0=1;
TR0=1;
}
void Delay(unsigned long u32DelayTime)
{
for(;u32DelayTime>0;u32DelayTime--);
}
void PeripheralInitial(void)
{
}
void BeepOpen(void)
{
P3_4=0;
}
void BeepClose(void)
{
P3_4=1;
}
void VoiceScan(void)
{
static unsigned char Su8Lock=0;
if(1==vGu8BeepTimerFlag&&vGu16BeepTimerCnt>0)
{
if(0==Su8Lock)
{
Su8Lock=1;
BeepOpen();
}
else
{
vGu16BeepTimerCnt--;
if(0==vGu16BeepTimerCnt)
{
Su8Lock=0;
BeepClose();
}
}
}
}
void KeyScan(void)//此函数放在定时中断里每1ms扫描一次
{
static unsigned char Su8KeyLock1; //1号按键的自锁
static unsigned intSu16KeyCnt1; //1号按键的计时器
static unsigned char Su8KeyLock2; //2号按键的自锁
static unsigned intSu16KeyCnt2; //2号按键的计时器
//1号按键
if(0!=KEY_INPUT1)//IO是高电平,说明按键没有被按下,这时要及时清零一些标志位
{
Su8KeyLock1=0; //按键解锁
Su16KeyCnt1=0; //按键去抖动延时计数器清零,此行非常巧妙,是全场的亮点。
}
else if(0==Su8KeyLock1)//有按键按下,且是第一次被按下。这行很多初学者有疑问,请看专题分析。
{
Su16KeyCnt1++; //累加定时中断次数
if(Su16KeyCnt1>KEY_FILTER_TIME) //滤波的“稳定时间”KEY_FILTER_TIME,长度是25ms。
{
Su8KeyLock1=1;//按键的自锁,避免一直触发
vGu8KeySec=1; //触发1号键
}
}
//2号按键
if(0!=KEY_INPUT2)
{
Su8KeyLock2=0;
Su16KeyCnt2=0;
}
else if(0==Su8KeyLock2)
{
Su16KeyCnt2++;
if(Su16KeyCnt2>KEY_FILTER_TIME)
{
Su8KeyLock2=1;
vGu8KeySec=2; //触发2号键
}
}
}
void KeyTask(void) //按键任务函数,放在主函数内
{
if(0==vGu8KeySec)
{
return; //按键的触发序号是0意味着无按键触发,直接退出当前函数,不执行此函数下面的代码
}
switch(vGu8KeySec) //根据不同的按键触发序号执行对应的代码
{
case 1: //1号按键
vGu8BeepTimerFlag=0;
vGu16BeepTimerCnt=KEY_VOICE_TIME;//触发按键后,发出固定长度的声音
vGu8BeepTimerFlag=1;
vGu8KeySec=0;//响应按键服务处理程序后,按键编号必须清零,避免一致触发
break;
case 2: //2号按键
vGu8BeepTimerFlag=0;
vGu16BeepTimerCnt=KEY_VOICE_TIME;//触发按键后,发出固定长度的声音
vGu8BeepTimerFlag=1;
vGu8KeySec=0;//响应按键服务处理程序后,按键编号必须清零,避免一致触发
break;
}
}
【92.2 专题分析:else if(0==Su8KeyLock1)。】
疑问:
if(0!=KEY_INPUT1)
{
Su8KeyLock1=0;
Su16KeyCnt1=0;
}
else if(0==Su8KeyLock1)//有按键按下,且是第一次被按下。为什么?为什么?为什么?
{
Su16KeyCnt1++;
if(Su16KeyCnt1>KEY_FILTER_TIME)
{
Su8KeyLock1=1;
vGu8KeySec=1;
}
}
解答:
首先,我们要明白C语言的语法中,
if(条件1)
{
}
else if(条件2)
{
}
以上语句是一对组合语句,不能分开来看。当(条件1)成立的时候,它是绝对不会判断(条件2)的。当(条件1)不成立的时候,才会判断(条件2)。
回到刚才的问题,当程序执行到(条件2) else if(0==Su8KeyLock1)的时候,就已经默认了(条件1) if(0!=KEY_INPUT1)不成立,这个条件不成立,就意味着0==KEY_INPUT1,也就是有按键被按下,因此,这里的else if(0==Su8KeyLock1)等效于else if(0==Su8KeyLock1&&0==KEY_INPUT1),而Su8KeyLock1是一个自锁标志位,一旦按键被触发后,这个标志位会变1,防止按键按住不松手的时候不断触发按键。这样,按键只能按一次触发一次,松开手后再按一次,又触发一次。
【92.3 专题分析:if(0!=KEY_INPUT1)。】
疑问:为什么不用if(1==KEY_INPUT1)而用if(0!=KEY_INPUT1)?
解答:其实两者在功能上是完全等效的,在这里都可以用。之所以本教程优先选用后者if(0!=KEY_INPUT1),是因为考虑到了代码在不同单片机平台上的可移植性和兼容性。很多32位的单片机提供的是库函数,库函数返回的按键状态是一个字节变量来表示,当被按下的时候是0,但是,当没有按下的时候并不一定等于1,而是一个“非0”的数值。
【92.4 专题分析:把KeyScan函数放在定时器中断里。】
疑问:为什么把KeyScan函数放在定时器中断里?
解答:中断函数里放的函数或者代码越少越好,但是KeyScan函数是特殊的函数,是涉及到IO口输入信号的滤波,滤波就涉及到时间的及时性与均匀性,放在定时中断函数里更加能保证时间的一致性。比如,蜂鸣器驱动,动态数码管驱动,按键扫描驱动,我个人都习惯放在定时中断函数里。
【92.5 专题分析:if(0==vGu8KeySec)return。】
疑问:if(0==vGu8KeySec)return是不是多此一举?
解答:在KeyTask函数这里,if(0==vGu8KeySec)return这行代码删掉,对程序功能是没有影响的,这里之所以多插入这行判断语句,是因为,当按键多达几十个的时候,避免主函数每次进入KeyTask函数,都挨个扫描判断switch的状态进行多次判断,如果增加了这行if(0==vGu8KeySec)return代码,就可以直接退出省事,在理论上感觉更加运行高效。其实,不同单片机不同的C编译器可能对switch语句的翻译不一样,因此,这里的是不是更加高效我不敢保证。但是可以保证的是,加了这行代码也没有其它副作用。
吴坚鸿
发表于 2017-10-29 11:01:10
本帖最后由 吴坚鸿 于 2017-10-29 11:51 编辑
第九十三节: 独立按键鼠标式的单击与双击。
【93.1 鼠标式的单击与双击。】
上图93.1.1独立按键电路
上图93.1.2LED电路
上图93.1.3有源蜂鸣器电路
鼠标的左键,可以触发单击,也可以触发双击。双击的规则是这样的,两次单击,如果第1次单击与第2次单击的时间比较“短”的时候,则这两次单击就构成双击。编写这个程序的最大亮点是如何控制好第1次单击与第2次单击的时间间隔。程序例程要实现的功能是:(1)单击改变LED灯的显示状态,单击一次LED从原来“灭”的状态变成“亮”的状态,或者从原来“亮”的状态变成“灭”的状态,依次循环切换。(2)双击则蜂鸣器发出“嘀”的一声。代码如下:
#include "REG52.H"
#define KEY_VOICE_TIME 50 //按键触发后发出的声音长度
#define KEY_FILTER_TIME25 //按键滤波的“稳定时间”25ms
#define KEY_INTERVAL_TIME250//连续两次单击之间的最大有效时间250ms
void T0_time();
void SystemInitial(void) ;
void Delay(unsigned long u32DelayTime) ;
void PeripheralInitial(void) ;
void BeepOpen(void);
void BeepClose(void);
void LedOpen(void);
void LedClose(void);
void VoiceScan(void);
void KeyScan(void); //按键识别的驱动函数,放在定时中断里
void SingleKeyTask(void); //单击按键任务函数,放在主函数内
void DoubleKeyTask(void); //双击按键任务函数,放在主函数内
sbit P3_4=P3^4; //蜂鸣器
sbit P1_4=P1^4; //LED
sbit KEY_INPUT1=P2^2;//K1按键识别的输入口。
volatile unsigned char vGu8BeepTimerFlag=0;
volatile unsigned int vGu16BeepTimerCnt=0;
unsigned char Gu8LedStatus=0; //记录LED灯的状态,0代表灭,1代表亮
volatile unsigned char vGu8SingleKeySec=0;//单击按键的触发序号
volatile unsigned char vGu8DoubleKeySec=0;//双击按键的触发序号
void main()
{
SystemInitial();
Delay(10000);
PeripheralInitial();
while(1)
{
SingleKeyTask(); //单击按键任务函数
DoubleKeyTask(); //双击按键任务函数
}
}
void T0_time() interrupt 1
{
VoiceScan();
KeyScan(); //按键识别的驱动函数
TH0=0xfc;
TL0=0x66;
}
void SystemInitial(void)
{
TMOD=0x01;
TH0=0xfc;
TL0=0x66;
EA=1;
ET0=1;
TR0=1;
}
void Delay(unsigned long u32DelayTime)
{
for(;u32DelayTime>0;u32DelayTime--);
}
void PeripheralInitial(void)
{
/* 注释一:
* 把LED的初始化放在PeripheralInitial而不是放在SystemInitial,是因为LED显示内容对上电
* 瞬间的要求不高。但是,如果是控制继电器,则应该把继电器的输出初始化放在SystemInitial。
*/
//根据Gu8LedStatus的值来初始化LED当前的显示状态,0代表灭,1代表亮
if(0==Gu8LedStatus)
{
LedClose();//关闭LED
}
else
{
LedOpen(); //点亮LED
}
}
void BeepOpen(void)
{
P3_4=0;
}
void BeepClose(void)
{
P3_4=1;
}
void LedOpen(void)
{
P1_4=0;
}
void LedClose(void)
{
P1_4=1;
}
void VoiceScan(void)
{
static unsigned char Su8Lock=0;
if(1==vGu8BeepTimerFlag&&vGu16BeepTimerCnt>0)
{
if(0==Su8Lock)
{
Su8Lock=1;
BeepOpen();
}
else
{
vGu16BeepTimerCnt--;
if(0==vGu16BeepTimerCnt)
{
Su8Lock=0;
BeepClose();
}
}
}
}
/* 注释二:
* 双击按键扫描的详细过程:
* 第一步:平时没有按键被触发时,按键的自锁标志,去抖动延时计数器一直被清零。
* 如果之前已经有按键触发过1次单击,那么启动时间间隔计数器Su16KeyIntervalCnt1,
* 在KEY_INTERVAL_TIME这个允许的时间差范围内,如果一直没有第2次单击触发,
* 则把累加按键触发的次数Su8KeyTouchCnt1也清零,上一次累计的单击数被清零,
* 就意味着下一次新的双击必须重新开始累加两次单击数。
* 第二步:一旦有按键被按下,去抖动延时计数器开始在定时中断函数里累加,在还没累加到
* 阀值KEY_FILTER_TIME时,如果在这期间由于受外界干扰或者按键抖动,而使
* IO口突然瞬间触发成高电平,这个时候马上把延时计数器Su16KeyTimeCnt1
* 清零了,这个过程非常巧妙,非常有效地去除瞬间的杂波干扰,以后凡是用到开关感应器的时候,
* 都可以用类似这样的方法去干扰。
* 第三步:如果按键按下的时间超过了阀值KEY_FILTER_TIME,马上把自锁标志Su8KeyLock1置1,
* 防止按住按键不松手后一直触发。与此同时,累加1次按键次数,如果按键次数累加有2次,
* 则认为触发双击按键,并把编号vGu8DoubleKeySec赋值。
* 第四步:等按键松开后,自锁标志Su8KeyLock1及时清零解锁,为下一次自锁做准备。并且累加间隔时间,
* 防止两次按键的间隔时间太长。如果连续2次单击的间隔时间太长达到了KEY_INTERVAL_TIME
* 的长度,立即清零当前按键次数的计数器,这样意味着上一次的累加单击数无效,下一次双击
* 必须重新累加新的单击数。
*/
void KeyScan(void)//此函数放在定时中断里每1ms扫描一次
{
static unsigned char Su8KeyLock1; //1号按键的自锁
static unsigned intSu16KeyCnt1; //1号按键的计时器
static unsigned char Su8KeyTouchCnt1; //1号按键的次数记录
static unsigned intSu16KeyIntervalCnt1; //1号按键的间隔时间计数器
//1号按键
if(0!=KEY_INPUT1)//IO是高电平,说明按键没有被按下,这时要及时清零一些标志位
{
Su8KeyLock1=0; //按键解锁
Su16KeyCnt1=0; //按键去抖动延时计数器清零,此行非常巧妙。
if(Su8KeyTouchCnt1>=1) //之前已经有按键触发过一次,再来一次就构成双击
{
Su16KeyIntervalCnt1++; //按键间隔的时间计数器累加
if(Su16KeyIntervalCnt1>=KEY_INTERVAL_TIME) //达到最大允许的间隔时间
{
Su16KeyIntervalCnt1=0; //时间计数器清零
Su8KeyTouchCnt1=0; //清零按键的按下的次数
}
}
}
else if(0==Su8KeyLock1)//有按键按下,且是第一次被按下。此行如有疑问,请看第92节的讲解。
{
Su16KeyCnt1++; //累加定时中断次数
if(Su16KeyCnt1>=KEY_FILTER_TIME) //滤波的“稳定时间”KEY_FILTER_TIME,长度是25ms。
{
Su8KeyLock1=1;//按键的自锁,避免一直触发
Su16KeyIntervalCnt1=0; //按键有效间隔的时间计数器清零
Su8KeyTouchCnt1++; //记录当前单击的次数
if(1==Su8KeyTouchCnt1) //只按了1次
{
vGu8SingleKeySec=1; //单击任务
}
else if(Su8KeyTouchCnt1>=2)//连续按了两次以上
{
Su8KeyTouchCnt1=0; //统计按键次数清零
vGu8SingleKeySec=1; //单击任务
vGu8DoubleKeySec=1; //双击任务
}
}
}
}
void SingleKeyTask(void) //单击按键任务函数,放在主函数内
{
if(0==vGu8SingleKeySec)
{
return; //按键的触发序号是0意味着无按键触发,直接退出当前函数,不执行此函数下面的代码
}
switch(vGu8SingleKeySec) //根据不同的按键触发序号执行对应的代码
{
case 1: //单击任务
//通过Gu8LedStatus的状态切换,来反复切换LED的“灭”与“亮”的状态
if(0==Gu8LedStatus)
{
Gu8LedStatus=1; //标识并且更改当前LED灯的状态。0就变成1。
LedOpen(); //点亮LED
}
else
{
Gu8LedStatus=0; //标识并且更改当前LED灯的状态。1就变成0。
LedClose();//关闭LED
}
vGu8SingleKeySec=0;//响应按键服务处理程序后,按键编号必须清零,避免一致触发
break;
}
}
void DoubleKeyTask(void) //双击按键任务函数,放在主函数内
{
if(0==vGu8DoubleKeySec)
{
return; //按键的触发序号是0意味着无按键触发,直接退出当前函数,不执行此函数下面的代码
}
switch(vGu8DoubleKeySec) //根据不同的按键触发序号执行对应的代码
{
case 1: //双击任务
vGu8BeepTimerFlag=0;
vGu16BeepTimerCnt=KEY_VOICE_TIME;//触发双击后,发出“嘀”一声
vGu8BeepTimerFlag=1;
vGu8DoubleKeySec=0;//响应按键服务处理程序后,按键编号必须清零,避免一致触发
break;
}
}