程式碼是怎麼執行的?

sogeisetsu發表於2021-11-15

前言

“我花了幾個星期…試著弄清楚“強型別”、“靜態型別”、“安全”等術語,但我發現這異常的困難…這些術語的用法不盡相同,所以也就近乎無用。


來源:程式語言專家 Benjamin C. Pierce

今天,我突然發現,我雖然自認為會幾門程式語言和會使用幾種框架,卻對程式語言的原理的理解停留在十分淺薄的層面,我原先對程式語言的理解不過是編譯器將語言編譯成機器程式碼,然後機器再執行機器程式碼,解釋型語言是直譯器邊解釋邊執行,我還知道像java這種語言是先編譯成bytecode(class檔案),然後在jvm裡面用JIT即時編譯……過去我對程式語言的理解僅僅限於上面這些。如果問我諸如直譯器和編譯器的區別,即時編譯、動態編譯、AOT的異同,什麼叫解釋什麼叫編譯,靜態語言和動態語言的區別,python算是強型別語言嗎等問題,我八成會答錯,更讓人沮喪的是在很多部落格網站上像csdn和簡書裡關於程式語言的基本認知錯誤有很多,筆者通過廣泛的閱讀維基百科相關內容和大學教材《編譯原理》,同時閱讀了msdn的官方文件和知乎上一些高質量回答,寫下本文,希望拋磚引玉的同時能夠對網際網路上一些關於程式語言的錯誤言論起到正本清源的作用。

什麼是程式語言

程式語言(英語:programming language),是用來定義計算機程式形式語言。它是一種被標準化的交流技巧,用來向計算機發出指令,一種能夠讓程式設計師準確地定義計算機所需要使用資料的計算機語言,並精確地定義在不同情況下所應當採取的行動。


來源:程式語言 - 維基百科,自由的百科全書 (wikipedia.org)

程式語言,其實就是一個能夠讓計算機明白使用者意圖,同時能讓使用者對計算機發出指令的有統一規則的交流技巧。這裡可以打個擬人化的比喻,cpu所能聽懂的語言和人說的語言是不一樣的,所以需要有一箇中間語言——程式語言來搭建交流的橋樑,事實上從人類的語言到cpu能聽懂的語言往往並不是一個程式語言就能辦到的,通常需要多個程式語言參與其中。

本篇文章的價值觀為一切定義以維基百科為準,如果維基百科和《編譯原理》的定義有衝突,則以《編譯原理》為準。

強弱與動靜態,從C語言說起

先說結論,c語言是弱型別、靜態型別、有編譯器的編譯語言

強弱型別和動靜態型別不是一個東西,也不是一個標準下的產物。

強型別:如果一門語言傾向於不對變數的型別做隱式轉換,那我們將其稱之為強型別語言

弱型別:相反,如果一門語言傾向於對變數的型別做隱式轉換,那我們則稱之為弱型別語言

動態型別:如果一門語言可以在執行時改變變數的型別,那我們稱之為動態型別語言

靜態型別:相反,如果一門語言不可以在執行時改變變數的型別,則稱之為靜態型別語言

作者:網仙
連結:https://www.zhihu.com/question/43498005/answer/266431585
來源:知乎
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。

強弱型別指的是型別檢查的嚴格程度,動靜態區分的是型別檢查在程式執行的哪個階段發生的。(當然了,你如果說型別檢查是早就在IDE編輯器裡面發生的,那就沒意思了。?)

動態和靜態往往是有比較明顯的區分,可是強弱型別確實沒有一條明顯的分界線。就拿python來說,一個整數和浮點數相加會變成浮點數,這就是對變數的型別做了隱式變換。可是即使如此,python在大多數情況下是要比JavaScript這種語言更加的傾向於不做隱式變換,至少python不會去認為整數1和字串1相等(當然,這得是在沒有重寫equals函式和沒有自定義一個函式去主動將字串轉變為浮點數的情況下)。即使和大多數高階語言一樣,在特定的時候會進行隱式轉換,但是那是為了程式設計的方便,而不是因為自身的特性。

比如這段C#程式碼裡面,試圖去進行做隱式資料轉換,可以看到即使在某些情況下這是被允許的,但是,c#的傾向為不做隱式轉換。故而c#為強型別語言。

        /// <summary>
        /// 驗證隱式變換
        /// </summary>
        internal void YinShiChange()
        {
            Console.WriteLine("-=-=-=-=-=-=-=-=-=-=-=-=");
            var a = 1;
            //a += "121"; 這是錯誤的
            Console.WriteLine(a.GetType());
            //在列印的時候是可以的,這時候是將所有的都轉換成字串
            Console.WriteLine(a + "123");
            var v = Convert.ToString(a);
            Console.WriteLine(v.GetType());
            Type intType = typeof(int);
            Console.WriteLine(intType == a.GetType());
            // 值型別的隱式轉換
            var aa = 1;
            Console.WriteLine(intType == aa.GetType());
            double cc = aa;
            Console.WriteLine(cc.GetType() + "\t" + (cc.GetType() == typeof(double)));

            #region
            /*
            -=-=-=-=-=-=-=-=-=-=-=-=
            System.Int32
            1123
            System.String
            True
            True
            System.Double   True
             */
            #endregion

        }

下面這張圖片,清晰的說明了目前維基百科·對幾種知名的語言的強弱型別和動靜態型別的區分。事實上,在維基百科裡面清楚的說明了強弱型別(Strong and weak typing)這兩個術語並沒有非常明確的定義。甚至python的作者吉多一度認為python屬於弱型別語言。下面這個表格僅僅代表了目前比較主流的看法。

b0aeb7ffd1667b9162e5329154d43777_720w.jpg (500×337) (zhimg.com)

C語言是怎麼執行的?

總的來說,從C語言程式碼翻譯為二進位制的過程,主要經歷以下四個階段

  • 階段一:預編譯
  • 階段二:編譯
  • 階段三:彙編
  • 階段四:連結

python 是怎麼執行的?

python語言在執行過程中,是先把python編譯成一種叫做pyc的類似位元組碼的語言,然後cpython編譯器將pyc檔案逐步解釋成機器程式碼來執行,pyc到機器程式碼這一步是逐句翻譯(邊執行邊編譯)的。

我們需要注意到的是python檔案從.py.pyc的過程並不屬於AOT(預先編譯),.py.pyc的過程預設是在每一次的python執行過程中發生的。但是從.py.pyc的過程可以用python -m py_compile file.py進行提前編譯。

python提前編譯成位元組碼

這是一個one.py的普通檔案:

def forEachList(list):
    for i in list:
        if(i % 2 == 0):
            print(i)
        else:
            print("{}無法被整除".format(i))


if __name__ == '__main__':
    ll = []
    for i in range(0, 10):
        ll.append(i)
    forEachList(ll)
    print("hello wrold")

執行python -m py_compile .\one.py,就會目錄變成以下樣子:

.
├── __pycache__
│   └── one.cpython-38.pyc
└── one.py

one.cpython-38.pyc就是編譯出的pyc檔案,可以直接用python .\one.cpython-38.pyc來正常執行這個檔案。

提前編譯的好處

位元組碼在某種意義上會保護程式碼,讓普通人無法直接看到程式碼內部的內容,但是這種保護措施在多如牛毛的反編譯工具面前是沒有什麼意義的。另一方面,提前編譯成位元組碼會省去編譯程式碼的一道步驟,加快執行速度,事實上,像cpython這種沒有JIT(即時編譯)的直譯器,最好是能直接編譯成native code,因為cpython在程式碼執行過程中起到的優化作用有限,甚至所起到的優化作用比不上直接編譯成native code的好處。

請注意,cythoncpythoncpython是python官方的直譯器,Cython 是包含 C 資料型別的 Python,Cython 是 Python,幾乎所有 Python 程式碼都是合法的 Cython 程式碼。 (存在一些限制,但是差不多也可以。) Cython 的編譯器會轉化 Python 程式碼為 C 程式碼,這些 C 程式碼均可以呼叫 Python/C 的 API。

使用cython來達到AOT的效果

Cython入門教程 - 簡書 (jianshu.com)

README | Cython 官方文件中文版 (gitbooks.io)

使用cython可以將pyx檔案直接編譯成native code,這大大提高執行效率,但是這個所謂的native code是離不開cpython直譯器的,原因是因為現在的高階語言它需要直譯器提供類庫來達到最終的效果,如果python有像dotnet這種self-contained的模式並且可以自動裁剪未使用的程式集的話,就會方便很多,可是我並沒有發現python有提供這樣的功能(當然,可能會有一些開源的第三方工具來實現類似功能)。python官方也沒有提供明確的python執行時下載地址(或者說整個python安裝資料夾就是執行時),人們通常認為python安裝資料夾內的lib資料夾為python標準庫的地址,python.exe為python直譯器。

在很多linux發行版的軟體源中提供了名為python-dev的資料夾,裡面包含了python執行所需要的呼叫python api的c/c++檔案。

示例

檔案結構是這樣的:

.
├── one.py
├── run.pyx
└── setup.py

run.pyx:

def sum(x:int,y:int)->int:
    if(x!=0 and y!=0):
        return x+y
    return 0

setup.py:

from distutils.core import setup
from Cython.Build import cythonize

setup(
    ext_modules = cythonize("run.pyx")
)

執行python setup.py build_ext --inplace,檔案結構如下:

.
├── build
│   └── temp.win-amd64-3.8
├── one.py
├── run.c
├── run.cp38-win_amd64.pyd
├── run.pyx
└── setup.py

pyd格式是D語言(C/C++綜合進化版本)生成的二進位制檔案,實際也會是dll檔案。可以在正常的程式碼中引用run.cp38-win_amd64.pyd。以下是one.py引用它的方式:

import run
if __name__ == '__main__':
    c:int=run.sum(12,4)
    print(c)

可以正常執行。

pyd檔案的本質是一個動態連結庫,這個沒法直接執行,只能被呼叫。當然了,寫一個命令,自動將pyd包裹進一個python檔案,然後該檔案呼叫pyd並執行指定的方法也是可行的。

執行時

執行時被叫做runtime,這是一個很形而上學的概念,可以將它看作是程式執行所必須的東西,這就叫執行時。

不同語言,不同直譯器所定義的執行時內容是有不同的。比如說java的執行時叫做jre,它包含jvm虛擬機器和執行時庫,jvm虛擬機器的作用就是對位元組碼進行JIT編譯,記憶體管理,GC垃圾清理等功能,最終生成機器程式碼,將機器程式碼交給cpu執行。.NET的執行時被稱作是CLR(公共語言執行時),和java不同,儘管CLR 也是一種虛擬機器,但是.NET不像java一樣對虛擬機器單獨命名(jvm),CLR 處理記憶體分配和管理。 CLR 也是一種虛擬機器,不僅可執行應用,還可使用 JIT 編譯器快速生成和編譯程式碼。BCL是.net的基類庫,在過去的一些書中,BCL並不被認為是CLR的一部分,但是,現在BCL已經被放在了.NET的runtime開源倉庫中,.NET 5(和 .NET Core)及更高版本的 BCL 的原始碼包含在 .NET 執行時儲存庫中。所以BCL應該被看作是CLR的一部分。儘管MSDN仍然會在很多時候將CLR和BCL放在同樣的位置卻稱呼BCL為執行時庫

AOT

In computer science, ahead-of-time compilation (AOT compilation) is the act of compiling an (often) higher-level programming language into an (often) lower-level language before execution of a program, usually at build-time, to reduce the amount of work needed to be performed at run time.


來源:Ahead-of-time compilation - Wikipedia

AOT編譯(Ahead-of-time compilation)指的是預先編譯,即提前將更高階的程式碼轉換成低階的程式碼,這個低階的程式碼並非專指native machine code。在有的論文中,人們將讓bytecode轉變成c語言程式碼的過程稱之為AOT,也有一個學術專案中將JavaScript程式碼編譯成不依賴JavaScriptCore的位元組碼的過程稱之為AOT,但是人們很少將java原始碼編譯成bytecode的過程稱之為AOT。

從某種意義上來講,AOT和靜態編譯(static compilation)一樣都是提前編譯。在一般使用的過程中,通常將提前編譯且預編譯的行為能帶來某種優勢的行為稱之為AOT,之前說java原始碼到bytecode不被認為是AOT是因為這是java執行的要求,卻不是為java做優化。(In fact, since all static compilation are technically performed ahead of time, this particular wording are often used to emphasize some kind of performance advantages from the act of such pre-compiling. The act of compiling Java to Java bytecode is hence rarely referred to as AOT since it's usually a requirement, not an optimization.)

JIT

即時編譯(英語:just-in-time compilation,縮寫為JIT;又譯及時編譯實時編譯),也稱為動態翻譯執行時編譯,是一種執行計算機程式碼的方法,這種方法涉及在程式執行過程中(在執行期)而不是在執行之前進行編譯


來源:即時編譯 - 維基百科,自由的百科全書 (wikipedia.org)

JIT也是一個形而上學的概念,現在人們通常將JIT等同於動態翻譯。事實上JIT僅僅是動態翻譯的一種實現方式動態編譯(dynamic compilation)指的是“在執行時進行編譯”;與之相對的是事前編譯(ahead-of-time compilation,簡稱AOT),也叫靜態編譯(static compilation)。JIT編譯(just-in-time compilation)狹義來說是當某段程式碼即將第一次被執行時進行編譯,因而叫“即時編譯”。

在這裡,用.NET的JIT編譯作為講解的物件,因為MSDN上有比較官方的說法。但要注意的是不同的編譯器在JIT的實現上是有不同的,就像之前說的,JIT是一個形而上學的概念。

Smalltalk(1983年)開創了JIT編譯的新領域。例如,按需翻譯為機器程式碼,快取結果以供以後使用。當記憶體不足時,系統會刪除部分程式碼,並在需要時重新生成。[4][21]Sun的Self語言廣泛地改進了這些技術,一度是世界上速度最快的Smalltalk系統;運用完全物件導向的語言實現了高達優化C語言一半的速度。[22]

Self被Sun拋棄了,但是研究轉向了Java語言。“即時編譯”這個術語是從製造術語“及時”中借來的,並由Java普及,James Gosling從1993年開始使用這個術語。[23]目前,大多數Java虛擬機器的實現都使用JIT技術,因為HotSpot建立在這個研究基礎之上,而且使用廣泛。


來源:即時編譯 - 維基百科,自由的百科全書 (wikipedia.org)

.NET的JIT,從字面上來理解,及時編譯,按需編譯。

這是.NET的託管程式碼的執行過程:

  1. 選擇編譯器

    若要獲取公共語言執行時提供的好處,必須使用一個或多個面向執行時的語言編譯器。

  2. 將程式碼編譯為 MSIL

    編譯將你的原始碼轉換為 Microsoft 中間語言 (MSIL) 並生成必需的後設資料。

  3. 將 MSIL 編譯為本機程式碼

    在執行時,實時 (JIT) 編譯器將 MSIL 轉換為本機程式碼。 在此編譯期間,程式碼必須通過檢查 MSIL 和後設資料的驗證過程以查明是否可以將程式碼確定為型別安全。

  4. 執行程式碼

    公共語言執行時提供啟用要發生的執行的基礎結構以及執行期間可使用的服務。


    來源:託管執行過程 | Microsoft Docs

程式碼編譯的第一步是編譯器將原始碼轉換為 Microsoft 中間語言 (MSIL),MSIL也可以被稱作CIL或者IL,這個類似於java的bytecode。其實也可以管他叫位元組碼。

JIT編譯為針對每個檔案、每個函式甚至任何任意程式碼片段進行編譯; 程式碼可以在即將執行時進行編譯(因此稱為“即時”),然後快取並在以後重用,無需重新編譯。JIT在執行時只編譯一次,之後都是複用。

簡單點說,JIT即時編譯器就是把一些經常跑的程式碼(位元組碼)提前編成本地機器碼,後面就直接跑機器碼,可以跑的更快些。在人們通常將JIT等同於動態翻譯的今天,JIT是一種更聰明、更有效果的動態編譯。

關於JIT編譯的具體辨析請檢視本文JIT正本清源章節

What is the difference between Just-in-time compilation and dynamic compilation?

這是一個來自stack overflow的問題,也是我關於動態編譯最大的困惑,我知道JIT是動態編譯的一部分。既然有了JIT,那麼常規的動態編譯(dynamic compilation)或者直譯器(Interpreter)和JIT的區別在哪裡呢?就拿python來舉例,為什麼說cpython的從pyc到native machine code 的過程不是JIT呢?

What is the Difference Between Interpreter and JIT Compiler - Pediaa.Com

compiler construction - What is the difference between Just-in-time compilation and dynamic compilation? - Stack Overflow

java - JIT vs Interpreters - Stack Overflow

我試著查閱了一些資料,不止是Wikipedia is confusing,很多人的回答也是令人困惑。所以我得出了一個武斷的結論(因為很多網友的說法難以自圓其說,維基百科又是模稜兩可),下面是我對我的結論的陳述:

首先,Till now computers don't execute anything other than machine code。現在事實上電腦只能看懂native machine code,不管是編譯器(compiler)還是直譯器(Interpreter)的最終目的只有一個就是讓是將便於人編寫、閱讀、維護的高階計算機語言所寫作的原始碼程式,翻譯為計算機能解讀、執行的低階機器語言的程式,也就是可執行檔案。最終讓程式執行在電腦上。

編譯器追求的是一次性將所有原始碼編譯成二進位制檔案。

一個現代編譯器的主要工作流程如下:

原始碼(source code)→ 前處理器(preprocessor)→ 編譯器(compiler)→ 彙編程式(assembler)→ 目的碼(object code)→ 連結器(linker)→ 可執行檔案(executables),最後打包好的檔案就可以給電腦去判讀執行了。


來源:編譯器的工作流程 - 維基百科,自由的百科全書 (wikipedia.org)

直譯器的目的是邊解釋邊執行(by line),因此依賴於直譯器的程式啟動速度比較緩慢。直譯器的好處是它不需要重新編譯整個程式,從而減輕了每次程式更新後編譯的負擔,並且因為直譯器往往會有執行時對程式碼進行分析和處理,執行速度一般會更快(python除外,據我的經驗,python執行的真心不快,網上有大佬認為是cpython的設計不科學的問題)。

直譯器執行程式的方法一般被認為是下面3種:

  1. 直接執行高階程式語言(如Shell內建的編譯器)
  2. 轉換高階程式語言到更有效率的位元組碼(Bytecode),並執行位元組碼
  3. 用直譯器包含的編譯器對高階語言進行編譯,並指示中央處理器執行編譯後的程式(例如:JIT

來源:直譯器 - 維基百科,自由的百科全書 (wikipedia.org)

python是屬於第二種方法,即從原始碼全部翻譯為到pyc,然後在邊解釋邊執行pyc,執行pyc的過程就是pyc到native machine code的過程,當然這個過程裡面或許會需要虛擬機器(不是真的硬體,而是一種位元組碼直譯器,pyx就相當於虛擬機器的native machine code)。

JIT編譯器的執行過程從大的方面(原始碼->位元組碼->虛擬機器執行位元組碼,將位元組碼解釋為機器程式碼)來看是和cpython是一致的。

JIT概念的爭論

我並不認同“原始碼–>位元組碼”這一過程是JIT執行的一部分,我認為“原始碼–>位元組碼”是AOT的一部分(對虛擬機器來說),JIT僅是“位元組碼->虛擬機器執行位元組碼,將位元組碼解釋為機器程式碼”這一部分。在MSDN和維基百科的某些頁面上也是這麼認為的,MSDN的託管程式碼執行過程認為實時 (JIT) 編譯器將 MSIL 轉換為本機程式碼。 維基百科關於直譯器的說明中認為即時編譯(Just-in-time compilation)是指一種在執行時期把位元組碼編譯成原生機器程式碼的技術;這項技術是被用來改善虛擬機器的效能的。但是在維基百科的關於JIT的頁面中認為JIT編譯是兩種傳統的機器程式碼翻譯方法——提前編譯(AOT)和解釋——的結合,它結合了兩者的優點和缺點。維基百科的本意可能不是將AOT歸入JIT,但是確實有可能讓人產生困惑,Wikipedia is confusing。在CSDN的討論中認為JIT是編譯和解釋的結合。

就像前面說的一樣,人們有時會預設將JIT代表動態編譯,人們也因為JIT和原始碼到位元組碼的過程緊密相關而將提前編譯為位元組碼認為是JIT的一部分。這不能說是錯的,這應該是不準確的。

JIT正本清源

JIT的基本執行過程,基本上沒有什麼爭論:

JIT編譯的一個常見實現是首先進行AOT編譯,把原始碼編譯成位元組碼(虛擬機器程式碼),稱為位元組碼編譯,然後將JIT編譯為機器碼(動態編譯),而不是解釋位元組碼。與解釋相比,這提高了執行時效能,但代價是編譯造成的延遲。與直譯器一樣,JIT編譯器不斷地進行翻譯,但是對編譯後的程式碼進行快取可以最大限度地減少在給定執行期間將來執行相同程式碼的延遲。


來源:即時編譯 - 維基百科,自由的百科全書 (wikipedia.org)

儘管JIT稱之為編譯,但和直譯器一樣JIT也是不斷地進行翻譯,而非直接全部翻譯出來。但是JIT的編譯後的程式碼會被快取在記憶體中,來增加複用率。更高階的JIT甚至後對不同程式碼所發揮價值的大小來進行不同優化程度的編譯。早在1983年,當時self語言的動態編譯(當時還沒有JIT這個詞彙)就已經會根據記憶體大小來動態進行編譯和在記憶體不足時刪除部分編譯結果並在需要的時候重新生成了。

JIT和一般意義上地直譯器的區別為是否對到機器程式碼的過程進行動態的優化,這也是動態編譯和一般直譯器的區別,有些優化是隻有到了執行過程中在有可能根據當前的機器狀態等資訊進行優化。維基百科的動態編譯詞條認為使用動態編譯的執行環境一開始執行速度較慢,之後,完成大部分的編譯和再編譯後,會執行得比非動態編譯程式快很多。維基百科應該是說的不全面,它遵循了現在的習慣,將動態編譯指代了JIT。動態編譯應該像AOT一樣,之所以單獨命名成動態編譯而非延用直譯器這個名稱是因為和單純的直譯器相比能帶來某種優勢。

JIT和一般意義上的動態編譯(這個一般意義指的是1993年以前,當時java還沒有提出JIT,在1995年之前的論文中,動態編譯是唯一術語,The term dynamic compilation used the be the standard and only term to refer to the family of techniques of compiling code at run-time before 1995. )的區別是優化的程度以及優化的成本,JIT編譯器不斷地進行翻譯,但是對編譯後的程式碼進行快取可以最大限度地減少在給定執行期間將來執行相同程式碼的延遲。其實看到這裡會清楚的發現,試圖去理清JIT和動態編譯的區別是很難的,JIT脫胎於動態編譯,1983年self語言雖然被稱作是動態編譯,但是已經和現在的JIT的邏輯很相似了。

歷史原因

我們現在都知道JIT是動態編譯的一種形式,動態編譯還有一種形式叫做自適應動態編譯(adaptive dynamic compilation,也是一種動態編譯,但它通常執行的時機比JIT編譯遲,先讓程式“以某種形式”先執行起來,收集一些資訊之後再做動態編譯。現在只能這麼說,常規意義上的動態編譯和JIT編譯的定義幾乎一致。現在的高階語言的JIT已經有了和我們常規意義上的動態編譯有了很大的區別,比如.NET的分層編譯能夠根據程式碼的重要程度和複雜程度來確定優化的程度來達到執行速度和效能的平衡。

之所以難以釐清JIT和動態編譯之間的區別有很大一部分是因為歷史原因。動態編譯的歷史並不是像我們想象的那樣是由一個技術概念而引發的技術應用,事實情況是,在動態編譯的發展歷史之中,往往是一個技術在被使用了多年之後,人們才將其抽象成一個概念。這樣就會導致往往會有一個時間段裡面某個技術的運用包含了太多後來的技術概念,可是因為歷史的原因,依然很難將當時的技術命名為之後才抽象出來的概念。就像隋煬帝開始設進士科,進行科舉值之前的近百年裡已經有一種名為分科舉人的通過考試來選拔人才的制度了,儘管這樣,歷史書上還是隻能將開創科舉制歸功於隋煬帝。

從1960年,計算機學家約翰·麥卡錫就已經提出了動態編譯的雛形(其實準確的來說這應該是直譯器的優化雛形,當時所謂的機器程式碼還是以打孔卡作為儲存介質),後來在1986年的sun公司的self語言中運用了一種能夠按需翻譯為機器程式碼,快取結果以供以後使用。當記憶體不足時,系統會刪除部分程式碼,並在需要時重新生成的技術。從能查閱到最早的關於這一使用這一技術而非處於科學研究階段的論文是1984年的 Efficient implementation of the Smalltalk-80 system中可以看到當時並沒有將這一技術命名為dynamic compilation或Just-in-time compilation,但是從原理上來看已經和今天的JIT幾乎一致了,在這之前,dynamic compilation作為一個概念曾廣泛存在於論文中,比如1983年的Performance enhancements to a relational database system就提到了dynamic compilation這一名詞,總之我們可以確定的是在早期人們並不明確的將現在的動態編譯技術和動態編譯這一名詞結合起來,直到後來sun公司將原用於self的這一技術運用於java語言,並命名為Just-in-time compilation,也就是JIT編譯。其實準確的將這一技術命名為JIT是在1993年,當時java語言還沒有釋出,sun公司正在研發java語言的前身oak(Oak was renamed Java in 1994 after a trademark search revealed that Oak was used by Oak Technology)。

結論

因為之前一直沒有明確的結論,現在我武斷的下一個結論,dynamic compilation這一技術的本質是能提供某種優化的Interpreter,Just-in-time compilation、dynamic recompilation和adaptive dynamic compilation都是dynamic compilation的一種形式,其中JIT編譯(Just-in-time compilation)的技術標準是為針對每個檔案、每個函式甚至任何任意程式碼片段進行編譯; 程式碼可以在即將執行時進行編譯(因此稱為“即時”),然後快取並在以後重用,無需重新編譯。JIT在執行時只編譯一次,之後都是複用。

在過去某一個時間裡面,JIT雖然被運用,但是並沒有被命名成Just-in-time compilation而是依然用dynamic compilation來命名。由於JIT的廣泛使用,一般情況下,人們會將dynamic compilation等同於Just-in-time compilation。

現在很多優秀的語言編譯器已經不在滿足於僅僅使用JIT,而是更加智慧和提供了更多選擇,比如java的HotSpot技術會將經常被執行的程式碼採用JIT編譯成機器程式碼並快取,對只執行幾次的位元組碼採用普通的解釋執行。微軟的.NET的JIT採用了分層編譯的技術,對不重要和不頻繁呼叫的位元組碼採用快速JIT編譯成優化程度較低的機器程式碼,對呼叫頻繁,複雜的的位元組碼採用完全優化的JIT進行編譯(.NET Core 3.0 的新增功能 | Microsoft Docs)。.NET也有類似於AOT的readytorun和NativeAOT預編譯解決方案。

  • ReadyToRun

    可以通過將應用程式集編譯為 ReadyToRun (R2R) 格式來改進.NET Core 應用程式的啟動時間。 R2R 是一種預先 (AOT) 編譯形式。應用的大多數程式碼都是 AOT 編譯的,但有些程式碼是 JIT 編譯的。 某些程式碼模式不適用於 AOT(如泛型)R2R 二進位制檔案更大,因為它們包含中間語言 (IL) 程式碼(某些情況下仍需要此程式碼)和相同程式碼的本機版本。

  • NativeAOT

    目前還是預覽版,工作就是通過.NET將程式碼完全編譯成機器程式碼。不包含IL程式碼。微軟計劃在.NET 7時間範圍中對NativeAOT做這些優化。現在最新的版本是preview 7.0.0-*版本。

程式碼混淆

程式碼混淆可以有效的保證程式碼的安全性,從理論上來講所有的程式碼最後都要變成指令去讓cpu執行,而cpu是由人類設計的,也就是說,反編譯這個事情是一個困難程度的事情而不是有沒有可能的事情,想讓原始碼不被人看到是不太可能。但是讓自己的原始碼別人看不懂是由可能的,如果自己的程式碼列印個“hello wrold”都需要100行程式碼,那麼別人能看懂的機率就不是很大。

我的原始碼保護經驗是,能用私有屬性就儘量用私有屬性,能不public就不public。程式碼一定要經過混淆。這是保護程式碼的最後一道防線。在這裡我用.NET Reactor來做程式碼混淆,用de4dot來進行去殼操作。用dotpeek作為反編譯工具。看一下最終和原始碼的區別。

這是使用dotpeek反編譯未經程式碼混淆的控制檯程式:

using System;
using System.Collections.Generic;

namespace OOB
{
    /// <summary>
    /// 定義了方法
    /// </summary>
    internal class Program
    {
        private static void Hi(ref int a, List<string> list)
        {
            if (a != 0)
            {
                for (int i = 0; i < list.Count; i++)
                {
                    Console.WriteLine(list[i]);
                    if (i % 2 == 0)
                    {
                        list.Add(list[i] + "a");
                    }
                    else
                    {
                        list.RemoveAt(i);
                    }
                }
            }
            else
            {
                Console.WriteLine(a);
            }
        }

        /// <summary>
        /// 主方法
        /// </summary>
        /// <param name="args"></param>
        private static void Main(string[] args)
        {
            Console.WriteLine("Hello World!");
            List<string> listA = new() { "123", "你好", "<name>", "hahah" };
            int a = 12;
            Hi(ref a, listA);
        }
    }
}

可以看到.NET的dll檔案毫無還手之力,被及其容易的反編譯。

現在用.NET Reactor進行混淆,混淆之後效果如下:

using HS1iDvP6cPSr6EVC1K;
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using Y04pXxuti1Wwbm9GOm;

namespace SnxhMamAqk3EZqxPU3
{
  internal class zvuf870dMXwr3ZTmjl
  {
    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void FG3ePcOJ0(ref int _param0, List<string> _param1)
    {
      if (_param0 != 0)
      {
        for (int index = 0; index < _param1.Count; ++index)
        {
          Console.WriteLine(_param1[index]);
          if (index % 2 == 0)
            _param1.Add(_param1[index] + ynPCefDwj0sfr3loBQ.MQb0Gqmik(0));
          else
            _param1.RemoveAt(index);
        }
      }
      else
        Console.WriteLine(_param0);
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void yjhx0jGwM(string[] _param0)
    {
      if (26902 - 382226 == -355324)
        ;
      do
      {
        int num;
        List<string> stringList;
        do
        {
          do
          {
            Console.WriteLine(ynPCefDwj0sfr3loBQ.MQb0Gqmik(6));
          }
          while (110020 - 555598 == -445577);
          stringList = new List<string>()
          {
            ynPCefDwj0sfr3loBQ.MQb0Gqmik(34),
            ynPCefDwj0sfr3loBQ.MQb0Gqmik(44),
            ynPCefDwj0sfr3loBQ.MQb0Gqmik(52),
            ynPCefDwj0sfr3loBQ.MQb0Gqmik(68)
          };
          if (288185 - 437976 == -149791)
            num = 12;
        }
        while (227838 - 260470 == -32631);
        zvuf870dMXwr3ZTmjl.FG3ePcOJ0(ref num, stringList);
      }
      while (18107 - 405292 == -387184);
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    public zvuf870dMXwr3ZTmjl()
    {
      VO4Fe7fvUeFLREiK87.MfgIrOivFT5UF();
      // ISSUE: explicit constructor call
      base.\u002Ector();
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    internal static bool ilDR2IAPbOxK9JUgww() => true;

    [MethodImpl(MethodImplOptions.NoInlining)]
    internal static bool joInk2HxpG3g4TIRnb() => false;
  }
}

可以說面目全非。

用著名去殼軟體de4dot來反混淆,效果如下:

using HS1iDvP6cPSr6EVC1K;
using System;
using System.Collections.Generic;
using Y04pXxuti1Wwbm9GOm;

namespace SnxhMamAqk3EZqxPU3
{
  internal class zvuf870dMXwr3ZTmjl
  {
    private static void FG3ePcOJ0(ref int int_0, List<string> list_0)
    {
      if (int_0 != 0)
      {
        for (int index = 0; index < list_0.Count; ++index)
        {
          Console.WriteLine(list_0[index]);
          if (index % 2 == 0)
            list_0.Add(list_0[index] + ynPCefDwj0sfr3loBQ.MQb0Gqmik(0));
          else
            list_0.RemoveAt(index);
        }
      }
      else
        Console.WriteLine(int_0);
    }

    private static void yjhx0jGwM(string[] args)
    {
      Console.WriteLine(ynPCefDwj0sfr3loBQ.MQb0Gqmik(6));
      List<string> list_0 = new List<string>()
      {
        ynPCefDwj0sfr3loBQ.MQb0Gqmik(34),
        ynPCefDwj0sfr3loBQ.MQb0Gqmik(44),
        ynPCefDwj0sfr3loBQ.MQb0Gqmik(52),
        ynPCefDwj0sfr3loBQ.MQb0Gqmik(68)
      };
      int int_0 = 12;
      zvuf870dMXwr3ZTmjl.FG3ePcOJ0(ref int_0, list_0);
    }

    public zvuf870dMXwr3ZTmjl()
    {
      VO4Fe7fvUeFLREiK87.MfgIrOivFT5UF();
      // ISSUE: explicit constructor call
      base.\u002Ector();
    }

    internal static bool ilDR2IAPbOxK9JUgww() => true;

    internal static bool joInk2HxpG3g4TIRnb() => false;
  }
}

可以看到已經很接近原始碼了,可是依然含有很多無用程式碼,再加上註釋已經被全部刪除了。閱讀起來依然有很大的困難。況且這個程式碼非常簡單,如果是一個稍微複雜一點的專案,混淆之後的閱讀難度將會呈幾何增加。在這次程式碼混淆demo裡面,我只使用了.NET Reactor的如下功能,只佔了.NET Reactor功能的一小部分:

LICENSE

本文已將所有引用其他文章之內容清楚明白地標註,其他部分皆為作者勞動成果。對作者勞動成果做以下宣告:

copyright © 2021 蘇月晟,版權所有。

知識共享許可協議
作品蘇月晟採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。不可以商業目的轉載和引用此文章,在非商業轉載時請註明來源和作者資訊,並採用相同的許可協議。如需商業轉載需聯絡作者並取得作者本人同意。

相關文章