특정 버전의 FFmpeg를 Android용으로 크로스컴파일하는 방법 정리

2021. 3. 31. 18:40Programming/Android

반응형

zerodice0/ffmpeg-android-build 브랜치를 참고해주세요.

Android NDK을 사용해서 특정 버전의 ffmpeg을 빌드하는 방법에 대해 알아봅시다. 최신 버전의 ffmpeg라면 ffmpeg-android-buildbuild_android.sh/build_android_64.sh만 실행시키면 간단히 빌드되므로 굳이 이 글을 참조할 필요는 없습니다.

이렇게 별도의 브랜치를 작성하는 이유는, 최근 ffmpeg를 안드로이드 용으로 크로스 컴파일하는 방법을 검색하면 독립실행형 툴체인(Stand-alone toolchain)을 사용하는 방법만 나오기 때문입니다. 독립 실행형 툴체인은 이미 진즉에 Depreacted 됐죠. 그래서 예전 버전의 NDK를 사용하지 않으면 그 방법들로는 빌드할 수가 없습니다. 사실 안드로이드의 아키텍처는 크게 변할 일이 없기때문에, 많은 오픈소스 라이브러리가 최신 버전의 ffmpeg을 크로스컴파일 해주고 있어요. 따라서 직접 ffmpeg를 빌드할 일은 없을겁니다. 하지만 당신이 이 글을 보고 있는 이유는 그런 상황을 맞이했기 때문이겠죠. 예를들면 레거시 코드가, 특정 ffmpeg버전을 특이한 옵션을 사용해서 빌드한 Shared-library를 참조하고 있다던가 말이죠. 으으, 얘기만 들어도 소름끼치네요.

다행인지 불행인지 저도 지금 이 글을 보고있는 여러분과 비슷한 상황에 처했습니다. 슬픔은 나누면 반이 된다는데 말이죠. 어때요, 좀 덜 슬퍼졌나요? 아무튼 서론은 길었지만, 결국 이 글은 특정 버전의 ffmpeg를 wsl에서 빌드하는 방법에 대해 설명할겁니다.

~ 해보지는 않았지만 Mac에서도 아마 제대로 동작하긴 할거에요. 결국은 리눅스 기반의 운영체제에서 Android용으로 크로스 컴파일하는 방법에 대한 얘기니까요.

Mac에서 사용하기 위해서는 sed 실행구문이 조금 달라 에러가 발생합니다. 또, M1칩이 탑재된 맥에서는 크로스 컴파일이 안되는 것 같네요.

퇴근시간이 다가오고 있기 때문에 별로 길게 작성하고싶은 마음은 없지만, 혹시라도 글이 길어질까봐 간단하게 절차를 먼저 설명해볼께요. 이 글에서 설명하는 FFmpeg의 빌드 방법은 아래의 절차에 따라 진행됩니다.

  1. Android NDK 다운로드
  2. 특정 버전의 ffmpeg 다운로드
    • ffmpeg.org에서 ffmpeg를 다운로드합니다.
    • 더 오래된 버전은 ffmpeg.org/olddownload에서 다운로드하면 됩니다.
    • 최신 버전의 소스코드는 GitHub에서 받으세요.
  3. ffmpeg의 소스코드에 build_android_64.sh를 복사합니다.
  4. build_android_64.sh 파일을 열어 NDK 경로와 Prefix값을 변경한 뒤 실행해줍니다.
  5. make install을 실행해서 설치한 후, prefix의 경로에 생성된 결과물을 확인합니다.

1번~3번 과정은 별도로 설명할 내용이 없어서, 4번부터 살펴보도록 합시다.

build_android_64.sh 수정

# 지원하는 최소 Android 버전
export MIN=21
export ANDROID_NDK_PLATFORM=android-28
# NDK 경로
export NDK=/home/zerodice0/workspace/android-ndk-r21e

line 12~line 16에서는 #1 과정에서 다운받은 Android NDK의 경로와 최소버전, 그리고 플랫폼 버전을 지정합니다.

export CC=$TOOLCHAIN/bin/$ARCH-linux-android$MIN-clang
export CXX=$TOOLCHAIN/bin/$ARCH-linux-android$MIN-clang++

line 23, line 24에서 gcc 대신 clang을 사용하는 것도 슬쩍 봐둡시다. 해당 경로에 가보면 NDK toolchain에서 더 이상 gcc를 제공하지 않기 때문에, clang/clang++을 명시적으로 지정하지 않으면 gcc를 찾다가 에러가 발생합니다.

sed  -i "s/SLIBNAME_WITH_MAJOR='\$(SLIBNAME).\$(LIBMAJOR)'/SLIBNAME_WITH_MAJOR='\$(SLIBPREF)\$(FULLNAME)-\$(LIBMAJOR)\$(SLIBSUF)'/" configure
sed  -i "s/LIB_INSTALL_EXTRA_CMD='\$\$(RANLIB) \"\$(LIBDIR)\\/\$(LIBNAME)\"'/LIB_INSTALL_EXTRA_CMD='\$\$(RANLIB) \"\$(LIBDIR)\\/\$(LIBNAME)\"'/" configure
sed  -i "s/SLIB_INSTALL_NAME='\$(SLIBNAME_WITH_VERSION)'/SLIB_INSTALL_NAME='\$(SLIBNAME_WITH_MAJOR)'/" configure
sed  -i "s/SLIB_INSTALL_LINKS='\$(SLIBNAME_WITH_MAJOR) \$(SLIBNAME)'/SLIB_INSTALL_LINKS='\$(SLIBNAME)'/" configure

line 34~line 37에서는 sed를 사용하여 configure의 내용을 직접 수정하고 있습니다. 이렇게 수정하지 않으면 .so파일이 생성되지 않으니 주의해주세요.

./configure \
  --prefix=$PREFIX \
  --ar=$AR \
  --as=$AS \
  --cc=$CC \
  --cxx=$CXX \
  --nm=$NM \
  --ranlib=$RANLIB \
  --strip=$STRIP \
  --arch=$ARCH \
  --target-os=android \
  --enable-cross-compile \
  --disable-asm \
  --enable-shared \
  --disable-static \
  --disable-ffprobe \
  --disable-ffplay \
  --disable-ffmpeg \
  --disable-debug \
  --disable-symver \
  --disable-stripping \
  --extra-cflags="-Os -fpic $OPTIMIZE_CFLAGS" \
  --extra-ldflags="$ADDI_LDFLAGS"

line 38~line 60에서는 ffmpeg의 옵션을 지정해줍니다. 필요한 옵션을 지정해주면 되는데, 일부 옵션의 경우에는 특정 버전에서 에러가 발생할 수 있으니 주의해주세요. define을 사용해서 에러에 대응할 수 있는 경우에는 아래 라인에서 처리합니다.

target-os는 android로 설정해주는 경우 각 라이브러리의 심볼릭 링크가 생성되지 않습니다. 심볼릭 링크가 필요한 경우에는 target-os를 linux로 설정해주세요. 윈도우 환경에서는 심볼릭 링크를 지원하지 않으므로, 하드카피 시 링크에 걸린 파일이 복사되기때문에 용량이 두 배가 될 수 있으니 주의합시다.

sed  -i "s/#define HAVE_TRUNC 0/#define HAVE_TRUNC 1/" config.h
sed  -i "s/#define HAVE_TRUNCF 0/#define HAVE_TRUNCF 1/" config.h
sed  -i "s/#define HAVE_RINT 0/#define HAVE_RINT 1/" config.h
sed  -i "s/#define HAVE_LRINT 0/#define HAVE_LRINT 1/" config.h
sed  -i "s/#define HAVE_LRINTF 0/#define HAVE_LRINTF 1/" config.h
sed  -i "s/#define HAVE_ROUND 0/#define HAVE_ROUND 1/" config.h
sed  -i "s/#define HAVE_ROUNDF 0/#define HAVE_ROUNDF 1/" config.h
sed  -i "s/#define HAVE_CBRT 0/#define HAVE_CBRT 1/" config.h
sed  -i "s/#define HAVE_CBRTF 0/#define HAVE_CBRTF 1/" config.h
sed  -i "s/#define HAVE_COPYSIGN 0/#define HAVE_COPYSIGN 1/" config.h
sed  -i "s/#define HAVE_ERF 0/#define HAVE_ERF 1/" config.h
sed  -i "s/#define HAVE_HYPOT 0/#define HAVE_HYPOT 1/" config.h
sed  -i "s/#define HAVE_ISNAN 0/#define HAVE_ISNAN 1/" config.h
sed  -i "s/#define HAVE_ISFINITE 0/#define HAVE_ISFINITE 1/" config.h
sed  -i "s/#define HAVE_INET_ATON 0/#define HAVE_INET_ATON 1/" config.h
sed  -i "s/#define getenv(x) NULL/\\/\\/ #define getenv(x) NULL/" config.h

configure 혹은 make 실행 시 config.hdefine을 통해 회피할 수 있는 경우가 있습니다. 예를 들면 아래의 에러같은 것들이죠.

libavutil/time_internal.h:26:26: error: static declaration of 'gmtime_r' follows non-static declaration
static inline struct tm *gmtime_r(const time_t* clock, struct tm *result)
                         ^
/home/zerodice0/workspace/android-ndk-r21e/toolchains/llvm/prebuilt/linux-x86_64/bin/../sysroot/usr/include/time.h:75:12: note: previous declaration is here
struct tm* gmtime_r(const time_t* __t, struct tm* __tm);
           ^
In file included from libavutil/parseutils.c:32:
libavutil/time_internal.h:37:26: error: static declaration of 'localtime_r' follows non-static declaration
static inline struct tm *localtime_r(const time_t* clock, struct tm *result)
                         ^
/home/zerodice0/workspace/android-ndk-r21e/toolchains/llvm/prebuilt/linux-x86_64/bin/../sysroot/usr/include/time.h:72:12: note: previous declaration is here
struct tm* localtime_r(const time_t* __t, struct tm* __tm);
           ^
2 errors generated.

에러가 발생한 libavutil/time_internal.h 파일을 열어보면, 아래와 같은 내용을 확인할 수 있습니다.

#if !HAVE_GMTIME_R && !defined(gmtime_r)
static inline struct tm *gmtime_r(const time_t* clock, struct tm *result)
{
    struct tm *ptr = gmtime(clock);
    if (!ptr)
        return NULL;
    *result = *ptr;
    return result;
}
#endif

#if !HAVE_LOCALTIME_R && !defined(localtime_r)
static inline struct tm *localtime_r(const time_t* clock, struct tm *result)
{
    struct tm *ptr = localtime(clock);
    if (!ptr)
        return NULL;
    *result = *ptr;
    return result;
}
#endif

잘 보면 위의 에러는 HAVE_GMTIME_R값과 HAVE_LOCALTIME_R값을 1로 설정해주면, 빌드시 참조하지 않기때문에 발생하지 않는다는 걸 알 수 있습니다. HAVE_GMTIME_R값과 HAVE_LOCALTIME_R값을 수정해주려면 config.h를 직접 수정해줘도 되지만, 혹시라도 다시 빌드하는 경우를 위해서 build_android_64.sh에 다음과 같은 sed 실행 구문을 추가해줄거에요.

sed     -i "s/#define HAVE_GMTIME_R 0/#define HAVE_GMTIME_R 1/" config.h
sed     -i "s/#define HAVE_LOCALTIME_R 0/#define HAVE_LOCALTIME_R 1/" config.h

이걸로 ffmpeg를 다시 빌드하는 경우에도 문제는 발생하지 않습니다.

빌드 결과물 확인

make를 실행해도 에러가 발생하지 않는다면, make install을 실행해서 빌드 결과물을 한 곳에 모아봅시다. 빌드 결과물은 Prefix로 지정한 경로에 저장되는데, 별도로 Prefix값을 수정하지 않았다면 ffmpeg 소스코드/android에 빌드 결과물이 생성될거에요. 이제 생성된 *.so 파일을 안드로이드 프로젝트에 넣고, 정상적으로 동작하는지 확인해볼 일만 남았습니다. :)


+ avformat 관련 런타임 에러

Fatal Exception: java.lang.UnsatisfiedLinkError
dlopen failed: cannot locate symbol "atexit" referenced by "libavformat.so"...

빌드는 어찌어찌 끝났지만, 실제로 영상을 디코딩해보니 위와 같은 에러가 발생하면서 크래시가 발생했습니다. FFmpeg 소스코드의 libavformat에서 atexit를 찾아보면, 다음과 같은 atexit 관련 함수명들을 찾을 수 있습니다. avisynth 관련된 모듈인가보네요.

static av_cold void avisynth_atexit_handler(void)
{
    AviSynthContext *avs = avs_ctx_list;

    while (avs) {
        AviSynthContext *next = avs->next;
        avisynth_context_destroy(avs);
        avs = next;
    }
    FreeLibrary(avs_library.library);

    avs_atexit_called = 1;
}

configure --help에서 avisynth와 관련된 플래그를 찾아보면 아래와 같은 플래그를 찾을 수 있습니다. 기본값은 [no]이고, configure를 실행할 때 --enable-avisynth 플래그를 사용하는 경우 활성화되네요. 만약 이 플래그가 포함되어있다면, 제거해주세요.

--enable-avisynth        enable reading of AviSynth script files [no]

이제 FFmpeg를 사용해서 영상을 디코딩해도, atexit 관련 런타임 에러가 발생하지 않습니다. :)

반응형