这篇是《STM32寄存器白话手册》的第五章。前四章一路读下来,你应该已经把LED点亮、把系统时钟理清楚、也知道了寄存器操作和中断是怎么回事。这一章我选择讲SPI。它在通信场景里出场率极高,和USART比,它的时序更固定、逻辑更简单,四根线就能把数据送出去,但恰恰因为简单,很多人反而容易在细节上翻车。寄存器流开发还能让你直接看到发送缓冲、接收缓冲、忙标志这些底层状态,这对理解SPI协议本身有奇效。如果你是从HAL库切过来、想弄明白那几行初始化代码背后到底改了什么,这一章同样适合你,我会把每个寄存器位掰开揉碎讲清楚。
1. 为什么第五章必须讲SPI
1.1 学SPI前你需要哪些底子
SPI看似独立,实际和前面章节的内容环环相扣。想顺利操作SPI控制器,你需要已经会配置GPIO复用功能,知道时钟树上的APB外设时钟从哪里来,最好还要理解中断标志位的基本套路。
前四章里,GPIO寄存器操作是基础中的基础。SPI的SCK、MOSI、MISO引脚必须被配置为复用功能,CS片选脚则通常用普通GPIO输出。分频系数的计算依赖你对系统主频的理解,如果连当前PCLK是多少都不知道,SPI通信速率就是一笔糊涂账。中断标志位和轮询标志位本质是同一件事,第五章里我们用轮询读SR状态寄存器,这个套路和看串口的TXE、RXNE几乎一样。
所以这一章不是平地起高楼。能点亮LED、能收发串口字节,你就已经具备了学SPI所需的全部前提。
1.2 寄存器操作与HAL库在SPI上的本质差异
很多人第一次接触SPI都是用现成库函数,比如调用一次发送接口就能把一包数据发出去,看起来简单。可一旦波形不对、数据错位,库函数只会丢给你一个超时错误,你根本不知道哪一步出了问题。
寄存器操作则完全不同。你自己控制时钟、自己写GPIO复用寄存器、自己查看TBXEFLYE和BTX标志位,每一步都有据可查。写寄存器驱动时你会被迫理解:为什么发送前一定要等TXE置1?为什么接收后不读DR数据会溢出?这些细节恰恰是SPI稳定运行的关键。我的观点是:无论你以后用不用寄存器开发,至少把一个外设用寄存器方式完整走一遍,形成对底层硬件的手感,再回去看库封装的头文件,会一下子通透很多。
2. 协议补课:SPI总线上到底在传什么
2.1 四根线,各自扮演什么角色
SPI是主从结构的同步串行通信,最常见的接法是四根信号线:SCK时钟线、MOSI主出从入、MISO主入从出、CS片选线。
你可以把SCK理解成节拍器,主设备产生多少个时钟脉冲,就完成多少个bit的数据交换。MOSI是主机发送、从机接收的单向线,MISO则反过来。CS线是整个通信的启动信号,低电平有效,主机把CS拉低说明“我开始和你说话了”,其他从设备看到CS没被拉低,就保持沉默。值得注意的是,SPI和串口最大的不同在于它本质是移位交换:主机发一个bit的同时,从机也会送一个bit出来。这意味着每一次对DR寄存器的写入,都会同时启动一次接收,只是你读取的时机需要在一次完整交换之后。
2.2 CPOL与CPHA:四种模式怎么选
SPI时钟极性CPOL决定空闲时SCK是高电平还是低电平,时钟相位CPHA决定数据采样发生在第一个边沿还是第二个边沿。两者组合出四种模式,从Mode 0到Mode 3。
| 模式 | CPOL | CPHA | 空闲SCK电平 | 数据采样边沿 |
|---|---|---|---|---|
| Mode 0 | 0 | 0 | 低电平 | 第一个上升沿 |
| Mode 1 | 0 | 1 | 低电平 | 第二个下降沿 |
| Mode 2 | 1 | 0 | 高电平 | 第一个下降沿 |
| Mode 3 | 1 | 1 | 高电平 | 第二个上升沿 |
关于模式选择,我的经验是:先去查从设备的数据手册,看它在哪种模式下工作,然后再配置STM32对应寄存器。比如很多串行Flash默认支持Mode 0和Mode 3,而某些LCD驱动芯片则固定要求Mode 0。如果你把模式配错,经常出现数据能发出去、收回来却是乱码的诡异情况,波形上看着每根线都有动作,但就是死活不通。
2.3 主从之间一次完整的字节传输
一次SPI字节交换可以简化为:主机把CS拉低,然后向DR寄存器写入一个字节,硬件会按照你配置的波特率把8个bit逐位移到MOSI线上。与此同时,从机也把它的数据逐位移到MISO线上。
等待内部移位完成、接收缓冲非空之后,主机读取DR,就能拿到从机返回的字节。像Flash这类器件,主机的发送数据往往是“命令字节+地址”,从机返回的数据可能是“空字节+真正内容”。这段时序需要在脑子里有一个清晰模型,后面写驱动才不会被“为什么发送0却收到了数据”这种问题卡住。
3. STM32 SPI寄存器逐个说人话
3.1 CR1寄存器:模式、分频和总开关
CR1是SPI外设最主要的一个控制寄存器,它同时掌管协议模式、主从选择、分频系数和使能开关。它包含位:
- 第6位SPE是SPI使能位,相当于总电源开关。配置其他位的时候通常先把SPE置0,全部配置完成后再置1。
- 第3位MSTR主从选择位,置1是主机,清0是从机。
- 第2位CPOL和第1位CPHA决定四种SPI模式。
- 第6到第4位是BR[2:0],波特率预分频。具体分频值是2^(BR值+1),例如BR=0代表2分频,BR=3代表16分频。
- 第10位SSM和第9位SSI用来管理NSS引脚,做主机时强烈建议用软件方式管理NSS,把SSM置1、SSI置1,然后完全无视NSS引脚动态,用普通GPIO代替CS控制。
- 第8位LSBFIRST是数据位顺序,默认从最高位开始发送,对应大部分器件的习惯,不要乱改。
- 第7位RXONLY如果置1,SPI会变成只接收模式,主机不再输出时钟,这个位在主机主动读取从机数据时可以配合MSTR使用,但初学阶段不要动它。
- 第12位DFF是数据帧格式,默认8位,对应绝大多数场景。
我建议初学阶段把CR1当成一个开关集合看待:先把模式位、分频位、主从位按需配好,最后开SPE。不要先开SPE再改MSTR、CPOL,那样会让内部逻辑进入一个不确定状态。虽然大多数情况下改完也能正常工作,但这是不必要的风险。
3.2 SR寄存器:判断能不能发、能不能收
SR状态寄存器是SPI的仪表盘。它最关键的两个标志位是:第1位TXE发送缓冲空,和第0位RXNE接收缓冲非空。
当TXE为1,说明发送缓冲腾出来了,你可以往DR寄存器写入下一个字节。当RXNE为1,说明一个字节的接收已经完成,数据正等在DR寄存器里,你应该尽快把它读走。还有第7位BSY忙标志,指示SPI是否正在收发,不过平时轮询方式下很少需要等BSY,等TXE和RXNE就够了。
还有一个容易被忽略的溢出标志OVR,它在接收缓冲还没被读走、新数据又到达时置1。处理OVR的方法很讲究:官方推荐的顺序是先读SR再读DR,这样能正确清除溢出标志。反过来的顺序有时也能清掉,但不够规范。养成“先状态、后数据”的习惯,后面调多个字节连续通信时能少踩很多坑。
3.3 DR寄存器:发送接收共用的窗口
DR是SPI的数据窗口,16位宽。发送字节时把它写到DR,硬件自动把内容移到移位寄存器并通过MOSI发出去;接收字节时,MISO进来的数据完成移位后,最终也汇集到DR里。
在默认8位帧格式下,读写DR都只要关心低8位。需要注意的是,写DR的时候,一旦写入,TXE标志就会很快变成0,直到这个字节完整发送结束才再次变成1。所以连续发送多字节时一定要检查TXE或者RXNE,否则丢数据是必然的。读DR时,则务必等RXNE为1再读,读空缓冲只会得到无意义的数据。
3.4 NSS的硬件与软件管理
NSS是SPI的片选引脚。STM32提供了硬件NSS管理模式和软件NSS管理模式。
硬件模式下,NSS引脚的电平会被SPI控制器实时监测。主模式下如果NSS引脚被外设拉低,SPI可能自动切换成从模式,这在不经意间就会导致通信失效。软件模式则是把NSS的控制权完全收回,让SPI忽略NSS引脚的电平状态,片选逻辑交给普通GPIO来做。
实用中我基本不用硬件NSS,除非特殊场景需要多主机仲裁,否则软件模式加普通GPIO控制CS就是最稳的方案。尤其对串行Flash这类器件,CS是纯粹的片选信号,高电平表示空闲,低电平表示选中,完全不需要硬件介入。
4. 手把手搭建寄存器版SPI驱动
4.1 GPIO复用先行,别急着碰SPI寄存器
很多人上来就配SPI寄存器,结果发现SCK没波形,最后才意识到引脚根本没切换到复用功能。在STM32 F1系列里,SPI1默认可以映射到PA4到PA7这几个引脚,但需要修改GPIO复用配置。
还是以SPI1、PA4作为软件CS、PA5为SCK、PA6为MISO、PA7为MOSI为例。F1的GPIO是32位寄存器CRL管理低16位引脚,4位一组描述一个引脚。其中MODE位指示速率,CNF位指示输入还是复用输出。PA5是普通输出或复用推挽,模式10代表50MHz复用推挽,PA6输入模式则要用浮空输入,PA4作为CS配置成通用推挽输出就行。
// 开启相关时钟 RCC->APB2ENR |= (1 << 2); // GPIOA时钟 RCC->APB2ENR |= (1 << 0); // AFIO时钟,部分复用功能需要 RCC->APB2ENR |= (1 << 12); // SPI1时钟 // 配置PA4为通用推挽输出,作为软件CS GPIOA->CRL &= ~(0xF << 16); GPIOA->CRL |= (0x3 << 16); // 配置PA5为复用推挽输出,SPI1_SCK GPIOA->CRL &= ~(0xF << 20); GPIOA->CRL |= (0xB << 20); // 配置PA6为复用浮空输入,SPI1_MISO GPIOA->CRL &= ~(0xF << 24); GPIOA->CRL |= (0x4 << 24); // 配置PA7为复用推挽输出,SPI1_MOSI GPIOA->CRL &= ~(0xF << 28); GPIOA->CRL |= (0xB << 28);这段代码的顺序有讲究:先开时钟,再配置引脚,最后才碰SPI寄存器。如果反过来,引脚模式还没切到复用功能,SPI就已经被开启,第一帧数据大概率发不出来。
4.2 分频系数我们当场算一遍
SPI1挂在APB2总线上,常见F1的系统时钟是72MHz。串行Flash这类外设通常允许最大几十MHz的时钟,为了留足裕量,把它降到约4.5MHz比较稳妥。4.5MHz恰好对应72MHz除以16的情况,所以BR[2:0]应该选3,也就是011。
按照里面的公式,BR值n对应分频2^(n+1)。n=3就是16分频,PCLK=72MHz,通信时钟就是4.5MHz。下面这个初始化函数完整执行了复位、模式配置、软件NSS配置和使能操作:
void SPI1_Init(void) { // 复位SPI1,确保从干净状态开始 RCC->APB2RSTR |= (1 << 12); RCC->APB2RSTR &= ~(1 << 12); // 配置SPI1控制寄存器 SPI1->CR1 = 0; // 软件NSS模式:SSM=1, SSI=1 SPI1->CR1 |= (1 << 10) | (1 << 9); // 主机模式:MSTR=1 SPI1->CR1 |= (1 << 3); // CPOL=0, CPHA=0,即Mode 0 // 分频BR=011,即PCLK/16 SPI1->CR1 |= (3 << 4); // 使能SPI SPI1->CR1 |= (1 << 6); }4.3 收发一体的核心函数
SPI通信的发送和接收是同一个动作,不需要像串口那样分开写。一个统一的交换函数即可:
uint8_t SPI1_ReadWriteByte(uint8_t byte) { // 等待发送缓冲空 while (!(SPI1->SR & (1 << 1))); // 写入发送数据,立即开始移位交换 SPI1->DR = byte; // 等待接收缓冲非空 while (!(SPI1->SR & (1 << 0))); // 返回收到的数据 return (uint8_t)(SPI1->DR & 0xFF); }这个函数同时完成了发送和接收。发送0x9F,你会收到从机返回的3字节JEDEC ID;发送0x00,如果从机正常工作,你会收到它当前地址上的数据。理解这一点,是掌握SPI的关键。
4.4 用串行Flash把驱动跑起来
我常用一颗串行Flash来验证SPI驱动。它支持JEDEC标准读ID指令0x9F,返回三个字节的制造商ID和型号信息。代码如下:
#define CS_LOW() (GPIOA->BRR = (1 << 4)) #define CS_HIGH() (GPIOA->BSRR = (1 << 4)) uint32_t Flash_ReadID(void) { uint32_t id = 0; CS_LOW(); SPI1_ReadWriteByte(0x9F); // 发送读ID指令 id = SPI1_ReadWriteByte(0x00); // 接收第一字节 id = (id << 8) | SPI1_ReadWriteByte(0x00); // 第二字节 id = (id << 8) | SPI1_ReadWriteByte(0x00); // 第三字节 CS_HIGH(); return id; }CS必须先拉低,再开始发送指令。指令发送完毕、把需要的数据全部读完之后,再把CS拉高。整个过程保持低电平是Flash正常工作的大前提,如果CS拉高过早或者过晚,读回来的ID都会异常。
我还习惯在验证驱动后顺手把Byte Read指令0x03加上:
uint8_t Flash_ReadByte(uint32_t addr) { uint8_t value = 0; CS_LOW(); SPI1_ReadWriteByte(0x03); SPI1_ReadWriteByte((addr >> 16) & 0xFF); SPI1_ReadWriteByte((addr >> 8) & 0xFF); SPI1_ReadWriteByte(addr & 0xFF); value = SPI1_ReadWriteByte(0x00); CS_HIGH(); return value; }读ID正确代表驱动本身没问题,读任意地址字节正常则说明寻址逻辑也正确。能在Flash上跑通这两步,SPI对你来说就不再是黑盒子了。
5. 从HAL切过来的人,如何快速建立映射
5.1 HAL初始化参数对照CR1位
如果你用过库函数,那你一定见过SPI初始化结构体。把HAL里那一个个字段和CR1寄存器位对应起来,是快速建立底层认知的最好方法。
| HAL结构体成员 | 作用 | 对应寄存器位 |
|---|---|---|
| Mode | 主机还是从机 | MSTR |
| BaudRatePrescaler | 波特率预分频 | BR[2:0] |
| ClockPolarity | 空闲时钟极性 | CPOL |
| ClockPhase | 采样边沿 | CPHA |
| DataSize | 帧长度 | DFF |
| FirstBit | 位序 | LSBFIRST |
| NSS | 片选管理 | SSM/SSI和CR2的SSOE |
| TIMode | TI模式 | 对应FRF等配置 |
每次用库函数把参数填完,你其实就是在生成一份CR1的值,只是工具帮你算了结果。一旦通信异常,与其去翻库源码,不如直接读CR1寄存器看当前状态,往往一眼就能发现问题。我现在调试时习惯把关键寄存器值打印出来,比盲猜配置要快得多。
5.2 库函数背后帮你省了什么,又藏了什么
库函数最大价值是抽象和封装,它让你可以用一行代码完成初始化。但问题在于它屏蔽了执行顺序和状态判断。比如HAL的SPI发送接口内部会等待TXE置1,但它异常时返回一个超时错误,错误原因可能需要你去查库源码才知道。
寄存器操作则把这一层全部铺开。你亲眼看到TXE什么时候变1、RXNE什么时候变1、BSY到底什么时候拉高。建立在寄存器理解之上的HAL使用,会让你在排查问题时有自己的判断力,而不是换参数试试。
5.3 一个实战映射案例
假设你在HAL驱动里看到配置字符串时钟为4.5MHz,心里要有这样一个推算过程:系统时钟72MHz,挂在APB2外设上,SPI1需要16分频。这项计算在寄存器手册里并不复杂,但库函数只给你预设好的枚举值,不告诉你它代表多少分频。
知道这一点,你在排查慢速设备通信失败时,就会自然地去确认是否分频不足导致时序过快,而不是盲目增加延时。真实项目中,我遇到过几次Flash读取偶发失败,最后都是因为分频配得太高,板子走线一长,信号边沿不够陡峭导致采样不准。这种问题用库函数调参很难发现,用寄存器方式却可以从时钟上直接算出来。
6. 调试SPI,我把波形外的实战技巧也写全
6.1 示波器和逻辑分析仪看什么
调SPI最直接的手段是看波形,但很多人不知道要看哪些点。我建议按四步验证:首先看CS拉低后SCK有没有正常输出脉冲,没有脉冲说明时钟或分频配置有问题;其次看MISO上的数据相对于SCK边沿是否稳定,不稳定往往是相位模式不对;再次看数据位是否连续,有没有多余或丢失;最后确认CS拉高的时机对不对,太长或者太短都可能让从设备误判。
如果条件有限,没有示波器,也可以用GPIO点灯法辅助判断。比如在TXE等待循环之前点亮一个灯,在RXNE等待循环之后点亮另一个灯,观察灯的闪烁规律就能大致推断程序卡在哪一步。这个方法虽然简陋,但能帮你快速缩小问题范围。
6.2 状态寄存器是排错的第一现场
波形只能告诉你现象,状态寄存器却可以告诉你原因。SPI调试时我习惯先读SR寄存器,然后对照标志位判断:
- 如果TXE一直为0,说明配置有问题,可能SPI没有使能,或者引脚没有复用成功。
- 如果RXNE一直为0,说明从设备没有送达数据,要检查CS、供电以及从设备本身是否工作。
- 如果OVR置1,说明接收缓冲被新数据冲掉,处理办法是先读SR再读DR。
读SR的顺序也是很多人的盲区。正确清除OVR的方式一定是先读SR,再读DR。这和你写接收中断代码时的处理逻辑完全一致,靠这个顺序确保控制器把溢出标志释放掉,否则下一次接收可能仍带有残留状态。
6.3 没有波形工具时如何一步步缩小问题
没有工具不代表不能调试。我的思路是:先单字节通信,再连续通信;先慢速,再提速;先读固定ID,再读可变内容。把问题拆成最小闭环,每个环节各验证一次。
实践中我遇到过数据全0xFF的情况,排查后发现是MOSI和MISO接反了。还遇到过CS拉低后SCK有波形、数据却不对的情况,最终定位是CPHA配错。这些问题在波形工具下其实一眼就能看出来,但没有工具时,通过修改模式、交换引脚、降低速率等变量逐一试验,同样能锁定原因。
7. 常见问题速查与排查实录
7.1 一张速查表解决大部分SPI报错
这里把调试中最高频的几个现象整理成表格,方便大家直接对照。
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| SCK无波形 | 时钟未开启、SPE未置1、引脚未复用 | 检查RCC使能位、CR1配置、GPIO复用 |
| 数据全0xFF | MOSI/MISO接反、从设备未上电、CS未拉低 | 检查接线、供电、CS控制 |
| 数据全0x00 | 主机发送正常但接收线浮动 | 检查MISO复用配置、主设备模式 |
| 收发乱码 | CPOL/CPHA不匹配 | 查询从设备手册,修改CR1对应位 |
| 偶发丢字节 | 波特率过高、分频太小 | 降低SPI速率,或优化走线 |
| OVR溢出标志频繁出现 | 读取DR不及时 | 使用中断或DMA,或提高读取优先级 |
| 从机不响应 | CS时序错误 | 确认CS拉低时序是否覆盖完整指令 |
7.2 一个个真实踩坑记录
我在调试某Flash读取时,遇到过读回ID前两字节正常、第三字节却是0xFF的诡异故障。逻辑分析仪显示波形完整,CS时序也对,后来发现是主控供电在通信瞬间电压跌落,Flash工作电压不足,导致最后一字节丢失。这个问题提醒我,SPI问题不一定只在SPI本身,电源稳定性也是排查重点。
另一个项目里,SPI通信速率调到18MHz后开始随机出错,降回9MHz就稳定。排查后发现PCB上SCK走线经过一个过孔,过孔阻抗不连续引起信号振铃。这个案例说明,分频系数的选择要综合考虑信号完整性和器件极限,不能只看理论速率。
7.3 中断和DMA要不要用
轮询方式适合学习阶段和数据量小的场景。一旦涉及大块数据传输,比如从Flash读取固件、驱动LCD刷屏,轮询会长时间占住CPU。这时可以考虑中断接收,或者用DMA把数据搬运交给硬件完成。
寄存器开发下开DMA也不复杂:把TXDMAEN置1、RXDMAEN置1,然后配置好DMA通道和目的地址即可。不过我建议先把轮询模式调通,再考虑中断与DMA,否则问题叠加在一起会非常难排查。还有一个细节,发送结束不能只看TXE,要确认BSY从1变到0,表示移位寄存器彻底把最后一位送出去了,否则CS提前拉高会导致最后一点数据被截断。
8. 写给后来者的几句实在话
SPI寄存器开发真的不难,难的是你之前没机会看到底层发生了什么。当你亲手配完GPIO、算好分频、看到CS这个信号一拉低,数据就顺着时钟线流出,再读回几句有意义的反馈时,那种感觉比用库函数调通一个接口要踏实得多。
我个人建议,如果你想把这章内容真正内化,不要只复制代码去跑通,而是尝试改三次:第一次把SPI从主机改成从机,看看寄存器状态会发生什么变化;第二次把Mode 0改成Mode 3,观察波形一样但数据是否有差异;第三次把BR值改到最小分频,人为制造偶发错误,然后用本章的排查思路定位一次问题。把这三轮做完,你对SPI的理解至少会提升一个台阶。
下一章我会接着讲定时器,会更贴近PWM输出和输入捕获。如果这一章里有些细节暂时没想明白,直接翻芯片参考手册的SPI章节,配合本文的寄存器位对照表,配合一块最小系统板,亲手调一遍。寄存器手册并不会咬人,它只是特别老实,你问什么它答什么。