System Programming Windows

P/Invoke та Windows API — Міст між .NET та Native Code

Повний розбір Platform Invocation Services (P/Invoke) — від базових викликів Win32 API до складного marshalling структур, callback-функцій та SafeHandle. Теорія, анатомія та практика з детальними прикладами взаємодії з нативним кодом Windows.

P/Invoke та Windows API — Міст між .NET та Native Code

Навіщо Виходити за Межі Managed Code?

.NET Runtime надає величезну бібліотеку класів (BCL), що покриває більшість повсякденних завдань: файли, мережа, потоки, колекції. Проте існують сценарії, коли managed API або не існує, або недостатньо для вирішення задачі. Саме тоді розробники звертаються до Platform Invocation Services (P/Invoke) — механізму виклику нативних функцій з C/C++ бібліотек безпосередньо з C# коду.

Розглянемо три типові сценарії, що вимагають P/Invoke:

Сценарій перший: Низькорівневий доступ до ОС. Потрібно отримати детальну інформацію про систему, яку BCL не надає. Наприклад, визначити точну архітектуру процесора (x86, x64, ARM64), отримати серійний номер BIOS, або дізнатися версію firmware материнської плати. Win32 API функція GetSystemInfo() з kernel32.dll надає структуру з 20+ полями, тоді як Environment.ProcessorCount дає лише кількість ядер.

Сценарій другий: Інтеграція зі сторонніми native бібліотеками. Компанія має legacy C++ бібліотеку для обробки зображень, що працює швидше за managed альтернативи завдяки SIMD-інструкціям та ручній оптимізації. Переписувати 100,000 рядків коду на C# — економічно недоцільно. P/Invoke дозволяє викликати функції з DLL безпосередньо, передаючи вказівники на пам'ять та отримуючи результати.

Сценарій третій: Автоматизація UI та системні хуки. Потрібно створити глобальний hotkey (Ctrl+Shift+F12), що працює навіть коли застосунок не у фокусі, або перехопити всі натискання клавіш у системі для keylogger-функціональності (легітимної, наприклад, для accessibility tools). RegisterHotKey() та SetWindowsHookEx() з user32.dll — єдиний спосіб реалізувати це на Windows.

.NET не ізолює вас від платформи — навпаки, надає контрольований міст до нативного коду. P/Invoke — це той міст. Але як і будь-який міст між двома світами (managed та unmanaged), він вимагає розуміння обох сторін: як працює CLR, як працює Win32, і як правильно перекладати дані між ними.


Архітектура Windows API: Історія та Структура

Від Win16 до Win32: Еволюція API

Windows API (також відомий як Win32 API) — це набір C-функцій та структур даних, що становлять фундаментальний інтерфейс операційної системи Windows. Історія його розвитку допомагає зрозуміти деякі дивні на перший погляд рішення.

Windows 1.0–3.11 (1985–1994): Win16 Era. Перші версії Windows працювали у 16-бітному режимі процесорів Intel 8086/80286. API складався з ~450 функцій, розподілених між трьома основними DLL: KERNEL (управління пам'яттю, процеси), USER (вікна, повідомлення), GDI (графіка). Функції мали обмеження: максимум 64 КБ на сегмент пам'яті, кооперативна багатозадачність (програма сама мала віддавати управління).

Windows 95/NT (1995): Win32 Revolution. Перехід на 32-бітну архітектуру (Intel 80386+) дав плоску модель пам'яті (4 ГБ адресного простору), preemptive multitasking, захищену пам'ять. API розширився до ~2000 функцій. З'явилися суфікси A (ANSI) та W (Wide/Unicode) для функцій, що працюють з рядками: CreateFileA() приймає char*, CreateFileW()wchar_t*. Windows NT використовував Unicode всередині, тому CreateFileA() насправді викликає CreateFileW() після конвертації.

Windows XP–11 (2001–2024): Win32 Maturity. API стабілізувався на ~4000 функціях. Додалися нові DLL: advapi32.dll (безпека, реєстр), ws2_32.dll (сокети), ole32.dll (COM). З'явилася WinRT (Windows Runtime) для UWP застосунків, але класичний Win32 залишається основою для desktop програм.

Структура Win32 API: Основні DLL

Win32 API не є монолітною бібліотекою — це колекція DLL, кожна з яких відповідає за певну підсистему:

kernel32.dll
Core OS
Ядро операційної системи: управління процесами (CreateProcess, TerminateProcess), потоками (CreateThread), пам'яттю (VirtualAlloc, HeapAlloc), файлами (CreateFile, ReadFile, WriteFile), синхронізацією (CreateMutex, WaitForSingleObject). Це найнижчий рівень, що доступний з user mode.
user32.dll
User Interface
Віконна система: створення вікон (CreateWindow), обробка повідомлень (GetMessage, DispatchMessage), діалоги (MessageBox), меню, іконки. Кожен GUI застосунок на Windows використовує user32.dll.
gdi32.dll
Graphics
Graphics Device Interface: малювання (LineTo, Rectangle, TextOut), шрифти, кольори, bitmap-и. До Windows Vista це був єдиний спосіб малювати на екрані. Зараз частково замінений на Direct2D/Direct3D, але залишається для сумісності.
advapi32.dll
Advanced Services
Розширені служби: реєстр Windows (RegOpenKeyEx, RegQueryValueEx), безпека (OpenProcessToken, AdjustTokenPrivileges), служби (CreateService, StartService), Event Log. Багато функцій вимагають підвищених прав.
shell32.dll
Shell
Windows Shell: робота з файловою системою через Shell (SHFileOperation), ярлики, іконки файлів, діалоги вибору папок (SHBrowseForFolder). Інтеграція з Explorer.
ws2_32.dll
Networking
Winsock 2: Berkeley Sockets API для мережі. socket(), connect(), send(), recv(). .NET System.Net.Sockets використовує це під капотом.

Handles: Універсальна Абстракція Ресурсів

Центральна концепція Win32 API — Handle (дескриптор). Це непрозоре 32/64-бітне значення (тип HANDLE у C, IntPtr у C#), що ідентифікує системний ресурс: файл, процес, потік, mutex, вікно, bitmap, шрифт.

Handle — це не вказівник на об'єкт (хоча на ранніх версіях Windows це було саме так). Це індекс у таблиці дескрипторів процесу, що зберігається у kernel memory. Kernel зберігає реальний вказівник на _OBJECT структуру, а процесу повертає лише індекс. Це забезпечує безпеку: процес не може безпосередньо читати kernel memory, навіть якщо знає handle.

// Приклад: Handle на файл
IntPtr fileHandle = CreateFile(
    "data.txt",
    GENERIC_READ,
    FILE_SHARE_READ,
    IntPtr.Zero,
    OPEN_EXISTING,
    FILE_ATTRIBUTE_NORMAL,
    IntPtr.Zero
);

if (fileHandle == INVALID_HANDLE_VALUE)
{
    // Помилка відкриття файлу
    int error = Marshal.GetLastWin32Error();
    throw new Win32Exception(error);
}

// Використання handle...

// ОБОВ'ЯЗКОВО закрити handle після використання
CloseHandle(fileHandle);
Handle Leak — класична помилка. Кожен відкритий handle споживає kernel memory (зазвичай 100-200 байт). Якщо не закривати handles — процес поступово "витікає" пам'ять. Task Manager показує це у колонці "Handles". Нормальне значення: 100-1000. Якщо 10,000+ — ймовірно leak.

DllImport: Базовий Синтаксис P/Invoke

Перший Виклик: MessageBox

Найпростіший спосіб зрозуміти P/Invoke — викликати функцію, що не вимагає складних параметрів. MessageBox() з user32.dll — ідеальний кандидат.

Сигнатура у C (з MSDN):

int MessageBoxW(
  HWND    hWnd,        // Handle на батьківське вікно (NULL = немає)
  LPCWSTR lpText,      // Текст повідомлення (Unicode string)
  LPCWSTR lpCaption,   // Заголовок вікна
  UINT    uType        // Кнопки та іконка (MB_OK, MB_YESNO, тощо)
);

Переклад на C#:

MessageBoxExample.cs
using System.Runtime.InteropServices;

class Program
{
    // Атрибут [DllImport] вказує CLR, де знайти функцію
    [DllImport("user32.dll", CharSet = CharSet.Unicode)]
    static extern int MessageBox(
        IntPtr hWnd,          // HWND → IntPtr (handle)
        string lpText,        // LPCWSTR → string (CLR автоматично конвертує)
        string lpCaption,     // LPCWSTR → string
        uint uType            // UINT → uint
    );

    // Константи для uType (з winuser.h)
    const uint MB_OK = 0x00000000;
    const uint MB_ICONINFORMATION = 0x00000040;
    const uint MB_YESNO = 0x00000004;
    const uint MB_ICONQUESTION = 0x00000020;

    static void Main()
    {
        // Виклик виглядає як звичайний C# метод
        int result = MessageBox(
            IntPtr.Zero,                           // Немає батьківського вікна
            "Це виклик нативної Win32 функції!",   // Текст
            "P/Invoke Demo",                       // Заголовок
            MB_OK | MB_ICONINFORMATION             // Кнопка OK + іконка інформації
        );

        Console.WriteLine($"Користувач натиснув кнопку з кодом: {result}");
    }
}
MessageBoxExample
$ dotnet run
[З'являється нативне вікно Windows MessageBox]
Користувач натиснув кнопку з кодом: 1

Анатомія Атрибута DllImport

[DllImport] — це спеціальний атрибут, що інструктує CLR виконати наступні дії під час виклику методу:

Крок 1: Завантаження DLL. При першому виклику MessageBox() CLR шукає user32.dll у стандартних шляхах Windows: C:\Windows\System32, C:\Windows\System, поточна директорія, PATH. Якщо DLL не знайдено — викидається DllNotFoundException.

Крок 2: Пошук функції. CLR шукає експортовану функцію з іменем MessageBox у таблиці експорту DLL. Якщо не знайдено — EntryPointNotFoundException. Важливо: ім'я має точно збігатися, включаючи регістр (хоча Windows API зазвичай case-insensitive).

Крок 3: Marshalling параметрів. CLR конвертує managed типи (C#) у unmanaged (C):

  • stringwchar_t* (UTF-16 Unicode) — CLR виділяє unmanaged пам'ять, копіює рядок, передає вказівник
  • IntPtrvoid* — передається "як є"
  • uintunsigned int — передається "як є"

Крок 4: Виклик функції. CLR переключається з managed code у unmanaged, викликає функцію через function pointer, чекає повернення.

Крок 5: Marshalling результату. Повернене значення int копіюється назад у managed heap. CLR звільняє тимчасову unmanaged пам'ять, виділену для рядків.

Крок 6: Обробка помилок. Якщо функція повертає код помилки (зазвичай 0 або -1), CLR може автоматично викинути Win32Exception якщо вказано SetLastError = true.

Властивості DllImport: Повний Список

EntryPoint
string
Ім'я функції у DLL, якщо воно відрізняється від імені C# методу. Корисно для функцій з суфіксами A/W або для уникнення конфліктів імен.
[DllImport("user32.dll", EntryPoint = "MessageBoxW")]
static extern int ShowMessage(IntPtr hWnd, string text, string caption, uint type);
CharSet
CharSet enum
Визначає, як конвертувати рядки: CharSet.Ansi (ANSI/UTF-8, додає суфікс A), CharSet.Unicode (UTF-16, додає суфікс W), CharSet.Auto (Unicode на NT-based Windows, Ansi на Win9x — застаріло). Рекомендація: завжди CharSet.Unicode для сучасних Windows.
SetLastError
bool
Якщо true — CLR зберігає значення GetLastError() після виклику функції. Доступ через Marshal.GetLastWin32Error(). Обов'язково для функцій, що повідомляють про помилки через GetLastError() (більшість Win32 API).
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr CreateFile(string lpFileName, uint dwDesiredAccess, ...);

IntPtr handle = CreateFile("test.txt", GENERIC_READ, ...);
if (handle == INVALID_HANDLE_VALUE)
{
    int error = Marshal.GetLastWin32Error();
    throw new Win32Exception(error); // Автоматично форматує повідомлення помилки
}
CallingConvention
CallingConvention enum
Визначає, як передаються параметри у стек: CallingConvention.Winapi (за замовчуванням, StdCall на x86, Cdecl на x64), StdCall (параметри справа наліво, callee очищає стек), Cdecl (caller очищує стек). Для Win32 API завжди StdCall або Winapi.
ExactSpelling
bool
Якщо true — CLR не додає суфікси A/W автоматично. Корисно для функцій без Unicode/ANSI варіантів або коли ви явно вказали EntryPoint.
PreserveSig
bool
За замовчуванням true. Якщо false — CLR перетворює HRESULT (COM error code) у exception. Використовується для COM Interop, не для Win32 API.

CharSet та Unicode: Чому Це Важливо

Windows внутрішньо використовує UTF-16 Unicode для всіх рядків. Функції з суфіксом A (ANSI) — це обгортки, що конвертують char*wchar_t*, викликають W-версію, конвертують назад. Це додає overhead та може призвести до втрати даних (не всі Unicode символи можна представити в ANSI).

// ❌ ПОГАНО: використання ANSI
[DllImport("user32.dll", CharSet = CharSet.Ansi)]
static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);

MessageBox(IntPtr.Zero, "Привіт 🚀", "Test", MB_OK);
// Результат: "Привіт ?" — emoji втрачено при конвертації в ANSI

// ✅ ДОБРЕ: використання Unicode
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);

MessageBox(IntPtr.Zero, "Привіт 🚀", "Test", MB_OK);
// Результат: "Привіт 🚀" — все коректно
Best Practice: Завжди використовуйте CharSet = CharSet.Unicode для Win32 API. Це уникає зайвих конвертацій, зберігає всі символи та відповідає внутрішній реалізації Windows.

Marshalling: Перетворення Типів між Світами

Blittable vs Non-Blittable Types

Marshalling — це процес конвертації даних між managed (CLR) та unmanaged (native) пам'яттю. Не всі типи вимагають конвертації. Типи, що мають однакове представлення в обох світах, називаються blittable (від "blit" — block transfer).

Blittable типи (можна копіювати побайтово без змін):

byte, sbyte, short, ushort, int, uint, long, ulong
float, double
IntPtr, UIntPtr

Ці типи передаються "як є" — CLR просто копіює байти. Швидко та безпечно.

Non-blittable типи (вимагають конвертації):

bool      // C#: 1 байт, C: 4 байти (int) або 1 байт (BOOL)
char      // C#: 2 байти UTF-16, C: 1 байт ANSI або 2 байти wchar_t
string    // C#: managed object, C: char* або wchar_t*
arrays    // C#: managed array, C: pointer

Для цих типів CLR виконує складну роботу: виділяє unmanaged пам'ять, копіює дані, конвертує формат, передає вказівник, після виклику звільняє пам'ять.

Marshalling Рядків: Детальний Розбір

Рядки — найскладніший випадок marshalling. Існує кілька стратегій:

Стратегія 1: Автоматичний marshalling (рекомендовано для більшості випадків):

[DllImport("user32.dll", CharSet = CharSet.Unicode)]
static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);

// CLR автоматично:
// 1. Виділяє unmanaged пам'ять
// 2. Копіює рядок у UTF-16
// 3. Передає wchar_t* у функцію
// 4. Після виклику звільняє пам'ять

Стратегія 2: StringBuilder для out-параметрів:

Коли функція записує дані у буфер, що ви надаєте:

[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern uint GetCurrentDirectory(uint nBufferLength, StringBuilder lpBuffer);

var buffer = new StringBuilder(260); // MAX_PATH
uint length = GetCurrentDirectory((uint)buffer.Capacity, buffer);

if (length == 0)
    throw new Win32Exception(Marshal.GetLastWin32Error());

string currentDir = buffer.ToString();
Console.WriteLine($"Поточна директорія: {currentDir}");
StringBuilder — це managed клас, але CLR має спеціальну логіку marshalling для нього: передає вказівник на внутрішній буфер, дозволяє native коду писати туди, після виклику синхронізує довжину рядка.

Стратегія 3: Ручний marshalling через IntPtr:

Для максимального контролю або коли автоматичний marshalling не підходить:

string managedString = "Hello, Win32!";

// Виділяємо unmanaged пам'ять та копіюємо рядок
IntPtr unmanagedString = Marshal.StringToHGlobalUni(managedString);

try
{
    // Передаємо IntPtr у функцію
    SomeNativeFunction(unmanagedString);
}
finally
{
    // ОБОВ'ЯЗКОВО звільняємо пам'ять
    Marshal.FreeHGlobal(unmanagedString);
}
Пам'ять, виділена через Marshal.StringToHGlobalUni(), НЕ управляється GC. Якщо забути FreeHGlobal() — це memory leak. Завжди використовуйте try/finally або using з custom wrapper.

Marshalling Структур: Layout та Alignment

Проблема: Різні Правила Розміщення

У C# структури за замовчуванням мають автоматичний layout — CLR може переставляти поля для оптимізації (вирівнювання, щільність). У C структури мають послідовний layout — поля розміщені у порядку оголошення з вирівнюванням за правилами платформи.

Коли ви передаєте C# структуру у Win32 API, CLR має знати, як розмістити поля у пам'яті, щоб native код правильно їх прочитав. Для цього використовується атрибут [StructLayout].

Приклад: SYSTEMTIME Structure

Win32 API функція GetSystemTime() заповнює структуру SYSTEMTIME поточним часом UTC:

Визначення у C (з winbase.h):

typedef struct _SYSTEMTIME {
  WORD wYear;           // 2 байти
  WORD wMonth;          // 2 байти
  WORD wDayOfWeek;      // 2 байти
  WORD wDay;            // 2 байти
  WORD wHour;           // 2 байти
  WORD wMinute;         // 2 байти
  WORD wSecond;         // 2 байти
  WORD wMilliseconds;   // 2 байти
} SYSTEMTIME;           // Загалом: 16 байт

Переклад на C#:

SystemTimeExample.cs
using System.Runtime.InteropServices;

// StructLayout.Sequential — поля у порядку оголошення
// CharSet.Unicode — для сумісності з іншими структурами, що містять рядки
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct SYSTEMTIME
{
    public ushort wYear;         // WORD → ushort (2 байти)
    public ushort wMonth;
    public ushort wDayOfWeek;
    public ushort wDay;
    public ushort wHour;
    public ushort wMinute;
    public ushort wSecond;
    public ushort wMilliseconds;
}

class Program
{
    [DllImport("kernel32.dll")]
    static extern void GetSystemTime(out SYSTEMTIME lpSystemTime);

    static void Main()
    {
        SYSTEMTIME st;
        GetSystemTime(out st);

        Console.WriteLine($"UTC Time: {st.wYear:D4}-{st.wMonth:D2}-{st.wDay:D2} " +
                          $"{st.wHour:D2}:{st.wMinute:D2}:{st.wSecond:D2}.{st.wMilliseconds:D3}");
        Console.WriteLine($"Day of week: {(DayOfWeek)st.wDayOfWeek}");
    }
}
SystemTimeExample
$ dotnet run
UTC Time: 2026-03-31 20:17:45.376
Day of week: Tuesday

StructLayout: Три Режими

LayoutKind.Sequential
Послідовний
Поля розміщуються у порядку оголошення. CLR може додавати padding для вирівнювання (alignment). Це стандартний режим для P/Invoke структур. Використовується у 95% випадків.
LayoutKind.Explicit
Явний
Ви вручну вказуєте offset кожного поля через [FieldOffset(N)]. Дозволяє створювати union-и (кілька полів за однією адресою) або пропускати байти. Складніший, але дає повний контроль.
[StructLayout(LayoutKind.Explicit)]
struct Union
{
    [FieldOffset(0)] public int IntValue;
    [FieldOffset(0)] public float FloatValue;  // Той самий offset — union!
    [FieldOffset(0)] public byte Byte0;
    [FieldOffset(1)] public byte Byte1;
    [FieldOffset(2)] public byte Byte2;
    [FieldOffset(3)] public byte Byte3;
}
LayoutKind.Auto
Автоматичний
CLR сам вирішує, як розмістити поля (може переставляти для оптимізації). Ніколи не використовуйте для P/Invoke — native код отримає неправильний layout.

Alignment та Padding: Чому Розмір Структури Може Відрізнятися

Процесори ефективніше читають дані, вирівняні за певними адресами. Наприклад, 4-байтове int краще читати з адреси, кратної 4. Компілятор C та CLR автоматично додають padding (порожні байти) для вирівнювання.

// Приклад: структура з padding
[StructLayout(LayoutKind.Sequential)]
struct Example
{
    public byte b1;      // Offset 0, розмір 1
    // [padding 3 байти]
    public int i;        // Offset 4, розмір 4 (вирівняно за 4)
    public byte b2;      // Offset 8, розмір 1
    // [padding 3 байти]
}
// Загальний розмір: 12 байт (не 6!)

Console.WriteLine(Marshal.SizeOf<Example>()); // Виведе: 12

Якщо C структура має інший padding (наприклад, через #pragma pack), ви можете вказати Pack у StructLayout:

[StructLayout(LayoutKind.Sequential, Pack = 1)] // Без padding
struct Packed
{
    public byte b1;   // Offset 0
    public int i;     // Offset 1 (не 4!)
    public byte b2;   // Offset 5
}
// Розмір: 6 байт
Зміна Pack може призвести до unaligned access — на деяких архітектурах (ARM) це викликає exception, на x86/x64 — просто повільніше. Використовуйте Pack тільки якщо C код також використовує #pragma pack.

Marshalling Масивів у Структурах

Коли структура містить масив фіксованого розміру, використовуйте [MarshalAs]:

// C структура:
// typedef struct {
//     char name[256];
//     int id;
// } Person;

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
struct Person
{
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
    public string name;  // Фіксований масив 256 байт

    public int id;
}

Для масивів чисел:

[StructLayout(LayoutKind.Sequential)]
struct Data
{
    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)]
    public int[] values;  // Масив з 10 int-ів (40 байт)
}

LibraryImport: Сучасна Альтернатива (.NET 7+)

Проблема DllImport: Runtime Marshalling

Традиційний [DllImport] виконує marshalling у runtime — CLR генерує IL-код для конвертації типів під час виконання програми. Це має кілька недоліків:

  1. Overhead: Кожен виклик P/Invoke проходить через marshalling stub, що додає 10-50 наносекунд
  2. Reflection: CLR використовує reflection для аналізу типів, що повільно
  3. Trimming: При публікації з PublishTrimmed=true важко визначити, які типи використовуються для marshalling

LibraryImport: Source Generator Approach

.NET 7 додав [LibraryImport] — атрибут, що використовує source generator для генерації marshalling коду на етапі компіляції. Це дає:

  • Швидкість: Marshalling код inline-иться, немає runtime overhead
  • AOT-сумісність: Працює з Native AOT (повна компіляція у native код без JIT)
  • Trimming-friendly: Компілятор точно знає, які типи потрібні

Порівняння синтаксису:

// Старий підхід (DllImport)
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);

// Новий підхід (LibraryImport) — .NET 7+
[LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
static partial int MessageBox(IntPtr hWnd, string text, string caption, uint type);
//     ^^^^^^^ Обов'язково partial — source generator додасть реалізацію

Source generator створює приблизно такий код (спрощено):

static partial int MessageBox(IntPtr hWnd, string text, string caption, uint type)
{
    IntPtr textPtr = Marshal.StringToCoTaskMemUni(text);
    IntPtr captionPtr = Marshal.StringToCoTaskMemUni(caption);
    try
    {
        return MessageBoxW(hWnd, textPtr, captionPtr, type);
    }
    finally
    {
        Marshal.FreeCoTaskMem(textPtr);
        Marshal.FreeCoTaskMem(captionPtr);
    }
}

[DllImport("user32.dll", EntryPoint = "MessageBoxW", ExactSpelling = true)]
static extern int MessageBoxW(IntPtr hWnd, IntPtr text, IntPtr caption, uint type);

Коли Використовувати LibraryImport

✅ Використовуйте LibraryImport

  • Новий код на .NET 7+
  • Потрібна максимальна продуктивність (hot path)
  • Публікація з Native AOT
  • Trimming-friendly застосунки

⚠️ Залишайтеся на DllImport

  • Підтримка .NET Framework або .NET Core 3.1
  • Складний marshalling (custom marshalers)
  • Legacy код, що працює стабільно
  • COM Interop (LibraryImport не підтримує COM)

SafeHandle: Безпечне Управління Ресурсами

Проблема: IntPtr та Finalizers

Коли ви отримуєте handle з Win32 API як IntPtr, виникає проблема: GC не знає, що це системний ресурс, який потрібно звільнити. Якщо об'єкт, що тримає IntPtr, збирається GC до виклику CloseHandle() — handle leak.

Наївний підхід (небезпечний):

class FileWrapper
{
    private IntPtr _handle;

    public FileWrapper(string path)
    {
        _handle = CreateFile(path, GENERIC_READ, ...);
        if (_handle == INVALID_HANDLE_VALUE)
            throw new Win32Exception();
    }

    // ❌ ПРОБЛЕМА: якщо забути викликати Dispose — handle leak
    public void Dispose()
    {
        if (_handle != IntPtr.Zero && _handle != INVALID_HANDLE_VALUE)
        {
            CloseHandle(_handle);
            _handle = IntPtr.Zero;
        }
    }

    // ❌ ПРОБЛЕМА: finalizer може викликатися у будь-якому потоці,
    // після того як об'єкт частково зруйновано
    ~FileWrapper()
    {
        Dispose();
    }
}

SafeHandle: Правильне Рішення

SafeHandle — абстрактний клас з BCL, що вирішує всі проблеми:

  1. Critical Finalizer: Гарантовано викликається навіть при AppDomain unload або Thread.Abort()
  2. Reference Counting: Захист від race condition при закритті handle
  3. GC Integration: GC знає, що це системний ресурс

Реалізація для файлових handles:

SafeFileHandleExample.cs
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;

// SafeHandleZeroOrMinusOneIsInvalid — базовий клас для handles,
// де 0 або -1 означають невалідний handle
class SafeFileHandle : SafeHandleZeroOrMinusOneIsInvalid
{
    // Конструктор для створення "порожнього" handle
    public SafeFileHandle() : base(ownsHandle: true) { }

    // Конструктор для обгортання існуючого handle
    public SafeFileHandle(IntPtr handle, bool ownsHandle) : base(ownsHandle)
    {
        SetHandle(handle);
    }

    // Метод звільнення ресурсу — викликається автоматично GC або при Dispose
    protected override bool ReleaseHandle()
    {
        return CloseHandle(handle);
    }

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern bool CloseHandle(IntPtr handle);
}

class Program
{
    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    static extern SafeFileHandle CreateFile(
        string lpFileName,
        uint dwDesiredAccess,
        uint dwShareMode,
        IntPtr lpSecurityAttributes,
        uint dwCreationDisposition,
        uint dwFlagsAndAttributes,
        IntPtr hTemplateFile
    );

    const uint GENERIC_READ = 0x80000000;
    const uint OPEN_EXISTING = 3;

    static void Main()
    {
        // using гарантує виклик Dispose, навіть при exception
        using SafeFileHandle handle = CreateFile(
            "test.txt",
            GENERIC_READ,
            0,
            IntPtr.Zero,
            OPEN_EXISTING,
            0,
            IntPtr.Zero
        );

        if (handle.IsInvalid)
        {
            throw new Win32Exception(Marshal.GetLastWin32Error());
        }

        Console.WriteLine("Файл відкрито успішно!");
        // handle автоматично закриється при виході з using
    }
}
.NET вже надає готові SafeHandle класи:SafeFileHandle (для файлів), SafeWaitHandle (для синхронізації), SafeProcessHandle (для процесів). Використовуйте їх замість власних реалізацій.

Callback Functions: Виклики з Native у Managed

Проблема: Native Код Викликає C# Метод

Багато Win32 API функцій приймають callback — вказівник на функцію, яку вони викликатимуть для повідомлення про події. Класичний приклад — EnumWindows(), що перебирає всі вікна у системі та викликає ваш callback для кожного.

Сигнатура у C:

BOOL EnumWindows(
  WNDENUMPROC lpEnumFunc,  // Callback функція
  LPARAM      lParam       // Користувацькі дані
);

// Тип callback-функції
typedef BOOL (CALLBACK *WNDENUMPROC)(
  HWND   hwnd,      // Handle вікна
  LPARAM lParam     // Користувацькі дані
);

Delegate як Function Pointer

У C# делегат може бути перетворений на function pointer для передачі у native код. Використовується атрибут [UnmanagedFunctionPointer]:

EnumWindowsExample.cs
using System.Runtime.InteropServices;
using System.Text;

class Program
{
    // Делегат, що відповідає сигнатурі WNDENUMPROC
    [UnmanagedFunctionPointer(CallingConvention.StdCall)]
    delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);

    [DllImport("user32.dll")]
    static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);

    [DllImport("user32.dll", CharSet = CharSet.Unicode)]
    static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);

    [DllImport("user32.dll")]
    static extern bool IsWindowVisible(IntPtr hWnd);

    static void Main()
    {
        var windows = new List<(IntPtr Handle, string Title)>();

        // Callback функція — буде викликана для кожного вікна
        bool EnumWindowCallback(IntPtr hWnd, IntPtr lParam)
        {
            // Фільтруємо тільки видимі вікна
            if (!IsWindowVisible(hWnd))
                return true; // true = продовжити перебір

            var title = new StringBuilder(256);
            int length = GetWindowText(hWnd, title, title.Capacity);

            if (length > 0)
            {
                windows.Add((hWnd, title.ToString()));
            }

            return true; // Продовжити перебір
        }

        // Передаємо делегат у native функцію
        EnumWindows(EnumWindowCallback, IntPtr.Zero);

        Console.WriteLine($"Знайдено {windows.Count} видимих вікон:\n");
        foreach (var (handle, title) in windows.Take(10))
        {
            Console.WriteLine($"[0x{handle:X8}] {title}");
        }
    }
}
EnumWindowsExample
$ dotnet run
Знайдено 47 видимих вікон:
[0x000A0C12] Visual Studio Code
[0x00120F3A] Google Chrome
[0x00051B24] Windows Terminal
[0x00082D16] Task Manager
[0x000F1C08] Spotify

GC та Callback: Критична Проблема

Коли ви передаєте делегат у native код, CLR створює function pointer (thunk), що перенаправляє виклик з unmanaged у managed. Проблема: GC не знає, що native код тримає посилання на делегат.

Сценарій помилки:

// ❌ НЕБЕЗПЕЧНО!
void RegisterCallback()
{
    EnumWindows((hWnd, lParam) => 
    {
        Console.WriteLine($"Window: {hWnd}");
        return true;
    }, IntPtr.Zero);
}
// Делегат (lambda) — локальна змінна, може бути зібрана GC
// Native код може викликати callback після того, як GC зібрав делегат
// Результат: AccessViolationException або corrupted state

Правильний підхід:

class WindowEnumerator
{
    // Зберігаємо делегат як поле класу — GC не збере його
    private readonly EnumWindowsProc _callback;

    public WindowEnumerator()
    {
        _callback = EnumWindowCallback;
    }

    private bool EnumWindowCallback(IntPtr hWnd, IntPtr lParam)
    {
        // Обробка...
        return true;
    }

    public void Enumerate()
    {
        EnumWindows(_callback, IntPtr.Zero);
    }
}
Золоте правило callback-ів: Якщо native код зберігає function pointer для пізнішого виклику (не одразу), ви обов'язково маєте тримати посилання на делегат у managed коді (поле класу, статична змінна). Інакше GC може зібрати делегат, і native виклик призведе до crash.

GCHandle.Alloc: Pinning для Довгоживучих Callbacks

Для callbacks, що живуть довго (наприклад, Windows Hooks), використовуйте GCHandle.Alloc з GCHandleType.Normal:

class HookManager
{
    private EnumWindowsProc _callback;
    private GCHandle _callbackHandle;

    public void Install()
    {
        _callback = MyCallback;
        
        // "Закріплюємо" делегат — GC не зможе його зібрати
        _callbackHandle = GCHandle.Alloc(_callback, GCHandleType.Normal);

        // Передаємо у native код...
        SetWindowsHookEx(..., _callback, ...);
    }

    public void Uninstall()
    {
        UnhookWindowsHookEx(...);

        // Звільняємо GCHandle — тепер GC може зібрати делегат
        if (_callbackHandle.IsAllocated)
            _callbackHandle.Free();
    }

    private bool MyCallback(IntPtr hWnd, IntPtr lParam)
    {
        // ...
        return true;
    }
}

Практичний Приклад: GetSystemInfo

Побудуємо повноцінну утиліту для отримання детальної інформації про систему через Win32 API.

Крок 1: Визначення структури SYSTEM_INFO

SystemInfo.cs
using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Sequential)]
struct SYSTEM_INFO
{
    public ushort wProcessorArchitecture;
    public ushort wReserved;
    public uint dwPageSize;
    public IntPtr lpMinimumApplicationAddress;
    public IntPtr lpMaximumApplicationAddress;
    public IntPtr dwActiveProcessorMask;
    public uint dwNumberOfProcessors;
    public uint dwProcessorType;
    public uint dwAllocationGranularity;
    public ushort wProcessorLevel;
    public ushort wProcessorRevision;
}

// Константи для wProcessorArchitecture
enum ProcessorArchitecture : ushort
{
    Intel = 0,
    MIPS = 1,
    Alpha = 2,
    PowerPC = 3,
    ARM = 5,
    IA64 = 6,
    AMD64 = 9,
    ARM64 = 12,
    Unknown = 0xFFFF
}

Крок 2: P/Invoke декларація

NativeMethods.cs
static class NativeMethods
{
    [DllImport("kernel32.dll")]
    public static extern void GetSystemInfo(out SYSTEM_INFO lpSystemInfo);

    [DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
    public static extern void GetSystemDirectory(
        StringBuilder lpBuffer, 
        uint uSize
    );

    [DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
    public static extern void GetWindowsDirectory(
        StringBuilder lpBuffer, 
        uint uSize
    );
}

Крок 3: Обгортка з зручним API

SystemInfoHelper.cs
using System.Text;

class SystemInfoHelper
{
    public static void PrintSystemInfo()
    {
        SYSTEM_INFO sysInfo;
        NativeMethods.GetSystemInfo(out sysInfo);

        Console.WriteLine("═══════════════════════════════════════════════");
        Console.WriteLine("           SYSTEM INFORMATION");
        Console.WriteLine("═══════════════════════════════════════════════\n");

        // Архітектура процесора
        var arch = (ProcessorArchitecture)sysInfo.wProcessorArchitecture;
        Console.WriteLine($"Processor Architecture: {arch}");
        
        string archDescription = arch switch
        {
            ProcessorArchitecture.Intel => "x86 (32-bit)",
            ProcessorArchitecture.AMD64 => "x64 (64-bit)",
            ProcessorArchitecture.ARM64 => "ARM64 (64-bit)",
            ProcessorArchitecture.ARM => "ARM (32-bit)",
            _ => "Unknown"
        };
        Console.WriteLine($"  → {archDescription}\n");

        // Кількість процесорів
        Console.WriteLine($"Number of Processors: {sysInfo.dwNumberOfProcessors}");
        Console.WriteLine($"  → Logical cores available to OS\n");

        // Розмір сторінки пам'яті
        Console.WriteLine($"Page Size: {sysInfo.dwPageSize:N0} bytes");
        Console.WriteLine($"  → {sysInfo.dwPageSize / 1024} KB (memory allocation unit)\n");

        // Діапазон адресного простору
        Console.WriteLine($"Application Address Space:");
        Console.WriteLine($"  Min: 0x{sysInfo.lpMinimumApplicationAddress:X16}");
        Console.WriteLine($"  Max: 0x{sysInfo.lpMaximumApplicationAddress:X16}");
        
        long addressSpaceSize = sysInfo.lpMaximumApplicationAddress.ToInt64() - 
                                sysInfo.lpMinimumApplicationAddress.ToInt64();
        Console.WriteLine($"  → {addressSpaceSize / (1024.0 * 1024 * 1024):F1} GB virtual address space\n");

        // Granularity для VirtualAlloc
        Console.WriteLine($"Allocation Granularity: {sysInfo.dwAllocationGranularity:N0} bytes");
        Console.WriteLine($"  → {sysInfo.dwAllocationGranularity / 1024} KB (VirtualAlloc alignment)\n");

        // Системні директорії
        var sysDir = new StringBuilder(260);
        var winDir = new StringBuilder(260);
        NativeMethods.GetSystemDirectory(sysDir, (uint)sysDir.Capacity);
        NativeMethods.GetWindowsDirectory(winDir, (uint)winDir.Capacity);

        Console.WriteLine($"System Directory: {sysDir}");
        Console.WriteLine($"Windows Directory: {winDir}\n");

        // Маска активних процесорів (bitmap)
        Console.WriteLine($"Active Processor Mask: 0x{sysInfo.dwActiveProcessorMask:X}");
        Console.WriteLine($"  → Binary: {Convert.ToString(sysInfo.dwActiveProcessorMask.ToInt64(), 2).PadLeft(64, '0')}");
        
        int activeCores = CountBits(sysInfo.dwActiveProcessorMask.ToInt64());
        Console.WriteLine($"  → {activeCores} cores enabled\n");

        Console.WriteLine("═══════════════════════════════════════════════");
    }

    private static int CountBits(long value)
    {
        int count = 0;
        while (value != 0)
        {
            count += (int)(value & 1);
            value >>= 1;
        }
        return count;
    }
}

Крок 4: Точка входу

Program.cs
class Program
{
    static void Main()
    {
        try
        {
            SystemInfoHelper.PrintSystemInfo();
        }
        catch (Exception ex)
        {
            Console.ForegroundColor = ConsoleColor.Red;
            Console.WriteLine($"Error: {ex.Message}");
            Console.ResetColor();
        }
    }
}

Крок 5: Запуск

dotnet run
System Information Utility
$ dotnet run
═══════════════════════════════════════════════
SYSTEM INFORMATION
═══════════════════════════════════════════════
Processor Architecture: AMD64
→ x64 (64-bit)
Number of Processors: 16
→ Logical cores available to OS
Page Size: 4,096 bytes
→ 4 KB (memory allocation unit)
Application Address Space:
Min: 0x0000000000010000
Max: 0x00007FFFFFFEFFFF
128.0 GB virtual address space
Allocation Granularity: 65,536 bytes
→ 64 KB (VirtualAlloc alignment)
System Directory: C:\Windows\system32
Windows Directory: C:\Windows
Active Processor Mask: 0xFFFF
→ Binary: 0000000000000000000000000000000000000000000000001111111111111111
16 cores enabled
═══════════════════════════════════════════════

Marshal Class: Швейцарський Ніж P/Invoke

Клас System.Runtime.InteropServices.Marshal надає статичні методи для ручного управління unmanaged пам'яттю та конвертації типів. Це низькорівневий API, що дає повний контроль.

Виділення та Звільнення Пам'яті

AllocHGlobal(int)
IntPtr
Виділяє блок unmanaged пам'яті заданого розміру з глобальної heap (через GlobalAlloc Win32 API). Повертає вказівник. Пам'ять не ініціалізована (містить сміття).
IntPtr ptr = Marshal.AllocHGlobal(1024); // 1 KB
try
{
    // Використання пам'яті...
}
finally
{
    Marshal.FreeHGlobal(ptr); // ОБОВ'ЯЗКОВО звільнити
}
AllocCoTaskMem(int)
IntPtr
Виділяє пам'ять через COM allocator (CoTaskMemAlloc). Використовується для COM Interop та деяких Win32 API, що очікують саме цей allocator.
FreeHGlobal(IntPtr)
void
Звільняє пам'ять, виділену через AllocHGlobal. Виклик з невалідним вказівником — undefined behavior (crash).
FreeCoTaskMem(IntPtr)
void
Звільняє пам'ять, виділену через AllocCoTaskMem.

Копіювання Даних між Managed та Unmanaged

Copy(byte[], int, IntPtr, int)
void
Копіює дані з managed масиву у unmanaged пам'ять.
byte[] data = { 1, 2, 3, 4, 5 };
IntPtr ptr = Marshal.AllocHGlobal(data.Length);
Marshal.Copy(data, 0, ptr, data.Length);
// Тепер unmanaged пам'ять містить {1, 2, 3, 4, 5}
Copy(IntPtr, byte[], int, int)
void
Копіює дані з unmanaged пам'яті у managed масив (зворотна операція).
PtrToStructure<T>(IntPtr)
T
Читає структуру з unmanaged пам'яті та створює managed об'єкт.
IntPtr ptr = GetSomeNativeStructure();
SYSTEMTIME st = Marshal.PtrToStructure<SYSTEMTIME>(ptr);
StructureToPtr<T>(T, IntPtr, bool)
void
Записує managed структуру у unmanaged пам'ять.

Робота з Рядками

StringToHGlobalUni(string)
IntPtr
Конвертує managed рядок у unmanaged UTF-16 (Unicode) рядок. Виділяє пам'ять через AllocHGlobal.
StringToHGlobalAnsi(string)
IntPtr
Конвертує у ANSI рядок (поточна code page системи). Може втратити символи.
PtrToStringUni(IntPtr)
string
Читає null-terminated UTF-16 рядок з unmanaged пам'яті та створює managed string.
PtrToStringAnsi(IntPtr)
string
Читає ANSI рядок.

Отримання Інформації про Типи

SizeOf<T>()
int
Повертає розмір структури у unmanaged представленні (з урахуванням padding).
int size = Marshal.SizeOf<SYSTEMTIME>(); // 16 байт
OffsetOf<T>(string)
IntPtr
Повертає offset поля у структурі.
IntPtr offset = Marshal.OffsetOf<SYSTEMTIME>("wYear"); // 0
IntPtr offset2 = Marshal.OffsetOf<SYSTEMTIME>("wMonth"); // 2

Наскрізний Приклад: Window Automation Tool

Побудуємо інструмент для автоматизації роботи з вікнами Windows: пошук вікон за заголовком, зміна позиції/розміру, відправка повідомлень.

Крок 1: Структури та константи

WindowStructures.cs
using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Sequential)]
struct RECT
{
    public int Left;
    public int Top;
    public int Right;
    public int Bottom;

    public int Width => Right - Left;
    public int Height => Bottom - Top;

    public override string ToString() => 
        $"({Left}, {Top}) - ({Right}, {Bottom}) [{Width}x{Height}]";
}

[StructLayout(LayoutKind.Sequential)]
struct POINT
{
    public int X;
    public int Y;

    public override string ToString() => $"({X}, {Y})";
}

static class WindowConstants
{
    // ShowWindow commands
    public const int SW_HIDE = 0;
    public const int SW_SHOWNORMAL = 1;
    public const int SW_SHOWMINIMIZED = 2;
    public const int SW_SHOWMAXIMIZED = 3;
    public const int SW_RESTORE = 9;

    // SetWindowPos flags
    public const uint SWP_NOSIZE = 0x0001;
    public const uint SWP_NOMOVE = 0x0002;
    public const uint SWP_NOZORDER = 0x0004;
    public const uint SWP_SHOWWINDOW = 0x0040;

    // Window messages
    public const uint WM_CLOSE = 0x0010;
    public const uint WM_SETTEXT = 0x000C;
}

Крок 2: Native методи

WindowNativeMethods.cs
using System.Runtime.InteropServices;
using System.Text;

static class WindowNativeMethods
{
    [DllImport("user32.dll", CharSet = CharSet.Unicode)]
    public static extern IntPtr FindWindow(string? lpClassName, string? lpWindowName);

    [DllImport("user32.dll", CharSet = CharSet.Unicode)]
    public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);

    [DllImport("user32.dll")]
    public static extern int GetWindowTextLength(IntPtr hWnd);

    [DllImport("user32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);

    [DllImport("user32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static extern bool SetWindowPos(
        IntPtr hWnd,
        IntPtr hWndInsertAfter,
        int X,
        int Y,
        int cx,
        int cy,
        uint uFlags
    );

    [DllImport("user32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);

    [DllImport("user32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static extern bool IsWindowVisible(IntPtr hWnd);

    [DllImport("user32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static extern bool IsIconic(IntPtr hWnd); // Мінімізоване

    [DllImport("user32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static extern bool IsZoomed(IntPtr hWnd); // Максимізоване

    [DllImport("user32.dll")]
    public static extern IntPtr SendMessage(
        IntPtr hWnd, 
        uint Msg, 
        IntPtr wParam, 
        IntPtr lParam
    );

    [DllImport("user32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static extern bool SetForegroundWindow(IntPtr hWnd);

    [UnmanagedFunctionPointer(CallingConvention.StdCall)]
    public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);

    [DllImport("user32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
}

Крок 3: Обгортка Window

WindowWrapper.cs
using System.Text;

class WindowWrapper
{
    public IntPtr Handle { get; }

    public WindowWrapper(IntPtr handle)
    {
        if (handle == IntPtr.Zero)
            throw new ArgumentException("Invalid window handle", nameof(handle));
        
        Handle = handle;
    }

    public string GetTitle()
    {
        int length = WindowNativeMethods.GetWindowTextLength(Handle);
        if (length == 0)
            return string.Empty;

        var sb = new StringBuilder(length + 1);
        WindowNativeMethods.GetWindowText(Handle, sb, sb.Capacity);
        return sb.ToString();
    }

    public void SetTitle(string title)
    {
        IntPtr titlePtr = Marshal.StringToHGlobalUni(title);
        try
        {
            WindowNativeMethods.SendMessage(
                Handle, 
                WindowConstants.WM_SETTEXT, 
                IntPtr.Zero, 
                titlePtr
            );
        }
        finally
        {
            Marshal.FreeHGlobal(titlePtr);
        }
    }

    public RECT GetBounds()
    {
        if (!WindowNativeMethods.GetWindowRect(Handle, out RECT rect))
            throw new InvalidOperationException("Failed to get window bounds");
        
        return rect;
    }

    public void SetBounds(int x, int y, int width, int height)
    {
        WindowNativeMethods.SetWindowPos(
            Handle,
            IntPtr.Zero,
            x, y, width, height,
            WindowConstants.SWP_NOZORDER | WindowConstants.SWP_SHOWWINDOW
        );
    }

    public void MoveTo(int x, int y)
    {
        WindowNativeMethods.SetWindowPos(
            Handle,
            IntPtr.Zero,
            x, y, 0, 0,
            WindowConstants.SWP_NOSIZE | WindowConstants.SWP_NOZORDER
        );
    }

    public void Resize(int width, int height)
    {
        WindowNativeMethods.SetWindowPos(
            Handle,
            IntPtr.Zero,
            0, 0, width, height,
            WindowConstants.SWP_NOMOVE | WindowConstants.SWP_NOZORDER
        );
    }

    public void Show() => 
        WindowNativeMethods.ShowWindow(Handle, WindowConstants.SW_SHOWNORMAL);

    public void Hide() => 
        WindowNativeMethods.ShowWindow(Handle, WindowConstants.SW_HIDE);

    public void Minimize() => 
        WindowNativeMethods.ShowWindow(Handle, WindowConstants.SW_SHOWMINIMIZED);

    public void Maximize() => 
        WindowNativeMethods.ShowWindow(Handle, WindowConstants.SW_SHOWMAXIMIZED);

    public void Restore() => 
        WindowNativeMethods.ShowWindow(Handle, WindowConstants.SW_RESTORE);

    public void BringToFront() => 
        WindowNativeMethods.SetForegroundWindow(Handle);

    public void Close() => 
        WindowNativeMethods.SendMessage(Handle, WindowConstants.WM_CLOSE, IntPtr.Zero, IntPtr.Zero);

    public bool IsVisible => 
        WindowNativeMethods.IsWindowVisible(Handle);

    public bool IsMinimized => 
        WindowNativeMethods.IsIconic(Handle);

    public bool IsMaximized => 
        WindowNativeMethods.IsZoomed(Handle);

    public string GetState()
    {
        if (!IsVisible) return "Hidden";
        if (IsMinimized) return "Minimized";
        if (IsMaximized) return "Maximized";
        return "Normal";
    }
}

Крок 4: Window Manager

WindowManager.cs
class WindowManager
{
    public static WindowWrapper? FindByTitle(string title)
    {
        IntPtr handle = WindowNativeMethods.FindWindow(null, title);
        return handle != IntPtr.Zero ? new WindowWrapper(handle) : null;
    }

    public static WindowWrapper? FindByTitleContains(string partialTitle)
    {
        WindowWrapper? found = null;

        WindowNativeMethods.EnumWindows((hWnd, lParam) =>
        {
            if (!WindowNativeMethods.IsWindowVisible(hWnd))
                return true;

            var window = new WindowWrapper(hWnd);
            string title = window.GetTitle();

            if (title.Contains(partialTitle, StringComparison.OrdinalIgnoreCase))
            {
                found = window;
                return false; // Зупинити перебір
            }

            return true;
        }, IntPtr.Zero);

        return found;
    }

    public static List<WindowWrapper> GetAllVisibleWindows()
    {
        var windows = new List<WindowWrapper>();

        WindowNativeMethods.EnumWindows((hWnd, lParam) =>
        {
            if (WindowNativeMethods.IsWindowVisible(hWnd))
            {
                var window = new WindowWrapper(hWnd);
                string title = window.GetTitle();
                
                if (!string.IsNullOrWhiteSpace(title))
                    windows.Add(window);
            }

            return true;
        }, IntPtr.Zero);

        return windows;
    }
}

Крок 5: CLI інтерфейс

Program.cs
class Program
{
    static void Main(string[] args)
    {
        Console.OutputEncoding = System.Text.Encoding.UTF8;

        if (args.Length == 0)
        {
            ShowHelp();
            return;
        }

        try
        {
            string command = args[0].ToLower();

            switch (command)
            {
                case "list":
                    ListWindows();
                    break;

                case "find":
                    if (args.Length < 2)
                    {
                        Console.WriteLine("Usage: find <title>");
                        return;
                    }
                    FindWindow(args[1]);
                    break;

                case "move":
                    if (args.Length < 4)
                    {
                        Console.WriteLine("Usage: move <title> <x> <y>");
                        return;
                    }
                    MoveWindow(args[1], int.Parse(args[2]), int.Parse(args[3]));
                    break;

                case "resize":
                    if (args.Length < 4)
                    {
                        Console.WriteLine("Usage: resize <title> <width> <height>");
                        return;
                    }
                    ResizeWindow(args[1], int.Parse(args[2]), int.Parse(args[3]));
                    break;

                default:
                    Console.WriteLine($"Unknown command: {command}");
                    ShowHelp();
                    break;
            }
        }
        catch (Exception ex)
        {
            Console.ForegroundColor = ConsoleColor.Red;
            Console.WriteLine($"Error: {ex.Message}");
            Console.ResetColor();
        }
    }

    static void ShowHelp()
    {
        Console.WriteLine("Window Automation Tool");
        Console.WriteLine("======================\n");
        Console.WriteLine("Commands:");
        Console.WriteLine("  list                          - List all visible windows");
        Console.WriteLine("  find <title>                  - Find window by title");
        Console.WriteLine("  move <title> <x> <y>          - Move window to position");
        Console.WriteLine("  resize <title> <w> <h>        - Resize window");
    }

    static void ListWindows()
    {
        var windows = WindowManager.GetAllVisibleWindows();
        
        Console.WriteLine($"Found {windows.Count} visible windows:\n");
        Console.WriteLine($"{"Handle",-12} {"State",-12} {"Bounds",-30} {"Title"}");
        Console.WriteLine(new string('─', 100));

        foreach (var window in windows.Take(20))
        {
            try
            {
                var bounds = window.GetBounds();
                Console.WriteLine(
                    $"0x{window.Handle:X8}  " +
                    $"{window.GetState(),-12} " +
                    $"{bounds.Width}x{bounds.Height} at ({bounds.Left},{bounds.Top})".PadRight(30) +
                    $"{window.GetTitle()}"
                );
            }
            catch { }
        }
    }

    static void FindWindow(string title)
    {
        var window = WindowManager.FindByTitleContains(title);
        
        if (window == null)
        {
            Console.WriteLine($"Window not found: {title}");
            return;
        }

        Console.WriteLine($"Found window:");
        Console.WriteLine($"  Handle: 0x{window.Handle:X8}");
        Console.WriteLine($"  Title: {window.GetTitle()}");
        Console.WriteLine($"  State: {window.GetState()}");
        Console.WriteLine($"  Bounds: {window.GetBounds()}");
    }

    static void MoveWindow(string title, int x, int y)
    {
        var window = WindowManager.FindByTitleContains(title);
        if (window == null)
        {
            Console.WriteLine($"Window not found: {title}");
            return;
        }

        window.MoveTo(x, y);
        Console.WriteLine($"✓ Moved '{window.GetTitle()}' to ({x}, {y})");
    }

    static void ResizeWindow(string title, int width, int height)
    {
        var window = WindowManager.FindByTitleContains(title);
        if (window == null)
        {
            Console.WriteLine($"Window not found: {title}");
            return;
        }

        window.Resize(width, height);
        Console.WriteLine($"✓ Resized '{window.GetTitle()}' to {width}x{height}");
    }
}

Крок 6: Використання

# Список всіх вікон
dotnet run list

# Знайти вікно
dotnet run find "Chrome"

# Перемістити вікно
dotnet run move "Chrome" 100 100

# Змінити розмір
dotnet run resize "Chrome" 1280 720
Window Automation Tool
$ dotnet run list
Found 23 visible windows:
Handle State Bounds Title
────────────────────────────────────────────────────────────────────────────────────────────────────
0x000A0C12 Normal 1920x1080 at (0,0) Visual Studio Code
0x00120F3A Maximized 1920x1080 at (0,0) Google Chrome
0x00051B24 Normal 1200x800 at (360,140) Windows Terminal
0x00082D16 Normal 800x600 at (560,240) Task Manager
$ dotnet run find "Chrome"
Found window:
Handle: 0x00120F3A
Title: Google Chrome
State: Maximized
Bounds: (0, 0) - (1920, 1080) [1920x1080]
$ dotnet run resize "Chrome" 1280 720
Resized 'Google Chrome' to 1280x720

Підсумок

P/Invoke Основи

  • [DllImport] — атрибут для виклику native функцій
  • CharSet.Unicode — завжди для Win32 API
  • SetLastError = true — для обробки помилок через Marshal.GetLastWin32Error()
  • [LibraryImport] — сучасна альтернатива (.NET 7+) з source generation

Marshalling

  • Blittable типи (int, float, IntPtr) — без конвертації
  • Non-blittable (string, bool, arrays) — вимагають marshalling
  • [StructLayout(LayoutKind.Sequential)] — для структур
  • [MarshalAs] — для складних типів (масиви, рядки фіксованого розміру)

SafeHandle

  • Безпечне управління handles
  • Critical finalizer — гарантоване звільнення
  • Reference counting — захист від race conditions
  • Використовуйте готові: SafeFileHandle, SafeWaitHandle, SafeProcessHandle

Callbacks

  • Делегат як function pointer
  • [UnmanagedFunctionPointer] — вказати calling convention
  • Критично: тримати посилання на делегат (поле класу) — інакше GC збере
  • GCHandle.Alloc — для довгоживучих callbacks

Практичні Завдання

Рівень 1: System Information Extended

Розширте приклад GetSystemInfo:

  1. Додайте виклик GetComputerName() для отримання імені комп'ютера
  2. Додайте GetUserName() для поточного користувача
  3. Додайте GetTickCount64() для uptime системи (мілісекунди з останнього завантаження)
  4. Виведіть інформацію у форматованому вигляді з кольорами

Рівень 2: File Attributes Manager

Створіть CLI утиліту для роботи з атрибутами файлів через Win32 API:

  1. Використайте GetFileAttributes() та SetFileAttributes() з kernel32.dll
  2. Підтримка атрибутів: ReadOnly, Hidden, System, Archive
  3. Команди: get <file>, set <file> <attributes>, clear <file> <attributes>
  4. Обробка помилок через GetLastError()

Рівень 3: Global Hotkey Manager

Реалізуйте систему глобальних hotkey-ів:

  1. Використайте RegisterHotKey() та UnregisterHotKey() з user32.dll
  2. Message loop через GetMessage() та DispatchMessage()
  3. Підтримка модифікаторів: Ctrl, Alt, Shift, Win
  4. Конфігурація hotkey-ів з JSON файлу
  5. Виконання команд при натисканні (запуск програм, відкриття файлів)
  6. Коректне завершення при Ctrl+C (звільнення всіх hotkey-ів)