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

重置、crt0 與連結腳本

第一篇留給你一塊裸板、沒有作業系統,還有一顆直接執行你程式碼的晶片。現在跟著看看,從電源到來那一刻、到你的 main() 開始跑那一刻之間,究竟發生了什麼——重置向量、那段親手搭起 C 世界的微小啟動常式,以及決定每個位元組住在哪裡的連結腳本。

第一道指令:晶片到底從哪裡開始?

從第一篇你已經接受了裸機程式設計這個奇怪的前提:你底下沒有作業系統、沒有讀取 ELF 檔並把東西安排好的載入器、也沒有某個執行期客氣地替你呼叫的 main()。微控制器只是通電、然後開始執行指令。所以最初的問題殘酷地具體:當電壓穩定下來,CPU 第一個抓取的是記憶體裡哪一個位元組?晶片無法用猜的。答案被固定在矽晶片裡:核心讀取一個已知、硬接線的位址,並把它在那裡找到的東西當成起點。

在一顆 Cortex-M 晶片上——這一階用來貫穿全篇的範例家族——那個硬接線的位置就在位址映射的最底部。通電時核心從快閃記憶體的起點讀取兩個 32 位元的字:位址 0x00000000 處的字是初始堆疊指標,而 0x00000004 處的字是重置向量——也就是要執行的第一道指令的位址。所以在你程式的任何一行執行之前,硬體已經載入了堆疊指標、並跳到你存在第二個欄位裡的那個位址。依慣例,那個目標是一段通常叫做 Reset_Handler 的常式。

crt0:搭起 C 世界的微小程式

這裡是讓從託管環境寫程式的人吃驚的部分。當你在 Linux 上寫一支普通程式,你理所當然地認為:等 main() 開始跑時,你的全域變數早已持有它們的初始值、你那些初始化為零的全域變數確實是零、堆疊也準備好了。在桌面上,C 執行期啟動程式碼——歷史上那個叫做 crt0 的目的檔——在 main() 之前默默地做了那份工作。在裸機上,沒有人會替你做。所以(或你工具鏈那段微小的啟動程式碼)必須親手搭起整個 C 執行期環境,而重置處理常式正是這件事發生的地方。

為什麼會有工作要做?因為東西住在哪裡。你的程式待在快閃記憶體裡,它是非揮發的、執行期唯讀;你的變數則住在 RAM 裡,它快速、可寫,而且在通電時完全未初始化——裡頭裝著垃圾。一個像 `int counter = 7;` 的全域變數有個難題:值 7 必須來自某個持久的地方(快閃),但 counter 本身最終必須落在可寫的 RAM 裡,程式才能改它。編譯器可以把所有這類變數的初始映像存在快閃裡,卻無法把它們擺進 RAM,因為只有快閃能熬過斷電。彌合那道鴻溝,正是啟動程式碼第一件真正的工作。

所以重置處理常式在膽敢呼叫 main() 之前,會做三件具體的雜活。第一,它把所有已初始化全域變數的初始值——也就是 .data 區——一個字一個字地,從快閃裡的映像複製到它們在 RAM 裡的最終歸宿。第二,它把 .bss 區清零,那裡裝著每一個 C 標準說從零開始的全域變數(像沒有初始值的 `int total;`);標準保證這些是零,而在裸機上,那個保證之所以為真,正是因為啟動程式碼寫了零讓它為真。第三,它可能會設好堆疊與任何函式庫狀態。直到此刻它才呼叫 main()。略過 .data 複製,你的 counter 會讀到垃圾;略過 .bss 清零,一個你以為是 0 的變數,就會是通電時 RAM 裡剛好裝著的隨便什麼垃圾——一個藏到那一次未初始化讀取咬你才現身的臭蟲。

記憶體映射:誰決定快閃與 RAM 在哪裡?

啟動程式碼一直說「從快閃複製到 RAM」,這帶出一個明顯的問題:它怎麼知道快閃與 RAM、.data 映像、.bss 區的位址?在託管系統上那些位址是作業系統在載入期選的。在裸機上它們沒得商量——它們是關於你那顆特定晶片的物理事實,寫在它的資料手冊裡。這就是 MCU 記憶體映射:單一扁平的 32 位元位址空間被切成固定的範圍,快閃也許從 0x08000000 起、RAM 也許從 0x20000000 開始,而周邊暫存器住在更高處它們自己的範圍。同一個位址空間的不同範圍,觸及不同的物理硬體。

把它想成一架高高的位址梯子。最底部坐著向量表;快閃從約 0x08000000 開始,依序裝著向量表(.isr_vector)、你的程式碼(.text)、你的唯讀 const 資料(.rodata),以及 .data 的快閃映像;RAM 從約 0x20000000 開始,裝著活的 .data、接著 .bss、接著向上長的堆積,而堆疊則從 RAM 頂端往下長;再高一點、約 0x40000000 處,坐著你會在第四篇讀寫的周邊暫存器。關鍵的微妙之處在於 .data 出現了兩次:一份在快閃的永久映像,與一份在 RAM 的活複製。你剛剛遇見的啟動複製,正是這兩個歸宿之間的橋。

畫出這張映射,也曝露了一個你無法一廂情願抹去的限制:快閃與 RAM 是有限而且很小的。一顆中等的晶片也許有 256 KiB 快閃與 64 KiB RAM——不是好幾 MiB,更不是好幾 GiB。你的程式碼加上所有 const 資料、再加上 .data 映像,全部必須塞進快閃;你的堆疊加堆積、加上所有活的變數,必須同時塞進 RAM,沒有虛擬記憶體來掩飾不足,也沒有作業系統在你溢出時客氣地把你結束掉。跑過 RAM 的盡頭,你不會得到區段錯誤——沒有東西會抓住它——你會默默地把隔壁的東西弄壞。所以某個東西必須把每一塊擺進它確切的範圍、並驗證它塞得下。那個東西就是連結腳本。

連結腳本:記憶體的平面圖

你在連結器與載入器那一階見過連結器,它是那個解析符號、修補位址的工具。在託管系統上它用一份內建的預設版面,你從來不必去想。在裸機上你必須親手交給它一份明確的平面圖,而那份圖就是連結腳本(通常是一個 .ld 檔)。它做兩件事。第一,MEMORY:它宣告你晶片實際的區域——名稱、起始位址、大小——直接照著資料手冊抄,例如一個位在 0x08000000、長度 256K 的 FLASH 區域,與一個位在 0x20000000、長度 64K 的 RAM 區域。第二,SECTIONS:它說哪個輸出節區放進哪個區域,以及以什麼順序。

連結腳本也是啟動程式碼與版面握手的地方,而這是令人滿足的部分。記得重置處理常式需要確切知道:.data 映像在快閃裡從哪裡開始、.data 必須落在 RAM 的哪裡、.bss 又從哪裡開始到哪裡結束——但那些位址是版面的輸出,不是 C 程式設計師事先能知道的東西。連結腳本靠在相關邊界定義符號來解決這件事:它把一個像 _sdata 的符號放在 RAM 裡 .data 的起點、_edata 放在它的終點、_sidata 放在快閃裡的 .data 映像、_sbss 與 _ebss 放在 .bss 區的兩端。啟動程式碼接著用名字去引用那些符號,而連結器替它們填進真正的位址。腳本畫出平面圖;啟動程式碼從圖上讀出尺寸。

/* sketch of an embedded linker script (.ld) */
MEMORY {
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 256K
  RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}

SECTIONS {
  .isr_vector : { *(.isr_vector) } > FLASH     /* vector table first */
  .text       : { *(.text*) *(.rodata*) } > FLASH

  _sidata = LOADADDR(.data);          /* .data image, in FLASH   */
  .data : {
    _sdata = .;                       /* start of .data in RAM   */
    *(.data*)
    _edata = .;                       /* end of .data in RAM     */
  } > RAM AT> FLASH                    /* lives in RAM, stored in FLASH */

  .bss : {
    _sbss = .;
    *(.bss*) *(COMMON)
    _ebss = .;
  } > RAM
}
連結腳本的兩半:MEMORY 為晶片真實的區域命名;SECTIONS 擺放程式碼與資料、並定義邊界符號(_sidata、_sdata、_edata、_sbss、_ebss),啟動程式碼就在這些符號之間進行複製與清零。

把它組起來:從通電到 main(),一步一步來

現在整條鏈都看得見了,值得從頭到尾走一遍,因為這些零件只有當成一場接力來看才講得通。連結腳本決定了位址;重置向量指向啟動程式碼;啟動程式碼用連結器定義的符號搭起 C 世界;直到此刻你的 main() 才在一個終於符合 C 在別處處處假設的環境裡執行。以下就是這場接力,一棒接一棒。

  1. 通電。核心從映射底部讀取兩個字:初始堆疊指標(0x00000000)與重置向量(0x00000004)。它載入堆疊指標、跳到重置向量的目標——Reset_Handler。你的程式碼還一行都沒跑。
  2. 複製 .data。處理常式讀取 _sidata、_sdata、_edata(全是連結腳本定義的位址),把已初始化全域變數的映像一個字一個字地從快閃複製到 RAM,好讓一個像 counter = 7 的全域變數真的讀到 7。
  3. 清零 .bss。用 _sbss 與 _ebss,處理常式在整個 .bss 區寫滿零,讓 C 標準那句「未初始化的靜態變數從零開始」的保證,在這個硬體上真的成立。
  4. 選擇性的設定。初始化任何 C 函式庫狀態、設好時脈,並(在某些工具鏈上)執行 C++ 全域物件的建構式。環境現在完全搭好了。
  5. 呼叫 main()。處理常式終於呼叫你的 main()。在裸機上 main() 絕不該回返——沒有殼層可以回去——所以它最後通常是一個無窮迴圈,而萬一它真的掉了出來,啟動程式碼也會困在一個迴圈裡。