NanoMQ 博客

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

在 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 中:
    Shell
    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
Zephyr4.4,11a87708d41(e1000 补丁适用于该版本)
Zephyr SDK1.0.1
NanoMQ0.25.6(基于上游 master)
NanoNNG(nng/ 子模块)基于上游 main,加上 Zephyr 平台适配层。zephyr-rtos 分支提交了子模块的 gitlink,因此 git clone --recursive 会检出完整的平台适配层

如果还没有创建 Zephyr 工作区,可以执行下面的命令(这些命令把 venv 放在 ~/zephyr-venv,也就是本文验证环境使用的位置;放在其他位置,或按官方文档的建议放在 <workspace>/.venv,同样可行):

Shell
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 不参与编译。

  1. 获取 ESP-IDF(它提供 west、esptool 和 idf-monitor)。以下两种安装方式任选其一:
    Shell
    # 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
    
  2. 每次编译前激活环境,并把 ZEPHYR_SDK_INSTALL_DIR 指向 Zephyr SDK。
    本文的验证环境在 ~/.bashrc 中把这一步写成了 alias:
    Shell
    alias esp-zephyr='source ~/.espressif/tools/activate_idf_v6.1.sh \
        && export ZEPHYR_SDK_INSTALL_DIR=$HOME/zephyr-sdk-1.0.1'
    

    每次编译前先运行 esp-zephyr。展开后相当于:
    Shell
    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,但显式设置更不容易出错。
  3. 获取 Espressif HAL blob,在工作区中执行一次即可:
    Shell
    cd ~/zephyrproject
    west blobs fetch hal_espressif
    

    不要跳过这一步。 缺少 blob 时,CONFIG_WIFI_ESP32 会在没有任何提示的情况下变为不可用。编译照样成功,固件也能正常启动,但 Wi-Fi 无法使用,构建系统也不会给出任何警告。串口控制台上永远不会出现预期的 wifi: connected 信息。
    各发行版的具体配置说明(包括系统依赖、SDK 安装、权限和常见错误)见 setup-fedora-zh.md。该指南以 Fedora 为例,使用其他发行版时请相应替换包管理器命令。

获取源码

Shell
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,可以用以下命令初始化子模块:

Shell
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,然后用以下命令应用:

Shell
cd ~/zephyrproject/zephyr
git apply /path/to/zephyr-e1000-bam.patch
Shell
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-S3qemu_x86认证 / 加密
MQTT over TCP1883支持支持无认证,无 TLS
REST API8081支持支持Basic 认证;凭据需要自行配置(没有可用的默认值),无 TLS
MQTT over WebSocket8083支持支持无认证,无 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 中,你的凭据不会提交到仓库。

Shell
cp demo/nanomq_zephyr_esp32s3/local.conf.example \
   demo/nanomq_zephyr_esp32s3/local.conf

编辑 demo/nanomq_zephyr_esp32s3/local.conf,填入 SSID 和 PSK:

Shell
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 的路径相对于应用目录解析:

Shell
west build -b esp32s3_devkitc/esp32s3/procpu -d build/esp32s3_nanomq \
    demo/nanomq_zephyr_esp32s3 -- -DEXTRA_CONF_FILE=local.conf

本文验证的配置启用了 MQTT、REST 和 WebSocket,编译成功后会输出如下内存报告:

Shell
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 为例。请在你的系统上查看实际的设备名:

Shell
ls /dev/ttyUSB* /dev/ttyACM*
# Or check which device node appears when you connect the board
dmesg | tail -5

如果烧录时因权限不足而失败,把当前用户加入设备所属的用户组,然后重新登录使更改生效:

Shell
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 替换为实际的设备名:

Shell
west flash -d build/esp32s3_nanomq --runner esp32 --esp-device /dev/ttyUSB0

4. 查看串口控制台

波特率为 115200。可以使用 idf-monitor 或 miniterm。给 idf-monitor 传入 ELF 文件后,它可以解码二进制日志:

Shell
idf-monitor --port /dev/ttyUSB0 build/esp32s3_nanomq/zephyr/zephyr.elf

下面是一次实际运行的启动日志,其中省略了路径前缀,SSID 用占位符代替:

Shell
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 会直接连接失败,这是正常现象):

Shell
curl -u <rest-user>:<rest-pass> http://<board-ip>:8081/api/v4/brokers/

测试 MQTT 消息收发时,在任意终端中用 Mosquitto 客户端订阅:

Shell
mosquitto_sub -h <board-ip> -t 'test/#' -v

在另一个终端中发布一条消息:

Shell
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):

Shell
west build -b qemu_x86 -d build/nanomq_zephyr_qemu_x86 demo/nanomq_zephyr_qemu_x86

编译成功时,输出的结尾类似这样:

Shell
[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,并把串口控制台的输出写入文件,这样终端可以留给后面的验证步骤使用:

Shell
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:

Shell
west build -d build/nanomq_zephyr_qemu_x86 -t run

这种方式会自动使用 prj.conf 中的三个端口转发,但会把串口控制台接到 stdio 上,并占用当前终端,所以只适合用来观察启动过程。

重启前先清理旧实例。残留的 QEMU 会一直占用这三个端口,新实例会立即启动失败:

Shell
qemu-system-i386: ... Could not set up host forwarding rule 'tcp:127.0.0.1:8083-:8083'

-t run 尤其容易留下这种进程:停止它时,它启动的 QEMU 实例不一定会一起退出。清理时,按端口查出对应的 PID:

Shell
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 行和路径前缀):

Shell
*** 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 端口上没有服务监听):

Shell
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 依赖(运行器会检查这些依赖,缺失时报错退出):

Shell
python3 -m pip install paho-mqtt requests

系统中还需要安装 Mosquitto 命令行客户端(mosquitto_pub 和 mosquitto_sub)。在 Debian/Ubuntu 上安装 mosquitto-clients 软件包;在 Fedora 上安装 mosquitto。

--list 会列出所有测试组(本文涉及其中 8 个,见下表):

Shell
python3 demo/nanomq_zephyr_qemu_x86/function_test.py --list

对于正在运行的硬件示例,用 --no-manage 告诉运行器 Broker 已在运行,不需要由运行器管理:

Shell
python3 demo/nanomq_zephyr_qemu_x86/function_test.py --no-manage --addr <board-ip>

也可以只运行指定的测试组:

Shell
python3 demo/nanomq_zephyr_qemu_x86/function_test.py --no-manage --addr <board-ip> \
    --group mqtt_v311 --group rest_get

以下结果在 ESP32-S3 硬件示例上得到,用 --group 逐个运行下表中的 8 个测试组。为便于阅读,省略了路径前缀。

Shell
========================================================================
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,不算失败:

Shell
| [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.1279.0s
ws_v5基于 WebSocket 的 MQTT 515.9s
capacity12 个并发 CONNECT + QoS 1 回环13.1s
ws_abortWebSocket 连接异常中断时连接池的稳定性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.cnng_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.cmalloc / freePSRAM 上的 k_heap,或 libc 的 malloc()(视目标而定)
时钟与休眠zephyr_clock.cclock_gettime / nanosleepk_uptime_get(),加上 Zephyr 的 POSIX nanosleep()
线程与同步zephyr_thread.cpthread_*(互斥锁 / 读写锁 / 条件变量 / 线程)Zephyr 的 pthread 实现(CONFIG_POSIX_API)
原子操作zephyr_atomic.cC11 <stdatomic.h>原生 64 位原子操作,或基于内嵌互斥锁的回退实现
调试与错误码zephyr_debug.cabort / printf / strerror / errnoprintk、libc,以及 errno ↔ nng 错误码的映射
TCP(客户端)zephyr_tcpdial.c, zephyr_tcpconn.csocket / connect / setsockoptZephyr 网络栈提供的 BSD 兼容 socket
TCP(服务端)zephyr_tcplisten.csocket / bind / listen / accept同上
UDPzephyr_udp.csocket / sendmsg / recvmsg同上
描述符与轮询队列zephyr_pollq_poll.c, zephyr_sockfd.cpoll()(POSIX 上还会在此之上叠加 epoll / kqueue / eventfd)Zephyr 的 poll()
Socket 地址转换zephyr_sockaddr.csockaddr 与 nng 结构体之间的转换Zephyr 的 sockaddr(去掉了 AF_UNIX)
DNS 解析zephyr_resolv_gai.cgetaddrinfo()Zephyr 有这个接口,但语义不同(见下表)
随机数zephyr_rand_urandom.c/dev/urandom, getrandom()按是否有硬件熵源分别处理(见下表)
文件zephyr_file.cstdio、stat、文件锁stdio 和 stat 是真实实现;临时目录和文件锁是回退实现(见下表)
唤醒管道zephyr_pipe.cpipe() / 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 连接过程需要监听多个事件。直觉上会把它们合并成一个掩码,只注册一个回调:

C
/* 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)完全相等,而不是做按位与:

C
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,与两者都不相等。

正确的做法是每个事件单独注册一个回调,处理函数可以共用:

C
/* 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_esp32s3demo/nanomq_zephyr_qemu_x86
目标平台ESP32-S3 硬件qemu_x86
硬件要求ESP32-S3 开发板无
网络Wi-Fi STA + DHCPQEMU SLIRP 用户态网络
内存 / 存储16 MB Flash + 八线 PSRAM(数据面位于 PSRAM)31 MB 模拟 RAM,无持久存储
额外前提Wi-Fi 凭据Zephyr e1000 驱动补丁
典型用途实际场景评估和硬件测试开发阶段的快速验证

两个示例共用同一份应用源码和同一套 NanoNNG ExternalProject 编译流程,区别只在于板级支持和网络层。

开发阶段推荐使用 qemu_x86。完整地编译并运行一次只需几秒,比在硬件上烧录再复位快得多。

实际场景评估和硬件测试请使用 ESP32-S3 示例。

两个示例完整的编译和运行步骤见本文对应章节。