作 者:道哥,10+年的嵌入式開發老兵。
公眾號:【IOT物聯網小鎮】,專注於:C/C++、Linux操作系統、應用程序設計、物聯網、單片機和嵌入式開發等領域。 公眾號回復【書籍】,獲取 Linux、嵌入式領域經典書籍。
轉 載:歡迎轉載文章,轉載需注明出處。
飯是一口一口的吃,計算機也是一步一步的發展,例如下面這張英特爾公司的 CPU
型號歷史:
為了利用性能越來越強悍的計算機,操作系統的也是在逐步變得膨脹和復雜。
為了從最底層來學習操作系統的一些基本原理,我們只有拋開操作系統的外衣,從最原始的硬件和編程方式來入手,才能了解到一些根本的知識。
這篇文章我們就來繼續挖掘一下,8086 這個開天辟地的處理器中,是如何利用段機制來對內存進行尋址的。
什么是代碼段?
在上一篇文章:Linux 從頭學 01:CPU 是如何執行一條指令的? 中,已經提到過,在處理器的內部,執行每一條指令碼時,CPU
是非常機械、非常單純地從 CS:IP 這 2
個寄存器計算得到轉換后的物理地址,從這個物理地址所指向的內存地址處,讀取一定長度的指令,然后交給邏輯運算單元(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 11
這 3
個字節被讀取之后,IP 寄存器中的內容自動增加
3`,從而指向下一條指令:
當第二條指令碼 BB 44 33
這 3
個字節被讀取之后,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
地址處的數據大小是多少:11H
?2211
H?還是 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
可以拆成 2
個 8
位的寄存器里使用:ah
和 al
。
mov al, [0]
因為指令碼中的 al
寄存器是 8
位,因此 CPU
就只讀取 30000H
處的一個字節 11
,放到 al
寄存器中。(此時 ax
寄存器的高 8
位,也就是 ah
中的值保持不變)
那如果想取 3
個字節或 4
個字節怎么辦?
作為相當古老的處理器,8086
CPU 中是 16
位的,只能對 8
位或 16
位的數據進行操作。
尋址范圍
從以上內容可以總結得出:
代碼段和數據段都是通過 【基地址 + 偏移地址】的方式進行尋址;
基地址都放在各自的段寄存器中,CPU 會自動把段寄存器的值,左移 1 位之后,作為段的基地址;
偏移地址決定了段中的每一個具體的地址,最大偏移地址是 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
個字:后進先出。
在上面介紹數據段的時候,我們都是在指令碼中手動對數據的偏移地址進行設置,指哪打哪,因為這些數據放在什么位置、表示什么意思、怎么來使用,開發者自己心里最門清。
但是棧有些不一樣,雖然它的功能也是用來存儲數據的,但是操作棧的方式,是由處理器提供的一些專門的指令來操作的:push
和 pop
。
push(入棧): 往棧空間中放入一個數據;
pop(出棧): 從棧空間中彈出一個數據;
注意:這里的數據是固定 2 個字節,也就是一個字。
寫過 C/C++
程序的小伙伴都知道:在函數調用的時候,存在入棧操作;在函數返回的時候,存在出棧操作。
既然棧也是指一塊內存空間,那么也就是表現為內存中的一個段。
既然是一個段,那肯定就存在一個段寄存器,用來代表它的基地址,這個棧的段寄存器就是 SS
。
此外,由於棧在入棧和出棧的時候,是按照連續的地址順序操作的,因此處理器為棧也提供了一個偏移地址寄存器:SP
(稱作:棧頂指針),指向棧空間中最頂上的那個元素的位置。
例如下面這張圖:
棧空間的基地址是 1000:0000
,SS: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
個段的基地址和尋址范圍都是一樣的!主要的區別就是 Type
和 DPL
字段不同。
DPL
表示優先級,2
個用戶段(代碼段和數據段) 的優先級值是 3
,優先級最低(值越大,優先級越低);2
個內核段(代碼段和數據段)的優先級值是 0
,優先級最高。
因此,可以得出 Linux
系統中的一個重要結論:邏輯地址與線性地址,在數值上是相等的,因為基地址是 0x00000000。
關於 Linux
中的內存分段和分頁尋址方式更詳細的內容,我們以后再慢慢聊。
推薦閱讀
【1】C語言指針-從底層原理到花式技巧,用圖文和代碼幫你講解透徹
【2】一步步分析-如何用C實現面向對象編程
【3】原來gdb的底層調試原理這么簡單
【4】內聯匯編很可怕嗎?看完這篇文章,終結它!
其他系列專輯:精選文章、C語言、Linux操作系統、應用程序設計、物聯網