简介:这是一套面向工业自动化与嵌入式开发者的Linux TCPModbus服务端程序,基于Modbus/TCP协议实现输入寄存器、离散输入、保持寄存器的读写操作,适合需要快速搭建上位机与PLC等设备通信场景的C语言开发者参考。压缩包共4个文件,体积约10KB,包含C源程序、Linux多线程头文件、Makefile构建脚本以及已编译的可执行程序,从源码阅读到编译运行均有覆盖。目前已有971人学习下载。资源重点实现了0x04读输入寄存器、0x02读离散输入、0x03读保持寄存器及0x06单写、0x10批量写等常用功能码,并采用多线程处理并发客户端请求;通过阅读C源程序与线程头文件,可掌握TCP服务框架、Modbus请求解析与响应组包流程,结合Makefile可快速生成可执行文件并部署到Linux环境。适合学习Modbus协议栈实现、服务端并发编程及工业通信调试的开发者作为实践参考。
1. Linux下Modbus TCP服务端:从能通到稳定的落地路径
如果你负责的设备或上位机只认Modbus TCP协议,那么Linux下做一个稳定的服务端就是绕不开的活。很多时候,网上下载的demo只能点对点通信,一接真实设备就协议乱套、并发翻车。这篇笔记来自我拆过的一份linux tcpmodbus服务端资源包,它把协议解析、设备寄存器映射、并发处理和调试脚本都打包好了。新手可以直接照着起一个虚拟设备,熟手能直接替换底层链路层。注意,这不是科普,而是把每个配置和坑位都摊开来说。
2. 先搞清楚Modbus TCP的协议细节:选型前必须知道的四个重点
Modbus TCP服务端本质上是一个TCP服务器,它接收客户端的请求帧,解析出功能码和地址,再去操作对应的寄存器或线圈,最后构造响应帧返回。这里有个容易忽略的地方:Modbus TCP和Modbus RTU不是简单地把串口变成网络口,多了一个MBAP头,长度和校验方式全变了。很多人在串口上写惯了,直接拿RTU帧套TCP,结果客户端报“非法响应”或“CRC错误”。实际上Modbus TCP帧里根本没有CRC,校验交给TCP协议栈处理,如果还用RTU那套,等于给系统加了多余负担还掩盖了真正的问题。
2.1 报文结构:MBAP头与PDU的边界
完整的Modbus TCP帧由两部分组成:MBAP头(Modbus Application Protocol Header)和PDU(Protocol Data Unit)。MBAP头固定7字节,顺序是事务处理标识符(Transaction Identifier,2字节)、协议标识符(Protocol Identifier,2字节)、长度(Length,2字节)、单元标识符(Unit Identifier,1字节)。事务处理标识符由客户端生成,用于匹配请求和响应;在同一个TCP连接里,客户端每次发起请求时递增,服务端必须在响应里原样拷贝。如果服务端不拷贝或者客户端不递增,调试工具里能看到响应超时,因为请求和响应没法配对。
协议标识符在Modbus TCP里固定是0x0000,表示这是Modbus协议。长度字段表示从这个字段之后到帧结束的字节数,等于单元标识符的长度1加上PDU的长度。很多服务端实现容易犯一个错:把长度算成整个帧的长度,或者直接忽略长度,用recv一次收完。TCP是流协议,一次recv拿到的字节数并不固定,可能只收到半个请求,也可能一个recv里堆了多个请求。所以服务端必须先读7字节的MBAP头,解出长度字段,再按剩余长度循环收完PDU,才能交给解析函数。
单位标识符(Unit ID)在TCP里通常写1。但如果你的服务端是网关,后面挂着多个RTU从站,这个字段就用来区分不同从站。服务端收到请求后,要决定是本地处理还是转发到后端的串口或网络从站。这个资源包默认Unit ID为1,但配置里留了映射表,方便改成网关模式。
2.2 功能码与数据模型:明确寄存器空间边界
Modbus协议定义了四种基本数据对象:线圈(Coil)、离散输入(Discrete Input)、输入寄存器(Input Register)和保持寄存器(Holding Register)。线圈是可读写的位,地址范围一般是0x开头;离散输入是只读的位;输入寄存器是只读的16位字;保持寄存器是可读写的16位字。服务端在实现时,需要用四个独立的内存数组来模拟这四个空间,不能混用。
功能码方面,读操作常用0x01(读线圈)、0x02(读离散输入)、0x03(读保持寄存器)、0x04(读输入寄存器);写操作常用0x05(写单个线圈)、0x06(写单个保持寄存器)、0x0F(写多个线圈)、0x10(写多个保持寄存器)。服务端要特别处理请求地址和数量越界的情况。比如客户端请求读保持寄存器,起始地址是90,数量是20,总共覆盖到地址110,但你的保持寄存器数组只有100个,这时必须返回异常功能码0x02(非法数据地址),不能直接截断或忽略,否则客户端会拿着错数据去联动设备,可能出安全事故。
有些工业设备还会用0x2B(封装接口)或0x17(读/写多个寄存器)这类扩展功能码。如果你的服务端只想做基础设备模拟,可以不实现,但一定要返回非法功能码异常(0x01),而不是直接断开连接。这个资源包实现了常用功能码,并在配置里增加了“enabled_function_codes”白名单,默认打开0x01、0x03、0x06、0x10、0x10,其他的一律抛异常,避免被扫描器当作漏洞利用。
2.3 字节序:大端、小端与厂商野路子
Modbus协议标准规定,寄存器数据在网络上使用大端字节序(Big-Endian),也就是高字节在前、低字节在后。比如寄存器值0x1234,在帧里就是12 34。这一条不能含糊,服务端在解析和组包时全部要按大端处理。但实际碰到的设备千奇百怪,有些厂商的温湿度传感器,内部寄存器存的是小端,还有的以两个寄存器拼一个32位浮点数,顺序还分ABCD、CDAB、BADC等多种花样。服务端如果死板地只按大端读写,客户端读到的数据就会乱掉。
所以我在调这个资源包时,特意在配置里加了“byte_order”选项,支持“big_endian”和“little_endian”两种模式。对于单寄存器,小端模式下,0x1234会被写成34 12。对于双寄存器组成的32位整数或浮点数,又增加了“word_order”参数,决定两个寄存器谁先谁后。这样做的好处是,适配不同厂商设备时不用改代码,改配置重启就行。注意,字节序不是服务端单方面能定的,要和客户端约定好。如果客户端是按大端解析,你服务端设成小端,那哭笑不得。所以资源包里的示例配置默认采用标准大端顺序,同时把开关和说明写在注释里。
2.4 Linux环境与语言选型:C、Python、Go怎么选
在Linux上写服务端,语言选择直接影响开发效率和运行性能。C语言适合嵌入式设备,编译出的二进制体积很小,启动速度快,毫秒级延迟,但多线程并发和内存管理全靠手写,一个空指针就会让服务崩掉。Python开发速度快,有pymodbus这样的现成库,调试方便,但GIL锁让多线程并发性能一般,适合设备数量少、吞吐量不高的场合。Go语言有优秀的goroutine并发模型和标准库,交叉编译容易,部署就是单二进制,适合做中高并发的网关或边缘网关。
这份资源包里正好给了Python和C两套实现。Python版本用来快速搭建原型,验证协议逻辑;C版本用来部署到内存受限的嵌入式设备。如果你只是测试用,我的习惯是先跑Python版,把寄存器映射调好,再用C版编译上线。语言本身不是关键,关键是你对Linux socket和TCP流边界的理解。后面的章节我会用Python版带你看核心实现,C版的差异只在内存布局和锁处理上。
3. 手把手搭建服务端:资源包结构、核心代码与配置项
3.1 资源包里的目录结构与模块分工
下载下来的资源包解压后,目录结构并不复杂,但每个文件都有明确分工。根目录下有server_py/和server_c/两个子目录,分别放两套实现;configs/底下放.json格式的配置文件;tools/里是模拟客户端和压测脚本。这种“一套配置、两套实现”的结构,是我个人比较喜欢的方式,因为换底层语言时,业务逻辑和寄存器表不用重写。
Python版本的核心文件是server.py、db.py和conf.py。conf.py读取JSON配置并生成运行时参数;db.py把配置里定义的寄存器初始值加载到内存;server.py负责建立TCP监听、接收请求、解析和响应。日志输出全部走标准库的logging,级别默认是INFO,如果调试可以把环境变量LOG_LEVEL设为DEBUG,就能看到每一次收发的十六进制帧。
配置文件是config.json,里面包含监听端口、最大连接数、寄存器映射表、字节序、Unit ID映射等。资源包给了一个example_config.json,里面预置了100个保持寄存器、100个线圈、10个输入寄存器,初始值写的是某设备校准后的正常数值,比如频率、温度、压力。你用的时候只需要改寄存器数量,不改代码。
3.2 核心代码:用Python实现一个最小可用的Modbus TCP服务端
先看主循环。代码里用标准库socket实现监听,不做任何第三方依赖。实现思路是每个客户端连接分配一个线程,线程里先读MBAP头,再按长度读取PDU,然后分发到功能码处理函数。下面是一段经过简化的核心代码,来自server.py:
import socket import struct import threading # 寄存器与线圈空间,初始值从配置加载 holding_registers = [0] * 100 input_registers = [0] * 10 coils = [0] * 100 discrete_inputs = [0] * 10 def recv_exact(conn, n): """从连接中读取精确n个字节,避免TCP粘包问题""" buf = b'' while len(buf) < n: chunk = conn.recv(n - len(buf)) if not chunk: return None buf += chunk return buf def handle_pdu(trans_id, unit_id, pdu): """根据功能码分发处理""" func = pdu[0] if func == 0x03: # 读保持寄存器 start = struct.unpack('>H', pdu[1:3])[0] count = struct.unpack('>H', pdu[3:5])[0] if start + count > len(holding_registers): return struct.pack('>B', 0x83) + b'\x02' values = holding_registers[start:start+count] resp_body = bytes([func, count * 2]) for v in values: resp_body += struct.pack('>H', v) return resp_body elif func == 0x10: # 写多个保持寄存器 start = struct.unpack('>H', pdu[1:3])[0] count = struct.unpack('>H', pdu[3:5])[0] byte_count = pdu[5] payload_start = 6 for i in range(count): offset = start + i holding_registers[offset] = struct.unpack('>H', pdu[payload_start + i*2:payload_start + i*2 + 2])[0] return struct.pack('>BH', 0x10, start) + struct.pack('>H', count) else: # 非法功能码 return struct.pack('>B', func | 0x80) + b'\x01' def client_worker(conn): while True: header = recv_exact(conn, 7) if not header: break trans_id, proto_id, length, unit_id = struct.unpack('>HHHB', header) # 从length读取PDU字节数 pdu = recv_exact(conn, length - 1) if pdu is None: break if proto_id != 0: # 协议标识符非0,直接关闭连接 break resp_pdu = handle_pdu(trans_id, unit_id, pdu) # 构造响应MBAP头,trans_id原样返回 resp_header = struct.pack('>HHHB', trans_id, 0, len(resp_pdu) + 1, unit_id) conn.sendall(resp_header + resp_pdu) conn.close() def start_server(host, port): server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((host, port)) server.listen(10) while True: conn, addr = server.accept() t = threading.Thread(target=client_worker, args=(conn,)) t.start()代码逻辑说明:recv_exact函数循环调用recv直到收够指定字节,这是TCP流协议下必须做的事。handle_pdu里对每个功能码都做了越界检查,异常情况下返回带异常标志的响应帧;0x83表示0x03功能码的异常响应,后续字节0x02是非法数据地址。client_worker先收7字节MBAP头,通过length字段确定PDU长度,再精确收完PDU,然后调用处理函数并把事务ID原样带回。
参数说明:start_server(host, port)的host如果传0.0.0.0,那么服务端会监听所有网卡地址;如果要在调试时只监听本机,改成127.0.0.1。listen(10)是未处理连接队列的最大长度,通常调成和最大连接数一致即可。资源包配置里max_connections参数会直接映射到这个值。
3.3 配置参数:端口、最大连接数、超时时间与日志级别
配置文件是JSON格式,典型的config.json内容如下:
{ "host": "0.0.0.0", "port": 5020, "max_connections": 10, "timeout_seconds": 30, "byte_order": "big_endian", "word_order": "big_word_first", "unit_id_map": { "client1": 1, "client2": 2 }, "registers": { "holding": { "count": 100, "init_values": [0, 0, 0, 0, 50, 60, 70], "byte_order": "big_endian" }, "input": { "count": 10, "init_values": [100, 200, 300] }, "coil": { "count": 100, "init_values": [0, 0, 1, 1] } }, "logging": { "level": "INFO" } }port默认是5020,因为标准Modbus TCP端口是502,但Linux下普通用户绑定低于1024的端口需要root权限。开发调试阶段我习惯用5020,部署到正式环境再改成502并用防火墙限制来源IP。timeout_seconds是socket超时时间,超过这个时间没有收到数据就主动断开,防止僵尸连接占用线程。byte_order和word_order前面说过,用于应对非标设备。registers段定义了各地址空间的大小和初始值,注意init_values只填前面几个,后面自动补零,这样既省空间又直观。
还有一个容易忽略的参数是“allow_zero_address”。很多PLC的Modbus地址从0开始,但部分上位机软件地址从1开始,软件发送时会在地址上加1,比如上位机地址1实际对应服务端地址0。如果你发现客户端的数值和服务端寄存器偏移总是差一位,把它设为true,服务端在收到请求后会把地址减1再操作。这个参数不是标准协议的一部分,但实际调试中非常有用。
4. 常见问题排查:四个实战踩坑记录
4.1 客户端连接后立刻断开,服务端没日志
| 现象 | 原因 | 解决 |
|---|---|---|
| modpoll等客户端一连接就退出,服务端没有任何报错 | 客户端发送的不是Modbus TCP帧,可能直接发送了RTU帧 | 用Wireshark抓包看端口5020上的数据。如果报文里没有MBAP头,说明客户端配置成了RTU Over TCP。把客户端改成Modbus TCP协议 |
这种情况最常发生在刚把设备从串口转网口时。某厂家的模块默认工作在RTU Over TCP模式,就是把Modbus RTU帧原封不动塞进TCP流里。服务端尝试按MBAP头解析,结果长度字段完全对不上。解决方法是确认客户端协议栈选择的是“Modbus TCP”而不是“Modbus RTU over TCP”。资源包的README里特意加了这个提示,因为踩的人实在太多。
4.2 寄存器数值总是对不上,读写结果像被异或了一样
| 现象 | 原因 | 解决 |
|---|---|---|
| 写单个寄存器0x1234,读回来变成0x3412 | 字节序配置错误 | 将config.json里的byte_order改为little_endian,或者改写寄存器的代码 |
这一条在湖库水环境监测项目里碰到过。某传感器的固件是用小端保存水位数据的,但它的Modbus协议规范文档里写的是标准大端。厂家明白,你却不知道。修改配置后,读数立刻正常。血泪经验:遇到数值不对,先改字节序配置,别急着在代码里手动reverse。
4.3 多客户端同时写寄存器时,数据出现丢失或覆盖
| 现象 | 原因 | 解决 |
|---|---|---|
| 两个监控系统同时往保持寄存器写数据,结果值偶尔变成奇怪数字 | 没有加锁,GIL并未保证多线程写列表原子性 | 使用threading.RLock对寄存器内存块加锁 |
Python的GIL只能保证单条字节码指令的原子性,但不能保证“读取-修改-写回”这个序列的原子性。当客户端A读完寄存器值,客户端B又改了它,A再写回旧值,B的修改就丢了。解决方法是给全局寄存器数组加threading.RLock。资源包里C版本用的是pthread_mutex_t,效果一样。还有一点,如果写操作和读操作都很频繁,锁粒度不要太小,最好一个事务持锁,避免中间插队。
4.4 在Linux防火墙开启的机器上连接超时,服务端日志却一切正常
| 现象 | 原因 | 解决 |
|---|---|---|
| 客户端提示超时,服务端用ss显示listen队列没有活跃项目 | 防火墙或SELinux把TCP包丢了 | 使用iptables -L -n和getenforce检查,放行502端口,或修改SELinux布尔值 |
某次部署在CentOS类系统上,客户端从Windows机器怎么都连不上,但服务端程序在本机用localhost又能通。排查半天,是iptables INPUT链没有放行5020端口。还有一次更隐蔽,SELinux强制模式把非标准端口的网络套接字也拦了,临时用setenforce 0能通,但重启就失效。正规做法是放行端口并添加SELinux策略,但这需要权限,正式环境请注意操作前备份策略。
5. 进阶用法:从能跑到好用,加验证和压测脚本
5.1 用Linux命令行工具快速验证服务端
搭建好服务端后,不用急着找上位机,用Linux自带的nc就能手动构造一个Modbus TCP请求。下面命令假设服务端监听在127.0.0.1的5020端口,我们发送一条“读保持寄存器起始地址0,读10个寄存器”的请求。MBAP头为00 01 00 00 00 06 01,PDU为03 00 00 00 0A,所以整个请求是00 01 00 00 00 06 01 03 00 00 00 0A:
echo -ne '\x00\x01\x00\x00\x00\x06\x01\x03\x00\x00\x00\x0A' | nc -w 2 127.0.0.1 5020 | xxd你会看到响应帧里以00 01 00 00 00 17 01 03 14开头,其中00 17是长度,01是Unit ID,03是功能码,14是后面寄存器字节数(20字节)。通过这个原始帧,你能快速确认服务端协议解析是否正常,不用依赖任何第三方客户端。
5.2 用Python脚本做并发压测与持久性验证
资源包tools/里有一个stress_test.py,它创建多个TCP连接,每个连接循环执行写寄存器和读寄存器操作,最后校验读取值与写入值是否一致。核心思路是用事务ID匹配请求与响应,确保时序不混乱。压测参数包括并发连接数、单连接请求次数、写值范围。我在一台低功耗Linux设备上测过,50个并发连接、每连接1000次请求,Python版服务端能跑满,但CPU已经到85%,改用C版后CPU降到40%以内。
我自己的习惯是:每改一次配置文件,先跑一遍stress_test.py,再跑一遍Wireshark抓包做完整性校验。从那以后每次线上变更,我都强制把“改完先压测”走一遍,确认没有引入新的字节序错误或并发问题,再交付到生产环境。希望这份资源能让你少走几个弯路,也希望这些踩坑记录能帮到你。
本文还有配套的精品资源,点击获取