目录
- 一、前言
- 二、源码结构与三层架构
- 三、完整流程概览
- [四、步骤一:建立 TCP 连接](#四、步骤一:建立 TCP 连接)
- [五、步骤二:MQTT 连接](#五、步骤二:MQTT 连接)
- 六、步骤三:订阅主题
- 七、步骤四:发布消息
- [八、步骤五:接收消息(Yield 循环)](#八、步骤五:接收消息(Yield 循环))
- 九、总结
- 十、结尾
一、前言
大家好,这里是 Hello_Embed。上篇体验了 MQTT 协议:在 PC 上搭起 emqx Broker,用 MQTTX 跑通了发布/订阅流程。
本篇进入源码层面,分析 Paho MQTT Embedded-C 的实现,理清每一步做了什么,为后续移植到 FreeRTOS 打好基础。
源码地址:
- GitHub:https://github.com/eclipse-paho/paho.mqtt.embedded-c
- 国内镜像:https://gitee.com/mirrors_to_compile/paho.mqtt.embedded-c
二、源码结构与三层架构
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_setsockopt、FreeRTOS_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 等待确认 |
| 接收消息 | MQTTYield → cycle |
轮询读包,解包后查表调用回调函数 |
源码三层架构:
- 网络操作层 (
Network):平台相关,移植时只需替换这一层 - 数据包处理层 (
MQTTPacket):封包/解包,平台无关 - API 层 (
MQTTClient):业务逻辑,平台无关
十、结尾
本篇完成了 Paho MQTT Embedded-C 的源码分析:从 TCP 连接、MQTT 握手、订阅注册,到发布消息、接收回调,梳理了完整的五步流程,也明确了移植的关键点------只需替换 Network 层的读写函数。
下一篇将把 Paho MQTT 移植到 FreeRTOS,融合进本系列的项目工程,敬请期待~
Hello_Embed 继续带你从原理到实践,掌握嵌入式上位机开发的核心技能,敬请关注~