Pull to refresh

Взаимодействие C# и C++ кроссплатформенно

Reading time 7 min
Views 44K
Вам приходилось сталкиваться с необходимостью взаимодействия кода на 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-поток останавливать.

У меня на сегодня всё. Надеюсь, кому-то это будет полезно.
Tags:
Hubs:
+35
Comments 27
Comments Comments 27

Articles