Linux從頭學06:16張結構圖,徹底理解【程式碼重定位】的底層原理

IOT物聯網小鎮發表於2021-08-11

作 者:道哥,10+年的嵌入式開發老兵。

公眾號:【IOT物聯網小鎮】,專注於:C/C++、Linux作業系統、應用程式設計、物聯網、微控制器和嵌入式開發等領域。 公眾號回覆【書籍】,獲取 Linux、嵌入式領域經典書籍。

轉 載:歡迎轉載文章,轉載需註明出處。

在上一篇文章中Linux從頭學05-系統啟動過程中的幾個神祕地址,你知道是什麼意思嗎?,我們以幾個重要的記憶體地址為線索,介紹了 x86 系統在上電開機之後:

  1. CPU 如何執行第一條指令;

  2. BIOS 中的程式如何被執行;

  3. 作業系統的引導程式碼(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, 0x094 個位元組的資料 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 ~ 0x094 個位元組中儲存起來(橙色部分):

段表重定位

此時,系統還是在 bootloader 的控制之下,資料段暫存器 ds 仍然為 0x2000,想一想為什麼?

因為 bootloader 讀取作業系統程式的第一扇區之前,希望把資料讀取到實體地址 0x20000 的地方,右移一位就得到了邏輯段地址 0x2000,把它寫入到資料段暫存器 ds 中。

我們一直忽略了 bootloader 使用的棧空間,因為這部分與檔案主題無關。

作業系統程式如果想要執行,必須使用自己程式檔案中的資料段和棧段

但是,Header 中記錄的這 2 個段的開始地址,都是相對於程式檔案開頭而言的

而且作業系統檔案並不知道:自己被 bootloader 讀取到記憶體中的什麼位置?

因此,bootloader 也需要把這 2 個段,在記憶體中的開始地址進行重新計算,然後更新到 Header 中。

這樣的話,當作業系統程式開始執行的時候,才能從 Header 中得到資料段和棧段的邏輯段地址

當然了,這裡所舉的示例中只有 3 個段,一個實際的程式可能會包括很多個段,每一個段的地址都需要進行重定位。

bootloaderHeader0x0A ~ 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

至此,作業系統程式終於可以愉快的開始執行了!


------ End ------

這篇文章,我們描述了關於程式碼重定位的最底層原理。

在以後學習到 Linux 中的重定位相關知識時,會接觸到更多的概念和技巧,但是最底層的基本原理都是相通的。

希望這篇文章,能夠成為你前進路上的墊腳石!

推薦閱讀

【1】C語言指標-從底層原理到花式技巧,用圖文和程式碼幫你講解透徹
【2】一步步分析-如何用C實現物件導向程式設計
【3】原來gdb的底層除錯原理這麼簡單
【4】內聯彙編很可怕嗎?看完這篇文章,終結它!

其他系列專輯精選文章C語言Linux作業系統應用程式設計物聯網

星標公眾號,能更快找到我!

相關文章