第4章 GPIO外设实战:通过AXI-Lite总线在FPGA侧实现GPIO控制器,ARM侧通过MMIO读写控制LED

好,咱们今天来点真家伙。

前面几章讲了理论、讲了架构,你可能觉得有点虚。这一章,我们直接上手做一个完整的GPIO控制器。说白了,就是让ARM核通过AXI-Lite总线,去读写FPGA侧的一个寄存器,然后控制LED亮灭。

我在项目中做过不下十种类似的GPIO控制器,有的用在工业控制,有的用在消费电子。嗯,这里面的坑,我踩过不少。今天一并讲给你听。

4.1 为什么用AXI-Lite?

你可能会问:控制个LED而已,至于用AXI这么复杂的总线吗?

其实,AXI-Lite是AXI总线的“轻量版”。它去掉了突发传输、去掉了乱序传输,只保留了最基本的读写操作。说白了,就是给外设寄存器用的。

我个人习惯把AXI-Lite比作“挂号信”——每次只送一个数据,但保证送到。而完整的AXI就像“快递专车”,一次能拉一车货。

对于GPIO这种简单外设,AXI-Lite完全够用。而且,它让你熟悉了AXI总线的接口规范,后面做更复杂的外设时,上手就快了。

核心要点:AXI-Lite总线接口信号只有地址、读写数据、读写使能,没有突发长度、没有ID信号。非常适合寄存器映射类外设。

4.2 FPGA侧:GPIO控制器的RTL实现

我们先写Verilog代码。这个GPIO控制器,功能很简单:

  • 支持32位数据宽度
  • 每个bit对应一个GPIO引脚
  • 写寄存器控制输出,读寄存器获取输入
  • 支持方向控制(输入/输出)

来看代码:

module axi_gpio #
(
  parameter C_S_AXI_DATA_WIDTH = 32,
  parameter C_S_AXI_ADDR_WIDTH = 4
)
(
  // AXI-Lite 从机接口
  input  wire                      S_AXI_ACLK,
  input  wire                      S_AXI_ARESETN,
  input  wire [C_S_AXI_ADDR_WIDTH-1:0] S_AXI_AWADDR,
  input  wire                      S_AXI_AWVALID,
  output wire                      S_AXI_AWREADY,
  input  wire [C_S_AXI_DATA_WIDTH-1:0] S_AXI_WDATA,
  input  wire [C_S_AXI_DATA_WIDTH/8-1:0] S_AXI_WSTRB,
  input  wire                      S_AXI_WVALID,
  output wire                      S_AXI_WREADY,
  output wire [1:0]                S_AXI_BRESP,
  output wire                      S_AXI_BVALID,
  input  wire                      S_AXI_BREADY,
  input  wire [C_S_AXI_ADDR_WIDTH-1:0] S_AXI_ARADDR,
  input  wire                      S_AXI_ARVALID,
  output wire                      S_AXI_ARREADY,
  output wire [C_S_AXI_DATA_WIDTH-1:0] S_AXI_RDATA,
  output wire [1:0]                S_AXI_RRESP,
  output wire                      S_AXI_RVALID,
  input  wire                      S_AXI_RREADY,
  
  // GPIO 端口
  inout  wire [31:0]               gpio_io
);

  // 内部寄存器
  reg [31:0] gpio_data_out;
  reg [31:0] gpio_dir;
  wire [31:0] gpio_data_in;
  
  // 方向控制:1为输出,0为输入
  // 地址映射:
  // 0x00: 数据输出寄存器 (GPIO_DATA_OUT)
  // 0x04: 方向控制寄存器 (GPIO_DIR)
  // 0x08: 数据输入寄存器 (GPIO_DATA_IN) - 只读
  
  // 写操作逻辑
  reg awready, wready, bvalid;
  reg [1:0] bresp;
  reg [C_S_AXI_ADDR_WIDTH-1:0] awaddr;
  
  always @(posedge S_AXI_ACLK or negedge S_AXI_ARESETN) begin
    if (!S_AXI_ARESETN) begin
      awready <= 1'b0;
      wready <= 1'b0;
      bvalid <= 1'b0;
      bresp <= 2'b00;
      gpio_data_out <= 32'h00000000;
      gpio_dir <= 32'h00000000;
    end else begin
      // 地址握手
      if (S_AXI_AWVALID && !awready) begin
        awready <= 1'b1;
        awaddr <= S_AXI_AWADDR;
      end else begin
        awready <= 1'b0;
      end
      
      // 数据握手
      if (S_AXI_WVALID && !wready) begin
        wready <= 1'b1;
      end else begin
        wready <= 1'b0;
      end
      
      // 写响应
      if (awready && wready && !bvalid) begin
        bvalid <= 1'b1;
        bresp <= 2'b00; // OKAY
        case (awaddr[3:2])
          2'b00: gpio_data_out <= S_AXI_WDATA;
          2'b01: gpio_dir <= S_AXI_WDATA;
          default: bresp <= 2'b10; // SLVERR
        endcase
      end else if (S_AXI_BREADY && bvalid) begin
        bvalid <= 1'b0;
      end
    end
  end
  
  // 读操作逻辑
  reg arready, rvalid;
  reg [1:0] rresp;
  reg [C_S_AXI_DATA_WIDTH-1:0] rdata;
  
  always @(posedge S_AXI_ACLK or negedge S_AXI_ARESETN) begin
    if (!S_AXI_ARESETN) begin
      arready <= 1'b0;
      rvalid <= 1'b0;
      rresp <= 2'b00;
      rdata <= 32'h00000000;
    end else begin
      if (S_AXI_ARVALID && !arready) begin
        arready <= 1'b1;
        case (S_AXI_ARADDR[3:2])
          2'b00: rdata <= gpio_data_out;
          2'b01: rdata <= gpio_dir;
          2'b10: rdata <= gpio_data_in;
          default: begin
            rdata <= 32'h00000000;
            rresp <= 2'b10;
          end
        endcase
      end else begin
        arready <= 1'b0;
      end
      
      if (arready && !rvalid) begin
        rvalid <= 1'b1;
      end else if (S_AXI_RREADY && rvalid) begin
        rvalid <= 1'b0;
      end
    end
  end
  
  // 三态缓冲控制
  genvar i;
  generate
    for (i = 0; i < 32; i = i + 1) begin : gpio_tri
      assign gpio_io[i] = gpio_dir[i] ? gpio_data_out[i] : 1'bz;
    end
  endgenerate
  
  assign gpio_data_in = gpio_io;
  
  // 输出赋值
  assign S_AXI_AWREADY = awready;
  assign S_AXI_WREADY = wready;
  assign S_AXI_BRESP = bresp;
  assign S_AXI_BVALID = bvalid;
  assign S_AXI_ARREADY = arready;
  assign S_AXI_RDATA = rdata;
  assign S_AXI_RRESP = rresp;
  assign S_AXI_RVALID = rvalid;

endmodule

经验之谈:注意看地址译码部分。我用了awaddr[3:2]来选寄存器,而不是awaddr[1:0]。因为AXI-Lite的地址是以字节为单位的,但数据宽度是32位,所以地址要按4字节对齐。这个细节,我第一次做的时候搞反了,结果读写全乱套。

4.3 地址映射与寄存器布局

这个GPIO控制器,我设计了三个寄存器。地址映射如下:

地址偏移 寄存器名称 读写属性 描述
0x00 GPIO_DATA_OUT 读写 数据输出寄存器,写该寄存器控制LED亮灭
0x04 GPIO_DIR 读写 方向控制寄存器,1为输出,0为输入
0x08 GPIO_DATA_IN 只读 数据输入寄存器,读取外部引脚电平

你想想看,这个设计其实很灵活。如果你只想控制LED,那就把GPIO_DIR对应bit设为1,然后往GPIO_DATA_OUT写数据就行。如果你想读按键,那就把对应bit设为0,然后读GPIO_DATA_IN。

4.4 ARM侧:MMIO读写操作

FPGA侧搞定了,ARM侧怎么访问呢?

在ARM Linux系统中,外设寄存器是映射到内存地址空间的。我们通过/dev/mem或者mmap系统调用,就能直接读写这些物理地址。

来看一段C代码示例:

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>

#define GPIO_BASE_ADDR 0x43C00000  // AXI-GPIO的基地址
#define MAP_SIZE 0x10000           // 映射大小

// 寄存器偏移
#define GPIO_DATA_OUT 0x00
#define GPIO_DIR      0x04
#define GPIO_DATA_IN  0x08

int main() {
    int fd;
    void *gpio_base;
    volatile unsigned int *gpio_data_out;
    volatile unsigned int *gpio_dir;
    volatile unsigned int *gpio_data_in;
    
    // 打开/dev/mem
    fd = open("/dev/mem", O_RDWR | O_SYNC);
    if (fd < 0) {
        printf("无法打开/dev/mem,请以root权限运行\n");
        return -1;
    }
    
    // 映射物理地址到用户空间
    gpio_base = mmap(NULL, MAP_SIZE, PROT_READ | PROT_WRITE,
                     MAP_SHARED, fd, GPIO_BASE_ADDR);
    if (gpio_base == MAP_FAILED) {
        printf("mmap失败\n");
        close(fd);
        return -1;
    }
    
    // 计算寄存器地址
    gpio_data_out = (volatile unsigned int *)(gpio_base + GPIO_DATA_OUT);
    gpio_dir = (volatile unsigned int *)(gpio_base + GPIO_DIR);
    gpio_data_in = (volatile unsigned int *)(gpio_base + GPIO_DATA_IN);
    
    // 设置GPIO0为输出
    *gpio_dir = 0x00000001;
    printf("方向寄存器已设置: 0x%08x\n", *gpio_dir);
    
    // 点亮LED
    *gpio_data_out = 0x00000001;
    printf("LED已点亮\n");
    
    sleep(2);
    
    // 熄灭LED
    *gpio_data_out = 0x00000000;
    printf("LED已熄灭\n");
    
    // 读取输入状态
    printf("输入寄存器值: 0x%08x\n", *gpio_data_in);
    
    // 清理
    munmap(gpio_base, MAP_SIZE);
    close(fd);
    
    return 0;
}

注意:访问物理内存需要root权限。运行程序时记得加sudo。另外,O_SYNC标志很重要,它保证读写操作不会被缓存,直接访问硬件寄存器。我曾经因为忘了加这个标志,调试了整整一个下午。

4.5 避坑指南

这部分是我最想跟你分享的。这些坑,我当年都踩过。

  • 地址对齐问题:AXI-Lite的地址必须是4字节对齐的。如果你用awaddr[1:0]做译码,那肯定出问题。正确的做法是用awaddr[3:2]
  • 握手时序:AXI-Lite的握手信号必须严格按照协议来。AW和W通道可以独立握手,但写响应B通道必须在AW和W都完成后才能发出。我见过有人把BVALID和WREADY同时拉高,结果仿真没问题,上板就挂。
  • 三态缓冲:FPGA内部的三态缓冲要小心使用。如果方向寄存器没初始化,默认是输入,那输出引脚就是高阻态。LED可能不亮,但也不会烧东西。但如果你把方向设为输出,然后又没给数据,那引脚状态是不确定的。
  • MMIO的缓存问题:ARM的Cache可能会缓存你的寄存器读写。所以一定要用volatile关键字,并且mmap时加上MAP_SHARED。在更严格的场景下,你还需要调用__iormb()__iowmb()来做内存屏障。

4.6 实战验证

代码写完了,怎么验证呢?

我建议你分三步走:

  1. 仿真验证:用Vivado或ModelSim跑仿真,写一个简单的testbench,模拟ARM的读写时序。确认每个寄存器的读写都正确。
  2. 上板测试:把bitstream下载到FPGA,用串口或JTAG连上ARM。先跑一个简单的读写测试,确认MMIO能正常工作。
  3. LED闪烁:写一个循环,让LED以1Hz的频率闪烁。这是最直观的验证方式。如果LED能正常闪烁,说明整个链路都通了。

一个小技巧:在调试阶段,我习惯在FPGA侧加一个计数器,每100ms翻转一次LED。这样即使ARM还没跑起来,你也能确认FPGA侧的时钟和复位是正常的。这个习惯帮我排除了很多低级错误。

4.7 扩展思考

这个GPIO控制器虽然简单,但它的架构可以扩展到更复杂的外设。比如:

  • 增加中断功能:当输入引脚变化时,产生中断给ARM
  • 增加PWM功能:用硬件产生PWM波形,减轻ARM负担
  • 增加滤波功能:对输入信号做去抖处理

这些扩展,本质上都是在AXI-Lite总线上挂更多的寄存器。你只要把地址空间规划好,剩下的就是逻辑设计的事了。

好了,这一章的内容就到这。下一章,我们会做一个更复杂的外设——定时器。到时候你会看到,同样的AXI-Lite接口,怎么复用到不同的功能模块上。