.NET в unmanaged окружении: platform invoke или что такое LPTSTR

    Методика все та же — минимум объяснений, максимум рецептов. Для глубинного понимания происходящих процессов рекомендую обратиться к документации в MSDN — этот раздел уже даже перевели на русский язык.

    Строки и enum-ы



    Учиться будем на конкретных примерах. Естественно, как это принято, начнем мы с самого простого приложения — hello, world! Для вывода этого текста, мы заинтеропим функцию MessageBox из WinAPI, на примере которой подробно разберемся со строками и кодировкой.

    Так, кто сказал MessageBox.Show? Мне не нравится ваша розовая кофточка, ваши сиськи и ваш микрофон, встала и ушла отсюда. Вернешься с умными советами, когда мы будем массивы структур маршаллить. Чтобы у остальных не было соблазнов — работать будем в рамках консольного проекта, не подключая Windows.Forms.

    Итак, пример первый, тривиальный. Создаем статический класс, пишем определение функции с extern, указываем, что за функцию мы хотим получить, а дальше все точно так же, как и в моей предыдущей статье — определяем параметры маршаллинга соответствующими атрибутами.

        public static class PInvoke
        {
          [DllImport("User32.dll", EntryPoint="MessageBox", CharSet=CharSet.Auto)]
          public extern static int MsgBox(
            [MarshalAs(UnmanagedType.I4)] int hwnd,
            [MarshalAs(UnmanagedType.LPTStr)] string text,
            [MarshalAs(UnmanagedType.LPTStr)] string caption,
            [MarshalAs(UnmanagedType.U4)] uint type);
        }


    Следует обратить внимание на задание EntryPoint и CharSet. EntryPoint — это экспортируемое имя функции, которую мы хотим вызвать, а CharSet — используемая кодировка. Если вы немного разбираетесь в WinAPI, то знаете, что большинство функций имеют две версии — ANSI и Unicode. Первые жуют и выдают ANSI-строки, вторые, соответственно, Unicode. Различаются они суффиксом — так, функция MessageBox, работающая с Unicode-строками (которую, мы, кстати, и вызываем) в библиотеке имеет имя MessageBoxW. Однако умный маршаллер .NET знает об этой особенности WinAPI (еще бы ли, он не знал), а потому при обработке атрибута DllImport автоматически подставляет суффикс в зависимости от кодировки.

    Однако, мы можем задать используемую кодировку и напрямую. Например, так:
    [DllImport("User32.dll", EntryPoint="MessageBoxA", CharSet=CharSet.ANSI)]


    Здесь мы работаем с ANSI-кодировкой. Однако, подняв окошко с messagebox-ом, который определен таким образом, вы увидите, что от передаваемых строк остались лишь первые символы. В чем же дело?

    Дело в маршаллинге строк, а точнее в типа LPTStr. Остановимся на нем поподробнее.

    Дело в том, что в C++, в отличии от C#, где царит его величество Unicode, существуют еще и ANSI-строки. В терминах C++, тип, используемый для ANSI-строки называется LPSTR (long pointer to string), для Unicode-строки — LPWSTR (long pointer to wide string), а LPTSTR — специальный тип, который определен следующим образом:

    #ifdef UNICODE
    typedef LPCWSTR LPCTSTR;
    #else
    typedef LPCSTR LPCTSTR;
    #endif


    То есть в зависимости от того, как мы компилируемся, у нас подставляется тот или иной тип строки — либо Unicode, либо ANSI. Это было сделано для того, чтобы обеспечить совместимость на уровне кода с версиями ОС, которые не поддерживают Unicode. Теперь это конечно выглядит атавизмом, но Microsoft вынуждена тащить это решение для совместимости со старыми версиями.

    А что есть такое LPTStr в .NET? MSDN услужливо подсказывает, что этот тип эквивалентен либо LPStr для Windows 98, либо LPWStr для Windows NT и старше. Таким образом, теоретически, определенная таким образом функция будет работать и в Windows 98. Однако, если вспомнить, что для Win98 доступен только фреймворк версии 1.1 — то можно выкинуть все то, что я написал, из головы, и всегда маршаллить функции как Unicode следующим образом:

        public static class PInvoke
        {
          [DllImport("User32.dll", EntryPoint="MessageBox", CharSet=CharSet.Unicode)]
          public extern static int MsgBox(
            [MarshalAs(UnmanagedType.I4)] int hwnd,
            [MarshalAs(UnmanagedType.LPWStr)] string text,
            [MarshalAs(UnmanagedType.LPWStr)] string caption,
            [MarshalAs(UnmanagedType.U4)] uint type);
        }


    Ну а если вдруг вам попадется хитрая библиотека, которая понимает только Ansi — спокойненько прописываете CharSet.Ansi и используете LPStr для строк. Вот и вся магия.

    Кстати, EntryPoint можно и не задавать — по умолчанию он эквивалентен тому имени функции, которое вы задаете в коде, так что если они совпадают — то игнорируйте этот параметр.

    Если функция принимает в качестве входного параметра перечисление (так, например, у MessageBox это параметр type), то можно определить соответствующий enum в C#, унаследовав его от нужного типа, и передать его в качестве параметра, например, так

        [Flags]
        public enum MBoxStyle : uint
        {
          MB_OK = 0,
          MB_OKCANCEL = 1,
          MB_RETRYCANCEL = 2,
          MB_YESNO = 4,
          MB_YESNOCANCEL = 8,
          MB_ICONEXCLAMATION = 16,
          MB_ICONWARNING = 32,
          MB_ICONINFORMATION = 64 ...
        }

          [DllImport("User32.dll", EntryPoint="MessageBox", CharSet=CharSet.Unicode)]
          public extern static int MsgBox(
            [MarshalAs(UnmanagedType.I4)] int hwnd,
            [MarshalAs(UnmanagedType.LPWStr)] string text,
            [MarshalAs(UnmanagedType.LPWStr)] string caption,
            [MarshalAs(UnmanagedType.U4)] MBoxStyle type);


    Теперь мы можем использовать не числовые константы, а нормальный enum.

    Для того, чтобы определить возвращаемую из функции строку — просто прописываете out или ref у параметра функции. Однако, здесь существует одна хитрость, связанная с тем, что для возврата строк многие функции требуют под себя буфер фиксированного размера. Так, например, функция GetWindowText, которая определена следующим образом:

    int GetWindowText(HWND hWnd, LPTSTR lpString, INT nMaxCount);


    При вызове функции из C++ делается что-то вроде этого:

    const int BUFF_SIZE = 200;
    LPTSTR buff = new TCHAR[BUFF_SIZE];
    GetWindowText(hwnd, buff, BUFF_SIZE);


    Функция GetWindowText заполняет полученный буфер требуемыми данными, а с помощью nMaxCount следит за тем, чтобы не произошло переполнения буфера.

    Если мы попробуем воспользоваться стандартной методикой:

          [DllImport("User32.dll", EntryPoint = "GetWindowText", CharSet = CharSet.Unicode)]
          public extern static void GetWindowText(int hWnd, [MarshalAs(UnmanagedType.LPWStr)] ref string lpString, int nMaxCount);


    То попытка вызова такой функции обернется неудачей. Дело в том, что строки в C# принципиально не поддаются изменению — при любой операции, вроде конкатенации, поиска подстроки и тому подобной создается новый объект в памяти. Тогда как же быть?

    Выход — использовать StringBuilder.

          [DllImport("User32.dll", EntryPoint = "GetWindowText", CharSet = CharSet.Unicode)]
          public extern static void GetWindowText(int hWnd, [MarshalAs(UnmanagedType.LPWStr)] StringBuilder lpString, int nMaxCount);


    И вызывать функцию следующим образом:

          StringBuilder sb = new StringBuilder(256);
          PInvoke.GetWindowText(handle, sb, sb.Capacity);


    Кстати, если вы обратите внимание, то у объекта StringBuilder нет модификатора ref. Он и не нужен — дело в том, что маршаллер .NET понимает, что в таких ситуациях StringBuilder используется для возвращаемого значения.

    Маршаллинг возвращаемого функцией значения делается аналогично тому, как я показывал в предыдущей статье — установкой атрибута [return: MarshalAs(...)].

    Структуры и объединения.



    При маршаллинге структур они обязательно должны быть выровненные (LayoutKind.Sequential).

    Если в структуре имеется строковый буфер фиксированной длины, то маршаллеру следует особо на это указать, иначе структура «расползется» в памяти и вы получите на выходе черте что.

    Рассмотрим оба этих правила на примере функции GetVersionEx. Полное определение ее на С++ таково:

    typedef struct _OSVERSIONINFO
    {
    DWORD dwOSVersionInfoSize;
    DWORD dwMajorVersion;
    DWORD dwMinorVersion;
    DWORD dwBuildNumber;
    DWORD dwPlatformId;
    TCHAR szCSDVersion[128];
    } OSVERSIONINFO;

    BOOL GetVersionEx(LPOSVERSIONINFO lpVersionInfo);


    Итак, мы видим, что в структуре содержится строковый буфер фиксированной длины. Соответственно, наши действия будут такими. Определяем структуру

        [StructLayout(LayoutKind.Sequential)]
        public struct OSVERSIONINFO
        {
         [MarshalAs(UnmanagedType.I4)] public int dwOSVersionInfoSize;
         [MarshalAs(UnmanagedType.I4)] public int dwMajorVersion;
         [MarshalAs(UnmanagedType.I4)] public int dwMinorVersion;
         [MarshalAs(UnmanagedType.I4)] public int dwBuildNumber;
         [MarshalAs(UnmanagedType.I4)] public int dwPlatformId;
         [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string szCSDVersion; // Задаем буфер константного размера
        } 


    И функцию.
          [DllImport("kernel32", EntryPoint = "GetVersionEx", CharSet=CharSet.Unicode)]
          public static extern bool GetVersionEx2(ref OSVERSIONINFO osvi); 


    Однако, кроме структур в C++ существует такая вещь, как объединение. Объединение — это когда один и тот же объем двоичных данных в памяти интерпретируется различным образом, в зависимости от каких-то внешних или внутренних факторов. Самым известным типом объединения является тип VARIANT.

    Объединения хороши тем, что позволяют экономить память при сохранении огромной гибкости внутреннего представления.

    В .NET тоже можно создать объединение. Выглядеть это будет следующим образом — мы задаем в атрибуте StructLayout параметр LayoutKind.Explicit, который говорит о том, что мы сами определяем, как в памяти размещается структура, и задаем смещение каждого поля в байтах от начала структуры.
        [StructLayout(LayoutKind.Explicit)]
        public struct TestStruct
        {
          [FieldOffset(0)]
          public int a;
          [FieldOffset(0)]
          public float b;
        }


    Выглядит эта штука дико забавно.

          TestStruct s = new TestStruct();
          s.b = 5.2f;
          Console.WriteLine(s.b);
          Console.WriteLine(s.a);
          s.a = 99;
          Console.WriteLine(s.b);
          Console.WriteLine(s.a);
          Console.ReadLine();


    Результат работы программы:

    5,2
    1084647014
    1,387285E-43
    99


    Казалось бы, проблема с объединениям решена, но не тут-то было. Попытавшись добавить в объединение ссылочный тип (например, строку)

        [StructLayout(LayoutKind.Explicit)]
        public struct TestStruct
        {
          [FieldOffset(0)]
          public int a;
          [FieldOffset(0)]
          public float b;
          [FieldOffset(0)]
          public string c;
        }


    Вы получите оглушительный «бум» по голове.

    Could not load type 'TestStruct' from assembly 'TestPInvoke, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' because it contains an object field at offset 0 that is incorrectly aligned or overlapped by a non-object field.

    Проблема в том, что ссылочные поля в памяти располагаются в виде указателей. Перекрывая (overlap) указатель типом-значением, мы получаем возможность манипуляции памятью. Например, так.

          s.c = "";
          s.a = 0xA000;
          Console.WriteLine(s.c);


    В этом случае на экран вывелось бы содержимое памяти по адресу 0xA000 до первого двойного нуля. Используя в качестве ссылочного типа класс, содержащий типы-значения (например, int), мы можем манипулировать памятью напрямую. Естественно, такого безобразия CLR допустить не может, а потому подобные действия явно запрещены.

    Что делать? Начинать биться головой об стену, потому что единственный выход — это определять столько видов структур, сколько требуется для того, чтобы все поля-значения не перекрывались полями-ссылками, а для вызова функций использовать перегрузку с гаданием на кофейной гуще, какого типа результат вернет нам функция. Жуть. Страшная жуть. Поэтому по возможности — избегайте таких объединений, где поля-ссылки соседствуют с полями-значениями. А если не удается — штудируйте MSDN, данная тема слишком велика для нашего обзора.

    Работа с HANDLE.



    Множество функций WinAPI требуют или возвращают HANDLE. Если говорить упрощенно, то HANDLE — это указатель на объект ядра ОС, а по факту — это четырехбайтное целое, которое остается неизменным с момента, как мы запрашиваем объект до момента, когда мы его освобождаем.

    Для работы с HANDLE в C# предназначен класса SafeHandle. Однако, передача этого класса напрямую в функции через p/invoke невозможна. Придется изворачиваться.

    Возьмем в качестве примера функцию SetSecutiryInfo (описание функции на MSDN). Пока не обращаем внимания на всякие левые вещи, сосредоточимся на главном.

        [DllImport("advapi32.dll", SetLastError = true, CallingConvention = CallingConvention.Winapi)]
        public static extern Int32 SetSecurityInfo
        (
          IntPtr handle,
          SE_OBJECT_TYPE ObjectType,
          SECURITY_INFORMATION SecurityInfo,
          IntPtr psidOwner,
          IntPtr psidGroup,
          IntPtr pDacl,
          IntPtr pSacl
        );


    Сценарий работы с SafeHandle демонстрируется на примере FileStream.

          FileStream fs = new FileStream("c:\test.txt");
          bool success = false;
          // Добавляем ссылку на объект - это не позволит GC прибить его во время работы.
          fs.SafeFileHandle.DangerousAddRef(ref success);
          if (success)
          {
            // Получаем handle в IntPtr
            IntPtr h = fs.SafeFileHandle.DangerousGetHandle();
            // Вызываем native-метод
            PInvoke.SetSecutiryInfo(h, <остальные параметры>);
            // Теперь handle нам не нужен и его можно убивать - убираем лишнюю ссылку.
            fs.SafeFileHandle.DangerousRelease();
          }
          else
          {
            // невалидный HANDLE, обработка ошибок.
          }


    Сценарий следует выполнять с точностью до строчки кода. Работа с unmanaged-ресурсами требует точности и аккуратности, дабы не допустить memory leak-ов.

    Здесь мы сталкиваемся с двумя неизвестными доселе параметрами атрибута DllImport. Первый — SetLastError при установки в true позволяет, в случае ошибки, сохранить ее код, после чего его можно получить при помощи Marshal.GetLastWin32Error. Если в описании функции сказано, что значение ошибки можно получить через GetLastError, то следует устанавливать этот флаг. Второй — CallingConvention устанавливает соглашение вызова функции — PASCAL (он же stdcall, он же WinApi) или cdecl. Вся разница — в том, кто очищает стек — вызывающий код или вызванная функция. Все WinAPI функции используют соглашение stdcall.

    Однако, статья уже переросла все мыслимые пределы и я вынужден остановиться. За пределами нашего рассмотрения остались такие интересные вещи, как ручной парсинг структур из IntPtr и обратно, оборачивание возвращаемых из функции HANDLE, GlobalAlloc и буферы, сложные типы данных и ручной маршаллинг с помощью класса Marshall. Честно говоря, мне эта тема кажется уж слишком специфичной для аудитории хабра, поэтому я скорее всего рассматривать ее не буду. Данных методов вполне достаточно для большинства типовых задач, в которых требуется p/invoke, ну а если вам попалась задачка посложнее — тогда, что поделать, придется штудировать MSDN.

    UPD: Из зала подсказывают, что на Win98 возможна установка .NET вплоть до версии 2.0, что сильно ничего не меняет, но тем не менее, стоит отметить этот факт.

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

      0
      прочитал полностью, отлично
      у меня вопрос, судя по уровню вашего знания предмета, вам много приходится работать с unmanaged-кодом
      а можете про это больше рассказать? зачем, почему, какие задачи это решает?
        0
        На самом деле, работать с unmanaged-кодом приходится не много. По факту, буквально два-три случая вынудили меня изучить этот раздел .NET.

        Несмотря на всю мощь фреймворка, многие узкоспециализированные задачи на нем не решаются. Например, установка integrity level (англ.) с помощью SDDL невозможна во встроенных классах безопасности .NET (попытка подать SDDL с описанием integrity level не вызывает ошибки, но не выполняет требуемых действий), поэтому приходится работать через p/invoke. Ну а COM применяется достаточно часто в больших проектах, а писать их на C# куда проще, чем на С++. Мне показалось проще один раз изучить все проблемы взаимодействия, чем учиться правильно и грамотно писать на С++.

        Отмечу, что многие задачи на C# решить все-таки не получится. Например, работа с MAPI требует такой тонны рутинной работы по COM Interop (здесь фреймворк не справляется), что легче написать требуемый код на C++, обернуть его с помощью ATL в COM и заинтеропить уже получившийся объект. Получается что-то вроде Redemption, только заточенного под собственные нужды.
          0
          а готовых оберток для работы с MAPI нет?
            0
            Есть, конечно. Тот же самый Redemption. Но он, во-первых, стоит денег, а во-вторых — это дыра в безопасности.
          0
          Нам тоже приходится много взаимодействовать с неуправляемым кодом. На это две причины: взаимодействие через COM (например, с Microsoft Office или 1C) и работа с нативными длл (последнее — на Mobile Devices через .NET CF).

          С первым пунктом всё должно быть очевидно — лёгкость разработки на .NET приводит к экономии времени, которая в несколько раз превышает затраты на исследования COM Interop.

          Во втором пункте приходится работать с функциями, управляющими специфичными для устройств оборудованием — WiFi-адаптером, вибратором, сканером штрихкодов. Что касается последних — то там вообще никакой унификации нет, каждый производитель предлагает свой подход.
            0
            спасибо, интересно
          0
          Следуем заметить что если вы создаете деволтный DLL Win32 проект под Visual Studio (ну, тот который 42 возвращает) то с ходу работать с P/Invoke он не будет — нужно обернуть все элементы в extern «C» {… }. Или указать calling convention.
          • НЛО прилетело и опубликовало эту надпись здесь
              0
              На самом деле в настройках DllImport есть возможность указать в качестве соглашения thiscall, а вот fastcall действительно не поддерживается.

              То есть мы можем работать через stdcall, cdecl и thiscall.
              • НЛО прилетело и опубликовало эту надпись здесь
            0
            Статья просто супер!
              0
              Конечно нужно продолжение, тема очень интересна, а освещается, особенно на русскоязычных ресурсах редко. И у вас, на мой взгляд, отлично получается объяснять простыми словами сложные вещи.
                +1
                Все WinAPI функции используют соглашение stdcall.

                Не все. Функции, которые принимают ... используют соглашение cdecl aka WINAPIV, ибо в случае с ... вызываемая функция не может сама очистить стек.

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

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