02. x86處理器執行方式

阿狸喜羊羊發表於2024-04-27


【CPU指令】

CPU控制器透過讀取儲存器中的指令確定要執行的功能,CPU執行需要不停的讀取指令,計算機啟動後CPU會從固定地址處開始讀取指令,首先讀取 NOR Flash 儲存器中的韌體,韌體執行完畢後引導作業系統執行。

指令是一個二進位制資料,主要由如下兩部分組成:
1.操作碼,設定控制器執行的功能,比如讀寫資料、數學運算、邏輯運算。
2.地址碼,設定指令讀寫資料的位置,可以是暫存器、記憶體單元、IO埠,若要將一個固定的資料寫入到其它位置,可以將資料直接儲存在地址碼中,儲存在地址碼中的資料稱為立即數。

x86是英特爾發明的一種複雜指令集,最早用於16位的8086處理器,之後擴充套件到32位處理器,進入64位處理器時代後,由AMD擴充套件為支援64位處理器,此版本的x86稱為x86-64,或AMD64。

在計算機中,一個資料既可以用來表示一個數學中使用的資料,也可以表示指令,為了區分兩者,之後的文章將資料分為數學資料和指令資料,以免混亂。


【指令執行方式】

指令執行順序

CPU使用專用暫存器記錄指令資料所在的記憶體地址,依靠此暫存器讀取指令資料執行,指令的執行順序有兩種:

1.順序執行,本條指令執行完畢後,CPU自動增加指令地址暫存器的值,定位到下一條指令所在的地址,指令按儲存順序依次執行。
2.跳轉執行,本條指令執行完畢後,不按照儲存順序執行下一條指令,稱為跳轉執行,可以向前跳轉也可以向後跳轉,跳轉執行由跳轉指令實現,跳轉指令會修改指令地址暫存器的值,從而實現跳轉執行。

指令流水線與轉移預測

CPU為了提高指令的執行速度,會將每一條指令的執行分為多個步驟,使用流水線的方式執行每一個步驟,一條指令正在執行時,其它指令已經在流水線中進行前期準備工作。

若遇到跳轉指令,CPU會進行分析,預測將要跳轉到的地址,然後讀取預測地址處的指令進入流水線,這個預測的正確率一般為90%以上,但是無法做到100%,若判斷出錯,就要清空指令流水線,重新來過,為了避免這種情況發生,在編寫程式程式碼時應該儘量減少使用有條件跳轉指令,高階程式語言的編譯器會使用多條非跳轉指令的組合代替某些有條件跳轉指令。

指令排程策略

程式指令按照自身設定的順序去執行,但是程式設定的指令執行順序並非最優、最快的,CPU為了更快的執行指令,會將指令執行順序重新排序,稱為動態排程,同時在本條指令進入等待狀態後(等待某個資料傳輸)執行下一條指令,動態排程的前提是不會影響指令原先執行順序的最終執行結果。

高階語言編譯器也會進行類似最佳化,將指令進行重新排序,不會完全按照程式碼的編寫順序進行編譯,稱為靜態排程最佳化,可以減輕CPU動態排程的工作量。

指令執行限制

最早的計算機同一段時間只需要執行一個程式,程式的執行無需做任何限制,指令可以使用CPU的任何資源、可以讀寫任意記憶體地址、可以隨意跳轉執行。
之後人們需要在計算機中同時執行多個程式,此時就需要使用一個程式管理其它所有程式的執行,提供管理功能的程式稱為作業系統。

x86處理器為了配合作業系統實現管理功能將指令的執行分為4個許可權等級,使用0-3表示。
0級許可權最高,可以使用CPU的任何資源、可以讀寫任意記憶體地址、可以跳轉到低許可權指令執行。(注:80486之後,0級指令也不能在只讀記憶體區寫入資料,需要首先取消只讀限制)
3級許可權最低,不能使用CPU的某些暫存器、只能讀寫作業系統為其分配的記憶體空間、不能跳轉到高許可權指令執行。

作業系統使用0級許可權執行指令,從而控制使用者程式的執行,使用者程式使用3級許可權,1-2級許可權被作業系統廢棄不用。

x86處理器啟動後,預設不對指令進行任何限制,此模式稱為真實模式,指令可以透過修改控制暫存器CR0的PG位進入保護模式,保護模式會開啟指令的限制功能,進入保護模式後就不能再返回真實模式。


【處理器中斷】

處理器工作時可能會發生各種事件,此時需要暫停正在執行的程式,轉而去處理事件,處理器中斷功能提供此功能,中斷用於暫停正在執行的程式,每個中斷對應一個事件,每個中斷都有一個編號,CPU透過中斷編號確定事件型別,每個中斷都可以繫結一個事件處理程式,發生中斷後CPU暫停正在執行的程式,然後執行事件處理程式,處理程式執行完畢後返回之前的程式執行。

x86處理器使用地址 0 - 1023 的記憶體單元儲存事件處理程式的執行入口地址,這段記憶體空間也稱為中斷向量表。

CPU執行中斷處理程式之前,首先使用入棧指令將標誌暫存器、CS、IP暫存器中的資料儲存到記憶體中,稱為儲存現場,然後從中斷向量表中讀取事件處理程式的地址修改CS、IP暫存器,執行處理程式,處理程式執行完畢後,CPU不會自動將標誌暫存器CS、IP暫存器的值恢復,恢復現場的工作由中斷處理程式完成,恢復現場使用iret指令即可,執行iret執行時相當於執行如下3條指令:

pop ip
pop cs
popf

中斷分為兩大類,內中斷和外中斷。

內中斷

由CPU內部產生的中斷稱為內中斷,x86處理器在以下情況會發出內中斷:

1.執行除法指令時出錯,比如除數為0,比如被除數長度不夠。
2.處理器啟用了單步中斷功能,每執行一條指令就產生一個內中斷,用於除錯程式。
3.訪問記憶體錯誤,比如向只讀記憶體區寫入資料、3級指令訪問不屬於自己的記憶體空間。
4.執行int、into指令。

單步中斷用於除錯程式,控制器每執行完一條指令就會檢測標誌暫存器的TF位,若為1則產生單步中斷,之後CPU執行儲存現場工作,此時會將標誌暫存器的TF、IF位設定為0,避免在執行處理程式時一直產生單步中斷。

外中斷

由CPU之外的裝置產生的中斷稱為外中斷,外中斷分為可遮蔽中斷和不可遮蔽中斷,多數外中斷都是可遮蔽的,可以透過修改標誌暫存器的IF位遮蔽這些外中斷。

CPU對外連線的兩個引腳用於外中斷功能(對應可遮蔽中斷和不可遮蔽中斷),CPU每執行完一條指令就會檢查外中斷引腳的電壓,外部裝置透過改變此引腳的電壓來告知CPU是否有外部事件發生,比如按下鍵盤按鍵後會向CPU發出一個外中斷,告知CPU輸入裝置有資料傳送,硬體裝置執行出錯時也會向CPU發出一個外中斷。


【多位元組資料儲存方式】

多位元組資料在記憶體中的排序方式有兩種:大端排序、小端排序,資料使用哪種排序方式沒有固定規則,不同處理器有不同的安排,x86處理器使用小端排序方式。

大端排序方式

資料的低位位元組儲存在高地址中,高位位元組儲存在低地址中,比如一個4位元組資料 0x12345678(0x表示16進位制,2位16進位制數字對應1位元組),需要儲存在地址0-3的記憶體單元中,地址0儲存高位的0x12,地址0-3依次儲存的資料為 0x12345678。

小端排序方式

資料的低位位元組儲存在低地址中,高位位元組儲存在高地址中,比如一個4位元組資料 0x12345678(0x表示16進位制,2位16進位制數字對應1位元組),需要儲存在地址0-3的記憶體單元中,地址0儲存低位的0x78,地址0-3依次儲存的資料為 0x78563412。


【記憶體管理方式】

最簡單的處理器讀寫記憶體單元時直接使用一個資料指定操作的記憶體地址,此時程式必須放在固定的記憶體地址處,放在其他位置就會執行出錯,比如程式需要跳轉執行時,跳轉到的地址已經寫死,無法改變。
這種記憶體管理方式適用於功能簡單的微控制器,它只執行一組固定的程式,程式儲存在固定的地址中。

當計算機需要同時執行多個程式時,記憶體管理方式就會變的複雜,程式需要放在記憶體的任何位置都能正常執行,並且每個程式只能使用自己佔用的記憶體空間,不能使用其它程式佔用的記憶體空間,為此x86提供了分段、分頁兩種記憶體管理方式。

分段管理方式

此方式下處理器將記憶體分為多組使用,每一組稱為一個段,記憶體地址使用兩個資料相加得出,這樣做的目的有兩個:
1.形成更大範圍的記憶體地址空間,使用容量更大的記憶體。
2.方便程式重定位,放在記憶體中的哪個位置都能夠正確執行。

記憶體分段後,指令使用兩個資料指定記憶體地址,分別稱為段地址、偏移地址。
1.段地址,儲存記憶體分段在記憶體空間中的起始地址。
2.偏移地址,儲存分段內部儲存單元的編號,編號從0開始分配。

記憶體分段方式起源於8086處理器,8086是16位處理器,記憶體地址暫存器也是16位的,但是8086為了使用容量更大的記憶體設計了20位的記憶體地址。
為了讓16位的8086形成20位的記憶體地址,處理器使用兩個資料相加得出記憶體地址,但是兩個16位資料相加最大也只能形成長度17位的資料,8086的解決方法是將段地址乘以16,然後再與偏移地址相加,這樣就能形成一個長度20位的二進位制資料,乘以16是因為可以使用右移4位的方法代替乘法,右移運算比乘法運算更快,第一個記憶體分段地址為0,0 * 16 = 0,所以第一個記憶體分段依然從0開始。

作業系統啟動後,首先在記憶體中建立全域性描述符表,用於記錄每個分段的起始地址、長度、訪問屬性等等資訊,之後啟用CPU的保護模式。
程式執行時,只能佔用作業系統設定好的分段,不能自己建立分段,作業系統會為每個執行的程式建立一個區域性描述符表,用於記錄此程式佔用的記憶體分段相關資訊。
指令讀寫記憶體單元時只需要指定偏移地址,之後CPU在描述符表中查詢段地址,並與偏移地址相加合成記憶體地址,這樣就實現了將程式放在記憶體的任何位置都能正確執行。

分頁管理方式

記憶體分段管理方式不能設定很小的分段,使用不方便,容易造成浪費,作業系統為程式分配的分段尺寸不一定是程式需要的容量。

為了解決這個問題,從80386開始有了記憶體分頁管理方式,處理器將記憶體按照4KB的大小分組,每組稱為一個頁,程式以容量更小的頁為單位佔用記憶體,避免浪費,CPU啟動後預設以分段方式使用記憶體,之後啟動作業系統,作業系統為程序設定頁表後切換到記憶體分頁使用方式。

在32位x86處理器中,啟用分頁機制後,分段方式依然保留,記憶體地址依然使用兩個資料相加的方式得出,目的是為了形成更大的記憶體地址,使用容量更大的記憶體,畢竟一個32位的二進位制資料最多隻能定址4GB的記憶體。
在64位x86-64處理器中,使用64位的記憶體地址暫存器,64位資料可以定址足夠大的記憶體空間,啟用分頁機制後,分段機制將被廢棄,不再使用,只使用一個64位的資料指定記憶體地址即可。

無論是32位處理器還是64位處理器,啟用分頁機制後,指令使用的記憶體地址都不再是真實記憶體地址,而是一箇中間地址,稱為虛擬地址、或線性地址。

計算機檔案有一個檔案偏移地址的概念,我更習慣稱其為檔案內部地址,它的作用是為檔案內部資料按儲存順序分配編號,編號從0開始,第一個位元組分配編號0、第二個位元組編號為1、直到最後一個位元組,透過這個編號呼叫檔案資料很方便,無需知道檔案存放在哪個位置,就好比使用火車託運貨物,你只需要記錄火車車廂的編號即可,無論火車走到哪裡,你只要告訴管理人員你的貨物在幾號車廂就能拿走你的貨物,至於火車停在哪個軌道上,你無需關心,那是管理人員的事。

虛擬地址的作用就類似檔案內部地址,它用於為程式內部資料分配編號,無論程式放在記憶體中的哪個位置,都可以透過內部地址呼叫其中的資料。

處理器預設以真實模式、記憶體分段管理方式執行,作業系統啟動後,首先啟用處理器的保護模式,之後啟用記憶體分頁管理方式,程式執行時作業系統按頁為其分配記憶體,併為程序建立一個頁表,用於記錄程式佔用了哪些頁、每個頁的屬性資訊(比如此頁是否為只讀),此時虛擬地址會與記憶體實體地址建立繫結關係,程式指令透過虛擬地址說明要呼叫程式內的第幾個資料,至於這個虛擬地址對應哪個記憶體地址,程式不關心,也無需知道,CPU會根據頁表中的記錄將虛擬地址轉換為記憶體實體地址,之後執行指令,從而實現將程式放在記憶體中的哪個位置都可以正確執行。

注:
x86-64處理器的記憶體地址暫存器長度為64位,使用一個64位的二進位制資料可以形成非常大的記憶體地址空間,但實際上個人使用者遠用不到這麼大的記憶體地址,所以某些處理器只使用其中的48位,這樣可以簡化虛擬地址的轉換步驟,指令執行速度更快,剩餘的高16位有兩種狀態,作業系統核心指令將其全部設定為1(只是單純設定為1,不參與頁錶轉換),使用者程式指令將其全部設定為0,若不是這兩種狀態則是不規範的虛擬地址,處理器無法使用,而伺服器CPU需要使用更大容量記憶體,某些伺服器CPU也會開放高16位中的某些位。


【暫存器】

暫存器按功能可以分為5類:通用暫存器、段地址暫存器、偏移地址暫存器、標誌暫存器、控制暫存器。

通用暫存器

通用暫存器可以儲存任何資料,又分為多種長度型別,64位暫存器只存在於64位處理器中,但是同時包含32位、16位、8位暫存器。

x86-64處理器的通用暫存器如下:

64位:rax、rbx、rcx、rdx,繼承自最早的x86指令集,x86-64將其擴充套件為64位。
64位:r8、r9、r10、r11、r12、r13、r14、r15,x86-64指令集新增通用暫存器。

32位:eax、ebx、ecx、edx,由64位的rax-rdx拆分形成,不能與對應的64位暫存器同時使用。
32位:r8d、r9d、r10d、r11d、r12d、r13d、r14d、r15d,由64位的r8-r15拆分形成。

16位:ax、bx、cx、dx,由32位的eax-edx拆分形成。
16位:r8w、r9w、r10w、r11w、r12w、r13w、r14w、r15w,由64位的r8-r15拆分形成。

8位:al、bl、cl、dl、ah、bh、ch、dh,由16位的ax-dx拆分形成,ax的低8位為al,高8位為ah。
8位:r8b、r9b、r10b、r11b、r12b、r13b、r14b、r15b,由64位的r8-r15拆分形成。

段地址暫存器

CS,儲存指令資料的段地址。
DS,儲存數學資料的段地址。
SS,儲存棧空間的段地址。
ES、FS、GS,輔助段暫存器,供程式自由安排使用。

啟用保護模式後,CS暫存器儲存的不再是段地址,而是段選擇子,段選擇子用於儲存了作業系統設定好的記憶體分段編號、並記錄分段內資料的屬性資訊,比如指令許可權級別。

在x86-64處理器中,啟用記憶體分頁管理方式後,段地址暫存器將不再使用。

偏移地址暫存器

16位的8086處理器定義了5個偏移地址暫存器:

IP,儲存指令資料的偏移地址,CPU透過CS+IP合成指令資料所在地址。
SP,儲存棧空間的偏移地址,push、pop指令透過SS+SP合成要操作的記憶體地址。
BP,儲存棧空間的偏移地址,mov指令讀寫棧空間時使用BP暫存器指定偏移地址,不與push、pop棧指令共用SP偏移地址暫存器,避免混亂。
SI、DI,儲存數學資料的偏移地址,與DS暫存器合成要操作的記憶體地址,服務於讀寫陣列相關指令,比如stosb、movsb。

其中SP、BP、SI、DI也可以當做通用暫存器使用,在x86-64指令集中,SP、BP、SI、DI又可以拆分為4個8位暫存器:SPL、BPL、SIL、DIL,這4個8位暫存器只存在於x86-64指令集中。

在32位x86處理器中,偏移地址暫存器長度為32位,使用32位模式時需要在名稱前新增字母E,比如:EIP、EDI。
在64位x86處理器中,偏移地址暫存器長度為64位,使用64位模式時需要在名稱前新增字母R,比如DI表示16位模式、EDI表示32位模式、RDI表示64位模式。

標誌暫存器

標誌暫存器(FLAGS)用於記錄指令執行結果、設定處理器某些功能是否啟用,標誌暫存器使用二進位制位儲存資訊和設定功能,常用位如下:
0位,進位標誌位(CF),記錄運算指令是否發生進位行為。
2位,奇偶標誌位(PF),記錄運算指令結果的低8位中數字1的數量是雙數還是單數,若數量為雙數或0則PF儲存1,若數量為單數PF儲存0。
4位,AF位,服務於BCD資料。
6位,0標誌位(ZF),記錄數學運算、邏輯運算指令執行結果中是否所有二進位制位都為0,若都為0則ZF位儲存1,否則儲存0,用於判斷執行結果是否為0。
7位,符號標誌位(SF),記錄有符號數運算結果是否為負數,若為負則SF儲存1,若為正儲存0。
8位,單步終端標誌位(TF),設定是否產生單步中斷,若為1則每執行一條指令就產生一個單步中斷。
9位,遮蔽外中斷標誌位(IF),設定處理器是否接收可被遮蔽的的外中斷,若為1則接收,為0不接收。
10位,方向標誌位(DF),設定迴圈讀寫資料指令(stosb、lodsb、movsb)的讀寫方向,地址遞增或是遞減。
11位,溢位標誌位(OF),記錄有符號數運算是否發生溢位,若溢位則OF設定為1,否則為0。
12-13位,IOPL位,設定指令處於不同許可權時,讀寫IO地址空間的權利。
14位,NT位,控制IRET指令的執行方式。
16位,RF位,設定CPU是否接收除錯程式時產生的故障,RF為0則接收,RF為1則不接收。
17位,VM位,模擬8086CPU工作方式的開關。
18位,AC位,設定是否開啟地址對齊檢查,需要與控制暫存器CR0的AM位共同使用。

其中CF位有三種功能:
1.記錄執行無符號數加法運算時結果是否因長度過大導致溢位,若溢位則CF位儲存1,否則CF為0。
2.記錄執行無符號數減法運算時低位是否向高位借位,若參與減法運算的資料長度超過暫存器長度,處理器將減數分為高低位兩部分進行運算,若低位減法發生了向高位借位則CF儲存1,否則CF為0,之後進行高位減法時會額外減去CF位的值。
3.記錄乘法運算結果是否使用了儲存高位的暫存器,執行乘法運算時計算結果使用兩個暫存器儲存,防止乘法結果長度過大導致溢位,若使用了高位暫存器則CF為1,否則CF為0。

控制暫存器

控制暫存器用於設定處理器工作模式、快取的禁用與啟用、虛擬地址的轉換等等功能。
32位x86處理器有4個控制暫存器:CR0 - CR3,其中CR1保留不用,而x86-64定義了CR0-CR15共16個控制暫存器,但只使用其中的CRO、CR2、CR3、CR4、CR8。

CR0的PE位用於設定是否啟用保護模式,若為1則啟用保護模式,進入保護模式後不能再回到真實模式,PG位用於設定是否啟用記憶體分頁管理模式,啟用分頁模式之前必須首先啟用保護模式。


【快取】

記憶體的讀寫速度雖然很快,但是依然不能滿足CPU的需求,為此CPU設計了快取,快取是位於CPU內部的儲存器,讀寫速度比記憶體更快。
不同處理器的快取容量以及佈局不同,快取內部的儲存單元會進行多級分組,首先分為多個大組,大組再分為多個小組,小組有32位元組、64位元組、或其它容量,快取小組也稱為快取行或快取塊,不同的人有不同的稱呼,建立記憶體對映時以快取小組容量建立。

快取由CPU自動管理,程式無法直接讀寫,快取是記憶體的對映體,那什麼是對映呢,對映表示行為結果的轉移,這很像中國古裝電影裡的一個劇情,製作一個紙人並在上面寫上某人的名字,之後做法,即可將紙人變成某人的對映體,對紙人施加傷害會轉移到原體中,原體的屬性也會轉移到紙人中,快取就是如此,CPU將常用的一組資料從記憶體讀取到快取,之後在快取中讀寫資料,對快取的操作會轉移到記憶體中,長時間不使用的記憶體對映體會在快取中刪除。

快取對映記憶體的方式

讀取一個記憶體地址時CPU會將此資料以及周圍的一組資料全部讀取到快取塊中(快取塊完全填充滿之前指令可以照常執行,無需等待),因為多數情況下之後執行的指令會使用此資料周圍的其它資料,之後讀取記憶體時會首先判斷快取是否對映了此記憶體地址,若有對映則直接讀取快取,若沒有對映則重複以上步驟。

向記憶體地址寫入資料時CPU首先判斷快取是否對映了此地址,若有對映則直接將資料寫入快取,之後執行下一條指令,指令無需等待CPU將資料從快取寫入記憶體,若沒有對映則直接將資料寫入記憶體,多核CPU還會涉及到每個核心專用快取的更新,保證多個核心快取的一致性,這裡不詳細討論此問題。

地址對齊

因為快取的存在,CPU實際上讀取的是快取,而非記憶體,記憶體資料會首先讀取到快取中再使用,CPU在讀取記憶體資料時並非只讀取需要的資料,而是將附近資料一起讀取到快取,對於64位CPU,讀取記憶體時每次可以讀取8位元組(8*8=64位),此時CPU可以對記憶體單元按8位元組長度進行分組使用,比如地址0-7、8-15、16-23、24-31,然後按此分組將資料從記憶體讀取到快取,這樣每次讀取的記憶體地址低3位都為0,這3個低位值可以無需記錄,從而簡化快取內部結構、降低發熱量、增加器件執行頻率,所以一個資料在記憶體中儲存時就需要儘量放在同一組記憶體單元中,方便CPU整體讀取,此時資料的儲存需要滿足如下規則:

2位元組資料,需要放在地址為2的倍數記憶體地址中。
4位元組資料,需要放在地址為4的倍數記憶體地址中。
8位元組資料,需要放在地址為8的倍數記憶體地址中。

若有一個長度8位元組的資料,放在起始地址為4的8個記憶體單元中,則CPU需要分2次讀取,這種儲存方式就是地址不對齊,對於訪問非地址對齊資料的行為,不同CPU有不同的執行結果,x86處理器預設只是增加資料的讀寫時間,降低讀寫速度,而ARM處理器預設會產生錯誤和異常。


下面使用C語言程式碼驗證地址對齊方式,計算機使用x86-64處理器、linux作業系統、gcc編譯器。

#include <stdio.h>
char a = 1;         //長度1位元組
int b = 2;          //長度4位元組
short c = 3;        //長度2位元組
long long d = 4;    //長度8位元組
int main()
{
	printf("%d\n%d\n%d\n%d\n", a,b,c,d);
	return 0;
}

變數a、b、c、d的儲存方式如下:

Contents of section .data:
 404028 00000000 00000000 00000000 00000000
 404038 01000000 02000000 03000000 00000000
 404048 04000000 00000000

左邊的404028表示虛擬地址(使用16進位制),右邊的資料為程式全域性變數,同樣使用16進製表示,2位16進位制數字對應一個位元組,使用小端序儲存方式,實際檢視時需要按位元組重新排序。

地址404038處儲存變數a,變數b並非在404039處,因為此處並非4的倍數,而是在40403c處儲存,40403c是4的倍數,之後變數c儲存於404040處,最後的變數d需要儲存在倍數8的地址中,所以中間空餘6個位元組,在404048處開始儲存。

相關文章