STM32F103实战:用HAL库+软件模拟I2C驱动SPL06气压传感器(附完整代码)

在无人机定高、智能车坡道检测或者室内导航项目中,精准的高度信息往往是决定成败的关键。硬件I2C外设虽然方便,但在资源紧张的STM32F103这类Cortex-M3内核MCU上,有时会面临引脚冲突、时序不稳定或者DMA配置复杂等问题。这时,回归底层,用几根普通的GPIO口“手搓”一个软件模拟I2C协议,反而成了一种高效可靠的解决方案。本文将带你深入实战,基于STM32 HAL库,从零构建一个驱动SPL06高精度气压传感器的完整工程,不仅会详细拆解软件I2C的每一个时序细节,更会分享如何利用数据结构优化校准系数管理,并实现一套高效的高度滤波算法,让你的项目飞得更稳、跑得更准。

1. 项目环境搭建与工程配置

在开始敲代码之前,一个清晰、模块化的工程结构能避免后续无数麻烦。我习惯为每个重要的外设或传感器创建独立的.c/.h文件对,这样主程序逻辑干净,调试和移植也方便。

1.1 硬件连接与CubeMX基础配置

首先,确保你的硬件连接正确。SPL06传感器通常支持I2C和SPI,我们选择I2C模式。其I2C地址由SDO引脚电平决定:SDO接高电平(VDDIO)时地址为0x77,接低电平(GND)时为0x76。这里我们以0x77为例。

硬件连接表:

STM32F103引脚SPL06引脚说明
3.3VVDD电源
GNDGND地
任意GPIO (如PB6)SCL时钟线
任意GPIO (如PB7)SDA数据线
3.3V 或 GNDSDO地址选择

注意:用于模拟I2C的GPIO口必须配置为**开漏输出(Open-Drain)**模式,并且外部需要接上拉电阻(通常4.7KΩ)。这是I2C总线标准的硬件要求,确保总线能够实现“线与”功能。

使用STM32CubeMX初始化项目:

  1. 选择你的STM32F103型号。
  2. 在System Core -> SYS中,将Debug选为Serial Wire(如果要用SWD下载调试)。
  3. 配置时钟树(RCC),根据你的外部晶振选择HSE,并设置系统时钟为72MHz(F103的典型值)。
  4. 关键一步:为我们用于模拟I2C的两个GPIO(例如PB6和PB7)配置模式。在Pinout & Configuration视图的芯片图上,找到PB6和PB7,将其模式设置为GPIO_Output。然后,在左侧System Core -> GPIO中,分别点击PB6和PB7,将其GPIO output level初始化为高电平(High),GPIO mode设置为Open Drain,GPIO Pull-up/Pull-down设置为No pull-up and no pull-down(因为我们在外部接了上拉电阻)。
  5. 生成代码(Generate Code),选择MDK-ARM(Keil)或你使用的IDE。

1.2 软件I2C底层驱动实现

软件I2C的核心就是通过精确控制GPIO电平的变化来模拟I2C协议的时序。我们创建一个MyI2C.c和MyI2C.h文件。代码的健壮性在于对每一个时序细节的把握,比如启动、停止、应答信号。

// MyI2C.h
#ifndef __MYI2C_H
#define __MYI2C_H

#include "main.h"

// 根据你的实际连接修改引脚定义
#define BARO_SCL_GPIO_Port GPIOB
#define BARO_SCL_Pin GPIO_PIN_6
#define BARO_SDA_GPIO_Port GPIOB
#define BARO_SDA_Pin GPIO_PIN_7

// 基础时序控制函数
void MyI2C_W_SCL(uint8_t BitValue);
void MyI2C_W_SDA(uint8_t BitValue);
uint8_t MyI2C_R_SDA(void);

// I2C协议层函数
void MyI2C_Start(void);
void MyI2C_Stop(void);
void MyI2C_SendByte(uint8_t Byte);
uint8_t MyI2C_ReceiveByte(void);
void MyI2C_SendAck(uint8_t AckBit);
uint8_t MyI2C_ReceiveAck(void);

// 应用层封装函数(针对SPL06)
uint8_t MyI2C_WriteRegister(uint8_t RegAddr, uint8_t byte);
uint8_t MyI2C_ReadRegister(uint8_t RegAddr);
uint8_t MyI2C_ReadMultiRegister(uint8_t I2CAddr, uint8_t RegAddr, uint8_t length, uint8_t* buffer);
uint8_t MyI2C_WriteMultiRegister(uint8_t I2CAddr, uint8_t RegAddr, uint8_t length, uint8_t* buffer);

#endif

在.c文件中,实现最关键的几个函数。这里有一个我调试时遇到的坑:SCL高电平期间,SDA必须保持稳定。这意味着在MyI2C_SendByte和MyI2C_ReceiveByte函数中,改变SDA电平的操作必须在SCL为低时进行。

// MyI2C.c 部分核心代码
void MyI2C_SendByte(uint8_t Byte) {
    uint8_t i;
    for (i = 0; i < 8; i++) {
        // 先设置SDA数据位(SCL为低)
        MyI2C_W_SDA(Byte & (0x80 >> i)); // 取出最高位依次发送
        Delay_us(5); // 短暂延时,保证数据稳定
        // 拉高SCL,产生时钟脉冲,从机在此时采样SDA
        MyI2C_W_SCL(1);
        Delay_us(10); // 保持高电平时间,满足传感器时序要求
        // 拉低SCL,为下一个数据位做准备
        MyI2C_W_SCL(0);
        Delay_us(5);
    }
}

uint8_t MyI2C_ReceiveByte(void) {
    uint8_t i, Byte = 0x00;
    MyI2C_W_SDA(1); // 释放SDA线,设置为输入模式(靠外部上拉)
    for (i = 0; i < 8; i++) {
        MyI2C_W_SCL(1); // 拉高SCL
        Delay_us(10);
        if (MyI2C_R_SDA()) { // 读取SDA电平
            Byte |= (0x80 >> i); // 从最高位开始组装数据
        }
        MyI2C_W_SCL(0); // 拉低SCL
        Delay_us(5);
    }
    return Byte;
}

Delay_us函数需要一个微秒级延时,你可以用HAL库的HAL_Delay(毫秒级)配合循环实现,或者使用SysTick定时器实现更精确的延时。这是软件模拟时序的精度保障。

2. SPL06传感器驱动深度解析

SPL06是一款性能出色的MEMS气压传感器,其精度和温度补偿算法是亮点。驱动它不仅仅是读写寄存器,更要理解其内部工作机制和校准流程。

2.1 寄存器配置与初始化流程

SPL06的初始化是一个有严格顺序的过程,乱序可能导致传感器工作异常。完整的初始化函数SPL06_Init应该包含以下步骤:

  1. I2C通信测试:向传感器写入再读出,验证物理连接和基本通信是否正常。这是排查硬件问题的第一道关卡。
  2. 软复位:向RESET寄存器写入特定序列0x89,让传感器恢复到上电默认状态,确保配置起点一致。
  3. 配置测量模式:通过MEAS_CFG寄存器选择连续测量模式(例如同时连续测量气压和温度)。
  4. 配置气压和温度测量参数:分别设置PRS_CFG和TMP_CFG寄存器,关键参数是测量速率(Measure Rate)和过采样率(Oversampling Rate)。过采样率越高,单次测量时间越长,但能有效抑制噪声,提高分辨率。需要根据应用在速度和精度间权衡。
  5. 启用P-Shift:当气压过采样率设置大于8倍时,必须将CFG_REG寄存器的P_SHIFT位置1,以避免数据溢出。
  6. 读取校准系数:这是获得精确数据的核心。传感器出厂时,会将一组独特的补偿系数(c00, c10, c01, c11, c20, c21, c30, c0, c1)写入OTP存储器。必须在测量前读取并妥善处理。

这里提供一个关键配置的示例代码片段:

// 配置连续测量气压和温度
uint8_t SPL_OperatingModeInit(MeasureModeConfig config) {
    if(MyI2C_WriteRegister(MEAS_CFG, config)) {
        return 1; // 写入失败
    }
    return 0;
}

// 在主初始化函数中调用
if(SPL_OperatingModeInit(STRAT_CONTINUOUS_PSR_TEMP)) {
    // 处理错误
}

2.2 校准系数的读取与处理

校准系数是24位有符号补码形式存储的,直接使用会导致计算错误。GetCoefVal函数完成了读取和转换。这里面的难点在于补码到原码的转换,以及不同系数位宽(12位、16位、20位)的处理。

// 以20位系数c00为例,演示补码转换逻辑
#define Total_Number_20 1048576.0 // 2^20

if(COEF->raw_C00 & 0x80000) { // 检查最高位(符号位)是否为1
    // 如果是负数,补码转原码:原码 = 补码 - 2^n
    COEF->C00 = COEF->raw_C00 - Total_Number_20;
} else {
    COEF->C00 = COEF->raw_C00; // 正数,补码即原码
}

所有系数转换后,会存储在一个结构体中,供后续补偿计算使用。务必确保在传感器上电并完成初始测量后(COEF_RDY标志位置位)再读取系数,否则读到的可能是默认值。

3. 数据结构优化:链表管理比例因子

SPL06的原始气压和温度数据(Praw和Traw)需要根据设置的过采样率除以一个对应的**比例因子(Scale Factor)**才能得到用于补偿计算的Praw_sc和Traw_sc。这个比例因子与过采样率是一一对应的固定关系。

一种简单的实现是用switch-case或查找表。但这里我介绍一种更优雅、易于维护和扩展的方法:使用单向链表。当未来需要支持更多过采样率或类似传感器时,只需在链表中添加节点,而无需修改查找逻辑。

// 定义链表节点结构
typedef struct node {
    uint8_t OverSamplingRate; // 过采样率配置值(寄存器值)
    uint32_t FACTOR;          // 对应的比例因子
    struct node* next;
} Factor_List;

// 全局链表头指针
Factor_List* b_list;

// 初始化链表并插入所有已知的过采样率-比例因子对
void Init_FactorList(void) {
    b_list = initList(); // 创建头节点,存储单次过采样的数据
    insertTail(b_list, _2TIMES_OVERSAMPLING, _2TIMES_SCALE_FACTOR);
    insertTail(b_list, _4TIMES_OVERSAMPLING, _4TIMES_SCALE_FACTOR);
    // ... 插入其他节点
    insertTail(b_list, _128TIMES_OVERSAMPLING, _128TIMES_SCALE_FACTOR);
}

// 根据过采样率查找比例因子
uint32_t FindFactor(Factor_List* l, uint8_t val) {
    while(l != NULL) {
        if(l->OverSamplingRate == val) {
            return l->FACTOR;
        }
        l = l->next;
    }
    return 0; // 未找到,返回0或错误码
}

在GetCompensatedVal函数中,只需调用FindFactor(b_list, PSR_InitStructure.OVER_SAMPLING_TIMES)即可获得正确的KP值。这种设计将数据与算法解耦,提高了代码的清晰度和可复用性。

4. 从气压到高度:计算与滤波实战

得到校准后的气压值(单位:Pa)后,就可以将其转换为高度了。同时,由于气压信号本身存在噪声,直接计算的高度值会跳动,必须进行滤波。

4.1 气压-高度转换公式

国际标准大气压模型给出了气压与海拔高度的近似关系。我们使用最常用的公式:

[ \text{Altitude} = 44330 \times \left( 1 - \left( \frac{P}{P_0} \right)^{\frac{1}{5.255}} \right) ]

其中:

  • Altitude 是海拔高度(米)。
  • P 是测量点的绝对气压(帕斯卡,Pa)。
  • P0 是海平面标准大气压,通常取 101325 Pa。
#define P0 101325.0f

float getAltitude(float P) { // 输入P单位为Pa
    float Altitude;
    // 增加合理性检查,避免无效数据
    if(P > 30000.0f && P < 120000.0f) {
        Altitude = 44330.0f * (1.0f - powf(P / P0, 1.0f/5.255f));
    } else {
        return -9999.0f; // 返回一个明显错误值
    }
    return Altitude;
}

提示:这个公式在低空(<11km)精度较高。P0也可以根据当地气象站数据实时校准,以消除天气变化带来的基准误差,这对于高精度定高应用尤为重要。

4.2 高度数据滤波算法:快速排序结合中值均值滤波

原始高度数据往往毛刺较多。简单的移动平均滤波在数据出现突发跳变(野值)时效果很差。我推荐一种结合了中值滤波抗野值和均值滤波平滑特性的混合算法:“掐头去尾”中值均值滤波。

其思路是:

  1. 维护一个固定长度(例如32个点)的滑动窗口,存储最近的高度原始值。
  2. 每次得到新数据时,更新这个窗口。
  3. 对窗口内的数据进行快速排序。
  4. 排序后,丢弃最高和最低的各三分之一数据(即去除可能存在的野值)。
  5. 对剩余的中间三分之一数据求平均值,作为本次滤波输出。
#define FILTER_WINDOW_SIZE 32

float AltitudeFilter(float newAltitude) {
    static float dataWindow[FILTER_WINDOW_SIZE] = {0};
    static int index = 0;
    float sortedWindow[FILTER_WINDOW_SIZE];
    float sum = 0.0f;

    // 1. 更新滑动窗口
    dataWindow[index] = newAltitude;
    index = (index + 1) % FILTER_WINDOW_SIZE;

    // 2. 复制到临时数组用于排序(不破坏原始顺序)
    memcpy(sortedWindow, dataWindow, sizeof(dataWindow));

    // 3. 快速排序
    quickSort(sortedWindow, 0, FILTER_WINDOW_SIZE - 1);

    // 4. & 5. 掐头去尾,取中间值平均
    int startIdx = FILTER_WINDOW_SIZE / 3;
    int endIdx = FILTER_WINDOW_SIZE * 2 / 3;
    int validCount = endIdx - startIdx;

    for(int i = startIdx; i < endIdx; i++) {
        sum += sortedWindow[i];
    }

    return sum / validCount;
}

这个quickSort函数需要自己实现一个针对浮点数的快速排序算法。这种滤波方法在无人机项目中实测效果显著,既能滤除高频噪声,又能有效抵抗偶尔的跳变干扰,输出曲线非常平滑。

5. 系统集成与性能调优

将各个模块组合起来,并在主循环中调用,就完成了整个驱动。但要让它在实际项目中稳定运行,还需要关注一些细节。

5.1 主程序逻辑与任务调度

在main.c中,逻辑应该清晰简洁。初始化后,进入主循环,以固定的周期读取、计算并滤波高度值。

int main(void) {
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();

    float baro_hPa, temp_C, altitude_m, filtered_altitude_m;
    uint8_t init_status;

    // 初始化SPL06
    init_status = SPL06_Init();
    if(init_status != 0) {
        // 初始化失败,根据状态码处理错误,例如通过LED闪烁或串口打印
        Error_Handler(init_status);
    }

    while (1) {
        // 1. 获取校准后的气压和温度
        if(GetCompensatedVal(&baro_hPa, &temp_C, &Coefficient) == 0) {
            // 2. 转换为高度(注意单位转换,GetCompensatedVal输出hPa,getAltitude输入Pa)
            altitude_m = getAltitude(baro_hPa * 100.0f);

            // 3. 滤波
            filtered_altitude_m = AltitudeFilter(altitude_m);

            // 4. 使用滤波后的高度值(例如:发送到飞控、显示、记录)
            // UseFilteredAltitude(filtered_altitude_m);
        } else {
            // 读取数据失败,进行错误处理
        }

        // 控制读取频率,例如20Hz (50ms)
        HAL_Delay(50);
    }
}

5.2 常见问题排查与优化建议

在实际部署中,你可能会遇到以下问题:

  • I2C通信失败:首先用逻辑分析仪或示波器抓取SCL和SDA波形,检查时序是否符合标准(启动、停止、数据建立/保持时间)。检查上拉电阻是否接好,GPIO模式是否为开漏。
  • 高度数据跳变大:
    • 检查电源:确保给传感器的3.3V电源干净、稳定。电机等大功率设备可能引起电源噪声。
    • 调整过采样率:提高气压测量的过采样率(如从_8TIMES提高到_64TIMES)可以大幅降低噪声,但会降低输出速率。
    • 优化滤波参数:调整FILTER_WINDOW_SIZE。窗口越大,越平滑,但延迟也越大。对于高速运动的无人机,需要权衡。
  • 高度值存在固定偏差:
    • 校准P0:在已知海拔高度的地方(如地面),读取稳定的气压值,反推出当地的P0基准值,替换公式中的101325。
    • 温度补偿:确保温度测量和补偿流程正确。SPL06的温度补偿算法已经比较完善,但极端环境仍需验证。

最后,将完整的驱动代码模块化封装好,你会发现,这套基于软件I2C的SPL06驱动,其稳定性和可移植性甚至优于某些硬件I2C的驱动。它不依赖于特定的硬件外设,只要有两个空闲的GPIO,就能在任何一款STM32甚至其他ARM MCU上快速跑起来,这为你在不同项目间复用代码、快速原型开发提供了极大的便利。

更多推荐