1. 程式人生 > >從零開始之驅動發開、linux驅動(二十、linux裝置驅動中的併發控制)

從零開始之驅動發開、linux驅動(二十、linux裝置驅動中的併發控制)

本文參考自宋寶華老師的《linux驅動開發詳解》

併發(Concurrency) 指的是多個執行單元同時、 並行被執行, 而併發的執行單元對共享資源(硬體資源和軟體上的全域性變數、 靜態變數等) 的訪問則很容易導致競態(Race Conditions)  

只要併發的多個執行單元存在對共享資源的訪問, 競態就可能發生。

1.對稱多處理器(SMP) 的多個CPU

SMP是一種緊耦合、 共享儲存的系統模型,, 它的特點是多個CPU使用共同的系統匯流排, 因此可訪問共同的外設和儲存器。

在SMP的情況下, 兩個核(CPU0和CPU1) 的競態可能發生於CPU0的程序與CPU1的程序之間、CPU0的程序與CPU1的中斷之間以及CPU0的中斷與CPU1的中斷之間, 下圖中任何一條線連線的兩個實體都有核間併發可能性。

2.單個CPU程序核心和搶佔它的程序

Linux 2.6以後的核心支援核心搶佔排程, 一個程序在核心執行的時候可能耗完了自己的時間片(timeslice) , 也可能被另一個高優先順序程序打斷, 程序與搶佔它的程序訪問共享資源的情況類似於SMP的多個CPU。

3.中斷(硬中斷、 軟中斷、 Tasklet、 底半部) 與程序之間 中斷可以打斷正在執行的程序, 如果中斷服務程式訪問程序正在訪問的資源, 則競態也會發生。此外, 中斷也有可能被新的更高優先順序的中斷打斷, 因此, 多箇中斷之間本身也可能引起併發而導致競態。 但是Linux 2.6.35之後, 就取消了中斷的巢狀。 老版本的核心可以在申請中斷時, 設定標記IRQF_DISABLED以避免中斷巢狀, 由於新核心直接就預設不巢狀中斷, 這個標記反而變得無用了。

上述併發的發生除了SMP是真正的並行以外, 其他的都是單核上的“巨集觀並行, 微觀序列”, 但其引發的實質問題和SMP相似。

解決競態問題的途徑是保證對共享資源的互斥訪問, 所謂互斥訪問是指一個執行單元在訪問共享資源的時候, 其他的執行單元被禁止訪問。訪問共享資源的程式碼區域稱為臨界區(Critical Sections) , 臨界區需要被以某種互斥機制加以保護。

中斷遮蔽、 原子操作、 自旋鎖、 訊號量、 互斥體等是Linux裝置驅動中可採用的互斥途徑。  

7.2 編譯亂序和執行亂序

理解Linux核心的鎖機制, 還需要理解編譯器和處理器的特點。 比如下面一段程式碼, 寫端申請一個新的struct foo結構體並初始化其中的a、 b、 c, 之後把結構體地址賦值給全域性gp指標。

struct foo {
    int a;
    int b;
    int c;
};

struct foo *gp = NULL;
/* . . . */
p = kmalloc(sizeof(*p), GFP_KERNEL);
p->a = 1;
p->b = 2;
p->c = 3;
gp = p;

而讀端如果簡單做下處理,則程式的執行可能是不符合預期的。

p = gp;

if(NULL != p)
{
    do_something(p->a, p->b, p->c);
}

有兩種可能的原因會造成程式出錯,一種可能性是編譯亂序,另 一種可能性是執行亂序。

關於編譯方面,C語言順序“p->a = 1; p->b = 2; p->c = 3; gp = p;”的編譯結果的指令順序可能是gp的賦值指令發生在a,b,c的賦值之前。現代的高效能編譯器在目標碼優化上都具備對指令進行亂序優化的能力。編譯器可以對訪存的指令進行亂序,減少邏輯上不必要的訪問,以儘量提高cache命中率和CPU的load/store單元的工作效率。因此在開啟編譯器優化以後,看到生成的彙編碼並沒有嚴格按照程式碼的邏輯順序,這是正常的。

解決編譯亂序問題,需要通過barrier()編譯屏障進行。我們可以在程式碼中設定barrier()屏障,這個屏障可以阻擋編譯器的優化。對於編譯器來說,這個編譯屏障可以保證屏障前的語句和屏障後的語句不亂“串門”。

比如下面一段程式碼在e=d[4095],於b = a、c = a之間沒有編譯屏障

int main(int argc,char *argv[])
{
    int a = 0, b, c, d[4096], e;

    e = d[4095];
    b = a;
    c = a;
    printf("a:%d b:%d c:%d e:%d\n",a, b, c, c, e);

    return 0;
}

使用下面命令編譯

arm-none-linux-gnueabi-gcc -O2 barrier.c  -o barrier

使用下面命令反彙編後,重定位到.dis檔案中

arm-none-linux-gnueabi-objdump -D barrier > barrier.dis

反彙編的結果是(thumb 2)

int main(int argc,char *argv[])
{  
    831c: b530         push {r4, r5, lr}
    831e: f5ad 4d80    sub.w sp, sp, #16384 ; 0x4000    4096*4個位元組
    8322: b083         sub sp, #12               ; b c e共12個位元組
    8324: 2100         movs r1, #0
    8326: f50d 4580    add.w r5, sp, #16384 ; 0x4000
    832a: f248 4018    movw r0, #33816      ; 0x8418
    832e: 3504         adds r5, #4
    8330: 460a         mov r2, r1           ;-> b= a;
    8332: 460b         mov r3, r1           ;-> c= a;
    8334: f2c0 0000    movt r0, #0
    8338: 682c         ldr r4, [r5, #0]
    833a: 9400         str r4, [sp, #0]     ;-> e = d[4095];
    833c: f7ff         efd4 blx 82e8 <_init+0x20>
}

顯然,儘管原始碼級別b = a,c  = a發生在c = d[4095]之後,但是目的碼的b = a,c = a指令發生在c = d[4095]之前。

我們重新編寫程式碼,在c = d[4095],與b = a,c = a之間加上編譯屏障:

#define barrier()        __asm__ volatole ("": : :"memory")

int main(int argc,char *argv[])
{
    int a = 0, b, c, d[4096], e;

    e = d[4095];
    barrier();
    b = a;
    c = a;
    printf("a:%d b:%d c:%d e:%d\n",a, b, c, c, e);

    return 0;
}

再次使用

arm-none-linux-gnueabi-gcc -O2 barrier.c  -o barrier

優化編譯,反彙編的結果是:

int main(int argc,char *argv[])
{
    831c: b510             push {r4, lr}
    831e: f5ad 4d80        sub.w sp, sp, #16384 ; 0x4000
    8322: b082             sub sp, #8
    8324: f50d 4380        add.w r3, sp, #16384 ; 0x4000
    8328: 3304             adds r3, #4
    832a: 681c             ldr r4, [r3, #0]
    832c: 2100             movs r1, #0
    832e: f248 4018        movw r0, #33816      ; 0x8418
    8332: f2c0 0000        movt r0, #0
    8336: 9400             str r4, [sp, #0]     ;-> e = d[4095];
    8338: 460a             mov r2, r1           ;-> b= a;
    833a: 460b             mov r3, r1           ;-> c= a;
    833c: f7ff efd4        blx 82e8 <_init+0x20>

}

因為__asm__ vloatile ("" : : :"memory")這個編譯屏障的存在,原來的3條指令的順序  撥亂反正 了。 關於解決編譯亂序的問題,C語言的volatile關鍵字的作用較弱,它更多的知識避免記憶體訪問行為的合併,對C語言編譯器而言,volatile是暗示除了當前的執行線索之外,其它的執行線索可可能改變某記憶體,所以他的含義是“異變的”。換句話說,就是如果執行緒A讀取var這個記憶體中的變數兩次而沒有修改var,編譯器可能覺得讀一次就行了,第二次直接取第一次的結果(在暫存器中)。但如果加了volatile關鍵字來形容var,則就是告訴編譯器執行緒B,執行緒C或者其他執行實體也可能把var改掉了,因此編譯器就不會把執行緒A程式碼的第2次記憶體讀取優化掉了。另外,volatile也不具備保護臨界資源的作用。總之,linux核心明顯不太喜歡volatile,這個可以看考原始碼目錄下的文件Documentation/volatile-considered-harmful.txt  

亂序編譯時編譯器的行為,而執行亂序則是處理器執行時的行為。指向亂序是指變異的二進位制指令的順序按照“p ->a = 1; p- > = 2; p->c = 3; gp = p;”排放,在處理器上執行時,後放置的指令話是可能先執行完,這是處理器的“亂序執行”策略。高階點的CPU可以根據自己快取的組織特性,將訪存指令重新排序執行。連續地址的訪問可能會先執行,因為這樣花村命中率高。有的還允許訪存的非阻塞,即如果前面一條訪問指令因為快取不命中,造成長延時的儲存訪問時,後面的訪存指令可以先執行,一遍從快取重取數。因此,即便是從彙編上看順序正確的指令,其回想的順序也是不可預知的。

舉例,ARM v6/v7的處理器會對一下指令順序進行優化。

ldr r0, [r1]
str r2, [r3]

 假設第一條ldr指令導致快取未命中,這樣快取就會填充行,並需要較多的時鐘週期才能完成。老的ARM處理器,比如ARM926EJ-S會等待這個動作完成,再指行下一條str指令。而ARM v6/v7處理器會識別下一條指令(str),且不需要等待第一條指令(ldr)完成(並不依賴r0的值),即會先執行str指令,而不是等待ldr指令完成。

對於大多數體系結構而言,儘管每個cpu都是亂序執行,但是這一亂序對於單核的程式是不可見的,因為單個CPU在碰到依賴點(後面的指令依賴前面的指令額執行結果)的時候會等待,所以程式設計師可能感覺不到這個亂序過程。但是這個依賴點等待的過程,在SMP處理器裡面對於其他核是不可見的。比如若是在CPU0上執行:

while(f == 0);
    print x

CPU1上執行

x = 42;
f = 1;

我們不能武斷地認為CPU0上列印的x一定小於等於42,因為CPU1上即使 “f = 1”編譯在“先= 42”後面,執行時仍然可能先於“x = 42”完成,所以這個時候CPU0上列印的x不一定就是42。

處理器為了解決多核間一個核的記憶體行為對另一個核可見的問題,引入了一些記憶體屏障的指令。譬如,ARM處理器的屏障指令包括:

dmb(資料記憶體屏障):在dmb之後的顯示記憶體訪問執行之前,保證所有在dmb指令之前的記憶體訪問完成;
dsb(資料同步指令):等待所有在dsb指令之前的指令完成(位於此指令前的所有顯示記憶體訪問均完成,位於此指令前的所有快取,跳轉預測和TLB維護操作全部完成);
isb(指令同步屏障):flush流水線,是的所有isb之後的指令都是從快取或記憶體中獲得的。

linux的核心自旋鎖、互斥體等互斥邏輯,需要用到上述指令;在請求獲得鎖時,呼叫屏障指令;在解鎖時,也需要呼叫屏障指令。

下面程式碼清單用匯編程式碼描述了一個簡單的互斥邏輯,留意其中的14和22行。

LOCKED EQU 1
UNLOCKED EQU 0
lock_mutex
        ; 互斥量是否鎖定?
LDREX r1, [r0]            ; 檢查是否鎖定
CMP r1, #LOCKED           ; 和"locked"比較
WFEEQ                     ; 互斥量已經鎖定, 進入休眠
BEQ lock_mutex            ; 被喚醒, 重新檢查互斥量是否鎖定
        ; 嘗試鎖定互斥量
MOV r1, #LOCKED
STREX r2, r1, [r0]        ; 嘗試鎖定
CMP r2, #0x0              ; 檢查STR指令是否完成
BNE lock_mutex            ; 如果失敗, 重試
DMB                       ; 進入被保護的資源前需要隔離, 保證互斥量已經被更新
BX lr

unlock_mutex
DMB                       ; 保證資源的訪問已經結束 
MOV r1, #UNLOCKED         ; 向鎖定域寫"unlocked"
STR r1, [r0]

DSB                       ; 保證在CPU喚醒前完成互斥量狀態更新
SEV                       ; 像其他CPU傳送事件, 喚醒任何等待事件的CPU

BX lr

前面提到每個CPU都是亂序執行,但是每個CPU在碰到依賴點的時候會等待,所以執行亂序對單核不一定可見。但是在訪問外設的暫存器時,這些暫存器的訪問順序在CPU的邏輯上構不成依賴關係,但是從外設的邏輯上將,可能需要固定的暫存器讀寫順序,這個時候,也需要使用CPU的記憶體屏障指令。核心文章\Documentatio\nmemory-barriers.txt  \Documentatio\io_ordering.txt

對此程序了描述。

在linux核心中,定義了 mb()、讀屏障rmb()、寫屏障wmb()、以及作用於暫存器讀寫的__ioemb()、__iowmb()這樣的API。讀寫暫存器的readl_relaxed()和readl()、write_relaxed()和writel()API的區別就體現在有無屏障方面。

比如我們通過write_relaxed()寫完DMA的開始地址、結束地址、大小之後,我們一定要呼叫writel來啟動DMA(以保障前面的寫成功)。

writel_relaxed(DMA_SRC_REG, src_addr);
writel_relaxed(DMA_DST_REG, dst_addr);
writel_relaxed(DMA_SIZE_REG, size);
writel (DMA_ENABLE, 1);


#if __LINUX_ARM_ARCH__ >= 7 ||		\
	(__LINUX_ARM_ARCH__ == 6 && defined(CONFIG_CPU_32v6K))
#define sev()	__asm__ __volatile__ ("sev" : : : "memory")
#define wfe()	__asm__ __volatile__ ("wfe" : : : "memory")
#define wfi()	__asm__ __volatile__ ("wfi" : : : "memory")
#endif

#if __LINUX_ARM_ARCH__ >= 7
#define isb(option) __asm__ __volatile__ ("isb " #option : : : "memory")
#define dsb(option) __asm__ __volatile__ ("dsb " #option : : : "memory")
#define dmb(option) __asm__ __volatile__ ("dmb " #option : : : "memory")
#elif defined(CONFIG_CPU_XSC3) || __LINUX_ARM_ARCH__ == 6
#define isb(x) __asm__ __volatile__ ("mcr p15, 0, %0, c7, c5, 4" \
				    : : "r" (0) : "memory")
#define dsb(x) __asm__ __volatile__ ("mcr p15, 0, %0, c7, c10, 4" \
				    : : "r" (0) : "memory")
#define dmb(x) __asm__ __volatile__ ("mcr p15, 0, %0, c7, c10, 5" \
				    : : "r" (0) : "memory")
#elif defined(CONFIG_CPU_FA526)
#define isb(x) __asm__ __volatile__ ("mcr p15, 0, %0, c7, c5, 4" \
				    : : "r" (0) : "memory")
#define dsb(x) __asm__ __volatile__ ("mcr p15, 0, %0, c7, c10, 4" \
				    : : "r" (0) : "memory")
#define dmb(x) __asm__ __volatile__ ("" : : : "memory")
#else
#define isb(x) __asm__ __volatile__ ("" : : : "memory")
#define dsb(x) __asm__ __volatile__ ("mcr p15, 0, %0, c7, c10, 4" \
				    : : "r" (0) : "memory")
#define dmb(x) __asm__ __volatile__ ("" : : : "memory")
#endif

3.中斷遮蔽

在單CPU範圍內避免競態的一種簡單而有效的方法是在進入臨界區之前遮蔽系統的中斷,但是在驅動程式設計中不值得推薦,驅動通常需要考慮跨平臺特點而不假定自己在單核上執行。CPU一般都具備遮蔽中斷和開啟中斷的功能,這項功能可以保證正在執行的核心執行路徑不被中斷處理程式所搶佔,防止某些競態條件的發生。具體而言,中斷遮蔽將使得中斷與程序之間的併發不再發生,而且,由於linux核心的程序排程等操作都是依賴中斷實現,核心搶佔程序之間的併發也得以避免了。

中斷遮蔽的使用方法為:

local_irq_disable();    /* 遮蔽中斷 */
...
/* 臨界區 */
...
local_irq_enable();    /* 開中斷 */

其底層的實現原理是讓CPU本身不響應中斷,比如,對於ARM處理器而言,其底層的實現是遮蔽ARM CPSR的I位:

static inline void arch_local_irq_enable(void)
{
	asm volatile(
		"	cpsie i			@ arch_local_irq_enable"
		:
		:
		: "memory", "cc");
}

static inline void arch_local_irq_disable(void)
{
	asm volatile(
		"	cpsid i			@ arch_local_irq_disable"
		:
		:
		: "memory", "cc");
}

由於linux的非同步I/O,程序排程等多個重要操作都依賴於中斷,中斷對比核心的的執行非常重要,在遮蔽中斷期間,所有的中斷都無法得到處理,因此長時間遮蔽中斷是很危險的,這有可能會造成資料丟失乃至系統崩潰等後果。這就要求在遮蔽了中斷之後,當前的核心執行路徑應當儘快的執行完境界區的程式碼。

local_irq_disable()和local_irq_enable()都只能禁止和使能本CPU內的中斷,因此,並不能解決SMP多CPU引發的競態。因此,單獨使用中斷遮蔽通常不是一種值得推薦的避免競態的方法(換句話說,驅動中使用local_irq_disable/local_irq_enable通常意味著一個bug),它適合於下文將要介紹的自旋鎖聯合使用。

local_irq_disable()不同的是,loacl_irq_save(flags)處了經行禁止中斷的操作以外,還儲存目前CPU的中斷位資訊,local_irq_restore(flags)進行的是與local_irq_save(flags)相反的操作。對於arm處理器而言,其實就是儲存和恢復CPSR。

如果只是想禁止中斷的底半 部,應該使用local_bh_disable(),使能用loacl_bh_disable()禁止的底半部應該使用local_bh_enable()。

4. 原子操作

原子操作可以保證對一個整型資料的修改是排他性的。linux核心提供了一系列函式來實現核心中的原子操作,這些函式分為兩類,分別針對位和整型變數進行原子操作。位和整型變數的原子操作都依賴於底層CPU的原子操作,因此所有這些函式都與CPU架構密切相關。對於ARM處理器而言,底層使用ldrex和strex指令,比如atomic_inc()底層的實現會呼叫掉atomic_add(),其程式碼如下:

/*
 * ARMv6 UP and SMP safe atomic ops.  We use load exclusive and
 * store exclusive to ensure that these are atomic.  We may loop
 * to ensure that the update happens.
 */
static inline void atomic_add(int i, atomic_t *v)
{
	unsigned long tmp;
	int result;

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