A .NET a szofverfejlesztéshez szükséges eszközök egész tárházát biztosÃtja a fejlesztÅ‘k számára. Amióta bevezetésre került a NuGet csomagkezelÅ‘ rendszer, a lehetÅ‘ségek száma tovább bÅ‘vült. A sok managed megoldás mellett felmerülhet az igény, hogy natÃv (unmanaged) .dll fájlból hÃvjunk meg függvényeket a .NET-re épülÅ‘ programunkból. Erre biztosÃt lehetÅ‘séget a Platform Invoke, vagy röviden PInvoke.
Tipikus Platform Invoke hÃvás lehet például egy komplex alkalmazásban valamilyen .NET alatt nem elérhetÅ‘ Windows API függvény meghÃvása. A Windows API a Microsoft Windows operációs rendszerek alkalmazásprogramozási felülete. Az API egészen a kezdeti Windows verziók óta jelen van és visszafelé kompatibilis, többé-kevésbé. Anno C-ben kezdték el megÃrni, ezért a DLL fájlokban függvények hÃvogatásával tudunk dolgokat megvalósÃtani.
A .NET keretrendszer számos ilyen API függvényhez biztosÃt kulturált, objektum orientált hozzáférési lehetÅ‘séget, ezért manapság nem sok szükség van Windows API hÃvások beiktatására a programunkba.
Nézzünk egy példát a Platform Invoke működésére.
using System;
using System.Runtime.InteropServices;
namespace PeldaPinvoke
{
class Program
{
[DllImport("user32.dll", CharSet = CharSet.Auto)]
public static extern int MessageBox(IntPtr hWnd, string text, string caption, int options);
static void Main(string[] args)
{
MessageBox(IntPtr.Zero, "Pinvoke Minta", "Pinvoke", 0);
Console.ReadKey();
}
}
}
A példában a MessageBox metódus egy külsÅ‘ függvény, amit meg tudunk hÃvni. Ezt az extern kulcsszó jelzi a fordÃtónak. Az extern jelzÅ‘vel ellátott metódusoknak statikusnak kell lenniük.
A metódus előtt lévő DllImport attribútum mondja meg, hogy a metódus implementációja melyik dll fájlban található. A CharSet paraméter megadása tanácsos abban az esetben, ha a metódus szövegekkel dolgozik. Ha ezt elmulasztjuk, akkor az átadott vagy visszaadott szövegekben hibák lehetnek, mivel C/C++ esetén ASCII és UTF karakterkódolás is használható.
A MessageBox metódus klasszikus WinAPI hÃvás, mondhatni a legegyszerűbb, ami valami látványosat csinál. Ez egy felugró üzenetet fog megjelenÃteni a felhasználónak.
A Windows API hÃvások leÃrása a https://docs.microsoft.com/en-us/windows/desktop/apiindex/windows-api-list cÃmen található meg. C# használatuk legnagyobb nehézsége talán az, hogy a C/C++ tÃpusokat menedzselt tÃpusoknak feleltessük meg, illetve a megfelelÅ‘ konvertálással ellássuk ezeket a metódusokat. A menedzselt/nem menedzselt tÃpus konverziót a keretrendszer Marshalling (rendezés) néven ismeri.
Szerencsére a legtöbb Windows API hÃváshoz elérhetÅ‘ C# deklaráció a https://pinvoke.net/ weboldalon.
LibraryImport
.NET 7 óta elérhetÅ‘ a LibraryImport attribútum, ami a natÃv kódok hÃvását hivatott felgyorsÃtani. NatÃv hÃváskor a legtöbb erÅ‘forrást a menedzselt – nem menedzselt – menedzselt memória tér váltások viszik el. A DllImport esetén ezek a konverziók futási idÅ‘ben történnek, de mivel a LibraryImport forráskódot generál, ezért ez a konverziós logikát fordÃtás közben előállÃtja, Ãgy a hÃvás esetén a konverzióknak nincs akkora hatása.
Ez különösen hasznos, ha a kód struktúrát vagy string tÃpust alkalmaz.
Az alábbi kódrészlet a LibraryImport használatát mutatja a be az első MessageBox példán keresztül:
using System;
using System.Runtime.InteropServices;
namespace PeldaPinvoke
{
//partial, mivel kód generálódik az osztályba
partial class Program
{
//szintén partial, mert a tényleges kódja generálódik
[LibraryImport("user32.dll", EntryPoint = "MessageBoxW", StringMarshalling = StringMarshalling.Utf16)]
private static partial int MessageBox(nint hWnd, string text, string caption, nint options);
static void Main(string[] args)
{
MessageBox(0, "Pinvoke Minta", "Pinvoke", 0);
Console.ReadKey();
}
}
}
A fenti kód csak akkor fog működni, ha az unsafe engedélyezve van. Ennek az oka az, hogy a LibraryImport pointerekkel működő kódot generál. Szintén fontos kiemelni, hogy az EntryPoint meghatározása jelen esetben azért szükséges, mert a user32.dll valójában nem rendelkezik MessageBox függvénnyel. Helyette létezik MessageBoxA és MessageBoxW. Az egyik 8 bites ANSI szövegekkel dolgozik, a másik pedig 16 bites UTF karakterekkel. Éppen ezért a StringMarshalling meghatározása is kötelező.
Felmerülhet a kérdés, hogy ha nem létezik a MessageBox függvény, akkor a DllImport esetén hogyan működhetett a kód? A válasz az, hogy a DllImport fel van készÃtve a Windows API függvény A és W nevezéktanára és a karakterkódolás alapján határozza meg menet közben, hogy most melyik függvényt fogja meghÃvni.
PInvoke saját C++ dll esetén
Adódhat az eset, amikor egy általunk készÃtett C++ dll függvényeit kellene meghÃvnunk a C# programunkból. Természetesen erre is lehetÅ‘ség van. Azonban ellentétben a WinAPI-val, itt ügyelnünk kell néhány extra dologra. Leginkább arra, hogy ha a dll fájl x86 (32 bites), akkor a C# programunk is csak akkor fogja tudni használni, ha Å‘ is x86-ra van fordÃtva. Ugyanez igaz az x64 dll fájlok esetén is.
Továbbá fontos, hogy a dll fájlunk exportált metódusai esetén C# oldalon beállÃtsuk a megfelelÅ‘ hÃvási módot a C# metódus annotálásakor. Erre azért van szükség, mert a nem menedzselt világban több metódushÃvási technikát kitaláltak anno, hogy a lehetÅ‘ legjobb kódot eredményezze a fordÃtás. Erre leginkább azért volt szükség, mert 32 bites módban a processzor csak 4 regiszterrel rendelkezik általános feladatokra, ami igen kevés bármire is.
A két népszerű metódushÃvási módszer az __stdcall és a __cdecl. Az utóbbi manapság nem annyira elterjedt, ez a C szabvány szerinti metódushÃvás, leginkább tisztán C-ben Ãrt réges-régi dll fájlok esetén találkozhatunk vele. Az __stdcall hÃvási módszert használja a Windows API. Ezt a hÃvási módot minden Windows-os natÃv dll-t készÃteni tudó fordÃtónak támogatnia kell. Tehát ha a dll fájlunkban az exportált metódusokat ezzel jelöljük meg, akkor nagy valószÃnűséggel gond nélkül működni fog.
Nézzünk egy komplexebb példát. A C++ dll kód egy Fibonacci számsor számÃtást valósÃt meg, natÃv memória kezeléssel. A kódban az InitFibonacci metódus inicializál egy tömböt, amibe a megadott elemszámig kiszámÃtásra kerülnek a Fibonacci számsor elemei. A DeleteFibonacci metódus a tömb tartalmát szabadÃtja fel. A tényleges számértékeket pedig a GetFibonacci metódussal tudjuk lekérni. A példába került még egy GetString metódus ami, egy szöveget ad vissza.
A C++ kód:
#define WIN32_LEAN_AND_MEAN // Exclude rarely-used stuff from Windows headers
// Windows Header Files
#include <windows.h>
#include <SDKDDKVer.h>
#include <string>
BOOL APIENTRY DllMain(HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
int64_t __stdcall GetString(wchar_t str[], int64_t size)
{
std::wstring string = L"Hello from native dll";
return wcscpy_s(str, size, string.c_str());
}
int64_t *szamok;
uint32_t allocated;
uint32_t __stdcall InitFibonacci(uint32_t limit)
{
szamok = new int64_t[limit +2];
allocated = limit;
szamok[0] = 0;
szamok[1] = 1;
for (uint32_t i = 2; i <= limit; i++)
{
szamok[i] = szamok[i - 1] + szamok[i - 2];
}
return limit;
}
void __stdcall DeleteFibonacci()
{
delete[]szamok;
}
uint64_t __stdcall GetFibonacci(uint32_t limit)
{
if (limit < 0 || limit > allocated)
return -1;
else
return szamok[limit];
}
A C++ kód modern, mert sima ASCII char helyett UTF kompatibilis wchar_t tÃpust használ szöveg leÃrásra és architektúra független egész tÃpusokkal van felépÃtve. Ez a két kódolási konvenció újonnan Ãrt C++ alkalmazások, dll fájlok esetén erÅ‘sen ajánlott.
C# oldalon elsÅ‘ körben deklarálnunk kell az importált metódusokat. Ezeket ajánlás szerint egy internal módosÃtóval ellátott statikus osztályban kell megtenni, ami csak a natÃv import metódusok definÃcióját tartalmazza.
using System.Runtime.InteropServices;
using System.Text;
namespace PeldaPinvoke2
{
internal static class Native
{
[DllImport("PELDAPINVOKENATIVE.dll", CallingConvention = CallingConvention.StdCall, CharSet =CharSet.Unicode)]
public static extern long GetString(StringBuilder output, long size);
[DllImport("PELDAPINVOKENATIVE.dll", CallingConvention = CallingConvention.StdCall)]
public static extern uint InitFibonacci([MarshalAs(UnmanagedType.U4)]uint limit);
[DllImport("PELDAPINVOKENATIVE.dll", CallingConvention = CallingConvention.StdCall)]
public static extern void DeleteFibonacci();
[DllImport("PELDAPINVOKENATIVE.dll", CallingConvention = CallingConvention.StdCall)]
public static extern long GetFibonacci([MarshalAs(UnmanagedType.U4)]uint limit);
}
}
A DllImport attribútumban minden metódus esetén meg lett adva az StdCall hÃvási mód, csak úgy mint a natÃv kódban. Ha itt eltérés lenne, akkor a natÃv metódusnak más módszerrel történne az adatátadás, ami futás idejű hibát váltana ki. A GetString metódus esetén a karakterkészlet explicit meg lett mondva, hogy Unicode alapú.
A MarshalAs attribútummal mondható meg paraméterek esetén, hogy melyik tÃpust mire konvertálja a keretrendszer. Erre akkor lehet szükség, ha a C# deklaráció tÃpusai és a C++ deklaráció tÃpusai eltérnek egymástól. Azonban ha megegyeznek, akkor igazából nincs rájuk szükség.
Eltérni tÃpusokban a C# deklarációban1 lehetséges, ha megfelelÅ‘en konvertálhatók a tÃpusok, de nem ajánlott olyan esetekben, amikor sűrűn egymás után hÃvjuk a natÃv metódust, mert akkor a konvertálás miatt a programunk nagyon belassulhat.
A szöveg átadása mindig problémás tud lenni. Ha a metódus Ãr egy buffert, akkor StringBuilder osztályt érdemes használni a C# deklarációban, ha pedig bemeneti paraméter a C++ kód számára, akkor string tÃpust.
A natÃv metódusok elrejtésére és rendes C# kezelésre, ami megbirkózik a nem menedzselt memória felszabadÃtásával készÃtettem egy C# osztályt:
using System;
using System.Text;
namespace PeldaPinvoke2
{
public class NativeFibonacci: IDisposable
{
uint _count;
public NativeFibonacci(uint limit)
{
_count = Native.InitFibonacci(limit);
StringBuilder sb = new StringBuilder(100);
long value = Native.GetString(sb, (long)sb.Capacity);
Console.WriteLine(sb.ToString());
}
~NativeFibonacci()
{
Dispose(true);
}
public void Kiir()
{
Console.WriteLine();
for (uint i=1; i<_count; i++)
{
long eredmeny = Native.GetFibonacci(i);
Console.Write("{0}, ", eredmeny);
}
Console.WriteLine();
}
protected void Dispose(bool finalizer)
{
if (_count > 0)
{
Native.DeleteFibonacci();
_count = 0;
}
}
public void Dispose()
{
Dispose(false);
}
}
}
A segédosztályt felhasználó fő program:
using System;
namespace PeldaPinvoke2
{
class Program
{
static void Main(string[] args)
{
NativeFibonacci fibonacci = new NativeFibonacci(42);
fibonacci.Kiir();
Console.ReadKey();
}
}
}
Struktúrák PInvoke esetén
Előfordulhat, hogy a metódus egy struktúrát vár bemeneti paraméterként, vagy a kimeneti paramétere egy struktúra. Természetesen ilyen metódusok használatára is lehetőség van. Példaként nézzünk egy egyszerű C++ kódot, ami struktúrákkal dolgozik.
typedef struct Pont
{
float X;
float Y;
};
void __stdcall GetStruct(Pont* output)
{
output->X = 3.14;
output->Y = 1.41;
}
A GetStruct metódus egy paraméterként kapott Pont struktúra tartalmát módosÃtja. Ahhoz, hogy C# esetén meg tudjuk hÃvni a metódust, a Pont struktúrát át kell ültetnünk C# kódba, ami jelen esetben a tÃpusok egyezése miatt további Marshall annotációt nem fog igényelni. Viszont meg kell mondanunk a keretrendszer számára, hogy a struktúránk a memóriában hogy helyezkedik el.
Alapértelmezés szerint a CLR, hogy kevesebbszer kelljen töredezettségmentesÃteni, a nagyobb méretű struktúrákat darabokban tárolja a memóriában. Ez menedzselt kód esetén tökéletes megoldás, de natÃv hÃvás esetén nem tud a C++ kódnak egy egybefüggÅ‘ memóriaterületet mutatni, amibe majd Ãródik a hÃvás eredménye.
Ezért került bevezetésre StructLayout attribútum, ami egy LayoutKind felsorolásból vár értékeket. A LayoutKind két fontos értéke a LayoutKind.Sequential és a LayoutKind.Explicit. Alapértelmezezés a LayoutKind.Auto, ami csak CLR kód esetén alkalmazható.
A Sequential jelzÅ‘ a CLR-t arra utasÃtja, hogy a struktúra elemei egymás után sorban, szekvenciálisan helyezkedjenek el, mint egy C/C++ struktúra esetén. Az Explicit jelzÅ‘ viszont arra utasÃtja a CLR-t, hogy a struktúra elemei pontosan úgy helyezkedjenek el a memóriában, mint ahogy az további attribútumokkal annotálva van. Utóbbi megoldás úniók konvertálása esetén használatos, mivel a C# nyelven nincs Union tÃpus.
Az adatok elhelyezkedésének annotálására a FieldOffset attribútum alkalmazható, aminek a konstruktorában byte értékként meg kell mondani, hogy az adott változó hol helyezkedik el a struktúra elejéhez képest.
Ennyi felvezetés után jöjjön egy C# mintakód a fentebb bemutatott GetStruct meghÃvására:
using System;
using System.Runtime.InteropServices;
namespace PeldaPinvoke3
{
[StructLayout(LayoutKind.Sequential)]
struct Pont
{
public float X;
public float Y;
}
//AlternatÃv, explicit leÃrása ugyanannak a struktúrának
//A Pack = 8 azt mondja ki, hogy a struktúra 8 byte méretű
//float = 32 bit => 4 byte
[StructLayout(LayoutKind.Explicit, Pack = 8)]
struct PontExplicit
{
//0. byte-tól kezdődően
[FieldOffset(0)]
public float X;
//4. byte-tól kezdődően
[FieldOffset(4)]
public float Y;
}
class Program
{
[DllImport("PELDAPINVOKENATIVE.dll", CallingConvention = CallingConvention.StdCall)]
private static extern void GetStruct(out Pont pont);
static void Main(string[] args)
{
Pont p = new Pont();
GetStruct(out p);
Console.WriteLine("x: {0}; y: {1}", p.X, p.Y);
Console.ReadKey();
}
}
}
Mivel a C++ kód mutatót ad vissza, ezért a hÃvás esetén vagy referenciaként (ref) adjuk át a változót, vagy kimeneti változónak jelöljük (out). Jelen esetben mindkét megoldás működne, de az out paraméteres megoldás szebb, mivel a C++ kód csak Ãrja a struktúrát. Értelemszerűen ha bemeneti paraméter is lenne, akkor már csak a referencia átadás lenne a jó megoldás.
PInvoke és a biztonság
A PInvoke segÃtségével behÃvott natÃv kódok esetén fontos megemlÃteni, hogy a natÃv kódban keletkezÅ‘ kivételek elkapására nincs lehetÅ‘ség. Ennek az oka az, hogy a natÃv API hÃvások C konvenciót követnek és a C nem rendelkezik a kivételkezelés koncepciójával. EbbÅ‘l adódóan, ha a behÃvott kódban hiba történik, akkor az magával viszi a teljes alkalmazásunkat. Ilyen hiba C és C++ esetén a legtöbb esetben a buffer under és overflow.
Az ilyen hibák megtalálása nem minden esetben triviális. Azonban ha rendelkezünk a natÃv komponens forráskódjával, akkor egy jó kiindulópont lehet a biztonság felé, hogy nem használjuk a C standard library azon függvényeit, amelyek biztonsági szempontból problémásak. A legproblémásak: strcpy, strcat, getwd, gets, scanf, fscanf, sprintf.
Felmerülhet a kérdés, hogy mi van akkor, ha nem rendelkezünk a komponens forráskódjával, de mégis biztonságosan szeretnénk natÃv kódot hÃvni? Ebben az esetben a legjobb megoldás amit tehetünk az, hogy a natÃv kód hÃvását egy külön binárisba csomagoljuk, amit külön folyamatként indÃtunk el és valamilyen IPC megoldással kommunikál a programunk a natÃv kódot hÃvó programmal. Ebben az esetben, ha a natÃv kód valami hiba miatt elhal, akkor "csak" a natÃv kódot futtató alkalmazás hal el, amit a másik programunk újra tud indÃtani szükség esetén.
-
Például a natÃv metódus
longtÃpust vár, de miintparaméterrel definiáltuk a metódusunkat↩