CPU眼里的:缓存 | cache

B站影视 港台电影 2025-03-14 07:00 1

摘要:众所周知,在CPU主频达到4GHZ后,由于技术上的种种限制,CPU主频就很难有更大突破了。市面上可以在正常使用的情况下,突破5GHZ频率的CPU着实不多。

“cache是现代CPU的性能利器,也是致命弱点”

01

提出问题

众所周知,在CPU主频达到4GHZ后,由于技术上的种种限制,CPU主频就很难有更大突破了。市面上可以在正常使用的情况下,突破5GHZ频率的CPU着实不多。

为了在CPU主频不变的情况下,继续提高CPU的执行效率,大家开始把注意力集中在缓存上。因为很多时候,CPU的运行效率,往往被缓慢的外部内存读、写拖了后腿,而缓存由于集成在CPU内部,拥有更高的读、写效率,所以,有时候增加缓存的空间,比提升CPU的主频都行之有效。

当然上面的知识,问百度或ChatGPT就可以了,没有必要看这本文,所以,我们的任务是证明CPU缓存的真实性和一些副作用。

02

思想实验

阿布没有办法剥开一个现代CPU,让大家亲眼目睹CPU缓存的工作细节。所以,我们只能设计一个思想实验,间接证明缓存的存在。

思想实验是这样的,这是一个8字节无符号类型的数组data,这里,你也可以简单认为它是一个字符型的数组,数组里面保存着一个字符串“password”, 普通情况下,我们只需要通过读操作,就可以读取数组的内容:

uint8_t data = "password";uint8_t value = data[0];

例如,通过读取data[0]的值,就可以得到第0个数组元素的值:112,也就是字符p对应的ASCII码。其对应的CPU操作,往往是一条mov指令,它会从data[0]所在的内存中,读取一个字节的数据,并存入到变量value里面。

但我们的挑战不是通过这种常规操作(uint8_t value = data[0];)来直接读取data[0]里面的数据,而是通过CPU缓存的原理,“猜”出data[0]里面的数据。

由于data[0]只有一个字节,所以,它的值一共有256种可能,也就是0至255中的任意一个值。

如何猜出到底是这256个数值中的哪个一个呢?我们的办法是这样的。在一个干净的计算机系统中,开始这个思想实验,如图所示:

此时的CPU是干净的,没有缓存任何内存数据,随后,我们构建256个内存页,每个内存页的大小是4KB。

接着,我们让CPU读出data[0]的值,但不要输出它的结果,这样,此时只有上帝和CPU知道data[0]的值是多少。

所以,我们拜托上帝,以data[0]的数值为编号,把对应编号的内存页加载到CPU缓存里面,因为上帝知道data[0]的数值是:112,也就是字符p对应的ASCII码,所以,上帝就把第112号的内存页加载到缓存里面。如图所示:

好了,现在轮到我们干活了,我们知道有一个内存页,已经被加载到缓存里面了,但不知道具体是哪一个编号的内存页,如果我们能猜出缓存中的内存页编号,也就知道data[0]的值了。

猜测的方法很简单,我们把每个内存页,都读一遍,并作一下计时,显然由于存储位置的差异,在读取其他编号的内存页时,由于需要在物理内存中读取,所以用时会比较多;唯独在读取第112号的内存页时,因为是从CPU缓存中读取,所以用时会明显减少。如图所示:

这样,我就可以确定data[0]的数值是:112。

03

代码分析

好了,假设上面的逻辑没有问题,我们就可以用代码实现上面的思想实验了。让我们一起浏览一下代码,首先我们看看计算每个内存页访问时间的函数:

int get_access_time(uint8_t* page){ unsigned long long tick1, tick2; unsigned int aux; tick1 = __rdtscp(&aux); uint8_t tmp = *page; tick2 = __rdtscp(&aux); return (tick2 - tick1);}

这个函数(get_access_time)比较简单,其核心代码就是访问某个内存页的第一个字节,并在访问代码的前后,记录一下CPU的时钟心跳数,tick1和tick2;二者之差就是访问该内存页所需的时间,时间单位是CPU的时钟心跳数。

接着我们就可以进入这次实验的主函数了:

uint8_t data = "password";int main{ printf("The data: "); for (int i = 0; i

主函数也非常简单,我们依次输入数组data中每个元素的内存地址,然后调用detect_memory函数,来检测指定内存地址上的值,如果一切顺利的话,我们应该可以依次打印出数组data的值:password

最后就是最关键的detect_memory函数了,它会利用CPU缓存的特性,来“猜出”指定内存地址上存储的数值:

uint8_t detect_memory(uint8_t* address){ static uint8_t mem_pages[MAX_VALUE][PAGE_SIZE]; for (int i = 0; i

为了代码的紧凑性,我们就地定义256个静态内存页,每个内存页的大小都是4KB,其实它们就是一块连续的内存,为了便于索引,我们使用了二维数组的代码形式。

然后,通过x86 CPU专门的API(_mm_flush),把这些内存页都从CPU缓存中清理出去,以此确保此时我们的CPU缓存是“干净”的。

接着我们就可以把一个内存页加载到CPU缓存了,由于此前的_mm_cflush操作,所有的内存页此时都还在物理内存上。这样,在我们读取一个内存页的第一个字节(读到:temp)时,CPU就会自动把整个4KB的内存页,从物理内存加载到CPU缓存中。

至于CPU具体加载了哪个内存页,取决于输入的内存地址(address参数)上存储的数值。如代码所示,这个index的值是多少,就加载第几号内存页。

最后,我们就可以通过测试每一个内存页的读取时间,来确定是第几号内存页被加载到了缓存里面,方法也很简单,我们通过调用刚才的内存页访问函数(get_access_time),依次检测每个内存页的读取时间,一旦读取时间足够短,低于某个阈值(根据经验决定阈值),我们就认为这个内存页是在CPU缓存里面。而该内存页的编号,就等于输入内存地址(address)上,存储的数值。

需要注意的是:为了防止在运行函数get_access_time中,加载了过多的连续内存页到CPU缓存中,从而影响后面内存页的计时准确性,我们要刻意的打乱一下内存页的检测顺序(mix_i = ((i * 167) + 13) & 255;)。

好了,让我们在Visual Studio的命令行下,编译一下代码,再运行一下,不出意外的话,我们就可以顺利“猜”出了数组data的数据信息。

至此整个实验完成,如果还意犹未尽,也可以自己动手,亲手测试一下自己的CPU缓存,完整的代码和编译方法在这里:https://github.com/idea4good/AbuCoding/blob/main/source/cache.c欢迎大家下载。

需要注意的是,针对不同的操作系统和硬件,代码可能要做作一些调整。

04

总结

1. 我们任何一次读写内存的操作,CPU都可能把内存数据从外部内存,加载到CPU缓存。根据局部性原理,可以以此提高未来读、写在同一块内存区域时的读、写效率。从而提高系统的整体性能,并降低功耗。

2. 缓存会占用CPU宝贵的芯片面积,增加CPU的成本,所以缓存的容量往往是有限的。因此缓存无法存储程序所需的全部数据和指令,可能导致缓存未命中,此时,CPU需要从外部内存中加载数据,系统性能也会随之降低。

3. 缓存会显著增加CPU的设计难度,特别是多个CPU核心分享缓存的时候。此时,缓存一致性就是一个挑战,需要用额外的硬件或者软件算法来确保数据的一致性,增加了设计复杂度和成本。

4. 由于缓存的原因,会大大提高CPU的访问效率,显著降低CPU访问内存时的时间,这让黑客可以通过计算内存的访问时间,确定该内存数据所在的位置(在外部内存条上,还是在CPU缓存里面);如果配合CPU的分支预测机制,稍微修改一下代码,我们就可以重现当年震惊计算机行业的:spectre和meltdown的场景。

05

来源:阿布编程

相关推荐