ESP32 使用RS485模块实现Modbus通信(eModbus)

MODBUS是一种广泛使用的工业通信协议,它允许通过串行线路在不同设备之间进行通信和数据交换。RS485模块是一个在ESP32上实现MODBUS协议的硬件。在本教程中,我们将使用RS485模块在ESP32开发板上创建一个MODBUS主机和从机设备,并实现与MODBUS主机的通信。

实验效果

多个Modbus(Server)从机设备与一个Modbus主机(Client)设备进行通信。

元件说明

mnb4v465

  1. 使用工业级芯片,传输距离可达上千米
  2. 具有高达正负15KV的防静电保护
  3. 芯片内置限摆率控制,大大减少信号干扰
  4. 接收器输入阻抗仅1/4单位,支持多设备连接,可连接128个设备
  5. 工作温度范围广,-40°C到85°C正常工作
  6. 支持热插拔,不会出现信号锁死问题
  7. 使用贴片大容量电解电容进行电源滤波
  8. 双瞬态抑制二极管过压保护
  9. 10欧电流保护电阻,提高信号完整性
  10. RS485和TTL信号单面布线,保证信号质量
  11. 大面积铺铜层,提高抗干扰能力
  12. 提供2.54mm间距洞洞焊接,方便二次开发
  13. 常用M3螺钉固定孔,安装可靠
  14. 120欧端接电阻,支持自动适配
  15. 提供发射/接收指示灯,方便调试
  16. 弯角插针设计,方便测试
  17. 支持3.3V和5V控制,兼容性强

引脚说明

  • RXD —— 接受数据
  • TXD ——传输数据
  • VCC —— 5V供电
  • GND —— 接地
  • A —— 非反相接收器输入和非反相驱动器输出
  • B —— 反相接收器输入和反相驱动器输出

BOM表

  • ESP32 N个
  • RS485模块 N个
  • 跳线
  • 屏蔽信号线

接线图

gfd98g8hg8fh7

ESP32 连接 RS485模块
GND <-> GND
GPIO 17 <-> RXD
GPIO 16 <-> TXD
5V <-> VCC

jkh543lk5h6h

把所有RS485的模块的A和B,分别A连接A,B连接B,全部连接起来。

安装库

使用的库 eModbus

文档:https://emodbus.github.io/

github:https://github.com/eModbus/eModbus

程序代码

在Modbus中,主机(Client)和从机(Server/Slave)的概念与一般理解的不一样。我的理解是主机(Client)是不断访问所有设备的主设备,所以也是一个访问者(Client)。从机(Sever)就是根据主机(Client)的访问命令返回特定信息。

主机代码 Client

以下代码,会在Modbus总线上访问ID为1的设备。分别实现功能READ_HOLD_REGISTERWRITE_MULT_REGISTERS

// =================================================================================================
// eModbus: Copyright 2020 by Michael Harwerth, Bert Melis and the contributors to ModbusClient
//               MIT license - see license.md for details
// =================================================================================================

// Example code to show the usage of the eModbus library. 
// Please refer to root/Readme.md for a full description.

// Includes: <Arduino.h> for Serial etc.
#include <Arduino.h>

// Include the header for the ModbusClient RTU style
#include "ModbusClientRTU.h"

// Create a ModbusRTU client instance
// In my case the RS485 module had auto halfduplex, so no parameter with the DE/RE pin is required!
ModbusClientRTU MB;

// Define an onData handler function to receive the regular responses
// Arguments are Modbus server ID, the function code requested, the message data and length of it, 
// plus a user-supplied token to identify the causing request
void handleData(ModbusMessage response, uint32_t token) 
{
  Serial.printf("Response: serverID=%d, FC=%d, Token=%08X, length=%d:\n", response.getServerID(), response.getFunctionCode(), token, response.size());
  for (auto& byte : response) {
    Serial.printf("%02X ", byte);
  }
  Serial.println("");
}

// Define an onError handler function to receive error responses
// Arguments are the error code returned and a user-supplied token to identify the causing request
void handleError(Error error, uint32_t token) 
{
  // ModbusError wraps the error code and provides a readable error message for it
  ModbusError me(error);
  Serial.printf("Error response: %02X - %s\n", (int)me, (const char *)me);
}

// Setup() - initialization happens here
void setup() {
// Init Serial monitor
  Serial.begin(115200);
  while (!Serial) {}
  Serial.println("__ OK __");

// Set up Serial2 connected to Modbus RTU
// (Fill in your data here!)
  RTUutils::prepareHardwareSerial(Serial2);
  Serial2.begin(19200, SERIAL_8N1, GPIO_NUM_17, GPIO_NUM_16);

// Set up ModbusRTU client.
// - provide onData handler function
  MB.onDataHandler(&handleData);
// - provide onError handler function
  MB.onErrorHandler(&handleError);
// Set message timeout to 2000ms
  MB.setTimeout(2000);
// Start ModbusRTU background task
  MB.begin(Serial2);

// We will first read the registers, then write to them and finally read them again to verify the change

// Create request for
// (Fill in your data here!)
// - server ID = 1
// - function code = 0x03 (read holding register)
// - address to read = word 33
// - data words to read = 6
// - token to match the response with the request.
//
// If something is missing or wrong with the call parameters, we will immediately get an error code 
// and the request will not be issued
  uint32_t Token = 1111;

  Error err = MB.addRequest(Token++, 1, READ_HOLD_REGISTER, 33, 6);
  if (err!=SUCCESS) {
    ModbusError e(err);
    Serial.printf("Error creating request: %02X - %s\n", (int)e, (const char *)e);
  }

// Create request for
// (Fill in your data here!)
// - server ID = 1
// - function code = 0x16 (write multiple registers)
// - address to write = word 33ff
// - data words to write = see below
// - data bytes to write = see below
// - token to match the response with the request.
//
  uint16_t wData[] = { 0x1111, 0x2222, 0x3333, 0x4444, 0x5555, 0x6666 };

  err = MB.addRequest(Token++, 1, WRITE_MULT_REGISTERS, 33, 6, 12, wData);
  if (err!=SUCCESS) {
    ModbusError e(err);
    Serial.printf("Error creating request: %02X - %s\n", (int)e, (const char *)e);
  }

// Create request for
// (Fill in your data here!)
// - server ID = 1
// - function code = 0x03 (read holding register)
// - address to read = word 33
// - data words to read = 6
// - token to match the response with the request.
//
  err = MB.addRequest(Token++, 1, READ_HOLD_REGISTER, 2, 2);
  if (err!=SUCCESS) {
    ModbusError e(err);
    Serial.printf("Error creating request: %02X - %s\n", (int)e, (const char *)e);
  }

// The output on the Serial Monitor will be (depending on your Modbus the data will be different):
//      __ OK __
//      Response: serverID=1, FC=3, Token=00000457, length=15:
//      01 03 0C 60 61 62 63 64 65 66 67 68 69 6A 6B
//      Response: serverID=1, FC=16, Token=00000458, length=19:
//      01 10 00 21 00 06 0C 11 11 22 22 33 33 44 44 55 55 66 66
//      Response: serverID=1, FC=3, Token=00000459, length=15:
//      01 03 0C 11 11 22 22 33 33 44 44 55 55 66 66
}

// loop() - nothing done here today!
void loop() {
}

从机代码 Server

以下代码,实现从收到Modbus指令后,返回特定功能码信息。

// =================================================================================================
// eModbus: Copyright 2020 by Michael Harwerth, Bert Melis and the contributors to ModbusClient
//               MIT license - see license.md for details
// =================================================================================================
// Includes: <Arduino.h> for Serial etc., WiFi.h for WiFi support
#include <Arduino.h>
#include "HardwareSerial.h"

// Modbus server include
#include "ModbusServerRTU.h"

// Create a ModbusRTU server instance listening with 2000ms timeout
ModbusServerRTU MBserver(2000);

// FC03: worker do serve Modbus function code 0x03 (READ_HOLD_REGISTER)
ModbusMessage FC03(ModbusMessage request) {
  uint16_t address;           // requested register address
  uint16_t words;             // requested number of registers
  ModbusMessage response;     // response message to be sent back

  // get request values
  request.get(2, address);
  request.get(4, words);

  // Address and words valid? We assume 10 registers here for demo
  if (address && words && (address + words) <= 10) {
    // Looks okay. Set up message with serverID, FC and length of data
    response.add(request.getServerID(), request.getFunctionCode(), (uint8_t)(words * 2));
    // Fill response with requested data
    for (uint16_t i = address; i < address + words; ++i) {
      response.add(i);
    }
  } else {
    // No, either address or words are outside the limits. Set up error response.
    response.setError(request.getServerID(), request.getFunctionCode(), ILLEGAL_DATA_ADDRESS);
  }
  return response;
}

// Setup() - initialization happens here
void setup() {
// Init Serial monitor
  Serial.begin(115200);
  while (!Serial) {}
  Serial.println("__ OK __");

// Init Serial2 connected to the RTU Modbus
// (Fill in your data here!)
  RTUutils::prepareHardwareSerial(Serial2);
  Serial2.begin(19200, SERIAL_8N1, GPIO_NUM_17, GPIO_NUM_16);

// Register served function code worker for server 1, FC 0x03
  MBserver.registerWorker(0x01, READ_HOLD_REGISTER, &FC03);

// Start ModbusRTU background task
  MBserver.begin(Serial2);
}

// loop() - nothing done here today!
void loop() {
  delay(10000);
}

MODBUS的基本概念

由于时代的发展,使用的设备越来越先进,Modbus的开发也变得更加灵活。

线圈和寄存器

在 Modbus 的内容中多次提到线圈(coil)和寄存器(register)的概念,尤其是 Moddbus功能码中,操作的对象基本上都是线圈和寄存器。

在 Modbus 协议中之所以仍然称为线圈和寄存器,完全是历史原因。在 PLC 应用领域,一个线圈就代表一个 PLC 输出点,也称为输出继电器。通过控制线圈导通与否来改变继电器输出状态,实现弱电控制强电。

4lhk53lhk36

但实际上,在如今的 Modbus 设备中,它们都只是对应一块内存区域而已。其中,线圈代表位操作(bit),表示一个布尔变量;寄存器代表字操作(word),表示一个整型变量(当然也可以通过多个字的组合,表示浮点数以及其他复合数据结构)。在 Modbus 协议中,字(word)的长度是 16 位,即 2 个字节。

寄存器种类说明

在 Modbus 协议中,所有数据均存放于寄存器中。根据存放的数据类型以及各自读写特性,可以将寄存器分为四个部分,这四个部分可以连续也可以不连续,完全由开发者决定。

下表展示了四类寄存器的含义以及与 PLC 的类比。

寄存器种类 含义 PLC 示例
线圈状态 (Coil Status) 输出端口(可读可写) DO(数字量输出) 电磁阀输出、LED 显示
离散输入状态 (Input Status) 输入端口(只读) DI(数字量输入) 拨码开关、微动开关
保持寄存器 (Holding Register) 输出参数(可读可写) AO(模拟量输出) PID 运行参数、阈值上下限
输入寄存器 (Input Register) 输入参数(只读) AI(模拟量输入) 传感器数据输入

寄存器地址分配

Modbus 寄存器地址分配如下表所示,同样参照了 PLC 寄存器地址的分配方法。

寄存器种类 寄存器PLC地址 寄存器Modbus协议地址 简称
线圈状态 00001~09999 0000H~FFFFH 0x
离散输入状态 10001~19999 0000H~FFFFH 1x
保持寄存器 40001~49999 0000H~FFFFH 4x
输入寄存器 30001~39999 0000H~FFFFH 3x

该表中的 PLC 地址可以理解为 Modbus 协议地址的变种,在触摸屏和 PLC 编程中应用较为广泛。

  • 寄存器 PLC 地址指存放于控制器中的地址,这些控制器可以是 PLC,也可以是触摸屏,或者文本显示器。PLC 地址一般采用十进制描述,共有 5 位,其中第一位数字代表寄存器类型。
  • 寄存器 Modbus 协议地址指的是通信时使用的寄存器寻址地址,例如 PLC 地址 40001 对应寻址地址 0x0000,40002 对应寻址地址 0x0001。寄存器寻址地址一般使用十六进制描述。

细心的你会发现,PLC 寄存器地址 40003 对应的协议地址是 0x0002,PLC 寄存器地址 30003 对应的协议地址也是 0x0002,虽然通信时使用两个相同的 Modbus 协议地址,但是因为不同寄存器的功能码是不相同的,因此并不存在访问冲突。

Modbus 功能码

Modbus 功能码是 Modbus 消息帧(报文)的重要组成部分,是 Modbus 协议中通信事务处理的基础。

img

Modbus 功能码占用一个字节,取值范围是 1~127(即 0x01~0x7F)。同时,使用功能码 + 0x80 表示异常状态,即 129~255 代表异常码。

在 Modbus 标准协议中,一共规定了三类 Modbus 功能码。

  1. 公共功能码
    • 被明确定义的功能码;
    • 保证唯一性;
    • 由 Modbus 协会确认,并提供公开的文档;
    • 可进行一致性测试;
    • 包括协议定义的功能码和保留将来使用的功能码。
  2. 用户自定义功能码
    • 有两个用户自定义功能码区域,分别是 65~72 和 100~110;
    • 用户自定义,不保证唯一性。
  3. 保留功能码
    • 保留功能码是因为历史遗留原因,某些公司的传统产品上现行使用的功能码不作为公共使用。

本教程主要介绍公共功能码,下表展示了 Modbus 协议中的部分公共功能码。

代码 名称 英文 寄存器 PLC 地址 位/字操作 操作数量
01 读线圈状态 Read Coils 00001~09999 位操作 单个或多个
02 读离散输入状态 Read Discrete Inputs 10001~19999 位操作 单个或多个
03 读保持寄存器 Read Holding Registers 40001~49999 字操作 单个或多个
04 读输入寄存器 Read Input Registers 30001~39999 字操作 单个或多个
05 写单个线圈 Write Single Coil 00001~09999 位操作 单个
06 写单个保持寄存器 Write Single Register 40001~49999 字操作 单个
15 写多个线圈 Write Multiple Coils 00001~09999 位操作 多个
16 写多个保持寄存器 Write Multiple Registers 40001~49999 字操作 多个

功能码的操作可分为两种:

  • 位操作 —— 最小单位为一位(bit),包括读线圈状态功能码 01、读离散输入状态功能码 02、写单个线圈功能码 05 和写多个线圈功能码 15;
  • 字操作 —— 最小单位为两个字节,包括读保持寄存器功能码 03、读输入寄存器功能码 04、写单个保持寄存器功能码 06 和写多个保持寄存器功能码 16。

功能码详解

0x01 读取线圈/离散量输出值

  • 该功能码用于读取从设备的线圈或离散量输出的状态,即各 DO(Discrete Output,离散输出)的 ON/OFF 状态。
  • 消息帧中指定了需读取的线圈起始地址和线圈数目。
  • 起始地址由 2 个字节构成,取值范围为 0x0000 到 0xFFFF。
  • 线圈数量由 2 个字节构成,取值范围为 0x0001 到 0x07D0(即十进制 1~2000)。
  • 需要注意,在 Modbus 协议规定的 PDU 中,规定所有线圈或寄存器地址从 0 开始计算。

0x02 读取离散量输入值

  • 该功能码用于读取从设备的离散输入,即 DI(Discrete Input)的 ON/OFF 状态。
  • 消息帧中指定了需读取的离散输入寄存器起始地址和数目,可读取 1~2000 个连续的离散量输入状态。
  • 如果从设备接受主设备的请求则回复功能码 02,并返回离散量输入各变量的当前状态(如果返回的 DI 数量不是 8 的整数倍,将用 0 填充最后数据字节的剩余位)。
  • 起始地址由 2 个字节构成,取值范围为 0x0000 到 0xFFFF。
  • 离散量数量由 2 个字节构成,取值范围为 0x0001 到 0x07D0(即十进制 1~2000),最多一次可读取 2000 个离散输入状态值。

0x03 读取保持寄存器值

  • 该功能码用于读取从设备保持寄存器的内容,不支持广播模式。
  • 消息帧中指定了需读取的保持寄存器的起始地址和数目,而保持寄存器中各地址的具体内容和意义则由设备开发者自行规定。
  • 起始地址由 2 个字节构成,取值范围为 0x0000 到 0xFFFF。
  • 寄存器数量由 2 个字节构成,取值范围为 0x0001 到 0x007D(即十进制 1~125),最多一次可连续读取 125 个寄存器值。
  • 需要注意,Modbus 的保持寄存器和输入寄存器是以字(Word)为基本单位的(1Word 等于 2Bytes)。因此,在读取时需要注意字节序(大小端)问题。

0x04 读取输入寄存器值

  • 该功能码用于读取从设备输入寄存器的内容,不支持广播模式。(与 03 功能码类似)
  • 消息帧中指定了需读取的输入寄存器的起始地址和数目,而输入寄存器中各地址的具体内容和意义则由设备开发者自行规定。
  • 起始地址由 2 个字节构成,取值范围为 0x0000 到 0xFFFF。
  • 寄存器数量由 2 个字节构成,取值范围为 0x0001 到 0x007D(即十进制 1~125),最多一次可连续读取 125 个寄存器值。
  • 同样需要注意字节序问题。

0x05 写单个线圈/单个离散输出

  • 该功能码用于将单个线圈寄存器(或离散输出)设置为 ON 或 OFF,支持广播模式。
  • 在广播模式下,所有从站设备的同一地址的值将被统一修改。
  • 消息帧中指定了需要变更的线圈地址和设定的状态值。
  • 起始地址由 2 个字节构成,取值范围为 0x0000 到 0xFFFF。
  • 目标数据(即查询报文中的 ON/OFF 状态)由报文数据字段的常数指定,0xFF00 表示 ON 状态,0x0000 表示 OFF 状态,其余所有值均是非法的。
  • 需要注意,在 Modbus 协议规定的 PDU 中,规定所有线圈或寄存器地址从 0 开始计算。

0x06 写单个保持寄存器

  • 该功能码用于更新从设备的单个保持寄存器的值,支持广播模式。
  • 在广播模式下,所有从站设备的同一地址的值将被统一修改。
  • 消息帧中需要指定从设备地址以及需要变更的保持寄存器地址和设定值。
  • 起始地址由 2 个字节构成,取值范围为 0x0000 到 0xFFFF。
  • 变更目标数据由 2 个字节构成,取值范围为 0x0000 到 0xFFFF。
  • 保持寄存器以字(Word)为基本单位,写入时需要注意目标数据的字节序问题。

0x08 诊断功能

  • 该功能码仅用于串行链路,主要用于检测主设备和从设备之间的通信故障,或检测从设备的各种内部故障,该功能不支持广播。
  • 查询报文中需要指定从设备地址、功能码(Modbus Command)以及子功能码(Diagnostic Sub-function)。其中,子功能码字段为 2 个字节,用于区别各诊断类型。
  • 在正常的响应报文中,从设备将原样回复功能码和子功能码。

常用的 Modbus 诊断子功能码定义如下:

功能码 子功能码 描述 说明
08 00(0x00) Return Data Query (Loop-back) 原样返回查询报文
08 01(0x01) Restart Communications 用于初始化并重新启动从站设备 其中,报文字段 0x00, 0x00 表示保持事件记录 0xFF, 0x00 表示清除事件记录
08 02(0x02) Return Diagnostic Register 返回诊断寄存器内容
08 03(0x03) Change ASCII Input Delimiter
08 04(0x04) Force Listen Only Mode 强制被寻址的从站设备进入只听模式 使其与网络中的其他设备断开,不返回响应
08 10(0x0A) Clear Counters and Diagnostic Registers 清除计数器和诊断寄存器
08 11(0x0B) Return Bus Message Count 返回总线报文计数值
08 12(0x0C) Return Bus Communication Error Count 返回总线通信 CRC 出错计数
08 13(0x0D) Return Bus Exception Error Count 返回总线异常计数
08 14(0x0E) Return Slave Message Count 返回从站设备接收的报文数量
08 15(0x0F) Return Slave No Response Count 返回从站设备没有返回响应的报文数量
08 16(0x10) Return Slave NAK Count
08 17(0x11) Return Slave Busy Count 返回从站设备响应忙的报文数量
08 18(0x12) Return Bus Character Overrun Count 返回总线字符超限的报文数量
08 19(0x13) Return IOP Overrun Count (884)
08 20(0x14) Clear Overrun Counter and Flag (884)

0x0B 获取通信事件计数器

  • 该功能码主要用于获取从设备通信计数器中的状态字和事件计数的值,不支持广播模式。
  • 可以通过在通信报文之前和之后读取通信事件计数值,来确定从设备是否正常处理报文。
  • 对于正常完成报文处理和传输的场合,事件计数器增加 1;而对于异常响应、轮询命令或读事件计数器(即 0x0B 功能码)的场合,则计数器不变。
  • 通过 0x08 诊断功能中的 0x01 子功能和 0x0A 子功能,可以复位事件寄存器。

0x0C 获取通信事件记录

  • 该功能码主要用于从从设备获取状态字、事件计数、报文计数以及事件字节字段。
  • 其中状态字和事件计数与功能码 0x0B 获取的值一致。
  • 报文计数器包含了加电重启、清除计数器之后的报文数量,报文计数与通过 0x08 诊断功能中的 0x0B 子功能码获取的值一致。
  • 事件字节字段包含 0~64 个字节,定义各种事件。
  • 正常情况下响应报文包括一个 2 Bytes 状态字字段、一个 2 Bytes 事件计数字段、一个 2 Bytes 消息计数字段以及 0~64 个字节的事件字段。
  • 由于事件字段是变长的,因此增加了一个 1 Byte 的数据长度字段,以方便读取响应数据。

0x0F 写多个线圈

  • 该功能码用于将连续的多个线圈(或离散输出)设置为 ON 或 OFF,支持广播模式。
  • 在广播模式下,所有从站设备的同一地址的值将被统一修改。
  • 消息帧中指定了需要变更的线圈起始地址和线圈数目。
  • 起始地址由 2 个字节构成,取值范围为 0x0000 到 0xFFFF。
  • 寄存器数量字段由 2 个字节构成,取值范围为 0x0001 到 0x07B0。
  • 数据字段中为逻辑 1 的位对应 ON,逻辑 0 的位对应 OFF。

0x10 写多个保持寄存器

  • 该功能码用于设置或写入从设备保持寄存器的多个连续的地址块(1~123个寄存器),支持广播模式。
  • 在广播模式下,所有从站设备的同一地址的值将被统一修改。
  • 消息帧中需要指定从设备地址以及需要变更的保持寄存器地址和数量。
  • 起始地址由 2 个字节构成,取值范围为 0x0000 到 0xFFFF。
  • 寄存器数量字段由 2 个字节构成,取值范围为 0x0001 到 0x007B(即十进制 1~123)。
  • 在实际开发中,该功能码常用于方便用户写入多字节类型的数据,例如浮点数值。因此,需要注意字节序问题。

0x11 报告从站 ID

  • 该功能码仅适用于串行链路,用于读取从站设备的 ID、类型描述、当前状态以及其他信息,不支持广播模式。
  • 查询报文中没有数据字段。
  • 响应消息的构成由从站设备决定。

总结

常用 Modbus 公共功能码下表所示。

img

其中,支持广播模式的功能码有:

  • 0x05 写单个线圈
  • 0x06 写单个保持寄存器
  • 0x0F 写多个线圈
  • 0x10 写多个保持寄存器

除了广播模式的报文以外,其他所有查询报文都希望能够获取一个正常的响应报文。如果一切正常,则从站设备将返回一个正常响应报文,该响应报文的功能码与请求报文的功能码一致。

另外,对于字操作的功能码,存在多字节存储的大小端问题,因此主站设备和从站设备必须保持一致的规则处理,约定 Modbus 传输中的数据字段的字节序。

参考资料:

https://getiot.tech/modbus/common-modbus-registers.html
https://getiot.tech/modbus/modbus-function-codes.html