一条数据链路的四段
“读取传感器”听起来只是一行代码,但要让云端看到一条可信的曲线,中间至少要经过四段处理:采样、标定、滤波、上报。任何一段偷懒,最终呈现的数据都会失真——采样太慢会漏掉突变,不标定就没有物理意义,不滤波会让曲线毛刺丛生,不做上报策略则要么流量爆炸,要么关键变化被淹没。
传感器的原始值只是电压或寄存器里的一个整数,它变成“23.5 ℃”这件事,是标定、滤波和单位约定共同完成的。把这四段分开写、分开测,是嵌入式数据采集最基本的工程习惯。
下面按这四段依次展开,代码以 Python 形式给出便于阅读,逻辑可以直接映射到 C 语言实现。
采样入口:I2C、SPI 与 ADC
数字传感器(温湿度、气压、姿态)通常通过 I2C 或 SPI 读取,模拟传感器则要经过 ADC 采样。三种入口的特点决定了后续处理方式。
| 入口 | 典型器件 | 特点与注意事项 |
|---|---|---|
| I2C | SHT30、MPU6050、BMP280 | 两线制、可挂多设备;速率较低,长线易受干扰,需要上拉电阻 |
| SPI | 高精度 ADC、Flash、显示屏 | 速率高、全双工;片选线多,走线要求高 |
| ADC | 热敏电阻、电位器、电流环 | 需要参考电压与分辨率换算,噪声大,必须滤波 |
# 以 I2C 读取 SHT30 为例(树莓派 / Linux SBC,Python)
import smbus2, time
BUS, ADDR = 1, 0x44 # SHT30 默认地址
bus = smbus2.SMBus(BUS)
def read_raw():
bus.write_i2c_block_data(ADDR, 0x2C, [0x06]) # 高重复性、无时钟拉伸
time.sleep(0.02)
d = bus.read_i2c_block_data(ADDR, 0x00, 6)
t_raw = (d[0] << 8) | d[1]
h_raw = (d[3] << 8) | d[4]
# SHT30 数据手册给出的换算公式,原始值本身就是定标的
return -45 + 175 * t_raw / 65535, 100 * h_raw / 65535
print(read_raw())
用 ADC 时则要先明确两个参数:分辨率(多少位)和参考电压。12 位 ADC、参考 3.3V 时,每个计数约 0.8mV,换算关系是 value = raw / 4095 × 3.3。采样前的输入调理电路(分压、运放、RC 滤波)往往比软件更重要。
标定:从计数值到物理量
数字传感器通常在手册里已经给出线性换算公式,而模拟传感器几乎都要求现场标定。最通用的是两点标定:取两个已知参考点,解出直线的斜率与截距。
# 两点标定:已知参考点 (raw1, phys1)、(raw2, phys2)
def fit_two_point(raw1, phys1, raw2, phys2):
k = (phys2 - phys1) / (raw2 - raw1)
b = phys1 - k * raw1
return k, b
def apply(k, b, raw):
return k * raw + b
# 例:热敏电阻电桥在 0℃ 时输出 512,在 100℃ 时输出 870
k, b = fit_two_point(512, 0.0, 870, 100.0)
print("斜率 %.4f,截距 %.4f" % (k, b))
print("当前温度 %.1f ℃" % apply(k, b, 640))
# 现场标定结果应固化到配置里而不是代码中,便于不同批次单独校准
import json
json.dump({"k": k, "b": b, "unit": "C", "ver": "2026-10-13"},
open("/etc/sensor_calib.json", "w"))
- 标定参数外置固化在配置文件或 Flash 参数区,不同批次设备可单独校准,不必重新编译固件。
- 留出标定元数据记录标定时间、参考源、操作人,否则半年后没人能说清这条曲线准不准。
- 非线性要分段热敏电阻、pH 电极这类非线性器件用单段直线误差会很大,应按区间分段拟合或查表插值。
滤波:四种方法的选择
原始数据里混着三类噪声:随机抖动、尖峰脉冲、系统性漂移。不同噪声要用不同工具处理。
# 1) 滑动平均:抑制随机抖动,代价是响应变慢
from collections import deque
win = deque(maxlen=8)
def avg(v):
win.append(v)
return sum(win) / len(win)
# 2) 中值滤波:专治尖峰脉冲(如电机干扰导致的一帧跳变)
def median3(a, b, c):
return sorted([a, b, c])[1]
# 3) 一阶低通(指数加权):计算量极小,适合 MCU 逐点执行
# alpha = dt / (RC + dt),alpha 越小越平滑、越迟钝
class LowPass:
def __init__(self, alpha):
self.a, self.y = alpha, None
def __call__(self, x):
self.y = x if self.y is None else self.a * x + (1 - self.a) * self.y
return self.y
lp = LowPass(0.2)
print([round(lp(v), 2) for v in [23.0, 26.0, 23.2, 23.4, 23.1]])
选择原则很朴素:只要滤掉随机抖动,用滑动平均或低通;如果现场有明显的脉冲干扰(继电器、电机启停),先做中值再低通,顺序反了效果会差很多;只有在需要同时抑制噪声并估计变化趋势(如姿态融合)时,才值得上卡尔曼滤波。低通滤波器的 alpha 参数一定要用实际数据试出来,凭理论公式估算常常过于激进。
上报策略:既要省流量,又要不丢关键变化
把滤波后的每个点都发到云端,是最不经济的做法。常见的三种上报策略通常会组合使用。
import json, time
class Reporter:
"""周期上报 + 死区变化上报 + 极值聚合"""
def __init__(self, period=60, deadband=0.5, max_len=20):
self.period, self.deadband = period, deadband
self.last_report_ts, self.last_value = 0, None
self.buf = [] # (ts, v)
self.max_len = max_len
def push(self, ts, v):
self.buf.append((ts, v))
if len(self.buf) > self.max_len:
self.buf.pop(0)
changed = self.last_value is None or abs(v - self.last_value) >= self.deadband
due = ts - self.last_report_ts >= self.period
if changed or due:
self.flush(ts, v)
def flush(self, ts, v):
payload = {
"ts": ts,
"cur": round(v, 2),
"min": round(min(x for _, x in self.buf), 2),
"max": round(max(x for _, x in self.buf), 2),
"avg": round(sum(x for _, x in self.buf) / len(self.buf), 2),
"n": len(self.buf)
}
# 这里换成实际的 MQTT / HTTP 上报
print("上报:", json.dumps(payload, ensure_ascii=False))
self.last_report_ts, self.last_value = ts, v
self.buf.clear()
三种策略各管一段:周期上报保证“无论有没有变化,云端都知道设备还活着”;死区上报保证“有剧烈变化时立刻上报”;极值聚合保证“中间过程不会被完全丢掉”。另外要留一个本地环形缓冲区,断网时先存后补,网络恢复后按顺序重发——这是弱网现场最重要的可靠性设计。
数据质量与异常识别
数据一旦上了云,就要开始怀疑它。下面这些检查成本很低,却能提前发现设备故障。
- 量程越界:温度读数跑到 -100 ℃ 或 200 ℃,多半是硬件坏或换算公式错,直接标记为无效值而不是照发。
- 持续不变:数值长时间完全不动(方差为 0),通常是传感器死锁或卡在某个固定值。
- 突变跳变:相邻两点差值超过物理可能范围,应判定为脉冲干扰并丢弃,而不是让滤波去追。
- 时间戳异常:设备本地时间不可靠,统一以服务端接收时间为主、设备单调计数为辅。
# 简单的异常判定:量程 + 变化率 + 卡死
class Validator:
def __init__(self, lo, hi, max_delta, stuck_n=30):
self.lo, self.hi, self.max_delta, self.stuck_n = lo, hi, max_delta, stuck_n
self.prev, self.same = None, 0
def check(self, v):
if not (self.lo <= v <= self.hi): return "out_of_range"
if self.prev is not None and abs(v - self.prev) > self.max_delta:
self.prev = v
return "jump"
self.same = self.same + 1 if v == self.prev else 0
self.prev = v
return "stuck" if self.same >= self.stuck_n else "ok"
把异常判定放在设备侧和平台侧各做一次是值得的:设备侧拦截明显错误值,减少无效上报;平台侧对所有设备统一评估,用于发现批量性问题(比如同一批次传感器集体漂移)。
实战:温湿度 + 加速度的完整采集
最后把链路串起来:两条 I2C 通道分别读温湿度与加速度,各自滤波,合成一份上报报文。
import json, time, smbus2
bus = smbus2.SMBus(1)
lp_t, lp_h = LowPass(0.15), LowPass(0.15)
val = Validator(-40, 125, 5)
rep = Reporter(period=30, deadband=0.3)
def read_mpu6050_accel(addr=0x68):
d = bus.read_i2c_block_data(addr, 0x3B, 6)
def s16(hi, lo):
v = (hi << 8) | lo
return v - 65536 if v > 32767 else v
# ±2g 量程:16384 LSB/g
return [s16(d[i], d[i + 1]) / 16384.0 for i in (0, 2, 4)]
while True:
t, h = read_raw() # 上一节的 SHT30 读取
t_f, h_f = lp_t(t), lp_h(h)
ax, ay, az = read_mpu6050_accel()
state = val.check(t_f)
if state in ("ok", "jump"):
rep.push(int(time.time()), t_f)
else:
print("温度数据异常:", state)
print(json.dumps({"t": round(t_f, 2), "h": round(h_f, 1),
"acc": [round(ax, 3), round(ay, 3), round(az, 3)],
"valid": state == "ok"}))
time.sleep(1)
这段代码里每一行都有前面的知识在支撑:I2C 读取负责采样,量程换算负责标定,LowPass 负责滤波,Validator 负责质量,Reporter 负责上报节奏。把它部署到一台开发板上跑一天,你拿到的就不再是“能读出数”,而是一条经得起追问的数据链路。