资讯详情

Modbus TCP C语言实现:主从站开发与避坑指南

📅 2026/9/13 16:13:49 | 华诺云谱 👁 阅读
Modbus TCP C语言实现:主从站开发与避坑指南
简介一个基于C语言的Modbus TCP协议实现源码包面向工业自动化、嵌入式系统开发者常用于PLC、数据采集设备及远程监控模块的以太网通信开发Modbus TCP把传统Modbus报文封装进TCP/IP包中使设备能跨局域网或互联网可靠交换数据。压缩包内仅含1个C语言源文件包体约5KB代码紧凑便于移植到各类嵌入式工程但通常涵盖客户端/服务器端连接建立、Modbus请求组帧与解析、响应处理及基础错误处理等核心路径。已有364人学习适合需要快速上手Modbus TCP底层实现的开发者通过阅读源码可掌握C语言下Socket编程与Modbus数据帧构造的关键手法。配合Wireshark抓包分析还能进一步验证报文格式和通信时序为开发基于以太网的工业通信模块打下基础。若要在STM32、Linux或Windows等平台移植该单文件实现也提供了清晰的裁剪入口。1. 为什么一个 modbus-tcp.c.zip 值得拆开看modbus-tcp.c.zip 在工控源码分享里很常见解开后核心文件通常就是一个 modbus-tcp.c加两个头文件就能编译。可很多工程师把这套最小代码直接拖进产品工程连三菱 FX5U 时经常出现 connect 成功但读不回寄存器、或者响应报文被丢掉的情况于是开始怀疑硬件、怀疑协议栈。事实上Modbus TCP 的大部分坑不在 TCP 三次握手而在 MBAP 头那 7 个字节、Length 字段的取值以及寄存器寻址和数量上限。这里把 C 实现 Modbus TCP 最常踩的一串约束讲清楚7 字节 MBAP 头怎么按字节拼接recv 怎么收满一帧03 / 06 / 10 功能码的请求长度分别怎么算unit id 和 40001 偏移在真实 PLC 上如何处理。照文中的最小代码改主站和从站都能在 Linux 开发板上直接跑通。2. 先把 Modbus TCP 帧结构吃透MBAP 头与 PDU 的 C 语言映射2.1 7 字节 MBAP 头是第一个分水岭Modbus TCP 与 Modbus RTU 最大的区别在帧格式。RTU 需要设备地址和 CRC而 Modbus TCP 把传输控制交给 TCP 层只保留一个 MBAP 头。每个完整报文由 MBAP 头7 字节和 PDU 组成。MBAP 有四个字段事务标识符、协议标识符、长度、单元标识符。事务标识符由主站生成每发一个请求就递增响应报文原样带回协议标识符固定为 0x0000长度字段从单元标识符开始数一直到报文结束单元标识符相当于 RTU 的从站地址用于在一条 TCP 连接上区分多个从站。字段关系如下表。表里我特意把 Length 的取值说明单独列出因为这是最容易写错的地方。字段长度方向说明Transaction Identifier2 字节请求/响应每次请求 1响应原样返回Protocol Identifier2 字节请求/响应Modbus/TCP 固定为 0x0000Length2 字节请求/响应表示后面 Unit ID PDU 的字节总数Unit Identifier1 字节请求/响应从站地址单站时常用 1 或 0xFFFunction Code1 字节PDU 起始如 0x03、0x06、0x10Data可变PDU寄存器地址、数量、实际数据等不要把 Length 和整个 TCP 报文长度混在一起。比如读保持寄存器请求的 Length 是 0x0006但整个 TCP payload 是 12 字节两者相差的就是 MBAP 头前 4 个字节加 Length 自身 2 字节。2.2 最稳的 C 映射是 uint8_t 数组而不是结构体有人习惯把 MBAP 头定义成#pragma pack(1)的结构体然后直接把结构体指针交给send和recv。这样在 x86 和小端 ARM 上大多数时候能跑但遇到严格字节序、未对齐访问或跨编译器时很容易出现字段错位。更稳的做法是把整个 ADU 定义成uint8_t数组用宏或常量定位字段偏移发送前手动填充。#include stdint.h #define MBAP_TID_HI 0 #define MBAP_TID_LO 1 #define MBAP_PID_HI 2 #define MBAP_PID_LO 3 #define MBAP_LEN_HI 4 #define MBAP_LEN_LO 5 #define MBAP_UNIT 6 #define PDU_FUNC 7 #define ADU_MAX_LEN 260 static void put_be16(uint8_t *buf, uint16_t val) { buf[0] (uint8_t)(val 8); buf[1] (uint8_t)(val 0xff); } static uint16_t get_be16(const uint8_t *buf) { return (uint16_t)((buf[0] 8) | buf[1]); }put_be16和get_be16只做一件事把 16 位数值拆成或拼成大端字节序。Modbus TCP 所有多字节字段都是大端包括事务标识符、协议标识符、Length、寄存器地址、寄存器数量、寄存器数值。如果把uint16_t变量直接按字节转进 buffer在 little-endian 平台上字段顺序就是反的。2.3 为什么 03 功能码的请求 Length 固定是 6以读保持寄存器功能码 0x03为例PDU 内容是功能码 1 字节、起始地址 2 字节、寄存器数量 2 字节一共 5 字节。MBAP 的 Length 从单元标识符开始数单元标识符占 1 字节再加 PDU 的 5 字节Length 一定是 0x0006。这也是设备判断一帧请求边界的重要依据。看一组实际报文会更清楚。请求读从地址 0 开始的 2 个保持寄存器完整请求字节如下00 01 00 00 00 06 01 03 00 00 00 02第 0 字节到第 1 字节是事务 ID 0x0001第 2 字节到第 3 字节是协议 ID 0x0000第 4 字节到第 5 字节是 Length 0x0006第 6 字节是单元 ID 0x01第 7 字节是功能码 0x03第 8 字节到第 9 字节是起始地址第 10 字节到第 11 字节是寄存器数量。响应报文则类似00 01 00 00 00 07 01 03 04 12 34 56 78响应里 Length 是 0x0007对应单元 ID 1 字节、功能码 1 字节、字节数 1 字节、寄存器数据 4 字节。如果从站返回的 Length 不符合这个规律主站要么等不到完整报文要么把下一帧请求的数据误当成当前响应导致事务 ID 错乱。所以写 C 时校验 Length 是必不可少的一步。3. 用 C 写可用的 Modbus TCP 主站/从站socket 处理与请求响应流程3.1 先用 connect 完成 TCP 三次握手Modbus TCP 的传输层还是普通 TCP建连就是一次三次握手。直接看代码#include sys/socket.h #include netinet/in.h #include arpa/inet.h #include string.h #include unistd.h int modbus_tcp_connect(const char *ip, uint16_t port) { int fd socket(AF_INET, SOCK_STREAM, 0); if (fd 0) return -1; struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(port); if (inet_pton(AF_INET, ip, addr.sin_addr) ! 1) { close(fd); return -1; } if (connect(fd, (struct sockaddr *)addr, sizeof(addr)) 0) { close(fd); return -1; } return fd; }这段代码socket创建 TCP socketinet_pton把点分 IP 转成二进制地址connect发起三次握手。端口一般情况下填 502。如果连接失败先检查设备有没有启用 Modbus 服务再看防火墙是否放行 502 端口。Linux 下 CentOS 用firewall-cmd --list-ports查Ubuntu 用ufw status查。这些问题和 Modbus 协议本身无关但会卡住联调进度。3.2 主站读保持寄存器的最小函数下面这个函数建立好连接后可以反复调用。它发送 03 功能码请求读取连续保持寄存器并把结果放到regs数组里。代码里最关键的是recv必须循环收因为 TCP 是流式协议一次recv返回的可能是半包也可能是多个报文粘在一起。int modbus_read_holding_regs(int fd, uint8_t unit, uint16_t start, uint16_t count, uint16_t *regs) { uint8_t req[12]; uint8_t buf[7 255]; static uint16_t tid 0; ssize_t n; size_t got; if (count 0 || count 125) return -1; tid; put_be16(req[0], tid); /* Transaction ID */ put_be16(req[2], 0); /* Protocol ID */ put_be16(req[4], 6); /* Length */ req[6] unit; req[7] 0x03; put_be16(req[8], start); put_be16(req[10], count); if (send(fd, req, sizeof(req), 0) ! sizeof(req)) return -2; got 0; while (got 7) { n recv(fd, buf got, 7 - got, 0); if (n 0) return -3; got n; } int pdu_len get_be16(buf[4]); while (got 7 pdu_len) { n recv(fd, buf got, 7 pdu_len - got, 0); if (n 0) return -3; got n; } if (buf[6] ! unit || buf[7] ! 0x03) return -4; if (buf[8] ! count * 2) return -5; for (int i 0; i count; i) regs[i] get_be16(buf[9 i * 2]); return 0; }组装请求时 Length 写死为 6这是 03 功能码固定值。接收时第一阶段先收满 7 字节 MBAP 头第二阶段用响应里的 Length 字段确定后续总长度继续收满。这里的pdu_len是响应 Length 字段的值它已经包含 Unit ID 和 PDU因此总长度就是7 pdu_len。最后校验 Unit ID、功能码和字节数如果buf[8]不等于count * 2说明从站返回的数据长度不对通常是寄存器地址越界或功能码被代理网关改写。3.3 从站响应组装事务 ID 原样带回Length 必须重写如果自己写从站核心是把请求的前 7 字节原样拷贝到响应再重新计算 Length。下面是一个只处理 03 读保持寄存器的简化版。int modbus_tcp_handle_request(int client_fd) { uint8_t req[260], resp[260]; ssize_t n recv(client_fd, req, sizeof(req), 0); if (n 0) return -1; if (req[7] ! 0x03) return -2; uint16_t start get_be16(req[8]); uint16_t count get_be16(req[10]); memcpy(resp, req, 7); resp[7] 0x03; resp[8] (uint8_t)(count * 2); for (int i 0; i count; i) { uint16_t v reg_table[start i]; put_be16(resp[9 i * 2], v); } put_be16(resp[4], 3 count * 2); return send(client_fd, resp, 9 count * 2, 0); }这里memcpy(resp, req, 7)把事务 ID、协议 ID、Unit ID 原样带回去。Length 在req[4]和req[5]里是请求长度复制过来之后必须用put_be16(resp[4], 3 count * 2)重写因为响应数据长度和请求不同。reg_table是内存里的寄存器映射表实际项目中由设备应用层更新。send的返回值最好循环检查只在极其简单的场景下才直接信任一次 send。3.4 并发请求与 socket 超时参数Modbus TCP 允许在同一条连接上同时存在多个请求但现实里绝大多数 PLC 和网关从站并不支持乱序响应。连续发两个请求后从站可能只回较新的一个主站用事务 ID 对不上就会陷入超时重发。所以我一般每个从站独占一个 socket同一时间只发一个请求收到响应后再发下一个。socket 层面有几个参数要在建连后立刻设置参数建议值说明SO_RCVTIMEO10003000 msrecv 超时防止设备掉电后永久阻塞TCP_NODELAY1关闭 Nagle 算法降低小报文延迟SO_KEEPALIVE1开启 TCP 保活但探测周期较长单连接在途请求1请求-响应严格串行避免事务 ID 失配很多从网上拿到的 modbus-tcp.c.zip 最小实现不设置超时设备一断网整个进程卡在recv里现场排查时非常痛苦。在建连之后马上设置超时是这类源码工程化改造的第一步。4. 常见功能码与寄存器操作的参数细节03/04/06/16等4.1 一张表看全 01/02/03/04/05/06/15/16Modbus TCP 和 Modbus RTU 复用同一套功能码只是传输层不同。常用功能码汇总如下功能码十六进制功能操作对象单帧最大数量01读线圈离散输出2000 位02读离散输入离散输入2000 位03读保持寄存器16 位寄存器125 个04读输入寄存器16 位寄存器125 个05写单个线圈离散输出1 位06写单个寄存器16 位寄存器1 个0F写多个线圈离散输出1968 位10写多个寄存器16 位寄存器123 个这些最大数量不是拍脑袋定的而是被 PDU 长度限制。线圈按位计数每 8 个线圈打包成 1 字节读 2000 位时数据区 250 字节加上功能码和字节数已经接近 PDU 上限。保持寄存器一次最多 125 个是因为响应数据区为 2×count 字节加上功能码和字节数不能超过 253 字节。写多个寄存器请求里还要带上起始地址、数量和字节数所以上限只有 123 个。4.2 数量限制在 C 代码里怎么校验把 03 请求的寄存器数量写成 200从站会回异常码 0x03表示非法数据值。这不是设备故障而是协议限制。主站函数入口应加校验if (count 0 || count 125) { errno EINVAL; return -1; } if (start count REG_TABLE_SIZE) { errno ERANGE; return -2; }这里start count REG_TABLE_SIZE用的是大于号。如果REG_TABLE_SIZE是寄存器表长度合法地址范围是 0 到REG_TABLE_SIZE - 1最后一个寄存器的起始地址是REG_TABLE_SIZE - count条件式写成才是对的。很多工程师写成结果恰好丢掉最后一个寄存器这种错误在现场很隐蔽。4.3 地址偏移和 unit id三菱 FX5U 这类 PLC 怎么对上协议里的寄存器地址从 0 开始计算而组态软件和触摸屏里常写成 40001、40002也就是 40001 对应地址 040010 对应地址 9。C 代码请求字段直接填相对地址不需要加 40001除非你对接的网关把绝对地址映射表暴露成 0 开头。遇到不确定抓包看一次请求就能确认。三菱 FX5U 做 Modbus TCP 通讯时PLC 侧需要启用 Modbus/TCP 服务器功能端口默认 502。Unit ID 的取值因固件和专项参数而异有的模块固定 0xFF有的用站号。很多工程师用默认 unit1 去连 FX5U连接成功但响应超时把 unit 改成 0xFF 就通了。因此主站代码里的 unit 必须是可配置项不要硬编码成 1。这也是从最小源码升级成工程代码时最优先改的地方。4.4 手写的主站先用 Modbus Slave 验证再用真实 PLC调试阶段不要直接拿 PLC 试先用 Modbus Slave 这类工具起一个虚拟从站端口 502从站地址设为 1在保持寄存器区域填上 0x1234、0x5678 这样有规律的数据。然后运行自己写的主站程序如果读回来和填写一致说明主站代码没问题。再用 Modbus Poll 模拟主站连接同一个虚拟从站如果 Poll 能读到而从站侧代码不行问题就从站侧找。Modbus 测试工具里-a 是单元标识符-r 是起始地址-c 是数量这些参数要和 C 代码里的 start、count 严格一致。我经常遇到的现象是Modbus Poll 按 40001 填地址能通自己代码按 0 填却不通最后发现是地址偏移或者 byte count 校验写错了。先用最小数值的地址和数量交叉验证能把协议栈和业务逻辑的错误分开。5. 让 Modbus TCP 代码经得起现场考验超时、抓包与连续请求的坑5.1 用 tcpdump 看一帧都不少的大端报文在 Linux 下联调时先开一个抓包窗口只留 502 端口tcpdump -i eth0 -n -X tcp port 502-n不解析主机名-X同时输出十六进制和 ASCII。程序跑起来后重点看请求的第 4 字节和第 5 字节是否为 0x00 0x06再看响应报文的 Length 是否为 0x00 0x17。读取 10 个保持寄存器时响应长度是 Unit ID 1 字节、功能码 1 字节、字节数 1 字节、数据 20 字节合计 23也就是 0x0017。如果 Length 对不上后面改功能码、改地址都白搭。5.2 三个必调参数接收超时、keepalive、轮询间隔struct timeval tv { 0 }; tv.tv_sec 2; tv.tv_usec 0; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv)); int on 1; setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, on, sizeof(on));SO_RCVTIMEO让recv最多等 2 秒超时返回 -1errno 为 EAGAIN。SO_KEEPALIVE只负责极端断线检测默认要两小时才发探测包不能依赖它做业务超时。应用层轮询间隔建议 50200 ms不要把 Modbus 请求写进毫秒级忙循环很多 PLC 处理完一个请求后还要刷新内部映射区间隔太短会把从站打成异常状态。5.3 从 zip 源码到工程把事务 ID 和 fd 收进上下文结构体modbus-tcp.c.zip 里的最小实现通常有一堆全局变量比如static uint16_t tid。一旦要同时管多个从站全局变量会让各连接的事务 ID 互相覆盖响应就会错乱。我一般会改成上下文结构体typedef struct { int fd; uint8_t unit; uint16_t timeout_ms; uint16_t tid; } modbus_tcp_ctx_t;每个从站一个实例调用时传入指针。协议层函数只负责填充和解析缓冲区socket 操作留在带 fd 的层里。验证方式很简单开两个虚拟从站端口分别用 502 和 1502用同一套代码同时发起请求两个连接的事务 ID 必须各自独立。如果还是全局变量两个连接发出去的请求会拿到相同的事务 ID响应一错位就只能靠超时兜底。抓包时最后再看一眼响应报文的 Length它与“3 2×寄存器数量”不相等就说明从站代码或者网关把帧边界弄错了优先检查put_be16(resp[4], ...)里的长度计算。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。