目录
一、前言
二、 什么是程序环境?
三、 为什么要有程序环境?
四、如何理解程序环境?
🍎 ANSI C 标准
🍐 翻译环境和执行环境
五、详解翻译环境和执行环境
🍇翻译环境(重点!!)
💦编译环境(预处理---编译---汇编)
💦链接环境(链接)
🍉执行环境
六、预处理详解
🍓 预定义符号
🍌#define
💦#define定义标识符
💦#define定义宏
💦#define替换规则
🍊#和##
💦#
💦##
🍋#undef
🥝宏和函数对比
🍍文件包含
💦头文件被包含的方式
💦嵌套文件的包含
七、常考面试题
八、共勉
一、前言
本文主要是认识与学习C/C++中的程序环境和预处理,因为这一部分是面试经常会考到的一个知识点,并且对于后续学习Linux系统编程有很大的帮助(主要是自己不清楚😊),所以接下来我将详细的剖析C/C++中的程序环境和预处理。
二、 什么是程序环境?
⭐ 程序环境:是指将源文件(code.c / code.cpp)转换为程序的过程
三、 为什么要有程序环境?
⭐ 如果没有程序环境,编译器(gcc / g++)就无法识别我们所写的C/C++代码,也无法将源代码转换成可执行程序。
四、如何理解程序环境?
🍎 ANSI C 标准
ANSI C是由美国国家标准协会(ANSI)及国际化标准组织(ISO)推出的关于C语言的标准。ANSI C 主要标准化了现存的实现, 同时增加了一些来自 C++ 的内容 (主要是函数原型) 并支持多国字符集 (包括备受争议的三字符序列)。
ANSI C 几乎被所有广泛使用的编译器所支持,且多数C代码是在ANSI C基础上写的。
⭐ : 在ANSI C(标准C) 的任何一种实现中,都存在程序环境,并且将程序环境又细分为两种不同的环境。
⭐ :程序环境分为:翻译环境和执行环境
🍐 翻译环境和执行环境
💦翻译环境:在这个环境中源代码被转换为可执行程序的机器指令。
⭐ :在编译器中(以VS2019为例),我们写的C/C++代码都是文本的信息(各种字符串,数据,结构体等),站在人类的角度,我们能够理解,但是计算机不行,代码需要被翻译成计算机能够识别的指令: 二进制指令
💦执行环境:它用于实际执行代码
⭐ :原代码经过翻译环境后生成的二进制指令代码(可执行程序),再由执行环境执行生成最终需要的结果。
五、详解翻译环境和执行环境
我们先来笼统地讲一下这两个环境的联动,在第二模块再进行细讲
- 首先对于一个【test.c】的源文件来说,我们要将代码执行的结果输出到屏幕上,就需要有一个可执行程序【test.exe】
- 将【test.c】文件转变为【test.exe】文件的这段过程叫做翻译环境,翻译环境分为编译和链接两部分,而对于编译来说,又可以进行细分为【预编译】、【编译】和【汇编】三个组成部分;当经过翻译环境之后,就会生成一个【test.exe
】
的可执行文件- 此时再到执行环境,通过将程序读入内存,调用堆栈【stack】,存储函数的局部变量和返回地址,来计算出程序的运行结果,若是有打印语句就将结果打印在屏幕上💻
🍇翻译环境(重点!!)
接下去我们来详细说说翻译环境,也就是【编译】+【链接】的部分
- 组成一个程序的每个源文件(.c)通过编译过程分别转换成目标代码(.obj)
- 每个目标文件由链接器捆绑在一起,形成一个单一而完整的可执行程序。
- 链接器同时也会引入标准C库函数中任何被该程序所用到的函数,且可以搜索程序员个人的程序库,将其需要的函数也链接到程序中。
⭐ 注意1:为什么需要多个源文件?
对于目前没有接触过项目的程序员来说,你完全可以将所有的代码都塞在一个源文件中,这是因为一个人需要写的代码量非常的少。
但你可以设想一下,现在你需要和一个团队进行配合来完成一个大项目,每个人都有不同的分工,但现在只有一个源文件,这会出现什么样的局面?
只有一个源文件意味着什么?这意味着你的团队只能有一人写代码,而其他人只能干看着;如果有多个源文件,那每个人都能在不同的源文件上写代码来完成协同工作。
⭐ 注意2:这里的链接库是什么?
由于每个源文件【.c】都会经过编译器处理生成目标文件。多个目标文件又会经过链接器的处理以及链接库链接生成可执行程序
但是一定有同学对这个链接库有所疑问,我们来看一段代码
- 可以看到,对于这个我们写C语言时经常使用的printf(),它就被称为是库函数,包含在
stdio.h
这个头文件中。
- 而对于库函数来说是存放在链接库里的。当程序里要使用来自外部的函数时,就是使用库函数,所以在链接时就应该把他们所依赖的链接库链接进来。
举个例子:test.c、add.c、minu.c 分别为源文件
💦编译环境(预处理---编译---汇编)
⭐ :预处理
在这个阶段,预处理器将对源代码进行预处理,处理以#开头的预处理指令。
主要的预处理指令包括宏定义、文件包含和条件编译等。(所有的预处理指令都是在预处理阶段处理的)预处理器会展开宏定义,并将#include指令所引用的头文件内容插入到源代码中。同时,条件编译指令会根据条件判断决定是否保留特定部分的代码。
主要作用:
- 展开头文件
- 注释的删除
- 宏定义的符号替换
⭐ :编译
编译器会将预处理后的代码转换成汇编代码(assembly code)。汇编代码是由CPU能够直接理解和执行的低级指令。
在编译过程中,编译器会进行语法和语义分析(语法分析,词法分析,语义分析,符号汇总),检查代码是否符合C语言的规范,并生成相应的汇编代码。
主要作用:
- 语法分析
- 词法分析
- 语义分析
- 符号汇总
⭐ :汇编
汇编器将汇编代码翻译成机器代码(二进制代码),并形成符号表。这些机器代码是计算机的底层指令,可以由计算机的CPU直接执行。
汇编后的文件通常称为目标文件(Object File -- .obj),它包含了二进制代码以及一些有关符号(函数和变量)的信息,但还没有解析它们的具体位置。
主要作用:
- 将汇编指令转换为二进制指令(需要特定的文本阅读器)
- 形成符号表(没错,就这个功能)
💦链接环境(链接)
⭐ :链接
链接器将目标文件与需要的库文件(静态链接库或动态链接库)进行合并,形成最终的可执行程序。(合并段表,符号表的合并以及重定位)
链接器解析目标文件中的符号,将它们与其他目标文件或库文件中的定义进行匹配。如果找不到某个符号的定义,链接过程将失败,并给出相应的错误消息。
主要作用:
- 把多个目标文件和链接库进行编译
- 合并段表
- 符号表的合并和重定位
举个例子:
① sum.c
int global_val = 2021;
void print(const char* string) {printf("%s\n", string);
}
② test.c
#include <stdio.h>int main(void) {extern void print(char* string);extern int global_val;printf("%d\n", global_val);printf("Hello,World!\n");return 0;
}
翻译过程为:
解析图如下:
🍉执行环境
接着我们来聊聊程序的执行环境,这一块的话因为内容过于复杂,有太多底层的细节,因此不在这里祥解
⭐ :程序执行的过程🏃
- 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
- 程序的执行便开始。接着便调用main函数。
- 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。
- 终止程序。正常终止main函数;也有可能是意外终止
六、预处理详解
🍓 预定义符号
在C语言中,有一些预定义的符号,当我们需要查询当前文件的相关信息时,就可以使用这个预定义符号
1.__FILE__ //进行变异的源文件
2.__LINE__ //文件当前的行号
3.__DATE__ //文件被编译的日期
4.__TIME__ //文件被编译的时间
5.__STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义
6.__FUNCTION__ //返回所在函数的函数名
在预处理阶段被处理的已经定义好的符号为预定义符号。这些符号是可以直接使用的,是在C语言中已经内置好的。
注意:值得注意的是,__ 为两个下划线!
用法演示:
#include <stdio.h>int main(void) {printf("%s\n", __FILE__); // 返回使用行代码所在的源文件名,包括路径printf("%d\n", __LINE__); // 返回行号printf("%s\n", __DATE__); // 返回程序被编译的日期printf("%s\n", __TIME__); // 返回程序被编译的时间printf("%s\n", __FUNCTION__); // 返回所在函数的函数名return 0;
}
运行结果:
那么这些预定义符号有什么用?
- 如果一个工程特别复杂,这时去调试时可能会无从下手。所以需要代码在运行的过程中记录一些日志信息,通过日志信息分析程序哪里出了问题,再进行排查就如同瓮中捉鳖。
🍌#define
理解预处理,那#define肯定要理解,这个相信大家都用到过
💦#define定义标识符
#define NAME stuff
用法演示:
#include <stdio.h>#define TIMES 100int main(void) {int t = TIMES;printf("%d\n", t);return 0;
}
运行结果:100
在预处理阶段会把 TIMES 替换为 100。预处理结束后 int t = TIMES 就没有TIMES 了,会变为 int t = 100。
// 预处理前
int t = TIMES;
// 预处理后
int t = 100;
当然了, #define 定义的符号可不仅仅只有数字,还可以用来做很多事,比如
1.#define REG register //给关键字register,创建一个简短的名字
2.#define DEAD_LOOP for(;;) //用更形象的符号来替换一种实现
① #define REG register,给关键字 register,创建一个简短的名字:
#define REG registerint main(void) {register int num = 0;REG int num = 0; // 这里REG就等于registerreturn 0;
}
② #define DEAD_LOOP for(;;),用更形象的符号来替换一种实现:
#define DEAD_LOOP for(;;)int main(void) {DEAD_LOOP // 预处理后替换为 for(;;); ; // 循环体循环的是一条空语句DEAD_LOOP; // 那么可以这么写,这个分号就是循环体,循环的是一个空语句return 0;
}
③ #define CASE break;case ,在写case语句的时候自动字上break(很巧妙的偷懒):
#define CASE break;case // 在写case语句的时候自动字上breakint main(void) {int n = 0;//switch (n) {// case 1:// break;// case 2:// break;// case 3:// break;//}switch (n) {case 1: // 第一个case不能替换CASE 2: // 相当于 break; case 2:CASE 3: // 相当于 break; case 3:}return 0;
}
有个细节,再前面 #define 定义标识符时,为什么末尾没有加上分号呢?
这是因为,分号也会被当作替换内容替换到文本当中,可能会导致出现错误:
#define _CRT_SECURE_NO_WARNINGS 1#include <stdio.h>#define TIMES 100;int main(void) {int a, b;if (a > 10)b = TIMES; // b = 100;;else //else没有匹配对象b = -TIMES; // b = 100;;return 0;
}
所以,在 #define 定义标识符时,尽量不要在末尾加分号!(必须加的情况除外)
💦#define定义宏
#define NAME(parament-list) stuff
#define 机制允许把参数替换到文本中,这种实现通常被称为宏(macro)或 定义宏(define macro),parament-list 是一个由逗号隔开的符号表,他们可能出现在 stuff 中。
注意:
- 参数列表的左括号必须与 name 紧邻。
- 如果两者之间由任何空白存在,参数列表就会将其解释为 stuff 的一部分。
用法演示:3*3=9
#include <stdio.h>#define SQUARE(X) X*Xint main(void) {printf("%d\n", SQUARE(3)); // printf("%d\n", 3 * 3);return 0;
}
那么,(3+1) 的结果是什么?
#include <stdio.h>#define SQUARE(X) X*Xint main(void) {printf("%d\n", SQUARE(3+1));return 0;
}
运行结果:7
这是因为替换是在预处理阶段时替换,表达式真正计算出结果是在运行时计算。所以先替换:3+1*3+1=7
如果想获得 3+1 相乘(也就是得到 4×4 = 16) 的结果,我们需要给他们添加括号:
#include <stdio.h>// 整体再括一个括号,严谨
#define SQUARE(X) ((X)*(X))int main(void) {printf("%d\n", SQUARE(3+1));return 0;
}
另外,整体再套一个括号!让代码更加严谨,防止产生不必要的错误。比如,,我希望得到 10* DOUBLE,可能会得到以下情况:
*所以,用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,可以有效避免在使用宏时由于参数中的操作符或邻近操作符之间不可预料地相互作用。*不要吝啬括号!!!
💦#define替换规则
在程序中扩展 #define 定义符号或宏时,需要涉及的步骤如下:
- 检查:在调用宏时,首先对参数进行检查,看看是否包含任何由 #define 定义的符号。如果包含,它们首先被替换。
- 替换:替换文本随后被插入到程序中原来的文本位置。对于宏,函数名被它们的值替换。
- 再次扫描:最后,再次对结果文件进行扫描,看看是否包含任何由 #define 定义的符号。如果包含,就重复上述处理过程。
注意事项:
- 宏参数 和 #define 定义中可以出现 #define 定义的变量。但是对于宏绝对不能出现递归!
- 当预处理器搜索 #define 定义的符号的时候,字符串常量的内容并不被搜索。
🍊#和##
我们知道,宏是把参数替换到文本中。那么如何把参数插入到字符串中呢?
比如这种情况,使用函数是根本做不到的:
void print(int x) {printf("变量?的值是%d\n", ?) 函数根本做不到
}int main(void) {int a = 10;// 打印内容:变量a的值是10print(a);int b = 20;// 打印内容:变量b的值是20print(b);int c = 30;// 打印内容:变量c的值是30print(c);return 0;
}
这种情况,就可以用 宏 来实现。
💦#
# //把一个宏参数变成对应的字符串
#把一个宏参数变成对应的字符串。
使用 # 解决上面的问题:
#include <stdio.h>
#define PRINT(X) printf("变量"#X"的值是%d\n", X);
// #X 就会变成 X内容所定义的字符串int main(void) {// 打印内容:变量a的值是10int a = 10;PRINT(a); // printf("变量""a""的值是%d\n", a);// 打印内容:变量b的值是20int b = 20;PRINT(b); // printf("变量""b"的值是%d\n", b);// 打印内容:变量c的值是30int c = 30;PRINT(c); // printf("变量""c""的值是%d\n", c);return 0;
}
运行结果:
改进:让程序不仅仅支持打印整数,还可以打印其他类型的数(比如浮点数):
#include <stdio.h>
#define PRINT(X, FORMAT) printf("变量"#X"的值是 "FORMAT"\n", X);int main(void) {// 打印内容:变量a的值是10int a = 10;PRINT(a, "%d");// 打印内容:变量f的值是5.5float f = 5.5f;PRINT(f, "%.1f"); //printf("变量""f""的值是 ""%.1f""\n", f);return 0;
}
运行结果:
💦##
## //把位于它两边的符号合并成一个符号
##可以把位于它两边的符号融合成一个符号。它允许宏定义从分离的文本片段创建标识符。
用法演示:
#include <stdio.h>#define CAT(X,Y) X##Yint main(void) {int vs2003 = 100;printf("%d\n", CAT(vs, 2003)); // printf("%d\n", vs2003);return 0;
}
运行结果:
100
🍋#undef
#undef NAME //移除一个宏定义
用于移除一个宏定义。
用法演示:用完 M 之后移除该定义
#include <stdio.h>#define M 100int main(void) {int a = M;printf("%d\n", M);
#undef M // 移除宏定义return 0;
}
🥝宏和函数对比
举个例子:在两数中找较大值
① 用宏:
#include <stdio.h>#define MAX(X,Y) ((X)>(Y)?(X):(Y))int main(void) {int a = 10;int b = 20;int m = MAX(a, b); // int m = ((a)>(b) ? (a):(b))printf("%d\n", m);return 0;
}
② 用函数:
#include <stdio.h>int Max(int x, int y) {return x > y ? x : y;
}int main(void) {int a = 10;int b = 20;int m = Max(a, b);printf("%d\n", m);return 0;
}
那么,宏和函数那种更好呢?
答案是宏
- 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多,所以宏比函数在程序的规模和速度方面更胜一筹。
- 更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之,宏可以适用于整型、长整型、浮点型等可以用于比较的类型。因为宏与类型无关的。
当然,宏也有劣势的地方:
- 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。
- 宏不能调试。
- 宏由于类型无关,因为没有类型检查,所以不够严谨。
- 宏可能会带来运算符优先级的问题,导致程容易出现错。
所以,如果一个运算的逻辑足够简单,建议使用宏。反之,如果一个运算的逻辑足够复杂,建议使用函数。
🍍文件包含
我们已经知道,#include 指令可以使另外一个文件被编译。就像它实际出现于 #include 指令的地方一样。替换方式为,预处理器先删除这条指令,并用包含文件的内容替换。这样一个源文件被包含10次,那就实际被编译10次。
💦头文件被包含的方式
#include "filename"
#include <filename.h>
< > 和 " " 包含头文件的本质区别:查找的策略的区别:
- " " 的查找策略:先在源文件所在的工程目录下查找。如果该头文件未找到,则在库函数的头文件目录下查找。(如果仍然找不到,就提示编译错误)
- < > 的查找策略:直接去标准路径下去查找。(如果仍然找不到,就提示编译错误)
既然如此,那么对于库文件是否也可以使用 " " 包含?
答案是可以的。但是这样做查找的效率就低些,当然这样也不容易区分是库文件还是本地文件了。为了效率不建议这么做。
💦嵌套文件的包含
头文件被重复包含的情况:
- 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
还有一种非常简单的方法:
#pragma once // 让头文件即使被包含多次,也只编译一份
七、常考面试题
1. 由多个源文件组成的C程序,经过编辑、预处理、编译、链接等阶段会生成最终的可执行程序。下面哪个阶段可以发现被调用的函数未定义?( )
A.预处理
B.编译
C.链接
D.执行
【答案】:C
【解析】:
预处理只会处理
#
开头的语句,编译阶段只校验语法
,链接时才会去找实体,所以是链接时出错的,故选C。这里附上每个步骤的具体操作方式:
- 预处理:相当于根据预处理指令组装新的C/C++程序。经过预处理,会产生一个没有头文件(都已经被展开了)、宏定义(都已经替换了),没有条件编译指令(该屏蔽的都屏蔽掉了),没有特殊符号的输出文件,这个文件的含义同原本的文件无异,只是内容上有所不同。
- 编译:将预处理完的文件逐一进行一系列词法分析、语法分析、语义分析及优化后,产生相应的汇编代码文件。编译是针对单个文件编译的,只校验本文件的语法是否有问题,不负责寻找实体。
- 链接:通过链接器将一个个目标文件(或许还会有库文件)链接在一起生成一个完整的可执行程序。 链接程序的主要工作就是将有关的目标文件彼此相连接,也就是将在一个文件中引用的符号同该符号在另外一个文件中的定义连接起来,使得所有的这些目标文件成为一个能够被操作系统装入执行的统一整体。在此过程中会发现被调用的函数未被定义。需要注意的是,链接阶段只会链接调用了的函数/全局变量,如果存在一个不存在实体的声明(函数声明、全局变量的外部声明),但没有被调用,依然是可以正常编译执行的
2. test.c文件中包括如下语句:
#define INT_PTR int*
typedef int*int_ptr;
INT_PTR a,b;
int_ptr c,d;
其中定义的四个变量,哪个变量不是指针类型?( )
A.a
B.b
C.c
D.d
【答案】:B
【解析】:
预处理的#define是查找替换,所以替换过后的语句是
int* a, b;
我们到Linux来看看,更加清晰
其中b只是一个int变量,如果要让b也是指针,必须写成
int *a, *b;
而typedef没有这个问题,c、d都是指针
八、共勉
以下就是我对【C/C++】程序环境和预处理的理解,如果有不懂和发现问题的小伙伴,请在评论区说出来哦,同时我还会继续更新对Linux--gcc的理解,请持续关注我哦!!!!!