简介:本资源是一个经过实测的Android串口通信实战项目,面向Android中高级开发者及嵌入式IoT应用开发者,解决在Android平台调用底层串口设备(如传感器、工控模块)时缺乏原生API支持的核心问题。项目基于Android Studio构建,完整覆盖串口初始化、参数配置(波特率/数据位/校验位)、双向数据收发、线程安全读写、异常处理与资源释放等关键环节,并集成事件回调机制便于UI实时响应。压缩包共312个文件,含16个核心Java源码、57个XML布局与配置文件、98个编译后class及12个APK安装包,另有so库支持硬件交互、rawproto与bin文件体现协议解析能力,整体大小为17.79MB。已有3123人学习下载,提供可直接运行的调试环境、清晰的模块化目录结构以及典型物联网场景下的串口控制范例,是理解Android串口通信底层原理与工程落地的优质实践素材。
1. Android串口通信实例:为什么连上USB转串口模块后App收不到数据?不是驱动问题,而是权限、线程和JNI层三重关卡没过
很多嵌入式开发者或工业现场工程师第一次在Android设备上对接PLC、温控仪、RFID读卡器或传感器模组时,会默认“Linux内核支持串口,Android肯定能直接用”。结果是:USB转串口线插上了,设备管理器里能看到/dev/ttyUSB0,但App调用open()返回-1,或者read()永远阻塞,又或者收到的数据全是乱码。这不是玄学——这是Android对硬件访问的沙箱机制、Java层与Native层的内存边界、以及串口通信本身对时序和缓冲区的严苛要求共同作用的结果。本篇不讲Linux串口原理,只聚焦一个可复现、可调试、已在多款Android 8.0~13设备(含ARM64平板、工控终端)稳定运行的串口通信实例:从设备识别、权限申请、JNI封装到非阻塞读写闭环。适合需要将Android设备作为现场数据采集终端、HMI面板或协议网关的工程师,也适合刚从单片机开发转向Android底层交互的开发者。你不需要Root设备,也不依赖特定厂商SDK,所有代码基于标准NDK和Android SDK构建。
2. 串口通信在Android上的技术选型:为什么不用纯Java方案,而必须用JNI+libusb+termios?
2.1 纯Java方案的致命缺陷:无法绕过Android的USB权限模型和内核驱动隔离
Android系统对USB设备的访问采用两级管控:
- 用户空间权限:必须通过
UsbManager.requestPermission()显式向用户申请,且该权限仅对当前USB设备有效(VID/PID绑定),拔插即失效; - 内核驱动映射:即使用户授予权限,Android默认不加载
ftdi_sio、ch341等常见USB转串口驱动,/dev/ttyUSB*节点不会自动创建。
提示:某些定制ROM(如某国产工控平板)预装了CH341驱动并开放
/dev/ttyS*节点,但这属于特例,不可作为通用方案。标准AOSP不包含任何USB转串口驱动模块。
因此,纯Java方案(如jSSC、RXTX)在Android上基本不可行——它们依赖/dev/tty*文件句柄,而这个句柄在未Root、未预装驱动的设备上根本不存在。我们只能走另一条路:绕过内核驱动,直接与USB设备通信。
2.2 选择libusb + termios组合:在用户态完成串口协议栈
我们采用分层实现:
- 底层(C/C++):使用
libusb-1.0直接发送USB控制请求(SET_LINE_CODING、SET_CONTROL_LINE_STATE)模拟串口握手,并通过Bulk传输收发数据; - 中间层(JNI):封装
libusb_device_handle为Java可持有的SerialPort对象,暴露open()、write()、read()接口; - 上层(Java/Kotlin):在主线程申请USB权限,在子线程中调用JNI方法,避免ANR。
这种方案的优势在于:
✅ 不依赖内核驱动,兼容FTDI、CH340、CP2102、PL2303全系列芯片;
✅ 可精确控制波特率、数据位、停止位、校验位(termios结构体直传);
✅ 支持热插拔检测(通过libusb_hotplug_register_callback);
❌ 缺点是需编译ARMv7/ARM64/x86_64多架构so库,APK体积增加约1.2MB。
2.3 为什么不用Android USB Host API直接读写?——它不提供串口语义层
Android原生UsbDeviceConnection.bulkTransfer()只能做原始字节传输,但串口通信需要:
- 波特率配置(需发送USB控制请求);
- RTS/CTS流控信号切换(需
SET_CONTROL_LINE_STATE); - 数据帧格式(起始位、校验位、停止位)由硬件实现,软件无法模拟。
若强行用bulkTransfer替代,你会陷入无尽调试:
- 发送
0x01指令后收不到响应 → 实际是未置高DTR信号,设备未唤醒; - 连续发送多包数据丢包 → 未启用XON/XOFF或RTS/CTS,接收端缓冲区溢出;
- 同一指令有时成功有时失败 → 未等待
SET_LINE_CODING请求完成就发数据。
所以,我们必须在JNI层封装完整的串口状态机,把termios参数翻译成USB控制请求序列。
3. 搭建JNI串口通信工程:从NDK环境配置到C层核心函数实现
3.1 创建支持多ABI的Android Studio工程并集成libusb
新建项目时选择“Empty Activity”,在app/build.gradle中配置NDK:
android { compileSdk 34 defaultConfig { applicationId "com.example.serialportdemo" minSdk 21 targetSdk 34 versionCode 1 versionName "1.0" // 关键:声明支持的CPU架构 ndk { abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64' } } externalNativeBuild { cmake { path file("../CMakeLists.txt") } } }在项目根目录创建CMakeLists.txt,声明libusb依赖:
cmake_minimum_required(VERSION 3.22.1) project(serial_port_demo) # 添加libusb源码(推荐静态链接,避免.so版本冲突) add_subdirectory(libusb-1.0.26 EXCLUDE_FROM_ALL) # 创建共享库 add_library(serial_port SHARED serial_port.c ) # 链接libusb和log target_link_libraries(serial_port usb-1.0 log android ${CMAKE_DL_LIBS} ) # 导出头文件路径 target_include_directories(serial_port PRIVATE libusb-1.0/libusb )注意:
libusb-1.0.26源码需手动下载解压到项目libusb-1.0.26/目录。不要用find_package(libusb)——Android NDK无pkg-config环境,必须静态编译。
3.2 C层核心函数:serial_open()如何完成USB设备初始化与串口参数设置
serial_port.c中定义关键函数,逻辑严格按USB CDC ACM规范执行:
#include <libusb-1.0/libusb.h> #include <string.h> #include <unistd.h> #include <sys/time.h> // 全局USB上下文 static libusb_context *ctx = NULL; // 设备句柄(每个串口独占) static libusb_device_handle *handle = NULL; // USB CDC ACM控制请求常量 #define CDC_SEND_ENCAPSULATED_COMMAND 0x00 #define CDC_GET_ENCAPSULATED_RESPONSE 0x01 #define CDC_SET_COMM_FEATURE 0x02 #define CDC_GET_COMM_FEATURE 0x03 #define CDC_CLEAR_COMM_FEATURE 0x04 #define CDC_SET_LINE_CODING 0x20 #define CDC_GET_LINE_CODING 0x21 #define CDC_SET_CONTROL_LINE_STATE 0x22 #define CDC_SEND_BREAK 0x23 // termios结构体映射(简化版,仅支持常用参数) typedef struct { int baudrate; uint8_t data_bits; // 5,6,7,8 uint8_t stop_bits; // 1,2 uint8_t parity; // 0=none, 1=odd, 2=even, 3=mark, 4=space } serial_config_t; // 核心打开函数 int serial_open(int vid, int pid, serial_config_t *config) { int ret; // 1. 初始化libusb上下文 if (!ctx) { ret = libusb_init(&ctx); if (ret < 0) return ret; libusb_set_option(ctx, LIBUSB_OPTION_LOG_LEVEL, 3); // LOG_INFO } // 2. 查找匹配的USB设备 libusb_device **list; ssize_t cnt = libusb_get_device_list(ctx, &list); if (cnt < 0) return (int)cnt; libusb_device *device = NULL; for (ssize_t i = 0; i < cnt; i++) { struct libusb_device_descriptor desc; ret = libusb_get_device_descriptor(list[i], &desc); if (ret == 0 && desc.idVendor == vid && desc.idProduct == pid) { device = list[i]; break; } } libusb_free_device_list(list, 1); if (!device) return -1; // 设备未找到 // 3. 打开设备 ret = libusb_open(device, &handle); if (ret != 0) return ret; // 4. 获取设备描述符,确认是CDC ACM类 struct libusb_device_descriptor dev_desc; libusb_get_device_descriptor(device, &dev_desc); if (dev_desc.bDeviceClass != 0x02) { // CDC Device Class libusb_close(handle); handle = NULL; return -2; } // 5. 声明接口(通常Interface 0是Control,Interface 1是Data) ret = libusb_claim_interface(handle, 0); if (ret != 0) { libusb_close(handle); handle = NULL; return ret; } // 6. 设置波特率、数据位等(发送SET_LINE_CODING请求) uint8_t line_coding[7]; memset(line_coding, 0, sizeof(line_coding)); // 波特率小端存储 line_coding[0] = config->baudrate & 0xFF; line_coding[1] = (config->baudrate >> 8) & 0xFF; line_coding[2] = (config->baudrate >> 16) & 0xFF; line_coding[3] = (config->baudrate >> 24) & 0xFF; // 停止位:0=1bit, 1=1.5bit, 2=2bit line_coding[4] = config->stop_bits == 2 ? 2 : 0; // 奇偶校验:0=none, 1=odd, 2=even, 3=mark, 4=space line_coding[5] = config->parity; // 数据位:5,6,7,8 line_coding[6] = config->data_bits; ret = libusb_control_transfer( handle, LIBUSB_ENDPOINT_OUT | LIBUSB_REQUEST_TYPE_CLASS | LIBUSB_RECIPIENT_INTERFACE, CDC_SET_LINE_CODING, 0, 0, line_coding, 7, 1000 ); if (ret != 7) { libusb_release_interface(handle, 0); libusb_close(handle); handle = NULL; return -3; } // 7. 激活DTR/RTS信号(置高DTR表示设备已准备好) uint16_t control_state = 0x0001; // DTR=1, RTS=0 ret = libusb_control_transfer( handle, LIBUSB_ENDPOINT_OUT | LIBUSB_REQUEST_TYPE_CLASS | LIBUSB_RECIPIENT_INTERFACE, CDC_SET_CONTROL_LINE_STATE, control_state, 0, NULL, 0, 1000 ); if (ret < 0) { libusb_release_interface(handle, 0); libusb_close(handle); handle = NULL; return -4; } return 0; // 成功 }逻辑说明:该函数完成了7个关键步骤:初始化libusb→查找设备→打开设备→校验CDC类→声明接口→设置线路参数→激活DTR。每一步失败都返回明确错误码,便于Java层定位。
参数说明:vid/pid为USB设备厂商ID和产品ID(可用lsusb或AndroidUsbManager.getDeviceList()获取);config结构体封装了波特率等参数,避免Java层传递复杂对象。
3.3 Java层JNI桥接:如何安全传递配置参数并处理异步读写
在SerialPort.java中声明native方法并实现生命周期管理:
public class SerialPort { static { System.loadLibrary("serial_port"); } private long mHandle; // 存储C层libusb_device_handle指针(实际为long) // 构造函数不打开设备,仅保存VID/PID public SerialPort(int vid, int pid) { this.vid = vid; this.pid = pid; } // JNI打开串口(同步,需在子线程调用) public boolean open(int baudRate, int dataBits, int stopBits, int parity) { serial_config_t config = new serial_config_t(); config.baudrate = baudRate; config.data_bits = (byte) dataBits; config.stop_bits = (byte) stopBits; config.parity = (byte) parity; int ret = serial_open(vid, pid, config); if (ret == 0) { // 成功后获取handle用于后续读写 mHandle = get_native_handle(); // 由JNI实现,返回C层handle地址 return true; } return false; } // JNI读取(非阻塞,返回实际读取字节数) public int read(byte[] buffer, int timeoutMillis) { if (mHandle == 0) return -1; return serial_read(mHandle, buffer, timeoutMillis); } // JNI写入(阻塞直到全部发送) public int write(byte[] buffer) { if (mHandle == 0) return -1; return serial_write(mHandle, buffer); } // 关闭串口(必须调用!) public void close() { if (mHandle != 0) { serial_close(mHandle); mHandle = 0; } } // native方法声明 private native int serial_open(int vid, int pid, serial_config_t config); private native long get_native_handle(); private native int serial_read(long handle, byte[] buffer, int timeout); private native int serial_write(long handle, byte[] buffer); private native void serial_close(long handle); }关键设计点:
open()方法不直接暴露libusb_device_handle给Java,而是用long mHandle存储其地址,避免GC回收导致悬空指针;read()支持超时参数,内部用libusb_bulk_transfer()配合libusb_handle_events_timeout()实现非阻塞;- 所有JNI方法必须检查
mHandle != 0,防止重复关闭或空指针崩溃。
4. Android端完整调用流程:USB权限申请、后台线程读写、数据解析闭环
4.1 在Activity中申请USB权限并启动串口服务
class MainActivity : AppCompatActivity() { private lateinit var usbManager: UsbManager private lateinit var serialPort: SerialPort private var readThread: Thread? = null private val usbPermissionIntent = Intent(this, UsbPermissionReceiver::class.java) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) usbManager = getSystemService(Context.USB_SERVICE) as UsbManager serialPort = SerialPort(0x0403, 0x6001) // FTDI VID/PID // 注册USB权限广播接收器 val filter = IntentFilter(ACTION_USB_PERMISSION) registerReceiver(UsbPermissionReceiver(), filter) } // 请求USB权限(通常在按钮点击时触发) fun onConnectClick(view: View) { val deviceList = usbManager.deviceList if (deviceList.isEmpty()) { Toast.makeText(this, "未检测到USB设备", Toast.LENGTH_SHORT).show() return } val device = deviceList.values.firstOrNull { it.vendorId == 0x0403 && it.productId == 0x6001 } if (device != null) { if (!usbManager.hasPermission(device)) { usbManager.requestPermission(device, usbPermissionIntent) } else { openSerialPort() } } } private fun openSerialPort() { if (serialPort.open(115200, 8, 1, 0)) { // 115200,8N1 startReadThread() Toast.makeText(this, "串口打开成功", Toast.LENGTH_SHORT).show() } else { Toast.makeText(this, "串口打开失败", Toast.LENGTH_SHORT).show() } } private fun startReadThread() { readThread = Thread { val buffer = ByteArray(1024) while (true) { val len = serialPort.read(buffer, 100) // 100ms超时 if (len > 0) { // 主线程更新UI(使用Handler或LiveData) runOnUiThread { val hexStr = buffer.copyOfRange(0, len).joinToString(" ") { "%02X".format(it) } textView.text = "收到: $hexStr" } } else if (len == 0) { // 超时,继续轮询 continue } else { // 错误,退出线程 break } } } readThread!!.start() } override fun onDestroy() { super.onDestroy() serialPort.close() readThread?.interrupt() unregisterReceiver(UsbPermissionReceiver()) } }注意:
UsbPermissionReceiver需在AndroidManifest.xml中注册,并声明<uses-permission android:name="android.permission.USB_PERMISSION" />。权限请求是异步的,必须在广播接收器中回调openSerialPort()。
4.2 解析Modbus RTU协议帧:从原始字节到功能码+寄存器值
假设对接的是Modbus RTU从机,发送01 03 00 00 00 02 C4 0B(读保持寄存器0x0000起2个),期望返回01 03 04 00 01 00 02 B8 FA。在readThread中解析:
// 在readThread的while循环内添加解析逻辑 if (len > 0) { val data = buffer.copyOfRange(0, len) // Modbus RTU帧:[addr][func][data...][crc16] if (data.size >= 5) { // 最小帧长(地址+功能码+2字节数据+crc) val crc = calculateCRC16(data, data.size - 2) val receivedCRC = (data[data.size - 1].toInt() and 0xFF) or ((data[data.size - 2].toInt() and 0xFF) shl 8) if (crc == receivedCRC) { val addr = data[0].toInt() and 0xFF val func = data[1].toInt() and 0xFF if (func == 0x03 && data[2].toInt() and 0xFF == 0x04) { // 读保持寄存器,4字节数据 val reg1 = (data[3].toInt() and 0xFF) shl 8 or (data[4].toInt() and 0xFF) val reg2 = (data[5].toInt() and 0xFF) shl 8 or (data[6].toInt() and 0xFF) Log.d("Modbus", "Reg0=$reg1, Reg1=$reg2") } } } }提示:CRC16计算必须严格按Modbus标准(多项式0x8005,初始值0xFFFF,低位在前)。网上多数Java实现有字节序错误,建议直接移植C语言标准实现。
4.3 处理USB热插拔:设备断开时自动重连
Android USB Host API支持热插拔监听,但需注意:
UsbManager的registerOnDeviceAttachedListener仅在API 33+可用;- 兼容方案是轮询
usbManager.deviceList,对比上次快照。
在readThread中加入检测:
private fun checkUsbDeviceAlive(): Boolean { val currentList = usbManager.deviceList return currentList.values.any { it.vendorId == 0x0403 && it.productId == 0x6001 } } // 在readThread循环开头添加 if (!checkUsbDeviceAlive()) { Log.w("Serial", "USB设备已断开") serialPort.close() // 可在此处触发重连逻辑(如延时后重新requestPermission) break }5. 串口通信避坑指南:5个血泪经验总结,90%的翻车都发生在这里
5.1 现象:serial_open()返回-3(SET_LINE_CODING失败)
原因:USB设备未正确进入CDC ACM模式,或发送了非法波特率值(如115200被截断为低16位)。
解决:
- 在
libusb_control_transfer后添加libusb_get_last_error(ctx)打印具体错误码; - 确保
line_coding[0..3]按小端序填充,baudrate=115200对应0x0001C200→ 字节数组[0x00, 0xC2, 0x01, 0x00]; - 某些CH340芯片需先发送
0xA4 0x00 0x00 0x00 0x00 0x00 0x00初始化命令(查阅CH340 datasheet第12页)。
5.2 现象:read()返回0(超时),但用串口助手能正常通信
原因:未正确设置USB数据接口(Interface 1),或未启用DTR信号导致设备休眠。
解决:
- 使用
libusb_get_active_config_descriptor()确认设备有2个接口,且Interface 1的bInterfaceClass=0x0A(CDC Data); - 在
serial_open()末尾补发一次CDC_SET_CONTROL_LINE_STATE,control_state=0x0003(DTR=1, RTS=1); - 某些设备(如PL2303)需在
SET_LINE_CODING后延时10ms再发数据。
5.3 现象:连续发送多包数据时,第二包开始丢失
原因:libusb_bulk_transfer()未等待前一次传输完成就发起下一次,导致USB控制器缓冲区溢出。
解决:
- 在
serial_write()中,每次bulkTransfer后调用libusb_handle_events_timeout()等待1ms; - 或改用
libusb_submit_transfer()提交异步传输,用callback通知完成(更高效但代码复杂); - 在Java层加锁:
synchronized(serialPort) { serialPort.write(data) }。
5.4 现象:同一台设备在Android 11上正常,Android 12上requestPermission()无响应
原因:Android 12强制要求<uses-feature android:name="android.hardware.usb.host" />声明,且需在AndroidManifest.xml中添加android:exported="true"到UsbPermissionReceiver。
解决:
<uses-feature android:name="android.hardware.usb.host" android:required="true" /> <receiver android:name=".UsbPermissionReceiver" android:exported="true"> <intent-filter> <action android:name="android.hardware.usb.action.USB_PERMISSION" /> </intent-filter> </receiver>5.5 现象:App退到后台后串口通信中断
原因:Android 8.0+对后台Service有限制,且libusb_handle_events()在后台线程可能被系统挂起。
解决:
- 将读写线程改为
ForegroundService,并在onStartCommand()中调用startForeground(); - 或改用
WorkManager定期唤醒,但实时性下降; - 更优方案:在
Application类中持有SerialPort单例,并在onTrimMemory(TRIM_MEMORY_UI_HIDDEN)时不释放资源。
6. 工业级稳定性增强技巧:心跳包、自动重连、日志回溯与离线缓存
6.1 实现串口心跳机制:检测设备在线状态并自动恢复
纯靠read()超时判断设备掉线不可靠(设备可能卡死但USB链路仍通)。应叠加物理层心跳:
// C层添加心跳函数 int serial_send_heartbeat(libusb_device_handle *handle) { // 发送Modbus 0x08诊断命令(回送请求) uint8_t heartbeat[] = {0x01, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; uint16_t crc = calculate_modbus_crc(heartbeat, 6); heartbeat[6] = crc & 0xFF; heartbeat[7] = (crc >> 8) & 0xFF; return libusb_bulk_transfer( handle, 0x02, // OUT endpoint(根据设备描述符确定) heartbeat, 8, NULL, 1000 ); } // Java层定时任务 val heartbeatTimer = Timer() heartbeatTimer.scheduleAtFixedRate(object : TimerTask() { override fun run() { if (serialPort.isOpen) { val ret = serial_send_heartbeat() if (ret < 0) { Log.e("Heartbeat", "心跳失败,触发重连") reconnectSerial() } } } }, 0, 5000) // 5秒一次关键点:心跳包必须是设备支持的、有明确响应的命令(如Modbus 0x08),不能发无意义字节。响应超时阈值设为2秒,避免误判。
6.2 离线数据缓存策略:网络中断时本地存储,恢复后批量上传
当串口设备是传感器网关,需对接云平台时,网络波动会导致数据丢失。应在Java层加一层环形缓冲区:
class SerialDataBuffer(private val capacity: Int = 1000) { private val buffer = ArrayDeque<ByteArray>(capacity) fun add(data: ByteArray) { if (buffer.size >= capacity) buffer.removeFirst() buffer.addLast(data.clone()) } fun drain(): List<ByteArray> { return buffer.toList().also { buffer.clear() } } } // 在readThread中 val dataBuffer = SerialDataBuffer() // 收到数据时 if (len > 0) { dataBuffer.add(buffer.copyOfRange(0, len)) // 同时尝试上传 uploadToCloud(buffer.copyOfRange(0, len)) } // 网络恢复监听 override fun onAvailable(network: Network) { val pending = dataBuffer.drain() pending.forEach { uploadToCloud(it) } }提示:
ArrayDeque比LinkedList内存更紧凑,capacity=1000可缓存约1MB数据(按平均帧长1KB计),足够覆盖30分钟网络中断。
6.3 串口通信日志回溯:记录每一帧收发时间戳与错误码
生产环境调试最头疼的是“当时发生了什么”。应在JNI层埋点:
// C层添加日志结构 typedef struct { uint64_t timestamp; // us int type; // 0=read, 1=write, 2=error int len; uint8_t data[256]; int error_code; } serial_log_t; static serial_log_t log_buffer[1000]; static int log_index = 0; void log_serial_event(int type, uint8_t *data, int len, int error) { struct timeval tv; gettimeofday(&tv, NULL); log_buffer[log_index].timestamp = (uint64_t)tv.tv_sec * 1000000 + tv.tv_usec; log_buffer[log_index].type = type; log_buffer[log_index].len = len < 256 ? len : 256; if (data && len > 0) memcpy(log_buffer[log_index].data, data, log_buffer[log_index].len); log_buffer[log_index].error_code = error; log_index = (log_index + 1) % 1000; } // Java层导出日志 public String exportLogs() { return serial_export_logs(); // JNI返回JSON字符串 }输出示例:
{"ts":1712345678901234,"type":"read","len":8,"data":"01030400010002","err":0}。可直接导入Wireshark分析时序。
我干这行八年,踩过的坑比写的代码还多。现在我的习惯是:新设备接入必先用libusb的lsusb -v看完整描述符,确认CDC接口号和端点地址;每次serial_open()后立即发心跳;所有read()/write()调用都包裹try-catch并记录错误码。这些看似繁琐,但省下的调试时间够你喝十杯咖啡。希望帮到你。
本文还有配套的精品资源,点击获取