(STC15)Modbus-RTU 下位机编程

Modbus-RTU下位机的实现主要包括以下几个部分:

  • 串口数据收发
  • 接收帧超时处理
  • 请求命令解析
  • 响应帧数据组装
  • 用户协议数据点表

1、串口发送-循环缓冲区

先从最简单的串口发送数据开始,常见的串口发送程序如下:

void Uart_Send_Byte(uint8_t dat)
{
    
    
	SBUF = dat;
	while(!TI);
	TI = 0;
}

这个串口发送的代码很简单,但缺点也很明显,发送数据太多,波特率较低时,等待时间太长,会影响主程序其他任务的响应。

需要优化一下发送模式,设置一个发送缓冲区,发送的数据放在缓冲区,每次需要发送时启动发送即可,主程序就可以继续执行其他任务,不必低效的死等了。主程序只启动发送,后续的字节由谁来发送?答案是中断。

关键在于怎么启动发送,这里要用到中断的一个特性,在打开串口中断的前提下,发送完了一个字节,MCU会触发进入中断,即TI = 1,这个进入中断的方式是异步的。而启动是主程序“通知”串口中断要开始发送数据了,这是同步的操作,那么就需要主程序主动置位TI,即赋值 TI = 1主动触发进入中断,进行第一个字节的发送,然后第二个字节在第一个字节发送完进入中断后再开始发送,后续以此类推……

程序如下:

//485模式切换有关的宏
sbit UART1_485_DE_nRE = P2^0;	
#define SEND_MODE (bit)1
#define RECV_MODE (bit)0
#define UART1_485_SEND_MODE() {UART1_485_DE_nRE = SEND_MODE;}
#define UART1_485_RECV_MODE() {UART1_485_DE_nRE = RECV_MODE;}

//发送缓冲区数据定义
#define TX_MAX_SIZE 64
uint8_t UART1_TX_Buffer[TX_MAX_SIZE];	//发送缓冲区-循环缓冲区
uint8_t TX_Start = 0;	//启动发送标志
uint8_t TX_Write = 0;	//发送写缓冲索引
uint8_t TX_Read = 0;	//中断读缓冲索引

//发送字节函数
void Uart_Send_Byte(uint8_t dat)
{
    
    
	//写缓冲太快,发送太慢,写溢出,写索引循环了一圈追上了读索引,就等待一会,防止数据覆盖,或者直接加大缓冲区
	while(TX_Write < TX_Read);	
	
	UART1_485_SEND_MODE();	//初始化是接收模式,每次发送切换为发送模式
	
	UART1_TX_Buffer[TX_Write++] = dat;
	if(TX_Write >= TX_MAX_SIZE)
		TX_Write = 0;
	
	if(TX_Start == 0){
    
    	//启动发送
		TX_Start = 1;
		TI = 1;			//主动触发进入中断
	}
}
//串口1中断服务程序
void Uart1_Isr(void) interrupt Vector_Uart1
{
    
    
	if(TI){
    
    
		TI = 0;
		if(TX_Read != TX_Write){
    
    
			SBUF = UART1_TX_Buffer[TX_Read++];
			if(TX_Read >= TX_MAX_SIZE)
				TX_Read = 0;
		} else {
    
    
			TX_Start = 0;	//最后一个字节发送完了,清标志
			UART1_485_RECV_MODE();	//本次发送完切换为接收模式
		}
	}
	if(RI){
    
    
		RI = 0;
	}
}

//重定向putchar,就可以使用printf了
//最好把STDIO.H的putchar注释掉
char putchar(char c)
{
    
    
	Uart_Send_Byte(c);
	return c;
}

//发送字符串,用printf就足够了,这个用处不大
void Uart_Send_String(uint8_t const* str)
{
    
    
	while(*str)
		Uart_Send_Byte(*str++);
}

//发送字节流
void Uart_Send_Stream(uint8_t const* src, uint16_t len)
{
    
    
	while(len--)
		Uart_Send_Byte(*src++);
}

2、串口接收-RTU帧超时界定

由于Modbus RTU模式没有固定的帧开始和结束符,只能以T1.5和T3.5的字符时间来鉴别两个不同的帧,实际传输中,不用太在意T1.5这个时间,参考关于MODBUS RTU的T3.5 、T1.5的时序问题,因为连续的两个字节之间是停止位和起始位,字节和字节是紧密连接在一起的。

除非上位机发送期间转到其他延时比较厉害的进程,再回来接着发送时,超过了T1.5字符时间,但是却不到T3.5字符时间,按照Modbus RTU的定义,新的数据被认为是下一帧,之前的数据要丢弃处理,而新的一帧CRC校验必定不通过,仍然无法进行正常的通信。这样的上位机从设计之初就是有问题的。

因此只考虑T3.5的字符时间来界定不同帧,同时对上述的情况也有一定的包容性。(实际的上位机连续两帧的间隔通常是远远大于T3.5的)

一般用定时器进行帧超时的判断,具体来说分为两种:

  • 固定超时时间
    例如对于波特率>19200bps时,应该使用2个定时的固定值,建议字符间的超时时间t1.5为750us;帧间超时时间为1.75ms
  • 随波特率变化的超时时间
    即严格意义上的1.5个或3.5个字符时间,例如9600波特率,8数据位,1校验位,1停止位,1BYTE传输用时约1.14ms,T1.5为1.7ms,T3.5为4ms。

1. 固定的超时时间

初始化定时器即可简单实现,基本流程是:

  1. 初始化定时器并开中断,先不运行定时器
  2. 串口接收完一帧数据的第一个字节,通知定时器运行开始计时,并每次接收到数据时重装载定时值,帧结束之前不会触发中断
  3. 一旦进入定时器中断,说明帧超时时间到了,认为一帧接收完成,然后关闭定时器,通知主程序处理数据。

这种方式的优缺点:

  • 优点:定时器只需要在一帧结束后触发一次中断,额外引入的中断时间短
  • 缺点:需要占用一个定时器硬件资源,波特率变化时,重载值需要重新计算

程序略。

2. 随波特率变化的超时时间

一般串口的可变波特率是由定时器溢出产生的(MSC-51架构),波特率 = 定时器溢出率 / 4

这个用于波特率发生器的定时器,一般用不到它的中断,这里刚好可以利用起来。

串口的波特率设置好后,意味着使用的定时器也对应的设置好了,定时时间为1/4bit传输的用时,显然只需使用一个计数器,在传输过程中打开定时器中断,即可精确的判断任意长度的字符超时时间。

以任意波特率,8数据位,1校验位,1停止位为例,超时计数器用Timeout表示则:

T1.5时,Timeout>= 4 * 11 * 1.5 + 4 = 70
T3.5 时,Timeout>= 4 * 11 * 3.5 + 4 = 158

(加4是因为,MSC-51的串口收发时,第8bit数据位收/发完就会触发中断,而不是在停止位发送完后触发中断,实际使用时可以再多增加一点冗余)

则程序流程跟之前类似:

  1. 初始化波特率发生器定时器,先关闭定时器中断
  2. 串口接收完一帧数据的第一个字节,打开定时器中断,并在每次接收到数据时清0超时计数器Timeout
  3. 每次定时器中断里Timeout++,并判断 Timeout >= T3.5,当没有新接收数据清零Timeout时,条件成立,说明帧超时时间到了,认为一帧接收完成,然后关闭定时器中断,通知主程序处理数据。

这种方式的优缺点:

  • 优点:波特率自适应,不需要计算波特率变化时不同的超时时间,不需要占用额外的定时器硬件资源
  • 缺点:中断太频繁,会降低CPU效率,使用时中断程序需优化,代码越少越好,尽可能降低中断时间,最好用于波特率较低的情况。

下面给出基于随波特率变化的超时帧界定程序,实测使用STC15L2,11.0592MHz,波特率<=38400时通信完全没问题,更高的波特率没有测试了,因为实际的modbus组网传输通常是长距离多机通信,也不可能用太高的波特率。

程序:

//定时器中断和串口接收允许有关的宏
#define TIMER1_INT_ENABLE() {ET1 = 1;}
#define TIMER1_INT_DISABLE() {ET1 = 0;}
#define UART1_RECV_ENABLE() {REN = 1;}
#define UART1_RECV_DISABLE() {REN = 0;}

//超时时间定义
#define RECV_TIMEOUT_1_5  	70			//T1.5
#define RECV_TIMEOUT_3_5	158			//T3.5
#define RECV_TIMEOUT_1_Sec  (BAUD * 4)	//4倍波特率时间,约1秒,用于测试,
										//例如9600波特率,超时1秒为38400,Timeout的数据类型也需要相应改为uint16_t

//接收缓冲区数据定义
#define RX_MAX_LEN 100
uint8_t Uart1_RX_Buffer[RX_MAX_LEN];	//接收缓冲区
uint8_t Recv_Cnt = 0;		//接收字节个数,也是接收缓冲区索引
uint8_t Timeout = 0;		//超时计数器
uint8_t Recv_OK = 0;		//一帧接收完成标志
uint8_t Unitaddr = 1;		//本机地址

//串口1中断服务程序里,补全接收部分
if(RI){
    
    
	RI = 0;
	//非本机地址数据不接收,总线上其他设备的通信数据不处理
	//广播地址暂时不实现
	if(Recv_Cnt == 0 || *Uart1_RX_Buffer == Unitaddr){
    
    
		Uart1_RX_Buffer[Recv_Cnt++] = SBUF;
		if(Recv_Cnt >= RX_MAX_LEN)
			Recv_Cnt = 0;
		
		if(Recv_Cnt == 1)	//一帧的首字节打开定时器中断
			TIMER1_INT_ENABLE();
	}
	Timeout = 0;		//每次接收到数据,重新进行超时判断
}

//定时器1中断服务
void Timer1_Isr(void) interrupt Vector_Timer1 using 2
{
    
    
    if(++Timeout > RECV_TIMEOUT_T3_5){
    
    
    
        if(Recv_Cnt >= 8){
    
      //常用标准请求帧最少8字节
        
            Recv_OK  = 1;     //主程序处理接收数据
            UART1_RECV_DISABLE();   //先关闭接收
            
        }else Recv_Cnt = 0;	//否则丢弃该帧

        TIMER1_INT_DISABLE(); //超时关闭定时器中断
    }
}

//modbus请求命令接收服务,运行于main主循环中
void Modbus_Recv_Ser()
{
    
    
	if(Recv_OK){
    
    
		Recv_OK = 0;
		//……
		//CRC16校验
		//modbus响应服务
		
		Recv_Cnt = 0;		//最后索引清零
		UART1_RECV_ENABLE();//应答完后才允许再次接收-半双工
	}
}

至此,Modbus-RTU通信与硬件有关的底层驱动基本完成了,还需要增加部分GPIO和串口的初始化即可。

应用层的实现因人而异,下面的程序可以作为参考。

3、请求命令解析服务程序

//响应数据缓冲区,rsp_pdu在这里组装
#define SEND_MAX_SIZE 200
uint8_t mb_rsp_buff[SEND_MAX_LEN];

//常用功能码宏定义
#define READ_COIL          0x01   //读线圈状态       DO 例如继电器、LED
#define READ_INPUT_COIL    0x02   //读输入线圈状态   DI 例如外部开关状态
#define READ_HOLD_REG      0x03   //读保持寄存器值   AO 例如温湿度设置值
#define READ_INPUT_REG     0x04   //读输入寄存器值   AI 例如4-20mA输入 温度测量值
#define WRITE_COIL         0x05   //写单个线圈状态   DO
#define WRITE_HOLD_REG     0x06   //写单个保持寄存器 AO 
#define WRITE_MULTI_COIL   0x0F   //写多个线圈状态   DO
#define WRITE_MULTI_REG    0x10   //写多个保持寄存器 AO

//错误码宏定义
#define RECV_NO_ERROR      0x00   
#define ILLEGAL_FUNCTION   0x01   //非法的功能码
#define ILLEGAL_DATA_ADDR  0x02   //非法起始地址 ADRR超界
#define ILLEGAL_DATA_LEN   0x03   //非法数据长度 ADDR+LEN超界
#define ILLEGAL_DATA_VALUE 0x03   //或写入非法数据值 如温度设定值超界
#define DEVICE_FAILURE     0x04   //设备服务故障


//补全之前的接收请求命令服务
//为了方便了解思路,按照倒叙贴出示例代码

//Modbus接收数据处理服务
void Modbus_Recv_Ser()
{
    
    
	uint8_t crc[2];
	if(Recv_OK){
    
    
		Recv_OK = 0;
		
		CRC16(Uart1_RX_Buffer, Recv_Cnt, crc);
		if(memcmp(Uart1_RX_Buffer+Recv_Cnt-2, crc, 2)==0)
			//到了这里,必定是首字节是从机地址且校验通过
			Modbus_Resp_Ser(Uart1_RX_Buffer, Recv_Cnt, mb_rsp_buff);
			
		Recv_Cnt = 0;		//最后索引清零
		UART1_RECV_ENABLE();//应答完后才允许再次接收-半双工
							//执行到这里响应帧还没有发送完,但是不影响,此时485还是发送模式,上位机也处于监听状态
	}
}
//modbus响应服务
//链路层数据服务
//负责响应帧头,帧尾校验数据组装,错误处理和发送数据
//收发缓冲区用参数传递,应用层和链路层分离
void Modbus_Resp_Ser(uint8_t *recv_buff, uint8_t recv_cnt, uint8_t *send_buff)
{
    
    
	uint8_t send_cnt;
    uint8_t err=RECV_NO_ERROR;
    
    memcpy(send_buff, recv_buff, 2);    //modbus addr + func code
    
    rsp_cnt = Modbus_Req_Func_Match(recv_buff+1, recv_cnt, send_buff+2,&err);
    
   if(rsp_cnt == 0)           //帧长度错误时,rsp_cnt为0,不处理
        return;

    if (err != RECV_NO_ERROR){
    
    
        send_buff[1] += 0x80;
        send_buff[2] = err;
        rsp_cnt = 3;
    }
    else rsp_cnt += 2;         //返回pdu长度不包含地址和功能码
    
    CRC16(send_buff, send_cnt, send_buff+send_cnt);
    Uart_Send_Stream(send_buff, send_cnt+2);
}

//Modbus命令请求功能码匹配
/**
  *	  正常情况下返回响应帧全部payload的字节数,不包括帧头的modbus地址,功能码,和帧尾CRC校验个数
  *   请求帧长度错误时,返回0
  *   非法的请求地址、长度或数据值时和设备服务故障时,err不为0,返回-1
 */
uint8_t Modbus_Req_Func_Match(uint8_t *recv_pdu, uint8_t recv_cnt, uint8_t *send_pdu, uint8_t *err)
{
    
    
    uint8_t rsp_cnt;
    uint8_t func_code;
    
    func_code = *recv_pdu++;

    switch(func_code){
    
    
        case READ_COIL://0102功能码使用时不作区分
        case READ_INPUT_COIL:*err = ILLEGAL_FUNCTION;break;  //不支持的功能码先返回01错误码
        case READ_HOLD_REG://0304功能码使用时不作区分
        case READ_INPUT_REG:rsp_cnt=Read_Mb_Reg_Rsp(recv_pdu, recv_cnt, send_pdu, err);break;
        case WRITE_COIL:
        case WRITE_HOLD_REG:
        case WRITE_MULTI_COIL:
        case WRITE_MULTI_REG:
        default:*err = ILLEGAL_FUNCTION;break;              //不支持的功能码先返回01错误码
    }
    return rsp_cnt;
}

3、响应帧数据组装

例如现在用AD采集8路温度值,AD转换结果存放在一个数组中,那么响应数据的组装就很简单了,一个for循环搞定:

uint16_t ADC_Res[8]={
    
    	//模拟数据
	0x1111,
	0x2222,
	0x3333,
	0x4444,
	0x5555,
	0x6666,
	0x7777,
	0x8888
};
//功能码0x04
uint8_t Read_Input_Reg_Rsp(uint8_t *req_pdu, uint8_t recv_cnt, uint8_t *send_pdu, uint8_t *err)
{
    
    
	uint16_t i,req_addr,req_len,pdu_len;
    uint16_t *pdu_ptr;
    
	if(recv_cnt != 8)           //功能码03、04的请求长度只能为8byte
        return 0;
        
	pdu_ptr = (uint16_t *)req_pdu;
	req_addr = *pdu_ptr++;
	req_len  = *pdu_ptr;
	
    //data_addr -= INPUT_REG_OFFSET; //全局地址点表是1001,转换为0

    if(data_addr > sizeof(ADC_Res)/sizeof(uint16_t)){
    
    	//请求地址限制
        *err = ILLEGAL_DATA_ADDR;
        return -1;
    }
    
    if(data_addr+data_len>sizeof(ADC_Res)/sizeof(uint16_t) ||
        data_len > (SEND_MAX_LEN-5)/2){
    
    	 //请求长度限制
        *err = ILLEGAL_DATA_LEN;
        return -1;
    }
    
	*send_pdu++ = pdu_len = 2 * req_len;
	pdu_ptr = (uint16_t *)send_pdu;
	
    for(i=req_addr; i<req_addr +req_len; i++)
    	*pdu_ptr++ = ADC_Res[i];
    return pdu_len+1;
}

有时modbus点表数据来源于多个不同模块,比如温度,电压等,就需要把分散的数据点组织起来,也可以用一个指针数组管理(需要额外的内存开销),例如:

uint16_t *Modbus_Reg_Table[N]={
    
    
	&ADC_Result,
	   ……
};

上面的两种方式,数组和指针数组,对于modbus点表数据比较少的情况,或者具有相同类型,有序的,有规律的数据,可以高效的组织和遍历数据。

但是这种方式也有一定的局限性,modbus请求的地址必须是连续的,一般从0开始,最多加个偏移,当分散的数据点比较多时,不太方便后期维护和扩展。例如现在要增加一个特殊的扩展需求,在原有的数组数据基础上,要把10000开始的一段地址定义为modbus从机地址,差错信息统计等,20000开始的一段地址又用来定义其他的数据,等等,就很不方便了。

实际的工程往往是多个模块协调工作的,modbus点表的数据来源于其他模块,即使在工程一开始就预先决定好哪些数据需要作为modbus点表,但是随着工程的推进,总是需要维护或增加新的点表。不同的模块数据类型也不尽相同,常见的有bit,uint8_t,uint16_t,uint32_t,float等。这时modbus点表的设计,就是一件很棘手的事情。

很容易想到,可以定义一个结构体作为modbus点表,来容纳所有的数据。但是这样做有两个问题,首先是内存的额外开销,通常各个模块已经有预先定义好的数据,为何不直接拿来用。其次,数据的更新,必须添加到各个模块的内部,这是很麻烦的。另外,有些数据可能是通过接口函数获得,不仅需要存储空间来接受返回值,还需要动态的运行一段程序。

考虑到modbus点表的维护和扩展方便,以及不同类型的数据和modbus请求地址的灵活性,把数据结构变成过程,即函数,用户应用程序只需要与modbus点表的函数交互即可。这里参考了51单片机的MODBUS

//读单个寄存器值,功能码0x03、0x04
uint8_t Read_Mb_Reg_Rsp(uint8_t *req_pdu, uint8_t recv_cnt, uint8_t *send_pdu, uint8_t *err)
{
    
    
    uint16_t req_addr,req_len,pdu_len;
    uint16_t *pdu_ptr;
	
    if(recv_cnt != 8)           //功能码03、04的请求长度只能为8byte
        return 0;

	pdu_ptr = (uint16_t *)req_pdu;
	req_addr = *pdu_ptr++;
	req_len  = *pdu_ptr;
	
	if(req_len == 0 || req_len > 125    //标准请求帧读出的总长度
    || req_len > (SEND_MAX_LEN-5)/2){
    
    	//本地发送限制
		*err = ILLEGAL_DATA_LEN;
		return -1;
	}

	*send_pdu++ = pdu_len = 2 * req_len;
	pdu_ptr = (uint16_t *)send_pdu;
	while(req_len--){
    
    
		*pdu_ptr++ = Read_Modbus_Reg(req_addr++, err);
		if(*err != RECV_NO_ERROR)
			return -1;
	}
    return pdu_len+1;
}

//读modbus寄存器点表,功能码0x03,0x04调用
uint16_t Read_Modbus_Reg(uint16_t req_addr, uint8_t *err)
{
    
    
	uint16_t reg = 0xFFFF;   //预留的modbus地址,给个特定返回值以示区别,也可以为0
	
	switch(req_addr){
    
    
        case 0:reg=Get_PWM_LED_Brightness();break;              //API 
        case 1:reg=PWM.Duty;break;	                //uint8_t
		case 2:reg=ADC.ADC_Value;break;					        //uint16_t
		case 3:reg=*(uint16_t*)&NTC.NTC_Temperature;break;  	//float
		case 4:reg=*((uint16_t*)&NTC.NTC_Temperature+1);break;		   
        case 5:break;
        case 6:reg=*(uint16_t*)&NTC.NTC_Voltage;break;
        case 7:reg=*((uint16_t*)&NTC.NTC_Voltage+1);break;
        case 8:break;                                           //预留
        case 9:break;                                           
        case 10:break;                                           
		case 1001:reg=Modbus.uintaddr;break;                    //modbus从机地址
        
        case 1002:*err = DEVICE_FAILURE;break;                  //模拟读寄存器失败,错误码04
		default:*err=ILLEGAL_DATA_ADDR;break;                   //非法地址
	}
	return reg;
}

//获取PWM LED灯亮度 返回值范围:0~100 单位:%
uint8_t Get_PWM_LED_Brightness()
{
    
    
    return PWM.LED_Brightness;
}

这样我们就有了自己的Modbus对外点表(简易版):

寄存器地址 数据内容 数据格式 数据长度 读写属性 范围 单位
0 PWM灯亮度 UINT16 1 R 0-100 %
1 PWM占空比 UINT16 1 R 0-100 %
2 ADC采集值 UINT16 1 R 0-1023 -
3 NTC温度值 FLOAT 2 R -30-70
5 预留 UINT16 1 R - -
6 NTC电压值 FLOAT 2 R 0-3.3 V
7 预留 UINT16 1 R - -
8 预留 UINT16 1 R - -
9 预留 UINT16 1 R - -
10 预留 UINT16 1 R - -
1001 modbus地址 UINT16 1 R - -

再封装一下代码,使用同样的方式,编写其他功能码即可完成基本的modbus下位机框架。
这样就可以任意的扩展其他应用程序的数据到modbus点表了。
程序略。

猜你喜欢

转载自blog.csdn.net/weixin_42663377/article/details/109705079