4. 惯性传感器原理

好,咱们正式开始写代码了。这一章我带你从零搭建一个 SPI 轮询驱动。说白了,就是直接操作寄存器,让 SPI 外设按照我们的意愿工作。

你可能会问:现在有那么多 HAL 库、LL 库,为什么还要自己操作寄存器?我在项目里吃过这个亏——某次用 HAL 库调一个高速传感器,发现库函数里插了好多冗余检查,导致每次传输都多花几十微秒。后来我直接操作寄存器,性能一下就上来了。所以,理解底层寄存器,是嵌入式工程师的硬功夫。

4.1 寄存器操作基础

在 STM32 上,SPI 外设的寄存器都映射到一段连续的内存地址。我们通过指针来读写它们。我个人习惯用结构体来封装,这样代码更清晰。

核心思路: SPI 的寄存器操作,就是往特定地址写控制字,然后读状态、读数据。就这么简单。

先看一个典型的 SPI 寄存器基地址定义(以 STM32F4 为例):

// SPI 寄存器基地址
#define SPI1_BASE             0x40013000
#define SPI2_BASE             0x40003800
#define SPI3_BASE             0x40003C00

// 寄存器偏移量
#define SPI_CR1_OFFSET        0x00
#define SPI_CR2_OFFSET        0x04
#define SPI_SR_OFFSET         0x08
#define SPI_DR_OFFSET         0x0C

// 直接操作宏
#define SPI1->CR1             (*(volatile uint32_t *)(SPI1_BASE + SPI_CR1_OFFSET))
#define SPI1->CR2             (*(volatile uint32_t *)(SPI1_BASE + SPI_CR2_OFFSET))
#define SPI1->SR              (*(volatile uint32_t *)(SPI1_BASE + SPI_SR_OFFSET))
#define SPI1->DR              (*(volatile uint32_t *)(SPI1_BASE + SPI_DR_OFFSET))

嗯,这里要注意:volatile 关键字绝对不能少。编译器很聪明,它会优化掉看似「多余」的读取。但寄存器值随时可能被硬件改变,不加 volatile,你读到的可能是缓存里的旧值。我曾经因为这个 bug 调了整整两天。

4.2 SPI_CR1 / CR2 / SR / DR 寄存器详解

这四个寄存器,是 SPI 驱动的核心。我画了一张图,帮你理清它们的关系:

SPI 核心寄存器关系图 SPI 核心寄存器关系图 CR1 控制寄存器 1 CR2 控制寄存器 2 SR 状态寄存器 DR 数据寄存器 说明: CR1 配置模式、波特率、时钟极性/相位;CR2 使能中断和 DMA; SR 反映当前状态(忙、发送空、接收非空);DR 是收发共用的数据通道。

4.2.1 CR1 寄存器——SPI 的「大脑」

CR1 决定了 SPI 的工作模式。它的每一位都很关键:

位域 名称 功能描述
15 BIDIMODE 双向数据模式使能。0=双线,1=单线
14 BIDIOE 双向模式下的输出使能
13 CRCEN 硬件 CRC 使能。一般不用,除非你搞安全通信
12 CRCNEXT 下一个传输使用 CRC
11 DFF 数据帧格式。0=8位,1=16位
10 RXONLY 只接收模式
9 SSM 软件从设备管理使能
8 SSI 内部从设备选择。配合 SSM 使用
7 LSBFIRST 数据位顺序。0=MSB 先,1=LSB 先
6 SPE SPI 使能。写 1 开启外设
5:3 BR[2:0] 波特率分频。000=fPCLK/2, 001=fPCLK/4, ... 111=fPCLK/256
2 MSTR 主从选择。1=主机,0=从机
1 CPOL 时钟极性。0=空闲低,1=空闲高
0 CPHA 时钟相位。0=第一个边沿采样,1=第二个边沿采样

我的经验: 配置 CR1 时,建议先关掉 SPE(写0),等所有参数配好后再使能。否则外设可能在配置中途就开始工作,产生不可预期的时序。

4.2.2 CR2 寄存器——中断和 DMA 的开关

CR2 主要控制中断和 DMA 的使能。在轮询模式下,我们基本用不到它,但了解总没坏处:

  • 位 7 (TXEIE): 发送缓冲区空中断使能。当 TXE 标志置位时触发中断。
  • 位 6 (RXNEIE): 接收缓冲区非空中断使能。当 RXNE 标志置位时触发中断。
  • 位 5 (ERRIE): 错误中断使能。溢出、CRC 错误、模式错误都会触发。
  • 位 1:0 (RXDMAEN / TXDMAEN): DMA 请求使能。高速传输时用。

轮询模式下,这些位全部保持 0 即可。

4.2.3 SR 寄存器——SPI 的「晴雨表」

SR 寄存器告诉我们 SPI 当前的状态。轮询模式的核心,就是不断检查这些标志位:

位 名称 含义 轮询中的用途
7 BSY 忙标志。1=正在传输或接收 发送前检查是否空闲
6 OVR 溢出标志。接收溢出时置1 错误处理
5 MODF 模式错误。多主机冲突时置1 错误处理
4 CRCERR CRC 错误标志 错误处理
1 TXE 发送缓冲区空。1=可以写入新数据 发送前等待此位置1
0 RXNE 接收缓冲区非空。1=有数据可读 接收前等待此位置1

注意: BSY 标志在高速传输时可能不稳定。我建议用 TXE 和 RXNE 来判断传输状态,而不是依赖 BSY。BSY 更适合在传输结束后做一次最终确认。

4.2.4 DR 寄存器——数据通道

DR 是 SPI 的数据寄存器。发送和接收共用这一个寄存器。你写数据到 DR,就是发送;你从 DR 读数据,就是接收。

这里有个关键点:SPI 的收发是同步的。你发送一个字节的同时,一定会收到一个字节。所以轮询模式下,发送和接收是成对出现的。

4.3 轮询发送与接收实现

好,理论知识够了。咱们直接写代码。下面是一个完整的轮询模式 SPI 收发函数:

/**
 * @brief  SPI 轮询模式发送一个字节,并接收一个字节
 * @param   data: 要发送的数据
 * @return  接收到的数据
 */
uint8_t SPI_Polling_Transfer(uint8_t data)
{
    uint32_t timeout = 0xFFFFF;  // 超时计数
    
    // 步骤1:等待 TXE 置位(发送缓冲区空)
    while (!(SPI1->SR & SPI_SR_TXE))
    {
        if (--timeout == 0)
        {
            // 超时处理
            return 0xFF;  // 返回错误值
        }
    }
    
    // 步骤2:写入数据到 DR
    SPI1->DR = data;
    
    // 步骤3:等待 RXNE 置位(接收缓冲区非空)
    timeout = 0xFFFFF;  // 重置超时
    while (!(SPI1->SR & SPI_SR_RXNE))
    {
        if (--timeout == 0)
        {
            return 0xFF;
        }
    }
    
    // 步骤4:读取接收数据
    return (uint8_t)(SPI1->DR);
}

这个函数看起来简单,但有几个细节要注意:

  • 先等 TXE,再写数据: 确保发送缓冲区是空的,否则写入会覆盖未发送的数据。
  • 写 DR 后立即等 RXNE: 因为 SPI 是同步的,发送完成时接收也完成了。
  • 读 DR 前必须等 RXNE: 如果提前读,可能读到的是旧数据。

批量传输优化: 如果你要发送多个字节,可以这样写:

void SPI_Polling_Transfer_Buffer(uint8_t *tx_buf, uint8_t *rx_buf, uint16_t len)
{
    for (uint16_t i = 0; i < len; i++)
    {
        rx_buf[i] = SPI_Polling_Transfer(tx_buf[i]);
    }
}

注意:如果只发送不关心接收,可以忽略 rx_buf;如果只接收,可以发送 0xFF 来产生时钟。

4.4 超时处理机制

轮询模式最大的风险是什么?死等。如果从设备没响应,或者硬件出了问题,你的程序就会卡在 while 循环里出不来。这就是为什么每个等待循环都要加超时。

我常用的超时方案有两种:

方案 实现方式 优点 缺点
软件计数 递减一个变量,到0则超时 简单,不依赖硬件 时间不精确,受主频影响
硬件定时器 用 SysTick 或 TIM 计时 时间精确,可移植 需要额外配置定时器

在裸机开发中,我倾向于先用软件计数,简单够用。等系统复杂了再换硬件定时器。

下面是一个带硬件定时器超时的版本:

uint8_t SPI_Polling_Transfer_Timeout(uint8_t data, uint32_t timeout_ms)
{
    uint32_t tick_start = GetSysTick();  // 获取当前系统滴答
    
    // 等待 TXE
    while (!(SPI1->SR & SPI_SR_TXE))
    {
        if ((GetSysTick() - tick_start) > timeout_ms)
        {
            return 0xFF;  // 超时返回
        }
    }
    
    SPI1->DR = data;
    tick_start = GetSysTick();  // 重置计时
    
    // 等待 RXNE
    while (!(SPI1->SR & SPI_SR_RXNE))
    {
        if ((GetSysTick() - tick_start) > timeout_ms)
        {
            return 0xFF;
        }
    }
    
    return (uint8_t)(SPI1->DR);
}

避坑指南: 我曾经在一个项目里忘了加超时,结果从设备在初始化阶段没准备好,主控直接卡死在 SPI 发送函数里。整个系统看起来像死机了一样。从那以后,我所有的轮询循环都强制加超时,哪怕超时时间设得很大。

4.5 完整的 SPI 初始化与轮询驱动

最后,我们把所有东西串起来,写一个完整的 SPI 初始化函数:

void SPI1_Init(void)
{
    // 1. 使能 SPI1 时钟
    RCC->APB2ENR |= RCC_APB2ENR_SPI1EN;
    
    // 2. 配置 GPIO(PA5=SCK, PA6=MISO, PA7=MOSI)
    // 这里省略 GPIO 配置细节,后续章节会详细讲
    
    // 3. 配置 SPI1 控制寄存器
    SPI1->CR1 = 0;  // 先清零
    
    // 配置为主机模式,CPOL=0, CPHA=0, 8位数据, MSB先
    // 波特率分频:fPCLK/16(假设 PCLK=84MHz,则 SCK=5.25MHz)
    SPI1->CR1 |= (1 << 2);   // MSTR = 1,主机模式
    SPI1->CR1 |= (3 << 3);   // BR[2:0] = 011,分频系数 16
    // CPOL=0, CPHA=0 保持默认
    
    // 4. 使能 SPI1
    SPI1->CR1 |= (1 << 6);   // SPE = 1
}

初始化完成后,就可以用 SPI_Polling_Transfer() 来收发数据了。记住:每次传输前检查一下 SR 的 BSY 位,确保 SPI 不在忙状态。

我的习惯: 在初始化函数的末尾,做一个自检——发送一个已知值(比如 0x55),然后检查接收到的值。如果自检失败,直接报错。这样可以在开发阶段尽早发现问题。