首页 > 版块 > STM32 > 帖子正文

STM32 C语言预处理深入解析

张角 发布于 2021-10-27 14:14
收藏 1 回复 0 浏览 332 原创

大家好,我是张飞实战电子张角老师! 我们今天对C语言的预处理指令做一个总结其实C语言的预处理指令主要有三大类文件包含宏定义和条件编译我们首先要明白什么是预处理第二个C语言编译之前为什么需要预处理或者说这些预处理的添加能够给C语言的编程带来什么好处?再一个,我们就展开讲解一下C语言常用的三种预处理指令,看看他们如何使用以及有各自有什么优缺点。

所谓预编译或者说预处理,就是在编译之前进行的一些操作,这其中比较主要的可能就是展开操作。那么C语言为什么要添加这些预编译指令呢?从设计者的角度上来看,这些指令肯定会对C语言的编程带来效率的提升,C语言才会保留这些关键字。因为整体上各个预处理指令之间的关联性比较小,我们这里没有办法给他们的优点和缺点做一个统一的定论,这里只能是展开来讲一个一个仔细讲解。

第一个我们我来看一下,头文件指令#include”。那么在回答这个文件之前,我们要先讨论一下头文件存在的必要性,或者说为什么C语言要分为头文件和源文件。为什么C语言不能像C#Java那样,不需要头文件么?

第一个我们从C语言发展的历史来看或者说从共享代码对存储空间的使用量上来看,C语言头文件的必要性。之所以C语言一定需要头文件,一个最本质的原因,是因为C编译器编译出来的二进制码(.o, .obj, .lib, .dll)不包括自我描述的符号信息,那么要复用这种可执行代码的话,需要另外的文件。C#Java的可执行代码可以自带元数据信息,但是这也意味着运行时的内存需求的增加,毕竟这种自我描述的数据对最终用户来说是无用的。当然在C#Java被发明的时候,存储空间以及很便宜了。但是C被发明的时候,64K的内存是400多美金。一个程序经常几十个模块,运行的程序为某给模块去浪费几十K是不可能的,价格太高了。那么头文件也就应运而生了,可执行代码的元数据信息是在头文件中保存的,头文件可以充当这些模块复用的桥梁,那么进而就可以降低可执行模块复用时对存储空间的需求。

第二,我们从编程器的角度来看一下。C时代的时候编译器比较简单,是固定的编译和链接两个过程,编译一次只处理一个文件,进行预处理之后,头文件会插入到这一个文件里,不同源代码文件的处理时独立的,这样如果头文件里面定义了一个函数的实现,编译的时候所有引用这个头文件的源码文件,生成的obj里都会有这个符号。而链接是通用的链接程序,从汇编时代就用的工具,没有什么高级功能,同一个符号链接时出现两次是会报错的。但是,我们又说了,每个文件的编译是独立的,所以如果实现不在当前源文件里面,调用的时候编译器就不知道这个函数的类型和签名,没法生成调用代码,所以必须在调用之前先声明一遍。如果不把声明写在头文件里面,就必须在每个用到这个函数的源文件里都声明一遍,很不方便,所以综合之后的解决方案就是实现写源码文件里面,声明写头文件里面。

最后,我们从知识产权保护的角度上来分析一下。接口和实现分立之后,使用者只需要关注接口就行了,按照接口的说明使用就好了,不用关注具体是如何实现的。那么作为开发者呢,则可以对实现部分进行加密,那样是不是就可以保护一定的商业秘密了。具体到头文件来说,我们在头文件中进行的函数声明,其实就有这个作用。

也就是说基于以上各种原因,头文件对于C语言来说,是一个确定的存在。那么就肯定要有一个关键字来处理头文件,那这个关键字就是“#include”。在程序正式编译之前,预处理程序会首先展开头文件中的函数以及变量声明。然后程序在编译的时候,对于没有在本文件中实现的变量或者函数,就会做好标记。然后在链接阶段,就会根据类型、名称信息,去其他可执行文件中去找在该文件中没有实现的同时声明过的变量或者函数,如果找到了,而且不重复,那么程序的编译就能够通过。如果没找到,或者找到两个,那么编译就不会成功。

具体到#include的用法,这里还有区别。#include <xx.h>,这种尖括号写法,预编译程序只会到系统指定的目录中去找这些头文件,看看他们是否存在。#include xx.h这种写法则不是,这种双引号类型的写法,预编译程序会首先到工程目录去找相应的头文件,如果找不到,预处理程序会再到系统指定的目录里面去找。那也就是说,如果是自己写的头文件,我们一定需要使用#include xx.h的写法;如果是系统头文件,两种写法都行,但是#include <xx.h>这种写法,效率应该会更高一些。

讲完了文件包含,我们看一下宏定义的预处理指令#define。这个指令大家应该都比较常见。使用宏定义,可以带来不少优点,比如可以避免意义相对模糊的数字出现,#define PI 3.1415926,那么我们就可以在程序中直接使用PI来代替那么一长串的数字。再一个,这个PI会在预编译的时候,直接进行值替代,并不需要为这些常量提供存储空间,那么就进一步提高了程序的执行效率。但是这个也会有隐患的,编译器这里只是进行展开,并不进行类型检查,与此对比的就是const的类型的变量,使用这个变量就有可能避免潜在的错误。再一个,像下面这个,#define FILE_PATH E:folder11.txt如果目录较长的话使用起来是不是非常不方便呀我们就可以直接使用FILE_PATH这个字符串去去代替较长的目录,这样是不是就方便了许多。

虽然#define,给我们带来了许多便利,但是使用它的时候,一定要小心再小心,否则编程的时候,就比较容易出错。

#define PCHAR char*

PCHAR p1,p2;

char** a = &p1;

char** b = &p2

像这段代码,其实就有问题。这里p2其实是char类型,并不是char*类型。对于这种类型的错误,可以使用typedef的方式来解决。

我们再举一个例子,#define MUL(A,B) A*B而在使用的时候,这样的调用:int a=1,b=2,c=3,d=0;d=MUL(a+b,c)经过编译时候展开,就变成了d=a+b*c而不是我们所希望的d=(a+b)*c其解决办法也很简单,就是给每个分量,都加上括号,就可以避免此类问题,在宏定义的时候,如此定义:#define MUL(A,B) ((A)*(B))不过有些时候加了括号也没有办法避免这些问题

但是使用#define来定义函数,确实可以减少系统开销,提高运行效率。为什么会这样呢?因为在C语言中,发生函数调用的时候,需要保留调用函数的现场,子函数执行完毕以后还有回复函数调用的现场,这都需要一定的时间。如果子函数执行的任务比较多,这点时间是可以忽略的,但是如果子函数的功能比较少,比如只是一个加法的操作,那这部分转换操作的开销就太大了。使用带参数的宏定义就不会出现这样的问题,因为他是在预处理阶段就进行了宏展开,在执行的时候不需要进行转换,即在当地执行。宏定义可以完成简单的操作,但是复杂的操作还是要借助函数调用来实现。另外,宏定义的代码如果比较长,预编译的时候,所有引用的部分的代码都需要展开,那么目标代码的空间就会相对较大。所以,我们还是要根据实际情况来决定是否使用宏定义。

实际上,针对宏定义函数的一些缺点,C++引入了inline函数这个关键字。inline函数,可以像宏定义的函数一样,直接展开,而不会发生函数调用的开销。但是呢,inline函数,本质上就是函数,会有参数以及返回执行类型的检查,使用的时候,相对要安全很多。

讲完了宏定义,我们就到了条件编译的部分。所谓条件编译,就是满足条件的时候,就编译,不满足条件的时候,就不编译。条件编译一个很重要的作用,就是可以提高程序的可移植性,我们可以根据不同的平台选择编译不同的程序。条件编译一般有三种格式,这里总结如下。

 

第一种:

#ifdef 标示符 (注意:标示符一般用#define命令定义)
程序段1
#else
程序段2
#endif

    第二种:

#ifndef 标示符 (注意:标示符一般用#define命令定义)
程序段1
#else
程序段2
#endif

    第三种:

#if 表达式 (注意:标示符一般用#define命令定义,如果表达式为真则编译程序1
程序段1
#else
程序段2
#endif

大家在实际使用的时候,参考这三种类型进行使用,就可以了。

我们今天对预编译指令的总结,就先到这里。下一篇文章,我们就谈谈其他的预编译指令。


参考资料:

1. 为什么C/C++要分为头文件和源文件 https://www.zhihu.com/question/280665935

2. C语言预编译的缺点 https://blog.csdn.net/weixin_39632379/article/details/117079935

3. C语言预处理详解 https://blog.csdn.net/czc1997/article/details/81079498

4. #define宏定义的优点和缺点 https://blog.csdn.net/u013910522/article/details/22672057

5. C语言提供的三种预处理命令 https://blog.csdn.net/xiongzebao/article/details/45586743

 


0 1
发表评论 侵权投诉
评论 (0)

声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表乌云踏雪网立场。

文章及其配图仅供工程师学习之用,如有内容图片侵权或者其他问题,请联系本站作侵删。