☰
FPGA通用SPI收发模块:基于AXI4-Lite接口的Verilog设计
2026/10/19 5:32:08 网站建设 项目流程

FPGA里写SPI接口,大部分工程师第一反应是“这不简单吗,拉几个GPIO模拟一下就行”。但等你真的接过几个项目就会发现,SPI外设五花八门,有的要一字多字节、有的要切换模式、有的时钟频率还得动态调,每次重写一套代码纯属重复劳动,而且很容易踩时序的坑。这次分享的verilog SPI通用收发模块,就是冲着“一套代码通吃各种SPI外设”这个目标去的,核心是SPI收发逻辑,外面套了一个AXI4-Lite从机接口,方便挂在片上总线上给处理器控制和访问。我实际在Xilinx的FPGA上验证过,逻辑清晰、移植性强,适合做IP核集成或者当学习模板。

1. 项目整体设计与思路拆解

1.1 为什么执着于把SPI模块做成“通用型”

实际项目里最烦的事情不是写SPI时序,而是SPI外设的种类太多了。同样是SPI接口,Flash芯片一般要求高时钟、支持多字节连续读;ADC/DAC芯片通常字长是16位、要关心转换完成标志;传感器类芯片动不动要和寄存器地址绑定;还有一些老式外设,时序要求里带着奇奇怪怪的要求,比如CS拉高后要等多久才能发起下一次传输。

如果每次都从头写代码,一来浪费时间,二来很容易在新项目里踩之前踩过的坑。通用型模块的思路是把变化的部分全部参数化:数据位宽可变、时钟极性/相位可选、分频系数可配置、持续片选时间可调。剩下的核心收发逻辑就固定下来,变成一套稳定可靠的状态机。这样遇到新外设时,最多改改参数、配配寄存器,核心代码不需要动。这就是“通用”两个字的实际意义。

1.2 选择AXI4-Lite接口的两个现实理由

可能有人疑问,一个小小SPI模块为什么非得上AXI4-Lite这种“正式”总线接口?它跟我自己拉几个寄存器地址、用简单的接口控制有什么区别?这个问题的答案跟应用场景绑定。

如果你只是在自己写的简单逻辑里用SPI,那确实不需要AXI4-Lite。但这个模块的目标定位是可复用IP,尤其是在Zynq这类带硬核处理器的平台上,处理器要通过总线访问外设寄存器、发送数据、读取结果,AXI4-Lite是事实标准。在Vivado的Block Design里,一个带AXI4-Lite接口的IP可以直接被处理器访问,不需要你手工去对地址、拉信号。

从软件开发角度看,AXI4-Lite接口意味着你可以直接写C语言驱动,通过内存映射的方式操作SPI模块寄存器,跟操作普通单片机寄存器一样方便。经过AXI总线的跨时钟域处理后,软件侧只需要关心读写地址和数据,完全不需要深究FPGA内部的时序细节。这个价值在项目联调阶段尤其明显——省去了一堆调试总线的麻烦。

1.3 模块架构与寄存器规划

整个模块从内部结构上划分成三个部分:AXI4-Lite从机接口(负责跟总线打交道)、寄存器堆(负责参数配置和数据缓冲)、SPI核心收发引擎(负责对外时序)。三者各自独立、通过内部信号连接,逻辑复杂度被拆解掉了,排错时也方便定位问题。

寄存器规划方面,我预留了这几路关键的:

寄存器名称偏移地址位宽功能
控制寄存器0x0032启动传输、软复位、中断使能
配置寄存器0x0432分频系数、CPOL/CPHA、字长
发送数据寄存器0x0832写入待发送的数据
接收数据寄存器0x0C32读取接收到的数据
状态寄存器0x1032忙标志、发送完成、接收有效

每路寄存器都有明确的读写规则,避免出现“写完数据没触发发送”这种很尴尬的乌龙。控制寄存器的bit0用于触发一次传输,bit1是软复位,bit2使能中断;配置寄存器里的分频系数和模式选择都集中在一起,方便软件一次性配置好。

2. SPI协议核心细节与Verilog实现要点

2.1 四种SPI工作模式避坑梳理

SPI通信里最基础但最容易搞乱的就是极性CPOL和相位CPHA。当年我在某个项目里因为CPHA配反了,导致从设备读到的数据全部错位,排查了一整天才反应过来。这两种位组合直接决定数据采样的时刻和空闲时钟电平,绝对不能想当然。

CPOLCPHA空闲时钟电平数据采样沿典型外设
00低上升沿多数Flash
01低下降沿部分ADC
10高下降沿部分传感器
11高上升沿SPI EEPROM常见

模块实现中,我在发送数据、采样数据时分别根据CPOL和CPHA生成对应的边沿事件信号,这样核心逻辑里只需要在正确的时钟沿处理数据即可。这四个模式在很多外设上不是随便选一个就能用,一定要去看datasheet的时序图,确认它是“上升沿采样”还是“下降沿采样”,再对照上表把CPOL和CPHA定下来。

2.2 参数化设计与时钟分频配置

参数化是通用性的基础。我定义了几个顶层参数:数据位宽DATA_WIDTH默认8位,支持4到32位;分频系数DIV_WIDTH决定分频寄存器位宽;还可以通过参数选择是否启用FIFO缓冲。

分频逻辑直接用计数器实现。假设输入时钟是100MHz,要产生5MHz的SPI时钟,分频系数就是100/5/2=10。因为SPI时钟是半周期翻转一次,所以内部计数器对输入时钟计数到分频系数的一半就翻转SPI时钟一次。分频系数来自配置寄存器,软件可以运行时动态修改,不需要重新编译,这点在时钟频率需要频繁切换的场景特别好用。

补一个我在参数化设计上的心得:宁可参数多一些,也不要把设计写“死”。比如说CS拉高后等待时间、字节间延迟,这些参数看起来无关紧要,但在跟某些“脾气差”的外设对接时,没有它们就只能干瞪眼。所以我把这些支持也都做成参数或者寄存器可控项。

2.3 收发状态机设计与关键代码

状态机是整个SPI核心模块的“心脏”。我设计的状态机分成五个状态:IDLE、SETUP、TRANSFER、FINISH、WAIT_CS_HIGH。IDLE状态等待启动信号和配置就绪;SETUP状态做一些预处理,比如拉低CS、准备SCLK极性;TRANSFER状态按位收发数据;FINISH状态处理收尾;WAIT_CS_HIGH保证CS释放后的最小间隔。

下面给出TRANSFER状态的核心代码片段,这是整个模块最实质的部分:

// SPI核心状态机:TRANSFER状态按位收发 always @(posedge sclk_int or negedge rst_n) begin if (!rst_n) begin bit_cnt <= 0; rdata_shift <= 0; busy <= 1'b0; end else if (spi_cs_n_internal) begin // CS无效时复位位计数和忙碌标志 bit_cnt <= 0; busy <= 1'b0; end else if (sclk_rising_edge && (bit_cnt < DATA_WIDTH)) begin // 上升沿采样数据(对应CPOL=0/CPHA=0等模式) rdata_shift <= {rdata_shift[DATA_WIDTH-2:0], miso}; bit_cnt <= bit_cnt + 1'b1; end else if (sclk_falling_edge && (bit_cnt < DATA_WIDTH)) begin // 下降沿更新输出数据(对应CPOL=0/CPHA=0等模式) mosi <= tdata_shift[DATA_WIDTH-1]; tdata_shift <= {tdata_shift[DATA_WIDTH-2:0], 1'b0}; end end

关键点在于采样沿和更新沿必须分开处理,避免同一时刻既采样又更新导致亚稳态。从设备在下降沿锁存数据、在上升沿输出数据的话,你的采样时刻就必须避开输出变化瞬间。这就是CPHA相位定采样沿的根本原因。

2.4 时序约束与跨时钟域兜底处理

AXI4-Lite接口工作在处理器总线的时钟域里,SPI接口工作在自己分频出来的时钟域里。通常SPI时钟比系统时钟慢,而且频率还会动态变化,这两个时钟域之间直接传数据肯定要出问题。我在这里用了异步FIFO来兜底,写侧接AXI总线时钟域,读侧接SPI时钟域,数据跨时钟域传输就变得安全了。

除了FIFO之外,寄存器配置也要做同步处理。AXI写进来的配置值,不能直接给SPI核心逻辑用,需要打两拍做同步。如果配置寄存器的变化发生在SPI通信过程中,可能造成时钟沿抖动或者模式突变,所以在控制寄存器里留了一个“传输进行中”标志位,软件在修改配置前可以先查询这个标志,确认SPI空闲了再动配置。

3. AXI4-Lite接口设计与寄存器详解

3.1 AXI4-Lite信号映射与读写通道

AXI4-Lite协议比AXI全协议简单得多,它只支持单拍传输,每次读写一个地址,数据位宽32位。主要的写通道有AWADDR/AWVALID/AWREADY、WDATA/WVALID/WREADY、BVALID/BREADY,读通道有ARADDR/ARVALID/ARREADY、RDATA/RVALID/RREADY。

这个模块做从机接口,所以代码里主要任务是响应主机发来的读写请求。握手信号的核心规则是:VALID和READY同时拉高时,这一次传输才真正完成。我用的设计技巧是,把地址通道和数据通道分开处理,写请求到来时先锁存地址,数据到来时再锁存数据,然后交给寄存器堆更新逻辑。这样即使地址和数据不是同时到达(AXI协议允许这种延迟),也能正确完成写入。

3.2 寄存器读写逻辑的实现细节

写逻辑比较简单,核心判断是地址译码和写使能。读逻辑需要多注意一步:AXI读是组合逻辑输出数据,地址在ARVALID有效时就应该把对应寄存器的值放在RDATA上,而且要通过延迟逻辑保证在RVALLID有效之前数据已经稳定。

以下是读逻辑的代码示意:

// AXI4-Lite读数据通道 reg [31:0] rdata_reg; always @(*) begin case (araddr[3:0]) 4'h0: rdata_reg = ctrl_reg; 4'h1: rdata_reg = cfg_reg; 4'h2: rdata_reg = tx_reg; 4'h3: rdata_reg = rx_reg; 4'h4: rdata_reg = status_reg; default: rdata_reg = 32'h0; endcase end

有个坑是读操作也要考虑数据有效性,不能看到地址就立刻把数据传出去,而应该等ARVALID和ARREADY握手成功后再把数据放到总线上。实际设计里我用了一个写延迟逻辑:当写地址握手完成时置位一个内部标志,等待合适时机再拉高RVALID,部件间的数据通路始终不能靠“时序碰运气”。

3.3 从AXI写到SPI发送的完整数据链路

这个模块最核心的调用逻辑,是软件通过AXI写入发送数据后,SPI核心如何在正确的时机把数据推出去。具体链路是这样的:

  1. 软件向控制寄存器写1(启动传输),同时写发送数据寄存器;
  2. AXI写逻辑检测到启动信号后,将发送数据寄存器的内容搬运到异步FIFO写侧;
  3. SPI核心一旦检测到FIFO非空,自动拉低CS、产生SCLK、按位输出数据;
  4. 接收数据同步移入移位寄存器,收满一个完整字长后写入异步FIFO读侧,回填到接收数据寄存器;
  5. 状态寄存器更新忙标志和接收有效位,软件轮询或中断获取结果。

这条链路里最容易出问题的是“启动传输”和“写发送数据”两个动作的顺序。有的软件工程师会先把数据写了,再启动传输,或者反过来。我在控制寄存器里做了一个互锁:检测到启动信号时,必须同步从发送数据寄存器取值。如果软件先启动再写数据,取到的值可能还是旧数据,所以我建议软件端的操作顺序一定是“先写数据寄存器,再写控制寄存器”。

4. 仿真验证与工程实践记录

4.1 Testbench设计与分阶段验证流程

写Testbench的时候,我把验证过程分成三个阶段:SPI核心单模块验证、AXI接口单模块验证、顶层集成验证。

第一个阶段验证SPI时序最直接的方式是挂一个仿真模型。我写了一个支持四种模式的SPI从设备模型,主从对接后检查发送数据和接收数据是否一致。这个阶段里我故意配置成各种模式组合,比如模式0下发送0xA5、模式2下发送0x5A8位数据,通过检查仿真波形来确认时序正确。

第二个阶段验证AXI接口,用一个简单的AXI主机模型发起读写操作,检查各寄存器的读写结果。重点验证地址边界和未使用地址的读写行为,确保不会出现“地址越界写坏了别的寄存器”这种低级错误。

第三个阶段就是顶层全链路验证,把UART输出也挂上去,在仿真环境里跑一遍“处理器写数据→SPI发送→外部回环→接收→软件读取”的完整流程。这一步通过了,上板基本心里就有底。

4.2 上板调试与实测记录

我把这个模块打包成IP,在Zynq平台上跑了起来,用PS端的C代码控制PL端的SPI模块,接了一个SPI Flash芯片做读写测试。

第一次上板就碰到一个问题:读取Flash的ID时,数据完全不对。排查后发现问题不在SPI核心逻辑,而在时序约束——我在XDC文件里没有对SPI的时钟信号做约束,导致综合工具把SCLK的路径布线布得很乱,信号完整性问题在低速时看不出来,速度提高到10MHz后就暴露了。

重新加上时钟约束、把关键路径设为伪路径后,问题解决。这给我一个很深的教训:哪怕SPI频率不高,只要它是一个由内部逻辑产生的时钟,时序约束就必须跟上。

实测下来,模块工作在50MHz系统时钟下,SPI时钟配置为12.5MHz,读写Flash ID正常,页面写入和读取数据比对完全一致。用逻辑分析仪抓出来的波形和数据手册上的时序图完美对齐,看到这个结果的时候还是觉得挺踏实的。

4.3 常见问题速查与工程化经验

这里把实际开发过程中遇到的典型问题和排查思路整理成表格,方便以后复用:

现象可能原因排查方法
读回数据全为0CPHA配置反了,采样沿不对对照datasheet时序图检查模式配置
数据错位或高低位颠倒位序配置错误确认外设是MSB First还是LSB First
传输偶尔失败跨时钟域同步不到位检查FIFO异步复位和同步逻辑
高速时数据不稳定时序约束缺失添加SCLK时钟约束,检查路径延迟
软件写寄存器无响应AXI地址对齐错误确认地址总线的偏移量是否匹配模块

工程化方面有几个非常值得注意的经验。第一,所有寄存器都要有复位默认值,而且默认值要选一个大概率能工作的配置组合,比如模式0、8位字长、分频系数适中,这样上电后不配置也能跑起来,方便快速验证。第二,状态机编码尽量用独热码,虽然在综合资源上稍微多一点点,但调试时看状态一目了然,不用去查二进制编码表。第三,信号命名要统一,像spi_cs_n、sclk_int这类含语义的后缀,在波形图上排查问题能省大量时间。

这个模块后续还可以往很多方向扩展,比如加一个简单的命令解析器、把FIFO深度做大、支持DMA接口等。但从当前的使用效果来看,它已经能解决我手头绝大多数SPI外设对接需求了。做这类通用模块,最值钱的工作其实是把不同外设的共性抽象出来,再把差异用参数暴露出去,这份抽象的眼光比写代码本身难得多,也是我做完这个模块后最大的收获。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询