基于VS2022 MFC的Modbus TCP转RTU/ASCII协议转换工具实现

基于VS2022 MFC的Modbus TCP转RTU/ASCII协议转换工具实现 简介基于VS2022 MFC框架的Modbus协议转换工具源码面向工业自动化领域的上位机与嵌入式开发者解决Modbus TCP与Modbus RTU/ASCII之间的双向转换及纯透传需求让传统串行设备能够便捷接入现代TCP/IP网络。资源包共55个文件以.h/.cpp源代码、.vcxproj/.sln工程配置、.pdb/.obj编译中间文件、.exe可执行程序为主整体约77.08MB可直接使用Visual Studio 2022打开并编译运行。工程采用MFC标准界面框架模块划分清晰目前已吸引282人学习下载内容涵盖串口通信、TCP通信、参数配置、日志看板与异常处理等核心环节支持南向串口RTU/ASCII及透传、北向TCP及透传的灵活配置便于现场快速调试。源码同时预留扩展接口并保留完整调试信息与运行产物方便开发者快速搭建协议网关或进行二次定制对工业通信中间件的研发和调试具有较高的参考价值。1. 为什么要自己写一个Modbus协议转换工具很多现场还跑着RS485总线的电表、温控器和老式PLC而上位机却已经迁到了TCP/IP网络。中间这段距离就是Modbus协议转换工具的生存空间它把Modbus TCP主站发来的请求翻译成RTU或ASCII帧再通过串口转发给从站从站应答再逆向翻译回去保证两边都认为自己在和原生协议对话。这个基于VS2022 MFC实现的源码核心价值不是那几百个类而是把协议解析、串口收发、TCP监听和界面操作放在了一个可改的对话框程序里。如果你正在做设备网关、上位机集成或者老设备改造又不想被商业化网关的黑盒逻辑卡住这份源码提供了一个能逐行读懂的起点。2. VS2022 MFC工程骨架与串口/网络线程模型2.1 工程结构与关键类划分用VS2022新建MFC对话框应用后源码里的文件分工很明确uart.cpp封装串口打开、配置和读写modbus.cpp处理RTU、ASCII、TCP三种帧的封包与解析modbusDlg.cpp负责窗口控件、启动线程和转发调度。不要把所有逻辑都堆在对话框类里否则后面加串口参数校验或转发规则时界面线程会被阻塞死。对话框初始化时要做两件事加载上次保存的串口号和端口号然后启动网络监听线程。串口一般不急着打开等用户点了“启动转换”再打开避免程序一启动就把COM口占住导致别的调试工具没法用。2.2 串口打开与参数设置串口端直接调用Windows API比引入第三方串口库更可控。下面是打开串口并设置波特率、数据位、校验位的核心代码// uart.cpp 中的串口打开函数 HANDLE OpenSerial(const CString portName, int baud, int dataBits, int stopBits, char parity) { HANDLE hCom CreateFile(portName, GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr); if (hCom INVALID_HANDLE_VALUE) return hCom; DCB dcb { sizeof(DCB) }; GetCommState(hCom, dcb); dcb.BaudRate baud; dcb.ByteSize dataBits; dcb.StopBits (stopBits 1) ? ONESTOPBIT : TWOSTOPBITS; dcb.Parity (parity E) ? EVENPARITY : NOPARITY; dcb.fBinary TRUE; dcb.fParity FALSE; SetCommState(hCom, dcb); COMMTIMEOUTS timeouts { 0 }; timeouts.ReadIntervalTimeout 2; timeouts.ReadTotalTimeoutConstant 20; timeouts.ReadTotalTimeoutMultiplier 0; SetCommTimeouts(hCom, timeouts); return hCom; }逻辑说明CreateFile负责打开设备第三个参数传0表示独占串口避免别的进程同时读写。DCB结构定义串口参数SetCommState应用这些参数COMMTIMEOUTS设置读取超时其中ReadIntervalTimeout是两个字节之间的最大间隔这个值直接决定RTU帧会不会被拆开。参数说明portName需要是\\\\.\\COM3这种完整设备路径baud通常取9600、19200或38400parity传E表示偶校验传N表示无校验。2.3 北向TCP监听线程由于MFC对话框本身有消息循环网络监听必须放到后台线程。用Winsock的阻塞接口配合AfxBeginThread比非阻塞IO更容易理解// 监听线程等待北向TCP主站连接 UINT ListenThread(LPVOID pParam) { CModbusDlg* pDlg (CModbusDlg*)pParam; WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), wsaData); SOCKET listenSock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); SOCKADDR_IN addr { 0 }; addr.sin_family AF_INET; addr.sin_port htons((u_short)pDlg-m_tcpPort); addr.sin_addr.s_addr htonl(INADDR_ANY); bind(listenSock, (SOCKADDR*)addr, sizeof(addr)); listen(listenSock, 4); while (!pDlg-m_bStop) { SOCKADDR_IN clientAddr; int len sizeof(clientAddr); SOCKET client accept(listenSock, (SOCKADDR*)clientAddr, len); if (client INVALID_SOCKET) { if (pDlg-m_bStop) break; continue; } pDlg-HandleClient(client); } closesocket(listenSock); WSACleanup(); return 0; }逻辑说明WSAStartup初始化Winsocksocket创建TCP套接字bind绑定到本机所有网卡的指定端口listen进入监听状态。accept在阻塞模式下会一直等待新连接所以退出循环要依靠m_bStop标志先关闭监听套接字让accept出错返回再退出线程。参数说明m_tcpPort是用户在对话框里输入的端口号m_bStop在OnClose里置TRUE。2.4 双端收发线程与数据队列串口和TCP各自有读写线程线程之间通过一个受临界区保护的字节队列交换数据。下表是这张网里每个线程的职责线程/函数处理对象核心职责ListenThreadTCP监听accept新连接每来一个客户端创建单独会话TcpRecvThreadTCP接收recv北向请求解帧后写入串口发送队列SerialSendThread串口发送取队列里的请求帧WriteFile到串口SerialRecvThread串口接收ReadFile读取从站应答写入TCP发送队列TcpSendThreadTCP发送取队列里的应答帧send给北向主站队列必须用临界区保护否则两个发送线程同时写串口会把两帧数据交叠在一起。最简单的队列操作长这样void CModbusDlg::AppendToQueue(const BYTE* buf, int len) { m_csQueue.Lock(); for (int i 0; i len; i) { m_txQueue.AddTail(buf[i]); } m_csQueue.Unlock(); SetEvent(m_hDataEvent); // 唤醒等待该事件的发送线程 }逻辑说明把收到的一整段字节复制到m_txQueue链表中并触发事件。发送线程原本在WaitForSingleObject上阻塞收到事件后取出所有字节写出去。参数说明m_csQueue是CCriticalSection对象m_hDataEvent是CreateEvent创建的事件句柄m_txQueue是CListBYTE。3. Modbus协议栈RTU/ASCII/TCP帧格式与校验实现3.1 三种帧格式对比Modbus协议家族里RTU、ASCII和TCP的帧结构差别很大。转换工具必须能识别从哪个协议来、到哪个协议去。下面这张表是写协议解析前必须理清的基础协议类型帧起始地址字段功能码数据域校验帧结束Modbus RTU无1字节1字节0~252字节CRC16低字节在前无Modbus ASCII冒号:2个ASCII字符2个ASCII字符最多252个ASCII字符LRC 2字符CR LFModbus TCP无单元标识1字节1字节0~252字节无依赖TCP校验无RTU和TCP的数据部分完全一样区别只在地址字段前多了6字节MBAP头、少了尾部CRC。ASCII则是把RTU的每个字节拆成两个十六进制字符发送再用LRC代替CRC。理解了这种映射转换代码就是在做“剥壳”和“穿壳”。3.2 RTU模式的CRC16实现CRC16是Modbus RTU最重要的校验算法多项式是0xA001。按位计算的版本最直观也方便后期改造成查表版// 计算Modbus CRC16返回值为16位校验值 unsigned short Crc16(const unsigned char* data, int len) { unsigned short crc 0xFFFF; for (int i 0; i len; i) { crc ^ data[i]; for (int bit 0; bit 8; bit) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }逻辑说明初始值固定为0xFFFF每个字节先与当前CRC异或再右移8次遇到最低位为1就异或多项式0xA001。这一步结束后crc要保持低字节在前即发送顺序为crc 0xFF后发crc 8后发。参数说明data是去掉了帧头、包含地址、功能码和数据的原始字节序列len是这些字节的个数不包括CRC本身。3.3 ASCII模式的LRC实现与帧封装ASCII模式的LRC是RTU数据部分所有字节累加后取二进制补码。代码很短unsigned char Lrc(const unsigned char* data, int len) { unsigned char sum 0; for (int i 0; i len; i) { sum data[i]; } return (unsigned char)(-(signed char)sum); }逻辑说明sum累加过程中自动丢弃高字节进位最后用-(signed char)sum取补码。参数说明data是地址、功能码和数据域len是这三部分的总长度。发送时还要把数据字节转成大写十六进制字符再把LRC也转成两个字符最后补上\r\n。下面这段代码把一段RTU数据不含CRC封装成ASCII帧void RtuDataToAscii(const BYTE* rtuData, int dataLen, BYTE* ascBuf, int ascLen) { ascBuf[0] :; int pos 1; for (int i 0; i dataLen; i) { ascBuf[pos] 0123456789ABCDEF[rtuData[i] 4]; ascBuf[pos] 0123456789ABCDEF[rtuData[i] 0x0F]; } unsigned char lrc Lrc(rtuData, dataLen); ascBuf[pos] 0123456789ABCDEF[lrc 4]; ascBuf[pos] 0123456789ABCDEF[lrc 0x0F]; ascBuf[pos] \r; ascBuf[pos] \n; ascLen pos; }逻辑说明先把每个字节的高4位和低4位分别映射成ASCII字符再把LRC追加进去最后加CRLF。注意这里不包含CRC16因为ASCII帧用LRC写转换逻辑时要先判断输出协议再决定调用哪个校验函数。参数说明rtuData是地址功能码数据域dataLen是这三部分总和ascBuf是输出缓冲区至少需要dataLen * 2 4字节。3.4 串口字节流的状态机分帧串口读到的是一串不知道从哪里断开的字节流所以要先判断一帧是否完整。RTU按“静默间隔”分帧即两个字节间隔超过3.5个字符时间就认为是新帧ASCII按:开头、CRLF结束分帧TCP按MBAP长度字段分帧。一个简化的状态机如下enum FRAME_STATE { WAIT_HEAD, WAIT_BODY }; void OnSerialByte(unsigned char ch) { switch (m_state) { case WAIT_HEAD: if (m_modeAscii ch :) { m_frameLen 0; m_state WAIT_BODY; } else if (!m_modeAscii) { m_frameBuf[0] ch; m_frameLen 1; m_state WAIT_BODY; } break; case WAIT_BODY: if (m_modeAscii) { m_frameBuf[m_frameLen] ch; if (ch \n m_frameLen 3) ProcessAsciiFrame(m_frameBuf, m_frameLen); } else { m_frameBuf[m_frameLen] ch; // 通过地址、功能码和长度字段判断完整后交给转码 } break; } }逻辑说明ASCII模式下收到\n就认为帧结束RTU模式则要配合定时器超过3.5字符时间仍未收到新字节就按已收字节处理。这个状态机是协议转换工具的骨架后面所有转码函数都从这里入口。参数说明m_modeAscii由界面上的协议类型下拉框决定m_frameBuf是环形缓存m_frameLen是已接收字节数。4. 协议转换核心从TCP字节流到串口字节流的路由与透传4.1 双向转换的数据流方向转换工具同时承担两个方向北向TCP主站发出请求南向串口从站返回应答。请求方向要把TCP帧去掉MBAP头按输出协议组装成RTU或ASCII帧应答方向要把串口帧剥掉校验补上MBAP头传给TCP主站。方向搞反是新手最容易犯的错写代码前最好先把流程图贴在屏幕上特别是处理异常码时。4.2 TCP请求转RTU帧先看请求方向。接收缓冲区里是一段完整的Modbus TCP帧开头6字节是MBAP头前2字节事务标识、中间2字节协议标识、第5字节后续长度、第6字节单元标识。转换时去掉MBAP头第6字节直接作为RTU地址void OnTcpRequest(const BYTE* tcpFrame, int len, bool toAscii) { if (len 8) return; // MBAP 6字节 功能码至少1字节 BYTE unitId tcpFrame[6]; BYTE func tcpFrame[7]; int dataLen len - 8; // 去掉MBAP和功能码 if (dataLen 0) return; BYTE rtuData[256]; rtuData[0] unitId; rtuData[1] func; memcpy(rtuData 2, tcpFrame 8, dataLen); int frameLen dataLen 2; if (!toAscii) { unsigned short crc Crc16(rtuData, frameLen); BYTE outBuf[260]; memcpy(outBuf, rtuData, frameLen); outBuf[frameLen] crc 0xFF; outBuf[frameLen] (crc 8) 0xFF; WriteSerial(outBuf, frameLen); } else { BYTE ascBuf[520]; int ascLen 0; RtuDataToAscii(rtuData, frameLen, ascBuf, ascLen); WriteSerial(ascBuf, ascLen); } }逻辑说明先把TCP帧里的事务标识、协议标识和单元标识拆出来再拼成没有校验的RTU数据区。如果输出RTU调用Crc16追加两字节如果输出ASCII调用RtuDataToAscii生成:开头、CRLF结尾的ASCII帧。参数说明toAscii是界面上的配置开关len是recv返回的实际字节数注意TCP可能半包完整实现要先按MBAP长度字段拼包再交给这个函数。4.3 串口应答转TCP帧串口从站应答回来后要把RTU或ASCII帧还原成TCP帧。以RTU应答为例原始帧长度是地址1功能码1数据CRC2转成TCP后要剥掉CRCvoid OnSerialResponse(const BYTE* rtuFrame, int len, WORD transactionId) { if (len 4) return; // 地址、功能码、2字节CRC int payloadLen len - 4; // 去掉CRC后的数据长度 BYTE unitId rtuFrame[0]; BYTE func rtuFrame[1]; BYTE tcpFrame[260]; tcpFrame[0] transactionId 8; tcpFrame[1] transactionId 0xFF; tcpFrame[2] 0x00; // 协议标识 tcpFrame[3] 0x00; tcpFrame[4] (BYTE)((payloadLen 2) 8); tcpFrame[5] (BYTE)((payloadLen 2) 0xFF); tcpFrame[6] unitId; memcpy(tcpFrame 7, rtuFrame 1, payloadLen 1); // 功能码数据 SendToTcpClient(tcpFrame, 7 payloadLen 1); }逻辑说明TCP帧的MBAP长度字段等于后续所有字节数即单元标识1字节功能码1字节数据payloadLen字节所以payloadLen 2。应答中的事务标识必须回填请求时的值否则Modbus Poll等主站工具会报超时。参数说明transactionId在收到TCP请求时保存SendToTcpClient内部调用send如果send返回SOCKET_ERROR要主动关闭连接并重发。4.4 透传模式与多从站注意事项透传模式不关心Modbus协议北向收到什么字节就原样写串口串口收到什么就原样发回TCP。这种模式用在一头是自发自收的调试工具或者非标准Modbus变种上。开启透传后要绕过所有转换逻辑直接在两对收发线程之间接一个字节流管道。最简单的实现是在队列里加一个m_bPassthrough标志入库前判断不走协议解析。多从站场景下RTU总线上一帧只能回应一个地址。转换工具要做地址过滤把TCP请求里的单元标识和串口应答里的地址对应起来。如果多个TCP客户端同时往一个串口发请求要串行化否则总线冲突。常见做法是把所有请求帧先放入一个队列串口发送线程逐帧取出等待应答超时后再发下一帧超时时间跟波特率相关建议设为200ms到500ms。还有一个容易踩的坑串口发完一帧后不能立刻读否则RS485收发切换的瞬间会把数据回环进来。用RS485转RS232模块时尤其明显要在发送函数后加一个短暂延时再开始读。5. 配置持久化、日志窗口与MFC界面联调技巧5.1 用INI保存串口和TCP端口配置每次启动都要重新输入串口号很麻烦。用WritePrivateProfileString把配置写到INI文件程序启动时再读出来填到控件里。下面是保存端的代码void SaveConfig(const CString iniPath) { WritePrivateProfileString(_T(Serial), _T(Port), m_comboPort.GetString(), iniPath); WritePrivateProfileString(_T(Serial), _T(Baud), m_comboBaud.GetString(), iniPath); WritePrivateProfileString(_T(Network), _T(TcpPort), m_editTcpPort.GetString(), iniPath); }逻辑说明第一个参数是节名第二个是键名第三个是值。读取时用GetPrivateProfileString注意默认值要传合理的初始参数。参数说明m_comboPort是下拉框控件的变量m_editTcpPort是编辑框控件变量iniPath可以用GetModuleFileName取得exe路径后替换扩展名得到。5.2 多线程日志刷新到MFC列表工作线程不能直接调用AddString会刷新ListBox导致界面卡死。正确做法是PostMessage把日志字符串指针投递到主线程由主线程负责插入列表#define WM_APP_LOG (WM_APP 100) LRESULT CModbusDlg::OnLog(WPARAM wParam, LPARAM lParam) { CString* pMsg reinterpret_castCString*(wParam); if (pMsg) { m_listLog.AddString(*pMsg); delete pMsg; } return 0; }工作线程中这样发消息CString* pMsg new CString(_T(TCP连接已建立)); PostMessage(GetSafeHwnd(), WM_APP_LOG, (WPARAM)pMsg, 0);逻辑说明new出来的字符串指针作为WPARAM传给主线程主线程处理完delete避免使用栈上临时变量。参数说明m_listLog是ListBox控件变量日志太多时可以限制最大行数比如超过500行就DeleteString(0)。5.3 用虚拟串口与哑工具验证转换结果验证协议转换工具最有效的办法是双虚拟串口加一个串口调试器。把工具的南向串口设为COM3虚拟串口软件把COM3和COM4连起来再用一个串口调试工具打开COM4这样工具发出的RTU帧就能被调试工具收到。用Modbus TCP调试软件连接工具的北向端口发一个读保持寄存器的请求观察COM4上是否出现正确帧。常见问题和调整手段写在这里现象原因处理办法TCP连接正常但串口没数据请求帧长度判断错误或MBAP解析不对打印收到的TCP帧长度核对长度字段串口有数据但主站报超时应答帧CRC错误或事务标识不匹配检查CRC计算和transactionId回填ASCII模式下从站不响应LRC计算范围包含了冒号或CRLFLRC只计算地址、功能码和数据部分RTU帧频繁被拆开ReadIntervalTimeout设置太小按9600波特率至少要2ms建议设5ms5.4 字符集与依赖库的最后一公里VS2022默认MFC工程是Unicode编码而Modbus是纯粹的字节流协议。所有协议缓冲区都该用BYTE数组不要用CString拼接二进制帧否则数值会被转成宽字符。另外Release版本如果链接Winsock失败需要在项目属性的“链接器-输入-附加依赖项”里手动加上ws2_32.lib。64位编译时还要注意串口路径是\\\\.\\COM3而不是简单的COM3。本文还有配套的精品资源点击获取