本篇文章将从电机控制、加速度计 FIFO 数据采集、环境配置、模型训练与部署,介绍如何在 NUCLEO-H533RE 开发板上配合 NanoEdge AI Studio 在由 LIS3DH 采集的数据集上训练的电机运行状态分类应用,代码已经开源到 Git 仓库 https://codeberg.org/scgummy/eepw-letsdo-2026-p3
Zephyr 驱动步进电机
Zephyr 下,我们可以用官方的 Stepper API 来操作步进电机,为了接入 Stepper API,我们仍然像之前一样需要用设备树描述我们的 4 根驱动线.
这次活动使用的 Seeed Studio 的步进电机套装中配套了一个驱动板,可以看到引出有 4 线输入,GND / VCC / VM,电机是 5V DC 步进电机.

原理图如下,可以看到除外围配套电路之外,其实驱动板的灵魂部件就是 ULN2003A.

ULN2003A 内部是 7 路 Darlington 晶体管阵列,每路有输入电阻和续流二极管,适合驱动继电器、电磁阀、步进电机这类感性负载. 用在 28BYJ-48 这类四相步进电机时,它的功能是接入 MCU 的 4 个 GPIO 输出相序后,把 GPIO 的小电流逻辑信号放大成绕组电流;驱动类型为低侧灌电流驱动,即电机公共端接电源正,ULN2003 输出导通时把绕组拉到地;内置续流二极管可以吸收绕组断电时的反电动势.
使用的 Zephyr 4.3.1 版本中,这类直接通过 GPIO 时序驱动的步进电机已经被并入 H 桥电机的驱动了,虽然这里并不是 H 桥,但是驱动层面 Zephyr 做了合并,老版本的 Zephyr 是分离,可能对使用不同 Zephyr 版本的开发者造成一定困惑.
对照我们的扩展板原理图,用设备树配置我们的步进电机端口.


然后可以实现用串口控制步进电机,包含三种模式:停转、慢转和快转. 慢转对应字符 1 或者 S,快转对应字符 2 或者 F,其它情况则停转.

加速度 FIFO 读取
前一篇帖子读出了加速度计数据并显示在 OLED 上,但这里使用的是 Zephyr 的 Sensor API 读取的,它只暴露最新的值. 在电机状态识别这类场景下,我们采集的加速度值的时间序列数据必须是前后间隔一致,完全连续采集的模式,否则特征提取阶段就会出现问题.

大多数 MEMS 的 IMU 都有 ODR 和环形 FIFO,ODR 决定采多快,环形 FIFO 则把已采数据暂存下来,但如果数据消费者(控制器侧,一般是 MCU)没有来得及取,新数据就会从队列开头覆盖老数据.

本次选择的 LIS3DH 模块片上带深度为 32 的 FIFO,同时提供不同的 FIFO 模式,因为我们是连续采集,所以我们将配置为 stream 模式.

打开 FIFO 涉及到 2 个具体的寄存器,分别是 FIFO_CTRL 和 CTRL5,前者包含一个 FM 字段可以选择 FIFO 模式,后者 CTRL5 包含 FIFO 的总开关,不打开 FIFO 就不会装入采集的数据.


落实到代码上,需要用 Zephyr 的 I2C reg_write_byte / reg_read_byte 系列函数进行操作,此外因为 Zephyr 本身就会初始化传感器的不少寄存器,防止打架,我们先读取已经有的值再用 bitops 去修改具体的字段.
int lis3dh_fifo_enable(const struct i2c_dt_spec *spec)
{
int ret;
uint8_t reg;
ret = i2c_reg_read_byte_dt(spec, LIS3DH_REG_FIFO_CTRL, ®);
if (ret < 0) {
return ret;
}
reg &= ~LIS3DH_FM_MASK;
reg |= LIS3DH_FM_SELECT(LIS3DH_FM_STREAM);
ret = i2c_reg_write_byte_dt(spec, LIS3DH_REG_FIFO_CTRL, reg);
if (ret < 0) {
return ret;
}
ret = i2c_reg_read_byte_dt(spec, LIS3DH_REG_CTRL5, ®);
if (ret < 0) {
return ret;
}
reg |= LIS3DH_FIFO_EN(1);
ret = i2c_reg_write_byte_dt(spec, LIS3DH_REG_CTRL5, reg);
if (ret < 0) {
return ret;
}
return 0;
}然后怎么流式读取 LIS3DH 上的数据呢?简单来说,直接从 0x28 这个 OUT_X_L 寄存器开始发起 I2C burst read,连续读 6 个字节就是 1 组样本,连续读 12 个字节就是 2 个样本,设备会自动按照 0x28 0x29 0x2a 0x2b 0x2c 0x2d 的递进并从头循环.

落实到 Zephyr 代码上,我们对外提供一个 fifo_read 的函数,用户可以提供样本缓冲区的大小 count,我们将其跟 FIFO 中可用数据量(单位是 1 组样本)比较,取较小值作为我们实际读取的量,数值转换方面我们是从 Zephyr 官方库中复用的函数.
ssize_t lis3dh_fifo_read(
const struct i2c_dt_spec *spec,
int accel_fs,
struct sensor_accel *buf,
size_t count
)
{
int ret;
uint8_t src;
size_t total;
size_t done = 0;
ret = i2c_reg_read_byte_dt(spec, LIS3DH_REG_FIFO_SRC, &src);
if (ret < 0) {
return ret;
}
total = src & LIS3DH_FSS_MASK;
if (count < total) {
total = count;
}
while (done < total) {
uint8_t chunk[LIS3DH_FIFO_CHUNK_SAMPLES * LIS3DH_FIFO_SAMPLE_SIZE];
size_t n = total - done;
if (n > LIS3DH_FIFO_CHUNK_SAMPLES) {
n = LIS3DH_FIFO_CHUNK_SAMPLES;
}
ret = i2c_burst_read_dt(spec, LIS3DH_REG_OUT_X_L | 0x80, chunk, n * LIS3DH_FIFO_SAMPLE_SIZE);
if (ret < 0) {
return ret;
}
for (int i = 0; i < n; i++) {
const uint8_t *p = &chunk[i * LIS3DH_FIFO_SAMPLE_SIZE];
struct sensor_accel *target = &buf[done + i];
lis3dh_convert((int16_t)sys_get_le16(&p[0]), ACCEL_SCALE(1600), &target->xyz[0]);
lis3dh_convert((int16_t)sys_get_le16(&p[2]), ACCEL_SCALE(1600), &target->xyz[1]);
lis3dh_convert((int16_t)sys_get_le16(&p[4]), ACCEL_SCALE(1600), &target->xyz[2]);
}
done += n;
}
return (ssize_t)done;
}数据采集和处理
我们在 Zephyr 上编写了一段采集函数,初始化后就执行,5 秒内如果串口上有数据就跳过采集模式直接回到主程序. 数据交换格式采用自定义输出,走 UART,一条数据类似下方这种格式.
@TAG(x1,x2),(y1,y2),(z1,z2)#00
其中 TAG 合法值有 X S F,分别代表停止、慢转和快转,后面紧跟三轴数据,x1 和 x2 分别对应 Zephyr 定义的 sensor_value 结构体中 val1 和 val2 的值,其本质上是一种定点数表示,真实值 v = val1 + val2 * 1e-6.
static int app_collect(struct app *app)
{
int ret;
unsigned char c;
enum app_collect_state {
STATE_STOP = 'X',
STATE_SLOW = 'S',
STATE_FAST = 'F',
} state = STATE_STOP;
int64_t last;
#define COOLDOWN 5
#define STATE_KEEP (15 * 1000) // 15 secs
#define SLOW_INTERVAL 1464844
#define FAST_INTERVAL 732422
for (int i = 0; i < COOLDOWN; i++) {
ret = uart_poll_in(app->uart, &c);
if (ret == 0) {
return -1;
}
ret = cfb_framebuffer_clear(app->oled, false);
ret = cfb_print(app->oled, "Press any key to exit sampler...", 0, 0);
ret = cfb_framebuffer_finalize(app->oled);
k_sleep(K_MSEC(1000));
}
ret = cfb_framebuffer_clear(app->oled, false);
ret = cfb_print(app->oled, "Collecting", 0, 0);
ret = cfb_framebuffer_finalize(app->oled);
k_sleep(K_MSEC(1000));
stepper_enable(app->stepper);
last = 0;
while (1) {
size_t n;
struct sensor_accel accel[16];
ret = app_fetch_accel_fifo(app, accel, ARRAY_SIZE(accel));
if (ret < 0) {
printk("Failed to read accel: %d.\n", ret);
return ret;
}
n = ret;
for (int i = 0; i < n; i++) {
char tag = (char)state;
printf(
"@%c(%d,%03d),(%d,%03d),(%d,%03d)#%02d\n",
tag,
accel[i].xyz[0].val1, accel[i].xyz[0].val2,
accel[i].xyz[1].val1, accel[i].xyz[1].val2,
accel[i].xyz[2].val1, accel[i].xyz[2].val2,
i
);
}
if ((k_uptime_get() - last) >= STATE_KEEP) {
enum app_collect_state old = state;
stepper_stop(app->stepper);
k_sleep(K_MSEC(10));
switch (old) {
case STATE_STOP:
state = STATE_SLOW;
stepper_set_microstep_interval(app->stepper, SLOW_INTERVAL);
stepper_run(app->stepper, STEPPER_DIRECTION_POSITIVE);
break;
case STATE_SLOW:
state = STATE_FAST;
stepper_set_microstep_interval(app->stepper, FAST_INTERVAL);
stepper_run(app->stepper, STEPPER_DIRECTION_POSITIVE);
break;
case STATE_FAST:
state = STATE_STOP;
stepper_stop(app->stepper);
break;
}
last = k_uptime_get();
printf("State: %c -> %c\n", old, state);
}
k_sleep(K_MSEC(5));
}
return 0;
}UART 用 115200 baud/s 可能太慢了,我们直接开到 921600 baud/s 这样可以确保 FIFO 来得及拿数据.

采集得到的原始输出是.

这样的格式方便最快的输出,但是 NanoEdge AI Studio 是不认的,我们用一个 Python 脚本将其转化成 NEAI Studio 识别的格式,转化成每一行 256 个样本,即 256 * 3 个浮点数.


输出多份 CSV,慢转 S 共 4 份数据集,快转 F 共 4 份数据集,停转 X 共 5 份.


环境配置
我们需要安装 NanoEdge AI Studio,由于笔者开发环境在 Linux 上,安装方法略有不同,先前往 https://stm32ai.st.com/download-nanoedgeai/ 填写信息拿到安装包.

下载安装包后,使用 apt install 命令即可安装完成.

训练模型
打开 NEAI Studio 后,创建项目,选择 Classify 因为我们要分类不同的状态.

选择 NUCLEO-H533RE 这块板子,这里其实影响不大,因为我们不用 NEAI Studio 做数据采集.

传感器一栏选择「时序数据」的加速度计,3 轴.

之后我们从处理过的数据中导入 CSV 就可以了.

在左侧导入数据后,右侧自动出现了不同分类的数据图表.

然后开始数据训练,训练完成后准确度会达到比较高的值.


然后我们可以查看遍历的不同组合的模型的具体性能指标,包含准确度、RAM/Flash 占用、执行时间等,我们就选用综合排序最高的这个模型.

部署模型时,我们打开浮点 ABI,不过这也要求我们的 Zephyr 程序也要打开浮点 ABI.

拿到编译产物后,就可以开始着手接入我们的应用了.

我们首先在 CMakeLists 中,加入 include 目录,并且把 libneai.a 加入链接库列表.

然后我们在 Zephyr 编写一个这样的推理函数即可.

然后 OLED 上面将分类结果绘制出来即可.

为了更好可视化数据,我们还实现了一套 FFT 频谱计算,并显示在 OLED 上.

扩大 RAM 布局
接入模型后,我们开头试跑的几次采样是可以输出 tag 的,但是之后就会发生 bus fault,使用 addr2line 工具考证后发现程序跑飞,怀疑 stack 大小不足导致 stack 本身的数据被破坏.

解决方法也很简单,直接在 Zephyr 的 prj.conf 中,设置合理的 stack 大小,同时考虑到可能会有潜在的 heap 上的分配,我们一并扩大.
CONFIG_MAIN_STACK_SIZE=8192 CONFIG_HEAP_MEM_POOL_SIZE=16384
最终效果
我们用一个小键盘接到电脑上,焦点对准串口的窗口,这样就可以按键控制电机状态,按键 1 进入慢转,按键 2 进入快转,按键 3 停转,可以看到屏幕上显示了实时频谱和 NEAI 库的推理结果,可以看到是一致的.

我要赚赏金
