這一篇來做一個簡單的串口上位機程序,配合【STM32F103筆記】中的串口程序使用,後續還可以在這個串口小程序的基礎上添加更多功能,可以根據預先設計的數據格式,將串口小程序接收到的數據進行不同的顯示,並根據接收到的數據向STM32發送控制指令,比如上位機PID控制STM32電機調速或者轉角控制等等,會很有意思。
筆者也是剛開始學C#,就當做和大家一起學習進步啦。
C#開發環境
Visual Studio下載
作爲C#小程序開發的第一篇,首先介紹一下C#的開發環境——Visual Studio(VS),是美國微軟公司的開發工具包系列產品,這裏給出官方下載地址(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用杜邦線短接,這樣相當於發送給自己接收,用於測試串口上位機程序:
調試運行程序,結果如下: