1. Einführung
In der vorherigen Vorlesung haben wir darüber gesprochen, wie C# und das .NET Runtime den Großteil der Speicherverwaltung übernehmen. Trotzdem gibt es Situationen, in denen man eine niedrigstufigere Kontrolle über den Speicher braucht oder haben will — besonders zur Leistungsoptimierung oder zur Interaktion mit unmanaged Code (z.B. System-APIs, die in C++ geschrieben sind). C# bietet dafür mehrere Features, die mächtig sind, aber vorsichtig verwendet werden müssen.
Wenn wir von "niedrigstufiger Speichersteuerung" in C# sprechen, meinen wir normalerweise:
- Direkte Arbeit mit Pointern: Zugriff auf Speicher über Adressen, wie in C/C++.
- Speicherallokation außerhalb des GC-Heaps: Nutzung von Speicher, den der Garbage Collector nicht verfolgt.
- Ressourcenmanagement an der Grenze zwischen managed/unmanaged Code: Effektive Interaktion mit nativen Bibliotheken.
In regulärem C#-Code ist direkte Arbeit mit Speicheradressen (Pointer) aus Sicherheits- und Stabilitätsgründen nicht erlaubt. Für niedrigstufige Operationen kann man jedoch Pointer innerhalb eines unsafe-Kontexts verwenden.
Was ist ein unsafe-Kontext?
Ein Block oder eine Methode, die mit dem Schlüsselwort unsafe markiert ist, erlaubt die Verwendung der Pointer-Syntax und Operationen, die vom CLR nicht auf Sicherheit geprüft werden. Code im unsafe-Kontext ist nicht sicherer als nativer Code. Um unsafe-Code zu verwenden, muss das Projekt mit der Option /unsafe kompiliert werden.
Beispiel: Deklaration einer unsafe-Methode und eines Blocks
public unsafe class UnsafeExamples
{
// Unsafe-Methode
public static unsafe void ManipulatePointer(int* ptr)
{
Console.WriteLine($"Wert am Pointer: {*ptr}");
*ptr = 200; // Wir ändern den Wert an der Adresse
}
public static void DemoUnsafeBlock()
{
int value = 100;
unsafe // Unsafe-Block innerhalb einer normalen Methode
{
int* ptr = &value; // Wir bekommen die Adresse der Variable
ManipulatePointer(ptr); // Wir übergeben den Pointer an die unsafe-Methode
Console.WriteLine($"Neuer Wert: {value}"); // Ausgabe: Neuer Wert: 200
}
}
}
Pointer-Typen
In C# kann man Pointer auf Value-Typen deklarieren (int*, bool*, MyStruct*) und auf void (void* als generischer Pointer). Pointer auf Reference-Typen kann man nicht direkt deklarieren, aber man kann einen Pointer auf ein Feld eines Reference-Typs bekommen, wenn dieses Feld ein Value-Typ ist.
Beispiel: Verschiedene Pointer-Typen
unsafe
{
double d = 123.45;
double* dPtr = &d; // Pointer auf double
int[] numbers = { 1, 2, 3 };
fixed (int* arrPtr = numbers) // 'fixed' pinnt das Objekt im Speicher, damit GC es nicht verschiebt
{
Console.WriteLine($"Erstes Array-Element: {arrPtr[0]}");
Console.WriteLine($"Zweites Array-Element: {*(arrPtr + 1)}");
}
}
Pointer-Operatoren
- & (Adresse): Liefert die Adresse einer Variable.
- * (Dereferenzierung): Liefert den Wert an der Adresse.
- -> (Zugriff auf Member): Zugriff auf ein Member einer Struktur/Klasse über einen Pointer auf sie (nur für Value-Typen).
- [] (Indexierung): Zugriff auf Array-Elemente über einen Pointer (wie in C/C++).
2. Gepinnte und allokierte Speicherbereiche
Wenn man mit Pointern arbeitet, ist es wichtig, dass das Objekt, auf das der Pointer zeigt, nicht vom Garbage Collector verschoben wird, während man damit arbeitet. Dafür nutzt man die Schlüsselwörter fixed und stackalloc.
Operator fixed
Der Operator fixed "pinnt" eine Referenz-Variable im Speicher und verhindert, dass sie vom Garbage Collector während der Ausführung des fixed-Blocks verschoben wird. Das ist kritisch, wenn man Pointer auf managed Objekte an unmanaged Code übergibt oder direkt mit ihnen arbeitet.
Beispiel: Verwendung von fixed mit Arrays
public unsafe class FixedExample
{
public static void ProcessFixedArray()
{
byte[] buffer = new byte[10];
// Wir füllen den Puffer
for (int i = 0; i < buffer.Length; i++)
{
buffer[i] = (byte)(i + 1);
}
unsafe
{
// Wir pinnen das Array im Speicher und bekommen einen Pointer auf sein erstes Element
fixed (byte* p = buffer)
{
// Jetzt kann man sicher mit p arbeiten, da das Array nicht verschoben wird
Console.WriteLine($"Wert am ersten Byte: {*p}");
Console.WriteLine($"Wert am zweiten Byte: {*(p + 1)}");
// Man kann 'p' an eine native Funktion übergeben, die einen Pointer erwartet
} // Nach Verlassen des 'fixed'-Blocks darf das Array wieder vom GC verschoben werden
}
}
}
fixed kann auch mit Struktur-Feldern oder Strings verwendet werden.
Speicherallokation auf dem Stack: stackalloc
stackalloc erlaubt die Allokation eines Speicherblocks auf dem Stack. Das ist sehr schnell, aber der Speicher ist nur bis zum Ende der aktuellen Methode verfügbar. Der alloziierte Speicher wird nicht vom Garbage Collector verwaltet.
- Vorteile: Sehr schnelle Allokation, keine GC-Overheads, deterministische Freigabe des Speichers.
- Nachteile: Begrenzte Größe (der Stack ist relativ klein), Risiko eines Stacküberlaufs (StackOverflowException) bei zu großen Allokationen.
- Verwendung: Ideal für kleine, kurzlebige Puffer.
Beispiel: Verwendung von stackalloc
public unsafe class StackAllocExample
{
public static void ProcessStackAlloc()
{
unsafe
{
// Wir allozieren 10 ints auf dem Stack
int* numbers = stackalloc int[10];
for (int i = 0; i < 10; i++)
{
numbers[i] = i * 10;
}
Console.WriteLine($"Wert des ersten Elements: {numbers[0]}");
Console.WriteLine($"Wert des fünften Elements: {numbers[4]}");
} // Der Speicher wird automatisch freigegeben, wenn diese Methode beendet wird.
}
}
Der Operator stackalloc kann mit Span<T> verwendet werden, was die Arbeit mit diesem Speicher sicherer macht, da Span<T> ein managed Typ (struct) ist, der sicheren Zugriff auf zusammenhängende Speicherbereiche bietet. Mehr zu Span in der nächsten Vorlesung.
Beispiel: stackalloc mit Span<T>
using System;
public class StackAllocWithSpan
{
public static void DemoSpanStackAlloc()
{
// Allokation auf dem Stack, aber wir arbeiten über den sicheren Span
Span
buffer = stackalloc int[10]; for (int i = 0; i < buffer.Length; i++) { buffer[i] = i * 2; } Console.WriteLine($"Erstes Span-Element: {buffer[0]}"); Console.WriteLine($"Letztes Span-Element: {buffer[9]}"); // Man kann Span
an Methoden übergeben, die ihn akzeptieren PrintSpan(buffer); } public static void PrintSpan(Span
s) { foreach (var item in s) { Console.Write($"{item} "); } Console.WriteLine(); } }
Das ist ein hybrider Ansatz: Allokation auf dem Stack (niedrigstufig), aber sicherer Zugriff über Span<T> (hochstufig).
3. Interaktion mit unmanaged Code (P/Invoke)
Platform Invoke (P/Invoke) ist ein Mechanismus, der es C#-Code erlaubt, Funktionen aus unmanaged Bibliotheken aufzurufen (z.B. DLLs des Windows API oder Linux .so-Dateien). Das ist ein fundamentaler Aspekt der niedrigstufigen Speichersteuerung, da man häufig Pointer auf Daten zwischen der managed- und unmanaged-Welt übergibt.
Deklaration von externen Funktionen
Man benutzt das Attribut DllImport, um statische extern-Methoden zu deklarieren, die nativen Funktionen entsprechen.
Beispiel 3.1: Aufruf einer Windows-API-Funktion
using System.Runtime.InteropServices;
public class PInvokeExample
{
// Wir importieren die native Funktion MessageBox aus 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()
{
// Wir rufen die native Funktion auf
MessageBox(IntPtr.Zero, "Privet iz C#!", "Zagolovok okna", 0);
}
}
Marshalling von Daten
Beim Aufruf nativer Funktionen führt das .NET Runtime das Marshalling durch — die Umwandlung von Datentypen zwischen managed- und unmanaged-Formaten. Zum Beispiel kann ein string in C# als char* oder wchar_t* in C++ gemarshalled werden.
Für komplexeres Marshalling kann man das Attribut MarshalAs und die Klasse Marshal verwenden.
Beispiel: Marshalling von Strukturen
using System.Runtime.InteropServices;
// Diese Struktur wird in eine native Struktur gemarshalled
[StructLayout(LayoutKind.Sequential)] // Sagt, dass Felder sequentiell angeordnet sein sollen
public struct NativePoint
{
public int X;
public int Y;
}
public class StructMarshalExample
{
[DllImport("your_native_lib.dll")] // Beispiel: Funktion in einer nativen Bibliothek
public static extern void ProcessPoint(NativePoint point);
[DllImport("your_native_lib.dll")]
public static extern void FillPoint(out NativePoint point); // Nimmt einen Pointer auf die Struktur
public static void DemoStructMarshal()
{
NativePoint myPoint = new NativePoint { X = 10, Y = 20 };
ProcessPoint(myPoint); // Die Struktur wird per Wert gemarshalled (Kopie)
NativePoint resultPoint;
FillPoint(out resultPoint); // Die Struktur wird von der nativen Funktion gefüllt
Console.WriteLine($"Point von native: ({resultPoint.X}, {resultPoint.Y})");
}
}
4. GCHandle und das Pinnen von Objekten
GCHandle ist eine Struktur, die es erlaubt, einen "Handle" auf ein Objekt im managed Heap zu bekommen und dieses bei Bedarf temporär zu pinnen, sodass es nicht vom GC verschoben oder gesammelt wird. Das ist nützlich, wenn man einen stabilen Pointer auf ein managed Objekt an unmanaged Code übergeben möchte.
Beispiel: Objekt mit GCHandle pinnen
using System.Runtime.InteropServices;
public class GCHandleExample
{
public static void PinObject()
{
byte[] data = new byte[100];
GCHandle handle = GCHandle.Alloc(data, GCHandleType.Pinned); // Wir pinnen das Array im Speicher
try
{
IntPtr pointer = handle.AddrOfPinnedObject(); // Wir bekommen den Pointer auf das gepinnte Objekt
Console.WriteLine($"Adresse des gepinnten Arrays: {pointer:X}");
// Jetzt kann 'pointer' sicher an eine native Funktion übergeben werden.
// Die native Funktion kann direkt mit diesem Speicher arbeiten.
Marshal.WriteByte(pointer, 0, 255); // Wir ändern das erste Byte über den Pointer
Console.WriteLine($"Erstes Byte des Arrays: {data[0]}"); // Ausgabe: 255
}
finally
{
if (handle.IsAllocated)
{
handle.Free(); // Wir geben den Handle frei, GC kann das Objekt wieder verwalten
}
}
}
}
Eine tiefere Auseinandersetzung mit P/Invoke und GCHandle geht über den Umfang dieses Kurses hinaus, aber sie können sehr nützlich sein, wenn du Funktionen aus Windows-Bibliotheken direkt aufrufen willst. Mindestens weißt du jetzt, wo du weiter nachlesen kannst.
GO TO FULL VERSION