[关闭]
@Awille 2019-01-12T12:31:01.000000Z 字数 13615 阅读 84

Android 串口开发(NDK开发)

Android Ndk开发


目前开发中设计到了串口读写的问题,这需要使用jni,鉴于以前没有实践过,下面尝试下踩踩坑。
目前在Android Studio3.0以上的版本可以直接构建一个ndk demo,ide会自动创建一个例子,但是我觉的这样还是不是十分了解ndk的一个细节开发流程,害怕以后开发遇到坑不会解决,所以决定还是自己尝试一下,熟悉一下流程。如果觉得赶时间的建议暂时就不要看这篇文章了,直接创建含有c++ support的项目文件就可以了,直接就有demo在老方法上踩踩坑,熟悉一下流程。

环境的配置

1、下载并配置ndk以及c++开发相关工具。
在目录Tool->SDK Manager->System Settings->Android SDK->Sdk Tools
勾选一下三个工具:
Android NDK: native develop kit,jni开发工具。首先要明确JNI是java语言特性,是用来让java与其他语言进行交互的接口(如c,c++),它与android无关,而ndk是android开发工具包,是用来将c,c++编译成的so库打包到apk文件当中的。
CMake:外部构建工具,c,c++编译工具,用来加载so库,
LLDB:c、c++代码调试器

2、在local.properties指定ndk路径

ndk.dir=C:\\Android\\Sdk\\ndk-bundle
sdk.dir=C:\\Android\\Sdk

3、在gradle.properties文件夹当中,添加属性:(如果你使用的是android.mk和application.mk进行配置才需要使用下面的属性,使用cmake可以跳过):

android.useDeprecateNdk=true
//这里的声明是为了说明对旧版本ndk的支持

对JNI开发流程的了解

1、在java的类当中声明本地方法
2、将java文件编译成class文件
3、用javah 生成.h头文件
4、完成c++代码的编写
5、编译so库
6、链接so库

在ndk开发大致中应该也是这个流程,但是应该会根据具体的ndk环境

首先感受下ndk的编译过程:在这流程的实现上会有些许不同。

在用编译android studio直接生成的ndk demo的时候,查看assemble task的执行,我们可以看到下面这样的执行日志:

Build native-lib x86_64
[1/2] Building CXX object CMakeFiles/native-lib.dir/src/main/cpp/native-lib.cpp.o
[2/2] Linking CXX shared library ..\..\..\..\build\intermediates\cmake\debug\obj\x86_64\libnative-lib.so
Build native-lib x86
[1/2] Building CXX object CMakeFiles/native-lib.dir/src/main/cpp/native-lib.cpp.o
[2/2] Linking CXX shared library ..\..\..\..\build\intermediates\cmake\debug\obj\x86\libnative-lib.so
Build native-lib arm64-v8a
[1/2] Building CXX object CMakeFiles/native-lib.dir/src/main/cpp/native-lib.cpp.o
[2/2] Linking CXX shared library ..\..\..\..\build\intermediates\cmake\debug\obj\arm64-v8a\libnative-lib.so
Build native-lib armeabi-v7a
[1/2] Building CXX object CMakeFiles/native-lib.dir/src/main/cpp/native-lib.cpp.o
[2/2] Linking CXX shared library ..\..\..\..\build\intermediates\cmake\debug\obj\armeabi-v7a\libnative-lib.so

可以看到这里就是cmake编译不同cpu架构下的c++文件,并且绑定so库。 想验证的同学可到具体目录下查看相关的源文件以及编译生成的so库。

这里AS是默认全平台编译的,如果你需要编译那么多,只要特定的几个平台,那么在gradle的 android的defaultconfig字段当中,指定编译平台就好了:

ndk {
    //设置支持的SO库架构(开发者可以根据需要,选择一个或多个平台的so)
    abiFilters "armeabi-v7a", "arm64-v8a"
}

使用CMake工具进行ndk开发

现在版本的android studio都是默认使用cmake工具进行ndk开发的,如果在老项目是沿用ndk-build进行ndk开发,那么就只好继续使用,但是老项目还没有ndk模块或创建一个全新的项目,推荐使用这种方式进行

NDK开发。

创建新的原生源文件

1、在模块->src->main 目录之下创建新的文件夹,用于存放我们写的c或c++代码文件。
(我这里创建文件夹名为 jni)
2、创建新的源文件 (如serialPort.cpp)
这里先创建空文件,目前主要目的是把ndk的开发流程跑通

创建CMakeLists构建脚本

1、在模块跟目录下创建File文件,文件名字一定要是CMakeLists.txt,这是一个纯文本文件。
2、创建文件以后可以采用就可以往里面添加脚本命令来创建原生库。

# Sets the minimum version of CMake required to build your native library.
# This ensures that a certain set of CMake features is available to
# your build
# 声明 CMake工具的最低版本
cmake_minimum_required(VERSION 3.4.1)
# Specifies a library name, specifies whether the library is STATIC or
# SHARED, and provides relative paths to the source code. You can
# define multiple libraries by adding multiple add.library() commands,
# and CMake builds them for you. When you build your app, Gradle
# automatically packages shared libraries with your APK.
add_library( # Specifies the name of the library.
             serial
             # Sets the library as a shared library.
             SHARED
             # Provides a relative path to your source file(s).
             # 这里可以指定多个
             src/main/jni/SerialPort.cpp )

看到上面add_library的设置so库参数名当中,我设置的为serial,Cmake采用以下规范来为库文件命名:

lib库名称.so

所以可以知道文件名为libserial.so

(下面这个在找不到头文件的时候才要管):
使用 add_library() 向您的 CMake 构建脚本添加源文件或库时,Android Studio 还会在您同步项目后在 Project 视图下显示关联的标头文件。不过,为了确保 CMake 可以在编译时定位您的标头文件,您需要将 include_directories() 命令添加到 CMake 构建脚本中并指定标头的路径

include_directories(src/main/cpp/include/)

添加ndk提供的其他api库(想快速走完流程可先跳过)

假如你需要使用ndk提供的一些api库,你可以在cmake脚本文件当中加上去:

  1. find_library( # Defines the name of the path variable that stores the
  2. # location of the NDK library.
  3. log-lib
  4. # Specifies the name of the NDK library that
  5. # CMake needs to locate.
  6. log )

log为ndk提供的android特定日志的支持库,这里将log库的路径存储在log_lib变量当中。

为了确保你编译的so库可以调用你添加的api库,您需要使用Cmake构建脚本中的target_link_libraties命令关联库:
target_link_libraties专门用来链接库的,当你需要在你的原生库中调用一些别的库,你都需要这个链接。默认情况下,库依赖项是传递的。当这个目标链接到另一个目标时,链接到这个目标的库也会出现在另一个目标的连接线上。这个传递的接口存储在interface_link_libraries的目标属性中,可以通过设置该属性直接重写传递接口。

  1. # Links your native library against one or more other native libraries.
  2. target_link_libraries( # Specifies the target library.
  3. serial
  4. # Links the log library to the target library.
  5. ${log-lib} )

在ndk当中,还会以源代码的形式包含一些库,可以用add_library将这些源代码编译成so库,然后target_link_libraries添加到你想要使用这个编译出来的so库 的 so库当中。

添加其他预构建库(想快速走完流程可先跳过)

这里的预构建库,可以理解为已经编译好的so库,假若你想直接依赖一个别人给你的so库,则使用下面的Cmake 命令:
这个与编译so库相似,需要使用IMPORTED标志告知Cmake你只希望将库导入到项目当中。
下面参数的第一个依然是so库的名字,你可以自己选。记得与下面set_target_properties的第一个参数照应。

  1. add_library( imported-lib
  2. SHARED
  3. IMPORTED )

接着使用set_target_properties命令指定要导入so库的路径。
某些库在不同的ABI(Application Binary Interface)(CPU架构:x86, x86_64, arm64-v8a, armeabi-v7a)中,提供有不同的so库,你想一次性添加的话,可以使用
ANDROID_ABI 路径变量,这变量存储了ndk默认支持的cpu架构名字。
假如你想要指定平台,参考上面提到的ndk字段指定abiFilters的值。

  1. set_target_properties( # Specifies the target library.
  2. imported-lib
  3. # Specifies the parameter you want to define.
  4. PROPERTIES IMPORTED_LOCATION
  5. # Provides the path to the library you want to import.
  6. imported-lib/src/${ANDROID_ABI}/libimported-lib.so )

同样为了确保Cmake可以定位到你的头文件,也需要使用include_directories包含头文件路径。

include_directories( imported-lib/include/ )

互相链接:serial 链接 imported-lib, imported-lib链接log-lob

target_link_libraries(serial imported-lib ${log-lib})

java代码中加载库

  1. public class SerialPort {
  2. static {
  3. System.loadLibrary("serial");//参数为so库名称
  4. }
  5. }

将gradle关联到你的原生库

意思即是将grale与你的cmakelists脚本文件相关联,让androidstudio识别到脚本文件。
方法1:直接模块的grale文件当中,在android字段里面添加字段:

  1. // Encapsulates your external native build configurations.
  2. externalNativeBuild {
  3. // Encapsulates your CMake build configurations.
  4. cmake {
  5. // Provides a relative path to your CMake build script.
  6. path "CMakeLists.txt"
  7. }
  8. }

注:如果您想要将 Gradle 关联到现有 ndk-build 项目,请使用 ndkBuild {} 块而不是 cmake {},并提供 Android.mk 文件的相对路径。如果 Application.mk 文件与您的 Android.mk 文件位于相同目录下,Gradle 也会包含此文件。

另外,还可以在defaultConfig当中,配置另外一个externalNativeBuild{}块,为Cmake或ndkbuild 指定相关参数(这一步我们可以暂时忽略)

下面我们同步下项目,如果同步到项目出错,请认真检查才编写Cmakelists文件的时候,相关路径是否写对了,比如add_library指定c++源文件的时候。
同步成功后,编译项目,你可以看到编译成功的so文件,在/Users/will/AndroidStudioProjects/HelloJniOld/app/build/intermediates/cmake/debug/obj 路径之下,我这里只编译了debug版本,所以在debug目录之下。

开发流程大致如此,后面就是jni开发中的技术问题了。

ndk 小demo

1、首先要了解 JNI c++ 文件的方法命名规范:
Java_包名类名方法

2、JNI数据类型和类型对应关系表,这里可以自己上网找,里面描述了JNI类型(即是卸载c++ 文件中的类型)跟java类型的对照。这里给你一个 网址吧,先大概浏览下,继续往下面的流程走 :
Jni本地类型与java类型对照表

知道以上两个规则之后,我们假若在包 com.example.will 下的类 Serial 中有个方法 public native String JniTest(); 那么c++ 中文件为:

  1. extern "C" JNIEXPORT jstring JNICALL
  2. Java_com_example_will_Serial_JniTest(JNIEnv *env, jobject instance) {
  3. // TODO
  4. return (*env)->NewStringUTF(env, returnValue);
  5. }

其中 JNIEXPORT 与 JNICALL为JNI中宏定义,我们暂时不管
extern "C":这是用来c与c++混编的,的主要作用就是为了能够正确实现C++代码调用其他C语言代码,如果创建的是cpp文件,一定要加否则会报错找不到本地函数的实现方法,但如果创建的是c文件,就不用写
JNIEnv* : 指向jni环境的指针
jobject:即java对象中的this
创建c++文件跟c文件,对env指针的操作不一样,差别在哪里自己上网查,这里说明下:

  1. return (*env)->NewStringUTF(env, returnValue);

这是c的写法
c++的写法为:

  1. return env->NewStringUTF(returnValue);

下面开始尝试:
实现函数:

  1. #include <jni.h>
  2. #include <string>
  3. #include <iostream>
  4. using namespace std;
  5. //函数的格式名字:Java_包名_类名_方法
  6. //所以函数名字为: Java_com_example_will_hellojniold_SerialPort_testJni 返回参数与传入参数看后面
  7. extern "C" JNIEXPORT jstring JNICALL
  8. Java_com_example_will_hellojniold_SerialPort_testJni(JNIEnv *env, jobject instance) {
  9. char* returnValue = "Hello";
  10. return env->NewStringUTF(returnValue);
  11. }

其中java文件声明为:

  1. package com.example.will.hellojniold;
  2. public class SerialPort {
  3. static {
  4. System.loadLibrary("serial");
  5. }
  6. public native String testJni();
  7. }

在mainactivity 的oncreate中调用:

  1. @Override
  2. protected void onCreate(Bundle savedInstanceState) {
  3. super.onCreate(savedInstanceState);
  4. setContentView(R.layout.activity_main);
  5. Log.e("Awille", new SerialPort().testJni());
  6. }

跑起来跑起来:在logcat中看到:

11-19 20:23:52.650 1773-1773/? E/Awille: Hello

将c++中的代码输出到android 的logcat当中

  1. #include <android/log.h>
  2. //在需要log的地方使用:
  3. __android_log_print(ANDROID_LOG_DEBUG, "Awille", "in c++ success");

查看logcat输出:

11-19 21:03:08.319 1857-1857/com.example.will.hellojniold E/Awille: in c++ success

Bingo 下面揭开串口开发的神秘面纱;

串口开发

下面是c++有关串口读写的相关函数说明:(可以先看下面的实现,看到不懂再来翻这里)
1、tcgetattr函数的说明
2、c文件打开函数open说明第三个参数是打开文件的方式
在我们使用这个函数的时候,是传入flag为0的,看c里面的实现是 0_RDWR|flag 可见一可读写加0代表的含义打开文件。在c++的fcntl.h文件当中,有对0的宏定义,0代表的是

#define _O_RDONLY 0x0000

只读,而0_RDWR代表的值是2,所以 0_RDWR|0 = 0_RDWR|_O_RDONLY, 那么我们传入的flag值是希望当文件不是可读写时,我们希望以只读的方式打开,如果可读都不能打开才报错。
3、tcgetattr函数说明
tcgetattr函数用于获取与终端相关的参数。参数fd为终端的文件描述符,返回的结果保存在termios 结构体中,该结构体一般包括如下的成员:
termios结构体的定义,tcflag_t类型是unsigned int, cc_t c_cc是unsigned char, NCCS值为19。

  1. struct termios {
  2. tcflag_t c_iflag;
  3. tcflag_t c_oflag;
  4. tcflag_t c_cflag;
  5. tcflag_t c_lflag;
  6. cc_t c_line;
  7. cc_t c_cc[NCCS];
  8. };

4、有关串口读写的相关知识 //有关串口部分在博客的偏后部分

c++实现:

  1. #include <jni.h>
  2. #include <string>
  3. #include <android/log.h>
  4. #include <iostream>
  5. #include <sys/ioctl.h>
  6. #include <fcntl.h>
  7. #include <pty.h>
  8. #include <unistd.h>
  9. using namespace std;
  10. static char* TAG = "SerialPort";
  11. //鉴于在android日志当中输出太麻烦了,这里用宏做做一个封装
  12. #define LOGE(fmt, args...) __android_log_print(ANDROID_LOG_ERROR, TAG, fmt, ##args);
  13. //函数的格式名字:Java_包名_类名_方法
  14. //所以函数名字为: Java_com_example_will_hellojniold_SerialPort_testJni 返回参数与传入参数看后面
  15. extern "C" JNIEXPORT jstring JNICALL
  16. Java_com_example_will_serialport_SerialProvider_Serial_JniTest(JNIEnv *env, jobject instance) {
  17. char* returnValue = "Hello";
  18. __android_log_print(ANDROID_LOG_ERROR, TAG, "Hey, i am from c++");
  19. return env->NewStringUTF(returnValue);
  20. }
  21. //speed_t为unsigned int 类型 剩下的B值全部都有定义 在sys/ioctl.h文件当中
  22. static speed_t getBaudrate(jint bau) {
  23. switch (bau) {
  24. case 0: return B0;
  25. case 50: return B50;
  26. case 75: return B75;
  27. case 110: return B110;
  28. case 134: return B134;
  29. case 150: return B150;
  30. case 200: return B200;
  31. case 300: return B300;
  32. case 600: return B600;
  33. case 1200: return B1200;
  34. case 1800: return B1800;
  35. case 2400: return B2400;
  36. case 4800: return B4800;
  37. case 9600: return B9600;
  38. case 19200: return B19200;
  39. case 38400: return B38400;
  40. case 57600: return B57600;
  41. case 115200: return B115200;
  42. case 230400: return B230400;
  43. case 460800: return B460800;
  44. case 500000: return B500000;
  45. case 576000: return B576000;
  46. case 921600: return B921600;
  47. case 1000000: return B1000000;
  48. case 1152000: return B1152000;
  49. case 1500000: return B1500000;
  50. case 2000000: return B2000000;
  51. case 2500000: return B2500000;
  52. case 3000000: return B3000000;
  53. case 3500000: return B3500000;
  54. case 4000000: return B4000000;
  55. default: return -1;
  56. }
  57. }
  58. //__________________________________________________________________
  59. extern "C"
  60. JNIEXPORT jobject JNICALL
  61. Java_com_example_will_serialport_SerialProvider_Serial_open(JNIEnv *env, jclass type, jstring path_,
  62. jint baudrate, jint flags) {
  63. int fd;//文件描述符,每个文件会有唯一的文件描述符
  64. speed_t speed;
  65. jobject mFileDescriptor;
  66. speed = getBaudrate(baudrate);
  67. if (speed == -1) { //获取波特率失败
  68. LOGE("Invalid baudrate");
  69. return NULL;
  70. }
  71. jboolean iscopy;
  72. const char* path_uft = env->GetStringUTFChars(path_,&iscopy);
  73. //0_RDWR :代表可读可写 0_RDONLY:只读 O_WRONLY:只写
  74. LOGE("Opening serial port %s with flags 0x%x", path_uft, 0_RDWR | flags);
  75. //open函数为c++打开文件函数,定义在fcntl.h文件当中
  76. fd = open(path_uft, 0_RDWR | flags);
  77. LOGE("open() fd = %d", fd);
  78. if (fd == -1) {
  79. //要么串口地址不存在,要么没有权限
  80. LOGE("Cannot open the port");
  81. return NULL;
  82. }
  83. //如果打开成功,则对串口进行配置
  84. struct termios cfg;
  85. if (tcgetattr(fd, &cfg)) {
  86. LOGE("tcgetattr() failed");
  87. close(fd);
  88. return NULL;
  89. }
  90. //设置读取串口的波特率
  91. cfmakeraw(&cfg);
  92. cfsetispeed(&cfg, speed);
  93. cfsetospeed(&cfg, speed);
  94. if (tcsetattr(fd, TCSANOW, &cfg)) {
  95. LOGE("tcsetattr() failed");
  96. close(fd);
  97. return NULL;
  98. }
  99. //创建匹配的文件描述符
  100. jclass cFileDescriptor = env -> FindClass("java/io/FileDescriptor");
  101. jmethodID iFileDescriptor = env -> GetMethodID(cFileDescriptor, "<init>", "()V");
  102. jfieldID descriptorID = env -> GetFieldID(cFileDescriptor,"descriptor", "I");
  103. mFileDescriptor = env -> NewObject(cFileDescriptor, iFileDescriptor);
  104. env -> SetIntField(mFileDescriptor, descriptorID, (jint)fd);
  105. return mFileDescriptor;
  106. }
  107. //__________________________________________________________________
  108. extern "C"
  109. JNIEXPORT void JNICALL
  110. Java_com_example_will_serialport_SerialProvider_Serial_close(JNIEnv *env, jobject instance) {
  111. jclass SerialPortClass = env -> GetObjectClass(instance);
  112. jclass FileDescriptorClass = env-> FindClass("java/io/FileDescriptor");
  113. jfieldID mFdID = env->GetFieldID(SerialPortClass, "mFd", "Ljava/io/FileDescriptor;");
  114. jfieldID descriptorID = env->GetFieldID(FileDescriptorClass, "descriptor", "I");
  115. jobject mFd = env->GetObjectField(instance, mFdID);
  116. jint descriptor = env->GetIntField(mFd, descriptorID);
  117. LOGE("close(fd = %d)", descriptor);
  118. close(descriptor);
  119. }

2、在c的部分完成了以后,java部分就比较简单了:

  1. public class Serial {
  2. static {
  3. System.loadLibrary("serial");//参数为so库名称
  4. }
  5. public native String JniTest();
  6. private native static FileDescriptor open(String path, int baudrate, int flags);
  7. public native void close();
  8. private FileDescriptor mFd;
  9. private FileInputStream mFileInputStream;
  10. private FileOutputStream mFileOutputStream;
  11. public SerialPort(File device, int baudrate, int flags) throws SecurityException, IOException {
  12. /* Check access permission */
  13. if (!device.canRead() || !device.canWrite()) {
  14. try {
  15. /* Missing read/write permission, trying to chmod the file */
  16. Process su;
  17. su = Runtime.getRuntime().exec("/system/bin/su");
  18. String cmd = "chmod 666 " + device.getAbsolutePath() + "\n"
  19. + "exit\n";
  20. su.getOutputStream().write(cmd.getBytes());
  21. if ((su.waitFor() != 0) || !device.canRead()
  22. || !device.canWrite()) {
  23. throw new SecurityException();
  24. }
  25. } catch (Exception e) {
  26. e.printStackTrace();
  27. throw new SecurityException();
  28. }
  29. }
  30. mFd = open(device.getAbsolutePath(), baudrate, flags);
  31. if (mFd == null) {
  32. Log.e(TAG, "native open returns null");
  33. throw new IOException();
  34. }
  35. mFileInputStream = new FileInputStream(mFd);
  36. mFileOutputStream = new FileOutputStream(mFd);
  37. }
  38. // Getters and setters
  39. public InputStream getInputStream() {
  40. return mFileInputStream;
  41. }
  42. public OutputStream getOutputStream() {
  43. return mFileOutputStream;
  44. }
  45. }

得到输入输出流后面就是对流进行操作了

3、有关串口的路径问题
在一般情况下,串口路径都存储在/dev路径之下

  1. public Vector<File> getDevices() {
  2. if (mDevices == null) {
  3. mDevices = new Vector<File>();
  4. File dev = new File("/dev");
  5. File[] files = dev.listFiles();
  6. int i;
  7. for (i=0; i<files.length; i++) {
  8. if (files[i].getAbsolutePath().startsWith(mDeviceRoot)) {
  9. Log.d(TAG, "Found new device: " + files[i]);
  10. mDevices.add(files[i]);
  11. }
  12. }
  13. }
  14. return mDevices;
  15. }

## Android.mk文件的编写(待续)

  1. LOCAL_PATH := $(call my-dir)
  2. //指定.mk的路径 $(call my-dir)调用ndk内部的函数获得当前.mk的路径
  3. //每个android.mk文件必须以定义LOCAL_PATH为开始,用于在开发tree中查找源文件
  4. //宏my-dir由build system提供,返回包含android.mk的目录路径
  5. include $(CLEAR_VARS)
  6. //清空除了local_path之外所有LOCAL_xxx变量的值
  7. //CLEAR_VARS变量由build System提供,并指向一个指定的
  8. TARGET_PLATFORM := android-17
  9. LOCAL_MODULE := serial_port_framework
  10. LOCAL_SRC_FILES := SerialPort.c
  11. LOCAL_LDLIBS := -llog
  12. include $(BUILD_SHARED_LIBRARY)

1、在要使用本地方法的build.gradle配置文件当中,添加ndk节点:
在android字段的defaultConfig字段中添加你打开节点:

  1. ndk {
  2. moduleName "serial_jni" //编译的so库的名字
  3. stl "stlport_static" //使用stl标准库,默认情况下是无法使用的
  4. ldLibs "log" //log表示加入android的调试日志,只要导入 #include <android/log.h>
  5. //就可以使用 _android_log_print方法打印日志到logcat当中
  6. }
添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注