一、概述:翻译环境、执行环境
在ANSI C(标准C语言)的任何一种实现中,都存在两种环境:
1.翻译环境
在这个环境中,源代码被转换为可执行的二进制指令
在一个项目工程中,test.c就是源文件(源代码),可以在存放该项目工程的文件夹中找到该源文件。该源文件存放的是文本信息代码,可以直接用记事本打开,可以看到源代码
这样的源代码并不能直接运行,而是要通过翻译环境形成一个可执行程序text.exe,该可执行程序中存放的是二进制指令(机器指令)。同样也可以用记事本打开,但是打开后看到的是一堆乱码,因为存放的都是二进制的信息,记事本打开无法查看。
2.执行环境(运行环境)
用于实际执行代码(执行二进制指令)
当源文件test.c通过翻译环境产生可执行文件test.exe后,该可执行文件就可以通过执行环境执行,最终得到执行结果。
二、详述翻译环境——编译环境、链接环境
在翻译环境下,又分为两个环境:编译环境和链接环境
1.编译环境
源文件test.c通过编译器生成目标文件test.obj(Linux环境下的目标文件时test.o),该目标文件中存放的是二进制指令,用记事本打开同样是一堆乱码。
(VS2022环境下的编译器是cl.exe)
如果有多个源文件,那么这些源文件都要单独通过编译器生成对应的目标文件
2.链接环境
所有目标文件和链接库经过链接器的处理最终生成一个可执行文件test.exe
(VS2022环境下的链接器是link.exe)
链接库:是Windows系统中封装代码和数据以及实现资源共享的一种方式,本质上是已经编译好的二进制指令文件(机器指令文件)
例如包含头文件时使用的C语言标准库就是以链接库的形式和其他目标文件通过链接器链接
三、详述编译过程——预编译、编译、汇编
编译器对源文件进行编译的具体过程分为3步:预编译(预处理)、编译、汇编
1.预编译(预处理)
预编译时,编译器会进行以下动作:
1.将所有的注释替换为空格
2.将#define定义的标识符(宏)替换为对应的常量或函数(宏展开)
3.将包含的头文件中所有的内容以及源文件代码整合并生成一个test.i文件
(预处理后得到的test.i文件仍然是C语言代码)
VS2022环境下,将编译器设置改为预处理到文件,这样编译器会将预处理完成后的结果放到test.i文件。相比于源文件,该test.i文件末尾代码是进行了注释替换和宏展开的源代码,上面还有一万多行代码是将头文件所包含的所有内容。
2.编译(Linux环境下测试)
将test.i文件的C语言代码翻译成汇编代码,存放到test.s文件中
编译器如何将C语言代码翻译成汇编代码?
(1)词法分析
源代码程序被输入扫描器,扫描器的任务就是进行简单的词法分析。将代码中的字符分割成一系列的记号(关键字、标识符、字面量、特殊字符等)。
例如下面代码,代码中的字符可被分割成一系列的记号:
(2)语法分析
语法分析器对扫描器扫描产生的记号进行语法分析,从而产生语法树。这些语法树是以表达式为节点的树。
(3)语义分析
主要是检查是否结构正确的句子所表示的意思也合法、执行规定的语义动作(如:表达式求值、符号表填写、中间代码生成等)
(4)符号汇总
将所有源文件的全局符号汇总起来,包括全局变量名、全局函数名、main。(因为只有全局的函数、变量才涉及到跨文件使用)
3.汇编(Linux环境下测试)
将test.s汇编代码通过汇编器翻译为二进制指令,存放到test.o目标文件中(Windows环境下的目标文件为test.obj)
汇编器如何将汇编代码翻译成二进制指令?
(1)生成符号表
每个源文件都有自己的符号,汇编器会将源文件各自的符号列成一个表,并为每个符号给予地址。
例如,在下列项目中,有两个源文件 add.c 和 test.c ,汇编器在各自目标文件(.obj)中文件中生成符号表
add.obj
test.obj
四、详述链接过程——合并段表、符号表的合并与重定位
1.合并段表
在gcc编译器(Linux环境)中,所有生成的目标文件和二进制文件都是按照 elf 文件格式组织的。将所有的目标文件或二进制文件分成不同的段,每个段存放不同的数据。
合并段表就是将这些目标文件和二进制文件相同的段进行合并,生成二进制可执行文件
2.符号表的合并与重定位
每个目标文件都有自己的符号表,需要将这些符号表进行合并
最终去查找函数的时候,只需要通过这个合并的符号表根据各符号的地址去查找即可
正是因为符号表合并的存在,才可以进行函数等的跨文件调用。
如果程序运行时出现了未定义的外部符号报错,说明源文件中并未定义该符号。根据地址查找该符号时,找不到该符号的定义。
五、详述运行环境——程序的执行过程
程序的执行过程
1.程序载入内存中
在有操作系统的环境中,该操作由操作系统完成。在独立的环境中,该操作由手工完成,或者通过可执行代码置入只读内存来完成。
2.调用main函数
程序载入内存完成后,紧接着便调用main函数
3.执行程序代码
此时程序调用一个运行时堆栈,存储函数的局部变量和返回地址。同时也调用一个静态内存,存储静态变量和全局变量,存储于静态内存中的变量在程序的整个执行过程中一直保留它们的值。
4.终止程序
正常终止main函数,或发生意外终止main函数