|
前言 如果你写过单片机代码,哪怕只是点过一个LED,你就已经和"指令集"打过交道了。 但你大概率没想过一个问题:为什么同样一句C语言的a = b + c,在8051上编译出来是4个字节,在Cortex-M3上是2个字节,在RISC-V上可能只有2个字节,但执行效率却天差地别? 为什么8051能做除法,而Cortex-M0不能?为什么ARM明明是32位架构,却要搞一套16位的Thumb指令?为什么RISC-V从一开始就把压缩指令设计好,而ARM是后来才打的补丁? 这篇文章不打算用教科书的方式来讲指令集,我们换一个角度:从三条非常具体的线索出发,把单片机指令集40年的演化史串起来。这三条线索是: 一条指令有多长?一条指令吃几个操作数?一条指令能不能直接完成C语言里的一个基本运算? 带着这三个问题,我们从1980年的8051出发,一路走到今天的RISC-V,看看芯片设计者们是怎么在"性能、成本、功耗、代码体积"这四堵墙之间反复腾挪的。 第一站:8051的时代,一切从"够用"开始1980年,英特尔推出8051。这是一颗8位单片机,机器码是变长的,1到3个字节不等。 变长这件事,是CISC架构的本能。8051的指令集里,操作数格式是"累加器+源"的双操作数形态。比如加法指令,永远是"把某个东西加到累加器A里",写成汇编就是ADD A, Rn或者ADD A, #data。A是隐式的,永远是被加数也是结果存放地。 这种设计在硬件上极省资源。8051内部寄存器极少,只有一个累加器A、一个B寄存器、几组R0到R7。指令集不需要告诉你"结果放哪",因为永远放A里。 从C语言完备性的角度看,8051反而是个优等生。它有8位的加法、减法、乘法、除法。除法指令叫DIV AB,把A除以B,商放A,余数放B。取模运算硬件直接支持。移位、逻辑与或非、关系比较,全都有对应的原生指令。 按我们前面定的标尺,8051是8位完备的。它能在硬件层面直接支撑C语言全部8位基本运算,编译器不需要把除法退化为软件模拟。 但8051的代价是什么?是代码密度和性能的天花板。变长指令意味着取指电路复杂,流水线很难做深。累加器架构意味着数据搬移频繁,想保留一个中间结果,你得先MOV到别处。8位数据宽度意味着处理16位或32位数据时要拆成多条指令拼。 这些都不是8051的"缺陷",而是1980年的技术约束下的合理选择。那时候内存按字节卖都嫌贵,芯片面积以平方毫米计就是成本,能把一个完整的计算机塞进一个芯片里已经是个奇迹。 第二站:80251的尴尬,16位完备但32位跛脚8051的成功让它活了40多年,至今还在很多家电里跑着。但工业界需要更强的算力。1990年代,英特尔推出了80251架构,作为8051的增强版。 80251是8/16/32位混合架构,机器码仍然变长。操作数格式开始向更灵活的方向演进,不再死绑累加器。 但它有一个致命的跛脚:有32位乘法指令,却没有32位除法指令。 这意味着什么?如果你写一句C语言的32位除法a = b / c,编译器只能生成一大段软件模拟代码,用移位和减法循环去算。而同样的操作,16位除法它倒是能硬件完成。 所以80251的完备性是分裂的:16位完备,32位不完备。 这个设计决策背后是工程权衡。32位除法器在硬件上很贵,1990年代的工艺面积不允许。但市场又需要32位乘法来做DSP和数据处理。于是乘法留下了,除法砍掉了。 这种"半完备"状态在嵌入式历史上反复出现,后面你会看到更多例子。 第三站:x86的参照系,CISC的变长双操作数哲学在讲ARM和RISC-V之前,我们必须先看看同时代的x86,因为它代表了另一条完全不同的路。 1978年的8086,16位,变长指令1到6个字节。操作数格式是双操作数:ADD AX, BX,意思是AX = AX + BX。目的和左源是同一个寄存器。 这种格式的好处是直观,写汇编的人觉得"我在把BX加到AX上",语义紧凑。坏处是目的寄存器被覆盖了,如果你后面还需要原来的AX,你得先PUSH或者MOV到别处。 8086是16位完备的,加减乘除取余全有。80286、80386一路演进,到386变成32位完备,到x64变成64位完备。x86的完备性一直保持得很好,因为它面向通用计算,软件生态不允许除法靠软件模拟。 但x86的代价是解码器的复杂度。一条指令可以从1字节到15字节不等,CPU前端必须逐字节解析前缀、操作码、ModR/M、SIB、位移、立即数,才能知道这条指令到底多长、在干什么。现代x86 CPU实际上先把变长指令翻译成定长的微操作(μops),再送进流水线执行。 这是CISC的核心矛盾:指令功能强大、密度高,但硬件解码成本巨大。RISC的回答是:把复杂度从硬件搬到编译器,指令做简单,解码做快。 第四站:ARM的诞生,三操作数成为RISC的旗帜1985年,Acorn公司做出了ARM1。这是一颗32位RISC处理器,机器码固定4字节,操作数格式是三操作数:ADD Rd, Rn, Rm。 这条指令的意思是:把Rn和Rm相加,结果放进Rd。Rn和Rm都不会被修改。 这个设计看起来只是多了一个目标寄存器字段,但它对编译器的意义是革命性的。三操作数意味着你可以保留所有源操作数,同时产生一个新结果。不需要额外的MOV指令来搬运数据。流水线调度更简单,因为每条指令的输入输出在编码里就明确了。 ARM1的硬件结构也和这个格式完美匹配:寄存器堆有两个读端口、一个写端口,ALU每拍吃两个源操作数、写一个结果。三操作数不是后来加的,是和芯片一起从零设计出来的。 从C语言完备性看,早期的ARM(ARMv1到ARMv3)有加法、减法、乘法(后期加入),但没有除法指令。按我们的标尺,它们是32位不完备的。但那个年代的RISC哲学认为:除法太慢,不如让编译器生成软件例程,硬件保持简单。这个选择在桌面和服务器领域可以接受,但在嵌入式领域后来被证明是个问题。 第五站:Thumb-1的妥协,为密度退回双操作数1994年,ARM7TDMI处理器引入了Thumb指令集。这是ARM历史上最重要也最纠结的一个决定。 当时的嵌入式市场,内存非常贵。一颗4KB的Flash可能比芯片本身还值钱。纯32位ARM指令代码体积太大,客户抱怨。ARM公司需要一种方式来压缩代码,又不至于重新设计一颗芯片。 Thumb的解决方案是:加一套16位的指令子集。机器码从4字节缩到2字节。 但16位里塞不下三个4位寄存器字段加操作码。于是Thumb-1的大部分指令退化成了双操作数:ADD R0, R1,意思是R0 = R0 + R1。目的和左源又合并了,和CISC的双操作数看起来一样,但动机完全不同。 Thumb-1不是独立的ISA,它是ARM指令集的压缩编码。CPU内部有一个解包器,把16位Thumb指令透明地展开成32位ARM指令再执行。程序员不需要关心展开过程,但编译器必须知道哪些指令能用Thumb编码,哪些不能。 操作数退化只是表面。更深的代价是:Thumb-1砍掉了条件执行的大部分能力,砍掉了桶形移位器,只能用低8个寄存器。而且为了切换ARM和Thumb状态,函数调用时要通过BX指令切换处理器状态,链接器和操作系统都要配合。 从完备性看,Thumb-1(以及后来Cortex-M0/M0+使用的ARMv6-M子集)有乘法指令MULS,但没有除法指令SDIV/UDIV。取余运算全靠软件模拟。按我们的标尺,它和80251一样,是某种意义上的"不完备"。 但Thumb-1达成了它的目标:代码体积缩小了大约30%,在嵌入式市场大获成功。后来的Cortex-M系列几乎全部默认使用Thumb指令集,ARM的32位定长指令反而变成了"只在需要性能时偶尔混用"的选项。 第六站:Cortex-M和Cortex-A的分道扬镳这里必须插入一个重要的对比:Cortex-M系列和Cortex-A系列,虽然都叫ARM,但指令集的取向完全不同。 Cortex-A系列面向应用处理器,跑Linux、Android,追求性能。它使用ARMv7-A或ARMv8-A架构,支持完整的32位或64位指令集,有Thumb-2(16位和32位混合),有NEON SIMD,有虚拟化扩展。从完备性看,A系列是32位完备的,后来64位也是完备的。 Cortex-M系列面向微控制器,跑裸机或RTOS,追求实时性和低功耗。它使用ARMv6-M、ARMv7-M或ARMv8-M架构,只执行Thumb/Thumb-2指令,不支持完整的ARM状态。M3和M4有SDIV和UDIV除法指令,是32位完备的。但M0和M0+没有,是不完备的。 这个分裂不是偶然。A系列不怕面积和功耗,可以堆除法器、堆缓存、堆流水线。M系列要在几美分成本的芯片上跑,除法器太贵,砍掉。结果是同样的C代码,在M3上除法是一条指令,在M0上是一段几百字节的软件库函数。 如果你是一个嵌入式程序员,选M0还是M3,不只是在选性能,也是在选"我的除法是硬件跑还是软件模拟"。 第七站:MIPS和SuperH的旁证在ARM崛起的同时,其他RISC架构也在做类似的探索。 MIPS在1990年代后期推出了MIPS16,一套16位压缩指令子集。和Thumb-1一样,它也是两档混合,需要状态切换。后来MIPS又推出了microMIPS,改进为同流解码,不需要切换状态。但MIPS16砍掉了部分乘除指令,完备性打了折扣。 SuperH架构从1992年的SH-1开始就是16位定长指令,后来SH-2A演进为16/32位混合。SuperH的设计比ARM Thumb更早证明了一件事:16位指令可以在RISC上高效运行。ARM的Thumb设计团队当时确实研究过SuperH,这不是秘密。 PowerPC的VLE(Variable-Length Encoding)扩展也是类似的思路,在嵌入式PowerPC上用16/32位混合来压缩代码。 这些架构共同指向一个结论:RISC做压缩指令是刚需,但怎么做、切不切状态、保不保完备性,各家答案不同。 第八站:RISC-V的重新思考2010年,加州大学伯克利分校的Krste Asanović和David Patterson团队开始设计第五代RISC指令集,也就是后来的RISC-V。 Patterson是RISC架构的奠基人之一,他亲身经历了RISC-I的设计,也看着ARM、MIPS、x86在历史中反复纠结。RISC-V的设计从第一天起就带着这些历史教训。 第一条教训:指令长度要有弹性,但不要变成x86那种无底洞。RISC-V的基础指令RV32I是32位定长,但指令编码的前两位被预留为长度标识。如果是00、01、10,说明这是一条16位压缩指令;如果是11,说明是32位标准指令。硬件解码器看前两位就知道取几个字节,不需要逐字节解析前缀。 第二条教训:压缩指令不要破坏主线ISA的正交性。Thumb-1退回双操作数、切状态、限制寄存器,这些在RISC-V的C扩展里全部避免。C扩展里的压缩指令,底层仍然是三操作数语义。比如c.add rd, rs实际上被解码为add rd, rd, rs,目的和源的关系由编译器安排,指令集本身不强制合并。 第三条教训:完备性应该模块化。RISC-V把整数运算放在I扩展里,乘法除法放在M扩展里。I是强制的,M是可选的。厂商可以根据成本和需求选择是否实现M。如果实现了M,就是32位完备的;如果没实现,就是不完备的。这个选择是透明的,编译器知道,程序员知道,不会有"同一颗芯片的不同型号行为不同"的混乱。 从C语言完备性的角度看,RV32IM是32位完备的,加减乘除取余全有硬件指令。RV32I或RV32IC没有M扩展,缺乘除,是不完备的。这和Cortex-M0缺除法是同一类问题,但RISC-V把它明确标注在ISA字符串里,你看到RV32IMC就知道它是完备的,看到RV32IC就知道它不完备。 第九站:CH32V003,RISC-V不完备的典型样本2021年,南京沁恒微电子推出了CH32V003单片机,售价不到一元人民币。它使用的是自研的QingKe V2A内核,指令集字符串是RV32EC。 E扩展意味着只有16个通用寄存器(标准RV32I是32个),C扩展意味着支持16位压缩指令。但没有M扩展,没有硬件乘法器,没有硬件除法器。 写一句C语言的a = b * c,编译器会生成一段软件乘法例程。写一句a = b / c,同样走软件模拟。它的完备性和Cortex-M0一样,是32位不完备的。 但CH32V003的意义不在于完备性,而在于成本。一颗不到一块钱的32位单片机,能跑RISC-V指令集,有DMA、有ADC、有定时器,代码可以用标准GCC编译。它用不完备换来了极低的价格,在遥控器、小家电、玩具这类对算力要求不高的场景里完全够用。 这恰恰是嵌入式世界的真实逻辑:不是所有应用都需要除法。如果你的代码里从不出现除法运算,RV32EC的不完备对你没有任何影响。指令集的完备性是一个上限,而实际项目往往不需要触达上限。 第十站:一条指令的三个维度,贯穿40年的取舍回到我们开头提出的三个维度。 机器指令的长度,从8051的变长1到3字节,到ARM的定长4字节,到Thumb的2字节,到RISC-V的2/4字节混合。这个演进的主线是:CISC用变长换取表达力,RISC用定长换取解码速度,后来RISC用两档混合试图兼得两者。 操作数的个数,从8051的累加器+源(隐式双操作数),到8086的显式双操作数,到ARM的三操作数,到Thumb-1退化的双操作数,到RISC-V坚持三操作数。这个演进的主线是:双操作数省编码空间但覆盖源操作数,三操作数费空间但给编译器最大的自由度。 C语言的完备性,从8051的8位完备,到80251的16位完备32位跛脚,到ARM早期的不完备,到Cortex-M3的完备,到M0的不完备,到RISC-V的模块化完备。这个演进的主线是:完备性需要硬件代价,嵌入式芯片在成本和功能之间反复权衡。 这三条线索交织在一起,就是单片机指令集的发展史。它不是一条直线前进的进化史,而是一部在四面墙之间反复试探的妥协史。每一次"进步"都伴随着某种牺牲,每一次"简化"都隐藏着某种代价。 总结从8051到RISC-V,40年过去了。指令集从变长到定长到两档混合,操作数从双操作数到三操作数再回到压缩后的三操作数,完备性从全硬件支持到部分软件模拟再到模块化可选。 这段历史告诉我们一件事:没有完美的指令集,只有适合某个时代、某个市场、某个成本约束的指令集。8051没有死,因为它足够简单便宜。x86没有倒,因为它足够完备兼容。ARM赢了嵌入式,因为它在正确的时间用Thumb解决了代码密度问题。RISC-V正在崛起,因为它从别人的教训里学会了把选择权交给用户。 下次当你写下a = b + c然后按下编译键的时候,不妨想一想:这行C代码最终变成了几条机器指令?每条指令几个字节?几个操作数?有没有除法被编译器悄悄替换成了软件循环? 理解了这些,你就不再只是一个写代码的人,你开始理解计算机最底层的语言了。
|