简介:一份基于FPGA的32位SPI控制器工程资源,面向嵌入式开发者与FPGA学习者,采用VHDL语言实现SPI主/从通信逻辑,适用于高速大位宽数据交换、外设扩展等场景。包内共91个文件,以Quartus工程配置文件(qpf/qsf)、VHDL源码(vhd)、仿真波形(vwf)及编译报告(qmsg/rpt/hdb)为主,并附Modelsim仿真目录、引脚约束文件等,便于完整复现项目环境。资源包仅927KB,轻量紧凑。目前已有143人学习下载。通过该工程,使用者可对照源码与波形深入理解SPI时序控制、时钟极性与相位配置、从设备选择机制;也可直接修改数据宽度、时钟参数或波特率设置,将控制器快速移植到自己的FPGA项目中,是学习或二次开发的高质量参考模板。
1. 32位SPI控制器:先弄清“32位”到底说的是什么
手头一颗标着“32位 SPI”的控制器,最容易被误解成“一次只能传 32 位”或者“传 8 位就浪费”。实际上,在绝大多数嵌入式项目里,这种控制器天天都在传 8 位帧,比如读写 GD25Q128E 这类 NOR Flash;而“32位”这个标签,横跨了芯片总线位宽、数据寄存器宽度和 SPI 帧格式三件互相独立的事。把这三件事混在一起,调试时就会看到一模一样的症状:第一个字节正确,后面全是乱码;或者读回来的数据永远差一个字节。这篇文章把“32位 SPI 控制器”拆成选型口径、寄存器实现、时序排错和动态帧宽切换四个层面来写,目标读者是做 MCU、DSP、FPGA 外设驱动和板级 bring-up 的工程师;新手能按步骤把一次 SPI 读写跑通,老手能在参数边界和异常时序上找到对照。
2. SPI控制器位宽怎么选:总线位宽、数据寄存器与帧格式的关系
2.1 位宽不是“一次传32位”那么简单
SPI 控制器文档里出现“32位”时,至少指三种不同含义,选型和配置前必须先分清。第一种是控制器挂在芯片内部总线上的位宽,比如 AXI、AHB、APB 总线是 32 位,这决定了寄存器读写的最小自然宽度,也决定了 DMA 搬运时一次 burst 能搬多少。第二种是数据寄存器(Data Register)的物理宽度,很多 32 位控制器的数据寄存器是 32 位,但外部引脚每时钟只移出 1 位,寄存器宽度只影响数据如何对齐。第三种是帧格式位宽(Frame Format),也就是一次片选有效期间连续发出多少个时钟、被当成一个完整 SPI 帧,常见配置是 8、16、32 位。
把三者列成对照表会更直观,这也是选型时最先要确认的口径:
| 位宽概念 | 典型宽度 | 由谁决定 | 出错时的表现 |
|---|---|---|---|
| 总线位宽 | 32 位 | SoC 设计 | 寄存器读改写丢高位,DMA burst 错位 |
| 数据寄存器宽度 | 8/16/32 位 | 外设设计 | 读回数据左右对齐不对,高字节为 0 或 0xFF |
| 帧格式位宽 | 8/16/32 位 | 驱动程序配置 | 从设备收到多余时钟,字节序错乱 |
| 从设备接口位宽 | 8 位居多 | 从设备芯片 | 主控按 32 位帧发,从设备只认前 8 个时钟 |
帧格式位宽是 SPI 协议里最容易被“32位”误导的一项。从设备是 8 位接口时,主控如果配置成 32 位帧,一次片选会产生 32 个时钟,从设备会把后 24 个时钟当成新命令或新地址,轻则返回错误数据,重则把 Flash 的状态寄存器写坏。反过来,主控只有 8 位帧配置、但 DMA 描述符按 32 位搬运,读回的数据在内存里会变成每 4 字节里只有低 8 位有效,这就是典型的“第一个字节对、后面全乱”的来源。
2.2 32位帧配合DMA为什么比8位帧省CPU
同样的数据量,用 32 位帧和 8 位帧传,SPI 总线上时钟数完全相同,差别在 CPU 被打断的次数和 DMA 的搬运效率。假设 SPI 时钟 10MHz,用 8 位帧传 4KB 数据,需要 4096 次“发一字节→等待完成→收一字节”的循环;如果每次都用中断,则每字节进一次中断,CPU 在频繁保存恢复现场上消耗的周期远超搬数据本身。用 32 位帧后,一次中断能处理 4 字节,配合 FIFO 和 DMA,中断次数直接降到原来的四分之一。
我一般会在驱动里做这样一组换算来确定帧宽:设 SPI 时钟为 Fsck,帧宽为 N 位,则一帧耗时 N/Fsck 秒;若 FIFO 深度为 D 帧,则 DMA 每 D 帧触发一次传输完成事件,实际 CPU 参与频率是 Fsck/(N×D)。当从设备支持连续突发读时,比如 GD25Q128E 的 Fast Read 支持任意长度连续输出,把帧宽设成 32 位、DMA burst 设成 4 字节(32 位),能让 Flash 读吞吐明显高于逐字节轮询。注意帧宽变成 32 位后,从设备接收到的仍是一个字节一个字节的比特流,字节序取决于控制器是 MSB first 还是 LSB first,而不是简单地把 32 位数据一次塞给从设备。
2.2.1 中断次数与FIFO水位的换算
这里给一个通用估算公式,方便写驱动前判断该用轮询、中断还是 DMA。设 FIFO 水位阈值为 T 帧,SPI 时钟为 Fsck,帧宽为 N,则每 T 帧产生一次 FIFO 触发事件,事件间隔为 T×N/Fsck 秒。当这个间隔小于 CPU 中断响应时间(常见几十微秒级别)时,轮询比中断更稳;当间隔大于中断响应时间但 CPU 还要同时跑 RTOS 任务时,DMA 是更合理的选择。用 8 位帧、10MHz 时钟、FIFO 水位 4 帧计算,事件间隔只有 3.2 微秒,Cortex-M 级别内核的中断响应通常追不上,表现为 RXNE 标志丢失或者 RX 溢出。改用 32 位帧后,事件间隔变成 12.8 微秒,软件处理压力小一个量级。这就是“32位”在实际工程里最直接的收益:不是总线传得更快,而是中断密度更低。
2.3 接入8位从设备时要做哪些适配
同一个 32 位 SPI 控制器接 8 位从设备,最常见的是 SPI NOR Flash、SD/TF 卡、温度传感器和 ADC。适配工作分三块:帧格式配成 8 位、数据对齐按从设备手册处理、片选时序按命令类型区分。帧格式配 8 位后,数据寄存器里写入的值只有低 8 位被移位发送,高 24 位被忽略;读回时,如果从设备是 8 位输出,数据也只落在低 8 位,高 24 位可能是 0 也可能是寄存器残留值,驱动里必须做掩码,例如rx &= 0xFF,否则后续判断会出错。
数据对齐是另一个容易踩的坑。某些 32 位 ADC 输出的是 32 位有符号整数,且数据左对齐(高 24 位有效)或右对齐(低 24 位有效)由控制寄存器决定;如果控制器把数据寄存器里的内容原样送给 DMA,而 DMA 按 32 位搬运到内存,就要在软件里做一次算术右移把符号位保留下来。还有人问“ESP8266 模块能连接 SPI 接口芯片吗”,这类模块的硬件 SPI 往往只有一个从机选择引脚,且帧宽配置范围有限,最常见的可靠方案是用 GPIO 模拟 SPI 时序;软件模拟 SPI 通信代码的位宽与 CPU 无关,关键是每 bit 的时钟翻转延时要满足从设备 t_clk 最小值,32 位 MCU 在这里的优势只是移位和置位效率高,协议层仍然要按 8 位从设备的时序来。
3. 寄存器级实现:SPI控制器初始化、收发流程与片选策略
3.1 初始化代码与参数对照表
很多从网上下载的spi.rar压缩包里,驱动源码的寄存器定义和实际芯片手册对不上,最常见的原因就是不同厂家的寄存器位偏移完全不同。下面这段初始化代码用偏移别名代替具体寄存器,适配任何一款 32 位 SPI 控制器时,只需把宏定义改成手册里的实际偏移。
/* 通用寄存器偏移,按芯片手册替换 */ #define SPI_BASE 0x40013000 #define SPI_CR1 0x00 /* 控制寄存器1:时钟、帧宽、片选 */ #define SPI_CR2 0x04 /* 控制寄存器2:中断、DMA使能 */ #define SPI_SR 0x08 /* 状态寄存器:TXE/RXNE/BSY */ #define SPI_DR 0x0C /* 数据寄存器:发送写、接收读 */ typedef struct { uint8_t frame_bits; /* 8 / 16 / 32 */ uint8_t cpol; /* 0: CLK空闲低电平, 1: CLK空闲高电平 */ uint8_t cpha; /* 0: 第一沿采样, 1: 第二沿采样 */ uint8_t lsb_first; /* 0: MSB first, 1: LSB first */ uint8_t baud_div; /* 时钟分频, 范围由具体芯片决定 */ uint8_t sw_cs; /* 1: 软件控制NSS, 0: 硬件片选 */ } spi_config_t; void spi_init(void *base, const spi_config_t *cfg) { uint32_t cr1 = 0; /* SPE(SPI使能)先清零, 避免配置写到一半被打断 */ *((volatile uint32_t *)(base + SPI_CR1)) &= ~(1u << 6); /* 帧宽: 不同芯片组合DFF/FRF位, 这里用独立编码 */ if (cfg->frame_bits == 8) cr1 |= (0u << 11); /* 8位帧, 多数芯片默认 */ else if (cfg->frame_bits == 16) cr1 |= (1u << 11); /* 16位帧 */ else if (cfg->frame_bits == 32) cr1 |= (1u << 15); /* 32位帧, 部分芯片用DS[3:0]编码 */ /* CPOL+CPHA组合, 也就是SPI模式0~3 */ cr1 |= (cfg->cpol << 1) | (cfg->cpha); /* LSB first置位后, 数据位从低位开始移出 */ if (cfg->lsb_first) cr1 |= (1u << 7); /* 波特率分频系数, 只取低3位作为示例 */ cr1 |= (cfg->baud_div & 0x07) << 3; /* SSM=1表示NSS由软件控制, 配合SSI位拉高/拉低 */ if (cfg->sw_cs) cr1 |= (1u << 9); *((volatile uint32_t *)(base + SPI_CR1)) = cr1; /* 最后才置位SPE, 让外设按新参数启动 */ *((volatile uint32_t *)(base + SPI_CR1)) |= (1u << 6); }这段代码的逻辑顺序是固定的:先把 SPI 外设关掉(SPE=0),再写分频、极性和帧宽,最后重新打开。原因在于大部分 32 位控制器在 SPE=1 状态下会持续产生内部移位时钟,改变帧宽或极性可能让当前正在传输的半个字节发出无效时钟。参数里baud_div的值不是绝对频率,需要对照具体芯片手册里的 BR[2:0] 表格,例如 000 对应 fPCLK/2、001 对应 fPCLK/4,写错一档就会让时序偏离从设备的 t_clk 要求。sw_cs设置为 1 时,NSS 引脚由软件强制控制,适合手动拉 CS 的场景;设置为 0 时,硬件自动在每帧传输前后拉低拉高片选,适合只挂单个从设备的板子。
3.2 轮询收发与DMA收发的取舍(spi需要两个DMA吗)
初始化之后,最小可用的收发函数用轮询方式实现。SPI 是全双工协议,MOSI 移出一位的同时 MISO 收回一位,所以一次写操作必然伴随一次读操作,即使读回来的数据不需要,也要把数据寄存器读掉,否则 RXNE 置位不处理,下一帧会把 FIFO 堵死。
int spi_xfer_frame(void *base, uint32_t tx, uint32_t *rx) { uint32_t sr; uint32_t timeout = 100000; /* TXE=1表示发送缓冲空, 可以写入新数据 */ while (((sr = *((volatile uint32_t *)(base + SPI_SR))) & (1u << 1)) == 0) { if (--timeout == 0) return -1; /* 超时, 多半是SPE没置位 */ } *((volatile uint32_t *)(base + SPI_DR)) = tx; /* RXNE=1表示接收缓冲有数据 */ timeout = 100000; while (((sr = *((volatile uint32_t *)(base + SPI_SR))) & (1u << 0)) == 0) { if (--timeout == 0) return -2; } if (rx) *rx = *((volatile uint32_t *)(base + SPI_DR)); return 0; }这里有一个初学者常犯的误解:认为“发送完”是看 TXE,但 TXE 只代表数据从 CPU 搬到了外设的移位缓冲,不代表移位完成。真正要等的是 BSY 位(忙标志)清零,或者在收发完成后等待 RXNE。上面的函数在发送后立刻等待 RXNE,因为全双工模式下 RXNE 置位意味着本次帧的移位已经完成,等效于同时确认了发送结束。timeout是一个递减计数,实际运行中如果 SPI 时钟极慢,比如低速 ADC 只有 100kHz,一帧要 80 微秒,5MHz 时钟下 100000 次循环远超实际耗时,但在兆赫兹级时钟下足够覆盖异常挂死场景。
关于“SPI 需要两个 DMA 吗”,答案是看数据流向。SPI 全双工硬件上需要两条 DMA 通道:一条从内存把发送数据搬到 SPI 数据寄存器,另一条从数据寄存器搬到内存。如果只做发送不关心返回数据,比如初始化 NOR Flash 的写使能命令,一条 DMA 通道就够;如果只是读取从设备,也要两条,因为主机必须持续输出时钟,而时钟由发送 DMA 驱动,没有发送数据时钟就不会产生。常见做法是用两个 DMA 通道绑定同一个 SPI 外设请求,接收通道用循环模式或者普通模式,发送通道搬到最后一个字节后触发完成中断。少数控制器支持“发送空数据自动产生时钟”的模式,例如发送寄存器填 0 时继续输出时钟,那种场景下接收可以单独用一个 DMA 通道。
3.3 硬件片选与软件片选(含Linux软件拉片选的设备树写法)
片选策略直接决定 SPI 时序能不能满足从设备要求。硬件片选由控制器在每帧开始前自动拉低 NSS,帧结束后自动拉高,优点是 CPU 零干预,缺点是两次连续帧之间 CS 高电平时间短,某些从设备要求 CS 释放后至少 t_csh 时间才能再次拉低,硬件自动时序不一定满足;软件片选则把 NSS 当普通 GPIO 控制,在整条命令事务期间保持拉低,直到全部字节完成后拉高,适合 Flash 的 Read、Page Program 这类需要连续多字节、中间不能被 CS 中断的操作。
| 片选方式 | 拉低时机 | 拉高时机 | 适用场景 | 注意点 |
|---|---|---|---|---|
| 硬件片选 | 每帧自动 | 每帧结束 | 单字节读温度、单字节写 DAC | CS 高电平间隔短,多字节传输可能被拆帧 |
| 软件片选 | 事务开始前 | 事务结束后 | Flash 整页读写、ADC 连续采样 | CS 拉低和拉高要自己做延时 |
| Linux GPIO 软片选 | transfer 开始前 | transfer 结束后 | 内核 spidev 用户态访问 | 需要设备树里声明 cs-gpios |
在 Linux 下使用软件拉片选的常见做法,是在设备树中把片选引脚声明为cs-gpios,控制器驱动看到这个属性后不再使用硬件 NSS,而是通过 gpiolib 在每次传输前后拉低拉高。以spi-gpio这类通用驱动为例,设备树片段大致如下:
&spi1 { status = "okay"; cs-gpios = <&gpio4 10 GPIO_ACTIVE_LOW>; spidev@0 { reg = <0>; compatible = "spidev"; spi-max-frequency = <10000000>; }; };cs-gpios里的GPIO_ACTIVE_LOW表示片选低有效,reg = <0>对应第 0 个片选;spi-max-frequency是软件层面对时钟频率的约束,实际分频仍由控制器寄存器决定。这个写法让片选时序完全交给 Linux 的 SPI 核心层:每次spi_transfer提交时,核心层保证先拉低 CS,所有 buffer 传输完成后再拉高,中间不会插入其他设备的事务,比硬件自动片选更适合 Flash 这类需要原子化多字节命令的外设。注意compatible = "spidev"只是最常见的占位写法,实际产品里应当填写具体从设备器件的 compatible,否则内核会报 spidev 使用限制的警告。
4. SPI时序排错:CPOL/CPHA、丢字节与控制器故障定位
4.1 逻辑分析仪下的SPI时序核对方法
SPI 报错排错的第一件事,不是改代码,而是把 MISO、MOSI、CLK、CS 四根线接到逻辑分析仪上抓一次真实波形。SPI 协议不像 I2C 那样有应答位,数据错误发生时没有任何硬件提示,只能靠波形确认主控发出来的时序和从设备期望的是不是同一套。打开逻辑分析仪后,先看 CS 拉低到第一个 CLK 上升沿的间隔,记为 t_lead;再看数据线在 CLK 哪个沿变化,哪个沿保持稳定。SPI 模式由 CPOL/CPHA 两个参数决定:CPOL 决定空闲时 CLK 电平,CPHA 决定数据在第一个沿还是第二个沿被采样。CubeMX 等工具生成工程时,这两个参数通常按从设备数据手册里的“SPI Mode 0/1/2/3”填写,但很多人只改了时钟分频没改模式,抓波形后会发现 CLK 空闲电平反了,所有采样沿错开半个周期,读回来的数据位全部颠倒。
核对采样沿有一个通用技巧:观察 MISO 线上的数据跳变点,数据总是在时钟的一个边沿变化、在另一个边沿被主机采样。如果 MISO 在 CLK 上升沿跳变,那么主机应该配置成上升沿之后的下降沿采样;如果分析仪上看到的数据跳变沿和控制器采样沿一致,字节大概率全部错位。还要顺手量一下两个相邻字节之间 CS 是否保持低电平,很多软件片选方案在每字节之间都会误拉高 CS,让从设备把一整个读命令拆成多个独立事务,返回的只有第一个字节正确。
4.2 常见故障表:从0xFF到丢最后一字节
把日常 bring-up 里见过的高频 SPI 故障整理成一张表,对照症状查原因比从头看代码快得多。
| 症状 | 最可能原因 | 检查项 |
|---|---|---|
| 每次读回都是 0xFF | MISO 无上拉,或 CPOL/CPHA 采样沿错误 | 用逻辑分析仪看 MISO 是否真被从设备拉低过 |
| 只在读 Flash 时返回 0xFF | Flash 的 MISO 是开漏,需要上拉电阻 | 查板子原理图,Flash/ TF 卡 SPI 模式建议加上拉 |
| 第一个字节对,后面全错 | 帧宽设错,主控按 32 位帧解 8 位数据 | 打印每次读回的完整寄存器值,看高字节是否为残留 |
| 页写结尾丢最后一个字节 | 没等 BSY 清零就拉高 CS | 页写命令后轮询 Status Register 的 WIP 位 |
| DMA 搬运完但数据顺序乱 | DMA burst 宽度和外设数据寄存器宽度不匹配 | 外设 32 位时 DMA 侧也配 32 位 |
| 时钟一直没有输出 | SPI 外设时钟门没打开 | 查 RCC 或时钟树里 SPI 模块的使能位 |
其中“TF 卡 SPI 需要上拉吗”这个问题的答案也在表里:SD/TF 卡在 SPI 模式下,MISO 是开漏输出,必须接上拉电阻,否则读出来的第一个字节经常是 0xFF,而且是在高速时钟下尤其明显。另一个高频场景是接 AD7124 这类高精度 ADC,它们对 t_lead、t_trail 的要求写得很细,软件片选在事务开始前要拉低 CS 至少几十纳秒,某些控制器响应太慢,需要手动加一个nop或延时函数才能满足。
4.3 定位寄存器状态:一个轮询转储小工具
当故障现象不再是“完全不通”,而是“偶尔丢一帧”时,连续抓波形的成本太高,先写一个小工具把状态寄存器转储出来更高效。下面的函数在每次 SPI 传输后读取 SR,把 TXE、RXNE、BSY、FIFO 错误位一次性打印出来:
#include <stdio.h> void spi_dump_status(void *base, uint32_t expected_rx) { uint32_t sr = *((volatile uint32_t *)(base + SPI_SR)); uint32_t dr = *((volatile uint32_t *)(base + SPI_DR)); printf("SR=0x%08X TXE=%d RXNE=%d BSY=%d OVR=%d MODF=%d\n", sr, (sr >> 1) & 1, /* TXE: 发送缓冲空 */ (sr >> 0) & 1, /* RXNE: 接收缓冲非空 */ (sr >> 7) & 1, /* BSY: 忙标志 */ (sr >> 6) & 1, /* OVR: 溢出错误 */ (sr >> 5) & 1); /* MODF: 模式错误 */ printf("expected=0x%08X actual=0x%08X\n", expected_rx, dr); }OVR溢出位是“偶发丢字节”的头号元凶,它表示接收数据还没被 CPU 或 DMA 读走,新的一帧数据就到了。轮询模式下最常见的原因是中断优先级别配错,SPI 中断频繁被更高优先级任务打断;DMA 模式下则往往是接收 DMA 通道的 FIFO 阈值配得大于外设单帧触发条件。MODF模式错误表示多个主设备同时在驱动 MOSI,或者是把 NSS 配成了输入模式但引脚电平不对,这两个错误位一旦置位,后续发送会被控制器自行禁止,直到清除错误位并重新使能。这个转储函数加在异常分支里,配合一段固定数据的回环测试,半小时内基本能定位是软件层丢数据还是硬件层时序问题。
5. 一个SPI控制器同时接8位和16位从设备:动态帧宽切换技巧
同一个控制器上挂两种帧宽的从设备,第一反应是每次切换前改写帧宽寄存器。但帧宽寄存器在多数 32 位控制器里是写保护状态,必须先把 SPE 清零才能改;而 SPE 清零意味着 SPI 外设停止,正在传输的半帧会被打断。所以动态切换的关键不是“怎么改寄存器”,而是“什么时候改”。
一个可靠的事务级切换函数是:先拉高所有片选,确认当前事务已经结束,再 SPE=0,写帧宽,SPE=1,最后拉低目标从设备的 CS。伪代码如下:
void spi_set_frame_bits(void *base, uint8_t bits) { /* 拉高所有片选, 保证没有事务在进行 */ gpio_set_all_cs(1); while ((*((volatile uint32_t *)(base + SPI_SR)) & (1u << 7)) != 0) ; /* 等BSY=0才能动寄存器 */ *((volatile uint32_t *)(base + SPI_CR1)) &= ~(1u << 6); /* SPE=0 */ /* 按具体芯片修改帧宽位 */ *((volatile uint32_t *)(base + SPI_CR1)) |= (bits << 11); *((volatile uint32_t *)(base + SPI_CR1)) |= (1u << 6); /* SPE=1 */ }如果从设备对“多余时钟”不敏感,还有另一个取巧做法:把所有帧宽统一配置成 32 位,和 8 位从设备通信时,把有效数据放到最高 8 位,从设备只会在 CS 低电平期间取前 8 个时钟的内容。这个做法只对“在 CS 上升沿锁存数据”的器件有效,比如某些 LCD 控制器、LED 驱动芯片;对命令连续流式的 Flash 和 ADC 则完全不可行,它们会把多余的 24 个时钟当成下一段数据。所以号称“一个 32 位帧打天下”的方案,实际上是拿从设备的协议特性换来的,选型时必须先确认从设备是否允许 CS 有效期间多出时钟。
切换完成后,做一次物理回环验证最稳妥:把 MOSI 和 MISO 短接,主机发一组已知伪随机数,校验读回的数据和发出的完全一致。这样能把控制器配置、DMA 通道、帧宽切换逻辑一起覆盖到;回环通过后再接上真实从设备,用逻辑分析仪抓一次 CS 下降沿到第一个 CLK 的间隔,确认动态切换后的时序仍满足 8 位设备和 16 位设备各自的 t_lead 要求,整个双帧宽适配才算真正闭环。
本文还有配套的精品资源,点击获取