Journal Article

基于 ESP32-S3 + ESP-NOW 的闭环步进电机屏幕遥控系统

# 基于 ESP32-S3 + ESP-NOW 的闭环步进电机屏幕遥控系统 项目地址:<https://github.com/UYSAUIYO/emotroctrl> ## 项目背景 这个项目实现了一套由两块 ESP32-S3 组成的闭环步进电机遥控系统: - 一块 `CX-ESP32S3` 带屏开发板作为遥控端。 - 一块 `motor_board` 作为电机控制端。 - 两块板之间使用

26 min15 views

基于 ESP32-S3 + ESP-NOW 的闭环步进电机屏幕遥控系统

项目地址:https://github.com/UYSAUIYO/emotroctrl

项目背景

这个项目实现了一套由两块 ESP32-S3 组成的闭环步进电机遥控系统:

  • 一块 CX-ESP32S3 带屏开发板作为遥控端。
  • 一块 motor_board 作为电机控制端。
  • 两块板之间使用 ESP-NOW 低延迟通信。
  • 电机控制端通过 UART 接入 ZDT 闭环步进电机驱动器。
  • 遥控端提供 LVGL 中文触摸界面、物理按键、声音反馈和实时状态显示。

最终目标不是简单地“发一个转动命令”,而是构建一个可以现场使用的电机遥控系统:能显示实时转速、角度、圈数,能保存配置,能安全停止,还能处理往复运动、当前位置设零、回原点等常用动作。

系统架构

整体链路如下:

text
┌────────────────────────────┐
│ CX-ESP32S3 遥控端           │
│                            │
│ LVGL UI / 触摸 / 物理按键   │
│ 声音反馈 / WS2812 状态灯    │
│ ESP-NOW Sender             │
└──────────────┬─────────────┘
               │ ESP-NOW
               ▼
┌────────────────────────────┐
│ motor_board 电机控制端      │
│                            │
│ ESP-NOW Receiver           │
│ ZDT UART Command Executor  │
│ Telemetry Cache Task       │
└──────────────┬─────────────┘
               │ UART
               ▼
┌────────────────────────────┐
│ ZDT 闭环步进电机驱动器      │
│                            │
│ 速度控制 / 位置控制         │
│ 实时速度 / 实时位置读取     │
└────────────────────────────┘

项目中遥控端和电机端共用一套代码仓库,通过 Kconfig 选择目标板:

  • CONFIG_BOARD_CX_ESP32S3=y:编译屏幕遥控端。
  • CONFIG_BOARD_MOTOR_BOARD=y:编译电机控制端。

这样可以复用协议、命令结构、ZDT 驱动和构建系统,同时避免维护两份互相漂移的代码。

硬件组成

遥控端:CX-ESP32S3

遥控端使用 CX-ESP32S3 带屏开发板,主要外设包括:

外设用途
ST7789 LCD显示中文控制界面
FT6336U电容触摸输入
XL9555IO 扩展,控制背光、复位、按键、喇叭使能
WS2812连接状态和信号强度提示
I2S 喇叭点击、确认、错误、停止等声音反馈
4 个物理按键快速动作控制

关键引脚包括:

text
XL9555 I2C: GPIO10(SDA), GPIO11(SCL)
FT6336U I2C: GPIO13(SDA), GPIO12(SCL)
LCD BL: XL9555 IO1_3
LCD RST: XL9555 IO1_2
TP RST: XL9555 IO1_0
Speaker BCLK: GPIO46
Speaker WS: GPIO9
Speaker DOUT: GPIO8
Speaker EN: XL9555 IO0_0

物理按键映射:

按键XL9555 IO当前功能
按键 1IO0_1+4.8 圈-4.8 圈 之间往复
按键 2IO0_2将当前位置设置为新的原点
按键 3IO0_3平滑回到原点位置
按键 4IO0_4立即停止

电机端:motor_board

电机端主要负责:

  • 接收 ESP-NOW 命令。
  • 将遥控命令转换成 ZDT UART 命令。
  • 周期读取电机实时速度和位置。
  • 将遥测数据通过 ACK 返回给遥控端。

电机端通过 UART 连接 ZDT 闭环步进电机驱动器。

ESP-NOW 通信设计

项目没有依赖 Wi-Fi AP,也不需要联网。两块 ESP32-S3 使用 ESP-NOW 直接通信。

初始化时遥控端和电机端都会启动 Wi-Fi STA 模式,并关闭省电模式:

c
esp_wifi_set_mode(WIFI_MODE_STA);
esp_wifi_set_ps(WIFI_PS_NONE);
esp_now_init();

关闭省电模式是为了降低通信延迟和丢包概率。对于电机控制这种实时交互场景,稳定性比省电更重要。

命令结构

遥控端发送的命令抽象为 RemoteCommand

cpp
struct RemoteCommand {
    uint16_t magic;
    uint8_t version;
    RemoteCommandType type;
    uint32_t sequence;
    uint8_t address;
    uint8_t flags;
    Direction direction;
    uint16_t acceleration;
    float rpm;
    float angle_deg;
    uint16_t checksum;
} __attribute__((packed));

命令类型包括:

类型含义
enable电机使能
stop停止
speed速度模式
position位置模式
sync同步触发
heartbeat心跳和遥测刷新
encoder_calibrate编码器校准
reset_position_zero将当前位置设置为新的零点

这里需要注意:reset_position_zero 是 ESP32 遥控链路中的内部命令类型,不是电机驱动器原生的 ESP-NOW 命令。电机驱动器并不理解 ESP-NOW,它只理解 UART 上的 ZDT 协议。

电机端收到 reset_position_zero 后,实际转成 ZDT UART 帧:

text
addr 0x0A 0x6D 0x6B

对应驱动代码:

cpp
esp_err_t ZdtMotor::reset_curpos_to_zero(uint8_t address)
{
    const uint8_t frame[] = {address, 0x0A, 0x6D, 0x6B};
    return send_frame(frame, sizeof(frame));
}

ACK 与遥测

电机端每次收到命令后都会返回 RemoteAck

cpp
struct RemoteAck {
    uint16_t magic;
    uint8_t version;
    RemoteCommandType type;
    uint32_t sequence;
    int32_t result;
    uint8_t telemetry_valid;
    int16_t rpm;
    int32_t position_0p1deg;
    uint16_t checksum;
} __attribute__((packed));

其中:

  • rpm 是实时转速。
  • position_0p1deg 是实时位置,单位为 0.1°
  • telemetry_valid 表示本次 ACK 是否携带有效遥测。

遥控端收到 ACK 后会更新 UI:

text
转速: xxx
角度: xxx
圈数: xxx.x

ZDT 电机协议封装

项目将 ZDT UART 协议封装在 ZdtMotor 中,包括:

  • 速度模式控制。
  • 位置模式控制。
  • 停止。
  • 同步触发。
  • 读取实时速度。
  • 读取实时位置。
  • 编码器校准。
  • 当前位置清零。

实时速度读取使用 ZDT 0x35 命令:

text
addr 0x35 0x6B

实时位置读取使用 ZDT 0x36 命令:

text
addr 0x36 0x6B

读取到的位置会换算成:

cpp
turns = position_0p1deg / 3600.0f;
angle = (position_0p1deg % 3600) / 10;

其中一圈是 360°,而 ZDT 返回单位是 0.1°,所以一圈等于 3600

电机端 Telemetry Cache

早期实现中,电机板在处理 ESP-NOW ACK 时同步读取 ZDT UART 遥测。这样会带来两个问题:

  • ACK 路径被 UART 读取阻塞。
  • 命令响应帧和遥测帧容易互相污染。

现在电机端使用独立的 telemetry task:

text
motor_tel task
    每 200ms 读取一次 0x35 / 0x36
    更新缓存

ESP-NOW ACK
    不直接读 UART
    只读取缓存

同时通过 mutex 串行化 UART 访问:

cpp
SemaphoreHandle_t s_uart_lock;
SemaphoreHandle_t s_cache_lock;

这样命令执行和遥测读取不会同时抢 UART,ACK 也不会因为读取遥测而长时间阻塞。

遥控端 UI 设计

遥控端使用 LVGL 构建中文界面,包含:

  • 标题栏。
  • ESP-NOW 信号强度图标。
  • 实时转速、角度、圈数、方向显示。
  • 动作按钮。
  • 速度输入框。
  • 配置保存/加载页面。
  • 小键盘输入。

主界面动作包括:

  • 正转一圈。
  • 反转一圈。
  • 正转半圈。
  • 反转半圈。
  • 停止。
  • 配置。

配置页支持 4 个槽位,每个槽位可以保存:

  • 名称。
  • 当前圈数。
  • 方向。
  • 速度。

配置使用 NVS 持久化,namespace 为:

text
remote_cfg

中文字体生成

LVGL 需要 C 源码形式的字体文件。项目中使用的是自生成的最小中文字体:

text
main/fonts/font_puhui_20_cn.c

字体源文件来自 Alibaba PuHuiTi:

text
AlibabaPuHuiTi-3-55-Regular.ttf

生成工具是 lv_font_conv

bash
npm install -g lv_font_conv

示例生成命令:

bash
lv_font_conv \
  --font "/home/yuwen/CX-ESP32S3开发板资料/小智AI代码/移植好的代码1.8.6版本/xiaozhi-esp32/managed_components/78__xiaozhi-fonts/AlibabaPuHuiTi-3-55-Regular.ttf" \
  --range "0x20-0x7e" \
  --symbols "ESP-NOW 电机控制器转速角度圈数方向设置状态最后无就绪正转反转停止配置保存加载成功失败速度已原点动作执行中启动名称空当前位置新零点" \
  --size 20 \
  --format lvgl \
  --bpp 4 \
  --no-compress \
  --force-fast-kern-format \
  --output main/fonts/font_puhui_20_cn.c

这里故意只生成 UI 实际用到的字符。如果直接生成完整中文字体,固件会明显变大,甚至超过 app 分区。

如果屏幕上出现缺字,需要把缺失字符加入 --symbols,然后重新生成 font_puhui_20_cn.c

声音反馈

遥控端增加了 I2S 喇叭音效反馈,包括:

  • 点击音。
  • 确认音。
  • 取消音。
  • 成功音。
  • 错误音。
  • 停止音。

声音不是播放 WAV 文件,而是直接生成短 PCM 波形写入 I2S。这样不需要 SPIFFS 音频分区,也不会引入额外资源文件。

音效任务独立运行:

text
UI / 按键事件
    投递 SoundCue

sound task
    生成 PCM
    写 I2S

这样不会阻塞 LVGL UI。

安全往复控制

物理按键 1 用于执行 -4.8 / +4.8 圈 循环往复。

早期版本使用的是相对位移:

text
当前位置 0
相对 +4.8 圈
相对 -9.6 圈
相对 +9.6 圈

这个策略存在风险:如果实际位置和预期不一致,继续发送相对 9.6 圈 就可能累计越界,甚至撞机。

修复后的策略改成绝对位置目标:

text
目标 +4.8 圈
目标 -4.8 圈
目标 +4.8 圈
循环

这样每次目标都是固定边界,不会因为前一次运动偏差继续累计。

当前逻辑:

  • 按键 1:启动 +4.8 <-> -4.8 绝对位置往复。
  • 按键 4:立即停止,适合紧急情况。
  • 按键 3:取消往复任务,并直接下发 绝对位置目标,不先急停。
  • 按键 2:先停止,再将当前位置设置为新的零点。

按键 3 特意不再先发 stop,原因是从往复切换到原点时,急停再启动会造成电流冲击。直接切换目标位置可以让驱动器按自身加减速逻辑平滑回到中间。

构建与烧录

项目使用 ESP-IDF v5.4.3:

bash
source ~/.espressif/v5.4.3/esp-idf/export.sh

屏幕板通常是:

text
/dev/ttyACM0

电机板通常是:

text
/dev/ttyUSB0

屏幕板需要配置:

text
CONFIG_BOARD_CX_ESP32S3=y
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y

电机板需要配置:

text
CONFIG_BOARD_MOTOR_BOARD=y
CONFIG_ESP_CONSOLE_UART_DEFAULT=y

烧录示例:

bash
idf.py build
idf.py -p /dev/ttyACM0 flash

或者电机板:

bash
idf.py build
idf.py -p /dev/ttyUSB0 flash

经验总结

这个项目中最关键的点不是单个外设驱动,而是多个实时因素之间的协调:

  • ESP-NOW 要低延迟且可靠。
  • UART 命令和遥测读取不能互相污染。
  • UI 不能被网络等待或音效阻塞。
  • 电机动作不能依赖不可靠的相对位移累计。
  • 回原点和急停必须区分,不能为了简单而牺牲机械安全。

最终系统采用了几个重要设计原则:

  • 遥控链路和电机协议分层。
  • 命令 ACK 和遥测缓存解耦。
  • UI、声音、ESP-NOW、电机 UART 各自独立任务。
  • 高风险动作尽量使用绝对目标。
  • 立即停止只用于真正需要急停的场景。

这些改动让系统从“能转”逐步变成“可用、可调试、可现场操作”的闭环电机遥控器。


推荐硬件

ESP32-S3开发板

购买链接:淘宝 - ESP32-S3开发板

ESP32-S3小智AI聊天物联网开发套装

购买链接:淘宝 - 小智AI聊天开发套装

微信图片_20260721153856_18_62.jpg ESP32-S3项目封面