Android串口测试工具开发实战:从JNI原理到十六进制收发与避坑指南
简介这是一份面向Android嵌入式开发、物联网调试与硬件测试人员的串口通信测试工具源码包围绕Android平台串口读写场景解决设备间数据传输与硬件联调时缺少可视化调试手段的问题。资源以CommManager类为核心涵盖串口打开、参数配置、数据收发与资源释放等模块并配套串口选择、波特率、数据位、停止位、校验位设置及收发文本框等界面逻辑适合具备一定Android基础、需要快速验证串口设备的开发者参考。压缩包为rar格式整体约2.62MB文件类型明细上游暂未提供可结合CommManager等核心类文件理解串口管理实现。目前已有766人学习下载读者可从中获取串口权限声明、第三方库调用、读写线程与异常处理等排错思路并基于现有类结构扩展日志记录与数据解析功能提升调试效率。1. 从一次现场调试翻车说起android 串口测试工具到底解决什么问题去年冬天某工业现场的一台 Android 一体机需要对接一块自定义的采集板卡协议是 Modbus RTU over RS485。板卡厂商给的调试文档只有一页纸寄存器地址表还写错了两处。当时手头只有一台笔记本和一个 USB 转 485 模块但设备端是 Android 系统跑的是定制固件根本没法把笔记本直接串进去看数据。现场工程师折腾了一下午最后靠一个装在 Android 设备上的串口测试工具把收发报文实时抓出来才发现是波特率被固件默认设成了 9600而板卡要求 115200。这个坑不大但没有趁手的工具就是查不出来。android 串口测试工具本质上是一个跑在 Android 设备上的串口通信调试助手。它要解决的核心问题很具体在 Android 端直接打开串口、配置参数、收发数据、查看十六进制报文而不依赖 PC 端。适合的人群也很明确——做 Android 工控平板、智能硬件、车载终端、自助设备开发的工程师尤其是那些设备已经装在现场、拆机接 PC 成本很高的场景。它不是什么万能神器但在“设备端直接看串口”这件事上确实能省掉很多来回折腾。2. 串口通信在 Android 上的实现原理与选型逻辑2.1 Android 串口和 PC 串口的本质差异PC 上做串口调试打开设备管理器看到 COM3用串口助手一开就能收发。Android 上完全不是这个逻辑。Android 内核虽然基于 Linux理论上支持 tty 设备但普通应用层没有权限直接访问/dev/ttyS*或/dev/ttyUSB*。这是第一道门槛。第二道门槛是权限模型。Android 的 SELinux 和权限组机制会把串口设备节点锁死普通 APK 即使拿到 root也要处理节点属主和 SELinux 上下文。常见做法是设备厂商在固件里预置权限规则或者应用以系统签名运行。如果设备是自研的通常会在init.rc或ueventd.rc里给串口节点放权限比如把/dev/ttyS1的属主设为system权限设为0666。这一步不做后面所有代码都是白搭。第三道门槛是 JNI 层。Android 的 Java 层没有直接操作串口的 API必须通过 JNI 调用 Linux 的open、ioctl、read、write等系统调用。所以一个能用的 android 串口测试工具底层一定有一层 C/C 的 native 实现Java 层只是壳。2.2 选型时先确认三件事在动手写或者选现成工具之前我一般会先确认三件事顺序不能乱。第一串口设备节点是什么。在 adb shell 里执行ls -l /dev/tty*看有哪些节点。常见的有/dev/ttyS0到/dev/ttyS4USB 转串口通常是/dev/ttyUSB0。如果列表里什么都没有说明内核没使能对应驱动后面不用看了。第二当前应用有没有权限。执行cat /proc/tty/drivers可以看驱动注册情况但更直接的是用ls -l看节点权限。如果显示crw-rw---- root root普通应用打不开。这时候要么改固件要么用 root 方案。第三波特率、数据位、停止位、校验位这四件套是否和目标设备一致。很多“串口没反应”的问题最后查出来就是波特率差了一个档位。提示不要一上来就写代码。先用 adb shell 把节点和权限确认清楚能省掉至少一半的调试时间。2.3 一个最小可用的 JNI 串口打开流程下面这段 C 代码是串口打开的核心逻辑我把它简化到最小可用状态。实际工具里还会加超时、非阻塞、select 轮询等处理但先看懂这个骨架。#include fcntl.h #include termios.h #include unistd.h #include stdio.h int open_serial(const char *dev_path, int baudrate) { // O_NOCTTY 防止串口成为控制终端O_NDELAY 非阻塞打开 int fd open(dev_path, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) { perror(open serial failed); return -1; } struct termios options; tcgetattr(fd, options); // 获取当前配置 cfsetispeed(options, baudrate); // 输入波特率 cfsetospeed(options, baudrate); // 输出波特率 options.c_cflag | (CLOCAL | CREAD); // 使能接收忽略调制解调器控制线 options.c_cflag ~CSIZE; // 清除数据位设置 options.c_cflag | CS8; // 8 位数据位 options.c_cflag ~PARENB; // 无校验 options.c_cflag ~CSTOPB; // 1 位停止位 options.c_cflag ~CRTSCTS; // 关闭硬件流控 options.c_lflag ~(ICANON | ECHO | ECHOE | ISIG); // 原始模式 options.c_iflag ~(IXON | IXOFF | IXANY); // 关闭软件流控 options.c_oflag ~OPOST; // 原始输出 tcsetattr(fd, TCSANOW, options); // 立即生效 return fd; }这段代码里几个参数值得单独说。O_NOCTTY是防止串口被当成控制终端不然后台进程可能被信号干扰。CLOCAL | CREAD是让程序忽略 modem 控制线并允许接收。CS8是 8 位数据位工业协议里最常见。PARENB和CSTOPB清零分别代表无校验和 1 位停止位。ICANON清零是关掉行缓冲否则read会一直等换行符串口数据根本没有换行概念这个坑我见过不止一次。波特率不能直接传整数cfsetispeed接受的是B115200这类常量。实际工具里会做一个映射表把用户选的 9600、19200、115200 转成对应的B*常量。如果传错串口会以默认速率打开表现就是“能打开但收不到正确数据”。2.4 Java 层怎么调JNI 层封装好之后Java 层通过native方法调用。典型结构如下public class SerialPort { static { System.loadLibrary(serial_port); // 加载 native 库 } private long nativeHandle; // 保存 native 层 fd public native long open(String path, int baudrate); public native int write(byte[] data, int length); public native int read(byte[] buffer, int timeout); public native void close(); }nativeHandle保存的是 C 层的文件描述符后续读写都靠它。read方法一般会带一个超时参数底层用select或poll实现避免阻塞线程。实际工具里还会把读操作放在独立线程通过 Handler 或回调把数据抛回 UI 层。3. 从零搭一个可用的串口测试工具界面、收发与十六进制显示3.1 界面布局的最小集一个能用的串口测试工具界面不需要花哨但下面这些控件一个都不能少串口节点下拉框、波特率下拉框、数据位/停止位/校验位选择、打开/关闭按钮、发送输入框、发送按钮、接收显示区、十六进制显示开关、清空按钮。布局上我习惯把参数区放在顶部收发区放在中间发送输入和按钮放在底部。接收区用ScrollView包一个TextView等宽字体这样十六进制对齐好看。如果数据量大TextView会卡常见做法是换成RecyclerView或者限制最大行数超过就滚动丢弃旧数据。3.2 打开串口的完整调用链下面这段 Java 代码展示了从界面点击到 native 打开的完整链路private void openSerial() { String path spPathSpinner.getSelectedItem().toString(); int baudrate getBaudrateFromSpinner(); // 映射成 B115200 等常量 if (serialPort null) { serialPort new SerialPort(); } long fd serialPort.open(path, baudrate); if (fd 0) { Toast.makeText(this, 打开失败检查节点和权限, Toast.LENGTH_SHORT).show(); return; } isOpen true; openBtn.setText(关闭); startReadThread(); // 启动接收线程 }getBaudrateFromSpinner里维护一个MapString, Integer把界面显示的“115200”映射到B115200。startReadThread里循环调用serialPort.read(buffer, 100)读到数据就通过runOnUiThread追加到接收区。这里注意read返回 -1 不代表出错可能只是超时要继续循环。3.3 十六进制收发的处理串口调试里十六进制显示和发送是刚需。接收方向把byte[]转成String时不能直接new String(bytes)那样遇到非打印字符会乱码。正确做法是逐字节格式化public static String bytesToHex(byte[] data, int len) { StringBuilder sb new StringBuilder(); for (int i 0; i len; i) { sb.append(String.format(%02X , data[i])); } return sb.toString(); }发送方向如果用户勾选了十六进制发送输入框里的内容要先按空格或连续字符串解析成byte[]。常见做法是去掉空格和0x前缀每两个字符转一个字节。这里有个坑用户输入A这种单个字符时要补零成0A否则解析会错位。public static byte[] hexToBytes(String hex) { hex hex.replaceAll(\\s|0x, ); if (hex.length() % 2 ! 0) { hex 0 hex; // 奇数长度补零 } byte[] result new byte[hex.length() / 2]; for (int i 0; i result.length; i) { result[i] (byte) Integer.parseInt(hex.substring(i * 2, i * 2 2), 16); } return result; }3.4 参数配置的持久化现场调试时参数经常要反复改。如果每次打开都重新选一遍效率很低。我一般会把上次用的节点、波特率、十六进制开关存到SharedPreferences下次启动自动恢复。这个功能不复杂但很实用。private void saveConfig() { SharedPreferences.Editor editor getSharedPreferences(serial_cfg, MODE_PRIVATE).edit(); editor.putString(path, spPathSpinner.getSelectedItem().toString()); editor.putInt(baudrate, baudrateSpinner.getSelectedItemPosition()); editor.putBoolean(hex_mode, hexSwitch.isChecked()); editor.apply(); }读取时反向操作即可。注意baudrate存的是位置索引而不是值这样下拉框选项顺序变了也不会错。4. 避坑与排查串口测试里最容易翻车的五个点4.1 现象打开成功但收不到任何数据原因通常有三个。第一节点选错了比如设备有两个串口一个接了板卡一个悬空选到悬空的那个自然没数据。第二波特率不匹配虽然能打开但采样点错位收到的全是乱码或零。第三硬件流控被意外使能CRTSCTS没关掉对方不发 RTS 就收不到。解决顺序先用adb shell确认节点再用示波器或逻辑分析仪看 TX/RX 是否有波形最后检查termios配置里CRTSCTS是否清零。没有仪器的话把波特率从 115200 降到 9600 试一遍有时候能快速定位。4.2 现象发送正常接收偶尔丢包这是典型的读线程阻塞问题。如果read是阻塞模式UI 线程或其他逻辑一卡数据就丢了。常见做法是把read放在独立线程并且用select带超时超时后继续循环而不是退出。另外接收缓冲区要足够大read一次读不完的数据要循环读。还有一个隐蔽原因tcsetattr的TCSANOW和TCSADRAIN用错。TCSANOW立即生效可能打断正在传输的数据TCSADRAIN会等发送完成。打开串口时用TCSANOW没问题但运行中改参数建议用TCSADRAIN。4.3 现象十六进制显示出现FFFFFF前缀这是 Java 的byte转int时的符号扩展问题。byte是有符号的0x80到0xFF会被当成负数String.format(%02X, b)就会输出FFFFFF80这种。解决办法是先和0xFF做与运算sb.append(String.format(%02X , data[i] 0xFF));这个坑几乎每个做串口十六进制显示的人都踩过改起来一行代码但不知道原因能查半天。4.4 现象应用切到后台后串口断开Android 在内存紧张时会回收后台进程串口 fd 被关闭。如果工具需要长时间运行常见做法是加一个前台服务把串口读写放在 Service 里通知栏显示状态。这样系统不会轻易杀掉。另外onDestroy里要确保close被调用否则 fd 泄漏下次打开可能失败。4.5 现象不同设备上同一个节点名行为不一致Android 设备厂商对串口的命名没有统一标准。有的把调试串口叫/dev/ttyS0有的叫/dev/ttyMT0USB 转串口有的识别成/dev/ttyUSB0有的识别成/dev/ttyACM0。更麻烦的是同一个节点在不同固件版本上权限可能不同。解决思路是不要硬编码节点名启动时扫描/dev下所有tty*节点列出可读写的让用户选。扫描可以用File.listFiles配合canRead/canWrite判断。虽然不能覆盖所有情况但比写死一个节点强得多。注意扫描节点时不要在主线程做/dev下文件多的时候会卡顿放到子线程再更新 UI。5. 进阶技巧用脚本批量验证串口稳定性与自动化回归5.1 为什么需要批量验证单次手动收发只能证明“通了”不能证明“稳”。工业现场最怕的是偶发丢包或长时间运行后死锁。我一般会在工具里加一个压力测试模式按固定间隔发送递增序号接收端校验序号连续性统计丢包率和超时次数。这个功能用 Java 写不复杂但能提前暴露很多问题。5.2 用 adb 做无界面回归如果设备已经 root 或者有权限可以直接用 adb 跑一个 shell 脚本做基础验证不依赖 APK。下面这个脚本循环打开串口、发送数据、读回显适合产线快速筛设备。#!/system/bin/sh # 简单串口回环测试需要设备端有 stty 和 cat DEV/dev/ttyS1 BAUD115200 stty -F $DEV $BAUD cs8 -cstopb -parenb -echo echo config done # 后台读前台写 cat $DEV READER_PID$! for i in $(seq 1 100); do echo PING_$i $DEV sleep 0.1 done kill $READER_PID echo test finished这个脚本依赖设备端有stty和cat不是所有 Android 都有。如果有它能快速验证硬件通路。stty的参数cs8是 8 数据位-cstopb是 1 停止位-parenb是无校验-echo关回显。cat后台读echo前台写观察输出是否完整。5.3 在工具里加一个“自动重连”开关现场设备可能因为电磁干扰导致串口驱动异常表现是 fd 还在但读写失败。我一般会在读线程里判断连续 N 次read返回 -1 且errno不是超时就自动关闭并重新打开串口。重连间隔从 1 秒开始失败翻倍最多到 30 秒。这个逻辑不复杂但能减少很多现场维护成本。private void readLoop() { byte[] buffer new byte[1024]; int failCount 0; while (isOpen) { int len serialPort.read(buffer, 100); if (len 0) { failCount 0; postData(buffer, len); } else if (len 0) { failCount; if (failCount 10) { reconnect(); failCount 0; } } } }reconnect里先close再open注意加锁避免和发送线程冲突。重连成功后要清空缓冲区防止旧数据干扰。5.4 一个容易被忽略的细节串口缓冲区清理打开串口后/dev/ttyS*的驱动缓冲区里可能残留上次的数据。如果不清理第一次读到的可能是历史垃圾。常见做法是打开后先tcflush(fd, TCIOFLUSH)把输入输出缓冲区都清掉。这个调用在 JNI 层加一行就行但很多人不知道导致“第一次数据不对后面就正常了”这种玄学问题。tcflush(fd, TCIOFLUSH); // 清空输入输出缓冲从那以后我每次打开串口都强制走一遍tcflush不管看起来有没有必要。这个习惯帮我省掉过至少两次现场排查。希望帮到你。本文还有配套的精品资源点击获取