第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 实战验证
代码写完了,怎么验证呢?
我建议你分三步走:
- 仿真验证:用Vivado或ModelSim跑仿真,写一个简单的testbench,模拟ARM的读写时序。确认每个寄存器的读写都正确。
- 上板测试:把bitstream下载到FPGA,用串口或JTAG连上ARM。先跑一个简单的读写测试,确认MMIO能正常工作。
- LED闪烁:写一个循环,让LED以1Hz的频率闪烁。这是最直观的验证方式。如果LED能正常闪烁,说明整个链路都通了。
一个小技巧:在调试阶段,我习惯在FPGA侧加一个计数器,每100ms翻转一次LED。这样即使ARM还没跑起来,你也能确认FPGA侧的时钟和复位是正常的。这个习惯帮我排除了很多低级错误。
4.7 扩展思考
这个GPIO控制器虽然简单,但它的架构可以扩展到更复杂的外设。比如:
- 增加中断功能:当输入引脚变化时,产生中断给ARM
- 增加PWM功能:用硬件产生PWM波形,减轻ARM负担
- 增加滤波功能:对输入信号做去抖处理
这些扩展,本质上都是在AXI-Lite总线上挂更多的寄存器。你只要把地址空间规划好,剩下的就是逻辑设计的事了。
好了,这一章的内容就到这。下一章,我们会做一个更复杂的外设——定时器。到时候你会看到,同样的AXI-Lite接口,怎么复用到不同的功能模块上。