简介面向嵌入式初学者与STM32项目实践者的步进电机控制例程围绕手机APP经蓝牙串口与STM32通讯、由定时器生成脉冲驱动电机展开可用于智能家居、自动化设备等需要远程定位与速度调节的场景。压缩包为rar格式共479个文件、约71.95MB以C源文件和头文件为主辅以编译中间文件、工程配置及说明文档保留较完整的工程结构与调试产物。已有841人学习下载。内容覆盖主程序初始化、蓝牙串口协议处理、步进电机驱动、定时器中断配置和APP命令解析等模块读者可据此理解正反转、速度调整与脉冲频率控制的实现思路借鉴串口收发数据组织、命令解析流程与目录结构快速复现实验或将例程迁移到自有控制方案中。1. 从手机按钮到电机轴一条命令要走完的四段路步进电机、STM32、串口通讯、定时器这几个词凑在一起最容易出问题的地方往往在脉冲发得准不准接线本身反倒还是其次。手机 App 上按一下「正转 10 圈」蓝牙透传模块把一帧命令送进 USART固件解析后交给定时器生成对应数量的脉冲驱动器放大电流推着电机走——这条链上任何一环参数不对电机要么转不到位要么原地干嚎不转。这份资源给的是完整的一套STM32 侧的定时器脉冲生成、串口帧解析与校验、蓝牙透传链路以及手机端怎么把界面上的按钮变成一条合法命令帧。它不是闪灯级别的入门例程而是带步数统计、带回传应答、能加减速的可用原型。适合谁手里已经有 STM32 最小系统板、一个步进电机加驱动器、一个蓝牙串口模块想做小型滑台、云台或者机械臂夹爪又不想从零造轮子的人。新手可以照着接线和改参数跑起来熟手可以直接跳到协议帧和定时器配置那几段看参数边界。2. 选型与接线五个部件串成一条能跑通的链路先把手上的东西摊开对一遍很多「代码没错但不转」的问题根源在这一章。2.1 五个部件各自负责什么整条链路是手机 App → 蓝牙透传模块 → STM32 的 USART → 定时器 → 驱动器 → 电机。每一段都有自己的坑先看清单。部件常见做法关键参数容易忽略的点步进电机42 规格两相混合式步距角 1.8°200 步/圈相线颜色各家不同以驱动器说明为准驱动器小电流细分模块或带光耦的大电流模块细分 1/81/32电流 12A电流设大了低速啸叫设小了重载丢步主控STM32 最小系统板72MHz至少 1 路通用定时器 1 路 USART定时器通道要能映射到实际引出的引脚蓝牙串口透传模块出厂 9600可改 1152003.3V 逻辑电平TX/RX 必须交叉手机端串口调试类 App 或可视化搭的界面支持 HEX 发送文本模式和 HEX 模式发出的字节完全不同这张表里最容易被跳过的是「定时器通道映射」这一行。STM32 同一个定时器的不同通道可能分布在不同的端口上比如某一路在 PA6另一路却在 PB0焊接前先翻引脚复用表确认比飞线强。2.2 驱动器细分怎么定电流怎么调驱动器的细分决定了每圈需要多少脉冲。换算很简单每圈脉冲数 200 × 细分数细分 8 就是 1600 脉冲/圈细分 16 就是 3200。细分不是越高越好细分越高同样的转速需要越高的脉冲频率定时器中断的压力也越大细分太低低速时抖动明显、噪声大。我一般先用 1/8 或 1/16 打底跑顺了再按噪声和低速平稳度往上调。电流设定通常靠驱动器上的电位器或者拨码按电机额定电流的 70%100% 起步。判断标准很直接空载运行十分钟摸一下电机外壳和驱动器散热片温热可以接受烫手就是电流给大了。接线方式分共阳和共阴两种。共阴接法是 STM32 的脉冲、方向、使能三根信号直接进驱动器驱动器内部光耦另一端接地共阳接法则需要把公共端接到驱动器侧电源正STM32 输出低电平有效。两种接法逻辑极性相反代码里对 PUL 的有效电平判断要跟着改这一点后面第五章还会提到。还有一个硬指标驱动器对脉冲宽度的要求多数模块是 ≥2.5µs。PWM 占空比 50% 的时候只要频率不超过 100kHz 就肯定满足所以正常用法不用特别操心但如果你把脉冲频率拉到很高、占空比又设得很小就会踩到这个下限。2.3 蓝牙透传模块进 AT 模式改参数出厂状态一般是 9600 波特率、从机模式、名字一串乱码。9600 在长帧场景下丢包概率不低建议先改成 115200。改参数要先把模块切进命令模式常见做法是按住模块上的 KEY 键再上电此时指示灯慢闪串口参数变成命令模式专用的一组和通信模式不是一回事具体看模块说明。# 用串口助手或命令行串口工具逐条发模块先进入命令模式 AT # 回 OK 说明已经进命令模式 ATNAMEMotorCtl # 改设备名手机扫描时一眼能认出来 ATUART115200,0,0 # 波特率 1152001 位停止位无校验 ATROLE0 # 从机模式等手机主动连 ATRESET # 复位后新参数生效每条命令后面要跟回车换行返回OK才算成功返回ERROR大概率是波特率不对或者没进命令模式。改完之后模块重新上电此时才是通信模式手机端要按新波特率去连。这里有个血泪经验改完波特率忘了同步改 STM32 的 USART 初始化现象就是「蓝牙能连上、但发什么都没反应」查半天代码最后发现是两边速率不一致。改参数的时候蓝牙和固件两边必须一起改。2.4 手机端两条路按需选第一条路是用现成的蓝牙串口调试 App界面上能自定义按钮每个按钮绑定一串十六进制数据点一下就发出去。这种方式十分钟能搭起来适合先验证链路通不通。第二条路是用可视化平台自己拼界面放几个滑块控制速度、几个按钮控制方向和步数后台拼成固定格式的帧再发。适合要做成演示或者交付给非技术人员用。不管走哪条路手机端发出的必须是一串字节不是人类可读的文本。App 里如果只有「发送文本」的选项发出去的是 ASCII 码固件按二进制帧解析必然对不上这是新手最常见的第一个翻车点。3. 定时器发脉冲从步距角算出 ARR 与加减速档位链路通了接下来是让脉冲又准又可控。3.1 为什么不能用 delay 发脉冲用delay_us()加 GPIO 翻转是最直观的写法但它在真实场景里几乎不能用。原因有三个一是阻塞式延时会占住 CPU串口中断来了响应不及时手机连发两条命令就丢二是延时函数的实际精度受编译优化和中断影响频率偏差可能到百分之几走 10000 步的误差肉眼可见三是没法在运行中平滑地改频率加减速无从谈起。定时器的价值就在这里脉冲由硬件产生CPU 只在每个脉冲周期结束时进一次中断做计数和调速剩下的时间全留给串口和业务逻辑。3.2 PSC 与 ARR把转速换算成寄存器值STM32 的定时器时钟来自 APB 总线72MHz 主频下通用定时器一般是 72MHz。脉冲频率的公式是f_pulse f_tim / ((PSC 1) × (ARR 1))先把 PSC 固定成 71计数频率就是 1MHz这样 ARR 直接等于「一个脉冲周期内计数多少次减一」心算方便ARR 1000000 / f_pulse - 1再往前推一步转速和脉冲频率的关系是f_pulse 转速(rpm) / 60 × 200 × 细分把两张表对照着看更直观细分每圈脉冲30 rpm 需要120 rpm 需要对应 ARR120rpm1200100 Hz400 Hz249981600800 Hz3200 Hz3111632001600 Hz6400 Hz1553264003200 Hz12800 Hz77这张表能看出边界细分 32 跑 120rpmARR 只有 77中断频率 12.8kHz如果中断里还干了别的事CPU 占用会明显上升。所以高细分配高转速是要付代价的一般中小型机构不必追到 1/32。3.3 PWM 输出模式与翻转模式的取舍定时器输出脉冲有两条路。一条是 PWM 模式占空比设 50%每个周期输出一个完整方波驱动器在上升沿计一步。另一条是输出比较翻转模式每次比较匹配翻转一次电平两个匹配周期才凑成一个完整脉冲。我现在基本都用 PWM 模式理由是配置简单、占空比可控、能保证最小脉宽而且更新中断和脉冲周期天然对齐计数不会错。翻转模式的优势是可以用一个通道同时给出两路反相脉冲但普通两相步进驱动用不上。3.4 初始化代码与固定步数控制/* TIM3 CH1 输出步进脉冲PA6 引出到驱动器 PULDIR 和 ENA 用普通 GPIO */ #define TIM_CLK_HZ 72000000UL #define CNT_CLK_HZ 1000000UL /* PSC 71计数频率 1MHz */ static void StepPulse_Init(uint32_t freq_hz) { GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef tb; TIM_OCInitTypeDef oc; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); gpio.GPIO_Pin GPIO_Pin_6; /* TIM3_CH1 */ gpio.GPIO_Mode GPIO_Mode_AF_PP; /* 复用推挽脉冲要陡 */ gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); tb.TIM_Prescaler 71; /* 72MHz / 72 1MHz */ tb.TIM_Period CNT_CLK_HZ / freq_hz - 1; tb.TIM_CounterMode TIM_CounterMode_Up; tb.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, tb); oc.TIM_OCMode TIM_OCMode_PWM1; oc.TIM_OutputState TIM_OutputState_Enable; oc.TIM_Pulse (tb.TIM_Period 1) / 2; /* 50% 占空比脉宽远大于 2.5us */ oc.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM3, oc); TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM3, ENABLE); /* ARR 预装载改频率时不会产生畸形脉冲 */ TIM_ClearITPendingBit(TIM3, TIM_IT_Update); TIM_ITConfig(TIM3, TIM_IT_Update, ENABLE); /* 每个脉冲周期进一次中断做计数 */ NVIC_EnableIRQ(TIM3_IRQn); }代码里TIM_ARRPreloadConfig这一句别省。开了预装载新写入的 ARR 要等到当前周期结束才生效调速过程中不会出现一个周期被截断的情况不开的话运行中改 ARR 可能产生一个极窄的脉冲驱动器直接漏计一步。脉冲计数放在更新中断里volatile uint32_t g_steps_left 0; /* 0 表示连续运行不清零就停不下来 */ void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); if (g_steps_left ! 0) { g_steps_left--; if (g_steps_left 0) { /* 走完预定步数立刻停脉冲 */ TIM_Cmd(TIM3, DISABLE); TIM_Cmd(TIM3, DISABLE); /* 双保险老型号关一次可能残留 */ Motor_OnArrived(); /* 回一帧应答给手机 */ } } } }g_steps_left用 32 位是因为实际项目里一次走几十万步很常见16 位溢出后会多走一大截做滑台时直接撞限位。3.5 加减速把 ARR 一档一档挪过去频率突变的后果是丢步。电机从静止直接吃 6kHz 的脉冲转子跟不上定子磁场表现为「嗡嗡响但轴不转」或者「转是转了位置差了十几度」。常见做法是梯形曲线加速段把 ARR 从大往小逐周期递减匀速段保持减速段再递增回去。/* 每个脉冲周期调一次arr_now 向 arr_target 逼近 */ static void Accel_Tick(void) { uint32_t step 8; /* 每周期最多改 8越小越平滑、加速越慢 */ if (g_motor.arr_now g_motor.arr_target) { g_motor.arr_now (g_motor.arr_now - g_motor.arr_target step) ? g_motor.arr_now - step : g_motor.arr_target; } else if (g_motor.arr_now g_motor.arr_target) { g_motor.arr_now (g_motor.arr_target - g_motor.arr_now step) ? g_motor.arr_now step : g_motor.arr_target; } TIM3-ARR g_motor.arr_now; /* 预装载已开下一周期生效 */ }step这个参数决定加速时间。以 ARR 从 999 降到 155 为例跨度 844每周期减 8 要 105 个脉冲周期按 6.4kHz 算大约 16ms负载大的机构会觉得偏急可以减到 24。经验值是空载可以激进带皮带或丝杠的机构一律把step压到 2多花的加速时间通常不到 100ms但丢步概率下降一个量级。4. 串口收命令帧格式、DMA 空闲中断与命令分发脉冲这一层稳了接下来解决「手机发来的这串字节到底怎么可靠地变成动作」。4.1 帧格式先把协议定死透传模块进来的是纯字节流没有边界。必须自己约定帧头、长度和校验否则一帧发一半断掉解析器就会错位后续所有命令全乱。字段长度说明帧头2 字节固定 0xA5 0x5A远离常见数据的取值范围长度1 字节载荷长度不含帧头、长度和校验命令字1 字节0x01 设速度0x02 走步数0x03 连续转0x04 停0x05 查状态数据N 字节小端序多字节参数低位在前校验1 字节命令字到数据末尾的累加和取低 8 位帧头选0xA5 0x5A而不是0xAA 0x55是因为后者在数据区里出现概率偏高虽然长度字段能兜底但少一次误判就少一个深夜加班。4.2 DMA 空闲中断不占 CPU 地收数据串口接收有三种写法。轮询是最省事也最不可靠的单字节中断在 115200 下每 87µs 进一次中断命令密集时占用不小DMA 加空闲中断是现在的常规做法DMA 把字节搬进缓冲区一帧发完总线空闲时进一次中断CPU 几乎无感。#define RX_BUF_SIZE 128 static uint8_t rx_dma_buf[RX_BUF_SIZE]; void Usart1_RxDma_Init(void) { DMA_InitTypeDef dma; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel5); /* USART1_RX 通常挂在通道 5 */ dma.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; dma.DMA_MemoryBaseAddr (uint32_t)rx_dma_buf; dma.DMA_DIR DMA_DIR_PeripheralSRC; dma.DMA_BufferSize RX_BUF_SIZE; dma.DMA_PeripheralInc DMA_PeripheralInc_Disable; dma.DMA_MemoryInc DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; dma.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; dma.DMA_Mode DMA_Mode_Circular; /* 循环模式配合空闲中断取长度 */ dma.DMA_Priority DMA_Priority_High; DMA_Init(DMA1_Channel5, dma); DMA_Cmd(DMA1_Channel5, ENABLE); USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); /* 空闲中断是这套方案的关键 */ USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); } void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { volatile uint32_t tmp USART1-SR; /* 先读 SR */ tmp USART1-DR; /* 再读 DR这两步才能清 IDLE 标志 */ (void)tmp; uint16_t len RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); if (len 0 len RX_BUF_SIZE) { Frame_Push(rx_dma_buf, len); /* 送进解析器别在中断里解析 */ } DMA_Cmd(DMA1_Channel5, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel5, RX_BUF_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); } }两个细节值得单独说。第一清 IDLE 标志必须「先读 SR 再读 DR」只读 SR 清不掉会反复进中断。第二判断这一帧长度不能自己猜得用总长度 - 剩余计数循环模式下这个差值是准的但必须在重新装载计数器之前取。Frame_Push里只做一件事把字节拷进环形缓冲函数立刻返回。解析、校验、执行动作全部放到主循环里做。中断里干重活是脉冲不均匀的头号元凶后面第五章会具体讲这个现象。4.3 解析状态机与命令分发解析器按字节推进遇到帧头才开始攒长度和校验都对上才交付。typedef enum { ST_HEAD0, ST_HEAD1, ST_LEN, ST_BODY } ParseState_t; static void Frame_ParseByte(uint8_t b) { static ParseState_t st ST_HEAD0; static uint8_t len 0, idx 0; static uint8_t body[16]; switch (st) { case ST_HEAD0: if (b 0xA5) st ST_HEAD1; break; case ST_HEAD1: st (b 0x5A) ? ST_LEN : ST_HEAD0; break; case ST_LEN: if (b 2 b sizeof(body)) { len b; idx 0; st ST_BODY; } else st ST_HEAD0; /* 长度离谱直接丢回到找帧头 */ break; case ST_BODY: body[idx] b; if (idx len) { uint8_t sum 0; for (uint8_t i 0; i len - 1; i) sum body[i]; if (sum body[len - 1]) Frame_Dispatch((Frame_t *)body); st ST_HEAD0; /* 无论校验对错都复位等下一条 */ } break; } }分发部分用 switch每个分支先校验参数范围再执行非法参数回一帧错误应答不要让电机带着错误值跑起来。static void Frame_Dispatch(const Frame_t *f) { switch (f-cmd) { case CMD_SET_SPEED: { uint16_t hz (uint16_t)(f-data[0] | (f-data[1] 8)); if (hz 50 || hz 20000) { Send_Ack(f-cmd, ERR_RANGE); return; } g_motor.freq_target hz; Send_Ack(f-cmd, ERR_NONE); break; } case CMD_MOVE_STEPS: { uint32_t n (uint32_t)f-data[0] | ((uint32_t)f-data[1] 8) | ((uint32_t)f-data[2] 16) | ((uint32_t)f-data[3] 24); uint8_t dir f-data[4] 0x01; if (g_motor.state MOTOR_RUNNING) { Send_Ack(f-cmd, ERR_BUSY); return; } if (n 0 || n 500000UL) { Send_Ack(f-cmd, ERR_RANGE); return; } Motor_MoveSteps(n, dir); Send_Ack(f-cmd, ERR_NONE); break; } case CMD_STOP: Motor_StopNow(); Send_Ack(f-cmd, ERR_NONE); break; case CMD_QUERY: Send_Status(); break; default: Send_Ack(f-cmd, ERR_UNKNOWN); break; } }注意CMD_MOVE_STEPS里对上限做了 50 万步的限制。不做限制的话App 上一个误触输入框填了0xFFFFFFFF电机会一直转到撞限位或者丢步丢到失步机械结构先坏的往往不是电机。4.4 回传与超时保护每执行一条命令回一帧简短应答手机端就能显示「已执行」还是「被拒了」联调效率差很多。static void Send_Ack(uint8_t cmd, uint8_t err) { uint8_t buf[6] { 0xA5, 0x5A, 0x03, cmd, err, 0 }; buf[5] (uint8_t)(cmd err); Usart_SendBytes(buf, 6); /* 发送可以直接阻塞或走 DMA量很小 */ }超时保护在连续控制场景下必须加如果超过 2 秒没收到任何合法帧连心跳帧也没有强制停机。蓝牙链路断开时手机端不会通知固件模块只是安静地不再送数据没有超时保护的话电机会按最后一条命令一直跑下去。5. 避坑实录联调现场反复出现的 5 个故障与排查顺序这一章都是实际调试里反复遇到的按现象、原因、解决三段写遇到问题可以照着比对。5.1 电机嗡嗡响但不转轴只是轻微抖动现象给电、发命令能听到驱动器有节奏的电流声轴在原地小幅抖动十几秒后驱动器开始发烫。原因基本是相序错了。两相四线电机有 A A- B B-接线顺序错一对定子磁场就在两个位置之间来回拉扯转不起来。另一种可能是脉冲频率超过了电机的启动频率——静止状态下直接给 6kHz转子根本跟不上。解决先断脉冲用万用表量相通断把同一相的线找出来接到驱动器的同一组。接对了还是抖就把初始频率降到 200Hz 试一次能转说明是启动频率问题需要加减速。最深的一个坑是有人把驱动器公共端的接法和 STM32 输出极性搞反了脉冲在高电平有效和低电平有效之间切换看似有波形实际驱动器一次都没计到示波器一量立刻现形。5.2 蓝牙连上了手机发数据电机毫无反应现象手机能搜索到设备、能配对、能连接成功App 上点发送按钮固件里打断点或者点灯都没反应。原因按出现概率排第一是 App 用了文本发送模式发出去的是字符01而不是字节0x000x01帧头对不上第二是两边波特率不一致改了模块忘了改固件第三是 TX/RX 没交叉模块的 TX 接了 STM32 的 TX两边都在说话谁也听不见谁第四是共地没接蓝牙模块和主控各用一套电源地没连在一起电平参考对不上。解决用一个 USB 转串口板直接并在蓝牙模块的 TX 上看数据或者把 STM32 的 RX 短接到自己的 TX 做回环测试。回环通了说明固件接收链路没问题问题在模块侧回环不通说明初始化代码或者引脚映射错了。这个自测步骤两分钟能做能省掉大量猜测。5.3 电机一转起来蓝牙就掉线或者主控复位现象不发脉冲的时候通信正常一启动电机串口就开始丢包严重的时候主控直接重启屏幕上显示重新初始化。原因典型的电源和干扰问题。电机启停瞬间电流突变如果主控和电机驱动器共用一路电源电压跌落会直接把 MCU 拉到复位阈值以下。另外驱动器信号线如果和电机相线捆在一起走感性负载的尖峰耦合到脉冲信号上会被误当成有效脉冲。解决主控和电机供电分开或者至少加一级 LC 滤波加足够的储能电容信号线用屏蔽线或者双绞和电机相线分开走线驱动器的信号输入如果是光耦隔离型把两侧的地彻底分开。我一般还会把脉冲频率降一档再测如果降速后不掉线了说明就是电源裕量不够不是代码问题。5.4 低速时抖动明显噪声大得像在打齿现象频率 200Hz 以下运行电机声音沉闷手摸机构能明显感到每步一顿速度提上去反而顺了。原因细分数设得太低。细分 1 或者 2 的时候每个脉冲走一整个步距角两相之间的转矩过渡是跳变的低速下共振最明显。另一种原因是电流设得太小输出转矩不足以维持平稳过渡。解决把细分调到 1/8 以上劳动强度最低的一步如果机械结构刚性不足比如长皮带、薄板支架共振可能没法完全消除可以在共振转速区间直接跳过去——加减速时让 ARR 快速穿过那一段不在上面停留。做过的项目里同一条滑台在细分 1/16 加电流上调 20% 之后噪声降了大约一半。5.5 走 1000 步实际只走了 990 多步位置慢慢偏现象单次动作看不出来来回走几十次之后位置累积偏移回不到原点。原因四个方向查。第一脉冲计数中断里干了耗时的事比如调用串口打印函数导致某个脉冲周期被拉长驱动器漏计第二ARR 改得太急加速段的初始频率已经超过电机的牵入频率每次启动丢几步第三驱动器方向信号换向时没有建立时间DIR 刚翻转脉冲就来了第一拍方向是错的第四机械本身有反向间隙。解决中断里只做计数和最简的 ARR 调整任何打印都挪到主循环方向信号和第一个脉冲之间插至少 5µs 延时把加速步长从 8 降到 2 再测一次。如果偏移量变成了固定值比如每次换向都差那么几步那就是机械回差只能靠软件补偿或者换机构跟代码没关系了。6. 进阶验证把脉冲和协议帧一起抓到逻辑分析仪上调到最后一定会遇到那种「代码看着没问题、现象就是不对」的玄学时刻这时候靠猜没用得看波形。我的做法是逻辑分析仪四个通道分别接脉冲 PUL、方向 DIR、STM32 的 TX、STM32 的 RX。一次抓够 10 秒协议和脉冲同步展开能同时回答三个问题命令帧到底有没有被收进来、解析后有没有真的启动定时器、脉冲数量和频率对不对。观察点正常表现异常对应STM32 RX 脚上有没有 0xA5 0x5A每次按键都有完整一帧没有说明手机或模块侧就没发出来PUL 起始频率从低到高平滑爬升无跳变突变说明加减速没生效PUL 总个数与命令里的步数完全一致少了说明中断漏计或被拉长PUL 与 DIR 先后DIR 稳定至少 5µs 后才出第一个脉冲同时变化说明换向丢步停止后 PUL 电平稳定在低无毛刺有毛刺说明定时器没真正关掉抓的时候有个小技巧把分析仪的采样率调到 1MHz 以上脉冲和串口帧的时序关系才看得清采样率太低会把 25µs 的脉冲拉成一条模糊的线。解码串口的时候直接按 115200、8N1 设置省去手数位的时间。验证完时序还有一个更省事的自检办法在固件里加一个回环模式把 USART 的 TX 直接短接到 RX手机发什么就回什么。链路一通说明蓝牙和串口配置全对剩下的问题必然在解析逻辑或者电机侧排查范围立刻砍一半。还有一个建议值得早点做给每条命令都加一个序号字段手机端发什么序号固件回什么序号。联调的时候一旦出现「命令发了但电机没动」看序号就知道是丢包还是执行失败比翻代码高效得多。这个功能我最初嫌麻烦没加后来一次现场调试电机偶发不动、复现概率大概几十次一次靠打印日志查了两个晚上才定位到是某一帧被 DMA 半个周期截断。从那以后我每次做这类串口控制的板子都强制先把带序号的应答和逻辑分析仪两路抓取走一遍再谈功能。希望帮到你。本文还有配套的精品资源点击获取