
实验室环境与机房、配电室、车间有一个本质区别:监测点位的分布是"工位级"的,而非"区域级"。
一台步入式试验室可能只需要2–3个监测点,但一个大型环境实验室往往同时运行几十台小型温箱、恒温槽、培养箱、老化架,每个设备旁边都需要独立监测。这些点位的特征是:
维度 | 机房/配电室 | 实验室多工位 |
|---|---|---|
点位数量 | 几十到几百,集中部署 | 几十到上百,分散在不同实验室 |
点位密度 | 每机柜1–2个 | 每台设备1个,设备间距大 |
网络条件 | 专用监控网络,布线规范 | 共用实验室办公网,端口有限 |
变更频率 | 低(季度级) | 高(实验方案调整,设备频繁移动) |
运维要求 | 7×24无人值守 | 有人值守,可接受轻量维护 |
数据需求 | 长期存档、告警联动 | 实验周期内连续记录、实验结束后导出 |
这些特征决定了技术方案的取舍方向:
┌─────────────────────────────────────────────────────────────────────┐
│ 实验室A(恒温实验室) │
│ │
│ [工位1] 温箱#1 ──┐ │
│ [工位2] 温箱#2 ──┤ │
│ [工位3] 恒温槽#1 ─┼── 实验室交换机 ──┐ │
│ [工位4] 培养箱#3 ─┘ │ │
│ │ │
├──────────────────────────────────────┼────────────────────────────┤
│ 实验室B(老化实验室) │ │
│ │ │
│ [工位5-20] 老化架×4 ──── 交换机 ───┼── 核心交换机 │
│ │ │
├──────────────────────────────────────┼────────────────────────────┤
│ 实验室C(EMC实验室) │ │
│ │ │
│ [工位21-30] 屏蔽室内的设备 ── 光纤 ─┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
│
┌─────────▼─────────┐
│ 轻量接收服务器 │
│ (Linux, 无显示器) │
│ │
│ udp_recv (主程序) │
│ ├── UDP监听 │
│ ├── 报文解析 │
│ ├── 实验会话管理 │
│ ├── SQLite存储 │
│ └── HTTP API │
│ │
│ 资源占用: │
│ CPU < 2% │
│ RAM < 30MB │
│ Disk ~按实验规模 │
└─────────┬─────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ 实验员PC │ │ 实验员PC │ │ 数据导出 │
│ 浏览器 │ │ 浏览器 │ │ CSV/Excel │
│ 实时查看 │ │ 实时查看 │ │ 实验报告 │
└───────────┘ └───────────┘ └───────────┘核心设计决策:
决策 | 选择 | 理由 |
|---|---|---|
传输协议 | UDP广播/单播 | 无连接,设备移动无需重新配置 |
存储引擎 | SQLite | 零配置、单文件、实验室环境足够 |
服务框架 | 单二进制+systemd | 部署简单,不需要容器 |
数据视图 | 实验会话(Session) | 按实验组织数据,而非按设备持续记录 |
接口 | 内嵌HTTP API | 浏览器直接访问,无需安装客户端 |
配置 | 命令行参数+JSON文件 | 实验室工程师可编辑 |
实验室场景对带宽不敏感,但对解析效率和设备端实现简单性有要求。采用固定28字节二进制格式:
┌──────────┬──────────┬────────────┬──────────────────────────────────┐
│ 偏移 │ 长度 │ 字段 │ 说明 │
├──────────┼──────────┼────────────┼──────────────────────────────────┤
│ 0 │ 2 │ 魔数 │ 0x55AA(帧同步) │
│ 2 │ 1 │ 版本 │ 0x01 │
│ 3 │ 1 │ 设备类型 │ 0x20=实验室记录仪 │
│ 4 │ 4 │ 设备ID │ 唯一标识(MAC后4字节或自编) │
│ 8 │ 4 │ 序列号 │ 设备内递增,用于去重 │
│ 12 │ 4 │ 温度 │ IEEE754 float, ℃ │
│ 16 │ 4 │ 湿度 │ IEEE754 float, %RH │
│ 20 │ 2 │ 状态字 │ Bit0=正常, Bit1=低温告警... │
│ 22 │ 4 │ 时间戳 │ Unix时间戳(设备RTC) │
│ 26 │ 2 │ CRC16 │ 校验(含魔数到时间戳全部字节) │
│ 总计 │ 28字节 │ │ │
└──────────┴──────────┴────────────┴──────────────────────────────────┘// 设备固件中的UDP发送代码(伪代码)
void send_env_udp(void) {
static uint32_t seq = 0;
env_packet_t pkt;
pkt.magic = 0x55AA;
pkt.version = 0x01;
pkt.dev_type = 0x20;
pkt.dev_id = get_dev_id(); // 从Flash或MAC获取
pkt.seq = seq++;
pkt.temp = read_temperature();
pkt.humi = read_humidity();
pkt.status = get_status();
pkt.timestamp = get_rtc_unix();
pkt.crc = crc16((uint8_t*)&pkt, 26);
// 发送到组播地址(实验室网络推荐组播而非广播)
udp_send(&pkt, sizeof(pkt),
MCAST_IP, // 239.255.2.100
MCAST_PORT // 9100
);
}为什么用组播而非广播:实验室网络通常与其他办公网络共用,广播会产生不必要的流量。组播让只有加入组播组的接收者才能收到数据。
┌────────────────────────────────────────────────────────────┐
│ udp_recv (单进程,事件驱动) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ UDP接收线程 │───▶│ 解析/去重 │───▶│ 会话路由 │ │
│ │ (非阻塞IO) │ │ (CRC+序列号) │ │ (dev→session)│ │
│ └─────────────┘ └──────────────┘ └──────┬───────┘ │
│ │ │
│ ┌─────────────┐ ┌──────────────┐ │ │
│ │ HTTP API │◀───│ 查询/导出 │◀──────────┘ │
│ │ (内置) │ │ (SQLite) │ │
│ └─────────────┘ └──────────────┘ │
│ │
│ ┌─────────────┐ │
│ │ 会话管理 │ 实验开始→创建session→绑定设备 │
│ │ (内存表) │ 实验结束→关闭session→导出数据 │
│ └─────────────┘ │
└────────────────────────────────────────────────────────────┘// session.h
#ifndef SESSION_H
#define SESSION_H
#include <stdint.h>
#include <time.h>
#define MAX_DEVICES_PER_SESSION 32
#define MAX_SESSIONS 16
#define DEV_ID_LEN 8
typedef struct {
uint32_t dev_id;
char dev_label[32]; // 用户自定义标签,如"温箱#3-上层"
uint32_t last_seq; // 去重:上次收到的序列号
time_t last_recv_time; // 最后收到数据的时间
int active; // 是否活跃
} device_slot_t;
typedef struct {
uint32_t session_id;
char session_name[64]; // 实验名称,如"高温老化-20250115"
char operator_name[32]; // 操作人
time_t start_time;
time_t end_time;
int active; // 1=进行中, 0=已结束
int device_count;
device_slot_t devices[MAX_DEVICES_PER_SESSION];
char db_path[256]; // 该session对应的SQLite文件路径
} session_t;
#endif// udp_recv.c
#include <arpa/inet.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <pthread.h>
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#include "session.h"
#define LISTEN_PORT 9100
#define BUF_SIZE 1024
#define MAX_PACKETS 1000000
// 全局状态
static session_t sessions[MAX_SESSIONS];
static int session_count = 0;
static pthread_mutex_t sessions_mutex = PTHREAD_MUTEX_INITIALIZER;
static sqlite3 *current_db = NULL; // 当前活跃session的数据库
// UDP报文结构
#pragma pack(push, 1)
typedef struct {
uint16_t magic;
uint8_t version;
uint8_t dev_type;
uint32_t dev_id;
uint32_t seq;
float temp;
float humi;
uint16_t status;
uint32_t timestamp;
uint16_t crc;
} env_packet_t;
#pragma pack(pop)
// CRC16-CCITT
static uint16_t crc16_ccitt(const uint8_t *data, size_t len) {
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; i++) {
crc ^= (uint16_t)data[i] << 8;
for (int j = 0; j < 8; j++) {
if (crc & 0x8000)
crc = (crc << 1) ^ 0x1021;
else
crc <<= 1;
}
}
return crc & 0xFFFF;
}
// 查找设备所在的session
static session_t *find_session_for_device(uint32_t dev_id) {
for (int i = 0; i < session_count; i++) {
if (!sessions[i].active) continue;
for (int j = 0; j < sessions[i].device_count; j++) {
if (sessions[i].devices[j].dev_id == dev_id)
return &sessions[i];
}
}
return NULL;
}
// 初始化SQLite数据库(新session)
static int init_session_db(session_t *sess) {
char db_name[256];
snprintf(db_name, sizeof(db_name),
"/var/lib/udp_recv/%s_%lu.db",
sess->session_name, (unsigned long)sess->start_time);
strncpy(sess->db_path, db_name, sizeof(sess->db_path) - 1);
if (current_db) {
sqlite3_close(current_db);
}
if (sqlite3_open(db_name, ¤t_db) != SQLITE_OK) {
fprintf(stderr, "Cannot open database: %s\n", sqlite3_errmsg(current_db));
return -1;
}
const char *sql =
"CREATE TABLE IF NOT EXISTS env_data ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" dev_id INTEGER,"
" dev_label TEXT,"
" timestamp INTEGER,"
" temp REAL,"
" humi REAL,"
" status INTEGER,"
" seq INTEGER,"
" received_at INTEGER"
");"
"CREATE INDEX IF NOT EXISTS idx_timestamp ON env_data(timestamp);"
"CREATE INDEX IF NOT EXISTS idx_dev_id ON env_data(dev_id);";
char *errmsg = NULL;
if (sqlite3_exec(current_db, sql, NULL, NULL, &errmsg) != SQLITE_OK) {
fprintf(stderr, "SQL error: %s\n", errmsg);
sqlite3_free(errmsg);
return -1;
}
printf("Session DB created: %s\n", db_name);
return 0;
}
// 写入数据到SQLite
static int write_to_db(session_t *sess, device_slot_t *dev,
env_packet_t *pkt, time_t received_at) {
if (!current_db) return -1;
sqlite3_stmt *stmt;
const char *sql =
"INSERT INTO env_data (dev_id, dev_label, timestamp, temp, humi, status, seq, received_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
if (sqlite3_prepare_v2(current_db, sql, -1, &stmt, NULL) != SQLITE_OK) {
return -1;
}
sqlite3_bind_int(stmt, 1, pkt->dev_id);
sqlite3_bind_text(stmt, 2, dev->dev_label, -1, SQLITE_STATIC);
sqlite3_bind_int(stmt, 3, pkt->timestamp);
sqlite3_bind_double(stmt, 4, pkt->temp);
sqlite3_bind_double(stmt, 5, pkt->humi);
sqlite3_bind_int(stmt, 6, pkt->status);
sqlite3_bind_int(stmt, 7, pkt->seq);
sqlite3_bind_int(stmt, 8, (int)received_at);
int rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
return (rc == SQLITE_DONE) ? 0 : -1;
}
// 创建新session(通过命令行或HTTP API调用)
int create_session(const char *name, const char *operator,
uint32_t *dev_ids, int dev_count, char **labels) {
pthread_mutex_lock(&sessions_mutex);
if (session_count >= MAX_SESSIONS) {
pthread_mutex_unlock(&sessions_mutex);
return -1;
}
session_t *sess = &sessions[session_count];
memset(sess, 0, sizeof(session_t));
sess->session_id = session_count + 1;
strncpy(sess->session_name, name, sizeof(sess->session_name) - 1);
strncpy(sess->operator_name, operator, sizeof(sess->operator_name) - 1);
sess->start_time = time(NULL);
sess->active = 1;
sess->device_count = dev_count;
for (int i = 0; i < dev_count && i < MAX_DEVICES_PER_SESSION; i++) {
sess->devices[i].dev_id = dev_ids[i];
sess->devices[i].active = 1;
if (labels && labels[i])
strncpy(sess->devices[i].dev_label, labels[i],
sizeof(sess->devices[i].dev_label) - 1);
}
session_count++;
pthread_mutex_unlock(&sessions_mutex);
// 初始化数据库
init_session_db(sess);
printf("Session created: %s (ID=%u, devices=%d)\n",
name, sess->session_id, dev_count);
return sess->session_id;
}
// 关闭session
int close_session(int session_id) {
pthread_mutex_lock(&sessions_mutex);
for (int i = 0; i < session_count; i++) {
if (sessions[i].session_id == session_id && sessions[i].active) {
sessions[i].active = 0;
sessions[i].end_time = time(NULL);
printf("Session closed: %s (duration: %lds)\n",
sessions[i].session_name,
sessions[i].end_time - sessions[i].start_time);
// 关闭数据库
if (current_db) {
sqlite3_close(current_db);
current_db = NULL;
}
pthread_mutex_unlock(&sessions_mutex);
return 0;
}
}
pthread_mutex_unlock(&sessions_mutex);
return -1;
}
// UDP接收主循环
static void *udp_receive_loop(void *arg) {
int sockfd;
struct sockaddr_in servaddr, cliaddr;
socklen_t len = sizeof(cliaddr);
char buffer[BUF_SIZE];
// 创建UDP socket
if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) < 0) {
perror("socket creation failed");
pthread_exit(NULL);
}
// 设置SO_REUSEADDR
int reuse = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
// 增大接收缓冲区
int rcvbuf = 16 * 1024 * 1024; // 16MB
setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = INADDR_ANY;
servaddr.sin_port = htons(LISTEN_PORT);
if (bind(sockfd, (const struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
perror("bind failed");
close(sockfd);
pthread_exit(NULL);
}
printf("UDP receiver listening on port %d\n", LISTEN_PORT);
while (1) {
int n = recvfrom(sockfd, buffer, BUF_SIZE, 0,
(struct sockaddr *)&cliaddr, &len);
if (n < sizeof(env_packet_t)) {
continue; // 报文太短,丢弃
}
env_packet_t *pkt = (env_packet_t *)buffer;
// 验证魔数
if (pkt->magic != 0x55AA) {
continue;
}
// CRC校验
uint16_t calc_crc = crc16_ccitt((uint8_t *)pkt,
sizeof(env_packet_t) - 2);
if (calc_crc != pkt->crc) {
continue; // CRC错误,丢弃
}
// 查找该设备所属的session
pthread_mutex_lock(&sessions_mutex);
session_t *sess = find_session_for_device(pkt->dev_id);
if (!sess) {
pthread_mutex_unlock(&sessions_mutex);
continue; // 设备未绑定到任何session,丢弃
}
// 查找设备槽位
device_slot_t *dev = NULL;
for (int i = 0; i < sess->device_count; i++) {
if (sess->devices[i].dev_id == pkt->dev_id) {
dev = &sess->devices[i];
break;
}
}
if (!dev) {
pthread_mutex_unlock(&sessions_mutex);
continue;
}
// 去重(序列号检查)
if (dev->last_seq == pkt->seq) {
pthread_mutex_unlock(&sessions_mutex);
continue; // 重复包,丢弃
}
dev->last_seq = pkt->seq;
dev->last_recv_time = time(NULL);
pthread_mutex_unlock(&sessions_mutex);
// 写入数据库
time_t now = time(NULL);
write_to_db(sess, dev, pkt, now);
// 控制台输出(可选,生产环境可关闭)
printf("[%u] dev=%08X temp=%.1f humi=%.1f seq=%u\n",
(unsigned)sess->session_id, pkt->dev_id,
pkt->temp, pkt->humi, pkt->seq);
}
close(sockfd);
return NULL;
}
// HTTP API线程(轻量实现,使用libmicrohttpd)
#ifdef ENABLE_HTTP
#include <microhttpd.h>
static enum MHD_Result http_handler(void *cls, struct MHD_Connection *connection,
const char *url, const char *method,
const char *version, const char *upload_data,
size_t *upload_data_size, void **con_cls) {
// 处理API请求:
// GET /api/sessions - 列出所有session
// POST /api/sessions - 创建新session
// POST /api/sessions/{id}/close - 关闭session
// GET /api/sessions/{id}/data - 查询数据
// GET /api/sessions/{id}/export - 导出CSV
// 具体实现略...
return MHD_YES;
}
#endif
int main(int argc, char *argv[]) {
printf("UDP Environment Data Receiver v1.0\n");
printf("=====================================\n");
// 创建数据目录
system("mkdir -p /var/lib/udp_recv");
// 启动UDP接收线程
pthread_t udp_thread;
pthread_create(&udp_thread, NULL, udp_receive_loop, NULL);
#ifdef ENABLE_HTTP
// 启动HTTP服务器
struct MHD_Daemon *httpd = MHD_start_daemon(
MHD_USE_THREAD_PER_CONNECTION, 8080, NULL, NULL,
&http_handler, NULL, MHD_OPTION_END);
printf("HTTP API listening on port 8080\n");
#endif
// 如果命令行指定了session,自动创建
if (argc > 3) {
// ./udp_recv "实验名称" "操作人" dev_id1,dev_id2,...
// 解析参数并创建session
}
printf("Server running. Press Ctrl+C to stop.\n");
// 等待信号
while (1) {
sleep(1);
}
return 0;
}# 编译(需要SQLite3和可选的libmicrohttpd)
gcc -O2 -pthread -o udp_recv udp_recv.c -lsqlite3 -lmicrohttpd -DENABLE_HTTP
# 安装
cp udp_recv /usr/local/bin/
mkdir -p /var/lib/udp_recv
chmod 755 /usr/local/bin/udp_recv
# systemd服务文件
cat > /etc/systemd/system/udp-recv.service << 'EOF'
[Unit]
Description=UDP Environment Data Receiver
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/udp_recv
Restart=always
RestartSec=5
User=nobody
Group=nogroup
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable udp-recv
systemctl start udp-recv方法 | 路径 | 说明 |
|---|---|---|
GET | /api/sessions | 列出所有session |
POST | /api/sessions | 创建新session |
POST | /api/sessions/{id}/close | 关闭session |
GET | /api/sessions/{id}/data | 查询数据(支持时间范围、设备过滤) |
GET | /api/sessions/{id}/export | 导出CSV |
GET | /api/devices | 列出所有活跃设备 |
GET | /api/status | 服务器状态 |
curl -X POST http://localhost:8080/api/sessions \
-H "Content-Type: application/json" \
-d '{
"name": "高温老化-20250115",
"operator": "张三",
"devices": [
{"id": 0x00000041, "label": "温箱#1-上层"},
{"id": 0x00000042, "label": "温箱#1-下层"},
{"id": 0x00000043, "label": "恒温槽#2"}
]
}'curl "http://localhost:8080/api/sessions/1/data?start=1700000000&end=1700086400&dev_id=0x00000041"返回:
{
"session": "高温老化-20250115",
"device": "温箱#1-上层",
"count": 2880,
"data": [
{"ts": 1700000000, "temp": 85.2, "humi": 45.0, "status": 1},
{"ts": 1700000005, "temp": 85.3, "humi": 45.1, "status": 1}
]
}curl "http://localhost:8080/api/sessions/1/export" > experiment_data.csvCSV格式:
timestamp,device,temperature,humidity,status
2025-01-15T08:00:00,温箱#1-上层,85.2,45.0,1
2025-01-15T08:00:05,温箱#1-上层,85.3,45.1,1
2025-01-15T08:00:10,温箱#1-上层,85.2,44.9,1内嵌一个简单的HTML页面,无需额外安装:
<!-- 内嵌在HTTP服务器中,访问 http://server:8080/ 即可 -->
<!DOCTYPE html>
<html>
<head>
<title>实验室环境监测</title>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.session { border: 1px solid #ddd; padding: 15px; margin: 10px 0; }
.active { border-color: #4CAF50; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td { border: 1px solid #ddd; padding: 8px; }
.data-table th { background: #f2f2f2; }
.chart { width: 100%; height: 300px; }
</style>
</head>
<body>
<h1>实验室环境监测</h1>
<div id="sessions"></div>
<div id="chart"></div>
<script>
// 从API加载数据并绘制简单图表
fetch('/api/sessions')
.then(r => r.json())
.then(data => {
const div = document.getElementById('sessions');
data.sessions.forEach(s => {
const cls = s.active ? 'session active' : 'session';
div.innerHTML += `<div class="${cls}">
<h3>${s.name}</h3>
<p>操作人: ${s.operator}</p>
<p>设备数: ${s.device_count}</p>
<p>状态: ${s.active ? '进行中' : '已结束'}</p>
<button onclick="exportCSV(${s.id})">导出CSV</button>
</div>`;
});
});
function exportCSV(id) {
window.open(`/api/sessions/${id}/export`);
}
</script>
</body>
</html>场景:30台设备,每5秒上报一次
CPU:
UDP接收: ~0.5% (单核)
数据库写入: ~0.3% (批量提交)
HTTP服务: ~0.2% (空闲时)
总计: < 2%
内存:
程序代码: ~2MB
Session数据: ~4KB × 16 sessions = 64KB
SQLite缓存: ~2MB (默认page_cache)
总计: < 30MB
磁盘:
单条记录: ~40字节 (SQLite行)
30设备 × 每5秒1条 × 3600秒 = 21600条/小时 = ~840KB/小时
24小时实验: ~20MB限制项 | 默认值 | 说明 |
|---|---|---|
最大session数 | 16 | 可调整 |
每session最大设备数 | 32 | 可调整 |
SQLite并发写入 | 单线程 | 实验室场景足够 |
UDP接收缓冲区 | 16MB | 可调整 |
问题 | 排查方法 |
|---|---|
收不到数据 | 检查UDP端口监听:netstat -anup \| grep 9100 |
数据未写入数据库 | 检查session是否创建、设备是否绑定 |
数据库锁定 | SQLite并发写入冲突,检查是否有多个进程同时写 |
丢包 | 检查接收缓冲区:cat /proc/net/udp |
时间不对 | 检查设备RTC和服务器NTP同步 |
# 查看UDP接收统计
cat /proc/net/udp
# 抓包验证
tcpdump -i eth0 udp port 9100 -vv
# 查看SQLite数据库内容
sqlite3 /var/lib/udp_recv/experiment.db
.tables
SELECT * FROM env_data LIMIT 10;
# 查看服务状态
systemctl status udp-recv
journalctl -u udp-recv -f关键词:实验室,多工位监测,UDP协议,温湿度记录仪,轻量服务器,接收程序,实验会话,SQLite,HTTP API,数据导出
标签:#实验室 #多工位 #UDP #温湿度记录仪 #轻量服务器 #数据采集 #SQLite #实验会话 #HTTP API #环境监测
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。