你在學(xué)寫程序的時(shí)候,有沒有想過,古老年代的計(jì)算機(jī)程序是怎么寫出來的?
上大學(xué)的時(shí)候,我們系里教 C 語言程序設(shè)計(jì)的老師說,他們當(dāng)年學(xué)寫程序的時(shí)候,不像現(xiàn)在這樣,都是用一種古老的物理設(shè)備,叫作“打孔卡(Punched Card)”。用這種設(shè)備寫程序,可沒法像今天這樣,掏出鍵盤就能打字,而是要先在腦海里或者在紙上寫出程序,然后在紙帶或者卡片上打洞。這樣,要寫的程序、要處理的數(shù)據(jù),就變成一條條紙帶或者一張張卡片,之后再交給當(dāng)時(shí)的計(jì)算機(jī)去處理。

上世紀(jì) 60 年代晚期或 70 年代初期,Arnold Reinold 拍攝的 FORTRAN 計(jì)算程序的穿孔卡照片
你看這個(gè)穿孔紙帶是不是有點(diǎn)兒像我們現(xiàn)在考試用的答題卡?那個(gè)時(shí)候,人們?cè)谔囟ǖ奈恢蒙洗蚨椿蛘卟淮蚨?,來代表?”或者“1”。
為什么早期的計(jì)算機(jī)程序要使用打孔卡,而不能像我們現(xiàn)在一樣,用 C 或者 Python 這樣的高級(jí)語言來寫呢?原因很簡(jiǎn)單,因?yàn)橛?jì)算機(jī)或者說 CPU 本身,并沒有能力理解這些高級(jí)語言。即使在 2019 年的今天,我們使用的現(xiàn)代個(gè)人計(jì)算機(jī),仍然只能處理所謂的“機(jī)器碼”,也就是一連串的“0”和“1”這樣的數(shù)字。
那么,我們每天用高級(jí)語言的程序,最終是怎么變成一串串“0”和“1”的?這一串串“0”和“1”又是怎么在 CPU 中處理的?今天,我們就來仔細(xì)介紹一下,“機(jī)器碼”和“計(jì)算機(jī)指令”到底是怎么回事。
在軟硬件接口中,CPU 幫我們做了什么事?
我們常說,CPU 就是計(jì)算機(jī)的大腦。CPU 的全稱是 Central Processing Unit,中文是中央處理器。
我們上一節(jié)說了,從硬件的角度來看,CPU 就是一個(gè)超大規(guī)模集成電路,通過電路實(shí)現(xiàn)了加法、乘法乃至各種各樣的處理邏輯。
如果我們從軟件工程師的角度來講,CPU 就是一個(gè)執(zhí)行各種計(jì)算機(jī)指令(Instruction Code)的邏輯機(jī)器。這里的計(jì)算機(jī)指令,就好比一門 CPU 能夠聽得懂的語言,我們也可以把它叫作機(jī)器語言(Machine Language)。
不同的 CPU 能夠聽懂的語言不太一樣。比如,我們的個(gè)人電腦用的是 Intel 的 CPU,蘋果手機(jī)用的是 ARM 的 CPU。這兩者能聽懂的語言就不太一樣。類似這樣兩種 CPU 各自支持的語言,就是兩組不同的計(jì)算機(jī)指令集,英文叫 Instruction Set。這里面的“Set”,其實(shí)就是數(shù)學(xué)上的集合,代表不同的單詞、語法。
所以,如果我們?cè)谧约弘娔X上寫一個(gè)程序,然后把這個(gè)程序復(fù)制一下,裝到自己的手機(jī)上,肯定是沒辦法正常運(yùn)行的,因?yàn)檫@兩者語言不通。而一臺(tái)電腦上的程序,簡(jiǎn)單復(fù)制一下到另外一臺(tái)電腦上,通常就能正常運(yùn)行,因?yàn)檫@兩臺(tái) CPU 有著相同的指令集,也就是說,它們的語言相通的。
一個(gè)計(jì)算機(jī)程序,不可能只有一條指令,而是由成千上萬條指令組成的。但是 CPU 里不能一直放著所有指令,所以計(jì)算機(jī)程序平時(shí)是存儲(chǔ)在存儲(chǔ)器中的。這種程序指令存儲(chǔ)在存儲(chǔ)器里面的計(jì)算機(jī),我們就叫作存儲(chǔ)程序型計(jì)算機(jī)(Stored-program Computer)。
說到這里,你可能要問了,難道還有不是存儲(chǔ)程序型的計(jì)算機(jī)么?其實(shí),在沒有現(xiàn)代計(jì)算機(jī)之前,有著聰明才智的工程師們,早就發(fā)明了一種叫 Plugboard Computer 的計(jì)算設(shè)備。我把它直譯成“插線板計(jì)算機(jī)”。在一個(gè)布滿了各種插口和插座的板子上,工程師們用不同的電線來連接不同的插口和插座,從而來完成各種計(jì)算任務(wù)。下面這個(gè)圖就是一臺(tái) IBM 的 Plugboard,看起來是不是有一股滿滿的蒸汽朋克范兒?

一臺(tái) IBM 的 Plugboard
從編譯到匯編,代碼怎么變成機(jī)器碼?
了解了計(jì)算機(jī)指令和計(jì)算機(jī)指令集,接下來我們來看看,平時(shí)編寫的代碼,到底是怎么變成一條條計(jì)算機(jī)指令,最后被 CPU 執(zhí)行的呢?我們拿一小段真實(shí)的 C 語言程序來看看。
// test.c
int main()
{
int a = 1;
int b = 2;
a = a + b;
}
這是一段再簡(jiǎn)單不過的 C 語言程序,即便你不了解 C 語言,應(yīng)該也可以看懂。我們給兩個(gè)變量 a、b 分別賦值 1、2,然后再將 a、b 兩個(gè)變量中的值加在一起,重新賦值給了 a 這個(gè)變量。
要讓這段程序在一個(gè) Linux 操作系統(tǒng)上跑起來,我們需要把整個(gè)程序翻譯成一個(gè)匯編語言(ASM,Assembly Language)的程序,這個(gè)過程我們一般叫編譯(Compile)成匯編代碼。
針對(duì)匯編代碼,我們可以再用匯編器(Assembler)翻譯成機(jī)器碼(Machine Code)。這些機(jī)器碼由“0”和“1”組成的機(jī)器語言表示。這一條條機(jī)器碼,就是一條條的計(jì)算機(jī)指令。這樣一串串的 16 進(jìn)制數(shù)字,就是我們 CPU 能夠真正認(rèn)識(shí)的計(jì)算機(jī)指令。
在一個(gè) Linux 操作系統(tǒng)上,我們可以簡(jiǎn)單地使用 gcc 和 objdump 這樣兩條命令,把對(duì)應(yīng)的匯編代碼和機(jī)器碼都打印出來。
$ gcc -g -c test.c
$ objdump -d -M intel -S test.o
可以看到,左側(cè)有一堆數(shù)字,這些就是一條條機(jī)器碼;右邊有一系列的 push、mov、add、pop 等,這些就是對(duì)應(yīng)的匯編代碼。一行 C 語言代碼,有時(shí)候只對(duì)應(yīng)一條機(jī)器碼和匯編代碼,有時(shí)候則是對(duì)應(yīng)兩條機(jī)器碼和匯編代碼。匯編代碼和機(jī)器碼之間是一一對(duì)應(yīng)的。
test.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
int main()
{
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
int a = 1;
4: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1
int b = 2;
b: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
a = a + b;
12: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
15: 01 45 fc add DWORD PTR [rbp-0x4],eax
}
18: 5d pop rbp
19: c3 ret
這個(gè)時(shí)候你可能又要問了,我們實(shí)際在用 GCC(GUC 編譯器套裝,GNU Compiler Collectipon)編譯器的時(shí)候,可以直接把代碼編譯成機(jī)器碼呀,為什么還需要匯編代碼呢?原因很簡(jiǎn)單,你看著那一串?dāng)?shù)字表示的機(jī)器碼,是不是摸不著頭腦?但是即使你沒有學(xué)過匯編代碼,看的時(shí)候多少也能“猜”出一些這些代碼的含義。
因?yàn)閰R編代碼其實(shí)就是“給程序員看的機(jī)器碼”,也正因?yàn)檫@樣,機(jī)器碼和匯編代碼是一一對(duì)應(yīng)的。我們?nèi)祟惡苋菀子涀?add、mov 這些用英文表示的指令,而 8b 45 f8 這樣的指令,由于很難一下子看明白是在干什么,所以會(huì)非常難以記憶。盡管早年互聯(lián)網(wǎng)上到處流傳,大神程序員著拿小刀在光盤上刻出操作系統(tǒng)的梗,但是要讓你用打孔卡來寫個(gè)程序,估計(jì)浪費(fèi)的卡片比用上的卡片要多得多。

從高級(jí)語言到匯編代碼,再到機(jī)器碼,就是一個(gè)日常開發(fā)程序,最終變成了 CPU 可以執(zhí)行的計(jì)算機(jī)指令的過程。
解析指令和機(jī)器碼
了解了這個(gè)過程,下面我們放大局部,來看看這一行行的匯編代碼和機(jī)器指令,到底是什么意思。
我們就從平時(shí)用的電腦、手機(jī)這些設(shè)備來說起。這些設(shè)備的 CPU 到底有哪些指令呢?這個(gè)還真有不少,我們?nèi)粘S玫?Intel CPU,有 2000 條左右的 CPU 指令,實(shí)在是太多了,所以我沒法一一來給你講解。不過一般來說,常見的指令可以分成五大類。
第一類是算術(shù)類指令。我們的加減乘除,在 CPU 層面,都會(huì)變成一條條算術(shù)類指令。
第二類是數(shù)據(jù)傳輸類指令。給變量賦值、在內(nèi)存里讀寫數(shù)據(jù),用的都是數(shù)據(jù)傳輸類指令。
第三類是邏輯類指令。邏輯上的與或非,都是這一類指令。
第四類是條件分支類指令。日常我們寫的“if/else”,其實(shí)都是條件分支類指令。
最后一類是無條件跳轉(zhuǎn)指令。寫一些大一點(diǎn)的程序,我們常常需要寫一些函數(shù)或者方法。在調(diào)用函數(shù)的時(shí)候,其實(shí)就是發(fā)起了一個(gè)無條件跳轉(zhuǎn)指令。
你可能一下子記不住,或者對(duì)這些指令的含義還不能一下子掌握,這里我畫了一個(gè)表格,給你舉例子說明一下,幫你理解、記憶。

下面我們來看看,匯編器是怎么把對(duì)應(yīng)的匯編代碼,翻譯成為機(jī)器碼的。
我們說過,不同的 CPU 有不同的指令集,也就對(duì)應(yīng)著不同的匯編語言和不同的機(jī)器碼。為了方便你快速理解這個(gè)機(jī)器碼的計(jì)算方式,我們選用最簡(jiǎn)單的 MIPS 指令集,來看看機(jī)器碼是如何生成的。
MIPS 是一組由 MIPS 技術(shù)公司在 80 年代中期設(shè)計(jì)出來的 CPU 指令集。就在最近,MIPS 公司把整個(gè)指令集和芯片架構(gòu)都完全開源了。想要深入研究 CPU 和指令集的同學(xué),我這里推薦一些資料,你可以自己了解下。

MIPS 的指令是一個(gè) 32 位的整數(shù),高 6 位叫操作碼(Opcode),也就是代表這條指令具體是一條什么樣的指令,剩下的 26 位有三種格式,分別是 R、I 和 J。
R 指令是一般用來做算術(shù)和邏輯操作,里面有讀取和寫入數(shù)據(jù)的寄存器的地址。如果是邏輯位移操作,后面還有位移操作的位移量,而最后的功能碼,則是在前面的操作碼不夠的時(shí)候,擴(kuò)展操作碼表示對(duì)應(yīng)的具體指令的。
I 指令,則通常是用在數(shù)據(jù)傳輸、條件分支,以及在運(yùn)算的時(shí)候使用的并非變量還是常數(shù)的時(shí)候。這個(gè)時(shí)候,沒有了位移量和操作碼,也沒有了第三個(gè)寄存器,而是把這三部分直接合并成了一個(gè)地址值或者一個(gè)常數(shù)。
J 指令就是一個(gè)跳轉(zhuǎn)指令,高 6 位之外的 26 位都是一個(gè)跳轉(zhuǎn)后的地址。
add $t0,$s2,$s1
我以一個(gè)簡(jiǎn)單的加法算術(shù)指令 add t0,s1, $s2, 為例,給你解釋。為了方便,我們下面都用十進(jìn)制來表示對(duì)應(yīng)的代碼。
對(duì)應(yīng)的 MIPS 指令里 opcode 是 0,rs 代表第一個(gè)寄存器 s1 的地址是 17,rt 代表第二個(gè)寄存器 s2 的地址是 18,rd 代表目標(biāo)的臨時(shí)寄存器 t0 的地址,是 8。因?yàn)椴皇俏灰撇僮?,所以位移量?0。把這些數(shù)字拼在一起,就變成了一個(gè) MIPS 的加法指令。
為了讀起來方便,我們一般把對(duì)應(yīng)的二進(jìn)制數(shù),用 16 進(jìn)制表示出來。在這里,也就是 0X02324020。這個(gè)數(shù)字也就是這條指令對(duì)應(yīng)的機(jī)器碼。

回到開頭我們說的打孔帶。如果我們用打孔代表 1,沒有打孔代表 0,用 4 行 8 列代表一條指令來打一個(gè)穿孔紙帶,那么這條命令大概就長(zhǎng)這樣:

好了,恭喜你,讀到這里,你應(yīng)該學(xué)會(huì)了怎么作為人肉編譯和匯編器,給紙帶打孔編程了,不用再對(duì)那些用過打孔卡的前輩們頂禮膜拜了。
總結(jié)延伸
到這里,想必你也應(yīng)該明白了,我們?cè)谶@一講的開頭介紹的打孔卡,其實(shí)就是一種存儲(chǔ)程序型計(jì)算機(jī)。
只是這整個(gè)程序的機(jī)器碼,不是通過計(jì)算機(jī)編譯出來的,而是由程序員,用人腦“編譯”成一張張卡片的。對(duì)應(yīng)的程序,也不是存儲(chǔ)在設(shè)備里,而是存儲(chǔ)成一張打好孔的卡片。但是整個(gè)程序運(yùn)行的邏輯和其他 CPU 的機(jī)器語言沒有什么分別,也是處理一串“0”和“1”組成的機(jī)器碼而已。
這一講里,我們看到了一個(gè) C 語言程序,是怎么被編譯成為匯編語言,乃至通過匯編器再翻譯成機(jī)器碼的。
除了 C 這樣的編譯型的語言之外,不管是 Python 這樣的解釋型語言,還是 Java 這樣使用虛擬機(jī)的語言,其實(shí)最終都是由不同形式的程序,把我們寫好的代碼,轉(zhuǎn)換成 CPU 能夠理解的機(jī)器碼來執(zhí)行的。
只是解釋型語言,是通過解釋器在程序運(yùn)行的時(shí)候逐句翻譯,而 Java 這樣使用虛擬機(jī)的語言,則是由虛擬機(jī)對(duì)編譯出來的中間代碼進(jìn)行解釋,或者即時(shí)編譯成為機(jī)器碼來最終執(zhí)行。
然而,單單理解一條指令是怎么變成機(jī)器碼的肯定是不夠的。接下來的幾節(jié),我會(huì)深入講解,包含條件、循環(huán)、函數(shù)、遞歸這些語句的完整程序,是怎么在 CPU 里面執(zhí)行的。
推薦閱讀
這一講里,我們用的是相對(duì)最簡(jiǎn)單的 MIPS 指令集作示例。想要對(duì)我們?nèi)粘J褂玫?Intel CPU 的指令集有所了解,可以參看《計(jì)算機(jī)組成與設(shè)計(jì):軟 / 硬件接口》第 5 版的 2.17 小節(jié)。
課后思考
我們把一個(gè)數(shù)字在命令行里面打印出來,背后對(duì)應(yīng)的機(jī)器碼是什么?你可以試試通過 GCC 把這個(gè)的匯編代碼和機(jī)器碼打出來。
歡迎你在留言區(qū)寫下你的思考和疑問,你也可以把今天的文章分享給你朋友,和他一起學(xué)習(xí)和進(jìn)步。