看这篇文章之前要对Modbus协议要有一个简单的了解,本篇文章以STM32单片机为例写一个简易版的从机Modbus.
Modbus通信机制需要单片机两个外设资源:串口和定时器。
设一个向上计数的定时器,计数周期为3.5个字符的时间。3.5个字符时间如何计算请参考这个https://zhidao.baidu.com/question/2266066387336737428.html
其实这个时间设长一点也没关系,比如设个10ms,100ms甚至是1s,如果设为1s,主机的Modbus发送两帧数据的间隔就不能低于1s,看完从机的具体实现就会明白为什么了。
用Stm32CubeIDE配置一个50ms中断的定时器
再配置串口
串口中断回调函数如下:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { /* Prevent unused argument(s) compilation warning */ UNUSED(huart); /* NOTE: This function Should not be modified, when the callback is needed, the HAL_UART_TxCpltCallback could be implemented in the user file */ if(huart->Instance == huart1.Instance) { Rx_Buf[RxCount++] = aRx1Buffer;//把接收到的数据存入接收缓存数组中 __HAL_TIM_SET_COUNTER(&htim7,0);//只要有数据进来就离开清空定时器计数器,如果没有新的数据进来即从此刻开始计时50ms,进入定时器中断回调函数,此时意味着接收完一帧数据。 HAL_TIM_Base_Start_IT(&htim7);//启动定时器 HAL_UART_Receive_IT(&huart1, (uint8_t *)&aRx1Buffer, 1); //再开启接收串口中断 } }
在定时器中断回调函数如下:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == htim7.Instance) { SLAVE_RS485_SEND_MODE;//485切换成发送模式,不再接受新的串口中断 HAL_TIM_Base_Stop_IT(&htim7);//停止定时器 MB_Parse_Data();// 提取数据帧,进行解析数据帧 MB_Analyze_Execute();//对接收到的数据进行分析并执行 RxCount = 0;//清空接收计数 SLAVE_RS485_RECEIVE_MODE;//数据处理完毕后,再重新接收串口中断 } }
串口引脚接了485芯片,SLAVE_RS485_SEND_MODE和SLAVE_RS485_RECEIVE_MODE就是普通的IO口,用于控制485芯片发送与接收模式
#define SLAVE_RS485_SEND_MODE HAL_GPIO_WritePin(USART1_EN_GPIO_Port, USART1_EN_Pin, GPIO_PIN_SET) #define SLAVE_RS485_RECEIVE_MODE HAL_GPIO_WritePin(USART1_EN_GPIO_Port, USART1_EN_Pin, GPIO_PIN_RESET)
接下来看提取数据帧函数
/* 提取数据帧,进行解析数据帧 */ void MB_Parse_Data() { PduData.Code = Rx_Buf[1]; // 功能码 PduData.Addr = ((Rx_Buf[2]<<8) | Rx_Buf[3]);// 寄存器起始地址 PduData.Num = ((Rx_Buf[4]<<8) | Rx_Buf[5]);// 数量(Coil,Input,Holding Reg,Input Reg) PduData._CRC = MB_CRC16((uint8_t*)&Rx_Buf,RxCount-2); // CRC校验码 PduData.byteNums = Rx_Buf[6]; // 获得字节数 }
PduData是一个结构体如下:
/* 类型定义 ------------------------------------------------------------------*/ typedef struct { __IO uint8_t Code ; // 功能码 __IO uint8_t byteNums; // 字节数 __IO uint16_t Addr ; // 操作内存的起始地址 __IO uint16_t Num; // 寄存器或者线圈的数量 __IO uint16_t _CRC; // CRC校验码 __IO uint8_t *ValueReg; // 10H功能码的数据 }PDUData_TypeDef;
MB_CRC16校验函数适用于校验一帧数据是否正确,这个函数需要数据表很占篇幅,稍后把它放到文章的最后,读者直接调用即可。接下来看最重要的对接收到的数据进行分析并执行
我们以简单的写线圈寄存器为例
/** * 函数功能: 对接收到的数据进行分析并执行 * 输入参数: 无 * 返 回 值: 异常码或0x00 * 说 明: 判断功能码,验证地址是否正确.数值内容是否溢出,数据没错误就发送响应信号 */ uint8_t MB_Analyze_Execute(void ) { uint16_t ExCode = EX_CODE_NONE; uint16_t tem_crc; if(PduData._CRC !=((Rx_Buf[RxCount-1])<<8 | Rx_Buf[RxCount-2])) { /* Modbus异常响应 */ ExCode = EX_CODE_02H; // 异常码02H return ExCode; } /* 根据功能码分别做判断 */ switch(PduData.Code) { /* ---- 01H 02H 读取离散量输入(Coil Input)---------------------- */ case FUN_CODE_01H: break; case FUN_CODE_02H: break; case FUN_CODE_03H: break; case FUN_CODE_04H: break; case FUN_CODE_05H: /* 写入一个线圈值 */ if(PduData.Num == 0xFF00) { usCoilBuf[PduData.Addr] = 1;//把这个PduData.Addr地址的线圈寄存器写1 } else { usCoilBuf[PduData.Addr] = 0;//把这个PduData.Addr地址的线圈寄存器写0 } MB_SendRX();//把接收到的数据原封不动的发送出去(即应答) break; } /* 数据帧没有异常 */ return ExCode; // EX_CODE_NONE }