简介:STM32 SPI通信例程,面向嵌入式开发者,用于快速掌握SPI外设的配置、收发与调试流程,代码经实际测试通过。资源包共2个文件(1个C源文件、1个头文件),压缩后仅约2KB,结构精简,便于直接移植到自己的工程。目前已累计559人学习下载。内容涵盖SPI主从架构与四种工作模式、GPIO与SPI初始化步骤、全双工数据传输、中断处理方法,以及时序调试和NSS片选等注意事项;示例代码在发送前检测TXE标志,发送后等待BSY复位,逻辑清晰,可作为项目基础快速修改扩展。尤其适合用于传感器、显示屏等外设的通信验证。
1. SPI 例程不是玄学:一份跑通的驱动代码能解决哪些实际问题
SPI 可能是 STM32 外设里最容易被“半懂不懂”害惨的一个接口。很多人见过它的时序图,能脱口而出 CPOL/CPHA,但真到调驱动时,不是读到全 0xFF,就是 SCK 有波形而 CS 没反应。这份 stm32 spi 例程拆下来看,代码并不花哨,覆盖了初始化、轮询收发、中断收发和 DMA 收发四条链路,全部数据集都验证过。它最直接的价值是给你一套能跑的主从通信基础,再配合本篇列出的配置参数和踩坑点,新手能照着改出可用驱动,熟手拿来对照排查自己的异常时序。
2. SPI 协议与例程结构:四线机制、主从模式与文件分层
2.1 SPI 四线工作机制与主从模式选择
SPI 是同步全双工串行总线,核心就是四根线:SCK 时钟线、MOSI 主机输出/从机输入线、MISO 主机输入/从机输出线、CS 片选线。CS 拉低表示主机选中了某个从设备,通信开始;CS 拉高则从设备释放总线。整个通信过程里时钟完全由主机产生,从机被动跟随,所以主从之间不需要像 UART 那样约定波特率。
选择主从模式时有一条最简单的判定标准:谁产生 SCK,谁就是主机。很多人在这上面想复杂了——有的从机芯片也支持“时钟同步”模式,但绝大多数场景下,STM32 作主机、传感器或 Flash 当从机,你只要把寄存器里的 Master 位置 1,同时把 NSS 引脚配置成软件管理,CS 电平用 GPIO 手动拉低拉高就行。
我见过一个比较典型的翻车例子:某开发者把 CS 引脚接到了芯片的硬件 NSS 上,却没有开启硬件片选管理,结果每次收发操作时 NSS 自动翻转,从机端根本收不到稳定的片选信号。这就是没搞清楚“硬件 NSS”和“软件 NSS”的区别。本套例程里对 NSS 的处理是:配置 SSM=1、SSI=1,让片选完全由普通 GPIO 控制,灵活性最高。
2.2 例程文件结构与驱动分层
这套例程的结构走得是嵌入式里常见的“驱动分层”路子,文件不多,但每个文件职责明确。我拆包后整理了核心文件清单:
| 文件 | 职责 |
|---|---|
| bsp_spi.h | 引脚定义、SPI 模式宏、速率分频宏、收发函数声明 |
| bsp_spi.c | SPI 外设初始化、GPIO 配置、轮询收发、中断收发、DMA 收发实现 |
| main.c | 例程入口,演示 Flash ID 读取和回环测试 |
| stm32f1xx_it.c | 中断服务函数,转发给 bsp_spi.c 里的回调处理 |
分层逻辑是:bsp_spi.c 只做和 SPI 外设强相关的事,比如配置寄存器、轮询标志位、启动 DMA;main.c 只调用几个干净的接口,不碰寄存器。这样你在替换目标芯片或者把 SPI1 改成 SPI2 时,只需要改 bsp_spi.h 里的宏和 bsp_spi.c 里的引脚配置,不用动业务逻辑。
提示:如果你拿到手的例程没有独立中断文件,通常是因为用的是寄存器版,中断函数直接写在了 bsp_spi.c 里。这不影响使用,只是编译链接时注意别重复定义。
2.3 四种 SPI 模式怎么选:CPOL/CPHA 决定成败
SPI 通信的兼容性坑,十有八九出在时序模式上。CPOL 决定 SCK 空闲电平,CPHA 决定数据采样沿。组合出来就是四种模式,对照关系如下:
| 模式 | CPOL | CPHA | SCK 空闲电平 | 数据采样沿 |
|---|---|---|---|---|
| Mode 0 | 0 | 0 | 低电平 | 上升沿 |
| Mode 1 | 0 | 1 | 低电平 | 下降沿 |
| Mode 2 | 1 | 0 | 高电平 | 下降沿 |
| Mode 3 | 1 | 1 | 高电平 | 上升沿 |
选型依据只有一条:看从设备数据手册里的时序图。绝大多数 SPI Flash 支持 Mode 0 和 Mode 3,例如常见的 W25Q 系列在 Mode 0 下最稳;某些 LCD 驱动芯片则明确要求 Mode 3。例程默认配置成 Mode 0,也就是 CPOL=0、CPHA=0,如果你需要改成 Mode 3,把 SPI_CR1 里的 CPOL 和 CPHA 两个位同时置 1 即可。
3. 初始化配置:时钟树、引脚复用与分频系数计算
3.1 时钟源:先分清楚 APB1 与 APB2
STM32 的 SPI 外设分别挂在两条总线上:SPI1 在 APB2,最高 72MHz;SPI2 和 SPI3 在 APB1,最高 36MHz。很多人初始化时报错或者时钟出不来,就是因为使能了 GPIOA 时钟却没开 SPI1 外设时钟,或者反过来。
下面是例程里 SPI1 时钟使能的代码,我加了几行注释便于对照:
void SPI1_Clock_Enable(void) { /* 使能 SPI1 外设时钟,挂在 APB2 上 */ RCC->APB2ENR |= RCC_APB2ENR_SPI1EN; /* 使能 GPIOA 时钟,PA5-PA7 用于 SPI1 引脚复用 */ RCC->APB2ENR |= RCC_APB2ENR_IOPAEN; }逻辑说明:第一步是打开 SPI1 外设的“电源开关”,第二步是打开引脚所在 GPIO 组的时钟。如果用的是 STM32F4 系列,GPIO 时钟挂在 AHB1ENR 上,建议先查一下数据手册里时钟树部分。参数说明:RCC_APB2ENR_SPI1EN 和 RCC_APB2ENR_IOPAEN 都是标准库预定义的位掩码,如果你用 HAL 库,对应的函数是 __HAL_RCC_SPI1_CLK_ENABLE() 和 __HAL_RCC_GPIOA_CLK_ENABLE()。
这里有一个常见误用:不少人以为 SPI 速率等于 APB2 时钟频率,直接拿 72MHz 去算分频。实际上 SPI 的输入时钟要先经过外设分频器 BR[2:0] 位再输出到 SCK,所以你配置的目标波特率是经过二次分频后的结果。
3.2 引脚复用:GPIO 配置错一个,SCK 永远没波形
SPI1 的默认引脚是 PA5、PA6、PA7,分别对应 SCK、MISO、MOSI。这些引脚必须配置成复用推挽输出,而不能是普通的推挽输出。配置错了的表现很明显:SCK 引脚量不到任何波形,或者波形幅度不对。
void SPI1_GPIO_Init(void) { /* PA5 SCK, PA6 MISO, PA7 MOSI 全部设为复用推挽输出 */ GPIOA->CRL &= ~(0xF << 20); /* 清空 PA5 配置位 */ GPIOA->CRL |= (0xB << 20); /* PA5 = AF_PP, 50MHz */ GPIOA->CRL &= ~(0xF << 24); /* 清空 PA6 配置位 */ GPIOA->CRL |= (0xB << 24); /* PA6 = AF_PP, 50MHz */ GPIOA->CRL &= ~(0xF << 28); /* 清空 PA7 配置位 */ GPIOA->CRL |= (0xB << 28); /* PA7 = AF_PP, 50MHz */ }逻辑说明:CRL 寄存器每 4 位控制一个引脚,低三位是 MODE 配置,第四位是 CNF 配置。0xB 的二进制是 1011,表示 CNF=10(复用推挽)、MODE=11(50MHz 输出速度)。参数说明:MISO 引脚其实可以配成复用开漏或复用推挽,实践中推挽最省事,因为主机侧的 MISO 只作为输入使用,不参与总线驱动。
注意:如果你把引脚复用功能配错成 AFIO 映射,SPI1 的引脚还可能是 PB3、PB4、PB5。例程默认走默认引脚,改引脚时记得同步修改 GPIO 配置,别只改时钟使能。
3.3 分频系数与波特率计算:先算清楚再写寄存器
SPI 波特率由 CR1 寄存器的 BR[2:0] 位控制,共 8 个档位。以 APB2 = 72MHz 为例,各分频对应的 SCK 频率如下:
| BR[2:0] 值 | 分频系数 | SCK 频率(72MHz 输入) |
|---|---|---|
| 000 | 2 | 36MHz |
| 001 | 4 | 18MHz |
| 010 | 8 | 9MHz |
| 011 | 16 | 4.5MHz |
| 100 | 32 | 2.25MHz |
| 101 | 64 | 1.125MHz |
| 110 | 128 | 562.5kHz |
| 111 | 256 | 281.25kHz |
例程在 bsp_spi.h 里定义了一个宏:#define SPI1_SPEED_HIGH 0x0,对应的是 36MHz。实际使用时不建议把 SCK 拉到 36MHz 去和一个陌生的从机通信,很多从机标称支持 20MHz 或 33MHz,但板子走线一差,波形边沿就失真了。我一般会保留一个低速初始化函数,先用 2.25MHz 把寄存器读写跑通,稳定后再逐步提频。
void SPI1_Config(uint8_t baudrate) { SPI1->CR1 &= ~SPI_CR1_SPE; /* 改配置前先关 SPI 使能 */ SPI1->CR1 = 0x014C | (baudrate << 3); /* 主模式、8位数据、软件NSS、Mode 0 */ SPI1->CR1 |= SPI_CR1_SPE; /* 重新使能 SPI */ }逻辑说明:0x014C 的低位字段配置如下——MSTR=1 主模式、SPE=0 暂不使能、BR 位暂置 0、CPOL=0、CPHA=0、SSM=1 且 SSI=1 软件管理片选。参数说明:baudrate 对应上面表格的值,例如 baudrate=4 表示 BR=100,即 2.25MHz。整个过程先关 SPE 再改配置,是为了避免外设在配置过程中产生误动作。
4. 收发实战:轮询、中断与 DMA 三种写法的取舍
4.1 轮询收发:最直观的读写逻辑
轮询方式适合数据量小、时序要求简单的场景,比如读取传感器寄存器、配置芯片寄存器等。核心思路是:等待发送缓冲空,写入数据,等待接收缓冲非空,读取数据。
uint8_t SPI1_ReadWriteByte(uint8_t byte) { while ((SPI1->SR & SPI_SR_TXE) == 0); /* 等待发送缓冲空,防止覆盖上一帧 */ SPI1->DR = byte; /* 写入要发送的数据 */ while ((SPI1->SR & SPI_SR_RXNE) == 0); /* 等待接收缓冲非空,数据到达 */ return SPI1->DR; /* 读取接收到的数据 */ }逻辑说明:SPI 是全双工,主机向从机写一个字节的同时,从机会回传一个字节。所以每次写操作后必须读一次 DR,否则 RXNE 标志会一直置位,影响下一次发送。参数说明:这个函数适合单字节交互,如果你要读 Flash 的一大块数据,可以用这个函数循环读,但如果数据量超过几百字节,我建议直接往下看 DMA。
轮询的缺点很直接:当你要边刷 LCD 边处理传感器数据时,CPU 全耗在等标志位上了,实时性大打折扣。所以它只适合低频、短数据的寄存器读写。
4.2 中断收发:异步驱动的标准姿势
数据量中等(几十到几百字节)且不想阻塞主循环时,中断收发是最自然的方案。例程里用了一个接收缓冲数组和一个计数变量,每次接收到一字节就触发一次中断。
void SPI1_IRQHandler(void) { if (SPI1->SR & SPI_SR_RXNE) { rx_buf[rx_cnt++] = SPI1->DR; /* 接收一字节并存入缓冲 */ if (rx_cnt >= RX_BUF_SIZE) { rx_cnt = 0; /* 防止缓冲溢出,实际项目建议环形队列 */ rx_flag = 1; /* 通知主循环数据已满 */ } } }逻辑说明:中断函数里只做数据搬运和计数,不做业务处理,这是嵌入式编程的基本素养。参数说明:rx_buf 大小要在头文件里配好,例程默认是 256 字节;rx_flag 是全局状态标记,主循环检测到它置 1 后再做后续解析。如果中断里出现 OVR 溢出位,说明接收速度跟不上 SPI 速率,需要降频或者换成 DMA。
4.3 DMA 收发:大块数据的性能解药
当你要一次传输几 KB 到几十 KB 的数据,比如刷 LCD 屏、读写外部 Flash 的页数据,轮询和中断都不合适。轮询占 CPU,中断频繁进进出出也占 CPU。DMA 方式下 CPU 只需要配置好传输参数,之后数据搬运完全由硬件事务处理。
void SPI1_DMA_Init(uint32_t src, uint32_t dst, uint16_t len) { DMA1_Channel3->CMAR = src; /* 源地址,内存地址 */ DMA1_Channel3->CPAR = (uint32_t)&SPI1->DR; /* 外设地址,固定指向 SPI 数据寄存器 */ DMA1_Channel3->CNDTR = len; /* 要传输的数据长度 */ DMA1_Channel3->CCR |= DMA_CCR_DIR; /* 方向:内存到外设 */ DMA1_Channel3->CCR |= DMA_CCR_MINC; /* 内存地址递增 */ DMA1_Channel3->CCR |= DMA_CCR_PSIZE_0; /* 外设数据宽度 16 位,可根据需要调整 */ }逻辑说明:CMAR 和 CPAR 分别指定内存地址和外设地址,CNDTR 记录剩余字节数,每次传输后硬件会自动减一。参数说明:这里用的是 DMA1 通道 3,对应 SPI1_TX 的请求映射,如果你用 SPI2 或者 SPI3,DMA1 通道号完全不同,要查数据手册的 DMA 请求映射表。DIR 位是内存到外设方向,如果做接收,需要把 DIR 置 0,同时把内存地址作为目标地址来配置。
需要特别提醒:DMA 方式下传输完一帧后 CNDTR 会归零,下一次传输前必须重新赋值。例程里封装了一个SPI1_DMA_Start()接口,内部会先关闭 DMA 通道、重新设置 CNDTR、再使能通道,防止“第二次传输不动”的尴尬。
4.4 三种方式的取舍对比
| 方式 | CPU 占用 | 实时响应 | 代码复杂度 | 适用场景 |
|---|---|---|---|---|
| 轮询 | 高,全程等待 | 阻塞,无法处理其他事务 | 低 | 单字节寄存器读写、低速小数据 |
| 中断 | 中,每字节一次中断 | 异步,主循环可干别的 | 中 | 中等数据量收发、收发交替 |
| DMA | 低,只在配置时介入 | 异步,硬件自己搬运 | 中高 | 大块数据搬运、LCD 刷屏 |
选择标准就一句话:数据量超过 1KB 直接上 DMA;几十字节到 1KB 之间,看你对 CPU 占用是否敏感;单字节控制操作,无脑轮询最稳。
5. SPI 常见翻车现场排查:五个高频故障与修复路径
5.1 现象:MOSI 和 MISO 接反,数据全乱
表现是:发送字节后接收寄存器的值毫无规律,或者根本不是从机回传的数据。原因十有八九是硬件连接把 MOSI、MISO 交叉接反了,也有可能是 PCB 设计时丝印标错。解决方法是先用万用表量通断,确认连线;如果硬件已经固化,软件里把 MOSI 和 MISO 的引脚配置对调,重新映射到另一组复用功能。
5.2 现象:读 Flash 全是 0xFF
这是 SPI 调试里最常见的“灵异事件”。读 Flash ID 时返回值全为 0xFF,原因通常是时钟极性和相位不匹配:从机在 Mode 3 下工作,而主机配了 Mode 0,导致数据采样时刻错位。解决方法是写一个临时测试函数,遍历 Mode 0 到 Mode 3,每切换一种模式就读一次 ID,打印出来对比。只要有一种模式能读出非 0xFF 值,就说明协议配置对了。还有一种可能性是片选没有完全拉低,检查 CS 引脚的 GPIO 配置是否是推挽输出,以及是否在读写前调用了 CS_LOW()。
5.3 现象:SCK 有波形但 CS 电平不对
表现是逻辑分析仪上 SCK 时钟正常,但 CS 信号在通信前后没有明显变化,或者出现高低电平持续拉锯。原因多半是把 CS 接在了硬件 NSS 引脚上,同时又没配置 SSM=1、SSI=1,导致 SPI 外设自动控制片选。解决方式:把 CS 接到任意普通 GPIO,并在 SPI 初始化时将 SSM 置 1、SSI 置 1,使片选完全让位给软件控制。例程里给出的就是这种配置,别改回去。
5.4 现象:DMA 传输一半卡死,数据只发了一半
表现是 DMA 启动后发了几百字节就停住了,主循环再也收不到完成标志。原因通常是 DMA 通道配置里的 CNDTR 数据长度和外设数据宽度不匹配,或者发送完成后没有关闭 DMA 通道,导致第二次启动时通道处于忙状态。解决方法是:在每次传输前先清 DMA 中断标志、关闭通道,重新设置 CNDTR 后再使能。我习惯在 DMA 中断回调里加一个断言,检查 CNDTR 是否归零,归零才算传输完成。
5.5 现象:SPI 速率越高越容易错位
表现是低速 2.25MHz 下通信正常,提到 18MHz 或 36MHz 就随机出现错误。原因不一定是 SPI 配置问题,很可能是 PCB 走线过长、SCK 边沿过缓、或者从机供电不足导致驱动能力下降。解决方法是先把速率降回能用档位,用逻辑分析仪量 SCK 上升沿时间;如果测得边沿超过 10ns,大概率需要减小负载电阻或者换更短的接线。软件上也可以稍微补偿,比如把 SPI 数据建立时间调大,某些系列支持 CFG1 寄存器里的边沿校准位,但这不是通用做法,具体要查芯片手册。
6. 验证技巧:回环测试与逻辑分析仪让时序一目了然
拿到这套例程后,建议不要急着接真实从机,先做一次回环测试。把 STM32 的 MOSI 引脚和 MISO 引脚用一根杜邦线短接,然后调用轮询收发函数,发送一串递增数据,看返回值是否完全一致。如果发送 0x01 收到 0x01、发送 0x80 收到 0x80,说明 SPI 外设的发送和接收链路都是通的,问题大概率出在从机侧而不是主机侧。
void SPI1_Loopback_Test(void) { uint8_t test_data = 0xA5; uint8_t ret = SPI1_ReadWriteByte(test_data); if (ret == test_data) { /* 回环成功,收发链路正常 */ loopback_ok = 1; } else { /* 回环失败,检查 GPIO 配置和 SPI 模式 */ loopback_ok = 0; } }逻辑说明:回环测试的妙处在于它把所有从机侧的干扰因素全部排除掉了,只验证主机外设本身。参数说明:test_data 随便选一个非对称的数值,比如 0xA5、0x55、0xCC,便于在示波器上观察波形特征。
回环通过后再接真实设备,此时如果还调不通,就要上逻辑分析仪抓时序了。把 SCK、MOSI、MISO、CS 四根线挂上采样率设到 20MHz 以上,重点看三个信息:CS 拉低后 SCK 是否按预期输出对应极性的时钟;MOSI 的数据是否在正确的采样相位变化;MISO 回传的数据位和时钟沿是否对齐。我见过很多“代码翻来覆去改不对”的案例,最后靠一帧时序图直接定位到从机芯片某根引脚虚焊,根本不是软件问题。从那以后我每次调 SPI,都强制先跑一遍回环测试,再挂逻辑分析仪看一帧完整时序。希望帮到你。
本文还有配套的精品资源,点击获取