本文內容:volatile關鍵字的含義,它與barrier()和編譯亂序的關系,以及內核里面READ_ONCE()、WRITE_ONCE()的實現原理。
作者簡介:李浩,就職于南京富士通南大軟件,熟悉 x86 架構,對內存和文件系統有些研究。
如果一個變量被聲明為 volatile 的,就是告訴編譯器即使我們當前編譯的代碼不會修改這個變量,該變量對應的內存數據也可能會由于其他原因而被修改,這可能的原因有很多,比如該變量對應的內存位置是使用 memory mapped I/O 機制映射的一個外設端口,即我們本質上是在訪問一個硬件寄存器,它的值的變化當然不受程序控制。
那為什么要告訴編譯器這個信息呢?因為這樣的話,生成匯編代碼時,每次使用該變量時都會去對內存位置做一次讀訪問以獲取最新的值。相反,如果不加 volatile,那么編譯器為了效率,很可能先把該變量加載到寄存器,以后需要用時就都去讀這個寄存器了,不會再去讀內存,即使內存的數據變動了我們的代碼也不知道,還在用寄存器里的老數據。我們常用的 ioread 函數就封裝了 volatile 操作,保證能讀到最新數據,具體定義可以參見 build_mmio_read。
但要注意,除了這種 memory mapped I/O 以及其他少數幾個特殊情況[1],如果一個變量可能被多個過程并發訪問,這種情況不應該使用 volatile 關鍵字來保證每個過程都能看到該變量的最新值,正確的做法是使用鎖來保護它,加鎖成功后只需要把被保護變量從內存讀一次扔到寄存器就行了,后面都用寄存器的值,這樣效率高,在我們出臨界區之前鎖機制會保證不會有其他過程來修改此變量,所以寄存器里的數據一直是有效的。這個時候如果畫蛇添足把被保護變量聲明為 volatile,會阻止編譯器在臨界區內對該變量的讀取優化,每次都要從內存讀,這顯然沒必要。
volatile 的另一種用法需要結合 READ_ONCE/WRITE_ONCE 這兩個宏來看,內核注釋里提到這兩個宏有阻止編譯亂序的作用。
The compiler is also forbidden from reordering successive instances ofREAD_ONCE and WRITE_ONCE
我們下文以 READ_ONCE 讀取變量為例展開分析。
從內核對這個宏的定義來看,它的本質其實就是使用 volatile 關鍵字對變量做了類型修飾,怎么看都不像是能起到阻止亂序的作用。
#define READ_ONCE(x) \({\compiletime_assert_rwonce_type(x); \__READ_ONCE(x); \})#define __READ_ONCE(x) (*(const volatile __unqual_scalar_typeof(x) *)&(x))
所以我們只好試一試。
首先是一段 C 語言代碼:
int a, b;int i, j;void foo(){a = i;b = j/16;}
使用 gcc -O2 example.c -S 生成匯編:
movl j(%rip), %edx // 讀取 jmovl i(%rip), %eax // 讀取 itestl %edx, %edxmovl %eax, a(%rip)leal 15(%rdx), %eaxcmovns %edx, %eaxsarl $4, %eaxmovl %eax, b(%rip)
很明顯地看到 i 和 j 的讀取順序與 C 語言語句顛倒了。那么為了阻止這種優化,我們首先試下編譯屏障 barrier(),看看效果如何。
#define barrier() __asm__ __volatile__("": : :"memory")int a, b;int i, j;void foo(){a = i;barrier();b = j/16;}
匯編如下:
movl i(%rip), %eax // 讀取 imovl %eax, a(%rip) // 寫入 a-------------------------------- 屏障在此movl j(%rip), %edx // 讀取 jtestl %edx, %edxleal 15(%rdx), %eaxcmovns %edx, %eaxsarl $4, %eaxmovl %eax, b(%rip) // 寫入 b
顯然,barrier() 編譯屏障很管用,它告訴編譯器:在 barrier() 前后是兩個世界,屏障前的語句不能跑到屏障后,反之亦然,也就是編譯亂序不能穿透屏障。所以,讀取 i 寫入 a 和 讀取 j 寫入 b 這兩組操作被屏障隔離了。
在見識了編譯屏障的作用后,我們再試試 volatile 究竟有沒有起到類似的作用。
#define __READ_ONCE(x) (*(const volatile int *)&(x))int a, b;int i, j;void foo(){a = __READ_ONCE(i);b = __READ_ONCE(j)/16;}
匯編如下:
movl i(%rip), %eax // 讀取 imovl j(%rip), %edx // 讀取 jmovl %eax, a(%rip) // 寫入 atestl %edx, %edxleal 15(%rdx), %eaxcmovns %edx, %eaxsarl $4, %eaxmovl %eax, b(%rip) // 寫入 b
可以看到 i 和 j 的讀取順序被保證了。但是注意,volatile 畢竟不是編譯屏障,不能把第一條 C 語句和第二條語句完全隔離開,所以我們用 __READ_ONCE 能保證的也只是 i 和 j 的讀取順序,其他的寫入順序或者讀寫之間的順序無法被保證 (比如讀取 j 和寫入 a 就顛倒了)。
那編譯器為何會對 volatile 有這樣的約束行為呢,這是因為 C 標準做出了如下規定:
The least requirements on a conforming implementation are:At sequence points, volatile objects are stable in the sense that previous accesses are complete and subsequent accesses have not yet occurred..........The following are the sequence points described in 5.1.2.3:The end of a full expression: an initializer (6.7.8); the expression in an expressionstatement (6.8.3); the controlling expression of a selection statement (if or switch)(6.8.4); the controlling expression of a while or do statement (6.8.5); each of theexpressions of a for statement (6.8.5.3); the expression in a return statement(6.8.6.4).
這里引出了 sequence point 的概念,簡單來說就是 sequence point 之前的表達式所造成的影響不能擴散到 sequence point 之后。尤其是對于 volatile 變量來說,以一個 sequence point 為分界點,對于前面 volatile 變量的訪問必須完成,且對于后面 volatile 變量的訪問必須沒有開始。遵照如上標準,; 就是個 sequence point,那么 a = __READ_ONCE(i) 和 b = __READ_ONCE(j)/16 之間隔著一個 sequence point,所以對 i 的訪問必須放在 j 之前。
但需要注意的是,編譯器只是保證 volatile 變量與 volatile 變量的讀取不會被亂序,但是 non-volatile 變量和 volatile 變量的讀取順序依然是可以被亂序的。
比如我們把 j 的 __READ_ONCE 去掉:
#define __READ_ONCE(x) (*(const volatile int *)&(x))int a, b;int i, j;void foo(){a = __READ_ONCE(i);b = j/16;}
產生的匯編如下:
movl j(%rip), %edx // 讀取 jmovl i(%rip), %eax // 讀取 itestl %edx, %edxmovl %eax, a(%rip)leal 15(%rdx), %eaxcmovns %edx, %eaxsarl $4, %eaxmovl %eax, b(%rip)
可以看到 i 和 j 的讀取順序又顛倒了。
到這里,我們就把 READ_ONCE 也即 volatile 在變量讀取中的作用分析完了,它可以保證變量嚴格地按照代碼給出的順序去讀。同理,WRITE_ONCE 則是保證了變量的寫入順序。
那么如果 READ_ONCE 和 WRITE_ONCE 兩者混合使用,又會怎樣呢。其實,按照 C 標準,沒有特指這個保序只針對讀與讀或寫與寫,所以讀寫混合的順序也會得到保證。下面舉個例子:
int a, b;int i;void foo(){a = i/16;b = 0;}
這個函數的匯編如下:
movl $0, b(%rip) // 寫入 bmovl i(%rip), %edx // 讀取 itestl %edx, %edxleal 15(%rdx), %eaxcmovns %edx, %eaxsarl $4, %eaxmovl %eax, a(%rip) // 寫入 a
可以看到讀取 i 、寫入 a、寫入 b 這三者的順序已經徹底打亂了。
我們用上 volatile:
#define __READ_ONCE(x) (*(const volatile int *)&(x))#define __WRITE_ONCE(x, val) do {*(volatile typeof(x) *)&(x) = (val);} while(0)int a, b;int i;void foo(){a = __READ_ONCE(i)/16;__WRITE_ONCE(b, 0);}
生成的匯編如下:
movl i(%rip), %edx // 讀取 imovl $0, b(%rip) // 寫入 btestl %edx, %edxleal 15(%rdx), %eaxcmovns %edx, %eaxsarl $4, %eaxmovl %eax, a(%rip) // 寫入 a
可見,i 的讀取和 b 的寫入是嚴格按照 C 代碼的順序來的,說明 volatile 生效了。但是 a 的寫入被放到 b 寫入的后面了,這是因為 a 在被寫入時沒有被 volatile 修飾。如果我們把代碼改成這樣:
__WRITE_ONCE(a, __READ_ONCE(i)/16);__WRITE_ONCE(b, 0);
生成的匯編就會如下:
movl i(%rip), %edx // 讀取 itestl %edx, %edxleal 15(%rdx), %eaxcmovns %edx, %eaxsarl $4, %eaxmovl %eax, a(%rip) // 寫入 amovl $0, b(%rip) // 寫入 b
可以看到現在的順序和 C 代碼完全對應了。不過其實可以寫的更簡單一點,因為 a 需要靠 i 算出來,有計算依賴,所以編譯器會保證 i 的讀取在 a 寫入之前,第一行的 __READ_ONCE 可以去掉,寫成下面這樣效果是一樣的:
__WRITE_ONCE(a, i/16);__WRITE_ONCE(b, 0);
volatile 這種防止亂序的作用在 Java 中相當清晰,JVM 本身就類似于一個操作系統,Java 編譯為字節碼后也有指令重排導致編譯亂序的問題,所以 Java 中的 volatile 關鍵字明確帶有阻止優化的作用,這已經在 Java 開發者中成為了常識,而 C 語言中的 volatile 卻稍顯隱晦。
[1] 特殊情況:
https://www.kernel.org/doc/html/latest/process/volatile-considered-harmful.html
更多精彩,盡在"Linux閱碼場",掃描下方二維碼關注

別忘了分享、點贊或者在看哦~