安卓与串口通信-modbus篇

前言

在之前的两篇文章中,我们讲解了串口的基础知识和在安卓中使用串口通信的方法,如果还没看过之前文章的同学们,建议先看一遍,不然可能会不理解这篇文章讲的某些内容。

事实上,在实际应用中,我们很少会直接使用串口通信,一般都会使用到 Modbus。

因为正如我上篇文章所说,如果直接使用串口通信的话,需要我们自定义数据层协议,或者干脆就直接发送一个 byte 的数字进行通信,这显然是不方便的,也不安全的。

例如我上篇提到过的一个问题,我所使用的驱动版厂商定义的协议中没有定义数据长度(或者在数据中附上数据长度),也没有定义停止符号,这会导致出现“沾包”或“分包”情况时不好区分数据。

并且自定义协议还需要自己去解析并处理数据,使用起来不是那么方便。

所以,我司在尝试过直接使用串口通信后,最终还是决定放弃直接使用串口通信,而是改用 Modbus 通信。

本篇文章属于系列文章的扩展篇,我们将讲解 Modbus 的基础知识以及如何在安卓中使用 Modbus。

本文中部分图表来自文末标注的参考资料

Modbs 基础

简介

Modbus 是一种应用层报文传输协议,由 Modicon 公司在 1979 年发布,是为了解决 PLC 通信而研发的协议。

因为 Modbus 是开源的且无著作权要求、易于部署维护、可靠性强的特性,所以 Modbus 已经成为工业领域通信协议事实上的业界标准,并且现在是工业电子设备之间常用的连接方式。

由于 Modbus 定义的只是应用层的报文协议,所以它可以使用串口(RS232、RS485)、以太网作为物理层接口。

Modbus 分为三种传输模式:RTU、ASII、TCP。

在使用 Modbus 时,所有设备的传输模式必须相同。

RTU 使用二进制数据传输、ASCII 使用 ASCII 字符传输。

使用串口连接时支持 RTU 和 ASCII 模式。

使用以太网连接时支持 TCP 模式。

因为本系列文章的重点在于讲解串口通信,所以我们不过多讲解 TCP 模式,同时,由于 ASCII 模式在目前实际应用中比较少,我们一般都是使用的 RTU 模式。故,我们会重点讲解 Modbus RTU。如果对其他传输模式感兴趣的可以阅读参考资料 4 的文档。

额外说明一下,Modbus 和 RS232、RS485 的区别。

RS232、RS485定义的是物理层标准,即接线方式,电平高低,数据传输方式等。

而 Modbus 是应用层协议,即定义了上述物理层传输过来的数据应该以什么样的格式去解析。

Modbus RTU

使用串口作为物理层协议时,通常采用的是 RS485 。

而我们在第一篇文章就说过,RS485 支持一主多从多个设备同时连接,所以使用 RS485 的 Modbus 同样支持多个设备连接。在标准负载情况下,支持一个主机连接最多32个从机。并且在连接设备时,只能使用菊花链连接,不能使用星型网络:

1

另外,Modbus 是一种请求/应答协议,即只能通过主站(主机)发送请求给从站后,从站响应数据给主站,而不能从站直接主动发送数据给主站。

储存区数据模型

在 Modbus 中定义了4种不同的数据模型,具体如下:

名称 数据类型 访问类型 说明
离散量输入 单个比特(bit) 只读 I/O系统提供
线圈 单个比特(bit) 读写 可通过应用程序改写
输入寄存器 字(word,16bit) 只读 I/O系统提供
保持寄存器 字(word,16bit) 读写 可通过应用程序改写

其中 线圈 和 离散量输入 又可以称为 输出线圈 和 输入线圈。

它们的数据长度都是一个 bit,即只能表示 1 或 0,表现在程序中就是一个 Boolean 类型的数据。对于安卓程序员来说,可能会疑惑啥是线圈,其实这两个模型之所以叫做线圈是因为 Modbus 是为了 PLC 通信而编写的协议,而在 PLC 中一些物理设备(例如继电器)只有两种状态:断开与接通(即 0 或 1 ,或者 Boolean 的 false 与 true ),这些物理设备的状态切换一般都是依赖于线圈的通/断电来实现,所以在 Modbus 中就将这种类型的数据称为 线圈。

而 输入寄存器 和 保持寄存器 又可以称为 输入寄存器 和 输出寄存器。

它们的数据长度是一个 word,即 16 bit,2 byte,表现在程序中可以看成一个 Int 类型。

显然,在同一个设备中不同的数据模型肯定不止一个可用的数据区块,理论上来说,每种数据模型最大可以定义 65536 个数据区块。

因此,每种数据模型的地址定义为如下:

数据模型 地址范围
线圈 00001-09999
离散输入 10001-19999
输入寄存器 30001-39999
保持寄存器 40001-49999

可以看到,虽然我们上面说每种模型理论上支持 65536 个数据区块,但是实际使用中每种数据模型一般都只会定义最大 10000 个数据区块。

Modbus 允许将四种不同的数据模型存放在不同的数据区块,这样使用不同的功能码(下面会说什么是功能码)读到的是不同的数据:

2

同时,Modbus 也可以将不同的数据模型映射到同一个数据区块中,这样一来,不同的功能码读取到的可能是相同的数据:

3

功能码

在上一节我们介绍了储存区数据模型,那么我们要如何去读取不同的数据模型数据呢?或者说,在 Modbus 中是怎么区分不同的数据模型?

此时,就要用到 功能码。

在 Modbus 中定义了三种类型的功能码:

  • 公共功能码 : Modbus 组织定义的标准的公开的通用的功能码,包括已定义的和保留的功能码
  • 用户自定义功能码 : 用户可以自定义自己需要的功能码,范围在 65-72 和 100-110(都是十进制)之间。
  • 保留功能码 : 一些公司的传统设备中使用的功能码,对公共功能码无效。

4

公共功能码定义了如下几种:

5

而我们一般会使用到的有以下几种:

6

可以看到,我们常用的有 8 个功能码,其实仔细一看就能看出不过是读所有数据模型;以及可写数据模型和写单个/写多个的排列组合。

读取数据时所有数据模型均支持只读取单个和同时读取多个数据,并且使用的都是同一个功能码。

写入数据同样支持只写入单个数据和同时写入多个数据,但是写入单个和写入多个的功能码是分开的。

可能有细心的读者发现了,为什么表中的所有 寄存器地址 都是一样的啊,这是因为上表中的 PLC 地址使用的是绝对地址,一般用于文档中或程序中。

而实际设备的寄存器地址则使用的是相对地址。由于我们已经通过功能码区分开了不同的数据区块,所以为了节约传输时的字节占用,直接使用相对地址即可(如果使用绝对地址,那么现在的字节数不够表示所有地址)。

主/从站

上文中提到过,使用串口的 Modbus 是主-从协议。即,在同一时刻,只有一个主节点和一个或多个子节点连接在同一个串行总线上。

Modbus 的通信总是由主节点发起,子节点响应。并且子节点之间不会相互通信。

在 Modbus 中,主节点没有地址,每个子节点都有自己唯一的地址(1-247),通常称为从站地址。

主节点有两种方式发出请求:单播模式与广播模式。

7

在单播模式中,主站(主节点)发送一个带有从站(子节点)地址的请求给当前连接的所有设备,但是只有从站地址符合的从站会响应该请求,并返回数据。其他设备不会响应也不会执行任何操作(读取到地址不符合后直接抛弃这个请求报文)。在这个模式中会产生两个报文:主站的请求报文和从站的响应报文。

在广播模式中所有从站都不会发送响应报文给主站,但是会执行请求的操作,并且主站的请求会发送给所有从站。广播模式一般用于写数据。此时主站发送的请求报文中的从站地址为 0 ,表示广播。

数据帧

一个 Modbus RTU 的报文帧由 4 个部分组成:

8位从站地址+8位功能码+最大252*8位数据+16位差错校验

8

在 RTU 中通常使用的错误校验方式是 CRC 校验(眼熟吗?CRC 又出现了)

不知道你们有没有发现,这里的功能码使用了 2 byte ,但是上面介绍功能码时明明最大才到 127 ,那么剩下的一半去哪儿呢?

在 Modbus 定义中,从机如果能够正确处理主机的请求,则返回报文中的功能码将和主机请求的功能码一样,如果出现错误,无法正确的处理请求,则从机返回报文的功能码将是最高位为 1 的功能码,即 128-255 。

数据位在不同的功能码以及主机请求还有从机响应都有不同的数据内容和长度,例如请求读取线圈则数据位的内容为:2字节数据表示读取线圈起始地址+2字节数据表示要读取的线圈数量。

此时从机将会按照请求读取的线圈数量返回数据,数据格式为:1字节表示数据的字节数+N字节表示读取到线圈状态数据。如果读取到的线圈状态数据不是 8 位的整数,则会在后面填充 0 使其满足 8 位的倍数。

9

数据位在某些情况下,可以为空。

下面举一个数据帧的完整例子(例子来自参考资料 1)。

我们有一个从站是温湿度传感器,从站地址为 1,它会将采集到的湿度写入保持寄存器的 40001 区块中;温度写入保持寄存器的 40002 区块中。此时我们发送读取保持寄存器请求去获取它的温湿度信息。

则,主机的请求报文为:

0103040146013B5A59

分别拆解这个数据帧为:

01 :从站地址

03 :功能码,读保持寄存器

00 00 : 读取的起始寄存器地址(对应 40001 的相对地址)

00 02 :读取的寄存器长度(这里表示连续读取两个寄存器)

C4 0B : CRC校验码

从机在接收到请求后,响应报文为:

0103040146013B5A59

拆解数据:

01:从站地址

03: 功能码,读保持寄存器

04 :读取到的数据的字节长度(这里表示4字节)

01 46 01 3B :读取到的数据,前两个字节为湿度(换算成十进制为 326 ,即 32.6% ),后两个字节为温度(十进制为 315,即 31.5 摄氏度)

5A 59 : CRC校验码

这里提一句,别纠结为啥读取到的温湿度的值要除以 10 才是实际值,因为这是温湿度传感器厂家定义的。

在安卓中使用 Modbus

经过上面的介绍,相信大家已经对于 Modbus 有了一个大致的了解。

那么,如何在安卓中使用 Modbus 呢?如果你理解了 Modbus 的基础,并且前面的两篇文章也大致理解了,那么这就不是问题了。

核心思路就是通过上篇文章介绍的使用 android-serialport-api 或使用 USB Host 的方法打开串口,并获取到输入输出流,然后在发送和接收数据时按照 Modbus 协议标准封装或解析即可。

其中如何打开串口以及获取输入输出流已经在上篇文章介绍,因此现在需要解决的是如何封装/解析数据。

当然,你可以按照 Modbus 标准文档自己动手写一个。

或者,你也可以不用重复造轮子,直接使用现成的第三方库。

这里我们可以使用 modbus4j,但是,从它的名字就可以看出来,这是一个 java 库,好在我们只需要使用它的解析和封装的功能,所以在安卓中依旧可以使用。

modbus4j

老规矩,使用 modbus4j 前需要先引入依赖:

// 添加仓库地址
repositories {
	...
	maven { url 'https://jitpack.io' }
}

……

// 添加依赖
implementation 'com.github.MangoAutomation:modbus4j:3.1.0'

然后在正式使用之前,我们需要新建一个类继承自 SerialPortWrapper ,用于实现在安卓上的串口功能:

class AndroidWrapper : SerialPortWrapper {
    // 关闭串口
    override fun close() {
        TODO("Not yet implemented")
    }

    // 打开串口
    override fun open() {
        TODO("Not yet implemented")
    }

    // 获取输入流
    override fun getInputStream(): InputStream {
        TODO("Not yet implemented")
    }

    // 获取输出流
    override fun getOutputStream(): OutputStream {
        TODO("Not yet implemented")
    }

    // 获取波特率
    override fun getBaudRate(): Int {
        TODO("Not yet implemented")
    }

    // 获取数据位
    override fun getDataBits(): Int {
        TODO("Not yet implemented")
    }

    // 获取停止位
    override fun getStopBits(): Int {
        TODO("Not yet implemented")
    }

    // 获取校验位
    override fun getParity(): Int {
        TODO("Not yet implemented")
    }
}

在我们新建的这个类中重写上述几个方法,用于提供串口通信所需要的几个参数即可。

然后,初始化 modbus4j 并发送消息:

val modbusFactory = ModbusFactory()

val wrapper: SerialPortWrapper = AndroidWrapper()

// 创建管理对象
val master = modbusFactory.createRtuMaster(wrapper)
    
// 发送消息
val request = ……
val response = master.send(request) // requst 为要发送的数据,response 为接收到的响应数据

上面就是 modbus4j 的简单使用方法,如果同学们甚至都不想自己去完成串口通信的话,还可以用这个库 Modbus4Android ,这个库基于 android-serialport-api 和 上面的 modbus4j 封装了一个安卓上到手即用的 Modbus 库。

不过它使用的是 android-serialport-api 实现串口通信,如果需要使用 USB Host 的话可能还是需要自己去封装一个库了。(等我找到合适的测试设备后抽空我也封装一个)

并且,这个库使用了 RxJava 如果不喜欢 RxJava 的话也得自己封装一个了,其实封装起来也不算难,完全可以基于这个库自己改一改就好了。

Modbus4Android

使用这个库的第一步,依旧是导入依赖:

// 添加远程仓库
repositories {
   maven { url 'https://jitpack.io' }
}

……

// 添加依赖
dependencies {
   implementation 'com.github.licheedev:Modbus4Android:2.0.2'
}

接下来,为了方便使用,同时为了避免重复初始化,我们可以创建一个全局单例实例 ModbusManager

class ModbusManager : ModbusWorker() {



    /**
     * 释放整个ModbusManager,单例会被置null
     */
    @Synchronized
    override fun release() {
        super.release()
        sInstance = null
    }

    companion object {
        @Volatile
        private var sInstance: ModbusManager? = null
        fun getInstance(): ModbusManager {
            var manager = sInstance
            if (manager == null) {
                synchronized(ModbusManager::class.java) {
                    manager = sInstance
                    if (manager == null) {
                        manager = ModbusManager()
                        sInstance = manager
                    }
                }
            }
            return manager!!
        }
    }
}

然后初始化串口连接:

private fun initConnect(): Boolean {
    Log.i(TAG, "initConnect: 开始初始化连接 Modbus\nconfig=$config")

    val param = SerialParam
        .create(config.serialPath, config.serialRate) // 串口地址和波特率
        .setDataBits(config.serialDataBits) // 数据位
        .setParity(config.serialParity) // 校验位
        .setStopBits(config.serialStopBits) // 停止位
        .setTimeout(config.serialTimeout)  //超时时间
        .setRetries(config.serialRetries) // 重试次数

    try {
        // 初始化前先关闭,避免串口已经被打开过
        ModbusManager.getInstance().closeModbusMaster()
        val modbusMaster = ModbusManager.getInstance().syncInit(param)
        return true
        // 初始化(打开串口)成功
    } catch (e: ModbusInitException) {
        Log.e(TAG, "initConnect: 初始化modbus出错!", e)
    } catch (e: InterruptedException) {
        Log.e(TAG, "initConnect: 初始化modbus出错!", e)
    } catch (e: ExecutionException) {
        Log.e(TAG, "initConnect: 初始化modbus出错!", e)
    } catch (e: ModbusTransportException) {
        Log.e(TAG, "initConnect: 初始化modbus出错!", e)
    } catch (e: ModbusRespException) {
        Log.e(TAG, "initConnect: 初始化modbus出错!", e)
    }
    return false
}

完成上述步骤后,我们就可以开始发送请求并接收数据了。

这里依旧以读取线圈数据为例,我们可以使用同步请求:

val slaveId = 1 // 从站地址
val start = 00001 // 读取的起始位置
val len = 1 // 需要读取的长度

val response = ModbusManager.getInstance().syncReadCoil(slaveId, start, len)

其中的 response 即为响应数据信息。

另外,我们也可以使用异步读取的方式:

ModbusManager.getInstance().readCoil(slaveId, start, len, object : ModbusCallback<ReadCoilsResponse> {
    override fun onSuccess(response: ReadCoilsResponse?) {
        // 请求成功,收到回复为 response
    }
    override fun onFailure(tr: Throwable?) {
        // 请求失败
    }
    override fun onFinally() {
        // 请求完成
    }
})

该库支持的所有读取方法如下:

10

所有写数据方法如下:

11

总结

我们在这篇文章中介绍了在安卓中使用串口通信时大概率会接触到的一种应用层协议 – Modbus,并讲解了如何在安卓中使用 Modbus ,另外介绍了几个个人认为比较好用的第三方库。

自此,关于安卓上的串口通信内容就讲的差不多了。

下一篇看情况写一写各个校验方法的原理和算法实现或者是上文中挖的使用 USB Host 实现 Modbus 的坑,也可能这个系列就此完结吧,哈哈。

参考资料

  1. 这节课带你吃透Modbus通信协议
  2. 6分钟快速理解Modbus通信协议!
  3. Modbus
  4. modbus_proto_cn.pdf