内容简介:本文是基于RT-Thread移植较新paho-mqtt软件包(24年移植1.2.0版本)实现串口到mqtt透传开发时,总结当前环境下尚未支持的部分功能以及应用中不符合本人项目开发的性能缺陷,并对其提出优化方案以供借鉴。
概念引入
本章节目的是对后续移植使用时涉及的概念进行初步导入,对于MQTT协议的解释并非重点,若要详细了解mqtt协议操作可参考文档:《MQTT协议中文版》Introduction · MQTT协议中文版
本文面向项目移植使用paho mqtt的开发者,对pahomqtt库代码熟悉有一定要求,可预先熟悉相关源码:https://github.com/RT-Thread-packages/paho-mqtt
mqtt概念解读
MQTT(Message Queuing Telemetry Transport,消息队列遥测传输协议),是一种基于发布/订阅(publish/subscribe)模式的"轻量级"通讯协议,是一个基于服务器(Broker)-客户端(Client)的消息传输协议。
在 MQTT 协议中有三种身份:发布者(Publish)、代理(Broker)(服务器)和订阅者(Subscribe)。其中消息的发布者和订阅者都是客户端,消息代理是服务器,消息发布者可以同时是订阅者,这三者的关系如下图所示:
MQTT的通信核心是服务器(Broker)分发机制,每一个客户端都可进行发布/订阅操作,其消息流向可以参考下图:
结合上图,可以为您得出以下特点:
- MQTT协议客户端(client)之间是消息产生和使用的主体,服务器(Broker)作为消息管理者身份进行转发。
- 消息的发布与订阅无数量限制,上限取决于服务器性能以及应用处理速度。
MQTT协议作为多客户端对多客户端的信息交互协议,为保证消息传输的稳定、准确性,传输协议中引入了消息等级(QOS)的概念:
- QOS0:发送端消息仅发送一次,接收端要么收到一条消息,要么消息丢失
- QOS1:发送端发送消息后等待回复,共两包数据交互,接收端至少收到一条消息
- QOS2:发送端与接收端进行四包数据交互,接收端仅收到一条消息且不会丢失
虽然QOS保证了消息的安全稳定,但是同样也增加了资源的开销,因此安全与性能是呈反比的。
心跳机制(Keep Alive)是mqtt服务端确认客户端存活的方法,MQTT协议中的心跳交互为两包报文,当客户端检测到连接空闲时间超过T时,必须向Broker发送心跳请求PINGREQ,Broker收到心跳请求后返回心跳响应PINGRESP,且Broker应在1.5倍心跳时间后未接收到心跳包请求应主动断开连接,客户端在一定时间内未收到心跳响应也应断开连接。
MQTT协议针对应用增加的辅助扩展功能较多,例如MQTT协议服务器面对多个客户端,为了处理客户端上线以及掉线的问题,MQTT协议中引入保留消息、遗言功能。本文该部分内容相关不大,读者可自行网上了解学习。
RTT应用mqtt库现状
RT-Thread软件包当前并无公认的可商用mqtt库可用,不可避免的会存在需要优化的功能或性能,因此选择合适的库对于提高功能开发效率、保障系统安全尤为重要。
RTT支持的mqtt客户端库较多:pahomqtt、mymqtt、kawaii mqtt(mqttclient)、
RyanMqtt、umqtt,根据各软件包简介,除了kawaii mqtt是作者基于socket API进行单独开发的,其余软件包都是基于pahomqtt的不完全优化,且不支持TLS,并不适合当前开发功能的应用场景。
- pahomqtt:RTT官方团队移植维护的mqtt库,功能支持不完全,资源占用较高,好在用户较多,网上有充足的优化经验可以借鉴学习,并且公司有较多相关开发者使用,在开发中较易得到经验协助。
- mymqtt:大神基于pahomqtt进行开发,接口风格与pahomqtt统一并且比pahomqtt资源占用少,作者自述支持mqtt完整功能,但是其对QOS1、QOS2的实现较为暴力,没有等待发送机制,且网上可借鉴经验较少,风险未知。
- kawaii mqtt(mqttclient):作者基于socket API进行单独开发,框架设计优美,支持多平台,有一定的用户基础,且通过滑动窗口的机制实现了QOS1、QOS2消息的高效率发送,是目前笔者认为实现最为完善的mqtt库,但缺少项目验证的经验。
- RyanMqtt、umqtt:由于网上信息少且不支持TLS,对于当前项目的匹配度较差,因此并未对其进行深入研究。
注:关于QOS1、QOS2的机制以及实现方法可以参考开发笔记:MQTT服务质量(QOS)的深度解读以及QOS2实现方法
在此选用paho mqtt的原因便是其使用人数多,网上经验丰富,RTT官方团队维护的优势。但同样存在占用资源大、传输逻辑缺陷、功能支持不完全的问题。在选取软件包时,为保证项目进度以及尽可能降低试错成本,在此便采用pahomqtt作为开发软件包,并学习吸收众多大佬的解决方案进行优化。
RTT环境paho库目前已经更新两个版本(最新版本V1.1.0),但是其性能以及功能依旧存在很大的优化空间,本文便是以RTT环境下的最新版本的paho mqtt库进行应用以及优化。
RTT下paho库的使用
本段简单介绍移植使用pahomqtt的操作步骤,移植使用方法网上有很多的应用例程,因此本章节粗略总结操作步骤供读者参考。
移植
使用软件包需要在 BSP 目录下使用 menuconfig 命令打开 Env 配置界面,在 RT-Thread online packages → IoT - internet of things
中选择 Paho MQTT 软件包,操作界面如下图所示:
配置项介绍如下:
--- Paho MQTT: Eclipse Paho MQTT C/C++ client for Embedded platforms
MQTT mode (Pipe mode: high performance and depends on DFS) --->#高级功能
[*] Enable MQTT example #开启 MQTT 功能示例
[ ] Enable MQTT test #开启 MQTT 测试例程
[ ] Enable support tls protocol #开启 TLS 安全传输选项
(8) Max pahomqtt subscribe topic handlers #设置 Topic 最大订阅数量
[*] Enable debug log output #开启调试Log输出
version (latest) ---> #选择软件包版本,默认为最新版
选择合适的配置项后,使用 pkgs --update
命令下载软件包并添加到工程中即可。
由于芯片平台的不同且pahomqtt经过一次较大的更新,在有些平台的BSP支持包会出现宏未适配最新pahomqtt库的情况,在此可以通过简单修改,将部分已经替换的宏定义适配,具体问题具体分析。
应用
pahomqtt的核心处理封装程度较高,通过对外接口以及回调的方式便可以实现消息的发送与接收,在使用时只需要填充结构体参数,实现接收处理函数后启动线程便可以实现客户端的基础功能。
客户端结构体配置
mqtt功能配置全部由static MQTTClient
类型的结构体包含,在启动功能之前需要预先对该结构体进行配置。
配置代理服务器(broker)信息
在使用前我们需要获取到要连接代理服务器的:地址、用户名、密码等必要信息,并且需要添加连接ID、心跳时间、清理会话的配置。
/* init condata param by using MQTTPacket_connectData_initializer */
MQTTPacket_connectData condata = MQTTPacket_connectData_initializer;
/* 配置连接参数 */
rt_memcpy(&client.condata, &condata, sizeof(condata));
client.condata.clientID.cstring = "";
client.condata.keepAliveInterval = 60;
client.condata.cleansession = 1;
client.condata.username.cstring = MQTT_USERNAME; //设置账号
client.condata.password.cstring = MQTT_PASSWORD; //设置密码
配置遗言
遗言是客户端在连接时告诉服务器的信息,可以起到客户端下线时通知到其他客户端的作用,需要设置消息等级、推送 Topic、以及断开通知消息等配置。
/* 配置断开通知消息 */
client.condata.willFlag = 1;
client.condata.will.qos = 1;
client.condata.will.retained = 0;
client.condata.will.topicName.cstring = rt_strdup(MQTT_PUBTOPIC); //设置推送主题,需要分配空间存储 topic,以便后面订阅多个 topic
client.condata.will.message.cstring = MQTT_WILLMSG; //设置断开通知消息
设置事件回调
在运行时,可以通过自己实现回调函数在mqtt开始连接、上线、下线过程中执行用户自定义的功能。
/* 设置事件回调函数,回调函数需要自己编写,在例程中为回调函数留了空函数 */
client.connect_callback = mqtt_connect_callback; //设置连接回调函数
client.online_callback = mqtt_online_callback; //设置上线回调函数
client.offline_callback = mqtt_offline_callback; //设置下线回调函数
配置客户端订阅表
MQTT 客户端可以同时订阅多个 Topic, 所以需要维护一个订阅表,在这一步需要为每一个 Topic 的订阅设置参数,主要包括 Topic 名称、该订阅的回调函数以及消息等级。
/* 配置订阅表 */
client.messageHandlers[0].topicFilter = MQTT_SUBTOPIC; //设置第一个订阅的 Topic
client.messageHandlers[0].callback = mqtt_sub_callback;//设置该订阅的回调函数
client.messageHandlers[0].qos = QOS1; //设置该订阅的消息等级
/* set default subscribe event callback */
client.defaultMessageHandler = mqtt_sub_default_callback; //设置一个默认的回调函数,如果有订阅的 Topic 没有设置回调函数,则使用该默认回调函数
启动MQTT客户端
/* 运行 MQTT 客户端 */
paho_mqtt_start(&client);
推送消息
连接服务器成功之后,便可以向指定的 Topic 推送消息。推送消息时需要设置消息内容、Topic、消息等级等配置。
// 向指定 Topic 发送消息信息
int paho_mqtt_publish(MQTTClient *client, enum QoS qos, const char *topic, const char *msg_str);
RTT环境paho库存在的问题以及优化
上行数据高频异常
影响性能以及功能实现,强烈建议修改
现象描述
正常进行mqtt连接后,对发布主题进行长时间高频的稳定性通信测试,数据量无要求,发送间隔控制为10ms内,在不固定的时间会出现上行数据暂停,随后出现溢出断言。
原因
新版paho mqtt采用管道机制(pipe)进行发布消息,由于读取的不及时导致数据消息处理异常。
导致该部分异常的原因是多方面的:
- 管道实现机制保证了先进先出,但读取管道时会一次性读空,导致无法获取原始数据。
- 管道依赖于环形缓冲区(ringbuffer)实现,当积压数据过多会存在覆盖旧数据的情况
- 放入管道的发布消息有严格的格式,包括主题、QOS等级、消息内容,读出管道时通过偏移找到对应数据
- 发布流程中有较多的拷贝过程,难保证执行效率,写入读出无法保证按顺序进行
该部分处理流程可以参考以下流程图:
该逻辑的设计应该保证pipe的写入读取顺序严格,否则mqtt线程解析数据时无法获得真实有效的数据。
解决方案
针对以上问题原因分析,在问题解决中思考了以下几种实测可行的方案:
- 取消环形缓冲区特性,当缓冲区内容为0时才可写入,新数据禁止覆盖旧数据;
- 增加资源互斥控制,写入pipe后等待读取才可以继续写入;
- 通信方式修改为通过邮箱传输数据内容。
在测试时,第一、二解决方法虽然修改较小,但是会影响写入速度,导致缓存名存实亡,虽然能起到保护数据的作用但也严重影响了效率,因此最终的方案采用第三种,避免了拷贝造成的资源浪费,保证了数据不重合。
在进行优化时做出如下修改:
- mqtt配置增加邮箱初始化操作
- 屏蔽管道select,代替为邮箱接收,由阻塞变为轮询,会存在消耗的增加
处理流程图如下:
部分代码对比如下
原接收处理逻辑:
static void paho_mqtt_thread(void *param)
{
...
if (FD_ISSET(c->pub_pipe[0], &readset))
{
MQTTMessage *message;
MQTTString topic = MQTTString_initializer;
//LOG_D("pub_sock FD_ISSET");
len = read(c->pub_pipe[0], c->readbuf, c->readbuf_size);
if (len < sizeof(MQTTMessage))
{
c->readbuf[len] = '\0';
LOG_D("pub_sock recv %d byte: %s", len, c->readbuf);
if (strcmp((const char *)c->readbuf, "DISCONNECT") == 0)
{
goto _mqtt_disconnect_exit;
}
continue;
}
message = (MQTTMessage *)c->readbuf;
message->payload = c->readbuf + sizeof(MQTTMessage);
topic.cstring = (char *)c->readbuf + sizeof(MQTTMessage) + message->payloadlen;
//LOG_D("pub_sock topic:%s, payloadlen:%d", topic.cstring, message->payloadlen);
len = MQTTSerialize_publish(c->buf, c->buf_size, 0, message->qos, message->retained, message->id,
topic, (unsigned char *)message->payload, message->payloadlen);
if (len <= 0)
{
LOG_D("MQTTSerialize_publish len: %d", len);
goto _mqtt_disconnect;
}
if ((rc = sendPacket(c, len)) != PAHO_SUCCESS) // send the subscribe packet
{
LOG_D("MQTTSerialize_publish sendPacket rc: %d", rc);
goto _mqtt_disconnect;
}
if (c->isblocking && c->pub_mutex)
{
rt_mutex_release(c->pub_mutex);
}
} /* pbulish sock handler. */
...
}
修改后的处理逻辑:
static void paho_mqtt_thread(void *param)
{
...
if(rt_mb_recv(mqtt_usr_data->mb.mqtt_data_mb,(rt_ubase_t *)&msg_send_mqtt,2) == RT_EOK)
{
MQTTMessage *message;
MQTTString topic = MQTTString_initializer;
//LOG_D("pub_sock FD_ISSET");
if(msg_send_mqtt == RT_NULL)
{
continue;
}
message = &msg_send_mqtt->topic_msg;
topic.cstring = (char *)msg_send_mqtt->topic_name;
// LOG_D("pub_sock topic:%s, payloadlen:%d", topic.cstring, message->payloadlen);
if(message->qos > 0)
{
static rt_uint16_t id_num = 0;
message->id = id_num ++;
}
len = MQTTSerialize_publish(c->buf, c->buf_size, 0, message->qos, message->retained, message->id,
topic, (unsigned char *)message->payload, message->payloadlen);
if (len <= 0)
{
rt_free(msg_send_mqtt->topic_msg.payload);
rt_free(msg_send_mqtt);
LOG_D("MQTTSerialize_publish len: %d", len);
goto _mqtt_disconnect;
}
if ((rc = sendPacket(c, len)) != PAHO_SUCCESS) // send the subscribe packet
{
rt_free(msg_send_mqtt->topic_msg.payload);
rt_free(msg_send_mqtt);
LOG_D("MQTTSerialize_publish sendPacket rc: %d", rc);
goto _mqtt_disconnect;
}
if (c->isblocking && c->pub_mutex)
{
rt_mutex_release(c->pub_mutex);
}
// 释放邮箱地址指向的缓存
rt_free(msg_send_mqtt->topic_msg.payload);
rt_free(msg_send_mqtt);
} /* mb recv */
...
}
补充:
该部分修改涉及mqtt线程select机制的变动,使得依赖select定时的功能需要重新调整实现方式,由于线程由阻塞变为了轮询机制,只需要调整为get_tick进行对比时间即可,逻辑较原版更加简单,相关代码可以参考:CANET200-RT外贸定制项目代码。
设备对部分数据包无回复(QOS2未支持)
影响功能需求,可以按需修改
现象描述
设备作为客户端与服务器进行QOS2级别的消息通信时,设备接收100包数据之后便无数据交互,也没有相关网络包,为服务器暂停发送。
原因
paho库没有支持对QOS2的功能逻辑,QOS2一次数据包存在4次通信。
对于QoS 2的分发协议,发送者
- 必须给要发送的新应用消息分配一个未使用的报文标识符。
- 发送的PUBLISH报文必须包含报文标识符且报文的QoS等于2。
- 必须将这个PUBLISH报文看作是 未确认的 ,直到从接收者那收到对应的PUBREC报文。
- 收到PUBREC报文后必须发送一个PUBREL报文。PUBREL报文必须包含与原始PUBLISH报文相同的报文标识符。
- 必须将这个PUBREL报文看作是 未确认的 ,直到从接收者那收到对应的PUBCOMP报文。
- 一旦发送了对应的PUBREL报文就不能重发这个PUBLISH报文。
出现该问题的原因是设备未按照第五步执行最后一包回复,导致服务端缓存队列中将该消息一直保留最终缓存占满不再进行数据交互。
解决方案
对于以上问题,需要进行两步操作:
- 增加当前对MQTT通信协议包的处理;
- 即使补全了通信协议的数据包,因为没有QOS2的消息缓存队列,其并不具备QOS2等级消息的安全性,因此应该实现QOS2逻辑的缓存逻辑。
增加协议包处理
实现通信协议包的处理较为简单,mqtt报文格式由三部分组成:固定报头、可变报头、有效载荷。需要补全的协议包PUBCOMP的格式可以通过:《MQTT协议中文版》第三章
paho库本身已经提供了接口可以快速的生成报文,将该部分落实到代码,便是在接收处理函数中增加以下部分:
static int MQTT_cycle(MQTTClient *c)
{
mqtt_data *usr_data = (mqtt_data *)c->user_data;
// read the socket, see what work is due
int packet_type = MQTTPacket_readPacket(c);
int len = 0,
rc = PAHO_SUCCESS;
if (packet_type == -1)
{
rt_kprintf("[usr]pack type err %d.\n",packet_type);
rc = PAHO_FAILURE;
goto exit;
}
switch (packet_type)
{
...
case PUBREL:
{
unsigned short mypacketid;
unsigned char dup, type;
if (MQTTDeserialize_ack(&type, &dup, &mypacketid, c->readbuf, c->readbuf_size) != 1)
rc = PAHO_FAILURE;
else if ((len = MQTTSerialize_ack(c->buf, c->buf_size, PUBCOMP, 0, mypacketid)) <= 0)
rc = PAHO_FAILURE;
else if ((rc = sendPacket(c, len)) != PAHO_SUCCESS) // send the PUBCOMP packet
rc = PAHO_FAILURE; // there was a problem
break;
}
...
}
支持QOS2安全逻辑
以上操作只是实现QOS2的通信协议,保证了通信的正常执行,但是并没有数据安全之实,要保证QOS2有且仅有一次消息的到达,还要实现QOS2的处理逻辑。
下图为MQTT协议文档所给出的QOS2通信处理步骤:
由上图指出的通信过程,有两种方法可以提供给客户端保证可靠性,两种方法特性对比如下:
- 分发消息时机:方法A的消息分发时机比B的时机延后
- 占用空间:方法A需要保存消息内容,占用高;方法B保存标识符,占用低
- 实现逻辑:方法A分发逻辑为先保存,等待收到PUBREL后再分发,若保存了多个数据则需要处理消息顺序后分发;方法B在接收到消息后直接向前分发消息,在接收PUBREL之前屏蔽接收相同报文标识符的PUBLISH。
参考Linux平台paho库QOS2通过以多个队列对消息管理实现数据安全的,其资源占用并不适用于RTT平台逻辑,在RTT平台中使用方法B需要构建一个高效快速的数据结构。
QOS2实现报文标识符保存队列采用数组作为缓存,链表进行操作的方式进行,选取此种方式的目的如下:
- 保证地址连续,报文标识数量可控
- 查找快速,通过数组遍历效率高
- 管理方便,链表进行插入检出操作
以上特点只有当等待确认的标识符数量多的时候才能体现,仅为了尽可能减少QOS2传输时占用的消耗。
操作接口以及代码实现如下:
// QOS2消息链表
#define QOS2_MAX_SAVE_ID 100
// 报文标识符链表缓存最小单位节点
typedef struct qos2_save_id{
rt_slist_t list; // 单链表
unsigned short packetid; // QOS2:保存报文标识符
unsigned short used; // QOS2:是否插入链表中
}save_id_t;
// 报文标识符存储,缓存与链表均在此,将该结构传入函数中进行处理
typedef struct{
save_id_t slist_head; // 链表头,其后续的next在下方数组中查找
save_id_t qos_id[QOS2_MAX_SAVE_ID]; // 存储报文标识符的数组,对应地址放入单向链表中并设置used
}qos2_dispose_t;
int find_qos2id_inlist(qos2_dispose_t *msg,unsigned short id)
{
int i,re = 0;
for(i = 0; i < QOS2_MAX_SAVE_ID ; i++ )
{
if(msg->qos_id[i].packetid == id && msg->qos_id[i].used == 1)
{
re ++;
if(re > 1)
{
msg->qos_id[i].used = 0;
rt_slist_remove(&msg->slist_head.list,&msg->qos_id[i].list);
re = 1;
}
}
}
return re;
}
int set_qos2id_inlist(qos2_dispose_t *msg,unsigned short id)
{
int i = 0;
rt_slist_t *list_p = RT_NULL;
save_id_t *id_list_msg = RT_NULL;
for(i = 0; i < QOS2_MAX_SAVE_ID ;i ++)
{
if(msg->qos_id[i].used == 0) // 还有空余的没有插入链表的元素
{
break;
}
}
if(i >= QOS2_MAX_SAVE_ID) // 没有空余的空间
{
// 遍历找到链表末尾的地址
list_p = &msg->slist_head.list;
while(list_p->next != RT_NULL)
{
list_p = rt_slist_next(list_p);
}
// 弹出链表尾
rt_slist_remove(&msg->slist_head.list,list_p);
// 要插入消息重装到该节点中
id_list_msg = rt_slist_entry(list_p,save_id_t,list);
id_list_msg->packetid = id;
id_list_msg->used = 1;
// 插入链表头
rt_slist_insert(&msg->slist_head.list,&id_list_msg->list);
}
else
{
msg->qos_id[i].packetid = id;
msg->qos_id[i].used = 1; // 一定要在插入之前将其值赋1
rt_slist_insert(&msg->slist_head.list,&msg->qos_id[i].list);
}
return 0;
}
int remove_qos2id_inlist(qos2_dispose_t *msg,unsigned short id)
{
int i,re = 0;
for(i = 0; i < QOS2_MAX_SAVE_ID ; i++ )
{
if(msg->qos_id[i].packetid == id && msg->qos_id[i].used == 1)
{
msg->qos_id[i].used = 0;
rt_slist_remove(&msg->slist_head.list,&msg->qos_id[i].list);
}
}
return re;
}
以上只是实现了QOS2报文标识符存储操作的接口,将其应用到paho库的处理过程可以参考如下接收处理过程的代码:
static int MQTT_cycle(MQTTClient *c)
{
mqtt_data *usr_data = (mqtt_data *)c->user_data;
// read the socket, see what work is due
int packet_type = MQTTPacket_readPacket(c);
int len = 0,
rc = PAHO_SUCCESS;
if (packet_type == -1)
{
rt_kprintf("[usr]pack type err %d.\n",packet_type);
rc = PAHO_FAILURE;
goto exit;
}
switch (packet_type)
{
case CONNACK:
case PUBACK:
case SUBACK:
{
int count = 0, grantedQoS = -1;
unsigned short mypacketid;
if (MQTTDeserialize_suback(&mypacketid, 1, &count, &grantedQoS, c->readbuf, c->readbuf_size) == 1)
rc = grantedQoS; // 0, 1, 2 or 0x80
if (rc != 0x80)
rc = 0;
break;
}
case UNSUBACK:
{
unsigned short mypacketid;
if (MQTTDeserialize_unsuback(&mypacketid, c->readbuf, c->readbuf_size) == 1)
rc = PAHO_SUCCESS;
else
rc = PAHO_FAILURE;
break;
}
case PUBLISH:
{
MQTTString topicName;
MQTTMessage msg;
int intQoS;
if (MQTTDeserialize_publish(&msg.dup, &intQoS, &msg.retained, &msg.id, &topicName,
(unsigned char **)&msg.payload, (int *)&msg.payloadlen, c->readbuf, c->readbuf_size) != 1)
{
rt_kprintf("publish err.\n");
goto exit;
}
msg.qos = (enum QoS)intQoS;
do
{
if(msg.qos == QOS2)
{
if(find_qos2id_inlist(&usr_data->qos_id_save,msg.id)) // msg.id在单向链表中
{
break;
}
else
{
// 插入到链表中,满了便踢掉最旧的
set_qos2id_inlist(&usr_data->qos_id_save,msg.id);
}
}
deliverMessage(c, &topicName, &msg);
} while (0);
if (msg.qos != QOS0)
{
if (msg.qos == QOS1)
len = MQTTSerialize_ack(c->buf, c->buf_size, PUBACK, 0, msg.id);
else if (msg.qos == QOS2)
len = MQTTSerialize_ack(c->buf, c->buf_size, PUBREC, 0, msg.id);
if (len <= 0)
rc = PAHO_FAILURE;
else
rc = sendPacket(c, len);
if (rc == PAHO_FAILURE)
goto exit; // there was a problem
}
break;
}
case PUBREC:
{
unsigned short mypacketid;
unsigned char dup, type;
if (MQTTDeserialize_ack(&type, &dup, &mypacketid, c->readbuf, c->readbuf_size) != 1)
rc = PAHO_FAILURE;
else if ((len = MQTTSerialize_ack(c->buf, c->buf_size, PUBREL, 0, mypacketid)) <= 0)
rc = PAHO_FAILURE;
else if ((rc = sendPacket(c, len)) != PAHO_SUCCESS) // send the PUBREL packet
rc = PAHO_FAILURE; // there was a problem
if (rc == PAHO_FAILURE)
goto exit; // there was a problem
break;
}
case PUBREL:
{
/***
* paho库对qos2支持不完全,在收到qos2 PUBREL之后应回复一个完成包(PUBCOMP)通知broker
* 根据mqtt协议,此时才应该调用回调,但mqtt协议提供了另一种方法来保证QOS2防止多收的处理
* 检测报文标识符,添加报文标识符管理,新建一个等待链表,该链表要尽可能保证安全,在此采用数组元素作为节点,也可优化查找时的性能
* 1.接收到PUBLISH,检测其报文标识符是否在等待链表中
* 1.1 在等待链表中,则跳过发送,回复PUBREC
* 1.2 不在,则执行回调,并将其放入等待链表,等待PUBREL的到来
* 2.接收到PUBREL,此时证明报文所有权完全给到client
* 3.回复PUBCOMP,并将报文标识符在等待链表中删除
***/
unsigned short mypacketid;
unsigned char dup, type;
if (MQTTDeserialize_ack(&type, &dup, &mypacketid, c->readbuf, c->readbuf_size) != 1)
rc = PAHO_FAILURE;
else if ((len = MQTTSerialize_ack(c->buf, c->buf_size, PUBCOMP, 0, mypacketid)) <= 0)
rc = PAHO_FAILURE;
else if ((rc = sendPacket(c, len)) != PAHO_SUCCESS) // send the PUBCOMP packet
rc = PAHO_FAILURE; // there was a problem
if (rc == PAHO_FAILURE)
goto exit; // there was a problem
else
// 删除链表中的qos2报文id
remove_qos2id_inlist(&usr_data->qos_id_save,mypacketid);
break;
}
case PUBCOMP:
break;
case PINGRESP:
c->tick_ping = rt_tick_get();
break;
}
exit:
return rc;
}
由上操作:收到PUBLISH报文的QOS2级别消息,查找是否在链表中,若在则忽略,不在则插入链表;收到PUBREL报文是证明该报文标识符代表的数据权限由客户端接管,在链表中删除该报文标识符。
经过以上修改,可以尽可能的保证QOS2消息的到达次数,当前的设计逻辑在以下特殊情况下依旧有可能无法保证数据到达次数:等待链表满时,再次接收到新报文标识符会弹出最早的报文标识符,弹出的报文标识符便无法保证到来数据的次数。
接收解析存在内存溢出
逻辑实现缺陷,存在风险,强烈建议修改
现象描述
通过网络性能极差的路由器与设备进行连接时,进行数据双向传输,挂测时间长达10h~48h不等后,设备出现断言死机。
原因
在接收缓冲区反序列化为主题消息(MQTTDeserialize_publish)处理时,未对剩余长度进行计算判断,导致数据长度变为随机值。
该问题出现条件特殊,问题出现时网络出现瞬时大量的数据流入设备进行处理,确定存在问题,但其复现方法较难确定。
问题函数如下:
/**
* @param mqttstring the MQTTString structure into which the data is to be read
* @param pptr pointer to the output buffer - incremented by the number of bytes used & returned
* @param enddata pointer to the end of the data: do not read beyond
* @return 1 if successful, 0 if not
*/
int readMQTTLenString(MQTTString* mqttstring, unsigned char** pptr, unsigned char* enddata)
{
int rc = 0;
FUNC_ENTRY;
/* the first two bytes are the length of the string */
if (enddata - (*pptr) > 1) /* enough length to read the integer? */
{
mqttstring->lenstring.len = readInt(pptr); /* increments pptr to point past length */
if (&(*pptr)[mqttstring->lenstring.len] <= enddata)
{
mqttstring->lenstring.data = (char*)*pptr;
*pptr += mqttstring->lenstring.len;
rc = 1;
}
}
mqttstring->cstring = NULL;
FUNC_EXIT_RC(rc);
return rc;
}
在进行13行的判断时,对长度不足未做出反馈,最终导致mqttstring->lenstring.len
变为非常大的随机值,在之后调用isTopicMatched
函数来获取主题名长度进行特殊通配符(#、+、/)解析的操作便会导致溢出死机。
解决方案
最直接的解决方法便是增加判断:
/**
* @param mqttstring the MQTTString structure into which the data is to be read
* @param pptr pointer to the output buffer - incremented by the number of bytes used & returned
* @param enddata pointer to the end of the data: do not read beyond
* @return 1 if successful, 0 if not
*/
int readMQTTLenString(MQTTString* mqttstring, unsigned char** pptr, unsigned char* enddata)
{
int rc = 0;
FUNC_ENTRY;
/* the first two bytes are the length of the string */
if (enddata - (*pptr) > 1) /* enough length to read the integer? */
{
mqttstring->lenstring.len = readInt(pptr); /* increments pptr to point past length */
if (&(*pptr)[mqttstring->lenstring.len] <= enddata)
{
mqttstring->lenstring.data = (char*)*pptr;
*pptr += mqttstring->lenstring.len;
rc = 1;
}
}
else
{
mqttstring->lenstring.len = 0;
}
mqttstring->cstring = NULL;
FUNC_EXIT_RC(rc);
return rc;
}
以上修改后即可解决问题,但paho库对接收部分的代码处理不严谨,在其上级便是出现了返回值未更新的问题,需要按照注释进行简单修改。
keepalive时间10s以下失效
功能缺陷,可以按需求选择方案进行修改
现象描述
设备作为客户端设置keepalive时间为1进行连接代理服务器,向服务器推送消息失败,连接被服务器关闭,抓包发现客户端没有心跳请求。
原因
paho库计算心跳的时间如下:
tick_now = rt_tick_get();
if (((tick_now - c->tick_ping) / RT_TICK_PER_SECOND) > (c->keepAliveInterval - 5))
{
timeout.tv_sec = 1;
//LOG_D("tick close to ping.");
}
else
{
timeout.tv_sec = c->keepAliveInterval - 10 - (tick_now - c->tick_ping) / RT_TICK_PER_SECOND;
//LOG_D("timeount for ping: %d", timeout.tv_sec);
}
timeout.tv_usec = 0;
其中c->keepAliveInterval
为unsigned int
类型,由以上处理可以看出,当心跳时间设置10s以下时,实际获取的timeout.tv_sec
数值非常大。
解决方案
由于心跳时间配置为10s以下的流量消耗较大,最直接的解决办法便是前端限制不可设置心跳时间在10s以下。
若对高频心跳确实有要求,可以通过以下计算修改可以将心跳请求尽可能在代理服务器踢掉之前发出请求:
tick_now = rt_tick_get();
if (((tick_now - c->tick_ping) / RT_TICK_PER_SECOND) > (c->keepAliveInterval - 1))
{
timeout.tv_sec = 0;
//LOG_D("tick close to ping.");
}
else
{
timeout.tv_sec = c->keepAliveInterval - (tick_now - c->tick_ping) / RT_TICK_PER_SECOND;
//LOG_D("timeount for ping: %d", timeout.tv_sec);
}
timeout.tv_usec = 0;
由于会受到网络延迟的影响,当心跳时间设置1~2s时,有较小概率会出现,心跳请求可以正常发送出,但是设备依旧断开连接的情况,该现象出现的频率与网络环境以及设置时间相关。
读取mqtt数据频繁重连
影响稳定性以及网络环境兼容性,建议修改
现象描述
当由于高频数据或者网络波动导致网络阻塞较多数据,且读出数据不准确后便会执行重连,该问题的出现概率较小但影响长时间的通信稳定性,一旦出现一包错误,后续断连便会导致更多的数据丢失。
原因
paho库处理接收数据时会比对读取长度,当获取的长度与预期不符合时直接进行锻炼操作,相关代码如下:
static int MQTTPacket_readPacket(MQTTClient *c)
{
int rc = PAHO_FAILURE;
MQTTHeader header = {0};
int len = 0;
int rem_len = 0;
/* 1. read the header byte. This has the packet type in it */
if (net_read(c, c->readbuf, 1, 0) != 1)
goto exit;
len = 1;
/* 2. read the remaining length. This is variable in itself */
decodePacket(c, &rem_len, 50);
len += MQTTPacket_encode(c->readbuf + 1, rem_len); /* put the original remaining length back into the buffer */
/* 3. read the rest of the buffer using a callback to supply the rest of the data */
if (rem_len > 0 && (net_read(c, c->readbuf + len, rem_len, 300) != rem_len))
goto exit;
header.byte = c->readbuf[0];
rc = header.bits.type;
exit:
return rc;
}
根据以上代码,通过decodePacket()
获取接收网络数据的长度,之后通过net_read
获取实际数据内容。
static int net_read(MQTTClient *c, unsigned char *buf, int len, int timeout)
{
int bytes = 0;
int rc;
while (bytes < len)
{
#ifdef MQTT_USING_TLS
if (c->tls_session)
{
rc = mbedtls_client_read(c->tls_session, &buf[bytes], (size_t)(len - bytes));
if (rc <= 0)
{
bytes = -1;
break;
}
else
{
bytes += rc;
}
goto _continue;
}
#endif
rc = recv(c->sock, &buf[bytes], (size_t)(len - bytes), MSG_DONTWAIT);
if (rc == -1)
{
if (errno != ENOTCONN && errno != ECONNRESET)
{
bytes = -1;
break;
}
}
else
bytes += rc;
#ifdef MQTT_USING_TLS
_continue:
#endif
if (bytes >= len)
{
break;
}
if (timeout > 0)
{
fd_set readset;
struct timeval interval;
LOG_D("net_read %d:%d, timeout:%d", bytes, len, timeout);
timeout = 0;
interval.tv_sec = 1;
interval.tv_usec = 0;
FD_ZERO(&readset);
FD_SET(c->sock, &readset);
select(c->sock + 1, &readset, RT_NULL, RT_NULL, &interval);
}
else
{
LOG_D("net_read %d:%d, break!", bytes, len);
break;
}
}
return bytes;
}
在net_read
执行中,会至多读取两次,直到获取到rem_len
长度的数据,在实测时会发现存在第一次读取成功,第二次读取失败,导致直接返回失败(-1)的情况,有效数据没有得到保障,该处可以继续进一步的优化。
解决方案
为了尽可能的将有效数据向前端传递,需要:
- 将
MQTTPacket_readPacket
中长度比对的报错屏蔽 net_read
内部逻辑重新处理,使其将读取到的有效数据返回,忽略读取失败的情况。
static int MQTTPacket_readPacket(MQTTClient *c)
{
int rc = PAHO_FAILURE;
MQTTHeader header = {0};
int len = 0;
int rem_len = 0;
/* 1. read the header byte. This has the packet type in it */
if (net_read(c, c->readbuf, 1, 0) != 1)
goto exit;
len = 1;
/* 2. read the remaining length. This is variable in itself */
decodePacket(c, &rem_len, 50);
len += MQTTPacket_encode(c->readbuf + 1, rem_len); /* put the original remaining length back into the buffer */
/* 3. read the rest of the buffer using a callback to supply the rest of the data */
if (rem_len > 0 && (net_read(c, c->readbuf + len, rem_len, 300) != rem_len))
{
rt_kprintf("[usr] rem len err %d:%d.\n",bytes,rem_len);
// goto exit;
}
header.byte = c->readbuf[0];
rc = header.bits.type;
exit:
return rc;
}
static int net_read(MQTTClient *c, unsigned char *buf, int len, int timeout)
{
int bytes = 0;
int rc;
while (bytes < len)
{
#ifdef MQTT_USING_TLS
if (c->tls_session)
{
rc = mbedtls_client_read(c->tls_session, &buf[bytes], (size_t)(len - bytes));
if (rc <= 0)
{
if(bytes > 0) // 已经有有效数据的情况下,直接返回有效数据
{
}
else
{
bytes = -1;
}
break;
}
else
{
bytes += rc;
}
goto _continue;
}
#endif
rc = recv(c->sock, &buf[bytes], (size_t)(len - bytes), MSG_DONTWAIT);
if (rc == -1)
{
if (errno != ENOTCONN && errno != ECONNRESET)
{
if(bytes > 0)
{
}
else
{
bytes = -1;
}
break;
}
}
else
bytes += rc;
#ifdef MQTT_USING_TLS
_continue:
#endif
if (bytes >= len)
{
break;
}
if (timeout > 0)
{
fd_set readset;
struct timeval interval;
LOG_D("net_read %d:%d, timeout:%d", bytes, len, timeout);
timeout = 0;
interval.tv_sec = 1;
interval.tv_usec = 0;
FD_ZERO(&readset);
FD_SET(c->sock, &readset);
select(c->sock + 1, &readset, RT_NULL, RT_NULL, &interval);
}
else
{
LOG_D("net_read %d:%d, break!", bytes, len);
break;
}
}
return bytes;
}