ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android串口通信开发全解析:从NDK配置到JNI实战与避坑指南

Android串口通信开发全解析:从NDK配置到JNI实战与避坑指南 简介这是一款面向嵌入式开发与串口调试初学者的轻量级Android串口调试工具ComAssistant V1.1专为需要多路串口通信验证、协议交互测试及硬件联调的开发者设计。资源包共85个文件涵盖29个.class字节码、9个核心Java源码含SerialPort.c/.h等JNI底层实现、8个ARM/x86平台so库、6套XML布局与配置文件以及APK安装包、NDK编译脚本Android.mk/Application.mk和自动构建shell脚本等完整呈现从JNI层串口驱动封装到UI层收发控制的全链路工程结构压缩包仅375KB便于快速部署与源码研读。已有428人学习下载适合Android底层通信开发入门者理解串口API调用、线程安全刷新机制、配置持久化cfg/properties自动存取及NDK r8b交叉编译实践。1. 项目概述与核心价值最近在整理旧项目资料时翻出了一个名为“ComAssistantV1.1.zip”的压缩包。解压后里面是一个典型的Android串口通信助手项目版本号V1.1。这个项目虽然名字看起来平平无奇但它的代码结构和实现方式恰恰是很多Android硬件开发者在入门阶段会遇到的“标准模板”同时也藏着不少新手容易踩的坑。它不是一个简单的界面演示而是直接触及了Android与底层硬件如单片机、工控板、传感器模组通信的核心——通过JNI调用C/C库实现串口读写。如果你正在或即将从事Android物联网、工业控制、智能硬件配套App开发那么这个项目里涉及的SerialPort类、NDK配置、AndroidManifest.xml权限声明以及如何将C代码编译进APK都是你必须啃下来的硬骨头。我打算结合这个ComAssistant V1.1的源码把它彻底拆解一遍不仅告诉你它怎么跑起来的更重点分享那些官方文档不会写、但实际开发中至关重要的配置细节、调试技巧和避坑指南。2. 项目整体架构与设计思路拆解2.1 核心功能与定位分析ComAssistant顾名思义是运行在Android设备上的串口通信助手。它的核心功能非常明确打开指定的串口设备如/dev/ttyS1、/dev/ttyUSB0设置波特率、数据位、停止位、校验位等参数然后实现数据的发送与接收显示。这类工具在硬件调试阶段不可或缺工程师可以用它来测试下位机如STM32、Arduino发送的数据是否正确或者手动发送指令控制硬件。这个V1.1版本的项目结构清晰地反映了早期Android NDK开发的典型模式。它没有使用现在更流行的CMake或ndk-build的现代语法而是采用了传统的Android.mk和Application.mk来组织本地代码。整个项目的骨架由三大部分构成Java层UI与逻辑控制、JNI接口层、以及最底层的C语言串口操作库。这种分层设计是性能与灵活性的平衡UI和业务逻辑用Java/Kotlin快速开发而要求实时性高、直接操作硬件的部分串口读写用C实现通过JNI桥接。2.2 技术栈选型背后的考量为什么选择NDK和JNI这是由串口通信的特性决定的。Android系统基于Linux内核串口设备在系统中以文件形式存在如/dev/ttyS*。虽然Java本身可以通过FileInputStream和FileOutputStream操作文件但对串口设备的精确控制如设置波特率、硬件流控需要调用标准的POSIX接口如termios.h中的tcsetattr函数。这些系统调用在纯Java层是无法直接完成的必须借助本地代码。因此项目引入了android-serialport-api或其类似实现。这通常是一个开源的、封装好的C库提供了open、close、read、write以及配置串口参数的函数。我们的Java代码通过声明native方法比如public native static FileDescriptor open(String path, int baudrate, int flags);然后在JNI层实现这些方法内部再去调用那个C库。这样做的好处是将复杂的、平台相关的硬件操作封装在底层给上层提供一个相对简洁、统一的Java API。3. 核心模块深度解析与配置要点3.1 AndroidManifest.xml 权限与特性声明这是项目能运行起来的第一道门槛也是最容易出错的地方之一。很多开发者把代码跑起来后发现打开串口就崩溃十有八九是这里没配好。uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE/ !-- 可能还需要 READ_EXTERNAL_STORAGE -- uses-permission android:nameandroid.permission.INTERNET / !-- 注意在Android 6.0 (API 23) 及以上部分权限需要动态申请 --首先串口操作本身不需要特殊的Android权限。因为从Linux层面看它只是操作/dev下的一个设备文件。但是操作这个文件需要对应的Linux用户组权限。通常串口设备文件属于root用户或system组普通应用没有访问权限。这就是为什么很多调试教程会提到需要root手机或者在编译系统时修改设备的权限配置ueventd.rc或init.rc让串口设备对应用所属的组如inet可读可写。在非root的商业设备上这通常意味着你需要是系统应用或者设备制造商已经预置了相应的权限策略。其次项目中常见的WRITE_EXTERNAL_STORAGE权限通常不是为了串口而是为了保存通信日志、导出接收到的数据到文件等功能。INTERNET权限则可能是为了扩展的网络功能如TCP转发在基础串口助手中不一定需要。重要提示从Android 10 (API 29) 开始作用域存储Scoped Storage政策收紧直接通过路径访问外部存储变得困难。如果你的应用有保存文件的需求并且目标API级别较高务必使用MediaStore或SAF存储访问框架来替代传统的文件路径操作。3.2 NDK环境配置与编译脚本解读项目里一般会有一个jni文件夹里面存放着C/C源码和Android.mk、Application.mk。这是老式NDK编译的“心脏”。Android.mk文件定义了编译的规则LOCAL_PATH : $(call my-dir) include $(CLEAR_VARS) LOCAL_MODULE : serial_port LOCAL_SRC_FILES : SerialPort.c LOCAL_LDLIBS : -llog include $(BUILD_SHARED_LIBRARY)LOCAL_MODULE指定编译生成的库名称这里是serial_port最终会生成libserial_port.so。LOCAL_SRC_FILES指定要编译的源文件。LOCAL_LDLIBS : -llog链接Android的日志库这样在C代码中可以使用__android_log_print输出日志到Logcat对于调试至关重要。Application.mk文件定义项目全局设置APP_ABI : armeabi-v7a arm64-v8a x86 x86_64 APP_PLATFORM : android-14 APP_STL : c_staticAPP_ABI指定要为哪些CPU架构生成so库。为了减小APK体积可以只选择当前主流架构如armeabi-v7a和arm64-v8a。APP_PLATFORM指定目标Android平台版本。这里android-14对应Android 4.0。这是一个极易引发兼容性问题的地方。如果设置得太低可能无法使用某些新的NDK API如果设置得过高而你的minSdkVersion较低在低版本设备上运行时可能会因为链接到不存在的系统库而崩溃。通常建议APP_PLATFORM与app/build.gradle中的minSdkVersion保持一致或略高。在Android Studio中的配置 在现代Android Studio项目中你需要在app模块的build.gradle文件中配置NDK路径和编译选项android { ... defaultConfig { ... ndk { abiFilters armeabi-v7a, arm64-v8a // 指定生成哪些ABI的库 } externalNativeBuild { ndkBuild { // 指定Android.mk的路径如果使用CMake则是CMakeLists.txt path src/main/jni/Android.mk } } } externalNativeBuild { ndkBuild { path src/main/jni/Android.mk } } }确保本地的NDK版本已安装并通过File - Project Structure - SDK Location正确设置。3.3 SerialPort JNI 桥接层实现剖析JNI层是连接Java和C的桥梁。我们来看一个典型的实现。在Java类android_serialport_api.SerialPort中public class SerialPort { static { System.loadLibrary(serial_port); // 加载编译好的 libserial_port.so } private native FileDescriptor open(String path, int baudrate, int flags); public native void close(); ... }System.loadLibrary必须在调用任何native方法之前执行通常放在静态代码块中。对应的JNI C代码SerialPort.c或.cpp中需要实现这些native方法#include jni.h #include android/log.h #include serial_port.h // 假设这是你的串口操作C头文件 JNIEXPORT jobject JNICALL Java_android_1serialport_1api_SerialPort_open (JNIEnv *env, jclass clazz, jstring path, jint baudrate, jint flags) { const char *path_utf (*env)-GetStringUTFChars(env, path, NULL); int fd serial_port_open(path_utf, baudrate, flags); // 调用底层C函数 (*env)-ReleaseStringUTFChars(env, path, path_utf); if (fd -1) { // 打开失败可以抛出一个IOException jclass exceptionCls (*env)-FindClass(env, java/io/IOException); (*env)-ThrowNew(env, exceptionCls, Cannot open serial port); return NULL; } // 将Linux的文件描述符(fd)包装成Java的FileDescriptor对象返回 jclass fdClass (*env)-FindClass(env, java/io/FileDescriptor); jmethodID fdConstructor (*env)-GetMethodID(env, fdClass, init, ()V); jobject fileDescriptor (*env)-NewObject(env, fdClass, fdConstructor); jfieldID descriptorField (*env)-GetFieldID(env, fdClass, descriptor, I); (*env)-SetIntField(env, fileDescriptor, descriptorField, fd); return fileDescriptor; }关键点解析函数名映射规则JNI函数名必须遵循Java_完整类名_方法名的格式其中.替换为_。例如android.serialport.api.SerialPort.open对应Java_android_1serialport_1api_SerialPort_open注意包名中的.被替换为_1。字符串处理Java的String在JNI中是jstring不能直接当C字符串用。必须使用GetStringUTFChars转换并且用完一定要ReleaseStringUTFChars释放否则会导致内存泄漏。异常处理在JNI中检测到错误如打开文件失败可以通过ThrowNew向Java层抛出异常。这是将底层错误信息传递到上层的关键机制。文件描述符传递C层open返回的是一个整型的文件描述符file descriptor, fd。为了在Java层使用它进行读写需要将其包装成java.io.FileDescriptor对象。这是通过JNI反射操作实现的找到该类、获取其descriptor字段一个int类型然后将fd的值设置进去。4. 完整集成与调试流程实战4.1 项目导入与依赖检查拿到一个类似ComAssistant的NDK老项目第一步不是直接运行。我建议按以下步骤进行解压与目录审视解压ComAssistantV1.1.zip观察目录结构。标准的应该包含app模块有src、res、jni文件夹含C源码和.mk文件、libs可能预编译了so库等。Android Studio导入使用Android Studio的Open an existing Android Studio project选择项目根目录。如果是很老的项目AS会提示进行Gradle插件升级等先不要盲目升级。可以尝试先用项目原有的gradle/wrapper配置。同步与错误排查同步项目Sync Project with Gradle Files。这里大概率会报错常见问题有NDK版本不匹配老项目指定的NDK版本可能已不存在。需要在local.properties中指定一个已安装的NDK版本路径或在app/build.gradle中配置android.ndkVersion。Gradle插件版本过低如果AS强烈建议升级可以尝试逐步升级gradle/wrapper/gradle-wrapper.properties中的Gradle版本和项目根build.gradle中的classpath插件版本。切记备份每次只升级一个小的版本号同步测试。缺失serial_port库如果jni源码存在但运行时报错找不到libserial_port.so说明so库没有正确编译打包进APK。检查build.gradle中的externalNativeBuild配置是否正确指向了Android.mk并确保abiFilters包含你测试设备的架构。4.2 串口通信核心流程代码实现假设环境配置无误我们来看Java层如何调用这个串口库。核心流程通常封装在一个SerialPortManager或类似的工具类中。public class SerialPortHelper { private SerialPort mSerialPort; private InputStream mInputStream; private OutputStream mOutputStream; private ReadThread mReadThread; public boolean open(String devicePath, int baudrate) { try { // 1. 通过JNI打开串口获取FileDescriptor mSerialPort new SerialPort(new File(devicePath), baudrate, 0); // 2. 获取输入输出流 mInputStream mSerialPort.getInputStream(); mOutputStream mSerialPort.getOutputStream(); // 3. 开启读线程 mReadThread new ReadThread(); mReadThread.start(); return true; } catch (IOException | SecurityException e) { e.printStackTrace(); return false; } } public void send(byte[] data) { if (mOutputStream ! null) { try { mOutputStream.write(data); mOutputStream.flush(); } catch (IOException e) { e.printStackTrace(); } } } private class ReadThread extends Thread { Override public void run() { super.run(); while (!isInterrupted()) { int size; try { byte[] buffer new byte[1024]; if (mInputStream null) return; // 4. 阻塞式读取数据 size mInputStream.read(buffer); if (size 0) { byte[] receivedData new byte[size]; System.arraycopy(buffer, 0, receivedData, 0, size); // 5. 通过Handler或LiveData将数据传递到UI线程更新界面 Message message mHandler.obtainMessage(); message.obj receivedData; mHandler.sendMessage(message); } } catch (IOException e) { e.printStackTrace(); return; } } } } public void close() { if (mReadThread ! null) { mReadThread.interrupt(); } if (mSerialPort ! null) { mSerialPort.close(); mSerialPort null; } } }流程要点初始化调用SerialPort构造函数内部会执行JNI的open函数打开设备文件并配置参数。获取流通过SerialPort对象获取InputStream和OutputStream。底层这些流操作最终会映射到C层的read和write系统调用。读线程串口读取必须是阻塞的、独立的线程。因为InputStream.read()会一直等待直到有数据到达或发生错误。如果在主线程执行会导致界面卡死。数据解析读取到的是原始的字节流。如何解析取决于你的通信协议如Modbus、自定义帧头帧尾。这里只是简单地将字节数组传递给UI层。复杂的协议解析应该在读线程内或另一个解析线程中完成。线程通信使用Handler、runOnUiThread或LiveData将接收到的数据从后台线程发送到UI线程进行显示如追加到TextView或RecyclerView。4.3 界面设计与用户交互ComAssistant的UI通常比较简单包含以下元素串口参数选择Spinner或EditText用于选择设备路径如/dev/ttyS1、波特率9600, 115200等、数据位、停止位、校验位。打开/关闭按钮控制串口连接状态。发送区一个EditText用于输入要发送的字符串支持Hex或ASCII格式一个按钮触发发送。接收区一个Scrollable的TextView或RecyclerView实时显示接收到的数据同样支持Hex/ASCII格式切换。清空按钮清空接收区。一个关键的细节是发送格式的处理// 文本模式发送 String msg mEditSend.getText().toString(); byte[] sendBytes msg.getBytes(UTF-8); mSerialHelper.send(sendBytes); // HEX模式发送 String hexStr mEditSend.getText().toString().replace( , ); if (hexStr.length() % 2 ! 0) { // 提示错误Hex字符串长度必须为偶数 return; } byte[] sendBytes new byte[hexStr.length() / 2]; for (int i 0; i sendBytes.length; i) { int high Character.digit(hexStr.charAt(i * 2), 16); int low Character.digit(hexStr.charAt(i * 2 1), 16); sendBytes[i] (byte) ((high 4) low); } mSerialHelper.send(sendBytes);在HEX模式下必须处理用户输入的空格、校验长度是否为偶数并正确地将如A1B2这样的字符串转换为字节0xA1, 0xB2。5. 开发与调试中的常见问题与解决方案5.1 权限问题导致无法打开串口现象调用SerialPort.open()时抛出IOException: Cannot open serial port或Permission denied。排查步骤确认设备路径首先确保你尝试打开的路径在设备上确实存在。可以通过ADB Shell连接设备执行ls -l /dev/tty*来查看可用的串口设备。不同设备厂商的路径可能不同/dev/ttyHSL1,/dev/ttyMT1,/dev/ttyS0等。检查文件权限在ADB Shell中使用ls -l /dev/ttyS1以你的设备路径为准查看权限。输出可能类似crw-rw---- root system 204, 64 2024-01-01 10:00 ttyS1。这里显示所有者是root所属组是system只有所有者(root)和组成员(system)有读写权限。解决方案临时测试需root在已root的设备上使用ADB Shell执行chmod 666 /dev/ttyS1赋予所有用户读写权限。但这重启后会失效。永久方案需系统权限修改设备的系统镜像在ueventd.rc或init.rc文件中为对应的设备节点添加权限规则例如/dev/ttyS1 0666 root system。这需要编译系统固件适用于设备制造商或深度定制ROM的开发者。应用层方案如果你的应用是系统应用预装在/system/app或/system/priv-app并且签名了平台密钥可以申请android.permission.ACCESS_SUPERUSER不推荐或依赖设备厂商提供的特定API。对于普通第三方应用在未授权的商业设备上直接操作系统串口几乎是不可能的。此时需要考虑通过USB OTG连接外置的USB转串口适配器如CP2102、CH340芯片这些适配器在Android上通常会通过USB HostAPI来访问权限问题由USB框架处理。5.2 数据收发异常乱码、丢包、延迟现象能打开串口但发送或接收的数据不对或者反应很慢。排查与解决参数匹配这是最常见的原因。确保Android端设置的波特率、数据位、停止位、校验位与下位机如单片机完全一致。哪怕只有波特率对不上收到的也全是乱码。流控设置检查硬件流控RTS/CTS和软件流控XON/XOFF是否被意外启用。在不需要流控的场合在串口配置时C层的termios设置应明确关闭它们。在SerialPort.c的配置函数中确保c_cflag中没有设置CRTSCTSc_iflag中没有设置IXON,IXOFF。缓冲区与线程阻塞发送延迟确保发送不是在UI线程中直接进行大量数据的写入。即使是一个单独的发送动作如果数据量大也应考虑放在子线程。接收丢包检查读线程的缓冲区大小。如果下位机发送数据很快而Android端读取处理慢内核的串口输入缓冲区可能会溢出导致丢包。可以适当增大C层open函数中termios结构体的c_cc[VMIN]和c_cc[VTIME]参数或增大Java层读线程的缓冲区如从1024改为4096。但根本解决之道是优化数据解析和处理逻辑确保读线程能及时取走数据。日志输出在JNI C代码的关键位置打开、配置、读、写添加__android_log_print(ANDROID_LOG_DEBUG, SerialPort, format, ...)日志。通过Android Studio的Logcat查看可以确认C层函数是否被调用、参数是否正确、系统调用是否返回错误。这是调试NDK代码最有效的手段。5.3 NDK编译与链接错误现象项目同步成功但编译时报错提示找不到符号、链接失败、ABI不兼容等。常见错误与解决undefined reference to ...链接错误通常是C代码中调用了某个函数但编译器找不到它的实现。检查对应的.c文件是否在Android.mk的LOCAL_SRC_FILES中列出。如果是系统库函数如open,close,tcsetattr确保LOCAL_LDLIBS中链接了正确的库对于标准C库和Linux系统调用通常不需要额外链接它们默认包含。如果是自定义函数检查函数声明头文件和定义源文件是否匹配以及是否被#ifdef __cplusplusextern C包裹防止C编译器进行名称修饰。UnsatisfiedLinkError: dlopen failed: library libserial_port.so not found运行时错误表示APK中缺少对应CPU架构的so库或者加载路径不对。检查app/build.gradle中的abiFilters是否包含了测试设备的CPU架构现在主流是arm64-v8a。检查jniLibs.srcDirs设置确保so库被正确打包到APK的lib/目录下。使用APK分析工具Android Studio的Build - Analyze APK打开生成的APK查看lib/文件夹下是否有对应架构的libserial_port.so。java.lang.UnsatisfiedLinkError: No implementation found for ...JNI函数签名不匹配。这是最棘手的错误之一。仔细核对函数名JNI函数名必须完全匹配包括包名、类名、方法名以及“_1”这样的转义。使用javah或javac -h工具这是最可靠的方法。进入包含你的Java native方法的类所在目录执行javac -h . YourClass.java它会自动生成包含正确函数签名的头文件.h你可以用这个头文件中的函数签名来对照检查你的C实现。检查JNIEnv和参数类型在C和C中JNIEnv*的使用方式不同C中为(*env)-Method(env, ...)C中为env-Method(...)。确保你的实现文件.c或.cpp和包含的头文件风格一致。5.4 高版本Android系统兼容性问题随着Android系统更新一些旧的开发方式会遇到挑战。NDK API弃用某些老的NDK函数或常量在新版NDK中被标记为废弃。编译时会产生警告甚至错误。解决方案是查阅Android NDK的官方修订说明替换为新的推荐API。例如一些旧的bionic头文件或函数。文件路径访问限制Scoped Storage如果你的串口助手有保存日志到SD卡的功能在Android 10及以上直接使用Environment.getExternalStorageDirectory()获取路径并写入文件会失败。必须改用MediaStoreAPI或使用应用专属外部存储空间Context.getExternalFilesDir()。后台执行限制如果串口助手需要在后台长时间运行并监听数据在Android 8.0后台服务限制和Android 12精确的闹钟权限之后需要妥善处理。可以考虑使用ForegroundService前台服务并发送持续的通知或者根据业务场景使用WorkManager进行调度同时申请必要的权限如SCHEDULE_EXACT_ALARM。6. 性能优化与进阶扩展思路一个基础的串口助手完成后可以考虑从以下方面提升其稳定性和实用性。6.1 读写性能优化策略双缓冲读线程在读线程内部使用两个缓冲区Buffer A和Buffer B。当Buffer A正在被InputStream.read()填充时Buffer B可以将之前已满的数据交给解析线程处理。这样可以减少因数据处理导致的I/O等待提高吞吐量。非阻塞I/O与Selector对于需要同时监听多个串口或多个数据源如串口网络的场景可以考虑在JNI层使用Linux的select()或poll()系统调用。这允许单个线程监听多个文件描述符上的事件可读、可写、错误避免为每个串口创建一个阻塞读线程的开销。不过这需要更深入的JNI和Linux编程知识。发送队列如果存在短时间内密集发送指令的场景建议实现一个发送队列。将发送请求放入队列由一个专门的发送线程按顺序取出并执行write操作避免在主线程或UI线程中直接进行可能阻塞的I/O操作。6.2 功能扩展方向协议解析集成不仅仅是显示十六进制流。可以集成常见的工业协议解析器如Modbus RTU/ASCII、CAN总线数据帧解析等。接收到的数据经过解析后可以直接以结构化的形式显示如“寄存器地址40001 值1250”极大提升调试效率。数据可视化将接收到的数据如传感器数值实时绘制成曲线图折线图、波形图。可以利用MPAndroidChart等开源图表库来实现。脚本自动化增加简单的脚本功能如基于Lua或JavaScript引擎允许用户编写脚本自动发送特定指令序列并根据接收到的数据做出条件判断和响应实现自动化测试。网络转发将串口数据通过TCP/UDP转发到远程服务器或另一台设备实现数据的远程监控和调试。这需要处理好网络通信的稳定性重连机制、心跳包和数据转换的准确性。USB Host支持如前所述通过Android的USB Host API支持外置的USB转串口适配器。这需要处理USB设备的探测、权限请求、接口声明和端点通信复杂度更高但通用性极强几乎可以在任何支持OTG的Android设备上使用。6.3 从传统NDK构建迁移到现代CMake如果这个老项目需要长期维护我强烈建议将其NDK构建系统从Android.mk迁移到CMake。CMake是Android Studio现在官方推荐的方式语法更清晰跨平台性更好与IDE的集成也更紧密。迁移步骤大致如下在app模块下创建CMakeLists.txt文件。在CMakeLists.txt中定义库、添加源文件、链接库。cmake_minimum_required(VERSION 3.10.2) project(serialport) add_library(serial_port SHARED SerialPort.c) find_library(log-lib log) target_link_libraries(serial_port ${log-lib})修改app/build.gradle将externalNativeBuild从ndkBuild改为cmake并指向新的CMakeLists.txt。android { ... externalNativeBuild { cmake { path src/main/cpp/CMakeLists.txt } } }将原有的jni文件夹下的C源码移动到src/main/cpp目录这是CMake的默认源码目录。同步项目并测试编译。这个过程可能会遇到一些路径或编译标志的差异但一旦迁移成功后续的依赖管理、多ABI构建、调试都会更加方便。回过头看这个ComAssistant V1.1项目它就像一块经典的“砖”虽然外观朴素但结构扎实包含了Android与硬件通信最核心的要素。通过彻底拆解它我们不仅学会了如何让一个串口助手跑起来更重要的是理解了背后JNI的工作机制、NDK的配置精髓、以及跨Java-C边界的编程思维。在实际产品开发中你可能不会直接使用这样原始的代码但这份理解能让你在遇到更复杂的NDK问题、性能瓶颈或兼容性挑战时有足够的知识储备去分析和解决。硬件开发的世界总是伴随着各种“不标准”和“特殊情况”而扎实的基础和清晰的调试思路是应对这一切最好的工具。本文还有配套的精品资源点击获取
返回列表