十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于C++.Net与SerialPort的多单片机上位机开发实战

基于C++.Net与SerialPort的多单片机上位机开发实战 简介一套使用串口控件SerialPort开发的多单片机与PC通信上位机程序基于C/.NET技术栈面向Windows平台Winform开发者适用于工业控制、设备监控等场景。资源完整包含Visual Studio工程文件涵盖窗体界面设计、串口参数配置、数据收发与事件处理等核心模块界面提供串口选择、数据显示、开关控制等交互方式并演示了轮询、多线程及异步事件驱动三种多设备通信策略便于对比选择。包内共44个文件以源码、头文件、工程配置文件为主辅以资源文件、脚本、图标及编译中间文件压缩包仅1012KB目录结构清晰可直接编译运行。已有151人学习下载适合正在学习串口编程或需要快速搭建上位机原型的技术人员。代码示例展示了串口打开、数据写入与接收事件处理流程并包含常见错误处理与调试思路窗体设计兼顾实时状态显示与参数设置可帮助读者理解串口通信原理并快速迁移到实际项目中。 做嵌入式、工控这行的兄弟基本都绕不开上位机。我自己在调一块四路采集板的时候天天用串口助手挨个打开看数据烦到不行干脆写了一个基于 C.NetC/CLI和 SerialPort 控件的 WinForm 上位机让一台电脑同时跟多个单片机通信把数据汇总到界面上还能下发指令。项目名字虽然说得很直白——“基于SerialPort控件的C.Net的多个单片机和PC串口通信上位机WinForm程序”但实际开发过程中涉及的东西远不止打开串口这么简单。这种上位机解决的核心问题是调试和产品交付时的数据可视化与集中控制。以前只有一个单片机还好办现成的串口助手就能搞定一旦设备变成三块板子、四块板子每块都要独立监控和配置任何通用串口助手都帮不上忙必须自己写一套带协议、带界面、带交互的小工具。这个项目比较适合两类人看一类是刚接触上位机开发的嵌入式工程师另一类是需要在现有 C 代码基础上快速出调试界面的朋友。下面我会把技术选型、多串口管理、协议设计、DataReceived 事件处理、跨线程更新这些实操细节都过一遍再把我踩过的坑一起写出来照着做能省不少弯路。1. 项目整体设计与思路拆解1.1 为什么选 C.Net WinForm而不是 C#、Qt 或者 MFC这个选择看起来像个人偏好但实际是综合考虑现有代码和技术背景的结果。最直接的原因底层很多算法和协议栈都是 C 写的如果直接上 C#就得把所有旧代码封装成 C/CLI 的包装类或者导出成 C 接口的 DLL工作量大且维护麻烦。用 C.NetC/CLI可以直接引用原生 C 头文件和静态库数据在主程序和底层模块之间传递基本零成本。界面开发方面C.Net 下的 WinForm 和 C# 的 WinForm 几乎没有差别控件库、事件模型、属性窗口完全一样。我身边有不少 C 功底扎实但不熟悉 C# 的嵌入式工程师让他们去写 C# 反而要重新学语法和项目结构。C/CLI 的语法虽然称不上优雅但对熟悉 C 的人很友好基本上拿起 Visual Studio 新建一个 CLR 项目就能拖控件。为什么不用 QtQt 本身当然是好东西界面更现代、跨平台能力也强。但实际项目里有个很现实的问题部署时要额外带 Qt 的 DLL现场机器环境较旧的时候光装 Qt 运行库就够折腾。WinForm 的部署要简单得多目标机器有 .NET Framework 就能跑大部分工控电脑默认都有。至于 MFC我个人的体会是开发效率确实低控件美化、布局、字符串处理都费劲。WinForm 用托管字符串和串口收发时 byte 数组之间的转换直接了当不用再纠结 TCHAR、CString 那套体系。方案界面开发效率C 代码复用部署依赖推荐场景C.Net / WinForm高拖控件即可强直接引用.NET Framework已有 C 逻辑、需要快速出工具C# / WinForm高需要包装 DLL.NET Framework新项目、团队熟悉 C#Qt Widgets中高强官方支持Qt 运行库需要跨平台或者界面复杂MFC低强系统自带老项目维护1.2 多单片机连接方式一机多串口 vs RS485 总线标题里写的是“多个单片机”这句话在不同项目里对应的硬件连接方案其实完全不一样。第一种方案是一机多串口PC 上插多个 USB 转串口线或者主板自带多个串口每个串口接一个单片机。这种连接最直观上位机只需要为每个串口实例化一个 SerialPort 对象数据互不干扰单个设备想重启就单独重启调试起来很舒服。缺点是占 USB 口比较多COM 口号容易乱跳而且串口线一多物理上比较乱。第二种方案是 RS485 总线一拖多PC 通过一个串口加 RS485 转接板用两根差分线把多个单片机挂在同一条总线上。这种方案布线少、抗干扰强、传输距离远在大工业现场非常常见。代价是协议层必须带地址寻址每个命令都要指定从机地址谁被点到谁应答不能同时回复否则总线冲突。我的项目其实两种都支持关键在分层设计。界面上的“设备列表”和串口不一定是 1:1 的关系如果一个串口下面挂了多个 RS485 地址那就在同一串口下维护多个设备节点。为了文章不写散下面主要以“一机多串口”作为示例展开但帧协议里会保留地址字段这样 RS485 方案也能无缝切换过来。1.3 程序设计的基础分层写这种工具最忌讳的就是把收发逻辑和界面代码全糊在一起。我最早一版就是直接在 DataReceived 事件里操作控件数据量一上来界面就崩后面加功能更是寸步难行。合理做法是拆成四层串口管理层负责枚举、打开/关闭、维护 SerialPort 实例和事件协议解析层负责把收到的字节流按帧切分、校验、提取地址和命令业务处理层把解析结果转成数据模型更新设备状态、触发日志UI 展示层只管显示数据、接收用户点击操作。这样分层之后后续把串口换成 TCP/IP、把数据显示换成曲线控件都只需要替换某一层。对长期维护的价值远大于刚开发完那几天省下的几个小时。2. 核心细节解析与实操要点2.1 SerialPort 控件的关键成员与配置SerialPort 在工具箱里看起来像控件其实它是一个组件拖到窗体上会显示在下方非可视区域。本质上它是 .NET 对 Win32 串口 API 的封装核心成员就那些PortName、BaudRate、DataBits、StopBits、Parity以及用于事件的 ReceivedBytesThreshold、用于超时的 ReadTimeout/WriteTimeout。这些参数里波特率、数据位、停止位、校验位四项必须和单片机完全一致否则收到的全是乱码。绝大多数单片机默认配置是 115200、8、1、None所以我把界面上这个组合作为默认值同时留出下拉框让用户改方便对接不同波特率的设备。有一个特别容易踩的坑拖到窗体的 SerialPort 组件设计器里会生成默认参数如果代码里又去重新赋值必须保证在 Open 之前把参数改好。一旦串口已经 Open再修改部分属性会直接抛异常。因为这个我在多串口项目里基本不用设计器拖出来的组件而是全部在代码里动态创建参数在构造函数里一次传完省去很多莫名其妙的错误。2.2 DataReceived 事件与线程模型SerialPort 的 DataReceived 事件是在后台线程触发的不在 UI 线程上。这是新手最常见的坑直接在事件处理函数里写textBox1-Text ...程序表现时好时坏偶尔直接抛“线程间操作无效”的异常。我的处理原则是DataReceived 里只做读字节、追加缓存、协议解析这些不碰控件的计算需要更新界面时统一用 BeginInvoke 投递到 UI 线程执行。Invoke 和 BeginInvoke 的区别是前者同步等待 UI 线程执行完才返回后者异步投递立即返回。数据量小的时候哪个都行数据量大的时候我会避免用 Invoke否则后台线程排队等待会把整个接收逻辑拖慢。2.3 粘包、半包与自定义帧协议串口本质上是字节流没有消息边界接收方看到的是一串连续字节。所以协议里必须要有定界和校验机制否则你根本不知道哪几个字节算一条完整消息。我用的帧格式很简单帧头帧头设备地址命令字数据长度数据区校验和0xAA0x551 字节1 字节1 字节N 字节1 字节校验和从帧头开始累加一直到数据区末尾取低 8 位放进最后一字节。这个协议不复杂但足以演示多设备通信的核心环节。接收端最简单的可靠做法是维护一个缓冲区每次从串口读到的字节先追加进去然后循环尝试切帧。这个思路能同时处理半包一帧数据分两次到达和粘包两帧数据挤在一次到达比一次性 Read 后直接解析要稳得多。如果你负责单片机端的串口驱动建议在 STM32 这类芯片上用串口空闲中断加 DMA 接收硬件自动攒完一帧再进中断能大大降低 CPU 开销。轮询方式在低波特率下勉强能用波特率一旦跑到 460800 以上按字节中断基本就是灾难。2.4 多串口的动态管理方式多个串口同时工作最直观的方式是用字典存实例DictionaryString^, SerialPort^键是 COM 口号或者自定义设备 ID值是 SerialPort 对象。这样查找、遍历、关闭都方便。很多个串口会触发同一个 DataReceived 事件处理函数吗可以而且我推荐这么做。因为事件参数里的 sender 就是触发事件的 SerialPort 对象拿到它的 PortName 再去字典反查是哪个串口再找到对应的接收缓存。这样就不用为每个串口写一个事件处理函数十路串口也只要维护一套逻辑。3. 实操过程与核心环节实现3.1 环境准备与项目创建开发环境用 Visual Studio 2019 或 2022 都行安装时勾选“使用 C 的桌面开发”工作负载。新建项目时选择“Visual C - CLR - Windows 窗体应用程序”模板名称在中文版里叫“CLR 项目”下的“Windows 窗体应用程序”。创建完成后确认项目属性的“公共语言运行时支持”是/clr。工具箱里能看到 SerialPort 组件拖到窗体上就能自动生成一个 serialPort1。但前面说过为了多串口管理方便我建议在代码里动态创建下面所有示例也都按这个方式写。3.2 界面布局怎么设计调试工具界面不用花哨但布局要合理。我分了四个区域顶部串口下拉框、波特率下拉框、刷新按钮、打开/关闭按钮左侧设备列表ListBox显示每个串口或每个单片机地址在线状态用文字标出来中间DataGridView 实时数据表每行一个设备字段包括设备地址、温度、湿度、电压、状态、最后更新时间底部日志窗口只读多行 TextBox记录打开串口、发送帧、接收帧和异常信息。老项目真的不用花太多时间搞界面美化把实时数据刷新和日志做好比什么都强。后期如果要给客户用再考虑换皮肤或者第三方控件工具期功能优先。3.3 串口枚举与动态创建设备下面是核心代码片段C/CLI 语法如果平时用 C# 的话逻辑完全对应。using namespace System::IO::Ports; using namespace System::Collections::Generic; // 成员变量 DictionaryString^, SerialPort^^ portDict gcnew DictionaryString^, SerialPort^(); ListBox^ listBoxPorts; // 界面上的串口下拉框控件 void RefreshPorts() { arrayString^^ ports SerialPort::GetPortNames(); listBoxPorts-Items-Clear(); for each (String^ p in ports) { listBoxPorts-Items-Add(p); } } void OpenPort(String^ portName, int baud) { if (portDict-ContainsKey(portName)) return; SerialPort^ sp gcnew SerialPort(portName, baud, Parity::None, 8, StopBits::One); sp-ReceivedBytesThreshold 1; try { sp-Open(); sp-DataReceived gcnew SerialDataReceivedEventHandler(this, MainForm::OnDataReceived); portDict-Add(portName, sp); AddLog(已打开串口: portName); } catch (Exception^ ex) { AddLog(打开串口失败: portName 原因: ex-Message); sp-Dispose(); } }ReceivedBytesThreshold 这里设成 1意思是缓冲区里只要有 1 个字节就触发 DataReceived不要在事件触发之前攒太多数据。串口数据到达没有固定时序设为 1 最保险拆帧逻辑会去处理粘包。3.4 数据发送与指令帧构造给某个设备下发指令时把地址、命令、数据组装成上面的协议帧然后通过对应串口写出去。void SendCommand(String^ portName, Byte addr, Byte cmd, arrayByte^ payload) { if (!portDict-ContainsKey(portName)) return; SerialPort^ sp portDict[portName]; int dataLen payload-Length; int frameLen 5 dataLen 1; arrayByte^ frame gcnew arrayByte(frameLen); frame[0] 0xAA; frame[1] 0x55; frame[2] addr; frame[3] cmd; frame[4] (Byte)dataLen; for (int i 0; i dataLen; i) frame[5 i] payload[i]; Byte sum 0; for (int i 0; i frameLen - 1; i) sum frame[i]; frame[frameLen - 1] sum; sp-Write(frame, 0, frameLen); AddLog(TX [ portName - 0x addr.ToString(X2) ] ByteArrayToHex(frame)); }注意 frameLen 最小是 6 字节即使 payload 为空也有帧头 2 字节、地址 1 字节、命令 1 字节、长度 1 字节、校验 1 字节。校验字节放在最后一位接收端解析时也按这个规则校验。3.5 数据接收与多设备帧解析这是整个程序最核心的部分。每次 DataReceived 触发时先把串口缓冲区里的字节全部读到数组追加到对应串口的 ListByte 缓存然后循环切帧。// 成员变量每个串口维护一个接收缓存 DictionaryString^, ListByte^^ recvBuffer gcnew DictionaryString^, ListByte^(); void MainForm::OnDataReceived(System::Object^ sender, SerialDataReceivedEventArgs^ e) { SerialPort^ sp (SerialPort^)sender; String^ key sp-PortName; if (!recvBuffer-ContainsKey(key)) recvBuffer-Add(key, gcnew ListByte()); int n sp-BytesToRead; arrayByte^ buf gcnew arrayByte(n); sp-Read(buf, 0, n); ListByte^ cache recvBuffer[key]; for each (Byte b in buf) cache-Add(b); ParseFrames(key, cache); } void MainForm::ParseFrames(String^ key, ListByte^ cache) { while (cache-Count 6) { if (cache[0] ! 0xAA || cache[1] ! 0x55) { cache-RemoveAt(0); continue; } int dataLen cache[4]; int frameLen 5 dataLen 1; if (cache-Count frameLen) { break; // 半包数据还没收完整等下一次 DataReceived } Byte sum 0; for (int i 0; i frameLen - 1; i) sum cache[i]; if (sum ! cache[frameLen - 1]) { cache-RemoveAt(0); // 校验失败抛弃当前字节重新找帧头 continue; } Byte addr cache[2]; Byte cmd cache[3]; arrayByte^ payload gcnew arrayByte(dataLen); for (int i 0; i dataLen; i) payload[i] cache[5 i]; UpdateDeviceData(key, addr, cmd, payload); AddLog(RX [ key - 0x addr.ToString(X2) ] ByteArrayToHex(cache-GetRange(0, frameLen)-ToArray())); cache-RemoveRange(0, frameLen); } }这个拆帧逻辑非常实用。校验失败时我从帧头下一个字节继续找而不是把整帧丢弃避免因为中间一个坏字节导致后续所有数据全乱。半包情况则直接 break等下一次 DataReceived 数据到了再继续解析。3.6 UI 更新与跨线程调用UpdateDeviceData 里会更新 DataGridView但它可能在后台线程被调用。所以标准的做法是先判断 InvokeRequired如果需要就通过委托转到 UI 线程。// 在 MainForm 类内部声明委托 delegate void UpdateGridDelegate(String^ portName, Byte addr, arrayByte^ payload); void UpdateDeviceData(String^ portName, Byte addr, Byte cmd, arrayByte^ payload) { if (this-InvokeRequired) { this-BeginInvoke(gcnew UpdateGridDelegate(this, MainForm::UpdateDeviceData), portName, addr, payload); return; } String^ deviceKey portName -0x addr.ToString(X2); // 找到 DataGridView 中对应设备行更新数值和最后更新时间 // 比如温度 BitConverter::ToSingle(payload, 0) // 电压 BitConverter::ToUInt16(payload, 4) / 1000.0f }InvokeRequired 为 true 说明当前线程不是本文还有配套的精品资源点击获取
返回列表