
简介这份资源是一套完整的Modbus协议栈驱动代码面向嵌入式开发工程师与工业自动化方向的学习者用于解决STM32等平台上Modbus主机与从机通信的开发需求。协议栈同时支持主机和从机两种模式且允许两种模式并行开启从机端覆盖Modbus RTU、Modbus ASCII及Modbus TCP三种模式主机端目前支持常用的Modbus RTU模式代码可直接编译运行便于快速移植到实际项目中。压缩包共663个文件以271个C源文件与208个头文件为核心配合90个汇编文件、29个SConscript构建脚本及若干工程配置与说明文档整体约2.05MB目录结构清晰方便按模块查阅与二次开发。目前已有938人学习下载适合希望深入理解Modbus协议实现、搭建主从通信实验或进行工业现场调试的开发者参考借鉴。1. 一套驱动代码同时跑通 Modbus RTU、ASCII 和 TCP主机从机双模式到底怎么落地车间里一台老 PLC 走串口上位机走网口中间还有几台仪表只认 ASCII 帧这种混搭现场太常见了。如果每种协议各写一套收发逻辑维护起来就是灾难。标题里这套驱动代码要解决的核心问题是用同一套抽象层把 Modbus RTU、Modbus ASCII、Modbus TCP 三种报文形态统一起来同时支持主机主动轮询和从机被动响应两种角色。它适合做工业网关、数据采集终端、嵌入式协议栈的工程师也适合手里有 STC51 或 HAL 库平台、想把 Modbus 一次做扎实的人。下面按「协议差异怎么抽象 → 三种模式怎么实现 → 主机从机怎么切换 → 坑在哪」的顺序拆开讲。2. 三种 Modbus 变体的帧结构差异与统一抽象2.1 RTU、ASCII、TCP 的帧格式对比先把三种帧摆在一起看差异集中在帧边界判定和校验方式上。RTU 靠 3.5 个字符时间的静默间隔断帧ASCII 靠冒号开头、CRLF 结尾TCP 靠 MBAP 头里的长度字段。校验上 RTU 用 CRC16ASCII 用 LRCTCP 因为底层有 TCP 校验所以直接省掉。特性Modbus RTUModbus ASCIIModbus TCP帧起始3.5 字符静默冒号:无靠 MBAP帧结束3.5 字符静默CRLF\r\n长度字段校验CRC16LRC无地址字段1 字节2 字符 ASCIIMBAP 中 Unit ID典型场景串口仪表、PLC老式调制解调器链路以太网网关统一抽象的关键是把「帧边界识别」和「校验计算」做成可替换的策略PDU协议数据单元部分完全复用。PDU 就是功能码加数据这部分三种模式一模一样。2.2 用函数指针把校验和断帧策略抽出来我一般会定义一个传输层接口结构体把发送、接收、校验、断帧四个动作挂上去。这样上层的主机轮询和从机响应逻辑只操作 PDU不关心底层是串口还是网口。typedef struct { int (*send)(const uint8_t *pdu, uint16_t len); int (*recv)(uint8_t *pdu, uint16_t *len, uint32_t timeout_ms); uint16_t (*calc_crc)(const uint8_t *data, uint16_t len); uint8_t (*calc_lrc)(const uint8_t *data, uint16_t len); uint8_t frame_mode; /* 0RTU 1ASCII 2TCP */ } modbus_transport_t; /* RTU 校验标准 CRC16多项式 0xA001 反向 */ uint16_t modbus_crc16(const uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ data[i]; for (uint8_t j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; } /* ASCII 校验LRC 是逐字节求和取反加一 */ uint8_t modbus_lrc(const uint8_t *data, uint16_t len) { uint8_t sum 0; for (uint16_t i 0; i len; i) sum data[i]; return (uint8_t)(-sum); }calc_crc和calc_lrc分别对应 RTU 和 ASCIITCP 模式下这两个指针可以置空。frame_mode决定接收状态机走哪条分支。参数上唯一要注意的是 CRC 初值标准 Modbus 是 0xFFFF有些私有协议改成 0x0000对不上就永远校验失败。2.3 接收状态机怎么区分三种帧RTU 的断帧靠定时器收到第一个字节启动 3.5 字符定时超时即认为一帧结束。ASCII 简单扫到:开始、扫到\r\n结束。TCP 读 MBAP 头前 6 字节拿到长度再读剩余部分。/* RTU 接收字节间隔超时判定帧尾 */ int rtu_recv(uint8_t *pdu, uint16_t *len, uint32_t timeout_ms) { uint32_t last_tick get_tick(); uint16_t idx 0; while (1) { if (uart_data_available()) { pdu[idx] uart_read_byte(); last_tick get_tick(); /* 每收到字节刷新计时 */ } else if (get_tick() - last_tick T35_TICKS) { break; /* 静默超时帧结束 */ } if (idx MAX_PDU_LEN) return -1; /* 防溢出 */ } *len idx; return 0; }T35_TICKS是 3.5 字符时间换算成的 tick 数波特率 9600 时约 4ms115200 时约 0.3ms。这个值设大了会把两帧粘一起设小了会把一帧切断是 RTU 最玄学的地方。3. 主机模式轮询调度与超时重试的实现3.1 请求帧组装与发送主机模式的核心是构造请求 PDU加上地址和校验后发出去。以读保持寄存器 0x03 为例PDU 是功能码加起始地址加寄存器数量。/* 主机读保持寄存器slave_addr 从机地址reg 起始寄存器num 数量 */ int modbus_master_read_holding(uint8_t slave_addr, uint16_t reg, uint16_t num, uint16_t *out) { uint8_t pdu[5]; pdu[0] 0x03; pdu[1] reg 8; pdu[2] reg 0xFF; pdu[3] num 8; pdu[4] num 0xFF; uint8_t frame[256]; uint16_t flen build_frame(slave_addr, pdu, 5, frame); if (transport.send(frame, flen) ! 0) return -1; uint8_t resp[256]; uint16_t rlen; if (transport.recv(resp, rlen, RESP_TIMEOUT_MS) ! 0) return -2; return parse_read_response(resp, rlen, num, out); }build_frame根据frame_mode决定加 CRC、加 LRC 还是加 MBAP 头。RESP_TIMEOUT_MS一般设 100 到 300ms串口慢链路可以放到 500ms。返回负值分别代表发送失败和接收超时上层据此决定是否重试。3.2 轮询调度表与重试策略多从机场景下我习惯用一张轮询表描述要采集哪些点主循环按表逐条发请求。typedef struct { uint8_t slave; uint16_t reg; uint16_t num; uint16_t *buf; uint8_t retry; } poll_item_t; poll_item_t poll_table[] { {0x01, 0x0000, 10, reg_buf_1, 3}, {0x02, 0x0000, 10, reg_buf_2, 3}, }; void master_poll_loop(void) { for (int i 0; i sizeof(poll_table)/sizeof(poll_item_t); i) { poll_item_t *it poll_table[i]; for (int r 0; r it-retry; r) { if (modbus_master_read_holding(it-slave, it-reg, it-num, it-buf) 0) break; delay_ms(50); /* 重试间隔给从机恢复时间 */ } } }retry设 3 次是经验值再多说明链路本身有问题。重试间隔别省从机处理不过来时立刻重发只会连续失败。3.3 异常码的处理从机返回的异常响应功能码最高位置 1后面跟一个异常码。常见的有 0x01 非法功能、0x02 非法地址、0x03 非法数据值。主机必须解析异常码而不是当成正常数据。if (resp_pdu[0] 0x80) { uint8_t exc resp_pdu[1]; log_error(slave %d exception 0x%02X, slave_addr, exc); return -3; }异常码 0x02 通常是寄存器地址写错了0x03 是数量超限对着从机手册核一遍就能定位。4. 从机模式请求解析与响应生成4.1 从机地址过滤与功能码分发从机收到帧后第一件事是判断地址是否匹配广播地址 0x00 要处理但不回复。然后按功能码分发到对应处理函数。void modbus_slave_process(uint8_t *frame, uint16_t len) { uint8_t addr frame[0]; if (addr ! SLAVE_ADDR addr ! 0x00) return; /* 地址不匹配丢弃 */ uint8_t func frame[1]; uint8_t resp[256]; uint16_t rlen 0; switch (func) { case 0x03: handle_read_holding(frame, len, resp, rlen); break; case 0x06: handle_write_single(frame, len, resp, rlen); break; case 0x10: handle_write_multi(frame, len, resp, rlen); break; default: build_exception(func, 0x01, resp, rlen); /* 非法功能 */ break; } if (addr ! 0x00 rlen 0) transport.send(resp, rlen); /* 广播不回复 */ }SLAVE_ADDR从配置文件或拨码开关读入。广播不回复这条规则很多人会漏导致总线上多出一帧冲突。4.2 寄存器映射表的组织从机的数据区一般用一张映射表描述把寄存器地址和实际变量绑起来。typedef struct { uint16_t addr; uint16_t *data; uint16_t count; uint8_t rw; /* 0只读 1读写 */ } reg_map_t; reg_map_t reg_map[] { {0x0000, holding_regs, 100, 1}, {0x0064, input_regs, 50, 0}, };处理读请求时遍历这张表找到对应区间越界就返回异常码 0x02。写请求还要检查rw标志只读区被写要返回 0x03。4.3 响应帧的构造与校验响应 PDU 构造完后交给传输层加地址和校验。RTU 加 CRC 时注意低字节在前这是 Modbus 的规定写反了主机永远校验不过。uint16_t build_frame(uint8_t addr, uint8_t *pdu, uint16_t plen, uint8_t *out) { uint16_t idx 0; if (transport.frame_mode 0) { /* RTU */ out[idx] addr; memcpy(out[idx], pdu, plen); idx plen; uint16_t crc modbus_crc16(out, idx); out[idx] crc 0xFF; /* CRC 低字节在前 */ out[idx] crc 8; } else if (transport.frame_mode 1) { /* ASCII */ out[idx] :; idx to_hex_ascii(addr, out[idx]); idx to_hex_ascii_buf(pdu, plen, out[idx]); uint8_t lrc modbus_lrc(pdu, plen); idx to_hex_ascii(lrc, out[idx]); out[idx] \r; out[idx] \n; } else { /* TCP */ out[idx] 0x00; out[idx] 0x00; /* 事务 ID */ out[idx] 0x00; out[idx] 0x00; /* 协议 ID */ out[idx] (plen 1) 8; /* 长度含 Unit ID */ out[idx] (plen 1) 0xFF; out[idx] addr; /* Unit ID */ memcpy(out[idx], pdu, plen); idx plen; } return idx; }ASCII 模式每个字节要转成两个十六进制字符转换函数别写错大小写有些设备只认大写。5. 避坑与排查那些让帧收不全、校验不过的细节5.1 现象RTU 偶发校验失败重试就好原因多半是 3.5 字符定时不准。用系统 tick 做超时tick 精度 1ms 时在 115200 波特率下 3.5 字符只有 0.3ms根本测不准。解决方法是高波特率下改用固定短超时或者用 UART 空闲中断配合 DMA 收整帧。5.2 现象ASCII 模式收到帧但解析出乱码检查十六进制转换有没有处理字母大小写以及:和 CRLF 有没有被算进 LRC 计算范围。LRC 只算地址到数据结束不含冒号和 CRLF算多了校验必错。5.3 现象TCP 模式粘包一次读到两帧TCP 是流式协议没有帧边界。必须严格按 MBAP 长度字段读先读 6 字节头拿到长度再读长度-1字节的 PDU。用recv一次读一大块然后自己切分是常见翻车点。5.4 现象从机不回复主机一直超时先确认从机地址和波特率对不对再看从机是不是把广播地址也回复了导致总线冲突。还有一种情况是从机处理函数里阻塞太久超过了主机的响应超时把耗时操作挪到主循环外。5.5 现象写单个寄存器成功写多个失败写多个寄存器 0x10 的请求里字节数字段必须等于寄存器数量乘 2写错了从机会返回异常码 0x03。这个字段很多人会算错尤其是寄存器数量为奇数时。6. 用回环测试和边界用例验证驱动可靠性写完驱动别急着上现场先在本地做回环验证。串口把 TX 和 RX 短接跑主机模式发请求看能不能收到自己发的帧并正确解析。TCP 模式起一个本地服务端用脚本模拟从机响应。# 本地模拟 Modbus TCP 从机验证主机请求格式 import socket, struct srv socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.bind((127.0.0.1, 502)) srv.listen(1) conn, _ srv.accept() while True: mbap conn.recv(6) if not mbap: break length struct.unpack(H, mbap[4:6])[0] pdu conn.recv(length - 1) print(func0x%02X data%s % (pdu[0], pdu[1:].hex())) # 回一个固定响应功能码 0x032 字节数据 resp_pdu bytes([0x03, 0x02, 0x00, 0x64]) resp mbap[:4] struct.pack(H, len(resp_pdu)1) b\x01 resp_pdu conn.send(resp)这个脚本能帮你确认 MBAP 头的事务 ID、协议 ID、长度字段有没有写对。边界用例重点测三种寄存器数量为 0、数量超过 125、起始地址加数量溢出 16 位。这三种情况驱动都应该返回异常码而不是崩溃。我自己的习惯是每加一个功能码先在回环环境跑通正常帧再手动构造异常帧验证错误处理最后才接真实设备。现场调试的时间成本远高于本地多写几个测试用例。这套驱动值不值得做取决于你手上是不是真有多种协议混跑的采集需求如果有一次抽象到位比后面反复打补丁省太多事。希望帮到你。本文还有配套的精品资源点击获取