简介本资源是一个已实测可用的Android串口通信实战项目面向Android中高级开发者及嵌入式IoT应用开发者解决在Android平台调用底层串口设备如传感器、工控模块时缺乏原生API支持的核心问题。项目基于Android Studio构建完整覆盖串口初始化、参数配置波特率/数据位/校验位、双向数据收发、线程安全接收、异常处理与资源释放等关键环节并集成常见第三方串口库如Android-SerialPort-API的适配实践。压缩包共312个文件含16个Java源码文件核心逻辑、57个XML布局与配置文件、98个class字节码及12个APK安装包含调试版另有so库支持硬件通信整体大小为17.79MB。已有3123人学习下载读者可直接运行调试、分析源码结构、复用通信模块或借鉴权限动态申请、UI线程解耦、回调驱动数据更新等工程化设计思路。1. Android串口通信实例为什么你写的串口App在小米/华为上收不到数据却在模拟器里一切正常这不是一个“调通就行”的玩具项目。真实产线上的扫码枪、PLC控制器、温湿度传感器、医疗设备外设90%以上仍通过RS232/RS485转USB串口如CH340、CP2102、FTDI芯片与Android终端对接——而Android原生不提供串口API。你写的App在开发机上能发能收一到产线平板尤其搭载高通SM6系或联发科Helio G系列SoC的定制ROM设备就丢包、卡死、权限拒绝甚至系统级串口占用冲突。根本原因不是代码写错了而是你没绕过Android的硬件抽象层HAL限制、没处理好USB Device Policy的动态授权、更没适配不同厂商对UsbManager的私有增强逻辑。本篇不讲SerialPort.open()这种黑匣子封装而是从Linux内核设备节点/dev/ttyUSB0出发用纯JavaJNI方式打通从USB枚举、权限申请、文件描述符获取到非阻塞读写的全链路。适合需要长期维护工业级外设接入的嵌入式Android开发者、IoT设备集成工程师以及被“串口收不到数据”问题卡住三天以上的中级安卓工程师。2. 串口通信底层原理与Android适配选型为什么必须绕过UsbSerialDriver直连/dev/ttyUSB*2.1 Linux串口本质设备文件 termios控制 非阻塞IOAndroid底层是Linux内核所有USB转串口设备最终映射为/dev/ttyUSB*或/dev/ttyACM*设备节点。通信本质是对设备文件的open/read/write/ioctl操作关键控制参数由termios结构体定义// termios核心字段对应Java中SerialConfig c_cflag: // 控制标志波特率(CBAUD)、数据位(CS8)、停止位(CSTOPB)、校验位(PARENB) c_iflag: // 输入处理是否启用回显(ICANON)、是否忽略奇偶错误(IGNPAR) c_oflag: // 输出处理是否延迟输出(OPOST) c_lflag: // 本地标志是否启用回显(ECHO)、是否启用信号(SIGINT) c_cc[VMIN]: // 最小读取字节数设为0即非阻塞 c_cc[VTIME]: // 读取超时设为0即立即返回提示Android NDK r21已废弃asm/termbits.h必须用linux/termios.h并手动定义BOTHER以支持非标准波特率如460800、921600这是工业设备常见需求。2.2 为什么UsbSerialDriver类库在产线环境大概率翻车某跨平台系统曾用usb-serial-for-androidv3.4.6上线后37%设备报IOException: read failed, socket might closed or timeout, read ret: -1。根因如下问题类型现象原因规避方式厂商ROM劫持USB权限UsbManager.requestPermission()回调永不触发华为EMUI/荣耀Magic UI拦截android.hardware.usb.action.USB_DEVICE_ATTACHED广播且不触发UsbManager的getDeviceList()改用UsbManager.openDevice()直接打开跳过广播监听内核驱动加载失败ls /dev/ttyUSB*无输出但dmesggrep usb显示ch341-uart converter now attached to ttyUSB0小米MIUI 14强制禁用ch341内核模块CONFIG_USB_CH341m被注释需用户手动insmod多进程串口抢占App A打开/dev/ttyUSB0后App B调用open()返回EBUSYAndroid未实现POSIXO_EXCL语义open(/dev/ttyUSB0, O_RDWR)不保证独占在JNI层用fcntl(fd, F_SETLK, lock)加文件锁血泪经验不要依赖任何Java层串口库的“自动识别芯片型号”。CH340和CP2102在idVendor/idProduct相同如0x1a86/0x7523时内核可能加载错误驱动。必须在/sys/class/tty/*/device/下读取uevent文件确认DRIVERch341而非ftdi_sio。2.3 我们的选择纯JNI方案 手动termios配置放弃所有第三方Java串口库采用以下技术栈Java层仅负责USB设备枚举、权限申请、JNI接口调用JNI层Copen()设备文件 →ioctl(fd, TCSETS, tio)配置termios →fcntl(fd, F_SETFL, O_NONBLOCK)设非阻塞 →read()/write()循环资源预置将ch341.ko、cp210x.ko、ftdi_sio.ko编译为ARM64-v8a/armeabi-v7a双架构so随APK分发这样做的好处✅ 完全可控的错误码errno可直接映射到具体问题✅ 绕过厂商对UsbManager的私有修改✅ 支持自定义波特率如PLC要求的256000✅ 可注入调试日志到__android_log_print(ANDROID_LOG_DEBUG, SerialJNI, read %d bytes, n)3. 从零实现Android串口JNI设备枚举、权限申请与文件描述符获取3.1 Java层安全获取USB设备并请求权限// MainActivity.java private UsbManager usbManager; private UsbDeviceConnection connection; private static final String ACTION_USB_PERMISSION com.example.SERIAL_USB_PERMISSION; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); usbManager (UsbManager) getSystemService(Context.USB_SERVICE); // 1. 注册权限广播接收器注意此Receiver必须在AndroidManifest.xml中静态注册 PendingIntent permissionIntent PendingIntent.getBroadcast( this, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT ); usbManager.requestPermission(getTargetUsbDevice(), permissionIntent); // getTargetUsbDevice()见下文 } // 2. 动态查找目标设备避免硬编码VID/PID private UsbDevice getTargetUsbDevice() { for (UsbDevice device : usbManager.getDeviceList().values()) { // 工业设备常见VID/PID组合可扩展 if ((device.getVendorId() 0x1a86 device.getProductId() 0x7523) || // CH340 (device.getVendorId() 0x10c4 device.getProductId() 0xea60) || // CP2102 (device.getVendorId() 0x0403 device.getProductId() 0x6001)) { // FTDI return device; } } return null; }注意UsbManager.getDeviceList()在Android 12需声明uses-permission android:nameandroid.permission.USB_PERMISSION /且该权限为normal级别无需运行时申请。3.2 JNI层打开设备文件并配置termios关键C代码// serial_jni.cpp #include jni.h #include unistd.h #include fcntl.h #include sys/ioctl.h #include linux/termios.h #include android/log.h #define LOG_TAG SerialJNI #define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) extern C { // Java调用入口openSerial(/dev/ttyUSB0, 115200, 8, N, 1) JNIEXPORT jint JNICALL Java_com_example_serial_SerialPort_openSerial( JNIEnv *env, jobject thiz, jstring devicePath, jint baudRate, jint dataBits, jchar parity, jint stopBits) { const char *path env-GetStringUTFChars(devicePath, nullptr); int fd open(path, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) { LOGD(open %s failed: %s, path, strerror(errno)); env-ReleaseStringUTFChars(devicePath, path); return -1; } // 2. 配置termios重点必须清空所有标志位再设置 struct termios tio; memset(tio, 0, sizeof(tio)); // 设置波特率支持BOTHER if (cfsetispeed(tio, BOTHER) 0 || cfsetospeed(tio, BOTHER) 0) { LOGD(cfsetispeed failed); close(fd); env-ReleaseStringUTFChars(devicePath, path); return -1; } tio.c_ispeed baudRate; tio.c_ospeed baudRate; // 数据位、停止位、校验位 tio.c_cflag CS8; // 8位数据 if (stopBits 2) tio.c_cflag | CSTOPB; // 2停止位 if (parity E) tio.c_cflag | PARENB; // 偶校验 if (parity O) tio.c_cflag | (PARENB | PARODD); // 奇校验 // 关闭所有输入/输出/本地处理工业通信必须 tio.c_iflag 0; tio.c_oflag 0; tio.c_lflag 0; // 非阻塞读取VMIN0, VTIME0 tio.c_cc[VMIN] 0; tio.c_cc[VTIME] 0; // 应用配置 if (ioctl(fd, TCSETS, tio) 0) { LOGD(ioctl TCSETS failed: %s, strerror(errno)); close(fd); env-ReleaseStringUTFChars(devicePath, path); return -1; } // 3. 设为非阻塞模式双重保险 int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); env-ReleaseStringUTFChars(devicePath, path); return fd; // 返回文件描述符给Java层保存 }逻辑说明O_NOCTTY防止设备成为控制终端避免SIGTTIN/SIGTTOU信号干扰O_NDELAY使open()不等待载波检测DTR/RTS这对无硬件流控的USB串口至关重要cfsetispeed(..., BOTHER)是支持任意波特率的核心c_ispeed/c_ospeed直接赋值整数c_iflag/c_oflag/c_lflag 0关闭所有行规程处理确保原始字节流透传3.3 Java层保存fd并启动读写线程// SerialPort.java public class SerialPort { private int mFd; // 从JNI获取的文件描述符 private boolean isRunning false; private Thread readThread; public boolean open(String devicePath, int baudRate) { mFd openSerial(devicePath, baudRate, 8, N, 1); // 调用JNI if (mFd 0) return false; isRunning true; readThread new Thread(this::readLoop); readThread.start(); return true; } private void readLoop() { byte[] buffer new byte[1024]; while (isRunning) { int len readSerial(mFd, buffer); // JNI read函数 if (len 0) { // 通知UI线程或解析协议 onReceived(buffer, len); } else if (len 0) { // 非阻塞read返回0无数据继续轮询 try { Thread.sleep(10); } catch (InterruptedException e) {} } else { // len 0检查errno如EAGAIN表示无数据EIO表示设备断开 if (getLastError() EIO) { close(); // 设备拔出 break; } } } } }参数说明buffer大小建议1024字节太小导致频繁系统调用太大增加内存拷贝开销Thread.sleep(10)是平衡功耗与实时性的经验值工业场景可降至1ms需Process.setThreadPriority(Thread.MIN_PRIORITY)降权4. 串口通信避坑指南产线实测5大高频故障与根治方案4.1 现象App首次运行能通信重启后open()返回-1dmesg显示usb 1-1.2: ch341-uart converter now disconnected原因USB设备热插拔时内核卸载驱动但未释放/dev/ttyUSB0节点残留/sys/class/tty/ttyUSB0/device软链接指向已销毁设备。此时open()因ENODEV失败。解决在openSerial()前增加设备节点存在性校验struct stat sb; if (stat(path, sb) ! 0 || !S_ISCHR(sb.st_mode)) { LOGD(Device node %s not exist or not char device, path); return -1; }更彻底方案监听/sys/class/tty/目录变化用inotify_add_watch()捕获IN_CREATE/IN_DELETE事件动态更新可用设备列表。4.2 现象发送数据正常但接收缓冲区始终read()返回0cat /proc/tty/driver/usbserial显示tx: 1234 rx: 0原因termios.c_cflag未清除CRTSCTS硬件流控标志而外设未连接RTS/CTS线导致内核丢弃所有接收数据。解决在termios初始化后强制关闭流控tio.c_cflag ~CRTSCTS; // 清除硬件流控 tio.c_cflag ~HUPCL; // 清除挂起时关闭DTR tio.c_cflag ~CLOCAL; // 清除本地线路位避免忽略载波检测4.3 现象在华为Mate 50上UsbManager.requestPermission()回调永不触发但adb shell ls /dev/ttyUSB*可见设备原因华为EMUI 13对UsbManager做了私有增强要求应用在AndroidManifest.xml中声明uses-feature android:nameandroid.hardware.usb.host /且android:requiredtrue否则系统拒绝广播。解决在AndroidManifest.xml添加uses-feature android:nameandroid.hardware.usb.host android:requiredtrue / uses-permission android:nameandroid.permission.USB_PERMISSION /若仍无效改用UsbManager.openDevice()绕过权限系统// Java层直接打开需targetSdkVersion 30 UsbDevice device getTargetUsbDevice(); connection usbManager.openDevice(device); // 返回UsbDeviceConnection // 后续通过connection.getFileDescriptor()获取fd需反射调用4.4 现象多线程同时调用writeSerial()导致数据错乱Wireshark抓包显示帧头被截断原因write()系统调用非原子多个线程并发写入同一fd内核缓冲区出现粘包。解决JNI层加互斥锁static pthread_mutex_t write_mutex PTHREAD_MUTEX_INITIALIZER; JNIEXPORT jint JNICALL Java_com_example_serial_SerialPort_writeSerial( JNIEnv *env, jobject thiz, jint fd, jbyteArray data) { pthread_mutex_lock(write_mutex); // ... write()调用 pthread_mutex_unlock(write_mutex); return len; }更优方案Java层用单线程HandlerThread串行化所有写请求避免JNI锁竞争。4.5 现象readSerial()返回负值getLastError()为EAGAIN但设备确实在发数据原因O_NONBLOCK模式下EAGAIN表示“暂时无数据”但某些厂商ROM如某国产工控平板的USB驱动存在bugread()在数据到达瞬间仍返回EAGAIN。解决实现带退避的重试机制int retry 0; while (retry 3) { int len readSerial(mFd, buffer); if (len 0) return len; if (len 0 || getLastError() EAGAIN) { try { Thread.sleep((long) Math.pow(2, retry) * 10); } // 指数退避 retry; } else break; }5. 工业级稳定性强化超时控制、断线重连与波特率自适应5.1 用select()替代忙等降低CPU占用率当前readLoop()用Thread.sleep(10)轮询空转耗电。升级为select()系统调用让内核通知数据到达// JNI中新增select_read() JNIEXPORT jint JNICALL Java_com_example_serial_SerialPort_selectRead( JNIEnv *env, jobject thiz, jint fd, jbyteArray buffer) { fd_set read_fds; FD_ZERO(read_fds); FD_SET(fd, read_fds); struct timeval timeout; timeout.tv_sec 0; timeout.tv_usec 50000; // 50ms超时 int ret select(fd 1, read_fds, nullptr, nullptr, timeout); if (ret 0 FD_ISSET(fd, read_fds)) { // 有数据可读执行read() jbyte *buf env-GetByteArrayElements(buffer, nullptr); ssize_t len read(fd, buf, env-GetArrayLength(buffer)); env-ReleaseByteArrayElements(buffer, buf, 0); return (jint) len; } return 0; // 超时或无数据 }优势CPU占用从15%降至0.3%特别适合7×24小时运行的工控终端。5.2 断线自动重连检测read()返回EIO后的恢复流程// SerialPort.java private void handleDeviceDisconnect() { isRunning false; if (readThread ! null) { try { readThread.join(1000); } catch (InterruptedException e) {} } // 1. 关闭fd closeSerial(mFd); // 2. 延迟3秒后尝试重连避免USB复位抖动 new Handler(Looper.getMainLooper()).postDelayed(() - { if (reconnectCount 3) { // 最多重连3次 if (open(devicePath, baudRate)) { reconnectCount 0; LOGD(Reconnect success); } else { reconnectCount; handleDeviceDisconnect(); // 递归重试 } } }, 3000); }5.3 波特率自适应解决PLC设备启动时波特率不一致问题某些PLC上电后首帧为固定格式如0x55 0xAA 0x00 0x01但波特率可能是9600/19200/38400中的一个。传统方案需用户手动选择我们实现自动探测步骤操作说明1. 初始化以9600波特率发送AT\r\n大部分设备响应OK2. 轮询探测若超时切换至19200再发最多尝试4种常用波特率9600/19200/38400/1152003. 响应匹配收到OK或设备特征字符串如PLC-2000即锁定用ByteBuffer缓存最近128字节正则匹配PLC-\d// 自适应核心逻辑伪代码 private int autoDetectBaudRate() { int[] rates {9600, 19200, 38400, 115200}; String probeCmd AT\r\n; String pattern (OK|PLC-\\d); for (int rate : rates) { if (open(devicePath, rate)) { write(probeCmd.getBytes()); String resp readUntilTimeout(500); // 读500ms if (Pattern.compile(pattern).matcher(resp).find()) { return rate; // 探测成功 } close(); } } return -1; // 探测失败 }这个功能让产线工人无需打开App设置页插上设备3秒内自动握手成功——这才是工业软件该有的体验。6. 验证与压测用真实PLC数据跑满72小时我的3条血泪经验6.1 必须做的三类验证测试测试类型方法合格标准工具基础连通性发送01 03 00 00 00 02 C4 0BModbus读保持寄存器解析响应响应帧校验和正确数据域长度≥4字节自研串口调试助手APK压力稳定性每秒发送10帧含CRC持续72小时丢帧率0.001%内存泄漏1MB/24hadb shell dumpsys meminfo 自定义计数器异常恢复力拔插USB线100次每次间隔10秒100%自动重连无ANR无Native CrashPython脚本控制USB Hub电源我参与的某图像处理Demo项目在交付前用这三类测试暴露出两个致命问题问题1压力测试中read()返回ENOMEM内存不足——根因是ByteBuffer.allocateDirect(1024)在JNI层未free()改为malloc()free()手动管理问题2异常恢复测试第87次拔插后ANR——HandlerThread消息队列积压改用LinkedBlockingQueuepoll(timeout)防阻塞。6.2 产线部署必备的5个APK加固项加固项实施方式为什么必要1. 预置ko驱动将ch341.ko放入src/main/jniLibs/arm64-v8a/安装时Runtime.getRuntime().exec(insmod /data/app/xxx/lib/arm64/ch341.ko)避免MIUI等ROM禁用内核模块2. USB权限白名单AndroidManifest.xml中添加meta-data android:nameandroid.hardware.usb.action.USB_DEVICE_ATTACHED android:resourcexml/device_filter /device_filter.xml精确声明VID/PID防止系统弹窗被用户误拒3. 后台服务保活startForegroundService()startForeground()NotificationCompat.BuilderAndroid 12限制后台服务不保活则USB断开后无法重连4. 日志分级输出LOGD()用于调试LOGI()用于业务LOGE()用于错误全部重定向到/sdcard/serial_debug.log产线问题必须能现场导出日志不能只靠Logcat5. 硬件加速开关在Application.onCreate()中调用System.setProperty(sun.nio.ch.bugLevel, )某些高通芯片JNIread()在开启GPU加速时偶发SIGSEGV6.3 我坚持的3个开发习惯第一永远用strace看系统调用。当open()失败不猜“是不是权限问题”而是adb shell strace -p $(pidof com.example.serial) -e traceopen,ioctl,read,write 21 | grep -E (ttyUSB|errno)这条命令能直接看到open(/dev/ttyUSB0, O_RDWR) -1 EACCES (Permission denied)比Logcat精准10倍。第二把errno映射表贴在显示器边框。EIO(5)是设备断开EAGAIN(11)是无数据ENODEV(19)是设备不存在——记不住就查别靠玄学。第三每次提交代码前用ndk-stack符号化解析Native Crash。哪怕只是signal 11 (SIGSEGV)也要定位到serial_jni.cpp:142而不是写“已修复”。这些习惯让我在三年内交付的7个工业串口项目0次因串口问题返工。希望帮到你。本文还有配套的精品资源点击获取