🌉 第15课:内存映射I/O

📖 本课目标

实现内存映射I/O(MMIO)桥接模块,将I/O设备映射到内存地址空间$FF00-$FFFF。MMIO是CPU与外设通信的桥梁——通过普通的内存读写指令就能控制外设。

🧠 什么是内存映射I/O?

内存映射I/O(Memory-Mapped I/O)将I/O设备的寄存器映射到内存地址空间。CPU不需要特殊的I/O指令,用普通的LDA/STA就能读写设备寄存器。这种设计简化了CPU,也是现代ARM架构的标准做法。

MMIO地址映射表 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ $FF00 ┌──────────────────────┐ │ UART数据寄存器 │ R/W $FF01 │ UART状态寄存器 │ R │ bit0: TX空 │ │ bit1: RX就绪 │ $FF02 ├──────────────────────┤ │ 键盘数据寄存器 │ R $FF03 │ 键盘状态寄存器 │ R │ bit0: 有按键 │ $FF04 ├──────────────────────┤ │ 定时器计数低8位 │ R $FF05 │ 定时器计数高8位 │ R $FF06 │ 定时器控制寄存器 │ R/W │ bit0: 使能 │ │ bit1: 中断使能 │ │ bit2: 模式(0=oneshot│ │ 1=periodic) │ $FF07 ├──────────────────────┤ │ GPIO数据寄存器 │ R/W $FF08 │ GPIO方向寄存器 │ R/W │ 0=输入, 1=输出 │ $FF09 ├──────────────────────┤ │ 中断挂起寄存器 │ R $FF0A │ 中断屏蔽寄存器 │ R/W │ bit0: 定时器中断 │ │ bit1: 键盘中断 │ │ bit2: UART中断 │ $FF0B ├──────────────────────┤ │ VGA光标位置低8位 │ R/W $FF0C │ VGA属性寄存器 │ R/W │ bit3:0=前景色 │ │ bit7:4=背景色 │ $FF0D ├──────────────────────┤ │ 系统控制寄存器 │ R/W │ bit0: 复位 │ │ bit1: NMI │ │ bit2: ROM映射 │ $FF0F └──────────────────────┘

🔧 Verilog实现

// ========================================
// mmio.v - 内存映射I/O桥接模块
// Retro8 复古电脑 存储子系统
// ✅Verilator验证通过
// ========================================

module mmio (
    input  wire          clk,
    input  wire          rst_n,
    input  wire [15:0]  addr,
    input  wire [7:0]   cpu_data_out,
    input  wire          cpu_rd,
    input  wire          cpu_wr,

    // UART接口
    input  wire [7:0]   uart_rx_data,
    input  wire          uart_tx_empty,
    input  wire          uart_rx_ready,
    output wire [7:0]   uart_tx_data,
    output wire          uart_tx_start,

    // 键盘接口
    input  wire [7:0]   kbd_data,
    input  wire          kbd_valid,

    // 定时器接口
    input  wire [15:0]  timer_count,
    output wire [7:0]   timer_ctrl,

    // GPIO接口
    output wire [7:0]   gpio_out,
    input  wire [7:0]   gpio_in,
    output wire [7:0]   gpio_dir,

    // CPU读回数据
    output reg  [7:0]   cpu_data_in,
    output wire          mmio_select  // 地址在MMIO范围
);

    assign mmio_select = (addr >= 16'hFF00);

    // I/O寄存器
    reg [7:0] gpio_data_reg;
    reg [7:0] gpio_dir_reg;
    reg [7:0] timer_ctrl_reg;
    reg [7:0] vga_cursor_lo;
    reg [7:0] vga_attr_reg;

    // 写操作解码
    wire [7:0] port = addr[7:0];

    always @(posedge clk) begin
        if (!rst_n) begin
            gpio_data_reg <= 8'd0;
            gpio_dir_reg  <= 8'd0;
            timer_ctrl_reg <= 8'd0;
            vga_cursor_lo <= 8'd0;
            vga_attr_reg  <= 8'h0F;  // 白色前景,黑色背景
        end else if (cpu_wr && mmio_select) begin
            case (port)
                8'h00: begin // UART TX
                end
                8'h06: timer_ctrl_reg <= cpu_data_out;
                8'h07: gpio_data_reg <= cpu_data_out;
                8'h08: gpio_dir_reg  <= cpu_data_out;
                8'h0B: vga_cursor_lo <= cpu_data_out;
                8'h0C: vga_attr_reg  <= cpu_data_out;
                default: ;
            endcase
        end
    end

    // 读操作解码
    always @(*) begin
        if (cpu_rd && mmio_select) begin
            case (port)
                8'h00: cpu_data_in = uart_rx_data;
                8'h01: cpu_data_in = {6'd0, uart_rx_ready, uart_tx_empty};
                8'h02: cpu_data_in = kbd_data;
                8'h03: cpu_data_in = {7'd0, kbd_valid};
                8'h04: cpu_data_in = timer_count[7:0];
                8'h05: cpu_data_in = timer_count[15:8];
                8'h06: cpu_data_in = timer_ctrl_reg;
                8'h07: cpu_data_in = gpio_in;
                8'h08: cpu_data_in = gpio_dir_reg;
                8'h0B: cpu_data_in = vga_cursor_lo;
                8'h0C: cpu_data_in = vga_attr_reg;
                default: cpu_data_in = 8'hFF;
            endcase
        end else begin
            cpu_data_in = 8'hFF;
        end
    end

    // 输出连接
    assign uart_tx_data  = cpu_data_out;
    assign uart_tx_start = cpu_wr && mmio_select && (port == 8'h00);
    assign timer_ctrl   = timer_ctrl_reg;
    assign gpio_out     = gpio_data_reg;
    assign gpio_dir     = gpio_dir_reg;

endmodule

🔍 MMIO设计要点

MMIO vs 独立I/O(Port I/O)

特性MMIO独立I/O (x86)
指令LDA/STAIN/OUT (专用)
地址空间统一内存空间独立I/O空间
优点简单,所有指令可用I/O不占内存空间
缺点占用内存地址需要额外指令和引脚
典型架构ARM, MIPS, 6502x86 (但也支持MMIO)

📝 练习

练习1:添加新的I/O端口

在MMIO映射中添加一个SPI控制器($FF10-$FF13),包含数据、时钟、片选和控制寄存器。

练习2:原子位操作

MMIO寄存器经常需要位操作(设置/清除某一位)。但读-改-写不是原子的。设计一个支持位设置的MMIO寄存器:写入时,bit7=1表示设置,bit7=0表示清除,bit6:0是位掩码。

练习3:I/O访问时序

有些外设比CPU慢,需要插入等待状态。修改MMIO模块,当访问慢速设备时自动插入1-3个等待周期。

🏆 成就解锁

🌉 I/O桥接者

达成条件:

奖励:你架起了CPU与外设的桥梁!CPU现在可以"看到"外部世界——键盘、串口、GPIO,一切皆可控制。

🔬 MMIO访问的特殊考虑

副作用读写

MMIO寄存器与普通RAM不同——读写可能有副作用:

这意味着编译器不能对MMIO访问做优化(如合并两次读、消除"无用"读等)。在C语言中,MMIO寄存器必须声明为volatile

// C语言中的MMIO寄存器声明
volatile uint8_t* uart_data  = (uint8_t*)0xFF00;
volatile uint8_t* uart_status = (uint8_t*)0xFF01;

// 等待UART发送完成
while (!(*uart_status & 0x01));  // 等待TX空
*uart_data = 'A';              // 发送字符

MMIO访问的原子性

读-改-写操作(如设置GPIO的某一位)在MMIO中可能不安全:

// 不安全:中断可能在LDA和STA之间发生
LDA  R1, $FF07    // 读GPIO
ORI  R1, #0x01    // 设置bit0
// ← 中断可能在这里修改了GPIO!
STA  R1, $FF07    // 写回GPIO(可能覆盖中断的修改)

// 安全方法1:关中断
CLI              // 关中断
LDA  R1, $FF07
ORI  R1, #0x01
STA  R1, $FF07
SEI              // 开中断

// 安全方法2:使用位操作寄存器
// (如果硬件支持,如ARM的BSRR/BRR寄存器)

I/O空间分配的最佳实践