☰
手写一个MODBUS TCP Server:从协议拆解到源码实现
2026/9/26 6:50:27 网站建设 项目流程

简介:这是一份基于C++实现的MODBUS TCP服务器端源码,面向工业自动化、物联网及网络通信方向的开发者,用于解决PLC等设备通过以太网进行MODBUS通信的需求。源码实现了MODBUS功能码03(读输入寄存器/离散输入)和16(写多个保持寄存器),涵盖请求解析、应答构造、错误处理等核心逻辑,可作为二次开发的基础框架。压缩包共22个文件,以7个.h头文件和5个.cpp源文件为主,辅以Visual Studio工程文件(.sln、.vcxproj等)与界面资源文件(.rc、.ico等),整体仅152KB,结构紧凑、便于阅读和扩展。已有2169人学习下载,适合希望深入理解MODBUS TCP协议并快速搭建服务器原型的开发者。通过阅读源码,可掌握标准TCP端口502的通信机制、功能码03/16的报文格式、寄存器读写与错误处理等关键点,并可在此基础上扩展支持线圈读写(01/05)等更多功能,应用于智能能源管理、环境监测或自动化产线等场景。 前阵子有个设备接入项目把我折腾得够呛。现场一台老式流量计只出MODBUS RTU串口,新上的MES系统却只认MODBUS TCP,中间必须加一个协议转换层。翻遍现成的开源库,要么是给PC上位机用的重型实现,要么在嵌入式Linux上编译麻烦还带一堆依赖。一咬牙,干脆自己照着MODBUS TCP协议规范写一个Server。这大概就是“MODBUS TCP SERVER 源码”这种标题背后最常见的真实需求:不是你买不起现成方案,而是你的场景太“非标”,需要一份能看懂、能裁剪、能自己维护的代码。

这篇文章就从我自己写Server的完整思路出发,把协议拆解、服务端骨架、功能码实现、Modbus Poll调试、踩坑排查这些环节都过一遍。适合嵌入式开发者、上位机工程师,以及需要在现场临时做协议转接的自动化集成朋友。你不需要把MODBUS整个协议族都读一遍,跟着我的思路走,一个小而可用的Server很快就能立起来。

1. 为什么非要自己写一个MODBUS TCP Server,而不是调现成库

先说结论:不是现成的方案不好,而是“够用”和“顺手”之间往往隔着一条鸿沟。

网上能直接用的MODBUS TCP Server方案大致有三类。第一类是完整的开源协议栈,比如FreeMODBUS、libmodbus,功能全、社区活跃,但设计目标是一个通用协议栈,代码分层多、抽象厚,放到嵌入式Linux或单片机里你得先做一轮裁剪。裁完才发现,为了支持一堆你用不上的功能码和传输模式,底层耦合已经缠在一起了。第二类是工业网关设备,买来即用,但成本几百到几千不等,而且市面上多数网关的寄存器映射、数据长度、读写权限都是通过配置软件配的,碰上一两个奇怪的协议细节,你没法改固件,只能干瞪眼。第三类是纯教学用的小Demo,GitHub上一搜一大堆,代码短,但多数只处理了单个功能码,连TCP粘包都没处理,不具备上线条件。

我自己选择手写的核心原因有三个。一是协议转换场景太碎,比如我那个现场,RTU侧采集到的数据要经过工程量换算、量程压缩,再映射到Server的保持寄存器里,只有把Server这层代码握在自己手里,才能让映射逻辑和业务逻辑无缝配合。二是嵌入式设备资源有限,一个完整版协议栈编译出来可能多占几十KB RAM和几百KB Flash,而一个只支持常用功能码的最小Server,代码量可以控制在几百行以内,这对单片机环境非常友好。三是排查问题的能力,写完这个Server,你对MODBUS协议的理解深度会有一个质变。以后现场碰到“为什么读上来的数不对”“为什么动不动超时”这类问题,你不用再黑盒猜,看一眼报文就能定位。

当然,自己写不等于从零造轮子。协议规范是公开的,寄存器模型是固定的,你要做的只是把TCP这层收包、解析、分发的逻辑按自己的场景重新实现一遍。我会明确画一条边界:只做稳定可靠的最小集,不追求功能码全覆盖。

2. 先吃透协议再写码:MBAP报文头、PDU和四张数据表

很多人写MODBUS TCP Server,都是照着别人代码抄一遍就跑了,结果一被问“为什么这个字段是这么填的”就哑火。其实协议本身不复杂,难的是先建立正确的“报文思维”。

2.1 MBAP报文头:MODBUS TCP与RTU的分水岭

MODBUS TCP和MODBUS RTU最直观的区别,就是报文最前面多了7个字节的MBAP报文头,全称是MODBUS Application Protocol Header。这7个字节拆开来看:

  • Transaction Identifier(事务标识符,2字节):每次请求递增,Server响应时要原样返回。这个字段用来让客户端把请求和响应配对,尤其是在一个TCP连接上连续发多个请求时。
  • Protocol Identifier(协议标识符,2字节):MODBUS协议里固定为0x0000。这不是让你随便填的,如果收到非0值,应该直接丢弃。
  • Length(长度,2字节):它表示后面“单元标识符 + PDU”的总字节数。注意它不包括自身和前面的4个字节。
  • Unit Identifier(单元标识符,1字节):相当于RTU里的从站地址。通过TCP网关连接多个RTU从站时,这个字节用来路由到具体从站;纯TCP设备通常填0x01或0xFF。

报文头之后就是PDU,即“功能码 + 数据”。功能码1字节,后面跟着的具体数据因功能码而异,比如读保持寄存器是“起始地址2字节 + 寄存器数量2字节”。很多新手第一次在Wireshark里看MODBUS TCP,会被一堆十六进制数字吓到,其实你按这个结构去拆,非常清晰。

我建议你动手前先抓一次包,用Wireshark过滤“tcp.port == 502”,发一条读请求,然后对照MBAP结构逐字节看。这个习惯能帮你省掉后面至少一半的排查时间。

2.2 四张数据表:MODBUS设备的数据世界观

MODBUS协议把设备里的数据抽象成四张表,这是一个非常关键的建模方式。不管你接的是PLC、仪表还是自己写的自定义设备,本质都是向上位机暴露这四张表:

表类型数据宽度访问权限功能码
线圈(Discrete Output Coil)1位可读写0x01读,0x05写单,0x0F写多
离散输入(Discrete Input)1位只读0x02读
保持寄存器(Holding Register)16位可读写0x03读,0x06写单,0x10写多
输入寄存器(Input Register)16位只读0x04读

你用生活化一点的方式理解:线圈和离散输入是开关墙上的按钮和指示灯状态,保持寄存器是带读写权限的数据仓库,输入寄存器是只允许查看的数据展板。Server要做的,就是在内存里维护好这四张表,再对上层请求做出正确的读写响应。

2.3 为什么默认端口是502

MODBUS TCP的默认端口是502,这个端口低于1024,属于特权端口。在Linux上,普通用户直接bind会失败,要么用root跑,要么通过authbind或iptables端口转发到高位端口。Windows上一般没这个问题,但防火墙经常会把非标准端口的入站流量拦下来,部署时要注意放行。

3. 服务端骨架与请求分发:从socket监听说到功能码路由

协议层吃透了,写代码就是顺水推舟的事。Server的整体结构可以分成四层:连接管理、报文解析、功能码分发、数据表操作。我按这个顺序来讲。

3.1 连接管理:一次accept背后发生了什么

服务端最底层的骨架还是那套经典的socket流程:socket、bind、listen、accept,然后进入“recv请求 - 处理 - send响应”的循环。你可以用每连接一个线程,也可以用一个主线程循环accept,来一个连接就开一个线程去处理。这个最简单,在十几个客户端连接的场景下完全够用。

这里要提一下TCP三次握手和accept之间的关系。客户端发起connect时,内核已经帮你在底层完成三次握手,连接进入“已完成队列”,你的accept只是从队列里取一个已经建立好的连接。所以Server端accept慢,不会丢连接,只会让队列堆积。但listen的backlog参数一定要给够,默认值在Linux上可能只有几十,如果现场有大量客户端同时连入,backlog太浅会导致客户端连接失败或超时。我习惯把它设成64甚至128,同时配合非阻塞模式,避免个别慢客户端占住accept线程。

连接管理里还要考虑异常断开。客户端拔网线、断电,不会正常发FIN包,服务端会一直以为连接还在。解决办法是开启SO_KEEPALIVE,再配合设置TCP keepalive参数,或者更简单可靠的方式:在应用层做一个最后活跃时间戳,超过一定时长(比如60秒)没收过请求就主动断开。很多现场出现“连接数越来越多,新客户端连不上”的问题,就是这个环节没做好。

3.2 收包解析:必须先解决TCP是流的问题

TCP是字节流,不像UDP那样有天然的消息边界,所以一次recv可能收到半个请求,也可能一次收到好几个请求。这就是所谓的“粘包/半包”问题。处理办法很标准:在Server端维持一个接收缓冲区,把recv到的数据累积进去,然后尝试从缓冲区里切出一个完整报文去处理。

MODBUS TCP因为有Length字段,切包其实很简单:先判断缓冲区是否够7个字节的MBAP头,够就解析出Length。Length是“单元标识符1字节 + 功能码1字节 + 数据N字节”,所以完整报文总长度是7 + Length。如果缓冲区不足这个长度,就继续等;够了就切出去处理,剩下没处理完的留给下次循环。这个过程我建议用一个小函数封装,配合while循环把缓冲区里所有完整报文都处理完,避免一个连接同时来多条请求时阻塞在第一条上。

3.3 功能码分发与异常响应

解析出完整的MBAP + PDU之后,服务端要检查几个关键项:Protocol Identifier是否为0,Unit Identifier是否匹配,Length是否在合理范围内。这些检查通过后再进入功能码分发,一个switch就够了。

以最常用的几个功能码为例:

switch (function_code) { case 0x03: // 读保持寄存器 handle_read_holding_regs(); break; case 0x06: // 写单个寄存器 handle_write_single_reg(); break; case 0x10: // 写多个寄存器 handle_write_multiple_regs(); break; default: send_exception(frame, 0x01); // 非法功能码 }

功能码分发之后的工作,核心是地址和长度的合法性校验。请求里的起始地址和寄存器数量都是大端2字节,注意地址是从0开始编号的,但PLC习惯把保持寄存器叫成40001之类的一开始编号,现场沟通时经常会有人把报文里的地址0和PLC的40001混为一谈,这个坑我在第4节展开讲。

一旦地址越界、数量过大,或者写入的数据值超出合理范围,就要返回异常帧。异常响应帧的格式是:功能码的最高位置1,即原功能码加0x80,后面跟一个异常码。最常用的异常码就四个:

异常码含义常见原因
0x01非法功能Server不支持该功能码
0x02非法数据地址起始地址或数量超出数据表范围
0x03非法数据值写入的值超出合理范围
0x04从站设备故障内部处理错误或硬件异常

3.4 寄存器映射:用数组加锁搞定四张表

数据表的实现不需要奇技淫巧,直接为每张表准备一个数组就行。线圈和离散输入是位数组,可以每个位单独用一个uint8_t表示,简单直观;保持寄存器和输入寄存器就是uint16_t数组,按功能码和地址去索引。

多客户端连接时会有一个并发问题。Modbus Poll连着读,上位机软件连着写,如果多个客户端同时对同一个寄存器写,或者一边写一边读,数据就会错乱。最简单的做法是在Server里维护一个读写锁,读操作拿共享锁,写操作拿独占锁。对于寄存器这种一次读写就是几个字节的粒度,整个函数加锁就行,性能不会有大问题。如果设备数据需要分两步更新,比如先写高16位再写低16位,一定要把两步也包在一个事务里,否则中间态会被读请求看到,上位机就会读到一闪而过的脏值。

4. 实测踩坑:粘包切包、字节序反向、02异常和断开重连

代码写出来不等于能上线,我用Modbus Poll实测的过程中接连踩了几个坑,每个都有代表性。这一节我把完整排查链路记下来,你知道原因之后,以后碰到同类问题不用再猜。

4.1 Modbus Poll报02 illegal data address:不是设备坏了,是你的表太小

第一次用Modbus Poll读保持寄存器时,设置起始地址0,数量10,结果返回“02 illegal data address”。这里的02不是TCP的错误代码,而是MODBUS异常码,对应“非法数据地址”。

排查链路是这样的:我先用Wireshark抓包,看到Poll发出“0x03功能码 + 起始地址0x0000 + 数量0x000A”的请求,Server返回了“0x83 + 0x02”的异常帧。问题很清楚:Server注册的保持寄存器数组只有8个元素,起始地址0开始读10个,第9、第10个越界了。解决办法要么把数组扩到10个,要么让上位机把数量改到8以内。

这个坑看起来简单,但背后是一个很常见的误解。很多人以为PLC里的40001对应报文地址1,其实MODBUS报文里的地址是从0开始的,40001对应报文地址0。你在设计自己的Server时,数据表的大小和起始偏移要写清楚,最好在README里直接写明“支持保持寄存器地址范围0-99,对应PLC地址40001-40100”,省得现场沟通扯皮。

4.2 粘包和半包:一次recv收到两个请求,直接死等

我的Server最初版本写得比较天真,每次recv只读一次,读多少处理多少。结果用Modbus Poll把轮询周期设到100ms时,问题就出来了:一次recv可能同时收到两个读请求,代码只处理了第一个,第二个被丢在缓冲区里没人管,上位机那边就出现周期性超时。

排查过程用了一个很简单的方法:在recv后面把收到的原始字节用十六进制打印出来。当时打印结果里明显看到报文尾部还跟着一个完整的MBAP头,也就是第二个请求已经粘在第一个后面了。修复方式就是第3.2节说的循环切包:把recv到的数据先塞进缓冲区,然后在while循环里按Length切完整的请求出来处理,直到不够一个完整报文才退出。

半包则反着来,可能一个请求分两次到,第二次recv才收到后半段。如果代码不处理这种情况,解析出来的Length就会对不上,甚至触发内存越界。稳妥做法是:解析前先判断“缓冲区里有没有完整报文”,没有就继续收,千万不要用recv的次数当消息边界。

4.3 字节序:读出来的温度值像乱码,问题不在通信

有一次实测读回来的温度是0x1234,但实际应该是0x3412,直接把字节换一下就对了。这是MODBUS TCP开发里最经典也最容易栽跟头的坑。

MODBUS协议规定寄存器值是高位字节在前(Big-Endian),也就是网络字节序。但很多MCU和小型设备的内部存储是小端,我在把设备原始数据填进保持寄存器时忘了做转换。比如设备内部用一个int16_t存温度,内存里是低字节在前,直接塞进uint8_t数组再发出去,字节顺序自然就反了。

解决思路不是“强拧成一种格式”,而是明确一条规则:Server对外交互的寄存器值,严格按照大端格式收发;内部业务代码用自己的本机字节序,在读写寄存器数组的边界做一次转换。用函数封装一下,比如本地uint16_t转网络字节序:高字节放低地址、低字节放高地址。调试时一旦发现某个寄存器值大小对但字节序反了,优先检查Server是不是在填充寄存器时漏了转换。

4.4 连接断不开:西门子和上位机“每次重启才能连上一分钟”的现象分析

热词里有条“西门子tcp只有每次重启的时候才能连上一分钟”,这个现象我在现场也见过。说白了,一般是服务端在某个环节主动或半主动地把连接断掉了,但客户端还保持着旧连接的状态,导致下一次connect失败。

最常见的有三种情况。第一种是Server端没有开启KeepAlive,客户端异常下线后,半开连接一直占着文件描述符不释放,时间一长Server卡在旧连接上。第二种是代码里把recv返回0当成误判了,客户端主动close时recv会返回0,正确做法是返回0就关闭连接并释放资源。第三种是TCP连接被中间设备(如防火墙)静默丢弃,Server不知道,客户端也没感知,两边都以为连得好好的,实际数据已经不通。

排查链路我建议这样做:先在Server端用“ss -tnp”或“netstat -tnp”看当前TCP连接状态,重点看有没有大量ESTABLISHED状态的僵尸连接;再用Wireshark或tcpdump抓包,看是不是有RST包,RST就是在告诉你“这个连接已经不存在,强制清掉”;最后检查代码的recv返回值处理和KeepAlive设置。把这三步走完,大多数连接问题都能定位到根因。

4.5 特权端口502的坑

非root用户启动Server时,bind 502端口会报“Permission denied”。有些项目图省事直接让服务用root跑,我一般不这么干。安全一点的方案是让程序监听一个高位端口,比如1502或5502,然后用iptables把外部访问502端口的流量转发到高位端口。现场如果只有几个设备接入,直接让客户端连高位端口也行。Windows上部署时要注意防火墙入站规则,不然外部客户端连不上,这个问题反而更多。

5. 用Modbus Poll做体检:连接配置、轮询设置和错误码研判

Server写好了,不能光自己觉得能用,得拿专业的主站调试工具去“拷打”一遍。Modbus Poll是Windows上最常用的MODBUS TCP主站模拟器,界面直白,适合做交互式调试。虽然它是商业软件,但官方有试用版,日常调试足够了。如果你不想折腾,也有开源的替代品,比如QModMaster,或者命令行工具mbpoll,功能上完全够用。

5.1 连接配置和轮询设置

打开Modbus Poll,先进入“Connection”填写TCP/IP信息:IP地址本机填127.0.0.1,端口502。连接类型选TCP/IP。连接成功后,再设置读操作:功能码选03(读保持寄存器),起始地址0,数量根据你的数据表大小填,轮询周期可以设成100ms或200ms。

轮询周期这个参数很有讲究。设得太快,比如10ms,会把Server的CPU占用打满,同时掩盖一些本应该暴露出的并发问题;设得太慢,比如5秒,又没法及时验证Server的稳定性。我建议初期调试用200ms,重点观察返回数据是否稳定;做压力测试时再逐步缩短到50ms、20ms,看Server会不会出现超时或崩溃。

界面里如果显示红色错误码,双击那条错误记录,可以直接看到异常帧。这是判断Server实现是否正确最直接的方式。比如你回一个0x83 0x02,Poll就会在界面上显示“02 illegal data address”,你在上位机工程里排查时,看到这个错误基本就能锁定寄存器地址越界。

5.2 三种测试包:正常读、批量写、异常帧一个都不能少

我自己的体检流程,通常分三步走。

先测正常读:把四个功能码(01、02、03、04)都跑一遍,确认每张数据表都能读到正确值。读出来的值建议用两种方式验证:一种是在Server端把寄存器数组里手动填几个已知值,对比Poll读出来的数据;另一种是用Modbus Slave工具把另一个模拟从站挂上来,用Poll同时读两个地址,对比两边数据是否一致。这一步先把“通”的问题解决掉。

再测写操作和回读:用功能码06写单个寄存器、0x10写多个寄存器,写完立即用03读回。这里重点验证执行的写操作是不是原子的——如果Server端写一个寄存器分了两次内存操作,中间被打断就可能出现读回值与写入值不一致。

最后测异常帧:故意让请求越界,比如从地址9000开始读,Server必须返回0x02异常码;故意发一个不支持的功能码,比如0x2B,Server必须返回0x01异常码。如果Server直接丢弃请求不回复,客户端那边大概率会一直等到超时,这在工业现场是不能接受的。异常帧的价值不是“报错”,而是让上位机能第一时间明确问题在哪。

5.3 抓包验证:用Wireshark给Server做一次最终裁决

Modbus Poll界面上看到的数据还不够,我最后都会用Wireshark抓一次完整的MODBUS TCP包,从字节层面确认Server的响应是干净的。过滤器就一行:tcp.port == 502。打开后能看到客户端请求和Server响应的完整报文,包括事务标识符是否回配、长度字段是否正确、寄存器数据字节序是否符合预期。

这一步很有价值,因为有些问题在Poll界面显示不出来。比如事务标识符不复用导致客户端配对错乱,或者长度字段极端情况下填写错误,这些问题只有看原始报文才能发现。对一个准备上线的Server来说,这是最后一道体检。

我自己在这套流程里吃过亏的地方是:前期太依赖功能测试,忽略原始报文的检查,结果有个字节序错误在功能测试阶段一直没暴露,到现场对接第三方系统时才被对方开发抓出来。现在无论多小的改动,都要跑完“功能码覆盖 + 异常帧 + 原始报文”这三步再发布。

——写完这个Server之后最大的体会是,MODBUS TCP Server的源码难点不在协议本身,而在工程细节:连接怎么管、缓冲区怎么切、字节序怎么换、异常帧怎么回。把这几个细节兜住,一个最小可用的Server也就三百行。以后遇到类似的协议转接,这套思路可以直接复用。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询