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 驱动的核心。我画了一张图,帮你理清它们的关系:
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),然后检查接收到的值。如果自检失败,直接报错。这样可以在开发阶段尽早发现问题。