电子产品世界 » 论坛首页 » DIY与开源设计 » 电子DIY » 【let'sdo2026年第3期】过程帖|ThreadX双线程:先让步进电机转起

共1条 1/1 1 跳转至页

【let'sdo2026年第3期】过程帖|ThreadX双线程:先让步进电机转起来,再把振动数据送上串口

菜鸟
2026-11-14 10:26:00     打赏
0. 工程准备

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 KB10 ms 轮询

之所以这么拆,是因为采样必须绝对准时——线程唤醒有调度延迟,744 µs 周期里抖 30 µs 就是 4%,FFT 上看得见;所以读传感器这件事放在定时器中断里做,线程只负责搬运。而电机对时序的要求反而宽松,晚几微秒完全无感。两者的优先级,恰恰和直觉相反。

屏幕截图 2026-11-14 085526.png

创建线程的代码基于 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:

MCU 引脚ULN2003说明
PC12IN1A 相
PC10IN2B 相
PC11IN3C 相
PD2IN4D 相


/* ── 代码位 ② 四相 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 八拍半步序列

用八拍半步驱动,比四拍整步平顺,振动小一些,也更适合后面做状态区分:

拍IN1IN2IN3IN4十六进制
110000x01
211000x03
301000x02
401100x06
500100x04
600110x0C
700010x08
810010x09

序列正着走是正转,倒着走就是反转,不用改接线。

半步节拍交给硬件定时器(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 了。


微信图片_20261114101936_509_4.jpg

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_REG10x200x97ODR 档位 1001(Normal 模式 1.344 kHz)+ XYZ 使能
CTRL_REG40x230x88BDU=1,±2g,高分辨率
STATUS_REG0x27—bit3 = ZYXDA,新数据就绪
OUT_X_L~OUT_Z_H0x28~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. 踩过的坑

  1. HAL 时基被 RTOS 饿死 → 现象是 HAL_Delay(100) 实际等了 4.7 秒,所有 HAL 超时都跟着失真。查了半天:TIM15 的 PSC、ARR、输入时钟全部正确(用 SWD 直接读寄存器、甚至数计数器步进确认过),但中断实际只有 ~21 Hz。原因是 TIM15 的 NVIC 优先级是 15,和 ThreadX 的 PendSV 同优先级,而 PendSV 每秒被置位几千次,同级不能互相抢占,时基中断只能捡空档跑。把时基中断优先级提到 6 就恢复正常。同一个坑后面又踩了一次:串口接收中断也是 7、和采样定时器同级,导致上位机命令随机丢 30%——同样的解法,把优先级提上去。

  2. 队列创建失败但没检查返回值 → 现象是采集线程在队列接收函数里空转,同时「丢点计数」几分钟涨到七万多。原因有两层:一是 tx_queue_create() 的 message_size 单位是 32 位字而不是字节,我按字节算导致容量被算成 1(等于单缓冲);二是返回值被 (void) 掉了,创建失败后所有收发都静默报错。改成按字传参、并且检查每一个创建类 API 的返回值。

  3. 发送完才归还缓冲 → 现象是每块稳定丢 112 个点。112 × 744 µs ≈ 83 ms,正好等于一次 DMA 发送的耗时,顺着这个数字就定位到了。原因是「发完再还」这个直觉顺序:发送期间 ISR 手里没有空缓冲,只能丢点。改成格式化完立刻归还(数据已经复制到发送缓冲里了),再用三块缓冲兜住格式化那几毫秒,丢点归零。

6. 小结

到这一步,两条链路都通了:电机能按设定转速稳定转动,加速度数据能以固定窗口格式从串口持续输出,三条执行流互不干扰。下一篇进入 NanoEdge AI Studio,用这批数据训练「静止 / 低速 / 高速」三分类模型,并把生成的 C 库部署回 STM32 做在线推理。





关键词: ThreadX     过程     步进电机    

共1条 1/1 1 跳转至页

回复

匿名不能发帖!请先 [ 登陆 注册 ]