• STC15单片机-上位机通过Modbus-RTU协议与开发板通信


    上位机通过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();                
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

    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;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    波特率改为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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    实现串口协议函数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;
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    Timer0.h:增加延时定时器usDelay_Timer
    Timer0.c:中断处理函数中usDelay_Timer++

    NTC.h:增加用于Modbus发送的温度值uTemperature
    NTC.c:将ADC采集值Temp赋值给uTemperature,给Modbus.c读寄存器函数调用,封装成帧发送给主机

      //用于Modbus传输到上位机的温度值
      NTC.uTemperature = Temp;
    
    • 1
    • 2

    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
    ********************************************************/
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    /* Includes ------------------------------------------------------------------*/
    #include 
    
    /* 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
    ********************************************************/
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    增加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
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    处理主机发送过来的信息帧步骤

    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设置为接收模式
    
    • 1
    • 2
    • 3

    查看使用到的代码源文件,发现PWM.c源文件的打印按键信息语句处有问题,与RS-485设置发送模式和接收模式没有关系,这只是一个引脚的状态位改变而已,没有宏定义Monitor_Run_Code语句时,虽然串口切换到了RS-485的引脚上,但那些ADC采集值,NTC电压,温度是用 printf 打印的,没有宏定义 Monitor_Run_Code 语句也就这些信息都没有进行打印,问题就出在了

    printf("KEY2 click detected\r\n\r\n");    //打印单击信息
    
    • 1

    这条语句上,因为重写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
    
    • 1
    • 2
    • 3

    更改后在没有涉及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();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    改进延时方法

    将延时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();
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
  • 相关阅读:
    【世界历史】第二集——文明的曙光
    【C++】多态学习
    【Python零基础入门篇 · 7】:列表、元组的相关操作(完整版)
    cmd/bat 输出符,控制台日志输出到文件
    《MongoDB》Mongo Shell中的基本操作-更新操作一览
    Linux用户管理
    【Miniconda】Linux系统中 .condarc 配置文件的位置一般在哪里
    K8s的网络——Underlay和Overlay网络
    Log4j日志框架多种日志级别
    (01)ORB-SLAM2源码无死角解析-(57) 闭环线程→计算Sim3:理论推导(1)求解R,使用四元数
  • 原文地址:https://blog.csdn.net/weixin_46251230/article/details/126682848