Cache一致性導致的踩內存問題【轉】

轉自:http://blog.coderhuo.tech/2019/07/28/DMA_mem_crash/

本文主要分享一個Cache一致性踩內存問題的定位過程,涉及到的知識點包括:backtrace、內存分析、efence、wrap系統函數、硬件watchpoint、DMA、Cache一致性等。

1 背景

設備上跑的是嵌入式實時操作系統(RTOS,具體爲商業閉源的ThreadX),非Linux平臺,導致一些常見的問題排查方法無法使用。

問題描述: 重啓壓力測試時,發現設備啓動過程中偶爾會死機,概率較低。稍微修改程序後,問題可能就不再出現了,所以版本回退、代碼屏蔽等方法不太適用。

2 基於backtrace分析

由於平臺侷限性,不支持gdb等常用調試方法,爲了便於定位死機問題,本平臺引入了backtrace機制,在死機的時候,會自動回溯出函數調用棧。

2.1 原理

本平臺的backtrace並不是基於libc的(平臺不支持),而是採用很原始的方法,當程序異常時,捕獲PC寄存器和SP寄存器的值,依次回溯棧幀,從棧幀中搜索歷史PC指針,進而還原出函數調用棧。

具體可以參閱:arm平臺根據棧幀進行backtrace的方法

2.2 分析

死機時的堆棧如下:

Oops: CPU Exception!
pc : [<0xa000a8ac>]lr : [<0xa000a884>]
sp : 0xa37a60d8  ip : 0xa37a60d8  fp : 0x0000000b
r10: 0xa37a62c4  r9 : 0xa0fe9ab0  r8 : 0x00000000
r7 : 0x00000000  r6 : 0xa37a61c0  r5 : 0x00000000
r4 : 0x0000006c  r3 : 0x00000075  r2 : 0xa0fe9630
r1 : 0x600001d3  r0 : 0x60000113

==level:0 pc:a000a8ac sp:a37a60d8==
find push in[0xa000a834], register_num=8, stack_frame_size=32
this frame size is 32
==level:1 pc:a046e900 sp:a37a60f4==
find push in[0xa046e8f0], register_num=2, stack_frame_size=8
this frame size is 8
==level:2 pc:a046b184 sp:a37a60fc==
find sub in[0xa046b0a8], stack_frame_size=228
find push in[0xa046b0a4], register_num=9, stack_frame_size=264
this frame size is 264
==level:3 pc:a040d3f4 sp:a37a6204==
find sub in[0xa040d28c], stack_frame_size=108
find push in[0xa040d288], register_num=7, stack_frame_size=136
this frame size is 136
==level:4 pc:a03e676c sp:a37a628c==
find sub in[0xa03e65dc], stack_frame_size=340
find push in[0xa03e65d4], register_num=9, stack_frame_size=376
this frame size is 376
==level:5 pc:a040b3ac sp:a37a6404==
backtrace end

PC指針0xa000a8ac對應的反彙編代碼如下,可以看出,是死在了_txe_semaphore_create函數中(從上面的打印信息可以看出r5寄存器的值是0x00000000, 從下面的反彙編代碼可以看出死機時在嘗試訪問該值偏移20字節的內存地址)。通過上面的各級PC指針進行回溯,發現回溯出來的函數都是有效的(棧被破壞的情況下,回溯出來的調用棧可能是無效的,後面會提到)。

_txe_semaphore_create函數反彙編代碼

雖然ThreadX不是開源的,但我們有幸在github上找到了一份開源代碼,而且這份代碼和我們的反彙編基本上能對應起來。_txe_semaphore_create的源碼(經過裁剪,僅爲示例,實際代碼以參考文檔1爲準)如下:

_txe_semaphore_create函數源碼

而結構體TX_SEMAPHORE定義如下:

TX_SEMAPHORE定義

tx_semaphore_created_next在結構體TX_SEMAPHORE中正好位於起點偏移20字節的地方,結合反彙編,可以推斷異常發生在函數_txe_semaphore_create中下面的語句:

next_semaphore =  next_semaphore -> tx_semaphore_created_next; 

其中右邊的next_semaphore(即寄存器r5)爲NULL。

從這裏可以看出,有信號量被破壞了。顯然,這裏只是問題的表象,根因並不在這裏。
出問題的時候,系統中共有一百多個信號量,另外,程序運行過程中會動態的創建/銷燬信號量,目前無法確定是哪裏的信號量出了問題。

接下來,我們需要確認這個信號量是誰創建的?

3 確定受害者身份

3.1 ThreadX的信號量管理機制

從源碼可以看出,ThreadX的信號量是以雙向鏈表的形式維護的,如下圖所示(SCB是Semaphore Control Block的簡稱,其實就是上面的結構體TX_SEMAPHORE)。_tx_semaphore_created_ptr指向表頭,另外有個全局變量_tx_semaphore_created_count說明當前總共有多少個信號量。

信號量鏈表

正常的信號量在內存中如下圖所示,紅框中爲一個完整的信號量。信號量結構體中第二個字段是信號量名稱,可惜我們使用的接口是被二次封裝過的,無法設置信號量的名字,否則可以根據名字知道哪個信號量出問題了。

正常信號量內存示意圖

3.2 分析

我們可以在死機的時候,遍歷信號量鏈表,檢查現存的信號量,看看哪個出問題了。同時可以把每個信號量及其周邊的內存dump出來,或許可以從這裏面找到一些信息。

根據上面的思路復現後,發現某個信號量(紅框內,首地址位於內存地址0xa394554c)變成了下面這個樣子,面目全非了,最前面的magic等都被破壞了。從這裏識別不出來這個信號量是哪裏創建的。

異常信號量內存示意圖

不過,我們的程序託管(非hook,只是基於系統接口重新封裝了一套接口)了內存申請/釋放的接口,死機的時候會把當前已申請但還未釋放的內存打印出來。打印信息如下,其中包含了函數名、行號、線程號、申請的內存大小、地址等信息:

[func1: 870:0xa1861368] malloc:0 bytes. offset:0 ptr:0xa394554c
[func2:2252:0xa1861368] malloc:4 bytes. offset:c ptr:0xa39455a0

可以看到,首地址爲0xa394554c的內存塊是由func1動態申請的。但是func1是個通用接口函數,好在只有四個地方調用了該接口,排查範圍一下子縮小了很多。

注:上面的size爲0,是因爲該值是從內存中直接解析出來的,從這裏也可以看出該內存區域被破壞了,導致解析出來的內存塊大小異常。後面介紹內存標記後就可以理解這裏的值爲啥會爲0。

排查代碼未發現異常,那就繼續添加調試信息。在每個調用func1的地方把創建的信號量地址打印出來,復現後和被破壞的信號量地址比較。這樣修改後,可能是因爲影響了代碼的執行順序,變得難復現了。好在還是復現出來了,最終確定了被踩信號量的身份。

後來經過多次復現,發現被踩的總是這個信號量,但是被踩後該區域的內容都是無規律的,也就是說從內存痕跡看不出是誰踩了這裏。

4 誰踩了這個信號量

我們目前分析到的信息如下:

  • 被踩區域是動態申請的,內存地址是不固定的,但總是那個可憐的信號量所在的地方。
  • 代碼走查發現,該信號量創建後只在特定情況下才會使用,從啓動到設備出問題應該是沒人用過的。

前面提到我們託管了內存申請/釋放的接口,所以可以知道受害信號量前後的內存是哪裏分配的。排查相關代碼,均無內存越界的可能性。也就是說,臨近的內存不會越界傷害到這個信號量。難道是飛來橫禍,某個野指針恰好落在了這個區域?

這時候,首先想到的就是內存保護。如果能像linux那樣調用mprotect函數,把這塊內存設爲只讀屬性,誰往這裏寫東西就會觸發異常,通過調用棧可以抓到兇手。

可惜我們的平臺沒有mprotect這類函數。後來從驅動組同事那裏瞭解到,可以直接通過該平臺提供的MMU操作接口設置內存的只讀屬性。寫了個demo,確實可以正常工作。

但是我們忽略了一個問題:上面提到信號量是以雙向鏈表的形式維護的,信號量的動態創建/銷燬都會操作鏈表,也就是會對信號量所在內存區域進行寫操作,所以我們沒法對信號量本身進行保護。

那麼,我們是否可以借鑑Linux下Electric Fence的原理進行內存越界檢測呢?

4.1 利用Electric Fence原理進行定位

Electric Fence(簡稱efence)是Linux平臺定位堆內存非法訪問問題的利器,它的優勢在於事前報警而非事後,直指第一現場。efence就是基於MMU的內存訪問屬性來實現的,可以檢測上邊界溢出、下邊界溢出、訪問已釋放內存(野指針)等問題,具體可以參考https://linux.die.net/man/3/efence

我們打算借鑑其上下邊界檢測的原理。如下圖所示,黃色的Data Buffer部分是用戶申請的內存,灰色斜線部分是由於MMU必須按頁申請而額外申請的內存,Guard page部分是被設置爲不可訪問屬性的內存頁,起保護作用。

下圖左側是向下溢出的檢測原理:返回給用戶的起始地址是按內存頁大小對齊的,然後在用戶內存的下邊界處放置一個不可訪問的內存頁,這樣當程序訪問黃色區域下面的內存時,系統會立馬產生異常,就可以抓到誰是兇手。

下圖右側是向上溢出的檢測原理:和向下檢測不同,這次是把Guard page放在用戶內存的上邊界處,用戶內存的上邊界地址必須是按內存頁對齊的,下邊界就不要求了。

注意:如果代碼非法訪問灰色區域,efence是檢測不到的。

efence檢測原理

根據上面的原理在本平臺上實現了簡單的efence代碼,遺憾的是,無論是上邊界檢測還是下邊界檢測,問題都不再出現。這可能和下面兩個因素有關:

  • 內存佈局被改變導致問題不再復現,因爲正常情況下一個信號量才28個字節,但是爲了使用MMU的內存保護功能,必須保證信號量的起始地址是4KB對齊的,並且被保護內存區域大小也是4KB的倍數。
  • 修改代碼導致程序執行順序發生變化,該出現的問題不再出現了。

至此,一頭霧水,我們還是不知道案發現場在哪裏。

4.2 加大內存檢測頻率

前面提到我們託管了內存申請/釋放的接口,其實我們不光記錄誰申請了多少內存,還在用戶內存的前後加了相關標記。通過該標記,可以知道這塊內存的前後邊界有沒有被破壞(踩內存的兩種情況:上溢出和下溢出)。另外有個後臺線程,定時檢測已分配出去的內存有沒有被破壞。

內存示意圖如下所示(問題排查期間對部分字段做了冗餘),圖中的數字代表該字段的長度,單位是bit。最前面有個unused區域,這是因爲,如果返回給用戶的地址按一定字節對齊,前半部分就可能會浪費一小塊內存。owner字段填充的是申請本塊內存的線程號,通過該字段可以知道這塊內存屬於誰。

內存佈局示意圖

注:該機制還可以用來統計內存的使用情況,檢測有無內存泄露。

後臺檢測線程每秒執行一次檢測任務,檢測到內存被破壞後打印相關信息。該機制並未檢測到這個錯誤,可能是由於下面兩個原因:

  • 檢測週期較長,死機的時候還沒檢測到,設備就掛了
  • 檢測到了,但是打印還沒來得及輸出(輸出是異步的,有緩衝),設備就掛了

抱着試試看的態度,把檢測週期改爲50ms,並且檢測到內存錯誤後,立即拋出異常,防止其他程序破壞現場。

修改程序後復現,跑出來的結果也是五花八門,而且有些日誌還誤導了我們,以爲找到了兇手,但是排查相關代碼,發現那塊代碼並沒問題。

希望再次變成失望。迷茫中,只能對一次次的死機日誌進行分析,期望能找到蛛絲馬跡。

其中一次死機日誌引起了我們的注意,如下圖所示,紅色方框中是受害信號量,已經面目全非了。奇怪的是,這塊內存區域已經被其他線程佔用(整個黃色背景區域,已經被線程0xa3921494佔用),從內存標記看,這塊內存是合法申請的。

內存重疊

上圖對應的內存申請記錄如下:

[func1:870:0xa1864a28] malloc:2688197448 bytes. offset:501d513e ptr:0xa394552c
[func2:698:0xa3921494] malloc:116 bytes. offset:c ptr:0xa39454e8

可以看出:

0xa39454e8(func2所申請內存首地址) + 116(十進制) + 12字節的尾部標記 = 0xa3945568 >  0xa394552c(func1所申請內存首地址)

也就是說,兩塊內存重疊了(func1所申請內存的大小由於被破壞已經沒意義了)。嚴格來說,func2申請的內存塊,完全包含了func1的內存塊。而func1是先申請的,並且從記錄看並沒有釋放,爲啥func2又申請到了這塊內存?

難道誰釋放了這個信號量所佔用的內存?

5 誰釋放了這塊內存

我們設備上的內存接口示意圖如下,共有兩套接口,其中業務模塊的接口做了內存申請釋放的統計,可以確認受害信號量所在內存塊沒有被釋放過,但是不排除已通過ThreadX自帶的接口被誤釋放。

內存接口示意圖

5.1 hook ThreadX自帶的內存接口

爲了確認這塊內存有沒有被釋放,我們打算hook ThreadX的內存管理函數。

鏈接工具ld提供了–wrap選項,可以在程序鏈接期間進行符號替換(可參考https://ftp.gnu.org/old-gnu/Manuals/ld-2.9.1/html_node/ld_3.html)。

使用方法:在鏈接選項(通常爲LDFALGS)中增加–wrap symbol,其中symbol爲待替換的函數名、全局變量名等。

如果使用了這個選項,程序鏈接期間引用符號symbol的地方都將被替換爲__wrap_symbol(也就是說本來調用函數symbol的地方,將調用函數__wrap_symbol),引用符號__real_symbol的地方都將被替換爲symbol(也就是說本來調用函數__real_symbol的地方,將調用函數symbol)。

下面的例子hook了malloc函數,並插入了部分代碼。原來程序中調用malloc的地方,都將調用__wrap_malloc,而__wrap_malloc中又調用了__real_malloc,鏈接期間__real_malloc會被替換爲真正的、系統提供的malloc。

void *__wrap_malloc (int size)
{
	void *ptr;
	/* do something you like before call malloc */
	ptr = __real_malloc (size);
	/* do something you like after call malloc */
	return ptr;
}

按照這個思路,我們hook了ThreadX的內存釋放接口_txe_byte_release,發現並沒有人釋放這塊內存。

這就奇怪了,難道ThreadX的內存管理出了問題?

5.2 ThreadX的內存管理機制

ThreadX的內存池分爲Byte Pool和Block Pool兩種,前者可分配任意大小的內存塊,後者只能分配固定大小的內存塊。我們出問題的內存屬於Byte Pool,所以這裏只講述Byte Pool相關機制。

Byte Pool是用單向鏈表管理內存塊的,下圖是其初始狀態。需要注意的是,該鏈表並不是維護在專有的內存區域,而是直接在本Byte Pool中,如果發生踩內存的情況,Byte Pool的鏈表有可能被破壞。

Byte Pool初始狀態

Byte Pool的內存分配方式是first-fit manner(最先匹配原則,與之相對的是best-fit manner),即找到第一個大於用戶申請大小的內存塊,並根據一定的規則對該內存塊進行切割(如果該內存塊大小和用戶申請內存相差不大,可能就不切割了,直接給用戶使用),一分爲二,前者給用戶使用,後者作爲空閒塊,留着下次使用。內存申請過程中也可能對多個連續的空閒內存塊進行合併操作。

Byte Pool首次分配後的狀態如下圖所示,注意,如果本內存塊已被分配,owner ptr區域填寫的是本Byte Pool的地址,如果本內存塊未被分配,填充的是0xFFFFEEEE。

Byte Pool首次分配後狀態

5.3 基於內存管理機制進行分析

根據ThreadX的內存管理機制,再次對4.2節提到的重疊內存區域進行分析。可以看到,下圖中的[0xa39454d4, 0xa394556c)(兩個next ptr之間的內存塊)爲一個合法的內存塊,其owner ptr是正確的,next ptr也確實指向了下一個合法的內存塊。而我們可憐的信號量就位於該內存塊中,這塊內存本屬於這個信號量,在無人釋放的情況下,又分配給了其他人。

同一塊內存重複分配

出現該現象,可能是兩種原因導致的:

  • ThreadX的內存管理模塊出了問題
  • 內存踩到了特定區域,把ThreadX已分配的內存塊標記爲Free狀態了

既然暫時找不到誰破壞了這塊內存,那就先確認下這塊內存被破壞的時間,進一步靠近案發現場。
我們加強了內存檢測機制,在每次申請/釋放內存的時候都對受害信號量進行檢查,如果發現異常,立即dump附近內存,並終止程序運行。示例代碼如下:

void *__wrap_malloc (int c)
{
	void *ptr;
	/* 檢測受害信號量內存是否被破壞 */
	ptr = __real_malloc (c);
	/* 檢測受害信號量內存是否被破壞 */
	return ptr;
}

跑出來的結果讓人瞠目結舌,從下圖可以看出,紅色方框裏面的信號量完好無損,但是,這塊區域已經被標記爲free狀態了。接下來如果誰申請內存,這塊區域可能就給別人了。

被釋放的信號量

不過本次實驗中有個奇怪的現象,檢測到信號量異常的位置,總是在malloc或者free的前面。如果是ThreadX的內存管理模塊出了問題,檢測到信號量異常的位置,應該在malloc或者free的後面。

基於此,可以初步排除ThreadX內存管理模塊的嫌疑。但是,如果是踩內存的話,偏偏只踩了中間的0xffffeeee這四個字節,而且前面的內容沒踩,後面的內容也沒踩,更詭異的是,被踩區域寫入的恰好是ThreadX的內存free標記

難道ThreadX的內存管理模塊不是線程安全的? 從github上的源碼和實際工程的反彙編看,應該是線程安全的。爲了排除該嫌疑,我們特意在hook後的malloc/free中加了把鎖,結果問題還是可以出現。該嫌疑被排除。

如果把ThreadX的內存free標記改爲其他的呢?踩內存的現象還會出現嗎?出現的話,被寫入的還是0xffffeeee嗎?

基於該想法,我們把github上Byte Pool的代碼移植到設備上(實際工程中Byte Pool的代碼我們拿不到,無法調試,和供應商確認過,github上的代碼和他們提供給我們的差別不太大),並且做了以下兩點改動:

  • 將內存free的標記改爲0xaaaabbbb。
  • 在每個標記內存塊爲free狀態的地方加了判斷,如果被free的內存塊是那個信號量的,直接拋出異常。

很幸運,該代碼完全可以運行,而且問題還能復現。根據復現現象,我們得到以下信息:

  • 被踩區域仍然是那個信號量
  • 進一步確認了上面的推斷:不是內存管理模塊將那個信號量釋放的
  • 不可思議的是,被踩區域被寫入的不再是0xffffeeee, 而是0xaaaabbbb

現在可以確認是踩內存問題了。 但是誰踩的呢? 這踩的也太有技術了,在相對固定的位置,寫下具有特殊含義的數值,該數值還和ThreadX內存free的標記保持同步。

我真是太佩服寫這個bug的人了,大寫的NB!!!

6. 誰踩了這塊內存

轉了一圈,又回到了原點。現在梳理下目前的排查情況:

  • ThreadX內存管理模塊的嫌疑已排除。
  • 內存踩的很有技巧,相對位置固定的地址,前面的內容不破壞後面的內容不破壞,偏偏只破壞了中間的四個字節,而且這四個字節和內存管理模塊free狀態的magic code保持一致。

這時,基本無思路了,問題就在那裏,但就是抓不到兇手。不甘心,又瞎折騰了幾種定位方法,雖然知道基本上無效,但是希望能影響執行時序,跑出不一樣的日誌,找到新線索:

  • hook了memcpy、memmove、strcpy等內存操作函數,在內部檢查有沒有破壞那個信號量,結果沒啥新發現。
  • 從反彙編中看哪些地方會寫0xffffeeee到內存區域,其實從上面的實驗就可以知道該方法無效了,因爲即使改爲0xaaaabbbb問題仍然出現。
  • 開啓棧保護功能,原理和操作方法可以參考《如何在實時操作系統(RTOS)中使用GCC的棧溢出保護(SSP)功能》。同樣,從上面的分析結果看,該問題不像是棧溢出導致的。實際證明加上該機制仍然沒啥新發現。

6.1 硬件watchpoint

現在最有效的定位手段就是,對那四個字節做寫保護,但是前面提到的MMU做不到,因爲MMU的最小保護單元是一個內存頁,一般爲4KB。

關鍵時刻,驅動組同事有了新想法,Linux下可以通過gdb的watchpoint監控特定內存區域,我們的系統是否也可以引入類似的機制?

通過gdb相關代碼可以看到,它是利用了arm的協處理器cp14來實現的,該機制是芯片自帶的,和操作系統、調試工具無關,我們的平臺也可以支持。具體原理和操作方法可以參考《如何利用硬件watchpoint定位踩內存問題》

Demo實測證明該工具超級好用,完全可以滿足我們的需求。感覺終於要到開獎的時刻了,只等問題復現。
然而,現實再次給了我們一巴掌。問題復現出來了,但是該機制沒檢測到。真讓人抓狂!!!

6.2 浮出水面的DMA

有個特殊內存塊(256KB),在整個問題定位過程中,一直被我們懷疑來懷疑去,但總是找不到具體的證據。該內存和受害信號量所在內存緊挨着,並且位於受害信號量前面。幾波人反覆走讀過相關代碼找不到可疑點。但是,每次問題出現的時候,它總是和受害者相鄰。

[某線程的內存  ]Index:385 Type:1. size:262172 caller:0 mem_addr:a37a6fb8 Tick:0 Diff:262180
[受害信號量內存]Index:386 Type:1. size:76 caller:0 mem_addr:a37e6fdc Tick:0 Diff:0

驅動同事問“這個內存是幹嘛的”,答“讀寫TF卡文件用的”。這時驅動同事恍然大悟,“怪不得watchpoint抓不住,搞不好就是它了,因爲Cache操作不會觸發watchpoint”。

Cache操作不會觸發內存監控

讀寫文件是經過DMA拷貝的,而我們的系統上是有Cache的,這個過程涉及Cache和主存的同步。

6.3 殺手現身

首先我們不再從內存池中動態申請這256KB內存,而是以全局數組的形式在編譯期就分配好,復現了一段時間,問題果然沒出現。當然不能憑此給它定罪,因爲我們的問題本身出現概率就不高,有可能是改代碼導致執行時序等發生變化,問題不再出現。

接下來我們進行正面驗證。我們以全局數組的形式在編譯期申請了512KB內存,前256KB給嫌疑模塊正常使用(後面稱爲A),後256KB寫入固定的內容(後面稱爲B),然後週期性檢測後半部分會不會被修改。實驗表明,問題沒再出現,B也沒被篡改,仍然沒法給它定罪。

仔細想想,上面的驗證邏輯有問題。假設是DMA導致的踩內存,那應該是在Cache和主存同步過程中出現的,也就是說二者的一致性出問題了。但上面的例子中,B中的內容永遠是固定的,也就是說Cache和主存中是一致的。我們需要構造Cache和主存不一致的情況。

下面的代碼看起來很不可思議(全局數組mem_for_sd的後256KB,即上面提到的B,只在函數change_and_check_mem中使用),先把B賦值,然後過一會再檢查有沒有被修改。函數change_and_check_mem在後臺線程中週期性執行。

DMA驗證試驗一

更不可思議的是,問題很快就復現了。如下圖所示,0xa182f710是B的起始地址,可以看到,有16個字節被破壞了。整個過程描述如下:

  1. 上次change_and_check_mem執行完,整個B被填充爲0x34
  2. 本次執行change_and_check_mem時,先將B的填充改成0x49
  3. 休眠10ms
  4. 對B檢測,發現B的前16個字節被改爲0x34,而0x34是B的歷史值,紅色方框裏也應該被填充爲0x49

DMA驗證試驗一結果

該實驗證明了:真兇在此!!!

由於整個B中被填充的都是同一個值,下面兩種情況無法區分:

  1. B前16個字節的值被緩存,而後又被賦值到原來的位置
  2. B的某個字節的值被緩存,而後又將該值填充到B的前16個字節(如果是這種情況,就不太像是DMA導致的了)

爲了摸清規律,我們又進行了下面的實驗。和上面實驗的不同之處在於,B中的值不再是一樣的,而是從一個隨機值遞增的,到0xFF則迴歸到0x0。

DMA驗證試驗二

問題很快就又出現了,結果如下圖所示,0xa182f714是全局數組B的起始地址,可以看到,有12個字節被破壞了。整個過程描述如下:

  1. 上次change_and_check_mem執行完,B的起始地址被填充爲0x11,後面依次爲0x12,0x13,每個字節加1,遇到0xff變爲0x00
  2. 本次執行change_and_check_mem時,修改B的填充,起始地址填充爲0x26,後面依次爲0x27, 0x28, 每個字節加1,遇到0xff變爲0x00(這部分在下面的截圖中看不出來,因爲已經被踩了)
  3. 休眠10ms
  4. 對B檢測,發現B的前12個字節被改爲11121314 15161718 191a1b1c(下圖紅色方框內的數據),而這些值是B的歷史值。紅色方框內的數值應該爲26272829 2a2b2c2d 2e2f3031。

DMA驗證試驗二結果

從這次實驗結果看,應該是B前面幾個字節的值被緩存,而後又被賦值到原來的位置。不過值得注意的是,B這次被踩了12個字節,而不是16個字節。結合B的首地址和被踩字節數,可以發現最終得到的都是0x‭a182f720(該值爲32的倍數)。也就是說被踩字節數和首地址是有關聯的。

第一次試驗: 0xa182f710 + 16(十進制) = 0x‭a182f720
第二次試驗: 0xa182f714 + 12(十進制) = 0x‭a182f720 ‬

6.4 DMA與Cache一致性

DMA會導致Cache一致性問題。如下圖所示,CPU的運算操作會修改Cache中的數據,而DMA會修改主存DDR中的數據,這就要求二者需要通過一定的機制保持同步,即Cache一致性。

DMA框架

下面的流程圖展示了在內存讀寫過程中,Cache是如何與主存同步的,注意下面三點:

  • dirty的Cache在被置換出去的時候,必須回寫到主存(下圖中的lower memory)
  • Cache未命中的時候,是從主存中讀取原始數據的
  • CPU修改Cache中的數據後,並未直接回寫到主存,而是將該Cache標記爲dirty

Cache工作原理

瞭解了上面的原理,我們結合DMA分析下磁盤文件的讀寫流程。

寫數據到磁盤:

  1. 判斷Cache中的數據是否爲dirty,如果dirty就回寫到主存DDR
  2. 啓動DMA將數據從主存搬運到磁盤

從磁盤讀數據:

  1. 啓動DMA將數據從磁盤搬運到主存DDR
  2. 將對應主存區域的Cache全部置爲無效(invalid cache,注意不是dirty,這樣程序訪問的時候,纔會從主存讀取最新數據)

6.5 幕後主使在此

驅動同事分析DMA相關代碼,發現本平臺的Cache Line爲32字節,DMA操作的時候,未考慮Cache Line的對齊問題,導致Cache與主存的一致性出了問題,進而在文件讀取的時候破壞了相鄰的內存(大家可以思考下,爲什麼寫文件的時候沒有出問題)。以6.3節第二次實驗爲例,具體原因如下:

  1. 程序從文件中讀取256KB的數據到下圖中的內存區域A,和A緊挨着的內存區域B爲另一個線程的,B的前12字節在主存中的內容和Cache中的內容不一致(結合上面介紹的知識,我們知道這是正常的)。 invalid cache前

  2. DMA將文件讀取到主存的A區域後,需要將A區域對應的Cache invalid(失效)掉,以保證Cache和主存中的數據是一致的。
    注:實際上,A對應的內存區域可能已經不在Cache中了,但DMA不知道,爲了保證數據的一致性,它必須將A對應的Cache invalid掉。

  3. Invalid Cache的時候就帶來問題了。前面提到本平臺的Cache Line爲32字節,也就是說一次進入Cache或清除Cache的最小單位是32字節,而A的首地址爲0xa182f714,大小爲256KB,爲了保證整個A區域的Cache被清除,必須清除至地址0x‭a182f720。計算方法爲:按32字節的倍數向上對齊,如下所示:

     ROUND_UP(0xa182f714 + 0x40000, 32) = 0x‭a182f720  
    
  4. B現在最新的數值是在Cache中,而上面的操作會將B前12字節對應的Cache invalid掉。如下圖所示,後續程序再訪問B的前12字節,cache未命中,只有從主存中取,結果取到的是歷史值。 invalid cache前

就這樣,B躺着中槍了!!!

實際程序中,那個可憐的信號量就在上面的B處。

7. 修改方法

由於本平臺的Cache Line爲32字節,所以我們需要保證拿去做DMA的內存首地址32字節對齊,並且大小也是32字節的倍數。這樣就不會出現上面的踩內存問題。

7.1 方法一:應用層規避

所有使用DMA的業務代碼,自行保證內存首地址和大小均按32字節對齊。但是該方案存在以下明顯的缺點:

  • 上層業務必須知道哪些接口是使用DMA的
  • 有些內存變量的對齊不好做,比如棧上的局部變量
  • 增加了上層業務開發的複雜度

7.2 方法二:驅動層規避

比較合理的解決方法是,驅動層保證。如下圖所示,驅動層識別到首地址不是32字節對齊的,就先用一個臨時內存塊(該臨時內存塊首地址32字節對齊,大小是32字節倍數)做前12字節的DMA,然後將前12個字節通過memcpy拷貝到主存的0xa17ef714~0xa17ef720,接下來的1024字節因爲滿足對齊和大小要求,所以可以直接進行DMA,尾部剩餘14字節只滿足首地址對齊的要求,不滿足大小是12字節倍數的要求,所以也要藉助臨時內存完成數據搬運。

驅動層規避方案

8 總結

本文涉及的知識點如下:

  1. backtrace回溯函數調用棧
  2. 彙編代碼分析
  3. 內存打標記,及基於此的內存非法訪問檢測
  4. 基於MMU的內存保護
  5. Electric Fence(efence)內存非法方法檢測機制
  6. 通過wrap鏈接選項替換系統函數
  7. 基於GCC的棧溢出保護(SSP)功能
  8. 硬件watchpoint
  9. TheadX內存管理機制、信號量管理機制
  10. DMA、Cache一致性

9 參考資料

  1. ThreadX源碼
  2. arm平臺根據棧進行backtrace的方法
  3. real-time-embedded-multithreading-using-threadx-and-arm.pdf
  4. https://linux.die.net/man/3/efence
  5. 函數wrap原理
  6. https://en.wikipedia.org/wiki/Cache_(computing)#WRITE-BACK
  7. https://en.wikipedia.org/wiki/Cache_coherence
  8. 如何在實時操作系統(RTOS)中使用GCC的棧溢出保護(SSP)功能
  9. 如何利用硬件watchpoint定位踩內存問題
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章