1.程序运行的过程
当我们在源文件中写好程序,我们的下一步就是点击编译按钮,程序就会运行,那么在这个运行过程中,程序的具体运行过程是什么呢?
答:任何一个程序都必须经过以下两个环境:
- 翻译环境:在这个环境中,原代码会转换为可执行的机器指令,也就是二进制代码。
- **运行环境:**在这个环境中,会执行代码,输出结果。
那么再让小编继续解析过程:
- **翻译环境:**包括编译和链接两大部分。
- **编译:**将代码转换为机器指令(二进制代码),包括预编译、编译和汇编三个过程。
- **链接:**链接文件,生成可执行程序。
- **运行环境:**包括运行可执行程序和输出结果。
我们将具体过程整理成如下图所示:
2.翻译环境
**⚀**编译
编译过程是通过编译器(cl.exe)进行实现的
🟢预编译(预处理)
(1).过程结果
将后缀为.c的源文件和后缀为.h的头文件处理生成为后缀为.i的文件
(2).具体过程
大致过程如下:
宏定义展开:
- #define的语句被删除,宏定义的内容被展开。
删除注释:
- "/* */"包含的语句都会被删除,在C99标准之后"//"后面的语句也会被删除。
增减行号和文件标识符:
- 方便调试。
【补充】处理包含文件:
- #include包含的头文件和源文件,会被加载到当前文件中来。
【补充】条件编译:
#if
、#elif
、#else
和#endif
指令允许根据条件选择性地编译代码。预处理器会根据给定的条件决定是否包含某段代码。#ifdef
和#ifndef
指令检查某个宏是否被定义,从而控制某段代码是否被编译。#ifdef
还可以与#define
结合使用,通过定义或取消定义某个宏来开启或关闭条件编译。【补充】空行和空白字符处理:
- 预处理器还会删除源代码中的空行和不必要的空白字符,以便于编译器处理。
【补充】处理其他预处理指令:
#error
和#pragma
等其他预处理指令也会在这个阶段被处理。#error
用于在发现错误时停止编译过程,#pragma
用于向编译器发送特定的指令或信息。生成临时文件:
- 预处理的结果通常会保存到一个临时文件中,这个文件包含了所有预处理指令处理后的代码,这个文件就是后缀为.i的文件。
大致过程如上,在后面会为大家详细介绍预编译过程中的一系列操作。
🟣编译
(1).过程结果
将后缀为.i的文件处理生成为后缀为.s的文件
(2).具体过程
大致过程如下:
- 词法分析:
- 使用词法扫描器读取源代码的字符序列,并将其分割成一系列的记号,如关键字、标识符、字面量、操作符和界符等。
- 语法分析:
- 使用语法分析器进行分析,利用前一阶段产生的记号来构建一个语法树,这个树形结构代表了程序的语法结构。
- 语义分析:
- 使用语义分析器在语法树的基础上进行静态语义分析,检查程序中的错误,如类型不匹配、未声明的变量等。同时,它还会处理动态语义,这些语义只能在运行期才能确定。
- 【补充】源代码优化:
- 源代码优化器会将整个语法树转化为中间代码,中间代码是与目标机器和运行环境无关的代码。
- 优化器会尝试改进代码的效率,例如删除不必要的计算、重新排列语句以提高性能等。
- 代码生成:
- 在代码生成阶段,编译器将中间代码转换为目标语言,就是转换为汇编代码,可能是直接转换为机器代码,形成的文件为后缀为.s的文件
- 【补充】目标代码优化:
- 在生成目标代码后,还可能对其进行进一步的优化,以提高执行效率。
大致过程就是如此,下面通过分析一条简单语句来掌握编译过程:
cpp//假设有一个整形数组arr,大小为5 int n = sizeof(arr)/sizeof(arr[0]);
词法扫描器进行扫描,一共有15个记号:
|----|--------|------|
| | 记号 | 类型 |
| 1 | int | 关键字 |
| 2 | n | 标识符 |
| 3 | = | 等于 |
| 4 | sizeof | 标识符 |
| 5 | ( | 左圆括号 |
| 6 | arr | 标识符 |
| 7 | ) | 右圆括号 |
| 8 | / | 除法 |
| 9 | sizeof | 标识符 |
| 10 | ( | 左圆括号 |
| 11 | arr | 标识符 |
| 12 | [ | 左方括号 |
| 13 | 0 | 数字 |
| 14 | ] | 右方括号 |
| 15 | ) | 右圆括号 |语法分析器将这15个记号的语法顺序用语法树的形式表现出来:
语义分析器在语法树的基础上进行分析,类型分析、转换等:
然后就将语法树转换为中间代码,再生成机器指令。
🟤汇编
(1).过程结果
将后缀为.s的文件处理生成为后缀为.obj的文件
(windows系统下为.obj文件,linux系统下为.o文件)
(2).具体过程
将经过预编译和编译阶段生成的汇编代码转换为机器指令(二进制代码)
⚁链接
链接过程是通过链接器(link.exe)进行实现的
(1).过程结果
将后缀为.obj/.o的文件链接库文件处理生成后缀为.exe的可执行文件
(2).具体过程
大致过程如下:
- 代码合并:
- 不同文件的代码进行合并,每个文件代码的分布空间是类似的,合并时就将对应空间的代码进行合并即可。
- 【补充】地址和空间分配:
- 确定各个模块在内存中的地址空间,并为全局变量和静态数据分配存储空间。
- 【补充】符号解析:
- 解决模块之间的外部引用,确保每个引用的符号都能找到其定义,并且正确地连接到相应的地址上。
- 【补充】重定位:
- 根据符号解析的结果,链接器会对引用的地址进行修正,以确保它们指向正确的内存位置。
- 【补充】静态链接:
- 如果程序使用了静态链接库,链接器会将所需的库函数代码拷贝到最终的可执行文件中。
- 【补充】动态链接:
- 相对于静态链接,动态链接则不会将库函数代码直接拷贝到可执行文件中,而是在程序运行时通过动态链接库(如Windows下的DLL或Linux下的SO)来完成
过程很复杂,我们知道合并代码、合并符号表和地址空间分配就可以了,下面看个例子:
cpp//Sub.c文件 int Sub(int a, int b) { return a - b; } //test.c文件 #include<stdio.h> //声明外部函数 extern int Sub(int a, int b); int main() { int a = 123; int b = 23; int ret = Sub(a, b); printf("%d\n", ret); return 0; }
3.运行环境
(1).过程结果
执行可执行文件.exe输出结果
(2).具体过程
【补充】
- *加载 :操作系统将编译好的可执行文件加载到内存中,为程序的运行准备环境。这包括分配内存空间和初始化必要的数据结构。
- 建立进程:操作系统创建一个新的进程,将加载的程序代码和数据放入进程的地址空间。
- *执行:CPU开始执行程序的指令。这时,操作系统的调度器会负责分配CPU时间给该进程,使其得以运行。
- 管理资源:操作系统负责管理程序运行时使用的资源,如内存、文件句柄等,并处理程序可能发出的系统调用,比如读写文件、网络通信等。
- *处理输入输出:程序在运行过程中可能需要与用户或其他程序进行交互,操作系统会处理这些输入输出请求,确保数据正确流动。
- 异常处理:如果程序运行过程中出现错误或异常,操作系统会进行处理,可能会终止程序或者提供错误信息给用户。
- *结束进程:一旦程序完成执行或被用户终止,操作系统会清理进程占用的资源,并释放相关内存空间。
大致过程就是程序加载到内存中去,分配空间地址,CPU执行程序指令,输出结果,最后用户关闭程序。
4.预编译详解
**⚀**预定义符号
C语言设置了几个预定义符号,可以直接使用,它们也在预编译阶段被替换掉:
|------------|--------------------------|
| 预定义符号 | 作用 |
| _ FILE _ | 进行编译的源文件 |
| _ LINE _ | 文件当前的行号 |
| _ DATE _ | 程序编译时的日期 |
| _ TIME _ | 程序编译时的时间 |
| _ STDC _ | 如果程序完全支持ANSI标准,就是1,反之无意义 |
比如我们写个程序将这些值打印出来:
cpp
//}
#include<stdio.h>
int main()
{
printf("%s\n", __FILE__);//打印源文件名字
printf("%s\n", __DATE__);//打印编译时的日期
printf("%s\n", __TIME__);//打印编译时的时间
printf("%ld\n", __LINE__);//打印文件当前的行号
//printf("%s\n", __STDC__);//打印ASNI设置值,报错了,说明VS不完全支持ASNI C标准
return 0;
}
运行结果
⚁****#define定义
#difine定义的东西都会被后面的表达式在预编译阶段被替换掉
(1).#define定义常量
😎定义方法
cpp#define CONSTANT_NAME CONSTANT_VALUE
- CONSTANT_NAME 定义的常量名,一般用大小字母和下划线来命名,以便于区分普通变量。
- CONSTANT_VALUE 常量的值,可以是整形、字符串、语句,任何C语言有用的表达式。
😎简单例子
简单定义
定义整形:
cpp#define A 1
定义字符串:
*
cpp#define B "wo shi da shuai ge"
复杂定义
定义表达式:
以打印为例:
cpp#define C printf("名字:%s 性别:%s 自我介绍:%s","zhangsan","man","wo shi yi ge da shuai ge")
上面的代码太长了都在一行,小编教大家一个方法:
cpp#define C printf("名字:%s 性别:%s 自我介绍:%s",\ "zhangsan",\ "man",\ "wo shi yi ge da shuai ge");
使用续行符" \ "就可以将语句分为多段,客观性就变好了。
(2).#define定义宏
😎定义方法
cpp#define MACRO_NAME(para1,para2,...) REPLACEMENT_TEXT
MACRO_NAME 定义的宏的名称,一般用大小字母和下划线来命名,以便于区分函数。
**para1,para2,...**参数,与函数的参数作用上类似,传值调用
REPLACEMENT_TEXT****要替换的文本或者表达式
#define定义的宏不同于定义的常量,宏中要包含参数,且参数要出现在后面的文本或者表达式中 ;
和函数类似,宏名==函数名,宏的参数==函数的参数,文本/表达式中包含参数==函数主体中包含参数。
😎注意事项
《论宏定义文本中的操作符和宏使用时的操作符之间的影响》
先看一段代码:
cpp#include<stdio.h> //定义宏,作用是求自己相乘的结果 #define TEST(x) x*x int main() { int a = 7; printf("%d\n", TEST(a + 2)); return 0; }
运行结果
显然这个宏是不可能得到目标结果的,当宏替换后TEST(a+2)==a+2*a+2==7+2*7+2==23,答案并不是81,这就是因为宏是展开的,当文本在宏出现的地方展开,附近的操作符优先级高(低)于文本内部的优先级,宏就达不到自己想要的结果了,这里就是如此,展开后加法的优先级低于乘法,使得乘法先算了。
那么解决的方法也很简单,定义文本时出现一个参数就给它加上圆括号,出现运算优先级也加上,最后再把文本加上,下面就是优化后的代码:
cpp#define TEST(x) ((x)*(x))
《论带有副作用的参数》副作用嘛,很简单就是自加加和自减减:
cppint a = 6; a++; ++a; a--; --a; //使用时改变原本的值,不是6了
直接给个例子体会一下:
cpp#include<stdio.h> //定义一个宏,比较两个整形数,返回大的数,使用三目操作符实现的 #define COMPAR_INT(a,b) ((a)>(b)?(a):(b)) int main() { int a = 10; int b = 20; int ret = 0;//返回值 //事实上是想比较它们加一后的数值 printf("ret == %d\n", COMPAR_INT(a++, b++)); //理论上是想要打印:21 printf("a == %d\n", a++); //理论上是想要打印:11 printf("b == %d\n", b++); //理论上是想要打印:21 return 0; }
下面我们来分析一下:
- 替换后的文本为:((a++)>(b++)?(a++):(b++))
- 显然是因为后置加加影响了输出结果,后置加加是先使用再加加,
- 那么比较后b大于a,此时a++,b++,a=11,b=21,
- 之后要返回b++的值,此时先返回21,之后b++,b=22
就是因为自加加影响了目标结果,所以我们要做的就是谨慎使用自加加/减减符号。
😎宏替换的规则
- 寻找宏:
- 宏被调用时,首先对参数进行检查,查看是否有#define定义的符号;如果有,它们首先被替换掉。
- 展开宏:
- 替换文本随后被插入程序中原来对应的文本的位置,原来的宏名以及参数被文本替换删除。
- 再次寻找宏:
- 之后再次重复上面的操作,直到所有的宏都被处理完以后,就结束。
注意:
- 宏参数和#define定义中可以出现其他#define定义的符号。
- 对于宏,不能出现递归。
- 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
😎定义宏和函数的对比
⚂****#和##
(1).#运算符
- 作用:将一个标识符(通常是宏参数)转换成一个字符串。
- 这在需要将标识符转换为字符串字面量时非常有用,比如在生成错误消息或者调试输出时。
- 比如:
cpp#include<stdio.h> #define PRINT(x) printf("This number is" " " #x) int main() { PRINT(6); return 0; }
运行结果
(2).##运算符
- 作用: 用于连接两个标记(可以是对象或函数的标识符、常量等)。
- 这个运算符在宏替换中经常使用,可以用于创建复合的标识符或合并多个标记以形成单个标记。
- 例如,如果有宏定义#define A(a, b) a##b,那么A(Hello,World)将被替换为标识符HelloWorld。
- 比如,相似函数的创建:
**⚃**条件编译
条件编译简单来说就是满足条件就编译,不满足就不编译,而且条件编译只保留满足条件的代码,不满足条件的代码将被删除。
(1).#if、#elif和#else
#if、#elif和#else是三条常用条件指令,注意看,其实是和我们前面学习的if、else if和else语句相似(elif==else if(简写)),事实上,它们功能上也是一模一样,每段语句后面接着的表达式为真就执行,为假就不执行;除此之外,在使用时还需要在末尾搭配#endif,表明#if家族判断结束。
使用方法
😎单分支判断#if
#if 常量表达式
语句
...
#endif //#endif是表示条件编译结束
- 解析:
- 如果常量表达式为真,以下到#endif前的代码就在预编译阶段保留下来,在后续过程中执行。
- 如果常量表达式为假,以下到#endif前的代码就在预编译阶段全部被删除掉了。
😎多分支判断#if、#elif和#else
#if 常量表达式
语句
...
#elif 常量表达式
语句
...
#elif 常量表达式
语句
...
#...(n条#elif语句)
#else
语句
...
#endif //#endif是表示条件编译结束
- 解析:
- 如果#if后的常量表达式为真,就在预编译阶段保留#if后面跟着的语句(到第一个#elif为止),其他条件下的语句不保留直接删除,不执行。
- 如果#elif后的常量表达式为真,就在预编译阶段保留#elif后面跟着的语句(到下一个#elif为止),其他条件下的语句不保留直接删除,不执行。
- 如果上面的#if语句和所有的#elif语句都为假,那么就保留#else下的语句,其他条件下的语句不保留直接删除,不执行。
当然还可以#if里面嵌套#if,很简单原理相同,就不过多介绍了
我们通过上面分析发现这其实就是if语句的变种,判断执行方法一模一样。
下面通过一个简单的例子来实战一下:
cpp
#include<stdio.h>
int main()
{
//定义一个宏
#define A 3
//如果宏的值为1,则打印
#if A==1
printf("定义的宏值==1\n");
//如果宏的值为2,则打印
#elif A==2
printf("定义的宏值==2\n");
//如果宏的值不是1和2,就打印不是
#else
printf("定义的宏值不是1和2");
#endif
return 0;
}
运行结果
😎#if作为注释
(2).#if判断宏是否定义
如果宏被定义,就执行
- #if definned(宏)
- 相当于在#if加上关键字defined(定义),来判断宏是否定义
- #ifdef 宏
- #ifdef可以理解为#if defined的简写
如果宏未被定义,就执行
- #if !defined(宏)
- 就是在判断宏定义的表达式上,加上"!",将判断条件反过来
- #ifundef
- 同理也可以理解#ifundef是简写,"!"变成了un(不)
由于上面的都是条件编译的关键字,使用时一定要配合#endif使用,表示条件编译的结束。
cpp#include<stdio.h> #define A 1 int main() { #if defined(A) printf("宏已定义\n"); #endif // #ifdef A printf("宏已定义\n"); #endif // #if !defined(A) printf("宏未定义\n"); #endif // #ifndef A printf("宏未定义\n"); #endif return 0; }
运行结果
(3).#undef
cpp//#define定义一个宏 #define A 1 //#undef取消一个宏的定义 #undef A
使用形式:#undef 宏
⚄头文件的包含
(1).头文件的包含方式
头文件的包含方式分为以下两种:
- 本地文件包含:
- 方法: 使用 #include "filename"的形式包含头文件。
- 查找方式:编译器会先在源文件所在的目录下查找指定的文件 ,如果找不到,则会继续在**标准位置(库文件所在位置)**查找头文件。如果最终仍然找不到,编译器会提示编译错误。
- 标准库头文件包含 :
- **方法:**使用 #include <filename>的形式包含头文件。
- 查找方式:编译器会在标准库路径下查找头文件,这些路径通常是预先设置好的(不同系统路径不同),包含了所有标准库头文件。
- 当然库文件的包含也可用双引号的形式包含,只不过查找方式上多了一步,效率降低了,所以一般不推荐这样包含。
- 补充一下不同系统下的标准库路径吧:
Linux系统:
标准库文件通常位于/usr/include目录下。
用户可以通过修改环境变量LD_LIBRARY_PATH来添加额外的库路径,
或者编辑/etc/ld.so.conf文件来添加库的搜索路径。
例如,如果需要添加/opt/gtk/lib作为库路径,可以使用命令export
LD_LIBRARY_PATH=/opt/gtk/lib:$LD_LIBRARY_PATH。
Windows系统:
对于Visual Studio,标准库文件通常位于安装目录下的VCinclude路径中,
例如C: \Program Files(x86)\Microsoft Visual StudioVC98\INCLUDE。
macOS系统:
在macOS系统中,GCC编译器的标准库路径通常默认为/usr/include。
(2).嵌套文件包含
嵌套文件包含是指在一个头文件中包含另一个头文件,而这个过程可以多层嵌套。
假设在编译的源文件中多次包含了同一头文件,那么编译器是如何处理的呢?
对于这种就是文件里面包含文件,就是嵌套文件包含。
而编译器对于源文件中多次包含同一头文件的处理方式就是包含多少次,就展开多少次。
那就会导致代码重复多余,编译的压力增大,那么又怎么办呢?
答案是:使用条件编译,只需要在头文件这样操作即可:
cpp
#ifndef __TEST_H__
#define __TEST_H__
//头⽂件的内容
#endif //__TEST_H
或者头文件中添加下面这条命令:
cpp
#pragma once
本章内容介绍,下章见,拜拜!!!