一口氣看完45個暫存器,CPU核心技術大揭祕

軒轅之風發表於2020-10-21

序言

前段時間,我連續寫了十來篇CPU底層系列技術故事文章,有不少讀者私信我讓我寫一下CPU的暫存器。

暫存器這個太多太複雜,不適合寫故事,拖了很久,總算是寫完了,這篇文章就來詳細聊聊x86/x64架構的CPU中那些紛繁複雜的暫存器們。

長文預警,時速較快,請繫好安全帶~起飛~

 

 

自1946年馮·諾伊曼領導下誕生的世界上第一臺通用電子計算機ENIAC至今,計算機技術已經發展了七十多載。

從當初專用於數學計算的龐然大物,到後來大型機伺服器時代,從個人微機技術蓬勃發展,到網際網路浪潮席捲全球,再到移動網際網路、雲端計算日新月異的當下,計算機變的形態各異,無處不在。

這七十多年中,出現了數不清的程式語言,通過這些程式語言,又開發了無數的應用程式。

 

 

可無論什麼樣的應用程式,什麼樣的程式語言,最終的程式邏輯都是要交付給CPU去執行實現的(當然這裡有些不嚴謹,除了CPU,還有協處理器、GPU等等)。所以瞭解和學習CPU的原理都是對計算機基礎知識的夯實大有裨益。

在七十多年的漫長曆程中,也湧現了不少架構的CPU。

  • MIPS
  • PowerPC
  • x86/x64
  • IA64
  • ARM
  • ······

這篇文章就以市場應用最為廣泛的x86-x64架構為目標,通過學習瞭解它內部的100個暫存器功能作用,來串聯闡述CPU底層工作原理。

通過這篇文章,你將瞭解到:

  • CPU指令執行原理
  • 記憶體定址技術
  • 軟體除錯技術原理
  • 中斷與異常處理
  • 系統呼叫
  • CPU多工技術

什麼是暫存器?

暫存器是CPU內部用來存放資料的一些小型儲存區域,用來暫時存放參與運算的資料和運算結果以及一些CPU執行需要的資訊。

x86架構CPU走的是複雜指令集(CISC) 路線,提供了豐富的指令來實現強大的功能,與此同時也提供了大量暫存器來輔助功能實現。這篇文章將覆蓋下面這些暫存器:

  • 通用暫存器
  • 標誌暫存器
  • 指令暫存器
  • 段暫存器
  • 控制暫存器
  • 除錯暫存器
  • 描述符暫存器
  • 任務暫存器
  • MSR暫存器

通用暫存器

首當其衝的是通用暫存器,這些的暫存器是程式執行程式碼最最常用,也最最基礎的暫存器,程式執行過程中,絕大部分時間都是在操作這些暫存器來實現指令功能。

所謂通用,即這些暫存器CPU沒有特殊的用途,交給應用程式“隨意”使用。注意,這個隨意,我打了引號,對於有些暫存器,CPU有一些潛規則,用的時候要注意。

  • eax: 通常用來執行加法,函式呼叫的返回值一般也放在這裡面
  • ebx: 資料存取
  • ecx: 通常用來作為計數器,比如for迴圈
  • edx: 讀寫I/O埠時,edx用來存放埠號
  • esp: 棧頂指標,指向棧的頂部
  • ebp: 棧底指標,指向棧的底部,通常用ebp+偏移量的形式來定位函式存放在棧中的區域性變數
  • esi: 字串操作時,用於存放資料來源的地址
  • edi: 字串操作時,用於存放目的地址的,和esi兩個經常搭配一起使用,執行字串的複製等操作

在x64架構中,上面的通用暫存器都擴充套件成為64位版本,名字也進行了升級。當然,為了相容32位模式程式,使用上面的名字仍然是可以訪問的,相當於訪問64位暫存器的低32位。

rax rbx rcx rdx rsp rbp rsi rdi

除了擴充套件原來存在的通用暫存器,x64架構還引入了8個新的通用暫存器:

r8-r15

在原來32位時代,函式呼叫時,那個時候通用暫存器少,引數絕大多數時候是通過執行緒的棧來進行傳遞(當然也有使用暫存器傳遞的,比如著名的C++ this指標使用ecx暫存器傳遞,不過能用的暫存器畢竟不多)。

進入x64時代,暫存器資源富裕了,引數傳遞絕大多數都是用暫存器來傳了。暫存器傳參的好處是速度快,減少了對記憶體的讀寫次數。

當然,具體使用棧還是用暫存器傳引數,這個不是程式語言決定的,而是編譯器在編譯生成CPU指令時決定的,如果編譯器非要在x64架構CPU上使用執行緒棧來傳參那也不是不行,這個對高階語言是無感知的。

標誌暫存器

標誌暫存器,裡面有眾多標記位,記錄了CPU執行指令過程中的一系列狀態,這些標誌大都由CPU自動設定和修改:

  • CF 進位標誌
  • PF 奇偶標誌
  • ZF 零標誌
  • SF 符號標誌
  • OF 補碼溢位標誌
  • TF 跟蹤標誌
  • IF 中斷標誌
  • ······

 

 

在x64架構下,原來的eflags暫存器升級為64位的rflags,不過其高32位並沒有新增什麼功能,保留為將來使用。

指令暫存器

eip: 指令暫存器可以說是CPU中最最重要的暫存器了,它指向了下一條要執行的指令所存放的地址,CPU的工作其實就是不斷取出它指向的指令,然後執行這條指令,同時指令暫存器繼續指向下面一條指令,如此不斷重複,這就是CPU工作的基本日常。

而在漏洞攻擊中,黑客想盡辦法費盡心機都想要修改指令暫存器的地址,從而能夠執行惡意程式碼。

同樣的,在x64架構下,32位的eip升級為64位的rip暫存器。

段暫存器

段暫存器與CPU的記憶體定址技術緊密相關。

早在16位的8086CPU時代,記憶體資源寶貴,CPU使用分段式記憶體定址技術:

 

 

16位的暫存器能定址的範圍是64KB,通過引入段的概念,將記憶體空間劃分為不同的區域:分段,通過段基址+段內偏移段方式來定址。

這樣一來,段的基地址儲存在哪裡呢?8086CPU專門設定了幾個段暫存器用來儲存段的基地址,這就是段暫存器段的由來。

段暫存器也是16位的。

段暫存器有下面6個,前面4個是早期16位模式就引入了,到了32位時代,又新增了fs和gs兩個段暫存器。

  • cs: 程式碼段
  • ds: 資料段
  • ss: 棧段
  • es: 擴充套件段
  • fs: 資料段
  • gs: 資料段

段暫存器裡面儲存的內容與CPU當前工作的記憶體定址模式緊密相關。

當CPU處於16位實地址模式下時,段暫存器儲存段的基地址,定址時,將段暫存器內容左移4位(乘以16)得到段基地址+段內偏移得到最終的地址。

當CPU工作於保護模式下,段暫存器儲存的內容不再是段基址了,此時的段暫存器中存放的是段選擇子,用來指示當前這個段暫存器“指向”的是哪個分段。

注意我這裡的指向打了引號,段暫存器中儲存的並不是記憶體段的直接地址,而是段選擇子,它的結構如下:

 

 

16個bit長度的段暫存器內容劃分了三個欄位:

  • PRL: 特權請求級,就是我們常說的ring0-ring3四個特權級。
  • TI: 0表示用的是全域性描述符表GDT,1表示使用的是區域性描述符表LDT。
  • Index: 這是一個表格中表項的索引值,這個表格叫記憶體描述符表,它的每一個表項都描述了一個記憶體分段。

這裡提到了兩個表,全域性描述符表GDT和區域性描述符表LDT,關於這兩個表的介紹,下面介紹描述符暫存器時再詳述,這裡只需要知道,這是CPU支援分段式記憶體管理需要的表格,放在記憶體中,表格中的每一項都是一個描述符,記錄了一個記憶體分段的資訊。

保護模式下的段暫存器和段描述符到最後的記憶體分段,通過下圖的方式聯絡在一起:

 

 


通用暫存器、段暫存器、標誌暫存器、指令暫存器,這四組暫存器共同構成了一個基本的指令執行環境,一個執行緒的上下文也基本上就是這些暫存器,在執行執行緒切換的時候,就是修改它們的內容。

 

 

控制暫存器

控制暫存器是CPU中一組相當重要的暫存器,我們知道eflags暫存器記錄了當前執行執行緒的一系列關鍵資訊。

那CPU執行過程中自身的一些關鍵資訊儲存在哪裡呢?答案是控制暫存器!

 

 

32位CPU總共有cr0-cr4共5個控制暫存器,64位增加了cr8。他們各自有不同的功能,但都儲存了CPU工作時的重要資訊:

  • cr0: 儲存了CPU控制標記和工作狀態
  • cr1: 保留未使用
  • cr2: 頁錯誤出現時儲存導致出錯的地址
  • cr3: 儲存了當前程式的虛擬地址空間的重要資訊——頁目錄地址
  • cr4: 也儲存了CPU工作相關以及當前人任務的一些資訊
  • cr8: 64位新增擴充套件使用

其中,CR0尤其重要,它包含了太多重要的CPU資訊,值得單獨關注一下:

 

 

一些重要的標記位含義如下:

PG: 是否啟用記憶體分頁

AM: 是否啟用記憶體對齊自動檢查

WP: 是否開啟記憶體防寫,若開啟,對只讀頁面嘗試寫入時將觸發異常,這一機制常常被用來實現寫時複製功能

PE: 是否開啟保護模式

除了CR0,另一個值得關注的暫存器是CR3,它儲存了當前程式所使用的虛擬地址空間的頁目錄地址,可以說是整個虛擬地址翻譯中的頂級指揮棒,在程式空間切換的時候,CR3也將同步切換。

除錯暫存器

在x86/x64CPU內部,還有一組用於支援軟體除錯的暫存器。

除錯,對於我們程式設計師是家常便飯,必備技能。但你想過你的程式能夠被除錯背後的原理嗎?

程式能夠被除錯,關鍵在於能夠被中斷執行和恢復執行,被中斷的地方就是我們設定的斷點。那程式是如何能在遇到斷點的時候停下來呢?

 

 

對於一些解釋執行(PHP、Python、JavaScript)或虛擬機器執行(Java)的高階語言,這很容易辦到,因為它們的執行都在直譯器/虛擬機器的掌控之中。

而對於像C、C++這樣的“底層”程式語言,程式程式碼是直接編譯成CPU的機器指令來執行的,這就需要CPU來提供對於除錯的支援了。

對於通常的斷點,也就是程式執行到某個位置下就停下來,這種斷點實現的方式,在x86/x64上,是利用了一條軟中斷指令:int 3來進行實現的。

注意,這裡的int不是指高階語言裡面的整數,而是表示interrupt中斷的意思,是一條彙編指令,int 3則表示中斷向量號為3的中斷。

在我們使用偵錯程式下斷點時,偵錯程式將會把對應位置的原來的指令替換為一個int 3指令,機器碼為0xCC。這個動作對我們是透明的,我們在偵錯程式中看到的依然是原來的指令,但實際上記憶體中已經不是原來的指令了。

順便提一句,兩個0xCC是漢字【燙】的編碼,在一些編譯器裡,會給執行緒的棧中填充大量的0xCC,如果程式出錯的時候,我們經常會看到很多燙燙燙出現,就是這個原因。

 

 

言歸正傳,CPU在執行這條int 3指令時,將自動觸發中斷處理流程(雖然這實際上不是一個真正的中斷),CPU將取出IDTR暫存器指向的中斷描述符表IDT的第3項,執行裡面的中斷處理函式。

而這個中斷描述符表,早在作業系統啟動之初,就已經提前安排好了,所以執行這條指令後,作業系統的中斷處理函式將介入,來處理這一事件。

後面的過程就多了,簡單來說,作業系統會把觸發這一事件的程式凍結起來,隨後將這一事件傳送到偵錯程式,偵錯程式拿到之後就知道目標程式觸發斷點了。這個時候,我們們程式設計師就能通過偵錯程式的UI互動介面或者命令列除錯介面來除錯目標程式,檢視堆疊、檢視記憶體、變數都隨你。

如果我們要繼續執行,偵錯程式將會把之前修改的int 3指令給恢復回去,然後告知作業系統:我處理完了,把目標程式解凍吧!

上面簡單描述了一下普通斷點的實現原理。現在思考一個場景:我們發現一個bug,某個全域性整數型變數的值老是莫名其妙被修改,但你發現有很多執行緒,很多函式都有可能會去修改這個變數,你想找出到底誰幹的,怎麼辦?

這個時候上面的普通斷點就沒辦法了,你需要一種新的斷點:硬體斷點

這時候就該本小節的主人公除錯暫存器登場表演了。

 

 

在x86架構CPU內部,提供了8個除錯暫存器DR0~DR7。

DR0~DR3:這是四個用於儲存地址的暫存器

DR4~DR5:這兩個有點特殊,受前面提到的CR4暫存器中的標誌位DE位控制,如果CR4的DE位是1,則DR4、DR5是不可訪問的,訪問將觸發異常。如果CR4的DE位是0,則DR4和DR5將會變成DR6和DR7的別名,相當於做了一個軟連結。這樣做是為了將DR4、DR5保留,以便將來擴充套件除錯功能時使用。

DR6:這個暫存器中儲存了硬體斷點觸發後的一些狀態資訊

DR7:除錯控制暫存器,這裡面記錄了對DR0-DR3這四個暫存器中儲存地址的中斷方式(是對地址的讀,還是寫,還是執行)、資料長度(1/2/4個位元組)以及作用範圍等資訊

通過偵錯程式的介面設定硬體斷點後,CPU在執行程式碼的過程中,如果滿足條件,將自動中斷下來。

回答前面提出的問題,想要找出是誰偷偷修改了全域性整形變數,只需要通過偵錯程式設定一個硬體寫入斷點即可。

描述符暫存器

所謂描述符,其實就是一個資料結構,用來記錄一些資訊,‘描述’一個東西。把很多個描述符排列在一起,組成一個表,就成了描述符表。再使用一個暫存器來指向這個表,這個暫存器就是描述符暫存器

在x86/x64系列CPU中,有三個非常重要的描述符暫存器,它們分別儲存了三個地址,指向了三個非常重要的描述符表。

gdtr: 全域性描述符表暫存器,前面提到,CPU現在使用的是段+分頁結合的記憶體管理方式,那系統總共有那些分段呢?這就儲存在一個叫全域性描述符表(GDT)的表格中,並用gdtr暫存器指向這個表。這個表中的每一項都描述了一個記憶體段的資訊。

ldtr: 區域性描述符表暫存器,這個暫存器和上面的gdtr一樣,同樣指向的是一個段描述符表(LDT)。不同的是,GDT是全域性唯一,LDT是區域性使用的,可以建立多個,隨著任務段切換而切換(下文介紹任務暫存器會提到)。

 

 

GDT和LDT中的表項,就是段描述符,描述了一個記憶體分段的資訊,其結構如下:

 

 

一個表項佔據8個位元組(32位CPU),裡面儲存了一個記憶體分段的諸多資訊:基地址、大小、許可權、型別等資訊。

除了這兩個段描述符暫存器,還有一個非常重要的描述符暫存器:

idtr: 中斷描述符表暫存器,指向了中斷描述符表IDT,這個表的每一項都是一箇中斷處理描述符,當CPU執行過程中發生了硬中斷、異常、軟中斷時,將自動從這個表中定位對應的表項,裡面記錄了發生中斷、異常時該去哪裡執行處理函式。

 

 

IDT中的表項稱為Gate,中文意思為,因為這是應用程式進入核心的主要入口。雖然表的名字叫中斷描述符表,但表中儲存的不全是中斷描述符,IDT中的表項存在三種型別,對應三種型別的門:

  • 任務門
  • 陷阱門
  • 中斷門

 

 

三種描述符中都儲存了處理這個中斷/異常/任務時該去哪裡處理的地址。三種門用途不一,其中中斷門是真正意義上的中斷,而像前面提到的除錯指令int 3以及老式的系統呼叫指令int 2e/int 80都屬於陷阱門。任務門則用的較少,要了解任務門,先了解下任務暫存器。

任務暫存器

現代作業系統,都是支援多工併發執行的,x86架構CPU為了順應時代潮流,在硬體層面上提供了專門的機制用來支援多工的切換,這體現在兩個方面:

  • CPU內部設定了一個專用的暫存器——任務暫存器TR,它指向當前執行的任務。

 

 


  • 定義了描述任務的資料結構TSS,裡面儲存了一個任務的上下文(一系列暫存器的值),下圖是一個32位CPU的TSS結構圖:
  •  

     

x86CPU的構想是每一個任務對應一個TSS,然後由TR暫存器指向當前的任務,執行任務切換時,修改TR暫存器的指向即可,這是硬體層面的多工切換機制。

這個構想其實還是很不錯的,然而現實卻打了臉,包括Linux和Windows在內的主流作業系統都沒有使用這個機制來進行執行緒切換,而是自己使用軟體來實現多執行緒切換。

所以,絕大多數情況下,TR暫存器都是指向固定的,即便執行緒切換了,TR暫存器仍然不會變化。

注意,我這裡說的的是絕大多數情況,而沒有說死。雖然作業系統不依靠TSS來實現多工切換,但這並不意味著CPU提供的TSS作業系統一點也沒有使用。還是存在一些特殊情況,如一些異常處理會使用到TSS來執行處理。

下面這張圖,展示了控制暫存器、描述符暫存器、任務暫存器構成的全貌:

 

 

模型特定暫存器

從80486之後的x86架構CPU,內部增加了一組新的暫存器,統稱為MSR暫存器,中文直譯是模型特定暫存器,意思是這些暫存器不像上面列出的暫存器是固定的,這些暫存器可能隨著不同的版本有所變化。這些暫存器主要用來支援一些新的功能。

隨著x86CPU不斷更新換代,MSR暫存器變的越來越多,但與此同時,有一部分MSR暫存器隨著版本迭代,慢慢固化下來,成為了變化中那部分不變的,這部分MSR暫存器,Intel將其稱為Architected MSR,這部分MSR暫存器,在命名上,統一加上了IA32的字首。

這裡選取三個代表性的MSR簡單介紹一下:

  • IA32_SYSENTER_CS
  • IA32_SYSENTER_ESP
  • IA32_SYSENTER_EIP

這三個MSR暫存器是用來實現快速系統呼叫

在早期的x86架構CPU上,系統呼叫依賴於軟中斷實現,類似於前面除錯用到的int 3指令,在Windows上,系統呼叫用到的是int 2e,在Linux上,用的是int 80

軟中斷畢竟還是比較慢的,因為執行軟中斷就需要記憶體查表,通過IDTR定位到IDT,再取出函式進行執行。

系統呼叫是一個頻繁觸發的動作,如此這般勢必對效能有所影響。在進入奔騰時代後,就加上了上面的三個MSR暫存器,分別儲存了執行系統呼叫後,核心系統呼叫入口函式所需要的段暫存器、堆疊棧頂、函式地址,不再需要記憶體查表。快速系統呼叫還提供了專門的CPU指令sysenter/sysexit用來發起系統呼叫和退出系統呼叫。

在64位上,這一對指令升級為syscall/sysret

總結

以上就是全部要介紹的暫存器了,需要說明一下的是,這並不是x86CPU全部所有的暫存器,除了這些,還存在XMM、MMX、FPU浮點數運算等其他暫存器。

這篇文章以x86/x64架構CPU為目標,通過對CPU內部暫存器的闡述,串講了CPU執行程式碼機制、記憶體定址技術、中斷與異常處理、多工管理、系統呼叫、除錯原理等多種計算機底層知識。

文章寫作不容易,歡迎大家轉發支援~

 

 

往期TOP5文章

我是Redis,MySQL大哥被我害慘了!

CPU明明8個核,網路卡為啥拼命折騰一號核?

因為一個跨域請求,我差點丟了飯碗

完了!CPU一味求快出事兒了!

雜湊表哪家強?幾大程式語言吵起來了!

 

相關文章