本来需要用到Modbus,就了解了一下,不过后来用不到了,只作了一些调研,简单记录下。所有内容未实际测试。
0.概述
Modbus是一种工业协议,于1979年开发,旨在实现自动化设备之间的通信。最初是作为串行层传输数据的应用层协议实现的,现已扩展到包括串行、TCP和UDP的实现。
Modbus使用主从关系实现的请求-响应协议。在主从关系中,通信总是成对发生 - 一个设备必须发起请求,然后等待响应 - 并且发起设备(主设备)负责发起每次交互。通常,主设备是人机界面(HMI)或监控和数据采集(SCADA)系统,从设备是传感器、可编程逻辑控制器(PLC)或可编程自动化控制器(PAC)。这些请求和响应的内容以及发送这些消息的网络层由协议的不同层来定义。
主从网络关系
1.名词解释
协议数据单元(PDU)
通俗的理解,协议数据单元就是包含Modbus协议所规定的从机地址,功能码,数据,校验等各个数据部分的数据单元。Modbus应用协议规范主要就是在定义这几个部分数据的含义。
Modbus数据模型
Modbus数据模型包含线圈状态,离散输入,输入寄存器,保持寄存器四种可访问的数据类型。(Modbus协议最开始是用来解决PLC的通信问题,这些名词来源于PLC专业属于,因此对于部分行业的人来说难以理解)。可以理解为这个主从设备构成的系统的公用寄存器,这些寄存器数据类型不同,其处于不同的地址范围,主从设备都可以访问这些寄存器,但主从设备有着不同的访问权限。
内存区块名称 | 功能码* | 数据类型 | 主设备访问权限 | 从设备访问权限 | 地址范围 |
线圈状态 | 01H,05H,0FH | 位 | 读/写 | 读/写 | 0x00000-0x0ffff |
离散输入 | 02H | 位 | 读 | 读/写 | 0x10000-0x1ffff |
保持寄存器 | 03H,06H,10H | 无符号双字节整型 | 读/写 | 读/写 | 0x40000-0x4ffff |
输入寄存器 | 04H | 无符号双字节整型 | 读 | 读/写 | 0x30000-0x3ffff |
功能码
主设备用功能码表示希望从设备执行什么操作。比如,01H功能码表示主设备想读取单个或多个线圈状态。后面会详细描述。
功能码分为三种:
公共功能码(Public Function Codes):在公开文档种有明确定义,并保证唯一的功能码。
用户定义功能码(User-Defined Function Codes):用户可以选择实现未被标准支持的功能码,以支持需要的功能。
保留功能码(Reserved Function Codes):被部分公司使用,作为遗留项目而未公开使用的功能码。
以下是官方文档中对功能码的分类:
2.功能码
功能码概述
功能码是主设备告诉从设备其想执行什么操作。常用的有以下几种:
功能码 | 描述 | 数据类型 | 操作数量 | 寄存器地址 |
01H | 读线圈状态 | 位 | 单个或多个 | |
02H | 读离散输入 | 位 | 单个或多个 | |
03H | 读保持寄存器 | 无符号双字节整型 | 单个或多个 | |
04H | 读输入寄存器 | 无符号双字节整型 | 单个或多个 | |
05H | 写单个线圈状态 | 位 | 单个 | |
06H | 写单个保持寄存器 | 无符号双字节整型 | 单个 | |
0FH | 写多个线圈状态 | 位 | 多个 | |
10H | 写多个保持寄存器 | 无符号双字节整型 | 多个 |
功能码使用示例
1.读多个线圈状态
功能码01H读取Modbus从机中线圈寄存器的状态,可以是单个寄存器,或者多个连续的寄存器。
第一步,主机发送命令
假设从机地址为01H,读取的线圈寄存器的起始地址为0017H,读取38个寄存器,指令如下表所示:
从机地址 | 功能码 | 起始地址高位 | 起始地址低位 | 寄存器数量高位 | 寄存器数量低位 | CRC高位 | CRC低位 |
01 | 01 | 00 | 17 | 00 | 26 | 0D | D4 |
第二步,从机发送响应
各线圈的状态与数据内容的每个bit对应,1代表ON,0代表OFF。如果查询的线圈数量不是8的倍数,则在最后一个字节的高位补0。
从机地址 | 功能码 | 返回字节数 | 数据1 | 数据2 | 数据3 | 数据4 | 数据5 | CRC高位 | CRC低位 |
01 | 01 | 05 | CD | 6B | B2 | 0E | 1B | 44 | EA |
响应数据与寄存器对应关系:
其中,第一个字节CDH对应线圈0017H到001E的状态,转为二进制是11001101,其中bit0对应0017H,bit7对应001E,具体对应关系如下:
线圈0017H到001EH的状态
001EH | 001DH | 001CH | 001BH | 001AH | 0019H | 0018H | 0017H |
1 | 1 | 0 | 0 | 1 | 1 | 0 | 1 |
ON | ON | OFF | OFF | ON | ON | OFF | ON |
最后一个字节为1BH,对应线圈0037H到003CH的状态,转为二进制是00011011,其中bit0对应0037H,bit5对应003CH,其余两位用0填充,如下表所示:
线圈0037H到003CH的状态
003CH | 003BH | 003AH | 0039H | 0038H | 0037H | 0036H | 0035H |
0 | 0 | 0 | 1 | 1 | 0 | 1 | 1 |
填充 | 填充 | OFF | ON | ON | OFF | ON | ON |
2.写入单个线圈寄存器
功能码05H写单个线圈寄存器,FF00H请求线圈处于ON状态,0000H请求线圈处于OFF状态。
第一步,主机发送命令
假设从机地址为01H,线圈寄存器的地址为00ACH,使其处于ON状态的指令如下表所示:
从机地址 | 功能码 | 寄存器地址高位 | 寄存器地址低位 | 数据高位 | 数据低位 | CRC高位 | CRC低位 |
01 | 05 | 00 | AC | FF | 00 | 4C | 1B |
第二步,从机返回发送的指令
如果写入成功,返回发送的指令,即010500ACFF004C1B。
3.libmodbus源码解析
这里对libmodbus源码做简要解析,详细解析可参考猪哥博客
libmodbus使用:https://zhuge.blog.csdn.net/article/details/89185837
libmodbus源码解析: https://zhuge.blog.csdn.net/article/details/104088091
数据结构定义
功能码定义
// modbus.h
/* Modbus function codes */
#define MODBUS_FC_READ_COILS 0x01
#define MODBUS_FC_READ_DISCRETE_INPUTS 0x02
#define MODBUS_FC_READ_HOLDING_REGISTERS 0x03
#define MODBUS_FC_READ_INPUT_REGISTERS 0x04
#define MODBUS_FC_WRITE_SINGLE_COIL 0x05
#define MODBUS_FC_WRITE_SINGLE_REGISTER 0x06
#define MODBUS_FC_READ_EXCEPTION_STATUS 0x07
#define MODBUS_FC_WRITE_MULTIPLE_COILS 0x0F
#define MODBUS_FC_WRITE_MULTIPLE_REGISTERS 0x10
#define MODBUS_FC_REPORT_SLAVE_ID 0x11
#define MODBUS_FC_MASK_WRITE_REGISTER 0x16
#define MODBUS_FC_WRITE_AND_READ_REGISTERS 0x17
modbus设备上下文modbus_t,里面包含了从机地址,socket(TCP/UDP)或文件描述符(串口),超时时间,以及消息处理方法等成员。
// modbus.h
typedef struct _modbus modbus_t;
//modbus-private.h
struct _modbus {
/* Slave address */
int slave;
/* Socket or file descriptor */
int s;
int debug;
int error_recovery; // 用户是否允许自动重连
struct timeval response_timeout;
struct timeval byte_timeout;
struct timeval indication_timeout;
const modbus_backend_t *backend;
void *backend_data;
};
上下文种比较重要的一个定义是后端 modbus_backend_t,该数据结构位struct,里面存储了对于modbus数据的处理函数,用于屏蔽不同的底层协议(RTU、TCP、UDP等)
//modbus-private.h
typedef struct _modbus_backend {
unsigned int backend_type;
unsigned int header_length;
unsigned int checksum_length;
unsigned int max_adu_length;
int (*set_slave) (modbus_t *ctx, int slave);
int (*build_request_basis) (modbus_t *ctx, int function, int addr,
int nb, uint8_t *req);
int (*build_response_basis) (sft_t *sft, uint8_t *rsp);
int (*prepare_response_tid) (const uint8_t *req, int *req_length);
int (*send_msg_pre) (uint8_t *req, int req_length);
ssize_t (*send) (modbus_t *ctx, const uint8_t *req, int req_length);
int (*receive) (modbus_t *ctx, uint8_t *req);
ssize_t (*recv) (modbus_t *ctx, uint8_t *rsp, int rsp_length);
int (*check_integrity) (modbus_t *ctx, uint8_t *msg,
const int msg_length);
int (*pre_check_confirmation) (modbus_t *ctx, const uint8_t *req,
const uint8_t *rsp, int rsp_length);
int (*connect) (modbus_t *ctx);
void (*close) (modbus_t *ctx);
int (*flush) (modbus_t *ctx);
int (*select) (modbus_t *ctx, fd_set *rset, struct timeval *tv, int msg_length);
void (*free) (modbus_t *ctx);
} modbus_backend_t;
在初始化具体的modbus_t上下文时,modbus_backend_t将会被赋值为对应不同底层协议的结构体。
两个关键步骤解析
以RTU为例,比较重要的两个步骤,解析:
1.初始化modbus上下文
modbus_t *ctx = NULL; // modbus_t 指针定义
ctx = modbus_new_rtu("/dev/ttySP0", 9600, 'N', 8, 1); // 初始化上下文,以RTU为传输协议
// modbus_new_rtu函数中,进行了以下操作:为backend结构体赋值,_modbus_rtu_backend是在相应的协议文件(modbus-rtu.c)中定义好的,从而屏蔽具体协议,可以看出,_modbus将backend_data定义为void*类型,也是因为不同的协议有不同的特性参数,因此需要各自定义数据结构,分别初始化
> ctx->backend = &_modbus_rtu_backend;
> ctx->backend_data = (modbus_rtu_t *)malloc(sizeof(modbus_rtu_t));
> ctx_rtu = (modbus_rtu_t *)ctx->backend_data;
> ctx_rtu->device = xxx
> ctx_rtu->baud = xxx
....
2.操作寄存器,以读保持寄存器为例
uint16_t tab_reg[64] = {0}; // 定义存放数据的数组
modbus_read_registers(ctx, 0, 10, tab_reg); // 从地址0开始读取10个寄存器,存入tab_reg
// 这里,不同功能码被分为了几类操作,读寄存器之类的操作被封装到read_registers函数中
> read_registers(ctx, MODBUS_FC_READ_HOLDING_REGISTERS, addr, nb, dest); // 这个函数实现了各种功能码定义的操作,只需要把相应功能码传给它,就可以实现不同的功能
>> req_length = ctx->backend->build_request_basis(ctx, function, addr, nb, req); // 打包请求消息体
>> rc = send_msg(ctx, req, req_length); // 发送请求消息体
>>> msg_length = ctx->backend->send_msg_pre(msg, msg_length); // 发送前填充校验
>>> rc = ctx->backend->send(ctx, msg, msg_length); // 发送
>> rc = _modbus_receive_msg(ctx, rsp, MSG_CONFIRMATION); // 这里按照不同的消息格式,读不同长度的response。如果receive出现故障,在错误重连被允许的情况下,将自动重连。
>>> ctx->backend->check_integrity(ctx, msg, msg_length); // CRC检查
>> rc = check_confirmation(ctx, req, rsp, rc); // 里面区分各种功能码,检查不同的返回值是否符合要求,并进行错误检查
>> // 填充数据到tab_reg, read_registers中的dest
完整的RTU master读寄存器程序示例
// modbus 从机示例代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <errno.h>
#include <modbus.h>
int main(int argc, char *argv[])
{
uint16_t tab_reg[64] = {0}; // 定义存放数据的数组
modbus_t *ctx = NULL; // modbus_t 指针定义
int rc;
int i;
// 以串口的方式创建libmobus实例,并设置参数
// 使用UART1,对应的设备描述符为ttySP0
ctx = modbus_new_rtu("/dev/ttyUSB0", 9600, 'N', 8, 1); //相 当于初始化 modbus_t
if (ctx == NULL) {
fprintf(stderr, "Unable to allocate libmodbus contex\n");
return -1;
}
modbus_set_debug(ctx, 1); // 设置1可看到调试信息
modbus_set_slave(ctx, 1); // 设置slave ID
if (modbus_connect(ctx) == -1) { // 等待连接设备
fprintf(stderr, "Connection failed:%s\n", modbus_strerror(errno));
return -1;
}
while (1) {
printf("\n----------------\n");
rc = modbus_read_registers(ctx, 0, 10, tab_reg);
if (rc == -1) { // 读取保持寄存器的值,可读取多个连续输入保持寄存器
fprintf(stderr, "%s\n", modbus_strerror(errno));
return -1;
}
for (i = 0; i < 10; i++) {
printf("reg[%d] = %d(0x%x)\n", i, tab_reg[i], tab_reg[i]);
}
sleep(1);
}
modbus_close(ctx); // 关闭modbus连接
modbus_free(ctx); // 释放modbus资源,使用完libmodbus需要释放掉
return 0;
}
slave端在linux端应用应该较少,更多使用小嵌入式设备实现传感器数据的发送,据说freemodbus工程更适合slave端应用,有机会再读相关源码吧。
4.异常处理
协议
从设备使用异常来指示各种不良状况,比如错误请求或不正确输入。 但是,异常也可以作为对无效请求的应用程序级响应。 从设备不响应发出异常的请求。 相反,从设备忽略不完整或损坏的请求,并开始等待新的消息传入。
异常以定义好的数据包格式报告给用户。 首先将一个功能代码返回给等同于与原始功能代码的请求主设备,除了设置了最高有效位。 这等同于为原始功能代码的值加上0x80。 异常响应包括一个异常代码来代替与给定函数响应相关的正常数据。
在标准内,四种最常见的异常代码是01,02,03和04。表下介绍了这些代码以及每种功能的标准含义。
异常代码 | 含义 |
01 | 不支持接收到功能代码。 要确认原始功能代码,请从返回值中减去0x80。 |
02 | 尝试访问的请求是一个无效地址。 在标准中,只有起始地址和请求的数值超过216时才会发生这种情况。 但是,有些设备可能会限制其数据模型中的地址空间。 |
03 | 请求包含不正确的数据。 在某些情况下,这意味着参数不匹配,例如发送的寄存器的数量与“字节数”字段之间的参数不匹配。 更常见的情况是,主机请求的数据比从机或协议允许的要多。 例如,主设备一次只能读取125个保持寄存器,而资源受限的设备可能会将此值限制为更少的寄存器。 例如,主设备一次只能读取125个保持寄存器,而资源受限的设备可能会将此值限制为更少的寄存器。 |
04 | 尝试处理请求时发生不可恢复的错误。 这是一个异常的代码,表示请求有效,但从设备无法执行该请求。 |
libmodbus
// modbus.h定义了多种异常类型
/* Protocol exceptions */
enum {
MODBUS_EXCEPTION_ILLEGAL_FUNCTION = 0x01,// 前四个错误码对应上表中的四个异常代码
MODBUS_EXCEPTION_ILLEGAL_DATA_ADDRESS,
MODBUS_EXCEPTION_ILLEGAL_DATA_VALUE,
MODBUS_EXCEPTION_SLAVE_OR_SERVER_FAILURE,
MODBUS_EXCEPTION_ACKNOWLEDGE,
MODBUS_EXCEPTION_SLAVE_OR_SERVER_BUSY,
MODBUS_EXCEPTION_NEGATIVE_ACKNOWLEDGE,
MODBUS_EXCEPTION_MEMORY_PARITY,
MODBUS_EXCEPTION_NOT_DEFINED,
MODBUS_EXCEPTION_GATEWAY_PATH,
MODBUS_EXCEPTION_GATEWAY_TARGET,
MODBUS_EXCEPTION_MAX
};
这些错误类型由从机发送,发送时,从机首先仍发送从机地址和功能码(不是真正的功能码,是功能码+0x80,以和功能码区分开来)字节,接下来直接发送错误码。
那么主机如何检查到错误呢?还记得上面的check_confirmation函数吗,该函数将进行错误检查,即检查发送功能码和接收功能码是否一致,,如果不一致,则报错,并给errno赋值为相应的错误码。
因此用户需要在modbus_read_registers之后执行modbus_strerror(errno),检查是否出现错误。
// modbus.c
const char *modbus_strerror(int errnum) {
switch (errnum) {
case EMBXILFUN:
return "Illegal function";
case EMBXILADD:
return "Illegal data address";
case EMBXILVAL:
return "Illegal data value";
....
default:
return strerror(errnum); // modbus_strerror不光能检查自定义错误,还能检查标准错误,原因是其直接使用对errno赋值的方式实现的
}
}
modbus_strerror(errno);
发表评论