本文还有配套的精品资源点击获取简介一套开箱即用的Android串口通信实现专注应用层调用封装不涉及底层驱动开发。提供已验证的SerialPort Java类完整封装open/close/read/write操作自动处理端口占用、权限拒绝、读写异常等常见问题。支持通过USB转串口适配器如CH340、CP2102或设备内置串口与单片机、PLC、传感器等外设通信。项目基于Gradle构建兼容Android 5.0API 21至最新版本可直接导入Android Studio运行只需修改串口路径如/dev/ttyUSB0、波特率、数据位等参数即可适配不同硬件。配套包含AndroidManifest权限声明示例、USB权限弹窗触发逻辑、Logcat日志调试输出、build.gradle依赖配置说明以及本地NDK环境或预编译so库的接入指引。目录结构清晰含标准app/src/main代码组织无冗余文件适合快速集成到工业控制、智能硬件联调、现场数据采集等实际项目中。1. 项目概述为什么在Android上做串口通信远比“连个USB线”复杂得多你手头有一台工业平板想让它读取温湿度传感器的RS232数据或者你正在调试一款带STM32主控的智能电表需要通过USB转串口模块比如常见的CH340芯片把实时电流值传到App里显示又或者你在做一款便携式水质检测仪安卓终端要和内部的UART透传模组持续交换AT指令——这些场景都绕不开一个看似简单、实则暗坑密布的问题Android应用层怎么稳定、可靠、可维护地收发串口数据这不是PC端写个SerialPort.Open()就能搞定的事。Android不是Windows它没有原生串口API它也不是Linux桌面发行版不能直接open(/dev/ttyS0)就完事。它的权限模型、HAL抽象层、USB设备热插拔机制、SELinux策略、甚至不同厂商对/dev/tty*路径的命名习惯全都在给“串口通信”这件事层层设防。我做过不下20个硬件联调项目最常听到客户说的一句话是“你们的App在A设备上能连在B设备上点开就闪退日志里只有一行Permission denied到底谁没权限”——这问题背后不是代码写错了而是对Android串口通信的底层约束理解得不够深。这套封装库就是我在三年内踩过所有典型坑之后沉淀下来的“最小可行但足够健壮”的应用层方案。它不碰驱动不改内核不越权root完全运行在标准SDK权限体系下它用Java封装了SerialPort核心类但所有关键逻辑如fd文件描述符管理、JNI调用时机、USB权限回调链路都做了防御性设计它支持两种主流接入方式一种是设备自带物理串口常见于工控平板、车载终端路径形如/dev/ttyS1另一种是外接USB转串口适配器CH340/CP2102/FTDI路径动态生成为/dev/ttyUSB0并自动触发系统USB权限弹窗。更重要的是它把“异常处理”当第一优先级来设计——端口被其他进程占用捕获IOException并提示具体设备名用户点了“拒绝”USB权限不崩溃而是优雅降级并给出重试入口读缓冲区空了但线程还在等超时机制中断标记双保险。配套的build.gradle配置、AndroidManifest.xml声明模板、NDK环境接入指引全都按真实开发流程组织不是Demo玩具而是能直接拖进你现有项目的生产级组件。关键词里的“Android串口”“SerialPort封装”“USB串口通信”不是标签而是三个必须同时满足的硬约束它必须跑在Android上不是模拟器是真机、必须封装成可复用的Java接口不是一堆零散方法、必须兼容USB热插拔场景不是固定路径硬编码。接下来我会带你一层层拆解这个封装库是怎么从Linux内核的termios结构体一步步走到你App的mSerialPort.write(data)这一行调用背后的。2. 整体架构与设计思路为什么不用现成的开源库为什么坚持自己封装JNI先说结论我们没有用usb-serial-for-android或android-serialport-api这类流行库而是选择基于android_serialport_api原始分支深度定制并自研JNI层封装。这不是为了炫技而是工业现场的真实需求倒逼出来的选择。2.1 现有开源方案的三大硬伤我对比测试过5个主流Android串口库覆盖从2014年老项目到2023年新维护版本它们在实验室环境跑得飞快但一到客户现场就集体掉链子。原因很现实USB权限回调不可靠usb-serial-for-android依赖UsbManager.requestPermission()但华为EMUI、小米MIUI、OPPO ColorOS对USB权限弹窗的拦截策略各不相同。有些机型弹窗根本不出现在前台用户根本看不到有些机型点了“允许”后回调onReceive()里grantResults[0]却是PackageManager.PERMISSION_DENIED而库本身没做二次校验直接走空指针。我们遇到过某电力巡检终端70%的设备首次连接失败就卡在这一步。串口路径硬编码泛滥很多库默认写死/dev/ttyS0但实际中瑞芯微RK3399平台常用/dev/ttyS2高通骁龙865平板可能是/dev/ttyHS0而USB转串口在不同内核版本下路径从/dev/ttyUSB0变成/dev/bus/usb/001/002。开源库往往只提供一个setDevicePath()方法但没人告诉你路径变更必须在open()前设置且close()后再次open()时若路径不存在会静默失败——日志里连错误都不打。读写线程模型脆弱多数库用单线程HandlerThread轮询read()一旦外部设备发送数据速率超过50KB/s比如高速扫码枪回传图像特征码缓冲区溢出、线程阻塞、ANR就接踵而至。更致命的是它们很少处理EAGAIN资源暂时不可用这种Linux底层返回码导致read()返回0字节时误判为“通信结束”。2.2 我们的分层封装策略四层隔离责任明确为解决上述问题我们构建了清晰的四层架构每层只做一件事且边界严格层级名称职责关键实现细节第1层Native层C/C直接调用Linuxopen()/ioctl()/write()/read()配置termios结构体波特率、数据位、停止位、校验位使用#include asm/termbits.h确保兼容ARM64/ARMv7cfsetispeed()/cfsetospeed()分别设置输入输出波特率tcflush(fd, TCIOFLUSH)清空缓冲区防脏数据第2层JNI桥接层.so库将Native函数暴露为Java可调用方法管理jobject生命周期避免内存泄漏将Linux errno映射为Java Exception预编译libserial_port.so支持armeabi-v7a/arm64-v8a/x86_64throwIOException(env, open failed: %s, strerror(errno))统一异常格式第3层SerialPort核心类Java封装open()/close()/read()/write()四个原子操作维护FileDescriptor引用计数提供setTimeout()控制读超时open()成功后立即调用setSpeed()验证波特率是否生效read()使用ByteBuffer.allocateDirect(4096)避免GC停顿close()前强制tcdrain()确保数据发完第4层SerialPortManager业务胶水管理多串口实例处理USB热插拔广播封装权限请求逻辑提供线程安全的sendAndReceive()同步方法监听UsbManager.ACTION_USB_DEVICE_ATTACHED/DETACHED权限回调后自动重试open()sendAndReceive()内部用CountDownLatch等待响应超时抛TimeoutException这个设计的核心思想是把Linux系统调用的不确定性锁死在Native层把Android框架的碎片化隔离在Manager层让业务开发者只和干净的Java接口打交道。比如你要发一条AT指令并等待OK响应只需写String response manager.sendAndReceive(ATVERSION\r\n, OK, 3000);而不用关心当前设备是USB还是内置串口、权限弹窗是否已授权、读线程是否卡死、缓冲区有没有残留垃圾数据。2.3 为什么必须自己编译.so预编译库的隐性成本项目里提供了app/src/main/jniLibs/目录下的预编译.so库但我们也附带了完整的jni/源码和Android.mk。很多人问“既然有预编译库为什么还要放源码”答案是预编译库省了你第一次编译的时间但埋下了长期维护的雷。我们遇到过最典型的案例某客户采购的国产工控板内核是定制的Linux 4.19但禁用了CONFIG_TTY_PRINTK选项。结果我们的预编译库在open()时调用ioctl(fd, TCGETS, tty)获取当前串口参数内核返回ENOTTY错误而库没做ENOTTY特殊处理直接抛IOException。如果只有预编译库你只能干瞪眼但有了源码加三行判断就解决if (ioctl(fd, TCGETS, tty) -1) { if (errno ENOTTY) { // 内核禁用TTY ioctl跳过参数检查用默认值 memset(tty, 0, sizeof(tty)); cfmakeraw(tty); // 设置原始模式 } else { throwIOException(env, TCGETS failed: %s, strerror(errno)); return -1; } }所以我们的.so库不是黑盒而是“开箱即用但随时可修”的白盒组件。后续章节会详细说明如何根据你的硬件平台快速修改JNI源码并重新编译。3. 核心细节解析SerialPort类的每一个字段都是踩坑后加上的SerialPort.java是整个封装库的心脏。它看起来只有几百行代码但每一行都对应一个真实场景的教训。下面我逐字段解读其设计意图不讲概念只说“为什么这么写”。3.1 关键字段设计从Linux fd到Java对象的生命周期绑定private FileDescriptor mFd; // Linux内核分配的文件描述符open()后非空 private int mBaudRate; // 波特率open()时传入Native层用于cfsetispeed() private boolean mIsOpened; // 双重校验标志mFd ! null mIsOpened true才认为打开成功 private final Object mReadLock new Object(); // 读操作独占锁防止多线程并发read()导致缓冲区错乱 private final Object mWriteLock new Object(); // 同理写操作锁 private volatile boolean mIsReading; // 原子布尔标记读线程是否正在运行close()时用于中断 private volatile boolean mIsWriting; // 同理标记写线程状态 private static final SparseArraySerialPort sOpenPorts new SparseArray(); // 全局串口实例缓存key为fd整数值重点解释几个容易被忽略的细节mIsOpened为什么需要独立于mFd因为open()调用JNI后可能mFd已赋值但紧接着setSpeed()失败比如波特率不支持此时mFd还有效但串口逻辑上并未真正打开。若只判断mFd ! null就认为可用后续read()会返回0或-1业务层难以区分是“没数据”还是“根本没通”。所以必须用mIsOpened作为业务态开关。SparseArraySerialPort缓存的意义当USB设备热插拔时系统可能回收旧FileDescriptor并分配新fd。如果业务层持有旧SerialPort实例调用write()时传入已失效的fdNative层write()会返回-1并置errnoEBADFBad file descriptor。通过SparseArray以fd为key缓存实例可在UsbReceiver收到DETACHED广播时快速定位并清理所有关联实例避免野指针。volatile修饰mIsReading/mIsWriting这是为了close()方法能安全中断读写线程。close()里会先置mIsReading false再调用mReadThread.interrupt()。由于线程可能正在执行while(mIsReading) { read(); }循环volatile保证了mIsReading的修改对读线程立即可见避免无限循环。3.2 open()方法权限、路径、参数的三重校验open()不是简单调JNI而是包含三个阶段的防御性流程阶段1权限预检Android Framework层// 检查是否已获得USB权限针对USB设备 if (device ! null !usbManager.hasPermission(device)) { // 触发权限弹窗不直接open() usbManager.requestPermission(device, mPermissionIntent); return false; } // 检查是否已获得串口设备文件读写权限针对内置串口 if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { if (!Environment.isExternalStorageManager()) { // Android 11 需要MANAGE_EXTERNAL_STORAGE但串口不涉及存储此处为示例 // 实际中检查的是SELinux上下文见下文 } }阶段2路径合法性校验Linux Kernel层// 路径必须存在且可访问 File deviceFile new File(devicePath); if (!deviceFile.exists()) { throw new IOException(Device path not exist: devicePath); } if (!deviceFile.canRead() || !deviceFile.canWrite()) { // 关键这里会触发SELinux拒绝日志需在logcat中搜索avc: denied throw new IOException(No read/write permission on devicePath); }提示canRead()/canWrite()在Android上受SELinux策略控制。比如某设备/dev/ttyS1的SELinux上下文是u:object_r:serial_device:s0而App进程上下文是u:r:untrusted_app:s0:c123,c256,c512,c768策略未授权则返回false。解决方案不是关闭SELinux不安全而是让厂商在sepolicy中添加规则allow untrusted_app serial_device:chr_file { read write open }。阶段3Native层参数验证C Library层JNI层open()最终调用int fd open(device_path, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) { throwIOException(env, open %s failed: %s, device_path, strerror(errno)); return -1; } struct termios tty; if (tcgetattr(fd, tty) ! 0) { close(fd); throwIOException(env, tcgetattr failed: %s, strerror(errno)); return -1; } cfmakeraw(tty); // 清除所有输入输出处理标志 cfsetispeed(tty, speed); // 设置输入波特率 cfsetospeed(tty, speed); // 设置输出波特率 tty.c_cflag | CREAD | CLOCAL; // 启用接收器忽略MODEM控制线 tty.c_cflag ~CRTSCTS; // 禁用硬件流控工业设备通常不用 tty.c_cflag ~CSIZE; // 清除数据位掩码 tty.c_cflag | CS8; // 设置8位数据位 tty.c_cflag ~PARENB; // 禁用校验位 tty.c_cflag ~CSTOPB; // 设置1位停止位 tty.c_iflag ~(IXON | IXOFF | IXANY); // 禁用软件流控 tty.c_lflag ~(ICANON | ECHO | ECHOE | ISIG); // 原始输入模式 tty.c_oflag ~OPOST; // 原始输出模式 if (tcsetattr(fd, TCSANOW, tty) ! 0) { close(fd); throwIOException(env, tcsetattr failed: %s, strerror(errno)); return -1; } return fd;这段C代码的关键在于它不信任任何上层传来的“配置正确”假设每个ioctl()调用后都检查返回值并将errno转化为Java可读的异常信息。比如tcsetattr()失败可能是波特率不被硬件支持EINVAL也可能是串口被其他进程独占EBUSY异常消息里直接带出strerror(errno)让你一眼定位根因。3.3 read()与write()如何避免“数据粘包”和“线程阻塞”串口是字节流没有消息边界。传感器发0x02 0x01 0x03你read(3)可能只拿到0x02下次read(3)才拿到0x01 0x03。很多库把这个问题甩给业务层但我们封装了基础协议解析能力。read()的健壮实现public byte[] read(int timeoutMillis) throws IOException { synchronized (mReadLock) { if (!mIsOpened) { throw new IOException(Serial port not opened); } // 分配直接内存缓冲区避免GC影响实时性 ByteBuffer buffer ByteBuffer.allocateDirect(4096); int len serial_read(mFd, buffer, timeoutMillis); // JNI调用 if (len 0) { return new byte[0]; // 超时或无数据返回空数组而非null } byte[] data new byte[len]; buffer.get(data, 0, len); return data; } }serial_read()在JNI层实现超时控制int serial_read(int fd, uint8_t *buffer, int timeout_ms) { fd_set read_fds; struct timeval tv; FD_ZERO(read_fds); FD_SET(fd, read_fds); tv.tv_sec timeout_ms / 1000; tv.tv_usec (timeout_ms % 1000) * 1000; int ret select(fd 1, read_fds, NULL, NULL, tv); if (ret 0) { return 0; // timeout } else if (ret 0) { return -1; // select error } // select返回后fd肯定可读read不会阻塞 return read(fd, buffer, 4096); }write()的防丢包设计public void write(byte[] data) throws IOException { synchronized (mWriteLock) { if (!mIsOpened) { throw new IOException(Serial port not opened); } // 检查数据长度避免Native层缓冲区溢出 if (data.length 0) return; if (data.length 4096) { throw new IllegalArgumentException(Data length too large: data.length); } serial_write(mFd, data, data.length); // JNI调用 // 关键强制刷新输出缓冲区确保数据立即发出 tcdrain(mFd); } }tcdrain()是工业场景的救命稻草。没有它write()返回后数据可能还卡在内核发送缓冲区设备端迟迟收不到。加上这行才能保证“写完即发”。4. 实操过程详解从零开始集成到稳定运行的完整链路现在我们把理论落地。假设你刚拿到一台新设备比如RK3399工控平板想用它连接一个通过CH340转接的温湿度传感器。以下是我在客户现场手把手教工程师的操作步骤精确到点击位置和日志关键字。4.1 环境准备Android Studio配置与NDK接入第一步确认Android Studio版本与NDK版本匹配- 推荐Android Studio Giraffe | 2022.3.1 Patch 2或更高- NDK版本必须为23.1.7779620这是经过我们全平台测试最稳定的版本- 在local.properties中指定NDK路径properties ndk.dir/Users/yourname/Library/Android/sdk/ndk/23.1.7779620注意NDK 24版本移除了android-serialport-api依赖的asm/termbits.h头文件会导致编译失败。如果你必须用新版NDK请在jni/Android.mk中添加makefile APP_CFLAGS -I$(NDK_ROOT)/sysroot/usr/include/arm-linux-androideabi第二步导入项目并识别架构- 打开Android Studio →Open an existing project→ 选择项目根目录- 等待Gradle同步完成观察Build窗口- 若出现Could not find method android() for arguments [...]说明build.gradleProject中classpath com.android.tools.build:gradle:7.4.2版本过低升级到8.1.1- 若出现NDK not configured点击Install NDK按钮选择23.1.7779620第三步检查jniLibs目录- 展开app/src/main/jniLibs/应看到arm64-v8a/ libserial_port.so armeabi-v7a/ libserial_port.so x86_64/ libserial_port.so- 如果缺少某个架构比如没有arm64-v8a运行时会报java.lang.UnsatisfiedLinkError: dlopen failed: library libserial_port.so not found。此时需手动编译缺失架构或从项目prebuilt/目录复制。4.2 权限配置AndroidManifest.xml与运行时请求AndroidManifest.xml必须声明!-- USB串口必需 -- uses-feature android:nameandroid.hardware.usb.host / !-- 串口设备访问Android 10 需要 -- uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / !-- 读写串口设备文件Android 12 SELinux要求 -- uses-permission android:nameandroid.permission.READ_MEDIA_IMAGES / !-- 仅调试用正式版可移除 -- uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE /提示ACCESS_FINE_LOCATION权限看似无关实则是Android 10对USB设备枚举的强制要求。不声明此权限UsbManager.getDeviceList()返回空Map你的CH340设备永远“看不见”。运行时USB权限请求逻辑在MainActivity.java中private UsbManager mUsbManager; private UsbDeviceConnection mConnection; private PendingIntent mPermissionIntent; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); mUsbManager (UsbManager) getSystemService(Context.USB_SERVICE); mPermissionIntent PendingIntent.getBroadcast(this, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE); // 注册USB权限广播接收器 IntentFilter filter new IntentFilter(ACTION_USB_PERMISSION); registerReceiver(mUsbReceiver, filter); } private final BroadcastReceiver mUsbReceiver new BroadcastReceiver() { Override public void onReceive(Context context, Intent intent) { String action intent.getAction(); if (ACTION_USB_PERMISSION.equals(action)) { synchronized (this) { UsbDevice device (UsbDevice) intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { // 权限已授予尝试打开串口 openSerialPort(device); } else { Toast.makeText(context, USB permission denied, Toast.LENGTH_SHORT).show(); // 此处可引导用户去系统设置手动授权 } } } } }; private void requestUsbPermission(UsbDevice device) { if (device ! null !mUsbManager.hasPermission(device)) { mUsbManager.requestPermission(device, mPermissionIntent); } }关键经验-PendingIntent.FLAG_IMMUTABLE是Android 12强制要求漏写会导致SecurityException-UsbManager.hasPermission()必须在requestPermission()前调用否则可能重复弹窗- 权限弹窗有时被系统通知栏遮挡建议在onResume()中检查一次权限状态未授权则Toast提醒4.3 串口参数配置如何找到正确的设备路径与波特率这是新手最容易卡住的环节。别猜用工具验证。步骤1连接USB转串口模块查看设备列表- 在Android StudioTerminal中执行bash adb shell ls /dev/tty*- 常见输出/dev/ttyS0 /dev/ttyS1 /dev/ttyUSB0 /dev/ttyHS0- 如果看到/dev/ttyUSB0说明CH340已被识别如果只有/dev/ttyS*说明设备用的是内置串口。步骤2确认USB设备VID/PID匹配驱动adb shell cat /sys/bus/usb/devices/*/idVendor 2/dev/null | grep -A1 1a86 # CH340的VID是1a86 adb shell cat /sys/bus/usb/devices/*/idProduct 2/dev/null | grep -A1 7523 # CH340的PID是7523步骤3设置串口参数以温湿度传感器为例- 传感器手册标明波特率9600数据位8停止位1无校验无流控- 在代码中javaSerialPortConfig config new SerialPortConfig();config.devicePath “/dev/ttyUSB0”; // 或 “/dev/ttyS1”config.baudRate 9600;config.dataBits 8;config.stopBits 1;config.parity “none”;config.flowControl “none”;try {mSerialPort new SerialPort(config);Log.d(“Serial”, “Open success: ” config.devicePath);} catch (IOException e) {Log.e(“Serial”, “Open failed”, e);// e.getMessage() 会包含具体错误如 “open /dev/ttyUSB0 failed: Permission denied”}避坑指南-路径大小写敏感/dev/ttyusb0是错的必须是/dev/ttyUSB0-波特率必须匹配传感器设9600你设115200收到全是乱码且read()可能永远阻塞-不要在onCreate()里直接openActivity可能被系统回收应在onResume()中检查并重连4.4 数据收发实战一个完整的AT指令交互示例假设传感器支持AT指令查询温度发送ATTEMP?返回TEMP:25.6\r\nOK\r\n。// 发送指令并等待响应 private String sendAtCommand(String command, String expect, long timeoutMs) { try { // 1. 清空输入缓冲区避免残留数据干扰 mSerialPort.clearInputBuffer(); // 2. 发送指令注意\r\n换行符 mSerialPort.write((command \r\n).getBytes(StandardCharsets.US_ASCII)); // 3. 循环读取直到收到expect或超时 long startTime System.currentTimeMillis(); StringBuilder response new StringBuilder(); while (System.currentTimeMillis() - startTime timeoutMs) { byte[] data mSerialPort.read(100); // 每次读最多100字节 if (data.length 0) { response.append(new String(data, StandardCharsets.US_ASCII)); // 检查是否包含期望响应 if (response.toString().contains(expect)) { return response.toString(); } } Thread.sleep(10); // 避免忙等待 } return TIMEOUT; } catch (Exception e) { Log.e(AT, Send failed, e); return ERROR: e.getMessage(); } } // 调用 String result sendAtCommand(ATTEMP?, OK, 5000); Log.d(TEMP, Result: result); // 输出Result: TEMP:25.6\r\nOK\r\n实测心得-clearInputBuffer()至关重要。传感器可能在上电时自发发送启动信息不清理会导致ATTEMP?的响应混在垃圾数据里-Thread.sleep(10)不是随便写的。实测发现间隔小于5ms时某些低端USB转串口芯片如劣质CH340会丢包大于20ms则响应延迟过高-StandardCharsets.US_ASCII显式指定编码避免中文系统下默认UTF-8导致getBytes()长度计算错误5. 常见问题与排查技巧实录那些让工程师熬夜的日志我把近三年支持客户过程中高频出现的12个问题整理成速查表。每个问题都附带现象、根因、验证命令、解决方案按发生频率排序。序号现象根因验证命令解决方案1open failed: Permission deniedSELinux策略拒绝访问/dev/tty*adb logcat \| grep avc看是否有avc: denied { open } for ...联系设备厂商添加SELinux规则或临时用adb shell su -c setenforce 0仅调试2read() returns 0 bytes repeatedly外部设备未供电或TX/RX线接反用万用表测/dev/ttyUSB0的RX引脚电压正常应为3.3V左右检查接线CH340模块RX接传感器TXTX接传感器RX3App闪退logcat报java.lang.UnsatisfiedLinkErrorjniLibs中缺少当前CPU架构的.soadb shell getprop ro.product.cpu.abi输出arm64-v8a则需该目录从prebuilt/复制对应架构so或重新编译4USB设备插入后UsbManager.getDeviceList()为空AndroidManifest.xml未声明uses-feature android:nameandroid.hardware.usb.host /adb shell dumpsys usb看mHostController是否为true补全uses-feature声明5sendAndReceive()总超时但串口助手能正常通信传感器响应末尾无\r\n或使用\n而非\r\n用串口助手发送ATTEMP?观察返回是否含OK及换行符修改sendAtCommand()中的expect为OK去掉\r\n匹配6多次热插拔USB后read()返回乱码内核缓冲区残留未清空adb shell cat /proc/tty/driver/usbserial看rx计数是否持续增长mSerialPort.clearInputBuffer()放在每次read()前7write()后传感器无反应但串口助手正常tcdrain()未调用数据卡在内核缓冲区adb shell cat /proc/tty/driver/usbserial看tx计数是否增加在write()方法末尾添加tcdrain(mFd)JNI层8华为手机无法弹出USB权限框EMUI系统限制后台弹窗adb shell dumpsys activity activities \| grep mResumedActivity确认Activity在前台确保requestPermission()在onResume()中调用且Activity未被系统回收9read()偶尔返回-1errnoETIMEDOUTselect()超时时间设置过短查看serial_read()中timeout_ms参数是否小于传感器响应时间将timeoutMillis从1000改为500010同一设备上A App能连B App连不上A App已独占串口O_EXCL标志阻止其他进程打开adb shell lsof \| grep ttyUSB0看是否有其他进程占用close()后确保mFd置null或重启设备释放占用11build.gradle同步失败报NDK version is outdatedbuild.gradleModule中ndkVersion与本地NDK不匹配cat local.properties \| grep ndk.dir再看$NDK_PATH/source.properties在build.gradleModule中设置ndkVersion 23.1.777962012logcat无任何串口相关日志Log.d()被ProGuard混淆移除adb logcat \| grep Serial无输出则检查ProGuard在proguard-rules.pro中添加-keep class com.yourpackage.serial.** { *; }5.1 一个真实案例某电力终端的“间歇性丢包”之谜客户反馈设备连续运行8小时后串口通信开始丢包read()返回数据不完整重启App即可恢复。日志里没有任何异常。排查过程1. 首先排除硬件用同一CH340模块连接PC串口助手24小时满负荷测试无丢包 → 硬件正常2. 检查内存adb shell dumpsys meminfo your.package.name发现Dalvik Heap持续增长但未OOM → 可能内存泄漏3. 关键线索adb shell cat /proc/tty/driver/usbserial显示rx计数在丢包时停滞但tx计数正常 → 问题在接收侧4. 深入JNI层发现serial_read()中select()返回后read()调用前未检查FD_ISSET()导致read()可能读取到已关闭的fd → 返回-1但Java层未捕获errnoEBADF修复方案在JNIserial_read()中添加if (FD_ISSET(fd, read_fds)) { ret read(fd, buffer, size); } else { ret 0; // select返回但fd不可读视为超时 }经验总结- 工业场景的“偶发问题”90%源于资源未正确释放fd、线程、内存- 不要迷信日志/proc/tty/driver/下的内核统计才是真相-select()只是告诉你“可能有数据”read()前必须FD_ISSET()二次确认6. 进阶扩展与定制化如何适配你的特殊硬件需求这套封装库的设计原则是“开箱即用按需定制”。以下是我为客户做的三次典型定制说明如何安全地修改源码。6.1 定制1支持RS485自动收发切换DE引脚控制某客户用RS485总线连接16台电表需要MCU控制DEDriver Enable引脚。标准串口库不提供GPIO控制。改造方案- 在jni/serial_port.c中添加GPIO控制函数c #define GPIO_PATH /sys/class/gpio/gpio123/value // 假设DE接GPIO123 void set_rs485_de(int enable) { int fd open(GPIO_PATH, O_WRONLY); if (fd 0) { write(fd, enable ? 1 : 0, 1); close(fd); } }- 在Java层SerialPort.java中暴露java public void setRs485De(boolean enable) { serial_set_rs485_de(enable ? 1 : 0); }- 使用时java mSerialPort.setRs485De(true); // 拉高DE进入发送模式 mSerialPort.write(data); mSerialPort.setRs485De(false); // 拉低DE进入接收模式6.2 定制2添加CRC16校验自动附加与校验传感器协议要求每帧数据尾部加2字节CRC16Modbus RTU。手动计算易出错。改造方案- 在SerialPortManager.java中添加javapublic byte[] appendCrc16(byte[] data) {short crc calculateCrc16(data);return Bytes.concat(data, new byte[]{(byte)(crc 0xFF), (byte)((crc 8) 0xFF)});}private short calculateCrc16(byte[] data) {short crc 0xFFFF;for (byte b : data) {crc ^ (short) (b 0xFF);for (int i 0; i 8; i) {if ((crc 1) ! 0) {crc (short) ((crc 1) ^ 0xA001);} else {crc 1;}}}return crc;}- 使用javabyte[] frame appendCrc16(“ATREAD”.getBytes());mSerialPort.write(frame);6.3 定制3对接Android Things GPIO已停产但原理通用虽然Android Things已停止维护但其GPIO API设计值得借鉴。我们将其抽象为GpioController接口public interface GpioController { void export(int pin) throws IOException; void setValue(int pin, boolean value) throws IOException; void unexport(int pin) throws IOException; } // 具体实现可基于/sys/class/gpioLinux标准或厂商HAL public class SysfsGpioController implements GpioController { Override public void export(int pin) throws IOException { writeToFile(/sys/class/gpio/export, String.valueOf(pin)); } // ... 其他方法 }这样未来对接任何GPIO方案只需替换实现类上层业务代码完全不变。7. 最后的实操体会稳定性的本质是把所有“可能”都变成“必然”写这篇博文时我正坐在客户工厂的调试现场面前是三台不同品牌的安卓工控机分别连着PLC、变频器和条码扫描枪。它们此刻都在稳定地收发数据日志里没有一行红色的E/Serial。这看起来很平常但我知道这“平常”背后是无数个被Permission denied卡住的下午是反复刷机验证SELinux策略的深夜是抓着示波器看TX引脚波形确认波特率的周末。这套串口封装库的价值从来不在代码有多炫酷而在于它把Android串口通信中所有“可能出问题”的环节都变成了“必然有应对”的设计。open()失败时你知道是权限、路径还是SELinuxread()超时时你能立刻判断是设备响应慢还是线程卡死USB热插拔后权限回调的每一步都清晰可控。它不承诺“100%兼容所有设备”但承诺“每一次失败都给你足够精准的线索去定位”。如果你正面临类似的硬件联调任务我的建议是先别急着改代码花10分钟把adb logcat | grep -E (Serial|usb|avc)的输出贴到文本编辑器里按时间排序像读侦探小说一样找出第一个异常信号。90%的问题根因就藏在那第一条avc: denied日志里。剩下的不过是按图索骥。最后分享一个小技巧在SerialPort.java的close()方法末尾加一行Log.d(Serial, Closed: mDevicePath);。当现场问题扑朔迷离时这行日志能帮你确认——到底是串口真的关了还是你以为它关了其实还在后台偷偷发着数据。本文还有配套的精品资源点击获取简介一套开箱即用的Android串口通信实现专注应用层调用封装不涉及底层驱动开发。提供已验证的SerialPort Java类完整封装open/close/read/write操作自动处理端口占用、权限拒绝、读写异常等常见问题。支持通过USB转串口适配器如CH340、CP2102或设备内置串口与单片机、PLC、传感器等外设通信。项目基于Gradle构建兼容Android 5.0API 21至最新版本可直接导入Android Studio运行只需修改串口路径如/dev/ttyUSB0、波特率、数据位等参数即可适配不同硬件。配套包含AndroidManifest权限声明示例、USB权限弹窗触发逻辑、Logcat日志调试输出、build.gradle依赖配置说明以及本地NDK环境或预编译so库的接入指引。目录结构清晰含标准app/src/main代码组织无冗余文件适合快速集成到工业控制、智能硬件联调、现场数据采集等实际项目中。本文还有配套的精品资源点击获取
Android应用层串口通信封装库(含USB转串口调试可用源码)
本文还有配套的精品资源点击获取简介一套开箱即用的Android串口通信实现专注应用层调用封装不涉及底层驱动开发。提供已验证的SerialPort Java类完整封装open/close/read/write操作自动处理端口占用、权限拒绝、读写异常等常见问题。支持通过USB转串口适配器如CH340、CP2102或设备内置串口与单片机、PLC、传感器等外设通信。项目基于Gradle构建兼容Android 5.0API 21至最新版本可直接导入Android Studio运行只需修改串口路径如/dev/ttyUSB0、波特率、数据位等参数即可适配不同硬件。配套包含AndroidManifest权限声明示例、USB权限弹窗触发逻辑、Logcat日志调试输出、build.gradle依赖配置说明以及本地NDK环境或预编译so库的接入指引。目录结构清晰含标准app/src/main代码组织无冗余文件适合快速集成到工业控制、智能硬件联调、现场数据采集等实际项目中。1. 项目概述为什么在Android上做串口通信远比“连个USB线”复杂得多你手头有一台工业平板想让它读取温湿度传感器的RS232数据或者你正在调试一款带STM32主控的智能电表需要通过USB转串口模块比如常见的CH340芯片把实时电流值传到App里显示又或者你在做一款便携式水质检测仪安卓终端要和内部的UART透传模组持续交换AT指令——这些场景都绕不开一个看似简单、实则暗坑密布的问题Android应用层怎么稳定、可靠、可维护地收发串口数据这不是PC端写个SerialPort.Open()就能搞定的事。Android不是Windows它没有原生串口API它也不是Linux桌面发行版不能直接open(/dev/ttyS0)就完事。它的权限模型、HAL抽象层、USB设备热插拔机制、SELinux策略、甚至不同厂商对/dev/tty*路径的命名习惯全都在给“串口通信”这件事层层设防。我做过不下20个硬件联调项目最常听到客户说的一句话是“你们的App在A设备上能连在B设备上点开就闪退日志里只有一行Permission denied到底谁没权限”——这问题背后不是代码写错了而是对Android串口通信的底层约束理解得不够深。这套封装库就是我在三年内踩过所有典型坑之后沉淀下来的“最小可行但足够健壮”的应用层方案。它不碰驱动不改内核不越权root完全运行在标准SDK权限体系下它用Java封装了SerialPort核心类但所有关键逻辑如fd文件描述符管理、JNI调用时机、USB权限回调链路都做了防御性设计它支持两种主流接入方式一种是设备自带物理串口常见于工控平板、车载终端路径形如/dev/ttyS1另一种是外接USB转串口适配器CH340/CP2102/FTDI路径动态生成为/dev/ttyUSB0并自动触发系统USB权限弹窗。更重要的是它把“异常处理”当第一优先级来设计——端口被其他进程占用捕获IOException并提示具体设备名用户点了“拒绝”USB权限不崩溃而是优雅降级并给出重试入口读缓冲区空了但线程还在等超时机制中断标记双保险。配套的build.gradle配置、AndroidManifest.xml声明模板、NDK环境接入指引全都按真实开发流程组织不是Demo玩具而是能直接拖进你现有项目的生产级组件。关键词里的“Android串口”“SerialPort封装”“USB串口通信”不是标签而是三个必须同时满足的硬约束它必须跑在Android上不是模拟器是真机、必须封装成可复用的Java接口不是一堆零散方法、必须兼容USB热插拔场景不是固定路径硬编码。接下来我会带你一层层拆解这个封装库是怎么从Linux内核的termios结构体一步步走到你App的mSerialPort.write(data)这一行调用背后的。2. 整体架构与设计思路为什么不用现成的开源库为什么坚持自己封装JNI先说结论我们没有用usb-serial-for-android或android-serialport-api这类流行库而是选择基于android_serialport_api原始分支深度定制并自研JNI层封装。这不是为了炫技而是工业现场的真实需求倒逼出来的选择。2.1 现有开源方案的三大硬伤我对比测试过5个主流Android串口库覆盖从2014年老项目到2023年新维护版本它们在实验室环境跑得飞快但一到客户现场就集体掉链子。原因很现实USB权限回调不可靠usb-serial-for-android依赖UsbManager.requestPermission()但华为EMUI、小米MIUI、OPPO ColorOS对USB权限弹窗的拦截策略各不相同。有些机型弹窗根本不出现在前台用户根本看不到有些机型点了“允许”后回调onReceive()里grantResults[0]却是PackageManager.PERMISSION_DENIED而库本身没做二次校验直接走空指针。我们遇到过某电力巡检终端70%的设备首次连接失败就卡在这一步。串口路径硬编码泛滥很多库默认写死/dev/ttyS0但实际中瑞芯微RK3399平台常用/dev/ttyS2高通骁龙865平板可能是/dev/ttyHS0而USB转串口在不同内核版本下路径从/dev/ttyUSB0变成/dev/bus/usb/001/002。开源库往往只提供一个setDevicePath()方法但没人告诉你路径变更必须在open()前设置且close()后再次open()时若路径不存在会静默失败——日志里连错误都不打。读写线程模型脆弱多数库用单线程HandlerThread轮询read()一旦外部设备发送数据速率超过50KB/s比如高速扫码枪回传图像特征码缓冲区溢出、线程阻塞、ANR就接踵而至。更致命的是它们很少处理EAGAIN资源暂时不可用这种Linux底层返回码导致read()返回0字节时误判为“通信结束”。2.2 我们的分层封装策略四层隔离责任明确为解决上述问题我们构建了清晰的四层架构每层只做一件事且边界严格层级名称职责关键实现细节第1层Native层C/C直接调用Linuxopen()/ioctl()/write()/read()配置termios结构体波特率、数据位、停止位、校验位使用#include asm/termbits.h确保兼容ARM64/ARMv7cfsetispeed()/cfsetospeed()分别设置输入输出波特率tcflush(fd, TCIOFLUSH)清空缓冲区防脏数据第2层JNI桥接层.so库将Native函数暴露为Java可调用方法管理jobject生命周期避免内存泄漏将Linux errno映射为Java Exception预编译libserial_port.so支持armeabi-v7a/arm64-v8a/x86_64throwIOException(env, open failed: %s, strerror(errno))统一异常格式第3层SerialPort核心类Java封装open()/close()/read()/write()四个原子操作维护FileDescriptor引用计数提供setTimeout()控制读超时open()成功后立即调用setSpeed()验证波特率是否生效read()使用ByteBuffer.allocateDirect(4096)避免GC停顿close()前强制tcdrain()确保数据发完第4层SerialPortManager业务胶水管理多串口实例处理USB热插拔广播封装权限请求逻辑提供线程安全的sendAndReceive()同步方法监听UsbManager.ACTION_USB_DEVICE_ATTACHED/DETACHED权限回调后自动重试open()sendAndReceive()内部用CountDownLatch等待响应超时抛TimeoutException这个设计的核心思想是把Linux系统调用的不确定性锁死在Native层把Android框架的碎片化隔离在Manager层让业务开发者只和干净的Java接口打交道。比如你要发一条AT指令并等待OK响应只需写String response manager.sendAndReceive(ATVERSION\r\n, OK, 3000);而不用关心当前设备是USB还是内置串口、权限弹窗是否已授权、读线程是否卡死、缓冲区有没有残留垃圾数据。2.3 为什么必须自己编译.so预编译库的隐性成本项目里提供了app/src/main/jniLibs/目录下的预编译.so库但我们也附带了完整的jni/源码和Android.mk。很多人问“既然有预编译库为什么还要放源码”答案是预编译库省了你第一次编译的时间但埋下了长期维护的雷。我们遇到过最典型的案例某客户采购的国产工控板内核是定制的Linux 4.19但禁用了CONFIG_TTY_PRINTK选项。结果我们的预编译库在open()时调用ioctl(fd, TCGETS, tty)获取当前串口参数内核返回ENOTTY错误而库没做ENOTTY特殊处理直接抛IOException。如果只有预编译库你只能干瞪眼但有了源码加三行判断就解决if (ioctl(fd, TCGETS, tty) -1) { if (errno ENOTTY) { // 内核禁用TTY ioctl跳过参数检查用默认值 memset(tty, 0, sizeof(tty)); cfmakeraw(tty); // 设置原始模式 } else { throwIOException(env, TCGETS failed: %s, strerror(errno)); return -1; } }所以我们的.so库不是黑盒而是“开箱即用但随时可修”的白盒组件。后续章节会详细说明如何根据你的硬件平台快速修改JNI源码并重新编译。3. 核心细节解析SerialPort类的每一个字段都是踩坑后加上的SerialPort.java是整个封装库的心脏。它看起来只有几百行代码但每一行都对应一个真实场景的教训。下面我逐字段解读其设计意图不讲概念只说“为什么这么写”。3.1 关键字段设计从Linux fd到Java对象的生命周期绑定private FileDescriptor mFd; // Linux内核分配的文件描述符open()后非空 private int mBaudRate; // 波特率open()时传入Native层用于cfsetispeed() private boolean mIsOpened; // 双重校验标志mFd ! null mIsOpened true才认为打开成功 private final Object mReadLock new Object(); // 读操作独占锁防止多线程并发read()导致缓冲区错乱 private final Object mWriteLock new Object(); // 同理写操作锁 private volatile boolean mIsReading; // 原子布尔标记读线程是否正在运行close()时用于中断 private volatile boolean mIsWriting; // 同理标记写线程状态 private static final SparseArraySerialPort sOpenPorts new SparseArray(); // 全局串口实例缓存key为fd整数值重点解释几个容易被忽略的细节mIsOpened为什么需要独立于mFd因为open()调用JNI后可能mFd已赋值但紧接着setSpeed()失败比如波特率不支持此时mFd还有效但串口逻辑上并未真正打开。若只判断mFd ! null就认为可用后续read()会返回0或-1业务层难以区分是“没数据”还是“根本没通”。所以必须用mIsOpened作为业务态开关。SparseArraySerialPort缓存的意义当USB设备热插拔时系统可能回收旧FileDescriptor并分配新fd。如果业务层持有旧SerialPort实例调用write()时传入已失效的fdNative层write()会返回-1并置errnoEBADFBad file descriptor。通过SparseArray以fd为key缓存实例可在UsbReceiver收到DETACHED广播时快速定位并清理所有关联实例避免野指针。volatile修饰mIsReading/mIsWriting这是为了close()方法能安全中断读写线程。close()里会先置mIsReading false再调用mReadThread.interrupt()。由于线程可能正在执行while(mIsReading) { read(); }循环volatile保证了mIsReading的修改对读线程立即可见避免无限循环。3.2 open()方法权限、路径、参数的三重校验open()不是简单调JNI而是包含三个阶段的防御性流程阶段1权限预检Android Framework层// 检查是否已获得USB权限针对USB设备 if (device ! null !usbManager.hasPermission(device)) { // 触发权限弹窗不直接open() usbManager.requestPermission(device, mPermissionIntent); return false; } // 检查是否已获得串口设备文件读写权限针对内置串口 if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { if (!Environment.isExternalStorageManager()) { // Android 11 需要MANAGE_EXTERNAL_STORAGE但串口不涉及存储此处为示例 // 实际中检查的是SELinux上下文见下文 } }阶段2路径合法性校验Linux Kernel层// 路径必须存在且可访问 File deviceFile new File(devicePath); if (!deviceFile.exists()) { throw new IOException(Device path not exist: devicePath); } if (!deviceFile.canRead() || !deviceFile.canWrite()) { // 关键这里会触发SELinux拒绝日志需在logcat中搜索avc: denied throw new IOException(No read/write permission on devicePath); }提示canRead()/canWrite()在Android上受SELinux策略控制。比如某设备/dev/ttyS1的SELinux上下文是u:object_r:serial_device:s0而App进程上下文是u:r:untrusted_app:s0:c123,c256,c512,c768策略未授权则返回false。解决方案不是关闭SELinux不安全而是让厂商在sepolicy中添加规则allow untrusted_app serial_device:chr_file { read write open }。阶段3Native层参数验证C Library层JNI层open()最终调用int fd open(device_path, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) { throwIOException(env, open %s failed: %s, device_path, strerror(errno)); return -1; } struct termios tty; if (tcgetattr(fd, tty) ! 0) { close(fd); throwIOException(env, tcgetattr failed: %s, strerror(errno)); return -1; } cfmakeraw(tty); // 清除所有输入输出处理标志 cfsetispeed(tty, speed); // 设置输入波特率 cfsetospeed(tty, speed); // 设置输出波特率 tty.c_cflag | CREAD | CLOCAL; // 启用接收器忽略MODEM控制线 tty.c_cflag ~CRTSCTS; // 禁用硬件流控工业设备通常不用 tty.c_cflag ~CSIZE; // 清除数据位掩码 tty.c_cflag | CS8; // 设置8位数据位 tty.c_cflag ~PARENB; // 禁用校验位 tty.c_cflag ~CSTOPB; // 设置1位停止位 tty.c_iflag ~(IXON | IXOFF | IXANY); // 禁用软件流控 tty.c_lflag ~(ICANON | ECHO | ECHOE | ISIG); // 原始输入模式 tty.c_oflag ~OPOST; // 原始输出模式 if (tcsetattr(fd, TCSANOW, tty) ! 0) { close(fd); throwIOException(env, tcsetattr failed: %s, strerror(errno)); return -1; } return fd;这段C代码的关键在于它不信任任何上层传来的“配置正确”假设每个ioctl()调用后都检查返回值并将errno转化为Java可读的异常信息。比如tcsetattr()失败可能是波特率不被硬件支持EINVAL也可能是串口被其他进程独占EBUSY异常消息里直接带出strerror(errno)让你一眼定位根因。3.3 read()与write()如何避免“数据粘包”和“线程阻塞”串口是字节流没有消息边界。传感器发0x02 0x01 0x03你read(3)可能只拿到0x02下次read(3)才拿到0x01 0x03。很多库把这个问题甩给业务层但我们封装了基础协议解析能力。read()的健壮实现public byte[] read(int timeoutMillis) throws IOException { synchronized (mReadLock) { if (!mIsOpened) { throw new IOException(Serial port not opened); } // 分配直接内存缓冲区避免GC影响实时性 ByteBuffer buffer ByteBuffer.allocateDirect(4096); int len serial_read(mFd, buffer, timeoutMillis); // JNI调用 if (len 0) { return new byte[0]; // 超时或无数据返回空数组而非null } byte[] data new byte[len]; buffer.get(data, 0, len); return data; } }serial_read()在JNI层实现超时控制int serial_read(int fd, uint8_t *buffer, int timeout_ms) { fd_set read_fds; struct timeval tv; FD_ZERO(read_fds); FD_SET(fd, read_fds); tv.tv_sec timeout_ms / 1000; tv.tv_usec (timeout_ms % 1000) * 1000; int ret select(fd 1, read_fds, NULL, NULL, tv); if (ret 0) { return 0; // timeout } else if (ret 0) { return -1; // select error } // select返回后fd肯定可读read不会阻塞 return read(fd, buffer, 4096); }write()的防丢包设计public void write(byte[] data) throws IOException { synchronized (mWriteLock) { if (!mIsOpened) { throw new IOException(Serial port not opened); } // 检查数据长度避免Native层缓冲区溢出 if (data.length 0) return; if (data.length 4096) { throw new IllegalArgumentException(Data length too large: data.length); } serial_write(mFd, data, data.length); // JNI调用 // 关键强制刷新输出缓冲区确保数据立即发出 tcdrain(mFd); } }tcdrain()是工业场景的救命稻草。没有它write()返回后数据可能还卡在内核发送缓冲区设备端迟迟收不到。加上这行才能保证“写完即发”。4. 实操过程详解从零开始集成到稳定运行的完整链路现在我们把理论落地。假设你刚拿到一台新设备比如RK3399工控平板想用它连接一个通过CH340转接的温湿度传感器。以下是我在客户现场手把手教工程师的操作步骤精确到点击位置和日志关键字。4.1 环境准备Android Studio配置与NDK接入第一步确认Android Studio版本与NDK版本匹配- 推荐Android Studio Giraffe | 2022.3.1 Patch 2或更高- NDK版本必须为23.1.7779620这是经过我们全平台测试最稳定的版本- 在local.properties中指定NDK路径properties ndk.dir/Users/yourname/Library/Android/sdk/ndk/23.1.7779620注意NDK 24版本移除了android-serialport-api依赖的asm/termbits.h头文件会导致编译失败。如果你必须用新版NDK请在jni/Android.mk中添加makefile APP_CFLAGS -I$(NDK_ROOT)/sysroot/usr/include/arm-linux-androideabi第二步导入项目并识别架构- 打开Android Studio →Open an existing project→ 选择项目根目录- 等待Gradle同步完成观察Build窗口- 若出现Could not find method android() for arguments [...]说明build.gradleProject中classpath com.android.tools.build:gradle:7.4.2版本过低升级到8.1.1- 若出现NDK not configured点击Install NDK按钮选择23.1.7779620第三步检查jniLibs目录- 展开app/src/main/jniLibs/应看到arm64-v8a/ libserial_port.so armeabi-v7a/ libserial_port.so x86_64/ libserial_port.so- 如果缺少某个架构比如没有arm64-v8a运行时会报java.lang.UnsatisfiedLinkError: dlopen failed: library libserial_port.so not found。此时需手动编译缺失架构或从项目prebuilt/目录复制。4.2 权限配置AndroidManifest.xml与运行时请求AndroidManifest.xml必须声明!-- USB串口必需 -- uses-feature android:nameandroid.hardware.usb.host / !-- 串口设备访问Android 10 需要 -- uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / !-- 读写串口设备文件Android 12 SELinux要求 -- uses-permission android:nameandroid.permission.READ_MEDIA_IMAGES / !-- 仅调试用正式版可移除 -- uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE /提示ACCESS_FINE_LOCATION权限看似无关实则是Android 10对USB设备枚举的强制要求。不声明此权限UsbManager.getDeviceList()返回空Map你的CH340设备永远“看不见”。运行时USB权限请求逻辑在MainActivity.java中private UsbManager mUsbManager; private UsbDeviceConnection mConnection; private PendingIntent mPermissionIntent; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); mUsbManager (UsbManager) getSystemService(Context.USB_SERVICE); mPermissionIntent PendingIntent.getBroadcast(this, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE); // 注册USB权限广播接收器 IntentFilter filter new IntentFilter(ACTION_USB_PERMISSION); registerReceiver(mUsbReceiver, filter); } private final BroadcastReceiver mUsbReceiver new BroadcastReceiver() { Override public void onReceive(Context context, Intent intent) { String action intent.getAction(); if (ACTION_USB_PERMISSION.equals(action)) { synchronized (this) { UsbDevice device (UsbDevice) intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { // 权限已授予尝试打开串口 openSerialPort(device); } else { Toast.makeText(context, USB permission denied, Toast.LENGTH_SHORT).show(); // 此处可引导用户去系统设置手动授权 } } } } }; private void requestUsbPermission(UsbDevice device) { if (device ! null !mUsbManager.hasPermission(device)) { mUsbManager.requestPermission(device, mPermissionIntent); } }关键经验-PendingIntent.FLAG_IMMUTABLE是Android 12强制要求漏写会导致SecurityException-UsbManager.hasPermission()必须在requestPermission()前调用否则可能重复弹窗- 权限弹窗有时被系统通知栏遮挡建议在onResume()中检查一次权限状态未授权则Toast提醒4.3 串口参数配置如何找到正确的设备路径与波特率这是新手最容易卡住的环节。别猜用工具验证。步骤1连接USB转串口模块查看设备列表- 在Android StudioTerminal中执行bash adb shell ls /dev/tty*- 常见输出/dev/ttyS0 /dev/ttyS1 /dev/ttyUSB0 /dev/ttyHS0- 如果看到/dev/ttyUSB0说明CH340已被识别如果只有/dev/ttyS*说明设备用的是内置串口。步骤2确认USB设备VID/PID匹配驱动adb shell cat /sys/bus/usb/devices/*/idVendor 2/dev/null | grep -A1 1a86 # CH340的VID是1a86 adb shell cat /sys/bus/usb/devices/*/idProduct 2/dev/null | grep -A1 7523 # CH340的PID是7523步骤3设置串口参数以温湿度传感器为例- 传感器手册标明波特率9600数据位8停止位1无校验无流控- 在代码中javaSerialPortConfig config new SerialPortConfig();config.devicePath “/dev/ttyUSB0”; // 或 “/dev/ttyS1”config.baudRate 9600;config.dataBits 8;config.stopBits 1;config.parity “none”;config.flowControl “none”;try {mSerialPort new SerialPort(config);Log.d(“Serial”, “Open success: ” config.devicePath);} catch (IOException e) {Log.e(“Serial”, “Open failed”, e);// e.getMessage() 会包含具体错误如 “open /dev/ttyUSB0 failed: Permission denied”}避坑指南-路径大小写敏感/dev/ttyusb0是错的必须是/dev/ttyUSB0-波特率必须匹配传感器设9600你设115200收到全是乱码且read()可能永远阻塞-不要在onCreate()里直接openActivity可能被系统回收应在onResume()中检查并重连4.4 数据收发实战一个完整的AT指令交互示例假设传感器支持AT指令查询温度发送ATTEMP?返回TEMP:25.6\r\nOK\r\n。// 发送指令并等待响应 private String sendAtCommand(String command, String expect, long timeoutMs) { try { // 1. 清空输入缓冲区避免残留数据干扰 mSerialPort.clearInputBuffer(); // 2. 发送指令注意\r\n换行符 mSerialPort.write((command \r\n).getBytes(StandardCharsets.US_ASCII)); // 3. 循环读取直到收到expect或超时 long startTime System.currentTimeMillis(); StringBuilder response new StringBuilder(); while (System.currentTimeMillis() - startTime timeoutMs) { byte[] data mSerialPort.read(100); // 每次读最多100字节 if (data.length 0) { response.append(new String(data, StandardCharsets.US_ASCII)); // 检查是否包含期望响应 if (response.toString().contains(expect)) { return response.toString(); } } Thread.sleep(10); // 避免忙等待 } return TIMEOUT; } catch (Exception e) { Log.e(AT, Send failed, e); return ERROR: e.getMessage(); } } // 调用 String result sendAtCommand(ATTEMP?, OK, 5000); Log.d(TEMP, Result: result); // 输出Result: TEMP:25.6\r\nOK\r\n实测心得-clearInputBuffer()至关重要。传感器可能在上电时自发发送启动信息不清理会导致ATTEMP?的响应混在垃圾数据里-Thread.sleep(10)不是随便写的。实测发现间隔小于5ms时某些低端USB转串口芯片如劣质CH340会丢包大于20ms则响应延迟过高-StandardCharsets.US_ASCII显式指定编码避免中文系统下默认UTF-8导致getBytes()长度计算错误5. 常见问题与排查技巧实录那些让工程师熬夜的日志我把近三年支持客户过程中高频出现的12个问题整理成速查表。每个问题都附带现象、根因、验证命令、解决方案按发生频率排序。序号现象根因验证命令解决方案1open failed: Permission deniedSELinux策略拒绝访问/dev/tty*adb logcat \| grep avc看是否有avc: denied { open } for ...联系设备厂商添加SELinux规则或临时用adb shell su -c setenforce 0仅调试2read() returns 0 bytes repeatedly外部设备未供电或TX/RX线接反用万用表测/dev/ttyUSB0的RX引脚电压正常应为3.3V左右检查接线CH340模块RX接传感器TXTX接传感器RX3App闪退logcat报java.lang.UnsatisfiedLinkErrorjniLibs中缺少当前CPU架构的.soadb shell getprop ro.product.cpu.abi输出arm64-v8a则需该目录从prebuilt/复制对应架构so或重新编译4USB设备插入后UsbManager.getDeviceList()为空AndroidManifest.xml未声明uses-feature android:nameandroid.hardware.usb.host /adb shell dumpsys usb看mHostController是否为true补全uses-feature声明5sendAndReceive()总超时但串口助手能正常通信传感器响应末尾无\r\n或使用\n而非\r\n用串口助手发送ATTEMP?观察返回是否含OK及换行符修改sendAtCommand()中的expect为OK去掉\r\n匹配6多次热插拔USB后read()返回乱码内核缓冲区残留未清空adb shell cat /proc/tty/driver/usbserial看rx计数是否持续增长mSerialPort.clearInputBuffer()放在每次read()前7write()后传感器无反应但串口助手正常tcdrain()未调用数据卡在内核缓冲区adb shell cat /proc/tty/driver/usbserial看tx计数是否增加在write()方法末尾添加tcdrain(mFd)JNI层8华为手机无法弹出USB权限框EMUI系统限制后台弹窗adb shell dumpsys activity activities \| grep mResumedActivity确认Activity在前台确保requestPermission()在onResume()中调用且Activity未被系统回收9read()偶尔返回-1errnoETIMEDOUTselect()超时时间设置过短查看serial_read()中timeout_ms参数是否小于传感器响应时间将timeoutMillis从1000改为500010同一设备上A App能连B App连不上A App已独占串口O_EXCL标志阻止其他进程打开adb shell lsof \| grep ttyUSB0看是否有其他进程占用close()后确保mFd置null或重启设备释放占用11build.gradle同步失败报NDK version is outdatedbuild.gradleModule中ndkVersion与本地NDK不匹配cat local.properties \| grep ndk.dir再看$NDK_PATH/source.properties在build.gradleModule中设置ndkVersion 23.1.777962012logcat无任何串口相关日志Log.d()被ProGuard混淆移除adb logcat \| grep Serial无输出则检查ProGuard在proguard-rules.pro中添加-keep class com.yourpackage.serial.** { *; }5.1 一个真实案例某电力终端的“间歇性丢包”之谜客户反馈设备连续运行8小时后串口通信开始丢包read()返回数据不完整重启App即可恢复。日志里没有任何异常。排查过程1. 首先排除硬件用同一CH340模块连接PC串口助手24小时满负荷测试无丢包 → 硬件正常2. 检查内存adb shell dumpsys meminfo your.package.name发现Dalvik Heap持续增长但未OOM → 可能内存泄漏3. 关键线索adb shell cat /proc/tty/driver/usbserial显示rx计数在丢包时停滞但tx计数正常 → 问题在接收侧4. 深入JNI层发现serial_read()中select()返回后read()调用前未检查FD_ISSET()导致read()可能读取到已关闭的fd → 返回-1但Java层未捕获errnoEBADF修复方案在JNIserial_read()中添加if (FD_ISSET(fd, read_fds)) { ret read(fd, buffer, size); } else { ret 0; // select返回但fd不可读视为超时 }经验总结- 工业场景的“偶发问题”90%源于资源未正确释放fd、线程、内存- 不要迷信日志/proc/tty/driver/下的内核统计才是真相-select()只是告诉你“可能有数据”read()前必须FD_ISSET()二次确认6. 进阶扩展与定制化如何适配你的特殊硬件需求这套封装库的设计原则是“开箱即用按需定制”。以下是我为客户做的三次典型定制说明如何安全地修改源码。6.1 定制1支持RS485自动收发切换DE引脚控制某客户用RS485总线连接16台电表需要MCU控制DEDriver Enable引脚。标准串口库不提供GPIO控制。改造方案- 在jni/serial_port.c中添加GPIO控制函数c #define GPIO_PATH /sys/class/gpio/gpio123/value // 假设DE接GPIO123 void set_rs485_de(int enable) { int fd open(GPIO_PATH, O_WRONLY); if (fd 0) { write(fd, enable ? 1 : 0, 1); close(fd); } }- 在Java层SerialPort.java中暴露java public void setRs485De(boolean enable) { serial_set_rs485_de(enable ? 1 : 0); }- 使用时java mSerialPort.setRs485De(true); // 拉高DE进入发送模式 mSerialPort.write(data); mSerialPort.setRs485De(false); // 拉低DE进入接收模式6.2 定制2添加CRC16校验自动附加与校验传感器协议要求每帧数据尾部加2字节CRC16Modbus RTU。手动计算易出错。改造方案- 在SerialPortManager.java中添加javapublic byte[] appendCrc16(byte[] data) {short crc calculateCrc16(data);return Bytes.concat(data, new byte[]{(byte)(crc 0xFF), (byte)((crc 8) 0xFF)});}private short calculateCrc16(byte[] data) {short crc 0xFFFF;for (byte b : data) {crc ^ (short) (b 0xFF);for (int i 0; i 8; i) {if ((crc 1) ! 0) {crc (short) ((crc 1) ^ 0xA001);} else {crc 1;}}}return crc;}- 使用javabyte[] frame appendCrc16(“ATREAD”.getBytes());mSerialPort.write(frame);6.3 定制3对接Android Things GPIO已停产但原理通用虽然Android Things已停止维护但其GPIO API设计值得借鉴。我们将其抽象为GpioController接口public interface GpioController { void export(int pin) throws IOException; void setValue(int pin, boolean value) throws IOException; void unexport(int pin) throws IOException; } // 具体实现可基于/sys/class/gpioLinux标准或厂商HAL public class SysfsGpioController implements GpioController { Override public void export(int pin) throws IOException { writeToFile(/sys/class/gpio/export, String.valueOf(pin)); } // ... 其他方法 }这样未来对接任何GPIO方案只需替换实现类上层业务代码完全不变。7. 最后的实操体会稳定性的本质是把所有“可能”都变成“必然”写这篇博文时我正坐在客户工厂的调试现场面前是三台不同品牌的安卓工控机分别连着PLC、变频器和条码扫描枪。它们此刻都在稳定地收发数据日志里没有一行红色的E/Serial。这看起来很平常但我知道这“平常”背后是无数个被Permission denied卡住的下午是反复刷机验证SELinux策略的深夜是抓着示波器看TX引脚波形确认波特率的周末。这套串口封装库的价值从来不在代码有多炫酷而在于它把Android串口通信中所有“可能出问题”的环节都变成了“必然有应对”的设计。open()失败时你知道是权限、路径还是SELinuxread()超时时你能立刻判断是设备响应慢还是线程卡死USB热插拔后权限回调的每一步都清晰可控。它不承诺“100%兼容所有设备”但承诺“每一次失败都给你足够精准的线索去定位”。如果你正面临类似的硬件联调任务我的建议是先别急着改代码花10分钟把adb logcat | grep -E (Serial|usb|avc)的输出贴到文本编辑器里按时间排序像读侦探小说一样找出第一个异常信号。90%的问题根因就藏在那第一条avc: denied日志里。剩下的不过是按图索骥。最后分享一个小技巧在SerialPort.java的close()方法末尾加一行Log.d(Serial, Closed: mDevicePath);。当现场问题扑朔迷离时这行日志能帮你确认——到底是串口真的关了还是你以为它关了其实还在后台偷偷发着数据。本文还有配套的精品资源点击获取简介一套开箱即用的Android串口通信实现专注应用层调用封装不涉及底层驱动开发。提供已验证的SerialPort Java类完整封装open/close/read/write操作自动处理端口占用、权限拒绝、读写异常等常见问题。支持通过USB转串口适配器如CH340、CP2102或设备内置串口与单片机、PLC、传感器等外设通信。项目基于Gradle构建兼容Android 5.0API 21至最新版本可直接导入Android Studio运行只需修改串口路径如/dev/ttyUSB0、波特率、数据位等参数即可适配不同硬件。配套包含AndroidManifest权限声明示例、USB权限弹窗触发逻辑、Logcat日志调试输出、build.gradle依赖配置说明以及本地NDK环境或预编译so库的接入指引。目录结构清晰含标准app/src/main代码组织无冗余文件适合快速集成到工业控制、智能硬件联调、现场数据采集等实际项目中。本文还有配套的精品资源点击获取