3. 传感器驱动开发:I2C、SPI、UART 与寄存器操作实战
各位同学,欢迎来到传感器驱动开发这一章。说实话,搞物联网数据采集,最核心的硬功夫就是跟总线协议和寄存器打交道。你想想看,传感器再高级,数据最终都得通过这几条线传出来。今天我就把 I2C、SPI、UART 这三个最常用的协议掰开揉碎了讲,再带你们上手操作寄存器。
本章核心目标:掌握三种总线协议的物理层特性、时序差异和驱动编写套路,能独立完成传感器寄存器的读写操作。
3.1 I2C 总线协议详解与驱动编写
I2C 总线,说白了就是两根线干大事——一根时钟线 SCL,一根数据线 SDA。它最大的特点是支持多从机,每个设备都有唯一的 7 位或 10 位地址。我刚开始做项目时,总觉得 I2C 协议简单,结果在时序上栽过跟头。
3.1.1 物理层与通信流程
I2C 是半双工通信,主机负责产生时钟信号。通信流程分四步:
- 起始条件:SCL 高电平时,SDA 从高变低。
- 发送地址:主机发送 7 位从机地址 + 1 位读写位。
- 数据收发:每字节 8 位,后面跟一个 ACK 应答位。
- 停止条件:SCL 高电平时,SDA 从低变高。
避坑指南:我曾经在调试温湿度传感器时,发现读回来的数据全是 0xFF。查了半天,原来是上拉电阻没焊。I2C 总线必须要有上拉电阻,一般 4.7kΩ 起步,总线电容大时得换小一点的阻值。
3.1.2 驱动编写示例(以 STM32 为例)
写 I2C 驱动,我个人习惯用寄存器操作,不依赖 HAL 库。这样你能真正理解底层在干什么。下面是一个读取传感器寄存器值的核心代码:
// I2C 读取多字节函数
// 参数:dev_addr - 从机地址,reg_addr - 寄存器地址
// pData - 数据缓冲区,len - 读取长度
uint8_t I2C_ReadReg(uint8_t dev_addr, uint8_t reg_addr,
uint8_t *pData, uint16_t len)
{
// 1. 发送起始条件
I2C_GenerateSTART(ENABLE);
while(!I2C_CheckEvent(I2C_EVENT_MASTER_MODE_SELECT));
// 2. 发送从机地址 + 写位
I2C_Send7bitAddress(dev_addr, I2C_DIRECTION_TX);
while(!I2C_CheckEvent(I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED));
// 3. 发送寄存器地址
I2C_SendData(reg_addr);
while(!I2C_CheckEvent(I2C_EVENT_MASTER_BYTE_TRANSMITTED));
// 4. 重新发送起始条件(重复起始)
I2C_GenerateSTART(ENABLE);
while(!I2C_CheckEvent(I2C_EVENT_MASTER_MODE_SELECT));
// 5. 发送从机地址 + 读位
I2C_Send7bitAddress(dev_addr, I2C_DIRECTION_RX);
while(!I2C_CheckEvent(I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED));
// 6. 循环读取数据
for(uint16_t i = 0; i < len; i++)
{
if(i == len - 1)
I2C_AcknowledgeConfig(DISABLE); // 最后一字节不发 ACK
while(!I2C_CheckEvent(I2C_EVENT_MASTER_BYTE_RECEIVED));
pData[i] = I2C_ReceiveData();
}
// 7. 发送停止条件
I2C_GenerateSTOP(ENABLE);
return 0;
}
注意:重复起始条件(Repeated Start)是 I2C 协议的精髓。很多新手在这里犯错,直接发了停止再发起始,这样会导致总线竞争。记住,读操作必须用重复起始,不能断开总线。
3.2 SPI 总线协议详解与驱动编写
SPI 跟 I2C 不一样,它是全双工通信,四根线:SCLK、MOSI、MISO、CS。速度比 I2C 快得多,适合高速传感器,比如加速度计、陀螺仪。我记得有个项目要用 SPI 读取 3D 磁力计数据,速率跑到 10MHz 都没问题。
3.2.1 四种工作模式
SPI 有四种模式,由时钟极性 CPOL 和时钟相位 CPHA 决定。很多传感器数据手册会写“Mode 0”或“Mode 3”,你得仔细看。
| 模式 | CPOL | CPHA | 时钟空闲电平 | 数据采样边沿 |
|---|---|---|---|---|
| Mode 0 | 0 | 0 | 低电平 | 上升沿 |
| Mode 1 | 0 | 1 | 低电平 | 下降沿 |
| Mode 2 | 1 | 0 | 高电平 | 下降沿 |
| Mode 3 | 1 | 1 | 高电平 | 上升沿 |
3.2.2 驱动编写要点
写 SPI 驱动,核心就是操作移位寄存器。你往数据寄存器写一个字节,同时就会收到一个字节。下面是一个简单的 SPI 读写函数:
// SPI 读写一个字节
uint8_t SPI_ReadWriteByte(uint8_t txData)
{
// 等待发送缓冲区空
while(SPI_I2S_GetFlagStatus(SPI_FLAG_TXE) == RESET);
// 发送数据
SPI_I2S_SendData(txData);
// 等待接收缓冲区非空
while(SPI_I2S_GetFlagStatus(SPI_FLAG_RXNE) == RESET);
// 返回接收到的数据
return SPI_I2S_ReceiveData();
}
// 读取传感器寄存器(先发地址,再读数据)
uint8_t SPI_ReadReg(uint8_t regAddr)
{
uint8_t result;
// 片选拉低
GPIO_ResetBits(SPI_CS_GPIO, SPI_CS_PIN);
// 发送寄存器地址(读操作时,地址最高位通常为 1)
SPI_ReadWriteByte(regAddr | 0x80);
// 发送 dummy 字节,同时接收数据
result = SPI_ReadWriteByte(0x00);
// 片选拉高
GPIO_SetBits(SPI_CS_GPIO, SPI_CS_PIN);
return result;
}
经验之谈:我曾经在调试 SPI 时,发现读回来的数据总是错位。后来用示波器一看,原来是 CS 片选信号没等时钟稳定就拉低了。记住,CS 拉低后要加一小段延时,等时钟稳定再开始传输。这个坑我踩过两次,现在每次写 SPI 驱动都会加个 nop 指令。
3.3 UART 协议详解与驱动编写
UART 是异步串口,不需要时钟线。它靠波特率来同步,两根线:TX 和 RX。很多传感器用 UART 输出数据,比如 GPS 模块、激光雷达。UART 的协议层比较简单,但波特率匹配是个关键点。
3.3.1 数据帧格式
一个 UART 数据帧包含:起始位(1 位)、数据位(5-8 位)、校验位(可选)、停止位(1-2 位)。最常见的是 8N1:8 位数据、无校验、1 位停止位。
3.3.2 驱动编写示例
UART 驱动主要处理中断接收和环形缓冲区。我习惯用 DMA 方式接收不定长数据,这样 CPU 不用一直轮询。
// UART 初始化配置(以 115200 波特率为例)
void UART_Init(void)
{
USART_InitTypeDef USART_InitStructure;
USART_InitStructure.USART_BaudRate = 115200;
USART_InitStructure.USART_WordLength = USART_WordLength_8b;
USART_InitStructure.USART_StopBits = USART_StopBits_1;
USART_InitStructure.USART_Parity = USART_Parity_No;
USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
USART_InitStructure.USART_HardwareFlowControl =
USART_HardwareFlowControl_None;
USART_Init(USART1, &USART_InitStructure);
USART_Cmd(USART1, ENABLE);
}
// 中断接收处理(放在中断服务函数中)
void UART_IRQHandler(void)
{
if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET)
{
uint8_t data = USART_ReceiveData(USART1);
// 将数据放入环形缓冲区
RingBuf_Write(&uart_rx_buf, data);
}
}
注意:UART 的波特率误差不能超过 ±2%。我见过有人用 8MHz 晶振配 115200 波特率,结果误差达到 3.4%,通信时好时坏。建议用示波器实测 TX 引脚的波形,数一下位宽是否准确。
3.4 寄存器操作实战
好了,前面讲了三种总线怎么传数据。但传感器驱动最核心的是——操作寄存器。每个传感器内部都有一堆寄存器,你得知道哪个寄存器存了什么数据,怎么配置。
3.4.1 寄存器操作三板斧
我个人总结了一套套路,不管什么传感器,都按这三步走:
- 读数据手册:找到寄存器映射表,确认地址、默认值、位定义。
- 写配置寄存器:设置工作模式、量程、采样率等。
- 读数据寄存器:连续读取多个字节,拼接成有效数据。
3.4.2 实战:读取加速度计数据
以 ADXL345 为例,它的加速度数据存放在 0x32-0x37 寄存器中,每个轴占 2 个字节。下面是用 I2C 读取的完整流程:
// 初始化 ADXL345
void ADXL345_Init(void)
{
uint8_t data;
// 读取设备 ID,确认通信正常
I2C_ReadReg(ADXL345_ADDR, 0x00, &data, 1);
if(data != 0xE5)
{
// 设备 ID 不对,报错
return;
}
// 设置数据格式:±2g,13位分辨率
data = 0x0B; // 全分辨率模式
I2C_WriteReg(ADXL345_ADDR, 0x31, &data, 1);
// 设置采样率:100Hz
data = 0x0A; // 100Hz
I2C_WriteReg(ADXL345_ADDR, 0x2C, &data, 1);
// 进入测量模式
data = 0x08;
I2C_WriteReg(ADXL345_ADDR, 0x2D, &data, 1);
}
// 读取三轴加速度
void ADXL345_ReadAccel(float *pX, float *pY, float *pZ)
{
uint8_t buf[6];
int16_t rawX, rawY, rawZ;
// 从 0x32 开始连续读取 6 个字节
I2C_ReadReg(ADXL345_ADDR, 0x32, buf, 6);
// 拼接原始数据(小端模式)
rawX = (int16_t)(buf[1] << 8 | buf[0]);
rawY = (int16_t)(buf[3] << 8 | buf[2]);
rawZ = (int16_t)(buf[5] << 8 | buf[4]);
// 转换为实际加速度值(±2g 量程下,1 LSB = 3.9mg)
*pX = rawX * 0.0039f;
*pY = rawY * 0.0039f;
*pZ = rawZ * 0.0039f;
}
避坑指南:我曾经在读取 16 位数据时,没注意字节序。传感器数据手册写的是小端模式,我按大端拼接了,结果加速度值全反了。记住,一定要确认数据手册里的字节序说明,别想当然。
3.5 本章知识体系总览
下面这张图总结了三种总线的核心差异和适用场景,方便你快速对照选择:
嗯,到这里,三种总线协议和寄存器操作的核心内容就讲完了。你想想看,其实不管传感器多复杂,底层无非就是通过 I2C、SPI 或 UART 去读写寄存器。掌握了这个套路,以后遇到任何新传感器,你都能快速上手。