news 2026/9/14 12:55:21

C++实现Modbus Slave从站:地址模型、TCP/RTU与CRC16全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++实现Modbus Slave从站:地址模型、TCP/RTU与CRC16全解析

简介:面向工业通信与嵌入式开发者的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 开始的本区偏移量。

区域位宽读写属性主站视图的地址区间常用功能码
线圈 Coil1 bit可读写00001 ~ 099990x01 / 0x05 / 0x0F
离散输入 Discrete Input1 bit只读10001 ~ 199990x02
保持寄存器 Holding Register16 bit可读写40001 ~ 499990x03 / 0x06 / 0x10
输入寄存器 Input Register16 bit只读30001 ~ 399990x04

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,例如0x830x86。主站就是靠这个最高位区分正常响应和异常响应的,实现时不要在异常路径里复用正常响应的组装函数,否则会把异常码当成寄存器数据回传。

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 ID1MBAP 的 unit_id 为 1,不匹配的帧丢弃
Function03 Read Holding Register触发 0x03 分支,返回字节计数字段
Address0PDU 偏移 0,对应 holding_[0]
Quantity125返回 250 字节数据,单帧上限内的正常路径
Quantity126触发异常 0x83 0x03
Address60000触发 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 errorMBAP 长度回填错误核对长度字段是否等于 pdu_len + 1
CRC errorCRC 字节序反了低字节优先写入报文
偶发超时帧长换算错误,半包被吞检查粘包循环里 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清掉残留字节,避免把历史乱码误判成帧头。把地址越界、数量越限、非法功能码三个异常路径做一组自动化用例,每次改动后用同一组序列回归,这比只测正向路径可靠得多。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 12:54:52

基于Qt与OpenCV DNN的YOLOv5 GPU目标检测桌面应用实战

简介&#xff1a;一套基于Qt部署YOLOv5、并通过OpenCV DNN模块与CUDA实现加速推理的完整项目资料&#xff0c;面向正在准备毕业设计、课程设计或期末大作业的计算机相关专业学生。源码结构完整&#xff0c;涵盖界面设计、推理封装与模型调用等模块&#xff0c;配合文档说明可快…

作者头像 李华
网站建设 2026/9/14 12:53:05

RS485与Modbus分层协作原理及工业通信实战避坑指南

1. 这不是协议之争&#xff0c;而是物理层、数据链路层和应用层的三层协作现场实录你手头那台PLC突然收不到温控器的数据&#xff0c;串口调试助手刷出一堆乱码&#xff1b;现场接线时发现RS485总线上挂了7个从站&#xff0c;一上电就通信中断&#xff1b;用Modbus Poll测试时明…

作者头像 李华
网站建设 2026/9/14 12:52:52

GoFr 如何连接 Couchbase 执行 KV 读写与 N1QL 查询

GoFr 如何连接 Couchbase 执行 KV 读写与 N1QL 查询 【免费下载链接】gofr An opinionated GoLang framework for accelerated microservice development. Built in support for databases and observability. 项目地址: https://gitcode.com/GitHub_Trending/go/gofr 在…

作者头像 李华