4. 存储控制器架构:控制器内部模块划分、命令队列、数据通路

好,咱们今天聊聊存储控制器的内部架构。说白了,就是把这个黑盒子拆开,看看里面到底有哪些模块在干活。

我在做第一个DDR控制器项目时,老板丢给我一份架构文档,密密麻麻的模块图。我当时心想:不就是读写数据吗?至于搞这么复杂?后来踩了坑才明白——模块划分不清,后面调试能让你怀疑人生

4.1 控制器内部模块划分

一个典型的存储控制器,我习惯把它分成三大块:

  • 前端接口层:负责跟AHB/AXI总线打交道
  • 核心控制层:命令调度、地址映射、时序控制
  • 后端物理层:直接驱动DDR颗粒的PHY接口

你想想看,这三层各司其职。前端不用管DDR的时序细节,后端也不用关心总线协议。这种分层设计,我在项目里屡试不爽。

核心原则:模块间接口要简单、清晰。我见过最糟糕的设计,是前端模块直接去操作DDR的刷新计数器——调试时简直是一场噩梦。

具体到模块级别,我通常会划分出:

  • 命令解析器:把AHB/AXI的交易转换成内部命令
  • 地址映射器:把总线地址转换成DDR的rank、bank、row、column
  • 命令队列管理器:排队、重排序、合并
  • 时序生成器:负责tRCD、tCL、tRP这些参数
  • 刷新控制器:自动处理DDR的刷新请求
  • 数据通路:读写数据的缓冲和路由

嗯,这里要注意:刷新控制器经常被新手忽略。我曾经有一个项目,因为刷新请求和读写请求的优先级没处理好,导致系统每隔一段时间就卡顿一下。查了三天才发现是刷新占用了太多带宽。

4.2 命令队列

命令队列,说白了就是个缓冲区。但别小看它,它决定了存储控制器的性能上限。

为什么需要命令队列?因为总线的请求是随机的,而DDR的访问有固定的时序要求。没有队列,你就得让总线等DDR,效率极低。

我常用的命令队列结构是这样的:

// 命令队列条目结构
typedef struct {
    uint8_t  cmd_type;    // 读/写/刷新/预充电
    uint32_t addr;        // 目标地址
    uint8_t  burst_len;   // 突发长度
    uint8_t  priority;    // 优先级
    uint8_t  status;      // 等待/执行/完成
} cmd_entry_t;

// 队列深度:通常16~64
#define QUEUE_DEPTH 32
cmd_entry_t cmd_queue[QUEUE_DEPTH];

队列深度怎么选?我个人的经验是:

应用场景 推荐队列深度 说明
低功耗IoT 8~16 请求少,深度太深浪费面积
多媒体处理 16~32 流式数据,需要一定缓冲
高性能计算 32~64 乱序请求多,深度越大越好

小技巧:队列深度不是越大越好。深度每翻一倍,面积大概增加40%。我在一个项目里试过128深度,结果面积超标,性能提升不到5%。后来改回32,刚刚好。

命令队列的核心功能有三个:

  1. 缓冲:吸收总线的突发请求
  2. 重排序:把访问同一行的命令排在一起,减少预充电次数
  3. 合并:连续的读写请求可以合并成一次DDR访问

我曾经遇到一个性能问题:系统带宽利用率只有40%。查了半天,发现是命令队列没有做重排序。每次读写都在不同的行,DDR一直在做预充电和激活。加上重排序逻辑后,利用率直接飙到85%。

4.3 数据通路

数据通路,就是数据从总线到DDR颗粒的物理路径。听起来简单,但这里坑最多。

我画了一张数据通路的框架图,帮你理解数据是怎么流动的:

存储控制器数据通路框架图 AHB/AXI总线 写数据缓冲 读数据缓冲 数据选择器 DDR PHY 命令队列 写数据 读数据 控制信号 读回数据 返回数据 总线接口 写通路 读通路 数据选择 物理层 控制通路

数据通路里,我最想强调三点:

4.3.1 写数据缓冲

写数据缓冲的作用是吸收写突发。AHB/AXI的写突发可以很长,但DDR一次只能写固定长度。缓冲可以把总线数据攒起来,凑够一次DDR访问再发出去。

我建议写缓冲的深度至少是DDR突发长度的2倍。比如DDR一次写8个beat,缓冲就做16个beat。为什么?因为总线可能连续发两个写请求,缓冲不够的话,总线就得等。

4.3.2 读数据缓冲

读数据缓冲稍微复杂一些。读请求发出去后,DDR要过好几个时钟周期才能返回数据(读延迟)。这段时间里,总线不能闲着,得继续处理其他请求。

读缓冲需要支持乱序返回。什么意思?就是你先发的读请求,可能后返回。因为DDR内部bank的访问时间不一样。我见过一个设计,读缓冲只支持顺序返回,结果性能直接腰斩。

注意:读缓冲的标签管理很关键。每个读请求都要带一个ID标签,返回数据时根据标签找到对应的总线事务。我曾经因为标签位宽不够,导致两个读请求的标签冲突,数据送错了地方。调试了整整两天。

4.3.3 数据宽度转换

总线数据宽度和DDR数据宽度往往不一样。比如AHB是32位,DDR可能是64位或128位。这就需要一个宽度转换器

转换逻辑其实不复杂,但要注意字节对齐。我习惯的做法是:

// 32位总线 -> 64位DDR
// 总线地址0x00 -> DDR地址0x00, 数据[31:0] -> DDR[31:0]
// 总线地址0x04 -> DDR地址0x00, 数据[31:0] -> DDR[63:32]
// 总线地址0x08 -> DDR地址0x08, 数据[31:0] -> DDR[31:0]

always @(posedge clk) begin
    if (write_valid) begin
        case (addr[2])  // 字节偏移
            1'b0: ddr_wdata[31:0]  <= bus_wdata;
            1'b1: ddr_wdata[63:32] <= bus_wdata;
        endcase
    end
end

嗯,这里要注意:字节掩码也要跟着转换。32位总线的字节掩码是4位,64位DDR是8位。转换不对,写数据就会错位。我曾经因为这个bug,写进去的数据读出来全是乱的。

好了,存储控制器的内部架构就聊到这儿。模块划分、命令队列、数据通路,这三块搞清楚了,剩下的就是细节打磨了。


公众号:蓝海资料掘金营,微信deep3321