低成本Modbus传感器的实现(传感器设计+上位机访问多个传感器+源码讲解)

目录

前言

传感器硬件资源

1.开关量模块

2.环境检测模块

3.温湿度变送器模块

UART编程

1.使用 STM32CubeMX 进行配置

① 配置 UART1

② 配置 RS485 方向引脚

2.封装 UART

3.上机实验

传感器设计

1.设计思路

2.三款传感器功能及所用引脚

3.点表设计

① 开关量模块(SWITCH)

② 环境监测模块(ENV_MONITOR)

③ 温湿度模块(TEMP_HUMI)

4.开关量传感器程序设计

5.环境监测传感器程序设计

6.温湿度传感器程序设计

上位机访问多个传感器

Server程序

Client程序

LibmodbusCH1SwitchClientTask​编辑​编辑

LibmodbusCH1ENVClientTask

LibmodbusCH2TempHumiClientTask

上位机访问传感器

程序改进

1.添加 UART 错误恢复代码

2.调整 libmodbus 的超时时间

3.主控发出 libmodbus 请求的间隔加大


前言

看这篇博客之前,可以先去看看我前几个博客,了解相关知识点:
1.UART开发基础
2.USB设备编程(一)
3.USB设备编程(二)
4.Modbus通讯协议
5.libmodbus编程应用

        本文最终效果是:基于Modbus协议,借助 Modbus Poll(主站设备)软件 实现上位机访问多个传感器。主要学习点表的设计;上位机、主控和传感器模块通过Modbus协议进行通信;传感器和主控代码编写;希望本篇博客能为你提供 Modbus 协议相关项目的设计思路。


传感器硬件资源

        本篇博客设计了三个传感器开发板,用的芯片是 STM32F030 芯片,每个板子的硬件资源稍有不同,目的是使用不同的Modbus寄存器,主要是学习如何将Modbus寄存器与硬件资源对应起来,简单来说只是读写各个传感器的值而已。

        (传感器模块你当然可以自己设计,根据不同的场景需求设计对应的传感器,以下的传感器相关设计只供参考,本文重点不在于多复杂的传感器设计,而是作为项目学习)

1.开关量模块

        传感器设计:


补充:根据Modbus协议相关寄存器的定义:
        DI——离散输入状态
        DO——线圈状态
        AI——输入寄存器
        AO——保持寄存器


对于开关量模块:
        需要 5 个 DO 寄存器(1位可读可写),对应 3 个LED灯和 2 个继电器
        需要 3 个 DI 寄存器(1位只读),对应 3 个按键

2.环境检测模块

        传感器设计:

对于开关量模块:
        需要 5 个 DO 寄存器(1位可读可写),对应 3 个LED灯和 2 个有源蜂鸣器
        需要 2 个 AI 寄存器(16位只读),对应 1 个光敏电阻和 1 个可调电阻
 

3.温湿度变送器模块

        传感器设计:

 对于温湿度变送器模块:
        需要 5 个 DO 寄存器(1位可读可写),对应 3 个LED灯和 2 个有源蜂鸣器
        需要 2 个 AI 寄存器(16位只读),对应 1 个温湿度传感器(2 个AI寄存器分别保存温度和湿度)


UART编程

1.使用 STM32CubeMX 进行配置

        RS4385接口原理图如下:

        需要在STM32CubeMX里配置UART1,并且配置PA8为输出引脚(等会会讲为什么)。

① 配置 UART1

        先使能 UART1:

        然后使能中断:

        在前面STM32H5的UART程序里使用了DMA,本节故意不使用DMA而使用纯中断来实现UART,多学一种编程方法。

② 配置 RS485 方向引脚

        我使用的STM32H5主控板上使用的RS485转换芯片是MAX13487EESA,它会自动切换发送、接收方向,无需程序进行方向的控制。使用STM32F030制作的“廉价传感器”里,使用的RS485转换芯片是SIT3088ETK,它需要使用一个GPIO来控制方向,如下图所示:

        上图中,RS485_CTRL使用的引脚是PA8,所以还需要把它配置为输出引脚,输出低电平(让SIT3088ETK默认为接收状态)。如下配置:

2.封装 UART

        UART的封装参考主控 H5 芯片的代码:

        分别定义了各个串口的结构体,可以看到对于不同的串口,需要实现不同的函数,但是串口2和串口4的代码都是类似的,我们可以改造他,让不同的串口可以使用同一套函数,实现更好的封装。 

        先定义出这样的结构体(后面会加上私有数据):

struct UART_Device g_uart1_dev = {"uart1", stm32_uart_init, stm32_uart_send, stm32_uart_recv, stm32_uart_flush};

        我们并没有像之前那样,局限于用哪个函数,这些带stm32前缀的都是串口的通用函数

问:怎样分辨是哪个串口?

        在 UART_Device 结构体中添加一个新的成员,它用来表示私有数据,我们就通过这个私有数据来分辨是哪个串口。

UART_Device 结构体:

struct UART_Device {
    char *name;
	int (*Init)( struct UART_Device *pDev, int baud, char parity, int data_bit, int stop_bit);
	int (*Send)( struct UART_Device *pDev, uint8_t *datas, uint32_t len, int timeout);
	int (*RecvByte)( struct UART_Device *pDev, uint8_t *data, int timeout);
	int (*Flush)(struct UART_Device *pDev);
    void *priv_data;//私有数据
};

问:这个私有数据要怎么使用?

        话不多说,我们直接上代码,先实现初始化、发送、接收和Flush的通用函数。

stm32_uart_init/send/recv/flush 函数:

/*先定义出私有数据的结构体*/
struct UART_Data {
    /*要分辨是哪个串口在使用通用函数,私有数据里必定要有串口句柄*/
    UART_HandleTypeDef *huart;

    /*对于其他串口,485芯片可能不需要手动控制,所以也需要根据私有数据来判断*/
    GPIO_TypeDef* GPIOx_485;
    uint16_t GPIO_Pin_485;

    /*串口的收发需要用到队列、信号量和缓冲区*/
    QueueHandle_t xRxQueue;
    SemaphoreHandle_t xTxSem;
    uint8_t rxdata;
};

/*给串口1的成员赋值,队列信号量和缓冲区都是临时生成的,所以不需要提前赋值*/
static struct UART_Data g_uart1_data = {
    &huart1,
    GPIOA,
    GPIO_PIN_8,
};

/*将私有数据结构体传给UART_Device*/
struct UART_Device g_uart1_dev = {"uart1", stm32_uart_init, stm32_uart_send, stm32_uart_recv, stm32_uart_flush, &g_uart1_data};


static int stm32_uart_init(struct UART_Device *pDev, int baud, char parity, int data_bit, int stop_bit)
{
    /*
     * 关键操作!
     * 通过传进来的UART_Device结构体里的私有数据来分辨是哪个串口
     */
    struct UART_Data * uart_data = pDev->priv_data;
    
	if (!uart_data->xRxQueue)
	{
		uart_data->xRxQueue = xQueueCreate(200, 1);
		uart_data->xTxSem   = xSemaphoreCreateBinary();

        /* 配置RS485转换芯片的方向引脚,让它输出0表示接收 */
        HAL_GPIO_WritePin(uart_data->GPIOx_485, uart_data->GPIO_Pin_485, GPIO_PIN_RESET);
		
		HAL_UART_Receive_IT(uart_data->huart, &uart_data->rxdata, 1);
	}
	return 0;
}

static int stm32_uart_send(struct UART_Device *pDev, uint8_t *datas, uint32_t len, int timeout)
{
    struct UART_Data * uart_data = pDev->priv_data;
    
    /* 配置RS485转换芯片的方向引脚,让它输出1表示发送 */
    HAL_GPIO_WritePin(uart_data->GPIOx_485, uart_data->GPIO_Pin_485, GPIO_PIN_SET);
    
	HAL_UART_Transmit_IT(uart_data->huart, datas, len);
	
	/* 等待1个信号量(为何不用mutex? 因为在中断里Give mutex会出错) */
	if (pdTRUE == xSemaphoreTake(uart_data->xTxSem, timeout))
	{
        HAL_GPIO_WritePin(uart_data->GPIOx_485, uart_data->GPIO_Pin_485, GPIO_PIN_RESET);
		return 0;
	}
	else
	{
        HAL_GPIO_WritePin(uart_data->GPIOx_485, uart_data->GPIO_Pin_485, GPIO_PIN_RESET);
		return -1;
	}
}

static int stm32_uart_recv(struct UART_Device *pDev, uint8_t *pData, int timeout)
{
    struct UART_Data * uart_data = pDev->priv_data;
	if (pdPASS == xQueueReceive(uart_data->xRxQueue, pData, timeout))
		return 0;
	else
		return -1;
}


static int stm32_uart_flush(struct UART_Device *pDev)
{
    struct UART_Data * uart_data = pDev->priv_data;
	int cnt = 0;
	uint8_t data;
	while (1)
	{
		if (pdPASS != xQueueReceive(uart_data->xRxQueue, &data, 0))
			break;
		cnt++;
	}
	return cnt;
}

        获得串口的私有数据后,就可以用指针的方式操作私有数据里的成员,极大程度删减了全局变量,这就是面向对象的编程思想。

再来看看发送和接收的回调函数:

void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
	if (huart == &huart1)
	{
        struct UART_Data * uart_data = g_uart1_dev.priv_data;        
		xSemaphoreGiveFromISR(uart_data->xTxSem, NULL);
        HAL_GPIO_WritePin(uart_data->GPIOx_485, uart_data->GPIO_Pin_485, GPIO_PIN_RESET);
	}
}

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
	if (huart == &huart1)
	{
        struct UART_Data * uart_data = g_uart1_dev.priv_data;        
		xQueueSendFromISR(uart_data->xRxQueue, (const void *)&uart_data->rxdata, NULL);
        HAL_UART_Receive_IT(uart_data->huart, &uart_data->rxdata, 1);
	}	
}

        这里就无法传入 UART_Device 结构体的句柄了,好在是在同一个文件里,直接获得私有数据即可。

3.上机实验

        要测试 STM32F030 的串口,只需要把它的 485 接口连接到 PC 去就可以了,但是我们没有 PC 上使用的“USB 转 485”模块,所以使用 STM32H5 来实现一个“USB 转 485 模块”:
① 它从 USB 串口读到数据,再从 485 接口发送出去;
② 它从 485 接口读到数据,再从 USB 串口发送给 PC。

硬件连接:

        在 H5 的代码里创建两个任务,任务一接收PC通过USB串口发来的数据,通过485转发给F030;任务二接收F030通过板载串口发来的数据,通过USB串口转发给PC。这里 H5 的板子只是作为一个中转站的作用,这里就不做现象演示了。


传感器设计

1.设计思路

        上位机(PC 软件)或中控(STM32H5)通过 modbus 协议访问 STM32F030 传感器时,读写的是 STM32F030 分配出来的 4 个类型的缓冲区。这里需要解决 2 个问题:
① 这 4 个类型的缓冲区起始地址、大小分别是多少?这根据传感器的功能来设置。比如有 2 个按键,那么就可以分配 2 个“只读的位寄存器”(DI)。
② 这些寄存器的值,如何跟硬件对应?比如上位机读 DI 寄存器时,谁提供这些值?传感器的程序应该读取按键值,填充 DI 寄存器。

2.三款传感器功能及所用引脚

        这 3 款传感器控制外设所用的引脚,列表如下:


3.点表设计

        所谓点表,就是一个 modbus 设备,它的地址是什么?它里面 4 类寄存器的地址、功能是什么。
        在查看点表时,经常碰到“遥测、遥信、遥控、遥调”的概念。它们实质上就是前面讲解 modbus 时引入的“AI、DI、DO、AO”。这些概念起源于电力系统。
        AI、DI、DO、AO 都是英文名称的首字母缩写,A 的英文全称 Analog (模拟量)、D 的英文全称 Digital (数字量) 、I 的英文全称 Input (输入)、O 的英文全称 Output (输出)。因此, AI 表示的是模拟信号输出,AO 是模拟信号输入,DI 是数字信号输入,DO 是数字信号输出。
        原文链接:https://blog.youkuaiyun.com/LuohenYJ/article/details/106027626

        在阅读点表时,还会碰到下表中的“PLC/组态地址”,或者表中的简称“0x、1x、4x、3x”,它们的本质都是用来分辨“AI、AO、DI、DO”四类寄存器:

        点表的设计,是完全由开发人员自行定义的。 

① 开关量模块(SWITCH)

        寄存器说明:

② 环境监测模块(ENV_MONITOR)

        寄存器说明:

③ 温湿度模块(TEMP_HUMI)

        寄存器说明:

4.开关量传感器程序设计

        继电器原理图如下:

        继电器对外的信号有 3 个:
① COM:公共端,通常是中间的触点,与常开或常闭触点相连
② NC(Normally Closed):常闭接口,继电器吸合前与 COM 连接,吸合后悬空
③ NO(Normally Open):常开接口,继电器吸合前悬空,吸合后与 COM 连接
        开路即通路、断路,闭合指的是开关闭合,也就是说,在没有任何上电之类的动作时,NC 和 COM 端相当于已经连通。

代码设计:

(注意:三款传感器模块可以共用一套代码,只需要定义一些宏,在和板子对应的代码部分做一些宏判断,烧写前注释掉其他模块的宏定义即可,这里我只是提供一些思路,大家有更好的做法也可以)

        例如我要烧写温湿度传感器的代码,就把其他两个宏注释掉,这样其他两个模块相关的定义和代码就不会起作用。 

        在 freertos.c 的默认任务里,传感器作为从机,不断接收中控/上位机发来的 Modbus 报文,这在我之前关于libmodbus库的博客里有讲,这里主要讲传感器相关的代码要怎么写。

        在调用 modbus_receive 函数接收到报文后,在调用 reply 函数回复主机之前,需要读取硬件资源的数据并且更新对应的寄存器,这样上位机才能实时监测传感器数据的变化。以开关量传感器为例,剩下两个传感器也是类似的:

#ifdef USE_SWITCH_SENSOR
        /* key1 */
        /*读按键电平*/
        val = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_3);
        if (val == GPIO_PIN_RESET)
        {
            /*
             * 更新对应寄存器
             * 按键对应 DI 寄存器,写tab_input_bits数组
             * key1查看点表,寄存器地址为0,就写数组的第0个元素为1(表示按下)
             */
            mb_mapping->tab_input_bits[0] = 1;
        }
        else
        {
            mb_mapping->tab_input_bits[0] = 0;
        }

        /* key2 */
        ...
        /* key3 */
        ...
#endif

        在调用 reply 函数后,如果主机要修改某个寄存器的值,这个函数会自动帮我们修改,比如主机想要控制从机的 led 等等,所以调用这个函数后我们还要自己判断对应的寄存器的值是否改变。 

#ifdef USE_SWITCH_SENSOR
        /* switch1 */
        /*继电器、LED、蜂鸣器都对应的是DO寄存器,即可读可写,对应tab_bits数组*/
		if (mb_mapping->tab_bits[0])
            HAL_GPIO_WritePin(GPIOB, GPIO_PIN_5, GPIO_PIN_SET);
		else
			HAL_GPIO_WritePin(GPIOB, GPIO_PIN_5, GPIO_PIN_RESET);

        /* switch2 */
		if (mb_mapping->tab_bits[1])
            HAL_GPIO_WritePin(GPIOB, GPIO_PIN_4, GPIO_PIN_SET);
		else
			HAL_GPIO_WritePin(GPIOB, GPIO_PIN_4, GPIO_PIN_RESET);
        
        /*LED1*/
		if (mb_mapping->tab_bits[2])
            HAL_GPIO_WritePin(GPIOB, GPIO_PIN_11, GPIO_PIN_RESET);
		else
			HAL_GPIO_WritePin(GPIOB, GPIO_PIN_11, GPIO_PIN_SET);

        /*LED2和3省略*/
#endif

        和点表提前设计好的寄存器地址对应起来就好了,并不难。剩下两个传感器也是操控LED灯和蜂鸣器,后面就不再写了,重点是看点表

5.环境监测传感器程序设计

硬件电路:

        光敏电路如下,光照越强,U6 阻值越低,OPTO_ADC 电压值就越低:

        可调电阻器如下,R33 阻值越大,RES_ADC 电压值越小:

配置 GPIO 和 ADC:

        先在STM32CubmeMX里配置GPIO和ADC引脚,使能“Discontinuous Conversion Mode”

读取 ADC 的关键代码:

// 1. 检验
HAL_ADCEx_Calibration_Start(&hadc);

// 启动、读2次数值
for (int i = 0; i < 2; i++)
{
    /*每次读通道前都要启动ADC*/
    HAL_ADC_Start(&hadc);
    if (HAL_OK == HAL_ADC_PollForConversion(&hadc, 100))
    {
        mb_mapping->tab_input_registers[i] = HAL_ADC_GetValue(&hadc);
    }
}

        读到可调电阻和光敏电阻的12位数值后写 AI 寄存器,同样在调用 reply 函数后,判断主机是否修改了寄存器的值,操控对应硬件(和开关量模块一样,只不过把继电器换成蜂鸣器)。

6.温湿度传感器程序设计

硬件电路与操作方法: 

        原理图如下:

        AHT20 操作方法如下: 

        详解如下: 
① 发送测量命令:传感器的 VDD 上电后需等待 5ms, 发送写测量命令 0x70 0xAC 0x33 0x00, 等待 80ms 测量完成;
② 获取温湿度校准数据:在等待 80ms 测量完成后,发送 0x71 读传感器,可获取状态字 Status、温湿度校准数据 SRH[19:0]、ST[19:0]以及校准字 CRC;
③ 根据公式计算温湿度:

计算检验码的函数如下: 

//CRC校验类型: CRC8
//多项式: X8+X5+X4+1
//Poly:0011 0001 0x31
unsigned char Calc_CRC8(unsigned char *message,unsigned char Num)
{
    unsigned char i;
    unsigned char byte;
    unsigned char crc =0xFF;
    for (byte = 0;byte<Num;byte++)
    {
        crc^=(message[byte]);
        for(i=8;i>0;--i)
        {
            if(crc&0x80)
                crc=(crc<<1)^0x31;
            else
                crc=(crc<<1);
        }
    }
    return crc;
}

配置 I2C:

读取温湿度关键代码:

        注意:读取一次温湿度值,耗时至少 80ms。不可能在接收到 modbus 请求后再去读温湿度。而是使用另一个任务不断读取温湿度。

//定义全局变量
static uint32_t g_temp, g_humi;

/*这个任务在调用 reply 函数之前获取温湿度值*/
static void aht20_get_datas(uint16_t *temp, uint16_t *humi)
{
	*temp = g_temp;
	*humi = g_humi;
}

/*freeRTOS创建这个任务*/
void AHT20Task(void *argument)
{
	uint8_t cmd[] = {0xAC, 0x33, 0x00};	
	uint8_t datas[7];	
	uint8_t crc;	
	
	extern I2C_HandleTypeDef hi2c1;
	
	vTaskDelay(10); /* wait for ready */

	while (1)
	{
		if (HAL_OK == HAL_I2C_Master_Transmit(&hi2c1, 0x70, cmd, 3, 100))
		{
			vTaskDelay(100); /* wait for ready */
			if (HAL_OK == HAL_I2C_Master_Receive(&hi2c1, 0x70, datas, 7, 100))
			{
				/* cal crc */
				crc = Calc_CRC8(datas, 6);
				if (crc == datas[6])
				{
					/* ok */
					g_humi = ((uint32_t)datas[1] << 12) | ((uint32_t)datas[2] << 4) | ((uint32_t)datas[3] >> 4);
					g_temp = (((uint32_t)datas[3] & 0x0f) << 16) | ((uint32_t)datas[4] << 8) | ((uint32_t)datas[5]);

                    /*单位转换,最后显示的是整数,单位为0.1%/0.1C*/
					g_humi = g_humi * 100 * 10/ 0x100000; /* 0.1% */
					g_temp = g_temp * 200 * 10/ 0x100000 - 500; /* 0.1C */
				}
			}
		}
        /*延时一会,不用这么频繁*/
		vTaskDelay(20);
	}
}

主机操控从机硬件的代码和另外两个板子一样(5 个DO寄存器)。 


上位机访问多个传感器

连接示意图为:

        H5 控制板使用 USB 线供电,中间的 HUB 也使用 USB 供电,开关量和环境监测模块共用一个串口,后续要加上互斥操作;温湿度模块使用串口4。

        在上位机(PC)的角度,它只看到 H5 主控板一个Modbus设备。上位机怎么去访问接在H5上的其他3个传感器?这里需要进行“映射”:上位机读写H5的某个寄存器,其实是去读写某个传感器。
        本节使用一个固定的映射点表,如下(你当然可以自己设计,无论是点表还是映射表,都是完全由开发人员自行定义的):


编写H5主控程序:
① 任务 1:创建一个 Modbus 设备,分配 modbus_mapping_t,读取 PC 发来的请求并进行回应
② 任务 2:使用 CH1 访问开关量传感器(ID=1),在 modbus_mapping_t 和传感器之间传递数据。
③ 任务 3:使用 CH1 访问环境监测传感器(ID=2),在 modbus_mapping_t 和传感器之间传递数据。
④ 任务 4:使用 CH2 访问温湿度传感器(ID=3),在 modbus_mapping_t 和传感器之间传递数据。 

程序框图如下图所示:

(纠错:DO寄存器应该有16个,第1个(0000H)用来给PC控制H5板子的1个LED灯)

        任务1要不断接收PC发来的请求,PC读的是H5主控板里创建的一个大的寄存器,映射关系如上,分别和挂载的三个传感器模块对应。
        剩下三个任务对应三个传感器,读取传感器的值更新H5主控里大寄存器的对应位置;还要不断判断大寄存器对应位置的值是否改变,以改变硬件的状态(点亮LED,让蜂鸣器响)。

Server程序

我们先来写主控作为从机(server)的代码:

LibmodbusServerTask:


        后面做一些出错后的内存释放操作(一般都不会跳出死循环),LibmodbusServerTask 函数就写完了。


Client程序

        我们编写完了任务1的代码(LibmodbusServerTask),下面来编写任务2/3/4的代码:

LibmodbusCH1SwitchClientTask

LibmodbusCH1ENVClientTask

        和开关量的代码差不多,注意串口的互斥操作即可

LibmodbusCH2TempHumiClientTask

        由于单独使用一个串口,所以没有互斥操作,代码也比较简单,这三个传感器的代码还是比较相似的,相信你一定能看懂。 

上位机访问传感器

打开Modbus Poll软件。打开三个窗口,设置从机地址都为1(因为PC访问的是H5主控,我们在server程序里设置了自己的从机地址是1,至于三个client任务是H5作为主机的视角去访问三个从机),然后查看16个DO寄存器,3个DI寄存器,4个AI寄存器,哪个地址对应哪个传感器看映射表

效果如下:


程序改进

        之前我们给f030的芯片封装了更好的UART函数,在H5上同样可以这样封装,这里不做演示了。

1.添加 UART 错误恢复代码

        无论是主控程序还是传感器程序,使用 UART 进行数据传输是本项目的关键。如果发生了 UART 错误,应该能从错误中恢复。
        在错误中断回调函数里,重新初始化 UART、重新启动数据接收,代码如下:

        在重启中断接收之前要初始化硬件。 

2.调整 libmodbus 的超时时间

如下修改“Middlewares\Third_Party\libmodbus\modbus-private.h”:

#define _RESPONSE_TIMEOUT 10000
#define _BYTE_TIMEOUT 10000

3.主控发出 libmodbus 请求的间隔加大

        获得锁之后,等待一会再发送 Modbus 请求,以便让从机超时退出并进入新一轮的等待:


        至此代码就写完了,有不懂的欢迎评论区讨论,希望大家点赞支持一下,感谢感谢,后续可能会更新程序升级的内容。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

sakabu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值