CrossCompile

Android NDK& JNI 리눅스 디바이스 드라이버를 사용하여 개발하기

sh1mj1 2022. 11. 11. 16:41

이번에는 리눅스 환경에서 ADB 타겟보드로 크로스 컴파일만 하는 것을 넘어서 안드로이드 (JAVA) 환경에서 C or C++ (Native source) 을 연동/연결하여 상호 호환하는 기술을 학습해봅시다.

NDK

NDK 는 네이티브 개발 키트 (Native Development Kit)

즉, 안드로이드에서 C 및 C++ 코드를 사

용할 수 있게 해주는 도구 모음. → 네이티브 소스 코드를 사용함으로써 효과는 속도, 연산, 호환성 등의 SDK 보다 빠른 작업 속도 가능.

JNI

JNI 는 자바 네이티브 인터페이스 (Java Native Interface)

즉, JAVA(JVM) 과 C 및 C++ 을 연결시켜주는 인터페이스.

자바 메서드 호출로 C 및 C++ 로 작성된 코드 및 함수를 실행할 수 있다.

기본적으로 NDK는 자바소스를 기반으로 자바에서 C을 읽을 수 있게 도와주는 헤더 파일을 생성한다. 헤더 파일을 생성하려면 내가 어떤 메서드를 사용할 것인지 알아야 하고 미리 작성을 해주어야 한다.

이 때 .so 파일은 동적 라이브러리 파일이다.

여기에는 하나 이상의 프로그램에서 리소스를 오프로드하는데 사용할 수 있는 정보가 포함되어 있다.

안드로이드 스튜디오에서 프로젝트를 만들 때 아래와 같이 만든다.

 

 

위 과정으로 프로젝트를 생성하면 위와 같은 파일이 자동으로 생성된다.

CMakeLists.txt

[CMake 튜토리얼] 2. CMakeLists.txt 주요 명령과 변수 정리 - ECE - TUWLAB

이 때 CMakeLists.txt .파일이 무엇일까?

우리는 리눅스 환경에서 타겟보드에 크로스 컴파일을 할 때 Makfile 에 대해 알아보았다.

Make 는 쉘 스크립트로 빌드하는 것보다 편리하지만 프로젝트의 규모가 거대해지면서 관리해야 할 소스 파일들이 많아지고 의존성 관계가 복잡하게 뒤엉킬 수 있다.

Make 는 의존성 정보를 검사할 때 소스 파일까지 일일이 뒤져서 어떤 헤더파일이 포함되어 있는지 조사하지 않으므로 의존성이 잘 못 기술되어 있으면 빌드가 꼬일 수 있다.

그런데 CMake 을 사용하면 의존성 정보를 일일이 기술해 주지 않아도 된다!

프로젝트를 처음 시작할 때 Build Step 만 잘 구성해 놓으면 소스 파일(*.c) 을 처음 추가할 때만 CMakeLists.txt 파일을 열어서 등록해 주면 된다.

CMake 는 소스파일 내부까지 들여다 보고 분석해서 의존성 정보를 스스로 파악한다. 또 Makefile 에서 빌드 중간 생성물인 object 파일들의 이름과 의존성도 직접 기술해 주어야 하는 것과 다르게 CMake 에서는 그럴 필요가 없다. 또 여러 IDE 에서 프로젝트 설정 파일로 사용할 수 있따는 장점 또한 있다.

위 설명을 읽어보면 CMake 가 Makefile 과는 완전히 다른 새로운 것이라고 느낄 수 있지만 CMake 는 그저 Makefile 을 더 쉽게 기술해주는 일종의 Meta-Makefile 일 뿐이다.

CMakeList 의 구체적인 내부 동작은 따로 알아보고 이 프로젝트에서 사용하는 명령어만 몇 개 알아봅시다.

cmake_minimum_required(VERSION 3.18.1)

project("segmentt")

add_library( 
        native-lib # Sets the name of the library.
        SHARED     # Sets the library as a **shared** library.
        native-lib.cpp)  # Provides a relative path to your source file(s).
add_library( 
        JNIDriver
        SHARED
        JNIDriver.c)

# Searches for a specified prebuilt library and stores the path as a
# variable. Because CMake includes system libraries in the search path by
# default, you only need to specify the name of the public NDK library
# you want to add. CMake verifies that the library exists before
# completing its build.

find_library( # Sets the name of the path variable.
        log-lib

        # Specifies the name of the NDK library that
        # you want CMake to locate.
        log)

# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.

target_link_libraries( # Specifies the target library.
        native-lib
        # Links the target library to the log library
        # included in the NDK.
        ${log-lib})
target_link_libraries( 
        JNIDriver
        ${log-lib})
  • cmake_minimum_required(VERSION <버전>)
    • 빌드 스크립트를 실행하기 위한 최소 버전 명시. (CMake 의 버전)
  • project(”<NAME>”)
    • 프로젝트 이름 설정
  • add_library( <라이브러리 이름> <STATIC or SHARED or MODULE> <소스 파일>….)
    • 빌드 최종 결과물로 생성할 라이브러리를 추가한다.
    • 이 명령을 반복해서 생성할 라이브러리를 계속 추가할 수 있다.
      • <라이브러리 이름>: 생성할 라이브러리 이름 (lib~~.a / lib~~.so 에서 ~ 에 들어갈 값임.)
      • <STATIC or SHARED or MODULE>: 라이브러리의 종류. (생략 시 STATIC 이 된다.)
      • <소스 파일>: 라이브러리를 생성하는데 필요한 소스 파일.
  • find_library
    • Android NDK에서는 유용할 수 있는 일련의 네이티브 API 및 라이브러리를 제공합니다. 프로젝트의 CMakeLists.txt 스크립트 파일에 NDK 라이브러리를 포함하여 이러한 API를 사용할 수 있습니다.
    • NDK 라이브러리는 이미 CMake의 검색 경로에 포함되어 있기 때문에 로컬 NDK 설치에서 라이브러리 위치를 지정할 필요가 없습니다. 즉, 사용하려는 라이브러리 이름을 CMake에 제공하고 자체 네이티브 라이브러리에 연결하기만 하면 됩니다.
    • CMake 빌드 스크립트에 find_library() 명령어를 추가하여 NDK 라이브러리를 찾고 라이브러리 경로를 변수로 저장합니다. 이 변수를 사용하여 빌드 스크립트의 다른 부분에서 NDK 라이브러리를 참고할 수 있습니다. 다음 샘플은 Android 관련 로그 지원 라이브러리를 찾고 log-lib에 라이브러리 경로를 저장합니다.
  • target_link_libraries( <Target 이름> <라이브러리> <라이브러리> .….)
    • Target 링크 시 포함할 라이브러리 목록을 지정한다. 이 때 라이브러리 파일명의 Prefix 및 Postfix 는 제외하고 라이브러리 이름만 입력한다.
    • 네이티브 라이브러리에서 log 라이브러리의 함수를 호출하려면 다음과 같이 CMake 빌드 스크립트에서 target_link_libraries() 명령어를 사용하여 라이브러리를 연결해야 합니다.
    • 또한, NDK에는 빌드하여 네이티브 라이브러리에 연결해야 하는 몇 가지 라이브러리가 소스 코드로 포함되어 있습니다.

native-lib.cpp

#include <jni.h>
#include <string>

extern "C" JNIEXPORT jstring JNICALL
Java_com_example_segmentt_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}

jni.h 헤더를 추가해서 ndk 작업을 할 때 필요한 타입, 구조체, 함수 등을 추가한다.

  • extern “C”
    • extern C 는 cpp 내부에서 c 언어 코드를 작성해서 컴파일 하겠다고 선언하는 코드
  • JNIEnv*
    • VM 을 가리키는 포인터이다.
  • jobject 는 자바 측에서 전달된 암시적 this 객체를 가리키는 포인터

VM API (*env) 을 호출하여 여기에 반환 값을 전달한다. 여기서 반환 값은 자바 측의 함수가 요청한 문자열이다.

JNIEXPORT 매크로를 이용해서 해당 함수의 반환 자료형을 jstring 으로 지정한다. 해당 코드는 안드로이드에서 아무런 역할도 하지 않는, 함수의 가시성을 위해 추가하는 코드이다.

native code 에서의 함수 이름의 규칙은 아래와 같다.

  • 맨 앞에 Java_ 접두사가 필요하다.
  • Java_ 다음에 패키지 이름이 들어간다. 패키지 이름의 점은 밑줄로 대체된다.
  • 패키지 이름 뒤에는 함수가 속한 자바 클래스의 이름이 들어간다.
  • 클래스 이름 뒤에 함수 이름이 들어간다.

MainActivity Preview

public class MainActivity extends AppCompatActivity {

    // Used to load the 'segmentt' library on application startup.
    static {System.loadLibrary("segmentt");}

    // JNI methods
    private native static int openDriver(String path);
    private native static void closeDriver();
    private native static void writeDriver(byte[] data, int length);
	
		...
/**
     * A native method that is implemented by the 'segment' native library,
     * which is packaged with this application.
     */
	  public native String stringFromJNI();
}

loadLibrary

libname argument 로 명시된 네이티브 라이러브러를 로드한다. 라이브러리 이름은 확장자 미포함.

만약 네이티브 라이브러리가 VM 에 정적으로 링크되면 라이브러리의 JNI_OnLoad_libname 이라는 함수가

주입된다.

libname argument 는 시스템 라이브러리 위치로부터 로드되고, 네이티브 라이브러리 이미지에 매핑된다.

segmentt 라는 라이브러리를 로드한다. → 현재 이 프로젝트가 segmentt 이므로 이 프로젝트 내의 모든 c 파일에 있는 JNI 메소드들을 읽을 수 있게 된다.

JNI methods 을 위처럼 선언했다. 이 메소드들 은 JNIDriver.c 라는 소스파일에서 정의될 것이다.

JNIDriver.c

이미 ADB 에 push 해 놓은 C 언어로 이루어진 디바이스 드라이버를 사용합니다.

openDriver

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <jni.h>

int fd = 0;

JNIEXPORT jint JNICALL
Java_com_example_segmentt_MainActivity_openDriver(JNIEnv *env, 
																									jclass clazz, 
																									jstring path) {
    jboolean iscopy;
    const char *path_utf = (*env)->**GetStringUTFChars**(env, path, &iscopy);
    fd = **open**(path_utf,O_WRONLY); // fd 가 0이상: 정상적으로 엶. -1: 오류
    (*env)->**ReleaseStringUTFChars**(env, path, path_utf);

    if (fd < 0) return -1;
    else return 1;
}

**GetStringUTFChars**

const char * GetStringUTFChars(JNIEnv *env, jstring string, jboolean *isCopy)

UTF-8 인코딩에서 string 을 표현하는 바이트 배열로의 포인터를 리턴한다.

이 배열은 ReleaseStringUTFChars() 에 의해 해제될 때까지 유효하다.

만약 isCopy 가 NULL 이 아닐 때

copy 가 만들어지면 *isCopy 는 JNI_TRUE 로 설정되고 ,copy 가 만들어지지 않으면 JNI_FALSE 가 된다.

파라미터

  • env: the JNI interface pointer
  • string: 자바 string object
  • isCopy: boolean 으로의 포인터
  • 리턴: 수정된 UTF-8 string 으로의 포인터를 리턴, 혹은 동작 실패시 NULL 을 리턴.

**ReleaseStringUTFChars**

void ReleaseStringUTFChars(JNIEnv *env, jstring string, const char *utf);

VM 이 더 이상 utf 에 접근할 필요가 없다는 것을 알려준다. utf argument 는 GetStringUTFChars 을 사용하는 string 으로부터 유래된 포인터다. Informs the VM that the native code no longer needs access to utf. The utf argument is a pointer derived from string using GetStringUTFChars().

PARAMETERS:

env: the JNI interface pointer.

string: a Java string object.

utf: a pointer to a modified UTF-8 string.

여기서 open(path_utf, O_WRONLY) 의 open 은 fcntl.h 에 있는 메서드를 사용한 것이다. 이미 존재하는 파일을 열거나 새로운 파일을 생성하는 System call 함수이다.

closeDriver

JNIEXPORT void JNICALL
Java_com_example_segmentt_MainActivity_closeDriver(JNIEnv *env, jclass clazz) {
    if (fd < 0) close(fd);
}

close 는 unistd.h 에 있는 open으로 열었던 파일을 닫아주는 함수입니다.

하나의 프로세스에서 너무 많은 파일을 열게되면 시스템 자원을 낭비하게 되기 때문에 사용하지 않는 파일은 닫아주는 것이 좋습니다.

단 프로세스가 종료되면 파일은 자동으로 닫히기 때문에 프로세스 전반적으로 이용하는 파일의 경우에는 수동으로 닫아주지 않아도 됩니다.

매개변수 fd 는 닫고자 하는 파일 디스크립터.

리턴값

성공시 0 리턴. 실패 시 -1 리턴 후 errno 설정

writeDriver

JNIEXPORT void JNICALL
Java_com_example_segmentt_MainActivity_writeDriver(JNIEnv *env, 
																										jclass clazz, 
																										jbyteArray data,
                                                   jint length) {
    jbyte *chars = (*env)->GetByteArrayElements(env, data, 0);
    if (fd > 0) write(fd, (unsigned char *) chars, length);
    (*env)->ReleaseByteArrayElements(env, data, length, 0);
}

Get<PrimitiveType>ArrayElements Routines

Get<PrimitiveType>ArrayElements(JNIEnv *env, ArrayType array, jboolean *isCopy)
// 아래 예
GetByteArrayElements(JNIEnv *env, ByteArray array, jboolean *isCopy)

Byte array 의 바디를 리턴하는 함수.

결과는 Release<PrimitiveType>ArrayElements 을 호출할 때까지 유효하다.

반환된 array 가 자바 배열의 copy 일 수도 있기 때문에 Release<PrimitiveType>ArrayElements() 가 호출될 때까지 리턴된 배열의 변경내용이 바로 원래 배열에 반영되지는 않는다.

즉, 위에서는 data 가 배열인데 여기에 바로 반영되지는 않는다는 뜻.

isCopy 가 NULL 이면

copy 가 만들어지면 *isCopy 가 JNI_TRUE 가 됨. copy 가 안 만들어지면 *isCopy 가 JNI_FALSE 가 된다.

즉, 위에서는 자바의 ByteArray 을 C 의 *chars 로 변환한다.

PARAMETERS:

env: the JNI interface pointer.

array: a Java string object.

isCopy: a pointer to a boolean.

Release<PrimitiveType>ArrayElements Routines

Release**<PrimitiveType>ArrayElements(**JNIEnv *env, ArrayType array, NativeType *elems, jint mode

ReleaseByteArrayElements(JNIEnv *env, ArrayType array, NativeType *elems, jint mode)

더이상 elems(ByteArray 에서는 jbyte) 로의 접근이 필요하지 않다는 것을 VM에게 알려주는 함수.

elems argument 는 그에 맞는 Get<PrimitiveType>ArrayElements() 함수를 사용하는 배열로의 포인터이다.

만약 필요하다면 이 함수는 elems 로 만드는데 생긴 모든 변화를 다시 원래 배열로 copy 한다.

mode argument 는 array buffer 가 어떻게 해제되는지를 뜻한다. mode 는 만약 elems 가 array의 요소들의 copy 가 아니라면 아무 의미가 없다.

mode

0: copy back the content and free the elems buffer

JNI_COMMIT: copy back the content but do not free the elems buffer

JNI_ABORT: free the buffer without copying back the possible changes

여기서 write(fd, (unsigned char *) chars, length) 의 write 는

open(2), creat(2), socket(2), accept(2) 등으로 생성한 file descriptor로 데이터 쓰기 또는 전송합니다. 파일에 쓰기를 하면 파일의 쓰기 또는 읽기 위치가 쓴 size만큼 뒤로 이동합니다. write(2)함수는 -1이 return되지 않는 한, 웬만하면 count만큼 정상적으로 write됩니다. O_APPEND flag로 파일을 open했다면 현재 위치가 아닌 항상 파일의 끝에 쓰기가 일어납니다.

MainActivity

package com.example.segmentt;

import androidx.appcompat.app.AppCompatActivity;

import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;

import com.example.segmentt.databinding.ActivityMainBinding;

public class MainActivity extends AppCompatActivity {

    // Used to load the 'segmentt' library on application startup.
    static {System.loadLibrary("segmentt");}

    // JNI methods
    private native static int openDriver(String path);
    private native static void closeDriver();
    private native static void writeDriver(byte[] data, int length);

    int data_int, i;
    boolean mThreadRun, mStart;
    SegmentThread mSegThread;

    @Override
    protected void onPause() {
        closeDriver();
        mThreadRun = false;
        mSegThread = null;
        super.onPause();
    }

		// 액티비티가 Resumed 이면 드라이버를 열고 스레드를 실행시킴. 
    @Override
    protected void onResume() {
        if (openDriver("/dev/sm9s5422_segment") < 0) {
            Toast.makeText(MainActivity.this,
                    "Driver Open Failed", Toast.LENGTH_SHORT).show();
        }
        mThreadRun = true;
        mSegThread = new SegmentThread();
        mSegThread.start();

        super.onResume();
    }

		// 버튼일 눌리면 EditText 에 있는 수를 data_int 에 저장, mStart 을 true 로 바꿈.
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Button btn = (Button) findViewById(R.id.button1);
        btn.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                String str = ((EditText) findViewById(R.id.editText1)).getText().toString();
                try {
                    data_int = Integer.parseInt(str);
                    mStart = true;
                } catch (NumberFormatException E) {
                    Toast.makeText(MainActivity.this, "Input Error", Toast.LENGTH_SHORT).show();
                }
            }
        });

    }
		
		// n이라는 byteArray 을 0으로 초기화. mStart 가 false 이면 LED 을 0으로 설정. (불 꺼짐)
		// mStart 가 true 이면 LED 위치에 맞게 값을 준다. 이 때 반복문을 사용한다
				// 반복문을 빠르게 돌면서 LED 에 불이 계속 들어와 있는 것처럼 보이게 한다.
		// data_int 가 양수이면 계속 1씩 줄인다. 
		// 그리고 다시 반복문을 돌기 때문에 LED 의 총 수는 1씩 줄어드는 것처럼 보인다.
    private class SegmentThread extends Thread {
        @Override
        public void run() {
            super.run();
            while (mThreadRun) {
                byte[] n = {0, 0, 0, 0, 0, 0,};

                if (mStart == false) {
                    writeDriver(n, n.length);
                } else {
                    for(i=0; i<100; i++){
                        n[0] = (byte) (data_int % 1000000 / 100000);
                        n[1] = (byte) (data_int % 100000 / 10000);
                        n[2] = (byte) (data_int % 10000 / 1000);
                        n[3] = (byte) (data_int % 1000 / 100);
                        n[4] = (byte) (data_int % 100 / 10);
                        n[5] = (byte) (data_int % 10);
                        writeDriver(n, n.length);
                    }
                    if (data_int > 0) {
                        data_int--;
                    }
                }

            }
        }
    }

}

 

결과

'CrossCompile' 카테고리의 다른 글

Gaussian Blur (OpenCL 없이 CPU 에서 실습)  (0) 2022.11.12
openCL에서 Vector Add 테스트하기  (1) 2022.11.12
Linux Device Driver (LED)  (0) 2022.11.11
Cross-compile & Linking & Make  (2) 2022.11.11
GCC 가 무엇일까요?  (0) 2022.11.10