目录
可执行程序的生成:
预处理(预编译):
预定义符号:
#define(重难点):
第一种的讲解(定义常量):
第二种的讲解(定义宏):
#和# #:
#undef:
命名约定:
条件编译:
编译:
词法分析:
语法分析:
语义分析:
汇编:
链接:
运行环境:
头文件的包含:
头文件的嵌套包含:
可执行程序的生成:
电脑不能直接执行C语言代码,计算机能够执行二进制指令;而编译器就是把C语言代码翻译成二进制指令,所以编译器就是完成翻译官的工作
注:可执行程序中包含了二进制指令,翻译环境一般是指编译器,运行环境一般是指操作系统
对于常见的编写代码的软件:vscode2022来说,它是一个集成开发环境(包含了编译器、编辑器、链接器、调试器)
c1.exe --- 编译器
link.exe --- 链接器
注:上图是在windows环境下的编译与链接(目标文件以 .obj 为后缀)
注意事项:
- 多个 .c 文件单独经过编译器,编译处理生成对应的目标文件
- 多个目标文件和链接库一起经过链接器处理生成最终的可执行程序
- 链接库是指运行时库(它是支持程序运行的基本函数集合)或者第三方库
编译又可以分成:预处理(有些书也叫做预编译)、编译、汇编三个过程;Windows环境下编译原理与在linux环境下一致
注:上图是在linux环境下的编译与链接(目标文件以 .o 为后缀)
预处理(预编译):
在C语言中,使用gcc来进行编译,预处理使用到的指令应该是 -E 选项,得到的是 .i 文件;可以通过 -o 来生成文件;预处理会进行以下操作
操作事项:
- 将所有的 #define 删除,并展开所有的宏定义
- 处理所有的条件编译指令,如:#if、#ifdef、#elif、#else、#endif
- 处理#include预编译指令,将包含的头文件的内容插入到该预编译指令的位置,这个过程是递归进行的,也就是说被包含的头文件也可能包含其他头文件
- 删除所有的注释
- 添加行号和文件名标识,方便后续编译器生成调试信息等
- 保留所有的#pragma的编译器指令,编译器后续会使用
经过预处理后的 .i 文件中不再包含宏定义,因为宏已经被展开。并且包含的头文件都被插入到 .i 文件中,所以当我们无法知道宏定义或者头文件是否包含正确的时候,可以查看预处理后的 .i 文件来确认
预定义符号:
C语言设置了一些预定义符号,可以直接使用,预定义符号也是在预处理阶段处理的。
1 __FILE__ //进行编译的源文件
2 __LINE__ //文件当前的行号
3 __DATE__ //文件被编译的日期
4 __TIME__ //文件被编译的时间
5 __STDC__ //如果编译器遵循ANSI C(C语言标准),其值为1,否则未定义
1是为了找到文件所在位置,2是指使用__LINE__语句的行号,不是所有编译器都遵循ANSI C(例如vscode2022)
经过预处理以后,预定义符号已经替换成当前数据了
#define(重难点):
分为两种定义
1. #define 定义常量
2. #define 定义宏
第一种的讲解(定义常量):
#define MAX 1000
在经过预处理以后,整个文件当中只要出现MAX的地方都换成1000,#define消失
在C语言当中,#define还有以下常见定义格式:
由上图不难看出,#define也可以是定义一个循环语句或者打印语句(诸如此类的C语言代码)
那么 #define MAX 1000 与 #define MAX 1000; 有区别吗?
前者在经历预处理后,任何的MAX都会变成1000;而后者在经历预处理后,任何的MAX都会变成1000;
第二种的讲解(定义宏):
#define机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏或者宏定义。
下面是宏的申明方式:
#define name( parament_list) stuff
其中的parament_list是一个由逗号隔开的符号表,它们可能出现在stuff中。(即parament_list是name这个符号表的参数)
注意:参数列表的左括号必须与name紧邻,如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分(就是定义了一个符号name,name后的所有代码都是name的内容)。
例如下述代码,就是创建了一个函数,这个函数是完成了x^2的操作
#define SQUARE(x) x*x
那么如果给一个变量 int a = 5,那么SQUARE(a) 就是把a放入宏当中,然后宏再返回一个表达式,预处理过后将#define删除
那么按照上面的思路来推理SQUARE(a+1)还是不是36?
答:不是,最后是11。宏的参数是直接替换进去的,例如我们假设的a+1的例子,传参之后,得到的表达式应该是a+1*a+1(并没有括号,根据运算符的先后,最后结果应该是5+5+1 == 11)。因此如果我们想要得到36,就需要 ((x)*(x)) 这样来定义宏。
以上告诉我们,在使用宏时不要吝啬括号
宏替换的注意事项:
- 宏参数和#define定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归(在宏中调用宏本身)
- 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
宏定义相较于一般自定义函数的优点:
- 一般函数需要经过传参(函数调用)、执行运算和return操作(函数返回),而宏函数简化成只有执行运算的操作(在预处理后,计算机已经把所有宏定义的符号替换成宏的内容了)。所以宏比函数在程序规模和速度方面更胜一筹
- 函数参数声明只能一种类型,而宏的参数类型是任意的
- 宏有时可以完成一些函数做不到的事情
比如:宏的参数可以出现类型,但函数不能
比如我们在进行动态内存开辟的时候,每次使用malloc函数都需要:
(void*)p = (void*)malloc(10 * sizeof(void));
//次数void是指任意类型,而不是空
那么在这种情况下,如果我们想要将他简化,可以创造一个自定义函数Malloc,让他等于赋值等号后面一串内容;这样写起来即为Malloc(10,void)(还是以上述代码为例)
可是函数参数不能是类型,因此我们需要用到宏定义来解决这个问题,就像如下定义
#define Malloc(n,type) (type*)malloc(n*sizeof(type))
带有副作用的宏参数:
- 每次使用宏,一份宏定义的代码将插入到程序当中。除非宏比较短,否则程序代码长度会很长。而一般函数代码只需要出现一次,后续直接进行调用操作即可,代码相对简短很多
- 宏的调试是不够清晰明了的
- 宏由于类型未定义,不够严谨
- 宏可能会因为运算符优先级问题,导致程序出错
- 如果宏中参数多次出现同一个,那么假设使用该宏(名字为test)的时候 ,test(a++) 中的参数a会执行多次a++的运算,这不同于函数调用只会在传参前 +1
宏和函数对比总结:
#和# #:
#运算符:
在讲解该运算符以前,我们先得了解以下两种代码
printf("helloworld");
printf("hello" "world");
以上两种代码的输出是相同的,这也告诉了我们,在C语言当中,使用printf函数时打印内容可以由多个字符串组合而成
#运算符是将宏的一个参数转换为字符串字面量。它仅允许出现在带参数的宏的替换列表中
因此,#运算符操作可以理解为“字符串化”
当我们有一个变量int a = 10 的时候,如果想要打印出:the value of a is 10
就可以写:
#define PRINT(n) printf("the value of "#n" if %d",n);
//
int a = 10;
……;
//
PRINT(a);
上述代码详解:
首先是#define,进行了宏定义(符号表为PRINTF(n)),参数为n;后续对该宏的展开是指printf一个内容,这个内容里面包含了数据的数值打印以及#n;而 #n 完成的操作即是后续程序输入什么变量名称,他就会在 #n 处打印什么名称,最后可以得到 the value of a is 10;换言之,如果没有#运算符,最后打印结果就会变成 the value of n is 10。然后在后续代码使用该宏即可。
但以上代码只能完成整型打印,所以如果想要优化代码,我们可以将宏改为PRINT(n,type),type指的是要打印的数据在printf函数里的表现形式。在这以后,还是以上述代码为例,我们就可以是 PRINT(a,"%d") 了
# # 运算符:
# # 运算符可以把位于它两端的符号合并成一个符号,它允许宏定义从分离的文本片段创建标识符。该操作符被称为记号粘合,这样的连接必须产生一个合法的标识符,否则其结果就是未定义的。
假设现在我们要比较两个数据的大小,并且两个数据的类型是任意的(但两数据类型相同) ,那么在解决这一问题时就需要多次声明比较大小的函数,比较麻烦
因此我们就可以通过宏定义来解决这个问题,并在宏定义时使用# # 运算符
上述代码解释:
\ 是连接符,由于宏定义需要写在一行内,而如果在实现宏定义内容时过长,可以考虑通过 \ 把内容放在好几行,计算机在运行时会将其视为一行,增加代码可读性。
上述代码中,定义了一个符号表GENERIC_MAX(type),并在后续加上了一个自定义函数,这就表明宏的内容可以是自定义函数也可以库函数(限制小);其中,自定义函数返回类型和参数都是type(也就是我们输入计算机的类型)
而函数名我们想让它以 类型_max 的形式出现,就可以通过 type##_max 的取名方式;此处 type##_max 中的type##就是直接以传入的类型来去命名我们的函数
因此,通过了宏定义(内容为函数),多次调用宏,即可实现多个代码大致相同的自定义函数的创建,一步到位;并在后续代码中直接使用由宏定义来定义的函数
# 、# #的不同:
#是对某一个字符(例如 n 和 #n)的修改;# #是对字符串(例如函数名)中的某部分的修改
#undef:
该指令是用来移除一个宏定义的
命名约定:
由于函数和宏的使用语法很相似,所以仅仅通过代码语言无法区分两者。
因此我们平时有个习惯
宏名全大写
函数名不要全部大写
条件编译:
有时候,我们会写一些调试性代码,这种代码删除可惜,保留又碍事(代码可读性受损),此时我们可以选择性地进行编译,即称条件编译。以下有几种条件编译的种类(全部都是以宏为基础而存在的):
种类一:
#if 常量表达式 是判断由#define定义的常量是否满足某一个关系,就比如M(随意假设的一个由define定义的常量)>0,M < 10 等;#endif 是到这里结束的含义,即如果M的表达式为真,就开始执行从 #if 开始到 #endif 语句结束的代码;通过这样的方式,我们不想执行某一块的代码只需将 #define M num 中的num更改掉,改为表达式为假的情况即可
种类二:
基于种类一的全部特点之下,可以有选择地挑选自己想要执行的语句,就像是else-if语句中的 else if语句 else语句 if语句
种类三:
#if defined(symbol):如果某个符号被用 #define 定义了为真,未被定义为假
#ifdef symbol:和#if defined(symbol) 相同用法
e.g #define MAX 10 #ifdef MAX
由于已经定义了MAX这个符号,因此 #ifdef MAX 为真
#if !defined(symbol):与 #if defined(symbol) 相对,某个符号未被定义为真,被定义了为假
#ifndef symbol:和#if !defined(symbol)相同用法,即if not defined 的含义
种类四:
该种类即为判断某个符号是否有被定义的分支语句
编译:
编译过程就是将预处理后的文件进行一系列的:词法分析、语法分析、语义分析及优化,生成相应代码的汇编文件
用到的是 -S 指令,是针对 .i文件进行操作,生成的文件是 .s 文件(里面存储了汇编代码)
即是将 C语言代码变成汇编代码
词法分析:
将源代码程序被输入扫描器,扫描器就是将代码中的字符分割成一系列的记号(关键字、标识符、字面量、特殊字符等)
例如下面的代码:
array[index] = (index+4)*(2+6)
会进行以下拆分
语法分析:
接下来语法分析器,将对扫描产生的记号进行语法分析,从而产生语法树。这些语法树是以表达式为节点的树
语义分析:
由语义分析器来完成语义分析,即对表达式的语法层面分析。编译器所能做的分析是语义的静态分析。静态语义分析通常包含声明和类型的匹配,类型的转化等。这个阶段会报告错误的语法信息。
以上只是笼统地将编译器的工作原理概括了出来,具体内容还请搜寻《编译原理》这门课程或者相关资料
汇编:
用到的是 -c 指令,是针对 .s 文件进行操作,生成的文件是 .o 文件(目标文件)
即是把汇编代码转换成了机器指令
链接:
链接是一个复杂的过程,链接的时候需要把一堆文件链接在一起才生成可执行程序文件。
链接过程主要包括:地址和空间分配,符号决议和重定位等等
链接解决的是一个项目中多文件、多模块之间相互调用的问题。(在一个文件中只需要声明外部还有一个文件,另外一个文件中的内容也可以在该文件中使用,如下图的两个 .c 文件,以及一个文件中通过 extern int Add(int,int))
而符号表指的是由某个函数符号及其函数符号所存放的地址构成的表格,如下图所示
在使用链接操作合并多个 .c 文件时,会将符号表一同合并,那么到底是保留位于 0x1000 的Add函数,还是保留 0x0000 的Add函数呢?(Add函数所位于的位置都是假设的)
由于后一个Add函数地址无效,所以只保留前一个Add函数。
部分全局符号表,在合并时将有效地址留下来,无效的地址舍去,这一过程叫做重定位
运行环境:
- 程序必须载入内存中。在有操作系统的环境中:一般由操作系统来完成。在独立环境中,程序的载入必须手工安排,也可能是通过可执行代码置入只读内存来完成。
- 程序的执行便开始,接着便调用main函数。
- 开始执行程序代码。这个时候程序将使用一个运行时栈堆(函数栈帧内容),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存或者动态开辟内存(malloc,realloc)
- 终止程序。正常终止也可能意外终止(电脑死机、断电等)
头文件的包含:
"文件名":该种包含方式叫做本地文件包含,是先在源文件所在目录下查找,如果该头文件未被找到,编译器就会像找库函数头文件一样在标准库里查找头文件
<文件名>:该种包含方式叫做库文件包含,是直接去标准库里查找,如果找不到就提示编译错误
所以,库文件也可以通过 "文件名" 的方式来查找,只是这样做查找的效率降低,同时不容易区分库文件还是本地文件
头文件的嵌套包含:
#include 指令可以使另外一个文件被编译,就像它实际出现在了 #include 指令的地方一样
替换方式:预处理器先删除这条指令,并用包含文件的内容替换。
一个头文件被包含10次,那就编译10次,因此被重复包含对编译压力比较大。
而在开发程序写代码的时候,极大可能会出现多次包含的情况
就比如有个 test.c 文件,temp1.c 文件和 temp2.c 文件都因某种需要包含了它
与此同时,又有一个 end.c 文件包含了 temp1.c 文件和 temp2.c 文件
这时候,end.c 文件就包含了两次 test.c 文件,重复编译两次
那么像上面这种情况应该怎么办呢?
方法1(条件编译):
每个头文件的开头写
#ifndef __TEST_H__
#define __TEST_H__
//头文件的内容
#endif
//__TEST_H__可以改成其他标识符,例如M,N……
方法2(用编译器自带的指令):
#pragma once
就可以避免头文件的重复引用