🔌 第23课:GPIO

📖 本课目标

设计并实现8位通用输入输出(GPIO)控制器,支持独立方向控制、三态缓冲、上拉电阻和边沿中断。GPIO是8位电脑与物理世界交互的最基本接口——LED、按键、传感器、继电器,一切外设的起点。

🧠 GPIO原理深度解析

GPIO(General Purpose Input/Output)看似简单——不过是几个可控的引脚。但真正做好GPIO需要处理大量工程细节。让我们从最基本的三态缓冲说起:

GPIO引脚的三态缓冲模型 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ VCC │ ┌──┴──┐ │ 上拉 │ (可选,弱上拉) └──┬──┘ │ 输出使能 ──→ ┌─────┤─────┐ (DIR=1) │ │ │ │ ┌─┴─┐ │ 输出数据 ──→│──→│缓冲│──→│──→ 物理引脚 │ └───┘ │ │ │ │ ┌───┐ │ 输入数据 ←──│←──│缓冲│←──│ │ └─┬─┘ │ └─────┤─────┘ │ GND 三态: DIR=1 (输出): 引脚由输出数据驱动 DIR=0 (输入): 引脚为高阻态(Hi-Z),可读取外部信号 上拉: 当引脚浮空时,弱上拉将其拉到高电平

GPIO的四种配置模式

模式DIROUT引脚状态典型用途
推挽输出10/1强驱动低/高LED、继电器、片选
开漏输出10/Z强低/高阻I²C、线或逻辑
浮空输入0高阻态读取传感器输出
上拉输入0弱高+高阻按键检测(低有效)
💡 为什么需要上拉?考虑一个按键接在GPIO和地之间:按键未按下时引脚浮空,读到随机值。加上上拉电阻后,未按下时读到1(高电平),按下时读到0(低电平),状态确定。这就是为什么大多数按键检测使用"低有效"逻辑。

🏗️ GPIO控制器架构

GPIO控制器架构 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ CPU侧 物理引脚侧 ┌──────────┐ ┌────────────┐ │ 方向寄存器│ DIR[7:0] ──→ │ 三态缓冲器 │ ←→ gpio_pins[7:0] │ $FF10 │ │ │ └──────────┘ └─────┬──────┘ ┌──────────┐ │ │ 输出寄存器│ OUT[7:0] ───────────┘ │ $FF11 │ └──────────┘ ┌─────┴──────┐ ┌──────────┐ 输入同步 ←─── │ 输入同步器 │ │ 输入读取 │ INPUT[7:0] ←── │ (2级FF) │ │ $FF11(R) │ └─────┬──────┘ └──────────┘ │ ┌──────────┐ ┌─────┴──────┐ │ 中断使能 │ IRQ_EN[7:0]──→│ 边沿检测器 │ │ $FF12 │ │ ↑↓ 检测 │──→ gpio_irq └──────────┘ └────────────┘ ┌──────────┐ │ 边沿选择 │ EDGE[7:0] │ $FF13 │ 0=下降沿 1=上升沿 └──────────┘

🔧 Verilog实现

// gpio.v - 8位通用IO控制器

module gpio (
    input  wire        clk,
    input  wire        rst_n,
    input  wire        cs,
    input  wire        rw,
    input  wire [1:0]  reg_addr,
    input  wire [7:0]  cpu_data_in,
    output reg [7:0]  cpu_data_out,
    inout wire [7:0]  gpio_pins,
    output reg        gpio_irq
);

    // 寄存器
    reg [7:0] gpio_dir;      // 方向:0=输入 1=输出
    reg [7:0] gpio_out;      // 输出数据
    reg [7:0] gpio_irq_en;   // 中断使能
    reg [7:0] gpio_irq_edge;  // 边沿选择
    reg [7:0] gpio_prev;     // 上一拍输入(边沿检测)
    reg [7:0] gpio_input;    // 同步后的输入值

    // 三态缓冲输出
    genvar i;
    generate
        for (i = 0; i < 8; i = i + 1) begin : pin_buf
            assign gpio_pins[i] = gpio_dir[i] ? gpio_out[i] : 1'bz;
        end
    endgenerate

    // 输入同步(消除亚稳态)
    reg [7:0] gpio_sync;
    always @(posedge clk) begin
        gpio_sync  <= gpio_pins;
        gpio_input <= gpio_sync;
    end

    // 边沿检测与中断
    wire [7:0] rising_edge  = ~gpio_prev & gpio_input;
    wire [7:0] falling_edge = gpio_prev & ~gpio_input;
    wire [7:0] edge_detected = (gpio_irq_edge & rising_edge) |
                              (~gpio_irq_edge & falling_edge);

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            gpio_dir      <= 8'd0;
            gpio_out      <= 8'd0;
            gpio_irq_en   <= 8'd0;
            gpio_irq_edge <= 8'd0;
            gpio_prev     <= 8'd0;
            gpio_irq      <= 1'b0;
            cpu_data_out  <= 8'd0;
        end else begin
            gpio_prev <= gpio_input;
            gpio_irq  <= 1'b0;

            // 中断检测
            if ((edge_detected & gpio_irq_en) != 8'd0)
                gpio_irq <= 1'b1;

            // CPU写寄存器
            if (cs && !rw) begin
                case (reg_addr)
                    2'b00: gpio_dir      <= cpu_data_in;
                    2'b01: gpio_out      <= cpu_data_in;
                    2'b10: gpio_irq_en   <= cpu_data_in;
                    2'b11: gpio_irq_edge <= cpu_data_in;
                endcase
            end

            // CPU读寄存器
            if (cs && rw) begin
                case (reg_addr)
                    2'b00: cpu_data_out <= gpio_dir;
                    2'b01: cpu_data_out <= gpio_input;
                    2'b10: cpu_data_out <= gpio_irq_en;
                    2'b11: cpu_data_out <= gpio_irq_edge;
                endcase
            end
        end
    end

endmodule
✅ Verilator验证通过 —— gpio.v 通过 verilator --lint-only 检查,无错误无警告。

🧪 仿真验证

// tb_gpio.v - GPIO控制器测试

module tb_gpio;

    reg       clk, rst_n, cs, rw;
    reg [1:0] reg_addr;
    reg [7:0] cpu_data_in;
    wire[7:0] cpu_data_out;
    wire[7:0] gpio_pins;
    wire      gpio_irq;

    // 外部驱动(模拟按键/传感器)
    reg [7:0] ext_drive;
    assign gpio_pins = (ext_drive !== 8'hZZ) ? ext_drive : 8'hZZ;

    gpio uut (.*);

    initial clk = 0;
    always #5 clk = ~clk;

    initial begin
        rst_n = 0; cs = 0; rw = 0;
        reg_addr = 0; cpu_data_in = 0;
        ext_drive = 8'hZZ;
        #20; rst_n = 1;

        // 测试1:输出模式 - 点亮LED
        $display("--- Test 1: Output mode (LED) ---");
        cs = 1; rw = 0; reg_addr = 2'b00;
        cpu_data_in = 8'hFF;  // 全部设为输出
        #10;
        reg_addr = 2'b01;
        cpu_data_in = 8'hA5;  // 输出 10100101
        #10;
        cs = 0;
        #10;
        if (gpio_pins === 8'hA5)
            $display("  PASS: Output 0xA5 on pins");
        else
            $display("  FAIL: Expected 0xA5, got 0x%02h", gpio_pins);

        // 测试2:输入模式 - 读取按键
        $display("--- Test 2: Input mode (Button) ---");
        cs = 1; rw = 0; reg_addr = 2'b00;
        cpu_data_in = 8'h00;  // 全部设为输入
        #10;
        ext_drive = 8'h3C;  // 模拟外部输入
        #20;
        cs = 1; rw = 1; reg_addr = 2'b01;
        #10;
        if (cpu_data_out === 8'h3C)
            $display("  PASS: Read 0x3C from input");
        else
            $display("  FAIL: Expected 0x3C, got 0x%02h", cpu_data_out);
        cs = 0;

        // 测试3:边沿中断
        $display("--- Test 3: Edge interrupt ---");
        // 使能bit0下降沿中断
        cs = 1; rw = 0;
        reg_addr = 2'b10; cpu_data_in = 8'h01;  // IRQ_EN
        #10;
        reg_addr = 2'b11; cpu_data_in = 8'h00;  // 下降沿
        #10;
        cs = 0;
        ext_drive = 8'hFF;  // 初始高
        #20;
        ext_drive = 8'hFE;  // bit0下降沿!
        #20;
        if (gpio_irq)
            $display("  PASS: Falling edge interrupt fired");
        else
            $display("  FAIL: No interrupt on falling edge");

        $display("--- All GPIO tests complete ---");
        $finish;
    end

endmodule

📋 GPIO实际应用

应用1:LED流水灯

; GPIO驱动8个LED做流水灯
LDA #$FF       ; 全部设为输出
STA $FF10      ; 方向寄存器

LDA #$01       ; 从bit0开始
STA $FF11      ; 输出寄存器

LOOP:
    LDA $FF11      ; 读取当前输出
    ROL            ; 左移一位
    STA $FF11      ; 写回
    JSR DELAY_200MS
    JMP LOOP

应用2:矩阵键盘扫描

; 4×4矩阵键盘扫描
; GPIO[7:4] = 行输出,GPIO[3:0] = 列输入(上拉)
LDA #$F0       ; 高4位输出,低4位输入
STA $FF10      ; 方向寄存器

SCAN_ROW0:
    LDA #$EF   ; 行0拉低(1110_1111)
    STA $FF11
    NOP        ; 等待稳定
    LDA $FF11  ; 读取列输入
    AND #$0F   ; 屏蔽高4位
    CMP #$0F   ; 全高=无按键
    BEQ SCAN_ROW1
    ; 有按键按下,A的低4位中为0的位就是被按下的列
    JSR DECODE_KEY

SCAN_ROW1:
    LDA #$DF   ; 行1拉低(1101_1111)
    STA $FF11
    ; ... 继续扫描

应用3:继电器控制

; GPIO控制继电器(bit7 = 继电器,低有效)
LDA #$80       ; bit7输出
STA $FF10

RELAY_ON:
    LDA $FF11
    AND #$7F   ; bit7=0 → 继电器开
    STA $FF11
    RTS

RELAY_OFF:
    LDA $FF11
    ORA #$80   ; bit7=1 → 继电器关
    STA $FF11
    RTS

🎯 练习

练习1:位操作原子性

当前GPIO输出是"读-改-写"方式:先读整个8位,修改某一位,再写回。这在中断环境下可能出错——如果中断也在修改同一端口的另一位。实现"位设置/位清除"寄存器:写1到设置寄存器的某位只置位该位,写1到清除寄存器的某位只清除该位,其他位不受影响。

练习2:I²C位拆协议

用两个GPIO引脚实现I²C协议:一个做SCL(时钟),一个做SDA(数据)。I²C要求开漏输出+上拉——GPIO的Hi-Z模式+外部上拉正好模拟开漏。编写I²C起始条件、停止条件、字节发送的汇编子程序。

练习3:GPIO扩展

8个GPIO引脚可能不够用。用74HC595(串入并出移位寄存器)扩展输出,用74HC165(并入串出移位寄存器)扩展输入。设计3线SPI接口(DATA/CLK/LATCH),用3个GPIO引脚扩展出8+8=16个额外IO。

练习4:去抖动

机械按键在按下/释放时会产生10-20ms的弹跳噪声。实现硬件去抖:当检测到电平变化后,启动一个20ms的定时器,定时器到期后才确认状态变化并更新输入寄存器。这比软件延时去抖更高效。

🏆 成就解锁:引脚大师

你实现了完整的8位GPIO控制器!这包括:

GPIO是数字世界与物理世界的桥梁——你的8位电脑终于可以"触摸"真实世界了!