C#基礎之IL ,輕鬆讀懂中間程式碼IL 轉載
[No0000152]C#基礎之IL,輕鬆讀懂IL
先說說學IL有什麼用,有人可能覺得這玩意平常寫程式碼又用不上,學了有個卵用。到底有沒有卵用呢,暫且也不說什麼學了可以看看一些語法糖的實現,或對.net理解更深一點這些虛頭巴腦的東西。其實IL本身邏輯很清楚,主要是把指令的意思搞明白就好辦了。記指令只要記住幾個規律就好,我把它們分為三類。
第一類 :直觀型
這一類的特點是一看名字就知道是幹嘛的,不需要多講,如下:
名稱 |
說明 |
Add |
將兩個值相加並將結果推送到計算堆疊上。 |
Sub |
從其他值中減去一個值並將結果推送到計算堆疊上。 |
Div |
將兩個值相除並將結果作為浮點(F型別)或商(int32型別)推送到計算堆疊上。 |
Mul |
將兩個值相乘並將結果推送到計算堆疊上。 |
Rem |
將兩個值相除並將餘數推送到計算堆疊上。 |
Xor |
計算位於計算堆疊頂部的兩個值的按位異或,並且將結果推送到計算堆疊上。 |
And |
計算兩個值的按位"與"並將結果推送到計算堆疊上。 |
Or |
計算位於堆疊頂部的兩個整數值的按位求補並將結果推送到計算堆疊上。 |
Not |
計算堆疊頂部整數值的按位求補並將結果作為相同的型別推送到計算堆疊上。 |
Dup |
複製計算堆疊上當前最頂端的值,然後將副本推送到計算堆疊上。 |
Neg |
對一個值執行求反並將結果推送到計算堆疊上。 |
Ret |
從當前方法返回,並將返回值(如果存在)從呼叫方的計算堆疊推送到被呼叫方的計算堆疊上。 |
Jmp |
退出當前方法並跳至指定方法。 |
Newobj |
New Object建立一個值型別的新物件或新例項,並將物件引用推送到計算堆疊上。 |
Newarr |
New Array將對新的從零開始的一維陣列(其元素屬於特定型別)的物件引用推送到計算堆疊上。 |
Nop |
如果修補操作碼,則填充空間。儘管可能消耗處理週期,但未執行任何有意義的操作。Debug下的 |
Pop |
移除當前位於計算堆疊頂部的值。 |
Initobj |
Init Object將位於指定地址的值型別的每個欄位初始化為空引用或適當的基元型別的0。 |
Isinst |
Is Instance測試物件引用是否為特定類的例項。 |
Sizeof |
將提供的值型別的大小(以位元組為單位)推送到計算堆疊上。 |
Box |
將值類轉換為物件引用。 |
Unbox |
將值型別的已裝箱的表示形式轉換為其未裝箱的形式。 |
Castclass |
嘗試將引用傳遞的物件轉換為指定的類。 |
Switch |
實現跳轉表。 |
Throw |
引發當前位於計算堆疊上的異常物件。 |
Call |
呼叫由傳遞的方法說明符指示的方法。 |
Calli |
通過呼叫約定描述的引數呼叫在計算堆疊上指示的方法(作為指向入口點的指標)。 |
Callvirt |
對物件呼叫後期繫結方法,並且將返回值推送到計算堆疊上。 |
強調一下,有三種call,用的場景不太一樣:
Call:常用於呼叫編譯時就確定的方法,可以直接去元資料裡找方法,如靜態函式,例項方法,也可以call虛方法,不過只是call這個型別本身的虛方法,和例項的方法性質一樣。另外,call不做null檢測。
Calli: MSDN上講是間接呼叫指標指向的函式,具體場景沒見過,有知道的朋友望不吝賜教。
Callvirt: 可以呼叫例項方法和虛方法,呼叫虛方法時以多型方式呼叫,不能呼叫靜態方法。Callvirt呼叫時會做null檢測,如果例項是null,會丟擲NullReferenceException,所以速度上比call慢點。
第二類:載入(ld)和儲存(st)
我們知道,C#程式執行時會有執行緒棧把引數,區域性變數放上來,另外還有個計算棧用來做函式裡的計算。所以把值載入到計算棧上,算完後再把計算棧上的值存到執行緒棧上去,這類指令專門幹這些活。
比方說 ldloc.0:
這個可以拆開來看,Ld打頭可以理解為Load,也就是載入;loc可以理解為local variable,也就是區域性變數,後面的 .0表示索引。連起來的意思就是把索引為0的區域性變數載入到計算棧上。對應的 ldloc.1就是把索引為1的區域性變數載入到計算棧上,以此類推。
知道了Ld的意思,下面這些指令也就很容易理解了。
ldstr = load string,
ldnull = load null,
ldobj = load object,
ldfld = load field,
ldflda = load field address,
ldsfld = load static field,
ldsflda = load static field address,
ldelem = load element in array,
ldarg = load argument,
ldc 則表示載入數值,如ldc.i4.0,
關於字尾
.i[n]:[n]表示位元組數,1個位元組是8位,所以是8*n的int,比如i1, i2, i4, i8,i1就是int8(byte), i2是int16(short),i4是int32(int),i8是int64(long)。
相似的還有.u1 .u2 .u4 .u8 分別表示unsigned int8(byte), unsigned int16(short), unsigned int32(int), unsigned int64(long);
.R4,.R8 表示的是float和double。
.ovf (overflow)則表示會進行溢位檢查,溢位時會丟擲異常;
.un (unsigned)表示無符號數;
.ref (reference)表示引用;
.s (short)表示短格式,比如說正常的是用int32,加了.s的話就是用int8;
.[n]比如 .1,.2 等,如果跟在i[n]後面則表示數值,其他都表示索引。如 ldc.i4.1就是載入數值1到計算棧上,再如ldarg.0就是載入第一個引數到計算棧上。
ldarg要特別注意一個問題:如果是例項方法的話ldarg.0載入的是本身,也就是this,ldarg.1載入的才是函式的第一個引數;如果是靜態函式,ldarg.0就是第一個引數。
與ld對應的就是st,可以理解為store,意思是把值從計算棧上存到變數中去,ld相關的指令很多都有st對應的,比如stloc, starg, stelem等,就不多說了。
第三類:比較指令,比較大小或判斷bool值
有一部分是比較之後跳轉的,程式碼裡的 if 就會產生這些指令,符合條件則跳轉執行另一些程式碼:
以b開頭:beq, bge, bgt, ble, blt, bne
先把b去掉看看:
eq:equivalentwith,==
ge:greaterthanorequivalentwith,>=
gt:greaterthan,>
le:lessthanorequivalentwith,<=
lt:lessthan,<
ne: notequivalentwith, !=
這樣是不是很好理解了,beq IL_0005就是計算棧上兩個值相等的話就跳轉到IL_0005, ble IL_0023是第一個值小於或等於第二個值就跳轉到IL_0023。
以br(break)開頭:br, brfalse, brtrue,
br是無條件跳轉;
brfalse表示計算棧上的值為 false/null/0 時發生跳轉;
brtrue表示計算棧上的值為 true/非空/非0 時發生跳轉
還有一部分是c開頭,算bool值的,和前面b開頭的有點像:
ceq 比較兩個值,相等則將 1 (true) 推到棧上,否則就把 0 (false)推到棧上
cgt比較兩個值,第一個大於第二個則將 1 (true) 推到棧上,否則就把 0 (false)推到棧上
clt 比較兩個值,第一個小於第二個則將 1 (true) 推到棧上,否則就把 0 (false)推到棧上
以上就是三類常用的,把這些搞明白了,IL指令也就理解得七七八八了。就像看文章一樣,認識大部分字後基本就不影響閱讀了,不認識的猜下再查下,下次再看到也就認得了。
例子
下面看個例子,隨手寫段簡單的程式碼,是否合乎邏輯暫不考慮,主要是看IL:
原始碼:
1 using System; 2 3 namespace ILLearn 4 { 5 class Program 6 { 7 const int WEIGHT = 60; 8 9 static void Main(string[] args) 10 { 11 var height = 170; 12 13 People people = new Developer("brook"); 14 15 var vocation = people.GetVocation(); 16 17 var healthStatus = People.IsHealthyWeight(height, WEIGHT) ? "healthy" : "not healthy"; 18 19 Console.WriteLine($"{vocation} is {healthStatus}"); 20 21 Console.ReadLine(); 22 } 23 } 24 25 abstract class People 26 { 27 public string Name { get; set; } 28 29 public abstract string GetVocation(); 30 31 public static bool IsHealthyWeight(int height, int weight) 32 { 33 var healthyWeight = (height - 80) * 0.7; 34 return weight <= healthyWeight * 1.1 && weight >= healthyWeight * 0.9; //標準體重是 (身高-80) * 0.7,區間在10%內都是正常範圍 35 } 36 } 37 38 class Developer : People 39 { 40 public Developer(string name) 41 { 42 Name = name; 43 } 44 45 public override string GetVocation() 46 { 47 return "Developer"; 48 } 49 } 50 }
在命令列裡輸入:csc /debug- /optimize+ /out:program.exe Program.cs
開啟IL檢視工具:C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.6 Tools\ildasm.exe,不同版本可能目錄不太一樣。開啟剛編譯的program.exe檔案,如下:
雙擊節點就可以檢視IL,如:
Developer的建構函式:
1 .method public hidebysig specialname rtspecialname 2 instance void .ctor(string name) cil managed 3 { 4 // 程式碼大小 14 (0xe) 5 .maxstack 8 6 IL_0000: ldarg.0 //載入第1個引數,因為是例項,而例項的第1個引數始終是this 7 IL_0001: call instance void ILLearn.People::.ctor() //呼叫基類People的建構函式,而People也會呼叫Object的建構函式 8 IL_0006: ldarg.0 //載入this 9 IL_0007: ldarg.1 //載入第二個引數也就是name 10 IL_0008: call instance void ILLearn.People::set_Name(string) //呼叫this的 set_Name, set_Name這個函式是編譯時為屬性生成的 11 IL_000d: ret //return 12 } // end of method Developer::.ctor
Developer的GetVocation:
1 .method public hidebysig virtual instance string //虛擬函式 2 GetVocation() cil managed 3 { 4 // 程式碼大小 6 (0x6) 5 .maxstack 8 //最大計算棧,預設是8 6 IL_0000: ldstr "Developer" //載入string "Developer" 7 IL_0005: ret //return 8 } // end of method Developer::GetVocation
People的IsHealthyWeight:
1 .method public hidebysig static bool IsHealthyWeight(int32 height, //靜態函式 2 int32 weight) cil managed 3 { 4 // 程式碼大小 52 (0x34) 5 .maxstack 3 //最大計算棧大小 6 .locals init ([0] float64 healthyWeight) //區域性變數 7 IL_0000: ldarg.0 //載入第1個引數,因為是靜態函式,所以第1個引數就是height 8 IL_0001: ldc.i4.s 80 //ldc 載入數值, 載入80 9 IL_0003: sub //做減法,也就是 height-80,把結果放到計算棧上,前面兩個已經移除了 10 IL_0004: conv.r8 //轉換成double,因為下面計算用到了double,所以要先轉換 11 IL_0005: ldc.r8 0.69999999999999996 //載入double數值 0.7, 為什麼是0.69999999999999996呢, 二進位制存不了0.7,只能找個最相近的數 12 IL_000e: mul //計算棧上的兩個相乘,也就是(height - 80) * 0.7 13 IL_000f: stloc.0 //存到索引為0的區域性變數(healthyWeight) 14 IL_0010: ldarg.1 //載入第1個引數 weight 15 IL_0011: conv.r8 //轉換成double 16 IL_0012: ldloc.0 //載入索引為0的區域性變數(healthyWeight) 17 IL_0013: ldc.r8 1.1000000000000001 //載入double數值 1.1, 看IL_0010到IL_0013,載入了3次,這個函式最多也是載入3次,所以maxstack為3 18 IL_001c: mul //計算棧上的兩個相乘,也就是 healthyWeight * 1.1, 這時計算棧上還有兩個,第一個是weight,第二個就是這個計算結果 19 IL_001d: bgt.un.s IL_0032 //比較這兩個值,第一個大於第二個就跳轉到 IL_0032,因為第一個大於第二個表示第一個條件weight <= healthyWeight * 1.1就是false,也操作符是&&,後面沒必要再算,直接return 0 20 IL_001f: ldarg.1 //載入第1個引數 weight 21 IL_0020: conv.r8 //轉換成double 22 IL_0021: ldloc.0 //載入索引為0的區域性變數(healthyWeight) 23 IL_0022: ldc.r8 0.90000000000000002 //載入double數值 0.9 24 IL_002b: mul //計算棧上的兩個相乘,也就是 healthyWeight * 0.9, 這時計算棧上還有兩個,第一個是weight,第二個就是這個計算結果 25 IL_002c: clt.un //比較大小,第一個小於第二個則把1放上去,否則放0上去 26 IL_002e: ldc.i4.0 //載入數值0 27 IL_002f: ceq //比較大小,相等則把1放上去,否則放0上去 28 IL_0031: ret //return 棧頂的數,為什麼沒用blt.un.s,因為IL_0033返回的是false 29 IL_0032: ldc.i4.0 //載入數值0 30 IL_0033: ret //return 棧頂的數 31 } // end of method People::IsHealthyWeight
主函式Main:
1 .method private hidebysig static void Main(string[] args) cil managed 2 { 3 .entrypoint //這是入口 4 // 程式碼大小 67 (0x43) 5 .maxstack 3 //大小為3的計算棧 6 .locals init (string V_0, 7 string V_1) //兩個string型別的區域性變數,本來還有個people的區域性變數,被release方式優化掉了,因為只是呼叫了people的GetVocation,後面沒用,所以可以不存 8 IL_0000: ldc.i4 0xaa //載入int型170 9 IL_0005: ldstr "brook" //載入string "brook" 10 IL_000a: newobj instance void ILLearn.Developer::.ctor(string) //new一個Developer並把棧上的brook給建構函式 11 IL_000f: callvirt instance string ILLearn.People::GetVocation() //呼叫GetVocation 12 IL_0014: stloc.0 //把上面計算的結果存到第1個區域性變數中,也就是V_0 13 IL_0015: ldc.i4.s 60 //載入int型60 14 IL_0017: call bool ILLearn.People::IsHealthyWeight(int32, //呼叫IsHealthyWeight,因為是靜態函式,所以用call 15 int32) 16 IL_001c: brtrue.s IL_0025 //如果上面返回true的話就跳轉到IL_0025 17 IL_001e: ldstr "not healthy" //載入string "not healthy" 18 IL_0023: br.s IL_002a //跳轉到IL_002a 19 IL_0025: ldstr "healthy" //載入string "healthy" 20 IL_002a: stloc.1 //把結果存到第2個區域性變數中,也就是V_1, IL_0017到IL_002a這幾個指令加在一起用來計算三元表示式 21 IL_002b: ldstr "{0} is {1}" //載入string "{0} is {1}" 22 IL_0030: ldloc.0 //載入第1個區域性變數 23 IL_0031: ldloc.1 //載入第2個區域性變數 24 IL_0032: call string [mscorlib]System.String::Format(string, //呼叫string.Format,這裡也可以看到C# 6.0的語法糖 $"{vocation} is {healthStatus}",編譯後的結果和以前的用法一樣 25 object, 26 object) 27 IL_0037: call void [mscorlib]System.Console::WriteLine(string) //呼叫WriteLine 28 IL_003c: call string [mscorlib]System.Console::ReadLine() //呼叫ReadLine 29 IL_0041: pop 30 IL_0042: ret 31 } // end of method Program::Main
很簡單吧,當然,這個例子也很簡單,沒有事件,沒有委託,也沒有async/await之類,這些有興趣的可以寫程式碼跟一下,這幾種都會在編譯時插入也許你不知道的程式碼。
就這麼簡單學一下,應該差不多有底氣和麵試官吹吹牛逼了。
1.例項解析IL
作為C#程式設計師,IL的作用不言而喻,首先來看一個非常簡單的程式和它的IL解釋圖,通過這個程式的IL指令來簡單的瞭解常見的IL指令是什麼意思。
class Program { static void Main(string[] args) { int i = 2; string str= "C#"; Console.WriteLine("hello "+str); } }
class Program { static void Main(string[] args) { int i = 2; string str= "C#"; Console.WriteLine("hello "+str); } }
接下來要明確一個概念,.NET執行時任何有意義的操作都是在堆疊上完成的,而不是直接操作暫存器。這就為.NET跨平臺打下了基礎,通過設計不同的編譯器編譯相同的IL程式碼來實現跨平臺。對於堆疊我們的操作無非就是壓棧和出棧,在IL中壓棧通常以ld開頭,出棧則以st開頭。知道這個後再看上面的指令感覺一下子就豁然開朗了,接下來繼續學習的步伐,下面的表格是對於一些常見ld指令。st指令則是將ld指令換成st,功能有壓棧變為出棧,有時候會看到在st或ld後加.s這表示只取一個位元組。再來看看流程控制,知道壓出棧和流程控制後,基本上看出IL的大概意思那就冒悶踢啦。流程控制主要就是迴圈和分支,下面我寫了個有迴圈和分支的小程式。其中我們用到了加法和比較運算,為此得在這裡介紹最基本的三種運算:算術運算(add、sub、mul乘法、div、rem求餘);比較運算(cgt大於、clt小於、ceq等於);位運算(not、and、or、xor異或、左移shl、右移shr)。要注意在比較運算中,當執行完指令後會直接將結果1或0壓棧,這個過程是自動完成的。對於流程控制,主要是br、brture和brfalse這3條指令,其中br是直接進行跳轉,brture和brture則是進行判斷再進行跳轉。
ldarg | 載入成員的引數,如上面的ldarg.0 |
ldarga | 裝載引數的地址,注意一般加個a表示取地址 |
ldc | 將數字常量壓棧,如上面的ldc.i4.2 |
ldstr | 將字串的引用壓棧 |
ldloc/ldloca | ldloc將一個區域性變數壓棧,加a表示將這個區域性變數的地址壓棧 |
Ldelem | 表示將陣列元素壓棧 |
ldlen | 將陣列長度壓棧 |
ldind | 將地址壓棧,以地址來訪問或操作資料內 |
class Program { static void Main(string[] args) { int count = 2; string strName= "C#"; if (strName == "C#") { for(int i=0;i<count;i++) Console.WriteLine("hello C#"); } else Console.WriteLine("ha ha"); } }
class Program { static void Main(string[] args) { int count = 2; string strName= "C#"; if (strName == "C#") { for(int i=0;i<count;i++) Console.WriteLine("hello C#"); } else Console.WriteLine("ha ha"); } }
2.面向物件的IL
有了前面的基礎後,基本上看一般的IL程式碼不會那麼方了。如果我們在程式中宣告一個類並建立物件,則在IL中可以看到newobj、class、instance、static等關鍵字。看IL指令會發現外部是類,類裡面有方法,雖然方法裡面是指令不過這和C#程式碼的結構是很相似的。從上面的這些現象可以很明顯的感受到IL並不是簡單的指令,它是面向物件的。當我們在C#中使用new建立一個物件時則在IL中對應的是newobj,另外還有值型別也是可以通過new來建立的,不過在IL中它對應的則是initobj。newobj用來建立一個物件,首先會分配這個物件所需的記憶體,接著初始化物件附加成員同步索引塊和型別物件指標然後再執行建構函式進行初始化並返回物件引用。initobj則是完成棧上已經分配好的記憶體的初始化工作,將值型別置0引用型別置null即可。另外string是引用型別,從上面的例子可以看到一般是使用ldstr來將元資料中的字串引用載入到棧中而不是newobj。但是如果在程式碼中建立string變數不是直接賦值而是使用new關鍵字來得到string物件,那麼在IL中將會看到newobj指令。當建立一維零基陣列時還會看到newarr指令,它會建立陣列並將首地址壓棧。不過如果陣列不是一維零基陣列的話仍將還是會看到我們熟悉的newobj。
既然是面向物件的,那麼繼承中的虛方法或抽象方法在IL中肯定會有相應的指令去完成方法的呼叫。呼叫方法主要是call、callvirt、calli,call主要用來呼叫靜態方法,callvirt則用來呼叫普通方法和需要執行時繫結的方法(也就是用instance標記的例項方法),calli是通過函式指標來進行呼叫的。不過也存在特殊情況,那就是call去呼叫虛方法,比如在密封類中的虛方法因為一定不可能會被重寫因此使用call可提高效能。為什麼會提高效能呢?不知道你是否還記得建立一個物件去呼叫這個物件的方法時,我們經常會判斷這個物件是否為null,如果這個物件為null時去呼叫方法則會報錯。之所以出現這種情況是因為callvirt在呼叫方法時會進行型別檢測,此外判斷是否有子類方法覆蓋的情況從而動態繫結方法,而採用call則直接去呼叫了。另外當呼叫基類的虛方法時,比如呼叫object.ToString方法就是採用call方法,如果採用callvirt的話因為有可能要檢視子類(一直檢視到最後一個繼承父類的子類)是否有重寫方法,從而降低了效能。不過說到底call用來呼叫靜態方法,而callvirt呼叫與物件關聯的動態方法的核心思想是可以肯定的,那些採用call的特殊情況都是因為在這種情況下根本不需要動態繫結方法而是可以直接使用的。calli的意思就是拿到一個指向函式的引用,通過這個引用去呼叫函式,不過在我的學習中沒有使用到這個,這個具體是如何拿到引用的我也不清楚,感興趣者請自行百度。
3.IL的角色
大家都知道C#程式碼編譯後就會生成元資料和IL,可是我們常見的exe這樣的程式集是如何生成的呢,它與IL是什麼關係呢?首先有一點是可以肯定的,那就是程式集中肯定會包含元資料和IL,因為這2樣東西是程式集中的核心。下面是一個描述程式集和內部組成圖,從圖中可以看出一個程式集是有多個託管模組組成的,一個模組可以理解為一個類或者多個類一起編譯後生成的程式集。程式集清單指的是描述程式集的相關資訊,PE檔案頭描述PE檔案的檔案型別、建立時間等。CLR頭描述CLR版本、CPU資訊等,它告訴系統這是一個.NET程式集。然後最主要的就是每個託管模組中的元資料和IL了。元資料用來描述類、方法、引數、屬性等資料,.NET中每個模組包含44個元資料表,主要包括定義表、引用表、指標表和堆。定義表包括類定義表、方法表等,引用表描述引用到型別或方法之間的對映記錄,指標表裡存放著方法指標、引數指標等。可以看到元資料表就相當於一個數據庫,多張表之間有類似於主外來鍵之間的關係。
由前面的知識可以總結出IL是獨立於CPU且面向物件的指令集。.NET平臺將其之上的語言全都編譯成符合CLS(公共語言規範)的IL指令集,接著再由不同的編譯器翻譯成原生代碼,比如我們常見的JIT編譯器,如果在Mac上執行C#可通過Mac上的特定編譯器來將IL翻譯成Mac系統能夠執行的機器碼。也就是說IL正如它的名字一樣是作為一種中間語言來執行動態程式,比如我們呼叫一個方法表中的方法,這個方法會指向一個觸發JIT編譯器地址和方法對應的IL地址,於是JIT編譯器便將這個方法指向的IL編譯成原生代碼。生成原生代碼後這個方法將會有一條引用指向原生代碼首地址,這樣下次呼叫這個方法的時候將直接執行指向的原生代碼。
結束
IL其實不難,有沒有用則仁者見仁,智者見智,有興趣就學一下,也花不了多少時間,確實也沒必要學多深,是吧。
當然,也是要有耐心的,複雜的IL看起來還真是挺頭痛。好在有工具ILSpy,可以在option裡選擇部分不反編譯來看會比較簡單些。
最後介紹兩個工具:
.Net Reflector可以把使用者自己編寫的IL指令轉化為正常程式碼,大家可以自己下載安裝;
IL檢視工具可以把正常程式碼轉化為IL指令,vs2010中路徑為C:\Program Files (x86)\Microsoft SDKs\Windows\v7.0A\Bin\NETFX 4.0 Tools\ildasm.exe,不同版本目錄可能不太一樣。
有了這兩個工具當我們想用IL指令實現某一功能但不會寫時,可以先用正常程式碼把功能寫出來,在IL檢視工具中檢視IL程式碼是什麼樣的,然後自己再根據轉化的IL程式碼邏輯使用IL指令實現想要的功能。