当CPU根据程序要求需要读取某个地址上的数据时,首先会在L1 Cache中查找。为了适应CPU的速度,L1缓存实现为Virtually indexed physically tagged「VIPT」的形式,即用虚拟地址即可直接读取该虚拟地址对应的物理地址的内容,而不再需要多加一道转换的工序。
如果L1 Cache miss,则会在下级缓存中查找。但越过L1 Cache之后,对L2$和L3$的速度要求就不再这么严苛。此时CPU core给出的虚拟地址请求会先通过TLB转换为物理地址,再送入下级缓存中查找。而检查进程有没有权限读取某一地址这一过程,仅在地址转换的时候发生,而这种转换和检查是需要时间的,所以有意地安排在了L1 Cache之后。
L1缓存这种必须求“快”的特性,成了整个事件的楔子。 分支预测
分支预测是一种提高流水线执行效率的手段。在遇到if..else..这种程序执行的分支时,可以通过以往的历史记录判断哪一分支是最可能被执行的分支,并在分支判断条件真正返回判断结果之前提前执行分支的代码。详情可以在上面提到的连载文章中阅读。
需要强调的是,提前执行的分支代码,即便事后证明不是正确的分支,其执行过程中所读取的数据也可以进入L1缓存。在Intel的官网文档《Intel® 64 and IA-32 Architectures Optimization Reference Manual》第2.3.5.2节中指:
L1 DCache Loads:– Be carried out speculatively, before preceding branches are resolved.– Take cache misses out of order and in an overlapped manner.Show you the [伪] code:
if (likely(A < B)) { value = *(kernel_address_pointer);}当分支判断条件A < B被预测为真时,CPU会去提前执行对内核地址的读取。当实际条件为A > B时,虽然内核的值不会真正写入寄存器(没有retire),但会存入L1 Cache,再加之上一节介绍的,获取L1 Cache的值毋须地址转换,毋须权限检查,这就为内核信息的泄漏创造了可能。
从理论上来讲,如果可以控制程序的分支判断,并且可以获取L1缓存中的数据(这个没有直接方法,但可以通过其他间接手法)的话,就完全可以获取内核信息。而分支预测这种特性是不能随随便便就关闭的,这也就是这次问题会如此棘手的原因。 乱序执行
还有一个原因是乱序执行,但原理大致类似。乱序执行是Intel在1995年首次引入Pentium Pro处理器的机制。其过程首先是将我们在汇编代码中看到的指令“打散”,成为更细粒度的微指令「micro-operations」,更小的指令粒度将会带来更多的乱序排列的组合,CPU真正执行的是这些微指令。
没有数据依赖的微指令在有相应执行资源的情况下乱序并行执行,进而提升程序的并行程度,提高程序性能。但引入的问题是,读取内核数据的微指令可能会在流水线发出exception之前将内核数据写入L1 Cache。与分支选择一样,为通过用户态进程获取内核代码提供了可能。
限于篇幅,更详细的内容读者可以在国外安全团队发布的消息中获取。 后续
刚刚查阅之前连载中的一些细节的时候,看到在“流水线”那一章里写过这样一段话:
在面对问题的时候,人总是会倾向于引入一个更复杂的机制来解决问题,多级流水线就是一个例子。复杂可以反映出技术的改良,但“复杂”本身就是一个新的问题。这也许就是矛盾永远不会消失,技术也不会停止进步的原因。但“为学日益,为道日损”,愈发复杂的机制总会在某个时机之下发生大破大立,但可能现在时机还没有到来
很难讲现在是不是就是所谓的那个“时机”。虽然对整个行业都产生了负面影响,但我对此仍保持乐观。因为这就是事物自然发展的一个正常过程。性能损失并不是一件坏事,尤其是对牙膏厂的用户来说。