STM32F103C8T6 入门实战:蓝牙遥控小车 GPIO/PWM/UART 速通教程
2026/9/18 10:36:52 网站建设 项目流程

项目速通单片机:STM32F103C8T6 第1章

1. 项目整体设计与方案选型

1.1 为什么用“遥控车”来速通 STM32F103C8T6

很多人学单片机,第一步是点灯,第二步是流水灯,然后就卡在第三步不知道该干嘛了。我见过太多人把 GPIO、定时器、串口挨个学了一遍,真让他做个东西,还是无从下手。这次我直接拿一台遥控车当载体,把 STM32F103C8T6 最常用的几个外设全都串起来:GPIO 控制电机方向,TIM 定时器输出 PWM 调速,UART 接收蓝牙指令,软件状态机处理遥控协议。一台车做完,你基本上就把这块芯片的家底摸清了。

选 STM32F103C8T6 还有一个很现实的原因:便宜。国产替代芯片满地都是,几块钱一片,最小系统板十来块包邮,烧了也不心疼。而且它虽然是 F1 系列的入门型号,但该有的外设一个不少:3 个 USART、4 个 16 位定时器、2 个 SPI、2 个 I2C、37 个 GPIO,做一个小车绰绰有余。更关键的是,网上资料密度极高,不管你是用标准外设库还是 HAL 库,遇到问题基本都能搜到答案。

先说清楚这个项目要解决的核心问题:怎么让一辆小车听你的话。它涉及一条完整的数据链路——手机发指令,蓝牙模块收到后通过 UART 传给 STM32,STM32 解析指令,用状态机判断当前应该做什么动作,最后通过 TIM 输出 PWM 控制电机转速,用 GPIO 控制电机方向。任何一个环节断了,车都不会动。所以做完这台车,你不是只学会了某一个外设,而是学会了怎么把多个外设拼成一个能跑起来的系统,这个能力比背一百个寄存器都值钱。

1.2 核心方案与技术选型分析

在动手之前,先把方案定下来,这能帮你省掉后面大量返工的麻烦。我选的这套组合,是踩过不少坑之后觉得最适合新手的:

模块选型理由
主控STM32F103C8T6 最小系统板便宜、资料多、外设齐全
蓝牙HC-05 从机模块走串口协议,直接和 UART 对接,无需额外驱动
电机驱动TB6612FNG 模块体积小、压降小、逻辑电平兼容 3.3V,直接能用
电机TT 马达 + 减速齿轮箱便宜耐用,自带减速比,扭矩够用
供电两节 18650 锂电池组电压范围 6~8.4V,刚好符合电机驱动输入范围
调试工具ST-Link V2 + 串口转 USB 模块下载程序 + 打印调试日志,两个口都要用

为什么用 TB6612 而不是经典的 L298N?第一,L298N 的压降太大了,两个二级管加一个三极管串在回路里,6V 的电池经过它再到电机,实际到电机上的电压可能只有 4V 多,车跑起来软绵绵的。第二,L298N 的逻辑输入虽然也兼容 3.3V,但它的使能引脚需要拉到高电平,接线麻烦一些。TB6612 是 MOS 管结构,压降小很多,而且直接有 3.3V 逻辑兼容的输入,和 STM32 对接非常干净。

蓝牙选 HC-05 也是同样的逻辑——它本质就是一个串口透传模块,手机发什么字节,它就从 TX 引脚把这个字节流原封不动地送给 STM32 的 RX 引脚。这就意味着你不需要学习任何蓝牙协议栈,只需要会配 UART 就能遥控小车。HC-05 默认波特率是 9600,我后面会介绍怎么把它改到 115200,因为 9600 在传输频率高的指令时会有明显延迟。

1.3 项目目录与章节规划

这系列文章我打算按“从零到一”的顺序展开,每一章都在前一章的基础上加东西:

  • 第 1 章(本章):搭建硬件环境,点亮 LED,跑通 GPIO 和 UART 基本收发
  • 第 2 章:配置 TIM 输出 PWM,让电机转起来,掌握调速的底层逻辑
  • 第 3 章:接入蓝牙模块,设计遥控协议,用状态机实现完整的遥控逻辑
  • 第 4 章:扩展测速、避障、循迹等进阶功能,把定时器中断用起来

这次的标题虽然写得很满,但说到底就是一条主线:GPIO 控制、PWM 调速、UART 通信、状态机调度。你把这四个东西打通了,遥控车就有了;有了这辆车,后面再加什么传感器都是水到渠成的事。

2. 硬件准备与接线要点

2.1 硬件清单与接线总览

先把你需要的东西全部列出来,别等到做了一半发现缺个电容。

  • STM32F103C8T6 最小系统板,蓝色那种就行,注意买带两个晶振的
  • ST-Link V2 下载器,淘宝山寨版即可,刷个固件就能识别
  • HC-05 蓝牙模块,买带底板的那种,方便接线
  • TB6612FNG 电机驱动模块
  • TT 马达两个,配套的轮胎和联轴器
  • 18650 电池两节,带并联充电保护板的电池盒
  • 面包板或者洞洞板一块,建议用洞洞板焊,面包板容易接触不良
  • 杜邦线若干,公对公、公对母都备一些
  • 5V 稳压模块或者 AMS1117-5V 芯片,给 STM32 和蓝牙供电
  • LED、电阻(330Ω 和 10KΩ)、按键若干

接线是整个项目里最容易出错又最容易被忽视的环节。我给你一张我自己验证过的接线表,照着接就行,不用自己在数据手册里翻半天引脚。

STM32 引脚连接目标说明
PA9 (USART1_TX)HC-05 RXD单片机发送,蓝牙接收,交叉连接
PA10 (USART1_RX)HC-05 TXD蓝牙发送,单片机接收
PA0TB6612 AIN1左电机方向1
PA1TB6612 AIN2左电机方向2
PA6 (TIM3_CH1)TB6612 PWMA左电机调速
PA7 (TIM3_CH2)TB6612 PWMB右电机调速
PB0TB6612 BIN1右电机方向1
PB1TB6612 BIN2右电机方向2
GND所有模块的 GND必须共地,否则通信会乱跳

注意看,电机方向控制用的引脚我故意没有用同一组 GPIO,因为 PA0/PA1 和 PB0/PB1 分布在两个端口,写代码的时候要稍微注意一点,但这反而能让你练到跨端口操作的能力。

2.2 电源系统设计:别让电压压垮你的板子

这块板子最容易被新手搞坏的地方就是电源。STM32F103C8T6 最小系统板上通常有一个 AMS1117-3.3 稳压芯片,它可以把 USB 的 5V 或者 VIN 引脚输入的电压降到 3.3V 给芯片供电。但问题在于,很多淘宝店家会把 VIN 和 5V 引脚之间串一个二极管,导致你的输入电压在 6V 以上时,AMS1117 会发热非常严重,长时间工作甚至烧板子。

我的做法是:电池电压先经过一个单独的 5V 稳压模块(用 LM2596 降压模块就行,几块钱),把 6~8.4V 的电池电压降到稳定的 5V,再给 STM32 板的 VIN 供电。这样 AMS1117 输入就只有 5V,工作温度低很多。蓝牙模块 HC-05 的供电范围是 3.6V~6V,所以可以直接从板上 5V 引脚取电。

TB6612 的 VM 引脚接电池正极(不经过稳压),因为电机需要的就是高电压才有劲。但要注意,TB6612 的逻辑电压 VCC 必须接 3.3V,和 STM32 保持一致,否则逻辑电平不匹配会出各种奇怪问题。

整个电源系统的电流路径是这样的:

  • 电池正极 → TB6612 VM(电机电源)
  • 电池正极 → LM2596 → 5V → STM32 VIN、HC-05 VCC、TB6612 VCC
  • 电池负极 → TB6612 GND、LM2596 GND、STM32 GND、HC-05 GND

这里最重要的一个点就是共地——所有模块的地必须连在一起,形成一个统一的参考电位。如果没有共地,UART 通信会因为电位差而收到乱码,PWM 信号也可能会漂移。我一开始做的时候就犯过这个错,蓝牙能连上但收不到任何有效指令,排查了一下午最后发现是蓝牙模块和单片机地没接在一起。

2.3 下载器的连接与验证

ST-Link V2 是 20 针 JTAG 接口,但你只需要接 4 根线就能给 STM32F103C8T6 下载程序:

  • 3.3V → 3.3V
  • SWDIO → SWDIO(PA13)
  • SWCLK → SWCLK(PA14)
  • GND → GND

注意 SWD 接口的这两个引脚 PA13 和 PA14 默认是调试功能,你在初始化 GPIO 的时候不要去重新配置它们,否则会丢失调试能力。我见过有人为了省引脚把 PA13/PA14 改成普通 IO 用了,结果程序烧进去一次之后 ST-Link 就再也连不上了,最后只能通过串口 ISP 擦除整个 Flash 才救回来。

接好之后,先别急着写代码,直接用 ST-Link 配套的软件或者 Keil 里的 Settings 检测一下能不能识别到芯片。能识别到的话,说明硬件链路是通的,后面写代码就放心了。

3. 开发环境搭建与基础工程创建

3.1 Keil MDK 安装与工程配置

Windows 下最主流的开发环境还是 Keil MDK-ARM,虽然界面老了点,但它调试功能顺手,下载速度快,配合 ST-Link 非常稳定。安装过程没什么特别的,关键是装完之后要装 STM32F1 系列的支持包(DFP Pack),否则 MDK 不认识这个芯片。

工程创建建议直接走 HAL 库加 STM32CubeMX 的路线。有些人觉得 CubeMX 生成的代码太啰嗦,喜欢用标准外设库,我自己早期也是标准库的忠实用户,但现在必须承认,HAL 库在工程管理、时钟配置、外设初始化这些方面省了太多事。尤其 LED 闪烁这种入门工程,CubeMX 拖几下就能生成一个能跑的工程,你只需要在 main 里加两行代码就行。

具体的配置流程我用一张表记下来:

步骤具体操作说明
选芯片STM32F103C8Tx在 CubeMX 里直接选这个型号
配置时钟外部高速晶振 HSE + PLLF103C8 最高 72MHz
配置调试口SWD把 SYS 里的 Debug 选成 Serial Wire
配置串口USART1 异步模式波特率先用 115200
配置 LED选一个 GPIO 设为输出我习惯用 PC13,板载 LED,低电平亮
生成代码IDE 选 MDK-ARM版本选 V5 以上

时钟树这块要特别提醒一下:STM32F103C8T6 最大主频是 72MHz,CubeMX 默认会帮你把它拉满。但如果你用的是国产替代芯片,有些厂商的片子虽然叫 F103C8,但内部晶振起振可能不太稳定,上电后程序能跑,但串口波特率偏了一点。遇到这种情况,优先检查外部晶振是不是 8MHz——市面上最常见的就是 8MHz 无源晶振,CubeMX 里也默认按 8MHz 计算。

3.2 第一个程序:GPIO 点灯与验证时钟

工程建好之后,主函数里加这么一段,是最快的“系统自检”:

int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); while (1) { HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); HAL_Delay(200); } }

这段代码的逻辑非常简单:翻转 LED 引脚电平,延时 200ms,再来一次。如果你的 LED 在闪烁,说明三件事全部正常:系统时钟已经配好,GPIO 输出模式配置正确,HAL 库的延时函数在正常工作。

这里我要解释一下 GPIO 配置的底层逻辑,因为它几乎决定了你后面所有外设能不能正常工作。STM32 的每个 GPIO 引脚都有 8 种工作模式,但你实际用到的其实就那么几种:输出控制 LED 和电机方向用推挽输出;读取按键、编码器信号用上拉或下拉输入;串口 RX 引脚用浮空输入;PWM 输出脚必须配成复用推挽输出。

很多新手在配置 PWM 的时候把引脚设成了普通推挽输出,结果输出波形完全不对。原因就是,普通推挽输出模式下,TIM 外设的信号根本没连到引脚上,你得把引脚切换到复用功能,让它听 TIM 的指挥。CubeMX 里面你只要把 PWM 通道勾上,它会自动帮你把引脚设置成复用模式,不需要手动改。

3.3 串口打印调试:嵌入式工程师的“眼睛”

做嵌入式开发,尤其调试这种多模块联动的系统,串口打印就是你的眼睛。没有串口日志,你的代码跑没跑、跑到哪了、收到什么数据,全靠猜,效率太低了。

配置串口打印的步骤这里不多说,CubeMX 里勾一下 USART1 就行。关键是波特率的选择,我建议直接设 115200,不要在 9600 浪费感情。蓝牙模块出厂默认 9600,后面我们会教怎么改,但调试阶段用 115200 连接 ST-Link 旁边的 USB 转串口模块,速度和稳定性都要好不少。

把 printf 重定向到串口,是个常见的操作。在 HAL 库环境下,你只需要重写一个_write函数(MDK 环境用的是fputc),printf 就会把内容送到 UART。

int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }

加完这个函数,你就可以在任意位置用 printf 打印调试信息了。我习惯在每个模块的初始化函数里加一条日志,比如printf("USART1 init ok\r\n"),这样系统在启动的时候会依次打印各模块的初始化状态,一旦某个外设挂了,一眼就能定位到是哪一步出了问题。

4. UART 通信与蓝牙模块对接

4.1 HC-05 蓝牙模块的工作模式与配置方法

HC-05 这个模块有两个工作模式:AT 命令模式和透传模式。在 AT 命令模式下,你可以配置模块的名称、配对密码、波特率等参数;在透传模式下,它就老老实实地把串口数据通过蓝牙发出去,同时也把收到的蓝牙数据交给串口引脚。

切换模式的方法很简单:按住模块上的按钮上电,进入 AT 模式,此时模块慢闪(大概 2 秒一次)。然后你在单片机上跑一个串口发送程序,通过 AT 指令修改参数。我自己常用的几条 AT 指令是这样的:

指令作用说明
AT测试通信返回 OK
AT+NAME=MyCar改蓝牙名称手机搜到的名字会变成 MyCar
AT+PSWD=1234修改配对密码默认是 1234
AT+UART=115200,0,0修改波特率停止位 1,无校验
AT+ROLE=0设为从机模式手机主动连接它

注意,AT 指令必须以\r\n结尾,也就是回车换行两个字符。而且指令是大小写敏感的,写错了模块不会给你任何提示。改完波特率之后需要重新上电才生效,这个很多新手容易踩坑,改完之后发现模块没有反应,其实是你没断电重启。

4.2 串口接收中断与环形缓冲区的设计

蓝牙模块把数据发送到 STM32 的 RX 引脚后,STM32 什么时候去收这些数据?最笨的办法是在主循环里一直调用 HAL_UART_Receive,但这样做的问题是:主循环一旦去处理电机控制了,串口数据就丢了。更严重的后果是,如果你在发送数据的同时主流程正在处理别的事情,两个操作互相干扰,程序就乱套了。

正确的做法是使用串口接收中断。简单说,就是当 UART 硬件收到一个字节的时候,它会自动跳到中断服务函数里去,把数据放到某个地方,主循环只需要在很多毫秒之后来这里取就行。那这个“某个地方”就是环形缓冲区,一个先进先出的数据结构。

我自己常用的方式是在 CubeMX 里打开 USART1 全局中断,然后在HAL_UART_RxCpltCallback回调函数里开启下一轮接收,同时把收到的字节写入环形缓冲区:

#define RX_BUF_SIZE 128 uint8_t rx_byte; uint8_t rx_ring_buf[RX_BUF_SIZE]; volatile uint16_t rx_head = 0; volatile uint16_t rx_tail = 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { rx_ring_buf[rx_head] = rx_byte; rx_head = (rx_head + 1) % RX_BUF_SIZE; HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } }

在 main 里初始化时启动一次接收中断:

HAL_UART_Receive_IT(&huart1, &rx_byte, 1);

这样之后,每收到一个字节,它都会被存进环形缓冲区。主循环这边,你只需要判断rx_head != rx_tail就知道有没有新数据来了,然后从rx_tail位置一个字节一个字节取出来处理。这样做的好处是接收数据完全不影响主循环的控制逻辑,电机控制密集运算的时候,蓝牙数据也一个都不会丢。

4.3 UART 数据帧的设计:怎么区别不同指令

蓝牙收到的是一串字节流,比如手机发“F100”,你怎么知道它是前进还是后退?是速度 100 还是 10 后面跟了个 0?所以我们必须设定一套数据帧协议。

我设计的遥控协议非常简洁,用固定长度的 4 字节帧:

字节内容说明
00xAA帧头,所有指令固定以 0xAA 开头
1命令字F/B/L/R/S 对应前进、后退、左转、右转、停止
2速度值0~100 的整数,对应 PWM 占空比
30x55帧尾,用于校验帧完整性

这样做的好处是,STM32 解析的时候不需要处理变长数据,固定收满 4 个字节就判断一次。但固定长度也有个隐患:一旦中间某个字节错了,后面的数据全都会错位。所以在解析的时候,一定要有严格的帧头帧尾校验,如果发现帧头不是 0xAA,就直接丢弃这个字节,重新搜索帧头。

手机端配套的蓝牙串口 APP 有很多,用起来都差不多。我在电脑调试阶段用的是虚拟串口工具,直接输入十六进制字节串,方便精确控制;在手机调试时就用普通的蓝牙串口助手,发送比如AA 46 64 55(F 的 ASCII 是 0x46,100 的十六进制是 0x64),小车就会以 100 的速度向前跑。

5. PWM 电机调速:TIM 定时器的实战应用

5.1 定时器和 PWM 的基本概念

PWM 的本质就是“占空比”——一个周期内高电平的时间和整个周期的比例。占空比越高,等效输出电压越高,电机转速就越快。

那 PWM 是怎么产生的?这就要说到 STM32 的定时器了。你可以把定时器理解为一个不停从 0 数到 N 的计数器,当计数器的值小于某个阈值 A 时,引脚输出高电平;当计数器的值大于等于 A 时,引脚输出低电平。通过调整 N 和 A,你就能得到不同周期、不同占空比的方波信号。

STM32F103C8T6 有 4 个 16 位定时器:TIM1 是高级定时器,TIM2/TIM3/TIM4 是通用定时器。它们的功能差别不大,但对初学者来说,我建议用 TIM3 做 PWM 输出,因为它的 4 个通道都在方便引用的 GPIO 上:PA6、PA7、PB0、PB1,正好对应 TB6612 的两路 PWM 输入。

具体配置参数,我给出一个经过验证的经典组合:

参数计算依据
预分频器 PSC7172MHz ÷ 72 = 1MHz 计数频率
自动重装载 ARR991MHz ÷ 100 = 10kHz PWM 频率
初始占空比50%CCR=50,后面通过改变 CCR 调速

PSC 和 ARR 怎么来的?STM32F103 的 APB1 时钟是 72MHz,你要得到 10kHz 的 PWM,最简单的方式是先用 PSC 把 72MHz 分频到 1MHz,也就是 PSC=71,这样计数器每微秒加一;然后又用 ARR 让计数器从 0 数到 99,也就是一个周期 100 微秒。1MHz 除以 100 正好是 10kHz。这个频率在人耳听觉范围之外,所以电机转起来不会有烦人的“滋滋”声。

5.2 用 HAL 库配置 PWM 输出

CubeMX 里的配置我简要说一遍:在 TIM3 的 Channel1/Channel2 勾选 PWM Generation CH1/CH2,然后设置 Prescaler=71,Counter Period=99,Pulse 先填 50。这样生成的代码,就已经在跑 10kHz 的 PWM 了。

但是要注意,CubeMX 默认生成的 PWM 是“常开”的,也就是配置完立即输出。如果你想在程序里临时停止 PWM,可以调用HAL_TIM_PWM_Stop。一个更优雅的控制方式是,在初始化的时候先用__HAL_TIM_SET_COMPARE把比较值设置成 0(占空比 0%),电机自然就不转,需要驱动的时候再设置成目标值。

调速的核心代码就这么简单:

void motor_set_speed_left(uint8_t speed) { __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, speed); }

传入的 speed 范围是 0~100,而 ARR 是 99,所以 100 会溢出。我习惯在函数内部做一次线性映射,把 0~100 映射到 0~99:

uint32_t ccr_value = speed * 100 / 100; // 99 对应的就是 100%

5.3 PWM 频率的选择与实测效果

PWM 频率不是随便定的。频率太低(比如 1kHz 以下),电机会一抽一抽地转,噪声巨大;频率太高(比如 50kHz 以上),MOS 管开关损耗会增加,模块发热明显。10kHz 是一个比较均衡的选择。

但有一点很关键:TT 马达本身的电感比较大,PWM 信号到了电机上并不会直接变成稳定电压,而是会变成脉冲电压。如果你发现车子在低速时抖动严重,可以在电机两端并联一个小容量的陶瓷电容(100nF 左右),能明显改善。这个电容要尽量靠近电机引脚焊,效果最好。

我实测下来的效果是这样的:占空比 0% 时电机完全静止,占空比 20% 左右(等效电压 1V 左右)电机开始克服静摩擦力转起来,50% 左右电机有明显扭矩,90% 以上电机接近全速。所以你设计遥控器的时候,可以把速度档位分成“低速/中速/高速”三档,而不是线性可调,这样操控手感会更稳定。

控制电机方向是 GPIO 的事。TB6612 的逻辑很简单:AIN1 高 AIN2 低,电机正转;AIN1 低 AIN2 高,电机反转;两个都低或者都高,电机刹车。我用一个函数把方向和速度封装在一起:

void motor_set(int8_t speed) { if (speed > 0) { GPIO_SetLeftDir(1, 0); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, speed); } else if (speed < 0) { GPIO_SetLeftDir(0, 1); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, -speed); } else { GPIO_SetLeftDir(1, 1); // 刹车 __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 0); } }

这里有个细节,负速度取反之后再去设置 CCR,因为 CCR 只能是正数。既然 speed 范围是 -100~100,取负之后正好落在 0~100 的区间。

6. 电机驱动与 GPIO 控制细节

6.1 TB6612 工作原理与逻辑电平匹配

TB6612 的输入逻辑非常简单,概括成一张真值表:

AIN1AIN2模式电机表现
00短接刹车电机迅速停转
01反向电机反转
10正向电机正转
11刹车电机停转(同短接刹车)

输入端 AIN1/AIN2 的匹配问题值得多说一句。TB6612 的数据手册上写了输入高电平最低是 0.66×VCC(当 VCC=3.3V 时,高电平阈值约 2.18V),而 STM32 的输出高电平是 3.3V,完全满足要求,不需要加电平转换芯片。对比之下,L298N 的逻辑高电平阈值在 2.3V 以上,虽然也能用 3.3V 驱动,但它的使能引脚如果接 3.3V,输出电流能力会打折扣。这就是我坚持用 TB6612 的原因——接口设计就是按 3.3V 单片机考虑的。

有一个很多人会忽略的点:TB6612 的 VCC(逻辑电压)不接的话,即使 VM 有电,芯片也是不工作的。我见过有人把 VCC 悬空,结果电机一点反应都没有,查了半天发现是这个原因。VCC 必须要接,就算不接 3.3V 接 5V 也能工作,但逻辑阈值会相应抬高,不推荐。

6.2 差速转向的原理与代码实现

遥控车的转向,有舵机和差速两种方式。玩具车上常见的是前轮舵机转向,但舵机需要一个额外的 PWM 信号(50Hz 左右),而且安装结构比较复杂。所以我选择差速转向:左轮和右轮速度不一样,车就会向慢的一侧转弯。

差速转向的数学模型很简单:设定一个基础速度 base,和一个转向系数 turn,取值范围 -100~100。左右轮速度分别这样算:

  • 左轮速度 = base + turn
  • 右轮速度 = base - turn

当 base=50、turn=0 时,两轮都是 50,车直行;base=50、turn=30 时,左轮 80 右轮 20,车明显左转;base=50、turn=100 时,左轮 150 被限幅到 100,右轮 -50 反向,车原地旋转。

代码里要限幅,否则 pulse 超过 ARR 就会出现反转的情况:

int16_t left_speed = base + turn; int16_t right_speed = base - turn; if (left_speed > 100) left_speed = 100; if (left_speed < -100) left_speed = -100; if (right_speed > 100) right_speed = 100; if (right_speed < -100) right_speed = -100; motor_set_left(left_speed); motor_set_right(right_speed);

原地旋转在狭小空间里非常实用,遥控车能不能“漂移”就看你这个转向函数写得顺不顺。

7. 状态机设计:让小车行为可控

7.1 为什么遥控逻辑适合用状态机

把遥控逻辑做成状态机,是我在这个项目里最想强调的一点。很多人写遥控代码是这样的:主循环里不断判断蓝牙有没有来指令,来什么就执行什么。这看起来没问题,但实际跑起来会发现,小车的行为很“神经质”——上一秒还在前进,下一秒突然变后退;按一下左转键,车转了大半圈才停下,操控手感稀烂。

这背后的原因是:你的代码没有“记忆”,它不知道车当前处于什么状态,自然也就不知道怎么平滑地过渡到下一个状态。状态机的核心思想就是把行为拆成若干个明确的“状态”,然后用一个变量记录当前状态,根据当前状态和输入事件决定下一步该干什么。

我设计的遥控小车状态机有 5 个状态:

状态含义进入条件执行动作
IDLE待机上电或收到停止指令刹车,所有速度清零
FORWARD前进收到前进指令左右轮同速正转
BACKWARD后退收到后退指令左右轮同速反转
LEFT左转收到左转指令左轮减速右轮加速
RIGHT右转收到右转指令左轮加速右轮减速

这个状态机的妙处在于,每种状态下你只需要关心怎么维持当前行为,而不需要关心“我是从哪里来的”。而且你可以在状态切换的时候统一处理一些事情,比如从 FORWARD 切换到 BACKWARD 时,先刹车 100ms 再反转,就能避免冲击电流烧驱动模块。

7.2 在 STM32 上实现状态机的代码结构

主循环里的调度逻辑我这样写:

typedef enum { STATE_IDLE, STATE_FORWARD, STATE_BACKWARD, STATE_LEFT, STATE_RIGHT } CarState; CarState current_state = STATE_IDLE; void car_state_machine_run(void) { uint16_t speed_base = 60; uint16_t speed_turn = 40; switch (current_state) { case STATE_IDLE: motor_set_left(0); motor_set_right(0); break; case STATE_FORWARD: motor_set_left(speed_base); motor_set_right(speed_base); break; case STATE_BACKWARD: motor_set_left(-speed_base); motor_set_right(-speed_base); break; case STATE_LEFT: motor_set_left(speed_base - speed_turn); motor_set_right(speed_base + speed_turn); break; case STATE_RIGHT: motor_set_left(speed_base + speed_turn); motor_set_right(speed_base - speed_turn); break; default: break; } }

这样每调用一次car_state_machine_run(),它就会根据当前状态驱动电机。你只需要在收到有效的蓝牙指令时修改current_state的值,状态机自己会把行为执行到位。

7.3 状态机与串口指令解析的配合

现在把前面做的所有东西串起来。串口中断负责收字节,主循环里解析指令,解析成功后修改状态机状态:

void process_command(uint8_t cmd, uint8_t speed_value) { switch (cmd) { case 'F': current_state = STATE_FORWARD; break; case 'B': current_state = STATE_BACKWARD; break; case 'L': current_state = STATE_LEFT; break; case 'R': current_state = STATE_RIGHT; break; case 'S': current_state = STATE_IDLE; break; default: break; } }

这里速度值我没有直接用,而是让每个状态使用预设的固定速度。如果你想实现调速功能,可以再加一个全局变量target_speed,在状态机运行的时候用它替换speed_base

主循环里做的事就非常纯粹了:

while (1) { parse_uart_frame(); // 解析串口数据帧,调用 process_command car_state_machine_run(); // 根据状态控制电机 HAL_Delay(10); // 10ms 一个控制周期 }

10ms 的控制周期意味着小车每秒刷新 100 次状态,人操控起来完全感觉不到延迟,而单片机的负载又非常低。

8. 常见问题与排查技巧实录

8.1 小车不动?从电源到电机逐级排查

我把实际调试中遇到的高频问题都整理出来,按排查优先级排序:

现象可能原因排查方法
电机完全不动ST-Link 没给板子供电独立供电后测试
电机完全不动TB6612 VCC 没接 3.3V万用表量 VCC 引脚
电机完全不动PWM 通道没初始化示波器看 PA6 波形,没有就查 CubeMX
电机只能转一个方向AIN1/AIN2 反了交换两线或者交换 GPIO 初始化代码
电机抖动不转PWM 频率太低检查 PSC/ARR,改成 10kHz
蓝牙连上但控制没反应波特率不匹配HC-05 设的波特率和代码里不一致
蓝牙偶尔断开电源电流不足电池内阻太大,换新的或者加一个电解电容

万用表是排查这种问题的最好工具。我调试的时候会先量电源,再量逻辑电平,最后量 PWM 波形。从前往后一级一级查,通常十分钟就能定位到问题。

重点说说 PWM 波形的检查。没有示波器的同学可以用万用表测电压来判断 PWM 是否在输出:如果 PA6 引脚电压大概 1.65V(占空比 50% 时),说明 PWM 在跑。如果电压是 0 或者 3.3V,说明没有 PWM 输出或者占空比是 0 或 100%。

8.2 串口数据乱码与丢失的典型场景

很多人第一次调蓝牙会遇到这种情况:手机这边发“hello”,单片机打印出来是乱七八糟的符号,或者干脆什么都没收到。我总结了几种最常见的场景:

第一种,波特率不匹配。HC-05 默认 9600,如果你代码里配的是 115200,收出来的字节全是乱码。解决方法是先用 AT 指令把蓝牙波特率改成和代码一致,或者反过来把代码改成 9600。改的时候注意:更改波特率后必须重新上电。

第二种,串口引脚接反了。TX 要接对方的 RX,RX 接对方的 TX,也就是交叉连接。很多人直接用杜邦线一对一接,TX 接 TX,一辈子收不到数据。这个错误太典型了,你可以在纸上画一遍数据流向再接线。

第三种,数据丢失严重。这通常是中断接收没有处理好。如果你用的是轮询方式接收,主循环执行其他模块的耗时操作时,串口数据就会不断丢失。解决方法是像前面说的那样,改成中断加环形缓冲区。

串口调试助手我推荐两个,Windows 上首选免费的“SSCOM”,界面简单,支持十六进制显示;手机端用“蓝牙调试器”,它可以同时显示 ASCII 和十六进制,方便你对照协议帧。

8.3 掉坑后的三条心得

项目完成后,回头复盘,有几条心得体会值得写出来。

第一,硬件接线一定要画图再动手。我在没有画接线图的情况下直接插线,结果把 AIN1 和 AIN2 接反了,程序跑起来小车只往一个方向转,查了老半天才发现是硬件问题。画图十分钟,排查两小时,这个账怎么算都划算。

第二,程序一步一步加,不要一下把所有功能都堆上去。先把 LED 点亮,确认工程没毛病;然后让电机转起来,确认 PWM 正常;再加蓝牙接收,最后才做状态机联动。每一步的成果都能验证,出了问题也只在最近改动的地方找,不会满屏代码找 bug。

第三,串口日志要养成习惯性地去用。尤其是在写状态机的时候,在状态切换的地方加一行 printf,调试起来简直不要太舒服。我看到状态机从 FORWARD 跳到 LEFT 了,剩下的事就是检查转向速度计算对不对,再也不用盲猜小车到底在想什么。

这个项目做到这里,一台能通过手机蓝牙控制的遥控车就已经能跑了。从 GPIO 到 PWM,从 UART 到状态机,你亲手把这块芯片从“一堆引脚”变成了“一个有行为的东西”。下一章我会在这个基础上加入定时器中断、编码器测速和闭环控制,让小车不仅“能跑”,还能“跑得稳”。到时候你会进一步发现,单片机的世界远比你想象的更有意思。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询