免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

Android车载串口开发实战:UART/RS232/RS485与协议解析

Android车载串口开发实战:UART/RS232/RS485与协议解析 1. 车载项目里的串口为什么这活儿绕不开做车载相关开发的朋友应该都有同感车机上跟外设打交道串口是出现频率最高、也最让人头疼的通信方式之一。什么中控屏跟行车记录仪通信、车机跟后排娱乐屏联动、主机跟车身控制模块交换状态甚至外接的OBD诊断盒、雷达模块、传感器采集板十有八九都是靠串口在传数据。我接触过的不少项目里Android车机跟底层MCU之间就是通过一根UART线连着的MCU那边跑着RTOS或者裸机程序Android这边用Java或Kotlin打开串口读写数据两边按约定好的协议你来我往。这篇文章就系统整理一下Android车载串口开发里我实际踩过的坑和沉淀下来的方案覆盖UART、RS232、RS485三种常见电气接口的选型区别Android端串口配置的参数细节以及数据通信协议设计和线上问题排查方法。适合刚接手车载项目、对串口通信还比较陌生的Android开发也适合做嵌入式但是需要跟Android端联调的朋友参考。需要先说明一点Android系统本身没有公开的串口APIGoogle官方也没有一套标准的串口框架。所以实际开发中大家普遍用的是两种路子一种是用开源的android-serialport-api这类JNI封装库另一种是自己在AOSP源码里加系统服务把串口设备变成系统级的能力。大部分应用层开发场景用前者就够了这也是这篇文章讨论的重点。另外要强调一个观念串口开发看着简单不就是打开文件、读写数据嘛但真正到了车厂这种量产项目里问题往往出在电气层和协议层的配合上。你光把数据读上来没有用读上来之后还要能解释数据、排查干扰、保证不丢帧不乱码这才叫会做串口开发。2. 先分清UART、RS232、RS485通讯物理层决定你的板子怎么接2.1 三种接口的核心区别与适用场景很多人一上来就混淆UART、RS232、RS485这三个概念其实它们说的是不同层面的东西。UARTUniversal Asynchronous Receiver/Transmitter是一种异步串行通信协议规定了数据怎么按位收发而RS232和RS485是基于UART协议之上的电气标准规定了电平、线缆、传输距离和连接方式。打个比方UART就好比两个人约定好了说中文RS232和RS485则规定了这两个人是用喊的还是用对讲机、以及声音能传多远。具体到实际项目里的区别我整理了一张表基本覆盖了选型时需要关注的核心参数比较项UARTTTL电平RS232RS485电平标准0~3.3V/5V±12V左右逻辑1为负逻辑0为正差分信号A/B线间电压差通信方式全双工TX/RX独立全双工TX/RX独立半双工共用一对总线需方向切换传输距离一般1米以内板级通信理论15米左右理论1200米节点数量点对点点对点一主多从最多32个节点标准负载抗干扰能力弱一般强差分传输接线TX接RXRX接TX共地同上但电平不同需转换芯片A接AB接B需收发控制在车机项目里这三种我都遇到过。UARTTTL最常用于板级通信比如车机主板跟内置的4G模块、蓝牙模块、MCU之间走的就是UART距离短、不需要转接直接在主板上走线。RS232以前多用于外接诊断设备、工控屏现在新车已经很少直接给RS232口了但一些老车型的售后诊断口还是RS232电平需要转接或者用USB转串口线。RS485则在车载里的应用很广比如车身控制总线、灯光控制、充电桩通信、外围传感器组网等。因为RS485支持一主多从、距离远、抗干扰强车载这种电磁环境复杂的场景下它比RS232靠谱很多。2.2 RS485自动收发电路为什么不能直接接UARTRS485用的人多踩坑也多。最典型的一个坑就是把RS485的A/B线直接当成UART的TX/RX去接结果发现只能发不能收或者只能收不能发。原因是RS485是半双工通信同一时刻只能有一方在发送所以必须要有一个收发方向控制脚DE/RE。传统做法是用MCU的一个GPIO去控制方向发送时拉高DE接收时拉低RE。但在Android车机这种场景下Android系统不是一个实时系统你对GPIO的控制时序很难做到精确如果收发切换太慢或者切换错误就会出现数据冲突或者丢字节。所以车载Android项目里我强烈建议用带自动收发切换的RS485方案。市面上常见的自动收发电路比如用三极管加RC延时电路检测到发送起始位时自动打开发送通道发送完成后自动切回接收状态。这类电路的好处是Android端不用关心方向控制把它当成普通串口用就行省了很多麻烦。我用过的自动收发电路方案里比较典型的做法是在发送端加一个由TX信号驱动的晶体管开关来控制DE/RE引脚。具体电路原理不展开了但有一个参数需要注意自动切换电路的切换延时决定了你发送数据时相邻两字节之间不能间隔太久否则电路会误判为发送结束、切回接收状态导致数据被截断。实测中我遇到过波特率9600下没问题、但波特率115200下偶发丢字节的情况查到最后就是自动收发电路的RC时间常数设置不合适。所以如果你的项目用了自动收发电路一定要在最高波特率下做连续压力测试不能只测低速。2.3 车载项目串口选型的实际建议说了这么多给一个比较直接的选型建议如果通信距离在板级或设备内部优先用UARTTTL接线最简单成本最低。注意Android主控的UART电平通常是1.8V或3.3V跟外设的5V电平不匹配时需要加电平转换芯片别硬接。如果通信距离在几米到十几米且只有两个节点可以考虑RS232但新项目不推荐线材粗、电平高、速率上不去。如果是一主多从组网、距离超过十米、或者现场电磁干扰明显直接上RS485并且强烈建议用自动收发电路。另外一个容易忽略的点是车机主板上的物理串口在内核里会被注册为ttyS0、ttyS1、ttyMTK、ttymxc等不同的设备节点。不同平台、不同内核版本串口节点命名完全不一样。做方案设计时一定要向硬件同事或者BSP板级支持包负责人确认我要用的这个物理串口对应的设备节点是什么有没有被别的服务占用能不能用。3. Android侧串口开发从JNI到应用层的完整链路3.1 权限处理没有权限一切白搭Android串口开发的第一关不是代码是权限。打开串口设备节点比如/dev/ttyS3本质上就是Linux下的open()系统调用如果你的进程没有这个设备节点的读写权限open直接返回Permission denied。在实际项目里权限问题常见的几种情况和处理办法如下应用进程有root权限的情况。这种情况下比较简单直接用su切到root修改设备节点权限或者以root身份运行应用。但量产车机上通常不会给应用root权限这种做法只适合开发调试阶段。通过udev规则或者init.rc设置节点权限。Android系统启动时init进程会根据init.rc里的chmod/chown命令设置设备节点权限。你可以在init.rc里给特定串口节点执行chmod 666这样所有应用都能读写。或者更规范的做法是用SELinux策略给特定应用或特定域添加对该设备的访问权限。这个需要动系统镜像适合有系统定制能力的团队。最简单粗暴的通用做法在应用里通过Runtime.exec()执行su并chmod。代码大概长这样private void grantSerialPortPermission(String path) { try { Process process Runtime.getRuntime().exec(su); DataOutputStream os new DataOutputStream(process.getOutputStream()); os.writeBytes(chmod 666 path \n); os.writeBytes(exit\n); os.flush(); process.waitFor(); } catch (Exception e) { // 没有root或权限授予失败 Log.e(TAG, grant permission failed, e); } }注意这个方案只适合开了root或者userdebug版本的系统。纯商用release版本一般不允许所以项目立项时一定要确认系统固件是否开放了su权限。如果量产版本不给root就得提前协调BSP团队在系统里配置好权限别等开发到一半再回头搞这个。SELinux也是一个高频坑点。就算你chmod 666了SELinux enforcing模式下应用域还是没有权限访问串口节点。常见表现是fd能open成功但read/write时报权限错或者直接被SELinux拦截。这时候只能让系统团队加te规则比如allow untrusted_app tty_device:chr_file rw_file_perms;。我在项目里被这个问题折腾过两天建议大家联调之前先确认一下当前系统是enforcing还是permissive模式。3.2 串口参数配置波特率、数据位、停止位、校验位串口配置的核心是对termios结构体的设置。Android的JNI层最终调用的就是Linux的tcgetattr/tcsetattr系列函数。参数主要有四个波特率、数据位、停止位、校验位。这四个参数必须跟对端设备完全一致任何一项不匹配都收不到正确数据。各参数的含义和作用波特率每秒传输的比特数。常见的有9600、19200、38400、115200、460800等。波特率误差超过一定范围一般是±2%~3%通信就会出错。车载MCU侧常用的是9600和115200具体看MCU的主频和协议需求。数据位一个字节里有效数据的位数常见的有5、6、7、8。现在基本都用8位。停止位标志一帧数据传输结束的位常见1位或2位。一般用1位。校验位用于简单错误检测可选无校验None、奇校验Odd、偶校验Even。如果协议里没有特别说明一般选无校验。在Android串口库的实现里波特率是通过termios的cfsetispeed/cfsetospeed函数设置的而且Android原生Linux内核里波特率是一个整数常量Linux内核支持非标准的自定义波特率比如1000000、250000等但需要驱动支持。这个在车载项目里偶尔会遇到比如有的雷达模块用250K波特率标准termios里没有就得用BOTHER等特殊宏。如果你的串口库不支持自定义波特率就要自己改JNI层代码了。下面是android-serialport-api库的核心JNI代码里配置串口参数的部分C语言我加了注释方便理解static speed_t getBaudrate(jint baudrate) { switch (baudrate) { case 9600: return B9600; case 19200: return B19200; case 38400: return B38400; case 57600: return B57600; case 115200: return B115200; case 460800: return B460800; case 921600: return B921600; default: return B115200; } } static int configure_port(int fd, jint baudrate, jint dataBits, jint stopBits, jint parity) { struct termios cfg; if (tcgetattr(fd, cfg) -1) { return -1; } // 设置原始模式禁用流控和回显 cfmakeraw(cfg); cfsetispeed(cfg, getBaudrate(baudrate)); cfsetospeed(cfg, getBaudrate(baudrate)); // 数据位CSIZE掩码清掉原来的设置 cfg.c_cflag ~CSIZE; if (dataBits 7) { cfg.c_cflag | CS7; } else { cfg.c_cflag | CS8; } // 停止位CSTOPB置位表示2位停止位 if (stopBits 2) { cfg.c_cflag | CSTOPB; } else { cfg.c_cflag ~CSTOPB; } // 校验位PARENB使能校验PARODD选择奇偶 if (parity 1) { // 奇校验 cfg.c_cflag | PARENB | PARODD; } else if (parity 2) { // 偶校验 cfg.c_cflag | PARENB; cfg.c_cflag ~PARODD; } else { // 无校验 cfg.c_cflag ~PARENB; } tcsetattr(fd, TCSANOW, cfg); return 0; }这里的cfmakeraw()函数很关键它会让串口进入“原始模式”不做任何行处理比如把回车换行转换、把CtrlC当信号等。做串口数据通信必须用原始模式否则系统可能对接收到的字节做奇怪的处理跟对端协议完全对不上。很多新手遇到“数据能收到但内容不对”的问题往往是没设置原始模式。3.3 打开串口与读写线程的工程化设计串口打开流程其实不复杂但工程上要处理好线程模型、异常重连、资源释放等问题。打开串口的完整步骤如下检查并授予设备节点权限方式见3.1节。用open()打开设备节点open的flag建议用O_RDWR | O_NOCTTY | O_NONBLOCK。O_NOCTTY是为了防止串口成为控制终端否则你按键盘可能给串口发信号O_NONBLOCK是因为部分驱动在阻塞模式下open会卡住。配置termios参数波特率、数据位、停止位、校验位。清除输入输出缓冲tcflush(fd, TCOFLUSH)和tcflush(fd, TCIFLUSH)。启动读线程和写方法。一个值得注意的细节是open之后先清除缓冲区再开始收发。因为设备刚上电或者之前的连接断开时缓冲区里可能残留着旧数据不清除的话应用一启动就会收到一堆“脏数据”容易干扰协议解析。读写线程方面我的建议是读用独立线程循环阻塞读取写用带锁的队列由业务线程直接写或者也用一个独立的写线程。读线程的循环大致长这样private void startReadLoop() { mReadThread new Thread(() - { byte[] buffer new byte[256]; while (!mIsStop mFd ! null mFd 0) { try { int size mSerialPort.read(buffer); // 底层是JNI read调用 if (size 0) { byte[] data Arrays.copyOfRange(buffer, 0, size); mDataReceiver.onDataReceived(data); } } catch (IOException e) { // 处理异常可能串口被拔出或关闭 onSerialError(e); break; } } }, SerialReadThread); mReadThread.start(); }这里有一个特别重要的工程细节read操作不要在UI线程执行也不要用InputStream的read()直接读因为可读长度不确定。有些第三方串口库把读操作封装成了InputStream用起来方便但要注意InputStream.read()在无数据时会阻塞你必须在独立线程里读。而且串口驱动返回的读取长度跟你的buffer大小没有固定关系可能一次读回来半帧、两帧甚至更多所以应用层必须自己处理“粘包”和“拆包”。4. 数据通信协议能跑通和能稳定跑是两码事4.1 协议帧设计帧头、长度、命令、校验串口物理层打通了接下来最关键的就是通信协议。很多项目在联调阶段出问题不是串口配置错了而是协议设计有缺陷导致数据解析老出错。一个完整的串口数据帧通常包含以下几个部分帧头固定字节比如0xAA 0x55接收方靠它判定一帧数据的开始。长度字段表示数据域的长度方便接收方知道一帧要多长。命令字表示这条消息要做什么比如读传感器、写屏幕亮度。数据域具体的业务数据。校验字段CRC16、CRC32或者简单的累加和校验。帧设计里面有个原则长度字段一定不能省。虽然是串口按字节流收发但如果帧长是变长的没有长度字段接收方就不知道怎么切分数据。固定帧长的协议可以省掉长度字段但扩展性差车载项目里的协议往往都带长度字段。下面是我用过的一个比较典型的帧格式字段字节数说明帧头2固定为0xAA 0x55版本号1协议版本方便扩展命令字1如0x01表示查询0x81表示应答长度2数据域长度小端模式数据域N业务数据校验2CRC16覆盖从帧头到数据域所有字节4.2 CRC校验的选型与实现校验方式的选择直接影响通信的可靠性。简单的累加和把所有字节相加取低8位实现简单但检错能力弱。对于车载这种电磁环境复杂、数据安全要求较高的场景我建议用CRC16检错能力远比累加和好。常见的有CRC16/MODBUS、CRC16/CCITT等具体用哪种要看对端MCU那边支持哪种两边统一即可。CRC16的计算可以自己写查表法也可以直接用现成库。查表法性能好适合数据量大或者低功耗的场景逐位计算简单适合数据量小的场景。下面是CRC16/MODBUS的查表法实现供参考public class Crc16 { private static final int[] TABLE new int[256]; static { for (int i 0; i 256; i) { int crc i; for (int j 0; j 8; j) { if ((crc 0x0001) ! 0) { crc (crc 1) ^ 0xA001; } else { crc crc 1; } } TABLE[i] crc; } } public static int calc(byte[] data, int offset, int length) { int crc 0xFFFF; for (int i offset; i offset length; i) { int index (crc ^ data[i]) 0xFF; crc (crc 8) ^ TABLE[index]; } return crc; } }使用的时候发送方算出CRC后填入帧尾接收方对收到的整帧包含CRC字段重新计算CRC如果结果为0说明传输无误。这是UART串口通信里最常用的校验方式比累加和可靠得多。4.3 粘包、半包处理状态机拆包串口通信里最让人头疼的问题之一就是“粘包”和“半包”。因为串口是流式传输没有消息边界应用层read()一次读回来的数据可能只是完整帧的一部分半包也可能是几帧拼在一起粘包。处理这个问题有两个层面一是把一次read读回来的原始字节放进缓冲区然后按帧格式逐字节解析二是维护一个协议解析状态机从缓冲区里取出完整的一帧再交给业务逻辑。我的做法一般是维护一个字节缓冲队列把每次read到的数据追加进去然后在一个循环里尝试解析帧头。如果缓冲区里剩余字节数不足一帧根据长度字段判断就等待下一次read继续追加如果够一帧就切出来一帧交给协议处理剩下继续解析。伪代码如下private final ByteArrayOutputStream mBuffer new ByteArrayOutputStream(); private void onRawDataReceived(byte[] data) { mBuffer.write(data, 0, data.length); byte[] buffer mBuffer.toByteArray(); int offset 0; while (true) { // 找帧头 0xAA 0x55 int headerIndex findHeader(buffer, offset); if (headerIndex 0) { buffer new byte[0]; break; } // 判断剩余长度是否足够读长度字段 if (buffer.length - headerIndex 5) { // 帧头2 版本1 命令1 长度2 6? 这里按实际协议 break; } int length ((buffer[headerIndex 4] 0xFF)) | ((buffer[headerIndex 5] 0xFF) 8); int totalLen HEADER_LEN length CRC_LEN; if (buffer.length - headerIndex totalLen) { break; // 不完整等下一次数据 } byte[] frame Arrays.copyOfRange(buffer, headerIndex, headerIndex totalLen); handleFrame(frame); offset headerIndex totalLen; } // 把剩余数据放回缓冲区 byte[] remaining Arrays.copyOfRange(buffer, offset, buffer.length); mBuffer.reset(); mBuffer.write(remaining, 0, remaining.length); }这个状态机看起来简单但写起来容易出细节问题常见的有找帧头时不能找到第一个0xAA就算完因为0xAA可能是数据域里的字节。所以要先找0xAA 0x55连续两个字节如果只找到0xAA但下一个字节不是0x55要跳过而不是卡住。如果对端发送的帧头和数据里都含有0xAA 0x55接收方会误判这个要靠帧内长度字段和CRC双重校验来避免。实在不行可以把帧头设计成更长的特征码比如3~4字节。解析长度字段时注意大端小端。不同MCU协议的字节序不一样Android侧统一按协议规定解析不要想当然。前面说的都是半包和粘包还有一个常见问题是“断帧”。就是通信过程中一帧数据发送到一半线路异常导致后面部分丢了接收方的缓冲区里就会残留一帧不完整的“尾巴”。如果不处理下一次收到新帧时状态机可能从“尾巴”的中间开始找帧头导致解析错乱。解决办法是连续多次比如3次解析失败或CRC校验失败后主动清空缓冲区、重新同步帧头。这个逻辑一定要加不然线上跑久了数据会越积越乱。5. 常见问题与排查技巧实录5.1 串口打不开先查权限再查设备节点排查串口开发问题第一步永远是确认设备节点存在、权限正确。命令行下可以用ls -l /dev/ttyS*检查节点是否存在用su后chmod 666调整权限。如果你是用Android Studio的adb shell进去看的那要特别留意有些项目的串口设备节点在Android的/dev下但selinux权限会拦截应用访问。判断方法很简单执行命令看有没有avc denied的logadb logcat -b events | grep avc或者直接看dmesg里的avc报错。如果看到avc denied说明SELinux拦截了要么改策略要么临时切permissive模式验证开发阶段。如果设备节点不存在可能性就很多了串口驱动没加载、设备树里这个串口被禁用了、或者物理串口被别的进程占用了。这种情况必须找BSP/驱动同事协助。在车机项目里硬件改版后设备节点变了这种事也经常发生不要默认代码里写死的/dev/ttyS3永远存在。5.2 串口收到乱码八成是波特率不对收到乱码、或者数据完全对不上90%以上的概率是波特率不匹配。之前就有人拿着USB转串口工具去调试一个工业屏代码里配了115200结果对端屏幕实际是9600还以为是线有问题换了三根线都白搭。排查波特率问题有一个技巧在固定波特率下发送一串0x55或0xAA这样的交替位字节01010101用示波器量TX引脚的波形计算位周期1/波特率。如果位周期跟配置的波特率不符就能马上定位是哪边的配置不对。没有示波器的话可以用串口调试助手先把波特率挨个试一遍9600、19200、38400、57600、115200通常能快速定位。还有一个容易被忽略的波特率虽然名字一样但两端的晶振误差叠加可能导致比特位偏移。特别是对端是廉价的MCU、晶振精度不高时长时间传输大量数据会出现偶发的字节错误。这种情况下只能尝试降低波特率或者让对端换高精度晶振。5.3 持续收到错误帧CRC和帧解析的坑还有一种情况是数据能收到、波特率也对但帧解析总是失败或者CRC校验经常不过。排查思路如下用串口调试工具先裸抓数据确认对端发的原始字节到底长什么样。这一步能排除应用层解析逻辑的问题。把抓到的原始字节手动过一遍CRC算法确认你写的CRC计算方式跟对端一致多项式、初值、结果异或值都可能不同。确认字节序。长度字段和数据域是否是大端/小端两边约定要一致。确认是否存在RS485方向切换导致的第一字节丢失。前面提过自动收发电路如果RC时间常数不合适发送端的第一字节可能被“咬掉”半个字节。表现就是对端能收到数据但第一个字节是错的、或者整帧长度不对。5.4 adb调试与USB转串口工具的选择开发阶段调试串口几乎离不开USB转串口工具。市面上的USB转串口芯片主要有CH340、CP2102、FT232、FT231X等。Android开发调试时需要注意很多USB转串口工具在Windows下插上即用但Linux/Android下需要确认内核有对应驱动。CH340在大多数内核里都有驱动但在某些Android设备上可能需要手动加载。FT232/FT231X稳定性好但价格偏高适合对可靠性要求高的调试场景。另外有一个很多新手会犯的错用USB转串口工具调试车机串口时USB转串口工具输出的是TTL电平而车机串口节点直接引出的是RS232电平或者RS485差分信号直接把USB转TTL的线接到RS232/RS485接口上是收不到数据的甚至可能烧坏芯片。正确做法是先确认车机串口引出的是什么电气标准再决定用USB转TTL还是USB转RS232还是USB转RS485或者自己在中间加一个电平转换板。这个不搞清楚你换再多调试软件也没用。5.5 高频实操注意事项速查表根据我吃过的亏整理一张速查表每一条都是真金白银踩出来的场景常见问题处理建议打开串口Permission denied检查uid/gid权限、SELinux策略必要时chmod 666配置参数数据全乱码逐项核对波特率、数据位、停止位、校验位用示波器量波形发送数据发不出去或只发一半检查TX/RX是否接反、RS485方向切换是否正常接收数据偶发丢帧/断帧检查缓冲区大小是否够用、协议解析的异常重置逻辑接收数据帧解析错乱帧头设计加长、状态机增加超时重置、CRC校验必须做联调阶段两边协议说不通用串口助手裸抓数据先确认原始字节流再谈协议问题量产阶段个别车出问题优先怀疑硬件连接松动、线束过长、电气干扰让硬件同事配合排查6. 个人实操中的几点总结写了这么多最后说几点我在实际项目里沉淀下来的体会不算总结算是给自己备忘也希望能帮大家少走弯路。第一串口开发在车载项目里看似不起眼但它的调试周期往往被严重低估。原因在于它横跨Android应用层、Linux内核层、硬件电气层三层任何一层出了问题表象都是“数据不对”或者“收不到数据”。所以做这活儿之前一定要把调试工具备齐至少一根靠谱的USB转串口线、一个串口调试助手PC端和Android端都要有、最好再有个示波器。示波器在排查电气问题时作用无可替代不要省这个投入。第二Android端的串口库建议优先用经过验证的开源方案但也别完全依赖现成库要会改JNI。我碰到过好几次需要自定义波特率、需要修改读写超时时间、需要增加DTR/RTS控制的需求这些都是现成库不一定支持的。会改JNI层代码串口开发的上限会高很多。第三协议设计里别省校验收发字节别为了省几个字节的传输量把可靠性牺牲掉。车载环境下一根线束老化、一个电磁干扰脉冲就可能让一帧数据出错。没有校验的协议线上排障会让你怀疑人生。第四务必做好日志记录。应用层收发到的原始字节、解析出的每一帧内容、CRC校验失败记录都要有清晰的log。量产的车上你不可能随时拿调试器去抓串口波形强大的log系统往往是排查问题唯一的手段。我习惯在串口通信模块里加一个“原始数据镜像”功能把收发到的所有字节同步写一份到log文件出问题时直接拉log分析效率比在现场盲猜高太多了。关于Android车载串口开发就先写到这里。最后再分享一个小技巧联调时不管对端是什么设备先从最简单的“回环测试”开始——把收发短接发什么收什么确认链路通了你再上业务协议。这一步能帮你快速区分“链路问题”还是“协议问题”调试效率至少提升一倍。
返回列表