Künstliche Intelligenz
C-Libraries in Java nutzen 2: Funktionen mit veränderlichen Parametern
Javas Foreign Function & Memory API (FFM) dient dazu, auf Code in einer Shared Library beziehungsweise DLL zuzugreifen, der in einer Programmiersprache wie C oder Rust geschrieben ist. Allerdings muss der Code dazu einige Voraussetzungen erfüllen. Diese dreiteilige Artikelserie zeigt anhand einer in C geschriebenen Demo-Library, wie eine Java-Anwendung die Funktionen der Bibliothek aufruft, welche Vorbereitungen erforderlich sind und welche Regeln zu beachten sind.
Weiterlesen nach der Anzeige
Rudolf Ziegaus ist Software-Entwickler, Java-Trainer und Geschäftsführer der IO Software GmbH. Seine Lieblingsthemen sind PKi, Kryptographie und systemnahe Programmierung.
Nachdem der erste Teil gezeigt hat, wie man in Java eine in C geschriebene Shared Library lädt und einfache Funktionen dieser Shared Library aufruft, geht es jetzt um komplexere Szenarien. Er zeigt, wie man aus Java Funktionen mit veränderbaren Parametern aufrufen und Arrays sowie Strukturen übergeben kann.
Funktionen mit veränderbarem Parameter
Die bisherigen Beispiele haben die Aufrufe der nativen Funktionen einfach gehalten. Die Java-Anwendung hat lediglich Parameter durchgereicht und den Rückgabewert übernommen.
Anders sieht es bei den nächsten Beispielen aus. Als erstes folgt die einfache C-Funktion getVersion2, die wie die Funktion getVersion aus Teil 1 die Version der Library ermittelt. Die neue Funktion gibt die Versionsnummer aber nicht als Wert zurück, sondern verändert dazu einen Parameter. Das funktioniert in C, indem eine Anwendung für einen Parameter nicht den Wert selbst, sondern dessen Adresse übergibt (Call by Reference). Dieses Konstrukt sieht in C folgendermaßen aus:
EXPORT void getVersion2(int* version);
Folgender Java-Code ruft die Funktion auf:
Weiterlesen nach der Anzeige
int version;
getVersion2(&version);
Das & kennzeichnet in C, dass die Funktion die Adresse der Variablen nutzt. Java erlaubt das Vorgehen nicht, sodass ein Rückgabewert unerlässlich ist. Folgende Java-Methode verwendet die C-Funktion mit Referenz:
public int getVersion2() throws Throwable
{
MethodHandle method = getMethodHandle("getVersion2",
FunctionDescriptor.of(
ValueLayout.JAVA_INT,
ValueLayout.ADDRESS
));
try (Arena arena = Arena.ofConfined())
{
MemorySegment versionSeg =
arena.allocate(ValueLayout.JAVA_INT.byteSize());
method.invoke(versionSeg);
int version = versionSeg.get(ValueLayout.JAVA_INT, 0);
return version;
}
}
Zuerst ruft der Code wie im ersten Teil der Serie wieder die Methode getMethodHandle() auf. Der Aufruf definiert den FunctionDescriptor für die Funktion getVersion2().
Die Angabe ValueLayout.ADDRESS für den Parameter zeigt an, dass die C-Funktion eine Adresse erwartet.
Jetzt kommt der spannendere Teil: Um eine Adresse übergeben zu können, muss die Java-Anwendung mittels der FFM-API einen Speicherbereich von vier Byte (für den Datentyp int) reservieren. Das geschieht mit einer Arena, die er erste Teil bereits erläutert hat. Das Erzeugen der Arena mit dem try-with-ressources-Statement stellt sicher, dass die Arena nach dem try-Block automatisch geschlossen und der darin verwaltete Speicher automatisch freigegeben wird. Es gibt verschiedene Typen von Arenas – die im Beispiel über ofConfined erzeugte sorgt dafür, dass die Anwendung nur auf Speicher des aktuellen Thread zugreifen kann. Eine mit ofConfined() erzeugte Arena – beziehungsweise der damit allozierte Speicher – ist daher nicht threadsicher.
Als nächstes gilt es, den erforderlichen Speicherbereich für den Parameter version zu allokieren. Dafür besitzt die Arena die Methode allocate(). Die Größe des benötigten Speichers kann man durch die Funktion byteSize() für die Variable ermitteln. Hier sei nochmals darauf hingewiesen, dass der Wert die Größe des Java-Datentyps darstellt und nicht zwingend etwas über den C-Datentyp aussagt. Da die C-Funktion einen int-Parameter entgegennimmt, sind wir auf der sicheren Seite, da int in C stets vier Byte umfasst. Bei einem long-Wert in C hängt die Größe dagegen von der Plattform ab.
Der Speicherbereich wird durch ein MemorySegment dargestellt, das beim Aufruf der Methode invoke an die C-Funktion weitergereicht werden muss.
Anschließend kann die Anwendung das Ergebnis auslesen. Dazu ruft sie auf dem MemorySegment die Funktion get auf und übergibt ihr das Layout des Speichers (in diesem Fall ein JAVA_INT) und den Offset zum Lesen aus dem MemorySegment. Für das Beispiel ist der Offset null. Durch die Angabe JAVA_INT gibt die Funktion einen int-Wert zurück, den die Anwendung weiterverarbeiten kann.
Funktionen mit einem Array-Parameter
Die nächste Aufgabe baut auf dem Vorgehen auf, verarbeitet aber nicht nur einen Wert, sondern ermittelt den Durchschnitt aus einer Liste von int-Werten. Dazu muss sie der nativen Funktion ein Array von int-Werten übergeben:
public double calcAverage(int [] values) throws Throwable
{
MethodHandle calcAverage =
getMethodHandle("calcAverage"),
FunctionDescriptor.of(
ValueLayout.JAVA_DOUBLE, // return value
ValueLayout.ADDRESS, // data values
ValueLayout.JAVA_INT)); // number of elements
try(Arena arena = Arena.ofConfined())
{
long totalSize = ValueLayout.JAVA_INT.byteSize() * values.length;
MemorySegment valueSegment = arena.allocate(totalSize);
for (int i = 0; i < values.length; i++)
{
valueSegment.setAtIndex(ValueLayout.JAVA_INT, i, values[i]);
}
double result = (double) calcAverage.invoke(valueSegment,
values.length);
return result;
}
}
Zunächst berechnet der Code die gesamte Speichergröße des Arrays (totalSize) und reserviert den benötigten Speicher mit allocate(). Anschließend belegt der Code den Speicher mit der Methode setAtIndex für das jeweilige MemorySegment. Der Aufruf erfolgt für jedes Element des Arrays.
Schließlich ruft der Code die Methode invoke für den MethodHandle auf und übergibt ihr als Parameter das Array und dessen Länge. Schließlich gibt sie das Ergebnis der C-Funktion zurück.