C#'ta Interop ve unsafe Kod: P/Invoke, Pointer'lar ve fixed
AllowUnsafeBlocks ve pointer'lar, fixed ile GC'ye karşı sabitlemek, DllImport ile libc çağırmak, string marshalling, function pointer ile callback, NativeMemory ve span ile unsafe kodu küçük tutmak.
Sürüm notu: Bu yazıdaki kodlar C# 10 ve .NET 6 API'leriyle yazılmıştır; .NET 10 SDK ile derlenip denenmiştir.
Önce Tahmin Et
Üç kitabın stok sayısını tutan bir array'imiz var. Bir pointer alıp ikinci element'i doğrudan bellek adresi üzerinden değiştiriyoruz. Bu kod, varsayılan ayarlarla oluşturulmuş bir console projesinde derlenir mi? Derlenirse ne yazar?
int[] stock = { 5, 3, 8 };
unsafe
{
fixed (int* p = stock)
{
*(p + 1) = 42;
}
}
Console.WriteLine(string.Join(", ", stock));
unsafe: Compiler'dan İzin Almak
Derlenmiyor:
Program.cs(3,1): error CS0227: Unsafe code may only appear if compiling with /unsafe
C#'ta pointer yazmak mümkün, ama iki kez "evet, biliyorum" demen gerekiyor. Birincisi kodda: pointer içeren her şey bir unsafe bloğunun, method'unun ya da tipinin içinde olmalı. İkincisi projede: compiler'a unsafe koda izin verdiğini csproj'da söylemelisin:
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
Bu satırı ekleyince aynı kod derlenir ve çalışır:
5, 42, 8
"Unsafe" kelimesi biraz yanıltıcı. Kod tehlikeli değil; sadece compiler ve runtime'ın sana verdiği güvenlik garantileri bu bloğun içinde geçerli değil. Index sınır kontrolü yok, tip kontrolü yok, garbage collector'ın object'leri taşımasına karşı koruma yok. Doğruluğun sorumluluğu sende. Çift kilit de bu yüzden: biri kodu okuyana "burası farklı" der, diğeri projeyi devralana "bu projede böyle kod var" der.
Pointer'lar, fixed ve Garbage Collector
Örnekteki fixed satırı en önemli kısım. Garbage collector, heap'i sıkıştırırken object'leri bellekte taşıyabilir. Managed bir referans bu taşımadan haberdardır, GC onu günceller. Ham bir pointer ise sadece bir sayıdır; array taşınırsa pointer eski adreste kalır ve artık başka bir şeyi gösterir. fixed, bloğun sonuna kadar array'i yerine sabitler (pinning) ve int* türünden ilk element'in adresini verir.
*(p + 1) pointer aritmetiği: p'nin gösterdiği adresten bir int (4 byte) ileri git ve oraya yaz. p[1] ile aynı şey. Bir önceki iki yazıda span ve ref ile yaptığımız her şeyi pointer'la da yapabilirsin; fark, span'in uzunluğu bildiği ve sınırı kontrol ettiği, ref'in de compiler tarafından denetlendiği. Pointer'da ikisi de yok. Modern C#'ta pointer'a ihtiyaç duyduğun yerlerin çoğu artık span ile çözülüyor; pointer'ın asıl kaldığı yer native kodla konuşmak.
P/Invoke: Native Bir Fonksiyonu Çağırmak
P/Invoke (platform invoke), C ile yazılmış bir kütüphanedeki fonksiyonu C#'tan çağırmanın yolu. İşletim sisteminin API'leri, bir sıkıştırma ya da görüntü işleme kütüphanesi, bir donanım SDK'sı... Fonksiyonun signature'ını extern bir method olarak tanımlayıp [DllImport] ile hangi kütüphanede olduğunu söylersin:
using System.Runtime.InteropServices;
Console.WriteLine(OperatingSystem.IsMacOS());
Console.WriteLine(Native.getpid() == Environment.ProcessId);
Console.WriteLine(Native.strlen("Dune"));
Console.WriteLine(Native.strlen("Café"));
Console.WriteLine("Café".Length);
static class Native
{
[DllImport("libc")]
public static extern int getpid();
[DllImport("libc")]
public static extern nuint strlen(string text);
}
True
True
4
5
4
Bu örnek macOS'ta çalıştı ve sistemin C kütüphanesini (libc) çağırdı. getpid, runtime'ın Environment.ProcessId ile verdiği process id'nin aynısını döndü. Kütüphane adını uzantısız yazdık; .NET adı platforma göre tamamlar (.dll, .so, .dylib uzantısını ve gerekirse lib önekini deneyerek arar). Ama fonksiyonların kendisi platforma bağlı: Windows'ta process id kernel32.dll'deki GetCurrentProcessId'den gelir. Çok platformlu bir uygulamada OperatingSystem.IsWindows() gibi kontrollerle doğru signature'ı seçersin.
Unsafe yazmadığımıza dikkat et. P/Invoke'un kendisi unsafe gerektirmez; sadece signature'da pointer kullanırsan gerekir.
Marshalling: Veri Sınırı Nasıl Geçer
Son iki satır ilginç: "Café" C# tarafında 4 karakter, ama strlen 5 dedi. C# string'leri UTF-16, C fonksiyonları ise \0 ile biten byte array'leri bekler. Runtime çağrı anında string'i native belleğe kopyalayıp çevirir; Windows dışındaki platformlarda varsayılan çeviri UTF-8'dir ve "é" UTF-8'de iki byte tutar. Bu çevirme işine marshalling denir.
Her tip marshalling gerektirmez. int, long, double, nuint ve yalnızca bunlardan oluşan struct'lar her iki tarafta aynı bellek düzenine sahiptir (blittable tipler); runtime onları olduğu gibi geçirir. string, bool, array'ler ve class'lar ise çevrilir ve bu bir maliyettir. Sıcak bir döngüde native fonksiyon çağırıyorsan signature'ı blittable tiplerle kurmak (string yerine byte* ya da span'den alınmış bir pointer) hem hızlı hem de öngörülebilir olur. C tarafındaki size_t'nin karşılığının nuint olduğuna da dikkat: platformun pointer genişliğinde bir tamsayı.
.NET 7 ile gelen [LibraryImport], bu marshalling kodunu runtime'da değil compile time'da üreten bir source generator. Source generator'ların ne olduğunu bir sonraki yazıda göreceğiz; .NET 6'da yolumuz [DllImport].
Callback: Native Koddan C#'a Dönmek
Bazı C fonksiyonları senden bir fonksiyon ister. Klasik örnek, libc'nin qsort'u: neyi sıraladığını bilmez, karşılaştırmayı senin vereceğin fonksiyona sorar. C# 9 ile gelen function pointer'lar ve [UnmanagedCallersOnly] ile bunu delegate oluşturmadan yapabilirsin:
using System.Runtime.InteropServices;
int[] stock = { 12, 3, 40, 7, 25 };
unsafe
{
fixed (int* p = stock)
{
Native.qsort(p, (nuint)stock.Length, sizeof(int), &Native.CompareStock);
}
}
Console.WriteLine(string.Join(", ", stock));
static unsafe class Native
{
[DllImport("libc")]
public static extern void qsort(void* items, nuint count, nuint size, delegate* unmanaged<void*, void*, int> compare);
[UnmanagedCallersOnly]
public static int CompareStock(void* a, void* b) => (*(int*)a).CompareTo(*(int*)b);
}
3, 7, 12, 25, 40
CompareStock'un üstündeki attribute, runtime'a bu method'un native koddan doğrudan çağrılacağını söyler. &Native.CompareStock onun adresini delegate* unmanaged<void*, void*, int> tipinde bir function pointer olarak verir. qsort array'in adresini, element sayısını ve element boyutunu alıp her karşılaştırmada bizim method'umuzu çağırdı. Array'i fixed ile sabitlediğimiz için sıralama boyunca GC onu taşıyamadı.
Eski yol, bir delegate tanımlayıp onu marshal etmekti. Hâlâ çalışır ama bir tuzağı var: native taraf callback'i saklıyorsa ve delegate GC tarafından toplanırsa, native kod artık var olmayan bir fonksiyonu çağırır. Function pointer'ın gösterdiği static method ise hep yerindedir.
Native Bellek ve Span
Native bir kütüphaneye buffer vermen ya da GC'nin hiç görmeyeceği büyük bir alan ayırman gerekebilir. .NET 6 ile gelen NativeMemory class'ı C'nin malloc/free'sine denk gelir. Ve Span yazısında söylediğimiz gibi, span bu belleğin önüne de konabilir:
using System.Runtime.InteropServices;
unsafe
{
byte* buffer = (byte*)NativeMemory.Alloc(16);
try
{
var span = new Span<byte>(buffer, 16);
span.Fill(0x2A);
span[0] = 1;
Console.WriteLine($"{buffer[0]} {buffer[1]} {span.Length}");
Console.WriteLine(span.IndexOf((byte)1));
}
finally
{
NativeMemory.Free(buffer);
}
}
1 42 16
0
Pointer'ı yalnızca bir kez, span oluşturmak için kullandık; sonrası Fill, indexer ve IndexOf ile sınır kontrolü olan güvenli kod. Bu, unsafe kodu küçük tutmanın en iyi yolu: pointer'ı sınırda span'e çevir, işi span'le yap. try/finally şart; GC bu belleği tanımaz ve Free'yi çağırmazsan program bitene kadar sızar. Native bir kaynağı uzun süre tutan bir class yazıyorsan, OOP serisinde gördüğümüz SafeHandle türevleri ve IDisposable bu temizliği garanti altına alır.
Sık Düşülen Tuzaklar
Pointer'ın sınır tanımaması. Aynı array'in dördüncü element'ini önce pointer'la, sonra span'le okuyalım:
int[] stock = { 5, 3, 8 };
unsafe
{
fixed (int* p = stock)
{
int value = p[3];
Console.WriteLine("pointer read past the end: no exception");
}
}
try
{
Span<int> span = stock;
Console.WriteLine(span[3]);
}
catch (IndexOutOfRangeException ex)
{
Console.WriteLine($"span: {ex.GetType().Name}");
}
pointer read past the end: no exception
span: IndexOutOfRangeException
Pointer, array'in bittiği yerden sonrasını hiç itiraz etmeden okudu; orada ne varsa (başka bir object'in parçası, boşluk) onu getirdi. Yazsaydı başka bir object'i bozardı ve hata çok sonra, alakasız bir yerde çıkardı. Span ise aynı okumada IndexOutOfRangeException fırlattı. Pointer'la çalışırken her erişimin sınırını sen hesaplarsın.
Pointer'ı fixed bloğunun dışına kaçırmak. fixed içinde aldığın adresi bir field'a ya da bloğun dışındaki bir değişkene yazarsan compiler seni her zaman durduramaz. Blok bittiğinde pin kalkar; GC array'i taşıdığında elindeki adres geçersiz olur. Bu hatalar test ortamında, GC'nin çalışmadığı kısa koşularda görünmez, production'da yük altında ortaya çıkar.
Yanlış platformdaki kütüphane. P/Invoke signature'ı compile time'da kontrol edilmez; kütüphane ilk çağrıda yüklenir. Windows'a özgü bir çağrıyı macOS'ta denersen:
using System.Runtime.InteropServices;
try
{
Console.WriteLine(Win32.GetCurrentProcessId());
}
catch (DllNotFoundException ex)
{
Console.WriteLine(ex.GetType().Name);
Console.WriteLine(ex.Message.Split('.')[0]);
}
static class Win32
{
[DllImport("kernel32")]
public static extern uint GetCurrentProcessId();
}
DllNotFoundException
Unable to load shared library 'kernel32' or one of its dependencies
Benzer şekilde, signature'daki tipler C tarafıyla uyuşmazsa (32 bit yerine 64 bit, yanlış calling convention) exception bile almazsın; yanlış değerler ya da process çökmesi alırsın. Signature'ları belgelerden dikkatle çıkar ve her platformda test et.
Gereksiz yere unsafe'e kaçmak. "Pointer daha hızlıdır" sezgisi çoğu zaman yanlış. JIT, span ve array döngülerinde sınır kontrollerini büyük ölçüde kaldırabiliyor. Unsafe'i yalnızca native kodla konuşmak zorunda olduğunda ya da ölçülmüş bir darboğazda, span'in yetmediğini gördüğünde kullan.
Küçük Bir Deneme
★☆☆ strlen örneğine kendi adını ve birkaç aksanlı karakter içeren bir kelime ekle. Length ile strlen arasındaki farkı Encoding.UTF8.GetByteCount ile karşılaştırıp doğrula.
★★☆ qsort örneğini, stoklara göre büyükten küçüğe sıralayacak şekilde değiştir. Sonra [UnmanagedCallersOnly] attribute'unu silip derle; compiler ne diyor?
★★★ Blittable bir struct BookRecord { public long Isbn; public int Stock; } tanımla ve bir BookRecord[] array'ini qsort ile önce stoğa, eşitlikte ISBN'e göre sırala. sizeof(BookRecord) kaç çıkıyor ve neden 12 değil?
P/Invoke'ta marshalling kodunun compile time'da üretilebildiğinden bahsettik. Bir sonraki yazıda bu fikrin kendisine bakıyoruz: compiler'a eklenip derleme sırasında senin için C# kodu yazan source generator'lar ve incremental generator ile basit bir örnek.