
简介一份基于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]编码 */ /* CPOLCPHA组合, 也就是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; /* SSM1表示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 外设关掉SPE0再写分频、极性和帧宽最后重新打开。原因在于大部分 32 位控制器在 SPE1 状态下会持续产生内部移位时钟改变帧宽或极性可能让当前正在传输的半个字节发出无效时钟。参数里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; /* TXE1表示发送缓冲空, 可以写入新数据 */ while (((sr *((volatile uint32_t *)(base SPI_SR))) (1u 1)) 0) { if (--timeout 0) return -1; /* 超时, 多半是SPE没置位 */ } *((volatile uint32_t *)(base SPI_DR)) tx; /* RXNE1表示接收缓冲有数据 */ 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 中断的操作。片选方式拉低时机拉高时机适用场景注意点硬件片选每帧自动每帧结束单字节读温度、单字节写 DACCS 高电平间隔短多字节传输可能被拆帧软件片选事务开始前事务结束后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; spidev0 { 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 故障整理成一张表对照症状查原因比从头看代码快得多。症状最可能原因检查项每次读回都是 0xFFMISO 无上拉或 CPOL/CPHA 采样沿错误用逻辑分析仪看 MISO 是否真被从设备拉低过只在读 Flash 时返回 0xFFFlash 的 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(SR0x%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(expected0x%08X actual0x%08X\n, expected_rx, dr); }OVR溢出位是“偶发丢字节”的头号元凶它表示接收数据还没被 CPU 或 DMA 读走新的一帧数据就到了。轮询模式下最常见的原因是中断优先级别配错SPI 中断频繁被更高优先级任务打断DMA 模式下则往往是接收 DMA 通道的 FIFO 阈值配得大于外设单帧触发条件。MODF模式错误表示多个主设备同时在驱动 MOSI或者是把 NSS 配成了输入模式但引脚电平不对这两个错误位一旦置位后续发送会被控制器自行禁止直到清除错误位并重新使能。这个转储函数加在异常分支里配合一段固定数据的回环测试半小时内基本能定位是软件层丢数据还是硬件层时序问题。5. 一个SPI控制器同时接8位和16位从设备动态帧宽切换技巧同一个控制器上挂两种帧宽的从设备第一反应是每次切换前改写帧宽寄存器。但帧宽寄存器在多数 32 位控制器里是写保护状态必须先把 SPE 清零才能改而 SPE 清零意味着 SPI 外设停止正在传输的半帧会被打断。所以动态切换的关键不是“怎么改寄存器”而是“什么时候改”。一个可靠的事务级切换函数是先拉高所有片选确认当前事务已经结束再 SPE0写帧宽SPE1最后拉低目标从设备的 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) ; /* 等BSY0才能动寄存器 */ *((volatile uint32_t *)(base SPI_CR1)) ~(1u 6); /* SPE0 */ /* 按具体芯片修改帧宽位 */ *((volatile uint32_t *)(base SPI_CR1)) | (bits 11); *((volatile uint32_t *)(base SPI_CR1)) | (1u 6); /* SPE1 */ }如果从设备对“多余时钟”不敏感还有另一个取巧做法把所有帧宽统一配置成 32 位和 8 位从设备通信时把有效数据放到最高 8 位从设备只会在 CS 低电平期间取前 8 个时钟的内容。这个做法只对“在 CS 上升沿锁存数据”的器件有效比如某些 LCD 控制器、LED 驱动芯片对命令连续流式的 Flash 和 ADC 则完全不可行它们会把多余的 24 个时钟当成下一段数据。所以号称“一个 32 位帧打天下”的方案实际上是拿从设备的协议特性换来的选型时必须先确认从设备是否允许 CS 有效期间多出时钟。切换完成后做一次物理回环验证最稳妥把 MOSI 和 MISO 短接主机发一组已知伪随机数校验读回的数据和发出的完全一致。这样能把控制器配置、DMA 通道、帧宽切换逻辑一起覆盖到回环通过后再接上真实从设备用逻辑分析仪抓一次 CS 下降沿到第一个 CLK 的间隔确认动态切换后的时序仍满足 8 位设备和 16 位设备各自的 t_lead 要求整个双帧宽适配才算真正闭环。本文还有配套的精品资源点击获取