在單片機項目開發中,上位機也是一個很重要的部分,主要用於數據顯示(波形、溫度等)、用戶控制(LED,繼電器等),下位機(單片機)與 上位機之間要進行數據通信的兩種方式都是基於串口的:
- USB轉串口 —— 上位機和下位機通過USB轉串口連接線直接相連進行數據交互;
- 串口轉WIFI(ESP8266) —— 上位機和下位機基於TCP/IP協議通過WIFI傳輸數據;
- 串口轉藍牙(HC-06)—— 不多用,暫不介紹;
上位機軟軟件開發主要包括以下兩種:
1、Windows上位機(EXE可執行程序)
在Windows上,最早用VB語言開發,后來由於C++的發展,采用MFC開發,近幾年,微軟發布了基於.NET框架的面向對象語言C#,更加穩定安全,再配合微軟強大的VS進行開發,效率奇高;
另外,如果想要在Linux上跨平台運行,可以選用Qt;如果想要更加豐富好看的數據顯示界面,可以選用Labview開發;
2、Android上位機(APP)
在Android操作系統上,主要采用Java語言,使用WIFI或者藍牙基於TCP/IP協議傳輸數據,利用Android Studio開發;
在此,我們主要介紹如何通過VS + C#開發電腦上位機,其它上位機的開發暫且不論。
注:VS下載與安裝參考這篇較詳細的博客
https://blog.csdn.net/qq_36556893/article/details/79430133
上一篇大致了解了一下單片機實際項目開發中上位機開發部分的內容以及VS下載與安裝,按照編程慣例,接下來就是“Hello,World!”
1、新建C#項目工程
首先選擇新建Windows窗體應用(.NET Framework),然后選擇項目保存位置,填寫項目名稱,這里因為我們不需要用git進行版本管理,所以不用新建GIT存儲庫;
框架是指.net框架,4以及4以下的.NET框架可以在xp上運行,4以上可以在win7/8/10上運行,鑒於當前大多數操作系統都是win7或win10,選擇4.5版本。
2、窗體介紹及代碼分析
這里我們雙擊窗體界面,這也是VS的特性,雙擊一個控件,就會進入對應代碼文件部分,這些代碼全由VS在生成項目時自動生成,下面進行詳細的解釋:
1 /*filename:Form1.cs*/ 2 //使用命名空間 3 using System; 4 using System.Collections.Generic; 5 using System.ComponentModel; 6 using System.Data; 7 using System.Drawing; 8 using System.Linq; 9 using System.Text; 10 using System.Windows.Forms; 11 12 //用戶項目工程自定義命名空間HelloWorld 13 namespace HelloWorld 14 { 15 //定義了一個名稱為Form1的公共類,並且在定義類的同時創建了一個這個類的對象,名為Form 16 //partial關鍵字 17 public partial class Form1 : Form 18 { 19 //與類同名的構造方法 20 public Form1() 21 { 22 InitializeComponent(); 23 } 24 //用戶自定義方法,窗體加載時由Form對象調用 25 private void Form1_Load(object sender, EventArgs e) 26 { 27 } 28 } 29 }
命名空間(namespace):在C#中用命名空間將很多類的屬性及其方法進行封裝供調用,類似C語言中將變量和函數封裝成一個個.h文件,調用的時候只需要#include "filepath + filename"就可以使用,比如剛開始時用關鍵字using聲明了一些所需要的系統命名空間(line1-10);然后采用關鍵字namespace來自定義一個用戶工程所需的命名空間HelloWorld,在我們定義的這個命名空間里就可以定義一些類和方法來進行下一步的實現;
類(class):C#是一門面向對象的編程語言,所以最基本的就是類和對象,對象的特征是具有屬性(C語言中稱為變量)和方法(C語言中稱為函數),然后我們定義一個類來描述這個對象的特征,注意:這個時候定義的類不是真實存在的,所以不會分配內存空間,當我們用所定義的這個類去創建一個類的對象,這個對象是真實存在的,它會占用內存空間,比如在這個工程中定義了一個名稱為Form1的公共類,並且在定義類的同時創建了一個這個類的對象,名為Form;
方法:前面已經說過,在面向對象編程中是沒有變量和函數的,所有的函數都被封裝在類中,屬於對象的方法,最基本的是類的構造方法,該方法與類名同名,在用類創建一個具體對象時自動調用,不可缺少,比如Form1( );另外一種是自己定義的用戶方法,比如該類中的Form1_Load()方法,就是在初始化窗口時,通過具體對象Form調用:Form.Form1_Load( );
訪問修飾符:用來控制類、屬性、方法的訪問權限,常用有5個,默認私有,不能被外部訪問;
私有的private,公共的public,受保護的protected,內部的internal,受保護內部的protect internal;
這里有一個重點,在定義Form1類的時候含有一個關鍵字partial,這里就不得不說C#語言設計一個重要的特性了,能作為大多數人開發上位機的首選,C#有一個特性就是設計的時候界面與后台分離,但是類名相同,首先看一下工程文件結構:
可以看到,Form1.cs文件下面包含了另一個Form1.Designer.cs文件,再打開Form1.Designer.cs這個文件,是不是很驚奇,和前面一模一樣,再次定義了一個命名空間HelloWorld和Form1類,這個部分類中定義了我們使用的控件、事件委托以及如Dispose方法等。因為這里面的代碼都是自動生成的,因此設計成了一個部分類。最關鍵的一點,這里類也是用partial關鍵字修飾的,可以看到,Partial是局部類型的意思,允許我們將一個類、結構或接口分成幾個部分,分別實現在幾個不同的.cs文件中,用partial定義的類可以在多個地方被定義,最后C#編譯器編譯時會將這些類當作一個類來處理;
1 /*@filename:Form1.Designer.cs */ 2 3 namespace HelloWorld 4 { 5 partial class Form1 6 { 7 /// <summary> 8 /// 必需的設計器變量。 9 /// </summary> 10 private System.ComponentModel.IContainer components = null; 11 12 /// <summary> 13 /// 清理所有正在使用的資源。 14 /// </summary> 15 /// <param name="disposing">如果應釋放托管資源,為 true;否則為 false。</param> 16 protected override void Dispose(bool disposing) 17 { 18 if (disposing && (components != null)) 19 { 20 components.Dispose(); 21 } 22 base.Dispose(disposing); 23 } 24 25 #region Windows 窗體設計器生成的代碼 26 27 /// <summary> 28 /// 設計器支持所需的方法 - 不要修改 29 /// 使用代碼編輯器修改此方法的內容。 30 /// </summary> 31 private void InitializeComponent() 32 { 33 this.SuspendLayout(); 34 // 35 // Form1 36 // 37 this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F); 38 this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; 39 this.ClientSize = new System.Drawing.Size(418, 331); 40 this.Name = "Form1"; 41 this.Text = "Form1"; 42 this.Load += new System.EventHandler(this.Form1_Load); 43 this.ResumeLayout(false); 44 45 } 46 #endregion 47 } 48 }
Main: 一切程序都有入口主函數main,C#也是如此,在Program.cs文件中定義了Program類,該類中擁有主函數main( ), 在main函數中,第三行代碼是一切的開始,調用Form1類的構造函數,創建一個Form對象,一切由此開始,代碼如下:
1 /* @filename: Program.cs */
2 using System;
3 using System.Collections.Generic;
4 using System.Linq;
5 using System.Windows.Forms;
6
7 namespace HelloWorld
8 {
9 static class Program
10 {
11 /// <summary>
12 /// 應用程序的主入口點。
13 /// </summary>
14 [STAThread]
15 static void Main()
16 {
17 Application.EnableVisualStyles();
18 Application.SetCompatibleTextRenderingDefault(false);
19 Application.Run(new Form1()); //調用Form1類的構造函數,創建一個Form對象,一切由此開始 20 } 21 } 22 }
再來解釋一下最后三個文件:第一個文件主要是應用程序發布時的一些屬性設置,版本號,屬性,版權之類的,其余兩個文件是工具自動生成的一些設置文件,不再過多贅述;
/* @filename:Assemblylnfo.cs*/ using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; // 有關程序集的一般信息由以下 // 控制。更改這些特性值可修改 // 與程序集關聯的信息。 [assembly: AssemblyTitle("HelloWorld")] [assembly: AssemblyDescription("")] [assembly: AssemblyConfiguration("")] [assembly: AssemblyCompany("")] [assembly: AssemblyProduct("HelloWorld")] [assembly: AssemblyCopyright("Copyright © 2018")] [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] // 將 ComVisible 設置為 false 會使此程序集中的類型 //對 COM 組件不可見。如果需要從 COM 訪問此程序集中的類型 //請將此類型的 ComVisible 特性設置為 true。 [assembly: ComVisible(false)] // 如果此項目向 COM 公開,則下列 GUID 用於類型庫的 ID [assembly: Guid("094ac56a-7a59-4f32-a2eb-857135be4d2c")] // 程序集的版本信息由下列四個值組成: // // 主版本 // 次版本 // 生成號 // 修訂號 // // 可以指定所有值,也可以使用以下所示的 "*" 預置版本號和修訂號 // 方法是按如下所示使用“*”: : // [assembly: AssemblyVersion("1.0.*")] [assembly: AssemblyVersion("1.0.0.0")] [assembly: AssemblyFileVersion("1.0.0.0")]
3、Hello,World
下面就正式開始C#程序的設計,首先是界面的實現,可以隨意從控件工具箱中拖放控件到窗體中,這里我拖動兩個Button和一個TextBox,並在右邊設置框中修改每個控價的屬性,界面如圖:
這個時候如果查看Form1.cs文件,會發現和之前一樣,這里就需要介紹另外幾個開發GUI界面的知識點了,首先,我們想要實現的功能是:當按下Send按鈕時,文本框顯示^_^Hello,World^_^字樣,當按下Clear按鈕時,文本框清空;這屬於人機交互,一般人機交互的處理方式有兩種,第一種是查詢處理方式,比如在DOS系統下、Linux系統等命令行下的程序設計,第二種是事件處理機制,有了很多的優越性,由傳統的查詢法耗費CPU一直在檢測,變成了事件處理機制下的主動提醒告知,大幅度減輕CPU資源浪費,在事件處理機制中有以下幾個概念:
事件源(EventSource):描述人機交互中事件的來源,通常是一些控件;
事件(ActionEvent):事件源產生的交互內容,比如按下按鈕;
事件處理:這部分也在C++中被叫做回調函數,當事件發生時用來處理事件;
注:這部分在單片機中也是如此,中斷源產生中斷,然后進入中斷服務函數進行響應;
清楚了這幾個概念后,就來實現我們想要的功能,按下按鈕是一個事件,那么,如何編寫或者在哪編寫這個事件的事件處理函數呢?在VS中很方便,只需要雙擊這個控件,VS就會自動將該控件的事件處理函數添加進Form1.cs文件,此處我先雙擊“Send”按鈕,可以看到VS自動添加進了 private void button1_Click(object sender, EventArgs e) 這個方法,然后在里面編寫代碼,讓文本框顯示:這里所有的控件都是一個具體的對象,我們要通過這些對象設置其屬性或者調用其方法;同樣的道理,雙擊Clear按鈕,添加文本框清空代碼,完整代碼如下:
//用戶項目工程自定義命名空間HelloWorld namespace HelloWorld { //定義了一個名稱為Form1的公共類,並且在定義類的同時創建了一個這個類的對象,名為Form //partial關鍵字 public partial class Form1 : Form { //與類同名的構造方法 public Form1() { InitializeComponent(); } private void Form1_Load(object sender, EventArgs e) { } private void button1_Click(object sender, EventArgs e) { //按下Send按鈕 textBox1.Text = "^_^Hello,World^_^"; //文本框顯示 } private void button2_Click(object sender, EventArgs e) { //按下Clear按鈕 textBox1.Text = ""; //文本框清空 } } }
至此,大功告成,第一個應用程序創建成功,點擊啟動按鈕看下效果:
上一篇簡單介紹了C#的一些基本知識,並成功的Hello,World,那么從這篇開始,我們來自己動手寫一個串口助手:
1、構思功能
串口助手在單片機開發中經常被用來調試,最基本的功能就是接收功能和發送功能,其次,串口在打開前需要進行一些設置:串口列表選擇、波特率、數據位、校驗位、停止位,這樣就有了一個基本的雛形;然后我們在下一篇中在此功能上添加:ASCII/HEX顯示,發送,發送新行功能,重復自動發送功能,顯示接收數據時間這幾項擴展功能;
2、設計布局
根據以上功能,將整個界面分為兩塊:設置界面(不可縮放)+ 接收區和發送區(可縮放),下面就來依次拖放控件實現:
1)容器控件(Panel)
Panel是容器控件,是一些小控件的容器池,用來給控件進行大致分組,要注意容器是一個虛擬的,只會在設計的時候出現,不會顯示在設計完成的界面上,這里我們將整個界面分為6個容器池,如圖:
2)文本標簽控件(Lable)
用於顯示一些文本,但是不可被編輯;改變其顯示內容有兩種方法:一是直接在屬性面板修改“Text”的值,二是通過代碼修改其屬性,見如下代碼;另外,可以修改Font屬性修改其顯示字體及大小,這里我們選擇微軟雅黑,12號字體;
label1.Text = "串口"; //設置label的Text屬性值
3)下拉組合框控件(ComboBox)
用來顯示下拉列表;通常有兩種模式,一種是DropDown模式,既可以選擇下拉項,也可以選擇直接編輯;另一種是DropDownList模式,只能從下拉列表中選擇,兩種模式通過設置DropDownStyle屬性選擇,這里我們選擇第二種模式;
那么,如何加入下拉選項呢?對於比較少的下拉項,可以通過在屬性面板中Items屬性中加入,比如停止位設置,如圖,如果想要出現默認值,改變Text屬性就可以,但要注意必須和下拉項一致:
另外一種是直接在頁面加載函數代碼中加入,比如波特率的選擇,代碼如下:
private void Form1_Load(object sender, EventArgs e) { int i; //單個添加for (i = 300; i <= 38400; i = i*2) { comboBox2.Items.Add(i.ToString()); //添加波特率列表 } //批量添加波特率列表 string[] baud = { "43000","56000","57600","115200","128000","230400","256000","460800" }; comboBox2.Items.AddRange(baud); //設置默認值 comboBox1.Text = "COM1"; comboBox2.Text = "115200"; comboBox3.Text = "8"; comboBox4.Text = "None"; comboBox5.Text = "1"; }
4)按鈕控件(Button)
5)文本框控件(TextBox)
TextBox控件與label控件不同的是,文本框控件的內容可以由用戶修改,這也滿足我們的發送文本框需求;在默認情況下,TextBox控價是單行顯示的,如果想要多行顯示,需要設置其Multiline屬性為true;
TextBox的方法中最多的是APPendText方法,它的作用是將新的文本數據從末尾處追加至TextBox中,那么當TextBox一直追加文本后就會帶來本身長度不夠而無法顯示全部文本的問題,此時我們需要使能TextBox的縱向滾動條來跟蹤顯示最新文本,所以我們將TextBox的屬性ScrollBars的值設置為Vertical即可;
至此,我們的顯示控件就全部添加完畢,但是還有一個最重要的空間沒有添加,這種控件叫做隱式控件,它是運行於后台的,用戶看不見,更不能直接控制,所以也成為組件,接下來我們添加最主要的串口組件;
6)串口組件(SerialPort)
這種隱式控件添加后位於設計器下面 ,串口常用的屬性有兩個,一個是端口號(PortName),一個是波特率(BaudRate),當然還有數據位,停止位,奇偶校驗位等;串口打開與關閉都有接口可以直接調用,串口同時還有一個IsOpen屬性,IsOpen為true表示串口已經打開,IsOpen為flase則表示串口已經關閉。
添加了串口組件后,我們就可以通過它來獲取電腦當前端口,並添加到可選列表中,代碼如下:
//獲取電腦當前可用串口並添加到選項列表中 comboBox1.Items.AddRange(System.IO.Ports.SerialPort.GetPortNames());
啟動后可以看到界面布局效果圖如下(確保USB轉串口CH340已連接):
3、搭建后台
界面布局完成后,我們就要用代碼來搭建整個軟件的后台,這部分才是重中之重。
首先,我們先來控制打開/關閉串口,大致思路是:當按下打開串口按鈕后,將設置值傳送到串口控件的屬性中,然后打開串口,按鈕顯示關閉串口,再次按下時,串口關閉,顯示打開按鈕;
在這個過程中,要注意一點,當我們點擊打開按鈕時,會發生一些我們編程時無法處理的事件,比如硬件串口沒有連接,串口打開的過程中硬件突然斷開,這些被稱之為異常,針對這些異常,C#也有try..catch處理機制,在try中放置可能產生異常的代碼,比如打開串口,在catch中捕捉異常進行處理,詳細代碼如下:
private void button1_Click(object sender, EventArgs e) { try { //將可能產生異常的代碼放置在try塊中 //根據當前串口屬性來判斷是否打開 if (serialPort1.IsOpen) { //串口已經處於打開狀態 serialPort1.Close(); //關閉串口 button1.Text = "打開串口"; button1.BackColor = Color.ForestGreen; comboBox1.Enabled = true; comboBox2.Enabled = true; comboBox3.Enabled = true; comboBox4.Enabled = true; comboBox5.Enabled = true; textBox_receive.Text = ""; //清空接收區 textBox_send.Text = ""; //清空發送區 } else { //串口已經處於關閉狀態,則設置好串口屬性后打開 comboBox1.Enabled = false; comboBox2.Enabled = false; comboBox3.Enabled = false; comboBox4.Enabled = false; comboBox5.Enabled = false; serialPort1.PortName = comboBox1.Text; serialPort1.BaudRate = Convert.ToInt32(comboBox2.Text); serialPort1.DataBits = Convert.ToInt16(comboBox3.Text); if (comboBox4.Text.Equals("None")) serialPort1.Parity = System.IO.Ports.Parity.None; else if(comboBox4.Text.Equals("Odd")) serialPort1.Parity = System.IO.Ports.Parity.Odd; else if (comboBox4.Text.Equals("Even")) serialPort1.Parity = System.IO.Ports.Parity.Even; else if (comboBox4.Text.Equals("Mark")) serialPort1.Parity = System.IO.Ports.Parity.Mark; else if (comboBox4.Text.Equals("Space")) serialPort1.Parity = System.IO.Ports.Parity.Space; if (comboBox5.Text.Equals("1")) serialPort1.StopBits = System.IO.Ports.StopBits.One; else if (comboBox5.Text.Equals("1.5")) serialPort1.StopBits = System.IO.Ports.StopBits.OnePointFive; else if (comboBox5.Text.Equals("2")) serialPort1.StopBits = System.IO.Ports.StopBits.Two; serialPort1.Open(); //打開串口 button1.Text = "關閉串口"; button1.BackColor = Color.Firebrick; } } catch (Exception ex) { //捕獲可能發生的異常並進行處理 //捕獲到異常,創建一個新的對象,之前的不可以再用 serialPort1 = new System.IO.Ports.SerialPort(); //刷新COM口選項 comboBox1.Items.Clear(); comboBox1.Items.AddRange(System.IO.Ports.SerialPort.GetPortNames()); //響鈴並顯示異常給用戶 System.Media.SystemSounds.Beep.Play(); button1.Text = "打開串口"; button1.BackColor = Color.ForestGreen; MessageBox.Show(ex.Message); comboBox1.Enabled = true; comboBox2.Enabled = true; comboBox3.Enabled = true; comboBox4.Enabled = true; comboBox5.Enabled = true; } }
接下來我們構建發送和接收的后台代碼,串口發送和接收都是在串口成功打開的情況下進行的,所以首先要判斷串口屬性IsOpen是否為1;
串口發送有兩種方法,一種是字符串發送WriteLine,一種是Write(),可以發送一個字符串或者16進制發送(見下篇),其中字符串發送WriteLine默認已經在末尾添加換行符;
private void button2_Click(object sender, EventArgs e) { try { //首先判斷串口是否開啟 if (serialPort1.IsOpen) { //串口處於開啟狀態,將發送區文本發送 serialPort1.Write(textBox_send.Text); } } catch (Exception ex) { //捕獲到異常,創建一個新的對象,之前的不可以再用 serialPort1 = new System.IO.Ports.SerialPort(); //刷新COM口選項 comboBox1.Items.Clear(); comboBox1.Items.AddRange(System.IO.Ports.SerialPort.GetPortNames()); //響鈴並顯示異常給用戶 System.Media.SystemSounds.Beep.Play(); button1.Text = "打開串口"; button1.BackColor = Color.ForestGreen; MessageBox.Show(ex.Message); comboBox1.Enabled = true; comboBox2.Enabled = true; comboBox3.Enabled = true; comboBox4.Enabled = true; comboBox5.Enabled = true; } }
接下來開始最后一個任務 —— 串口接收,在使用串口接收之前要先為串口注冊一個Receive事件,相當於單片機中的串口接收中斷,然后在中斷內部對緩沖區的數據進行讀取,如圖,輸入完成后回車,就會跳轉到響應代碼部分:
//串口接收事件處理 private void SerialPort1_DataReceived(object sender, System.IO.Ports.SerialDataReceivedEventArgs e) { }
同樣的,串口接收也有兩種方法,一種是16進制方式讀(下篇介紹),一種是字符串方式讀,在剛剛生成的代碼中編寫,如下:
//串口接收事件處理 private void SerialPort1_DataReceived(object sender, System.IO.Ports.SerialDataReceivedEventArgs e) { try { //因為要訪問UI資源,所以需要使用invoke方式同步ui this.Invoke((EventHandler)(delegate { textBox_receive.AppendText(serialPort1.ReadExisting()); } ) ); } catch (Exception ex) { //響鈴並顯示異常給用戶 System.Media.SystemSounds.Beep.Play(); MessageBox.Show(ex.Message); } }
這里又有了一個新的知識點,這個串口接收處理函數屬於一個單獨的線程,不屬於main的主線程,而接收區的TextBox是在主線程中創建的,所以當我們直接用serialPort1.ReadExisting()讀取回來字符串,然后用追加到textBox_receive.AppendText()追加到接收顯示文本框中的時候,串口助手在運行時沒有反應,甚至報異常,如圖:
所以,這個時候我們就需要用到invoke方式,這種方式專門被用於解決從不是創建控件的線程訪問它,加入了invoke方式后,串口助手就可以正常接收到數據了,如圖:
上一篇中我們完成了一個串口助手的雛形,實現了基本發送和接收字符串功能,並將打開/關閉串口進行了異常處理,這篇就來按照流程,逐步將功能完善:
1、構思功能
首先是接收部分,要添加一個“清空接收”的按鈕來清空接收區;因為串口通信協議常用都是8bit數據(低7bit表示ASCII碼,高1bit表示奇偶校驗),作為一個開發調試工具,它還需要將這個8bit碼用十六進制方式顯示出來,方便調試,所以還需要添加兩個單選框來選擇ASCII碼顯示還是HEX顯示;
然后是發送部分,與之前對應,調試過程中還需要直接發送十六進制數據,所以也需要添加兩個單選框來選擇發送ASCII碼還是HEX碼;除了這個功能,還需要添加自動發送的功能,自動發送新行功能方便調試;
2、設計布局
1)單選按鈕控件(RadioButton)
接收數據顯示只能同時選中ASCII顯示或者HEX顯示,所以要用單選按鈕控件,在同一組中(比如之前所講述的容器)的單選按鈕控件只能同時選中一個,剛好符合我們的要求;
2)復選框控件(CheckBox)
這個通常被用於選擇一些可選功能,比如是否顯示數據接收時間,是否在發送時自送發送新行,是否開啟自動發送功能等,它與之前的RadioButton都有一個很重要的屬性 —— CHecked,若為false,則表示未被選中,若為true,則表示被選中;
3)數值增減控件(NumericUpDown)
顯示用戶通過單擊控件上的上/下按鈕可以增加和減少的單個數值,這里我們用來設置自動發送的間隔時長;
4)定時器組件(Timer)
這里之所以稱為組件是因為它和之前的串口一樣,都不能被用戶直接操作;它是按用戶定義的間隔引發事件的組件;
Timer主要是Interval屬性,用來設置定時值,默認單位ms;在設置定時器之后,可以調用Timer對象的start()方法和stop()方法來啟動或者關閉定時器;在啟動之后,Timer就會每隔Interval毫秒觸發一次Tick事件,如果設置初始值為100ms,我們只需要設置一個全局變量i,每次時間到后i++,當i==10的時候,就表示計數值為1s(這里Timer的使用方法是不是和單片機相同^_^);
整體設計出來的效果圖如下:
3、搭建后台
按照之前的思路,界面布局完成后,就要開始一個軟件最重要的部分 —— 搭建后台:
1、狀態欄串口狀態顯示
這里直接添加代碼即可,無需多言;
label6.Text = "串口已打開"; label6.ForeColor = Color.Green;
label6.Text = "串口已關閉"; label6.ForeColor = Color.Red;
2、接收部分
之前我們直接在串口接收事件中調用serialPort1.ReadExisting()方法讀取整個接收緩存區,然后追加到接收顯示文本框中,但在這里我們需要在底部狀態欄顯示接收字節數和發送字節數,所以就不能這樣整體讀取,要逐字節讀取/發送並且計數;
1)類的屬性
首先定義一個用於計數接收字節的變量,這個變量的作用相當於C語言中的全局變量,在C#中稱之為類的屬性,這個屬性可以被這個類中的方法所訪問,或者通過這個對象來訪問,代碼如下:
public partial class Form1 : Form { private long receive_count = 0; //接收字節計數, 作用相當於全局變量 ....... }
2)按字節讀取緩沖區
首先通過訪問串口的BytesToRead屬性獲取到接收緩沖區中數據的字節數,然后調用串口的Read(byte[ ] buffer, int offset, int count)方法從輸入緩沖區讀取一些字節並將那些字節寫入字節數組中指定的偏移量處:
//串口接收事件處理 private void SerialPort1_DataReceived(object sender, System.IO.Ports.SerialDataReceivedEventArgs e) { int num = serialPort1.BytesToRead; //獲取接收緩沖區中的字節數 byte[] received_buf = new byte[num]; //聲明一個大小為num的字節數據用於存放讀出的byte型數據 receive_count += num; //接收字節計數變量增加nun serialPort1.Read(received_buf,0,num); //讀取接收緩沖區中num個字節到byte數組中
//未完,見下 }
上一步我們將串口接收緩沖區中的數據按字節讀取到了byte型數組received_buf中,但是要注意,這里的數據全部是byte型數據,如何顯示到接收文本框中呢?要知道接收文本框顯示的內容都是以字符串形式呈現的,也就是說我們追加到文本框中的內容必須是字符串類型,即使是16進制顯示,也是將數據轉化為16進制字符串類型顯示的,接下來講述如何將字節型數據轉化為字符串類型數據;
3)字符串構造類型(StringBuilder)
我們需要將整個received_buf數組進行遍歷,將每一個byte型數據轉化為字符型,然后將其追加到我們總的字符串(要發送到接收文本框去顯示的那個完整字符串)后面,但是String類型不允許對內容進行任何改動,更何況我們需要遍歷追加字符,所以這個時候就需要用到字符串構造類型(StringBuilder),它不僅允許任意改動內容,還提供了Append,Remove,Replace,Length,ToString等等有用的方法,這個時候再來構造字符串就顯得很簡單了,代碼如下:
public partial class Form1 : Form { private StringBuilder sb = new StringBuilder(); //為了避免在接收處理函數中反復調用,依然聲明為一個全局變量 //其余代碼省略 }
//串口接收事件處理 private void SerialPort1_DataReceived(object sender, System.IO.Ports.SerialDataReceivedEventArgs e) { //接第二步中的代碼 sb.Clear(); //防止出錯,首先清空字符串構造器 //遍歷數組進行字符串轉化及拼接 foreach (byte b in received_buf) { sb.Append(b.ToString()); } try { //因為要訪問UI資源,所以需要使用invoke方式同步ui Invoke((EventHandler)(delegate { textBox_receive.AppendText(sb.ToString()); label7.Text = "Rx:" + receive_count.ToString() + "Bytes"; } ) ); } //代碼省略
}
接下來我們運行看一下效果:
可以看到,當我們發送字符“1”的時候,狀態欄顯示接收到1byte數據,表明計數正常,但是接收到的卻是字符形式的“49”,這是因為接收到的byte類型的數據存放的就是ASCII碼值,而調用byte對象的ToString()方法,由下圖可看到,這個方法剛好又將這個ASCII值49轉化成為了字符串“49”,而不是對應的ASCII字符'1';
4)C#類庫——編碼類(Encoding Class)
接着上一個問題,我們需要將byte轉化為對應的ASCII碼,這就屬於解碼(將一系列編碼字節轉換為一組字符的過程),同樣將一組字符轉換為一系列字節的過程稱為編碼;
這里因為轉換的是ASCII碼,有兩種方法實現:第一種采用Encoding類的ASCII屬性實現,第二種采用Encoding Class的派生類ASCIIEncoing Class實現,我們采用第一種方法實現,然后調用GetString(Byte[ ])方法將整個數組解碼為ASCII數組,代碼如下:
sb.Append(Encoding.ASCII.GetString(received_buf)); //將整個數組解碼為ASCII數組
再次運行一下,可以看到正常顯示:
5)byte類型值轉化為十六進制字符顯示
在第3節中我們分析了byte.ToString()方法,它可以將byte類型直接轉化為字符顯示,比如接收到的是字符1的ASCII碼值是49,就將49直接轉化為“49”顯示出來,在這里,我們需要將49用十六進制顯示,也就是顯示“31”(0x31),這種轉化並沒有什么實質上的改變,只是進行了數制轉化而已,所以采用格式控制的ToString(String)方法,具體使用方法見下圖:
這里我們需要將其轉化為2位十六進制文本顯示,另外,由於ASCII和HEX只能同時顯示一種,所以我們還要對單選按鈕是否選中進行判斷,代碼如下:
if (radioButton2.Checked) { //選中HEX模式顯示 foreach (byte b in received_buf) { sb.Append(b.ToString("X2") + ' '); //將byte型數據轉化為2位16進制文本顯示,用空格隔開 } } else { //選中ASCII模式顯示 sb.Append(Encoding.ASCII.GetString(received_buf)); //將整個數組解碼為ASCII數組 }
再來運行看一下最終效果(先發送“Mculover66”加回車,然后發送“1”加回車):
6)日期時間結構(DateTime Struct)
當我們勾選上顯示接收數據時間時,要在接收數據前加上時間,這個時間通過DateTime Struct來獲取,首先還是聲明一個全局變量:
private DateTime current_time = new DateTime(); //為了避免在接收處理函數中反復調用,依然聲明為一個全局變量
這個時候current_time是一個DateTime類型,通過調用ToString(String)方法將其轉化為文本顯示,具體選用哪種見下圖:
在顯示的時候,依然要對用戶是否選中進行判斷,代碼如下:
//因為要訪問UI資源,所以需要使用invoke方式同步ui Invoke((EventHandler)(delegate { if (checkBox1.Checked) { //顯示時間 current_time = System.DateTime.Now; //獲取當前時間 textBox_receive.AppendText(current_time.ToString("HH:mm:ss") + " " + sb.ToString()); } else { //不顯示時間 textBox_receive.AppendText(sb.ToString()); } label7.Text = "Rx:" + receive_count.ToString() + "Bytes"; } ) );
再來運行看一下效果:
7)清空接收按鈕
這里就不需要多說了,直接貼代碼:
private void button3_Click(object sender, EventArgs e) { textBox_receive.Text = ""; //清空接收文本框 textBox_send.Text = ""; //清空發送文本框 receive_count = 0; //計數清零 label7.Text = "Rx:" + receive_count.ToString() + "Bytes"; //刷新界面 }
3、發送部分
首先為了避免發送出錯,啟動時我們將發送按鈕失能,只有成功打開后才使能,關閉后失能,這部分代碼簡單,自行編寫;
1)字節計數 + 發送新行
有了上面的基礎,實現這兩個功能就比較簡單了,要注意Write和WriteLine的區別:
2)正則表達式的簡單應用
這是一個很重要很重要很重要的知識 —— 正則表達式!我們希望發送的數據是0x31,所以功能應該被設計為在HEX發送模式下,用戶輸入“31”就應該發送0x31,這個不難,只需要將字符串每2個字符提取一下,然后按16進制轉化為一個byte類型的值,最后調用write(byte[ ] buffer,int offset,int count)將這一個字節數據發送就可以,那么,當用戶同時輸入多個十六進制字符呢該符合發送呢?
這個時候就需要用到正則表達式了,用戶可以將輸入的十六進制數據用任意多個空格隔開,然后我們利用正則表達式匹配空格,並替換為“”,相當於刪除掉空格,這樣對整個字符串進行遍歷,用剛才的方法逐個發送即可!
完整的發送代碼如下:
private void button2_Click(object sender, EventArgs e) { byte[] temp = new byte[1]; try { //首先判斷串口是否開啟 if (serialPort1.IsOpen) { int num = 0; //獲取本次發送字節數 //串口處於開啟狀態,將發送區文本發送 //判斷發送模式 if (radioButton4.Checked) { //以HEX模式發送 //首先需要用正則表達式將用戶輸入字符中的十六進制字符匹配出來 string buf = textBox_send.Text; string pattern = @"\s"; string replacement = ""; Regex rgx = new Regex(pattern); string send_data = rgx.Replace(buf, replacement); //不發送新行 num = (send_data.Length - send_data.Length % 2) / 2; for (int i = 0; i < num; i++) { temp[0] = Convert.ToByte(send_data.Substring(i * 2, 2), 16); serialPort1.Write(temp, 0, 1); //循環發送 } //如果用戶輸入的字符是奇數,則單獨處理 if (send_data.Length % 2 != 0) { temp[0] = Convert.ToByte(send_data.Substring(textBox_send.Text.Length-1,1), 16); serialPort1.Write(temp, 0, 1); num++; } //判斷是否需要發送新行 if (checkBox3.Checked) { //自動發送新行 serialPort1.WriteLine(""); } } else { //以ASCII模式發送 //判斷是否需要發送新行 if (checkBox3.Checked) { //自動發送新行 serialPort1.WriteLine(textBox_send.Text); num = textBox_send.Text.Length + 2; //回車占兩個字節 } else { //不發送新行 serialPort1.Write(textBox_send.Text); num = textBox_send.Text.Length; } } send_count += num; //計數變量累加 label8.Text = "Tx:" + send_count.ToString() + "Bytes"; //刷新界面 } } catch (Exception ex) { serialPort1.Close(); //捕獲到異常,創建一個新的對象,之前的不可以再用 serialPort1 = new System.IO.Ports.SerialPort(); //刷新COM口選項 comboBox1.Items.Clear(); comboBox1.Items.AddRange(System.IO.Ports.SerialPort.GetPortNames()); //響鈴並顯示異常給用戶 System.Media.SystemSounds.Beep.Play(); button1.Text = "打開串口"; button1.BackColor = Color.ForestGreen; MessageBox.Show(ex.Message); comboBox1.Enabled = true; comboBox2.Enabled = true; comboBox3.Enabled = true; comboBox4.Enabled = true; comboBox5.Enabled = true; } }
下面來看看運行效果:
3)定時器組件(Timer)
自動發送功能是我們搭建的最后一個功能了,第2節介紹定時器組件的時候已經說過,這個定時器和單片機中的定時器用法基本一樣,所以,大致思路如下:當勾選自動發送多選框的時候,將右邊數值增減控件的值賦給定時器作為定時值,同時將右邊數值選擇控件失能,然后當定時器時間到后,重新定時器值並調用發送按鈕的回調函數,當為勾選自動發送的時候,停止定時器,同時使能右邊數值選擇控件,代碼如下:
private void checkBox2_CheckedChanged(object sender, EventArgs e) { if (checkBox2.Checked) { //自動發送功能選中,開始自動發送 numericUpDown1.Enabled = false; //失能時間選擇 timer1.Interval = (int)numericUpDown1.Value; //定時器賦初值 timer1.Start(); //啟動定時器 label6.Text = "串口已打開" + " 自動發送中..."; } else { //自動發送功能未選中,停止自動發送 numericUpDown1.Enabled = true; //使能時間選擇 timer1.Stop(); //停止定時器 label6.Text = "串口已打開"; } } private void timer1_Tick(object sender, EventArgs e) { //定時時間到 button2_Click(button2, new EventArgs()); //調用發送按鈕回調函數 }
運行一下看一下效果: