PInvoke

Adam Boduch

NET jest nową platformą programistyczną. Upłynie jeszcze sporo czasu zanim programiści przystosują swoje aplikacje do nowej platformy oraz obdarzą ją zaufaniem. Mimo tego, iż .NET udostępnia dziesiątki klas umożliwiających łatwiejsze programowanie, w Delphi 8 nadal będziesz zapewne, nieraz korzystał ze standardowych funkcji Win32. Wystarczy spojrzeć na zawartość modułu Borland.Vcl.Windows.pas, który co prawda został zmodyfikowany w celu dostosowania do .NET, ale nadal zawiera funkcje znane nam ze wcześniejszych wersji Delphi:

[...]
[DllImport(kernel32, CharSet = CharSet.Auto, SetLastError = True, EntryPoint = 'GetWindowsDirectory')]
function GetWindowsDirectory; external;
[DllImport(advapi32, CharSet = CharSet.Auto, SetLastError = True, EntryPoint = 'GetUserName')]
function GetUserName; external;
[...]

Jak widzisz zmienił się nieco sposób ich importu w aplikacji. W wielu przypadkach użycie funkcji Win32 stanie się wręcz niezastąpione tak więc w tej części zajmiemy się wykorzystaniem bibliotek Win32 DLL w aplikacjach .NET.

1 Wywołanie tradycyjne
2 Użycie atrybutu DLLImport
3 Parametry wyjściowe
4 Dane wskaźnikowe
5 Pobieranie danych z bufora
6 Używanie funkcji Win32
7 Marshaling
     7.1 Atrybuty [in] oraz [out]
     7.2 Przekazywanie struktur
     7.3 Inne atrybuty marshalingu
     7.4 Wskaźnik na strukturę
          7.4.1 Program korzystający z biblioteki Win32
     7.5 Przekazywanie struktur
8 Wady PInvoke

W tym celu będziemy korzystać z mechanizmu zwanego Platform Invocation Service, czyli Platform Invoke, zwanego w skrócie PInvoke (lub P/Invoke). Mechanizm ów pozwala na importowanie funkcji z bibliotek Win32 DLL przy pomocy atrybutu [DllImport]. Mechanizm P/Invoke nie odpowiada jedynie za wywołanie i wykonanie kodu z bibliotek niezarządzanych, lecz również za poprawną współpracę aplikacji Win32 oraz .NET.

W przypadku Delphi sprawa jest nieco ułatwiona, gdyż programiści Borland sami zadbali o modyfikację modułu Windows tak, aby można było korzystać przy jego pomocy, z funkcji Win32.

Mówiąc o module Windows mam w rzeczywistości na myśli moduł Borland.Vcl.Windows. Dla Delphi nie ma to różnicy, czy do listy Uses dodany zostanie moduł Windows, czy Borland.Vcl.Windows. Ułatwia to proces przenoszenia projektu ze wcześniejszych wersji Delphi.

Wywołanie tradycyjne

Jeżeli chodzi o importowanie procedur z bibliotek DLL, to w Delphi nie uległo to zmianie. Nadal istnieje słowo kluczowe external, które umożliwia załadowanie procedury w sposób statyczny. Poniższy fragment kodu to biblioteka DLL skompilowana w wersji Delphi dla Win32:

library SimpleDLL;

uses
  Windows;

procedure About; stdcall;
begin
  MessageBox(0, 'Hello World!', 'Hello', MB_OK + MB_ICONINFORMATION);
end;

exports
  About name 'About';

begin
end.

Jest to bardzo prosta biblioteka zawierająca jedną funkcję About, wyświetlającą tekst Hello World. Chcąc użyć tej funkcji, z biblioteki DLL, w programie pisanym w Delphi 8, możemy zastosować poniższy kod:

program DotNet;

procedure About; stdcall external 'SimpleDLL.dll' name 'About';

begin
  About;
end.

Taki kod działa dobrze, a wywołanie funkcji jest proste w użyciu ze względu na brak parametrów łańcuchowych. Ponieważ wiele funkcji API (a także występujących zwykłych bibliotekach napisanych dla systemu Win32) korzystało ze wskaźników i typów PChar, które są niedozwolone w .NET pojawia się więc problem konwersji typów, tak, aby można było skompilować kod w Delphi 8.

Poprzedni przykład importujący funkcję DLL można zapisać nieco inaczej, z wykorzystaniem atrybutów:

program DotNet;

{$APPTYPE CONSOLE}

uses Windows, System.Runtime.InteropServices;

[DllImport('SimpleDLL.dll')]
procedure About; external;     

begin
  About;
end.

Użycie atrybutu DLLImport

Przed skorzystaniem z atrybutu DllImport musisz dodać moduł System.Runtime.InteropServices do listy Uses. Użycie tego atrybutu, w najprostszym wydaniu prezentuje poprzedni przykład. Budowa jest dość prosta, gdyż w takim wypadku należy podać jedynie nazwę biblioteki DLL:

[DllImport('Nazwa biblioteki DLL')];

W takim przypadku atrybut nie posiada żadnych dodatkowych parametrów. Normalnie możliwe jest określenie konwencji wywołania parametrów (Cdecl, Stdcall itp.), nazwy ładowanej procedury bądź funkcji oraz kodowania łańcuchów (unikod, ANSI String). Parametry atrybutu DllImport można określać w ten sposób:

[DllImport('SimpleDLL.dll', CallingConvention = CallingConvention.Stdcall, EntryPoint='About')]

W powyższym przykładzie określiliśmy sposób wywołania parametrów funkcji (parametr CallingConvention) oraz określiliśmy dokładnie nazwę importowanej funkcji (EntryPoint).

Istnieje jeszcze jeden parametr używany tylko w przypadku, gdy w parametrze funkcji lub procedury znajduje się łańcuch (String). Tym parametrem jest CharSet, który określa kodowanie:

  • Ansi ? łańcuchy ANSI;
  • Unicode ? łańcuchy Unikodu;
  • None ? oznacza to samo, co parametr Ansi;
    W przypadku Win32 API wiele funkcji posiadało dwie odmiany ? jedną z parametrem typu PAnsiChar, a drugą ? z PWideChar. Dla przypomnienia: wszystkie łańcuchy w .NET są unikodem, także typ String jest teraz równoważny z typem WideString.

Jeżeli więc importujemy funkcję z biblioteki, która posiada parametry AnsiString, możemy to zapisać w ten sposób:

[DllImport('nazwa.dll', CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
function DoSomethingA(const Msg: String): Boolean;
external;

Parametry wyjściowe

Windows był pisany w języku C, stąd też przykłady w dokumentacji WinAPI zapisane są w tym języku. Wiele funkcji zwracało rezultaty do parametru ? warto tu wspomnieć chociażby o funkcji GetWindowsDirectory, czy GetSystemDirectory. Ich deklaracja w C wyglądała następująco:

UINT GetWindowsDirectory(
    LPTSTR lpBuffer,	// address of buffer for Windows directory 
    UINT uSize 	// size of directory buffer 
   );
UINT GetSystemDirectory(
    LPTSTR lpBuffer,	// address of buffer for system directory 
    UINT uSize 	// size of directory buffer 
   );

Przekształcając ten zapis na język Delphi, w środowisku Win32, owe funkcje były zadeklarowane następująco:

function GetWindowsDirectory(lpBuffer: PChar; uSize: UINT): UINT; stdcall;
function GetSystemDirectory(lpBuffer: PChar; uSize: UINT): UINT; stdcall;

Niestety w .NET (Delphi 8) nie możemy użyć typu PChar, tak więc zmieniono zapis tych funkcji, w module Windows na następujący:

function GetWindowsDirectory (lpBuffer: StringBuilder; uSize: Cardinal): BOOL;
function GetSystemDirectory (lpBuffer: StringBuilder; uSize: Cardinal): BOOL;

Jak widzisz, typ PChar został tutaj zastąpiony klasą StringBuilder, co może sprawić trudności w użyciu tej funkcji. Poniższy listing prezentuje sposób na pobranie ścieżki katalogu Windows oraz katalogu systemowego w Delphi 8.

program Listing_7;

{$APPTYPE CONSOLE}

uses
  Windows,
  System.Text,
  System.Runtime.InteropServices;

var
  Buffer : StringBuilder;
begin
  Buffer := StringBuilder.Create(128);

  GetWindowsDirectory(Buffer, Buffer.Capacity);
  Writeln(Buffer.ToString); // wyświetlenie scieżki

  GetSystemDirectory(Buffer, Buffer.Capacity);
  Writeln(Buffer.ToString);
  
  Buffer.Free;
  Readln;
end.

Drugim parametrem funkcji GetWindowsDirectory oraz GetSystemDirectory musi być liczba określająca długość buforu. Właściwość Capacity zwraca wielkość buforu z klasy StringBuilder (w tym wypadku liczbę 128).

Funkcje GetWindowsDirectory i GetSystemDirectory zostały zachowane ze względów kompatybilności (podobnie jak inne podobne funkcje tego typu). Nastąpiła pewna zmiana jeśli chodzi o ich użycie dlatego pokazałem jak przekształcić swoje programy, tak, aby nadal mogły korzystać ze starych sprawdzonych funkcji. Na przyszłość prostsze okaże się zastosowanie klasy Environment, która posiada funkcje zwracające ścieżkę systemu, nazwę zalogowanego użytkownika systemu itp. Przykładowo odczyt ścieżki systemowej realizuje taki kod:

Console.WriteLine(Environment.get_SystemDirectory);

Zacznijmy jednak od początku. Klasa StringBuilder służy w dużym uogólnieniu do manipulowania danymi tekstowymi. Jako, że jest to klasa (podobnie jak String), na początku wywołujemy jej konstruktor podając jako parametr maksymalną długość tekstu, jaki może pomieścić łańcuch.

Dane wskaźnikowe

Wskaźniki zostały uznane w .NET za typ niebezpieczny, lecz istnieje sposób na zastąpienie typowych wskaźników innymi rozwiązaniami takimi jak typ IntPtr oraz klasą Marshal (z podzespołu System.Runtime.InteropServices) służąca do zarządzania, alokacji i kopiowania bloków pamięci. W tym miejscu można się posłużyć przykładem wysyłania komunikatów, gdzie czasami konieczne było użycie operatora @ przekazując parametr do funkcji SendMessage, PostMessage. Posłużę się przykładem wysyłania komunikatu WM_SETTEXT. W Win32 należałoby skorzystać z takiego kodu:

var
  hWindow : HWND;
begin
  hWindow := FindWindow(nil, 'Program testowy');
  SendMessage(hWindow, WM_SETTEXT, 0, Longint(PChar('Nowy tekst')));
end;

Mimo, że nie było konieczne użycie operatora @, to musieliśmy użyć niejako typu wskaźnikowego PChar. W .NET kod realizujący to samo zadanie będzie wyglądał tak:

uses System.Runtime.InteropServices;

procedure TForm1.Button1Click(Sender: TObject);
var
  hWindow : HWND;
  Buffer : IntPtr;
begin
  Buffer := Marshal.StringToHGlobalAuto('Nowy tekst');
  hWindow := FindWindow(nil, 'Program testowy');
  SendMessage(hWindow, WM_SETTEXT, 0, Buffer.ToInt32);

  Marshal.FreeHGlobal(Buffer);
end;

Dwie zmienne zadeklarowane w tej procedurze odpowiadają za przechowanie uchwytu do okna, do którego zostanie skierowany komunikat (hWindow) oraz jako wskaźnik (Buffer) typu IntPtr.

Na samym początku korzystając z klasy Marshal umieszczamy w pamięci nowe dane, które zostaną przekazane jako parametr komunikatu ? w tym przypadku jest to napis Nowy tekst. Po odnalezieniu uchwytu okna wysyłamy komunikat WM_SETTEXT, jako drugi parametr podając wartość Buffer.ToInt32. Taka kombinacja jest konieczna z tego względu, iż drugi parametr w funkcji SendMessage musi być typu Integer, a nie IntPtr, lecz sposób na ominięcie tego jest prosty ? funkcja ToInt32 z klasy IntPtr konwertuje dane na liczbę 32-bitową typu Integer.

Pobieranie danych z bufora

Poprzedni przykład bazował na wysyłaniu komunikatu WM_SETTEXT. Sprawdźmy teraz jak ma się sprawa z odbieraniem danych korzystając z klasy Marshal oraz typu IntPtr. Do wybranego okna wyślemy komunikat WM_GETTEXT nakazujący zwrócenie tytułu okna. W tym celu korzystając z klasy Marshal konieczne będzie zadeklarowanie 128 bajtów pamięci na potrzeby naszej procedury:

uses System.Runtime.InteropServices;

procedure TForm1.Button1Click(Sender: TObject);
var
  hWindow : HWND;
  Buffer : IntPtr;
begin
  Buffer := Marshal.AllocHGlobal(128);
  hWindow := FindWindow(nil, 'Program testowy');
  SendMessage(hWindow, WM_GETTEXT, 128, Buffer.ToInt32);

  MessageBox(0, Marshal.PtrToStringAuto(Buffer), '', 0);

  Marshal.FreeHGlobal(Buffer);
end;

Alokacją pamięci zajmuje się procedura AllocHGlobal z klasy Marshal, w której jako parametr należy podać liczbę, określającą ilość bajtów do alokacji. W naszym przypadku zaalokowaliśmy 128 bajtów pamięci zakładając, że tytuł okna nie przekroczy tej długości.

Po wysłaniu komunikatu tytuł okna powinien zostać umieszczony w strukturze Buffer. Odkodowaniem otrzymanej "paczki" zajmuje się funkcja PtrToStringAuto, która zwraca dane w postaci typu String.

Oczywiście podany przeze mnie przykład jest trochę bez sensu, bo skoro znamy tytuł okna (bo znamy używając go w funkcji FindWindow) niepotrzebnie wysyłamy komunikat WM_GETTEXT. Jednak sama idea odczytywania danych z komunikatów się nie zmienia, przykład ten można więc uznać za wartościowy.

Jako ciekawostkę, mogę powiedzieć, że taki sam rezultat można uzyskać stosując funkcję GetWindowText oraz klasę StringBuilder:

uses System.Runtime.InteropServices, System.Text;

procedure TForm1.Button1Click(Sender: TObject);
var
  hWindow : HWND;
  Buffer : StringBuilder;
begin
  Buffer := StringBuilder.Create(128);
  hWindow := FindWindow(nil, 'Program testowy');
  GetWindowText(hWindow, Buffer, 128);

  MessageBox(0, Buffer.ToString, '', 0);

  Buffer.Free;
end; 

Funkcja GetWindowText jest standardową funkcją Win32 API (zadeklarowaną w module Windows.pas). Dla przypomnienia powiem, iż, aby skorzystać z klasy StringBuilder, należy do listy Uses dodać przestrzeń nazw System.Text.

Używanie funkcji Win32

Importując do aplikacji funkcje z bibliotek Win32, należy dokonać pewnych zmian, co związane jest z obecnością wskaźników czy innych niedozwolonych danych w parametrach procedur i funkcji. W niektórych przypadkach zmiany nie będą konieczne lub będą minimalne ? najczęściej wiążą się z zastąpieniem typu PChar, klasą StringBuilder lub wskaźników typem IntPtr. Poniższa tabela prezentuje rodzaje parametrów, które występują w Win32 oraz w .NET. Pierwsza kolumna zawiera rodzaje typów, które nie są obsługiwane w .NET; dwie kolejne kolumny to typy .NET, którymi należy zastąpić nieobsługiwane już rodzaje danych. Należy zwrócić uwagę na parametry wejściowe oraz wyjściowe. Mam tu na myśli parametry przekazywane np. przez wartość lub prze referencję.

Kod niezarządzanyZarządzany
Parametr wejściowyParametr wyjściowy
Typ PChar</th>StringStringBuilder
Wskaźnik na strukturę (PRect)</th>const TRect;var TRect;
Wskaźnik na typ prosty (PByte)</th>const Byte;var Byte;
Wskaźnik na typ wskaźnikowy (^PInteger)</th>IntPtrIntPtr

Dla przykładu: w bibliotece DLL, skompilowanej w Win32 znajduje się taka procedura:

  procedure SendChar(var lpPChar : PChar); stdcall;
  begin
    lpPChar := 'Ala ma kota';
  end;

Parametrem owej procedury jest lpPChar typu PChar. Teraz w aplikacji .NET typ PChar należy zastąpić typem StringBuilder. Co prawda Borland zaleca w takich sytuacjach użycie typu StringBuilder, ale zwykły String także zadziała:

[DLLImport('Win32DLL.dll', EntryPoint = 'SendChar', CallingConvention = CallingConvention.Stdcall)]
  procedure SendChar(var lpPChar : String); external;

var
  S : String;
begin
  SendChar(S);
  Console.WriteLine(S); // wyświetl wartość otrzymaną z DLL'a
  Console.ReadLine;
end.

Marshaling

Jak niedługo zobaczysz na przykładach, istotą marshalingu jest dokładne określenie z jakim rodzajem danych mamy do czynienia. Dzięki temu dajemy kompilatorowi do zrozumienia jak ma traktować dane w pamięci. Podstawowym narzędziem określania danych, są atrybuty.

Atrybuty są nowym elementem, który pojawił się w .NET (czyli zarówno w Delphi, VB.NET, C# i innych językach).

Atrybuty [in] oraz [out]

Atrybuty [in] oraz [out] zadeklarowane są w przestrzeni nazw: System.Runtime.InteropServices i stanowią informację o rodzaju przekazania parametru (przez wartość czy przez referencję). Atrybuty [in] oraz [out] są opcjonalne ? stanowią jednak dodatkową informację o rodzajach parametru. Atrybuty należy wstawić tuż przed deklaracją rzeczywistego parametru:

procedure SendChar([out] var lpPChar : String); external;

Atrybut [in] mówi, iż wartość danego parametru przekazywana jest przez wartość lub stałą. Atrybut [out] informuje o przekazaniu parametru przez referencję oraz iż oczekuje się zwrócenia danych przez wywoływaną funkcję.

Przekazywanie struktur

Największe różnice przynosi przenoszenie struktur (czyli np. rekordów) z kodu niezarządzanego do zarządzanego i odwrotnie (przekazywanie rekordów do procedur z kodu niezarządzanego). Jeżeli np. rekord zawiera tablice, typy PChar to konieczne będzie przekształcenie owych typów na dane akceptowane przez kompilator Delphi 8. To nie wszystko ? potrzebna będzie także usługa marshalingu w celu szczegółowego określenia zawartości rekordu.
Spójrz na następujący rekord, znajdujący się w bibliotece DLL, skompilowanej w Delphi 2:

  type
    TWin32Rec = record
      Buffer : array[0..127] of Char;
      lpPChar : PChar;
      Numbers : array[1..2] of Byte;
    end;

Przede wszystkim, biblioteka zawiera parametr Buffer, który jest tablicą znaków (Char). Dodatkowo zawiera pole lpPChar, które jest typu PChar ? je także trzeba zastąpić typem String ? to już wiesz po lekturze poprzednich sekcji. W Delphi 8 taki rekord trzeba będzie przekształcić do takiej postaci:

  type
    TWin32Rec = record
      Buffer : String;
      lpPChar : String;
      Numbers : array[1..2] of Byte;
    end;

Zarówno typ PChar jak i tablicę Char zastąpiliśmy typem String. Uwaga! W rekordach nigdy nie będzie potrzebne użycie typu StringBuilder ? używaj więc zawsze typu String.

Uwaga! Zasada zastąpienia typu PChar przez String nie jest sztywna. Czasami możliwe lub konieczne będzie użycie IntPtr, o czym przekonasz się w dalszej części artykułu.

Jak widzisz, z tego samego rekordu w Delphi 2 oraz Delphi 8, identyczne zostało jedynie pole Numbers, które jest dwu-elementową tablicą typu Byte. Poniższa tabela zawiera typy z kodu niezarządzanego i ich odpowiedniki z kodu zarządzanego, które należy zmienić w polach struktur.

Kod niezarządzanyZarządzany
Parametr wejściowyParametr wyjściowy
Tablica znaków</th>StringString
Dowolna tablica typu liczbowego (array[0..1] of Byte)array[0..1] of Bytearray[0..1] of Byte
Tablica dynamiczna</th>IntPtrIntPtr
Wskaźnik do struktury (np. PRect)IntPtrIntPtr
Wskaźnik do typu prostego (np. PByte)IntPtrIntPtr
Wskaźnik do typu wskaźnikowego (np. ^PInteger)IntPtrIntPtr

Inne atrybuty marshalingu

Oprócz oczywistego tłumaczenia rekordu w Delphi 8, należy użyć także dodatkowych atrybutów określających wcześniejszą budowę rekordu w Win32. Po zastosowaniu atrybutów nasz rekord wygląda następująco:

  type
    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
    TWin32Rec = record
      [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
      Buffer : String;
      [MarshalAs(UnmanagedType.LPStr, SizeConst = 30)]
      lpPChar : String;
      [MarshalAs(UnmanagedType.ByValArray, SizeConst = 2)]
      Numbers : array[1..2] of Byte;
    end;

Pierwszy atrybut ? StructLayout określa warstwę rekordu: położenie elementów oraz kodowania (w tym wypadku ? ANSI). Pierwszy atrybut ? LayoutKind, nie był wcześniej omawiany ? może posiadać następujące wartości:

*LayoutKind.Automatic ? zezwala CLR na porządkowanie elementów rekordu według własnego uznania, tak, aby było to jak najbardziej efektywne.

  • LayoutKind.Sequential ? położenie poszczególnych rekordów musi być identyczne, z tym jak je zadeklarowano.
  • LayoutKind.Explicit ? zaawansowany parametr; daje możliwość samodzielnego określenia konkretnego pola przy pomocy innego atrybutu ? FieldOffset.

Kolejny atrybut ? MarshalAs służy szczegółowemu określeniu konkretnego pola: typu oraz długości. Szczególną rolę odgrywa tu parametr UnmanagedType, który służy szczegółowemu określeniu typu pola w rekordzie. Parametr ten może przybrać wartości, takie jak w tabeli poniżej.

UnmanagedType.LPStrŁańcuchowy typ wskaźnikowy ? np. PChar (typu ANSI) lub wskaźnik na tablicę.
UnmanagedType.LPWStrZnaki typu Unikod lub wskaźnik na tablice.
UnmanagedType.LPTStrWskaźnik na tablicę.
UnmanagedType.ByValTStrTablica znaków (Char), z ograniczoną długością.
UnmanagedType.ByValArrayTablica dowolnego typu.

Oprócz wspomnianego wcześniej parametru UnmanagedType, we wcześniejszym przykładzie użyłem także parametru SizeConst, który deklaruje długość konkretnego pola. Np. zmienna Buffer, w Win32 była tablicą 128 elementową, więc długość zmiennej na pewno nie przekroczy 128 znaków. Nieco kłopotliwe może być tylko określenie długości zmiennej lpPChar. Nie wiemy bowiem dokładnie jaką długość posiada zmienna, a pozostawiając atrybut MarshalAs bez parametru SizeConst, program nie będzie działał zgodnie z oczekiwaniami. Warto więc przyjąć sobie górną granicę jakiej się spodziewamy ? ja nadałem elementowi lpPChar długość 30 znaków.

Listing 1. prezentuje przykładową bibliotekę DLL, która eksportuje rekord TWin32Rec. Listing 2. to aplikacja napisana w Delphi 8 odczytująca wartości eksportowanego rekordu.

Listing 1. Przykładowa biblioteka Win32 DLL

library Win32DLL;

{ kompilowane w Delphi 2 (Win32) }

{ Important note about DLL memory management: ShareMem must be the
  first unit in your library's USES clause AND your project's (select
  View-Project Source) USES clause if your DLL exports any procedures or
  functions that pass strings as parameters or function results. This
  applies to all strings passed to and from your DLL--even those that
  are nested in records and classes. ShareMem is the interface unit to
  the DELPHIMM.DLL shared memory manager, which must be deployed along
  with your DLL. To avoid using DELPHIMM.DLL, pass string information
  using PChar or ShortString parameters. }

uses Windows;

  type
    TWin32Rec = record
      Buffer : array[0..127] of Char;
      lpPChar : PChar;
      Numbers : array[1..2] of Byte;
    end;

  procedure SendBuf(var Win32Rec : TWin32Rec); stdcall;   
  begin
  { eksport rekordu }
    with Win32Rec do
    begin
      Buffer := 'Znaki typu Char';
      lpPChar := 'Łańcuch PChar';
      Numbers[1] := 1;
      Numbers[2] := 2;
    end;
  end;

exports
  SendBuf name 'SendBuf';

begin
end.

Listing 2. Przykładowy program .NET współpracujący z biblioteką DLL

program Listing_2;

{$APPTYPE CONSOLE}

uses
  System.Runtime.InteropServices,
  System.Text;
                 
  type
    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
    TWin32Rec = record
      [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
      Buffer : String;
      [MarshalAs(UnmanagedType.LPStr, SizeConst = 30)]
      lpPChar : String;
      [MarshalAs(UnmanagedType.ByValArray, SizeConst = 2)]
      Numbers : array[1..2] of Byte;
    end;

  [DLLImport('Win32DLL.dll', EntryPoint = 'SendBuf', Charset = CharSet.Ansi, CallingConvention = CallingConvention.Stdcall)]
  procedure SendBuf([out] var Win32Rec : TWin32Rec); external;

var
  Win32Rec : TWin32Rec;

begin
  SendBuf(Win32Rec);
  Console.WriteLine('Tablica Char: ' + Win32Rec.Buffer);
  Console.WriteLine('Zmienna PChar: ' + Win32Rec.lpPChar);
  Console.WriteLine('Tablica Numbers: [' + Convert.ToString(Win32Rec.Numbers[1]) + '][' + Convert.ToString(Win32Rec.Numbers[2]) + ']');
  Console.ReadLine;
end.

Po uruchomieniu programu importuje on funkcję SendBuf, z biblioteki Win32DLL.dll. Funkcja SendBuf wysyła zawartość rekordu do zmiennej Win32Rec, który na końcu wyświetlany zostaje na konsoli.
Myślę, że po tym, co omówiliśmy, budowa aplikacji jak i biblioteki DLL nie powinna być niezrozumiała.

Wskaźnik na strukturę

Do tej pory nie pokazywałem jeszcze rozwiązania problemu parametru procedury, który jest wskaźnikiem na rekord. Rozwiązanie tego problemu zaprezentuje na przykładzie programu odczytującego tag ID3v1, z pliku mp3.

Niegdyś napisałem bibliotekę DLL, która odczytywała informacje na temat pliku mp3 (wykonawca, tytuł itp.). Biblioteka działa znakomicie w środowisku Win32. Teraz, chciałbym ją wykorzystać w programie .NET. Ponieważ biblioteka (listing 3.) eksportowała rekord z informacjami o pliku mp3, w postaci wskaźnika na rekord, nachodzi pytanie: jak zastąpić wskaźnik w programie w Delphi 8? Wbrew pozorom rozwiązanie jest bardzo proste.

Listing 3. Kod źródłowy biblioteki DLL Win32

{
  Copyright (c) 2003 by Adam Boduch
}
library mp3DLL;

uses Windows;


type
{ rekord, który będzie eksportowany do aplikacji }
  TMp3 = packed record
    ID: String[3]; // czy Tag istnieje?
    Title : String[30]; // tytuł
    Artist : String[30]; // wykonawca
    Album : String[30]; // album
    Year : String[4]; // rok wydania
    Comment : String[30]; // komentarz
    Genre : String[30]; // typ - np. POP, Techno, Jazz itp.
  end;
  PMp3 = ^TMp3;



const
{  oto tablica zawierająca typy utworów }
  GenreArray : array[0..79] of ShortString = (
  ('Blues'), ('Classic Rock'), ('Country'), ('Dance'), ('Disco'),
  ('Funk'), ('Grunge'), ('Hip-Hop'), ('Jazz'), ('Metal'), ('New Age'),
  ('Oldies'), ('Other'), ('Pop'), ('R&B'), ('Rap'), ('Reggae'),
  ('Rock'), ('Techno'), ('Industrial'), ('Alternative'), ('Ska'),
  ('Death Metal'), ('Pranks'), ('Soundtrack'), ('Euro-Techno'), ('Ambient'),
  ('Trip-Hop'), ('Vocal'), ('Jazz+Funk'), ('Fusion'), ('Trance'),
  ('Classical'), ('Instrumental'), ('Acid' ), ('House'), ('Game'),
  ('Sound Clip'), ('Gospel'), ('Noise'), ('AlternRock'), ('Bass'),
  ('Soul'), ('Punk'), ('Space'), ('Meditative'), ('Instrumental Pop'),
  ('Instrumental Rock'), ('Ethnic'), ('Gothic'), ('Darkwave'),
  ('Techno-Industrial'), ('Electronic'), ('Pop-Folk'), ('Eurodance'),
  ('Dream'), ('Southern Rock'), ('Comedy'), ('Cult'), ('Gangsta'),
  ('Top 40'), ('Christian Rap'), ('Pop/Funk'), ('Jungle'), ('Native American'),
  ('Cabaret'), ('New Wave'), ('Psychadelic'), ('Rave'), ('Showtunes'),
  ('Trailer'), ('Lo-Fi'), ('Tribal'), ('Acid Punk'), ('Acid Jazz'),
  ('Polka'), ('Retro'), ('Musical'), ('Rock & Roll'), ('Hard Rock')
  );

procedure LoadTag(const lpFileName : PChar; Tag : PMp3); stdcall;
var
  F : File;
  Buffer : array[1..128] of char;
begin
  AssignFile(F, String(lpFileName));
  try
    Reset(F, 1);
    { przesunięcie pozycji na 128 bajt od końca }
    Seek(F, FileSize(F) - 128);
    BlockRead(F, Buffer, 128); // odczytanie zawartości bufora

    { do rekordu przypisz informacje odczytane z pliku mp3 }
    with Tag^ do
    begin
      ID := Copy(Buffer, 1, 3);
      Title := Copy(Buffer, 4, 30);
      Artist := Copy(Buffer, 34, 30);
      Album := Copy(Buffer, 64, 30);
      Year := Copy(Buffer, 94, 4);
      Comment := Copy(Buffer, 98, 30);
      Genre := GenreArray[Ord(Buffer[128])];
    end;
    
  finally
    CloseFile(F);
  end;
end;

exports
  LoadTag name 'LoadTag';

begin
end.

Działanie biblioteki jest w gruncie rzeczy proste. Znając budowę pliku mp3, wiemy gdzie znajdują się informacje odnośnie tagu ? w ostatnich 128 bajtach pliku. Teoretycznie proces odczytu tejże informacji jest następujący:
#Otwieramy plik MP3 na podstawie ścieżki podanej przez użytkownika;
#Przemieszczamy się w pliku na ostatnie 128 bajtów;
#Odczytujemy ostanie 128 bajtów informacji z pliku;
#Rozdzielamy odczytane informacje na poszczególne pola z rekordu;
#Utworzony rekord wraz z zawartością eksportujemy na zewnątrz biblioteki.
Praktyczne rozwiązanie tego problemu znajduje się na listingu 3.

Program korzystający z biblioteki Win32

Program, który napiszemy będzie aplikacją konsolową, wywoływaną z wiersza poleceń. Użytkownik wywołując program będzie musiał, jako parametr ? podać ścieżkę do pliku mp3:

getMp3 C:\mp3.mp3

Odczyt parametru przekazanego do programu możliwy jest dzięki klasie Envrionment:

Args := Environment.GetCommandLineArgs;

Zmienna Args to tablica dynamiczna typu String. Funkcja GetCommandLineArgs zwraca parametry przekazane do programu w formie tablicy. Zmienna Args[1] zawierać będzie pierwszy parametr (w naszym przypadku ? ścieżkę do pliku mp3, który należy odczytać), Args[2] drugi itd.

Struktura rekordu, jaki jest zwracany przez funkcję LoadTag, z biblioteki DLL jest znana:

type
[StructLayout(LayoutKind.Sequential, Pack = 1, Charset = Charset.Ansi)]
TMp3 = packed record
  [MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)]
  ID: String[3];
  [MarshalAs(UnmanagedType.ByValArray, SizeConst = 31)]
  Title : String[30];
  [MarshalAs(UnmanagedType.ByValArray, SizeConst = 31)]
  Artist : String[30];
  [MarshalAs(UnmanagedType.ByValArray, SizeConst = 31)]
  Album : String[30];
  [MarshalAs(UnmanagedType.ByValArray, SizeConst = 5)]
  Year : String[4];
  [MarshalAs(UnmanagedType.ByValArray, SizeConst = 31)]
  Comment : String[30];
  [MarshalAs(UnmanagedType.ByValArray, SizeConst = 31)]
  Genre : String[30];
end;

Oczywiście korzystając z odpowiednich atrybutów musimy dokładnie określić elementy struktury i ich rozmiar.
Nie wspominałem wcześniej o parametrze Pack atrybutu StructLayout. Jest on związany z wyrównaniem pól w rekordzie. W Win32 wyrównanie pól w rekordzie spoczywało na kompilatorze, który kierował się szybkością działania. Rozmiar rekordu równy był rozmiarowi pól rekordu, które dodatkowo były zaokrąglane do 1, 2, 4 lub 8 bajtów. Dzięki temu dostęp do danych z pól był szybszy. Dopiero użycie słowa kluczowego packed powodowało "kompresję" rekordu.

Parametr Pack określa właśnie zaokrąglanie pól w rekordzie. Listing 4. zawiera pełny kod źródłowy programu importującego bibliotekę mp3DLL.dll, z funkcją LoadTag.

Listing 4. Kod źródłowy programu wykorzystującego bibliotekę Win32

program Listing_4;

{$APPTYPE CONSOLE}

uses
  System.Text,
  SysUtils, { <-- wymagany prze funkcję FileExists }
  System.Runtime.InteropServices;

type
[StructLayout(LayoutKind.Sequential, Pack = 1, Charset = Charset.Ansi)]
TMp3 = packed record
  [MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)]
  ID: String[3];
  [MarshalAs(UnmanagedType.ByValArray, SizeConst = 31)]
  Title : String[30];
  [MarshalAs(UnmanagedType.ByValArray, SizeConst = 31)]
  Artist : String[30];
  [MarshalAs(UnmanagedType.ByValArray, SizeConst = 31)]
  Album : String[30];
  [MarshalAs(UnmanagedType.ByValArray, SizeConst = 5)]
  Year : String[4];
  [MarshalAs(UnmanagedType.ByValArray, SizeConst = 31)]
  Comment : String[30];
  [MarshalAs(UnmanagedType.ByValArray, SizeConst = 31)]
  Genre : String[30];
end;


  [DllImport('mp3DLL.dll', EntryPoint = 'LoadTag', CharSet = CharSet.Ansi, CallingConvention = CallingConvention.StdCall)]
  procedure LoadTag(const lpFileName : String; var Tag : TMp3); external;

var
  Tag : TMp3;
  Args : array of String;
begin
  try
  { odczyt argumentów (parametrów) przekazanych do programu }
    Args := Environment.GetCommandLineArgs;

  { sprawdzenie, czy użytkownika podał poprawną ścieżkę do pliku mp3 }
    if ((Length(Args[1]) = 0) or (FileExists(Args[1]) = False)) then
    begin
      Console.WriteLine('Podaj ścieżkę do pliku mp3');
    end else
    begin
    { wywołanie funkcji z biblioteki DLL }
      LoadTag(Args[1], Tag);

    { jeżeli plik MP3 posiada znacznik - wyświetlenie zawartości rekordu }
      if Tag.ID = 'TAG' then
      begin
        Console.WriteLine('Plik: {0}', Args[1]);
        Console.WriteLine('---------------------------');
        Console.WriteLine('Tytuł: ' + Tag.Title);
        Console.WriteLine('Wykonawca: ' + Tag.Artist);
        Console.WriteLine('Album: ' + Tag.Album);
        Console.WriteLine('Rok produkcji: ' + Tag.Year);
        Console.WriteLine('Komentarz: ' + Tag.Comment);
        Console.WriteLine('Gatunek: ' + Tag.Genre);
      end else
      begin
        Console.WriteLine('Brak informacji o utworze!');
      end;
    end;
  except
  { obsługa wyjątków }
    on E : TypeLoadException do
      Console.WriteLine('Nie można załadować biblioteki: ' +
        E.Message);
    on E : Exception do
      Console.WriteLine(E.Message);
  end;
end.

Porównajmy nagłówek procedury LoadTag, z nagłówkiem z biblioteki DLL. Typ PChar został zastąpiony typem String, a w miejsce wskaźnika na rekord (PMp3) wpisałem typ zwykły ? TMp3.

Na samym początku działania programu musimy sprawdzić, czy użytkownik wywołał naszą aplikację z parametrem oraz ? czy ścieżka do pliku jest prawidłowa. Dopiero później można użyć procedury LoadTag i wyświetlić zawartość rekordu. Oto rezultat działania programu;
Plik: D:\A.Boduch - Club House.mp3

Tytuł: Club House
Wykonawca: Adam Boduch
Album:
Rok produkcji: 2004
Komentarz: Club-House DJ's Set
Gatunek:

Dobrym zwyczajem jest w wypadkach takich jak ten ? implementowanie obsługi wyjątków. W naszym programie obsłużyliśmy standardowy wyjątek ? TypeLoadException, który występuje w sytuacjach, gdzie niemożliwe jest załadowanie biblioteki (np. plik został usunięty). Druga obsługa wyjątku nie jest związana z żadnym konkretnym typem ? wyświetla standardowy komunikat błędu.

Przekazywanie struktur

W ostatnim przykładzie pokażę, w jaki sposób można najpierw przekazać strukturę do funkcji z biblioteki DLL, aby następnie ją odzyskać. Być może mój przykład będzie nieco uproszczony, ale w praktyce może wyglądać to nieco inaczej: np. biblioteka DLL po odebraniu rekordu może dokonywać na nim pewnych operacji, a następnie zwracać zmodyfikowany rekord z powrotem do aplikacji.

Na samym początku, w podzespole .NET, raz zadeklarowany rekord zostanie przekazany do biblioteki DLL. Biblioteka DLL, po otrzymaniu rekordu umiejscowi go w pamięci. Ta sama biblioteka będzie posiadała również funkcję GetRecord, która zwróci wskaźnik do tego samego rekordu. Listing 4. zawiera kod źródłowy przykładowej biblioteki DLL.

Listing 4. Przykładowy kod źródłowy biblioteki DLL

library Win32DLL;

{ KOMPILOWAC W DELPHI DLA Win32 } 

uses
  Windows;

  type
  { deklaracja przykładowego rekordu }
    TWin32Rec = record
      Buffer : array[0..127] of Char;
      lpPChar : PChar;
      Numbers : array[1..2] of Byte;
    end;
    PWin32Rec = ^TWin32Rec; // wskaźnik na strukturę

  var
  { zmienna będzie przechowywała dane rekordu w pamięci na czas działania
    biblioteki }
    Rec : TWin32Rec;

  procedure SetRecord(Win32Rec : TWin32Rec); stdcall;
  begin
  { przekazane dane zapisz do zmiennej Rec }
    Rec := Win32Rec;
  end;

  function GetRecord : PWin32Rec; stdcall;
  begin
  { zwróć wskaźnik do rekordu }
    Result := @Rec;
  end;

exports
  SetRecord name 'SetRecord',
  GetRecord name 'GetRecord';


begin
end.

Ktoś nie będący w temacie powiedziałby, że istnienie takiej biblioteki jest bez sensu. W gruncie rzeczy ma racje; funkcje w niej zawarte odbierają, a następnie wysyłają rekord do aplikacji, nie dokonując na nim żadnych operacji.
W programie .NET korzystającym z takiej biblioteki, będzie trzeba użyć typu IntPtr oraz klasy Marshal. Wszystko dlatego, że funkcja GetRecord zwraca wskaźnik do rekordu ? nie jego kopię. Dzięki temu w pamięci nie jest tworzona kopia danych, która będzie przesyłana do aplikacji.

Dodatkowo, po zapisaniu rekordu TWin32Rec, w programie .NET, zamiast typu String użyłem IntPtr:

  type
    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
    TWin32Rec = record
      [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
      Buffer : String;
      lpPChar : IntPtr;
      [MarshalAs(UnmanagedType.ByValArray, SizeConst = 2)]
      Numbers : array[1..2] of Byte;
    end;

Pozostałe pola rekordu pozostają niezmienione ? rekord jest identyczny jak ten z programu z listingu 4., z takim wyjątkiem, iż pole lpPChar jest typu IntPtr. Listing 5. prezentuje cały kod źródłowy programu.

Listing 5. Kod źródłowy przykładowego programu

program P6_16;

{$APPTYPE CONSOLE}

uses System.Runtime.InteropServices;


  type
    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
    TWin32Rec = record
      [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
      Buffer : String;
      lpPChar : IntPtr;
      [MarshalAs(UnmanagedType.ByValArray, SizeConst = 2)]
      Numbers : array[1..2] of Byte;
    end;


  [DLLImport('Win32DLL.dll', CharSet = CharSet.Ansi, EntryPoint = 'SetRecord', CallingConvention = CallingConvention.Stdcall)]
  procedure SetRecord([in] Win32Rec : TWin32Rec); external;

  [DLLImport('Win32DLL.dll', CharSet = CharSet.Ansi, EntryPoint = 'GetRecord', CallingConvention = CallingConvention.Stdcall)]
  function GetRecord : IntPtr; external;

var
  Win32Rec : TWin32Rec;
  Buffer : IntPtr;
begin
  Console.WriteLine('Przekazywanie rekordu: kod zarządzany do niezarządanego...');

{ przypisanie wartości do rekordu }
  Win32Rec.Buffer := 'Znaki typu Char';
  Win32Rec.lpPChar := Marshal.StringToHGlobalAnsi('Łańcuch PChar');
  Win32Rec.Numbers[1] := 1;
  Win32Rec.Numbers[2] := 2;

{ wysłanie rekordu do biblioteki DLL }
  SetRecord(Win32Rec);
  Console.WriteLine('Przekazano...');

  Console.WriteLine('Odbieranie rekordu z kodu niezarządzanego');

{ odebranie rekordu i przypisanie do zmiennej typu IntPtr }
  Buffer := GetRecord;

{ przekształcenie bufora otrzymanych danych na normalny rekord }
  Win32Rec := TWin32Rec(Marshal.PtrToStructure(Buffer, TypeOf(TWin32Rec)));

  Console.WriteLine('Tablica Char: ' + Win32Rec.Buffer);
  Console.WriteLine('Zmienna PChar: ' + Marshal.PtrToStringAnsi(Win32Rec.lpPChar));
  Console.WriteLine('Tablica Numbers: [' + Convert.ToString(Win32Rec.Numbers[1]) + '][' + Convert.ToString(Win32Rec.Numbers[2]) + ']');
  
end.

Na samym początku pola rekordu muszą zostać wypełnione, aby mogły być przesłane do funkcji SendRecord. W celu przypisania danych tekstowych, do pola lpPChar, należało skorzystać z funkcji StringToHGlobalAnsi, z klasy Marshal. Przekazanie rekordu do kodu niezarządzanego jest prostsze niż jego odebranie.

Funkcja GetRecord zwraca dane w postaci zmiennej IntPtr; odczytanie więc nie stanowi problemu:

Buffer := GetRecord;

Zmienna Buffer także jest typu IntPtr. Od tego momentu posiadamy już dane, które chcemy odczytać; problemem jest konwersja danych na postać rekordu. Z tego względu konieczne było użycie funkcji PtrToStructure z klasy Marshal:

Win32Rec := TWin32Rec(Marshal.PtrToStructure(Buffer, TypeOf(TWin32Rec)));

Pierwszym parametrem funkcji PtrToStructure musi być zmienna typu IntPtr; drugim typ (rekord) na który odbędzie się "rzutowanie" danych. Po takim zabiegu pola rekordu umiejscowione zostaną we właściwych pozycjach, zmiennej Win32Rec.

Wady PInvoke

Oprócz oczywistych zalet zastosowania PInvoke (możliwość używania funkcji Win32) są także wady. Przede wszystkim biblioteka CLR nie jest w stanie zapewnić bezpieczeństwa niezarządzanego kodu. Funkcje z bibliotek DLL nie podlegają bezpośrednio CLR ? nie ma gwarancji ich poprawnego wykonania.

Po drugie użycie PInvoke jednocześnie ogranicza działanie naszych aplikacji, jedynie do systemu Windows. Co prawda na dzień dzisiejszy platforma .NET działa jedynie na system Windows, ale niewykluczone jest ? ba ? nawet całkiem realne, iż powstaną implementacje na inne platformy (np. na Windows CE, Linux). Wówczas aplikacje korzystające z funkcji Win32 nie będą działały. To dlatego w trakcie użycia modułów typowych dla Windows ? np. Windows, Messages, Delphi wyświetla komunikat ostrzegawczy mówiący o tym, że taki program może nie działać na innych platformach.

0 komentarzy