简介:这份基于STM32的蓝牙与电机控制工程包,面向具备C语言和单片机基础的嵌入式学习者,围绕蓝牙无线指令驱动直流电机或步进电机的典型场景,帮助理解串口通信、脉宽调制调速、方向控制与调试流程。压缩包共136个文件,约3.15MB,以35个头文件、34个源文件及多份编译中间文件为主,同时包含Keil工程配置、可执行输出、映射清单和分散加载脚本,较完整保留了从源码、外设库到构建产物的项目结构。已有596人学习。读者可据此复现蓝牙服务与电机控制固件的组织方式,参考定时器调速、引脚方向控制和串口指令解析的实现,并借助调试配置排查编译、下载与运行问题;对标准外设库的调用关系、中断与定时器配置也能形成直观认识,适合课程设计、毕业设计前期验证及无线控制入门练习。
1. 从手机按键到轮子转动:这份 STM32 蓝牙电机控制包补的是中间一段
很多人第一次把 STM32、蓝牙串口模块和直流电机凑到一起,会遇到一个反直觉的结果:代码一行都没错,电机单独能转,手机单独能连,可两边一联动,电机就开始抽风、蓝牙就开始掉线。问题通常出在中间的传话、供电和时序环节,与算法无关。这份 zip 覆盖的正是中间这一段——从手机发出的一个字节,到定时器输出的一路 PWM,中间要经过串口中断、环形缓冲、切帧校验、方向脚电平和占空比映射。它适合刚脱离点灯、按键、串口老三样、想直接做整机控制的人,也适合手上硬件已经躺了半个月、缺一份能对照排查的参考工程的人。下面按解压、接线、收包、闭环、排坑的顺序拆开说。
2. 拆包看骨架:一个能跑的 STM32 蓝牙电机工程由哪四层
拿到压缩包后先别点编译。源码工程类资源最容易浪费时间的步骤,就是上来直接按 F7,然后被几十条“找不到头文件”“器件型号不匹配”顶回来。这个包的结构其实就是标准的分层:硬件抽象层、板级驱动层、协议层、应用层。先花十分钟把层次认清楚,后面改一行代码就知道会动到哪一层,比盲目翻 main.c 快得多。
2.1 四个目录各自的职责,先看 BSP 再看 main
典型的目录长这样:Drivers/放 HAL 库和 CMSIS,基本不用碰;Core/放main.c、stm32f1xx_it.c、stm32f1xx_hal_msp.c,是启动和中断入口;BSP/(有的包叫User/或Hardware/)放motor.c、encoder.c、protocol.c、bt_port.c,这才是要读的部分;MDK-ARM/或MDK-ARM/xxx.uvprojx是工程文件,另外常配一份.ioc和一张接线图。
我的习惯是先看.ioc:哪几个脚配成了定时器通道、串口用哪一路、时钟树怎么分的,图形化界面里一眼可见,比在代码里 grep 引脚名快得多。然后再看main.c的初始化顺序和主循环,最后回到stm32f1xx_it.c确认中断服务函数有没有被改写。如果包里没有.ioc,那就先看main.c里每个MX_xxx_Init()的排列,它就是作者的配置清单。
一个必须提前确认的点:工程面向的具体型号。包里只要不是同一颗芯片,启动文件、stm32f1xx.h里的宏、Flash 容量都会对不上,编译过了也可能下载失败。常见做法是打开工程选项里的 Device 一栏,跟手上板子的丝印对一遍,不一致就换器件型号并重选启动文件,别硬编。
2.2 接线:三张表比十次调试都省事
蓝牙和电机同时工作时出的问题,八成来自接线,尤其是共地和串口交叉。把下面三张表抄到纸上,接完逐个打勾。
| 连接项 | 模块侧 | MCU 侧(示例) | 注意 |
|---|---|---|---|
| 串口发送 | 蓝牙模块 RXD | USART1_TX(PA9) | 必须交叉,TX 接 RX |
| 串口接收 | 蓝牙模块 TXD | USART1_RX(PA10) | 同上 |
| 供电 | VCC / GND | 3.3V 或 5V / GND | 看模块电平,3.3V 逻辑别喂 5V 电平 |
| 连接项 | 驱动模块侧 | MCU 侧(示例) | 注意 |
|---|---|---|---|
| 左路调速 | ENA | TIM2_CH1(PA0) | 必须接 PWM 脚,不能接普通 GPIO |
| 右路调速 | ENB | TIM2_CH2(PA1) | 同上 |
| 左路方向 | IN1 / IN2 | PB12 / PB13 | 两个脚不能同时为高 |
| 右路方向 | IN3 / IN4 | PB14 / PB15 | 同上 |
| 电源项 | 做法 | 原因 |
|---|---|---|
| 电机电源 | 单独电池组,电压符合电机额定 | 电机启动瞬间电流是堵转电流的好几倍 |
| 逻辑电源 | MCU 与蓝牙模块共用一路 3.3V | 避免上电时序错乱导致串口乱码 |
| 共地 | 电机电源负极、逻辑电源负极、驱动模块 GND 全部连一起 | 不共地,PWM 信号没有参考点,表现为时转时不转 |
第三张表里“共地”这一条,是我见过翻车最多的。表现是:单独测电压一切正常,一接电机 PWM 就乱,示波器一看信号幅度被抬高了几伏。原因就是两块电源浮着,信号回流路径不存在。
2.3 时钟、PWM 频率和引脚:改之前先记在纸上
初始化顺序不要乱,标准写法是时钟、GPIO、定时器、串口,最后才启动通道。下面这段是常见的骨架,放在main.c的main()里、while(1)之前:
/* 以 72MHz 主频的型号为例:HSE 8MHz × PLL9 = 72MHz */ SystemClock_Config(); MX_GPIO_Init(); /* 方向脚、使能脚、LED,统一先输出低电平 */ MX_TIM2_Init(); /* PWM:72MHz / 72 = 1MHz 计数,ARR = 999 → 1kHz */ MX_TIM3_Init(); /* 编码器接口:TI1/TI2 双边沿计数,ARR = 65535 */ MX_USART1_UART_Init(); /* 9600 8N1,与蓝牙模块出厂参数对齐 */ HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_2); HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL); HAL_UART_Receive_IT(&huart1, &rx_byte, 1); /* 只挂 1 字节,中断频率可控 */几个参数的实际含义:预分频PSC=71把 72MHz 分到 1MHz,自动重装ARR=999得到 1kHz 的 PWM 周期,占空比就由比较寄存器CCR在 0~999 之间取值。1kHz 是驱动这类双 H 桥模块的常用值——开关损耗小、驱动模块跟得上,代价是电机有轻微啸叫;要求安静就抬到 16~20kHz,但要注意驱动模块的响应上限,抬太高会出现“占空比给了、电机没劲”的现象。
改引脚之前务必同步改三处:.ioc(或 CubeMX 配置)、stm32f1xx_hal_msp.c里的 GPIO 复用与时钟使能、以及你自己代码里写死的通道常量。只改第一处,编译能过,运行没波形,这类问题排查起来非常玄学。
3. 从一串字节到一次调速:UART 收包、协议切帧与 PWM 输出
链路本身不复杂:手机 App 发字节,串口中断收字节,主循环切帧,业务层解析出方向和速度,最后写进定时器比较寄存器。难点全在“中断里该做多少事”和“帧边界怎么找”。这两件事处理不好,现象就是:低速全对,一加油门就丢指令,或者偶尔电机自己反转一下。
3.1 中断里只做搬运,环形缓冲扛住突发
串口中断服务函数里绝对不要做解析、不要调printf、不要碰电机。常见做法是定义一个固定长度的环形缓冲,中断里只负责把字节塞进去,主循环再来取。用 HAL 的接收完成回调比手写中断更省心:
#define RX_BUF_SIZE 64 static volatile uint8_t rx_buf[RX_BUF_SIZE]; static volatile uint16_t rx_head = 0, rx_tail = 0; static uint8_t rx_byte; /* 每收完 1 字节回调一次:只搬运,不解析 */ void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uint16_t next = (rx_head + 1) % RX_BUF_SIZE; if (next != rx_tail) { /* 缓冲满则丢弃新字节,绝不阻塞 */ rx_buf[rx_head] = rx_byte; rx_head = next; } HAL_UART_Receive_IT(&huart1, &rx_byte, 1); /* 必须重新挂载,否则只收一字节 */ } } /* 溢出/帧错误必须清,否则一次异常之后就再也不进中断 */ void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { __HAL_UART_CLEAR_OREFLAG(huart); HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } }RX_BUF_SIZE取 64 对 9600 波特率足够:一帧 4 字节,手机端连点最多也就十几帧每秒,64 字节能扛住主循环被其他任务拖住几十毫秒的情况。回调里的HAL_UART_Receive_IT是必须的——只挂一次就只收一个字节,这是新手最常见的“串口只收到第一个字符”的原因。ErrorCallback里清溢出标志同样关键,串口溢出后硬件会锁住接收,不清标志的表现就是“发多少都没反应,重启才好”。
3.2 三字节协议:帧头 + 数据 + 异或校验
帧格式设计得越简单越好,别一上来就上 Modbus。四字节定长帧:0xAA | 方向 | 速度 | 校验,校验取前三字节异或。定长帧的好处是不用等超时判帧尾,切帧逻辑可以写成纯状态机:
typedef struct { uint8_t dir; uint8_t speed; } cmd_t; static uint8_t frame[4]; static uint8_t idx = 0; /* 主循环里调用,一次把缓冲里的字节全部处理掉 */ void protocol_poll(void) { while (rx_tail != rx_head) { uint8_t b = rx_buf[rx_tail]; rx_tail = (rx_tail + 1) % RX_BUF_SIZE; if (idx == 0 && b != 0xAA) continue; /* 没等到帧头,丢弃噪声 */ frame[idx++] = b; if (idx == 4) { uint8_t sum = frame[0] ^ frame[1] ^ frame[2]; if (sum == frame[3]) { apply_cmd(frame[1], frame[2]); /* 校验通过才执行 */ } idx = 0; /* 无论对错都复位,避免粘帧 */ } } }两个细节值得说:一是idx在收到完整帧后无条件清零,哪怕校验失败。有些实现校验失败后只回退一位,遇到连续坏帧就会错位,表现为“偶尔自己动一下”。二是速度字段用 0~100 的百分比而不是原始 CCR 值,这样协议和硬件解耦,将来换 PWM 频率或换电机都不用改手机端。
方向字段一般定义成:0 停止、1 前进、2 后退、3 原地转向。原地转向时左右轮反向,靠两个方向脚分别控制,代码里就是一次分支。
3.3 占空比映射:死区、限幅和停止时的刹车
拿到方向和百分比之后,落到硬件就三步:算 CCR、定方向脚、处理低速死区。
#define PWM_ARR 999 static void apply_cmd(uint8_t dir, uint8_t speed) { if (speed > 100) speed = 100; uint32_t ccr = (uint32_t)(PWM_ARR + 1) * speed / 100; if (ccr > PWM_ARR) ccr = PWM_ARR; switch (dir) { case 0: /* 停止:两方向脚同低,占空比清零,避免半桥直通 */ IN1_L(); IN2_L(); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 0); break; case 1: /* 前进 */ IN1_H(); IN2_L(); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, ccr); break; case 2: /* 后退 */ IN1_L(); IN2_H(); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, ccr); break; case 3: /* 原地转:左轮正、右轮反,占空比共享 */ IN1_H(); IN2_L(); IN3_L(); IN4_H(); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, ccr); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_2, ccr); break; } }死区这件事必须单独处理。这类直流减速电机在 15%~25% 占空比以下基本不转,只是嗡嗡响,玩家在手机上从 1 加到 100,前半段没反应,一到临界点突然窜出去。我的做法是分段映射:输入 1~10 映射到 0,11~100 线性映射到 25%~100%,并把这个 25% 做成一个宏,方便按电池电压变化时微调。停止时方向脚必须同时拉低再清占空比,顺序反了会出现一个十几毫秒的“反接制动”,驱动模块发烫。
4. 把开环变闭环:编码器测速与增量式 PID 的落地参数
开环占空比在平地上够用,一遇到坡道、地毯或者电池从 8.4V 掉到 6.8V,速度就完全不一样了。想要“给 60 就跑 60”,就得测速加闭环。这一步是这份包从“能转”升级到“能用”的分界线,也是大多数人卡住的地方:不是 PID 公式难,而是测速数据本身不干净。
4.1 编码器用定时器的硬件计数模式
别用外部中断数脉冲。两路编码器在高速下每秒上千个边沿,中断一多,串口就抢不到 CPU,蓝牙立刻开始丢包。正确做法是把定时器配成编码器接口模式,硬件自动加减计数,软件只管定时取差值。
/* 20ms 调用一次:取走计数并清零,差值即这段时间的脉冲数 */ int16_t encoder_read_delta(void) { int16_t cnt = (int16_t)__HAL_TIM_GET_COUNTER(&htim3); __HAL_TIM_SET_COUNTER(&htim3, 0); return cnt; } /* 脉冲数换算转速:ppr 为编码器线数,gear 为减速比,4 为四倍频 */ float delta_to_rpm(int16_t delta, uint16_t ppr, uint16_t gear, float dt) { return (float)delta * 60.0f / ((float)ppr * 4.0f * gear * dt); }参数怎么定:常见的霍尔编码器是 11 线或 13 线,减速比 1:30 到 1:48,四倍频后一转的计数就是ppr*4*gear。定时器要配成 16 位、ARR=65535、双边沿计数,溢出靠 16 位有符号差值自然回绕,不用额外判断。dt建议取 20ms,太短则量化误差大(低速时 20ms 内可能只有几个脉冲),太长则响应慢,PID 调起来发散。
4.2 一阶滤波:把毛刺挡在 PID 外面
原始测速数据一定带毛刺,尤其是 PWM 和编码器共板、地线处理不好的时候。滤波不用复杂,一阶低通足够:
static float spd_flt = 0.0f; #define ALPHA 0.3f float speed_filter(float raw) { spd_flt += ALPHA * (raw - spd_flt); /* ALPHA 越小越平滑,滞后越大 */ return spd_flt; }ALPHA=0.3对应约 5 个采样周期(100ms)跟上阶跃,对轮式小车够用。ALPHA调到 0.1 会明显迟钝,转弯时内侧轮速跟不上;调到 0.6 以上滤波基本失效,毛刺直接进 PID,表现是电机周期性“一顿一顿”。
4.3 增量式 PID:三个参数分别管什么
位置式 PID 要存积分项,抗饱和麻烦,电机调速更适合增量式——输出的是“这次该加多少占空比”,天然限幅:
typedef struct { float kp, ki, kd; float e1, e2; /* 上两次误差 */ float out; /* 累计输出,即占空比百分比 */ float limit; /* 输出限幅,一般 100 */ } pid_t; float pid_calc(pid_t *p, float target, float actual) { float e = target - actual; float inc = p->kp * (e - p->e1) + p->ki * e + p->kd * (e - 2.0f * p->e1 + p->e2); p->e2 = p->e1; p->e1 = e; p->out += inc; if (p->out > p->limit) p->out = p->limit; if (p->out < -p->limit) p->out = -p->limit; return p->out; }调参顺序我一般这样走:先ki=0, kd=0,把kp加到转速能跟上、但明显过冲并小幅振荡;然后加ki消静差,加到过冲可接受、静差进 2% 以内就停;kd最后加,只用来压超调,小车轮速噪声大,kd一般取kp的 0.05~0.2 倍,加多了反而抖得更凶。
积分限幅不能省。堵转时误差恒定,ki会把输出一直往上顶,松开车轮瞬间满占空比窜出去。上面的写法靠out限幅天然裁掉,但更稳的做法是再加一个“误差持续超过阈值 500ms 就清零out”的堵转保护。
5. 五个必踩的坑:供电、中断、死区、波特率与下载方式
这一章按现象、原因、解决三段的格式写,都是我自己和身边人踩过的。遇到问题时先在这里对一遍,多数情况能省掉半天。
5.1 电机一转蓝牙就断,松手又自动连上
现象:手机 App 显示连接正常,一给速度就断开,停止后几秒恢复。原因不是蓝牙模块坏,而是电机启动瞬间的浪涌把 3.3V 拉下来,模块复位。解决:电机电源与逻辑电源分开,逻辑侧加 100μF 电解并 0.1μF 瓷片去耦;蓝牙模块 VCC 脚就近再并一个 10μF;驱动模块的电机输出端各并一个 0.1μF 瓷片吸收换向火花。改完还没好,就把 PWM 频率从 1kHz 抬到 15kHz 以上,避开电机的机械共振区。
5.2 低速指令全对,一加油门就丢帧
现象:速度 30% 以下控制丝滑,60% 以上开始漏指令,偶尔方向错乱。原因多半是中断里干了重活——在HAL_UART_RxCpltCallback里直接解析、直接算 PID,甚至调了HAL_Delay。解决:回调只搬字节,解析放到主循环;把printf重定向改成环形缓冲发;确认HAL_UART_Receive_IT每帧都重新挂载。如果主循环里还有 1ms 的软件延时,一并去掉,改用定时器标志位。
5.3 电机不转、驱动模块发烫
现象:上电后轮子不动,摸驱动模块明显发热,电流表读数偏大。原因基本是方向脚电平组合非法或半桥直通:两路方向脚同时为高,或者停止时只清占空比没清方向。解决:把方向控制改成“先拉低两脚,延时 1ms,再设置新方向,最后给占空比”的固定序列;初始化时把所有方向脚配成推挽输出并置低;上电自检时串口打印四个方向脚的电平,确认没有同时为高的组合。
5.4 串口收到的全是乱码
现象:串口助手能看到字节,但内容是乱码或首字节固定错。原因有三类:波特率不一致(模块出厂 9600,代码里写了 115200);晶振与时钟配置不匹配(外部 8MHz 却按内部 8MHz 算,误差累积到十几帧就错位);电平不匹配(模块 5V 逻辑直接怼 3.3V 引脚)。解决:先用示波器或逻辑分析仪量 TX 上一个字节的宽度,反推实际波特率;时钟配置里核对 HSE 值与实际晶振;电平不匹配串一个分压或用电平转换。这类问题没有后悔药,只能量。
5.5 换一块板子就下载不了、或者下载后不运行
现象:同一份工程在旧板子上好使,新板子一烧就提示找不到目标,或者烧进去了不跑。原因是器件型号、启动文件、下载方式三处之一对不上:新型号 Flash 更大但启动文件还是小容量版本;BOOT0 被拉高进了系统存储器;调试口被复用成了普通 GPIO。解决:工程选项里改器件型号并重选启动文件;确认 BOOT0 接地、复位后从主 Flash 启动;调试口(SWD 的两根线)在代码里不要配置成普通输出。烧完不跑时,先看复位后第一秒有没有串口输出,有输出说明程序在跑,问题在外设;没输出就往启动文件和时钟上找。
6. 上电前先自检:三张判据表与一套阶跃测试
真正省时间的做法是开环自检流程,而不是等出了问题再修。我的习惯是每改一次硬件或参数,都按下面的顺序走一遍,五分钟能挡掉大部分问题。
第一步,不接电机,只接蓝牙和串口,手机发一串固定字节,串口助手看回显是否完整。第二步,接驱动模块但不接电机,用万用表量两路输出的电压随占空比线性变化,0%、50%、100% 三个点各测一次,看有没有跳变。第三步,接上电机悬空,跑 20%、50%、80% 三档各十秒,用手感受驱动模块温度,超过 60℃ 就说明 PWM 频率或死区设置有问题。第四步才落地跑。
阶跃测试我一般写成一段固定流程,让它自己跑,人盯着串口曲线:
/* 阶跃自检:0% → 40% → 80% → 0%,每档 2 秒,串口打印目标值与实测转速 */ void selftest_step(void) { const uint8_t steps[] = {0, 40, 80, 0}; for (uint8_t i = 0; i < sizeof(steps); i++) { target_rpm = steps[i] * 3; /* 百分比换算成目标转速,系数按电机实测改 */ uint32_t t0 = HAL_GetTick(); while (HAL_GetTick() - t0 < 2000) { protocol_poll(); control_loop_20ms(); /* 内部完成测速、滤波、PID、写 CCR */ log_printf("%d,%d\r\n", target_rpm, (int)spd_flt); } } }判据表:
| 观测点 | 工具 | 合格判据 |
|---|---|---|
| PWM 波形 | 示波器 / 逻辑分析仪 | 频率与设定一致,占空比误差 < 2%,上升沿无台阶 |
| 方向脚时序 | 逻辑分析仪双通道 | 换向前后有 ≥1ms 全低死区,无同时为高 |
| 转速阶跃响应 | 串口曲线 | 上升时间 < 300ms,超调 < 10%,稳态误差 < 3% |
最后说个习惯。早些年我图快,改完 PID 直接落地跑,结果一次 80% 占空比的阶跃把小车送上了墙,编码器线也被扯断。从那以后我每次改控制参数,都强制先走一遍“悬空、三档、十秒”的空载自检,再落地。多花五分钟,能省掉一次拆车。希望帮到你。
本文还有配套的精品资源,点击获取