1. 程式人生 > 其它 >指令跳轉:原來if...else就是goto

指令跳轉:原來if...else就是goto

一般一個程式包含很多條指令。因為有 if...else、for 這樣的條件和迴圈存在,這些指令也不會一路平鋪直敘地執行下去。

一個計算機程式是怎麼被分解成一條條指令來執行的。

拿我們用的 Intel CPU 來說,裡面差不多有幾百億個電晶體。實際上,一條條計算機指令執行起來非常複雜。好在 CPU 在軟體層面已經為我們做好了封裝。對於我們這些做軟體的程式設計師來說,我們只要知道,寫好的程式碼變成了指令之後,是一條一條順序執行的就可以了。

邏輯上,我們可以認為,CPU 其實就是由一堆暫存器組成的。而暫存器就是 CPU 內部,由多個觸發器(Flip-Flop)或者鎖存器(Latches)組成的簡單電路。

N 個觸發器或者鎖存器,就可以組成一個 N 位(Bit)的暫存器,能夠儲存 N 位的資料。比方說,我們用的 64 位 Intel 伺服器,暫存器就是 64 位的。

一個 CPU 裡面會有很多種不同功能的暫存器。我這裡給你介紹三種比較特殊的。

一個是 PC 暫存器(Program Counter Register),我們也叫指令地址暫存器(Instruction Address Register)。顧名思義,它就是用來存放下一條需要執行的計算機指令的記憶體地址

第二個是指令暫存器(Instruction Register),用來存放當前正在執行的指令

第三個是條件碼暫存器(Status Register),用裡面的一個一個標記位(Flag),存放 CPU 進行算術或者邏輯計算的結果

除了這些特殊的暫存器,CPU 裡面還有更多用來儲存資料和記憶體地址的暫存器。這樣的暫存器通常一類裡面不止一個。我們通常根據存放的資料內容來給它們取名字,比如整數暫存器、浮點數暫存器、向量暫存器和地址暫存器等等。有些暫存器既可以存放資料,又能存放地址,我們就叫它通用暫存器

實際上,一個程式執行的時候,CPU 會根據 PC 暫存器裡的地址,從記憶體裡面把需要執行的指令讀取到指令暫存器裡面執行,然後根據指令長度自增,開始順序讀取下一條指令。可以看到,一個程式的一條條指令,在記憶體裡面是連續儲存的,也會一條條順序載入。

從 if...else 來看程式的執行和跳轉

// test.c


#include <time.h>
#include <stdlib.h>


int main()
{
  srand(time(NULL));
  int r = rand() % 2;
  int a = 10;
  if (r == 0)
  {
    a = 1;
  } else {
    a = 2;
  } 

我們用 rand 生成了一個隨機數 r,r 要麼是 0,要麼是 1。當 r 是 0 的時候,我們把之前定義的變數 a 設成 1,不然就設成 2。

$ gcc -g -c test.c
$ objdump -d -M intel -S test.o 

我們把這個程式編譯成彙編程式碼。你可以忽略前後無關的程式碼,只關注於這裡的 if...else 條件判斷語句。對應的彙編程式碼是這樣的:

    if (r == 0)
  3b:   83 7d fc 00             cmp    DWORD PTR [rbp-0x4],0x0
  3f:   75 09                   jne    4a <main+0x4a>
    {
        a = 1;
  41:   c7 45 f8 01 00 00 00    mov    DWORD PTR [rbp-0x8],0x1
  48:   eb 07                   jmp    51 <main+0x51>
    }
    else
    {
        a = 2;
  4a:   c7 45 f8 02 00 00 00    mov    DWORD PTR [rbp-0x8],0x2
  51:   b8 00 00 00 00          mov    eax,0x0
    } 

可以看到,這裡對於 r == 0 的條件判斷,被編譯成了 cmp 和 jne 這兩條指令。

cmp 指令比較了前後兩個運算元的值,這裡的 DWORD PTR 代表操作的資料型別是 32 位的整數,而[rbp-0x4]則是變數 r 的記憶體地址。所以,第一個運算元就是從記憶體裡拿到的變數 r 的值。第二個運算元 0x0 就是我們設定的常量 0 的 16 進製表示。cmp 指令的比較結果,會存入到條件碼暫存器當中去。

在這裡,如果比較的結果是 True,也就是 r == 0,就把零標誌條件碼(對應的條件碼是 ZF,Zero Flag)設定為 1。除了零標誌之外,Intel 的 CPU 下還有進位標誌(CF,Carry Flag)、符號標誌(SF,Sign Flag)以及溢位標誌(OF,Overflow Flag),用在不同的判斷條件下。

cmp 指令執行完成之後,PC 暫存器會自動自增,開始執行下一條 jne 的指令。

跟著的 jne 指令,是 jump if not equal 的意思,它會檢視對應的零標誌位。如果為 0,會跳轉到後面跟著的運算元 4a 的位置。這個 4a,對應這裡彙編程式碼的行號,也就是上面設定的 else 條件裡的第一條指令。當跳轉發生的時候,PC 暫存器就不再是自增變成下一條指令的地址,而是被直接設定成這裡的 4a 這個地址。這個時候,CPU 再把 4a 地址裡的指令載入到指令暫存器中來執行

跳轉到執行地址為 4a 的指令,實際是一條 mov 指令,第一個運算元和前面的 cmp 指令一樣,是另一個 32 位整型的記憶體地址,以及 2 的對應的 16 進位制值 0x2。mov 指令把 2 設定到對應的記憶體裡去,相當於一個賦值操作。然後,PC 暫存器裡的值繼續自增,執行下一條 mov 指令。

這條 mov 指令的第一個運算元 eax,代表累加暫存器,第二個運算元 0x0 則是 16 進位制的 0 的表示。這條指令其實沒有實際的作用,它的作用是一個佔位符。我們回過頭去看前面的 if 條件,如果滿足的話,在賦值的 mov 指令執行完成之後,有一個 jmp 的無條件跳轉指令。跳轉的地址就是這一行的地址 51。我們的 main 函式沒有設定返回值,而 mov eax, 0x0 其實就是給 main 函式生成了一個預設的為 0 的返回值到累加器裡面。if 條件裡面的內容執行完成之後也會跳轉到這裡,和 else 裡的內容結束之後的位置是一樣的。

上一講我們講打孔卡的時候說到,讀取打孔卡的機器會順序地一段一段地讀取指令,然後執行。執行完一條指令,它會自動地順序讀取下一條指令。如果執行的當前指令帶有跳轉的地址,比如往後跳 10 個指令,那麼機器會自動將卡片帶往後移動 10 個指令的位置,再來執行指令。同樣的,機器也能向前移動,去讀取之前已經執行過的指令。這也就是我們的 while/for 迴圈實現的原理。

如何通過 if...else 和 goto 來實現迴圈?

int main()
{
    int a = 0;
    for (int i = 0; i < 3; i++)
    {
        a += i;
    }
}

我們再看一段簡單的利用 for 迴圈的程式。我們迴圈自增變數 i 三次,三次之後,i>=3,就會跳出迴圈。整個程式,對應的 Intel 彙編程式碼就是這樣的:


    for (int i = 0; i <= 2; i++)
   b:   c7 45 f8 00 00 00 00    mov    DWORD PTR [rbp-0x4],0x0
  12:   eb 0a                   jmp    1e 
    {
        a += i;
  14:   8b 45 f8                mov    eax,DWORD PTR [rbp-0x4]
  17:   01 45 fc                add    DWORD PTR [rbp-0x8],eax

  1a:   83 45 f8 01             add    DWORD PTR [rbp-0x4],0x1
  1e:   83 7d f8 02             cmp    DWORD PTR [rbp-0x4],0x2
  22:   7e f0                   jle    14 
  24:   b8 00 00 00 00          mov    eax,0x0
    }

可以看到,對應的迴圈也是用 1e 這個地址上的 cmp 比較指令,和緊接著的 jle 條件跳轉指令來實現的。主要的差別在於,這裡的 jle 跳轉的地址,在這條指令之前的地址 14,而非 if...else 編譯出來的跳轉指令之後。往前跳轉使得條件滿足的時候,PC 暫存器會把指令地址設定到之前執行過的指令位置,重新執行之前執行過的指令,直到條件不滿足,順序往下執行 jle 之後的指令,整個迴圈才結束。

如果你看一長條打孔卡的話,就會看到卡片往後移動一段,執行了之後,又反向移動,去重新執行前面的指令。

其實,你有沒有覺得,jle 和 jmp 指令,有點像程式語言裡面的 goto 命令,直接指定了一個特定條件下的跳轉位置。雖然我們在用高階語言開發程式的時候反對使用 goto,但是實際在機器指令層面,無論是 if...else... 也好,還是 for/while 也好,都是用和 goto 相同的跳轉到特定指令位置的方式來實現的。