1. 程式人生 > 其它 >Linux從頭學02:x86中記憶體【段定址】方式的來龍去脈

Linux從頭學02:x86中記憶體【段定址】方式的來龍去脈

拋開作業系統的外衣,看一下x86中記憶體的段定址機制到底是什麼東西!

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

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

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

目錄

飯是一口一口的吃,計算機也是一步一步的發展,例如下面這張英特爾公司的 CPU 型號歷史:

為了利用效能越來越強悍的計算機,作業系統的也是在逐步變得膨脹和複雜。

為了從最底層來學習作業系統的一些基本原理,我們只有拋開作業系統的外衣,從最原始

的硬體和程式設計方式來入手,才能瞭解到一些根本的知識。

這篇文章我們就來繼續挖掘一下,8086 這個開天闢地的處理器中,是如何利用段機制來對記憶體進行定址的。

什麼是程式碼段?

在上一篇文章:Linux 從頭學 01:CPU 是如何執行一條指令的? 中,已經提到過,在處理器的內部,執行每一條指令碼時,CPU 是非常機械、非常單純地從 CS:IP2 個暫存器計算得到轉換後的實體地址,從這個實體地址所指向的記憶體地址處,讀取一定長度的指令,然後交給邏輯運算單元(Arithmetic Logic Unit, ALU)去執行。

實體地址的計算方式是:CS * 16 + IP。

CPU 讀取一條指令後,根據指令操作碼

它能夠自動知道這條指令一共需要讀取多少個位元組。

指令被讀取之後,IP 暫存器中的內容就會自增,指向記憶體中下一條指令的地址。

例如,在記憶體 20000H 開始的地方,存在 2 條指令:

mov ax, 1122H
mov bx, 3344H

當執行第一條指令時,CS = 2000H,IP = 0000H,經過地址轉換之後的實體地址是:2000H * 16 + 0000 = 20000H(乘以 16 也就表示十六進位制的數左移 1 位):

當第一條指令碼 B8 22 113 個位元組被讀取之後,IP 暫存器中的內容自動增加 3`,從而指向下一條指令:

當第二條指令碼 BB 44 333 個位元組被讀取之後,IP 暫存器中的內容又增加 3

,變為 0006H

正如上篇文章所寫,CPU 只是反覆的從 CS:IP 指向的記憶體地址中讀取指令碼、執行指令,再讀取指令碼、再執行指令。

可以看出,要完成一個有意義的工作,所有的指令碼必須集中在一起,統一放在記憶體中某個確定的地址空間中,才能被 CPU 依次的讀取、執行。

記憶體中的這塊地址空間就叫做一個,又因為這個段中儲存的是程式碼編譯得到的指令,因此又稱作程式碼段

因此,用來對程式碼段進行定址的這兩個暫存器 CS 和 IP,它們的含義就非常清楚了:

CS: 段暫存器,其中的值左移 1 位之後,得到的值就表示程式碼段在記憶體中的首地址,或者稱作基地址;

IP: 指令指標暫存器,表示一條指令的地址,距離基地址的偏移量,也就是說,IP 暫存器是用來幫助 CPU 記住:哪些指令已經被處理過了,下一個要被處理的指令是哪一個;

什麼是資料段?

作為一個有意義的程式,僅僅只有指令是不夠的,還必須操作資料。

這些資料也應該集中放在一起,位於記憶體中的某個地址空間中,這塊地址空間,也是一個段,稱作資料段

也就是說:程式碼段和資料段,就是記憶體中的兩個地址空間,其中分別儲存了指令和資料

可以想象一下:假如指令和資料不是分開存放的,而是夾雜放在一起,那麼 CPU 在讀取一條指令時,肯定就會把資料當做指令來讀取、執行,就像下面這樣,不發生錯誤才怪呢!

CPU 對記憶體中資料段的訪問方式,與訪問程式碼段是類似的,也是通過一個基地址,再加上一個偏移量來得到資料段中的某個實體地址

8086 處理其中,資料段的段暫存器是 DS,也就是說,當 CPU 執行一條指令,這條指令需要訪問資料段時,就會把 DS 這個資料段暫存器中的值左移 1 位之後得到的地址,當做資料段的基地址

遺憾的是,CPU 中並沒有提供一個類似 IP 暫存器的其他暫存器,來表示資料段的偏移地址暫存器。

這其實並不是壞事,因為一個程式在處理資料時,需要對資料進行什麼樣操作,程式的開發者是最清楚的,因此我們就可以用更靈活的方式來告訴 CPU 應該如何計算資料的偏移地址。

就像猴子掰苞米一樣,不需要按照順序來掰,想掰哪個就掰哪個。同樣的,程式在操作資料時,無論操作哪一個資料,直接給出該資料的偏移地址的值就可以了。

資料的型別和長度

但是,在操作資料段中每一個數據,有一個比較重要的概念需要時刻銘記:資料的型別是什麼,這個資料在記憶體中佔據的位元組數是多少

我們在高階語言程式設計中(eg: C 語言),在定義一個變數的時候,必須明確這個變數的型別是什麼。一旦型別確定了,那麼它在被載入到記憶體中之後,所佔據的空間大小也就確定了。

比如下面這張圖:

假設 30000H 是資料段的基地址(也就意味著 DS 暫存器中的內容是 3000H),那麼 30000H 地址處的資料大小是多少:11H2211H?還是 44332211H

這幾個都有可能,因為沒有確定資料的型別

我們知道,在 C 語言中,假如有一個指標 ptr 最終指向了這裡的 30000H 實體地址處(C 程式碼中的 ptr 是虛擬地址,經過地址轉換之後執行這裡的 30000H 實體地址)。

如果 ptr 定義成:

char *ptr;

那麼可以說 ptr 指標指向的數值是 11H

如果 ptr 定義成:

int *ptrt;  

就可以說 ptr 指標指向的數值就是 44332211H(假設是小端格式)。

也就是說,指標 ptr 指向的資料,取決於定義指標變數時的型別

這是高階語言中的情況,那麼在組合語言中呢?

PS: 之前我曾說過,文章的主要目的是學習 Linux 作業系統,但是為了學習一些相對底層的內容,在開始階段必須拋開作業系統的外衣,進入到硬體最近的地方去看。

但是該怎麼看呢?還是要藉助一些原始的手段和工具,那麼彙編程式碼無疑就是最好的、也是唯一的手段;

不過,涉及到的彙編程式碼都是最簡單的,僅僅是為了說明原理;

彙編語言中,CPU 是通過指令碼中的相關暫存器來判斷操作資料的長度。

在上一篇文章中說過,相對於暫存器來說,CPU 操作記憶體的速度是很慢的。

因此,CPU 在對資料段中的資料進行處理的時候,一般都是先把原始資料讀取到通用暫存器中(比如:ax, bx, cx dx),然後進行計算。

得到計算結果之後,再把結果寫回到記憶體的資料段中(如果需要的話)。

那麼 CPU 在讀寫資料時,就根據指令碼中使用的暫存器,來決定讀寫資料的長度。例如:

mov ax, [0]

其中的 [0] 表示記憶體的資料段中偏移地址是 0 的位置。

CPU 在執行這條指令的時候,就會到 30000H(假設此時資料段暫存器 DS 的值為 3000H) 這個實體地址處,取出 2 個位元組的資料,放到通用暫存器 ax 中,此時 ax 暫存器中的值就是 2211H

為什麼取出 2 個位元組?因為 ax 暫存器的長度是 16 位,就是 2 個位元組。

那如果只想取 1 個位元組,該怎麼辦?

16 位的通用暫存器 ax 可以拆成 28 位的暫存器裡使用:ahal

mov al, [0]

因為指令碼中的 al 暫存器是 8 位,因此 CPU 就只讀取 30000H 處的一個位元組 11,放到 al 暫存器中。(此時 ax 暫存器的高 8 位,也就是 ah 中的值保持不變)

那如果想取 3 個位元組或 4 個位元組怎麼辦?

作為相當古老的處理器,8086 CPU 中是 16 位的,只能對 8 位或 16 位的資料進行操作。

定址範圍

從以上內容可以總結得出:

  1. 程式碼段和資料段都是通過 【基地址 + 偏移地址】的方式進行定址;

  2. 基地址都放在各自的段暫存器中,CPU 會自動把段暫存器的值,左移 1 位之後,作為段的基地址;

  3. 偏移地址決定了段中的每一個具體的地址,最大偏移地址是 16 個 bit1,也即是 64KB 的空間;

注意:這裡的段暫存器左移 1 位,是指十六進位制的左移,相當於是乘以 16,因此段的基地址都是 16 的倍數。

我們再來看一下這裡的 64 KB 空間,與 20 根地址線有什麼瓜葛。

上篇文章說到:8086 處理器有 20 根地址線,一共可以表示 1MB 的記憶體空間,即使給它更大的空間,它也沒有福氣去享受,因為定址不到大於 1 MB 的地址空間啊!

1MB 的記憶體空間,就可以分割為很多個段。

例如:第 1 個段的地址範圍是:

我們來計算最後一個段的空間。

段暫存器和偏移地址都取最大值,就是 FFFF:FFFF,先偏移再相加:FFFF0 + FFFF = 10FFEF =1M + 64K - 16Bytes

超過1 MB 的空間大小,但是畢竟只有 20 根地址線,肯定是無法定址超過 1 MB 地址空間的,因此係統會採取迴繞的方式來定位到一個地址空間,類似與數學中的取模操作。

此外還有一點,在表示一個記憶體地址的時候,一般不會直接給出實體地址的值(比如:3000A),而是使用 段地址:偏移地址 這樣的形式來表示(比如:3000:000A)。

棧也是資料空間的一種,只不過它的操作方式有些特殊而已。

棧的操作方式就是 4 個字:後進先出

在上面介紹資料段的時候,我們都是在指令碼中手動對資料的偏移地址進行設定,指哪打哪,因為這些資料放在什麼位置、表示什麼意思、怎麼來使用,開發者自己心裡最門清

但是有些不一樣,雖然它的功能也是用來儲存資料的,但是操作棧的方式,是由處理器提供的一些專門的指令來操作的:pushpop

push(入棧): 往棧空間中放入一個數據;
pop(出棧): 從棧空間中彈出一個數據;

注意:這裡的資料是固定 2 個位元組,也就是一個

寫過 C/C++ 程式的小夥伴都知道:在函式呼叫的時候,存在入棧操作;在函式返回的時候,存在出棧操作。

既然棧也是指一塊記憶體空間,那麼也就是表現為記憶體中的一個段。

既然是一個段,那肯定就存在一個段暫存器,用來代表它的基地址,這個棧的段暫存器就是 SS

此外,由於棧在入棧和出棧的時候,是按照連續的地址順序操作的,因此處理器為棧也提供了一個偏移地址暫存器SP(稱作:棧頂指標),指向棧空間中最頂上的那個元素的位置

例如下面這張圖:

棧空間的基地址是 1000:0000SS:SP 執行的地址空間是棧頂,此時棧頂中的元素是 44

當執行下面這 2 條指令時:

mov ax, 1234H
push as

棧頂指標暫存器 SP 中的值首先減 2,變成 000A

然後,再把暫存器 ax 中的值 1234H 放入 SS:SP 指向的記憶體單元處:

出棧的操作順序是相反的:

pop bx

首先把 SS:SP 指向的記憶體單元中的資料 1234H 放入暫存器 bx 中,然後把棧頂指標暫存器 SP 中的值加 2,變成 000C

以上描述的是 8086 處理器中對操作的執行過程。

如果你看過其他一些棧相關的描述書籍,可以看出這裡使用的是 “滿遞減” 的棧操作方式,另外還還有:滿遞增,空遞減,空遞增 這幾種操作方式。

滿:是指棧頂指標指向的那個空間中,是一個有效的資料。當一個新資料入棧時,棧頂指標先指向下一個空的位置,然後 把資料放入這個位置;

空:是指棧頂指標指向的那個空間中,是一個無效的資料。當一個新資料入棧時,先把資料放入這個位置,然後棧頂指標指向下一個空的位置;

遞增:是指在資料入棧時,棧頂指標向高地址方向增長;

遞減:是指在資料入棧時,棧頂指標向低地址方向遞減;

真實模式和保護模式

從以上對記憶體的定址方式中可以看出:只要在可定址的範圍內,我們寫的程式是可以對記憶體中任意一個位置的資料進行操作的。

這樣的定址方式,稱之為真實模式。實,就是實在、實際的意思,簡潔、直接,沒有什麼彎彎繞。

既然編寫程式碼的是人,就一定會犯一些低階的小錯誤。或者一些惡意的傢伙,故意去操作那些不應該、不可以被操作的記憶體空間中的程式碼或資料。

為了對記憶體進行有效的保護,從 80386 開始,引入了 保護模式 來對記憶體進行定址。

有些書籍中會提到 IA-32A 這個概念,IA-32 是英特爾 Architecture 32-bit簡稱,即英特爾32位體系架構,也是在386中首先採用。

雖然引進了保護模式,但是也存在真實模式,即向前相容。電腦開機後處於真實模式,BIOS 載入主引導記錄以及進行一些暫存器的設定之後就進入保護模式。

386 以後引入的保護模式下,地址線變成了 32 根,最大定址空間可以達到 4GB

當然,處理器中的暫存器也變成了 32 位。

我們還是用 段基址 + 偏移量 的方式來計算一個實體地址,假設段暫存器中內容為 0,偏移地址最大長度也是 32 位,那麼一個段能表示的最大空間也就是 4GB

這也是為什麼如今現代處理器中,每個程序的最大可定址空間是 4GB(一般指的是虛擬地址)。

一句話總結:真實模式和保護模式最根本的區別就是 記憶體是否收到保護

Linux 中的分段策略

上面描述的分段機制是 x86 處理器中所提供的一種記憶體定址機制,這僅僅是一種機制而已。

x86 處理器之上,執行著 Windows、Linux 獲取其它作業系統。

我們開發者是面對作業系統來程式設計的,寫出來的程式是被作業系統接管,並不是直接被 x86 處理器來接管。

相當於作業系統把應用程式和 x86 處理器之間進行了一層隔離:

因此,如何利用 x86 提供的分段機制是作業系統需要操心的問題。

而作業系統提供什麼樣的策略應用程式來使用,這就是另外一個問題了。

那麼,Linux 作業系統是如何來包裝、使用 x86 提供的段定址方式的呢?

是否還記得上一篇文章中的這張圖:

這是 Linux2.6 版本中四個主要的段描述符,這裡先不用管段描述符是什麼,它們最終都是用來描述記憶體中的一塊空間而已。

在現代作業系統中,分段和分頁都是對記憶體的劃分和管理方式,在功能上是有點重複的。

Linux 以非常有限的方式使用分段,更喜歡使用分頁方式

上面的這張圖,一共定義了 4 個段,每一個段的基地址都是 0x00000000,每一個段的 Limit 都是 0xFFFFF

Limit 的值可以得到:最大值是 2 的 20 次方,只有 1 MB 的空間。

但是其中的 G 欄位表示了段的粒度1 表示粒度是 4 K,因此 1 MB * 4K = 4 GB ,也就是說,段的最大空間是 4 GB

4 個段的基地址定址範圍都是一樣的!主要的區別就是 TypeDPL 欄位不同。

DPL 表示優先順序,2使用者段(程式碼段和資料段) 的優先順序值是 3,優先順序最低(值越大,優先順序越低);2核心段(程式碼段和資料段)的優先順序值是 0,優先順序最高

因此,可以得出 Linux 系統中的一個重要結論:邏輯地址與線性地址,在數值上是相等的,因為基地址是 0x00000000

關於 Linux 中的記憶體分段和分頁定址方式更詳細的內容,我們以後再慢慢聊。


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

推薦閱讀

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

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

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