编译器是如何编译的
Posted 嵌入式那点事儿
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了编译器是如何编译的相关的知识,希望对你有一定的参考价值。
当源程序代码编写好后,就是要编译运行,这必须先将它转成二进制的机器码,这样机器才能”认识“,这一些列整个都是我们编译器的任务。
比如,有下面这段程序源码(这里文件名是test.c)。
#include <stdio.h>
int main(void)
{
fputs("Hello, world!\n", stdout);
return 0;
}
我们要先用编译器去处理一下,也就是要先编译,才能运行。
$ gcc test.c
$ ./a.out
Hello, world!
对于一些复杂的项目工程,编译器编译的过程还必须分成三步。
$ ./configure
$ make
$ make install
这么多的命令到底在干什么?在大多数的书籍和资料,都语焉不详,只说这样就可以编译了,没有进一步的解释它们的所以然来。
本文将介绍一下编译器的工作过程,也就是上面这三个命令各自的任务什么,如何式作的。我主要参考了Alex Smith的文章《Building C Projects》。这里需要声明的是,本文主要针对gcc编译器来介绍,也就是针对C和C++这两种语言,不一定适用于其他语言的编译,但大的道理是相通的。
第一步 配置(configure)
编译器在开始工作之前,它首先需要知道当前的操作系统环境,比如标准库存储在哪里(通常会用环境变量指向)、软件的安装路径位置在哪里(比如/opt/test文件夹下面,这是Linux、MAC系统下的路径写法,Windows下可能是C:\program files\test)、需要安装哪些组件等等。这是因为不同计算机的操作系统环境不一样,通过指定了编译参数后,编译器就可以灵活适应环境,编译出各种环境都能运行的机器码(二进制编码)。这个确定编译参数的步骤,就叫做"配置"(configure)。通常会在具体的配置文件里指定所需的参数,有时也可直接跟在命令的后面(configure --prefix=xxxxxxx)
这些配置信息都保存在一个配置文件之中,约定俗称是一个叫做configure的脚本文件。通常它是由autoconf工具自动生成的。编译器通过运行这个脚本,获知编译参数。这里autoconf也是一个工具,非常强大,它可以对项目工程中的代码进行组织,生成一个配置脚本文件。
在configure脚本中,它已经尽量考虑到不同系统之间的差异,并且对各种编译参数给出了默认值。如果用户的操作系统环境比较特殊,或者有一些特定的需求,就需要手动向configure脚本提供编译参数,或者修改参数。
$ ./configure --prefix=/www --with-mysql
上面的这行代码是php源代码的一种编译配置方法,用户指定了安装后的文件保存在www目录下,并且编译的时候加入对mysql模块的支持。
第二步 确定标准库和头文件的位置
源程序代码中通常都会用到标准的库函数(standard library)和头文件(header),比如C语言的标准库glib。它们可以被存放在系统的任意目录下面,编译器实际上没有办法自动检测它们的位置,因为编译器不是神,没有那么智能,只能通过配置文件告诉它才能知道。
编译器编译的第二步,就是要从配置文件中获知标准库和头文件的位置是什么。一般来说,配置文件会给出一个清单,列出几个具体的目录。等到编译的时候,编译器就会按顺序到这几个目录中,逐一寻找所需要的目标。
第三步 确定依赖关系
对于一个大型项目来说,源代码文件之间往往存在依赖关系,编译器需要确定编译的先后顺序。假如有A文件依赖于B文件,编译器应该保证做到下面这两点。
(1)只有在B文件编译完成后,才能开始编译A文件。
(2)当B文件发生变化的时候,A文件才会被重新编译。
编译的顺序保存在一个叫做makefile的文件中,里面列出哪个文件应该先编译,哪个文件后编译。而makefile文件由configure脚本运行生成,这就是为什么编译时configure必须首先运行的原因。其实Makefile有点像编译的向导,它告诉编译器应该编译那些文件,用什么编译器,以及编译器编译时需要的参数、编译后最终生成什么等等内容。
在确定了依赖关系的同时,用什么编译器也确定了,编译时会用到哪些头文件也确定了。
第四步 头文件的预编译(precompilation)
不同的源代码文件,可能会引用同一个头文件(比如stdio.h)。那编译器在编译的时候,头文件也必须一起编译。为了节省时间,编译器会在编译源码之前,先编译头文件。这保证了头文件只需编译一次,不必每次用到的时候,都重新编译了,所以在我们的项目源代码文件中,虽然在多个个文件中声明了同一个头文件,也不用担心它的重复使用,因为最终在编译器编译时,只会有一个。
但是,也并不是头文件的所有内容,都会被预编译的。用来声明宏的#define命令,就不会被预编译。
第五步 预处理(Preprocessing)
在预编译完成后,编译器就开始替换掉源码中bash的头文件和宏了。以本文开头的那段源码为例,它包含头了文件stdio.h,替换后的样子就如下面所示。
extern int fputs(const char *, FILE *);
extern FILE *stdout;
int main(void)
{
fputs("Hello, world!\n", stdout);
return 0;
}
为了便于我们阅读,上面代码只截取了头文件中与源码相关的那部分,即fputs和FILE的声明,省略了stdio.h的其他部分内容(因为它们非常长)。另外,上面代码的头文件没有经过预编译,而实际上,插入源码的是预编译后的结果。编译器在这一步还会移除注释(所以,在编写代码时,多加一点注释也不用担心会不会使编译后的文件变大,其实多写一点注释,对源代码阅读还是很有帮助的,那就养成写注释的习惯吧)。
这一步称为"预处理"(Preprocessing),因为完成这一步之后,就要开始真正的编译处理了。
第六步 编译(Compilation)
经过了预处理之后,编译器就开始要生成机器码了。对于某些编译器来说,还存在一个中间的步骤,会先把源码转换为汇编码(assembly),然后再把汇编码转为机器码。
下面是本文开头的那段源程序代码转成的汇编码。
.file "test.c"
.section .rodata
.LC0:
.string "Hello, world!\n"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movq stdout(%rip), %rax
movq %rax, %rcx
movl $14, %edx
movl $1, %esi
movl $.LC0, %edi
call fwrite
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Debian 4.9.1-19) 4.9.1"
.section .note.GNU-stack,"",@progbits
这种转码后的文件称为对象文件,或者叫目标文件(object file)。
第七步 连接(链接)(Linking)
编译生成的对象文件还不能运行,必须进一步转成可执行文件才行。如果你仔细看上一步的转码结果,就会发现其中引用了stdout函数和fwrite函数。也就是说,程序要正常运行,除了上面的代码以外,还必须有stdout和fwrite这两个函数的代码,它们是由C语言的标准库提供的。
编译器的下一步工作,就是要把外部函数的代码(通常是后缀名为.lib和.a的文件),添加到可执行文件中来。这就叫做连接(linking)。这种通过拷贝,将外部函数库添加到可执行文件的方式,叫做静态连接(static linking),后文会提到还有动态连接(dynamic linking)。静态连接的可执行文件会比较大。
make命令的作用,就是从第四步头文件预编译开始的,一直到做完这一步。
第八步 安装(Installation)
上一步的连接是在内存中进行的,即编译器在内存中生成了可执行文件。下一步,必须将可执行文件保存到用户事先指定的安装目录。
从表面上看,这一步很简单,就是将可执行文件(连带相关的数据文件)拷贝过去就行了。但是实际上,这一步还必须完成创建目录、保存文件、设置权限等步骤。这整个的保存过程就称为"安装"(Installation)。
第九步 操作系统连接
可执行文件在安装之后,必须以某种方式通知计算机的操作系统,让它知道可以使用这个程序了。比如,我们安装了一个文本阅读程序,往往希望双击txt文件,该程序就会自动运行。
这就要求我们在操作系统中,记录这个程序的元数据:文件名、文件描述、关联后缀名等等。Linux系统中,这些信息通常是保存在/usr/share/applications目录下的.desktop文件中。另外,在Windows操作系统中,还需要在Start启动菜单中,建立一个快捷方式指向它。
这些事情就叫做"操作系统连接"。make install命令,就是用来完成"安装"和"操作系统连接"这两步。
第十步 安装包生成
本文写到这里,源码编译的整个过程就基本完成了。但是只有很少一部分用户(程序员),愿意耐着性子,从头到尾做一遍这个全过程。事实上,如果你只有源码可以交给用户,他们会认定你是一个不友好的家伙。大部分用户要的是一个二进制的可执行程序,立刻就能运行。这就要求开发者,将上一步生成的可执行文件,做成可以分发的安装包。
所以,这就要编译器还必须有生成安装包的功能。通常是将可执行文件(连带相关的数据文件),以某种目录结构的形式,保存成压缩的文件包或者安装包,交给用户。
第十一步 动态连接(Dynamic linking)
按道理,到这一步,程序都是可以运行了,但对于编译时选择动态编译的情况那就以外了,如果没有合适的动态库,程序还是运行不起来的。但是如果编译时选择的是静态编译的话,那就没有这样的顾虑,因为静态编译已经将所需要的库都链接进行可执行文件中去了,唯一不好的就是可执行文件”太胖了“,而且也有一些不足之处,比如后续的维护升级都要牵一发而动全身。
而动态连接的做法就正好相反了,外部函数库不需要包含进入安装包,只需要在运行的期间动态引用即可。它的好处就是安装包会比较小,而且有多个应用程序的话,也可以很方便地共享库文件了;但缺点是用户必须事先安装好相关的库文件,而且版本和安装位置都必须符合要求,否则就不能正常运行。
在实际的应用中,大部分软件还都是采用的动态连接,共享库文件方式。这种动态共享的库文件,在Linux平台下是以后缀名为.so的文件命名的,Windows平台是.dll文件,Mac平台是.dylib文件。其实使用动态连接库也有很多好处,其中最重要的是便于管理升级。
Stay hungry,Stay Foolish。
求知若饥,虚心若愚
以上是关于编译器是如何编译的的主要内容,如果未能解决你的问题,请参考以下文章
Android 逆向Android 逆向通用工具开发 ( Android 平台运行的 cmd 程序类型 | Android 平台运行的 cmd 程序编译选项 | 编译 cmd 可执行程序 )(代码片段
flutter解决 dart:html 只支持 flutter_web 其他平台编译报错 Avoid using web-only libraries outside Flutter web(代码片段