简介:一套基于C++的Modbus TCP服务器实现源码包,面向工业自动化领域开发者,用于解决PLC等设备通过以太网进行Modbus通信的需求。源码完整覆盖服务器核心逻辑:监听502端口、接受客户端连接、解析Modbus请求帧、按功能码读写寄存器、组装响应报文并处理错误,适合学习TCP/IP网络编程与Modbus协议原理的进阶读者。压缩包共14个文件,包含5个头文件和5个C++源文件,另附Visual Studio工程文件、解决方案、README及说明文本,整体仅13KB,结构精简,便于快速阅读和移植。已有680人学习该资源。通过阅读代码可掌握工业通信服务器的模块划分与实现技巧,借助随包工程可在VS中直接打开编译调试,是理解Modbus TCP落地实现的实用参考资料。
1. Modbus TCP服务器:先想清楚它到底在服务什么
很多刚接触工业通信的开发者容易把 Modbus TCP 想简单:不就是把串口 Modbus 的报文塞进 TCP 包吗?真上手写一个 C++ 服务端就知道不是那么回事——事务标识怎么递增、多客户端同时读写寄存器、半包粘包、字节序翻转,任何一个点没处理好,现场设备就是连不上,而设备侧根本不会告诉你原因。这东西像个标准的黑匣子:看上去只有一个端口 502,里面却要同时处理协议解析、并发读写和异常回包。
这份资源是一个可编译落地运行的 C++ 实现、基于 Modbus TCP 协议的服务器端代码,解决的是设备联调、协议仿真、数据采集这类场景。适合三类人:做上位机软件、需要先起一个服务端当调试靶子的人;嵌入式开发、要把设备数据通过以太网交给 SCADA 或组态软件的人;以及在做协议网关、需要把握站侧响应逻辑的工程师。目标就一个:你能把它跑起来,并且敢拿它去对接真实的主站。
2. 报文与选型:MBAP头的五个字段、功能码分类和两种线程模型
2.1 MBAP头与PDU:7字节头部不是装饰品
Modbus TCP 的报文结构分两部分:MBAP 头(Modbus Application Protocol header)加上 PDU(Protocol Data Unit)。MBAP 头固定 7 字节,在串口 Modbus 里没有这个东西,它负责让 TCP 流里的报文能互相区分。具体字段如下:
| 字段 | 长度 | 含义 | 注意事项 |
|---|---|---|---|
| Transaction Identifier | 2 字节 | 事务标识,请求与响应要一致 | 每次请求自增,多客户端并发时不要求全局唯一,但同一连接内要能区分 |
| Protocol Identifier | 2 字节 | 协议标识 | Modbus TCP 固定为 0x0000 |
| Length | 2 字节 | 后面 Unit Identifier + PDU 的总字节数 | 注意不是整个 TCP 包的长度,是剩余部分的长度 |
| Unit Identifier | 1 字节 | 从站地址 | 串口是 1~247,TCP 场景通常填 0x01 或 0xFF |
入坑点集中在 Length 字段上。它统计的是 Unit Identifier 开始到报文结尾的字节数,比如只读一个保持寄存器,PDU 是 0x03 0x00 0x00 0x00 0x01,那 Unit ID 占 1 字节、PDU 占 5 字节,Length 就得填 6。写错这个值,主站会在解析阶段直接丢弃响应,抓包看却什么都有,最容易把人绕进去。
事务标识的递增策略也要提前定好。我一般做法是每个 socket 连接维护一个独立的 16 位计数器,收到请求时记录对端发来的 Transaction ID,响应时原样带回。这样既满足协议要求,又避免多线程加锁抢一个全局计数器造成的性能瓶颈。
2.2 功能码分类:不是所有功能码都需要实现
Modbus 协议的功能码很多,但做服务器端时真正高频出现的是 8 个。位操作类有读线圈(0x01)、读离散输入(0x02)、写单线圈(0x05)、写多个线圈(0x0F);字操作类有读保持寄存器(0x03)、读输入寄存器(0x04)、写单寄存器(0x06)、写多个寄存器(0x10)。
实际工程里,上位机或组态软件最常用的是 0x03 和 0x10,一个读一个写,覆盖 90% 的变量读写场景。0x01 和 0x02 一般在设备状态指示、报警位场景下使用,0x05 和 0x0F 则用在开关控制。如果是给 PLC 或者网关做适配,这 8 个建议全实现。但协议上没要求的功能码,比如诊断类 0x08、文件访问类 0x14,返回异常码 0x01 即可,不需要真的处理。
2.3 线程模型:每连接一线程还是事件循环
服务器端要考虑多客户端同时连接。C++ 场景下两种主流方案:每连接一个std::thread,或者用 IO 多路复用(epoll/select)做事件循环。
| 方案 | 适用规模 | 优点 | 缺点 |
|---|---|---|---|
| 每连接一线程 | 连接数 < 200 | 代码直观,一个连接一个上下文,调试方便 | 线程开销大,高并发下上下文切换成本高 |
| 单线程 epoll | 连接数 > 500 | 内存占用低,无锁处理请求 | 回调逻辑要小心,一个请求阻塞全部阻塞 |
| 线程池 + epoll | 大规模部署 | 兼顾并发和开发效率 | 实现复杂,数据区要加锁 |
工业现场典型的组网场景是几十台上位机同时读取一台设备,连接数很少超过三位数,所以这个资源里用的是每连接一线程——简单、直观、不容易引入难排查的并发 bug。如果你要接入的节点数特别多,再考虑改造成 epoll,但数据区并发保护的模式是一样的,后面避坑章会详细说。
3. C++服务器骨架:socket监听循环、连接线程与四区寄存器存储
3.1 服务端初始化:端口、地址重用与监听队列
核心骨架通常长这样。先做 socket 初始化,把监听 socket 准备好:
#include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <unistd.h> #include <cstring> int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket create failed"); return -1; } int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡 addr.sin_port = htons(502); // Modbus TCP 标准端口 if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind failed"); close(listen_fd); return -1; } if (listen(listen_fd, 16) < 0) { perror("listen failed"); close(listen_fd); return -1; }这段代码里有三个关键点。SO_REUSEADDR必须设,否则服务器异常重启后端口还处于 TIME_WAIT 状态,bind 直接失败,这是现场最容易碰到的第一个坑。htonl(INADDR_ANY)表示监听本机所有网卡,如果只在特定网卡提供服务,改成inet_addr("192.168.x.x")。listen的第二个参数是等待队列长度,现场设备不多时给 16 足够。
3.2 连接接受循环:每个客户端一个独立线程
监听建立后,进入 accept 循环。一个新连接到来就创建一个线程去处理:
while (true) { struct sockaddr_in client_addr; socklen_t addr_len = sizeof(client_addr); int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &addr_len); if (conn_fd < 0) { perror("accept failed"); continue; } // 每个连接一个线程,detach 后由系统回收 std::thread([conn_fd, client_addr]() { handle_client(conn_fd); close(conn_fd); }).detach(); }用detach()的线程不需要手动 join,连接关闭时资源自动回收,逻辑上最省事。handle_client(conn_fd)就是读请求、解析、查数据区、回包的完整循环。这里要留意一点:client_addr按值捕获到 lambda 里,是为了记录日志时能看到对端 IP;如果不需要日志,只捕获conn_fd即可。
这个模式的边界要想清楚:每连接一线程意味着有多少个 socket 连接就有多少个线程,默认线程栈大小是 8MB,如果现场有几十个连接,虚拟内存占用会涨上去,但通常没到撑爆的程度。若你的设备要支撑几百个连接,再去考虑线程池改造。
3.3 寄存器数据区:四个存储区各有各的内存语义
Modbus 服务器的数据区按功能码分为四类:线圈(Coil)、离散输入(Discrete Input)、保持寄存器(Holding Register)、输入寄存器(Input Register)。存储设计上,用四个数组最直接:
#include <vector> #include <mutex> struct ModbusDataStore { // 按 bit 存储的线圈区,通常用 bool 数组模拟 std::vector<bool> coils; // 可读写,对应 0x01/0x05/0x0F std::vector<bool> discrete_inputs; // 只读,对应 0x02 std::vector<uint16_t> holding_regs; // 可读写,对应 0x03/0x06/0x10 std::vector<uint16_t> input_regs; // 只读,对应 0x04 std::mutex mtx; // 数据区互斥锁 ModbusDataStore() : coils(1024, false), discrete_inputs(1024, false), holding_regs(10240, 0), // 10K 个保持寄存器 input_regs(10240, 0) {} };数据区大小按需调整。10K 个保持寄存器已经能满足常见组态软件的需求;如果只是给测试用,初始化为 1024 个也够。这里用std::vector<bool>存线圈有个要注意的坑:vector<bool>是压缩位存储的,返回的是代理对象而不是真正 bool 引用,多线程下读写同一索引时要小心,把它当普通数组去并发访问会出现数据竞争。更稳妥的做法是用std::vector<uint8_t>配合位运算自己管理,后面避坑章展开讲。
这个ModbusDataStore是全局唯一实例,所有客户端线程共享。读取很简单,但写入必须走互斥锁,否则两个客户端同时写保持寄存器,轻则数据错乱,重则崩溃。
4. 功能码处理与异常码:读保持寄存器、批量写入的边界规则
4.1 请求包解析:拆 MBAP 和 PDU 之前先检查长度
TCP 是流协议,客户端可能一次发多个请求,也可能一个请求分几段发。处理的第一步是收满一个完整的请求帧再解析。Modbus TCP 的帧边界靠 Length 字段来确认:
bool read_request(int conn_fd, uint8_t* buffer, int buffer_size) { const int MBAP_LEN = 7; int received = 0; // 先读 MBAP 头 7 字节 while (received < MBAP_LEN) { int n = recv(conn_fd, buffer + received, MBAP_LEN - received, 0); if (n <= 0) return false; received += n; } // 从 Length 字段得到剩余字节数 int remain = (buffer[4] << 8) | buffer[5]; while (received < MBAP_LEN + remain) { int n = recv(conn_fd, buffer + received, MBAP_LEN + remain - received, 0); if (n <= 0) return false; received += n; } return true; }这里用buffer[4] << 8 | buffer[5]从 MBAP 头里取出 Length 字段。注意 Length 包含 Unit ID 那 1 个字节,所以剩余要收的字节数是remain,但总收包长度是MBAP_LEN + remain。第一次读的时候如果只来了半个请求,第二次 recv 会继续收,直到收满为止。
实际工程里还要加最大报文长度校验。Modbus TCP 的 PDU 最大 253 字节,加上 MBAP 就是 260 字节,超过这个长度的帧直接丢弃并关闭连接,防止对端发超大包把缓冲区撑爆。缓冲区数组大小给 1024 足够,用uint8_t而不是char,避免符号扩展在后续移位运算中引入难查的错误。
4.2 读保持寄存器(0x03)与写单寄存器(0x06)
实现 0x03 的响应逻辑,注意地址偏移和数量校验:
bool handle_read_holding(uint8_t unit_id, const uint8_t* req, uint8_t* resp, int& resp_len) { // req[0] 是功能码,地址在 req[1..2],数量在 req[3..4] int start_addr = (req[1] << 8) | req[2]; int quantity = (req[3] << 8) | req[4]; // 数量校验:最多读 125 个寄存器 if (quantity < 1 || quantity > 125) { resp[0] = 0x83; // 功能码 | 0x80 resp[1] = 0x03; // 异常码:非法数据值 resp_len = 2; return true; } std::lock_guard<std::mutex> lock(store.mtx); if (start_addr + quantity > store.holding_regs.size()) { resp[0] = 0x83; resp[1] = 0x02; // 异常码:非法数据地址 resp_len = 2; return true; } resp[0] = req[0]; resp[1] = (uint8_t)(quantity * 2); // 后续数据的字节数 for (int i = 0; i < quantity; i++) { uint16_t val = store.holding_regs[start_addr + i]; resp[2 + i * 2] = val >> 8; // 高字节在前 resp[3 + i * 2] = val & 0xFF; } resp_len = 2 + quantity * 2; // MBAP 头的 Length 字段要在外面统一填写 return true; }两个边界条件必须锁死:数量上限 125 是协议规定的,一次读超过 125 个寄存器直接返回非法数据值;地址加上数量越界返回非法地址。响应里每个寄存器都按大端序放,高 8 位在前低 8 位在后,这是 Modbus 的硬性规定,搞反了数据根本没法看。
0x06 写单个寄存器的逻辑更简单,请求里 PDU 是0x06 地址高 地址低 数据高 数据低,响应则按协议要求,把收到的请求原样返回给主站。唯一要注意的是地址有效性检查,以及写入时必须持有互斥锁。写成功的标志不是返回 OK,而是返回的报文和请求报文完全一致,主站是靠着响应和请求逐字节比对来判断的。
4.3 写多个寄存器(0x10)的逻辑与常见误用
0x10 是批量写寄存器,请求格式比 0x06 复杂:0x10 起始地址(2B) 寄存器数量(2B) 字节数(1B) 数据(数量*2B)。响应则是0x10 起始地址(2B) 寄存器数量(2B)。
bool handle_write_multi_holding(const uint8_t* req, uint8_t* resp, int& resp_len) { int start_addr = (req[1] << 8) | req[2]; int quantity = (req[3] << 8) | req[4]; int byte_count = req[5]; // 校验:字节数必须等于数量 * 2 if (byte_count != quantity * 2) { resp[0] = 0x90; resp[1] = 0x03; resp_len = 2; return true; } std::lock_guard<std::mutex> lock(store.mtx); if (start_addr + quantity > store.holding_regs.size()) { resp[0] = 0x90; resp[1] = 0x02; resp_len = 2; return true; } // 数据区写入,同样按大端序解析 for (int i = 0; i < quantity; i++) { uint16_t val = (req[6 + i * 2] << 8) | req[7 + i * 2]; store.holding_regs[start_addr + i] = val; } resp[0] = 0x10; resp[1] = req[1]; resp[2] = req[2]; resp[3] = req[3]; resp[4] = req[4]; resp_len = 5; return true; }这个地方最常见的误用是把响应格式搞错。有些照着 0x06 的响应方式,把整段写进去的数据又原样回传一遍,主站收到就解析失败。协议要求的响应就是 5 个字节,不带任何数据内容,这个务必严格按协议来。
4.4 异常码的生成规则和对照表
服务器端判定请求不合法时,响应包要把功能码的最高位置 1(即功能码加 0x80),后面跟一个异常码。比如 0x03 请求地址越界,响应首字节是 0x83,第二个字节是 0x02。异常码优先级也是有讲究的:
| 异常码 | 含义 | 触发场景 | 优先级 |
|---|---|---|---|
| 0x01 | 非法功能 | 不支持的功能码 | 先于地址校验 |
| 0x02 | 非法数据地址 | 起始地址越界、地址+数量越界 | 先于数据值校验 |
| 0x03 | 非法数据值 | 数量超上限、字节数不匹配 | 最后 |
| 0x04 | 服务器故障 | 内部状态异常或处理失败 | 通用兜底 |
| 0x06 | 服务器忙碌 | 正在处理长任务无法响应 | 很少用 |
在设备联调现场,主站日志里出现异常码 0x02 通常是地址配置错了,而不是服务器坏了。先查组态软件里的寄存器地址映射,再查服务器的数据区大小,比对两组地址范围,就能定位问题。异常码 0x03 则基本是两个原因:数量超了 125 的读取上限,或者 0x10 请求里字节数填的和实际数据不对。
5. 避坑清单:端口权限、字节序、共享数据区和连接泄漏的五个现场
这一章是从实际联调里踩出来的血泪经验。每条都是现象、原因、解决三件套,你在自己复现时遇到同样问题可以直接对照。
5.1 端口 502 启动被拒:非 root 权限的经典翻车
现象:编译好的服务器在嵌入式设备上运行,bind直接报Permission denied,进程起不来。在开发机上用 5020 端口测一切正常,一换到 502 就失败。
原因:502 属于小于 1024 的特权端口,Linux 系统要求进程具备 root 权限或 CAP_BIND_SERVICE 能力才能绑定。开发机上你可能是普通用户,能成功绑定 5020,但换到生产环境的端口约定就成了问题。
解决:两种常见做法。一是部署时用 root 启动,二是给二进制文件单独授予绑定低端口的权限。更推荐第二种,避免让整个进程以 root 身份运行。
# 给服务器二进制文件添加绑定低端口的能力 sudo setcap cap_net_bind_service=+ep ./modbus_tcp_server # 查看是否设置成功 getcap ./modbus_tcp_server注意这个能力在文件被重新编译或替换后需要重新设置。另外如果在容器里跑,还要确认容器启动时没有用非 root 用户的限制。
5.2 字节序整错:读出来的数据全部大小头倒置
现象:用主站软件能连上服务器,0x03 也能正常响应,但读到的数值完全不对。比如服务器里存的 0x1234,读出来变成 0x3412;写入的 0x5678,再读回来变成 0x7856。浮点数更是直接变成乱码。
原因:把主机的字节序直接往缓冲区里写,没有做大小端转换。x86 系列主机是小端序,Modbus 协议要求大端序,即高字节在前、低字节在后。只做memcpy把uint16_t塞进响应缓冲区,发送时就是低字节在前,主站按大端解析自然读反。
解决:所有和协议缓冲区交互的 16 位数据,必须显式拆分高低字节。我一般在代码里固定用移位拼接,绝不依赖htonl/ntohl这类与平台相关的函数:
// 写响应:value 拆成高字节和低字节 resp[i * 2] = (value >> 8) & 0xFF; resp[i * 2 + 1] = value & 0xFF; // 读请求:拼出地址或数量 uint16_t start = (req[1] << 8) | req[2];判断是否踩中这个坑的方法很简单:用主站工具写一个已知值,比如 0x1234,再读回来。读出来是 0x3412 那就是字节序问题,不是数据区赋值的问题。
5.3 多个客户端并发读写导致数据错乱
现象:一台服务器接两台主站,A 主站写入寄存器,B 主站读同一寄存器。逻辑上 B 应该读到 A 的写入值,但实际偶尔读到旧值,甚至程序崩溃。单客户端测试时正常,多客户端一开就出问题。
原因:handle_client里对数据区的读写没加锁,或者加锁的范围没覆盖整个“读地址→取数据→组响应”的过程。多个线程同时访问std::vector<bool>或std::vector<uint16_t>,数据竞争是未定义行为,表现就是读到半新半旧的值,严重时直接段的错误。
解决:用std::mutex保护整个数据区操作,并且注意锁的粒度控制。在 0x03 的处理函数里,锁要拿到store.holding_regs.size()检查之后,整个响应组装完再释放,中间不能解锁再重入。如果单次批量处理时间太长,会造成其他客户端短暂等待,但工业现场读寄存器都是微秒级操作,这个等待可以忽略。
补充一个细节:std::vector<bool>不是普通数组,它是位压缩存储,并发读写同一个 bool 元素和读写相邻 bool 元素,操作的是同一个底层字节,这本身就存在竞争。建议把线圈区改成std::vector<uint8_t>,每个线圈占一个字节,牺牲一点内存换并发安全,省心得多。
5.4 客户端异常断开后连接不释放:句柄泄漏的隐患
现象:服务器连续运行几天后,用netstat -antp查看发现大量ESTABLISHED状态的连接,进程句柄数涨到几千,新客户端连不进来,或者服务整体变慢。
原因:只处理了正常请求循环,没处理 recv 返回 0 和对端 RST 的情况。客户端断电、网线拔掉、主站程序强退时,TCP 连接不会主动通知服务器,recv要么返回 0(对端关闭)要么超时,但代码里没有针对这两种情况的退出逻辑,线程就一直阻塞在读操作上。
解决:在连接处理函数里明确收包失败的处理路径,同时加上空闲超时保护。
struct timeval tv; tv.tv_sec = 30; // 30 秒无数据则关闭连接 tv.tv_usec = 0; setsockopt(conn_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); // recv 返回 0 或 -1 且 errno 不是 EINTR 时,直接关闭连接并退出设置SO_RCVTIMEO后,recv在超时时间内等不到数据会返回 -1 且errno为EAGAIN或EWOULDBLOCK,这时也要关闭连接。工业主站一般有周期轮询,30 秒没有任何数据基本可以断定连接已经死了。这个参数按现场轮询周期调整,别设太短导致正常的长空闲连接被误杀。
5.5 单元 ID 不匹配导致的“连得上但读不到数据”
现象:服务器 IP 和端口都能连通,主站发送请求后服务器有响应,但响应内容显示超时或报错。用抓包工具看,请求和响应都在,事务 ID 也对得上,就是主站不认。
原因:Modbus TCP 的请求里带了 Unit Identifier,主站期望从站返回同样的 Unit ID。服务器在解析时忽略了这个字段,处理完业务后把响应包里的 Unit ID 填成固定值(比如 0),或者直接把请求拷贝后没改这个字节,导致响应里的 Unit ID 和请求不一致。
解决:响应组装时把请求里的第 6 个字节(MBAP 头里 Unit ID 的位置)原样拷贝到响应里。这个规则是协议的握手基础,不需要做任何逻辑判断,原样送回即可。如果服务器打算做多从站聚合,则要根据 Unit ID 路由到不同的数据区,但每连接一客户端的模型下,直接原样回填是最稳妥的。
这个坑隐蔽在不起眼的位置。抓包看响应时,Transaction ID、Protocol ID、Length 都容易注意到,唯独 Unit ID 这个单字节最容易漏。我之前在这个问题上卡了半个下午,最后用主站工具逐字节看报文才定位到,从那以后每次写完响应函数,第一件事就是检查 Unit ID 有没有按照原样回填。
6. 交付前的验证闭环:手搓客户端、异常码遍历与几处性能调优
服务器写完,验证环节千万别省。用现成的主站调试工具连一下能通,但离交付还有距离。我习惯先用一个手写的 Python 脚本把核心功能码全测一遍,再做异常码遍历,最后才接真实主站。
手搓客户端的思路很直接:用 socket 构造一个 0x03 读保持寄存器请求,和服务器走一遍完整交互,断言返回的数据和预期一致:
import socket import struct def build_request(transaction_id=1, unit_id=1, func=0x03, addr=0x0000, qty=0x000A): mbap = struct.pack('>HHHB', transaction_id, 0x0000, 1 + 2 + 2 + 1, unit_id) # 这里 length = unit_id 1B + PDU 5B = 6 pdu = struct.pack('>BHH', func, addr, qty) return mbap + pdu s = socket.create_connection(('127.0.0.1', 502), timeout=5) req = build_request(transaction_id=0x1234, func=0x03, addr=0, qty=10) s.send(req) resp = s.recv(1024) # 响应: MBAP头 7B + 功能码 1B + 字节数 1B + 数据 20B = 30B assert len(resp) == 30, f'响应长度异常: {len(resp)}' assert resp[0] == 0x12 and resp[1] == 0x34, '事务ID不匹配' assert resp[7] == 0x03, '功能码错误' assert resp[8] == 20, '数据字节数错误' # 验证第 0 个寄存器的值 value = (resp[9] << 8) | resp[10] print(f'寄存器 0 的值: 0x{value:04X}') s.close()build_request里>HHHB是 Python 的大端模式打包格式,对应 MBAP 头的前四个字段。PDU 部分用>BHH对应功能码、地址、数量,结构清晰不容易写乱。抓响应时特别注意第 0 字节和第 1 字节要和请求的事务 ID 一致,这是验证连接对应关系的基本点。
功能码遍历则是一条条测试边界。0x03 读 0 个寄存器要返回异常码 0x03;0x03 读地址 1023 数量 2 要返回 0x02;0x10 写寄存器时字节数填错要返回 0x03;0x06 写一个不存在的地址要返回 0x02。把这些用例全部跑通,服务器才算出得了门。
性能调优方面,两个参数值得一改。一是前面提过的TCP_NODELAY,关闭 Nagle 算法,减少小报文的延迟;二是发送缓冲区和接收缓冲区的大小,工业主站的标准请求包不超过 260 字节,默认缓冲区已经够用,不需要额外调大。如果在嵌入式低配硬件上跑,可以把数据区数组缩小到实际需要的范围,减少内存占用。
最后留一个习惯:交付前强制遍历一遍异常码。不是只测正常通路,而是把每个异常分支都触发一遍,确保响应里功能码的最高位被正确置 1、异常码取值符合协议定义、Unit ID 原样回填。协议这个东西,正常跑通不算跑通,异常分支响应正确才叫真正的跑通。这个习惯帮我在现场避免了很多次“看起来连上了,一读就超时”的尴尬。代码骨架拿回去之后,先用这个验证脚本过一遍,再上真实主站,顺序别颠倒。希望帮到你。
本文还有配套的精品资源,点击获取