前言
在上一篇文章中我们讲解了关于串口的基础知识,没有看过的同学推荐先看一下,否则你可能会不太理解这篇文章所述的某些内容。
这篇文章我们将讲解安卓端的串口通信实践,即如何使用串口通信实现安卓设备与其他设备例如PLC主板之间数据交互。
需要注意的是正如上一篇文章所说的,我目前的条件只允许我使用 ESP32 开发版烧录 Arduino 程序与安卓真机(小米10U)进行串口通信演示。
准备工作
由于我们需要使用 ESP32 烧录 Arduino 程序演示安卓端的串口通信,所以在开始之前我们应该先把程序烧录好。
那么烧录一个怎样的程序呢?
很简单,我这里直接烧了一个 ESP32 使用 9600 的波特率进行串口通信,程序内容就是 ESP32 不断的向串口发送数据 “e” ,并且监听串口数据,如果接收到数据 “o” 则打开开发版上自带的 LED 灯,如果接收到数据 “c” 则关闭这个 LED 灯。
代码如下:
#define LED 12
void setup() {
Serial.begin(9600);
pinMode(LED, OUTPUT);
}
void loop() {
if (Serial.available()) {
char c = Serial.read();
if (c == 'o') {
digitalWrite(LED, HIGH);
}
if (c == 'c') {
digitalWrite(LED, LOW);
}
}
Serial.write('e');
delay(100);
}
上面的 12 号 Pin 是这块开发版的 LED。
使用 Arduino自带串口监视器测试结果:
可以看到,确实如我们设想的通过串口不断的发送字符 “e”,并且在接收到字符 “o” 后点亮了 LED。
安卓实现串口通信
原理概述
众所周知,安卓其实是基于 Linux 的操作系统,所以在安卓中对于串口的处理与 Linux 一致。
在 Linux 中串口会被视为一个“设备”,并体现为 /dev/ttys
文件。
/dev/ttys
又被称为字符终端,例如 ttys0
对应的是 DOS/Windows 系统中的 COM1 串口文件。
通常,我们可以简单理解,如果我们插入了某个串口设备,则这个设备与 Linux 的通信会由 /dev/ttys
文件进行 “中转”。
即,如果 Linux 想要发送数据给串口设备,则可以通过往 /dev/ttys
文件中直接写入要发送的数据来实现,如:
echo test > /dev/ttyS1
这个命令会将 “test” 这串字符发送给串口设备。
如果想读取串口发送的数据也是一样的,可以通过读取 /dev/ttys
文件内容实现。
所以,如果我们在安卓中想要实现串口通信,大概率也会想到直接读取/写入这个特殊文件。
android-serialport-api
在上文中我们说到,在安卓中也可以通过与 Linux 一样的方式–直接读写 /dev/ttys
实现串口通信。
但是其实并不需要我们自己去处理读写和数据的解析,因为谷歌官方给出了一个解决方案:android-serialport-api
为了便于理解,我们会大致说一下这个解决方案的源码,但是就不上示例了,至于为什么,同学们往下看就知道了。另外,虽然这个方案历史比较悠久,也很长时间没有人维护了,但是并不意味着不能使用了,只是使用条件比较苛刻,当然,我司目前使用的还是这套方案(哈哈哈哈)。
不过这里我们不直接看 android-serialport-api 的源码,而是通过其他大佬二次封装的库来看: Android-SerialPort-API
在这个库中,通过
// 默认直接初始化,使用8N1(8数据位、无校验位、1停止位),path为串口路径(如 /dev/ttys1),baudrate 为波特率
SerialPort serialPort = new SerialPort(path, baudrate);
// 使用可选参数配置初始化,可配置数据位、校验位、停止位 - 7E2(7数据位、偶校验、2停止位)
SerialPort serialPort = SerialPort
.newBuilder(path, baudrate)
// 校验位;0:无校验位(NONE,默认);1:奇校验位(ODD);2:偶校验位(EVEN)
// .parity(2)
// 数据位,默认8;可选值为5~8
// .dataBits(7)
// 停止位,默认1;1:1位停止位;2:2位停止位
// .stopBits(2)
.build();
初始化串口,然后通过:
InputStream in = serialPort.getInputStream();
OutputStream out = serialPort.getOutputStream();
获取到输入/输出流,通过读取/写入这两个流来实现与串口设备的数据通信。
我们首先来看看初始化串口是怎么做的。
首先检查了当前是否具有串口文件的读写权限,如果没有则通过 shell 命令更改权限为 666
,更改后再次检查是否有权限,如果还是没有就抛出异常。
注意这里的执行 shell 时使用的 runtime 是 Runtime.getRuntime().exec(sSuPath);
也就是说,它是通过 root 权限来执行这段命令的!
换句话说,如果想要通过这种方式实现串口通信,必须要有 ROOT 权限!这就是我说我不会给出示例的原因,因为我手头的设备无法 ROOT 啊。至于为啥我司还能继续使用这种方案的原因也很简单,因为我们工控机的安卓设备都是定制版的啊,拥有 ROOT 权限不是基本操作?
确定权限可用后通过 open
方法拿到一个类型为 FileDescriptor
的变量 mFd
,最后通过这个 mFd
拿到输入输出流。
所以核心在于 open
方法,而 open 方法是一个 native 方法,即 C 代码:
private native FileDescriptor open(String absolutePath, int baudrate, int dataBits, int parity,
int stopBits, int flags);
C 的源码这里就不放了,只需要知道它做的工作就是打开了 /dev/ttys
文件(准确的说是“终端”),然后通过传递进去的这些参数去按串口规则解析数据,最后返回一个 java 的 FileDescriptor
对象。
在 java 中我们再通过这个 FileDescriptor
对象可以拿到输入/输出流。
原理说起来是十分的简单。
看完通信部分的原理后,我们再来看看我们如何查找可用的串口呢?
其实和 Linux 上也一样:
public Vector<File> getDevices() {
if (mDevices == null) {
mDevices = new Vector<File>();
File dev = new File("/dev");
File[] files = dev.listFiles();
if (files != null) {
int i;
for (i = 0; i < files.length; i++) {
if (files[i].getAbsolutePath().startsWith(mDeviceRoot)) {
Log.d(TAG, "Found new device: " + files[i]);
mDevices.add(files[i]);
}
}
}
}
return mDevices;
}
也是通过直接遍历 /dev
下的文件,只不过这里做了一些额外的过滤。
或者也可以通过读取 /proc/tty/drivers
配置文件后过滤:
Vector<Driver> getDrivers() throws IOException {
if (mDrivers == null) {
mDrivers = new Vector<Driver>();
LineNumberReader r = new LineNumberReader(new FileReader("/proc/tty/drivers"));
String l;
while ((l = r.readLine()) != null) {
// Issue 3:
// Since driver name may contain spaces, we do not extract driver name with split()
String drivername = l.substring(0, 0x15).trim();
String[] w = l.split(" +");
if ((w.length >= 5) && (w[w.length - 1].equals("serial"))) {
Log.d(TAG, "Found new driver " + drivername + " on " + w[w.length - 4]);
mDrivers.add(new Driver(drivername, w[w.length - 4]));
}
}
r.close();
}
return mDrivers;
}
关于读取可用串口设备,其实从这里的路径也可以看出,都是系统路径,也就是说,如果没有权限,大概率也是读取不到东西的。
这就是使用与 Linux 一样的方式去读取串口数据的基本原理,那么问题来了,既然我说这个方法使用条件比较苛刻,那么更易用的替代方案是什么呢?
我们下面就会介绍,那就是使用安卓的 USB host
(USB主机)的功能。
USB host
Android 3.1(API 级别 12)或更高版本的平台直接支持 USB 配件和主机模式。USB 配件模式还作为插件库向后移植到 Android 2.3.4(API 级别 10)中,以支持更广泛的设备。设备制造商可以选择是否在设备的系统映像中添加该插件库。
在安卓 3.1 版本开始,支持将USB作为主机模式(USB host)使用,而我们如果想要通过 USB 读取串口数据则需要依赖于这个主机模式。
在正式开始介绍USB主机模式前,我们先简要介绍一下安卓上支持的USB模式。
安卓上的USB支持三种模式:设备模式、主机模式、配件模式。
设备模式即我们常用的直接将安卓设备连接至电脑上,此时电脑上显示为 USB 外设,即可以当成 “U盘” 使用拷贝数据,不过现在安卓普遍还支持 MTP模式(作为摄像头)、文件传输模式(即当U盘用)、网卡模式等。
主机模式即将我们的安卓设备作为主机,连接其他外设,此时安卓设备就相当于上面设备模式中的电脑。此时安卓设备可以连接键盘、鼠标、U盘以及嵌入式应用USB转串口、转I2C等设备。但是如果想要将安卓设备作为主机模式可能需要一条支持 OTG 的数据线或转接头。(Micro-USB 或 USB type-c 转 USB-A 口)
而在 USB 配件模式下,外部 USB 硬件充当 USB 主机。配件示例可能包括机器人控制器、扩展坞、诊断和音乐设备、自助服务终端、读卡器等等。这样,不具备主机功能的 Android 设备就能够与 USB 硬件互动。Android USB 配件必须设计为与 Android 设备兼容,并且必须遵守 Android 配件通信协议。
设备模式与配件模式的区别在于在配件模式下,除了 adb 之外,主机还可以看到其他 USB 功能。
使用USB主机模式与外设交互数据
在介绍完安卓中的三种USB模式后,下面我们开始介绍如何使用USB主机模式。当然,这里只是大概介绍原生APi的使用方法,我们在实际使用中一般都都是直接使用大佬编写的第三方库。
准备工作
在开始正式使用USB主机模式时我们需要先做一些准备工作。
首先我们需要在清单文件(AndroidManifest.xml)中添加:
<!-- 声明需要USB主机模式支持,避免不支持的设备安装了该应用 -->
<uses-feature android:name="android.hardware.usb.host" />
<!-- …… -->
<!-- 声明需要接收USB连接事件 -->
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
一个完整的清单文件示例如下:
<manifest ...>
<uses-feature android:name="android.hardware.usb.host" />
<uses-sdk android:minSdkVersion="12" />
...
<application>
<activity ...>
...
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
</activity>
</application>
</manifest>
声明好清单文件后,我们就可以查找当前可用的设备信息了:
private fun scanDevice(context: Context) {
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val deviceList: HashMap<String, UsbDevice> = manager.deviceList
Log.i(TAG, "scanDevice: $deviceList")
}
将 ESP32 开发版插上手机,运行程序,输出如下:
可以看到,正确的查找到了我们的 ESP32 开发版。
这里提一下,因为我们的手机只有一个 USB 口,此时已经插上了 ESP32 开发版,所以无法再通过数据线直接连接电脑的 ADB 了,此时我们需要使用无线 ADB,具体怎么使用无线 ADB,请自行搜索。
另外,如果我们想要通过查找到设备后请求连接的方式连接到串口设备的话,还需要额外申请权限。(同理,如果我们直接在清单文件中提前声明需要连接的设备则不需要额外申请权限,具体可以看看参考资料5,这里不再赘述)
首先声明一个广播接收器,用于接收授权结果:
private lateinit var permissionIntent: PendingIntent
private const val ACTION_USB_PERMISSION = "com.android.example.USB_PERMISSION"
private val usbReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (ACTION_USB_PERMISSION == intent.action) {
synchronized(this) {
val device: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
device?.apply {
// 已授权,可以在这里开始请求连接
connectDevice(context, device)
}
} else {
Log.d(TAG, "permission denied for device $device")
}
}
}
}
}
声明好之后在 Acticity 的 OnCreate 中注册这个广播接收器:
permissionIntent = PendingIntent.getBroadcast(this, 0, Intent(ACTION_USB_PERMISSION), FLAG_MUTABLE)
val filter = IntentFilter(ACTION_USB_PERMISSION)
registerReceiver(usbReceiver, filter)
最后,在查找到设备后,调用 manager.requestPermission(deviceList.values.first(), permissionIntent)
弹出对话框申请权限。
连接到设备并收发数据
完成上述的准备工作后,我们终于可以连接搜索到的设备并进行数据交互了:
private fun connectDevice(context: Context, device: UsbDevice) {
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
CoroutineScope(Dispatchers.IO).launch {
device.getInterface(0).also { intf ->
intf.getEndpoint(0).also { endpoint ->
usbManager.openDevice(device)?.apply {
claimInterface(intf, forceClaim)
while (true) {
val validLength = bulkTransfer(endpoint, bytes, bytes.size, TIMEOUT)
if (validLength > 0) {
val result = bytes.copyOfRange(0, validLength)
Log.i(TAG, "connectDevice: length = $validLength")
Log.i(TAG, "connectDevice: byte = ${result.contentToString()}")
}
else {
Log.i(TAG, "connectDevice: Not recv data!")
}
}
}
}
}
}
}
在上面的代码中,我们使用 usbManager.openDevice
打开了指定的设备,即连接到设备。
然后通过 bulkTransfer
接收数据,它会将接收到的数据写入缓冲数组 bytes
中,并返回成功接收到的数据长度。
运行程序,连接设备,日志打印如下:
可以看到,输出的数据并不是我们预料中的数据。
这是因为这是非常原始的数据,如果我们想要读取数据,还需要针对不同的串口转USB芯片或协议编写驱动程序才能获取到正确的数据。
顺道一提,如果想要将数据写入串口数据的话可以使用 controlTransfer()
。
所以,我们在实际生产环境中使用的都是基于此封装好的第三方库。
这里推荐使用 usb-serial-for-android
usb-serial-for-android
使用这个库的第一步当然是导入依赖:
// 添加仓库
allprojects {
repositories {
...
maven { url 'https://jitpack.io' }
}
}
// 添加依赖
dependencies {
implementation 'com.github.mik3y:usb-serial-for-android:3.4.6'
}
添加完依赖同样需要在清单文件中添加相应字段以及处理权限,因为和上述使用原生API一致,所以这里不再赘述。
和原生 API 不同的是,因为我们此时已经知道了我们的 ESP32 主板的设备信息,以及使用的驱动(CDC),所以我们就不使用原生的查找可用设备的方法了,我们这里直接指定我们已知的这个设备(当然,你也可以继续使用原生API的查找和连接方法):
private fun scanDevice(context: Context) {
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val customTable = ProbeTable()
// 添加我们的设备信息,三个参数分别为 vendroId、productId、驱动程序
customTable.addProduct(0x1a86, 0x55d3, CdcAcmSerialDriver::class.java)
val prober = UsbSerialProber(customTable)
// 查找指定的设备是否存在
val drivers: List<UsbSerialDriver> = prober.findAllDrivers(manager)
if (drivers.isNotEmpty()) {
val driver = drivers[0]
// 这个设备存在,连接到这个设备
val connection = manager.openDevice(driver.device)
}
else {
Log.i(TAG, "scanDevice: 无设备!")
}
}
连接到设备后,下一步就是和数据交互,这里封装的十分方便,只需要获取到 UsbSerialPort
后,直接调用它的 read()
和 write()
即可读写数据:
port = driver.ports[0] // 大多数设备都只有一个 port,所以大多数情况下直接取第一个就行
port.open(connection)
// 设置连接参数,波特率9600,以及 “8N1”
port.setParameters(9600, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
// 读取数据
val responseBuffer = ByteArray(1024)
port.read(responseBuffer, 0)
// 写入数据
val sendData = byteArrayOf(0x6F)
port.write(sendData, 0)
此时,一个完整的,用于测试我们上述 ESP32 程序的代码如下:
@Composable
fun SerialScreen() {
val context = LocalContext.current
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = { scanDevice(context) }) {
Text(text = "查找并连接设备")
}
Button(onClick = { switchLight(true) }) {
Text(text = "开灯")
}
Button(onClick = { switchLight(false) }) {
Text(text = "关灯")
}
}
}
private fun scanDevice(context: Context) {
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val customTable = ProbeTable()
customTable.addProduct(0x1a86, 0x55d3, CdcAcmSerialDriver::class.java)
val prober = UsbSerialProber(customTable)
val drivers: List<UsbSerialDriver> = prober.findAllDrivers(manager)
if (drivers.isNotEmpty()) {
val driver = drivers[0]
val connection = manager.openDevice(driver.device)
if (connection == null) {
Log.i(TAG, "scanDevice: 连接失败")
return
}
port = driver.ports[0]
port.open(connection)
port.setParameters(9600, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
Log.i(TAG, "scanDevice: Connect success!")
CoroutineScope(Dispatchers.IO).launch {
while (true) {
val responseBuffer = ByteArray(1024)
val len = port.read(responseBuffer, 0)
Log.i(TAG, "scanDevice: recv data = ${responseBuffer.copyOfRange(0, len).contentToString()}")
}
}
}
else {
Log.i(TAG, "scanDevice: 无设备!")
}
}
private fun switchLight(isON: Boolean) {
val sendData = if (isON) byteArrayOf(0x6F) else byteArrayOf(0x63)
port.write(sendData, 0)
}
运行这个程序,并且连接设备,输出如下:
可以看到输出的是 byte 的 101,转换为 ASCII 即为 “e”。
然后我们点击 “开灯”、“关灯” 效果如下:
对了,这里发送的数据 “0x6F” 即 ASCII “o” 的十六进制,同理,“0x63” 即 “c”。
可以看到,可以完美的和我们的 ESP32 开发版进行通信。
实例
无论使用什么方式与串口通信,我们在安卓APP的代码层面能够拿到的数据已经是处理好了的数据。
即,在上一篇文章中我们说过串口通信的一帧数据包括起始位、数据位、校验位、停止位。但是我们在安卓中使用时一般拿到的都只有 数据位 的数据,其他数据已经在底层被解析好了,无需我们去关心怎么解析,或者使用。
我们可以直接拿到的就是可用数据。
这里举一个我之前用过的某型号驱动版的例子。
这块驱动版关于通信的信息如图:
可以看到,它采用了 RS485 的通信方式,波特率支持 9600 或 38400,8位数据位,无校验,1位停止位。
并且,它还规定了一个数据协议。
在它定义的协议中,第一位为地址;第二位为指令;第三位到第N位为数据内容;最后两位为CRC校验。
需要注意的是,这里定义的协议是基于串口通信的,不要把这个协议和串口通信搞混了,简单来说就是在串口通信协议的数据位中又定义了一个自己的协议。
而且可以看到,虽然定义串口参数时没有指定校验,但是在它自己的协议中指定了使用 CRC 校验。
另外,弱弱的吐槽一句,这个驱动版的协议真的不好使。
在实际使用过程中,主机与驱动版的通信数据无法保证一定会在同一个数据帧中发送完成,所以可能会造成“粘包”、“分包”现象,也就是说,数据可能会分几次发过来,而且你不好判断这数据是上次没发送完的数据还是新的数据。
我使用过的另外一款驱动版就方便的多,因为它会在帧头加上开始符号和数据长度,帧尾加上结束符号。
这样一来,即使出现“粘包”、“分包”我们也能很好的给它解析出来。
当然,它这样设计协议肯定是有它的道理的,无非就是减少通信代价之类的。
我还遇到过一款十分简洁的驱动版,直接发送一个整数过去表示执行对应的指令。
驱动版回传的数据同样非常简单,就是一个数字,然后事先约定各个数字表示什么意思……
说归说,我们还是继续来看这款驱动版的通信协议:
这是它的其中一个指令内容,我们发送指令 “1” 过去后,它会返回当前驱动版的型号和版本信息给我们。
因为我们的主板是定制工控主板,所以使用的通信方式是直接用 android-serialport-api。
最终发送与接收回复也很简单:
/**
* 将十六进制字符串转成 ByteArray
* */
private fun hexStrToBytes(hexString: String): ByteArray {
check(hexString.length % 2 == 0) { return ByteArray(0) }
return hexString.chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
}
private fun isReceivedLegalData(receiveBuffer: ByteArray): Boolean {
val rcvData = receiveBuffer.copyOf() //重新拷贝一个使用,避免原数据被清零
if (cmd.cmdId.checkDataFormat(rcvData)) { //检查回复数据格式
isPkgLost = false
if (cmd.cmdId.isResponseBelong(rcvData)) { //检查回复命令来源
if (!AdhShareData.instance.getIsUsingCrc()) { //如果不开启CRC检验则直接返回 true
resolveRcvData(cmdRcvDataCallback, rcvData, cmd.cmdId)
coroutineScope.launch(Dispatchers.Main) {
cmdResponseCallback?.onCmdResponse(ResponseStatus.Success, rcvData, 0, rcvData.size, cmd.cmdId)
}
return true
}
if (cmd.cmdId.checkCrc(rcvData)) { //检验CRC
resolveRcvData(cmdRcvDataCallback, rcvData, cmd.cmdId)
coroutineScope.launch(Dispatchers.Main) {
cmdResponseCallback?.onCmdResponse(ResponseStatus.Success, rcvData, 0, rcvData.size, cmd.cmdId)
}
return true
}
else {
coroutineScope.launch(Dispatchers.Main) {
cmdResponseCallback?.onCmdResponse(ResponseStatus.FailCauseCrcError, ByteArray(0), -1, -1, cmd.cmdId)
}
return false
}
}
else {
coroutineScope.launch(Dispatchers.Main) {
cmdResponseCallback?.onCmdResponse(ResponseStatus.FailCauseNotFromThisCmd, ByteArray(0), -1, -1, cmd.cmdId)
}
return false
}
}
else { //数据不符合,可能是遇到了分包,继续等待下一个数据,然后合并
isPkgLost = true
return isReceivedLegalData(cmd)
/*coroutineScope.launch(Dispatchers.Main) {
cmdResponseCallback?.onCmdResponse(ResponseStatus.FailCauseWrongFormat, ByteArray(0), -1, -1, cmd.cmdId)
}
return false */
}
}
// ……省略初始化和连接代码
// 发送数据
val bytes = hexStrToBytes("0201C110")
outputStream.write(bytes, 0, bytes.size)
// 解析数据
val recvBuffer = ByteArray(0)
inputStream.read(recvBuffer)
while (receiveBuffer.isEmpty()) {
delay(10)
}
isReceivedLegalData()
本来打算直接发我封装好的这个驱动版的协议库的,想了想,好像不太合适,所以就大概抽出了这些不完整的代码,懂这个意思就行了,哈哈。
总结
从上面介绍的两种方式可以看出,两种方式使用各有优缺点。
使用 android-serialport-api 可以直接读取串口数据内容,不需要转USB接口,不需要驱动支持,但是需要 ROOT,适合于定制安卓主板上已经预留了 RS232 或 RS485 接口且设备已 ROOT 的情况下使用。
而使用 USB host ,可以直接读取USB接口转接的串口数据,不需要ROOT,但是只支持有驱动的串口转USB芯片,且只支持使用USB接口,不支持直接连接串口设备。
各位可以根据自己的实际情况灵活选择使用什么方式来实现串口通信。
当然,除了现在介绍的这些串口通信,其实还有一个通信协议在实际使用中用的非常多,那就是 MODBUS 协议。
下一篇文章,我们将介绍 MODBUS。
参考资料
- android-serialport-api
- What is tty?
- Text-Terminal-HOWTO
- Terminal Special Files
- USB host
- Android开启OTG功能/USB Host API功能