目录
1. 程序的翻译环境和执行环境
1.1.翻译环境
1.2.详解编译+链接过程
1.3.运行环境
2.预处理详解
2.1.预定义符号
2.2.#define
2.2.1.#define 定义标识符
2.2.2.#define 定义宏
2.2.3.#define 替换规则
2.2.4.#和##
2.2.5.带副作用的宏参数
2.2.6.宏和函数的对比
2.2.7.命名约定
2.3.#undef
2.4.命令行定义
2.5.条件编译
2.6.文件包含
2.6.1 头文件被包含的方式
2.6.2 嵌套文件包含
3. 其他预处理指令
在ANSI C(遵循美国国家标准总局定义的c语言标准的c语言)的任何一种实现中,存在两个不同的环境。第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令第2种是执行环境,它用于实际执行代码
test.c里面存的是c语言的源代码
test.exe是二进制文件,里面存的是二进制指令/机器指令(机器能够读懂的是二进制,二进制指令是机器能够读懂的,因此也叫机器指令)
翻译环境就是将test.c中的c语言的源代码翻译成test.exe中机器能够读懂的二进制指令
运行环境就是去处理test.exe中的二进制指令,产生我们想要的结果
翻译环境去翻译c语言源代码可以分为两个过程:
1.编译:test.c中的c语言源代码经过编译过程,会生成一个目标文件test.obj
2.链接:编译生成的目标文件test.obj再经过链接,会生成test.exe的可执行程序
注:
1.当有多个.c源文件,每一个.c文件都会单独经过编译器编译处理生成对应各自的.obj文件;然后这些.obj文件整体再加上链接库一起,经过链接器链接处理生成.exe可执行文件
2.我们使用的库函数(比如printf等)都是放在相应的.lib静态库里面的(函数对应的库如下图所示),因此如果我们使用库函数的话,链接器也应该把对应的.lib静态库链接进去
总的过程如下图所示:
编译可以分为:预编译、编译、汇编三个过程
下面用gcc c语言编译器演示程序的编译和链接的过程:
我们创建两个文件:
add.c里面存放add函数代码
test.c里面存放主函数代码
1.预编译过程
gcc编译器中执行预编译操作的代码如下:
gcc -E test.c:对test.c文件进行预处理操作,并将预处理产生的结果打印在屏幕上
gcc -E test.c -o test.i:对test.c文件进行预处理操作,并将预处理产生的结果放在test.i文件中
对test.c文件预编译的结果:(展示预编译结果最后的一部分)
注:
1.对test.c文件预编译的结果与test.c文件进行比较我们发现有一个不同的地方就是,在test.c文件预编译的结果中test.c文件里面的#include
2.对test.c文件预编译的结果与test.c文件进行比较我们发现有一个不同的地方就是,在test.c文件预编译的结果中test.c文件里面的注释内容被删除掉了(其实是被空格替换了)
3.我们给test.c文件中加入#define代码如下面左图所示,test.i文件中预编译的结果如下面右图所示,这里面我们可以看出预编译操作会对#define定义的符号进行替换,并将#define的代码进行删除
预编译功能总结:
预编译操作是一种文本操作,操作如下:
1.头文件进行包含(#include)
2.删除注释
3.#define定义符号的替换
2.编译过程
gcc编译器中执行编译操作的代码如下:
gcc -S test.i:对test.i的预编译文件进行编译操作,并将编译产生的结果放在test.s文件中
对test.i预编译文件进行编译的结果:
注:
1.我们发现test.i文件经过编译生成的test.s文件中都是汇编代码,因此编译的过程其实就是将c语言代码转换成了汇编代码
编译功能总结:
编译操作进行如下过程,其实就是将c语言代码转换成汇编代码
1.语法分析
2.词法分析
3.语义分析
4.符号汇总(将代码里的全局符号汇总起来)
3.汇编的过程
gcc编译器中执行汇编操作的代码如下:
gcc -c test.s:对test.s的编译文件进行汇编操作,并将汇编产生的结果放在test.o文件中,此处的test.o文件就是目标文件(linux环境下目标文件的后缀是.o,windows环境下目标文件的后缀是.obj)
对test.s编译文件进行汇编操作的结果:
注:
1.这里面我们看不懂是因为里面完全是二进制的数据了,因此汇编是将test.s文件中的汇编代码转换成了test.o文件中的二进制指令/机器指令。在linux系统中.o的目标文件和可执行文件的文件格式是elf格式,使用readelf可以解析elf格式的文件
2.前面编译操作有一个符号汇总,会将test.c文件中的main、add,add.c文件中的add这种全局符号汇总起来,汇编操作会通过汇总的符号形成符号表
什么是符号表呢?test.c源文件单独进行编译器处理的时候,编译器在这个文件里面只见过add函数的声明,没有见过add函数的实现,因此没有见过add函数的有效地址,此时给add函数一个0x000填充地址;而在test.c源文件中main函数是有明确地址的,假如main函数的地址为0x400,那么就形成了如下表(左),这就是test文件的符号表。add.c源文件单独进行编译器处理的时候,编译器在这个文件里面是见过add函数实现也就是定义的,假如add函数的地址为0x200,那么就形成了如下表(右),这就是add文件的符号表
汇编功能总结:
汇编操作是把汇编代码转换成二进制的指令,会形成符号表
4.链接过程
gcc编译器中执行链接操作的代码如下:
gcc add.o test.o:将add.o和test.o目标文件进行链接,默认生成一个可执行程序a.out
gcc add.o test.o -o test:将add.o和test.o目标文件进行链接,生成一个可执行程序test
注:两种方法生成的a.out和test可执行程序是完全一样的
链接功能:
1.合并段表
2.符号表的合并和重定位
合并段表功能解释:
前面我们说过,在linux系统中.o的目标文件和可执行文件的文件格式是elf格式的,这种elf文件的格式其实就是把一个文件分成了各个段,每个段有其特殊的意义。因为test.o和add.o的目标文件的文件格式相同,所以test.o和add.o的目标文件每个段是对应的,其功能和意义是相同的,链接会把两个目标文件对应的段进行合并
符号表的合并和重定位功能解释:
前面经过编译处理,test文件和add文件都会形成各自的符号表,如下图所示
合并main的时候其地址没有争议,合并add的时候因为有两个add会进行重定位,经过符号表的合并和重定位会形成如下新的符号表
生成的可执行文件test里面就有上面新的符号表
链接的意义(合并段表、符号表的合并和重定位的意义):
多个目标文件进行链接的时候会通过符号表查看来自外部的符号是否真是存在,如果外部符号不存在,就会报下图所示的链接错误(下图把add.c里面的代码屏蔽了,符号表的合并和重定位后,符号表里面就没有add符号的地址(0x000是无效的地址)因此会报链接错误)
因为链接的时候是有符号表的合并和重定位操作的,因此如果在test.c代码中及时没有extern声明add函数,不会影响链接时生成的符号表,符号表里面还是有add函数的地址,程序也是可以运行出来的,只是会报一个警告,如下图所示
程序执行的过程:1. 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中(没有操作系统的设备,比如单片机、嵌入式设备上),程序的载入必须由手工安排(将代码克隆到板子上内存中去),也可能是通过可执行代码置入只读内存来完成。2. 程序的执行便开始。接着便调用 main 函数。3. 开始执行程序代码。这个时候程序将使用一个运行时堆栈(函数栈帧)(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static )内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。4. 终止程序。正常终止 main 函数;也有可能是意外终止。
我们一直用的 #include / #define 其实是一种预处理指令,下面我们对一些预处理指令进行详细介绍
__FILE__ // 进行编译的源文件__LINE__ // 文件当前的行号__DATE__ // 文件被编译的日期__TIME__ // 文件被编译的时间__STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义(vs2022无法运行,因此不完全遵循ANSI C)注:1.这些预定义符号都是语言内置的。2.这些预定义符号可以用来记录日志(程序如果太多代码太长,我们分析的时候就会比较复杂,我们可以借助于打日志的方法找问题)
#include
int main()
{
printf("%s\n", __FILE__);
printf("%d\n", __LINE__);
printf("%s\n", __DATE__);
printf("%s\n", __TIME__);
}
运行结果1:
代码2:(写日志的例子)
#include
#include
#include
int main()
{
int i = 0;
//记录日志
FILE* pf = fopen("log.txt", "w");
if (pf == NULL)
{
printf("%s\n", strerror(errno));
return 1;
}
for (i = 0; i < 10; i++)
{
fprintf(pf, "%s %s %s %d i=%d\n", __DATE__, __TIME__, __FILE__, __LINE__, i);
}
fclose(pf);
pf = NULL;
return 0;
}
运行后log.txt文件里面的内容:
语法:#define name stuff注: #define name stuff后面不能加;如果后面加了;那么;也会别系统认为是stuff中的内容(如下图所示,预编译时系统会把所有的MM变成100;那么if语句中里面的代码为a=100;;两个分号是两条语句,if语句后面跟两个语句要加大括号,所以系统会报错)
#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__ )
代码:
#include
#define MM 100
int main()
{
printf("%d\n", MM);
return 0;
}
运行结果:
注:
1.在windows底下也是可以看到#define在预编译中的效果的
右击项目点击属性,在c/c++栏预处理器中,将预处理到文件选项改成是,应用确定
ctrl+F7编译代码或ctrl+F5运行代码(如果是使用ctrl+F5运行代码,提示无法打开.obj文件,是因为我们在修改预处理文件为是之前没有运行过该代码,我们在修改前先运行就会生成.obj文件,然后再修改预处理文件为是并运行即可,ctrl+F7编译不会有这种问题),运行之后debug文件夹里会生成.i文件,我们用vs编译器打开该.i文件
从上图我们可以看见经过预处理后,MM已经被替换成100了并且#define定义的代码已经没有了
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)
下面是宏的申明方式:#define name( parament-list ) stuff其中的 parament - list 是一个由逗号隔开的符号表,它们可能出现在 stuff 中注:1.其实宏也是进行替换,如下图所示,MAX(a,b)会被替换成 x>y?x:y2.我们在使用宏的时候最好是给符号用圆括号括起来,因为如果传过去的是表达式,表达式中的运算符和stuff中的运算符会判断优先级进行运算,和我们的逻辑不符,符号用圆括号括就会避免这种情况发生,如下图所示
3.参数列表的左括号必须与name紧邻。如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分
如下图所示,如果加了空格,预编译后代码中的DOUBLE被替换成(x) ((x)+(x))
代码:
#include
#define DOUBLE(x) x+x
int main()
{
printf("%d\n", 10*DOUBLE(3));
return 0;
}
运行结果:
注:
1.我们的预期是DOUBLE(3)为6,6*10为60,但代码运行出来结果为33,因为DOUBLE(3)是被替换成了3+3,替换后代码为printf("%d\n", 10*3+3),所以打印出来是33,但是如果给符号用圆括号括起来,那么就不会出现这样的问题,如下图所示因为加上括号,替换后代码为
printf("%d\n", 10*((3)+(3))),与我们预期想法是相同的,所以写宏的时候符号要用圆括号括起来
在程序中扩展 #define 定义符号和宏时,需要涉及几个步骤。1. 在调用宏时,首先对参数进行检查,看看是否包含任何由 #define 定义的符号。如果是,它们首先被替换(如下图所示会先将这里的M替换成100,再进行宏的替换)2. 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
3. 最后,再次对结果文件进行扫描,看看它是否包含任何由 #define 定义的符号。如果是,就重复上述处理过程。注:1. 宏参数和 #define 定义中可以出现其他 #define 定义的符号。但是对于宏,不能出现递归(宏里面不能包含一个宏)2. 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
#的作用:
宏的stuff中在一个宏的行参前面加上#,在替换的时候#参数变成对应的字符串,即"实参"
如何把参数插入到字符串中?函数是做不到的,宏可以做到,见下面代码
代码1:
#include
int main()
{
printf("hello world\n");
printf("hello " "world\n");
printf("hel" "lo " "world\n");
return 0;
}
运行结果:
注:
1.只要printf括号内是字符串,当有多个字符串时在printf打印字符串的时候会拼在一个字符串中,
代码2:
#include
#define PRINT(n) printf("the value of "#n" is %d\n", n)
int main()
{
int a = 10;
PRINT(a);
printf("the value of ""a"" is %d\n", a); //PRINT(a)替换后就变成了该行代码
int b = 20;
PRINT(b);
printf("the value of ""b"" is %d\n", b);//PRINT(b)替换后就变成了该行代码
return 0;
}
运行结果:
注:
1.当我们想打印多个变量值时,一个变量用一次printf打印不方便,如下图所示我们想到使用函数对打印功能进行封装进行打印,但是打印的时候,双引号里面的变量名也要改变,函数不好改变双引号里面的变量名,如下图箭头所指,我们可以用宏来实现
2.在一个宏的参数前面加上#,就可以让这个参数变成对应的字符串,比如将a传给PRINT(n),那么stuff中的#n就代表"a"
## 的作用##可以把位于它两边的符号合成一个符号。它允许宏定义从分离的文本片段创建标识符。
代码:
#include
#define CAT(C, num) C##num
int main()
{
int Class104 = 10000;
printf("%d\n", CAT(Class, 104));//Class104
return 0;
}
运行结果:
注:
1.上面代码的CAT(Class, 104)会被替换成Class104,##可以把位于它两边的符号合成一个符号,允许宏定义从分离的文本片段创建标识符
2.其实#和##在代码开发过程中用的是很少的
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果比如下面的getchar函数,fgetc函数,++a代码getchar函数在读取一个字符的同时,输入缓冲区里面该字符真的会被读取走
fgetc从文件中读取一个字符的时候,文件指针会指向下一个字符地址b = ++a在给a+1赋值给b的同时,a也会加1这些就是副作用
#include
#define MAX(x,y) ((x)>(y)?(x):(y))
int main()
{
int a = 3;
int b = 5;
int m = MAX(a++, b++);
printf("%d\n", m);
printf("a=%d b=%d\n", a, b);
return 0;
}
运行结果:
注:
1.宏的参数是不进行计算直接替换进去,上面代码a++和b++作为表达式会直接替换进去,MAX(a++, b++)变成((a++)>(b++)?(a++):(b++))
2.代码中a为3,b为5,m=((a++)>(b++)?(a++):(b++))执行完后m=6,a=4,b=7
3.如果宏的参数带有副作用,那么这个副作用会在宏的内部体现出来,因为参数是直接替换进去的,如果参数替换多份,那么副作用就会体现多次,因此给宏传参的时候参数尽量不要带有副作用
宏和函数的使用场景:宏通常被应用于执行简单的运算,逻辑运算比较复杂的情况下宏容易出错推荐用函数比如在两个数中找出较大的一个#define MAX(a, b) ((a)>(b)?(a):(b))
那为什么不用函数来完成这个任务?原因有二:1. 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹 。(使用宏花费的时间只有逻辑运算(.exe文件在运行的时候已经预编译进行替换过了),使用函数花费的时间有函数调用+逻辑运算+函数返回)2. 更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之宏可以适用于整形、长整型、浮点型等可以用于 > 来比较的类型。 宏是类型无关的 。
宏的缺点:当然和函数相比宏也有劣势的地方:1. 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。2. 宏是没法调试的(调试是产生可执行程序后进行调试的,此时已经预编译过了,宏已经被替换因此调试时无法观察到宏)3. 宏由于类型无关,也就不够严谨4. 宏可能会带来运算符优先级的问题,导致程序容易出错
宏的优点:宏有时候可以做函数做不到的事情,比如:1.宏可以 把参数插入到字符串中(前面在宏的stuff中#的使用)2.宏的参数可以出现 类型 ,但是函数做不到,见下面代码#include
#define MALLOC(num, type) (type*)malloc(num*sizeof(type)) int main() { int* p = (int*)malloc(10 * sizeof(int)); //该代码可借助宏简化,见下面代码 int* p2 = MALLOC(10, int); //int *p2 = (int*)malloc(10*sizoef(int)); return 0; }
宏和函数的对比:
属 性 #define 定义宏 函数 代码长度 每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长 函数代码只出现于一个地方;每次使用这个函数时,都调用那个 地方的同一份代码 执行速度 更快 存在函数的调用和返回的额外开销,所以相对慢一些 操作符优先级 宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多些括号 函数参数只在函数调用的时候求值一次,它的结果值传递给函数。表达式的求值结果更容易预测 带有副作用的参数 参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果 函数参数只在传参的时候求值一次,结果更容易控制 参数类型 宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用于任何参数类型 函数的参数是与类型有关的,如果参数的类型不同,就需要不同的函数,即使他们执行的任务是不同的。 调试 宏是不方便调试的 函数是可以逐语句调试的 递归 宏是不能递归的 函数是可以递归的
一般来讲函数的宏的使用语法很相似。所以语言本身没法帮我们区分二者。那我们平时的一个习惯是:把宏名全部大写函数名不要全部大写
这条指令用于移除一个#define的定义符号,包括纯符号或宏
许多 C 的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。例如:当我们根据同一个源文件要编译出不同的一个程序的不同版本的时候,这个特性有点用处。(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器内存大写,我们需要一个数组能够大些。)
代码:(gcc中演示)
命令行1:
运行结果1:(运行结果存在了a.out文件中,打开a.out里面就是我们运行的结果)
命令行2:
运行结果2:
在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。比如说:1.代码跨平台,一个功能在unix系统下用一段代码实现,在windows系统下需要用另一段代码实现,需要使用条件编译进行判断(这种情况使用是很常见的,比如像stdio.h这种库里面使用非常频繁)2.调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译,见如下代码注:
1.#if和#endif是成对出现的,如果#if后面的值为真,那么#if和#endif之间的内容会进行编译,如果#if后面的值为假,那么#if和#endif之间的内容不会进行编译(本质上如果#if后面为真,那么预编译时删除#if和#endif预处理指令,如果#if后面为假,那么预编译时删除#if、#endif预处理指令和它们之间的代码)
2.#if后面是常量表达式,但是不能放变量,因为#if和#endif都是预处理指令,这些指令在预处理阶段就要决定是否进行编译,而变量是代码编译通过产生可执行程序,可执行程序运行起来之后才产生的
常见的条件编译指令: (条件编译指令也是预处理指令)-------------------------------------------------------------------------------------------------------------------------1.#if 常量表达式//...#endif// 常量表达式由预处理器求值。-------------------------------------------------------------------------------------------------------------------------2.多个分支的条件编译#if 常量表达式//...#elif 常量表达式//...#else//...#endif-------------------------------------------------------------------------------------------------------------------------
3.判断是否被定义方式1:#if defined(symbol)//...#endif
方式2:#ifdef symbol方式3:#if !defined(symbol)//...#endif方式4:#ifndef symbol//...#endif注:1.这里只判断符号是否被定义过2.方式1和方式2是同一个意思,方式3和方式4是同一个意思-------------------------------------------------------------------------------------------------------------------------4.嵌套指令#if defined(OS_UNIX)#ifdef OPTION1unix_version_option1();#endif#ifdef OPTION2unix_version_option2();#endif#elif defined(OS_MSDOS)#ifdef OPTION2msdos_version_option2();#endif#endif
我们已经知道, #include 指令可以使另外一个文件被编译。就像它实际出现于 #include 指令的地方一样这种替换的方式很简单:预处理器先删除这条指令,并用包含文件的内容替换。这样一个源文件被包含 10 次,那就实际被编译 10 次。
1.本地文件包含
#include "filename"
使用双引号包含文件的查找策略(查两次):先在源文件所在目录下查找;如果该头文件未找到,编译器就像查找库函数头文件一样在标准头文件路径(不同编译器,指定的标准头文件位置不同)查找头文件;如果还找不到就提示编译错误。
2.库文件包含
#include
使用尖括号包含文件的查找策略(查一次):查找头文件直接去标准头文件路径下去查找,如果找不到就提示编译错误。
注:这样我们可以得出,其实包含库文件也可以用双引号包含头文件,但是这样做的话,效率会降低,因为会查找两次
comm.h 和 comm.c 是公共模块。test1.h 和 test1.c 使用了公共模块。test2.h 和 test2.c 使用了公共模块。test.h 和 test.c 使用了 test1 模块和 test2 模块。这样最终程序中就会出现两份 comm.h 的内容。这样就造成了文件内容的重复。
我们应该如何解决上面这种问题?答案:条件编译
-------------------------------------------------------------------------------------------------------------------------
方法一:
#ifndef __TEST_H__#define __TEST_H__//头文件的内容#endif
注:1.这种方法,如果第一次包含这个头文件遇到#ifndef __TEST_H__代码,第一次运行文件没有定义过__TEST_H__,#ifndef __TEST_H__和#endif之间代码就要进行编译,#define __TEST_H__进行了定义并且对内容进行编译;第二次再次包含头文件时,因为已经定义过__TEST_H__,那么遇到#ifndef __TEST_H__后,后面的内容不再进行编译,这样就防止了一个头文件被多次包含-------------------------------------------------------------------------------------------------------------------------方法二:(在新的编译器支持,一些老的编译器是不支持这种写法的)
#error#pragma:#pragma once(防止头文件被多次包含)#pragma pack(结构体设置默认对齐数 )#pragma comment(导入静态库,在函数那一章函数的声明和定义那一节提到过)#line
如果还想学习其他预处理指令,可以参考《C语言深度解剖》学习,B站中蛋哥这个课(课程网址在下面)就是围绕这本书来讲解的C语言编程序设计教程C语言编程零基础入门C语言视频教程C语言带你学C带你飞小甲鱼翁凯C语言C语音C语言谭浩强C语言大佬C语言指针计算机二级计算机考研C语言专升本_哔哩哔哩_bilibili