Как стать автором
Обновить

Снова используем Unmanaged С++ код в .NET программах

Время на прочтение7 мин
Количество просмотров4.7K
.NET C++

Около года назад я писал статью о том, как можно вызывать методы классов написанных на чистом Си++ из любой .NET программы не прибегая к регистрации COM библиотек, C++/CLI и т.п.

Сегодня я расскажу об еще одном оригинальном и весьма удобном подходе, а, кроме того, этот топик будет интересен всем хаброчитателям которые хотят побольше узнать о замечательном инструменте Reflection.Emit (на мой взгляд эта тема на хабре недостаточно хорошо освещена).


За основу примера возьмем все то же старое приложение с парой C++'сных классов и тестовой формой.

Итак, приступим. Первым делом возьмем старый пример и удалим из него весь мусор, так или иначе связанный с COM. Кроме того, уберем возвращаемые кругом, по поводу и без, HRESULT'ы, GUID'ы и прочую нечисть. Кода стало сразу чуть ли не вдвое меньше :) Вместо этого добавим один метод Dispose, который будет освобождать объект.

Итак, наш самый простой C++ класс теперь выглядит так:

class CHello
{
public:
  LIBCALL Dispose()
  {
    delete this;
  }

  LIBCALL SetName(LPWSTR AName)
  {
    mName = AName;
  }

  LIBCALL Say(HWND AParent)
  {
    wstring message = L"Hello, my friend " + mName;
    MessageBox(AParent, message.c_str(), L"From C++", MB_ICONINFORMATION);
  }

private:
  wstring mName;
};



Здесь LIBCALL == virtual void __stdcall

Перейдем к C# части. Первое, что нам надо сделать, это объявить интерфейс, описывающий экспортируемый C++ класс (здесь важно сохранять порядок объявления функций):
[InvokerObject(EObjectType.Hello)]
public interface IHello : IDisposable
{
  void SetName([MarshalAs(UnmanagedType.LPWStr)]string AName);
  void Say(IntPtr AParent);
}



Вы можете заметить, что интерфейс наследуется от IDisposable, таким образом, первым методом объекта будет, по сути, метод Dispose. Про InvokerObject атрибут я еще расскажу.

Теперь, чтобы использовать наш C++ объект в C# программе достаточно написать:

IHello hello = Invoker.Create<IHello>();
hello.SetName(txtName.Text);
hello.Say(Handle);



Оно правда работает

По ту сторону кода



Теперь, можно перейти к самому интересному — как все это работает изнутри. Главный герой нашей сегодняшней программы — CIL опкод Calli. Этот опкод позволяет осуществить вызов функции по произвольному машинному адресу с заданным набором аргументов. Именно на нем и будет строиться вся работа нашего врапера.

Вся работа по созданию объекта-враппера, предназначенного для вызова Unmanaged функций, будет осуществляться нашим Классом Invoker. Я не буду приводить здесь его полный код, а расскажу только о его идее и принципах работы (кому интересно, могут скачать полный исходный код примера в конце статьи архивом).

Алгоритм работы класса Invoker таков:
  1. Создаем динамическую сборку (AppDomain.CurrentDomain.DefineDynamicAssembly)
  2. Создаем в этой сборке класс, наследованный от указанного интерфейса
  3. Создаем в классе конструктор, который принимает IntPtr — указатель на Unmanaged класс и, в соответствии с количеством методов в интерфейсе, читает нужное количество адресов функций из виртуальной таблицы методов
  4. Обходим все методы в интерфейсе
  5. Для каждого метода создаем собственную функцию, принимающую нужный набор аргументов и осуществляющую вызов конечного метода С++ класса с помощью Calli


Чтобы хаброчитателю проще было понять суть, я привожу здесь прокомментированный код функции создания этого класса (шаги 2 и 3):
// Количество методов в виртуальной таблице (+1 т.к. есть еще Dispose)
int k = InterfaceType.GetMethods().Count() + 1;

// Создаем новый тип который будет служить врапером между unmanaged объектом и нашей программой
typeBuilder = InvokerDynamicAssembly.Instance.Builder.DefineType(TypeName, TypeAttributes.Class | TypeAttributes.Public);
// Этот тип будет реализовывать указанный интерфейс
typeBuilder.AddInterfaceImplementation(InterfaceType);

// Создадим поля для нашего класса
ptrThis = typeBuilder.DefineField("ptr", typeof(IntPtr), FieldAttributes.Private);
methods = typeBuilder.DefineField("methods", typeof(IntPtr[]), FieldAttributes.Private);
vtbl = typeBuilder.DefineField("vtbl", typeof(IntPtr), FieldAttributes.Private);

// И объявим конструктор, который будет извлекать данные из VTBL unmanaged объекта и сохранять в нашем классе
ConstructorBuilder constructorBuilder = typeBuilder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard, new Type[] { typeof(System.IntPtr) });
constructorBuilder.DefineParameter(1, ParameterAttributes.In, "Pointer");
ILGenerator ctorGen = constructorBuilder.GetILGenerator();

// Вызовем базовый конструктор
ctorGen.Emit(OpCodes.Ldarg_0);
ctorGen.Emit(OpCodes.Call, ObjectCtorInfo);

// Сохраним переданый указатель в поле ptrThis
ctorGen.Emit(OpCodes.Ldarg_0);
ctorGen.Emit(OpCodes.Ldarg_1);
ctorGen.Emit(OpCodes.Stfld, ptrThis);

// Запоминаем адрес виртуальной таблицы методов объекта
ctorGen.Emit(OpCodes.Ldarg_0);
ctorGen.Emit(OpCodes.Ldarg_1);
ctorGen.Emit(OpCodes.Call, ReadIntPtr); // Ссылка на Marshal.ReadIntPtr
ctorGen.Emit(OpCodes.Stfld, vtbl);

ctorGen.Emit(OpCodes.Ldarg_0);
ctorGen.Emit(OpCodes.Ldc_I4_S, k);
ctorGen.Emit(OpCodes.Newarr, typeof(IntPtr));
ctorGen.Emit(OpCodes.Stfld, methods);

// И извлекаем все адреса методов
for (int i = 0; i < k; i++)
{
  ctorGen.Emit(OpCodes.Ldarg_0);
  ctorGen.Emit(OpCodes.Ldfld, methods);
  ctorGen.Emit(OpCodes.Ldc_I4_S, i);

  ctorGen.Emit(OpCodes.Ldarg_0);
  ctorGen.Emit(OpCodes.Ldfld, vtbl);
  ctorGen.Emit(OpCodes.Ldc_I4, i * IntPtr.Size);
  ctorGen.Emit(OpCodes.Add);
  ctorGen.Emit(OpCodes.Call, ReadIntPtr);

  ctorGen.Emit(OpCodes.Stelem, typeof(IntPtr));
}

// Конец метода
ctorGen.Emit(OpCodes.Ret);

// Создаем реализацию на каждый метод в интерфейсе
AddMethods();

createdType = typeBuilder.CreateType();



Теперь поговорим о InvokerObjectAttribute. Самая главная функция класса Invoker:

/// <summary>
/// Создать неуправляемый объект с заданным интерфейсом
/// </summary>
/// <returns>Созданный интерфейс для взаимодействия с неуправляемым объектом</returns>
public static T Create<T>()
  where T : class, IDisposable
{
  object[] attr = typeof(T).GetCustomAttributes(true);
  foreach (var it in attr)
  {
    if (it is InvokerObjectAttribute)
    {
      var objectType = (it as InvokerObjectAttribute).ObjectType;

      Invoker inv = new Invoker();
      inv.InterfaceType = typeof(T);
      inv.Pointer = Lib.CreateObject(objectType);
      inv.InitializeType();
      return inv.CreateInstance() as T;
    }
  }
  return null;
}


Тут мы с помощью InvokerObjectAttribute узнаем какой объект нужно создать и с помощтью P\Invoke вызова Lib.CreateObject просим С++ библиотеку создать новый неуправляемый объект нужного типа и вернуть нам указатель.

Что особенно интересно, мы можем объявлять новый тип внутри динамической сборки только при первом обращении к интерфейсу, а в последствии использовать уже существующий, передавая ему новый указатель. Работает это примерно так:

private void InitializeType()
{
  if (InvokerDynamicAssembly.Instance.HasType(TypeName))
    createdType = InvokerDynamicAssembly.Instance.GetType(TypeName);
  else
    CreateType();
}



Очевидно, что главная задача Invoker'а — создать методы оборачивающие вызов unmanaged функций. Вот так выглядит созданная обертка для метода Say:
.method public hidebysig virtual
  instance void Say (
    native int ''
  ) cil managed
{
  // Method begins at RVA 0x2164
  // Code size 29 (0x1d)
  .maxstack 4

  IL_0000: ldarg.0
  IL_0001: ldfld native int InvokerDynamicAssembly.net2c.IHello::ptr
  IL_0006: ldarg.1
  IL_0007: ldarg.0
  IL_0008: ldfld native int[] InvokerDynamicAssembly.net2c.IHello::methods
  IL_000d: ldc.i4.s 2
  IL_000f: nop
  IL_0010: nop
  IL_0011: nop
  IL_0012: ldelem.any [mscorlib]System.IntPtr
  IL_0017: calli System.Void(System.IntPtr,System.IntPtr)
  IL_001c: ret
}


Как видите, он всего лишь берет переданные на вход аргументы и вызывает с ними виртуальный метод неуправляемого объекта.

IL Spy в живую

Преимущества подхода


1. Работает быстрее чем COM Interop примерно в 1,6 раз. С C++/CLI не сравнивал, желающие могут протестировать и отписаться.

2. Отвязка от MTA\STA. COM Interop требует строгого соблюдения Thread Appartament State, между тем, очень часто (на моей памяти чуть ли не всегда) вместо помощи программисту, это создает множество лишних сложностей в работе с объектами в C#. Данный метод полностью лишен этого недостатка, т.к. никаких привязок к потоку он не осуществляет.

3. Хорош в плане простоты использования и малого количества необходимого кода (не считая, конечно, класс Invoker который пишется один раз и навсегда).

Из основных минусов — я не придумал как сделать правильный маршалинг с использованием атрибута MarshalAs (кроме варианта полной реализации вручную). Поэтому на данный момент, отдельный маршалинг реализован только для типа string (чтобы обеспечить Unicode представление), остальные типы маршалятся по умолчанию. Лично для меня это не является особой проблемой, т. к. в неуправляемый код я выношу только алгоритмы требующие жесткой оптимизации производительности, и как правило, им достаточно параметров простейших типов (указатели и числа). Но если кто подскажет правильное решение по этому вопросу, я буду очень очень признателен, потому что довольно долго ломал голову над этой задачей.

P.S. Если Вы программируете на C#, но все еще знакомы с IL только по наслышке, рекомендую почитать хорошее краткое введение в тему: www.wasm.ru/series.php?sid=22

P.P.S Да, чуть не забыл, как и обещал, прикладываю полный исходник всего этого безобразия: public.66bit.ru/files/2011.10.07/7458c3ca96a602091fe049117974fab4/Net2C.rar
Теги:
Хабы:
+52
Комментарии7

Публикации

Изменить настройки темы

Истории

Работа

.NET разработчик
74 вакансии

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн