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

  • 接收

    • 接受数据,数据 1 对应 A 端子电压 - B 端子电压 > 200 mV
    • 接受数据,数据 0 对应 B 端子电压 - A 端子电压 > 200 mV
1.4 485 原理图分析
1.4.1 原理图分析连线关系和对应引脚

2

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 芯片进入数据接收模式。
#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
#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;光纤、无线等等)上的异步串行传输。

5

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

  • 当前图例是一主多从方式进行 RS485 电气链接。
  • 可用 RS485 电气连接方式,基于 ModBus 协议,可以完成 RS485 一主多从设备控制
  • 以下是设备地址范围,485 设备支持修改【波特率】和【设备地址】
广播地址设备可用地址保留地址
0x00x01 ~ 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 设备内存地址管理方式。

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

  • ![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=http%3A%2F%2Fwww.ip33.com%2Fcrc.html&pos_id=img-SHwZZd8u(https//www.ip33.com/crc.html)
2.9 RS485 + ModBus 案例
2.9.1 开发分析

读取 485 接口的传感器数据

  • 因为当前设备有且只有温湿度,大气压,光照强度,根据文档分析,对应的【输入寄存器】位置

  • 需要知晓当前设备的地址和对应波特率

    • 假设当前设备地址对应 0x01,波特率对应 9600

问询帧

  • 湿度和温度数据问询帧

    地址码功能码寄存器起始地址寄存器长度CRC低位CRC高位
    0x010x030x01 0xF40x00 0x020x840x05
  • 大气压值+光照强度问询帧

    地址码功能码寄存器起始地址寄存器长度CRC低位CRC高位
    0x010x030x01 0xF90x00 0x030xD40x06
    地址码功能码有效字节数大气压值Lux高位Lux低位CRC低位CRC高位
    0x010x030x060x03 0xE80x00 0x010x38 0x800x020xF1
2.9.2 代码实现
#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);
}
}
#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);
  }
#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));
}
#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 实现读取保持寄存器)

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 码的编码和解码)

# 十进制数转 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 通信中,你可以根据实际需求使用这些函数对数据进行编码和解码。