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