Modbus协议原理
RT-Thread官网开源modbus
RT-Thread官方提供 FreeModbus开源。
野火有移植的例程。
QT经常用 libModbus库。
Modbus是什么?
Modbus协议,从字面理解它包括Mod和Bus两部分,首先它是一种bus ,即总线协议,和I2C、SPI类似,总线就意味着有主机,有从机,这些设备在同一条总线上。
Modbus支持单主机,多从机,最多支持247个从机设备。
Mod协议最早用在PLC产品上,后来被其他工业控制器厂商广泛接收,成为了一种主流的通讯协议,用于控制器和外围设备通信。
Modbus在7层OSI参考模型中属于第七层应用层,
数据链路层有两种:基于标准串口协议和TCP协议,物理层可使用3线232、2线485、4线422,或光纤、网线、无线等多种传输介质。
Modbus协议是一种请求/应答方式的交互过程,主机主动发起通讯请求,从机响应主机的请求,从机在没有收到主机的请求时,不会主动发送数据,从机之间不会进行通讯。
Modbus官方标准文档可以直接在野火官网下载到。
Modbus协议在STM32上面就是把串口引脚接到 MAX485 芯片(RS485电平)/或者MAX3232芯片(RS232电平)上。
注意这是个协议,主要规定了数据帧的传输格式和数据交互方法。
Modbus RTU和Modbus extended
Modbus、Modbus RTU和Modbus Extended之间的区别可以精简地归纳如下:
定义与范围:
Modbus:是一种通信协议,定义了数据传输的格式和规则。
Modbus RTU:是Modbus协议的一种实现方式,采用二进制编码,通常用于串行通信。
Modbus Extended(或称为Modbus RTU Extend):是Modbus RTU的扩展版本,提供了更多高级功能和更大的数据集支持。
数据集大小:
Modbus RTU支持最多1024个数据项(从机),但每次通信量少。
Modbus Extended是Modbus RTU的扩展,虽支持数据项可能较少,通常256个数据项(从机),但每次可传输更多数据(也就是单个数据项更大,可能32字节),处理更复杂操作。
功能特点:
Modbus RTU:提供基本的数据读写功能,适用于简单自动化需求。
Modbus Extended:在Modbus RTU基础上增加了高级特性,如可变长度字符串(VLS)、错误检测和纠正(EDC),增强了处理复杂数据的能力。
应用场景:
Modbus RTU:常用于小型、简单的自动化系统,如工厂控制或楼宇管理。
Modbus Extended:更适合大型、复杂的自动化系统,特别是对数据量、性能和可靠性要求较高的场景。
3 种协议模式
基于串口的 ASCII码模式、RTU模式,
ASCII码模式采用 LRC 校验,RTU模式采用 16位 CRC 校验。
基于以太网的 TCP 模式。TCP 模式不使用校验,因为TCP自带校验和。
Modbus总线上所有的设备传输模式必须相同。
实际使用要根据设备使用手册来选择采用哪种模式。
1. ASCII模式数据帧例子
主机发送请求(读取从机地址为1的保持寄存器0x0405的值):
:010304050001CRCLF
:
起始字符01
从机地址03
功能码(读取保持寄存器)0405
寄存器地址0001
读取长度CRC
LRC校验码(由数据计算得出,此处为占位符)LF
换行符(结束字符)
从机响应:
:010302XXXXCRCLF
:
起始字符01
从机地址03
功能码(读取保持寄存器)02
数据长度XXXX
寄存器数据(实际数据,此处为占位符)CRC
LRC校验码LF
换行符
2. RTU模式数据帧例子
|------|-----|-------|-------|-------|-------|----|
| 从站地址 | 功能码 | 起始(高) | 起始(低) | 数量(高) | 数量(低) | 校验 |
| | | | | | | |
主机发送请求(写入从机地址为1的保持寄存器0x0405的值0x1234):
01 06 04 05 12 34 CRC
01
从机地址06
功能码(写入单个保持寄存器)0405
寄存器地址1234
写入的数据CRC
CRC校验码(由数据计算得出,此处为占位符)
从机响应:
01 06 04 05 12 34 CRC
- 内容与请求相同,表示写入成功
3. TCP模式数据帧例子
主机发送请求(读取从机地址为1的输入寄存器,起始地址0x0000,读取2个字):
注意 PLC通常是x86架构,字长(机器位数)16位,因此一个字是16位。
Transaction Identifier: 0x0001
Protocol Identifier: 0x0000
Length Field: 0x0006
Unit Identifier: 0x01
Function Code: 0x04
Starting Address: 0x0000
Quantity of Registers: 0x0002
- 该数据帧为 Modbus TCP的 ADU(应用数据单元),其中包含了 7个字段,用于标识交易、协议、长度、单元(从机地址)、功能码、起始地址和读取长度。
从机响应:
Transaction Identifier: 0x0001
Protocol Identifier: 0x0000
Length Field: 0x0005
Unit Identifier: 0x01
Function Code: 0x04
Byte Count: 0x04
Data: 0x1234 0x5678
- 响应中包含了请求中的交易标识符、协议标识符等,以及数据字段,表示读取到的寄存器值。
Modbus协议应用技巧
首先,Modbus协议经常被拿来跟 PLC、传感器通讯,PLC属于x86架构或者AMD架构,用的CISC指令集。这是 PLC和 STM32的区别,STM是 RISC指令集。
其次,modbus只是个协议,规定了数据帧的格式,你能满足它的数据帧,就能通信。
功能码
modbus协议功能码
读取操作:
读线圈(0x01):
cpp
发送请求帧格式:
[从站地址] [0x01] [起始地址高] [起始地址低] [读取数量高] [读取数量低] [校验码]
01 01 00 00 00 01 CRC(假设从站地址为01,读取起始地址为0000,数量为1个线圈)
返回响应帧格式:
[从站地址] [0x01] [字节数] [线圈状态数据...] [校验码]
(字节数通常为读取数量,线圈状态数据为每个线圈的状态,通常为00或FF表示OFF或ON)
01 01 01 00 CRC
(假设读取的线圈状态为ON/开,状态字节为01,后续字节为数据值,
但在此例中只有一个线圈,所以数据值为00)
读离散量输入(0x02)
数据帧和读线圈类似,但功能码为0x02。
读保持寄存器(0x03):
cpp
发送请求帧:
[从站地址] [0x03] [起始地址高] [起始地址低] [读取数量高] [读取数量低] [校验码]
01 03 00 00 00 02 CRC(假设从站地址为01,读取起始地址为0000,数量为2个寄存器)
返回响应帧:
[从站地址] [0x03] [字节数] [寄存器数据...] [校验码]
01 03 04 00 01 00 02 CRC
(假设读取的两个寄存器值分别为0001和0002,每个寄存器值占两个字节,所以总字节数为4)
读输入寄存器(0x04):
请求帧格式与读保持寄存器类似,但功能码为0x04。
写入操作:
写单个线圈(0x05):
cpp
发送请求帧格式:
[从站地址] [0x05] [目标地址高] [目标地址低] [要写入的值] [校验码]
(要写入的值通常为00或FF表示OFF或ON)
01 05 00 00 FF 00 CRC
(假设从站地址为01,目标地址为0000,写入的值为ON/开)
返回响应帧格式:
[从站地址] [0x05] [目标地址高] [目标地址低] [写入的值] [校验码]
(写入成功后,从站通常返回与请求相同的帧,但实际应用中可能返回其他格式的响应帧)
01 05 00 00 FF 00 CRC
(写入成功后,从站通常返回与请求相同的帧作为响应,但实际应用中可能有所不同)
写单个寄存器(0x06):
cpp
[从站地址] [0x06] [目标地址高] [目标地址低] [要写入的数据高] [要写入的数据低] [校验码]
发送请求帧:01 06 00 00 00 13 CRC
(假设从站地址为01,目标地址为0000,写入的数据值为0013)
[从站地址] [0x06] [目标地址高] [目标地址低] [写入的数据高] [写入的数据低] [校验码]
返回响应帧:01 06 00 00 00 13 CRC
(写入成功后,从站通常返回与请求相同的帧作为响应,但实际应用中可能有所不同)
写多个线圈(0x0F):
cpp
[从站地址] [0x0F] [起始地址高] [起始地址低]
[要写入的线圈数量高] [要写入的线圈数量低] [字节数] [线圈状态数据...] [校验码]
发送请求帧:01 0F 00 00 00 02 01 01 CRC
(假设从站地址为01,起始地址为0000,写入2个线圈,第一个线圈ON,第二个线圈OFF)
[从站地址] [0x0F] [起始地址高] [起始地址低] [写入的线圈数量高] [写入的线圈数量低] [校验码]
返回响应帧:01 0F 00 00 00 02 CRC
(写入成功后,从站返回包含起始地址和写入数量的响应帧,但实际应用中可能有所不同)
写多个寄存器(0x10):
cpp
[从站地址] [0x10] [起始地址高] [起始地址低]
[要写入的寄存器数量高] [要写入的寄存器数量低]
[字节数] [寄存器数据...] [校验码]
发送请求帧:01 10 00 00 00 02 04 00 01 00 02 CRC
(假设从站地址为01,起始地址为0000,写入2个寄存器,第一个寄存器值为0001,第二个寄存器值为0002)
[从站地址] [0x10] [起始地址高] [起始地址低]
[写入的寄存器数量高] [写入的寄存器数量低] [校验码]
返回响应帧:01 10 00 00 00 02 CRC
(写入成功后,从站返回包含起始地址和写入数量的响应帧,但实际应用中可能有所不同)
源码移植
下面看一下野火移植的源码:
main函数
cpp
/* Private user code ---------------------------------------------------------*/
/* 离散输入变量 */
extern UCHAR ucSDiscInBuf[S_DISCRETE_INPUT_NDISCRETES/8] ;
/* 线圈 */
extern UCHAR ucSCoilBuf[S_COIL_NCOILS/8];
/* 输入寄存器 */
extern USHORT usSRegInBuf[S_REG_INPUT_NREGS];
/* 保持寄存器 */
extern USHORT usSRegHoldBuf[S_REG_HOLDING_NREGS];
int main(void){
/* 串口2初始化在portserial.c中 */
...
/* 定时器4初始化 */
MX_TIM4_Init();
...
/* Modbus初始化 */
eMBInit(
MB_RTU, // 传输模式:RTU (Remote Terminal Unit),即Modbus RTU模式
MB_SAMPLE_TEST_SLAVE_ADDR,// 从站地址:在此示例中使用的测试从站地址
MB_MASTER_USARTx, // 串口配置:指定用于Modbus通信的USART(串行通讯接口)
MB_MASTER_USART_BAUDRATE, // 波特率:设置USART的波特率,用于Modbus通信的速率
MB_PAR_NONE // 校验位和停止位配置:无校验,通常表示8位数据位,1个停止位
);
/* 启动Mdobus */
eMBEnable();
while (1)
{
/* 更新保持寄存器值 */
usSRegHoldBuf[0] = HAL_GetTick() & 0xff; //获取时间戳 提出1至8位
usSRegHoldBuf[1] = (HAL_GetTick() & 0xff00) >> 8; //获取时间戳 提出9至16位
usSRegHoldBuf[2] = (HAL_GetTick() & 0xff0000) >> 16 ; //获取时间戳 提出17至24位
usSRegHoldBuf[3] = (HAL_GetTick() & 0xff000000) >> 24; //获取时间戳 提出25至32位
/* 更新输入寄存器值 */
usSRegInBuf[0] = HAL_GetTick() & 0xff; //获取时间戳 提出1至8位
usSRegInBuf[1] = (HAL_GetTick() & 0xff00) >> 8; //获取时间戳 提出9至16位
usSRegInBuf[2] = (HAL_GetTick() & 0xff0000) >> 16 ; //获取时间戳 提出17至24位
usSRegInBuf[3] = (HAL_GetTick() & 0xff000000) >> 24; //获取时间戳 提出25至32位
/* 更新线圈 */
ucSCoilBuf[0] = HAL_GetTick() & 0xff; //获取时间戳 提出1至8位
ucSCoilBuf[1] = (HAL_GetTick() & 0xff00) >> 8; //获取时间戳 提出9至16位
ucSCoilBuf[2] = (HAL_GetTick() & 0xff0000) >> 16 ; //获取时间戳 提出17至24位
ucSCoilBuf[3] = (HAL_GetTick() & 0xff000000) >> 24; //获取时间戳 提出25至32位
/* 离散输入变量 */
ucSDiscInBuf[0] = HAL_GetTick() & 0xff; //获取时间戳 提出1至8位
ucSDiscInBuf[1] = (HAL_GetTick() & 0xff00) >> 8; //获取时间戳 提出9至16位
/* 可以不用延时,如果延时时间过长主机会timeout */
HAL_Delay(200);
/*从机轮询*/
( void )eMBPoll( );
}
}
主要有
eMBInit
cpp
eMBInit(
MB_RTU, // 传输模式:RTU (Remote Terminal Unit),即Modbus RTU模式
MB_SAMPLE_TEST_SLAVE_ADDR, // 从站地址:在此示例中使用的测试从站地址
MB_MASTER_USARTx, // 串口配置:指定用于Modbus通信的USART(串行通讯接口)
MB_MASTER_USART_BAUDRATE, // 波特率:设置USART的波特率,用于Modbus通信的速率
MB_PAR_NONE // 校验位和停止位配置:无校验,通常表示8位数据位,1个停止位
);
/*
eMBInit 函数功能简述:
参数验证:检查从设备地址是否有效。
模式选择:根据通信模式设置函数指针。
初始化:调用对应模式的初始化函数配置通信参数。
事件初始化:初始化端口事件模块以处理通信事件。
状态设置:成功初始化后,设置模块为禁用状态。
返回状态:返回初始化结果的状态码。
*/
cpp
/*eMBInit内部的传输模式初始化*/
#if MB_RTU_ENABLED > 0
case MB_RTU: // RTU模式
// 设置RTU模式相关的函数指针
pvMBFrameStartCur = eMBRTUStart;
pvMBFrameStopCur = eMBRTUStop;
peMBFrameSendCur = eMBRTUSend;
peMBFrameReceiveCur = eMBRTUReceive;
pvMBFrameCloseCur = MB_PORT_HAS_CLOSE ? vMBPortClose : NULL;
pxMBFrameCBByteReceived = xMBRTUReceiveFSM;
pxMBFrameCBTransmitterEmpty = xMBRTUTransmitFSM;
pxMBPortCBTimerExpired = xMBRTUTimerT35Expired;
// 初始化RTU
eStatus = eMBRTUInit(ucMBAddress, ucPort, ulBaudRate, eParity);
break;
#endif
cpp
/*
eMBRTUInit 函数的功能是初始化 Modbus RTU 通信模式,具体包括:
串口配置:设置指定端口的波特率、8个数据位和校验位。
定时器设置:根据波特率计算并设置定时器T35的值,以确保正确的通信时序。
错误处理:在初始化过程中,如遇到任何失败,则返回相应的错误状态。
*/
eMBRTUInit( UCHAR ucSlaveAddress, UCHAR ucPort, ULONG ulBaudRate, eMBParity eParity )
{
eMBErrorCode eStatus = MB_ENOERR; // 初始化状态为无错误
ULONG usTimerT35_50us; // 定时器T35的50微秒单位值
( void )ucSlaveAddress; // 目前未使用从设备地址参数
ENTER_CRITICAL_SECTION( ); // 进入临界区,保护共享资源
//__set_PRIMASK(1),设置PRIMASK寄存器,由CMSIS库提供
//屏蔽除 NMI 和 HardFalut 外的所有异常和中断。
// Modbus RTU使用8个数据位
if( xMBPortSerialInit( ucPort, ulBaudRate, 8, eParity ) != TRUE )
{
eStatus = MB_EPORTERR; // 串口初始化失败,设置错误状态
}
else
{
// 根据波特率设置定时器T35的值
if( ulBaudRate > 19200 )
{
usTimerT35_50us = 35; // 波特率大于19200时使用固定值
}
else
{
// 计算T35的值为3.5个字符时间
usTimerT35_50us = ( 7UL * 220000UL ) / ( 2UL * ulBaudRate );
}
// 初始化定时器
if( xMBPortTimersInit( ( USHORT ) usTimerT35_50us ) != TRUE )
{
eStatus = MB_EPORTERR; // 定时器初始化失败,设置错误状态
}
}
EXIT_CRITICAL_SECTION( ); // 退出临界区
//__set_PRIMASK(0) 设置Primask寄存器
return eStatus; // 返回初始化状态
}
上面可以看到,modbus模块的初始化,根据波特率设置了所谓Timer35定时器的值,
但这个定时器其实是我们自己在 main里设置的(示例用的TIM4),这里定时器初始化直接返回了True。
cpp
BOOL
xMBPortTimersInit( USHORT usTim1Timerout50us ) //定时器初始化直接返回TRUE,已经在mian函数初始化过
{
return TRUE;
}
实际的设置代码,野火原版是hal库的,我这里给个标准库的参考版本:
cpp
void MX_TIM4_Init(void)
{
// 开启TIM4时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE);
// 初始化定时器基础配置
TIM_TimeBaseInitTypeDef TIM_TimeBaseStruct;
TIM_TimeBaseStruct.TIM_Prescaler = 4200 - 1; // 设置预分频器
TIM_TimeBaseStruct.TIM_CounterMode = TIM_CounterMode_Up; // 向上计数
TIM_TimeBaseStruct.TIM_Period = 35; // 设置周期
TIM_TimeBaseStruct.TIM_ClockDivision = TIM_CKD_DIV1; // 时钟不分频
TIM_TimeBaseStruct.TIM_RepetitionCounter = 0; // 重复计数器为0(通常不需要)
TIM_TimeBaseInit(TIM4, &TIM_TimeBaseStruct); // 初始化TIM4
// 启用TIM4更新中断
TIM_ITConfig(TIM4, TIM_IT_Update, ENABLE);
// 启动TIM4
TIM_Cmd(TIM4, ENABLE);
// 配置NVIC以启用TIM4中断
NVIC_InitTypeDef NVIC_InitStruct;
NVIC_InitStruct.NVIC_IRQChannel = TIM4_IRQn; // 设置中断通道
NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 0; // 设置抢占优先级
NVIC_InitStruct.NVIC_IRQChannelSubPriority = 0; // 设置子优先级
NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE; // 启用中断
NVIC_Init(&NVIC_InitStruct); // 初始化NVIC
}
cpp
/*TIM4的中断服务函数*/
void TIM4_IRQHandler(void)
{
HAL_TIM_IRQHandler(&htim4);
}
/**stm32f4xx_it.c中的溢出回调函数**/
/* USER CODE BEGIN 1 */
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) //定时器中断回调函数,用于连接porttimer.c文件的函数
{
/* NOTE : This function Should not be modified, when the callback is needed,
the __HAL_TIM_PeriodElapsedCallback could be implemented in the user file
*/
prvvTIMERExpiredISR( );//freemodbus移植过来的函数
}
/*定时器中调用freemodbus移植过来的函数*/
void prvvTIMERExpiredISR( void ) //modbus定时器动作,需要在中断内使用
{
( void )pxMBPortCBTimerExpired( );//这个函数其实是指向 xMBRTUTimerT35Expired()
}
//定时器最终调用的函数在下个代码块给出
xMBRTUTimerT35Expired 函数是 Modbus RTU 通信协议中的一部分,用于处理接收状态定时器 T35 到期时的逻辑。
它首先初始化一个轮询标志 xNeedPoll,然后根据当前接收状态 eRcvState 执行不同操作:
在启动阶段结束时发布"准备就绪"事件,
在接收到完整帧时发布"帧接收"事件,
若发生错误则跳过。
无论状态如何,都会禁用并重置定时器并将接收状态设置为空闲。
最后,函数返回是否需要轮询的标志。
简而言之,该函数根据 T35 定时器的到期情况更新接收状态、模拟时间队列发布相应事件,并禁用计时器。
cpp
BOOL xMBRTUTimerT35Expired( void )
{
BOOL xNeedPoll = FALSE;
switch (eRcvState)
{
// Timer t35到期,启动阶段结束
case STATE_RX_INIT:
xNeedPoll = xMBPortEventPost(EV_READY);
break;
// 接收到帧且t35到期,通知监听器收到新帧
case STATE_RX_RCV:
xNeedPoll = xMBPortEventPost(EV_FRAME_RECEIVED);
break;
// 接收帧时发生错误
case STATE_RX_ERROR:
break;
// 函数在非法状态下被调用
default:
assert((eRcvState == STATE_RX_INIT) || (eRcvState == STATE_RX_RCV) || (eRcvState == STATE_RX_ERROR));
}
// 禁用端口计时器
vMBPortTimersDisable();
// 设置接收状态为空闲
eRcvState = STATE_RX_IDLE;
return xNeedPoll;
}
cpp
/*模拟事件上报*/
BOOL xMBPortEventPost( eMBEventType eEvent )
{
// 设置事件在队列中的标志为TRUE
xEventInQueue = TRUE; //注意这里不是真实的队列,只是个bool模拟队列状态
// 保存传入的事件类型
eQueuedEvent = eEvent;
// 返回TRUE表示事件成功发布
return TRUE;
}
eMBpoll
main函数while里面还有个 eMBpoll()从机轮询。
此函数是Modbus协议栈中的轮询函数,负责处理协议栈中的事件。
它首先检查协议栈是否准备就绪,然后检查是否有事件可用(参考定时器回调的模拟事件)。
若有事件,将根据事件类型执行相应的操作,如接收帧、执行功能码处理或发送回复帧等。
函数通过静态变量和局部变量来存储和处理接收到的帧、地址、功能码、异常等信息,并根据需要调用其他函数来执行具体的操作。
最后,函数返回无错误状态。
cpp
/*从机轮询*/
eMBErrorCode eMBPoll( void )
{
// 静态变量定义,用于存储接收到的帧、地址、功能码等信息
static UCHAR *ucMBFrame;
static UCHAR ucRcvAddress;
static UCHAR ucFunctionCode;
static USHORT usLength;
static eMBException eException;
// 局部变量定义
int i;
eMBErrorCode eStatus = MB_ENOERR; // 初始化状态为无错误
eMBEventType eEvent;
// 检查协议栈是否准备就绪
if( eMBState != STATE_ENABLED )
{
return MB_EILLSTATE; // 如果未就绪,则返回非法状态错误
}
// 检查是否有事件可用
if( xMBPortEventGet( &eEvent ) == TRUE )
{
switch ( eEvent )
{
case EV_READY:
// 准备就绪事件,无需特殊处理
break;
case EV_FRAME_RECEIVED:
// 接收到帧事件
eStatus = peMBFrameReceiveCur( &ucRcvAddress, &ucMBFrame, &usLength );
if( eStatus == MB_ENOERR )
{
// 如果帧是发送给我们的或者是广播帧,则发布执行事件
if( ( ucRcvAddress == ucMBAddress ) || ( ucRcvAddress == MB_ADDRESS_BROADCAST ) )
{
( void )xMBPortEventPost( EV_EXECUTE );
}
}
break;
case EV_EXECUTE:
// 执行事件
ucFunctionCode = ucMBFrame[MB_PDU_FUNC_OFF]; // 获取功能码
eException = MB_EX_ILLEGAL_FUNCTION; // 初始化异常为非法功能
// 遍历函数处理器数组,查找匹配的功能码并执行相应的处理函数
for( i = 0; i < MB_FUNC_HANDLERS_MAX; i++ )
{
if( xFuncHandlers[i].ucFunctionCode == 0 )
{
break; // 没有更多的函数处理器,退出循环
}
else if( xFuncHandlers[i].ucFunctionCode == ucFunctionCode )
{
eException = xFuncHandlers[i].pxHandler( ucMBFrame, &usLength );
break; // 找到匹配的功能码并执行处理函数,退出循环
}
}
// 如果接收地址不是广播地址,则发送回复帧
if( ucRcvAddress != MB_ADDRESS_BROADCAST )
{
if( eException != MB_EX_NONE )
{
// 如果发生异常,构建错误帧
usLength = 0;
ucMBFrame[usLength++] = ( UCHAR )( ucFunctionCode | MB_FUNC_ERROR );
ucMBFrame[usLength++] = eException;
}
// (可选)在发送前延迟一段时间(仅适用于ASCII模式)
if( ( eMBCurrentMode == MB_ASCII ) && MB_ASCII_TIMEOUT_WAIT_BEFORE_SEND_MS )
{
vMBPortTimersDelay( MB_ASCII_TIMEOUT_WAIT_BEFORE_SEND_MS );
}
// 发送回复帧
eStatus = peMBFrameSendCur( ucMBAddress, ucMBFrame, usLength );
}
break;
case EV_FRAME_SENT:
// 帧发送事件,无需特殊处理
break;
}
}
return MB_ENOERR; // 函数返回无错误状态
}
串口数据帧接收/发送
cpp
void USART2_IRQHandler(void)
{
...
if(__HAL_UART_GET_IT_SOURCE(&huart2, UART_IT_RXNE)!= RESET)
{
prvvUARTRxISR();//接收,函数指针
}
if(__HAL_UART_GET_IT_SOURCE(&huart2, UART_IT_TXE)!= RESET)
{
prvvUARTTxReadyISR();//发送,函数指针
}
...
}
cpp
/*真实的发送*/
BOOL xMBRTUTransmitFSM( void )
{
BOOL xNeedPoll = FALSE; // 初始化轮询需求为不需要
assert( eRcvState == STATE_RX_IDLE ); // 断言接收状态应为空闲
switch ( eSndState ) // 根据发送状态进行处理
{
case STATE_TX_IDLE:
// 如果发送状态为空闲
vMBPortSerialEnable( TRUE, FALSE ); // 启用接收器,禁用发送器
break;
case STATE_TX_XMIT:
// 如果发送状态为正在发送
if( usSndBufferCount != 0 ) // 检查发送缓冲区是否还有数据
{
xMBPortSerialPutByte( ( CHAR )*pucSndBufferCur ); // 发送当前字节
pucSndBufferCur++; // 移动到缓冲区中的下一个字节
usSndBufferCount--; // 减少缓冲区计数
}
else
{
xNeedPoll = xMBPortEventPost( EV_FRAME_SENT ); // 发布帧发送完成事件,可能需要轮询
vMBPortSerialEnable( TRUE, FALSE ); // 禁用发送器,防止再次发送缓冲区空中断
eSndState = STATE_TX_IDLE; // 将发送状态设置为空闲
}
break;
}
return xNeedPoll; // 返回是否需要轮询的标志
}
最后被串口中断调用的,串口接收函数。
cpp
BOOL xMBRTUReceiveFSM( void )
{
BOOL xTaskNeedSwitch = FALSE; // 初始化任务切换需求标志为FALSE
UCHAR ucByte; // 用于存储接收到的字节
assert( eSndState == STATE_TX_IDLE ); // 确保发送状态为空闲
/*串口读取字符*/
// 总是读取字符(无论当前接收状态如何)
( void )xMBPortSerialGetByte( ( CHAR * ) & ucByte );
switch ( eRcvState ) // 根据接收状态进行处理
{
case STATE_RX_INIT:
// 如果在初始化状态接收到字符,等待帧结束
vMBPortTimersEnable( ); // 启用定时器
break;
case STATE_RX_ERROR:
// 在错误状态,等待损坏帧的所有字符传输完毕
vMBPortTimersEnable( ); // 启用定时器
break;
case STATE_RX_IDLE:
// 在空闲状态,等待新字符。接收到字符后,启动定时器,并进入接收状态
usRcvBufferPos = 0; // 重置接收缓冲区位置
ucRTUBuf[usRcvBufferPos++] = ucByte; // 将接收到的字节存入缓冲区
eRcvState = STATE_RX_RCV; // 更改接收状态为正在接收
vMBPortTimersEnable( ); // 启用定时器
break;
case STATE_RX_RCV:
// 正在接收帧。每接收到一个字符,重置定时器。
// 如果接收到的字节数超过Modbus帧的最大可能大小,则忽略该帧
if( usRcvBufferPos < MB_SER_PDU_SIZE_MAX )
{
ucRTUBuf[usRcvBufferPos++] = ucByte; // 将接收到的字节存入缓冲区
}
else
{
eRcvState = STATE_RX_ERROR; // 接收字节数超标,更改接收状态为错误
}
vMBPortTimersEnable( ); // 启用定时器(为了保持接收超时检测)
break;
}
return xTaskNeedSwitch; // 返回任务切换需求标志(在此函数中始终为FALSE)
}
每一次定时器溢出,都将 eRcvState转变为STATE_RX_IDLE状态,然后 接收,
一次性接受完全部数据帧。
再重启定时器,又是 IDLE状态。
modbus帧解析
在临界区内接收并处理一个Modbus RTU帧,进行长度和CRC校验,如果校验通过,则提取并返回地址、长度和PDU数据,否则设置错误码。
cpp
#define MB_SER_PDU_SIZE_MIN 4 // Modbus RTU 帧的最小大小
#define MB_SER_PDU_SIZE_MAX 256 // Modbus RTU 帧的最大大小
#define MB_SER_PDU_SIZE_CRC 2 // PDU 中 CRC 字段的大小
#define MB_SER_PDU_ADDR_OFF 0 // Ser-PDU 中从站地址的偏移量
#define MB_SER_PDU_PDU_OFF 1 // Ser-PDU 中 Modbus-PDU 的偏移量
cpp
/*该函数将数据存放在数组中,并返回从站存储位置,帧存储位置,帧长度*/
eStatus = peMBFrameReceiveCur( &ucRcvAddress, &ucMBFrame, &usLength );
cpp
/*RTU帧解析*/
eMBErrorCode eMBRTUReceive( UCHAR * pucRcvAddress, // 接收到的从站地址存储位置
UCHAR ** pucFrame, // 接收到的帧数据存储位置
USHORT * pusLength ) // 接收到的帧数据长度存储位置
{
BOOL xFrameReceived = FALSE; // 帧接收标志
eMBErrorCode eStatus = MB_ENOERR; // 初始化错误码为无错误
ENTER_CRITICAL_SECTION( ); // 进入临界区
assert( usRcvBufferPos < MB_SER_PDU_SIZE_MAX ); // 断言:接收缓冲区位置应小于最大PDU大小
// 长度和CRC校验
if( ( usRcvBufferPos >= MB_SER_PDU_SIZE_MIN )
&& ( usMBCRC16( ( UCHAR * ) ucRTUBuf, usRcvBufferPos ) == 0 ) )
{
// 保存地址字段
*pucRcvAddress = ucRTUBuf[MB_SER_PDU_ADDR_OFF];
// 计算Modbus-PDU总长度 = 接收缓冲区位置-从站地址偏移-校验偏移
*pusLength =
( USHORT )( usRcvBufferPos - MB_SER_PDU_PDU_OFF - MB_SER_PDU_SIZE_CRC );
// 返回Modbus PDU的起始位置
*pucFrame = ( UCHAR * ) & ucRTUBuf[MB_SER_PDU_PDU_OFF];
xFrameReceived = TRUE; // 标记帧已接收
}
else
{
eStatus = MB_EIO; // 设置错误码为输入/输出错误
}
EXIT_CRITICAL_SECTION( ); // 退出临界区
return eStatus;
}