4、CPU发起读请求流程:从Load指令到Bank选择
好,咱们今天聊一个硬核话题——CPU怎么从DDR里把数据读回来。
你可能觉得,不就是读个数据吗?CPU发个地址,内存返回数据,完事。嗯,如果真这么简单,我也没必要专门开一章来讲了。实际流程远比你想的复杂,而且每一步都可能成为性能瓶颈。
我个人习惯把整个读请求拆成四个阶段:
- CPU核心发出Load指令
- L1/L2/L3缓存全部未命中
- 内存控制器接手请求
- 地址映射,选定具体的Bank、Row、Column
下面我一个一个说。
4.1 CPU核心发出Load指令
一切从一条指令开始。
比如你的C代码里写了个 int a = *ptr;,编译器会把它翻译成一条Load指令。CPU核心的执行单元看到这条指令,就知道:嘿,我要去拿某个地址的数据了。
但这里有个细节——CPU不会直接去找DDR要数据。它先问自己家的缓存。
关键点:Load指令发出后,CPU先查L1缓存。如果命中,数据在几个时钟周期内就回来了。只有缓存全部未命中,才会走到内存控制器这一步。
我在项目中遇到过一个问题:某次性能测试发现程序跑得特别慢,查了半天,发现是缓存行冲突导致频繁未命中。说白了,就是两个频繁访问的变量恰好映射到了同一个缓存行,互相踢来踢去。嗯,这种坑踩过一次就记住了。
4.2 L1/L2/L3缓存全部未命中
假设L1没命中,数据会去L2找。L2也没命中,再去L3。如果L3还是没命中……好,这下只能找内存了。
这个过程叫缓存缺失(Cache Miss)。缺失的代价有多大?我列个大概的数字给你感受一下:
| 存储层级 | 典型延迟 | 相对比例 |
|---|---|---|
| L1缓存命中 | ~1ns | 1x |
| L2缓存命中 | ~4ns | 4x |
| L3缓存命中 | ~12ns | 12x |
| DDR内存访问 | ~60-100ns | 60-100x |
看到了吧?一旦走到DDR这一步,延迟直接跳一个数量级。所以为什么我们做优化时总强调数据局部性——就是为了尽量让数据留在缓存里,别去麻烦内存。
注意:缓存未命中不光是延迟问题,还会占用内存带宽。如果大量核心同时未命中,内存控制器会忙到崩溃,这就是所谓的"内存墙"问题。
4.3 内存控制器接收请求
好,现在请求到了内存控制器手里。
内存控制器是什么?你可以把它理解成CPU和DDR之间的交通警察。它负责接收来自CPU核心(甚至多个核心)的读写请求,然后按照DDR的时序要求,一条一条发出去。
这里有个容易忽略的点:内存控制器不是收到请求就立刻发出去的。它会做几件事:
- 请求重排序:把访问同一Bank的请求尽量合并,减少Bank切换开销
- 优先级仲裁:读请求通常优先于写请求(因为读会阻塞CPU)
- 地址转换:把CPU发来的物理地址,翻译成DDR能理解的Bank、Row、Column
我曾经调试过一个性能问题:某个模块的读写延迟忽高忽低,抓波形看了半天,发现是内存控制器的仲裁策略导致写请求被频繁插队。后来调整了优先级权重,问题就解决了。这种问题,光看代码是看不出来的,必须上逻辑分析仪抓实际波形。
4.4 地址映射:Bank/Row/Column选择
这是整个流程里最绕的一步,我尽量讲清楚。
DDR内部不是一块连续的大内存,而是被分成了多个Bank。每个Bank又分成很多Row(行),每行里再分Column(列)。
CPU发来的物理地址,需要被映射到具体的Bank、Row、Column上。映射方式不同,性能差异巨大。
举个例子,假设物理地址是0x12345678,内存控制器会把它拆成三部分:
物理地址: 0x12345678
┌─────────┬──────────┬──────────┐
│ Bank位 │ Row位 │ Column位 │
│ 2 bits │ 14 bits │ 10 bits │
└─────────┴──────────┴──────────┘
具体怎么拆,取决于芯片设计时的地址映射策略。常见的策略有两种:
- 连续映射:先走完一个Row的所有Column,再换Row。适合顺序访问。
- 交叉映射:相邻地址映射到不同Bank。适合随机访问,能利用Bank级并行。
我的建议:如果你在做嵌入式或SoC设计,一定要搞清楚你的芯片用的是哪种映射策略。选错了,性能可能差30%以上。
为什么Row的选择这么重要?因为DDR访问时,打开一个Row需要额外的时间(tRCD,Row到Column的延迟)。如果频繁切换Row,大部分时间都花在开Row上了,真正读写数据的时间反而很少。
这就是所谓的Row冲突。我见过一个极端案例:某算法因为数据布局不合理,导致每次访问都在换Row,DDR利用率不到10%。后来把数据重新排列,让连续访问落在同一Row里,性能直接翻了5倍。
4.5 完整流程的SVG示意图
下面这张图把整个读请求流程串起来了,你可以对照着看:
4.6 避坑总结
最后,我把自己这些年踩过的坑总结一下,你遇到了可以少走弯路:
- 缓存行对齐:数据结构尽量按64字节对齐,避免一个变量跨两个缓存行。我曾经因为一个结构体没对齐,读一次数据要访问两次内存,性能直接腰斩。
- Bank冲突:多线程访问时,尽量让不同线程访问不同Bank。否则Bank级并行性发挥不出来,所有请求都在排队。
- Row命中率:连续访问同一Row的数据,比随机访问快得多。设计算法时,尽量让数据在Row内连续存放。
一句话总结:CPU读DDR数据,不是发个地址就完事。缓存、内存控制器、地址映射,每一层都有坑。理解了这些细节,你才能写出真正高性能的代码。
嗯,这一章就到这儿。内容不少,你慢慢消化。下一章我们聊聊写请求——写比读更麻烦,因为还有写缓冲和写合并的问题。
公众号:蓝海资料掘金营,微信deep3321