3.5 存储层次本文内容摘自本人的开源书《从沙子到车辙 - 一个工程师的理解》 在线阅读/下载from-sand-to-rutsgitclone https://github.com/Lularible/from-sand-to-ruts⭐ 如果对您有帮助欢迎 Star 支持也欢迎通过 GitHub Issues 交流讨论。图书管理员的聪明办法你是一个图书管理员。图书馆有几百万本书都在地下仓库里。读者来借书你要去地下仓库找——一趟来回 10 分钟。读者排队每个人都要等 10 分钟。你受不了了。你在借阅台后面放了一个小书架——最多 50 本书。每次读者还书你把最常用的书放在小书架上。有人来借如果书在小书架上3 秒拿出来。如果不在再去地下仓库取——顺便把取回来的书也放在小书架上挤掉最不常用的那本。这就是Cache缓存。你不可能把所有书都放在手边太贵了。但你可以把最常用的放在手边。这个策略之所以有效靠两个规律时间局部性你刚刚翻过的书大概率还会再翻。空间局部性你正在看第 5 章大概率马上需要第 6 章。所有计算机的存储层次——从寄存器到 SSD——遵循的都是同样的逻辑。存储金字塔---------- 最快/最贵/最小 | 寄存器 | ~1ns, ~1KB, 在 CPU 核心里 ---------- | L1 Cache| ~1-2ns, ~32KB, 在核心里 ---------- | L2 Cache| ~3-10ns, ~256KB, 在同一个 die 上 ---------- | SRAM | ~5-20ns, ~64-256KB, 片上 ---------- | DRAM | ~50-100ns, ~几百 MB, 外部芯片 ---------- | Flash | ~50-100ns (读), ~ms (擦写), 片上或外部 ---------- | SSD/HDD | ~μs-ms, ~GB-TB, 外部存储 ---------- 最慢/最便宜/最大注这是通用计算机的存储层次。在车规 MCU如 Cortex-M4/M7中通常只有 L1 Cache 或没有 Cache片上 SRAM 直接挂在系统总线上延迟为确定的单周期或几周期。每一层之间延迟差一个数量级。寄存器 ~1nsL1 Cache ~1-2nsSRAM ~5-20nsDRAM ~50-100ns。从寄存器到 DRAM差两个数量级。从寄存器到 SSD差六个数量级。如果没有两个局部性——如果你的内存访问是完全随机的——Cache 对你毫无用处。每次访问都 miss你只是在为存不下的数据付出额外的查找开销。而幸运的是几乎所有程序的访存模式都遵循这两个局部性。指令顺序执行数据集中分布——这是图灵机和冯·诺依曼架构带给我们的礼物。Cache 怎么工作Tag、Index、Offset以最简单的直接映射 Cache为例。Cache 被分成若干 Cache Line比如每个 32 字节。内存地址被切成三个字段| Tag (高位) | Index (中间) | Offset (低位) |Index决定数据映射到哪个 Cache Line。同一个 Index 的不同地址共享同一个 Line 位置。Tag存到该 Cache Line 中用来判断当前缓存的数据是不是你要的那个地址。Offset在 Cache Line 内定位到具体字节。CPU 访问一个地址时用 Index 找到对应的 Cache Line。比较 Tag——匹配就是Cache Hit数据直接返回1 个时钟。不匹配——Cache Miss。硬件自动从下一级存储L2 或主存把整个 Cache Line 加载进来替换掉旧的。延迟 10-100 个时钟。亲手拆一个地址假设一个直接映射 CacheCache 容量 16KBCache Line 32 字节所以有 16KB / 32B 512 个 Cache LineIndex 占 9 位Offset 占 5 位log2 3232 位地址中Tag 占 32 - 9 - 5 18 位访问地址0x0000A0100x0000A010 0000 0000 0000 0000 0000 0000 1010 0000 0001 0000 (二进制) |___ Tag (18) ____| |_ Index (9) _| |_Off(5)_| Tag 0x00000 Index 0b101000000 320 Offset 0x10 16Cache 控制器做的事查看 Set 320 中 Tag 字段是否等于0x00000Valid 位是否为 1。如果匹配——Cache Hit。如果不匹配——Cache Miss从主存加载地址0x0000A010所在的整个 32 字节块从0x0000A000到0x0000A01F到 Set 320把 Tag 写成0x00000Valid 置 1。在往下读代码之前先在脑子里把这个地址走一遍Index320指向第320号Cache Line。硬件检查这一行的Tag是否等于0x00000。如果是——命中Hit直接读出offset16处的数据。如果不是——缺失Miss硬件自动从下一级存储L2或主存加载整个32字节的块替换这一行更新Tag。下面的C代码就是在模拟这个检查→命中/缺失→替换的循环。用 C 实现一个直接映射 Cache 模拟器#includestdio.h#includestdint.h#includestring.h#defineCACHE_LINES512#defineLINE_SIZE32typedefstruct{uint8_tvalid;uint32_ttag;uint8_tdata[LINE_SIZE];}CacheLine;CacheLine cache[CACHE_LINES];uint32_thits0,misses0;uint8_tcache_access(uint32_taddr,uint8_twrite,uint8_tdata){uint32_toffsetaddr0x1F;// 低 5 位uint32_tindex(addr5)0x1FF;// 中 9 位uint32_ttagaddr14;// 高 18 位CacheLine*linecache[index];// Cache Hitif(line-validline-tagtag){hits;if(write)line-data[offset]data;returnline-data[offset];}// Cache Missmisses;// 在真实硬件中这里会从主存加载整个 Cache Line// 模拟中我们直接置 Valid 和 Tagline-valid1;line-tagtag;if(write)line-data[offset]data;returnline-data[offset];}voidrun_trace(uint32_taddrs[],intcount){hitsmisses0;memset(cache,0,sizeof(cache));for(inti0;icount;i)cache_access(addrs[i],0,0);printf(Hit rate: %.2f%% (%u hits, %u misses)\n,100.0*hits/count,hits,misses);}测试顺序访问0x1000, 0x1004, 0x1008, 0x2000, 0x100C。0x1000 → Index128, Tag0 → Miss (cold) 0x1004 → Index128, Tag0 → Hit (同 Line, 0x1000-0x101F) 0x1008 → Index128, Tag0 → Hit 0x2000 → Index256, Tag0x0 → Miss与0x1000在同一组Tag不同——冲突缺失 0x100C → Index128, Tag0 → Hit (还在 Line 128 中)命中率 3/5 60%。注意0x2000映射到 Index0 而不是 Index128——因为它和0x1000的 Index 不同0x2000 5 0x100,0x1000 5 0x80。它们不会冲突。但如果后续访问0x100A000Index 也是 128Tag0x80——就会驱逐当前0x1000的 Cache Line。这就是直接映射的冲突缺失Conflict Miss两个不同的地址映射到了同一个 Cache Line。组相连与 LRU 替换直接映射的缺点很明显如果两个频繁访问的地址恰好撞到同一个 Index就会反复把对方踢出去——抖动thrashing。组相连 CacheSet-Associative Cache缓解了这个问题每个 Index 对应 N 个 Cache LineN 路地址可以在 N 路中任选一个存放。N2 叫 2 路组相连N16 叫 16 路组相连。N 越大冲突越少但硬件越复杂需要并行比较 N 个 Tag。当 N 路都满了需要踢掉一个时——用哪个策略**LRULeast Recently Used**是最经典的替换算法踢掉最久没有被访问的那一路。用 C 实现一个 4 路组相连 LRU Cache// LRU 替换策略概念伪代码 function access_cache(set_index, tag): for way in 0..WAYS-1: if cache[set][way].valid cache[set][way].tag tag: // Cache Hit update_lru(set_index, way) hits return cache[set][way].data // Cache Miss先找空路 for way in 0..WAYS-1: if !cache[set][way].valid: fill_line(set_index, way, tag) return // 无空路踢掉 LRU 最久未被访问的 way victim find_lru_way(set_index) if cache[set][victim].dirty: write_back(victim) cache[set][victim].tag tag cache[set][victim].valid 1 update_lru(set_index, victim) misses核心思路每个 Set 内的每一路维护一个年龄计数。每次访问命中时被命中的路年龄重置为最大最新其他路递减。当需要替换时选择年龄最小的路——它最久没被访问过。实际上find_lru_way和update_lru不需要存储完整时间戳。以 4 路组相连为例只需 6 个 bit 编码 4! 24 种相对访问顺序——用一个 LRU 状态机即可不必每路配一个 32 位计数器。这个模拟器让你看到LRU 的实现成本随路数 N 线性增长。在每个 Set 内你需要存储 N 个 LRU 时间戳并做 N 路比较。真实硬件中4 路组相连用 6 个 bit 的 LRU 状态机就够了不是完整时间戳而是相对排序编码但 16 路以上通常改用伪 LRUPLRU或随机替换——因为真 LRU 的硬件开销已经不值得那微小的命中率提升。写策略Write-Through vs Write-Back当 CPU 写数据时Cache 面临一个选择Write-Through同时写 Cache 和下一级存储主存。优点简单Cache 和主存永远一致——不需要 dirty bit。缺点每次 Store 都要等主存写入确认——延迟高、功耗高。通常配合Write Buffer使用——CPU 把写数据放入一个 FIFOWrite Buffer 在后台慢慢写主存CPU 不用等。但如果 Write Buffer 满了CPU 还是得 stall。Write-Back只写 Cache标记该 Line 为 “dirty”脏。当该 Line 被替换出去时再把整个 Line 写回主存。优点Store 延迟低只写 Cache总线流量少。缺点需要 dirty bitCache 控制器逻辑更复杂掉电时 Cache 中的脏数据丢失。在写操作 miss 时还有一个选择Write-Allocate先加载整个 Line 到 Cache再在 Cache 中写该字节。利用了空间局部性——你可能马上还会写这个 Line 的相邻字节。No-Write-Allocate绕过 Cache 直接写主存。适合流式写入大数据块只写一次不会重复使用。典型组合Write-Back Write-Allocate最常见比如 Cortex-A 系列的 L1/L2 Cache。Write-Through No-Write-Allocate在实时系统中多见——减少了Cache一致性维护的复杂度保证了确定性。多核一致性问题MESI 协议如果你有两个 CPU 核——每个有自己的 L1 Cache——它们各自缓存了同一块主存地址 0x2000 的副本。CPU0 修改了它——CPU1 看到的还是旧值。这就是Cache Coherence缓存一致性问题。想象两个图书管理员各自有一个小书架L1 Cache共享同一个地下仓库主存。当管理员A在自己书架上修改了一本书——管理员B书架上的那本同样的书就变成了旧版。他们需要一种方式互相通知。MESI协议就是这套通知规则MModified我的书比仓库新别人不能有、EExclusive我的书和仓库一样新别人不能有、SShared我的书和仓库一样新别人也可以有、IInvalid我的书过时了要读的话从仓库重新拿。MESI 协议是最经典的 snooping-based 一致性协议。每个 Cache Line 处于四种状态之一ModifiedM该 Line 只在这个 Cache 中有副本且已被修改dirty。主存中的副本是过期的。ExclusiveE该 Line 只在这个 Cache 中有副本且与主存一致clean。SharedS该 Line 在多个 Cache 中有副本所有副本与主存一致。InvalidI该 Line 无效。状态转换的核心规则CPU 读 → 如果 I发 BusRd转到 S或 E如果没有其它共享者 CPU 写 → 如果 S/E发 BusUpgrinvalidate 其它副本转到 M Snoop BusRd → 如果 M写回主存转到 S Snoop BusRdX别的 CPU 要写→ 转到 IMESI 是一种监听snooping协议——每个 Cache 控制器都在总线上监听其他 Cache 的读写操作根据地址判断是否需要更新自己的状态。这在总线系统中工作良好但在大型多核16 核中总线带宽成为瓶颈——现代 CPU 转向目录协议用一个中央目录记录每个 Cache Line 被哪些核共享。对于汽车 MCU 来说多核一致性通常简单得多。Cortex-R5 的双核锁步模式中两个核运行相同代码——不存在一致性问题。即使是非锁步的多核 MCU比如 Renesas RH850、Infineon Aurix核间通信用的是共享 SRAM 而不是 hardware-coherent Cache——程序员用 memory barrier 指令DSB/DMB/ISB in ARM显式管理一致性。车规 MCU 为什么不爱 Cache许多车规 Cortex-M4 MCU没有 Cache或只有简单的指令 Cache。Cortex-M7 通常同时具有 I-Cache 和 D-Cache——这是它的架构特性决定的六级流水线需要低延迟的指令和数据供应。但在功能安全关键路径上工程师往往主动禁用 D-Cache以保证 WCET 分析的可预测性。它们用紧耦合 SRAMSystem RAM——片上 SRAM 的访问延迟是确定的单周期或两三个等待周期。不用的原因就一个WCET 无法分析。Cache miss 发生在哪一次循环迭代不知道。取决于历史访问模式。Flash 的等待周期在当前 VCC 和温度下是多少不确定。ISR 可能因为一次 cache miss 时间加倍——你敢让 ABS 的 ISR 有这种不确定性吗车规 MCU 宁愿把这些硅面积拿来做更多的片上 SRAM也不做 Cache。面积换确定性。TCM软件显式管理的确定性 CacheCortex-R5 的 TCM 设计更是把这个思路推到极致不是盲目的透明 Cache是软件显式管理的紧耦合存储。让我给你一个具体的画面。在 Cortex-R5 的 MPU 配置中我把 ISR 代码锁到了 TCM 的 A 区域ATCMTCM A (ATCM)0x00000000-0x00003FFF — ISR 代码 关键数据 TCM B (BTCM)0x00004000-0x00007FFF — 栈空间 TCM R (TCMR)配置寄存器TCM 的访问延迟是确定的一或两个周期——跟 Cache Hit 一样快但没有不确定性。每次 CPU 访问 TCM 地址空间数据一定在那个周期内返回。没有 tag 比较没有 miss没有替换没有状态机。代价是什么呢TCM 的总容量很小Cortex-R5 上典型 32-64KB——因为它是用 SRAM 做的和 Cache 争夺同一块硅面积。而且内容必须由程序员显式管理——你在 linker script 中声明哪些 section 放在 TCM在运行时用 DMA 把关键数据搬进 TCM用完后搬出来。没有硬件自动化。TCM 和 Cache 的根本区别TCM 是我知道什么快Cache 是我相信什么快。在安全关键系统中“我相信是不够的——你需要我知道”。SRAM 单元的物理脆弱性SRAM 单元由 6 个晶体管组成——4 个构成两个交叉耦合的反相器存储 0 或 12 个是访问晶体管连接字线和位线。WL (字线) │ M5 ───┼─── M6 │ ┌──M1─┴──┐ │ 交叉 │ │ 耦合 │ └──M2─┬──┘ │ BL BL (位线)这个结构的稳定性取决于 6 个晶体管的 Vt阈值电压匹配。在制造过程中由于随机掺杂波动RDF, Random Dopant FluctuationM1 和 M2 的 Vt 可能有微小差异。在小尺寸工艺28nm 以下RDF 的影响更加显著——一个晶体管中掺杂原子只有几十个多一个或少一个都可能导致 Vt 漂移几十 mV。如果 M1 的 Vt 漂高变得更难导通而 M2 的 Vt 正常——那么在读操作时M5 导通、BL 被拉到低电平的过程中M1 的驱动力不足。交叉耦合反相器的正反馈可能被破坏——存储的1翻转成了0。这叫做读破坏Read Disturb。它是 SRAM 在小尺寸下最常见的一个物理失效机制——不是电路设计错误是制造变异使单个 bit cell 的噪声裕度降到了一个标准偏差以下。MBISTMemory Built-In Self Test的作用就是在晶圆测试和封装后测试中遍历所有 bit cell 的各种访问序列——连续读、连续写、写后立即读、相邻行扰动——来暴露那些噪声裕度不够的弱 bit。车规 SRAM 的 MBIST 覆盖率要求接近 100%因为——你能容忍你的 ECU 在某次过弯时因为一个弱 SRAM bit 导致堆栈数据翻转吗Flash 的擦写寿命一个收敛的故事一片标准的嵌入式 NOR Flash 的擦写寿命是 10 万次。假设你的 DTC诊断故障码log 每 10 秒记录一次诊断数据。每次写入一个 4 字节的 DTC 条目。如果你直接把 DTC log 放在 Flash 的一个扇区里——每次写都擦除整扇再写回——10 万次 ÷ 24小时 × 3600秒 / 10秒≈ 11.6 天。你的 Flash 在不到两周内就报废了。这就是为什么所有 Flash 存储系统必须要磨损均衡Wear Leveling。你不在同一个物理扇区反复擦写——而是在一片更大的 Flash 区域内比如 64KB用软件循环使用扇区。每次写到一个新位置同时维护一个映射表——告诉你逻辑地址 0x0000对应的当前物理扇区是哪一个。更进一步——你用 SRAM 做缓冲。DTC 数据先积累在 SRAM 里积累到 512 字节然后一次性编程到 Flash。10 秒的写入频率变成了512/4× 10 1280 秒 ≈ 21 分钟。同样 10 万次擦写寿命现在能撑约 38 年——超过车的设计寿命。三层协作为什么没有一层能单独解决问题这个设计里藏着三层协作。每一层都单独不够用组合起来才能满足整车的全生命周期要求SRAM (速度) → 快速缓冲但掉电丢失 Flash (持久) → 掉电不丢但擦写慢、寿命有限 磨损均衡 (寿命) → 分摊擦写但需要额外计算 三者协作 → 完整的非易失性存储系统三层分别解决速度、持久性和寿命——SRAM提供快速缓冲、Flash提供持久化存储、磨损均衡算法平均化擦写。硬件层面SRAM和Flash是两种物理介质软件层面磨损均衡算法是一段代码。三层跨越了硬件和软件的边界共同构成一个可靠的存储子系统。你的 Embedded C 和存储层次中断响应Flash 里的 ISR你写了一个 ISR读 CAN 报文并存储到环形缓冲。ISR 被放在 Flash 里。Flash 读取延迟——在 112MHz 下——可能是 3-5 个等待周期。没有指令 Cache每次 fetch 都 miss。优化把 ISR 重映射到 SRAM通过 linker script 的 section 放置或启用指令 Cache 把关键代码锁到 Cache Line。循环局部性的教科书for(inti0;i1024;i){dst[i]src[i]*gain;}完美的空间局部性顺序访问src和dst和时间局部性循环体指令重复执行。有 data cache 时命中率接近 100%。没有 Cache 时片上 SRAM 也是单周期的——只是功耗高一点每次迭代都在读取 SRAM。Flash 模拟 EEPROM多层的协作你的 MCU 没有独立 EEPROM但标定参数需要掉电不丢失。你用片上 Flash 来模拟Flash 擦除是按扇区的几 KB 到几十 KB擦写寿命 ~10 万次。更新参数前把该扇区的其他有效数据备份到 SRAM → 擦除整个扇区 → 把 SRAM 备份 新参数写回 Flash。这里面暗含了存储层次的协作Flash非易失性、慢擦写 SRAM易失性、快读写 一个可靠的非易失性存储系统。你利用了两个层次各自的优势——Flash 的持久性和 SRAM 的灵活读写——来构建一个单层存储提供不了的能力。有限资源的最优解存储层次回答的问题是如何用有限资源做到接近最快的性能你不可能把所有数据都放在寄存器里——寄存器只有几十个。不能都放在 Cache 里——Cache 只有几十 KB。不能都放在 SRAM 里——SRAM 也只有几百 KB。但你可以把这些层次组合起来让程序员在写data[x] y的时候感觉不到多层存储的存在。这是透明的优化——在用户无感知的情况下把有限资源用到极致。这个哲学贯穿了整个计算机系统设计你没有无限的存储但你有多层缓存——有限资源最优解。你没有完美的网络但你有时间同步和确定性调度——有限资源最优解。你没有无限的人力做充分测试但你用 MISRA C、ISO 26262 流程、形式化方法把风险压到 ASIL 级别——还是有限资源最优解。你用 128KB SRAM 在 ECU 上跑通一个 CAN 诊断栈——是在践行这个信条。你用硬件触发器和组合逻辑在纳秒精度下捕捉时间戳——是在践行这个信条。你设计一套基于 Flash 模拟 EEPROM 的存储架构省掉一颗外部芯片——还是在践行这个信条。存储层次是有限资源最优解最优雅的体现。本篇小结今天我们做了一件事理解了存储层次——快和大不可兼得于是我们用多级存储来骗过这个限制。关键结论存储层次是逐级妥协寄存器亚纳秒/几KB→ Cache几纳秒/几十KB→ SRAM十几纳秒/几百KB→ DRAM几十纳秒/几百MB→ Flash微秒级/GB——每层速度差一个数量级。Cache靠局部性工作时间局部性刚用过的可能再用和空间局部性附近的可能被用——命中率通常在90%以上。硬实时系统需要确定性Cache miss的时间不可预测所以车规MCU倾向用TCM——把关键代码和数据锁在固定位置的SRAM里延迟完全确定。下一部分计算的连接——从片内总线到车载网络芯片与芯片之间怎么对话。【下集预告】片上计算能力拉满了存储层次也搭好了。但一个 ECU 不是孤岛——它要和传感器对话要和执行器对话要和其他 ECU 对话。芯片与芯片之间怎么通信I2C、SPI、UART、CAN、FlexRay、Ethernet——这些总线到底在物理层和数据链路层做了什么谁来决定轮到谁说话了下一部分我们从片内走向整车——总线、协议、网络。Part 4 正式启程。