有关C语言的知识马上就要结束了,在学完了前面的基础之上我们就来深究一下程序底层的逻辑,关于程序的预处理编译指令,话不多说,我们直接开始:
在ANSI C的任何一种实现中,存在两个不同的环境:
第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。
第2种是执行环境,它用于实际执行代码。
每一个源文件都是单独的进行编译的一个过程,不受其他源文件的干扰。
1. 组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code)。
2. 每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。
3. 链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中。
代码演示:
源文件:test.c
#include
//声明
extern int Add(int x, int y);
int main()
{
int a = 100;
int b = 200;
int sum = Add(a, b);
printf("%d %d\n%d", a, b, sum);
return 0;
}
源文件:add.c
int Add(int x, int y)
{
return x + y;
}
在程序运行之后会产生两个对应的目标文件:
之前我们写的代码都是在VS上面写的,VS是一个集成开发环境,集成了许多的功能,当我们Ctrl+F5运行代码之后,直接就完成了编译、执行然后在控制窗口里面展示给了我们,因此使用VS不方便观察编译的细节步骤,所以我们需要使用gcc编译器来观察编译阶段的细节步骤,有关搭建gcc编译环境以及使用方法大家可以去我的另外一篇博客中查看: VScode搭建C/C++开发环境详细教程
源代码的运行需要经过翻译和执行两个步骤,那么在翻译中还需要经过编译和链接,在编译这一步中还有许多细节:预编译、编译、汇编。
gcc环境下:
预编译:gcc 文件名 -E
#include
int main()
{
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
int i = 0;
for(i = 0; i<10; i++)
{
printf("%d ",arr[i]);
}
printf("\n");
return 0;
}
可以看到在预编译的这个过程居然有将近900多行的代码,但是我们能认识的只有后面的几行,和我们源代码没有啥区别,但是少了头文件的包含,因此,前面800多行的代码就表示头文件的包含(include这个头文件的内容)。
所以在预编译阶段所要做的第一步就是头文件的包含 。
当我们在源代码中添加上一些注释和#define定义的符号的时候会发生什么呢?
所以在预编译阶段要做的还有注释的删除和#define定义符号的替换
总结:
在预编译阶段都会进行以下操作(文本操作):
1.头文件的包含。
2.注释的删除。
3.#define定义的符号的替换。
编译:gcc 预编译文件名 -S
在这个过程中会生成一个test.s的汇编文件
所以我们就可以理解在编译这个阶段,会将C语言源代码翻译为汇编代码。
总结:
在编译阶段进行的步骤:
将C语言代码翻译为汇编代码
1.语法分析。
2.词法分析。
3.语义分析。
4.符号汇总(汇总全局符号)。
汇编:gcc 汇编文件 -c
在这个过程中会生成一个test.o的目标文件(二进制文件)。
在VS中生成的目标文件后缀是:.obj
在VScode中生成的目标文件的后缀是:.o
当我们打开这个文件的时候会发现打不开,表示的是二进制文件:
总结:
在汇编阶段进行的操作:
1.将汇编代码转化为二进制的指令。
2.形成符号表。
当汇编完之后,得到了二进制文件就可以进行链接生成可执行程序:
gcc test.o -o test.exe
然后对可执行程序进行执行操作:.\+可执行程序名字:
在链接的过程中进行了合并段表和符号表的合并和重定位
程序执行的过程:
1. 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
2. 程序的执行便开始。接着便调用main函数。
3. 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。
4. 终止程序。正常终止main函数;也有可能是意外终止。
//预定义符号 __FILE__ //进行编译的源文件 __LINE__ //文件当前的行号 __DATE__ //文件被编译的日期 __TIME__ //文件被编译的时间 __STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义
这些预定义符号都是语言内置的。
代码演示:#include
int main() { int i = 0; int arr[10] = { 1,2,3,4,5,6,7,8,9,10 }; for (i = 0; i < 10; i++) { printf("%d ---%s %s %s line = %d\n", arr[i], __FILE__, //打印编译的文件路径 __DATE__, //打印编译日期 __TIME__, //打印编译时间 __LINE__); //打印编译的行数 } return 0; }
1.#define的定义的标识符
2.#define定义的宏
语法:
#define name stuff
举例:
#define MAX 1000 #define reg register //为 register这个关键字,创建一个简短的名字 #define do_forever for(;;) //用更形象的符号来替换一种实现,表示死循环 #define CASE break;case //在写case语句的时候自动把 break写上。 // 如果定义的 stuff过长,可以分成几行写,除了最后一行外,每行的后面都加一个反斜杠(续行符)。 #define DEBUG_PRINT printf("file:%s\tline:%d\t \ date:%s\ttime:%s\n" ,\ __FILE__,__LINE__ , \ __DATE__,__TIME__ )
当然#define也不能多去定义,过多的#define定义的标识符会导致代码的可读性变差。
#define定义的标识符在预编译的时候是直接进行替换 。
gcc环境:
问:#define定义标识符的需要在后面加上 ; 吗?
#define MAX 1000;
#define MAX 1000
我们可以来验证一下
gcc环境 :
不加;
加上;
如果在定义的时候加上;时就会导致有的程序出现错误,因此在使用#define来定义标识符的时候最好最好是不要加;
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)。
//语法:
#define name( parament-list ) stuff
//其中的 parament - list 是一个由逗号隔开的符号表,它们可能出现在stuff中
注意:
参数列表的左括号必须与name紧邻。
如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。
代码演示:
gcc环境下:
用宏实现一个数的平方:
#include
#define SQURE(x) x*x int main() { int x_squre = SQURE(5); printf("%d\n", x_squre); return 0; }
可以看到 #define定义的宏也是在预编译阶段进行替换的。
但是呢,关于这个宏还是存在问题的,如果我们传递的参数是4+1呢?
可以看到x_squre变成了4+1*4+1 = 9,这样子写的话就出现了问题,所以关于上面的宏我们还是得再改造改造:
#include
#define SQURE(x) ((x)*(x)) //给参数都加上() int main() { int x_squre = SQURE(4 + 1); printf("%d\n", x_squre); return 0; } 这样子改造就可以得到正确的结果,所以我们在定义宏的时候给单个参数加上(),然后给整个表达式也可以加上(),这样就防止了错误的发生。
练习:
使用宏实现求两个数的较大值:
代码演示:
#include
#define MAX(x,y) ((a)>(b)? (a): (b)) int main() { int a = 3; int b = 5; printf("max = %d\n", MAX(a, b)); return 0; }
在程序中扩展#define定义符号和宏时,需要涉及几个步骤。
1. 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如 果是,它们首先被替换。
2. 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
3. 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果 是,就重复上述处理过程。
注意:
1. 宏参数和#define 定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归。
2. 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
#: 把一个宏参数变成对应的字符串
我们来看这样一段代码:
#include
int main() { int a = 10; printf("The value of a is %d\n", a); int b = 20; printf("The value of b is %d\n", b); float f = 3.14f; printf("The value of f is %f\n", f); return 0; } 这段代码的打印步骤有点冗余,我们可不可以使用一个函数来实现这个打印过程呢?
答案是不可以的:因为这个打印过程中不能确定打印的格式和元素,所以我们可以使用宏来实现这个打印:
C语言中的字符串可以这样子打印:
#include
int main() { printf("Hello World!\n"); printf("Hello " "World!\n"); //可以使用两个双引号将两个字符串连接在一起 return 0; } 因此我们可以结合双引号和#来实现宏用来打印:
#define PRINT(num,type) \ printf("The value of "#num" is "type,num) int main() { int a = 10; PRINT(a, "%d\n"); int b = 20; PRINT(b, "%d\n"); float f = 3.14f; PRINT(f, "%f\n"); return 0; }
使用双引号将多个字符串连接在一起,再使用#将宏参数变成字符串。
##:可以把位于它两边的符号合成一个符号。
它允许宏定义从分离的文本片段创建标识符。#include
int Class2023 = 100; #define CAT(x,y) x##y int main() { printf("%d\n", CAT(Class, 2023)); return 0; } ##将两边的符号合成为一个符号。
注:
这样的连接必须产生一个合法的标识符。否则其结果就是未定义的。
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果。
例如:
x+1; //没有副作用 x++; //带有副作用
代码演示:
#include
#define MAX(x,y) ((x)>(y)? (x):(y)) int main() { int a = 5; int b = 8; int max = MAX(a++, b++); printf("a = %d b = %d max = %d", a, b, max); return 0; } 可以看到打印出的结果完全不是我们想要的,在正常情况下:a++先使用再加加,所以传递的是5,a在加一之后变成了6,b++先使用再加加,所以传递的是8,加一之后变成了9,所以打印的结果应该是a = 6,b = 9, max = 8,那为什么不一样呢?我们在gcc环境下面进行预编译处理来观察一下:
在预编译可以看到max被替换成了这样一个表达式:
我们可以来分析一下:
宏通常被应用于执行简单的运算。
比如求两个数的较大值:我们通常会选择使用宏 来实现:
#define MAX(x,y) ((x)>(y)? (x):(y))
那为什么不用函数来完成这个任务?
原因有二:
1. 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。
所以宏比函数在程序的规模和速度方面更胜一筹。(函数调用时间的花费:1.函数调用前的准备(传参,函数栈帧空间的维护)2.主要运算。3.函数的返回值的处理,函数栈帧的销毁)
2. 更为重要的是函数的参数必须声明为特定的类型。
所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点等可以用于>来比较的类型。
宏是类型无关的。
当然宏也有宏的缺点:
1. 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。
2. 宏是没法调试的。
3. 宏由于类型无关,也就不够严谨。
4. 宏可能会带来运算符优先级的问题,导致程容易出现错。
宏有时候可以做函数做不到的事情。比如:宏的参数可以出现类型,但是函数做不到。
在之前我们使用动态内存开辟的函数时,是直接进行创建,那如果我们需要开辟多次,而且都是类型不一样的,依次进行开辟就会很麻烦,因此我们就可以使用一个宏来实现这个开辟的过程:#include
#include #define MALLOC(num,type) (type*)malloc(num*sizeof(type)) int main() { int* p = MALLOC(10, int); //传递类型 if (p == NULL) { perror("p:"); return 1; } //使用 //... float* f = MALLOC(5, float); //传递类型 if (f == NULL) { perror("f:"); return 1; } //使用 //... //释放 free(p); p = NULL; free(f); f = NULL; return 0; } 预处理之后:
宏和函数的对比:
属 性 | #define定义宏 | 函数 |
代 码 长 度 | 每次使用时,宏代码都会被插入到程序中。除了非常 小的宏之外,程序的长度会大幅度增长 |
函数代码只出现于一个地方;每 次使用这个函数时,都调用那个 地方的同一份代码 |
执 行 速 度 | 更快 | 存在函数的调用和返回的额外开 销,所以相对慢一些 |
操 作 符 优 先 级 | 宏参数的求值是在所有周围表达式的上下文环境里, 除非加上括号,否则邻近操作符的优先级可能会产生 不可预料的后果,所以建议宏在书写的时候多些括 号。 |
函数参数只在函数调用的时候求 值一次,它的结果值传递给函 数。表达式的求值结果更容易预 测。 |
带 有 副 作 用 的 参 数 | 参数可能被替换到宏体中的多个位置,所以带有副作 用的参数求值可能会产生不可预料的结果。 |
函数参数只在传参的时候求值一 次,结果更容易控制。 |
参 数 类 型 | 宏的参数与类型无关,只要对参数的操作是合法的, 它就可以使用于任何参数类型。 |
函数的参数是与类型有关的,如 果参数的类型不同,就需要不同 的函数,即使他们执行的任务是 相同的。 |
调 试 | 宏是不方便调试的 | 函数是可以逐语句调试的 |
递 归 | 宏是不能递归的 | 函数是可以递归的 |
一般来讲函数和宏的使用语法很相似。所以语言本身没法帮我们区分二者。
那我们平时的一个习惯是:把宏名全部大写
函数名不要全部大写
这条指令用于移除一个宏定义。
代码演示:
#include
#define M 1000 int main() { printf("%d\n", M); #undef M //取消定义之后就不能使用了 printf("%d\n", M); //err return 0; }
许多C 的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。
例如:当我们根据同一个源文件要编译出一个程序的不同版本的时候,这个特性有点用处。(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器内存大些,我们需要一个数组能够大些。)
代码演示:
#include
int main() { int arr[SZ]; int i = 0; for (i = 0; i < SZ; i++) { arr[i] = i; } for (i = 0; i < SZ; i++) { printf("%d ", arr[i]); } printf("\n"); return 0; }
在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。
例如:
调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。
代码演示:#include
#define __DEBUG__ int main() { int i = 0; int arr[10] = { 0 }; for (i = 0; i < 10; i++) { //赋值 arr[i] = i; #ifdef __DEBUG__ //打印 printf("%d\n", arr[i]); #endif } return 0; } 那这段代码是什么意思呢?#ifdef后面的__DEBUG__如果使用#define没有定义了,那么它与#endif之间的语句将不会执行,如果使用#define定义了,那么将会执行。
常见的条件编译指令:
1. #if 常量表达式 //... #endif //常量表达式由预处理器求值。 如: #define __DEBUG__ 1 #if __DEBUG__ //.. #endif 2.多个分支的条件编译 #if 常量表达式 //... #elif 常量表达式 //... #else //... #endif 3.判断是否被定义 #if defined(symbol) #ifdef symbol #if !defined(symbol) #ifndef symbol 4.嵌套指令 #if defined(OS_UNIX) #ifdef OPTION1 unix_version_option1(); #endif #ifdef OPTION2 unix_version_option2(); #endif #elif defined(OS_MSDOS) #ifdef OPTION2 msdos_version_option2(); #endif #endif
1.
#if 常量表达式
...
#endif
代码演示:
#include
int main() { #if 1 == 1 //如果这个常量表达式为真,则执行下面的语句,如果为假则不执行 printf("Yes\n"); #endif return 0; }
2.多个分支的条件编译
#if 常量表达式
...
#elif 常量表达式
...
#else
...
#endif
代码演示:#include
int main() { #if 1 == 2 printf("hehe\n"); #elif 2 == 3 printf("haha\n"); #elif 3 == 4 printf("heihei\n"); #else printf("YES\n"); #endif return 0; }
3.判断是否被定义
#if defined(符号名)...
#endif
#ifdef 符号名...
#endif
这两种定义的结果都是一样的只不过形式不一样,二者选其一。
#if !defined(符号名)...
#denif
#ifndef 符号名...
#denif
这两种定义的结果都是一样的只不过形式不一样,二者选其一。
代码演示:
#include
#define MAX int main() { #if defined(MAX) priintf("hehe\n"); #endif return 0; } #include
int main() { #if !defined(MAX) //没有定义表示为真 printf("hehe\n"); #endif return 0; }
4.嵌套指令
#if defined(OS_UNIX)
#ifdef OPTION1
unix_version_option1();
#endif
#ifdef OPTION2
unix_version_option2();
#endif
#elif defined(OS_MSDOS)
#ifdef OPTION2
msdos_version_option2();
#endif
#endif我们来分析一下这段代码:
条件编译指令和我们的分支语句是很相似的,都可以嵌套定义。
我们已经知道, #include 指令可以使另外一个文件被编译。就像它实际出现于 #include 指令的地方一样。
这种替换的方式很简单:
预处理器先删除这条指令,并用包含文件的内容替换。
这样一个源文件被包含10次,那就实际被编译10次。
本地文件包含:
#include "filename"
查找策略:
先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。
如果找不到就提示编译错误。
库文件包含:
#include
查找策略:
查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。
两种方式都可以查找到对应文件,那么是不是在任何包含头文件的地方都使用双引号的形式呢?答案是可以的,但是呢,在某些情况下会一定程度的降低代码的运行效率,当然,这样也是不容易区分是库文件还是本地文件了。
在一个程序中,如果不小心重复包含头文件,那么头文件的内容就会反复的编译,这样就会使得代码的效率下降,那我们为了防止这种情况该怎么做呢?有两种方法:
1.条件编译指令:
在每个头文件中使用条件编译指令
#ifndef __TEST_H__ #define __TEST_H__ //头文件的内容 #endif //__TEST_H__
2.在头文件的开头使用#pragma once
#pragma once
在VS2019这个编译器中,当我们创建了一个头文件,它就会自动帮我们加上这段代码,很方便。
以上两种方式就可以防止我们重复包含头文件
各位小伙伴,讲到这里有关C语言的知识就完结了,喜欢的朋友可以留下你们的三连,感谢大家的支持!