淺談 C# Assembly 與 IL (一):C# Assembly 與 Reflection

Compasslg發表於2021-04-26

作者:Compasslg

前言

前一陣子想利用閒餘時間寫一個 Unity 遊戲的翻譯工具,主要是用於翻譯一些內嵌在程式碼中的文字,最初想偷懶看了一下網上的教學推薦說可以先利用DnSpy、ILSpy等工具反編譯,直接修改反編譯出來的程式碼中的字串然後再重新編譯,這樣就只需要寫一個提取和置換c#程式碼中所有文字的工具就行了。但在略微嘗試一下後發現這些反編譯工具並不能完美的生成可編譯的程式碼,於是只能暫時擱置了。
剛好近期工作中在編寫一些Debug工具,需要大量的利用 c# 的 Reflection 和 Mono.Cecil、ICSharpCode.Compiler等工具來讀取程式集中的CIL,以及利用IL注入動態的生成一些Debug程式碼。在一段時間的學習後這些工具彷彿開啟了新世界的大門,此前提到的翻譯工具也不再是問題了,就想在這裡分享一下成果,也算是對前段時間的所學做一些記錄。

介紹

c#的程式碼在編譯後,會先生成一個 Assembly 程式集(.exe, .dll),該程式集包含一些類似於彙編的中間語言(CIL)構成的託管程式碼,然後再通過類似於Java虛擬機器的虛擬執行環境 CLR (Common Language Runtime) 來進行JIT (just-in-time) 編譯成機器語言。而這篇文章以及之後整個專欄的目的就是介紹如何檢視與編輯 c# 所編譯出來的程式集中所包含的IL程式碼。

微軟官方提供了 System.Reflection (反射) 來用於讀取c#程式集中的內容,常常用於檢視程式集中的內容並動態的生成其中類的例項,呼叫裡面的方法。在不涉及太底層的分析時,反射能很好的達到目的並快速的應用。

使用反射(Reflection)讀取程式集

反射作為c#官方提供的工具,本身使用起來非常的簡單。你可以用它快速的讀取當前程式的程式集,或者讀取指定路徑的程式集。

  1. 獲取當前正在執行的程式集
Assembly currentAssem = Assembly.GetExecutingAssembly();
  1. 根據已有類讀取
Assembly assembly = Typename.GetType().Assembly;
  1. 從檔案中讀取
Assembly assembly = Assembly.Loadfile("File path");

要注意的是,Assembly 只能讀取指定版本的程式集,這個所謂的版本不單單是指的.Net Framework的版本,還有目標是x86架構還是x64。不同的架構可能會導致assembly讀取失敗。

生成類的動態例項,呼叫方法

當我們從 Assembly 中獲得 Runtime Type 以及其方法資訊以後,就可以利用Activator.CreateInstance() 來生成其動態的物件並呼叫其中方法了。

// 將該類生成的例項作為動態的物件, 直接呼叫方法
dynamic dynamicTypeInstance = Activator.CreateInstance(type);
// 可以在type後面插入object陣列作為 constructor 引數傳入


// 動態類可以直接呼叫方法並填入引數
dynamicTypeInstance.MethodName(1, 2);

// 將該類生成的例項作為 object, 通過 MethodInfo.Invoke 來呼叫方法
object typeInstance = Activator.CreateInstance(type);
method.Invoke(typeInstance, new object[] {1, 2, "string"});

可能有人(比如我)會覺得用 dynamic 來呼叫方法速度會比method.invoke(object) 慢,但經過一番測試後,結果卻令我感到驚訝。我使用的測試方法先編寫一個簡單的GetSum方法計算兩個引數的和並返回,然後用如下程式碼分別計算動態呼叫、通過Method.Invoke呼叫、以及直接呼叫該方法 10000 次的總時間消耗和最大單次時間消耗。

 Stopwatch watch = new Stopwatch();
 watch.Start();
 long initialTime = watch.ElapsedMilliseconds;
 dynamic dynamicTypeInstance = Activator.CreateInstance(typeof(Program));
 long maxTime = 0;
 long maxTimeId = 0;

 // 動態呼叫方法一萬次
 for (int i = 0; i < 10000; i++)
 {
     var startTime = watch.ElapsedTicks;
     // 動態類可以直接呼叫方法並填入引數
     dynamicTypeInstance.GetSum(i, i+1);
     var endTime = watch.ElapsedTicks;
     var timeDifference = endTime - startTime;
     if(timeDifference > maxTime)
     {
         maxTime = timeDifference;
         maxTimeId = i;
     }
 }
 Console.WriteLine("Dynamic");
 Console.WriteLine("Max Time Id: " + maxTimeId);
 Console.WriteLine("Max Time: " + maxTime);
 Console.WriteLine("Total Time: " + (watch.ElapsedTicks - initialTime));
 watch.Stop();


 watch.Start();
 initialTime = watch.ElapsedMilliseconds;
 object typeInstance = Activator.CreateInstance(typeof(Program));
 MethodInfo method = typeof(Program).GetMethod("GetSum");
 maxTime = 0;
 maxTimeId = 0;
 
 // 通過 Method.Invoke 呼叫一萬次
 for (int i = 0; i < 10000; i++)
 {
     var startTime = watch.ElapsedTicks;
     method.Invoke(typeInstance, new object[] { i, i + 1 });
     var endTime = watch.ElapsedTicks;
     var timeDifference = endTime - startTime;
     if (timeDifference > maxTime)
     {
         maxTime = timeDifference;
         maxTimeId = i;
     }
 }
 Console.WriteLine("Method.Invoke");
 Console.WriteLine("Max Time Id: " + maxTimeId);
 Console.WriteLine("Max Time: " + maxTime);
 Console.WriteLine("Total Time: " + (watch.ElapsedTicks - initialTime));
 watch.Stop();

 watch.Start();
 Program program = new Program();
 maxTime = 0;
 maxTimeId = 0;

 // 直接呼叫一萬次
 for (int i = 0; i < 10000; i++)
 {
     var startTime = watch.ElapsedTicks;
     program.GetSum(i, i + 1);
     var endTime = watch.ElapsedTicks;
     var timeDifference = endTime - startTime;
     if (timeDifference > maxTime)
     {
         maxTime = timeDifference;
         maxTimeId = i;
     }
 }
 Console.WriteLine("Direct Call");
 Console.WriteLine("Max Time Id: " + maxTimeId);
 Console.WriteLine("Max Time: " + maxTime);
 Console.WriteLine("Total Time: " + (watch.ElapsedTicks - initialTime));
 watch.Stop();

輸出如下

Dynamic
Max Time Id: 0
Max Time: 2870643
Total Time: 2926522

Method.Invoke
Max Time Id: 7232
Max Time: 342
Total Time: 3009383

Direct Call
Max Time Id: 1342
Max Time: 23
Total Time: 3040620

由此可見,動態方法在第一次呼叫時效率極低,但之後的重複呼叫對比另外兩種方案卻沒有太明顯的差別,甚至有著一定的優勢。

使用反射來向Assembly中獲取或注入IL程式碼

Reflection本身也有提供檢視和注入IL程式碼的方法,但其在這兩方面都有很大的限制。

檢視IL程式碼

Reflection 可以通過

MethodInfo.GetMethodBody().GetILAsByteArray();

來獲取方法中的IL程式碼。但正如你所見,IL是以Byte陣列的形式被讀出來,你往往需要自己對讀出的ByteArray進行額外的包裝和翻譯處理才能做有意義的應用,而這需要對 CIL 這門中間語言有相當程度的理解才能做到。

示範

下面我會舉一個例子示範。
首先假設我們在Program類中,除了main以外還有一個方法

// 這是一個用於計算1 + 2 + ... + x 的方法,作為示範
public int SumOf1ToNum(int num)
{
    if(num < 1)
    {
        Console.WriteLine("Sum = 0;");
        return 0;
    }
    int sum = 0;
    for(int i = 1; i < num; i++)
    {
        sum += i;
        Console.Write(i + " + ");
    }
    sum += num;
    Console.WriteLine(num + " = " + sum);
    return sum;
}

然後在main函式中,利用Reflection.Asssembly直接讀取當前Program類所在的程式集(該示範是直接獲取當前程式的程式集作為演示,大多數情況我們會用上面提到的方法獲取其他程式集,這裡就偷個懶了)

// 下面這兩步其實是畫蛇添足的,可以直接通過 typeof(Program).GetMethod() 來獲取對應的MethodInfo,
// 而這裡是在演示從 Assembly 中獲取方法,為了偷懶沒有建立額外的程式集,也順便演示一下可以這麼做
Assembly assembly = typeof(Program).Assembly;
// 這裡注意類名需要輸入全名,也就是要包含 namespace
MethodInfo sumOf1ToNumMethod = assembly.GetType("AssemblyExample.Program").GetMethod("SumOf1ToNum");

byte[] ilByteArr = sumOf1ToNumMethod.GetMethodBody().GetILAsByteArray();

// BitConverter 也是C#中非常實用的工具,常用於各個基本型別與二進位制資料間的轉換
Console.WriteLine(BitConverter.ToString(ilByteArr));

用DnSpy或ILSpy等工具檢視,你將看到

上面這段程式碼的Output為:
00-03-17-FE-04-0B-07-2C-10-00-72-49-00-00-70-28-16-00-00-0A-00-16-0C-2B-54-16-0A-17-0D-2B-20-00-06-09-58-0A-09-8C-19-00-00-01-72-5B-00-00-70-28-18-00-00-0A-28-19-00-00-0A-00-00-09-17-58-0D-09-03-FE-04-13-04-11-04-2D-D6-06-03-58-0A-03-8C-19-00-00-01-72-63-00-00-70-06-8C-19-00-00-01-28-1A-00-00-0A-28-16-00-00-0A-00-06-0C-2B-00-08-2A

嗯。。。你或許已經發現,這串程式碼根本不是給人讀的,但 Reflection 似乎只打算給你看這個(至少我只找到這個,歡迎指正),Thanks, Microsoft!

動態生成 IL 程式碼

Reflection 在 Reflection.Emit 的名稱空間下提供了各種 Builder 類用於動態的輸出 IL 程式碼,這些只能動態的生成新的 Runtime Type 以及新的 Assembly,而並不能直接修改已經存在的 Assembly,如此一來能應用到的方面就非常侷限了(不過好歹比 GetILAsByteArray() 實用一點)。

利用反射動態生成程式碼最常用的兩種方式為下:

  1. 生成動態方法,並利用託管或介面呼叫
DynamicMethod dynamicSum = new DynamicMethod("GetSum",typeof(int), new Type[] {typeof(int), typeof(int)}, typeof(Program).Module);
ILGenerator generator = dynamicSum.GetILGenerator();
generator.Emit(OpCodes.Ldarg_0);
generator.Emit(OpCodes.Ldarg_1);
generator.Emit(OpCodes.Add);
generator.Emit(OpCodes.Ret);
var getSum = (GetSumDelegate)dynamicSum.CreateDelegate(typeof(GetSumDelegate));
Console.WriteLine(getSum(1, 3));
  1. 生成 Runtime 程式集,然後動態呼叫方法
AssemblyBuilder assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName("RuntimeAssembly"), AssemblyBuilderAccess.Run);
ModuleBuilder moduleBuilder = assemblyBuilder.DefineDynamicModule("MainModule");
TypeBuilder typeBuilder = moduleBuilder.DefineType("MyRuntimeType", TypeAttributes.Public);
MethodBuilder methodBuilder = typeBuilder.DefineMethod("RuntimeSum", MethodAttributes.Public | MethodAttributes.Static);
methodBuilder.SetParameters(new Type[] {typeof(int), typeof(int)});
methodBuilder.SetReturnType(typeof(int));
ILGenerator generator = methodBuilder.GetILGenerator();
generator.Emit(OpCodes.Ldarg_0);
generator.Emit(OpCodes.Ldarg_1);
generator.Emit(OpCodes.Add);
generator.Emit(OpCodes.Ret);

Type dynamicType = typeBuilder.CreateType();
var dynamicMethod = dynamicType.GetMethod("RuntimeSum", BindingFlags.Public | BindingFlags.Static);
assemblyBuilder.SetEntryPoint(dynamicMethod);
Console.WriteLine("Sum of 1 + 2 = " + assemblyBuilder.EntryPoint.Invoke(null, new object[] { 1, 2 }));
Console.WriteLine("Sum of 1 + 2 = " + dynamicMethod.Invoke(null, new object[] { 1, 2}));

如果需要將程式集儲存在本地,你需要在定義 Assembly 的時候使用

AssemblyBuilder assemblyBuilder = AppDomain.CurrentDomain.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.RunAndSave);
ModuleBuilder module = assembly.DefineDynamicModule("MainModule", "NewAssembly.dll");

然後在結尾加上

assemblyBuilder.Save("NewAssembly.exe"); // 生成exe或者是dll擴充名的c#程式集

動態生成的方法在第一次執行時也會有一定的速度上的overhead,這是因為執行前還沒有經過JIT compiler的編譯。之後的重複執行便不會有任何速度劣勢了。

總結

總體來說,Reflection 非常適合從整體結構上分析 Assembly 中的類,檢視其中的 Field, Property 及其屬性 Attributes,同時也能夠讓開發者很方便的動態的使用其中的類和方法,但它在提供方法中的IL程式碼的方式上真的一言難盡。其IL的獲得與注入方式註定了對方法中內容難以進行具體或複雜的分析和生成,也無法做到動態的將 IL 程式碼注入到已有的程式集程式碼中,這也是我們為什麼會需要 Mono.Cecil 等第三方工具的主要原因,這個我會在下一篇中介紹。

參考

Assembly:

https://docs.microsoft.com/en-us/dotnet/standard/assembly/
https://docs.microsoft.com/en-us/dotnet/api/system.reflection.assembly?view=net-5.0

Reflection:

https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/reflection
https://docs.microsoft.com/en-us/dotnet/api/system.reflection.methodbody.getilasbytearray?view=net-5.0
https://docs.microsoft.com/en-us/dotnet/api/system.reflection.emit?view=net-5.0
https://www.codeproject.com/articles/121568/dynamic-type-using-reflection-emit
https://docs.microsoft.com/en-us/dotnet/framework/reflection-and-codedom/how-to-define-and-execute-dynamic-methods
https://flylib.com/books/en/4.453.1.58/1/
https://www.codeproject.com/Questions/494701/Can-27tplusfigureplusoutpluswhyplusthisplusDynamic

相關文章