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) 或软件管理
注意:MISO 是输入引脚,千万别配成推挽输出。我曾经见过有人把 MISO 配成输出,结果两个输出怼在一起,直接烧了引脚。

代码怎么写?我习惯用标准库,清晰明了:

// 使能 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);
小技巧:如果你用的是硬件 NSS,PA4 也要配成复用推挽输出。但我个人更推荐软件 NSS 管理,后面会讲为什么。

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 高电平 上升沿
核心原则:主从机的 CPOL 和 CPHA 必须完全一致。我遇到过最坑的一次,是某款传感器手册里写的是模式 0,但实际测试发现它工作在模式 3。最后用示波器抓波形才确认。所以,不要完全相信手册,用示波器验证最靠谱。

波特率怎么算?很简单:

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 是同步通信,主机每发送一个字节,从机也会同时返回一个字节。如果你只发不收,接收缓冲区里的数据会堆积,导致下次接收错位。所以,即使你只关心发送,也要把接收到的数据读出来扔掉。

实际使用中,比如读写一个 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);
}
注意:超时值要根据你的系统时钟和实际场景来调整。太短容易误判,太长又起不到保护作用。我一般先用示波器测出正常通信的最长时间,然后乘以 2~3 倍作为超时阈值。

更好的做法是使用硬件定时器来做超时,但那是后面章节的内容了。对于轮询模式,软件计数已经够用。

3.5 本章知识结构图

下面这张图总结了 SPI 轮询模式驱动的完整流程,从 GPIO 配置到带超时的收发函数:

SPI 轮询模式驱动开发流程 1. GPIO 初始化 2. SPI 参数配置 3. 使能 SPI (SPI_Cmd) 4. 数据收发 (轮询) 5. 超时处理机制 关键点 • 复用推挽输出 • CPOL/CPHA 匹配 • 波特率计算 • TXE/RXNE 标志 • 收发成对出现 • 软件 NSS 管理 • 超时防死等 • 示波器验证