在 Zephyr RTOS 上运行 NanoMQ:让 MQTT Broker 直接跑在 MCU 上

简介
MQTT 和 Zephyr 最常见的组合方式在客户端一侧:基于 Zephyr 的设备作为 MQTT 客户端,连接远端的 Broker。那么,Broker 本身能不能直接跑在 MCU 上?
在一些边缘场景中,专门部署一台 Linux 网关会带来不必要的复杂度,这时这种做法就能派上用场。例如,几十台现场设备通过 Modbus、串口或私有协议通信,需要统一汇聚到 MQTT;又如网络不稳定,需要先在本地缓存数据,等连接恢复后再上传。常规做法是在设备旁边部署一台 Linux 网关,但额外的功耗、维护和物理防护成本都不低。把 Broker 直接运行在 MCU 上,就可以省掉这一层。
NanoMQ 是 EMQ 开源的边缘 MQTT Broker,最初为 Linux 和其他 POSIX 环境设计。本文探讨在 Zephyr RTOS 上运行 NanoMQ Broker 核心需要做哪些工作。应用层使用同一套 nanomq/ 源码和 NanoNNG MQTT 协议栈,没有裁剪功能。受 Zephyr 平台的约束,部分外围功能已关闭或尚未验证;完整的差异和限制列表见附录 A。
仓库中包含两个示例:一个运行在真实的 ESP32-S3 硬件上,另一个运行在 qemu_x86 仿真环境中。两个示例的对比和选择建议见附录 B。
本文接下来依次介绍环境准备、编译与部署、功能测试,以及移植过程本身,包括移植中遇到的框架层面的问题。
Zephyr 简介
Zephyr 是由 Linux 基金会托管的开源实时操作系统(RTOS),面向资源受限的嵌入式设备。
Zephyr 有四个特性与本次移植关系密切:
- 可配置、可伸缩。 系统功能通过 Kconfig 选项开启,最小配置可以压缩到几十 KB。因此同一套代码既能用在简单的传感器节点上,也能用在边缘网关上。
- 用 devicetree 描述硬件。 开发板之间的差异通过 devicetree 和 board overlay 定义,应用代码与底层硬件无关。正因如此,本次移植可以用同一份应用源码同时编译
qemu_x86和 ESP32-S3 两个目标。 - 内置网络协议栈。 Zephyr 提供 TCP/IP、兼容 BSD 的 socket 层以及 TLS。NanoNNG 的 Zephyr 平台适配层就构建在这套 socket API 之上。
- POSIX API 子集。 Zephyr 通过
CONFIG_POSIX_API支持 pthreads、文件描述符等接口,原本为 POSIX 环境设计的中间件因此也能移植过来。
本文基于 Zephyr 4.4(11a87708d41)。
NanoMQ 简介
NanoMQ 是 EMQ 开源的 MQTT Broker,也是 LF Edge 旗下的项目,定位是面向物联网边缘环境的超轻量、高性能 MQTT Broker。EMQX 面向云端部署,NanoMQ 则面向嵌入式系统和工业现场。
架构
NanoMQ 采用分层架构:
- 平台适配层: 识别底层硬件和操作系统,向上层提供可移植的 API,避免依赖特定平台。本文通过新增一个平台适配层来支持 Zephyr,依靠的正是这一层设计。
- 任务层: 采用 Actor 模型和线程级并行,支持在 SMP 系统上扩展。
- 传输层: 通过 pipe 和 client 管理 TCP 和 UDP 流,使用零拷贝机制减少内存占用。
- 协议层: 把字节流解析为 MQTT 报文,生成事件,并管理飞行窗口。
- 应用层: 集成规则引擎和全局主题树,通过无锁状态机对外提供 MQTT 消息和事件。
实现
NanoMQ 完全用 C 语言实现,没有外部运行时依赖,适合交叉编译到各种目标开发板。
它的异步 I/O 和 Actor 架构构建在 NanoMQ 团队维护的 NNG 派生版本(NanoNNG)之上,这个版本扩展了 MQTT 协议支持,并加入了 nanolib 工具库。
在协议层面,NanoMQ 完整支持 MQTT 3.1.1、MQTT 3.1 和 MQTT 5.0,包括 QoS 0/1/2、保留消息、遗嘱消息、共享订阅、主题别名等 MQTT 特性。
环境准备
前置条件
需要准备以下内容:
- Zephyr 4.4+ 的 west 工作区,以及配套的 Python 虚拟环境。这个 venv 里同时装有
west本身和 Zephyr 的 Python 依赖。 venv 放在哪里由你决定:Zephyr 官方文档使用工作区根目录下的.venv/,本文验证所用的环境则放在~/zephyr-venv。激活后,west就在PATH中:source <venv>/bin/activate # ~/zephyr-venv in this article export ZEPHYR_SDK_INSTALL_DIR=$HOME/zephyr-sdk-1.0.1 - Zephyr SDK 1.0.x。需要单独下载并解压 SDK,然后把
ZEPHYR_SDK_INSTALL_DIR设为它的安装目录。 - 如果要运行 ESP32-S3 硬件示例,还需要 ESP-IDF 环境(见下一节)。只编译
qemu_x86则不需要。 - 功能测试需要额外的 Python 依赖和 Mosquitto 客户端,详见“功能测试”一节。
验证所用的版本固定如下。e1000 补丁针对的是对应的 Zephyr 源码版本,升级 Zephyr 后需要重新验证。
| 组件 | 版本 / Commit |
|---|---|
| Zephyr | 4.4,11a87708d41(e1000 补丁适用于该版本) |
| Zephyr SDK | 1.0.1 |
| NanoMQ | 0.25.6(基于上游 master) |
NanoNNG(nng/ 子模块) | 基于上游 main,加上 Zephyr 平台适配层。zephyr-rtos 分支提交了子模块的 gitlink,因此 git clone --recursive 会检出完整的平台适配层 |
如果还没有创建 Zephyr 工作区,可以执行下面的命令(这些命令把 venv 放在 ~/zephyr-venv,也就是本文验证环境使用的位置;放在其他位置,或按官方文档的建议放在 <workspace>/.venv,同样可行):
python3 -m venv ~/zephyr-venv
source ~/zephyr-venv/bin/activate
pip install west
west init ~/zephyrproject
cd ~/zephyrproject
west update
pip install -r zephyr/scripts/requirements.txt
ESP32-S3 开发环境
本节只适用于 ESP32-S3 硬件示例。如果只编译 qemu_x86,可以跳过本节,使用上一节的标准 Zephyr 环境。 硬件示例需要 Espressif 的 HAL blob 以及烧录和串口工具,但交叉编译器仍然来自 Zephyr SDK:1.0.1 已经包含该目标所需的 xtensa-espressif_esp32s3_zephyr-elf 工具链,ESP-IDF 不参与编译。
- 获取 ESP-IDF(它提供
west、esptool和idf-monitor)。以下两种安装方式任选其一:# a) Clone from git git clone --recursive https://github.com/espressif/esp-idf.git ~/esp/esp-idf cd ~/esp/esp-idf && ./install.sh esp32s3 # the activation script is ~/esp/esp-idf/export.sh # b) Install with the ESP-IDF Installation Manager (eim) # the activation script is ~/.espressif/tools/activate_idf_v<version>.sh - 每次编译前激活环境,并把
ZEPHYR_SDK_INSTALL_DIR指向 Zephyr SDK。
本文的验证环境在~/.bashrc中把这一步写成了 alias:alias esp-zephyr='source ~/.espressif/tools/activate_idf_v6.1.sh \ && export ZEPHYR_SDK_INSTALL_DIR=$HOME/zephyr-sdk-1.0.1'
每次编译前先运行esp-zephyr。展开后相当于:source ~/.espressif/tools/activate_idf_v6.1.sh # ~/esp/esp-idf/export.sh if installed from git export ZEPHYR_SDK_INSTALL_DIR=$HOME/zephyr-sdk-1.0.1 export ZEPHYR_TOOLCHAIN_VARIANT=zephyr
激活这个环境后,就用它替代上一节的 Zephyr venv:ESP-IDF 的 Python 环境自带west(以及python3),两者激活一个即可,以最后激活的为准。编译 ESP32 目标时用这个环境;如果只编译qemu_x86,用上一节的 Zephyr venv 即可,不需要安装 ESP-IDF。
激活 ESP-IDF 与使用哪个编译器无关,它只是把west、esptool(west flash的 ESP32 runner 会调用它)和idf-monitor加入PATH。编译器由ZEPHYR_SDK_INSTALL_DIR和ZEPHYR_TOOLCHAIN_VARIANT=zephyr决定,后者指向 Zephyr SDK 中对应的 Xtensa 工具链。不要把它设为espressif:当前的 Zephyr 版本已经移除了这个 variant,设置后 CMake 会去查找源码树中并不存在的文件。也可以完全不设置ZEPHYR_TOOLCHAIN_VARIANT,Zephyr 会自己找到 Zephyr SDK,但显式设置更不容易出错。 - 获取 Espressif HAL blob,在工作区中执行一次即可:
cd ~/zephyrproject west blobs fetch hal_espressif
不要跳过这一步。 缺少 blob 时,CONFIG_WIFI_ESP32会在没有任何提示的情况下变为不可用。编译照样成功,固件也能正常启动,但 Wi-Fi 无法使用,构建系统也不会给出任何警告。串口控制台上永远不会出现预期的wifi: connected信息。
各发行版的具体配置说明(包括系统依赖、SDK 安装、权限和常见错误)见 setup-fedora-zh.md。该指南以 Fedora 为例,使用其他发行版时请相应替换包管理器命令。
获取源码
cd ~/zephyrproject
git clone --recursive -b zephyr-rtos https://github.com/nanomq/nanomq.git
cd nanomq
--recursive 选项会同时拉取 nng/ 子模块,其中是带有 Zephyr 平台适配层的 NanoNNG 分支。zephyr-rtos 分支在仓库中提交了子模块的 gitlink,因此检出的版本与上表列出的 commit 一致。git clone --recursive 总是检出仓库记录的 gitlink;要切换到其他 commit,需要显式执行 checkout。
如果克隆时没有加 --recursive,可以用以下命令初始化子模块:
git submodule update --init --recursive
克隆完成后,用 git submodule status nng 检查子模块:如果输出的开头不是 - 也不是 +,说明子模块已经检出到仓库记录的 gitlink(- 表示尚未初始化,+ 表示检出的 commit 与记录的不一致)。
本文后续所有命令都在仓库根目录(~/zephyrproject/nanomq)下执行。
qemu_x86:Zephyr e1000 驱动补丁
qemu_x86 示例需要给 Zephyr 的 e1000 驱动打一个两行的补丁。这个补丁是必需的。 不打补丁时,Broker 能正常启动并打印 NanoMQ Broker is started successfully!,但任何客户端都无法建立连接。
问题的原因是 QEMU 的 e1000 设备模型在复位时会清零 RCTL 寄存器,其中包括 RCTL_BAM(Broadcast Accept Mode,bit 15)。真实硬件默认会置位这一位,设备模型却不会。
Zephyr 的 eth_e1000 驱动只写入 RCTL_EN | RCTL_MPE,从不设置 BAM。结果是设备模型会静默丢弃所有广播帧,ARP 解析因此失败,SLIRP 也就无法转发最初的 TCP 连接。截至 4.4(11a87708d41),上游 Zephyr 中仍然存在这个问题。
补丁要打在 Zephyr 源码树本身(工作区中的 zephyr/ 目录,而不是本仓库)。
把下面的 diff 保存为 zephyr-e1000-bam.patch,然后用以下命令应用:
cd ~/zephyrproject/zephyr
git apply /path/to/zephyr-e1000-bam.patch
diff --git a/drivers/ethernet/eth_e1000.c b/drivers/ethernet/eth_e1000.c
--- a/drivers/ethernet/eth_e1000.c
+++ b/drivers/ethernet/eth_e1000.c
@@ -329,7 +329,7 @@ static const struct ethernet_api e1000_api = {
\
irq_enable(DT_INST_IRQN(inst)); \
iow32(dev, CTRL, CTRL_SLU); /* Set link up */ \
- iow32(dev, RCTL, RCTL_EN | RCTL_MPE | DT_INST_PROP(inst, rdmts) << RDMTS_OFFSET); \
+ iow32(dev, RCTL, RCTL_EN | RCTL_MPE | RCTL_BAM | DT_INST_PROP(inst, rdmts) << RDMTS_OFFSET); \
iow32(dev, ITR, DT_INST_PROP(inst, itr) & (uint32_t)GENMASK(15, 0)); \
} \
\
diff --git a/drivers/ethernet/eth_e1000_priv.h b/drivers/ethernet/eth_e1000_priv.h
--- a/drivers/ethernet/eth_e1000_priv.h
+++ b/drivers/ethernet/eth_e1000_priv.h
@@ -27,6 +27,7 @@ extern "C" {
#define IMS_RXT0 (1 << 7) /* Receiver Timer */
#define RCTL_MPE (1 << 4) /* Multicast Promiscuous Enabled */
+#define RCTL_BAM (1 << 15) /* Broadcast Accept Mode */
#define TDESC_EOP (1) /* End Of Packet */
#define TDESC_RS (1 << 3) /* Report Status */
注意,RCTL_MPE 的含义是 Multicast Promiscuous Enabled(bit 4),只影响组播接收,并不会开启广播接收,所以驱动现有的设置不能代替 BAM。也正是因为这一区别,这个问题长期没有被发现。
这个补丁只影响 QEMU 模拟的网卡。ESP32-S3 硬件示例不需要这个补丁。
端口与服务
在本文验证过的配置下,两个示例开放相同的 MQTT、REST 和 WebSocket 端口。对于 qemu_x86,端口实际能否访问取决于下一节介绍的端口转发配置。
| 服务 | 端口 | ESP32-S3 | qemu_x86 | 认证 / 加密 |
|---|---|---|---|---|
| MQTT over TCP | 1883 | 支持 | 支持 | 无认证,无 TLS |
| REST API | 8081 | 支持 | 支持 | Basic 认证;凭据需要自行配置(没有可用的默认值),无 TLS |
| MQTT over WebSocket | 8083 | 支持 | 支持 | 无认证,无 TLS;路径为 /mqtt |
REST 监听器通过 Kconfig 选项 CONFIG_BROKER_REST_API 开启:main.c 根据它设置 http_server.enable 和 auth_type,而 conf_init 中的默认值是关闭。但只打开这个开关还不够:CONFIG_BROKER_REST_USER / CONFIG_BROKER_REST_PASS 默认都是空字符串,也就是说没有可用的默认凭据;上游公开的凭据 admin / public 同样会被拒绝。只有把两者都设为你自己的凭据,8081 端口才会监听;否则 Broker 会在启动时打印一行说明,并保持该端口关闭。如果你自己从配置中去掉这个开关,8081 端口同样不会有服务监听,客户端会收到 RST。这是预期行为,并不是故障。
安全注意事项
两个示例的默认配置都假定运行在受控的实验环境中。不加修改直接部署会带来安全风险。
| 项目 | 当前配置 |
|---|---|
| 监听地址 | MQTT nmq-tcp://0.0.0.0:1883、WebSocket nmq-ws://0.0.0.0:8083/mqtt 和 REST 0.0.0.0:8081 都监听所有网络接口 |
| REST 认证 | Basic 认证;凭据来自 Kconfig 选项 CONFIG_BROKER_REST_USER / _PASS,两者默认都是空字符串(没有可用的默认凭据);未设置这两项,或仍设为上游公开的 admin / public 时,8081 端口不会监听 |
| MQTT 认证 | 未启用,任何客户端都可以连接、发布和订阅 |
| 传输加密 | 无。NanoNNG 的 Zephyr 移植没有启用 TLS,因此 MQTT、WebSocket 和 REST 流量都以明文传输 |
在明文 HTTP 上,Basic 认证只是 Base64 编码,并不是加密。 因此,同一网络路径上的观察者可以还原出凭据。它能防止误访问,但挡不住有意的攻击。真正的访问控制仍然要靠网络边界。
在实验网络以外使用示例之前,至少要做到:把监听地址限制在实际需要的网络接口上;设置你自己的 REST 凭据(公开的 admin / public 组合现在会被拒绝);把管理接口放在可信网络之后,或放在额外的 TLS 代理之后。至于 MQTT 认证:如果这个移植版本的认证路径已经验证过,可以直接启用;否则应在链路层或 TLS 代理处实施访问控制。不要以为通用版 NanoMQ 的认证设置可以直接用在这些示例的嵌入式配置上(两者的配置来源不同,见附录 A)。不要把示例的默认配置暴露在公网或不可信网络中。
编译与运行:ESP32-S3 硬件示例
1. 配置本地凭据(Wi-Fi 与 REST)
将 local.conf.example 复制为 local.conf。该文件已列在 .gitignore 中,你的凭据不会提交到仓库。
cp demo/nanomq_zephyr_esp32s3/local.conf.example \
demo/nanomq_zephyr_esp32s3/local.conf
编辑 demo/nanomq_zephyr_esp32s3/local.conf,填入 SSID 和 PSK:
CONFIG_BROKER_WIFI_SSID="your-ssid"
CONFIG_BROKER_WIFI_PSK="your-passphrase"
# prj.conf already enables CONFIG_BROKER_REST_API, but 8081 only starts
# listening once these two are set (and are not the public admin/public);
# leaving them unset means the port stays closed.
CONFIG_BROKER_REST_USER="your-rest-user"
CONFIG_BROKER_REST_PASS="your-rest-passphrase"
2. 编译
EXTRA_CONF_FILE 的路径相对于应用目录解析:
west build -b esp32s3_devkitc/esp32s3/procpu -d build/esp32s3_nanomq \
demo/nanomq_zephyr_esp32s3 -- -DEXTRA_CONF_FILE=local.conf
本文验证的配置启用了 MQTT、REST 和 WebSocket,编译成功后会输出如下内存报告:
Memory region Used Size Region Size %age Used
FLASH: 961204 B 16776960 B 5.73%
iram0_0_seg: 54028 B 415492 B 13.00%
dram0_0_seg: 379640 B 399108 B 95.12%
irom0_0_seg: 686320 B 32 MB 2.05%
drom0_0_seg: 830132 B 32 MB 2.47%
ext_dram_seg: 5152752 B 32 MB 15.36%
...
Successfully created ESP32-S3 image.
其中有两个数字尤其重要:
dram0_0_seg对应内部 SRAM,使用率已达 95.12%。这是最紧张的内存区域,也是要把 Broker 堆和静态内存池移到 PSRAM 的原因;ext_dram_seg显示 5,152,752 B(≈5.2 MB),这是实际放入外部 PSRAM 的数据量,占该区域的 15.36%。这个数字与该区域 32 MB 的大小、模组 16 MB 的容量之间是什么关系,见下文关于开发板。
3. 烧录固件
通过 USB 将开发板连接到主机。开发板可能使用板载 USB-UART 桥接芯片,也可能使用 ESP32-S3 原生的 USB-JTAG/Serial 接口,因此设备会显示为以下两种之一:
/dev/ttyUSB*:独立的 USB-UART 桥接芯片,例如 CP2102 或 CH34x/dev/ttyACM*:原生 USB CDC,包括 ESP32-S3 的 USB-JTAG/Serial 接口
本文以 /dev/ttyUSB0 为例。请在你的系统上查看实际的设备名:
ls /dev/ttyUSB* /dev/ttyACM*
# Or check which device node appears when you connect the board
dmesg | tail -5
如果烧录时因权限不足而失败,把当前用户加入设备所属的用户组,然后重新登录使更改生效:
ls -l /dev/ttyUSB0 # The fourth column shows the group
sudo usermod -aG <group> "$USER"
这个用户组在 Debian/Ubuntu 和 Fedora 上通常是 dialout,在 Arch 上是 uucp。请始终以 ls -l 显示的用户组为准,不要按发行版的默认值去推测。
然后烧录固件,把 /dev/ttyUSB0 替换为实际的设备名:
west flash -d build/esp32s3_nanomq --runner esp32 --esp-device /dev/ttyUSB0
4. 查看串口控制台
波特率为 115200。可以使用 idf-monitor 或 miniterm。给 idf-monitor 传入 ELF 文件后,它可以解码二进制日志:
idf-monitor --port /dev/ttyUSB0 build/esp32s3_nanomq/zephyr/zephyr.elf
下面是一次实际运行的启动日志,其中省略了路径前缀,SSID 用占位符代替:
I (octal_psram): vendor id : 0x0d (AP)
I (octal_psram): density : 0x05 (128 Mbit)
I (esp_psram): Found 16MB PSRAM device
I (esp_psram): Speed: 80MHz
I (esp_psram): SPI SRAM memory test OK
*** Booting Zephyr OS build v4.4.0-4779-g11a87708d415 ***
wifi: connecting to "<your-ssid>" (attempt 1)
wifi: connected to "<your-ssid>"
wifi: connected — starting DHCPv4 client
wifi: IPv4 address assigned (DHCPv4)
sntp: ntp.aliyun.com: epoch=1789468030, realtime seeded
net: iface 0x3fc96fd8 dev=wifi up=1
net: ipv4 <board-ip>
2026-09-15 10:27:10 [0] INFO web_server.c:548 start_rest_server: http://0.0.0.0:8081/api/v4
2026-09-15 10:27:10 [0] WARN broker.c:1400 broker: NanoMQ (ver 0.25.6) Serving HTTP Server on http://(null):8081
NanoMQ Broker is started successfully!
只有在编译时配置了 REST 凭据(即第 1 步中的 local.conf),才会出现 start_rest_server 和 Serving HTTP Server 这两行。未配置时,这个位置只会显示一行 rest: REST API stays off - ...,8081 端口上也没有任何服务监听。
启动顺序为:PSRAM 检测与内存测试 → Zephyr 启动 → Wi-Fi 关联 → DHCP 分配地址 → SNTP 校时 → Broker 初始化。最后一行出现 NanoMQ Broker is started successfully! 时,Broker 即已就绪。
5. 验证连接
Broker 就绪后,在同一网络中的主机上执行以下检查。先测试 REST API(凭据就是你在 local.conf 中设置的那一组;如果没有设置,8081 端口上没有服务监听,curl 会直接连接失败,这是正常现象):
curl -u <rest-user>:<rest-pass> http://<board-ip>:8081/api/v4/brokers/
测试 MQTT 消息收发时,在任意终端中用 Mosquitto 客户端订阅:
mosquitto_sub -h <board-ip> -t 'test/#' -v
在另一个终端中发布一条消息:
mosquitto_pub -h <board-ip> -t 'test/hello' -m 'hello from mosquitto' -q 1
订阅端应收到:test/hello hello from mosquitto
关于开发板
本示例在 ESP32-S3-LCD-EV-Board 上验证,使用 ESP32-S3-WROOM-1-N16R16V 模组(16 MB Flash 和 16 MB 八线 PSRAM)。板级配置沿用 esp32s3_devkitc/esp32s3/procpu 的 SoC 定义。模组配置上的差异体现在两处:boards/esp32s3_devkitc_procpu.overlay(Flash 与 PSRAM 容量)和 prj.conf 中的 CONFIG_SPIRAM_MODE_OCT(八线模式)。如果你的模组不是 N16R16V,这两处都必须修改。八线(Octal)和四线(Quad)PSRAM 的配置不能只改一个参数就互换:使用四线 PSRAM 的模组还需要把 CONFIG_SPIRAM_MODE_OCT 换成 CONFIG_SPIRAM_MODE_QUAD。
链接报告显示:FLASH 961,204 B,内部 SRAM 379,640 B / 399,108 B(95.12%),放入外部 PSRAM 的数据为 5,152,752 B。
最后这个数字(约 5.2 MB)很容易被误读为 PSRAM 的容量或其地址窗口的大小,所以有必要说清这三个值之间的关系。模组的 PSRAM 物理容量是 16 MB(CONFIG_ESP_SPIRAM_SIZE)。链接脚本把 ext_dram_seg 声明为一个 32 MB 的区域,这是它与 drom0_0_seg 共用的 DCACHE0 地址窗口,只是地址空间的上限,并不代表可用容量。链接报告中的 5,152,752 B(≈5.2 MB) 是该区域的 Used Size,即实际放入 PSRAM 的数据量。其中包括 CONFIG_ESP_SPIRAM_HEAP_SIZE 指定的 4 MB Broker 堆,以及从内部 SRAM 移出的静态内存池,例如 Wi-Fi 驱动的 .noinit 段、net_buf 内存池和 Zephyr POSIX 对象池。也就是说,5.2 MB 既不是 PSRAM 的容量,也不是它的映射窗口大小。
时钟。开发板上没有 RTC。DHCP 完成后,main.c 会执行一次 SNTP 同步,为 CLOCK_REALTIME 设置初始时间:最多把两台公共 SNTP 服务器轮流查询两遍,任意一台响应即返回。因此日志中的时间戳是真实的 UTC 时间。这一步只是尽力而为:如果没有服务器响应,Broker 仍会正常启动,只是时间戳停留在 Unix 纪元(每次查询 2 秒,两遍之间间隔 1 秒,所以最坏情况下启动时间会增加约 9 秒)。
编译与运行:qemu_x86
1. 编译
打上 e1000 补丁后执行以下命令(先激活“环境准备 → 前置条件”中的 Zephyr 工作区 venv,确保 west 可用;不需要 ESP-IDF):
west build -b qemu_x86 -d build/nanomq_zephyr_qemu_x86 demo/nanomq_zephyr_qemu_x86
编译成功时,输出的结尾类似这样:
[289/290] Building C object zephyr/CMakeFiles/zephyr_final.dir/misc/empty_file.c.obj
[290/290] Linking C executable zephyr/zephyr.elf
Memory region Used Size Region Size %age Used
RAM: 2608020 B 31 MB 8.02%
IDT_LIST: 0 B 2 KB 0.00%
Generating files from build/nanomq_zephyr_qemu_x86/zephyr/zephyr.elf for board: qemu_x86/atom
(这个数值对代码布局很敏感:代码改动几十个字节,就可能让某个段跨过页边界,使报告的数值整整变化一个 4 KB 的页。这部分是对齐填充,并不是实际增长。)
结尾显示的 qemu_x86/atom 是完整的板卡名称。atom 是 qemu_x86 的 SoC 限定符,所用的板卡/SoC 限定机制与 ESP32-S3 一侧的 esp32s3_devkitc/esp32s3/procpu 相同。这并不表示 -b qemu_x86 选错了板卡。
2. 运行
推荐的做法是在后台启动 QEMU,并把串口控制台的输出写入文件,这样终端可以留给后面的验证步骤使用:
qemu-system-i386 -m 32 -cpu qemu32,+nx,+pae,sse,sse2,pni -machine q35 \
-device isa-debug-exit,iobase=0xf4,iosize=0x04 -no-reboot -machine acpi=off \
-serial file:/tmp/qemu.log -display none \
-netdev user,id=n1,hostfwd=tcp:127.0.0.1:1883-:1883,hostfwd=tcp:127.0.0.1:8081-:8081,hostfwd=tcp:127.0.0.1:8083-:8083 \
-device e1000,netdev=n1 \
-kernel build/nanomq_zephyr_qemu_x86/zephyr/zephyr.elf &
端口转发通过 prj.conf 中的 CONFIG_NET_QEMU_USER_EXTRA_ARGS 配置,它把 1883/8081/8083 绑定到 127.0.0.1,与上面的手动命令一致。两者遵循同一条规则:谁运行 QEMU,谁就决定它的端口绑定到哪些网络接口。
也可以让 west 直接启动 QEMU:
west build -d build/nanomq_zephyr_qemu_x86 -t run
这种方式会自动使用 prj.conf 中的三个端口转发,但会把串口控制台接到 stdio 上,并占用当前终端,所以只适合用来观察启动过程。
重启前先清理旧实例。残留的 QEMU 会一直占用这三个端口,新实例会立即启动失败:
qemu-system-i386: ... Could not set up host forwarding rule 'tcp:127.0.0.1:8083-:8083'
-t run 尤其容易留下这种进程:停止它时,它启动的 QEMU 实例不一定会一起退出。清理时,按端口查出对应的 PID:
ss -ltnp | grep -E ':(1883|8081|8083)' | grep -oP 'pid=\K[0-9]+' | sort -u | xargs -r kill
qemu-system-i386 也可以由 west 自行找到(可以是 Zephyr SDK 的主机工具,也可以是 PATH 中由发行版软件包提供的版本,例如 Fedora 的 qemu-system-x86)。如果手动运行时报 command not found,请把 QEMU 所在的目录加入 PATH。
-m 32 选项为 QEMU 分配 32 MB 内存。Zephyr 的链接脚本会预留其中一部分,因此链接报告中显示的可用 RAM 约为 31 MB(本文使用的镜像占用约 2.5 MB,其中约 1 MB 是 malloc arena)。
3. 启动日志
以下是一次实际运行的启动日志(省略了部分 DEBUG 行和路径前缀):
*** Booting Zephyr OS build v4.4.0-4779-g11a87708d415 ***
rtc: CMOS clock 2026-09-15 10:27:23 UTC, realtime seeded
net: iface 0x19e94c dev=eth0 up=1
net: ipv4 10.0.2.15
2026-09-15 10:27:23 [0] DEBUG broker.c:1102 broker: db init finished
2026-09-15 10:27:23 [0] DEBUG broker.c:1111 broker: listener init finished
2026-09-15 10:27:23 [0] DEBUG broker.c:1128 broker: HTTP init finished
2026-09-15 10:27:23 [0] INFO web_server.c:548 start_rest_server: http://0.0.0.0:8081/api/v4
2026-09-15 10:27:23 [0] WARN broker.c:1400 broker: NanoMQ (ver 0.25.6) Serving HTTP Server on http://(null):8081
NanoMQ Broker is started successfully!
只有在编译时配置了凭据,才会出现那两行 REST 日志。
最后一行出现 NanoMQ Broker is started successfully! 时,Broker 即已就绪。之后可以验证 REST API(前提是编译时已通过 local.conf 设置了 CONFIG_BROKER_REST_USER / _PASS;否则 8081 端口上没有服务监听):
curl -u <rest-user>:<rest-pass> http://127.0.0.1:8081/api/v4/brokers/
关于 malloc arena
qemu_x86 启用了 MMU,而 Zephyr 的 libc 默认提供的 malloc arena 只有 16 KB。NNG 的 Zephyr 平台适配层用 malloc() 作为分配器,单个 pipe 的接收队列会占用 msq_len * 8 字节,可能超出这个上限。
因此 prj.conf 把 arena 增大到 1 MB。这样一来,镜像的内存占用中约有 1 MB 属于 malloc arena,不属于 Broker 本身。
功能测试
仓库附带一套功能测试,对两个示例运行同一组测试。运行测试前,先安装所需的 Python 依赖(运行器会检查这些依赖,缺失时报错退出):
python3 -m pip install paho-mqtt requests
系统中还需要安装 Mosquitto 命令行客户端(mosquitto_pub 和 mosquitto_sub)。在 Debian/Ubuntu 上安装 mosquitto-clients 软件包;在 Fedora 上安装 mosquitto。
--list 会列出所有测试组(本文涉及其中 8 个,见下表):
python3 demo/nanomq_zephyr_qemu_x86/function_test.py --list
对于正在运行的硬件示例,用 --no-manage 告诉运行器 Broker 已在运行,不需要由运行器管理:
python3 demo/nanomq_zephyr_qemu_x86/function_test.py --no-manage --addr <board-ip>
也可以只运行指定的测试组:
python3 demo/nanomq_zephyr_qemu_x86/function_test.py --no-manage --addr <board-ip> \
--group mqtt_v311 --group rest_get
以下结果在 ESP32-S3 硬件示例上得到,用 --group 逐个运行下表中的 8 个测试组。为便于阅读,省略了路径前缀。
========================================================================
Zephyr broker functional test suite — broker <board-ip>:1883
groups: mqtt_v311, mqtt_v5, rest_get, ws_v311, ws_v5, capacity, ws_abort, survival
========================================================================
--no-manage: assuming a broker is already running; leftover mosquitto clients are NOT cleaned up
broker connect RTT 16.1 ms -> time-scale 4.0 (auto)
[1/8] mqtt_v311 PASS (120.1s)
[2/8] mqtt_v5 PASS (211.3s)
[3/8] rest_get PASS (2.9s)
[4/8] ws_v311 PASS (279.0s)
[5/8] ws_v5 PASS (15.9s)
[6/8] capacity PASS (13.1s)
[7/8] ws_abort PASS (31.9s)
[8/8] survival PASS (90.3s)
------------------------------------------------------------------------
RESULT: pass=8 fail=0
rest_get 测试组还要求被测 Broker 在编译时配置了 REST 凭据(见各示例的 local.conf.example)。未配置时,该测试组会报告为 SKIP,不算失败:
| [worker rest_get] SKIP: no REST API on <addr>:8081 — the demos leave it off
| until CONFIG_BROKER_REST_USER/PASS are set (local.conf); pass
| --rest-user/--rest-pass for a broker that has them
也就是说,测试套件会区分“REST 未启用”和“REST 有故障”:前者会跳过并给出说明,只有后者才判为失败。Broker 配置好凭据后,用 --rest-user / --rest-pass 把同一组凭据传给测试套件,该测试组即可正常运行。
硬件示例上各测试组的覆盖范围和运行时间如下:
| 测试组 | 覆盖范围 | 硬件运行时间 |
|---|---|---|
mqtt_v311 | 会话、保留消息,以及 v4/v5 互通 | 120.1s |
mqtt_v5 | 会话过期、用户属性、$share 和主题别名 | 211.3s |
rest_get | 所有 REST GET 路由 | 2.9s |
ws_v311 | 基于 WebSocket 的 MQTT 3.1.1 | 279.0s |
ws_v5 | 基于 WebSocket 的 MQTT 5 | 15.9s |
capacity | 12 个并发 CONNECT + QoS 1 回环 | 13.1s |
ws_abort | WebSocket 连接异常中断时连接池的稳定性 | 31.9s |
survival | 基于上游 attack.py 缩小规模的负载与会话反复建连测试 | 90.3s |
运行器会自动调整参数:它先测量到 Broker 的 TCP 往返时间(本地回环低于 1 ms,真实硬件上经 Wi-Fi 为几十到几百毫秒;本次运行为 16.1 ms),判断 Broker 不在本地后,默认切换到 --time-scale 4,把 CI 脚本按本地网络延迟设定的 sleep 间隔拉长。
移植过程
NanoMQ 的分层本身就包含平台适配层(nng 的 nni_plat_*),所以大部分移植工作是为 Zephyr 补全这一层。真正的硬性障碍反而在应用层:nanomq/ 中有几处直接调用 POSIX,没有办法绕开。下面逐层说明。
1. 应用层:裁剪 POSIX 依赖
应用层的 POSIX 依赖集中在四处,全部用 __ZEPHYR__ 条件编译处理,没有另加一层抽象:
| 文件 | 原有的 POSIX 依赖 | 处理方式 |
|---|---|---|
apps/broker.c | 用 signal() / sigaction 安装信号处理函数 | 整段跳过。Zephyr 没有 POSIX 信号语义;嵌入式 Broker 的主循环按自身的条件退出,不靠 ^C 终止 |
nanomq.c | #include <sys/ptrace.h> | 条件编译。check_trace() 只在 CLI 路径上调用,嵌入式 Broker 不会走这条路径 |
mqtt_api.c | nng_access(dir, W_OK)(检查文件日志目录是否可写) | 条件编译。picolibc 没有 W_OK;嵌入式环境下文件日志后端始终关闭,跳过这项检查没有副作用 |
process.c(整个编译单元) | fork / kill / chdir, <paths.h> | 不参与编译;示例提供 process_stub.c,补上它的公开符号(全部返回 -1) |
process_stub.c 之所以能这样“假装”实现,是因为这五个符号要么根本没有调用点,要么只会在永远不会执行的路径上被调用:process_daemonize() 的两处调用都受 daemon == true 判断保护,process_send_signal() 只被 CLI 路径上的 check_trace() 调用,而 process_is_alive() / pidgrp_send_signal() / process_create_child() 在整个应用中都没有调用点。嵌入式 Broker 调用 broker() 和 conf_init() 时,daemon 默认为 false,所以这些路径一条都不会走到——桩实现的实际作用只是让链接成功。
2. 平台适配层:接口清单与 POSIX 差异
这一层是 nng 的 Zephyr 实现(src/platform/zephyr/,共 22 个文件,其中 18 个是 .c:16 个实现下面列出的各个接口族,另有 2 个桩实现 zephyr_peerid.c 和 zephyr_socketpair.c,对应表中两项不支持的功能)。代码按 nng 的接口族组织,每个接口族映射到 Zephyr 中的相应机制:
接口清单
| 接口族 | 实现文件 | 对应的 POSIX 机制 | Zephyr 实现 |
|---|---|---|---|
| 内存 | zephyr_alloc.c | malloc / free | PSRAM 上的 k_heap,或 libc 的 malloc()(视目标而定) |
| 时钟与休眠 | zephyr_clock.c | clock_gettime / nanosleep | k_uptime_get(),加上 Zephyr 的 POSIX nanosleep() |
| 线程与同步 | zephyr_thread.c | pthread_*(互斥锁 / 读写锁 / 条件变量 / 线程) | Zephyr 的 pthread 实现(CONFIG_POSIX_API) |
| 原子操作 | zephyr_atomic.c | C11 <stdatomic.h> | 原生 64 位原子操作,或基于内嵌互斥锁的回退实现 |
| 调试与错误码 | zephyr_debug.c | abort / printf / strerror / errno | printk、libc,以及 errno ↔ nng 错误码的映射 |
| TCP(客户端) | zephyr_tcpdial.c, zephyr_tcpconn.c | socket / connect / setsockopt | Zephyr 网络栈提供的 BSD 兼容 socket |
| TCP(服务端) | zephyr_tcplisten.c | socket / bind / listen / accept | 同上 |
| UDP | zephyr_udp.c | socket / sendmsg / recvmsg | 同上 |
| 描述符与轮询队列 | zephyr_pollq_poll.c, zephyr_sockfd.c | poll()(POSIX 上还会在此之上叠加 epoll / kqueue / eventfd) | Zephyr 的 poll() |
| Socket 地址转换 | zephyr_sockaddr.c | sockaddr 与 nng 结构体之间的转换 | Zephyr 的 sockaddr(去掉了 AF_UNIX) |
| DNS 解析 | zephyr_resolv_gai.c | getaddrinfo() | Zephyr 有这个接口,但语义不同(见下表) |
| 随机数 | zephyr_rand_urandom.c | /dev/urandom, getrandom() | 按是否有硬件熵源分别处理(见下表) |
| 文件 | zephyr_file.c | stdio、stat、文件锁 | stdio 和 stat 是真实实现;临时目录和文件锁是回退实现(见下表) |
| 唤醒管道 | zephyr_pipe.c | pipe() / eventfd() | Zephyr 没有对应的机制(见下表) |
POSIX 假设不成立的地方
| POSIX 假设 | Zephyr 现状 | 处理方式 |
|---|---|---|
readv() / writev() | 不可用,也没有 <sys/uio.h> | 用 read() / write() 循环模拟,并明确保留短传输语义(细节 1) |
pipe() / eventfd() | 不可用 | 轮询队列不注册唤醒描述符,改为靠自身的超时向前推进 |
flock() / lockf() | 没有文件锁 | 沿用 posix_file.c 自带的嵌入式回退做法:返回成功,但不实际加锁 |
| 临时目录 / 工作目录 | 两个概念都不存在 | nni_plat_temp_dir() 返回 "/tmp"(那里是否真的挂载了文件系统,由调用方自己的文件操作反映出来);nni_plat_getcwd() 返回 NULL |
SO_PEERCRED 等对端凭据 | 不可用 | 返回 NNG_ENOTSUP。对端凭据只用于 AF_UNIX IPC 传输,而 Zephyr 上没有这种传输 |
socketpair()(AF_UNIX 域 socket 对) | 没有 AF_UNIX | 返回 NNG_ENOTSUP;依赖它的 IPC 传输因此在 Zephyr 上不可用(见附录 A) |
/dev/urandom | 没有这个设备 | 有硬件熵源的目标(ESP32-S3 TRNG)使用 sys_csrand_get();没有硬件熵源的目标回退到软件生成器,代码中注明它不适合用于机密数据 |
| 64 位原子操作 | 32 位非 x86 目标没有原生支持 | 回退到“基于内嵌互斥锁的原子操作”(细节 2) |
SO_BINDTODEVICE | 不支持绑定网络接口 | 在设置选项时直接返回 NNG_ENOTSUP,而不是接受该选项后悄悄使用默认接口 |
getaddrinfo() | 有,但是同步调用 | 接受同步语义:解析在调用方线程上完成,不引入工作线程 |
pthread_condattr_setclock() | 依赖 CONFIG_POSIX_CLOCK_SELECTION | 启动时校验;单调时钟不可用时进程直接终止——否则所有带超时的等待都会悄无声息地立即超时 |
细节 1:readv / writev 的短传输语义。简单地“循环到所有 iovec 都写完为止”是错误的。POSIX 的 readv / writev 在第一次出现短传输时就返回,报告已传输的字节数,剩下的部分由调用方重新提交。模拟实现如果越过一个没传完的 iovec 继续往下传,字节流里就会出现空洞;如果在已经成功传输一部分、随后遇到 EAGAIN 时返回 -1,调用方会以为什么都没发出去,于是把这些数据再发一遍。正确的做法是在第一次短传输处停下,返回累计的字节数——posix_sockfd.c 中的注释("we didn't send all the data, the caller will resubmit")就是调用方所依赖的约定。
细节 2:原子操作为什么不用 pthread 互斥锁。直观的做法是在每个原子变量里内嵌一个 pthread_mutex_t,但 Zephyr 的 pthread_mutex_init() 从固定大小的池中分配(posix_mutex_pool,按位图分配,池耗尽后返回 ENOMEM)。nng 中初始化原子变量的地方共有 26 处,只有 5 处有配对的 nni_atomic_fini*() 调用来归还槽位(pipe 上的三处、消息引用计数,以及 inproc pair),其余的都没有对应的释放调用。改用内核自己的 struct k_mutex 就没有这个问题:它直接内嵌在变量中,不占用池,也不需要释放。
细节 3:ENABLE_LOG 必须同时传给两侧。nanolib 的 conf.c 是否初始化日志后端、log_*() 是否编译成实际代码,都由 -DENABLE_LOG 决定,而且这个宏必须同时传给应用和 libnng。原因在构建系统:nng 的 CMake 只识别 NNG_* 形式的缓存变量,普通宏只能通过 CMAKE_C_FLAGS 传进去。只传给一侧时不会有任何提示——链接成功,Broker 也能运行,但一行日志都不会输出(conf->log.type 没有被初始化)。ACL_SUPP 也是如此,而且条件不满足时后果更危险:两侧定义不一致会导致 struct conf 的内存布局错位。
3. 编译期宏约定
| 宏 | 作用 | 不满足时 |
|---|---|---|
ENABLE_LOG | 让 nanolib 初始化日志后端,并把 log_*() 编译成实际代码 | 日志功能静默失效 |
ACL_SUPP | 让 struct conf 带上 ACL 字段(默认开启) | 两侧定义不一致 → 结构体布局错位 |
SUPP_NANO_LIB | 隐藏 nanomq.c 的 main(),同时保留 get_cache_argc/argv | 与示例自己的入口函数冲突 |
SUPP_SYSLOG | 故意不设置 | 会去找 syslog(),而 Zephyr 没有这个函数 |
4. 两个框架层面的陷阱
下面两个问题属于同一类:Zephyr API 的实际行为与预期不同,出错时又没有任何错误信息,排查成本很高。
1. 首个客户端连入时 PSRAM 堆损坏
ESP32-S3 有约 512 KB 内部 SRAM,其中约 416 KB 可用。Broker 的数据面需要几 MB 内存,因此必须使用 PSRAM。
Zephyr 提供了 shared_multi_heap 来管理多个堆区域,乍看很合适。但它不是线程安全的:底层的 sys_heap 在访问时没有加锁。
NanoNNG 会在多个线程中分配内存,包括 poller、taskq 工作线程,以及每个连接关联的 aio。结果就是第一个客户端连接建立时,堆就被破坏了。
解决方法是不用 shared_multi_heap_alloc(),改为在 PSRAM 窗口上使用带锁的普通 struct k_heap。不需要驻留在 SRAM 中的静态池也一并移到 PSRAM,包括 Wi-Fi 驱动的 .noinit 段、net_buf 池和 Zephyr POSIX 对象池(约 60 KB)。这样内部 SRAM 就留给了真正需要它的部分。
2. net_mgmt 回调掩码不是按位匹配的
Wi-Fi 连接过程需要监听多个事件。直觉上会把它们合并成一个掩码,只注册一个回调:
/* Incorrect: the two events use different layer codes */
net_mgmt_init_event_callback(&cb, handler,
NET_EVENT_WIFI_CONNECT_RESULT | NET_EVENT_IPV4_DHCP_BOUND);
net_mgmt_add_event_callback(&cb);
这样配置后,回调永远不会被触发,也不会产生任何日志或错误码。
原因在 mgmt_run_slist_callbacks()(subsys/net/ip/net_mgmt.c)中。它在判断回调是否匹配某个事件时,要求层代码(layer code)完全相等,而不是做按位与:
if (!(NET_MGMT_GET_LAYER(mgmt_event->event) ==
NET_MGMT_GET_LAYER(cb->event_mask)) ||
!(NET_MGMT_GET_LAYER_CODE(mgmt_event->event) ==
NET_MGMT_GET_LAYER_CODE(cb->event_mask)) || ...
把层代码不同的事件合并到一个掩码里,cb->event_mask 中的层代码就变成了两者的按位或,结果和哪个事件都匹配不上。以这两个事件为例,Wi-Fi 事件的层代码是 0x0D,IPv4 是 0x03,按位或得到 0x0F,与两者都不相等。
正确的做法是每个事件单独注册一个回调,处理函数可以共用:
/* Correct: one callback structure per event */
net_mgmt_init_event_callback(&wifi_cb, handler, NET_EVENT_WIFI_CONNECT_RESULT);
net_mgmt_add_event_callback(&wifi_cb);
net_mgmt_init_event_callback(&dhcp_cb, handler, NET_EVENT_IPV4_DHCP_BOUND);
net_mgmt_add_event_callback(&dhcp_cb);
总结
在 MCU 上运行 Broker 的价值在于省掉一台边缘网关:现场已经部署的开发板可以顺带运行一个 Broker,把异构设备统一接入 MQTT,并在网络中断时先在本地缓存消息。
适用场景
结合附录 A中的列表来看,当前实现适合在受控网络内做小规模的边缘汇聚和协议转换。选型前需要先确认以下几点:
- 目前不包含 TLS、磁盘持久化和 MQTT 侧认证(三者在附录 A 的“后续计划”一列中都标为计划支持),因此现阶段不适合用作公网或不可信网络中的 Broker;本文的示例默认监听所有网络接口,REST 虽然提供 Basic 认证,但没有加密,凭据也需要自行配置(没有可用的默认凭据)。详见“安全注意事项”。
- 没有文件系统,缓存的消息和持久会话在断电后会丢失。
- 并发能力受 Zephyr 线程配额限制。本文没有给出正式的吞吐量或并发基准测试数据;附录 A 引用的官方性能数据来自多核 POSIX 环境,不代表本示例的性能。
- 除
qemu_x86和 ESP32-S3 外,尚未在其他开发板上验证。
代码与示例
移植代码位于 nanomq/nanomq 的 zephyr-rtos 分支。示例已移到 nanomq/NanoMQ-Zephyr-Example 的 examples/ 目录下,各自的 README 写明了从零开始搭建并运行的完整流程。
如果你在其他开发板上验证了这个移植版本,或者遇到了新问题,欢迎到 NanoMQ 社区参与讨论。
附录 A:与通用版 NanoMQ 的差异
这个 Zephyr 移植版本不能等同替代通用版 NanoMQ。它在多个方面与通用版不同,针对具体场景评估这个移植版本时,需要考虑这些差异。
基线版本。 移植基于上游 master 分支,即 NanoMQ 0.25.6。
配置来源。 通用版 NanoMQ 从 nanomq.conf 读取配置。Zephyr 版本改用内嵌的最小配置:以 conf_init() 提供的内置默认值为基础,再由启动代码直接设置监听地址等关键字段,不经过配置文件解析器。conf 结构体及其语义保持不变,只是配置来源从文件变成了在内存中构造。
功能可用性。 外围功能(REST、规则引擎、桥接等)全部参与编译,在运行时控制开关,没有在编译期裁掉,这样同一份源码在各平台上的行为保持一致。下表列出此版本中不可用或未经验证的能力,并区分两类情况:尚未完成的工作(计划支持),以及受平台或依赖限制(暂无计划):
| 能力 | 现状 | 后续计划 |
|---|---|---|
| 文件系统相关 | 配置、日志和持久会话不写入磁盘;$SYS 只反映运行时状态 | 支持:只需挂载 Zephyr 的 fs 子系统(fat / ext2 / fcb 等) |
| SQLite 持久化 | 未包含(NNG_ENABLE_SQLITE=OFF) | 支持:随文件系统一起启用 |
| Parquet | 未包含。由 SUPP_PARQUET 宏控制(不是 CMake 选项),未定义时不会编译进来 | 支持:同上,依赖文件系统 |
| TLS | 未包含(NNG_ENABLE_TLS=OFF)。Zephyr 提供 mbedTLS(本次编译已启用)和 tls_credentials 子系统 | 支持:启用后即可使用 MQTTS / WSS / HTTPS |
| MQTT 侧认证(密码 / ACL) | 既未接入,也未验证;目前任何客户端都能连接 | 支持:只需接入配置并完成验证 |
| 规则引擎 | 内嵌配置中没有对应的开关路径;未验证 | 支持:纯软件模块,只差配置接入 |
| IPC 传输 | 示例中已关闭(main.c 中设置 ipc_internal = false,这是运行时赋值,不是编译选项),因此 nanomq ctl 管理通道不可用 | 待平台支持:需要具名 AF_UNIX,而 Zephyr 目前只提供匿名 socketpair |
| QUIC | 未包含(NNG_ENABLE_QUIC=OFF)。nng 的 QUIC 实现依赖 msquic | 暂无计划:这些依赖的体积不适合嵌入式目标 |
线程模型。 Linux 版本依赖完整的 POSIX 动态线程池。在 Zephyr 上,NanoNNG 的线程数是固定的(taskq = 2 / poller = 1 / expire = 1),再加上 Zephyr POSIX 线程池的上限 CONFIG_POSIX_THREAD_THREADS_MAX=16,且每个 pthread 的栈由 CONFIG_DYNAMIC_THREAD_STACK_SIZE 固定为 16 KB,Broker 的并发能力会相应受限。
性能数据。 NanoMQ 官方公布的数据包括:最小功能集下启动占用不到 200 KB;吞吐量最高可达百万级 TPS;在多核 CPU 上性能最高可达 Mosquitto 的 10 倍。需要强调这些数据的适用范围:它们来自多核 POSIX 环境下的基准测试,官方页面没有给出相应测试的硬件和配置细节,而且这些数据不代表本文示例的性能——Zephyr 端的线程配额远小于多核 Linux 系统(见上一条),实际的并发特性也不同。本文没有对示例做正式的性能测试。
内存分配。 不同目标使用的分配器不同:qemu_x86 使用 libc 的 malloc(),其 malloc arena 就是 Broker 的堆(见 qemu 一节);ESP32-S3 的内部 SRAM 放不下数据面,因此定义了 NNG_ZEPHYR_ALLOC_SMH,把 nng 的内存分配切换到 PSRAM 上的 k_heap(见“移植过程 → 4. 两个框架层面的陷阱”)。
时间来源。 两个示例都使用真实的 UTC 时间,但获取方式不同:qemu_x86 从 QEMU 的 CMOS RTC 获取初始时间,ESP32-S3 没有 RTC,通过 SNTP 获取初始时间。Zephyr 不包含时区数据库,因此显示的时间始终是 UTC。
移除的模块。 Broker 的 process.c 依赖 fork / kill / chdir,无法在 Zephyr 上编译;示例中用一个提供相同符号的桩实现替代它。这些符号要么只在守护进程和 CLI 路径中调用,要么根本没有被调用,因此嵌入式 Broker 不会执行到它们(见“移植过程 → 1. 应用层”中表格的对应条目)。
已验证平台。 qemu_x86(32 位)和 ESP32-S3。构建系统会为所有 32 位非 x86 目标(ARM、RISC-V、Xtensa 等)启用原子操作的回退实现(NNG_ZEPHYR_NO_STDATOMIC,见“移植过程 → 2. 平台适配层”中的细节 2)——ESP32-S3 本身就在使用它,所以这条路径已经包含在硬件验证中。尚未验证的是 ARM / RISC-V 开发板本身(后续计划:在 ARM / RISC-V 等开发板上验证,届时网络驱动、中断和内存预算都需要重新验证)。
附录 B:两个示例对比
demo/nanomq_zephyr_esp32s3 | demo/nanomq_zephyr_qemu_x86 | |
|---|---|---|
| 目标平台 | ESP32-S3 硬件 | qemu_x86 |
| 硬件要求 | ESP32-S3 开发板 | 无 |
| 网络 | Wi-Fi STA + DHCP | QEMU SLIRP 用户态网络 |
| 内存 / 存储 | 16 MB Flash + 八线 PSRAM(数据面位于 PSRAM) | 31 MB 模拟 RAM,无持久存储 |
| 额外前提 | Wi-Fi 凭据 | Zephyr e1000 驱动补丁 |
| 典型用途 | 实际场景评估和硬件测试 | 开发阶段的快速验证 |
两个示例共用同一份应用源码和同一套 NanoNNG ExternalProject 编译流程,区别只在于板级支持和网络层。
开发阶段推荐使用 qemu_x86。完整地编译并运行一次只需几秒,比在硬件上烧录再复位快得多。
实际场景评估和硬件测试请使用 ESP32-S3 示例。
两个示例完整的编译和运行步骤见本文对应章节。