Вам приходилось сталкиваться с необходимостью взаимодействия кода на C# и native-C++ (или скорее С)? Причины могли быть разными: библиотека уже есть, на С/С++ написать проще, разработка частей приложения ведётся разными командами, _______________ (нужное вписать).

Известно, что языки базируются на совершенно разных наборах аксиом.

В С# (CLR, если точнее) вы имеете дело с типами фиксированных размеров (за редкими оговорками), код может быть скомпилирован JIT-компилятором под любую из поддерживаемых целевых платформ (если явно не оговорено иное).

В мире C++ всё совсем иначе: одни и те же типы могут иметь разные размеры при компиляции на разные платформы (привет, size_t), код генерируется по-разному для разных платформ, операционных систем и прочих прелестей.

Под катом будем пробовать их подружить с учётом указанных особенностей.

Для взаимодействия управляемого (managed) с неуправляемым (native, unmanaged) кода, при котором в managed-приложение подключаются unmanaged-библиотеки, существует механизм Platform Invoke (p/Invoke). Такое взаимодействие классифицируется как внутрипроцессное.
Оно имеет следующие ограничения:

  • Возможно вызвать только unmanaged-функции, но нельзя обратиться к экспортируемым переменным;
  • Импортируемые функции становятся статическими методами классов;
  • Импортируемые функции объявляются как extern и маркируются специальным атрибутом DllImport, который указывает компилятору на необходимость генерации специального кода маршализации вызовов;
  • В процессе вызова unmanaged-кода поток, который его выполняет, не может быть прерван, в отличие от кода на C#. Так, если на нём вызвать Abort или Interrupt, то подъём исключений будет отложен до возвращения в управляемый контекст;

Список, конечно, неполный, но даёт представление о том, что происходит.

Мы не будем рассматривать все аспекты работы с p/Invoke, а сосредоточимся только на том, как для p/Invoke решить проблему вызова на разных архитектурах (на примере x86 и x64), и не будем касаться других архитектур и операционных систем, однако того, что будет описано в статье, теоретически достаточно чтобы развить мысль дальше. Будем считать это домашним заданием для тех, кому это нужно.

Итак, давайте раскручивать клубок.

Нам нужно импортировать некоторый набор функций из unmanaged-библиотеки на C++ для вызова их из кода на C#, при этом поддерживать нужно одновременно две архитектуры: x86 и x64, выбирая их в зависимости от того, на какой из платформ работает хост-приложение на C#.

Я использую MS Visual Studio 2015 Community Edition для примера, но всё должно работать и при разработке с использованием других средств. CMake и прочими прелестями (пока) не заморачиваемся.

Исходный код с процессом эволюции доступен на гитхабе по ссылке.

После создания решения с двумя проектам (CrossPlatformInterop типа Console Application на C# и CrossPlatformLibrary типа Win32 Project / DLL) сконфигурируем их так, чтобы выходной каталог был $(SolutionDir)Output\$(Configuration)\, а для C++-проекта имя собираемого файла — $(ProjectName)-$(PlatformShortName).dll для того, чтобы на x86 и x64 получались разные файлы.

Результаты конфигурации можно посмотреть в ветке project-setup в репозитории.

Реализуем простенькую функцию на С++, которая принимает 2 числа и имитирует бурную деятельность в виде форматирования какой-то строки и передачи её в managed-код через функцию обратного вызова:

// header

typedef void(__stdcall* Notification)(const char*);

int32_t CROSSPLATFORMLIBRARY_API __stdcall ProcessData(int32_t start, int32_t count, Notification notification);

Исходный код

// source

int32_t __stdcall ProcessData(int32_t start, int32_t count, Notification notification)
{
    if (notification == nullptr)
    {
        return 0;
    }
    int32_t result = 0;
    for (int32_t i = 0; i < count; ++i)
    {
        char buffer[64];
        result += sprintf_s(buffer, "Notification %d from C++", i + start);
        notification(buffer);
        Sleep(rand() % 500 + 500);
    }
    return result;
}

Обратите внимание, что здесь явно указаны размеры типов данных и конвенции вызовов. Поскольку мы взаимодействуем с другим языком, нам приходится это знать, и правила написания портируемого кода на С++ здесь не работают. Зато, в отличие от типов вроде size_t, мы всегда знаем, какому типу на C# фиксированного размера он соответствует.

Здесь есть одна тонкость: указатель, который в C++ выглядит как void* или T*, имеет разный размер для разных платформ, но при этом со стороны C# он транслируется в специальный тип IntPtr, который также имеет переменный размер. Так что с маршалингом указателей нам помогает сам компилятор.

Когда компилятор оперирует именами, он их преобразует, кодируя в них типы объектов, аргументов, возвращаемых значений, конвенции вызова и много чего ещё. Эта операция называется декорированием (decoration, mangling). Так, имя функции компилятором от Microsoft преобразуется к виду ?ProcessData@@YGHHHP6GXPBD@Z@Z или ?ProcessData@@YAHHHP6AXPEBD@Z@Z (найдите одно отличие — оно зависит от размера указателя). Вы ведь видели что-то подобное, когда ругался линковщик в С++-проектах?

Работать с такими именами неудобно, поэтому мы попросим компилятор во внешнем программном интерфейсе привести их к более читаемому виду, добавив в объявление функции extern "C". Если использовать конвенцию вызова __cdecl, то вопросов нет, но если использовать __stdcall, то имя всё равно не станет «нормальным», а будет иметь вид _ProcessData@12 для x86 (после собаки указано количество занятых на стеке байтов). Можно, конечно, сделать def-файл в проекте и указать там список функций для экспорта, но мы так не будем делать.

Будем работать с __stdcall, потому как в Windows принято использовать эту конвенцию при работе с библиотеками.

Дальше для того, чтобы импортировать эту функцию, достаточно было бы написать следующий код:

    public class LibraryImport
    {
        [UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet=CharSet.Ansi)]
        public delegate void Notification(string value);

        [DllImport("CrossPlatformLibrary-x86", CallingConvention=CallingConvention.StdCall)]
        public static extern int ProcessData(int start, int count, Notification notification);
    }

Использование могло бы выглядеть как:

LibraryImport.ProcessData(1, 10, s => Console.WriteLine(s));

Но если у нас код выполняется в 64-битной среде, то при загрузке класса будет поднято исключение BadImageFormatException, то есть попытка загрузить образ библиотеки несовместимого формата. Надеюсь, пояснять, почему образы несовместимы, не нужно. При импорте 64-битной библиотеки из 32-битной среды будет та же проблема.

Конечно, можно было бы сказать, что мы стремительно завершаем второе десятилетие XXI века, и пора хоронить 32-битные системы, но я бы не стал торопиться хотя бы потому, что у меня есть планшет на винде с 32-битной системой, а ещё есть старый парк железа на работе, где тоже 32-битки вертятся. И вообще, подход будем справедлив и переходе на другие архитектуры процессоров (мы же доживём до того счастливого момента, когда ARM-ы и прочие Байкалы будут поддерживаться в дотнере полном объёме?).

В этом коде есть ещё одна проблема, но мы её разберём позже.

Теперь давайте займёмся полноценным импортом. Возьмём на заметку тот факт, что загрузка типов в .NET ленивая, то есть пока какой-то класс не понадобится среде выполнения, он не будет разобран и скомпилирован. То есть если мы не обращаемся к типу, там может быть импорт некорректной библиотеки.

Первое, что нужно сделать — это спрятать импортируемые методы от постороннего взгляда. Вообще, отдавать что-то из внутренней кухни, слишком торчащее наружу — плохо. Импортируемые методы будем делать приватными, а наружу предоставим методы-обёртки. А список методов вынесем в интерфейс.

Исходный код
    [UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet = CharSet.Ansi)]
    public delegate void Notification(string value);

    public interface ILibraryImport
    {
        int ProcessData(int start, int count, Notification notification);
    }

    internal class LibraryImport_x86 : ILibraryImport
    {
        [DllImport("CrossPlatformLibrary-x86", CallingConvention = CallingConvention.StdCall, ExactSpelling = false, EntryPoint = "_ProcessData@12")]
        private static extern int ProcessDataInternal(int start, int count, Notification notification);

        public int ProcessData(int start, int count, Notification notification)
        {
            return ProcessDataInternal(start, count, notification);
        }
    }

    internal class LibraryImport_x64 : ILibraryImport
    {
        [DllImport("CrossPlatformLibrary-x64", CallingConvention = CallingConvention.StdCall, ExactSpelling = false, EntryPoint = "ProcessData")]
        private static extern int ProcessDataInternal(int start, int count, Notification notification);

        public int ProcessData(int start, int count, Notification notification)
        {
            return ProcessDataInternal(start, count, notification);
        }
    }

Обратите внимание, что классы объявлены как внутренние, а интерфейс — публичным. Зря я, конечно, не сделал библиотеку-обёртку отдельно от приложения, ну да ладно: идея должна быть понятной.

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

Исходный код
    public static class LibraryImport
    {
        public static ILibraryImport Select()
        {
            if (IntPtr.Size == 4) // 32-bit application
            {
                return new LibraryImport_x86();
            }
            else // 64-bit application
            {
                return new LibraryImport_x64();
            }
        }
    }


Здесь сделано предположение о том, что у нас выбор всего из двух вариантов. В общем случае, именно здесь принимается решение о том, какие классы использовать для какой реализации. А ещё здесь же можно делать много других странных вещей.

Использование уже достаточно простое:

    class Program
    {
        static void Main(string[] args)
        {
            ILibraryImport import = LibraryImport.Select();
            import.ProcessData(1, 10, s => Console.WriteLine(s));
        }
    }

Предложенное решение имеет большой недостаток: достаточно большой объём дублирующегося стереотипного кода: сигнатура каждой импортируемой функции присутствует в пяти местах, и используется тремя разными способами. И ещё пара мест в native-коде. К сожалению, какого-то способа минимизировать количество вариантов, кроме как кодогенерацией, я не придумал. Но если получится сделать что-то более элегантное, я обязательно напишу.

Я обещал указать ещё на одну проблему этого кода. Она не относится напрямую к обсуждаемой теме, но я всё равно хочу её упомянуть. Но под спойлером.

<spoiler title=«Проблема висящих указателей>Предположим, что вызов у нас асинхронный, например, при нажатии на кнопку у нас будет выполняться в фоне некоторый код, который генерирует протокол работы и ещё что-нибудь полезное, но обработчик кнопки завершил работу, и, следовательно, все локальные объекты могут быть собраны сборщиком мусора. А у нас есть такой объект и очень важный: делегат, инкапсулирующий функцию обратного вызова. Через произвольный промежуток времени код просто упадёт с непонятной ошибкой обращения либо к нулевому указателю, либо, что ещё хуже, к произвольной области памяти. А всё потому, что указатель на функцию в unmanaged-коде ещё живой, а делегат, на который он ссылается, уже нет, скорее всего, его память очищена и теперь у нас есть висящий указатель.

Чтобы такого не происходило, нужно увеличить время жизни делегата, либо сохранив его как поле в объекте, либо каким-то иным образом, в зависимости от обстоятельств.

А ещё в этом случае следует подумать о том, как unmanaged-поток останавливать.

У меня на сегодня всё. Надеюсь, кому-то это будет полезно.

Комментарии (27)


  1. kekekeks
    23.01.2018 16:19
    +1

    Обратите внимание, что здесь явно указаны размеры типов данных и конвенции вызовов. Поскольку мы взаимодействуем с другим языком, нам приходится это знать, и правила написания портируемого кода на С++ здесь не работают. Зато, в отличие от типов вроде size_t, мы всегда знаем, какому типу на C# фиксированного размера он соответствует.

    Для size_t используйте в шарповых декларациях IntPtr. Он соответствует размеру указателя на целевой платформе.


    1. mayorovp
      23.01.2018 17:14

      Лучше UIntPtr. А IntPtr подойдет для ptrdiff_t или intptr_t.


  1. kekekeks
    23.01.2018 16:21

    Если у вас предполагается кроссплатформенность, то смысл городить огород с stdcall и с подчёркиваниями перед именами? Если выставить cdecl, то никаких проблем на никсах не возникнет.


    1. a-tk Автор
      23.01.2018 16:24

      А что насчёт short, например?


  1. kekekeks
    23.01.2018 16:23

    И вообще, подход будем справедлив и переходе на другие архитектуры процессоров (мы же доживём до того счастливого момента, когда ARM-ы и прочие Байкалы будут поддерживаться в дотнере полном объёме?).

    Не будет, сейчас принято писать имя библиотеки как есть и класть в nuget-пакет версии оной под поддерживаемые платформы. В дальнейшем загрузчик CoreCLR и dotnet publish сами разбираются, какую версию использовать.


    Если так хочется это разруливать в рантайме — дёргайте вручную LoadLibrary/GetProcAddress/dlopen/dlsym и Marshal.GetDelegateFromFunctionPointer вместо ваших тонн копипасты.


    1. a-tk Автор
      23.01.2018 16:26

      Осталось дождаться только, когда на них можно будет создавать полноценные UI для desktop-приложений.


      1. kekekeks
        23.01.2018 16:30

        1. a-tk Автор
          23.01.2018 16:35

          Я знаю и смиренно жду.


      1. kekekeks
        23.01.2018 16:33

        Что касается использования на десктопном фреймворке — это тоже решается средствами NuGet и MSBuild. Рекомендую посмотреть в сторону SkiaSharp, где не прибегают к подобного рода "ухищрениям" с несколькими декларациями.


        1. a-tk Автор
          23.01.2018 16:36

          Посмотрю, спасибо.


    1. a-tk Автор
      23.01.2018 16:28

      Если так хочется это разруливать в рантайме — дёргайте вручную LoadLibrary/GetProcAddress/dlopen/dlsym и Marshal.GetDelegateFromFunctionPointer вместо ваших тонн копипасты.

      Копипаста появится в другом месте, и читабельности явно не добавится.


      1. kekekeks
        23.01.2018 16:30

        https://github.com/AvaloniaUI/Avalonia/blob/master/src/Gtk/Avalonia.Gtk3/Interop/Native.cs#L23 — покажите мне тут прибавившуюся копипасту.


        1. a-tk Автор
          23.01.2018 16:36

          Разве я что-то говорил про копипасту в коллбэках?


          1. kekekeks
            23.01.2018 16:37

            А вы где-то видите колбеки?


            1. a-tk Автор
              23.01.2018 16:40

              Тупанул.
              Загрузка полей класса Native через рефлексию идёт?



  1. a-tk Автор
    23.01.2018 16:28

    (мимо)


  1. kekekeks
    23.01.2018 16:29

    Чтобы такого не происходило, нужно увеличить время жизни делегата, либо сохранив его как поле в объекте, либо каким-то иным образом, в зависимости от обстоятельств.

    Чтобы такого не происходило, в сишных библиотеках используется паттерн вида:


    typedef void(* callback_func)(void*);
    void foo(callback_func* func, void* user_data)

    При использовании оных из C# callback_func является статической функцией, делегат к которой аллоцируется в единственном экземпляре. В user_data в дальнейшем передаётся свежеаллоцированый GCHandle, который в этой статической функции разименуется. Это даёт возможность чёткого контроля за временем жизни объекта в нативной среде.


    1. a-tk Автор
      23.01.2018 16:33

      В целом — согласен, но представим себе, что библиотека уже существует и ковырять её дорого и/или геморройно.


  1. Flaksirus
    23.01.2018 18:36

    Я на дотнет коре похожую проблему решал:
    вот проектик github.com/flaksirus/CrossPlatformLibraryLoader


  1. huhen
    24.01.2018 09:31

    Чтобы такого не происходило, нужно увеличить время жизни делегата, либо сохранив его как поле в объекте, либо каким-то иным образом, в зависимости от обстоятельств.

    Мне попадалась информация, что сохранение как поле в объекте не гарантирует что он(делегат) не будет перемещен в памяти.
    Я делал так:
    private GCHandle gch_ppp_output;
    
    internal void SetupCallbacksPPP(ppp_output_d ppp_output)
    {
        CleanUpPPP();
        gch_ppp_output = GCHandle.Alloc(ppp_output);
        var p_ppp_output = Marshal.GetFunctionPointerForDelegate(ppp_output);
        //здесь можно передать нативной функции значение p_ppp_output для callback
    }
    
    //не забываем освободить 
    internal void CleanUpPPP()
    {
        if (gch_ppp_output != null && gch_ppp_output.IsAllocated)
            gch_ppp_output.Free();
    }
    


    1. mayorovp
      24.01.2018 10:30

      Простой GCHandle.Alloc тоже не защищает от перемещения в памяти; его имеет смысл использовать только если вы полностью передаете владение управляемым объектом неуправляемому коду. В противном случае он ничем не лучше статического поля.


      Но в данном случае это не требуется. Ведь перемещается же делегат в управляемой куче — а исполнение кода в ней запрещено. Следовательно, при маршалинге для делегата генерируется код-заглушка в совершенно другой области памяти, где нет никакого сборщика мусора и перемещения объектов. Все что требуется от управляемого кода — удержать в памяти сам делегат достаточно долго.


  1. anikavoi
    24.01.2018 20:42

    Где ж ты в декабре был!
    Я чуть не рехнулся, пока с++ dll прикрутил к C#!

    На самом деле, после разборок с вызовом функции из dll наступает неприятность с передачей структур. Вот об этом еще напиши.


    1. mayorovp
      24.01.2018 21:00

      А что не так с передачей структур? Там самое нудное — объявлять одинаковые структуры в двух языках, но не сказал бы что это прямо уж так сложно...


      Передаются структуры как ref или out, можно еще как IntPtr передавать (с ручным копированием).


      1. a-tk Автор
        25.01.2018 16:08

        Со структурами проблем нет, надо маппить правильно и не забывать обвешивать атрибутом StructLayout со стороны .NET-а, а ещё учитывать выравнивание (или бороться с ним) со стороны Си.


      1. Myzrael
        25.01.2018 22:01

        можно idl использовать, тогда объявление будет одно.


        1. mayorovp
          25.01.2018 22:56

          А он вообще кроссплатформенный? Что-то не гуглятся утилиты под линукс…