1.1.1 RTU协议帧数据 Modbus有两种通信传输方式,一种是ASCII模式,一种是RTU模式。由于ASCII模式的数据字节是7bit数据位,51单片机无法实现,而且实际应用的也比较少,所以这里我们只用RTU模式。两种模式相似,会用一种另外一种也就会了。一条典型的RTU数据帧如图18-5所示。 图18-5 RTU数据帧 与之前我们讲解实用串口通信程序时用的原理相同,一次发送的数据帧必须是作为一个连续的数据流进行传输。我们在实用串口通信程序中采用的方法是定义30ms,如果数据接收时超过了30ms还没有接收到下一个字节,我们就认为这次的数据结束。而Modbus的RTU模式规定不同数据帧之间的间隔是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) | 可使主机与一台正在执行长程序任务从机通信,探询该从机是否已完成其操作任务,仅在含有功能码 9 的报文发送后,本功能码才发送 | 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 | 保留 | 用于异常应答 | 程序对功能码的处理,就是来检测这个字节的数值,然后根据其数值来做相应的功能处理。 数据:跟在功能代码后边的是n个8bit的数据。这个n值的到底是多少,是功能代码来确定的,不同的功能代码后边跟的数据数量不同。举个例子,如果功能码是0x03,也就是读保持寄存器,那么主机发送数据n的组成部分就是:2个字节的寄存器起始地址,加2个字节的寄存器数量N。从机数据n的组成部分是:1个字节的字节数,因为我们回复的寄存器的值是2个字节,所以这个字节数也就是2N个,再加上2N个寄存器的值,如图18-6所示。 图18-6 读保持寄存器数据结构 CRC校验:CRC校验是一种数据算法,是用来校验数据对错的。CRC校验函数把一帧数据除最后两个字节外,前边所有的字节进行特定的算法计算,计算完后生成了一个16bit的数据,作为CRC校验码,添加在一帧数据的最后。接收方接收到数据后,同样会把前边的字节进行CRC计算,计算完了再和发过来的16bit的CRC数据进行比较,如果相同则认为数据正常,没有出错,如果比较不相同,则说明数据在传输中发生了错误,这帧数据将被丢弃,就像没收到一样,而发送方会在得不到回应后做相应的处理错误处理。 RTU模式的每个字节的位是这样分布的:1个起始位、8个数据位,最小有效位先发送、1个奇偶校验位(如果无校验则没有这一位)、1位停止位(有校验位时)或者2个停止位(无校验位时)。 1.1 Modbus多机通信例程 给从机下发不同的指令,从机去执行不同的操作,这个就是判断一下功能码即可,和我们前边学的实用串口例程是类似的。多机通信,无非就是添加了一个设备地址判断而已,难度也不大。我们找了一个Modbus调试精灵,通过设置设备地址,读写寄存器的地址以及数值数量等参数,可以直接替代串口调试助手,比较方便的下发多个字节的数据,如图18-7所示。我们先来就图中的设置和数据来对Modbus做进一步的分析,图中的数据来自于调试精灵与我们接下来要讲的例程之间的交互。 图18-7 Modbus调试精灵 如图,我们的USB转RS485模块虚拟出的是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 02和00 03的寄存器内部的数据,而FA 33就是CRC校验了。 似乎越来越明朗了,所谓的Modbus通信协议,无非就是主机下发了不同的指令,从机根据指令的判断来执行不同的操作而已。由于我们的开发板没有Modbus功能码那么多相应的功能,我们在程序中定义了一个数组regGroup[5],相当于5个寄存器,此外又定义了第6个寄存器,控制蜂鸣器,通过下发不同的指令我们改变寄存器组的数据或者改变蜂鸣器的开关状态。在Modbus协议里寄存器的地址和数值都是16位的,即2个字节,我们默认高字节是0x00,低字节就是数组regGroup对应的值。其中地址0x0000到0x0004对应的就是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 sbit BUZZ = P1^6; bit flagBuzzOn = 0; //蜂鸣器启动标志 unsigned char T0RH = 0; //T0重载值的高字节 unsigned char T0RL = 0; //T0重载值的低字节 unsigned char regGroup[5]; //Modbus寄存器组,地址为0x00~0x04 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)) //只支持0x0000~0x0005 { 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)) //只支持0x0000~0x0005 { if (buf[3] <= 0x04) { i = buf[3]; //提取寄存器地址 regGroup = buf[5]; //保存寄存器数据 cnt = regGroup >> 4; //显示到液晶上 if (cnt >= 0xA) str[0] = cnt - 0xA + 'A'; else str[0] = cnt + '0'; cnt = regGroup & 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并返回原帧
|