一、程序的翻译环境和执行环境
在ANSI C的任何一种实现中,都存在两个不同的环境:
- 第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。
- 第2种是执行环境,它用于实际执行代码。
1、翻译环境
一个程序编译、链接的过程在翻译环境中执行,其具体流程为:
每个源文件 (.c文件) 单独 经过编译器处理后形成目标文件。(在Windows环境下目标文件的后缀为 .obj,在Linux环境下目标文件的后缀为.0)
所有的目标文件通过链接器捆绑在一起,形成一个单一而完整的可执行程序 (.exe 文件)。
链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它也可以搜索程序员个人的程序库,将其需要的函数也链接到程序中(链接库操作)。
2、执行环境
程序的实际执行值执行环境中进行,程序执行的具体过程如下:
程序必须先被载入内存中。在有操作系统的环境中,此过程一般由操作系统完成;在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
接下来程序开始执行,调用main函数。
开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),用于存储函数的局部变量和返回地址;程序同时也可以使用静态(static)内存,存储于静态内存中的变量其值在程序的整个执行过程将会被一直保留。
终止程序。正常终止main函数,也有可能是意外终止。
二、编译的具体过程
现在我们知道了程序的编译链接是在翻译环境中进行的,接下来我们来探讨程序编译链接的具体过程。首先,我们来探讨编译,编译其实分为三个阶段,分别是:预处理(预编译)、编译、汇编。这三个阶段所执行的具体操作如下。
1、预处理
预处理也叫预编译,程序在预处理阶段会完成如下操作:
- 注释的删除。
- #define 定义的符号、宏的替换以及删除。
- 各种条件编译的处理。
- 头文件的展开:将头文件中的代码展开到到当前代码中。
在Linux下我们可以通过如下命令来得到预处理之后的代码:
gcc -E test.c -o test.i # gcc:表示用 gcc 编译器来编译此代码 # -E:表示让代码在完成预处理后停下来,不再继续往后编译 # test.c:我们要编译的代码 # -o test.i:重定向操作,表示将预处理后得到的代码保存到 test.i 文件中,没有此命令代码将会直接显示到终端
下面我们以一个例子来说明预处理所执行的各种操作:
预处理之前的代码:
#include <stdio.h> //这是我包含的头文件 #define N 5 //这是用define定义的一个符号 #define ADD(m,n) ((m)+(n)) //这是用define定义的一个宏 int main() { int a = 0; int b = 0; int c = N + ADD(2, 3); printf("%d\n", c); return 0; }
预处理之后的代码:
{ 此代码块表示stdio.h 中的代码 } int main() { int a = 0; int b = 0; int c = 5 + ((2)+(3)); printf("%d\n", c); return 0; }
2、编译
程序在编译阶段会完成如下操作:
- 语法分析。
- 词法分析。
- 语义分析。
- 符号汇总。
这里我们重点关注符号汇总,因为在这里汇总出来的符号在后面汇编以及链接阶段都会用到;符号汇总会将我们代码中的全局的符号全部汇总起来,比如全局变量名、函数名;符号汇总不会将局部的变量名汇总进来,因为局部变量只有当程序运行起来,进入该变量所在的局部范围时才会被创建,而编译是在编译阶段进行的。
经过编译,我们的C语言代码会被转化为汇编代码。
在Linux下我们可以通过如下命令来得到编译之后的代码:
gcc -S test.c //编译器会对代码进行预处理+编译操作 gcc -S test.i //当代码已经经过预处理,形成了test.i文件时,可以使用此命名 # -S:表示让代码在完成编译后停下来,不再继续往后编译 # 注意:编译、汇编阶段形成的代码会被自动保存到对应文件中,不需要进行重定向操作 # 编译产生的文件为 test.s
3、汇编
经过汇编,我们的汇编代码会被转化为二进制代码/机器代码,并且会形成一个/多个符号表。
我们以下面的代码为例:
Add.c 文件中代码:
int Add(int x, int y) { return (x + y); }
test.c 文件中代码:
#include <stdio.h> extern Add(int, int); //函数声明 int main() { int a = 10; int b = 20; int sum = Add(a, b); printf("%d\n", sum); return 0; }
我们知道,每一个源文件都会单独经过编译器编译生成目标文件,在上面代码中,Add.c 经过预处理、编译后形成 add.s,并且会汇总 Add 符号;test.c 经过预处理、编译后形成 test.s,并且会汇总 main 、Add (在test.c函数中声明了Add函数) 符号。
然后,add.s 经过汇编会生成 add.o 文件,test.s 经过汇编会生成 test.o 文件(Linux下,Windows下为 .obj 文件)。
在汇编过程中,add.s 和 test.s 都会单独生成自己的符号表,所谓的符号表其实就是把 Add main 这些符号与一个地址相关联;add.s 中的 Add 符号与一个地址相关联,test.s 中的 main与一个地址相关联、Add 也与一个地址相关联;
这里需要注意:因为 test.s 中的 Add 是函数的声明,编译器不知道 Add 函数是否真的存在,所以 test.s 中与 Add 符号相关联的地址是无效的。
汇编阶段生成的符号表会在链接阶段被使用。
在Linux下我们可以通过如下命令来得到编译之后的代码:
gcc -c test.c gcc -c test.i //已经经过了预处理,生成了.i文件 gcc -c test.s //已经经过了编译,生成了.s文件 # -c:表示让代码在完成编译后停下来,不再继续往后编译 # 汇编产生的文件为 test.o
三、链接的具体过程
程序在链接阶段会完成如下操作:
合并段表:编译器会把在汇编阶段生成的多个目标文件中相同格式的数据合并在一起,最终形成一个 .exe 文件。
符号表的合并和重定位:符号表的合并是指编译器会把在汇编阶段生成的多个符号表合并为一个符号表;重定位则是指当同一个符号出现在两个符号表中时,编译器会选取其中和有效地址相关的那一个,舍弃另外一个。
链接过程符号表的合并和重定向的实际意义是非常大的,因为它保证了符号表中的每一个符号都和有效的地址相关联,我们可以通过该地址找到对应符号,可以让我们跨文件的调用函数,使得我们链接生成的 .exe 文件能够被正常执行。
如果在合并符号表的过程中与某一符号相关联的地址是无效的,程序就会抛出链接性错误;比如我们把上面 add.c 文件中的代码删去,再运行程序:
经过链接,我们的C程序就会从 .c 文件被转化为 .exe 文件,这时我们只需要打开 .exe 文件就可以让我们的程序运行起来了。
上面就是一个C程序如何一步一步被执行起来的,实际上编译器在编译链接过程中还有许多许多需要去注意和设计的地方,我们这里也只是浅浅的学习了一下程序编译链接的过程,更深入的知识需要我们去学习编译原理等相关知识,这里就不再探讨,如果有同学对这些知识很感兴趣,可以去看看 《程序员的自我修养》 这本书,里面做了较为详细的介绍,电子版书籍我放在阿里云盘里面了,有需要的同学自取。
阿里云盘链接:https://www.aliyundrive.com/s/9zqTyvaBu13 提取码:x7g1
四、编译器调用函数的规则
在知道了程序编译链接的具体过程之后,我们需要知道编译器调用函数的规则:
首先,编译器会在当前文件中寻找函数的定义,如果找到了,就直接调用函数,在调用函数期间会形成函数的符号表;所以对于定义在本文件内的函数,编译器不需要再去确认符号表中函数的地址,也就不需要进行后续的链接操作;
如果编译器在本文件中没有找到函数的定义,那么编译器就会去寻找函数的声明,找到之后生成一个符号表,并将符号表关联一个无效的地址;这时候,编译器就需要通过后续链接阶段符号表的合并来匹配有效地址,从而实现跨文件调用函数;当然,也有可能合并不到有效的地址,从而在重定位时发生链接型错误;
最后,如果编译器在本文件内既没有找到函数的定义,也没有找到函数的声明,那么编译器就会直接报出编译型错误。
五、预处理操作
在了解了程序在编译链接时所进行的各种操作后,我们再来学习一下预处理相关的操作。
1、预处理符号
C语言中有许多内置的预定义符号,我们可以在程序中直接使用:
__FILE__ //进行编译的源文件 __LINE__ //文件当前的行号 __DATE__ //文件被编译的日期 __TIME__ //文件被编译的时间 __STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义
例如:
#include <stdio.h> int main() { int i = 0; for (i = 1; i <= 10; i++) { printf("%s %d %s %s %d\n", __FILE__, __LINE__, __DATE__, __TIME__, i); } return 0; }
2、#define 定义标识符
我们可以使用 #define 来定义标识符:
#define name stuff
例如:
#define MAX 1000 //用 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 定义的标识符会在程序的预处理阶段被全部替换掉。
- 用 #define 定义标识符的时候,不要在末尾加上分号,避免造成程序逻辑的错误。
3、#define 定义宏
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)。
宏的声明方式如下:
#define name( parament-list ) stuff # 其中的 parament-list 是一个由逗号隔开的符号表(参数列表),它们可能出现在stuff中
注意:参数列表的左括号必须与name紧邻。 如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。
#define 定义宏易错的地方:
我们可能这样来定义一个宏:
#define SQUARE(x) x*x
然后以这样的方式来调用它:
#define SQUARE(x) x*x int main() { int a = 5; int ret = SQUARE(a); printf("%d\n", ret); return 0; }
乍一看这样写没问题,当然,在这里,SQUARE 的使用确实没问题,但是如果我这样来使用呢?
#define SQUARE(x) x*x int main() { int a = 5; printf("%d\n", SQUARE(a + 1)); }
当我们以上面这种方式来使用 SQUART 宏的时候,结果还会是我们想要的 36 吗?答案是否定的。我们知道,宏是在预处理的时候被替换的,所以上面这段代码结果预处理后编程了这样:
int main() { int a = 5; printf("%d\n", a+1*a+1); }
在经过了这次的教训后,你恍然大悟,把宏改成了这样:
#define SQUARE(x) (x)*(x)
这样定义的宏遇到上面的使用当然不会出错,但如果我换一个宏使用呢?
#define DOUBLE(x) (x)+(x) int main() { int a = 5; printf("%d\n", 10 * DOUBLE(a)); }
上面的宏会像我们预想中的那样输出100吗?也不会;所以正确的宏定义应该像如下这样:
#define SQUART(x) ((X)*(x)) #define DOUBLE(x) ((X)+(X))
这样不管我们以何种方式来使用宏,答案都不会出错;所以用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数中的操作符或邻近操作符之间不可预料的相互作用。