JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

記憶體映射暫存器與正確使用 volatile

在裸機微控制器上,你和矽晶之間沒有一層驅動程式——你閃一顆 LED,靠的是把一個數字寫進一個位址。我們學怎麼誠實地搆到一個週邊暫存器、為什麼少了一個 volatile 會讓一個看起來正確的迴圈永遠卡死,以及 volatile 究竟承諾了什麼、又沒承諾什麼。

這底下沒有 API——只有位址

在一台有作業系統托管的機器上,點亮一盞燈意味著 open()、write()、一個核心、一個驅動程式。在一台裸機微控制器上,這些鷹架一概沒有:你的程式碼就是整個程式,而影響外部世界的唯一辦法,是在特定的位址讀寫數字。你在這一級第一篇遇過記憶體映射——那條長街,有些門牌號是 RAM、有些是快閃記憶體,還有些根本不是記憶體,而是週邊的控制面板:GPIO 區塊、一個計時器、一個序列埠。要點亮一顆 LED,你把一個位元樣式儲存到晶片參考手冊指派給某個 GPIO 輸出暫存器的那個位址。那次儲存從不落在 RAM 裡;它行進到矽晶上,於是一支接腳變高。

這些控制面板上的每一個格子,都是一個週邊暫存器。一個週邊暫存器不過是一個固定的位址——在這些晶片上通常 32 位元寬——其中每個位元都有手冊記載、硬體會據以動作的意義。某個暫存器的第 5 位元可能啟用計時器;另一個的低位元組可能存著你正要從序列線送出的值;讀第三個則告訴你一個位元組是否已傳完。手冊對每一個暫存器給你它的位址(常以一個基底加一個偏移量表示,例如 0x40020000 + 0x14)和一張說明各位元用途的表。你大半時候的全部工作,就是把那張表翻譯成正確的遮罩

你怎麼從 C 搆到一個位址?你讓一個指標持有那個確切的數字,然後解參考它。把暫存器的位址寫成一個整數常數、轉型成正確寬度的指標,於是對 `*p` 的載入或儲存就成了對矽晶的載入或儲存。這個轉型第一次看起來嚇人——你正從一個字面量裡變出一個指標——但它在這裡恰恰是正確的:那個整數真的就是某個真實之物的位址。這和上一級核心驅動程式透過 MMIO 輔助函式所用的把戲是同一個;裸機只是把它攤在明面上做,底下沒有藏任何東西。

// GPIOA on a typical Cortex-M part (addresses from the reference manual)
#define GPIOA_BASE   0x40020000u
#define GPIOA_ODR    (*(volatile uint32_t *)(GPIOA_BASE + 0x14))  // output data
#define GPIOA_IDR    (*(volatile uint32_t *)(GPIOA_BASE + 0x10))  // input data

GPIOA_ODR |=  (1u << 5);   // drive pin 5 high  -> LED on
GPIOA_ODR &= ~(1u << 5);   // drive pin 5 low   -> LED off
uint32_t pins = GPIOA_IDR;  // read all 16 input pins at once
一個暫存器就是一個你把指標轉型過去的位址;volatile(下一節)才是這段為什麼正確、而不只是能編譯的原因。

卡死的迴圈:為什麼 volatile 不是可有可無

這就是那個幾乎每個人都會被伏擊一次的臭蟲。你想等到序列週邊回報它就緒,於是你在一個迴圈裡讀它的狀態暫存器、直到某個旗標位元出現。不帶 volatile 寫出來,這段 C 是 `while ((*status & READY) == 0) { }`。對編譯器而言 `*status` 是一次普通的記憶體讀取,而它依自己的規則正確地推論:這個迴圈裡沒有任何東西寫 `status`,所以它的值在各次反覆之間不可能改變——讀一次、把結果留在一個暫存器裡、就對著那份快取的副本繞圈。真實矽晶裡那個旗標一微秒後翻轉了,但編譯器已經不再看了。迴圈對著一個過時的值永遠空轉,於是你的韌體卡死。更糟的是,它在 -O0 下可能會動*——那時編譯器每次都重讀——只在 -O2 下卡死,這是最令人喪氣的那種臭蟲。

解法是一個關鍵字。volatile 修飾詞告訴編譯器:這個位置可能以你看不見的方式改變,所以別假設、別快取、別跳過。有了 `volatile uint32_t *status`,原始碼裡每一次讀 `*status` 都成為一次對該位址的真正載入、每一次寫都成為一次真正的儲存——一個都不省略、一個都不憑空捏造,而且對單一一個 volatile 物件的那些存取彼此之間保持程式順序。volatile 不是一個效能提示、根本不是任何提示;它是你就一個其值由你程式之外某物所支配的位置,向編譯器說出真相。對一個週邊暫存器而言,那個「某物」就是硬體,而真相是:什麼都別假設。

讀取、修改、寫回——又不殃及鄰居

一個暫存器通常把許多互不相干的控制塞進它的 32 個位元裡。一個 GPIO 埠的輸出暫存器裝著全部十六支接腳;一個計時器的設定暫存器把啟用位元、方向、時脈除頻數一個挨一個地裝在一起。所以要改一支接腳,你絕不能覆寫整個暫存器——你必須改你那個位元、把其餘的原封不動留著。這就是讀取—修改—寫回的樣式,而位元運算子正是你表達它的方式:讀出當前的值、把你想設起的位元 OR 進去、把你想清掉的位元的補數 AND 進來,再把結果寫回去。

  1. 把暫存器讀一次到一個區域變數:uint32_t v = REG;(這是一次真正的 volatile 載入——硬體當前的狀態)。
  2. 先清掉你即將設定的那些位元,免得舊值殘留:v &= ~(mask);
  3. 設上新的位元:v |= (new_value << shift);——用一個位移把這個欄位落到正確的位置。
  4. 用一次儲存把整個字寫回去:REG = v;(一次真正的 volatile 儲存——你在第一步讀到的鄰居被保留了下來)。

對這個樣式有兩個誠實的提醒,因為它比看起來更精細。第一,有些暫存器根本不像記憶體那樣行事。少數是「寫 1 來清除」:要確認一個中斷旗標,你往那個位元寫 1,寫 0 什麼也不做——所以讀取—修改—寫回那套舞步對它們是錯的,你該寫一個只有那個位元被設起的單純遮罩。其他的可能是唯讀的、或在你一讀的瞬間就自我清除。手冊裡的位元配置就是法律;沒有一條通則適合每一個暫存器,而假設「它就是記憶體罷了」正是那些隱微臭蟲誕生的方式。第二,這個讀取—修改—寫回是三個操作、不是一個——如果一個中斷服務常式在你的讀取與寫回之間碰了同一個暫存器,你的寫回可能踩掉那個 ISR 的更動。我們下一節就正面碰上那個危害。

volatile 沒有承諾的事

這部分是讓一個人成為謹慎的嵌入式程式設計者、而非僥倖者的關鍵。volatile 替你買到恰恰三樣東西、一樣都不多:原始碼裡每一次存取都真的發生、那些存取不被最佳化掉、以及對同一個 volatile 物件的存取彼此之間保持順序。它讓一次存取成為不可分割的、它為一次 volatile 存取相對於一次普通的非 volatile 存取排序、它也豎起一道硬體記憶體屏障。人們伸手去拿 volatile,指望它讓共享旗標的程式碼變安全,而它做不到。

再拿讀取—修改—寫回來說。對一個 volatile 暫存器做 `REG |= bit;` 不是不可分割的——它編譯成一次載入、一次 OR、一次儲存。如果一個 ISR 在那個縫隙裡觸發、改了 REG,你的儲存會寫回一個在 ISR 跑之前算出的值,無聲地抹掉它的工作。volatile 忠實地讓三次存取都真的發生;它從沒聲稱要把它們熔成一個。解藥不是更多的 volatile,而是適合單核心的互斥:在這段序列前後短暫地停用相關的中斷,或在晶片提供時用一個硬體的位元設定/位元清除暫存器(許多 Cortex-M 零件正是為此攤開不可分割的設定/重置暫存器,好讓你翻一支接腳時完全不必讀取—修改—寫回)。

把它組裝起來:一次你可以信賴的暫存器存取

讓我們把所有東西組裝成一位老練的嵌入式程式設計者幾乎不假思索就會做的那套小儀式。把參考手冊翻到那個週邊。找出暫存器的基底與偏移量、它的寬度、以及每個位元的意義——包括是否有任何位元是「寫 1 清除」或唯讀的。定義一個帶 volatile 修飾的存取巨集,好讓這個修飾詞永遠不會被忘記。要改一個欄位,就用遮罩與位移做讀取—修改—寫回;要在一顆有不可分割設定/重置暫存器的晶片上改單一一個位元,就優先用它們、完全跳過讀取。在一個 ISR 共用某暫存器之處,靠短暫地遮罩那個中斷來保護讀取—修改—寫回。而在跨週邊的順序重要之處,放一道明確的屏障進去——volatile 不會替你做那件工作。

退一步,注意這一級的概念疊得多乾淨。第一篇的記憶體映射給了我們那些位址;週邊暫存器是那張映射上的一個格子;volatile 讓我們對那個格子的 C 存取變誠實;位元遮罩讓我們碰一個欄位而不擾動它的鄰居;而第三篇的 ISRNVIC 則是那個非同步的另一方,正是它的存在逼我們去想不可分割性。你現在能正確地閃一顆 LED、送一個位元組、輪詢一個狀態旗標——不是靠運氣,而是因為你知道每一次存取究竟編譯成什麼。這一級最後一篇從這套戳暫存器之上升起,問怎麼同時跑好幾個有時序的活動而不落入一堆手寫狀態機的糾纏:一個即時作業系統、排程,以及決定性。