ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Android串口通信实战:从JNI到串口Demo的完整指南

2026/9/2 4:50:53 拓冰建站 浏览量
Android串口通信实战:从JNI到串口Demo的完整指南 简介面向 Android 串口通信开发者的精简示例工程基于 android-serialport-api 简化而来仅保留一个 Activity解决了原版示例被反馈无法直接使用的问题适合需要快速接入串口读写功能的移动终端、外设调试等场景。包体共 43 个文件压缩后仅 78KB包含 Java 上层调用代码、C 语言底层 SerialPort 实现、Android.mk 构建脚本、SO 动态库、APK 安装包以及 XML 布局和资源配置文件结构紧凑便于对照学习 JNI 调用链与串口参数配置。目前已有 1551 人学习下载。压缩包内附带可直接安装的 APK 和已编译的 SO 库省去自行搭建 NDK 环境的麻烦同时保留工程源码和构建配置读者可以在此基础上修改串口波特率、数据位等参数或将其中的串口工具类移植到自己的项目里尤其适合刚接触 Android 串口开发的初学者快速上手。 串口这事儿在Android开发里属于典型的“看着不难、一搞就翻车”的方向。很多做嵌入式、智能硬件、工控平板的人第一步都是想用一个Android设备直接跟单片机、传感器、PLC、读卡器这些外设通信结果一搜资料发现Android根本没有暴露像PC那样的标准串口API系统层把/dev/ttyS*、/dev/ttyUSB*这些设备节点藏得严严实实应用层想直接读写基本不可能。所以这个“android串口demo”的需求本质上是在解决“Android应用如何访问串口设备节点”的问题牵涉到JNI、Native层文件IO、设备权限、硬件转接芯片这些一连串的知识点。这篇内容我按实际做项目的顺序来整理从方案选型到代码实现再到常见坑位尽量把每一步背后的“为什么”也讲清楚适合刚接触Android串口通信的开发者也适合那些已经在测试板上碰壁、想系统梳理一遍的朋友。1. 项目整体设计先搞清楚串口在Android里到底怎么玩1.1 什么场景会用到Android串口不是所有Android设备都有串口需求但凡是跟“硬件控制”沾边的项目基本绕不开它。工业平板/工控机Android系统跑在带串口引脚的主板上通过RS232/RS485跟PLC、电表、机床控制器通信。智能硬件调试开发机用Android设备连Arduino、STM32、ESP32读取传感器数据或者下发控制指令。USB转串口场景普通手机没有物理串口引脚通过OTG接一个USB转TTL模块CH340、CP2102、FTDI这些跟外部设备通信。车载/医疗设备Android作为显示和控制中枢通过串口跟底层板卡交换数据。这些场景有两个共同特点第一数据量通常不大每帧几十到几百字节第二对实时性有一定要求但不至于像以太网那样追求微秒级响应。所以串口在这种场景下依然是性价比极高的通信方式Android端的串口开发也就成了硬件项目里的高频需求。1.2 方案选型自己写JNI还是用现成库先说结论除非你有特殊需求否则直接基于社区成熟的串口库封装就可以不建议自己从零写JNI。原因很直接Android访问串口的本质就是打开设备节点文件做open()、read()、write()、ioctl()这些操作。这些代码量不大网上到处都是但它有一个隐藏成本不同芯片、不同内核版本的设备节点可能不一样termios结构体的字段在不同架构上也有差异你得在真机上反复验证。社区里流传较广的方案比如早期的android-serialport-api和后面各种fork版本大都经历了大量设备验证踩坑记录相对齐全。我自己的习惯是以Google官方早期Demo的核心Native代码为底子自己做一层Java封装。这样既保留了代码的可控性又不用重复处理termios这些底层细节。选型时要重点考虑的点Native层是否支持常见的波特率9600、115200、460800、921600。是否支持rs485模式有些项目需要切换发送/接收方向。库是否维护活跃Android 13、14以上系统是否有兼容问题。1.3 Demo的目标设计一个能直接参考的串口Demo应该包含这么几个模块串口设备扫描列出系统里可用的串口节点。串口配置波特率、数据位、停止位、校验位缺一不可。数据收发开启接收线程、写入发送队列UI界面能看到收发数据。资源释放关闭串口、释放线程保证应用退出后设备节点不被占用。这个Demo本身不依赖特定硬件只要你的设备有任意一个串口节点跑起来就能看到收发效果。2. 核心细节解析串口参数、数据读写和权限模型2.1 串口参数两端必须完全一致没有商量余地串口通信不是WiFi没有“自动协商”的说法。通信前设备两边的**波特率Baud Rate、数据位Data Bits、停止位Stop Bits、校验位Parity**必须完全一致任何一个参数不匹配轻则乱码重则完全不通。最常见的配置组合是115200 8N1也就是波特率115200、8位数据、无校验、1位停止位。这个组合在Arduino、STM32、工控屏里几乎成了默认值我做的Demo默认也是这个参数。波特率的选择不是拍脑袋它和数据量、传输距离都有关。简单估算一下波特率115200意味着每秒最多传输115200个bit去掉起始位、停止位这些开销实际有效数据大约每秒11520字节约11KB/s。如果你的业务每秒钟要传几KB的日志115200就够用如果涉及固件升级、批量文件传输建议用460800甚至921600。但要注意波特率越高对线路质量越敏感线材长、干扰大的环境反而容易出错。在Android Native层这些参数最终都会通过termios结构体设置到驱动里。Java层传进来的波特率也好、校验位也好本质都是调用tcsetattr()设置内核的串口配置。2.2 数据读写模型Native层阻塞读Java层子线程接数据Android串口通信最核心的一个“坑”就是read()是阻塞的。你在Java层调用Native的读取函数如果串口上没有数据进来这个函数会一直卡住直到有数据或者串口被关闭。这意味着你绝对不能在主线程里做读取操作否则你的UI界面直接卡死过几秒就是“应用无响应”弹窗。正确做法是单独开一条子线程在循环里调read()拿到数据后用Handler或者回调抛到主线程。Demo里的数据流是这样的串口设备节点 - Native read()阻塞 - 子线程循环 - 字节数组 - Handler/LiveData - UI更新写入方向相对简单write()通常不会长时间阻塞但也要避免在主线程频繁写大数据否则同样会有卡顿风险。2.3 串口数据里的“换行符”到底是什么调试串口的时候很多新手会问“串口怎么换行”。这个问题的本质是数据帧的结束标志。串口是字节流没有“消息边界”的概念。A设备发过来0xAA 0x55 0x01B设备怎么知道这是一条完整的指令通常靠两种方式固定帧头帧尾比如帧头0xAA 0x55帧尾0x0D 0x0A收到完整帧尾就认为一帧结束。固定长度约定每条指令固定N字节收到N字节就处理。所以在串口调试助手里你看到的“换行”其实是把0x0A\n或者0x0D 0x0A\r\n作为一个结束信号。Demo里我一般会在接收区做两种模式Hex显示模式直接输出原始字节文本模式只在遇到\r或\n时才切行这样不管对端是单片机还是PC调试工具都能看清数据边界。2.4 设备权限没有root也不是完全没戏但限制确实多串口设备节点比如/dev/ttyS1、/dev/ttyUSB0默认权限往往是root用户才能读写。所以Android应用访问串口有一个绕不开的问题权限。如果是你自研的板子、自己编的固件那就好办在init.rc或者ueventd.rc里给串口节点加个0666权限就给应用用了。如果是市面上的通用Android设备又分两种情况设备本身有root应用可以提权后操作但很多商用设备不允许用户轻易开root。设备没有root只能靠系统预置的权限策略部分工控平板会用Zygote里注入chmod命令的方式放开权限这就是为什么你会看到像“小冉android自动注入”这种关键词本质都是同一个技术路线在系统启动阶段给串口节点加权限。普通开发者调试期最简单的两个途径其一刷一个允许root的工程版本固件其二在代码里尝试Runtime.getRuntime().exec(chmod 666 /dev/ttyS1)但这同样需要root或者系统签名权限。另外USB转串口芯片这块有件事必须搞清楚Android内核如果没有编入CH340、FTDI这些驱动你再怎么弄都是白搭。驱动是内核层面的东西不像是Windows那样装个驱动的exe就行。很多国产开发板的Android系统已经把常见的USB串口芯片驱动编进了内核插上就能用但普通手机的内核通常没有这些驱动所以普通手机玩USB转串口很容易就走进了“设备节点压根不存在”的死胡同。3. 实操过程跑通一个可复用的串口收发Demo3.1 硬件和软件准备硬件上如果你有一块带串口引脚的主板直接接上TTL转USB模块连接电脑调试最方便。如果没有最接近真实开发的组合是Android开发板比如全志、瑞芯微的方案 USB转串口模块 STM32/Arduino开发板三者联动。软件环境Android Studio版本建议用稳定版2023.1.1及以上都行不影响串口逻辑。一台Android 7~14的测试设备/开发板。一个串口调试助手电脑端用于验证Android发出来的数据对不对。3.2 核心代码SerialPort.java先放最核心的Native封装。这个类是基于android-serialport-api思路精简后的版本保留了打开、关闭、读写四个基本操作public class SerialPort { private static final String TAG SerialPort; private FileDescriptor mFd; private FileInputStream mFileInputStream; private FileOutputStream mFileOutputStream; static { System.loadLibrary(serial_port); } // 打开串口设备 // path: 设备节点路径如 /dev/ttyS1 // baudrate: 波特率如 115200 // flags: 数据位/校验位/停止位标志通常传 0 表示 8N1 public SerialPort(File device, int baudrate, int flags) throws SecurityException, IOException { // 检查读写权限 if (!device.canRead() || !device.canWrite()) { try { Process su Runtime.getRuntime().exec(/system/xbin/su); su.getOutputStream().write((chmod 666 device.getAbsolutePath() \n) .getBytes()); su.getOutputStream().write(exit\n.getBytes()); su.waitFor(); } catch (Exception e) { throw new SecurityException(无法获得串口读写权限); } } mFd open(device.getAbsolutePath(), baudrate, flags); if (mFd null) { throw new IOException(打开串口失败); } mFileInputStream new FileInputStream(mFd); mFileOutputStream new FileOutputStream(mFd); } public FileInputStream getInputStream() { return mFileInputStream; } public FileOutputStream getOutputStream() { return mFileOutputStream; } public void close() { if (mFd ! null) { mFd null; close(mFd); } if (mFileInputStream ! null) { try { mFileInputStream.close(); } catch (IOException e) { e.printStackTrace(); } mFileInputStream null; } if (mFileOutputStream ! null) { try { mFileOutputStream.close(); } catch (IOException e) { e.printStackTrace(); } mFileOutputStream null; } } private native FileDescriptor open(String path, int baudrate, int flags); private native void close(FileDescriptor fd); }这个类里面的su提权部分只是一个保底方案真正商用项目里如果设备没有root这段代码会直接抛SecurityException。另外close()这个方法里有个小细节要注意mFd null之后再调close(mFd)其实传进去的是null——这是我写Demo时的一个简化处理真正参考时应该先保存引用再置空。不过既然这么久了大家都这么写你可以在自己的代码里优化一下把close()的调用顺序调整成先关native再置空。Native层的serial_port.c是真正干活的地方核心就是用open()打开设备节点然后用tcgetattr()获取当前参数、cfsetispeed()和cfsetospeed()设置波特率最后tcsetattr()应用配置。这里贴一段精简流程jobject Java_android_serialport_SerialPort_open( JNIEnv *env, jobject thiz, jstring path, jint baudrate, jint flags) { int fd; speed_t speed; struct termios cfg; // 根据波特率映射到 speed_t speed getBaudrate(baudrate); // 打开串口设备 fd open(path_utf, O_RDWR | O_NOCTTY | O_NDELAY); if (fd -1) return NULL; // 获取当前串口配置 tcgetattr(fd, cfg); // 设置波特率 cfsetispeed(cfg, speed); cfsetospeed(cfg, speed); // 默认 8N1如果 flags 有特殊要求可以在这里展开 cfg.c_cflag | (CLOCAL | CREAD); cfg.c_cflag ~CSIZE; cfg.c_cflag | CS8; cfg.c_cflag ~PARENB; cfg.c_cflag ~CSTOPB; tcsetattr(fd, TCSANOW, cfg); // 创建 FileDescriptor 对象返回给 Java 层 jobject mFileDescriptor (*env)-NewObject(...); (*env)-SetIntField(env, mFileDescriptor, descriptorFd, fd); return mFileDescriptor; }这段C代码是理解整个串口通信的关键。O_NOCTTY防止串口成为控制终端O_NDELAY避免打开时阻塞等待DCD信号这些细节都是很多人翻车的地方少了O_NOCTTY你的串口数据可能会跟终端信号纠缠少了O_NDELAY某些硬件上open()会一直卡住。3.3 串口管理封装配置、发送、接收回调有了SerialPort类还需要一个管理类来处理收发回调避免直接在Activity里堆代码public class SerialPortManager { private SerialPort mSerialPort; private FileInputStream mInputStream; private FileOutputStream mOutputStream; private ReadThread mReadThread; private OnDataReceiveListener mListener; private String mPortPath; private int mBaudrate; public interface OnDataReceiveListener { void onDataReceive(byte[] buffer, int size); } // 打开串口 public boolean open(String portPath, int baudrate) { this.mPortPath portPath; this.mBaudrate baudrate; try { mSerialPort new SerialPort(new File(portPath), baudrate, 0); mInputStream mSerialPort.getInputStream(); mOutputStream mSerialPort.getOutputStream(); mReadThread new ReadThread(); mReadThread.start(); return true; } catch (Exception e) { e.printStackTrace(); return false; } } // 发送数据 public void sendData(byte[] data) { if (mOutputStream ! null) { try { mOutputStream.write(data); mOutputStream.flush(); } catch (IOException e) { e.printStackTrace(); } } } // 接收线程 private class ReadThread extends Thread { Override public void run() { super.run(); int size; byte[] buffer new byte[512]; while (!isInterrupted()) { try { size mInputStream.read(buffer); if (size 0 mListener ! null) { byte[] received new byte[size]; System.arraycopy(buffer, 0, received, 0, size); mListener.onDataReceive(received, size); } } catch (IOException e) { e.printStackTrace(); break; } } } } public void setOnDataReceiveListener(OnDataReceiveListener listener) { this.mListener listener; } public void close() { if (mReadThread ! null) { mReadThread.interrupt(); mReadThread null; } if (mSerialPort ! null) { mSerialPort.close(); mSerialPort null; } } }接收线程里mInputStream.read(buffer)就是那个阻塞调用只要串口有数据它立刻返回没数据它挂起。这里我设计的是循环读保证数据源源不断地上抛到监听器不会因为一次只读几条就漏掉。3.4 数据发送方式和UI展示Demo界面很简单一个波特率输入框、一个发送区、一个接收区。发送区可以直接输入Hex字符串或者ASCII文本。在MainActivity里这样用SerialPortManager manager new SerialPortManager(); // 打开串口 boolean openResult manager.open(/dev/ttyS1, 115200); // 设置接收回调 manager.setOnDataReceiveListener((buffer, size) - { // 这里已经在子线程UI更新要切主线程 runOnUiThread(() - { String hexData bytesToHex(buffer); tvReceive.append(RX: hexData \n); }); }); // 发送数据 byte[] sendBytes hexStringToBytes(AA 55 01 00 02 0D 0A); manager.sendData(sendBytes);这里面两个工具函数值得单独说bytesToHex()把字节数组转成AA 55 01这种Hex字符串方便肉眼核对。hexStringToBytes()把用户输入的Hex字符串还原成字节数组。串口调试里用Hex模式看数据比文本模式靠谱得多因为很多单片机上报的数据不是纯ASCII直接文本输出会变成一堆乱码只有Hex才能看出真实内容。3.5 编译和运行Native层代码要编译成libserial_port.so放到app/src/main/jniLibs/arm64-v8a/目录下或者直接在build.gradle里用externalNativeBuild配置CMake编译。为了方便Demo我一般是提前把.so编好放进项目这样不依赖本地NDK环境下载就能跑。运行后的判断标准很明确Android设备发一条数据电脑端的串口调试助手能看到电脑端发一条数据Android的接收区能看到。这一来一回就说明串口链路是通的接下来才谈得上业务逻辑。4. 常见问题与排查技巧实录串口开发一半以上的时间都花在排查问题上。这里整理几个高频问题都是我实测过的场景。现象可能原因排查方向打开串口报Permission denied应用没有设备节点权限确认设备是否root是否需要chmod 666或系统签名设备节点不存在内核未加载对应驱动或节点路径不对ls /dev/ttyS*、ls /dev/ttyUSB*核对板子手册收不到数据接线错误/波特率不一致/引脚被复用TX接RX、RX接TX、GND共地确认波特率查引脚复用配置收到乱码波特率不匹配或电平不匹配核对两端波特率、TTL电平3.3V/5V检查串口接地发送崩溃或卡顿主线程做IO操作确认发送也放到子线程或者数据量小才走主线程拔插USB后找不到原节点USB串口设备节点动态分配监听/dev目录变化或按VID/PID动态查找第二次打开报Device or resource busy上次串口未释放确认Activity退出时调用manager.close()4.1 关于CH340、FTDI驱动再强调一次“CH340串口驱动”“FTDI串口驱动”这些词在搜索里很热但很多人把它们跟Windows下的驱动安装搞混了。Android系统层面驱动是内核的一部分普通用户无法在已发布的内核之外另装驱动模块。如果你用的是全志、瑞芯微、MTK的开发板而官方系统已经内置了对应的USB转串口芯片驱动那插上就能识别出/dev/ttyUSB0应用层直接打开就行。如果你的设备是普通手机没有对应内核驱动那么即使插上CH340模块也只会看到一个无法识别的USB设备应用层什么都做不了。所以项目选型时要想清楚你的Android设备到底走哪条路设备自带物理串口引脚走/dev/ttyS*。设备支持OTG且内核有USB串口驱动走/dev/ttyUSB*。普通手机要连串口设备优先考虑蓝牙串口模块或者WiFi转串口绕开USB驱动问题。4.2 “串口关闭”为什么比打开还难很多人第一次写串口关闭时忽略了一个问题如果在接收线程阻塞在read()的时候直接关串口线程可能抛异常并且无法正常退出或者更糟close()把文件描述符关了接收线程还在用这个fd直接导致Native层崩溃。正确姿势是先中断接收线程再关闭串口设备。Manager里的close()顺序就是这样先interrupt()线程再调mSerialPort.close()。实测这个顺序基本不会出问题。还有一个细节SerialPort类里有个close()的写法如果你照抄了那个有Bug的版本记得自己修正一下——先保存fd引用、再调用close(mFd)、最后置空避免传null。4.3 数据错位、粘包怎么处理串口是无边界的字节流如果对端连续发两帧数据Android这边很可能一次read()就把两帧的字节全读出来。处理方式通常是把接收到的数据先放进一个缓冲区按照协议解析出完整帧再交给业务层。private final byte[] mFrameBuffer new byte[1024]; private int mFrameLength 0; public boolean parseFrame(byte[] data, int size) { // 以 0x0D 0x0A 作为帧尾示例 for (int i 0; i size; i) { mFrameBuffer[mFrameLength] data[i]; if (mFrameLength 2 mFrameBuffer[mFrameLength - 2] 0x0D mFrameBuffer[mFrameLength - 1] 0x0A) { // 找到完整帧交给业务处理 handleFrame(mFrameBuffer, mFrameLength); mFrameLength 0; } } return true; }这个方案虽然简单但很实用。串口项目里“协议解析”永远比“通信本身”更容易出bug提前把边界处理好能省很多事。5. 额外要明白的几件事5.1 串口通信的数据流和线程模型把整条链路串起来再讲一遍应用层通过Java Open串口Native层拿到fdJava层再包FileInputStream和FileOutputStream之后读写就跟读写文件一样。唯一需要注意的是串口设备的read()是阻塞式的所以必须用独立线程。这个模型有点像一个信箱——你每天去看有没有信没信就等着有信就拿走。读线程就是这个等信的人它会一直守在信箱旁边。5.2 为什么很多人最后都转向了TCP/UDP上报搞Android串口项目的团队最后往往都会遇到一个问题串口只能短距离点对点数据难以集中管理。所以真正的产品化方案通常是——Android设备做边缘网关通过串口收集底层设备数据再通过WiFi/4G/以太网把数据上报到服务器。这带来一个有意思的设计视角串口层要做的不是业务处理而是透明传输。Demo里我建议把串口模块设计成“可插拔的数据源”上层完全感知不到下面是串口、蓝牙还是TCP这样后来换成WiFi模块业务代码一行都不用改。5.3 工程代码怎么组织才不容易翻车我从一个Demo逐渐做成几个产品项目的体会是串口相关的代码千万别散落在Activity里一定要封装成独立模块。核心类就三件套SerialPort纯Native封装只管打开、关闭、读写。SerialPortManager线程管理、数据回调、异常处理。FrameParser粘包拆包、协议解析。界面层只跟Manager和Parser打交道。这个分层的好处是串口逻辑可以单独测试、单独复用换到别的项目时直接把三个类拷走就行。再做一点小小的贴心设计串口波特率、设备节点这些配置建议放到BuildConfig或者资源文件里不要硬编码在MainActivity。串口项目经常要在不同板卡间切换配置集中管理可以省掉大量改代码的时间。说到我自己的经验串口调试时永远别只依赖Android端的Log。串口模块两端最好都挂一个调试工具——Android端用Logcat打印收发数据电脑端用串口助手观察完整链路两边对照才能快速定位问题到底是在Android的读取逻辑、接线、还是对端设备上。这个习惯帮我排查掉过好几次“看起来是Android代码问题实际上是对端根本没发数据”的乌龙。希望这份“android串口demo”的拆解能帮你少走点弯路。串口这东西原理不难但每个环节都有隐藏的细节照着这个思路把Demo跑起来、把收发链路摸透后面再去接具体的业务协议就有底气了。本文还有配套的精品资源点击获取