欢迎您访问365答案网,请分享给你的朋友!
生活常识 学习资料

c语言进阶-第7节-程序环境和预处理

时间:2023-04-30
1、程序的翻译环境和执行环境
在ANSI C(遵循美国国家标准总局定义的c语言标准的c语言)的任何一种实现中,存在两个不同的环境。 第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令 第2种是执行环境,它用于实际执行代码

test.c里面存的是c语言的源代码

test.exe是二进制文件,里面存的是二进制指令/机器指令(机器能够读懂的是二进制,二进制指令是机器能够读懂的,因此也叫机器指令)

翻译环境就是将test.c中的c语言的源代码翻译成test.exe中机器能够读懂的二进制指令

运行环境就是去处理test.exe中的二进制指令,产生我们想要的结果

1.1.翻译环境

翻译环境去翻译c语言源代码可以分为两个过程:

1.编译:test.c中的c语言源代码经过编译过程,会生成一个目标文件test.obj

2.链接:编译生成的目标文件test.obj再经过链接,会生成test.exe的可执行程序

注:

1.当有多个.c源文件,每一个.c文件都会单独经过编译器编译处理生成对应各自的.obj文件;然后这些.obj文件整体再加上链接库一起,经过链接器链接处理生成.exe可执行文件

2.我们使用的库函数(比如printf等)都是放在相应的.lib静态库里面的(函数对应的库如下图所示),因此如果我们使用库函数的话,链接器也应该把对应的.lib静态库链接进去

总的过程如下图所示:

1.2.详解编译+链接过程

编译可以分为:预编译、编译、汇编三个过程

下面用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代码用很多代码代替了,其他完全一样。其实这里预编译就是把stdio.h文件里面的代码包含进来了,相当于对头文件进行了展开

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.3.运行环境
程序执行的过程: 1、 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中(没有操作系统的设备,比如单片机、嵌入式设备上),程序的载入必须由手工安排(将代码克隆到板子上内存中去),也可能是通过可执行代码置入只读内存来完成。 2、 程序的执行便开始。接着便调用 main 函数。 3、 开始执行程序代码。这个时候程序将使用一个运行时堆栈(函数栈帧)(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static )内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。 4、 终止程序。正常终止 main 函数;也有可能是意外终止。

2.预处理详解 2.1.预定义符号
__FILE__       // 进行编译的源文件 __LINE__     // 文件当前的行号 __DATE__     // 文件被编译的日期 __TIME__     // 文件被编译的时间 __STDC__     //如果编译器遵循ANSI C,其值为1,否则未定义(vs2022无法运行,因此不完全遵循ANSI C) 注: 1.这些预定义符号都是语言内置的。 2.这些预定义符号可以用来记录日志(程序如果太多代码太长,我们分析的时候就会比较复杂,我们可以借助于打日志的方法找问题)
代码1:

#include int main(){printf("%sn", __FILE__);printf("%dn", __LINE__);printf("%sn", __DATE__);printf("%sn", __TIME__);}

运行结果1:

代码2:(写日志的例子)

#include #include #include int main(){int i = 0;//记录日志FILE* pf = fopen("log.txt", "w");if (pf == NULL){printf("%sn", strerror(errno));return 1;}for (i = 0; i < 10; i++){fprintf(pf, "%s %s %s %d i=%dn", __DATE__, __TIME__, __FILE__, __LINE__, i);}fclose(pf);pf = NULL;return 0;}

运行后log.txt文件里面的内容:

2.2.#define 2.2.1.#define 定义标识符
语法: #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:%stline:%dt                                                   date:%sttime:%sn" ,                                                   __FILE__,__LINE__ ,                                                         __DATE__,__TIME__ )  

代码:

#include #define MM 100int main(){printf("%dn", MM);return 0;}

运行结果:

注:

1.在windows底下也是可以看到#define在预编译中的效果的

右击项目点击属性,在c/c++栏预处理器中,将预处理到文件选项改成是,应用确定

ctrl+F5运行代码(如果提示无法打开.obj文件,是因为我们在修改预处理文件为是之前没有运行过该代码,我们在修改前先运行就会生成.obj文件,然后再修改预处理文件为是并运行即可),运行之后debug文件夹里会生成.i文件,我们用vs编译器打开该.i文件

 

从上图我们可以看见经过预处理后,MM已经被替换成100了并且#define定义的代码已经没有了

2.2.2.#define 定义宏
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)
下面是宏的申明方式: #define name( parament-list ) stuff 其中的 parament - list 是一个由逗号隔开的符号表,它们可能出现在 stuff 中 注: 1.其实宏也是进行替换,如下图所示,MAX(a,b)会被替换成 x>y?x:y

2.我们在使用宏的时候最好是给符号用圆括号括起来,因为如果传过去的是表达式,表达式中的运算符和stuff中的运算符会判断优先级进行运算,和我们的逻辑不符,符号用圆括号括就会避免这种情况发生,如下图所示

 

3.参数列表的左括号必须与name紧邻。如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分

如下图所示,如果加了空格,预编译后代码中的DOUBLE被替换成(x) ((x)+(x))

代码:

#include #define DOUBLE(x) x+xint main(){printf("%dn", 10*DOUBLE(3));return 0;}

运行结果:

注:

1.我们的预期是DOUBLE(3)为6,6*10为60,但代码运行出来结果为33,因为DOUBLE(3)是被替换成了3+3,替换后代码为printf("%dn", 10*3+3),所以打印出来是33,但是如果给符号用圆括号括起来,那么就不会出现这样的问题,如下图所示因为加上括号,替换后代码为

printf("%dn", 10*((3)+(3))),与我们预期想法是相同的,所以写宏的时候符号要用圆括号括起来

2.2.3.#define 替换规则
在程序中扩展 #define 定义符号和宏时,需要涉及几个步骤。 1、 在调用宏时,首先对参数进行检查,看看是否包含任何由 #define 定义的符号。如果是,它们首先被替换(如下图所示会先将这里的M替换成100,再进行宏的替换)

2、替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。

3、 最后,再次对结果文件进行扫描,看看它是否包含任何由 #define 定义的符号。如果是,就重复上述处理过程。 注: 1、 宏参数和 #define 定义中可以出现其他 #define 定义的符号。但是对于宏,不能出现递归(宏里面不能包含一个宏)

2、当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。

2.2.4.#和##

#的作用:

宏的stuff中在一个宏的行参前面加上#,在替换的时候#参数变成对应的字符串,即"实参"

如何把参数插入到字符串中?函数是做不到的,宏可以做到,见下面代码

代码1:

#include int main(){printf("hello worldn");printf("hello " "worldn");printf("hel" "lo " "worldn");return 0;}

运行结果:

注:

1.只要printf括号内是字符串,当有多个字符串时在printf打印字符串的时候会拼在一个字符串中,


代码2:

#include #define PRINT(n) printf("the value of "#n" is %dn", n)int main(){int a = 10;PRINT(a);printf("the value of ""a"" is %dn", a); //PRINT(a)替换后就变成了该行代码int b = 20;PRINT(b);printf("the value of ""b"" is %dn", b);//PRINT(b)替换后就变成了该行代码return 0;}

运行结果:

注:

1.当我们想打印多个变量值时,一个变量用一次printf打印不方便,如下图所示我们想到使用函数对打印功能进行封装进行打印,但是打印的时候,双引号里面的变量名也要改变,函数不好改变双引号里面的变量名,如下图箭头所指,我们可以用宏来实现

2.在一个宏的参数前面加上#,就可以让这个参数变成对应的字符串,比如将a传给PRINT(n),那么stuff中的#n就代表"a"

## 的作用 ##可以把位于它两边的符号合成一个符号。 它允许宏定义从分离的文本片段创建标识符。

代码:

#include #define CAT(C, num) C##numint main(){int Class104 = 10000;printf("%dn", CAT(Class, 104));//Class104return 0;}

运行结果:

注:

1.上面代码的CAT(Class, 104)会被替换成Class104,##可以把位于它两边的符号合成一个符号,允许宏定义从分离的文本片段创建标识符

2.其实#和##在代码开发过程中用的是很少的

2.2.5.带副作用的宏参数
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果 比如下面的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("%dn", m);printf("a=%d b=%dn", 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.如果宏的参数带有副作用,那么这个副作用会在宏的内部体现出来,因为参数是直接替换进去的,如果参数替换多份,那么副作用就会体现多次,因此给宏传参的时候参数尽量不要带有副作用

2.2.6.宏和函数的对比
宏和函数的使用场景: 宏通常被应用于执行简单的运算,逻辑运算比较复杂的情况下宏容易出错推荐用函数 比如在两个数中找出较大的一个

#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 定义宏 函数 代 码 长 度 每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长 函数代码只出现于一个地方;每次使用这个函数时,都调用那个 地方的同一份代码 执 行 速 度 更快 存在函数的调用和返回的额外开 销,所以相对慢一些 操 作 符 优 先 级 宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多些括号 函数参数只在函数调用的时候求值一次,它的结果值传递给函数。表达式的求值结果更容易预测 带 有 副 作 用 的 参 数 参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果 函数参数只在传参的时候求值一次,结果更容易控制 参 数 类 型 宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用于任何参数类型 函数的参数是与类型有关的,如果参数的类型不同,就需要不同的函数,即使他们执行的任务是不同的。 调 试 宏是不方便调试的 函数是可以逐语句调试的 递 归 宏是不能递归的 函数是可以递归的
2.2.7.命名约定

Copyright © 2016-2020 www.365daan.com All Rights Reserved. 365答案网 版权所有 备案号:

部分内容来自互联网,版权归原作者所有,如有冒犯请联系我们,我们将在三个工作时内妥善处理。