
简介这是一份用C语言实现的Modbus TCP协议栈源码面向工业自动化、嵌入式系统及物联网网关开发者主要解决设备通过以太网进行远程数据采集与控制的通信问题。整个压缩包非常精简仅包含一个C源文件大小约五KB代码轻量且无复杂依赖适合放入资源受限的单片机项目也可作为学习Modbus TCP协议的入门范本。源码覆盖了Modbus TCP客户端与服务器端的基础功能包括TCP连接建立与关闭、MBAP报文头解析、常用功能码的请求构造与响应处理以及超时与错误返回等逻辑函数划分清晰便于阅读和二次开发。开发者既可整体移植到自有平台也能使用Wireshark抓包对比源码深入理解Modbus报文与TCP交互细节。目前已有三百六十四人学习适合具备C语言与网络基础知识、希望在项目中快速集成Modbus TCP通信能力的工程技术人员。1. 一个 zip 压缩包里最常见的 modbus-tcp 场景modbus-tcp.c.zip这类命名在工控项目文件传递里很常见一个 C 源文件、几个功能码宏定义、一段 TCP 收发逻辑打个包传给现场做设备接入。它的职责很纯粹用普通 socket 把 PLC、仪表、网关里的寄存器和线圈读出来、写进去不依赖专用库能在 Linux 上位机上跑也能裁剪后移植到单片机。这套东西对三类人最有用做设备接入的嵌入式工程师写上位机控制软件但不想引入 libmodbus 这类重依赖的开发者以及被三菱 FX5U、西门子 S7 等厂家私有协议折腾过、想回到标准 Modbus TCP 报文上的现场调试人员。下文按报文结构、主站实现、从站联调、抓包排错的顺序把标题背后这条最常见的技术路线完整讲一遍。2. Modbus TCP 报文结构与 TCP 承载下的断帧逻辑2.1 去掉 CRC 后的 MBAP 头4 个字段逐个拆开Modbus RTU 在串口上靠 CRC16 校验保证一帧数据不被线路噪声破坏而 Modbus TCP 跑在 TCP 之上校验、重传、顺序保证都由 TCP 协议栈承担所以协议里不再有 CRC 字段。取而代之的是一个 7 字节的 MBAP 头Modbus Application Protocol Header它解决的是另一个问题TCP 是字节流没有串口那种天然的帧边界接收方必须知道一帧从哪开始、到哪结束。MBAP 头包含四个字段事务标识符Transaction ID2 字节、协议标识符Protocol ID2 字节固定填 0、长度Length2 字节表示 Unit ID 加 PDU 的总字节数、单元标识符Unit ID1 字节等价于 RTU 里的从站地址。事务标识符由主站生成从站原样带回主站靠它把响应和请求配对协议标识符只要不是 0这一帧就不是 Modbus 报文Unit ID 在直连设备时通常填 1只有经过网关下挂多台从站时才需要区分。字段字节数请求帧典型值解析要点Transaction ID20x0001每帧递增响应必须原样带回主站据此配对Protocol ID20x0000非 0 说明不是标准 Modbus 报文Length20x0006从 Unit ID 开始计数含 PDU不含 MBAP 自身Unit ID10x01直连设备填 1过网关时填从站地址这里最容易出错的是 Length 的计数范围。读 2 个保持寄存器的请求整帧是 12 字节但 Length 字段写的是 0x0006因为前 6 字节Transaction ID 2 字节 Protocol ID 2 字节 Length 自身 2 字节不计入。第一次抓包看到这个不一致不用疑惑解析代码也只认 Unit ID 之后的长度这一点记牢能避开后面第 5 章讲的高频坑。2.2 功能码与 PDU从读到写的报文形状MBAP 之后是 PDUProtocol Data Unit格式为功能码 数据。功能码决定这一帧是读还是写、操作对象是什么0x01 读线圈、0x03 读保持寄存器、0x04 读输入寄存器、0x05 写单个线圈、0x06 写单个寄存器、0x10 写多个寄存器。PDU 内所有多字节数值一律大端序高字节在前这一点和 x86 内存里的小端序正好相反组包时一定要手动移位。读保持寄存器的请求 PDU 是03 00 00 00 02依次是功能码、起始地址、寄存器数量。拼上 MBAP 头之后完整请求帧是00 01 00 00 00 06 01 03 00 00 00 02。功能码最高位置 1 表示异常响应例如83 02就是 FC3 请求被从站回了个非法数据地址。异常码的含义要记熟0x01 非法功能、0x02 非法数据地址、0x03 非法数据值、0x04 从站设备故障。上位机收到异常码不要直接丢弃把功能码、寄存器地址、异常码拼成一行日志打出来现场排查能省一半时间。单帧读保持寄存器最多 125 个写多个寄存器最多 123 个超出上限从站会直接回异常。2.3 半包与粘包按 Length 切帧的标准写法Modbus TCP 和很多请求-响应协议不同它没有结束符也不能像 RTU 那样靠帧间空闲间隔判断一帧结束。TCP 可能把多个请求粘在一个段里一次到达也可能把一个请求拆成几个段分多次到达所以收发两端都必须维护缓冲区按 MBAP 头里的 Length 字段切帧。/* 判断 buf 里是否已凑齐一帧完整 Modbus TCP 报文凑齐返回整帧长度否则返回 0 */ int modbus_frame_length(const unsigned char *buf, int len) { if (len 6) return 0; /* MBAP 头都还没收齐继续等 */ int pdu_len (buf[4] 8) | buf[5]; /* Length 字段Unit ID PDU */ int total 6 pdu_len; /* 整帧长度 MBAP 头 6 字节 Length */ if (len total) return 0; /* 半包返回 0 表示需要追加数据 */ return total; /* 返回整帧长度剩余字节属于下一帧 */ }这段逻辑是 Modbus TCP 解析器的地基先等够 6 字节拿到 Length再用 6 Length 判断整帧是否到齐。缓冲区里超出整帧长度的部分要保留给下一帧常见做法是把剩余字节 memmove 到 buffer 头部然后继续循环收包。TCP_NODELAY 建议在建立连接后立刻置 1否则小报文会被 Nagle 算法延迟合并一个 12 字节的读请求可能多等 40ms 才发出去轮询周期短的上位机能明显感到节奏被打乱。3. 用 C 语言实现 modbus-tcp 主站的最小可运行代码3.1 50 行读保持寄存器组包、发送、解析主站客户端只做三件事建立 TCP 连接、按 MBAP PDU 组包、解析响应。下面的例子在 Linux 上用 raw socket 实现读保持寄存器不依赖任何第三方库编译只要一条命令gcc -Wall -o modbus_client modbus_client.c。#include stdio.h #include string.h #include unistd.h #include arpa/inet.h #include sys/socket.h #include sys/time.h #define PORT 502 #define UNIT_ID 1 /* 组一帧 FC3 读保持寄存器请求tid 为事务号addr 为起始地址count 为个数 */ int build_read_req(unsigned char *buf, int tid, int addr, int count) { buf[0] tid 8; /* Transaction ID 高字节 */ buf[1] tid 0xFF; /* Transaction ID 低字节 */ buf[2] 0x00; /* Protocol ID 固定 0 */ buf[3] 0x00; buf[4] 0x00; /* Length 高字节 */ buf[5] 0x06; /* Unit ID 1 功能码 1 地址 2 数量 2 */ buf[6] UNIT_ID; buf[7] 0x03; /* 功能码读保持寄存器 */ buf[8] addr 8; buf[9] addr 0xFF; buf[10] count 8; buf[11] count 0xFF; return 12; } int main(void) { int fd socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in sa; sa.sin_family AF_INET; sa.sin_port htons(PORT); inet_pton(AF_INET, 192.168.1.10, sa.sin_addr); if (connect(fd, (struct sockaddr *)sa, sizeof(sa)) 0) { perror(connect); return 1; } int one 1; setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, one, sizeof(one)); unsigned char req[12], rsp[260]; int len build_read_req(req, 0x0001, 0, 2); /* 读地址 0 开始的 2 个寄存器 */ send(fd, req, len, 0); int n recv(fd, rsp, sizeof(rsp), 0); /* 简化处理只收第一次返回 */ if (n 0 (rsp[7] 0x80) 0) { int bytes rsp[8]; /* 数据字节数 寄存器数 * 2 */ for (int i 0; i bytes / 2; i) { int val (rsp[9 i * 2] 8) | rsp[10 i * 2]; /* 大端拼接 */ printf(reg[%d]0x%04X (%d)\n, i, val, val); } } close(fd); return 0; }组装函数里前 6 字节是 MBAP 头buf[6]是 Unit IDbuf[7]开始是 PDU。事务 ID 在示例里手动填 0x0001实际工程中要做到每发一帧递增。收包后第一件事是检查rsp[7] 0x80最高位为 1 表示异常响应此时数据区只有一个异常码字节按正常格式解析会得到负数或错乱值。寄存器数据从rsp[9]开始两字节一组(rsp[9 i*2] 8) | rsp[10 i*2]是大端拼接的标准写法。目标地址用inet_pton转换注意它只接受点分十进制字符串工业现场最常见的错误就是把 IP 和端口写反或者传了主机名进去。参数上需要注意三点PORT 默认 502Linux 普通用户连接目标端口 502 没有权限限制只有监听 1024 以下端口才需要 rootcount 最大 125超过 125 从站直接回异常码 0x02rsp[8]这个字节是数据字节数不是寄存器个数很多人第一次解析就栽在这里。抓包验证可以用tcpdump -i eth0 port 502 -X把发出和收到的十六进制抓出来和代码里的 buf 逐字节比对。3.2 超时、重连与事务 ID从示例走向可用的关键裸 recv 会永久阻塞PLC 没开机或者网线被拔掉时程序会一直卡死在 recv 上。解决方式是给 socket 设置 SO_RCVTIMEO读超时设为 500ms 到 1s。超时后 recv 返回 -1errno 为 EAGAIN 或 EWOULDBLOCK此时应该记录一次失败并关闭连接而不是原地重试。struct timeval tv { .tv_sec 1, .tv_usec 0 }; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv)); int ret recv(fd, rsp, sizeof(rsp), 0); if (ret 0 (errno EAGAIN || errno EWOULDBLOCK)) { close(fd); /* 超时后不能复用旧 fdTCP 半开连接无法靠 send 探测 */ /* 重新 socket connect失败计数 1连续 3 次后进入重连节奏 */ }参数推荐值说明SO_RCVTIMEO500ms ~ 1s小于 PLC 程序扫描周期会误报超时重连间隔1s ~ 3s建议指数退避避免日志刷屏失败阈值3 次连续 3 次失败再触发重连容忍偶发抖动事务 ID 管理有一个隐蔽问题响应可能乱序到达。上一请求超时但旧响应还在路上此时重发了新请求收到的响应 tid 和当前请求对不上。如果直接当成当前结果解析数据会错位且没有任何报错。正确做法是维护一个期望的 tid收到响应先比对不匹配就丢弃继续 recv直到超时或收到匹配帧。Modbus Poll 这类测试工具内部就是tid 递增 超时重试 不匹配丢弃这套逻辑自己实现主站时照着抄就行。3.3 字节序与 32 位浮点的字序陷阱Modbus 协议规定寄存器值按大端序传输但设备厂商经常在此基础上再做一次字序交换。同一个 32 位浮点数西门子和三菱的寄存器排列顺序可能正好相反典型是 ABCD 和 CDAB 两种。处理办法是不改协议解析层在应用层加一个可配置的字序交换开关。/* 读取 32 位浮点先按设备文档给的顺序把 4 个字节排好再取 float */ union { float f; unsigned char b[4]; } val; val.b[0] rsp[9]; /* 低地址寄存器的低字节按收到顺序排列 */ val.b[1] rsp[10]; val.b[2] rsp[11]; val.b[3] rsp[12]; float result val.f; /* 若设备是 CDAB则先交换 b[0]/b[2] 和 b[1]/b[3] */关键点在于字节序处理必须发生在 union 赋值之前先把四个字节按设备手册给的顺序填好再整体读出 float。反过来先转 float 再倒字节序符号位会被打乱而且不会报任何错排查起来非常痛苦。联调时第一步先读一个已知的整数常量确认地址偏移第二步读一个已知浮点常量确认字序两步分开验证出问题时定位成本最低。4. 从站模拟与三菱 FX5U 联调端口、单元号和字节序4.1 用纯 socket 写一个最小从站主站写完先别急着接真机先用从站把收发链路验证一遍。下面这段是从站的核心循环收到 FC3 读请求后回一组假数据。真正的产品级从站要处理异常码回复和并发连接单线程 accept 后为每个连接开线程即可核心逻辑不变。while (1) { int n recv(cfd, buf, sizeof(buf), 0); if (n 0) break; int len (buf[4] 8) | buf[5]; /* Length 字段 */ if (n 6 len) continue; /* 半包继续收与主站是同一套断帧逻辑 */ unsigned char *pdu buf 7; /* Unit ID 之后是 PDU 起点 */ int fc pdu[0]; if (fc 0x03) { int addr (pdu[1] 8) | pdu[2]; int cnt (pdu[3] 8) | pdu[4]; rsp[0] buf[0]; rsp[1] buf[1]; /* Transaction ID 原样带回 */ rsp[2] 0x00; rsp[3] 0x00; /* Protocol ID */ rsp[4] 0x00; rsp[5] cnt * 2 3; /* LengthUnitID FC 字节数 数据 */ rsp[6] buf[6]; /* Unit ID 原样带回 */ rsp[7] 0x03; rsp[8] cnt * 2; /* 数据字节数注意不是寄存器个数 */ for (int i 0; i cnt; i) { rsp[9 i * 2] 0x00; rsp[10 i * 2] addr i; /* 回一个递增的假数据 */ } send(cfd, rsp, 9 cnt * 2, 0); } }从站比主站少了很多状态管理核心规则只有两条事务 ID 原样带回Length 按响应实际内容重新计算。特别要注意rsp[8]是数据字节数FC3 响应里它是寄存器个数乘以 2整帧响应长度是9 cnt * 2。很多从站实现写错就在这里把rsp[8]当成寄存器个数去填 Length导致主站端永远等不齐一帧表现为周期性的接收超时和重发。4.2 用 Modbus Slave 和 Modbus Poll 搭测试环境主站程序先连 PC 上跑的 Modbus Slave 验证组包再连真机这是最稳妥的联调顺序。Modbus Slave 监听端口设 502Unit ID 设 1配置一段连续保持寄存器区域主站连 127.0.0.1 跑通读写协议栈部分就算排除了。反过来验证自己实现的从站用 Modbus Poll 当主站去读能读到假数据说明响应组包正确。这两个 modbus 测试工具的试用模式足够覆盖联调验证场景。需要注意 Modbus Poll 的 Display 菜单里有 Word swap 和 Byte swap 选项勾选不同选项时显示的十六进制完全不同模拟数据前要先确认两端的显示字节序一致否则很容易误判主站代码有 bug。测试工具本身只反映协议层行为最终功能正确与否仍然以抓包为准。4.3 三菱 FX5U 侧配置清单三菱 FX5U 内置以太网口直接支持 Modbus TCP 从站功能不需要扩展模块。在 GX Works3 里要核对三个位置缺一个都会导致通信失败配置项位置常见错误端口以太网端口设置TCP 502改成自定义端口后没在主站同步Unit IDModbus 从站设置FX5U 默认 0主站报文填 1 两边对不上寄存器映射D 寄存器偏移地址D0 不一定对应地址 0需查固件手册FX5U 默认字序是 CDAB与西门子的 ABCD 相反。32 位浮点存在相邻的两个 D 寄存器里换算方式是高地址寄存器的两个字节放进 32 位值的高 16 位低地址寄存器的两个字节放进低 16 位。第一次接 FX5U先读一个整数寄存器确认地址偏移再读浮点常量确认字序两次都通过之后再写业务逻辑能少走很多弯路。现场最常见的连接失败原因是端口被占用Windows 用netstat -ano | findstr :502Linux 用ss -lntp | grep 502查看。调试阶段建议从站工具监听 5020 端口避免和真机的 502 冲突联调收尾时再改回正式端口。5. 抓包验证与 3 个高频坑事务 ID、半包和重连5.1 tcpdump Wireshark 验证每一帧验证 Modbus TCP 实现正确与否唯一权威手段是抓包。Linux 上执行tcpdump -i eth0 port 502 -w modbus.pcap收一段时间把 pcap 文件拖进 Wireshark它自带 Modbus TCP 解析器MBAP 各字段、寄存器值、异常码全部可视化直接和组包代码里的 buf 十六进制比对。如果请求与响应间隔超过 100ms 且确认不是设备处理慢先怀疑 Nagle 合并导致的延迟再看链路里是否插了额外的网关设备。抓包还能暴露一个隐藏问题响应慢导致重发请求与旧响应在链路上交叉事务 ID 一旦重复就会出现串线Wireshark 里能看到同一个 tid 出现两次。/* 收到响应后先比对事务 ID不一致则丢弃继续 recv 直到超时 */ if ((rsp[0] 8 | rsp[1]) ! expected_tid) { continue; /* 旧响应或乱序响应不能当作当前请求结果解析 */ }5.2 高频坑一事务 ID 固定不递增单请求单响应场景下 tid 填 0 也能跑通但一旦加上超时重发就必须递增。重发时复用旧 tid会收到上一次的迟到响应并被误判为本次结果数据错位且没有任何报错。规则只有一句请求方 tid 每帧递增从站原样带回主站按 tid 配对。5.3 高频坑二Length 用错导致粘包断帧Length 从 Unit ID 开始计数读 2 个寄存器的请求 Length 是 6整帧是 12 字节。解析最稳的写法就是第 2 章那段modbus_frame_length先等 6 字节拿到 Length再用 6 Length 判断整帧。响应 Length 要按功能码分别计算FC3 响应是3 2*countFC5 写单线圈响应固定是 6异常响应是 3。统一套一个公式必出错接三菱 FX5U 时异常帧尤其常见解析前务必先做0x80异常检查。5.4 高频坑三半开连接与重连PLC 断电重启后主站 socket 可能还处于 ESTABLISHED 状态此时 send 会成功因为数据进了内核缓冲区但 recv 永远等不到响应。检测半开连接要靠业务层recv 超时即事务失败连续 N 次失败就 close fd 重新 connect。SO_KEEPALIVE 默认两小时探测一次对秒级轮询场景毫无意义不要拿它做业务检测。重连时注意 TIME_WAIT 状态调试期可以临时打开net.ipv4.tcp_tw_reuse1但产品代码不应依赖系统参数而是每次重连都新建 socket。排查顺序固定下来能省一半时间先抓包看请求是否发出再对 tid 是否递增第三步查 Length 是否按功能码分支计算第四步核对 Unit ID 与 PLC 配置最后读 float 寄存器前先验证字序。五步走完抓包还对不上问题基本出在设备侧参数配置上。本文还有配套的精品资源点击获取