一套代码搞定Modbus RTU/ASCII/TCP:主机从机通用驱动设计

发布时间:2026/10/29 7:48:46
一套代码搞定Modbus RTU/ASCII/TCP:主机从机通用驱动设计 简介这份资源是一套完整的Modbus协议栈驱动代码面向嵌入式开发工程师、工控协议学习者及STM32项目开发者用于解决设备通信中主机与从机模式并存、多协议切换的工程需求。协议栈同时支持Modbus主机和从机两种模式且允许两种模式同时开启从机侧覆盖Modbus RTU、Modbus ASCII及Modbus TCP三种模式主机侧目前支持常用的Modbus RTU模式代码可直接编译运行。资源包共663个文件以271个C源文件、208个头文件、90个汇编文件及29个SConscript构建脚本为主另含工程配置、说明文档与少量网页文件压缩包约2.05MB整体结构清晰便于按模块查阅与移植。目前已有938人学习下载。读者可从中获得完整的协议栈实现、STM32平台工程模板、主从机调度逻辑与移植参考适合用于工业网关、数据采集终端等场景的二次开发与协议学习。1. 一套驱动代码同时吃下 Modbus RTU、ASCII 和 TCP主机从机都能跑意味着什么手上有个采集项目现场既有走 RS485 的老电表又有走网口的 PLC还有一台只认 Modbus ASCII 的进口仪表。如果每种协议各写一套收发逻辑光是超时重传和异常码处理就能把人拖垮。Modbus RTU、Modbus ASCII、Modbus TCP三种报文虽然封装不同但 PDU协议数据单元完全一致功能码、寄存器地址、异常响应都是同一套语义。所谓「一套驱动代码支持主机和从机两种模式」本质是把协议解析层和物理传输层解耦PDU 编解码只写一遍RTU/ASCII/TCP 只是三种不同的「信封」主机从机只是调用方向不同。这套东西适合谁做工业采集网关、PLC 通讯中间件、嵌入式数据采集终端的工程师。你不需要为每种协议维护独立代码分支切换协议只改一个枚举和一个串口/网口句柄。下面按「协议差异 → 代码结构 → 三种模式实现 → 避坑 → 进阶验证」的顺序拆开讲每一步都给可复现的代码和参数。2. 三种 Modbus 变体的报文差异与统一抽象2.1 RTU、ASCII、TCP 的帧结构对比先把三种帧摆在一起看差异全在「信封」上PDU 那一段是一样的。特性Modbus RTUModbus ASCIIModbus TCP起始标志无3.5 字符静默冒号:(0x3A)无靠 MBAP 头结束标志无3.5 字符静默CRLF (0x0D 0x0A)无靠长度字段地址字段1 字节从机地址2 字符 ASCIIMBAP 中 Unit ID校验CRC16LRC无TCP 自带校验数据编码二进制每字节转 2 个 ASCII 字符二进制最大 PDU253 字节253 字节253 字节典型物理层RS485/RS232RS232/RS485以太网关键结论PDU功能码数据三种协议完全一致差异只在 ADU应用数据单元的封装。所以驱动代码的第一层抽象就是把 PDU 编解码独立出来第二层才是三种 ADU 的打包/解包。2.2 统一抽象层的接口设计我一般会定义一个传输抽象接口把「发送一帧」和「接收一帧」抽象出来上层的主机/从机逻辑只跟 PDU 打交道。/* modbus_transport.h - 传输层抽象接口 */ typedef enum { MB_RTU 0, MB_ASCII, MB_TCP } mb_mode_t; typedef struct mb_transport { mb_mode_t mode; /* 发送原始字节返回实际发送字节数-1 表示失败 */ int (*send)(struct mb_transport *t, const uint8_t *buf, uint16_t len); /* 接收一帧返回帧长度0 表示超时-1 表示错误 */ int (*recv)(struct mb_transport *t, uint8_t *buf, uint16_t max_len, uint32_t timeout_ms); void *priv; /* 指向串口句柄或 socket */ } mb_transport_t; /* PDU 层与传输方式无关 */ typedef struct { uint8_t unit_id; /* 从机地址 / TCP Unit ID */ uint8_t func_code; uint8_t data[253]; uint16_t data_len; } mb_pdu_t; int mb_pdu_encode(const mb_pdu_t *pdu, uint8_t *out, uint16_t *out_len); int mb_pdu_decode(const uint8_t *in, uint16_t in_len, mb_pdu_t *pdu);send/recv两个函数指针就是三种协议的接入点。RTU 的recv内部要做 3.5 字符静默判断ASCII 的recv要扫描:和 CRLFTCP 的recv直接按 MBAP 长度字段读。上层主机轮询逻辑完全不用改。提示data[253]是 PDU 最大长度不是 ADU 长度。RTU 的 ADU 还要加地址和 CRCTCP 要加 7 字节 MBAP 头缓冲区分配时别只按 253 算。2.3 主机与从机模式的角色切换主机Master/Client和从机Slave/Server的区别不在协议而在「谁发起请求」。主机主动发请求、等响应、做超时重传从机被动监听、解析请求、执行后回响应。同一套 PDU 编解码主机调mb_pdu_encode发请求、mb_pdu_decode解响应从机反过来。/* 主机侧发起读保持寄存器请求 */ mb_pdu_t req { .unit_id 0x01, .func_code 0x03 }; req.data[0] 0x00; req.data[1] 0x00; /* 起始地址 0 */ req.data[2] 0x00; req.data[3] 0x0A; /* 读 10 个寄存器 */ req.data_len 4; uint8_t frame[260]; uint16_t flen; mb_pdu_encode(req, frame, flen); transport-send(transport, frame, flen); /* 从机侧收到请求后解析并回响应 */ mb_pdu_t rsp; mb_pdu_decode(rx_buf, rx_len, rsp); if (rsp.func_code 0x03) { /* 填充寄存器数据func_code 最高位置 0 表示正常响应 */ build_read_holding_response(rsp, reg_map, reg_count); mb_pdu_encode(rsp, frame, flen); transport-send(transport, frame, flen); }角色切换靠一个mb_role_t role字段控制主循环走请求分支还是监听分支。实际项目里我见过有人把主机从机写成两份代码结果异常码处理逻辑不一致从机返回 0x83 主机却按 0x03 解析直接翻车。统一 PDU 层就是为了避免这种分裂。3. 用 C 把 RTU/ASCII/TCP 三种传输层跑通3.1 RTU 传输层CRC16 与 3.5 字符静默RTU 最容易踩坑的是帧边界判断。协议规定帧间隔至少 3.5 个字符时间但很多 USB 转串口芯片的驱动会把多个帧合并上报。我的做法是接收时用定时器记录最后一个字节到达时间超过 3.5 字符时间就认为一帧结束。/* CRC16 查表法多项式 0xA001Modbus 标准 */ static const uint16_t crc_table[256] { /* 省略标准表 */ }; uint16_t mb_crc16(const uint8_t *buf, uint16_t len) { uint16_t crc 0xFFFF; while (len--) { crc (crc 8) ^ crc_table[(crc ^ *buf) 0xFF]; } return crc; } /* RTU 接收按 3.5 字符静默切帧 */ int rtu_recv(mb_transport_t *t, uint8_t *buf, uint16_t max_len, uint32_t timeout_ms) { uint32_t baud ((serial_priv_t *)t-priv)-baudrate; /* 3.5 字符时间(ms) 3.5 * 11 * 1000 / baud11 位含起始校验停止 */ uint32_t silent_ms (uint32_t)(3.5 * 11 * 1000 / baud) 1; uint16_t idx 0; uint32_t last_rx get_tick_ms(); while (get_tick_ms() - last_rx timeout_ms) { int c serial_read_byte(t-priv, 10); if (c 0) { if (idx max_len) buf[idx] (uint8_t)c; last_rx get_tick_ms(); } else if (idx 0 (get_tick_ms() - last_rx) silent_ms) { break; /* 静默超时一帧结束 */ } } if (idx 4) return 0; /* 最短帧地址功能码CRC 4 字节 */ /* 校验 CRC */ uint16_t crc_calc mb_crc16(buf, idx - 2); uint16_t crc_recv buf[idx-2] | (buf[idx-1] 8); return (crc_calc crc_recv) ? idx : -1; }silent_ms的计算是重点11 位是 1 起始位 8 数据位 1 校验位 1 停止位。9600 波特率下 3.5 字符约 4ms115200 下约 0.33ms加 1ms 兜底。如果串口驱动本身有缓冲延迟这个值要适当放大否则会把一帧切成两半。3.2 ASCII 传输层LRC 校验与冒号/CRLF 边界ASCII 模式每个字节用两个十六进制字符表示帧以:开头、CRLF 结尾。LRC 是纵向冗余校验把地址到数据所有字节求和取反加一。/* LRC 校验所有字节求和取二进制补码 */ uint8_t mb_lrc(const uint8_t *buf, uint16_t len) { uint8_t sum 0; while (len--) sum *buf; return (uint8_t)(-sum); } /* ASCII 接收扫描 : 起始和 CRLF 结束 */ int ascii_recv(mb_transport_t *t, uint8_t *buf, uint16_t max_len, uint32_t timeout_ms) { uint16_t idx 0; int started 0; uint32_t start_tick get_tick_ms(); while (get_tick_ms() - start_tick timeout_ms) { int c serial_read_byte(t-priv, 10); if (c 0) continue; if (!started) { if (c :) { started 1; idx 0; } continue; } if (idx max_len) buf[idx] (uint8_t)c; /* 检测 CRLF 结束 */ if (idx 2 buf[idx-2] \r buf[idx-1] \n) { /* 去掉 : 和 CRLF得到纯 hex 字符串长度应为偶数 */ uint16_t hex_len idx - 3; /* 减去 : 和 CRLF */ if (hex_len % 2 ! 0) return -1; /* 把 hex 字符串转回二进制 */ uint16_t bin_len hex_len / 2; for (uint16_t i 0; i bin_len; i) { buf[i] hex2byte(buf[1 i*2]); } /* 校验 LRC最后一个字节是 LRC */ uint8_t lrc_calc mb_lrc(buf, bin_len - 1); return (lrc_calc buf[bin_len-1]) ? bin_len : -1; } } return 0; }注意hex_len idx - 3idx包含了开头的:和结尾的 CRLF减去 3 才是纯 hex 字符数。这个偏移量我见过有人写成idx - 2结果 LRC 校验永远差一个字节查了半天。3.3 TCP 传输层MBAP 头与粘包处理Modbus TCP 的 MBAP 头 7 字节事务 ID2 协议 ID2固定 0 长度2 单元 ID1。长度字段表示后面还有多少字节单元 ID PDU。TCP 是流式协议一次recv可能收到半个帧或两个半帧必须按长度字段精确读取。/* TCP 接收先读 7 字节 MBAP 头再按长度字段读剩余 */ int tcp_recv(mb_transport_t *t, uint8_t *buf, uint16_t max_len, uint32_t timeout_ms) { int sock ((tcp_priv_t *)t-priv)-sock; uint8_t mbap[7]; /* 精确读 7 字节头 */ if (recv_exact(sock, mbap, 7, timeout_ms) ! 7) return 0; uint16_t proto_id (mbap[2] 8) | mbap[3]; if (proto_id ! 0) return -1; /* 协议 ID 必须为 0 */ uint16_t remain (mbap[4] 8) | mbap[5]; /* 单元ID PDU 长度 */ if (remain 6 max_len) return -1; memcpy(buf, mbap, 6); /* 前 6 字节是事务ID协议ID长度 */ if (recv_exact(sock, buf 6, remain, timeout_ms) ! remain) return 0; return remain 6; }recv_exact是循环读直到读满指定字节数或超时。TCP 模式下没有 CRC/LRC校验靠 TCP 自身的校验和所以 PDU 层解码时不需要再验。但要注意MBAP 的长度字段包含单元 ID所以remain是「单元 ID PDU」的总长别少算一个字节。4. 主机轮询与从机响应的完整实现4.1 主机侧请求队列、超时重传与异常码处理主机核心是一个轮询循环从请求队列取一条编码发送等响应超时则重传重传 N 次仍失败则标记该从机离线。typedef struct { uint8_t unit_id; uint8_t func_code; uint16_t start_addr; uint16_t count; uint8_t retry; /* 剩余重传次数 */ uint32_t timeout_ms; } mb_request_t; int mb_master_poll(mb_transport_t *t, mb_request_t *req, uint16_t *out_regs) { uint8_t frame[260]; uint16_t flen; mb_pdu_t pdu { .unit_id req-unit_id, .func_code req-func_code }; pdu.data[0] req-start_addr 8; pdu.data[1] req-start_addr 0xFF; pdu.data[2] req-count 8; pdu.data[3] req-count 0xFF; pdu.data_len 4; mb_pdu_encode(pdu, frame, flen); for (int attempt 0; attempt req-retry; attempt) { t-send(t, frame, flen); uint8_t rx[260]; int rx_len t-recv(t, rx, sizeof(rx), req-timeout_ms); if (rx_len 0) continue; /* 超时重传 */ mb_pdu_t rsp; if (mb_pdu_decode(rx, rx_len, rsp) 0) continue; if (rsp.unit_id ! req-unit_id) continue; if (rsp.func_code 0x80) { /* 异常响应功能码最高位为 1data[0] 是异常码 */ log_error(unit %d exception code %d, req-unit_id, rsp.data[0]); return -2; } /* 正常响应解析寄存器数据 */ uint8_t byte_count rsp.data[0]; for (int i 0; i byte_count / 2; i) { out_regs[i] (rsp.data[1 i*2] 8) | rsp.data[2 i*2]; } return byte_count / 2; } return -1; /* 重传耗尽 */ }retry一般设 2~3 次timeout_ms按波特率和帧长算9600 波特率下 8 字节帧约 10ms加上从机处理时间设 200~500ms 比较稳。异常响应判断用func_code 0x80这是 Modbus 规范定义的别用 0x83这种硬编码否则换个功能码就漏判。4.2 从机侧寄存器映射表与功能码分发从机维护一张寄存器映射表收到请求后按功能码分发到对应处理函数。typedef struct { uint16_t addr; uint16_t *regs; uint16_t count; } mb_reg_map_t; /* 功能码分发 */ int mb_slave_handle(mb_transport_t *t, const uint8_t *rx, uint16_t rx_len) { mb_pdu_t req, rsp; if (mb_pdu_decode(rx, rx_len, req) 0) return -1; rsp.unit_id req.unit_id; rsp.func_code req.func_code; rsp.data_len 0; switch (req.func_code) { case 0x03: { /* 读保持寄存器 */ uint16_t addr (req.data[0] 8) | req.data[1]; uint16_t cnt (req.data[2] 8) | req.data[3]; if (cnt 1 || cnt 125) { build_exception(rsp, 0x03); break; } uint16_t *regs reg_map_lookup(addr, cnt); if (!regs) { build_exception(rsp, 0x02); break; } /* 非法地址 */ rsp.data[0] cnt * 2; for (int i 0; i cnt; i) { rsp.data[1 i*2] regs[i] 8; rsp.data[2 i*2] regs[i] 0xFF; } rsp.data_len 1 cnt * 2; break; } case 0x06: { /* 写单个寄存器 */ uint16_t addr (req.data[0] 8) | req.data[1]; uint16_t val (req.data[2] 8) | req.data[3]; if (!reg_map_write(addr, val)) { build_exception(rsp, 0x02); break; } memcpy(rsp.data, req.data, 4); /* 回显请求 */ rsp.data_len 4; break; } default: build_exception(rsp, 0x01); /* 非法功能码 */ } uint8_t frame[260]; uint16_t flen; mb_pdu_encode(rsp, frame, flen); t-send(t, frame, flen); return 0; }build_exception把功能码最高位置 1data[0] 填异常码。常见异常码0x01 非法功能、0x02 非法数据地址、0x03 非法数据值、0x04 从机故障。从机侧一定要做地址范围检查否则主机读越界地址时你返回一堆垃圾数据排查起来就是黑匣子。4.3 三种模式共用一个主循环把传输层和角色组合起来主循环结构非常清晰void mb_run(mb_transport_t *t, mb_role_t role) { if (role MB_MASTER) { while (1) { mb_request_t req; if (request_queue_pop(req) 0) { uint16_t regs[125]; int n mb_master_poll(t, req, regs); if (n 0) on_data_callback(req.unit_id, req.start_addr, regs, n); else on_error_callback(req.unit_id, n); } sleep_ms(10); } } else { uint8_t rx[260]; while (1) { int len t-recv(t, rx, sizeof(rx), 1000); if (len 0) mb_slave_handle(t, rx, len); } } }切换 RTU/ASCII/TCP 只需要换一个mb_transport_t实例主循环代码一行不改。这就是统一抽象的价值。5. 调试 Modbus 驱动时最容易翻车的 5 个坑5.1 现象RTU 偶尔收到半帧CRC 校验失败原因串口驱动或 USB 转串口芯片有接收缓冲把一帧拆成两次上报或者两帧合并成一次。3.5 字符静默判断依赖字节到达时间如果驱动层已经做了缓冲时间信息就丢了。解决优先用带硬件 FIFO 超时中断的串口如果只能轮询把静默时间从 3.5 字符放大到 5~10 字符并在应用层做「收到完整帧才处理」的缓冲。更稳的做法是结合长度字段预判读请求固定 8 字节读响应 3 2×寄存器数先按预期长度收收满再校验。5.2 现象ASCII 模式 LRC 校验永远差 1原因LRC 计算范围搞错。LRC 覆盖从地址到数据的所有字节不包括开头的:和结尾的 CRLF也不包括 LRC 自身。有人把:的 ASCII 值算进去了或者把 CRLF 算进去了。解决先把 hex 字符串转回二进制得到[地址][功能码][数据...][LRC]对前面所有字节求和取反加一跟最后一个字节比。转换时注意 hex 字符串长度必须是偶数奇数说明帧不完整。5.3 现象TCP 模式读到的数据错位事务 ID 对不上原因TCP 粘包。一次recv可能返回两个完整的 Modbus TCP 帧如果按固定长度读就会错位。或者 MBAP 长度字段理解错把「单元 ID PDU」当成「PDU」。解决严格按 MBAP 长度字段读先读 7 字节头解析出remain再精确读remain字节。remain包含单元 ID所以实际 PDU 长度是remain - 1。事务 ID 用于匹配请求和响应主机侧要校验响应的事务 ID 跟请求一致不一致就丢弃。5.4 现象从机响应了但主机报超时原因从机响应帧的单元 ID 或功能码跟请求不一致主机侧校验失败当超时处理。常见于从机代码里rsp.unit_id没从请求里复制用了默认值 0。解决从机构造响应时unit_id和func_code必须从请求 PDU 复制。异常响应时func_code要置最高位。主机侧收到响应先校验unit_id和func_code正常响应功能码相同异常响应功能码最高位为 1再解析数据。5.5 现象多从机轮询时某台设备频繁掉线原因轮询间隔太短从机还没处理完上一条请求下一条就来了。或者 RS485 总线冲突主机发送时从机也在发。解决主机侧对每台从机维护独立的超时和重传计数连续失败 N 次才标记离线不要一次失败就判死。RS485 半双工要严格控制收发切换时序发送完成后等最后一个字节移出移位寄存器再切回接收这个延迟按波特率算115200 下约 0.1ms9600 下约 1ms。我一般会在发送后加一个tcdrain或等 TXE 标志再延时 1 个字符时间。6. 用回环测试和协议分析仪验证驱动正确性写完驱动别急着接真实设备先做回环测试。RTU/ASCII 可以把串口的 TX 和 RX 短接自己发自己收TCP 可以起一个本地 echo 服务或直接连自己的监听端口。下面这个测试用例覆盖三种模式的主机从机对拍# modbus_loopback_test.py - 用 pymodbus 做参考实现对拍 from pymodbus.client import ModbusTcpClient from pymodbus.server import StartTcpServer import threading, time # 启动一个参考从机 def run_server(): from pymodbus.datastore import ModbusSequentialDataBlock, ModbusSlaveContext, ModbusServerContext store ModbusSlaveContext(hrModbusSequentialDataBlock(0, [i for i in range(100)])) context ModbusServerContext(slavesstore, singleTrue) StartTcpServer(contextcontext, address(127.0.0.1, 5020)) threading.Thread(targetrun_server, daemonTrue).start() time.sleep(1) # 用参考主机读验证你的从机实现是否返回相同数据 client ModbusTcpClient(127.0.0.1, port5020) client.connect() rr client.read_holding_registers(address0, count10, slave1) print(参考从机返回:, rr.registers) client.close()对拍思路让你的驱动和 pymodbus 互为参考。你的主机读 pymodbus 从机pymodbus 主机读你的从机两边数据一致就说明 PDU 编解码和传输层都对了。RTU/ASCII 可以用pyserial加pymodbus的ModbusSerialClient做同样的事。进阶验证用协议分析仪或 Wireshark。Wireshark 自带 Modbus TCP 解析器抓包后能直接看到事务 ID、功能码、寄存器地址和值。RTU 抓包需要串口分析仪把差分信号转成字节流重点看帧间隔是否满足 3.5 字符、CRC 是否正确。我一般会构造几个边界用例读 125 个寄存器最大合法值、读 126 个应返回异常 0x03、写只读寄存器应返回异常 0x02、功能码 0x00应返回异常 0x01。这些用例跑通驱动基本就稳了。最后一个习惯每次改完 PDU 编解码或传输层先跑回环测试再上真实设备。真实设备一旦通讯异常你分不清是驱动问题还是设备问题回环测试能帮你把变量隔离掉。这套驱动代码的价值不在于支持三种协议而在于让你在协议切换时不用重写业务逻辑省下的时间够你多调几个现场。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询