【抬槓.NET】如何進行IL程式碼的開發

月光雙刀發表於2021-08-06

背景

在有些時候,由於C#的限制,或是追求更高的效能,我們需要編寫IL程式碼來達到我們的目的。

本文將介紹幾種IL程式碼開發的幾種方式,環境為visual studio 2019 + net5.0 sdk。

本文所用程式碼均在 https://github.com/huoshan12345/ILDevelopSamples 可以找到

 

方法1:建立IL專案

專案 System.Runtime.CompilerServices.Unsafe 就是由這種方式編寫。

目前,visual studio 2019和dotnet命令並不支援直接建立IL專案,但實際上二者是有對其的基本支援的,所以我們需要“曲線救國一下”。

1.首先我們使用visual studio 2019建立一個空解決方案名為ILDevelopSamples,然後建立一個netstandard2.0的library專案ILDevelopSamples.ILProject,然後關閉解決方案。

2.然後修改該專案的.csproj檔案的內容為:

<Project Sdk="Microsoft.NET.Sdk.IL">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <_HasReferenceToSystemRuntime>true</_HasReferenceToSystemRuntime>
  </PropertyGroup>

  <ItemGroup>
    <Content Include="**\*.il" />
  </ItemGroup>
</Project>

3.將該.csproj檔案的副檔名改為.ilproj

4.修改.sln檔案內容,將其中對於該專案的引用路徑修正,即將其副檔名由.csproj改為.ilproj

5.在解決方案根目錄新建一個檔名為global.json並填寫內容為 

{
  "sdk": {
    "version": "5.0",
    "rollForward": "latestMajor",
    "allowPrerelease": false
  },
  "msbuild-sdks": {
    "Microsoft.NET.Sdk.IL": "5.0.0"
  }
}

 重新開啟解決方案,然後此時vs就可以正常載入該專案了。

6.我們可以新增一個il檔案來編寫程式碼了,例如:

【抬槓.NET】如何進行IL程式碼的開發
.assembly extern mscorlib {}

.assembly ILDevelopSamples.ILProject
{
  .ver 1:0:0:0
}

.module ILDevelopSamples.ILProject.dll

.class public abstract auto ansi sealed beforefieldinit System.IntHelper
{
  .method public hidebysig static int32 Square(int32 a) cil managed aggressiveinlining
  {
    .maxstack 2
    ldarg.0
    dup
    mul
    ret
  }
}
View Code

 7.到此,該專案就可以正常編譯並被其他.net專案引用了。 

 

方法2:C#專案混合編譯IL

這種方式就是通過自定義msbuild的targets,來實現在某個已有的C#專案中新增並編譯.il檔案,即.cs和.il兩者的混合編譯。

目前有一款vs的外掛對此進行了支援:ILSupport 但這個外掛使用了windows平臺獨有的ildasm.exe和ilasm.exe,所以無法在非windows環境使用,也無法在rider或者使用dotnet命令進行編譯。

不過我們可以使用跨平臺版本的ildasm/ilasm,並借鑑它的思路,在此感謝這個外掛的作者。

1.我們建立一個netstandard2.0的library專案ILDevelopSamples.ILMixed

2.然後在該專案中建立一個新檔案il.targets, 並填寫以下內容

【抬槓.NET】如何進行IL程式碼的開發
<?xml version="1.0" encoding="utf-8"?>
<Project>

  <PropertyGroup>
    <_OSPlatform Condition="$([MSBuild]::IsOSPlatform('windows'))">win</_OSPlatform>
    <_OSPlatform Condition="$([MSBuild]::IsOSPlatform('linux'))">linux</_OSPlatform>
    <_OSPlatform Condition="$([MSBuild]::IsOSPlatform('osx'))">osx</_OSPlatform>
    <_OSPlatform Condition="$([MSBuild]::IsOSPlatform('freebsd'))">freebsd</_OSPlatform>
    <_OSArchitecture>$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)</_OSArchitecture>

    <MicrosoftNetCoreIlasmPackageRuntimeId Condition="'$(MicrosoftNetCoreIlasmPackageRuntimeId)' == ''">$(_OSPlatform)-$(_OSArchitecture.ToLower())</MicrosoftNetCoreIlasmPackageRuntimeId>
    <MicrosoftNETCoreILAsmVersion Condition="'$(MicrosoftNETCoreILAsmVersion)' == ''">5.0.0</MicrosoftNETCoreILAsmVersion>
    <MicrosoftNetCoreIlasmPackageName>runtime.$(MicrosoftNetCoreIlasmPackageRuntimeId).microsoft.netcore.ilasm</MicrosoftNetCoreIlasmPackageName>
    <MicrosoftNetCoreIldasmPackageName>runtime.$(MicrosoftNetCoreIlasmPackageRuntimeId).microsoft.netcore.ildasm</MicrosoftNetCoreIldasmPackageName>

    <!-- If ILAsmToolPath is specified, it will be used and no packages will be restored
         Otherwise packages will be restored and ilasm and ildasm will be referenced from their packages.  -->
    <_IlasmDir Condition="'$(ILAsmToolPath)' != ''">$([MSBuild]::NormalizeDirectory($(ILAsmToolPath)))</_IlasmDir>
    <_IldasmDir Condition="'$(ILAsmToolPath)' != ''">$([MSBuild]::NormalizeDirectory($(ILAsmToolPath)))</_IldasmDir>
    <CoreCompileDependsOn Condition="'$(ILAsmToolPath)' == ''">$(CoreCompileDependsOn);ResolveIlAsmToolPaths</CoreCompileDependsOn>
  </PropertyGroup>

  <ItemGroup Condition="'$(ILAsmToolPath)' == ''">
    <_IlasmPackageReference Include="$(MicrosoftNetCoreIlasmPackageName)" Version="$(MicrosoftNETCoreILAsmVersion)" />
    <_IlasmPackageReference Include="$(MicrosoftNetCoreIldasmPackageName)" Version="$(MicrosoftNETCoreILAsmVersion)" />
    <PackageReference Include="@(_IlasmPackageReference)" ExcludeAssets="native" PrivateAssets="all" IsImplicitlyDefined="true" />
  </ItemGroup>

  <ItemGroup>
    <IL Include="**\*.il" Exclude="**\obj\**\*.il;**\bin\**\*.il" />
  </ItemGroup>

  <Target Name="ProcessILAfterCompile" AfterTargets="Compile">
    <CallTarget Targets="ResolveIlAsmToolPaths; InitializeIL; CoreDecompile; CoreCompileIL" />
  </Target>

  <Target Name="ResolveIlAsmToolPaths">
    <ItemGroup>
      <_IlasmPackageReference NativePath="$(NuGetPackageRoot)\%(Identity)\%(Version)\runtimes\$(MicrosoftNetCoreIlasmPackageRuntimeId)\native" />
      <_IlasmSourceFiles Include="%(_IlasmPackageReference.NativePath)\**\*" />
    </ItemGroup>
    <Error Condition="!Exists('%(_IlasmPackageReference.NativePath)')" Text="Package %(_IlasmPackageReference.Identity)\%(_IlasmPackageReference.Version) was not restored" />

    <PropertyGroup>
      <_IlasmDir Condition="'$(_IlasmDir)' == '' and '%(_IlasmPackageReference.Identity)' == '$(MicrosoftNetCoreIlasmPackageName)'">%(_IlasmPackageReference.NativePath)/</_IlasmDir>
      <_IldasmDir Condition="'$(_IldasmDir)' == '' and '%(_IlasmPackageReference.Identity)' == '$(MicrosoftNetCoreIldasmPackageName)'">%(_IlasmPackageReference.NativePath)/</_IldasmDir>
    </PropertyGroup>
  </Target>

  <Target Name="InitializeIL">
    <PropertyGroup>
      <ILFile>@(IntermediateAssembly->'%(RootDir)%(Directory)%(Filename).il', ' ')</ILFile>
      <ILResourceFile>@(IntermediateAssembly->'%(RootDir)%(Directory)%(Filename).res', ' ')</ILResourceFile>
      <ILFileBackup>@(IntermediateAssembly->'%(RootDir)%(Directory)%(Filename).il.bak', ' ')</ILFileBackup>
      <AssemblyFile>@(IntermediateAssembly->'"%(FullPath)"', ' ')</AssemblyFile>
    </PropertyGroup>
  </Target>

  <Target Name="CoreDecompile"
          Inputs="@(IntermediateAssembly)"
          Outputs="$(ILFile)"
          Condition=" Exists ( @(IntermediateAssembly) ) ">
    <PropertyGroup>
      <ILDasm>$(_IldasmDir)ildasm $(AssemblyFile) /OUT="$(ILFile)"</ILDasm>
    </PropertyGroup>
    <!--<Message Text="$(ILDasm)" Importance="high"/>-->
    <Exec Command="$(ILDasm)" ConsoleToMSBuild="true" StandardOutputImportance="Low">
      <Output TaskParameter="ExitCode" PropertyName="_IldasmCommandExitCode" />
    </Exec>
    <Error Condition="'$(_IldasmCommandExitCode)' != '0'" Text="ILDasm failed" />
    <Copy SourceFiles="$(ILFile)" DestinationFiles="$(ILFileBackup)" />
    <ItemGroup>
      <!--MSBuild maintains an item list named FileWrites that contains the files that need to be cleaned.
      This list is persisted to a file inside the obj folder that is referred to as the "clean cache."
      You can place additional values into the FileWrites item list so that they are removed when the project is cleaned up.-->
      <FileWrites Include="$(ILFile)" />
      <FileWrites Include="$(ILResourceFile)" />
      <FileWrites Include="$(ILFileBackup)" />
    </ItemGroup>
    <PropertyGroup>
      <ILSource>$([System.IO.File]::ReadAllText($(ILFile)))</ILSource>
      <Replacement>// method ${method} forwardref removed for IL import</Replacement>
      <Pattern>\.method [^{}]+ cil managed forwardref[^}]+} // end of method (?&lt;method&gt;[^ \r\t\n]+)</Pattern>
      <ILSource>$([System.Text.RegularExpressions.Regex]::Replace($(ILSource), $(Pattern), $(Replacement)))</ILSource>
      <Pattern>\.method [^{}]+ cil managed[^\a]+"extern was not given a DllImport attribute"[^}]+} // end of method (?&lt;method&gt;[^ \r\t\n]+)</Pattern>
      <ILSource>$([System.Text.RegularExpressions.Regex]::Replace($(ILSource), $(Pattern), $(Replacement)))</ILSource>
    </PropertyGroup>
    <WriteLinesToFile File="$(ILFile)" Lines="$(ILSource)" Overwrite="true" />
    <PropertyGroup>
      <ILSource />
    </PropertyGroup>
    <Delete Files="@(IntermediateAssembly)" />
  </Target>

  <Target Name="CoreCompileIL"
          Inputs="$(MSBuildAllProjects);
                  @(Compile)"
          Outputs="@(IntermediateAssembly)"
          Returns=""
          DependsOnTargets="$(CoreCompileDependsOn)">

    <PropertyGroup>
      <_OutputTypeArgument Condition="'$(OutputType)' == 'Library'">-DLL</_OutputTypeArgument>
      <_OutputTypeArgument Condition="'$(OutputType)' == 'Exe'">-EXE</_OutputTypeArgument>

      <_KeyFileArgument Condition="'$(KeyOriginatorFile)' != ''">-KEY="$(KeyOriginatorFile)"</_KeyFileArgument>

      <_IlasmSwitches>-QUIET -NOLOGO</_IlasmSwitches>
      <_IlasmSwitches Condition="'$(FoldIdenticalMethods)' == 'True'">$(_IlasmSwitches) -FOLD</_IlasmSwitches>
      <_IlasmSwitches Condition="'$(SizeOfStackReserve)' != ''">$(_IlasmSwitches) -STACK=$(SizeOfStackReserve)</_IlasmSwitches>
      <_IlasmSwitches Condition="'$(DebugType)' == 'Full'">$(_IlasmSwitches) -DEBUG</_IlasmSwitches>
      <_IlasmSwitches Condition="'$(DebugType)' == 'Impl'">$(_IlasmSwitches) -DEBUG=IMPL</_IlasmSwitches>
      <_IlasmSwitches Condition="'$(DebugType)' == 'PdbOnly'">$(_IlasmSwitches) -DEBUG=OPT</_IlasmSwitches>
      <_IlasmSwitches Condition="'$(Optimize)' == 'True'">$(_IlasmSwitches) -OPTIMIZE</_IlasmSwitches>
      <!--<_IlasmSwitches Condition="'$(IlasmResourceFile)' != ''">$(_IlasmSwitches) -RESOURCES=$(IlasmResourceFile)</_IlasmSwitches>-->

      <ILAsm>$(_IlasmDir)ilasm $(_IlasmSwitches) $(_OutputTypeArgument) $(IlasmFlags) -OUTPUT=@(IntermediateAssembly->'"%(FullPath)"', ' ') @(IL->'"%(FullPath)"', ' ')</ILAsm>
    </PropertyGroup>

    <!--<Message Text="$(ILAsm)" Importance="high"/>-->
    <PropertyGroup Condition=" Exists ( '$(ILFile)' ) ">
      <ILAsm>$(ILAsm) "$(ILFile)"</ILAsm>
    </PropertyGroup>
    <Exec Command="$(ILAsm)">
      <Output TaskParameter="ExitCode" PropertyName="_ILAsmExitCode" />
    </Exec>

    <Error Condition="'$(_ILAsmExitCode)' != '0'" Text="ILAsm failed" />

    <ItemGroup>
      <FileWrites Include="@(IntermediateAssembly->'%(RootDir)%(Directory)DesignTimeResolveAssemblyReferencesInput.cache', ' ')" />
    </ItemGroup>
    <Touch Files="$(ILFile)" />
  </Target>

</Project>
View Code

 3.修改該專案的.csproj檔案為

<Project Sdk="Microsoft.NET.Sdk">
  <Import Project="il.targets" />
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>
</Project>

4.我們可以新增一個il檔案來編寫程式碼了,例如:

.assembly extern mscorlib {}

.class public abstract auto ansi sealed beforefieldinit System.ObjectHelper
{
    .method public hidebysig static int32 SizeOf<T>() cil managed aggressiveinlining
    {
        .maxstack 1
        sizeof !!0
        ret
    }
}

5.此時該專案裡面的方法即ObjectHelper.SizeOf<T>已經可以被其他專案所使用了。不過,如果我想在本專案中呼叫這個方法呢?

6.建立與該IL程式碼中的類System.ObjectHelper同名的C#類,即名稱空間為System,類名為ObjectHelper,並填寫內容為 

using System.Runtime.CompilerServices;

namespace System
{
    public class ObjectHelper
    {
        [MethodImpl(MethodImplOptions.ForwardRef)]
        public static extern int SizeOf<T>();
    }
}

可以注意到,此處有一個方法和IL程式碼中的方法簽名相同,但是沒有body,並且有一個特性即[MethodImpl(MethodImplOptions.ForwardRef)]

7.此時,在本專案中,也可以呼叫ObjectHelper.SizeOf<T>這個方法了。

以上這些功能得以實現的奧祕就來自於il.targets這個檔案。其思路如下:

  • C#專案並不會編譯il檔案,所以先令這個專案編譯為dll
  • 反編譯該dll為il檔案
  • 將標記為ForwardRef的方法註釋掉
  • 將反編譯的il檔案和專案中原有的il檔案一起編譯為dll 

 

方法3:使用InlineIL.Fody

InlineIL.Fody這個包能讓你在編寫C#程式碼時,能夠呼叫其提供的與IL指令對應的C#方法,在C#專案編譯時,這個庫又會將這些方法替換成真正的IL指令。

1.我們建立一個netstandard2.0的library專案ILDevelopSamples.Fody

2.修改該項FodyInlineIL.Fody這兩個nuget包。

3.我們建立一個新C#類,然後填寫以下內容

using InlineIL;
using static InlineIL.IL.Emit;

namespace System
{
    public class GenericHelper
    {
        public static bool AreSame<T>(ref T a, ref T b)
        {
            Ldarg(nameof(a));
            Ldarg(nameof(b));
            Ceq();
            return IL.Return<bool>();
        }
    }
}

4.可以看出GenericHelper.AreSame<T>這個方法內部就是呼叫了一些在InlineIL.IL.Emit名稱空間下的方法,它們都分別與一條IL指令對應。

 

方法4:使用ILGenerator動態構建IL

這種方法就是使用ILGenerator在執行時構建IL程式碼,例如使用DynamicMethod動態構建一個方法。

和上面三種方法的使用場景有所不同,它適合於那些需要在執行時才能確定的程式碼。

這個專案 AspectCore,在這方面有廣泛引用。這是一款卓越的Aop框架。

 

總結

本文介紹了三種進行IL程式碼的方法。它們各自有自己優缺點和應用場景,總結如下

方法 優點 缺點 應用場景
建立IL專案 原生IL 建立的時候較為複雜 較多程式碼需IL實現
C#專案混合編譯IL 原生IL,專案內C#可呼叫IL方法 專案特別大的時候編譯可能會慢 少量方法或類需IL實現 
使用InlineIL.Fody 純C#編寫體驗 相比原生IL有極其極其輕微的效能損耗 少量方法或類需IL實現
使用ILGenerator 執行時生成程式碼,靈活     效能有損耗,需快取一些物件 需執行時生成程式碼

 

有什麼問題歡迎一起探討~~~

 

相關文章