3. 第一个AXI-Lite从机外设:使用Vivado IP Packager创建自定义AXI-Lite外设
好,到了真正动手的时候了。前面两章我们把AXI总线的概念和Zynq的地址映射理了一遍,现在该亲手做一个属于自己的AXI-Lite从机外设了。说实话,我第一次用IP Packager的时候也踩了不少坑,生成出来的模板代码看着挺唬人,其实核心逻辑就那么几块。今天我们就把它拆开揉碎,讲清楚。
3.1 为什么需要自定义AXI-Lite外设?
你想想看,Zynq开发里最频繁的操作是什么?无非就是PS往PL写几个控制字,或者从PL读回几个状态值。比如你控制一个LED的亮灭、读取一个按键的电平、设置一个PWM的占空比——这些场景用AXI-Lite再合适不过了。
AXI-Lite的特点很明确:
- 轻量级:不支持突发传输,一次只传一个数据
- 简单:地址对齐,数据位宽固定(通常是32位)
- 够用:控制类寄存器操作,完全够用
我在项目中遇到过有人非要用AXI-Full去传一个32位的配置字,结果把互联逻辑搞得特别复杂。其实没必要,选对总线类型,能省一半的调试时间。
3.2 Vivado IP Packager 使用流程
Vivado里创建自定义IP的流程,说白了就三步:
- 创建IP工程:用IP Packager向导生成模板
- 修改逻辑:在模板里加上你自己的寄存器逻辑
- 打包发布:把修改后的IP封装成.xci文件,供顶层工程调用
嗯,这里要注意一个细节:IP Packager生成的模板里,寄存器读写逻辑是完整的,你只需要关注用户逻辑部分,也就是slv_reg0、slv_reg1这些寄存器怎么跟你的硬件接口对接。
3.3 寄存器映射与地址分配
这是整个章节的核心。AXI-Lite外设的寄存器映射,说白了就是PS端通过地址总线访问PL内部寄存器的对应关系。Vivado的IP Packager会帮你生成默认的地址分配,但你需要理解它背后的逻辑。
我们来看一个典型的寄存器映射表:
| 偏移地址 | 寄存器名称 | 位宽 | 访问类型 | 描述 |
|---|---|---|---|---|
| 0x00 | SLV_REG0 | 32 | R/W | 控制寄存器(如LED使能) |
| 0x04 | SLV_REG1 | 32 | R/W | 配置寄存器(如PWM周期) |
| 0x08 | SLV_REG2 | 32 | R | 状态寄存器(如按键值) |
| 0x0C | SLV_REG3 | 32 | R/W | 数据寄存器(通用) |
为什么偏移地址是0x00、0x04、0x08、0x0C?因为每个寄存器占4字节(32位),地址按字对齐。这个规则是AXI协议强制要求的,不能改。
关键点:PS端访问这些寄存器时,基地址由Vivado的地址编辑器分配。比如你给这个外设分配了0x43C0_0000的基地址,那么SLV_REG0的实际地址就是0x43C0_0000,SLV_REG1是0x43C0_0004,以此类推。
3.4 模板代码解读:核心逻辑就这几行
IP Packager生成的Verilog代码里,最核心的是下面这段寄存器写逻辑。我把它简化出来:
// 寄存器写逻辑
always @(posedge S_AXI_ACLK)
begin
if (S_AXI_ARESETN == 1'b0)
begin
slv_reg0 <= 32'h00000000;
slv_reg1 <= 32'h00000000;
slv_reg2 <= 32'h00000000;
slv_reg3 <= 32'h00000000;
end
else
begin
if (S_AXI_WVALID && S_AXI_AWVALID && S_AXI_WREADY && S_AXI_AWREADY)
begin
case (S_AXI_AWADDR[ADDR_LSB+OPT_MEM_ADDR_BITS:ADDR_LSB])
2'h0 : slv_reg0 <= S_AXI_WDATA;
2'h1 : slv_reg1 <= S_AXI_WDATA;
2'h2 : slv_reg2 <= S_AXI_WDATA;
2'h3 : slv_reg3 <= S_AXI_WDATA;
endcase
end
end
end
这段代码干了什么?说白了就是:当写地址有效且写数据有效时,根据地址的低2位(因为每个寄存器间隔4字节,所以地址低2位就是寄存器编号),把数据写入对应的寄存器。
读逻辑更简单,就是根据读地址把对应寄存器的值放到读数据总线上:
// 寄存器读逻辑
always @(*)
begin
case (S_AXI_ARADDR[ADDR_LSB+OPT_MEM_ADDR_BITS:ADDR_LSB])
2'h0 : reg_data_out <= slv_reg0;
2'h1 : reg_data_out <= slv_reg1;
2'h2 : reg_data_out <= slv_reg2;
2'h3 : reg_data_out <= slv_reg3;
default : reg_data_out <= 32'h00000000;
endcase
end
3.5 地址分配:Vivado地址编辑器怎么用?
当你把自定义IP添加到Block Design后,需要手动分配地址。操作路径是:
- 在Block Design中点击Address Editor标签
- 找到你的IP实例,右键选择Assign Address
- Vivado会自动分配一个未使用的地址范围
默认情况下,Vivado会给每个AXI-Lite从机分配64KB的地址空间(0x0000~0xFFFF)。但实际上你的外设可能只用了4个寄存器(16字节),剩下的地址空间都是空的。不过别担心,AXI协议会处理未映射地址的访问,返回一个错误响应(DECERR)。
我个人习惯是:手动把地址范围改成跟实际寄存器数量匹配的最小值。比如4个寄存器只需要0x00~0x0F共16字节,我就把地址范围设成4K(0x000~0xFFF),这样地址空间利用率更高,也方便后续扩展。
3.6 实战:从模板到第一个可用外设
我们来走一遍完整的流程,从创建IP到在PS端读写寄存器:
- 创建IP工程:Tools → Create and Package New IP → 选择AXI-Lite模板
- 修改寄存器逻辑:在模板的slv_reg0上接一个LED输出,slv_reg1接一个按键输入
- 打包IP:Tools → Package IP → 选择输出路径
- 在顶层工程中使用:IP Catalog → 右键 → Add IP → 找到你的自定义IP
- 分配地址:Address Editor → Assign Address
- 生成Bitstream:导出硬件描述文件(.xsa)
- PS端编程:用Xil_Out32和Xil_In32函数读写寄存器
PS端的代码示例:
#include "xil_io.h"
#define MY_IP_BASE_ADDR 0x43C00000
#define LED_REG_OFFSET 0x00
#define KEY_REG_OFFSET 0x04
// 写LED寄存器(点亮LED)
Xil_Out32(MY_IP_BASE_ADDR + LED_REG_OFFSET, 0x01);
// 读按键寄存器
u32 key_value = Xil_In32(MY_IP_BASE_ADDR + KEY_REG_OFFSET);
就这么简单。你写0x01到LED寄存器,PL那边的LED就亮了;你读按键寄存器,就能拿到按键的电平值。
3.7 本章核心知识结构
下面这张图把整个流程串起来了,从IP创建到PS读写,一目了然:
3.8 避坑指南
最后,我把这些年做AXI-Lite外设时遇到的典型问题列出来,你遇到类似情况时可以快速定位:
- 寄存器写不进去:检查S_AXI_WVALID和S_AXI_AWVALID的握手时序,大概率是ready信号没拉高
- 读回来的值全是0:检查读地址是否对齐,或者寄存器复位值是不是0
- 地址分配冲突:两个IP不能共用同一个地址范围,Vivado会报错,但有时手动分配时容易忽略
- PS端崩溃:访问了未映射的地址,AXI总线返回DECERR,PS端如果没有异常处理就会挂掉
嗯,这一章的内容就到这里。你按照上面的流程走一遍,应该能在半小时内跑通第一个自定义AXI-Lite外设。记住,核心就三件事:模板生成、寄存器逻辑修改、地址分配。搞懂了这三块,后面的复杂外设都是同样的套路。