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

в 11:18, , рубрики: .net, c++, exceptions, memory management, PInvoke, wrapper, Блог компании SimbirSoft

В статье рассмотрена полная интеграция 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 среды.
Unmanaged C++ library в .NET. Полная интеграция - 1

Интеграция интерфейса

Для интеграции интерфейсов нам потребуется обратная связь. Т.е. для полноценного использования интегрируемого модуля нам нужна возможность реализации его интерфейсов. Реализация связана с определением методов в 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++ library в .NET. Полная интеграция - 2

Интеграция классов с поддержкой наследования

Задача — интегрировать класс и предоставить возможность переопределения его методов. Указатель на класс мы будем отдавать в 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);
    }

    //...
}

Unmanaged C++ library в .NET. Полная интеграция - 3
Такой класс можно смело применять в managed коде, наследовать, переопределять его методы, передавать указатель на него в unmanaged среду. Даже если мы не переопределяли никаких методов, мы всё равно передадим указатель на UnmanagedC. Это не очень рационально, учитывая, что unmanaged код будет вызывать методы unmanaged класса C транслируя вызовы через managed код. Но такова цена за возможность переопределения методов. В примере, прикреплённом к статье, этот случай продемонстрирован, с помощью вызова метода method у класса D. Если посмотреть на callstack, можно увидеть такую последовательность:
Unmanaged C++ library в .NET. Полная интеграция - 4

Исключения

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());
    }

    virtual 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

Автор: SimbirSoft

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js