摘要:本文在国家标准GB/T 19582-2008的框架下,讨论Modbus协议在串行链路RS485以及TCP/IP上的实现过程和注意事项。涉及到Modbus帧界定、lwip协议栈移植等关键内容,对于难度较大的读写多个线圈命令,本文给出了关键源代码。
1. 简介
从1979年开始,Modbus作为工业串行链路的事实标准,Modbus使成千上万的自动化设备能够通信。目前,对简单而精致的Modbus结构的支持仍在增长。互联网用户能够使用TCP/IP栈上的保留系统端口502访问Modbus。
Modbus通信可以使用串行链路和以太网上的TCP/IP这两种方式实现。本文使用两种物理层进行Modbus通讯,Modbus在串行链路上的实现是通过RS485接口,在TCP/IP上的实现是通过以太网接口。
- RS485物理层由EIA/TIA-485标准规定,此外还需满足国标GB/T 19582.2-2008的第7部分的要求。
- 以太网物理层由IEEE 802.3标准规定
本文不对Modbus协议层做过多描述,假设读者了解基本的Modbus协议,已经通读过GB/T 19582.1-2008、GB/T 19582.2-2008、GB/T 19582.3-2008文档。
2. Modbus串行链路
Modbus通信可以在串行链路上实现,从数据流的角度来看,其本质是在各种介质上的异步串行传输。Modbus也有自己的帧格式,串行链路的数据链路层的主要工作是将收到的数据打包成帧交给应用层处理并将应用层处理后的数据发送出去。
2.1帧格式
每帧数据均包括地址码、功能码、数据区和检验,如图2-1所示。最大帧数据长度为256字节,整个帧采用CRC16校验。CRC16描述详见国标GB/T 19582.2-2008的附录B。
2.2帧界定
帧与帧间隔时间至少为3.5个字符传输时间,我们根据这个时间来判定是否一帧结束。波特率为1200时,这个时间约为35ms;波特率越大,需要的定时器越精细,为了减轻CPU负担,当波特率大于19200bit/s,这个时间固定为1.75ms。
帧中的字符与字符之间的空闲间隔不得小于1.5个字符传输时间,如果超过该时间,整个帧丢掉。当波特率大于19200bit/s,同样为了减轻CPU负担,这个时间固定为750us。
为了获取一帧数据,软件需要维护两个定时值:帧与帧间隔定时以及一帧当中字符与字符间隔定时。可以使用一个硬件定时器来完成帧与帧间隔定时以及一帧当中字符与字符间隔定时的判定,硬件定时器的定时间隔与波特率有关,波特率越大,定时间隔应越小。以1200bps为例,1ms的定时间隔就足够了,但如果波特率在19200bps以上,就需要更细粒度的定时器,比如100us的定时器。
本文以1200bps为例,给出获取一帧数据的伪代码。
2.2.1 定义数据结构以及变量:
1./*Modbus帧接收结构体*/ 2.#define FARME_END_FALG 0x5A 3.#define START_REC_TIMER 0x5A 4.typedef struct{ 5. uint16_t rec_end_flag; //帧结束标志,为FARME_END_FALG时找到完整帧 6. uint16_t rec_len; //帧长度,包括地址码和校验 7. uint32_t rec_timer_flag; //启动定时器标志,为START_REC_TIMER启动定时器 8. uint32_t rec_timer; //定时器计数值 9. uint8_t *rec_data; //指向接收缓冲区 10.}modbus_rec_struct;
2.2.2 定义数据变量
modbus_rec_struct modbus_rec;
2.2.3初始化数据结构
使用前必须先对数据结构初始化,一般放在串口初始化中。
1./** 2.* @brief 初始化modbus 接收数据结构体 3.* @param p_modbus_rec:指向Modbus接收数据结构变量 4.* @param rec_buf:指向接收缓冲区 5.*/ 6.void init_modbus_data_str(modbus_rec_struct *p_modbus_rec,uint8_t * rec_buf) 7.{ 8. p_modbus_rec->rec_len=0; 9. p_modbus_rec->rec_end_flag=0; 10. p_modbus_rec->rec_timer_flag=0; 11. p_modbus_rec->rec_timer=0; 12. p_modbus_rec->rec_data=rec_buf; 13.}
2.2.4 串口接收中断
在串口接收中断中,一方面要进行数据接收,另一方面要判断一帧中字符与字符间隔不大于1.5个字符传输时间。
1./** 2.* 串口中断服务函数 3.*/ 4.void modbus_irq_handler(void) 5.{ 6. //其它状态判断 7. 8. if(有数据需要接收) 9. { 10. modbus_rec.rec_timer_flag=START_REC_TIMER; 11. if(modbus_rec.rec_timer>1.5个字符超时时间) //1.5个字符超时,丢弃帧 12. { 13. modbus_rec.rec_len=0; 14. } 15. 16. if(接收缓存未越界) 17. { 18. modbus_rec.rec_data[...]=接收数据; 19. } 20. else 21. { 22. //其它处理 23. } 24. modbus_rec.rec_timer=0; 25. } 26.}
2.2.5 定时器中断
一帧是否结束,是在定时器中判断。
1./** 2.* 定时器处理函数,1毫秒调用一次 3.* 在定时器中断中调用,判断帧是否结束.modbus要求帧结束的标志为总线上有3.5个字符传输时间空闲 4.*/ 5.void modbus_handle_timer(void) 6.{ 7. if(modbus_rec.rec_timer_flag==START_REC_TIMER) 8. { 9. modbus_rec.rec_timer++; 10. } 11. else 12. { 13. modbus_rec.rec_timer=0; 14. } 15. 16. if(modbus_rec.rec_timer>超时时间) //找到一帧数据 17. { 18. modbus_rec.rec_timer_flag=0; 19. modbus_rec.rec_timer=0; 20. modbus_rec.rec_end_flag=FARME_END_FALG; 21. 22. //其它处理 23. } 24.}
3. Modbus TCP
Modbus协议在TCP/IP上的实现是在TCP/IP协议层上的应用,它需要一个完整的TCP/IP协议栈做支撑。
Modbus帧由TCP层提供,不需要像串行链路那样自己判断一帧是否结束,所有数据传输由TCP/IP层处理,Modbus帧结构见图3-1所示,最大帧数据长度为260字节。与串行链路相比,Modbus TCP帧多了MBAP报文头,少了地址码和校验字段。 因为TCP本身就是被设计为安全交付型协议,所以校验部分就交给TCP层来处理。Modbus TCP通过IP地址以及端口号来唯一确定一个设备,此外在MBAP报文头中也包含一个协议标识符字段,在获取到数据帧后,需要判断这个字段值是否为0。
其中,MBAP报文头包括下列字段,见表2-1
表2-1 MBAP报文头的字段
字 段 | 长 度 | 描 述 | 客 户 机 | 服 务 器 |
事务处理标识符 | 2字节 | Modbus请求/响应事务处理的识别 | 由客户机设置 | 服务器从接收的请求中重新复制 |
协议标识符 | 2字节 | 0=Modbus | 由客户机设置 | 服务器从接收的请求中重新复制 |
长度 | 2字节 | 随后字节的数量(包括单元标识符) | 由客户机设置 | 由服务器设置(响应) |
单元标识符 | 1字节 | 串行链路或其他总线上连接的远程从站的识别 | 由客户机设置 | 服务器从接收的请求中重新复制 |
3.1 lwIP协议栈
Modbus TCP需要一个完整的TCP/IP协议栈做支撑,目前公司使用TCP/IP协议栈多为lwIP协议。该协议栈专为嵌入式系统而设计,可在资源匮乏的微控制器上实现完整的可裁剪的TCP/IP协议。下面简单介绍一下lwIP移植。
3.1.1编写与编译器相关的头文件
头文件cc.h主要完成协议栈内部使用的数据类型定义,用户需要根据自己使用的编译器和处理器特性来定义好这些数据类型长度;除此之外,cc.h文件还要完成临界代码保护、协议栈调试信息输出相关宏、大小端定义等。
3.1.2编写与硬件的接口函数
lwIP协议栈已经在ethernetif.c文件中给出了硬件接口函数的原型,一共5个函数的框架,包括函数名、函数参数、函数内容等,用户需要完成这5个函数的编写。当然,你也可以按照自己的需求来编写底层硬件接口函数,不必和ethernetif.h文件中给出的函数相同。大多数应用是以这5个函数为基础的,所以我们这里来讨论一下这五个函数。
3.1.2.1 网卡初始化
1. static void low_level_init(struct netif *netif)
主要完成对底层硬件MAC和PHY芯片的初始化工作,此外还需要设置协议栈网络接口管理结构netif中与网卡属性相关的字段,比如网卡MAC地址长度等。
3.1.2.2 数据包发送
1. static err_t low_level_output(struct netif *netif,struct pbuf *p)
将内核数据结构pbuf描述的数据包发送出去。
3.1.2.3 数据包接收
1. static struct pbuf * low_level_input(struct netif *netif)
要将网卡接收的数据封装到内核能识别的pbuf形式。
3.1.2.4 接收数据初步解析
1. static void ethernetif_input(struct netif * netif)
调用数据包接收函数low_level_input从网卡处读取一个数据包,然后解析该数据包类型(ARP或IP包),最后将该数据包递交给相应的上层。对于一般无操作系统应用,该函数可以直接使用。
3.1.2.5 底层初始化
1. err_t ethernetif_init(struct netif * netif)
由协议栈自动调用该函数,主要完成netif结构中某些初始化,并最终调用low_level_init完成对网卡的初始化。对于一般应用,该函数可以直接使用。
3.2 lwIP 应用关键事项
对于Modbus TCP应用,使用lwIP协议栈还需要一些额外注意的点:
- 禁止Nagle算法,以便允许lwIP发送小数据包。如果不禁用,lwIP的默认机制会尽量等到更多的数据,再一起发送,这样不利于控制的实时性。lwIP提供了禁用Nagle算法的函数。
- 使能并更改保活机制。客户端和服务器建立连接后,如果二者长时间进行通讯,服务器会发送探测包,来检测客户端是否还在线,如果这时客户端没有响应服务器的探测包,服务器端会释放相应的资源,以便接受其它客户端连接。但默认情况下,lwIP并没有开启这个功能,需要手动开启。
4. Modbus应用层
无论是Modbus串行链路还是Modbus TCP接收到的一帧数据,都要将协议数据单元(PDU,即图2-1和图3-1的功能码及数据区域部分)从一帧中剥离出来提交给Modbus应用层处理,应用层根据功能码来执行相应的操作。Modbus有着众多功能码,其中某些功能码还具有子码,可以根据具体应用来实现这些功能码的全部或一部分,一般数据访问功能码都是需要实现的。
关于功能码的描述可以参考GB/T 19582.1-2008,下面介绍几个实现起来稍繁琐的功能码,并给出关键源代码。
4.1读线圈
主机或客户端使用该功能码从远程设备中读取1~2000个连续的线圈状态,请求帧中会指定第一个线圈地址和线圈数目。从机或服务器需要将每位一个线圈进行打包,第一个数据字节的LSB包含包含询问中所寻址的输出。其它线圈依次类推,一直到这个字节的高位为止,并在后续字节中按照从低位到高位的顺序排列。如果返回的输出数量不是8的倍数,将用零填充最后数据字节中的剩余位。
如果从机(客户端)设备的每一个线圈都占用1个字节存储,那么要应答读线圈是很简单的,这么做的好处是简化数据处理逻辑,提高响应速度;坏处是占用的RAM会增多。但如果线圈数量比较少,而微控制器的RAM又足够多的话,非常推荐这么处理。
1./** 2.* @brief 将要返回的线圈状态打包,在设备中每个线圈占1位 3.* @param coil_num:线圈数量 4.* @param src_data:指向第一个线圈所在的字节地址,按照字节读取 5.* @param send_buf:指向打包后的线圈存放地址,按照字节存放 6.*/ 7.void pack_coils(uint16_t coil_num,uint8_t *src_data,uint8_t *send_buf) 8.{ 9. uint32_t tmp_data =0; 10. uint32_t data_addr=0; 11. uint32_t send_data_offset=0; 12. uint32_t i; 13. while(coil_num) 14. { 15. if(coil_num>=8) 16. { 17. for(i=0;i<8;i++) 18. { 19. if(src_data[data_addr++]) 20. { 21. tmp_data += 1<<i ; 22. } 23. } 24. send_buf [send_data_offset++]=tmp_data; 25. tmp_data =0; 26. coil_num -= 8; 27. } 28. else 29. { 30. for(i=0;i<coil_num ;i++) 31. { 32. if(src_data[data_addr++]) 33. { 34. tmp_data += 1<<i ; 35. } 36. } 37. send_buf [send_data_offset++]=tmp_data; 38. coil_num =0; 39. } 40. } }
有些时候线圈数量非常多或者微控制器RAM资源紧张的情况下,一个线圈占用一个位存储空间,微控制器内的RAM通常是最小按照字节访问的,这样一字节存储空间可以放8个线圈。如果按照这种位存储,打包程序会变得繁琐,要考虑各各种情况,比如起始地址是否从整字节开始,线圈数量是否大于8个等等。下面给按位存储情况下的打包示例程序。
1./** 2.* @brief 将要返回的线圈状态打包,在设备中每个线圈占1位 3.* @param start_addr:线圈起始地址 4.* @param coil_num:线圈数量 5.* @param src_data:指向第一个线圈所在的字节地址,按照字节读取 6.* @param send_buf:指向打包后的线圈存放地址,按照字节存放 7.*/ 8.void pack_coils(uint16_t start_addr,uint16_t coil_num,uint8_t *src_data, 9. uint8_t *send_buf) 10.{ 11. uint32_t data_addr=0; 12. uint32_t send_data_offset=0; 13. 14. if(start_addr%8==0) //从整字节开始 15. { 16. while(coil_num) 17. { 18. if(coil_num>=8) 19. { 20. send_buf[send_data_offset++]=src_data[data_addr++]; 21. coil_num-=8; 22. } 23. else 24. { 25. send_buf[send_data_offset]=src_data[data_addr]; 26. send_buf[send_data_offset]&=((1<<coil_num)-1); 27. coil_num=0; 28. } 29. } 30. } 31. else //非整字节开始 32. { 33. uint32_t bit_nonint,bit_remainder,read_data,tmp_data; 34. 35. bit_remainder =start_addr%8; //先处理非整字节的位 36. bit_nonint=8-bit_remainder; 37. if(bit_nonint<coil_num) 38. { 39. read_data=src_data[data_addr++]; 40. tmp_data = read_data>>bit_remainder; 41. coil_num-=bit_nonint ; 42. while(coil_num) 43. { 44. if(coil_num>8) 45. { 46. read_data=src_data[data_addr++]; 47. tmp_data+=read_data<<bit_nonint; 48. send_buf[send_data_offset++]=tmp_data; //够1字节则存盘 49. tmp_data=read_data>>bit_remainder; 50. coil_num-=8; 51. } 52. else 53. { 54. read_data=src_data[data_addr++]; 55. if(coil_num>bit_remainder) 56. { 57. tmp_data+=read_data<<bit_nonint; 58. send_buf[send_data_offset++]=tmp_data; 59. tmp_data=read_data>>bit_remainder; 60. tmp_data &=((1UL<<(coil_num-bit_remainder))-1); 61. send_buf[send_data_offset]=tmp_data; 62. } 63. else 64. { 65. tmp_data+=(read_data & ((1UL<<coil_num)-1))<<bit_nonint ; 66. send_buf[send_data_offset++]=tmp_data; 67. } 68. coil_num=0; 69. } 70. } 71. } 72. else 73. { 74. read_data=src_data[data_addr]; 75. tmp_data = read_data>>bit_remainder; 76. tmp_data &=((1UL<<coil_num)-1); 77. send_buf[send_data_offset]=tmp_data; 78. } 79. } 80.}
4.2 读离散量和写多个线圈
读离散量和写多个线圈这两个功能码的实现所遇到的问题跟读线圈类似,从机(服务器)设备都可以用不同的方法存储离散量和线圈,可以选择按字节存储,也可以选择按位存储,两种方法各有优点。如果设备条件允许,建议按照字节存储。由于实现方法类似于读线圈,所以不再给出打包(解包)源码。
5. 总结
本文介绍Modbus 串行链路和Modbus TCP的从机(服务器)设计的关键点,虽然有众多的文献、论文涉及到该内容,但本文不局限于文字,还以源码或伪代码的形式给出参考例程,可以帮助开发者更快的理解Modbus协议,缩短开发周期。