ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android串口通讯实战:从底层UART到JNI开发与权限排查

Android串口通讯实战:从底层UART到JNI开发与权限排查

做安卓和嵌入式板联调,十有八九绕不开串口通讯。摄像头云台、工业传感器、扫码枪、门禁控制器……这些设备很多都靠串口把数据送进手机或智能终端。别看串口是老协议,到Android这边它反而容易让人卡壳:权限、SELinux、设备节点、串口被占用,哪个都能让进度归零。这篇内容专门聊Android与板的串口通讯,从底层机制到实际工程代码,把常见坑都过一遍。适合正在调试串口但一脸懵的Android开发、嵌入式工程师,或者准备从零做硬件对接的玩家。

1. 串口通讯的基础:板子和Android到底在怎么“说话”

先理清楚串口通讯是什么。串口对应硬件上的UART,全称Universal Asynchronous Receiver/Transmitter,通用异步收发器。异步意味着收发双方不需要共享时钟,而是靠约定好的波特率各自采样,所以通讯双方必须先把速率、数据格式对齐。工作方式就是一根TX发送、一根RX接收、一根GND共地,数据按位逐个发过去。板子端常见的是TTL电平,3.3V或5V;而Android设备通常没有原生物理串口,真正常见的是两种形态:一种是设备上引出UART调试排针,通过串口转接模块接出来;另一种是Android设备支持USB Host,外挂USB转串口模块后再去连接下位机。不管哪种形态,底层都对应Linux内核里的tty驱动,应用层最终看到的是一个字符设备节点,比如/dev/ttyS1、/dev/ttyUSB0。

只要明白“Android读串口本质上就是读写Linux字符设备”这一点,后面所有问题都有了解题线索。很多人卡在第一步,是因为把一个字符设备当成普通文件去读,结果发现读到的内容永远不对。原因在于串口不是简单的字节管道,它有一套自己的参数体系,参数不匹配,硬件再怎么正确也传不出有效数据。

1.1 串口协议里那几个关键参数,配置错了就是乱码

串口通讯里最核心的参数有四个:波特率、数据位、停止位、校验位。把这套参数组合起来,就成了通讯双方必须共同遵守的“语言规则”。

波特率就是每秒传输的bit数,常见的有9600、19200、115200。简单类比,波特率就像两个人约定用多快的语速说话,语速差太多,对方根本听不清。如果板子端配置的是9600,Android这边用了115200,那收到的数据就是典型的“乱码”。有人在项目里死活调不通,最后用示波器一看,发现板子那边因为晶振误差实际波特率偏离了标称值好几个百分点,才导致偶发错位。这种情况下把波特率降到9600往往能缓解,低速率对时钟偏差的容忍度更高。

数据位一般用8位,偶尔有用7位的,取决于设备协议。校验位用来做简单差错检测,常见的是None(无校验),也有Even/Odd。停止位通常为1位,也有1.5位或2位的老设备。最常用的组合是“8N1”,也就是数据位8、无校验、停止位1。这个组合覆盖了绝大多数工业设备。配置串口时一定确认一下下位机文档里写的参数,而不是直接套默认值。有些老式称重仪表可能还在用7E1,数据位7、偶校验、停止位1,Android端配置成8N1后读出来的数值就会整体错位。

还有一个容易被忽略的是流控。硬件流控RTS/CTS用两根额外引脚来协调发送节奏,软件流控用XON/XOFF字符。嵌入式设备里大部分都不开流控,因为很多板子根本没接RTS/CTS引脚。如果设备文档里没提流控,就在代码里主动关掉,否则某些串口驱动会一直等CTS信号,导致数据发不出去。

1.2 为什么Android不能像嵌入式开发板一样直接读写串口

嵌入式Linux板上用C语言写串口程序,直接open设备节点,然后配置termios就能跑。Android虽然内核也是Linux,但应用层想碰设备节点,要比纯Linux多过好几道关。

第一道关是权限。Android系统里普通App运行在受限的SELinux域里,对/dev/ttyS*、/dev/ttyUSB0这类设备节点默认没有读写的安全上下文。你用adb shell ls -l /dev/ttyS1看,设备owner多半是root或者radio,普通应用连open都打不开。这也是为什么很多人第一次跑串口库时报Permission denied。

第二道关是Linux的访问控制模型。即便用adb shell获得shell用户身份,也可能不在设备的dialout或serial用户组里,同样无权访问。开发阶段很多人直接chmod 777 /dev/ttyS1,这条路只能临时验证,设备重启之后权限会重置,机器一旦量产根本没有手动操作的机会。

第三道关是机制层。Android没有提供公开的串口API,Java层的FileInputStream可以打开文件,但没法设置波特率、数据位等参数,因为这些控制操作要通过ioctl和termios结构体完成,Java层没有现成封装。所以业界方案基本是:用JNI/NDK在C层调open、tcgetattr、tcsetattr,拿到文件描述符后再封装给Java层用。即使你说“我只用USB转串口,不碰原生UART”,USB转串口芯片的驱动最终也是注册成tty节点,还是要走同一套路径。

了解这三道关之后,思路就清晰了:要解决的是“如何获得设备节点访问权”和“如何在Android侧配置串口参数并稳定读写”这两个问题,而不是在网上漫无目的地找什么神奇库。

2. 方案选型:自己撸JNI还是直接用现成串口库

做Android串口通讯,面临的第一选择就是底层实现方式。有人想自己写C代码,彻底搞懂每一行;有人想直接集成串口库,把时间省到业务逻辑上。这两种路线我都试过,各有各的适用场景。

2.1 自研NDK串口的门槛到底在哪

自己写串口底层,本质就是封装Linux的termios接口。流程大概是:JNI层调用open打开设备节点,用tcgetattr读取当前串口属性,修改c_cflag、c_iflag等字段后再用tcsetattr写回去,然后返回文件描述符。读写可以复用read和write系统调用。

听起来不复杂,但实际写起来有几个容易踩坑的地方。第一,termios在Android Bionic libc里虽然有,但头文件路径、宏定义和glibc略有差异,网上很多Linux串口代码直接搬过来会编译报错。第二,Java层拿到文件描述符后,底层close和Java对象生命周期必须管理好,一个fd被GC回收时开了另一个线程还在读,就会引发野指针或fd泄漏。第三,Android的串口设备往往不支持O_NONBLOCK的写法,读线程要正确处理阻塞、超时和线程退出,否则关闭串口时读线程还卡在read上,导致程序无法退出。

对于产品验证或短期项目,自研的成本不小。但你真要长期维护,或者要适配多种串口芯片,自己掌控底层会更有底气。成熟串口库出了问题你是很难深入改的,尤其遇到调度时序问题时只能干瞪眼。所以我建议:专业做系统集成、量比较大的团队,自己有底层能力就自研;普通应用开发者,优先考虑现成库。

2.2 现成串口库省了哪些事,又可能埋什么雷

业内流传比较广的Android串口库,基本思路都是NDK编译一个C库,再封装成Java接口。好处显而易见:接入快、调试方便、社区里踩过坑的人多,你遇到权限问题一搜就能找到解法。

但用现成库也不是高枕无忧。第一个雷是库对设备节点的适配程度。有的库默认只找/dev/ttyS*,可你的USB转串口模块打开的是/dev/ttyUSB0,那就直接扑空。第二个雷是JNI层读线程的生命周期。调试时频繁打开、关闭串口,有的库会出现读线程没有完全退出发送数据后收不到回包的现象。第三个雷是串口参数配置不全。一旦设备需要7E1、半双工或者特殊流控,很多库根本没法扩展。

选库时重点看三件事:底层源码是否开源并可以自己编译;是否支持自定义设备节点路径而不仅是自动扫描;代码里是否对read超时、线程关闭做了处理。以我个人的经验,不要盲目迷信某个人气很高的库,能不能在你的设备上稳定跑起来,才是唯一标准。

2.3 USB转串口设备的动态权限申请

如果你的Android设备没有原生UART,而是靠USB转串口模块,那还多一个USB Host权限问题。Android把USB外设访问权控制得很严格,不能直接在代码里遍历/dev/ttyUSB0,要先通过UsbManager拿到UsbDevice,再申请用户授权。

常规流程是:注册ACTION_USB_DEVICE_ATTACHED广播,设备插入时拿到UsbDevice;然后用UsbManager.requestPermission弹窗让用户授权;授权成功后再把UsbDeviceConnection的fd传给底层,底层才能去操作对应的tty节点。这里有个经验点:有的USB转串口芯片在插入后并不会立刻生成/dev/ttyUSB0,内核驱动加载、tty节点注册需要几百毫秒,你在广播回调里立刻去open,大概率拿不到设备。正确做法是加一个延时重试机制,比如每50ms尝试一次,最多试20次。

产品化的时候,动态权限弹窗对用户体验影响很大。可以考虑在接入引导页提前说明“需要连接外设”,让用户插好设备再授权;如果已经授权过,UsbManager能拿到设备但也能重新拉起权限请求,这种情况要避免反复弹窗。还有一个思路是预先过滤VID/PID,只对自家模块发起权限申请,而不是对所有USB设备都弹窗,体验会干净很多。

3. 从识别到读写:完整实操流程

到这里,原理和选型都讲完了,下面进入能直接照着做的环节。我会以最常见的场景为例:Android设备通过USB转串口模块连接一块嵌入式开发板,板子每隔100ms发一帧固定协议的数据,Android负责接收解析并上报。

3.1 先把设备节点找出来,再谈配置

连上设备后,第一步不是写代码,而是确认设备节点路径和设备权限。使用adb shell进入设备,执行下面命令:

ls -l /dev/tty*

输出里一般能看到三类节点:

节点前缀典型来源说明
/dev/ttyS0板载UART原生串口,常见于某些主板引出排针
/dev/ttyUSB0USB转串口最常用,外挂USB转串口模块后生成
/dev/ttyACM0USB CDC ACM部分单片机用USB直接虚拟串口
/dev/ttyMT0移动平台内部UART某些手机/平板的调试串口,应用层一般不碰

找到疑似节点后,还要确认读写权限。看文件owner和group,如果权限是crw-rw----,root和所属组能访问,普通应用不行。

ls -l /dev/ttyUSB0 # crw-rw---- root dialout /dev/ttyUSB0

开发调试阶段,先用adb shell临时放开权限验证可行性:

adb root adb shell chmod 666 /dev/ttyUSB0

这只是验证手段,设备重启后就会失效。量产或正式集成还是要靠USB动态权限+SELinux规则,或者直接把应用跑在有权限的进程里。别让一个chmod 777的临时脚本成为你项目的最终方案。

3.2 打开与配置串口的完整代码片段

拿到设备节点并确认权限后,下面这段C代码能完成串口打开和参数配置。这是NDK/JNI层最核心的部分,参数严格按照8N1、115200来设置。

#include <termios.h> #include <fcntl.h> #include <unistd.h> #include <string.h> int open_serial(const char *path, int baud) { int fd = open(path, O_RDWR | O_NOCTTY | O_NDELAY); if (fd < 0) { return -1; } struct termios options; memset(&options, 0, sizeof(options)); tcgetattr(fd, &options); // 控制模式标志 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_oflag &= ~OPOST; // 输入模式:关闭校验、软件流控 options.c_iflag &= ~(IXON | IXOFF | IXANY); options.c_iflag &= ~(INPCK | ISTRIP); // 设置波特率 cfsetispeed(&options, B115200); cfsetospeed(&options, B115200); tcsetattr(fd, TCSANOW, &options); return fd; }

需要注意,O_NOCTTY是为了防止串口成为控制终端,O_NDELAY可以避免open时因为DCD信号抖动而阻塞。实际项目中,波特率如果要从外部配置动态传入,就得在cfsetispeed前根据值映射到对应的B常量。某些芯片对波特率精度有要求,比如921600这类非标准值,直接用常量会失败,那就需要自己计算divisor并通过ioctl设置,这个属于专项适配,普通场景用115200就够。

打开成功后,fd就是读写句柄。写数据直接用write(fd, buf, len),读数据用read(fd, buf, len)。但要注意,串口读数据不是来了就能一次性收完整帧,因为底层缓冲区、调度延迟、分包都可能让一帧数据被拆成好几次读取。后面会讲如何处理。

3.3 数据读取与回调封装:别在主线程里傻等

一旦在Java主线程里执行read,ANR马上教你做人。串口数据是异步到达的,标准做法是单独开一个读线程,循环读数据,通过回调把结果抛回主线程。

public class SerialManager { private FileDescriptor mFd; private ExecutorService mReadExecutor; private volatile boolean mRunning; private OnDataReceivedListener mListener; public void startReading() { mRunning = true; mReadExecutor.execute(() -> { byte[] buffer = new byte[512]; while (mRunning) { int len = nativeRead(mFd, buffer); if (len > 0) { byte[] data = new byte[len]; System.arraycopy(buffer, 0, data, 0, len); if (mListener != null) { runOnUiThread(() -> mListener.onDataReceived(data)); } } } }); } public void stopReading() { mRunning = false; if (mReadExecutor != null) { mReadExecutor.shutdownNow(); } } private native int nativeRead(FileDescriptor fd, byte[] buffer); }

mRunning这个标志要配合read超时一起使用。如果底层read一直是阻塞模式,调用stopReading时线程会卡在读数据上,没法立刻退出。所以配置termios时建议设置VMIN和VTIME,让读操作在指定时间内返回:

options.c_cc[VTIME] = 100; // 最多等待10秒 options.c_cc[VMIN] = 0; // 不要求读到完整长度才返回

写数据则要串行化。某些项目在快速轮询下位机时会开多个发送任务,结果多个线程同时write,帧就互相撕碎了。建议在Java层用一个队列+单一发送线程来保证顺序,或者给写操作加锁。同时发送后要留一点呼吸时间,尤其是半双工设备,发完指令马上收,设备还没反应过来就超时了,这种问题不是代码逻辑错,而是时序太激进。

4. 常见问题与排查技巧:半小时定位问题

串口调试最烦的不是业务逻辑,而是那些看起来毫无头绪的系统级问题。下面这些问题,基本覆盖了实际联调中九成以上的场景。

4.1 打开设备提示Permission denied,别急着上root

Permission denied是串联口最常见的报错。如果你确认设备节点路径是对的,第一反应应该是看权限,而不是怀疑是库的问题。先执行:

adb shell ls -l /dev/ttyUSB0 adb shell id

如果应用进程的uid不在设备的owner/group列表里,那肯定没有权限。开发阶段可以直接adb root再chmod 666,但如果目标设备不允许root,就得走正路:要么把应用做成系统应用并配置SELinux策略,要么用USB动态授权方式绕开设备节点权限。切记,SELinux策略文件的路径和语法因Android版本而异,别拿旧版策略硬套新版系统,否则开机直接起不来。

我见过最迷惑的情况是:应用是系统应用,节点权限也给了,open还是失败。最后查日志发现是SELinux拦截了,avc: denied { read write }。解决办法是给应用进程的domain添加对应设备的allow规则。这个操作对普通开发者比较陌生,但也说明了一个道理:Android串口权限不只是chmod一件事,而是Linux DAC权限+SELinux两层过滤。

4.2 数据全是乱码:先查协议参数,再看接线

乱码大概率是参数配置错了。你可以先做回环测试:把串口模块的TX和RX直接短接,然后用Android发送一串已知的十六进制数据,看能不能原样收回来。能收回来说明链路和参数都是通的,问题下位机;收不回来,就检查参数和接线。

接线方面,最容易犯的错是TX和RX反接。串口必须A的TX接B的RX,A的RX接B的TX。如果两个设备都把TX接TX,数据只能发不能收,表现就是“我发的它收不到,它发的我也收不到”。另外一定记得共地,GND不接会出现偶发乱码或完全没反应。

还有一类乱码是电平不匹配。板子串口是3.3V TTL,某USB转串口模块只能接受5V,虽然有时候也能通,但长期跑会出现随机丢字节。这种情况要么换支持3.3V的模块,要么加电平转换芯片,别指望软件能修好电气问题。

4.3 串口被占用、拔插失效、无法连续打开

如果串口只能成功打开一次,第二次就报Device or resource busy,一般是上一次的文件描述符没关闭,或者读线程还没退出。JNI层在close时,一定要先通知读线程退出,等线程真正结束,再调用close(fd)。顺序反了,close完fd线程还在read,线程会一直占用设备的引用计数,导致后续open失败。

USB转串口拔插失效也很常见。设备拔掉再插上,/dev/ttyUSB0可能变成/dev/ttyUSB1,因为内核没有立即释放旧的设备节点。处理方法是不要硬编码节点路径,而是在ACTION_USB_DEVICE_ATTACHED之后动态扫描最新的ttyUSB节点;同时要监听ACTION_USB_DEVICE_DETACHED,在拔掉设备时主动释放串口资源和关闭读线程。

4.4 常见问题速查表

现象可能原因解决方向
打开失败:Permission denied节点权限不足 / SELinux拦截chmod临时验证;配置SELinux规则
打开失败:No such file节点路径错误 / 驱动未加载ls /dev/tty* 确认,等待驱动加载
open成功但read阻塞VMIN/VTIME未配置设置VTIME=100,VMIN=0
数据乱码波特率/校验位/停止位配置不符回环测试,确认8N1
数据半包、粘包帧被拆解或合并按协议头/尾/长度重新组帧
偶尔丢字节电平不匹配 / 波特率过高检查电压,降低波特率
第二次打开失败fd未关闭,读线程未退出先停线程再close fd
拔插后找不到设备tty节点编号变化动态扫描节点,监听拔插广播

5. 几个从实际项目里沉淀下来的经验

技术细节讲了这么多,最后聊点更偏“实战手感”的东西。这些经验不算高深,但每次都能帮我在现场少踩几个坑。

5.1 硬件端最容易忽视的细节

接线之前一定要看模块原理图或引脚定义,不要凭颜色判断。很多串口模块的杜邦线颜色并不标准,有人把红黑当成电源,结果插上就把传感器烧了。上电之前先量一遍电压,确认供电和信号线不会短路,再连Android端。

下位机的上电时序也很重要。Android应用启动时如果立刻去open串口,而此时下位机还没完成初始化,可能收到它启动过程中的一串乱码。建议在协议层做握手,比如先发一个查询命令,收到合法应答后才开始正式业务。

回环测试是最值得养成的习惯。不管代码写得多么自信,先把串口模块的TX和RX短接,在Android端发什么收什么,证明底层通路没问题,然后再去接下位机。这一步能帮你省掉大量“代码没问题怎么数据不对”的自我怀疑时间。

5.2 软件侧提升串口稳定性的做法

协议解析不要在主线程做,读线程只负责搬运原始字节,解析放到独立的工作线程或者线程池。这样即使某帧数据异常导致解析卡住,也不会影响UI响应。我见过协议解析里有个死循环导致ANR的案例,排查半天才定位,就是因为解析逻辑跑在了UI线程。

日志记录要加十六进制输出。字符串日志在串口调试里会误导人,因为不可见字符直接消失。调试阶段把收发的每一帧原始字节都以hex形式打印,数据对不对一目了然。上线前再把详细日志关掉,避免IO开销和日志刷屏。

断线重连是工业项目里的刚需。USB设备拔插、串口被外部工具临时占用,都会导致通道中断。建议用一个状态机管理连接:初始化、已打开、读异常、重连中、已恢复。重连时不要无限循环,每次间隔递增,比如1秒、2秒、4秒,最大不超过30秒,避免设备反复插拔时把CPU和功耗耗光。

最后再分享一个很多人不注意的小技巧:发送数据后,如果下位机没回应,别急着重发,先看一眼下位机的回应时间。不少设备从收到指令到准备好响应需要几十毫秒甚至上百毫秒,你立刻重发反而会让设备处理队列更乱。用一个可配置的响应超时时间,比如默认500ms,比在代码里硬编码快很多。这个参数被后面的维护同事看到,会少很多骂人的冲动。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进