1. Вступ
У попередній лекції ми розглядали, як C# і .NET Runtime беруть на себе значну частину клопотів із керування памʼяттю. Втім, інколи виникає потреба або бажання отримати ще нижчий рівень контролю над памʼяттю — для підвищення продуктивності чи взаємодії з некерованим кодом (наприклад, із системними API на C++). C# пропонує для цього низку інструментів. Вони потужні, проте потребують обережного застосування.
Коли ми говоримо про «низькорівневе керування памʼяттю» у C#, зазвичай маємо на увазі:
- Пряму роботу з вказівниками: доступ до памʼяті за адресами, як у C/C++.
- Виділення памʼяті поза купою GC: використання памʼяті, яку збирач сміття не відстежує.
- Керування ресурсами на межі керованого й некерованого коду: ефективна взаємодія з нативними бібліотеками.
У C# звичайному коду не дозволено безпосередньо працювати з адресами памʼяті (вказівниками) задля безпеки та стабільності. Однак для низькорівневих операцій можна використовувати вказівники всередині unsafe-контексту.
Що таке unsafe-контекст?
Блок або метод, позначений ключовим словом unsafe, дозволяє використовувати синтаксис вказівників і виконувати операції, які CLR не перевіряє на безпеку. Код у unsafe-контексті не безпечніший за нативний код. Щоб використовувати unsafe-код, проєкт має бути скомпільовано з параметром /unsafe.
Приклад: Оголошення unsafe-методу та блоку
public unsafe class UnsafeExamples
{
// Unsafe-метод
public static unsafe void ManipulatePointer(int* ptr)
{
Console.WriteLine($"Значення за вказівником: {*ptr}");
*ptr = 200; // Змінюємо значення за адресою
}
public static void DemoUnsafeBlock()
{
int value = 100;
unsafe // Unsafe-блок усередині звичайного методу
{
int* ptr = &value; // Отримуємо адресу змінної
ManipulatePointer(ptr); // Передаємо вказівник в unsafe-метод
Console.WriteLine($"Нове значення: {value}"); // Вивід: Нове значення: 200
}
}
}
Типи вказівників
У C# можна оголошувати вказівники на значущі типи (int*, bool*, MyStruct*) і на void (void* для універсального вказівника). Не можна оголошувати вказівники безпосередньо на посилальні типи, але можна отримати вказівник на поле посилального типу, якщо це значущий тип.
Приклад: Різні типи вказівників
unsafe
{
double d = 123.45;
double* dPtr = &d; // Вказівник на double
int[] numbers = { 1, 2, 3 };
fixed (int* arrPtr = numbers) // 'fixed' фіксує об'єкт у пам'яті, щоб GC не перемістив його
{
Console.WriteLine($"Перший елемент масиву: {arrPtr[0]}");
Console.WriteLine($"Другий елемент масиву: {*(arrPtr + 1)}");
}
}
Оператори вказівників
- & (адреса): отримує адресу змінної.
- * (розіменування): отримує значення за адресою.
- -> (доступ до члена): доступ до члена структури або класу через вказівник на нього (лише для значущих типів).
- [] (індексація): доступ до елементів масиву через вказівник (як у C/C++).
2. Фіксовані та виділені блоки памʼяті
Коли ви працюєте з вказівниками, важливо, щоб обʼєкт, на який посилається вказівник, не переміщувався збирачем сміття під час роботи з ним. Для цього використовують ключові слова fixed і stackalloc.
Оператор fixed
Оператор fixed «закріплює» змінну посилального типу в памʼяті, запобігаючи її переміщенню збирачем сміття на час виконання fixed-блоку. Це критично важливо, коли ви передаєте вказівники на керовані обʼєкти в некерований код або працюєте з ними безпосередньо.
Приклад: Використання fixed з масивами
public unsafe class FixedExample
{
public static void ProcessFixedArray()
{
byte[] buffer = new byte[10];
// Заповнюємо буфер
for (int i = 0; i < buffer.Length; i++)
{
buffer[i] = (byte)(i + 1);
}
unsafe
{
// Фіксуємо масив у пам'яті, отримуємо вказівник на його перший елемент
fixed (byte* p = buffer)
{
// Тепер можна безпечно працювати з p, знаючи, що масив не буде переміщений
Console.WriteLine($"Значення першого байта: {*p}");
Console.WriteLine($"Значення другого байта: {*(p + 1)}");
// Можна передати 'p' у нативну функцію, яка очікує вказівник
} // Після виходу з блоку 'fixed' масив може бути переміщений GC
}
}
}
fixed також можна використовувати з полями структур і з рядками.
Виділення памʼяті на стеку: stackalloc
stackalloc дозволяє виділити блок памʼяті на стеку. Це дуже швидко, але памʼять доступна лише до завершення поточного методу. Виділена памʼять не керується збирачем сміття.
- Переваги: дуже швидке виділення, відсутність накладних витрат GC, детерміноване звільнення памʼяті.
- Недоліки: обмежений розмір (стек порівняно малий), ризик переповнення стеку (StackOverflowException) за надто великих виділень.
- Використання: оптимально для невеликих, нетривалих буферів.
Приклад: Використання stackalloc
public unsafe class StackAllocExample
{
public static void ProcessStackAlloc()
{
unsafe
{
// Виділяємо 10 int-ів на стеку
int* numbers = stackalloc int[10];
for (int i = 0; i < 10; i++)
{
numbers[i] = i * 10;
}
Console.WriteLine($"Значення першого елемента: {numbers[0]}");
Console.WriteLine($"Значення п'ятого елемента: {numbers[4]}");
} // Пам'ять автоматично звільняється при виході з цього методу.
}
}
Оператор stackalloc можна використовувати зі Span<T>, що робить роботу з такою памʼяттю безпечнішою, адже Span<T> — це керований тип (структура), який надає безпечний доступ до суцільних блоків памʼяті. Докладніше про Span — у наступній лекції.
Приклад: stackalloc зі Span<T>
using System;
public class StackAllocWithSpan
{
public static void DemoSpanStackAlloc()
{
// Виділення на стеку, але працюємо через безпечний Span<int>
Span<int> buffer = stackalloc int[10];
for (int i = 0; i < buffer.Length; i++)
{
buffer[i] = i * 2;
}
Console.WriteLine($"Перший елемент Span: {buffer[0]}");
Console.WriteLine($"Останній елемент Span: {buffer[9]}");
// Span<T> можна передавати в методи, які його приймають
PrintSpan(buffer);
}
public static void PrintSpan(Span<int> s)
{
foreach (var item in s)
{
Console.Write($"{item} ");
}
Console.WriteLine();
}
}
Це гібридний підхід: виділення на стеку (низькорівнево), але безпечний доступ через Span<T> (високорівнево).
3. Взаємодія з некерованим кодом (P/Invoke)
Platform Invoke (P/Invoke) — це механізм, який дозволяє C#-коду викликати функції з некерованих бібліотек (наприклад, DLL-файлів Windows API або файлів .so у Linux). Це фундаментальний аспект низькорівневого керування памʼяттю, адже ви часто передаватимете вказівники на дані між керованим і некерованим світами.
Оголошення зовнішніх функцій
Використовуйте атрибут DllImport для оголошення статичних extern-методів, що відповідають нативним функціям.
Приклад 3.1: Виклик функції Windows API
using System.Runtime.InteropServices;
public class PInvokeExample
{
// Імпортуємо нативну функцію MessageBox з user32.dll
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int MessageBox(IntPtr hWnd, string lpText, string lpCaption, uint uType);
public static void ShowMessageBox()
{
// Викликаємо нативну функцію
MessageBox(IntPtr.Zero, "Привіт з C#!", "Заголовок вікна", 0);
}
}
Маршалінг даних
Під час виклику нативних функцій .NET Runtime виконує маршалінг — перетворення типів даних між керованим і некерованим форматами. Наприклад, string у C# може бути замаршалений як char* або wchar_t* у C++.
Для складнішого маршалінгу можна використовувати атрибут MarshalAs і клас Marshal.
Приклад: Маршалінг структур
using System.Runtime.InteropServices;
// Цю структуру буде замаршалено в нативну структуру
[StructLayout(LayoutKind.Sequential)] // Вказує, що поля мають бути розташовані послідовно
public struct NativePoint
{
public int X;
public int Y;
}
public class StructMarshalExample
{
[DllImport("your_native_lib.dll")] // Приклад: функція в нативній бібліотеці
public static extern void ProcessPoint(NativePoint point);
[DllImport("your_native_lib.dll")]
public static extern void FillPoint(out NativePoint point); // Приймає вказівник на структуру
public static void DemoStructMarshal()
{
NativePoint myPoint = new NativePoint { X = 10, Y = 20 };
ProcessPoint(myPoint); // Структуру буде замаршалено за значенням (копія)
NativePoint resultPoint;
FillPoint(out resultPoint); // Структуру буде заповнено нативною функцією
Console.WriteLine($"Point from native: ({resultPoint.X}, {resultPoint.Y})");
}
}
4. GCHandle і закріплення обʼєктів
GCHandle — це структура, яка дозволяє отримати «дескриптор» (handle) обʼєкта в керованій купі памʼяті і, за потреби, тимчасово закріпити його, запобігаючи переміщенню або збиранню GC. Це корисно, коли потрібно передати стабільний вказівник на керований обʼєкт у некерований код.
Приклад: Закріплення обʼєкта за допомогою GCHandle
using System.Runtime.InteropServices;
public class GCHandleExample
{
public static void PinObject()
{
byte[] data = new byte[100];
GCHandle handle = GCHandle.Alloc(data, GCHandleType.Pinned); // Закріплюємо масив у пам'яті
try
{
IntPtr pointer = handle.AddrOfPinnedObject(); // Отримуємо вказівник на закріплений об'єкт
Console.WriteLine($"Адреса закріпленого масиву: {pointer:X}");
// Тепер 'pointer' можна безпечно передати в нативну функцію.
// Нативна функція може працювати з цією пам'яттю напряму.
Marshal.WriteByte(pointer, 0, 255); // Змінюємо перший байт через вказівник
Console.WriteLine($"Перший байт масиву: {data[0]}"); // Вивід: 255
}
finally
{
if (handle.IsAllocated)
{
handle.Free(); // Звільняємо дескриптор, дозволяємо GC знову керувати об'єктом
}
}
}
}
Детальний розбір P/Invoke і GCHandle виходить за рамки цього курсу, проте ці інструменти можуть бути дуже корисними, коли потрібно напряму викликати функції бібліотек Windows. Принаймні тепер ви знаєте, у якому напрямі рухатися далі.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ