快速入门

虚拟 Modbus

qianmoQqianmoQ· 更新于 2026-10-01· 阅读 21 分钟· 0 次阅读

登录后可跨设备保存划线和私人笔记登录

如果你想开始使用 Apache PLC4X,但手边没有任何 PLC,本教程将演示如何使用虚拟 Modbus Slave 软件来模拟与支持 Modbus 的 PLC 之间的通信。

这样一个完全开源的软件是 ModbusPal,可以从此处获取。

你需要做的只是下载名为 ModbusPal.jar 的文件。

设置虚拟 Modbus 从站

要运行该软件,只需在下载 Jar 文件所在的目录中执行以下命令:

java -jar ModbusPal.jar

完成此步后,你将看到 ModbusPal 的界面,其外观如下:

ModbusPal 1

图 1. ModbusPal 主界面

在此请确保 TCP port 被设置为 502(这是默认的 Modbus 端口)。

接下来点击 Add 以添加一个新的 Modbus 从站:

ModbusPal 2

图 2. ModbusPal 添加从站界面

选择一个 Slave ID 以及一个 name。

点击 Add 之后,你的新 Modbus 从站便准备就绪,可以启动了。

ModbusPal 3

图 3. ModbusPal 主界面,其中一个从站已启动

点击 Run 按钮后,你的从站通常就可以进行通信了。

然而到目前为止,这个新从站还没有任何可共享的数据,因为我们尚未创建任何资源,所以它将是一个空的从站。

因此作为最后的步骤,我们需要创建一些可供访问的资源。

为此,请点击你新建从站的 eye 图标。

ModbusPal 4

图 4. ModbusPal 主界面,其中一个从站

这将打开一个编辑器,我们可以在其中添加一些所谓的 Holding registers 和一些 Coils。

让我们先从添加一些保持寄存器(Holding registers)开始。

请确保选中了 Holding registers,然后点击 Add 按钮:

ModbusPal 6

图 5. 保持寄存器视图

工具会建议创建 65536 个新寄存器,我通常会创建 10 个左右。

我在使用此工具时,有时不得不多次点击 Add 按钮,因为编辑器并不总是在第一次就能打开。

ModbusPal 7

图 6. 添加保持寄存器对话框

完成此步后,Holding register 标签页应如下所示:

ModbusPal 9

图 7. 添加保持寄存器对话框

现在在 Coils 标签页上执行相同的操作,完成后的效果应如下所示:

ModbusPal 8

图 8. 添加保持寄存器对话框

作为最后一步,我建议打开 Console。

你可以通过点击主界面的 Console 按钮来实现。

这将打开以下窗口:

ModbusPal 5

图 9. 控制台

ModbusPal 将在此处记录每一个重要事件……尤其是当收到来自 PLC4X 的新连接时。

Modbus 基础知识

总的来说,Modbus 只认识两种数据类型:Coils 和 Registers。

线圈(Coils)始终是简单的 bits 或 boolean 值,而寄存器(Registers)始终是 16 bit short values。

不过,这些类型各有不同,并且有不同的名称:

  • Discrete Inputs(布尔值)(只读)
  • Coils(布尔值)(读写)
  • Input Registers(16 位短整型值)(只读)
  • Holding Registers(16 位短整型值)(读写)

对于 Modbus 而言,地址并没有一个被普遍接受的表示法,不过由于地址非常简单,在 PLC4X 中我们按如下方式寻址:

Modbus 类型PLC4X 地址语法
离散输入(Discrete Inputs)discrete-input:{start-number} 或 discrete-input:{start-number}[{number-of-elements}]
线圈(Coils)coil:{start-number} 或 coil:{start-number}[{number-of-elements}]
输入寄存器(Input Registers)input-register:{start-number} 或 input-register:{start-number}[{number-of-elements}]
保持寄存器(Holding Registers)holding-register:{start-number} 或 holding-register:{start-number}[{number-of-elements}]

表 1. 表格标题

所有地址均支持带长度和不带长度的表示法。如果未指定长度,PLC4X 将默认长度为 1。

PLC4X 的连接字符串对于所有驱动程序通常具有相同的结构:

{driver-name}:{transport-name}://{transport-config}?{parameters}

片段 transport-name 和 parameters 通常是可选的,因为每个驱动都可以支持一种默认传输方式(default-transport),当省略 transport 时便会使用它。而且大多数驱动的实现方式并不要求任何参数,只有当连接需要一些非默认设置时才必须提供这些参数。

对于 Modbus,典型的传输方式有:

  • tcp(transport-config 是从站的 IP 或主机名,可以通过一个端口作为后缀,端口与 IP/主机之间用 : 分隔)
  • serial(transport-config 是串行接口的设备名称)

如果我们按照上文所述配置好了 ModbusPal 并在同台计算机上运行,那么用于连接它的连接字符串应为:

modbus-tcp://localhost

如果将 ModbusPal 启动在与 Modbus 默认端口 502 不同的端口上,那么你还需要在连接字符串中提供该端口。

因此,假设你将其启动在 1502 端口上,则连接字符串如下所示:

modbus-tcp://localhost:1502

编写代码

要编写一个有效的 PLC4X Java 应用,你只需添加对 api module 的依赖。使用 Maven 时,只需添加以下依赖:

    <dependency>
      <groupId>org.apache.plc4x</groupId>
      <artifactId>plc4j-api</artifactId>
      <version>1.0.0</version>
    </dependency>

这将使你能够编写出一个有效的应用,它能正常编译。然而,为了真正通过某个给定协议连接到设备,你需要将该协议的实现加入到类路径中。

例如,要使用 Modbus 协议进行通信,你需要添加如下依赖:

    <dependency>
      <groupId>org.apache.plc4x</groupId>
      <artifactId>plc4j-driver-modbus</artifactId>
      <version>1.0.0</version>
      <scope>runtime</scope>
    </dependency>

连接

因此,一旦你的项目中具备了 API 和驱动实现,你首先需要获取一个 PlcConnection 实例。这可以通过 PlcDriverManager 来完成,由它为给定的 PLC4X connection string 创建一个实例。

String connectionString = "modbus-tcp://localhost";

try (PlcConnection plcConnection = new PlcDriverManager().getConnection(connectionString)) {

  ... do something with the connection here ...

}

PLC4X 通常只支持非常有限的功能集,这并不是因为我们没有实现相关功能,而是因为 PLC 本身一般只支持非常有限的功能集。

PLC 所支持的、因而也是 PLC4X 所支持的基本功能包括:

  • 读取数据
  • 写入数据
  • 订阅数据
  • 在 PLC 中执行功能
  • 列出 PLC 中的资源

总体而言,我们会尽可能提供更多的功能。因此,如果某种协议不支持基于订阅的通信,我们的目标是通过后台轮询来模拟这一功能,从而对用户保持透明。

但在某些情况下,我们无法进行模拟,或者某些功能被有意禁用:

  • 如果 PLC 和/或协议不支持执行功能,我们就无法提供该功能。
  • 我们将提供精简版的驱动程序,例如有意不支持任何数据写入和功能执行。

因此,我们使用元数据以编程方式检查某项功能是否可用:

读取数据

// Check if this connection support reading of data.
if (!plcConnection.getMetadata().isReadSupported()) {
  logger.error("This connection doesn't support reading.");
  return;
}

一旦确认所需功能可用,即可着手构建第一个请求。具体做法是获取一个 PlcRequestBuilder:

// Create a new read request:
// - Give the single item requested the alias name "value"
PlcReadRequest.Builder builder = plcConnection.readRequestBuilder();
builder.addTagAddress("value-1", "coil:1");
builder.addTagAddress("value-2", "coil:3[4]");
builder.addTagAddress("value-3", "holding-register:1");
builder.addTagAddress("value-4", "holding-register:3[4]");
PlcReadRequest readRequest = builder.build();

所以,如你所见,你需要准备一个请求,方法是向请求中添加条目,并在最后调用 build 方法。这里第一个字符串值只是你给地址起的一个别名,因为这通常是一种更具可读性的方式。第二个参数则是地址字符串本身。

在上面的示例中,我们没有添加 discrete-input 和 input-registers,因为 ModbusPal 似乎不支持这些。

通过在请求对象上执行 execute 方法,即可将请求发送到 PLC:

CompletableFuture<? extends PlcReadResponse> asyncResponse = readRequest.execute();
asyncResponse.whenComplete((response, throwable) -> {
  ... process the response ...
});

一般来说,所有请求都是异步执行的。因此,一旦请求被完整处理,回调就会被调用:如果一切顺利,回调中将包含一个 readResponse;如果出现问题,则包含一个 throwable。

不过,如果你希望以更同步的方式来编写代码,下面的替代写法可以满足这一需求:

PlcReadResponse response = readRequest.execute().get();

两种情况下响应的处理方式完全相同。下面的示例将展示您可以使用的一些选项:

for (String tagName : response.getTagNames()) {
    if(response.getResponseCode(tagName) == PlcResponseCode.OK) {
        int numValues = response.getNumberOfValues(tagName);
        // If it's just one element, output just one single line.
        if(numValues == 1) {
            logger.info("Value[" + tagName + "]: " + response.getObject(tagName));
        }
        // If it's more than one element, output each in a single row.
        else {
            logger.info("Value[" + tagName + "]:");
            for(int i = 0; i < numValues; i++) {
                logger.info(" - " + response.getObject(tagName, i));
            }
        }
    }
    // Something went wrong, to output an error message instead.
    else {
        logger.error("Error[" + tagName + "]: " + response.getResponseCode(tagName).name());
    }
}

在 for 循环中,我们演示了用户如何遍历响应中的地址别名。对于普通的读取请求,这些别名将由请求中的项目预先确定;而在订阅响应的情况下,响应中可能只包含被订阅项目中的一部分。

在访问数据之前,建议先检查某个项目是否被正确返回。这是通过对给定别名调用 getResponseCode 方法来完成的。如果结果为 PlcResponseCode.OK,则一切正常;但也可能是以下几种情况之一:

  • NOT_FOUND
  • ACCESS_DENIED
  • INVALID_ADDRESS
  • INVALID_DATATYPE
  • INTERNAL_ERROR
  • RESPONSE_PENDING

假设返回码为 OK,我们便可以继续访问数据。

由于某些地址支持读取数组,用户可以通过 getNumberOfValues 方法检查返回了多少个给定类型的项目。为方便使用,响应对象提供了单参数方法用于访问数据,默认返回第一个元素。

response.getObject(fieldName)

若要访问指定的元素编号,请改用双参数版本:

response.getObject(fieldName, 42)

PLC4X 提供了针对多种 Java 类型的 getter 和 setter,并会自动处理类型转换。但是,例如尝试以 byte 类型获取一个 long 值,而该 long 值超出了较小类型所支持的范围时,就会抛出一个类型为 PlcIncompatibleDatatypeException 的 RuntimeException。不过,为了避免抛出该异常,存在 isValid{TypeName} 方法可供你用来检查该值是否兼容。

写入数据

写入的代码与读取的代码非常相似,只是在创建写请求时,我们需要在字段定义的同时传入数据。

虽然全部 4 种字段类型都支持读取,但只有 coil 和 holding-register 字段类型支持写入。如果你尝试向这些字段写入数据,将会导致错误。

因此,我们首先检查该驱动是否支持写入(某些驱动,例如被动模式驱动或只读版本,可能根本不支持写入):

// Check if this connection support reading of data.
if (!plcConnection.getMetadata().isWriteSupported()) {
  logger.error("This connection doesn't support writing.");
  return;
}

一旦确认某个功能可用,你就可以着手构建第一个请求了。方法是获取一个 PlcRequestBuilder:

// Create a new read request:
// - Give the single item requested the alias name "value"
PlcWriteRequest.Builder builder = plcConnection.writeRequestBuilder();
builder.addTagAddress("value-1", "coil:1", new PlcBOOL(true);
builder.addTagAddress("value-2", "coil:3[4]", new PlcBOOL(true), new PlcBOOL(false), new PlcBOOL(true), new PlcBOOL(true));
builder.addTagAddress("value-3", "holding-register:1", new PlcINT(42);
builder.addTagAddress("value-4", "holding-register:3[4]", new PlcINT(1), new PlcINT(2), new PlcINT(3), new PlcINT(4));
PlcWriteRequest writeRequest = builder.build();

你可以看到,对于每个需要写入的元素,都必须提供相同数量的参数。

PlcWriteResponse response = writeRequest.execute().get();

实际执行写操作与读操作几乎完全相同,唯一的区别在于,结果仅提供状态码,而不包含数值。

for (String fieldName : response.getFieldNames()) {
    if(response.getResponseCode(fieldName) == PlcResponseCode.OK) {
        logger.info("Value[" + fieldName + "]: successfully written to device.");
    }
    // Something went wrong, to output an error message instead.
    else {
        logger.error("Error[" + fieldName + "]: " + response.getResponseCode(fieldName).name());
    }
}

评论

登录后参与评论

正在加载评论…