目录
LibmodbusCH1SwitchClientTask编辑编辑
LibmodbusCH2TempHumiClientTask
前言
看这篇博客之前,可以先去看看我前几个博客,了解相关知识点:
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 请求,以便让从机超时退出并进入新一轮的等待:
至此代码就写完了,有不懂的欢迎评论区讨论,希望大家点赞支持一下,感谢感谢,后续可能会更新程序升级的内容。