程序环境和预处理:编译器做了些什么?程序到底是怎么运转起来的?

目录

程序的执行环境:

翻译环境:

分步细述:

编译与链接:

 符号表是什么?

链接:

预处理详解

预定义符号

#define定义标识符

#define 定义宏

 #define 替换规则

 #和##

​编辑

 ## 的作用

带副作用的宏参数

宏和函数对比

优点:

 缺点:

命名约定

#undef

命令行定义

条件编译

文件包含

详细说说:

嵌套文件包含


程序的执行环境:

在ANSI C的任何一种实现中,存在两个不同的环境。
第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。
第2种是执行环境,它用于实际执行代码。

编译器其实可以概括为一位翻译官,我们输入的高级语言在经编译器之手后才能变成机器看得懂的语言,机器才会帮助我们计算我们想要的答案,好比我们闭着眼睛就能写出来的“Hello World”,其中编译器的活要干的还真的很多,接下来则是编译器在与机器“交流”的过程简述,其本身内容其实有非常多细节,如果有机会我会尝试整理一篇更加完整的过程,在这里我们只需要知道编译器大概干了些声明就好。

推荐参考书籍:《程序员的自我修养》

翻译环境:

程序生成exe可执行文件的过程大致如下图所示

源文件通过编译过程生成目标文件,目标文件通过链接器与库内的函数或者是程序员自己实现的函数相链接捆绑在一块实现生成可执行文件。

分步细述:

每个源文件.c都会经过编译器然后生成一个后缀名为.obj的目标文件,目标文件与链接库经过链接器之后生成可执行程序

链接库是除了我们自己实现的一些功能外所提供头文件的函数等等。以上为笼统的概述,更多细节如下。

编译与链接:

 编译的过程分步可分为以下3步。

1.预编译,预处理:我们借用Linux里来观察,发现在预编译的过程中生成了一个后缀为.i的文件,我们发现,在这个.i文件里,包含了了整个stdio.h的头文件

 

在尝试定义了符号之后发现,在.i文件里define定义的符号被替换,定义的符号也被删除,注释也被删除。

总结下来,在整个预处理的环节,差不多基本上属于文本操作

2.编译

在这个环节,所有被文本处过后的.i文件被处理成了装满汇编语言的.s文件,在这个文件里都是汇编语言,总结下来就是把C语言转换为汇编代码,其中进行了语法分析,词法分析,符号汇总,语义分析,这四个动作

3.汇编

而在汇编该过程,生成了一个.o文件,打开来看发现,在这个过程汇编代码被转换成为了二进制的指令,并且会形成符号表

 符号表是什么?

每一个符号,比如main,Add函数都会被分配一个地址形成一个表格,这个表格会在链接这个过程中发生作用。

当我们需要使用某一些函数的时候,这些符号表就相当于函数的地址,链接阶段会寻找这个函数的符号,这个符号上有这个函数的地址,这样就成功建立了文件之间函数的联系,并且由于链接的时候本质上是一次大杂烩,函数的声明与定义不在同一个文件内也是不会怎么出问题的,大杂烩的时候直接可以寻找到函数的符号。

链接:

1.合并段表

.o文件内部在生成之后其内部有各种各样的段,如存放机器指令的代码段,存放变量的数据段,以及存放未初始化变量的BSS段以及各种其他的段,在这里不过分展开。

2.合并符号表和重定位(这个过程简单来讲就是选取有效的地址应用,比如有两个源文件,分别有一个Add函数,另一个有Add和main函数,我们在生成符号表的阶段会给函数们一个符号,差不多也是个地址。

在这个情况中有虽然我们声明了在另一个文件里的Add函数,根据前面我们所了解的过程,在编译的过程下只会将各自源文件内部的符号,表达式等等整理出来,进行优化,然后将各个部分在链接阶段整合起来,此时在链接的过程中,编译器寻找到了Add的符号,并将它的地址调用,完成了源文件之间的链接。

#pragma pack once 头文件中使用,功能是防止头文件重复应用

预处理详解

在前文提到过的预处理环节里我们能做的事情还是很多的,比如定义一个宏或者标识符以及其他的东西,接下来则是对宏这类在预处理阶段被替换的东西较为详细的记述。

预定义符号

__FILE__ //进行编译的源文件
__LINE__ //文件当前的行号
__DATE__ //文件被编译的日期
__TIME__ //文件被编译的时间
__STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义

这些符号是c语言内置的,我们尝试将这些符号打印出来康康

 借用这些符号我们可以很轻松的得到当前文件的信息。

#define定义标识符

语法:
#define name stuff

 #define 所提供的功能可以很方便的帮助我们做一些繁琐的工作,如下:

#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__ )

注意,创建完的#define stuff后面最好不要添加分号,因为这样会引发歧义。

#define MAX 1000;
#define MAX 1000

f(condition)
max = MAX;
else
max = 0;

这样子会引发语法错误。

#define 定义宏

 #define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)

 宏的定义其实更像是文本的替换,在预编译的过程中,所有define定义的宏都会直接替换对应名字的文本,替换之后的文本可以是参数,也可以是一串表达式。

下面是宏的申明方式:

#define name( parament-list ) stuff

其中的 parament-list 是一个由逗号隔开的符号表,它们可能出现在stuff中。注意:参数列表的左括号必须与name紧邻。如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分

 比如说我们希望创建一个宏,让其直接替换掉加法的函数,我们可以照着规则这样子创建:

#define name( parament-list ) stuff

#define ADD(x,y) ((x) + (y))

 我们发现它确实实现了加法函数的效果,也非常像函数的传参,那么我们可能会有一些问题,为啥要加这么多括号呢?还有,这样子的话宏是否可以完全替代函数了呢?

我们先讨论第一个问题,为什么要加这么多括号,之后再将其与函数做对比,探讨第二个问题。

   我们观察一个例子来理解它的规则:

#define SQUARE( x ) x * x

   这个宏接收一个参数 x .

   如果在上述声明之后,我们把

SQUARE( 5 );

   置于程序中,预处理器就会用下面这个表达式替换上面的表达式:

5 * 5

   这个宏存在一个问题:
   观察下面的代码段:

int a = 5;
printf("%d\n" ,SQUARE( a + 1) );

   我们乍一看可能会觉得它会被替换为6*6得到36,但其实它输出得是11.

   为什么?

   替换文本时,参数x被替换成a + 1,所以这条语句实际上变成了:
   printf ("%d\n",a + 1 * a + 1 );

   替换可不是代入,根据我们通常的数学计算逻辑应该这样修改:

  

 #define SQUARE(x) (x) * (x)

   这样子,这个表达式就会被这样替换:

printf ("%d\n",(a + 1) * (a + 1) );

   但是这样子加括号还是很难避免一些其他计算问题,比如当我们尝试去替换另一些表达式   的时候

比如下面这个:

int a = 5;
printf("%d\n" ,10 * DOUBLE(a));

  我们把表达式替换进去,就变成了如下的算式:

printf ("%d\n",10 * (5) + (5));

这样子计算出来的结果就不一样了

改成如下这样就可以了:

#define DOUBLE( x) ( ( x ) + ( x ) )

所以为了保证计算顺序不被运算符等操作干扰计算顺序,用于对数值表达式进行求值的宏定义都应该用这种方式加上括号
 

 #define 替换规则

在程序中扩展#define定义符号和宏时,需要涉及几个步骤。
1. 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先
被替换。
2. 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
3. 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上
述处理过程。
注意:
1. 宏参数和#define 定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归。
2. 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
 

总结一下就是:宏不会替换字符串常量内同名的文本,宏不能实现递归,宏在替换完成后其内部参数值被替换。在这里,我们也回答了一部分上面宏是否能替代函数的问题,很显然,函数的递归功能是不可以被宏替代的。

字符串常量的内容并不被搜索的实例:

 #和##

 既然字符串内的表达式不会被替换,那么我们怎么用宏去替换字符串内的参数呢?

如何把参数插入到字符串中?

char* p = "hello ""bit\n";
printf("hello"" bit\n");
printf("%s", p);

我们执行以上代码,发现“”会被直接掠过,前后的字符串会被直接拼接

 那么我们可以修改宏,其中#作用是不将符号做任何替换,直接转换成对应字符串

int i = 10;
#define PRINT(FORMAT, VALUE)\
printf("the value of " #VALUE "is "FORMAT "\n", VALUE);
...
PRINT("%d", i+3);//产生了什么效果?

 ## 的作用

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

#define ADD_TO_SUM(num, value) \
sum##num += value;
...
ADD_TO_SUM(5, 10);//作用是:给sum5增加10.

这样的连接必须产生一个合法的标识符。否则其结果就是未定义的

带副作用的宏参数

 由于宏本身的替换性,我们应该避免使用一些带有本值更改的操作符或者表达式比如:

#define ADD x++;
#define DOUBLE x*=2;

这样的表达式都会产生一些副作用,因为更改了x的本值。

宏和函数对比

 我们再次回到上面提出的问题,宏除了递归和函数有什么区别?宏是否在某些方面优于函数?

我们一般使用宏都用于实现简单的表达式,因为有如下优缺点:

优点:

1.宏是预编译过程的文本替换,所以与函数不同,它不会创建额外的栈帧,并且函数需要时间去返回值,所以宏在速度与规模上都优于函数。

2.更为重要的是函数的参数必须声明为特定的类型。
所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点型等可以
用于>来比较的类型。
宏是类型无关的。这意味着它可以适配更多中不同类型变量时的情况
 

 缺点:

1.如果我们定义的宏比较的长,那么替换之后的程序文本也会相应的变长。

2.宏是没有办法调试的。成也替换,败也替换,宏不同于函数,我们在调试的时候可以轻松的进入到函数内部观察其运行时的效果,宏是完全没法看的到的,因为直接将表达式替换掉了。

3.优先级问题,如前文所提,控制表达式的优先级很麻烦,需要加很多的括号,很蛋疼。

4.宏是类型无关的,所以也是不够严谨的。

宏有时候可以做函数做不到的事情。比如:宏的参数可以出现类型,但是函数做不到

#define MALLOC(num, type)\
(type *)malloc(num * sizeof(type))
...
//使用
MALLOC(10, int);//类型作为参数
//预处理器替换之后:
(int *)malloc(10 * sizeof(int));

直接替换掉类型可以很方便的帮助我们动态开辟内存,这也算是宏的一大优点。

总结一下:宏在应付小问题,比如简单的加法运算之类的时候速度以及大小方面碾压函数,但一旦复杂则非常麻烦且难以调试以及修改,与类型无关的它也不够严谨,可能会出现类型错误。但它对于类型的替换也可以让其变得灵活。

所以,我们更希望利用宏去干一些小活,把大的还是留给函数。

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

命名约定

一般来讲函数的宏的使用语法很相似。所以语言本身没法帮我们区分二者。
那我们平时的一个习惯是:
把宏名全部大写
函数名不要全部大写

#undef

 这条指令用于移除一个宏定义,其实更像是定义抹除。

#undef NAME
//如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除

命令行定义

 许多C的编译器支持了这个功能,简单来说就是我们可以不用在main函数外头用#deifne定义一个符号,可以直接像定义变量一样直接定义在命令行内。

#include <stdio.h>
int main()
{
    int array [ARRAY_SIZE];
    int i = 0;

    for(i = 0; i< ARRAY_SIZE; i ++)
    {
        array[i] = i;
    }

    for(i = 0; i< ARRAY_SIZE; i ++)
    {
        printf("%d " ,array[i]);
    }
    printf("\n" );

    return 0;
}

在LInux环境下,我们可以在编译前直接对ARRAY_SIZE这个符号赋值,然后输出

条件编译

我们有时候会遇到一些很蛋疼的情况,比如我们希望看到数组内部赋值的时候每次迭代的情况,调试起来看稍微有点麻烦,借助printf的话比较好,但是用完了就要删,删完了又有问题就又要再用,删了可惜,留着又碍事,我们不妨借助条件编译来选择性编译某一段代码。

#include <stdio.h>
#define __DEBUG__
int main()
{
	int i = 0;
	int arr[10] = { 0 };
	for (i = 0; i < 10; i++)
	{
		arr[i] = i; 
	#ifdef __DEBUG__
	printf("%d\n", arr[i]);//为了观察数组是否赋值成功。
	#endif 
	}
	return 0;
}

 这个时候,DEBUG是有定义的,printf生效。

假如我们注释掉DEBUG的定义,会发生什么?

 没有打印,VS也很智能的直接把printf灰掉了。这样我们就实现了选择性的编译。

还有一些其他花里胡哨的条件编译指令,以下为其列举:

常见的条件编译指令:

1.
#if 常量表达式
//...
#endif

其本体逻辑和if语句是完全一样的,常量表达式内为真执行从if到endif的内容,表达式为假则不执行。

2.多个分支的条件编译
#if 常量表达式
//...
#elif 常量表达式
//...
#else
//...
#endif

 当然,既然有了if 语句,那就会有else if 其逻辑是相同的,在这里不做过多演示了。

3.判断是否被定义
#if defined(symbol)
#ifdef symbol
#if !defined(symbol)
#ifndef symbol

这个就是上面所举的例子了。

相应的,条件编译也是支持嵌套的。

4.嵌套指令
#if defined(OS_UNIX)//如果OS_UNIX是被定义的,那么执行以下指令
	#ifdef OPTION1
		unix_version_option1();
	#endif
	#ifdef OPTION2
		unix_version_option2();
	#endif
#elif defined(OS_MSDOS)
	#ifdef OPTION2
		msdos_version_option2();
	#endif
#endif

文件包含

 #include这条指令的效果非常好理解,在预编译阶段被删除,然后找到对应的文件名或者是库名替换然后展开,假如说一个源文件被包含了10次,那么就会被编译10次。

详细说说:

本地文件的包含 : #include "name"   不同与头文件的包含,本地文件的包含使用的不是<>而是双引号。

查找策略:先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。
如果找不到就提示编译错误。

Linux环境的标准头文件的路径:

/usr/include

VS环境的标准头文件的路径:

C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\include
//这是VS2013的默认路径

库文件包含:

#include <filename.h>

查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。
这样是不是可以说,对于库文件也可以使用 “” 的形式包含?
答案是肯定的,可以
但是这样做查找的效率就低些,当然这样也不容易区分是库文件还是本地文件了

嵌套文件包含

 假如说我们正在编写一个比较大的项目,有很多的源文件和头文件,整个项目的逻辑稍微有点复杂以至于变成了如下图的逻辑的时候我们可能会面对一个问题,一个文件被重复多次调用

 test1.c和test.h为了成功的调用comm.h内部的函数或者参数,需要include一次comm.h

需要调用test1.h的test.c则因为需要使用test1.h内部的参数,需要包含一次test1,但是test1.h内部的某些参数变量可能需要comm.h里头的参数,造成了重复调用。

如何解决这个问题?
答案:条件编译。
 

#ifndef __TEST_H__
#define __TEST_H__
//头文件的内容
#endif //__TEST_H__

或者:

#pragma once

就可以避免头文件的重复引入。
至此,概述结束,希望对你有点帮助!感谢阅读!

 

猜你喜欢

转载自blog.csdn.net/m0_53607711/article/details/126957124