現代程式語言中迴圈是十分常見的功能,幾乎任何程式語言都有類似for
, while
這樣的迴圈語句,不過在計算機底層就沒有那麼幸福了,許多的硬體其實並沒有提供硬體級別的迴圈。不過硬體級別的限制,似乎並沒有影響到我們日常的工作,今天就主要來看看迴圈的本質是什麼。
指令集
如今使用的程式語言,以及各類不同的軟體,其實到最後都會轉換成二進位制的形式,用以控制底層硬體的執行。這些上層軟體其實是底層功能的抽象,不管上層業務多麼複雜,在底層幾乎都是通過有限的暫存器,指令集,還有記憶體來實現相關的功能。我們所編寫的應用程式,與CPU的指令集息息相關。其實所謂的指令集,就是CPU提供的一系列用於控制硬體的指令的集合。不同的硬體廠家,所生產的CPU指令集肯能會有所不同。目前主要分成了兩大陣營,分別是CISC-複雜指令集計算機和RISC-精簡指令集計算機。AMD以及Intel這些廠家生產的CPU(x86指令集)基本上都屬於CISC,他們所包含的指令相當多也比較複雜,不過似乎不打算支援硬體級別的迴圈。而一些的手機CPU,ARM架構的開發版都屬於RISC的範疇,它們的特點是指令相對較少,也比較簡單,並且部分RISC的CPU甚至支援硬體級別的迴圈。
迴圈的“變態”
這裡的“變態”並沒有罵人的意思。根據生物學中的描述,變態其實指代了形態的變化。在某種意義上,迴圈也存在著變態。
在C語言中迴圈普遍有3中表達方式,分別是for
迴圈,while
迴圈以及do-while
迴圈
// 1. for迴圈
for (init-expr; test-expr; update-expr) {
....
}
// 2. while迴圈
init-expr;
while (test-expr) {
.....
update-expr;
}
// 3. do-while迴圈
init-expr;
do {
....
update-expr;
} while (test-expr)
複製程式碼
不過問題是計算機底層並沒有那麼多表達迴圈的方式,為了在底層實現迴圈功能,必須要以另一種方式來表達迴圈。實際上迴圈在底層都會通過指令跳轉配合狀態更改的方式來實現。相當於用goto
這類語句來實現迴圈模式。goto
語句在業界是很讓人詬病的,許多的語言都不支援goto
這類語法,不過好在C語言還是支援的。接下來來看看要如何進行這種“變態”。
fact_while
是一個用while
迴圈實現的階乘函式,當然我們也可以用for
迴圈來實現等價的功能,這裡不一一舉例。
long fact_while(long n) {
long result = 1;
while (n > 1) {
result *= n;
n -= 1;
}
return result;
}
複製程式碼
我們的任務就是不使用while
,for
這些迴圈語句,只用goto
語句來實現上述迴圈。大體上有兩種翻譯方式,分別是jump to middle
以及gurade-do
。
1. Jump To Middle
jump to middle直接翻譯過來就是跳轉到中間,它的原理其實就是**把條件測試寫在中間部分,在首次迭代開始之前先行跳轉並執行條件測試語句。**翻譯過來大概就是
long fact_jump_to_middle(long n) {
long result = 1;
goto test;
loop:
result *= n;
n --;
test:
if (n > 1) goto loop;
return result;
}
複製程式碼
這種翻譯方式最為關鍵的是goto test;
語句,在進入迴圈區域之前便直接跳轉到條件測試語句,測試是否符合n > 1
這個條件。如果符合條件則進入迴圈體並執行迴圈體中的邏輯,否則繼續往下執行程式,返回結果。這種翻譯方式還有個特點,當你嘗試把goto test;
這條語句去掉之後會發生什麼事情呢?
long fact_jump_to_middle_without_first_jump(long n) {
long result = 1;
loop:
result *= n;
n --;
test:
if (n > 1) goto loop;
return result;
}
複製程式碼
從邏輯上講它其實就是一個測試條件相同的do-while
迴圈實現,while
語句與do-while
語句最大的不同就在於,while
語句是先進行條件測試,當符合條件的時候才會進入到迴圈體中,而do-while
則是執行了一次迴圈體中的語句之後才進行迴圈相關的條件測試。這麼看來do-while
迴圈本質上就是少了初始條件檢測的while
迴圈。
2. guarded-do
另一種翻譯方式被稱為guarded-do,它的原理是在迭代之前設定一個“門衛”條件。如果不符合條件的話,則直接跳到迴圈邏輯之後,否則就進入迴圈邏輯中,此處的迴圈邏輯依舊用do-while
迴圈來實現。按照這種翻譯方式所翻譯的goto
版本如下
long fact_guarded_do(long n) {
long result = 1;
if (n <= 1) goto done;
loop:
result *= n;
n --;
if (n > 1) goto loop;
done:
return result;
}
複製程式碼
可見最關鍵的地方是設定的“門衛”條件,該條件應該設定成迴圈條件的補集。只要滿足這個“門衛”條件則跳過整個迴圈邏輯,否則就進入迴圈區域中。有些書還會把上面的過程寫成
long fact_guarded_do(long n) {
long result = 1;
if (n <= 1) goto done;
loop:
result *= n;
n --;
if (n != 1) goto loop;
done:
return result;
}
複製程式碼
其實兩種方式是等價的。只要符合條件n > 1
便能夠進入到迴圈區域中,在迴圈中每次迭代都會進行減一操作,那麼只要滿足條件n != 1
便可持續進行迭代。
真實場景
前面部分簡單地介紹了迴圈,以及如何對迴圈進行變形,用goto
語句來取代while
, for
, do-while
這類迴圈語句。然而正常情況下我們並不會去把一個C語言的迴圈版本,轉換成與之等價的C語言的goto
版本,這麼做其實只是為了方便原理的解釋。真實場景下,在語言進行編譯的時候,其實會先轉換成彙編程式碼。
通過命令
gcc -Og -S while.c
複製程式碼
把最開始的fact_while
階乘函式編譯成組合語言版本,生成的彙編程式會儲存在檔案while.s
中,丟掉一些雜七雜八的東西之後大概結果如下
movl $1, %eax
cmpq $2, %rdi
jl LBB0_2
LBB0_1:
imulq %rdi, %rax
cmpq $2, %rdi
leaq -1(%rdi), %rdi
jg LBB0_1
LBB0_2:
retq
複製程式碼
簡單起見,我把一些方法呼叫相關的暫存器行為給去掉了,只保留了迴圈邏輯的部分。閱讀彙編程式碼的關鍵點在於瞭解不同暫存器的作用,其中暫存器%rax
用於存放返回值,暫存器%rdi
用於存放函式第一個引數的值。把上面的彙編程式轉換成更加親民的版本,並加上註釋可得
movl $1, %eax ## 把數值1放進暫存器%eax中
cmpq $2, %rdi ## 把引數n的值與數值2進行比較
jl done ## 如果n < 2則跳到標籤done處
loop: ## 標識著即將進入迴圈區域
imulq %rdi, %rax ## 把%rax (就是%eax中的數值0擴充套件到64位)的數值與%rdi(數值n)相乘,並把結果儲存到%rax中
cmpq $2, %rdi ## 把n的值與數值2進行比較,比較結果會記錄在其他地方 (1)
leaq -1(%rdi), %rdi ## 改變n的值,n = n - 1
jg loop ## 獲取(1)處的比較結果,如果在遞減之前n是大於2的則跳轉到迴圈區域開始的地方
done: ## 標識著已經離開迴圈區域
retq ## 函式返回,返回值存放在暫存器%rax中
複製程式碼
總體上看來這裡是採用了guarded-do
的翻譯方式。不過它的具體邏輯看起來跟我們前面用C語言的goto
語句描述的過程稍微有些不同,但是隻要仔細琢磨,其實它們所做的東西是等價的,為了少執行一些指令,編譯器會進行了一些優化,不過在本例中所採用的優化等級還算是比較低的了。
結尾
這篇文章主要簡單地總結了一下在計算機底層迴圈的實現方式,即便是現代最流行的x86指令集都沒有硬體級迴圈的支援,常見的做法是利用硬體的條件跳轉指令來實現迴圈的相關邏輯。為了更直觀地看到這個過程,我們利用C語言的goto
語句模擬了底層的迴圈實現。最後還提供了一個優化等級較低的組合語言版本,能進一步體現出底層硬體的工作方式。