在 20 世紀 60-70 年代,寫程序還要用到打孔卡(Punched Card)。Coder 需要先把程序想好,在紙帶上打孔,然后把打孔卡交給計算機去處理。
FORTRAN 程序打孔卡
不難看出,這張類似答題卡的紙帶上,通過打孔或不打孔來代表“0”和“1”。
時至今日,CPU 本身也沒有理解高級編程語言的能力,仍然只能處理機器碼,也就是一連串的“0”和“1”。那么我們用高級編程語言編寫的代碼,是如何轉變成一連串的“0”和“1”的?這一串數字,又是如何在 CPU 中處理的?這篇博客就來看看,機器碼和計算機指令(Instruction Code)究竟是怎么回事。
1. CPU 在軟硬件接口中做了什么
1.1 硬件工程師眼中的 CPU
從硬件角度來看,CPU 就是一個超大規模集成電路,通過電路實現了加法、乘法等處理邏輯。
1.2 軟件工程師眼中的 CPU
從軟件角度來講,CPU 就是一個執行各種計算機指令(Instruction Code)的邏輯機器。
1.3 計算機也有多語種——計算機指令與計算機指令集
計算機指令,就好比一種 CPU 能聽懂的語言,我們也把它叫做機器語言(Machine Language)。
正如人類有多種語言,不同 CPU 能聽懂的語言也不大一樣。Intel 的 CPU 和 ARM 的 CPU,語言就不同。這是因為它們使用了不同的計算機指令集(Instruction Set)。
1.4 存儲程序型計算機
計算機程序是由成千上萬條指令組成的,不可能將這些指令一直存放在 CPU 中。計算機程序通常是存儲在存儲器中的,這種計算機就叫做存儲程序型計算機(Stored-program Computer)。
既然提到存儲程序型計算機,那么必然有不存儲程序的計算機咯。沒錯,在現代計算機出現之前,出現過插線板計算機(Plugboard Computer)。這種設備通過插拔電線來完成各種計算任務。
IBM 的 Plugboard Computer
2. 代碼到機器碼的華麗變身——編譯與匯編
我們用一段 C 語言代碼來看看,我們平時編寫的代碼是如何變成計算機指令的。
1 // test.c 2 int main() 3 { 4 int a = 1; 5 int b = 2; 6 a = a + b; 7 }
想在 Linux 系統上運行這段代碼,要先把它翻譯成匯編語言(Assembly Language,ASM)的程序。從高級編程語言代碼到匯編語言的過程就是編譯(Compile)。
匯編代碼還不是機器碼,要用匯編器(Assembler)處理匯編代碼,才能生成機器碼(Machine Code)。這些機器碼由“0”和“1”組成的機器語言表示。這些機器碼就是一條條計算機指令,這些計算機指令才是 CPU 真正能讀懂的。
高級編程語言到機器語言的過程
在 Linux 系統中,使用 gcc 和 objdump 這兩條指令,可以將對應的匯編代碼和機器碼都打印出來。
1 $ gcc -g -c test.c 2 $ objdump -d -M intel -S test.o
左側的數字就是機器碼,右邊的 push、mov、add、pop 等就是匯編代碼。一行 C 語言代碼,有時只對應一條機器碼和匯編代碼,有時則對應兩條機器碼和匯編代碼。匯編代碼和機器碼則是一一對應的。
1 test.o: file format elf64-x86-64 2 Disassembly of section .text: 3 0000000000000000 <main>: 4 int main() 5 { 6 0: 55 push rbp 7 1: 48 89 e5 mov rbp,rsp 8 int a = 1; 9 4: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1 10 int b = 2; 11 b: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2 12 a = a + b; 13 12: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8] 14 15: 01 45 fc add DWORD PTR [rbp-0x4],eax 15 } 16 18: 5d pop rbp 17 19: c3 ret
我們在使用 GCC(GNU Compiler Collection,GNU 編譯器)時,可以直接將高級編程語言代碼編譯成機器碼。那為什么還要讓匯編代碼在中間轉一手呢?這是因為機器碼很難看懂,但匯編則容易理解得多。換句話說,匯編就是給程序員看的機器碼。也正是基於這一點,機器碼和匯編代碼是一一對應的。
3. 解析指令和機器碼
3.1 指令的分類
上一節中反編譯出的匯編代碼和機器指令到底是什么意思呢?Intel CPU 有 2000 多條 CPU 指令,我們無法一一掌握。但通常來說,常見指令可以分成五類。
3.1.1 算數類指令
加減乘除等運算,在 CPU 層面都會變成算術類指令。
3.1.2 數據傳輸類指令
變量賦值、讀寫內存等操作,用的都是數據傳輸類指令。
3.1.3 邏輯類指令
與或非都是此類指令。
3.1.4 條件分支類指令
if-else 等都是此類指令。
3.1.5 無條件跳轉指令
調用函數時,就是發起了一個無條件跳轉指令。
指令分類
3.2 匯編器如何將匯編代碼翻譯成機器碼
前文說過,不同的 CPU 有不同的指令集,自然也就對應着不同的匯編語言和不同的機器碼。我們以最簡單的 MIPS 指令集為例,看看機器碼是如何生成的。
MIPS 指令集是一組由 MIPS 公司在 80 年代中期設計出的 CPU 指令集,最近已經開源。如果想進一步研究 CPU 和指令集細節,請戳。
MIPS 的指令是一個 32 位整數,其中高 6 位是操作碼(Opcode),用於說明這是一條什么樣的指令。剩下的 26 位有三種格式,分別為 R、I、J。
MIPS 指令示意
3.2.1 R 指令
用於算術和邏輯操作,其中有讀取、寫入數據用到的寄存器地址。如果是邏輯位移操作,后面還會加上位移操作的位移量。最后的功能碼,是在前面的操作碼不夠時,擴展操作碼以表示具體指令的。
rs(register source):第一個源寄存器;
rt 則有二義性。有些資料中說 t 是 target 之意,也有資料稱 t 是 s 的下一個字母,並非具體單詞的縮寫。此處應為第二個源寄存器,所以我偏向於第二種解釋。
rd(register destination):目標寄存器。
3.2.2 I 指令
用於數據傳輸、條件分支,以及運算時使用的並非常量而是常數的時候。
此時沒有位移量和操作碼,也沒有第三個寄存器,於是把這三部分直接成一個地址值或常數。
3.2.3 J 指令
跳轉指令,高 6 位之外的 26 位都是一個跳轉后的地址。
接下來,我們用一條語句為例來解釋:
1 add $t0,$s2,$s1
這條語句在 MIPS 指令中,opcode 為 0x0,rs 地址為 0x11,rt 地址為 0x12,rd 是目標的臨時寄存器地址,假設為 0x8。因為我們是加法而非位移操作,所以位移量為 0。將以上數字拼起來,就是下圖所示:
MIPS 指令示例(首行為十進制)
接下來,我們將這條指令的機器碼打在打孔卡上(打孔表示 1,不打孔表示 0):
MIPS 指令的打孔卡
4. 拓展資料
如果想了解 Intel CPU 的指令集,可以參考《計算機組成與設計:軟 / 硬件接口》第 5 版的 2.17 小節。