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. 固定的超时时间
初始化定时器即可简单实现,基本流程是:
- 初始化定时器并开中断,先不运行定时器
- 串口接收完一帧数据的第一个字节,通知定时器运行开始计时,并每次接收到数据时重装载定时值,帧结束之前不会触发中断
- 一旦进入定时器中断,说明帧超时时间到了,认为一帧接收完成,然后关闭定时器,通知主程序处理数据。
这种方式的优缺点:
- 优点:定时器只需要在一帧结束后触发一次中断,额外引入的中断时间短
- 缺点:需要占用一个定时器硬件资源,波特率变化时,重载值需要重新计算
程序略。
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数据位收/发完就会触发中断,而不是在停止位发送完后触发中断,实际使用时可以再多增加一点冗余)
则程序流程跟之前类似:
- 初始化波特率发生器定时器,先关闭定时器中断
- 串口接收完一帧数据的第一个字节,打开定时器中断,并在每次接收到数据时清0超时计数器Timeout
- 每次定时器中断里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://01、02功能码使用时不作区分
case READ_INPUT_COIL:*err = ILLEGAL_FUNCTION;break; //不支持的功能码先返回01错误码
case READ_HOLD_REG://03、04功能码使用时不作区分
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点表了。
程序略。