作 者:道哥,10+年的嵌入式開發老兵。
公眾號:【IOT物聯網小鎮】,專注於:C/C++、Linux作業系統、應用程式設計、物聯網、微控制器和嵌入式開發等領域。 公眾號回覆【書籍】,獲取 Linux、嵌入式領域經典書籍。
轉 載:歡迎轉載文章,轉載需註明出處。
在上一篇文章中Linux從頭學05-系統啟動過程中的幾個神祕地址,你知道是什麼意思嗎?,我們以幾個重要的記憶體地址為線索,介紹了 x86 系統在上電開機之後:
CPU 如何執行第一條指令;
BIOS 中的程式如何被執行;
作業系統的引導程式碼(bootloader) 被讀取到實體記憶體中被執行;
下一個環節,就應該是載入程式(bootloader
)把作業系統程式,讀取到記憶體中,然後跳入到作業系統的第一條指令處開始執行。
這篇文章,我們繼續以 8086
這個簡單的處理器為原型,把程式的載入過程描述一下。其中的重點部分就是程式碼重定位,我們用畫圖的方式,盡我所能,把程式載入、地址重定位的計算過程描述清楚。
PS: 文中所說的程式、作業系統檔案,都是指同一個東西。
程式的結構
為了便於下面的理解,我們有必要把待載入的作業系統程式的檔案結構先介紹一下。
當然了,這裡介紹的檔案結構,是一個非常簡化版本的作業系統程式,本質上與我們平常所寫的應用程式沒有什麼差別,因此我們也可以把它看做一個普通的程式檔案。
作業系統程式靜靜的躺在硬碟中,等待 bootloader
來讀取,此時 bootloader
可以看做一個載入器。
它倆畢竟是屬於兩個不同的東西,為了讓 bootloader
知道程式的長度,需要某種“協議”來進行溝通,這個“協議”就是程式檔案的頭資訊(Header
)。
也就是說,在程式的開頭部分,會詳細的介紹自己,包括:程式的總長度是多少位元組,一共有多少個段,入口地址在什麼位置等等。
還記得之前介紹過的 Linux
系統中使用的 ELF
檔案格式嗎?Linux系統中編譯、連結的基石-ELF檔案:扒開它的層層外衣,從位元組碼的粒度來探索
那篇文章把一個典型的 Linux ELF
格式的可執行檔案徹底拆解了一遍,可以看到,在 ELF
檔案的頭部資訊中,詳細描述了檔案中每一部分內容。
其實 Windows
中的程式格式(PE
格式)也是類似的,它與 ELF
格式來源於同一個祖宗。
1. 程式頭(Header)的描述資訊
為了便於描述,我們假設程式中包括 3
個段:程式碼段,資料段和棧段,再加上程式頭部資訊,一共是 4
個組成部分。如下所示:
為什麼中間留有白色的空白?
因為每一個段並不是緊挨著排列的,為了段地址能夠記憶體對齊(16
個位元組對齊),段與段之間可能會空餘一段空間,這些空間裡的資料都是無效的。
剛才說了,為了能夠讓載入器(bootloader
)儘可能的瞭解自己,程式檔案會在自己的 Header
部分,詳細的描述自己的資訊:
有了這樣的描述資訊,bootloader
就能夠知道一共要讀取多少個位元組的程式檔案,跳轉到哪個位置才能讓作業系統的指令開始執行。
2. 關於彙編地址
在程式的頭資訊中,可以看到彙編地址和偏移量這樣的資訊。
編譯器在編譯原始碼的時候,它是不知道 bootloader
會把程式載入到記憶體中的什麼位置的。
bootloader
會檢視哪個位置有足夠的空間,找到一個可用的位置之後,就把作業系統程式讀取到這個位置,可以看做是一個動態的過程。
因此,編譯器在編譯階段用來定位變數、標籤等使用的地址,都是相對於當前段的開始地址來計算的。
還是拿剛才的圖片來舉例:
我們假設 Header
部分是 32
個位元組,三個段的開始地址分別是:
程式碼段 addrCodeStart: 0x00020(距離檔案的第一個位元組是 32 Bytes);
資料段 addrDataStart: 0x01000(距離檔案的第一個位元組是 4K Bytes);
棧段 addrStackStart: 0x01200(距離檔案的第一個位元組是 4K+512 Bytes);
在程式碼段中,定義了一個標籤 label_1
,它距離程式碼段的開始位置(0x00020
)是 512
個位元組(0x0200
)。
同時,可以算出它距離檔案開頭的第一個位元組就是 512 + 32 = 544 位元組,因為程式碼段的開始地址距離檔案頭部是 32
個位元組。
在 label_1
之前的程式碼中,會引用到這個標籤。
那麼在使用的地方,將會填上 0x0200
,表示:引用的這個位置是距離程式碼段開始地址的 512 位元組處。
以上的這些地址,指的就是彙編地址。
我們再來拿程式的入口地址偏移量來舉例,入口地址是通過 start
標籤來定義的:
假設:在程式碼段中,入口地址標籤 start
位於程式碼段開始位置的 0x0100
偏移處,也就是距離程式碼段開始位置的 256
個位元組。
那麼,在程式的 Header
資訊中,入口點偏移量的位置就要填寫 0x0100
,這樣的話,bootloader
把程式讀取到記憶體中之後,就能從這裡獲取到程式入口點的偏移地址,然後經過一系列的重定位,就可以準確跳轉到程式的第一條指令的地方去執行了。
按照剛才假設的地址資訊,程式頭 Header
中的資訊就是下面這個樣子:
最右側的藍色字型,表示每一個專案佔用的位元組數,一共是 24
個位元組。
剛才說到,每一個段的開始地址都是按照 16
位元組對齊的,因此在 Header
之後,要空餘 8
個位元組的空間,之後,才是程式碼段的開始地址(0x00020 = 32 Bytes)。
bootloader 把程式從硬碟讀取到記憶體
1. 讀取到記憶體中的什麼位置?
bootloader
在把作業系統檔案,從硬碟上讀取到記憶體之前,必須決定一件事情:把檔案內容存放到記憶體中的什麼位置?
從上一篇文章我們瞭解到,在讀取作業系統之前,記憶體佈局模型是下面這樣的:
注意:這是 8086
系統中,20
根地址線能夠定址的 1 MB
的地址空間。
其中頂部的 64 KB
,對映到 ROM
中的 BIOS
程式。
底部從 0
開始的 1 KB
地址空間,是儲存 256
個中斷向量(下一篇文章準備聊聊中斷的事情)。
中間的從 0x07C00
地址開始的地方,是 BIOS
從硬碟的引導區讀取的 bootloader
程式所存放的地方。
黃色部分的空間一共是 640 KB
的空間,都是對映到 RAM
中的,因此,有足夠大的空閒地址空間來儲存作業系統程式檔案。
假設:bootloader
就決定從地址 0x20000
開始(128 KB),存放從硬碟中讀取的作業系統程式檔案。
2. bootloader 設定資料段基地址
從硬碟上讀取檔案,是按照扇區為讀取單位的,也就是每次讀取一個扇區(512
位元組)。
至於如何通過指定扇區號、傳送埠命令,來從硬碟上讀取資料,這是另一個話題,暫且不表,我們把目光集中在 bootloader
上。
對於 bootloader
來說,讀取作業系統檔案就相當於讀取普通的資料。
既然已經決定把讀取的資料從地址 0x20000
開始存放,那麼 bootloader
就要把資料段暫存器 ds
設定為 0x2000
,這樣的話,經過邏輯地址的計算公式:
實體地址 = 邏輯段地址 * 16 + 偏移地址
才能得到正確的實體地址,例如:
讀取的第 1 個扇區的資料放在:0x2000:0x0000 地址處;
讀取的第 2 個扇區的資料放在:0x2000:0x0200 地址處;
讀取的第 3 個扇區的資料放在:0x2000:0x0400 地址處;
...
讀取的第 10 個扇區的資料放在:0x2000:0x1200 地址處;
3. bootloader 讀取所有扇區
bootloader
需要把作業系統程式的所有內容讀取到記憶體中,需要讀取的長度是多少呢?
程式檔案的 Header
中有這個資訊,因此,bootloader
需要先讀取程式檔案的第一個扇區,也就是 512
位元組,放在 0x20000
開始的位置。
我們繼續假設一下:程式的總長度是 5K
位元組(0x01400
),那麼程式檔案的前 512
個位元組(第一個扇區)讀取到記憶體中,就是下面這個樣子:
注意:這是檔案內容被讀取到記憶體中的佈局,最下面是低地址,最上面是高地址,這與前面描述靜態檔案中內容的順序是相反的。
讀取了第一個扇區之後,就可以取出 0x20000
開始的 4 個位元組的資料:0x01400
,得到程式檔案的總長度: 5 K 位元組。
每個扇區是 512
位元組,5 K
位元組就是 10
個扇區。
第一個扇區已經讀取了,那麼還需要繼續讀取剩下的 9
個扇區。
於是,bootloader
把所有扇區的資料,依次讀取到:0x2000:0x0000, 0x2000:0x0200, 0x2000:0x0400, ... 0x2000:0x1200 地址處。
4. 如果程式檔案超過 64 KB 怎麼辦?
這裡有一個延伸的問題可以思考一下:
8086 的段定址方式,由於偏移量暫存器的長度是 16
位,最大隻能表示 64 KB
的空間。
我們所假設的例子中,程式檔案只有 5 KB
,在一個資料段內完全可以包括,因此 bootloader
可以一直用 0x2000:偏移量 的方式來讀取檔案內容。
那如果程式的長度是 100 KB
,超過了偏移量的 64 KB
最大定址空間,那麼 bootloader
應該怎麼樣做才能正確把 100 KB
的程式讀取到記憶體中?
解答:
可以在讀取檔案的過程中,動態的增加資料段邏輯地址。
比如,在讀取前面的 64 KB
資料(扇區 1 ~ 扇區 128)時,段暫存器 ds
設定為 0x2000
。
在讀取第 65 KB
資料(扇區 129)之前,把段暫存器 ds
設定為 0x3000
,這樣讀取的資料就從 0x3000:0x0000
處開始存放了。
程式碼重定位
現在,作業系統程式已經被讀取到記憶體中了,下一個步驟就是:跳轉到作業系統的程式入口點去執行!
程式入口點重定位
程式入口點的偏移量,已經被記錄在 Header
中了(0x04 ~ 0x05
位元組,橙色部分):
Header
中記錄的程式碼段中入口點 start
標籤的偏移量是 0x100
,即:入口點距離程式碼段的開始地址是 256 個位元組。
同樣的道理,程式碼段中所有相關的地址,都是相對於程式碼段的開始地址來計算偏移量的。
因此,如果(這裡是如果啊) bootloader
把程式碼段的開始地址(不是整個檔案的開始),直接放到記憶體的 0x00000
地址處,那麼程式碼段裡所有地址就都不用再修改了,可以直接設定:cs = 0x0000, ip=0x0100,這樣就直接跳轉到 start
標籤的地方開始執行了。
可惜,bootloader
是把作業系統程式讀取到地址 0x20000
開始的地方,因此,需要把程式碼段暫存器 cs
設定為當前程式碼段在記憶體中的實際開始位置,也即是下面這個五角星的位置:
以上兩段文字,可以再多讀幾遍!
在 Header
中,0x06,0x07, 0x08, 0x09 這 4
個位元組的資料 0x00020
,就是程式碼段的開始位置距離程式檔案開頭的位元組數。
只要把這個數值(0x00020
),與檔案儲存的開始地址(0x20000
)相加,就可以得到程式碼段的開始地址在實體記憶體中的絕對地址:
0x00020 + 0x20000 = 0x20020
即:程式碼段的開始地址,位於實體記憶體中 0x20020
的位置。
對於一個實體地址,我們可以用多種不同的邏輯地址來表示,例如:
0x20020 = 0x2002:0x0000
0x20020 = 0x2000:0x0020
0x20020 = 0x1FF0:0x0120
面對這 3
個選擇,我們當然是選擇第 1
個,而且只能選擇第 1
個,因為程式碼段內部所有的地址偏移,在編譯的時候都是基於 0
地址的(也即是上面所說的彙編地址),或者稱作相對地址。
明白了這個道理之後,就可以把 cs:ip
設定為 0x2002:0x0100
,這樣 CPU
就會到 start
標籤處執行了。
但是,在進行這個操作之前還有其他幾件事情需要處理,因此,要把程式碼段的邏輯段地址 0x2002
, 寫回到 Header
中的 0x06 ~ 0x09
這 4
個位元組中儲存起來(橙色部分):
段表重定位
此時,系統還是在 bootloader
的控制之下,資料段暫存器 ds
仍然為 0x2000
,想一想為什麼?
因為 bootloader 讀取作業系統程式的第一扇區之前,希望把資料讀取到實體地址 0x20000 的地方,右移一位就得到了邏輯段地址 0x2000,把它寫入到資料段暫存器 ds 中。
我們一直忽略了 bootloader 使用的棧空間,因為這部分與檔案主題無關。
作業系統程式如果想要執行,必須使用自己程式檔案中的資料段和棧段。
但是,Header
中記錄的這 2
個段的開始地址,都是相對於程式檔案開頭而言的。
而且作業系統檔案並不知道:自己被 bootloader 讀取到記憶體中的什麼位置?
因此,bootloader
也需要把這 2
個段,在記憶體中的開始地址進行重新計算,然後更新到 Header
中。
這樣的話,當作業系統程式開始執行的時候,才能從 Header
中得到資料段和棧段的邏輯段地址。
當然了,這裡所舉的示例中只有 3
個段,一個實際的程式可能會包括很多個段,每一個段的地址都需要進行重定位。
bootloader
從 Header
的 0x0A ~ 0x0B
這 2 個位元組,可以得到一共有多少個段地址需要重定位。
然後按照順序,依次讀取每一個段的偏移地址,加上程式檔案的載入地址(0x20000),計算出實際的實體地址,然後再得到邏輯段地址,具體如下:
程式碼段偏移量 0x00020:0x20000 + 0x00020 = 0x20020(實體地址),右移一位得到邏輯段地址:0x2002;
資料段偏移量 0x0x01000: 0x20000 + 0x01000 = 0x21000(實體地址),右移一位得到邏輯段地址:0x2100;
棧段 段偏移量 0x0x01200: 0x20000 + 0x01200 = 0x21200(實體地址),右移一位得到邏輯段地址:0x2120;
下圖橙色部分:
我們把程式碼段、資料段、棧段在記憶體中的佈局模型全部畫出來:
跳轉到程式的入口地址
萬事俱備,只欠東風!
一切工作已經準備就緒,最後一步就是進入作業系統程式中程式碼段的 start
入口點了。
在上面的準備工作中,bootloader
已經把程式程式碼段的邏輯段地址 0x2002
,儲存在 Header
中的 0x06 ~ 0x09 這 4 個位元組中了,只要把它賦值給程式碼段暫存器 cs
即可。
程式入口點位於 start
標籤處,它距離程式碼段的開始位置偏移 0x100
,儲存在 Header
中的 0x04 ~ 0x05 這 2 個位元組,只要把它賦值給指令指標暫存器 ip
即可。
我們可以手動讀取,然後賦值。
也可以直接利用 8086 CPU 中的這條指令: jmp [0x04] 來實現 cs:ip
的賦值。
因為此刻還是在 bootloader
的控制下,資料段暫存器 ds
的值仍然為 0x2000
,因此跳轉到 0x2000:0x04
內建中所表示的地址,就可以把正確的邏輯段地址和指令地址賦值給 cs:ip
,從而開始執行作業系統程式的第一條指令。
作業系統程式的執行
作業系統的第一條指令在執行時,資料段暫存器 ds
和 棧段暫存器 cs
中的值,仍然為 bootloader
中所設定的值。
因此,作業系統首先要把這 2
個段暫存器設定為自己程式檔案的值,然後才能開始後續指令的執行。
上文已經說過,每一個段在記憶體中的邏輯段地址,已經被 bootloader
重新計算,並且更新到了 Header
中。
所以,作業系統就可以從 ds:0x14 的位置,讀取新的棧段邏輯地址 0x2120,並把它賦值給棧段暫存器 cs
。
從這個時候開始,所有的棧操作就是作業系統程式自己的了。
注意:此時資料段暫存器 ds 仍然沒有改變,仍然是 bootloader 中使用的 0x2000。
然後再從 ds:0x10 的位置讀取新的資料段邏輯地址 0x2100,並把它賦值給資料段暫存器 ds
。
從這個時候開始,所有的資料操作就是作業系統程式自己的了。
注意:給 cs、ds
的賦值順序不能顛倒。
如果先給 ds
賦值,那麼再去 Header
中讀取 cs
邏輯段地址的時候,就沒法定位了。
因為此時 ds
暫存器已經指向了新的地址(ds = 0x2100),沒法再去 0x2000:0x14
地址處獲取資料了。
最後還有一點,對於棧操作,除了設定棧的段暫存器 ss
外,還需要設定棧頂指標暫存器 sp
。
我們假設程式中設定的棧空間是 512
位元組,棧頂指標是向低地址方向增長的,因此,需要把 sp
初始化為 512
。
至此,作業系統程式終於可以愉快的開始執行了!
這篇文章,我們描述了關於程式碼重定位的最底層原理。
在以後學習到 Linux
中的重定位相關知識時,會接觸到更多的概念和技巧,但是最底層的基本原理都是相通的。
希望這篇文章,能夠成為你前進路上的墊腳石!
推薦閱讀
【1】C語言指標-從底層原理到花式技巧,用圖文和程式碼幫你講解透徹
【2】一步步分析-如何用C實現物件導向程式設計
【3】原來gdb的底層除錯原理這麼簡單
【4】內聯彙編很可怕嗎?看完這篇文章,終結它!
其他系列專輯:精選文章、C語言、Linux作業系統、應用程式設計、物聯網