(本文由轉載所得)
第一篇文章我相信很多人不看都能做的出來,但是,用過微軟SerialPort類的人,都遇到過這個尷尬,關閉串口的時候會讓軟件死鎖。天哪,我可不是武 斷,算了。不要太絕對了。99.9%的人吧,都遇到過這個問題。我想只有一半的人真的解決了。另外一半的人就睜隻眼閉隻眼阿彌佗佛希望不要在客戶那裏出現 這問題了。
你看到我的文章,就放心吧,這問題有救了。我們先回顧一下上一篇中的代碼
- void comm_DataReceived(object sender, SerialDataReceivedEventArgs e)
- {
- //先記錄下來,避免某種原因,人爲的原因,操作幾次之間時間長,緩存不一致
- int n = comm.BytesToRead;
- //聲明一個臨時數組存儲當前來的串口數據
- byte[] buf = new byte[n];
- //增加接收計數
- received_count += n;
- //讀取緩衝數據
- comm.Read(buf, 0, n);
- //清除字符串構造器的內容
- builder.Clear();
- //因爲要訪問ui資源,所以需要使用invoke方式同步ui。
- this.Invoke((EventHandler)(delegate{...界面更新,略}));
- }
- private void buttonOpenClose_Click(object sender, EventArgs e)
- {
- //根據當前串口對象,來判斷操作
- if (comm.IsOpen)
- {
- //打開時點擊,則關閉串口
- comm.Close();//這裏就是可能導致軟件死掉的地方
- }
- else
- {...}
- }
爲什麼會死鎖呢,併發衝突。
我們要了解一下SerialPort的實現和串口通訊機制,在你打開串口的時候,SerialPort會創建一個監聽線程ListenThread,在這 個線程中,等待註冊的串口中斷,當收到中斷後,會調用DataReceived事件。調用完成後,繼續進入循環等待,直到串口被關閉退出線程。
我們的UI主線程如何做的呢,首先創建一個窗體,然後執行了Application.Run(窗體實例)。是這樣把,這裏的Application.Run就是創建了一個消息循環,循環的處理相關的消息。
這裏我們就有了2個線程,UI主線程、串口監聽線程。那麼你在DataReceived處理數據的時候,就需要線程同步,避免併發衝突,什麼是併發衝突? 併發衝突就是2個或多個並行(至少看上去像)的線程運行的時候,多個線程共同的操作某一線程的資源,在時序上同時或沒有按我們的預計順序操作,這樣就可能 導致數據混亂無序或是彼此等待完成死鎖軟件。
而串口程序大多是後者。爲什麼呢,看看我們的例子中DataReceived做了什麼?首先讀取數據,然後就是調用this.Invoke方法更新UI 了。這裏Invoke的時候,監聽線程將等待UI線程的標誌,等到後,開始操作UI的資源,當操作完成之前,監聽線程也就停在DataReceived方 法的調用這裏,如果這個時候。併發了關閉串口的操作會如何呢?SerialPort的Close方法,會首先嚐試等待和監聽線程一樣的一個互斥體、臨界 區、或是事件(不確定.net用的哪種)。那這個同步對象什麼時候釋放呢?每次循環結束就釋放,哦。循環爲什麼不結束呢?因爲這一次的循環操作執行到 DataReceived之後,執行了Invoke去更新界面了,那Invoke怎麼又沒有執行完成呢?看上去很簡單的幾行代碼。雖然我沒仔細研讀 過.net的Invoke原理,但我猜測是通過消息的方式來同步的,這也是爲什麼這麼多的類,只有控件(窗體也是控件的一種,.net在概念上,顛覆了微 軟自己的概念,傳統的win32編程,是說所有的控件都是個window,只是父窗體不同,表現形式不同,但都是基於系統消息隊列的,.net出於更高的 抽象,正好反過來了。呵呵)纔有Invoke方法了。(委託自己的Invoke和這個不同)
我猜測控件/窗體的Invoke是SendMessage方式實現的,那麼發送消息後就會等待消息循環來處理消息了。如果你直接去關閉串口了。你點擊按鈕 本身也會被轉換成消息WM_CLICK,消息循環在處理按鈕的WM_CLICK時候,調用你按鈕的OnClick方法,進而觸發調用你的 ButtonClose_Click事件,這都是同步調用的,你的主線程,處理消息的過程,停在了這個Click事件,而你的Click事件又去調用了 SerialPort的Close方法,Close方法又因爲和串口監聽線程的同步信號量關聯在一起需要等待一次的while結束,而這個while循環 中調用了DataReceived方法,這個方法中調用了Invoke,也就是發送了消息到消息隊列等待結果,但消息循環正在處理你的關閉按鈕事件等待退 出。
實在太複雜了,這個情況下,你想要真的關閉串口成功,就需要while中的DataReceived方法調用結束釋放同步信號,就需要執行完 Invoke,就需要執行消息循環,幸運的是,我們真的有辦法執行消息循環來打破僵局。Application.DoEvents()。還好,不幸中的萬 幸。可是問題又來了,你能讓Invoke結束,但你無法確定是否在你調用消息循環後,你的某一時刻不會再次併發,可能由於單cpu的串行操作模擬並行中, 又把時間片先分給了優先級高的串口監聽線程呢?是有可能的。所以,我們就需要一點方法來避免再次invoke窗體。優化後不會司機的例子如下,我們修改 DataReceived方法,關閉方法,並定義2個標記Listening和Closing。
- using System;
- using System.Collections.Generic;
- using System.ComponentModel;
- using System.Data;
- using System.Drawing;
- using System.Linq;
- using System.Text;
- using System.Windows.Forms;
- using System.IO.Ports;
- using System.Text.RegularExpressions;
- namespace SerialportSample
- {
- public partial class SerialportSampleForm : Form
- {
- private SerialPort comm = new SerialPort();
- private StringBuilder builder = new StringBuilder();//避免在事件處理方法中反覆的創建,定義到外面。
- private long received_count = 0;//接收計數
- private long send_count = 0;//發送計數
- private bool Listening = false;//是否沒有執行完invoke相關操作
- private bool Closing = false;//是否正在關閉串口,執行Application.DoEvents,並阻止再次invoke
- public SerialportSampleForm()
- {
- InitializeComponent();
- }
- //窗體初始化
- private void Form1_Load(object sender, EventArgs e)
- {
- //初始化下拉串口名稱列表框
- string[] ports = SerialPort.GetPortNames();
- Array.Sort(ports);
- comboPortName.Items.AddRange(ports);
- comboPortName.SelectedIndex = comboPortName.Items.Count > 0 ? 0 : -1;
- comboBaudrate.SelectedIndex = comboBaudrate.Items.IndexOf("9600");
- //初始化SerialPort對象
- comm.NewLine = "\r\n";
- comm.RtsEnable = true;//根據實際情況吧。
- //添加事件註冊
- comm.DataReceived += comm_DataReceived;
- }
- void comm_DataReceived(object sender, SerialDataReceivedEventArgs e)
- {
- if (Closing) return;//如果正在關閉,忽略操作,直接返回,儘快的完成串口監聽線程的一次循環
- try
- {
- Listening = true;//設置標記,說明我已經開始處理數據,一會兒要使用系統UI的。
- int n = comm.BytesToRead;//先記錄下來,避免某種原因,人爲的原因,操作幾次之間時間長,緩存不一致
- byte[] buf = new byte[n];//聲明一個臨時數組存儲當前來的串口數據
- received_count += n;//增加接收計數
- comm.Read(buf, 0, n);//讀取緩衝數據
- builder.Clear();//清除字符串構造器的內容
- //因爲要訪問ui資源,所以需要使用invoke方式同步ui。
- this.Invoke((EventHandler)(delegate
- {
- //判斷是否是顯示爲16禁止
- if (checkBoxHexView.Checked)
- {
- //依次的拼接出16進制字符串
- foreach (byte b in buf)
- {
- builder.Append(b.ToString("X2") + " ");
- }
- }
- else
- {
- //直接按ASCII規則轉換成字符串
- builder.Append(Encoding.ASCII.GetString(buf));
- }
- //追加的形式添加到文本框末端,並滾動到最後。
- this.txGet.AppendText(builder.ToString());
- //修改接收計數
- labelGetCount.Text = "Get:" + received_count.ToString();
- }));
- }
- finally
- {
- Listening = false;//我用完了,ui可以關閉串口了。
- }
- }
- private void buttonOpenClose_Click(object sender, EventArgs e)
- {
- //根據當前串口對象,來判斷操作
- if (comm.IsOpen)
- {
- Closing = true;
- while (Listening) Application.DoEvents();
- //打開時點擊,則關閉串口
- comm.Close();
- Closing = false;
- }
- else
- {
- //關閉時點擊,則設置好端口,波特率後打開
- comm.PortName = comboPortName.Text;
- comm.BaudRate = int.Parse(comboBaudrate.Text);
- try
- {
- comm.Open();
- }
- catch(Exception ex)
- {
- //捕獲到異常信息,創建一個新的comm對象,之前的不能用了。
- comm = new SerialPort();
- //現實異常信息給客戶。
- MessageBox.Show(ex.Message);
- }
- }
- //設置按鈕的狀態
- buttonOpenClose.Text = comm.IsOpen ? "Close" : "Open";
- buttonSend.Enabled = comm.IsOpen;
- }
- //動態的修改獲取文本框是否支持自動換行。
- private void checkBoxNewlineGet_CheckedChanged(object sender, EventArgs e)
- {
- txGet.WordWrap = checkBoxNewlineGet.Checked;
- }
- private void buttonSend_Click(object sender, EventArgs e)
- {
- //定義一個變量,記錄發送了幾個字節
- int n = 0;
- //16進制發送
- if (checkBoxHexSend.Checked)
- {
- //我們不管規則了。如果寫錯了一些,我們允許的,只用正則得到有效的十六進制數
- MatchCollection mc = Regex.Matches(txSend.Text, @"(?i)[\da-f]{2}");
- List<byte> buf = new List<byte>();//填充到這個臨時列表中
- //依次添加到列表中
- foreach (Match m in mc)
- {
- buf.Add(byte.Parse(m.Value));
- }
- //轉換列表爲數組後發送
- comm.Write(buf.ToArray(), 0, buf.Count);
- //記錄發送的字節數
- n = buf.Count;
- }
- else//ascii編碼直接發送
- {
- //包含換行符
- if (checkBoxNewlineSend.Checked)
- {
- comm.WriteLine(txSend.Text);
- n = txSend.Text.Length + 2;
- }
- else//不包含換行符
- {
- comm.Write(txSend.Text);
- n = txSend.Text.Length;
- }
- }
- send_count += n;//累加發送字節數
- labelSendCount.Text = "Send:" + send_count.ToString();//更新界面
- }
- private void buttonReset_Click(object sender, EventArgs e)
- {
- //復位接受和發送的字節數計數器並更新界面。
- send_count = received_count = 0;
- labelGetCount.Text = "Get:0";
- labelSendCount.Text = "Send:0";
- }
- }
- }
至此,不會再出現關閉死鎖問題了。
希望這篇文章能解你的燃眉之急,非常高興能與讀者分享我層遇到,大多數人都遇到的這個問題。如果說的不明白,歡迎討論。
後續的有關通訊程序底層設計的文章會講述一個具有豐富擴展性,但有設計簡介的萬能通訊庫,支持網絡、藍牙、串口通訊、並口通訊。但不要指望我都實現出來了,我只是設計出這個框架。