LV01-14-C语言-预处理
本文主要是C语言基础——预处理相关笔记,若笔记中有错误或者不合适的地方,欢迎批评指正😃。
点击查看使用工具及版本
Windows | windows11 |
Ubuntu | Ubuntu16.04的64位版本 |
VMware® Workstation 16 Pro | 16.2.3 build-19376536 |
SecureCRT | Version 8.7.2 (x64 build 2214) - 正式版-2020年5月14日 |
开发板 | 正点原子 i.MX6ULL Linux阿尔法开发板 |
uboot | NXP官方提供的uboot,NXP提供的版本为uboot-imx-rel_imx_4.1.15_2.1.0_ga(使用的uboot版本为U-Boot 2016.03) |
linux内核 | linux-4.15(NXP官方提供) |
STM32开发板 | 正点原子战舰V3(STM32F103ZET6) |
点击查看本文参考资料
参考方向 | 参考原文 |
#pragma、#error指令 | C语言 | 认识认识#pragma、#error指令_wx60b650682e725的技术博客_51CTO博客 |
#pragma的使用方法 | C语言 详细讲解#pragma的使用方法_C 语言_脚本之家 (jb51.net) |
#pragma的用法 | #pragma的用法 - Boblim - 博客园 (cnblogs.com) |
#paragma详解 | #paragma详解_Surenon的博客-CSDN博客 |
内存对齐 | C/C++内存对齐详解 - 知乎 (zhihu.com) |
一、预处理的概念
预处理就是指在进行编译的第一遍扫描(词法扫描和语法分析)之前所做的工作,它由预处理程序负责完成。当编译一个程序时,系统将自动调用预处理程序对程序中的 # 开头的预处理部分进行处理,处理完毕之后才会进入程序的编译阶段。
C 语言为我们提供了多种预处理功能,如宏定义,文件包含和条件编译等。
二、预定义
1.宏
在 C 语言源程序中,允许使用一个标识符来表示一串符号,称之为宏,被定义为宏的标识符称为宏名。在编译预处理的时候,对程序中出现的所有宏名,都会用宏定义中的符号串去代替,这被称为宏替换或者叫宏展开。
2.预定义符号串
在 C 语言中,有一些预定义的符号串,它们的值是字符串常量或者是十进制数字常量,通常是用于在调试程序时输出源程序的各项信息。
符号 | 常量类型 | 含义 |
__FILE__ | 字符串 | 正在预编译编译的文件名(字符串字面值) |
__LINE__ | 整数 | 文件的当前行号(十进制常量) |
__DATE__ | 字符串 | 文件被编译的日期 |
__TIME__ | 字符串 | 文件被编译的时间 |
__STDC__ | 整数 | 如果编译器遵循ANSI C,其值为1,否则未定义 |
__FUNCTION__ | 字符串 | 表示调用此预定义的函数名称 |
__func__ | 字符串 | 表示调用此预定义的函数名称 |
点击查看实例
1 |
|
在终端执行以下命令:
1 | gcc test.c -Wall # 编译程序 |
然后我们会看到有如下信息输出:
1 | The __FILE__ is : test.c |
3.宏定义
除了预定义的符号外,我们也可以自己定义宏,宏定义就是用一个标识符来表示一个字符串,如果在后面的代码中出现了该标识符,那么就全部替换成指定的字符串。
【注意】
(1)这里的字符串字可以是常数、表达式、格式串等。不仅仅是代表“字符串”。
(2)如果说,“字符串”是一个含参的表达式,一定要注意,先替换,再运算。
3.1无参宏定义
无参宏定义的宏名,也就是标识符,后边不带参数,定义的一般形式是:
1 |
点击查看各部分说明
# | 表示这是一条预处理命令 |
define | 宏定义命令 |
标识符 | 所定义的宏名(习惯上用全大写表示,但是也允许小写,看个人喜好喽),后边在程序中使用的宏都是直接使用宏名 |
字符串 | 可以是常数,表达式,格式串等 【说明】 字符串 与 (字符串) 似乎没有区别。 |
点击查看实例
1 |
|
在终端执行以下命令:
1 | gcc test.c -Wall # 编译程序 |
会看到有如下信息输出:
1 | The pi is : 3.141593 |
【说明】
(1)宏定义用宏名来表示一串符号,在宏展开的时候又以该符号串取代宏名,这只是一种简单的替换,符号串中可以包含任何字符,可以是常数,也可以是表达式,预处理程序不对它做任何检查。如果有错误的话,只能在编译已被宏展开后的源程序时被发现。
(2)宏定义不是声明或者语句,行尾不必加分号( ; ),如果加上分号( ; )了,就会连分号( ; )一起替换。不过我在测试的时候是直接报错了,这里注意一下就好啦。
(3)宏定义的作用域包括从宏定义命名起到源程序结束,如果要终止其作用域,我们可以使用 #undef 来取消宏作用域。
点击查看实例
1 |
|
在终端执行以下命令:
1 | gcc test.c -Wall # 编译程序 |
其中 pi 只会在 fun1() 中生效,会直接在 fun2() 定义时报错,会看到有如下信息输出:
1 | test.c: In function ‘fun2’: |
(4)宏名引用时不要写在 “ “ 中,否则预处理程序不会对其进行替换。
(5)宏定义允许嵌套,在宏定义的符号串中就可以使用已经定义过的宏名,在宏展开时由预处理程序进行层层替换。
(6)可以对输出做一个宏定义,以减少编写麻烦。但是这样格式就不能自己想怎样就怎样了,不过还是要看自己需求啦。
点击查看实例
1 |
|
在终端执行以下命令:
1 | gcc test.c -Wall # 编译程序 |
会看到有如下信息输出:
1 | 5 |
3.2含参宏定义
含参宏定义的宏名,也就是标识符,后边带参数,这个参数称为形参,调用的时候不仅要进行宏展开,还要传入实参。定义的一般形式是:
1 |
点击查看各部分说明
# | 表示这是一条预处理命令 |
define | 宏定义命令 |
标识符 | 所定义的宏名(习惯上用全大写表示,但是也允许小写,看个人喜好喽),后边在程序中使用的宏都是直接使用宏名 |
(形参表) | 形参的列表,可以有多个形参,用逗号 "," 分隔,形参最好用 () 括起来,以免出错 |
字符串 | 可以是常数,表达式,格式串等 【说明】字符串 与 (字符串) 似乎没有区别,但是在含参的宏中最好用 () 括起来,减小出错的概率。 |
调用的一般形式是:
1 | 标识符(实参表); |
【注意】
(1)定义的时候不要带分号( ; ),调用的时候就是语句了,这就需要带上分号( ; )。
(2)注意先替换,再运算。
点击查看实例
1 |
|
在终端执行以下命令:
1 | gcc test.c -Wall # 编译程序 |
会看到有如下信息输出:
1 | MAX(a, b) is : 5 |
【说明】
(1)含参宏定义中,宏名和形参表之间不可以有空格。
点击查看实例
1 |
这在处理的时候直接报错了,其实按理来说,这句相当于是一个无参宏定义,宏名为 MAX ,它代表 (a, b) (a > b)?a:b 。
(2)在含参的宏定义中,形参是不会被分配内存的,所以不必做类型的定义,这是与函数不同的。在含参宏定义中,这只是符号的替换,不存在值的传递。
(3)宏定义的形参相当于一个标识符,宏调用的实参可以是一个表达式。
点击查看实例
1 |
|
在终端执行以下命令:
1 | gcc test.c -Wall # 编译程序 |
会看到有如下信息输出:
1 | MAX(a, b) is : 6 |
3.3宏与函数
含参的宏与函数有着很类似的形式,但是他们却有着很大的不同之处:
属性 | 宏 | 函数 |
处理阶段 | 预处理阶段,只是符号串的简单的替换 | 编译阶段 |
代码长度 | 每次使用宏时,宏代码都被插入到程序中。因此,除了非常小的宏之外,程序的长度都将被大幅增长 | 除了inline 函数之外,函数代码只出现在一个地方,每次使用这个函数,都只调用那个地方的同一份代码 |
执行速度 | 更快 | 存在函数调用/返回的额外开销(inline函数除外) |
操作符优先级 | 宏参数的求值是在所有周围表达式的上下文环境中,除非它们加上括号,否则邻近操作符的优先级可能会产生不可预料的结果 | 函数参数只在函数调用时求值一次,它的结果值传递给函数,因此,表达式的求值结果更容易预测 |
参数求值 | 参数每次用于宏定义时,它们都将重新求值。由于多次求值,具有副作用的参数可能会产生不可预料的结果 | 参数在函数被调用前只求值一次,在函数中多次使用参数并不会导致多种求值问题,参数的副作用不会造成任何特殊的问题 |
参数类型 | 宏与类型无关,只要对参数的操作是合法的,它可以使用任何参数类型 | 函数的参数与类型有关,如果参数的类型不同,就需要使用不同的函数,即使它们执行的任务是相同的 |
3.4 # 与 ##
3.4.1 # 的用途
# 的功能是将其后面的宏参数进行字符串化操作( Stringfication ),简单说就是在对它所引用的宏变量,在预编译完成替换后同时在其左右各加上一个双引号。
例如,下边的测试程序:
1 |
|
然后我们在终端中输入以下命令进行预编译:
1 | gcc -E test.c -o test.i # 预编译 |
然后在文件的结束,我们会看到如下几行:
1 | # 前边的省略 ... ... |
我们会发现, mine 被替换为 “mine” 了。
3.4.2 ## 的用途
后边学习过程中,遇到了一个定义:
1 |
当时看的我一脸懵逼,查阅资料后,了解到,在宏定义中, ## 也就是两个 # 连用,称为连接符,主要是用于连接两个参数, ## 符会把传递过来的参数当成字符串进行替代。例如,下边的测试程序:
1 |
|
然后我们在终端中输入以下命令进行预编译:
1 | gcc -E test.c -o test.i # 预编译 |
然后在文件的结束,我们会看到如下几行:
1 | # 前边的省略 ... ... |
可以看到,我们传入的参数 x_ 在预编译后,与 fanily 连接起来了,变成了 x_family ,然后 a##e##b 变成了 AeB 。
三、文件包含
文件包含是 C 语言预处理的另一种方式,文件包含的一般形式是:
1 |
|
有木有觉得很熟悉呢,这经常用于引入对应的头文件( .h 文件)。
文件包含的处理过程很简单,就是将头文件的内容插入到该语句所在行的位置,从而把头文件和当前源文件连接成一个源文件,这与复制粘贴的效果相同。
点击查看两种写法的区别
使用尖括号 < > 和双引号 “ “ 的区别在于头文件的搜索路径不同:
- 使用尖括号 < > ,编译器会到系统路径下查找头文件;
- 使用双引号 “ “ ,编译器首先在当前目录下查找头文件,如果没有找到,再到系统路径下查找。
像 stdio.h 和 stdlib.h 这些都是标准头文件,它们存放于系统路径下,所以使用尖括号和双引号都能够成功引入;而我们自己编写的头文件,一般存放于当前项目的路径下,所以不能使用尖括号,只能使用双引号。
【注意】
(1)一个 include 命令只能指定一个被包含的文件,若有多个文件要包含,则需要用多个 include 命令。而且文件的包含允许嵌套,在一个被包含的文件中还可以包含别的文件。
(2)在使用我们自己编写的头文件时,也可以在 include 中直接指明路径,例如,
1 |
(3)同一个头文件可以被多次引入,多次引入的效果和一次引入的效果相同,因为头文件在代码层面有防止重复引入的机制。
四、条件编译
当我们写的源代码是跨平台的,而代码每次只会在一个平台上运行,我们就全部编译所有代码吗?那样多少有些浪费。
其实 C 语言为我们提供了条件编译功能,以便于我们只编译需要的部分。所谓条件编译,就是能够根据不同情况编译不同代码、产生不同目标文件的机制。条件编译的关键字为 #if 、 #ifdef 和 #ifndef 。
1. #if
1.1使用格式
使用的时候,可以有三种格式:
1 | /* 1.只有 #if */ |
【说明】如果常量表达式为真( 1 ),则编译语句块,若常量表达式为假( 0 ),则不做处理。
1 | /* 2. #if ... #else... */ |
【说明】如果常量表达式为真( 1 ),则编译语句块 1 ,若常量表达式为假( 0 ),则编译语句块 2 。
1 | /* 3. #if ... #elif... */ |
【说明】如果 常量表达式1 的值为真(非 0 ,也就是 1 ),就对 语句块1 进行编译;否则计算 常量表达式表达式2 ,结果为真就对 语句块2 进行编译;若为假就继续往下匹配,直到遇到值为真的表达式,或者遇到 #else 。
1.2使用实例
点击查看实例
1 |
|
在终端执行以下命令:
1 | gcc test.c -Wall # 编译程序 |
会看到有如下信息输出:
1 | elif N==1 |
【注意】 #if 命令要求判断条件为整型常量表达式,也就是说,表达式中不能包含变量,而且结果必须是整数。这是与 if 不同的地方。例如,若表达式结果为 1.3 ,将会报以下错误:
1 | error: floating constant in preprocessor expression |
2. #ifdef
2.1使用格式
使用的时候,常见的有两种格式,如下所示:
1 | /* 1.只有 #ifdef */ |
【说明】如果宏 macro 定义了,则编译语句块,若宏 macro 未定义,则不做处理。
1 | /* 2. #ifdef ... #else... */ |
【说明】如果宏 macro 定义了,则编译语句块 1 ,若宏 macro 未定义,则编译语句块 2 。
2.2使用实例
点击查看实例
1 |
|
在终端执行以下命令:
1 | gcc test.c -Wall # 编译程序 |
会看到有如下信息输出:
1 | DEBUG1 is define |
【注意】这种情况定义宏的话,可以有字符串替换宏名,也可以没有。
3. #ifndef
3.1使用格式
使用的时候,一般格式如下:
1 | /* 1.只有 #ifndef */ |
【说明】如果宏 macro 没有被定义,则编译语句块,若宏 macro 被定义,则不做处理。
1 | /* 2. #ifndef ... #else... */ |
【说明】如果宏 macro 没有被定义,则编译语句块 1 ,若宏 macro 被定义,则编译语句块 2 。
3.2使用实例
点击查看实例
1 |
|
在终端执行以下命令:
1 | gcc test.c -Wall # 编译程序 |
会看到有如下信息输出:
1 | EBUG1 is define |
【注意】这种情况定义宏的话,可以有字符串替换宏名,也可以没有。
五、 #pragma
4.1简介
#pragma 用于指示编译器完成一些特定的动作,它所定义的很多指示字是编译器特有的,并且在不同的编译器间是不可移植的。预处理器将会忽略它不认识的 #pragma 指令,不同的编译器可能会使用不同的方式解释同一条 #pragma 指令。
#pragma 指令应该是预处理指令中最复杂的,其用法很多。
4.2 message
4.2.1语法格式
1 |
该参数可以在编译信息输出窗口中输出相应的信息。
4.2.2使用实例
点击查看实例
1 |
|
在终端执行以下命令:
1 | gcc test.c -Wall |
然后,终端会有以下信息显示:
1 | test.c:3:9: note: ‘#pragma message: This is pragma message!’ |
4.3 once
4.3.1语法格式
1 |
该参数用于保证头文件只被编译一次,它与编译器相关,不一定被编译器所支持。还记得之前我们定义头文件的时候使用的是以下形式
1 |
|
它与使用 #pragma once 的区别在于前者是 C 语言所支持的,并不是只包含一次头文件,而是会包含多次,然后通过宏控制是否嵌入到源代码中,也就是说通过宏的方式,可以保证头文件里面的内容只被嵌入一次,但是由于包含了多次,预处理器还是处理了多次,所以效率上来说比较低;后者是告诉预处理器当前文件只编译一次,所以说效率较高。
如果说既想要保证移植性,又想要保证效率,我们可以两种方式同时使用:
1 |
|
4.3.2使用实例
点击查看实例
1 |
|
1 |
|
在终端执行以下命令编译程序:
1 | gcc test.c -Wall |
然后若是我们没有在 global.h 中添加 #pragma once 的话,会有以下信息产生:
1 | In file included from test.c:3: |
我们发现报错了, a 出现了重复定义,我们在 global.h 中加上 #pragma once 之后,便不会再有报错。我们在终端执行 ./a,out 会有以下信息显示:
1 | a=10 |
4.4 pack
后边在自定义数据类型的地方还会用到这个参数。
4.4.1内存对齐
什么是内存对齐?
我使用的 64 位 Ubuntu 中, int 类型占 4 字节, char 类型占 1 字节,当他们出现在一个结构体(后边会学习到)中应该是 5 字节,但是实际上却会是 8 字节,这就是内存对齐导致的。
点击查看实例
1 |
|
在终端执行以下命令编译程序:
1 | gcc test.c -Wall |
然后,终端会有以下信息显示:
1 | sizeof(S1)=8, sizeof(S1.a)=1, sizeof(S1.b)=4 |
为什么要内存对齐?
内存是以字节为单位,但是大部分处理器并不是按字节块来存取内存的。它一般会以 2 、 4 、 6 、 8 甚至 32 字节为单位来存取内存.现在以每次存取 4 字节的处理器为例分析,取 int 类型变量( 64 位系统),该处理器只能从地址为 4 的倍数的内存开始读取数据。
假如没有内存对齐机制,数据可以任意存放,现在一个 int 变量存放在从地址 1 开始的连续 4 个字节字节地址中,该处理器去取数据时,要先从 0 地址开始读取第一个 4 字节块,并剔除不想要的字节( 0 地址),然后从地址 4 开始读取下一个 4 字节块,同样需要删除不要的数据(也就是 5 , 6 , 7 地址处的数据),最后留下的 2 块数据便是我们存放的 int 类型数据,这就意味着处理器要进行大量的处理,存取数据效率将会很低。
现在有了内存对齐, int 类型数据只能存放在按照对齐规则的内存中,比如说 0 地址开始的内存。那么现在该处理器在取数据时一次性就能将数据读出来了,而且不需要做额外的操作,提高了效率。内存对齐在结构体和联合体的大小计算中会得到很好的体现。
对齐规则?
编译器都有自己的默认“对齐系数”(也叫对齐模数)。 gcc 中默认 #pragma pack(4) ,可以通过预编译命令 #pragma pack(n) , n = 1,2,4,8,16 来改变这一系数。
给定值 #pragma pack(n) 和结构体中最长数据类型长度中较小的那个被称之为有效对齐值,也叫对齐单位。
4.4.2语法格式
1 |
该参数可以改变编译器默认的字节对齐方式。
4.4.2使用实例
点击查看实例
1 |
|
在终端执行以下命令:
1 | gcc test.c -Wall |
然后,终端会有以下信息显示:
1 | sizeof(stu1) = 20 |
六、 #error
1.使用格式
#error 也是一个预处理命令,当编译器遇到 #error 的时候将停止编译,并输出自定义的消息,一般使用格式如下:
1 |
其中 [] 中的内容是可选的,也可以不输出提示信息。我们可以使用该预处理指令来停止编译,保证程序是按照我们所设想的那样进行编译的,以免产生不可预料的后果。
【注意】
(1)自定义的错误消息不需要加引号 “ “ ,如果加上的话,引号会被一起输出。
(2)当程序比较大时,往往有些宏定义是在外部指定的(如 makefile ),或是在系统头文件中指定的。
2.使用实例
1 |
|
当我们编译的时候,会有如下提示:
1 | gcc main.c -Wall -o main |
七、易错点
1.案例1
【题目】
有以下宏定义:
1在主程序有以下语句:
1 sum = M(a + b, b + c, c + a);若 a = 1, b = 2, c = 3,则sum为多少?
【解答】
错误解答:sum = (a + b) * (b + c) + (c + a) = 3 * 5 + 4 = 19
正确解答:sum = a + b * b + c + c + a) = 1 + 2 * 2 + 3 + 3 + 1 = 12
【注意】一定是先替换,后运算,不可直接运算。