文章目录
-
- [RS485 + ModBus](#RS485 + ModBus)
-
-
- [1. RS485](#1. RS485)
-
- [1.1 RS485 概述](#1.1 RS485 概述)
- [1.2 为什么要用 RS485](#1.2 为什么要用 RS485)
- [1.3 RS485 工作数据发送和数据接收](#1.3 RS485 工作数据发送和数据接收)
- [1.4 485 原理图分析](#1.4 485 原理图分析)
-
- [1.4.1 原理图分析连线关系和对应引脚](#1.4.1 原理图分析连线关系和对应引脚)
- [1.4.2 开发流程分析](#1.4.2 开发流程分析)
- [1.4.3 目前实现的效果](#1.4.3 目前实现的效果)
- [2. ModBus 【重点】](#2. ModBus 【重点】)
-
- [2.1 ModBus 概述](#2.1 ModBus 概述)
- [2.2 ModBus 通信栈和数据帧](#2.2 ModBus 通信栈和数据帧)
- [2.3 RS485 一主多从结构和 ModBus 地址域](#2.3 RS485 一主多从结构和 ModBus 地址域)
- [2.4 ModBus 数据类型【小重点】](#2.4 ModBus 数据类型【小重点】)
- [2.5 ModBus 功能码](#2.5 ModBus 功能码)
- [2.6 ModBus 数据模式](#2.6 ModBus 数据模式)
- [2.7 ModBus 数据间隔【重点】](#2.7 ModBus 数据间隔【重点】)
- [2.8 ModBus 实际数据帧分析](#2.8 ModBus 实际数据帧分析)
- [2.9 RS485 + ModBus 案例](#2.9 RS485 + ModBus 案例)
-
- [2.9.1 开发分析](#2.9.1 开发分析)
- [2.9.2 代码实现](#2.9.2 代码实现)
- 基本概念
- 消息帧结构
- 数据存储和表示
- 通信过程
- [示例代码(Python 实现读取保持寄存器)](#示例代码(Python 实现读取保持寄存器))
- 基本概念
- [在 ModBus RTU 中的应用](#在 ModBus RTU 中的应用)
- 优缺点
- [代码示例(Python 实现 BCD 码的编码和解码)](#代码示例(Python 实现 BCD 码的编码和解码))
-
RS485 + ModBus
1. RS485
1.1 RS485 概述
- 基于硬件有线连接,数据传输方式。主要用于**【工业场景】**
- RS485/RS232 都是**【串行】通信方式**。
- RS232 电气属性稳定性较差,会被其他信号干扰或者影响,同时传输距离较短。 RS485 稳定性好,同时传输距离很远。
- RS485 需要两个数据线进行通信,对应 RS485 A 和 RS 485 B。MCU 都是通过【差分线】连接对应 485 芯片,保证数据传递的一致性和稳定性。
1.2 为什么要用 RS485
- 传输距离远,再较低传递速度和良好的布线要求下, 可以满足 1200 米作用的传输距离。同时可以通过【中继节点】可以延续更远的传递距离。
- 传输速度较快,最高可达 10Mbps ==> 1.25 MB/s,使用最大速度,传递距离较短。
- RS485 可以连接多个设备,理论单一设备可以同时连接 32 个其他 485 设备。每一个设备都可以自定义设备地址编号,一般是从 0x01 ~ 0xXX。可以利用其他技术,将同一个 485 端口上的设备,扩充到 128 台。
- RS485 芯片通信成本和设备成本较低。
1.3 RS485 工作数据发送和数据接收
RS485 仅通过 A B 两根数据线进行数据发送和接收。485 芯片根据 MCU 提供的时钟周期,在时钟周期内,通过调整 A B 两根数据线的电压差,完成数据的发送和接收。
发送
485 发送数据 1,A 端子电压 - B 端子电压 > 200mv ~ 6 V,根据当前开发板原理图分析,对应 485 芯片 VCC --> 3.3V 当前 485 可以提供的电压最大值是 3.3V。根据实际开发版情况和使用要求,一般发送数据 1 对应的 A 端子电压 - B 端子电压 > 2V ~ 3.3V
485 发送数据 0,根据以上分析,B 端子电压 - A 端子电压 > 2V ~ 3.3V
tips: 当前电压范围是一个200mv ~ 6V 理论值。因为 200 mV 是 485 A B 两个端子判断 0 or 1 最低参考标准,因为导线会存在一定的压降,会导致 485 两端电压小于 200 mv 压差。会导致数据丢失。
接收
- 接受数据,数据 1 对应 A 端子电压 - B 端子电压 > 200 mV
- 接受数据,数据 0 对应 B 端子电压 - A 端子电压 > 200 mV
1.4 485 原理图分析
1.4.1 原理图分析连线关系和对应引脚

1.4.2 开发流程分析
【引脚分析】
当前使用的串口对应 USART2
- USART2_TX ==> PA2 复用推挽模式
- USART2_RX ==> PA3 浮空输入
485 芯片数据发送模式和数据接收模式控制
- RS485_RE --> PD7 推挽模式
代码实现过程时钟使能
- USART2 GPIOA GPIOD
引脚配置
- PA2 复用推挽模式
- PA3 浮空输入
- PD7 推挽模式
配置 USART2
- 波特率,8N1,USART_TX | USART_RX
- 中断使能 RXNE 和 IDLE,对应 USART2_IRQn
- 中断函数 USART2_IRQHandler
【重点】
- 通过 USART2 进行数据发送操作,
- 要求必须通过 PD7 设置为高电平输出,打开 485 发送数据模式,
- 当前数据发送完成,将 PD7 设置为低电平输出,485 芯片进入数据接收模式。
h
#ifndef _RS485_H
#define _RS485_H
#include "stm32f10x.h"
#include "stdio.h"
#include "stdlib.h"
#include "string.h"
#include "usart1.h"
#include "systick.h"
#include "led.h"
#include "beep.h"
#define RS485_DATA_SIZE (256)
typedef struct rs485_data
{
u8 data[RS485_DATA_SIZE]; // 接受数据缓冲区
u8 flag; // 数据处理标志位
u16 count; // 读取到的有效字节个数
} RS485_Data;
extern RS485_Data rs485_val;
void RS485_Init(u32 brr);
void RS485_SendByte(u8 byte);
void RS485_SendBuffer(u8 *buffer, u16 count);
void RS485_SendString(const char * str);
#endif
c
#include "rs485.h"
RS485_Data rs485_val = {0};
void RS485_Init(u32 brr)
{
// 1. 时钟使能 USART2 GPIOA GPIOD
RCC_APB2PeriphClockCmd(RCC_APB2ENR_IOPAEN | RCC_APB2ENR_IOPDEN, ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1ENR_USART2EN, ENABLE);
// 2. GPIO PA2 配置 复用推挽
GPIO_InitTypeDef GPIO_InitStructure = {0};
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// GPIO PA3 配置 浮空输入
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// GPIO PD7 配置 推挽
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_7;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOD, &GPIO_InitStructure);
/// 3. USART2 配置
USART_InitTypeDef USART_InitStructure = {0};
USART_InitStructure.USART_BaudRate = brr;
USART_InitStructure.USART_WordLength = USART_WordLength_8b;
USART_InitStructure.USART_Parity = USART_Parity_No;
USART_InitStructure.USART_StopBits = USART_StopBits_1;
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx;
USART_Init(USART2, &USART_InitStructure);
USART_Cmd(USART2, ENABLE);
// 4. USART2 串口中断配置
USART_ITConfig(USART2, USART_IT_IDLE, ENABLE);
USART_ITConfig(USART2, USART_IT_RXNE, ENABLE);
NVIC_InitTypeDef NVIC_InitStructure = {0};
NVIC_InitStructure.NVIC_IRQChannel = USART2_IRQn;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 2;
NVIC_Init(&NVIC_InitStructure);
}
void USART2_IRQHandler(void)
{
u16 val = 0;
/*
检测到数据总线空闲,表示数据接收完毕,进行展示处理,同时
处理当前中断标志位
IDLE 清除要求
1. 读取 USARTx->SR
2. 读取 USARTx->DR
不建议使用 void USART_ClearFlag(USART_TypeDef* USARTx, uint16_t USART_FLAG)
*/
if (USART_GetITStatus(USART2, USART_IT_IDLE) == SET)
{
rs485_val.flag = 1;
val = USART2->SR;
val = USART2->DR;
USART1_SendBuffer(rs485_val.data, rs485_val.count);
}
/*
1. 处理 RXNE 中断
接收数据缓冲区非空,需要进行接收数据处理
2. 处理 IDLE 中断
当前接收数据总线已空闲,数据接收完毕
*/
/*
如果 flag 为 1 表示以当前数据已进行后续处理,需要对当前占用的内存空间
进行擦除操作,方便进入下一次数据接受
*/
if (rs485_val.flag)
{
memset(&rs485_val, 0, sizeof(RS485_Data));
}
if (USART_GetITStatus(USART2, USART_IT_RXNE) == SET)
{
// 从 USART3 读取数据,存储到 rs485_val 结构中,同时赋值 data 存储数据
// count 累加有效数据个数
rs485_val.data[rs485_val.count++] = USART_ReceiveData(USART2);
// 如果数据已满,利用 USART1 发送数据到 PC 串口调试工具
if (rs485_val.count == RS485_DATA_SIZE)
{
// USART1_SendBuffer(esp8266_val.data, esp8266_val.count);
rs485_val.flag = 1;
USART1_SendBuffer(rs485_val.data, rs485_val.count);
}
}
}
void RS485_SendByte(u8 byte)
{
while (USART_GetFlagStatus(USART2, USART_FLAG_TC) == RESET);
USART_SendData(USART2, byte);
}
void RS485_SendBuffer(u8 *buffer, u16 count)
{
// PD7 高电平
GPIO_SetBits(GPIOD, GPIO_Pin_7);
while (count--)
{
RS485_SendByte(*buffer);
count += 1;
}
// PD7 低电平
GPIO_ResetBits(GPIOD, GPIO_Pin_7);
}
void RS485_SendString(const char * str)
{
// PD7 高电平
GPIO_SetBits(GPIOD, GPIO_Pin_7);
while (*str)
{
RS485_SendByte(*str);
str++;
}
// PD7 低电平
GPIO_ResetBits(GPIOD, GPIO_Pin_7);
}
1.4.3 目前实现的效果

2. ModBus 【重点】
2.1 ModBus 概述
开放性 :Modbus 协议是完全开放的,任何人都可以免费使用,不需要支付许可证费用。这使得它在工业自动化领域得到了广泛的应用,不同厂商的设备可以方便地实现互联互通。
简单性 :协议简单易懂,易于实现 。它采用主从通信方式,通信规则明确,对于开发者来说,无论是硬件实现还是软件编程都相对容易上手。
可靠性:在工业环境中,通信的可靠性至关重要。Modbus 协议具有一定的错误检测机制,例如奇偶校验、**CRC(循环冗余校验)**等,可以有效保证数据传输的准确性。
灵活性 :支持多种电气接口,如 RS - 232、RS - 485 等,还可以通过以太网进行通信(Modbus TCP)。同时,它可以应用于不同类型的设备,包括 PLC、传感器、执行器、变频器等。
Modbus 支持三种数据传输模式Modbus RTU(Remote Terminal Unit) :这是一种紧凑的、高效的传输模式,使用二进制编码表示数据。在 RTU 模式下,每个字节包含 8 位数据,通信效率较高,常用于串行通信(如 RS - 485)。
Modbus ASCII:采用 ASCII 字符编码表示数据,每个字节由两个 ASCII 字符组成。这种模式相对 RTU 模式数据量较大,但可读性强,适用于对数据可读性要求较高的场合。
Modbus TCP:基于 TCP/IP 协议的 Modbus 版本,通过以太网进行通信。它使用标准的 TCP 端口 502,通信速度快,适用于远程监控和大规模的工业自动化系统。
2.2 ModBus 通信栈和数据帧
目前,使用下列情况实现 MODBUS:
以太网上的 TCP/IP。各种媒介(有线:EIA/TIA-232-E、EIA-422、EIA/TIA-485-A;光纤、无线等等)上的异步串行传输。


- ModBus 数据传递的标准格式
- ADU: Application Data Unit 应用程序数据单元,是整个 ModBus 协议要求的数据传递完整数据包
- 地址域 : 当前数据发送/接受目标接收设备的地址。
- PDU : 协议数据单元/功能码数据单元,组成是功能码 + 数据
- 差错校验(CRC) : 针对于整个 ADU 数据的校验机制。
- PDU:Protocol Data Unit 协议数据单元/功能码数据单元
- 功能码:绝对当前 ModBus 协议内容具体功能模式,例如读,写操作
- 数据:可以认为是 ModBus 有效载荷。有效数据。
2.3 RS485 一主多从结构和 ModBus 地址域

- 当前图例是一主多从方式进行 RS485 电气链接。
- 可用 RS485 电气连接方式,基于 ModBus 协议,可以完成 RS485 一主多从设备控制
- 以下是设备地址范围,485 设备支持修改【波特率】和【设备地址】
| 广播地址 | 设备可用地址 | 保留地址 |
|---|---|---|
| 0x0 | 0x01 ~ 0xF7 (1 ~ 247) | 0xF8 ~ 0xFF(248 ~ 255) |
- 注意 RS485 是半双工模式,如果主机采用 ModBus 广播方式进行数据发送,要求从机一般情况下不做应答要求,防止出现数据阻塞。如果需要应答,采用点名方式。
- 如果是主机和单一从机进行数据交互,一般情况下都会有数据应答机制,保证数据的完整性和一致性。
2.4 ModBus 数据类型【小重点】
| 类型 | 占用数据空间大小 | 读写权限 | 内容 |
|---|---|---|---|
| 离散量输入 | 1 bit | 只读 | 数据反馈,仅一个 bit 数据只读 |
| 线圈 | 1 bit | 读写 | 可以进行操作控制 |
| 输入寄存器 | 16 bit | 只读 | 传感器设备数据只读内容 |
| 保持寄存器 | 16 bit | 读写 | 设备状态,设备控制寄存器 |
- 离散量输入
- 一般用于设备状态,例如设备开光状态,设备运行状态,状态仅有 0 和 1,程序无法控制【离散量输入】数据内容, 完全由硬件本身状态控制。
- 线圈
- LED 灯控制,Beep 控制,声光警告器控制,继电器,固态继电器。仅需要一位二进制既可以控制工作状态,例如 0 表示不工作,1 表示正常工作。同样可以读取设备工作状态。
- 输入寄存器
- 只读寄存器,一般对应传感器采样数据在当前设备中的存储位置,数据仅可以通过传感器采样分析方式修改,用户只能读取传感器反馈的数据内容,对应 2 个字节(16 bit)。如果传感器采样数据较为复杂,可能会利用多组【输入寄存器】来描述数据内容。例如 数据高位 2 字节,数据低位 2 字节,精度 2 字节,指数范围 2 字节...
- 保持寄存器
- 可以进行写入数据控制,读取数据内容,例如车辆行驶模式设置(纯电,混动,增程,运动,越野,雪地,自定义),设备工作状态。
2.5 ModBus 功能码
ModBus 功能码绝对当前内容具体作用
- 公共功能码【重点】
- 要求所有支持 ModBus 协议通信的设备必须执行的功能码内容。例如 离散量输入读取,线圈读写操作
- 用户定义功能码
- 企业/个人,可以根据自身需求,自定义功能码,要求 ModBus 协议支持的两端
- 保留功能码
- 一般是用于较早期设备功能保持使用,目前逐步淘汰,或者不再使用。

针对于离散量输入,线圈,输入寄存器和保持寄存器操作【功能码】

2.6 ModBus 数据模式
三种数据模式
ModBus RTU : 8421 BCD 码
ModBus ASCII
ModBus TCP
发送数据 15 ,利用 ModBus 方式发送ModBus RTU 方式: 0001 0101
ModBus ASCII 方式:0011 0001 0001 0101 按照字符
'1'和 字符'5'处理RTU 8421BCD 码
2.7 ModBus 数据间隔【重点】
- T1.5,数据帧中,每一个字节/字符直接的间隔时间小于 1.5 字符/字节传递时间。
- 假设数据帧中,发送一个字节/字符对应的时间是 1ms,下一个字节/字符发送间隔时间小于 1.5 ms
- T3.5,两个数据帧之间,时间间隔大于等于 3.5 字节/3.5 字符周期
- 假设数据帧中,发送一个字节/字符对应的时间是 1ms。两个数据帧的时间间隔大于 3.5 ms
- 依据波特率 9600 计算
- 根据当前的 USART2 数据发送给 485 芯片,485 芯片根据 ModBus 协议将数据发送给其他 485 设备
- USART2 发送数据格式是 【8N1】格式
- 一个起始位
- 八个数据位
- 0 个校验位
- 一个停止位
- 波特率 9600 情况下,1 描述可以发送给 485 芯片的有效字节个数是 960 个字节。
- T1.5 时间 (1 / 960 * 1000 * 1000) * 1.5 ==> 1500000 / 960 ==> 1562.5 us ==> 1.5625 ms
- T3.5 时间 (1 / 960 * 1000 * 1000) * 3.5 ==> 3500000 / 960 ==> 3645.83 us ==> 3.645 ms
2.8 ModBus 实际数据帧分析
威盟士气象多要素百叶箱(485型).doc
问询帧和传感器设备应答帧数据结构以及设备中【输入寄存器】地址和对应含义,需要在文档中明确当前数据对应地址,操作方式和数据内容形式。
- 重点寄存器地址对应的寄存器字节数是 2 个字节(16 bit),不同于 MCU 设备内存地址管理方式。

询问帧和应答帧案例,注意温度有负数。

2.9 RS485 + ModBus 案例
2.9.1 开发分析
读取 485 接口的传感器数据
因为当前设备有且只有温湿度,大气压,光照强度,根据文档分析,对应的【输入寄存器】位置
需要知晓当前设备的地址和对应波特率
- 假设当前设备地址对应 0x01,波特率对应 9600
问询帧湿度和温度数据问询帧
地址码 功能码 寄存器起始地址 寄存器长度 CRC低位 CRC高位 0x01 0x03 0x01 0xF4 0x00 0x02 0x84 0x05 大气压值+光照强度问询帧
地址码 功能码 寄存器起始地址 寄存器长度 CRC低位 CRC高位 0x01 0x03 0x01 0xF9 0x00 0x03 0xD4 0x06
地址码 功能码 有效字节数 大气压值 Lux高位 Lux低位 CRC低位 CRC高位 0x01 0x03 0x06 0x03 0xE8 0x00 0x01 0x38 0x80 0x02 0xF1
2.9.2 代码实现
c
#include "stm32f10x.h"
#include "led.h"
#include "key.h"
#include "delay.h"
#include "beep.h"
#include "usart1.h"
#include "adc.h"
#include "systick.h"
#include "tim6.h"
#include "tim3.h"
#include "sg90.h"
#include "myiic.h"
#include "spi.h"
#include "spi_flash.h"
#include "ESP8266.h"
#include "mqtt.h"
#include "rs485.h"
#include "modbus.h"
#include "stdio.h"
#include "stdlib.h"
#include "string.h"
#define SSID "RedMiGame"
#define PSK "12345678"
int main(void)
{
NVIC_SetPriorityGrouping(NVIC_PriorityGroup_2);
Led_Init();
USART1_Init(115200);
USART1_Interrupt_Enable();
RS485_Init(9600);
ModBus_TIMConfig(9600);
ESP8266_Init();
ESP8266_Connect(SSID, PSK);
MQTT_Config();
u8 mqtt_buffer[64] = {0};
while (1)
{
int wait_time = 500;
ModBus_Send03Cmd(1, 500, 2);
while (rs485_val.flag == 0 && wait_time > 0)
{
wait_time--;
SysTick_Delay_ms(1);
}
if (rs485_val.flag) {
Analysis_ModBus_Tem_Hum();
}
RS485_Clear();
ModBus_Send03Cmd(1, 505, 3);
wait_time = 500;
while (rs485_val.flag == 0 && wait_time > 0)
{
wait_time--;
SysTick_Delay_ms(1);
}
if (rs485_val.flag) {
Analysis_ModBus_BP_Lux();
}
RS485_Clear();
printf("Sensor_data 温度 : %d.%d, 湿度 : %d.%d, 大气压 : %d.%d, 光照强度 : %d\r\n",
sensor_data.tem / 10, sensor_data.tem % 10,
sensor_data.hum / 10, sensor_data.hum % 10,
sensor_data.bp / 10, sensor_data.bp % 10,
sensor_data.lux);
sprintf((char *)mqtt_buffer, "{\"Temp\":%d.%d,\"Hum\":%d.%d,\"Bp\":%d.%d,\"Lux\":%d}",
sensor_data.tem / 10, sensor_data.tem % 10,
sensor_data.hum / 10, sensor_data.hum % 10,
sensor_data.bp / 10, sensor_data.bp % 10,
sensor_data.lux);
MQTT_Send_Publish_Package(PUBLISH_TOPIC, (char *)mqtt_buffer, strlen((char *)mqtt_buffer));
memset(mqtt_buffer, 0, 64);
Led0_Ctrl(1);
SysTick_Delay_ms(1000);
Led0_Ctrl(0);
SysTick_Delay_ms(45000);
}
}
c
#include "modbus.h"
Sensor_Data sensor_data = {0};
void ModBus_Send03Cmd(u8 id, u16 addr, u16 data_len)
{
// 用于存储 03 功能码对应发送 ModBus 协议问询帧
u8 modbus_03_buffer[8];
// ModBus 协议组包,设备地址 + 03 功能码
modbus_03_buffer[0] = id;
modbus_03_buffer[1] = 0x03;
// 寄存器起始地址
modbus_03_buffer[2] = addr / 256; // 寄存器地址高位
modbus_03_buffer[3] = addr % 256; // 寄存器地址低位
// 请求数据长度
modbus_03_buffer[4] = data_len / 256; // 请求寄存器个数数据高位
modbus_03_buffer[5] = data_len % 256; // 请求寄存器个数数据高位
uint16_t crc = ModBus_CRC16(modbus_03_buffer, 6);
// CRC 校验位
modbus_03_buffer[6] = crc % 256;// CRC 数据校验结果低位
modbus_03_buffer[7] = crc / 256;// CRC 数据校验结果高位
RS485_SendBuffer(modbus_03_buffer, 8);
}
void Analysis_ModBus_Tem_Hum(void)
{
/*
模拟解析过程,真实数据在 RS485 对应 USART2 数据结构体中。
modbus_data <==> rs485.data
*/
// u8 modbus_data[] = {0x01, 0x03, 0x04, 0x02, 0x92, 0xFF, 0x9B, 0x5A, 0x3D};
// 首先计算收到数据对应的 CRC 结果
uint16_t crc = ModBus_CRC16(rs485_val.data, rs485_val.count - 2);
// 比较判断当前 CRC 对应数据情况。
if (crc != (rs485_val.data[rs485_val.count - 1] << 8 | rs485_val.data[rs485_val.count - 2]))
{
printf("CRC ERROR!\r\n");
// 如果两个数据不同,表示数据接收存在一定的错误
// 无需解析。擦除当前 RS485 接收数据内容。
return;
}
/*
根据当前应答帧数据分析
第一个字节对应设备地址
第二个字节对应当前主机发送给目标 485 ModBus 操作码
第三个字节对应当前 ModBus 应答帧有效数据个数。
考虑通用性代码实现,可以根据应答帧有效数据个数进行循环数据解析。
也可以根据应答帧数据排列情况直接解析
*/
sensor_data.hum = rs485_val.data[3] << 8 | rs485_val.data[4];
sensor_data.tem = rs485_val.data[5] << 8 | rs485_val.data[6];
return;
}
void Analysis_ModBus_BP_Lux(void)
{
// 首先计算收到数据对应的 CRC 结果
uint16_t crc = ModBus_CRC16(rs485_val.data, rs485_val.count - 2);
// 比较判断当前 CRC 对应数据情况。
if (crc != (rs485_val.data[rs485_val.count - 1] << 8 | rs485_val.data[rs485_val.count - 2]))
{
// 如果两个数据不同,表示数据接收存在一定的错误
// 无需解析。擦除当前 RS485 接收数据内容。
return;
}
/*
根据当前应答帧数据分析
第一个字节对应设备地址
第二个字节对应当前主机发送给目标 485 ModBus 操作码
第三个字节对应当前 ModBus 应答帧有效数据个数。
考虑通用性代码实现,可以根据应答帧有效数据个数进行循环数据解析。
也可以根据应答帧数据排列情况直接解析
*/
sensor_data.bp = rs485_val.data[3] << 8 | rs485_val.data[4];
sensor_data.lux = rs485_val.data[5] << 24
| rs485_val.data[6] << 16
| rs485_val.data[7] << 8
| rs485_val.data[8];
}
uint16_t ModBus_CRC16(uint8_t *data, uint16_t length)
{
uint16_t crc = 0xFFFF;
uint16_t i, j;
for (i = 0; i < length; i++) {
crc ^= (uint16_t)data[i];
for (j = 0; j < 8; j++) {
if (crc & 0x0001) {
crc >>= 1;
crc ^= 0xA001;
} else {
crc >>= 1;
}
}
}
return crc;
}
/*
定时器 TIM1 对应的 TIM1_UP_IRQn 中断处理函数,用于
在当前 TIM1 中的 CNT 计数器从 0 开始到设定的 ARR 值,数据溢出
触发当前中断
*/
void TIM7_IRQHandler(void)
{
#if 1
// printf("TIM1_UP_IRQHandler!\r\n");
// 获取当前 TIM1 TIM_FLAG_Update 中断标志位,如果是 Update 进行处理。
if (TIM_GetITStatus(TIM7, TIM_IT_Update))
{
// 1. 清除中断标志位
TIM_ClearITPendingBit(TIM7, TIM_IT_Update);
/*
设置当前定时器中断的计数器为 0,方便下一次重新计数。
*/
TIM_SetCounter(TIM7, 0);
// 本次监控 ModBus 问询 + 应答间隔时间任务完成,定时关闭
// 直到下一次进行 问询 + 应答 操作开启。
TIM_Cmd(TIM7, DISABLE);
/*
【重点】
当前数据帧时间间隔定时器处理函数已触发,表示数据已经进入
到 RS485 数据接收缓冲区
ModBus 接受数据,数据包内容
1. 设备地址(1 字节) 0x01
2. ModBus 功能码(1 字节) 0x03
3. 数据包有效字节个数(1 字节) 0x04
4. 数据内容 ==> 数据包有效字节个数 4
5. CRC 校验(2 字节)
根据当前数据包案例分析 ModBus 应答数据帧数据包最小数据为 5 个字节
设备地址(1 字节) + ModBus 功能码(1 字节) 数据包有效字节个数(1 字节)0x00
+ RC 校验(2 字节)
如果接收数据小于 5 个字节,表示当前 ModBus 数据帧接收不完全。
*/
if (rs485_val.count >= 5)
{
/*
rs485_val.count 接收到的有效字节个数大于等于 5 ,我们认为
当前 ModBus 接收数据包完整
将 rs485_val.flag 数据接收完毕标志位赋值为 1,可以继续后续的
数据分析操作。
*/
rs485_val.flag = 1;
printf("rs485_val.flag : %d\r\n", rs485_val.flag);
}
else
{
/*
如果接收数据小于 5 个字节,表示 ModBus 数据包不完成,
清空当前接收数据缓冲区所有内容。
*/
RS485_Clear();
}
// printf("TIM Stop!\r\n");
}
#else
TIM_ClearFlag(TIM7, TIM_FLAG_Update);
TIM_Cmd(TIM7, DISABLE);
#endif
return;
}
void ModBus_TIMConfig(u32 brr)
{
// 1. 时钟使能 TIM1
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM7, ENABLE);
TIM_Cmd(TIM7, DISABLE);
TIM_TimeBaseInitTypeDef TIM_InitStructure = {0};
// 设置当前进入带 TIM 定时器时钟的分频倍数,因为
// APB2 提供的最大时钟按照框图分析为 72 MHz,定时器初步分频控制
// 选择分频倍数为 1,到达定时器的时钟频率为 72MHz
TIM_InitStructure.TIM_ClockDivision = TIM_CKD_DIV1;
// 设置当前定时器中计数器计数规则向上计数。
TIM_InitStructure.TIM_CounterMode = TIM_CounterMode_Up;
// 当前定时器的预分频倍数,对应的 72 - 1 ,根据当前时钟周期 72MHz
// 计数器完成一次累加 / 递减操作,对应的时间为 1 us
TIM_InitStructure.TIM_Prescaler = 72 - 1;
/*
标准 ModBus 要求的间隔是 T3.5
当前传感器文档要求必须 >= T4
根据 brr 和数据传递方式计算
(1 / 960 * 1000 * 1000) * 3.5 ==> 3500000 / 960 ==> 3645.83 us ==> 3.645 ms
(1 / 9600 * 10 * 1000 * 1000) * 4 ==>
*/
TIM_InitStructure.TIM_Period = (uint16_t)(TIME_INTERVAL * 1000 * 1000 * 10 / brr);
TIM_TimeBaseInit(TIM7, &TIM_InitStructure);
// 配置中断,当前 TIM1 配置向上更新中断触发。
TIM_ITConfig(TIM7, TIM_IT_Update, ENABLE);
// 中断配置初始化
NVIC_InitTypeDef NVIC_InitStructure = {0};
NVIC_InitStructure.NVIC_IRQChannel = TIM7_IRQn; // TIM7_IRQn ==> TIM7_IRQHandler
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStructure);
TIM_Cmd(TIM7, DISABLE);
TIM_SetCounter(TIM7, 0);
}
c
#include "rs485.h"
RS485_Data rs485_val = {0};
void RS485_Init(u32 brr)
{
// 1. 时钟使能 USART2 GPIOA GPIOD
RCC_APB2PeriphClockCmd(RCC_APB2ENR_IOPAEN | RCC_APB2ENR_IOPDEN, ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1ENR_USART2EN, ENABLE);
// 2. GPIO PA2 配置 复用推挽
GPIO_InitTypeDef GPIO_InitStructure = {0};
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// GPIO PA3 配置 浮空输入
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// GPIO PD7 配置 推挽
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_7;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOD, &GPIO_InitStructure);
/// 3. USART2 配置
USART_InitTypeDef USART_InitStructure = {0};
USART_InitStructure.USART_BaudRate = brr;
USART_InitStructure.USART_WordLength = USART_WordLength_8b;
USART_InitStructure.USART_Parity = USART_Parity_No;
USART_InitStructure.USART_StopBits = USART_StopBits_1;
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx;
USART_Init(USART2, &USART_InitStructure);
USART_Cmd(USART2, ENABLE);
// 4. USART2 串口中断配置
// USART_ITConfig(USART2, USART_IT_IDLE, ENABLE);
USART_ITConfig(USART2, USART_IT_RXNE, ENABLE);
NVIC_InitTypeDef NVIC_InitStructure = {0};
NVIC_InitStructure.NVIC_IRQChannel = USART2_IRQn;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStructure);
}
void USART2_IRQHandler(void)
{
/*
使用定时器完成数据接收判断,同时利用定时器完成数据间隔处理
*/
if (USART_GetITStatus(USART2, USART_IT_RXNE) == SET)
{
// 清除中断标志位
uint8_t data = USART_ReceiveData(USART2);
USART_ClearITPendingBit(USART2, USART_IT_RXNE);
if (0 == rs485_val.count)
{
/*
当前 USART2 RX 收到了第一个字节数据,开启定时器
此时定时器有两个作用
1. 监控判断 T3.5 数据间隔数据
2. 监控 T1.5 数据帧内字节数据间隔
*/
TIM_SetCounter(TIM7, 0);
TIM_Cmd(TIM7, ENABLE);
}
// 如果接收的不是第一个字节数据,将定时器计数器初始化为 0,重新计数
// 如果接收的数据是最后一个字节,后续 RXNE 无法再次触发,定时器达到
// 时间周期之后,触发 TIM1_UP_IRQHandler 中断函数。
TIM_SetCounter(TIM7, 0);
// 并且将数据进行存储。
rs485_val.data[rs485_val.count++] = USART_ReceiveData(USART2);
}
}
void RS485_SendByte(u8 byte)
{
while (USART_GetFlagStatus(USART2, USART_FLAG_TC) == RESET);
USART_SendData(USART2, byte);
}
void RS485_SendBuffer(u8 *buffer, u16 count)
{
// PD7 高电平
GPIO_SetBits(GPIOD, GPIO_Pin_7);
while (count--)
{
RS485_SendByte(*buffer);
buffer++;
}
while (USART_GetFlagStatus(USART2, USART_FLAG_TC) == RESET);
// PD7 低电平
GPIO_ResetBits(GPIOD, GPIO_Pin_7);
}
void RS485_SendString(const char * str)
{
// PD7 高电平
GPIO_SetBits(GPIOD, GPIO_Pin_7);
while (*str)
{
RS485_SendByte(*str);
str++;
}
while (USART_GetFlagStatus(USART2, USART_FLAG_TC) == RESET);
// PD7 低电平
GPIO_ResetBits(GPIOD, GPIO_Pin_7);
}
void RS485_Clear(void)
{
memset((void *)&rs485_val, 0, sizeof(RS485_Data));
}
h
#ifndef _RS485_H
#define _RS485_H
#include "stm32f10x.h"
#include "stdio.h"
#include "stdlib.h"
#include "string.h"
#include "usart1.h"
#include "systick.h"
#include "led.h"
#include "beep.h"
#define RS485_DATA_SIZE (256)
typedef struct rs485_data
{
u8 data[RS485_DATA_SIZE]; // 接受数据缓冲区
u8 flag; // 数据处理标志位
u16 count; // 读取到的有效字节个数
} RS485_Data;
extern RS485_Data rs485_val;
void RS485_Init(u32 brr);
void RS485_SendByte(u8 byte);
void RS485_SendBuffer(u8 *buffer, u16 count);
void RS485_SendString(const char * str);
void RS485_Clear(void);
#endif
#ifndef _MODBUS_H
#define _MODBUS_H
#include "stm32f10x.h"
#include "rs485.h"
#include "stdio.h"
#include "stdlib.h"
#include "string.h"
typedef struct
{
short tem;
short hum;
short bp;
u32 lux;
} Sensor_Data;
extern Sensor_Data sensor_data;
/**
* @brief ModBus 发送 03 功能码函数,对应功能是读取多个【输入寄存器】
* 或者【保持寄存器】数据
*
* @param id 设备地址
* @param addr 读取目标数据寄存器地址
* @param data_len 读取的数据个数
*/
void ModBus_Send03Cmd(u8 id, u16 addr, u16 data_len);
void Analysis_ModBus_Tem_Hum(void);
void Analysis_ModBus_BP_Lux(void);
/**
* @brief ModBus CRC 校验函数
*/
uint16_t ModBus_CRC16(uint8_t *data, uint16_t length);
/*
利用定时器设置数据帧发送之后的 T3.5 时间间隔,也需要参考当前设备
指定的数据帧间隔时间。
标准 ModBus 协议数据帧间隔时间是 3.5 字节/字符时间
当前使用的传感器对应的间隔时间要求为 >= 4 字节/字符时间
需要根据当前设备所需调整代码。
【文档】必须认真看!!!
*/
#if 1
#define TIME_INTERVAL (3.5)
#else
#define TIME_INTERVAL (4.0)
#endif
/**
* @brief ModBus 数据帧间隔 T3.5 定时器处理函数,利用中断和定时周期
* 控制 ModBus 数据帧间隔。
* 当前选择 TIM1
*/
void ModBus_TIMConfig(u32 brr);
#endif
ModBus RTU(Remote Terminal Unit)是一种在工业自动化领域广泛应用的串行通信协议,它定义了一种数据传输和交互的标准模式,以下从多个方面详细介绍 ModBus RTU 数据模式:
基本概念
ModBus RTU 采用二进制编码形式来表示数据,以紧凑的方式在主站(通常是 PLC、工控机等)和从站(如传感器、执行器等设备)之间进行通信。在 RTU 模式下,每个字节包含 8 位二进制数据,通信效率较高,适合于距离较短、速率要求较高的应用场景。
消息帧结构
ModBus RTU 通信以消息帧为单位进行数据传输,一个完整的消息帧由以下几个部分组成:
- 地址域:1 个字节,用于标识从站设备的地址。主站通过指定不同的地址来选择与之通信的从站,地址范围通常是 1 - 247,其中 0 为广播地址,主站向地址 0 发送的命令会被所有从站接收,但从站不会返回响应。
- 功能码 :1 个字节,用于指示主站希望执行的操作类型。常见的功能码有:
- 01 号功能码:读取线圈状态,用于获取从站的数字输出状态。
- 02 号功能码:读取离散输入,用于获取从站的数字输入状态。
- 03 号功能码:读取保持寄存器,用于获取从站的模拟输出值。
- 04 号功能码:读取输入寄存器,用于获取从站的模拟输入值。
- 05 号功能码:写单个线圈,用于控制从站的单个数字输出。
- 06 号功能码:写单个保持寄存器,用于设置从站的单个模拟输出值。
- 15 号功能码:写多个线圈,用于同时控制从站的多个数字输出。
- 16 号功能码:写多个保持寄存器,用于同时设置从站的多个模拟输出值。
- 数据域:可变长度,用于携带与功能码相关的具体数据。例如,在使用 03 号功能码读取保持寄存器时,数据域包含要读取的寄存器起始地址和寄存器数量;在使用 06 号功能码写单个保持寄存器时,数据域包含要写入的寄存器地址和数据值。
- CRC 校验域:2 个字节,用于检测消息在传输过程中是否发生错误。CRC(Cyclic Redundancy Check)是一种循环冗余校验算法,主站和从站在发送和接收消息时都会计算 CRC 值,并进行比较。如果计算得到的 CRC 值与接收到的 CRC 值不一致,则认为消息传输有误,接收方会丢弃该消息。
数据存储和表示
- 寄存器 :ModBus RTU 协议使用寄存器来存储和传输数据,主要包括以下几种类型的寄存器:
- 线圈(Coils):每个线圈对应一个二进制位,用于表示数字输出状态(ON 或 OFF),可以通过功能码进行读取和写入操作。
- 离散输入(Discrete Inputs):每个离散输入对应一个二进制位,用于表示数字输入状态(ON 或 OFF),只能通过功能码进行读取操作。
- 保持寄存器(Holding Registers):每个保持寄存器为 16 位(2 个字节),用于存储模拟输出值,如温度、压力等,可以通过功能码进行读取和写入操作。
- 输入寄存器(Input Registers):每个输入寄存器为 16 位(2 个字节),用于存储模拟输入值,如传感器采集的数据,只能通过功能码进行读取操作。
- 数据表示:寄存器中的数据可以表示不同类型的数值,如整数、浮点数等。对于整数类型的数据,通常直接以二进制形式存储在寄存器中;对于浮点数类型的数据,需要按照特定的格式进行编码和解码,常见的格式有 IEEE 754 单精度浮点数(32 位,占用 2 个连续的寄存器)。
通信过程
- 主站请求:主站根据需要向从站发送请求消息,消息帧包含地址域、功能码、数据域和 CRC 校验域。
- 从站响应:从站接收到主站的请求消息后,首先检查地址域和 CRC 校验值。如果地址匹配且 CRC 校验通过,则根据功能码执行相应的操作,并返回响应消息。响应消息的结构与请求消息类似,包含地址域、功能码、数据域和 CRC 校验域,其中数据域包含操作结果或所需的数据。
- 错误处理:如果从站在处理请求时发生错误,如请求的寄存器地址超出范围、功能码不支持等,会返回一个异常响应消息。异常响应消息的功能码最高位会被置为 1,同时在数据域中包含一个错误码,用于指示具体的错误类型。
示例代码(Python 实现读取保持寄存器)
python
import minimalmodbus
# 初始化仪器对象
instrument = minimalmodbus.Instrument('/dev/ttyUSB0', 1) # 串口设备和从站地址
instrument.serial.baudrate = 9600 # 波特率
instrument.serial.bytesize = 8
instrument.serial.parity = minimalmodbus.serial.PARITY_NONE
instrument.serial.stopbits = 1
instrument.serial.timeout = 1 # 超时时间
instrument.mode = minimalmodbus.MODE_RTU # RTU 模式
try:
# 读取保持寄存器,起始地址为 0,读取 1 个寄存器
result = instrument.read_register(0, 0)
print(f"读取到的数据: {result}")
except Exception as e:
print(f"通信错误: {e}")
以上代码使用 minimalmodbus 库实现了通过 ModBus RTU 协议读取从站保持寄存器的功能。在实际应用中,你可以根据需要修改串口设备、从站地址、波特率、寄存器地址和数量等参数。
在 ModBus RTU 通信中,8421 BCD(Binary-Coded Decimal)码是一种常用的数据编码方式,下面为你详细介绍:
基本概念
8421 BCD 码是用 4 位二进制数来表示 1 位十进制数的编码方式。在 8421 BCD 码里,每 4 位二进制数的权重分别为 8、4、2、1,其取值范围是 0000 - 1001,分别对应十进制数 0 - 9。这种编码方式将十进制数的每一位单独用 4 位二进制数表示,方便在数字系统中处理和显示十进制数据。
在 ModBus RTU 中的应用
在 ModBus RTU 协议里,数据通常以字节(8 位)或寄存器(16 位)为单位进行传输。当使用 8421 BCD 码时,一个字节可以表示 2 位十进制数,一个 16 位寄存器可以表示 4 位十进制数。
编码示例
- 单字节编码:假设要编码十进制数 25,将其拆分为 2 和 5。2 的 8421 BCD 码是 0010,5 的 8421 BCD 码是 0101,组合起来得到 0010 0101,即十六进制的 0x25。
- 双字节(16 位寄存器)编码:若要编码十进制数 3698,拆分为 3、6、9、8。3 的 8421 BCD 码是 0011,6 是 0110,9 是 1001,8 是 1000。组合后得到 0011 0110 1001 1000,即十六进制的 0x3698。
解码示例
- 单字节解码:若接收到一个字节数据 0x47,将其拆分为高 4 位 0100(对应十进制 4)和低 4 位 0111(对应十进制 7),解码后的十进制数就是 47。
- 双字节解码:当接收到一个 16 位寄存器数据 0x2356,拆分为 0010(2)、0011(3)、0101(5)、0110(6),解码后的十进制数为 2356。
优缺点
优点
- 直观性:8421 BCD 码与十进制数的对应关系清晰,便于人类理解和处理,在需要直接显示或输入十进制数据的场合非常方便。
- 转换简单:与十进制数之间的转换相对容易,不需要复杂的算法。
缺点
- 编码效率低:相比纯二进制编码,8421 BCD 码需要更多的位数来表示相同范围的数值。例如,一个 16 位的纯二进制数可以表示 0 - 65535 的数值,而 16 位的 8421 BCD 码只能表示 0 - 9999 的数值。
- 运算复杂:在进行算术运算时,8421 BCD 码不如纯二进制编码高效,需要额外的调整步骤来保证结果的正确性。
代码示例(Python 实现 BCD 码的编码和解码)
python
# 十进制数转 BCD 码
def decimal_to_bcd(decimal_num):
bcd_num = 0
factor = 1
while decimal_num > 0:
digit = decimal_num % 10
bcd_num += digit * factor
factor *= 16
decimal_num //= 10
return bcd_num
# BCD 码转十进制数
def bcd_to_decimal(bcd_num):
decimal_num = 0
factor = 1
while bcd_num > 0:
digit = bcd_num % 16
decimal_num += digit * factor
factor *= 10
bcd_num //= 16
return decimal_num
# 示例
decimal = 2356
bcd = decimal_to_bcd(decimal)
print(f"十进制 {decimal} 转换为 BCD 码: {hex(bcd)}")
decoded = bcd_to_decimal(bcd)
print(f"BCD 码 {hex(bcd)} 转换为十进制: {decoded}")
以上代码实现了十进制数与 8421 BCD 码之间的相互转换。在 ModBus RTU 通信中,你可以根据实际需求使用这些函数对数据进行编码和解码。



