2. 传感器数据采集与预处理
传感器把物理世界的信号变成了电信号,这第一步算是完成了。但接下来呢?你得把这些信号「读」进来,还得读得准、读得快。我见过不少项目,传感器选得挺好,结果栽在数据采集上——不是接口时序没调对,就是噪声把信号淹没了。
这一节,咱们就聊聊数据采集的那些事儿。从硬件接口到软件滤波,再到时间同步,每一步都有坑,也有技巧。
2.1 数据采集接口:I2C、SPI、UART
选哪个接口,说白了取决于你的传感器和主控能怎么连。三种接口各有脾气,我列个表给你看:
| 接口 | 通信方式 | 速度 | 引脚数 | 典型场景 |
|---|---|---|---|---|
| I2C | 同步、半双工 | 100kHz~3.4MHz | 2(SDA+SCL) | 温湿度、气压、IMU |
| SPI | 同步、全双工 | 10MHz~80MHz | 4(MOSI+MISO+SCLK+CS) | 高速ADC、陀螺仪、激光雷达 |
| UART | 异步、全双工 | 9600bps~10Mbps | 2(TX+RX) | GPS、串口屏、老式传感器 |
2.1.1 I2C 采集实战
I2C 的麻烦在于地址冲突和时钟延展。我记得有一次调试一个九轴 IMU,死活读不到数据,折腾了半天才发现是上拉电阻焊错了——4.7kΩ 换成了 10kΩ,信号上升沿太慢,主控不认。
下面是一个标准的 I2C 读取代码片段,以 MPU6050 为例:
#include <Wire.h>
#define MPU6050_ADDR 0x68
void setup() {
Wire.begin();
Serial.begin(115200);
Wire.beginTransmission(MPU6050_ADDR);
Wire.write(0x6B); // 电源管理寄存器
Wire.write(0x00); // 唤醒传感器
Wire.endTransmission();
}
void loop() {
Wire.beginTransmission(MPU6050_ADDR);
Wire.write(0x3B); // 加速度计数据起始地址
Wire.endTransmission(false);
Wire.requestFrom(MPU6050_ADDR, 6, true);
int16_t ax = Wire.read() << 8 | Wire.read();
int16_t ay = Wire.read() << 8 | Wire.read();
int16_t az = Wire.read() << 8 | Wire.read();
Serial.print("Accel: "); Serial.print(ax); Serial.print(", ");
Serial.print(ay); Serial.print(", "); Serial.println(az);
delay(100);
}
2.1.2 SPI 采集实战
SPI 比 I2C 快,但引脚多。它的核心是片选(CS)——每个从设备独占一根 CS 线。我习惯在读取前后把 CS 拉低再拉高,确保时序干净。
void readSPI(uint8_t csPin, uint8_t reg, uint8_t *data, uint8_t len) {
digitalWrite(csPin, LOW);
SPI.transfer(reg); // 发送寄存器地址
for (uint8_t i = 0; i < len; i++) {
data[i] = SPI.transfer(0x00); // 读取数据
}
digitalWrite(csPin, HIGH);
}
嗯,这里要注意:SPI 的时钟极性和相位(CPOL、CPHA)必须和传感器手册一致。我曾经因为 CPHA 设反了,读出来的数据全是 0xFF,还以为是传感器坏了。
2.1.3 UART 采集实战
UART 最简单,但波特率要对。GPS 模块常用 9600 或 115200,别搞混了。我一般用中断方式接收,避免阻塞主循环。
void setup() {
Serial2.begin(115200); // 使用 UART2
}
void loop() {
if (Serial2.available()) {
String line = Serial2.readStringUntil('\n');
if (line.startsWith("$GPGGA")) {
// 解析 GPS 数据
parseGPGGA(line);
}
}
}
2.2 滤波算法:让数据「干净」起来
传感器原始数据通常很「脏」。噪声来自电源、机械振动、电磁干扰……你想想看,一个加速度计装在机械臂上,读数抖得像筛子,不滤波根本没法用。
三种常用滤波算法,我按场景给你拆开讲。
2.2.1 均值滤波
最简单,也最直观。连续采 N 个值,取平均。适合噪声是白噪声的场景,比如温度、气压。
#define FILTER_SIZE 10
float buffer[FILTER_SIZE];
int index = 0;
float meanFilter(float newValue) {
buffer[index] = newValue;
index = (index + 1) % FILTER_SIZE;
float sum = 0;
for (int i = 0; i < FILTER_SIZE; i++) {
sum += buffer[i];
}
return sum / FILTER_SIZE;
}
2.2.2 中值滤波
把 N 个值排序,取中间那个。对付椒盐噪声(比如激光雷达的飞点)特别好用。
#define MEDIAN_SIZE 5
float medianFilter(float newValue) {
static float window[MEDIAN_SIZE];
static int idx = 0;
window[idx] = newValue;
idx = (idx + 1) % MEDIAN_SIZE;
float sorted[MEDIAN_SIZE];
memcpy(sorted, window, sizeof(sorted));
// 冒泡排序(N 小,够用)
for (int i = 0; i < MEDIAN_SIZE - 1; i++) {
for (int j = 0; j < MEDIAN_SIZE - i - 1; j++) {
if (sorted[j] > sorted[j+1]) {
float tmp = sorted[j];
sorted[j] = sorted[j+1];
sorted[j+1] = tmp;
}
}
}
return sorted[MEDIAN_SIZE / 2];
}
2.2.3 卡尔曼滤波
这个稍微复杂点,但效果最好。它结合了预测和观测,能给出最优估计。说白了,就是「猜一个值,再拿测量值修正它」。
一维卡尔曼滤波的五个核心公式:
- 预测状态: x̂k|k-1 = x̂k-1|k-1
- 预测协方差: Pk|k-1 = Pk-1|k-1 + Q
- 卡尔曼增益: Kk = Pk|k-1 / (Pk|k-1 + R)
- 更新状态: x̂k|k = x̂k|k-1 + Kk(zk - x̂k|k-1)
- 更新协方差: Pk|k = (1 - Kk)Pk|k-1
代码实现:
typedef struct {
float Q; // 过程噪声协方差
float R; // 测量噪声协方差
float P; // 估计误差协方差
float K; // 卡尔曼增益
float X; // 状态估计值
} KalmanFilter;
void kalmanInit(KalmanFilter *kf, float initValue) {
kf->Q = 0.01;
kf->R = 0.1;
kf->P = 1.0;
kf->X = initValue;
}
float kalmanUpdate(KalmanFilter *kf, float measurement) {
// 预测
kf->P = kf->P + kf->Q;
// 更新
kf->K = kf->P / (kf->P + kf->R);
kf->X = kf->X + kf->K * (measurement - kf->X);
kf->P = (1 - kf->K) * kf->P;
return kf->X;
}
2.3 数据对齐与时间戳同步
多传感器系统里,每个传感器采集的时刻可能不一样。你想想看,摄像头 30fps,IMU 200Hz,激光雷达 10Hz——如果不做时间同步,融合出来的轨迹就是「鸡同鸭讲」。
我遇到过最典型的问题:视觉 SLAM 里,图像和 IMU 数据差了 20ms,结果轨迹估计直接发散。后来加了硬件时间戳才解决。
2.3.1 软件时间戳
最简单的方法:在读取传感器数据的瞬间,记录当前系统时间(微秒级)。
uint32_t timestamp_us = micros();
float value = readSensor();
saveToBuffer(timestamp_us, value);
但这里有个坑:如果传感器内部有 FIFO 缓存,你读到的是「之前」的数据。比如 MPU6050 的 FIFO 可以存 32 组数据,你读的时候,数据其实是几百微秒前采的。这时候软件时间戳就不准了。
2.3.2 硬件时间戳
更可靠的做法:让传感器在数据就绪时触发一个中断,主控在中断服务函数里打时间戳。
void sensorISR() {
timestamp_us = micros(); // 硬件触发,时间最准
sensorReady = true;
}
void loop() {
if (sensorReady) {
sensorReady = false;
float data = readSensor();
saveWithTimestamp(timestamp_us, data);
}
}
2.3.3 多传感器时间对齐
当你有多个传感器时,需要把数据插值到同一个时间基准上。常用的方法是线性插值:
// 已知 t1 时刻的值 v1,t2 时刻的值 v2
// 求 t 时刻的值(t1 < t < t2)
float interpolate(float t1, float v1, float t2, float v2, float t) {
float ratio = (t - t1) / (t2 - t1);
return v1 + ratio * (v2 - v1);
}
举个例子:IMU 在 t=10ms 时输出 1.2m/s²,t=15ms 时输出 1.5m/s²。你想知道 t=12ms 时的值,插值一下就是 1.32m/s²。
2.4 本章知识体系总览
下面这张图把数据采集到预处理的完整链路串起来了。你可以把它当作一个检查清单: