资讯动态

基于AXI4-Lite的FPGA通用SPI控制器IP设计:寄存器、RTL与调试实战

发布时间:2026/10/19 11:14:43 来源:尧图企业网站定制
做FPGA的应该都遇过这种尴尬想调一颗SPI接口的ADC或者传感器板子上明明有SPI控制器可那个控制器是写死在逻辑里的——位宽、CPOL/CPHA、分频系数全是常量改一次参数就得动一遍RTL重新综合、布局布线半小时过去了。我折腾过几个项目之后干脆花时间写了一个带AXI4-Lite接口的Verilog SPI通用收发模块定位非常明确一个专门用来调试和控制SPI外设的通用IP。这篇文章把这套IP的设计思路、寄存器规划、核心RTL实现、仿真验证和板级调试里踩过的坑全部摊开讲适合正在自己写SPI控制器、或者想给FPGA配一个可复用外设控制核的工程师参考。1. 为什么要做这个带AXI4-Lite接口的SPI控制器1.1 先说说写死的SPI模块有多难用很多FPGA工程师第一次接触SPI是在某个项目里直接写一个移位寄存器循环计数到8或者16输出一个SCLK把MOSI的数据推出去MISO的数据装进来。这种代码半小时能写完配合单颗外设时完全够用。问题是它不通用。换一颗外设它的CPOL/CPHA不一样或者要读的寄存器地址是24位的又或者外设要求CS拉低后必须等至少几百纳秒才能来第一个SCLK。这个时候写死的模块只能靠改代码来迁就而改动一个常量往往牵一发动全身。更麻烦的是一旦你想做“调试”——在线改一下波特率观察不同速率下外设回包的稳定性这种纯逻辑模块完全无能为力只能重新编译一次工程。我自己最早就是这么干的直到有一次为了适配一颗时钟极性相反、需要16位命令加16位数据的传感器连续改了三版RTL才下定决心与其每次围绕外设打补丁不如花几天时间做一个可控、可配、可复用的通用控制器。1.2 通用SPI控制器到底要通用什么在设计之前我先把这个IP的需求明确下来。所谓“通用”不是指功能越堆越多而是把SPI协议里那些可以变化的维度全部参数化通过寄存器在运行时配置。对我这个IP来说至少需要覆盖以下几个方面支持Master模式对外提供SCLK、MOSI、MISO和CS信号用于控制各类SPI从机外设数据位宽可配支持常见的8位、16位、24位、32位很多传感器命令是8位指令加16位数据位宽固定的话就得在软件里拼包非常痛苦SPI模式四选一也就是CPOL和CPHA可以自由组合这是跨外设适配最基本的要求波特率可配分频系数由寄存器决定方便调试时在线改变SCLK频率支持MSB优先和LSB优先可配某些外设的协议非常别扭偏偏要求LSB-first支持单次传输和连续传输连续模式能一次性发完FIFO里的所有数据中间CS不拉高适合读写Flash或批量配置寄存器。把这些维度都做成寄存器位之后整个外设适配就变成了“C语言写寄存器”的工作而不是“改Verilog重新综合”的工作。这才是调试和控制SPI外设的题中之义。1.3 为什么总线接口选了AXI4-Lite接口选型的理由其实很实际。首先它要能被CPU访问无论CPU是FPGA里例化的软核还是开发板上的ARM硬核AXI4-Lite都是标准的寄存器访问总线驱动起来很方便。其次AXI4-Lite虽然不支持突发但它的握手协议简单、响应机制完整做寄存器读写非常干净对于“CPU往寄存器里写一个数、读一个状态”这种场景是最合适的。有人可能会问为什么不直接上AXI4或者用更简单的APBAXI4的优势在于突发和多个未完成事务但SPI控制器本身就是寄存器级别的操作突发几乎没有意义。APB确实更简单不过很多现成的SoC总线环境里AXI转APB的桥还需要额外例化不如直接做AXI4-Lite来得干净。另外如果整个系统里根本没有CPU你也可以用一段简单的Verilog测试序列来驱动这个AXI4-Lite从口本质上就是一个可综合的“寄存器写工具”我后面在仿真和调试部分会详细讲这个用法。2. 寄存器地图与总线握手设计把SPI塞进AXI4-Lite2.1 寄存器映射如何用一组寄存器表达一次SPI传输整个IP的核心是一个寄存器堆所有控制信息都通过AXI4-Lite写入。我这张寄存器表是实际项目里反复调整过的版本整体思路是控制类寄存器、配置类寄存器、数据类寄存器、状态类寄存器分开排布这样驱动代码好写也方便在调试器里逐个查看。地址偏移名称读写属性功能说明0x00CTRLRW总使能、单次/连续模式选择、软复位0x04CLK_DIVRWSPI时钟分频系数总线时钟半分频后作为SCLK0x08SPI_CFGRWCPOL、CPHA、位宽、MSB/LSB、CS极性0x0CCS_CTRLRWCS地址选择、CS拉低延时、帧间隔延时0x10TX_DATAW发送数据寄存器写入即启动SPI发送0x14RX_DATAR接收数据寄存器读完自动清除0x18STATUSRW忙标志、发送FIFO空、接收FIFO空、完成标志0x1CFIFO_CNTR发送/接收FIFO深度计数0x20IRQ_MASKRW中断屏蔽寄存器按位控制哪个事件可以产生中断0x24IRQ_STATUSRW中断状态寄存器写1清零我最开始没有单独的CS_CTRL寄存器CS延时是固定写死的8个SCLK周期结果适配一颗需要超长CS建立时间的EEPROM时完全拉不住只好回头补了这个寄存器。所以如果你自己在设计CS延时、帧间隔延时限一定要做可配置的这个在后面连续传输章节会细说。2.2 AXI4-Lite写通道和读通道的实践细节AXI4-Lite的接口看起来简单但手写起来比想象中容易出错。我的做法是把写通道拆成两个独立的状态机一个管写一个管读两者互不干扰。写通道的状态机流程是等待AWVALID和WVALID同时有效锁存地址和数据然后根据地址译码产生寄存器写使能之后返回写响应BRESPOKAY并拉高BVALID。读通道类似等待ARVALID锁存地址执行读数据拉高RVALID并返回RRESPOKAY。这里有一个重要的细节写响应BVALID必须等寄存器真正写入之后才能拉高。曾经有人写出过一个版本BVALID和寄存器更新在同一拍CPU收到写响应后立刻去读状态寄存器结果读到的是旧值。严格来说AXI协议允许响应和写入有一定时序关系但为了调试友好我推荐的做法是写响应比寄存器更新晚半拍到一拍保证CPU看到BVALID时内部状态已经稳定。这个“额外的一拍”能在后续调试里省下大量查“为什么寄存器没写进去”的时间。2.3 字节使能和数据对齐AXI4-Lite的WSTRB是按字节的而SPI的位宽可能是8/16/24/32。CPU写TX_DATA时经常只写其中的一个字节比如只写8位数据。这时候寄存器的写使能必须和WSTRB配合否则就会出现“写一个8位数却把整个32位寄存器都覆盖了”的尴尬问题。实际上更稳妥的做法是内部所有数据寄存器都保持32位宽度写入时根据WSTRB标志决定更新哪个字节。读出时RX_DATA直接给32位全宽数据CPU根据当前SPI位宽去取有效位。位宽切换发生在SPI核心读取数据的时候而不是在AXI总线上转换这样总线侧逻辑只有一个32位寄存器的读写简洁也安全。2.4 地址译码的边界与保护地址译码不能只做“等于比较”一旦CPU因为驱动Bug写了一个越界地址没有保护的话会静默失败很难定位。我在IP里加了一个简单的默认响应如果地址不在寄存器映射范围内写通道返回SLVERR读通道也返回SLVERR。这样驱动调试的时候非法访问会直接在总线日志里暴露出来比闷声不响吞掉错误要舒服得多。3. 核心RTL实现时钟分频、状态机与移位逻辑的取舍3.1 SPI时钟分频器防止毛刺的第一道防线SPI的SCLK不能直接从系统时钟打一拍输出就算完那样会产生很窄的毛刺外设在采样时可靠性很差。我的实现是先用总线时钟做一个自由运行的分频计数器计数范围由CLK_DIV寄存器决定计数器在每个总线时钟沿累加翻转点就是SCLK的上升沿和下降沿。这里最难处理的是运行时修改分频系数。直接改计数器比较值可能会导致SCLK出现一个很短半个周期的脉冲外设就会误判。我的处理办法是CLK_DIV的更新只在SCLK处于低电平的半周期窗口内生效具体说分频值寄存器的有效值先锁存到一个shadow寄存器等到SCLK的低电平且计数器走到一半时再替换真正的比较值。这样切换系数时SCLK永远是从低电平开始重新分频避免窄脉冲。当初在板子上抓波形时我就见过SCLK中间的毛刺后来排查发现是分频值与比较器组合逻辑竞争导致的。把分频值的更新锁存到SCLK低半周期之后这个毛刺彻底消失。3.2 主状态机每个节拍该做什么SPI核心的主状态机是整个IP的中枢我设计的流程是IDLE状态等待启动信号。写TX_DATA寄存器或连续模式下FIFO非空时拉高startCS_SETUP状态先拉低CS启动CS建立延时计数器延时结束进入下一个状态BIT_TRANSFER状态按位传送。每来一个SCLK沿根据CPHA决定在哪个沿移出MOSI、在哪个沿采样MISOCS_HOLD状态一帧数据传完拉高CS单次模式或保持CS低电平连续模式并还有数据要发回到IDLE或者连续模式下直接回到CS_SETUP发起下一帧。值得说明的是我把CS_SETUP和CS_HOLD做成独立状态而不是用简单的“先拉低CS延时再发数据”的组合逻辑原因是为了复用。在连续模式中每帧之间的帧间隔也需要延时这个延时和初始的CS建立延时是同一个计数器只是目标值不同状态机结构统一之后逻辑会非常好写。状态机的每个动作都跟SCLK分频计数器的相位对齐在SCLK上升沿和下降沿分别执行不同的移位和采样操作。千万不要用“延时多少个总线时钟”来模拟SCLK沿那样时间精度完全取决于分频倍数很容易和外设要求的采样窗口打架。3.3 移位寄存器和采样点CPOL/CPHA如何影响数据SPI协议里CPOL决定空闲时SCLK的电平CPHA决定数据采样发生在哪一类边沿。很多新手在这里容易搞混我直接列了一个表方便对照设计CPOLCPHA数据建立/发送沿数据采样/接收沿空闲SCLK电平00SCLK下降沿SCLK上升沿低电平01SCLK上升沿SCLK下降沿低电平10SCLK下降沿SCLK上升沿高电平11SCLK上升沿SCLK下降沿高电平在设计移位逻辑时采集沿和输出沿是两套独立逻辑。输出逻辑根据CPHA配置在SCLK的发送沿把发送移位寄存器最高位移到MOSI发送移位寄存器本身在发送沿之后更新。接收逻辑在采样沿把MISO采样进接收移位寄存器采样沿用时钟边沿检测产生一个单脉冲。这里有个容易踩的坑CPHA1时第一个bit是在CS拉低后、第一个SCLK边沿来之前就需要放到MOSI上的。也就是说CS拉低到第一个SCLK有效沿之间MOSI上必须先有这个bit的值而不是等SCLK到了才去推数据。我在实现时利用CS_SETUP状态的最后一个周期提前把第一个发送bit准备好这样CPHA1模式才能正常工作。这个问题我后面调试部分还会再提到因为它真的很隐蔽。MSB/LSB优先切换也不是简单的移位方向反转。LSB-first模式下发送时应该先发最低位接收时采集到的第一bit拼接到接收寄存器的最低位然后左移。我当时是用一个由配置位选择的向量来索引当前发送位的位置虽然逻辑稍复杂一点但不会因为单纯反转移位方向而导致某些位序混乱。3.4 MOSI空闲电平与传输结束的保护SPI从机在读MOSI时如果发现CS还拉着、MOSI却悬空或者乱跳很可能会把多余的数据当成有效命令。为了避免这种问题我在IP里加了一个空闲电平保护逻辑在CS为高空闲时MOSI输出强制为配置的空闲电平在CS拉低之后、第一个有效bit准备好之前MOSI也保持为该电平直到移位器准备好数据再切换。在连续传输模式下如果FIFO临时空了IP不会立刻拉高CS那样会打断一个正在进行的连续事务而是保持SCLK停止、MOSI稳定在最后一个bit的电平上等待FIFO有新的数据再继续。这里关键的地方在于等待FIFO期间不要继续产生SCLK一旦FIFO出现数据就能无缝续传这对一些要求CS在整个读写流程中一直拉低的Flash类外设非常重要。4. 连续传输、CS延时与多从机扩展容易忽略的细节4.1 连续传输模式下帧间隔的意义连续传输不代表SCLK一直不停。实际上很多外设要求每帧之间CS保持低电平的持续时间不小于某个最小值比如某些ADC在转换期间不能接收新命令某些Flash在页编程间隙需要稳定时间。如果CPU只是简单地把所有数据都塞进FIFOSPI控制器就连续发往往会造成外设无响应。我的设计里CS_CTRL寄存器里有帧间隔延时的配置位。进入连续模式之后每发送完一帧数据状态机并不会立刻开始下一帧而是进入CS_HOLD状态由帧间隔计数器决定停几个SCLK周期再回到CS_SETUP继续发下一帧。这样软件只需要在初始化时设置好外设需要的间隔后续批量发送时完全不用关心时序。4.2 FIFO下溢与上溢保护连续模式依赖发送FIFO和接收FIFO。发送FIFO空时如果状态机正在处理一帧数据的中间是不能被打断的但一帧结束之后如果FIFO还是空上面说过要保持CS低电平并等待。接收FIFO满时也有讲究如果RX FIFO已经满而SPI核心还在采样这时应该暂停采样还是丢弃数据我的设计是暂停启动下一帧等待CPU读取接收FIFO腾出空间后再继续。这样数据才不会静默丢失。还有一点发送FIFO写入时用了完整的AXI握手逻辑CPU侧可以通过STATUS寄存器的“FIFO满”标志来避免反复写同一个数据寄存器导致被覆盖。驱动里最常用的方式是先查询FIFO计数再决定是否写TX_DATA而不是无条件连续写这个习惯能避免很多数据丢失问题。4.3 多从机与CS译码通用SPI控制器往往要挂不止一个从机。CS_CTRL寄存器里我用三个位编码CS地址然后通过译码器输出8路片选信号同一时间只有一路有效。这个设计的好处是软件切换从机只需要修改CS地址不需要重新配置波特率和CPOL/CPHA之外的部分。多从机还有一个容易翻车的点不同从机的SPI模式和码率可能不一样。好在我的IP把SPI_CFG和CLK_DIV放在寄存器层CPU可以在选择从机后重写这些寄存器但这是在总线时钟高速运行前提下做的切换从机时的配置动作必须发生在CS拉高状态下否则可能产生半个错误的SCLK。所以在驱动层我建议“配置→延时→操作”的流程配置完成后等几个总线时钟再发起传输让内部状态机稳定下来。4.4 中断与状态标志的取舍早期版本只有轮询状态的方案CPU要不断去读STATUS寄存器虽然能用但很浪费。后来我加了IRQ_STATUS和IRQ_MASK寄存器把“传输完成”“发送FIFO空”“接收FIFO非空”这几类事件做成可屏蔽的中断源。CPU侧可以只在接收FIFO非空时被唤醒读取数据大幅降低空转。这里有个经验中断状态寄存器一定要设计成“写1清零”而不是读清零。读清零的寄存器在CPU读取的同时如果产生了新的中断事件很容易丢失。写1清零虽然驱动代码里要额外写一次但可靠性高得多。4.5 跨时钟域总线时钟和SCLK的关系这个IP里AXI4-Lite接口的时钟和SPI内部逻辑时钟在大多数情况下是同一个时钟域——系统时钟。SCLK是分频产生的它本身只是那个时钟域里一条经过分频的信号不存在真正的跨时钟域问题。但如果你的SoC结构是AXI4-Lite跑在100MHz而SPI逻辑挂在另一个125MHz时钟下就需要在状态标志和FIFO读写上做异步处理。在真正同源时钟的场景下我反而会建议所有人尽量保持这个假设总线时钟与SPI核心时钟同源所有内部逻辑都在这个时钟沿上工作。这样你不需要异步FIFO不需要两级同步器处理忙标志调试和验证的复杂度会下降一大截。异步跨域只留给那些真正需要独立SPI时钟的系统中去考虑。5. 仿真与板级调试回环测试和ILA抓波形查出了什么5.1 仿真环境比起用例数量更重要的是SPI从机模型很多人在仿真SPI控制器时习惯用“直接观察信号波形”来判断这很累也不容易自动检查。我的做法是写了一个Verilog的SPI从机行为模型它根据配置好的CPOL/CPHA接收MOSI上的数据同时按照一个预设的响应序列回发MISO数据。有了这个模型仿真就可以做成自动比对CPU侧的AXI测试序列先通过总线写入控制寄存器和SPI_CFG然后写入TX_DATA等状态机完成后模型回发的数据应该出现在RX_DATA寄存器中仿真脚本自动检查是否匹配。这样跑一个用例跟跑一百个用例的代价差不了多少几十个用例合在一起覆盖掉全部CPOL/CPHA组合和位宽组合一天之内就能完成回归。5.2 值得写进约束文件的测试组合基于实际项目经验我觉得最值得优先覆盖的测试组合有这些8/16/24/32四种位宽每个位宽下跑一遍CPOL0/1与CPHA0/1的全部四种组合MSB-first和LSB-first各跑一遍连续模式下填充FIFO超过深度两倍的数据量验证帧间隔和CS保持延时是否符合预期随机在FIFO非空时修改分频系数验证SCLK不出现窄脉冲总线读写地址越界访问验证SLVERR响应。我自己一般还会加一个“传输中途CPU停止写入”的用例模拟FIFO下溢的情况这个在板级调试时经常出现提前在仿真里暴露是最好的。5.3 板级回环测试最快发现问题的三板斧上板调试时我第一件事永远是回环测试把MOSI和MISO在FPGA引脚上直接短接然后让IP自己发一串数据检查RX_DATA是不是完整收到了同样的一串。这一步能一次性验证移位方向、位宽、CPOL/CPHA配置和分频电路。回环测试有三点要注意。第一一定要用递增的数而不是全0或者全1否则根本分不清是数据传对了还是线直接短死的。比如连续发0x01、0x02、0x04、0x08这种序列回环能验证每一位的移位正确性。第二回环时SCLK频率不要一开始就拉到最高先用较低速率确认无误再逐步提频。第三用ILA抓波形时采样时钟要用系统时钟而不是SCLK因为SCLK作为采样时钟本身可能不稳定。如果回环都通过再接入真实外设。接入外设后最常见的问题是回环没问题但外设的数据不对。这往往是因为采样时刻和发送时刻相对外设的内部时序不对我会直接用ILA同时抓CS、SCLK、MOSI、MISO四条线对照外设手册里的时序图逐项核对。5.4 踩坑实录四个真实的问题与根因说几个我在调试这个IP时印象最深的问题每个都花了不少时间才定位。第一个是SCLK毛刺问题。前面提过分频系数在线修改时产生了半周期脉冲。表面现象是外设偶发抖动抓波形才发现SCLK上多了一个很窄的高电平。解决方法是把分频值更新放在SCLK低电平时段。第二个是写响应和状态同步错拍。刚开始做CPU驱动时写完TX_DATA立刻读STATUS经常读到“还在忙”导致数据发送错乱。抓总线才发现写响应来得太早状态机还没真正开始动作。解决办法是让AXI写响应在SPI状态机启动信号有效后一拍再返回保证CPU看到写响应的时候内部传输已经启动。第三个是CPHA1的首bit丢失。前面提到过CPHA1时第一个bit必须在第一个SCLK有效边沿前就在MOSI上准备好。我在初始版本里没有做这个提前准备首bit被外设采样成恒定电平数据头两位是错的。加上CS_SETUP状态的预备输出后问题消失。第四个是连续模式时外设无响应。当时用ILA抓CS发现CS低电平持续时间太长远超过外设手册要求的帧间隔导致外设进入了复位状态。查下来是帧间隔计数器的宽位溢出了十六位计数器在某个分频系数下不够用。修复之后把计数器位宽加到二十位这类问题就再没出现过。5.5 配合CPU裸机驱动的调试技巧最后补充一点驱动侧的实用技巧在CPU里写一个非常简单的寄存器读写函数然后利用调试器逐字节查看每个寄存器的实时值。排查问题时“配置寄存器写一遍然后读一遍回读对照”这个动作几乎能滤掉一半人的低级错误——地址偏移写错是最常见的坑。回环测试通过后可以写一个连续发送的C代码让IP按照递增序列连续发几千帧同时用逻辑分析仪统计实际发送的帧数和位错误率。这个测试跑一晚上如果零错误这套SPI控制器基本就可以放心交付给业务逻辑使用了。6. 从能用的IP到好用的IP还差什么6.1 加入中断和DMA能力我最初版本的IP只有轮询方式后来加了中断CPU的负载明显下降。如果你的应用需要大块数据交换比如读取大容量存储、批量采集ADC数据可以考虑再扩展一个AXI4-Stream的从接口配合DMA直接把RX_FIFO里的数据搬进内存CPU完全不参与数据通路。这样IP就能从“寄存器控制的控制器”升级为“数据通路引擎”。6.2 QSPI、双线模式与CRC很多新外设支持Quad SPI或者Dual SPI也就是同时用四根线或两根线传数据这样吞吐率能提升四倍或者两倍。只要把移位寄存器和引脚分配逻辑改成可配置的模式选择一个基础版SPI控制器就能自然扩展到QSPI。另一个值得做的就是CRC校验模块发送数据时自动算CRC接收时自动校验很多Flash和传感器协议都要求有了它上层软件能省掉大量软校验工作。6.3 参数化生成与IP打包通用IP如果每个项目都要手动改内部位宽、FIFO深度、CS数量那“通用”就打了折扣。比较理想的方式是用构建脚本管理参数比如用filelist加宏定义或者直接打包成标准IP核导出AXI4-Lite接口的寄存器描述文件。这样在集成到不同项目时只需要在脚本里填写参数就能自动生成对应位宽和FIFO深度的RTL做到“一处修改处处复用”。6.4 交付前的文档与仿真模型我后来总结出来一个IP能不能被团队其他人顺畅使用跟RTL代码写得好不好一样重要。交付时除了代码至少还要带上寄存器说明文档、验证平台包含SPI从机模型、以及一个可以直接跑的仿真用例。很多人刚开始写RTL时不愿意写文档但半年后再回去看自己的代码就会发现再简单的逻辑也需要注释和文档帮助回忆。我自己在实际使用中还有一个体会调试SPI这类串行协议最大的成本往往不是RTL本身而是“不知道现在线上到底发生了什么”。所以从一开始就把寄存器设计好、把状态标志做清晰、把回环测试当成标配比任何技巧都管用。我这个IP后来在很多项目里跑过最庆幸的始终是最开始就坚持了“全参数可配、总线标准、仿真自动比对”这三个原则。下一版如果再改我会优先把QSPI模式和DMA接口加上顺便把驱动从面包板式的寄存器操作封装成一套简单易用的C函数库让团队里不熟悉底层的人也能快速上手。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑