本文作者是安天微电子与嵌入式安全实验室的Tbsoft博士。
现代计算机的体系结构基本都是基于冯·诺依曼体系结构,也就是“存储程序方式”,即程序指令代码(指令)和数据都放在内存中,运行时CPU从内存中逐条取指令并执行,指令还可以在必要时访问内存数据。
哈佛结构是冯·诺依曼结构的一种变形,特点是将程序(指令)存储器和数据存储器分开,即有两块独立的内存,一块存放指令,一块存放数据,但没有脱离“存储程序方式”这种基本的方式,因此仍然属于冯·诺依曼结构的变形或者改进。
对于程序员,即使是汇编语言甚至机器语言程序员,最多也只能涉及到内存指令和数据这一级,计算机暴露给程序员的,只能是冯·诺依曼结构或者哈佛结构,称为计算机体系结构(Architecture)。
但CPU本身是由运算器、控制器、寄存器等部件组成的硬件电路,内存指令被取入CPU后,在CPU内部还要经过复杂的指令译码等过程,才能驱动CPU硬件电路相应部件,按照指令具体需求进行相应动作,最终完成指令执行,这一过程是由CPU内部的取指令、指令译码、部件驱动等硬件电路完成的,不同CPU差异很大,即使指令集相同的CPU也可以有不同的电路实现方法,这种CPU内部硬件电路的结构,称为CPU的微体系结构(Microarchitecture)。
CPU微结构是设计CPU的核心,对于程序员而言,CPU微结构并不暴露给程序员,最底层的程序员的极限也只能是通过CPU指令来访问CPU,换而言之,程序员使用CPU的最小操作粒度是指令。但CPU指令在CPU内部可能还会被解析成为更小的执行单元,最终驱动CPU的单个硬件电路部件,也就是说,在CPU微结构中有更小的操作粒度,例如微操作(MicroOP,μOP)或者微指令,它们都是由CPU微结构决定的。
或者说,CPU对于程序员而言也是一个黑盒子,程序员只要按照CPU指令集,用相应的指令编写程序(如果是高级语言,则由编译器或者解释器将高级语言语句最终转换为CPU指令),并将指令程序和相应数据加载到内存(现代通常由操作系统完成)即可执行程序,程序员只访问到计算机体系结构这一级,至于指令在CPU内部是怎么进一步解析并最终执行的,那是CPU微结构决定的,对程序员不开放,程序员不关心,通常也不应该关心,实际也无法关心。
CPU执行一条指令,一般来说,最少也要经过从内存中取指令,将指令译码解析成微操作(μOP),微操作最终驱动硬件电路部件三个步骤(简称取指令、译码和执行),如果执行一条指令,要等到这三个步骤都完成后,才能执行下一条指令,则一条指令执行时间过长(用CPU硬件术语说就是消耗多个时钟周期),CPU运行速度就无法得到有效提高。
如果在第一条指令译码时,同时取第二条指令;然后等到第一条指令执行时,第二条指令同时译码,同时还取第三条指令……这样周而复始,取指令、译码和执行就可以重叠进行,就好比生产流水线上的工人,第一个工人对第一个产品加工好第一道工序后,将产品传递给第二个工人加工第二道工序,这是第一个工人可以对第二个产品加工第一道工序了……这样可以避免工人不必要的空闲等待,生产效率就会大大提高,这一提高CPU运行速度的方法,也就称为“流水线”,现代CPU均使用流水线技术。
流水线有效减少了单条指令的平均执行时间,但指令仍然是一条条顺序执行的,实际上,很多指令是彼此不相关的,例如访问两组完全不同寄存器的两条指令,它们完全可以并行执行,执行顺序的先后并不影响最终执行结果,对于这样的不相关指令,完全可以设计多个执行部件电路,直接扔到不同执行部件中去并行执行,称为乱序(Out-of-order)执行,可以进一步提高指令执行速度。乱序执行在现代CPU中广泛应用,甚至对于有相关性的指令,也可以采用寄存器换名等手段将它们变成不相关指令,从而进行乱序执行。
但流水线和乱序执行都会碰到一个问题,从原理上讲它们仅适用于纯顺序执行的指令,一旦遇到分支,即条件跳转指令,因为不执行到条件跳转指令本身,是没法知道程序转向何处执行的,也就是条件跳转指令的下一条指令在未执行前不确定,因此无法预先取得条件跳转指令的后续指令,这时流水线和乱序执行都会失效,因为它们的前提是预先取得后续指令。
为了尽量解决这个问题,现代CPU广泛使用“分支预测”手段,也就是预测条件跳转指令会跳向哪个分支,然后对这个分支进行预取后续指令。分支预测的常用策略是:如果某一段时间内某一条件跳转都走向某一固定分支,则可以预测这条条件跳转指令下一次很大可能也走向这一分支。
典型例子:程序中的循环结构一般要循环多次才结束,那么在循环结束之前,判断循环条件的条件跳转指令显然都是走向继续循环分支的。
分支预测配合流水线和乱序执行,能够大大提高CPU的运行速度,因此现代CPU微结构基本都使用这种设计。
分支预测并不能保证100%的成功预测,一旦预测失败,也就是最终执行到条件跳转指令时,发现跳转目标不是先前预测的分支方向,那么按照分支预测预取的后续指令实际上失效,这些指令已经完成的工作必须“取消”掉,否则就会造成错误的指令执行。
用一个程序员容易理解的比喻:分支预测的后续指令执行,好比一个“事务”,如果分支预测是正确的,那么“事务”可以“提交”,这些后续指令就真正起作用;如果最终执行到条件跳转指令时发现分支预测是错误的,则“事务”必须“回滚”,即使后续指令已经执行了,甚至是乱序执行了,已经完成的工作也都必须全部“撤销”,后续指令要看起来没有起任何作用,重新到正确的分支取新的指令执行。
理论上说,不管指令执行“提交”还是“回滚”,都只与CPU微结构相关,其过程程序员应该看不到,程序员只能看到宏观指令按照程序流程执行,CPU内部对程序员仍然应该是黑盒子。
目前CPU主频已经达到3GHz以上,普遍采用多核并行,尽管主内存(DDR SDRAM)的主频已经达到2GHz—3GHz甚至更高,也无法完全满足多核CPU运行速度的需求,因为指令执行还是必须从内存中取指令,如果内存访问速度不够,CPU运行速度会受到内存访问速度的限制。
为了克服这个问题,目前采用在CPU与主内存之间插入多级高速缓存(Cache)的方法,Cache是一种访问速度极高的存储器,甚至可以集成在CPU内部,成为CPU微结构的一部分。Cache与主内存之间以块为单位交换数据,块长一般为数十字节。
当CPU需要访问内存,例如从内存中取指令时,第一次需要先将相应内存块一次性读入到空闲的Cache块,CPU再直接访问Cache块,此时内存访问速度会慢一些,因为存在主内存与Cache之间传输成块数据的时间;CPU第二次访问相同块内存时,即可直接访问Cache块,而无须访问主内存,内存访问速度会快得多。
主内存—Cache系统构成现代CPU的内存储器系统,其原理与操作系统中的硬盘—内存系统构成虚拟内存的原理极其相似。
如上所述,如果分支预测失败,则分支预测预取的后续指令,哪怕已经乱序执行了多条指令,也必须“回滚”,指令在CPU微结构中已经完成的工作必须全部“撤销”。
“撤销”指令是容易的,指令最终完成的工作,无外乎是对寄存器或者内存的修改,可以暂且将修改“缓存”起来,如果“撤销”,最终不真正修改寄存器或者内存即可。
但对于内存读写指令(在CPU设计中通常称为Load/Store指令或者LD/ST指令),以读内存指令为例,如果最终“提交”,就必须读取实际的内存地址,如果相应内存块还没有被读入到Cache块,读取速度就会受到影响,因此在读内存指令最终“提交”或者“回滚”之前,CPU微结构一般会事先将Cache块准备好,也就是如果相应内存块还没有被读入到Cache块则预先读入。
也就是说,即使分支预测失败,已经乱序执行的多条预取指令中只要有读内存指令,就算最后被“回滚”,对广义的CPU微结构还是有影响的——相应内存块已经读入到了Cache块。
而内存块是否已经读入到Cache块,访问速度是有一定差异的,这相当于“回滚”的读内存指令在CPU微结构中留下的“痕迹”,这个“痕迹”是可以被作为“侧信道”利用的。
操作系统的内核数据是受到CPU微结构保护的,用户模式的应用程序无法访问,如果访问是要引发CPU错误异常的。
构造一个分支,先检测读取内存的地址是否合法,合法就读取相应地址内存字节,然后根据内存字节的值,让内存字节的值与映射到不同Cache块的内存块对应起来,再故意读取一下映射到不同Cache块的内存块;如果访问内存的地址非法,例如操作系统内核数据地址,直接不读取。
显然,这样的分支,无论读取合法地址还是非法地址都是不会出错的。
用大循环执行多次这个分支,前若干次,读取内存地址都是合法的,“训练”CPU的分支预测,让CPU微结构认为下次也应该走向读取内存这一分支。
然后,突然执行一次非法的操作系统内核数据地址读取。
按道理说,读取内存地址非法,应该走向不读取这一分支,可是CPU的分支预测已经被“训练”成了“条件反射”,CPU稀里糊涂地预取了读取非法地址的指令,并乱序并行执行,只是暂时没有“提交”,因为没有“提交”,即使读取非法地址内存,也不会引发CPU异常;而且此时根据非法地址内存字节的值,故意读取映射到不同Cache块内存块的指令,相应的Cache块也被CPU微结构准备好了(因为是并行乱序执行嘛),也就是相应内存块已经读入到了Cache块。
当然,最后CPU发现这次分支预测错了,没关系,预取的指令乱序并行执行“回滚”,读取非法地址的指令根本没执行,没有引发CPU异常,皆大欢喜。
可是,“痕迹”却悄悄留下了,与非法地址内存字节值有意对应起来的内存块已经读入到了Cache块。
用程序遍历一下所有可能对应的内存块,看谁访问速度最快,谁就很可能在Cache中,而非法地址对应的操作系统内核数据字节值是有意与内存块对应的,原本在用户模式下不能访问的操作系统内核数据字节值就被推算出来。
这就是所谓的“侧信道泄漏”。
CPU微结构内部信息通过侧信道向宏观计算机体系结构的泄漏。
设计CPU追求速度快是理所当然的,但速度和安全性之间要有平衡点,微结构无论怎样追求高速优化,屁股要擦干净,不要向宏观体系结构泄漏内部信息。
1965年intel创世人之一、时任仙童半导体公司电子工程师的戈登摩尔提出了摩尔定律,对人类的计算之路的快速进步做出了预言。过去二十年,人类在互联网的带动下,信息化发展一路狂奔。这种对速度的追求一定程度上,透支的是安全的掉队。也许这正是一个重新定义平衡点的时刻。