【閒來無事玩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用杜邦線短接,這樣相當於發送給自己接收,用於測試串口上位機程序:
在這裏插入圖片描述
調試運行程序,結果如下:
在這裏插入圖片描述

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章