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

裸機:在沒有作業系統下寫程式

拿走作業系統、殼層、載入器,連 main() 那套舒適的前置設定也拿走——剩下的,就只有你、一顆晶片,和它的記憶體。這篇是通往那個世界的門:「裸機」到底是什麼意思、有什麼東西消失了、又有什麼東西得你自己親手建起來。

你一直踩著的一切都不見了

這整門課裡,你的腳下一直有一塊安靜的地板。當你執行「$ ./a.out」時,一個作業系統接收你的請求,載入器把你的可執行檔映射進記憶體,C 執行期啟動程式碼設好堆疊、把你的全域變數清零,然後輪到你的 main() 開始。每一次 malloc()、每一次 open()、每一個系統呼叫,都越過一道牆進到核心裡,由它替你管理硬體。裸機程式設計(bare-metal programming)就是把以上這一切都拿走之後發生的事。沒有作業系統。你的程式碼是唯一的程式碼。晶片一通電,就直接執行你的程式,底下什麼都沒有。

這不是一個思想實驗——它是地球上大多數電腦實際運作的方式。恆溫器裡的晶片、無人機的馬達控制器、心律調節器、汽車的安全氣囊感測器、USB 充電器:這些都是微控制器,而其中絕大多數跑的是裸機、或一個極小的排程器,從來不是完整的作業系統。一顆典型的微控制器(MCU)也許有 64 KiB 的 RAM 和 256 KiB 的快閃記憶體——比一台筆電少一千倍的記憶體。那裡沒有 Linux 的容身之地,也不需要它。MCU 的整個任務,就是在通電的那一瞬間開始、永遠地執行那唯一一個程式。你接下來要學的,就是寫那個程式。

一個平坦的位址空間,而硬體就住在裡面

在筆電上你見過虛擬記憶體:每個行程都看到自己私有的位址空間,那是 MMU 維持的一個錯覺,所以虛擬位址並不是真正的實體位址。把這個也丟掉。裸機 MCU 通常沒有 MMU。只有一個平坦的、實體的位址空間,而像 0x20000000 這樣的一個位址,就精確地對應到矽片上某一個真實的位置——永遠如此,對你程式的每一個部分都一樣。這就是很早那幾級裡的記憶體即位元組陣列圖像,被做成了字面意義:整台機器就是一個編了號的位元組陣列,而你用一個指標就能觸及其中任何一個。

現在來到第一次接觸時會覺得很怪的部分。在這個單一的位址空間裡,並不是每個位址都是記憶體。晶片的設計者把週邊裝置——計時器、序列埠、你可以切換的接腳——接到了特定的位址上,讓它們對那些位址做出反應。對位址 0x40020014 寫入,也許不會把任何值存到任何地方;它也許是把某根實體接腳上的電壓設成高電位。這就是微控制器記憶體映射(MCU memory map):一張固定的佈局圖,其中某些位址範圍是 RAM、某些是存放你程式碼的快閃記憶體,而一大片一大片根本不是記憶體,而是硬體的控制面板。下一篇會把這張圖講精確;現在,先抓住這句重點——在裸機上,一個指標可以指向一條電線。

和硬體對話:暫存器與 volatile

那些硬體控制面板是由週邊暫存器構成的——固定的、被映射到記憶體的位置,其中每一個位元都代表某件實體的事。要點亮一顆 LED,你不會去呼叫一個函式庫;你會在晶片的資料手冊裡找出正確暫存器的位址,做一個指向它的指標,然後寫入一個位元。和硬體的整段對話,就是在已知位址上讀寫數字,用的正是你很久以前學的那些位元運算子位元遮罩。像「GPIOA->ODR |= (1 << 5);」這樣的一行,真正的意思是「把 A 埠的輸出暫存器的第 5 位元設為 1」,再進一步真正的意思是「把接腳 A5 拉到高電位」。當抽象的 C 第一次讓一盞真實的燈亮起來時,那種滿足感是很深的。

但這裡潛伏著一個幾乎每個初學者都會被咬一口的陷阱,值得放慢腳步看清楚。假設你寫一個迴圈,一遍又一遍地讀某個狀態暫存器,等一個硬體旗標從 0 翻成 1。你的編譯器套用彷彿規則(as-if rule)這樣推理:「這個位址在迴圈裡從沒被寫過,所以它的值不會變——我讀一次、重複用就好。」按照普通 C 的規則,編譯器是對的,而你的迴圈會永遠空轉,因為編譯器並不知道有一條外部的電線會改變那個位置。解法是 volatile 修飾詞:把指標宣告為「指向 volatile 資料的指標」,等於告訴編譯器「這東西會在你背後改變——每一次都從真正的位址重新讀取,絕不快取、重排或消去這個存取」。

/* WRONG: compiler may read READY once and spin forever */
unsigned int *status = (unsigned int *)0x40004404;
while ((*status & 0x1) == 0) { /* wait for READY bit */ }

/* RIGHT: volatile forces a real read of the address each pass */
volatile unsigned int *status = (volatile unsigned int *)0x40004404;
while ((*status & 0x1) == 0) { /* now actually polls the hardware */ }
經典的 volatile 臭蟲。一樣的邏輯、一樣的迴圈——唯一的差別是一個關鍵字,它決定了這個存取能不能被最佳化掉。忘了它,會做出在 -O0 能動、在 -O2 卻當住的程式碼,那是最殘忍的一種意外。

誰來執行 main()?第一條指令的故事

在一個有主機作業系統的系統上,你從不需要好奇你的程式是怎麼開始的——作業系統處理了。裸機把這個問題逼到檯面上:當晶片通電時,CPU 執行的第一條指令到底是什麼?在一顆小小的 ARM Cortex-M MCU 上,硬體做的事漂亮地簡單。在重置時,它從快閃記憶體底端一個固定的地方讀兩個字(word):第一個字是要載入堆疊指標的初始值,第二個字是重置向量(reset vector)——也就是第一個要執行的函式的位址。CPU 載入堆疊指標、跳到那個位址,而你的程式碼現在掌管著整台機器。沒有載入器、沒有作業系統、沒有任何儀式。

但重置向量並不是直接跳到 main()——它不能,因為 main() 期待一個還不存在的世界。C 語言假設全域變數已被初始化、而未初始化的全域變數是零。是誰做這件事?在有主機作業系統的系統上是執行期做的;在這裡得你做。所以重置向量指向的是啟動程式碼(startup code,歷史上稱為 crt0),它把已初始化的全域變數從快閃記憶體複製進 RAM、把未初始化全域變數所在的 .bss 區段清零、如果你有堆積就把堆積設好,然後呼叫 main()。重置向量啟動程式碼兩者合起來,就替代了你過去視為理所當然的那整套作業系統與載入器機制——而它們正是接下來兩篇的主題。

當程式沒有終點:超級迴圈、中斷,以及前方的路

還有一個心態上的習慣必須改變。一個有主機作業系統的程式會執行、做完它的工作、然後從 main() 回傳——它會結束,作業系統再回收它的資源。一個裸機程式則絕不能結束。沒有任何地方好回去;如果 main() 回傳了,CPU 就會掉下邊緣、落入未定義的領域。所以一個嵌入式程式的 main() 幾乎總是以一個無窮迴圈收尾,也就是超級迴圈(super-loop):讀感測器、做決定、驅動輸出、重複、永遠如此。這是可能存在的最簡單的排程器——一個任務,從頭跑到尾,一遍又一遍——而對於數量龐大的裝置來說,這真的就是你需要的全部。

但純粹的超級迴圈有一個弱點:它只能在輪到的時候才去檢查事情。如果按鈕在迴圈正忙著做別的事時被按下,它可能就被錯過了。答案是中斷:硬體本身可以在某件事發生的那一瞬間暫停你的迴圈、跳到一個小小的處理常式、處理它、然後回到你原本所在的確切位置繼續。CPU 透過一張中斷向量表找到正確的處理常式——那是一份處理常式位址的清單,每個事件來源各一個——而每個處理常式就是一個中斷服務常式(ISR)。這和作業系統那幾級裡的中斷是同一個想法,只是現在你和硬體之間沒有核心了:來寫那些 ISR,而它們執行時,要麼是你的全副注意,要麼就沒人理。

這就是用一口氣講完的整個一級。我們從這個裸機心態出發;下一篇畫出精確的記憶體映射、以及讓一顆晶片活過來的「重置/啟動/連結器腳本」三人組;接著是讓它能夠反應的向量表、ISR、與中斷優先權;接著是完整嚴謹地講記憶體映射暫存器與 volatile;最後,當一個超級迴圈不再夠用時,登場的是一個帶有任務、即時排程、與決定性這種硬保證的即時作業系統。後面每一篇都假設你現在握著的這幅圖像:一顆晶片、一個其中住著硬體的平坦位址空間、你的程式碼是唯一的程式碼,從第一條指令開始、永遠地執行下去。歡迎來到裸機。