1. 从零开始理解SPI:为什么它比UART和I2C更“快”?
如果你刚开始玩STM32,搞定了GPIO点灯,也调通了UART串口打印,那恭喜你,已经迈出了嵌入式世界的第一步。接下来,你大概率会听到两个高频词:I2C和SPI。今天我们不聊I2C,专门聊聊SPI。很多教程一上来就给你看时序图,讲主从模式、时钟极性相位,把人绕得云里雾里。我的建议是,先别管那些细节,咱们从一个最朴素的问题开始:SPI到底是干嘛的,以及它凭什么号称“高速”通信?
简单说,SPI(Serial Peripheral Interface)是一种全双工、同步、串行的通信总线。全双工意味着数据可以同时收和发,同步意味着通信双方靠一根时钟线来对齐节奏,串行意味着数据是一位一位传输的。这听起来和I2C有点像,对吧?但SPI的核心优势就藏在它的“简单”里。它没有像I2C那样的地址概念,也没有复杂的起始、停止、应答信号。它的通信逻辑非常直接:主机发起时钟,从机在时钟的节拍下交换数据。这种“简单粗暴”的设计,牺牲了多设备管理的便利性(需要更多硬件片选线),但换来了极高的数据传输效率。
那么,这个“高速”到底是怎么来的?我们可以和UART、I2C做个快速对比。UART是异步通信,双方需要预先约定好相同的波特率,靠起始位和停止位来框定一帧数据,期间任何时钟的微小偏差都会导致累积误差,因此速度通常到几Mbps就到头了。I2C虽然同步,但它只有一根数据线(SDA),并且需要复杂的仲裁、应答机制,在标准模式下也就100Kbps,快速模式400Kbps,高速模式虽然能到3.4Mbps,但对硬件要求高。而SPI呢?它没有这些开销。通信一旦开始,就是纯粹的时钟驱动下的数据流。在STM32上,SPI的时钟频率可以轻松达到系统主频的一半(例如在72MHz系统下,SPI时钟可达36MHz),实际数据吞吐量非常可观。所以,当你需要驱动一个高速的OLED屏、读取一个快速的FLASH芯片或者连接一个高精度的ADC模块时,SPI往往是首选。
2. 拆解SPI的四大配置要素:CPOL与CPHA到底在说什么?
理解了SPI的定位,我们得直面那个让新手头疼的概念:时钟极性(CPOL)和时钟相位(CPHA)。几乎所有教程都会给你下面这张表,但看完可能还是不懂为什么要分四种模式。
| 模式 | CPOL (Clock Polarity) | CPHA (Clock Phase) | 空闲时SCK电平 | 数据采样时刻 |
|---|---|---|---|---|
| 0 | 0 | 0 | 低电平 | 第一个时钟边沿(上升沿) |
| 1 | 0 | 1 | 低电平 | 第二个时钟边沿(下降沿) |
| 2 | 1 | 0 | 高电平 | 第一个时钟边沿(下降沿) |
| 3 | 1 | 1 | 高电平 | 第二个时钟边沿(上升沿) |
别慌,我们用人话翻译一下。SPI通信就像两个人配合搬运东西(数据),时钟线SCK就是喊“一二一”的口令。
- CPOL(极性):决定了口令“一”的时候是吸气还是呼气。CPOL=0,意思是空闲时(没开始搬)口令线是低电平(吸气状态);CPOL=1,空闲时就是高电平(呼气状态)。
- CPHA(相位):决定了是在喊“一”的时候动手搬,还是在喊“二”的时候动手搬。CPHA=0,表示在第一个时钟边沿(也就是口令“一”喊出的瞬间)采样数据;CPHA=1,则表示在第二个时钟边沿(口令“二”喊出的瞬间)采样数据。
这里的关键是“采样”。对于SPI主机来说,它需要在正确的时刻去读取MISO线上的电平,那这个时刻就是由CPHA决定的。主从设备必须配置成相同的模式,否则一个在“一”的时候送数据,另一个在“二”的时候才来读,数据就全乱了。
注意:最常用的是模式0和模式3。很多传感器、FLASH芯片默认使用模式0。你不需要死记硬背,只需要记住一条黄金法则:查阅你所使用外设的数据手册(Datasheet)!在通信接口章节,一定会明确写明所需的SPI模式。你的STM32程序配置必须与之完全一致。
除了CPOL和CPHA,还有两个重要参数:
- 数据帧格式(Data Size):通常为8位或16位。这决定了一次传输多少比特数据。大部分器件是8位的。
- 时钟频率(Baud Rate):即SCK的频率。主机可以配置分频系数来设定。原则是:在保证通信稳定的前提下,从设备支持多快就配多快。一开始调试可以先用一个较低的频率,比如1MHz,等通信稳定后再逐步提高。
3. STM32 CubeMX实战:配置一个完整的SPI读写流程
理论说得再多,不如动手调一遍。我们以STM32F103C8T6(蓝桥杯常用板)为例,使用CubeMX和HAL库,实现与一个虚拟SPI从设备(比如FLASH芯片W25Q64)的通信。这里我们假设从设备模式为0,数据位宽8位。
3.1 CubeMX图形化配置
首先打开CubeMX,选择你的芯片型号。
启用SPI:在
Pinout & Configuration标签页,左侧找到Connectivity->SPI1。将Mode设置为Full-Duplex Master(全双工主机模式)。此时,对应的引脚(PA5-SCK, PA6-MISO, PA7-MOSI)会自动高亮。配置参数:在下方出现的配置面板中,进行关键设置:
Clock Parameters->Prescaler: 选择8分频(在72MHz系统时钟下,SPI时钟为9MHz)。初次调试可选更大分频,如64分频(约1.1MHz)。Clock Parameters->Clock Polarity: 设置为Low(对应CPOL=0)。Clock Parameters->Clock Phase: 设置为1 Edge(对应CPHA=0,即在第一个边沿采样)。这样组合就是模式0。Data Size: 选择8 bits。First Bit: 选择MSB First(高位先行)。绝大多数器件都是MSB先行。CRC Calculation: 禁用(初期不需要)。NSS Signal Type: 选择Software(软件片选)。这意味着我们需要手动控制一个GPIO引脚来充当片选信号(CS/SS),比如用PA4引脚。你需要在GPIO设置中将PA4设置为GPIO_Output,并给一个初始高电平(因为片选通常是低电平有效)。
生成代码:配置好系统时钟等其他必要项目后,点击
Project Manager,设置好工程路径和IDE(MDK-ARM或STM32CubeIDE),然后点击GENERATE CODE。
3.2 编写驱动代码:封装发送、接收与读写函数
CubeMX生成的代码已经帮我们初始化好了SPI外设和GPIO。接下来我们在main.c或单独的驱动文件里添加业务逻辑。
首先,定义片选引脚的操作宏,方便使用:
// 假设片选CS接在PA4上 #define SPI_CS_GPIO_PORT GPIOA #define SPI_CS_GPIO_PIN GPIO_PIN_4 #define SPI_CS_LOW() HAL_GPIO_WritePin(SPI_CS_GPIO_PORT, SPI_CS_GPIO_PIN, GPIO_PIN_RESET) #define SPI_CS_HIGH() HAL_GPIO_WritePin(SPI_CS_GPIO_PORT, SPI_CS_GPIO_PIN, GPIO_PIN_SET)然后,编写一个基础的SPI单字节交换函数。SPI是全双工,发送的同时也会接收。
/** * @brief 通过SPI1交换一个字节 * @param tx_data: 要发送的字节 * @retval 接收到的字节 */ uint8_t SPI1_ReadWriteByte(uint8_t tx_data) { uint8_t rx_data = 0; HAL_SPI_TransmitReceive(&hspi1, &tx_data, &rx_data, 1, 1000); // 超时时间1000ms return rx_data; }现在,我们可以模仿常见SPI器件(如W25Q64)的指令集,编写一个读取器件ID的函数。通常流程是:拉低片选 -> 发送命令码(如0x9F) -> 接收数据(ID通常为多个字节)-> 拉高片选。
/** * @brief 读取SPI Flash的制造商和设备ID * @param manufacturer_id: 存放制造商ID的指针 * @param device_id: 存放设备ID的指针 * @retval HAL status */ HAL_StatusTypeDef SPI_Flash_ReadID(uint8_t *manufacturer_id, uint8_t *device_id) { uint8_t cmd = 0x9F; // JEDEC ID命令 uint8_t data_buf[3] = {0}; SPI_CS_LOW(); // 使能器件 SPI1_ReadWriteByte(cmd); // 发送命令 data_buf[0] = SPI1_ReadWriteByte(0xFF); // 读制造商ID,发送哑元数据0xFF data_buf[1] = SPI1_ReadWriteByte(0xFF); // 读存储器类型 data_buf[2] = SPI1_ReadWriteByte(0xFF); // 读容量ID SPI_CS_HIGH(); // 禁用器件 *manufacturer_id = data_buf[0]; *device_id = data_buf[2]; // 通常用容量ID作为设备ID return HAL_OK; }在main函数的初始化部分后调用它,并通过串口打印出ID,就可以验证SPI硬件和底层驱动是否正常。
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_SPI1_Init(); MX_USART1_UART_Init(); // 假设也初始化了串口用于打印 uint8_t manu_id = 0, dev_id = 0; if(SPI_Flash_ReadID(&manu_id, &dev_id) == HAL_OK) { printf("Manufacturer ID: 0x%02X\r\n", manu_id); // W25Q64应该是0xEF printf("Device ID: 0x%02X\r\n", dev_id); // 可能是0x4017 } while (1) { // 主循环 } }4. 避坑指南:SPI调试中常见的“坑”与解决方案
代码写好了,但一上电发现数据不对,或者根本收不到任何东西。这是学习SPI的必经之路。下面是我总结的几个最常见的问题和排查思路,你可以像查清单一样逐一核对。
4.1 问题一:时钟波形正常,但数据线没反应
- 现象:用逻辑分析仪或示波器看,SCK时钟信号是有的,频率和极性相位也对,但MOSI或MISO线上就是没有数据变化。
- 排查:
- 检查片选(CS):这是最容易忽略的一点!确保片选信号在通信期间被正确拉低。很多初学者配置了软件片选,但在代码里忘了操作GPIO。用逻辑分析仪同时抓SCK和CS线,确认CS的下降沿在SCK产生之前,上升沿在SCK结束之后。
- 检查从设备是否上电/使能:确认你的传感器、FLASH等从设备供电正常,并且没有进入睡眠或关断模式。有些器件需要先发送一个特定的“唤醒”指令。
- 检查引脚复用:确认你使用的SPI引脚(PA5/6/7)没有被其他功能(如JTAG)占用。在CubeMX中,如果开启了调试接口(如SWD),这些引脚可能会被部分占用,需要仔细检查引脚分配图。
4.2 问题二:能收到数据,但全是0xFF或0x00
- 现象:主机发送了数据,从机似乎也有反应,但读回来的数据始终是固定的0xFF或0x00。
- 排查:
- 检查MISO/MOSI接线:把主机的MOSI和从机的MOSI接反了,或者把MISO接反了,是硬件连接中的高频错误。记住口诀:主出从入(MOSI),主入从出(MISO)。
- 检查从设备待机输出值:很多SPI从设备在不被选中(CS为高)时,其MISO引脚会处于高阻态。STM32的SPI输入引脚内部通常有弱上拉或下拉,如果处于高阻态,可能会读到固定的高电平(0xFF)或低电平。这不一定是故障,可能只是从设备在非选中状态下的正常行为。重点应放在CS拉低期间的数据是否变化。
- 检查数据帧格式和位顺序:确认主机和从机都是8位(或16位)帧格式,并且位顺序(MSB/LSB)一致。虽然大部分是MSB先行,但有些ADC或特殊器件可能是LSB先行。
4.3 问题三:通信不稳定,高速时出错
- 现象:在低时钟频率(如1MHz)下通信正常,但一旦把SPI时钟频率调高(如18MHz),就出现数据错乱。
- 排查:
- 检查PCB布局和走线:SPI属于高速信号(相对MCU世界而言)。如果走线过长、过绕,或者靠近干扰源,在高频下容易产生信号完整性问题。尽量让SCK、MOSI、MISO、CS四根线等长、平行、靠近走线,并远离电源等噪声源。
- 检查上拉电阻:SPI总线虽然理论上不需要上拉电阻(因为是推挽输出),但在某些长距离或干扰大的场合,在SCK和MOSI上添加一个弱上拉(如10kΩ)有助于稳定信号。但MISO线一般不加,以免影响从设备输出。
- 降低时钟频率:这是最直接的解决方法。不是所有从设备都能跑满主机的最高时钟。仔细阅读从设备数据手册的“AC Characteristics”章节,找到其支持的最大SCK频率。STM32的SPI时钟分频系数是2的幂次方,合理选择。
- 引入延时:在连续发送多条指令时,尤其是对FLASH进行写操作后需要等待芯片内部编程完成,必须在指令间插入足够的延时。检查从设备数据手册中的“指令周期时间”或“页编程时间”等参数,使用
HAL_Delay()或更精确的定时器进行等待。
4.4 问题四:使用DMA时数据错位或丢失
- 现象:为了高效,使用DMA来搬运SPI数据,但发现收到的数据顺序不对,或者少了几个字节。
- 排查:
- 内存对齐与缓冲区大小:确保你定义的发送和接收缓冲区地址是对齐的(通常4字节对齐),并且大小足够。DMA传输时,数据宽度(Byte/HalfWord/Word)的设置要与缓冲区类型匹配。
- DMA传输完成中断与SPI空闲中断的配合:当使用DMA进行连续传输时,不能只依赖DMA传输完成中断(
HAL_SPI_TxRxCpltCallback)就认为SPI总线已经空闲。因为DMA搬运完数据后,SPI内核可能还在发送最后一个字节。更稳妥的做法是,在DMA传输完成后,再等待一下SPI的“传输完成”标志位(SPI_FLAG_TC)或使用SPI的传输完成中断。 - 关闭DMA的时机:在一次DMA传输尚未完成时,就开启下一次传输或关闭SPI/DMA,会导致硬件状态混乱。务必在前一次传输完全结束后(回调函数被调用,并且确认SPI总线空闲)再进行下一步操作。
个人心得:调试SPI,一个几十块钱的逻辑分析仪是你的最佳伙伴。它能同时捕捉多路信号,直观地展示CS、SCK、MOSI、MISO的时序关系,让你一眼就能看出模式、数据位、片选时序是否正确。比用串口打印十六进制数然后猜问题要高效十倍。
5. 进阶话题:软件片选 vs. 硬件片选与DMA应用浅析
当你掌握了基础的单字节读写后,自然会想追求更高效、更专业的用法。这里有两个进阶话题值得探讨。
5.1 软件片选与硬件片选的抉择
我们之前的例子用的是软件片选,即用一个普通的GPIO口来手动控制CS线。这是最常见、最灵活的方式。但STM32的SPI模块其实内置了硬件NSS(片选)管理功能。
- 硬件NSS(Hardware NSS):将SPI的NSS引脚(通常是PA4或PA15)配置为硬件模式。在主机模式下,你可以设置它为“输出”,这样当SPI使能后,硬件会自动拉低NSS引脚;SPI禁用时自动拉高。这简化了代码,但一个SPI外设通常只有一个硬件NSS引脚,如果你要接多个从设备,就需要用其他GPIO口作为额外的片选,最终还是回到了“软件片选”的混合模式。
- 软件片选(Software NSS):就是我们之前用的方法。优势是灵活,你可以用任意GPIO控制任意数量的从设备。劣势是需要手动控制时序,在通信开始前拉低,结束后拉高,并且要确保在CS有效期间完成整个通信帧。
我的建议是:在绝大多数多从机应用中,直接使用软件片选。理由很简单:可控性强,调试直观。硬件NSS在单从机、且对时序要求极其严格(要求CS和SCK边沿精确对齐)的场合可能有优势,但这种场景较少。
5.2 使用DMA驱动SPI以释放CPU
当你需要传输大量数据时,比如用SPI刷新LCD屏,或者连续读取FLASH中的一个扇区,如果还用HAL_SPI_TransmitReceive这种轮询函数,CPU会被完全阻塞在等待传输完成上。此时,DMA(直接存储器访问)就是救星。
DMA可以在不占用CPU核心的情况下,自动将内存中的数据搬运到SPI的发送数据寄存器(DR),或者将SPI接收数据寄存器(DR)的值搬运到内存。CPU只需要发起一次传输指令,就可以去处理其他任务,等DMA传输完成后再产生中断通知CPU。
在CubeMX中配置SPI DMA非常简单:
- 在SPI配置页,找到
DMA Settings选项卡。 - 点击
Add,为SPIx_TX添加一个DMA流(Stream)和通道(Channel),传输方向为Memory To Peripheral。 - 同样,为
SPIx_RX添加一个DMA流,传输方向为Peripheral To Memory。 - 在
Parameter Settings中,可以配置DMA的优先级、数据宽度、是否使用循环模式等。
配置完成后,生成的代码会初始化DMA。你可以使用HAL_SPI_TransmitReceive_DMA()函数来启动一次全双工的DMA传输。传输完成后,会在中断回调函数HAL_SPI_TxRxCpltCallback()中收到通知。
使用DMA的一个关键点:SPI是同步接口,TX和RX的时钟是同一个,因此发送和接收必须同时进行。即使你只想发送数据,接收DMA通道也必须开启,并指向一个有效的“垃圾桶”缓冲区,用于接收无用的数据。反之亦然。
6. 项目实战:构建一个简单的SPI FLASH读写模块
让我们把前面所有的知识点串起来,为一个SPI接口的FLASH芯片(以Winbond W25Q64为例)编写一个简单的驱动模块。这个模块将包含初始化、读ID、擦除扇区、写入数据和读取数据等基本功能。
6.1 定义指令集与基础函数
首先,我们定义一些常用的W25Q64指令码,并完善我们的底层读写函数。
// W25Qxx 指令集 (部分) #define W25X_WriteEnable 0x06 #define W25X_WriteDisable 0x04 #define W25X_ReadStatusReg1 0x05 #define W25X_ReadStatusReg2 0x35 #define W25X_WriteStatusReg 0x01 #define W25X_ReadData 0x03 #define W25X_FastReadData 0x0B #define W25X_PageProgram 0x02 #define W25X_SectorErase 0x20 #define W25X_ChipErase 0xC7 #define W25X_PowerDown 0xB9 #define W25X_ReleasePowerDown 0xAB #define W25X_DeviceID 0xAB #define W25X_ManufactDeviceID 0x90 #define W25X_JedecDeviceID 0x9F // 等待FLASH空闲 void W25Qxx_WaitBusy(void) { uint8_t status = 0; SPI_CS_LOW(); SPI1_ReadWriteByte(W25X_ReadStatusReg1); do { status = SPI1_ReadWriteByte(0xFF); } while(status & 0x01); // 检查BUSY位(bit0) SPI_CS_HIGH(); } // 写使能 void W25Qxx_WriteEnable(void) { SPI_CS_LOW(); SPI1_ReadWriteByte(W25X_WriteEnable); SPI_CS_HIGH(); }6.2 实现扇区擦除与页编程
FLASH写入前必须先擦除(变为0xFF),擦除的最小单位通常是扇区(Sector,如4KB)。
/** * @brief 擦除指定扇区 * @param sector_addr: 扇区地址 (0 ~ max_sector) * @retval 无 */ void W25Qxx_EraseSector(uint32_t sector_addr) { uint32_t addr = sector_addr * 4096; // 假设扇区大小为4KB W25Qxx_WriteEnable(); // 擦除前必须写使能 W25Qxx_WaitBusy(); SPI_CS_LOW(); SPI1_ReadWriteByte(W25X_SectorErase); SPI1_ReadWriteByte((addr >> 16) & 0xFF); // 发送24位地址 SPI1_ReadWriteByte((addr >> 8) & 0xFF); SPI1_ReadWriteByte(addr & 0xFF); SPI_CS_HIGH(); W25Qxx_WaitBusy(); // 等待擦除完成,耗时较长(几十ms) }写入数据的最小单位是页(Page,通常是256字节)。写入操作称为“页编程”。
/** * @brief 在指定地址写入数据(不超过一页) * @param pBuffer: 数据缓冲区指针 * @param write_addr: 写入起始地址 * @param num_bytes: 写入字节数(<=256) * @retval 无 */ void W25Qxx_PageWrite(uint8_t* pBuffer, uint32_t write_addr, uint16_t num_bytes) { if(num_bytes > 256) num_bytes = 256; // 页编程不能跨页 W25Qxx_WriteEnable(); W25Qxx_WaitBusy(); SPI_CS_LOW(); SPI1_ReadWriteByte(W25X_PageProgram); SPI1_ReadWriteByte((write_addr >> 16) & 0xFF); SPI1_ReadWriteByte((write_addr >> 8) & 0xFF); SPI1_ReadWriteByte(write_addr & 0xFF); for(uint16_t i=0; i<num_bytes; i++) { SPI1_ReadWriteByte(pBuffer[i]); } SPI_CS_HIGH(); W25Qxx_WaitBusy(); // 等待编程完成 }6.3 实现数据读取
读取数据相对简单,发送读指令和地址后,就可以连续读取数据。
/** * @brief 从指定地址读取数据 * @param pBuffer: 数据缓冲区指针 * @param read_addr: 读取起始地址 * @param num_bytes: 读取字节数 * @retval 无 */ void W25Qxx_ReadData(uint8_t* pBuffer, uint32_t read_addr, uint32_t num_bytes) { SPI_CS_LOW(); SPI1_ReadWriteByte(W25X_ReadData); SPI1_ReadWriteByte((read_addr >> 16) & 0xFF); SPI1_ReadWriteByte((read_addr >> 8) & 0xFF); SPI1_ReadWriteByte(read_addr & 0xFF); for(uint32_t i=0; i<num_bytes; i++) { pBuffer[i] = SPI1_ReadWriteByte(0xFF); // 发送哑元时钟,读取数据 } SPI_CS_HIGH(); }6.4 在主函数中进行测试
最后,我们在主循环中编写一个简单的测试流程,验证整个驱动链是否工作。
int main(void) { // ... 初始化代码(系统时钟、GPIO、SPI、UART等) uint8_t manu_id, dev_id; W25Qxx_ReadID(&manu_id, &dev_id); printf("Flash ID: Manu=0x%02X, Dev=0x%02X\r\n", manu_id, dev_id); uint32_t test_addr = 0x000000; // 测试地址 uint8_t write_buf[256]; uint8_t read_buf[256]; // 准备测试数据 for(int i=0; i<256; i++) { write_buf[i] = i; } printf("Erasing Sector...\r\n"); W25Qxx_EraseSector(test_addr / 4096); // 擦除目标扇区 HAL_Delay(500); // 等待擦除完成 printf("Writing Data...\r\n"); W25Qxx_PageWrite(write_buf, test_addr, 256); HAL_Delay(10); printf("Reading Data...\r\n"); W25Qxx_ReadData(read_buf, test_addr, 256); // 验证数据 bool success = true; for(int i=0; i<256; i++) { if(read_buf[i] != write_buf[i]) { success = false; printf("Mismatch at %d: W=0x%02X, R=0x%02X\r\n", i, write_buf[i], read_buf[i]); break; } } if(success) { printf("SPI FLASH R/W Test PASSED!\r\n"); } while (1) { // 主循环 } }通过这个完整的项目,你不仅掌握了SPI通信的配置和底层读写,还理解了如何根据一个具体外设(FLASH)的数据手册,去实现其完整的指令集操作。这才是嵌入式开发中“驱动”二字的真正含义:将硬件的数据手册翻译成可运行的、可靠的代码。当你下次遇到陀螺仪、触摸屏、以太网PHY等其他SPI设备时,你会发现流程是相通的——理解协议、配置模式、实现指令、处理时序。SPI这座桥,你就算真正走过去了。