首先向C语言之父Dennis Ritchie致敬!
今天几乎所有实用的编译器/解释器(以下统称编译器)都是用C语言编写的,一些语言如Clojure、Jython等是基于JVM或用Java实现的,而IronPython等则是基于.NET实现了,但是Java和C#本身也是依赖C/C++来实现的,相当于间接调用了C。 所以衡量某种高级语言的可移植性其实就是在讨论ANSI/ISO C的可移植性。
C语言是一种非常低级的语言,在很多方面都类似于汇编语言。 在《Intel 32-bit Assembly Language Programming》一书中,甚至介绍了一种将简单的C语言手工翻译成汇编的方法。 对于编译器这样的系统软件,用C来写是很自然的,甚至像Python这样的高级语言在底层仍然依赖于C(Python的例子是因为英特尔黑客试图让Python不需要操作系统就可以运行——有效消除BIOS 上的一次性 C 代码)。 现在的学生,在学习了编译原理后,只要有一点编程能力,就可以实现一个功能简单的类C语言编译器。
但问题来了。 不知道大家有没有想过。 大家都用C或者基于C的语言写编译器,那么世界上第一个C语言编译器是怎么写出来的呢? 这不是“先有鸡还是先有蛋”的问题……
让我们回顾一下C语言的历史:1970年Tomphson和Ritchie在BCPL(一种解释语言)的基础上开发了B语言,1973年在B语言的基础上成功开发出了现在的C语言。 在C语言被采用为系统编程语言之前c语言编译器哪个好,Tomphson也用B编写了操作系统。可见,在C语言实现之前,B语言已经可以投入实际使用。 所以完全有可能第一个C语言编译器的原型是用B语言或者B语言和PDP汇编语言混合编写的。 我们现在都知道B语言的执行效率比较低,但是如果全部用汇编语言写的话,不仅开发周期长,维护困难,更可怕的是会丢失高级编程语言所必需的可移植性。 所以早期的C语言编译器采取了一种取巧的方法:先用汇编语言为C语言的一个子集写一个编译器,然后通过这个子集递归完成完整的C语言编译器。 详细过程如下:
先创建一个只有C语言最基本功能的子集,称为C0语言。 C0语言足够简单,直接用汇编语言就可以写出一个C0编译器。 依托于C0已有的功能,设计上比C0复杂,但仍不完善,C语言的另一个子集,C1语言,其中C0属于C1,C1属于C,开发C1语言的编译器二氧化碳。 在C1的基础上,设计了C语言的另一个子集C2语言。 C2语言比C1复杂,但仍不是完整的C语言。 开发了一个C2语言的编译器……所以直到CN,CN足够强大,这个时间足以开发出C语言编译器的完整实现。 至于这里的N是什么,取决于你的目标语言(这里是C)的复杂程度和程序员的编程能力——简单来说,如果你达到了某个子集阶段,你可以很容易地使用已有的函数实现C语言时,然后你找到了 N。下图说明了这个抽象过程:
那么这种大胆的子集简化方法是如何实现的,其理论依据是什么? 先介绍一个概念,“自编译”Self-Compile,即对于一些具有明显自举特性的强类型(所谓强类型是指程序中的每个变量都必须先声明一个类型才能使用,比如作为C语言,相反一些脚本语言根本没有类型)编程语言可以借助它们的有限子集通过有限数量的递归来表达自己。 这样的语言有C、Pascal、Ada等,至于为什么可以自己编译。 可以参考清华大学出版社的《编译原理》,里面实现了一个Pascal子集编译器。 总之,有计算机科学家已经证明,C语言理论上可以通过上述CVM方法实现一个完整的编译器,那么在实践中如何简化呢? 这张照片是不是很眼熟? 对了,讲虚拟机的时候看到了,不过这里说的是CVM(C Language Virtual Machine),每种语言都可以在每个虚拟层上独立编译,而且除了C语言,每个层的输出都会是作为下一层的输入(最后一层的输出就是应用程序),和滚雪球一样。 用手(汇编语言)组合一小把雪,然后一点点滚下去,形成一个大雪球。 这大概就是所谓的0生1,1生C,C生万物吧?
以下是 C99 关键字:
仔细看看,其实帮助编译器优化的关键字有很多,还有一些是用来限制变量、函数、可链接性或生命周期的作用域(函数没有),这些在编译器中实现的有前期完全不用加,所以auto、restrict、extern、volatile、const、sizeof、static、inline、register、typedef都可以去掉,这样就形成了C、C3语言的子集,关键字C3语言如下:
再想一想,发现其实C3中有很多类型和类型修饰符是没有必要一下子全部加进去的。 比如这三种整数类型只需要实现int,就进一步去掉了这些关键字。 它们是:unsigned, float, short, char (char is int), signed, _Bool, _Complex, _Imaginary, long,这样就形成了我们的C2语言,C2语言的关键字如下:
继续想,即使是只有18个关键字的C2语言,也有很多高级特性,比如基于基本数据类型的复合数据结构。 此外c语言编译器哪个好,我们的关键字表中没有写运算符。 C语言中的复合赋值运算符->、运算符++、–等过于灵活的表达式此时可以完全删除,所以可以删除的关键字有:enum、struct、union,这样我们就可以得到key C1语言字符:
已经接近完美了,只是最后一步自然要大一些。 这时,数组和指针也会被移除。 另外,C1语言还有很多冗余。 例如,控制循环和分支的表达方式有很多种,但都可以简化为一种。 具体来说,循环语句有While循环、do…while循环和for循环,保留while循环即可; 分支语句有四种:if…{},if…{}…else,if…{}…else if…,switch形式,它们可以由两种以上的if…{}实现,所以是足以保留 if,…{}。 但是再想一想,所谓的分支和循环只是条件跳转语句,而函数调用语句只是压入和跳转语句,所以只需要goto(无限制的goto)即可。 因此,大胆去掉所有的结构关键字,连函数也不行,得到的C0语言关键字如下:
关键字只有5个,用汇编语言可以快速实现。 通过逆向分析,我们还原了第一个C语言编译器的编写过程,也感受到了前辈们的智慧与辛劳! 我们不过是巨人肩上的尘土! 0生1,1生C,C生万物,真是巧妙啊!