論Linux進程/線程同步在嵌入式驅動開發中的重要性(基於模擬IIC亂碼場景分析)

在學習資料滿天飛的大環境下,知識變得非常零散,體系化的知識並不多,這就導致很多人每天都努力學習到感動自己,最終卻收效甚微,甚至放棄學習。我的使命就是過濾掉大量的垃圾信息,將知識體系化,以短平快的方式直達問題本質,把大家從大海撈針的痛苦中解脫出來。

1 問題

一個大型嵌入式系統出現的問題往往會牽扯到各個功能模塊,而且很難迅速地抓住引發問題出現的本質原因。

解決問題的第一步就是找到問題,要不停地迭代逼近問題的“本尊”。

先簡單介紹一下問題背景。問題最早暴露在應用層,發現某些業務會偶現地出現邏輯錯誤,跟着該邏輯錯誤找到是EEPROM數據讀寫問題(查找過程略去一萬字……),繼續跟蹤(此處略去幾千字,哈哈)發現是EEPROM在切頁時地邏輯處理有一些問題,解決該問題後,發現EEPROM數據讀寫仍然存在問題,繼續跟蹤(繼續省略哈)發現模擬的IIC讀寫是存在誤碼(誤碼率大概是萬分之三)的。

本文就從模擬IIC誤碼問題正式開始~

Tips:如果有的小夥伴對EEPROM的驅動或者如何“潔癖地”實現模擬IIC代碼,可以關注我喲,後續會娓娓道來~

毛主席曾經說過,沒有調研就沒有發言權。話不多說,直接上誤碼產生時抓到的模擬IIC時序圖,如下圖(圖1)。

圖1:
在這裏插入圖片描述

圖1中,藍色線是SCL,黃色線是SDA。請大家睜大眼睛,找到紅色矩形框框住的區域,眼尖的小盆友已經發現這裏有一個藍色的脈衝,高度連1/2高電平都沒有過。少拍一個時鐘,自然會導致波形和數據產生“移碼突變”,從而產生誤碼。

2 分析

爲什麼會少拍一個時鐘呢?是硬件問題還是軟件問題呢?

首先,從軟件流程上排查問題,9個bit的時鐘都是正常拍的,代碼具有一致性,不存在前幾個bit時鐘可以正常拍出,突然就出現一個時鐘拍不出去的情況啊,所以,肯定不是時序邏輯上出了問題。

再次,從硬件上進行排查,是否將引腳配置配置爲開漏,上拉電阻的阻值是否根據RC充放電時間計算過,驅動電流是否合適。

然後,根據外接的IIC芯片手冊對一下嚴格的時間參數,比如上述沿和下降沿必須小於4us之類的。

然而,排查完上述項並改進後並不能解決問題。

最後的最後,來到了今天的主題——進程/線程間同步。
前幾個步驟分析的思路都是單進程/線程思路,所以還沒有找到誤碼的根本原因。

先給出結論,本次模擬IIC通信存在誤碼的根本原因是——互斥鎖鎖定的資源範圍選取過小。

使用GPIO模擬IIC需要不停拉高拉低引腳電平來模擬時序。代碼中使用的是 “讀-改-寫” 操作GPIO一個Port的數據寄存器來進行的單引腳操作。但是 “讀-改-寫” 並不是原子操作(使用的處理器不支持寄存器讀寫原子操作),問題就出在這裏了。

宏觀上模擬IIC和模擬SPI恰巧使用了同一個Port裏的引腳,然後都各自模擬着自己的波形,然後,還恰好跑在不同的線程上;在微觀上(指令週期級別)就有可能產生圖1中的現象。

在這裏插入圖片描述
爲了方便說明微觀上是如何產生圖1中誤碼波形的,假設GPIO寄存器只有3個bit,第0bit對應IIC時鐘引腳,第1bit對應IIC數據引腳,第2bit對應LED控制引腳。模擬IIC和LED控制引腳時都使用 “讀-改-寫” 操作。兩個線程交替“讀-改-寫”寄存器情況如下圖(圖2)所示。

圖2:在這裏插入圖片描述

線程P1的優先級高於P2的優先級,P2的“讀-改-寫”恰好被P1中斷,導致了一個結果:GPIO寄存器的第0bit先被P1寫成1,然後迅速又被P2改寫成了0,表現到引腳上就是時鐘引腳被拉高了一段時間(線程切換的時間+代碼運行的時間+虛地址轉實地址的時間),然後瞬間被“打回原形”,也就呈現出了圖一中的尖脈衝。

頭腦敏銳的同學肯定會發現圖1中的脈衝根本就沒有到高電平啊,這個怎麼解釋呢?
脈衝沒有拉高到高電平的原因是IIC總線上有耦合電容,充放電都需要時間,很短的時間不足以使充電完成;或者是驅動能力比較弱(總之是電氣特性引起的,咱不是硬件大拿,也只能分析到這兒了,歡迎硬件大佬斧正)所以,引腳不能馬上呈現出高電平狀態。

等等,我還有問題?小盆友,你是不是有很多滴問號~
爲啥,P1優先級比P2優先級高,在P1剛剛寫完寄存器之後,控制權就馬上被P2搶走了呢?
這個問題問的好~~
有兩種解釋,一種是時間片輪轉,一種是P1主動釋放。實際上是第二種情況(因爲使用的Linux系統閹割了時間片輪轉調度),P1在拉高時鐘後使用系統休眠延時主動釋放了CPU使用權,這也解釋了爲什麼誤碼率這麼高(萬分之三)。

Tips:Linux系統在應用時如果有頻繁的週期性中斷(比如1/10/100*us級別的),就需要禁止時間片輪轉調度算法了,防止過度頻繁的調度消耗太多的處理器資源,但此時,線程的劃分和優先級設計就非常重要和嚴格了,需要有詳細的設計說明,並禁止隨意更改,否則可能引起意想不到的錯誤,如正在討論的模擬IIC誤碼。

3 解決

找到問題的本質之後,解決起來就相對容易了。

3.1 方案一

將線程睡眠延時修改爲線程自旋延時。
這樣優先級高的線程拿到CPU控制權之後就可以完全按照“自己的意思”來自由操作引腳時序了。不會受到低優先級線程的“暗算”。
對於低優先級線程來說,本身就存在“後手優勢”,所以,高優先級線程是否釋放CPU使用權對自己影響不大。

方案一的缺點有哪些呢?
(1)當延時較大時會增加CPU利用率,影響系統的實時性和響應時間。
(2)使用較大的自旋延時,容易被不知情的隊友“優化”成睡眠延時。
(3)不是一種通用的解決硬件資源競爭的方法,不通用使用就不方便,容易出現紕漏。

3.2 方案二

調整優先級。
將使用同一硬件資源的線程,調整爲同一優先級,讓他們之間不能互相打斷。

缺點如下:
(1)如果在進行架構設計時硬件資源的管理並沒有指定專門的線程的話,硬件資源的訪問函數會被分散調用於各個線程中。第一,追蹤不方便,系統太大後還會存在部門牆,互相之間的代碼是看不到的;第二,即使追蹤到了,如果線程過多,僅僅就爲了解決硬件資源競爭問題就把所有相關線程調整爲同一優先級肯定是大大的不妥,影響了其他業務邏輯怎麼辦?
(2)對於使用時間片輪轉調度算法的系統,這招自然失效。

3.3 方案三

使用互斥鎖。
使用互斥鎖將整個GPIO Port寄存器鎖起來,進入臨界區之前獲取互斥鎖,退出臨界區後釋放鎖。
優點:使用簡單有效。
缺點:臨界區選取過大會造成系統實時性變差。

3.3.1 選取鎖定資源

通過前面的分析,知道競爭資源其實是一個GPIO的Port。先前程序出錯,產生IIC亂碼並不是沒有用鎖。其實也用了互斥鎖,但是鎖定資源沒有識別清楚,鎖定的是GPIO的一個引腳對應的內存(一個全局變量),而沒有鎖定整個GPIO的Port對應的寄存器。
使用鎖最根本和最重要的就是精準識別競爭資源的範圍,並精準加鎖。

3.3.2 選取臨界區

臨界區的選取是一門藝術。臨界區選取過大會造成系統實時性變差,臨界區選取過小,又會增加線程切換次數,造成CPU資源的浪費。
一般來說,如果臨界區可以選取到接近原子級別,則不再使用互斥鎖,而使用自旋鎖。
但是,在用戶態編程一般臨界區選取的都比較大,所以使用互斥鎖的情況居多。

3.4 方案四

使用自旋鎖。鎖定的資源和互斥鎖相同,都是GPIO一個Port對應的所有寄存器和相關的內存(定義的相關的變量)。不同的是,使用自旋鎖時臨界區的選取更加“精緻”——操作寄存器前進入,操作完寄存器馬上退出。
優點:加鎖粒度更小,減少資源等待週期,減少線程切換時間。
缺點:如果用戶態(模擬IIC的代碼是在用戶態實現的)開放了自旋鎖的接口,應用開發工程師使用起來“度”不好把握(可能會大面積使用,造成臨界區大小不好控制)。當然如果是在內核態編程,還是建議使用自旋鎖的方案。

Tips:自旋鎖的三個特性如下:
1)被自旋鎖保護的臨界區代碼執行時不能進入休眠態。
2)被自旋鎖保護的臨界區代碼執行時不能被中斷。
3)被自旋鎖保護的臨界區代碼執行時不能被內核搶佔。

Tips:Linux實現自旋鎖的方式如下:
1)在單核cpu、不可搶佔內核中,自旋鎖爲空操作。
2) 在單核cpu、可搶佔內核中,自旋鎖實現爲“禁止內核搶佔”,並不實現“自旋”。
3)在多核cpu、可搶佔內核中,自旋鎖實現爲“禁止內核搶佔” + “自旋”。

4 覆盤

覆盤最重要的是重新理順思路,從實踐中抽象出經驗、理論和規範。

1.使用鎖並不難,難的是使用合適的鎖,更難的是精準識別並確定鎖定的競爭資源的邊界。

2.編程過程中使用到共享資源,尤其是硬件資源,一定要“捫心自問”——我仔細考慮過如何避免競態了嗎?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章