JNI: Ejecutar algoritmos en C desde Java

0
1110

Ejecutar algoritmos en C tiene sus ventajas y muchas veces puede que queramos hacerlo desde Java. Con JNI, esta tarea es muy sencilla. Ya existe algún tutorial hablando de JNI, pero de vez en cuando viene bien actualizar los contenidos para mantener el ritmo de evolución de la tecnología.

Como siempre, antes de comenzar, aquí os dejo un enlace al proyecto de ejemplo en GitHub.

JNI: qué es y cómo funciona

Para facilitarnos la tarea, Java ofrece su propio mecanismo: JNI (Java Native Interface). Con JNI podemos wrappear nuestro código C y traducir automáticamente objetos primitivos entre C y Java.

El archivo C que queramos ejecutar debe incluir la cabecera jni.h. De esta forma, ya podrá trabajar con los diferentes objetos que ofrece JNI:

JNI object types
Captura de la documentación oficial

Ahora bien, los métodos en C que queramos llamar desde el mundo Java deben seguir una sintaxis especial (no basta solo con importar JNI). Como esta sintaxis es un poco fuera de lo común, Java pone a nuestra disposición el comando Javah. Con este comando podemos, a partir de una clase Java, generar el archivo de encabezado (.h) de nuestro wrapper en C, y después simplemente debemos implementarlos.

Ejemplo: calculadora de sumas y restas

En cualquier aplicación Java podríamos crear una clase como la siguiente:

Ahora bien, supongamos que queremos que estos algoritmos se ejecuten en C. Ya sea para mejorar su eficiencia si son complejos o porque es un software completo y grande que queremos ejecutar desde Java llamando a alguna de sus funciones sin tener que traducir todo ese tedioso código a Java.

Supongamos, entonces, que tenemos el mismo código, pero escrito en C o C++:

¿Cómo llamar a esas dos funciones? Para ello necesitamos nuestro wrapper, que traduzca dos enteros desde el mundo Java de forma que puedan ser entendidos en el mundo C.

Para hacerlo automáticamente, vamos a utilizar los comandos que Java nos ofrece. Debemos crear nuestra clase Java tal y como os he mostrado antes pero, en lugar de implementar cada método, los marcamos como nativos y sólo los declaramos, a modo de interfaz:

Esto, evidentemente, no va a funcionar. Ahora abrimos una terminal y viajamos hasta donde tenemos nuestro archivo JavaCalculator.java con el código. Debemos compilar con Javac el archivo indicándole que genere nuestro archivo .h para implementarlo en C:

javac JavaCalculator.java -h .

Esto nos creará un archivo cuyo nombre estará basado en la estructura de paquetes de nuestra aplicación. En este caso, el nombre exacto es:

com_urbanojvr_jniexample_JavaCalculator.h

Y veamos qué contiene este archivo escrito en C:

Al principio, en el primer comentario, nos indica claramente que no modifiquemos este archivo. Es importante mantener la sintaxis que nos ha puesto en los nombres de las funciones. JNIEXPORT y jnicall son instrucciones necesarias para poder gestionar el entorno de JNI desde C. El nombre de la función Java_com_urbanojvr_jniexample_JavaCalculator_sum no debemos cambiarlo. Si lo cambiamos nos dará error ya que será imposible para el entorno de JNI poder encontrar el punto de entrada al mundo C.

Además, podemos observar que el tipo de función es jint. Esto significa que la función devuelve un entero del contexto de Java. Vemos también que la función tiene dos parámetros que nosotros no hemos añadido: JNIEnv* y jobject. Estos dos primeros parámetros son obligatorios. Más adelante veremos la importancia de poder apuntar al entorno de Java para, por ejemplo, traducir cadenas de texto de Java (jstring) a cadenas de caracteres de C. El resto de parámetros de los métodos son los que nosotros hemos añadido. Si modificamos nuestra lógica y añadimos o quitamos parámetros, podemos modificarlos sin problema; siempre y cuando los dos primeros sean los mencionados anteriormente.

Implementación de las funciones nativas

Antes vimos qué lógica tenía nuestra calculadora básica de ejemplo. Ahora, gracias a javac -h, ya tenemos nuestro archivo de encabezado con la declaración de las funciones. Lo único que tenemos que hacer es implementarlas. Creamos un archivo .c que incluya el archivo .h mostrado antes con la lógica de las funciones. Le he llamado igual que su clase cabecera, para que quede claro que es la implementación de nuestro wrapper: com_urbanojvr_jniexample_JavaCalculator.c.

Como podemos ver en este código, es tan simple com una función normal de C o C++. La implementación se llama igual que la declaración pero contiene la lógica, no es ningún concepto nuevo. Lo único que puede resultar lioso es la sintaxis algo fuera de lo común. Pero nada más.

Compilando el código C

Esto dependerá de nuestro sistema. Es posible ejecutar el código C o C++ incluso en sistemas Android (si te interesa, busca NDK). Pero, evidentemente, debemos tener previamente nuestra librería compilada para el sistema que se va a utilizar. Para los sistemas basados en Unix (MacOS en mi caso) podemos utilizar el GCC para copilarla. Primero debemos compilarla como un objeto c común (.o) y, después, crear el shared object (.so) para poder ser importado.

Para compilar a .o:

gcc -c -fPIC -I${JAVA_HOME}/include -I${JAVA_HOME}/include/darwin com_urbanojvr_jniexample_JavaCalculator.c -o com_urbanojvr_jniexample_JavaCalculator.o

Es importante incluir nuestro directorio Java con las carpetas include y include/darwin porque contienen los archivos jni.h y jni_md.h, necesarios para poder utilizar las herramientas proporcionadas por JNI. Generalmente, en sistemas MacOS, las direcciones a incluir son:

  • -I /Library/Java/JavaVirtualMachines/jdk1.8.0_201.jdk/Contents/Home/include/
  • -I /Library/Java/JavaVirtualMachines/jdk1.8.0_201.jdk/Contents/Home/include/darwin/

A partir de com_urbanojvr_jniexample_JavaCalculator.o generamos el .so:

gcc com_urbanojvr_jniexample_JavaCalculator.o -shared -o libcalculator.so

Importar librería nativa en Java

Ahora bien, esto todavía no funciona. Tenemos en nuestra calculadora de Java las funciones nativas, pero no hemos importado la librería. Debemos añadir dicha importación, y para ello Java nos proporciona algunos métodos como este:

loadLibrary vs load

Es importante que, si queremos que nuestra librería pueda ser utilizada automáticamente, la creemos con ese nombre: libMILIBRERIA.so, ya que al importarla en el código visto en Java (utilizando loadLibrary), con poner el nombre MILIBRERIA el sistema, automáticamente, añadirá «lib» delante y «.so» detrás. También debo avisar de que, si queremos que esto funcione, debemos tener nuestro .so guardado en el directorio donde se encuentran las librerías de Java (java.library.path), que será diferente en cada sistema. Si no hacemos esto, Java nos lanzará un error:

no calculator in java.library.path (donde «calculator» será, evidentemente, el nombre que le hayamos dado al importar la librería).

Para ahorrarnos esto, podemos importar directamente la librería dándole la ruta absoluta con el nombre completo de la misma y así no busca en los directorios automáticos. Pero para esto, en lugar de usar loadLibrary debemos utilizar el método load:

Solo debemos rellenar nuestra ruta absoluta y ya tenemos linkeado nuestro proyecto Java con el código C.

Main de ejemplo

Para completar la explicación, en el proyecto os incluyo un main con el que probar las operaciones:

Y ya está. Ahora podrás ejecutar código C sin tener que traducirlo, pudiendo importar algoritmos ya escritos y crear nuevas funciones que se ejecuten directamente en el procesador de tu dispositivo, mejorando su eficiencia.

Si quieres profundizar más en esto, te recomiendo leer sobre el NDK. ¡Así podrás hacer lo mismo en Android!

Dejar respuesta

Please enter your comment!
Please enter your name here