做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或者用更简单的APB?AXI4的优势在于突发和多个未完成事务,但SPI控制器本身就是寄存器级别的操作,突发几乎没有意义。APB确实更简单,不过很多现成的SoC总线环境里,AXI转APB的桥还需要额外例化,不如直接做AXI4-Lite来得干净。另外,如果整个系统里根本没有CPU,你也可以用一段简单的Verilog测试序列来驱动这个AXI4-Lite从口,本质上就是一个可综合的“寄存器写工具”,我后面在仿真和调试部分会详细讲这个用法。
2. 寄存器地图与总线握手设计:把SPI塞进AXI4-Lite
2.1 寄存器映射:如何用一组寄存器表达一次SPI传输
整个IP的核心是一个寄存器堆,所有控制信息都通过AXI4-Lite写入。我这张寄存器表是实际项目里反复调整过的版本,整体思路是:控制类寄存器、配置类寄存器、数据类寄存器、状态类寄存器分开排布,这样驱动代码好写,也方便在调试器里逐个查看。
| 地址偏移 | 名称 | 读写属性 | 功能说明 |
|---|---|---|---|
| 0x00 | CTRL | RW | 总使能、单次/连续模式选择、软复位 |
| 0x04 | CLK_DIV | RW | SPI时钟分频系数,总线时钟半分频后作为SCLK |
| 0x08 | SPI_CFG | RW | CPOL、CPHA、位宽、MSB/LSB、CS极性 |
| 0x0C | CS_CTRL | RW | CS地址选择、CS拉低延时、帧间隔延时 |
| 0x10 | TX_DATA | W | 发送数据寄存器,写入即启动SPI发送 |
| 0x14 | RX_DATA | R | 接收数据寄存器,读完自动清除 |
| 0x18 | STATUS | RW | 忙标志、发送FIFO空、接收FIFO空、完成标志 |
| 0x1C | FIFO_CNT | R | 发送/接收FIFO深度计数 |
| 0x20 | IRQ_MASK | RW | 中断屏蔽寄存器,按位控制哪个事件可以产生中断 |
| 0x24 | IRQ_STATUS | RW | 中断状态寄存器,写1清零 |
我最开始没有单独的CS_CTRL寄存器,CS延时是固定写死的8个SCLK周期,结果适配一颗需要超长CS建立时间的EEPROM时完全拉不住,只好回头补了这个寄存器。所以如果你自己在设计,CS延时、帧间隔延时限一定要做可配置的,这个在后面连续传输章节会细说。
2.2 AXI4-Lite写通道和读通道的实践细节
AXI4-Lite的接口看起来简单,但手写起来比想象中容易出错。我的做法是把写通道拆成两个独立的状态机:一个管写,一个管读,两者互不干扰。写通道的状态机流程是:等待AWVALID和WVALID同时有效,锁存地址和数据,然后根据地址译码产生寄存器写使能,之后返回写响应BRESP=OKAY并拉高BVALID。读通道类似:等待ARVALID,锁存地址,执行读数据,拉高RVALID并返回RRESP=OKAY。
这里有一个重要的细节:写响应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非空时拉高start;
- CS_SETUP状态:先拉低CS,启动CS建立延时计数器,延时结束进入下一个状态;
- BIT_TRANSFER状态:按位传送。每来一个SCLK沿,根据CPHA决定在哪个沿移出MOSI、在哪个沿采样MISO;
- CS_HOLD状态:一帧数据传完,拉高CS(单次模式)或保持CS低电平(连续模式并还有数据要发);
- 回到IDLE,或者连续模式下直接回到CS_SETUP发起下一帧。
值得说明的是,我把CS_SETUP和CS_HOLD做成独立状态,而不是用简单的“先拉低CS延时再发数据”的组合逻辑,原因是为了复用。在连续模式中,每帧之间的帧间隔也需要延时,这个延时和初始的CS建立延时是同一个计数器,只是目标值不同,状态机结构统一之后逻辑会非常好写。
状态机的每个动作都跟SCLK分频计数器的相位对齐:在SCLK上升沿和下降沿分别执行不同的移位和采样操作。千万不要用“延时多少个总线时钟”来模拟SCLK沿,那样时间精度完全取决于分频倍数,很容易和外设要求的采样窗口打架。
3.3 移位寄存器和采样点:CPOL/CPHA如何影响数据
SPI协议里CPOL决定空闲时SCLK的电平,CPHA决定数据采样发生在哪一类边沿。很多新手在这里容易搞混,我直接列了一个表,方便对照设计:
| CPOL | CPHA | 数据建立/发送沿 | 数据采样/接收沿 | 空闲SCLK电平 |
|---|---|---|---|---|
| 0 | 0 | SCLK下降沿 | SCLK上升沿 | 低电平 |
| 0 | 1 | SCLK上升沿 | SCLK下降沿 | 低电平 |
| 1 | 0 | SCLK下降沿 | SCLK上升沿 | 高电平 |
| 1 | 1 | SCLK上升沿 | SCLK下降沿 | 高电平 |
在设计移位逻辑时,采集沿和输出沿是两套独立逻辑。输出逻辑:根据CPHA配置,在SCLK的发送沿把发送移位寄存器最高位移到MOSI;发送移位寄存器本身在发送沿之后更新。接收逻辑:在采样沿把MISO采样进接收移位寄存器,采样沿用时钟边沿检测产生一个单脉冲。
这里有个容易踩的坑:CPHA=1时,第一个bit是在CS拉低后、第一个SCLK边沿来之前就需要放到MOSI上的。也就是说CS拉低到第一个SCLK有效沿之间,MOSI上必须先有这个bit的值,而不是等SCLK到了才去推数据。我在实现时,利用CS_SETUP状态的最后一个周期,提前把第一个发送bit准备好,这样CPHA=1模式才能正常工作。这个问题我后面调试部分还会再提到,因为它真的很隐蔽。
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只是简单地把所有数据都塞进FIFO,SPI控制器就连续发,往往会造成外设无响应。
我的设计里,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四种位宽,每个位宽下跑一遍CPOL=0/1与CPHA=0/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看到写响应的时候,内部传输已经启动。
第三个是CPHA=1的首bit丢失。前面提到过,CPHA=1时第一个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函数库,让团队里不熟悉底层的人也能快速上手。