黑盒子与现实世界的桥梁:指令集
本文对体系结构相关知识做一个简单的梳理,不进行具体技术的讲解。
前言
今天,计算机正在以惊人的速度发展着。如果运输行业能够和自从20世纪40年代末诞生的计算机行业保持同样的发展速度,那么如今我们花一分钱就可以在一秒钟内从纽约赶到伦敦 (David A. Patterson,计算机组成与设计)。但是如今人们并没有时间去深入了解飞速发展期间的每一门技术,这就导致大部分人将所需的技术视为一个黑盒,之把时间花在学习其表面上,而并没有探索到其本质,仅仅接触表象同样导致很多人对计算机感到乏味。计算机结构的底层是晶体管,其顶层是计算机显示器上呈现的信息。尽管现代计算机的内部结构不断推陈出新,但其本质上仍然是一些常见且简洁的操作集合。受限于字数限制,本文不会对计算机最底层的组成细节和抽象层次较高的软件进行详细解释,而是将精力主要集中软件和硬件的接口——指令集。希望可以作为一个引子激发起更多人对于计算机的关注,当你掌握了计算机的本质,计算机就会变得越来越有趣。
程序表象之下
或许你曾使用过C实现某一个具体的算法,但你有没有好奇过它究竟是如何在计算机上运行并且实现想要实现的功能的?
从复杂的应用程序到简单原始的指令需要若干软件层次来将高层次操作解释或翻译成简单的计算机指令。我们可以简单将计算机抽象成三个层次,外层是应用软件,中心是硬件,而系统软件(systems software)则位于两者之间。在现代计算机中,系统软件中包含的操作系统和编译器是必须的。操作系统是指用户程序和硬件之间的接口,可以为用户提供服务和监控功能,它能够处理基本输入输出、分配外存和内存并为多个应用程序提供共享计算机资源的服务,本篇文章不会对其进行深入讲解。而编译器可以将高级语言(C、Java…)编写的程序翻译成硬件能执行的指令。那么问题来了,晶体管组成的计算机理论上只能理解0和1(或者说电信号的通和断),编译器翻译出的指令是什么鬼?其实在计算机刚刚诞生的时候,人们都是直接编写0和1组成的二进制数(我们可以将其称作机器语言)运行计算机,人们无法忍受这样乏味的工作,所以助记符就诞生了。助记符更加符合人类的思维方式,但把助记符手动翻译成机器语言还是过于麻烦,于是汇编器(assembler)应运而生(有没有注意到这里是汇编器而不是编译器?)。在今天,我们将助记符这种符号语言称作汇编语言(assembly language)。
在今天计算机组成原理相关的教材中常常会提到指令集是硬件与软件之间的接口。其实所谓的指令集就是标准的规定了汇编语言与机器语言之间的关系,现在主流的指令集有x86、ARM、RISCV和MIPS等等。英特尔的芯片通常基于x86开发,苹果和移动端的芯片基于ARM开发,而RISCV和MIPS通常被应用于教学。其中x86指令集被英特尔完全闭源,使用ARM的厂家每年要上交高额的授权费,开源的道路还是任重而道远啊…因为指令集也反映了软件人员对CPU进行编程的接口,指令集也被称作指令系统架构(Instruction System Architecture, ISA)或者处理器架构。
虽然编译器的诞生是一个巨大的进步,但是其与人们的现实生活仍然相差甚远,下面是一个汇编语言的举例: st.w $t2, $zero, 1028
这条指令选自LoongArch指令集,如果想要对其进行编写,需要很好地掌握官方手册中对于相关指令的定义并且熟记各个寄存器名,这对于高层的软件编写无疑是十分繁琐的。于是人们发明了编译器(Compiler)来将高级编程语言转换为汇编语言。人们通过高级编程语言对计算机编程是计算机早期的一个重大突破,大大地提高了软件的生产率。对于实现相同的计算问题,高级语言使用的代码量平均只有汇编语言的1/7。更少的代码意味着更少的编程时间和更少的错误。高级语言的另一个好处是"平台无关"。对于不同的指令集,汇编语言是不相同的。面向一种指令集的程序在移植到另外一种指令集时,汇编代码不能通用,几乎要从头重写。如果是用高级语言,代码对所有指令集是通用的,只需要使用新平台的编译器重新编译一遍,就可以生成面向新指令的机器代码。
那么又问题来了,高级语言这么好,汇编语言还有存在的必要吗?答案是汇编语言不可能完全消亡,其在以下两种场景中仍然有着应用价值:
- 用于实现高级语言不能实现的功能。高级语言只是定义了面向大多数问题的共性语法,但每一种指令集夺回有一些特性是高级语言无法实现的,例如读写CPU内部的寄存器、访问外设的端口等,这种情况下只能使用汇编语言。因此,在基本输入输出系统(BIOS)、操作系统内核和嵌入式控制程序中常见汇编语言。
- 对于程序性能高,需要优化代码。高级语言是通过编译器转换为机器指令的,有时候并不能生成最优的指令。如果是人工编写汇编语言,就可以针对CPU的特点,发挥最大性能。
一个无法掌握汇编语言的基本素养的计算机学生不是一个合格的计算机学生。
指令集生存指南
说了这么多,我们来聊聊如和让一个指令集活下去。首先我们要解释指令集的兼容性,运行相同指令集的CPU称为“兼容的”。这里的兼容主要是指CPU可以识别相同的指令编码,因此可以运行相同的上层程序。兼容性在CPU⽣态中具有重要的意义,⼀个良性发展的⽣态是在兼容的指令集基础上制造出更多计算机、开发出更多应⽤软件。有⽣命⼒的CPU企业都会⾮常看重CPU指令集的稳定性,向指令集中添加、删除指令都⾮常⼩⼼谨慎。如果指令集发⽣变化,很容易因为设计上的疏忽⽽引⼊“不兼容”问题,导致以前的软件⽆法在新的计算机上运⾏,那么新的计算机是不会被⽤户购买的。
那么指令集就永远不变了吗?也不是这样的。时代的发展总是要求计算机实现更多功能,指令集也应该与时俱进。⼈们在实践中找到⼀种⽐较好的折中⽅法,既能够保持兼容性、⼜能够让指令集越来越强⼤。这个⽅法就是“增量演进、向下兼容”。“增量演进”的意义是,指令集的发展只能添加新的指令,不允许删除现有的指令,也不允许改变现有指令的功能。这样做的好处是,以前的软件⼀定能够在新指令集的CPU上运⾏,新的CPU能够“继承”以前全部的软件成果。坚持这样的路线,新的计算机⼀定能够对⽼的计算机实现“向下兼容”(有的书上也叫“向前兼容”)。
前文说到当前主流的指令集都因为各种各样的原因导致运用到实际当中的代价十分昂贵,那我们自己能做指令集吗?指令集是⼀个标准规范。表⾯上看,“做指令集”的成果形式就是写出了⼀份⽂档。设计⼀个指令集不算什么⾼难度的事情,和做⼀个CPU动辄需要⼏年⼯夫相⽐,它可能⼏个⽉就能完成。⾸先,做指令集不难,难的是做软件⽣态。把CPU做出来只能算是第⼀步,还需要在这种CPU上开发越来越多的软件,这样才能让CPU的使⽤价值更⼤。然⽽,现在的软件开发是很耗成本的⼯作,⾼质量软件的销售价格很容易就超过计算机硬件。软件⼚商⾯对⼀种新指令集时,很难有动⼒为其投⼊成本做开发。尤其是在指令集刚推出、还没有多少⽤户的阶段,如何吸引软件⼚商是很难解决的问题。很多指令集本⾝设计得很好,只是因为没有打破“没⽤户—没⼚商—没⽤户”的双向悖论⽽迟迟不能打开局⾯。
其次,⾼端CPU需要的指令集已经⾮常复杂,远远超过简单CPU。对于只做简单控制类⼯作的嵌⼊式CPU、微控制器CPU,可能⼏⼗条指令就够⽤了。但是对于在台式计算机、服务器中使⽤的CPU,往往需要上百条甚⾄更多的指令。尤其是像电源管理、安全机制、虚拟化、调试接⼝这些技术,设计指令集时必须和CPU内部架构、操作系统进⾏统筹考虑。有时候甚⾄需要把CPU、操作系统的原型都开发出来,经过⻓期测试验证才能保证指令集的设计达到完善程度。
繁琐与精简
控制复杂性是计算机编程的本质。
——布莱恩·克尼汉(Brian Kernighan)
在计算机发展过程中,指令集形成了两种⻛格,即复杂指令集计算机(Complex Instruction Set Computer,CISC)和精简指令集计算机(Reduced Instruction Set Computer,RISC)。⼀起来回顾⼀下这两者的渊源。早期的计算机指令集都很简单。ENIAC主要⽤于数学计算,指令集只
包含50条指令。1971年发布的微处理器Intel 4004的指令集也只有45条。可以说从20世纪50年代到20世纪70年代,指令集的数量增⻓并不明显。
随后的计算机不断增加功能,指令集也越来越复杂化。到20世纪80年代,进⼊个⼈计算机时代,指令集包含的指令数量迅速增⻓。1978年推出的Intel 8086的指令集为72条,1985年推出的Intel 80386就超过了100条,1993年推出的Intel Pentium则达到了220条。2000年Intel发布的CPU的指令数量已经超过1000条。
为什么CPU的指令集会越来越庞⼤?主要有两个原因。第⼀,晶体管技术取代电⼦管技术后,CPU制造起来越来越容易,让CPU指令⽀持更多功能具备了可能性。例如Intel在Pentium中增加的MMX指令集,主要⾯向多媒体的⾳频、视频,可以在⼀条指令中对多个数据进⾏编码、解码,其性能远远超过以前的型号。第⼆,计算机从单纯科学计算⾛向个⼈计算机,应⽤软件越来越丰富,程序员希望指令集功能强⼤,来⽅便编写程序。例如,早期计算机每条指令只能访问⼀个内存单元,⽽“串指令”可以⼀次对连续的多个内存单元进⾏读写,这样在开发相同功能的软件时,汇编代码更为简短。
但是,指令集的增⻓也带来了很多弊端。第⼀,CPU的设计制造更复杂,⽤于解析指令的电路开销变⼤,也更容易导致设计错误。第⼆,指令之间产⽣了很多的重复功能,很多新增的指令只是把已有多条指令的功能组合起来,相当于引⼊了很多的冗余,不符合指令集的正交性原则。第三,也是最严重的问题,指令的⻓度不同,执⾏时间有⻓有短,不利于实现流⽔线式结构 ,也不利于编译器进⾏优化调度。只要⽭盾积累到⼀定程度,就会有⼈站出来提出⾰命性的理念。早在20世纪70年代,就有⼀些科学家开始反思“⼀味增加指令数量”的做法是否可取。
统计表明,计算机中各种指令的使⽤率相差悬殊,可以总结为“二⼋原则”:CPU中最常⽤的20%指令,占⽤80%的执⾏频率。使⽤最频繁的指令往往是加减运算、条件判断、内存访问这些最原始的指令。也就是说,⼈们为越来越复杂的指令系统付出了很⼤的设计代价,⽽实际上增加的指令被使⽤的机会是很低的。
“精简指令集”的设想正是受此启发——指令集应该只包含最常⽤的少量指令。指令集应该尽可能符合“正交性”,即每条指令都实现某⼀⽅⾯的独⽴功能,指令之间没有重复和冗余的功能,所有指令组合起来能够实现计算机的全部功能。按照这个原则设计⽽成的计算机称为RISC。
RISC天⽣具有便于实现流⽔线架构的优点。RISC指令集清晰简洁,容易在电路的硬件层⾯进⾏分析和优化,使⽤RISC指令集的CPU能够以相对简单的电路达到较⾼的主频和性能。20世纪90年代的处理器市场上,⾼主频、⾼性能CPU基本被RISC占领。
CISC⼚商痛定思痛,决⼼找到在保持指令集不变的前提下,解决性能问题的⽅法。保持指令集不变的根本原因是坚持兼容原则,避免影响⽣态、失去⽤户。
CISC⼚商发现,CISC指令集可以采⽤两级译码的⽅法转换成RISC。⾸先,CPU对运⾏的CISC指令先进⾏⼀种“预译码”转换,⽣成⼀种内部指令“微指令”,也叫微操作(μOP)。微指令是CPU内部使⽤的,对软件不可⻅。微指令完全采⽤RISC的设计思想,对微指令的执⾏过程完全可以采⽤流⽔线架构。这样,⼀个CPU既可以执⾏CISC指令集的软件、⼜可以达到RISC架构的相同性能。现在的Intel CPU⼤多是外表披着CISC外壳、⾥⾯都是RISC的结构。
指令不是越多越好,⽽是要以满⾜应⽤需求为标准。指令数量太多,对于学习成本、编译器复杂度都代价过⾼。优秀的指令集是每⼀条指令都有必要、每⼀条指令都能在软件中良好使⽤。
小结
本文简单对指令集的概念和发展历史进行了梳理,因为不涉及具体技术细节,感兴趣的读者可以自行查找相关资料(比如说指令集的官方手册)进行学习。虽然从宏观上来讲,指令集或许只是计算机组成中的一小部分。但其存在嫁接起了硬件与软件,在计算机组成中起着至关重要的作用。而近年也不断涌现出有识之士为开源指令集贡献力量以解决指令集生态的难题,让我们敬请期待…
参考资料:
《计算机组成与设计:软硬件接口(RISCV版)》
《CPU通识课》
《计算机是怎样跑起来的》