ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

用Verilog从零搭建Cache:映射、替换与状态机设计

用Verilog从零搭建Cache:映射、替换与状态机设计 1. Cache 到底是什么我们为什么非要建立它先说一个很容易被忽略的事实我们在“数字逻辑与部件设计”这门课里讨论 Cache目的并不是学会背几个名词而是要把“存储层次”这个概念真正落地成一套能在 FPGA 或 RTL 仿真里跑起来的硬件逻辑。很多人学到这一章会觉得前面铺垫太多直接看 Cache 映射和替换策略就行了其实等到真正写 Verilog 的时候就会发现前面那些“为什么”没想清楚代码根本无从下手。Cache 解决的核心问题永远是这一个CPU 太快内存太慢。拿数字逻辑课里最常用的频率来算假设 CPU 主频 500MHz周期 2ns内存访问延迟 60ns 左右那么 CPU 发一次读请求内存要等 30 个周期才返回数据。这 30 个周期里 CPU 只能空转等待。如果程序里能有一半的访问在 Cache 里命中平均延迟立刻可以降到十几个周期以内整体性能差距是数量级的不是百分之几十的优化。但是“能在 Cache 里命中”这件事不是天上掉下来的它依赖的是程序访问的局部性原理时间局部性刚访问过的数据大概率马上还会再访问和空间局部性访问了一个地址它周边地址大概率也会被访问。Cache 就是把最近用过的数据块暂存起来让 CPU 下次访问时不用再跑一趟内存。在这一章的语境里还有一个容易被忽略的点Cache 不只是软件优化或者体系结构概念的产物它本身就是一个数字逻辑电路。标签比较、命中判断、状态机切换、替换策略、写回操作这些全部要用可综合的 RTL 代码实现。也就是说学 Cache 的同时其实是在练状态机设计、流水线思维、多路选择器与比较器的组合运用。课程标题“数字逻辑与部件设计基础”重点落在“部件设计”上说明 Cache 是作为一个可设计的部件来讲的不是纯理论。适合读这一篇的人一个是正在学数字逻辑课程、准备做 Cache 相关实验的学生另一个是想把存储层次概念工具化、准备自己写一个简化 CPU 或 SoC 的学习者。不涉及复杂的工业级 Cache 优化比如多级 Cache、预取、一致性协议这些扩展内容但会把最基本的建立过程讲透保证你看完之后能对着代码讲清楚每一行在干什么。2. 建立 Cache 前的关键决策结构选型和参数计算2.1 三个基础结构直接映射、全相联、组相联Cache 的第一个设计决策就是选映射方式。映射方式决定了“内存中的一个块能存放在 Cache 的哪些位置”。先定几个基本术语后面都会用到。地址总线宽度决定可寻址空间按字节编址时地址低 2 位或者按具体总线宽度定是块内字节偏移中间若干位是块索引用于选中 Cache 中的某一行也称为一组剩余高位是标签用于和 Cache 行中存储的标签比较判断是否命中。直接映射每个内存块只能映射到 Cache 中的唯一一个位置。比如 Cache 有 64 行那么内存块号对 64 取模就得到 Cache 行号。硬件实现最简单只需要一组标签比较器和一个多路选择器。缺点是抖动严重如果程序恰好反复访问两个映射到同一行的地址命中率会非常难看。全相联每个内存块可以放在 Cache 中的任意一行。查找时要把 Cache 所有行的标签都拿过来和地址中的标签比较一次所以需要 N 个比较器N 为 Cache 行数硬件成本最高。但替换灵活命中率理论上最好适合行数较少的 Cache比如 TLB 常用的结构。组相联折中方案。把 Cache 分成若干组每个内存块可以映射到某一组的任意一行。组内行数称为相联度。地址中的索引字段选中组组内的几路并行比较标签。这是实际 CPU 中最常见的方案L1 Cache 通常 4 路或 8 路组相联。三种方式的硬件开销差异非常明显。直接映射只有一个比较器全相联有 N 个组相联的比较器数量等于路数。在选择时不能只看命中率还要考虑 FPGA 资源的消耗。如果你在开发板上做实验查找表LUT和触发器FF的预算往往是有限的。2.2 四个核心参数的计算方法确定映射方式之后接下来要定四个参数它们之间是相互关联的不能拍脑袋随便给。Cache 总容量比如 4KB、16KB、32KB。课程实验常用 4KB 或 8KB再大容易浪费 FPGA 资源。块大小也叫行大小即一次从内存取多少个字节进来。常见的有 16B、32B、64B。块越大空间局部性利用得越好但块内偏移字段会变长标签字段会变短而且发生缺失时搬运数据的开销也更大。对于实验室系统32B 是一个平衡的选择。相联度每组多少路。1 路就是直接映射组数等于 Cache 行数行数等于路数时就是全相联。中间值 2、4、8 都可以。行数总容量除以块大小乘以相联度。比如 4KB 总容量、32B 块、4 路组相联那么行数 4096 / (32 × 4) 32 行也就是 8 组每组 4 行。参数确定之后就可以算出地址划分了。拿一个 32 位地址总线、4KB Cache、32B 块、4 路组相联的例子来算块内偏移字段32B 2^5需要 5 位bit 4:0组索引字段8 组 2^3需要 3 位bit 7:5标签字段32 - 5 - 3 24 位bit 31:8看到没有标签比数据还宽。这是组相联 Cache 的固有特点也解释了为什么 Cache 行里除了数据还要存一个 tag 数组这一步很多人第一次写代码时会漏掉。2.3 为什么字地址和字节地址的计算容易出错在写 Verilog 之前还有一件非常容易出错的事地址的单位。很多教材在描述访问过程时用“字地址”但实际存储系统按字节编址。假设数据总线宽度是 32 位一次读一个 word4 字节那么地址的低 2 位其实不参与 Cache 查找——它们表示的是当前 word 在块内的第几个 word。换句话说Cache 块索引和 tag 比较时用的地址位是从 bit 2 开始往上的bit 1:0 要么进块内偏移字段如果需要支持字节访问要么直接当作对齐位忽略。我在实验中发现至少一半的 Cache 仿真错误都出在这个地方。如果地址位算错一位结果就是看起来“有时候命中、有时候缺失”但完全没有规律特别影响排查心情。所以开局先画一张地址位分布示意图把每个字段的 bit 范围标清楚后面写代码时才不会迷路。3. 标签存储、命中判断与数据通路设计3.1 一行 Cache 里到底要存什么基础的 Cache 行结构除了数据数组以外还必须有以下几个字段有效位valid表示这一行有没有被加载过有效数据。上电复位后所有行都无效第一次访问某个组时肯定缺失。标签tag地址的高位部分用来判断当前请求是否和这一行存储的数据匹配。脏位dirty可选写回策略才需要。表示这一行是否被 CPU 写过如果被写过替换时要先把数据写回内存。数据data块大小的数据数组比如 32B 就是 8 个 word。很多教材在 Cache 行结构图里把数据画成一个大矩形但实际在 Verilog 里数据更常见的实现方式是寄存器堆reg 数组以 word 为单位存储。这也是个重要的设计细节数据数组每个元素的位宽取决于你的数据总线宽度而不是块大小。比如块大小为 32B总线宽度 32 位那数据数组就是 8 个 32-bit 字访问时用块内偏移字段的高位来选中第几个字。3.2 命中判断的逻辑实现以 4 路组相联为例命中判断流程是这样的从地址中提取组索引和标签。选中那一组里的 4 行。逐行比较该行有效位是否为 1且该行存储的标签是否和请求标签相等。只要有一行满足条件就是命中否则就是缺失。对应的 Verilog 结构并不复杂wire hit0 valid[0] (tag[0] req_tag); wire hit1 valid[1] (tag[1] req_tag); wire hit2 valid[2] (tag[2] req_tag); wire hit3 valid[3] (tag[3] req_tag); wire hit hit0 | hit1 | hit2 | hit3;如果命中下一个问题就是“读取第几路的数据”。这需要组合逻辑hit0 为 1 时选路 0 的数据hit1 为 1 时选路 1 的数据以此类推。由于同一次访问最多只有一路命中正常情况下这个选择逻辑相当于一个人畜无害的优先级编码器。读取数据时要注意数据总线为 32 位时块内偏移字段中的 bit 4:2 决定取第几个 wordbit 1:0 要么参与字节选择如果需要 byte enable要么直接忽略。在 Cache 实验里通常按 word 访问所以低 2 位不参与。3.3 替换策略的实现选型当发生缺失且该组所有路都有效时必须选一个倒霉蛋换出去。最简单的策略是随机替换硬件上只需要一个伪随机数发生器或者一个计数器随机选一行覆盖即可。但随机替换的命中率不稳定课程实验往往要求用 LRU。LRU最近最少使用要求维护每组内部 4 行如果 4 路的访问历史。常见的简单实现是用一个 2-bit 饱和计数器或者维护一个“最近使用顺序”的状态编码。以 4 路为例可以用 3-bit 或者 6-bit 状态但更常用的做法是每行维护一个访问计数器每次该行命中时计数器加 1该组其他行命中时计数器减 1或者反过来。替换时选择计数值最小的那一路。下面是一种比较直观且容易实现的状态编码方案用 5-bit 状态表示最近使用顺序但讲真实验里用一组计数器更省事。我建议直接用访问计数换行法逻辑清晰验证也容易// 假设 4 路access_cnt[i] 是 2-bit 计数器 // 每周期命中第 i 路时 access_cnt[i] 加 1其余路减 1 // 缺失替换时选择 access_cnt 最小的那一路这个方案虽然不是严格意义上的 LRU但在 4 路小 Cache 里效果和 LRU 差别不大代码量少很多。如果你追求严格 LRU可以参考体系结构教材里真值表法或树形 LRU 的实现但课程实验用计数法足够。3.4 写入策略写直达还是写回写入方式会影响 Cache 的结构复杂度尤其影响脏位的使用。写直达write-through策略下CPU 每次写命中时同时写 Cache 和内存。优点是实现简单不需要脏位Cache 和内存始终一致适合做实验验证用缺点是写带宽开销大。写回write-back策略下CPU 写命中时只写 Cache不写内存并置脏位为 1。只有这一行被替换出去时才写回内存。优点是写内存的次数大大减少性能好缺点是要处理脏位、写回时机状态机复杂度明显增加。我第一次真正“建立起”一个能用的 Cache用的就是写回策略因为实验要求设计一个 read/write 的 Cache 控制器。如果你只是验证映射和替换逻辑建议先用写直达跑通再加脏位和写回。4. 实操环节用 Verilog 搭建一个可用的 Cache 核心4.1 顶层模块划分我个人习惯把 Cache 分成三个模块来设计cache_data存放数据数组和标签数组纯存储逻辑。cache_ctrl主状态机负责处理命中、缺失、替换、写回等状态转移。cache_top顶层封装包含地址分解、命中检测、数据选择、与 CPU 主模块的握手信号。为什么不把标签和数据全揉在控制逻辑里因为状态机和存储数组的行为频率不一样强行揉在一起综合时容易产生奇怪的路径延迟。当然如果是小实验放在一起写也能跑但后期加流水或者换参数会很痛苦。4.2 关键状态机设计写回策略写回策略下Cache 控制器的状态可以设计为三个主要状态IDLE等待 CPU 请求如果来了请求就进行命中判断。REPLACE缺失且需要替换此时如果有脏数据要先进入写回流程如果没有脏数据直接从内存读数据。REFILL从内存读取新数据块并写入 Cache然后回到 IDLE 响应 CPU。这里有一个工程上常见的细节CPU 请求和 Cache 控制器之间的握手时序。如果 CPU 发出读请求时 Cache 缺失CPU 不能继续发下一个请求需要在握手信号上拉低或者用 busy/ready 信号让 CPU 等待。我在第一版实现里就犯了“缺失时照常返回数据”的错误导致总线上出现了错误数据而不自知这个问题很像常见面试题里“memory stall cycle”的硬件版本。4.3 状态机的核心代码骨架下面是一个极简但可综合的 Cache 控制器核心代码骨架假设 4KB、4 路、32B 块、写回。代码不是完整的工程但对照注释可以看到状态机如何流转localparam IDLE 2d0; localparam REPLACE 2d1; localparam REFILL 2d2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; cpu_ready 1b0; end else begin case (state) IDLE: begin if (req_valid) begin if (hit) begin // 命中按读写类型更新数据/tag/access_cnt // 写命中还要 dirty 置位写回策略 cpu_ready 1b1; end else begin // 缺失进入替换流程 cpu_ready 1b0; state REPLACE; end end end REPLACE: begin // 有脏数据就发起写回没有脏数据就直接读内存 if (has_dirty) begin mem_write_req 1b1; // 等待内存写完成 if (mem_write_done) begin mem_write_req 1b0; state REFILL; end end else begin state REFILL; end end REFILL: begin // 向内存发起读请求等待数据返回 mem_read_req 1b1; if (mem_read_done) begin mem_read_req 1b0; // 更新数据、tag、valid、dirty0 cpu_ready 1b1; state IDLE; end end endcase end end几个细节要特别说明REFILL 状态下如果用的是 32-bit 数据总线而块大小是 32B那么一个块需要 8 次突发读才能填满。这种情况下要加一个 word 计数器每次 mem_read_done 后把返回的 word 写入数据数组对应的位置。这个“逐 word 填充”的过程是很多新手写缺失处理时最容易翻车的地方。写回时类似需要把脏行数据逐 word 写回内存也要一个 word 计数器。cpu_ready 信号要保证拉低期间 CPU 不会发起新请求。如果你在设计 CPU 主模块需要实现一个类似“等待应答”的机制不能拍脑袋让 CPU 一直占用总线。4.4 参数化设计用 define 还是 parameter写 Cache 代码时我强烈建议一开始就用 parameter 把所有关键参数定义好不要写死。尤其在调试时你会发现“把 Cache 从 4 路改成 2 路”是特别高频的操作如果代码里硬编码了位宽和索引范围每改一次都要动很多位置。parameter CACHE_SIZE 4096; // 4KB parameter BLOCK_SIZE 32; // 32B parameter ASSOC 4; // 4-way parameter ADDR_WIDTH 32; localparam OFFSET_WIDTH $clog2(BLOCK_SIZE); localparam LINE_COUNT CACHE_SIZE / (BLOCK_SIZE * ASSOC); localparam INDEX_WIDTH $clog2(LINE_COUNT); localparam TAG_WIDTH ADDR_WIDTH - OFFSET_WIDTH - INDEX_WIDTH;$clog2是 Verilog 2001 的系統函數在综合工具里一般都能用。用这种参数化写法改容量和相联度时只需要改最上面几个数字下面所有位宽和索引范围自动跟着变。我已经被“手工计算位宽但算错一位”的坑伤过两次后来一律参数化再也没出过这个错。5. 仿真与调试那些让我浪费一整晚的问题5.1 仿真激励怎么写才有意义写测试平台testbench时如果只是发几个读请求看看命中没有很容易漏掉边界情况。我建议测试用例至少覆盖以下场景连续访问同一块内的连续地址验证空间局部性、块内偏移字段正确性循环访问一个小数组验证时间局部性和计数器的累减故意访问映射到同一组但不同标签的地址验证组相联的比较与替换写命中后替换出去再读旧地址验证写回流程和脏位复位复位后第一次访问验证 valid 初始状态每次跑完仿真不要只看波形是否“看起来对”要专门检查几个关键点命中时返回的数据是不是“最新写入的值”缺失后 Cache 行的 tag 是否更新了脏位在替换后是否被正确清零access_cnt 的最小值选择是否正确。这几点任何一个有问题最终的访存结果都会在某个微妙的场景下出错。5.2 一个典型的错误案例替换时机写错了我在做实验时写过一版控制器缺失后没有判断 dirty 就直奔 REFILL 状态结果数据写得一塌糊涂。查了半天发现REPLACE 状态中需要先检测待替换行是否有效且为脏如果脏必须等写回完成才能执行后续替换否则旧数据就丢了。这个逻辑顺序对应到状态机就表现为“写回完成信号”和“进入 REFILL”的先后关系必须正确。其实这个问题深挖下去本质上是状态机设计里“握手等待”的经典问题。同步握手必须建立一个明确的完成标志不能用延时来碰运气。症状可能原因排查建议总是 miss从不 hit地址位划分错位索引或标签不对打印每次请求的 req_tag / req_index和 cache 行里存的 tag 对比第一次访问后第二次访问仍 missvalid 位没有在 REFILL 状态置 1检查 valid 的更新时序写数据后读回来不对写命中逻辑没更新数据数组或者缓存行写错路单独检查对数据数组的写地址和写使能替换后旧数据丢失dirty 标志的使用出错确认 REPLACE 状态是否等待 mem_write_done波形乱跳状态不受控状态机握手信号没有同步或者复位后状态不对先检查复位信号和初始态5.3 仿真技巧综合前的仿真和综合后仿真都要跑如果用的是带 FPGA 开发板的实验环境我强烈建议把 Modelsim/Questa 仿真跑到所有测试用例都通过后再上板验证。上板后如果要调试尽量用 ILA集成逻辑分析仪抓内部信号而不是靠 LED 猜结果。有一次我在板上调了一个小时的 bug最后用 ILA 抓信号发现只是地址总线连接反了两根线这种问题在仿真里一秒钟就能发现。综合时还需要注意一个时序问题标签比较逻辑和数据选择逻辑是 Cache 数据通路延迟最大的部分如果 Cache 容量变大或者相联度变高容易导致建立时间违例。解决方法是多打一拍pipeline register或者降低工作频率。课程实验一般不会让你改架构降频是最快的解法。6. 从“能跑”到“能改”Cache 实验的进阶方向6.1 用 parameter 调整参数观察命中率变化前文提到参数化设计这里再说说怎么用它来观察 Cache 的行为变化。把 CACHE_SIZE 从 4KB 改成 8KB或者把 ASSOC 从 1 改为 4然后跑同一个仿真程序记录 miss rate。你会发现块大小从 16B 增到 32B命中率往往有明显提升但继续增到 128B可能反而下降因为行数变少、替换更频繁也增加了缺失时填充的时间。这就是一个简单而经典的 Cache 设计权衡实验。顺便提一个测试程序的设计技巧如果你用一段行为仿真的循环程序来测命中率结果会和你写的循环结构强相关。比如循环步长为 1 的数组访问和步长为 16 的数组访问局部性差异很大。选择几个典型访存模式分别记录命中率才能全面评估 Cache 的性能。6.2 和 CPU 主模块真正连起来很多课程实验是把 Cache 和 CPU 分开做的最后再组合。组合时最容易出现的沟通问题是总线握手协议不匹配。比如 CPU 发出请求后期待一拍后拿到数据但 Cache 缺失时需要几个周期才能返回数据。这时候必须要用 ready/valid 握手或者阻塞等待机制把两者协调起来。我建议在连接 CPU 和 Cache 时先把访存时序定义成一张表请求发起时刻、应答时刻、数据返回时刻、写数据时刻都列出来。手头有这张表调起来会快很多。否则波形一复杂双方信号相互纠缠看着就头疼。6.3 从基础 Cache 到更高层设计如果这一章的实验做完还有余力可以试试给 Cache 增加以下功能多周期数据填充把每次读内存的数据按 word 流水返回减少缺失时 CPU 的等待时间。非阻塞 Cache支持缺失时继续处理后续不冲突的访问这个比较硬核。写合并缓冲把多个写回请求合并成一次内存写。简单预取根据空间局部性提前把下一个块读入 Cache。这些功能做起来都建立在基础 Cache 结构正确的前提之上。反过来如果你基础 Cache 的代码写得足够清晰加上这些扩展功能时不会觉得“全身都是返工点”而是像在插件口上加功能。7. 一点个人实践的体会如果只让我说一句关于 Cache 建立的经验那就是不要急着上板先把地址位划分画在纸上再把状态机画成图最后才写代码。Cache 的电路并不复杂复杂的是对地址、状态、时序之间关系的全局把握。很多人卡住不是不会写 Verilog而是对“字段怎么切”和“状态怎么流转”脑子里没有画面。我在做过几版 Cache 之后的最大感受是Curiosity Led 的那种“先跑起来再完善”的方式在 Cache 这类部件上不太好用。Cache 一旦有一个边角漏了比如某条路径没有置 dirty就可能在你验证“各种正常组合”时一直潜伏直到某次替换才突然爆发。所以宁可前期设计图纸多花半小时后期调试少熬两晚上。另外还有一点如果在实验里遇到完全找不到原因的 bug请相信“一定是自己哪里想简单了”而不是“工具出问题了”。Cache 实验的 bug 绝大多数情况下是逻辑错误不是仿真器或综合器的问题。保持这个心态调试效率会高很多。
返回列表