嵌入式C语言实现工业Modbus TCP网关:同时接入32台现场设备的通信调度
32台现场设备挂网关,最忌“来一个请求发一个包”。Modbus TCP本身靠MBAP事务号支持流水请求,但若后端是RS485 RTU从站,总线半双工会直接打架;即便后端也是TCP从站,不管理事务ID也会串包。 正确架构是:以太网侧收包解析→请求入队→调度器按从站分发→事务映射表回包。
一、MBAP帧先切干净
Modbus TCP没有CRC,靠TCP可靠传输,帧边界全看MBAP长度域。头7字节:事务ID(2)+协议ID(2)+长度(2)+单元ID(1)。
typedef struct {
uint16_t trans_id;
uint16_t proto_id;
uint16_t len;
uint8_t unit_id;
uint8_t pdu[256];
uint8_t pdu_len;
} mbap_frame_t;
int mbap_parse(uint8_t*buf,int n,mbap_frame_t*f){
if(n<8) return -1;
f->trans_id = (buf[0]<<8)|buf[1];
f->proto_id = (buf[2]<<8)|buf[3];
if(f->proto_id!=0) return -2;
f->len = (buf[4]<<8)|buf[5];
f->unit_id = buf[6];
f->pdu_len = f->len-1;
memcpy(f->pdu, buf+7, f->pdu_len);
return 0;
}
收包必须按长度域凑整帧,TCP会拆包粘包,别recv一次就当一帧。
二、32台设备调度表
每台从站登记IP/端口或RTU站号、功能码白名单、轮询周期、超时:
typedef struct {
uint8_t unit_id;
uint8_t mode; // 0=RTU后端 1=TCP从站
uint32_t ip;
uint16_t port;
uint16_t poll_ms;
uint16_t timeout_ms;
uint8_t retry;
uint8_t online;
} dev_entry_t;
dev_entry_t dev[32];
上位机请求里的Unit ID就是路由键,网关查表知道该转发到哪条总线或哪个socket。
三、请求队列解耦收发
以太网接收任务和总线发送任务不能写在一起,中间加FIFO:
typedef struct {
uint8_t sock; // 来源客户端
uint16_t trans_id; // 回包匹配
uint8_t unit_id;
uint8_t pdu[256];
uint8_t pdu_len;
uint32_t tick;
} req_t;
req_t qbuf[16];
uint8_t qhead,qtail,qcnt;
int req_enqueue(req_t*r){
if(qcnt>=16) return -1; // 队满丢请求,别积压
qbuf[qtail]=*r;
qtail=(qtail+1)%16; qcnt++;
return 0;
}
队满直接回异常码0x0B(从站无响应)比卡死强。
四、RTU后端:总线互斥单发
若32台实际是RS485上的站号1~32,串口同一时刻只能跑一个事务,调度器串行取队列:
void rtu_schedule_10ms(void){
static uint8_t busy=0;
if(busy) return; // 上一帧没回完
if(qcnt==0) return;
req_t*r=&qbuf[qhead];
dev_entry_t*d=&dev[r->unit_id-1];
rtu_build_and_send(d->unit_id,r->pdu,r->pdu_len);
busy=1;
r->tick=sys_tick();
g_pending=r; // 等串口ISR回包
}
void rtu_rx_done(uint8_t*pdu,int len){
req_t*r=g_pending;
mbap_reply(r->sock,r->trans_id,r->unit_id,pdu,len);
qhead=(qhead+1)%16; qcnt--;
g_pending=NULL;
}
超时在定时器里判,超过timeout_ms没回就重发,重发次数耗尽标记online=0并回异常。
五、TCP后端:事务ID映射
若32台本身是Modbus TCP设备,网关给每台维护socket(或同网段多Unit ID复用连接),发之前分配事务号:
uint16_t g_tid;
typedef struct { uint16_t tid; uint8_t sock; } tid_map_t;
tid_map_t tid_map[32];
void fwd_to_tcp(dev_entry_t*d,req_t*r){
uint16_t tid=++g_tid;
tid_map[tid&0x1F]=(tid_map_t){tid,r->sock};
uint8_t out[270];
out[0]=tid>>8; out[1]=tid&0xff;
out[2]=0;out[3]=0;
out[5]=r->pdu_len+1;
out[6]=d->unit_id;
memcpy(out+7,r->pdu,r->pdu_len);
tcp_send(d->sock_fd,out,r->pdu_len+7);
}
收到响应按事务ID查回原客户端,响应顺序不必和请求一致。
六、超时与重传
工业现场别一超时就断连接:
RTU后端:重发1~2次,间隔按波特率
TCP后端:超时重发同事务ID,旧响应迟到直接丢弃
连续N次失败→设备离线,周期拉长为慢轮询
队满、socket断开、MBAP协议ID非0→立即返回异常
七、主循环结构
while(1){
net_poll_sockets(); // 收Modbus TCP请求→解析→入队
rtu_schedule_10ms(); // 后端是RTU则串行发
tcp_schedule(); // 后端是TCP则按连接发
timeout_check(); // 重传/离线判定
usleep(1000);
}
Linux下可拆线程:收包线程、RTU状态机线程、各TCP从站轮询线程;RTU总线用互斥锁保护。
八、落坑清单
recv一次当一帧→粘包解析错
Unit ID不映射→32台全发到同从站
RTU后端并发发→总线冲突全丢
事务ID写死0→多请求回包串
队满还硬塞→响应全超时
超时就断开socket→工业网络抖动误杀
MBAP长度域不算单元ID→偏移错
响应按发送顺序匹配→流水线翻车
32台同周期轮询→广播风暴
CRC还加一遍→TCP模式多余且错
九、落地顺序
先通单台Modbus TCP收发+MBAP解析
建32台dev表,Unit ID路由
收包入队,主循环统一调度
RTU后端加总线互斥,单事务在途
TCP后端建事务ID映射表
超时重传+离线慢轮询
关键设备周期短、普通设备周期长
队满返异常、断开连接清理映射
32台接入的本质不是“并发32路”,是请求排队+事务匹配+后端介质互斥。以太网侧可以流水发,RS485侧必须单发;Unit ID负责寻址,Transaction ID负责回包对齐。固件别在socket回调里直接操作串口,也别假设响应按顺序来——队列和映射表一立,32台还是320台,调度逻辑都不用改。





