3. SPI 基础驱动开发(轮询模式)
好,咱们正式开始写代码了。这一章我带你从零搭建一个 SPI 轮询模式的驱动。说白了,就是 CPU 死等——发一个字节,等它发完,再收一个字节。虽然效率不高,但这是理解 SPI 时序和寄存器操作的基础。
我记得刚入行那会儿,上来就想搞 DMA,结果连 SPI 的极性相位都没配对,调了三天波形都不对。后来老老实实从轮询模式开始,反而一通百通。所以,别嫌轮询模式“低级”,它是你后面所有高级玩法的基础。
3.1 GPIO 初始化:复用功能推挽输出
SPI 的引脚不是普通的 GPIO,它需要被配置为“复用功能”。什么意思?就是引脚的控制权交给 SPI 外设,而不是 GPIO 模块。
以 STM32F103 为例,SPI1 的引脚通常是:
| 信号 | 引脚 | 模式 |
|---|---|---|
| SCK | PA5 | 复用推挽输出 (AF_PP) |
| MOSI | PA7 | 复用推挽输出 (AF_PP) |
| MISO | PA6 | 浮空输入 (IN_FLOATING) |
| NSS | PA4 | 复用推挽输出 (AF_PP) 或软件管理 |
代码怎么写?我习惯用标准库,清晰明了:
// 使能 GPIOA 和 SPI1 时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
// SCK: PA5 复用推挽输出
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// MOSI: PA7 复用推挽输出
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_7;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// MISO: PA6 浮空输入
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStructure);
3.2 SPI 参数配置:波特率、极性、相位
这是 SPI 驱动里最容易出坑的地方。三个参数必须和从机完全一致,否则数据全是乱的。
咱们先看配置结构体:
SPI_InitTypeDef SPI_InitStructure;
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; // 时钟极性:空闲低
SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge; // 时钟相位:第一个边沿采样
SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; // 软件管理 NSS
SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_16; // 分频系数 16
SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; // 高位先行
SPI_InitStructure.SPI_CRCPolynomial = 7; // CRC 多项式
SPI_Init(SPI1, &SPI_InitStructure);
SPI_Cmd(SPI1, ENABLE); // 使能 SPI
这里我重点说一下 CPOL 和 CPHA,也就是极性和相位。这两个参数决定了时钟信号在空闲时的电平,以及数据在哪个边沿被采样。
| 模式 | CPOL | CPHA | 空闲时钟 | 采样边沿 |
|---|---|---|---|---|
| 模式 0 | 0 | 0 | 低电平 | 上升沿 |
| 模式 1 | 0 | 1 | 低电平 | 下降沿 |
| 模式 2 | 1 | 0 | 高电平 | 下降沿 |
| 模式 3 | 1 | 1 | 高电平 | 上升沿 |
波特率怎么算?很简单:
SPI 时钟 = APB 时钟 / 分频系数
比如 APB2 是 72MHz,分频系数选 16,那么 SPI 时钟就是 72/16 = 4.5MHz。分频系数可选 2、4、8、16、32、64、128、256。注意,不要超过从机支持的最高频率。
3.3 数据收发函数编写
轮询模式的核心就是:发一个字节,等标志位,读一个字节。STM32 的 SPI 数据寄存器是 SPI_DR,发送和接收共用同一个寄存器。
标准库提供了两个函数:
SPI_I2S_SendData(SPIx, Data)—— 写数据到发送缓冲区SPI_I2S_ReceiveData(SPIx)—— 从接收缓冲区读数据
但注意,这两个函数只是操作寄存器,不会帮你检查状态。所以我们需要自己加标志位判断。
我写了一个通用的收发函数,你直接拿去用:
/**
* @brief SPI 发送一个字节并接收一个字节
* @param byte: 要发送的字节
* @retval 接收到的字节
*/
uint8_t SPI_ReadWriteByte(uint8_t byte)
{
// 等待发送缓冲区为空(TXE 标志位)
while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET);
// 发送数据
SPI_I2S_SendData(SPI1, byte);
// 等待接收缓冲区非空(RXNE 标志位)
while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET);
// 读取接收到的数据
return SPI_I2S_ReceiveData(SPI1);
}
实际使用中,比如读写一个 SPI Flash 的寄存器:
// 读取 SPI Flash 的状态寄存器
uint8_t SPI_Flash_ReadStatus(void)
{
uint8_t status;
// 拉低片选
GPIO_ResetBits(GPIOA, GPIO_Pin_4);
// 发送读状态寄存器指令 0x05
SPI_ReadWriteByte(0x05);
// 读取状态寄存器值
status = SPI_ReadWriteByte(0xFF); // 发送任意字节,接收从机返回的数据
// 拉高片选
GPIO_SetBits(GPIOA, GPIO_Pin_4);
return status;
}
3.4 超时处理机制
轮询模式最大的问题是什么?死等。如果从机挂了或者线路断了,程序就会卡死在 while 循环里,整个系统都瘫痪。
我早期做项目时就吃过这个亏。一个传感器偶尔会无响应,结果整机死机,客户投诉。从那以后,我所有的轮询代码都加了超时处理。
实现思路很简单:用一个变量计数,超过阈值就退出循环,返回错误码。
/**
* @brief 带超时的 SPI 收发函数
* @param byte: 要发送的字节
* @param timeout: 超时次数(单位:循环次数)
* @retval 0: 超时失败;非 0: 接收到的数据
*/
uint8_t SPI_ReadWriteByte_Timeout(uint8_t byte, uint32_t timeout)
{
// 等待 TXE,带超时
while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET)
{
if (--timeout == 0)
{
return 0; // 超时返回 0
}
}
SPI_I2S_SendData(SPI1, byte);
// 重置超时计数
timeout = 0xFFFF;
// 等待 RXNE,带超时
while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET)
{
if (--timeout == 0)
{
return 0; // 超时返回 0
}
}
return SPI_I2S_ReceiveData(SPI1);
}
更好的做法是使用硬件定时器来做超时,但那是后面章节的内容了。对于轮询模式,软件计数已经够用。
3.5 本章知识结构图
下面这张图总结了 SPI 轮询模式驱动的完整流程,从 GPIO 配置到带超时的收发函数: