组原学到Cache这一章,很多人的第一道坎就是地址映射。我当年翻教材,看到直接映射、全相联映射、组相联映射这三个名词,第一反应就是背结论:直接映射简单但冲突多,全相联命中率最高但硬件贵,组相联折中。可一旦做题,发现地址字段怎么切、标记位多少位、索引到底取哪几位,该错的还是错。直到后来自己动手把结构图画出来、按位算了一遍,才真正明白这三种映射方式背后的取舍逻辑。这篇笔记就把Cache主存地址映射这件事从头讲透,适合正在复习计算机组成原理、准备期末考或考研的同学,也适合想真正理解CPU里Cache工作原理的初学者。
1. 先说清楚:Cache主存地址映射到底在解决什么问题
先建立一个基本认知:主存和Cache之间的数据交换不是按字节进行的,而是按“块”进行的。主存被划分成一个个大小相同的块(block),Cache也被划分成同样大小的行(line),有些教材叫槽(slot)或组(set),本质上是一个意思。CPU要访问某个主存地址时,不能直接把那个字节搬到Cache里,而是把包含该地址的整个块都搬过来,这就是利用了空间局部性。
问题来了:主存容量远远大于Cache容量。一个4MB的主存,配一个4KB的Cache,主存被划分成几万个块,Cache只有几十到几百行。那么多主存块都有可能进入Cache,到底哪个块能放到Cache的哪一行?如果没有一个确定的规则,CPU访问Cache时就会无所适从——不知道去哪个位置找数据,也不知道新块来了该覆盖哪一行。
地址映射解决的就是这个问题:给每个主存块规定一个“可以放”的Cache位置集合。三种映射方式,实际上是给了三套不同的放置规则:
- 直接映射:每个主存块只能放到唯一一个Cache行。
- 全相联映射:每个主存块可以放到任意一个Cache行。
- 组相联映射:先把Cache行分组,每个主存块能放到某个组里的任意一行。
你可能会觉得,这不就是“位置是固定还是自由”的问题吗?对,本质上就是。但每套规则对应的地址结构、硬件成本和命中率完全不同。后面三节分别拆开讲。
理解映射的关键不在于背下“谁可以放哪”,而是要看懂一个问题:CPU拿到一个地址,怎么快速判断这个地址对应的块在不在Cache里,以及在的话具体在哪一行。这才是映射的真正考点。
2. 直接映射:规则最简单,硬件最省,但别忽视它的冲突
2.1 直接映射的规则与地址结构
直接映射的规则一句话:主存块号对Cache行数取模,得到的余数就是它唯一能放的Cache行号。
假设Cache有8行,主存的第64块,64 mod 8 = 0,所以它只能进Cache第0行。第72块,72 mod 8 = 0,也只能进第0行。这样一来,Cache每一行会对应多个主存块,这些主存块的块号对Cache行数取模后余数相同。但因为Cache一行只能放一块,所以同一时刻,这一行只能存放其中一个块,其他“同余”的块只能等它被替换出去再进来。
因为是按2的幂设计的,取模运算在硬件上其实就是取块号的低若干位。假设Cache有8行,块号低3位就是行号。从主存地址角度看,地址被切成了三部分:
| 标记 | 索引(行号) | 块内地址 |索引位就是Cache行号的编码,块内地址是块内偏移,标记位则是剩下的高位,用来区分“映射到同一行”的多个主存块。CPU顺着索引找到Cache行后,不能直接认为数据是想要的,还要把地址里的标记和这一行里存的标记做比较,相等才算命中。
2.2 直接映射的命中判断流程
直接映射的访问过程很好描述:
- 根据块内地址位数b,从主存地址中切出块内偏移。
- 紧接着的l位是索引,它直接指定了Cache行号。
- 剩下的高位是标记。
- CPU用索引找到那一行,先看有效位是否为1。
- 有效位为1,再比较标记:一致则命中,从该行按块内偏移读取数据;不一致则未命中。
注意,有效位这一步很多人容易忽略。Cache刚上电时全部是空的,每一行的有效位都是0。这时候即使标记碰巧相等,也不能算命中。有效位为0意味着这一行里根本没有有效数据。
直接映射最大的优势在于硬件简单。索引直接确定了唯一一行,CPU只需要一个比较器,比较一个标记位就行,所以访问延迟很低。但它的问题也很明显:冲突率高。如果程序里有两个频繁访问的数据块恰好映射到Cache同一行,它们就会反复把对方挤出去。每一次切换都触发一次未命中,导致Cache原本该有的加速效果大幅下降。这种现象有时候被形象地称为“抖动”。
2.3 直接映射的“硬伤”在哪里
我当年学直接映射时觉得没什么不好,后来看了一个对比才明白它的局限:Cache命中率不仅取决于数据是否被访问过,还取决于访问地址的分布。直接映射在地址分布上做了非常强的人为限制,很多明明可以缓存的数据,因为“位置冲突”被淘汰掉了。哪怕Cache里还有别的空行,也不能用,因为规则不允许把块放到其他行。
举例来说,Cache有8行,程序交替访问主存块64和72。这两个块都映射到第0行,每次切换都要重读主存,Cache命中率可能降到接近0。但程序访问的主存块其实只有两个,任何稍微宽松一点的映射规则都能让它们共存。所以直接映射的优点是便宜和快,代价是命中率受地址低位分布影响太大。现代CPU里已经很少用单纯的直接映射,但组相联的路数设为1时,退化的就是它。
3. 全相联映射:最自由的放置,最昂贵的比较
3.1 全相联映射的规则与地址结构
全相联映射的规则更简单:主存中的任何一块,可以放到Cache中的任何一行。没有“必须去哪个位置”的限制,新块调入时,只要Cache里有空行,就随便挑一个空行放进去;如果全满了,就按替换算法选一行覆盖。
因为位置完全自由,地址结构里就没有“索引”这个字段了,只剩下两部分:
| 标记 | 块内地址 |这就意味着,标记位要完整保存主存块号。CPU拿到一个地址,想判断某块在不在Cache,必须拿着这个完整的块号,去和Cache每一行的标记比较。
3.2 全相联如何做命中判断
全相联命中判断的特殊之处在于“并行比较”。CPU无法按索引定位到某一行,因为它不知道数据在哪个位置,只能一次性把Cache所有行的标记都拿出来和当前地址的标记比对。只要有一行标记相等且有效位为1,就是命中,数据就从那一行读出来。
这个“所有行同时比较”的操作,在硬件上意味着每行都要配置一个比较器,还要有相联查找的逻辑。如果Cache有64行,就需要64个比较器同时工作。行数一旦上几百上千,比较器的面积、功耗、延迟都会爆炸式增长。
这就是全相联没法成为通用Cache方案的根本原因。并不是它效果不好,而是硬件代价太高。它的命中率理论上是最高的,因为没有结构性冲突,同一个主存块只要还在Cache里,就不可能因为“位置被别的主存块占了”而被挤走。但全相联需要一个很大的内容和地址关联的查找表,这在工程上很难做到大容量。
3.3 全相联适合什么样的场景
全相联没有被大容量Cache采用,却在一些小而关键的结构里扎根。最典型的就是快表TLB,也就是用来加速虚拟地址到物理地址转换的页表缓存。TLB的条目通常只有几十到上百项,这个规模做全相联完全可行,而且能最大化命中率,对性能提升非常明显。
还有一个理解角度:全相联比直接映射多付出的成本,主要体现在“比较器数量”上。直接映射一个比较器,全相联每行一个比较器。如果Cache有65536行,全相联就要65536个比较器,这在成本和访问延迟上都是不可接受的。所以全相联适合“规模小、要求高命中率”的结构,不适合做容量大的数据Cache。
4. 组相联映射:折中方案里的智慧
4.1 组相联的规则与地址结构
组相联是实际CPU采用的主流方案,它的思路很巧妙:先把Cache行分成若干组,每组包含n行,这就是n路组相联。主存块可以放置到“某个组”里的任意一行。组的选择仍然按取模规则,但取模的对象不是Cache总行数,而是组数。
假设Cache有64行,采用4路组相联。那么就分成64/4 = 16组,每组4行。主存块号对16取模,得到组号。块一旦确定落在某组,组内4行随便选。这样一来,地址结构变成:
| 标记 | 组索引 | 块内地址 |组索引对应组号,标记区分映射到同一组的不同主存块。直接映射可以看作1路组相联,全相联可以看作只有1组、组内行数等于Cache总行数的组相联。这个统一视角特别有用,做题时判断特点会快很多。
4.2 组相联如何平衡成本和命中率
组相联的硬件开销取决于路数,也就是每组的行数,而不是Cache总行数。对于4路组相联,CPU需要并行比较4个标记,只需要4个比较器。和全相联动辄几十上百个比较器相比,成本可控得多。同时,由于每组内有多行缓冲,访问地址刚好映射到同一组的多个块可以同时共存,冲突率比直接映射大幅下降。
路数是组相联的核心参数。路数越高,每组能缓冲的块越多,命中率越好,但比较器个数和替换算法的复杂度也会上升。实际工程中,常见的路数是2路到16路。有研究显示,当路数超过8路以后,再提高路数对命中率的改善就非常有限了,但硬件成本和功耗还在明显增加。所以主流CPU的Cache大多选8路左右,就是在这个权衡点上做的取舍。
4.3 为什么说组相联是“真实世界”的选择
大家现在用的处理器,一级数据Cache、指令Cache以及二级三级Cache,几乎全都是组相联。原因不复杂:直接映射硬件最简单,但冲突太伤命中率;全相联命中率最优,但大容量下没法实现。组相联恰好站在两者中间,用很小的硬件代价规避了大部分冲突,同时保留了较快的访问速度。
这里有一个容易误解的概念:组相联并不是“3路都很平庸”,它是在关键路径上做的最优折中。Cache访问是处理器流水线里非常关键的一环,每一拍都要能给出结果。如果因为比较器太多导致访问延迟变长,会拖累整个CPU的频率。所以宁可牺牲一点理论命中率,也不能让关键路径变慢。
5. 地址位宽计算:用一个例子从头算到尾
5.1 从已知参数推出字段位数(三步走)
计算Cache地址字段位宽,其实只要三步:先定块内地址位数,再定索引或组号位数,最后用主存地址总位数减去前两者得到标记位数。
下面用一个完整例子说明。设主存地址为32位,Cache容量4KB,块大小64B,采用4路组相联。
第一步,块内地址位数b = log2(64) = 6位。
第二步,Cache总行数 = Cache容量 / 块大小 = 4KB / 64B = 64行。4路组相联下,组数 = 64 / 4 = 16组,组索引位数 g = log2(16) = 4位。
第三步,标记位数 = 32 - 4 - 6 = 22位。
所以这个Cache访问一个地址时,从高到低依次是22位标记、4位组索引、6位块内地址。
5.2 三种映射方式的位宽对比
如果把同样的Cache容量、同样块大小,分别套到三种映射下,字段位宽差别就很直观:
| 映射方式 | 索引位数 | 标记位数 | 硬件比较器数量 |
|---|---|---|---|
| 直接映射 | 6位 | 20位 | 1个 |
| 全相联映射 | 0位 | 26位 | 64个 |
| 4路组相联 | 4位 | 22位 | 4个 |
注意标记位数的变化:直接映射标记最短,因为索引占有6位;全相联没有索引,标记最长;组相联居中。这说明一个现象:同样的Cache数据容量,不同的映射方式会导致标记存储开销不同,Cache总容量也就不同。题目问“Cache总容量”的时候,不能只算4KB数据区,还要把标记阵列算进去。
5.3 标记阵列的开销计算
标记阵列是Cache里除了数据区之外用来存标记、有效位等信息的存储区。看上面的4路组相联例子,Cache共有64行,22位标记加1位有效位,每行23位,标记区总容量 = 64 × 23 = 1472位,折合184字节。数据区是4KB,标记开销约占数据区的4.5%。如果题目还考虑了脏位(写回法时需要),每行再加1位,就是64 × 24 = 1536位。
三种映射方式下,标记区大小分别是:直接映射64×(20+1)=1344位,全相联64×(26+1)=1728位,组相联64×(22+1)=1472位。全相联的标记开销最大,但这还不是重点,它最大的问题在于64个比较器。
另外要提醒一个教材差异:有的教材说“Cache容量”仅指数据区容量,有的教材用“Cache总容量”指数据区加标记区。考试时仔细看题干措辞,别被口径坑了。
5.4 手工拆解一个真实十六进制地址
理论算完了,来一个实际地址拆解练习。假设主存地址 = 0x00008F20,仍然是上面那个4路组相联Cache(块大小64B,4KB Cache,组数16)。
块内偏移:取地址低6位。0x00008F20的低6位对应的值是0x20,也就是十进制32。这个字节在块内的偏移是32。
组索引:先算块号 = 0x00008F20 >> 6 = 0x23C。组索引是块号的低4位,0x23C按十六进制看就是低一位C,即十进制12。说明这个地址落在第12组。
标记:剩下的高位就是0x00008F20 >> 10 = 0x23,展开成22位写作0x000023。
校验一下:0x23左移10位,加上12左移6位,加上32,正好等于0x00008F20。说明拆解正确。
如果是直接映射,索引就是块号0x23C的低6位,0x3C,即十进制60;标记则变成0x000008。如果是全相联,没有索引,标记就是完整块号0x23C。同一个地址,在三种方式下切法完全不同,这个对比很值得亲手算一遍,比背结论有用得多。
6. 把一次Cache访问全过程走一遍(从CPU视角)
6.1 命中判断的标准流程
前面讲地址结构时已经提到命中判断的过程,这里串起来完整走一遍。假设CPU要读取某个主存地址,Cache采用组相联。
第一步,CPU把目标地址交给Cache控制器。控制器先根据块大小切出块内偏移地址,再由组号字段定位到Cache的某个组。
第二步,检查组内每一行的有效位。这个动作是并行的,几路就同时看几行。如果有效位全是0,那直接判定未命中,不需要比较标记。
第三步,有有效行为1的行,再将该行存储的标记与地址的标记比较。只要任意一路相等,就命中。命中的行通过偏移量选中对应字节,返回给CPU,整个过程在一个时钟周期左右完成,这也是Cache能跟上CPU节奏的原因。
如果比较全都不等,那就是未命中。未命中意味着CPU这次访问必须到主存去取,代价要高得多。
6.2 未命中时发生了什么
未命中后,系统会做几件事:首先从主存读出目标地址所在的整个块,这个块大小就是块大小。Cache控制器根据映射规则确定这个块该放到哪个位置,组相联就是放到对应组,然后选择一个具体行。
选具体行时,如果组内已经有空行,优先用空行;没有空行,就要按替换策略选一行覆盖。覆盖前有个细节:如果这行数据被修改过(脏位为1),写回式Cache需要先把旧数据写回主存,再装新块,这会多花很多时间。写直达式Cache则不需要处理脏位,因为数据已经同步到主存了。
装块完成后,更新该行的标记、有效位和脏位,然后CPU重新访问Cache,这次必然命中。所以一次未命中带来的开销,是主存块传输时间加上可能的写回时间,远比正常命中长。这也是为什么冲突率高的映射方式会明显拖慢程序。
6.3 替换算法:LRU和它的朋友们
组相联和全相联都面临一个共同问题:新块要占用Cache里的一个位置,但位置满了,选谁“让位”?这就是替换算法。
最常用的是LRU,也就是最近最少使用。它的思路是:把组内各行的访问时间记录下来,新块调入时淘汰最久没被访问的那一路。LRU在局部性明显的程序里效果很好,但需要维护访问顺序信息,路数太高时实现成本会上升。
FIFO是先进先出,谁先进来就优先淘汰谁。实现简单,但可能淘汰掉正在频繁使用的块,命中率不如LRU。
随机替换最简单,随便选一个位置覆盖。某些场景下性能反而不错,因为它不会像FIFO那样系统性踩中热点。实际CPU的Cache通常会做近似LRU,比如用“伪LRU”树状结构,用少量状态位近似记录访问历史,既控制硬件开销,又保留LRU的大部分优势。
7. 学习与应试中常见的坑与避坑指南
7.1 块内地址为什么老算错
块内地址位数就是log2(块大小字节数)。块大小64B就是6位,128B就是7位。这个本身不难,但题目如果给“块大小 = 16字”而不是字节,就要小心了。需要先判断一个字是几字节,再换算成字节数。比如一个字4字节,16字就是64B,偏移还是6位,但中间多一步换算,很多人就在这丢分。
还有一个坑:块大小和Cache行大小是一回事。有的题目一会儿叫“行”,一会儿叫“块”,一会儿说“槽”,不要被名字干扰,都是同一个粒度。
7.2 标记、索引、组号别搞混
直接映射对应的是Cache行索引,组相联对应的是组索引,两者本质不同。行数是由Cache容量除以块大小得到的,组数则还要再除以路数。算索引位数时,先确认题目给的Cache参数是“行数”还是“组数”,还是“路数”。常见错误是拿到4路组相联,直接把Cache行数取log当索引位,忘了先除以路数再取log。
标记位数的计算也不要想当然。正确关系是:标记位数 = 主存地址总位数 - 索引/组号位数 - 块内地址位数。如果题目告诉你主存容量而非地址位数,那就先对主存容量取log得到总位数。
7.3 有效位和脏位:两个容易被忽略的细节
判断命中时,有效位必须为1。标记相等但有效位为0的情况,常见于刚上电或者该行已被设为无效,此时不能命中。考题偶尔会构造这种“标记碰巧相同”的情况来迷惑人。
脏位则是写相关。采用写回法时,Cache行被修改后和主存不一致,替换前必须写回。题目如果给了脏位,计算Cache总容量时要计入;描述替换过程时,也要说明是否触发写回。采用写直达法时,脏位可省。
7.4 一个小题检验掌握程度
最后留个小题,检查自己是不是真的掌握了。设主存地址20位,Cache容量2KB,块大小32B,采用2路组相联。请回答:
- 块内地址位数是多少?
- Cache总行数是多少?组数是多少?
- 组索引位数和标记位数分别多少?
- 如果访问一个地址,低5位块内偏移是0x1A,组索引是0x2,那么标记是多少?
参考答案:块内5位;Cache行数=2048/32=64,组数=64/2=32,组索引5位;标记=20-5-5=10位;标记其实由地址除去低10位得到,和低10位具体值是0x1A还是别的无关,需要题干给出完整地址才能算具体数值,这里只是考察字段划分逻辑。
如果这些都能快速答上来,说明地址映射的基本功已经过关。剩下的事就是多做几道混合题型,把替换算法和命中率计算也练熟,Cache这块在考试里基本就稳了。
这套东西看起来知识点密集,但内核就是一组对应关系:映射方式决定地址切法,地址切法决定硬件开销,硬件开销反过来约束设计选择。把这三者串起来理解,比死记硬背每种映射的优缺点靠谱得多。我后来再看CPU的Cache结构,也是先从这套映射原理入手的,理解了它,再去看那些路数、替换策略和各级Cache的配置,都会顺很多。