嵌入式代碼經(jīng)常產(chǎn)生bug的五大原因
在嵌入式開(kāi)發(fā)軟件中查找和消除潛在的錯誤是一項艱巨的任務(wù)。通常需要英勇的努力和昂貴的工具才能從觀(guān)察到的崩潰、死機或其他計劃外的運行時(shí)行為追溯到根本原因。
本文引用地址:http://dyxdggzs.com/article/202401/454709.htm在最壞的情況下,根本原因會(huì )破壞代碼或數據,使系統看起來(lái)仍然可以正常工作或至少在一段時(shí)間內仍能正常工作。
工程師常常放棄嘗試發(fā)現不常見(jiàn)異常的原因,這些異常在實(shí)驗室中不易再現,將其視為用戶(hù)錯誤或“小故障”。然而,機器中的這些鬼魂仍然存在。這是難以重現錯誤的最常見(jiàn)根本原因指南。每當您閱讀固件源代碼時(shí),請查找以下五個(gè)主要錯誤。并遵循建議的最佳做法,以防止它們再次發(fā)生在您身上。
1. 競爭條件
競爭條件是指兩個(gè)或多個(gè)執行線(xiàn)程(可以是RTOS任務(wù)或main() 和中斷處理程序)的組合結果根據交織指令的精確順序而變化的任何情況。每個(gè)都在處理器上執行。
例如,假設您有兩個(gè)執行線(xiàn)程,其中一個(gè)規則地遞增一個(gè)全局變量(g_counter + = 1; ),而另一個(gè)偶然將其歸零(g_counter = 0; )。如果不能始終以原子方式(即,在單個(gè)指令周期內)執行增量,則存在競爭條件。
如圖1所示,將任務(wù)視為汽車(chē)接近同一十字路口。計數器變量的兩次更新之間的沖突可能永遠不會(huì )發(fā)生,或者很少會(huì )發(fā)生。但是,這樣做的時(shí)候,計數器實(shí)際上不會(huì )在內存中清零。其值至少在下一個(gè)清零之前是損壞的。這種影響可能會(huì )對系統造成嚴重后果,盡管可能要等到實(shí)際碰撞后很長(cháng)一段時(shí)間才會(huì )出現。
最佳實(shí)踐:可以通過(guò)必須以適當的搶先限制行為對原子地執行代碼的關(guān)鍵部分,來(lái)避免競爭條件。為防止涉及ISR的爭用情況,必須在另一個(gè)代碼的關(guān)鍵部分持續時(shí)間內至少禁止一個(gè)中斷信號。
對于RTOS任務(wù)之間的爭用,最佳實(shí)踐是創(chuàng )建特定于該共享庫的互斥體,每個(gè)互斥體在進(jìn)入關(guān)鍵部分之前必須獲取該互斥體。請注意,依靠特定CPU的功能來(lái)確保原子性不是一個(gè)好主意,因為這只能防止爭用情況發(fā)生,直到更換編譯器或CPU。
共享數據和搶占的隨機時(shí)間是造成競爭狀況的元兇。但是錯誤可能并不總是會(huì )發(fā)生,這使得從觀(guān)察到的癥狀到根本原因的種族狀況跟蹤變得異常困難。因此,保持警惕以保護所有共享對象非常重要。每個(gè)共享對象都是一個(gè)等待發(fā)生的事故。
最佳實(shí)踐:命名所有潛在共享的對象(包括全局變量,堆對象或外圍寄存器和指向該對象的指針),以使風(fēng)險對于所有將來(lái)的代碼閱讀者而言都是顯而易見(jiàn)的;在Netrino嵌入式C編碼標準提倡使用“的G_ 為此,”前綴。查找所有可能共享的對象將是爭用條件代碼審核的第一步。
2. 不可重入功能
從技術(shù)上講,不可重入功能的問(wèn)題是爭用狀況問(wèn)題的特例。而且,由于相關(guān)原因,由不可重入函數引起的運行時(shí)錯誤通常不會(huì )以可重現的方式發(fā)生 —— 使它們同樣難以調試。不幸的是,非重入功能也比其他類(lèi)型的競爭條件更難在代碼審查中發(fā)現。
圖2顯示了一個(gè)典型的場(chǎng)景。在這里,要搶占的軟件實(shí)體也是RTOS任務(wù)。但是,它們不是通過(guò)直接調用共享對象而是通過(guò)函數調用間接操作。
例如,假設任務(wù)A調用套接字層協(xié)議功能,該套接字功能調用TCP層協(xié)議功能,調用IP層協(xié)議功能,該功能調用以太網(wǎng)驅動(dòng)程序。為了使系統可靠地運行,所有這些功能都必須是可重入的。
但是,以太網(wǎng)驅動(dòng)程序的所有功能都以以太網(wǎng)控制器芯片的寄存器形式操作相同的全局對象。如果在這些寄存器操作期間允許搶占,則任務(wù)B可以在將數據包A排隊之后但在發(fā)送開(kāi)始之前搶占任務(wù)A。
然后,任務(wù)B調用套接字層功能,該套接字層功能調用TCP層功能,再調用IP層功能,該功能調用以太網(wǎng)驅動(dòng)程序,該隊列將數據包B排隊并傳輸。
當CPU的控制權返回到任務(wù)A時(shí),它將請求傳輸。根據以太網(wǎng)控制器芯片的設計,這可能會(huì )重傳數據包B或產(chǎn)生錯誤。數據包A丟失,并且不會(huì )發(fā)送到網(wǎng)絡(luò )上。
為了可以同時(shí)從多個(gè)RTOS任務(wù)中調用此以太網(wǎng)驅動(dòng)程序的功能,必須使它們可重入。如果它們每個(gè)僅使用堆棧變量,則無(wú)事可做。
因此,C函數最常見(jiàn)的樣式固有地是可重入的。但是,除非精心設計,否則驅動(dòng)程序和某些其他功能將是不可重入的。
使函數可重入的關(guān)鍵是暫停對外圍設備寄存器,包括靜態(tài)局部變量,持久堆對象和共享內存區域在內的全局變量的所有訪(fǎng)問(wèn)的搶占。這可以通過(guò)禁用一個(gè)或多個(gè)中斷或獲取并釋放互斥鎖來(lái)完成。問(wèn)題的細節決定了最佳解決方案。
最佳實(shí)踐:在每個(gè)庫或驅動(dòng)程序模塊中創(chuàng )建和隱藏一個(gè)互斥量,這些互斥量不是本質(zhì)上可重入的。使獲取此互斥鎖成為操作整個(gè)模塊中使用的任何持久數據或共享寄存器的前提。
例如,相同的互斥鎖可用于防止涉及以太網(wǎng)控制器寄存器和全局或靜態(tài)本地數據包計數器的競爭情況。在訪(fǎng)問(wèn)這些數據之前,模塊中訪(fǎng)問(wèn)此數據的所有功能必須遵循協(xié)議以獲取互斥量。
注意非重入功能可能會(huì )作為第三方中間件,舊版代碼或設備驅動(dòng)程序的一部分進(jìn)入您的代碼庫。
令人不安的是,不可重入函數甚至可能是編譯器隨附的標準C或C++庫的一部分。如果您使用GNU編譯器來(lái)構建基于RTOS的應用程序,請注意您應該使用可重入的“newlib”標準C庫,而不是默認庫。
3. 缺少volatile關(guān)鍵字
如果未使用C的volatile關(guān)鍵字標記某些類(lèi)型的變量,則可能導致僅在將編譯器的優(yōu)化器設置為低級或禁用編譯器才能正常工作的系統中出現許多意外行為。該揮發(fā)性預選賽期間變量聲明,其中它的目的是為了防止優(yōu)化的讀取和變量的寫(xiě)入使用。
例如,如果您編寫(xiě)清單1所示的代碼,則優(yōu)化器可能會(huì )通過(guò)消除第一行來(lái)嘗試使程序更快速、更小,從而損害患者的健康。但是,如果將g_alarm聲明為volatile ,那么將不允許這種優(yōu)化。
最佳實(shí)踐:將揮發(fā)的關(guān)鍵字應該用于聲明每個(gè):由ISR和代碼的任何其他部分訪(fǎng)問(wèn)的全局變量,由兩個(gè)或多個(gè)RTOS任務(wù)訪(fǎng)問(wèn)的全局變量(即使已阻止了這些訪(fǎng)問(wèn)中的競爭條件),指向內存映射外設寄存器(或一組或一組寄存器)的指針,以及延遲循環(huán)計數器。
請注意,除了確保所有讀寫(xiě)操作都針對給定變量之外,使用volatile還通過(guò)添加其他“序列點(diǎn)”來(lái)限制編譯器。除易失性變量的讀取或寫(xiě)入之外的其他易失性訪(fǎng)問(wèn)必須在該訪(fǎng)問(wèn)之前執行。
4. 堆棧溢出
每個(gè)程序員都知道堆棧溢出是很不好的事情。但是,每次堆棧溢出的影響都各不相同。損壞的性質(zhì)和不當行為的時(shí)機完全取決于破壞哪些數據或指令以及如何使用它們。重要的是,從堆棧溢出到它對系統的負面影響之間的時(shí)間長(cháng)短取決于使用阻塞位之前的時(shí)間。
不幸的是,堆棧溢出比臺式計算機更容易遭受嵌入式系統的困擾。這有幾個(gè)原因,其中包括:
· 嵌入式系統通常只能占用較少的RAM;
· 通常沒(méi)有虛擬內存可回退(因為沒(méi)有磁盤(pán));
· 基于RTOS任務(wù)的固件設計利用了多個(gè)堆棧(每個(gè)任務(wù)一個(gè)),每個(gè)堆棧的大小都必須足夠大,以確保不會(huì )出現唯一的最壞情況的堆棧深度;
· 中斷處理程序可能會(huì )嘗試使用這些相同的堆棧。
使該問(wèn)題進(jìn)一步復雜化的是,沒(méi)有大量的測試可以確保特定的堆棧足夠大。您可以在各種加載條件下測試系統,但是只能測試很長(cháng)時(shí)間。僅在“半個(gè)藍月亮”中運行的測試可能不會(huì )見(jiàn)證僅在“一次藍月亮”中發(fā)生的堆棧溢出。
在算法限制(例如無(wú)遞歸)下,可以通過(guò)對代碼的控制流進(jìn)行自上而下的分析來(lái)證明不會(huì )發(fā)生堆棧溢出。但是,每次更改代碼時(shí),都需要重做自上而下的分析。
最佳實(shí)踐:啟動(dòng)時(shí),在整個(gè)堆棧上繪制不太可能的內存模式。(我喜歡使用十六進(jìn)制23 3D 3D 23,它看起來(lái)像ASCII內存轉儲中的籬笆' #==# '。)在運行時(shí),讓管理員任務(wù)定期檢查是否沒(méi)有任何涂料在預先設定的高水位上方標記已更改。
如果發(fā)現某個(gè)堆棧有問(wèn)題,請在非易失性?xún)却嬷杏涗浱囟ǖ腻e誤(例如哪個(gè)堆棧以及洪水的高度),并為產(chǎn)品的用戶(hù)做一些安全的事情(例如,受控關(guān)閉或重置)可能會(huì )發(fā)生真正的溢出。這是添加到看門(mén)狗任務(wù)中的一項不錯的附加安全功能。
5. 堆碎片化
嵌入式開(kāi)發(fā)工程師并沒(méi)有很好地利用動(dòng)態(tài)內存分配。其中之一是堆碎片的問(wèn)題。
通過(guò)C的malloc()標準庫例程或C++的new關(guān)鍵字創(chuàng )建的所有數據結構都駐留在堆中。堆是RAM中具有預定最大大小的特定區域。最初,堆中的每個(gè)分配都會(huì )減少相同字節數的剩余“可用”空間。
例如,特定系統中的堆可能從地址0x20200000開(kāi)始跨越10KB。一對4KB數據結構的分配將留下2KB的可用空間。
可以通過(guò)調用free()或使用delete關(guān)鍵字將不再需要的數據結構的存儲返回到堆中。從理論上講,這使該存儲空間可用于后續分配期間的重用。但是分配和刪除的順序通常至少是偽隨機的,這導致堆變成一堆更小的碎片。
若要查看碎片可能是一個(gè)問(wèn)題,請考慮如果上述4KB數據結構中的第一個(gè)空閑時(shí)會(huì )發(fā)生什么情況?,F在,堆由一個(gè)4KB的空閑塊和另一個(gè)2KB的空閑塊組成。它們不相鄰,無(wú)法合并。所以我們的堆已經(jīng)被分割了。盡管總可用空間為6KB,但超過(guò)4KB的分配將失敗。
碎片類(lèi)似于熵:兩者都隨時(shí)間增加。在長(cháng)時(shí)間運行的系統(換句話(huà)說(shuō),曾經(jīng)創(chuàng )建的大多數嵌入式系統)中,碎片最終可能會(huì )導致某些分配請求失敗。然后呢?您的固件應如何處理堆分配請求失敗的情況?
最佳實(shí)踐:避免完全使用堆是防止此錯誤的肯定方法。但是,如果動(dòng)態(tài)內存分配在您的系統中是必需的或方便的,則可以使用另一種結構化堆的方法來(lái)防止碎片。
關(guān)鍵觀(guān)察是問(wèn)題是由大小可變的請求引起的。如果所有請求的大小都相同,則任何空閑塊都將與其他任何塊一樣好,即使它恰巧不與任何其他空閑塊相鄰。圖3顯示了如何將多個(gè)“堆”(每個(gè)用于特定大小的分配請求)的使用實(shí)現為“內存池”數據結構。
許多實(shí)時(shí)操作系統都具有固定大小的內存池API。如果您可以訪(fǎng)問(wèn)其中之一,請使用它代替malloc()和free()?;蚓帉?xiě)自己的固定大小的內存池API。您只需要三個(gè)函數:一個(gè)用于創(chuàng )建新的池(大小為M塊N字節);另一個(gè)分配一個(gè)塊(來(lái)自指定的池);三分之一代替free()。
代碼審查仍然是最佳實(shí)踐,可以通過(guò)首先確保系統中不存在這些錯誤來(lái)避免許多調試麻煩。最好的方法是讓公司內部或外部的人員進(jìn)行全面的代碼審查。
強制使用我在這里描述的最佳實(shí)踐的標準規則編碼也應該會(huì )有所幫助。如果您懷疑現有代碼中存在這些討厭的錯誤之一,那么執行代碼審查可能比嘗試從觀(guān)察到的故障追溯到根本原因要快。
評論