《手把手教你学51单片机》之十八----RS485通信与Modbus协议

From:http://bbs.ickey.cn/group-topic-id-25006.html


第18章 RS485通信与Modbus协议

      在工业控制、电力通讯、智能仪表等领域,通常情况下是采用串口通信的方式进行数据交换。最初采用的方式是RS232接口,由于工业现场比较复杂,各种电气设备会在环境中产生比较多的电磁干扰,会导致信号传输错误。除此之外,RS232接口只能实现点对点通信,不具备联网功能,最大传输距离也只能达到十几米,不能满足远距离通信要求。而RS485则解决了这些问题,数据信号采用差分传输方式,可以有效的解决共模干扰问题,最大距离可达1200米,并且允许多个收发设备接到同一条总线上。随着工业应用通信越来越多,1979年施耐德电气制定了一个用于工业现场的总线协议Modbus协议,现在工业中使用RS485通信场合很多都采用Modbus协议,本节课我们就来讲解一下RS485通信和Modbus协议。


      单单使用一块KST-51开发板是不能够进行RS485实验的,应很多同学的要求,把这节课作为扩展课程讲一下,如果要做本课相关实验,需要自行购买USBRS485通信模块,或连接其它的RS485主控设备进行


1.1 RS485通信

RS232标准是诞生于RS485之前的,但是RS232有几处不足的地方:

1接口的信号电平值较高,达到十几V使用不当容易损坏接口芯片,电平标准也与TTL电平不兼容。

2传输速率有局限,不可以过高,一般到一两百千比特每秒(Kb/s)就到极限了。

3接口使用信号线和GND与其它设备形成共地模式的通信,这种共地模式传输容易产生干扰,并且抗干扰性能也比较弱。

4传输距离有限,最多只能通信几十米。

5通信的时候只能两点之间进行通信,不能够实现多机联网通信。


      针对RS232接口的不足,就不断出现了一些新的接口标准,RS485就是其中之一,它具备以下的特点:

1、采用差分信号。我们在讲A/D的时候,讲过差分信号输入的概念,同时也介绍了差分输入的好处,最大的优势是可以抑制共模干扰。尤其当工业现场环境比较复杂,干扰比较多时,采用差分方式可以有效的提高通信可靠性。RS485采用两根通信线,通常用AB或者D+D-来表示。逻辑“1”以两线之间的电压差为+(0.2~6)V表示,逻辑“0”以两线间的电压差为-(0.2~6)V来表示,是一种典型的差分通信。

2RS485通信速率快,最大传输速度可以达到10Mb/s以上。

3RS485内部的物理结构,采用的是平衡驱动器和差分接收器的组合,抗干扰能力也大大增加。

4传输距离最远可以达到1200米左右,但是它的传输速率和传输距离是成反比的,只有在100Kb/s以下的传输速度,才能达到最大的通信距离,如果需要传输更远距离可以使用中继。

5可以在总线上进行联网实现多机通信,总线上允许挂多个收发器,从现有的RS485芯片来看,有可以挂3264128256等不同个设备的驱动器。

6RS485的接口非常简单,与RS232所使用的MAX232是类似的,只需要一个RS485转换器,就可以直接与单片机的UART串口连接起来,并且使用完全相同的异步串行通信协议。但是由于RS485是差分通信,因此接收数据和发送数据是不能同时进行的,也就是说它是一种半双工通信。那我们如何判断什么时候发送,什么时候接收呢?


      RS485转换芯片很多,这节课我们以典型的MAX485为例讲解RS485通信,如图18-1所示。

《手把手教你学51单片机》之十八----RS485通信与Modbus协议 

18-1  MAX485硬件接口


      MAX485是美信(Maxim)推出的一款常用RS485转换器。其中5脚和8脚是电源引脚;6脚和7脚就是RS485通信中的AB两个引脚;1脚和4脚分别接到单片机的RXDTXD引脚上,直接使用单片机UART进行数据接收和发送;2脚和3脚是方向引脚,其中2脚是低电平使能接收器,3脚是高电平使能输出驱动器,我们把这两个引脚连到一起,平时不发送数据的时候,保持这两个引脚是低电平,让MAX485处于接收状态,当需要发送数据的时候,把这个引脚拉高,发送数据,发送完毕后再拉低这个引脚就可以了。为了提高RS485的抗干扰能力,需要在靠近MAX485AB引脚之间并接一个电阻,这个电阻阻值从100欧到1K是可以。


      在这里我们还要介绍一下如何使用KST-51单片机开发板进行外围扩展实验。我们的开发板只能把基本的功能给同学们做出来提供实验练习,但是同学们学习的脚步不应该停留在这个实验板上。如果想进行更多的实验,就可以通过单片机开发板的扩展接口进行扩展实验。大家可以看到蓝绿色的单片机座周围有32个插针,这32个插针就是把单片机的32IO引脚全部都引出来了。在原理图上体现出来的就是J4J5J6J74个器件,如图18-2所示。

《手把手教你学51单片机》之十八----RS485通信与Modbus协议 

18-2  单片机扩展接口


      这32IO中并不是所有的都可以用来对外扩展,其中既作为数据输出,又可以作为数据输入的引脚是不可以用的,比如P3.2P3.4P3.6引脚,这三个引脚是不可用的。比如P3.2这个引脚,如果我们用来扩展,发送的信号如果和DS18B20的时序吻合,会导致DS18B20拉低引脚,影响通信。除这3IO口以外的其它29个,都可以使用杜邦线接上插针,扩展出来使用。当然了,如果把当前的IO口应用于扩展功能了,板子上的相应功能就实现不了了,也就是说需要扩展功能和板载功能之间二选一。


      在进行RS485实验中,我们通信用的引脚必须是P3.0P3.1,此外还有一个方向控制引脚,我们使用杜邦线将其连接到P1.7上去。RS485的另外一端,大家可以使用一个USBRS485模块,用双绞线把开发板和模块上的AB分别对应连起来,USB那头插入电脑,然后就可以进行通信了。


      学习了第13章实用的串口通信方法和程序后,做这种串口通信的方法就很简单了,基本是一致的。我们使用实用串口通信例程的思路,做了一个简单的程序,通过串口调试助手下发任意个字符,单片机接收到后在末尾添加“回车+换行”符后再送回,在调试助手上重新显示出来,先把程序贴出来。


      程序中需要注意的一点是:因为平常都是将MAX485设置为接收状态,只有在发送数据的时候才将MAX485改为发送状态,所以在UartWrite()函数开头将MAX485方向引脚拉高,函数退出前再拉低。但是这里有一个细节,就是单片机的发送和接收中断产生的时刻都是在停止位的一半上,也就是说每当停止位传送了一半的时候,RITI就已经置位并且马上进入中断(如果中断使能的话)函数了,接收的时候自然不会存在问题,但发送的时候就不一样了:当紧接着向SBUF写入一个字节数据时,UART硬件会在完成上一个停止位的发送后,再开始新字节的发送,但如果此时不是继续发送下一个字节,而是已经发送完毕了,要停止发送并将MAX485方向引脚拉低以使MAX485重新处于接收状态时就有问题了,因为这时候最后的这个停止位实际只发送了一半,还没有完全完成,所以就有了UartWrite()函数内DelayX10us(5)这个操作,这是人为的增加了50us的延时,这50us的时间正好让剩下的一半停止位完成,那么这个时间自然就是由通信波特率决定的了,为波特率周期的一半。


/****************************RS485.c文件程序源代码*****************************/

#include <reg52.h>

#include <intrins.h>

 

sbit RS485_DIR = P1^7;  //RS485方向选择引脚

 

bit flagFrame = 0;  //帧接收完成标志,即接收到一帧新数据

bit flagTxd = 0;    //单字节发送完成标志,用来替代TXD中断标志位

unsigned char cntRxd = 0;   //接收字节计数器

unsigned char pdata bufRxd[64];  //接收字节缓冲区

 

extern void UartAction(unsigned char *buf, unsigned char len);

 

/* 串口配置函数,baud-通信波特率 */

void ConfigUART(unsigned int baud)

{

    RS485_DIR = 0; //RS485设置为接收方向

    SCON  = 0x50;  //配置串口为模式1

    TMOD &= 0x0F;  //清零T1的控制位

    TMOD |= 0x20;  //配置T1为模式2

    TH1 = 256 - (11059200/12/32)/baud;  //计算T1重载值

    TL1 = TH1;     //初值等于重载值

    ET1 = 0;       //禁止T1中断

    ES  = 1;       //使能串口中断

    TR1 = 1;       //启动T1

}

/* 软件延时函数,延时时间(t*10)us */

void DelayX10us(unsigned char t)

{

    do {

        _nop_();

        _nop_();

        _nop_();

        _nop_();

        _nop_();

        _nop_();

        _nop_();

        _nop_();

    } while (--t);

}

/* 串口数据写入,即串口发送函数,buf-待发送数据的指针,len-指定的发送长度 */

void UartWrite(unsigned char *buf, unsigned char len)

{

    RS485_DIR = 1;  //RS485设置为发送

    while (len--)   //循环发送所有字节

    {

        flagTxd = 0;       //清零发送标志

        SBUF = *buf++;    //发送一个字节数据

        while (!flagTxd); //等待该字节发送完成

    }

    DelayX10us(5);  //等待最后的停止位完成,延时时间由波特率决定

    RS485_DIR = 0;  //RS485设置为接收

}

/* 串口数据读取函数,buf-接收指针,len-指定的读取长度,返回值-实际读到的长度 */

unsigned char UartRead(unsigned char *buf, unsigned char len)

{

    unsigned char i;

    

    if (len > cntRxd)  //指定读取长度大于实际接收到的数据长度时,

    {                     //读取长度设置为实际接收到的数据长度

        len = cntRxd;

    }

    for (i=0; i<len; i++)  //拷贝接收到的数据到接收指针上

    {

        *buf++ = bufRxd[i];

    }

    cntRxd = 0;  //接收计数器清零

    

    return len;  //返回实际读取长度

}

/* 串口接收监控,由空闲时间判定帧结束,需在定时中断中调用,ms-定时间隔 */

void UartRxMonitor(unsigned char ms)

{

    static unsigned char cntbkp = 0;

    static unsigned char idletmr = 0;

 

    if (cntRxd > 0)  //接收计数器大于零时,监控总线空闲时间

    {

        if (cntbkp != cntRxd)  //接收计数器改变,即刚接收到数据时,清零空闲计时

        {

            cntbkp = cntRxd;

            idletmr = 0;

        }

        else                     //接收计数器未改变,即总线空闲时,累积空闲时间

        {

            if (idletmr < 30)  //空闲计时小于30ms时,持续累加

            {

                idletmr += ms;

                if (idletmr >= 30)  //空闲时间达到30ms时,即判定为一帧接收完毕

                {

                    flagFrame = 1;  //设置帧接收完成标志

                }

            }

        }

    }

    else

    {

        cntbkp = 0;

    }

}

/* 串口驱动函数,监测数据帧的接收,调度功能函数,需在主循环中调用 */

void UartDriver()

{

    unsigned char len;

    unsigned char pdata buf[40];

 

    if (flagFrame) //有命令到达时,读取处理该命令

    {

        flagFrame = 0;

        len = UartRead(buf, sizeof(buf)-2); //将接收到的命令读取到缓冲区中

        UartAction(buf, len);  //传递数据帧,调用动作执行函数

    }

}

/* 串口中断服务函数 */

void InterruptUART() interrupt 4

{

    if (RI)  //接收到新字节

    {

        RI = 0;  //清零接收中断标志位

        if (cntRxd < sizeof(bufRxd)) //接收缓冲区尚未用完时,

        {                                 //保存接收字节,并递增计数器

            bufRxd[cntRxd++] = SBUF;

        }

    }

    if (TI)  //字节发送完毕

    {

        TI = 0;   //清零发送中断标志位

        flagTxd = 1;  //设置字节发送完成标志

    }

}

/*****************************main.c文件程序源代码******************************/

#include <reg52.h>

 

unsigned char T0RH = 0;  //T0重载值的高字节

unsigned char T0RL = 0;  //T0重载值的低字节

 

void ConfigTimer0(unsigned int ms);

extern void UartDriver();

extern void ConfigUART(unsigned int baud);

extern void UartRxMonitor(unsigned char ms);

extern void UartWrite(unsigned char *buf, unsigned char len);

 

void main()

{

    EA = 1;              //开总中断

    ConfigTimer0(1);   //配置T0定时1ms

    ConfigUART(9600);  //配置波特率为9600

    

    while (1)

    {

        UartDriver();  //调用串口驱动

    }

}

/* 串口动作函数,根据接收到的命令帧执行响应的动作

   buf-接收到的命令帧指针,len-命令帧长度 */

void UartAction(unsigned char *buf, unsigned char len)

{

    //在接收到的数据帧后添加换车换行符后发回

    buf[len++] = 'r';

    buf[len++] = 'n';

    UartWrite(buf, len);

}

/* 配置并启动T0ms-T0定时时间 */

void ConfigTimer0(unsigned int ms)

{

    unsigned long tmp;  //临时变量

    

    tmp = 11059200 / 12;       //定时器计数频率

    tmp = (tmp * ms) / 1000;  //计算所需的计数值

    tmp = 65536 - tmp;         //计算定时器重载值

    tmp = tmp + 33;            //补偿中断响应延时造成的误差

    T0RH = (unsigned char)(tmp>>8);  //定时器重载值拆分为高低字节

    T0RL = (unsigned char)tmp;

    TMOD &= 0xF0;   //清零T0的控制位

    TMOD |= 0x01;   //配置T0为模式1

    TH0 = T0RH;     //加载T0重载值

    TL0 = T0RL;

    ET0 = 1;        //使能T0中断

    TR0 = 1;        //启动T0

}

/* T0中断服务函数,执行串口接收监控 */

void InterruptTimer0() interrupt 1

{

    TH0 = T0RH;  //重新加载重载值

    TL0 = T0RL;

    UartRxMonitor(1);  //串口接收监控

}


      现在看这种串口程序,是不是感觉很简单了呢?串口通信程序我们反反复复的使用,加上随着学习的模块越来越多,实践的越来越多,原先感觉很复杂的东西,现在就会感到简单了。从设备管理器里可以查看所有的COM口号,我们下载程序用的是COM4,而USBRS485虚拟的是COM5,通信的时候我们用的是COM5口,如图18-3所示。

《手把手教你学51单片机》之十八----RS485通信与Modbus协议 

18-3  RS485通信试验设置和结果

1.2 Modbus通信协议介绍

 我们前边学习UARTI2CSPI这些通信协议,都是最底层的协议,是“位”级别的协议。而我们在学习13做实用串口通信程序的时候,我们通过串口发给单片机三条指令,让单片机做了三件不同的事情,分别是“buzz on”、“buzz off”和“showstr”。随着系统复杂性的增加,我们希望可以实现更多的指令。而指令越来越多,带来的后果就是非常杂乱无章,尤其是这个人喜欢写成“buzz on”、“buzz off”,而另外一个人喜欢写成“on buzz”、“off buzz”。导致不同开发人员写出来的程序代码不兼容,不同厂家的产品不能挂到一条总线上通信。

随着这种矛盾的日益严重,就会有聪明人提出更合理的解决方案,提出一些标准来,今后我们的编程必须按照这个标准来,这种标准也是一种通信协议,但是和UARTI2CSPI通信协议不同的是,这种通信协议是字节级别的,叫做应用层通信协议。在1979年由Modicon(现为施耐德电气公司的一个品牌)提出了全球第一个真正用于工业现场总线的协议,就是Modbus协议。

1.2.1 Modbus协议特点

      Modbus协议是应用于电子控制器上的一种通用语言。通过此协议,控制器相互之间、控制器经由网络(例如以太网)和其他设备之间可以通信,已经成为一种工业标准。有了它,不同厂商生产的控制设备可以连成工业网络,进行集中监控。这种协议定义了一种控制器能够认识使用的数据结构,而不管它们是经过何种网络进行通信的。它描述了控制器请求访问其它设备的过程,如何回应来自其它设备的请求,以及怎样侦测错误记录,它制定了通信数据的格局和内容的公共格式。


       在进行多机通信的时候,Modbus协议规定每个控制器必须要知道它们的设备地址,识别按照地址发送过来的数据,决定是否要产生动作,产生何种动作,如果要回应,控制器将生成的反馈信息用Modbus协议发出。


      Modbus协议允许在各种网络体系结构内进行简单通信,每种设备(PLC、人机界面、控制面板、驱动程序、输入输出设备等)都能使用Modbus协议来启动远程操作,一些网关允许在几种使用Modbus协议的总线或网络之间的通信,如图18-4所示。

《手把手教你学51单片机》之十八----RS485通信与Modbus协议 

18-4  Modbus网络体系结构实例


      Modbus协议的整体架构和格式比较复杂和庞大,在我们的课程里,我们重点介绍数据帧结构和数据通信控制方式,作为一个入门级别的了解。如果大家要详细了解,或者使用Modbus开发相关设备,可以查阅相关的国标文件再进行深入学习。

1.1.1 RTU协议帧数据

      Modbus有两种通信传输方式,一种是ASCII模式,一种是RTU模式。由于ASCII模式的数据字节是7bit数据位,51单片机无法实现,而且实际应用的也比较少,所以这里我们只用RTU模式。两种模式相似,会用一种另外一种也就会了。一条典型的RTU数据帧如图18-5所示。

 

18-5  RTU数据帧


      与之前我们讲解实用串口通信程序时用的原理相同,一次发送的数据帧必须是作为一个连续的数据流进行传输。我们在实用串口通信程序中采用的方法是定义30ms,如果数据接收时超过了30ms还没有接收到下一个字节,我们就认为这次的数据结束。而ModbusRTU模式规定不同数据帧之间的间隔是3.5个字节通信时间以上。如果在一帧数据完成之前有超过3.5个字节时间的停顿,接收设备将刷新当前的消息并假定下一个字节是一个新的数据帧的开始。同样的,如果一个新消息在小于3.5个字节时间内接着前边一个数据开始,接收设备将会认为它是前一帧数据的延续。这将会导致一个错误,因此大家看RTU数据帧最后还有16bit的CRC校验。


       起始位和结束符:图18-5上代表的是一个数据帧,前后都至少有3.5个字节的时间间隔,起始位和结束符实际上没有任何数据,T1-T2-T3-T4代表的是时间间隔3.5个字节以上的时间,而真正有意义的第一个字节是设备地址。


      设备地址:很多同学不理解,在多机通信的时候,数据那么多,我们依靠什么判断这个数据帧是哪个设备的呢?没错,就是依靠这个设备地址字节。每个设备都有一个自己的地址,当设备接收到一帧数据后,程序首先对设备地址字节进行判断比较,如果与自己的地址不同,则对这帧数据直接不予理会,如果与自己的地址相同,就要对这帧数据进行解析,按照之后的功能码执行相应的功能。如果地址是0x00,则认为是一个广播命令,就是所有的从机设备都要执行的指令。


      功能代码:在第二个字节功能代码字节中,Modbus规定了部分功能代码,此外也保留了一部分功能代码作为备用或者用户自定义,这些功能码大家不需要去记忆,甚至都不用去看,直到你用到的那天再过来查这个表格即可,如表18-1所示。


                                                                           表18-1 Modbus功能码

功能码

名称

作用

01

读取线圈状态

取得一组逻辑线圈的当前状态(ON/OFF)

02

读取输入状态

取得一组开关输入的当前状态(ON/OFF)

03

读取保持寄存器 

在一个或多个保持寄存器中取得当前的二进制值

04

读取输入寄存器

在一个或多个输入寄存器中取得当前的二进制值

05

强置单线圈

强置一个逻辑线圈的通断状态

06

预置单寄存器

把具体二进值装入一个保持寄存器 

07

读取异常状态

取得8个内部线圈的通断状态,这8个线圈的地址由控制器决定,用户逻辑可以将这些线圈定义,以说明从机状态,短报文适宜于迅速读取状态 

08

回送诊断校验

把诊断校验报文送从机,以对通信处理进行评鉴

09

编程(只用于484)

使主机模拟编程器作用,修改PC从机逻辑

10

控询(只用于484)

可使主机与一台正在执行长程序任务从机通信,探询该从机是否已完成其操作任务,仅在含有功能码 的报文发送后,本功能码才发送 

11

读取事件计数

可使主机发出单询问,并随即判定操作是否成功,尤其是该命令或其应答产生通信错误时 

12

读取通信事件记录

使主机检索每台从机的ModBus事务处理通信事件记录。如果某项事务处理完成,记录会给出有关错误

13

编程(184/384 484 584 )

可使主机模拟编程器功能修改PC从机逻辑 

14

探询(184/384 484 584)

可使主机与正在执行任务的从机通信,定期控询该从机是否已完成其程序操作,仅在含有功能13的报文发送后,本功能码才得发送

15

强置多线圈

强置一串连续逻辑线圈的通断

16

预置多寄存器

把具体的二进制值装入一串连续的保持寄存器

17

报告从机标识

可使主机判断编址从机的类型及该从机运行指示灯的状态

18

884 MICRO 84

可使主机模拟编程功能,修改PC状态逻辑

19

重置通信链路

发生非可修改错误后,是从机复位于已知状态,可重置顺序字节 

20

读取通用参数(584L)

显示扩展存储器文件中的数据信息

21

写入通用参数(584L)

把通用参数写入扩展存储文件,或修改

22~64

保留作扩展功能备用

 

65~72

保留以备用户功能所用

留作用户功能的扩展编码 

73~119

非法功能

 

120~127

保留 

留作内部作用

128~255

保留

用于异常应答


      程序对功能码的处理,就是来检测这个字节的数值,然后根据其数值来做相应的功能处理。


      数据:跟在功能代码后边的是n8bit的数据。这个n值的到底是多少,是功能代码来确定的,不同的功能代码后边跟的数据数量不同。举个例子,如果功能码是0x03,也就是读保持寄存器,那么主机发送数据n的组成部分就是:2个字节的寄存器起始地址,加2个字节的寄存器数量N。从机数据n的组成部分是:1个字节的字节数,因为我们回复的寄存器的值是2个字节,所以这个字节数也就是2N个,再加上2N个寄存器的值,如图18-6示。

 

18-6  读保持寄存器数据结构


      CRC校验:CRC校验是一种数据算法,是用来校验数据对错的。CRC校验函数把一帧数据除最后两个字节外,前边所有的字节进行特定的算法计算,计算完后生成了一个16bit的数据,作为CRC校验码,添加在一帧数据的最后。接收方接收到数据后,同样会把前边的字节进行CRC计算,计算完了再和发过来的16bitCRC数据进行比较,如果相同则认为数据正常,没有出错,如果比较不相同,则说明数据在传输中发生了错误,这帧数据将被丢弃,就像没收到一样,而发送方会在得不到回应后做相应的处理错误处理。


      RTU模式的每个字节的位是这样分布的:1个起始位、8个数据位,最小有效位先发送、1个奇偶校验位(如果无校验则没有这一位)、1位停止位(有校验位时)或者2个停止位(无校验位时)。

1.1 Modbus多机通信例程

      给从机下发不同的指令,从机去执行不同的操作,这个就是判断一下功能码即可,和我们前边学的实用串口例程是类似的。多机通信,无非就是添加了一个设备地址判断而已,难度也不大。我们找了一个Modbus调试精灵,通过设置设备地址,读写寄存器的地址以及数值数量等参数,可以直接替代串口调试助手,比较方便的下发多个字节的数据,如图18-7所示。我们先来就图中的设置和数据来对Modbus做进一步的分析,图中的数据来自于调试精灵与我们接下来要讲的例程之间的交互。

 

18-7  Modbus调试精灵


     如图,我们的USBRS485模块虚拟出的是COM5,波特率9600,无校验位,数据位是8位,1位停止位,设备地址假设为1


      写寄存器的时候,如果我们要把01写到一个地址是0000的寄存器地址里,点一下“写入”,就会出现发送指令:01 06 00 00 00 01 48 0A。我们来分析一下这帧数据,其中01是设备地址,06是功能码,代表写寄存器这个功能,后边跟00 00表示的是要写入的寄存器的地址,00 01就是要写入的数据,48 0A就是CRC校验码,这是软件自动算出来的。而根据Modbus协议,当写寄存器的时候,从机成功完成该指令的操作后,会把主机发送的指令直接返回,我们的调试精灵会接收到这样一帧数据:01 06 00 00 00 01 48 0A

  

      假如我们现在要从寄存器地址0002开始读取寄存器,并且读取的数量是2个。点一下“读出”,就会出现发送指令:01 03 00 02 00 02 65 CB。其中01是设备地址,03是功能码,代表读寄存器这个功能,00 02就是读寄存器的起始地址,后一个00 02就是要读取2个寄存器的数值,65 CB就是CRC校验。而接收到的数据是:01 03 04 00 00 00 00 FA 33。其中01是设备地址,03是功能码,04代表的是后边读到的数据字节数是4个,00 00 00 00分别是地址为00 0200 03的寄存器内部的数据,而FA 33就是CRC校验了。


      似乎越来越明朗了,所谓的Modbus通信协议,无非就是主机下发了不同的指令,从机根据指令的判断来执行不同的操作而已。由于我们的开发板没有Modbus功能码那么多相应的功能,我们在程序中定义了一个数组regGroup[5],相当于5个寄存器,此外又定义了第6个寄存器,控制蜂鸣器,通过下发不同的指令我们改变寄存器组的数据或者改变蜂鸣器的开关状态。在Modbus协议里寄存器的地址和数值都是16位的,即2个字节,我们默认高字节是0x00,低字节就是数组regGroup对应的值。其中地址0x00000x0004对应的就是regGroup数组中的元素,我们写入的同时把数字又显示到1602液晶上,而0x0005这个地址,写入0x00,蜂鸣器就不响,写入任何其它数值,蜂鸣器就报警。我们单片机的主要工作也就是解析串口接收的数据执行不同操作。

/***************************Lcd1602.c文件程序源代码*****************************/

(此处省略,可参考之前章节的代码)

/****************************RS485.c文件程序源代码*****************************/

(此处省略,可参考之前章节的代码)

/****************************CRC16.c文件程序源代码****************************/

/* CRC16计算函数,ptr-数据指针,len-数据长度,返回值-计算出的CRC16数值 */

unsigned int GetCRC16(unsigned char *ptr,  unsigned char len)

    unsigned int index;

    unsigned char crch = 0xFF;  //CRC字节

    unsigned char crcl = 0xFF;  //CRC字节

    unsigned char code TabH[] = {  //CRC高位字节值表

        0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0,  

        0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,  

        0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0,  

        0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40,  

        0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1,  

        0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41,  

        0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1,  

        0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,  

        0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0,  

        0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40,  

        0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1,  

        0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40,  

        0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0,  

        0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40,  

        0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0,  

        0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40,  

        0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0,  

        0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,  

        0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0,  

        0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,  

        0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0,  

        0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40,  

        0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1,  

        0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,  

        0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0,  

        0x80, 0x41, 0x00, 0xC1, 0x81, 0x40  

    } ;  

    unsigned char code TabL[] = {  //CRC低位字节值表

        0x00, 0xC0, 0xC1, 0x01, 0xC3, 0x03, 0x02, 0xC2, 0xC6, 0x06,  

        0x07, 0xC7, 0x05, 0xC5, 0xC4, 0x04, 0xCC, 0x0C, 0x0D, 0xCD,  

        0x0F, 0xCF, 0xCE, 0x0E, 0x0A, 0xCA, 0xCB, 0x0B, 0xC9, 0x09,  

        0x08, 0xC8, 0xD8, 0x18, 0x19, 0xD9, 0x1B, 0xDB, 0xDA, 0x1A,  

        0x1E, 0xDE, 0xDF, 0x1F, 0xDD, 0x1D, 0x1C, 0xDC, 0x14, 0xD4,  

        0xD5, 0x15, 0xD7, 0x17, 0x16, 0xD6, 0xD2, 0x12, 0x13, 0xD3,  

        0x11, 0xD1, 0xD0, 0x10, 0xF0, 0x30, 0x31, 0xF1, 0x33, 0xF3,  

        0xF2, 0x32, 0x36, 0xF6, 0xF7, 0x37, 0xF5, 0x35, 0x34, 0xF4,  

        0x3C, 0xFC, 0xFD, 0x3D, 0xFF, 0x3F, 0x3E, 0xFE, 0xFA, 0x3A,  

        0x3B, 0xFB, 0x39, 0xF9, 0xF8, 0x38, 0x28, 0xE8, 0xE9, 0x29,  

        0xEB, 0x2B, 0x2A, 0xEA, 0xEE, 0x2E, 0x2F, 0xEF, 0x2D, 0xED,  

        0xEC, 0x2C, 0xE4, 0x24, 0x25, 0xE5, 0x27, 0xE7, 0xE6, 0x26,  

        0x22, 0xE2, 0xE3, 0x23, 0xE1, 0x21, 0x20, 0xE0, 0xA0, 0x60,  

        0x61, 0xA1, 0x63, 0xA3, 0xA2, 0x62, 0x66, 0xA6, 0xA7, 0x67,  

        0xA5, 0x65, 0x64, 0xA4, 0x6C, 0xAC, 0xAD, 0x6D, 0xAF, 0x6F,  

        0x6E, 0xAE, 0xAA, 0x6A, 0x6B, 0xAB, 0x69, 0xA9, 0xA8, 0x68,  

        0x78, 0xB8, 0xB9, 0x79, 0xBB, 0x7B, 0x7A, 0xBA, 0xBE, 0x7E,  

        0x7F, 0xBF, 0x7D, 0xBD, 0xBC, 0x7C, 0xB4, 0x74, 0x75, 0xB5,  

        0x77, 0xB7, 0xB6, 0x76, 0x72, 0xB2, 0xB3, 0x73, 0xB1, 0x71,  

        0x70, 0xB0, 0x50, 0x90, 0x91, 0x51, 0x93, 0x53, 0x52, 0x92,  

        0x96, 0x56, 0x57, 0x97, 0x55, 0x95, 0x94, 0x54, 0x9C, 0x5C,  

        0x5D, 0x9D, 0x5F, 0x9F, 0x9E, 0x5E, 0x5A, 0x9A, 0x9B, 0x5B,  

        0x99, 0x59, 0x58, 0x98, 0x88, 0x48, 0x49, 0x89, 0x4B, 0x8B,  

        0x8A, 0x4A, 0x4E, 0x8E, 0x8F, 0x4F, 0x8D, 0x4D, 0x4C, 0x8C,  

        0x44, 0x84, 0x85, 0x45, 0x87, 0x47, 0x46, 0x86, 0x82, 0x42,  

        0x43, 0x83, 0x41, 0x81, 0x80, 0x40  

    } ;

 

    while (len--)  //计算指定长度的CRC

    {

        index = crch ^ *ptr++;

        crch = crcl ^ TabH[index];

        crcl = TabL[index];

    }

    

    return ((crch<<8) | crcl);  

}

关于CRC校验的算法,如果不是专门学习校验算法本身,大家可以不去研究这个程序的细节,直接使用现成的函数即可。 

/*****************************main.c文件程序源代码******************************/

#include <reg52.h>

 

sbit BUZZ = P1^6;

 

bit flagBuzzOn = 0;   //蜂鸣器启动标志

unsigned char T0RH = 0;  //T0重载值的高字节

unsigned char T0RL = 0;  //T0重载值的低字节

unsigned char regGroup[5];  //Modbus寄存器组,地址为0x000x04

 

void ConfigTimer0(unsigned int ms);

extern void UartDriver();

extern void ConfigUART(unsigned int baud);

extern void UartRxMonitor(unsigned char ms);

extern void UartWrite(unsigned char *buf, unsigned char len);

extern unsigned int GetCRC16(unsigned char *ptr,  unsigned char len);

extern void InitLcd1602();

extern void LcdShowStr(unsigned char x, unsigned char y, unsigned char *str);

 

void main()

{

    EA = 1;              //开总中断

    ConfigTimer0(1);   //配置T0定时1ms

    ConfigUART(9600);  //配置波特率为9600

    InitLcd1602();     //初始化液晶

    

    while (1)

    {

        UartDriver();  //调用串口驱动

    }

}

/* 串口动作函数,根据接收到的命令帧执行响应的动作

   buf-接收到的命令帧指针,len-命令帧长度 */

void UartAction(unsigned char *buf, unsigned char len)

{

    unsigned char i;

    unsigned char cnt;

    unsigned char str[4];

    unsigned int  crc;

    unsigned char crch, crcl;

    

    if (buf[0] != 0x01) //本例中的本机地址设定为0x01

    {                      //如数据帧中的地址字节与本机地址不符,

        return;           //则直接退出,即丢弃本帧数据不做任何处理

    }

    //地址相符时,再对本帧数据进行校验

    crc = GetCRC16(buf, len-2);  //计算CRC校验值

    crch = crc >> 8;

    crcl = crc & 0xFF;

    if ((buf[len-2]!=crch) || (buf[len-1]!=crcl))

    {

        return;   //CRC校验不符时直接退出

    }

    //地址和校验字均相符后,解析功能码,执行相关操作

    switch (buf[1])

    {

        case 0x03:  //读取一个或连续的寄存器

            if ((buf[2]==0x00) && (buf[3]<=0x05)) //只支持0x00000x0005

            {

                if (buf[3] <= 0x04)

                {

                    i = buf[3];      //提取寄存器地址

                    cnt = buf[5];    //提取待读取的寄存器数量

                    buf[2] = cnt*2;  //读取数据的字节数,为寄存器数*2

                    len = 3;          //帧前部已有地址、功能码、字节数共3个字节

                    while (cnt--)

                    {

                        buf[len++] = 0x00;            //寄存器高字节补0

                        buf[len++] = regGroup[i++]; //寄存器低字节

                    }

                }

                else  //地址0x05为蜂鸣器状态

                {

                    buf[2] = 2;  //读取数据的字节数

                    buf[3] = 0x00;

                    buf[4] = flagBuzzOn;

                    len = 5;

                }

                break;

            }

            else  //寄存器地址不被支持时,返回错误码

            {

                buf[1] = 0x83;  //功能码最高位置1

                buf[2] = 0x02;  //设置异常码为02-无效地址

                len = 3;

                break;

            }

            

        case 0x06:  //写入单个寄存器

            if ((buf[2]==0x00) && (buf[3]<=0x05)) //只支持0x00000x0005

            {

                if (buf[3] <= 0x04)

                {

                    i = buf[3];               //提取寄存器地址

                    regGroup[i] = buf[5];   //保存寄存器数据

                    cnt = regGroup[i] >> 4; //显示到液晶上

                    if (cnt >= 0xA)

                        str[0] = cnt - 0xA + 'A';

                    else

                        str[0] = cnt + '0';

                    cnt = regGroup[i] & 0x0F;

                    if (cnt >= 0xA)

                        str[1] = cnt - 0xA + 'A';

                    else

                        str[1] = cnt + '0';

                    str[2] = '';

                    LcdShowStr(i*3, 0, str);

                }

                else  //地址0x05为蜂鸣器状态

                {

                    flagBuzzOn = (bit)buf[5]; //寄存器值转为蜂鸣器的开关

                }

                len -= 2; //长度-2以重新计算CRC并返回原帧

               &nb

break;

            }

            else  //寄存器地址不被支持时,返回错误码

            {

                buf[1] = 0x86;  //功能码最高位置1

                buf[2] = 0x02;  //设置异常码为02-无效地址

                len = 3;

                break;

            }

            

        default:  //其它不支持的功能码

            buf[1] |= 0x80;  //功能码最高位置1

            buf[2] = 0x01;   //设置异常码为01-无效功能

            len = 3;

            break;

    }

    crc = GetCRC16(buf, len); //计算返回帧的CRC校验值

    buf[len++] = crc >> 8;    //CRC高字节

    buf[len++] = crc & 0xFF;  //CRC低字节

    UartWrite(buf, len);      //发送返回帧

}

/* 配置并启动T0ms-T0定时时间 */

void ConfigTimer0(unsigned int ms)

{

    unsigned long tmp;  //临时变量

    

    tmp = 11059200 / 12;       //定时器计数频率

    tmp = (tmp * ms) / 1000;  //计算所需的计数值

    tmp = 65536 - tmp;         //计算定时器重载值

    tmp = tmp + 33;            //补偿中断响应延时造成的误差

    T0RH = (unsigned char)(tmp>>8);  //定时器重载值拆分为高低字节

    T0RL = (unsigned char)tmp;

    TMOD &= 0xF0;   //清零T0的控制位

    TMOD |= 0x01;   //配置T0为模式1

    TH0 = T0RH;     //加载T0重载值

    TL0 = T0RL;

    ET0 = 1;        //使能T0中断

    TR0 = 1;        //启动T0

}

/* T0中断服务函数,执行串口接收监控和蜂鸣器驱动 */

void InterruptTimer0() interrupt 1

{

    TH0 = T0RH;  //重新加载重载值

    TL0 = T0RL;

    if (flagBuzzOn)  //执行蜂鸣器鸣叫或关闭

        BUZZ = ~BUZZ;

    else

        BUZZ = 1;

    UartRxMonitor(1);  //串口接收监控

}

大家可以看到负责解析协议的UartAction函数很长,因为协议解析本来就是一件很繁琐的事情。我们的例程仅解析执行了两个功能命令,就已经有近百行程序了,如果你需要解析更多的功能命令的话,那么建议把每个功能都做一个函数,然后在相应的case分支里调用即可,这样就不会使单个函数过于庞大而难以维护。

1.1 练习题

1、了解RS485通信以及和RS232的不同用法。

2、了解Modbus协议以及RTU数据帧的规则。

3、写一个电子钟程序,并且可以通过485调试器校时。


  • 34
    点赞
  • 268
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
### 回答1: 51单片机C语言编程,可以帮助我们更深入地理解嵌入式系统的原理和工作方式。对于初者来说,掌握一份适合自己的习资料非常重要。 要51单片机-C语言版,可以阅读《手把手51单片机-C语言版pdf》这本电子书,这本书内容丰富,讲解详细,配合实例编程,非常适合初者自。以下是习本书的几个关键点: 第一,掌握基本的硬件知识,包括单片机的结构和特性,尤其是各种寄存器的作用和配置方法。 第二,了解C语言编程基础,尤其是语法、数据类型、运算符、控制结构、函数等,这是编写单片机程序的基础。 第三,通过实例编程加强对知识的理解和运用能力。例如,可以尝试写一些简单的IO控制、定时器中断、串口通讯等程序。 第四,可以搭配相应的开发板和开发环境进行实践习。例如,可以使用STC89C51开发板和Keil或SDCC开发环境。 总之,《手把手51单片机-C语言版pdf》这本电子书是一个不错的习资料,但也需要具备一定的基础知识和耐心,可以结合其他资料和实践不断提高自己的能力。 ### 回答2: 51单片机-c语言版, 需要基础的C语言编程知识。在习前,先要熟悉C语言的数据类型、循环、判断及函数等语法结构,并掌握C语言的编写方法。 在51单片机-c语言版之前,需要准备好习环境,如下载并安装Keil软件, 安装并关联好相应的单片机模拟器。Keil软件中有类似于记事本的编辑窗口用来编写C语言代码, 以及编译,调试和下载程序到单片机等功能。 在习时,可以选择一些简单的例程开始习,逐步理解其代码逻辑,了解基本的寄存器操作和中断等知识。可以从LED灯等简单的实验开始,逐渐增加难度和功能的复杂度。 同时,可以参考一些权威的习资料如《单片机原理与应用》、《51单片机习与应用》等相关书籍,或结合网络资源进行习。在习过程中,需要勤加练习,多编写代码进行实践,同时多与他人交流习体会和技术问题。通过坚持不断的习和练习,便可以逐步掌握51单片机-c语言版编程技巧,提高自己的单片机应用开发能力。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值