ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Android串口通信实战:设备节点、SELinux与JNI调试全指南

Android串口通信实战:设备节点、SELinux与JNI调试全指南

简介:本资源是一个面向Android开发者的串口通信实战入门项目,适用于需要与USB转串口设备、嵌入式硬件或工业传感器进行数据交互的中初级开发者。项目提供完整的可运行Demo及配套源码,覆盖串口初始化、参数配置(波特率/数据位/校验位)、数据收发、异常处理与UI状态反馈等核心环节,基于Android-SerialPort-API等成熟方案实现,无需Root即可在主流Android设备上调试。压缩包共2000个文件,以729个class字节码、576个flat资源、265个dex可执行文件、137个xml布局与配置、37个java源文件为主,辅以so本地库、apk安装包及调试用json配置,整体体积16.69MB,结构完整便于逆向分析与二次开发。已有606人学习下载,读者可直接导入Android Studio编译运行,快速掌握串口通信权限申请(如USB权限动态获取)、端口路径识别(/dev/ttyUSB0等)、线程安全读写及RealTerm联调验证等关键实践要点。

1. Android串口通信不是插上线就能通:SerialPort_Android demo 为什么跑不起来、权限怎么配、设备节点怎么找、JNI层报错怎么定位

你手上有块工控板、扫码枪、指纹模块或PLC,想用Android手机或平板直接读写串口数据——这不是“连USB线+装个APP”就能搞定的事。SerialPort_Android 这个经典开源方案(GitHub上超2k star,被大量工业终端、自助设备、嵌入式调试工具复用)表面看只是个带JNI的轻量库,但实际落地时,80%的开发者卡在设备节点识别失败、SELinux拒绝访问、串口参数协商异常、read()阻塞无返回、权限动态申请后仍被内核拦截这五道关卡。它不依赖ADB调试,也不走USB转串口虚拟COM模式,而是直通Linux底层/dev/ttyS或/dev/ttyUSB设备节点,这意味着你必须同时懂Android Framework层权限模型、Linux设备树命名规则、串口协议时序约束,以及JNI调用链中哪一行会触发EACCES错误。本文面向已能编译APK、有adb shell基础、正对着logcat里java.io.IOException: open failed: EACCES (Permission denied)发呆的工程师——我们不讲UART原理,只拆解这个zip包里最简demo如何从「编译通过」走到「稳定收发十六进制指令」,每一步都附可验证命令、关键日志特征和绕过玄学的硬核参数。


2. 搭建环境:NDK版本、ABI过滤、设备节点发现三件套缺一不可

2.1 确认你的NDK版本与so库ABI严格匹配

SerialPort_Android 的libs/目录下通常包含armeabi-v7a、arm64-v8a、x86_64三个ABI的libserial_port.so。但Android 12+ 已弃用 armeabi,而很多旧版demo仍打包了该架构。若你的目标设备是Pixel 7(arm64-v8a),却加载了armeabi-v7a的so,会在System.loadLibrary("serial_port")时报UnsatisfiedLinkError,且错误堆栈不提示ABI问题——这是第一处静默翻车点。

提示:用adb shell getprop ro.product.cpu.abi查设备真实ABI;用file libs/arm64-v8a/libserial_port.so验证so文件架构(输出应含AArch64)

# 在项目根目录执行,强制只打包目标ABI(以arm64-v8a为例) ndk { abiFilters 'arm64-v8a' }

参数说明:abiFilters必须与libs/下存在的目录名完全一致,大小写敏感;若删掉该配置,Gradle会尝试打包所有ABI,导致APK体积暴增且可能因ABI冲突崩溃。

2.2 设备节点发现:别信/dev/ttyS0,用ls -l /dev/tty*现场抓

Android串口设备节点没有固定路径。高通平台常用/dev/ttyHS0(High-Speed UART),瑞芯微平台可能是/dev/ttyS2,而USB转串口模块(如CH340)则映射为/dev/ttyUSB0。demo里硬编码的/dev/ttyS0在90%的设备上会直接open()失败。

# 连接设备后,在adb shell中执行 adb shell su # 需root权限才能列全设备 ls -l /dev/tty*

典型输出:

crw-rw---- 1 root dialout 204, 64 2023-05-10 14:22 /dev/ttyHS0 crw-rw---- 1 root dialout 188, 0 2023-05-10 14:22 /dev/ttyUSB0

逻辑说明:crw-rw----表示字符设备,第三列root dialout是关键——你的App进程需属于dialout组才能访问。204,64是主次设备号,用于确认是否为真实串口(非蓝牙虚拟端口)。

2.3 动态权限申请:android.permission.ACCESS_COARSE_LOCATION是个陷阱

SerialPort_Android demo常要求<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />,但这与串口无关——它是为USB设备枚举时获取厂商ID用的(Android 6.0+对USB权限的变相限制)。真正需要的是:

<!-- AndroidManifest.xml --> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <!-- 注意:以下权限无需声明,但需root或系统签名 --> <uses-permission android:name="android.permission.INTERNET" /> <!-- 仅当demo含网络调试功能 -->

参数说明:WRITE_EXTERNAL_STORAGE是为保存日志文件;READ_EXTERNAL_STORAGE用于读取配置文件。而串口访问本身不依赖任何Android权限声明,它靠Linux UID/GID和SELinux策略控制——这也是为什么动态申请权限后仍报EACCES。


3. 初始化串口:波特率、数据位、流控参数必须与外设硬件手册对齐

3.1SerialPort构造函数的四个参数含义与实测容错边界

demo中常见初始化代码:

serialPort = new SerialPort(new File("/dev/ttyUSB0"), 9600, 0);

其中第三个参数0是flags,第四个参数(若存在)是timeout。但flags=0意味着禁用所有流控,而多数工业设备(如RS485温湿度传感器)要求CRTSCTS_OUT(硬件流控)或IGNBRK(忽略断线信号)。

// 推荐显式指定flags,避免隐式行为 int flags = 0; flags |= SerialPort.FLAG_CRTSCTS_OUT; // 启用RTS/CTS硬件流控 flags |= SerialPort.FLAG_IGNBRK; // 忽略断线信号,防误触发 serialPort = new SerialPort(new File("/dev/ttyUSB0"), 115200, flags);

参数说明:

  • baudrate(波特率):必须与外设硬件手册一致。实测发现,即使设备标称支持9600,实际通信时用115200更稳定(因时钟抖动补偿);
  • flags:FLAG_CRTSCTS_OUT对应Linux termios的CRTSCTS标志,若外设无RTS/CTS引脚,设此flag会导致open()失败;
  • timeout(毫秒):read()操作超时时间,设为0表示阻塞等待,设为1000表示最多等1秒——工业场景建议设500~2000,避免主线程卡死。

3.2 数据帧格式:stopBits和parity不能靠猜

demo常省略stopBits和parity设置,导致接收乱码。SerialPort_Android默认使用8N1(8数据位、无校验、1停止位),但某款PLC要求7E2(7数据位、偶校验、2停止位)。

// 在open()后立即设置termios参数 FileDescriptor fd = serialPort.getFileDescriptor(); // 通过反射调用setParameters(因SerialPort类未暴露此方法) Method setParams = SerialPort.class.getDeclaredMethod("setParameters", int.class, int.class, int.class, int.class); setParams.setAccessible(true); setParams.invoke(serialPort, 115200, 7, 2, 'E'); // baud=115200, data=7, stop=2, parity='E'

逻辑说明:setParameters是SerialPort类私有方法,需反射调用。参数顺序为(baudrate, dataBits, stopBits, parity),其中parity取值为'N'(None)、'O'(Odd)、'E'(Even)、'M'(Mark)、'S'(Space)。

3.3 流控开关时机:必须在write()前启用,否则数据丢失

硬件流控(RTS/CTS)若在串口打开后未及时启用,首帧数据会因接收方未就绪而丢失。某扫码枪在open()后立即write(),结果永远收不到ACK。

// 正确顺序:open → enable RTS → write → read serialPort = new SerialPort(...); // 启用RTS(请求发送) FileDescriptor fd = serialPort.getFileDescriptor(); ParcelFileDescriptor pfd = ParcelFileDescriptor.dup(fd); // 通过ioctl设置TIOCM_RTS int[] params = {TIOCM_RTS}; Ioctl.ioctl(pfd.getFileDescriptor(), TIOCMGET, params); params[0] |= TIOCM_RTS; Ioctl.ioctl(pfd.getFileDescriptor(), TIOCMSET, params);

参数说明:TIOCM_RTS值为0x4,TIOCMSET为0x5402(需查asm-generic/ioctls.h)。此操作需root权限,普通App无法执行——因此工业设备通常要求外设主动拉低CTS,App侧只需确保FLAG_CRTSCTS_OUT已设。


4. 数据收发:read()阻塞、write()丢包、十六进制解析三大避坑指南

4.1read()阻塞无返回?检查VMIN和VTIME的Linux内核级配置

SerialPort_Android的read()底层调用read(fd, buffer, len),其行为由termios的VMIN和VTIME决定。默认VMIN=1, VTIME=0(读到1字节即返回),但某些内核配置为VMIN=0, VTIME=10(最多等1秒,有数据立即返回),导致read()在无数据时卡住1秒。

# 在adb shell中查看当前串口termios配置 stty -F /dev/ttyUSB0 -a # 输出关键行:min = 1; time = 0

解决方法:在open()后强制重置termios:

// JNI层serial_port.c中修改open_port() struct termios tty; tcgetattr(fd, &tty); cfmakeraw(&tty); // 清除所有特殊处理 tty.c_cc[VMIN] = 1; // 至少读1字节 tty.c_cc[VTIME] = 0; // 不等待 tcsetattr(fd, TCSANOW, &tty);

现象→原因→解决:

  • 现象:read()调用后线程挂起,logcat无日志;
  • 原因:内核termios中VMIN=0且VTIME>0,导致read()进入定时等待;
  • 解决:JNI层显式设置VMIN=1, VTIME=0,或改用read()非阻塞模式(需fcntl(fd, F_SETFL, O_NONBLOCK))。

4.2write()后数据未发出?检查TCFLSH和TCSADRAIN同步时机

write()系统调用返回成功,仅表示数据已写入内核缓冲区,不代表物理发送完成。某PLC要求发送指令后等待TX空闲再发下一帧,否则响应错乱。

// JNI层write_data()末尾添加同步 tcdrain(fd); // 等待输出缓冲区清空 // 或 tcflush(fd, TCIOFLUSH); // 清空输入输出缓冲区

现象→原因→解决:

  • 现象:write()返回字节数正确,但外设无响应;
  • 原因:内核缓冲区未刷新,数据滞留在UART FIFO中;
  • 解决:tcdrain()阻塞至所有数据发送完毕,工业场景必备。

4.3 十六进制字符串解析:0xFF被截断为0x7F?字节符号陷阱

Javabyte是有符号类型(-128~127),而串口数据是无符号字节(0~255)。0xFF读入Javabyte[]变为-1,若直接String.valueOf(byte)会得"-1",而非"FF"。

// 正确的十六进制转换 public static String bytesToHex(byte[] bytes) { StringBuilder sb = new StringBuilder(); for (byte b : bytes) { sb.append(String.format("%02X ", b & 0xFF)); // 关键:b & 0xFF 转为无符号 } return sb.toString().trim(); } // 示例:收到0xFF 0x00 0xAA → 输出 "FF 00 AA"

现象→原因→解决:

  • 现象:Log显示[255, 0, 170],但bytesToHex输出"FF 00 AA";
  • 原因:Javabyte符号扩展,0xFF存为-1,& 0xFF强制转为255;
  • 解决:所有串口数据解析前必做& 0xFF,这是血泪经验。

5. SELinux与Root权限:为什么su能通而App直接崩溃?

5.1 SELinux策略拦截:avc: denied { open }是真凶

即使chmod 666 /dev/ttyUSB0,App仍报EACCES,logcat必现:

avc: denied { open } for path="/dev/ttyUSB0" dev="tmpfs" ino=12345 scontext=u:r:untrusted_app:s0:c123,c256,c512,c768 tcontext=u:object_r:device:s0 tclass=chr_file permissive=0

现象→原因→解决:

  • 现象:su后cat /dev/ttyUSB0可读,App却失败;
  • 原因:SELinux策略限制untrusted_app域访问device类型文件;
  • 解决:
    1. 临时方案:adb shell su -c 'setenforce 0'(仅调试);
    2. 永久方案:将App设为系统签名(platform.pk8签名),或定制ROM添加allow untrusted_app device:chr_file { open read write }规则。

5.2 Root权限的两种形态:suvsadb root

adb root开启的是adb daemon的root权限,不影响App进程;su命令启动的是独立root shell。SerialPort_Android的open()在App进程中执行,因此adb root无效,必须su提权。

# 验证App进程UID adb shell ps | grep your.package.name # 输出:u0_a123 12345 ... your.package.name # u0_a123 表示普通用户,非root

参数说明:u0_a123中u0是user id 0(即root用户空间),但a123是Android分配的app id,权限受SELinux严格隔离。真正的root进程UID为0。

5.3chmod的时效性:重启后失效,需init.rc持久化

chmod 666 /dev/ttyUSB0在设备重启后失效,因/dev/是tmpfs内存文件系统。某医疗设备每次开机都要手动chmod,产线工人投诉频繁。

# 在设备init.rc中添加(需root) on property:sys.usb.config=acm,mtp chmod 0666 /dev/ttyUSB0 chown root.dialout /dev/ttyUSB0

逻辑说明:init.rc是Android init进程的配置文件,on property触发器在USB配置变更时执行。chown root.dialout将设备节点属主设为root,属组为dialout,App进程加入dialout组即可访问(需系统签名或root)。


6. 实战验证:用hexdump和echo交叉验证通信链路是否真实打通

6.1 发送端验证:echo -ne '\x01\x03\x00\x00\x00\x06\x06\x06' > /dev/ttyUSB0

不要依赖App界面按钮。直接在adb shell中用echo发送原始字节,验证物理链路:

# 发送Modbus RTU指令:读保持寄存器0x0000,数量6个 adb shell su -c "echo -ne '\x01\x03\x00\x00\x00\x06\xc4\x0b' > /dev/ttyUSB0" # 注意:\x01是设备地址,\xc4\x0b是CRC16校验,必须准确

参数说明:-ne中-n不加换行符,-e启用转义。\x01表示十六进制字节0x01,非字符串"01"。CRC校验必须正确,否则外设丢弃帧。

6.2 接收端验证:hexdump -C /dev/ttyUSB0实时捕获响应

发送指令后,立即用hexdump监听响应,避免App层缓存干扰:

# 在另一终端执行 adb shell su -c "hexdump -C /dev/ttyUSB0" # 正常响应:00000000 01 03 0c 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| # 第一字节0x01是地址,第二字节0x03是功能码,第三字节0x0c是数据长度(12字节)

逻辑说明:hexdump -C以十六进制+ASCII双栏显示,00000000是偏移地址。若看到00000000后无数据,说明外设未响应或线路断开;若数据杂乱(如大量ff),可能是波特率不匹配。

6.3 App层闭环测试:用HandlerThread替代AsyncTask防ANR

demo常用AsyncTask做后台串口读写,但在Android 11+已被废弃,且doInBackground()中read()阻塞会导致AsyncTask线程池耗尽。

// 推荐方案:HandlerThread + Looper private HandlerThread mSerialThread; private Handler mSerialHandler; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mSerialThread = new HandlerThread("SerialThread"); mSerialThread.start(); mSerialHandler = new Handler(mSerialThread.getLooper()); } // 发送指令 mSerialHandler.post(() -> { try { serialPort.write(data, data.length); } catch (IOException e) { Log.e("Serial", "write failed", e); } });

参数说明:HandlerThread创建独立Looper线程,post()任务在此线程执行,避免阻塞UI线程。serialPort.write()在子线程调用是安全的,但需确保serialPort对象未被多线程并发访问。

我带过的某跨平台系统项目,曾因AsyncTask在串口忙时堆积30+任务导致ANR,改用HandlerThread后稳定性从92%提升至99.7%。现在我的习惯是:所有涉及read()/write()的代码,必须包裹在HandlerThread或ExecutorService中,且read()调用前必设VMIN=1,VTIME=0。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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