Unmanaged C++ library в .NET. Повна інтеграція

У статті розглянута повна інтеграція C++ бібліотеки в managed оточення з використанням Platform Invoke. Під повною інтеграцією розуміється можливість успадкування класів бібліотеки, реалізації її інтерфейсів (інтерфейси будуть представлені в managed коді як абстрактні класи). Примірники спадкоємців можна буде «передавати» у unmanaged оточення.

Питання інтеграції вже не раз піднімалося на хабре, але, як правило, він присвячений інтеграції пари-трійки методів, які немає можливості реалізувати в managed коді. Перед нами ж стояло завдання взяти модуль з C++ і змусити його працювати .NET. Варіант написати заново, по ряду причин, не розглядався, так що ми приступили до інтеграції.

Ця стаття не розкриває всіх питань інтеграції unmanaged модуля .NET. Є ще нюанси з передачею рядків, логічних значень і т. п… З цих питань є документація і кілька статей на хабре, так що тут ці питання не розглядалися.

Варто відзначити, що .NET обгортка на базі Platform Invoke кроссплатформенна, її можна зібрати на Mono + gcc.

Інтеграція sealed класу
Перше, що доводиться усвідомити при інтеграції з допомогою Platform Invoke це те, що цей інструмент дозволяє інтегрувати лише окремі функції. Не можна просто так взяти і інтегрувати клас. Рішення проблеми виглядає просто:

На стороні Unmanaged пишемо функцію:
SomeType ClassName_methodName(ClassName * instance, SomeOtherType someArgument)
{
instance->methodName(someArgument);
}

Не забуваємо до подібних функцій додати extern «C», щоб їх імена не декорувалися C++ компілятором. Це завадило б нам при інтеграції цих функцій .NET.

Далі повторюємо процедуру для всіх публічних методів класу і інтегруємо отримані функції в клас, написаний в .NET. Цей клас не можна успадковувати, тому .NET такий клас оголошується як sealed. Як обійти це обмеження і з чим воно пов'язане — дивіться нижче.
А поки ось вам невеликий приклад:

Unmanaged class:
class A
{
int mField;
public:
A( int someArgument);
int someMethod( int someArgument);
};

Функції для інтеграції:
A * A_createInstance(int someArgument)
{
return new A(someArgument);
}

int A_someMethod(A *instance, int someArgument)
{
return instance->someMethod( someArgument);
}

void A_deleteInstance(A *instance)
{
delete instance;
}

Реалізація в .Net:
public sealed class A
{
private IntPtr mInstance;
private bool mDelete;

[ DllImport( "shim.dll", CallingConvention = CallingConvention .Cdecl)]
private static extern IntPtr A_createInstance( int someArgument);

[ DllImport( "shim.dll", CallingConvention = CallingConvention .Cdecl)]
private static extern int A_someMethod( IntPtr instance, int someArgument);

[ DllImport( "shim.dll", CallingConvention = CallingConvention .Cdecl)]
private static extern void A_deleteInstance( IntPtr instance);

internal A( IntPtr instance)
{
Debug.Assert(instance != IntPtr.Zero);
mInstance = instance;
mDelete = false;
}

public A( int someArgument)
{
mInstance = A_createInstance(someArgument);
mDelete = true;
}

public int someMethod( int someArgument)
{
return A_someMethod(mInstance, someArgument);
}

internal IntPtr getUnmanaged()
{
return mInstance;
}

~A()
{
if (mDelete)
A_deleteInstance(mInstance);
}

}

Internal конструктор і метод потрібні, щоб отримати екземпляри класу з unmanaged коду і передавати їх назад. Саме з передачею екземпляра класу назад в unmanaged середу пов'язана проблема спадкування. Якщо клас A отнаследовать в .NET і перевизначити ряд його методів (уявімо, що someMethod оголошений з ключовим словом virtual), ми не зможемо забезпечити виклик переопределенного коду з unmanaged середовища.

Інтеграція інтерфейсу
Для інтеграції інтерфейсів нам потрібен зворотній зв'язок. Тобто для повноцінного використання интегрируемого модуля нам потрібна можливість реалізації його інтерфейсів. Реалізація пов'язана з визначенням методів в managed середовищі. Ці методи потрібно буде викликати з unmanaged коду. Тут нам на допомогу прийдуть Callback Methods, описані в документації до Platform Invoke.

На стороні unmanaged середовища Callback представляється у вигляді покажчика на функцію:
typedef void (*PFN_MYCALLBACK )();
int _MyFunction(PFN_MYCALLBACK callback);

А .NET його роль буде грати делегат:
[UnmanagedFunctionPointerAttribute( CallingConvention.Cdecl)]
public delegate void MyCallback ();
[ DllImport("MYDLL.DLL",CallingConvention.Cdecl)]
public static extern void MyFunction( MyCallback callback);

Маючи інструмент для зворотного зв'язку ми легко зможемо забезпечити виклик перевизначених методів.

Але щоб передати примірник реалізації інтерфейсу, unmanaged середовищі нам його теж доведеться представити як екземпляр реалізації. Так що доведеться написати ще одну реалізацію в unmanaged середовищі. У цій реалізації ми, до речі кажучи, закладемо виклики Callback функцій.

На жаль, такий підхід не дозволить нам обійтися без логіки в managed інтерфейсах, так що нам доведеться представити їх у вигляді абстрактних класів. Давайте подивимося на код:

Unmanaged interface:
class IB
{
public:
virtual int method( int arg) = 0;
virtual ~IB() {};
};

Unmanaged реалізація
typedef int (*IB_method_ptr)(int arg);
class UnmanagedB : public IB
{
IB_method_ptr mIB_method_ptr;
public:
void setMethodHandler( IB_method_ptr ptr);
virtual int method( int arg);
//... конструктор/деструктор
};

void UnmanagedB ::setMethodHandler(IB_method_ptr ptr)
{
mIB_method_ptr = ptr;
}

int UnmanagedB ::method(int arg )
{
return mIB_method_ptr( arg);
}

Методи UnmanagedB просто викликають коллбэки, які йому видає managed клас. Тут нас чекає ще одна неприємність. До тих пір, поки в unmanaged коді у когось є вказівник на UnmanagedB, ми не маємо права видаляти екземпляр класу в managed коді, що реагує на виклик коллбэков. Вирішенню цієї проблеми присвячена остання частина статті.

Функції для інтеграції:
UnmanagedB *UnmanagedB_createInstance()
{
return new UnmanagedB();
}

void UnmanagedB_setMethodHandler(UnmanagedB *instance, IB_method_ptr ptr)
{
instance->setMethodHandler( ptr);
}

void UnmanagedB_deleteInstance(UnmanagedB *instance)
{
delete instance;
}

А ось і подання інтерфейсу в managed коді:
public abstract class AB
{
private IntPtr mInstance;

[DllImport("shim", CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr UnmanagedB_createInstance();

[DllImport("shim", CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr UnmanagedB_setMethodHandler( IntPtr instance,
[MarshalAs(UnmanagedType.FunctionPtr)] MethodHandler ptr);

[DllImport("shim", CallingConvention = CallingConvention.Cdecl)]
private static extern void UnmanagedB_deleteInstance( IntPtr instance);

[UnmanagedFunctionPointerAttribute( CallingConvention.Cdecl)]
private delegate int MethodHandler( int arg);

private int impl_method( int arg)
{
return method(arg);
}

public abstract int method(int arg);

public AB()
{
mInstance = UnmanagedB_createInstance();
UnmanagedB_setMethodHandler(mInstance, impl_method);
}

~AB()
{
UnmanagedB_deleteInstance(mInstance);
}

internal virtual IntPtr getUnmanaged()
{
return mInstance;
}

}

Кожному методу інтерфейсу відповідає пара:
  1. Публічний абстрактний метод, який ми будемо ігнорувати
  2. «Вызыватель» абстрактного методу (приватний метод з приставкою impl). Може здатися, що він не має сенсу, але це не так. Цей метод може містити додаткові перетворення аргументів та результатів виконання. Так само в ньому може бути закладена додаткова логіка для передачі винятків (як ви вже здогадалися, просто передати виключення з середовища в середу не вийде, виключення теж треба інтегрувати)
От і все. Тепер ми можемо отнаследовать клас AB і перевизначити його метод method. Якщо нам потрібно буде передати спадкоємця unmanaged код ми віддамо замість нього mInstance, який викличе перевизначено метод через покажчик на функцію/делегат. Якщо ж ми отримаємо покажчик на інтерфейс IB з unmanaged оточення, його потрібно представити у вигляді примірника AB в managed середовищі. Для цього ми реалізуємо спадкоємця AB «за замовчуванням»:
internal sealed class BImpl : AB
{
[DllImport("shim", CallingConvention = CallingConvention.Cdecl)]
private static extern int BImpl_method( IntPtr instance, int arg);

private IntPtr mInstance;
internal BImpl( IntPtr instance)
{
Debug.Assert(instance != IntPtr.Zero);
mInstance = instance;
}

public override int method(int arg)
{
return BImpl_method(mInstance, arg);
}

internal override IntPtr getUnmanaged()
{
return mInstance;
}

}

Функції для інтеграції:
int BImpl_method(IB *instance , int arg )
{
instance->method( arg);
}

За великим рахунком це та ж інтеграція класу без підтримки спадкування, описана . Не складно помітити, що створюючи примірник BImpl, ми також створюємо екземпляр UnmanagedB і робимо не потрібні прив'язки коллбэков. При бажанні цього можна уникнути, але це вже тонкощі, тут ми описувати не будемо.

Інтеграція класів з підтримкою спадкування
Завдання — інтегрувати клас і надати можливість перевизначення його методів. Покажчик на клас ми будемо віддавати в unmanaged, так що треба забезпечити клас коллбэками, щоб мати можливість викликати перевизначені методи.

Розглянемо клас C, має реалізацію в unmanaged коді:
class C
{
public:
virtual int method(int arg);
virtual ~C() {};
};

Для початку ми зробимо вигляд, що це інтерфейс. Інтегруємо його також, як це було зроблено :

Unmanaged спадкоємець для коллбэков:
typedef int (*С_method_ptr )(int arg);
class UnmanagedC : public cpp::C
{
С_method_ptr mС_method_ptr;
public:
void setMethodHandler( С_method_ptr ptr);
virtual int method( int arg);
};

void UnmanagedC ::setMethodHandler(С_method_ptr ptr)
{
mС_method_ptr = ptr;
}

int UnmanagedC ::method(int arg )
{
return mС_method_ptr( arg);
}

Функції для інтеграції:
//... опустимо методи createInstance і deleteInstance

void UnmanagedC_setMethodHandler(UnmanagedC *instance , С_method_ptr ptr )
{
instance->setMethodHandler( ptr);
}

І реалізація .Net:
public class C
{
private IntPtr mHandlerInstance;

[DllImport("shim", CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr UnmanagedC_setMethodHandler( IntPtr instance,
[MarshalAs(UnmanagedType.FunctionPtr)] MethodHandler ptr);

[UnmanagedFunctionPointerAttribute( CallingConvention.Cdecl)]
private delegate int MethodHandler( int arg);

//... також імпортуємо функції для створення/видалення екземпляра класу

private int impl_method( int arg)
{
return method(arg);
}

public virtual int method(int arg)
{
throw new NotImplementedException();
}

public C()
{
mHandlerInstance = UnmanagedC_createInstance();
UnmanagedC_setMethodHandler(mHandlerInstance, impl_method);
}

~C()
{
UnmanagedC_deleteInstance(mHandlerInstance);
}

internal IntPtr getUnmanaged()
{
return mHandlerInstance;
}

}

Отже, ми можемо перевизначити метод C. method і він буде коректно викликаний з unmanaged середовища. Але ми не забезпечили виклик реалізації за замовчуванням. Тут нам допоможе код першої частини статті:
Для виклику реалізації за замовчуванням нам потрібно інтегрувати. Також для її роботи нам потрібен відповідний екземпляр класу, який доведеться створювати та видаляти. Отримуємо вже знайомий код:
//... знову ж опускаємо createInstance і deleteInstance

int C_method(C *instance, int arg)
{
return instance->method( arg);
}

Допилим .Net реалізацію:
public class C
{
//...

[DllImport("shim", CallingConvention = CallingConvention.Cdecl)]
private static extern int C_method(IntPtr instance, int arg);

public virtual int method(int arg)
{
return C_method(mInstance, arg);
}

public C()
{
mHandlerInstance = UnmanagedC_createInstance();
UnmanagedC_setMethodHandler(mHandlerInstance, impl_method);
mInstance = C_createInstance();
}

~C()
{
UnmanagedC_deleteInstance(mHandlerInstance);
C_deleteInstance(mInstance);
}

//...
}


Такий клас можна сміливо застосовувати в managed коді, успадковувати, долати його методи, передавати покажчик на нього в unmanaged середу. Навіть якщо ми не переопределяли жодних методів, ми все одно передамо вказівник на UnmanagedC. Це не дуже раціонально, враховуючи, що unmanaged код буде викликати методи unmanaged класу C транслюючи виклики через managed код. Але така ціна за можливість перевизначення методів. У прикладі, прикріпленому до статті, цей випадок продемонстровано, з допомогою виклику методу method у класу D. Якщо подивитися на callstack, можна побачити таку послідовність:

Виключення
Platform Invoke не дозволяє передавати винятку і для обходу цієї проблеми ми перехватываем всі винятки перед переходом із середовища у середу, обертаємо інформацію про виключення в спеціальний клас і передаємо. На тій стороні генеруємо виняток на основі отриманої інформації.

Нам пощастило. Наш C++ модуль генерує тільки винятки типу ModuleException або його спадкоємців. Так що нам достатньо перехоплювати це виняток у всіх методах, в яких воно може бути згенеровано. Щоб прокинути об'єкт-винятку в managed середу нам потрібно інтегрувати клас ModuleException. По ідеї виключення повинно містити текстове повідомлення, але я не хочу морочитися з темою маршалинга рядків у цій статті, так що в прикладі «коди помилок»:
public sealed class ModuleException : Exception
{
IntPtr mInstance;
bool mDelete;

//... пропущено create/delete instance

[DllImport("shim", CallingConvention = CallingConvention.Cdecl)]
private static extern int ModuleException_getCode( IntPtr instance);

public int Code
{
get
{
return ModuleException_getCode(mInstance);
}
}

public ModuleException( int code)
{
mInstance = ModuleException_createInstance(code);
mDelete = true;
}

internal ModuleException( IntPtr instance)
{
Debug.Assert(instance != IntPtr.Zero);
mInstance = instance;
mDelete = false;
}

~ModuleException()
{
if (mDelete)
ModuleException_deleteInstance(mInstance);
}

//... пропущено getUnmanaged

}

Тепер припустимо, що метод C::method може генерувати виключення ModuleException. Перепишемо клас з підтримкою винятків:
//Весь клас описувати не будемо, нижче наведені тільки зміни
typedef int (*С_method_ptr )(int arg, ModuleException **error);

int UnmanagedC ::method(int arg )
{
ModuleException *error = nullptr;
int result = mС_method_ptr( arg, &error);
if (error != nullptr)
{
int code = error->getCode();
//... управління видаленням примірника error описано нижче і семпли
throw ModuleException(code);
}
return result;
}

int C_method(C *instance, int arg, ModuleException ** error)
{
try
{
return instance->method( arg);
}
catch ( ModuleException& ex)
{
*error = new ModuleException(ex.getCode());
return 0;
}
}

public class C
{
//...

[DllImport("shim", CallingConvention = CallingConvention.Cdecl)]
private static extern int C_method(IntPtr instance, int arg, ref IntPtr error);

[UnmanagedFunctionPointerAttribute( CallingConvention.Cdecl)]
private delegate int MethodHandler( int arg, ref IntPtr error);

private int impl_method( int arg, ref IntPtr error)
{
try
{
return method(arg);
}
catch (ModuleException ex)
{
error = ex.getUnmanaged();
return 0;
}
}

public virtual int method(int arg)
{
IntPtr error = IntPtr.Zero;
int result = C_method(mInstance, arg, ref error);
if (error != IntPtr.Zero)
throw ModuleException(error);
return result;
}

//...

}

Тут нас теж чекають неприємності з управлінням пам'яттю. У методі impl_method ми передаємо вказівник на помилку, але Garbage Collector може видалити її раніше, ніж вона буде оброблена в unmanaged коді. Пора вже розібратись із цією проблемою!

Складальник сміття проти коллбэков
Тут треба сказати, що нам більш-менш пощастило. Всі класи та інтерфейси интегрируемого модуля успадковувалися від якогось інтерфейсу IObject, містить методи addRef і release. Ми знали, що скрізь у модулі при передачі покажчика проводився виклик addRef. І всякий раз, коли потреба в покажчику зникала, проводився виклик release. За рахунок такого підходу ми легко могли відстежити потрібен покажчик unmanaged модулю або колбеки вже можна видалити.

Щоб уникнути видалення managed об'єктів, використовуваних в unmanaged середовищі, нам потрібен менеджер цих об'єктів. Він буде вважати виклики addRef і release з unmanaged коду і звільняти managed об'єкти, коли вони більше не будуть потрібні.

Виклики addRef і release будуть перекидатися з unmanaged коду в managed, так що перше, що нам знадобиться — це клас, який забезпечить такий закид:
typedef long (*UnmanagedObjectManager_remove )(void * instance);
typedef void (*UnmanagedObjectManager_add )(void * instance);

class UnmanagedObjectManager
{
static UnmanagedObjectManager mInstance;
UnmanagedObjectManager_remove mRemove;
UnmanagedObjectManager_add mAdd;
public:
static void add( void *instance);
static long remove( void *instance);

static void setAdd( UnmanagedObjectManager_add ptr);
static void setRemove( UnmanagedObjectManager_remove ptr);
};

UnmanagedObjectManager UnmanagedObjectManager ::mInstance;

void UnmanagedObjectManager ::add(void * instance )
{
if (mInstance.mAdd == nullptr)
return;
mInstance.mAdd( instance);
}

long UnmanagedObjectManager ::remove(void * instance )
{
if (mInstance.mRemove == nullptr)
return 0;
return mInstance.mRemove( instance);
}

void UnmanagedObjectManager ::setAdd(UnmanagedObjectManager_add ptr )
{
mInstance.mAdd = ptr;
}

void UnmanagedObjectManager ::setRemove(UnmanagedObjectManager_remove ptr)
{
mInstance.mRemove = ptr;
}

Друге, що ми повинні зробити, це змінити addRef і release інтерфейсу IObject так, щоб вони змінювали значення лічильника нашого менеджера, який зберігається в managed коді:
template < typename T >
class TObjectManagerObjectImpl : public T
{
mutable bool mManagedObjectReleased;
public:
TObjectManagerObjectImpl()
: mManagedObjectReleased( false)
{
}

virtual ~TObjectManagerObjectImpl()
{
UnmanagedObjectManager::remove(getInstance());
}

void *getInstance() const
{
return ( void *) this;
}

virtual void addRef() const
{
UnmanagedObjectManager::add(getInstance());
}

віртуальний bool release() const
{
long result = UnmanagedObjectManager::remove(getInstance());
if (result == 0)
if (mManagedObjectReleased)
delete this;
return result == 0;
}

void resetManagedObject() const
{
mManagedObjectReleased = true;
}
};

Тепер класи UnmanagedB і UnmanagedC необхідно отнаследовать від класу TObjectManagerObjectImpl. Розглянемо на прикладі UnmanagedC:
class UnmanagedC : public TObjectManagerObjectImpl <C>
{
С_method_ptr mС_method_ptr;
public:
UnmanagedC();
void setMethodHandler( С_method_ptr ptr);
virtual int method( int arg);
virtual ~UnmanagedC();
};

Клас C реалізує інтерфейс IObject, але тепер методи addRef і release перевизначені класом TObjectManagerObjectImpl, так що підрахунком кількості покажчиків буде займатися менеджер об'єктів в managed середовищі.
Пора вже поглянути на код самого менеджера:
internal static class ObjectManager
{
/ / імпортуємо... все, що необхідно, див. семпл

private static AddHandler mAddHandler;
private static RemoveHandler mRemoveHandler;

private class Holder
{
internal int count;
internal Object ptr;
}

private static Dictionary< IntPtr, Holder> mObjectMap;

private static long removeImpl( IntPtr instance)
{
return remove(instance);
}

private static void addImpl(IntPtr instance)
{
add(instance);
}

static ObjectManager()
{
mAddHandler = new AddHandler(addImpl);
UnmanagedObjectManager_setAdd(mAddHandler);
mRemoveHandler = new RemoveHandler(removeImpl);
UnmanagedObjectManager_setRemove(mRemoveHandler);

mObjectMap = new Dictionary<IntPtr , Holder >();
}

internal static void add(IntPtr instance, Object ptr = null)
{
Holder holder;
if (!mObjectMap.TryGetValue(instance, out holder))
{
holder = new Holder();
holder.count = 1;
holder.ptr = ptr;
mObjectMap.Add(instance, holder);
}
else
{
if (holder.ptr == null && ptr != null)
holder.ptr = ptr;
holder.count++;
}
}

internal static long remove(IntPtr instance)
{
long result = 0;
Holder holder;
if (mObjectMap.TryGetValue(instance, out holder))
{
holder.count--;
if (holder.count == 0)
mObjectMap.Remove(instance);
result = holder.count;
}
return result;
}
}

Тепер у нас є менеджер об'єктів. Перед передачею екземпляра managed об'єкта в unmanaged середу, ми повинні додати його в менеджер. Так що метод getUnmanaged у класів AB C необхідно змінити. Наведу код для класу C:
internal IntPtr getUnmanaged()
{
ObjectManager.add(mHandlerInstance, this);
return mHandlerInstance;
}

Тепер ми можемо бути впевнені, що коллбэки будуть працювати настільки довго, наскільки це необхідно.

Враховуючи специфіку модуля, потрібно переписати класи, замінивши всі виклики ClassName_deleteInstance на виклики IObject::release, а також не забувати робити IObject::addRef там, де це буде потрібно. Зокрема, це дозволить уникнути передчасного видалення ModuleException, навіть якщо збирач сміття видалить managed обгортку, unmanaged примірник, будучи спадкоємцем IObject, не буде видалений, поки unmanaged модуль не обробить помилку і не викличе для неї IObject_release.

Висновок
Насправді, поки ми займалися інтеграцією модуля, ми випробували величезну кількість емоцій, вивчили чимало нецензурних слів і навчилися спати стоячи. Напевно, ми повинні хотіти, щоб ця стаття комусь знадобилася, але не дай бог. Звичайно вирішувати проблеми управління пам'яттю, успадкування та передачі винятків було весело. Але ми інтегрували далеко не три класи і було в них далеко не по одному методу. Це був тест на витривалість.

Якщо ви зіткнетеся з таким завданням, то ось вам порада: любіть Sublime Text, регулярні вирази і сніппети. Цей невеликий набір вберіг нас від алкоголізму.

P. S. Робочий приклад інтеграції бібліотеки доступний за адресою github.com/simbirsoft-public/pinvoke_example

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.