作 者:道哥,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操作系統、應用程序設計、物聯網