本节主要深度剖析一下预处理和里面的宏定义以及代码编写的一些固定格式的原理
预处理宏定义头文件main深度剖析
❗️ 什么是预编译/预处理?🤔
预编译又称为预处理,是做些代码文本的替换工作。
比如:处理#开头的指令,比如拷贝#include包含的文件代码,#define宏定义的替换,条件编译等,就是为编译做的预备工作的阶段,主要处理#开始的预编译指令,预编译指令指示了在程序正式编译前就由编译器进行的操作,可以放在程序中的任何位置。
🏷️ 预处理功能
c编译系统在对程序进行通常的编译之前,先进行预处理。 c提供的预处理功能主要有以下三种:
- 1)宏定义
- 2)文件包含
- 3)条件编译
作用是为了:
- 总是使用不经常改动的大型代码体。
- 程序由多个模块组成,所有模块都使用一组标准的包含文件和相同的编译选项。在这种情况下,可以将所有包含文件预编译为一个预编译头。
宏定义#define
宏定义的作用域
宏定义开始,到文件结束(其他的文件包含宏定义的文件也可引用)。
为什么要使用宏
1) 提高代码的可读性和可维护性
2) 避免函数调用,提高程序效率
举例:
#define ERROR_POWEROFF -1
若不采用宏定义的方式,代码中出现-1 时,程序的可读性变差,代码中出现有具体的含义的单独的数字(比如上面-1) 称为魔鬼数,别人阅读代码的时候会抓狂,恐怕自己阅读的时候,也不知具体的含义
为什么使用宏比调用函数更加高效❓️
因为,使用宏就像使用头文件一样,就比如使用<string.h>,使用头文件<string.h>进行预编译之后,直接可以使用字符串进行定义使用;宏也是如此,在定义宏之后,编译器直接进行了预编译,这时候调用它,就是直接进行替换。
而调用其他函数时,要给他在内存中单独分配空间,普通变量分布在栈区,动态内存分布在堆区,静态变量在全局数据区(全局数据区也包括全局变量),字符常量在常量区,二进制指令(也就是函数体)分布在代码区。
执行这个函数时,要获取被调用函数指定的地址(被调用函数的地址有一个范围,起始地址就是函数的入口地址,被调用函数从起始地址开始一步步往下执行),之后程序会跳转到被调函数的第一条语句,一步步往下依次执行被调函数中的语句,直到函数执行结束。
所以,相比调用函数,宏的开销更小!
含参数的宏与函数的优缺点
| 带参 宏 | 函数 | |
|---|---|---|
| 处理时间 | 编译时 | 程序运行时 |
| 参数类型 | 没有参数类型问题 定义实参 | 形参类型 |
| 处理过程 | 不分配内存 | 分配内存 |
| 程序长度 | 变长 | 不变 |
| 运行速度 | 不占运行时间 | 调用和返回占用时间 |
宏定义的时候,每部分都加上括号
(1)#define SQR (x) x * x
当表达式 x = 10+1, SQR(x) * SQR(x) 替换为 10+1*10+1,显然这不是我们想要的结果,导致出错
(2)#define ADD (x) (x)+(x)
当表达式 x=5, ADD(x)*ADD(x) 替换为 (5)+(5) * (5)+5,显然这不是我们想要的结果,导致出错
- 解析:避免这种错误,当宏定义的时候,每部分都加上括号:
(1)#define SQR (x) ((x) *( x))
(2)#define ADD (x) ((x)+(x))
当宏出现在字符串中的时候,宏不会被替换
比如 printf("ADD(x)"); 打印的结果为 ADD(x) 而不是 (x)+(x)
宏定义函数的时候,函数标识符和参数之间不能有空格
比如 #define SQR (x) x * x , 宏将变成代码中用(x) x * x 替换代码中的SQR ;
但引用宏的时候可以有空格,比如 #define ADD (x) ((x)+(x)), 应用的时候 ADD (3) 和 ADD(3) 都是正确的
取消宏定义的符号 #undef
- 取消宏定义的符号
#undef,此符号之后的宏的定义将不再起作用
条件编译 ifndef
有时候我们会在 代码中的头文件.h中看见ifndef/define/endif ,那么他们的作用是什么?
#ifndef、#define、#endif 是预处理命令,它们一起用来根据不同情况编译不同代码、产生不同目标文件的机制,称为条件编译。
条件编译的形式之一:
(1) #ifdef 标识符
程序段1
#else
程序段2
#endif
(2) #if 常量表达式
程序段1
#else
程序段2
#endif
一个重要的作用是 防止该头文件被重复引用。
一般可以用于防止头文件重复包含。格式如下:
#ifndef _NAME_H
#define _NAME_H
// 头文件内容
#endif
当程序中第一次 #include 包含该头文件时,由于 _NAME_H 这个宏还没有定义,所以会定义 _NAME_H 这个宏,并执行”头文件内容“部分的代码;
当发生多次 #include 时,因为前面已经定义了 _NAME_H ,所以不会再重复执行”头文件内容“部分的代码。
文件包含#include
- 文件包含是预处理的一个重要功能,它可用来把多个源文件连接成一个源文件进行编译,结果将生成一个目标文件。
C语言提供#include 命令来实现文件包含的操作,它实际是宏定义的延伸;
(1)#include <filename>
C 编译系统所提供的并存放在指定的子目录下的头文件。找到文件后,用文件内容替换该语句;
(2)#include “filename”
预处理应在当前目录中查找文件名为filename 的文件.
若没有找到,则按系统指定的路径信息,搜索其他目录。找到文件后,用文件内容替换该语句。
#include 是将已存在文件的内容嵌入到当前文件中
❗️include <> 和 “” 区别
🤨 尖括号 <> 和引号 “” 的区别在于, 前者表示要包含的文件位于编译器的默认搜索路径中,而后者表示要包含的文件位于程序文件所在的目录或指定的搜索路径中。 也就是""先搜索当前目录的,如果找不到文件回到编译器的默认搜索路径重新搜索。
- 例如,#include “myheader.h” 指令会在编译时包含名为 myheader.h 的头文件。优先搜索当前目录的,如果找不到文件回到编译器的默认搜索路径重新搜索。
mian函数参数argc和argv的作用
主函数要写main (int argc, char* argv[ ])
argc在C语言中表示运行程序时传递给main()函数的命令行参数个数。
argv在C语言中表示运行程序时用来存放命令行字符串参数的指针数组。
argc、argv用命令行编译程序时有用。主函数main中变量(int argc,char *argv[ ])的含义:
- main(int argc, char *argv[ ], char **env)是UNIX和Linux中的标准写法。
- argc: 整数,用来统计你运行程序时送给main函数的命令行参数的个数
- argv[ ]: 指针数组,用来存放指向你的
字符串参数的指针,每一个元素指向一个参数。其中argv[0] 指向程序运行的全路径名,argv[1] 指向在DOS命令行中执行程序名后的第一个字符串,argv[2] 指向执行程序名后的第二个字符串,argv[argc]为NULL。 - argc、argv是在main( )函数之前被赋值的,编译器生成的可执行文件,main( )不是真正的入口点,而是一个标准的函数,这个函数名与具体的操作系统有关。
#pragma预处理
#pragma用于指示编译器完成一些特定的动作
下面讲解一下常用的几个#pragma 预处理命令
#pragma comment(…)
该指令将一个 注释记录放入一个对象文件或可执行文件中。常用的lib 关键字,可以帮我们连入一个库文件。比如:
#pragma comment(lib, "user32.lib")
- 该指令用来将user32.lib 库文件加入到本工程中。
linker:将一个链接选项放入目标文件中,你可以使用这个指令来代替由命令行传入的
或者在开发环境中设置的链接选项,你可以指定/include 选项来强制包含某个对象。
例如:
#pragma comment(linker, "/include:__mySymbol")
#pragma warning
#pragma warning(disable: 4507 34; once: 4385; error: 164)
含义:不显示 4507 和34 号警告信息
4385号警告信息仅报告一次
把164号警告信息作为一个错误
#pragma warning( disable : 4507 34; once : 4385; error : 164 )
等价于:
#pragma warning(disable:4507 34) // 不显示4507 和34 号警告信息
#pragma warning(once:4385) // 4385 号警告信息仅报告一次
#pragma warning(error:164) // 把164 号警告信息作为一个错误。
不过程序设计的时候,尽量少用disable,尽量在编码的时候,将warning问题解决掉,有的时候warning 也是潜在的bug
#pragma once
在头文件的最开始加入这条指令就能够保证头文件被编译一次
另外保证头文件只编译一次的方法:
#ifndef _FILENAME_H
#define _FILENAME_H
#endif
当程序中第一次 #include 包含该头文件时,由于 _NAME_H 这个宏还没有定义,所以会定义 _NAME_H 这个宏,并执行”头文件内容“部分的代码;
当发生多次 #include 时,因为前面已经定义了 _NAME_H ,所以不会再重复执行”头文件内容“部分的代码。
#pragma code_seg
另一个使用得比较多的pragma 参数是code_seg。格式如:
#pragma code_seg( ["section-name"[,"section-class"] ] )
它能够设置程序中函数代码存放的代码段,当我们开发驱动程序的时候就会使用到它
#pragma message
能够在编译信息输出窗口中输出相应的信息,这对于源代码信息的控制是非常重要的。其使用方法为:
#pragma message(“消息文本")
当编译器遇到这条指令时就在编译输出窗口中将消息文本打印出来。 这对于我们进行源码控制,代码调试有帮助。
#pragma pack内存对齐
内存对齐的原理:
内存对齐是指在计算机系统中,为了提高存储器和处理器的访问性能,将数据项存储在特定的地址上,使得访问该数据项的时候会更快。 这些特定的地址通常是某个较小的数的倍数,这个较小的数被称为 对齐粒度 。
例如,如果数据项是一个 32 位整数,那么对齐粒度就是 4 字节。如果将这个数据项存储在 4 字节的倍数的地址上,那么就是对齐的;否则,就是不对齐的。
对齐常常是为了让存储器访问更快。当处理器访问内存时,它通常是以块的形式一次性读取多个字节。如果数据项不对齐,那么处理器就必须两次访问内存,才能读取完整的数据项。这会降低访问效率。
不同的计算机系统有不同的内存对齐规则。有的系统要求所有数据项都必须对齐,有的系统则允许部分数据项不对齐。
#pragma pack 设置内存对齐粒度
“#pragma pack” 是一个编译指令,它可以用来设置编译器使用的内存对齐粒度。它通常用于调整结构体成员在内存中的对齐方式。
例如,如果在结构体中定义了两个成员,一个是 8 位整数,另一个是 32 位整数,那么如果编译器使用的内存对齐粒度是 4 字节,那么这两个成员在内存中的布局就会是这样的:
| 8 位整数 | 空 | 空 | 空 | 32 位整数 |
| :------: | :--------: | :--------: | :--------: | :--------: |
| a | | | | b |
这种布局方式被称为对齐。
但是,有时候我们希望结构体的成员不要对齐,而是按照定义的顺序在内存中连续存储。这时候就可以使用 “#pragma pack” 指令来设置内存对齐粒度。
例如,如果在结构体定义之前加上 “#pragma pack(1)”,那么编译器就会使用 1 字节作为内存对齐粒度,这样结构体的成员就会按照定义的顺序在内存中连续存储,而不会对齐。
注意,“#pragma pack” 指令仅对当前编译单元有效,也就是说,一旦编译单元结束,内存对齐粒度就会恢复。
内存对齐例子
举个内存对齐的例子:
假设我们有以下的结构体:
struct Data {
char a;
int b;
short c;
};
这个结构体中包含 3 个成员:一个 8 位整数、一个 32 位整数和一个 16 位整数。
如果编译器使用的内存对齐粒度是 4 字节,那么这个结构体在内存中的布局就会是这样的:
- 解释一下:当使用默认的内存对齐粒度 4 字节时,结构体中的成员在内存中的布局如下:
| 8 位整数 | 空 | 空 | 空 | 32 位整数 | 空 | 16 位整数 |
| :------: | :--------: | :--------: | :--------: | :--------: | :--------: | :--------: |
| a | | | | b | | c |
按照默认的对齐方式,c 的地址应该是 6,这样才能让 b 的地址是 4 的倍数,而 a 的地址是 2 的倍数。同时,也应该在 b 和 c 之间添加一个字节的空隙,以便 c 的地址也是 2 的倍数。
这是按照对齐粒4完成的。
struct TestStruct1
{
char c1;
short s;
char c2;
int i;
};
解析:此结构体在内存中的布局为 1*,11,1**,1111 (1 代表占用内存,* 代表为内存对齐补的内存空间)
所以 sizeof(TestStruct1) 为 字节大小=1+2+1+4+(1+3)空白位置=12

#pragma pack (n),编译器将按照n 个字节对齐
#pragma pack (),编译器将取消自定义字节对齐方式
例如:
#pragma pack(8)
struct TestStruct4
{
char a;
long b;
};
struct TestStruct5
{
char c;
TestStruct4 d;
long long e;
};
#pragma pack()
解析:
TestStruct4 内存布局: 1***,1111
TestStruct5 内存布局: 1***,1***,1111****,11111111
所以 sizeof(TestStruct4) 为 8,sizeof(TestStruct5)为 24
内存对齐的规则
(1)每个成员分别按自己的方式对齐,并能最小化长度
(2)复杂类型(如结构)的默认对齐方式是它最长的成员的对齐方式,这样在成员是复杂类型时,可以最小化长度
(3)对齐后的长度必须是成员中最大的对齐参数的整数倍,这样在处理数组时可以保证每一项都边界对齐
(4)对于数组,比如:char a[3];它的对齐方式和分别写3 个char 是一样的.也就是说它还是按1 个字节对齐,即数组按照数组中的每个成员的类型对齐
- 如果写: typedef char Array3[3];Array3 这种类型的对齐方式还是按1 个字节对齐,而不是按它的长度
(5)不论类型是什么,对齐的边界一定是1,2,4,8,16,32,64…中的一个
❗️最后易错点:结构体大小是结构成员大小和与字节对齐的做题区别 🆚
在一些情况下,结构体的大小确实是其成员字节大小的和,但在实际使用中,编译器可能会对结构体进行内存对齐,使得结构体的实际大小可能会大于其成员字节大小的和。
这是因为计算机硬件的限制。许多硬件要求访问内存时,变量的地址需要是对齐的,否则可能会导致性能下降或错误发生。为了满足这个要求,编译器会在结构体成员之间插入空白字节,使得每个成员的地址都能够被对齐,这样就保证了结构体在内存中的访问效率。
因此,结构体的大小不仅仅取决于其成员的字节大小,还取决于编译器使用的内存对齐粒度和结构体成员的排列顺序。
实际应用中还是会内存对齐的, 取决于题目问没问
‘#’在宏定义预处理的使用
“#”运算符
1、字符串中包含宏参数,可以使用“#”,可以把语言符号转化为字符串。
#define SQR(x) printf("The square of " #x " is %d. \n", ((x) * (x)));
SQR(8)
输出:The square of 8 is 64.
“##”运算符-粘合剂
1、也可以用于宏函数的替换部分,这个运算符把两个语言符号组合成单个语言符号
#define XNAME(n) x ## n
XNAME(8)
被展开为:x8
“ ## ” 就是个粘合剂,将前后两部分粘合起来。
结构体内存对齐↓↑
(配合上面#pragma pack内存对齐)
为什么要存在内存对齐?
平台原因(移植原因) : 不是所有的硬件平台都能访问任意地址上的任意数据的;某些平台只能在某些地址处取得某些特定类型的数据,否则抛出硬件异常。比如,当一个平台要取一个整型数据时只能在地址为4的倍数的位置取得,那么这时就需要内存对齐,否则无法访问到该整型数据。
性能原因: 数据结构(尤其是栈):应该尽可能的在自然边界上对齐。原因在于,为了访问未对齐内存,处理器需要作两次内存访问;而对齐的内存访问仅需一次。
规则
- 结构体的第一个成员直接对齐到相对于结构体变量起始位置为0处偏移。
- 从第二个成员开始,要对齐到某个【对齐数】的整数倍的偏移处。
- 结构体的总大小,必须是最大对齐数的整数倍。每个结构体成员都有一个对齐数,其中最大的对齐数就是
最大对齐数。 - 如果嵌套了结构体的情况。嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
举例简单结构体的对齐数的计算
首先,一般都是向较小的数取对齐数,例如,int大小为4,系统指定的对齐数为8. 8 > 4,所以取4为对齐数,就像:

红色和绿色的是存了的地址,白色的就是浪费的空间,所以说对齐方式很浪费空间,可是按照计算机的访问规则,这种方式提高了效率。
从上可以看出,该结构体的大小为:1 + 4 + 1 + 3(浪费的空间(白色)) = 9,然后通过法则三知道9是不行的,要偏移到12,因为总大小要是最大对齐数的整数倍。
综上 结构体的大小为:1 + 4 + 1 + 3 + 4(偏移的大小) = 12.
结构体中包含联合体的结构体大小
分支 联合体的大小计算:
在这之前咱先了解一下联合体大小计算规则:联合体中最大成员所占内存的大小且必须为最大类型所占字节的最小倍数。

计算结构体中的联合体大小
联合体在结构体里面比较特殊,他可以作为最大的对齐数,联合体大小为8,系统指定的对齐数为8,所以最大对齐数为8,然后可以根据上面的内存格子数一数。
uoion U先取最大类型 64位 8 字节double ,char[7]占7个字节,所以8字节够用了。
综上结构体的大小为:1 + 3 + 4 + 8 + 1 + 7(偏移量) = 24
位段和对齐
什么是位段? (可按位处理的整数类型)
位段是一种在 C 语言中的结构体字段,它允许程序员在一个字节中存储多个小的位域(bits)。位段可以用来节省内存,因为它允许使用不同大小的域来表示数据,而不必使用整个字节或更大的存储单位。
还是举例说明吧: 定义位域的语法格式
struct bitfield {
unsigned int field1 : n1;
unsigned int field2 : n2;
// ...
unsigned int fieldk : nk;
};
这个结构体包含了k个位段,每个位段占用n1、n2、…、nk个位。由于位段的长度不是标准的整数,因此它们通常被实现为 按位打包(bit-packing)的字段,其中多个位段被放置在同一个字节中。
-
注意:在C语言中,位段的数据类型必须是
整数类型(如int、unsigned int、signed int等),且所有的位段必须使用相同的整数类型。这是因为位段是使用位来存储数据的,而整数类型是C语言中能够被按位处理的类型。因此,位段的数据类型必须是可按位处理的整数类型。 -
位段的长度不能超过其数据类型的长度。例如,如果使用unsigned int作为位段的数据类型,则其最大长度为32位。因此,一个位段的长度不能超过32位。如果尝试定义一个超过该长度的位段,则编译器会产生一个错误。
举例说明语法
举个例子:当我们需要存储多个逻辑变量时,可以使用位段来减少内存的使用。以下是一个简单的例子
struct Flags {
unsigned int a: 1; //使用1位来存储变量a
unsigned int b: 1; //使用1位来存储变量b
unsigned int c: 1; //使用1位来存储变量c
unsigned int d: 1; //使用1位来存储变量d
};
在这个例子中,我们定义了一个名为Flags的结构体,它包含了4个位段,每个位段都使用1位来存储一个逻辑变量。由于每个位段只需要1位,因此这个结构体只需要1个字节的空间(即8位),而不是使用4个字节(即32位)来存储这4个逻辑变量。这样,我们就能够有效地减少内存的使用,特别是在需要大量存储逻辑变量时,这种方式能够节省大量的内存空间。
当需要使用这些逻辑变量时,可以按照以下方式进行访问:
Flags f;
f.a = 1; //设置变量a的值为1
f.b = 0; //设置变量b的值为0
在这个例子中,我们定义了一个名为f的Flags类型的变量,并使用.操作符来访问其中的每个位段。由于每个位段只需要1位,因此可以使用0或1来设置逻辑变量的值。
位段的对齐
对于位段,每个位段的长度是由其声明时指定的,但位段的对齐方式可能会受到编译器的限制。在某些情况下,编译器可能需要在结构体中添加一些额外的填充位(padding bits)以保证每个位段都能够被正确地对齐。
举例说明:
struct S{
int a:2;
int b:5;
int c:10;
int d:30;
}
因为a位段2,2个位小于int 4字节(32位)所以可以直接存入一个int。
a+b+c =17位 小于int 32位,所以前面的可以存入一个int,还剩15位
但是后面d30位大于15位剩余,需要申请新int 32位
所以最后需要字节=4+4=8字节
可以看出相比之前暴力的直接4x4=16个字节,现在的8个字节大大的节省了空间。
文章详细介绍了C语言中的预处理概念,包括预编译的功能、宏定义的作用、为什么使用宏以及宏定义的注意事项,如括号的使用。此外,还探讨了内存对齐的重要性、规则和计算方法,以及条件编译、文件包含和预处理指令如#pragma的作用。文章内容深入浅出,适合C语言学习者参考。
1683

被折叠的 条评论
为什么被折叠?



