STM32F103C8T6 这颗芯片在单片机圈子里算是老熟人了,价格便宜、资料多、性能也够用,拿它来入门嵌入式再合适不过。如果你手头正好有一块 STM32F103C8T6 最小系统板,又不想一直停留在点灯和串口打印的阶段,那用一台遥控小车来串起 GPIO、PWM、UART、TIM 定时器、状态机和电机驱动,是最快也是最过瘾的路径。我自己第一次把四个轮子转起来的时候,手机蓝牙一按,小车往前窜出去,那种感觉比点亮一百个 LED 都实在。这个项目不需要复杂的机械结构,也不用买昂贵的开发套件,核心就是一块最小系统板、一个电机驱动模块、一个蓝牙串口模块、两个减速电机和一套小车底盘。适合刚学完 C 语言、对单片机有零星了解、想找一个完整项目把知识点串起来的朋友。整台车从硬件接线到软件状态机,从定时器分频到串口收包,全部可以在一周左右的业余时间里跑通。我会把这一路踩过的坑、选型时的纠结、调参数的计算过程都摊开来写,尽量让你少走弯路。
1. 项目整体设计与选型思路拆解
1.1 为什么选“最小系统板 + 分立模块”而不是成品车
市面上几十块钱的遥控车套件很多,拆开一看,主控往往是打磨掉丝印的牛屎芯片,电机驱动直接集成在板子上,想改一行代码都找不到入口。这种成品车用来玩可以,用来学单片机就有点浪费。我选“STM32F103C8T6 最小系统板 + 分立模块”的原因很简单:每一根线都经过我的手,每一个信号我都能拿示波器或者逻辑分析仪去量,出了问题知道从哪查。最小系统板大概十块钱出头,L298N 或 TB6612 驱动模块几块到十几块,蓝牙串口模块十几块,TT 减速电机加轮子加底盘二十多块,整套下来五十块以内能搞定。更重要的是,这种搭建方式逼着你去查引脚定义、算 PWM 频率、处理共地问题,这些才是嵌入式入门真正值钱的经验。成品车把这些问题都藏起来了,你只学会了按遥控器,没学会单片机。
选分立模块还有一个好处是替换性强。今天用 L298N,明天觉得发热大,可以直接换 TB6612,引脚兼容度很高,代码几乎不用大改。蓝牙模块也一样,HC-05 和 JDY-31 都是串口透传,协议层完全一致,换个模块只改接线,不用重写驱动。这种“搭积木”的方式让整个项目的可维护性高很多,也方便你后面加超声波、红外、编码器这些扩展。我自己的习惯是先把最小系统跑通,再逐步往上叠功能,而不是一开始就画 PCB 打板,那样一旦设计有问题,改起来成本太高。
1.2 系统信号流:从手机指令到轮子转动
整台车的信号流其实不复杂,但每一段都有讲究。手机上的蓝牙串口助手发出一个字节或者一串字节,蓝牙模块通过无线收到后,从它的 TX 脚以 TTL 串口电平发给 STM32 的 USART1_RX 脚。STM32 的串口外设把串行数据转成并行数据,触发接收中断或者 DMA 搬运。主程序解析这包数据,判断是前进、后退、左转、右转还是调速。解析结果不是直接去写 GPIO,而是作为一个“事件”投递给整车状态机。状态机根据当前状态和事件决定下一状态,然后计算出左右两个电机的目标速度值。这个速度值被写入 TIM 定时器的捕获比较寄存器 CCR,定时器硬件自动在对应引脚上输出 PWM 波形。PWM 波形进入电机驱动模块的使能脚,方向控制引脚则决定 H 桥的导通方向。电机驱动输出大电流驱动两个直流减速电机,小车就动起来了。
这条链路里,任何一个环节出问题,车都不会按预期走。比如蓝牙模块和单片机之间没有共地,串口数据就会乱;PWM 频率算错了,电机可能啸叫或者抖动;状态机没写好,按了前进结果车原地转圈。所以我建议你在动手之前,先把这条信号流画在纸上,标出每一段的电平和参数。手机蓝牙是无线,不用管;蓝牙模块到单片机是 3.3V TTL 串口,波特率通常 9600 或 115200;单片机到电机驱动是 3.3V 逻辑电平,PWM 频率我一般设在 10kHz 到 20kHz;电机驱动到电机是电池电压的大电流。搞清楚这些,后面排查问题就有方向了。
1.3 核心器件选型与替代方案
选型这件事没有绝对的对错,只有适不适合你手头的预算和需求。我把常见方案列在下面这张表里,你可以直接对照着买。
| 器件 | 推荐型号 | 替代方案 | 关键注意点 |
|---|---|---|---|
| 主控 | STM32F103C8T6 最小系统板 | 同系列 C6T6、R8T6 | 注意板载晶振是 8MHz,倍频到 72MHz |
| 电机驱动 | TB6612FNG | L298N、AO3400 MOS 自制 | TB6612 效率高、发热小;L298N 压降大,约 1.4V |
| 蓝牙模块 | JDY-31 | HC-05、HC-06 | 都是串口透传,默认波特率可能不同 |
| 电机 | TT 减速电机 | 520 电机、N20 减速电机 | TT 电机便宜,但一致性差,需要左右补偿 |
| 电源 | 2 节 18650 串联 | 4 节 AA 电池 | 电压 7.4V 左右,注意电机驱动和单片机共地 |
| 下载器 | DAPLink | ST-Link V2 | 接线 SWDIO、SWCLK、GND、3.3V |
TB6612 和 L298N 最大的区别在压降和发热。L298N 是双 H 桥,内部达林顿管压降大概 1.4V 到 2V,电池 7.4V 经过它之后电机只能拿到 5V 多,而且发热明显。TB6612 是 MOS 管 H 桥,导通电阻小,压降只有零点几伏,效率高很多。如果你手头只有 L298N,也能用,但要注意散热,别把模块捂在底盘里。蓝牙模块方面,JDY-31 便宜且体积小,HC-05 功能更全但贵一些。买的时候一定要确认是“串口透传”类型,不要买成需要复杂 AT 指令配置的蓝牙音频模块。
电机选 TT 减速电机的原因是便宜、配套轮子和底盘多、扭矩够用。缺点是两个电机的转速一致性可能不太好,同样占空比下左右轮转速不一样,车会跑偏。解决办法是在代码里做左右轮补偿,比如左轮占空比乘以 0.95,右轮乘以 1.0,具体值实测调整。电源用 2 节 18650 串联是性价比很高的方案,电压 7.4V 左右,容量大,能反复充电。注意电池正负极千万别接反,反接一下单片机大概率就烧了。
2. 硬件接线与 GPIO 模式选择的关键细节
2.1 STM32F103C8T6 最小系统板引脚分配与供电注意
STM32F103C8T6 是 48 引脚封装,最小系统板把有用的引脚都引出来了。板子上一般有 5V 和 3.3V 两个电源输出,5V 来自 USB 或者外部输入,3.3V 由板载稳压芯片产生。给单片机供电可以用 USB 的 5V,也可以用电池经过稳压后的 5V。注意:电机驱动模块的电源输入要直接接电池,不要从单片机的 5V 取电,因为电机启动瞬间电流可能达到 1A 以上,单片机板上的稳压芯片扛不住,轻则复位,重则烧毁。
引脚分配上,我习惯把 USART1 留给蓝牙,也就是 PA9 做 TX、PA10 做 RX。TIM3 的通道 1 和通道 2 分别在 PA6 和 PA7,用来输出两路 PWM,正好控制左右电机。电机方向控制选 PA1、PA2、PA3、PA4 这四个普通 GPIO 做推挽输出。按键用 PA0,配置成上拉输入,按键另一端接 GND,按下时读低电平。DAPLink 下载器接 SWDIO 到 PA13,SWCLK 到 PA14,GND 和 3.3V 对应接上。如果你用的是 ST-Link,接线一样,只是名字不同。
供电方面有一个细节:最小系统板上的 3.3V 输出电流有限,通常只有几百毫安。蓝牙模块工作电流大概 20mA 到 40mA,可以直接从 3.3V 取电。但如果你蓝牙模块标注输入 5V,那就接 5V,内部有稳压。看模块丝印,别想当然。还有一点,电池电压经过电机驱动模块后,有些模块会输出一个 5V,比如 L298N 板载 5V 稳压输出,这个 5V 可以给单片机供电,但前提是你的电池电压在 7V 以上,而且电机电流不要太大。我实测过,L298N 的 5V 输出带最小系统板加蓝牙模块没问题,但电机一启动,5V 会掉到 4.5V 左右,单片机可能复位。所以稳妥的做法还是给单片机单独供电,或者加一个大电容缓冲。
2.2 GPIO 八种工作模式怎么选
GPIO 的八种工作模式是 STM32 入门必须搞懂的东西,很多人配错了模式,引脚就是没反应。这八种分别是:输入浮空、输入上拉、输入下拉、模拟输入、开漏输出、推挽输出、开漏复用、推挽复用。输入浮空就是引脚悬空,电平不确定,一般用于外部已经有上拉或下拉电阻的场景,比如 I2C 的 SDA 和 SCL。输入上拉和输入下拉是芯片内部接了一个电阻到 3.3V 或 GND,按键最常用输入上拉,这样按键没按下时读高电平,按下读低电平,不用外接电阻。
模拟输入用于 ADC 采集,这时候引脚的数字输入输出功能关闭,信号直接进 ADC。开漏输出只能输出低电平,高电平需要外部上拉电阻,好处是可以做电平转换或者多个输出线“线与”。推挽输出能主动输出高和低,驱动能力强,适合控制电机方向、LED、继电器。开漏复用和推挽复用是给外设用的,比如串口 TX 要配置成复用推挽,PWM 输出也要配置成复用推挽。串口 RX 一般配置成浮空输入或者上拉输入,因为外部蓝牙模块的 TX 会驱动它。
我整理了一个选择速查:
- 控制电机方向:推挽输出,初始电平拉低。
- 按键输入:上拉输入或下拉输入,看按键接 GND 还是 3.3V。
- PWM 输出:复用推挽,对应定时器通道。
- 串口 TX:复用推挽。
- 串口 RX:浮空输入或上拉输入。
- ADC 采样:模拟输入。
- I2C SDA/SCL:开漏复用,外接上拉电阻。
- 普通 LED:推挽输出。
有一个坑要注意:开漏输出如果不接上拉电阻,永远输出不了高电平。我见过有人用开漏输出驱动电机方向,结果电机只有一个方向能转,另一个方向没反应,查了半天才发现是模式选错了。还有复用功能,一旦引脚配置成复用推挽,普通的 GPIO 写操作就失效了,必须通过外设寄存器来控制。CubeMX 里配置好之后会自动生成代码,但你要知道它背后改了什么,不然出了问题不知道怎么查。
2.3 电机驱动、蓝牙、按键的具体接线表
下面这张表是我实际用过的接线方案,主控是 STM32F103C8T6 最小系统板,电机驱动以 TB6612 为例,L298N 的接线逻辑类似,只是引脚名字不同。
| STM32 引脚 | 功能 | 连接到 | 备注 |
|---|---|---|---|
| PA0 | GPIO 输入 | 按键一端,按键另一端接 GND | 配置上拉输入,按下为低 |
| PA1 | GPIO 输出 | TB6612 AIN1 | 左电机方向 1 |
| PA2 | GPIO 输出 | TB6612 AIN2 | 左电机方向 2 |
| PA3 | GPIO 输出 | TB6612 BIN1 | 右电机方向 1 |
| PA4 | GPIO 输出 | TB6612 BIN2 | 右电机方向 2 |
| PA6 | TIM3_CH1 | TB6612 PWMA | 左电机 PWM,复用推挽 |
| PA7 | TIM3_CH2 | TB6612 PWMB | 右电机 PWM,复用推挽 |
| PA9 | USART1_TX | 蓝牙模块 RX | 交叉连接 |
| PA10 | USART1_RX | 蓝牙模块 TX | 交叉连接 |
| PA13 | SWDIO | DAPLink SWDIO | 下载调试 |
| PA14 | SWCLK | DAPLink SWCLK | 下载调试 |
| 5V | 电源 | 蓝牙模块 VCC(若模块支持 5V) | 看模块丝印 |
| GND | 地 | 蓝牙模块 GND、电机驱动 GND、电池负极 | 必须共地 |
| 电池正极 | 电源 | TB6612 VM | 电机驱动电源 |
| 电池负极 | 电源 | TB6612 GND | 与单片机共地 |
TB6612 还有 STBY 引脚,必须接高电平(3.3V 或 5V)芯片才工作,很多人忘了接,结果电机完全不转。L298N 有 ENA 和 ENB 使能脚,如果用 PWM 调速,就把 ENA 接到 PA6,同时把跳线帽拔掉;如果不拔跳线帽,ENA 默认高电平,PWM 就进不去。蓝牙模块的 VCC 一定要确认,JDY-31 一般是 3.3V 供电,HC-05 有的支持 5V,接错了模块可能不工作甚至烧掉。TX 和 RX 必须交叉,单片机的 TX 接蓝牙的 RX,单片机的 RX 接蓝牙的 TX,接反了收不到数据。
2.4 电源与共地:最容易翻车的地方
电源和共地是硬件调试里最容易出问题的地方,没有之一。我第一版车就是电池直接给单片机供电,电机一启动单片机就复位,查了很久才发现是电源跌落。电机在启动瞬间电流很大,TT 电机堵转电流可能到 1A 以上,电池内阻和导线电阻上会产生压降,导致单片机供电电压瞬间掉到 2.8V 以下,触发欠压复位。解决办法有几个:第一,电机驱动电源和单片机电源分开走线,从电池端分别引出两根线,不要串联。第二,在单片机电源入口加一个 100uF 电解电容和一个 0.1uF 陶瓷电容,电解电容储能,陶瓷电容滤高频。第三,电池选放电能力强的,比如 18650 动力电池,普通电池内阻大,带电机很吃力。
共地问题也很关键。蓝牙模块、电机驱动模块、单片机、电池负极必须全部连在一起。如果不共地,串口电平没有参考点,数据就会乱码或者完全收不到。我见过有人用两个独立电池分别给单片机和电机驱动供电,但忘了把两个电池的 GND 连起来,结果单片机发 PWM 电机驱动根本不认。共地不是可选项,是必须项。另外,电机线最好双绞,减少对单片机的电磁干扰。如果电机一转,蓝牙就断连,很可能是电机干扰了蓝牙模块的电源,可以在蓝牙模块电源脚并一个 10uF 电容。
3. TIM 定时器与 PWM:让轮子按你想要的转速转
3.1 定时器基本概念与 PWM 频率计算
STM32F103C8T6 有多个定时器,TIM1 是高级定时器,TIM2、TIM3、TIM4 是通用定时器。我们输出 PWM 一般用通用定时器就够了。定时器的核心参数有三个:预分频器 PSC、自动重装载寄存器 ARR、捕获比较寄存器 CCR。PSC 用来把系统时钟分频,ARR 决定计数周期,CCR 决定比较值。PWM 频率的计算公式是:
Fpwm = Fclk / ((PSC + 1) * (ARR + 1))
其中 Fclk 是定时器时钟,STM32F103C8T6 在 72MHz 主频下,APB1 定时器时钟也是 72MHz(因为 APB1 预分频不为 1 时,定时器时钟会倍频)。举个例子,我想让 PWM 频率是 10kHz,那么 (PSC+1)*(ARR+1) = 72000000 / 10000 = 7200。我可以选 PSC=0,ARR=7199;或者 PSC=71,ARR=99。占空比的计算是:
占空比 = CCR / (ARR + 1)
比如 ARR=7199,CCR=3600,占空比就是 3600/7200 = 50%。CCR 可以在运行时动态修改,用来调速。注意 CCR 的值不能超过 ARR,否则输出一直是高电平。我一般会定义一个最大速度值,比如 7200,然后速度百分比映射到 0 到 7200 之间。
如果你要控制舵机,舵机需要 50Hz 的 PWM,周期 20ms。计算:72000000 / ((PSC+1)(ARR+1)) = 50,所以 (PSC+1)(ARR+1) = 1440000。可以选 PSC=71,ARR=19999,这样每个计数周期是 1us,CCR=500 对应 0.5ms,CCR=2500 对应 2.5ms,正好是舵机的脉宽范围。这个计算过程你要自己会推,不要死记。
3.2 电机 PWM 频率选择与飞车问题
电机 PWM 频率选多少合适?我的经验是 10kHz 到 20kHz。频率太低,比如 1kHz,电机会有明显的啸叫,而且低速时扭矩波动大,车走起来一顿一顿的。频率太高,比如 50kHz,电机驱动内部的 MOS 管开关损耗增加,发热会变大。10kHz 是常用值,刚好在音频范围上限附近,人耳不太容易听到刺耳的声音。TB6612 支持到 100kHz,L298N 建议不超过 40kHz。我一般用 10kHz,代码里配置 PSC=0,ARR=7199。
飞车问题是很多新手会遇到的:上电一瞬间电机突然狂转,车直接冲出去。原因主要有三个。第一,GPIO 在上电复位后默认是浮空输入,电平不确定,电机驱动可能误判为高电平。解决办法是在初始化时第一时间把方向引脚配置成推挽输出并输出低电平,PWM 比较值设为 0。第二,电机驱动模块的使能脚悬空,内部上拉导致使能有效。可以在使能脚加一个下拉电阻,或者软件初始化时先拉低。第三,程序跑飞或者复位后没有进入安全状态。我习惯在 main 函数开头就调用一个 Motor_Stop 函数,把方向引脚全部拉低,PWM 占空比清零,然后再进行其他初始化。这样即使中途复位,电机也不会突然转。
还有一个细节:L298N 的 ENA 和 ENB 如果接跳线帽,默认是高电平使能,你程序里 PWM 还没输出,电机就已经有方向信号了,如果方向引脚也是高电平,电机就会全速转。所以一定要把跳线帽拔掉,让 PWM 信号来控制使能。TB6612 的 STBY 引脚也要注意,初始化时先拉低,等系统准备好再拉高。这些安全措施看起来麻烦,但能避免小车一上电就撞墙。
3.3 CubeMX 配置 TIM + HAL 库 PWM 输出
用 CubeMX 配置 TIM3 输出 PWM 的步骤不复杂。打开 CubeMX,选择 STM32F103C8T6,先配置 RCC 的 HSE 为 Crystal/Ceramic Resonator,然后在 Clock Configuration 里把 HCLK 拉到 72MHz。接着配置 TIM3,Clock Source 选 Internal Clock,Channel1 选 PWM Generation CH1,Channel2 选 PWM Generation CH2。在下方的 Parameter Settings 里设置 Prescaler 为 0,Counter Period 为 7199,Pulse 初始值设为 0。PWM Generation 模式选 PWM mode 1,输出比较极性选 High。这样生成的代码里,PA6 和 PA7 会自动配置成复用推挽,定时器时钟 72MHz,PWM 频率 10kHz。
生成代码后,在 main 函数里启动 PWM:
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2);然后封装一个设置占空比的函数:
void Motor_SetPWM(int16_t left, int16_t right) { if (left >= 0) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_RESET); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, left); } else { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_SET); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, -left); } // 右电机同理,操作 PA3、PA4 和 TIM_CHANNEL_2 }注意__HAL_TIM_SET_COMPARE是宏,直接写寄存器,效率很高。如果你用的是标准库,对应的是TIM_SetCompare1。HAL 库的好处是移植方便,坏处是代码臃肿,但对我们这个小项目来说完全够用。
3.4 PWM 调速与差速转向逻辑
遥控车转向一般用差速转向,也就是左右轮速度不同,车就会转弯。具体来说,前进时左右轮都正转,占空比相同;左转时左轮速度降低甚至反转,右轮保持正转;右转相反;后退时左右轮都反转。我一般把速度值定义在 -7200 到 7200 之间,正数正转,负数反转,绝对值是占空比。这样上层状态机只需要输出两个速度值,底层驱动负责方向引脚和 CCR 的设置。
实际调试时会遇到电机死区问题。占空比低于某个值,比如 10%,电机可能不转,因为扭矩不够克服静摩擦力。这时候你给再小的占空比,车也不动。解决办法是在速度映射时加一个死区补偿,比如目标速度非零时,最小输出占空比不低于 15%。另外左右电机一致性差,同样占空比下左轮转得快,车会往右偏。你可以在代码里给右轮乘一个系数,比如 1.05,具体值边跑边调。我通常会在车底贴一条胶带做标记,让它直线跑五米,看偏移多少,然后调整系数。
差速转向的另一个细节是转弯时内侧轮子如果完全停转,车会转得很急,容易甩尾。可以设置一个最小转弯速度,比如内侧轮保留 20% 的占空比,外侧轮 80%,这样转弯更平滑。这些参数没有标准答案,取决于你的电机、轮子、地面摩擦力,多试几次就有感觉了。
4. UART 串口与蓝牙通信:把遥控指令接进来
4.1 UART 协议基础与参数设置
UART 是异步串口,不需要时钟线,靠约定的波特率来同步。一帧数据包括起始位、数据位、校验位、停止位。我们最常用的是 8 位数据位、无校验、1 位停止位,也就是 8N1。STM32F103C8T6 的 USART1 挂载在 APB2 总线上,时钟 72MHz;USART2 和 USART3 挂载在 APB1 总线上,时钟 36MHz。用 CubeMX 配置的时候,它会自动计算波特率寄存器的值。常用波特率是 9600 和 115200,蓝牙模块默认可能是 9600,你要和它保持一致,否则收到的全是乱码。
TTL 串口电平和 RS232 电平不一样,RS232 是负逻辑,电压范围正负十几伏,直接接单片机必烧。我们用的蓝牙模块输出的是 TTL 电平,3.3V 或 5V,和 STM32 兼容。接线时 TX 接 RX,RX 接 TX,这是交叉连接。有些人看到 TX 对 TX 就懵了,记住“发送对接收”就行。GND 必须连,不然没有参考电平。如果你用的是 USB 转串口模块调试,比如 FT232、CP2102 这类,也是一样的接法,注意模块的 VCC 不要接到单片机的 5V,除非你确定要给它供电,一般只接 TX、RX、GND 三根线就够了。
4.2 蓝牙模块的 AT 配置与接线
蓝牙模块拿到手,先别急着接单片机,用 USB 转串口模块连电脑配置一下。以 HC-05 为例,按住模块上的按键再上电,进入 AT 模式,指示灯慢闪。电脑串口助手发送AT,如果返回OK,说明通信正常。然后设置波特率:AT+UART=9600,0,0,设置名称:AT+NAME=MyCar,设置配对码:AT+PSWD=1234。JDY-31 的 AT 指令略有不同,但大同小异,卖家一般会给手册。配置完之后,模块重新上电,指示灯快闪,手机蓝牙搜索到 MyCar,配对密码 1234,连上之后就可以用蓝牙串口助手发数据了。
接线到单片机时,蓝牙模块的 TX 接 PA10(USART1_RX),RX 接 PA9(USART1_TX),VCC 和 GND 接好。注意有些蓝牙模块的 RX 引脚不耐 5V,而 STM32 的 TX 是 3.3V 电平,所以直接接没问题。反过来,蓝牙模块的 TX 如果是 5V 电平,接 STM32 的 RX 可能会损伤引脚,不过大多数模块都是 3.3V 电平,问题不大。如果你不放心,可以在蓝牙 TX 和单片机 RX 之间串一个 1k 电阻限流。蓝牙模块的天线区域不要被金属遮挡,也不要用手直接摸,会影响信号。
4.3 串口接收的三种方式:轮询、中断、DMA + 空闲中断
串口接收有三种常见方式,各有适用场景。轮询最简单,在主循环里调用HAL_UART_Receive,等收到数据再处理。缺点是阻塞 CPU,收不到数据就一直等,其他任务没法执行。中断方式好一些,每收到一个字节触发一次中断,在中断里把数据存到缓冲区。缺点是如果数据量大,中断频繁,CPU 开销大。DMA + 空闲中断是最优雅的方式,DMA 自动把串口数据搬到内存,空闲中断判断一帧结束,CPU 几乎不参与搬运。
对于遥控车这种不定长指令,我推荐 DMA + 空闲中断。配置步骤:在 CubeMX 里给 USART1 添加 DMA 接收通道,模式选 Normal 或 Circular。然后在代码里启动 DMA 接收和空闲中断:
uint8_t rx_buf[64]; uint8_t rx_len = 0; void UART_StartReceive(void) { HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, sizeof(rx_buf)); } void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart == &huart1) { rx_len = Size; // 把 rx_buf 里的数据交给解析函数 Protocol_Parse(rx_buf, rx_len); // 重新启动接收 HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, sizeof(rx_buf)); } }HAL_UARTEx_ReceiveToIdle_DMA是 HAL 库提供的带空闲检测的接收函数,收到数据或者总线空闲时都会触发回调。注意回调里要重新启动接收,否则只能收一包。DMA 缓冲区大小要大于最大帧长,我一般给 64 字节,足够用了。
4.4 自定义轻量通信协议
直接用单个字符控制不是不行,但扩展性差,容易误触发。我习惯定义一个简单的帧协议,比如:
| 字节 | 含义 |
|---|---|
| 0 | 帧头 0xAA |
| 1 | 帧头 0x55 |
| 2 | 长度(后续字节数) |
| 3 | 命令 |
| 4 ~ n | 数据 |
| n+1 | 校验和(从长度到数据的累加和取低 8 位) |
命令定义:0x01 前进,0x02 后退,0x03 左转,0x04 右转,0x05 停止,0x06 调速。调速命令后面跟一个字节的速度值 0 到 100。解析的时候用一个状态机,依次等待两个帧头、读取长度、读取命令和数据、校验。校验通过后,把命令和数据打包成一个事件,投递给整车状态机。
解析代码可以这样写:
typedef enum { WAIT_HEAD1, WAIT_HEAD2, WAIT_LEN, WAIT_CMD, WAIT_DATA, WAIT_CHECK } ParseState; void Protocol_Parse(uint8_t *buf, uint16_t len) { static ParseState state = WAIT_HEAD1; static uint8_t data[32]; static uint8_t data_len = 0; static uint8_t cmd = 0; static uint8_t index = 0; for (uint16_t i = 0; i < len; i++) { uint8_t byte = buf[i]; switch (state) { case WAIT_HEAD1: if (byte == 0xAA) state = WAIT_HEAD2; break; case WAIT_HEAD2: if (byte == 0x55) state = WAIT_LEN; else state = WAIT_HEAD1; break; case WAIT_LEN: data_len = byte; index = 0; state = WAIT_CMD; break; case WAIT_CMD: cmd = byte; state = (data_len > 0) ? WAIT_DATA : WAIT_CHECK; break; case WAIT_DATA: if (index < sizeof(data)) data[index++] = byte; if (index >= data_len) state = WAIT_CHECK; break; case WAIT_CHECK: { uint8_t sum = data_len + cmd; for (uint8_t j = 0; j < data_len; j++) sum += data[j]; if (sum == byte) { Event_Post(cmd, data, data_len); } state = WAIT_HEAD1; break; } } } }这个解析器能处理粘包和断帧,只要数据流不断,状态机会一直保持。注意data数组要足够大,防止溢出。校验和用累加和足够简单,如果你想要更强的检错能力,可以用 CRC8,但代码会复杂一些。对于遥控车,累加和完全够用。
5. 状态机设计:让遥控车的逻辑不乱套
5.1 状态机思想与单片机中的落地方式
状态机说白了就是“在什么状态下,遇到什么事件,做什么动作,转到什么状态”。单片机里最常见的写法是 switch-case,外层 switch 判断当前状态,内层 switch 判断事件。也可以用函数指针数组做成表驱动,更灵活但代码稍微难懂。对于遥控车,状态不多,switch-case 就够清晰了。状态机的好处是逻辑集中,不会到处散落 if-else,后面加功能也方便。比如你现在只有停止、前进、后退、左转、右转,以后想加自动避障,只需要加一个状态和相应的事件转移,不用把整个代码重写。
状态机的输入事件可以来自串口解析、按键扫描、传感器中断。状态机的输出可以是电机速度、LED 指示、蜂鸣器。我习惯把状态机放在主循环里,每隔 10ms 执行一次,这样按键消抖、串口事件处理、电机输出都能在一个节拍里完成,时序很稳定。注意事件不要直接在中断里处理,中断里只做标记或者往队列里投递,实际处理放在主循环,避免中断嵌套和竞态。
5.2 按键状态机:消抖、短按、长按
按键看起来简单,写起来容易出 bug。机械按键按下和松开时会有几十毫秒的抖动,如果直接读电平,一次按下可能被识别成多次。传统的做法是delay(20)消抖,但 delay 会阻塞 CPU,遥控车还要处理串口和 PWM,不能这么干。用状态机消抖就很好:每 10ms 扫描一次按键,状态在“释放”“消抖”“按下”“长按”之间转移。连续三次扫描到低电平才认为按下,连续三次高电平才认为释放。按下后计时,超过 1 秒算长按,短按和长按触发不同事件。
typedef enum { KEY_RELEASED, KEY_DEBOUNCE, KEY_PRESSED, KEY_LONG } KeyState; void Key_Scan(void) { static KeyState state = KEY_RELEASED; static uint8_t count = 0; static uint16_t long_count = 0; uint8_t level = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0); switch (state) { case KEY_RELEASED: if (level == 0) { state = KEY_DEBOUNCE; count = 0; } break; case KEY_DEBOUNCE: if (level == 0) { if (++count >= 3) { state = KEY_PRESSED; long_count = 0; Event_Post(EVENT_KEY_SHORT, NULL, 0); // 暂时按短按处理 } } else { state = KEY_RELEASED; } break; case KEY_PRESSED: if (level == 1) { if (++count >= 3) { state = KEY_RELEASED; } } else { count = 0; long_count++; if (long_count >= 100) { // 1 秒 state = KEY_LONG; Event_Post(EVENT_KEY_LONG, NULL, 0); } } break; case KEY_LONG: if (level == 1) { state = KEY_RELEASED; } break; } }这个按键状态机每 10ms 调用一次,不会阻塞,而且短按和长按都能识别。你可以把短按用来切换模式,长按用来急停。
5.3 整车状态机:停止、前进、后退、左转、右转、急停
整车状态我定义了六个:停止、前进、后退、左转、右转、急停。急停是一个特殊状态,进入后所有电机输出为 0,方向引脚拉低,并且只有收到明确的“解除急停”指令才能恢复。状态转移表如下:
| 当前状态 | 事件 | 下一状态 | 动作 |
|---|---|---|---|
| 停止 | 前进 | 前进 | 左轮 60%,右轮 60% |
| 停止 | 后退 | 后退 | 左轮 -60%,右轮 -60% |
| 停止 | 左转 | 左转 | 左轮 20%,右轮 70% |
| 停止 | 右转 | 右转 | 左轮 70%,右轮 20% |
| 前进 | 左转 | 左转 | 左轮 20%,右轮 70% |
| 前进 | 右转 | 右转 | 左轮 70%,右轮 20% |
| 前进 | 后退 | 后退 | 先停止 100ms 再反转 |
| 任意 | 急停 | 急停 | 所有输出 0 |
| 急停 | 停止 | 停止 | 所有输出 0 |
注意前进直接切后退时,最好先停止一小段时间,让电机停稳再反转,否则电流冲击大,电机驱动容易进入保护。这个“先停止 100ms”可以用一个软件定时器实现,也可以用状态机加一个过渡状态。我一般加一个“刹车”状态,持续 100ms 后自动转到目标状态。
5.4 状态机与串口指令、PWM 输出的联动
串口解析出来的命令不是直接操作电机,而是封装成事件投递给状态机。事件队列可以用一个简单的环形缓冲区,主循环里取出事件处理。这样做的好处是解耦:串口模块只负责收包和解析,状态机只负责逻辑,电机驱动只负责执行。三者互不干扰,调试的时候也容易定位问题。如果串口收到一堆数据,事件队列满了,可以丢弃旧事件,保证最新的指令能执行。
状态机每 10ms 执行一次,根据当前状态和事件更新目标速度。目标速度再经过一个斜坡函数,慢慢增加到目标值,避免电机突然全速导致电流冲击和车轮打滑。比如当前速度 0,目标速度 60%,每个周期增加 5%,大概 12 个周期也就是 120ms 后达到全速。这个斜坡函数对保护电机和齿轮很有用。最后,目标速度写入 PWM 比较寄存器,电机就转起来了。整个链路清晰可查,哪里出问题就看哪一段。
6. 常见问题与排查技巧实录
6.1 电机不转、只抖不动、飞车怎么办
电机不转是最常见的问题。排查顺序:先看电源,电池有没有电,电机驱动模块的电源指示灯亮不亮;再看共地,单片机、电机驱动、蓝牙模块的 GND 是否全部连在一起;然后看使能脚,TB6612 的 STBY 有没有拉高,L298N 的 ENA 跳线帽有没有拔掉;接着看 PWM 有没有输出,可以用 LED 接在 PWM 脚上,看亮度变化,或者用万用表测电压;最后看 GPIO 模式,方向引脚是不是推挽输出,PWM 引脚是不是复用推挽。我遇到过最多的情况是 STBY 忘了接,或者 L298N 的跳线帽没拔,查了半天代码,结果是硬件问题。
只抖不动通常是供电不足或者 PWM 频率不对。电池内阻大,电机启动瞬间电压跌落,电机得到的电压不够,就会抖动。换动力电池或者加电容。PWM 频率太低,比如 100Hz,电机会听到嗡嗡声而且抖动,改成 10kHz 就好了。飞车问题前面讲过,主要是上电初始电平不确定,解决办法是初始化时先拉低方向引脚,PWM 占空比清零,加下拉电阻。还有一个小概率情况是程序跑飞,看门狗复位后没有进入安全状态,可以在复位后第一时间执行电机停止函数。
6.2 串口收不到数据、乱码、丢包
串口收不到数据,先检查 TX 和 RX 是不是交叉连接。很多人 TX 接 TX,RX 接 RX,然后奇怪为什么没数据。然后检查波特率,蓝牙模块默认 9600,你单片机设 115200,收到的就是乱码。乱码通常是波特率不匹配,或者系统时钟配置错误。CubeMX 里如果 HSE 没起振,系统时钟会回落到内部 8MHz,波特率就全错了。可以先用示波器测一下 TX 脚有没有波形,有波形说明单片机在发,没波形说明串口没配置好。
丢包问题多半是接收缓冲区太小或者处理太慢。用 DMA + 空闲中断一般不会丢,但如果你的解析函数耗时太长,下一包数据来了还没处理完,就会覆盖缓冲区。解决办法是加大缓冲区,或者把解析放到主循环,中断里只拷贝数据。蓝牙模块本身也可能丢包,尤其是距离远或者有干扰的时候。可以在协议里加序号和重传,但对于遥控车,丢一两个包问题不大,状态机会保持上一个状态,不会失控。
6.3 程序能下载但不运行、复位后异常
程序下载成功但单片机不跑,先看 BOOT0 和 BOOT1 跳线。STM32F103C8T6 正常从 Flash 启动,BOOT0 要接 GND,BOOT1 随便。如果 BOOT0 接高,芯片进入系统存储器启动,你的程序不会执行。然后看复位电路,最小系统板一般有复位按键,按一下看程序是否重新运行。如果复位后正常,运行一会儿又死机,可能是电源纹波太大或者看门狗复位。用示波器看 3.3V 电源,如果纹波超过 100mV,加电容或者换电源。
还有一种情况是中断向量表偏移没设置对。如果你用了 Bootloader 或者自己做了 IAP,APP 程序的起始地址不是 0x08000000,需要设置SCB->VTOR。普通项目不用管这个。DAPLink 下载后有时候需要手动复位一次,或者下载器配置里勾选“Reset and Run”。如果程序卡在HAL_Delay里出不来,检查 SysTick 中断有没有被其他高优先级中断打断,或者时钟配置是不是对的。
6.4 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 电机完全不转 | 电源没接、共地没接、STBY 没拉高 | 检查电池、GND、STBY 接 3.3V |
| 电机只抖不动 | 供电不足、PWM 频率太低 | 换电池、加电容、PWM 改 10kHz |
| 上电飞车 | 初始电平不确定、方向引脚默认高 | 初始化先拉低方向,PWM 清零,加下拉 |
| 串口乱码 | 波特率不匹配、时钟错误 | 统一波特率 9600,检查 HSE 起振 |
| 串口收不到 | TX/RX 接反、蓝牙未配对 | 交叉接线,手机重新配对 |
| 蓝牙连不上 | 模块供电不足、配对码错误 | 独立供电,确认配对码 1234 |
| 程序下载后不跑 | BOOT0 接高、复位没按 | BOOT0 接 GND,按复位键 |
| 车跑偏 | 左右电机一致性差 | 代码里加左右轮补偿系数 |
| 转弯太急甩尾 | 内侧轮完全停转 | 内侧轮保留 20% 占空比 |
| 电机驱动发热严重 | L298N 压降大、散热差 | 换 TB6612,加散热片 |
这张表是我自己踩坑总结出来的,遇到问题先对照着查,大部分情况都能解决。如果还不行,就拿出万用表和示波器,从电源开始一级一级往后量,信号在哪里断了,问题就在哪里。
这台车我前后改了三个版本,第一版用 L298N,跑十分钟驱动模块就烫手,后来换 TB6612,连续跑半小时也只是温温的。蓝牙模块也从 HC-05 换成了 JDY-31,体积小了一半,接线更清爽。状态机我一开始写得比较随意,用了一堆 if-else,后来改成 switch-case,逻辑清晰多了,加功能也不用担心改坏原来的代码。如果你已经跑通第一台,别急着堆功能,先把状态机和串口协议写扎实,后面加编码器测速、PID 闭环、甚至 FreeRTOS 多任务都会顺手很多。我下一章打算把编码器接上,让车能定速巡航,顺便试试用 FreeRTOS 把串口、状态机、电机控制分成独立任务跑,到时候再聊。