简介:面向工业通信与嵌入式开发者的Modbus TCP从站仿真工程,基于C++实现,支持灵活配置寄存器起始地址与数据长度,可模拟多种寄存器的数据上送行为。压缩包共90个文件,以13个h头文件、11个cpp源文件为核心,并包含可直接运行的exe程序及调试符号文件,整体仅7.12MB,便于快速查阅与二次开发。目前已有270人学习下载,适合需要验证Modbus主站功能、开展协议联调或入门从站协议栈实现的开发人员。通过该源码可以直观梳理寄存器地址映射、请求帧解析与响应报文构造的关键流程;工程内保留的VC6.0项目文件也便于在经典开发环境中直接编译调试,结合可执行程序快速搭建仿真从站,为工控数据采集、监控软件测试等场景提供实用参考。
1. 为什么要在 C++ 里自己实现一个 Modbus Slave
Modbus 是工业现场应用最广泛的传感器总线协议之一,但大多数工程师常年只写主站(Master),一旦需要实现从站(Slave),很容易被“被动响应”这件事卡住。主站节奏可以自己控制,从站却要始终在线,毫秒级处理请求,地址越界、功能码不存在、CRC 错位都要快速给出异常响应,而不是让主站等到超时。在 C++ 里实现一个 MB_Slave 类,不是为了替代成熟商业软件,而是当你要把寄存器数据和自己的业务字段绑定、要在产测脚本里模拟设备行为、或是做协议转换网关时,一个结构清晰的从站比黑盒更可控。这篇文章按“地址模型 → TCP 最小实现 → RTU 串口扩展 → ModbusPoll 验证”推进,把从设计到测试的关键点一次说清。
2. 设计从站前必须理清的 Modbus 地址模型与功能码
2.1 四个数据区各自独立,不要做成一个扁平数组
第一次写从站的人,最容易把内存简化成一个uint16_t regs[65536]。Modbus 协议对一个设备的存储区划分是四块独立区域,每块的访问语义完全不同:线圈(Coil)是 1 位、可读可写;离散输入(Discrete Input)是 1 位、只读;保持寄存器(Holding Register)是 16 位、可读可写;输入寄存器(Input Register)是 16 位、只读。四个区的地址范围互不连续,PLC 侧看到的“40001”这类地址编号是主站视图,从站 PDU 里传输的其实是从 0 开始的本区偏移量。
| 区域 | 位宽 | 读写属性 | 主站视图的地址区间 | 常用功能码 |
|---|---|---|---|---|
| 线圈 Coil | 1 bit | 可读写 | 00001 ~ 09999 | 0x01 / 0x05 / 0x0F |
| 离散输入 Discrete Input | 1 bit | 只读 | 10001 ~ 19999 | 0x02 |
| 保持寄存器 Holding Register | 16 bit | 可读写 | 40001 ~ 49999 | 0x03 / 0x06 / 0x10 |
| 输入寄存器 Input Register | 16 bit | 只读 | 30001 ~ 39999 | 0x04 |
C++ 容器选型上,位区建议用std::vector<uint8_t>,寄存器区用std::vector<uint16_t>,各自按容量在初始化时分配。std::vector<bool>是标准库特化,operator[]返回代理对象,拿不到左值引用,批量置位和交接给旧代码都很别扭;用uint8_t存 0/1 只是多耗点内存,换来的是内存语义完全一致。四个独立容器还有一个好处:地址校验简化为addr + count <= 本区容量一次比较,不会发生跨区串位。
bool MB_Slave::init(size_t coils, size_t discrete, size_t holding, size_t input) { coils_.assign(coils, 0); // 0x 区,位状态,存 0/1 discrete_.assign(discrete, 0); // 1x 区,只读位 holding_.assign(holding, 0); // 4x 区,读写字 input_.assign(input, 0); // 3x 区,只读字 coils_n_ = coils; discrete_n_ = discrete; holding_n_ = holding; input_n_ = input; return true; }容量固定还有一个实际考虑:从站的寄存器表通常在设备上电时就要确定,地址越界大多意味着上层组态配错了,与其让从站动态扩张掩盖问题,不如在初始化阶段就把表长锁死。真需要动态扩容的业务,可以在类外面包一层“协议地址到容器索引”的映射表,不必改动从站核心逻辑。
2.2 支持哪些功能码:从常见主站的视角倒推
从站协议栈不必把 Modbus 应用协议里的功能码全部实现,真正被工业主站高频使用的是八个:读线圈 0x01、读离散输入 0x02、读保持寄存器 0x03、读输入寄存器 0x04、写单线圈 0x05、写单寄存器 0x06、写多线圈 0x0F、写多寄存器 0x10。像 0x07 读异常状态这类功能码,多数设备实现里都作为保留处理,直接回 0x01 非法功能即可。
这八个功能码按处理逻辑可以分成两组。单寄存器组(0x01、0x02、0x03、0x04、0x05、0x06)请求里只带地址和数据,帧长固定,解析简单;多寄存器组(0x0F、0x10)带数量字段,数量上限受 PDU 长度约束——线圈和离散输入单帧最多 2000 位,保持寄存器和输入寄存器单帧最多 125 个字。这个数字不是拍脑袋定的,它由“PDU 最大 253 字节减去功能码、地址、数量、字节数前缀”倒推而来。
多寄存器组的返回帧结构也容易搞混:读操作返回“字节计数字段 + 数据”,写操作返回“地址 + 数量”原样回显。判断标准只有一个,看功能码,不要试图从数据内容反推。实现时建议把单寄存器和多寄存器分两个内部函数处理,不要全部堆在同一个 case 分支里,否则数量字段的边界判断很容易写得重复且不一致。
2.3 异常响应的触发顺序:功能码、地址、数量
请求总有不合法的时候。Modbus 规定从站对非法请求必须回异常帧:功能码位置放“原功能码 | 0x80”,后续跟一个异常码。0x01 非法功能、0x02 非法数据地址、0x03 非法数据值是最常用的三个。
| 异常码 | 含义 | 触发场景举例 |
|---|---|---|
| 0x01 | 非法功能码 | 收到 0x07、0x08 等未实现功能码 |
| 0x02 | 非法数据地址 | 起始地址超过容量,或地址+数量-1 超出 |
| 0x03 | 非法数据值 | 数量字段为 0,或数量超过单帧上限 |
判断顺序值得在联调前确认:先判断功能码是否支持,再判断地址区间,最后判断数量合法性。这个顺序对应 PDU 里字段的解析顺序,也能让畸形帧在进入数据拼装前就被拦截。0x02 和 0x03 的边界在协议规范里没有严格定义,工控行业的通行做法是:地址越界回 0x02,数量越限回 0x03。如果你做的是通用协议转换网关,按这个划分最稳妥,因为主流主站软件对这两个异常码的提示信息是不同文案。
提示:异常帧的功能码最高位会被置 1,例如
0x83、0x86。主站就是靠这个最高位区分正常响应和异常响应的,实现时不要在异常路径里复用正常响应的组装函数,否则会把异常码当成寄存器数据回传。
3. 用 C++ 实现最小可用的 Modbus TCP Slave 内核
3.1 类的对外接口、容器与锁粒度
MB_Slave 类的设计目标是“一个实例就是一个设备实例”。对外提供两套操作接口:一套给业务线程读写寄存器,另一套给协议线程收发报文。两套接口在数据区上存在并发访问的可能,因此用一把std::mutex把它们统一保护起来。
// mb_slave.h #pragma once #include <cstdint> #include <vector> #include <mutex> #include <string> class MB_Slave { public: explicit MB_Slave(uint8_t unit_id = 1) : unit_id_(unit_id) {} bool init(size_t coils, size_t discrete, size_t holding, size_t input); bool start(int port = 502); void stop(); // 业务侧接口 uint16_t getHolding(uint16_t addr) const; void setHolding(uint16_t addr, uint16_t val); bool getCoil(uint16_t addr) const; void setCoil(uint16_t addr, bool val); // 协议侧:处理一个完整 PDU 帧(不含 MBAP 和串口地址) int handleFrame(const uint8_t* pdu, size_t pdu_len, uint8_t* out_pdu, size_t out_cap); // RTU 串口模式 bool open(const std::string& dev, int baud); private: uint8_t unit_id_; size_t coils_n_ = 0, discrete_n_ = 0, holding_n_ = 0, input_n_ = 0; std::vector<uint8_t> coils_, discrete_; std::vector<uint16_t> holding_, input_; mutable std::mutex mtx_; int listen_fd_ = -1, client_fd_ = -1, serial_fd_ = -1; bool running_ = false; };锁的粒度我选择全局限住整个 handleFrame,而不是为每个寄存器上细粒度锁。单帧处理耗时在微秒级,互斥量竞争开销已经接近业务耗时,再拆分读写锁只会让复杂度上升,收益趋近于零。业务侧的 getHolding / setHolding 也走同一把锁,避免协议线程读到写了一半的 16 位字段。
TCP 监听 socket 初始化要设置SO_REUSEADDR选项:开发期频繁重启程序,若上一个连接的 TIME_WAIT 还没释放,bind 会直接失败,这个选项允许地址立即复用。
bool MB_Slave::start(int port) { listen_fd_ = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd_ < 0) return false; int opt = 1; setsockopt(listen_fd_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; addr.sin_port = htons((uint16_t)port); if (bind(listen_fd_, (sockaddr*)&addr, sizeof(addr)) < 0) return false; if (listen(listen_fd_, 4) < 0) return false; return true; }监听队列长度设置为 4,对单个从站场景已经够用;要做到多主站并发接入,就得在 accept 循环里维护客户端 fd 集合,并考虑对每个连接做超时管理,那已经超出“最小实现”的范畴。单线程模型下,一次只服务一个客户端连接是最稳妥的起点。
3.2 MBAP 头解析:事务标识必须原样回填
Modbus TCP 的请求在 PDU 前加 7 字节 MBAP 头,顺序是事务标识符(2)、协议标识符(2)、长度(2)、单元标识符(1)。事务标识符是主站配对请求和响应的标记,从站必须原样带回;协议标识符固定为 0,否则视为非法报文;长度字段统计的是“单元标识符加 PDU”的字节数,不是整个帧的长度。
// 输入一个完整帧,输出 PDU 指针和长度 bool parseMbap(const uint8_t* frame, size_t frame_len, uint16_t& trans_id, uint16_t& proto_id, uint16_t& mbap_len, uint8_t& unit_id, const uint8_t** pdu, size_t* pdu_len) { if (frame_len < 7) return false; trans_id = (uint16_t)((frame[0] << 8) | frame[1]); proto_id = (uint16_t)((frame[2] << 8) | frame[3]); mbap_len = (uint16_t)((frame[4] << 8) | frame[5]); unit_id = frame[6]; if (proto_id != 0 || mbap_len < 2) return false; if ((size_t)mbap_len + 6 > frame_len) return false; *pdu = frame + 7; *pdu_len = mbap_len - 1; // 长度字段含 unit_id,减掉才是 PDU return true; }这里常出的偏差是位移多算 1:整个帧长度是mbap_len + 6,而不是mbap_len + 7,因为 mbap_len 本身包含了 unit_id 那一字节。PDU 长度等于mbap_len - 1,如果这个值小于函数码需要的最小长度,直接丢弃,避免后续 switch 越界读取。
3.3 PDU 分发:switch 里处理八种功能码
handleFrame 接收去掉 MBAP 之后的 PDU,输出的也是 PDU,这样 TCP 和 RTU 两个传输层可以共用同一套协议解析逻辑。下面以最常用的读保持寄存器 0x03 和写单个寄存器 0x06 为例:
int MB_Slave::handleFrame(const uint8_t* pdu, size_t pdu_len, uint8_t* out, size_t out_cap) { if (pdu_len < 2) return -1; uint8_t func = pdu[0]; std::lock_guard<std::mutex> lock(mtx_); out[0] = func; // 功能码先占位 size_t rp = 1; switch (func) { case 0x03: { // 读保持寄存器 if (pdu_len < 5) return -1; uint16_t addr = (uint16_t)((pdu[1] << 8) | pdu[2]); uint16_t cnt = (uint16_t)((pdu[3] << 8) | pdu[4]); if (cnt == 0 || cnt > 125) { out[0] = 0x83; out[1] = 0x03; return 2; } if (addr >= holding_n_ || (size_t)addr + cnt > holding_n_) { out[0] = 0x83; out[1] = 0x02; return 2; } out[rp++] = (uint8_t)(cnt * 2); for (uint16_t i = 0; i < cnt; ++i) { uint16_t v = holding_[addr + i]; out[rp++] = (uint8_t)(v >> 8); out[rp++] = (uint8_t)(v & 0xFF); } return (int)rp; } case 0x06: { // 写单个寄存器 if (pdu_len < 5) return -1; uint16_t addr = (uint16_t)((pdu[1] << 8) | pdu[2]); uint16_t val = (uint16_t)((pdu[3] << 8) | pdu[4]); if (addr >= holding_n_) { out[0] = 0x86; out[1] = 0x02; return 2; } holding_[addr] = val; out[1] = (uint8_t)(addr >> 8); out[2] = (uint8_t)(addr & 0xFF); out[3] = (uint8_t)(val >> 8); out[4] = (uint8_t)(val & 0xFF); return 5; } default: out[0] = (uint8_t)(func | 0x80); out[1] = 0x01; // 非法功能码 return 2; } }这段代码里三个细节决定兼容性。地址和数量按大端解析,x86 是小端主机,不能直接把两字节 memcpy 成uint16_t。0x03 返回的字节计数字段是“寄存器数量 × 2”,不是数量本身。异常响应的长度固定是 2,TCP 层和 RTU 层不需要区分正常帧和异常帧,它们只是 PDU 内容不同。0x06 写成功后回显“地址+值”,这是协议规定行为,主站靠这份回显确认写入已生效,不要改成读回再写。
多寄存器功能码 0x10 的响应是回显请求里的地址和数量,而 0x0F 的响应同样是回显;真正写多寄存器的处理逻辑放在解析与校验之后,一次性写入对应容器。数量字段拆成高低字节拼装的时候,注意循环写出的顺序保持大端。每个 case 的边界检查都要重复“地址越界、数量越限、返回异常码”三个动作,保持模式一致,便于 code review 时一眼扫出漏判。
3.4 粘包与半包:接收循环的帧切分
TCP 是字节流,一次 recv 不一定刚好收到一个请求,也可能一包收到多个请求。标准做法是维护接收缓冲,依赖 MBAP 长度字段逐帧切分:
uint8_t buf[1024], frame[512]; size_t have = 0; while (running_) { if (client_fd_ < 0) { client_fd_ = accept(listen_fd_, nullptr, nullptr); continue; } ssize_t n = recv(client_fd_, buf + have, sizeof(buf) - have, 0); if (n <= 0) { close(client_fd_); client_fd_ = -1; have = 0; continue; } have += (size_t)n; while (have >= 7) { uint16_t mbap_len = (uint16_t)((buf[4] << 8) | buf[5]); if (mbap_len < 2 || (size_t)mbap_len + 6 > have) break; frame[0] = buf[0]; frame[1] = buf[1]; // 事务 id 原样带回 frame[2] = 0; frame[3] = 0; // 协议 id 固定 0 frame[6] = buf[6]; // 单元标识符原样带回 int pdu_len = handleFrame(buf + 7, mbap_len - 1, frame + 7, sizeof(frame) - 7); if (pdu_len > 0) { uint16_t len_field = (uint16_t)(pdu_len + 1); frame[4] = (uint8_t)(len_field >> 8); frame[5] = (uint8_t)(len_field & 0xFF); send(client_fd_, frame, (size_t)pdu_len + 7, 0); } memmove(buf, buf + (size_t)(mbap_len + 6), have - (mbap_len + 6)); have -= (size_t)(mbap_len + 6); } }长度回填写的是pdu_len + 1,不是pdu_len,也不是帧总长,这正好匹配 MBAP 对长度字段“单元标识符加 PDU”的定义。send 的长度是pdu_len + 7,二者相差 6 字节,正好是事务标识符和协议标识符的长度,对不齐就会在 ModbusPoll 里看到 Length Error。处理完用 memmove 前移缓冲区剩余数据,下个循环继续消费。这套逻辑同样适用于 UDP 变体,只是需要额外保存对端地址。
4. 继续推进:Modbus RTU 串口从站与 CRC16
4.1 串口打开的参数配置
RTU 模式把协议直接铺在串口线上,帧结构只有“从站地址 + PDU + CRC16”,没有 MBAP 头。关键的差异在于帧边界判定依赖链路静默时间。首先解决串口参数配置:
bool MB_Slave::open(const std::string& dev, int baud) { serial_fd_ = ::open(dev.c_str(), O_RDWR | O_NOCTTY | O_NDELAY); if (serial_fd_ < 0) return false; struct termios tio{}; tcgetattr(serial_fd_, &tio); speed_t brate; switch (baud) { case 9600: brate = B9600; break; case 19200: brate = B19200; break; case 38400: brate = B38400; break; case 115200: brate = B115200; break; default: ::close(serial_fd_); return false; } // 原始模式:关闭行编辑、回显、软件流控 tio.c_iflag &= ~(ICRNL | IXON | BRKINT | ISTRIP); tio.c_oflag &= ~OPOST; tio.c_lflag &= ~(ICANON | ECHO | ISIG); tio.c_cflag |= (CLOCAL | CREAD | CS8); tio.c_cc[VTIME] = 1; tio.c_cc[VMIN] = 0; cfsetispeed(&tio, brate); cfsetospeed(&tio, brate); tcsetattr(serial_fd_, TCSANOW, &tio); return true; }三个参数决定行为:O_NDELAY防止 open 在等待 Modem 信号时挂死;VTIME=1表示 100ms 无数据则 read 返回,这是接收循环的安全兜底;CS8配置 8 数据位、无奇偶校验,是 RTU 最常用的电气参数。需要偶校验的设备再加PARENB,同时主站侧串口参数必须一致,否则底层就解错位,表现为整帧乱码。
调试串口前建议先用命令把端口固定成原始模式再跑程序,能排除环境参数干扰:
stty -F /dev/ttyUSB0 115200 raw程序里termios的配置会和这条命令互补,最终以代码内的设置为准。
4.2 CRC16 计算与发送字节序
Modbus RTU 的 CRC 算法是 CRC-16/MODBUS,多项式 0xA001,初始值 0xFFFF。逐位计算只有十几行,测试方便;查表法适合连续接收多帧的网关场景,空间换时间,代码边界也更清晰。
static std::array<uint16_t, 256> make_crc_table() { std::array<uint16_t, 256> t{}; for (uint16_t i = 0; i < 256; ++i) { uint16_t c = i; for (int k = 0; k < 8; ++k) c = (c & 1) ? (uint16_t)((c >> 1) ^ 0xA001) : (uint16_t)(c >> 1); t[i] = c; } return t; } uint16_t crc16_rtu(const uint8_t* data, size_t len, const std::array<uint16_t, 256>& table) { uint16_t crc = 0xFFFF; for (size_t i = 0; i < len; ++i) { uint8_t idx = (uint8_t)((crc ^ data[i]) & 0xFF); crc = (uint16_t)((crc >> 8) ^ table[idx]); } return crc; }字节序陷阱在收发两端都会出现:发送时 CRC 低字节在前,高字节在后。接收端把帧尾两字节按低字节在前拼成uint16_t,再与对一帧重新计算的值比较。很多人在这段卡住,因为数值明明和抓包工具里显示的一样,却总是 CRC error——多半是自己按高字节在前拼接对比值了。
| 现象 | 可能原因 | 检查动作 |
|---|---|---|
| 主站持续报 CRC error | 发送或接收时字节序反了 | 把 CRC 低字节先写入报文,接收端低字节在前拼值 |
| 偶尔一帧 CRC error | 帧间隔不够,从站收进残帧 | 串口打十六进制日志,对比每帧长度 |
| 所有请求超时 | 从站地址不匹配,请求被丢弃 | 核对 unit_id 和主站设置 |
| 偶发数据错位 | 半包拼接进下一帧 | 检查静默时间判定阈值 |
4.3 RTU 接收循环中的帧切分
read 循环不断读单字节,累积到接收缓冲,再判定一帧是否完整。判定方式有三种:按功能码推算固定长度、按 3.5 字符时间静默、或用固定超时认定一帧结束。第三方从站和 PLC 对 3.5 字符时间的实现都留了余量,所以更简单的 10ms 固定超时也能在多数现场工作稳定。
uint8_t rx[256]; size_t rx_len = 0; while (running_) { uint8_t b; ssize_t n = read(serial_fd_, &b, 1); if (n != 1) { check_frame_timeout(); continue; } rx[rx_len++] = b; if (rx_len >= 256) { rx_len = 0; continue; } if (timeout_elapsed()) { // 超过 10ms 没有新字节,视为一帧结束 if (rx_len >= 4 && rx[0] == unit_id_) { uint16_t calc = crc16_rtu(rx, rx_len - 2, crc_table); uint16_t got = (uint16_t)((rx[rx_len - 1] << 8) | rx[rx_len - 2]); if (calc == got) { uint8_t resp[256]; int pdu_len = handleFrame(rx + 1, rx_len - 3, resp + 1, sizeof(resp) - 1); if (pdu_len > 0) { resp[0] = unit_id_; // 回填从站地址 uint16_t crc = crc16_rtu(resp, (size_t)pdu_len + 1, crc_table); resp[pdu_len + 1] = (uint8_t)(crc & 0xFF); resp[pdu_len + 2] = (uint8_t)(crc >> 8); write(serial_fd_, resp, (size_t)pdu_len + 3); } } } rx_len = 0; } }这里复用 handleFrame 只处理 PDU 的特性,RTU 层负责剥地址和 CRC。handleFrame 入参是rx + 1,长度是rx_len - 3(去掉从站地址和两个 CRC 字节),出参写到resp + 1给地址位留空。CRC 计算要覆盖从站地址加 PDU,所以入参长度是pdu_len + 1。RTU 响应总长度等于 PDU 长度加 3 字节(地址、CRC 两字节),三个长度来源各不相同,建议在代码里写清楚相邻注释,避免日后重构时混用。
5. 用 ModbusPoll 和边界用例把从站钉死
5.1 搭建本地回归测试的配置组合
调试从站最省事的客户端工具是 ModbusPoll,关键是把它配成一台会主动触发边界条件的测试机。先连 TCP 跑通数据通路,再切 RTU 增加串口变量,按下面这组配置覆盖正常路径:
| ModbusPoll 设置项 | 填写的值 | 从站端预期 |
|---|---|---|
| Slave ID | 1 | MBAP 的 unit_id 为 1,不匹配的帧丢弃 |
| Function | 03 Read Holding Register | 触发 0x03 分支,返回字节计数字段 |
| Address | 0 | PDU 偏移 0,对应 holding_[0] |
| Quantity | 125 | 返回 250 字节数据,单帧上限内的正常路径 |
| Quantity | 126 | 触发异常 0x83 0x03 |
| Address | 60000 | 触发 0x83 0x02 地址越界 |
跑完常规路径切 0x06 写单寄存器,写入一个值再切回 0x03 读回来,确认数据落进 holding_。0x05 和 0x0F 的位区操作按同样方式验证。ModbusPoll 和 Modbus Slave 的组合使用,到这里才不只是“能通”,而是把边界也验证过了。
5.2 从异常现象倒推根因的排查顺序
ModbusPoll 一直转圈或显示错误时,按“端口 → 抓包 → PDU 逻辑”的顺序逐层缩窄。日志里的字节要打十六进制,只打“received ok”这类消息对错位定位毫无价值。
| 现象 | 根因 | 处理动作 |
|---|---|---|
| Connect Timeout | 端口未监听、防火墙拦截 | netstat -tlnp 确认 502 端口状态 |
| Connection reset | 业务代码误关了 client_fd | 在 accept 循环打印 fd 变化 |
| Length error | MBAP 长度回填错误 | 核对长度字段是否等于 pdu_len + 1 |
| CRC error | CRC 字节序反了 | 低字节优先写入报文 |
| 偶发超时 | 帧长换算错误,半包被吞 | 检查粘包循环里 mbap_len + 6 的计算 |
5.3 上线前最后补上的三个 C++ 细节
第一,长度计算只能集中在一个函数里。TCP 侧 “MBAP 长度字段 = PDU 长度 + 1” 只应在组装响应的位置出现一次,不要在 handleFrame 内部再算一遍帧长,否则改动 PDU 结构时会漏掉一处换算。第二,handleFrame 已经持有锁,不要在业务回调里又调用 getHolding 或 setHolding,std::mutex不可重入,同一线程会直接死锁。第三,关闭 client_fd 前先shutdown(SHUT_RDWR),让四次挥手由内核走完,否则长跑模拟器会在 TIME_WAIT 上积累连接,耗尽端口后所有新请求都失败。SO_REUSEADDR放在 bind 前设置,串口打开后先tcdrain清掉残留字节,避免把历史乱码误判成帧头。把地址越界、数量越限、非法功能码三个异常路径做一组自动化用例,每次改动后用同一组序列回归,这比只测正向路径可靠得多。
本文还有配套的精品资源,点击获取