3.5 存储层次
图书管理员的聪明办法
你是一个图书管理员。图书馆有几百万本书,都在地下仓库里。读者来借书,你要去地下仓库找。一趟来回 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 直接挂在系统总线上,延迟为确定的单周期或几周期。
每一层之间,延迟差一个数量级。寄存器 ~1ns,L1 Cache ~1-2ns,SRAM ~5-20ns,DRAM ~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 个时钟。
亲手拆一个地址
假设一个直接映射 Cache:
- Cache 容量 16KB
- Cache Line = 32 字节
- 所以有 16KB / 32B = 512 个 Cache Line(Index 占 9 位)
- Offset 占 5 位(log2 32)
- 32 位地址中,Tag 占 32 - 9 - 5 = 18 位
访问地址 0x0000A010:
0x0000a010 = 0000 0000 0000 0000 1010 0000 0001 0000 (二进制)
|___ Tag (18) ____| | Index (9) _| |_Off(5)_|
Tag = 0x00002
Index = 0b100000000 = 256
Offset = 0x10 = 16
Cache 控制器做的事:查看 Set 256 中 Tag 字段是否等于 0x00002,Valid 位是否为 1。如果匹配——Cache Hit。如果不匹配——Cache Miss,从主存加载地址 0x0000A010 所在的整个 32 字节块(从 0x0000A000 到 0x0000A01F)到 Set 256,把 Tag 写成 0x00002,Valid 置 1。
在往下读代码之前,先在脑子里把这个地址走一遍:Index=256指向第256号Cache Line。硬件检查这一行的Tag是否等于0x00002。如果是:命中(Hit),直接读出offset=16处的数据。如果不是:缺失(Miss),硬件自动从下一级存储(L2或主存)加载整个32字节的块,替换这一行,更新Tag。下面的C代码就是在模拟这个“检查→命中/缺失→替换”的循环。
用 C 实现一个直接映射 Cache 模拟器
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#define CACHE_LINES 512
#define LINE_SIZE 32
typedef struct {
uint8_t valid;
uint32_t tag;
uint8_t data[LINE_SIZE];
} CacheLine;
CacheLine cache[CACHE_LINES];
uint32_t hits = 0, misses = 0;
uint8_t cache_access(uint32_t addr, uint8_t write, uint8_t data) {
uint32_t offset = addr & 0x1f; // 低 5 位
uint32_t index = (addr >> 5) & 0x1ff; // 中 9 位
uint32_t tag = addr >> 14; // 高 18 位
CacheLine *line = &cache[index];
// Cache Hit
if (line->valid && line->tag == tag) {
hits++;
if (write) line->data[offset] = data;
return line->data[offset];
}
// Cache Miss
misses++;
// 在真实硬件中,这里会从主存加载整个 Cache Line
// 模拟中我们直接置 Valid 和 Tag
line->valid = 1;
line->tag = tag;
if (write) line->data[offset] = data;
return line->data[offset];
}
void run_trace(uint32_t addrs[], int count) {
hits = misses = 0;
memset(cache, 0, sizeof(cache));
for (int i = 0; i < count; 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 → Index=128, Tag=0 → Miss (cold)
0x1004 → Index=128, Tag=0 → Hit (同 Line, 0x1000-0x101f)
0x1008 → Index=128, Tag=0 → Hit
0x2000 → Index=256, Tag=0x0 → Miss(首次访问 Set 256,冷缺失)
0x100c → Index=128, Tag=0 → Hit (还在 Line 128 中)
命中率 = 3/5 = 60%。0x2000(Index=256)和 0x1000(Index=128)落在不同 Cache Line,彼此不冲突。如果后续访问 0x100A000(Index=128,Tag=0x80)就会驱逐当前 0x1000 的 Cache Line,这是冲突缺失。
这就是直接映射的“冲突缺失”(Conflict Miss):两个不同的地址映射到了同一个 Cache Line。
组相连与 LRU 替换
直接映射的缺点很明显:如果两个频繁访问的地址恰好撞到同一个 Index,就会反复把对方踢出去,出现抖动(thrashing)。
组相连 Cache(Set-Associative Cache)缓解了这个问题:每个 Index 对应 N 个 Cache Line(N 路),地址可以在 N 路中任选一个存放。N=2 叫 2 路组相连,N=16 叫 16 路组相连。N 越大,冲突越少,但硬件越复杂(需要并行比较 N 个 Tag)。
当 N 路都满了,需要踢掉一个时,用哪个策略?
LRU(Least 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 路以上通常改用伪 LRU(PLRU)或随机替换,因为真 LRU 的硬件开销已经不值得那微小的命中率提升。
写策略:Write-Through vs Write-Back
当 CPU 写数据时,Cache 面临一个选择:
Write-Through:同时写 Cache 和下一级存储(主存)。优点:简单,Cache 和主存永远一致,不需要 dirty bit。缺点:每次 Store 都要等主存写入确认,延迟高、功耗高。通常配合 Write Buffer 使用——CPU 把写数据放入一个 FIFO,Write Buffer 在后台慢慢写主存,CPU 不用等。但如果 Write Buffer 满了,CPU 还是得 stall。
Write-Back:只写 Cache,标记该 Line 为 “dirty”(脏)。当该 Line 被替换出去时,再把整个 Line 写回主存。优点:Store 延迟低(只写 Cache),总线流量少。缺点:需要 dirty bit,Cache 控制器逻辑更复杂;掉电时 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协议就是这套通知规则:M(Modified,我的书比仓库新,别人不能有)、E(Exclusive,我的书和仓库一样新,别人不能有)、S(Shared,我的书和仓库一样新,别人也可以有)、I(Invalid,我的书过时了,要读的话从仓库重新拿)。
MESI 协议是最经典的 snooping-based 一致性协议。每个 Cache Line 处于四种状态之一:
- Modified(M):该 Line 只在这个 Cache 中有副本,且已被修改(dirty)。主存中的副本是过期的。
- Exclusive(E):该 Line 只在这个 Cache 中有副本,且与主存一致(clean)。
- Shared(S):该 Line 在多个 Cache 中有副本,所有副本与主存一致。
- Invalid(I):该 Line 无效。
状态转换的核心规则:
CPU 读 → 如果 I:发 BusRd,转到 S(或 E,如果没有其它共享者)
CPU 写 → 如果 S/E:发 BusUpgr(invalidate 其它副本),转到 M
Snoop BusRd → 如果 M:写回主存,转到 S
Snoop BusRdX(别的 CPU 要写)→ 转到 I
MESI 是一种监听(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 分析的可预测性。它们用紧耦合 SRAM(System RAM),片上 SRAM 的访问延迟是确定的单周期(或两三个等待周期)。
不用的原因就一个:WCET 无法分析。
- Cache miss 发生在哪一次循环迭代?不知道。取决于历史访问模式。
- Flash 的等待周期在当前 VCC 和温度下是多少?不确定。
- ISR 可能因为一次 cache miss 时间加倍,你敢让 ABS 的 ISR 有这种不确定性吗?
车规 MCU 宁愿把这些硅面积拿来做更多的片上 SRAM,也不做 Cache。用面积换确定性。
TCM:软件显式管理的“确定性 Cache”
Cortex-R5 的 TCM 设计更是把这个思路推到极限:不是“盲目的透明 Cache”,是软件显式管理的紧耦合存储。
让我给你一个具体的画面。在 Cortex-R5 的 MPU 配置中,我把 ISR 代码锁到了 TCM 的 A 区域(ATCM):
TCM 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 或 1),2 个是访问晶体管(连接字线和位线)。
WL (字线)
│
M5 ───┼─── M6
│
┌──M1─┴──┐
│ 交叉 │
│ 耦合 │
└──M2─┬──┘
│
BL BL'
(位线)
这个结构的稳定性取决于 6 个晶体管的 Vt(阈值电压)匹配。在制造过程中,由于随机掺杂波动(RDF, Random Dopant Fluctuation),M1 和 M2 的 Vt 可能有微小差异。在小尺寸工艺(28nm 以下),RDF 的影响更加显著,一个晶体管中掺杂原子只有几十个,多一个或少一个都可能导致 Vt 漂移几十 mV。
如果 M1 的 Vt 漂高(变得更难导通)而 M2 的 Vt 正常,那么在读操作时,M5 导通、BL 被拉到低电平的过程中,M1 的驱动力不足。交叉耦合反相器的正反馈可能被破坏,存储的“1”翻转成了“0”。
这叫做读破坏(Read Disturb),它是 SRAM 在小尺寸下最常见的一个物理失效机制。不是电路设计错误,是制造变异使单个 bit cell 的噪声裕度降到了一个标准偏差以下。
MBIST(Memory Built-In Self Test)的存在,就是为了在芯片出厂前,用最严苛的访问序列(连续读写、读写翻转、邻位干扰)拷打每一个存储bit,不放过任何噪声裕度不够的弱bit。车规SRAM要求接近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 (int i = 0; i < 1024; 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 正式启程。