libwebsockets 实现 MQTT 客户端:minimal-mqtt-client 示例全解析

原创2026-10-13 13:30:47194 阅读
文章标签:网络通信WebSocket嵌入式

libwebsockets 实现 MQTT 客户端:minimal-mqtt-client 示例全解析

导读

本文以 libwebsockets 仓库中的 minimal-mqtt-client 示例为主线,完整讲解如何用 libwebsockets 在本地搭建一个可运行的 MQTT 3.1.1 客户端:包括构建、启动 mosquitto broker、订阅/发布消息、观察日志输出,并结合 minimal-mqtt-client.c 源码与 lib/sansio/mqtt 底层实现,剖析连接参数、回调机制、QoS 0/1 发送流程与 keep-alive 心跳等关键原理。读完本文,你将能够独立编译运行该示例,并具备在此基础上开发自定义 MQTT 客户端的基础。

示例定位与项目背景

该示例位于 minimal-examples-lowlevel/mqtt-client/ 目录下,与 minimal-mqtt-client-multi(多连接复用单条 TCP/TLS 连接的演示)并列,属于 libwebsockets 的 MQTT 角色(role)低层示例。它演示的是单一 MQTT 客户端连接的最基础操作:

  • 连接本地 broker(localhost);
  • 使用用户名/密码认证;
  • 设置 Keep Alive、Clean Start 与遗嘱(Will/LWT)消息;
  • 订阅两个主题并发布一长段文本(分别以 QoS0 和 QoS1 发送);
  • 通过回调驱动整个事件循环流程。

libwebsockets 将 MQTT 作为一等网络角色实现,相关协议解析位于 lib/sansio/mqtt/(含 mqtt.c、client/client-mqtt.c),公共 API 头文件为 include/libwebsockets/lws-mqtt.h,示例中所有参数结构体与回调名称均可在其中找到对应定义。

构建示例

进入示例目录后,使用 CMake 构建:

 $ cmake . && make

该目录下的 CMakeLists.txt 揭示了编译该示例所需的 libwebsockets 构建选项,任何一项不满足则示例不会被构建:

配置项 作用
LWS_ROLE_MQTT 启用 MQTT 角色,编译协议栈实现
LWS_WITH_CLIENT 启用客户端连接能力(lws_client_connect_via_info)
LWS_WITH_SYS_STATE 启用系统状态机,示例依赖 LWS_SYSTATE_OPERATIONAL 通知来延迟发起连接

因此,若你自行编译 libwebsockets 时需要确认这些选项已打开(通常在 CMakeLists.txt 顶层配置中通过 -DLWS_ROLE_MQTT=ON -DLWS_WITH_CLIENT=ON -DLWS_WITH_SYS_STATE=ON 设置)。构建产物为可执行文件 lws-minimal-mqtt-client。

命令行参数与运行

示例支持两个命令行选项:

命令行选项 含义
-d <loglevel> 十进制调试等级,例如 -d15(数字越大输出越详细)
-s 使用 TLS 并连接 8883 端口,替代默认的非加密 1883 端口

同时内置 --help 用于打印帮助信息。参数解析由 lws_cmdline_option / lws_cmdline_option_handle_builtin 完成,对应的开关表定义在源码 minimal-mqtt-client.c 中。

前置条件:本地启动 mosquitto broker

$ mosquitto

mosquitto 默认监听 1883 端口(非加密)。若使用 -s 选项,则需要 broker 在 8883 端口提供 TLS 服务,示例目录内已随附测试用证书 mosq-ca.crt、mosq-server.crt 与私钥 mosq-server.key。

运行(非加密模式)

[2020/01/31 10:40:23:7789] U: LWS minimal MQTT client unencrypted [-d<verbosity>][-s]
[2020/01/31 10:40:23:8539] N: lws_mqtt_generate_id: User space provided a client ID 'lwsMqttClient'
[2020/01/31 10:40:23:9893] N: _lws_mqtt_rx_parser: migrated nwsi 0x50febd0 to sid 1 0x5106820
[2020/01/31 10:40:23:9899] U: callback_mqtt: MQTT_CLIENT_ESTABLISHED
[2020/01/31 10:40:23:9967] U: callback_mqtt: WRITEABLE: Subscribing
[2020/01/31 10:40:24:0068] U: callback_mqtt: MQTT_SUBSCRIBED

验证订阅:向测试主题发布消息

mosquitto_pub -h 127.0.0.1 -p 1883 -t test/topic0 -m "hello"

观察客户端收到消息

[2020/01/31 10:40:27:1845] U: callback_mqtt: MQTT_CLIENT_RX
[2020/01/31 10:40:27:1870] N:
[2020/01/31 10:40:27:1945] N: 0000: 74 65 73 74 2F 74 6F 70 69 63 30                   test/topic0
[2020/01/31 10:40:27:1952] N:

日志中的 0000: ... 为 lwsl_hexdump_notice 输出的十六进制转储,内容正是收到的主题 test/topic0。程序正常完成一轮订阅/发布后,会输出 Completed: OK 并退出;bad 标志为 1 时则输出 Completed: failed,退出码也会相应为 1(参见 minimal-mqtt-client.c)。

连接参数:一次看懂 CONNECT 报文配置

示例在全局静态结构体 client_connect_param 中声明了连接参数,对应 lws-mqtt.h 中的 lws_mqtt_client_connect_param_t:

static const lws_mqtt_client_connect_param_t client_connect_param = {
	.client_id			= "lwsMqttClient",
	.keep_alive			= 60,
	.clean_start			= 1,
	.client_id_nofree		= 1,
	.username_nofree		= 1,
	.password_nofree		= 1,
	.will_param = {
		.topic			= "good/bye",
		.message		= "sign-off",
		.qos			= 0,
		.retain			= 0,
	},
	.username			= "lwsUser",
	.password			= "mySecretPassword",
};

各字段含义与底层影响:

  • client_id:MQTT 客户端标识。在 client-mqtt.c 的 lws_mqtt_generate_id() 中处理:若为空则生成随机 ID(长度取 LWS_MQTT_RANDOM_CIDLEN,即 23 字符,符合 MQTT 3.1.1 规范 1~23 字符的要求);示例提供了固定 ID,故日志显示 User space provided a client ID 'lwsMqttClient'。
  • keep_alive:Keep Alive 间隔(秒)。底层在 CONNECT 报文中写入该值,并由 retry 策略驱动 PINGREQ。
  • clean_start:会话清理标志,对应 LMQCFT_CLEAN_START 连接标志(见 lws-mqtt.h)。
  • *_nofree 系列:声明 client_id、username、password 字符串的内存由调用方(此处为静态字符串)持有,libwebsockets 不应释放。
  • will_param:遗嘱消息(LWT)。设置后底层会置 LMQCFT_WILL_FLAG,并将 QoS 与 retain 标志编码进 CONNECT 标志位(见 client-mqtt.c)。当连接意外中断时,broker 会代客户端向 good/bye 主题发布 sign-off。
  • username / password:认证凭据,对应 LMQCFT_USERNAME / LMQCFT_PASSWORD 标志。若凭据错误,broker 返回 CONNACK 拒绝,底层 lws_mqtt_client_connack_failed() 会把原因(如 invalid credentials、not authorized、server does not support MQTT protocol 3.1.1 等)作为连接失败信息回传给应用(见 client-mqtt.c)。

连接发起:事件驱动与系统状态门控

示例并非在 main() 中立刻连接,而是通过系统状态机延迟到网络就绪后再发起,原因在 system_notify_cb 的注释中写得很清楚:等每个 vhost 的协议初始化完成、网络与时间就绪后再连接,才能正确判断 TLS 证书有效性。回调在状态迁移到 LWS_SYSTATE_OPERATIONAL 时调用 connect_client()。

static int
connect_client(struct lws_context *context)
{
	struct lws_client_connect_info i;

	memset(&i, 0, sizeof i);

	i.mqtt_cp = &client_connect_param;
	i.address = "localhost";
	i.host = "localhost";
	i.protocol = "mqtt";
	i.context = context;
	i.method = "MQTT";
	i.alpn = "mqtt";
	i.port = 1883;

	if (do_ssl) {
		i.ssl_connection = LCCSCF_USE_SSL;
		i.ssl_connection |= LCCSCF_ALLOW_SELFSIGNED;
		i.port = 8883;
	}

	if (!lws_client_connect_via_info(&i)) {
		lwsl_err("%s: Client Connect Failed\n", __func__);
		return 1;
	}
	return 0;
}

要点:

  • protocol / method / alpn 均设为 "mqtt",这是 libwebsockets 识别 MQTT 角色并走 MQTT 协议栈的关键标识;
  • 连接目标写死为 localhost:1883,-s 时切换为 8883 并叠加 LCCSCF_USE_SSL 与 LCCSCF_ALLOW_SELFSIGNED(允许自签名证书,配合示例自带测试证书);
  • main() 中设置 info.port = CONTEXT_PORT_NO_LISTEN,表示本示例不运行任何服务端监听,纯客户端角色;
  • 连接信息中同时挂载了 retry 策略:空闲 20 秒后发 PINGREQ,若 25 秒仍无有效活动则判定挂死断开,这是 lws_retry_bo_t 结构体在 MQTT keep-alive 上的应用。

事件循环与 MQTT 回调:示例的状态机

示例协议定义将 per_session_data_size 设为 sizeof(struct pss),struct pss 是一个 4 状态的状态机:

enum {
	STATE_SUBSCRIBE,	/* subscribe to the topic */
	STATE_PUBLISH_QOS0,	/* Send the message in QoS0 */
	STATE_WAIT_ACK0,	/* Wait for the synthetic "ack" */
	STATE_PUBLISH_QOS1,	/* Send the message in QoS1 */
	STATE_WAIT_ACK1,	/* Wait for the real ack (or timeout + retry) */
	STATE_TEST_FINISH
};

整个测试流程由 LWS_CALLBACK_MQTT_CLIENT_WRITEABLE 回调中的 switch (pss->state) 驱动(minimal-mqtt-client.c),共涉及以下回调(完整枚举见 lws-callbacks.h):

回调 触发时机 示例中的处理
LWS_CALLBACK_CLIENT_CONNECTION_ERROR 连接失败 打印错误并退出事件循环
LWS_CALLBACK_MQTT_CLIENT_ESTABLISHED CONNACK 成功、连接建立 请求 WRITEABLE 以发起订阅
LWS_CALLBACK_MQTT_CLIENT_WRITEABLE 可写(含应用主动请求) 按状态机执行订阅/分块发布
LWS_CALLBACK_MQTT_SUBSCRIBED SUBACK 收到(或共享连接已订阅) 请求 WRITEABLE 以开始发布
LWS_CALLBACK_MQTT_CLIENT_RX 收到 PUBLISH 消息 转储主题与负载
LWS_CALLBACK_MQTT_ACK QoS0 发送完本地确认 / QoS1 收到 PUBACK 推进状态机,完成后退出
LWS_CALLBACK_MQTT_RESEND QoS1/2 等待 PUBACK/PUBREC 超时 重试(最多 3 次)
LWS_CALLBACK_MQTT_CLIENT_CLOSED 连接关闭 退出事件循环

注意回调注释强调:额外的 WRITEABLE 可能不请自来,因此必须通过自身状态机判断是否要利用这次可写机会,这正是示例把业务逻辑挂在 pss->state 上的原因。

订阅流程

在 STATE_SUBSCRIBE 状态下调用:

lws_mqtt_client_send_subcribe(wsi, &sub_param);

sub_param 指定了两个主题(见 minimal-mqtt-client.c):

static lws_mqtt_topic_elem_t topics[] = {
	[0] = { .name = "test/topic0", .qos = QOS0 },
	[1] = { .name = "test/topic1", .qos = QOS1 },
};

即分别以 QoS0、QoS1 订阅 test/topic0 与 test/topic1。lws_mqtt_subscribe_param_t 的 num_topics 上限为 LWS_MQTT_MAX_TOPICS(8),lws_mqtt_client_send_subcribe() 返回非零表示失败(lws-mqtt.h)。

分块发布:QoS0 与 QoS1

示例内置了一段 1337 字节的测试文本(TEST_STRING_LEN),发布到 test/topic 主题。MQTT 要求报文在开头即声明完整 PUBLISH 长度,但大帧不必一次写满——lws_mqtt_client_send_publish() 允许分片发送,首片携带头部,后续调用补充剩余负载,末片置 final 标志(API 文档见 lws-mqtt.h)。

示例按 300 字节/片 的块大小循环发送:

pub_param.topic	= "test/topic";
pub_param.topic_len = (uint16_t)strlen(pub_param.topic);
pub_param.qos = pss->state == STATE_PUBLISH_QOS0 ? QOS0 : QOS1;
pub_param.payload_len = TEST_STRING_LEN;

chunk = 300;
if (chunk > TEST_STRING_LEN - pss->pos)
	chunk = TEST_STRING_LEN - pss->pos;

if (lws_mqtt_client_send_publish(wsi, &pub_param,
		test_string + pss->pos, (uint32_t)chunk,
		(pss->pos + chunk == TEST_STRING_LEN)))
	return -1;

其中最后一个参数表示“这是最后一片”,源码注释也提醒:发送大帧时应按 MTU 量级切块,避免一次性灌入内核缓冲区。

QoS 语义与 ACK / RESEND

  • QoS0:没有 PUBACK。底层在最后一片发出后,于事件循环中本地合成 LWS_CALLBACK_MQTT_ACK(即示例状态机中的“合成 ack”);
  • QoS1:必须等待 broker 回 PUBACK 才触发 LWS_CALLBACK_MQTT_ACK;
  • 若等待 PUBACK/PUBREC 超时,底层改发 LWS_CALLBACK_MQTT_RESEND,要求应用按回调携带的 packet ID 重发。示例实现为重试 3 次:if (++pss->retries == 3) lws_default_loop_exit(context);(minimal-mqtt-client.c)。

这也解释了状态机的设计:QoS0 的“ack”是本地合成的(STATE_WAIT_ACK0 几乎立即通过),QoS1 的“ack”是真实的 PUBACK(STATE_WAIT_ACK1 依赖网络往返或超时重发)。LWS_CALLBACK_MQTT_ACK 返回非零可关闭 wsi。

收包处理

LWS_CALLBACK_MQTT_CLIENT_RX 的 in 指向 lws_mqtt_publish_param_t *,其中 topic/topic_len 与 payload/payload_len 描述收到的消息,示例直接以十六进制转储输出:

pub = (lws_mqtt_publish_param_t *)in;
lwsl_hexdump_notice(pub->topic, pub->topic_len);
lwsl_hexdump_notice(pub->payload, pub->payload_len);

底层机制佐证:日志背后的实现

示例运行日志中的两行恰好对应底层两个关键机制,可据此加深理解:

  1. lws_mqtt_generate_id: User space provided a client ID 'lwsMqttClient' 来自 client-mqtt.c。若应用未提供 client_id,同一函数会用 lws_get_random() 生成 23 字符随机 ID(字符集为 0-9a-zA-Z,共 62 字符)。

  2. _lws_mqtt_rx_parser: migrated nwsi ... to sid 1 ... 来自 mqtt.c。libwebsockets 的 MQTT 客户端连接存在 nwsi(网络 wsi)与 sid 1 子 wsi 的迁移:CONNECT 阶段由网络连接承载,CONNACK 成功后,连接状态(含客户端 ID、用户空间、解析器)整体移交到新的“sid 1”子连接,随后通过 lws_mqtt_set_client_established() 进入已建立状态并触发 LWS_CALLBACK_MQTT_CLIENT_ESTABLISHED。这一设计与多路复用架构一致——minimal-mqtt-client-multi 正是利用该机制让多条独立连接共享同一条 TCP/TLS 链路。

  3. Keep Alive 与 PINGREQ 连接参数中的 keep_alive = 60 由 retry 策略(minimal-mqtt-client.c)协同驱动:空闲 20 秒发送 PINGREQ,25 秒仍无有效活动则视为挂死。PINGREQ/PINGRESP 的解析与完成处理位于 mqtt.c 及报文类型表(LMQCP_CTOS_PINGREQ / LMQCP_STOC_PINGRESP),保证长时间静默时连接仍能被探测是否存活。

TLS 模式与证书

使用 -s 时,示例以 LCCSCF_USE_SSL | LCCSCF_ALLOW_SELFSIGNED 连接 8883 端口。源码中还有一段条件编译值得注意(minimal-mqtt-client.c):

#if defined(LWS_WITH_MBEDTLS) || defined(USE_WOLFSSL)
	/*
	 * OpenSSL uses the system trust store.  mbedTLS has to be told which
	 * CA to trust explicitly.
	 */
	info.client_ssl_ca_filepath = "./mosq-ca.crt";
#endif

即:OpenSSL 后端默认使用系统信任库,而 mbedTLS/WolfSSL 后端必须显式指定 CA 文件,这里指向示例自带的 mosq-ca.crt。使用 TLS 模式前,请确保 broker 的 8883 端口已配置好对应的 mosq-server.crt 与 mosq-server.key。

从示例到实战:如何改造成自己的客户端

基于该示例扩展时,通常只需要修改四处:

  1. 连接参数:替换 client_connect_param 中的 client_id、用户名密码、keep_alive、遗嘱主题,并接入你自己的 broker 地址(修改 connect_client() 中的 i.address / i.port);
  2. 主题表:按需增删 topics[] 与 sub_param,注意单次订阅不超过 LWS_MQTT_MAX_TOPICS(8 个);
  3. 发布内容:替换 test_string 与 TEST_STRING_LEN,并按实际 MTU 调整分块大小(示例为 300 字节/片);
  4. 回调业务:在 LWS_CALLBACK_MQTT_CLIENT_RX 中解析 pub->payload 做真正的业务处理;pss 状态机可按需扩充。

需要理解多连接复用、共享单条 TCP/TLS 传输时,可参考同目录的 minimal-mqtt-client-multi;涉及 QoS2 事务完整流程可参考 minimal-mqtt-client-qos2。完整的 MQTT API 定义(连接/发布/订阅参数、QoS 枚举、控制报文类型、属性与原因码)均在 include/libwebsockets/lws-mqtt.h 中,是进一步开发的权威参考。

小结

通过本文,我们完成了 libwebsockets MQTT 客户端的最小实践闭环:cmake . && make 构建 → 启动 mosquitto → 运行客户端观察 CONNECT/SUBSCRIBE/PUBLISH 日志 → 用 mosquitto_pub 反向验证收包。在此基础上,文中结合 minimal-mqtt-client.c、lws-mqtt.h 与 lib/sansio/mqtt 源码,解释了 client ID 生成、nwsi 到 sid 1 的迁移、QoS0 本地合成 ACK 与 QoS1 真实 PUBACK 的区别、超时重发、keep-alive 心跳以及 mbedTLS 需显式指定 CA 等关键机制,为在 libwebsockets 上开发生产级 MQTT 客户端提供了可复用的起点。

登录后查看全文
libwebsockets