1. 程式人生 > 其它 >圖解CPU執行一段程式

圖解CPU執行一段程式

程式執行

從打印出 Hello World 開始,程式如何執行起來,大家都很清楚。那麼底層如何執行的呢,讓我們一探究竟。

long main(){
  long a = 1;
  long b = 2;
  return a + b;
}

來一段 C 語言作為例子, gcc -S 生成彙編程式碼,簡化如下。

pushq   %rbp
movq    %rsp, %rbp
movq    $1, -8(%rbp)
movq    $2, -16(%rbp)
movq    -16(%rbp), %rax
movq    -8(%rbp), %rdx
addq    %rdx, %rax
popq    %rbp
ret

有點暈,讓我們補個知識。在所有 cpu 體系架構中,每個暫存器通常都是有建議的使用方法的,而編譯器也通常依照CPU架構的建議來使用這些暫存器,因而我們可以認為這些建議是編譯器都遵守的。

  • %rax 通常用於儲存函式呼叫的返回結果,同時也用於乘法和除法指令中。在imul 指令中,兩個64位的乘法最多會產生128位的結果,需要 %rax 與 %rdx 共同儲存乘法結果,在div 指令中被除數是128 位的,同樣需要%rax 與 %rdx 共同儲存被除數。
  • %rsp 是堆疊指標暫存器,通常會指向棧頂位置,堆疊的 pop 和push 操作就是通過改變 %rsp 的值即移動堆疊指標的位置來實現的。
  • %rbp 是棧幀指標,用於標識當前棧幀的起始位置
  • %rdi, %rsi, %rdx, %rcx,%r8, %r9 六個暫存器用於儲存函式呼叫時的6個引數(如果有6個或6個以上引數的話)。
  • 被標識為 “miscellaneous registers” 的暫存器,屬於通用性更為廣泛的暫存器,編譯器或彙編程式可以根據需要儲存任何資料。

這裡還要區分一下 “Caller Save” 和 ”Callee Save” 暫存器,即暫存器的值是由”呼叫者儲存“ 還是由 ”被呼叫者儲存“。當產生函式呼叫時,子函式內通常也會使用到通用暫存器,那麼這些暫存器中之前儲存的呼叫者(父函式)的值就會被覆蓋。為了避免資料覆蓋而導致從子函式返回時暫存器中的資料不可恢復,CPU 體系結構中就規定了通用暫存器的儲存方式。

如果一個暫存器被標識為”Caller Save”, 那麼在進行子函式呼叫前,就需要由呼叫者提前儲存好這些暫存器的值,儲存方法通常是把暫存器的值壓入堆疊中,呼叫者儲存完成後,在被呼叫者(子函式)中就可以隨意覆蓋這些暫存器的值了。如果一個寄存被標識為“Callee Save”,那麼在函式呼叫時,呼叫者就不必儲存這些暫存器的值而直接進行子函式呼叫,進入子函式後,子函式在覆蓋這些暫存器之前,需要先儲存這些暫存器的值,即這些暫存器的值是由被呼叫者來儲存和恢復的。

看了這些文件介紹,回想下 圖解CPU為何要亂序執行。CPU 執行單元並沒有函式概念,只要指令和取值都準備好了,就可以執行。不過我們寫程式一般都是一段程式(比如一個函式),外加資訊作為上下文。翻譯成指令,那肯定茫茫多,因此作業系統為了更好管理,設計了 Frame 用來封裝一段相關聯的程式碼。那麼現在執行邏輯抽象如下。

程式執行模擬棧呼叫,我們結合圖在看看剛才程式碼。

  1. 先把當前幀的資訊儲存起來, pushq %rbp
  2. 接著把要執行的幀賦給 %rpb, movq %rsp, %rbp
  3. 處理引數,先把引數讀取入棧,然後再賦值給暫存器
  4. 取存和指令都滿足了,那麼就可以執行
  5. 把返回結果放入暫存器 %rax,和上一步可以一起完成, addq %rdx, %rax
  6. 執行完畢,把原來的棧資訊取出,再放入 %rpb, popq %rbp
  7. 最後 ret 指令相當於 popq %rip,指向下一條指令

從棧呼叫來看,結合棧地址是從高到低,棧內參數位數對齊,緊密排布,那麼相對的偏移量也是固定的。因此直接通過 %rbp 執行偏移取值,就可以完成變數的處理,因此我們也可以得出一個抽象棧內分佈圖如下。

總結

棧執行主要看 %rbp 指向誰,而 %rbp 又是通過 %rsp 賦值的,因此只要我們想辦法把 %rsp 切換了就相當於換了執行單元的上下文環境。現在協程很流行,按照這個思路,大家是不是可以構思一下協程如何實現了~