В статье описан способ построения моста между неуправляемым и управляемым кодом на примере математического пакета Mathcad. На картинке показан пример, как бурундук Тот собирается обрабатывать своё изображение средствами математического пакета. Для этого он «использовал» пользовательскую функцию, написанную на VB.Net, в которой реализована возможность подключения к веб-камере и создания снимка. Результат работы функции сразу доступен в рабочем документе.
Исходники
Для нетерпеливых, кто хочет понять всё сразу, пробежав по-диагонали код, указываю хранилище: NetEFI. Там же можно найти тестовые пользовательские библиотеки на трёх языках: c#, vb.net и c++/cli (VS2012, .Net 2.0, x86-32). Пока доступна только 32-разрядная реализация.
Предыстория
В математической программе Mathcad существует возможность подключения сторонних библиотек. Называется этот интерфейс User EFI и был разработан больше 10 лет тому назад. С тех пор он не менялся вообще, хотя сам Mathcad изменился до неузнаваемости. Было время, когда этот интерфейс выкинули из пакета, но старые пользователи затребовали его обратно и в новых версиях Mathcad Prime этот раритетный интерфейс снова живее всех живых.
Существует довольно доходчивое руководство по созданию пользовательских библиотек, я привёл его в конце статьи. Если вкратце, то процесс выглядит примерно так. Мы создаём обычную dll, где в точке входа, т.е. при её загрузке, регистрируем наши функции. При этом, в описателе функции указываем её адрес для последующего вызова из Mathcad напрямую. Кроме этого, ещё можно зарегистрировать одну таблицу с сообщениями об ошибках. Результат, возвращаемый функцией пользователя в случае ошибки, может использоваться для выбора сообщений из этой таблицы. Вот в общем и вся кухня.
Описатель функции выглядит так:
typedef LRESULT (* LPCFUNCTION ) ( void * const, const void * const, ... );
// The FUNCTIONINFO structure contains the information that Mathcad uses to register a
// user function. Refer below for each member and its description.
typedef struct tagFUNCTIONINFO {
// Points to a NULL-terminated string that specifies the name of the user
// function.
char * lpstrName;
// Points to a NULL-terminated string that specifies the parameters of the
// user function.
char * lpstrParameters;
// Points to a NULL-terminated string that specifies the function description.
char * lpstrDescription;
// Pointer to the code that executes the user function.
LPCFUNCTION lpfnMyCFunction;
// Specifies the type of value returned by the function. The values are
// COMPLEX_ARRAY or COMPLEX_SCALAR.
long unsigned int returnType;
// Specifies the number of arguments expected by the function. Must be
// between 1 and MAX_ARGS.
unsigned int nArgs;
// Specifies an array of long unsigned integers containing input parameter
// types.
long unsigned int argType[ MAX_ARGS ];
} FUNCTIONINFO;
Проблема в том, что сегодня можно было бы гораздо удобнее писать свои функции, если бы мы делали это на .net языках. Но прямой путь для этого лежит через использование C++/CLI. Вариант «обёртывания» каждой пользовательской функции через переходник на C++/CLI или маршалинг структур, думаю, можно сразу отметать как непрактичный и требующий нетривиальных познаний от пользователя математической программы. Я хочу предложить универсальную «обёртку», которую назвал .Net User EFI.
Возникает вопрос, а как создать универсальную функцию, которую можно было бы регистрировать вместо всех функций всех подключаемых сборок, но при этом в точке входа она обладала бы всей необходимой информацией для вызова конкретной функции из конкретной сборки. Библиотека посредник, в которой такая функция находится, должна автоматически работать с любым количеством подключаемых сборок и функций в них.
Для реализации такой универсальности есть одна существенная проблема. Mathcad требует указывать адрес вызываемой функции, сам же прототип объявлен как имеющий переменное количество параметров. Получается, что в точке входа универсальной функции стек с параметрами будет иметь разный размер и передать эту информацию при вызове функции стандартными средствами нет никакой возможности, т.к. она определяется самим скомпилированным кодом. В структуре выше только сам адрес выступает в качестве параметра, по которому мы могли бы отличить вызов одной функции от другой.
И тут наша мысль должна прийти к одному известному решению, которое называется инъекцией кода. На хабре не раз об этом писали, но вот практических полезных примеров использования такой техники можно найти не так много. В каком-то смысле мы тоже будем перехватывать вызовы функций из dll, но в нашем случае всё будет выглядеть немного специфичней, но гораздо проще.
Идея
Итак, что же мы будем инъецировать, внедрять, куда и зачем. Ещё раз проясним ситуацию. Мы хотим написать универсальную функцию, которая будет единообразно обрабатывать все вызовы и распределять их в зависимости от типа вызываемой функции. Mathcad не должен ничего «заподозрить», а у нас должна откуда-то взяться дополнительная информация в точке входа универсальной функции о параметрах вызова.
Решение будет в динамическом формировании кода по адресу, который мы регистрируем в Mathcad. Мы зарезервируем в памяти много места под динамический код. Этот код будет осуществлять вспомогательную работу по передаче параметров универсальной функции. Наперёд скажу, что нам достаточно два параметра, это номер сборки в массиве загруженных сборок и номер функции из сборки. Существует два пути передачи параметров: глобальные переменные и стек. Я выбрал первый вариант, т.к. нарушить баланс стека (в котором находятся параметры) легко, а вот восстановить его в нашем случае, я думаю, будет сложно.
Забыл упомянуть, что типов параметров у функции пользователя всего три и все они передаются по указателю: MCSTRING, COMPLEXSCALAR и COMPLEXARRAY. Максимальное их число также ограничено — 10 штук. Это упрощает реализацию разбора параметров в универсальной функции.
Внедрение
Теперь мы морально готовы, чтобы разобрать конкретную последовательность событий, которая должна происходить на этапе внедрения и после него.
Шаг 1. Пользователь создаёт .net класс, реализующей интерфейс IFunction, который содержит необходимую информацию о функции. Компилирует его в сборку и копирует в папку userefi. Также в этой папке должна находиться сборка-посредник, будем называть её netefi.
Шаг 2. При загрузке Mathcad сборка-посредник netefi воспринимается как пользовательская библиотека. Она осуществляет поиск всех .net сборок в текущей папке и перебора функций в них на предмет реализации интерфейса IFunction.
Шаг 3. netefi сохраняет информацию о сборках и функциях в них во внутренних массивах, при этом, чтобы определить функцию, нужно два числа: индекс сборки и индекс функции в ней.
Шаг 4. netefi перебирает все функции и регистрирует их в Mathcad стандартным образом, но в поле адреса структуры FUNCTIONINFO мы записываем ссылку на динамический код, вид которого определяется двумя индексами из предыдущего шага.
Вот так выглядит конкретная реализация метода внедрения:
static int assemblyId = -1;
static int functionId = -1;
static PBYTE pCode = NULL;
#pragma unmanaged
LRESULT CallbackFunction( void * out, ... ) {
return ::UserFunction( & out );
}
#pragma managed
// TODO: 64-bit.
void Manager::InjectCode( PBYTE & p, int k, int n ) {
// Пересылка константы (номера сборки) в глобальную переменную.
* p++ = 0xB8; // mov eax, imm32
p[0] = k;
p += sizeof( int );
* p++ = 0xA3; // mov [assemblyId], eax
( int * & ) p[0] = & assemblyId;
p += sizeof( int * );
// Пересылка константы (номера функции) в глобальную переменную.
* p++ = 0xB8; // mov eax, imm32
p[0] = n;
p += sizeof( int );
* p++ = 0xA3; // mov [functionId], eax
( int * & ) p[0] = & functionId;
p += sizeof( int * );
// jmp to CallbackFunction.
* p++ = 0xE9;
( UINT & ) p[0] = ( PBYTE ) ::CallbackFunction - 4 - p;
p += sizeof( PBYTE );
}
Метод InjectCode() вызывается в цикле при регистрации функций в Mathcad. Глобальные переменные assemblyId и functionId используются для определения типа функции во время её вызова. Работает это так. Mathcad для каждой функции получает ссылку на такой вот динамический код. При этом в assemblyId записывается индекс сборки, известный на момент загрузки (параметр k), в functionId записывается индекс функции — параметр n. Далее идёт безусловный переход на CallbackFunction(), в которой вызывается наша универсальная функция. Это сделано для того, чтобы можно было в UserFunction() вызывать управляемый код. Директивы unmanaged / managed не дадут этого сделать в CallbackFunction().
Заметьте, что параметром универсальной функции является ссылка на стек CallbackFunction(), т.е. на массив параметров (возвращаемое значение находится там же). Динамический код не портит нам стек, поэтому после завершения CallbackFunction() управление вернётся к Mathcad. Вот и вся магия.
Шаг 5. После того, как регистрация завершена, вы можете вызвать пользовательскую функцию в документе Mathcad. Универсальная функция UserFunction() теперь может восстановить тип функции пользователя по глобальным параметрам assemblyId и functionId и разобрать стек, зная количество и тип параметров.
Шаг 6. Каждый неуправляемый тип параметра функции заменяется на аналог: MCSTRING на String, COMPLEXSCALAR на TComplex (я не стал использовать Complex из .Net 4.0, чтобы не было конфликта) и COMPLEXARRAY на TComplex[,].
Шаг 7. Вызывается реализация метода IFunction.NumericEvaluation для функции. Возвращаемый результат проходит обратную последовательность преобразований и отдаётся в Mathcad.
О реализации
Думаю, что этот конкретный способ внедрения я объяснил более менее понятно. Что касается непосредственно самих исходников проекта, то стоит вкратце упомянуть окружение и некоторые детали. В качестве среды разработки используется Visual Studio 2012, язык C++/CLI, .Net Framework 2.0 (выставлен соответствующий режим в свойствах проектов). Поскольку динамический код, вообще говоря, зависит от разрядности и я ещё не знаю точно как привести его к 64-битному представлению, то все проекты настроены на компиляцию для 32-битных машин. Хотя мне говорили, что изменений будет не много.
Использование глобальных переменных нехорошо, но работа в Mathcad не предполагает одновременный вызов нескольких функций. Там всё делается по-порядку, друг за другом.
В сборке-посреднике реализованы ещё некоторые идеи, которые позволяют полно использовать старый интерфейс в новом окружении. Это касается обработки ошибок и об этом нужно писать отдельно. Весь основной код сосредоточен в одном единственном классе Manager (netefi.cpp). Разбирая тестовые примеры, можно понять как работать с интерфейсом IFunction. Все тестовые примеры на разных языках делают одно и то же, и называются почти одинаково.
Примеры тестируются в Mathcad 15 и Mathcad Prime 3.0. Поскольку сам интерфейс User EFI не менялся больше 10 лет (и вряд ли уже изменится), то можно использовать описанный метод и в других версиях Mathcad, начиная, наверное, с 11 версии. В Mathcad Prime 3.0 пользовательским функциям дали новое название — Custom Functions, хотя начинка та же.
Тестовые примеры
Как было указано выше, вы можете найти их тут. Но статья была бы не полной, если не показать конкретный вид .net пользовательских функций для Mathcad.
Посмотрим как будет выглядеть функция «эхо» для одного строкового параметра.
using System;
using NetEFI;
public class csecho: IFunction {
public FunctionInfo Info {
get {
return new FunctionInfo( "csecho", "s", "return string",
typeof( String ), new[] { typeof( String ) } );
}
}
public FunctionInfo GetFunctionInfo( string lang ) { return Info; }
public bool NumericEvaluation( object[] args, out object result ) {
result = args[0];
return true;
}
}
Imports NetEFI
Public Class vbecho
Implements IFunction
Public ReadOnly Property Info() As FunctionInfo _
Implements IFunction.Info
Get
Return New FunctionInfo("vbecho", "s", "return string", _
GetType([String]), New Type() {GetType([String])})
End Get
End Property
Public Function GetFunctionInfo(lang As String) As FunctionInfo _
Implements IFunction.GetFunctionInfo
Return Info
End Function
Public Function NumericEvaluation(args As Object(), ByRef result As Object) As Boolean _
Implements IFunction.NumericEvaluation
result = args(0)
Return True
End Function
End Class
#pragma once
using namespace System;
using namespace System::Text;
using namespace NetEFI;
public ref class cppecho: public IFunction {
public:
virtual property FunctionInfo^ Info {
FunctionInfo^ get() {
return gcnew FunctionInfo( "cppecho", "s", "return string",
String::typeid, gcnew array<Type^> { String::typeid } );
}
}
virtual FunctionInfo^ GetFunctionInfo(String^ lang) { return Info; }
virtual bool NumericEvaluation( array< Object^ > ^ args, [Out] Object ^ % result ) {
result = args[0];
return true;
}
};
Прочее
Хотя основной функционал уже практически готов, есть некоторые недоделки. К примеру, желательно, чтобы работа универсальной функции выполнялась в отдельном потоке. Эта одна из первых вещей, которые надо реализовать. Прерывание работы путём вызова isUserInterrupted никак не отражена в новом интерфейсе. Вся надежда пока на то, что сам Mathcad может прервать работу функции. Над этим буду думать и это перекликается с работой в потоке.
Текущий проект пока работает только на 32-битных системах. Для добавления 64-битных конфигураций нужно протестировать работу динамического кода на 64-битных системах. Пока нет такой возможности.
Работа с COM внутри пользовательской функции сейчас тоже, видимо, невозможна. Столкнулся я с этим, когда реализовывал функцию для создания снимка с веб-камеры. Один из стандартных вариантов предполагал использовать интерфейс к Clipboard, так вот он не заработал, сообщив о том, что поток должен быть с атрибутом STAThreadAttribute. Решил проблему через Graphics.CopyFromScreen. Тоже нужно разбираться.
Загрузка недостающих сборок также пока сделана не достаточно надёжно, т.к. используется Assembly::LoadFile(). Если же использовать Assembly::LoadFrom(), то Mathcad зависает в этом месте. Есть ещё проблема с отладкой смешанного кода. Почему-то она у меня не заработала как надо. Я практически в уме отлаживал код, спасали только логи.
Может быть кто-то делал что-то подобное и мог бы подсказать хорошие идеи, чтобы упростить мой код. Выслушаю все практические варианты. Было бы вообще замечательно, если бы кто заставил мой проект работать под отладчиком студии в смешанном режиме. Пока работают только точки останова в неуправляемом коде. В тестовых примерах бродить по коду можно, конечно.
Ссылки
1. Исходники и тестовые примеры на github.
2. Creating a User DLL (pdf).
3. .Net User EFI interface (ветка на основном форуме PTC).
4. Исходники и сборки примера с веб-камерой (в той же ветке ниже).
5. Mathcad EFI plugin (другой мой проект, который выполняет обратную функцию — вызывает неуправляемый код из управляемого).
Автор: ViacheslavMezentsev