嵌入式Linux工业网关编程:实现Modbus、OPC UA、MQTT多协议转换
工业网关做多协议转换,最忌“收到OPC UA读请求→现场去问Modbus从站”。RS485半双工经不起以太网侧并发戳,几秒就CRC满天飞。 正确架构是三层解耦:南向采集线程写共享缓存 → 统一数据点模型 → 北向Modbus TCP/OPC UA/MQTT只读缓存。协议差异在采集层和输出层各自消化,中间只传“值+质量码+时间戳”。
一、统一数据点是核心
别让OPC UA节点直接绑Modbus寄存器地址,也别让MQTT拼字符串时现读串口。先建内部点表:
typedef struct {
char name[32]; // "motor_temp"
uint8_t dev_idx;
uint8_t func; // 03/04
uint16_t addr; // 40001类
float scale;
int16_t raw;
float value;
uint8_t quality; // 0 BAD 1 GOOD
uint64_t ts_ms;
} tag_t;
tag_t tags[256];
pthread_mutex_t tag_mtx;
Modbus原始值是整数,工程量靠scale换算;OPC UA要的是带类型的Variant,MQTT要的是JSON,都从tag_t派生。
点位表建议从JSON/YAML加载,别写死在代码里:设备多、地址改一动全身。
二、南向Modbus采集线程
libmodbus同时支持RTU/TCP,切换只是modbus_new_rtu/modbus_new_tcp。
void* modbus_poll_task(void*arg){
modbus_t*ctx = modbus_new_tcp("192.168.1.10",502);
modbus_set_slave(ctx,1);
modbus_connect(ctx);
uint16_t reg[32];
while(run){
for(int i=0;i<n_tags;i++){
tag_t*t=&tags[i];
if(modbus_read_registers(ctx,t->addr,1,reg)==1){
pthread_mutex_lock(&tag_mtx);
t->raw=reg[0];
t->value=reg[0]*t->scale;
t->quality=1; t->ts_ms=now_ms();
pthread_mutex_unlock(&tag_mtx);
}else{
t->quality=0;
}
usleep(t->poll_ms*1000);
}
}
modbus_close(ctx); modbus_free(ctx);
return NULL;
}
RTU后端注意:
485方向脚用RTS控制或硬件自动翻转
超时modbus_set_response_timeout按波特率留余量
单总线单主站,别让多个线程共用ctx
读失败标quality=BAD,别拿上次要塞
多设备轮询周期错开,别32台同时100ms,串口带宽会爆。
三、北向Modbus TCP服务(透传上位机)
如果上位机还要走Modbus TCP读网关,另起一个ctx监听502,回调函数从tags[]取数,不碰串口:
void mb_tcp_reply(){
modbus_mapping_t*map = modbus_mapping_new(0,0,256,0);
modbus_t*srv = modbus_new_tcp(NULL,502);
modbus_tcp_listen(srv,1);
for(;;){
int cli=modbus_tcp_accept(srv,NULL);
uint8_t q[256]; int n=modbus_receive(srv,q);
if(n>0){
pthread_mutex_lock(&tag_mtx);
for(int i=0;i<256;i++) map->tab_registers[i]=tags[i].raw;
pthread_mutex_unlock(&tag_mtx);
modbus_reply(srv,q,n,map);
}
}
}
TCP侧Unit ID映射到RTU从站地址,网关做桥接时别写死0/255。
四、OPC UA服务器(open62541)
OPC UA是给SCADA/上位做对象模型浏览的,节点值从tag缓存刷:
#include <open62541/server.h>
#include <open62541/server_config_default.h>
void add_tag_node(UA_Server*s, tag_t*t){
UA_VariableAttributes a=UA_VariableAttributes_default;
UA_Float v=0; a.value=UA_Variant_from_scalar(&v,&UA_TYPES[UA_TYPES_FLOAT]);
a.displayName=UA_LOCALIZEDTEXT("en-US",t->name);
UA_Server_addVariableNode(s, UA_NODEID_STRING(1,t->name),
UA_NODEID_NUMERIC(0,UA_NS0ID_OBJECTSFOLDER),
UA_NODEID_NUMERIC(0,UA_NS0ID_ORGANIZES),
UA_QUALIFIEDNAME(1,t->name),
UA_NODEID_NUMERIC(0,UA_NS0ID_BASEDATAVARIABLETYPE),
a,NULL,NULL);
}
void refresh_ua(UA_Server*s){
for(int i=0;i<n_tags;i++){
pthread_mutex_lock(&tag_mtx);
UA_Float v=tags[i].value; pthread_mutex_unlock(&tag_mtx);
UA_Variant var; UA_Variant_setScalar(&var,&v,&UA_TYPES[UA_TYPES_FLOAT]);
UA_Server_writeValue(s,UA_NODEID_STRING(1,tags[i].name),var);
}
}
主循环UA_Server_run跑独立线程,定时refresh即可,别在OPC UA回调里调modbus_read。
五、MQTT上云(paho / mosquitto)
MQTT只做北向推送,QoS1防丢,断网本地缓冲:
void mqtt_publish_tag(tag_t*t){
cJSON*root=cJSON_CreateObject();
cJSON_AddStringToObject(root,"name",t->name);
cJSON_AddNumberValue(root,"value",t->value);
cJSON_AddNumberValue(root,"q",t->quality);
cJSON_AddNumberValue(root,"ts",t->ts_ms);
char*txt=cJSON_PrintUnbuffered(root);
mosquitto_publish(mos,tid,"plant/cnc01/temp",strlen(txt),txt,1,0);
cJSON_free(txt);
}
要点:
QoS选1,云端ACK前重发
retain只给设备状态topic用,数据topic别retain
断网把payload存环形队列,恢复后补发
TLS+用户名密码,别匿名暴露502/1883
topic层级site/device/point,别堆查询参数
六、线程与数据一致性
架构主干:
[RTU/TCP采集线程] --> tags[] (mutex)
|
+-------------------+-------------------+
v v v
Modbus TCP服务 OPC UA刷新 MQTT发布线程
tags[]是唯一真相源,所有北向只读
写方向只有采集线程(或下行命令线程)
OPC UA/MQTT不持有modbus ctx
南下写命令(置位继电器)走单独写队列,避免和轮询抢帧
七、落坑清单
OPC UA回调里调modbus_read→RS485并发冲突
点位表写死数组→改地址重编译
Float不做4字节对齐→读Real乱





