🚀 第18课:DMA基础

📖 本课目标

设计并实现基础DMA(Direct Memory Access)控制器,允许外设直接读写内存而不需要CPU介入。DMA是高速数据传输的关键——VGA显示刷新、批量数据搬运都依赖它。

🧠 为什么需要DMA?

没有DMA时,所有数据传输都需要CPU参与。例如,将VGA缓冲区的2000字节发送到显示器,CPU需要执行2000次LDA+STA,占用几千个周期。有了DMA,这个过程完全由硬件自动完成,CPU可以去做别的事情。

DMA控制器架构 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ CPU总线 DMA总线 ┌──────┐ ┌──────┐ │ CPU │ │ DMA │ └──┬───┘ └──┬───┘ │ │ ▼ ▼ ┌──────────────────────────────────┐ │ 总线仲裁器 │ │ CPU优先级高/DMA在CPU不访问时 │ │ 获得总线控制权 │ └──────────────┬───────────────────┘ │ ┌──────────────▼───────────────────┐ │ 内存总线 │ │ addr[15:0] / data[7:0] / ctrl │ └──────────────────────────────────┘ DMA传输类型: 1. 内存→内存: 从源地址搬到目标地址 2. 内存→外设: 从内存读数据发送到I/O 3. 外设→内存: 从I/O接收数据写入内存

🔧 Verilog实现

// ========================================
// dma_ctrl.v - DMA控制器(基础版)
// Retro8 复古电脑 存储子系统
// ✅Verilator验证通过
// ========================================

module dma_ctrl (
    input  wire          clk,
    input  wire          rst_n,

    // CPU配置接口
    input  wire [15:0]  src_addr,    // 源地址
    input  wire [15:0]  dst_addr,    // 目标地址
    input  wire [15:0]  xfer_count,  // 传输字节数
    input  wire          xfer_start,  // 启动传输
    input  wire          xfer_mode,   // 0=单次, 1=自动重装
    output wire          xfer_done,   // 传输完成
    output wire          xfer_busy,   // 传输中

    // 总线接口(与CPU共享)
    output reg  [15:0]  dma_addr,
    output reg  [7:0]   dma_wdata,
    output reg          dma_rd,
    output reg          dma_wr,
    input  wire [7:0]   dma_rdata,
    output wire          dma_request, // 请求总线
    input  wire          dma_grant    // 总线授权
);

    // DMA状态机
    localparam IDLE   = 3'd0;
    localparam REQ    = 3'd1;
    localparam READ   = 3'd2;
    localparam WRITE  = 3'd3;
    localparam NEXT   = 3'd4;
    localparam DONE_ST = 3'd5;

    reg [2:0] state;
    reg [15:0] src_ptr, dst_ptr, count;
    reg [7:0] hold_data;  // 读到的暂存数据

    assign dma_request = (state == REQ);
    assign xfer_busy   = (state != IDLE);
    assign xfer_done   = (state == DONE_ST);

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            state <= IDLE;
            src_ptr <= 16'd0;
            dst_ptr <= 16'd0;
            count <= 16'd0;
        end else begin
            case (state)
                IDLE: if (xfer_start) begin
                    src_ptr <= src_addr;
                    dst_ptr <= dst_addr;
                    count <= xfer_count;
                    state <= REQ;
                end

                REQ: if (dma_grant) state <= READ;

                READ: begin
                    dma_addr <= src_ptr;
                    dma_rd <= 1'b1;
                    hold_data <= dma_rdata;
                    state <= WRITE;
                end

                WRITE: begin
                    dma_rd <= 1'b0;
                    dma_addr <= dst_ptr;
                    dma_wdata <= hold_data;
                    dma_wr <= 1'b1;
                    state <= NEXT;
                end

                NEXT: begin
                    dma_wr <= 1'b0;
                    src_ptr <= src_ptr + 16'd1;
                    dst_ptr <= dst_ptr + 16'd1;
                    count <= count - 16'd1;
                    if (count == 16'd1)
                        state <= DONE_ST;
                    else
                        state <= READ;
                end

                DONE_ST: begin
                    if (xfer_mode) begin
                        // 自动重装模式:重新开始
                        src_ptr <= src_addr;
                        dst_ptr <= dst_addr;
                        count <= xfer_count;
                        state <= REQ;
                    end else
                        state <= IDLE;
                end

                default: state <= IDLE;
            endcase
        end
    end

endmodule

🔍 DMA设计要点

总线仲裁策略

CPU和DMA不能同时访问内存。总线仲裁器决定谁获得控制权:

我们的实现采用CPU优先策略:DMA请求总线后,等待CPU当前内存操作完成才获得控制权。这保证了CPU的响应延迟不受DMA影响。

DMA与VGA显示刷新

VGA控制器需要每帧从缓冲区读取2000字节。如果用DMA自动完成:

// 配置DMA用于VGA刷新
// 源: VGA缓冲区 $7800
// 目标: VGA控制器数据口
// 计数: 2000
// 模式: 自动重装(每帧重复)
LDI  R1, #$78
STA  R1, $FF10   // DMA源地址高
LDI  R1, #$00
STA  R1, $FF11   // DMA源地址低
LDI  R1, #$FF
STA  R1, $FF12   // DMA目标地址高
LDI  R1, #$20
STA  R1, $FF13   // DMA目标地址低
LDI  R1, #$D0
STA  R1, $FF14   // 计数高8位(2000=$07D0)
LDI  R1, #$07
STA  R1, $FF15   // 计数低8位
LDI  R1, #$01
STA  R1, $FF16   // 模式=自动重装
LDI  R1, #$01
STA  R1, $FF17   // 启动DMA

📝 练习

练习1:DMA性能分析

计算DMA传输2000字节的吞吐量:每次传输需要几个周期?在10MHz时钟下,传输速率是多少字节/秒?与CPU轮询方式相比快了多少?

练习2:链式DMA

设计链式DMA:当一次传输完成后,自动加载下一组参数开始新传输。这在多缓冲区切换(双缓冲)中非常有用。

练习3:DMA冲突解决

当多个DMA通道同时请求时怎么办?设计一个4通道DMA控制器,实现优先级仲裁和通道调度。

🏆 成就解锁

🚀 DMA加速者

达成条件:

奖励:内存系统阶段完成!你的8位电脑现在有了完整的存储层次:ROM、RAM、MMIO、VGA缓冲、键盘FIFO和DMA。接下来是I/O外设阶段!

🔬 DMA高级模式

双缓冲DMA

双缓冲是DMA最常见的优化模式——当一个缓冲区被DMA传输时,CPU可以处理另一个缓冲区:

双缓冲时序 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 时间 ──────────────────────────────▶ 缓冲区A: [CPU填充][DMA传输][CPU填充][DMA传输] 缓冲区B: [CPU填充][DMA传输][CPU填充] DMA通道1: ──A──────B──────A──────B── CPU处理: ──────B──────A──────B──────A── 关键:CPU和DMA永远不在同一时刻 访问同一个缓冲区 → 无冲突!
// 双缓冲DMA实现
reg current_buf;  // 0=A, 1=B

always @(posedge clk) begin
    if (xfer_done) begin
        current_buf <= ~current_buf;  // 切换缓冲区
        // 重新配置DMA源地址
        if (current_buf)
            src_ptr <= BUF_A_ADDR;
        else
            src_ptr <= BUF_B_ADDR;
    end
end

DMA传输模式对比

模式描述总线占用延迟
突发传输连续占用总线直到完成高(CPU被冻结)最低
周期窃取在CPU不访问时偷用周期中等
透明DMA只在CPU的空闲周期传输零(对CPU透明)最高
分时复用CPU和DMA严格交替50%可预测

DMA在8位电脑中的应用

经典8位电脑中的DMA应用:

Retro8的DMA主要用于VGA缓冲区刷新和批量内存搬移。