做车载Android开发这几年,我接手的项目里十个有七个都绕不开串口通信。最近一个项目是把几台商用车的仪表信息(车速、转速、油量)通过串口接到中控Android屏上显示,另外还要跟一套RS485总线上挂着的多个传感器节点通信。刚开始以为Android端调个串口库就能搞定,结果UART、RS232、RS485三种协议、电平转换、设备节点权限、数据组帧、ModbusRTU轮询这些环节一个接一个踩过去。这篇文章把我在这个项目里实际验证过的方案、选型逻辑和排查经验完整记录下来,给正在做Android车载串口开发的同行一个可以直接参考的路线图。
我尽量用项目里真实发生过的顺序来讲:先解决“串口协议怎么选”,再解决“Android主机侧硬件通路怎么搭”,然后落到“串口库和参数配置”,最后才是数据通信层面的帧设计、ModbusRTU对接和硬件抗干扰。这样从物理层一路做到应用层,跟实际开发节奏是对上的。
1. 车载Android盒子的串口家族:UART、RS232、RS485怎么选
很多刚接触车载开发的Android工程师,看到板上有个4针排针标注TX、RX、5V、GND,就以为串口通信就是把两根线连上然后读写。这个认知在开发板调试阶段够用,但到了车载现场,你必须先搞清楚UART、RS232、RS485到底分别是什么,因为你的Android主机侧的TTL电平,跟外面那些车规设备用的RS232、RS485电平完全不兼容,直接接十有八九会烧口或者通信没反应。
1.1 UART是地基,RS232/RS485只是它的物理层外壳
UART(Universal Asynchronous Receiver/Transmitter)本质上是一种异步串行通信协议,只规定了数据帧格式和时序:空闲时信号线为高电平,起始位是一个低电平,接着是5到8个数据位,然后是可选的校验位,最后是1到2个停止位。它没有规定电平标准,所以同一个UART协议可以跑在TTL电平、RS232电平、RS485电平上,区别只在于外面加了一个电平转换/驱动芯片。
Android主机(或者任何嵌入式主板)串口引脚出来的通常是TTL电平,逻辑1是3.3V(少数是5V),逻辑0是0V。TTL电平的优点是直接和芯片引脚对接、电路简单,缺点是抗干扰能力差、传输距离短,一般超过1米就不太靠谱。所以TTL串口在车载上只用于板内通信,比如Android主板到4G模块、GPS模块、蓝牙模块这些。
RS232则把逻辑电平变换成负逻辑:逻辑1对应-3V到-15V,逻辑0对应+3V到+15V。这个电平摆幅大、抗干扰能力比TTL强不少,支持点对点全双工通信,最远能到15米左右。车载设备里很多老一代的仪表、计价器、诊断口用的都是RS232。
RS485用的是差分信号,靠A、B两根线之间的电压差(超过+200mV为逻辑1,低于-200mV为逻辑0)来传数据,抗共模干扰能力强,最远能到1200米,而且支持一主多从,一条总线上理论上可以挂32个标准负载节点。代价是半双工,同一时刻只能收或只能发。车载上凡是“多个传感器/设备挂一条总线”的场景,基本都是RS485。
1.2 车载设备选型依据——距离、节点和速率
我在项目里总结了一个简单粗暴的选型表,方便之前没做过车载的同事快速上手:
| 项目 | TTL UART | RS232 | RS485 |
|---|---|---|---|
| 电平 | 3.3V/5V | ±3V~±15V | A-B差分 |
| 传输距离 | 1米内 | 15米左右 | 1200米 |
| 通信方式 | 全双工 | 全双工 | 半双工 |
| 节点数 | 点对点 | 点对点 | 最多32/128个 |
| 抗干扰 | 弱 | 中 | 强 |
| 典型车载场景 | 板内模块通信 | 仪表、诊断口 | 多传感器总线、网关 |
实际车载项目里,中控屏和仪表之间如果距离很近、就一个屏一个仪表,很多方案直接用RS232;如果要在车身不同位置挂温度、压力、液位好几个传感器,99%会走RS485总线;而Android主机和内置的4G模块、蓝牙模块之间基本都是TTL串口。确定好协议类型之后,Android端要做的第一件事不是写代码,而是确认硬件上已经把TTL转换成了对应的RS232/RS485电平,并且知道转换板接在了主板的哪个串口控制器上。
另一个需要早点和硬件同事对齐的是波特率。车载设备最常用的是9600和115200,老一点的设备还有2400、4800,ModbusRTU协议默认常用9600、8N1。波特率不一致会出现“能收到数据但是全是乱码”,而且是那种非常规律的乱码。这个坑我在项目里遇到过两次,后面第六章详细说排查链路。
2. 主机侧串口通路:TTL引脚、USB转串口与设备节点
对Android应用层开发者来说,串口最终映射成一个设备节点文件,比如/dev/ttyS1、/dev/ttyMT0、/dev/ttyUSB0。你能读到这个文件,就能拿到串口数据。但是不同车机主板上串口是怎么引出的,直接决定了你用什么方式去打开这个节点。
2.1 车机上常见的三类串口引出方式
一类是主板直接引出TTL排针或者排座,这是最常见的方案。瑞芯微RK3288/RK3399方案的车机一般引出几路TTL串口,系统里对应/dev/ttyS0、/dev/ttyS1、/dev/ttyS2。全志方案类似,高通平台有时候是/dev/ttyHS开头,MTK平台则常见/dev/ttyMT0这类节点。如果你的Android主机是root过的或厂商给了系统权限,可以直接通过JNI去open这些节点。
第二类是USB转串口,很多不带原生串口的通用Android工控板会用USB转TTL/RS232/RS485模块来接外部设备,系统里枚举出来的节点是/dev/ttyUSB0(FT232R、CH340这类芯片)或者/dev/ttyACM0(USB CDC ACM设备)。这种情况下其实不需要root,用Android的USB Host API配合usb-serial-for-android库就能操作,但要求设备硬件上支持USB Host模式,而且每个串口都要占用一个USB口。
第三类是厂商SDK封装好的系统串口服务。部分车厂Rom会把串口封装成类似SerialManager的系统服务,对外提供Java接口,但这不是AOSP公开API,不同厂商接口差异很大,而且资料通常不公开。我见过有厂商直接给一个.so库和几个隐藏接口,这种就没什么通用经验可讲,只能按厂商文档来。
2.2 device节点与open方式:为什么一定要O_NOCTTY
无论哪种方式,Android端读写串口本质上是open设备文件,然后read/write。这里直接参考Google开源的android-serialport-api项目(GitHub上cepr维护的那个),底层SerialPort.c里open设备时带了几个关键flag:
int fd = open(path_utf, O_RDWR | O_NOCTTY | O_NONBLOCK);O_NOCTTY的意思是“如果打开的文件是终端设备,不要把它设置为当前进程的控制终端”,对串口开发来说是必须的,否则可能影响进程行为。O_NONBLOCK是非阻塞模式,这里一定要配合后面讲到的termios配置来用。打开成功后,还要用tcgetattr、cfmakeraw、cfsetispeed这些函数去配置串口参数。
2.3 权限问题:没有root要怎么处理
车载Android整机现在的SELinux策略收得越来越紧,即使设备有root权限,应用直接写/dev/ttyS1也可能被SELinux挡住。我常用的几种处理方式,按可靠性排序:
- 最稳的是在ROM的init.rc里给对应设备节点加chmod 666,并在file_contexts或者sepolicy里给应用放行对应串口节点的访问权限。
- 如果是root过的工程机,可以在应用启动时执行su -c chmod 666 /dev/ttyS1,但重启后失效,需要重复执行。
- 如果不是root设备,可以尝试用USB转串口方案,走USB Host API,应用层就能拿到访问权限,这是唯一不需要动系统底层的通用路径。
权限问题容易在项目联调阶段突然冒出来:App装上去打开串口报Permission denied,真机shell里用cat /dev/ttyS1能读到数据,同一台机器同一个节点,应用里就是打不开。这个就是典型的SELinux拦截。我曾经在这上面耗了整整半天,后来在系统日志里看到avc: denied记录才反应过来。
3. Android串口库选型与JNI配置:serial_port的termios真相
串口库选择上,Android生态里其实就两条主流路线,选错了后面维护很被动。一条是基于Google android-serialport-api下来的JNI直驱动路线,另一条是基于USB Host的usb-serial-for-android路线。两条我都用过,适用场景完全不同。
3.1 两条路线:JNI直驱动 vs USB Host库
JNI直驱动路线的代表是android-serialport-api,它通过JNI直接open设备节点,底层用Linux的termios接口配置串口。优点是速度快、延迟低、不依赖USB枚举,直接操作内核设备节点,非常接近嵌入式原生开发;缺点是要求设备有root或者厂商开权限,而且不同平台ABI要分别编译so库。车载上如果Android主板有原生TTL串口、系统权限也给了,这条路线是首选。
USB Host路线的代表是mik3y/usb-serial-for-android,底层调用Android的UsbManager和UsbSerialPort接口,不需要root。库本身封装好了FTDI、CH340、CP210x、PL2303等常见芯片的驱动。优点是免root、维护成本低;缺点是每个串口占一个USB口、通过UsbManager请求权限时有弹窗(车机环境有时候弹窗不好点)、链路比直接open节点多一层,而且少数车载盒子对USB转串口芯片的兼容性不太好。我见过一个全志方案的车机,插上CH340模块后系统一直不枚举设备,换FT232R就好了,没有任何道理可讲,芯片兼容性就是得实测。
选型结论很直接:有原生串口节点、有root或系统权限,用JNI直驱动;只有USB扩展、没有root,老老实实用usb-serial-for-android。不要试图在非root设备上硬改/dev节点权限,既不稳定也不安全。
3.2 编译JNI库的NDK配置与串口参数设置
如果你用android-serialport-api,拿到源码后需要编译出libserial_port.so。用Android Studio + NDK编译时,最需要注意的是Application.mk里把APP_ABI配全:
APP_ABI := arm64-v8a armeabi-v7a x86_64 APP_PLATFORM := android-21别只编一个arm64-v8a,很多车机虽然CPU是64位,但系统还是32位的用户态,只放64位so会直接崩。这个坑在RK3288(32位系统)上特别常见。
JNI层配置串口参数还是那套经典的termios调用:
struct termios cfg; tcgetattr(fd, &cfg); cfmakeraw(&cfg); cfsetispeed(&cfg, speed); cfsetospeed(&cfg, speed); cfg.c_cflag |= (CLOCAL | CREAD); // 忽略modem控制线,允许接收 cfg.c_cflag &= ~CSIZE; cfg.c_cflag |= CS8; // 8数据位 cfg.c_cflag &= ~CSTOPB; // 1停止位 cfg.c_cflag &= ~PARENB; // 无校验 cfg.c_cflag &= ~PARODD; cfg.c_cc[VMIN] = 1; cfg.c_cc[VTIME] = 0; tcflush(fd, TCIOFLUSH); tcsetattr(fd, TCSANOW, &cfg);这里c_cflag的配置是“先清零再置位”的套路。CSIZE、CSTOPB、PARENB这些位域,如果不先清零再设置想要的值,很容易因为默认值残留导致配置变成7位数据位、2位停止位之类,接收端自然就乱了。项目里经常有人改了波特率但忘了改数据位/校验位,结果就是和传感器设备对不上。另外VMIN=1、VTIME=0的意思是read在有数据时至少返回1个字节,没有数据时立即返回,这个配合非阻塞模式用刚好。
3.3 波特率、数据位、校验位、停止位背后的termios语义
串口参数本质上是在跟底层驱动约定“怎么把字节流变成电平跳变”。波特率就是每秒钟电平变化的次数,9600波特率下传1个字节要10个bit(1起始+8数据+1停止),耗时约1.04ms,这个时间对后面的超时设计很关键。数据位在车载设备里基本都用8,校验位常用无校验(8N1),少数老设备用偶校验(8E1)。停止位常用1位。
用termios配置时有个细节:如果要用奇偶校验,除了设置PARENB,还需要设置PARODD(奇校验)或清零(偶校验),否则设备行为可能和预期不一致。ModbusRTU设备大部分要求8N1,所以默认按8N1去配,遇到不吐数据的再回头检查是不是校验位的问题。另外cfsetispeed和cfsetospeed要同时设,虽然很多驱动里收和发是同一个时钟,但严谨起见两个都设上。
4. 串口数据通信实战:流式字节组帧与多路并发读取
串口通信应用层写起来有一个非常重要的思维转变:串口是流,不是包。TCP至少还能保证你不会读到半个包,串口连这层保障都没有。你在read()里拿到的可能是一个字节、半个帧、三个帧拼在一起。所有帧边界都需要自己设计、自己解析。
4.1 串口是“流”不是“包”——如何设计自己的帧协议
车载仪表、传感器设备一般都有自己的私有协议。如果没有,你就得自己设计一版最简可用的帧格式,比如:
帧头(2字节) 长度(1字节) 命令(1字节) 数据(N字节) 校验(1字节) AA 55 LEN CMD DATA CHECK帧头选0xAA 0x55是为了方便识别,这两个字节二进制是10101010 01010101,波形上看比较明显,而且出现随机匹配的概率低。长度字段表示后面数据部分的字节数,校验字段可以用异或校验,简单计算量小。
解析端建议别用边读边拆的土办法,而是维护一个字节缓冲区,每收到一段数据就追加进去,然后循环尝试从缓冲区里解析出完整帧。我用的是状态机方式,代码结构大概是:
public class FrameParser { private static final int STATE_WAIT_HEAD1 = 0; private static final int STATE_WAIT_HEAD2 = 1; private static final int STATE_WAIT_LEN = 2; private static final int STATE_WAIT_CMD = 3; private static final int STATE_WAIT_DATA = 4; private static final int STATE_WAIT_CHECK = 5; private int state = STATE_WAIT_HEAD1; private int frameLen; private byte[] frameBuffer; private int frameIndex; private List<byte[]> completeFrames = new ArrayList<>(); public void push(byte[] data, int size) { for (int i = 0; i < size; i++) { byte b = data[i]; switch (state) { case STATE_WAIT_HEAD1: if ((b & 0xFF) == 0xAA) { state = STATE_WAIT_HEAD2; } break; case STATE_WAIT_HEAD2: if ((b & 0xFF) == 0x55) { state = STATE_WAIT_LEN; } else if ((b & 0xFF) != 0xAA) { state = STATE_WAIT_HEAD1; } break; case STATE_WAIT_LEN: frameLen = (b & 0xFF); frameBuffer = new byte[frameLen + 5]; frameBuffer[0] = (byte) 0xAA; frameBuffer[1] = (byte) 0x55; frameBuffer[2] = b; frameIndex = 3; state = STATE_WAIT_CMD; break; // ... 略 } } } }这套状态机的要点是:任何一个字节不符合预期就回退到找帧头的状态,绝不能把一个错位字节当成帧头继续解析。帧错位是串口通信最常见的问题,一旦错位,轻则丢一帧,重则连续解出好几帧乱码。状态机这个写法虽然代码多几行,但鲁棒性比“用indexOf扫描帧头”要高得多,尤其是数据区里可能恰好出现0xAA 0x55这种伪帧头的情况下。
4.2 多路串口并发读的实现要点
车载中控经常要同时管多路串口,比如一路RS232接仪表、一路RS485挂总线、一路TTL接4G模块。每路串口一个独立线程去阻塞读,是所有方案里最不容易出问题的。
线程读串口的模板我一直在用:
new Thread(() -> { byte[] buffer = new byte[256]; while (!isStop) { int size = read(fd, buffer, buffer.length); if (size > 0) { parser.push(buffer, size); List<byte[]> frames = parser.getCompleteFrames(); for (byte[] frame : frames) { handler.onFrameReceived(frame); } } } }).start();几个实际项目里验证过的注意点:
- 读取缓冲区不要太小,256字节起步,否则高速率下一帧拆成多次read,解析逻辑就要处理半帧。
- 不要把业务解析逻辑放进读取线程里。读取线程只负责搬运字节和组帧,帧拿到之后丢到Handler或者阻塞队列里交给工作线程解析。有一次我把数据库写入直接写在读取线程里,结果串口数据一密集,界面直接卡死,还被误判成ANR。
- 每路串口必须有独立的心跳或超时监控。车载设备有时候会“假死”,就是不发送也不响应,如果读取线程被阻塞在read上(非阻塞模式下不会,但要防止应用层其他问题导致线程卡死),整个串口链路就断掉了。我习惯在解析线程里加一个LastReceiveTime时间戳,超过设定时间没有数据就报警或者主动重连。
轮询模式下还要注意:RS485是半双工,同一时刻只能一路在发。多路串口如果共用了同一个RS485总线,必须串行调度,按从站地址顺序轮流发请求,不能开多个线程同时往一条总线发数据,否则总线上全是冲突帧,谁也别想收到正常响应。
5. 对接车载协议栈:Modbus RTU、CRC16与一主多从轮询
车载设备通信里,Modbus RTU是出镜率最高的协议,尤其是工程机械、农用机械、充电桩、环境监测这些场景里的传感器和仪表,很多都支持Modbus RTU。Android端做主站(Master)轮询从站(Slave)是非常典型的车载网关形态。
5.1 Modbus RTU报文结构
Modbus RTU报文没有起始符和结束符,靠“总线空闲时间大于3.5个字符时间”来区分每一帧。每个帧的结构是固定的:
| 字段 | 长度 | 说明 |
|---|---|---|
| 从站地址 | 1字节 | 1~247,0为广播 |
| 功能码 | 1字节 | 03读保持寄存器、04读输入寄存器、06写单寄存器、10H写多寄存器等 |
| 数据 | N字节 | 寄存器地址、数量、数据值等 |
| CRC16 | 2字节 | 低字节在前,高字节在后 |
9600波特率下3.5个字符时间大约是3.64ms,也就是帧和帧之间要停顿这么久,对端才能识别出这是两个帧而不是一帧。在Android端做主站时,发完一帧请求后不要立刻发下一个请求,至少要等应答超时或收到完整响应后再继续。
5.2 Android端CRC16计算与常用功能码封装
Modbus CRC16的多项式是0xA001,初始值0xFFFF。Java实现不复杂,但要注意无符号右移和掩码处理:
public static int getCRC(byte[] data, int len) { int crc = 0xFFFF; for (int i = 0; i < len; i++) { crc ^= (data[i] & 0xFF); for (int j = 0; j < 8; j++) { if ((crc & 1) != 0) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; }调用时组装请求帧,比如读从站1的保持寄存器,起始地址0x0000,读2个寄存器:
byte[] request = new byte[8]; request[0] = 0x01; // 从站地址 request[1] = 0x03; // 功能码:读保持寄存器 request[2] = 0x00; // 起始地址高字节 request[3] = 0x00; // 起始地址低字节 request[4] = 0x00; // 寄存器数量高字节 request[5] = 0x02; // 寄存器数量低字节 int crc = getCRC(request, 6); request[6] = (byte) (crc & 0xFF); // CRC低字节在前 request[7] = (byte) ((crc >> 8) & 0xFF); // CRC高字节在后功能码03H的响应帧结构是:从站地址、功能码、字节数、寄存器数据(每个寄存器2字节高字节在前)、CRC16。解析的时候要注意字节序,Modbus寄存器数据是大端(高字节在前),这个跟很多嵌入式设备直接发小端数据的习惯不一样,我因为没注意字节序把温度和转速解析反了好几回。
5.3 车载网关的轮询调度与超时处理
一个Android主机挂多个Modbus从站设备时,最简单可靠的做法是定时轮询。轮询队列想清楚一个点:RS485是半双工,同一时刻只能有一个设备占用总线,所以Android端要“串行”地一帧一帧发请求、等响应、再发下一帧。绝对不能为每个从站开一个线程并发去读写,总线会直接乱掉。
我实现轮询的方式是维护一个请求队列,用一个单一的工作线程循环执行:
- 从队列头取出一个请求。
- 清空接收缓冲区的旧数据。
- 通过串口发送请求。
- 等待响应,设定超时时间(一般200~500ms,取决于波特率和设备响应速度)。
- 收到响应则解析、分发、记录该从站在线;超时则标记离线,发下一个请求。
- 一个周期扫完之后,重新入队。
响应帧还有可能被拆成两段到达,尤其是波特率低、帧数据长的时候。所以在主站解析时同样要“攒够字节再判断”,按最小帧长8个字节起算,读完一个完整的响应帧再做CRC校验。如果CRC不对,按照Modbus规范应该丢弃这帧而且不回复,在主站侧我一般连续收到3次错误CRC就把这个从站标记为异常,进入下一个从站,避免卡死在某个坏设备上。
超时时间怎么设:9600波特率下,一个字节约1.04ms,一帧典型请求/响应在8~20字节之间,算上设备的处理时间,超时设200ms已经非常充裕。但有些老式仪表响应极慢,可能要好几百毫秒,最好给每个从站单独配一个超时参数,而不是一刀切。
6. 实测链路硬件坑:RS485自动收发、防护电路与排查流程
Android端的代码写得再完美,硬件的坑一样能让你数据收不到。这一章是项目实测里最容易踩的几个硬件层面的问题,也是纯软件出身的Android工程师最陌生的部分。
6.1 RS485自动收发电路的原理与工作边界
RS485是半双工,所以芯片(比如MAX3485、SP3485)都有DE(发送使能)和RE(接收使能)引脚。常见设计是用一个GPIO控制DE/RE,发送时拉高DE、拉低RE,接收时反过来。但在很多现成的RS485模块上,你会看到它没有引出方向控制线,而是用了“自动收发电路”,靠串口自身的电平变化去切换方向。
自动收发电路的原理不复杂:UART空闲时TXD是高电平,把这个高电平经过三极管反相后接到DE/RE上,就能让模块默认处于接收状态;当TXD发起始位(低电平)时反相变成高电平,模块切到发送状态。用三极管搭的电路大致是这样的思路:TXD经过一个NPN三极管反相后去控制RE/DE引脚,空闲时TXD高→三极管导通→DE/RE为低→接收;发送起始位低→三极管截止→DE/RE被上拉为高→发送。
这类电路的好处是少一根MCU控制线,省事;坑在于它依赖TXD在发送整帧数据期间保持紧密连续的跳变。如果发送端每发一个字节之间有个微小的高电平间隙,自动收发电路就会误以为发送结束了,把方向切回接收,总线后半部分的数据直接发不出去。解决办法有两个:一是发送端要把整帧数据用一个write调用连续发出去,不要逐字节flush;二是对于波特率高的场合(超过115200),自动收发电路可能跟不上方向切换的速度,波形会变形,这时必须改成GPIO控制方向。我在项目里用过带自动收发的模块,9600波特率下工作正常,但提到115200后偶发丢字节,拆开看波形才发现是方向切换导致的。
6.2 RS232/RS485的防护设计:TVS、自恢复保险丝与隔离
车载环境对串口的浪涌和静电非常不友好。RS232接口上我测过,直接插拔线缆时会产生比较明显的电压毛刺,严重的时候会把电平转换芯片打坏。RS485总线上更是如此,长线缆在雷雨天气甚至大型电机启停时都会感应出高压。
最基础的防护方案是在接口处加TVS管(瞬态电压抑制二极管),RS232常用SM712这类专用的双极性TVS,RS485一般用法是A、B线对地各接一个TVS,再在A、B之间跨接一个。TVS管的作用是当线上电压超过钳位电压时迅速导通泄放能量,防止后级芯片被打坏。注意TVS的钳位电压要选得比芯片极限电压低,但又高于正常信号电平,否则正常通信就会被削波。
电源和信号地也要留意。RS232/RS485通信两端设备地电位不一致时,会有共模电流流过信号线,导致通信异常甚至烧毁接口芯片。我排查过一个“RS485在A设备上通信正常,换到B设备就丢数据”的案例,最后发现是B设备的外壳地没接好,A、B两端地电位差了十几伏,RS485芯片的共模输入范围也就-7V到+12V,直接超限。规范做法是RS485总线用双绞屏蔽线,屏蔽层单端接地,同时在每个设备端做好隔离,比较彻底的是用带DC-DC隔离电源的数字隔离芯片方案(比如ADUM1201+隔离电源)。车上不方便做完整隔离的话,至少保证收发器芯片的电源是干净的,并且总线的终端电阻要按规范只在最远端设备上并一个120欧姆。
6.3 丢帧、乱码、偶发卡死的排查流程
最后分享几个实测排查链路,都是我在项目里用了很多遍的招数,按这个顺序排查,绝大多数串口问题都能定位:
- 先确认物理连接和电平匹配。检查Android主机串口引出的到底是TTL还是RS485电平,跟目标设备是否一致。TTL对接RS232/RS485必须经过转换板,而且转换板的TXD要接目标设备的RXD,RXD接目标设备的TXD,交叉接线。接反的典型现象是收不到任何数据。
- 用串口助手验证目标设备本身是否在发数据。PC接USB转RS485,通过SSCOM这类工具看看能不能正常收到仪表/传感器的数据。这次能收到,说明问题在Android侧;收不到,说明设备侧或线缆有问题。
- 在Android端打原始字节日志。在JNI层或者解析层把read到的十六进制数据直接打出来,跟PC串口助手上的数据对比。如果Android端收到的字节跟PC端不一致,考虑波特率、校验位、地线问题;如果一致但业务解析不对,那就是帧解析逻辑问题。
- 做回环测试。把串口的TX和RX直接短接(如果是RS485,把A-A、B-B接好),在Android侧自发自收,能收到自己发的数据说明Android串口通路基本是通的,问题在外部链路。
- 检查地线。TTL和RS232通信必须在设备之间共地,否则表现为时通时不通、偶发乱码。RS485则在总线上A/B线之外,尽量保证各设备参考地一致。
- 波形测量。上述都不行就上示波器测TXD/RXD或A/B线的波形,看电平幅度够不够、波形畸变是否严重。我之前遇到一个RS232通信间歇性失败的问题,示波器一测发现某个转换板的负电平只有-3V,刚好在临界值,换一个转换板就好了。
另外还有一个隐蔽的坑:Android系统的串口节点有时候会被别的进程占用。比如有些车机的系统服务自己启动了串口读取,你的应用再去open同一个节点就会冲突,表现为设备节点打开成功但读不到数据,或者打开直接报错。排查方法是在shell里用lsof /dev/ttyS1或者fuser -v /dev/ttyS1看有没有其他进程占着。
车载项目里做串口开发,很大一部分精力其实不在Android代码上,而是在和硬件、协议打交道。一定要尽早确认电平类型、波特率、数据格式、帧协议、设备地址表这些信息,让硬件同事把所有设备节点的对应关系列成一张表,能省掉后面非常多联调时间。我自己吃过好几次亏都是因为“先写代码再补硬件确认”,结果代码写完发现电平都没对上,白白返工。建议你接到项目的头两天,先花半天时间把所有串口相关的硬件链路和协议文档梳理清楚,再动Android代码,效率会高很多。