Z pogranicza – używamy DLL pisanej w C++ – część 1.

Z pogranicza – używamy DLL pisanej w C++ – część 1.

Wstęp

Czasem bywa tak, że trzeba użyć DLL pisanej w innym języku. Sprawa jest dość prosta, jeśli to są języki „natywne”, jak np. C, C++, czy Pascal. Jednak podczas używania natywnej biblioteki w .NET czeka na nas kilka niespodzianek.

Ze względu na ogrom informacji i możliwości, podzielę ten artykuł na kilka części. W tej części ogólny zarys działania mechanizmu, przekazywanie typów prostych i stringów.

W artykule przedstawię głównie praktykę. Teorii jest naprawdę sporo, a znając praktyczne metody radzenia sobie z tym problemem, teoria niewiele daje. Jeśli jednak naprawdę chcecie wiedzieć dokładnie dlaczego tak, a nie inaczej i teoria zawarta w tym artykule jest dla Was niewystarczająca, dajcie znać w komentarzu 🙂

Za każdym razem, gdy piszę o funkcji C++, mam na myśli funkcję pisaną w dowolnym języku niskiego poziomu (niezarządzanego) typu C, C++, Pascal.

Zaznaczam, ż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.

Czym się różni funkcja od metody

Jeśli nigdy nie miałeś do czynienia z programowaniem w innych językach niż C#, możesz nie znać różnicy między funkcją a metodą. Można powiedzieć, że metoda to funkcja będąca składnikiem klasy. W innych językach nie wszystko musi być klasą, wobec czego funkcja może być umieszczona poza jakąkolwiek klasą.

Rozróżnienie w tym artykule jest istotne, ponieważ, aby można było używać DLL napisanej w C++, w kodzie muszą zostać wyeksportowane poszczególne funkcje (z klasami też w prawdzie się da, ale to jest już zupełnie inny mechanizm na zupełnie inny cykl).

Jak to działa z grubsza?

Mamy dwa światy – kody zarządzane (managed) i niezarządzane (unmanaged). Zarządzane to te pisane przykładowo w C#. Niezarządzane to natywne – może być to C++, Pascal itp.

Jak się pewnie domyślasz te dwa różne światy dzieli pewna przepaść. Powstał więc mechanizm marshalingu jako pomost między nimi.

Co to ten Marshaling?

Wyobraź sobie Marshaling jako proces przekształcania danych między różnymi formatami. Jeśli przykładowo wysyłasz żądanie HTTP z jakimś obiektem, to ten obiekt jest przekształcany na format JSON, a po drugiej stronie przekształcany z JSON na jakąś inną klasę (być może nawet w innym języku). Można powiedzieć, że to też pewna forma Marshalingu. Chociaż w przypadku programowania międzyplatformowego oczywiście mówimy o czymś bardziej skomplikowanym.

Ale wszystko sprowadza się właśnie do tego. Mechanizm marshalingu po prostu bierze kilka bajtów z pamięci i konwertuje je (przekształca) na odpowiedni typ danych.

Co to Platform Invoke (P/Invoke)?

To jest mechanizm (w C# wyrażany za pomocą atrybutów), który pozwala na używanie funkcji ze świata niezarządzanego w środowisku .NET. Częścią tego mechanizmu jest Marshaling.

Co się dzieje, gdy wywołujemy funkcję z C++?

Mechanizm P/Invoke wkracza do akcji i:

  1. Szuka biblioteki DLL, która zawiera wymaganą funkcję
  2. Ładuje bibliotekę do pamięci (tylko przy pierwszym wywołaniu)
  3. Znajduje adres w pamięci, w którym zaczyna się wywoływana funkcja
  4. Kładzie (push) argumenty na stosie, robiąc marshaling (marshaling tylko przy pierwszym wywołaniu)
  5. Uruchamia funkcję znajdującą się pod znalezionym adresem.
  6. Jeśli funkcja coś zwraca, wtedy P/Invoke znów dokonuje marshalingu (jeśli trzeba) i zwraca te dane do kodu zarządzanego (w uproszczeniu).

Lokalizowanie funkcji

Mangling

Każda funkcja w DLL ma swój numer lub nazwę. Niestety to wcale nie musi być nazwa jakiej się spodziewamy. Istnieje mechanizm manglingu, szczególnie istotny w C++. Polega to na tym, że do funkcji dodaje się prefix i/lub sufix, którego zadaniem jest utworzenie jednoznaczej nazwy funkcji. Załóżmy, że mamy dwie funkcje:

void foo();
void foo(int a);

Teraz mangling bierze pod uwagę parametry i typy zwracane poszczególnych funkcji i ostatecznie mogą one zostać wyeksportowane pod takimi nazwami (zupełnie przykładowo):

foo@_v1
foo@_vi2

Od czego zależy mangling? Chociażby od tego, czy funkcje eksportujesz jako funkcje C, czy C++. W przypadku eksportu funkcji jako C, mangling nie jest wykorzystywany. Dzięki czemu funkcje w DLL mają dokładnie takie nazwy, jakich się spodziewamy.

Kolejność parametrów i odpowiedzialność

Jak się domyślasz, kolejność parametrów jest cholernie ważna. Ale też może być różna. Co więcej, czasem za niszczenie parametrów na stosie odpowiedzialny jest caller, a czasem biblioteka, którą wywołujemy. To wszystko zależy od konwencji wywołania funkcji.

Mamy w programowaniu kilka konwencji:

  • stdcall – funkcja w bibliotece jest odpowiedzialna za zniszczenie parametrów na stosie. Parametry są przesyłane w odwrotnej kolejności (od prawej do lewej). Głównie świat Windows.
  • cdecl – kod wywołujący (caller) jest odpowiedzialny za zniszczenie parametrów na stosie. Parametry są przesyłane od lewej do prawej. Głównie świat Unix

Są jeszcze inne konwencje wywołań, ale nie będę o nich pisał, bo nie są istotne dla tego artykułu. Jeśli Cię to interesuje, poczytaj o: fastcall, thiscall, vectorcall, syscall.

Zatem, żeby możliwe było używanie funkcji z C++, konwencje wywołań muszą być jednakowe zarówno w bibliotece jak i w kodzie wywołującym. Jeśli będą inne – wszystko wybuchnie i posypią się meteoryty.

Zestaw znaków (Charset)

Funkcje WinApi (i niektórych frameworków) można podzielić m.in. na użycie zestawu znaków. Jedne używają czystego ANSI, a drugie Unicode (WideString). To jest istotne, ponieważ te, używające ANSI są zakończone literką A. Te drugie – W. Np.:

int MessageBoxA(const char * pMessage); //używa ANSI
int MessageBoxW(const wchar_t * pMessage); //używa Unicode

Jeśli zatem używasz funkcji, która przyjmuje w parametrze jakieś stringi, wtedy istotne jest, abyś określił zestaw znaków w mechanizmie P/Invoke. To pomaga na wyszukanie odpowiedniej wersji funkcji, a poza tym na odpowiedni marshaling stringów. Chociaż to jest trochę głębszy temat, do którego wrócimy później. Tak, mechanizm P/Invoke może szukać funkcji o nazwie FooA, zamiast Foo, jeśli określisz Charset na ANSI (analogicznie może szukać FooW, jeśli określisz Charset na Unicode).

Ok, czas pobrudzić sobie ręce…

Jedziemy z koksem

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 (kwiecień 2023) 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.

Budowanie solucji

Żeby móc zbudować tę solucję, musisz mieć zainstalowane narzędzia do budowania C++. Biblioteka używa toolseta v143 (VisualStudio 2022). Musisz mieć w Visual Studio Installer zaznaczone „Programowanie aplikacji klasycznych w C++”:

Uruchamianie i debugowanie

Zaznacz w Visual Studio konfigurację Debug x64, a jako projekt startowy – CsClient:

Zwróć też uwagę na plik launchSettings.json w projekcie CsClient. Powinien wyglądać tak:

{
  "profiles": {
    "CsClient": {
      "commandName": "Project",
      "nativeDebugging": true
    }
  }
}

Dzięki temu możliwe będzie debugowanie kodu C++ z aplikacji pisanej w C#.

Zacznijmy od czegoś prostego…

Podstawy

Przede wszystkim pamiętaj, że dobrze jest stworzyć sobie klasę w C#, która będzie odwoływać się do funkcji eksportowanych z biblioteki. Tutaj rolę tej klasy przejmuje LibraryClient.

Weź też po uwagę, że w rzeczywistym świecie niektóre biblioteki mogą nie nadawać się do importu. Nie eksportują żadnych funkcji albo eksportują je z natywnymi typami danych (np. std::wstring). Lub też eksportują całe klasy (jeśli to jest mechanizm COM, to można z tym sobie poradzić, ale to temat na zupełnie inny artykuł).

Pierwsza klasa

Na początek spójrzmy na kod w C++ – plik library.h. To jest plik nagłówkowy, w którym są umieszczane deklaracje funkcji. Zobacz, że są w bloku extern "C", dzięki czemu nie ma manglingu. Każda z nich jest eksportowana w konwencji stdcall. Teraz spróbujmy użyć pierwszej:

DLL_EXPORT int __stdcall add(int a, int b);

Ta funkcja dodaje do siebie dwa inty i zwraca ich sumę.

Stwórzmy teraz klasę wrappera w C#:

internal class LibraryClient
{
    [DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall)]
    private static extern int add(int a, int b);

    public int Add(int a, int b)
    {
        return add(a, b);
    }
}

Zobacz teraz, co się dzieje. W linijce 3 i 4 używamy mechanizmu P/Invoke. Mówimy mu: „Hej, używam metody add z biblioteki CppDll.dll z konwencją StdCall„. Mechanizm P/Invoke wie jakiej funkcji szukać, ponieważ taką nazwę przekazałeś w linijce 4. Oczywiście jest też parametr atrybutu DllImportEntryPoint, w którym możesz przekazać inną nazwę funkcji.

Tutaj nie ma żadnego problemu. Nie ma też marshalingu. Dlatego, że typ int jest tzw. typem kopiowalnym (blittable type). O tym za chwilę.

Co się dzieje:

  • P/Invoke ładuje bibliotekę CppDll.dll do pamięci
  • P/Invoke szuka adresu funkcji o nazwie add
  • P/Invoke kopiuje na jej stos parametry b, a
  • P/Invoke uruchamia funkcję
  • Po stronie C++ parametry są zdejmowane ze stosu, robione na nich obliczenia, następnie na stos jest zwracany wynik. C++ zwalnia pamięć używaną przez parametry po swojej stronie.
  • P/Invoke zdejmuje ze stosu wynik funkcji i zwraca go w odpowiedniej postaci: int.

Proste? Proste 🙂

Typy kopiowalne i niekopiowalne

Część typów danych ma wspólną reprezentację zarówno po stronie zarządzanej jak i niezarządzanej. Przykładowo typ int zarówno po stronie C++ jak i C# ma 4 bajty, a te 4 bajty są ułożone w tej samej kolejności. W związku z tym bez problemu można sobie je „kopiować”. Bo znaczą dokładnie to samo. Te typy są kopiowalne. Nie wymagają żadnej konwersji pomiędzy dwoma światami. Należą do nich:

  • System.Byte
  • System.SByte
  • System.Int16
  • System.Int32
  • System.UInt32
  • System.Int64
  • System.UInt64
  • System.IntPtr
  • System.UIntPtr
  • System.Single
  • System.Double
  • Jednowymiarowe statyczne tablice typów kopiowalnych
  • Typy wartościowe składające się z typów kopiowalnych

Teraz pytanie o typy niekopiowalne. One wymagają marshalingu. Czyli jakiejś formy konwersji.

Ważne jest, że referencje do obiektów nie są kopiowalne. Możesz mieć kopiowalną strukturę, ale jeśli masz tablicę referencji do obiektów tej struktury, to ten obiekt nie jest już kopiowalny.

Przesyłanie stringa

Trochę gorzej ze stringami. To jest typ niekopiowalny. Co więcej jest traktowany w specjalny sposób. Zanim w nie wejdziemy, muszę odpowiedzieć na pytanie – czym tak naprawdę jest string?

Czym jest string?

String to tablica znaków zakończona znakiem ASCII #0 (null) lub dwoma takimi znakami w przypadku unicode. W przypadku stringu ANSI na jeden znak przypada jeden bajt. Jednak w przypadku unicode jeden znak jest już kodowany dwoma bajtami (jeśli znak jest typowym znakiem z zakresu ASCII, wtedy drugi bajt ma wartość 0x00). A w niektórych przypadkach – nawet czterema (języki głównie azjatyckie). Spójrz na fragment pamięci z kodu w C++:

To jest najbardziej prawdziwy string. Znaki 0x00 na końcu są potrzebne do tego, żeby komputer wiedział, gdzie ten string się kończy. On nie ma informacji o długości ani o niczym innym. To jest po prostu tablica pojedynczych znaków.

A teraz spójrz na string w C#:

A co tu się dzieje? Co to za awantury?

No właśnie, string w .NET to nie jest prawdziwy string. To jest jakaś klasa, która posiada więcej informacji (chociażby długość stringa). Co więcej, wcale nie musi się kończyć dwoma nullami. Zatem widzisz, że to jest jakaś większa klasa. Coś jak std::wstring w C++ – to jest klasa. Dlatego też, jeśli w parametrze eksportowanej funkcji C++ jest std::string lub std::wstring, taka funkcja nie nadaje się do użytku w .NET (ogólnie poza kodem pisanym w C++ w odpowiedniej wersji biblioteki).

Przesyłanie stringa do C++

Na szczęście przesyłanie stringa do C++ jest banalnie proste.

Od strony C++ wystarczy go przechwycić jako wskaźnik na tablicę znaków:

DLL_EXPORT size_t getStrLen(const wchar_t* pStr);

A od strony C# po prostu wysyłamy go jako zwykły string:

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode)]
private static extern int getStrLen(string str);

Co ważne, zwróć uwagę, że przekazujemy tutaj CharSet. Jeśli tego nie zrobisz, domyślnie będzie użyty ANSI.

Zwróć uwagę, że do C++ jest w takim wypadku przesyłany dokładnie ten string (dokładnie to miejsce w pamięci na zarządzanej stercie, gdzie zaczyna się tekst).

I teraz bardzo ważne jest, żeby kod C++ używał tego stringa tylko do odczytu. Jeśli w jakiś sposób spróbuje go zmienić, najpewniej skończy się to uszkodzeniem sterty.

Jednak, jeśli po stronie C++ odbieramy ANSI string (char *), wtedy string z C# jest kopiowany do dodatkowego bufora (i konwertowany) i to to miejsce jest przekazywane do C++. Gdy wracamy z funkcji, zawartość tego bufora jest ponownie kopiowana do stringa w C#. Więc jeśli masz dużo takich wywołań, możesz mieć problem z wydajnością. Wtedy spróbuj wysyłać tablicę bajtów zamiast stringa i sprawdź, co się stanie.

Jeśli jednak masz taką opcję, to posłuż się BSTR – o którym piszę niżej.

Pobieranie string z C++

Tutaj jest trochę trudniej. Dla takiego stringa trzeba zaalokować pamięć na stercie i odpowiednio go przekonwertować. Jest kilka sposobów na pobranie stringa z C++. Wszystkie mają swoje plusy i minusy.

Pobieranie jako rezultat

Pobierz i zwolnij

Po stronie C++ musisz skopiować string do miejsca, w którym będzie rezultat.

DLL_EXPORT wchar_t* getInfo()
{
	return _wcsdup(L"Jestem wywołany z C++!"); //C++ tworzy kopię tego stringa i zwraca ją
}

W związku z tym, że C++ utworzył nowy obiekt na stercie, musi też go zwolnić w odpowiednim momencie. Dlatego też biblioteka powinna dać funkcję, która zwolni zaalokowaną pamięć, np.:

DLL_EXPORT void freeInfo(wchar_t* pData)
{
	delete pData;
}

Teraz po stronie C# musisz pobrać ten string jako wskaźnik (w końcu wchar_t * jest wskaźnikiem), potem użyć marshalingu, żeby skonwertować go na string .NETowy, na koniec musisz zwolnić pobrany wskaźnik:

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode)]
private static extern IntPtr getInfo();

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall)]
private static extern void freeInfo(IntPtr pData);

//

public string GetInfo()
{
    var ptr = getInfo();
    var result = Marshal.PtrToStringUni(ptr);
    freeInfo(ptr);
    return result;
}

Marshal.PtrToStringUni traktuje dane, na które wskazuje ptr jak string w Unicode. Czyli szuka jego końca (2 znaki null), kopiuje go na stertę zarządzaną i zwraca w postaci .NETowego stringa. Pobrany wskaźnik cały czas wskazuje na miejsce na stercie niezarządzanej, więc trzeba go zwolnić. Dlatego też wywołujemy metodę freeInfo z C++.

Pamiętaj, że za zwolnienie pamięci jest odpowiedzialna biblioteka, która tę pamięć zaalokowała. W tym przypadku C# używa innej biblioteki do zarządzania pamięcią niż C++. Dlatego też to po stronie C++ pamięć musi zostać zwolniona.

Pobieranie bez zwalniania

Jest jednak mechanizm, który zadziała nieco inaczej. Prościej dla programisty C#, jednak od strony C++ wymaga nieco więcej zachodu. To wykorzystuje technologię COM. Nie wchodząc w szczegóły – C# używa tej samej biblioteki do zwolnienia pamięci, co C++ do jej alokacji. Dlatego też w tym przypadku C# może być odpowiedzialny za zwolnienie tej pamięci i tak też się dzieje (zupełnie automatycznie). Od strony C++ wygląda to tak:

DLL_EXPORT wchar_t* __stdcall getInfoWithCom()
{
	std::wstring str = L"Cześć, jestem z COM";
	int allocSize = str.size() * 2 + 2;
	STRSAFE_LPWSTR result = (STRSAFE_LPWSTR)CoTaskMemAlloc(allocSize);
	StringCchCopy(result, allocSize, str.c_str());

	return (wchar_t*)result;
}

Najpierw liczymy, ile pamięci musimy zaalokować. Pamiętaj, że jeden znak stringa unicodowego zajmuje dwa bajty. Dlatego też ilość znaków w string str mnożymy przez dwa. Następnie trzeba dodać jeszcze dwa znaki na zakończenie stringa (to będą te dwa nulle na końcu).

Pamiętaj, że w niektórych przypadkach jeden znak może potrzebować aż 4 bajtów. Jeśli wiesz, że Twoja aplikacja będzie działała na rynku azjatyckim i nie znasz długości stringa, dla jakiego chcesz zaalokować pamięć, to lepiej mnóż przez 4.

Następnie dzieje się magia. Używamy funkcji CoTaskMemAlloc do zaalokowania odpowiedniej ilości pamięci. C# pod spodem użyje CoTaskMemFree do zwolnienia tej pamięci. To jest ta sama biblioteka.

Na koniec kopiujemy string str do zaalokowanej pamięci i zwracamy go.

A po stronie C#? Możemy to odebrać jako zwykły string:

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode)]
private static extern string getInfoWithCom();

//

public string GetInfoWithCom()
{
    return getInfoWithCom();
}

Plusy – to widać po stronie C#. Mniej kodu i programiści nie muszą zajmować się pamięcią. Minusy – to widać po stronie C++. Kod musi być odpowiednio napisany, żeby dało się tak zrobić.

Pobieranie z użyciem BSTR

BSTR to specjalna forma stringa, która składa się z trzech części:

  • długość stringu
  • tekst
  • znaki kończące (null)

Zwróć uwagę, że sam BSTR wskazuje na początek tekstu, a nie na 4 wcześniejsze bajty, w których jest zaszyta długość stringu. BSTR jest używany w mechanizmie COM i jest naturalny dla P/Invoke. Jego użycie jest bardzo podobne do tego, co było wyżej, jednak jest prostsze (od strony C++) i zwraca inny typ:

DLL_EXPORT BSTR __stdcall getInfoWithBstr()
{
	return SysAllocString(L"A tak działa BSTR");
}

A w C#:

[DllImport("CppDll.dll", CallingConvention=CallingConvention.StdCall, CharSet = CharSet.Unicode)]
[return:MarshalAs(UnmanagedType.BStr)]
private static extern string getInfoWithBstr();

//

public string GetInfoWithBstr()
{
    return getInfoWithBstr();
}

Tutaj jest istotne, żeby oznaczyć typ zwracany odpowiednim Marshalerem. W tym przypadku BStr. Dzięki temu to C# może zatroszczyć się o zwolnienie pamięci zarezerwowanej przez C++. Ta rezerwacja i zwalnianie też dzieje się przez mechanizm COM.

Pobieranie w parametrze

String można pobierać również przez parametr. W internetach (m.in. Stack Overflow) często widać taki kod jak poniżej. Jest on prosty, ale ma swoje problemy:

Użycie StringBuilder

Po stronie C++ mamy taki kod:

DLL_EXPORT void getInfo2(wchar_t* pData, int strLen)
{
	ZeroMemory(pData, strLen * 2);
	std::wstring result = L"Można i tak...";
	result = result.substr(0, strLen);
	std::copy(result.begin(), result.end(), pData);
}

W parametrze pData przekazujemy gotowe miejsce w pamięci do przyjęcia stringu. Jak duże? O tym mówi parametr strLen. Zarówno miejsce, jak i strLen jest przekazywane przez kod C#. Tylko trzeba pamiętać o dwóch rzeczach, o których powiem jeszcze przy okazji kodu C#.

Musisz przekazać strLen wystarczająco duży, aby pomieścił string, który będzie zwracany. No i parametr strLen musi też wziąć pod uwagę dwa dodatkowe bajty na zakończenie stringa (2 znaki null). W innym przypadku dostaniesz jakieś śmieci. A teraz, co się dzieje w kodzie?

W pierwszej kolejności zerujemy tę pamięć. Chodzi o to, żeby mieć pewność, że te dwa ostatnie znaki (zakończenie stringa) będą nullami. Tak, można by zerować tylko te dwa ostatnie bajty, ale tak jest mi prościej i łatwiej tłumaczyć 🙂

Potem kopiujemy do jakiejś zmiennej stringa – w tym przypadku, jeśli podamy ilość znaków większą niż string faktycznie zawiera, string będzie skopiowany do końca.

Na koniec kopiujemy go w przekazane w parametrze miejsce.

A jak to wygląda od strony C#?

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, EntryPoint = "getInfo2", CharSet = CharSet.Unicode)]
private static extern void getInfo(StringBuilder data, int length);

//

public string GetInfoWithStrBuilder()
{
    var sb = new StringBuilder(255);
    getInfo(sb, sb.Capacity);

    return sb.ToString();
}

No i tutaj sprawa jest prosta. Najpierw tworzymy StringBuildera odpowiednie dużego, żeby mógł przetrzymać zwracany string. Pamiętamy też o dwóch znakach kończących stringa. Następnie wywołujemy funkcję z tymi parametrami.

Jak dużo pamięci?

Teraz pojawia się pytanie – skąd mam wiedzieć, ile pamięci mam zarezerwować na ten string? Skąd mam znać jego długość?

No to jest odwieczne pytanie programistów C++. Dlatego też powstało kilka mechanizmów, które rozwiązują ten problem. Oczywiście trzeba je samemu zaprogramować.

Pierwszy sposób jest taki, że funkcja C++ getInfo zwraca wartość bool, która mówi, czy string się skończył, czy nie. Wtedy tę funkcję wywołujemy w pętli, przekazując od którego znaku ma być czytany string. Często ten mechanizm zwraca stałą ilość znaków (lub mniej, jeśli już nie ma).

Drugi sposób to taki, gdzie wywołujemy funkcję getInfo z parametrem pierwszym (miejsce w pamięci) ustawionym na null – C++ widzi, że to miejsce nie jest zaalokowane (bo jest nullem) i zwraca długość stringa, jaki wyprodukuje. Wtedy w C# ustawiamy odpowiednio duży bufor. To może wyglądać tak:

DLL_EXPORT int __stdcall getInfo3(wchar_t* pData, int strLen)
{
	std::wstring str = L"A to jest string o nieznanej długości";

	if (pData == nullptr)
		return str.size();

	ZeroMemory(pData, strLen * 2);
	str = str.substr(0, strLen);
	std::copy(str.begin(), str.end(), pData);
	return 0;
}

A po stronie C#:

public string GetInfoWithLen()
{
    int len = getInfoWithLen(null, 0);
    var sb = new StringBuilder(len + 2);
    getInfoWithLen(sb, sb.Capacity);

    return sb.ToString();
}

Tak naprawdę wystarczyłoby dodanie 1 znaku w zaznaczonej wyżej linii zamiast dwóch. Jeden znak w unicode to dwa bajty i o te dwa bajty nam chodzi. Dodałem specjalnie 2, żeby wbić do głowy te dwa bajty kończące string unicodowy.

Problemy

Minusem rozwiązania ze string builderem jest ilość alokacji na stercie. To jest stosunkowo wolna operacja, więc jeśli masz duże stringi i robisz to często, to raczej zauważysz problemy z wydajnością:

  • tworzenie StringBuildera alokuje pamięć na stercie zarządzanej
  • wywołanie funkcji:
    • alokuje bufor na stercie niezarządzanej
    • kopiuje zawartość StringBuildera do sterty niezarządzanej
    • tworzy zarządzaną tablicę i tam kopiuje zawartość natywnego stringa
  • StringBuffer.ToString() tworzy kolejną zarządzaną tablicę.

Także podczas tej operacji mamy 4 alokacje, których można by uniknąć. Dlatego tak mocno powielany sposób ze StringBuilderem uważam za zły. Są inne – lepsze.

Użycie tablicy

Zamiast StringBuildera, możesz użyć tablicy znaków – pamiętając, że string to nic innego niż tablica znaków. Nie ma tutaj za wiele do omówienie, po prostu pokażę kod:

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, EntryPoint = "getInfo3", CharSet = CharSet.Unicode)]
private static extern int getInfoWithArray([Out] char[] data, int length);

//

public string GetInfoWithArray()
{
    int len = getInfoWithArray(null, 0);
    char[] buffer = ArrayPool<char>.Shared.Rent(len + 1);
    getInfoWithArray(buffer, buffer.Length);

    return new string(buffer);
}

Tutaj nie ma już tylu alokacji co przy StringBuilderze, ten kod jest zdecydowanie lepszej jakości.

Użycie BSTR

Już pisałem wcześniej o użyciu BSTR. Mając wiedzę z całego artykułu, możesz na spokojnie wywnioskować taki kod:

DLL_EXPORT void __stdcall getInfo4(BSTR& data)
{
	data = SysAllocString(L"Dzień dobry, jak się masz?");
}

A po stronie C#:

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, EntryPoint = "getInfo4")]
private static extern void getInfo([MarshalAs(UnmanagedType.BStr)] ref string data);

//

public string GetInfoFromBstr()
{
    string result = "";
    getInfo(ref result);

    return result;
}

To jest najprostszy i najlepszy sposób pobierania stringa z parametru. Oczywiście nie zawsze biblioteka natywna będzie do tego przygotowana, ale jeśli możesz iść w tym kierunku, to idź. W taki sam sposób możesz przekazać stringa.


Ufff, to na tyle jeśli chodzi o pierwszą część artykułu o połączeniu C++ z C#. W drugiej części zajmiemy się tablicami.

Jeśli znalazłeś w artykule błąd lub czegoś nie zrozumiałeś, koniecznie daj znać w komentarzu. Dzięki za przeczytanie, koniecznie dodaj go do ulubionych, bo kiedyś może Ci się przydać. I do następnego! 🙂

Podziel się artykułem na: