ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Android串口工具源码详解:从JNI桥接到设备节点通信

Android串口工具源码详解:从JNI桥接到设备节点通信 简介这份资源是一套基于Android平台的串口通信工具完整源码面向需要实现串口调试、设备通信的Android应用开发者。项目以 ComAssistant-master 为基础覆盖了权限动态申请、串口API调用、波特率与校验位配置、数据读写、后台线程管理以及异常处理等关键开发环节适合入门学习或作为实际项目的搭建参考。资源共43个文件压缩包约469KB核心部分包括Java层源码、so动态库、jni原生C代码以及XML界面配置目录结构完整可从上层界面一路追踪到底层串口通信实现。已有1790人在CSDN学习浏览该资源验证了内容的实用性与参考价值。通过这份源码开发者能够清晰理解Android串口通信的完整流程掌握多设备枚举、连接状态管理、事件驱动收发等进阶要点有效缩短自主二次开发与排错周期。1. 为什么需要一份 Android 串口工具源码做工业平板、仪器仪表或车机应用的同学迟早会撞上这样一幕设备上留了一个 DB9 或 RS485 接口对方的协议手册写得像天书但业务上又必须在 Android 系统里把这路数据收进来。市面上现成的串口调试 App 一抓一大把能测通硬件的收尾但没法直接嵌进自己的应用里——后台服务、协议解析、断线重连全部得从自己的代码里长出来。这份标题里的源码核心就是一套从应用层打开 Linux 字符设备节点、配置波特率与数据位、再以流的形式完成收发的完整工程。它解决的是「Android 应用侧怎么和外部串口设备稳定通信」这个具体问题。它的适用对象很明确一类是要把串口能力接进自研 App 的工程师另一类是刚接触 Android 串口开发、想从一份可编译的工程里搞懂原理的初学者。很多开源实现把 Java 层和 C 层的职责分得很清楚——Java 层负责设备路径、权限和流接口C 层用 termios 系列函数真正操作设备文件。对开发者来说这份源码既是可直接引用的库也是一份能读懂的教科书。我见过不少人拿着源码在 ARM 板子上跑通后才发现自己在 JTAG 和权限上耗掉的时间比写业务代码还多这篇笔记就按「原理 → 拆源码 → 集成 → 避坑」的顺序把它讲透。2. 串口接入 Android 的第一性原理设备节点与 JNI 桥接2.1 Android 串口设备节点到底藏在哪Android 底层是 Linux 内核串口在 Linux 里并不神秘它就是一类字符设备文件。常见的设备节点路径有/dev/ttyS0、/dev/ttyMT0、/dev/ttyUSB0等具体叫什么取决于 SoC 平台和物理接口类型。比如多数 x86 工控板的串口是/dev/ttyS0不少 ARM 平台把 UART 命名为ttyMT、ttyAMA之类的前缀如果通过 USB 转串口芯片接入则多半是/dev/ttyUSB0。了解这一点主要是为了在代码里做设备路径的配置化别写死一个路径就以为万事大吉。串口作为字符设备的本质意味着打开它要用文件操作语义配置参数要走termios结构体收发数据就是read()和write()。Java 层不提供直接访问/dev/路径下的设备文件的能力所以所有开源方案都绕不开 JNI。常见做法是用 NDK 编译一个本地库在 C 层调用open()、tcgetattr()、tcsetattr()和read()/write()再把文件描述符传回 Java 层由 Java 层包装成InputStream和OutputStream。这个桥接层的质量直接决定了项目后续的稳定性。2.2 为什么 Java 不能直接操作串口不少刚接触的人会问我把文件路径交给FileInputStream不就行了实验一次就会发现不行。原因有两层一是 SELinux 和 Android 的应用沙箱权限模型不允许普通 App 直接打开/dev/ttyS*这类节点这点后面避坑章节再展开二是在 Android 中直接往任意/dev/文件写数据是受系统策略管控的即使侥幸打开配置波特率、数据位这种操作也没有对应的 Java API。串口参数本质是对驱动层的 termios 结构做ioctl设置这只能从原生代码完成。所以 JNI 层的核心任务就三件打开设备文件、配置 termios、把 fd 交给 Java 层做流式读写。打开设备的 C 代码中open()通常会传入O_RDWR和O_NOCTTY两个标志O_NOCTTY的意思是防止这个串口设备成为当前进程的控制终端避免进程收到意外的终端控制信号。参数用O_NONBLOCK还是阻塞模式取决于读线程的实现策略后面会细说。int fd open(device_path, O_RDWR | O_NOCTTY | O_NDELAY); if (fd -1) { // 打开失败打印 errno 便于排查权限与路径问题 return -1; } // 把串口设置为阻塞模式让读写有自然的等待语义 fcntl(fd, F_SETFL, 0);这段代码里的O_NDELAY在这里的作用是与F_SETFL搭配使用先以非阻塞方式探测能不能打开节点随后通过fcntl把 fd 恢复为阻塞模式。这样既避免打开阶段因设备状态异常导致挂死又保证正式读写时能按阻塞流模型处理。很多源码就是在这一层掉了链子——非阻塞 fd 传给 Java 层后InputStream.read()会立即返回 0 或抛出异常导致数据读不到或 CPU 空转。如果你看到的源码里没有这一行大概率会在实际设备上踩到这个坑。2.3 termios 配置波特率、数据位与校验位的 C 层写法配置 termios 是整个源码里最容易“看着没问题但跑起来全是乱码”的部分。标准流程是先tcgetattr拿到当前配置修改c_cflag、c_iflag、c_oflag、c_lflag再用tcsetattr写回。常见的干净做法是先调用cfmakeraw(options)把驱动设置为原始模式这样不会对输入输出做回车换行转换、不会把CtrlC当信号处理避免 Linux 行规程干扰二进制数据帧。struct termios options; tcgetattr(fd, options); cfmakeraw(options); // 设置波特率这里的速率宏要与 Java 层传过来的 int 值对应 cfsetispeed(options, B9600); cfsetospeed(options, B9600); // 数据位 8、停止位 1、无校验最常见组合 options.c_cflag ~CSIZE; options.c_cflag | CS8; options.c_cflag ~CSTOPB; options.c_cflag ~PARENB; // 启用接收关闭硬件流控大多数 RS232 设备不用 RTS/CTS options.c_cflag | CREAD | CLOCAL; options.c_cflag ~CRTSCTS;参数说明这一组配置的含义CS8表示 8 个数据位CSTOPB清零表示 1 个停止位若置位则是 2 个停止位PARENB清零表示无校验置位后还要配合PARODD决定奇偶校验。CREAD必须置位否则驱动不会把收到的数据交给应用CLOCAL忽略调制解调器的载波信号避免检测不到 DCD 就收不到数据。cfmakeraw之后c_iflag和c_lflag基本不用再改这一点是很多源码里容易忽略的细节——有人只设置了c_cflag却保留了行规程的转换结果收到的帧里时不时多一个\r或0x0A。双向波特率不一定要一样部分设备要求 115200 发送、9600 接收这种非对称配置。源码里一般会做成读波特率和写波特率两个参数内部依次调用cfsetispeed和cfsetospeed。如果发现设备回数据乱码第一件事就查这两个值有没有配对。3. 拆解源码核心模块读、写、关闭的生命周期管理3.1 Java 层与本地库的对应关系拿到一份串口工具源码先看 Java 层的类结构通常能整理出几个固定角色一个持设备路径和参数并调用 native 打开方法的门面类一个继承InputStream的读包装类一个继承OutputStream的写包装类再加上关闭时释放 fd 的清理逻辑。native 方法的典型声明是private native int open(String path, int baudrate, int flags)open返回的 int 就是要持有的文件描述符。这套设计的关键在于Java 层的流对象与底层 fd 是绑定的流生命周期必须跟设备开关周期严格同步。常见错误是只关闭了InputStream却没关闭底层 fd导致串口被长时间占用、其它进程无法再打开。所以源码里close()方法通常会先在 Java 层标记一个mOpen false再调用 nativeclose()并且在 native 内部做了 fd 合法性检查防止close(-1)导致崩溃。3.2 读线程模型阻塞读还是事件轮询串口数据是异步到达的所以读操作必然要放到子线程。很多源码会在打开设备后启动一个读线程在线程里循环调用InputStream.read(buffer)。阻塞模式下没有数据时线程会自然挂起不占 CPU数据到达时驱动唤醒线程完成读取。如果 C 层没有把 fd 恢复为阻塞模式这个循环就会变成空转CPU 占用率直线上升。Override public void run() { byte[] buffer new byte[256]; while (mIsOpen) { int length; try { length mFileInputStream.read(buffer); if (length 0) { byte[] data new byte[length]; System.arraycopy(buffer, 0, data, 0, length); if (mDataListener ! null) { mDataListener.onDataReceived(data); } } } catch (IOException e) { if (mIsOpen) { // 只有未主动关闭时的异常才需要上报 mDataListener.onError(e); } break; } } }逻辑说明这个读循环里三个容易写错的点。第一mIsOpen先判断再read()但read()可能长期阻塞在设备上所以关闭方法里要同时close()流来打断阻塞第二buffer大小通常 128256 字节对于一帧几十字节的协议足够遇到大帧场景再调大第三必须把IOException掐在本地区分是主动关闭还是异常断开否则线程会不正常终止。参数上我一般把buffer设为 512覆盖绝大多数传感器和工业协议的帧长度。3.3 写操作与串口驱动的发送缓冲写操作相对简单直接通过OutputStream.write()完成。需要留意的点是串口驱动的发送缓冲区并不是无限大写入速度超过设备消费能力时底层write()会阻塞所以业务上发送协议帧的线程不要放在 UI 线程。另一个细节是write()成功返回并不代表对方设备真的收到了完整数据它只表示数据进入内核发送缓冲区对讲机式的应用还要靠应答帧确认。public void send(byte[] data) { if (!mIsOpen || data null || data.length 0) { return; } try { mOutputStream.write(data); mOutputStream.flush(); } catch (IOException e) { // 写入失败时记录日志并触发重连回调 onConnectionError(e); } }这里flush()对流本身没有意义因为没有用户态缓冲区但保留这个调用是为了将来替换成带缓冲的包装流时行为保持一致。send()方法最好做成同步方法或由上层消息队列统一调度避免多个业务线程并发write()导致帧交叉错乱。多数设备协议对帧连续性敏感一条指令被另一条指令的字节打断解析端必然报错。3.4 串口参数的可配置化设计良好的源码不会把这些参数写死在 C 代码或者 Java 类的构造方法里而是支持读取配置或动态传入。设备路径、波特率、数据位、停止位、校验位、流控方式这六项每一项都应当被上层参数化。常见做法是提供一个配置类或SerialPortOptions结构体打开时一次性传入。参数可选值缺省建议设备路径/dev/ttyS0、/dev/ttyMT0、/dev/ttyUSB0运行时探测波特率9600、19200、115200 等按设备手册数据位5、6、7、88停止位1、21校验位NONE、EVEN、ODDNONE流控NONE、RTS/CTSNONE表格里的参数需要两者都要覆盖到一是 C 层的 termios 宏定义二是 Java 层的参数映射逻辑。特别提醒波特率只能使用内核预定义的速率值直接传一个随意数字给底层会设置失败或得到错误速率。非标波特率如 2400、57600 之外的奇异值需要内核和驱动支持BOTHER机制绝大多数商用源码不处理这种情况。4. 把源码集成进自己的 Android 工程从编译到第一个 Demo4.1 准备工作拷贝源码与本地库进工程无论从哪获得的源码通用集成步骤都是类似的找到 C 层源码或预编译的.so库、把 Java 层工具类拷贝到自己的src目录、配置CMakeLists.txt或把.so放到src/main/jniLibs/abi/下。如果是 .so 库需要确认包含arm64-v8a和armeabi-v7a目录某些源码只带 v7a在 64 位真机上可能加载失败。android { defaultConfig { externalNativeBuild { cmake { cppFlags // 指定要编译支持的 ABI abiFilters arm64-v8a, armeabi-v7a } } } externalNativeBuild { cmake { path src/main/cpp/CMakeLists.txt } } }这段配置的含义是让 Gradle 在构建时自动用 CMake 编译 C 层源码并只生成arm64-v8a和armeabi-v7a两种指令集。真机调试时这两个 ABI 基本覆盖全部设备如果还要支持 x86 模拟器可以临时加一个x86_64但发布包不推荐带上体积变大且没有实际意义。如果源码不附带 C 源码而只有 .so那就省去externalNativeBuild配置直接依赖预编译库。4.2 初始化与打开串口的最小可用代码集成调试阶段不要上来就套复杂的业务框架。先用最小代码把设备打开、收到一串数据、再关掉验证硬件链路是通的然后再往上层加协议解析。最小可用的调用逻辑一般是声明设备路径与参数 → 实例化串口工具类 → 注册数据回调 → 打开设备。// 1. 确定设备节点先执行 ls /dev/ttyS* 确认实际路径 String devicePath /dev/ttyS0; int baudrate 9600; // 2. 创建配置数据位8停止位1无校验 SerialPortOptions options new SerialPortOptions.Builder() .devicePath(devicePath) .baudrate(baudrate) .dataBits(8) .stopBits(1) .parity(SerialPortOptions.PARITY_NONE) .build(); // 3. 打开设备读线程自动启动 SerialPort serialPort new SerialPort(options); serialPort.setDataListener(data - { // 收到数据后投递到业务线程不要在这里做耗时操作 Log.d(SerialPort, recv: bytesToHex(data)); }); serialPort.open();逻辑说明这个代码块演示了从配置到打开的最小闭环。bytesToHex是调试期间必备的辅助方法把字节数组转换成十六进制字符串打印出来方便和协议手册逐字节对照。对于协议复杂的场景建议在回调里先做帧缓冲再按帧长度或帧头解析整包数据避免把半包数据交给业务逻辑。打开方法内部应该先检查设备是否已打开防止重复调用造成 fd 泄露。4.3 帧协议封装粘包与半包处理串口通信最常见的协议坑是“粘包”和“半包”。设备可能一次发来多个帧缓冲区读一次也可能只读到半帧。成熟的源码会提供一个帧缓存区把每次读到的数据追加到缓存尾部然后按协议格式循环解析逐帧回调上层。private final ByteArrayOutputStream mFrameBuffer new ByteArrayOutputStream(); public void onDataReceived(byte[] data) { // 1. 数据进入缓存 mFrameBuffer.write(data, 0, data.length); // 2. 按帧头长度字段循环拆包 byte[] buffer mFrameBuffer.toByteArray(); int offset 0; while (buffer.length - offset 4) { // 假设帧格式: 0xAA 0x55 len data... if ((buffer[offset] 0xFF) 0xAA (buffer[offset 1] 0xFF) 0x55) { int frameLength buffer[offset 2] 0xFF; if (buffer.length - offset frameLength 3) { break; // 数据不够一帧等下一包 } byte[] frame Arrays.copyOfRange(buffer, offset, offset 3 frameLength); mFrameListener.onFrameReceived(frame); offset 3 frameLength; } else { offset; // 没有帧头逐字节跳过 } } // 3. 处理剩余未消费数据 byte[] remain Arrays.copyOfRange(buffer, offset, buffer.length); mFrameBuffer.reset(); mFrameBuffer.write(remain, 0, remain.length); }这段代码的逻辑是典型的“缓存 循环拆包”模式先把新数据追加进缓冲区再根据定义的帧头与长度字段判断当前缓冲区里有没有完整的一帧。有就切割出来回调没有就跳出循环等待下包数据。跳字节找帧头的分支应对异常字节防止数据错位后整个缓存区作废。参数上协议长度字段通常是 1 或 2 字节如果帧长度超过 255 就要改成双字节小端序帧头越复杂比如 4 字节魔数误判概率越低但拆包代码也更繁琐。4.4 关闭与资源释放的完整顺序串口关闭的顺序里藏着不少 bug。一个常见的错误是先把mIsOpen置为 false 再关闭流然后直接 join 读线程但在某些定制系统上读线程可能深度阻塞join 会一直卡住。更稳妥的关闭顺序是主动设置关闭标记 → 关闭InputStream打断阻塞 → 等待读线程退出并设置超时 → 最后关闭OutputStream和底层 fd。public void close() { if (!mIsOpen) { return; } mIsOpen false; try { if (mFileInputStream ! null) { mFileInputStream.close(); // 打断阻塞读 } } catch (IOException ignored) { } try { if (mReadThread ! null) { mReadThread.join(500); // 等待线程退出避免残留 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } try { if (mFileOutputStream ! null) { mFileOutputStream.close(); } } catch (IOException ignored) { } mSerialPortNative.close(); // 释放 fd }这段关闭流程的巧妙之处在于借助InputStream.close()从外部打断阻塞的read()从而让读线程自然退出而不是依赖一个不确定的接口标志位。join(500)是保险策略如果驱动异常导致流关闭后线程仍不退出最多等 500ms避免关设备时 UI 卡死。mSerialPortNative.close()必须在最后调用等所有 Java 层流全部释放后再收起 fd。5. 串口集成避坑指南5 个最常见的翻车现场5.1 设备节点存在但打开报 Permission denied现象代码里路径写的是/dev/ttyS0文件也存在但open()返回 -1日志里打出的 errno 是 13。原因Android 的 SELinux 策略默认禁止普通应用访问 tty 设备节点即使应用声明了READ_EXTERNAL_STORAGE也无济于事。解决如果设备已经 root调试阶段可以用setenforce 0临时放行量产方案通常需要在系统固件的 sepolicy 里追加允许规则或者有系统签名权限的 App 通过系统接口申请。经验之谈是工业平板上跑串口应用十有八九要跟设备厂商要一个定制固件或系统授权不要指望纯应用层绕过。5.2 read() 一直接收不全整帧数据总是在半路断开现象打印收到的字节数组每次只有几十字节一帧 200 字节的数据要分四五次才能收完。原因驱动按内核缓冲区大小与中断节奏上报数据应用侧read()不会为你凑够一帧。解决从根上接受“读到多少算多少”的现实把协议拆包逻辑做在应用层用 4.3 节那种帧缓存持续拼接。另外一个隐蔽原因是缓冲区开得太小read(buffer)的 buffer 只有 64 字节驱动一次可用数据再多也只能取 64 字节改大到 512 或 1024 往往能减少切包次数。5.3 波特率设了 115200设备回的却是乱码现象收发硬件连线确认无误参数配置也检查过设备返回的字节流完全不可读。原因最典型的是 C 层未正确设置对应波特率的宏或者cfsetispeed与cfsetospeed只设置了一个。另一个可能位于不太容易被想到的位置USB 转串口芯片的驱动层默认配置和代码配置不一致导致实际链路速率与预期不同。解决排除法很有效——先用系统自带串口调试工具把设备调通再用自己的源码连确认不是硬件问题后回到 C 代码逐行打印tcgetattr读回的c_cflag实际值检查波特率宏是否真的生效。5.4 串口关闭后立刻重新打开失败现象执行close()后 1 秒内再次open()同一路径返回 -1提示Device or resource busy。原因关闭时 fd 还被子线程持有或者驱动内部有状态残留还有一个常见原因是close()没被完整调用Java 层流关了但 C 层 fd 没关。解决关闭流程严格按 4.4 节顺序执行若仍然复现在 close 末尾加一次usleep或让上层业务在收到关闭回调后再等 200ms 重新打开。某些底层驱动确实需要几十毫秒释放端口这种“玄学”问题见得多了重试机制比纠结驱动源码更省时间。5.5 数据回调里直接更新 UI 导致界面卡死或崩溃现象数据量一大界面掉帧甚至 ANR。原因串口读线程是一个高频触发源在回调里直接runOnUiThread刷新控件会把每帧数据都变成一次 UI 任务消息队列瞬间堆积。解决读线程只负责把帧投递到业务层的单线程队列由专门的 HandlerThread 消费只有在真正需要刷新 UI 的业务点上才切到主线程。比较实用的模式是回调里做数据聚合比如每 100ms 把累积数据合并发布一次而不是一帧一刷。6. 把串口源码变成趁手的调试工具一个长期值得投入的小习惯源码集成稳定之后我建议再做一层轻量封装把它做成一个可独立运行的工具型 Activity或至少保留一个不依赖业务模块的调试入口。界面不用复杂一个设备路径下拉框、一个波特率选择框、一个发送输入框、一个滚动接收日志区就够。别小看这个工具它解决的是实际开发里特别好用的痛——协议联调时对方的设备不可能每次都连着你电脑上的 USB 转串工具在 Android 设备上直接调才是现场真实链路。一个值得借鉴的做法是把接收区做成十六进制与 ASCII 双显并且加上行号和时间戳。工业协议里经常出现“非打印字符 残留旧帧”的组合纯文本模式下几乎无法定位问题。加上时间戳后从设备发出到应用收到的时间差一目了然能直接判断是链路延时长还是协议交互次数太多。我会习惯性地在工具页放一个“连续发送”按钮用来长时间压测设备在高频询问下会不会掉线——这个测试数据在量产评估里非常有说服力。在封装时给源码补两个能力一是把当前收发的字节数统计出来累计计数清零二是把每条日志同步落盘。字节统计能直观看到实际吞吐量和理论波特率对比就能大致判断底层链路是否健康。日志落盘的价值则在于复现问题现场设备环境复杂等用户把出问题的 logcat 抓回来往往已经丢了上下文有了本地文件排查效率能提高一倍不止。除此之外确认数据稳定后再考虑把它收敛为独立 Module串口逻辑与业务完全隔离对外只暴露 open/send/close 三个接口。这样以后从 Android 工程切到系统定制固件或换平台迁移成本都控制在很小的范围。这套源码对我来说更像是一个持续迭代的底座——每次遇到新硬件平台把设备节点映射和波特率策略调整一下剩下的稳定性保障都是现成的。希望能帮你的串口项目少走几段弯路。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表