物联网 · 盛世宏博 · 工业传感开发 · 以太网温湿度传感器 · Modbus TCP · 可靠性设计

关键词:断线重连、TCP Keepalive、心跳机制、CRC校验、Modbus TCP MBAP、数据丢包、滑动窗口、环形缓存、指数退避、质量位、乱序重组 标签:#物联网 #Modbus #RS485 #TCP/IP #POE供电 #腾讯云 #Python #以太网温湿度传感器 #网口温湿度变送器 #机房监控
一个常见误解:"以太网比 RS485 可靠,插上网线就能跑"。实际上,工业现场以太网环境远比实验室复杂:
以太网解决的是"物理层差错检测"(CRC32 硬件校验),不解决"应用层数据完整性、连接生命周期管理、网络分区后的数据连续性"。这三件事必须自己设计。
形态 | 表现 | 检测方式 |
|---|---|---|
TCP 静默断开 | send() 不报错,但对方已 RST | TCP Keepalive / 应用心跳 |
交换机端口 down | connect() 立即返回 EHOSTUNREACH | 系统级通知 / 轮询 |
IP 不可达(路由丢失) | connect() 超时 | 超时判定 |
传感器固件假死 | TCP 三次握手成功,但无 Modbus 响应 | 应用层超时 |
半开连接 | 一端已关闭,另一端仍认为连接有效 | Keepalive / 心跳超时 |
# reconnect.py
import socket
import time
from enum import Enum
class ConnState(Enum):
IDLE = 0
CONNECTING = 1
ESTABLISHED = 2
RECONNECTING = 3
FAILED = 4
class SmartReconnect:
def __init__(self, ip, port=502, timeout=2.0):
self.ip = ip
self.port = port
self.timeout = timeout
self.state = ConnState.IDLE
self._sock = None
self._fail_count = 0
self._last_connect_ts = 0
self._backoff = [1, 2, 4, 8, 15, 30, 60] # 指数退避,上限 60s
def _backoff_delay(self):
idx = min(self._fail_count, len(self._backoff) - 1)
return self._backoff[idx]
def connect(self, force=False):
now = time.time()
if not force and self.state == ConnState.RECONNECTING:
elapsed = now - self._last_connect_ts
if elapsed < self._backoff_delay():
return False # 退避期内不重试
self.state = ConnState.CONNECTING
self._last_connect_ts = now
try:
if self._sock:
self._safe_close()
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock.settimeout(self.timeout)
self._sock.connect((self.ip, self.port))
# TCP Keepalive 内核级配置
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
# Linux: 空闲 10s → 探测 5s → 重试 3 次
self._sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 10)
self._sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 5)
self._sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
self.state = ConnState.ESTABLISHED
self._fail_count = 0
return True
except (socket.timeout, ConnectionRefusedError, OSError) as e:
self._fail_count += 1
self.state = ConnState.RECONNECTING
return False
def _safe_close(self):
try:
self._sock.shutdown(socket.SHUT_RDWR)
except:
pass
try:
self._sock.close()
except:
pass
self._sock = None
def is_alive(self):
"""应用层心跳:发一个轻量 MBAP 头探测"""
if not self._sock:
return False
try:
# 构造最小 Modbus TCP 读请求(读 1 个寄存器)
mbap = b'\x00\x01\x00\x00\x00\x06\x01\x03\x00\x00\x00\x01'
self._sock.send(mbap)
data = self._sock.recv(12)
return len(data) >= 9
except:
self.state = ConnState.RECONNECTING
return Falseshutdown() 再 close(),避免 TIME_WAIT 堆积 层级 | 校验对象 | 覆盖范围 | 能否被应用层感知 |
|---|---|---|---|
Ethernet CRC32 | 以太网帧 | 链路层,交换机之间 | 否(坏帧直接丢弃) |
Modbus TCP CRC | 无(Modbus TCP 去掉了 RTU 的 CRC16) | — | — |
关键事实:Modbus TCP 协议本身没有 CRC 校验字段。它依赖 TCP 的校验和(Checksum)和 Ethernet 的 CRC32。但这两个都是"链路层/传输层"校验,存在盲区:
# app_crc.py
import struct
import zlib
class AppChecksum:
"""应用层数据完整性校验"""
@staticmethod
def checksum_registers(registers):
"""对寄存器列表计算 CRC32,用于跨网络传输校验"""
raw = struct.pack(f'>{len(registers)}H', *registers)
return zlib.crc32(raw) & 0xFFFFFFFF
@staticmethod
def verify_response(expected_crc, registers):
actual = AppChecksum.checksum_registers(registers)
return expected_crc == actual
# 传感器侧(伪代码,固件层面):
# 在 Modbus TCP 响应中,利用保留寄存器字段携带 CRC32
# 例如:读 4 个寄存器(温度/湿度/状态/露点),额外读第 5 个寄存器 = CRC32 低 16 位def validate_sample(temp, hum, dew, raw_registers):
"""多维度交叉验证"""
issues = []
# 1. 范围校验
if not (-40 <= temp <= 85):
issues.append(f"TEMP_OUT_OF_RANGE:{temp}")
if not (0 <= hum <= 100):
issues.append(f"HUM_OUT_OF_RANGE:{hum}")
# 2. 物理一致性:露点 ≤ 温度
if dew and dew > temp + 0.5:
issues.append(f"DEW_EXCEEDS_TEMP:dew={dew},temp={temp}")
# 3. 突变检测:与前值比较
if hasattr(validate_sample, '_last_temp'):
dt = abs(temp - validate_sample._last_temp)
if dt > 5.0: # 1 次采样变化 >5℃ → 可疑
issues.append(f"TEMP_SUDDEN_JUMP:{dt}℃")
validate_sample._last_temp = temp
# 4. 寄存器位模式校验
for i, reg in enumerate(raw_registers):
if reg == 0xFFFF:
issues.append(f"REG_{i}_IS_0xFFFF")
return len(issues) == 0, issues值域校验 + 突变检测 + 交叉一致性,比纯 CRC 更能抓到"数值错了但协议没报错"的情况。
来源 | 占比(典型现场) | 对策 |
|---|---|---|
网络拥塞/TCP 重传 | 30% | 限并发、错峰、QoS |
传感器响应超时 | 25% | 调整超时、重试 |
采集进程崩溃/重启 | 20% | 守护进程、环形缓存 |
交换机端口 err-disable | 15% | 端口监控、自动恢复 |
平台写入失败 | 10% | 本地缓存、补传 |
# ring_cache.py
import sqlite3
import time
import json
from collections import deque
class RingCache:
"""内存 + 磁盘双层缓存,防止进程崩溃丢数据"""
def __init__(self, max_memory=500, db_path="sensor_cache.db"):
self.max_memory = max_memory
self.memory = deque(maxlen=max_memory)
self.db_path = db_path
self._init_db()
def _init_db(self):
with sqlite3.connect(self.db_path) as c:
c.execute("""CREATE TABLE IF NOT EXISTS cache(
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id TEXT, collect_ts REAL, recv_ts REAL,
payload TEXT, uploaded INTEGER DEFAULT 0)""")
c.execute("CREATE INDEX IF NOT EXISTS idx_uploaded ON cache(uploaded, collect_ts)")
c.execute("PRAGMA journal_mode=WAL")
def push(self, node_id, temp, hum, quality, raw):
"""采集到数据立即入缓存"""
now = time.time()
item = {
"node_id": node_id,
"collect_ts": now, # 采集时刻(关键!)
"recv_ts": now, # 接收时刻
"temp": temp,
"hum": hum,
"quality": quality,
"raw": raw
}
# 内存层
self.memory.append(item)
# 磁盘层(异步写入)
with sqlite3.connect(self.db_path) as c:
c.execute("INSERT INTO cache(node_id, collect_ts, recv_ts, payload, uploaded) VALUES(?,?,?,?,0)",
(node_id, now, now, json.dumps(item)))
def pop_pending(self, limit=100):
"""取出未上传的数据,按采集时间排序"""
with sqlite3.connect(self.db_path) as c:
rows = c.execute(
"SELECT id, payload FROM cache WHERE uploaded=0 ORDER BY collect_ts LIMIT ?",
(limit,)).fetchall()
return rows
def mark_uploaded(self, ids):
"""标记已成功上传"""
with sqlite3.connect(self.db_path) as c:
for rid in ids:
c.execute("UPDATE cache SET uploaded=1 WHERE id=?", (rid,))
def cleanup(self, retain_hours=168):
"""清理已上传的旧数据(保留 7 天)"""
cutoff = time.time() - retain_hours * 3600
with sqlite3.connect(self.db_path) as c:
c.execute("DELETE FROM cache WHERE uploaded=1 AND collect_ts<?", (cutoff,))# reorder.py
class SequenceTracker:
"""处理网络乱序和重复数据"""
def __init__(self, window_size=100):
self.window = {} # node_id -> {seq_no: sample}
self.acked = {} # node_id -> last_seq
self.window_size = window_size
def on_receive(self, node_id, seq_no, sample):
if node_id not in self.acked:
self.acked[node_id] = seq_no - 1
last = self.acked[node_id]
if seq_no <= last:
return None # 重复包,丢弃
if seq_no > last + self.window_size:
# 跳号太多,可能丢包,触发重传请求
return {"action": "request_retransmit", "from": last + 1, "to": seq_no - 1}
self.window.setdefault(node_id, {})[seq_no] = sample
# 尝试交付连续序列
deliverable = []
while (last + 1) in self.window.get(node_id, {}):
last += 1
deliverable.append(self.window[node_id].pop(last))
self.acked[node_id] = last
return {"action": "deliver", "samples": deliverable}注:传感器端需支持序列号(可在 Modbus 保留寄存器中实现递增计数器)。
优化项 | 做法 | 效果 |
|---|---|---|
连接池复用 | 长连接 + Keepalive,避免每次 new Socket | 减少 TCP 握手丢包 |
超时分级 | 网络超时 2s,应用超时 5s | 区分网络故障和设备假死 |
指数退避 | 失败次数↑ → 重试间隔↑ | 避免网络风暴时雪崩 |
错峰采集 | 不同节点采集时间偏移 | 降低交换机瞬时负载 |
本地缓存 | 内存 + SQLite WAL | 进程崩溃/断网不丢数据 |
采集时间戳 | 采集时刻而非发送时刻 | 补传后曲线不乱序 |
质量位 | GOOD/STALE/TIMEOUT/BAD_VALUE | 平台区分"没数据"和"数据坏了" |
并发限制 | ≤8 线程,不超过传感器连接池 | 避免传感器 TCP 连接耗尽 |
# 交换机端口配置示例(华为/华三思路)
interface GigabitEthernet0/0/1
description Sensor-Port
port link-type access
port default vlan 100
storm-control broadcast min-cir 1000 # 广播风暴抑制
storm-control multicast min-cir 2000 # 组播抑制
loopback-detect enable # 环路检测
poe priority high # PoE 供电优先级
error-down auto-recovery cause crc-error interval 300 # CRC 错自动恢复# 定期检查端口 CRC 错误计数
for port in /sys/class/net/eth*/statistics/; do
crc=$(cat $port/rx_crc_errors 2>/dev/null)
if [ "$crc" -gt 0 ]; then
echo "$(basename $port): CRC errors = $crc"
fi
done端口 CRC 错误持续增加 = 网线/水晶头/端口硬件问题,不是传感器的问题。
传感器 MCU → [值域校验] → 网络传输 → [TCP校验+应用CRC] → 采集进程
→ [突变检测] → 环形缓存 → [去重+排序] → 时序数据库
→ [质量位标记] → Grafana 展示每一层都有独立的校验/标记,任何一层失败都不会"静默通过"。
告警项 | 触发条件 | 级别 |
|---|---|---|
单节点连续超时 | 3 次 poll 无响应 | 预警 |
总线 CRC 错误率 | >0.5% | 告警 |
缓存积压 | 未上传 >200 条 | 告警 |
时间断层 | 采集时间戳跳跃 >60s | 紧急 |
传感器重启 | TCP 连接数突增 + 序列号归零 | 预警 |
质量位持续非 GOOD | >5min | 告警 |
以太网温湿度传感器的可靠性设计,核心不是"让网络不出问题",而是"网络一定会出问题,系统必须能优雅降级、自动恢复、数据不丢、事后可查":
做到这三点,传感器才从"能 ping 通"升级为机房里三年不用人管的工业级组件。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。