
简介资源为Android平台串口通信工具的完整源码工程面向需要对接GPS、工业控制板卡、嵌入式模块等串口外设的开发者可解决串口权限申请、设备枚举、参数配置、数据收发等常见问题。工程基于ComAssistant示例项目组织涵盖Android运行时权限、SerialPort API封装、波特率与数据位设置、后台线程调度、异常处理及连接状态管理等关键知识点并附有JNI底层C源码与编译好的so库便于理解从Java层到硬件层的完整调用链路。压缩包共43个文件以Java源码、so动态库、XML资源与配置文件为主包含jar库、构建脚本等整体仅469KB结构清晰。已有1790人学习该资源适合具备基础Android开发经验、希望深入掌握串口通信原理或二次开发调试工具的开发者。一个能上生产环境的Android串口链接工具核心源码要点都在这里搞嵌入式或者物联网开发的兄弟应该都有过这种经历手头一块开发板没有屏幕想看看日志、发个指令第一反应是找TTL转USB线插到电脑上开串口助手。可一旦设备在户外、在现场或者电脑不在身边就只能悻悻拿起手机干瞪眼。后来我索性自己写了一个Android串口链接工具用手机OTG接USB转串口模块直接连开发板、连PLC、连各种传感器效果还不错。这篇文章把整套串口工具源码的核心思路拆开讲清楚包括JNI层怎么配置波特率、Java层怎么不丢数据、设备节点怎么识别、粘包怎么处理以及我在真机调试中踩过的那些坑。适合打算自己封装串口库、或者想深入理解Android串口通信机制的朋友照着这个思路能少走不少弯路。1. Android能直接操作串口吗先搞清楚底层链路很多人第一次接触Android串口开发会带着一个疑问Android不是有自带的UsbManager、UsbSerial之类的API吗为什么还要自己写源码、还要碰JNI这个问题的答案其实取决于你手头硬件属于哪一类。1.1 串口设备在Android系统中的存在形态Android底层是Linux内核绝大多数串口设备在内核中都会被抽象成字符设备节点常见路径有这么几类设备类型常见节点路径典型场景主板原生UART/dev/ttyS0、/dev/ttyMT0、/dev/ttyHSL0部分工业平板、定制Android主板USB转串口/dev/ttyUSB0、/dev/ttyACM0外接CH340、CP210x、FTDI模块蓝牙串口/dev/rfcomm0蓝牙SPP透传模块这些节点在Linux里本质就是一个文件读写串口本质上就是open()、read()、write()这个文件。但Android的应用层运行在沙箱里普通App默认没有访问/dev/ttyS0这类节点的权限直接FileInputStream去打开肯定会报Permission denied。所以要绕过这个限制最主流的方案就是写JNI层通过System.loadLibrary()加载Native库在C/C层完成open()和设备配置工作再把文件描述符丢回Java层做读写。1.2 Java层UsbSerial与JNI直通串口的取舍有些朋友可能会说我直接用android.hardware.usb.UsbDeviceConnection配合UsbSerial库不也挺方便这里要分场景。如果你只是接一个USB转串口模块用UsbSerial库确实省事因为它走的是USB Host协议在应用层通过UsbDeviceConnection.bulkTransfer()收发数据不需要root、也不用碰JNI。但它有一个明显局限它只能管USB接口枚举出来的串口管不到主板原生引出的UART调试串口。而很多工业设备或定制开发板串口是从排针直接引出来的属于/dev/ttyS*或者/dev/ttyMT*这种情况下UsbSerial根本枚举不到必须走JNI直通设备节点。所以我的工具源码里做了一层兼容优先枚举USB转串口附带芯片类型来自动适配同时也支持用户手动指定/dev/ttyS*路径来打开原生串口。这样一种工具既能应对现场快速接USB模块调试也能直接怼到板子的调试排针上通用性一下子就上来了。2. 源码整体架构一个串口工具该有哪些模块如果你只是临时用一下写个几十行的Demo就够了。但要让这个工具真正能上生产环境连PLC、跑个一整天不崩溃源码结构就必须往工程化方向设计。2.1 代码目录划分我自己在项目里把源码分成了五个模块互相解耦app/src/main/ ├── java/com/example/serialtool/ │ ├── SerialPort/ // 串口核心库JNI加载、参数配置、IO通道 │ ├── DeviceDetect/ // 设备节点扫描、权限检测 │ ├── Protocol/ // 数据帧解析Hex收发、ASCI收发、粘包拆包 │ ├── Ui/ // 连接页、调试页、日志展示 │ └── Utils/ // 字节转换、CRC校验、Hex格式化 └── cpp/ // JNI C/C源码serial_port.c、termios配置这样一个目录划分的好处是核心串口逻辑跟UI是隔离的如果后面想把这个源码改造成SDK给别的项目用直接把SerialPort和Protocol两个包抽出去即可。2.2 串口核心类的接口设计整个工具的核心入口是一个SerialPortManager类它统一管理打开、配置、发送、关闭、异常回调。接口设计成标准的监听器模式调用方只管注册回调就行public class SerialPortManager { private static final String TAG SerialPortManager; private SerialPort mSerialPort; private InputStream mInputStream; private OutputStream mOutputStream; private ReadThread mReadThread; private SerialPortEventListener mListener; /** * 打开串口并启动读取线程 * param devicePath 设备节点路径如/dev/ttyS0 * param baudRate 波特率如9600、115200 * param dataBits 数据位通常为8 * param stopBits 停止位通常为1 * param parity 校验位0无校验、1奇校验、2偶校验 */ public synchronized boolean open(String devicePath, int baudRate, int dataBits, int stopBits, int parity) { try { mSerialPort new SerialPort(new File(devicePath), baudRate, dataBits, stopBits, parity); mInputStream mSerialPort.getInputStream(); mOutputStream mSerialPort.getOutputStream(); mReadThread new ReadThread(); mReadThread.start(); return true; } catch (Exception e) { Log.e(TAG, 串口打开失败: devicePath, e); return false; } } }这里有个细节值得注意读取线程启动之后不要立刻返回true最好在open()里加一次Thread.sleep(200)确认读线程没有立刻崩掉才能认为串口初始化成功。我早期写的时候没有这个等待结果有的设备节点虽然open()返回了成功但读取线程因为termios配置问题瞬间退出界面显示已连接实际收不到任何数据这种问题排查起来特别隐蔽。3. JNI层实现串口打开、termios参数配置与read/writeJava层只是皮真正赚钱的是JNI层那几百行C代码。很多网上流传的Demo在open()之后不做任何配置直接拿默认参数去读结果串口收上来的数据全是乱码。问题就出在termios结构体没有按设备要求去设置。3.1 nativeOpen()打开了文件还不够得把参数写进内核看下面这段精简过但可以直接用的C代码jint Java_com_example_serialtool_SerialPort_SerialPort_open( JNIEnv *env, jobject thiz, jstring path, jint baudRate, jint dataBits, jint stopBits, jint parity) { int fd; struct termios cfg; const char *pathStr (*env)-GetStringUTFChars(env, path, 0); // 打开串口设备节点O_RDWR | O_NOCTTY | O_NDELAY注意不能用阻塞方式 fd open(pathStr, O_RDWR | O_NOCTTY | O_NDELAY); if (fd -1) { LOGE(无法打开串口 %s, pathStr); (*env)-ReleaseStringUTFChars(env, path, pathStr); return -1; } // 保存原有配置后面恢复用 if (tcgetattr(fd, cfg) ! 0) { LOGE(tcgetattr 失败); close(fd); return -1; } // 把串口配置清零避免旧配置干扰 cfmakeraw(cfg); // 使能接收 cfg.c_cflag | CLOCAL | CREAD; // 设置数据位 cfg.c_cflag ~CSIZE; switch (dataBits) { case 7: cfg.c_cflag | CS7; break; case 8: default: cfg.c_cflag | CS8; break; } // 设置停止位 cfg.c_cflag ~CSTOPB; if (stopBits 2) { cfg.c_cflag | CSTOPB; } // 设置校验位 cfg.c_cflag ~PARENB; cfg.c_iflag ~INPCK; if (parity 1) { // 奇校验 cfg.c_cflag | PARENB | PARODD; cfg.c_iflag | INPCK; } else if (parity 2) { // 偶校验 cfg.c_cflag | PARENB; cfg.c_iflag | INPCK; } // 设置波特率Android上常规波特率可以直接用cfsetispeed/cfsetospeed cfsetispeed(cfg, getBaudConstant(baudRate)); cfsetospeed(cfg, getBaudConstant(baudRate)); // 生效 tcsetattr(fd, TCSANOW, cfg); (*env)-ReleaseStringUTFChars(env, path, pathStr); return fd; }O_NDELAY这个标志要特别解释一下它让open()在设备没有载波信号比如没有接对端设备时也不会阻塞保证如果不小心连了一个悬空的串口引脚程序不会卡死在打开阶段。对于Android设备这个标志尤其重要因为很多USB转串口模块在没有真正建立连接时open()默认是会阻塞等待的。3.2 波特率常量与自定义波特率cfsetispeed和cfsetospeed接收的是B9600、B115200这组宏常量不能直接把9600这个整数传进去。所以需要做一个映射static speed_t getBaudConstant(int baudRate) { switch (baudRate) { case 300: return B300; case 1200: return B1200; case 2400: return B2400; case 4800: return B4800; case 9600: return B9600; case 19200: return B19200; case 38400: return B38400; case 57600: return B57600; case 115200: return B115200; case 230400: return B230400; case 460800: return B460800; case 921600: return B921600; default: return B9600; } }不过这里有一个大坑Android的termios在部分定制ROM里不支持B921600这种高速率或者有些芯片的USB转串口上报的波特率范围没覆盖到。如果遇到不支持的波特率映射最可靠的办法是绕过cfsetispeed直接往termios的c_cflag里写入自定义波特率// 自定义波特率时直接把波特率数值写入结构体不走宏常量转换 if (needCustomBaud) { cfg.c_cflag | CBAUD; cfg.c_ospeed baudRate; cfg.c_ispeed baudRate; }这个技巧我后来在调试一块需要250000这个非标波特率的工业读码器时用上了官方库里根本没有这个档位只能手动往内核里塞。如果你的目标设备是非标准波特率源码里一定要预留这个后门。3.3 读写线程Java层IO和Native层read的配合JNI层返回文件描述符后串口的read()和write()其实可以直接在Java层用FileInputStream、FileOutputStream来操作SerialPort类内部就是这么封装的public class SerialPort { static { System.loadLibrary(serial_port); } private FileDescriptor mFd; private FileInputStream mFileInputStream; private FileOutputStream mFileOutputStream; public SerialPort(File device, int baudRate, int dataBits, int stopBits, int parity) throws IOException { mFd open(device.getAbsolutePath(), baudRate, dataBits, stopBits, parity); if (mFd null) { throw new IOException(open 串口失败); } mFileInputStream new FileInputStream(mFd); mFileOutputStream new FileOutputStream(mFd); } private native FileDescriptor open(String path, int baudRate, int dataBits, int stopBits, int parity); private native void close(); // getInputStream / getOutputStream 省略 }读取线程里我推荐用byte[]缓冲区配合read()循环来消费数据而不是BufferedReader之类的字符流。串口数据本质是字节流可能有0x00、0xFF这些非打印字符用字符流会做编码转换极容易破坏数据帧private class ReadThread extends Thread { Override public void run() { super.run(); byte[] buffer new byte[1024]; int size; while (!isInterrupted() mInputStream ! null) { try { size mInputStream.read(buffer); if (size 0) { byte[] received new byte[size]; System.arraycopy(buffer, 0, received, 0, size); mListener.onDataReceived(received); } } catch (IOException e) { mListener.onError(e); return; } } } }while循环里read()默认是阻塞的这样省去了自己加Thread.sleep()的麻烦也保证CPU不会空转。线程中断用interrupt()不要直接用stop()否则可能把底层的文件描述符留在半开状态。4. 设备自动识别从扫描节点到权限处理一个串口工具如果每次都要手动输路径那体验就太差了。我在源码里做了一整套设备自动识别逻辑插上USB转串口模块后自动弹出可用设备列表。4.1 扫描常见串口节点Android设备上串口节点虽然路径不固定但分布的目录基本是稳定的。源码里维护了一个候选路径列表private static final String[] SERIAL_PATHS new String[]{ /dev/ttyS0, /dev/ttyS1, /dev/ttyS2, /dev/ttyS3, /dev/ttyUSB0, /dev/ttyUSB1, /dev/ttyUSB2, /dev/ttyACM0, /dev/ttyACM1, /dev/ttyMT0, /dev/ttyMT1, /dev/ttyMT2, /dev/ttyHSL0, /dev/ttyHSL1, /dev/ttyGS0, /dev/ttyGS1 };遍历时用File.exists()判断节点是否存在但不能只判断存在性还要做一次可写检测。很多节点存在权限不够照样打不开。我的做法是尝试以只读方式open一下再关掉能成功就放进候选列表这样用户点连接时才不会大面积报权限错误。4.2 权限问题没有root也能干活的方案JNI层打开设备节点时如果遇到Permission denied常见的解决办法有两条路。一是设备节点确实没权限如果设备已经root可以在Native层调用chmod(path, 0666)来提权源码里我会加一个tryChmod()函数判断geteuid()0才去执行避免非root环境误调用。二是根本没有节点权限但USB转串口模块是通过/dev/bus/usb暴露的这种情况不要去碰节点直接用UsbManager申请权限后走UsbSerial库的bulkTransfer通道。我的源码在设备扫描阶段就通过UsbManager.getDeviceList()枚举USB设备读VendorID和ProductID来判断是不是CP210x、FTDI、CH340等常见芯片如果是就直接把对应的/dev/ttyUSB0列出来同时提示用户授权USB权限。4.3 USB权限申请的小细节Android的USB设备权限是个容易忽略的点。插上USB转串口模块后如果App没有拿到该设备的独占权限open()设备节点时虽然内核层面没问题但上下位机之间可能无法正常通信或者拔插后系统会强制释放连接。所以在打开串口前一定要先走一遍权限申请UsbManager usbManager (UsbManager) getSystemService(Context.USB_SERVICE); PendingIntent permissionIntent PendingIntent.getBroadcast(this, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE); usbManager.requestPermission(usbDevice, permissionIntent);然后在注册的BroadcastReceiver里再确认granted标志之后才允许用户点击连接按钮。别嫌这一步啰嗦没有权限的时候串口打开是能打开但插拔一次之后设备节点就变成僵尸节点必须重启App才能恢复排查起来相当费劲。5. 数据解析与协议处理Hex收发和粘包实战串口数据是物理层到应用层之间最后一个毛坯房设备吐出来的原始字节流不经过解析根本没法看。很多工具直接拿String接收遇到非ASCII字符直接变成乱码这就逼着源码里必须把Hex收发、CRC校验这些基本功做好。5.1 Hex字符串与字节数组互转工控领域收发数据时习惯上看到的是01 03 00 00 00 01 84 0A这种Hex字符串。所以源码里一定有一个稳的字节转换工具网上一搜一大把但有很多实现处理不了带空格、带0x前缀的脏数据。我写了一个容错型转换函数public static byte[] hexStringToBytes(String hex) { if (hex null || hex.isEmpty()) return new byte[0]; // 去除空格、换行、0x前缀等干扰 String clean hex.replaceAll([\\s:,], ) .replaceAll((?i)0x, ); if (clean.length() % 2 ! 0) { clean 0 clean; // 奇数长度时补0避免越界 } byte[] bytes new byte[clean.length() / 2]; for (int i 0; i bytes.length; i) { int high Character.digit(clean.charAt(i * 2), 16); int low Character.digit(clean.charAt(i * 2 1), 16); if (high -1 || low -1) { throw new IllegalArgumentException(非法的Hex字符); } bytes[i] (byte) ((high 4) low); } return bytes; }Character.digit比Integer.parseInt(substring)高效且容错更好关键是它不会因为你传了0x这种前缀直接抛异常。这段代码我基本从项目开始到现在没改过属于那种写过一次就再也不想换的工具类。5.2 粘包与半包串口数据帧拆分的核心逻辑串联通信没有像TCP那样清晰的包边界设备可能一次吐100个字节也可能分10次每次吐10个字节还可能在数据中间断开。如果只是简单地把收到的数据直接贴到UI上那么显示出来的是碎片化的一堆无意义数据。我的做法是加一个接收缓冲区和拆包器。核心思路是暂时把收到的字节追加到一个ByteArrayOutputStream里再根据用户设置的帧结束符比如常见的0x0A换行符或者固定的帧长去切割public class FrameParser { private final ByteArrayOutputStream buffer new ByteArrayOutputStream(); private final FrameEndType endType; // 按结束符 or 按固定帧长 public Listbyte[] push(byte[] data) { Listbyte[] frames new ArrayList(); buffer.write(data, 0, data.length); byte[] all buffer.toByteArray(); int start 0; for (int i 0; i all.length; i) { if (endType.matches(all, i)) { int frameLen i - start 1; byte[] frame new byte[frameLen]; System.arraycopy(all, start, frame, 0, frameLen); frames.add(frame); start i 1; // 跳过结束符 } } if (start 0) { byte[] remaining new byte[all.length - start]; System.arraycopy(all, start, remaining, 0, remaining.length); buffer.reset(); buffer.write(remaining, 0, remaining.length); } return frames; } }这个实现之所以能同时处理粘包和半包核心在于每次解析后只保留未成帧的残留字节在缓冲区里。等下一批数据到达后再拼接、再拆分直到凑齐一个完整帧。实际体验下来这种方案比那种每次read都当一帧的写法稳定太多了像Modbus RTU这类带CRC的协议只要把帧长参数设置好几乎不会出现误拆。5.3 CRC校验别让脏数据骗了你产线上很多Modbus设备回帧带CRC16校验如果工具不去校验遇到通讯干扰时屏幕上显示的可能是模棱两可的垃圾数据。源码里我固定内置了CRC16-Modbus算法收到完整帧后先算CRC不对就直接丢弃并在日志区标红提示CRC校验失败public static int crc16Modbus(byte[] data, int offset, int length) { int crc 0xFFFF; for (int i offset; i offset length; i) { crc ^ (data[i] 0xFF); for (int j 0; j 8; j) { if ((crc 0x0001) ! 0) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return crc; }这段代码别看短它是Modbus协议标准里查表法的高速版一张表改成运行时计算省了256字节的常量表空间在JNI还没初始化的时候也能直接在Java层跑。对于一个调试工具来说郭这种先校验后显示的习惯能帮你快速区分是我线没接稳、还是设备真的回了空数据。6. 实战避坑我在真机调试过程中遇到的几个要命问题最后把这个工具落地到不同设备上时我踩过的坑确实不少挑几个典型的分享出来。这些问题你如果提前知道能省下至少两三天调试时间。6.1 USB转串口芯片的兼容性差异市面上的USB转串口模块主要就CH340、CP210x、FTDI、CH343这几颗芯片Android系统对它们的驱动支持程度很不一样。实测下来芯片型号打开/dev/ttyUSB节点稳定性常见问题CH340通常正常中某些ROM需手动加载驱动CP210x原生支持较好高拔插后节点释放慢FTDI需要额外驱动高多数ROM无内置驱动CH343视内核版本中高老内核可能识别为未知USB设备所以源码里我特意加了VendorID/ProductID的识别表插上设备后先打印芯片型号和驱动状态用户在界面上能看到识别到CH340路径/dev/ttyUSB0这类提示而不是一脸懵地乱试。6.2 拔插USB线导致文件描述符失效USB转串口模块有一个特别坑的地方拔掉USB线后再插上/dev/ttyUSB0节点会重新生成但你的FileInputStream持有的FileDescriptor已经指向一个悬空对象read()要么直接抛IOException要么凭空返回-1。最典型的现象是拔掉线再插上点发送没有任何反应程序也不崩溃。解决思路很简单在读取线程的IOException回调里统一做一次串口断开处理通知UI层重置连接状态并释放旧的文件描述符。用户重新点连接时再走一遍完整的open()流程。千万不要尝试在同一个SerialPortManager实例里直接重新open()因为旧实例里的FileInputStream还在容易产生双重释放。6.3 串口打开成功后立刻被系统回收这是我在一台Android 13的定制平板上踩到的open()返回成功、read()线程也起来了但几秒钟后logcat疯狂打印-EPIPE或Bad file number。查了两天才发现是USB电源管理在作祟平板USB口在无数据传输时进入了休眠模式把串口底层的端点挂起了。解决办法是在打开串口后周期性往设备发一个心跳包或者不断地调用tcgetattr()来保持USB端口的活跃状态。心跳间隔不用太短1到3秒发一个空命令即可既不会干扰业务数据又能保证底层USB设备不会因为长时间无数据被系统挂起。6.4 权限动态申请和Android版本差异如果你的工具要适配Android 6.0以上运行时权限、后台定位权限这些老生常谈就不细说了。这里只说一个隐藏点Android 10开始对/dev/ttyUSB*节点的访问限制更严了有些设备上即使App申请了READ_EXTERNAL_STORAGE权限打开串口时依然会报EACCES。我在源码里做了两套回退策略第一优先尝试直接打开节点失败后再询问用户是否需要切换为UsbSerial模式。这个回退机制虽然丑但在兼容性测试中救了无数次场。最后分享一个调试习惯如果你是在做类似工具建议在串口调试页面里加一个原始数据流显示开关。很多时候设备数据协议没调通根源不在你的工具代码而是对面板子上的Tx/Rx接反了、GND没共地、电平不匹配。这时候看一眼原始字节流比对着界面上的ASCII日志瞎猜高效得多。我在源码里就是用一个CheckBox控制日志区是否显示Hex原始数据默认打开只有需要友好展示时才切到ASCII模式。这个小开关在实际现场调试里帮了大忙。本文还有配套的精品资源点击获取