Вам приходилось сталкиваться с необходимостью взаимодействия кода на C# и native-C++ (или скорее С)? Причины могли быть разными: библиотека уже есть, на С/С++ написать проще, разработка частей приложения ведётся разными командами, _______________ (нужное вписать).
Известно, что языки базируются на совершенно разных наборах аксиом.
В С# (CLR, если точнее) вы имеете дело с типами фиксированных размеров (за редкими оговорками), код может быть скомпилирован JIT-компилятором под любую из поддерживаемых целевых платформ (если явно не оговорено иное).
В мире C++ всё совсем иначе: одни и те же типы могут иметь разные размеры при компиляции на разные платформы (привет, size_t), код генерируется по-разному для разных платформ, операционных систем и прочих прелестей.
Под катом будем пробовать их подружить с учётом указанных особенностей.
Для взаимодействия управляемого (managed) с неуправляемым (native, unmanaged) кода, при котором в managed-приложение подключаются unmanaged-библиотеки, существует механизм Platform Invoke (p/Invoke). Такое взаимодействие классифицируется как внутрипроцессное.
Оно имеет следующие ограничения:
Список, конечно, неполный, но даёт представление о том, что происходит.
Мы не будем рассматривать все аспекты работы с 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-код через функцию обратного вызова:
Обратите внимание, что здесь явно указаны размеры типов данных и конвенции вызовов. Поскольку мы взаимодействуем с другим языком, нам приходится это знать, и правила написания портируемого кода на С++ здесь не работают. Зато, в отличие от типов вроде size_t, мы всегда знаем, какому типу на C# фиксированного размера он соответствует.
Здесь есть одна тонкость: указатель, который в C++ выглядит как void* или T*, имеет разный размер для разных платформ, но при этом со стороны C# он транслируется в специальный тип IntPtr, который также имеет переменный размер. Так что с маршалингом указателей нам помогает сам компилятор.
Когда компилятор оперирует именами, он их преобразует, кодируя в них типы объектов, аргументов, возвращаемых значений, конвенции вызова и много чего ещё. Эта операция называется декорированием (decoration, mangling). Так, имя функции компилятором от Microsoft преобразуется к виду ?ProcessData@@YGHHHP6GXPBD@Z@Z или ?ProcessData@@YAHHHP6AXPEBD@Z@Z (найдите одно отличие — оно зависит от размера указателя). Вы ведь видели что-то подобное, когда ругался линковщик в С++-проектах?
Работать с такими именами неудобно, поэтому мы попросим компилятор во внешнем программном интерфейсе привести их к более читаемому виду, добавив в объявление функции
Будем работать с __stdcall, потому как в Windows принято использовать эту конвенцию при работе с библиотеками.
Дальше для того, чтобы импортировать эту функцию, достаточно было бы написать следующий код:
Использование могло бы выглядеть как:
Но если у нас код выполняется в 64-битной среде, то при загрузке класса будет поднято исключение BadImageFormatException, то есть попытка загрузить образ библиотеки несовместимого формата. Надеюсь, пояснять, почему образы несовместимы, не нужно. При импорте 64-битной библиотеки из 32-битной среды будет та же проблема.
Конечно, можно было бы сказать, что мы стремительно завершаем второе десятилетие XXI века, и пора хоронить 32-битные системы, но я бы не стал торопиться хотя бы потому, что у меня есть планшет на винде с 32-битной системой, а ещё есть старый парк железа на работе, где тоже 32-битки вертятся. И вообще, подход будем справедлив и переходе на другие архитектуры процессоров (мы же доживём до того счастливого момента, когда ARM-ы и прочие Байкалы будут поддерживаться в дотнере полном объёме?).
В этом коде есть ещё одна проблема, но мы её разберём позже.
Теперь давайте займёмся полноценным импортом. Возьмём на заметку тот факт, что загрузка типов в .NET ленивая, то есть пока какой-то класс не понадобится среде выполнения, он не будет разобран и скомпилирован. То есть если мы не обращаемся к типу, там может быть импорт некорректной библиотеки.
Первое, что нужно сделать — это спрятать импортируемые методы от постороннего взгляда. Вообще, отдавать что-то из внутренней кухни, слишком торчащее наружу — плохо. Импортируемые методы будем делать приватными, а наружу предоставим методы-обёртки. А список методов вынесем в интерфейс.
Обратите внимание, что классы объявлены как внутренние, а интерфейс — публичным. Зря я, конечно, не сделал библиотеку-обёртку отдельно от приложения, ну да ладно: идея должна быть понятной.
Дальше нужно сделать загрузчик, который отдаст экземпляр нужного класса в зависимости от битности текущей среды выполнения.
Здесь сделано предположение о том, что у нас выбор всего из двух вариантов. В общем случае, именно здесь принимается решение о том, какие классы использовать для какой реализации. А ещё здесь же можно делать много других странных вещей.
Использование уже достаточно простое:
Предложенное решение имеет большой недостаток: достаточно большой объём дублирующегося стереотипного кода: сигнатура каждой импортируемой функции присутствует в пяти местах, и используется тремя разными способами. И ещё пара мест в native-коде. К сожалению, какого-то способа минимизировать количество вариантов, кроме как кодогенерацией, я не придумал. Но если получится сделать что-то более элегантное, я обязательно напишу.
Я обещал указать ещё на одну проблему этого кода. Она не относится напрямую к обсуждаемой теме, но я всё равно хочу её упомянуть. Но под спойлером.
<spoiler title=«Проблема висящих указателей>Предположим, что вызов у нас асинхронный, например, при нажатии на кнопку у нас будет выполняться в фоне некоторый код, который генерирует протокол работы и ещё что-нибудь полезное, но обработчик кнопки завершил работу, и, следовательно, все локальные объекты могут быть собраны сборщиком мусора. А у нас есть такой объект и очень важный: делегат, инкапсулирующий функцию обратного вызова. Через произвольный промежуток времени код просто упадёт с непонятной ошибкой обращения либо к нулевому указателю, либо, что ещё хуже, к произвольной области памяти. А всё потому, что указатель на функцию в unmanaged-коде ещё живой, а делегат, на который он ссылается, уже нет, скорее всего, его память очищена и теперь у нас есть висящий указатель.
Чтобы такого не происходило, нужно увеличить время жизни делегата, либо сохранив его как поле в объекте, либо каким-то иным образом, в зависимости от обстоятельств.
А ещё в этом случае следует подумать о том, как unmanaged-поток останавливать.
У меня на сегодня всё. Надеюсь, кому-то это будет полезно.
Известно, что языки базируются на совершенно разных наборах аксиом.
В С# (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-поток останавливать.
У меня на сегодня всё. Надеюсь, кому-то это будет полезно.