Z pogranicza – używamy DLL pisanej w C++ – część 2 – struktury
Wstęp
W pierwszej części artykułu opisałem podstawy mieszania języków – C# i C++. Skupiliśmy się tam nieco bardziej na stringach, które nie są tak oczywiste i czasem sporo z nimi zabawy. Dzisiaj będzie coś jeszcze mniej oczywistego – struktury. Jeśli jednak nie ogarniasz podstaw, koniecznie przeczytaj pierwszą część artykułu.
Język C++ jest tylko przykładowy. Za każdy razem, gdy piszę o funkcji lub strukturze C++, mam na myśli funkcję / strukturę pisaną w dowolnym języku niskiego poziomu (niezarządzanego) typu C, C++, Pascal.
Przypominam, że cykl jest dość zaawansowany. Wymaga trochę wiedzy na temat pamięci i wskaźników. Co nieco pisałem w tym artykule. Ale jeśli czegoś nie rozumiesz, to koniecznie daj znać w komentarzu.
Przykładowy projekt
Przygotowałem prostą solucję, którą możesz pobrać z GitHuba. To są dwa projekty – jeden C++, drugi C#. Projekt C++ to prosta biblioteka DLL, którą będziemy używać w C#. Nie ma żadnego konkretnego sensu. Napisałem ją tylko na potrzeby tego artykułu. Na dzień pisania artykułu (lipiec 2024) biblioteka nie jest w pełni ukończona. Będę dodawał jej funkcjonalność przy kolejnych częściach artykułu. Niemniej jednak, projekty się budują i działają w obrębie tego artykułu jak i jego pierwszej części.
Jeśli masz problem z uruchomieniem solucji, jest to dokładnie opisane w pierwszej części artkułu.
Struktury w pamięci
Niestety struktury zarządzane i niezarządzane są nieco inaczej rozłożone w pamięci. Dlatego też trzeba zastosować kilka dodatkowych środków, żeby to ładnie pożenić. Ale od początku.
Co ciekawe, nazwy struktur nie mają tutaj żadnego znaczenia. Po stronie C++ możesz mieć strukturę nazwaną UserInfo
, natomiast po stronie C# to może być AdditionalUserData
. Nie ma to żadnego znaczenia.
To, co faktycznie ma znaczenie, to pola w tych strukturach. Muszą być zdefiniowane dokładnie w takiej samej kolejności i muszą zajmować dokładnie tylko samo bajtów w pamięci:
- ta sama kolejność pól
- pola tej samej długości
Niestety nie ma opcji, żeby w czasie kompilacji sprawdzić to. Po prostu jeśli zrobisz coś źle, to aplikacja się wywali w runtime. Z tego wychodzi pierwsze niebezpieczeństwo, które wiele lat temu (gdy na muchy mówiłem jeszcze „ptapty”) pozbawiło mnie kilku dni życia. I wcale to nie jest takie oczywiste.
Atrybut StructLayoutAttribute
W C#, ze względów wydajnościowych, pola w strukturze czasami są zamieniane miejscami. I zupełnie inaczej wyglądają w pamięci niż w kodzie. Dlaczego? Bo procesor najłatwiej i najszybciej odczytuje bloki po 4/8 bajtów w pamięci. Dochodzi do tego jeszcze padding.
Czym jest padding?
Jak już pisałem, procesor najlepiej radzi sobie (najbardziej optymalnie) z blokami po 4/8 bajtów w pamięci. Zatem ile miejsca w pamięci zajmie taka struktura?
struct MyStruct
{
public bool b;
public int i;
}
Odpowiedź może Cię zdziwić. Pomimo, że zmienne typu bool
potrzebują 1 bajta w pamięci, a int
4 bajtów, to taka struktura zajmie 8 bajtów, a nie 5. Dlaczego? Właśnie przez optymalizację. Gdy kompilator zobaczy zmienną typu bool
, będzie chciał dokonać wyrównania (alignment), czyli zarezerwuje jej pamięć tak, żeby zmienna znajdowała się w 4 bajtowym bloku. Czyli dopełni do 4 bajtów. To dopełnienie nazywa się paddingiem. Int
zajmuje już 4 bajty, czyli tutaj nic się nie stanie. Dokładniej rzecz biorąc nie chodzi konkretnie o 4 bajty, tylko o największy składnik w strukturze. Ale to już naprawdę głębokie szczegóły.
Zobacz, jak ta struktura wygląda w pamięci:
MyStruct mstr;
mstr.b = true;
mstr.i = 10;
Jeśli będziesz mieć taką strukturę:
struct MyStruct
{
public bool b;
public bool c;
public int i;
}
to ona też zajmie 8 bajtów. Kompilator zobaczy, że dwie pierwsze zmienne mają 2 bajty, więc dopełni je do 4. Czyli w tym przypadku nie ma różnicy, czy masz jedno pole typu bool, czy 4.
MyStruct mstr;
mstr.b = true;
mstr.c = true;
mstr.i = 10;
A co się stanie teraz?
struct MyStruct
{
public bool b;
public int i;
public bool c;
}
Prawdę mówiąc, nie jestem w stanie odpowiedzieć na to pytanie. Różne ustawienia i wersje kompilatora mogą doprowadzić do różnych wyników. Jednym z nich jest zarezerwowanie 12 bajtów w pamięci (b + 3 bajty paddingu, i, c + 4 bajty paddingu). Innym jest pomieszanie pól w taki sposób, żeby uzyskać strukturę jak z poprzedniego kodu – czyli 2 zmienne bool będą w pamięci obok siebie.
Tylklo dla dociekliwych
Załóżmy, że mamy taką strukturę:
struct MyStruct
{
public bool b;
public bool c;
public int i;
}
Domyślna wielkość paddingu to 4 bajty. W związku z tym, że 2 pierwsze elementy zajmują dwa bajty, kompilator doda kolejne 2 bajty przed zmienną int. Dzięki czemu mamy 2 bloki po 4 bajty.
Jednak jeśli ustawiłbyś jawnie padding na wielkość 2 bajtów – żaden padding nie zostanie dodany – w tym konkretnym przypadku. Dwa pierwsze pola zajmują już 2 bajty. Czyli mamy tutaj blok o określonej wielkości.
A jeśli ustawimy padding na 8 bajtów? Też nic się nie stanie. I dlaczego do cholery? Dlaczego? Ponieważ obliczając wielkość wyrównania, kompilator bierze pod uwagę nie tylko tą żądaną wielkość, lecz również wielkość największego pola w strukturze. A skoro tutaj największym polem jest int – 4 bajty, zatem do tych 4 bajtów będzie dokładane dopełnienie. Oczywiście, jeśli struktura posiadałaby pole typu long (8 bajtów), wtedy zastosowany będzie padding 8 bajtów. W jaki sposób?
struct MyStruct
{
public bool b;
public bool c;
public int i;
public long l;
}
Pierwsze dwa pola zajmą 8 bajtów – każde z nich będzie dopełnione do 4. Kolejne pole – int zajmie 8 bajtów – zostanie dopełnione do 8, No i long ma już 8 bajtów.
A co się stanie, jeśli padding ustawimy na 1?
Żaden padding nie zostanie zastosowany i pola nie będą dopełniane. Ale o tym za chwilę.
I jak to się ma z tym, co wcześniej napisałem? Że struktury w C++ i w C# muszą mieć pola o tej samej wielkości i w tym samym porządku?
Po stronie C# służy do tego wspomniany atrybut StructLayout
.
Jak używać StructLayout?
Atrybut StructLayout
mówi kompilatorowi w jaki sposób ma potraktować daną strukturę w pamięci. Atrybut posiada 4 parametry:
- LayoutKind
- CharSet
- Pack
- Size
LayoutKind
Możemy tutaj ustawić 3 wartości:
Sequential
– pola w strukturze będą ustawione dokładnie w takiej samej kolejności, w jakiej napisaliśmy w kodzie. Powinieneś użyć jeszcze parametruPack
, żeby określić dokładnie padding. I wtedy możesz przekazywać taką strukturę między światem niezarządzanym i zarządzanym.
[StructLayout(LayoutKind.Sequential, Pack = 1)] //Pack ustawiony na 1 zabrania dokonywania jakiegokolwiek paddingu
struct MyStruct
{
public byte b;
public byte b2;
public int i;
}
Explicit
– pola w strukturze są tak ustawione, jak je określisz za pomocą atrybutówFieldOffset
. To znaczy, że możesz dokładnie i jawnie (explicitly) podać w którym miejscu w pamięci ma się zacząć konkretne pole.
[StructLayout(LayoutKind.Explicit)]
struct MyStruct
{
[FieldOffset(0)] public byte b; //w bajcie 0
[FieldOffset(1)] public byte b2; //w bajcie 1
[FieldOffset(2)] public int i; //w bajcie 2
}
W tej sytuacji pole b
będzie na początku struktury. b2
zacznie się w pierwszym bajcie, a i
w drugim. Oczywiście musisz na to uważać, bo możesz zrobić głupotę:
[StructLayout(LayoutKind.Explicit)]
struct MyStruct
{
[FieldOffset(0)] public byte b;
[FieldOffset(2)] public byte b2
[FieldOffset(1)] public int i;
}
Pamiętaj, że pole int
zajmie 4 bajty. Byte
zajmie 1 bajt. I co się stanie teraz? Pole int
przesłoni pole byte
. I w zależności od tego, co przypiszesz jako ostatnie, będą różne dziwne wyniki. Więc jeśli stosujesz layout typu explicit
, to uważaj na to.
Auto
– domyślny układ – czyli pola mogą być w różnym miejscu z paddingiem.
Pack
Parametr Pack
określa wielkość paddingu. Opisałem to w akapicie dla dociekliwych, więc nie będę tutaj się powtarzał. Wartość 0 jest domyślna, natomiast jeśli w ogóle nie chcesz paddingu, daj tam wartość 1.
Size
Określić tym możesz wynikowy rozmiar CAŁEJ struktury. Jeśli jednak podasz zbyt małą liczbę, to spokojnie. Struktura i tak zajmie swoją minimalną wymaganą przestrzeń.
Padding po stronie C++
Po stronie C++ też należy określić padding dla struktury. Tam stosujesz dyrektywę pragma pack
:
#pragma pack(push, 1)
struct MyData
{
USHORT b;
USHORT b2;
int i;
};
#pragma pack(pop)
Pobieranie danych z C++ – struktury kopiowalne
Pobierzemy sobie wreszcie jakieś dane z C++. Na razie spójrzmy na struktury z polami kopiowalnymi (blitable). Jeśli nie pamiętasz, czym są takie pola, to jest to opisane w pierwszej części artykułu.
Po stronie C++ mamy taką strukturę:
#pragma pack(push, 1)
struct Point3d
{
float x;
float y;
float z;
};
#pragma pack(pop)
Prosta struktura reprezentująca trójwymiarowy punkt. Po stronie C# musimy napisać analogiczną:
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct Point3d
{
public float X;
public float Y;
public float Z;
}
Zmienne float
w C++ i w C# zajmują tyle samo miejsca – 4 bajty. Dodatkowo zapewniliśmy integralność obu struktur – brak paddingu
.
W tym momencie możemy traktować naszą zarządzaną strukturę tak jakby to była zwykła natywna struktura. A więc możemy zrobić tak:
//deklaracja w C++
DLL_EXPORT Point3d __stdcall getPoint3d();
//definicja w C++
DLL_EXPORT Point3d __stdcall getPoint3d()
{
return { 1.5f, 2.25f, 3.3f };
}
A po stronie C#:
//deklaracja w C#
[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall)]
private static extern Point3d getPoint3d();
Możemy też bawić się strukturą przez referencję:
//deklaracja w C++
DLL_EXPORT void __stdcall updatePoint3d(Point3d& point);
//definicja w C++
DLL_EXPORT void __stdcall updatePoint3d(Point3d& point)
{
point.x += 1.0f;
point.y += 1.0f;
point.z += 1.0f;
}
I po stronie C#:
[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall)]
private static extern void updatePoint3d(ref Point3d pt);
I teraz uwaga – jeśli w strukturze chcesz mieć jakieś metody – w niczym to nie przeszkadza. Dane przejdą normalnie.
Ten sam padding
Wcześniej pisałem, że procesor działa najbardziej optymalnie, gdy odczytuje bloki o odpowiedniej długości. Ustawiając padding
na 1, wyłączamy go w ogóle i powodujemy, że kod nie jest super optymalny. Oczywiście w standardowych przypadkach to pewnie będzie niezauważalne.
Ale chcę żebyś miał świadomość, że to nie chodzi o to, żeby tego paddingu nie było w ogóle, tylko żeby był taki sam po stronie natywnej i .NET.
Czyli równie dobrze nasze struktury mogłyby wyglądać tak:
#pragma pack(push, 8)
struct Point3d
{
float x;
float y;
float z;
};
#pragma pack(pop)
I po stronie C#:
[StructLayout(LayoutKind.Sequential, Pack = 8)]
public struct Point3d
{
public float X;
public float Y;
public float Z;
}
Padding jest taki sam po stronie C++ i C#. Dopełnienie jest do 8 bajtów. Procesor skorzysta na tym 🙂
Struktury niekopiowalne
Struktury ze stringami
Zacznijmy od struktury ze stringami.
Załóżmy, że po stronie C++ mamy taką strukturę:
#pragma pack(push, 1)
struct FileInfo
{
wchar_t fileName[MAX_PATH];
size_t fileSize;
};
#pragma pack(pop)
To po prostu nazwa pliku i rozmiar pliku. Istotne tutaj jest, że nazwa pliku jest przedstawiona jako statyczna tablica – tzn. tablica o stałej długości. W C# powinniśmy tę strukturę zadeklarować tak:
[StructLayout(LayoutKind.Sequential, Pack = 1, CharSet = CharSet.Unicode)]
public struct CppFileInfo
{
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
public string fileName;
public long fileSize;
}
Użycie StructLayout już znasz. Dodatkowo, w związku z tym, że w strukturze występują stringi, musimy w struct layout zaznaczyć, że te stringi są unicodowe. A są unicodowe, bo po stronie C++ mamy tablicę wchar_t, a nie char.
Ale druga rzecz jest taka, że w związku z tym, że łapiemy zwykłego stringa po stronie C#, musimy zrobić na nim Marshaling. W marshalingu zaznaczamy, że jest to string. Po stronie C++ mamy string w najczystszej postaci, więc musimy powiedzieć .NETowi, że właśnie tak ma traktować ten fragment pamięci. Rozmiar tego stringu ustawiamy na 260, bo taką wartość ma stała MAX_PATH
.
I teraz możemy już wywołać funkcję, która zwróci nam informacje o jakimś pliku. Po stronie C++:
//deklaracja:
DLL_EXPORT FileInfo __stdcall getFileInfo(const wchar_t* pFilePath);
//definicja
DLL_EXPORT FileInfo __stdcall getFileInfo(const wchar_t* pFilePath)
{
std::filesystem::path path{ pFilePath };
FileInfo result = { 0 };
result.fileSize = std::filesystem::file_size(path);
std::wstring filePath = pFilePath;
CopyMemory(result.fileName, pFilePath, filePath.size() * sizeof(wchar_t));
return result;
}
Po stronie C#:
[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode)]
private static extern CppFileInfo getFileInfo(string filename);
Zauważ, że tutaj CharSet
też ustawiamy, ale on odnosi się do zmiennej, którą przekazujemy do C++, a nie do tej ze struktury.
Podsumowując, ze stringami w strukturach radzimy sobie dokładnie tak samo, jak ze zwykłymi stringami – opisanymi w poprzednim artykule. Wszystko dotyczące stringów jest prawdą i tutaj.
Tylko różnica jest taka, że odpowiedni Marshaling musimy ogarnąć na poziomie pola w strukturze, a nie na poziomie definicji funkcji.
To wszystko jeśli chodzi o przekazywanie struktur. W kolejnej części zajmiemy się klasami i wskaźnikami. Będzie zabawa 🙂
Jeśli znalazłeś błąd w artykule lub czegoś nie rozumiesz, koniecznie daj znać w komentarzu.