简介面向需要在Visual Studio 2017中基于MFC框架实现串口通信的开发者这套工程以CSerialPort类为核心覆盖串口打开、参数配置、读写操作与关闭等完整流程可应用于工业控制、仪器数据采集、嵌入式设备调试等场景。压缩包共77个文件、134.75MB主要包含6个h与4个cpp源码、sln/vcxproj工程配置、Release和Debug编译输出其中exe可直接运行、pdb用于调试另有rc/ico等界面资源与编译中间文件目录按x64与Win32区分兼容32位和64位环境。内容系统梳理了Open、Close、GetPortName、SetBaudRate、SetParity、SetDataBits、SetStopBits、Read、Write等关键接口并在对话框工程中给出串口初始化、参数设置、数据收发的实现示例同时补充了串口打开失败、读写异常等常见问题处理以及通过工作线程避免界面卡顿的多线程优化思路便于实际项目落地。已有7677人学习/下载适合希望快速掌握MFC串口通信或需要复用工程模板的开发者既能用于课程设计与毕业设计也能直接作为工业通信模块的参考。 做嵌入式或者工控上位机的朋友十有八九都得写串口调试工具。我自己最早是用VB的MSComm控件后来切到VS2017 MFC环境第一次上手CSerialPort类的时候最大的感受是——终于不用跟一堆API参数死磕了。这个类把串口通信里最琐碎的打开、配置、收发、事件通知都封装好了直接往对话框工程里一扔就能用特别适合快速做上位机原型、工控小工具或者配合STM32这类单片机做联调。这篇文章我不打算只贴代码我会把为什么选CSerialPort类、怎么在VS2017里搭一个能用的MFC串口工程、数据收发到底走的什么机制、以及实际调试中一定会碰到的坑一次性讲透。适合刚接触MFC上位机的同学也适合那些已经写了几版串口工具但老觉得哪里不对劲的人。1. 项目定位与选型思路1.1 为什么是CSerialPort类而不是裸写API很多人第一次写串口会直接去调Windows的CreateFile、ReadFile、WriteFile这套原生API。这条路不是不行但你要自己处理的事情太多了串口参数的DCB结构体配置、超时设置、线程同步、事件等待、缓冲区管理。写个简单的收发demo还好一旦要长时间运行、高频收发、还能在界面上流畅显示数据代码量立刻膨胀而且越往后越难查bug。MSComm控件呢微软早就停止维护了在Win7以上的系统里兼容性越来越差注册控件那步就能卡住一批人。相比之下CSerialPort类是个开源类最初由Remon Spekreijse发布在CodeProject上被全世界的开发者用了二十年以上。它的设计思路很干净内部开一个监视线程串口有数据到达时通过Windows消息通知你的主窗口你只需要处理一个消息函数就行。这也是它特别适合MFC的原因——消息驱动本来就是MFC的强项。换句话说你不需要理解底层还有多少个API调用在跑只需要知道数据来了去哪里收、数据要发时往哪写就够了。1.2 串口通信的基础参数必须搞明白不管用哪个类串口通信本身有几个参数是绕不过去的串口号、波特率、数据位、停止位、校验位。这五个参数是通信双方的“共同约定”上位机和下位机必须完全一致否则就会出现收不到数据或者收到一堆乱码的情况。我举个例子我最常搭配的STM32F103C8T6用USART1和外设通信工程里初始化的参数是9600波特率、8位数据位、1位停止位、无校验。那么上位机这边也必须设置成同样的参数。你可能会问为什么不都改成更高的波特率因为有些老设备、长线传输、或者对端芯片的稳定性限制115200不一定跑得起来。实际项目里9600和115200是最常见的两个档位我一般优先用9600做联调稳定压倒一切。顺带说一句串口线不是USB线如果是USB转串口需要装对应芯片的驱动比如CH340、CP2102、FT232这些。设备管理器里能看到COM号这个号就是你在程序里要填的那个端口。2. 环境准备与工程搭建2.1 VS2017下的MFC开发环境VS2017属于Visual Studio 2017这个大版本安装的时候默认不会装MFC组件需要你在Visual Studio Installer里勾选使用C的桌面开发然后在右侧的适用于最新v142生成工具的C MFC这个可选组件打上勾。如果你是在Win7上安装VS2017注意安装程序需要相应补丁支持安装前最好把系统更新补丁打全否则可能报一堆莫名其妙的错误。确实有人习惯直接创建一个空项目然后手动去配置MFC的运行环境也就是热词里提到的空项目转MFC。理论上可行但你要改项目属性里的字符集、运行时库、入口函数MFC还得初始化CWinApp这套操作对新手来说太容易漏配置。我强烈建议直接用模板创建MFC应用省下来的时间用来写业务逻辑它不香吗。创建步骤很简单新建项目选Visual C里的MFC应用然后一直点下一步。在弹出的对话框里应用程序类型选基于对话框这样生成的工程自带一个主对话框模板后面放控件、绑数据都是现成的。其他选项保持默认就行。2.2 把CSerialPort类塞进你的工程CSerialPort类由两个文件组成SerialPort.h和SerialPort.cpp。你可以在CodeProject上找到原版也可以在GitHub上搜一下各版本的实现大同小异选一个支持VS2017的就行因为VS2017用的工具集版本较新某些太老的源码在编译时可能需要微调。把这两个文件拷贝到你的项目目录然后在VS里右键项目选择添加 - 现有项把SerialPort.h和SerialPort.cpp加进来。接下来在你的对话框类头文件里引入#include SerialPort.h并且声明一个CSerialPort的成员变量CSerialPort m_SerialPort;到这里类已经可以用了。但要注意CSerialPort的很多版本依赖MFC的消息宏所以它只能在MFC工程里用纯Win32控制台工程直接引用会报错。这也从另一个角度说明了它天生就是为MFC准备的。3. 核心实现基于CSerialPort的串口收发3.1 串口初始化与打开CSerialPort类最常用的接口是InitPort和StartMonitoring。InitPort负责配置串口参数并打开端口StartMonitoring负责启动内部监视线程。我在对话框的初始化函数OnInitDialog里写打开逻辑BOOL CMySerialDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 参数含义所属窗口、COM口号(3表示COM3)、波特率、校验位、数据位、停止位 if (m_SerialPort.InitPort(this, 3, 9600, N, 8, 1)) { m_SerialPort.StartMonitoring(); SetDlgItemText(IDC_STATIC_STATUS, _T(串口已打开)); } else { SetDlgItemText(IDC_STATIC_STATUS, _T(串口打开失败)); } return TRUE; }这里特别注意InitPort的第二个参数是数字3对应的是COM3不是字符串COM3。我第一次用的时候没仔细看注释把端口号写成字符串编译过了但运行怎么都打不开后来才发现参数类型不对。检查一下SerialPort.h里的声明你会发现这个参数是UINT类型。程序退出时记得在OnClose或者析构函数里关闭端口m_SerialPort.ClosePort();如果不关程序异常退出时串口可能处于占用状态下次打开会报Access denied。3.2 数据接收的消息机制这是CSerialPort类最核心、也最巧妙的部分。内部监视线程收到一个字节就通过::PostMessage向主窗口发送一条WM_COMM_RXCHAR消息消息的wParam里携带的就是那个字节的数据。主窗口只需要重载对应的消息处理函数就能实时拿到数据。首先在对话框类的头文件里添加消息处理函数声明afx_msg LRESULT OnCommunication(WPARAM wParam, LPARAM lParam);然后在.cpp文件的消息映射表里加入BEGIN_MESSAGE_MAP(CMySerialDlg, CDialogEx) ON_MESSAGE(WM_COMM_RXCHAR, OnCommunication) END_MESSAGE_MAP()最后实现处理函数LRESULT CMySerialDlg::OnCommunication(WPARAM wParam, LPARAM lParam) { char ch (char)wParam; // 把字符追加到接收缓冲区或者直接显示 return 0; }实测下来有一点要特别提醒WM_COMM_RXCHAR是每收到一个字节就触发一次。如果你一次性收到几百个字节就会被调用几百次。如果在这个函数里直接拼CString、更新编辑框界面会很卡而且某些情况下还会丢数据。正确做法是在这个函数里只做一件事——把数据扔到自己的接收缓冲区里比如一个CByteArray或者简单的char数组然后等一帧数据完整了再统一处理。3.3 数据发送与常见类型转换发送数据用的是WriteToPort它有两个重载版本一个接收char*字符串一个接收char*缓冲区加长度。实际使用中我更喜欢带长度那个因为通信协议里经常包含0x00这种空字符如果你用字符串版本数据在第一个0x00就断了。char sendBuf[8] {0xAA, 0x55, 0x01, 0x02, 0x00, 0x0D, 0x0A, 0x00}; m_SerialPort.WriteToPort(sendBuf, 8);这里就牵扯到一个非常高频的问题热词里也有人搜MFC CString转char*和TCHAR字符串操作。在MFC里由于字符集设置可能是UnicodeCString转char*确实有坑。我的经验是发命令给单片机时尽量避免用CString直接转而是自己维护一个字节数组。如果非要转常见做法是// 示例CString转char数组 CString strCmd _T(AT\r\n); char cmd[32] {0}; // 宽字符转ANSI用宽字节接口 int len WideCharToMultiByte(CP_ACP, 0, strCmd, -1, NULL, 0, NULL, NULL); WideCharToMultiByte(CP_ACP, 0, strCmd, -1, cmd, len, NULL, NULL);但要注意这个方法在Unicode字符集下才需要如果工程字符集本来就是多字节直接strcpy就行。新项目建议统一用Unicode和Windows现代API配合更顺畅。3.4 数据帧的拼接与协议解析思路如果只是把接收到的字符一股脑显示在编辑框里那算不上一个完整的串口工具。实际联调的时候我更习惯按帧来解析。比如我常用的下位机协议是帧头0xAA 0x55加长度加数据加校验和。在OnCommunication里收到一个字节就追加到缓冲区m_recvBuffer[m_recvLen] ch; if (m_recvLen 4) { // 简单判断帧头 if (m_recvBuffer[0] 0xAA m_recvBuffer[1] 0x55) { // 假设第二、三位是数据长度 int dataLen (m_recvBuffer[2] 8) | m_recvBuffer[3]; if (m_recvLen dataLen 4) { // 一帧数据完整了做校验和解析 ParseFrame(m_recvBuffer, m_recvLen); m_recvLen 0; } } else { // 帧头不对丢弃一个字节继续找帧头 memmove(m_recvBuffer, m_recvBuffer 1, m_recvLen - 1); m_recvLen--; } }这种状态机式的处理思路是串口解析的基础它比一次性读取缓冲区再解析要稳健得多因为你永远不知道下位机的数据是什么时候、分几批发过来的。4. 常见问题与排查技巧4.1 串口打不开、报访问被拒绝这个问题十有八九是串口被其他程序占用了。常见的占用场景包括串口调试助手没关、上一条程序异常退出没释放端口、或者别的工具还在后台监听。排查思路很简单先把所有可能占用串口的程序关掉然后打开设备管理器看目标COM号是否正常出现。还有一种情况是COM号变了。同一个USB转串口模块插不同的USB口系统分配的COM号可能不一样。比如这次插在USB2口是COM4下次插USB3口变成了COM7。我习惯在代码里写一个下拉框枚举所有可用串口而不是硬编码COM号。用SetupAPI枚举串口在CSerialPort类里没有直接暴露但网上有现成的枚举函数拷过来就能用。4.2 收到数据全是乱码乱码第一考虑参数不匹配波特率、校验位、数据位、停止位任何一个不一致都会乱。先确认两边参数完全一样尤其是校验位CSerialPort类里传的是字符N是无校验E是偶校验O是奇校验别写错了。第二考虑转换问题。下位机发的是二进制数据你在上位机用文本方式显示自然是一堆不可见字符或者乱码。这时候你需要区分显示模式。我通常加上十六进制显示和文本显示两个选项调试的时候默认用十六进制理清协议之后再看可读文本。还有一点是硬件层面的RS232和RS485的电平标准不同。如果你的设备是RS485必须通过485转接模块接到电脑直接连TTL电平的串口收不到数据或者收到乱码很常见。这个问题在之前帮别人调一个工业设备时碰到过折腾了半天最后发现是接线问题。4.3 界面卡死、接收大量数据时不流畅CSerialPort类的消息机制虽然方便但也容易让人误以为可以在OnCommunication里做任何事情。前面提过每个字节都会触发一次消息如果数据量大而你在消息函数里做重活——比如给ListBox逐条插入字符串、读写数据库、或者Sleep等待——界面就会卡死。我踩过最惨的一次是接收下位机高速上传的波形数据每秒钟几千个字节我在OnCommunication里直接把每个字节转成字符串追加到编辑框结果程序响应极慢点关闭按钮等了一分钟才退出。后来改成OnCommunication只往环形缓冲区写数据再单独开一个定时器每100毫秒从缓冲区取一次数据批量刷新界面。这样界面流畅多了数据也不丢。有个细节CSerialPort类的内部缓冲区本身是有限的如果你的应用程序来不及取走数据新数据可能会覆盖旧数据。所以缓冲区设计和读取节奏一定要匹配数据量。4.4 数据粘包与帧边界问题串口通信没有以太网那种一帧的概念它就是一个字节一个字节地流。下位机即使一次性发了20个字节上位机也可能分3次收到比如先收到7个再收到9个最后收到4个。这就是所谓的粘包或者拆包。所以解析数据时必须靠帧头、长度、校验这些协议要素来界定边界而不是指望一次收到完整的一帧。我的建议是每个通信协议都要包含以下几点明确的帧头、帧长度字段、帧尾或校验字段。即使是最简单的点灯控制也建议在数据前面加帧头、后面加校验和这样后面扩展指令时不会被粘包问题牵扯。4.5 CString转char*的深坑在Unicode字符集下直接对CString做(char*)(LPCTSTR)强制转换你得到的是乱码或者空串这个坑坑了无数新手。前面给了用WideCharToMultiByte的解法我再补充一个场景如果是通过串口发送AT指令这类纯ASCII文本我习惯直接用CT2A宏CString strCmd _T(ATCIPSTART\r\n); CT2A asciiCmd(strCmd); m_SerialPort.WriteToPort(asciiCmd, strlen(asciiCmd));CT2A是ATL里的转换宏简洁可靠省掉写一长串API的麻烦。但注意它分配的是栈内存不要在循环里长时间持有用完即走。5. 工程扩展与实用经验5.1 封装一个通用串口配置面板当你写了好几个串口工具之后会发现每次都在重复做同样的事下拉框选串口号、选波特率、打开按钮、关闭按钮。我建议把这块封装成一个通用的对话框或者一个配置结构体所有项目共用。我自己封装了一个CComPortDlg里面自动化枚举串口保存上次使用的参数到注册表下次打开自动加载极大提高效率。还有一个细节关闭串口后要干净地释放资源防止再次打开失败。我的处理是先StopMonitoring()再ClosePort()中间稍微延时一下确保监视线程退出。5.2 和STM32联调时的协作经验热词里有很多关于STM32串口通信的内容实际联调时上位机负责发送指令和解析回包下位机负责响应。我建议调试阶段两边都打开十六进制显示先确认基本字节流能通再去解析语义。另外如果STM32用的HAL库那么它默认的回调函数可能用的是中断接收需要注意缓冲区溢出处理。上位机这边不需要关心下位机实现细节只要协议定清楚就行。我在一个项目里同时用STM32F103C8T6和MFC上位机配合传输数据带简单校验实测跑一整天没有丢帧。5.3 界面交互与美化的一些轻量建议串口工具不追求花哨但基本的用户体验要有。比如接收区用ListBox或者RichEdit加上清除按钮发送区支持按回车直接发送状态栏显示当前串口状态。热词里提到的group box设置字体底色、clistctrl选中后变灰这些问题都属于控件自绘范畴想要深度定制可以研究一下自绘按钮和重绘ListBox或者直接引入一个轻量皮肤库。说实话MFC做漂亮界面确实费劲我的习惯是做一个简洁、清晰、好用的工具而不是把时间花在美工上。真正好用的上位机逻辑清晰、不卡不崩比花里胡哨重要得多。最后再分享一个经验用CSerialPort类做串口工具一定要自己写一个小工具来压测数据比如每秒发几百帧数据连续跑一小时观察有没有卡死、漏数据、内存上涨。因为串口工具的上线环境往往是车间、实验室一旦现场出了问题排查成本比在家里高得多。把这个坑提前踩掉后面会省很多事。本文还有配套的精品资源点击获取