3、加速度计原理与数据读取:MEMS加速度计工作原理、I2C/SPI接口读取、原始数据校准与滤波

好,咱们今天聊聊加速度计。这东西在嵌入式PDA里太常见了,但说实话,真正用好它的人不多。我见过不少项目,加速度计数据读出来就直接用,结果系统动不动就抽风。嗯,这章咱们就把它的底裤扒干净。

3.1 MEMS加速度计工作原理

MEMS加速度计,说白了就是一个微型的弹簧-质量块系统。你想想看,芯片里面有个微小的质量块,用硅悬臂梁挂着。当芯片加速时,质量块会惯性运动,导致电容变化。测量这个电容变化,就能算出加速度。

为什么会这样?核心原理就是牛顿第二定律:F=ma。质量块受到加速度作用产生位移,位移改变电容极板间距,电容值就变了。然后通过ASIC芯片把电容转成电压,再经过ADC变成数字量。

我在项目中遇到过一个问题:某款加速度计在静止时输出值总在跳。后来发现是芯片封装应力导致的。嗯,这里要注意,MEMS器件的封装应力对零偏影响很大,选型时得留个心眼。

关键参数速查表:

参数 典型值 说明
量程 ±2g / ±4g / ±8g / ±16g 根据应用场景选择,PDA常用±2g
灵敏度 16384 LSB/g (±2g时) 数字输出分辨率
零偏 ±40 mg 静止时输出偏差,需校准
噪声密度 150 μg/√Hz 影响滤波设计

3.2 I2C/SPI接口读取

读取加速度计数据,主流接口就两种:I2C和SPI。我个人习惯在PDA上用I2C,因为引脚少。但如果你对速率有要求,比如要做高频振动检测,那还是SPI靠谱。

以经典的MPU6050为例,咱们看看I2C怎么读。这芯片我用了不下十次,每次都得翻数据手册确认寄存器地址,后来干脆写了个通用驱动库。

// MPU6050 I2C读取示例
#define MPU6050_ADDR  0x68
#define ACCEL_XOUT_H  0x3B

void read_accel_raw(int16_t *ax, int16_t *ay, int16_t *az) {
    uint8_t buf[6];
    
    // 设置寄存器地址
    i2c_start(MPU6050_ADDR << 1 | 0);  // 写模式
    i2c_write(ACCEL_XOUT_H);
    i2c_stop();
    
    // 连续读取6字节
    i2c_start(MPU6050_ADDR << 1 | 1);  // 读模式
    for(int i = 0; i < 6; i++) {
        buf[i] = i2c_read(i < 5 ? 1 : 0);  // 最后字节发NACK
    }
    i2c_stop();
    
    // 合并高低字节
    *ax = (buf[0] << 8) | buf[1];
    *ay = (buf[2] << 8) | buf[3];
    *az = (buf[4] << 8) | buf[5];
}

个人经验:I2C读取时,连续读比单次读快得多。因为每次重新发起始信号和寄存器地址,开销很大。我一般用连续读模式,一次读完所有轴的数据。

SPI接口呢?速率更高,但要多用3根线。我建议在PDA上这样选:如果加速度计和陀螺仪共用,用I2C省引脚;如果单独用加速度计做高频采集,用SPI。

// SPI读取示例(ADXL345)
#define ADXL345_CS    PB0
#define ADXL345_ADDR  0x32  // 0x32 = 0x1D << 1

uint8_t read_reg_spi(uint8_t reg) {
    uint8_t data;
    
    CS_LOW();
    spi_transfer(reg | 0x80);  // 读操作,最高位置1
    data = spi_transfer(0x00); // 发送哑字节,接收数据
    CS_HIGH();
    
    return data;
}

void read_accel_spi(int16_t *x, int16_t *y, int16_t *z) {
    uint8_t buf[6];
    
    CS_LOW();
    spi_transfer(0x32 | 0x80 | 0x40);  // 多字节读,bit6置1
    for(int i = 0; i < 6; i++) {
        buf[i] = spi_transfer(0x00);
    }
    CS_HIGH();
    
    *x = (buf[1] << 8) | buf[0];  // ADXL345是小端模式
    *y = (buf[3] << 8) | buf[2];
    *z = (buf[5] << 8) | buf[4];
}

注意:不同厂家的加速度计,字节序可能不同。MPU6050是大端,ADXL345是小端。我曾经因为没注意这个,读出来的数据符号反了,排查了半天。

3.3 原始数据校准

原始数据直接能用吗?别闹。每个加速度计都有零偏和刻度误差。你把它平放,理论上Z轴应该是1g,X和Y是0g。但实际读出来呢?Z轴可能是1.02g,X轴可能是0.05g。这就是零偏。

校准方法其实不复杂。我常用的方法是六位置法:

  1. 把加速度计平放(Z轴朝上),采集N组数据取平均
  2. 翻转180度(Z轴朝下),再采集N组数据取平均
  3. 同理,X轴朝上/朝下,Y轴朝上/朝下
  4. 用这6组数据解算出零偏和刻度因子

嗯,这里要注意,采集数据时一定要让传感器完全静止。我一般采100组数据,去掉最大最小值,再取平均。这样能滤掉随机噪声。

// 简易校准代码
typedef struct {
    float bias[3];    // 零偏
    float scale[3];   // 刻度因子
} accel_calib_t;

accel_calib_t calibrate_accel(void) {
    accel_calib_t calib;
    float raw_pos[3], raw_neg[3];
    
    // 采集6个位置的数据(伪代码)
    // pos_z_up:  Z轴朝上
    // pos_z_down: Z轴朝下
    // ...
    
    // 计算零偏
    calib.bias[0] = (raw_pos[0] + raw_neg[0]) / 2.0f;
    calib.bias[1] = (raw_pos[1] + raw_neg[1]) / 2.0f;
    calib.bias[2] = (raw_pos[2] + raw_neg[2]) / 2.0f;
    
    // 计算刻度因子
    calib.scale[0] = 2.0f / (raw_pos[0] - raw_neg[0]);
    calib.scale[1] = 2.0f / (raw_pos[1] - raw_neg[1]);
    calib.scale[2] = 2.0f / (raw_pos[2] - raw_neg[2]);
    
    return calib;
}

// 应用校准
void apply_calib(int16_t raw[3], float calibed[3], accel_calib_t *cal) {
    for(int i = 0; i < 3; i++) {
        calibed[i] = (raw[i] - cal->bias[i]) * cal->scale[i];
    }
}

避坑指南:我曾经在量产时发现,同一批次的加速度计零偏差异很大。后来加了出厂校准流程,每个模块单独标定。虽然多了道工序,但产品一致性好了很多。

3.4 滤波处理

校准完的数据就能用了吗?还不行。你看看原始数据的波形,高频噪声一大堆。特别是PDA这种手持设备,手抖一下,数据就跳得飞起。

滤波方法我常用两种:

  • 滑动平均滤波:简单粗暴,适合低频应用。取最近N个数据平均,N越大越平滑,但延迟也越大。
  • 一阶低通滤波:计算量小,适合实时系统。公式:y[n] = α * x[n] + (1-α) * y[n-1]

我个人习惯用一阶低通。为什么?因为它只需要一个状态变量,内存占用小。在PDA这种资源受限的系统里,能省则省。

// 一阶低通滤波实现
typedef struct {
    float alpha;      // 滤波系数 0~1
    float filtered;   // 滤波后的值
} lowpass_filter_t;

void filter_init(lowpass_filter_t *f, float alpha) {
    f->alpha = alpha;
    f->filtered = 0.0f;
}

float filter_update(lowpass_filter_t *f, float raw) {
    f->filtered = f->alpha * raw + (1.0f - f->alpha) * f->filtered;
    return f->filtered;
}

// 使用示例
lowpass_filter_t filter_x, filter_y, filter_z;
filter_init(&filter_x, 0.2f);  // α=0.2,截止频率约 0.03 * 采样率
filter_init(&filter_y, 0.2f);
filter_init(&filter_z, 0.2f);

// 每次读取后滤波
float fx = filter_update(&filter_x, calibed[0]);
float fy = filter_update(&filter_y, calibed[1]);
float fz = filter_update(&filter_z, calibed[2]);

α值怎么选?我一般这样定:采样率100Hz时,α取0.1~0.3。α越小,滤波越强,但延迟越大。做姿态解算时,α取0.2左右比较平衡。如果只是检测倾斜,α可以取0.05,更平滑。

还有一种情况要注意:如果你要做步数检测或者手势识别,滤波太强会把有用信号也滤掉。这时候我建议用互补滤波,或者干脆不滤波,用原始数据做特征提取。

嗯,说到这,我想起一个项目。当时做PDA的计步器功能,用了滑动平均滤波,结果步数总是少计。后来发现是滤波把步态的峰值削平了。改成中值滤波后,效果好了很多。所以滤波方法没有银弹,得根据应用场景来选。

最后总结一下这章的核心:

  • MEMS加速度计本质是电容式微机械结构,输出受封装应力影响
  • I2C适合引脚受限的场景,SPI适合高速采集
  • 原始数据必须校准,六位置法是最实用的方法
  • 滤波要权衡平滑度和延迟,一阶低通是性价比最高的选择

下一章咱们聊陀螺仪,那个更刺激。陀螺仪的零偏漂移问题,我当年可是被它折磨得不轻。到时候给你讲讲怎么用艾伦方差分析噪声特性。