訂閱
糾錯
加入自媒體

Linux基礎(chǔ):16張結(jié)構(gòu)圖理解代碼重定位的底層原理

程序的結(jié)構(gòu)

bootloader 把程序從硬盤讀取到內(nèi)存

代碼重定位

程序入口點重定位

段表重定位

跳轉(zhuǎn)到程序的入口地址

操作系統(tǒng)程序的執(zhí)行

在上一篇文章中Linux從頭學(xué)05-系統(tǒng)啟動過程中的幾個神秘地址,你知道是什么意思嗎?,我們以幾個重要的內(nèi)存地址為線索,介紹了 x86 系統(tǒng)在上電開機之后:

CPU 如何執(zhí)行第一條指令;

BIOS 中的程序如何被執(zhí)行;

操作系統(tǒng)的引導(dǎo)代碼(bootloader) 被讀取到物理內(nèi)存中被執(zhí)行;

下一個環(huán)節(jié),就應(yīng)該是引導(dǎo)程序(bootloader)把操作系統(tǒng)程序,讀取到內(nèi)存中,然后跳入到操作系統(tǒng)的第一條指令處開始執(zhí)行。

這篇文章,我們繼續(xù)以 8086 這個簡單的處理器為原型,把程序的加載過程描述一下。其中的重點部分就是代碼重定位,我們用畫圖的方式,盡我所能,把程序加載、地址重定位的計算過程描述清楚。

PS: 文中所說的程序、操作系統(tǒng)文件,都是指同一個東西。

程序的結(jié)構(gòu)

為了便于下面的理解,我們有必要把待加載的操作系統(tǒng)程序的文件結(jié)構(gòu)先介紹一下。

當然了,這里介紹的文件結(jié)構(gòu),是一個非常簡化版本的操作系統(tǒng)程序,本質(zhì)上與我們平常所寫的應(yīng)用程序沒有什么差別,因此我們也可以把它看做一個普通的程序文件。

操作系統(tǒng)程序靜靜的躺在硬盤中,等待 bootloader 來讀取,此時 bootloader 可以看做一個加載器。

它倆畢竟是屬于兩個不同的東西,為了讓 bootloader 知道程序的長度,需要某種“協(xié)議”來進行溝通,這個“協(xié)議”就是程序文件的頭信息(Header)。

也就是說,在程序的開頭部分,會詳細的介紹自己,包括:程序的總長度是多少字節(jié),一共有多少個段,入口地址在什么位置等等。

還記得之前介紹過的 Linux 系統(tǒng)中使用的 ELF 文件格式嗎?Linux系統(tǒng)中編譯、鏈接的基石-ELF文件:扒開它的層層外衣,從字節(jié)碼的粒度來探索

那篇文章把一個典型的 Linux ELF 格式的可執(zhí)行文件徹底拆解了一遍,可以看到,在 ELF 文件的頭部信息中,詳細描述了文件中每一部分內(nèi)容。

其實 Windows 中的程序格式(PE 格式)也是類似的,它與 ELF 格式來源于同一個祖宗。

1. 程序頭(Header)的描述信息

為了便于描述,我們假設(shè)程序中包括 3 個段:代碼段,數(shù)據(jù)段和棧段,再加上程序頭部信息,一共是 4 個組成部分。如下所示:

為什么中間留有白色的空白?

因為每一個段并不是緊挨著排列的,為了段地址能夠內(nèi)存對齊(16個字節(jié)對齊),段與段之間可能會空余一段空間,這些空間里的數(shù)據(jù)都是無效的。

剛才說了,為了能夠讓加載器(bootloader)盡可能的了解自己,程序文件會在自己的 Header 部分,詳細的描述自己的信息:

有了這樣的描述信息,bootloader 就能夠知道一共要讀取多少個字節(jié)的程序文件,跳轉(zhuǎn)到哪個位置才能讓操作系統(tǒng)的指令開始執(zhí)行。

2. 關(guān)于匯編地址

在程序的頭信息中,可以看到匯編地址和偏移量這樣的信息。

編譯器在編譯源代碼的時候,它是不知道 bootloader 會把程序加載到內(nèi)存中的什么位置的。

bootloader 會查看哪個位置有足夠的空間,找到一個可用的位置之后,就把操作系統(tǒng)程序讀取到這個位置,可以看做是一個動態(tài)的過程。

因此,編譯器在編譯階段用來定位變量、標簽等使用的地址,都是相對于當前段的開始地址來計算的。

還是拿剛才的圖片來舉例:

我們假設(shè) Header 部分是 32 個字節(jié),三個段的開始地址分別是:

代碼段 addrCodeStart: 0x00020(距離文件的第一個字節(jié)是 32 Bytes);

數(shù)據(jù)段 addrDataStart: 0x01000(距離文件的第一個字節(jié)是 4K Bytes);

棧段   addrStackStart:0x01200(距離文件的第一個字節(jié)是 4K+512 Bytes);

在代碼段中,定義了一個標簽 label_1,它距離代碼段的開始位置(0x00020)是 512 個字節(jié)(0x0200)。

同時,可以算出它距離文件開頭的第一個字節(jié)就是 512 + 32 = 544 字節(jié),因為代碼段的開始地址距離文件頭部是 32 個字節(jié)。

在 label_1 之前的代碼中,會引用到這個標簽。

那么在使用的地方,將會填上 0x0200,表示:引用的這個位置是距離代碼段開始地址的 512 字節(jié)處。

以上的這些地址,指的就是匯編地址。

我們再來拿程序的入口地址偏移量來舉例,入口地址是通過 start 標簽來定義的:

假設(shè):在代碼段中,入口地址標簽 start 位于代碼段開始位置的 0x0100 偏移處,也就是距離代碼段開始位置的 256 個字節(jié)。

那么,在程序的 Header 信息中,入口點偏移量的位置就要填寫 0x0100,這樣的話,bootloader 把程序讀取到內(nèi)存中之后,就能從這里獲取到程序入口點的偏移地址,然后經(jīng)過一系列的重定位,就可以準確跳轉(zhuǎn)到程序的第一條指令的地方去執(zhí)行了。

按照剛才假設(shè)的地址信息,程序頭 Header 中的信息就是下面這個樣子:

最右側(cè)的藍色字體,表示每一個項目占用的字節(jié)數(shù),一共是 24 個字節(jié)。

剛才說到,每一個段的開始地址都是按照 16 字節(jié)對齊的,因此在 Header 之后,要空余 8 個字節(jié)的空間,之后,才是代碼段的開始地址(0x00020 = 32 Bytes)。

bootloader 把程序從硬盤讀取到內(nèi)存 1. 讀取到內(nèi)存中的什么位置?

bootloader 在把操作系統(tǒng)文件,從硬盤上讀取到內(nèi)存之前,必須決定一件事情:把文件內(nèi)容存放到內(nèi)存中的什么位置?

從上一篇文章我們了解到,在讀取操作系統(tǒng)之前,內(nèi)存布局模型是下面這樣的:

注意:這是 8086 系統(tǒng)中,20 根地址線能夠?qū)ぶ返?1 MB 的地址空間。

其中頂部的 64 KB,映射到 ROM 中的 BIOS 程序。

底部從 0 開始的 1 KB 地址空間,是存儲 256 個中斷向量(下一篇文章準備聊聊中斷的事情)。

中間的從 0x07C00 地址開始的地方,是 BIOS 從硬盤的引導(dǎo)區(qū)讀取的 bootloader 程序所存放的地方。

黃色部分的空間一共是 640 KB 的空間,都是映射到 RAM 中的,因此,有足夠大的空閑地址空間來存儲操作系統(tǒng)程序文件。

假設(shè):bootloader 就決定從地址 0x20000 開始(128 KB),存放從硬盤中讀取的操作系統(tǒng)程序文件。

2. bootloader 設(shè)置數(shù)據(jù)段基地址

從硬盤上讀取文件,是按照扇區(qū)為讀取單位的,也就是每次讀取一個扇區(qū)(512 字節(jié))。

至于如何通過指定扇區(qū)號、發(fā)送端口命令,來從硬盤上讀取數(shù)據(jù),這是另一個話題,暫且不表,我們把目光集中在 bootloader 上。

對于 bootloader 來說,讀取操作系統(tǒng)文件就相當于讀取普通的數(shù)據(jù)。

既然已經(jīng)決定把讀取的數(shù)據(jù)從地址 0x20000 開始存放,那么 bootloader 就要把數(shù)據(jù)段寄存器 ds 設(shè)置為 0x2000,這樣的話,經(jīng)過邏輯地址的計算公式:

物理地址 = 邏輯段地址 * 16 + 偏移地址

才能得到正確的物理地址,例如:

讀取的第 1 個扇區(qū)的數(shù)據(jù)放在:0x2000:0x0000 地址處;

讀取的第 2 個扇區(qū)的數(shù)據(jù)放在:0x2000:0x0200 地址處;

讀取的第 3 個扇區(qū)的數(shù)據(jù)放在:0x2000:0x0400 地址處;

...

讀取的第 10 個扇區(qū)的數(shù)據(jù)放在:0x2000:0x1200 地址處;

3. bootloader 讀取所有扇區(qū)

bootloader 需要把操作系統(tǒng)程序的所有內(nèi)容讀取到內(nèi)存中,需要讀取的長度是多少呢?

程序文件的 Header 中有這個信息,因此,bootloader 需要先讀取程序文件的第一個扇區(qū),也就是 512 字節(jié),放在 0x20000 開始的位置。

我們繼續(xù)假設(shè)一下:程序的總長度是 5K 字節(jié)(0x01400),那么程序文件的前 512 個字節(jié)(第一個扇區(qū))讀取到內(nèi)存中,就是下面這個樣子:

注意:這是文件內(nèi)容被讀取到內(nèi)存中的布局,最下面是低地址,最上面是高地址,這與前面描述靜態(tài)文件中內(nèi)容的順序是相反的。

讀取了第一個扇區(qū)之后,就可以取出 0x20000 開始的 4 個字節(jié)的數(shù)據(jù):0x01400,得到程序文件的總長度: 5 K 字節(jié)。

每個扇區(qū)是 512 字節(jié),5 K 字節(jié)就是 10 個扇區(qū)。

第一個扇區(qū)已經(jīng)讀取了,那么還需要繼續(xù)讀取剩下的 9 個扇區(qū)。

于是,bootloader 把所有扇區(qū)的數(shù)據(jù),依次讀取到:0x2000:0x0000, 0x2000:0x0200,  0x2000:0x0400, ... 0x2000:0x1200 地址處。

4. 如果程序文件超過 64 KB 怎么辦?

這里有一個延伸的問題可以思考一下:

8086 的段尋址方式,由于偏移量寄存器的長度是 16 位,最大只能表示 64 KB 的空間。

我們所假設(shè)的例子中,程序文件只有 5 KB,在一個數(shù)據(jù)段內(nèi)完全可以包括,因此 bootloader 可以一直用 0x2000:偏移量 的方式來讀取文件內(nèi)容。

那如果程序的長度是 100 KB,超過了偏移量的 64 KB 最大尋址空間,那么 bootloader 應(yīng)該怎么樣做才能正確把 100 KB 的程序讀取到內(nèi)存中?

解答:

可以在讀取文件的過程中,動態(tài)的增加數(shù)據(jù)段邏輯地址。

比如,在讀取前面的 64 KB 數(shù)據(jù)(扇區(qū) 1 ~ 扇區(qū) 128)時,段寄存器 ds 設(shè)置為 0x2000。

在讀取第 65 KB 數(shù)據(jù)(扇區(qū) 129)之前,把段寄存器 ds 設(shè)置為 0x3000,這樣讀取的數(shù)據(jù)就從 0x3000:0x0000 處開始存放了。

代碼重定位

現(xiàn)在,操作系統(tǒng)程序已經(jīng)被讀取到內(nèi)存中了,下一個步驟就是:跳轉(zhuǎn)到操作系統(tǒng)的程序入口點去執(zhí)行!

程序入口點重定位

程序入口點的偏移量,已經(jīng)被記錄在 Header 中了(0x04 ~ 0x05 字節(jié),橙色部分):

Header 中記錄的代碼段中入口點 start 標簽的偏移量是 0x100,即:入口點距離代碼段的開始地址是 256 個字節(jié)。

同樣的道理,代碼段中所有相關(guān)的地址,都是相對于代碼段的開始地址來計算偏移量的。

因此,如果(這里是如果啊) bootloader 把代碼段的開始地址(不是整個文件的開始),直接放到內(nèi)存的 0x00000 地址處,那么代碼段里所有地址就都不用再修改了,可以直接設(shè)置:cs = 0x0000, ip=0x0100,這樣就直接跳轉(zhuǎn)到 start 標簽的地方開始執(zhí)行了。

可惜,bootloader 是把操作系統(tǒng)程序讀取到地址 0x20000 開始的地方,因此,需要把代碼段寄存器 cs 設(shè)置為當前代碼段在內(nèi)存中的實際開始位置,也即是下面這個五角星的位置:

以上兩段文字,可以再多讀幾遍!

在 Header 中,0x06,0x07, 0x08, 0x09 這 4 個字節(jié)的數(shù)據(jù) 0x00020,就是代碼段的開始位置距離程序文件開頭的字節(jié)數(shù)。

只要把這個數(shù)值(0x00020),與文件存儲的開始地址(0x20000)相加,就可以得到代碼段的開始地址在物理內(nèi)存中的絕對地址:

0x00020 + 0x20000 = 0x20020

即:代碼段的開始地址,位于物理內(nèi)存中 0x20020 的位置。

對于一個物理地址,我們可以用多種不同的邏輯地址來表示,例如:

0x20020 = 0x2002:0x0000
0x20020 = 0x2000:0x0020
0x20020 = 0x1FF0:0x0120

面對這 3 個選擇,我們當然是選擇第 1 個,而且只能選擇第 1 個,因為代碼段內(nèi)部所有的地址偏移,在編譯的時候都是基于 0 地址的(也即是上面所說的匯編地址),或者稱作相對地址。

明白了這個道理之后,就可以把 cs:ip 設(shè)置為 0x2002:0x0100,這樣 CPU 就會到 start 標簽處執(zhí)行了。

但是,在進行這個操作之前還有其他幾件事情需要處理,因此,要把代碼段的邏輯段地址 0x2002, 寫回到 Header 中的 0x06 ~ 0x09 這 4 個字節(jié)中保存起來(橙色部分):

段表重定位

此時,系統(tǒng)還是在 bootloader 的控制之下,數(shù)據(jù)段寄存器 ds 仍然為 0x2000,想一想為什么?

因為 bootloader 讀取操作系統(tǒng)程序的第一扇區(qū)之前,希望把數(shù)據(jù)讀取到物理地址 0x20000 的地方,右移一位就得到了邏輯段地址 0x2000,把它寫入到數(shù)據(jù)段寄存器 ds 中。

我們一直忽略了 bootloader 使用的?臻g,因為這部分與文件主題無關(guān)。

操作系統(tǒng)程序如果想要執(zhí)行,必須使用自己程序文件中的數(shù)據(jù)段和棧段。

但是,Header 中記錄的這 2 個段的開始地址,都是相對于程序文件開頭而言的。

而且操作系統(tǒng)文件并不知道:自己被 bootloader 讀取到內(nèi)存中的什么位置?

因此,bootloader 也需要把這 2 個段,在內(nèi)存中的開始地址進行重新計算,然后更新到 Header 中。

這樣的話,當操作系統(tǒng)程序開始執(zhí)行的時候,才能從 Header 中得到數(shù)據(jù)段和棧段的邏輯段地址。

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

bootloader 從 Header 的 0x0A ~ 0x0B 這 2 個字節(jié),可以得到一共有多少個段地址需要重定位。

然后按照順序,依次讀取每一個段的偏移地址,加上程序文件的加載地址(0x20000),計算出實際的物理地址,然后再得到邏輯段地址,具體如下:

代碼段偏移量 0x00020:0x20000 + 0x00020 = 0x20020(物理地址),右移一位得到邏輯段地址:0x2002;

數(shù)據(jù)段偏移量 0x0x01000: 0x20000 + 0x01000 = 0x21000(物理地址),右移一位得到邏輯段地址:0x2100;

棧段 段偏移量 0x0x01200: 0x20000 + 0x01200 = 0x21200(物理地址),右移一位得到邏輯段地址:0x2120;

下圖橙色部分:

我們把代碼段、數(shù)據(jù)段、棧段在內(nèi)存中的布局模型全部畫出來:

跳轉(zhuǎn)到程序的入口地址

萬事俱備,只欠東風(fēng)!

一切工作已經(jīng)準備就緒,最后一步就是進入操作系統(tǒng)程序中代碼段的 start 入口點了。

在上面的準備工作中,bootloader 已經(jīng)把程序代碼段的邏輯段地址 0x2002,保存在 Header 中的 0x06 ~ 0x09 這 4 個字節(jié)中了,只要把它賦值給代碼段寄存器 cs 即可。

程序入口點位于 start 標簽處,它距離代碼段的開始位置偏移 0x100,保存在 Header 中的 0x04 ~ 0x05 這 2 個字節(jié),只要把它賦值給指令指針寄存器 ip 即可。

我們可以手動從內(nèi)存中讀取,然后賦值給 cs 和 ip 寄存器。

也可以直接利用 8086 CPU 中的這條指令:jmp [0x04] 來實現(xiàn) cs:ip 的賦值。

因為此刻還是在 bootloader 的控制下,數(shù)據(jù)段寄存器 ds 的值仍然為 0x2000,因此跳轉(zhuǎn)到 0x2000:0x04 內(nèi)置中所表示的地址,就可以把正確的邏輯段地址和指令地址賦值給 cs:ip,從而開始執(zhí)行操作系統(tǒng)程序的第一條指令。

操作系統(tǒng)程序的執(zhí)行

操作系統(tǒng)的第一條指令在執(zhí)行時,數(shù)據(jù)段寄存器 ds 和 棧段寄存器 cs 中的值,仍然為 bootloader 中所設(shè)置的值。

因此,操作系統(tǒng)首先要把這 2 個段寄存器設(shè)置為自己程序文件的值,然后才能開始后續(xù)指令的執(zhí)行。

上文已經(jīng)說過,每一個段在內(nèi)存中的邏輯段地址,已經(jīng)被 bootloader 重新計算,并且更新到了 Header 中。

所以,操作系統(tǒng)就可以從 ds:0x14 的位置,讀取新的棧段邏輯地址 0x2120,并把它賦值給棧段寄存器 cs。

從這個時候開始,所有的棧操作就是操作系統(tǒng)程序自己的了。

注意:此時數(shù)據(jù)段寄存器 ds 仍然沒有改變,仍然是 bootloader 中使用的 0x2000。

然后再從 ds:0x10 的位置讀取新的數(shù)據(jù)段邏輯地址 0x2100,并把它賦值給數(shù)據(jù)段寄存器 ds。

從這個時候開始,所有的數(shù)據(jù)操作就是操作系統(tǒng)程序自己的了。

注意:給 cs、ds 的賦值順序不能顛倒。

如果先給 ds 賦值,那么再去 Header 中讀取 cs 邏輯段地址的時候,就沒法定位了。

因為此時 ds 寄存器已經(jīng)指向了新的地址(ds = 0x2100),沒法再去 0x2000:0x14 地址處獲取數(shù)據(jù)了。

最后還有一點,對于棧操作,除了設(shè)置棧的段寄存器 cs 外,還需要設(shè)置棧頂指針寄存器 sp。

我們假設(shè)程序中設(shè)置的?臻g是 512 字節(jié),棧頂指針是向低地址方向增長的,因此,需要把 sp 初始化為 512。

至此,操作系統(tǒng)程序終于可以愉快的開始執(zhí)行了!

聲明: 本文由入駐維科號的作者撰寫,觀點僅代表作者本人,不代表OFweek立場。如有侵權(quán)或其他問題,請聯(lián)系舉報。

發(fā)表評論

0條評論,0人參與

請輸入評論內(nèi)容...

請輸入評論/評論長度6~500個字

您提交的評論過于頻繁,請輸入驗證碼繼續(xù)

暫無評論

暫無評論

    人工智能 獵頭職位 更多
    掃碼關(guān)注公眾號
    OFweek人工智能網(wǎng)
    獲取更多精彩內(nèi)容
    文章糾錯
    x
    *文字標題:
    *糾錯內(nèi)容:
    聯(lián)系郵箱:
    *驗 證 碼:

    粵公網(wǎng)安備 44030502002758號