022、SPI驱动开发:软件模拟SPI与硬件SPI外设实现(以STM32为例)
一、一个让我熬夜到凌晨三点的SPI问题
去年做一款工业传感器采集板,主控是STM32F407,外挂一个24位ADC芯片ADS1256。硬件SPI死活读不出数据,示波器一挂------SCK时钟正常,MOSI数据正常,MISO就是一根死线。折腾了四个小时,换GPIO模拟SPI,一次成功。后来发现是硬件SPI的NSS引脚配置成了硬件自动管理,而ADS1256要求片选信号必须在SCK第一个边沿之前建立,硬件NSS的时序差了那么几十纳秒。
从那以后,我养成了一个习惯:能用硬件SPI的场合,也一定要把软件模拟SPI的代码备着。不是硬件不好,是调试的时候,软件模拟能让你看到每一根线上的每一个电平变化。
二、软件模拟SPI:把时序掰开揉碎了看
软件模拟SPI的本质就是GPIO的位操作,但这里有个核心原则:时序必须严格,不能靠delay瞎凑。
2.1 基础框架
c
// 定义引脚,别用宏定义套娃,直接写清楚
#define SPI_SCK_PIN GPIO_PIN_5
#define SPI_MOSI_PIN GPIO_PIN_7
#define SPI_MISO_PIN GPIO_PIN_6
#define SPI_CS_PIN GPIO_PIN_4
#define SPI_GPIO_PORT GPIOB
// 片选操作,这里踩过坑:CS拉低后要等至少1us再发时钟
#define SPI_CS_LOW() HAL_GPIO_WritePin(SPI_GPIO_PORT, SPI_CS_PIN, GPIO_PIN_RESET)
#define SPI_CS_HIGH() HAL_GPIO_WritePin(SPI_GPIO_PORT, SPI_CS_PIN, GPIO_PIN_SET)
// 时钟和数据操作
#define SPI_SCK_HIGH() HAL_GPIO_WritePin(SPI_GPIO_PORT, SPI_SCK_PIN, GPIO_PIN_SET)
#define SPI_SCK_LOW() HAL_GPIO_WritePin(SPI_GPIO_PORT, SPI_SCK_PIN, GPIO_PIN_RESET)
#define SPI_MOSI_HIGH() HAL_GPIO_WritePin(SPI_GPIO_PORT, SPI_MOSI_PIN, GPIO_PIN_SET)
#define SPI_MOSI_LOW() HAL_GPIO_WritePin(SPI_GPIO_PORT, SPI_MOSI_PIN, GPIO_PIN_RESET)
#define SPI_MISO_READ() HAL_GPIO_ReadPin(SPI_GPIO_PORT, SPI_MISO_PIN)
2.2 核心收发函数------模式0(CPOL=0, CPHA=0)
c
uint8_t SPI_Soft_ExchangeByte(uint8_t data)
{
uint8_t rx_data = 0;
// 注意:这里从MSB开始,别写成LSB,除非你的器件要求
for(uint8_t i = 0; i < 8; i++)
{
// 先拉低时钟,准备数据
SPI_SCK_LOW();
// 发送位:数据在时钟上升沿被采样,所以要在上升沿之前准备好
if(data & 0x80)
SPI_MOSI_HIGH();
else
SPI_MOSI_LOW();
data <<= 1; // 左移,准备下一位
// 这里加一个空指令延时,别用HAL_Delay,太慢了
__NOP(); __NOP(); __NOP();
// 时钟上升沿,从机采样
SPI_SCK_HIGH();
// 读取MISO,注意:从机在上升沿输出数据,我们必须在上升沿之后读
// 别这样写:在SCK_LOW时读,会读到上一个数据
rx_data <<= 1;
if(SPI_MISO_READ())
rx_data |= 0x01;
// 保持高电平一段时间,让从机稳定
__NOP(); __NOP(); __NOP();
}
// 最后拉低时钟,为下一字节做准备
SPI_SCK_LOW();
return rx_data;
}
这里有个血泪教训 :不同SPI模式,时钟极性和相位不同。模式0是空闲低电平,上升沿采样。如果你的器件是模式3(空闲高电平,下降沿采样),上面的代码要反过来------先拉高时钟,下降沿采样。一定要看数据手册的时序图,别想当然。
2.3 多字节收发
c
void SPI_Soft_ReadWrite(uint8_t *tx_data, uint8_t *rx_data, uint16_t len)
{
SPI_CS_LOW();
// 这里加延时,有些器件CS拉低后需要等待内部准备
// 比如ADS1256需要至少100ns,我一般加几个NOP
__NOP(); __NOP(); __NOP(); __NOP(); __NOP();
for(uint16_t i = 0; i < len; i++)
{
if(tx_data != NULL)
rx_data[i] = SPI_Soft_ExchangeByte(tx_data[i]);
else
rx_data[i] = SPI_Soft_ExchangeByte(0x00); // 发送0x00来读取
}
SPI_CS_HIGH();
// 有些器件要求CS拉高后保持一段时间才能进行下一次通信
__NOP(); __NOP();
}
三、硬件SPI外设:别被HAL库惯坏了
STM32的硬件SPI其实很强大,但HAL库的封装让很多人忽略了底层配置。我见过太多人直接复制CubeMX生成的代码,出了问题一脸懵。
3.1 关键寄存器配置(以SPI1为例)
c
void SPI_Hardware_Init(void)
{
// 开启时钟,别漏了GPIO的时钟
RCC->APB2ENR |= RCC_APB2ENR_SPI1EN;
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
// GPIO配置:复用功能,推挽输出,速度50MHz
// PA5-SCK, PA7-MOSI, PA6-MISO
GPIOA->MODER &= ~(GPIO_MODER_MODER5 | GPIO_MODER_MODER6 | GPIO_MODER_MODER7);
GPIOA->MODER |= (GPIO_MODER_MODER5_1 | GPIO_MODER_MODER6_1 | GPIO_MODER_MODER7_1);
GPIOA->AFR[0] &= ~(0xF << 20 | 0xF << 24 | 0xF << 28);
GPIOA->AFR[0] |= (0x5 << 20) | (0x5 << 24) | (0x5 << 28); // AF5对应SPI1
// SPI配置
SPI1->CR1 = 0; // 先清零
SPI1->CR1 |= SPI_CR1_MSTR; // 主机模式
SPI1->CR1 |= SPI_CR1_BR_2; // 分频,这里设8分频,根据你的时钟调整
SPI1->CR1 |= SPI_CR1_CPOL_0; // 模式0,CPOL=0
// CPHA默认是0,不用设置
SPI1->CR1 |= SPI_CR1_SSM; // 软件管理NSS
SPI1->CR1 |= SPI_CR1_SSI; // 内部NSS高电平
// 数据帧格式:8位,MSB先行
SPI1->CR1 &= ~SPI_CR1_DFF; // 8位数据
SPI1->CR1 &= ~SPI_CR1_LSBFIRST; // MSB先行
// 使能SPI
SPI1->CR1 |= SPI_CR1_SPE;
}
注意 :这里用了软件管理NSS,片选信号自己用GPIO控制。为什么不用硬件NSS?因为硬件NSS在多从机场景下就是个坑,而且时序不可控。我从来不用硬件NSS,永远自己用GPIO控制片选。
3.2 硬件SPI收发函数
c
uint8_t SPI_Hardware_ExchangeByte(uint8_t data)
{
// 等待发送缓冲区空
while(!(SPI1->SR & SPI_SR_TXE));
SPI1->DR = data;
// 等待接收缓冲区非空
while(!(SPI1->SR & SPI_SR_RXNE));
return (uint8_t)SPI1->DR;
}
void SPI_Hardware_ReadWrite(uint8_t *tx_data, uint8_t *rx_data, uint16_t len)
{
// 片选控制
HAL_GPIO_WritePin(SPI_CS_GPIO_Port, SPI_CS_Pin, GPIO_PIN_RESET);
for(uint16_t i = 0; i < len; i++)
{
if(tx_data != NULL)
rx_data[i] = SPI_Hardware_ExchangeByte(tx_data[i]);
else
rx_data[i] = SPI_Hardware_ExchangeByte(0x00);
}
// 等待SPI空闲,别这样写:直接拉高CS
// 要等最后一个字节传输完成
while(SPI1->SR & SPI_SR_BSY);
HAL_GPIO_WritePin(SPI_CS_GPIO_Port, SPI_CS_Pin, GPIO_PIN_SET);
}
这里有个坑 :while(SPI1->SR & SPI_SR_BSY)这个等待很重要。有些从机要求CS必须在SCK停止后才能拉高,如果不等待BSY标志,CS可能在最后一个时钟还没结束时就被拉高了,导致数据错误。
四、软件模拟 vs 硬件外设:什么时候用哪个?
4.1 软件模拟的优势场景
- 引脚不够用:任何GPIO都能当SPI用,不用管复用功能
- 时序特殊:有些老器件时序不标准,比如要求SCK占空比不是50%,或者CS和SCK之间有特殊时序要求
- 调试阶段:软件模拟可以随时打断点看电平,硬件SPI一旦配置好,内部状态机你根本看不到
- 低速通信:几百KHz以下,软件模拟完全够用,而且代码移植性极强
4.2 硬件外设的优势场景
- 高速通信:STM32的SPI可以跑到几十MHz,软件模拟做不到
- DMA传输:硬件SPI+DMA可以实现零CPU开销的大批量数据传输
- 多从机管理:虽然我不用硬件NSS,但硬件SPI配合DMA可以轻松管理多个从机
- 低功耗场景:硬件SPI可以在睡眠模式下工作,软件模拟不行
4.3 我的选择原则
原型阶段用软件模拟,产品阶段用硬件外设。但硬件外设的代码里,一定要保留软件模拟的接口,作为调试后门。我在产品里经常这样写:
c
// 编译开关,调试时用软件模拟,发布时用硬件
#ifdef SPI_DEBUG_MODE
#define SPI_ExchangeByte SPI_Soft_ExchangeByte
#define SPI_ReadWrite SPI_Soft_ReadWrite
#else
#define SPI_ExchangeByte SPI_Hardware_ExchangeByte
#define SPI_ReadWrite SPI_Hardware_ReadWrite
#endif
五、那些年我踩过的SPI坑
5.1 时钟极性搞反了
这是最常见的坑。同一个器件,不同厂家的数据手册可能用不同方式描述SPI模式。有的写"Mode 0",有的写"CPOL=0, CPHA=0",还有的写"上升沿采样"。一定要看时序图,别只看文字描述。
5.2 片选时序不对
很多从机对CS的建立时间和保持时间有要求。比如W25Q64 Flash,CS拉低后至少要等100ns才能发第一个时钟。有些工程师直接用HAL_Delay(1),1ms太长了,严重影响通信速率。正确的做法是用NOP指令或者定时器微秒延时。
5.3 硬件SPI的BSY标志
SPI_SR_BSY这个标志在有些STM32型号上有bug。比如STM32F1系列,在单字节传输时,BSY标志可能在最后一个字节传输完成后没有立即清零。稳妥的做法是在BSY等待之后再加一个小的延时。
5.4 中断优先级
如果用中断方式收发SPI,中断优先级一定要设置好。我遇到过SPI中断被更高优先级的中断打断,导致数据错位的情况。SPI中断优先级不要设得太低,尤其是和系统滴答定时器比较。
六、个人经验性建议
-
永远不要相信第一次配置就能成功。SPI的调试,示波器是必须的。没有示波器?那就用逻辑分析仪,几十块钱的就行。看波形比看代码靠谱一万倍。
-
软件模拟SPI的代码,不要用HAL_Delay。HAL_Delay依赖SysTick中断,如果你的SPI通信在中断里调用,会死锁。用NOP循环或者DWT时钟周期计数器。
-
硬件SPI的时钟分频,不要算错。STM32的SPI时钟来自APB总线,不是主频。比如F407的APB2是84MHz,SPI1挂在这个总线上,如果设8分频,实际SPI时钟是10.5MHz,不是84/8=10.5?等等,84/8确实是10.5,但有些分频系数是2的幂次,要查手册。
-
多字节传输时,考虑用DMA。CPU轮询方式在高速率下会占用大量CPU时间。我做过一个项目,SPI速率12MHz,轮询方式CPU占用率高达60%,换成DMA后降到5%以下。
-
最后一条,也是最重要的:SPI通信失败时,先检查硬件连接,再检查时钟配置,最后才怀疑代码逻辑。我见过太多人花三天找代码bug,最后发现是杜邦线接触不良。
SPI这个接口,说简单也简单,说复杂也复杂。掌握了软件模拟,你就掌握了SPI的本质;学会了硬件外设,你就能发挥MCU的最大性能。两者结合,才能在嵌入式开发中游刃有余。