【嵌入式Linux应用开发】6. 温湿度监控系统——多线程与温湿度的获取显示

1. 概述

在前几篇的文章中,我们已经学习了LVGL界面绘制以及paho mqtt的同步客户端和异步客户端的操作,那么本篇就会综合前面的知识,加上Linux系统的多线程以及线程间通信的知识,将LVGL、MQTT、多线程、消息队列这些知识使用起来,形成我们最终的产品。

温湿度监控系统应用开发所有文章

  1. 【嵌入式Linux应用开发】1. 移植LVGL到Linux开发板
  2. 【嵌入式Linux应用开发】2. 初步移植MQTT到Ubuntu和Linux开发板
  3. 【嵌入式Linux应用开发】3. SquareLine Studio与LVGL模拟器
  4. 【嵌入式Linux应用开发】4. 温湿度监控系统——绘制温湿度折线图
  5. 【嵌入式Linux应用开发】5. 温湿度监控系统——学习paho mqtt的基本操作
  6. 【嵌入式Linux应用开发】6. 温湿度监控系统——多线程与温湿度的获取显示
  7. 【嵌入式Linux应用开发】7. 设计温湿度采集MCU子系统
  8. 【嵌入式Linux应用开发】8. 阿里云物联网平台的简单使用

适用开发板

适用于百问网的STM32MP157开发板和IMX6ULL开发板及其对应的屏幕,需要注意的是编译链要对应更改。

2. Linux的多线程编程

Linux的多线程编程如果要深入使用的话,会涉及到很多的知识,在一个庞大的嵌入式产品中,需要开发者对多线程进行精细化设计,来优化代码提高CPU的执行效率,但是在本次的温湿度监控系统中,我们只需要掌握多线程的创建和退出就好。
我们在Ubuntu的终端输入指令man pthread然后按TAB键自动补齐,可以看到很多关于线程的函数:


比如我们对创建线程的api感兴趣,想知道它的信息,就可以在终端输入指令man pthread_create,然后就看到如下信息:

这里会告诉我们要使用这个函数需要包含什么头文件,函数的每个参数是什么意思,返回值有哪些信息等,我们就可以通过这个说明来学习这个函数的使用,然后再去网上参考别人的使用经验,总结成为自己的学习经验。

2.1 创建线程

前面已经通过man指令查看了pthread_create的用法,我们现在直接写代码来学习。首先在前面创建的工作区间新建一个C源文件pthread_1.c

book@100ask:~/workspace$ cd /home/book/workspace
book@100ask:~/workspace$ touch pthread_1.c

然后编辑这个C源文件:

  • 包含线程头文件
#include <pthread.h>
  • 包含C库文件
#include <stdio.h>
  • 创建两个线程的入口函数

线程入口函数的形式从创建线程的API参数就可以确定下来

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);

可以看到是void *(*start_routine)(void*)这样的,所以我们的入口函数这样写:

static void *thread1(void *paramater);
static void *thread2(void *paramater)

在入口函数中我们打印一些信息,如下:

printf("Thread 1 running: %d\n", count++);

为了避免打印太快,我们可以加一个延时函数sleep/usleep,我们同样可以使用man指令来学习这两个函数:

所以我们需要在C源文件中包含头文件:

#include <unistd.h>

我们可以使两个线程不同时间间隔打印:

static void *thread1(void *paramater)
{
int count = 0;
​
while(1)
{
printf("Thread 1 running: %d\n", count++);
sleep(1);
}
}
​
static void *thread2(void *paramater)
{
int count = 0;
​
while(1)
{
printf("Thread 2 running: %d\n", count++);
sleep(2);
}
}
  • 创建线程入口函数写好后,我们就可以去创建线程了,我们在main函数中使用pthread_create创建线程:
    • 定义线程句柄
pthread_t thread1_t; pthread_t thread2_t;
  • 不设置优先级等属性也不传参创建线程
int ret = pthread_create(&thread1_t, NULL, thread1, NULL);
if(ret != 0)
{
	printf("Failed to create thread1.\n");
	return -1;
}

ret = pthread_create(&thread2_t, NULL, thread1, NULL);
if(ret != 0)
{
	printf("Failed to create thread2.\n");
	return -1;
}

所以我们的main函数最终是这样:

int main(char argc, char* argv)
{
    pthread_t thread1_t;
    pthread_t thread2_t;

    int ret = pthread_create(&thread1_t, NULL, thread1, NULL);
    if(ret != 0)
    {
        printf("Failed to create thread1.\n");
        return -1;
    }

    ret = pthread_create(&thread2_t, NULL, thread1, NULL);
    if(ret != 0)
    {
        printf("Failed to create thread2.\n");
        return -1;
    }

    while(1)
    {
        sleep(1);
    }
}

然后使用gcc编译它,需要注意的是编译的时候需要连接线程的库pthread,所以编译的时候要加上-lpthread

gcc -o pthread_1 pthread_1.c -lpthread

这样就得到了可执行输出文件pthread_1,我们./pthread_1执行后的效果:
image-20220704172748831
可以按CTRL+C退出程序。

2.2 退出线程

我们使用man指令学习下如何使用线程退出函数:

man pthread_exit


从描述那里可以看到这个函数可以终止调用该函数的线程,即如果我在thread1里面调用了pthread_exit,那么thread1就会被终止,而其它线程继续运行:

static void *thread1(void *paramater)
{
    int retval;
    int count = 0;

    while(1)
    {
        printf("Thread 1 running: %d\n", count++);
        sleep(1);
        if(count==5) pthread_exit(&retval);
    }
}

static void *thread2(void *paramater)
{
    int retval;
    int count = 0;

    while(1)
    {
        printf("Thread 2 running: %d\n", count++);
        sleep(2);
        if(count==3) pthread_exit(&retval);
    }
}

// 在main函数的主循环中加个打印
int main(char argc, char** argv)
{
    int ret = pthread_create(&thread1_t, NULL, thread1, NULL);
    if(ret != 0)
    {
        printf("Failed to create thread1.\n");
        return -1;
    }

    ret = pthread_create(&thread2_t, NULL, thread2, NULL);
    if(ret != 0)
    {
        printf("Failed to create thread2.\n");
        return -1;
    }

    while(1)
    {
        printf("Main>>>\r\n");
        sleep(1);
    }
}

我们这样修改后重新编译执行看下效果:
image-20220704174520751
可以看到线程1执行5此后就没再打印了,线程2打印了3次后就没打印了。

3. Linux的消息队列

对于消息队列的学习我们还是使用man来查询学习,先使用man msg+TAB键自动补齐,看一下有哪些函数:

3.1 获得一个消息队列


获取一个新的消息需要传入key关键字还要设置一个新建的标志msgflg,如果msgflag设置IPC_CREAT,那么不管key值有没有被其它的消息队列占用,都能成功的获取到消息队列,返回该消息队列的ID,如果该消息队列是已创建的则是打开一个已存在的消息队列;如果msgflag设置为ICP_CREAT | IPC_EXCL,那么如果key已经被其他的队列占用的话,是无法获取到该关键字对应的新的消息队列的,返回错误码-1,例如:

int msg_id = msgget(1234, IPC_PRIVATE | IPC_CREAT); int msg_id1 = msgget(2345, IPC_CREAT | IPC_EXCL);

3.2 发送消息

发送消息队列的API是msgsnd

int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);


可以看到在手册中msgsndmsgrcv是一起解释的,对于发送函数msgsnd,需要传入4个参数:

  • 消息队列的IDmsgid
  • 发送的消息数据指针msgp
  • 发送的消息数据大小msgsz
  • 发送标志msgflg;发送数据指针mgsp它指向的是一个形如:
struct msgbuf{ long mtype; char mtext[1]; };

的结构体,其中mtype必须是一个大于0的值来表示消息类型,这个类型值在后面接收消息的时候可以用到,比如可以让接收方不接收这个类型的消息(搭配msgflg=MSG_EXCEPT使用),也可以让接收方接收到消息队列中第一个类型为msgtyp=mtype的消息,这种情况下就没有队列的先进先出的特性了。

发送的数据大小是msgp中除了mtype的数据大小,而不是整个消息结构体的大小。

发送标志支持如下几种:

  • 0:当消息队列满时,msgsnd将会阻塞,直到消息能写进消息队列
  • IPC_NOWAIT:如果消息队列满了,新的消息将不会被写入队列
  • IPC_NOERROR:若发送的消息大于size字节,则把该消息截断,截断部分将被丢弃,且不通知发送进程

返回值:

  • 0:发送消息成功
  • -1:发送消息失败,错误码在erorr中

使用示例:

struct msgbuf{
    long mtype;
    char value;
};

struct msgbuf qbuf = {1, 12};
int qmsg = msgget(1234, IPC_CREAT | IPC_PRIVATE);
int ret0 = msgsnd(qmsg, &qbuf.mtype, sizeof(qbuf.value), 0);	// 阻塞发送
int ret1 = msgsnd(qmsg, &qbuf.mtype, sizeof(qbuf.value), IPC_NOWAIT);	// 非阻塞发送
int ret2 = msgsnd(qmsg, &qbuf.mtype, sizeof(qbuf.value), IPC_NOERROR);	// 如果超出截断发送

3.3 接收消息

接收消息的API:

ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);

参数解释:

参数名称 参数释义
msqid 消息队列的ID
msgp 存放消息的指针
msgsz 指定接收消息的大小
msgtyp 执行接收消息的类型: 0-读取消息队列中第一个数据; >0:读取消息队列中类型为msgtyp的第一个数据;如果msgflg=MSG_EXCEPT,那么队列中和第一个类型不为msgtyp的数据将会被读取; <0:将会读取队列中第一个等于或者小于msgtyp类型的数据
msgflg 接收消息队列的标志位: IPC_CREAT:如果队列中没有数据或者没有指定类型的数据,则直接返回,不堵塞线程,返回错误码在error中; MSG_COPY:这个标志需要搭配IPC_CREAT使用,将会从指定位置读取消息,如果指定位置处没有消息将会理解返回-1,错误码在error中; MSG_EXCEPT:指定不接收某些msgtyp的消息数据; MSG_NOEORROR:如果队列中的数据个数大于指定接收msgsz,那么就会截断只读取msgsz个数据出来;
返回值 0-成功;-1失败,错误码在error中

使用示例:

struct msgbuf{
    long mtype;
    char value;
};

struct msgbuf sbuf;
int smsg = msgget(1234, IPC_CREAT | IPC_PRIVATE);
int ret = msgrcv(smsg, &sbuf, sizeof(sbuf.value), 1, IPC_NOWAIT);

3.4 销毁消息队列

消息队列创建后是独立于线程的,所以如果退出线程而没有去撤销消息队列的话,那么我们创建的消息队列就会一直存在在后台中,除非我们重启系统,因而我们为了减小这样的影响,可以在退出线程结束程序前将我们创建的队列都销毁掉,使用的接口是:

int msgctl(int msqid, int cmd, struct msqid_ds *buf);

参数解释:

参数名称 参数解释
msgqid 消息队列的ID
cmd 控制这个消息队列的操作指令: IPC_STAT:将指定ID的消息队列的内核信息copy到msqid_ds *buf中; IPC_SET:给指定ID的消息队列写入一些保存在msqid_ds *buf中的信息; IPC_RMID:理解删除指定ID的消息队列; IPC_INFO:将指定ID的消息队列在系统中的信息copy到msqid_ds *buf中; MSG_INFO:返回一些和IPC_INFO类似的信息到buf中; MSG_STAT:返回一些和IPC_STAT类似的信息到buf中;
buf 保存信息的结构体指针,如果
返回值 IPC_STATIPC_SETIPC_RMID下成功的话返回0,在IPC_INFOMSG_INFO下成功的话返回队列在内核或者系统的索引值; 错误的话返回-1,错误码在error中;

使用示例:

int dmsg = msgget(1234, IPC_CREAT | IPC_PRIVATE);
int ret = msgctl(dmsg, `IPC_RMID, NULL);	// 删除队列

4. 显示温湿度

我们在前面已经学习了MQTT的一些基本操作,以及LVGL中表格chart和滑动条slider的一些基本操作,刚才又学习了线程通信的消息队列,现在就可以综合起来实现我们的目标了。

我们想要完成的是将Linux开发板变成一个MQTT客户端,和阿里云服务器建立连接,然后订阅一个主题获得温湿度数据,开发板在接收到了订阅的主题的消息后,处理数据,然后发送消息队列给ui,去设置chart和slider更新显示:
image-20220705174622434
所以我们的开发板其实只需要完成MQTT的订阅任务然后去处理再发送消息给LVGL即可。

4.1 更新chart和slider

我们可以将更新chart和slider数值和显示的函数放到main.c里面去实现:

book@100ask:~$ cd /home/book/workspace/lvgl_demo book@100ask:~/workspace/lvgl_demo$ vim main.c

然后找个地方加入如下的代码:

void set_temp_humi_data(uint16_t value)
{
    uint8_t temp_value = (value>>8)&0xFF;
    uint8_t humi_value = value&0xFF;
    
    lv_slider_set_value(ui_tempSlider, temp_value, LV_ANIM_OFF);
    lv_slider_set_value(ui_humiSlider, humi_value, LV_ANIM_OFF);

    lv_chart_set_next_value(ui_chart, temp, temp_value);
    lv_chart_set_next_value(ui_chart, humi, humi_value);
    lv_chart_refresh(ui_chart);
}

我们对于服务器发送的温湿度格式是:数据=温度*256+湿度,也就是代码中的:

value = (temp_value<<8) + humi_value

而解析就是:

uint8_t temp_value = (value>>8)&0xFF;
uint8_t humi_value = value&0xFF;

这个格式读者可以自定义,只要子系统发送以及监测系统解析的时候是同一套格式即可。

更新LVGL的chart和slider的值其实很简单,调用LVGL对应的API即可,如果不熟悉可以百度和查看LVGL的官方文档。

4.2 建立mqtt客户端以及订阅主题

我们需要将mqtt的源码移植到工程里面(前提是已经按照前面的文章将mqtt安装到了ubuntu和Linux开发板):

book@100ask:~$ cd /home/book/workspace/lvgl_demo book@100ask:~/workspace/lvgl_demo$ mkdir mqtt book@100ask:~/workspace/lvgl_demo$ cd mqtt book@100ask:~/workspace/lvgl_demo/mqtt$ cp -r /home/book/workspace/mqtt/paho.mqtt.c/src ./ book@100ask:~/workspace/lvgl_demo/mqtt$ touch mqtt_iot.h mqtt_iot.c mqtt.mk

这里直接放源码, 登录服务器的链接地址、用户名这些省略,读者自己去阿里云物联网平台建立设备然后填写信息:

// mqtt_iot.h
#ifndef __MQTT_IOT_H__
#define __MQTT_IOT_H__

#include <pthread.h>
#include <semaphore.h>
#include <sys/msg.h>

typedef enum{
    DisconThread,
    PubThread
}Mqtt_Thread;

typedef struct  
{
	long mtype;       /* message type, must be > 0 */
	unsigned int value;    /* message data */
}msgbuf;

int mqtt_disconnect(void);
int mqtt_iot(void);

#endif /* __MQTT_IOT_H__ */

// mqtt_iot.c
#include "mqtt_iot.h"
#include "src/MQTTClient.h"  //需要在系统中提前安装好MQTT,可以参考

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>

#define ADDRESS     "tcp://${your mqtt server url}:1883" //根据 MQTT 实际主机地址调整
#define CLIENTID    "${your client id}"
#define USERNAME    "${your username}"
#define PASSWORD    "${your password}"
#define QOS 0
#define TIMEOUT 	10000L
#define SUB_TOPIC 	"${your subscribe topic}" 

extern void set_temp_humi_data(unsigned short value);

MQTTClient client;  //定义一个MQTT客户端client
MQTTClient_connectOptions conn_opts = MQTTClient_connectOptions_initializer;

//传递给MQTTClient_setCallbacks的回调函数 消息到达后,调用此回调函数 
int msgarrvd(void *context, char *topicName, int topicLen, MQTTClient_message *message)
{
	printf("Message arrived\n");
	printf(" topic: %s\n", topicName);
	printf(" message: %.*s\n", message->payloadlen, (char*)message->payload);

	unsigned short value = 0;
	unsigned short len = message->payloadlen;
	char *buf = (char*)message->payload;
	for(unsigned short i=0; i<len; i++)
	{
		if(buf[i] == '\0')	break;
		if(buf[i]<='9' && buf[i]>='0')
			value = value*10 + buf[i] - '0';
	}
    
	set_temp_humi_data(value);	// 调用我们封装的LVGL更新函数

	MQTTClient_freeMessage(&message);	// 释放消息
	MQTTClient_free(topicName);	// 释放主题名
	return 1; 
}

//传递给MQTTClient_setCallbacks的回调函数 连接异常断开后调用此回调函数 
void connlost(void *context, char *cause)
{
	printf("\nConnection lost\n");
	printf(" cause: %s\n", cause);
}

// 封装主动断开连接服务器的函数
int mqtt_disconnect(void)
{
    int rc = EXIT_SUCCESS;

	if ((rc = MQTTClient_disconnect(client, 10000)) != MQTTCLIENT_SUCCESS)  //断开和服务器的连接 
	{
		printf("Failed to disconnect, return code %d\n", rc);
		rc = EXIT_FAILURE;
	}
	else
	{
		printf("MQTT disconnect success\n");
		MQTTClient_destroy(&client);
	}

	return rc;
}

// mqtt建立客户端、连接服务器、订阅主题的封装入口函数
int mqtt_iot(void)
{
    int rc = EXIT_SUCCESS;
	if ((rc = MQTTClient_create(&client, ADDRESS, CLIENTID,
					MQTTCLIENT_PERSISTENCE_NONE, NULL)) != MQTTCLIENT_SUCCESS)
	{
		printf("Failed to create client, return code %d\n", rc);
		goto exit;
	}
	//设置回调函数,
	if ((rc = MQTTClient_setCallbacks(client, NULL, connlost, msgarrvd,
					NULL)) != MQTTCLIENT_SUCCESS)
	{
		printf("Failed to set callbacks, return code %d\n", rc);
		goto destroy_exit;
	}
    conn_opts.username = USERNAME;
    conn_opts.password = PASSWORD;
	conn_opts.keepAliveInterval = 60;
	conn_opts.cleansession = 1;
	//连接服务器
	if ((rc = MQTTClient_connect(client, &conn_opts)) != MQTTCLIENT_SUCCESS)
	{
		printf("Failed to connect, return code %d\n", rc);
		goto destroy_exit;
	}

    //订阅主题 
	if ((rc = MQTTClient_subscribe(client, SUB_TOPIC, QOS)) != MQTTCLIENT_SUCCESS)
	{
		printf("Failed to subscribe, return code %d\n", rc);
        goto destroy_exit;
	}

	printf("MQTT connect success, press 'Q' or 'q' to disconnect mqtt server\n");
    return 0;

destroy_exit:
	MQTTClient_destroy(&client); //释放客户端的资源 
    return -1; 
exit:
    return -1;
}

而mqtt.mk文件就和ui.mk是一样的写法:

MQTT_DIR_NAME ?= mqtt CSRCS += $(wildcard $(LVGL_DIR)/$(MQTT_DIR_NAME)/*.c)

4.3 main函数初始化LVGL和mqtt

我们需要在main函数里面初始化LVGL的参数以及我们绘制的ui,还要初始化mqtt成功建立客户端和服务器的连接后,将主动断开服务器的任务放到一个线程里面去:

#include "ui/ui.h"
#include "mqtt/mqtt_iot.h"
#include "lvgl/lvgl.h"
#include "lv_drivers/display/fbdev.h"
#include "lv_drivers/indev/evdev.h"
#include <unistd.h>
#include <pthread.h>
#include <time.h>
#include <sys/time.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

// 断开和mqtt服务器连接的线程入口函数
static void *mqtt_disconnect_t(void* argv)
{
    while(1)
    {
        char ch;
        ch = getchar();
        if(ch=='Q' || ch=='q')
        {
            printf("Try to exit mqtt task\n");
            if(mqtt_disconnect() == EXIT_SUCCESS)   break;
           
        }
    }
    isConnected = false;
    pthread_exit(&discon_t);	// 退出线程
    return NULL;
}

// main函数
int main(void)
{
    /*LittlevGL init*/
    lv_init();

    /*Linux frame buffer device init*/
    fbdev_init();

    /*A small buffer for LittlevGL to draw the screen's content*/
    static lv_color_t buf[DISP_BUF_SIZE];

    /*Initialize a descriptor for the buffer*/
    static lv_disp_draw_buf_t disp_buf;
    lv_disp_draw_buf_init(&disp_buf, buf, NULL, DISP_BUF_SIZE);

    /*Initialize and register a display driver*/
    static lv_disp_drv_t disp_drv;
    lv_disp_drv_init(&disp_drv);
    disp_drv.draw_buf   = &disp_buf;
    disp_drv.flush_cb   = fbdev_flush;
    disp_drv.hor_res    = 1024;
    disp_drv.ver_res    = 600;
    lv_disp_drv_register(&disp_drv);

    evdev_init();
    static lv_indev_drv_t indev_drv_1;
    lv_indev_drv_init(&indev_drv_1); /*Basic initialization*/
    indev_drv_1.type = LV_INDEV_TYPE_POINTER;

    /*This function will be called periodically (by the library) to get the mouse position and state*/
    indev_drv_1.read_cb = evdev_read;
    lv_indev_t *mouse_indev = lv_indev_drv_register(&indev_drv_1);
	
    // 调用我们绘制的ui初始化函数
    ui_init();
	
    // 成功建立客户端和服务器的连接且订阅主题后才创建断开连接的线程
    if(mqtt_iot() == 0)
    {
        isConnected = true;
        pthread_create(&discon_t, 0, mqtt_disconnect_t, NULL);
    }

    /*Handle LitlevGL tasks (tickless mode)*/
    while(1) 
    {
        lv_timer_handler();
        usleep(5000);
    }

    return 0;
}

4.4 编译运行

因为我们添加了mqtt的代码以及对应的.mk,所以需要将mqtt.mk放到工程目录下的Makefile中:

include $(LVGL_DIR)/mqtt/mqtt.mk

然后执行make编译,并且将编译出来的可执行文件拷贝到挂载目录:

book@100ask:~/workspace/lvgl_demo$ make -j4
book@100ask:~/workspace/lvgl_demo$ cp demo ~/nfs_rootfs/

最后去开发板上将其拷贝出来执行:

[root@100ask:~]# mount -t nfs -o nolock,vers=3 192.168.50.12:/home/book/nfs_rootfs /mnt [root@100ask:~]# cp /mnt/demo ./ [root@100ask:~]# ./demo

这样界面运行起来了,且也能看到设置的mqtt打印信息:
image-20220705182333806

4.5 阿里云模拟下发消息验证

开发板已经将程序运行起来了,我们就去阿里云下发模拟消息看一下:


image-20220705182456527
image-20220705182516513
然后看下开发板的屏幕以及终端:
image-20220705182554348
可以看到接收到了消息,屏幕就不拍照了,实际上屏幕的滑动条数值也变了,表格也出来了点。

至此,一个温湿度监控系统就完成了,下一步是去搞一个探测温湿度的子系统,用单片机+RT-Thread+MQTT+DHT11来完成。

5. 总结

可以看到我们最终没有用到消息队列,这是因为当前的系统功能还很简单,不需要这个操作,但是读者可以在此基础上进行扩展,使这个系统不仅能订阅子系统的消息,还能发送消息给子系统来完成某个控制,这时候可以看下使用消息队列是否会更科学。

image
makefile改成这样