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.
Spis treści cyklu
- Podstawy i stringi
- Struktury
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:
- Szuka biblioteki DLL, która zawiera wymaganą funkcję
- Ładuje bibliotekę do pamięci (tylko przy pierwszym wywołaniu)
- Znajduje adres w pamięci, w którym zaczyna się wywoływana funkcja
- Kładzie (push) argumenty na stosie, robiąc marshaling (marshaling tylko przy pierwszym wywołaniu)
- Uruchamia funkcję znajdującą się pod znalezionym adresem.
- 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 DllImport
– EntryPoint
, 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ęciP/Invoke
szuka adresu funkcji o nazwieadd
P/Invoke
kopiuje na jej stos parametryb, 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ę strukturami.
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! 🙂