本篇文章包含以下內容,很長,但干貨滿滿,就看你能吸收多少了(這將是魚鷹本階段公眾號技術分享的最后一篇收尾文章):
傳入參數指針
互斥鎖釋放順序
數據幀檢查
串口空閑
通信吞吐量
內容很多,魚鷹慢慢寫,道友您也請慢慢看。
為了更好的理解接下來的知識點,魚鷹將設計一個串口框架,讓道友心中有一個參考方向。
本篇重點在于解決如何寫一個健壯、高效的串口接收數據,發送與接收處理過程略講。
幀格式
先聊聊幀格式,一般來說,一個數據幀有以下幾部分內容:
幀頭
幀頭用于分辨一個數據幀的起始,這個幀頭必須足夠特殊才行,因為它是分辨一個幀的起始,那么什么樣的幀頭是足夠特殊的數據呢?保證這個數據在一個幀內最好只出現一次的數據,那就是幀頭,比如0x55、0xAA之類的。而且最好有兩個字節以上,這樣幀頭才更加獨一無二。
但是數據域內的數據你是沒辦法保障不包含和幀頭一樣的數據。
那么如果不湊巧,除了幀頭外其他部分也有這樣的兩個字節的幀頭,那會出現什么問題?
幾乎不會出現問題。因為一般來說數據都是一幀一幀發送的,只要你前面的數據幀傳輸正確,那么即使下一幀的數據中有和幀頭一樣的數據(包括幀頭)也沒有問題,因為幀頭判斷已經在開始就判斷成功了,就不會繼續判斷后面的數據是否是幀頭了。
那么為什么說是幾乎,因為如果上一幀數據接收錯誤,那么程序必須再找一次幀頭才行(單字節接收時是如此,采用空閑中斷的話就不需要這么麻煩),這就導致找幀頭的時候在幀頭數據之外尋找了,很可能這些數據就有幀頭。
但是即使幀頭數據之外的假幀頭真的存在,也沒關系,還有第二重保障,那就是校驗,即使找到了一個錯誤的幀頭,那么數據校驗這一關也很難過去,所以放寬心。
如果校驗也湊巧通過了,那還有第三重保障:幀尾。應該到不了這里吧,畢竟這比中彩票還難。
又要上一幀數據接收錯誤,還要當前幀除了幀頭之外還有幀頭,另外你還能跳過校驗的檢查(還有功能字、長度信息的檢查),太難了。所以只要通過了這些檢查,你就可以認為這個數據幀是可用的了。所以一幀數據接收錯誤,導致的問題最多只是丟失了這幀數據,對后續接收是不會有影響的(前提是你這個接收程序設計的足夠好),發送端在發送超時后再發送一次即可,所以重發機制很重要。
事實上,如果你采用串口空閑中斷,幀頭、幀尾都可以不用,但一般來說,幀頭都會保留,幀尾可以不需要,這是為了當單片機沒有串口空閑中斷時考慮,當然也可能有其他考慮,所以幀頭得保留。
功能字
功能字主要用于說明該數據幀的功能,當然也可以作為函數指針的索引,一個索引值代表了一個具體功能,據此可找到對應的功能函數。
比如,設計一個函數指針數組,通過功能字進行索引,進而跳轉到對應的功能函數中處理。
特別注意的是,設計功能字的時候,要考慮兼容性,對數據幀的功能進行劃分,不要想到一個算一個,功能字也不要隨便安排,不然在以后增加數據幀的時候會很麻煩。
比如說,只有一個字節的功能字,前四位作為一個大類,后四位作為大類中具體類。這樣就可以將系統數據通信幀分為16個大類,每個大類下有16個可用的具體類,當你增加功能字的時候,就可以根據你的設計來確定屬于哪個大類了,然后再插入進去。這樣在管理、維護這些通信數據時你會發現很方便。
這個思想其實在ARM內核的中斷系統和設計 uCOS II 任務優先級的時候都有,而魚鷹在設計項目的通信協議的時候就是運用了這些思想。
(圖片來源于《權威指南》)
長度
長度信息也是一個非常關鍵的數據,別小看了它,因為它,魚鷹用了將近一個星期的時間才把一個HardFaul問題解決了,雖然這個程序bug不是我寫的(魚鷹一直用的是串口空閑接收方式,這個bug自然而然就跳過了),但確實很容易出錯。
因為它是決定了你這個數據域長度的關鍵信息(一般長度信息代表數據域的長度,而不包含其它部分長度),也是這個數據幀的長度信息(加上固定字節長度就是幀長度了),更是接收程序還要接收多少數據的關鍵信息(對于空閑中斷接收方式不算關鍵,這里的不關鍵是指不會造成程序異常問題)。
比如說你的程序剛好將幀頭、幀尾、功能字判斷完畢,然后中斷程序因為種種原因導致沒有及時接收串口數據,那么你可能得到的就是錯誤的數據,然后這個錯誤的長度數據就可能導致你的棧幀或者全局變量被破壞(單字節接收情況下就可能出現,因為魚鷹碰到過),這是很嚴重的事情。所以在接收數據域的數據之前一定一定要判斷這個長度信息(空閑中斷除外)是否合法,不合法的話及時扔掉這幀數據,開始下一幀的數據檢查。
所以為了保證及時接收數據,最好采用DMA傳輸。
數據域
這個沒啥好說的,就是整個幀你真正需要發送的數據。而為了讓你的發送函數能接收各種類型的數據,那么把參數類型設置為 void * 會是不錯的選擇。
校驗
一個數據在接收過程中可能會被干擾,導致接收到錯誤的數據,那么如何保證這幀數據的完整與準確性呢,就在校驗這一關了。
校驗有很多方式,和校驗、CRC校驗等(奇偶校驗是針對一個字節的,不是數據幀)。
和校驗算法簡單,CPU運算量小,累加最后只取最低字節即可(注意不是高字節,想想為什么),或者保存累加和的變量就是一個字節空間,這樣就不需要額外操作了。
CRC校驗,這個算法復雜,理解起來比較困難,但一般來說可以直接拿來用,因為它是對每一位(bit)進行校驗,所以糾錯率很高,幾乎不存在發現不了的數據錯誤,但正因為對每一位進行檢查,所以CPU運算量較大,但是有的單片機是可以硬件計算CRC校驗值的(比如stm32)。不過現在CPU運算速度都挺快的,軟件運算也是可以接受的。
那么該怎么校驗呢?是從幀頭開始到數據域部分,還是說直接校驗數據部分?其實都可以,區別就是運算量問題,不過問題不大(最好是從頭開始校驗,以保證整幀數據的準確性)。
幀尾
前面說了,幀尾在空閑中斷中可以不用,RXNE中斷接收時其實也可以不用,當然也可以加上,好處就是當你用串口助手查看數據流時,可以觀察出一幀數據是否發送完整了。
最后再說說為什么在數據域前面設計四個字節大小,除了協議本身需要外,還有一個原因就是強制類型轉化需要,我們知道,一般來說,賦值時都有字節對齊的限制(實際上有的CPU可以不對齊進行賦值),stm32是32位的,那么四字節對齊是最合適的,這樣就可以直接將我們收到的數據轉化為需要的數據類型了。
傳輸過程
聊完了幀格式,再從大的方向看串口的傳輸過程:
當發送端發送第一幀數據包時,接收端通過某種方式接收(串口接收非空RXNE中斷、串口空閑IDLE中斷),為了讓串口能夠觸發空閑中斷,必須在發送端兩個發送幀之間插入一段空閑時間(就是在此時間內不發數據,紅色部分),保證空閑中斷的準確觸發。
同理,為了讓發送端也能正常接收接收端的數據,也需要控制接收端的發送,不能在返回一幀數據時立馬發送下一幀數據,不然觸發不了發送端的空閑中斷。
事實上,有些程序員設計的發送、接收過程比這個簡單一些。即只有當接收端接收到一幀數據并返回一幀數據之后,發送端才能繼續發送數據,這樣一來,我們只需要控制好接收端的頻率,就可以控制整個通信過程,也能控制通信頻率。
但為什么還要設計成第一種傳輸情況呢?這是為了充分利用串口,增大數據吞吐率(這個后面再說)。
另外,不知道你是否觀察到圖中的每個數據幀占用的時間是不一樣的,這是因為每個數據幀不可能都是一樣長的,它們是不定長的數據包,所以你的定時不能從發送開始定時,而是從發送完成后開始定時控制空閑時間。
軟件設計
上面所有的內容都是設定一些條件、需求,那么該如何實現軟件設計呢?畢竟說的再多,如果不能實現這些,又有什么用呢?talk is cheap, show me the code。
下圖設計了三個數據幀:GetVision(),GetSN(),GetMsg()。
GetVision()用于獲取硬件版本號、軟件版本號。
GetSN()用于獲取產品序列號,用于識別唯一設備。
GetMsg()用于獲取消息,可以獲取各種傳感器數據,事實上,如果數據量多的話,根據傳感器的不同,會根據需要設計各種不同的數據幀(功能字不同)。
在軟件設計上一般都會對這些函數設計一個統一的函數類型,用函數指針數組統一管理。
既要統一,又要體現差異性,函數參數就顯得很有必要了。
這里設計了兩個參數,一個是void* (無類型指針),一個是length(長度)。
無類型指針主要是用于傳遞數據域的數據地址,而數據域的數據可能是整型、浮點型、結構體、枚舉、聯合體等,為了保證傳入的各種數據類型在不通過強制轉化情況下都能兼容,設計為 void * 就顯得很有必要了。
實際上為了顯得更專業性,加上 const 修飾會是不錯的選擇,因為這可以保證緩存數據不被修改(事實上只能保證不被程序員修改,而不能保證程序本身,這個后面會解釋)。
長度,長度參數是一個很關鍵的參數,為了保證長度的準確性,建議使用sizeof 獲取。
有人覺得sizeof 好像一個函數,會不會導致效率低下啊,畢竟每次通信都要計算一次長度,那你就大特大錯了。事實上,只要你的類型定義定義好了(不管是內置的類型定義還是自定義的結構體、枚舉、聯合體),編譯器都能確定 sizeof 最終的數據長度,根本不存在計算過程。
用 sizeof 的兩個好處:
1、可以忽略字節對齊問題(不同平臺不能忽略,比如window和單片機通信)。因為編譯器為了數據讀寫效率更高,一般會對數據進行地址對齊,這樣一來手工計算一個數據類型的長度變得麻煩(當然你可以說使用某些手段讓數據不進行對齊,這個另說),而 sizeof 將智能且準確計算數據大小。
2、當你使用 sizeof 時,兼容性更強,也顯得更專業。程序修修改改很正常,一個數據結構改來改去也很正常,特別是開發初期更是如此。但是不管你怎么改,只要在編譯器看來是固定長度的數據類型,那么 sizeof 就能在鏈接程序前計算出來;并且即使你后來加了數據不對齊的限制(加了這個限制后,很可能數據大小變小),也能準確計算。別問為什么,就是這么任性。
所以為了減小出錯的可能性、減少勞動量,sizeof 是不錯的選擇。
當接收到數據地址和長度信息后,就可以進行發送了。
因為只有數據域的數據,為了組成一幀完整的數據,就必須加入打包過程。加上數據幀頭、功能字、數據長度、校驗等數據。
當一幀數據打包好之后,就可以進行發送了,發送可以采用循環查詢發送,也可使用發送空TXE中斷,當然還是建議使用DMA發送,這樣你可以還沒等它發送完就可處理其它事情了。
以上就是發送過程,接收過程也是同理,根據功能字來調用相應的函數進行回復。
事實上,如果只是數據的傳輸過程,完全可以使用一個發送函數實現數據的特異性傳輸,這樣就可以減少一層數據傳遞,但是有些通信幀不只是數據的傳輸,可能在接收、發送時作一些其他處理(比如清除、設置某些標志位),所以需要再增加一層,用于進行差異性處理。
以上就是本篇內容的基礎內容了,你以為快完了?你錯了,現在只是剛開始而已,魚鷹寫本篇筆記的最終目的還在后面。
這只是前菜,正文才剛開始。
串口接收遇到的那些問題
以下內容不會用太多的筆墨描述如何寫發送、接收函數,而是重點關注串口接收過程中可能遇到的一些問題,如果說描述到了發送、接收函數,別會錯意,順帶的。
以下大部分問題都是因為采用RXNE(接收不為空)中斷方式導致的問題,只有一個問題是魚鷹從前沒有考慮到,也是IDLE + DMA方式不可忽略的問題。
這就是為什么魚鷹建議采用IDLE + DMA 的原因,不僅是因為效率問題,更因為它能避免很多問題,當然水平足夠高的話,采用RXNE也是完全(“完全”就未必,里面有一個問題是RXNE方式難以避免的問題)沒有問題。
事實上,即使魚鷹采用RXNE方式接收數據,也能避免以下大部分的問題,因為魚鷹的基礎足夠扎實,會在一開始編寫代碼的時候自然而然避免一些問題的出現。
但是看完以下內容后,相信各位道友寫出一個高效且健壯的串口接收程序根本不是問題,因為這就是所謂的經驗啊。
傳入參數指針
前面魚鷹已經提到了需要一個指針作為函數的參數,這里說說這個指針問題。
我們知道,為了維護方便,也是為了節省空間,一般都會將類似的功能整合成一個函數,比如串口經常要用的發送、接收功能,但是所發送的數據內存空間可能就處于五湖四海了,他們通過指針來指向將要發送的數據。
為了節省 RAM 空間或者其它不為人知的原因,傳遞給發送函數的指針就是實際發送數據的地址,并且在計算校驗值的時候也是直接使用這個地址進行校驗計算,然后采用循環查詢的方式發送數據,這樣一來,就不必拷貝一個數據的副本進行發送,而是直接從數據源的地址進行發送,節省了部分 RAM 空間。
但是這樣真的好嗎?
你是否考慮過在計算數據幀校驗值的時候,數據源改變了的問題呢?
比如說你采用和校驗,數據一開始是0x55,計算數據幀的校驗和值為tx_sum,然后被中斷程序或者DMA修改了這個數據源,變成了 0xAA,此時你再使用這個數據地址進行發送,接收端接收到了0xAA,接收端計算校驗和的時候是 rx_sum,那么rx_sum 必然不等于 tx_sum,然后接收端就認為該數據幀是錯誤的,然后丟失這幀數據,而這種情況是比較少見的,但確實是會偶爾出現接收錯誤的情況(當時發現這個問題時始終不得其解,明明我發送的是這個校驗值,為什么你計算的校驗值是另一個?開始懷疑是校驗函數的問題,但其他數據幀計算時沒有問題,只有一種數據幀會出現問題,然后魚鷹懷疑是數據源的問題,是的,魚鷹很快就懷疑數據源的問題,但當時驗證時,只改了校驗那部分地址,發送時的地址還是使用源地址,導致問題還是沒解決,過了好久之后才發現這個發送地址沒改,囧。所以說,即使你的思路是對的,但如果你解決時錯了,問題也很嚴重)。
如果說接收端(從機)具有重發機制,那么問題不是很大,丟失一幀數據而已,再重發就是,但事實是,一般串口設計成主從模式,主機會在沒有接收到從機的應答數據時會進行重發,但是從機一般不會主動重發數據的,它無法判斷主機是否成功接收,而從機一般會在成功發送完數據后開始清除一些標志位(比如鍵盤按鍵數據清空,不然主機下次獲取按鍵信息時還是同一個按鍵數據),事實上這個動作必須在對方成功接收才能進行(否則這次按鍵信息就丟失了),從這個角度來看,我們必須設計一個機制用于判斷主機是否成功接收。
I2C、CAN總線都有應答信號,但這是這些是總線自帶的特性,我們不可能在接收到一個字節后發送一個應答信號給主機,那么是否有其他辦法呢。
人們很容易想到的一個辦法就是在主機收到正確數據后,主動發送一幀專用數據幀用于清除這個標志(這個幀和普通幀一樣,所以可以確保主機數據能準確送達從機,因為如果超時沒有送達,會觸發重發機制)。這樣只要在獲取完這幀數據后,再額外的發送一幀數據用于對方確認即可,從機接收到后,即可開始著手清除一些標志位。
但這樣會有一個問題,因為這種特殊的需要從機確認的數據包(其他類型數據不需要確認是因為如果主機沒有正確收到數據還可以繼續獲取,獲取的數據是一樣并沒有關系,但這種需要從機確認,一旦從機認為發送成功了,數據就被清除了這種情況就需要確認,典型的就是按鍵信息了),我們需要額外處理并占用發送帶寬。這是魚鷹不愿忍受的。
那么是否有更好的辦法?
或許我們可以從 USB 協議中獲得啟發(這是在寫這篇筆記的時候想到的,當時寫按鍵板代碼的時候沒有想到過,但因為當時測試時傳輸成功率100%,所以就放棄處理這種情況了)。
USB協議是典型的主從機制,主機不主動獲取數據,從機是無法主動發送數據的。那么從機是如何確定對方成功接收數據了呢?
一個bit的翻轉位。
每當主機成功發送一幀數據后再發送下一幀數據時,就會翻轉這個位,從機就可以根據這個位判斷主機是屬于重發數據(重發數據表示主機接收失敗了)還是新數據了,這樣從機就能從下一幀數據確定上一幀數據是否成功發送了。
而主機發送的數據是由從機發送應答包確定的,和上面的串口協議類似,所以這個方向的數據是沒有問題的。
那么我們該如何重新設計這個協議呢?可以盡可能的不改變原來協議的情況下實現嗎?
或許可以從功能字出發。
為了保證對功能字的原有定義保持基本不變,使用最高 bit 作為這個特殊的位,這個 bit 開始是 0,之后主機每接收一個從機應答數據就進行翻轉,如果因為沒有接收到從機的應答數據,就會使用相同的翻轉位重復發送;而從機也根據這個bit來確定自己的上一幀數據對方是否接收(對比上一幀數據的翻轉位),如果主機沒有成功接收,就不清除標志位(之后主機會重發數據再次獲取),否則清除標志位,。
因為是魚鷹剛想到的,就不多說了,僅提供一個思路。
現在回到指針那塊的問題。
現在已經知道,如果你在計算校驗和與發送的過程中出現源數據改變的情況,就會導致數據幀校驗失敗,那么有什么解決辦法?
如果說你堅持使用查詢方式發送來節省部分空間,那么只要在計算校驗值之前拷貝一份源數據,然后用這份數據計算并發送即可。
另一種方法就是,直接把整幀數據拷貝到一個數據緩存中,使用DMA發送。
現在還有一個問題,那就是如果我想發送一個數據域為空的數據該怎么發送?
一般來說,在使用指針的時候,不會使用 NULL 空指針,但是在數據為空的情況下,就需要使用 NULL 指針了,并長度設置為 0,這個時候在檢查指針的時候,不能看到空指針就退出函數,還要判斷長度信息,當長度為0時在打包時就不拷貝源數據,但最終還是要發送數據幀的(當時別人寫的代碼將指針和長度判斷同時放到了 for 循環的條件里面,魚鷹覺得效率太低,導致修改代碼是沒考慮指針為空的情況,所以導致了一個小bug)。
互斥鎖釋放順序
現在考慮第二個問題:互斥鎖釋放順序問題。
如果沒有采用隊列方式接收數據,而是主機發送完成后等待接收從機數據后再發送下一幀數據,那么該如何處理互斥鎖的問題?
我們知道為了保證數據的同步,保證在接收到一幀數據進行處理時,不能被新的數據幀沖掉,這時就要加入一個互斥鎖,表示我正在處理數據,下面的數據我接收不了,這樣就能保證你正在處理的數據不會被新來的數據修改掉,從而進行正確的處理。
那么這個標志位(互斥鎖)該什么時候清掉(釋放掉)呢?
一般來說標志位,一般越早清掉越好,比如外部中斷標志位,進入中斷后,一般首先會清理標志位,這樣即使你正在處理本次中斷的程序,那么即使這時再來了中斷,也不會丟失中斷信息(有懸起標志位),這樣就可以在處理完這次中斷后,立馬進行下一次中斷的處理了(前提是優先級足夠高)。
但是如果你清理太早或者清理太晚會怎樣?
比如說你在接收到一幀數據后(數據幀所有檢查完成),開始設置標志位,當主程序查詢到這個標志時(一般數據處理不會放在中斷中),如果馬上開始清除這個標志……嗯,一般來說不會有問題。
那么什么時候會出現問題?當你的主程序查詢到這這個標志時開始清除標志,然后處理、返回數據給主機,如果此時主機超時重發數據時,,因為這個時候你雖然在處理數據,但是因為你的標志位已經被清除了,所以接收程序就會開始往接收緩存區存數據了,當你存完之后再回到數據處理那里,你的緩存區可能就不是你想要的數據了。
可能你會說,既然是重發,那么數據應該是相同的吧?好吧,你贏了,魚鷹編不下去了,這種情況(有重發機制)下清理太早好像是不會出現問題,但你怎么知道對方是采用副本進行重發數據的呢,如果重發時它又從源數據中拷貝后再進行重發會出現什么問題?比如時間信息,開始第一幀數據是11:59,CPU剛把11拷貝到用戶空間,被串口中斷程序打斷,導致下一幀接收的數據是12:00,此時回到主程序繼續拷貝,拷貝00,數據的完整性被破壞,這樣導致的結果就是11:00,而實際上時間是12:00,這就是你打斷數據處理過程的后果。
現在再說說清理太晚會怎么樣。
當你的主程序查詢到這個標志后,暫時不清除,而是等到從機發送完應答數據之后再清除標志,此時因為從機采用查詢方式(查詢方式表明從機發送完最后一個字節后后開始清除標志位,也就代表了主機就差最后一個字節沒有接收了,這樣發送和清除之間間隔時間較短,而采用 DMA 方式的話,發送和清除的間隔時間更短,因為可能DMA還沒開始發送第一幀數據,清除工作就已經完成了),或者因為其他原因(比如中斷處理)導致發送和清除之間的時間很長這種特殊情況,這樣可能主機已經開始下發下一幀數據了,但是因為此時標志還沒有清除,不能接收數據,所以主機這一幀數據就這樣丟失了。
那么這個清除標志位最合適的時機是什么時候?
你鎖定資源利用完的時候。
現在來看看,這個互斥鎖鎖定的是什么資源?對,就是接收緩存,那么接收緩存什么時候用完?當然是在數據處理完成之時。也就是說在數據處理完之前、發送數據之前清除最合適。
這樣就不會因為處理其他事情而導致清除操作過晚而丟失下一幀數據了,因為此時主機還沒收到從機上傳的數據,也就不會馬上開始下一幀數據的傳輸了。
說到清除,你覺得,需要把整個緩沖區進行清零操作嗎?這個問題在以往的文章解釋過,就看你是否看完了。
數據幀檢查
你是否會對接收的數據進行檢查?如果不進行檢查會發生什么?
我們知道一幀數據中,每個部分都有各自的含義,甚至有些部分可能在某些數據幀中不存在,比如數據域部分,我們需要根據長度信息來判斷數據域部分是否存在。
但是你能保證你所接收的數據都是準確的嗎?你能確保在工作環境下不會因為各種干擾導致數據長度信息由0x05變成0x85(最高位翻轉)嗎?,如果出現了會導致什么后果?
假設你采用RXNE中斷方式來一個、一個字節的接收數據,分析如下:
因為是單字節接收數據,所以你需要把所有接收的數據當成數據流,根據幀頭信息來確定幀的開始,一旦確定幀頭信息之后,你就可以根據接下來的一系列數據保證一幀數據的結束,同時開始新幀的接收……
初看這個接收流程沒有問題,但是真的如此嗎?
但是就像前面所說,你能保證你的數據沒有問題嗎?如果說你接收到一個長度信息,本來是0x05,但是最終接收的數據是0x85,這就意味著你接下來的數據域的長度是0x85,根據你的接收流程,你需要再接收0x85個字節之后,才能判斷校驗字節是否正確。
可能你會說,雖然你的長度信息由0x05變成了0x85,之后接收校驗過程肯定是失敗的,那么這幀數據就會被接收程序丟棄,從而導致接收程序進入重新尋找幀頭的流程,這個過程不是挺正常的嗎?按理說上述異常情況是能被接收流程處理掉的。
那么首先確認一點,上述異常能被接收流程處理嗎?
答案是能!
既然上述異常是能被接收狀態機處理的,那么還會有什么問題?
問題就出在這個錯誤數據本身!
因為你是根據錯誤數據來決定接下來需要接收多少數據,而一般來說,接收緩存大小設置為最大幀的長度,那么就出現一個問題,你的緩存夠你接收0x85個字節嗎?
如果說你開辟的接收緩存空間很大,足夠接收這么多數據,那么就算遇上以上情況,也是沒有任何問題,但是萬一你比較節省資源,緩存不夠大會出現什么情況?
這就涉及到內存分配問題:
你的串口緩存一般在 Data區域,一旦你接收的數據超出了你開辟的空間,那么必然導致緩存空間溢出!
那么緩存空間溢出會導致什么危害?
我們通過上圖可以知道,一旦緩存溢出,必然導致該緩存周圍的數據出現異常(數據被篡改),如果你的其它代碼剛好需要這個數據作為重要參考,而你在使用的時候又沒有對這個數據的有效性進行檢查,那么可能導致另一個災難性后果,而這個后果又導致了其他后果,從而導致雪崩效應。
而你修復這個bug時,你以為修復了,但你只修復了表面,真正內在bug還存在!
所以,千萬別太相信內存中的數據,每一個數據的輸入都要進行嚴格檢查,這個數據可以錯誤,但是不能導致程序崩潰!
所以千萬別寫能篡改別人數據的代碼,這是很危險的事情,也是很難解決的bug,因為你不知道它會在什么時候篡改哪里的數據!
再假如你的接收緩存放在棧中了呢(稍微有C語言常識的程序員都不會把串口接收緩存放在棧中,但魚鷹偏偏遇到過這種代碼,而為了解決這個bug整整花了一星期,這還是在bug復現率高的情況下)?
根據前面的圖可知,棧一般存放在高地址,并且一般棧生長方向為向低地址生長。如果出現上述情況(接收的數據大于開辟的棧緩存空間)會發生什么?
棧幀被破壞!
灰色部分因為接收的數據太多,導致原本存在的棧數據被串口的接收的數據修改了(注意篡改的數據可能不是連續的,因為每一次進入時,開辟的那部分棧空間可能都不在同一個地址),假如這個數據是保存返回寄存器LR的,那么必然導致返回錯誤,極可能觸發HardFault中斷!
那么有什么辦法解決棧被破壞的問題?
最有效的方式魚鷹覺得是使用ITM,如果無法在線調試,可以嘗試DMA循環傳輸PC指針值(但是如何得到這個值?畢竟這個寄存器本身是沒有地址概念的)到一塊內存中,這樣就可以得到最后正常執行的代碼地址,從而定位錯誤代碼的位置。
如果單片機不支持這些功能呢?魚鷹現有的知識體系好像無法解決,只能佛系調bug了(看和bug之間的緣分),囧。
前面說了由于外部工作環境導致數據長度信息錯誤從而出現數組溢出這種情況,如果說你保證工作環境非常好,不可能出現這種干擾,是否還會出現問題?
當然會!
前面分析了外在原因,現在分析內在原因,你的接收程序能保證及時接收發送端發送過來的數據嗎?如果不及時接收數據會出現什么問題?
我們知道,一個系統一般都有很多中斷需要處理,如果說你的接收程序的中斷優先級不是最高的,那么很可能出現接收程序無法及時接收的情況,即RXNE中斷來臨時,因更高優先級中斷需要處理,而且處理時間較長,那么就會出現當前接收的字節因為沒有接收完成而被后續的數據沖掉,即出現ORE(溢出錯誤)。
這樣會導致什么問題?
數據域信息(也可能是校驗值等數據)當成了長度信息(為什么只討論長度,而不討論功能字之類的數據,難道他們不會出現ORE的情況嗎?),這樣一來,如果這個數據很大,接收程序就會以為接下來還需要接收很多數據才能完成一幀的接收,導致后果和前面分析的數據干擾一樣嚴重。
那么采用RXNE接收方式時該怎么解決這種問題?
檢查長度信息的合理性,只要長度信息不會導致緩存溢出即可。
但是上面的解決方案會導致什么問題?
因為你的程序設計問題(采用RXNE接收導致不能及時處理),使得原本能接收的數據無法及時處理(DMA可以及時處理),最終使得當前這一幀數據無法正常接收(如果錯誤的長度信息夠大的話,還有可能接下來很多幀數據都無法接收),這你能接受嗎?
但是采用DMA為什么就不會有上述問題,除了DMA能自動接收數據提高效率之外,還有一點就是它不根據接收的數據來判斷接下來還需要接收多少數據,而是根據設定的接收數據長度來接收的(如果加入IDLE中斷,可以提前結束接收工作),這就避免了上述的緩存溢出和接收不及時問題。
最后再分析上述接收的另一個問題,那就是一幀數據中可能出現沒有數據域的情況,這種情況該怎么處理?
只要根據長度信息分開處理即可。如果不對沒有數據域的情況分開處理,那么你接收的下一個數據直接就是校驗值,而你的接收流程卻認為這是數據域的數據,必然導致校驗失敗。
現在總結使用RXNE方式接收的幾個問題:
1、緩存溢出。
緩存溢出有兩種可能,第一種就是環境干擾導致長度信息出錯,從而出現緩存溢出情況;第二種情況就是因為接收不及時,導致數據錯位,如果剛好是長度信息錯誤,并且這個長度信息太大,而你的代碼未對長度進行檢查,那么也會出現緩存溢出bug,而這種bug一旦出現,很難發現。所以在代碼中對數據的合理性檢查是非常有必要的一件事。
2、中斷及時處理。
如果中斷不及時處理,會導致數據錯位,輕則丟失至少一幀數據,重則緩存溢出!
3、狀態機是否需要接收數據部分。
由于數據幀有可能沒有數據域的情況,所以必須區別處理,保證代碼接收的準確性,否則有可能把校驗值當成數據了,這樣必然無法通過校驗,這一幀數據必然會丟失!
串口空閑
前面一直提到串口空閑,也大概明白串口的作用,但是一些細節問題還是需要好好說一下的。
第一個問題,如何清除串口空閑中斷標志位?
很多人會使用USART_ClearFlag標準庫函數進行清除,但是當你跳轉到該函數原型時,你會看到如下說明:
你會看到很多標志位是無法通過該函數清除的。
那么該如何清除IDLE標志呢?其實上面的注釋已經進行了說明。
PE、FE、NE、ORE、IDLE標志位的清除是通過一個軟件序列進行清除的:首先通過USART_GetFlagStatus讀取USART_SR寄存器的值,然后通過USART_ReceiveData函數讀取USART_DR的值即可。
那么這里就有一個問題,是否這些標志問題的清除都要單獨編寫清除序列呢?
答案是否定的。
因為這些標志位都是由同一種序列進行清除的,所以只要一個清除序列就會把所有的標志位都進行清除了(同樣一旦執行了這個序列,也就意味著你無法再通過USART_SR寄存器獲得標志位了)。
為了保證獲取標志位,我們可以在清除序列之前把USART_SR寄存器的值保存到副本中,然后再讀取USART_DR寄存器的值保存到副本來實現清除功能,注意該序列應該無條件執行(不在某個判斷語句中)。這樣后續我們就可以使用這個 USART_SR 的副本判斷哪一個標志置位了,同樣也可以使用 USART_DR 的副本獲取串口數據,而為了實現以上效果,USART_GetFlagStatus這個函數就不合適了,只能直接操作寄存器去實現。
第二個問題,在線調試時對空閑中斷會有影響嗎?
我們知道,KEIL能夠將一個結構體的數據全部讀取出來,而庫函數將串口模塊的所有寄存器都封裝在一個結構體中,這樣就會出現一個問題,如果你的窗口是實時刷新的,當你使用KEIL讀取串口模塊寄存器的時候(不管是使用peripheral窗口還是Watch窗口),就會出現先讀取SR再讀取DR的情況, 這樣就有可能出現KEIL和單片機CPU讀取這兩個寄存器沖突的情況。
如果全速運行時,KEIL 先執行了這個序列(通過調試器讀取這兩個寄存器的值),單片機CPU再讀取SR寄存值,必然是無法讀取到正確標志位的,因為這些標志位已經被KEIL的讀取序列清除了(這個情況魚鷹確實碰到過,當時明明下發了數據,但是單片機無法獲取標志位),所以在調試串口時,注意不要讓 KEIL 去讀取這些寄存器(即關閉這些窗口,只有在必須的情況下才開啟),防止出現莫名其妙的情況。
第三個問題,空閑中斷能準確觸發嗎?
如果從接收端考慮的話,如果觸發了空閑中斷,那么必然滿足了條件才觸發的,而不是意外觸發的(嗯,我們要相信STM32),但從發送端考慮的話,有可能出現一幀數據斷續發送,導致一幀數據觸發多次空閑中斷,所以如果是簡單的DMA+空閑中斷方式接收是很有問題的(空閑出現就認為一幀結束了,就會把一幀數據當成兩幀處理,這樣肯定無法通過數據檢查的)。
那么先來分析為什么會出現一幀數據多次觸發空閑中斷情況。我們知道linux、windows系統并不是實時系統,當應用程序需要發送一幀數據時,可能并沒有連續發送,而是發送完一個字節后去處理其他事情后才發送下一個字節,這樣一來,如果耽誤的時間夠長,就會觸發串口的空閑中斷,從而一幀數據當成兩幀處理了。
那有什么方法可以解決呢?魚鷹提供兩種解決思路。
第一種,使用兩個緩存空間,一個緩存空間專門用于接收串口數據,將接收到的數據存放到另一個緩存,這個緩存采用字節隊列的方式進行管理,應用程序從緩存隊列中一個字節一個字節的取出數據進行處理(注意檢查數據有效性),這樣就能保證及時處理。但是因為空閑中斷不再可靠,所以空閑中斷不再作為判斷一幀數據結束的依據(根據長度信息判斷),而是只在空閑中斷中將已接收數據復制到字節隊列緩存中,這樣就可以處理意外的空閑中斷。
第二種,還是一個緩存空間,還是DMA+空閑方式處理,但是需要增加額外的條件。就是當進入空閑中斷后,不再直接處理,而是獲取當前接收時刻,然后在處理數據的時候根據這個時刻來判斷是否達到足夠的空閑時間,只有在進入空閑中斷后并達到一定延時之后才認為一幀數據結束了,這樣可以避免一些非常短的空閑時間(魚鷹公眾號提供過的代碼使用這種方式)。
以上問題是就是魚鷹以前使用空閑中斷從未考慮的問題,魚鷹并不知道使用空閑中斷還可能出現誤觸發的情況,但是既然知道了,就要想辦法解決。但是為什么以前使用空閑中斷時沒有出現通信問題呢?
事實上不是沒有問題,而是有可能把分散的一幀數據的兩部分直接丟棄了而已,因為有重發機制,所以即使丟棄一幀數據,也能通信正常,而且這種一幀數據分散成兩部分的概率還是挺低的,ubuntu(linux系統)下大概千分之三左右的樣子。
第四個問題,如果單片機沒有空閑中斷又該如何做?
當我們使用 RXNE 的同時其實我們也可以使用空閑中斷,這樣也能確定一幀數據的結束(但是要注意前面的誤觸發問題)。但是如果有些低端單片機(如 51 )沒有空閑中斷又該怎么辦?
其實我們可以從 stm32 的空閑中斷得到相應的啟發。
所謂空閑中斷,就是當串口接收到數據后,在應該接收數據的時刻,發送方并沒有發送數據,所以串口模塊置位空閑標志位,從而引起空閑中斷。
那么我們是否可以軟件模擬串口模塊的這個功能,從而確定一幀數據的結束呢?
答案是肯定的(前提是每一幀數據之間有空閑時間)。
我們可以使用一個定時器,定時器向上計數。當接收到一個字節數據后,初始化計數器并啟動定時器,這樣一旦有一段時間沒有接收到串口數據(也就不再初始化計數器),那么定時器溢出,進入溢出中斷,而這個溢出中斷就類似于串口的空閑中斷(在溢出中斷中關閉定時器以達到清除空閑中斷標志的作用),這樣就達到了串口空閑中斷的效果(和前面問題的第二種解決方案類似)。
通信吞吐量
在以上分析過程中,都是采用主機發送,從機接收后再回復主機的方式進行通信,雖然通信正常,但實際上效率比較低下,單位時間傳輸的數據量較少,如下圖所示:
紅色部分就是必要的空閑時間,可以看到左右兩張圖的通信頻率是有差異的,右圖中從機必須等待前一幀數據發送完畢才能處理數據,而左圖可以在接收當前幀時處理上一幀數據,類似CPU的指令執行流水線。
責任編輯:pj
-
設計功能
+關注
關注
0文章
2瀏覽量
6145 -
幀格式
+關注
關注
0文章
7瀏覽量
5488
發布評論請先 登錄
相關推薦
評論