简介这份MODBUS TCP SERVER源码是一套基于C实现的Modbus TCP服务器端程序面向工业自动化开发者、嵌入式工程师以及正在学习Modbus协议与TCP/IP网络编程的初学者。源码实现了功能码03读取输入寄存器/离散输入与功能码16写多个保持寄存器覆盖了Modbus TCP最常见的数据读取与远程配置场景可作为理解协议帧格式、请求解析、应答构造与错误处理的入门框架。资源压缩包共22个文件主要包含7个头文件与5个C源文件以及Visual Studio工程配置相关文件.sln、.vcxproj、.rc等体积仅152KB结构精简、易读适合直接用于二次开发或学习调试。当前已有2173人学习浏览可见其作为Modbus TCP入门资料的实用价值。通过阅读源码读者能掌握功能码03/16的实现细节理解TCP端口502通信机制并在此基础上扩展更多功能码快速搭建符合自身需求的工业通信服务器。1. MODBUS TCP SERVER源码从报文解析到现场联调一次说透搞工控的人对 MODBUS TCP 并不陌生可真要找一份能直接读懂、敢改动的 Server 源码翻车概率不低。网上的示例大多是客户端轮询或者只实现单个功能码的演示程序一旦要对接点位表、处理异常码、扛住长连接这些代码就露馅了。这份 MODBUS TCP SERVER 源码把 TCP 流解析、寄存器数据区、功能码处理拆成独立模块适合三类人刚接手设备联网项目的开发者、要把 PLC 数据送到信息化平台的工程师、想搞清报文怎么封怎么解的学生。它不是黑匣子而是能逐行读完并拿到现场改造的落地实现。2. 协议骨架先把报文边界和寄存器映射立住协议解析不能靠猜。我看过几个自称能当网关的 demo功能码写得很全但报文边界全都用错了——程序默认每次 recv 到的就是完整请求包这在局域网里偶尔能跑可一旦遇到粘包或者现场网关转发日志就会开始报长度异常。所以要读这份源码建议先不看功能码先把边界和映射这两件事立住。2.1 从TCP流到PDU7字节MBAP头的解析边界MODBUS TCP 报文固定由 7 字节 MBAP 头 PDU 构成。MBAP 头里最关键的是“长度”字段它表示从 Unit ID 开始到 PDU 结束的字节数。这个字段单独就能解决“一次 recv 到的是不是一个完整帧”的问题。字段字节数说明事务标识符2客户端生成Server 原样带回用于并发请求配对协议标识符20x0000 表示 MODBUS非 0 可直接丢弃长度2从 Unit ID 到 PDU 结束的字节数单元标识符1网关场景下相当于串口的从站地址拿到源码后先找到协议解析入口。常见做法是维护一个接收缓冲区每次把 recv 到的数据追加进去再按长度字段判断是否凑够一帧int check_frame(const uint8_t *buf, int len, int *pdu_len) { if (len 7) return 0; /* MBAP头没收完 */ if (buf[2] ! 0x00 || buf[3] ! 0x00) return -1; /* 协议标识校验 */ int mb_len ((int)buf[4] 8) | (int)buf[5]; if (mb_len 1 || mb_len 254) return -2; /* 长度越界 */ int total_len 7 mb_len - 1; /* 长度已含Unit ID自身 */ if (len total_len) return 0; /* 半包等待后续数据 */ *pdu_len total_len - 7; return 1; /* 完整帧 */ }这里最容易翻车的点是total_len的计算。长度字段统计的是 Unit ID、功能码和数据部分的总字节数不是 PDU 字节数所以要用7 mb_len - 1。我看到过不少教程写成7 mb_lenServer 会多等一个字节平时 TCP 流不会立刻出错但高并发下响应延迟和粘包现象会被放大。代码里的mb_len 1判断也建议保留万一收到畸形包至少能快速断开而不是陷入死等。至于事务标识符源码里一般不会拿它做重排序只负责原样带回。但如果你的设备后面接了一个前置网关网关会用事务标识区分多路请求这时候就必须保证每个请求递增。我一般会把事务号和线程号对应起来排查时直接根据日志里的 tx_id 反推是哪条链路出的问题。2.2 功能码选择一份Server为什么先支持这四类这份源码默认支持的四个功能码和绝大多数现场点位表吻合0x03 读保持寄存器、0x04 读输入寄存器、0x06 写单个寄存器、0x10 写多个寄存器。对比关系如下功能码名称请求PDU不含MBAP响应要点0x03读保持寄存器03 起始地址(2B) 数量(2B)字节计数 寄存器值0x04读输入寄存器04 起始地址(2B) 数量(2B)同上0x06写单个寄存器06 地址(2B) 数值(2B)原样回显请求PDU0x10写多个寄存器10 地址 数量 字节数 数据回显地址和数量不回数据为什么没有默认实现 0x01、0x02 这类位操作主要有两个原因。一是工程项目里模拟量点位通常比数字量更复杂先把寄存器读写跑通数字量可以通过 PLC 内部逻辑映射成寄存器再读不占用额外功能码。二是位操作会引入“单个线圈地址映射”的问题不同厂家对线圈区起始地址的约定并不一致冒然加进去反而容易造成点位错位。如果你拿到源码后发现需要位操作按同样的模式新增就好——在功能码分发表里加一个分支数据区访问沿用现有映射函数。地址映射是我看代码时最先确认的地方。Modbus 协议字段里的地址从 0 开始但现场点表往往用 40001 这种带偏移的地址给人看。常见做法是提供一个转换函数把“可读地址”换算成数组下标。以下写法是这个源码里最常见的设计/* 连续地址段: 40001~41000 映射到数组下标 0~999 */ static int idx_of(uint16_t addr) { if (addr 40001 addr 41000) { return addr - 40001; } if (addr 30001 addr 30050) { return addr - 30001 1000; /* 输入寄存器驻留另一段 */ } return -1; /* 越界, 上层返回异常码0x02 */ }这个函数好在把“协议地址”和“数组下标”解耦。你接一个点位表不需要搬动寄存器数组只需要把可读地址范围加进去。返回 -1 时Server 会构造异常码 0x02Illegal Data Address发回去客户端在 OPC Server 或者组态软件里看到的提示就是“地址无效”。很多新手会直接在功能码里判地址范围我习惯把范围判断集中到映射函数逻辑更好维护也更方便接稀疏点位表。2.3 选型与取舍现成库能跑为什么还要这份源码调试阶段用现成的 Modbus 库确实快但交付现场的东西和实验室跑通是两回事。我的经验是把选择标准拆成三条场景推荐理由快速原型验证、临时采集现成库封装完整少踩坑需要定制功能码和异常码这份源码每个功能码的处理集中在一个分支好改资源受限的嵌入式设备这份源码裁剪可以去掉冗余日志甚至用静态数组取代动态内存现成库最大的问题是“错也要错得明白”。比如现场要求写入多个寄存器时如果数据长度不匹配库可能直接抛一个通用异常。而这份源码里功能码处理是一个独立的函数你可以先校验长度再校验范围返回值自己定义日志也能精确到具体请求。另一个常见场景是私有功能码比如某些仪表会自定义 0x41 去读固件版本。现成库未必支持自定义扩展但这套源码的分发表只需加一行。我并不建议在每台设备上都自己写协议栈。协议解析本身已经稳定不必重复造轮子。但如果是长期运行、需要和设备厂商点位表对齐的网关类项目一份看得见底层的源码比黑盒的库更适合做基线。用这份源码之前先确认它的连接模型是“每连接一线程”还是“select/event loop”这决定你后续往上面加设备时会不会把线程数跑满。每连接一线程的做法很典型accept 后直接 detach资源开销高但简单直接承担几十个点位够用如果现场要求上千点并发就需要把业务处理挪到一个有界线程池里accept 线程只负责接收连接具体展开在第 3.1 节。3. 编译启动与客户端联调让 Server 先跟测试工具说话拿到源码先别急着接现场设备先在开发机上把 Server 启动起来再用一个测试客户端把四个功能码完整走一遍。这个过程花不了一个小时却能帮你把所有协议问题约束在本地。3.1 启动参数与线程模型端口、单元号、地址范围一次配好这份源码的启动参数核心是端口、Unit ID 和寄存器范围。以常见启动方式为例# 本地调试建议先用高位端口, 避免root权限和防火墙干扰 ./modbus_tcp_server \ --bind 0.0.0.0 \ --port 1502 \ --unit 1 \ --reg-begin 40001 \ --reg-end 40020--bind 0.0.0.0必须写代表监听所有网卡如果只写127.0.0.1本机测试没问题局域网里的 PLC 或上位机永远连不上。--port 1502是调试期的省事选择因为 502 端口在 Linux 下需要 root 或额外授权等联调完再切到 502。--unit 1对应 MBAP 的 Unit ID和组态软件里的从站地址保持一致如果后面挂了多个设备源码头可能提供--slaves选项配置多个 Unit ID而不是只监听一个。连接模型通常是 accept 之后新建线程处理while (1) { int client accept(listen_fd, NULL, NULL); if (client 0) continue; pthread_create(tid, NULL, client_handler, client); pthread_detach(tid); }这里要特别小心client的传递。pthread_create把client传进线程而client是栈上的变量下一轮循环就可能被覆盖。我一般会在堆上分配一个 int 对象传给线程线程退出前释放。这个错误很隐蔽平时连接数少看不出来一旦现场设备频繁重连就会出现两个线程同时处理同一个文件描述符的诡异现象。3.2 数据区刷新机制轮询、锁与实时性平衡Server 可以不依赖后台线程请求来了再读设备但如果你的数据源是另一台 PLC 或者一堆仪表通常需要有一个轮询线程先把数据抓进本机寄存器表。源码里数据区会预分配一块连续数组线程按固定周期刷新。常见做法是pthread_mutex_lock(reg_lock); for (int i 0; i poll_count; i) { uint16_t addr poll_items[i].addr; reg_table[idx_of(addr)] read_device_raw(addr); } pthread_mutex_unlock(reg_lock); usleep(200 * 1000);poll_count不是越大越好。有些工程师把上位机点表里所有寄存器都放进去轮询结果每个点都要通过串口或网口去问一轮下来几百毫秒甚至几秒。我的做法是把点位分成快慢两组变化频繁的阀门开度、实时功率放快组刷新周期 100ms设备型号、累计运行时间这类不怎么变的放慢组刷新周期 500ms 或者 1s。锁的范围也尽量小只锁“复制到 reg_table”这一段不要把read_device_raw耗时操作也锁住否则客户端写寄存器时可能会被长时间阻塞。实时性要求的另一面是写入冲突。如果同一个寄存器既被轮询线程更新又被 0x06 写单个寄存器改写你需要在写处理函数里要么跳过刷新、要么用独立缓存。源码如果没有做这个细节我会在 0x06 处理完以后设置一个 dirty 标记轮询线程发现标记就暂停写回对应的地址避免设备端数据被马上覆盖。3.3 Python客户端联调用最原始的socket过一遍四个功能码联调时我不用第三方库直接用 Python socket 拼 MBAP 头这样报文哪里错了一眼就能跟抓包对应上。下面这段代码覆盖最常用的两个操作0x03 和 0x06import socket, struct def recv_exact(sock, n): data b while len(data) n: chunk sock.recv(n - len(data)) if not chunk: raise ConnectionError(connection lost) data chunk return data def build_request(tx_id, unit, func, payload): # MBAP头: 事务ID 协议ID(0) 长度 单元ID pdu struct.pack(B, func) payload mbap struct.pack(HHHB, tx_id, 0, len(pdu) 1, unit) return mbap pdu def parse_response(sock): head recv_exact(sock, 7) tx_id, proto, length, unit struct.unpack(HHHB, head) body recv_exact(sock, length - 1) if body[0] 0x80: # 最高位置1表示异常响应 raise RuntimeError(exception code: %d % body[1]) return tx_id, unit, body[0], body[1:] s socket.create_connection((127.0.0.1, 1502), timeout3) # 0x03 读保持寄存器: 可读地址40001 - 协议地址0 s.send(build_request(0x0100, 1, 0x03, struct.pack(HH, 0, 1))) print(read 40001:, parse_response(s)) # 0x06 写单个寄存器: 可读地址40002 - 协议地址1, 写入100 s.send(build_request(0x0101, 1, 0x06, struct.pack(HH, 1, 100))) print(write 40002:, parse_response(s)) s.close()重点看struct.pack(HH, 0, 1)这里第一个 0 是协议地址如果客户端直接传 40001MBAP 长度就会变成错误报文。parse_response里判断body[0] 0x80是通用的正常功能码最高位是 0异常响应会把功能码置最高位并跟一个异常码。如果读到异常码 0x02先检查地址范围读到 0x03通常是数据字段格式不对或者半包解析导致。0x10 写多个寄存器的验证思路一样只是在 payload 里多了一个字节计数字段。你可以在本地把四个功能码都跑通再上现场这一步能过滤掉至少一半的联调问题。这里每次调用build_request的 tx_id 我习惯递增这样后端日志能看出是哪条命令对应的哪个响应。虽然规范允许固定发送但现场有网关时固定值容易造成请求和响应错配。3.4 抓包定位Wireshark识别MODBUS TCP的两个前提客户端测试不通过时抓包是最快的定位手段。装上 Wireshark 后要注意两个前提否则很多包显示成普通 TCP 数据而不是 Modbus/TCP。第一件事看端口标准 502 端口 Wireshark 会自动识别端口改成 1502 后需要右键包列表选择 Decode As再指定为 MODBUS TCP。第二件事确认 MBAP 的协议标识确实是 0x0000以及长度字段和实际 PDU 匹配。长度算错时强制解码也会把后续的包切得乱七八糟。命令行环境可以用 tcpdump 配合 hex 输出sudo tcpdump -i eth0 -nn -s 0 port 1502 -X看十六进制的时候重点看前 4 字节前 2 字节是事务 ID后 2 字节是长度。如果请求包长度和响应包长度相差很多优先怀疑 Server 在构造响应时把整包长度算错了。抓包还有一个好处是可以对比重复的 tx_id能快速发现是不是有多线程在处理同一个连接时把顺序打乱。4. 避坑五个晚上加班的典型翻车点协议本身不复杂真正让人加班的是 TCP 流带来的边界问题和地址映射的约定问题。下面这五个坑我都在现场遇到过按现象、原因、解决的顺序整理你对照自己的症状查能省不少时间。4.1 半包粘包一帧当成两帧两帧挤成一帧现象客户端读 10 个寄存器Server 日志里解析失败或返回异常码 0x03一个写请求后面紧跟一个读请求时第二个请求开始错位频繁读写一段时间后连接断开。原因TCP 是字节流不保证一次 recv 就收到一个完整报文。现场上位机经常把多个请求连续发出Server 端一次 recv 可能收到两帧也可能收到半帧。如果业务代码直接用 recv 返回值当成一帧来处理必然随机出错。解决接收缓冲必须支持“追加 边界检查”。使用 2.1 节里的check_frame完整帧才交给handle_pdu处理完把已消费部分从缓冲前移while (1) { int n recv(fd, buf used, sizeof(buf) - used, 0); if (n 0) break; used n; int pdu_len 0; int r check_frame(buf, used, pdu_len); if (r 1) { handle_pdu(buf 7, pdu_len); int total 7 pdu_len; used - total; memmove(buf, buf total, used); } else if (r 0) { break; /* 协议非法, 按异常连接处理 */ } }这个循环里的memmove开销很小因为每次消费后剩下的字节通常不多。注意使用memmove而不是memcpy两个缓冲区重叠时行为才有保证。我第一次实现时图省事用memcpy调试了半个小时错位问题最后发现是 C 标准里不允许重叠区域用memcpy。这类问题一般用户态跑着不崩溃但逻辑已经错了。4.2 地址偏移点表地址与数组下标的1之差现象点表上写着液位40001客户端发起点读请求时用地址 40001拿到的是第二台泵的转速或者 Server 直接返回异常码 0x02。原因点表给人看的 40001 是“可读地址”报文里的地址字段按 0 起始。40001 对应报文地址 040002 对应 1。如果你在客户端里把 40001 塞进协议字段服务端按“协议地址数组下标”去映射读到的自然错位一位。有些工程点位几百个时这种错误根本看不出来。解决在服务端统一做一次减法把可读地址转成数组下标。建议和维护手册里约定“协议字段统一使用 0 起始的偏移量”并保留一个映射函数集中管理。也有少数厂商点表直接以 0 开头这时候不需要减一所以源码最好不要写死给一个addr_base配置项现场改了就能用。我的习惯是在映射函数里打日志前 10 条请求打印“addr40001 - idx0”确认无误后再关掉日志。4.3 字节序浮点数翻倍、负数变正数的源头现象读一个 16 位整数读到 0x3412 而现场仪表显示 0x123432 位浮点值变成负数、NaN或者数值比实际大一万倍。原因MODBUS 协议本身是大端但不少仪表固件内部是小端还有一部分 PLC 在多个寄存器组合浮点时把 word 顺序调换。数据链路层把寄存器原样搬上来后应用层必须做一次字节序适配。解决在应用层做统一转换不要指望每个功能码分支都去调。常见做法是提供两个工具函数uint16_t be16(const uint8_t *p) { return ((uint16_t)p[0] 8) | p[1]; } uint32_t be32_regs(uint16_t hi, uint16_t lo) { return ((uint32_t)hi 16) | lo; }如果设备文档写明寄存器字节序按照“CD AB”存储那就在解析完 16 位后把高低字节互换。32 位浮点更麻烦因为还涉及两个寄存器的先后顺序。我第一次接水表的时候文档只写了“浮点占两个寄存器”没写 word order实际读回来值全部接近 0。后来把(hi, lo)换成(lo, hi)数值才恢复正常。这个适配建议单独开一个值转换层不要放在协议分发里方便点位表逐个覆盖。4.4 连接假死断电了Server还当对方在线现象现场设备断电重启Server 还显示连接在线新客户端连进来后日志里的请求越来越少最后所有请求都超时。用netstat看一堆 ESTABLISHED 连接挂着不释放。原因TCP 断连不会主动通知服务端。设备掉电时对端没有机会发 FIN/RST服务端读操作会一直阻塞直到系统内核触发了 TCP 重传超时这个过程可能长达几分钟甚至更久。如果每个连接占一个线程且没有超时设置假死连接会把线程池耗尽。解决给每个 socket 配置读超时和 keepalivestruct timeval tv { .tv_sec 30, .tv_usec 0 }; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv)); int keepalive 1; setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, keepalive, sizeof(keepalive));读超时时间不宜太短有些 PLC 的响应间隔会超过 5 秒太短会误杀正常连接。我的默认值是 30 秒超过 30 秒连一个完整请求都收不到可以直接判定对端异常。同时要配合限制最大连接数防止异常网关把资源占光。顺带提一句SO_KEEPALIVE 只能保证内核定期探测对端不是应用层心跳它的探测频率默认是 2 小时所以不能替代读超时。4.5 端口权限502在Linux上起不来的真实原因现象本地用 1502 测试正常换到现场标准 502 后 Server 启动失败报 bind: Permission denied另一台 Windows 电脑上 502 能启动但局域网里的其他设备连不上。原因502 小于 1024Linux 下只有 root 或具有 CAP_NET_BIND_SERVICE 权限的进程才能绑定Windows 则是防火墙默认拦住了入站连接。解决先用sudo验证 502 能起确定源码本身没问题。生产环境如果必须用非 root 运行可以用setcap给二进制单独授权sudo setcap cap_net_bind_serviceep ./modbus_tcp_serverWindows 那边需要在防火墙高级设置里放行 TCP 502 端口最好是放行程序而不是只放端口避免换端口后又失效。如果你把服务部署在容器里还要在宿主机和安全组两层同时放开 502缺一层都会表现为“外面连不上本机 curl 正常”。这个问题不是源码逻辑错排查方向错了很浪费时间。5. 连上只是开始把 Server 改成能自己报状态的设备能读能写只是及格线。现场调试最头疼的问题是上位机说读不到数据到底是 Server 崩了、网线断了、还是寄存器没刷新我后来养成了一个习惯在源码的数据区里留出一段“健康状态寄存器”让 Server 把自己当成一个设备暴露出来。寄存器偏移含义示例值0进程启动后的运行分钟数1281当前活动连接数12累计收到请求数56783累计异常响应次数24最后一次错误码0实现很简单后台线程每秒钟把这些字段写入固定位置。上位机只要周期性读这几个寄存器就能知道 Server 本身是不是活着。这个技巧同样可以用在源码的日志增强上给功能码处理函数加一个耗时统计超过 200ms 的请求单独打一行。我见过很多现场“偶发卡顿”通过这个统计能定位到是某个地址段刷新太慢而不是网络问题。日志统计也可以交给脚本做假设源码默认日志格式里第四列是功能码、第五列是异常码awk {print $4} /var/log/modbus_tcp_server.log | sort | uniq -c | sort -rn如果 0x03 的异常次数很多优先查地址范围0x06 异常多查写入的数据长度是否和字节数声明一致。这类统计在交付后的前两周特别有用等运行平稳了再关掉也不迟。我把这个习惯保留到了所有 Server 项目里拿到任何一版 TCP 源码先过三个检查——长度字段会算吗地址偏移量是减一还是不减0x03 回环测试跑通了吗三件事不过完不接现场。连上位机的人容易忽略这些细节但翻车点往往就在这里。希望帮到你。本文还有配套的精品资源点击获取