作 者:道哥,10+年的嵌入式開發老兵。
公眾號:【IOT物聯網小鎮】,專注於:C/C++、Linux作業系統、應用程式設計、物聯網、微控制器和嵌入式開發等領域。 公眾號回覆【書籍】,獲取 Linux、嵌入式領域經典書籍。
轉 載:歡迎轉載文章,轉載需註明出處。
不論是在 x86
平臺上,還是在嵌入式平臺上,系統的啟動一般都經歷了 bootloader 到 作業系統,再到應用程式,這樣的三級跳過程。
每一個相互交接的過程,都是我們學習的重點。
這篇文章,我們仍然以 x86
平臺為例,一起來看一下:從上電之後,系統是如何一步一步的進入應用程式的入口地址。
bootloader 跳轉到作業系統
在上一篇文章中,討論了 bootloader
在進入保護模式之後,在地址 0x0001_0000
處建立了全域性描述符表(GDT),表中建立了 3
個段描述符:
只要在 GDT
中建立了這 3
個描述符,然後把 GDT
的地址(eg: 0x0001_0000)設定到 GDTR
暫存器中,此時就可以進入保護模式工作了(設定 CR0
暫存器的 bit0
為 1
)。
之前的第 6
篇文章中Linux從頭學06:16張結構圖,徹底理解【程式碼重定位】的底層原理,我們是假設 bootloader
把作業系統程式讀取到記憶體 0x0002_0000
的位置,這裡繼續使用這個示例:
關於檔案頭 header
的內容,與真實模式下是不同的。
在真實模式下,header
的佈局如下圖:
bootloader
在把作業系統,從硬碟載入到記憶體中之後,從 header
中取得 3
個段的彙編地址(即:段的開始地址相對於檔案開始的偏移量),然後計算得到段的基地址,最後把段基地址寫回到 header
的這 3
個段地址空間中。
這樣的話,作業系統開始執行時,就可以從 header
中準確的獲取到每一個段的基地址了,然後就可以設定相應的段暫存器,進入正確的執行上下文了。
那麼在保護模式下呢,作業系統需要的就不是段的基地址了,而是要獲取到每一個段的描述符才行。
很顯然,需要藉助 bootloader
才可以完成這個目標,也就是:
在 GDT 中為作業系統程式中的三個段,建立相應的描述符;
把每一個段的描述符索引號,寫回到作業系統程式的 header 中;
注意:
這裡描述的僅僅是一個可能的過程,主要用來理解原理。
有些系統可以用不同的實現方式,例如:在進入作業系統之後,在另外一個位置存放 GDT
,並重新建立其中的段描述符。
作業系統的 header 佈局
既然 header
需要作為媒介,來接收 bootloader
往其中寫入段索引號,所以 bootloader
與 OS
就要協商好,寫在什麼位置?
可以按照之前的方式,直接覆寫在每個段的彙編地址位置,也可以寫在其他的位置,例如:
其中,最後的 3
個位置可以用來接收作業系統的三個段索引號。
建立作業系統的三個段描述符
bootloader
把 OS
載入到記憶體中之後,會解析 OS
的 header
中資料,得到每個段的基地址以及界限。
雖然 header
中沒有明確的記錄每個段的界限,可以根據下一個段的開始地址,來計算得到上一個段的長度。
我們可以聯想一下:
現代 Linux
系統中 ELF
檔案的格式,在檔案頭部中記錄了每一個段的長度,具體解析請參考這篇文章:Linux系統中編譯、連結的基石-ELF檔案:扒開它的層層外衣,從位元組碼的粒度來探索。
此時,bootloader
就可以利用這幾個資訊:段基地址、界限、型別以及其他屬性,來構造出相應的段描述符了(下圖橙色部分):
PS:這裡的示例只為作業系統建立了 3 個段描述符,實際情況也許有更多的段。
OS
段描述符建立之後,bootloader
再把這 3
個段描述符在 GDT
中的索引號,填寫到 OS
的 header
中相應的位置:
上圖中,“入口地址”下面的那個 4
,本質上是不需要的,加上更有好處,好處如下:
當從 bootloader
跳入到作業系統的入口地址時,需要告訴處理器兩件事情:
程式碼段的索引號;
程式碼的入口地址;
因此,把入口地址和索引號放在一起,有助於 bootloader
直接使用跳轉語句,進入到 OS
的 start
標記處開始執行。
作業系統跳轉到應用程式
從現代作業系統來看,這個標題是有錯誤的:
作業系統是應用程式的下層支撐,相當於是應用程式的 runtime
,怎麼能叫做跳轉到應用程式呢?
其實我想表達的意思是:作業系統是如何載入、執行一個應用程式的。
既然是保護模式,那麼作業系統就承擔起重要的職責:保護系統不會受到每一個應用程式的惡意破壞!
因此,作業系統:把應用程式從硬碟上覆制到記憶體中之後,跳入應用程式的第一條指令之前,需要為應用程式分配好記憶體資源:
程式碼段的基地址、界限、型別和許可權等資訊;
資料段的基地址、界限、型別和許可權等資訊;
棧段的基地址、界限、型別和許可權等資訊;
以上這些資訊,都以段描述符的形式,建立在 GDT
中。
PS: 在現代作業系統中,應用程式都會有一個自己私有的區域性描述符表 LDT,專門儲存應用程式自己的段描述符。
還記得之前討論過的下面這張圖嗎?
段暫存器的 bit2
位 TI
標誌,就說明了需要到 GDT
中查詢段描述符?還是到 LDT
中去查詢?
為了方便起見,我們就把所有的段描述符都放在 GDT
中。
就猶如 bootloader
為 OS
建立段描述符一樣,OS
也以同樣的步驟為應用程式來建立每一個段描述符。
此時的 GDT
就是下面這樣:
從這張圖中已經可以看出一個問題了:
如果所有應用程式的段描述符都放在全域性的 GDT
中,當應用程式結束之後,還得去更新 GDT
,勢必給作業系統的程式碼帶來很多麻煩。
因此,更合理的方式應該是放在應用程式私有的 LDT
中,這個問題,以後還會進一步討論到。
不管怎樣,OS 啟動應用程式的整體流程如下:
作業系統把應用程式讀取到記憶體中的某個空閒位置;
作業系統分析應用程式 header 部分的資訊;
作業系統為應用程式建立每一個段描述符,並且把索引號寫回到 header 中;
跳轉到應用程式的入口地址,應用程式從 header 中獲取到每個段索引號,設定好自己的執行上下文(即:設定好各種暫存器);
應用程式呼叫作業系統中的函式
這裡的函式可以理解成系統呼叫,也就是作業系統為所有的應用程式提供的公共函式。
在 Linux
系統中,系統呼叫是通過中斷來實現的,在中斷處理器程式中,再通過一個暫存器來標識:當前應用程式想呼叫哪一個系統函式,也就是說:每一個系統函式都有一個固定的數字編號。
再回到我們當前討論的 x86
處理器中,作業系統提供系統函式的最簡單的方法就是:
把所有的系統函式都放在一個單獨的程式碼段中,把這個段的索引號以及每一個系統函式的偏移地址告訴應用程式。
這樣的話,應用程式就可以通過這 2
個資訊呼叫到系統函式了。
假如:有 2
個系統函式 os_func1
和 os_func2
,放在一個獨立的段中:
既然 OS
中多了一個程式碼段,那麼 bootloader
就需要幫助它在 GDT
中多建立一個段描述符:
在應用程式的 header
中,預留一個足夠大的空間來存放每一個系統函式的跳轉資訊(系統函式的段索引號和函式的偏移地址):
應用程式有了這個資訊之後,當需要呼叫 os_func1
時,就直接跳轉到相應的 段索引號:函式偏移地址,就可以呼叫到這個系統函式了。
這裡同樣的會引出 2
個問題:
如果作業系統提供的系統函式很多,應用程式也很多,那麼作業系統在載入每一個應用程式時,豈不是要忙死了?而且應用程式也不知道應該保留多大的空間來存放這些系統函式的跳轉資訊;
在執行系統函式時,此時程式碼段、資料段都是屬於作業系統的勢力範圍,但是棧基址和棧頂指標使用的仍然是應用程式擁有的棧,這樣合理嗎?
對於第一個問題,所以 Linux
中通過中斷,提供一個統一的呼叫入口地址,然後通過一個暫存器來區分是哪一個函式。
對於第二個問題,Linux
在載入每一個應用程式時,會在核心中建立與該應用程式相關的資料結構,並且在核心中建立一塊記憶體空間,專門用作:從這個應用程式跳轉到核心中執行程式碼時,所使用的棧空間。
從 bootloader
到作業系統,再到應用程式,這個三級跳的最簡流程就討論結束了。
希望對你有小小的幫助,謝謝!
方便的話,也請你轉發給身邊的技術小夥伴,讓我們一塊進步!
推薦閱讀
【1】C語言指標-從底層原理到花式技巧,用圖文和程式碼幫你講解透徹
【2】一步步分析-如何用C實現物件導向程式設計
【3】原來gdb的底層除錯原理這麼簡單
【4】內聯彙編很可怕嗎?看完這篇文章,終結它!
其他系列專輯:精選文章、C語言、Linux作業系統、應用程式設計、物聯網