【闲来无事玩C#】1、简单的串口上位机程序

这一篇来做一个简单的串口上位机程序,配合【STM32F103笔记】中的串口程序使用,后续还可以在这个串口小程序的基础上添加更多功能,可以根据预先设计的数据格式,将串口小程序接收到的数据进行不同的显示,并根据接收到的数据向STM32发送控制指令,比如上位机PID控制STM32电机调速或者转角控制等等,会很有意思。
笔者也是刚开始学C#,就当做和大家一起学习进步啦。

C#开发环境

Visual Studio下载

作为C#小程序开发的第一篇,首先介绍一下C#的开发环境——Visual StudioVS),是美国微软公司的开发工具包系列产品,这里给出官方下载地址(https://visualstudio.microsoft.com/zh-hans/downloads/):
在这里插入图片描述
可以看到VS共有三个发行版本:Community、Professional、Enterprise,作为个人开发一般选择Community就已经非常够用啦,如果你的网络不太好的话,可以点击下面的如何离线安装进行参考,具体安装就不再介绍了。

新建工程

打开VS,点击左上角文件->新建->项目,弹出新建项目对话框:
在这里插入图片描述
在左侧选择编程语言,这里笔者的默认编程语言设置的是C++,需要在其他语言里找到Visual C#,选择Window 桌面->Windows 窗体应用,然后在下方设置好项目名称位置等,点击确定就可以了:
在这里插入图片描述
左侧是项目的树形结构选项卡,右侧为工具箱属性设置等选项卡,这些选项卡都可以自己调整位置,这里按默认就很舒服,正中间就是我们的窗体了。

  • 工具箱:包含许多窗体控件,比如按钮、文本框等等;
  • 属性:用于设置控件属性和管理控件事件

拖动工具箱中的控件就可以在窗体中摆放,还请大家先了解一下C#的基本编程操作,这里就不展开说明了。(这个系列前几篇的程序都会详细给出并加上注释,可以就此了解C#程序设计的基本操作)

程序设计

界面设计

按照下面图中设置好窗体控件:
在这里插入图片描述
左侧主要为用户操作区,包括串口设置信息显示发送数据以及程序介绍,为了控件的分块整齐,这里使用了GroupBox控件放置同一功能区的控件。(这里所有的操作都放置在相近的地方(左侧),便于使用);右侧为显示区域,用于显示串口接收到的数据或者文本。

简单介绍一下:

  • 串口设置区:使用了两个ComboBox用于选择串口的端口号和波特率,其左边用两个Label控件进行信息提示,最下方是两个Button控件用于设置操作;
  • 信息显示区:使用一个CheckBox用于勾选是否将串口数据显示为十六进制,用两个Label控件对发送和接收的数据字节进行计数,右侧是一个Button控件用于清除计数和最右侧的数据显示区;
  • 发送数据区:使用一个TextBox用于输入发送的数据,两个Button用于进行发送和清除发送框;
  • 右侧显示区域:使用一个TextBox控件用于显示显示串口接收到的数据或者文本。

在设计程序之前,应该对窗体各个位置的分布进行设计,尽量操作方便且美观。

界面设计完成后,应该对各个控件进行命名,按照其控件类型与对应的功能,如上图中的“打开”按钮,将其命名为Btn_OpenPort,其中Btn为Button缩写,表示为按钮控件(控件的常用缩写可以在网上查到),OpenPort表示其功能为打开对应的串口端口号。

程序设计

各个控件设计运行的思路是:

  • 在程序开始运行时,检测电脑上已经存在的串口端口,将其在下拉选框控件进行显示,并配置默认的端口号和波特率;
  • 点击检测按钮,重新检测电脑上已经存在的串口端口,并配置默认的端口号和波特率;
  • 点击打开按钮,打开对应的串口,开始接收数据,并更新接收、发送数据字节数;
  • 点击清除按钮,清除计数及右侧显示;
  • 点击发送按钮,检测发送框中的数据并发送;
  • 点击发送数据区中的清除按钮,清除发送框中的数据。

添加控件的事件处理函数
添加控件的事件处理函数,一般双击控件可以添加默认的事件处理函数,对于其它事件处理函数需要在属性选项卡中的事件列表下双击添加:
在这里插入图片描述

MainForm_Load函数

窗体上双击或者在属性选项卡的事件下的Load事件右侧双击,如上图,自动生成窗体加载程序,即窗体加载时运行的程序:

		// 串口变量,这里需要添加命名空间using System.IO.Ports
        private SerialPort _serial;
        // 数据接收、端口关闭标志
        private bool _receiving, _closing;
        // 接收、发送计数
        private int _receiveCount, _sendCount;
		
		// 窗体加载程序
        private void MainForm_Load(object sender, EventArgs e)
        {
            // 调用InitializeSettings函数对控件进行初始化
            InitializeSettings();

            // 变量赋初值
            _receiving = false; _closing = false;
            _receiveCount = 0; _sendCount = 0;
            // 在串口没有打开之前不允许发送
            Btn_Send.Enabled = false;
        }
        
        /// <summary>
        /// 控件初始化函数,设置串口的端口号及波特率
        /// </summary>
        private void InitializeSettings()
        {
        	// 获取所有存在的端口号
            string[] ports = SerialPort.GetPortNames();
            // 排序一下,显得整齐一点
            Array.Sort(ports);
            // 重新添加端口号下拉选框控件的内容
            cb_PortNum.Items.Clear();
            cb_PortNum.Items.AddRange(ports);
            // 选择第一个端口为默认端口
            cb_PortNum.SelectedIndex = cb_PortNum.Items.Count > 0 ? 0 : -1;

			// 默认波特率
            string[] baudrate = { "2400", "4800", "9600", "19200", "38400", "57600", "115200" };
            // 设置波特率下拉选框的内容
            cb_Baudrate.Items.Clear();
            cb_Baudrate.Items.AddRange(baudrate);
            // 默认波特率选择115200
            cb_Baudrate.SelectedIndex = cb_Baudrate.Items.IndexOf("115200");

            // _serial赋初值
            _serial = new SerialPort
            {
                NewLine = "\n",
                //RtsEnable = false,
                //DtrEnable = false
            };
            // 添加串口数据接收事件处理函数
            _serial.DataReceived += _serial_DataReceived;
        }

MainForm_Load() 函数会在窗体加载(也就是显示窗口出来)的过程中运行,主要是检测系统中已经存在的串口的端口号,填入cb_PortNum(端口号下拉选框控件)中,同理初始化cb_Baudrate波特率下拉选框控件;
初始化串口变量_serial(这里初始化了换行控制符,以及添加数据接收处理函数),初始化其它后续需要使用的变量。

Btn_Detect_Click函数

双击检测按钮(Btn_Detect),或者在属性选项卡的事件下的Click事件右侧双击,添加按钮点击事件处理函数:

        private void Btn_Detect_Click(object sender, EventArgs e)
        {
        	// 调用InitializeSettings函数进行检测并设置
            InitializeSettings();
        }

这里检测按钮功能是重新检测系统中已经存在的串口的端口好,并初始化端口下拉选框和波特率下拉选框,因此直接调用InitializeSettings()函数就可以了。

Btn_OpenPort_Click函数

双击打开按钮(Btn_OpenPort),或者在属性选项卡的事件下的Click事件右侧双击,添加按钮点击事件处理函数;打开按钮用于打开或者关闭串口:

        private void Btn_OpenPort_Click(object sender, EventArgs e)
        {
        	// 判断串口端口是否已经打开
        	// 如果已经打开了,那么应该将其关闭
            if (_serial.IsOpen)
            {
            	// 正在关闭串口的标志
                _closing = true;
                // 若此时正在接收数据,那么等待这次的接收完成再关闭
                while (_receiving)
                    Application.DoEvents();
                try
                {
                	// 关闭串口
                    _serial.Close();
                }
                catch (Exception ex)
                {
                	// 如果出错则提示错误信息
                	// 一般为串口端口号不存在(因为串口可能在别处已经被关闭或者占用)
                    _serial = new SerialPort();
                    MessageBox.Show(ex.Message);
                }
                // 关闭过程结束,关闭标志清除(置false)
                _closing = false;
            }
            else
            { // 若串口没有打开则应该打开串口
            	// 首先判断串口端口号是否设置好
                if (!string.IsNullOrEmpty(cb_PortNum.Text))
                {
                	// 将_serial的端口号、波特率复制
                    _serial.PortName = cb_PortNum.Text;
                    _serial.BaudRate = int.Parse(cb_Baudrate.Text);
                    try
                    {
                    	// 尝试打开串口端口
                        _serial.Open();
                    }
                    catch (Exception ex)
                    {
                    	// 打开失败则输出错误信息
                        _serial = new SerialPort();
                        MessageBox.Show(ex.Message);
                    }
                }
                else
                    MessageBox.Show("请选择端口号!");
            }
            // 设置按钮显示字样,串口已经打开则显示“关闭”,否则显示“打开”
            Btn_OpenPort.Text = _serial.IsOpen ? "关闭" : "打开";
            // 当串口打开才能允许发送
            Btn_Send.Enabled = _serial.IsOpen;
        }

这里在关闭串口的过程中,首先将标志_closing置true,保证不再进行数据接收(后续函数中可以看到),同时判断_receiving标志,若为true则说明已经进入数据接收程序,那么应该等待此次接收完成再关闭串口;这两个标志位的用处是,在关闭串口和接收串口数据的过程中,可以避免两者相互冲突,造成程序出错(即卡死)。

Btn_ClearAll_Click

双击信息显示区的清除按钮(Btn_ClearAll),或者在属性选项卡的事件下的Click事件右侧双击,添加按钮点击事件处理函数:

        private void Btn_ClearAll_Click(object sender, EventArgs e)
        {
            txt_Display.Text = "";
            _receiveCount = _sendCount = 0;
            lbl_Received.Text = "接收:0";
            lbl_Send.Text = "发送:0";
        }

清除按钮用于清除数据显示区以及接收发送计数。

Btn_Send_Click

双击的发送按钮(Btn_Send),或者在属性选项卡的事件下的Click事件右侧双击,添加按钮点击事件处理函数:

        private void Btn_Send_Click(object sender, EventArgs e)
        {
            // 调用_serial.Write函数将发送数据框里的字符串发送出去
            _serial.Write(txt_Send.Text);

            // 更新发送计数
            _sendCount += txt_Send.Text.Length;
            lbl_Send.Text = "发送:" + _sendCount.ToString();
        }

这里直接调用了串口SerialPort的Write函数,直接向串口中写入字符串数据,即发送数据。

Btn_ClearSend_Click

双击数据发送区的清除按钮(Btn_ClearSend),或者在属性选项卡的事件下的Click事件右侧双击,添加按钮点击事件处理函数:

        private void Btn_ClearSend_Click(object sender, EventArgs e)
        {
            txt_Send.Text = "";
        }

这个函数用于清除数据发送框。

_serial_DataReceived

_serial_DataReceived函数为本篇最重要的函数,作用是当串口接收到数据时,将其读取并展示在数据显示文本框内:

        private void _serial_DataReceived(object sender, SerialDataReceivedEventArgs e)
        {
            // 如果正在关闭串口,则不再进行数据接收
            if (_closing)
                return;
            try
            {
                // 将_receiving标志置true,表示正在进行数据接收
                _receiving = true;
                // 接收缓冲区内的数据字节数
                int n = _serial.BytesToRead;
                // buf变量用于存储从接收缓冲区内读取的字节数据
                byte[] buf = new byte[n];
                // 更新接收数据计数
                _receiveCount += n;
                // 将数据读取到buf中
                _serial.Read(buf, 0, n);
                // 字符串构建
                StringBuilder sb = new StringBuilder();
                // invoke委托,进行数据处理及更新
                this.Invoke((EventHandler)(delegate
                {
                    // 判断是否显示十六进制,若是则将buf中的每个字节数据转换成十六进制
                    // 否则直接转换成ASCII编码的字符串
                    if (ckb_DataHexView.Checked)
                        foreach (byte b in buf)
                            sb.Append(b.ToString("X2") + " ");
                    else
                        sb.Append(Encoding.ASCII.GetString(buf));
                    // 更新数据接收显示
                    txt_Display.AppendText(sb.ToString());
                    // 更新计数显示
                    lbl_Received.Text = "接收:" + _receiveCount.ToString();
                }));
            }
            finally
            {
                // 数据接收结束后将_receiving标志置false
                _receiving = false;
            }
        }

从这里可以看出_closing和_receiving标志的相互配合使用,防止关闭串口的操作和数据读取操作相冲突。当然也可以在使用Invoke时改为使用BeginInvoke,这样就不需要使用_closing和_receiving这两个标志位了;
在更新数据显示时,使用了invoke委托。
笔者理解如下:

  • 窗体上的控件主线程的资源,在C#中不能跨线程访问资源,也就是说不能在其它线程中去改变另一个线程创建的控件的值;
  • 在打开串口时,会创建一个串口的监听线程对串口缓存区进行监听处理,当缓冲区有了数据之后,线程会调用接收数据处理函数(_serial_DataReceived);
  • 而在_serial_DataReceived函数中要对txt_Display等控件进行更新,即子线程访问主线程的资源,因此需要使用委托Invoke
  • this.Invoke((EventHandler)(delegate{ 委托的操作}));
    也就是说把操作委托给主线程,等待主线程执行操作,这样才能在数据接收到的同时更新txt_Display等控件;
  • 若不使用委托,直接操作txt_Display等控件,会发生错误:System.InvalidOperationException:“线程间操作无效: 从不是创建控件“txt_Display”的线程访问它。”
  • Invoke:在拥有此控件的基础窗口句柄的线程上执行指定的委托,这样会在子线程中等待主线程的操作执行完毕再向下执行,也就是在_serial_DataReceived函数中等待txt_Display等控件更新完毕;
  • 因此,若在接收大量数据后,主线程被委托更新控件,但由于数据量大,需要一定的时间,若正好在这时点击了关闭串口,而串口监听线程还在等待中,那么就会产生冲突(具体冲突原因还在查资料中),导致程序卡死;
  • 所以在使用Invoke委托时,由于其等待,需要对程序运行的状态进行判断(数据接受中_receiving,还是串口关闭中_closing);
  • BeginInvoke:在创建控件的基础句柄所在线程上异步执行指定委托,也就是说,BeginInvoke不会等待而继续执行,这时,串口监听线程调用的_serial_DataReceived函数将继续执行并结束,此时调用串口的Close函数就不会产生冲突;

至此,程序分析结束。

完整程序

using System;
using System.Text;
using System.Windows.Forms;

using System.IO.Ports;

namespace _1_SimpleSerialTool
{
    public partial class MainForm : Form
    {
        private SerialPort _serial;

        private bool _receiving, _closing;

        private int _receiveCount, _sendCount;

        public MainForm()
        {
            InitializeComponent();
        }

        private void MainForm_Load(object sender, EventArgs e)
        {
            InitializeSettings();

            _receiving = false; _closing = false;
            _receiveCount = 0; _sendCount = 0;
            Btn_Send.Enabled = false;
        }

        private void Btn_Detect_Click(object sender, EventArgs e)
        {
            InitializeSettings();
        }

        private void Btn_OpenPort_Click(object sender, EventArgs e)
        {
            if (_serial.IsOpen)
            {
                //_closing = true;
                //while (_receiving)
                //    Application.DoEvents();
                try
                {
                    _serial.Close();
                }
                catch (Exception ex)
                {
                    _serial = new SerialPort();
                    MessageBox.Show(ex.Message);
                }
                //_closing = false;
            }
            else
            {
                if (!string.IsNullOrEmpty(cb_PortNum.Text))
                {
                    _serial.PortName = cb_PortNum.Text;
                    _serial.BaudRate = int.Parse(cb_Baudrate.Text);
                    try
                    {
                        _serial.Open();
                    }
                    catch (Exception ex)
                    {
                        _serial = new SerialPort();
                        MessageBox.Show(ex.Message);
                    }
                }
                else
                    MessageBox.Show("请选择端口号!");
            }
            Btn_OpenPort.Text = _serial.IsOpen ? "关闭" : "打开";
            Btn_Send.Enabled = _serial.IsOpen;
        }

        private void Btn_ClearAll_Click(object sender, EventArgs e)
        {
            txt_Display.Text = "";
            _receiveCount = _sendCount = 0;
            lbl_Received.Text = "接收:0";
            lbl_Send.Text = "发送:0";
        }

        private void Btn_Send_Click(object sender, EventArgs e)
        {
            _serial.Write(txt_Send.Text);

            _sendCount += txt_Send.Text.Length;
            lbl_Send.Text = "发送:" + _sendCount.ToString();
        }

        private void Btn_ClearSend_Click(object sender, EventArgs e)
        {
            txt_Send.Text = "";
        }

        private void _serial_DataReceived(object sender, SerialDataReceivedEventArgs e)
        {
            //if (_closing)
            //    return;
            try
            {
                _receiving = true;
                int n = _serial.BytesToRead;
                byte[] buf = new byte[n];
                _receiveCount += n;
                _serial.Read(buf, 0, n);
                StringBuilder sb = new StringBuilder();
                // this.Invoke((EventHandler)(delegate
                this.BeginInvoke((EventHandler)(delegate
                {
                    if (ckb_DataHexView.Checked)
                        foreach (byte b in buf)
                            sb.Append(b.ToString("X2") + " ");
                    else
                        sb.Append(Encoding.ASCII.GetString(buf));
                    txt_Display.AppendText(sb.ToString());
                    lbl_Received.Text = "接收:" + _receiveCount.ToString();
                }));
            }
            finally
            {
                _receiving = false;
            }
        }

        private void InitializeSettings()
        {
            string[] ports = SerialPort.GetPortNames();
            Array.Sort(ports);
            cb_PortNum.Items.Clear();
            cb_PortNum.Items.AddRange(ports);
            cb_PortNum.SelectedIndex = cb_PortNum.Items.Count > 0 ? 0 : -1;

            string[] baudrate = { "2400", "4800", "9600", "19200", "38400", "57600", "115200" };
            cb_Baudrate.Items.Clear();
            cb_Baudrate.Items.AddRange(baudrate);
            cb_Baudrate.SelectedIndex = cb_Baudrate.Items.IndexOf("115200");

            _serial = new SerialPort
            {
                NewLine = "\n",
                //RtsEnable = false,
                //DtrEnable = false
            };
            _serial.DataReceived += _serial_DataReceived;
        }
    }  // end of MainForm
}

运行结果

将USB转串口设备的TX和RX用杜邦线短接,这样相当于发送给自己接收,用于测试串口上位机程序:
在这里插入图片描述
调试运行程序,结果如下:
在这里插入图片描述

完结撒花✿✿ヽ(°▽°)ノ✿

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 游动-白 设计师:上身试试 返回首页