嵌入式上位机开发入门(二十四):Paho MQTT 嵌入式客户端源码分析

目录


一、前言

大家好,这里是 Hello_Embed。上篇体验了 MQTT 协议:在 PC 上搭起 emqx Broker,用 MQTTX 跑通了发布/订阅流程。

本篇进入源码层面,分析 Paho MQTT Embedded-C 的实现,理清每一步做了什么,为后续移植到 FreeRTOS 打好基础。

源码地址:


二、源码结构与三层架构

Paho Embedded-C 的源码分为三个层次:

复制代码
┌──────────────────────────────────────┐
│         API 层(MQTTClient)          │  ← 应用直接调用
│  Connect / Subscribe / Publish / ...  │
├──────────────────────────────────────┤
│      数据包处理层(MQTTPacket)        │  ← 封包 / 解包
│  Serialize_connect / Deserialize_...  │
├──────────────────────────────────────┤
│       网络操作层(Network)            │  ← 平台相关
│  mqttread / mqttwrite / disconnect    │
└──────────────────────────────────────┘

API 层负责业务逻辑,调用数据包处理层完成 MQTT 帧的构造和解析,再通过网络操作层完成实际收发。移植时只需替换网络操作层,上层代码无需修改。

解析 :这种分层设计让 Paho 可以运行在 Linux、FreeRTOS、裸机等不同平台上。Network 结构体是平台适配的关键------只要把 mqttread/mqttwrite 替换为当前平台的 socket 实现,整个库就能工作。


三、完整流程概览

以 test1 为例,MQTT 客户端的完整流程如下:

步骤 类比 对应操作
① TCP 连接 打通电话 NetworkConnect
② MQTT 连接 验证用户名/密码 MQTTConnect:封包并发送,等待 CONNACK
③ 订阅主题 告诉电视台"我要看这个频道" MQTTSubscribe:封包并发送,本地记录处理函数
④ 发布消息 记者播报 MQTTPublish:封包并发送,等待确认
⑤ 接收消息 等待播报 MQTTYield:轮询读数据,解包后调用处理函数

四、步骤一:建立 TCP 连接

c 复制代码
NetworkInit(&n);
NetworkConnect(&n, options.host, options.port);

Network 是对网络操作的抽象,把平台相关的函数指针集中在一起:

c 复制代码
struct Network
{
    int my_socket;
    int (*mqttread)  (Network*, unsigned char*, int, int);
    int (*mqttwrite) (Network*, unsigned char*, int, int);
    void (*disconnect)(Network*);
};

NetworkInit 把函数指针绑定到 FreeRTOS 版本的实现:

c 复制代码
void NetworkInit(Network* n)
{
    n->my_socket = 0;
    n->mqttread  = FreeRTOS_read;
    n->mqttwrite = FreeRTOS_write;
    n->disconnect = FreeRTOS_disconnect;
}

网络读写函数使用 FreeRTOS 的超时机制,确保在规定时间内收完/发完所有数据:

c 复制代码
int FreeRTOS_read(Network* n, unsigned char* buffer, int len, int timeout_ms)
{
    TickType_t xTicksToWait = timeout_ms / portTICK_PERIOD_MS;
    TimeOut_t xTimeOut;
    int recvLen = 0;

    vTaskSetTimeOutState(&xTimeOut);        /* 记录进入时的时间戳 */
    do
    {
        int rc = 0;
        FreeRTOS_setsockopt(n->my_socket, 0, FREERTOS_SO_RCVTIMEO,
                            &xTicksToWait, sizeof(xTicksToWait));
        rc = FreeRTOS_recv(n->my_socket, buffer + recvLen, len - recvLen, 0);
        if (rc > 0)
            recvLen += rc;
        else if (rc < 0)
        {
            recvLen = rc;
            break;
        }
    } while (recvLen < len && xTaskCheckForTimeOut(&xTimeOut, &xTicksToWait) == pdFALSE);

    return recvLen;
}

写函数结构与读函数对称,不再重复展示。断开函数只需关闭 socket:

c 复制代码
void FreeRTOS_disconnect(Network* n)
{
    FreeRTOS_closesocket(n->my_socket);
}

解析FreeRTOS_setsockoptFreeRTOS_closesocket 等函数依赖 FreeRTOS+TCP 组件,移植到 W800 平台时需要用 W800 的 AT 指令或 socket API 替换这些函数------这正是后续移植工作的核心。

NetworkConnect 完成 DNS 解析 + socket 创建 + TCP 连接三步:

c 复制代码
int NetworkConnect(Network* n, char* addr, int port)
{
    struct freertos_sockaddr sAddr;
    int retVal = -1;
    uint32_t ipAddress;

    if ((ipAddress = FreeRTOS_gethostbyname(addr)) == 0)  /* DNS 解析 */
        goto exit;

    sAddr.sin_port = FreeRTOS_htons(port);
    sAddr.sin_addr = ipAddress;

    if ((n->my_socket = FreeRTOS_socket(FREERTOS_AF_INET,
                         FREERTOS_SOCK_STREAM, FREERTOS_IPPROTO_TCP)) < 0)
        goto exit;

    if ((retVal = FreeRTOS_connect(n->my_socket, &sAddr, sizeof(sAddr))) < 0)
    {
        FreeRTOS_closesocket(n->my_socket);
        goto exit;
    }

exit:
    return retVal;
}

解析goto exit 是 C 语言中统一处理错误返回的常见写法------任意一步失败都跳到 exit,避免嵌套 if-else。对应的 Linux 版本实现逻辑相同,只是调用标准 POSIX socket API。


五、步骤二:MQTT 连接

TCP 层建好后,还需要进行 MQTT 协议层的握手:

c 复制代码
MQTTPacket_connectData data = MQTTPacket_connectData_initializer;
data.clientID.cstring  = "single-threaded-test";
data.username.cstring  = "testuser";
data.password.cstring  = "testpassword";
data.keepAliveInterval = 20;
data.cleansession      = 1;
/* 遗愿消息(Will):连接异常断开时 Broker 自动发布 */
data.willFlag              = 1;
data.will.topicName.cstring = "will topic";
data.will.message.cstring  = "will message";
data.will.qos              = 1;
data.will.retained         = 0;

rc = MQTTConnect(&c, &data);

MQTTConnect 最终调用 MQTTConnectWithResults,核心逻辑是:

复制代码
① 将连接参数序列化为 MQTT CONNECT 报文(MQTTSerialize_connect)
② 通过 sendPacket 发送给 Broker
③ 等待 CONNACK 响应(waitfor)
④ 成功则置 c->isconnected = 1

MQTTSerialize_connect 按协议规范逐字段填充缓冲区,以 MQTT v4(v3.1.1)为例:

c 复制代码
header.bits.type = CONNECT;
writeChar(&ptr, header.byte);           /* 固定头部 */
ptr += MQTTPacket_encode(ptr, len);     /* 剩余长度 */
writeCString(&ptr, "MQTT");             /* 协议名 */
writeChar(&ptr, (char) 4);              /* 协议级别 = 4 (v3.1.1) */
writeChar(&ptr, flags.all);             /* 连接标志(用户名/密码/Will 等) */
writeInt(&ptr, options->keepAliveInterval);
writeMQTTString(&ptr, options->clientID);
/* Will、用户名、密码按 flags 决定是否写入 */

sendPacket 通过 c->ipstack->mqttwrite(即前面绑定的 FreeRTOS_write)将缓冲区发出:

c 复制代码
static int sendPacket(MQTTClient* c, int length, Timer* timer)
{
    int rc = FAILURE, sent = 0;
    while (sent < length && !TimerIsExpired(timer))
    {
        rc = c->ipstack->mqttwrite(c->ipstack, &c->buf[sent],
                                   length, TimerLeftMS(timer));
        if (rc < 0) break;
        sent += rc;
    }
    if (sent == length)
    {
        TimerCountdown(&c->last_sent, c->keepAliveInterval);
        rc = SUCCESS;
    }
    else
        rc = FAILURE;
    return rc;
}

解析sendPacket 用循环保证数据完整发出(TCP 可能分片),每次发送后用 TimerIsExpired 判断是否超时。c->last_sent 用于 keepalive 计时------上次成功发送后开始倒计时,到期前需要发一个 PINGREQ。


六、步骤三:订阅主题

c 复制代码
rc = MQTTSubscribe(&c, test_topic, subsqos, messageArrived);

MQTTSubscribeWithResults 做两件事:

① 封包并发送

c 复制代码
len = MQTTSerialize_subscribe(c->buf, c->buf_size, 0,
        getNextPacketId(c), 1, &topic, (int*)&qos);
sendPacket(c, len, &timer);

② 等待 SUBACK,成功后在本地注册处理函数

c 复制代码
if (waitfor(c, SUBACK, &timer) == SUBACK)
{
    if (MQTTDeserialize_suback(&mypacketid, 1, &count,
            (int*)&data->grantedQoS, c->readbuf, c->readbuf_size) == 1)
    {
        if (data->grantedQoS != 0x80)  /* 0x80 表示订阅被拒绝 */
            rc = MQTTSetMessageHandler(c, topicFilter, messageHandler);
    }
}

MQTTSetMessageHandler(主题, 回调函数) 对记录到客户端的处理函数表中,后续收到该主题的消息时会自动查表调用。

解析0x80 是 SUBACK 报文中表示"订阅失败"的特殊 QoS 值,意味着 Broker 拒绝了该订阅请求(权限不足或主题不合法)。只有 grantedQoS != 0x80 时才算订阅成功,才需要注册本地处理函数。


七、步骤四:发布消息

c 复制代码
rc = MQTTPublish(c, test_topic, &pubmsg);

发布逻辑根据 QoS 级别有所不同:

c 复制代码
/* QoS1/2 才分配消息 ID,QoS0 不需要 */
if (message->qos == QOS1 || message->qos == QOS2)
    message->id = getNextPacketId(c);

len = MQTTSerialize_publish(c->buf, c->buf_size, 0, message->qos,
        message->retained, message->id,
        topic, (unsigned char*)message->payload, message->payloadlen);
sendPacket(c, len, &timer);

/* QoS1:等待 PUBACK */
if (message->qos == QOS1)
{
    if (waitfor(c, PUBACK, &timer) != PUBACK)
        rc = FAILURE;
}
/* QoS2:等待 PUBCOMP(四次握手的最后一步) */
else if (message->qos == QOS2)
{
    if (waitfor(c, PUBCOMP, &timer) != PUBCOMP)
        rc = FAILURE;
}
QoS 级别 发布流程 可靠性
QoS 0 发送即完成,无确认 最多一次,可能丢失
QoS 1 发送 → 等待 PUBACK 至少一次,可能重复
QoS 2 四次握手(PUBLISH → PUBREC → PUBREL → PUBCOMP) 恰好一次

解析retained 标志为 1 时,Broker 会保存该消息,新客户端订阅该主题后立即收到最近一条保留消息,无需等待下一次发布------常用于设备状态上报。


八、步骤五:接收消息(Yield 循环)

c 复制代码
MQTTYield(c, 100);  /* 在 100ms 内轮询处理收到的消息 */

MQTTYield 在超时时间内循环调用 cycle

c 复制代码
int MQTTYield(MQTTClient* c, int timeout_ms)
{
    Timer timer;
    TimerInit(&timer);
    TimerCountdownMS(&timer, timeout_ms);
    do {
        if (cycle(c, &timer) < 0) { rc = FAILURE; break; }
    } while (!TimerIsExpired(&timer));
    return rc;
}

cycle 是接收处理的核心,读一个数据包,根据类型分发:

c 复制代码
int cycle(MQTTClient* c, Timer* timer)
{
    int packet_type = readPacket(c, timer);   /* 从 socket 读一帧 */

    switch (packet_type)
    {
        case 0:      /* 超时,本次没有数据 */
            break;
        case CONNACK: case PUBACK: case SUBACK: case UNSUBACK:
            break;   /* 这些在对应的 waitfor 里处理,这里忽略 */

        case PUBLISH:
        {
            /* 解包,取出主题和消息体 */
            MQTTDeserialize_publish(&msg.dup, &intQoS, &msg.retained,
                    &msg.id, &topicName,
                    (unsigned char**)&msg.payload, (int*)&msg.payloadlen,
                    c->readbuf, c->readbuf_size);

            deliverMessage(c, &topicName, &msg); /* 查表,调用注册的处理函数 */

            /* QoS1/2 需要回复确认 */
            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)
                sendPacket(c, len, timer);
            break;
        }

        case PUBREC: case PUBREL:
        {
            /* QoS2 四次握手的中间步骤 */
            MQTTDeserialize_ack(&type, &dup, &mypacketid, c->readbuf, c->readbuf_size);
            len = MQTTSerialize_ack(c->buf, c->buf_size,
                    (packet_type == PUBREC) ? PUBREL : PUBCOMP, 0, mypacketid);
            sendPacket(c, len, timer);
            break;
        }

        case PINGRESP:
            c->ping_outstanding = 0;  /* 收到心跳响应,清除待确认标志 */
            break;
    }

    keepalive(c);  /* 检查是否需要发送 PINGREQ */
    return rc;
}

deliverMessage 在内部遍历处理函数表,找到与 topicName 匹配的条目后调用回调:

复制代码
topicName → 查 messageHandlers[] → 找到匹配项 → 调用 messageArrived(msg)

解析 :单线程模型下,MQTTYield 既是"等待接收"的入口,也是"心跳维护"的驱动。需要周期性调用,否则 keepalive 超时后 Broker 会主动断开连接。移植到 FreeRTOS 后,通常将 Yield 放在独立任务的 for(;;) 循环里持续轮询。


九、总结

步骤 关键函数 核心动作
TCP 连接 NetworkConnect DNS 解析 + 创建 socket + TCP 握手
MQTT 连接 MQTTConnect 序列化 CONNECT 报文,等待 CONNACK
订阅主题 MQTTSubscribe 发送 SUBSCRIBE,本地注册 (主题, 回调)
发布消息 MQTTPublish 序列化 PUBLISH 报文,按 QoS 等待确认
接收消息 MQTTYieldcycle 轮询读包,解包后查表调用回调函数

源码三层架构:

  • 网络操作层Network):平台相关,移植时只需替换这一层
  • 数据包处理层MQTTPacket):封包/解包,平台无关
  • API 层MQTTClient):业务逻辑,平台无关

十、结尾

本篇完成了 Paho MQTT Embedded-C 的源码分析:从 TCP 连接、MQTT 握手、订阅注册,到发布消息、接收回调,梳理了完整的五步流程,也明确了移植的关键点------只需替换 Network 层的读写函数。

下一篇将把 Paho MQTT 移植到 FreeRTOS,融合进本系列的项目工程,敬请期待~

Hello_Embed 继续带你从原理到实践,掌握嵌入式上位机开发的核心技能,敬请关注~

相关推荐
黄焖鸡能干四碗3 小时前
企业元数据梳理和元数据管理方案(PPT方案)
大数据·运维·网络·分布式·spark
克莱因35810 小时前
思科 Cisco 标准ACL
网络·路由
LN花开富贵11 小时前
【ROS】鱼香ROS2学习笔记一
linux·笔记·python·学习·嵌入式·ros·agv
资深数据库专家11 小时前
总账EBS 应用服务器1 的监控分析
java·网络·数据库
yrx02030711 小时前
串口空闲中断+DMA接收+环形缓冲区 && 串口DMA发送+环形缓冲区
stm32·单片机
阿正的梦工坊11 小时前
拦截网络请求:一种更优雅的数据获取方式
网络·okhttp
聊点儿技术12 小时前
【游戏风控】如何用IP数据接口从“IP即判罚”升级为“IP参与评分”
tcp/ip·游戏·游戏安全·ip数据库·ip地理定位api·ip数据接口·ip风险画像
IpdataCloud12 小时前
大数据分析:如何高效查询海量IP归属地?
tcp/ip·数据挖掘·数据分析·ip
TechWayfarer13 小时前
IP归属地API 技术解析与应用实践
网络·网络协议·tcp/ip