0.1 外设配置
用 STM32CubeMX 新建工程,直接选 NUCLEO-H533RE 对应的开发板,需要打开的外设:
| GPIO ×4 | 驱动 ULN2003 的 IN1~IN4 | 推挽输出,初始低电平 |
| I2C1 | 读 LIS3DH | 标准模式/快速模式 400 kHz,7 位地址 |
| USART2 | 向 PC 输出数据 | 921600 8N1(一行 1536 个整数约 13 KB,115200 下要 1.2 s,太慢) |
| SYS | 时基 | 建议用 TIM 做 HAL 时基,避免和 RTOS 抢 SysTick |
注意:上 RTOS 之后 SysTick 归 ThreadX 用,HAL 的 HAL_Delay() 会失准,时基最好改到某个基本定时器上。这一点如果没改,后面会出现 HAL_Delay 卡死或延时翻倍的现象。
0.2 引入 ThreadX
在 CubeMX 的 Middleware 里勾选 ThreadX(Azure RTOS),生成后自动带出 tx_initialize_low_level 和 app_threadx.c。我建了三个线程,另加一个定时器中断:
| sampler 中断(TIM12) | 定时读 LIS3DH,攒窗口 | NVIC 7 | — | 1344 Hz(744 µs) |
| acq_thread | 搬运采满的窗口 → 格式化 → 送串口 | 10(较高) | 4 KB | 每块 512 点 ≈ 381 ms |
| motor_thread | 收命令队列,启停/调速/定步数 | 12(较低) | 2 KB | 命令驱动,无固定周期 |
| console_thread | 解析串口命令、处理按键 | 14(最低) | 2 KB | 10 ms 轮询 |
之所以这么拆,是因为采样必须绝对准时——线程唤醒有调度延迟,744 µs 周期里抖 30 µs 就是 4%,FFT 上看得见;所以读传感器这件事放在定时器中断里做,线程只负责搬运。而电机对时序的要求反而宽松,晚几微秒完全无感。两者的优先级,恰恰和直觉相反。

创建线程的代码基于 CubeMX 生成的骨架改写,这里只列采集和电机两个:
/* ── 代码位 ① 创建控制线程与采集线程 ────────────────── */ /* 栈和控制块都静态分配:CubeMX 给的内存池只有 1 KB, * 连一个线程都放不下,而且那个宏在 USER CODE 区外面,重新生成会被刷回去 */ static TX_THREAD acq_thread; static ULONG acq_stack[4096U / sizeof(ULONG)]; /* 4 KB:要跑 snprintf */ static TX_THREAD motor_thread; static ULONG motor_stack[2048U / sizeof(ULONG)]; /* 2 KB:只有整数运算 */ tx_thread_create(&acq_thread, "acq", acq_thread_entry, 0, acq_stack, sizeof(acq_stack), 10, 10, /* 优先级 / 抢占阈值 */ TX_NO_TIME_SLICE, TX_AUTO_START); tx_thread_create(&motor_thread, "motor", motor_thread_entry, 0, motor_stack, sizeof(motor_stack), 12, 12, /* 数字更大 = 优先级更低 */ TX_NO_TIME_SLICE, TX_AUTO_START);
这里和开头的计划有一处出入值得记下来:最后电机的优先级反而比采集低。原以为电机时序更金贵,实测发现真正不能晚的是采样——丢一个点整块频谱就废了,而电机晚几微秒完全无感。所以采集给 10、电机给 12。
1. 让电机转起来
1.1 接线
28BYJ-48 是 5 线四相减速步进电机,中间那根红线是线圈公共端,接 5V;另外四根接 ULN2003 驱动板的输出侧。驱动板输入侧 IN1~IN4 接 MCU 的四个 GPIO:
| PC12 | IN1 | A 相 |
| PC10 | IN2 | B 相 |
| PC11 | IN3 | C 相 |
| PD2 | IN4 | D 相 |
/* ── 代码位 ② 四相 GPIO 初始化 ──────────────────────── */
GPIO_InitTypeDef g = {0};
g.Mode = GPIO_MODE_OUTPUT_PP;
g.Pull = GPIO_NOPULL;
g.Speed = GPIO_SPEED_FREQ_LOW;
g.Pin = GPIO_PIN_10 | GPIO_PIN_11 | GPIO_PIN_12; /* IN2 / IN3 / IN1 */
HAL_GPIO_Init(GPIOC, &g);
g.Pin = GPIO_PIN_2; /* IN4 */
HAL_GPIO_Init(GPIOD, &g);
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_10 | GPIO_PIN_11 | GPIO_PIN_12, GPIO_PIN_RESET);
HAL_GPIO_WritePin(GPIOD, GPIO_PIN_2, GPIO_PIN_RESET); /* 上电四相全断 */
/* 四相同时输出:一次写 BSRR,低 16 位置位、高 16 位清零,无中间非法相位 */
#define GPIOC_MASK (GPIO_PIN_10 | GPIO_PIN_11 | GPIO_PIN_12)
#define GPIOD_MASK (GPIO_PIN_2)
static void phase_output(uint8_t pat)
{
uint32_t c = 0U;
if (pat & 0x1U) { c |= GPIO_PIN_12; } /* IN1 / A */
if (pat & 0x2U) { c |= GPIO_PIN_10; } /* IN2 / B */
if (pat & 0x4U) { c |= GPIO_PIN_11; } /* IN3 / C */
GPIOC->BSRR = c | ((~c & GPIOC_MASK) << 16);
GPIOD->BSRR = (pat & 0x8U) ? (uint32_t)GPIO_PIN_2
: ((uint32_t)GPIO_PIN_2 << 16);
}BSRR 是「置位/复位寄存器」:低 16 位写 1 表示把对应引脚拉高,高 16 位写 1 表示拉低,同一次写入里两者互不干扰。所以一个 32 位写就能把四路新状态同时生效,不会出现 0x05 这种「A 相已断、C 相还没通」的中间态。
1.2 供电(容易翻车的地方)
电机用独立 5V 电源,不要从开发板的 5V 引脚取电。28BYJ-48 单相电流在 200~300 mA 量级,四相轮流导通,叠加起来很容易把板载 LDO 拉垮,表现就是电机一转、MCU 就复位或者串口乱码。
电机电源和开发板必须共地,否则 ULN2003 的输入电平没有参考,会出现「有时转有时不转」的玄学现象。
电机相线远离 I2C 走线,我这边一开始线扎在一起,采集到的数据里能看到明显的周期性尖峰,把线分开就好了。
1.3 八拍半步序列
用八拍半步驱动,比四拍整步平顺,振动小一些,也更适合后面做状态区分:
| 1 | 1 | 0 | 0 | 0 | 0x01 |
| 2 | 1 | 1 | 0 | 0 | 0x03 |
| 3 | 0 | 1 | 0 | 0 | 0x02 |
| 4 | 0 | 1 | 1 | 0 | 0x06 |
| 5 | 0 | 0 | 1 | 0 | 0x04 |
| 6 | 0 | 0 | 1 | 1 | 0x0C |
| 7 | 0 | 0 | 0 | 1 | 0x08 |
| 8 | 1 | 0 | 0 | 1 | 0x09 |
序列正着走是正转,倒着走就是反转,不用改接线。
半步节拍交给硬件定时器(TIM7),核心逻辑是「中断里查表 → 输出 → 计步递减」:
/* ── 代码位 ③ 半步驱动:相位表 + 定时器中断 ──────────── */
static const uint8_t seq[8] = {0x01,0x03,0x02,0x06,0x04,0x0C,0x08,0x09};
static volatile int32_t s_steps_left;
static volatile int32_t s_position;
static volatile int8_t s_dir; /* +1 正转 / -1 反转 */
static volatile uint8_t s_phase; /* 0..7 */
static volatile uint8_t s_busy;
/* TIM7 更新中断里调用,一拍走一个半步 */
void stepper_on_tick(void)
{
if (s_busy == 0U) { return; }
phase_output(seq[s_phase]); /* 1. 输出当前拍 */
s_position += s_dir; /* 2. 推进 */
s_steps_left--;
s_phase = (uint8_t)((s_phase + (uint32_t)s_dir) & 0x07U);
if (s_steps_left <= 0) /* 3. 走完就断线圈 */
{
HAL_TIM_Base_Stop_IT(&htim7);
phase_output(0x00); /* 四相全断,靠减速箱自锁,不发热 */
s_busy = 0U;
tx_semaphore_put(&motor_done_sem);
}
}
/* 线程侧只负责下命令,不参与节拍 */
static VOID motor_thread_entry(ULONG input)
{
motor_cmd_t cmd;
for (;;)
{
if (tx_queue_receive(&motor_cmd_q, &cmd, TX_WAIT_FOREVER) != TX_SUCCESS)
{
continue;
}
stepper_move(cmd.steps, cmd.f_max_hz); /* 起定时器,立即返回 */
tx_semaphore_get(&motor_done_sem, TX_WAIT_FOREVER);
}
}这里没有用 tx_thread_sleep() 做半步节拍,和最初的设想不一样。原因是线程睡眠的最小粒度是一个 RTOS tick(本项目 100 Hz = 10 ms),就算改 tick 频率,唤醒抖动也有百微秒量级;半步周期只有 2.5 ms 时,这个抖动占比太大,电机听起来就是「一顿一顿」的。要精确周期就用硬件定时器,中断里只做查表和写寄存器。
关键点:四相写引脚最好一次性完成,如果分四次写,中间会短暂出现一个非法相位(比如 0x05),电机听起来会有「哒」的杂音,低速时甚至能看到抖动。可以先把 4 位拼成一个字节再整段输出。
1.4 速度换算
28BYJ-48 内部减速比约 1:64,输出轴一圈需要 4096 个半步。所以半步周期直接决定转速:
转一圈时间 = 4096 × 半步周期 6.67 ms/半步(150 Hz) → 约 27 s/圈,2.2 RPM 2.5 ms/半步(400 Hz) → 约 10 s/圈,5.9 RPM 1 ms/半步(1000 Hz)→ 约 4.1 s/圈,但已经带不动
我最终用的两档是 150 Hz(低速)和 400 Hz(高速),都是按「步频」而不是「周期」来定的——因为后面做模型时,采样率、频谱分析都是频域视角,用 Hz 对齐更省心。
再往上试过 600 Hz:手按在输出轴上能明显感觉到「跟不上了」,串口数据里振动幅值反而下降,说明不是转得更快而是被丢步搞成了不规则运动。更麻烦的是频谱——采样率 1344 Hz 下,600 Hz 的二次谐波 1200 Hz 会折回到 144 Hz,正好压在低速档的主频上,两类在特征空间里直接不可分。所以高速档就停在 400 Hz 了。

2. 把数据送上串口
2.1 LIS3DH 接线与地址
VCC 3.3V,GND 共地
SCL / SDA 接 I2C1
SDO 悬空或接高时地址为 0x19,接 GND 时为 0x18
本期用的是 Grove 接口的扩展板,HY2.0 端子对不上 2.54 排针,需要一根转接线。上电后先读 WHO_AM_I(0x0F),返回值 0x33 才说明通信正常,这一步能省掉后面一大半的排查时间。
2.2 关键寄存器
| CTRL_REG1 | 0x20 | 0x97 | ODR 档位 1001(Normal 模式 1.344 kHz)+ XYZ 使能 |
| CTRL_REG4 | 0x23 | 0x88 | BDU=1,±2g,高分辨率 |
| STATUS_REG | 0x27 | — | bit3 = ZYXDA,新数据就绪 |
| OUT_X_L~OUT_Z_H | 0x28~0x2D | — | 六字节加速度原始值 |
±2g 高分辨率下,12 位输出的 1 LSB ≈ 1 mg。但寄存器里存的是 16 位左对齐数据,所以读回来的原始值还要除以 16 才是 mg:静止时 Z 轴原始值约 16000,16000 ÷ 16 = 1000 mg = 1g,正好对上。这两个数字摆在一起容易把人绕晕(16000 看起来像 16 g),实测时先把这一步核对清楚,能快速判断量程和接线对不对。
/* ── 代码位 ④ LIS3DH 初始化与自检 ───────────────────── */
/* 7 位地址 0x19/0x18,HAL 要传左移一位后的 8 位:0x32 / 0x30 */
static const uint16_t cand[2] = { 0x30, 0x32 };
uint8_t id, found = 0;
for (uint32_t i = 0; i < 2U; i++)
{
s_addr = cand[i];
if ((lis3dh_device_id_get(&s_ctx, &id) == 0) && (id == LIS3DH_ID))
{
found = 1U;
break;
}
}
if (found == 0U) { return ERR_ID; } /* 总线上没人应答:查供电和上拉 */
/* 顺序按 ST 官方例程:BDU → ODR → 量程 → 工作模式 */
lis3dh_block_data_update_set(&s_ctx, PROPERTY_ENABLE);
lis3dh_data_rate_set(&s_ctx, LIS3DH_ODR_5kHz376_LP_1kHz344_NM_HP);
lis3dh_full_scale_set(&s_ctx, LIS3DH_2g);
lis3dh_operating_mode_set(&s_ctx, LIS3DH_HR_12bit);地址做成「两个都试一遍」很划算:模块上 SA0 焊在哪一侧、有没有悬空,事先都不知道,探测一次只要几百微秒,能省掉一整轮「换线—重烧—再看」的来回。
2.3 读取:一次事务拿完
从 0x27 开始连续读 7 个字节,子地址最高位置 1 打开自动递增,这样 STATUS 和六个数据字节来自同一次采样,不会出现 XYZ 拼错帧的情况:
uint8_t buf[7];
lis3dh_read(0x27 | 0x80, buf, 7);
if (buf[0] & 0x08) { /* ZYXDA */
x = (int16_t)((buf[2] << 8) | buf[1]);
y = (int16_t)((buf[4] << 8) | buf[3]);
z = (int16_t)((buf[6] << 8) | buf[5]);
}2.4 串口数据格式
每采满一个窗口,输出一段带标记的文本:块头一行,中间逐点一行,块尾一行。
#BLOCK 132 t=50294 n=512 err=0 ovr=0 24,-26,1013 21,-28,1008 22,-27,990 ... #END
| 块头 | #BLOCK <序号> t=<毫秒> n=<点数> err=<读失败次数> ovr=<丢点数> |
| 数据行 | 每行一个采样点,x,y,z 逗号分隔,单位 mg |
| 块尾 | #END |
| 每块点数 | 512(每轴),合计 1536 个整数 |
| 数值 | 已换算成 mg,不是原始寄存器值 |
| 行尾 | CRLF |
为什么不做成「一行 1536 个数字」:那样在串口助手里根本没法看,出了问题也没法定位是哪一段坏了。加 #BLOCK / #END 之后,PC 端脚本按标记切块,还能顺手读到 err 和 ovr 两个健康指标——它们直接告诉我这一块有没有 I2C 读失败、有没有丢点。
要喂给 NanoEdge 时,再离线转成它要求的宽格式(一行 1536 个数、空格分隔)。转换脚本几行就够,比在 MCU 上直接拼长行省事也更可靠。
/* ── 代码位 ⑤ 窗口缓冲与队列投递 ────────────────────── */
/* 三块缓冲轮转:ISR 写、线程读,互不覆盖。
* 只给两块不够——线程格式化那几毫秒里,ISR 会没有空缓冲可用。 */
static int16_t s_buf[3][512][3];
/* TIM12 中断,1344 Hz */
void sampler_on_tick(void)
{
int16_t raw[3];
if (lis3dh_app_read_raw_fast(raw) != 0)
{
s_err_cnt++; /* 读失败:这一拍丢点,但不停采集 */
return;
}
s_buf[s_widx][s_count][0] = raw[0];
s_buf[s_widx][s_count][1] = raw[1];
s_buf[s_widx][s_count][2] = raw[2];
if (++s_count < 512U) { return; }
/* 采满:投递缓冲"索引",不是指针,更不是数据本身 */
uint32_t idx = s_widx;
if (tx_queue_send(&s_ready_q, &idx, TX_NO_WAIT) == TX_SUCCESS)
{
s_block_idx++;
}
else
{
s_overrun_cnt += 512U; /* 队列满 = 线程严重落后,整块丢弃 */
}
s_count = 0U;
s_has_buf = 0U; /* 交出写权限,下一拍再从空闲队列领一块 */
}队列里流动的只有 4 字节的索引(0/1/2),3 KB 的数据一直躺在 s_buf 里不动。这一点很关键:如果按"把整块缓冲拷进队列"的直觉写,ISR 里就要搬 3 KB,744 µs 的中断根本扛不住。
3. 线程之间怎么配合
3.1 分工
| sampler 中断(TIM12) | 读 I2C、攒窗口、投递索引 | 不碰串口、不碰电机 |
| acq_thread | 取块 → 推理 → 格式化 → 启动 DMA | 不读 I2C(中断做)、不改电机状态 |
| motor_thread | 收命令 → 起停定时器 → 等完成信号 | 不出脉冲(TIM7 中断做)、不碰 I2C |
3.2 同步方式
启停 / 调速 / 定步数用消息队列下发,一条命令带上参数(走几步、最高频率)。原本想用事件标志组,后来发现标志只能表达「有事件」,带不了参数,而且连按两次会互相覆盖;队列天生排队,两次命令会依次执行;
窗口缓冲区用另一对队列投递索引,避免发送时阻塞采样;
串口是三个线程共用的(采集、电机、命令行),加一把互斥量。这里要用 TX_INHERIT 优先级继承,否则低优先级线程持锁时被中优先级线程抢占,会把等高优先级线程一起拖住。
/* ── 代码位 ⑥ 线程间同步 ────────────────────────────── */
/* 命令结构:走几步 + 最高频率 */
typedef struct { int32_t steps; uint32_t f_max_hz; } motor_cmd_t;
/* 注意 message_size 的单位是"32 位字",不是字节 */
#define CMD_WORDS (sizeof(motor_cmd_t) / sizeof(ULONG))
static ULONG cmd_mem[4 * CMD_WORDS];
tx_queue_create(&motor_cmd_q, "motor_cmd", CMD_WORDS, cmd_mem, sizeof(cmd_mem));
/* 下发方(命令行线程):不等待,投不进去就说明队列满了 */
tx_queue_send(&motor_cmd_q, &cmd, TX_NO_WAIT);
/* 执行方(电机线程):阻塞等命令 */
tx_queue_receive(&motor_cmd_q, &cmd, TX_WAIT_FOREVER);
/* 串口互斥量:三个线程共用同一个 USART2 */
tx_mutex_create(&uart_mutex, "uart", TX_INHERIT);message_size 这个参数坑过一次:它在 ThreadX 里的单位是32 位字,容量算法是 queue_size / (message_size × 4)。我按"4 字节就是 1 条消息"给了存储区,结果算出来容量是 0,队列创建返回失败——而返回值当时没检查,后面所有收发都静默报错,表现为「ISR 每拍都领不到缓冲,丢点计数暴涨」。凡是有返回值的创建类 API,一定要看返回值。
3.3 一个必须注意的点
串口发送如果用阻塞方式(HAL_UART_Transmit),一块 1536 个整数约 13 KB,在 921600 下要发 141 ms。这 141 ms 里采集线程完全停摆,窗口就断了——早期版本实测每块丢 112 个点,正好是发送耗时。
所以改用 DMA 发送:启动后线程挂在信号量上(不占 CPU),DMA 搬完在中断里 put 一次把它唤醒。发送这段时间里,定时器还在照常采样,只是往另一块缓冲里写。
/* ── 代码位 ⑦ 串口非阻塞发送 ────────────────────────── */
/* 启动 DMA:立刻返回,线程随即挂到信号量上 */
if (HAL_UART_Transmit_DMA(&huart2, (uint8_t *)buf, len) == HAL_OK)
{
s_dma_pending = 1U;
tx_semaphore_get(&tx_done_sem, TX_WAIT_FOREVER); /* 挂起,不占 CPU */
}
/* 发送完成:中断上下文,只能调 ISR 安全的 API */
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *h)
{
if ((h->Instance == USART2) && (s_dma_pending != 0U))
{
s_dma_pending = 0U;
tx_semaphore_put(&tx_done_sem); /* 唤醒采集线程 */
}
}那个 s_dma_pending 标志不是多余的。USART2 同时开着接收中断(命令行用),接收溢出等错误也会走到 HAL_UART_ErrorCallback。如果在错误回调里也 put 一次,信号量就会多记一次,下一轮 get 立刻返回虚假的「发送完成」——而实际上 DMA 还在传,这时候去改缓冲区,数据就花了。加个「是否真的有发送在途」的标志,两个回调都受它约束。
另外,普通 DMA 模式下发完不会直接调 TxCpltCallback:DMA 中断里只是把 TCIE(发送完成中断)打开,等最后一个字节真正移出移位寄存器、USART 的 TC 中断进来,才回调。所以那一刻缓冲区确实可以安全复用了。
4. 踩过的坑
HAL 时基被 RTOS 饿死 → 现象是 HAL_Delay(100) 实际等了 4.7 秒,所有 HAL 超时都跟着失真。查了半天:TIM15 的 PSC、ARR、输入时钟全部正确(用 SWD 直接读寄存器、甚至数计数器步进确认过),但中断实际只有 ~21 Hz。原因是 TIM15 的 NVIC 优先级是 15,和 ThreadX 的 PendSV 同优先级,而 PendSV 每秒被置位几千次,同级不能互相抢占,时基中断只能捡空档跑。把时基中断优先级提到 6 就恢复正常。同一个坑后面又踩了一次:串口接收中断也是 7、和采样定时器同级,导致上位机命令随机丢 30%——同样的解法,把优先级提上去。
队列创建失败但没检查返回值 → 现象是采集线程在队列接收函数里空转,同时「丢点计数」几分钟涨到七万多。原因有两层:一是 tx_queue_create() 的 message_size 单位是 32 位字而不是字节,我按字节算导致容量被算成 1(等于单缓冲);二是返回值被 (void) 掉了,创建失败后所有收发都静默报错。改成按字传参、并且检查每一个创建类 API 的返回值。
发送完才归还缓冲 → 现象是每块稳定丢 112 个点。112 × 744 µs ≈ 83 ms,正好等于一次 DMA 发送的耗时,顺着这个数字就定位到了。原因是「发完再还」这个直觉顺序:发送期间 ISR 手里没有空缓冲,只能丢点。改成格式化完立刻归还(数据已经复制到发送缓冲里了),再用三块缓冲兜住格式化那几毫秒,丢点归零。
6. 小结
到这一步,两条链路都通了:电机能按设定转速稳定转动,加速度数据能以固定窗口格式从串口持续输出,三条执行流互不干扰。下一篇进入 NanoEdge AI Studio,用这批数据训练「静止 / 低速 / 高速」三分类模型,并把生成的 C 库部署回 STM32 做在线推理。
我要赚赏金
