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

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

公衆號:【IOT物聯網小鎮】,專一於:C/C++、Linux操做系統、應用程序設計、物聯網、單片機和嵌入式開發等領域。 公衆號回覆【書籍】,獲取 Linux、嵌入式領域經典書籍。markdown

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

在上一篇文章中Linux從頭學05-系統啓動過程當中的幾個神祕地址,你知道是什麼意思嗎?,咱們以幾個重要的內存地址爲線索,介紹了 x86 系統在上電開機以後:oop

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

  2. BIOS 中的程序如何被執行;學習

  3. 操做系統的引導代碼(bootloader) 被讀取到物理內存中被執行;字體

下一個環節,就應該是引導程序(bootloader)把操做系統程序,讀取到內存中,而後跳入到操做系統的第一條指令處開始執行。spa

這篇文章,咱們繼續以 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 ~ 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操做系統應用程序設計物聯網

相關文章
相關標籤/搜索