Linux從頭學03:如何告訴 CPU,程式碼段、資料段、棧段在記憶體中什麼位置?

IOT物聯網小鎮發表於2021-07-15

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

公眾號:【IOT物聯網小鎮】,專注於:C/C++、Linux作業系統、應用程式設計、物聯網、微控制器和嵌入式開發等領域。 公眾號回覆【書籍】,獲取 Linux、嵌入式領域經典書籍。

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

前兩篇文章,我們一起學習了 8086 處理器中關於 CPU、記憶體的基本使用方式,重點對段暫存器和記憶體的定址方式進行了介紹。

可能有些小夥伴會對此不屑:現在都是多核的現代處理器,作業系統已經變得非常的強大,為何還去學習這些古董知識?

前幾天看到下面這段話,可以來回答這個問題:

“我們都希望學習最新的、使用的東西,但學習的過程是客觀的。”

“任何合理的學習過程(儘可能排除走彎路、盲目探索、不成系統)都是一個循序漸進的過程。”

“我們必須先通過一個易於全面把握的事物,來學習和探索一般的規律和方法。”

就拿學習 Linux 作業系統來說,作為一個長期的學習計劃,不太可能一上來就閱讀最新的 Linux 5.13 版本的程式碼。

更有可能是先學習 0.11 版本,理解了其中的一些原理、思想之後,再循序漸進的向高版本進行學習、探索。

那麼對於 《Linux 從頭學》這個系列的文章來說,我是希望自己能夠把學習路線再拉長一些,從更底層的硬體機制、驅動原理開始,由簡入繁,一步一步最終把 Linux 作業系統這個塊硬骨頭給啃下來。

那麼今天我們就繼續 8086 下的學習,來看看一個相對“完整”程式的基本結構。

幾個重要的段暫存器

x86 系統中,段定址機制以及相關的暫存器是如此的重要,以至於我忍不住在這裡,把幾個段暫存器再小結一下。

程式碼段:用來存放程式碼,段的基地址放在暫存器 CS 中,指令指標暫存器 IP 用來表示下一條指令在段中的偏移地址;

資料段:用來存放程式處理的資料,段的基地址存放在暫存器 DS 中。對資料段中的某個資料進行操作時,直接在彙編程式碼中通過立即數或暫存器來指定偏移地址;

棧段:本質上也是用來存放資料,只不過它的操作方式比較特殊而已:通過 PUSH 和 POP 指令來進行操作。段的基地址存放在暫存器 SS 中,棧頂單元的偏移地址存放在暫存器 IP 中。

這裡的段,本質上是我們把記憶體上的某一塊連續的儲存空間,專門儲存某一類的資料

我們之所以能夠這麼做,是因為 CPU 通過以上幾個暫存器,讓我們這樣的“安排”稱為一種可能。

一句話總結:CPU 將記憶體中的某個段的內容當做程式碼,是因為 CS:IP 指向了那裡;CPU 將某個段當做棧,是因為 CS:SP 指向了那裡。

在之前的一篇文章中,演示了一個 ELF 格式的可執行檔案中,具體包含了哪些段《Linux系統中編譯、連結的基石-ELF檔案:扒開它的層層外衣,從位元組碼的粒度來探索》:

雖然這張圖中描述的段結構更復雜,但是從本質上來說,它與 8086 中描述的段結構是一樣的

Linux 2.6 中的線性地址區間

在一個現代作業系統中,一個程式中使用的的地址空間,一般稱作虛擬地址(也稱作邏輯地址)。

虛擬地址首先經過段轉換,得到線性地址;然後線性地址再經過分頁轉換,得到最終的實體地址。

這裡再囉嗦一下,很多書籍中隊記憶體地址的稱呼比較多,都是根據作者的習慣來稱呼。

我是按照上圖的方式來理解的: 編譯器產生的地址叫做虛擬地址,也叫做邏輯地址,然後經過兩級轉換,得到最終的實體地址。

Linux 2.6 程式碼中,由於 Linux 把整個 4 GB 的地址空間當做一個“扁平”的結果來處理(段的基地址是 0x0000_0000,偏移地址的最大值是 4GB),因此虛擬地址(邏輯地址)在數值上等於線性地址。

我們再結合上次給出的這張圖來理解:

這張圖的意思是:在 Linux 2.6 中,使用者程式碼段的開始地址是 0,最大範圍是 4 GB;使用者資料段的開始地址是 0,最大範圍也是 4 GB;核心的資料段和程式碼段也是如此。

為什麼:虛擬地址(邏輯地址)在數值上等於線性地址?

線性地址 = 段基址 + 虛擬地址(偏移量),因為段基址為 0 ,所以線性地址在數值上等於虛擬地址。

Linux 之所以要這樣安排,是因為它不想過多的利用 x86 提供的段機制來進行記憶體地址的管理,而是想充分利用分頁機制來進行更加靈活的地址管理。

還有一點需要提醒一下:

在上述描述的文字中,我都會標明一個機制或者策略,它是由 x86 平臺提供的,還是由 Linux 作業系統提供的。

對於分頁機制也是如此,x86 硬體提供了分頁機制,但是 Linuxx86 提供的這個分頁機制的基礎上,進行了擴充套件,以達到更加靈活的記憶體地址管理目的。

因此,各位小夥伴在看一些書籍的時候,心中要有一個譜:當前描述內容的上下文環境是什麼。

當我們建立一個程式的時候,在核心中就會記錄這個程式所擁有的所有線性地址區間

程式所擁有的所有線性地址區間是一個動態的過程,根據程式的需求隨時進行擴充套件或縮小。例如:把一個檔案對映到記憶體,動態載入/解除安裝一個動態庫等等。

我們知道,核心在操作實體記憶體的時候,是通過“頁框”這個單位來管理的。

一個頁框可以包含 1-n 個頁,每一頁的大小一般是 4 KB,這是對實體記憶體的管理。

一個線性地址區間可以包含多個物理頁。每一個線性地址最終通過多級的頁錶轉換,來最終得到一個實體地址。

注意:上圖中,線性地址區間1,對映到實體地址空間中的 NPage,這些 Page 有可能是連續的,也有可能不是連續的。

雖然在實體記憶體中是不連續的,但是由於被分頁轉換機制進行了遮蔽,我們在應用程式中都是按照連續的空間來使用的。

一個“完整”的 8086 彙編程式

我們再繼續回到 8086 系統中來。

這裡描述的地址,經過段地址轉換之後,就是一個物理地址,沒有經過複雜的頁錶轉換。

這也是我們以 8086 系統作為學習平臺的目的:拋開復雜的作業系統,直接探索底層的東西

在這個最簡單的彙編程式中,會使用到 3 個段:程式碼段,資料段和棧段

前面已經說到:所謂的段,就是一個地址空間。既然是一個地址空間,必然包含 2 個元素:從什麼地方開始,長度是多少

還是直接上程式碼:

assume ds:addr1, ss:addr2, cs:addr3

addr1 segment           ; 把資料段安排在這個位置
        db 32 dup (0)   ; 這 32 個位元組,是資料段的大小
addr1 end

addr2 segment           ; 把棧段安排在這個位置
        db 32 dup(0)    ; 這 32 個位元組,是棧段的大小
addr2 end

addr3 segment           ; 把程式碼段安排在這個位置
start   
        mov ax, addr1
        mov ds, ax      ; 設定資料段暫存器
        
        mov ax, addr2
        mov ss, ax      ; 設定棧段暫存器
        mov sp, 20h     ; 設定棧頂指標暫存器
        
        ...             ; 其他程式碼
addr3 ends

end start

以上就是一個彙編程式碼的基本程式結構,我們給它安排了 3 個段。

3 個標號:addr1addr2addr3,代表了每一個段的開始地址。在程式碼段的開始部分,把資料段標號 addr1 代表的地址,賦值給 DS 暫存器;把棧段標號 addr2 代表的地址,賦值給 SS 暫存器。

這裡的標號,是不是與 C 語言中的 goto 標號很類似? 都是表示一個地址。

注意這裡賦值給棧頂指標 SP 暫存器的值是 20H

因為棧段的使用是從高地址向低地址方向進行的,所以需要把棧頂指標設定為最大地址單元的下一個地址空間。

假設把第一個資料入棧時(eg: 先執行 mov ax, 1234h,再執行 push ax),CPU 要做的事情是: 先執行 SP = SP - 2,此時 SS:SP 指向 1000:001E,然後再把 1234h 儲存到這個地址空間:

另外,程式碼中最後一句 end start,用來告訴編譯器:程式碼段中 start 標號代表的地址,就是這個程式的入口地址,編譯之後這個入口地址資訊也會被寫入可執行程式中。

當可執行檔案被載入到記憶體中之後,載入程式會找到這個入口地址,然後把 CS:IP 設定為指向這個入口地址,從而開始執行第一條指令。

我們再來對比一下《Linux系統中編譯、連結的基石-ELF檔案:扒開它的層層外衣,從位元組碼的粒度來探索》中列出的 ELF 可執行檔案中的入口地址,它與上面 8086 下的 start 標號代表的入口地址,在本質上都是一樣的道理:


------ End ------

推薦閱讀

【1】C語言指標-從底層原理到花式技巧,用圖文和程式碼幫你講解透徹
【2】一步步分析-如何用C實現物件導向程式設計
【3】原來gdb的底層除錯原理這麼簡單
【4】內聯彙編很可怕嗎?看完這篇文章,終結它!

其他系列專輯精選文章C語言Linux作業系統應用程式設計物聯網

星標公眾號,能更快找到我!

相關文章