- PVSM.RU - https://www.pvsm.ru -

Внедрение кода с пользой

Внедрение кода с пользой

В статье описан способ построения моста между неуправляемым и управляемым кодом на примере математического пакета Mathcad. На картинке показан пример, как бурундук Тот собирается обрабатывать своё изображение средствами математического пакета. Для этого он «использовал» пользовательскую функцию, написанную на VB.Net, в которой реализована возможность подключения к веб-камере и создания снимка. Результат работы функции сразу доступен в рабочем документе.

Исходники

Для нетерпеливых, кто хочет понять всё сразу, пробежав по-диагонали код, указываю хранилище: NetEFI [1]. Там же можно найти тестовые пользовательские библиотеки на трёх языках: c#, vb.net и c++/cli (VS2012, .Net 2.0, x86-32). Пока доступна только 32-разрядная реализация.

Предыстория

В математической программе Mathcad существует возможность подключения сторонних библиотек. Называется этот интерфейс User EFI и был разработан больше 10 лет тому назад. С тех пор он не менялся вообще, хотя сам Mathcad изменился до неузнаваемости. Было время, когда этот интерфейс выкинули из пакета, но старые пользователи затребовали его обратно и в новых версиях Mathcad Prime этот раритетный интерфейс снова живее всех живых.

Существует довольно доходчивое руководство по созданию пользовательских библиотек, я привёл его в конце статьи. Если вкратце, то процесс выглядит примерно так. Мы создаём обычную dll, где в точке входа, т.е. при её загрузке, регистрируем наши функции. При этом, в описателе функции указываем её адрес для последующего вызова из Mathcad напрямую. Кроме этого, ещё можно зарегистрировать одну таблицу с сообщениями об ошибках. Результат, возвращаемый функцией пользователя в случае ошибки, может использоваться для выбора сообщений из этой таблицы. Вот в общем и вся кухня.

Описатель функции выглядит так:

Структура FUNCTIONINFO

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-битных машин. Хотя мне говорили [2], что изменений будет не много.

Использование глобальных переменных нехорошо, но работа в Mathcad не предполагает одновременный вызов нескольких функций. Там всё делается по-порядку, друг за другом.

В сборке-посреднике реализованы ещё некоторые идеи, которые позволяют полно использовать старый интерфейс в новом окружении. Это касается обработки ошибок и об этом нужно писать отдельно. Весь основной код сосредоточен в одном единственном классе Manager (netefi.cpp). Разбирая тестовые примеры, можно понять как работать с интерфейсом IFunction. Все тестовые примеры на разных языках делают одно и то же, и называются почти одинаково.

Примеры тестируются в Mathcad 15 и Mathcad Prime 3.0. Поскольку сам интерфейс User EFI не менялся больше 10 лет (и вряд ли уже изменится), то можно использовать описанный метод и в других версиях Mathcad, начиная, наверное, с 11 версии. В Mathcad Prime 3.0 пользовательским функциям дали новое название — Custom Functions, хотя начинка та же.

Тестовые примеры

Как было указано выше, вы можете найти их тут [1]. Но статья была бы не полной, если не показать конкретный вид .net пользовательских функций для Mathcad.

Посмотрим как будет выглядеть функция «эхо» для одного строкового параметра.

C# вариант

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;
    }

}

VB.Net вариант

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

С++/CLI вариант

#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 [1].
2. Creating a User DLL [3] (pdf).
3. .Net User EFI interface [4] (ветка на основном форуме PTC).
4. Исходники и сборки примера с веб-камерой [5] (в той же ветке ниже).
5. Mathcad EFI plugin [6] (другой мой проект, который выполняет обратную функцию — вызывает неуправляемый код из управляемого).

Автор: ViacheslavMezentsev

Источник [7]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/c-3/63648

Ссылки в тексте:

[1] NetEFI: https://github.com/ViacheslavMezentsev/NetEFI

[2] мне говорили: http://stackoverflow.com/questions/24466513/calculating-jmp-instructions-address-x86-64

[3] Creating a User DLL: http://en.smath.info/forum/resource.ashx?a=1475

[4] .Net User EFI interface: http://communities.ptc.com/message/247400#247400

[5] Исходники и сборки примера с веб-камерой: http://communities.ptc.com/message/247740#247740

[6] Mathcad EFI plugin: http://en.smath.info/forum/yaf_postst755_Mathcad-EFI-plugin.aspx

[7] Источник: http://habrahabr.ru/post/227455/