Z pogranicza – używamy DLL pisanej w C++ – część 2 – struktury

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.

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 parametru Pack, ż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ów FieldOffset. 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.

Podziel się artykułem na: