Pull to refresh

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

Reading time 11 min
Views 7.4K
Методика все та же — минимум объяснений, максимум рецептов. Для глубинного понимания происходящих процессов рекомендую обратиться к документации в 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, что сильно ничего не меняет, но тем не менее, стоит отметить этот факт.
Tags:
Hubs:
+20
Comments 13
Comments Comments 13

Articles