Linux內核同步機制之(一):原子操作

一、源由

我們的程序邏輯經常遇到這樣的操作序列:

1、讀一個位於memory中的變量的值到寄存器中

2、修改該變量的值(也就是修改寄存器中的值)

3、將寄存器中的數值寫回memory中的變量值

如果這個操作序列是串行化的操作(在一個thread中串行執行),那麼一切OK,然而,世界總是不能如你所願。在多CPU體系結構中,運行在兩個CPU上的兩個內核控制路徑同時並行執行上面操作序列,有可能發生下面的場景:

CPU1上的操作 CPU2上的操作
讀操作  
  讀操作
修改 修改
寫操作  
  寫操作

多個CPUs和memory chip是通過總線互聯的,在任意時刻,只能有一個總線master設備(例如CPU、DMA controller)訪問該Slave設備(在這個場景中,slave設備是RAM chip)。因此,來自兩個CPU上的讀memory操作被串行化執行,分別獲得了同樣的舊值。完成修改後,兩個CPU都想進行寫操作,把修改的值寫回到memory。但是,硬件arbiter的限制使得CPU的寫回必須是串行化的,因此CPU1首先獲得了訪問權,進行寫回動作,隨後,CPU2完成寫回動作。在這種情況下,CPU1的對memory的修改被CPU2的操作覆蓋了,因此執行結果是錯誤的。

不僅是多CPU,在單CPU上也會由於有多個內核控制路徑的交錯而導致上面描述的錯誤。一個具體的例子如下:

系統調用的控制路徑 中斷handler控制路徑
讀操作  
  讀操作
  修改
  寫操作
修改  
寫操作  

系統調用的控制路徑上,完成讀操作後,硬件觸發中斷,開始執行中斷handler。這種場景下,中斷handler控制路徑的寫回的操作被系統調用控制路徑上的寫回覆蓋了,結果也是錯誤的。

二、對策

對於那些有多個內核控制路徑進行read-modify-write的變量,內核提供了一個特殊的類型atomic_t,具體定義如下:

typedef struct {
	int counter;
} atomic_t;

從上面的定義來看,atomic_t實際上就是一個int類型的counter,不過定義這樣特殊的類型atomic_t是有其思考的:內核定義了若干atomic_xxx的接口API函數,這些函數只會接收atomic_t類型的參數。這樣可以確保atomic_xxx的接口函數只會操作atomic_t類型的數據。同樣的,如果你定義了atomic_t類型的變量(你期望用atomic_xxx的接口API函數操作它),這些變量也不會被那些普通的、非原子變量操作的API函數接受。

具體的接口API函數整理如下:

接口函數 描述
static inline void atomic_add(int i, atomic_t *v) 給一個原子變量v增加i
static inline int atomic_add_return(int i, atomic_t *v) 同上,只不過將變量v的最新值返回
static inline void atomic_sub(int i, atomic_t *v) 給一個原子變量v減去i
static inline int atomic_sub_return(int i, atomic_t *v) 同上,只不過將變量v的最新值返回
static inline int atomic_cmpxchg(atomic_t *ptr, int old, int new) 比較old和原子變量ptr中的值,如果相等,那麼就把new值賦給原子變量。
返回舊的原子變量ptr中的值
atomic_read 獲取原子變量的值
atomic_set 設定原子變量的值
atomic_inc(v) 原子變量的值加一
atomic_inc_return(v) 同上,只不過將變量v的最新值返回
atomic_dec(v) 原子變量的值減去一
atomic_dec_return(v) 同上,只不過將變量v的最新值返回
atomic_sub_and_test(i, v) 給一個原子變量v減去i,並判斷變量v的最新值是否等於0
atomic_add_negative(i,v) 給一個原子變量v增加i,並判斷變量v的最新值是否是負數
static inline int atomic_add_unless(atomic_t *v, int a, int u) 只要原子變量v不等於u,那麼就執行原子變量v加a的操作。
如果v不等於u,返回非0值,否則返回0值

三、ARM中的實現

我們以atomic_add爲例,描述linux kernel中原子操作的具體代碼實現細節:

#if __LINUX_ARM_ARCH__ >= 6 ----------------------(1)
static inline void atomic_add(int i, atomic_t *v)
{
    unsigned long tmp;
    int result;

    prefetchw(&v->counter); -------------------------(2)
    __asm__ __volatile__("@ atomic_add\n" ------------------(3)
"1:    ldrex    %0, [%3]\n" --------------------------(4)
"    add    %0, %0, %4\n" --------------------------(5)
"    strex    %1, %0, [%3]\n" -------------------------(6)
"    teq    %1, #0\n" -----------------------------(7)
"    bne    1b"
    : "=&r" (result), "=&r" (tmp), "+Qo" (v->counter) ---對應%0,%1,%2
    : "r" (&v->counter), "Ir" (i) -------------對應%3,%4
    : "cc");
}

#else

#ifdef CONFIG_SMP
#error SMP not supported on pre-ARMv6 CPUs
#endif

static inline int atomic_add_return(int i, atomic_t *v)
{
    unsigned long flags;
    int val;

    raw_local_irq_save(flags);
    val = v->counter;
    v->counter = val += i;
    raw_local_irq_restore(flags);

    return val;
}
#define atomic_add(i, v)    (void) atomic_add_return(i, v)

#endif

(1)ARMv6之前的CPU並不支持SMP,之後的ARM架構都是支持SMP的(例如我們熟悉的ARMv7-A)。因此,對於ARM處理,其原子操作分成了兩個陣營,一個是支持SMP的ARMv6之後的CPU,另外一個就是ARMv6之前的,只有單核架構的CPU。對於UP,原子操作就是通過關閉CPU中斷來完成的。

(2)這裏的代碼和preloading cache相關。在strex指令之前將要操作的memory內容加載到cache中可以顯著提高性能。

(3)爲了完整性,我還是重複一下彙編嵌入c代碼的語法:嵌入式彙編的語法格式是:asm(code : output operand list : input operand list : clobber list)。output operand list 和 input operand list是c代碼和嵌入式彙編代碼的接口,clobber list描述了彙編代碼對寄存器的修改情況。爲何要有clober list?我們的c代碼是gcc來處理的,當遇到嵌入彙編代碼的時候,gcc會將這些嵌入式彙編的文本送給gas進行後續處理。這樣,gcc需要了解嵌入彙編代碼對寄存器的修改情況,否則有可能會造成大麻煩。例如:gcc對c代碼進行處理,將某些變量值保存在寄存器中,如果嵌入彙編修改了該寄存器的值,又沒有通知gcc的話,那麼,gcc會以爲寄存器中仍然保存了之前的變量值,因此不會重新加載該變量到寄存器,而是直接使用這個被嵌入式彙編修改的寄存器,這時候,我們唯一能做的就是靜靜的等待程序的崩潰。還好,在output operand list 和 input operand list中涉及的寄存器都不需要體現在clobber list中(gcc分配了這些寄存器,當然知道嵌入彙編代碼會修改其內容),因此,大部分的嵌入式彙編的clobber list都是空的,或者只有一個cc,通知gcc,嵌入式彙編代碼更新了condition code register。

大家對着上面的code就可以分開各段內容了。@符號標識該行是註釋。

這裏的__volatile__主要是用來防止編譯器優化的。也就是說,在編譯該c代碼的時候,如果使用優化選項(-O)進行編譯,對於那些沒有聲明__volatile__的嵌入式彙編,編譯器有可能會對嵌入c代碼的彙編進行優化,編譯的結果可能不是原來你撰寫的彙編代碼,但是如果你的嵌入式彙編使用__asm__ __volatile__(嵌入式彙編)的語法格式,那麼也就是告訴編譯器,不要隨便動我的嵌入彙編代碼哦。

 

(4)我們先看ldrex和strex這兩條彙編指令的使用方法。ldr和str這兩條指令大家都是非常的熟悉了,後綴的ex表示Exclusive(獨佔),是ARMv7提供的爲了實現同步的彙編指令。


LDREX  <Rt>, [<Rn>]

<Rn>是base register,保存memory的address,LDREX指令從base register中獲取memory address,並且將memory的內容加載到<Rt>(destination register)中。這些操作和ldr的操作是一樣的,那麼如何體現exclusive呢?其實,在執行這條指令的時候,還放出兩條“狗”來負責觀察特定地址的訪問(就是保存在[<Rn>]中的地址了),這兩條狗一條叫做local monitor,一條叫做global monitor。

STREX <Rd>, <Rt>, [<Rn>]

和LDREX指令類似,<Rn>是base register,保存memory的address,STREX指令從base register中獲取memory address,並且將<Rt> (source register)中的內容加載到該memory中。這裏的<Rd>保存了memeory 更新成功或者失敗的結果,0表示memory更新成功,1表示失敗。STREX指令是否能成功執行是和local monitor和global monitor的狀態相關的。對於Non-shareable memory(該memory不是多個CPU之間共享的,只會被一個CPU訪問),只需要放出該CPU的local monitor這條狗就OK了,下面的表格可以描述這種情況:

thread 1 thread 2 local monitor的狀態
    Open Access state
LDREX   Exclusive Access state
  LDREX Exclusive Access state
  Modify Exclusive Access state
  STREX Open Access state
Modify   Open Access state
STREX   在Open Access state的狀態下,執行STREX指令會導致該指令執行失敗
    保持Open Access state,直到下一個LDREX指令

開始的時候,local monitor處於Open Access state的狀態,thread 1執行LDREX 命令後,local monitor的狀態遷移到Exclusive Access state(標記本地CPU對xxx地址進行了LDREX的操作,獨佔標記位),這時候,中斷髮生了,在中斷handler中,又一次執行了LDREX ,這時候,local monitor的狀態保持不變,直到STREX指令成功執行,local monitor的狀態遷移到Open Access state的狀態(清除xxx地址上的LDREX的標記)。返回thread 1的時候,在Open Access state的狀態下,執行STREX指令會導致該指令執行失敗(沒有LDREX的標記,何來STREX),說明有其他的內核控制路徑插入了。

對於shareable memory,需要系統中所有的local monitor和global monitor共同工作,完成exclusive access,概念類似,這裏就不再贅述了。

大概的原理已經描述完畢,下面回到具體實現面。

"1:    ldrex    %0, [%3]\n"

其中%3就是input operand list中的"r" (&v->counter),r是限制符(constraint),用來告訴編譯器gcc,你看着辦吧,你幫我選擇一個通用寄存器保存該操作數吧。%0對應output openrand list中的"=&r" (result),=表示該操作數是write only的,&表示該操作數是一個earlyclobber operand,具體是什麼意思呢?編譯器在處理嵌入式彙編的時候,傾向使用儘可能少的寄存器,如果output operand沒有&修飾的話,彙編指令中的input和output操作數會使用同樣一個寄存器。因此,&確保了%3和%0使用不同的寄存器。

(5)完成步驟(4)後,%0這個output操作數已經被賦值爲atomic_t變量的old value,毫無疑問,這裏的操作是要給old value加上i。這裏%4對應"Ir" (i),這裏“I”這個限制符對應ARM平臺,表示這是一個有特定限制的立即數,該數必須是0~255之間的一個整數通過rotation的操作得到的一個32bit的立即數。這是和ARM的data-processing instructions如何解析立即數有關的。每個指令32個bit,其中12個bit被用來表示立即數,其中8個bit是真正的數據,4個bit用來表示如何rotation。更詳細的內容請參考ARM ARM文檔。

(6)這一步將修改後的new value保存在atomic_t變量中。是否能夠正確的操作的狀態標記保存在%1操作數中,也就是"=&r" (tmp)。

(7)檢查memory update的操作是否正確完成,如果OK,皆大歡喜,如果發生了問題(有其他的內核路徑插入),那麼需要跳轉到lable 1那裏,從新進行一次read-modify-write的操作。

另外再看一下atomic_set(v, i)函數:

#define atomic_set(v, i) (((v)->counter) = (i))

上面這條指令的反彙編是
  10:    e3a02004     mov    r2, #4
  14:    e5832000     str    r2, [r3]

該函數只要保證第二條指令是原子的就行了。

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