基于 ESP32-S3 + ESP-NOW 的闭环步进电机屏幕遥控系统
项目地址:https://github.com/UYSAUIYO/emotroctrl
项目背景
这个项目实现了一套由两块 ESP32-S3 组成的闭环步进电机遥控系统:
- 一块
CX-ESP32S3带屏开发板作为遥控端。 - 一块
motor_board作为电机控制端。 - 两块板之间使用
ESP-NOW低延迟通信。 - 电机控制端通过 UART 接入 ZDT 闭环步进电机驱动器。
- 遥控端提供 LVGL 中文触摸界面、物理按键、声音反馈和实时状态显示。
最终目标不是简单地“发一个转动命令”,而是构建一个可以现场使用的电机遥控系统:能显示实时转速、角度、圈数,能保存配置,能安全停止,还能处理往复运动、当前位置设零、回原点等常用动作。
系统架构
整体链路如下:
┌────────────────────────────┐
│ 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 | 电容触摸输入 |
| XL9555 | IO 扩展,控制背光、复位、按键、喇叭使能 |
| WS2812 | 连接状态和信号强度提示 |
| I2S 喇叭 | 点击、确认、错误、停止等声音反馈 |
| 4 个物理按键 | 快速动作控制 |
关键引脚包括:
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 | 当前功能 |
|---|---|---|
| 按键 1 | IO0_1 | 在 +4.8 圈 和 -4.8 圈 之间往复 |
| 按键 2 | IO0_2 | 将当前位置设置为新的原点 |
| 按键 3 | IO0_3 | 平滑回到原点位置 |
| 按键 4 | IO0_4 | 立即停止 |
电机端:motor_board
电机端主要负责:
- 接收 ESP-NOW 命令。
- 将遥控命令转换成 ZDT UART 命令。
- 周期读取电机实时速度和位置。
- 将遥测数据通过 ACK 返回给遥控端。
电机端通过 UART 连接 ZDT 闭环步进电机驱动器。
ESP-NOW 通信设计
项目没有依赖 Wi-Fi AP,也不需要联网。两块 ESP32-S3 使用 ESP-NOW 直接通信。
初始化时遥控端和电机端都会启动 Wi-Fi STA 模式,并关闭省电模式:
esp_wifi_set_mode(WIFI_MODE_STA);
esp_wifi_set_ps(WIFI_PS_NONE);
esp_now_init();
关闭省电模式是为了降低通信延迟和丢包概率。对于电机控制这种实时交互场景,稳定性比省电更重要。
命令结构
遥控端发送的命令抽象为 RemoteCommand:
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 帧:
addr 0x0A 0x6D 0x6B
对应驱动代码:
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:
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:
转速: xxx
角度: xxx
圈数: xxx.x
ZDT 电机协议封装
项目将 ZDT UART 协议封装在 ZdtMotor 中,包括:
- 速度模式控制。
- 位置模式控制。
- 停止。
- 同步触发。
- 读取实时速度。
- 读取实时位置。
- 编码器校准。
- 当前位置清零。
实时速度读取使用 ZDT 0x35 命令:
addr 0x35 0x6B
实时位置读取使用 ZDT 0x36 命令:
addr 0x36 0x6B
读取到的位置会换算成:
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:
motor_tel task
每 200ms 读取一次 0x35 / 0x36
更新缓存
ESP-NOW ACK
不直接读 UART
只读取缓存
同时通过 mutex 串行化 UART 访问:
SemaphoreHandle_t s_uart_lock;
SemaphoreHandle_t s_cache_lock;
这样命令执行和遥测读取不会同时抢 UART,ACK 也不会因为读取遥测而长时间阻塞。
遥控端 UI 设计
遥控端使用 LVGL 构建中文界面,包含:
- 标题栏。
- ESP-NOW 信号强度图标。
- 实时转速、角度、圈数、方向显示。
- 动作按钮。
- 速度输入框。
- 配置保存/加载页面。
- 小键盘输入。
主界面动作包括:
- 正转一圈。
- 反转一圈。
- 正转半圈。
- 反转半圈。
- 停止。
- 配置。
配置页支持 4 个槽位,每个槽位可以保存:
- 名称。
- 当前圈数。
- 方向。
- 速度。
配置使用 NVS 持久化,namespace 为:
remote_cfg
中文字体生成
LVGL 需要 C 源码形式的字体文件。项目中使用的是自生成的最小中文字体:
main/fonts/font_puhui_20_cn.c
字体源文件来自 Alibaba PuHuiTi:
AlibabaPuHuiTi-3-55-Regular.ttf
生成工具是 lv_font_conv:
npm install -g lv_font_conv
示例生成命令:
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 音频分区,也不会引入额外资源文件。
音效任务独立运行:
UI / 按键事件
投递 SoundCue
sound task
生成 PCM
写 I2S
这样不会阻塞 LVGL UI。
安全往复控制
物理按键 1 用于执行 -4.8 / +4.8 圈 循环往复。
早期版本使用的是相对位移:
当前位置 0
相对 +4.8 圈
相对 -9.6 圈
相对 +9.6 圈
这个策略存在风险:如果实际位置和预期不一致,继续发送相对 9.6 圈 就可能累计越界,甚至撞机。
修复后的策略改成绝对位置目标:
目标 +4.8 圈
目标 -4.8 圈
目标 +4.8 圈
循环
这样每次目标都是固定边界,不会因为前一次运动偏差继续累计。
当前逻辑:
- 按键 1:启动
+4.8 <-> -4.8绝对位置往复。 - 按键 4:立即停止,适合紧急情况。
- 按键 3:取消往复任务,并直接下发
0°绝对位置目标,不先急停。 - 按键 2:先停止,再将当前位置设置为新的零点。
按键 3 特意不再先发 stop,原因是从往复切换到原点时,急停再启动会造成电流冲击。直接切换目标位置可以让驱动器按自身加减速逻辑平滑回到中间。
构建与烧录
项目使用 ESP-IDF v5.4.3:
source ~/.espressif/v5.4.3/esp-idf/export.sh
屏幕板通常是:
/dev/ttyACM0
电机板通常是:
/dev/ttyUSB0
屏幕板需要配置:
CONFIG_BOARD_CX_ESP32S3=y
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
电机板需要配置:
CONFIG_BOARD_MOTOR_BOARD=y
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
烧录示例:
idf.py build
idf.py -p /dev/ttyACM0 flash
或者电机板:
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聊天开发套装

评论 (0)