本篇主要以window开发环境为背景介绍一下NDK开发中需要掌握的交叉编译等基础知识,选window系统主要是照顾大多数读者,mac ,linux操作系统基本是同样适用的。
交叉编译就是在A平台编译出可以在B平台执行的文件,对于我们安卓开发者来说交叉编译就是在window或者mac或者linux系统上编译出可在安卓系统上运行的可执行文件,什么时候需要用到交叉编译呢?音视频开发基本都会用到ffmpeg,opengl es等三方库,这时我们就需要在window或者mac或者linux系统上编译出可在安卓系统执行的文件,这里可编译出静态库或者动态库使用,这时候就会用到交叉编译。
本篇虽然是一些基础的知识或者操作,但是对于后续三方库的编译移植,CMake的配置是很重要的,否则后续遇到没用过的三方库你会感觉无从下手编译,很多CMake的配置也只是会配置而不懂具体什么含义。
进行本篇学习请先自己配置好MinGW(C/C++编译器)编译环境并配置到系统环境变量中,这些都是基础的操作,自己查询一下配置好就可以了,此外还需要自己下载好安卓平台提供的交叉编译工具链,下载地址:安卓平台交叉编译工具,我下载的是17c版本的。
好了,进入本文的学习
下文相关代码均来自:相关演示代码
编译器名称 | 描述 |
---|---|
clang | clang 是一个C、C++、Object-C 的轻量级编译器。基于LLVM (LLVM是以C++编写而成的构架编译器的框架系统,可以说是一个用于开发编译器相关的库),对比gcc,它具有编译速度更快、编译产出更小等优点,但是某些软件在使用clang编译时候因为源码中内容的问题会出现错误 |
gcc | GNU C编译器。原本只能处理 C语言,很快扩展,变得可处理 C++`。(GNU计划,又称革奴计划。目标是创建一套完全自由的操作系统) |
g++ | GNU c++编译器,后缀为 .c的源文件,gcc把它当作是C程序,而g++当作是C++程序;后缀为 .cpp`的,两者都会认为是c++程序,g++会自动链接c++标准库stl,gcc不会,gcc不会定义__cplusplus宏,而g++会 |
C/C++文件要经过预处理、编译、汇编、和连接才能变成可执行文件。
过程名称 | 主要作用 |
---|---|
预处理 | 预处理阶段主要处理include和define等。它把#include包含进来的.h 文件插入到#include所在的位置,把源程序中使用到的用#define定义的宏用实际的字符串代替 |
编译 | 编译阶段,编译器检查代码的规范性、语法错误等,检查无误后,编译器把代码翻译成汇编语言。 |
汇编 | 汇编阶段把 .s文件翻译成二进制机器指令文件.o,这个阶段接收.c, .i, .s的文件都没有问题 |
连接 | 链接阶段,链接的是其余的函数库,比如我们自己编写的c/c++文件中用到了三方的函数库,在连接阶段就需要连接三方函数库,如果连接不到就会报错 |
比如在命令行中我们执行如下命令:
gcc -o d:\main C:\Users\wanglei55\Desktop\main.c
将C:\Users\wanglei55\Desktop\main.c文件编译为可执行文件,输出到d盘名称为main,整个编译过程就包括预处理、编译、汇编、和连接过程。
以上主要介绍了常用C/C++编译器的区别以及C/C++文件的编译过程,大体了解一下即可。
接下来我们具体看一下交叉编译的流程,我们先来看一下window平台怎么编译出可执行文件。
我们编写如下C文件:
main.c
#include
int main()
{
int nn = 55;
printf("nn = %d\n", nn);
return 0;
}
很简单,就是输出一些信息,接下来我们将main.c用gcc编译器编译为可执行文件,执行如下命令:
gcc -o d:\main C:\Users\wanglei55\Desktop\main.c
这样就会在d盘根目录生成mian.exe文件(window平台下会加入扩展名.exe,mac/linux平台下则不会)。
接下来我们就可以在命令行执行这个可执行文件了:
到这里我们成功的在window平台生成了可执行文件,试想一下我们可以将这个可执行文件拷贝到安卓手机上执行吗?估计很多同学及时没试过也会觉得不会执行,但是为什么呢?最简单的说法就是安卓平台不认识.exe结尾的可执行文件,那如果我是在linux平台编译出来的呢?不就没有.exe了吗?及时在linux平台编译出来的拷贝到安卓平台同样是不能执行的,主要原因是两个平台的CPU指令集不一样,根本就无法识别指令。
那我们怎么将main.c文件编译为可以在安卓平台执行的文件呢?这样就用到交叉编译了,这里就是在window平台编译出可在安卓平台执行的文件,既然要编译出在安卓平台执行的文件就需要用到目标平台提供的编译工具了,安卓提供的编译工具上面已经给出了下载链接,我下载的是17c版本的:
下载对应平台的zip包即可。
解压后(我解压到桌面上了)目录下toolchains目录就有对应平台的编译工具,安卓手机目前大部分cpu都是arm架构的了,我们以arm平台为例:
对应目录下就为我们提供了相应的gcc编译器。
接下来我们就用安卓平台提供的gcc编译器来编译main.c文件,这里要多说一下接下来的过程我会讲的细一些,因为这里很重要,很重要,很重要,我工作中接触很多同事不明白编译器的参数传入方式有问题只能百度,即使问题解决了也不明白咋回事,其实很简单,下面过程会讲解到,好了,我们具体看一下吧编译安卓平台可执行文件的过程吧:
首先cd到arm-linux-androideabi-gcc.exe所在目录,执行如下命令:
arm-linux-androideabi-gcc.exe -o d:\main C:\Users\wanglei55\Desktop\main.c
执行命令会报如下错误:
这种错误是说在我们编译的时候编译器找不到我们引入的stdio.h头文件,那怎么告诉编译器stdio.h头文件在哪呢?
我们可以通过如下方式给编译器指定头文件的查找目录:
指定格式 | 说明 |
---|---|
–sysroot=XX | 使用xx作为这一次编译的头文件与库文件的查找目录,查找下面的 usr/include usr/lib目录,–sysroot即可指定头文件又可指定库文件 |
-isysroot XX | 指定头文件查找目录,覆盖–sysroot ,查找 XX/usr/include目录下头文件 |
-isystem XX | 指定头文件查找路径(直接查找根目录) |
-IXX | 头文件查找目录,I是大写的 |
指定方式有多种,选取其中一种即可。
既然知道了头文件的指定方式,那我们得知道stdio.h的头文件目录,stdio.h头文件位于如下目录:android-ndk-r17c-windows-x86_64\android-ndk-r17c\sysroot\usr\include
既然也知道头文件的目录了,我们就可以指定了,这里通过-isystem方式指定:
arm-linux-androideabi-gcc.exe -isystem C:\Users\wanglei55\Desktop\android-ndk-r17c-windows-x86_64\android-ndk-r17c\sysroot\usr\include -o d:\main C:\Users\wanglei55\Desktop\main.c
执行上面命令,又会报如下错误:
又提示 asm/types.h头文件找不到,我们也没用这个头文件啊?这里实在stdio.h文件中引入的:
所以,我们还需要指定上面的头文件目录,头文件所在目录为:android-ndk-r17c-windows-x86_64\android-ndk-r17c\sysroot\usr\include\arm-linux-androideabi
修改命令如下,增加额外查找命令:
arm-linux-androideabi-gcc.exe -isystem C:\Users\wanglei55\Desktop\android-ndk-r17c-windows-x86_64\android-ndk-r17c\sysroot\usr\include -isystem C:\Users\wanglei55\Desktop\android-ndk-r17c-windows-x86_64\android-ndk-r17c\sysroot\usr\include\arm-linux-androideabi -o d:\main C:\Users\wanglei55\Desktop\main.c
运行,还是会报错:
这里我就直接说了,上面我们都是指定头文件的查找路径,但是运行程序需要具体的实现来完成作用,比如在main.c中并没有定义”printf”的函数实现,且在预编译中包含进的”stdio.h”中也只有该函数的声明。系统把这些函数实现都被做到名为libc.so
的动态库。那怎么指定查找具体实现库的目录呢?同样编译的时候可以指定库文件的查找目录:
指定方式 | 说明 |
---|---|
–sysroot=XX | 上面已经说过–sysroot=XX即可指定头文件又可指定库文件的查找目录 |
-LXX | 指定库文件查找目录 |
-lxx | 指定需要链接的库名,如果库名为libc.so,指定库名可简写:-lc ,lib和.so可去掉 |
printf这种常用的函数都在libc.so动态库中实现,那libc.so在哪个目录下呢?如下:
接下来我们需要在编译的时候指定相关库的查找路径以及库名,修改命令如下:
arm-linux-androideabi-gcc.exe --sysroot=C:\Users\wanglei55\Desktop\android-ndk-r17c-windows-x86_64\android-ndk-r17c\platforms\android-22\arch-arm -lc -isystem C:\Users\wanglei55\Desktop\android-ndk-r17c-windows-x86_64\android-ndk-r17c\sysroot\usr\include -isystem C:\Users\wanglei55\Desktop\android-ndk-r17c-windows-x86_64\android-ndk-r17c\sysroot\usr\include\arm-linux-androideabi -o d:\main C:\Users\wanglei55\Desktop\main.c
到这里我们就可以正常编译了,但是要编译出安卓平台可执行文件,编译时还需要加入 -pie ,完整命令如下:
arm-linux-androideabi-gcc.exe --sysroot=C:\Users\wanglei55\Desktop\android-ndk-r17c-windows-x86_64\android-ndk-r17c\platforms\android-22\arch-arm -lc -isystem C:\Users\wanglei55\Desktop\android-ndk-r17c-windows-x86_64\android-ndk-r17c\sysroot\usr\include -isystem C:\Users\wanglei55\Desktop\android-ndk-r17c-windows-x86_64\android-ndk-r17c\sysroot\usr\include\arm-linux-androideabi -pie -o d:\main C:\Users\wanglei55\Desktop\main.c
到此我们就可以编译出在安卓平台的可执行文件了:
整个过程是不是感觉很繁琐,其实最核心的就是编译过程中头文件和库文件目录的指定方式,让编译器可以找到对应文件,否则编译的时候就会报各种错误,如果你有ndk相关开发经验,应该会理解我们在cmake或者mk中的配置很多也是这种配置,就是为了让编译器编译的时候能查找到对应头文件或者库文件。
在安卓平台上我们用的最多的是动态库与静态库,我们先来看看怎么编译出动态库与静态库并在安卓平台使用。
源文件为:
test.c
#include
int test(){
return 999;
}
就是定义了一个test方法,返回int值999,我们将这个源文件在电脑上先编译为动态库,然后在安卓平台使用。
在编译动态库的时候我们需要指定 -fPIC -shared额外参数给编译器,完整命令如下:
arm-linux-androideabi-gcc.exe --sysroot=C:\Users\wanglei55\Desktop\android-ndk-r17c-windows-x86_64\android-ndk-r17c\platforms\android-22\arch-arm -lc -isystem C:\Users\wanglei55\Desktop\android-ndk-r17c-windows-x86_64\android-ndk-r17c\sysroot\usr\include -isystem C:\Users\wanglei55\Desktop\android-ndk-r17c-windows-x86_64\android-ndk-r17c\sysroot\usr\include\arm-linux-androideabi -fPIC -shared C:\Users\wanglei55\Desktop\test.c -o d:\libTest.so
这样就将桌面上的test.c源文件(test.c我放在了桌面)在d盘生成了libTest.so动态库,接下来我们在安卓工程中使用libTest.so动态库中的test()方法。
在工程中新疆如下目录,并将libTest.so拷贝进去:
如不特殊指定,使用三方的动态so库,目录名称必须为jniLibs。
接下来我们在native-lib.cpp文件中调用libTest.so库中的test()方法,由于是在c++文件中调用c文件编译为动态库中的test()方法,所以需要加上如下声明:
//C++中使用C代码需要这样声明,防止C++编译器将C中方法名编译后认不出了
extern "C"{
extern int test();
}
调用test()方法如下:
JNIEXPORT jstring JNICALL
Java_com_wanglei55_ndk_MainActivity_stringFromJNI(JNIEnv *env,jobject /* this */) {
LOGE("libTest.so动态库中test()方法返回值为:%d", test());
int i = test();
std::string s1 = std::to_string(i);
std::string s2 = "Hello from C++";
std::string s = s1 + s2;
return env->NewStringUTF(s.c_str());
}
接下来我们还要在CMakelist.txt文件中配置一下让编译器编译的时候能够找到libTest.so库文件:
# CMAKE_CXX_FLAGS 会传给c++编译器
# CMAKE_C_FLAGS 会传给c编译器
# CMAKE_SOURCE_DIR 的值是当前CMakelist.txt所在目录
#相当于-L给编译器传查找库文件的目录
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${CMAKE_SOURCE_DIR}/src/main/jniLibs/armeabi-v7a")
# 相当于用-l给编译器传库名字参数
target_link_libraries( # Specifies the target library.
native-lib
# libTest.so 可以去掉lib与.so
Test
# Links the target library to the log library
# included in the NDK.
${log-lib} )
上面已经给出了相应注释不在多余解释,到此就可以运行工程了,控制台输入对应信息:
到此我们就自己编译了一个so动态库并在安卓平台使用了动态库中的方法。
接下来我们新建staticTest.c文件:
#include
int staticTest(){
return 666;
}
我们将staticTest.c编译为静态库,编译静态库需要分两步:
第一步:先将源文件使用gcc编译为.o文件,命令如下:
arm-linux-androideabi-gcc.exe --sysroot=C:\Users\wanglei55\Desktop\android-ndk-r17c-windows-x86_64\android-ndk-r17c\platforms\android-22\arch-arm -lc -isystem C:\Users\wanglei55\Desktop\android-ndk-r17c-windows-x86_64\android-ndk-r17c\sysroot\usr\include -isystem C:\Users\wanglei55\Desktop\android-ndk-r17c-windows-x86_64\android-ndk-r17c\sysroot\usr\include\arm-linux-androideabi -fPIC -c C:\Users\wanglei55\Desktop\staticTest.c -o d:\staticTest.o
接下来使用ar工具将上一步生成的staticTest.o 文件生成libStaticTest.a静态库,命令如下(第一步生成的staticTest.o文件我自己又拷贝到桌面了):
arm-linux-androideabi-ar.exe r d:\libStaticTest.a C:\Users\wanglei55\Desktop\staticTest.o
ar与gcc位于同一目录:
接下来我们就可以将静态库导入安卓工程使用了,静态库不用非得放入jniLibs目录,可以自己决定放入的目录,我放入的目录如下:
然后我们就可以使用其中的int staticTest()方法了:
extern "C"{
extern int test();
extern int staticTest();//声明静态库中的方法
}
extern "C"
JNIEXPORT jstring JNICALL
Java_com_wanglei55_ndk_MainActivity_stringFromJNI(JNIEnv *env,jobject /* this */) {
LOGE("libTest.so动态库中test()方法返回值为:%d", test());
LOGE("libStaticTest.a静态库中staticTest()方法返回值为:%d", staticTest());
int i = test();
int j = staticTest();
std::string s1 = std::to_string(i);
std::string s2 = std::to_string(j);
//std::string s2 = "Hello from C++";
std::string s = s1 +":::"+s2;
return env->NewStringUTF(s.c_str());
}
最后,通动态库一样,也需要配置导入的静态库目录为了让编译器编译链接的时候能找到静态库,CMakeLists.txt中静态库导入配置如下:
。。。
#引入静态库
# IMPORTED: 表示静态库是以导入的形式添加进来(预编译静态库)
add_library(StaticTest STATIC IMPORTED)
。。。
#设置静态库的导入路径
set_target_properties(StaticTest PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/src/main/static/armeabi-v7a/libStaticTest.a)
#生成native-lib动态库需要用到Test StaticTest log动态或者静态库
target_link_libraries( # Specifies the target library.
native-lib
# libTest.so 可以去掉lib与.so
Test
StaticTest
# Links the target library to the log library
# included in the NDK.
log )
这样我们就算将静态库引入工程并能正常调用其中方法了:
好了,到此我们经过上述操作将源文件用命令行方式分别生成动态库与静态库并导入安卓工程正常使用了,这都是一些基础方面的知识但是很重要,以后我们使用的三方库很多都是下载源代码,然后自己来生成静态库或者动态库来使用,上面就是演示的这样一个大题流程,那静态库与动态库有什么区别呢?接下来我们讨论一下二者的区别。
在平时工作中我们经常把一些常用的函数或者功能封装为一个个库供给别人使用,java开发我们可以封装为jar包提供给别人用,安卓平台后来可以打包成aar包,同样的,C/C++中我们封装的功能或者函数可以通过静态库或者动态库的方式提供给别人使用。
Linux平台静态库以.a结尾,而动态库以.so结尾。
那静态库与动态库有什么区别呢?
程序与静态库连接时,静态库中所有被使用的函数的机器码在编译的时候都被拷贝到最终的可执行文件中,并且会被添加到和它连接的每个程序中:
优点:运行起来会快一些,不用查找其余文件的函数库了。
缺点:导致最终生成的可执行代码量相对变多,运行时, 都会被加载到内存中. 又多消耗了内存空间。
与动态库连接的可执行文件只包含需要的函数的引用表,而不是所有的函数代码,只有在程序执行时, 那些需要的函数代码才被拷贝到内存中。
优点:生成可执行文件比较小, 节省磁盘空间,一份动态库驻留在内存中被多个程序使用,也同时节约了内存。
缺点:由于运行时要去链接库会花费一定的时间,执行速度相对会慢一些。
静态库是时间换空间,动态库是空间换时间,二者均有好坏。
如果我们要修改函数库,使用动态库的程序只需要将动态库重新编译就可以了,而使用静态库的程序则需要将静态库重新编译好后,将程序再重新编译一遍。
本篇我们主要讲解了交叉编译,以及交叉编译出可在安卓平台运行的可执行文件,动态库,静态库,核心是理解整个流程,以及给编译器传递头文件,库文件的查找路径,本篇同样是基础知识部分,但是对于后续我们编译ffmpeg等三方开源库又是十分重要的基础知识,好了,本篇到此为止。