正片开始👀
环境🤔
在ANSI的任何一种实现中,都存在两个不同的环境:翻译环境与执行环境
比如我们的 test.c 变成 test.exe 的执行文件就要经过翻译环境,test.exe 执行出来又需要执行环境。其实翻译环境就是将C语言源代码翻译成机器能懂二进制指令,再传给执行环境执行出结果。
翻译环境😎
翻译环境包括了预处理,编译,链接三大步骤
预处理(预编译)🤔
预定义符号👏
预处理符号都是语言内置的
// 进行编译的源文件名称 __FILE__ // 文件当前行号 __LINE__ // 文件被编译的日期 __DATE__ // 文件被编译的时间 __TIME__ // 如果编译器遵循ANSI C,值为1,否则未定义 __STDC__
比如:
printf("日期:%s 时间:%s\n", __DATE__, __TIME__);
1
#define定义宏👏
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称之为宏或定义宏,可以使用下面两种方式定义带有参数的宏:
#define 宏名称( [形参列表] ) 替换文本
#define 宏名称( [形参列表 ,] … ) 替换文本
注意宏是不能递归的!
还是那句话,千万不要吝惜你的括号,这会大大提高你宏的使用质量,因为操作符优先级可能会带来不可预估的后果。
#与##🤔
现在我们来康一些好康的,我们先来模拟一个情景:
int a = 1;
printf("a is %d\n",a);
int b =2;
printf("b is %d\n",b);
……
假设我现在有很多个数需要像上面一样打印,每次 printf 很麻烦,值是可以根据赋的值来改变,但是内部的固定字符串内容能时 a 时 b 时 c 的随时变化吗?宏就可以实现!
#define PRINT(n) printf(""#n" is %d\n",n);
int main()
{
int a = 1;
PRINT(a);
int b = 2;
PRINT(b);
}
其实这就是C语言里面双引号引发字符串的机制,这里的 #n 就相当于" n "
而 ## 可以把分离的文本合并为一个文本
#define num(a,b) a##b
int main()
{
int cdef = 10;
printf("%d\n",num(cd,ef));
return 0;
}
结果如下:
带副作用的宏参数🤔
这里的副作用指的是表达式求值的时候出现的永久性效果,当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么在使用这个宏的时候就可能出现危险,导致不可预估的后果。
比如:
b = a+1; //不带副作用
b = ++a; //带有副作用
延伸到宏的体内就是这样:
#define Max(a,b) ((a)>(b)?(a):(b))
int x = 5;
int y = 8;
int num = Max(x++,y++);
所以最后输出的x,y,num 分别为 6,10,9(b副作用伴有两次++效果)
编译🤔
一个源文件 test.c 在编译阶段又包括了预编译(预处理),编译和汇编。
我们说一个 .c 文件经过编译器变成目标文件,即 windows 环境中 .obj 后缀的文件,这个大的过程叫做编译;注意,每个源文件都会通过编译器生成自己的目标文件。
我们在Linux里,gcc 环境使用语句类似:
gcc -E test.c -O test.i
1
就能执行出预编译的效果
总结一下预编译在干个什么呢?
头文件的包含,将头文件的 #include 进行处理和展开;
删除注释,编译器是不需要的注释的,注释只是帮助我们阅读代码;
#define定义符号的替换;
实际上预编译阶段就是在进行一些文本操作
编译就是把C语言代码转换成汇编代码,但其中还包括了语法分析,词法分析,语义分析以及符号汇总,这些都涉及编译原理,感兴趣的可自行了解不赘述;
汇编阶段就是将汇编代码转换成二进制指令,其中有一个动作叫做形成符号表,像符号表和之前的符号汇总这些都是在干什么呢?这是在把全局范围内的符号进行收集,我们又对符号进行了补充,形成符号表。
因为编译器在进行处理时每当遇到一个函数名,编译没有见过函数的实现,编译器单独处理 main 函数时他只知道这里真真切切有这么个函数,函数并没有在 main 函数里面定义,编译器无从得知该函数再哪里,就不知道地址,这时他知道 main 函数地址,但不知道其他函数地址,在编译时就会为不知道地址的函数名进行自主填充,这个填充的地址就来源于我们的符号表。
在Linux里面,.o 目标文件和可执行程序的格式为 ELF,但明确告诉各位,ELF 的格式内容是二进制内容,也就是肉眼无法理解,要查看我们需要依赖 readelf 指令:
readelf命令,一般用于查看ELF格式的文件信息,常见的文件如在Linux上的可执行文件,动态库(.so)或者静态库(.a) 等包含ELF格式的文件
语法是 readelf (选项)(参数:文件),除了-v和-H之外,其它的选项必须有一个被指定参数,这里的选项有很多,这里列举一二:
-a
–all 显示全部信息,等价于 -h -l -S -s -r -d -V -A -I.
-h
–file-header 显示elf文件开始的文件头信息.
-l
–program-headers
–segments 显示程序头(段头)信息(如果有的话);
readelf 出来就不难发现我们的符号表是实际存在的!
链接🤔
链接部分包括了:
合并段表
合并段表就要牵扯到刚刚的ELF,ELF文件其实只是将 .o文件分成一个一个的段,每段有他独特的意义,我们这里不谈细节,ELF 既然是一种格式 ,那段数与每个段的意义也是一样的。
我们把所以 .o 文件链接起来最后形成一个可执行程序,链接过程中其实就是把这些段连接起来,相同的段需要进行一个合并,这就是所谓的合并段表。
符号表的合并与重定位
假如我们源文件存在 main 函数和一个 add 函数,那最后只形成一个可执行程序,那么来自于 test.o(main,add)里的符号表以及 add.o (add)里的符号表也需要进行合并。
本来应该是 2+1 = 3 个符号,main 函数没有争议,但 add 符号留下的究竟根据 test.o 还是 add.o 就会涉及到重定位的问题,因为 test.o 里面不知道 add 地址使用的是填充值,但 add.o 里面已经知道的就会给出 add 精确的地址,这时就会对有效地址重定位,最后采用这个有效值
意义👏
其实我们假设一个场景就能理解了:
我在 main 函数内部写了一个函数,但是在整个源文件里面我并没有去定义这个函数,那么在 test.o 里面对这个函数的符号理所应当的用了符号表的填充值,但在因为我未定义,这个函数的 .o 文件里面压根儿就没有这个符号,自然也就没有地址,这时候经典老番《无法解析的外部符号》就会来个当头一棒。
其实上面那一堆的东西全是在为链接做准备,多个目标文件进行链接的时候会通过符号表来查看来自外部的符号是否真实存在这也是为什么我们没有声明函数却也可以执行的原因。
运行环境😎
即执行程序的过程:
我们的程序必须载入内存中,在有操作系统的环境中,一般载入由操作系统完成;在独立的环境中,程序的载入由手工安排,也可能通过可执行代码置入只读内存来完成,比如嵌入式设备上常说的宕程序。
从执行就开始,然后调用 main 函数,开始执行代码,这时程序会使用一个运行时堆栈 ,其实就是函数栈帧,存储函数的局部变量和返回地址,程序同时也可以使用静态内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。
最后终止程序时正常终止掉 mian 函数(也可能意外终止)。