STM32 HAL库实现Modbus RTU从站

相关文章:STM32 HAL库实现Modbus RTU主站

本文实现了01、03、05、06、10、16功能码,Modbus RTU只需要在主站发送询问时,从站才需要回复相应的信息。

有关CubeMX的配置,可以查看主站的文章,有详细的配置过程。

01、05、15这三种功能码都是对线圈进行操作的,即对布尔量的数据进行操作。(对应PLC中的M、HM等)
03、06、16这三种功能码都是对寄存器进行操作,即对一个字进行操作。(对应PLC中的D、HD等)
下面先介绍这几种功能码的报文格式,方便程序编写。

01功能码,读线圈指令

例如主站希望获取02号从站,线圈起始地址为1000(0x03E8)后10(0x000A)个连续线圈的状态,主站应该发送的报文为:

地址功能码线圈地址
(高字节)
线圈地址
(低字节)
线圈个数
(高字节)
线圈个数
(低字节)
CRC校验
(高字节)
CRC校验
(低字节)
020103E8000A3C4E

假设从站1000~1009号线圈的状态分别为:

1000100110021003100410051006100710081009
ONONOFFONONOFFOFFONOFFON

那么,从站应该返回的报文为:

地址功能码字节数数据内容
(字节一)
数据内容
(字节二)
CRC校验
(高字节)
CRC校验
(低字节)
0201029B02170D

第一个字节9B就对应1007~1000,对应的二进制为1001 1011,第二个字节02对应1009~1008,对应的二进制为0000 0010。

03功能码,读寄存器指令

例如,主站希望获取02号从站,寄存器起始地址为2000(0x07D0)后5个连续寄存器的状态,主站应该发送的报文为:

地址功能码寄存器起始地址
(高字节)
寄存器起始地址
(低字节)
寄存器个数
(高字节)
寄存器个数
(低字节)
CRC校验
(高字节)
CRC校验
(低字节)
020307D000058577

假设从站的寄存器2000~2004的状态分别为:

20002001200220032004
+12345-123450-32768+32767

这里返回2000~2004这5个寄存器的数据给主站,每个寄存器分成2个字节发送给主站,分为高低位。从站返回的报文应该是:

地址功能码字节数数据内容
(高字节)
数据内容
(低字节)
数据内容
(高字节)
数据内容
(低字节)
数据内容
(高字节)
数据内容
(低字节)
数据内容
(高字节)
数据内容
(低字节)
数据内容
(高字节)
数据内容
(低字节)
CRC校验
(高字节)
CRC校验
(低字节)
02030A3039CFC7000080007FFFACB7

05功能码,写单个线圈

例如,主站希望写02号从站,给地址为2000的线圈写1,主站应该发送的报文为:

地址功能码线圈地址
(高字节)
线圈地址
(低字节)
数据内容
(高字节)
数据内容
(低字节)
CRC校验
(高字节)
CRC校验
(低字节)
020507D0FF008C84

注意:使用05功能码写单个线圈时,如果写1(ON),第5、6个字节固定写FF00,如果写0,第5、6个字节固定写0000。
从站会原样返回主站发送的内容给主站确认:

地址功能码线圈地址
(高字节)
线圈地址
(低字节)
数据内容
(高字节)
数据内容
(低字节)
CRC校验
(高字节)
CRC校验
(低字节)
020507D0FF008C84

06功能码,写单个寄存器

例如,主站希望写02号从站,地址为500的寄存器写-2345,主站发送的报文为:

地址功能码寄存器地址
(高字节)
寄存器地址
(低字节)
数据内容
(高字节)
数据内容
(低字节)
CRC校验
(高字节)
CRC校验
(低字节)
020601F4F6D7CE09

从站会原样返回主站发送的内容给主站确认:

地址功能码寄存器地址
(高字节)
寄存器地址
(低字节)
数据内容
(高字节)
数据内容
(低字节)
CRC校验
(高字节)
CRC校验
(低字节)
020601F4F6D7CE09

15功能码,写多个线圈

例如,主站希望给02号从站的100~109号线圈,分别写入下面的值:

100101102103104105106107108109
ONOFFONOFFONONOFFOFFONON

主站发送的报文为:

地址功能码线圈起始地址
(高字节)
线圈起始地址
(低字节)
线圈个数
(高字节)
线圈个数
(低字节)
字节数数据内容
(低字节)
数据内容
(高字节)
CRC校验
(高字节)
CRC校验
(低字节)
020F0064000A023503AEBD

数据分为2个字发送,高位在前低位在后,因此数据的十六进制就是0335,转换为2进制为:1100110101,分别对应509~500号线圈。
从站接收到主站的报文后,会返回:

地址功能码线圈起始地址
(高字节)
线圈起始地址
(低字节)
线圈个数
(高字节)
线圈个数
(低字节)
CRC校验
(高字节)
CRC校验
(低字节)
020F0064000A9420

16功能码,写多个寄存器

例如,主站希望给02号从站的2000~2002号寄存器,分别写入下面的值:

200020012002
+123450-12345

主站发送报文:

地址功能码寄存器起始地址
(高字节)
寄存器起始地址
(低字节)
寄存器个数
(高字节)
寄存器个数
(低字节)
字节数数据内容
(低字节)
数据内容
(高字节)
数据内容
(低字节)
数据内容
(高字节)
数据内容
(低字节)
数据内容
(高字节)
CRC校验
(高字节)
CRC校验
(低字节)
021007D000030630390000CFC77009

从站收到后,发送报文给主站确认:

地址功能码寄存器起始地址
(高字节)
寄存器起始地址
(低字节)
寄存器个数
(高字节)
寄存器个数
(低字节)
CRC校验
(高字节)
CRC校验
(低字节)
021007D0000380B6

Modbus RTU从站代码

Modbus RTU从站源码下载
首先在工程目录创建一个ModbusSlave文件夹,创建Modbus_Slave.c和Modbus_Slave.h文件。
在CmakeList.txt文件中,添加头文件目录和源文件。
在 target_include_directories 部分,添加头文件所在目录
在 target_sources 部分,添加源文件。
下面的代码还包含OLED的头文件和源文件,不需要使用可以去掉。

CmakeList.txt

123456789101112
# Add sources to executabletarget_sources(${CMAKE_PROJECT_NAME} PRIVATE    # Add user sources here    ${CMAKE_SOURCE_DIR}/ModbusSlave/Modbus_Slave.c    ${CMAKE_SOURCE_DIR}/OLED/OLED.c)# Add include pathstarget_include_directories(${CMAKE_PROJECT_NAME} PRIVATE    # Add user defined include paths    ${CMAKE_SOURCE_DIR}/ModbusSlave    ${CMAKE_SOURCE_DIR}/OLED)

Modbus_Slave.c

1234567891011121314151617
#include "Modbus_Slave.h"#include "main.h"#include <stdint.h>#include "OLED.h" extern UART_HandleTypeDef huart3;extern TIM_HandleTypeDef htim2; #define Modbus_Slave_ID 0x02 //Modbus RTU从站地址#define RX_BUFFER_SIZE 256 //接收缓冲区大小#define Frame_Timeout_ms 4 //帧间隔超时时间,单位为毫秒 uint8_t rx_buffer[RX_BUFFER_SIZE]; //接收缓冲区uint8_t rx_index = 0; //接收缓冲区索引uint32_t last_rx_time = 0; //上次接收数据的时间戳uint16_t Modbus_Register[5000];//寄存器数组,存储Modbus寄存器数据uint16_t Modbus_Coil[1000];//线圈数组,存储Modbus线圈数据

这里需要注意,Frame_Timeout_ms的时间由波特率决定,由于设定的波特率为9600,因此这里设置为4。

123456789101112131415161718192021
//Modbus定时器中断回调函数void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim){    if(htim->Instance == TIM2)    {        if (HAL_GetTick() - last_rx_time >= Frame_Timeout_ms)        {            if (rx_index > 0)            {                // 1. 先关闭正在进行的接收中断                HAL_UART_AbortReceive_IT(&huart3);                // 2. 处理已接收的帧                Modbus_Handle_Rx(rx_buffer, rx_index);                // 3. 重置索引                rx_index = 0;                // 4. 重新启动接收中断,从缓冲区头部开始                HAL_UART_Receive_IT(&huart3, &rx_buffer[0], 1);            }        }    }}

如果当前的时间和最后一次接到报文的时间大于超时时间(波特率9600的情况下为4ms),表明当前总线上处于空闲状态,即接收到了完整的一帧数据。
接收数据时,先确认接收缓冲区中确实有数据。如果有数据,先暂停接收中断,再去处理接收到的帧,最后再重新开启接收。

1234567891011121314151617181920
//Modbus接收数据回调函数void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart){    if (huart->Instance == USART3) //检查是否为串口3    {        //检查缓冲区是否已满        if (rx_index < RX_BUFFER_SIZE - 1)        {            last_rx_time = HAL_GetTick(); //更新接收时间戳            rx_index++; //接收到的数据存储到缓冲区,索引加1            HAL_UART_Receive_IT(&huart3, &rx_buffer[rx_index], 1); //继续接收下一个字节        }        else        {            //缓冲区已满,丢弃数据并重置索引            rx_index = 0;            HAL_UART_Receive_IT(&huart3, &rx_buffer[rx_index], 1); //重新开始接收        }    }}

和定时器中断进行配合,流程为:
启动接收中断 → 一个字节一个字节地进中断,记录时间 → 静默超时后,定时器中断中止接收,处理帧,重置索引 → 再次启动接收中断,循环往复。

12345
void Modbus_Slave_Init(void){    HAL_TIM_Base_Start_IT(&htim2);    HAL_UART_Receive_IT(&huart3, &rx_buffer[0], 1);}

Modbus从站初始化,启动定时器中断和串口接收中断。

123456789101112
//Modbus发送数据函数void Modbus_Send(uint8_t *data, uint16_t length){    // 发送前:使能发送,禁止接收    HAL_GPIO_WritePin(GPIOE, GPIO_PIN_3, GPIO_PIN_SET);    //通过串口3发送数据    HAL_UART_Transmit(&huart3, data, length, HAL_MAX_DELAY);     //等待发送完成    while (__HAL_UART_GET_FLAG(&huart3, UART_FLAG_TC) == RESET);    // 发送后:禁止发送,使能接收    HAL_GPIO_WritePin(GPIOE, GPIO_PIN_3, GPIO_PIN_RESET);}

Modbus发送函数,需要控制MAX485的RE和DE引脚。在发送数据之前,必须先将收发器置为发送模式,发送完成后恢复。发送数据时,RE和DE引脚悬空,接收数据时,RE和DE都接地。

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
//接收数据处理函数void Modbus_Handle_Rx(uint8_t *data, uint8_t length){    //crc校验    uint16_t received_crc = (data[length - 1] << 8) | data[length - 2]; //接收到的CRC值    uint16_t calculated_crc = Modbus_CRC16(data, length - 2);//计算得到的CRC值,排除最后两个字节的CRC    if (received_crc != calculated_crc)    {        return; //CRC校验失败,丢弃数据    }    if(data[0] != Modbus_Slave_ID)//检查从站地址是否匹配,如果不匹配则丢弃数据    {        return;     }    //解析从站返回的数据,根据功能码进行处理    uint8_t function_code = data[1]; //功能码    //根据功能码不同,进行不同的处理    if (function_code == 0x01) //功能码01处理    {        //解析地址        uint16_t start_address = (data[2] << 8) | data[3]; //起始地址        //解析线圈数量        uint16_t num_coils = (data[4] << 8) | data[5]; //线圈数量        //调用01功能码处理函数        Modbus_Slave_01(num_coils, start_address);    }    else if (function_code == 0x03)//功能码03处理    {        //解析地址        uint16_t start_address = (data[2] << 8) | data[3]; //起始地址        //解析寄存器数量        uint16_t num_registers = (data[4] << 8) | data[5]; //寄存器数量        //调用03功能码处理函数        Modbus_Slave_03(num_registers, start_address);    }    else if(function_code == 0x05) //功能码05处理    {        //解析地址        uint16_t coil_address = (data[2] << 8) | data[3]; //线圈地址        //解析线圈值        uint8_t coil_value = (data[4] == 0xFF) ? 1 : 0; //线圈值,0xFF表示ON,0x00表示OFF        //更新线圈状态        if (coil_value)        {            Modbus_Coil[coil_address / 16] |= (1 << (coil_address % 16)); //设置对应位为1        }        else        {            Modbus_Coil[coil_address / 16] &= ~(1 << (coil_address % 16)); //设置对应位为0        }        //调用05功能码处理函数        Modbus_Slave_05(coil_address, coil_value);    }    else if (function_code == 0x06) //功能码06处理    {        //解析地址        uint16_t reg_address = (data[2] << 8) | data[3]; //寄存器地址        //解析寄存器值        uint16_t reg_value = (data[4] << 8) | data[5]; //寄存器值        //更新寄存器值        Modbus_Register[reg_address] = reg_value;        //调用06功能码处理函数        Modbus_Slave_06(reg_address, reg_value);    }    else if (function_code == 0x0F) //功能码15处理    {        //解析地址        uint16_t start_address = (data[2] << 8) | data[3]; //起始地址        //解析线圈数量        uint16_t num_coils = (data[4] << 8) | data[5]; //线圈数量        //更新线圈状态        for (uint16_t i = 0; i < num_coils; i++)        {            uint16_t coil_index = start_address + i;      // 绝对线圈地址            uint16_t word_index = coil_index / 16;       // 在Modbus_Coil数组中的索引(第几个uint16_t)            uint8_t bit_position = coil_index % 16;      // 在该uint16_t中的位位置(0-15)             // 检查数据区中对应的位是否为ON            if (data[7 + i / 8] & (1 << (i % 8)))            {                Modbus_Coil[word_index] |= (1 << bit_position); //设置对应位为1            }            else            {                Modbus_Coil[word_index] &= ~(1 << bit_position); //设置对应位为0            }        }        Modbus_Slave_15(start_address, num_coils);    }    else if (function_code == 0x10) //功能码16处理    {        //解析地址        uint16_t reg_address = (data[2] << 8) | data[3]; //寄存器地址        //解析寄存器数量        uint16_t num_registers = (data[4] << 8) | data[5]; //寄存器数量        //更新寄存器值        for (uint16_t i = 0; i < num_registers; i++)        {            Modbus_Register[reg_address + i] = (data[7 + i * 2] << 8) | data[8 + i * 2]; //更新寄存器值        }        //调用16功能码处理函数        Modbus_Slave_16(reg_address, num_registers, (uint16_t *)&data[7]);    }}

接收数据的处理函数。根据不同的功能码分别处理,主要是保存寄存器/线圈的值。
处理数据之前,先进行CRC校验和从站号是否匹配,不正确直接丢弃。

1234567891011121314151617181920212223242526272829303132333435363738
//01功能码,Modbus读取线圈状态void Modbus_Slave_01(uint16_t Num_Coils,uint16_t Start_Coils){    uint8_t TX_Frame[5 + (Num_Coils + 7) / 8]; //根据需要调整缓冲区大小    uint16_t crc;    uint8_t byte_count = (Num_Coils + 7) / 8;     TX_Frame[0] = Modbus_Slave_ID; //Modbus RTU从站地址    TX_Frame[1] = 0x01; //功能码01    TX_Frame[2] = (Num_Coils + 7) / 8; //字节数     // 初始化数据区为0    for (uint8_t i = 0; i < byte_count; i++)    {        TX_Frame[3 + i] = 0;    }      // 填充线圈状态,每个uint16_t存储16个线圈    for (uint16_t i = 0; i < Num_Coils; i++)    {        uint16_t coil_index = Start_Coils + i;      // 绝对线圈地址        uint16_t word_index = coil_index / 16;       // 在Modbus_Coil数组中的索引(第几个uint16_t)        uint8_t bit_position = coil_index % 16;      // 在该uint16_t中的位位置(0-15)         // 检查该线圈是否为ON        if (Modbus_Coil[word_index] & (1 << bit_position))        {            // 在响应帧中设置对应位            TX_Frame[3 + i / 8] |= (1 << (i % 8));        }    }     crc = Modbus_CRC16(TX_Frame, 3 + (Num_Coils + 7) / 8); //计算CRC校验码    TX_Frame[3 + (Num_Coils + 7) / 8] = crc & 0xFF; //CRC低字节    TX_Frame[4 + (Num_Coils + 7) / 8] = (crc >> 8) & 0xFF; //CRC高字节     Modbus_Send(TX_Frame, 5 + (Num_Coils + 7) / 8);}

功能码1的处理,主要是回复主站查询地址的线圈状态,并返回给主站。
TX_Frame[2] = (Num_Coils + 7) / 8;由于Modbus报文一帧就是1个字,每8个线圈通过一个字(一帧报文)发送,每个线圈占一个二进制的位,因此一个字就是8个线圈(8个二进制位)。
比如查询9个连续的线圈,因为一帧只能保存8个二进制位,已经不够储存9个二进制位了,因此通过2帧发送。TX_Frame[2]就是2。

1234567891011121314151617181920
//03功能码,Modbus读取保持寄存器void Modbus_Slave_03(uint16_t Num_Regs,uint16_t Start_Regs){    uint8_t TX_Frame[5 + Num_Regs * 2]; //根据需要调整缓冲区大小    uint16_t crc;     TX_Frame[0] = Modbus_Slave_ID;//Modbus RTU从站地址    TX_Frame[1] = 0x03;    TX_Frame[2] = Num_Regs * 2;    for (uint16_t i = 0; i < Num_Regs; i++)     {        TX_Frame[3 + i * 2] = (Modbus_Register[Start_Regs + i] >> 8) & 0xFF;        TX_Frame[4 + i * 2] = Modbus_Register[Start_Regs + i] & 0xFF;    }    crc = Modbus_CRC16(TX_Frame, 3 + 2 * Num_Regs); //计算CRC校验码    TX_Frame[3 + 2 * Num_Regs] = crc & 0xFF; //CRC低字节    TX_Frame[4 + 2 * Num_Regs] = (crc >> 8) & 0xFF; //CRC高字节     Modbus_Send(TX_Frame, 5 + 2 * Num_Regs);}

功能码3的处理,这里TX_Frame[2] = Num_Regs * 2;是因为一个寄存器就是通过2帧发送的(一个寄存器就是一个字,两个字节,16个二进制位,而一帧只能发送8个二进制位,一个字节)
构建数据帧时,就是找到指定的寄存器,放到指定的位置。

12345678910111213141516171819
//05功能码,Modbus写单个线圈void Modbus_Slave_05(uint16_t Coil_Address, uint8_t Coil_Value){    uint8_t TX_Frame[8]; //根据需要调整缓冲区大小    uint16_t crc;     TX_Frame[0] = Modbus_Slave_ID; //Modbus RTU从站地址    TX_Frame[1] = 0x05;    TX_Frame[2] = (Coil_Address >> 8) & 0xFF; //线圈地址高字节    TX_Frame[3] = Coil_Address & 0xFF; //线圈地址低字节    TX_Frame[4] = (Coil_Value == 0) ? 0x00 : 0xFF; //线圈值,0x00表示OFF,0xFF表示ON    TX_Frame[5] = 0x00; //线圈值低字节,保持为0     crc = Modbus_CRC16(TX_Frame, 6); //计算CRC校验码    TX_Frame[6] = crc & 0xFF; //CRC低字节    TX_Frame[7] = (crc >> 8) & 0xFF; //CRC高字节     Modbus_Send(TX_Frame, 8);}

05功能码的处理,这里会把发送的报文原样返回给主站。
如果线圈值是0,TX_Frame[4]TX_Frame[5]都写0x00
如果线圈值是1,TX_Frame[4]写0xFF,TX_Frame[5]写0x00
因为在Modbus_Handle_Rx函数中已经把主站写线圈的数据放在了线圈数组中,所以这里等同于原样返回。

12345678910111213141516171819
//06功能码,Modbus写单个保持寄存器void Modbus_Slave_06(uint16_t Reg_Address, uint16_t Reg_Value){    uint8_t TX_Frame[8]; //根据需要调整缓冲区大小    uint16_t crc;     TX_Frame[0] = Modbus_Slave_ID;//Modbus RTU从站地址    TX_Frame[1] = 0x06;    TX_Frame[2] = (Reg_Address >> 8) & 0xFF; //寄存器地址高字节    TX_Frame[3] = Reg_Address & 0xFF; //寄存器地址低字节    TX_Frame[4] = (Reg_Value >> 8) & 0xFF; //寄存器值高字节    TX_Frame[5] = Reg_Value & 0xFF; //寄存器值低字节     crc = Modbus_CRC16(TX_Frame, 6); //计算CRC校验码    TX_Frame[6] = crc & 0xFF; //CRC低字节    TX_Frame[7] = (crc >> 8) & 0xFF; //CRC高字节     Modbus_Send(TX_Frame, 8);}

06功能码的处理,也是把主站发送的报文原样返回。

123456789101112131415161718
//15功能码,Modbus写多个线圈void Modbus_Slave_15(uint16_t Coil_Address, uint16_t Num_Coils){    uint8_t TX_Frame[8]; //根据需要调整缓冲区大小    uint16_t crc;     TX_Frame[0] = Modbus_Slave_ID; //Modbus RTU从站地址    TX_Frame[1] = 0x0F; //功能码15    TX_Frame[2] = (Coil_Address >> 8) & 0xFF; //线圈地址高字节    TX_Frame[3] = Coil_Address & 0xFF; //线圈地址低字节    TX_Frame[4] = (Num_Coils >> 8) & 0xFF; //线圈数量高字节    TX_Frame[5] = Num_Coils & 0xFF; //线圈数量低字节    crc = Modbus_CRC16(TX_Frame, 6); //计算CRC校验码    TX_Frame[6] = crc & 0xFF; //CRC低字节    TX_Frame[7] = (crc >> 8) & 0xFF; //CRC高字节     Modbus_Send(TX_Frame, 8);}

15功能码,构建发送帧时不需要返回数据,只要返回起始地址,线圈数量即可。

12345678910111213141516171819
//16功能码,Modbus写多个保持寄存器void Modbus_Slave_16(uint16_t Reg_Address, uint16_t Num_Regs, uint16_t *Reg_Values){    uint8_t TX_Frame[8]; //根据需要调整缓冲区大小    uint16_t crc;     TX_Frame[0] = Modbus_Slave_ID; //Modbus RTU从站地址    TX_Frame[1] = 0x10; //功能码16    TX_Frame[2] = (Reg_Address >> 8) & 0xFF; //寄存器地址高字节    TX_Frame[3] = Reg_Address & 0xFF; //寄存器地址低字节    TX_Frame[4] = (Num_Regs >> 8) & 0xFF; //寄存器数量高字节    TX_Frame[5] = Num_Regs & 0xFF; //寄存器数量低字节     crc = Modbus_CRC16(TX_Frame, 6); //计算CRC校验码    TX_Frame[6] = crc & 0xFF; //CRC低字节    TX_Frame[7] = (crc >> 8) & 0xFF; //CRC高字节     Modbus_Send(TX_Frame, 8);}

16功能码,也是返回寄存器起始地址和数量即可。

12345678910111213141516171819202122
//Modbus CRC16校验计算函数uint16_t Modbus_CRC16(uint8_t *data, uint16_t length){    uint16_t crc = 0xFFFF; //初始化CRC寄存器    for (uint16_t i = 0; i < length; i++)    {        crc ^= data[i]; //将数据与CRC寄存器进行异或运算        for (uint8_t j = 0; j < 8; j++)        {            if (crc & 0x0001) //检查最低位是否为1            {                crc >>= 1; //右移一位                crc ^= 0xA001; //与多项式进行异或运算            }            else            {                crc >>= 1; //右移一位            }        }    }    return crc; //返回计算得到的CRC值}

CRC校验函数。

🔍 ×