Day 14 跟 Day 17 介紹了「中斷」與「多執行緒」,其實就是為本篇的內容:volatile 鋪陳。
volatile 關鍵字的意義為「這個變數可能隨時被外部改變,不要對其做最佳化」,也就是告訴編譯器不要只讀取暫存器上的快取值。
volatile 的使用時機那什麼時候會不希望編譯器只讀快取值呢?至少有下列三種情況:
首先是與前幾篇文章無關的「硬體暫存器」,也就是會直接操作到硬體的暫存器,簡單範例如下:
#include <stdio.h>
volatile int status = 0; // 用簡單的 int 變數模擬硬體暫存器
void hardware_action() {
// 模擬硬體把 status 設為 1
status = 1;
}
int main() {
// 呼叫 hardware_action() 函數
// 把「硬體暫存器」的 status 改成 1
hardware_action();
// 持續檢查 status,直到變成 1
// 如果一開始 status 沒有寫 volatile,則可能只讀到 CPU 暫存器上的舊值 0
// 而沒有讀到硬體透過 hardware_action() 把硬體暫存器的 status 改成 1
// 造成跳不出這個迴圈,一直等待
while (status == 0) {
// 什麼都不做,單純等待
}
// status 不是 0 之後才有辦法執行這裡
printf("Do something\n");
return 0;
}
硬體暫存器的值很容易被硬體改變,例如本例中的 status,如果沒有加上 volatile,則即便硬體改變 status 的值,也不會被編譯器注意到,因為在經過優化後,編譯器讀的是存在 CPU 暫存器的舊值,而非硬體暫存器的新 status 值。
註:使用 volatile int status = 0; 時,其實變數是存在主記憶體,真正的硬體暫存器會用如以下的方法定義:
#define STATUS_REG (*(volatile int*)0x40011000)
這樣就可以透過 STATUS_REG 這巨集名字,直接操作 0x40011000 這個硬體暫存器位址中的值。
另一種使用情境是 Day 14 提到的「中斷服務常式(Interrupt Service Routine, ISR)」,就拿該篇文章的例子來說:
#define PI 3.14159
// 旗標 flag 同時被主程式 main() 與 ISR 存取
// 如果沒有 volatile,編譯器可能只會讀取放在 CPU 暫存器的舊值
// 就算 ISR 把 flag 的值改變,主程式也讀不到
volatile int flag = 0;
__interrupt void my_isr(void) {
flag = 1; // ISR 修改旗標,從舊值 0 改成新值 1
}
int main() {
while (1) {
if (flag) { // 主程式根據旗標的值,判斷是否進入 if 區塊
double area = PI * 2.0 * 2.0;
printf("Area = %f\n", area);
flag = 0;
}
}
return 0;
}
主程式 main() 透過旗標 flag 決定執行內容,而 flag 的值會被 ISR my_isr() 更改,如果 flag 沒有寫上 volatile,則編譯器可能只會讀取到暫存的舊值 0,就算 ISR 把旗標改成 1 也不會被編譯器發現。
在多執行緒工作中,當不同執行緒共用變數時,也要加上 volatile。
首先來看沒有 volatile 可能會產生的問題:
#include <iostream>
#include <thread>
int flag = 0; // 沒有 volatile
// 新執行緒會執行的函數,把 flag 變成 1
void setFlag() {
flag = 1;
}
int main() {
// 新執行緒,會把 flag 變成 1
std::thread t(setFlag);
// 主執行緒一直在等 flag 變成不是 0
while (flag == 0) {
// 什麼都不做
}
t.join();
std::cout << "flag = " << flag << std::endl;
return 0;
}
主執行緒 main() 會根據旗標 flag 的值是否為 0 決定是否跳出 while() 迴圈,而 flag 的值被新執行緒 std::thread t(setFlag); 改變。
然而,在沒有寫 volatile 的情況下,即便新執行緒把 flag 改成 1,編譯器可能還是只讀取到放在 CPU 暫存器上的舊 flag 值 0,導致跳不出 while() 迴圈,該迴圈以下的程式碼都將不會執行。
而如果在 flag 加上 volatile 如下:
#include <iostream>
#include <thread>
volatile int flag = 0; // 有 volatile
// 新執行緒會執行的函數,把 flag 變成 1
void setFlag() {
flag = 1;
}
int main() {
// 新執行緒,會把 flag 變成 1
std::thread t(setFlag);
// 主執行緒等 flag 變成不是 0 後跳出此 while 迴圈
while (flag == 0) {
// 什麼都不做
}
t.join();
std::cout << "flag = " << flag << std::endl;
return 0;
}
這裡的 flag 有綁定 volatile,表示編譯器不會只讀取放在 CPU 暫存器的舊 flag 值,新執行緒把 flag 改成 1 後也能被編譯器發現,如此才保證能讓主執行緒正常跳出 while() 迴圈,執行後面的工作。