上位机通过Modbus-RTU协议与开发板通信
Modbus协议
Modbus是一种串行通信协议,是Modicon公司(现在的施耐德电气Schneider Electric)于 1979年为使用可编程逻辑控制器(PLC)通信而发表。Modbus 已经成为工业领域通信协议的业界标准(De facto),并且现在是工业电子设备之间常用的连接方式。
更详细的介绍可看这篇文章:http://t.csdn.cn/n1T5O
实验前梳理
要进行Modbus通信,就要先写一个上位机软件,是用C#语言编写的(还不会,先用现成的);然后电脑通过USB转485工具与开发板的A、B端子相连接,Modbus协议使用RS-485进行传输;
实验功能
获取PCB板温度,并在数码管上显示;
按键2可以通过单击、双击、长按调整PWM灯的亮度
上位机通过Modbus协议实时获取PCB板的温度、PWM灯的亮度
上位机通过Modbus协议可以设置PWM灯的亮度
本次实验的报文格式
上位机读取开发板的板载温度和PWM灯的亮度,这不是某个位状态,而是寄存器数值,所以功能码是03,读单个或多个保持寄存器的值
上位机还可以设置PWM灯的亮度,所以用到的功能码是06,写单个保持寄存器
程序
文件结构
main.c ->主函数文件,包含main函数等;
Public.c ->公共函数文件,包含Delay延时函数等;
Sys_init ->系统初始化函数,包含GPIO初始化函数等;
ADC.c->ADC初始化,采集ADC值等;
NTC.c ->NTC外设函数,包含查表,获取环境温度等;
Modbus.c ->Modbus外设函数,主要包含Modbus协议解析函数,读寄存器函数和写寄存器函数;
CRC-16.c->校验函数,主要包含CRC-16校验函数。
与上一节数码管显示温度代码相比改动的地方
main.c:获取PCB板温度,数码管显示温度,状态机扫描按键2,按键2按下触发调整PWM灯亮度,串口1协议解析
系统主循环
while(1)
{
//获取PCB板温度
NTC.Get_Temperature_Value();
//数码管显示温度
TM1620.Disp_Temperture();
//延时500ms
/*这里不能用Public.Delay_ms(500),这样的话延时就太久了,上位机发送指令下来单片机可能收不到,所以改为用while循环
循环500次,每次延时1毫秒,然后进行按键2的状态机扫描*/
i = 500;
while(i--)
{
Public.Delay_ms(1);
KEY2.KEY_Detect(); //状态机扫描按键2 每隔10毫秒扫描一次
//这两条if语句起从机监听作用
if(UART1.ucRec_Flag == TRUE)//如果接收到上位机发送的指令,则用break退出循环,执行下面调整PWM灯亮度
{
break;
}
if(KEY2.KEY_Flag == TRUE) //如果按键2被按下,则跳出循环,及时改变PWM灯的亮度
{
break;
}
}
//按键触发调整PWM灯亮度
PWM.PWM_LED_Adjust_Brightness();
//串口1协议解析
UART1.Protocol();
}
public.c:增加内存清除函数Memory_Clr
/*
* @name Memory_Clr
* @brief 内存清除函数
* @param pucBuffer -> 要清除的内存首地址
* @param LEN -> 要清除内存长度
* @retval None
*/
static void Memory_Clr(uint8_t *pucBuffer,uint16_t LEN)
{
uint16_t i;
for(i=0;i<LEN;i++)
{
*(pucBuffer+i) = (uint8_t)0;
}
}
public.h:注释掉宏定义Monitor_Run_Code,定义时是往串口USB发送的,不定义就是RS-485发送,可在UART1.c文件中切换串口
UART1.c:串口1初始化函数 Init() 处,用预编译命令设置AUXR1寄存器,如果public.h中宏定义了Monitor_Run_Code,则将串口1映射到P30、P31引脚(串口USB调试信息);如果没定义Monitor_Run_Code,则将串口1映射到P36、P37引脚(RS-485传输)
//把串口1映射到USB转TTL模块连接的P30和P31引脚,默认该两位是0
#ifdef Monitor_Run_Code
AUXR1 &= ~(S1_S1); //AUXR1第7位清0
AUXR1 &= ~(S1_S0); //AUXR1第6位清0
#endif
//把串口1映射到RS-485连接到的P37和P36引脚
#ifndef Monitor_Run_Code
AUXR1 &= ~S1_S1; //AUXR1第7位清0
AUXR1 |= S1_S0; //AUXR1第6位置1
#endif
波特率改为9600,原来是115200;
预编译命令包括putchar发送函数重定义;在宏定义Monitor_Run_Code时,可以用printf函数将信息打印到串口上;没宏定义Monitor_Run_Code时,不能使用 printf 函数,否则会造成运行异常
/*
* @name putchar
* @brief 字符发送函数重定向
* @param ch:发送的字符
* @retval char
*/
#ifdef Monitor_Run_Code
extern char putchar(char ch)
{
UART1.UART_SendData((uint8_t)ch); //在putchar函数内直接调用串口发送字符函数
return ch;
}
#endif
实现串口协议函数Protocol
/*
* @name Protocol
* @brief 串口协议
* @param None
* @retval None
*/
static void Protocol()
{
//判断经过RS-485传输后串口是否接收到数据
if(UART1.ucRec_Flag == TRUE)
{
//过滤干扰数据0
//Modbus协议中,主机发送的信息帧第一个字节是从机地址,范围是1 ~ 247,该if语句则判断是否是从机地址,0是广播地址
if(ucRec_Buffer[0] != 0)
{
Timer0.usDelay_Timer = 0; //延时定时器清零,开始计时
while(UART1.ucRec_Cnt < 8)
{
if(Timer0.usDelay_Timer >= TIMER_100MS) //100ms内没接收完8个字节,则跳出循环
{
break;
}
}
//接收完8个字节数据,则进行协议分析
Modbus.Protocol_Analysis(&UART1);
}
//清除缓存
Public.Memory_Clr(ucRec_Buffer,(uint16_t)UART1.ucRec_Cnt);
//重新接收
UART1.ucRec_Cnt = 0;
UART1.ucRec_Flag = FALSE;
}
}
Timer0.h:增加延时定时器usDelay_Timer
Timer0.c:中断处理函数中usDelay_Timer++
NTC.h:增加用于Modbus发送的温度值uTemperature
NTC.c:将ADC采集值Temp赋值给uTemperature,给Modbus.c读寄存器函数调用,封装成帧发送给主机
//用于Modbus传输到上位机的温度值
NTC.uTemperature = Temp;
PWM.h:在结构体内增加声明指向PWM_Duty_Set()函数的指针
PWM.c:初始化指向PWM_Duty_Set()函数的指针,给后续主机通过Modbus协议写数据设置PWM灯亮度使用,用来设置占空比
增加CRC_16.h和CRC_16.c:CRC校验码的计算
#ifndef __CRC_16_H_
#define __CRC_16_H_
//定义CRC校验码的结构体
typedef struct
{
uint16_t CRC; //CRC校验值
uint8_t CRC_H; //高位
uint8_t CRC_L; //低位
uint16_t (*CRC_Check)(uint8_t *,uint8_t );
}CRC_16_t;
/* extern variables-----------------------------------------------------------*/
extern CRC_16_t idata CRC_16;
/* extern function prototypes-------------------------------------------------*/
#endif
/********************************************************
End Of File
********************************************************/
/* Includes ------------------------------------------------------------------*/
#include <main.h>
/* Private define-------------------------------------------------------------*/
/* Private variables----------------------------------------------------------*/
uint16_t CRC_Check(uint8_t *,uint8_t );
/* Public variables-----------------------------------------------------------*/
CRC_16_t idata CRC_16 =
{
0,
0,
0,
CRC_Check
};
/*******************************************************
说明:CRC添加到消息中时,低字节先加入,然后高字节
CRC计算方法:
1.预置1个16位的寄存器为十六进制FFFF(即全为1);称此寄存器为CRC寄存器;
2.把第一个8位二进制数据(既通讯信息帧的第一个字节)与16位的CRC寄存器的低
8位相异或,把结果放于CRC寄存器;
3.把CRC寄存器的内容右移一位(朝低位)用0填补最高位,并检查右移后的移出位;
4.如果移出位为0:重复第3步(再次右移一位);
如果移出位为1:CRC寄存器与多项式A001(1010 0000 0000 0001)进行异或;
5.重复步骤3和4,直到右移8次,这样整个8位数据全部进行了处理;
6.重复步骤2到步骤5,进行通讯信息帧下一个字节的处理;
7.将该通讯信息帧所有字节按上述步骤计算完成后,得到的16位CRC寄存器的高、低
字节进行交换;
********************************************************/
/* Private function prototypes------------------------------------------------*/
/*
* @name CRC_Check
* @brief CRC校验
* @param CRC_Ptr:数组指针
* @param LEN:数组长度
* @retval uint16_t类型的CRC校验值
*/
uint16_t CRC_Check(uint8_t *CRC_Ptr,uint8_t LEN)
{
uint16_t CRC = 0;
uint8_t i = 0;
uint8_t j = 0;
CRC = 0xFFFF;
for(i=0;i<LEN;i++) //CRC校验是校验一帧数据中CRC位置前面的所有数据,LEN根据实际情况改变
{
CRC ^= *(CRC_Ptr+i);//CRC_Ptr是8位,解引用取出某位后,与16位的CRC低8位进行异或操作
for(j=0;j<8;j++) //处理一个字节数据,8位
{
if(CRC & 0x0001)
{
CRC = (CRC >> 1) ^ 0xA001;//如果最低位是1,与0xA001进行异或
}
else
{
CRC = (CRC >> 1);//如果最低位一直是0,则一直右移
}
}
}
CRC = ((CRC >> 8) + (CRC << 8)); //交换高低字节
return CRC;
}
/********************************************************
End Of File
********************************************************/
增加Modbus.h和Modbus.c:
主要是Modbus协议解析,然后读寄存器和写寄存器再分别写成两个函数,读寄存器时是从机组帧并返回信息给主机,写寄存器时从机返回的信息与主机发来的一致,提取主机发来的PWM灯亮度值,调用PWM_Duty_Set()函数设置PWM灯亮度
#ifndef __Modbus_H_
#define __Modbus_H_
//定义结构体类型
typedef struct
{
uint16_t Addr; //从机地址
void (*Protocol_Analysis)(UART_t* );//协议分析
}Modbus_t;
/* extern variables-----------------------------------------------------------*/
extern Modbus_t idata Modbus;
/* extern function prototypes-------------------------------------------------*/
#endif
/********************************************************
End Of File
********************************************************/
处理主机发送过来的信息帧步骤
1.先独立计算出前6位的CRC校验码,并与主机发来的CRC校验码进行比较,校验码相等则进行下一步,不相等则不进行下面操作,直接提示校验码出错
2.判断从机地址,是否是本从机的地址,是则进行下一步操作
3.判断功能码,如果是03读保持寄存器,则调用Modbus_Read_Register()函数,组帧并返回给主机;如果是06写保持寄存器,则调用Modbus_Write_Register()函数,返回相同的信息帧给主机,提取主机发送的数据
遇到的问题
在编写Modbus相关函数代码前,想测试一下数码管显示温度的同时按键2是否可以改变PWM灯的亮度,在写好UART1.c的预编译切换串口到USB传输或者RS-485传输,波特率设为9600,将重写的putchar函数也包括在预编译指令中后,编译烧写,发现按键2按下后PWM灯没反应,数码管显示的温度也一直不变
后来发现,在public.h头文件中,如果宏定义了 Monitor_Run_Code,则按键2正常调节亮度,数码管实时显示温度,但仍然没有响应上位机的消息;如果没有宏定义 Monitor_Run_Code,则开发板按键2按下后PWM灯没法调亮度,数码管显示的温度不实时,温度显示卡死,把手按在NTC电阻上仍然是同一个温度,上位机发送数据开发板也没有响应;
原因
PWM.c文件的PWM_LED_Adjust_Brightness函数中原本单击的输出语句是这样的(双击、长按的也类似,输出的信息不一样)
UART1.RS485_Set_SendMode(); //RS-485设置为发送模式
printf("KEY2 click detected\r\n\r\n"); //打印单击信息
UART1.RS485_Set_RecMode(); //RS-485设置为接收模式
查看使用到的代码源文件,发现PWM.c源文件的打印按键信息语句处有问题,与RS-485设置发送模式和接收模式没有关系,这只是一个引脚的状态位改变而已,没有宏定义Monitor_Run_Code语句时,虽然串口切换到了RS-485的引脚上,但那些ADC采集值,NTC电压,温度是用 printf 打印的,没有宏定义 Monitor_Run_Code 语句也就这些信息都没有进行打印,问题就出在了
printf("KEY2 click detected\r\n\r\n"); //打印单击信息
这条语句上,因为重写putchar函数也是用预编译包含了,没宏定义 Monitor_Run_Code也就没有重写putchar函数,所以这里的printf函数就不是往串口上输出的,应该是原原本本的C语言printf输出函数,输出到标准输出流上,用在单片机这里就导致了系统运行错误,把printf这条语句注释掉则系统运行正常,也可以改为#ifdef #endif形式;如果宏定义了Monitor_Run_Code语句,那putchar函数会被重写,这里的printf函数也就正常往串口发送信息
可改为:
#ifdef Monitor_Run_Code
printf("KEY2 click detected\r\n\r\n"); //打印单击信息
#endif
更改后在没有涉及Modbus协议的前提下,数码管温度显示实时,按键2按下会调整PWM灯亮度
然后再确认Modbus协议代码没问题,编译烧写,发现上位机发送数据到开发板后,开发板会自动回应主机,同时上位机也获取到了开发板的温度和PWM灯亮度值,手动设置PWM灯亮度值时,PWM灯也会发生改变,程序运行正常且效果正确
看教程时需要注意的点
在UART1内部使用接收和发送数组时直接使用数组名,外部使用接收和发送数组时通过结构体指针调用
电脑开发一个上位机作主机,STC15开发板作从机,用MODBUS-RTU协议
Modbus只是定义了通信报文的格式,并没有定义数据的格式,数据格式要自己定义,所以行业中不同厂家的Modbus协议可能是不兼容的,因为数据的格式不一样
假如NTC获取的温度是 -30 ~ 70℃,要将温度编码传输,比如是-15.5度,先加30,结果再乘以2,得到29就传输过去,到达另一端后再解码,(29/2)-30 = -15.5
Modbus协议不一定是用RS-485接口,也可以用串口,CAN口,RS-232,RS-422等
通信格式的Address是通信地址:1 ~ 247 只有到247,其他地址可能有其他用途
寄存器地址定义中,地址40001被定义为PCB板温度,地址40002被定义为PWM灯亮度
主函数中while(1)循环内再用whlie循环实现延时,具有实时性,不能用Public.Delay_ms,会出错
重要方法
在main函数主循环中,获取PCB板温度并在数码管上显示,同时不断用状态机检测按键2,此时方法中没有加延时的话,串口打印的温度数据会非常快,按键虽然能起作用,但在串口中也看不到按键信息,很快就被刷上去了
普通延时方法
如果在数码管显示温度后用Delay_ms函数延时500ms,则按键2按下时会有很大概率检测不到,导致PWM灯没反应,因为按键2是定时器每隔10ms扫描状态机来检测的,速度很快,所以每个按下瞬间都能检测到,如果主函数中延时了500ms,就没有了实时性,所以这种延时方法对实时性要求高的操作来说是不可取的
while(1)
{
//获取PCB板温度
NTC.Get_Temperature_Value();
//数码管显示温度
TM1620.Disp_Tempareture();
Public.Delay_ms(500); //延时太久,可能按键2被按下瞬间还在延时里没有出来,导致按键2没有被检测到
KEY2.KEY_Detect();
//设置PWM灯亮度
PWM.PWM_LED_Adjust_Brightness();
}
改进延时方法
将延时500ms改为延时1ms,共循环500次;
先定义静态类型的16位无符号整型 i,然后要在while(1)里面赋值为500,因为main函数执行后,就一直在while(1)里循环执行,如果在定义时就初始化为500,那执行一遍后 i 就被减完了,而 i 又没有重新赋值,就没有延时效果,所以要在while(1)里面再赋值
这种写法在获取温度在数码管显示来看,是间隔了1 * 500 = 500ms才执行一次的,但按键2的检测只是延时了1ms,并不会影响定时器扫描状态机,当检测到按键2被按下时,立即退出剩余循环,实时设置PWM灯的亮度,所以这种方法在处理实时性操作时是比较可取的
int main(void)
{
static uint16_t i = 0;
//系统初始化
Hradware.Sys_Init();
//串口1发送初始化信息
#ifdef Monitor_Run_Code
printf("Initialization completed,system startup!\r\n\r\n");
#endif
//系统主循环
while(1)
{
//获取PCB板温度
NTC.Get_Temperature_Value();
//数码管显示温度
TM1620.Disp_Tempareture();
i = 500;
while(i--)
{
Public.Delay_ms(1);
//状态机检测按键2
KEY2.KEY_Detect();
if(KEY2.KEY_Flag == TRUE) //按键2被按下则退出循环,实时设置PWM灯亮度
{
break;
}
}
//设置PWM灯亮度
PWM.PWM_LED_Adjust_Brightness();
}
}