1. 项目概述为什么SPI对STM32F1如此重要如果你正在捣鼓STM32F1系列的单片机无论是做个小显示屏驱动、读写个SD卡还是和传感器通信大概率绕不开一个叫做SPI的接口。这东西全称叫串行外设接口听起来挺“外设”感觉像是配角但实际上在STM32F1的世界里它是个实打实的多面手和性能担当。我刚开始接触STM32的时候觉得UART串口用着挺顺手配置简单直到有一次需要高速、实时地读取一个陀螺仪芯片的数据用UART怎么都跟不上才被迫去啃SPI这块硬骨头。结果一用上就发现这玩意儿简直是单片机与外部芯片“对话”的高速公路一旦掌握很多项目的性能瓶颈就迎刃而解了。STM32F1的SPI模块硬件上已经帮你把通信的底层时序、数据移位这些繁琐的活都包揽了你只需要配置好几个寄存器就能以几Mbps甚至十几Mbps的速度稳定收发数据。它不像I2C那样要寻址、应答协议简单粗暴就是“我说你听”或者“同时说同时听”特别适合对速度有要求、数据量不大的场景。比如驱动OLED屏、FLASH芯片如W25Qxx系列、无线模块如NRF24L01等等SPI都是首选方案。但它的配置选项也比UART复杂一些什么时钟极性、相位、数据帧格式、主从模式一开始容易让人发懵。这篇内容我就结合自己踩过的坑和项目经验把STM32F1的SPI模块从协议原理到实操配置掰开揉碎了讲清楚目标是让你看完就能在自己的板子上跑起来。2. SPI协议核心思想与STM32F1的硬件实现2.1 SPI协议的精髓四线制与全双工同步SPI协议的核心思想其实非常直观它不像UART是异步的双方各自用自己的时钟也不像I2C是半双工的同一时刻只能单向传输。SPI是一种同步、全双工的串行通信协议。同步意味着通信双方共用一根时钟线SCK由主机产生从机根据这个时钟节拍来收发数据这就从根本上避免了因时钟频率微小差异导致的累积误差可以实现很高的通信速率。全双工意味着数据输入MISO和输出MOSI线是独立的主机和从机可以同时发送和接收数据效率翻倍。标准的SPI需要四根线SCK (Serial Clock)串行时钟线由主机产生和控制。MOSI (Master Output Slave Input)主机输出从机输入线。主机通过这根线发送数据给从机。MISO (Master Input Slave Output)主机输入从机输出线。主机通过这根线接收从机发来的数据。NSS (Slave Select)从机选择线也常称为CS Chip Select。这是片选信号低电平有效。主机通过将某个从机的NSS线拉低来“选中”并激活它与之通信。一个主机可以连接多个从机通过多根NSS线分别控制。注意很多资料会提到“三线制SPI”即只有SCK、MOSI和NSS没有MISO。这实际上是半双工模式只能主机向从机写数据无法读取。在STM32中通过配置“仅发送”模式可以实现但应用场景较窄。STM32F1的SPI硬件模块完美实现了这个四线制协议。它内部包含移位寄存器、发送/接收缓冲区、波特率发生器以及控制逻辑。当你把数据写入发送数据寄存器SPI_DR后硬件会在SCK的每个时钟沿自动将数据一位一位地从MOSI引脚移出同时MISO引脚上的数据也会被一位一位地采样并移入接收数据寄存器。整个过程完全由硬件完成CPU只需要在数据收发前后进行读写操作极大地解放了CPU资源特别适合配合DMA进行大批量数据传输。2.2 理解时钟极性(CPOL)与时钟相位(CPHA)通信的“节奏感”这是SPI配置中最容易让人困惑但也最关键的两个参数。它们共同定义了数据在时钟信号的哪个边沿被采样捕获和更新输出我习惯称之为通信的“节奏感”。配置错了主机和从机就会“对不上拍子”导致读取的数据全是0xFF或者乱码。时钟极性 CPOL (Clock Polarity)决定了SCK时钟线在空闲状态即两次传输之间NSS为高时时的电平。CPOL0SCK空闲时为低电平。CPOL1SCK空闲时为高电平。 你可以把它想象成乐队的“预备拍”是高举指挥棒高电平准备还是放下指挥棒低电平准备。时钟相位 CPHA (Clock Phase)决定了数据是在SCK的第几个边沿被采样。CPHA0数据在SCK的第一个边沿即从空闲状态跳变到第一个有效状态的边沿被采样。对于CPOL0第一个边沿是上升沿对于CPOL1第一个边沿是下降沿。数据则在相反的边沿更新。CPHA1数据在SCK的第二个边沿即第一个边沿之后的下一个边沿被采样。数据则在第一个边沿更新。CPOL和CPHA组合起来就形成了SPI的四种工作模式Mode 0, 1, 2, 3。这是必须与你的从设备传感器、FLASH等手册保持一致的参数没有商量余地。模式CPOLCPHASCK空闲电平数据采样边沿数据更新边沿常见器件举例Mode 000低电平第一个边沿上升沿下降沿很多FLASH芯片如W25Q64 SD卡SPI模式Mode 101低电平第二个边沿下降沿上升沿较少见Mode 210高电平第一个边沿下降沿上升沿较少见Mode 311高电平第二个边沿上升沿**下降沿一些RFID芯片 MPU6000传感器实操心得绝大多数常见的SPI器件如W25Q系列SPI FLASH OLED显示屏驱动芯片都使用Mode 0。当你遇到一个新器件第一件事就是翻数据手册找到“SPI timing diagram”或“interface”章节确认其要求的CPOL和CPHA。如果找不到可以先用Mode 0尝试因为它是应用最广泛的。2.3 数据帧格式与传输顺序先发高位还是低位除了时钟模式数据帧的格式也需要关注。STM32F1的SPI支持8位或16位数据帧。通常我们使用8位也就是一次传输一字节数据。另一个关键点是数据顺序Data Order即MSB最高有效位先行还是LSB最低有效位先行。这决定了你是先发送/接收一个字节的最高位bit7还是最低位bit0。例如要发送数据0x5A二进制0101 1010MSB First发送顺序是0(bit7),1(bit6),0(bit5),1(bit4),1(bit3),0(bit2),1(bit1),0(bit0)。LSB First发送顺序完全相反从0(bit0)开始。这个顺序也必须与从设备匹配。幸运的是绝大多数器件都采用MSB First这也是STM32 SPI的默认配置。如果你遇到一个要求LSB First的“另类”器件就需要在SPI控制寄存器中专门配置一下。3. STM32F1 SPI模块的配置与驱动编写3.1 硬件连接与引脚复用检查动手写代码前先确保硬件连接正确。以最常见的STM32F103C8T6蓝色药丸板的SPI1为例PA5-SCKPA6-MISOPA7-MOSIPA4或任意GPIO -NSS(对于从机选择我们通常用软件控制一个普通GPIO更灵活)注意STM32的SPI引脚是复用的一定要在代码中开启对应GPIO的复用功能AFIO并将引脚模式配置为复用推挽输出对于SCK MOSI或浮空输入/上拉输入对于MISO。NSS引脚如果用软件控制就配置为普通推挽输出即可。3.2 使用标准外设库SPL进行初始化配置虽然现在HAL库更流行但标准外设库SPL的代码对于理解寄存器操作更直观。下面是一个SPI1主机模式、Mode 0、MSB First的初始化函数示例并附上详细注释/** * brief 初始化SPI1为主机模式 * param 无 * retval 无 */ void SPI1_Init(void) { SPI_InitTypeDef SPI_InitStructure; GPIO_InitTypeDef GPIO_InitStructure; /* 1. 开启时钟 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 开启GPIOA时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE); // 开启SPI1时钟SPI1挂载在APB2高速总线上 /* 2. 配置SPI引脚PA5-SCK, PA6-MISO, PA7-MOSI */ // 配置SCK和MOSI为复用推挽输出速度50MHz GPIO_InitStructure.GPIO_Pin GPIO_Pin_5 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; // 复用推挽输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // 配置MISO为浮空输入或上拉输入根据外部电路决定 GPIO_InitStructure.GPIO_Pin GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; // 浮空输入 GPIO_Init(GPIOA, GPIO_InitStructure); /* 3. 配置NSS引脚这里用PA4为普通推挽输出软件控制 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_4; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; // 推挽输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); SPI1_CS_HIGH(); // 宏定义将PA4置高默认不选中从机 /* 4. 配置SPI1工作参数 */ SPI_InitStructure.SPI_Direction SPI_Direction_2Lines_FullDuplex; // 全双工 SPI_InitStructure.SPI_Mode SPI_Mode_Master; // 主机模式 SPI_InitStructure.SPI_DataSize SPI_DataSize_8b; // 8位数据帧 SPI_InitStructure.SPI_CPOL SPI_CPOL_Low; // CPOL0空闲低电平 SPI_InitStructure.SPI_CPHA SPI_CPHA_1Edge; // CPHA0第一个边沿采样 SPI_InitStructure.SPI_NSS SPI_NSS_Soft; // 软件控制NSS重要 SPI_InitStructure.SPI_BaudRatePrescaler SPI_BaudRatePrescaler_256; // 波特率预分频决定SCK速度 SPI_InitStructure.SPI_FirstBit SPI_FirstBit_MSB; // MSB先行 SPI_InitStructure.SPI_CRCPolynomial 7; // CRC多项式默认7不用CRC时可忽略 SPI_Init(SPI1, SPI_InitStructure); /* 5. 使能SPI1 */ SPI_Cmd(SPI1, ENABLE); }关键参数解析SPI_NSS_Soft强烈建议在主机模式下使用软件NSS控制。硬件NSS模式容易出问题特别是单主机单从机时用软件控制一个GPIO来拉高拉低片选信号是最稳定、最灵活的方式。SPI_BaudRatePrescaler这是决定SPI时钟SCK频率的关键。SCK频率 APB2时钟频率 / 预分频值。APB2在STM32F103上通常是72MHz系统时钟。SPI_BaudRatePrescaler_256意味着SCK 72MHz / 256 ≈ 281.25 kHz。你需要根据从设备支持的最高SPI时钟速度来选择合适的预分频。比如W25Q64最高支持104MHz你就可以用SPI_BaudRatePrescaler_236MHz甚至SPI_BaudRatePrescaler_418MHz。SPI_CPHA_1Edge这个枚举值的命名有点反直觉。1Edge对应的是CPHA0在第一个边沿采样2Edge对应的是CPHA1在第二个边沿采样。务必对照数据手册理解。3.3 实现基础的字节收发函数SPI初始化好后核心就是两个函数发送一个字节并接收返回的字节因为全双工收发同时进行。下面是一个阻塞式的实现/** * brief SPI1 读写一个字节 * param TxData: 要发送的字节 * retval 接收到的字节 */ uint8_t SPI1_ReadWriteByte(uint8_t TxData) { /* 等待发送缓冲区空TXE标志置1表示可以写入新数据 */ while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) RESET); /* 写入要发送的数据到数据寄存器(DR)写入操作会自动清除TXE标志 */ SPI_I2S_SendData(SPI1, TxData); /* 等待接收缓冲区非空RXNE标志置1表示数据已接收完毕 */ while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) RESET); /* 读取数据寄存器(DR)读取操作会自动清除RXNE标志 */ return SPI_I2S_ReceiveData(SPI1); }这个函数是SPI通信的基石。当你需要向从设备写入一个命令时你发送命令码同时会收到一个“垃圾数据”通常是之前传输残留或从机在MOSI为高阻态时的上拉值。当你需要读取从设备数据时你通常需要先发送一个虚拟字节如0xFF或0x00来“驱动”时钟从而把从机数据“挤”出来。软件控制NSS的典型流程// 要和一个SPI从设备通信 SPI1_CS_LOW(); // 拉低片选选中从机 delay_us(10); // 稍作延时等待从机准备就绪根据器件手册要求 SPI1_ReadWriteByte(cmd); // 发送命令字节 SPI1_ReadWriteByte(addr_high); // 发送地址高字节 SPI1_ReadWriteByte(addr_low); // 发送地址低字节 data SPI1_ReadWriteByte(0xFF); // 发送虚拟字节同时读取数据 SPI1_CS_HIGH(); // 拉高片选结束通信4. 进阶应用与性能优化技巧4.1 使用DMA进行大批量数据传输当你需要读写SPI FLASH的一个扇区比如4KB或者向显示屏发送一整帧图像数据时如果还用上面那种一个字节一个字节轮询的方式CPU会被完全占用效率极低。这时就该DMA直接存储器访问登场了。DMA可以在不打扰CPU的情况下自动在SPI数据寄存器和内存之间搬运数据。你只需要配置好源地址内存、目标地址SPI-DR、数据长度然后启动DMA和SPI的发送/接收就可以去处理其他任务了。等DMA传输完成中断触发你再去做后续处理。配置SPI TX DMA发送的大致步骤初始化DMA通道例如SPI1_TX用DMA1_Channel3。配置外设地址为(uint32_t)(SPI1-DR)内存地址为你数据数组的地址。配置传输方向为内存到外设数据宽度为字节或半字与SPI数据帧匹配。使能DMA通道。在SPI控制寄存器中使能TX DMA请求 (SPI_I2S_DMACmd(SPI1, SPI_I2S_DMAReq_Tx, ENABLE)。启动SPI传输通常是通过写入DR触发。在DMA传输完成中断中关闭DMA和SPI的DMA请求处理后续。实操心得SPI的RX DMA接收配置要小心。对于全双工接收通常需要同时使能TX DMA和RX DMA。因为SPI接收数据的前提是有时钟而时钟需要主机发送数据来产生。所以即使你只想读数据也需要用TX DMA发送一串虚拟数据如0xFF来“喂”时钟同时用RX DMA接收数据。TX和RX的DMA缓存长度要设置一致。4.2 应对不同从设备的特殊时序要求不是所有SPI设备都那么“标准”。有些设备有特殊的时序要求需要你在通信流程中插入延时或调整NSS信号。指令-地址-数据之间的延时有些FLASH芯片在发送写使能WREN指令后需要等待一小段时间t_WEL才能接受下一个指令。这时需要在SPI1_CS_HIGH()后加一个delay_us(5)之类的短延时。NSS信号管理有些设备要求在整个指令、地址、数据序列期间NSS必须持续保持低电平。而有些设备则允许在传输长数据时NSS可以短暂拉高再拉低但通常不建议。务必遵守数据手册的时序图。双向数据线3线SPI有些OLED屏驱动芯片如SSD1306为了节省引脚采用3线SPI只有SCK CS 和一根双向数据线D/C。这需要你在发送命令和数据时通过一个额外的GPIOD/C引脚来告知从机当前发送的是命令还是数据。这本质上还是用GPIO模拟了部分协议并非标准的SPI四线模式。4.3 多从机系统的软件架构建议当一个STM32主机需要连接多个SPI从机时比如同时接了一个FLASH和一个传感器硬件连接上可以将所有从机的SCK、MOSI、MISO分别并联到主机的对应引脚但每个从机必须有自己独立的NSS线GPIO。软件设计上关键是要保证任何时候只有一条NSS线是低电平。一个清晰的架构是为每个从设备编写一个独立的驱动文件如w25qxx.cmpu6050_spi.c在每个驱动的读写函数内部封装好对自身NSS引脚的操作。同时在更高层的应用逻辑中避免交叉调用或中断打断正在进行的SPI通信防止NSS信号冲突。如果实在有并发需求可以考虑使用互斥锁在RTOS中或标志位来对SPI总线进行软件层面的加锁和解锁。5. 调试排错与常见问题实录SPI通信不出数据或者数据不对是新手常遇到的问题。下面是我总结的一套排查流程和常见坑点。5.1 基础信号检查用示波器或逻辑分析仪“工欲善其事必先利其器”。调试SPI最直观的工具就是示波器或者一个几十块钱的逻辑分析仪。连接好SCK MOSI MISO和NSS线观察NSS信号在调用读写函数时是否产生了从高到低的跳变脉冲宽度是否符合从机要求SCK信号是否有时钟输出频率是否和你配置的一致通过预分频计算空闲电平是否符合CPOL的配置MOSI信号主机发送的数据波形是否出现在时钟的正确边沿根据CPHA判断数据位顺序MSB/LSB是否正确MISO信号从机是否有数据输出如果主机发送时MISO一直是高电平可能是从机没被正确选中NSS问题或者从机本身有故障。没有这些工具怎么办可以用一个“笨办法”将MISO引脚暂时配置为推挽输出在代码里让它输出一个固定的方波。然后用另一个GPIO或延时翻转去读这个引脚的电平。如果能读到变化的电平说明至少你的SPI基本读写函数和GPIO配置是通的问题可能出在从机或硬件连接上。5.2 软件配置与代码逻辑排查如果硬件信号基本正常但数据还是不对重点检查软件模式匹配这是头号杀手反复确认你的CPOL和CPHA设置是否与从机设备100%匹配。用逻辑分析仪看时序图对照从机手册的时序图一个边沿一个边沿地对。NSS配置主机模式下是否配置了SPI_NSS_Soft如果配成了SPI_NSS_Hard可能会导致奇怪的错误。确保你的软件NSS控制GPIO初始化正确且在通信前后有明确的拉低和拉高操作。时钟速度SPI时钟是否太快超过了从机支持的最大频率尤其是导线较长或有干扰时先降低波特率增大预分频试试。数据顺序检查SPI_FirstBit配置确保与从机要求一致。延时问题从机可能需要指令之间的特定延时t_WR t_PP等。在发送一些特殊命令如写使能、擦除扇区后是否加入了足够的手册要求的延时是否在等待从机的“忙”状态标志通过读状态寄存器函数调用顺序对于有固定命令序列的从机如FLASH的读ID0x90 - 地址 - 读数据你的函数调用顺序、发送的地址字节数是否正确多一个或少一个虚拟字节都会导致错位。5.3 典型问题速查表现象可能原因排查方向读取的数据始终是0xFF或0x001. 从机未被选中NSS问题2. MISO引脚连接错误或配置错误应为浮空/上拉输入3. 从机供电或本身故障4. SPI模式CPOL/CPHA严重不匹配查NSS信号、查MISO硬件、查从机电源、用逻辑分析仪核对时序读取的数据是错位的比如预期0xAB读成0xD51. 数据位顺序MSB/LSB设置错误2. 时钟相位CPHA错误导致采样点偏移一位检查SPI_FirstBit配置用逻辑分析仪看数据位和时钟边沿关系只能写不能读或读写不稳定1. 时钟速度过快2. 导线过长信号质量差有干扰3. 未正确处理从机“忙”状态降低波特率缩短连线加滤波电容在写操作后增加足够延时或轮询忙状态使用DMA时数据乱码或只传输了一部分1. DMA缓存区地址或长度配置错误2. DMA和SPI的使能顺序不对3. 内存地址未对齐如要求字对齐但用了字节4. 未等待DMA传输完成就操作了数据缓冲区仔细检查DMA初始化参数确保先使能SPI的DMA请求再启动DMA在传输完成回调或标志位中处理数据多从机系统中操作A设备影响了B设备1. NSS线在操作A时意外拉低了B的NSS2. SPI总线被意外共享通信过程被中断打断检查GPIO初始化确保NSS引脚彼此独立。对SPI总线操作加临界区保护或互斥锁。调试SPI耐心和细致的观察比盲目修改代码更重要。从最简单的读写一个已知寄存器比如FLASH的器件ID开始确保基础通信是通的再逐步实现更复杂的功能。把逻辑分析仪当成你的眼睛它会告诉你代码和硬件之间发生的真实故事。