관리 메뉴

드럼치는 프로그래머

[JNI/NDK] C/C++ 코드, 안드로이드 세상으로 마이그레이션 하기 본문

★─Programing/☆─JNI | NDK

[JNI/NDK] C/C++ 코드, 안드로이드 세상으로 마이그레이션 하기

드럼치는한동이 2013. 6. 19. 09:03

이번 컬럼에서는 JNI 코드를 작성하는데 도움을 주는 툴 사용법과 JNI API 중 사용빈도가 높은 타입변환, Java 객체 생성, Java 메소드 호출 및 필드를 다루는 방법에 대해 정리 하고 네이티브 라이브러리를 손쉽게 이클립스 IDE 환경을 이용하여 개발하는 방법에 대해 알아보도록 하겠다.

 

지난 컬럼부터 제시된 가상시나리오의완결판인 이번 컬럼은 실제 프로젝트 진행중에 벌어질 수 있는 문제상황을 제시하고, 동시에 이 문제를 해결해 나가는 과정을 중심으로 이야기를 진행하고자 한다.

 

등장인물

● 허우대 대리 : 몇 달 전 경력사원 공채로 새로 입사한 인물로 Java를 사용해봤다는 이유만으로 난생 처음으로 안드로이드 플랫폼에 기존에 c++로 작성한 소스를 마이그레이션 하는 업무를 맡는다. 허우대는 멀쩡하지만 성과가 없다고 일정만 차장으로 부터 허우대라는 별명을 얻었다.

● 신익한 선배 : 허우대 대리의 학교 선배로 모바일 분야에서 안해본게 없는 대단한 실력자 이지만, 일을 너무 사랑하는 나머지 인간관계는 별로 소질이 없어 보이는 시니컬한 성격의 소유자, 허우대 대리의 질문에 매번 퉁명스럽게 대답 하지만 그 속마음에는 후배를 스스로 깨우치게 하려는 나름의 철학을 가지고 있다.

● 일정만 차장 : 이번 프로젝트의 책임자로, 일에 대한 책임감이 투철하지만 상냥한 성격은 아니다. 특히 일정을 어기는 사람을 무척 싫어한다.


선배, JNI 작업 어디부터 시작해야 하는거지?
벌써 일정만 차장과 약속한 일주일의 시간 중 3일을 써버린 허우대 대리는 다급한 마음에 신익한 선배를 찾아가 자신이 여러 방면으로 조사한 결과 JNI를 이용하여 기존 C소스를 마이그레이션 하기로 했는데, 이후 어떤 식으로 작업을 진행해야 할지에 대해 조언을 구하게 된다.

“선배 이제 네이티브 라이브러리 빌드 방법, 배포 방법은 찾았는데 실제로 막상 기존 소스에 JNI부분을 넣어야 할텐데 어디부터 손을 대야 할지 막막하네.”

신익한 선배는 허우대 대리의 표정에서 그의 다급한 속마음을 읽었는지 평소와는 다르게 친절하게 화이트보드를 꺼내 <그림 1>을 그리기 시작했다.

 

 

<그림 1> JNI 작업 순서

“자, <그림 1>을 봐. 이게 일반적인 JNI 작업 순서야. Sax Parser.java 라는 인터페이스를 구현하는 과정은 다음처럼 예를 들어 볼께”

(1) 우선 SaxParser.java 파일에 메소드를 선언한다.
     지난 시간에 살펴본것 처럼 여기는 선언부만 있고 실제 구현은 C모듈에서 담당하게 된다.

(2) (1)에서 작성한 SaxParser.java파일을 javac를 이용해 컴파일한다. 여기서 Java 파일을 컴파일하는 이유는 다음 과정에서 javah 도구를 사용해 C헤더파일을 생성하기 위함이지, 실제 사용하기 위한 바이너리를 빌드하기 위함은 아니다.

(3) Javah 도구를 사용하여 (2)에서 만들어진 바이트 코드(.class)로 C모듈에서 사용할 헤더 파일을 생성한다. (<그림 1>에서 2.javah 라고 표현한 화살표가 이 과정에 해당한다.)

(4) C모듈에서 Java 객체를 생성하거나 특정 메소드를 호출하기 위해서는 이에 관한 시그니처가 필요한데 Javap라는 tool을 이용해 해당 시그니처를 생성한다.

(5) (3)과정에서 생성한 SaxParser.h 를 SaxParser.c 파일에서 구현한다.

(6) (5)과정에서 작성한 C 모듈을 컴파일 하는데 안드로이드 플랫폼에서 동작하는 바이너리를 제작하기 위해서는 안드로이드 NDK에서 제공하는 툴체인을 사용한다. <그림 1>로 보면 4.gcc라고 표현한 화살표가 이 과정에 해당한다.

(7) SaxApp.java 파일에서 SaxParser class를 import해 C 모듈인 libSaxParser.so에서 제공하는 기능을 사용할 수 있게된다.

신익한 선배의 설명을 경청한 허우대 대리는 JNI로 C/C++ 함수를 사용하기 위해서는 Java 에서 사용할 인터페이스를 선언하고 이 형태에 맞게 C/C++ 모듈을 구성해야 하는데, 이번 프로젝트처럼 기존에 작성한 코드를 재사용하는 경우라면, 이 JNI 모듈은 특별한 내용를 가지지 않고 Java 데이터 타입을 입력받아 기존 C/C++ 함수를 다시 호출하는 wrapper로 동작하면 되겠다는 구상이 떠올랐다. 즉, 아래의 Adaptor 패턴과 같은 구조가 될 것 이다 아래 Adaptor로 표현된 부분이 JNI로 구현한 네이티브 코드 부분이고 Adaptee가 기존 C/C++ 소스다.

 

 

<그림 2> UML로 표현한 Adaptor 패턴

 

허우대 대리는 신익한 선배의 설명을 듣고 Adaptor 패턴을 바로 떠올린 자신이 참 기특하다는 생각을 하며 기존의 C 인터페이스를 어떻게 Java인터페이스로 재설계 할지 고민에 빠졌다.

Java 인터페이스 설계하기
허우대 대리와 함께 기존의 C로 작성한 함수 인터페이스를 살펴보자. 이달의 디스켓을 통해 제공되는 SaxParser.h, SaxPa rser.c 파일을 열어서 허우대 대리와 함께 고민해 보기로 하자.

int parse(const char * pszContents, void * pUserData
 ,handler_cb_list* pContentHandlerCBlist);

이 함수는 첫번째 인자로 xml 내용을 입력받아 마지막 인자로 등록한 콜백 함수를 통한 xml문서의 파싱결과를 알리는 기능을 수행한다. 즉, 순서대로 xml 문서를 읽으면서 xml 문서가 최초 시작되거나 tag가 시작될 때 마다 마지막 인자로 등록한 콜백 함수를 통해 이 내용을 알려준다. 물론 실제 .c 파일의 구현은 간단하게 pContentHandlerCBlist를 통해 등록한 startElement함수를 한번 호출하도록 작성된 간단한 샘플이지만 위와 같이 가정하고, 이 함수를 Java 인터페이스로 재설계해보자.

함수 첫번째 인자는 xml 문서를 null로 종료되는 문자열 형태로 을 입력 받는다. 때문에 간단히 java의 String 타입으로 바꿀 수 있다. 두번째 인자인 void*는 함수 내부에서는 기능상 사용하지 않고 함수 세번째 인자로 등록한 콜백함수를 통해 그대로 넘겨주는 역할만을 수행한다. 호출자에 의해 다양한 용도로 사용하겠다는 의도로 void* 타입을 이용하고 있기 때문에 그대로 Java의 Object type으로 바꾸기로 한다.

세번째 인자인 handler_cb_list* 는 아래와 같이 콜백 함수의 주소를 가지는 구조체다. 이 데이터를 인자로 넘길 수 있도록 Java에 이 struct와 대응되는 iDocHandler라는 interface를 추가로 정의하도록 한다. 구조체 선언부는 <리스트 1>과 같다.

 

<리스트 1> SaxParser.h 의 handler_cb_list 구조체 선언부
typedef struct tag_elem_attr{

char* pszName;

char* pszValue;

}elem_attr;

 

typedef struct tag_elem_attrs{

elem_attr * attrs;

int nCnt;

}elem_attrs;

 

typedef int (*PFNSTARTDOCUMENTCB)(void * pUserData);

typedef int (*PFNENDDOCUMENTCB)(void * pUserData);

typedef int (*PFNSTARTELEMENTCB)(void * pUserData, char * pszName, elem_attrs* pAttributes);

typedef int (*PFNENDELEMENTCB)(void * pUserData, char * pszName );

 

typedef struct tag_handler_cb_list

{

PFNSTARTDOCUMENTCBpfnStartDocument;

PFNENDDOCUMENTCBpfnEndDocument;

PFNSTARTELEMENTCBpfnStartElement;

PFNENDELEMENTCBpfnEndElement;

}handler_cb_list;

<리스트 2> SaxParser.java

public class SaxParser{

 public native int parseString(String contents,Object userData,iDocHandler handler);

 class Attr{
  String name;
  String value;
 }

 interface iDocHandler{
  public int startDocCB(Object userData);
  public int endDocCB(Object userData);
  public int startElemCB(Object userData,String name,ArrayList<Attr> attrList);
  public int endElemCB(Object userData,String name);
 }
}

 

반환값의 경우 그대로 int를 사용하도록 한다. <리스트 1>의 c 인터페이스를 Java 형태로 다시 설계한 내용은 <리스트 2>와 같다.

 

C 인터페이스를 Java 인터페이스로 옮기기
C함수를 Java 함수로 옮기는 과정은 타입 캐스팅처럼 단순히 C에서 사용한 인자가 몇 바이트짜리 타입이니 Java에 몇 바이트 타입으로 옮긴다는 정량적인 계산으로 접근해서는 안된다. 그 이유는 실제 C함수에서 인자로 char*를 사용한 경우를 예를 들면 이 인자가 실제로 null로 종료하는 문자열로 사용될 수도 있고, 바이너리 데이터의 시작주소를 나타내는데 사용할 수도 있기 때문이다. void*나 void**의 경우, 함수 설계자에 의해 보다 다양한 용도로 설계될 수 있을 것이다.

이처럼 C언어의 경우, 메모리 주소 접근방법을 제공하는 언어적인 특성과 조금은 느슨하게 데이터 타입을 다루는 경향을 보이는 것을 이유로 Java와 비교해 덜 명확한 타입을 시그니처(signature)에 사용할 수 있기 때문에 정략적인 접근이 아니라 인터페이스에 대한 이해를 바탕으로 Java인터페이스 설계가 이뤄져야 한다.

특히 타인이 설계한 인터페이스를 Java로 옮겨야 할 경우, 레퍼런스 문서나 주석상으로 드러나지 않는 내용에 대해서도 관심을 가져주어야 한다.

 

Java 인터페이스로부터 JNI 함수 선언, 시그니처 얻기

JNI를 사용하기 위해서는 name mangling 규약, 함수 시그니처, Java ↔ C 간 타입변환에 대해 정확하게 이해하고, 이 부분에 대해 손수 작성할 수 있어야 한다. 이 부분은 실수하기 쉽고, 귀찮은 부분이기 때문에 Java SDK에는 JNI를 위해 몇가지 도구를 제공한다.

Javah는 Java 파일을 이용하여 JNI 함수 선언부인 .h 파일을 자동으로 생성해주는 도구로, 이때 인자와 반환값은 모두 자동으로 C 스타일로 변경되어야 한다. 메소드 이름의 경우 Java_<패키지명>_<class명>_<method명> 의 이름으로 변경된다.

이는 JNI name mangling 규약을 따르는 것으로. 이에 대한 자세한 내용은 이번 컬럼의 마지막에 제시된 참고문헌의 JNI 스펙 부분을 참고하도록 한다. 또한 이달의 디스켓으로 제공되는 SaxParser.java에 기술한 인터페이스를 가지고 JNI 헤더파일을 만들어보자.

javah 도구를 사용하기 위해서는 Java파일이 아닌 컴파일 된 .class이 필요하기 때문에 먼저 SaxParser.java를 컴파일한다. 콘솔에서 #javah <class명>을 입력한다. <화면 1>과 같이 아무런 메시지가 없으면 성공한 것이다.

 

 

<화면 1> Javah 실행 화면

 

<화면 1>을 보면 i_SaxParser.h,kr_co_imaso_woogi_Sax Parser_Attr.h,kr_co_imaso_woogi_SaxParser_iDocHandler.h 파일이 생성되었음을 확인할 수 있다. 그렇다면 이번에는 kr_co_imaso_woogi_SaxParser.h를 열어 내용을 확인해 보자. <리스트 3>과 같이 JNIEXPORT라는 키워드로 시작하는 C 함수 선언부를 확인할 수 있다.

 

<리스트 3>에서 함수선언부를 주의깊게 살펴보면 첫번째 인자 JNIEnv*와 두번째 인자 jobject가 추가된 것을 확인 할 수 있다. JNI 함수의 경우 첫번째, 두번째 인자는 JNI 규약에 의해서 예약되어 있고 세번째 인자부터 실제 메소드에서 사용하는 인자다. 여기서 첫번재 인자는 JNI 인터페이스로 일단 JNI API 함수 포인터 집합의 포인터다. 두번째 인자는 static 메소드의 경우는 class의 reference가 되고 일반 메소드의 경우 object의 reference가 된다. 우리가 정의한 메소드의 경우 일반 메소드기 때문에 object의 reference인데, 이것은 C++ 의 this 정도로 생각 할 수 있겠다.

<리스트 3>에서 javah를 이용해 얻은 함수 구현부에서 JNI 에서 제공하는 여러 API를 이용하여 Java 메소드를 호출 할 수 있고 특정 클래스의 인스턴스를 생성할 수도 있는데 이를 위해서는 메소드, 클래스 이름 이외에도 Java VM signature 정보가 필요하다. Java VM signature는 다음과 같은 규칙에 의해 결정된다.

 

 

<표 1> VM 시그니처 규칙

 

<표 1>의 내용을 대입해 예를 들어보면, Java 메소드 ‘long f (int n, String s, int[] arr);’ 의 경우 시그니처는 다음과 같다. 

(ILjava/lang/String;[I)J 

Java SDK는 이렇게 시그니처 정보를 얻어오는 도구를 제공한다. Javap는 원래 바이트 코드를 디스어셈블 하여 Java 코드로 보여주는 도구인데 부가적으로 시그니처 정보 등을 얻을 수 있다. 이번에는 콘솔에서 #javap ?s -p <클래스이름>을 입력해보자. 실제로 <화면 2>와 같이 iDocHandler interface의 시그니처를 얻어낼 수 있다. 여기에서 주의할 점은 iDocHandler처럼 inner class로 정의된 경우 클래스 이름은 <outerclass명’$’class명>으로도 나타낼 수 있다는 점이다.

 

 

<화면 2> Javap 실행 화면

 

C 함수 선언부와 필요한 Java class 시그니처를 얻었으니 이번에는 허우대 대리와 함께 실제 C 함수를 구현해보자.

C 함수 구현하기
kr_co_imaso_woogi_SaxParser.h 파일을 열어 parseString 함수 선언부를 다시 살펴보자.

JNIEXPORT jint JNICALL Java_kr_co_imaso_woogi_SaxParser _parseString
                        (JNIEnv *, jobject, jstring, jobject, jobject);

Java 메소드로부터 jstring, jobject와 같은 JNI 타입으로 Java 인자를 받게되는데, JNI 타입은 Java primitive type의 경우 Java type 에 ‘j ‘접두어가 붙은 형태가 된다.

 


<표 2> JNI 타입

 

레퍼런스 타입의 경우 아래 그림과 같은 모두 jobject로 처리할 수도 있고 Class나 String의 경우 jobject를 상속받은 jclass나 jstring 타입으로 처리할 수도 있다.

실제 이 함수의 구현부는 처음에 허우대 대리가 구상한 것처럼 <리스트 4>와 같이 단순히 기존 C 함수를 호출하는 형태로 구성될 것이다.

 


<그림 3> JNI reference type

 

<리스트 4> JNI 함수 구성

JNIEXPORT jint JNICALL Java_com_innoace_pmd_SaxParser_parseString
   (JNIEnv * env, jobject thiz, jstring contents, jobject userData , jobject iDocHandler)
{
… 기존 parser 함수 호출…
}

 

그러나 곧 허우대 대리는 아래 두가지 문제에 봉착하게 된다. 첫번째 문제는 JNI 타입의 인자를 아래와 같이 기존 C 타입으로 어떻게 바꿔서 호출하느냐이다.

int parse(const char * pszContents, void * pUserData
 ,handler_cb_list* pContentHandlerCBlist)

위 함수의 세번째 인자 jstring contents를 const char* pszContents으로 변경하려면,  GetStringUTFChars 라는 JNI 함수를 사용해 const char*로 변환이 가능하다. JNI 는 다양한 문자열 관련 함수를 제공하는데 자세한 내용은 이번 컬럼의 참고자료 중 JNI 스펙을 참고하기 바란다.

네번째 인자 jobject userData의 경우 void* 타입으로 변경하기 위해서는 부가 작업이 필요 없다. 이 인자의 경우 내부적으로 사용되지 않고 callback함수로 전달되는 용도이기 때문에 data 손실 없이 전달하기만 하면 되는데. jni.h 파일에서  jobject를 찾아보면 void*로 타입 정의 되어있기 때문이다. 

두번째 문제는’ iDocHandler 인터페이스를 입력받아서 C 타입 콜백으로 어떻게 연결할까?’ 인데, 실제 C 형태로 콜백을 정의한 뒤 이 함수 내부에서 iDocHandler의 메소드를 다시 호출하도록 한다. <리스트 5>는 이에 대한 대략적인 구현이다. 이달의 디스켓으로 제공되는 kr_co_imaso_woogi_SaxParser.c 파일을 열어 자세한 내용을 확인해 볼 수 있다. 또한 JNI함수를 사용하는 대표적인 예는 지면관계상 간략히 정리하여 이달의 디스켓으로 포함시켰으니 꼭 확인하길 바란다.

 

<리스트 5> 콜백 구성

int startElementCb(void * pUserData, char * pszName, _ATTRIBUTES * pAttributes)
{
… 
//C함수에서 java callback 메소드를 호출한다.
return env->CallIntMethod(g_callbackData.iDocHandler,startElemId
,g_callbackData.paramUserData,nameElemParm,attListParm);

}

JNIEXPORT jint JNICALL Java_kr_co_imaso_woogi_SaxParser_parseString
  (JNIEnv* env, jobject thiz, jstring contents, jobject userData, jobject iDocHandler)
{
 …
// 실제 C함수를 callback list에 등록한다.
 contentHandlerCBList->pfnStartElement=startElementCb;
 …

 return parse(_contents,_pUserData,&contentHandlerCBList);
}

 

이클립스 IDE에서 네이티브 라이브러리 빌드하기

JNI 함수작업이 어느 정도 마무리 되자 허우대 대리는 라이브러리 기능 검증을 위한 추가 테스트 애플리케이션을 작성해 보았다. 허우대 대리의 작업 내용은 다음과 같다. 네이티브 라이브러리 수정을 위해서 시그윈 콘솔 환경을 이용해 so 파일을 빌드하고 다시 테스트 애플리케이션 수정을 위해서는 이클립스 환경을  사용하였다. 테스트 중 문제가 발생하면 다시 시그윈 콘솔 환경을 열었다.

매 수정시마다 이러한 과정을 반복하는 것은 여간 불편한것이 아니다. 하지만 ‘필요는 발명의 어머니’라 했던가. 문득 허우대 대리는 이클립스는 범용 통합 개발 환경이지 Java 전용 개발 환경이 아니라는 생각이 들자 머리속에는 안드로이드 NDK와 이클립스 IDE를 연동할 아이디어가 떠올랐다. 지금부터 허우대 대리와 함께 안드로이드 NDK 툴체인을 이클립스 환경과 연동한 네이티브 라이브러리를 빌드해 보도록 하자.

우선 http://www.eclipse.org/cdt/downloads.php 에서 이클립스 환경에서 C/C++ 빌드 하기 위해 CDT를 추가로 설치한다. 테스트 애플리케이션을 위한 안드로이드 프로젝트를 생성한 후, 메뉴에서 File → New → Other (이클립스 3.5 Galieo 기준)를 선택하고, <화면 3>과 같이 Convert to C/C++ Project를 선택한다. 프로젝트 종류로 Makefile project를 선택한 다음, 툴체인을 선택하는데 안드로이드 NDK에서 제공하는 툴체인은 목록에 없는 관계로 Other ToolChain을 고른다.

다음으로는 이달의 디스켓으로 제공된 IDE 폴더의 Makefile을 project의 최상위 폴더로 복사한 뒤, ANDROID_NDK_ BASE 부분을 자신의 작업 환경에 맞게 설정한다. NDK 1.5를 사용할 경우 추가로 lib 경로와 include 경로도 변경해야 한다.

 

 

<화면 3> C/C++ 프로젝트 전환

 

 

<화면 4> C/C++ 프로젝트 툴체인 선택

 

<리스트 6> Makefile 내용

# The path (cygwin) to the NDK
ANDROID_NDK_BASE = /cygdrive/d/android_ndk/android-ndk-1.6_r1-windows/android-ndk-1.6_r1

# The name of the native library
LIBNAME = libsax.so

# Find all the C++ sources in the native folder
SOURCES = $(wildcard native/*.c)

 

<리스트 6>을 보면 native폴더 이하의 모든 *.c 파일을 빌드하게 되는데 Project 최상위 폴더에 native라는 이름의 폴더를 생성하고 NDK로 빌드 해야할 c/c++ 파일을 여기에 위치 시킨다. Makefile은 컴파일한 .so파일을 안드로이드 개발킷에 의해 apk로 묶일 수 있도록 프로젝트 최상위 폴더 이하 lib/armeabi 폴더로 자동 카피하도록 기술되어있다.

<화면 5>는 이달의 디스켓 IDE폴더에 담긴 project 화면이다. <화면 5>를 참고해 자신만의 프로젝트를 구성해보도록 하자.

 

 

<화면 5> 이클립스 환경 디렉토리 구성

 

[출처] https://www.imaso.co.kr/?doc=bbs/gnuboard.php&bo_table=article&page=40&wr_id=34479

Comments