Nos últimos meses, resolvi estudar como funciona o CLR do .NET. Um dos meus principais objetivos, era poder fazer modificações no corpo do método em tempo de execução sem ter que mudar a fonte original (de forma bruta, sem modificar o .cs). Tentei fazer isto com o Detour, mas da forma que eu queria não era possível fazer com tipos genéricos, já que tipos/classes/métodos genéricos, são compilados sobre demanda… Não teve jeito, tive que estudar o JIT.
Grande parte dos conteúdos que encontrei nesta área, são voltados para o .NET Framework antes e logo depois do SP2 (caso não saiba, a atualização do Service Pack 2 do .NET Framework, mudou a estrutura interna drasticamente). Na época em que foram escritos, a maioria são informações obtidas através de engenharia reversa ou estudos em cima do ROTOR (um “mini” .NET Framework código aberto publicado pela Microsoft em 2006). Resumindo: grande parte estão desatualizados, mas continuam bastante úteis.
Gostaria de agradecer o membro xoofx. O artigo dele foi a base para este projeto e pode ser encontrado aqui: Writing a Managed JIT in C# with CoreCLR. A parte principal do projeto, foi em base deste post dele.
O resultado final deste artigo, permitirá:
- Modificar o corpo IL do método (pré-compilação)
- Modificar o código nativo do método (pós-compilação)
- Monitorar todas as compilações do JIT no seu código
- Entre outras coisas…
Github
O projeto pode ser encontrado aqui: Jitex
Como será feito
No Runtime (antigo CoreCLR) do .NET, o JIT foi inteiramente escrito em C/C++, o que nos permite uma comunicação mais “natural” entre .NET e código nativo através do P/Invoke. Quando uma aplicação .NET é executada, o módulo clrjit.dll (ou em algumas versões mscorjit.dll) é carregado, contendo todas as informações sobre o JIT. Métodos no .NET, são compilados sobre demanda, ou seja, somente quando são executados (exceto em casos com PrepareMethod). Quando executados, o JIT faz a compilação do MSIL para código nativo.
O que iremos fazer, é modificar a chamada do método de compilação do JIT.
É possível obter o JIT através do próprio clrjit.dll. Dentro deste módulo, há um método exposto chamado getJit que nos retorna uma instância do ICorJitCompiler:
extern "C" ICorJitCompiler* __stdcall getJit();
Esta interface, contém o método compileMethod, que é o responsável de fazer a compilação do MSIL para código nativo:
public:
// compileMethod is the main routine to ask the JIT Compiler to create native code for a method. The
// method to be compiled is passed in the 'info' parameter, and the code:ICorJitInfo is used to allow the
// JIT to resolve tokens, and make any other callbacks needed to create the code. nativeEntry, and
// nativeSizeOfCode are just for convenience because the JIT asks the EE for the memory to emit code into
// (see code:ICorJitInfo.allocMem), so really the EE already knows where the method starts and how big
// it is (in fact, it could be in more than one chunk).
//
// * In the 32 bit jit this is implemented by code:CILJit.compileMethod
// * For the 64 bit jit this is implemented by code:PreJit.compileMethod
//
// Note: Obfuscators that are hacking the JIT depend on this method having __stdcall calling convention
virtual CorJitResult __stdcall compileMethod (
ICorJitInfo *comp, /* IN */
struct CORINFO_METHOD_INFO *info, /* IN */
unsigned /* code:CorJitFlag */ flags, /* IN */
BYTE **nativeEntry, /* OUT */
ULONG *nativeSizeOfCode /* OUT */
) = 0;
Este método é o nosso alvo.
Obtendo o JIT
Foi criada uma classe ManagedJit para fazer todo o controle do “nosso JIT”. Esta classe vai ser Singleton (afinal, não pode ter mais que um JIT em execução).
Para começar, temos que obter o endereço do ICorJitCompiler. Para isto, basta importamos a dll clrjit.dll apontando como entry o getJit:
[DllImport("clrjit.dll", CallingConvention = System.Runtime.InteropServices.CallingConvention.StdCall, SetLastError = true, EntryPoint = "getJit", BestFitMapping = true)]
private static extern IntPtr GetJit();
static ManagedJit() {}
Agora que nós obtemos o endereço do ICorJitCompiler, podemos ler a sua VTable, que é pequena e possui poucos métodos. Além desta, futuramente teremos que ler a VTable do CEEInfo. Para obtermos o endereço de um método, temos que saber o seu index na VTable. Este projeto está feito .NET Core 3.1 e os index estão para esta versão. Caso esteja utilizando uma versão do .NET Core diferente da 3.0 | 3.1, pode-se obter o index da seguinte forma:
- Acesse o ThunkInput.txt da sua versão (veja pelo histórico do git)
- Obtenha a lista de todos os métodos (FUNCTIONS)
- O index do método na VTable é a linha em que o método se encontra
Abaixo, os indexes de todos os métodos que vamos utilizar e todos os delegates para o P/Invoke:
internal static class VTable {
public const int CompileMethod = 0;
public const int GetModuleAssembly = 48;
public const int GetAssemblyName = 49;
public const int GetMethodDefFromMethod = 116;
}
internal static class Delegates {
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
public delegate IntPtr GetJitDelegate();
/// <summary>
/// Wrap delegate to compileMethod from ICorJitCompiler.
/// <see cref="https://github.com/dotnet/runtime/blob/f8eabc47a04a25e3cfa4afc78161e0d47209eb57/src/coreclr/src/inc/corjit.h#L238"/>
/// </summary>
/// <param name="thisPtr">this parameter.</param>
/// <param name="comp">(IN) - Pointer to ICorJitInfo.</param>
/// <param name="info">(IN) - Pointer to CORINFO_METHOD_INFO.</param>
/// <param name="flags">(IN) - Pointer to CorJitFlag.</param>
/// <param name="nativeEntry">(OUT) - Pointer to NativeEntry.</param>
/// <param name="nativeSizeOfCode">(OUT) - Size of NativeEntry.</param>
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
public delegate int CompileMethodDelegate(IntPtr thisPtr, IntPtr comp, ref CORINFO_METHOD_INFO info, uint flags, out IntPtr nativeEntry, out int nativeSizeOfCode);
/// <summary>
/// Wrap delegate to getMethodDefFromMethodDelegate from ICorJitInfo.
/// <see cref="https://github.com/dotnet/runtime/blob/f8eabc47a04a25e3cfa4afc78161e0d47209eb57/src/coreclr/src/inc/corinfo.h#L2910"/>
/// </summary>
/// <param name="thisPtr">this parameter.</param>
/// <param name="hMethodHandle">(IN) - Pointer to method handle.</param>
/// <return>Method token.</return>
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate int GetMethodDefFromMethodDelegate(IntPtr thisPtr, IntPtr hMethodHandle);
/// <summary>
/// Wrap delegate to getModuleAssembly from ICorJitInfo.
/// <see cref="https://github.com/dotnet/runtime/blob/f8eabc47a04a25e3cfa4afc78161e0d47209eb57/src/coreclr/src/inc/corinfo.h#L2391"/>
/// </summary>
/// <param name="thisPtr">this parameter.</param>
/// <param name="moduleHandle">(IN) - Pointer to module handle.</param>
/// <returns>Handle from assembly.</returns>
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate IntPtr GetModuleAssemblyDelegate(IntPtr thisPtr, IntPtr moduleHandle);
/// <summary>
/// Wrap delegate to getNameAssembly from ICorJitInfo.
/// <see cref="https://github.com/dotnet/runtime/blob/f8eabc47a04a25e3cfa4afc78161e0d47209eb57/src/coreclr/src/inc/corinfo.h#L2396"/>
/// </summary>
/// <param name="thisPtr">this parameter.</param>
/// <param name="assemblyHandle">(IN) - Pointer to assembly handle.</param>
/// <returns>Handle from assembly.</returns>
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate IntPtr GetAssemblyName(IntPtr thisPtr, IntPtr assemblyHandle);
}
Devemos obter o endereço do compileMethod e um delegate para ele:
public class ManagedJit {
private static readonly IntPtr JitVTable;
private static readonly CompileMethodDelegate OriginalCompileMethod;
private static readonly IntPtr OriginalCompiteMethodPtr;
static ManagedJit() {
//Obtém o endereço do JIT
IntPtr jit = getJit();
//Obtém a VTable
JitVTable = Marshal.ReadIntPtr(jit);
OriginalCompiteMethodPtr = Marshal.ReadIntPtr(JitVTable, IntPtr.Size * VTable.CompileMethod);
OriginalCompileMethod = Marshal.GetDelegateForFunctionPointer < CompileMethodDelegate > (OriginalCompiteMethodPtr);
}
}
Com o compileMethod em mãos, podemos criar o nosso, mas antes, precisamos replicar as estrutras e enums que o original usa. Elas podem ser localizadas aqui:
Vamos fazer uma implementação básica do nosso método. Primeiro, criamos uma classe para gerenciar a compilação:
internal class CompileTls
{
public int EnterCount;
}
Seguidamente, a implementação do nosso compileMethod:
//...
[ThreadStatic]
private static CompileTls _compileTls;
private static bool _hookInstalled;
//...
/// <summary>
/// Wrap delegate to compileMethod from ICorJitCompiler.
/// </summary>
/// <param name="thisPtr">this parameter.</param>
/// <param name="comp">(IN) - Pointer to ICorJitInfo.</param>
/// <param name="info">(IN) - Pointer to CORINFO_METHOD_INFO.</param>
/// <param name="flags">(IN) - Pointer to CorJitFlag.</param>
/// <param name="nativeEntry">(OUT) - Pointer to NativeEntry.</param>
/// <param name="nativeSizeOfCode">(OUT) - Size of NativeEntry.</param>
private int CompileMethod(IntPtr thisPtr, IntPtr comp, ref CORINFO_METHOD_INFO info, uint flags, out IntPtr nativeEntry, out int nativeSizeOfCode)
{
CompileTls compileEntry = _compileTls ??= new CompileTls();
compileEntry.EnterCount++;
try
{
if (!_hookInstalled)
{
nativeEntry = IntPtr.Zero;
nativeSizeOfCode = 0;
return 0;
}
return OriginalCompileMethod(thisPtr, comp, ref info, flags, out nativeEntry, out nativeSizeOfCode);
}
finally
{
compileEntry.EnterCount--;
}
}
Observe que ainda chamamos o compileMethodriginal, porque é ele quem conhece toda a implementação necessária de MSIL para código nativo. O nosso objetivo é manipular o que chega até nele, e não mudar ele em si.
Com o método implementado, podemos prepará-lo para escreve-lo na VTable.
Atualmente temos o seguinte cenário: o JIT está todo em C++ e tem que chamar o nosso método que está em C#, ou seja, uma chamada de código não-gerenciado para código gerenciado (Um P/Invoke reverso). Isto pode ser feito através de delegates, em que passamos o ponteiro do delegate para o C++ e quando ele for chamado, o JIT se encarrega de fazer a transação.
//...
private CompileMethodDelegate _customCompileMethod;
private IntPtr _customCompiledMethodPtr;
//...
private ManagedJit()
{
if (OriginalCompileMethod == null) return;
_customCompileMethod = CompileMethod;
_customCompiledMethodPtr = Marshal.GetFunctionPointerForDelegate(_customCompileMethod);
}
Se escrevermos o ponteiro do delegate na VTable da forma que está à cima, isto causará StackOverflow, pois entrará em loop infinito. Lembra que um método/delegate é compilado pelo JIT somente quando é chamado? Neste caso, o nosso compileMethod e o nosso delegate nunca foram chamados, logo nunca foram compilados. Quando o JIT for tentar executar o delegate, ele vai recorrer ao método compileMethod para compila-lo, mas o compileMethod é o nosso delegate, o que causará uma nova chamada para compilação, causando um loop infinito. Para poder fazer uma a compilação do delegate, temos que simular uma chamada do código nativo para o nosso delegate, forçando o JIT a compilar o delegate e o método. Isto pode ser feito com trampoline:
/// <summary>
/// Cria um trampoline 64 bits.
/// </summary>
/// <param name="address"></param>
/// <returns></returns>
public static IntPtr AllocateTrampoline(IntPtr address)
{
IntPtr jmpNative = VirtualAlloc(IntPtr.Zero, TrampolineInstruction.Length, AllocationType.Commit, MemoryProtection.ExecuteReadWrite);
Marshal.Copy(TrampolineInstruction, 0, jmpNative, TrampolineInstruction.Length);
Marshal.WriteIntPtr(jmpNative, 2, address);
return jmpNative;
}
/// <summary>
/// Libera o trampoline da memória.
/// </summary>
/// <param name="address"></param>
public static void FreeTrampoline(IntPtr address)
{
VirtualFree(address, new IntPtr(TrampolineInstruction.Length), FreeType.Release);
}
private ManagedJit()
{
if (OriginalCompileMethod == null) return;
_customCompileMethod = CompileMethod;
_customCompiledMethodPtr = Marshal.GetFunctionPointerForDelegate(_customCompileMethod);
//Impedir loop infinito
IntPtr trampolinePtr = AllocateTrampoline(_customCompiledMethodPtr);
CompileMethodDelegate trampoline = Marshal.GetDelegateForFunctionPointer<CompileMethodDelegate>(trampolinePtr);
CORINFO_METHOD_INFO emptyInfo = default;
trampoline(IntPtr.Zero, IntPtr.Zero, ref emptyInfo, 0, out _, out _);
FreeTrampoline(trampolinePtr);
InstallCompileMethod(_customCompiledMethodPtr);
_hookInstalled = true;
}
private static void InstallCompileMethod(IntPtr compileMethodPtr)
{
VirtualProtect(JitVTable + VTable.CompileMethod, new IntPtr(IntPtr.Size), MemoryProtection.ReadWrite, out var oldFlags);
Marshal.WriteIntPtr(JitVTable, VTable.CompileMethod, compileMethodPtr);
VirtualProtect(JitVTable + VTable.CompileMethod, new IntPtr(IntPtr.Size), oldFlags, out _);
}
Há formas de compilar tanto o delegate quanto um método através do PrepareMethod e do PrepareDelegate. Eu tentei fazer com estas duas, mas acabei levando StackOverflow em todas as minhas tentativas.
Dessa maneira, já é o suficiente para que o JIT chame o nosso compileMethod.
Basta terminarmos a implementação do Singleton e testarmos:
private static ManagedJit _instance;
public static ManagedJit GetInstance () {
lock (JitLock) {
return _instance ?? = new ManagedJit ();
}
}
class Program {
static void Main () {
ManagedJit managedJit = ManagedJit.GetInstance ();
int resultado = Somar (1, 1);
Console.WriteLine (resultado);
Console.ReadKey ();
}
public static int Somar (int num1, int num2) {
return num1 + num2;
}
}
O esperado é que não aconteça nada fora do comum e que o resultado seja 2. Isso é somente para testar se o hook foi feito corretamente. Após isto, podemos continuar com a implementação do compileMethod. De início, vamos tentar mudar somente o IL.
Modificando o IL
Para implementar o nosso compileMethod, é bom conhecer os parâmetros que são fornecidos:
- comp: É um ponteiro para a implementação ICorJitInfo. Ela será útil para nós, para obtermos o token do método e o assembly em que ele está.
- info: É a estrutura básica do método. Ela contém endereço do IL, tamanho do IL, assinatura do método, …
- flags: São flags que o JIT usará para compilar o método
- nativeEntry: Endereço do método já compilado
- nativeSizeOfCode: Tamanho do código nativo
Vamos fazer com que seja disparado um delegate toda vez que o método está prestes a ser compilado. Este delegate vai nos informar se o método deve ou não ser modificado e caso sim, qual será o novo IL dele.
public class ReplaceInfo {
public enum ReplaceMode {
IL,
ASM
}
public ReplaceInfo (ReplaceMode mode, byte[] body) {
Mode = mode;
Body = body;
}
public ReplaceMode Mode { get; }
public byte[] Body { get; }
}
public class ManagedJit{
//...
private static IntPtr _corJitInfoPtr = IntPtr.Zero;
public delegate ReplaceInfo PreCompile(MethodBase method);
public PreCompile OnPreCompile { get; set; }
//...
}
Vamos criar também um método para nos auxiliar nas chamadas dos métodos do ICorJitInfo:
private static TOut ExecuteCEEInfo<TDelegate, TOut, TValue> (TValue value, int ofset) {
IntPtr delegatePtr = Marshal.ReadIntPtr (_corJitInfoPtr, IntPtr.Size * offset);
Delegate delegateMethod = Marshal.GetDelegateForFunctionPointer (delegatePtr, typeof (TDelegate));
return (TOut) delegateMethod.DynamicInvoke (_corJitInfoPtr, value);
}
Sobrescrever o IL não requer grande trabalho, basta apenas informar o endereço de onde se encontra as instruções e o tamanho. O maior problema, é obter o MethodBase do método que será compilado.
A forma que utilizei para se obter informações do método, foi a mesma utilizada pelo xoofx:
- Obter o handle do assembly
- Obter o assembly pelo nome
- Obter o MetadataToken do método
- Procurar nos módulos do assembly, o MetadataToken do método.
É um trabalho pesado, mas não encontrei nenhuma outra forma. Para evitar que isto que seja feito toda hora, efetuamos o cache do handle em um dicionário.
if (compileEntry.EnterCount == 1 && OnPreCompile != null) {
if (_corJitInfoPtr == IntPtr.Zero) {
//Obtém a VTable do ICorJitInfo
_corJitInfoPtr = Marshal.ReadIntPtr (comp);
}
lock (JitLock) {
IntPtr assemblyHandle = ExecuteCEEInfo<GetModuleAssemblyDelegate, IntPtr, IntPtr> (info.scope, VTable.GetModuleAssembly);
if (!MapHandleToAssembly.TryGetValue (assemblyHandle, out assemblyFound)) {
IntPtr assemblyNamePtr = ExecuteCEEInfo<GetAssemblyName, IntPtr, IntPtr> (assemblyHandle, VTable.GetAssemblyName);
string assemblyName = Marshal.PtrToStringAnsi (assemblyNamePtr);
assemblyFound = AppDomain.CurrentDomain.GetAssemblies ().FirstOrDefault (assembly => assembly.GetName ().Name == assemblyName);
MapHandleToAssembly.TryAdd (assemblyHandle, assemblyFound);
}
if (assemblyFound != null) {
int methodToken = ExecuteCEEInfo<GetMethodDefFromMethodDelegate, int, IntPtr> (info.ftn, VTable.GetMethodDefFromMethod);
foreach (Module module in assemblyFound.Modules) {
try {
MethodBase methodFound = module.ResolveMethod (methodToken);
replaceInfo = OnPreCompile (methodFound);
} catch {
// ignored
}
}
}
}
}
//Verifica se devemos modificar o método ou não
if (replaceInfo != null) {
int ilLength = 0;
IntPtr ilAddress = IntPtr.Zero;
if (replaceInfo.Mode == ReplaceInfo.ReplaceMode.IL) {
ilLength = replaceInfo.Body.Length;
unsafe {
fixed (void * ptr = replaceInfo.Body) {
ilAddress = new IntPtr (ptr);
}
}
}
info.ILCode = ilAddress;
info.ILCodeSize = ilLength;
}
return OriginalCompileMethod (thisPtr, comp, ref info, flags, out nativeEntry, out nativeSizeOfCode);
Um detalhe interessante é o info.ftn, que é um ponteiro para o MethodDesc do método. Caso queira saber mais sobre o método, deve buscar nele.
Agora basta informamos o delegate ao chamar o ManagedJit:
class Program {
static void Main () {
ManagedJit managedJit = ManagedJit.GetInstance ();
managedJit.OnPreCompile = OnPreCompile;
int resultado = Somar (1, 1);
Console.WriteLine (resultado);
Console.ReadKey ();
}
private static ReplaceInfo OnPreCompile (MethodBase method) {
MethodInfo somarInfo = typeof (Program).GetMethod ("Somar");
if (somarInfo.MetadataToken == method.MetadataToken) {
MethodInfo somarAleatorioInfo = typeof (Program).GetMethod ("Calculo");
byte[] newIL = somarAleatorioInfo.GetMethodBody ().GetILAsByteArray ();
return new ReplaceInfo (ReplaceInfo.ReplaceMode.IL, newIL);
}
return null;
}
public static int Somar (int num1, int num2) {
return num1 + num2;
}
public static int Calculo (int num1, int num2) {
return num1 + 10 + num2 + 10;
}
}
Assim, toda vez que um método está prestes a ser compilado, notificamos e esperamos se ele deve ser modificado ou não.
Criei um método chamado chamado Calculo, ele é quem eu quero que substitua o Somar.
Quando executarmos, o resultado será 22:
Agora que conseguimos modificar o IL, podemos também modificar o código nativo.
Modificando o código nativo
Para modificar o código nativo é um pouco mais complicado, pois o código a ser reescrito tem que ser menor ou igual o código nativo gerado pelo JIT. Uma alternativa fácil para “burlar” isto, é induzir o tamanho do código nativo gerado pelo JIT através do IL.
Na teoria, quanto maior fosse o tamanho do IL, maior seria o tamanho do código nativo gerado pelo JIT, afinal, é mais código a ser executado. Mas isto não é realidade no .NET. Possuímos dois modos: DEBUG e RELEASE. No modo DEBUG, o cenário acima é real, quanto maior o IL, maior será o tamanho do código nativo, afinal, estamos em ambiente de depuração, não faria sentido otimizar. Em modo RELEASE, o cenário é diferente, o código sofre transformações e otimizações, ou seja, instruções são transformadas para instruções menores, mais eficientes, ou até mesmo removidas. Isto é um grande problemas para nós, pois precisamos achar uma forma de aumentar o tamanho do IL de uma forma que o JIT simplesmente não remova ou reduza drasticamente. Após muita procura, descobri que é possível fazer isto com instruções bitwise.
Ao injetar instruções bitwise, mesmo que não utilizadas, o JIT não remove elas do código final gerado e não faz grandes transformações. Assim podemos manipular o tamanho do código nativo através do IL.
Operadores bitwise foi uma das poucas instruções que encontrei que o JIT não remove no modo RELEASE. Outra instruções CALL, funcionaram também, mas daria mais trabalho para montar.
if (replaceInfo.Mode == ReplaceInfo.ReplaceMode.IL) {
unsafe {
fixed (void * ptr = replaceInfo.Body) {
ilAddress = new IntPtr (ptr);
}
}
} else {
Span<byte> emptyBody;
//Tamanho mínimo gerado pelo JIT no modo RELEASE.
const int minSize = 13;
if (replaceInfo.Body.Length > minSize && replaceInfo.Body.Length > 21) {
//Calcula o tamanho final que o IL deve ter.
int nextMax = replaceInfo.Body.Length + (3 - replaceInfo.Body.Length % 3);
ilLength = 4 + 2 * ((nextMax - 21) / 3);
if (ilLength % 2 != 0) {
ilLength++;
}
} else {
ilLength = 4;
}
unsafe {
void * ptr = stackalloc byte[ilLength];
emptyBody = new Span<byte> (ptr, ilLength);
ilAddress = new IntPtr (ptr);
}
emptyBody[0] = (byte) OpCodes.Ldc_I4_1.Value;
emptyBody[1] = (byte) OpCodes.Ldc_I4_1.Value;
emptyBody[2] = (byte) OpCodes.And.Value;
emptyBody[ ^ 1] = (byte) OpCodes.Ret.Value;
for (int i = 3; i < emptyBody.Length - 2; i += 2) {
emptyBody[i] = (byte) OpCodes.Ldc_I4_1.Value;
emptyBody[i + 1] = (byte) OpCodes.And.Value;
}
}
//Precisamos garantir que há espaço suficiente na stack
if (info.maxStack < 2) {
info.maxStack = 2;
}
info.ILCode = ilAddress;
info.ILCodeSize = ilLength;
O valor 13 da variável minSize, é o menor tamanho possível que encontrei de um código nativo, ele corresponde há um método void vazio. Não sei se este é realmente o menor tamanho possível, mas nos meus testes foi o suficiente.
Depois que o método estiver compilado, basta sobrescrever:
int result = Compiler.CompileMethod (thisPtr, comp, ref info, flags, out nativeEntry, out nativeSizeOfCode);
//ASM can be replaced just after method already compiled by jit.
if (replaceInfo?.Mode == ReplaceInfo.ReplaceMode.ASM) {
Marshal.Copy (replaceInfo.Body, 0, nativeEntry, replaceInfo.Body.Length);
}
return result;
Abaixo esta em um exemplo de como funciona:
class Program {
static void Main () {
ManagedJit managedJit = ManagedJit.GetInstance ();
managedJit.OnPreCompile = OnPreCompile;
int resultado = Somar (1, 1);
Console.WriteLine (resultado);
Console.ReadKey ();
}
private static ReplaceInfo OnPreCompile (MethodBase method) {
MethodInfo somarInfo = typeof (Program).GetMethod ("Somar");
if (somarInfo.MetadataToken == method.MetadataToken) {
//num1 + num2 + 1 + 2 + 3 + 4 + 5 + 6 + ....
//01 d1 add ecx,edx
//83 c1 0a add ecx,0x1
//83 c1 14 add ecx,0x2
//83 c1 1e add ecx,0x3
//83 c1 28 add ecx,0x4
//...
//89 c8 mov eax,ecx
//ff c0 inc eax
//c3 ret
List<byte> newASM = new List<byte> { 0x01, 0xd1 };
int count = 0;
//Simula um bytecode de no mínimo 500 bytes (bem maior que o gerado originalmente)
while (newASM.Count < 500) {
newASM.Add (0x83);
newASM.Add (0xc1);
newASM.Add ((byte) ++count);
}
newASM.Add (0x89);
newASM.Add (0xc8);
newASM.Add (0xff);
newASM.Add (0xc0);
newASM.Add (0xc3);
return new ReplaceInfo (ReplaceInfo.ReplaceMode.ASM, newASM.ToArray ());
}
return null;
}
public static int Somar (int num1, int num2) {
return num1 + num2;
}
}
O resultado será 3880.
Com isso, você já pode modificar o corpo do seu método em tempo de execução, tanto por IL quando por código nativo. Para restaurar compileMethod original, basta chamar o InstallCompileMethod passando o ponteiro do CompileMethodOriginal.
Há algumas limitações ainda nesta abordagem:
- O tamanho de variáveis a ser utilizado no IL (stloc.x , ldloc.x, …), tem que ser menor ou igual ao tamanho de variáveis do método original.
- O maxstack também deve ser menor ou igual ao original.
Resolverei estas limitações em um próximo post.
Fontes
Writing a Managed JIT in C# with CoreCLR
.NET Internals and Native Compiling
.NET Internals and Code Injection
.NET CLR Injection: Modify IL Code during Run-time