亂序執行和內存屏障

亂序執行和內存屏障

最近寫的一些關於在驅動程序開發中會遇到的關於亂序執行問題的短文,都是些通用的技術,貼上來share。另外,禁止轉載。
ps:這玩意原本是用Docbook寫得,轉過來還真是麻煩~~

處理器的亂序和併發執行

目前的高級處理器,爲了提高內部邏輯元件的利用率以提高運行速度,通常會採用多指令發射、亂序執行等各種措施。現在普遍使用的一些超標量處理器通常 能夠在一個指令週期內併發執行多條指令。處理器從L1 I-Cache預取了一批指令後,就會分析找出那些互相沒有關聯可以併發執行的指令,然後送到幾個獨立的執行單元進行併發執行。比如下面這樣的代碼(假定 編譯器不做優化):

z = x + y;
p = m + n;

CPU就有可能將這兩行無關代碼分別送到兩個算術單元去同時執行。像Freescale的MPC8541這種嵌入式處理器一個指令週期能夠加載4條指令、發射2條指令到流水線、用5個獨立的執行單元來併發執行。

通常來說訪存指令(由LSU單元執行)所需要的指令週期可能很多(可能要幾十甚至上百個週期),而一般的算術指令通常在一個指令週期就搞 定。所以有 可能代碼中的訪存指令耗費了多個週期完成執行後,其他幾個執行單元可能已經把後面有多條邏輯上無關的算術指令都執行完了,這就產生了亂序。

另外訪存指令之間也存在亂序的問題。高級的CPU可以根據自己Cache的組織特性,將訪存指令重新排序執行。訪問一些連續地址的可能會 先執行,因 爲這時候Cache命中率高。有的還允許訪存的Non-blocking,即如果前面一條訪存指令因爲Cache不命中,造成長延時的存儲訪問時,後面的 訪存指令可以先執行以便從Cache取數。對寫指令的訪存亂序有可能造成的錯誤後果,所以處理器通常有專門的機制(通常是做了個緩衝)保證在出現異常或者 錯誤的時候,可以丟棄異常點後面的寫指令的結果不做寫入。

處理器的分支預測功能也能引起併發執行。處理器的分支預測單元有可能直接把兩條分支的指令都預取來一塊併發執行掉。等到分支判斷的結果出來以後,再丟棄錯誤分支的計算結果。這樣在很多情況下可以實現0週期跳轉。比如這樣的代碼(假定編譯器不做優化):

z = x + y; 
if (z > 0) then
p = m + n;
else
p = m - n;

看上去如果z不計算出來是無法繼續的。但是實際上CPU有可能先把三個加法都同時進行計算,然後根據z=x+y的結果直接挑選正確的p值。

因此,即使是從彙編上看順序正確的指令,其執行的順序也是不可預知的。處理器能夠保證併發和亂序執行不會得到錯誤結果,但是如果是對一些硬 件寄存器 的操作不能允許亂序的話,程序員就必須把這個情況告訴CPU。告訴的方法就是通過CPU提供的一組同步指令實現,通常在CPU的文檔裏面有對同步指令的使 用說明。系統函數庫裏面的內存屏障(rmb/wmb/mb)實際上也是通過這些同步指令實現的。因此在C編碼的時候,只要設置好內存屏障,就能告訴CPU 哪些代碼是不能亂序的。

編譯器的亂序優化

受到處理器預取單元的能力限制,處理器每次只能分析一小塊指令的併發性,如果指令相隔比較遠就無能爲力了。但是從編譯器的角度來看,編譯器能夠對很 大一個範圍的代碼進行分析,能夠從更大的範圍內分辨出可以併發的指令,並將其儘量靠近排列讓處理器更容易預取和併發執行,充分利用處理器的亂序併發功能。 所以現代的高性能編譯器在目標碼優化上都具備對指令進行亂序優化的能力。並且可以對訪存的指令進行進一步的亂序,減少邏輯上不必要的訪存,以及儘量提高 Cache命中率和CPU的LSU(load/store unit)的工作效率。所以在打開編譯器優化以後,看到生成的彙編碼並不嚴格按照代碼的邏輯順序是正常的。和處理器一樣,如果想要告訴編譯器不要去對某些 指令亂序優化,也要通過一些方式來告訴編譯器。通常可以通過volatile關鍵字來抑制(注意,不是禁止)編譯器對相關變量的訪問優化。舉個例子:

int *p, *q; 
......;
*p = 1;
*p = 2;
*q = *p;

這樣,編譯器通常會優化掉前面一個對*p的寫入(邏輯上冗餘),僅對*p寫入2。而對*q賦值的時候,編譯器認爲此時*q的結果就應該是上次*p的值,會優化掉從*p取數的過程,直接把在寄存器中保存的*p的值給*q(PowrPC彙編):

(假設r3=p,r4=q) 
li r5, 2 // r5賦值2
stw r5, 0(r3) // 把r5寫到*p
stw r5, 0(r4) // 把r5寫到*q

但是如果爲p指針加上了volatile關鍵字,情況就不同了:

volatile int *p; 
int *q;
......;
*p = 1;
*p = 2;
*q = *p;

在這種情況下,編譯器看見*p是volatile的時候,就會:

  1. 不對*p操作生成亂序指令(通常如此,具體請看後面的解釋)

  2. 每次從*p取數據的時候,一定會進行一次訪存操作,哪怕前面不久才取過*p的值放在寄存器裏。

  3. 不合並對*p的寫操作(也只是通常如此,解釋見後)

所以這回的結果如下(PowrPC彙編):

(假設r3=p,r4=q) 
li r5, 1 // r5賦值1
stw r5, 0(r3) // 把r5寫到*p
li r5, 2 // r5賦值2
stw r5, 0(r3) // 把r5寫到*p
lwz r5, 0(r3) // 從*p取值到r5
stw r5, 0(r4) // 把r5寫到*q

這樣編譯器會在彙編碼級別保證指令有序和不優化掉訪存操作。通常簡單地使用volatile關鍵字就可以解決編譯器的亂序問題,但是這些指令到了處理器執行的時候,仍然可能被亂序。對於處理器亂序執行的避免就需要用到一組內存屏障函數(barrier)了。


重要

絕大多數的編譯器,通常不會優化掉對volatile對象的訪問,並且通常保持同一個volatile對象的一系列讀寫操作是有序的(但是不能保證不同的volatile對象之間有序)。

但是,這不是絕對的。因爲ANSI C99標準關於對volatile對象訪問時編譯器是否要絕對保證禁止亂序(reorder)和禁止訪問合併(combine access)並沒有做任何規定!僅僅是鼓勵編譯器最好不要去優化對volatile對象的訪問,而唯一的強制要求僅僅是要求編譯器保證對 volatile對象的訪問優化不會跨越“sequence point”即可(所謂sequence point是指一些諸如外部函數調用、條件或循環跳轉等關鍵點,具體定義請查閱C99標準內的詳細說明)。

這就是說,如果一個編譯器在兩個sequence point之間像對待普通變量一樣去優化volatile變量,也是完全符合C99標準的!比如:

volatile int a;

if (...) { ... } // sequence point
a = 1;
a = 2;
a = 3;
printk("..."); // sequence point

在兩個sequence point之間,要是有編譯器對a的賦值操作合併(即僅寫入3)或者亂序(如寫1和寫2對調),都是完全符合C99標準的。所以,我們在使用的時候,不能 指望用了volatile以後絕對能生成有序的完整的彙編碼,即不要指望volatile來保證訪存有序。實質上 volatile最大的作用主要還是在保證每次使用從內存中取值,而並不能保證編譯器不做其他任何優化(畢竟volatile從字面上看意思是“易變”而 不是“有序”。編譯器只保證對volatile對象即時更新但不保證訪問有序也不是說不過去的)。

從另一個角度看,即使是編譯器生成的彙編碼有序,處理器也不一定能保證有序。就算編譯器生成了有序的彙編碼,到了處理器那裏也拿不準是不 是會按照代碼順序執行。所以就算編譯器保證有序了,程序員也還是要往代碼裏面加內存屏障才能保證絕對訪存有序,這倒不如編譯器乾脆不管算了,因爲內存屏障 本身就是一個sequence point,加入後已經能夠保證編譯器也有序。

因此,對於切實是需要保障訪存順序的代碼,就算當前使用的編譯器能夠編譯出有序的目標碼來,我們也還是必須通過設置內存屏障的方式來保證有序,否則都是不嚴謹,有隱患的。

Barrier屏障函數

Barrier函數可以在代碼中設置屏障,這個屏障可以阻擋編譯器的優化,也可以阻擋處理器的優化。

對於編譯器來說,設置任何一個屏障都可以保證:

  1. 編譯器的亂序優化不會跨越屏障,即屏障前後的代碼不會亂序;

  2. 在屏障後所有對變量或者地址的操作,都會重新從內存中取值(相當於刷新寄存器中的變量副本)。

而對於處理器來說,根據不同的屏障有不同的表現(以下僅僅列舉3種最簡單的屏障):

  1. 讀屏障rmb()
    處理器對讀屏障前後的取數指令(LOAD)能保證有序,但是不一定能保證其他算術指令或者是寫指令的有序。對於讀指令的執行完成時間也不能保證,即它不能保證在屏障之前的讀指令一定都執行完成,只能保證屏障之前的讀指令一定能在屏障之後的讀指令之前完成。

  2. 寫屏障wmb()
    處理器對屏障前後的寫指令(STORE)能保證有序,但是不一定能保證其他算術指令或者是讀指令的有序。對於寫指令的執行完成時間也不能保證,即它不能保證在屏障之前的寫指令一定都執行完成,只能保證屏障之前的寫指令一定能在屏障之後的寫指令之前完成。

  3. 通用內存屏障mb()
    處理器保障只有屏障之前的訪存操作(包括讀寫)都完 成以後纔會執行屏障之後的訪存操作。即可以保障讀寫之間的有序(但是同樣無法保證指令完成的時 間)。這種屏障對處理器的執行單元效率產生的負面影響要比單純用讀屏障或者寫屏障來的大。比如對於PowerPC來說這種通用屏障通常是使用sync指令 實現的,在這種情況下處理器會丟棄所有預取的指令並清空流水線。所以頻繁使用內存屏障會降低處理器執行單元的效率。

對於驅動開發者來說,一些對設備寄存器的操作,通常是必須保證有序的。在絕大部分情況下,一般都是寫操作。對於有序的寫操作,必須設置寫屏障(wmb):

例:在驅動中使用寫屏障

/* Mask out everything */ 
im_intctl->ic_simrh = 0x00000000;
im_intctl->ic_simrl = 0x00000000;
wmb();  
/* Ack everything */ 
im_intctl->ic_sipnrh = 0xffffffff;
im_intctl->ic_sipnrl = 0xffffffff;

這是一個對中斷控制器操作的例子。在設置兩個mask寄存器的值的時候,這兩個寫操作沒有順序要求,因此可以不加屏障。但是對ack寄存器的設置必須在mask寄存器完成設置以後,所以在中間要加入寫屏障wmb()以保證對兩組寄存器的寫有序。

同樣的,對於一系列的只讀操作,也可以簡單使用rmb()來保證有序。


注意

任何一個rmb()或者wmb()都是可以被替換成mb()的。但是因爲上面提到過的mb()的效率問題,所以應該只有在同時需要讀屏障和寫屏障的 時候,才建議使用mb()。否則應該根據實際情況來選擇合適的屏障。當然,在設備初始化的時候,即使是使用mb()也不會對性能帶來什麼影響,因爲設備一 般只會初始化一次。但是在發生很頻繁的設備操作(比如網口的收發幀中斷等)時,應該考慮到mb()對性能的影響。

如果驅動不僅僅需要在單純的讀指令或者寫指令之間有序,還需要保證讀寫指令之間有序的時候,就需要設置mb()屏障了。下面將演示一個這樣的例子:

例:使用mb()屏障保證讀寫有序

我們假設有一個設備,在讀取設備信息時需要依次對REG1~3這三個寄存器進行寫入操作(寫入設備讀取命令),然後才能依次讀取REG4和REG5取得設備返回的信息。

REG1 = a; 
wmb(); // 保證REG1和REG2的寫有序

REG2 = b;
wmb(); // 保證REG2和REG3的寫有序

REG3 = c;

mb(); // 保證在對設備讀之前,前面的配置操作都完成(讀寫之間有序)

*d = REG4;
rmb(); // 保證REG4和REG5的讀有序

*e = REG5;

mb(); // 保證與未來對設備的操作有序
return;
  • 對於REG1~3的寫入,可以通過設置寫屏障來保證有序;

  • 在進行REG4和5的讀取之前,因爲得保證前面的寄存器寫操作都執行完才能讀,所以需要設置一個內存屏障mb()來保證前面對寄存器的寫都完成,以保障讀寫指令之間的有序;

  • 後面兩個讀操作之間就可以通過設置讀屏障來保證有序了;

  • 最後通常在從設備操作函數返回之前,我們一般需要保證對設備的操作都執行完畢了。這樣下次對設備進行操作的時候我們可以保證設備已經完成了上次操作,避免反覆調用設備操作函數帶來的函數間的亂序問題。所以在最後設置一個內存屏障mb(),保障和未來對設備的其他訪問有序。

進一步閱讀

如果還想進一步瞭解內存屏障的有關信息,特別是關於多處理器系統中的內存屏障,可以閱讀:

  • Linux內核源碼附帶的《LINUX KERNEL MEMORY BARRIERS》by David Howells <[email protected] >

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