Skip to content

第一个小项目:串口设备监视工具

内容类型:教学案例难度:基础适合:想做第一个上位机实践项目的学习者阅读时间:约 30 分钟前置:已安装 Visual Studio,了解 WPF 基本概念

你的第一个上位机项目不用很复杂。目标是:用 WPF 做一个串口监视工具,能选择串口、打开/关闭、发送数据、在列表里显示收到的数据

功能不多,但它覆盖了上位机开发最核心的能力:界面 + 通信 + 数据显示。做完它,你就知道一个上位机项目是怎么从零搭起来的。

开始前准备:没装好 Visual Studio 先看环境安装与版本选择;不熟悉波特率、数据位、校验位先看串口与 TCP 基础;想跟 AI 协作时能看清它改了什么,用Git 最小入门里的三条命令就够。

项目概览

这是一个贯穿式教学项目,让你跟着一条完整练习路径,从需求到可运行 Demo 做一个串口设备监控工具。这里为了教学会连续经历多个阶段;以后做真实项目中的一个明确小修改时,不需要机械重复整套流程。

阶段 1:整理需求 — 把模糊想法变成结构化需求,让 AI 帮忙发现遗漏阶段 2:项目设计 — 让 AI 输出模块划分和设计方案阶段 3:拆分任务 — 把大目标拆成 7 个可独立验证的小任务阶段 4:AI 实现 — 每一步提供任务卡,约束 AI 修改范围阶段 5:增加协议解析 — 从文本收发练习升级到一个自定义二进制协议示例阶段 6:增加实时曲线 — 数据显示层升级阶段 7:模拟 Bug 排查 — 练习给 AI 提供证据并验证修改
完成标准:能运行、能收发数据、能保存日志、能在 AI 辅助下排查 Bug。本教学项目使用虚拟串口和模拟数据,不代表真实生产环境或真实设备验收。

阶段 1:整理需求

目标:把"做个串口监视工具"这个模糊想法变成结构化需求。

人工任务

  1. 写出你理解的工具功能:选串口、打开/关闭、发送数据、显示接收数据、保存日志
  2. 把需求发给 AI,让它帮忙整理和发现遗漏

AI 提示

text
我正在设计一个 WPF 串口设备监控工具。请帮我整理需求,先不要写代码。

工具的基本功能:
- 选择串口和参数
- 打开/关闭串口
- 发送测试指令
- 接收并显示原始数据
- 保存接收数据到文件
- 显示连接状态

请帮我:
1. 整理成结构化需求清单
2. 发现我可能遗漏的功能
3. 列出每个功能的验收标准
4. 指出需要我补充的信息

人工裁剪:AI 的需求候选未必全要

把上面的 AI 输出作为候选框架,逐项判断:

P0 · 第一版必须做

串口配置、打开/关闭、数据接收、数据发送、日志保存、连接状态、异常提示

P1 · 后续再做

协议解析、实时曲线、历史数据查询、导出功能

不做清单

用户登录、权限管理、云同步、报表中心、多设备管理、远程访问

提示:把"不做清单"记下来,后续让 AI 编码时作为约束输入——防止它自动补充已排除的功能。详见 从模糊想法到第一版需求

验收:得到一份完整的需求清单,知道还需要确认什么(比如:需要虚拟串口工具、需要确认数据格式)。


阶段 2:项目设计

目标:确定软件的模块划分和设计方案。

AI 提示

text
我的需求清单如下:[粘贴阶段1的产出]

请帮我做项目设计,先不要写代码:
1. 模块划分(界面层、通信层、数据层各包含什么)
2. 类结构建议(MainWindow、SerialService、DataLogger 等)
3. 提供两种实现方案并分析优缺点
4. 技术选型建议(WPF/.NET版本、是否需要 NuGet 包)

验收:得到模块划分方案,确认技术选型。


阶段 3:拆分任务

目标:把项目拆成可独立验证的小任务。

AI 提示

text
我的项目设计如下:[粘贴阶段2的产出]

请帮我拆分成可独立完成的小任务。每个任务要求:
1. 目标单一(一个任务只做一件事)
2. 可以独立编译和测试
3. 有明确的输入输出
4. 有验收条件

按以下顺序拆分:
1. 创建 WPF 项目和基本窗口
2. 完成串口配置区 UI
3. 完成串口打开/关闭功能
4. 完成数据发送功能
5. 完成数据接收和显示
6. 完成日志保存
7. 完成异常提示和状态显示

验收:得到 7 个按顺序排列的任务卡。


阶段 4:AI 实现

现在拿到了 7 个任务卡,下面逐一实现。每完成一步都可以编译运行验证。

前置准备

  1. 安装 Visual Studio Community 2022,安装时勾选“.NET 桌面开发”工作负载。
  2. 安装虚拟串口工具(例如 com0com 或 Virtual Serial Port Driver),创建一对虚拟串口(如 COM1 ↔ COM2)。
  3. 准备一个串口调试助手(例如 SSCOM 或其它你熟悉的工具),用来模拟设备端收发数据。

第一步:创建 WPF 项目

打开 Visual Studio → 创建新项目 → 选择“WPF 应用程序” → 项目名称填 SerialMonitor → .NET 版本选 .NET 8.0(或你项目统一使用的受支持版本)。

创建完成后,你会看到一个只有空白窗口的项目。

这个示例使用 System.IO.Ports.SerialPort。现代 .NET 项目通常需要引用 System.IO.Ports NuGet 包。可以在 Visual Studio 的“管理 NuGet 程序包”中安装,也可以在项目目录执行:

powershell
dotnet add package System.IO.Ports

选择与你的目标框架兼容的稳定版本即可。不要因为教程代码能识别 using System.IO.Ports; 就假设所有项目模板已经自动引用这个包。

第二步:设计界面

打开 MainWindow.xaml,把里面的内容替换成下面的布局:

xml
<Window x:Class="SerialMonitor.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="串口监视工具" Height="500" Width="700">
    <Grid Margin="10">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>

        <!-- 串口设置区 -->
        <GroupBox Header="串口设置" Grid.Row="0" Margin="0,0,0,8">
            <StackPanel Orientation="Horizontal" Margin="5">
                <Label Content="串口:" VerticalAlignment="Center"/>
                <ComboBox x:Name="cmbPorts" Width="100" Margin="5,0"/>
                <Label Content="波特率:" VerticalAlignment="Center" Margin="10,0,0,0"/>
                <ComboBox x:Name="cmbBaudRate" Width="100" Margin="5,0"
                          SelectedIndex="0">
                    <ComboBoxItem>9600</ComboBoxItem>
                    <ComboBoxItem>19200</ComboBoxItem>
                    <ComboBoxItem>38400</ComboBoxItem>
                    <ComboBoxItem>115200</ComboBoxItem>
                </ComboBox>
                <Button x:Name="btnOpen" Content="打开串口"
                        Width="80" Margin="15,0,5,0" Click="BtnOpen_Click"/>
                <Button x:Name="btnClose" Content="关闭串口"
                        Width="80" Margin="5,0" Click="BtnClose_Click" IsEnabled="False"/>
            </StackPanel>
        </GroupBox>

        <!-- 接收数据区 -->
        <GroupBox Header="接收数据" Grid.Row="2" Margin="0,5,0,0">
            <ListBox x:Name="lstReceived" FontFamily="Consolas" FontSize="13"/>
        </GroupBox>

        <!-- 发送区 -->
        <GroupBox Header="发送数据" Grid.Row="3" Margin="0,8,0,0">
            <Grid>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="Auto"/>
                </Grid.ColumnDefinitions>
                <TextBox x:Name="txtSend" Grid.Column="0" Margin="5"/>
                <Button x:Name="btnSend" Content="发送" Grid.Column="1"
                        Width="60" Margin="5" Click="BtnSend_Click"/>
            </Grid>
        </GroupBox>
    </Grid>
</Window>

这个界面分为三个区域:顶部选串口和波特率,中间大块显示接收到的数据,底部是发送区。

第三步:写串口操作代码

打开 MainWindow.xaml.cs,替换成下面的代码。这里故意使用 code-behind 保持第一项目简单;它是教学 Demo,不是在规定生产项目必须把通信都写进窗口类。

⚠️ 源码文件编码与串口文本编码是两件事.cs/.xaml 保存为 UTF-8 可以避免源码中文字被写坏;串口收到的文本是否乱码,则取决于设备发送编码与 SerialPort.Encoding 是否一致。二进制协议不要先强行转成字符串。

csharp
using System.IO.Ports;
using System.Windows;

namespace SerialMonitor;

public partial class MainWindow : Window
{
    private readonly SerialPort _serialPort = new();

    public MainWindow()
    {
        InitializeComponent();

        // 事件只订阅一次,避免某次 Open 失败后再次点击造成重复订阅。
        _serialPort.DataReceived += SerialPort_DataReceived;

        // 窗口结束时释放串口资源。真实项目还应结合自己的 Stop/Cancel 生命周期设计。
        Closed += (_, _) =>
        {
            if (_serialPort.IsOpen)
                _serialPort.Close();

            _serialPort.Dispose();
        };

        RefreshPorts();
    }

    private void RefreshPorts()
    {
        cmbPorts.Items.Clear();
        foreach (var port in SerialPort.GetPortNames())
        {
            cmbPorts.Items.Add(port);
        }

        if (cmbPorts.Items.Count > 0)
            cmbPorts.SelectedIndex = 0;
    }

    private void BtnOpen_Click(object sender, RoutedEventArgs e)
    {
        if (cmbPorts.SelectedItem == null)
        {
            MessageBox.Show("请先选择一个串口");
            return;
        }

        try
        {
            _serialPort.PortName = cmbPorts.SelectedItem.ToString()!;
            _serialPort.BaudRate = int.Parse(
                ((ComboBoxItem)cmbBaudRate.SelectedItem).Content.ToString()!);

            _serialPort.Open();

            btnOpen.IsEnabled = false;
            btnClose.IsEnabled = true;
            cmbPorts.IsEnabled = false;
            cmbBaudRate.IsEnabled = false;

            AddLog($"串口 {_serialPort.PortName} 已打开");
        }
        catch (Exception ex)
        {
            MessageBox.Show($"打开串口失败:{ex.Message}");
        }
    }

    private void BtnClose_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            _serialPort.Close();

            btnOpen.IsEnabled = true;
            btnClose.IsEnabled = false;
            cmbPorts.IsEnabled = true;
            cmbBaudRate.IsEnabled = true;

            AddLog($"串口 {_serialPort.PortName} 已关闭");
        }
        catch (Exception ex)
        {
            MessageBox.Show($"关闭串口失败:{ex.Message}");
        }
    }

    private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
    {
        // DataReceived 在后台线程触发,不能直接修改 WPF UI 控件。
        string data = _serialPort.ReadExisting();
        Dispatcher.Invoke(() => AddLog($"收到:{data}"));
    }

    private void BtnSend_Click(object sender, RoutedEventArgs e)
    {
        if (!_serialPort.IsOpen)
        {
            MessageBox.Show("请先打开串口");
            return;
        }

        try
        {
            string text = txtSend.Text;
            _serialPort.Write(text);
            AddLog($"发送:{text}");
            txtSend.Clear();
        }
        catch (Exception ex)
        {
            MessageBox.Show($"发送失败:{ex.Message}");
        }
    }

    private void AddLog(string message)
    {
        string timestamp = DateTime.Now.ToString("HH:mm:ss.fff");
        lstReceived.Items.Insert(0, $"[{timestamp}] {message}");

        // 新消息插在索引 0,因此超过上限时删除列表末尾的最旧消息。
        while (lstReceived.Items.Count > 500)
            lstReceived.Items.RemoveAt(lstReceived.Items.Count - 1);
    }
}

这段代码的关键点

最容易踩的坑:UI 线程

串口的 DataReceived 事件是在后台线程触发的。如果直接在事件处理里操作 WPF 控件,可能触发跨线程异常。示例使用 Dispatcher.Invoke 切回 UI 线程。

这是最小教学写法。高频采集的生产项目还要考虑 UI 更新频率、队列、背压、取消和 Stop/Dispose 生命周期,不要把每一帧都同步阻塞地刷新界面。

ReadExisting() 读取的是文本,解码仍受 SerialPort.Encoding 影响。如果调试助手和程序需要收发中文或其它非 ASCII 文本,请让两端使用一致编码,并显式设置对应 Encoding。如果设备协议是二进制帧,应按字节读取和解析,而不是依赖 ReadExisting() 解决“乱码”。

第四步:运行和测试

  1. 确保虚拟串口工具已经创建一对串口(例如 COM1 ↔ COM2)。
  2. 打开串口调试助手,连接到 COM2。
  3. 运行 SerialMonitor,选择 COM1,点击“打开串口”。
  4. 在调试助手中发送 ASCII 测试文本,确认程序可以收到。
  5. 在程序中发送文本,确认调试助手可以收到。
  6. 关闭再重新打开串口,确认消息没有因为重复事件订阅而显示两次。
  7. 关闭窗口后确认串口资源已释放,调试助手/下一次启动可以重新占用端口。

如果这些步骤都通过,可以说明这个模拟环境下的文本收发 Demo 已跑通;不能因此推导真实设备协议已经验证。

第五步:让 AI 帮你扩展功能

基础版本跑通后,可以让 AI 帮你逐项加功能。这里仍然建议一个逻辑批次只改一个目标,方便判断是哪次修改引入问题:

“我有一个 WPF 串口监视工具,基础文本收发已经在虚拟串口环境跑通。现在请依次增加:十六进制显示/发送、清空接收区、接收数据保存、串口状态指示。每次只实现当前一项,保持已有行为,其余作为 NonGoals;完成后给出本项最小验证。”

这个模板的关键是:已有事实、当前目标、NonGoals、对应验证,而不是要求所有真实项目都固定一次只改一个文件。

可能遇到的问题

问题可能原因解决方向
串口列表为空没有可用串口安装虚拟串口工具,创建虚拟串口对
打开串口失败串口被其他程序占用 / 参数不可用关闭占用程序,核对端口与参数
收不到数据串口参数不匹配 / 对端没发送核对两端端口、波特率和发送动作
程序出现跨线程异常后台回调直接修改 UI把 UI 更新调度回 Dispatcher
中文或特殊字符乱码两端文本编码不一致显式统一 SerialPort.Encoding;二进制数据按 byte/HEX 处理
重开后同一消息显示多次DataReceived 被重复订阅确认订阅 ownership,只订阅一次或严格成对解绑

这个项目验证了什么

做完阶段 4,你已经实际练习过:

  • 用 XAML 搭建一个简单界面;
  • 用 C# 打开、关闭和收发串口文本;
  • 处理一次跨线程 UI 更新;
  • 管理最基本的事件订阅和资源释放;
  • 用 AI 扩展功能并根据现象排查问题;
  • 区分“虚拟串口 Demo 通过”和“真实设备已经验证”。

阶段 5:增加协议解析

目标:在文本收发 Demo 上练习一个自定义二进制协议解析器。

真实项目并不都使用同一种协议:可能是自定义二进制帧、ASCII 文本、SCPI、Modbus,也可能由厂商 SDK 封装。本阶段只是为了练习 framing,故意选择下面这个自定义二进制示例;不要把它当成所有串口设备的通用格式。

AI 提示

text
我的串口监视工具已经能收发文本。现在要新增一个“自定义二进制协议练习模式”。

本练习协议:
帧头(2B: 0xAA 0x55) + 长度(1B) + 命令(1B) + 数据(N字节) + 校验(1B: XOR)

请帮我:
1. 设计协议解析边界;
2. 针对这个流式协议处理半包和多帧连续到达;
3. 验证帧头、长度和本协议定义的 XOR 校验;
4. 保留原始字节用于日志/Replay;
5. 不破坏现有文本演示模式。

练习:用虚拟串口发送符合本练习协议的二进制样本,再补一个截断帧、连续两帧和错误校验样本。这里验证的是这个自定义协议,不是在证明所有串口协议都必须有 CRC/粘包测试。


阶段 6:增加实时曲线

目标:在工具中增加数据可视化。

AI 提示

text
我的串口监视工具已经能得到用于显示的数值。现在需要在界面上增加一个实时曲线图。

要求:
1. 显示最近 500 个数据点的曲线
2. X 轴固定窗口,新数据到达时自动滚动
3. 只影响界面显示,不修改通信和保存逻辑
4. 使用项目已有的图表控件(如果没有,请先推荐方案)
5. UI 更新不要阻塞接收路径

练习:用模拟数据记录一段固定场景下的 UI 响应、点数上限和内存趋势。短时间观察正常只能证明当前场景,没有长稳证据时不要写“无内存泄漏”。


阶段 7:模拟 Bug 排查

目标:练习在出现问题时如何给 AI 提供证据并验证修改。

以下是 7 个可以人为制造或假设的练习场景,选择一个来排查:

Bug 1:串口关闭后仍有旧回调

现象:关闭后仍看到旧任务/回调继续处理。练习确认资源 ownership 和生命周期。

Bug 2:发送按钮无响应

现象:点击发送没反应。练习追按钮到实际发送入口的调用链。

Bug 3:下拉框不能选择

现象:串口下拉框点击没反应。练习查控件状态、数据源和 UI 线程。

Bug 4:日志文件不生成

现象:路径配置了但没有文件。练习查写入入口、路径权限和异常证据。

Bug 5:长时间运行界面变慢

现象:运行后逐渐卡顿。练习先建立可见点数、Dispatcher、CPU/内存基线。

Bug 6:重复订阅事件

人为把订阅放进每次 Open,再制造一次失败/重试,观察为何同一数据会重复显示。

Bug 7:内存持续增长

人为移除列表上限,观察 UI 集合无限增长,再恢复边界。

排查流程(以 Bug 1 为例)

  1. 准备现象和复现步骤,必要时保存日志。
  2. 先让 AI 做 L1 分析:列出已确认事实、1–3 个候选原因和下一条最能区分它们的证据(见 AI 自主程度)。
  3. 拿到证据后再选择修复,不根据第一条猜测直接改代码。
  4. 限制修改到当前 Root Cause 对应范围。
  5. 按改动影响做 V1/V2 focused 验证,不机械扩大成整个解决方案回归。

项目复盘

完成以上阶段后,花 10 分钟写一个简短复盘:

text
项目:串口设备监控工具
完成阶段:1-7
做得好的地方:
遇到的最大困难:
AI 帮了哪些忙:
AI 在哪些地方出错了:
哪些只在模拟环境验证:
哪些仍是 Unverified / HardwarePending:
下次会怎么改进:

复盘完成后,你已经做完一次从需求、实现到模拟验证和问题调查的练习闭环。接下来可以进入完整项目工作流,理解真实项目为什么还需要更完整的设备边界、异常恢复和验收证据。

下一步

完成练习后,可以按学习路线深入更多专题,或进入完整项目工作流学习系统性开发方法。

查看完整学习路线 →进入完整项目工作流 →

别来无恙 · C# 上位机 AI 实战站 · 从零到交付 · QQ 群:1016188499