鍵字:從編譯器優(yōu)化到嵌入式與多線程實(shí)戰(zhàn))
1. 從一次詭異的Bug調(diào)試說(shuō)起為什么這個(gè)變量“不聽(tīng)話”幾年前我還在做一個(gè)嵌入式實(shí)時(shí)數(shù)據(jù)采集的項(xiàng)目遇到了一個(gè)讓我調(diào)試了整整兩天的詭異問(wèn)題。系統(tǒng)里有一個(gè)全局的標(biāo)志位data_ready主循環(huán)里不斷檢查它一旦為真就去處理新采集到的數(shù)據(jù)。中斷服務(wù)程序ISR在數(shù)據(jù)采集完成后會(huì)把這個(gè)標(biāo)志位置為真。邏輯看起來(lái)天衣無(wú)縫但實(shí)際跑起來(lái)數(shù)據(jù)處理的時(shí)機(jī)總是不對(duì)有時(shí)甚至?xí)G失一整包數(shù)據(jù)。我檢查了中斷優(yōu)先級(jí)、查看了匯編指令、甚至懷疑是硬件問(wèn)題。最后在一位資深同事的提示下我在那個(gè)全局標(biāo)志位的聲明前加了一個(gè)小小的關(guān)鍵字volatile。問(wèn)題迎刃而解。這個(gè)經(jīng)歷讓我深刻體會(huì)到volatile這個(gè)在教科書里常常被一筆帶過(guò)的關(guān)鍵字在實(shí)際開發(fā)中尤其是在嵌入式、多線程、設(shè)備驅(qū)動(dòng)這些領(lǐng)域是一個(gè)關(guān)乎系統(tǒng)穩(wěn)定性的“生死符”。它不像指針、結(jié)構(gòu)體那樣功能直觀更像是一個(gè)給編譯器的“特別提示”但這個(gè)提示一旦被忽略就可能引入極其隱蔽且難以復(fù)現(xiàn)的 Bug。今天我們就來(lái)徹底搞懂volatile的作用、原理、用法以及那些教科書里不會(huì)寫的“坑”。簡(jiǎn)單來(lái)說(shuō)volatile的中文意思是“易變的”。它用來(lái)修飾一個(gè)變量告訴編譯器“這個(gè)變量可能會(huì)被程序本身以外的力量改變比如硬件、另一個(gè)線程、或者一個(gè)信號(hào)處理函數(shù)。所以請(qǐng)你不要對(duì)這個(gè)變量的訪問(wèn)做任何自以為是的優(yōu)化。”2. volatile 的核心作用對(duì)抗編譯器的“過(guò)度優(yōu)化”要理解volatile必須先理解現(xiàn)代編譯器為了提升性能會(huì)做哪些我們可能“看不見(jiàn)”的優(yōu)化。volatile的核心作用就是禁用針對(duì)特定變量的某些優(yōu)化策略確保程序行為符合開發(fā)者的直觀預(yù)期。2.1 編譯器優(yōu)化帶來(lái)的“副作用”假設(shè)我們有如下一段簡(jiǎn)單的 C 代碼int flag 0; void wait_for_event(void) { while (flag 0) { // 空循環(huán)等待flag變?yōu)? } // 事件已發(fā)生進(jìn)行處理 } void interrupt_handler(void) { flag 1; // 中斷發(fā)生時(shí)修改flag }在wait_for_event函數(shù)中編譯器開啟較高優(yōu)化等級(jí)如-O2的優(yōu)化器可能會(huì)進(jìn)行如下分析在while循環(huán)內(nèi)部沒(méi)有任何代碼修改flag的值。因此它認(rèn)為flag 0這個(gè)條件在循環(huán)期間永遠(yuǎn)為真或者永遠(yuǎn)為假取決于初始值。為了“優(yōu)化”性能編譯器可能生成兩種“錯(cuò)誤”的代碼將變量加載到寄存器后不再更新它把flag的值從內(nèi)存讀入某個(gè)CPU寄存器比如eax然后在循環(huán)中一直比較這個(gè)寄存器的值而不再去內(nèi)存中讀取flag的最新值。因?yàn)榫幾g器認(rèn)為內(nèi)存中的flag沒(méi)人改。直接優(yōu)化掉循環(huán)更激進(jìn)的情況下它可能直接判定這個(gè)循環(huán)是死循環(huán)或無(wú)意義的從而將整個(gè)while循環(huán)體刪除注意編譯器的優(yōu)化是“合法”的因?yàn)樗?C 語(yǔ)言的抽象機(jī)模型進(jìn)行分析。在這個(gè)模型里如果沒(méi)有volatile修飾編譯器就認(rèn)為當(dāng)前執(zhí)行流當(dāng)前線程/當(dāng)前函數(shù)是修改這個(gè)變量的唯一可能來(lái)源。2.2 volatile 如何解決問(wèn)題當(dāng)我們給flag加上volatile修飾后volatile int flag 0;這個(gè)聲明是在向編譯器發(fā)出一個(gè)強(qiáng)烈的“警告”“嘿聽(tīng)著這個(gè)flag變量是‘易變’的。它的值可能在任何時(shí)候、被任何你無(wú)法察覺(jué)的方式改變比如另一個(gè)線程、一個(gè)硬件中斷。所以請(qǐng)你嚴(yán)格遵守我的代碼每次我要讀它你必須老老實(shí)實(shí)地從內(nèi)存里讀每次我要寫它你必須立刻把它寫回內(nèi)存。不許用寄存器緩存它的值也不許隨意調(diào)整讀寫操作的順序”具體來(lái)說(shuō)volatile關(guān)鍵字主要確保了以下兩點(diǎn)禁止寄存器緩存強(qiáng)制每次訪問(wèn)變量讀或?qū)懚贾苯釉谄鋬?nèi)存地址上進(jìn)行保證能讀取到該變量在任何時(shí)刻、被任何代理修改后的最新值。禁止指令重排部分保證它會(huì)阻止編譯器為了優(yōu)化而對(duì)該變量相關(guān)的讀寫指令進(jìn)行重排序。但請(qǐng)注意這并不完全等同于多線程編程中的內(nèi)存屏障Memory Barrier這一點(diǎn)后面會(huì)詳細(xì)討論。2.3 一個(gè)必須使用 volatile 的經(jīng)典場(chǎng)景清單理解了原理我們來(lái)看看哪些地方必須、或者強(qiáng)烈建議使用volatile內(nèi)存映射的硬件寄存器在嵌入式系統(tǒng)中控制硬件如 GPIO 端口、狀態(tài)寄存器、數(shù)據(jù)緩沖區(qū)通常是通過(guò)訪問(wèn)一個(gè)特定的內(nèi)存地址來(lái)實(shí)現(xiàn)的。這個(gè)地址上的值會(huì)隨著硬件狀態(tài)改變而改變與程序執(zhí)行無(wú)關(guān)。例如#define PORT_A (*(volatile unsigned char *)0x40000000) void wait_for_button(void) { while ((PORT_A 0x01) 0) { // 等待按鈕按下位0變高 // 空循環(huán) } }這里的PORT_A必須聲明為volatile因?yàn)樗闹涤赏獠堪粹o硬件決定編譯器不能假設(shè)它在循環(huán)中不變。被多個(gè)線程共享的全局變量無(wú)鎖場(chǎng)景當(dāng)一個(gè)簡(jiǎn)單的標(biāo)志位或狀態(tài)變量被多個(gè)線程訪問(wèn)且沒(méi)有使用互斥鎖等同步機(jī)制時(shí)例如一個(gè)線程寫另一個(gè)線程讀的簡(jiǎn)單通知場(chǎng)景該變量應(yīng)聲明為volatile。這確保了讀線程能看到寫線程的最新修改。但務(wù)必注意這僅適用于非常簡(jiǎn)單的、原子性的數(shù)據(jù)交換對(duì)于非原子操作如i或復(fù)雜數(shù)據(jù)結(jié)構(gòu)volatile不能替代鎖或原子操作。被信號(hào)處理函數(shù)修改的全局變量在 Unix/Linux 系統(tǒng)中信號(hào)處理函數(shù)Signal Handler是異步執(zhí)行的它可能在任何時(shí)刻中斷主程序的執(zhí)行并修改某個(gè)全局變量。主程序需要感知到這個(gè)變化。#include signal.h #include stdio.h volatile sig_atomic_t g_shutdown_requested 0; void handle_signal(int sig) { g_shutdown_requested 1; } int main() { signal(SIGINT, handle_signal); // 注冊(cè)CtrlC信號(hào) while (!g_shutdown_requested) { // 主工作循環(huán) } printf(Shutting down gracefully...\n); return 0; }這里g_shutdown_requested必須為volatile否則編譯器可能將while循環(huán)中的檢查優(yōu)化掉。在“忙等待”循環(huán)中檢查的變量本文開頭的例子就是典型。一個(gè)循環(huán)空轉(zhuǎn)等待某個(gè)外部條件滿足而這個(gè)條件會(huì)被外部事件改變。3. volatile 的用法詳解與常見(jiàn)誤區(qū)知道了為什么用接下來(lái)看看怎么用以及如何避免用錯(cuò)。3.1 語(yǔ)法與聲明位置volatile是一個(gè)類型限定符Type Qualifier和const一樣。它可以放在類型之前或之后。volatile int v1; // 常見(jiàn)寫法 int volatile v2; // 同樣正確與上一行等價(jià) volatile uint8_t *pReg; // 指針指向一個(gè) volatile 的內(nèi)存位置 uint8_t * volatile pBuf; // 指針變量本身是 volatile 的指針值會(huì)變 volatile uint8_t * volatile pBoth; // 指針本身和它指向的內(nèi)容都是 volatile 的在結(jié)構(gòu)體或聯(lián)合體中可以修飾單個(gè)成員struct device { uint32_t id; volatile uint32_t status_reg; // 只有這個(gè)寄存器是易變的 uint32_t config; };3.2 volatile 不能做什么澄清重大誤解這是很多開發(fā)者尤其是剛接觸并發(fā)編程的開發(fā)者最容易栽跟頭的地方。volatile不是線程同步的銀彈。誤區(qū)一volatile 能保證原子性。錯(cuò)volatile不保證操作的原子性。像v_counter這樣的操作在底層通常是“讀-改-寫”三個(gè)步驟即使變量是volatile的兩個(gè)線程同時(shí)執(zhí)行此操作依然會(huì)導(dǎo)致數(shù)據(jù)競(jìng)爭(zhēng)Data Race和不確定的結(jié)果。原子性需要借助編譯器或操作系統(tǒng)提供的原子操作如 C11 的_Atomic C11 的std::atomic或互斥鎖來(lái)保證。誤區(qū)二volatile 能防止指令重排。不完全對(duì)volatile會(huì)限制編譯器的重排優(yōu)化即編譯器不會(huì)把對(duì)volatile變量的訪問(wèn)與其他volatile變量的訪問(wèn)隨意調(diào)換順序。但是它不能阻止 CPU 的亂序執(zhí)行Out-of-Order Execution?,F(xiàn)代 CPU 為了性能會(huì)在指令間沒(méi)有依賴關(guān)系時(shí)動(dòng)態(tài)調(diào)整指令執(zhí)行順序。在多核系統(tǒng)中一個(gè)核上的內(nèi)存操作順序在另一個(gè)核看來(lái)可能是亂序的。這需要內(nèi)存屏障Memory Barrier 或 Fence指令來(lái)保證。volatile不隱含內(nèi)存屏障語(yǔ)義。誤區(qū)三用 volatile 修飾所有共享變量就能線程安全。大錯(cuò)特錯(cuò)線程安全是一個(gè)系統(tǒng)工程涉及原子性、可見(jiàn)性、有序性。volatile只解決了“可見(jiàn)性”的一部分強(qiáng)制從內(nèi)存讀而不是寄存器緩存且不保證原子性和完整的有序性。對(duì)于復(fù)雜的共享數(shù)據(jù)必須使用鎖mutex、信號(hào)量、原子變量等正確的同步原語(yǔ)。實(shí)操心得一個(gè)簡(jiǎn)單的判斷準(zhǔn)則是如果你使用volatile是為了解決多線程共享數(shù)據(jù)的問(wèn)題那么99%的情況下你應(yīng)該首先考慮使用std::atomic(C) 或_Atomic(C11) 或互斥鎖。volatile的典型主場(chǎng)在硬件寄存器和信號(hào)處理場(chǎng)景。3.3 volatile 與 const 的結(jié)合兩者可以同時(shí)使用表達(dá)不同的約束const volatile int x; 這是一個(gè)“只讀的易變對(duì)象”。程序代碼不能修改xconst保證但x的值可能被外部代理改變volatile要求。這在描述一個(gè)只讀的硬件狀態(tài)寄存器時(shí)非常有用比如只讀的溫度傳感器寄存器。volatile int * const p; 一個(gè)常量指針指向一個(gè)易變的整型。指針p本身的值不能變但它指向的整型值會(huì)變。4. 多線程場(chǎng)景下的深入辨析volatile vs atomic vs mutex為了徹底厘清概念我們構(gòu)造一個(gè)場(chǎng)景兩個(gè)線程一個(gè)生產(chǎn)者遞增計(jì)數(shù)器一個(gè)消費(fèi)者讀取計(jì)數(shù)器。錯(cuò)誤示范僅用 volatilevolatile int counter 0; void* producer(void* arg) { for(int i0; i100000; i) counter; } void* consumer(void* arg) { while(counter 100000) { /* 消費(fèi) */ } }這段程序結(jié)果不可預(yù)測(cè)。counter不是原子的即使counter是volatile兩個(gè)線程的操作也會(huì)交織在一起導(dǎo)致最終值小于200000。正確方案一使用原子操作 - C11 / C11#include stdatomic.h _Atomic int counter 0; // C11 // 或 C: std::atomicint counter(0); void* producer(void* arg) { for(int i0; i100000; i) atomic_fetch_add(counter, 1); // 原子加 }原子操作保證了counter的原子性同時(shí)也默認(rèn)包含了必要的內(nèi)存順序約束通常是memory_order_seq_cst保證了修改對(duì)所有線程的可見(jiàn)性和一定的順序性。這是解決此類問(wèn)題最現(xiàn)代、最高效的方式之一。正確方案二使用互斥鎖pthread_mutex_t lock PTHREAD_MUTEX_INITIALIZER; int counter 0; // 這里甚至可以不用 volatile void* producer(void* arg) { for(int i0; i100000; i) { pthread_mutex_lock(lock); counter; pthread_mutex_unlock(lock); } }互斥鎖在保護(hù)臨界區(qū)lock與unlock之間時(shí)隱式地包含了內(nèi)存屏障確保了在鎖釋放前對(duì)所有共享變量的修改都能被后續(xù)獲得鎖的線程看到。鎖提供了最強(qiáng)的同步保障但性能開銷也最大。對(duì)比總結(jié)表特性volatile原子變量 (atomic)互斥鎖 (mutex)保證原子性否是(針對(duì)特定操作)是(針對(duì)臨界區(qū))保證可見(jiàn)性是(僅編譯器層面)是(包含CPU內(nèi)存屏障)是(包含CPU內(nèi)存屏障)保證順序性有限(僅編譯器重排)是(可指定內(nèi)存序)是(最強(qiáng)順序)性能開銷很低低到中 (取決于平臺(tái)和操作)高 (涉及系統(tǒng)調(diào)用)主要用途硬件寄存器、信號(hào)處理變量無(wú)鎖數(shù)據(jù)結(jié)構(gòu)、計(jì)數(shù)器、標(biāo)志位保護(hù)復(fù)雜共享數(shù)據(jù)結(jié)構(gòu)、臨界區(qū)重要提示在 C/C 多線程編程中volatile通常不是正確的工具。C11 標(biāo)準(zhǔn)甚至明確指出volatile的語(yǔ)義與多線程無(wú)關(guān)。除非你在編寫與特定編譯器擴(kuò)展或平臺(tái)細(xì)節(jié)緊密相關(guān)的底層代碼否則請(qǐng)優(yōu)先考慮std::atomic。5. 嵌入式開發(fā)中的實(shí)戰(zhàn)與避坑指南在嵌入式領(lǐng)域volatile的使用更為普遍和關(guān)鍵。這里分享幾個(gè)實(shí)戰(zhàn)細(xì)節(jié)和常見(jiàn)坑點(diǎn)。5.1 訪問(wèn)硬件寄存器的標(biāo)準(zhǔn)模式通常我們會(huì)用宏或指針常量來(lái)定義寄存器地址// 定義寄存器地址 #define RCC_AHB1ENR (*(volatile uint32_t *)0x40023830) #define GPIOA_MODER (*(volatile uint32_t *)0x40020000) #define GPIOA_ODR (*(volatile uint32_t *)0x40020014) // 使用使能GPIOA時(shí)鐘設(shè)置PA5為輸出然后拉高PA5 RCC_AHB1ENR | (1 0); // 訪問(wèn)易變的寄存器 GPIOA_MODER ~(3 10); // 先清位 GPIOA_MODER | (1 10); // 再置位設(shè)置為輸出模式 GPIOA_ODR | (1 5); // 設(shè)置PA5輸出高電平避坑點(diǎn)對(duì)于只寫寄存器Write-Only通常也聲明為volatile雖然讀它可能無(wú)意義或返回不確定值但volatile能防止編譯器優(yōu)化掉“看似無(wú)用”的寫操作。5.2 編譯器屏障與 volatile有時(shí)我們不僅需要防止編譯器優(yōu)化對(duì)某個(gè)變量的訪問(wèn)還需要防止編譯器將其他普通變量的訪問(wèn)重排到volatile訪問(wèn)之外。雖然volatile本身有一定順序約束但更保險(xiǎn)的做法是使用編譯器屏障Compiler Barrier。// GCC/Clang 中使用內(nèi)存屏障宏 #define COMPILER_BARRIER() asm volatile( ::: memory) void write_to_device(volatile device_reg_t *reg, uint32_t data, uint32_t addr) { uint32_t local_data process(data); // 確保 local_data 的計(jì)算和賦值在寫寄存器之前完成 COMPILER_BARRIER(); reg-address addr; COMPILER_BARRIER(); // 確保地址先寫入 reg-data local_data; // 再寫入數(shù)據(jù) }asm volatile( ::: memory)告訴 GCC/Clang 編譯器內(nèi)聯(lián)匯編代碼此處為空會(huì)讀寫內(nèi)存因此編譯器不能跨這個(gè)屏障對(duì)內(nèi)存操作進(jìn)行重排。這比單純依賴volatile更嚴(yán)格。5.3 調(diào)試與 volatile 的副作用在調(diào)試時(shí)如果懷疑是volatile相關(guān)問(wèn)題可以查看反匯編對(duì)比變量加volatile和不加時(shí)編譯器生成的匯編代碼。你會(huì)發(fā)現(xiàn)不加volatile時(shí)變量可能被優(yōu)化到寄存器中循環(huán)檢查可能被移除加上后每次訪問(wèn)都是內(nèi)存加載指令如LOAD。調(diào)整優(yōu)化等級(jí)在開發(fā)調(diào)試階段可以暫時(shí)使用低優(yōu)化等級(jí)如-O0這樣編譯器幾乎不做優(yōu)化volatile的問(wèn)題可能不會(huì)顯現(xiàn)。但務(wù)必記住在最終發(fā)布版本使用-O2或-Os中必須正確使用volatile。使用調(diào)試器觀察在調(diào)試器中觀察volatile變量的值確保它在預(yù)期的時(shí)間點(diǎn)發(fā)生變化。一個(gè)真實(shí)的坑我曾遇到一個(gè)驅(qū)動(dòng)在-O0下工作正常-O2下就失效。查了很久發(fā)現(xiàn)是一個(gè)本應(yīng)聲明為volatile的狀態(tài)寄存器指針被錯(cuò)誤地傳遞到了一個(gè)普通的非volatile函數(shù)參數(shù)中。編譯器在該函數(shù)內(nèi)對(duì)這個(gè)參數(shù)進(jìn)行了優(yōu)化。教訓(xùn)是volatile屬性在類型系統(tǒng)中必須嚴(yán)格傳遞不能丟失。6. 在不同語(yǔ)言和平臺(tái)上的差異volatile的語(yǔ)義基本一致但細(xì)節(jié)上略有不同C/C如本文所述是編譯器指令用于防止優(yōu)化不直接提供多線程語(yǔ)義。C11 后多線程同步應(yīng)使用atomic庫(kù)。Javavolatile的語(yǔ)義被大大加強(qiáng)。在 Java 中volatile變量保證了可見(jiàn)性一個(gè)線程的修改立即可見(jiàn)和一定的有序性禁止指令重排可以安全地用于多線程間的標(biāo)志位通信。但它仍然不保證復(fù)合操作的原子性如i。C#與 Java 類似volatile關(guān)鍵字提供了內(nèi)存可見(jiàn)性和禁止重排序的保證。通常使用Volatile.Read()和Volatile.Write()方法進(jìn)行更精細(xì)的控制。RustRust 沒(méi)有volatile關(guān)鍵字。它通過(guò)標(biāo)準(zhǔn)庫(kù)提供的core::ptr::read_volatile和core::ptr::write_volatile函數(shù)來(lái)執(zhí)行易失性讀寫操作更加顯式和安全。7. 總結(jié)與最終建議回顧開頭的那個(gè) Bug其根源在于編譯器基于單線程模型做了合理的、卻不符合多代理并發(fā)場(chǎng)景的優(yōu)化。volatile就是我們?cè)?C/C 語(yǔ)言層面用來(lái)打破編譯器這個(gè)假設(shè)與外部世界硬件、其他線程、信號(hào)進(jìn)行正確通信的工具。給開發(fā)者的最終建議明確用途問(wèn)自己這個(gè)變量是否會(huì)被當(dāng)前執(zhí)行流之外的力量改變?nèi)绻怯布拇嫫?、信?hào)處理函數(shù)變量、或簡(jiǎn)單的無(wú)鎖多線程標(biāo)志考慮volatile。如果是復(fù)雜的多線程數(shù)據(jù)共享直接上鎖或原子變量。慎用于多線程在 C/C 中除非你非常清楚自己在做什么并且有明確的平臺(tái)/編譯器文檔支持否則不要依賴volatile進(jìn)行線程同步。std::atomic是更安全、更現(xiàn)代的選擇。嵌入式必備在嵌入式系統(tǒng)編程中訪問(wèn)硬件寄存器或與中斷服務(wù)程序共享的全局變量幾乎總是需要volatile。將其作為編碼規(guī)范的一部分。代碼審查點(diǎn)在代碼審查時(shí)看到共享的全局變量特別是用在循環(huán)條件或狀態(tài)檢查中的要下意識(shí)地問(wèn)一句“這個(gè)變量需要volatile嗎” 這能提前發(fā)現(xiàn)許多隱蔽的并發(fā) Bug。理解volatile不僅僅是記住一個(gè)關(guān)鍵字更是理解程序運(yùn)行時(shí)環(huán)境與編譯器優(yōu)化之間微妙關(guān)系的一扇窗。它提醒我們我們寫的代碼并非直接控制硬件而是通過(guò)編譯器和運(yùn)行時(shí)系統(tǒng)這個(gè)“翻譯官”來(lái)與機(jī)器對(duì)話。volatile就是我們給這個(gè)“翻譯官”的一條特別指令確保它準(zhǔn)確地傳達(dá)了我們的意圖尤其是在那些“嘈雜”的、存在異步事件的環(huán)境中。