摘要
SPI 是嵌入式里最常用的高速同步串行总线,Flash、OLED、AD 芯片、射频模块基本都用 SPI。很多人写 SPI 只简单实现读写,忽略片选时序、时钟极性相位 CPOL/CPHA、总线冲突,项目调试时频繁出现读不出数据、丢字节、偶尔通信不稳定问题。本文基于 STM32F103,采用标准库 + HAL 库两套极简代码,结合板载 W25Q64 存储芯片项目实战,讲解 SPI 主机通信、软件片选、数据收发逻辑,同时梳理工程高频踩坑点。
一、SPI 核心原理(工程精简版)
SPI 为 4 线同步总线:SCLK 时钟、MOSI 主机发、MISO 主机收、CS 片选。
- 主机输出时钟,从机跟随时钟同步收发;SPI 是全双工,发数据的同时也在接收数据,这点和串口单向收发有本质区别
- CPOL:时钟空闲电平;CPHA:数据采样边沿,两个参数必须和从机芯片手册保持一致,否则通信直接乱码
- 片选 CS:低电平选中从机,通信前拉低,一帧数据收发完成再拉高,严禁全程拉低
项目选型提醒:低速场景可用 GPIO 软件模拟 SPI,简单好调试;1MHz 以上高速通信,优先硬件 SPI,CPU 占用更低、稳定性更强。本文讲解硬件 SPI 主机。
二、硬件接线与 PCB 设计要点
以 STM32F103 SPI1 为例(APB2 最高 36MHz)
- PA5: SCK,PA7:MOSI,PA6:MISO,PA4:CS(普通 GPIO 软件片选,不用硬件 NSS)
- 硬件 SPI 必须使用对应复用引脚,不能随便分配普通 IO
- CS 使用独立 GPIO 软件控制,工程里比硬件 NSS 更灵活,支持多从机扩展
- 长走线场景,SCLK、MOSI、MISO 串 100Ω 电阻抑制信号反射;电源引脚就近放置 0.1μF 退耦电容
- 所有器件共地;高速 SPI 布线尽量短,SCLK 走线尽量远离模拟信号线,防止干扰
三、标准库精简代码(SPI1 主机,软件片选)
#include "stm32f10x.h"
#define SPI_CS_PIN GPIO_Pin_4
#define SPI_CS_PORT GPIOA
//片选控制宏
#define SPI_CS_L() GPIO_ResetBits(SPI_CS_PORT,SPI_CS_PIN)
#define SPI_CS_H() GPIO_SetBits(SPI_CS_PORT,SPI_CS_PIN)
//SPI1初始化,CPOL=0,CPHA=0,波特率分频4,9M
void SPI1_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct;
SPI_InitTypeDef SPI_InitStruct;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE);
//SCK MOSI 复用推挽输出
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
//MISO 浮空输入
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA,&GPIO_InitStruct);
//CS 普通推挽输出,默认高
GPIO_InitStruct.GPIO_Pin = SPI_CS_PIN;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
SPI_CS_H();
SPI_InitStruct.SPI_Direction = SPI_Direction_2Lines_FullDuplex;
SPI_InitStruct.SPI_Mode = SPI_Mode_Master;
SPI_InitStruct.SPI_DataSize = SPI_DataSize_8b;
SPI_InitStruct.SPI_CPOL = SPI_CPOL_Low;
SPI_InitStruct.SPI_CPHA = SPI_CPHA_1Edge;
SPI_InitStruct.SPI_NSS = SPI_NSS_Soft;
SPI_InitStruct.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_4;
SPI_InitStruct.SPI_FirstBit = SPI_FirstBit_MSB;
SPI_Init(SPI1,&SPI_InitStruct);
SPI_Cmd(SPI1,ENABLE);
}
//SPI读写1字节:SPI特性,发送同时接收
uint8_t SPI_RW_Byte(uint8_t dat)
{
while(SPI_I2S_GetFlagStatus(SPI1,SPI_I2S_FLAG_TXE)==RESET);
SPI_I2S_SendData(SPI1,dat);
while(SPI_I2S_GetFlagStatus(SPI1,SPI_I2S_FLAG_RXNE)==RESET);
return SPI_I2S_ReceiveData(SPI1);
}
//连续多字节发送
void SPI_Write_Buf(uint8_t *buf,uint16_t len)
{
uint16_t i;
SPI_CS_L();
for(i=0;i<len;i++)
{
SPI_RW_Byte(bufi);
}
SPI_CS_H();
}
//连续多字节读取
void SPI_Read_Buf(uint8_t *buf,uint16_t len)
{
uint16_t i;
SPI_CS_L();
for(i=0;i<len;i++)
{
bufi=SPI_RW_Byte(0xFF); //主机发0xFF,产生时钟,读取从机数据
}
SPI_CS_H();
}
四、HAL 库精简版本
#include "stm32f1xx_hal.h"
SPI_HandleTypeDef hspi1;
#define SPI_CS_L() HAL_GPIO_WritePin(GPIOA,GPIO_PIN_4,GPIO_PIN_RESET)
#define SPI_CS_H() HAL_GPIO_WritePin(GPIOA,GPIO_PIN_4,GPIO_PIN_SET)
void MX_SPI1_Init(void)
{
hspi1.Instance = SPI1;
hspi1.Init.Mode = SPI_MODE_MASTER;
hspi1.Init.Direction = SPI_DIRECTION_2LINES;
hspi1.Init.DataSize = SPI_DATASIZE_8BIT;
hspi1.Init.CLKPolarity = SPI_POLARITY_LOW;
hspi1.Init.CLKPhase = SPI_PHASE_1EDGE;
hspi1.Init.NSS = SPI_NSS_SOFT;
hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_4;
hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB;
hspi1.Init.TIMode = SPI_TIMODE_DISABLE;
hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE;
HAL_SPI_Init(&hspi1);
}
//读写单字节
uint8_t SPI_RW_Byte(uint8_t txDat)
{
uint8_t rxDat;
HAL_SPI_TransmitReceive(&hspi1,&txDat,&rxDat,1,100);
return rxDat;
}
//多字节写
void SPI_Write_Buf(uint8_t *buf,uint16_t len)
{
SPI_CS_L();
HAL_SPI_Transmit(&hspi1,buf,len,100);
SPI_CS_H();
}
//多字节读
void SPI_Read_Buf(uint8_t *buf,uint16_t len)
{
SPI_CS_L();
HAL_SPI_Receive(&hspi1,buf,len,100);
SPI_CS_H();
}
五、项目实战:W25Q64 简单读写调用示例
//读取W25Q64ID,测试SPI通信
uint32_t W25Q_ReadID(void)
{
uint8_t buf3={0x90,0x00,0x00};
uint8_t id2;
SPI_CS_L();
SPI_RW_Byte(0x90);
SPI_RW_Byte(0x00);
SPI_RW_Byte(0x00);
id0=SPI_RW_Byte(0xFF);
id1=SPI_RW_Byte(0xFF);
SPI_CS_H();
return (id0<<8)|id1;
}
六、工程高频坑点(项目实测)
- CPOL、CPHA 参数不匹配
最常见问题,必须严格对照从机 datasheet,参数错直接读乱码。W25Q 系列是 CPOL=0,CPHA=0;部分 OLED 屏是 CPOL=0,CPHA=1。 - 片选时序错误
不要提前拉高 CS,必须等一整包数据收发完成再释放;不要多个从机共用 CS,多设备需要独立 CS 引脚。 - 读数据时不知道发送 0xFF
SPI 全双工,读取从机数据,主机必须发送 0xFF 来输出 SCLK 时钟,没有时钟从机不会输出数据。 - 通信偶尔出错
原因:速率太高,信号反射。降低 SPI 波特率,增加串阻;检查电源、地线,增加退耦电容。 - 复用引脚配置错误
MOSI/SCK 要配置为复用推挽输出,MISO 是浮空输入;配置成普通 GPIO,硬件 SPI 无法工作。
七、总结
- SPI 全双工同步总线,硬件 SPI 适合高速场景,软件片选更适合多从机项目;
- 通信成败关键点:CPOL/CPHA、片选时序、引脚模式;
- 代码模块化,可直接移植到 Flash、OLED、高速 ADC 等外设;
- 调试优先降低 SPI 波特率排查,通信正常后再逐步提速。