计算机基础系列:源代码如何被计算机执行
计算机芯片的物理特性决定了它只能接受二进制指令。不同计算机芯片的指令集不同。高级编程语言需要转化成二进制机器语言才能被计算机所执行。编译型语言需要使用编译器经过编译和连接生成可执行文件,解释型语言需要使用解释器解释源代码。解释型语言更容易上手,但是运行速度更慢,必要时要使用C/C++重写或使用JIT技术加速。
现在各行各业的朋友都开始使用计算机解决自己的业务问题,网络上有大量的免费公开课,教我们处理数据并数学建模。Python等编程语言上手快,开源软件多,足以应付绝大多数的需求。在计算机软硬件体系中,上述工作都是在最顶层,用户执行程序需要依赖于计算机硬件和系统软件。聊天用的微信、娱乐玩的农药、上网打开的浏览器、还有我们自己写的程序…这些程序是如何从源代码,变成计算机芯片可以执行的程序呢?
本文并不是想把这个复杂的链条都解释得非常清楚,或者鼓励大家都去学习底层编程知识,而是想让读者对这个流程有一个基本的认识。因为,当我们熟练掌握了一定编程基础,开始上手更大规模的数据或更复杂的数学模型时,会遇到一些瓶颈,直接调用别人写好的程序或者应用新热算法都无法直接解决问题。这时候了解计算机的基本组成和运行原理,打好基础,才能为顶层应用拓展思维。
计算机只能执行二进制代码
也许你已经知道,计算机是基于二进制运行的。就像道家哲学的阴阳一样,计算机只有两个状态,开或关、真或假、1或0…因为,组成计算机的基本元件——半导体只能以二进制进行计算。我们编程所用的C/C++、Python、大数据、AI等层出不穷的技术,以及我们存储在电子设备的文本、音频、图像、视频等媒介,最终都是以二进制的形式,被计算和处理的。计算机体系最底层的工程师要使用二进制代码控制芯片来做计算和处理。
我在我的Mac上编写了一个名为plus
的c = a + b
程序,其二进制和汇编代码如下所示:
➜ objdump -s plus
plus: file format Mach-O 64-bit x86-64
_main:
100000f30: 55 pushq %rbp
100000f31: 48 89 e5 movq %rsp, %rbp
100000f34: 48 83 ec 20 subq $32, %rsp
100000f38: c7 45 fc 00 00 00 00 movl $0, -4(%rbp)
100000f3f: c7 45 f8 01 00 00 00 movl $1, -8(%rbp)
100000f46: c7 45 f4 02 00 00 00 movl $2, -12(%rbp)
100000f4d: c7 45 f0 00 00 00 00 movl $0, -16(%rbp)
100000f54: 8b 45 f8 movl -8(%rbp), %eax
100000f57: 03 45 f4 addl -12(%rbp), %eax
100000f5a: 89 45 f0 movl %eax, -16(%rbp)
100000f5d: 8b 75 f8 movl -8(%rbp), %esi
100000f60: 8b 55 f4 movl -12(%rbp), %edx
100000f63: 8b 4d f0 movl -16(%rbp), %ecx
100000f66: 48 8d 3d 35 00 00 00 leaq 53(%rip), %rdi
100000f6d: b0 00 movb $0, %al
100000f6f: e8 0e 00 00 00 callq 14 <dyld_stub_binder+0x100000f82>
100000f74: 31 c9 xorl %ecx, %ecx
100000f76: 89 45 ec movl %eax, -20(%rbp)
100000f79: 89 c8 movl %ecx, %eax
100000f7b: 48 83 c4 20 addq $32, %rsp
100000f7f: 5d popq %rbp
100000f80: c3 retq
........
首行的file format Mach-O 64-bit x86-64
表示这是一个可以运行在64位x86架构的处理器上、基于Mac OS的一段程序。不同的计算机芯片厂商所设计的半导体电路不同,在芯片上编程的二进制规则不同。执行同样的一段c = a + b
的逻辑,在基于ARM架构芯片的Android手机上所需要的二进制代码与上面展示的会截然不同。当前市场上计算机CPU芯片基本被几大科技公司垄断,除了刚提到的Intel和AMD研发的应用在个人电脑上的x86-64处理器,应用在手机、平板电脑等移动设备上的ARM架构处理器,还有应用在大型服务器和超级计算机上的IBM Power系列处理器等。不同架构的CPU处理器都有自己的一套指令集(instruction set architecture,简称ISA),这就像一个设计图纸和使用说明书,告诉编程人员如何使用在其芯片上进行编程:包括如何进行加减乘除计算,如何从内存中读取数据等指令操作。底层开发人员会根据不同指令集,适配不同的CPU处理器。计算机能执行的指令,又被成为机器语言或机器码。
前面所展示的二进制文件是一个可执行文件
。什么是可执行文件呢?可执行文件就是二进制机器语言的集合,可以被机器执行,得到我们想要的结果。我们在Windows上常会遇到的.exe
文件,就是可执行文件,exe
其实是executable
的缩写,从手机应用商店下载的APP也是可执行文件的一种变体。
C语言从源代码到可执行文件
很多朋友觉得C/C++编程调试难,没有比较就没有伤害,看到前文所提到的一个简单加法的程序竟然需要这么多看不懂的01代码,是不是觉得C语言简直是天才般的发明。是的,C语言的发明者当时考虑的就是不同芯片厂商有不同的指令集,相互之间难以兼容,于是想在那些晦涩难懂的底层语言上,建立一个更为通用的编程范式,这样编程人员不用浪费时间精力去识记大量的01二进制指令。那C语言代码是如何转化为可被机器执行的二进制文件呢?编译器和操作系统是两个非常关键的技术。
下面继续以加法计算plus.c
源代码为例,展示编译器和操作系统计算机将C语言转化为机器可执行文件。
int main()
{
int a = 1, b = 2, c = 0;
c = a + b;
printf("a = %d b = %d c = %d \n", a, b ,c);
return 0;
}
Linux和Mac OS用户可以使用gcc -o plus plus.c
这个命令来将plus.c
的源代码编译成名为plus
的可执行文件,plus
会生成在当前的文件夹下。
执行这个二进制文件,结果将被打印到屏幕上:
$ ./plus
a = 1 b = 2 c = 3
gcc
是一款开源的编译器,是GNU Compiler Collection中的一员,它可以将C语言代码编译成可执行文件。GNU Compiler Collection还有C++编译器g++
、Fortran编译器gfortran
,并且支持包括x86-64和ARM在内的不同指令集。
C语言从源代码到执行,要使用编译器来编译(compile)、汇编(assembly)并连接(link)所依赖的库,形成机器可执行文件。执行这个二进制文件时,操作系统会为程序分配内存和CPU资源。“编译”和“汇编”,相当于将C语言翻译成底层语言。另外,代码中使用了库函数printf
,当我们使用别人写好的函数时,需要将这些前人写好的库函数连接到我们的可执行文件中,否则有调用函数失败的错误。我们将这种需要编译的语言称为编译型语言。编译型语言有C/C++、Fortran等。
操作系统和编译器是紧密相连的,不同操作系统所提供的编译环境不同。Linux和GCC编译器密不可分,Windows有自家研发的MSVC(Microsoft Visual C++)。不同操作系统在管理网络、读写硬盘、图形化等具体的实现方式不同,库函数连接方式不同…可执行文件一般需要调用这些操作系统接口,所以最终连接生成的可执行文件会截然不同。了解了编译知识,就不难明白为什么很多软件提供商对同一个软件会提供Windows、Mac OS、Linux、iOS、Android等多个版本的下载。因为不同平台的硬件、编译器和操作系统存在着巨大差异,可执行文件完全不同。所以,也就不难理解Windows软件为什么不可能在Mac OS上运行。
实际构建一个大型项目时,编译要考虑的问题会更多。比如我自己编写了多个文件,文件1会被文件2调用,所以要先编译文件1,后编译文件2,否则会因为顺序颠倒而报错;还比如编译型语言对所以依赖的库函数非常挑剔,如果版本过低,有可能出现编译错误。类似的问题会很多,因此编译型语言在编程和调试时更麻烦,实际操作中一般会使用构建工具链(toolchain),根据一定的顺序,从前到后串起来地去编译。
解释性语言:Java、Python、R…
既然可以将01组成的机器语言抽象成容易编写的C语言,那为什么不能继续再用类似的办法,再做一次包装呢?IT圈的一句名言就是:计算机科学任何领域的问题都可以通过增加一个中间层来解决。一些大牛忍受不了C语言这样编写和调试太慢,系统平台之间无法共享移植的问题,于是开始自立门户,创建了新的编程语言,最有名的要数Java和Python,这类语言不需要每次都编译,因此被称为解释型语言。matlab、R、JavaScript也是解释语言。
解释型语言一般是使用C语言等偏底层的语言做一个虚拟机
或者解释器
,编程人员需要先在自己的计算机上安装这个解释器,接下来就只用关心自己的源代码,其他的事情都交给解释器去做。如果把编译型语言的编译过程比作将源代码“翻译”成机器语言的话,那么解释型语言就是同声传译。编译型语言是一篇提前就“翻译”好的稿子,拿过来就能被读出来,这样肯定更快;解释型语言要等翻译边“听”边“翻译”,速度当然慢很多。
有了解释器,我们可以在任何安装了Python的机器上运行同样一份.py
源代码文件。像Python这样的解释语言就像一个高级计算器,非常容易上手,有一些理工基础的朋友,半天时间就能学会。
其实,这就是一个妥协的过程,解释语言放弃了速度,取得了易用性和可移植性。
如果我还是关心速度呢?当然还是要回归底层,拒绝中间商赚差价嘛!
以Python为例,为了保证性能,大部分高性能科学计算库其实都是使用编译型语言编写的。比如numpy
,用户安装numpy
的包时,其实就是下载了C/C++和Fortran源代码,并在本地编译成了可执行的文件。Python用户自己可以使用Cython
这样的工具,R语言可以使用Rcpp
。我最近在使用Java来调用C++代码,速度有成倍提升。一些计算密集型的程序可以考虑用这种方法来进行优化。
另一种方案是JIT(Just-In-Time)技术。JIT把需要加速的代码编译成了机器语言,不再需要“同声传译”拖累自己了。我在Python上用numba
库进行过JIT测试,同样的代码会有8倍以上的速度提升。
本专栏以后也将介绍如何对解释语言进行加速。
小结
计算机芯片的物理特性决定了它只能接受二进制指令。不同计算机芯片的指令集不同。高级编程语言需要转化成二进制机器语言才能被计算机所执行。编译型语言需要使用编译器经过编译和连接生成可执行文件,解释型语言需要使用解释器解释源代码。解释型语言更容易上手,但是运行速度更慢,必要时可使用C/C++重写或使用JIT技术加速。