Есть достаточно много статей на тему JNI на уровне Hello World, но очень мало описаний как сделать портирование реальных библиотек, как дебагать нативный код в JNI, как реверсить работу библиотеки когда нету исходников, как работать с многопоточностью при использование JNI… Вообщем область очень емкая и интересная, планирую сделать серию статей на вышеперечисленные темы.

В этом цикле статей мы начнем с портирование C библиотеки по работе с OpenCAN под названием CANopenSocket.

Введение

Хочется покрыть все вопросы и задачи которые возникают при портирование библиотек через JNI их использования в Java. Также хочется чтобы материал был полезен как для ребят не сталкивающимся с JNI так и для тех кто уже в теме.

Почему выбрал CAN? Для меня ответ прост, так как для веба портирования C библиотек в наше время – это уже больше вид какого-то извращения, а вот для embedded проектов приходится постоянно сталкиваться С кодом и библиотеками. Тема автомотива и IoT сейчас очень востребована и уже многие приходят к выводу, что для продакшен проектов использовать Python не сильно хорошая идея, для прототипирования супер, а для продакшена все возвращаются к старому доброму C ну и C++, но для кроссплатформенных библиотек все таки доминирует C. Как не странно в устройствах в которых применяются CAN шины все чаще начинают использовать Java, особенно для Master устройств и по этой причины решил взять такую тему как CAN шины, в ней есть все что нужно для задач со звездочками да и в каждой автомобили есть CAN шина и часто не одна, думаю всем будет интересно сделать какой нибудь устройство для своего автомобиля или понимать как это все там общается между собой и работает.

Инструменты и окружение

  1. MacOS или Linux (большую часть буду делать с под MacOS, но и про Linux не забуду)
  2. XCode или GCC
  3. Eclipse JDT + CDT (можно использовать и IntelliJ)
  4. JDK 8+

Базовые вещи про JNI

Если вы уже что-то писали при использование Java Native Interface, то можете пропускать этот раздел.

Есть много определений Java Native Interface и вот так ее описывают в официальной документации:

Java Native Interface (JNI) is a standard programming interface for writing Java native methods and embedding the Java virtual machine into native applications. The primary goal is binary compatibility of native method libraries across all Java virtual machine implementations on a given platform.

Официальная документация очень полезна и должна быть под рукой:

Мне больше нравится определения, что JNI это фреймворк который позволяет из Java получать доступ к работе с кодом, динамическими библиотекам написаными компилируемых языках (многие пишут, что на Си подобных языках, но мне довелось видеть и интеграцию с библиотеками написанными на Pascal, это выходит за рамки статьи, но если будет интерес мы рассмотрим это в какой-то будущей статье).

В целом к использованию JNI настоятельно не рекомендую прибегать в своих проектах, это больше как крайняя мера и если единственный аргумент который у вас остается, что мол будет работать быстрее так как буду вызывать нативный код в JVM, то сразу отказывайтесь от этой затеи, так как прироста производительности вы скорее всего не получите (JNI Overhead), а вот секса будет много…

Прежде чем писать, а тем более дебажить код нужно понимать как устроена архитектура и коммуникация между разными слоями вашего любимого языка или платформы. Самое главное в случаи JNI это понимать как мы можем передавать данные с Java в нативный код и с нативного назад в Java одним словом надо знать все про шеринг данных и хорошо понимать как это все лежит в памяти. Когда вы вступаете на путь нативного кода, то понимание как, что лежит в памяти и как ее правильно массажировать, наверно самое основное, в целом это фундаментальные знания и редко когда они глобально отличаются в разных платформах.

Для себя выбрал Си так как по моему мнению это самый кроссплатформенный язык который на данный момент существует, по этой причине все примеры со стороны нативной части буду писать на Си.

Для того чтобы лучше понимать теория всегда начинаю с практики, то конь в вакууме это хорошо, но потыкать тушку палкой познавательно и эффективно… Предлагаю написать небольшое Си приложения и потом портировать его использование на Java через вызов нативных функций.

Куда же без Hello World

Напишем программу на Си которая возвращает нам площадь четырехугольника. Для этого берем наш Eclipse CDT, создаем пустой проект и пишем зародыш нашей библиотеке geometry. Кто хочет пропустить, то можете скачать исходники тут (JNIBattleWay_Unit1), но если вы читаете этот раздел, то советовал бы сделать все самостоятельно.

main.c:

#include <stdio.h>
#include <stdlib.h>
#include "geometry.h"

int main(void) {

	t_rectangle rec;

	rec.width = 5;
	rec.heigh = 3;
	rec.x = 5;
	rec.y = 10;

	int area = get_rectangle_area(&rec);

	printf("Area: %d\n", area);

	return EXIT_SUCCESS;
}

geometry.h:

#include <stdio.h>
#include <stdlib.h>

typedef struct {
	int x, y;
	int width, heigh;
} t_rectangle;

int get_rectangle_area(t_rectangle *rectangle);

geometry.c:

#include "geometry.h"

int get_rectangle_area(t_rectangle *rectangle)
{
	return rectangle->width * rectangle->heigh;
}

Все работает, теперь создадим динамическую нативную библиотеку geometry и интегрируем ее в Java. Создадим Java проект, потом создадим в нем класс описывающий Rectangle и добавим класс Geometry в котором и добавим обращение к нативному коду через метод getRectangleArea.

На Java стороне все просто теперь, переходим к C части:

  1. Описываем наше приложение по расчету площади прямоугольника, но саму ф-цию расчета будет использовать которую мы написали ранее в geometry.c
    package com.units.geometry;
    
    public class Rectangle {
    	public int x;
    	public int y;
    
    	public int width;
    	public int height;
    }
    package com.units.geometry;
    
    public class Geometry {
    	public native int getRectangleArea(Rectangle rectangle);
    }
    package com.units;
    
    import com.units.geometry.Geometry;
    import com.units.geometry.Rectangle;
    
    public class JNIBattleWay {
    
    	public static void main(String[] args) {
    		System.out.println("Hello World");
    		
    		System.loadLibrary("geometry.1.0");
    		
    		System.out.println("geometry lib online");
    		
    		Rectangle rec = new Rectangle();
    
    		Geometry geometry = new Geometry();
    
    		rec.width = 10;
    		rec.height = 4;
    
    		int area = geometry.getRectangleArea(rec);
    
    		System.out.println("Area: " + String.valueOf(area));
    	}
    
    }
  2. Нужно создать заголовочный файл в котором будет сгенерирован описания оборачивающих ф-ций. В будущем наша задача описать функции wrapper через которые и обеспечивается мост который обеспечивает дружбу между нативным котом и JVM. На этот счет есть много статей вы можете изучить детально этот вопрос, но мы остановимся только на структуре параметров передаваемых в эти wrapper функции и то как все это лежит в памяти и как работают указатели на JVM объекты. В Java 8, генерация заголовочных файлов делается сразу через javac, в более ранних версиях вам еще нужна будет использовать javah:
    cd src/com/units/geometry/
    javac Geometry.java Rectangle.java -h .
    

    После выполнения команды вы получете заголовочный файл:

  3. Нам теперь надо конвертировать проект в Convert to a C/C++ Project (Adds C/C++ Nature)
    • Переключаемся в вид C/C++: Window → Perspective → Open Perspective → Other → C/C++

    • Правой кнопкой нажимаем на проект → New → Convert to a C/C++ Project (Adds C/C++ Nature)

      или New → Other → C/C++ → Convert to a C/C++ Project (Adds C/C++ Nature)
    • C Project → Makefile project → MacOSX GCC или Linux GCC соотвественно для Linux

    Если вы не используете CDT, то можно использовать любую другую IDE для C или просто пользоваться VIM 😉

  4. Настроим C/C++ Build. Включаем Generate Makefiles automatically
  5. Нужно прописать пути заголовочным файлам JNI, чтобы редактор и сборщик работали корректно

    Properties → C/C++ General → Paths and Symbols → Includes и проверьте пути в Properties → C/C++ Build → Settings → Tool Settings → GCC C Compiler → Includes

    Нужно прописать путь для Mac (JAVA_HOME на маке обычно /Library/Java/JavaVirtualMachines/jdk1.8.X.jdk/Contents/Home/):

    [JAVA_HOME]/include
    [JAVA_HOME]/include/darwin
    

    Для Linux:

    [JAVA_HOME]/include
    [JAVA_HOME]/include/linux


  6. Создаем папку в корне проекта jni и переносим в нее наш com_units_geometry_Geometry.h, geometry.h, geometry.c (с первого нашего Си приложения). И прописываем в VM arguments -Djava.library.path=jni (это путь где у нас будет лежать наша библиотека, если у вас Target сборки будет другой то пропишите ваш относительный или абсолютный путь, это нужно для корректного нахождения библиотеки при вызове System.loadLibrary)
  7. Сборка библиотеки, тут вы можете воспользоваться стандартными средствами CDT и сгенерировать makefile. В данном случае выложу свой makefile который вы можете использовать в боевых проектах или задачить под свои нужды
    
    TARGET = geometry
    
    #------------------------------------------------
    # Version	                                                                     
    #------------------------------------------------
    LIB_MAJOR = 1
    LIB_MINOR = 0
    
    #------------------------------------------------
    # Debug code generation 	                                                                     
    #------------------------------------------------
    DEBUG = 1
    
    #------------------------------------------------
    # absolute or relative path to project root directory                                                                    
    #------------------------------------------------
    PRJ_DIR	= .
    
    #------------------------------------------------
    # Object directory                                                           
    #------------------------------------------------
    OBJ_DIR	= $(PRJ_DIR)/objs
    
    ######## !!!!!!!! Remove 
    
    JAVA_HOME = /Library/Java/JavaVirtualMachines/jdk1.8.0_202.jdk/Contents/Home
    
    ifeq ($(JAVA_HOME),)
    	ERR := $(error Not found path to JDK. Please check JAVA_HOME)
    endif
    
    #------------------------------------------------
    # Debug flags                                                    
    #------------------------------------------------
    ifeq ($(DEBUG), 1)
    	GDB_FLAG = -g3 -ggdb3 -O0
    	OPTIMIZE = -O0
    else
    	GDB_FLAG = 
    	OPTIMIZE = 
    endif
    
    #------------------------------------------------
    # Detect the OS                                                       
    #------------------------------------------------
    ifeq ($(OS),Windows_NT)
        CCFLAGS += -D WIN32
        ifeq ($(PROCESSOR_ARCHITEW6432),AMD64)
            CCFLAGS += -D AMD64
    		  LFLAGS += -shared -Wl,--out-implib,$(LIBNAME)$(LIB_MAJOR).$(LIB_MINOR).a
            LIBNAME  = lib$(TARGET).
            LIBSUFF = .dll 
        else
            ifeq ($(PROCESSOR_ARCHITECTURE),AMD64)
                CCFLAGS += -D AMD64
            endif
            ifeq ($(PROCESSOR_ARCHITECTURE),x86)
                CCFLAGS += -D IA32
            endif
        endif
    else
        UNAME_S := $(shell uname -s)
        UNAME_V := $(shell uname -v)
        ifeq ($(UNAME_S),Linux)
            CFLAGS  += -D LINUX -D TARGET="$(UNAME_V)"
            INC_DIR += -I $(JAVA_HOME)/include -I $(JAVA_HOME)/include/linux
            LFLAGS  += -shared
            LIBNAME  = lib$(TARGET).so.
            LIBSUFF  = 
        endif
        ifeq ($(UNAME_S),Darwin)
            CFLAGS  += -D OSX -D TARGET="$(UNAME_V)"
            INC_DIR += -I $(JAVA_HOME)/include -I $(JAVA_HOME)/include/darwin
            LFLAGS  += -single_module -dynamiclib -undefined dynamic_lookup
            LIBNAME  = lib$(TARGET).
            LIBSUFF  = .dylib
        endif
        UNAME_P := $(shell uname -p)
        ifeq ($(UNAME_P),x86_64)
            CCFLAGS += -D AMD64
        endif
        ifneq ($(filter %86,$(UNAME_P)),)
            CCFLAGS += -D IA32
        endif
        ifneq ($(filter arm%,$(UNAME_P)),)
            CCFLAGS += -D ARM
        endif
    endif
    
    #------------------------------------------------
    # Compiler settings                                                 
    #------------------------------------------------
    CC	=	$(CROSS)gcc
    
    #------------------------------------------------
    # Include directory for header files   
    # INC_DIR  += -I $(PRJ_DIR)/common                                                  
    #------------------------------------------------
    INC_DIR  += -I .
    
    #------------------------------------------------
    # Warning level                                                
    #------------------------------------------------
    WARN  = -Wall
    WARN += -Wextra 
    WARN += -std=c99
    
    #------------------------------------------------
    # Compiler flags                                              
    #------------------------------------------------
    CFLAGS	+= $(GDB_FLAG) $(OPTIMIZE) $(WARN) $(INC_DIR)
    CFLAGS  += -fPIC -c 
    
    #------------------------------------------------
    # Linker flags                                              
    #------------------------------------------------
    LFLAGS  +=  
    
    #------------------------------------------------
    # COM source files                                             
    #------------------------------------------------
    COM_SRC	= com_units_geometry_Geometry.c \
    geometry.c
    
    #------------------------------------------------
    # generate list of all required object files                                     
    #------------------------------------------------
    TARGET_OBJS = $(patsubst %.c,$(OBJ_DIR)/%.o, $(COM_SRC))
    
    #------------------------------------------------
    # Rules                                         
    #------------------------------------------------
    
    all: compiling $(TARGET_OBJS)
    	@echo - Linking $(LIBNAME)$(LIB_MAJOR).$(LIB_MINOR)$(LIBSUFF)
    	@$(CC) $(LFLAGS) -o ./$(LIBNAME)$(LIB_MAJOR).$(LIB_MINOR)$(LIBSUFF) $(TARGET_OBJS)
        
    show:
    	@echo JAVA_HOME: 
    	@echo $(JAVA_HOME) 
    	@echo TARGET_OBJS:
    	@echo $(TARGET_OBJS)
    	@echo INC_DIR:
    	@echo $(INC_DIR) 
       
    compiling:
    	@echo Build Target: $(TARGET) 
    
    install:
    	@ if [[ $EUID -ne 0 ]]; then \
    	echo "Must be run as root"; \
    	exit 1; \
    	fi
    	
    	cp -p $(LIBNAME)* /usr/local/lib
    	
    clean:
    	@rm -f lib$(TARGET)*.*
    	@rm -f $(OBJ_DIR)/*.d 
    	@rm -f $(OBJ_DIR)/*.o 
    	@rm -f $(OBJ_DIR)/*.elf
    
    $(OBJ_DIR)/%.o : %.c
    	@echo - Compiling : $(<F)
    	@$(CC) $(CFLAGS) $ < -o $@ -MMD
    
  8. Описываем наш JNI Wrapper:

    
    #include "com_units_geometry_Geometry.h"
    #include "geometry.h"
    
    JNIEXPORT jint JNICALL Java_com_units_geometry_Geometry_getRectangleArea
      (JNIEnv *env, jobject obj, jobject jrectangle)
    {
    	jclass cls = (*env)->GetObjectClass(env, jrectangle);
    
        jfieldID fidInt = (*env)->GetFieldID(env, cls, "width", "I");
        jint jwidth = (*env)->GetIntField(env, jrectangle, fidInt);
        printf("Width: %d\n", jwidth);
    
    
        fidInt = (*env)->GetFieldID(env, cls, "height", "I");
    	jint jheight = (*env)->GetIntField(env, jrectangle, fidInt);
    	printf("Height: %d\n", jheight);
    
    	t_rectangle rec;
    
    	rec.width = jwidth;
    	rec.heigh = jheight;
    
    	return get_rectangle_area(&rec);
    }
    
  9. Теперь собираем нашу библиотеку, предпочитаю собирать с консоли, но вы можете собрать через IDE:
    make all


    Уровень варнингов вы можете подправить в makefile

  10. Теперь запускаем наше Java приложение и должны увидеть в консоле:

Настройка сборки библиотеки через Eclipse CDT

  1. В настройках проекта (Properties -> C/C++ Build -> Builder Settings -> Builder Type) указываем использовать внутрений сборщик “Internal Builder”
  2. Для Linux убедитесь что у вас выбран правильный Tool Chain Editor:
  3. Указываем что будем собирать Shared Library (Properties -> C/C++ Build -> Settings -> Shared Library Settings)
    для linux:

  4. Указываем стандарт ISO C99
  5. Для дебага нам надо отключить оптимизации при сборке

  6. Указываем уровень дебага G3

  7. Указываем -fPIC

  8. Прописываем Build Artifact
    для linux

  9. Жмем Build

Шеринг данных в JNI или смотрим чуть пристальней на JNI Wrapper

Посмотрим на нашу сгенерированную функцию обертку, в частности нас интересуют параметры которые мы получаем в нашу функцию:


JNIEXPORT jint JNICALL Java_com_units_geometry_Geometry_getRectangleArea
  (JNIEnv *env, jobject obj, jobject jrectangle)

Первый параметр это указатель на структуру JNINativeInterface_
хранящую указатели на все системные функции JNI, полный список их можно посмотреть в jni.h, а еще лучше посмотреть и в документации

Второй параметр это ссылка на Java объект внутри которого и объявлена наш метод с инструкцией native, нашем случае это ссылка на объект класса Geometry. Мы можем использовать эту ссылку чтобы вызывать нужные методы этого объекта, это очень полезно в реальных проектах так как формально вам нужно объектно ориентированную логику конвертировать в функциональную, а через эту ссылку вы можете вызвать Java метод в JNI Wrapper.

Чаще всего в JNI Wrapper делают мапинг данных с подальшей передачей в нативный код. Тут на самом деле не все так сложно, но очень неудобно и некрасиво, особенно когда заходит речь об переносе коллекций и массивов объектов, но к счастью такое нужно не так часто.

Самое опасное в этом всем это утечка памяти и дружба с Garbage Collector так как в JNI части ее заметить на этапе разработки сложно, а выловить в продакшене ее практически невозможно. Нужно как никогда понимать как работает Garbage Collector особенно с JNI частью, с этим всем мы будем играться в будущих статьях и CAN в этом нам очень поможет, так как мы сможем увидеть цену кеширования ссылок на поля или объекты в JNI и что будет происходить с нашим нативным кодом когда GC решит удалить объект который еще нам нужен.

JNI Дебаг (println vs gdb / lldb)

Первый дебаг с println/printf

Как бы не был крут дебагер и насколько бы не была интуитивна IDE, многие программисты и я не исключение, в начале пытаюсь дебагать с помощью c помощью трейса сообщений в консоль. Так что первой линией обороны у большинства является System.out.println на стороне Java и printf на стороне Си. Но если вы обратили внимание, то при запуске нашего кода сообщения в консоли отображаются не в правильном порядке:

Нужно учитывать, что когда мы используем printf (или любой другой аналог) мы пишем в STDOUT поток который в свою очередь буферизируется и пока буфер не заполняется или мы его физически не освободим в мир мы ничего не будем видеть на экране. Для решения этой проблемы нужно или fflush вызывать после каждой команды printf:

printf("Width: %d\n", jwidth);
fflush(stdout);

или можно сказать в целом что буфер будет NULL и тогда все что вы пишите в поток будет сразу в него попадать без буферезации:

setbuf(stdout, NULL);

и теперь пересобираем проект и запускаем наше приложение:

Бинго теперь мы можем начинать дебагать старым добрым проверенным методом.

Взрослый дебаг JNI c gdb / lldb

В боевых проектах часто случаются ситуации когда просто логирование и println не помогают решить проблему или найти в чем конкретно баг. Тогда нам нужен уже дебаггер, но тут возникает проблема, как дебаггать в проекте с JNI частью, особенно когда баг явно под капотом нативной библиотеки?

Для этих задач нужно использовать gdb или lldb. Под Linux проще использовать gdb, а под MacOS – lldb.

GDB – это GNU Debugger переносимый отладчик проекта GNU, который работает на многих UNIX-подобных системах и умеет производить отладку многих языков программирования, включая Си. Его можно использовать как под Linux так и под MacOS, но под MacOS нужно быть готовым к мучениям с подписью приложения – это отдельная тема и мы ее думаю рассмотрим в будущем.

LLDB – высокопроизводительный отладчик, который по умолчанию используется в XCode и в целом он MacOS ориентированный, по этой причине им легче пользоваться под MacOS.

В целом lldb и gdb очень схоже, но есть и отличия, которые в будущем рассмотрим на более сложных этапах нашего портирования библиотеки для CAN.

Давайте теперь по дебаггаем наше приложение, создадим копию и назовем ее Unit3:

  1. Нужно создать дополнительный “Debug Configuration” только типа “C/C++ Attach to Application” и выбрать lldb дебаггер для MacOS (для Linux gdb)

  2. Расставим breakpoint на java части и в нашем com_units_geometry_Geometry.c
  3. Запускаем в начале дебаг java приложения

  4. В окне Debug мы видим наше приложение и java поток который приостановился на нашем breakpoint. Теперь запускаем дебег нашего нативного процесса, в данном случаи мы дебагаем нативную часть которая лежит в JNI части, что означает что мы должны дебеггать java процесс. При запуски нашего lldb или gdb он у нас запросит указать PID процесса который нужно дебажить.  Мы указываем наш java процесс (можете просто в строке поиска набрать java, можете посмотреть в консоли PID вашего запущенного процесса, а можете и написать нехитрые пару строк для логирования в которых будете указывать PID)

  5. После того мы присоединимся к процессу дебагер по умолчанию остановится на mach_msg_trap или какой-то подобной нативной ф-ции.  Вам нужно отпустить процесс дальше.

  6. Теперь можем отпускать наш breakpoint в java части и теперь наш дебагер работает как и с java часть так и с нативной. Единственное будьте внимательны какой процесс у вас выбран в окне Debug, чтобы не путать где отпускаете breakpoint
    теперь мы можем проводить полноценный инвестигейт и поиска наших багов в нативной части нашего приложения.

Возможные проблемы с GDB и LLDB

Could not determine GDB version using command… Unexpected output format

Эта проблема часто встречается под MacOS:

The lldb-mi not longer present from Xcode 11.x, but lldb and LLDB.Framework already included in the Xcode. Use the lldb-mi that comes bundled with previous versions of XCode( 10.x) , the location is ‘Xcode.app/Contents/Developer/usr/bin/lldb-mi’, copy it to the same location of current version XCode.

Эта проблема в то что lldb-mi с августа 2019 больше не включен в проект lldb и вынесен в отдельный проект https://github.com/lldb-tools/lldb-mi. Для решения этой проблемы нужно собрать этот модуль отдельно.

Unable to find Mach task port for process-id


Error in final launch sequence:

Failure to attach to process: java [75362]
Error:
Failed to execute MI command:
-target-attach 75362

Error message from debugger back end:
Unable to find Mach task port for process-id 75362: (os/kern) failure (0x5).
 (please check gdb is codesigned - see taskgated(8))
Failure to attach to process: java [75362]
Error:
Failed to execute MI command:
-target-attach 75362

Error message from debugger back end:
Unable to find Mach task port for process-id 75362: (os/kern) failure (0x5).
 (please check gdb is codesigned - see taskgated(8))

Для решение этой проблемы нужно подписать приложение сертификатом (codesign).

Failed to execute MI command. Operation not permitted.

Для решение этой проблемы нужно подписать приложение сертификатом (codesign) или запустите Eclipse из под root (sudo)

Резюме

Одна из главных проблем когда сталкиваешся с JNI – это что знать Java и C не достаточно, для того чтобы обеспечить надежную работу приложения. Задача статей описать, как работать и отлавливать баги в таком непростом симбиозе и отговорить людей оптимизировать свое приложение через переписывание части Java логики на C ради производительности. Надеюсь эта статья поможет, если на ваши плечи упадет поддержка или разработка проекта с  JNI частью.