做Android与板卡之间的串口通讯,实际做起来比想象中麻烦得多。我最初接手这个需求时,以为无非是打开设备节点、读写数据,结果真正跑通稳定链路,前后折腾了不少时间,涉及权限、协议、粘包、板卡复位、线程竞争一系列问题。这篇内容就把我走通这套链路的过程、代码和踩坑细节完整记录下来,给同样要做Android与单片机板、工控板、智能硬件板通讯的开发者做个参考。
1. 为什么选串口,而不是USB或以太网
1.1 这个项目里,Android为什么要直接对板卡读写
我当时做的项目,可以理解成一台Android主机作为人机交互和逻辑控制中心,外接了一块带传感器的下位机板卡。板卡负责采集数据、控制执行机构,Android负责展示数据、下发指令。两者之间不需要传图片、视频这类大流量数据,只需要来回交换状态、命令和配置信息,数据量很小,但对实时性和确定性有要求。
这种架构在工业触摸屏、智能网关、自助设备、机器人控制等场景非常常见。Android设备本身有完整的系统、网络栈和触摸屏,适合做业务逻辑和UI;而板卡多数是MCU或单片系统,串口是它天生就有的外设,协议简单,驱动容易。两边通过一根串口线就能接通,比USB和以太网都直接。
1.2 串口通讯的最小链路结构
一套可用的串口链路,最少包含三要素:
- 接口类型:TTL电平、RS232电平或RS485差分信号,Android主板和板卡之间必须匹配;
- 串口参数:波特率、数据位、停止位、校验位,两边必须完全一致;
- 数据格式:收发双方约定好的命令帧,而不是裸数据流。
这套结构里没有任何“协议栈”帮忙做分包、校验、重传,全部要应用层自己维护。这既是串口的优势——延迟低、实现简单、排错直观,也是它最容易被低估的地方——所有可靠性问题都要自己扛。
1.3 对比USB和网络方案的取舍
USB方案的问题在于,外设角色是固定的。Android设备大多做USB Host,板卡做Device,这意味着板卡要自己实现一套USB从机协议栈,复杂度明显上来。如果只传少量命令,USB的带宽远用不满,还引入了驱动匹配、枚举、拔插处理这些额外工作。
以太网方案则需要两边都有网口,且涉及IP地址配置、路由、连接管理。如果只是点对点通讯,没有必要为一个两层楼的应用场景引入三层网络协议。尤其在一些没有网络服务的环境下,串口反而最省心。
串口的取舍也很明确:它只适合小流量、短报文、实时性要求高的数据交换。如果哪一天项目里要传文件或图片,串口这条路就该果断放弃。
2. Android串口设备节点的权限问题,以及我的解决路径
2.1 权限问题的根源:设备节点访问控制
Android底层的Linux内核会把串口抽象成设备节点,常见路径有:
| 设备类型 | 常见路径 |
|---|---|
| 板载UART | /dev/ttyS0、/dev/ttyS1、/dev/ttyMT0 |
| USB转串口 | /dev/ttyUSB0、/dev/ttyACM0 |
| 蓝牙串口(SPP) | /dev/rfcomm0 |
这些节点默认的属主通常是root,权限可能只有rw-,普通应用进程根本没有打开权限。这一点和传统Linux桌面系统不一样,桌面系统会通过udev规则给特定用户开放串口权限,而Android设备出厂时一般不会做这一步,用户应用默认处于隔离沙箱中。
所以第一步不是写代码,而是确认应用能不能拿到这个节点的访问权。我在设备上执行ls -l /dev/ttyS0,看到的是root root和640权限,那一刻就知道问题来了。
2.2 打开串口的完整流程
即使权限不是问题,基本的打开流程也会卡住很多人。流程分四步:
- 以读写方式打开设备节点文件描述符;
- 获取当前终端参数,修改波特率、数据位等;
- 将修改后的参数写回驱动,让设备生效;
- 设置读写模式,根据需求选择阻塞或非阻塞。
这里面最容易出错的是第二步。串口的参数不是通过普通write写入设备的,而是通过ioctl调用设置termios结构体。Android系统阉割了一部分头文件,如果直接用Java调用标准接口,根本碰不到termios。所以必须通过JNI写一小段C代码来处理。
2.3 我最终用的权限解决路径
在我手头的Android主板上,最终是修改了系统的设备权限配置,让系统启动时自动给特定串口节点追加0666权限。具体做法是在init.rc或ueventd.rc里,为/dev/ttyS*添加chmod权限配置。这样应用就能直接open节点,不用root提权,也不用修改应用签名。
如果你手里是量产设备,建议优先走这条路径,因为配置在系统侧,应用侧代码完全干净。如果是开发调试阶段,临时用adb shell chmod 666 /dev/ttyS0也能快速验证链路通不通。还有个备用方案是应用内置su提权,但只适合调试,量产应用这么干既不安全也不优雅。
权限问题一定要在项目初期验证好,不然后续所有通讯代码都可能被一个Permission denied卡死。
3. 串口参数、帧协议和粘包拆包:通讯能走多远,全看这里
3.1 参数匹配:波特率、数据位、停止位和校验位
Android和板卡通讯之前,所有参数必须逐项对表。我用的是115200波特率、8位数据位、1位停止位、无校验,即8N1。这是串口世界默认的组合,两边都能轻松支持。
波特率决定了每秒传输的字节数。假设115200bps,每帧10bit,理论带宽是每秒11520字节,也就是约11KB/s。对于我这种每条命令不超过64字节的应用,余量非常大。同时要注意,波特率不是绝对精确的,如果板卡的晶振偏差较大,长时间通讯可能出现误码。所以在选波特率时,优先选双方误差小的档位,而不是盲目追求高速。
3.2 帧协议设计:帧头、长度、命令和校验
串口是字节流,没有消息边界。为了把我和板卡之间的数据组织成“一条一条的消息”,我自定义了一套简易帧协议,字段如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头1 | 1字节 | 固定0xAA |
| 帧头2 | 1字节 | 固定0x55 |
| 长度 | 1字节 | 从帧头1之后到校验之前的总字节数 |
| 命令字 | 1字节 | 区分不同功能 |
| 数据域 | 可变 | 业务数据 |
| 校验 | 2字节 | CRC16,覆盖前5个字段 |
帧头用双字节而不是单字节,是为了降低误判率。单字节0xAA在空数据流或噪声中出现的概率很高,双字节固定组合能明显提升同步的鲁棒性。长度字段必须存在,否则接收端不知道一帧在哪结束。
CRC16我选了Modbus标准的CRC16算法,网上实现很多,跑起来基本没问题。也可以用SUM8求校验和,速度更快、实现更短,但防误码能力弱一些。小数据量、短报文场景,SUM8其实够用,我为了稳妥还是上了CRC16。
3.3 粘包和拆包的处理策略
串口通讯里最常见的现象是“粘包”:板卡一次性把好几帧数据连在一起发出来,或者因为延时,接收端把两帧数据拼成一个大包。如果只按固定长度去读,必然错位。
拆包的正确做法不是“等数据够了再读”,而是维护一个累积缓冲区,边收边解析。我用了一个环形缓冲区和状态机来拆帧:
- 状态1:等待帧头AA;
- 状态2:等待帧头55;
- 状态3:读取长度字节,确认整帧大小;
- 状态4:按长度收集完整一帧,校验CRC,合法则提交给逻辑层,非法则丢掉。
这样不管对方发得多快、多乱,只要帧结构设计合理,都能稳定切割出完整消息。关键点在于,状态机任何时候遇到不匹配的字节,都要回到状态1重新同步,而不是继续按旧状态推进。
4. 核心代码:从打开串口到收发线程的完整写法
4.1 JNI层:打开串口与设置串口参数的C代码
Android Java层没有公开的串口API,必须用JNI封装。这里附上一段精简但可用的C代码框架,实现打开设备和设置波特率:
#include <termios.h> #include <fcntl.h> #include <jni.h> #include <errno.h> static speed_t get_baudrate(int baud) { switch (baud) { case 9600: return B9600; case 115200: return B115200; default: return B115200; } } JNIEXPORT jint JNICALL Java_com_example_serial_SerialPort_open(JNIEnv *env, jobject thiz, jstring path, jint baudrate) { const char *path_str = (*env)->GetStringUTFChars(env, path, NULL); int fd = open(path_str, O_RDWR | O_NOCTTY | O_NONBLOCK); (*env)->ReleaseStringUTFChars(env, path, path_str); if (fd < 0) { return -1; } struct termios opts; tcgetattr(fd, &opts); cfsetispeed(&opts, get_baudrate(baudrate)); cfsetospeed(&opts, get_baudrate(baudrate)); opts.c_cflag |= (CLOCAL | CREAD); opts.c_cflag &= ~CSIZE; opts.c_cflag |= CS8; opts.c_cflag &= ~PARENB; opts.c_cflag &= ~CSTOPB; opts.c_cflag &= ~CRTSCTS; opts.c_iflag &= ~(IGNBRK | BRKINT | PARMRK | INPCK | ISTRIP | INLCR | IGNCR | ICRNL | IXON); opts.c_oflag &= ~OPOST; opts.c_lflag &= ~(ECHO | ECHONL | ICANON | ISIG | IEXTEN); opts.c_cc[VTIME] = 0; opts.c_cc[VMIN] = 0; tcsetattr(fd, TCSANOW, &opts); return fd; } JNIEXPORT void JNICALL Java_com_example_serial_SerialPort_close(JNIEnv *env, jobject thiz, jint fd) { if (fd > 0) { close(fd); } }这段代码有几个关键点:O_NONBLOCK是必须的,因为Android上的串口打开时如果属性配置不对,阻塞模式可能直接挂起;CLOCAL和CREAD一定要设置,否则设备无法接收数据;关闭硬件流控CRTSCTS也很重要,很多板卡并没有把RTS/CTS接全,启用了反而会卡住收不到数据。
为什么VMIN和VTIME都设0?如果VMIN=1,read会阻塞直到至少收到1个字节,这在串口数据不断的情况下还好,一旦空闲,阻塞时间会直接影响应用退出或超时控制。设置成非阻塞模式,在Java层用轮询或Selector处理,逻辑更可控。
4.2 Java侧封装:串口管理与资源释放
JNI层只是“打开文件描述符”,Java层要负责串口生命周期管理。我写了一个SerialManager单例类,职责包括:
- 打开串口并配置参数;
- 提供writeBytes和注册readListener的接口;
- 关闭时释放文件描述符,通知所有监听者。
打开串口要防重入。如果同一个节点被打开两次,第二次会失败,而且可能影响第一次的通讯。所以我内部用AtomicBoolean记录打开状态,任何二次打开请求直接抛异常或返回失败。
资源释放是串口最容易泄漏的地方。App进程不会因为一个文件描述符没关就崩溃,但长时间运行会耗尽进程可用的文件描述符数量,最终导致后面所有串口都打开不了。我在SerialManager的close方法里,先停止读线程,再关闭fd,再把监听器置空,顺序不能反,否则读线程会在关闭的fd上抛异常,抛出的异常还可能传递到UI线程。
4.3 收发线程设计:避免ANR和丢数据
串口的读写绝对不能放在主线程。我设计了两条独立线程:
读线程是一个while循环,调用native层的read方法,把读取的字节写入一个环形缓冲区,然后由协议解析器从缓冲区拆帧。读到有效帧后,通过Handler把消息抛给业务层。这里用Handler而不是直接回调,是为了确保UI状态下能安全更新控件,同时解析逻辑和UI更新解耦。
写线程使用一个阻塞队列接收业务层发来的命令。这样做的好处是,多个业务模块可以同时调用sendCommand,但实际写串口的只有一个线程,不会因为两个线程同时write导致指令交织。写入时对fd做线程安全保护,也就是每次write都经过synchronized锁。这里的锁粒度要细,不要在锁内做sleep或等待应答,否则吞吐量和实时性都会受影响。
一个容易忽视的细节是读线程退出的方式。我在读线程里加了volatile标志位running,close时置false并打断阻塞读。因为read在非阻塞模式下会迅速返回,线程能很快退出。如果用了阻塞读,打不断就可能在close后仍然卡在系统调用里,进程不退出但fd已经被关闭,后续行为完全不可控。
5. 板卡侧配合逻辑:握手、超时和重发机制的注意事项
5.1 板卡端串口中断与环形缓冲区
串口通讯不是单方面的事。Android这头做得好,板卡那头发数据乱来,照样不稳定。板卡MCU端的标准做法是:串口接收中断里只做搬数据,把收到的字节塞进一个环形缓冲区,然后立即退出中断;真正解析帧协议放到主循环或低优先级任务里做。
有人在中断里直接做协议解析、字符串拼接、甚至调用延时,这是大忌。中断占用时间过长,会导致后续字节丢失;如果板卡还在中断里等待某种响应,系统就再也回不到正常流程。环形缓冲区的好处是生产者和消费者解耦,中断只管写指针,主循环只管读指针,只要读写速度匹配,不会有丢数据的问题。
5.2 握手流程:上电后建立可靠连接
Android和板卡上电时间通常不一致,直接开始收发可能两边状态不同步。我给这套链路加了握手流程:
- Android启动后发送握手命令帧,命令字为0x01,数据域带一个随机数种子;
- 板卡收到握手帧后,校验通过,回发握手应答帧;
- Android收到应答帧后,才认为链路可用,UI状态从“连接中”变“已连接”;
- 如果1秒内没收到应答,Android重复发送,最多重试3次。
握手成功后再进入正常业务交互,可以在很大程度上避免上电瞬间的错乱状态。握手阶段随机数的意义在于,防止上一次断电前的残留帧被误当成新握手应答。
5.3 应答超时和重发策略
板卡处理完一条指令后必须回一帧应答,Android根据应答决定下一步。这类一问一答模式的好处是链路状态一目了然,坏处是要做好超时处理。
我设定的策略是:Android每发出一个控制类指令,等待应答的最长时间为200ms,超过200ms没收到,重发同一指令,连续重发3次仍无应答,就认定通讯链路异常,上报业务层并建议重新握手。
这个200ms不是拍脑袋定的。我拿逻辑分析仪测过板卡从收到指令到回发应答的耗时,正常情况下在5到15ms之间。200ms已经给了它十倍以上的余量,既能容忍系统调度的抖动,又不至于让用户感觉到明显的卡顿。如果板卡处理任务更慢,这个阈值就应该按实测数据重新计算。
6. 我在这个项目中踩过的坑,以及完整的排查链路
6.1 坑一:Permission denied,最不起眼也最致命
应用第一次去open节点,直接返回Permission denied。当时第一反应是代码写错了,但代码确实没问题,于是我用adb shell手动执行open测试,发现shell用户能打开,而App用户打开不了,说明问题不在应用层,而在设备节点权限上。
排查链路要从最简单的命令开始:ls -l看属主权限,getprop查看SELinux状态,再检查当前进程的user id。如果SELinux处于Enforcing模式,即使chmod到了0666,App依然可能被SELinux策略拦截。此时要看dmesg或logcat中是否有avc: denied日志。最终解决方式是调整init配置加权限规则,同时确保SELinux策略放行。
这个坑提醒我,调试串口问题时,直接看logcat往往不够,还要配合dmesg查底层拒绝原因。
6.2 坑二:波特率不一致导致的乱码
有一次板卡端改了波特率,Android端没有同步改,结果收到的数据全是乱码。我当时还以为是协议解析写错了,排查了很久。后来用串口调试工具挂在同一根线上,发现调试工具收到的也是乱码,才确定是参数不对。
排查链路很直接:先接上串口调试工具,确认板卡到底以什么参数发数据;再用调试工具模拟Android发指令,看板卡能不能正确处理;两边都验证过了,最后才回来检查App。如果你的板卡通讯异常,建议第一步就是外接调试工具看原始数据流,这可以把协议问题、参数问题、代码问题分离开来。
6.3 坑三:粘包导致的协议解析错乱
板卡上电瞬间会连续上报好几帧状态数据,Android端如果按单帧读取,经常出现帧头错位、长度错误、校验失败的连锁反应。表现是偶尔有几条消息成功,其余全是垃圾。
这个问题靠增大缓冲区是解决不了的,必须靠状态机拆包。我在代码里引入帧同步状态机后,无论数据流连发多少帧,都能在第一个正确的帧头处完成同步。测试时我在板卡上写了一个循环发送1000帧的用例,Android全部正确解析,没有漏掉任何一帧。
6.4 坑四:读线程退出或者系统休眠导致的丢帧
长按待机后恢复,偶尔出现一段时间收不到数据,需要重启App才好。排查发现,Android系统休眠时会暂停用户态调度,读线程虽然还挂着,但无法及时处理缓冲数据。如果板卡持续发数据,内核缓冲区满后就会丢包。
对策有三个级别:一是提高读线程优先级,但治标不治本;二是在系统休眠前主动断开串口、恢复时重新打开,保证链路从干净的初始状态开始;三是和板卡约定周期性心跳,Android发现心跳中断就主动重启串口。
我最终采用了第三方案,因为心跳不仅能检测休眠丢帧,还能检测板卡死机,一举两得。
6.5 坑五:多线程同时写串口,指令互相穿插
业务侧同时有几个模块都在发指令,有的控制电机,有的读取温度,两条指令几乎同一时间发出,结果板卡收到的是两个消息拼接的乱帧。
排查链路也很典型:用串口调试工具抓线,发现线上数据确实是交叉的,说明代码里写串口没有做互斥保护。我给写入口加了一把全局锁,所有指令通过同一个发送队列进入写线程,这样从逻辑上保证任意时刻只有一个线程在write。再加锁后同样的问题再没出现过。
最后分享一个实操习惯
我摸索下来的一个很实用的习惯是:在写任何Android串口App之前,先用一套串口调试工具把板卡侧的空闲数据流和应答行为摸清楚。这一步能帮你过滤掉一半以上的问题,因为串口通讯出问题,大概率不是某一端的单一缺陷,而是两端参数、时序、协议彼此不匹配。打通了板卡,Android侧的工作就只是把同样的逻辑用代码再实现一遍。如果你现在正被串口通讯卡住,不妨回头看一眼最基础的参数和时序,很多时候答案就在那里。