Генерация P/Invoke сигнатур в C#. Нецелевое использование Interface Definition Language и OLE Automation Type Libraries

Это НЕ очередная статья о том что такое P/Invoke.

Итак, допустим в сферическом C# проекте необходимо использовать какую-либо технологию, отсутствующую в .NET, и все что у нас есть это Windows SDK 8.1 в котором имеется лишь набор заголовочных файлов для C/С++. Придется объявлять кучу типов, проверять корректность выравнивания структур и писать различные обертки. Это большое количество рутинной работы, и риск допустить ошибку. Можно конечно написать парсер заголовочных файлов… Тут просто и понятно все кроме количества требуемых на это человекочасов. Поэтому этот вариант отбрасываем и постараемся как либо иначе свести к минимуму количество необходимых действий для взаимодействия с unmanaged кодом.

Кроме того, полученный в результате код не будет зависеть от разрядности процесса, будет сохранена строгая типизация, будет применено автоматическое тестирование.


Взаимодействие Managed и Unmanaged кода.


Как известно, в .NET существует 2 основных способа взаимодействия с unmanaged кодом:
  1. С++/CLI: Можно написать враппер – обернуть unmanaged вызовы в managed методы, вручную преобразовывать native структуры, строки и массивы в managed объекты. Бесспорно это максимально гибко, но недостатков больше.
    Во-первых это куча кода, в том числе unmanaged, соответственно потенциальный риск допустить ошибку (без багов пишут только боги и лжецы).
    Во-вторых полученные сборки гвоздями приколочены к архитектуре – x64, x86 и.т.п., соответственно если у нас весь проект AnyCPU то придется собирать врапперы под несколько платформ и тащить их все с собой, распаковывая при установке или загружая при запуске сборку соответствующую конфигурации.
    В-третьих это C++, а он не нужен.
  2. P/Invoke и COM: Множество компонентов windows реализовано с использованием COM. В общем случае .net приемлемо работает с этой технологией. Необходимые интерфейсы и структуры можно либо объявлять вручную самостоятельно, либо, при наличии библиотеки типов, импортировать их оттуда автоматически с использованием специальной утилиты tlbimp.
    А вызывать экспортируемые функции из динамических библиотек можно объявив extern методы с атрибутом DllImport. Есть даже целый сайт где выложены объявления для основных winapi функций.

Остановимся подробнее на библиотеках типов. Библиотеки типов, как можно догадаться из названия, содержат информацию о типах, и получаются путем компиляции IDL – interface definition language – языка синтаксис которого чертовски схож с С. Библиотеки типов обычно поставляются либо в виде отдельных файлов с расширением .tlb либо встроены в ту же DLL где находятся описываемые объекты. Упомянутая выше утилита tlbimp генерирует из библиотек типов специальную interop-сборку содержащую необходимые объявления для .NET.
Поскольку синтаксис IDL схож объявлениями в заголовочных файлах языка C, то первая мысль которая приходит в голову – а не сгенерировать ли каким-либо образом библиотеку типов чтобы в дальнейшем импортировать ее в .net проект? Если в IDL файл можно скопировать все необходимые объявления из заголовочных файлов практически как есть, не задумываясь о конвертировании всяких там DWORD в uint, то это как раз то что нужно. Но есть ряд проблем: во-первых IDL не все поддерживает, а во-вторых tlbimp не все импортирует. В частности:
  • В IDL нельзя использовать указатели на функции
  • В IDL нельзя объявлять битовые поля
  • tlbimp не использует unsafe-код, поэтому на выходе подавляющее число указателей будут представлены нетипизированным IntPtr
  • Если в качестве аргумента в метод передается структура по ссылке, то tlbimp объявит такой аргумент как ref. И если в теории подразумевается, что туда на самом деле передавать надо адрес массива, то мы идем лесом. Конечно можно передать как ref нулевой элемент pinned-массива, оно даже будет работать, но выглядит такое несколько по-индусски. В любом случае из-за ref мы не сможем передать нулевой указатель если аргумент вдруг опциональный
  • Указатели на C-style null-terminated строки (а ля LPWSTR) tlbimp преобразует в string, и если вдруг нехороший COM объект вздумает что то записать в этот кусок памяти, приложение скажет “кря”
  • tlbimp импортирует только интерфейсы и структуры. Методы из DLL придется объявлять вручную
  • tlbimp генерирует сборку но не код. Хотя это и не так критично


Все проблемы с tlbimp решаются легко – мы не будем использовать эту утилиту, а напишем свою. А вот с IDL дело обстоит сложнее – придется шаманить. Предупреждаю сразу: поскольку библиотека типов будет являться лишь промежуточным звеном, то забудем о совместимости с какими-либо стандартами, хорошим тоном и.т.п. и будем хранить в ней все в том виде в котором удобнее нам.

IDL


Я не буду подробно останавливаться на описании этого языка, а лишь вкратце перечислю ключевые элементы IDL которые будут использованы. Полное описание IDL есть в msdn

Основной блок в IDL файле это library. Все типы, которые находится внутри него, будут включены в библиотеку. Типы объявленные вне блока library будут включены только если на них ссылается кто-либо из блока library. По хорошему блок library должен иметь имя и уникальный идентификатор. Есть и ряд других атрибутов, но нам ничего из этого не нужно.
[uuid(00000000-0000-0000-0000-000000000001)]
library Import
{
}
Но если все-таки необходимо принудительно включить тип объявленный вне блока, то можно внутри library написать
typedef MY_TYPE MY_TYPE; 

Внутри блока идут объявления типов. Нам понадобятся struct, union, enum, interface и module. Первые три абсолютно то же что и в С, поэтому не будем на них подробно останавливаться. Следует отметить только одну особенность, заключающуюся в том, что при таком объявлении:
typedef struct tagTEST
{
    int i;
} TEST;
именем структуры будет tagTEST, а TEST это alias который будет в итоге заменен именем. Поскольку во многих заголовочных файлах в объявлениях структур присутствуют различные мерзкие префиксы, то во избежание бардака в именах лучше принять какие-нибудь меры. А в целом, в IDL как и в C можно создавать любое количество alias-ов директивой typedef.

Для объявления интерфейсов используется блок interface. Внутри этого блока функции:
[uuid(38BF1A5B-65EE-4C5C-9BC3-0D8BE47E8A1F)]
interface IXAudio2MasteringVoice : IXAudio2Voice
{
    HRESULT GetChannelMask(DWORD* pChannelmask);
};
Все довольно очевидно. Из атрибутов в нашем случае важен только uuid, являющийся идентификатором интерфейса.

Еще есть блок module. В нем можно, к примеру, размещать функции из DLL, или какие-нибудь константы.
[dllname("kernel32.dll")]
module NativeMethods_kernel32
{ 
    const UINT DONT_RESOLVE_DLL_REFERENCES = 0x00000001;

    [entry("RtlMoveMemory")]
    void RtlMoveMemory(
        void *Destination,
        const void *Source,
        SIZE_T Length);
}
Здесь важны атрибуты dllname и entry, указывающие откуда будет загружаться метод. В качестве entry можно указывать ordinal функции вместо имени.

Объявления в IDL


Составим список того что надо брать из заголовочного файла:
  • Структуры и объединения, в.т.ч. с битовыми полями
  • Перечисления
  • Объявления функций импортируемых из DLL
  • Интерфейсы
  • Константы (макросы объявленные с помощью #define)
  • Указатели на функции
  • Alias-ы типов объявленные через typedef (т.е. всякие там DWORD-ы и.т.п.)

Теперь надо определиться как это все копировать в IDL.
  • Структуры и объединения: Копируем как есть, при желании убирая только лишние префиксы из имен.
  • Перечисления: Аналогично структурам.
  • Объявления функций импортируемых из DLL: Копируем как есть в блок module для соответствующей DLL. Очевидно, что для каждой DLL понадобится создать хотя бы по одному блоку module.
  • Константы (объявленные через #define): Тут конечно не очень хорошо получается – придется добавлять тип, т.е. константа из примера выше это на самом деле
    #define DONT_RESOLVE_DLL_REFERENCES 0x00000001
    
    вариантов немного – макросы то естественно никак не могут попасть в библиотеку типов.
    Другая проблема это всякие структуры вроде GUID-ов объявленных с помощью DEFINE_GUID. Ну если быть точным, то фактически это никакие не константы, а глобальные переменные, но используются обычно в качестве констант. Тут увы никак. GUID-ы то мы еще можем в виде строк объявить, но со всем остальным придется иметь дело вручную.
  • Alias-ы типов объявленные через typedef (т.е. всякие там DWORD-ы и.т.п.): Копируем как есть.
  • Интерфейсы: Поскольку ни C ни C++ не поддерживают интерфейсы, то в большинстве заголовочных файлов они объявлены через условную компиляцию двумя способами – как класс для C++ с __declspec(uuid(x)) в том или ином виде и как структура со списком указателей на функции для C. Нас интересуют объявления для C++. Они выглядят обычно так:
    MIDL_INTERFACE("0c733a30-2a1c-11ce-ade5-00aa0044773d")
    ISequentialStream : public IUnknown
    {
    public:
        virtual /* [local] */ HRESULT STDMETHODCALLTYPE Read( 
            /* [annotation] */ 
            _Out_writes_bytes_to_(cb, *pcbRead)  void *pv,
            /* [annotation][in] */ 
            _In_  ULONG cb,
            /* [annotation] */ 
            _Out_opt_  ULONG *pcbRead) = 0;
            
        virtual /* [local] */ HRESULT STDMETHODCALLTYPE Write( 
            /* [annotation] */ 
            _In_reads_bytes_(cb)  const void *pv,
            /* [annotation][in] */ 
            _In_  ULONG cb,
            /* [annotation] */ 
            _Out_opt_  ULONG *pcbWritten) = 0;
    };
    Необходимо почистить отсюда все лишнее, чтобы интерфейс выглядел так:
    [uuid(0c733a30-2a1c-11ce-ade5-00aa0044773d)]
    interface ISequentialStream : IUnknown
    {
        HRESULT Read(
            void *pv,
            ULONG cb,
            ULONG *pcbRead);
    
        HRESULT Write(
            void const *pv,
            ULONG cb,
            ULONG *pcbWritten);
    };
    При желании можно не трогать комментарии, а SAL-аннотации спрятать в атрибут [annotation(…)].
    Да, ряд операций проделывать все-таки приходится, но ключевой момент, как и основная суть статьи, здесь в том что мы не трогаем аргументы функций и возвращаемые значения. Т.е. даже несмотря на то что исходное объявление несколько изменяется, можно с достаточной уверенностью гарантировать его корректность, так как все типы и indirection level указателей остаются неизменными. Если что то забудем почистить, то оно не скомпилируется, но если скомпилируется то результат будет корректен поскольку “сигнатуры” не меняются.
  • Указатели на функции: Здесь начинаются костыли. Объявим интерфейс с одним методом, а при конвертации библиотеки типов такие интерфейсы будем преобразовывать в делегаты. Таким образом по-прежнему не будем трогать аргументы, да и остальной код использующий этот указатель не будет выдавать ошибок компиляции.
    Т.е. к примеру это:
    typedef LRESULT (CALLBACK* WNDPROC)(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam);
    
    будет выглядеть так:
    [uuid(C17B0B13-6E49-4268-B699-2D083BAE88F9)
    interface WNDPROC : __Delegate
    {
        LRESULT WNDPROC(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam);
    }
    
    В данном случае __Delegate это объявленный нами пустой интерфейс по которому мы будем отличать такой “указатель на функцию” от обычных интерфейсов. Атрибут uuid содержит случайное значение (чтобы не конфликтовать ни с чем), просто без него не скомпилируется. Можно конечно было бы заменить все указатели на функции на void*, но благодаря такому хаку мы сохраним строгую типизацию, например поле WNDPROC lpfnWndProc у структуры WNDCLASSEX в библиотеке типов будет также строго типизированным, а нам нужна информация только об имени типа и indirection level указателей, потому тот факт, что это интерфейс значения не имеет.
  • Битовые поля: Хотя это и относится к структурам, я вынес их в отдельный пункт поскольку здесь тоже придется хитрить. Надо к каждому каким-либо образом привязать информацию о числе бит. Например, можно сделать это с помощью массивов. А чтобы при конвертации библиотеки типов понять, что это битовое поле, добавить какой-нибудь ненужный атрибут. Например это:
    struct DWRITE_LINE_BREAKPOINT
    {
        UINT8 breakConditionBefore : 2;
        UINT8 breakConditionAfter : 2;
        UINT8 isWhitespace : 1;
        UINT8 isSoftHyphen : 1;
        UINT8 padding : 2;
    };
    
    объявим так:
    typedef struct DWRITE_LINE_BREAKPOINT
    {
        [replaceable]
        UINT8 breakConditionBefore[2];
        [replaceable]
        UINT8 breakConditionAfter[2];
        [replaceable]
        UINT8 isWhitespace[1];
        [replaceable]
        UINT8 isSoftHyphen[1];
        [replaceable]
        UINT8 padding[2];
    } DWRITE_LINE_BREAKPOINT;
    
    И для простоты условимся что если в структуре есть битовые поля то обычных полей там быть не должно. Тогда такие объявления:
    typedef struct TEST 
    {
        int i1 : 1;
        int i2 : 31;
        float f1;
    } TEST;
    
    Надо будет преобразовать в:
    typedef struct TEST
    {
        struct
        {
            int i1 : 1;
            int i2 : 31;
        };
        float f1;
    } TEST;
    
    Но битовые поля это очень большая редкость, потому в принципе их можно бы было и вообще не поддерживать, а заменять на базовый тип и уже в C# вручную делать все остальное:
    typedef struct TEST
    {
        int i;
        float f1;
    } TEST;
    


Вышеизложенного должно быть достаточно чтобы перенести в IDL информацию обо всем что может понадобиться при работе с native библиотеками. Конечно здесь не учитываются различные классы и шаблоны для C++, но во всяком случае процентов девяносто пять содержимого заголовочных файлов от Windows API таким образом перенести можно. Несмотря на наличие нескольких грязных хаков, копирование в IDL все равно проще, быстрее и безопаснее чем написание врапперов на CLI или ручного объявления типов в .NET.

Объявления в С#


Рассмотрим теперь как это все должно выглядеть в C#.

Генерировать мы будем unsafe код. Во-первых для строгой типизации указателей, во-вторых, чтобы не гонять данные туда-сюда всяческими там Marshal.PtrToStructure. Не столько из-за ловли блох на производительности, а просто потому что с расово-верными указателями код получается тупо проще. Маршалинг сложных типов иначе лаконично не сделать — это будут тонны кода. Я пробовал все варианты и очень долго пытался найти универсальный способ не использующий unsafe код. Его нет, и отказ от unsafe это палки себе в колеса – надежнее и безопаснее код не станет, а проблем добавится.

Разницу лучше всего видно когда надо в функцию передать структуру содержащую указатель на другую структуру, или на строку, или вообще рекурсивную ссылку. А если в unmanaged коде один указатель затем будет заменен на другой и надо чтобы эти изменения отразились на исходной структуре в managed коде… тут даже custom marshaling не особо поможет. Да, и кстати атрибут MarshalAs не нужен и использоваться не будет.

Кроме того, использование импортированных объявлений будет максимально приближено к таковому в С, что возможно сможет облегчить перенос уже написанного кода. Следует сразу отметить что чтобы в C# получить адрес переменной, она должна иметь blittable-тип. Все наши структуры будут соответствовать этим требованиям. Поля с массивами объявим как fixed, для строк будем использовать char*/byte*, но вот тип bool не является blittable, поэтому в нашем случае для его представления будет использоваться структура с int полем и implicit операторами для приведения от/к bool. На массивах внутри структур надо остановиться чуть подробнее. Есть ограничения: во-первых ключевое слово fixed применимо только к массивам примитивных типов, поэтому массивы структур так не объявить, а во-вторых поддерживаются только одномерные массивы. Обычные массивы (с атрибутом MarshalAs и опцией SizeConst) хоть и могут содержать структуры, но они не являются blittable-типом, кроме того они также могут быть только одномерными. Чтобы решить этот вопрос, для массивов мы будем создавать специальные структуры с private полями по числу элементов. Такие структуры будут иметь indexer property для доступа к элементам, а также implicit операторы для копирования из/в managed массивы. Псевдомногомерность будет обеспечиваться через доступ по нескольким индексам. Т.е. матрица 4х4 это будет структура с 16 полями, а indexer property будет брать адрес первого элемента и высчитывать смещение по такой формуле: индекс1 * длина1 + индекс2, где длина1 равна 4, а оба индекса – числа от 0 до 3.
  • Структуры и объединения: Структуры как структуры, ничего особенного. Для объединений LayoutKind.Explicit и FieldOffset(0) для всех полей. Особо следует отметить безымянные поля со структурами и объединениями. Дело в том что библиотеки типов такое не поддерживают, вместо этого им будут назначены сгенерированые имена, начинающиеся на __MIDL__.
    Структура
    typedef struct TEST
    {
        struct
        {
             int i;
        };
    } TEST;
    

    На самом деле будет чем то таким:
    typedef struct TEST
    {
        struct __MIDL___MIDL_itf_Win32_0001_0001_0001
        {
            int i;
        } __MIDL____MIDL_itf_Win32_0001_00010000;
    } TEST;
    
    Соответственно если импортировать в C# как есть, то получим следующее:
    [StructLayout(LayoutKind.Sequential)]
    public unsafe struct TEST
    {
        [StructLayout(LayoutKind.Sequential)]
        public unsafe struct __MIDL___MIDL_itf_Win32_0001_0001_0001
        {
            public int i;
        }
    
        public __MIDL___MIDL_itf_Win32_0001_0001_0001 __MIDL____MIDL_itf_Win32_0001_00010000;
    }
    
    В принципе и черт бы с ним, но доступ к полю i в C выполняется напрямую, как будто это поле основной структуры, т.е. myVar.i, а здесь будет жутковатое myVar. __MIDL____MIDL_itf_Win32_0001_00010000.i. Не годится, поэтому для таких случаев будем генерировать свойства для доступа напрямую к полям вложенных безымянных структур:
    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
    public unsafe struct TEST
    {
        [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
        public unsafe struct __MIDL___MIDL_itf_Win32_0001_0001_0001
        {
            public int i;
        }
    
        public __MIDL___MIDL_itf_Win32_0001_0001_0001 __MIDL____MIDL_itf_Win32_0001_00010000;
    
        public int i
        {
            get
            {
                return __MIDL____MIDL_itf_Win32_0001_00010000.i;
            }
            set
            {
                __MIDL____MIDL_itf_Win32_0001_00010000.i = value;
            }
        }
    }
    
    Возможно такой подход не лишен недостатков, но это позволяет добиться максимального соответствия объявлений и корректно обрабатывать к примеру такие структуры:
    typedef struct TEST
    {
        union
        {
            struct 
            {
                int i1;
                int i2;
            };
            struct
            {
                float f1;
                float f2;
            };
        };
        char c1;
    } TEST;
    
    Доступ напрямую через свойства позволит работать со структурой почти точно так же как в С. Исключением является только случай когда необходим адрес вложенных полей, тогда придется все-таки указывать полный путь.
  • Перечисления. Тут все просто, лишь незначительные различия в синтаксисе.
  • Битовые поля. Выглядеть они будут так – целочисленная private переменная (тип зависит от того какого суммарно размера структура с битовыми полями) и сгенерированные свойства выполняющие битовые операции для чтения/установки только соответствующих бит:
    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode, Pack = 1)]
    public unsafe struct DWRITE_LINE_BREAKPOINT
    {
        private byte __bit_field_value;
    
        public byte breakConditionBefore
        {
            get
            {
                return (byte)((__bit_field_value >> 8) & 3);
            }
            set
            {
                __bit_field_value = (byte)((value & 3) << 8);
            }
        }
    
        public byte breakConditionAfter
        {
            get
            {
                return (byte)((__bit_field_value >> 8) & 3);
            }
            set
            {
                __bit_field_value = (byte)((value & 3) << 8);
            }
        }
    
        ...
    
    }
    

  • Объявления функций импортируемых из DLL: Как обычно, static extern методы с атрибутом DllImport в классе NativeMethods
  • Alias-ы типов объявленные через typedef: Если в IDL случайно не затесались никакие лишние атрибуты то alias-ы будут заменены на сам тип при компиляции библиотеки типов (см. тут). А если все таки они туда попадут, то вместо них подставим тип который они представляют.
  • Константы: константы в классе NativeConstants. Строки или числа.
  • Указатели на функции (которые в виде специальных интерфейсов): Генерируем 2 основных типа: делегат и структуру, которая будет представлять собой сам указатель. В структуре одно private-поле имеющее тип void*. А через оператор implicit неявно приводить типы от/к делегату путем вызова Marshal.GetFunctionPointerForDelegate и Marshal.GetDelegateForFunctionPointer
  • Интерфейсы: Тут казалось бы все просто – объявил интерфейс с атрибутом ComImport и дело в шляпе, и в классе Marshal навалом методов для дополнительной функциональности.
    А вот нет, это работает только для COM-интерфейсов. А нам запросто могут вернуть нечто не наследующее IUnknown. Например IXAudio2Voice. И вот тут-то стандартные механизмы .NET скажут вам “кря”. Ну не страшно, в запасе есть хитрый ход конем – будем генерировать таблицы виртуальных методов сами и вызывать их через Marshal.GetFunctionPointerForDelegate и Marshal.GetDelegateForFunctionPointer. Здесь нет ничего особенного – интерфейсы будут представлены структурами, внутри которых есть private структуры с набором указателей. Для каждой функции интерфейса у основной структуры генерируется метод, вызывающий соответствующий указатель через Marshal.GetDelegateForFunctionPointer. А также набор implicit операторов чтобы поддержать приведение типов в случае наследования интерфейсов. Пример занял бы слишком много места чтобы привести его здесь, поэтому все можно посмотреть в приложенном архиве.


Утилита для преобразования


С теорией на этом все. Переходим к практике.

За преобразование IDL в библиотеку типов будет отвечать компилятор midl входящий в комплект Windows SDK.

За преобразование библиотеки типов в C# код будет отвечать собственная утилита (но из нее же будем запускать и компилятор).

Начну со второго. Для чтения содержимого библиотеки типов используются стандартные интерфейсы ITypeLib2 и ITypeInfo2. Документацию можно посмотреть здесь. Они же используются и в утилите tlbimp. Реализация конвертера ничего интересного из себя не представляет, поэтому больше про него рассказывать нечего. Исходный код в приложенном архиве (и да, я знаю, что существуют библиотеки для генерации C# кода, но без них проще).

Теперь о компиляции IDL.

Скопируем файлы компилятора в отдельную папку. Во-первых потому что придется их модифицировать, а во-вторых чтобы отвязаться от Windows 8.1 SDK и не прописывать нигде никаких абсолютных путей вида C:\Program Files (x86)\блаблабла.
Понадобятся следующие файлы:
C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\bin\amd64\1033\clui.dll
C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\bin\amd64\c1.dll
C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\bin\amd64\cl.exe
C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\bin\amd64\mspdb120.dll
C:\Program Files (x86)\Windows Kits\8.1\bin\x64\midl.exe
C:\Program Files (x86)\Windows Kits\8.1\bin\x64\midlc.exe
Все кроме clui.dll сваливаем в одну кучу. А clui.dll должен располагаться в подпапке 1033.

Процесс midl.exe запускает другой процесс – midlc.exe, который и выполняет всю работу.

Компилятор требует обязательное наличие файла с именем oaidl.idl где-либо в пределах досягаемости, с объявленым там интерфейсом IUnknown. Для удобства настройки создадим копию этого файла и скопируем туда основные объявления из исходного oaidl.idl и файлов на которые он ссылается. Хотя можно ограничиться и лишь интерфейсом IUnknown, а остальные объявления добавлять уже по мере использования. Разместим полученный файл рядом с компилятором.
Необходимо это затем, что часть системных типов придется немного подправить. К примеру BOOL и BOOLEAN нам нужны в виде структур с одним полем чтобы не возиться с int и byte, а поддержать приведение такой структуры к bool (который как уже было упомянуто выше, не является blittable типом и поэтому не может быть напрямую использован). Также надо там же объявить базовый интерфейс для типов обозначающих указатели на функции.

Исправление багов в компиляторе Обход ограничений компилятора


Бочкой дегтя была следующая особенность: http://support.microsoft.com/default.aspx?scid=kb;en-us;220137. Microsoft позиционирует это как feature. С одной стороны логично – основное предназначение библиотек типов это OLE Automation, что подразумевает поддержку регистронезависимых языков. С другой стороны реализация мягко говоря странная – между именами аргументов и именами методов или типов нет никакой связи, для чего использовать один глобальный список строк вместо отдельных списков для имен типов, отдельных списков для имен методов в каждом типе и.т.п.? В любом случае, нас такой “by design” не устраивает, ибо результатом является чудовищная помойка в именах, да и с автоматическим тестированием (см. ниже) будут проблемы, поскольку для этого необходимо точное соответствие имен тем что в исходных файлах.

Регистронезависимое сравнение строк обычно даже самые отъявленные индусы редко станут писать с нуля, потому с большой долей вероятности используется API-функция.

Вооружившись отладчиком наблюдаем практическое подтверждение описанного в KB220137 поведения:

Внутри компилятора есть глобальный словарь в который добавляются строки с именами. Если в файле хоть раз попалась строка “msg” (к примеру в качестве аргумента в какой-либо функции), то она будет добавлена в словарь. Если в дальнейшем в исходном файле попадется строка “Msg” (к примеру имя структуры), то выполнится проверка наличия этой строки в словаре с помощью CompareStringA и флагом NORM_IGNORECASE. Проверка вернет результат что строки одинаковы, текст “Msg” будет проигнорирован и компилятор в библиотеку типов в обоих случаях (и имя аргумента и имя структуры) запишет “msg”, хотя по факту они никак не связаны. Эта логика выполняется в зависимости от значения глобальной переменной.

Кроме того, для создания файла с библиотекой типов используются COM-объекты из oleaut32.dll (ICreateTypeLib, ICreateTypeInfo и.т.п.), которые также используют CompareStringA для проверки повторяющихся имен. К примеру, функция ICreateTypeInfo::SetVarName вернет результат TYPE_E_AMBIGUOUSNAME при попытке добавить поле в структуру отличающееся только регистром от существующего. Хотя там похоже глобальных словарей нет и такие проверки выполняются только для полей и методов в пределах содержащего их типа.

Из вышеизложенного становится очевидной задача – перехватить вызов CompareStringA и убрать из аргумента dwCmpFlags флаг NORM_IGNORECASE.

Midlc.exe импортирует CompareStringA из kernel32.dll, которая в свою очередь вызывает CompareStringA из kernelbase.dll, а oleaut32.dll использует сразу CompareStringA из kernelbase.dll. Поскольку подменить системную библиотеку не получится, будем перехватывать в рантайме.

Делается это элементарно: надо внедрить свой код в процесс и, получив адрес функции, модифицировать код так, чтобы передать управление в перехватчик, где выполнить необходимые операции и передать управление обратно. Для этого можно воспользоваться к примеру этой библиотекой: http://www.codeproject.com/Articles/44326/MinHook-The-Minimalistic-x86-x64-API-Hooking-Libra (В приложенном архиве слегка модифицированный вариант – код переписан на нормальный язык и почищен от лишней функциональности).

Для внедрения в процесс создадим DLL и модифицируем таблицы импорта файла midlc.exe чтобы при запуске он загружал нашу библиотеку. Инициализация перехватчика будет выполняться в точке входа DllMain.

Модифицировать таблицы импорта можно и вручную, но лучше воспользоваться готовыми утилитами, к примеру вот http://www.ntcore.com/exsuite.php. В утилите CFF Explorer надо открыть exe файл и выбрав слева Import Adder добавить нашу библиотеку и указать какую-н функцию для импорта (придется создать одну пустую функцию для этого, на практике ее никто никогда не вызовет) и нажав Rebuild Import Table сохранить файл.

Подключение файлов к проекту


Для снижения количества бесполезных файлов и левых build-event-ов применим известную технологию T4. Это мощный инструмент для генерации текста по шаблонам. Нам же в данном случае важна лишь возможность выполнения произвольного C# кода при сохранении файла. Шаблоном будет сам IDL файл. Суть в том что блок который будет распознан T4 помещаем в комментарий IDL файла и он будет проигнорирован midl-ом, а все что вне этого блока будет проигнорировано T4. Чтобы не дублировать код, вынесем в общий подключаемый файл весь запуск процесса и работу с файлами, оставив только директиву с подключаемым файлом. Таким образом где-н в начале каждого IDL файла будет всегда комментарий вроде
/* <#@ include file="..\InternalTools\TransformIDL.tt" #> */
А в свойствах IDL файла указываем TextTemplatingFileGenerator в качестве Custom Tool.

В подключаемом файле ничего интересного – просто запускается наша утилита с нужными параметрами. C# код сгенерированный нашей утилитой во временный файл считывается в скрипте T4-шаблона и возвращается в качестве результата. Если скрипт в T4 возвращает конкретную строку, то результирующий файл будет содержать только ее, и таким образом содержимое шаблона никогда не попадет в выходной файл и может быть произвольным.

Благодаря этому, сохранение любых изменений в .idl файле автоматически запустит генерацию и обновит код.

Следует отметить что в T4 есть ограничения на размеры непрерывных блоков текста (по слухам ~64кб), поэтому при попытке сохранить очень большой файл можно поймать ошибку “Compiling transformation: An expression is too long or complex to compile ”. В этом случае в файл надо периодически добавлять такие строки:
// <# #>


Настройки


Наши промежуточные библиотеки типов будут тащить за собой кучу типов, которые возможно будут повторяться если у нас несколько IDL файлов. К примеру объявление интерфейса IUnknown. Кроме того неплохо бы где то указывать пространство имен в котором лежат сгенерированные классы, а также перечислить используемые пространства имен. Список типов которые надо проигнорировать при кодогенерации и перечень пространств имен можно разместить в виде комментариев в начале IDL файла и считывать перед конвертацией.

Тестирование


Тестировать будем следующие вещи:
  • Наличие функций в указанных DLL
  • Размеры структур
  • Смещения всех полей в структурах

Причем поскольку описываемое решение позиционируется как не привязанное к разрядности ОС, то тестироваться все будет и в 32 разрядном и в 64 разрядном режимах.

Можно также тестировать размеры перечислений. Но в 99% случаев они занимают 4 байта. Поэтому возможность генерации перечислений с базовым типом отличным от int не рассматривается.

Информацию о размерах и смещениях надо получать из native кода. Для этого создадим две сборки на CLI (32 и 64). По сгенерированным утилитой managed-типам сгенерируем файл с кодом для получения необходимых данных. Генерировать будем макросы с инициализаторами:
#define STRUCT_SIZES \
{\
    { L"ARRAYDESC", sizeof(::ARRAYDESC) },\
    { L"BLOB", sizeof(::BLOB) },\    
    { NULL, 0 }\
}\

#define STRUCT_OFFSETS \
{\
    { L"ARRAYDESC.tdescElem", FIELD_OFFSET(::ARRAYDESC, tdescElem) },\
    { L"ARRAYDESC.tdescElem.lptdesc", FIELD_OFFSET(::ARRAYDESC, tdescElem.lptdesc) },\    
    { NULL, 0 }\
}\
для массивов структур:
STRUCT_SIZE structSizes[] = STRUCT_SIZES;
STRUCT_OFFSET structOffsets[] = STRUCT_OFFSETS;
Без патча компилятора этот шаг автоматизировать бы не удалось!

Пробегаясь по массивам в цикле преобразуем содержимое в Dictionary<string, int>. В первом случае ключом будет являться имя структуры а значением ее размер. Во втором – ключ это нечто вроде ‘полного пути’ к полю, а значение – смещение этого поля в структуре.

Данные будут различаться для 32 и 64 разрядных версий, именно поэтому нам необходимы две сборки. Эти данные подцепим из тестовых классов на C#. Далее тест будет сравнивать эти размеры и смещения с аналогами для managed структур, полученными с помощью Marshal.SizeOf и Marshal.OffsetOf.

Наличие методов в dll будем проверять вызывая LoadLibrary и GetProcAddress. Если они отработали, то все в порядке, если нет то или такой функции нет или накосячено в атрибутах в IDL.

Таким образом при добавлении новых объявлений тесты менять не придется. Ну точнее иногда надо будет только добавить директивы #include с файлами где объявлены исходные структуры, чтобы тест скомпилировался.

Но тут поджидает очередная проблема – VisualStudio не умеет одновременно искать и 32-разрядные и 64-разрядные тесты. Либо одно либо другое. По этой причине тесты будут запускать отдельные процессы которые и будут выполнять всю тестирующую логику, а сами тестовые классы лишь покажут результат выполнения.

Тесты иногда будут выявлять несоответствие выравнивания структур для какой-либо платформы. Поскольку для сохранения совместимости мы не можем указывать явные ненулевые смещения полей атрибутами FieldOffset или размеры структур (и то и другое будет отличаться для разных платформ), то придется химичить. Вот пример:
typedef struct SOCKET_ADDRESS_LIST 
{
    INT iAddressCount;
    SOCKET_ADDRESS Address[1];
} SOCKET_ADDRESS_LIST;
В x64 у массива Address будет смещение 8, т.е. после поля iAddressCount необходим padding из 4 байт. На х86 его быть не должно. Аналог в .NET будет выровнен по 4 байтам на обоих платформах. Хитрый ход конем заключается в следующем:
typedef struct SOCKET_ADDRESS_LIST 
{
    union
    {
        INT iAddressCount;
        [hidden]
        void* ___padding000;
    };
    SOCKET_ADDRESS  Address[1];
} SOCKET_ADDRESS_LIST;
С точки зрения использования в коде, структура остается эквивалентной, но в генерируемых .NET структурах это даст необходимый эффект – дополнительное поле будет занимать 4 байта в 32-разрядном режиме и 8 байт в 64-разрядном, тем самым “смещая” массив на 4 байта только в 64-разрядном режиме.
Маргинальные настройки выравнивания через условную компиляцию (а ля #pragma pack(2) для x86 и #pragma pack(16) для х64) здесь не рассматриваются — 99% структур выровнены либо по умолчанию либо по 1 байту, все остальное не нужно.

Изредка попадаются структуры кардинально отличающиеся на x86 и x64, например WSADATA. Для таких случаев у меня решения нет. С ними придется иметь дело вручную, но такие структуры попадаются крайне редко.

На этом все. Весь исходный код с примером использования в прилагаемом архиве.
Чтобы не нарушать никаких лицензионных соглашений, компилятор midl не прилагается. Его можно взять установив VisualStudio и пропатчить самостоятельно (была использована 64-разрядная версия).

Код к статье
  • +9
  • 12,8k
  • 3
Поделиться публикацией
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 3

    +2
    Какая огромная статья!
      0
      Сокращал как мог. Если разбивать на части то они бы не имели смысла друг без друга.
      Пришлось оставить за кадром ряд мелочей вроде использования RtlCompareMemory в перегруженных операторах сравнения структур или хранения делегатов в статических коллекциях чтобы GC не съел классы, чьи методы вызываются из unmanaged кода.
        0
        Я честно старался, но где то после середины перестал понимать что происходит.

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое