linux內核原子操作的實現

所謂原子操作,就是“不可中斷的一個或一系列操作”。

硬件級的原子操作:單處理器系統(UniProcessor)中,能夠在單條指令中完成的操作都可以認爲是“原子操作”,因爲中斷只發生在指令邊緣。在多處理器結構中(Symmetric Multi-Processor)就不同了,由於系統中有多個處理器獨立運行,即使能在單條指令中完成的操作也有可能受到干擾。在X86平臺生,CPU提供了在指令執行期間對總線加鎖的手段。CPU上有一根引線#HLOCK pin連到北橋,如果彙編語言的程序中在一條指令前面加上前綴"LOCK",經過彙編以後的機器代碼就使CPU在執行這條指令的時候把#HLOCK pin的電位拉低,持續到這條指令結束時放開,從而把總線鎖住,這樣同一總線上別的CPU就暫時不能通過總線訪問內存了,保證了這條指令在多處理器環境中的原子性。對於其他平臺的CPU,實現各不相同,有的是通過關中斷來實現原子操作(sparc),有的通過CMPXCHG系列的指令來實現原子操作(IA64)。本文主要探討X86平臺下原子操作的實現。

軟件級別的原子操作:軟件級別的原子操作實現依賴於硬件原子操作的支持。

Linux內核提供了兩組原子操作接口:一組是針對整數進行操作;另一組是針對單獨的位進行操作。

1、原子整數操作

原子操作通常針對int或bit類型的數據,但是Linux並不能直接對int進行原子操作,而只能通過atomic_t的數據結構來進行。目前瞭解到的原因有兩個。

一是在老的Linux版本,atomic_t實際只有24位長,低8位用來做鎖。這是由於Linux是一個跨平臺的實現,可以運行在多種 CPU上,有些類型的CPU比如SPARC並沒有原生的atomic指令支持,所以只能在32位int使用8位來做同步鎖,避免多個線程同時訪問。(最新版SPARC實現已經突破此限制)。原子整數操作最常見的用途就是實現計數器。常見的用法是:

atomic_t use_cnt;

atomic_set(&use_cnt, 2);

atomic_add(4, &use_cnt);

atomic_inc(use_cnt);

在X86平臺上,atomic_t定義如下:

  1. typedef struct {  
  2.     int counter;  
  3. } atomic_t;  
下面選取atomic_add來進行分析:
  1. static inline void atomic_add(int i, atomic_t *v)  
  2. {  
  3.     asm volatile(LOCK_PREFIX "addl %1,%0"  
  4.              : "+m" (v->counter)  
  5.              : "ir" (i));  
  6. }  
可以看到,atomic_add使用了gcc提供的內嵌彙編來實現,是用一個addl指令來實現增加操作。重點看一下LOCK_PREFIX宏,它就是上文提到的鎖總線操作,也就是它保證了操作的原子性。LOCK_PREFIX定義如下:
  1. #define LOCK_PREFIX \  
  2.                 ".section .smp_locks,\"a\"\n"   \  
  3.                 "  .align 4\n"                  \  
  4.                 "  .long 661f\n" /* address */  \  
  5.                 ".previous\n"                   \  
  6.                 "661:\n\tlock; "  
展開後變成:
  1. .section .smp_locks,"a"  
  2.   .align 4  
  3.   .long 661f  
  4. .previous  
  5. 661:  
  6.         lock;  
逐條解釋如下:
.section .smp_locks,"a"
下面的代碼生成到 .smp_locks 段裏,屬性爲"a", allocatable
  .align 4
四字節對齊
  .long 661f
生成一個整數,值爲下面的 661 標號的實際地址,f 表示向前引用,如果 661 標號出現
在前面,要寫 661b。
.previous
代碼生成恢復到原來的段,也就是 .text
661:
數字標號是局部標號,5.3 Symbol Names
        lock;
開始生成指令,lock 前綴
這段代碼彙編後,在 .text 段生成一條 lock 指令前綴 0xf0,在 .smp_locks 段生成四個字節的 lock 前綴的地址,鏈接的時候,所有的.smp_locks 段合併起來,形成一個所有 lock 指令地址的數組,這樣統計 .smp_locks 段就能知道代碼裏有多少個加鎖的指令被生成,猜測是爲了調試目的。

搜索了一下,找到了相關引用處,當一個內核模塊被加載時,會調用module_finalize函數:

  1. int module_finalize(const Elf_Ehdr *hdr,  
  2.             const Elf_Shdr *sechdrs,  
  3.             struct module *me)  
  4. {  
  5.     const Elf_Shdr *s, *text = NULL, *alt = NULL, *locks = NULL,  
  6.         *para = NULL;  
  7.     char *secstrings = (void *)hdr + sechdrs[hdr->e_shstrndx].sh_offset;  
  8.   
  9.     for (s = sechdrs; s < sechdrs + hdr->e_shnum; s++) {   
  10.         if (!strcmp(".text", secstrings + s->sh_name))  
  11.             text = s;  
  12.         if (!strcmp(".altinstructions", secstrings + s->sh_name))  
  13.             alt = s;  
  14.         if (!strcmp(".smp_locks", secstrings + s->sh_name))  
  15.             locks= s;  
  16.         if (!strcmp(".parainstructions", secstrings + s->sh_name))  
  17.             para = s;  
  18.     }  
  19.   
  20.     if (alt) {  
  21.         /* patch .altinstructions */  
  22.         void *aseg = (void *)alt->sh_addr;  
  23.         apply_alternatives(aseg, aseg + alt->sh_size);  
  24.     }  
  25.     if (locks && text) {  
  26.         void *lseg = (void *)locks->sh_addr;  
  27.         void *tseg = (void *)text->sh_addr;  
  28.         alternatives_smp_module_add(me, me->name,  
  29.                         lseg, lseg + locks->sh_size,  
  30.                         tseg, tseg + text->sh_size);  
  31.     }  
  32.   
  33.     if (para) {  
  34.         void *pseg = (void *)para->sh_addr;  
  35.         apply_paravirt(pseg, pseg + para->sh_size);  
  36.     }  
  37.   
  38.     return module_bug_finalize(hdr, sechdrs, me);  
  39. }  
上面的代碼說,如果模塊有 .text 和 .smp_locks 段,就調這個來處理,做什麼呢?

  1. void alternatives_smp_module_add(struct module *mod, char *name,  
  2.                  void *locks, void *locks_end,  
  3.                  void *text,  void *text_end)  
  4. {  
  5.     struct smp_alt_module *smp;  
  6.   
  7.     if (noreplace_smp)  
  8.         return;  
  9.   
  10.     if (smp_alt_once) {  
  11.         if (boot_cpu_has(X86_FEATURE_UP))  
  12.             alternatives_smp_unlock(locks, locks_end,  
  13.                         text, text_end);  
  14.         return;  
  15.     }  
  16.   
  17.     ........//省略無關代碼  
  18. }  
上面的代碼說,如果是單處理器(UP),就調這個:

  1. static void alternatives_smp_unlock(u8 **start, u8 **end, u8 *text, u8 *text_end)  
  2. {  
  3.     u8 **ptr;  
  4.     char insn[1];  
  5.   
  6.     if (noreplace_smp)  
  7.         return;  
  8.   
  9.     add_nops(insn, 1);  
  10.     for (ptr = start; ptr < end; ptr++) {  
  11.         if (*ptr < text)  
  12.             continue;  
  13.         if (*ptr > text_end)  
  14.             continue;  
  15.         text_poke(*ptr, insn, 1);  
  16.     };  
  17. }  
看到這裏就能明白,這是內核配置了 smp,但是實際運行到單處理器上時,通過運行期間打補丁,根據 .smp_locks 裏的記錄,把 lock 指令前綴替換成 nop 以消除指令加鎖的開銷,這個優化真是極致了……,可能考慮很多用戶直接使用的是配置支持 SMP 編譯好的內核而特地對 x86/x64 做的這個優化。

2、原子位操作的實現

編寫代碼時,以如下的方式進行操作

unsigned long word = 0;

set_bit(0, &word); /*第0位被設置*/

set_bit(1, &word); /*第1位被設置*/

clear_bit(1, &word); /*第1位被清空*/

change_bit(0, &word); /*翻轉第0位*/

爲什麼關注原子操作?
1)在確認一個操作是原子的情況下,多線程環境裏面,我們可以避免僅僅爲保護這個操作在外圍加上性能開銷昂貴的鎖。
2)藉助於原子操作,我們可以實現互斥鎖。
3)藉助於互斥鎖,我們可以把一些列操作變爲原子操作。

我們重點關注一下以下兩個函數的實現:

  1. /** 
  2.  * clear_bit - Clears a bit in memory 
  3.  * @nr: Bit to clear 
  4.  * @addr: Address to start counting from 
  5.  * 
  6.  * clear_bit() is atomic and may not be reordered.  However, it does 
  7.  * not contain a memory barrier, so if it is used for locking purposes, 
  8.  * you should call smp_mb__before_clear_bit() and/or smp_mb__after_clear_bit() 
  9.  * in order to ensure changes are visible on other processors. 
  10.  */  
  11. static inline void clear_bit(int nr, volatile void *addr)  
  12. {  
  13.     asm volatile(LOCK_PREFIX "btr %1,%0" : ADDR : "Ir" (nr));  
  14. }  
  1. /* 
  2.  * clear_bit_unlock - Clears a bit in memory 
  3.  * @nr: Bit to clear 
  4.  * @addr: Address to start counting from 
  5.  * 
  6.  * clear_bit() is atomic and implies release semantics before the memory 
  7.  * operation. It can be used for an unlock. 
  8.  */  
  9. static inline void clear_bit_unlock(unsigned nr, volatile void *addr)  
  10. {  
  11.     barrier();  
  12.     clear_bit(nr, addr);  
  13. }  
第一個clear_bit函數比較好理解,和上面atomic系列的函數實現類似。但是注意到clear_bit_unlock函數中多了一個barrier函數,這是什麼操作呢?
這就是有名的“內存屏障“或”內存柵欄“操作,先來補充一下這方面的知識。

可以看一下barrier的定義:

  1. #define barrier() __asm__ __volatile__("": : :"memory")  

解釋一下:__volatitle__是防止編譯器移動該指令的位置或者把它優化掉。"memory",是提示編譯器該指令對內存修改,防止使用某個寄存器中已經load 的內存的值。lock 前綴是讓cpu 的執行下一行指令之前,保證以前的指令都被正確執行。

事實上,不止barrier,還有一個mb系列的函數也起着內存屏障的功能:
  1. #include <asm/system.h>  
  2. "void rmb(void);"  
  3. "void wmb(void);"  
  4. "void mb(void);"  

這些函數在已編譯的指令流中插入硬件內存屏障,具體的插入方法是平臺相關的。rmb(讀內存屏障)保證了屏障之前的讀操作一定會在後來的讀操作執行之前完成。wmb保證寫操作不會亂序,mb 指令保證了兩者都不會。這些函數都是 barrier 函數的超集。解釋一下:編譯器或現在的處理器常會自作聰明地對指令序列進行一些處理,比如數據緩存,讀寫指令亂序執行等等。如果優化對象是普通內存,那麼一般會提升性能而且不會產生邏輯錯誤。但如果對I/O 操作進行類似優化很可能造成致命錯誤。所以要使用內存屏障,以強制該語句前後的指令以正確的次序完成。

其實在指令序列中放一個wmb 的效果是使得指令執行到該處時,把所有緩存的數據寫到該寫的地方,同時使得wmb 前面的寫指令一定會在wmb後面 的寫指令之前執行。

回到上面的函數,當clear_bit函數不用於實現鎖的目的時,不用給它加上內存屏障(我的理解:不管是不是讀到最新的數據,這一位就是要清零,不管加不加內存屏障,結果都是一樣的);而當用於實現鎖的目的時,必須使用clear_bit_unlock函數,其實現中使用了內存屏障,以此來確保此處的修改能在其他CPU上看到(我的理解:加鎖操作就是爲了在多個CPU間進行同步的目的,所以要避免寄存器優化,其他CPU每次都讀內存這樣才能看到最新的變化,這塊不是太明白)。這種操作也叫做serialization,即在執行這條指令前,CPU必須要完成前面所有對memory的訪問指令(read and write),這樣是爲了避免編譯器進行某些優化。

同樣使用serialization操作的還有test_and_set_bit函數:

  1. /** 
  2.  * test_and_set_bit - Set a bit and return its old value 
  3.  * @nr: Bit to set 
  4.  * @addr: Address to count from 
  5.  * 
  6.  * This operation is atomic and cannot be reordered. 
  7.  * It also implies a memory barrier. 
  8.  */  
  9. static inline int test_and_set_bit(int nr, volatile void *addr)  
  10. {  
  11.     int oldbit;  
  12.   
  13.     asm volatile(LOCK_PREFIX "bts %2,%1\n\t"  
  14.              "sbb %0,%0" : "=r" (oldbit), ADDR : "Ir" (nr) : "memory");  
  15.   
  16.     return oldbit;  
  17. }  
解釋一下:
1)memory 強制gcc 編譯器假設RAM 所有內存單元均被彙編指令修改,這樣cpu 中的registers 和cache 中已緩存的內存單元中的數據將作廢。cpu 將不得不在需要的時候重新讀取內存中的數據。這就阻止了cpu 又將registers,cache 中的數據用於去優化指令,而避免去訪問內存。

2)sbb  $0,0(%%esp)表示將數值0 減到esp 寄存器中,而該寄存器指向棧頂的內存單元。減去一個0,esp 寄存器的數值依然不變。即這是一條無用的彙編指令。在此利用這條無價值的彙編指令來配合lock 指令,在__asm__,__volatile__,memory 的作用下,用作cpu 的內存屏障。

這種寫法和前面的clear_bit_unlock中先寫一個barrier函數,再寫一個正常內嵌彙編函數的功能是一樣的。

發佈了13 篇原創文章 · 獲贊 13 · 訪問量 14萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章