计算机操作系统属于计算机基础,了解C语言从源文件到可执行文件的被处理过程,有助于认识操作系统,帮助我们理解C语言的某些程序现象。
第一部分
程序的翻译环境和执行环境
翻译环境
源文件被处理成可执行文件(源代码到机器指令)所依赖的环境。翻译环境又分为编译和链接。
执行环境
可执行文件执行所依赖的环境
翻译过程
翻译是.c文件到.exe文件的过程。翻译包括编译和链接两个步骤。在编译阶段,每个源文件都会被编译器单独处理,分别生成各自的目标文件;在链接阶段,各个目标文件和链接库在链接器的处理下生成一个可执行文件。
编译
编译阶段又分为预编译、编译和汇编三个阶段,下面逐一对其解释。
预编译
预编译将.c文件转化为.i文件。
在Linux中用gcc编译器对源文件进行预编译:
预编译进行的是文本操作,主要包括三个部分:
1.头文件的包含,即将#include包含的头文件的内容复制到文件中;
2.注释的删除,将源文件和头文件中的注释用空格替换
3.#define定义的符号的替换
编译
编译将.i文件转化为.s文件,即将c代码转化为汇编代码。
该步骤包括语法分析、词法分析、语义分析和符号汇总(全局变量、函数名等)。该部分原理较为复杂,详细请参考书籍:《程序员的自我修养》。
对.i文件进行编译后:
汇编
预编译将.s文件转化为.o文件。包括两个动作:
1.将汇编代码转化为二进制指令;
2.形成符号表,其中不同的.c文件分别各自形成不同的符号表。
符号表:将符号及其对应的符号信息制表得到。
将汇编转化为二进制指令,打开文件呈现出的是一堆乱码:
链接
数个.o文件在链接器的作用下,生成一个.exe文件。包括两个动作:
1.合并段表:.o文件本身会将自己分段,当各个.o文件链接在一起时,各个段表会合并。
2.符号表的合并和重定位:此举是为了找到函数位置;当符号名冲突时,用符号的有效地址。
链接并运行程序:
.exe文件的执行
程序的运行必须载入到内存中。在有操作系统的环境下,一般由操作系统来完成;在独立环境下,程序的载入必须手动完成。
程序运行时,首先main函数被调用,接着开始执行程序代码。此时程序将使用一个运行时栈帧,存储函数的局部变量和返回地址。程序也可以使用静态内存,使用静态内存的变量在程序运行的过程中一直保留他们的值。
最后程序会终止。这时可能是程序运行完毕正常终止,也可能是意外终止。
第二部分
预编译(预处理)详解
预定义符号
预定义符号是ANSIC规定的宏定义符号,这些符号在预处理阶段会被替换成实际的数据。常见的预定义符号有6个。
__FILE__ //代码所在的文件名
__LINE__ //代码所在的行号
__DATE__ //运行时刻的日期
__TIME__ //运行时刻的时间
__FUNCTION__ //代码所在的函数
__STDC__ //检测是否严格遵循C标准
这些预定义符号常见于运行日志的生成。需要注意的是,预定义是预先存在的符号,自己#define
定义的符号不属于预定义符号。
int main()
{
FILE* pf = fopen("test.log", "w");
if (pf == NULL)
{
perror("OpenFile:");
exit(-1);
}
for (int i = 0; i < 10; i++)
{
printf("%d ", i + 1);
fprintf(pf, "Date:%s Time:%s File:%s Function:%s Time:%d\n",
__DATE__, __TIME__, __FILE__, __FUNCTION__, __LINE__);
}
fclose(pf);
pf = NULL;
return 0;
}
运行后的文件内容:
预处理指令
一.#define
1.#define定义标识符
可以用#define赋予符号特定的意义,这些符号在预编译阶段会被替换成代码被编译
2.#define定义宏
#define name(parament-list) stuff
其中parament-list是一个用逗号隔开的参数表,约定将宏名大写
需要注意以下几点:
- 参数表的左括号必须与宏名紧邻,否则会被视为
stuff
的内容 - 宏完成的是替换,不是传参。为了避免不可预料的相互作用,尽量在定义宏时给每个独立部分加上括号
- 宏不能出现递归
- 当预处理器搜索
#define
定义的符号时,字符串常量的内容不被搜索
#define MAX(X, y) ((x) > (y) ? (x) : (y)) //定义一个宏
使用自定义符号和宏的步骤如下:调用宏时,首先对参数进行检查,看是否包含#define
定义的符号,若有,则它们首先被替换;替换文本随后被插入到原来文本的位置,对于宏,宏名和参数被替换为指定的运算;最后再对文件进行扫描,直至文本中#define
定义的符号被完全替换。
在宏的实际使用过程中,有时会由于操作不慎而产生带有副作用的宏参数,造成不可预料的结果。
输出结果是什么?
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int x = 5;
int y = 8;
int z = MAX(x++, y++); //带有副作用的宏参数
printf( "x = %d, y = %d, z = %d\n", x, y, z);
这段程序的输出为:x = 6, y = 10, z = 9
产生这个问题的本质原因是宏完成的是直接替换而不是传参,参与编译的实际上是如下代码:
z = ((x++) > (y++) ? (x++) : (y++))
看到这里相信你已经发现了问题所在,程序并没有如你所想般把 5 和 8 进行比较,而是被动了些手脚:x 和 y 进行比较后各自增1,然后 y 再次自增1,造成了现在的结果。
宏和函数的对比
既然宏和函数都能帮我们实现一些功能,那某者的存在是否是多余的呢?事实上两者各有优劣。
1.当宏和函数都能实现一个功能时,尽量使用宏。
宏的使用更加灵活:宏的参数是直接进行替换的,不进行类型检查,因此可以灵活对各种类型的数据进行操作;
宏的使用节省资源:宏不需要进行压栈等操作,没有函数函数调用和返回的开销。
#define MAX(x, y) ((x) > (y) ? (x) : (y))
int main()
{
double max1 = MAX(3,14, 3);
double max2 = MAX(5, 8); //宏的使用较灵活
printf("max1 = %f max2 = %f\n", max1, max2);
return 0;
}
2.宏的劣势
1.每使用一次宏,替换后程序中都会插入一行代码,有时会大大增加代码量;
2.宏不能进行调试。进行调试时,已经完成了预编译,此时宏已经被替换;
3.宏可能带来运算符优先级的问题;
4.宏是类型无关的,不够严谨。
二.#和##
在宏中,#
的使用可以实现将参数插入到字符串中
#define PRINT(X) printf("the value of " #X " is %d\n",X)
//在宏中 # 可以实现将参数插入到字符串中
int main()
{
int a = 10;
int b = 20;
PRINT(a);
PRINT(b);
return 0;
}
输出:
在宏中,##
可以把位于其两边的符号合成一个符号,它允许从分离的文本片段创建已存在的标识符
#define CONS(a, b) (int)(a##e##b)
int main()
{
int x = CONS(1, 3);
printf("%d\n", x);
return 0;
}
这段程序的 x
在编译阶段实际上是下面的代码:
int x = (int)(1e3);
输出:
三.#undef
#undef
用于移除一个宏定义
#define MAX 100
int main()
{
printf("%d\n", MAX);//输出 100
#undef MAX//移除MAX的定义
int MAX = 10;
printf("%d\n", MAX);//输出 10
return 0;
}
四.条件编译语句
使用条件编译语句可以选择性地对某些语句进行编译或删除。条件编译语句支持单分支、多分支和嵌套使用。
//单分支:
#ifdef 常量表达式
语句; //若常量表达式被定义则执行此语句,否则不执行
#endif
#ifndef 常量表达式
语句; //若常量表达式未被定义,则执行此语句,否则不执行
#denif
//多分支:
#if 常量表达式1
语句1; //若常量表达式1被定义则执行该语句
#elif 常量表达式2
语句2; //若常量表达式2被定义则执行该语句
#else 常量表达式3
语句3; //若常量表达式3被定义则执行该语句
#endif
条件编译在预处理阶段完成:
条件编译语句常用于防止头文件被重复包含:
#ifndef DE_BUG
#define DE_BUG
#endif
在头文件开头使用#pragma once
也可以达到同样效果
五.文件包含
当程序需要使用其他文件中的函数或功能时,需要包含该文件。被包含的文件在预编译阶段会被拷贝到程序中,并在其中查找对应的函数定义。#include
使另外一个文件中的程序被编译。
在C语言中文件包含有两种方式:#include"filename"
和#include<filename>
,二者主要的不同在于查找方式
本地文件包含:#include"filename"
。查找策略:先在工程目录下查找,找不到则在标准库中查找,再找不到则报错;
库文件包含:#include<filename>
。查找策略:直接在标准库中查找
理论上库文件也可以被" "
包含,但是考虑到效率问题,尽量选择合适的包含方式。有时会不可避免地重复引用同一个头文件,造成代码冗余,使用条件编译语句可以避免此问题。