资讯动态

Android串口通讯实战:USB权限、端点配置与读写线程避坑指南

发布时间:2026/10/16 5:54:31 来源:尧图企业网站定制
简介本资源面向需要在Android平台上实现串口通讯的开发者尤其适合物联网、自动化控制与嵌入式系统方向的初中级工程师。内容围绕Android Studio集成SerialPort库展开涵盖串口打开、参数配置、数据收发、异常处理与资源释放等核心环节并附带可直接安装运行的apk文件便于在真机上快速验证功能。压缩包共851个文件约19.59MB以xml布局与配置、png图片资源、json数据、class与java源码、jar与so库、rawproto及gradle构建脚本为主另含少量c与h底层源码完整呈现了一个可编译运行的Android工程结构。目前已有3450人学习下载读者可从中获取串口通讯的工程组织方式、SerialPort核心类的调用范例、数据解析与异常处理思路以及免编译直接测试的apk适合作为项目集成与二次开发的参考模板。1. 串口通讯在 Android 上到底卡在哪从一块开发板说起去年帮一个做工业手持终端的团队看代码他们的场景很典型一台 Android 平板通过 RS232 转 USB 接 PLC要实时读寄存器、下发控制指令。项目跑起来后数据偶尔丢包拔插一次 USB 就得重启 App。翻代码发现他们用的是官方UsbManager那套 API但权限申请、端点配置、读写线程全揉在一个 Activity 里bulkTransfer超时时间写死 200ms接收端还是阻塞式轮询。这不是个例Android 串口通讯的坑基本集中在三块USB 权限的动态申请、端点与波特率的正确配置、以及读写线程的生命周期管理。这份 Android Studio 串口通讯项目源码就是围绕这三块给出的一套可运行、可改造的工程模板适合做工业控制、仪器仪表、门禁考勤这类需要外接串口设备的开发者直接拿去改。2. 工程结构拆解从 USB 插拔到数据回传的完整链路2.1 目录结构与模块职责拿到源码先别急着跑把目录翻一遍能省很多事。这个工程是标准的单 Module 结构没有搞多 Module 那套对串口这种偏底层的功能来说反而清爽。核心目录大致是这样app/src/main/java/com/example/serialport/ ├── MainActivity.java // 界面入口负责设备列表展示与收发测试 ├── serial/ │ ├── SerialPortManager.java // 串口管理单例封装打开/关闭/读写 │ ├── SerialPortFinder.java // 扫描 /dev/tty* 下的可用串口节点 │ ├── SerialPort.java // 串口对象持有 FileDescriptor 与流 │ └── SerialInputThread.java // 接收线程循环读取并回调 ├── usb/ │ ├── UsbPermissionHelper.java // USB 权限申请与广播接收 │ └── UsbDeviceFilter.java // 设备过滤按 VID/PID 匹配 └── utils/ ├── ByteUtils.java // 字节与十六进制互转 └── Crc16.java // 常用校验工业场景基本都要SerialPortManager是全局单例对外只暴露open()、close()、send()和setOnDataReceivedListener()四个方法。SerialPortFinder负责遍历/dev目录用File.canRead()和canWrite()筛出真正可用的节点这一步在 root 设备和非 root 设备上行为不一样后面避坑章节会细说。SerialInputThread是独立线程不占用主线程读到的数据通过 Handler 或回调抛回 UI 层。2.2 串口打开的核心流程打开串口是整个项目最关键的环节涉及 JNI 调用和FileDescriptor的构造。源码里SerialPort类的构造方法大致长这样public class SerialPort { private FileDescriptor mFd; private FileInputStream mFileInputStream; private FileOutputStream mFileOutputStream; public SerialPort(File device, int baudrate, int flags) throws SecurityException, IOException { // 检查设备节点读写权限没有权限直接抛异常避免后续 native 层崩溃 if (!device.canRead() || !device.canWrite()) { try { Process su Runtime.getRuntime().exec(/system/bin/su); String cmd chmod 666 device.getAbsolutePath() \n exit\n; su.getOutputStream().write(cmd.getBytes()); if (su.waitFor() ! 0 || !device.canRead() || !device.canWrite()) { throw new SecurityException(串口节点无读写权限且提权失败); } } catch (Exception e) { throw new SecurityException(串口节点无读写权限); } } // 调用 native 方法打开串口返回 FileDescriptor mFd open(device.getAbsolutePath(), baudrate, flags); if (mFd null) { throw new IOException(native open 返回 null串口打开失败); } mFileInputStream new FileInputStream(mFd); mFileOutputStream new FileOutputStream(mFd); } // native 层实现通过 JNI 调用 open() 系统调用并配置 termios private native static FileDescriptor open(String path, int baudrate, int flags); public native void close(); }这段代码有几个参数值得说清楚。baudrate是波特率工业场景常用 9600、19200、115200必须和从设备严格一致差一点都不行。flags是打开标志位一般传 0 表示阻塞模式传O_NONBLOCK表示非阻塞源码里默认用阻塞模式配合独立线程读取。device.canRead()那一段是权限兜底非 root 设备上/dev/ttyS*通常只有 root 可读写所以要么设备已 root要么用 USB 转串口芯片走UsbManager那套权限体系。native 层的open方法在serial_port.c里实现核心是open()系统调用加tcsetattr()配置数据位、停止位、校验位这部分源码里已经封装好改波特率只需要改 Java 层传参。2.3 USB 权限申请与设备过滤如果设备没有 root就得走 USB Host 模式。UsbPermissionHelper封装了权限申请逻辑public class UsbPermissionHelper { private static final String ACTION_USB_PERMISSION com.example.serialport.USB_PERMISSION; private UsbManager usbManager; private Context context; public void requestPermission(UsbDevice device) { // 先检查是否已有权限有就直接回调避免重复弹窗 if (usbManager.hasPermission(device)) { onPermissionGranted(device); return; } // 动态注册广播接收用户授权结果 IntentFilter filter new IntentFilter(ACTION_USB_PERMISSION); context.registerReceiver(permissionReceiver, filter); PendingIntent pi PendingIntent.getBroadcast(context, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE); usbManager.requestPermission(device, pi); } private final BroadcastReceiver permissionReceiver new BroadcastReceiver() { Override public void onReceive(Context context, Intent intent) { if (ACTION_USB_PERMISSION.equals(intent.getAction())) { UsbDevice device intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); boolean granted intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false); if (granted) { onPermissionGranted(device); } else { onPermissionDenied(device); } context.unregisterReceiver(this); // 用完即注销防止内存泄漏 } } }; }UsbDeviceFilter负责按 VID/PID 匹配目标设备因为一台 Android 设备上可能同时插着多个 USB 外设。源码里把常见串口芯片的 VID/PID 列了个表比如 CH340 是0x1A86/0x7523CP2102 是0x10C4/0xEA60FT232 是0x0403/0x6001。匹配到设备后还要遍历UsbInterface找到USB_CLASS_CDC_DATA或厂商自定义的批量传输端点拿到UsbEndpoint才能开始读写。这一步如果端点找错表现就是能打开设备但收发全是空排查起来很费时间。3. 跑起来从零编译到收发第一条数据3.1 环境准备与编译配置这个工程用 Android Studio 打开就能编译但有几个前置条件得先确认。build.gradle里minSdkVersion设的是 21targetSdkVersion设的是 33NDK 部分用的是 CMake 构建 native 库。如果本地没装 NDKAndroid Studio 会提示下载版本选 21.4.7075529 或以上都行。CMakeLists.txt里只编译了一个serial_port.c链接了log库用于 native 层打日志。android { compileSdk 33 defaultConfig { minSdk 21 targetSdk 33 ndk { // 只保留 armeabi-v7a 和 arm64-v8a工业设备基本就这两种 abiFilters armeabi-v7a, arm64-v8a } externalNativeBuild { cmake { cppFlags -stdc11 } } } externalNativeBuild { cmake { path src/main/cpp/CMakeLists.txt } } }abiFilters这里只留了两个 32 位和 64 位 ARM 架构因为工业手持终端和平板基本都是 ARM 芯片x86 模拟器上跑不了串口别在模拟器上浪费时间。AndroidManifest.xml里需要声明 USB Host 特性uses-feature android:nameandroid.hardware.usb.host android:requiredtrue / uses-permission android:nameandroid.permission.USB_PERMISSION /如果设备是 root 的还要加android:sharedUserIdandroid.uid.system才能访问/dev/ttyS*节点不过这个属性要求应用用系统签名普通开发者拿不到所以非 root 场景老老实实走 USB 转串口。3.2 初始化与打开串口编译通过后接上 USB 转串口模块在MainActivity里初始化// 1. 获取 UsbManager UsbManager usbManager (UsbManager) getSystemService(Context.USB_SERVICE); // 2. 遍历已连接设备按 VID/PID 过滤 for (UsbDevice device : usbManager.getDeviceList().values()) { if (UsbDeviceFilter.isTargetDevice(device)) { // 3. 申请权限授权成功后打开串口 permissionHelper.requestPermission(device); break; } } // 4. 权限回调里打开串口 SerialPortManager.getInstance().open(device, 115200); // 5. 设置数据接收监听 SerialPortManager.getInstance().setOnDataReceivedListener(data - { String hex ByteUtils.bytesToHexString(data); runOnUiThread(() - tvReceive.append(hex \n)); });open()方法内部会先找端点再调 native 层打开。波特率这里传 115200实际用的时候改成和从设备一致的值。setOnDataReceivedListener的回调在子线程执行更新 UI 必须切主线程源码里用runOnUiThread包了一层。发送数据用send(byte[] data)内部走bulkTransfer超时时间设的 1000ms比默认的 200ms 宽容一些避免大数据量时误判超时。3.3 收发验证与日志观察跑通的标准是打开 App 能看到设备列表点击连接后权限弹窗出现授权后tvReceive开始滚动十六进制数据。如果接的是 PLC可以先发一条读指令比如 Modbus RTU 的01 03 00 00 00 01 84 0A看返回是不是01 03 02 XX XX CRC。源码里ByteUtils提供了hexStringToBytes和bytesToHexString两个方法调试时直接在输入框敲十六进制字符串发送就行。日志方面native 层用__android_log_print打了open的返回值和tcsetattr的结果Logcat 过滤SerialPort标签能看到。Java 层在SerialPortManager里也打了关键节点日志比如「端点找到EP_IN / EP_OUT」「串口打开成功fdXX」。如果收不到数据先看 Logcat 有没有「端点未找到」或「open 返回 -1」这两个是最常见的失败点。4. 避坑与排查串口通讯里那些让人头大的问题4.1 权限弹窗不出现或授权后仍打不开现象是调用requestPermission后没有任何弹窗或者弹窗授权了但open还是抛SecurityException。原因通常是PendingIntent的 flag 没设对Android 12 以上必须用FLAG_IMMUTABLE或FLAG_MUTABLE源码里用的是FLAG_IMMUTABLE。另一个原因是广播接收器注册时机不对必须在requestPermission之前注册否则授权结果回来时接收器还没就绪。解决办法就是按源码里UsbPermissionHelper的顺序来先registerReceiver再requestPermission授权回调里立刻unregisterReceiver。4.2 能打开但收发全是空现象是open返回成功send也不报错但对面设备没反应接收也一直没数据。八成是端点找错了。USB 串口设备通常有两个批量端点一个 IN 一个 OUTUsbInterface里getEndpointCount()返回 2但顺序不固定。源码里UsbDeviceFilter按endpoint.getDirection()区分USB_DIR_IN的给接收线程USB_DIR_OUT的给发送。如果两个都拿成 IN发送就写到空气里了。排查方法是在open里把端点方向打出来确认 IN/OUT 各一个。4.3 拔插 USB 后 App 崩溃现象是运行中拔掉 USB 转串口模块App 直接闪退Logcat 报IOException: Bad file descriptor。原因是接收线程还在read()阻塞着文件描述符被系统回收后read抛异常没捕获就崩了。源码里SerialInputThread的run方法用try-catch包住了read捕获到IOException后置一个isRunningfalse的标志位并退出循环同时回调onDisconnected让 UI 层更新状态。改造时记得在onDestroy里先close()再interrupt()线程顺序反了也会崩。4.4 数据粘包与半包现象是接收到的数据一截一截的或者两条指令粘在一起。串口是字节流没有消息边界粘包半包是常态。源码里没做协议解析只负责把原始字节抛给上层这是合理的因为不同协议解析方式不一样。如果接 Modbus得在回调里自己维护一个缓冲区按功能码和长度字段切分。常见做法是用ByteArrayOutputStream累积每次收到数据后判断缓冲区里有没有完整帧有就取出来解析没有就继续等。CRC 校验也要做Crc16类里已经实现了 Modbus 标准的 CRC16 算法直接调就行。4.5 高波特率下丢包现象是 115200 以上波特率时接收数据偶尔少几个字节。原因是bulkTransfer的缓冲区设小了或者接收线程优先级不够。源码里接收缓冲区设的是 4096 字节bulkTransfer超时 1000ms一般够用。如果还丢可以把接收线程的优先级提到THREAD_PRIORITY_URGENT_AUDIO在SerialInputThread构造里调Process.setThreadPriority()。另外别在主线程里做耗时解析收到数据先扔队列后台线程慢慢处理。5. 二次开发把这套模板改成你自己的协议栈拿到源码后最常改的三个地方是协议解析、发送封装和重连机制。协议解析这块建议单独抽一个ProtocolParser类输入是byte[]输出是业务对象。比如 Modbus RTU 可以这样写public class ModbusRtuParser { private ByteArrayOutputStream buffer new ByteArrayOutputStream(); public void feed(byte[] data) { buffer.write(data, 0, data.length); byte[] all buffer.toByteArray(); int offset 0; // 循环找完整帧地址(1) 功能码(1) 数据(n) CRC(2) while (offset 4 all.length) { int dataLen all[offset 2] 0xFF; int frameLen 3 dataLen 2; if (offset frameLen all.length) break; // 半包等下次 byte[] frame Arrays.copyOfRange(all, offset, offset frameLen); if (Crc16.check(frame)) { onFrameReceived(frame); // 校验通过才回调 } offset frameLen; } // 保留未解析完的尾部 buffer.reset(); buffer.write(all, offset, all.length - offset); } }这段代码的关键是buffer累积和offset推进dataLen从帧头第三个字节取不同协议字段位置不一样改的时候对着协议文档调。Crc16.check返回布尔值校验不过直接丢弃别把脏数据往上层传。发送封装建议加一个指令队列因为串口是半双工的同时发多条指令会互相干扰。用LinkedBlockingQueue存待发指令单独一个发送线程从队列取发完一条等回应或超时再发下一条。重连机制用UsbManager的ACTION_USB_DEVICE_ATTACHED和ACTION_USB_DEVICE_DETACHED广播插上自动打开拔掉自动关闭并清空队列。源码里UsbPermissionHelper已经注册了权限广播再加两个 action 就行。验证改造是否成功我一般用回环测试把 USB 转串口的 TX 和 RX 短接发什么收什么能稳定跑 10 分钟不丢包才算过。然后再接真实从设备用 Modbus Poll 这类工具对比收发数据。最后一步是压力测试每秒发 50 条指令连续跑半小时看内存和 CPU 占用是否平稳。从那以后我每次改串口代码都强制先跑一遍回环测试再接真实设备这个习惯帮我省了至少三次现场排查的差旅。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑