目标:
- 了解分文件的概念,要依次从C语言的函数声明、变量的存储类别、C语言编译预处理,说起。这些知识点我们之前或多或少接触过,这里做个总结与拓展。
- 经过总结,最后我们归纳出一个实现C语言模块化编程的技巧:“分文件”。
- 单片机的各类模块的融合使用的方法和C语言模块化编程的思路不谋而合,所以在以下学习中我们第一次在51单片机编程中运用分文件的方法,去简化原本又臭又长的代码。
1.C语言函数的跨文件声明
0、要点:
- 我们之前讲过《函数调用的四过程和三条件》,主调函数和被调函数在同一个.c文件中,可以用函数原型式声明方法来进行声明。
- 现在进行拓展,看一下同一个源文件(同一个目录)下,不同.c文件之间的函数是如何实现声明和调用的(当然,前提是源文件下只有一个主程序,也就是只有一个main函数)。
- 为了演示方便,我们接下来在Visual Studio这个IDE软件中进行演示跨文件的函数声明(不演示编译的过程),就先不用Linux下的gcc编译器(能够演示编译的过程),也不用keil4。
1、函数声明的注意点:
- 函数的声明仍然遵循函数调用的三条件。
- 同一个函数可以进行多次声明。
- 大前提:以下提到的.c和.h文件,都要在同一个目录下,也就是在同一个源文件夹下。
- 我们学过:在编译预处理中会先找到.c中#include的头文件,将其进行展开替代#include这句话。
- extern是C语言中存储相关的关键字,外部的意思,一般在头文件中声明函数和全局变量。通俗的说,extern是声明语句而非定义语句,“extern + 函数原型式声明”的功能是把别的文件中的函数给拿过来。extern关键字有时候是可以省略的,具体情况在下方的3种函数跨文件声明的方法中提到。
2、函数跨文件声明不成熟方法1:头文件间接声明
- 特点:
- 把所有main.c文件中自定义函数的函数原型式声明语句全部放在main.h文件中。
- main.h文件中不需要在函数声明前加“extern”关键字(也可以加),因为编译时会将#include "main.h" 语句替换成main.h文件中的函数声明,所以与我们原先将“函数原型式声明直接写在main.c文件中”的结果是一样的。
- 缺点:只是说我原来的main.c文件中少写了那么几行函数声明,main.c文件并没有得到精简。
3、函数跨文件声明不成熟方法2:分函数(在.h文件中)
- 特点:
- 把所有main.c文件中自定义函数的定义语句全部放在main.h文件中。
- main.h文件中不需要在函数声明前加“extern”关键字(绝对不可以加),因为这是在定义而非声明。
- 缺点:
- 看样子好像把函数分开写了,精简了main.c文件。但是潜在的问题是“.h文件不是我们能够编译的文件格式”。
- .h文件作用只是被.c文件包含(#include),然后在编译的预处理环节进行代码的替换,不遵循C语言模块化编程的思路(多个.c分别编译成二进制的OBJ文件,然后进行链接)。
4、函数跨文件声明不成熟方法3:分函数(在.c文件中)
- 特点:
- 把所有main.c文件中自定义函数的定义语句全部放在其他.c文件中(不唯一)。
- 这种情况下,main.c文件中需要在进入main函数前进行声明,声明时必须要加“extern”关键字。原因是:如果函数声明时不加extern会认为这是在main.c中定义过的函数,main.c文件在单独编译的时候就无法找出函数的定义式,自然就无法在main.c中调用该外部函数,无法生成OBJ文件。在keil4中进行编译时,会出现“missing function-prototype”(缺少函数原型)的错误。
- 分函数的文件中,如果函数用到了其他库函数,就需要包含该库函数的声明所在的头文件,比如上方在func1.c文件的fun()函数中用到了printf()函数,所以也需要包含"stdio.h"头文件。
- 缺点:确实把函数分开写了,精简了main.c文件,.c文件也是我们能够编译的文件格式。但问题是,当函数很多时,main.c中就需要写很多个“extern + 函数原型”的声明语句,有点麻烦。
5、函数的分文件原理:将上述三种方法(主要是方法1和方法3)的优势进行结合,可以总结出自定义函数的分文件方法。如图示例:
- 要点:每个.c文件会各自分开编译,生成各自的OBJ文件后,最后全部链接在一起,形成一个可执行程序。所以编辑时要注意每个.c文件一定要是可以编译通过的(需要考虑包含了这个头文件后有没有问题)
- 特点:
- 除了main函数所在的.c文件之外,还有其他.c文件,每个.c文件是一个模块(功能)。每个模块中有实现该模块功能的所有函数定义。
- 每个.c文件都有与之对应的.h文件,为了方便查看,它们最好写成同名的,比如上述在新建了"max.c"文件之后,就立即建立一个"max.h"文件。每个.h文件中编辑与之对应的.c文件中的函数的声明,目的是方便其他.c文件在有需要的时候直接包含这些.h文件。
- xxx.h文件中不需要在函数声明前加“extern”关键字(也可以加),因为编译时会将#include "xxx.h" 语句替换成xxx.h文件中的所有语句,这与我们原先将“函数原型式声明直接写在main.c文件中”的结果是一样的。
- 如果一个.c文件内部有函数调用,也需要考虑函数书写次序,防止编译时出现“missing function-prototype”的错误。如果我们不想去考虑函数书写次序,那么我们可以直接包含它的头文件即可,比如上述max.c文件可以包含"max.h"文件。
- 编程时,编辑完除了主函数所在程序之外的程序及其头文件后,只需要关心主函数所在程序。需要调用别的程序中的函数时,就直接包含头文件即可,比如上述main.c文件包含了"max.h"文件、"min.h"文件、以及自身的"main.h"文件。
- 注意事项:static关键字是可以修饰函数的,此时这个函数就成为了内部函数,static限定了函数的作用范围,只能在它所在的.c文件中有效,所以内部函数是不能用分文件的方法声明在头文件中的。
2.C语言变量的存储类别
1、变量的三特性:我们之前学过变量的四要素,现在看看变量具有哪三个特性。
- 变量的作用域:我们关注所定义的变量它能够被使用的作用范围。表现在编译和链接阶段,因此作用域的使用不当编译器会直接给你警告出来。
- 比如我们知道全局变量的作用域是整个程序,局部变量的作用域是它所在的函数或者复合语句内部。
- 变量的生命周期:我们关注变量从创建到变量的销毁之间的时间段。表现在程序运行阶段。
- 比如我们知道全局变量的生命周期是程序开始到结束、局部变量的生命周期是调用开始到调用结束、静态局部变量的生命周期是程序开始到结束。
- 变量的生长方向:我们关注变量到底是从内存的低地址到高地址单元存储,还是从高到低存储。表现在程序运行阶段。
- 变量的生长方向向上:意味着定义在程序前面的变量的地址低于定义在程序后面的变量的地址。也就是说随着程序自上而下的编译,如果变量的生长方向上,那么这些变量的地址是递增的。
- 变量的生长方向向下:意味着定义在程序前面的变量的地址高于定义在程序后面的变量的地址。也就是说随着程序自上而下的编译,如果变量的生长方向下,那么这些变量的地址是递减的。
2、内存的分区:这个内存我们一般认为是虚拟内存(32位平台下的虚拟内存有4G大小,gcc编译器就是64位平台),对其划分区域有利于程序员管理变量。
- 栈区(Stack):用于存储局部变量、函数参数、函数调用的上下文本信息。栈区的分配和释放是由编程器自动管理的,它具有自动分配和释放的特性。经典的操作系统中,栈区变量的生长方向是向下的,压栈操作使得栈顶的地址减小,弹栈操作使得栈顶地址增大。
- 堆区(Heap):用于动态分配内部存储,例如使用
malloc()
、calloc()
等函数手动分配的内部存储。堆区的内部分配和释放是由程序显式控制的,具有更长且可控的生命周期。堆区变量的生长方向是向上的。 - 静态全局区(Static Data Area):用于存储全局变量、静态局部变量、const常量、数组名。静态区在程序启动时分配,静态全局区的内部分配在整个程序执行期间保持不变。静态区又可以细分为以下两个区:
- 数据区(Data Segment):存储已经在程序中完成初始化的全局变量和静态局部变量,数据在程序启动时会进行初始化。
- BSS区(Block Started by Symbol):存储程序中未初始化的全局变量和静态局部变量,它们的值默认为0或是野指针。
- 代码区(Text,或Code Segment):存储程序的可执行代码。代码区只是读取的,且这些数据的存放地址在编译时就已经确定好。代码区包括程序指令和常量数据,CPU去代码区来取程序的指令,再去执行程序。常量数据又包括程序中用到的字符串,以及程序中用到的常量数据。
- 也有称代码区中的常量数据存放在文字常量区(String Literal Area)的说法。
- 在程序没有加载到内存之前,可执行程序内部已经分好了3段信息:代码区(text)、数据区(data)和未初始化数据区(bss)。
- 静态全局区的变量的生长方向是不确定的,static和const关键字也不会影响它们的生长方向,具体需要根据操作系统来确定。
- 关于数组名:数组名一般被存储在静态区,使用指向数组元素的指针。数组元素的实体存储位置决定于数组的类型(全局数组或局部数组)和分配方式(静态分配或动态分配) 。
- 关于宏定义:一般来说宏定义不会分配内存地址,宏定义本并不直接存在放于文字常量区,而是在调度阶段(预处理阶段)进行文本替换。宏定义的值会被直接接入到代码中,而不需要分配额外的存储空间。
- 关于内存各个分区的相对位置:
- ①它们的相对位置和大小取决于编程器、操作系统和具体的内存分配方式。
- ②堆区的存储地址通常比栈区的存储地址更高,但是这也不是绝对的。
- ③在大多数情况下,静态区域变化的存储空间比栈区变化的存储地址低。这是因为静态区域变化的存储空间在程序启动时就被分配,并经常位于比较低的内存地址。而栈区变量在每次调频使用时动态分配,通常位于比较高的内存地址。
- 关于内存各个分区的大小:
- ①栈区和堆区的大小并不是固定的,它们在程序运行过程中会分配和释放。大小会受到多种原因的影响,包括编译器、操作系统、硬件和程序本身的特性和配置。一般来说,可以认为堆区的占用内存空间比栈区的大。
- ②静态区的大小取决于程序中定义的全局变量和静态局部变量的大小。
- ③代码区的大小取决于程序的复杂性以及程序中所使用的文字常量的大小。
- 有两种分类逻辑:①局部 or 全局;②普通 or 静态。接下来说明它们在“变量的三特性”上的具体表现,以及它们分配在哪个内存分区上。
- 局部变量:是指在函数内部定义,函数形参,或者复合语句(用花括号包裹)中定义的变量。
- 作用范围:在函数内部定义的局部变量,或者函数的形参,就在它所在函数中生效;在复合语句中定义的局部变量,就在它所在复合语句中生效。举例:
- 生命周期:在函数(包括main函数)调用之前,局部变量不占用空间,只有调用函数的时候,才为局部变量开辟了空间,当函数结束后,局部变量就会被释放。举例:
- 存储位置:栈区。
- 生长方向:向下。
- 静态局部变量:在定义局部变量时,前面加static进行修饰。注意函数的形参前不能加static修饰,因为它的生命周期与函数的执行周期相对应,函数结束后会释放掉,不会保留其值和状态。
- 作用范围:与普通局部变量的作用范围一模一样,都是在它所在函数或者复合语句中有效。
- 生命周期:不在受限于函数或者复合语句的执行周期限制。在第一次调用函数时,为静态局部变量开辟空间,函数结束后,静态局部变量不会被释放,之后再次调用该函数时,就不会再为静态局部变量开辟空间了,也不赋初值,而是直接使用之前的变量。举例:
- 存储位置:静态全局区。
- 生长方向:不确定。
- 全局变量:是外部变量(在函数外部定义的变量)的一种,全局变量写在程序所有函数的上方。
- 作用范围:所在.c程序以及源文件(同一目录)下的其他.c程序的所有地方。注意如果在别的.c程序中想用该普通全局变量,那么用之前需要用extern进行声明。举例略。
- 生命周期:在程序运行的整个过程当中一直存在,直到程序结束。举例略。
- 存储位置:静态全局区。
- 生长方向:不确定。
- 静态全局变量:定义全局变量时,前面加static关键词进行修饰。
- 作用范围:被static修饰后限定了全局变量的作用范围,只能在它所在的.c程序中生效,其他的.c程序不能使用该静态全局变量。举例:
- 生命周期:在程序运行的整个过程当中一直存在,直到程序结束。举例略。
- 存储位置:静态全局区。
- 生长方向:不确定。
5、验证变量的生长方向:
- 测试代码:
#include <stdio.h> #include <stdlib.h> int a; int b; static int c; static int d; const int e; const int f; int main(int argc, char const *argv[]) { int g; int h; static int i; static int j; const int k; const int l; int *p1; p1 = (int *)malloc(3*4); int *p2; p2 = (int *)malloc(3*4); /* 测试全局变量的生长方向 */ printf("quanju:\n"); printf("%p\n", &a); printf("%p\n", &b); /* 测试static修饰变量(全局)的生长方向定义的生长方向 */ printf("static xiushi(quanju):\n"); printf("%p\n", &c); printf("%p\n", &d); /* 测试局部变量的生长方向 */ printf("jubu:\n"); printf("%p\n", &g); printf("%p\n", &h); /* 测试static修饰变量(局部)的生长方向 */ printf("static xiushi(jubu):\n"); printf("%p\n", &i); printf("%p\n", &j); /* 测试const修饰变量(全局)的生长方向 */ printf("const xiushi(quanju):\n"); printf("%p\n", &e); printf("%p\n", &f); /* 测试const修饰变量(局部)的生长方向 */ printf("const xiushi(jubu):\n"); printf("%p\n", &k); printf("%p\n", &l); /* 测试堆区变量的生长方向 */ printf("duiqu:\n"); printf("%p\n", p1); printf("%p\n", p2); return 0; }
- 测试结果:
6、变量的重名问题与就近原则:
- 原则一:同一作用范围内,不允许变量重名,否则会造成重定义;作用范围不同变量的可以重名。举例:
- 定义一个全局变量叫num,再在函数中定义一个变量也叫num。
- 在一个函数中定义的局部变量叫num,再在另外一个函数中定义一个变量也叫num。
- 原则二:局部范围内,重名的全局变量不起作用(遵循就近原则) 。举例:
3.全局变量的跨文件声明
0、要点:
- 我们知道当主调函数和被调函数在同一个.c文件中时,外部变量只需要写在程序的最上方,就能成为全局变量,进而被所有的函数使用。
- 我们也知道同一个源文件(同一个目录)下,全局变量的作用范围是所有的.c文件,现在进行拓展,仿照函数的分文件原理,看看某个.c文件是如何声明和调用其他.c文件中的全局变量的。
1、全局变量声明的注意点:
- 同一个全局变量可以进行多次声明。
- 大前提:以下提到的.c和.h文件,都要在同一个目录下,也就是在同一个源文件夹下。
- 我们学过:在编译预处理中会先找到.c文件中#include的头文件,将其进行展开替代#include这句话。
- extern是C语言中存储相关的关键字,外部的意思,一般在头文件中声明函数和全局变量。通俗的说,extern是声明语句而非定义语句,“extern + 全局变量声明”的功能是把别的文件中的全局变量给拿过来。extern关键字是不可以省略的。
2、全局变量的分文件原理:
- 要点:每个.c文件会各自分开编译,生成各自的OBJ文件后,最后全部链接在一起,形成一个可执行程序。所以编辑时要注意每个.c文件一定要是可以编译通过的(需要考虑包含了这个头文件后有没有问题)
- 特点:
- 每个.c文件都有与之对应的.h文件,为了方便查看,它们最好写成同名的,比如上述在新建了"main.c"文件之后,就立即建立一个"main.h"文件。每个.h文件中编辑与之对应的.c文件中的全局变量的声明,目的是方便其他.c文件在有需要的时候直接包含这些.h文件。
- xxx.h文件中一定要在全局变量声明前加“extern”关键字,因为编译时会将#include "xxx.h" 语句替换成xxx.h文件中的所有语句,如果不加extern关键字,那么这条语句就成为了一条定义全局变量的语句,最后包含这个头文件之后就会发生变量重定义的错误。
- 一种情况是可以不在.h文件中声明全局变量:在其他所有.c文件中用到这些全局变量时直接在其他.c文件中进行extern已经声明了。如上图中,我们已经在func.c文件中用extern int num;语句声明了这个全局变量。
- 另一种情况是为了查阅方便,需要在.h文件中声明全局变量:当我们在某些.c文件中通过extern声明的方法使用了在别的.c文件中定义的全局变量,为了方便我们去查找这个全局变量的来源,建议在所有.h文件中写上对应.c的所有全局变量的声明。
- 总之,为了保险和查阅方便,所有定义在某个.c文件中的全局变量都要在与其同名的头文件中用extern进行声明。
4.C语言的编译预处理
1、C语言编译过程回顾:
2、预处理之头文件包含:头文件的包含有两种方式。
- 用尖括号包含头文件:#include <xxx.h>,会在系统指定的路径(默认的头文件存储路径,装软件的时候编译器会有环境变量)下找头文件。找到头文件之后,会将其展开替换#include这句话。
- 用双引号包含头文件:#include "xxx.h",会先在当前目录下(即main.c所在路径)找头文件,如果找不到再到系统指定的路径下找。找到头文件之后,会将其展开替换#include这句话。举例:
- 注意1:对于写在当前路径的头文件xxx.h,若使用尖括号包含,那么是找不到的;
- 注意2:include可以用来包含.c文件,但最好不要这样用,因为include包含的文件会在预编译时被展开,如果一个.c被包含多次,那么就会在预处理时被展开多次,从而会导致函数、全局变量、结构体、宏名等的重定义。
- 注意3:include包含相同的头文件时,函数和全局变量的重复声明是绝对没有问题的,因为这是声明语句而非定义语句。而在一个程序中,变量的重复定义一定是会报错的,C语言规定不允许对一个变量重复初始化。
- 注意4:意外的是,在一个程序中,宏定义和结构体的重复定义也是不会报错的,因为当同一个宏被多次定义时,编译器会使用最后一次定义的值来替换宏的引用,当同一个结构体类型被多次定义时,编译器会使用最后一次定义的结构体类型。这意味着后续的定义会覆盖前面的定义,而且不会产生编译错误或警告。
- 注意5:对于头文件中的宏定义和结构体来说,宏定义的重复使用可能会导致意外的行为和错误,重复定义结构体类型可能会导致代码可读性和维护性的问题。所以建议在代码中避免重复宏定义,避免重复定义结构体。如果需要在多个源文件中使用相同的宏名以及结构体类型,可以将宏名以及结构体定义放在头文件中,并在需要使用的源文件中包含该头文件。
- 注意6:综上讨论,由于头文件中可能有宏定义以及结构体定义,所以要尽量避免头文件的重复包含,方法见下面第5点(选择性编译)。
- 注意7:预处理器只负责对源代码进行文本替换和宏展开等操作,不会对代码的语法进行验证。语法错误会在后续的编译阶段被检测到,并由编译器报告。编译器在编译阶段会对预处理后的代码进行词法分析、语法分析和语义分析,以及生成中间代码或目标代码。如果存在语法错误,编译器将在编译过程中报告错误,并指出错误的位置和类型。
3、预处理之定义宏:定义宏要用#define 宏名 值,宏是在预处理的时候进行替换的。宏定义能方便编程。
- 宏的作用范围是从定义的位置到本文件的末尾,它没有被分配内存用于存储,而是在预处理阶段直接被替换掉,成为代码区的一部分。虽然说#define语句可以写在程序的任意位置,但是通常把它写在文件开头
- 有办法在程序中终止宏的作用范围,使用#undef 宏名,如果有需要重新用到该宏名,需要重新宏定义。
- 允许发生重复宏定义的情况,这是因为当同一个宏被多次定义时,编译器会使用最后一次定义的值来替换宏的引用。如果在程序中途需要改变宏名的替换值,最好先用#undef的方法终止宏的作用范围,然后重新定义宏,尽量不要发生重定义宏。
- 一般来说,宏名全部用大写字母表示,用来与程序中的变量进行区分。
- 定义宏时,末尾不加分号。
- 定义宏时,宏值可以是数值常量(比如12、3.14),可以是单片机中的地址常量(比如P0),也可以是一个带参表达式(比如 a*b)。于是就有如下的两种定义宏的分类,不带参的宏定义,带参的宏定义。
- 不带参的宏:格式为 #define 宏名 值,比如#define len 12 ,比如#define PI 3.14,比如#define dataBuffer P0。举例:
- 带参的宏:格式为 #define 宏名(形参列表) 字符串表达式,比如#define S(a,b) (a)*(b)。这种写法与函数不同的一点是在实参为表达式而非单一值时,函数在传递实参值时,会先将实参求解出来;但是带参宏只是进行简单的替换。因此,为了避免这种宏的副作用,需要将宏定义时的表达式里面的每个形参都套个括号。
4、带参宏与带参函数的区别:
- 区别1(关于空间):定义函数时,定义一次、可以多次调用,即代码只有一份;而带参宏被调用多少次,就会被展开多少次,即代码会有多份,所以带参函数是节省空间的,带参宏是浪费空间的。
- 区别2(关于时间):在调用函数时需要压栈,函数执行完毕后需要弹栈,返回到被调用的地方(调用时保存了函数代码段的地址);而带参宏被调用时,直接执行指令,没有压栈弹栈的过程,所以带参函数是浪费时间的,带参宏是节省时间的。
- 区别3(关于形参类型):带参函数的形参是有类型的,带参宏的形参是没有类型的。
5、预处理之选择性编译:选择性编译有三种形式,选择性编译可以在源文件和头文件中使用。
- 概念:选择性编译是指在预处理阶段通过某些条件来判断哪些代码参与下一步的编译,哪些代码不参与编译。
- 原因:①在做项目的时候,想把某一个功能(代码块)给屏蔽掉,这时可以采用选择性编译的方法,即所谓的“软件裁剪”;②选择性编译可以防止头文件重复包含。
- 方式1(#ifdef):通常写在源文件(.c文件)中。
#ifdef 宏名 代码段1 //如果在当前的.c程序中的#ifdef这句话的上面定义过该宏,则编译器编译代码段1 #else //可有可无 代码段2 //如果在当前的.c程序中的#ifdef这句话的上面没有定义过该宏,则编译器编译代码段2 #endif //最后加#endif结尾
- 方式3(#if):通常写在源文件(.c文件)中,我们主要通过这种方式实现软件裁剪。
设置宏名用于选择编译的开关,比如: #define AAA 0 #if 表达式(一般用宏名) 代码段1 //如果表达式为真,则编译器编译代码段1 #else //可有可无 代码段2 //如果表达式为假,则编译器编译代码段2 #endif //最后加#endif结尾
- 方式3(#ifndef):通常写在头文件(.h文件)中,我们主要通过这种方式防止头文件重复包含:第一次包含头文件并在预处理被展开时,编译代码段,但第二次包含头文件并在预处理被展开时,使该代码段不参加编译(让它被注释掉)。
#ifndef 宏名 代码段1 //如果在当前的.h程序中的#ifndef这句话的上面定义过该宏,则编译器编译代码段1 #else //可有可无 代码段2 //如果在当前的.h程序中的#ifdef这句话的上面没有定义过该宏,则编译器编译代码段2 #endif //最后加#endif结尾 /* ------------------------------------------------------------------------------------ 1.注意在不同的.h文件中,上述宏名不可以重名,因为另外的.h会由于条件编译的原因就无法被包含进来 2.宏名的起名一般用:“__(两个下划线) + 头文件名(大写) + _(下划线) + H + __(两个下划线)”, 比如:__FUN_H__ ------------------------------------------------------------------------------------ */ 通常在.h文件中的做法(假设文件名是fun.h): #ifndef __FUN_H__ #define __FUN_H__ 头文件代码 #endif
5.分文件的引入(keil4)
1、分文件的目的:通过包含头文件的方式,避免编程代码过长的问题,实现模块化编程设计。
- 一个模块的测试开发:直接写在main.c中编程调试。
- 多个模块的测试开发:分文件实现模块化编程,以后哪个模块出问题,就去相应的分文件做具体修改。能够提高代码的复用性,实现代码的可维护性。
2、KEIL4中分文件建立流程:
3、分文件的原理:我以本项目作为背景,深入讨论一下如何实现分文件以及注意点。下面仅列举几个分文件说明问题。
- 要点:每个.c文件会各自分开编译,生成各自的OBJ文件后,最后全部链接在一起,形成一个可执行程序。所以编辑时要注意每个.c文件一定要是可以编译通过的(需要考虑包含了这个头文件后有没有问题)
- 特点:
- 每个.c文件都有与之对应的.h文件,为了方便查看,它们必须写成同名的。
- 包含头文件时,不区分大小写,这与我们包含头文件时可以写大写是一个道理。
- 每个.h文件中的代码要编辑与之对应的.c文件中的相关内容,可能包括以下几个方面:①外部函数的声明;②全局变量的声明;③宏定义;④自定义类型,等等,目的是方便其他.c文件在有需要的时候直接包含这些.h文件。
- 每个.h文件的代码段都要用选择性编译#ifndef语句包裹,目的是防止头文件被重复包含。
- 头文件中的全局变量声明可以无脑冲,直接写到头文件中:为了保险和查阅方便,所有定义在源文件中的全局变量都要在与其同名的头文件中用“extern”关键字声明。
- 头文件中的外部函数的声明可以无脑冲,直接写到头文件中:所有定义在源文件中的全局变量都要在与其同名的头文件中进行声明,关键字“extern”可加可不加。
- 头文件中的宏定义和结构体定义要斟酌1:考虑是否需要写到头文件中供别的源文件使用。
- 头文件中的宏定义和结构体定义要斟酌2:考虑这些定义是够会在头文件中的全局变量或者函数声明中被用到,如果被用到就需要在头文件中定义。
- 举例:如上图,我们在“uart.h”这一头文件中声明了“extern char serial_buffer[len]”这一个全局数组,这里len是一个宏名,如果我们不在“uart.h”中宏定义“#define len 12”,那么经过预处理后,len会被认为是一个没被定义的全局变量,当做0,最后编译器会报错,指出“uart.h”中len未被声明,数组serial_buffer有个非法的维度。
- 头文件中的宏定义和结构体定义要斟酌3:考虑头文件是否被自身的源文件包含。我们有时候为了省力,不想去考虑源文件中的函数书写顺序,会让源文件包含自身的头文件,这时候如果确实包含了自身的头文件,那么就需要斟酌,避免宏定义和结构体定义被重复。比如源文件中已经写了宏定义,源文件又包含了自身的头文件,那么头文件中就尽量不要再去定义该宏了。又比如头文件因为某些原因就需要去写宏定义,源文件又包含了自身的头文件,那么源文件中就尽量不要去定义该宏了。
- 举例:如上图,我们在“uart.h”这一头文件中声明了“extern char serial_buffer[len]”这一个全局数组,这里len是一个宏名,所以我们必须在头文件中定义宏“#define len 12”,同时源文件“uart.c”又包含了自身的头文件“uart.h”,那么“uart.c”中那就尽量不要去写该宏定义了。
- 单片机中的特殊点:
- 对于单片机中的可位寻址的寄存器,我们用sbit指令获取到它们的某一位,然后保存在一个全局变量中。试问:这种全局变量是否需要在头文件中声明呢?答:不要,也不能声明。因为单片机sbit指令前加extern是个语法错误,一般来说我们也不会在头文件中声明通过sbit指令访问得到的变量,因为对一个模块来说,往往某个I/O口是专属的,其他模块用不到,另外如果其他.c程序真的有需要,自己用sbit指令访问一下就可以了。
- 源文件中的中断处理程序(串口中断、外部中断、定时器中断),不需要在头文件中声明。
- 尽量将所有源文件中用到的全局变量都定义到位,用“extern”关键字声明到位(注释从哪个源文件中来的),方便阅读和修改程序。
6.项目硬件接线
1、接线示意图和实物图:
2、信号传输路线:
- 路线1:空气 ——> DHT11模块 ——> DHT11模块的DATA引脚 ——> 单片机P3^3引脚 ——> LCD1602的DB0~DB7口 ——> LCD液晶模块数据显示。
- 路线2:空气 ——> DHT11模块 ——> DHT11模块的DATA引脚 ——> 单片机P3^3引脚 ——> 单片机的TXD口 ——> PC串口助手数据显示。
- 路线3:空气 ——> DHT11模块 ——> DHT11模块的DATA引脚 ——> 单片机P3^3引脚 ——> 单片机的TXD口 ——> 蓝牙模块的RXD口 ——> 手机蓝牙助手app数据显示。
- 路线4:空气 ——> DHT11模块 ——> DHT11模块的DATA引脚 ——> 单片机P3^3引脚 ——> 单片机P1^6引脚 ——> 继电器IN引脚 ——> 继电器COM口和NO口 ——> 风扇所在电路。
- 路线5: 手机蓝牙助手app ——> 蓝牙模块的TXD口 ——> 单片机RXD口 ——> 单片机P1^6引脚 ——> 继电器IN引脚 ——> 继电器COM口和NO口 ——> 风扇所在电路。
7.项目实现流程
0、基本控制逻辑:温湿度数据管理系统
- 业务需求:
- 功能1:采集空气的温湿度数据显示在LCD1602液晶显示模块上。
- 功能2:采集空气的温湿度数据显示在手机app上。
- 功能3:当空气温度超过阈值30度时,通过步进电机打开风扇进行散热。
- 功能4:手机app可以控制风扇的开关。
- 控制逻辑:可以看出本项目包含4种模式
- 单片机通过单总线DATA解析从DHT11获取的温湿度数据。
- 温湿度数据通过P0这个I/O口组将数据实时显示在LCD1602显示屏上。
- 单片机通过继电器控制另一个电路,当温度到达某一个阈值时,就通过步进电机打开风扇。
- 温湿度数据通过透传设备到电脑或者手机app上显示。同时通过串口中断,实现透传设备控制风扇转动,这个透传设备可以选择我们学过的蓝牙模块,或者wifi模块,或者4g模块,这里为了方便起见,以蓝牙模块作为示例。
1、主程序:main.c
- 思路:
全局变量: 1. sbit指令找到P1这个I/O口组的第6位P1^6,它与继电器的IN引脚相连,控制风扇电路通断: sbit relay = P1^6; 2. 定义用于保存湿度字符串的全局数组,将来显示在LCD和串口上,格式为 "RH(%)=xx.xx": char r_Humidity[17]; // r_Humidity的传递路线为: //路线1: main.c ——> uart.c //路线2:main.c ——> extern LCD1602_showLine(char row, char column, char *str); 3. 定义用于保存温度字符串的全局数组,将来显示在LCD和串口上,格式为 "T(celcius)=xx.xx": char temperature[17]; // temperature的传递路线为: //路线1: main.c ——> uart.c //路线2:main.c ——> extern LCD1602_showLine(char row, char column, char *str); 全局变量的声明: 1. 声明从串口模块中获取表示强制关闭风扇的标志位: extern char forcedClose_Flag; 2. 声明从DHT11模块中获取表示温湿度有效数据的全局数组: extern char dataDHT[5];
1. 调用串口模块函数,初始化串口: UartInit(); 2. 调用延时模块函数,延时1秒,增加LCD成功显示数据的几率: Delay1000ms(); 3. 调用LCD模块函数,初始化LCD: LCD1602_Init(); 4. 调用延时模块函数,延时2秒,因为在DHT11传感器上电后,要等待1s以上 以越过不稳定状态: Delay1000ms(); Delay1000ms(); 5. while(1)死循环,每隔1秒进行数据读取,并在串口、LCD上显示 5.1 调用延时模块函数,延时1秒: Delay1000ms(); 5.2 调用DHT11模块函数,与DHT11完成一次通讯,读取DHT11模块数据(40bit)放在数组dataDHT[5]中: Read_Data_From_DHT11(); 5.3 判断温度是否大于30度,或者是否强制关闭 //判据是: dataDHT[2]>=30 || forcedClose_Flag==0 5.3.1 如果是,那么 连通继电器,打开风扇: relay = 0; 5.3.2 否则,如果温度是否小于30度,或者不强制关闭,那么 //判据是: dataDHT[2]<30 || forcedClose_Flag==1 关闭继电器,关闭风扇: relay = 1; 5.4 调用API1. 通过dataDHT[]构建要显示的温湿度字符串,结果保存在温/湿度字符串中: build_Datas_Show(); 5.5 调用串口模块函数,将DHT11的温/湿度字符串发送给串口: Send_Data_From_DHT11(); 5.6 调用LCD模块函数,将DHT11的湿度字符串显示在LCD上: LCD1602_showLine(1,0,r_Humidity); 5.7 调用LCD模块函数,将DHT11的温度字符串显示在LCD上: LCD1602_showLine(2,0,temperature);
/* 一级函数:f1 */ f1. 封装通过dataDHT[]构建温湿度字符串的API: void build_Datas_Show(); f1.1 逐字符往字符数组r_Humidity[]中写入湿度字符串,格式为"RH(%):xx.xx": f1.1.1 写入字符子串"RH(%):": r_Humidity[0] = 'R'; ................... r_Humidity[5] = ':'; f1.1.2 根据数字到字符的转化方式,保存湿度整数位(2位),它们在字符数组dataDHT的第0个元素: r_Humidity[6] = dataDHT[0]/10 + 0x30; r_Humidity[7] = dataDHT[0]%10 + 0x30; f1.1.3 写入字符'.',小数点: r_Humidity[8] = '.'; f1.1.4 根据数字到字符的转化方式,保存湿度小数位(2位),它们在字符数组dataDHT的第1个元素: r_Humidity[9] = dataDHT[1]/10 + 0x30; r_Humidity[10] = dataDHT[1]%10 + 0x30; f1.2 逐字符往字符数组r_Humidity[]中写入湿度字符串,格式为"T(celcius):xx.xx": f1.1.1 写入字符子串"T(celcius):": temperature[0] = 'T'; ................... temperature[10] = ':'; f1.1.2 根据数字到字符的转化方式,保存温度整数位(2位),它们在字符数组dataDHT的第2个元素: temperature[11] = dataDHT[2]/10 + 0x30; temperature[12] = dataDHT[2]%10 + 0x30; f1.1.3 写入字符'.',小数点: temperature[13] = '.'; f1.1.4 根据数字到字符的转化方式,保存温度小数位(2位),它们在字符数组dataDHT的第3个元素: temperature[14] = dataDHT[3]/10 + 0x30; temperature[15] = dataDHT[3]%10 + 0x30;
- 代码:
#include "reg52.h" #include "intrins.h" #include "main.h" #include "uart.h" #include "lcd1602.h" #include "delay.h" #include "dht11.h" sbit relay = P1^6; //继电器的IN引脚(低电平触发),控制风扇电路通断 char r_Humidity[17]; //将来显示在LCD和串口上的湿度字符串 char temperature[17]; //将来显示在LCD和串口上的温度字符串 extern char forcedClose_Flag; //From:"uart.c" extern char dataDHT[5]; //From:"dht11.c" void main(void) { UartInit(); Delay1000ms();//测试:增加LCD成功显示数据的几率 LCD1602_Init(); //DHT11传感器上电后,要等待1s以上 以越过不稳定状态 Delay1000ms(); Delay1000ms(); while(1){ //每隔一秒进行数据读取,并串口显示 Delay1000ms(); Read_Data_From_DHT11(); if(dataDHT[2] >= 30 || forcedClose_Flag == 0){ //这里手动开关风扇有差不多1s的延时,若想消除可以用外部中断(略) relay = 0; }else if(dataDHT[2] < 30 || forcedClose_Flag == 1){ relay = 1; } build_Datas_Show(); Send_Data_From_DHT11(); LCD1602_showLine(1,0,r_Humidity); LCD1602_showLine(2,0,temperature); } } void build_Datas_Show() { /* 湿度显示:RH(%):xx.xx */ r_Humidity[0] = 'R'; r_Humidity[1] = 'H'; r_Humidity[2] = '('; r_Humidity[3] = '%'; r_Humidity[4] = ')'; r_Humidity[5] = ':'; r_Humidity[6] = dataDHT[0]/10 + 0x30; //湿度整数位 r_Humidity[7] = dataDHT[0]%10 + 0x30; r_Humidity[8] = '.'; r_Humidity[9] = dataDHT[1]/10 + 0x30; //湿度小数位 r_Humidity[10] = dataDHT[1]%10 + 0x30; r_Humidity[11] = '\0'; /* 温度显示:T(celcius):xx.xx */ temperature[0] = 'T'; temperature[1] = '('; temperature[2] = 'c'; temperature[3] = 'e'; temperature[4] = 'l'; temperature[5] = 'c'; temperature[6] = 'i'; temperature[7] = 'u'; temperature[8] = 's'; temperature[9] = ')'; temperature[10] = ':'; temperature[11] = dataDHT[2]/10 + 0x30; //温度整数位 temperature[12] = dataDHT[2]%10 + 0x30; temperature[13] = '.'; temperature[14] = dataDHT[3]/10 + 0x30; //温度小数位 temperature[15] = dataDHT[3]%10 + 0x30; temperature[16] = '\0'; }
2、“DHT11”模块:DHT11模块相关的分文件:dht11.c
- 思路:
全局变量: 1. sbit指令找到P3这个I/O口组的第3位P3^3,它与DHT11模块的DATA数据线相连, 用来输出主机启动信号和接收数据: sbit dht11 = P3^3; 2. 用于保存DHT11和单片机一次通讯所传输的有效数据,一共40bit: char dataDHT[5]; //dataDHT[5]的传输路线为: //路线1:dht11(P3.3) ——> API2: Read_Data_From_DHT11() ——> bitFlag ——> temp ——> dataDHT[i] //路线2:dataDHT[] ——> main.c ——> 温/湿度字符串 ——> uart.c ——> SBUF ——> PC的串口助手,蓝牙app //路线3:dataDHT[] ——> main.c ——> 温/湿度字符串 ——> LCD显示屏
/* 一级函数:f1、f2 */ f1. 封装启动DHT11模块的API: void DHT11_Start(); f1.1 根据DHT11模块的时序逻辑分析,总结出如下过程: dht11 = 1; dht11 = 0; 调用API2. 软件延时30ms: Delay30ms(); dht11 = 1; 空循环体,卡d点,直到DATA变成低电平:while(!(dht11==0)); 空循环体,卡e点,直到DATA变成高电平:while(!dht11); 空循环体,卡f点,直到DATA变成低电平:while(!(dht11==0)); f2. 封装读取DHT11模块数据(40bit)放在数组dataDHT[5]中的API: void Read_Data_From_DHT11(); f2.1 定义局部变量temp,用于移位存放1bit数据: char temp; f2.2 定义1bit数据的标志位,1代表读到的是1,0代表读到的是0: char bitFlag; f2.3 调用API1. 每次数据采集(40bit)前都要发送开始信号: DHT11_Start(); f2.4 for循环嵌套,实现40bit数据的采集,代表数组下标的外层循环变量i从0开始,<5时进入循环 f2.4.1 for循环,循环变量j从0开始,<8时进入循环 f2.4.1.1 根据DHT11模块的时序逻辑分析,总结出如下过程: 空循环体,卡g点,直到DATA变成高电平:while(!dht11); 看时序图, 调用API4. 软件延时50微秒: Delay50us(); f2.4.1.2 紧接着判断数据线DATA上的电平是否是高电平,判据是dht11 == 1 f2.4.1.2.1 如果是,说明该bit是高电平,那么: 将该位数据以标志位形式保存在标志位变量bitFlag中: bitFlag = 1; 空循环体,卡f’点,直到DATA变成低电平:while(!(dht11==0)); f2.4.1.2.2 否则,说明该bit是低电平,那么: 将该位数据以标志位形式保存在标志位变量bitFlag中: bitFlag = 0; f2.4.1.3 通过移位和位运算,将1bit数据保存在变量temp中: temp = tem << 1; temp |= bitFlag; f2.4.2 经过内层循环,将已经获取到的8bit数据依次保存到字符数组dataDHT中: dataDHT[i] = temp;
- 代码:
#include "reg52.h" #include "delay.h" #include "dht11.h" sbit dht11 = P3^3; //DHT11模块的DATA数据线 char dataDHT[5]; //温湿度数据 void DHT11_Start() { dht11 = 1; dht11 = 0; Delay30ms(); dht11 = 1; while(!(dht11==0)); //卡d点 while(!dht11); //卡e点 while(!(dht11==0)); //卡f点 } void Read_Data_From_DHT11() { char i; //轮 char j; //每轮读多少次 char temp; //移位 char bitFlag; //该bit的标志位,1代表读到的是1,0代表读到的是0 DHT11_Start(); //每次数据采集(40bit)前都要发送开始信号 for(i=0; i<5; i++){ for(j=0; j<8; j++){ while(!dht11); //卡g点 Delay50us(); if(dht11 == 1){ //说明该bit是高电平 bitFlag = 1; while(!(dht11==0)); }else{ //说明该bit是低电平 bitFlag = 0; } temp = temp << 1; //temp <<= 1; temp |= bitFlag; } dataDHT[i] = temp; } }
3、“LCD”模块:LCD1602相关的分文件:lcd1602.c
- 思路:
宏定义: 1. 定义符号dataBuffer,用它代表P0这个I/O口组: #define dataBuffer P0 //dataBuffer的传递路线为: //路线1:API1. LCD_write_cmd(char cmd); ——> cmd ——> dataBuffer ——> LCD //路线2:API2. LCD_write_data(char datashow); ——> datashow ——> dataBuffer ——> LCD //路线3:API4. check_busy() ——> LCD ——> dataBuffer ——> temp 全局变量: 1. sbit指令找到P1这个I/O口组的第0位P1^0,把它与LCD的RS(LCD的数据/指令寄存器选择位)相连,用来输出指令给LCD: sbit RS = P1^0; 2. sbit指令找到P1这个I/O口组的第0位P1^1,把它与LCD的RW(LCD的读/写选择位)相连,用来输出指令给LCD: sbit RW = P1^1; 3. sbit指令找到P1这个I/O口组的第0位P1^4,把它与LCD的E(LCD的使能信号)相连,用来输出指令给LCD: sbit EN = P1^4;
/*一级函数:f1、f2、f3、f5*/ f1. 封装往LCD液晶显示模块写指令的API: void LCD_write_cmd(char cmd); //形参cmd是要写入的指令(地址) f1.1 调用API4,对LCD操作前检测忙信号: check_busy(); f1.2 RS低电平时,指令寄存器选择: RS = 0; f1.3 RW低电平,表示写操作: RW = 0; f1.4 根据写操作的时序分析,总结出如下过程: EN = 0; _nop_(); dataBuffer = cmd; _nop_(); EN = 1; _nop_(); EN = 0; _nop_(); f2. 封装往LCD液晶显示模块写内容的API: void LCD_write_data(char datashow); //形参datashow是要写入的内容 f2.1 调用API4,对LCD操作前检测忙信号: check_busy(); f2.2 RS高电平时,数据寄存器选择: RS = 1; f2.3 RW低电平,表示写操作: RW = 0; f2.4 根据写操作的时序分析,总结出如下过程: EN = 0; _nop_(); dataBuffer = datashow; _nop_(); EN = 1; _nop_(); EN = 0; _nop_(); f3. 封装初始化LCD液晶显示模块的API: void LCD1602_Init(); f3.1 调用延时模块函数,软件延时15ms: Delay15ms(); f3.2 调用API1,写指令38H(不检测忙信号): LCD_write_cmd(0x38); f3.3 调用延时模块函数,软件延时5ms: Delay5ms(); f3.4 显示模式设置: 调用API4,检测忙信号: check_busy(); 调用API1,写指令38H: LCD_write_cmd(0x38); f3.5 显示关闭: 调用API4,检测忙信号: check_busy(); 调用API1,写指令08H: LCD_write_cmd(0x08); f3.6 显示清屏: 调用API4,检测忙信号: check_busy(); 调用API1,写指令01H: LCD_write_cmd(0x01); f3.7 显示光标移动设置: 调用API4,检测忙信号: check_busy(); 调用API1,写指令06H: LCD_write_cmd(0x06); f3.8 显示开机光标设置: 调用API4,检测忙信号: check_busy(); 调用API1,写指令0CH: LCD_write_cmd(0x0C); f5. 封装在LCD液晶上显示一行字符串的API: void LCD1602_showLine(char row,char column,char *str); //形参row是行(1~2),column是列(0~15),str是要显示的字符串 f5.1 定义一个字符指针变量p用来保存字符串首地址: char *p = str; f5.2 switch选择语句,表达式为row f5.2.1 当row为1时:表示在第一行显示字符串 f5.2.1.1 往LCD显示模块中写起始地址: 调用API4,检测忙信号: check_busy(); 调用API1,告知显示地址为第1行第column列,LCD_write_cmd(0x80+column); f5.2.1.2 while循环,控制循环的变量是*p,当*p != '\0' 时进入循环,发送要显示的内容: 调用API4,检测忙信号: check_busy(); 调用API2. 往LCD显示模块中写当前字符指针p所在位置的字符: LCD_write_data(*p); 修改循环变量p的值,让指针p偏移: p++; f5.2.1.3 break提前退出当前选择控制语句: break; f5.2.2 当row为2时:表示在第二行显示字符串 f5.2.2.1 往LCD显示模块中写起始地址: 调用API4,检测忙信号: check_busy(); 调用API1,告知显示地址为第2行第column列,LCD_write_cmd(0x80+0x40+column); f5.2.2.2 while循环,控制循环的变量是*p,当*p != '\0' 时进入循环,发送要显示的内容: 调用API4,检测忙信号: check_busy(); 调用API2. 往LCD显示模块中写当前字符指针p所在位置的字符: LCD_write_data(*p); 修改循环变量p的值,让指针p偏移: p++; f5.2.2.3 break提前退出当前选择控制语句: break;
/* 二级函数:f4*/ f4. 封装检测(读取)忙信号的API: void check_busy(); f4.1 将从P0这个I/O口组获取到的LCD的8位数据线的数据,保存在字符变量temp中: char temp = 0x80; //内在逻辑:temp中包含了忙信号的标志位(bit7),标志位是1则表示LCD正忙,我们还没读取前 //就让51单片机认为LCD正忙,所以初始化为0x80(1000 0000),这样方便一会儿进入循环 f4.2 把LCD的busy这个状态做的更彻底一点,让P0这个I/O口组的bit7是1: dataBuffer = 0x80; f4.3 while循环,一直检测忙信号,直到检测到不忙,判据是:!((temp & 0x80)==0) //语法逻辑:用!表示“直到”,用 (temp & 0x80)==0 表示不忙。也就是说不忙就退出循环,不再检测 f4.3.1 RS低电平时,指令寄存器选择: RS = 1; f4.3.2 RW高电平,表示读操作: RW = 1; f4.3.3 根据读操作的时序分析,总结出如下过程: EN = 0; _nop_(); EN = 1; _nop_(); temp = dataBuffer; _nop_(); EN = 0; _nop_();
#include "reg52.h" #include "intrins.h" #include "delay.h" #include "lcd1602.h" #define dataBuffer P0 //LCD的8位数据线,刚好用dataBuffer这个I/O口组 sbit RS = P1^0; //LCD的数据/指令寄存器选择位 sbit RW = P1^1; //LCD的读/写选择位 sbit EN = P1^4; //LCD的使能信号 void LCD_write_cmd(char cmd) { check_busy(); RS = 0; //RS低电平时,指令寄存器选择,将1个字符写在数据线上告诉LCD这是指令 RW = 0; EN = 0; _nop_(); dataBuffer = cmd; _nop_(); EN = 1; _nop_(); EN = 0; _nop_(); } void LCD_write_data(char datashow) { check_busy(); RS = 1; //RS高电平时,数据寄存器选择,将1个字符写在数据线上告诉LCD这是内容 RW = 0; EN = 0; _nop_(); dataBuffer = datashow; _nop_(); EN = 1; _nop_(); EN = 0; _nop_(); } void LCD1602_Init() { Delay15ms(); //(1)延时15ms LCD_write_cmd(0x38); //(2)写指令38H(不检测忙信号) Delay5ms(); //(3)延时5ms //(4)以后每次写指令,读/写数据操作均需要检测忙信号 check_busy(); LCD_write_cmd(0x38); //(5)写指令38H:显示模式设置 check_busy(); LCD_write_cmd(0x08); //(6)写指令08H:显示关闭 check_busy(); LCD_write_cmd(0x01); //(7)写指令01H:显示清屏 check_busy(); LCD_write_cmd(0x06); //(8)写指令06H:显示光标移动设置 check_busy(); LCD_write_cmd(0x0C); //(9)写指令0CH:显示开机光标设置 } void check_busy() { char temp = 0x80; //一开始就busy dataBuffer = 0x80; while( !((temp & 0x80)==0) ){ //一直检测忙信号,直到检测到不忙(temp的bit7为高电平代表忙) RS = 0; RW = 1; EN = 0; _nop_(); EN = 1; _nop_(); temp = dataBuffer; _nop_(); EN = 0; _nop_(); } } void LCD1602_showLine(char row, char column, char *str) { char *p = str; switch(row){ case 1: check_busy(); LCD_write_cmd(0x80+column); //选择要显示的地址 while(*p != '\0'){ check_busy(); LCD_write_data(*p); //发送要显示的字符(不用发指令让光标移动,光标会自动后移) p++; } break; case 2: check_busy(); LCD_write_cmd(0x80+0x40+column); //选择要显示的地址 while(*p != '\0'){ check_busy(); LCD_write_data(*p); //发送要显示的字符(不用发指令让光标移动,光标会自动后移) p++; } break; default : break; } }
- 代码:
#include "reg52.h" #include "intrins.h" #include "delay.h" #include "lcd1602.h" #define dataBuffer P0 //LCD的8位数据线,刚好用dataBuffer这个I/O口组 sbit RS = P1^0; //LCD的数据/指令寄存器选择位 sbit RW = P1^1; //LCD的读/写选择位 sbit EN = P1^4; //LCD的使能信号 void LCD_write_cmd(char cmd) { check_busy(); RS = 0; //RS低电平时,指令寄存器选择,将1个字符写在数据线上告诉LCD这是指令 RW = 0; EN = 0; _nop_(); dataBuffer = cmd; _nop_(); EN = 1; _nop_(); EN = 0; _nop_(); } void LCD_write_data(char datashow) { check_busy(); RS = 1; //RS高电平时,数据寄存器选择,将1个字符写在数据线上告诉LCD这是内容 RW = 0; EN = 0; _nop_(); dataBuffer = datashow; _nop_(); EN = 1; _nop_(); EN = 0; _nop_(); } void LCD1602_Init() { Delay15ms(); //(1)延时15ms LCD_write_cmd(0x38); //(2)写指令38H(不检测忙信号) Delay5ms(); //(3)延时5ms //(4)以后每次写指令,读/写数据操作均需要检测忙信号 check_busy(); LCD_write_cmd(0x38); //(5)写指令38H:显示模式设置 check_busy(); LCD_write_cmd(0x08); //(6)写指令08H:显示关闭 check_busy(); LCD_write_cmd(0x01); //(7)写指令01H:显示清屏 check_busy(); LCD_write_cmd(0x06); //(8)写指令06H:显示光标移动设置 check_busy(); LCD_write_cmd(0x0C); //(9)写指令0CH:显示开机光标设置 } void check_busy() { char temp = 0x80; //一开始就busy dataBuffer = 0x80; while( !((temp & 0x80)==0) ){ //一直检测忙信号,直到检测到不忙(temp的bit7为高电平代表忙) RS = 0; RW = 1; EN = 0; _nop_(); EN = 1; _nop_(); temp = dataBuffer; _nop_(); EN = 0; _nop_(); } } void LCD1602_showLine(char row, char column, char *str) { char *p = str; switch(row){ case 1: check_busy(); LCD_write_cmd(0x80+column); //选择要显示的地址 while(*p != '\0'){ check_busy(); LCD_write_data(*p); //发送要显示的字符(不用发指令让光标移动,光标会自动后移) p++; } break; case 2: check_busy(); LCD_write_cmd(0x80+0x40+column); //选择要显示的地址 while(*p != '\0'){ check_busy(); LCD_write_data(*p); //发送要显示的字符(不用发指令让光标移动,光标会自动后移) p++; } break; default : break; } }
4、“串口”模块:串口相关的分文件:uart.c
- 思路:
宏定义: 1. 定义符号常量len,用它代表用于接收SBUF中缓冲字符串的全局数组的长度: #define len 12 全局变量: 1. sfr指令直接找到AUXR寄存器: sfr AUXR = 0X8E; //因为AUXR没有在reg52.h中声明 2. sbit指令找到P1这个I/O口组的第6位P1^6,它与继电器的IN引脚相连,控制风扇电路通断: sbit relay = P1^6; 3. sbit指令找到P3这个I/O口组的第6位P3^6,它代表D6这个LED: sbit ledD6 = P3^6; 4. 定义一个用于接收串口缓冲区字符串的全局数组serial_buffer: char serial_buffer[len]; //serial_buffer的传递路线为:SBUF ——> 串口中断(中断4)——> 临时字符变量temp 5. 定义表示强制关闭风扇的标志位,并默认强制关闭: char forcedClose_Flag = 1; //forcedClose_Flag的传递路线是:uart.c ——> main.c 全局变量的声明: 1. 声明从main.c中获取的湿度字符串: extern char r_Humidity[17]; 2. 声明从main.c中获取的温度字符串: extern char temperature[17];
中断: 中断4: 封装串口中断的中断服务程序, void Uart_Routine() interrupt 4 4.1 定义一个静态全局区的静态变量,用来表示数组serial_buffer的下标: static int i = 0; 4.2 定义一个临时字符变量temp,用于检测关键字眼,保证我们的字符串是从字符数粗的第0位开始存放的。 char temp; 4.3 中断处理程序中,对于接收中断的响应,判据是RI == 1 4.3.1 在接受到1字节数据后,程序复位RI: RI = 0; 4.3.2 串口缓冲区接收到的字符先存放在临时变量temp中: temp = SBUF; 4.3.3 从数据缓冲寄存器SBUF中读到字符后,根据我们提前设计好的关键字眼,关心: "cmd:open"的':',判据是temp==':' 4.3.3.1 如果是,那么需要从头开始存放: i = 0; 4.3.3.2 否则,那么什么也不做,继续往下执行 4.3.4 将temp的值保存在数组serial_buffer的第i个元素中: serial_buffer[i] = temp; 4.3.5 偏移数组下标: i++; 4.3.6 判断字符数组serial_buffer是否存满了,判据是 i == len //内在逻辑:由于serial_buffer长度的限制,当字符串超过len时,我们需要覆盖掉原先的字符 4.3.6.1 如果是,那么需要从头开始存放: i = 0; 4.3.6.2 否则,那么什么也不做,继续往下执行 4.3.7 通过字符数组的第0位和第2位捕捉关键字眼,判断蓝牙模块是否收到透传数据":open",判据是 serial_buffer[0]==':' && serial_buffer[1]=='o' && serial_buffer[2]=='p' 4.3.7.1 如果是, 修改代表强制关闭风扇的标志位,0代表不关闭,也就打开风扇: forcedClose_Flag = 0; 点亮D6: ledD6 = 0; 有效指令后清空字符数组: memset(serial_buffer,'\0',len); 4.3.8.2 否则,如果蓝牙模块收到透传数据":close" 修改代表强制关闭风扇的标志位,1代表关闭,也就是关闭风扇: forcedClose_Flag = 1; 熄灭D6: ledD6 = 1; 有效指令后清空字符数组: memset(serial_buffer,'\0',len); 4.4 中断处理程序中,对于发送中断的响应,判据是TI == 1 暂时不做任何事情
/* 一级函数:f1、f4 */ f1. 封装初始化串口的API: void UartInit(void); f1.1 禁用ALE信号: AUXR = 0X01; f1.2 让串口以方式1工作(8位UART,可变波特率),并允许串口接收: SCON = 0x50; f1.3 让定时器1以8位重载工作模式工作: TMOD &= 0xDF; TMOD |= 0x20; f1.4 根据波特率为9600,波特率不翻倍,设置定时器1的初值: TH1 = 0xFD; TL1 = 0xFD; f1.5 定时器开始数数: TR1 = 1; f1.6 开启串口中断: EA = 1; ES = 1; f4. 封装将DHT11模块测得的温湿度数据发送给串口的API: void Send_Data_From_DHT11(); f4.1 调用API3. 将湿度字符串r_Humidity发送给给串口:sendString(r_Humidity); f4.2 调用调用API3. 给串口发送字符串"\r\n",表示换行: sendString("\r\n"); f4.3 调用API3. 将温度字符串temperature发送给给串口:sendString(temperature); f4.4 调用API3. 给串口发送字符串"\r\n\r\n",表示显示空行: sendString("\r\n\r\n");
/* 二级函数:f3 */ f3. 封装给PC发送字符串的API: void sendString(char *str); //形参是字符串的地址 f3.1 定义一个字符指针变量p用来保存字符串首地址: char *p = str; f3.2 while循环,控制循环的变量是*p,当*p != '\0' 时,进入循环,进行单个字符的发送 f3.2.1 通过指针间接访问字符串字符,再调用API2. 发送单个字符: sendByte(*p); f3.2.2 修改循环变量p的值,让指针p偏移: p++;
/* 三级函数:f2 */ f2. 封装定时给PC发送一个字符的API: void sendByte(char data_msg); //形参是字符值 f2.1 往SBUF寄存器中写入字符data_msg: SBUF = data_msg; f2.2 根据串口发送中断触发位TI,利用空循环体暂停程序,直到TI变成1: while(!TI); f2.3 程序复位TI: TI = 0;
- 代码:
#include "reg52.h" #include <string.h> #include "uart.h" #include "main.h" sfr AUXR = 0x8E; sbit relay = P1^6; sbit ledD6 = P3^6; char serial_buffer[len]; //"uart.h"中宏名len = 12 char forcedClose_Flag = 1; //风扇标志位,默认关 extern char r_Humidity[17]; //From:"main.c" extern char temperature[17]; //From:"main.c" void UartInit(void) //9600bps@11.0592MHz { AUXR = 0x01; SCON = 0x50; //8位UART,允许串口接收 TMOD &= 0xDF; TMOD |= 0x20; //定时器8位重载工作模式 TH1 = 0xFD; TL1 = 0xFD; //9600波特率初值 TR1 = 1; EA = 1; ES = 1; //开启串口中断 } void sendByte(char data_msg) { SBUF = data_msg; // Delay10ms(); while(TI == 0); TI = 0; } void sendString(char *str) { char *p = str; while(*p != '\0'){ sendByte(*p); p++; } } void Send_Data_From_DHT11() { sendString(r_Humidity); sendString("\r\n"); sendString(temperature); sendString("\r\n\r\n"); } void Uart_Routine() interrupt 4 { static int i = 0;//静态变量,被初始化一次 char temp; /* 中断处理程序中,对于接收中断的响应 */ if(RI)//中断处理函数中,对于接收中断的响应 { RI = 0;//清除接收中断标志位 temp = SBUF; if(temp == ':'){ i = 0; } serial_buffer[i] = temp; i++; if(i == len) i = 0; //风控指令 if(serial_buffer[0] == ':' && serial_buffer[1] == 'o' && serial_buffer[2]=='p'){ forcedClose_Flag = 0;//风扇转 ledD6 = 0;//点亮D6 memset(serial_buffer, '\0', len); }else if(serial_buffer[0] == ':' && serial_buffer[1] == 'c' && serial_buffer[2]=='l'){ forcedClose_Flag = 1;//风扇不转 ledD6 = 1;//熄灭D6 memset(serial_buffer, '\0', len); } } /* 中断处理程序中,对于发送中断的响应 */ if(TI == 1){ // 暂时不做任何事情 } }
5、“软件延时”模块:软件延时相关的分文件:delay.c
- 思路:
f1. 封装软件延时30ms的API,用于API1的时序分析: void Delay30ms(); f2. 封装软件延时50微秒的API,用于DHT11传输数据中每1bit的时序分析: void Delay50us(); f3. 封装软件延时1s的API,用于DHT11模块上电后的稳定,以及每隔1s读取DHT11数据: void Delay1000ms(); f4. 封装软件延时15ms的API,用于LCD初始化: void Delay15ms(); f5. 封装软件延时5ms的API,用于LCD初始化: void Delay5ms();
- 代码:
#include "intrins.h" void Delay30ms() //@11.0592MHz { unsigned char i, j; i = 54; j = 199; do { while (--j); } while (--i); } void Delay50us() //@11.0592MHz { unsigned char i; _nop_(); i = 20; while (--i); } void Delay1000ms() //@11.0592MHz { unsigned char i, j, k; _nop_(); i = 8; j = 1; k = 243; do { do { while (--k); } while (--j); } while (--i); } void Delay15ms() //@11.0592MHz { unsigned char i, j; i = 27; j = 226; do { while (--j); } while (--i); } void Delay5ms() //@11.0592MHz { unsigned char i, j; i = 9; j = 244; do { while (--j); } while (--i); }