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

背景

在有些時候,由於C#的限制,或是追求更高的性能,我們可以編寫IL程式碼來達到我們的目的。本文將介紹幾種IL程式碼開發的幾種方式,環境為visual studio 2019 + net5.0 sdk。

本文所用程式碼均在 //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文件來編寫程式碼了,例如:

.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, 並填寫以下內容

<?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 運行時生成程式碼,靈活     性能有損耗,需快取一些對象 需運行時生成程式碼

 

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