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 среды.
079f2147399f46d4b499f51fe9dd8765.png

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


Для интеграции интерфейсов нам потребуется обратная связь. Т.е. для полноценного использования интегрируемого модуля нам нужна возможность реализации его интерфейсов. Реализация связана с определением методов в 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 и делаем не нужные привязки коллбэков. При желании этого можно избежать, но это уже тонкости, здесь мы их описывать не будем.
5206a8d8ddbb424fb520db8fd40cba21.png

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


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

    //...
}


e8eb9ab628a4472495afea24ca6ce1b6.png
Такой класс можно смело применять в managed коде, наследовать, переопределять его методы, передавать указатель на него в unmanaged среду. Даже если мы не переопределяли никаких методов, мы всё равно передадим указатель на UnmanagedC. Это не очень рационально, учитывая, что unmanaged код будет вызывать методы unmanaged класса C транслируя вызовы через managed код. Но такова цена за возможность переопределения методов. В примере, прикреплённом к статье, этот случай продемонстрирован, с помощью вызова метода method у класса D. Если посмотреть на callstack, можно увидеть такую последовательность:
6a301a8d10634ef8b585cab0a99f06b4.png

Исключения


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

    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

© Habrahabr.ru