简介面向工业自动化与嵌入式开发者的 Linux TCPModbus 服务端实现适合需要在 Linux 平台上搭建 Modbus 通信、对接 PLC 或传感器设备的中级开发者参考。代码完整覆盖 Modbus 常用功能码0x04 读取输入寄存器、0x02 查询离散输入状态以及保持寄存器的 0x03 读、0x06 单写与 0x10 批量写并借助多线程处理并发客户端连接可作为自建设备通信服务的起点。压缩包共 4 个文件、约 10KB包含 C 源码、线程相关头文件、构建脚本与可执行程序体量轻便、结构紧凑便于快速编译运行与二次修改。目前已有 972 人学习下载读者可从中获得协议解析、请求响应处理与并发调度的完整实现思路并参考其错误处理方式应对网络故障与无效请求从而搭建可靠的 Modbus 通信系统。1. 一个跑在 Linux 上的 TCP Modbus 服务端到底能省掉多少联调时间工控现场最磨人的环节往往不是写业务逻辑而是让上位机、PLC、采集网关和自研程序之间先“说上话”。Modbus TCP 因为简单、开放、几乎每台工控设备都认成了事实上的通用语。这份资源就是一个跑在 Linux 上的 TCP Modbus 服务端实现把从监听端口、解析 MBAP 报文、维护寄存器区到响应功能码的整条链路都摊开给你看。它适合两类人一类是要给自研采集程序造一个可控的模拟从站用来验证主站轮询逻辑另一类是接手了老旧工控项目需要读懂服务端到底怎么处理请求、怎么组织寄存器。拿到手不是终点能把它跑起来、改参数、接上自己的主站才算真正落地。2. 先把协议骨架立住MBAP 报文与寄存器模型2.1 为什么服务端要先吃透 MBAP 头Modbus TCP 和串口版 Modbus RTU 最大的差别就在报文前面那 7 个字节的 MBAP 头。它把从站地址、功能码、数据长度这些信息重新组织了一遍服务端解析的第一步永远是先读这 7 个字节再决定后面读多少。很多新手直接照搬 RTU 的帧结构去写 TCP 服务端结果主站一发请求就超时翻车点几乎都在这。MBAP 头结构如下字段长度说明事务标识符2 字节主站自增服务端原样回填用于匹配请求与响应协议标识符2 字节Modbus 固定为 0x0000长度2 字节后续字节数含单元标识符单元标识符1 字节从站地址TCP 场景下常填 0x01 或 0xFF服务端处理逻辑很直接先recv至少 7 字节校验协议标识符是否为 0读出长度字段再继续读剩余length - 1字节单元标识符已包含在长度里。这里有个容易忽略的细节——TCP 是流式协议一次recv不保证拿到完整一帧必须循环读直到凑够长度。我一般会封装一个recv_exact函数把“读满 N 字节”这件事做成阻塞语义后面所有解析都基于它。import socket import struct def recv_exact(conn, n): 阻塞读满 n 字节TCP 流式协议下必须这样处理 buf b while len(buf) n: chunk conn.recv(n - len(buf)) if not chunk: raise ConnectionError(对端关闭连接) buf chunk return buf def read_mbap(conn): 读取并解析 7 字节 MBAP 头 header recv_exact(conn, 7) trans_id, proto_id, length, unit_id struct.unpack(HHHB, header) if proto_id ! 0: raise ValueError(f协议标识符非法: {proto_id}) # length 含 unit_id所以后续数据长度为 length - 1 pdu recv_exact(conn, length - 1) return trans_id, unit_id, pdustruct.unpack(HHHB, ...)里的表示大端序这是 Modbus 的硬性规定用小端解析出来的长度字段会是一个离谱的大数然后程序就卡在等数据上。length - 1这个减法也是高频错误点长度字段统计的是“单元标识符 PDU”单元标识符已经单独解出来了剩下的才是功能码加数据。2.2 寄存器区怎么组织才不混乱Modbus 定义了四类数据区线圈可读写位、离散输入只读位、保持寄存器可读写 16 位、输入寄存器只读 16 位。服务端内部必须为这四类各维护一块存储且地址空间要能按主站请求的起始地址和数量精确定位。常见做法是用四个结构承载线圈和离散输入用字节数组按位寻址保持寄存器和输入寄存器用 16 位整数列表。地址从 0 开始主站请求“起始地址 100数量 10”时服务端就取索引 100 到 109。这里不要自作聪明去搞什么地址偏移映射除非你的设备文档明确要求 1-based 寻址——那属于业务层适配不该污染协议层。class DataStore: def __init__(self, coils1024, discretes1024, holdings1024, inputs1024): # 位区用 bytearray按位读写 self.coils bytearray((coils 7) // 8) self.discretes bytearray((discretes 7) // 8) # 寄存器区用 list每个元素 0~65535 self.holdings [0] * holdings self.inputs [0] * inputs def get_coil(self, addr): return (self.coils[addr // 8] (addr % 8)) 1 def set_coil(self, addr, value): byte_idx, bit_idx addr // 8, addr % 8 if value: self.coils[byte_idx] | (1 bit_idx) else: self.coils[byte_idx] ~(1 bit_idx)位寻址的addr // 8和addr % 8是标准做法set_coil里用位或和位与非分别置位、清位避免影响同字节内其他线圈。寄存器区直接用 list索引即地址读写都是 O(1)。如果现场寄存器数量很大比如上万点可以把 list 换成array(H)省内存但接口保持不变这是后期优化的事先跑通再说。3. 功能码处理从 0x03 到 0x10 的落地实现3.1 读类功能码的统一处理套路主站最常发的就是 0x03读保持寄存器和 0x04读输入寄存器两者结构几乎一样只是数据源不同。请求 PDU 是“功能码 起始地址(2) 数量(2)”响应是“功能码 字节数(1) 数据”。服务端要先校验数量是否在 1~125 之间协议规定单次最多读 125 个寄存器再校验地址范围是否越界任何一项不满足都要回异常响应而不是直接断开连接。异常响应的格式是“功能码 | 0x80 异常码”比如非法数据地址回0x83 0x02。这个细节很多自研服务端会漏主站收到正常格式但内容错误的包排查起来就是黑匣子。def handle_read_registers(pdu, store, reg_typeholding): func pdu[0] start, count struct.unpack(HH, pdu[1:5]) if not (1 count 125): return bytes([func | 0x80, 0x03]) # 非法数据值 source store.holdings if reg_type holding else store.inputs if start count len(source): return bytes([func | 0x80, 0x02]) # 非法数据地址 values source[start:start count] body struct.pack( H * count, *values) return bytes([func, len(body)]) bodystruct.pack( H * count, *values)动态拼格式串把整个寄存器列表一次性打包成大端字节流比循环拼接高效也更不容易出错。异常码 0x02 和 0x03 要严格区分地址越界是 0x02数量非法是 0x03主站侧对这两个码的处理逻辑可能不同别混用。3.2 写类功能码与多寄存器写入0x06写单个寄存器和 0x10写多个寄存器是控制类指令的主力。0x06 请求是“功能码 地址(2) 值(2)”响应原样回显0x10 请求是“功能码 起始地址(2) 数量(2) 字节数(1) 数据”响应回“功能码 起始地址 数量”。0x10 的数量上限是 123 个寄存器因为 PDU 最大 253 字节扣掉头部就剩这么多。写操作落地时有个绕不开的问题写入要不要立即生效、要不要做范围保护。工控场景下某些寄存器是只读配置项主站误写可能导致设备异常。稳妥做法是在 DataStore 之上加一层地址白名单或回调钩子写入前先过一遍业务校验不通过就回异常码 0x04从站设备故障或 0x02。这份资源里如果没带这层建议自己补上现场调试时能省掉大量“谁把参数改了”的扯皮。def handle_write_multiple(pdu, store): func pdu[0] start, count, byte_count struct.unpack(HHB, pdu[1:6]) if not (1 count 123) or byte_count ! count * 2: return bytes([func | 0x80, 0x03]) if start count len(store.holdings): return bytes([func | 0x80, 0x02]) values struct.unpack( H * count, pdu[6:6 byte_count]) store.holdings[start:start count] values return bytes([func]) struct.pack(HH, start, count)byte_count ! count * 2这个校验不能省主站或中间网关出错时字节数可能对不上不校验就会在struct.unpack处抛异常导致连接断开。响应里回显起始地址和数量是协议规定的确认语义主站靠它判断写入是否被接受。3.3 主循环与并发模型的选择服务端骨架通常是一个accept循环每来一个连接起一个线程处理。Modbus TCP 主站一般不会开大量并发连接线程模型足够用代码也直观。如果现场有几十上百个采集器同时连可以换成selectors或asyncio但要注意每个连接的状态半包缓存必须独立维护不能共享缓冲区。import threading def serve(host0.0.0.0, port502): store DataStore() srv socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((host, port)) srv.listen(16) while True: conn, addr srv.accept() threading.Thread(targetclient_loop, args(conn, store), daemonTrue).start() def client_loop(conn, store): with conn: while True: try: trans_id, unit_id, pdu read_mbap(conn) except (ConnectionError, ValueError): break resp dispatch(pdu, store) # 响应头事务标识符回填协议标识 0长度 1 len(resp) header struct.pack(HHHB, trans_id, 0, len(resp) 1, unit_id) conn.sendall(header resp)SO_REUSEADDR是为了服务重启时不被 TIME_WAIT 卡住端口工控现场反复调试时这个选项几乎是必加。daemonTrue让子线程随主进程退出避免 CtrlC 后残留。响应头的长度字段是len(resp) 1那个 1 就是单元标识符和解析侧对称写错一位主站就会认为帧不完整。4. 避坑与排查那些让联调卡半天的细节4.1 端口 502 绑定失败现象程序启动报Permission denied换 5020 就正常。原因Linux 下 1024 以下端口需要 root 或CAP_NET_BIND_SERVICE能力。解决调试阶段直接用高位端口上线时用setcap cap_net_bind_serviceep /path/to/binary授权或者交给 systemd 的 socket 激活别长期用 root 跑业务进程。4.2 主站一直超时但服务端日志显示已收包现象抓包能看到请求进来服务端也回了主站却报超时。原因响应的事务标识符没回填或者长度字段算错主站匹配不上就丢弃。解决对照 MBAP 表逐字段核对事务标识符必须原样回填长度字段等于“单元标识符 1 字节 PDU 长度”。抓包工具里看响应帧的第三、四字节能快速定位。4.3 读回来的寄存器值高低字节颠倒现象主站显示 0x1234 变成 0x3412。原因服务端打包用了小端或者业务层存值时按小端写入。解决协议层统一大端struct格式串一律带如果设备文档要求字交换word swap在业务映射层做转换别改协议层。4.4 多连接下数据串台现象两个主站同时读写A 写的值出现在 B 的响应里。原因多个连接共享了同一个可变缓冲区或者 DataStore 没做并发保护。解决每个连接独立维护接收缓冲DataStore 的读写加锁或者用单线程事件循环串行处理工控场景下串行往往比加锁更省心。4.5 异常响应格式不对导致主站直接断连现象地址越界后主站不再发请求。原因服务端回了正常格式但内容错误的包或者异常码用了协议未定义的值。解决严格按“功能码最高位置 1 标准异常码”构造常用 0x01 非法功能、0x02 非法地址、0x03 非法数据值、0x04 从站故障别自创异常码。5. 进阶验证用脚本把服务端压一遍再交付服务端写完不能只靠一台上位机点两下就认为没问题我习惯用脚本把边界情况全打一遍。下面这段用标准库socket直接构造请求不依赖任何第三方 Modbus 库能精确控制每一个字节适合验证异常分支。import socket, struct def build_request(trans_id, unit_id, pdu): return struct.pack(HHHB, trans_id, 0, len(pdu) 1, unit_id) pdu def probe(host, port, pdu, trans_id1, unit_id1): with socket.create_connection((host, port), timeout2) as s: s.sendall(build_request(trans_id, unit_id, pdu)) header s.recv(7) tid, pid, length, uid struct.unpack(HHHB, header) body b while len(body) length - 1: body s.recv(length - 1 - len(body)) return tid, uid, body # 正常读 10 个保持寄存器 print(probe(127.0.0.1, 502, struct.pack(BHH, 0x03, 0, 10))) # 越界读期望异常码 0x02 print(probe(127.0.0.1, 502, struct.pack(BHH, 0x03, 60000, 10))) # 非法数量 0期望异常码 0x03 print(probe(127.0.0.1, 502, struct.pack(BHH, 0x03, 0, 0)))probe里手动拼 MBAP 头事务标识符传不同值可以验证回填是否正确越界和非法数量两个用例专门打异常分支返回的 PDU 第一个字节应该是0x83第二个字节是对应异常码。这套脚本跑通基本能覆盖主站 90% 的异常场景。验证清单我一般按这个顺序过验证项方法通过标准正常读写0x03/0x06 各打一次值与写入一致边界地址起始地址 数量刚好等于区大小正常返回不越界越界地址起始地址超出区大小回 0x02 异常非法数量数量为 0 或超上限回 0x03 异常并发连接两个 probe 同时跑数据不串台半包分两次 send 一个请求服务端正确拼帧半包那条最容易被忽略用s.sendall分两次发中间sleep几十毫秒能验证recv_exact是否真的在循环读。我早年偷懒直接用一次recv解析本地测试全过一到现场网络抖动就偶发丢帧查了两天才定位到流式协议没读满。从那以后每次交付前都强制把半包和并发这两项跑一遍宁可多花十分钟也不想在现场被一个“偶发”拖住半天。希望这份拆解能帮到你把服务端跑起来只是开始把异常分支和并发边界都验证过才算真正能交付。本文还有配套的精品资源点击获取
企业数字化 ERP 产品动态
相关推荐
基于单片机的电子血压计设计:硬件架构、示波法算法与整机联调实战 简介:面向嵌入式开发初学者与医疗电子爱好者的一套智能电子血压计完整设计资料,围绕单片机实现血压采集、计算与无线传输的典型方案展开。压缩包共303个文件、约6.74MB,以55个c源文件、47个h头文件与75个obj中间文件为主体,辅以as… · 2026/11/4 8:45:20
Edge禁用更新后仍弹窗?四步根治更新提示方案 1. 项目概述Edge浏览器禁用更新后仍频繁弹窗提示“新版本已准备好”“请重启以应用更新”,这个问题在企业IT管理、教育机房、数字标牌系统、嵌入式Kiosk设备等场景中尤为典型。我接触过某高校公共计算机实验室的运维同事,他们批量部署了Windows 10Edge环… · 2026/11/4 8:45:20
轮转数组与乘积数组的高效算法解析 1. 轮转数组的高效解法剖析 轮转数组是算法面试中的经典问题,题目要求将数组元素向右轮转k个位置。新手常见的误区是直接使用暴力解法,通过k次单步轮转来实现,但这种方法的时间复杂度高达O(n*k),在数据量较大时性能堪忧。今天我要… · 2026/11/4 8:45:20
哈希表核心原理与LeetCode经典题解 1. 哈希表基础与算法训练营Day-6全解析 作为程序员面试必考的数据结构,哈希表在实际工程中的应用频率仅次于数组和链表。今天我想结合代码随想录训练营第六天的内容,系统梳理哈希表的核心原理和四道经典LeetCode题解。这些题目覆盖了哈希表90%的典型应用… · 2026/11/4 9:28:32
OceanBase一体机在金融行业的应用与优化实践 1. OceanBase一体机在金融行业的创新实践 最近在金融科技圈里,OceanBase数据库一体机(ODM)成了热门话题。作为从业十多年的数据库架构师,我特别关注到中邮证券和金谷信托这两个典型案例。他们不仅成功实现了传统数据库的升级换代&… · 2026/11/4 9:28:32
Java程序打包成独立可执行文件的ex4j实践指南 1. 项目背景与核心价值 去年接手一个企业级Java项目交付时,客户现场的环境限制让我头疼不已——他们的内网机器既没有安装JDK,也不允许连接外网下载依赖。这种场景下,传统的"jar包JDK环境"部署方式完全失效。正是这次经历让我开始研… · 2026/11/4 9:28:32
MiMo-V2.6双版本架构:API级模型工程化实践 1. MiMo-V2.6不是“又一个大模型”,而是API经济下的精密工程重构你点开新闻标题,第一反应可能是:“哦,小米又发了个新模型。”——但这次真不是。MiMo-V2.6的发布逻辑,和过去两年里绝大多数开源模型发布会截然不同&… · 2026/11/4 9:28:32
FFmpeg 4.4 Windows shared构建包:从解压到生产脚本的完整指南 简介:这份资源是面向音视频开发者与个人用户的 FFmpeg 4.4 预编译完整包,省去从源码编译的繁琐流程,可直接在 Windows 上执行转码、提取音频、裁剪画面等任务,也可将库文件集成进自有项目。压缩包共 201 个文件,约 49.… · 2026/11/4 9:28:32
SqlServer教室信息管理系统课程设计:从建库到冲突检测实战 简介:这份资源是面向高校数据库课程设计场景的教室信息管理系统 SQL 脚本合集,适合正在完成数据库课设、需要参考建库建表与业务逻辑实现的学生,也适合想借真实案例巩固 SQL Server 综合应用的初学者。压缩包共 6 个文件,全部为 s… · 2026/11/4 9:27:32
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38
AI辅助技术栈评估:提升研发决策效率60% 1. 项目背景与核心价值技术决策一直是研发团队面临的重大挑战。随着技术生态的快速演进,技术栈的选择、维护和迭代变得愈发复杂。我们团队在过去两年里,先后经历了三次重大技术栈迁移,每次都需要投入大量人力进行技术评估和风险分析。直到去年… · 2026/11/4 0:00:17
从生成Token到直接决策:Jev如何重塑AI Agent工程实践 1. 从"生成文字"到"拍板决策":Jev 到底在解决什么问题大多数人第一次接触大模型,脑子里建立的模型是"输入问题、输出文字"。你问它今天天气怎么样,它给你一段描述;你让它写个周报,它给你… · 2026/11/4 0:00:17
32G统一内存跑大模型:内存账、TPS实测与端云分流 想在一台 32G 统一内存的小主机上把大模型跑成"日常可用",这件事我前后折腾了小半年。标题里说的这台 Mac mini M6,是我们这次讨论的目标平台,具体到手规格以实物为准;但下面这套推算方法和实测思路,对任何统… · 2026/11/4 0:00:17
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38