我们为什么要编程呢?计算机的计算速度很快,而我们想让计算机帮我们做事情,就需要编写程序。最初,计算机从事的主要是科学计算工作。后来,人们也编写其他的程序,使得计算机能够处理文本、字符等,这使得人们可以使用更为简单直观的命令操纵计算机做一些简单的事;人类还发明了计算机语言,并编写能够根据这种结构化的语言生成程序的程序,甚至,使用一种语言来开发另一种语言。总之,人们能够调用编写好的程序,极大的简化工作的流程,也能使用这些程序更快的开发新的程序,来满足生产生活的需要。

归根结底,人们使用计算机就是在和数据打交道——不管是记录信息、读取信息、分享信息,还是从信息中分析出有用的数据。而要实现这些,就需要程序。而程序需要由人们来编写,编写的过程就是编程(programming),也就是以计算机能够理解的方式设计程序的步骤。

机器指令

那么什么是计算机能理解的语言呢?我们说,计算机的核心是计算,而实现计算的,是计算机的“中央处理器”,也就是我们说的 CPU。而计算机能理解的语言,就是 CPU 能理解的语言。

我们把 CPU 的语言叫做“机器指令”或者“机器码”。由于我们现如今的计算机几乎都是二进制的,“机器码”也就是以二进制形式表示的数码。

CPU 内部是复杂的电路,根据事先约定好的方式精心设计而成,它能够理解我们输入的数码、进行计算,并输出结果。一条指令,会让 CPU 做一次运算;编程,就是很多指令的形成的序列。

假如我们要计算表达式“12 + 34”的值,我们可以按照约定好的方式告诉 CPU 要做的事情——“计算加法,加数和被加数分别为 12 和 34”。而实际上输入 CPU 的,是一条二进制数构成的指令。假如“00001”代表“做加法”,而“12”“34”的二进制分别为“1100”“100010”,那么 CPU 接收到的的指令可能是下面这个样子:

  1. 00001 0000 0000 0000 1100 0000 0000 0010 0010

你可能会注意到,这次加法运算中的两个数据被补齐到了 16 位,以固定长度编码数据方便我们设计 CPU 内部的电路以及编写指令。此外,我们可以认为这台 CPU 能够处理(最长)16 位的数据,因此是一个 16 位的 CPU,也称机器的“字长”为 16 位。

同理,假如“00002”代表减法,那么计算“12 - 34”的指令可能是:

  1. 00002 0000 0000 0000 1100 0000 0000 0010 0010

上面只是一个简化的举例,实际情况中,CPU 指令的设计是一个比较复杂的事情。一台 CPU 能够支持的各种指令叫做“指令集”,通常有“复杂指令集”和“简单指令集”之分。

编程让 CPU 实现复杂一点的运算

刚才我们尝试进行了加减法的计算,这只需要进行一次运算就可以得到结果。但是很多计算过程都需要不止一次运算来实现——比如求数的阶乘、求数列的和等。

以计算 10 的阶乘为例,我们可能需要这样编写指令:

  1. 计算 1 * 2
  2. 结果(2 * 3
  3. 结果(6 * 4
  4. 结果(24 * 5
  5. ...
  6. 结果(362880 * 10

我们发现,这样编写指令效率很低,因为我们需要不断地得到结果,并根据结果编写新的指令。我们想,如果 CPU 能够记录下中间的结果就好了。现代 CPU 中确实有这样的设计,即“寄存器(register)”。如果我们用 $1 表示寄存器 1 号,则刚刚的程序可以这样改写:

  1. $1 = 1
  2. $1 = $1 * 2
  3. $1 = $1 * 3
  4. $1 = $1 * 4
  5. ...
  6. $1 = $1 * 10

这样子,我们虽然还是要写 10 行命令,但是可以一次性地将这个指令序列编写好,依次输入 CPU 即可。

不过我们现在又发现,这些指令之间大部分都是相似的,只有一个变数,并且还是有规律的变化(每次递增 1)。于是,我们可以考虑将这个变化的值也放入寄存器中。

然后我们可以这样写程序

  1. $1 = 1
  2. $2 = 2
  3. $1 = $1 * $2
  4. $2 增加 1
  5. 重复前 2 条指令 9

这样,我们就构造了一个“循环”,可以看到,使用循环之后的,程序的代码量大大降低。

不过,CPU 通常没有智能到能够明白我们所谓的“重复之前指令”。我们一般只能让 CPU 跳转到某个位置,如“2 条指令之前”。为此,我们还需要将代码改写一下:

  1. $1 = 1
  2. $2 = 2
  3. $2 大于 10,则跳转到 3 条指令后
  4. $1 = $1 * $2
  5. $2 增加 1
  6. 跳转到 3 条指令前

我们通过在一段指令的结束处跳转到之前的位置实现循环,并在循环体的入口做判断,来决定是否跳出循环。

编程让 CPU 调用一段过程

此外,我们有时要在程序里多次调用一段相同的过程(也称作子过程、函数),其中只是一些寄存器的值不同(我们把这些每次调用时不同的值称作函数的参数),这也需要通过跳转来实现:

  1. // 一个乘 3 加 1 的函数的代码,函数叫做 foo
  2. // 代码暂时略
  3. // 若干其他代码……
  4. // 下面开始调用函数 foo
  5. $参数1 = $1
  6. 跳转到函数 foo 的位置,即 xxx 条指令之前(之后)

我们将函数要用到的参数放在参数寄存器 $参数1 里面,这样,我们跳转到函数的指令位置时,就只需要读取 $参数1 中的值,就能获得我们带给函数的信息了。

函数运算完成后,可能会返回计算结果,这个也是通过类似上述将值存在寄存器中的方法,将计算结果存在返回值寄存器中,来实现代码区段之间的沟通

另外,当函数的代码执行完后,我们需要返回调用处,这是通过将当前调用处的位置存在“返回地址寄存器”中实现的。

了解了上述,我们来尝试编写代码:

  1. // 一个乘 3 加 1 的函数的代码,函数叫做 foo
  2. $临时1 = $参数1
  3. $临时1 = $临时1 × 3
  4. $临时1 = $临时1 + 1
  5. $返回值1 = $临时1
  6. 跳转到 $返回地址 中的地址
  7. // 若干其他代码……
  8. // 准备开始调用函数 foo
  9. // 即,将参数存在寄存器中,然后跳转到函数的代码段
  10. $参数1 = $1
  11. 跳转到函数 foo 的位置,即 xxx 条指令之前(之后)
  12. // 函数运算完成,会跳转到上一条指令之后的位置
  13. // 也就是调用处的下一条指令
  14. $2 = $返回值1 // 将函数的返回值存储在 $2 中以备后用

有时候,因为考虑到函数中可能还会调用其他函数,所以,我们需要将当前函数调用的状态——也称作“栈帧(stack frame)”——保存起来,放入一种叫做“栈”的容器中。当前函数的状态包括使用了的寄存器,以及“返回地址寄存器”中的值等。这样,当一个函数调用完成时、即将返回调用处之前,我们再从栈中将保存的值弹出,这样,在返回到调用处时,调用处用到的寄存器就是调用之前的值了。

于是,我们的代码改为如下:

  1. // 一个乘 3 加 1 的函数的代码,函数叫做 foo
  2. 入栈 $返回地址 的值
  3. 入栈 $参数1 的值
  4. $临时1 = $参数1
  5. $临时1 = $临时1 × 3
  6. $临时1 = $临时1 + 1
  7. $返回值1 = $临时1
  8. 出栈 $参数1 的值,放入 $参数1
  9. 出栈 $返回地址 的值,放入 $返回地址
  10. 跳转到 $返回地址 中的地址
  11. // 若干其他代码……
  12. // 准备开始调用函数 foo
  13. // 即,将参数存在寄存器中,然后跳转到函数的代码段
  14. $参数1 = $1
  15. 跳转到函数 foo 的位置,即 xxx 条指令之前(之后)
  16. // 函数运算完成,会跳转到上一条指令之后的位置
  17. // 也就是调用处的下一条指令
  18. $2 = $返回值1 // 将函数的返回值存储在 $2 中以备后用

在我们这个例子中,保存寄存器的值是没必要的,因为 foo 过程中没有调用其他过程,也就不会修改到 $返回地址 等寄存器中的值。但如果过程中调用了其他的过程,返回地址寄存器中的值就会被改变,因此,就有必要在每次函数调用的开始,将这次函数调用的“返回地址”记录下来(放入栈中),并且在这次调用结束时,取回属于这次调用的返回地址等值。试想,在不考虑栈的容量的情况下,如果每次调用都遵循这样的方式,那么每次函数调用都能回到正确的地方。

我们会把没有调用的其他过程的过程称为叶子过程。想象一下,过程的调用是不是像一个树?树最末端的元素被我们称作“叶子”。

  1. main
  2. / | \
  3. foo1 foo2 foo3
  4. / \
  5. foo4 foo5

foo2foo3foo4foo5 不会再调用其他过程了,因此也就不需要额外执行的保存 $返回地址 的操作。

以上便是 CPU 所能理解的语言,即机器语言。上述讲解过程中,我们避开了二进制数码的形式,而是用人类使用的语言描述了指令所实现的操作。

汇编语言

可以看到,要操作计算机,需要二进制码,而要记忆二进制数码对人类来说并不是件容易的事,于是人类发明了助记符,也就是用字母或单词代替机器指令中的二进制数码来编写程序,这便成了汇编语言。

为此,人们需要先编写好翻译“汇编语言”的汇编器,简单地说,就是一段将字符转化成二进制码的程序。汇编语言经过汇编器变成机器码。

高级语言

在前文中我们看到,汇编语言的编写难度依旧很高——命令是逐行执行的,不太能体现出层次和模块的概念。比如,要实现循环操作,或者重复使用之前写好的代码,需要使用指令的跳转来实现。这在编程过程中容易犯错,并且不容易被发现。此外,不同 CPU 的指令集不同,这意味着,同样的程序,需要针对不同的 CPU 编写不同机器指令,才能使 CPU 理解。

于是,人类又发明了诸多高级语言,用来描述算法。高级语言往往有着更清晰的语法,且与平台无关,再一次降低了编程的难度。高级语言通过词法分析、编译等步骤,最终变成 CPU 能够理解的机器指令。使用高级语言,程序员可能只需编写像 do {...} for 5 times 这样的语句,就能表示循环 5 次的操作。

同样,人们需要编写将高级语言变成机器码的程序,一般称作“编译器”。编译器也是(具体平台上的)一串机器码。

一般来说,一门新的高级语言的编译器最开始也需要由汇编语言写就,或者由现有的高级语言编写并编译而成。但可以想象,最开始的高级语言的编译器,是不得不用汇编语言来编写的。

要用汇编语言实现如词法分析等如此复杂的功能,听起来好像是个很困难的事。不过事实上,我们可以通过编写一个能够分析处理一些基础的语法的简单编译器,然后再用这些基础的语法实现更高级的功能,进而用这个简单编译器编译得到更高级的编译器。这个过程就叫做“自举(bootstrap)”。

除此之外,为了提高开发效率,我们常采用将多种高级语言翻译成一种中间语言(中间码)、再将中间码翻译成针对不同平台的机器码的方式。这种方式使得开发人员不必掌握从高级语言到具体平台机器码的全部知识——只需要专注高级语言翻译到中间码的过程和中间码到机器码的过程,降低了开发成本,同时也使协同开发更加方便,提升了开发效率。此外,我们也可以只专注于中间码的优化,这会更加方便,比如我们可以很容易的提取出某些特定形式的指令序列,并用等价的、更高效的指令进行替换。

大名鼎鼎的 C 语言就可以说是“最开始的高级语言”的存在。其语法比较贴近计算机运作的本质,换句话说,可以较为轻松的翻译成汇编语言或者说机器指令。

在 C 语言之前和之后,人们也开发了许许多多种语言,这些语言一般都有自己的风格和模式,但大多可以归入若干类“编程范式”中。编程范式之间的差异,主要在于其对问题的建模方式,以及对程序中数据流的处理理念

大多数情况下,编译器在将我们写就的高级语言翻译成机器指令时,会尽可能地利用平台的特性以及做出必要的优化。(当然,不同编译器的优化能力也不尽相同。)

尽管如此,程序员仍需对硬件有一定的了解,这样才能写出效率更高的程序。

解释执行

当然,并不是所有的高级语言都需要编译执行。规定一门编程语言相应的语法后,我们可以编写对应的解释器,来解释这些语法,最终将其转化为对应 CPU 平台上代码并执行。这个过程就叫做解释执行。而我们可以使用高级语言来编写解释器,这样就能生成不同平台上可用的解释器。而通过解释性语言编写的程序,可以直接分发,不用担心平台的差异——用户只要获得到对应平台上的解释器就可以运行程序。

解释执行的程序,通常为文本文件写成的代码,人类可读性强。

与此类似的,我们也可以设计一套指令,或者说机器码,并且编写一种可以执行这种机器码的程序,也就是“虚拟机”。Java 就是一种这样的语言。开发人员使用 Java 语言的语法编写程序,经过编译生成“字节码”,这种字节码需要通过 Java 的虚拟机来执行(即 JVM)。

如何理解高级语言的“高级”

之所以称为高级语言,是因为这些语言将机器底层的细节为我们封装起来了。我们只需要考虑语言的语法,或者说,语言想让程序员注意的层次即可。

越底层的语言,程序员越要考虑机器层面的情况,比如数据类型的长度、内存资源的获取与释放。而越高级的语言,其语法越抽象,往往程序员只用关心怎么解决问题,而更具体的细节由这门语言的编译器或者解释器来实现。但越高级也意味着,编译或执行时,速度可能会越缓慢。

像 C++ 提供了抽象的语法,使得程序员在编写时可以更加方便,但同时又要追求运行时的效率,所以有些处理就放在了编译阶段,用来节省运行时的时间。

总的来说,运行效率和抽象程度是相对的。

小结

  • 体会“层级”的概念,如新的程序在旧的程序上开发,多种语言翻译成中间语言、再进一步翻译成机器码等;
  • 体会编译器开发中“自举”的概念。