電腦基礎系列:源程式碼如何被電腦執行

  • 2019 年 12 月 25 日
  • 筆記

電腦晶片的物理特性決定了它只能接受二進位指令。不同電腦晶片的指令集不同。高級程式語言需要轉化成二進位機器語言才能被電腦所執行。編譯型語言需要使用編譯器經過編譯和連接生成可執行文件,解釋型語言需要使用解釋器解釋源程式碼。解釋型語言更容易上手,但是運行速度更慢,必要時要使用C/C++重寫或使用JIT技術加速。

現在各行各業的朋友都開始使用電腦解決自己的業務問題,網路上有大量的免費公開課,教我們處理數據並數學建模。Python等程式語言上手快,開源軟體多,足以應付絕大多數的需求。在電腦軟硬體體系中,上述工作都是在最頂層,用戶執行程式需要依賴於電腦硬體和系統軟體。聊天用的微信、娛樂玩的農藥、上網打開的瀏覽器、還有我們自己寫的程式…這些程式是如何從源程式碼,變成電腦晶片可以執行的程式呢?

電腦軟硬體體系結構

本文並不是想把這個複雜的鏈條都解釋得非常清楚,或者鼓勵大家都去學習底層編程知識,而是想讓讀者對這個流程有一個基本的認識。因為,當我們熟練掌握了一定編程基礎,開始上手更大規模的數據或更複雜的數學模型時,會遇到一些瓶頸,直接調用別人寫好的程式或者應用新熱演算法都無法直接解決問題。這時候了解電腦的基本組成和運行原理,打好基礎,才能為頂層應用拓展思維。

電腦只能執行二進位程式碼

也許你已經知道,電腦是基於二進位運行的。就像道家哲學的陰陽一樣,電腦只有兩個狀態,開或關、真或假、1或0…因為,組成電腦的基本元件——半導體只能以二進位進行計算。我們編程所用的C/C++、Python、大數據、AI等層出不窮的技術,以及我們存儲在電子設備的文本、音頻、影像、影片等媒介,最終都是以二進位的形式,被計算和處理的。電腦體系最底層的工程師要使用二進位程式碼控制晶片來做計算和處理。

我在我的Mac上編寫了一個名為plusc = a + b程式,其二進位和彙編程式碼如下所示:

➜  objdump -s plus    plus:   file format Mach-O 64-bit x86-64    _main:  100000f30:      55      pushq   %rbp  100000f31:      48 89 e5        movq    %rsp, %rbp  100000f34:      48 83 ec 20     subq    $32, %rsp  100000f38:      c7 45 fc 00 00 00 00    movl    $0, -4(%rbp)  100000f3f:      c7 45 f8 01 00 00 00    movl    $1, -8(%rbp)  100000f46:      c7 45 f4 02 00 00 00    movl    $2, -12(%rbp)  100000f4d:      c7 45 f0 00 00 00 00    movl    $0, -16(%rbp)  100000f54:      8b 45 f8        movl    -8(%rbp), %eax  100000f57:      03 45 f4        addl    -12(%rbp), %eax  100000f5a:      89 45 f0        movl    %eax, -16(%rbp)  100000f5d:      8b 75 f8        movl    -8(%rbp), %esi  100000f60:      8b 55 f4        movl    -12(%rbp), %edx  100000f63:      8b 4d f0        movl    -16(%rbp), %ecx  100000f66:      48 8d 3d 35 00 00 00    leaq    53(%rip), %rdi  100000f6d:      b0 00   movb    $0, %al  100000f6f:      e8 0e 00 00 00  callq   14 <dyld_stub_binder+0x100000f82>  100000f74:      31 c9   xorl    %ecx, %ecx  100000f76:      89 45 ec        movl    %eax, -20(%rbp)  100000f79:      89 c8   movl    %ecx, %eax  100000f7b:      48 83 c4 20     addq    $32, %rsp  100000f7f:      5d      popq    %rbp  100000f80:      c3      retq  ........

首行的file format Mach-O 64-bit x86-64表示這是一個可以運行在64位x86架構的處理器上、基於Mac OS的一段程式。不同的電腦晶片廠商所設計的半導體電路不同,在晶片上編程的二進位規則不同。執行同樣的一段c = a + b的邏輯,在基於ARM架構晶片的Android手機上所需要的二進位程式碼與上面展示的會截然不同。當前市場上電腦CPU晶片基本被幾大科技公司壟斷,除了剛提到的Intel和AMD研發的應用在個人電腦上的x86-64處理器,應用在手機、平板電腦等移動設備上的ARM架構處理器,還有應用在大型伺服器和超級電腦上的IBM Power系列處理器等。不同架構的CPU處理器都有自己的一套指令集(instruction set architecture,簡稱ISA),這就像一個設計圖紙和使用說明書,告訴編程人員如何使用在其晶片上進行編程:包括如何進行加減乘除計算,如何從記憶體中讀取數據等指令操作。底層開發人員會根據不同指令集,適配不同的CPU處理器。電腦能執行的指令,又被成為機器語言機器碼

前面所展示的二進位文件是一個可執行文件。什麼是可執行文件呢?可執行文件就是二進位機器語言的集合,可以被機器執行,得到我們想要的結果。我們在Windows上常會遇到的.exe文件,就是可執行文件,exe其實是executable的縮寫,從手機應用商店下載的APP也是可執行文件的一種變體。

C語言從源程式碼到可執行文件

很多朋友覺得C/C++編程調試難,沒有比較就沒有傷害,看到前文所提到的一個簡單加法的程式竟然需要這麼多看不懂的01程式碼,是不是覺得C語言簡直是天才般的發明。是的,C語言的發明者當時考慮的就是不同晶片廠商有不同的指令集,相互之間難以兼容,於是想在那些晦澀難懂的底層語言上,建立一個更為通用的編程範式,這樣編程人員不用浪費時間精力去識記大量的01二進位指令。那C語言程式碼是如何轉化為可被機器執行的二進位文件呢?編譯器和作業系統是兩個非常關鍵的技術。

下面繼續以加法計算plus.c源程式碼為例,展示編譯器和作業系統電腦將C語言轉化為機器可執行文件。

#include <stdio.h>    int main()  {      int a = 1, b = 2, c = 0;      c = a + b;      printf("a = %d b = %d c = %d n", a, b ,c);      return 0;  }

Linux和Mac OS用戶可以使用gcc -o plus plus.c這個命令來將plus.c的源程式碼編譯成名為plus的可執行文件,plus會生成在當前的文件夾下。

執行這個二進位文件,結果將被列印到螢幕上:

$ ./plus  a = 1 b = 2 c = 3

gcc是一款開源的編譯器,是GNU Compiler Collection中的一員,它可以將C語言程式碼編譯成可執行文件。GNU Compiler Collection還有C++編譯器g++、Fortran編譯器gfortran,並且支援包括x86-64和ARM在內的不同指令集。

源程式碼編譯執行過程

C語言從源程式碼到執行,要使用編譯器來編譯(compile)、彙編(assembly)並連接(link)所依賴的庫,形成機器可執行文件。執行這個二進位文件時,作業系統會為程式分配記憶體和CPU資源。「編譯」和「彙編」,相當於將C語言翻譯成底層語言。另外,程式碼中使用了庫函數printf,當我們使用別人寫好的函數時,需要將這些前人寫好的庫函數連接到我們的可執行文件中,否則有調用函數失敗的錯誤。我們將這種需要編譯的語言稱為編譯型語言。編譯型語言有C/C++、Fortran等。

作業系統和編譯器是緊密相連的,不同作業系統所提供的編譯環境不同。Linux和GCC編譯器密不可分,Windows有自家研發的MSVC(Microsoft Visual C++)。不同作業系統在管理網路、讀寫硬碟、圖形化等具體的實現方式不同,庫函數連接方式不同…可執行文件一般需要調用這些作業系統介面,所以最終連接生成的可執行文件會截然不同。了解了編譯知識,就不難明白為什麼很多軟體提供商對同一個軟體會提供Windows、Mac OS、Linux、iOS、Android等多個版本的下載。因為不同平台的硬體、編譯器和作業系統存在著巨大差異,可執行文件完全不同。所以,也就不難理解Windows軟體為什麼不可能在Mac OS上運行。

實際構建一個大型項目時,編譯要考慮的問題會更多。比如我自己編寫了多個文件,文件1會被文件2調用,所以要先編譯文件1,後編譯文件2,否則會因為順序顛倒而報錯;還比如編譯型語言對所以依賴的庫函數非常挑剔,如果版本過低,有可能出現編譯錯誤。類似的問題會很多,因此編譯型語言在編程和調試時更麻煩,實際操作中一般會使用構建工具鏈(toolchain),根據一定的順序,從前到後串起來地去編譯。

解釋性語言:Java、Python、R…

既然可以將01組成的機器語言抽象成容易編寫的C語言,那為什麼不能繼續再用類似的辦法,再做一次包裝呢?IT圈的一句名言就是:電腦科學任何領域的問題都可以通過增加一個中間層來解決。一些大牛忍受不了C語言這樣編寫和調試太慢,系統平台之間無法共享移植的問題,於是開始自立門戶,創建了新的程式語言,最有名的要數Java和Python,這類語言不需要每次都編譯,因此被稱為解釋型語言。matlab、R、JavaScript也是解釋語言。

解釋型語言執行過程

解釋型語言一般是使用C語言等偏底層的語言做一個虛擬機或者解釋器,編程人員需要先在自己的電腦上安裝這個解釋器,接下來就只用關心自己的源程式碼,其他的事情都交給解釋器去做。如果把編譯型語言的編譯過程比作將源程式碼「翻譯」成機器語言的話,那麼解釋型語言就是同聲傳譯。編譯型語言是一篇提前就「翻譯」好的稿子,拿過來就能被讀出來,這樣肯定更快;解釋型語言要等翻譯邊「聽」邊「翻譯」,速度當然慢很多。

有了解釋器,我們可以在任何安裝了Python的機器上運行同樣一份.py源程式碼文件。像Python這樣的解釋語言就像一個高級計算器,非常容易上手,有一些理工基礎的朋友,半天時間就能學會。

其實,這就是一個妥協的過程,解釋語言放棄了速度,取得了易用性和可移植性。

如果我還是關心速度呢?當然還是要回歸底層,拒絕中間商賺差價嘛!

以Python為例,為了保證性能,大部分高性能科學計算庫其實都是使用編譯型語言編寫的。比如numpy,用戶安裝numpy的包時,其實就是下載了C/C++和Fortran源程式碼,並在本地編譯成了可執行的文件。Python用戶自己可以使用Cython這樣的工具,R語言可以使用Rcpp。我最近在使用Java來調用C++程式碼,速度有成倍提升。一些計算密集型的程式可以考慮用這種方法來進行優化。

另一種方案是JIT(Just-In-Time)技術。JIT把需要加速的程式碼編譯成了機器語言,不再需要「同聲傳譯」拖累自己了。我在Python上用numba庫進行過JIT測試,同樣的程式碼會有8倍以上的速度提升。

本專欄以後也將介紹如何對解釋語言進行加速。

小結

北京後海 攝於2011年11月

電腦晶片的物理特性決定了它只能接受二進位指令。不同電腦晶片的指令集不同。高級程式語言需要轉化成二進位機器語言才能被電腦所執行。編譯型語言需要使用編譯器經過編譯和連接生成可執行文件,解釋型語言需要使用解釋器解釋源程式碼。解釋型語言更容易上手,但是運行速度更慢,必要時可使用C/C++重寫或使用JIT技術加速。