我们平常写的代码都是通过编译器来运行的。我们有没有想过编译器是怎么将代码转化为各种指令最后输出结果呢?这篇文章会详细解释编译器的运行的整个过程的细节,希望会对你有所帮助。
一、程序的翻译环境和执行环境
我们可以简单认为编译器把代码首先进行翻译,然后再执行。所以在ANSIC的任何一种实现中,存在两个不同的环境:
第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。
第2种是执行环境,它用于实际执行代码。
那编译器具体是怎么翻译和执行的呢?这就要看编译和链接的过程了。我们接着往下看。
二、编译和链接详解
2、1 翻译环境
我们先来看一下整个的翻译过程,翻译环境大致可分为以下三个步骤:
组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code)。
每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。
链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中。
具体我们也可结合下图一起理解:
我们再具体看其中的编译和执行的细节。
2、2 编译过程详解
我们先来看一段代码:
sum.c int g_val = 2016; void print(const char *str) { printf("%s\n", str); } test.c #include <stdio.h> int main() { extern void print(char *str); extern int g_val; printf("%d\n", g_val); print("hello bit.\n"); return 0; }
我们可以看到上述代码中有两个源文件,分别是:sum.c 和 test.c。在对上述代码进行编译的时候,具体又分为以下步骤:
预编译(预处理)。主要是处理预处理指令,有头文件的包含#include、定义符号的替换和删除#define、注释的删除等等。
编译。把C语言代码翻译成汇编代码。其中有语法分析、词法分析、语义分析、符号汇总。
汇编。把汇编代码翻译成二进制指令,同时形成符号表。
链接。符号表的合并和重定位、合并段表。
在上述编译的过程中,第2点的符号汇总是指讲全局变量函数名称当作符号汇总,然后再汇编阶段将汇总的全局变量函数名称与其地址形成一个符号表。最后,由于有多个源文件会生成多个符号表,在链接阶段对符号表进行合并和重定位。链接完后会生成可执行程序。
上述代码的编译过程中的符号表生成如下图:
注意,多个源文件隔离编译,生成各自的符号表。最后会在链接时对符号表进行汇总和重定位。
2、3 执行环境
上述讲述了编译和链接后生成可执行文件,那么我们再看一下程序执行的过程:
程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
程序的执行便开始。接着便调用 main 函数。
开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static )内存,存储于静态内存中的变量在程序的整个执行过程 一直保留他们的值。
终止程序。正常终止 main 函数,也有可能是意外终止。
三、预处理详解
3、1 预定义符号
__FILE__ //进行编译的源文件 __LINE__ //文件当前的行号 __DATE__ //文件被编译的日期 __TIME__ //文件被编译的时间 __STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义
上述的符号均为预处理符号。在文件的预处理阶段均会被替换成相应的数据。我们结合以下代码理解。
#include<stdio.h> int main() { printf("line:%d\n", __LINE__); printf("%s\n", __DATE__); printf("%s\n", __TIME__); return 0; }
上述代码的运行结果为,如下图:
3、2 #define
3、2、1 #define定义的符号
我们直接看#define的使用方法,代码如下:
//用法 #define name stuff //例子 #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__ )
我们对上述的例子进行一一解释。第一个就是用 MAX 代替了 1000。第二个我们在使用register 关键字时会感到很麻烦,因为这个关键字太长了。于是用了 reg 代替了regisert。第三个其实是死循环。第四个效果更加明显。当我们使用switch语句时,可能经常忘记break,于是用CASE 代替了 break;case。第五个就很简单,直接代替了一个打印语句。
注意,在define定义标识符的时候,在最后不要加上 ; 。因为define定义标识符时进行替换的,加上 ; 时可能会出现意想不到的错误。
3、2、2 #define 定义宏
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)。
语法:#define name( parament - list ) stuff
其中的 parament -list 是一个由逗号隔开的符号表,它们可能出现在 stuff 中。
注意: 参数列表的左括号必须与name 紧邻。 如果两者之间有任何空白存在,参数列表就会被解释为stuff 的一部分。
我们举一个例子,代码如下:
#define SQUARE( x ) x * x int main() { printf("%d", SQUARE(5)); return 0; }
将上面的代码进行预处理后,打印的是5*5的值。我们再来看一段代码:
#define SQUARE( x ) x * x int main() { //printf("%d", SQUARE(5)); int a = 5; printf("%d\n", SQUARE(a + 1)); return 0; }
我们的本意是想打印出a+1的平方,但是结果并非如此。结果如下图:
我们来分析一下。替换文本时,参数x被替换成a + 1,所以这条语句实际上变成了:
printf ("%d\n",a + 1 * a + 1 )。自然而然,结果就是11。
这样就比较清晰了,由替换产生的表达式并没有按照预想的次序进行求值。
在宏定义上加上两个括号,这个问题便轻松的解决了:
#define DOUBLE( x) ( ( x ) * ( x ) )
3、2、3 #define 替换规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤:
在调用宏时,首先对参数进行检查,看看是否包含任何由 #define 定义的符号。如果是,它们首先被替换。
替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
最后,再次对结果文件进行扫描,看看它是否包含任何由 #define 定义的符号。如果是,就重复上述处理过程。
注意:
宏参数和#define 定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归。
当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
3、3 宏和函数的对比
宏通常被应用于执行简单的运算。 比如在两个数中找出较大的值。
#define MAX(a, b) ((a)>(b)?(a):(b))
那么为什么不用函数呢?其有如下两个原因:
用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。 所以宏比函数在程序的规模和速度方面更胜一筹 。
更为重要的是函数的参数必须声明为特定的类型。 所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点型等可以
用于 > 来比较的类型。 宏是类型无关的。
当然,宏和函数对比也是有不足的,有如下几点:
每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。
宏可能会带来运算符优先级的问题,导致程容易出现错。
宏是没法调试的。
宏由于类型无关,也就不够严谨。
更加具体的宏和函数的对比总结如下表格:
属 性 |
#define定义宏 |
函数 |
3、4 条件编译
在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。
比如:调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。代码如下:
#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 //__DEBUG__ } return 0; }
条件编译指令有很多,我们来看一下常见的条件编译指令:
1. #if 常量表达式 //... #endif //常量表达式由预处理器求值。 如: #define __DEBUG__ 1 #if __DEBUG__ //.. #endif 2.多个分支的条件编译 #if 常量表达式 //... #elif 常量表达式 //... #else //... #endif 3.判断是否被定义 #if defined(symbol) #ifdef symbol #if !defined(symbol) #ifndef symbol 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
3、5 头文件的包含
我们已经知道, #include 指令可以使另外一个文件被编译。就像它实际出现于 #include 指令的地方一样。 这种替换的方式很简单: 预处理器先删除这条指令,并用包含文件的内容替换。 这样一个源文件被包含10 次,那就实际被编译 10 次。
3、5、1 头文件被包含的方式
头文件的包含方式有两种:
本地文件包含。如: #include "filename" 。
查找策略:先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。如果找不到就提示编译错误。
库文件包含。如: #include <filename.h> 。
查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。这样是不是可以说,对于库文件也可以使用 “” 的形式包含?答案是肯定的,可以 。 但是这样做查找的效率就低些,当然这样也不容易区分是库文件还是本地文件了。
3、5、2 嵌套文件包
如果出现如下场景:
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 //__TEST_H__ 或者 #pragma once
预处理指令的内容就讲解到这里,希望以上内容对你有所帮助ovo~