
做车载Android开发这几年串口这块算是我踩坑最多的一个方向。很多App层开发的同学一听到串口第一反应是“这玩意儿不是单片机才用的吗”真到了车机上对接行车记录仪、T-BOX、中控面板按键、外接传感器的时候才发现串口通信是Android系统和外部硬件打交道最直接、也最绕不开的一条路。这篇文章就把我实际做过的Android车载串口项目整理成笔记从UART、RS232、RS485的区别到串口配置参数再到Android上层怎么读写串口、怎么处理RS485的方向切换和数据收发一次性讲清楚希望能帮到正在这上面翻文档翻到头晕的同学。1. 车载串口开发前先把UART、RS232、RS485弄清楚1.1 三种“串口”到底是什么先说一个最基础也最容易被绕晕的概念UART、RS232、RS485这三个词经常混在一起说但它们在严格意义上并不是同一个层级的东西。UART是一种芯片内部的通信协议全称是Universal Asynchronous Receiver/Transmitter通用异步收发器。它定义的是数据怎么按位发送、怎么起始、怎么停止这是芯片和芯片之间通信的“逻辑规则”。我们在Android车机上看到的/dev/ttyS0、/dev/ttyMT1这种节点底下就是一颗UART控制器在干活。RS232和RS485则是在UART的基础上定义了“电气信号”怎么传输。也就是说同样是UART发出的那串0和1通过不同的电平转换芯片、不同的接线方式就变成了RS232信号或者RS485信号。可以这样理解UART是普通话的内容RS232和RS485是两种不同的“发音方式”发的内容一样但声波、音调不一样。拿一张对比表来看会更清楚项目UARTTTLRS232RS485电平标准0~3.3V或0~5V高电平为1±3V~±15V负逻辑差分信号A/B线电压差表示0和1传输方式全双工全双工半双工需要方向切换传输距离1米以内板级通信15米左右可达1200米组网能力点对点点对点一主多从最多几十个节点抗干扰差一般强适合工业车载环境接口形态板内排针/测试点DB9公头/母头接线端子A/B两根线实际在车载Android设备上CPU出来的一般都是UART TTL电平板子上焊不焊电平转换芯片决定了外部接口是RS232还是RS485。这个在对接外部设备前必须跟硬件工程师确认清楚否则直接接上去轻则通信乱码重则烧芯片。1.2 车载场景下如何选型与识别车机上常见的串口设备大致有这几类行车记录仪或AVM环视系统一般用UART TTL或者RS232波特率115200或460800协议多是自定义的GPS/G-Sensor数据上报。T-BOX车联网终端经常用RS485走CAN转串口模块或者Modbus RTU协议用来远程控制车门、空调、读取车辆状态。中控面板物理按键/旋钮多数是简单的UART波特率9600或19200一次发三五个字节。外接传感器比如胎压、温度、油量检测RS485比较常见。怎么识别车机上某个接口是哪种串口最靠谱的办法是看硬件原理图。如果没有原理图可以看接口物理形态RS232通常是DB9或者大的耳机座RS485一般是两线端子或者四线航空插头UART则是板内排针。还有一个土办法用万用表量静态电压RS232空闲时是负电压-3V~-15VUART TTL空闲时是高电平3.3V或5VRS485的A/B两线之间静态电压差几乎为0。这个在实地排查时非常管用。1.3 一个容易忽略的坑TTL电平的兼容性这里提醒一句UART TTL电平并不是统一标准。老的设备很多是5V TTL而现在的车规级主控芯片比如高通、联发科、瑞芯微、全志的方案串口IO电压通常是1.8V或者3.3V。如果把5V TTL的输出直接怼到3.3V的串口Rx引脚上长期工作会有烧毁主控的风险。所以对接外部设备前一定要确认两边电平是否匹配。不匹配时中间要加电平转换电路常用的有TXS0108EPWR电平转换芯片或者用MAX3232转成RS232再转换回来不过后者绕了一圈多此一举直接上电平转换芯片即可。2. 动手前的硬件确认与接线排查2.1 拿到板子第一件事确认串口节点和电平很多同学拿到开发板的第一件事是去翻Android代码这其实不太对。硬件没确认清楚软件写再多也是白搭。我自己的习惯是先做三件事第一看内核设备树。Android车载方案的串口一般在内核设备树里已经定义好了。找类似serial11002000这样的节点确认它是否被启用。如果节点status是disabled那这个串口在系统里压根不会出现。第二确认串口是否被占用。特别注意调试串口。很多开发板的调试串口就是UART0或UART2被内核的console参数占用了。你在用户层打开的/dev/ttyS0收到的不是正常的设备数据而是内核打印日志。如果遇到读出来的数据夹杂着启动日志十有八九就是这个原因需要改内核cmdline把console对应的那个串口让出来。第三确认用户层可见的设备节点权限。车机上串口节点通常是/dev/ttyS0到/dev/ttyS3这样的命名但也有的平台是/dev/ttyMTx联发科或者/dev/ttyHSLx高通。用以下命令可以快速查看adb shell ls -l /dev/tty* adb shell cat /proc/tty/drivers第二行命令能列出系统里所有注册的串口驱动及其主次设备号对照设备树就能定位哪个节点对应哪个物理串口。2.2 RS485的一主多从组网RS485在车载里的价值是一根总线挂多个设备。一主多从的组网方式是主机发指令各从机根据地址判断是不是发给自己的是就回复不是就保持沉默。通信协议上手写简单帧格式或者跑Modbus RTU都可以。Modbus RTU在工业设备里非常常见并且网上有成熟的CRC16校验算法建议优先考虑。接线方面有几个硬性要求总线的A跟A接B跟B接禁止交叉。在总线最远端的两个节点上要并联120欧终端电阻用来消除信号反射。如果只接一两个设备、距离又不远几米内终端电阻可以先不加但距离超过几十米或者数据偶发乱码时先查终端电阻。RS485是半双工同一时刻只能有一方在发送。这意味着软件上必须做好收发切换。切换太早或太晚都会导致数据截断或者收到自己发出的回声。2.3 RS485收发方向控制的三种做法方案一硬件自动收发电路。利用芯片的收发延时来做到“自动切换”代码只关心读写不用管方向。优点是省事缺点是最高波特率受限在某些波特率下自动切换电路会不稳定而且收发切换需要额外的延时对时序要求高的场合不适用。方案二GPIO控制方向。RE/DE引脚接到主控某个空闲GPIO上发送前置高发完置低。这个方案最可控缺点是每次收发都要做ioctl切换一旦状态没复位总线就会一直被拉在发送状态导致接收不到任何数据。方案三用RTS或DTR引脚控制方向。这是Linux下比较标准的做法Android里通过TIOCMBIS和TIOCMBIC这两个ioctl命令控制串口引脚电平。下面这段代码展示了怎么实现#include sys/ioctl.h #include linux/serial.h int set_rts(int fd, int level) { int status; ioctl(fd, TIOCMGET, status); if (level) status | TIOCM_RTS; else status ~TIOCM_RTS; return ioctl(fd, TIOCMSET, status); }发送前调set_rts(fd, 1)发完最后一字节后调set_rts(fd, 0)。注意必须等数据彻底发送完毕才能拉低RTS否则最后一个字节会被吃掉从机收到的帧不完整。判断数据发送完毕可以调用tcdrain(fd)等待发送缓冲区清空再切换方向。3. Android侧串口访问的技术基础3.1 为什么读 /dev/ttyS0 会被拒绝Android上打开串口节点经常遇到Permission denied。这跟Linux的文件权限体系有关。设备节点的默认权限一般是crw-rw---- root system普通App的用户ID是u0_aXX根本不在system组的成员列表里自然打不开。解决权限问题的常见办法有几种。最传统的是刷入root系统后用adb root进入root模式执行chmod 666但这只对调试阶段有效设备一重启权限就还原了。生产环境里比较正规的做法是在ueventd.rc里给指定串口节点配置权限。以安卓车机为例在/vendor/etc/ueventd.rc中加入/dev/ttyS0 0666 system system这样系统启动时init进程就会把ttyS0节点设为666权限所有App都能读写。如果使用SUPL脚本动态修改权限配合init.svc也可以实现开机自动授权不过要处理好时序否则容易被服务抢占资源。另外提一点有些定制系统里串口设备节点不在/dev下而是映射到了/sys/class/tty/ttyS0这种sysfs路径。这种节点不能直接open要做符号链接才能正常使用。3.2 串口参数为什么重要串口通信双方必须约定完全一致的参数否则就是乱码。这跟两个人说话一个说中文一个说日语互相听不懂是一个道理。波特率Baud Rate每秒传输的符号数。常见的有9600、19200、115200、460800。115200波特率下每秒约11.5KB远低于USB和蓝牙的速率所以串口只适合小数据量控制指令不适合传大文件。数据位Data Bits常见5、7、8。现在是8几乎不用考虑其他的。停止位Stop Bits常见1、1.5、2绝大多数设备是1位停止位。校验位Parity无校验N、奇校验O、偶校验E。现在主流的车载娱乐设备基本都是无校验老一点的工业仪表可能用的是偶校验。这四个参数里波特率最容易出问题。设备默认115200我程序里也设了115200但实际还是乱码排查了一下午最后发现设备上电瞬间初始化需要等两秒我在设备初始化之前就去读了读到的全是0x00和0xFF。所以配置参数之前先确认对端设备已经完成上电初始化。3.3 termios 是串口配置的核心Linux和Android下配置串口靠的是termios这个POSIX标准接口。它位于termios.h头文件中核心流程是先拿到当前配置然后修改flag再写入驱动。有一个特别容易踩的坑是打开串口节点后如果不做任何配置内核会使用默认的termios配置通常是规范模式canonical mode这种模式下读操作会一直阻塞到收到换行符才返回。串口设备返回的数据往往是二进制帧帧里不一定有\n所以读操作就会永远卡住。正确做法是open之后立即把串口设置成原始模式也就是清空ICANON、ECHO、ISIG这些标志位。4. 串口配置与数据通信的完整实现4.1 打开串口的完整流程先看一段完整的C代码这段代码是我在实际项目中整理出来的可以直接编译成JNI库供Android调用#include termios.h #include fcntl.h #include unistd.h #include sys/ioctl.h #include linux/serial.h int uart_open(const char *path, int baudrate) { int fd open(path, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) { return -1; } struct termios opts; if (tcgetattr(fd, opts) ! 0) { close(fd); return -1; } // 设置为原始模式不做任何行处理 cfmakeraw(opts); // 设置输入输出波特率 cfsetispeed(opts, baudrate); cfsetospeed(opts, baudrate); // 8位数据、无校验、1位停止位8N1 opts.c_cflag ~PARENB; opts.c_cflag ~CSTOPB; opts.c_cflag ~CSIZE; opts.c_cflag | CS8; opts.c_cflag ~CRTSCTS; // 不使用硬件流控 opts.c_cflag | (CLOCAL | CREAD); opts.c_cc[VMIN] 1; // 读到至少1个字节再返回 opts.c_cc[VTIME] 0; // 不启用超时定时器 tcsetattr(fd, TCSANOW, opts); return fd; }这里的几个细节值得展开说明。O_NOCTTY是防止串口成为控制终端终端控制信号比如CtrlC会导致进程收到SIGINT而退出。O_NDELAY让open调用不阻塞即使设备没有载波检测信号也能正常打开。cfmakeraw()是标准库函数会把所有输入输出处理选项全部关掉等价于手动清空ICANON | ECHO | ISIG | IEXTEN | OPOST这些标志。VMIN和VTIME这两个参数对读行为的控制非常关键。VMIN表示读操作至少要满足多少字节才算完成VTIME表示跨字节之间的超时时间单位是0.1秒。设置为VMIN1、VTIME0时内核只要收到1个字节就会唤醒read这在接收不定长数据时最方便。如果设置成VMIN0、VTIME10那就是2秒超时超时后即使没有数据read也会返回0这种适合做超时检测。4.2read线程里的数据拼帧处理串口设备发送的数据到达内核缓冲区后read会把数据取走。但底层并不知道上层协议的帧边界在哪里。比如协议规定一帧是AA 55 01 03 00 01 02共7个字节底层驱动很可能一次只到了3个字节read返回3剩下的4个字节过几十毫秒才到。很多新手会在这里犯一个错误一次read没读够7个字节就认为数据错了直接丢弃结果通信成功率极低。正确做法是维护一个接收缓冲区把每次read到的数据追加到缓冲区尾部再去缓冲区里查找完整帧找到一帧就取走一帧剩下的保留给下一次拼接。这就是常说的“粘包/半包处理”。下面是一个用Kotlin实现的简单拼帧逻辑class FrameAssembler { private val buffer ByteArrayOutputStream() private val frameLength 7 fun push(data: ByteArray): ListByteArray { buffer.write(data) val frames mutableListOfByteArray() val bytes buffer.toByteArray() var offset 0 while (bytes.size - offset frameLength) { // 检查帧头 if (bytes[offset] 0xAA.toByte() bytes[offset 1] 0x55.toByte()) { val frame bytes.copyOfRange(offset, offset frameLength) frames.add(frame) offset frameLength } else { offset } } buffer.reset() if (offset bytes.size) { buffer.write(bytes, offset, bytes.size - offset) } return frames } }这段代码的逻辑是持续从串口stream里读数据攒够一帧长度之后检查帧头匹配就解析不匹配就跳过一个字节继续找。这种“滑动窗口”方式虽然粗暴但在大多数结构化协议里非常实用。帧尾校验如果在协议里有定义比如CRC16或累加和需要在解析到完整帧后额外校验不通过就丢弃。4.3 一个实用的串口通信框架完整的Android串口通信框架虽然各家代码风格不同但核心模块基本是这四个串口打开与参数配置负责open、termios设置、把文件描述符传给JNI层。读取线程用一个后台线程循环read通过回调把数据抛给业务层。写入方法提供带锁的write方法避免多个线程同时写导致数据交叉。生命周期管理关Activity/Fragment时释放串口关闭文件描述符避免内存泄漏和文件句柄耗尽。如果是对接Modbus RTU设备协议层的读写会更明确主站发请求帧从站在一定时间内响应主站需要处理超时、重试、CRC校验。车载场景里一般用Android做Modbus主站轮询各个从站的寄存器。5. 实测中踩过的坑与排查方案5.1 常见问题速查表下面这份速查表可以说是我几次项目中反复遇到、用了最长时间排查出来的问题直接整理给大家现象可能原因排查与解决打不开/dev/ttyS0权限不够节点不存在查看/ueventd.rc权限配置确认设备树节点enable数据乱码波特率不匹配电平不匹配用示波器/逻辑分析仪看波形确认两边参数一致读不到任何数据串口被console占用设备没上电检查内核cmdline去掉console参数确认外设供电收帧不全没做粘包处理VMIN设置不对引入帧拼接缓冲区设置VMIN1RS485收到自己发的数据方向切换太慢RE/DE引脚没拉低发送完成后tcdrain再拉低方向脚或使用硬件自动收发偶发乱码或字节丢失RS485终端电阻缺失信号反射总线两端加120欧电阻检查双绞线质量串口被其他进程占用系统服务在监听该节点用lsof或cat /proc/pid/status检查占用进程设备重启后权限还原权限配置未固化到ueventd.rc修改vendor分区下的ueventd.rc并重新打包5.2 三个比较隐蔽的坑第一个坑是ttyS*节点被核显或蓝牙驱动抢占。有些SoC方案的串口引脚有复用功能设备树里同一个引脚被配置成了别的用途导致用户层看到节点存在但读写全无反应。排查方法是查设备树里该节点的pinctrl配置确认引脚mux是否正确。第二个坑是Android系统里Java层直接读FileInputStream会被线程中断问题卡住。串口读是一个长期阻塞的read如果在close()文件描述符后读线程还挂在read上会在close时触发SIGPIPE信号导致整个进程崩溃。解决办法是在JNI层用pthread专门管理读循环并在close之前调用pthread_kill或者用poll加超时的方式让read退出阻塞。第三个坑是波特率不是标准值。有些外设使用非标波特率比如57600、38400这些还算常见但曾经遇到一个胎压模块用了115200的倍频实际是230400排查的时候按标准波特率列表挨个试都不对最后看设备文档才发现是230400。Android的termios接口同样支持这类波特率但需要确认内核里的B230400宏已经定义且驱动支持。5.3 抓日志定位串口问题的思路当串口出现问题时最忌讳的是瞎猜。我个人的定位思路是这样的先看硬件层用示波器或逻辑分析仪挂在串口接收脚上观察外部设备有没有发出数据。这一步最关键可以直接排除“外设压根没发数据”和“Android没收到数据”两种可能。逻辑分析仪也不贵建议嵌入式/Android开发手头常备一个。再看驱动层打开内核串口驱动的动态调试echo file drivers/tty/serial/* p /sys/kernel/debug/dynamic_debug/control这样内核会把每次串口中断收到的数据打印到dmesg可以看到数据是否真正到达了驱动层。最后看应用层在App里把每次read到的原始数据以十六进制打日志注意不要用String直接打印因为二进制帧里包含不可见字符应该用Hex工具类转换。下面的代码片段可以作为参考fun bytesToHex(bytes: ByteArray): String { val sb StringBuilder() for (b in bytes) { sb.append(String.format(%02X , b)) } return sb.toString().trim() }沿着“硬件→驱动→应用”这条链路逐层排查一般半小时内能定位到问题所在。如果一开始乱试很容易把问题搞得越来越复杂。6. 几个压箱底的经验回头看看做Android车载串口开发最花时间的往往不是代码本身而是硬件确认、权限配置和协议调试这些“看不见”的环节。有几个经验想单独拿出来说说。第一串口通信一定要在协议层做超时和重试。串口不像TCP没有底层的可靠传输和重传机制数据丢了就是丢了。不管是自定义协议还是Modbus RTU都要在代码里做超时重发。Modbus的标准响应超时一般是500ms到1s自定义协议也可以参考这个范围。第二发送数据时最好在应用层做一个发送队列避免多个业务模块同时调串口write导致数据交叉。串口是半双工同一时刻只能有一个任务在发送。如果没有队列可能出现“A业务发了半帧B业务插进来发了半帧”对端解析出来的数据完全错乱。第三对于RS485通信建议在协议层面区分帧间隔和设备响应时间。有些从设备在收到帧之后要处理几十毫秒才回复如果主站着急读只会读到空。轮询周期和设备超时要分开设置不能把轮询周期直接当成响应超时。做串口开发真的是一件磨练耐心的事一个波形没对、一根线没插紧、一个校验位错了都可能导致整出来的数据毫无规律。但话说回来一旦把整套链路打通了那种“一条命令发出去设备听话地完成动作”的成就感其他开发方向还真比不上。上面这些内容是我自己在车机项目里一点点踩出来的希望对正在做或者准备做Android串口开发的你有点帮助。