【C语言进阶】程序环境和预处理

前言:本篇文章将详细问你讲解一个程序到底是如何从代码到编译到运行起来的,希望对你有帮助。

一.程序的翻译和执行环境

对于程序的翻译环境和执行环境,其实在ANSI C的任何一种实现中,存在两个不同的环境。ANSI C是由美国国家标准协会及国际标准化组织推出的关于C语言的标准。

第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。 第2种是执行环境,它用于实际执行代码。

也就是说,从一个test.c的源文件,到可执行程序的test.exe的文件,从一个文本文件代码,到一个二进制文件,经过的就是翻译环境,然后到执行环境中实现执行。

程序翻译运行环境展示图:

从代码到运行,经过了翻译环境中的预编译,编译还有汇编,也就是在编译器中进行的,在vs编译器中有一个cl.exe的编译运行程序,然后再到翻译环境的链接,在链接器进行,vs的连接器是一个link.exe的程序。然后再到运行环境中运行。比如这里的5+5变成10,就要经过这些过程。

而对于编译器中的每一个源文件,都会有自己独立的编译器中进行,然后得到目标文件,生成的多个目标文件再移动到链接器,和链接库中的链接,一起完成变成可执行程序。比如说我们在vs中test.c的源文件,而对于test.h的头文件来说,就不是进入编译的过程的。

比如说我在10.13日的代码,经过编译器变成目标文件obj,然后再变成可执行文件exe:

对于上面的部分,可以总结为:

1.组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code)。
2.每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。
3.链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中。


二.C语言程序的编译+链接

1.预编译

对于头文件的包含,其实在vs中并不明显,因为vs是集成开发环境,有一些底层的东西说看不见的。但是我们可以通过其他途径去了解,比如linux环境下的gcc编译器。在里面写下代码:

然后去编译完成后会生成一个a.out文件,然后打开运行就会看到结果,但是我们需要的不是结果,所以我们要看其他东西,所以我们可以在编译命令后加-E打开文件,会得到一堆东西,当我们查看到最后,我们会发现:

最后面的是我们的源代码!那前面800多行是什么,其实就是我们引头文件#include <stdio.h>的内容,也就是包含了头文件里面的内容,所以如printf的函数才能使用。

其实,在你引用的头文件中,所以的东西都会被引用到源文件中,只不过我们平时看不见而已,但是在编译器内部有,所以如果你自定义一个头文件的内容,在这里同样的方法编译运行,得到的就有我们的头文件内容。

像这样的#include对于头文件的包含,还有#define#pragma等预处理指令,就算在预编译的阶段完成的。对于预处理阶段,其实主要处理了下面几项内容:

1.头文件的包含#include (如#include <stdio.h>
2.#define定义符号的替换 (如#define p 100int x = p 经过预处理后会直接变成int x = 100
3.把源代码中的注释都删除掉

其实都属于文本操作!!

2.编译

到了编译这一步,就是要将C语言代码转化为汇编代码,也就是更底层的代码。听起来是不是觉得没什么感觉,其实这一阶段,我们做的是四个地方:

1.语法分析
2.词法分析
3.语义分析
4.符号汇总

1.语法分析是编译过程的一个逻辑阶段。语法分析的任务是在词法分析的基础上将单词序列组合成各类语法短语,如“程序”,“语句”,“表达式”等等。

2.词法分析(英语:lexical analysis)是计算机科学中将字符序列转换为单词(Token)序列的过程。进行词法分析的程序或者函数叫作词法分(Lexical analyzer,简称Lexer),也叫扫描器。

3.语义分析是编译过程的一个逻辑阶段, 语义分析的任务是对结构上正确的源程序进行上下文有关性质的审查,进行类型审查。语义分析是审查源程序有无语义错误,为代码生成阶段收集类型信息。

实际上上面三种就是都是一个检查和让我们的源代码是机器能看懂的,也就是像检查英语作文一样,语法有没有错误,有没有用错词,这几个词的作用是什么,然后收集信息。

4.对于符号汇总,汇总的都是全局的符号。比如自定义的函数,main函数。为往下一个阶段做准备。

3.汇编

汇编这一步就是把汇编代码转换成二进制指令(机器代码)。

然后在汇编这一步,还有一个形成符号表,就是将刚刚在编译过程中符号汇总好的东西编制成一个表,表中有符号的名字和它的地址。

4.链接

链接是在电子计算机程序的各模块之间传递参数和控制命令,并把它们组成一个可执行的整体的过程。

在链接这一步有两个东西做,第一个是合并段表,第二个是符号表合并和符号位重定位。

对于合并段表,其实就是把不同类型的东西整合一下,有符号段,文本段,等:

而对于符号表合并,就是把符号位合并到相同区域内,比如说你有好几个源文件,每一个源文件都独立就经过编译器得到目标文件,就有多个符号位:

那多个源文件通过编译器得到多个目标文件,有时候就会有相同的符号位名字,那么就要到符号位重定位了。

比如说下面这个函数:

//add.c
int Add(int x,int y)
{ 
    return x+y;
}
//test.c
extern int Add(int x, int y);
int main()
{
	int a = 10;
	int b = 20;
	int ret = 0;
	ret = Add(a, b);
	printf("%d", ret);

	return 0;

}

如果当我们生成符号表的时候,得到两个Add的符号,但是是不同的地址,取哪一个好,如果选择在test.c中的,那只是一个声明函数,找不到地址,所以代码就会报错,所以要选择add.c中的Add函数,这就是符号位重定位的作用。

所以总的来说就是:

编译环境中经过了下面的这几个过程,逐步形成一个可执行程序。


三.运行环境

对于运行环境来说,其实就是将可执行程序在电脑中运行起来。程序执行的过程主要有以下的步骤:

程序执行的过程:

1.程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。

2.程序的执行便开始。接着便调用main函数。

3.开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。

4. 终止程序。正常终止main函数;也有可能是意外终止。


四.预处理详解

上面大致的讲到了一个代码从代码到运行起来的一个过程,现在我们来详细了解一下各个部分,看看更深处的东西。

1.预定义符号

首先预处理中有一些预定义符号,我们来看一下:

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

既然是符号,我们可以打印他们看看是什么:

int main()
{

	printf("%s\n", __FILE__);
	printf("%d\n", __LINE__);
	printf("%s\n", __DATE__);
	printf("%s\n", __TIME__);
	return 0;
}

得到的是这样子的运行结果:

其实就是我们预定义中的一些数据,比如说存储代码的地址在哪,编译的这一行的行号是多少,编译的时间是什么等等等等。

我们直接将鼠标放在这些预定义的符号上,也可以看到:

所以,这些预定义符号都是语言内置的。我们可以通过打印看见里面表达的内容,在预编译过程中机器会知道些什么。


2.#define

#define是用来定义标识符常量,定义宏的。

语法:
#define name stuff

①定义标识符常量

我们可以在代码中定义一些#define的值,而当我们进行到预编译的时候,我们代码中的名字就会被替换。

比如:

#define MAX 100
//当我们定义一个MAX为100
int main()
{

	int a = MAX;
	printf("%d", a);

	return 0;
}

经历了预处理的过程后,其实代码中会变成这样:

//#define消失了
int main()
{

	int a = 100;//MAX被替换为#define定义的值
	printf("%d", a);

	return 0;
}

而且#define定义的不止是一些数值,还可以定义一些标识符常量:

#define db double
//比如我嫌double变量名字太长,我把他定义为db
int main()
{

	db a = 5.5;
	printf("%lf", a);
    //打印结果:5.500000
	return 0;
}

还有很多例子:

#define do_forever for(;;)     //用更形象的符号来替换一种实现
#define CASE break;case        //在写case语句的时候自动把 break写上。

然后问题来了,#define后面需不需要加呢?

答案是建议不要加上 ; ,这样容易导致问题。 因为别人的语法就是#define name stuff,你硬要加给分号,比如#define MAX 100;,那编译器就觉得100;是MAX,所以运行替换的时候,比如printf("%d",MAX);就会变成printf("%d",100;);,这不就运行错误了吗?在很多替换的场景的会导致一些问题,所以建议是不要的。

② 定义宏

#define还可以定义宏。

直接上代码:


#define SQUARE(X) X*X

int main()
{
	int a = 5;
	int ret = SQUARE(a);
	printf("%d", ret);
   //结果:25
	return 0;
}

其实在定义宏的过程中,SQUARE这个就是宏名,X就是宏的参数,X*X就是宏体内的参数。当我们传参过去之后会经过替换成宏的内容,然后再继续进行。而且对于X*X其实应该写为(X)*(X),因为在传参的时候如果我想实现的是int ret = SQUARE(a+5);,我想表达的是乘了之后再加,但是这里的也是就是传过去的就是a+5了,所以最后要加是括号。

总结:
用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数中的操作符或邻近操作符之间不可预料的相互作用。

③#define 替换规则

在程序中扩展#define定义符号和宏时,需要涉及几个步骤。

1.在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换。

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

3.最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程。

注意:

1.宏参数和#define 定义中可以出现其他#define定义的变量。但是对于宏,不能出现递归。

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


④带副作用的宏参数

当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果!

MAX宏可以证明具有副作用的参数所引起的问题。

比如这段代码:

#define MAX(a, b) ( (a) > (b) ? (a) : (b) ) 
int main()
{ 
    x = 5; 
    y = 8; 
    z = MAX(x++, y++); 
    printf("x=%d y=%d z=%d\n", x, y, z);//输出的结果是什么?
    return 0;
}

输出的结果是:x=6 y=10 z=9

其实在这里就是宏定义产生的副作用,因为宏定义中,宏的参数是不计算直接替换进去的,所以当我们预处理之后,表达式就会变成:

z = ( (x++) > (y++) ? (x++) : (y++));

所以当我们的表达式进行判断的时候,x和y都已经++过一次了,得到的也不是原来的值了,并且判断后还++了一次。


五.宏和函数的对比

上面我们写了两个数求较大值以宏的方式实现,而函数也可以实现,所以我们现在来对比一下宏和函数:

//宏的实现 
#define MAX(X,Y) ((X)>(Y)?(X):(Y))

//函数的实现 -
int Max(int x, int y)
{
	return x > y ? x : y;
}

int main()
{
	int a = 5;
	int b = 8;
	int z = Max(a, b);
	//int z = MAX(a, b);
	
	printf("a=%d\n", a);
	printf("b=%d\n", b);
	printf("z=%d\n", z);

	return 0;
}

哪个更好呢?选1选2,买定离手!!!

单纯在这里,其实是宏更好一点,原因如下:

1.用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹。

我们想一想,对于一个宏定义执行起来只是一个简单的替换,而对于函数来说,要进行函数调用,调用进去之后要进行计算,将计算结果再返回到我们的主函数中,这样子计算耗费的时间要更多。

2.更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点型等可以用于>来比较的类型。

对于宏是类型无关的!所以我们写一个宏就可以比较各种类型,但是函数的返回值却是有类型的,所以宏比函数灵活很多。


键盘侠:所以函数就是个弟弟。
程序员:当然不是!其实函数也有很多比宏优秀的地方!

1.每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。
2. 宏是没法调试的,是在预处理中直接完成了。
3. 宏由于类型无关,也就不够严谨。
4. 宏可能会带来运算符优先级的问题,导致程容易出现错。


所以说,宏有时候可以做函数做不到的事情。比如:宏的参数可以出现类型,但是函数做不到。而函数又有许多比宏优秀的地方,我们要辩证去看待。这里有一张宏和函数对比的表:

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

其实函数和宏的交手是有来有回的,我们应该根据实际情况去使用。

对于函数和宏还有一个命名约定(不强迫):一般来讲函数的宏的使用语法很相似。所以语言本身没法帮我们区分二者。

那我们平时的一个习惯是:
1.把宏名全部大写
2.函数名不要全部大写


六.预处理指令 #undef

前面一直在说#define的定义与作用,那么我们会不会定义太多了呢,又或者我需要已经定义的那个名字,如果我们想移除掉#define定义的宏定义,就可以用到 #undef这个指令。

#undef 这条指令用于移除一个宏定义。

比如:

#define	MAX 100

int main()
{
	int m = MAX;
#undef MAX
	int n = MAX;//报错!未定义标识符
	return 0;
}


七.条件编译

在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。

调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。

其实条件编译指的是:满足条件代码就参与编译,不满足条件,代码就不参与编译。那么是什么样子的呢,我们看一下:

#include <stdio.h>

int main()
{

#if 0//这就是条件,现在条件为0就不执行,为1就执行if到endif中的语句
	printf("hello");
#endif

	return 0;
}

而对于预处理阶段后,如果这个代码的条件为0,就不会出现在后续代码中,了,而如果条件为1,则按正常的代码一样出现在后面的代码中。

对于条件编译指令,常见的有下面的:

1.单个条件编译

#if 常量表达式
 //... 
#endif 
//常量表达式由预处理器求值。

如:

#define __DEBUG__ 1 

int main()
{
#if __DEBUG__ 
    printf("hello!!!");
#endif

    return 0;
}

2.多个分支的条件编译

#if 常量表达式
 //... 
#elif 常量表达式
 //... 
#else 
 //... 
#endif 

比如:

#define X 100
int main()
{

#if X==100
	printf("hello!");
#elif X==200
	printf("aaaahhhhhh!");
#else
	printf("ohhhhhhhhh!");
#endif
	return 0;
}

3.判断是否被定义

格式:

#if defined(symbol) 
#ifdef symbol 

#if !defined(symbol) 
#ifndef symbol 

这里的条件判断就是判断是否被定义,也就是这个量是否被定义了,被定义了就执行下面代码,未定义不行。

#define M 100
int main()
{
#if defined(M)//判断符号是否被定义
   printf("hello!");
#endif

#ifdef M//简略版写法
   printf("world");
#endif
    return 0;
}

4.嵌套指令

其实对于指令,是可以嵌套使用的,比如下面的条件编译

#if defined(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 指令可以使另外一个文件被编译。就像它实际出现于 #include 指令的地方一样。

这种替换的方式很简单: 预处理器先删除这条指令,并用包含文件的内容替换。 这样一个源文件被包含10次,那就实际被编译10次。

而对于头文件被包含的方式有两种:

第一种是本地文件包含:#include "filename" ,对于自定义的头文件,或者在本地的头文件,我们使用" "对其进行包含引用。

第二种是对库目录的包含:#include <stdio.h>,对于引用的是库函数里面的头文件,我们使用< >对其进行包含引用。


Q1:这两种有什么区别呢?

实际上,当我们使用" "去引头文件的时候,它首先会去当前过程的目录下查找,然后如果查不到,就去库函数的目录下查找,如果还找不到就报错。而使用< >引头文件是直接到库函数所在目录查找的。

Q2:所以查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。这样是不是可以说,对于库文件也可以使用 “” 的形式包含?

答案是肯定的,可以!但是这样做查找的效率就低些,当然这样也不容易区分是库文件还是本地文件了。

那么还有一个问题,大家有没有想过一个套娃的问题,比如说A需要一个a的头文件,然后B也需要a这个头文件,但是A又引用了B,那A的地方会不会重复两个a的头文件??

比如下面这张混乱的图:

comm.h和comm.c是公共模块。 test1.h和test1.c使用了公共模块。 test2.h和test2.c使用了公共模块。 test.h和test.c使用了test1模块和test2模块。 这样最终程序中就会出现两份comm.h的内容。这样就造成了文件内容的重复。

那么如何解决?这就可以用到我们上面学的条件编译了。

在每一个头文件的开头写上:

#ifndef __TEST_H__
//如果头文件是TEST就是这个, 格式:__头文件名_H__
#define __TEST_H__ 
      //头文件的内容
#endif //__TEST_H__ 

或者在最前面写上:#pragma once,就可以避免头文件的重复,以至于代码实现的时候冗长了。


关于本篇程序环境和预处理就到这里啦,如果觉得对你有帮助不仿点个赞,一起努力学习!

还有一件事:

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

恒等于C

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值