打开你的STM32开发板,把小车底盘拼好,电机驱动板插上杜邦线,手机装上蓝牙串口APP——这就是很多电子爱好者的入门必修课:串口通信、红外通信与蓝牙遥控小车。这个项目听起来简单,但它其实串起了一整条嵌入式学习主线:STM32怎么接收数据、串口通信协议怎么设计、PWM怎么控制电机转速、蓝牙模块怎么配对与传输。
这篇文章就围绕这个项目,把串口通信在蓝牙遥控小车里的完整链路讲清楚,顺带聊几句红外通信作为对比方案时的坑。适合刚学完STM32基础外设、准备做第一个完整小项目的同学,也适合做毕设或者课后作品需要快速落地的人。
1. 先想清楚:一个遥控小车,为什么非要碰串口通信
很多新手拿到题目"STM32蓝牙遥控小车",第一反应是打开某个APP直接发字符、小车动起来就行。但真做起来会发现,手机和STM32之间隔着一层蓝牙,而蓝牙模块和单片机之间交换数据的唯一通道,就是串口通信。所以这个项目的真正核心,不是小车,而是那条从手机一路延伸到电机驱动芯片的数据链。
1.1 三种遥控方式的取舍
红外通信、蓝牙通信、2.4G射频遥控,三者在小车项目里都有人用,但学习成本和体验差别很大。我一开始想用红外通信来做,因为手边正好有闲置的遥控器和一体化红外接收头,觉得便宜、简单。红外方案的优点很直接:元器件很便宜,接收头一两块钱,遥控器十几块,而且不涉及任何配对流程,按一下键就发一串脉冲,接收端解码就行。缺点也很致命:红外是直线传播的,中间隔个书包、转个身,信号就断了。在室内桌面上玩玩倒可以,一旦小车开到桌子底下,你就得趴下去对着它按遥控器,非常痛苦。
蓝牙方案的价格也就多个二三十块,换来的是全向控制、手机APP操作、距离更远,而且能顺便把串口通信练熟。2.4G方案最稳定,但一般要用航模遥控器加接收机,成本高、接线复杂,对纯学习场景来说没必要。最终我给小车定了蓝牙路线,理由很简单:这个项目最有价值的部分不是"让它动",而是"它学会听懂你说的话",即串口通信。
| 遥控方式 | 成本 | 通信距离 | 抗遮挡 | 学习价值 | 推荐场景 |
|---|---|---|---|---|---|
| 红外通信 | 极低 | 3-5米,需对准 | 差 | 定时器输入捕获、NEC协议解析 | 入门练手、固定方向控制 |
| 蓝牙(HC-05/HC-06) | 低 | 10米左右 | 中等 | 串口通信、协议设计、中断接收 | 手机遥控、毕设作品 |
| 2.4G射频(NRF24L01) | 中等 | 20米以上 | 较好 | SPI通信、双机通信 | 进阶项目、多机协同 |
1.2 系统架构与控制链路
现在把整条链路画出来,你就明白每个模块在干什么了。手机APP通过蓝牙无线信道发送字节流,HC-05蓝牙模块收到后,从TXD引脚把数据按串口协议一字一字地吐给STM32的USART1接收引脚。STM32在串口中断里收完一帧数据,解析出前进、后退、左转、右转、调速指令,再通过定时器输出的PWM信号控制电机驱动板的IN引脚,从而改变电机两端电压,完成动作。
这条链路里最容易被忽略的是串口通信的"帧"概念。很多人第一次写代码,直接在蓝牙APP里发字符'a'、'b'、'c',STM32端每次收到一个字节就执行一个动作。这种方式在小车上也能跑,但很不健壮,因为蓝牙传输是流式的,你发"前进"这个词的时候,实际是一串字节陆续到达的,如果中间被拆包、或者干扰插进来一个字节,控制就会错乱。所以后文我会用一个带起始标志和数据长度的简单协议来管理串口通信,这是整个项目最值得学的地方。
2. 硬件选型与接线里的那些坑
硬件选型上,我看到很多人直接抄网上的清单:STM32F103C8T6最小系统板、HC-05蓝牙模块、L298N电机驱动、四轮小车底盘。这套组合能用,但有几个细节不搞清楚,光接线就够你折腾一晚上。
2.1 核心器件的选型逻辑
主控芯片我选了STM32F103C8T6,也就是大家说的"蓝丸"板。它有64KB Flash、20KB RAM,USART、定时器、GPIO资源足够,而且CubeMX一键初始化,网上资料多得看不完。如果预算再多一点点,可以考虑STM32F411CEU6黑丸板,主频96MHz,处理加速度计、陀螺仪这些后续扩展会轻松不少,但入门用103完全够。
蓝牙模块是另一个容易踩坑的地方。HC-05和HC-06外观几乎一样,但HC-05既能当从机也能当主机,能主动去连接别的蓝牙设备,还支持AT指令配置;HC-06只能当从机,价格便宜十几块。做遥控小车只需要手机去连接模块,HC-06就够了。不过我自己习惯用HC-05,因为它带一个状态LED,连接成功后指示灯闪烁频率会变化,排查连接问题非常方便。而且HC-05默认波特率是38400,很多新手烧录代码时用的是9600,导致串口通信一直乱码,后面会细说。
电机驱动板,网上最常见的是L298N。它结实、耐造,但压降大——两个通道同时输出时,单片机的5V稳压可能扛不住。我后来换成了DRV8833模块,体积极小,MOS管压降小,3-10V供电,GPIO电平直接兼容3.3V,省电不少。如果只是做一个小车,DRV8833这类小板是更现代的选择,接线也更简单:AIN1、AIN2控制左电机正反转,BIN1、BIN2控制右电机,PWM引脚可以直连STM32的TIM引脚调速。
2.2 接线与供电布局
接线布局是整个项目里最容易出问题的环节。我经历过最惨的一次:所有线看起来都对,小车就是不动,最后发现是电源共地没接——电机驱动板的GND和STM32的GND没有连在一起,导致PWM信号根本没有参考地,逻辑电平飘在空中。
STM32F103C8T6最小系统板的USB口供电能力很弱,接两个电机转起来,瞬间电流可能到1A以上,USB口的5V会被拉低,板载AMS1117稳压出来的3.3V也跟着掉,于是出现"蓝牙一连接,单片机就重启"的怪现象。靠谱的做法是:电池单独给电机驱动板供电,驱动板上的5V输出再当作单片机和蓝牙模块的电源来源(如果驱动板是L298N,它自带的5V输出就是这么用的),同时必须把驱动板的GND、单片机GND、蓝牙模块GND全部连在一起。我用的是两节18650锂电池串联约7.4V,接L298N的12V输入口,驱动板5V输出给单片机,实测很稳。
接线建议这样排布:
HC-05 STM32F103C8T6 VCC ------> 3.3V (不能接5V,HC-05的VCC标称3.6-6V但板载稳压容易烧) GND ------> GND TXD ------> PA10 (USART1_RX) RXD ------> PA9 (USART1_TX,注意这里要分压,因为HC-05的RXD是3.3V电平) 电机驱动板 STM32F103C8T6 IN1 ------> PB0 (左电机正转) IN2 ------> PB1 (左电机反转) IN3 ------> PB2 (右电机正转) IN4 ------> PB3 (右电机反转) GND ------> GND (共地,必须) 电池正极 -----> 驱动板VM 驱动板5V ----> STM32 5V (给板子供电)要注意一点:如果直接用HC-05的TXD连STM32的PA10,用HC-05的RXD连PA9,在STM32F103板子上是可以的,因为HC-05和STM32都是3.3V逻辑。但如果你的蓝牙模块是5V逻辑的,就必须给RXD串一个1k左右电阻分压,否则长时间跑会把引脚烧掉。
3. 从点到面的关键实现:串口协议与PWM调速
硬件搭好之后,真正决定项目好坏的是软件。我见过有人把串口代码写成轮询方式,在while循环里不断读数据寄存器,结果小车稍微复杂一点,主循环被占满,中断一多系统就卡死。正确思路是:串口接收全部走中断,主循环只负责“处理解析好的命令”。
3.1 通信协议怎么定
我们首先需要定一个串口通信协议。不要一上来就发裸字符,哪怕你只是自己玩玩,也建议用一个极简单帧格式:
帧头(0xAA) | 类型(1字节) | 数据(2字节) | 校验(1字节)类型可以定义成:
- 0x01:前进
- 0x02:后退
- 0x03:左转
- 0x04:右转
- 0x05:刹车
- 0x10:设置速度(数据字节存放0-100的百分比)
数据字节可以是速度百分比,比如前进时发送AA 01 50 00,表示50%速度前进。校验字节可以用简单的异或校验,把所有字节异或一遍放在最后,接收端算出来对不上就直接丢帧。为什么要异或校验?因为蓝牙信道虽然比红外稳定,但在室内也会受WiFi、微波炉这些2.4G信号的干扰,偶尔蹦一个错误字节出来,如果没校验,小车可能突然往墙上撞。
手机端发送的时候,可以用任意一款蓝牙串口助手,在指令输入框里填十六进制:AA 01 50 00。如果用的是字符串发送,也可以定义成文本协议,比如发送*MOTOR:F:50#这种带起始符和结束符的字符串。文本协议调试直观,不用十六进制转来转去,但解析稍微费点内存。我个人更推荐初学者用二进制帧,因为嵌入式设备以后对接上位机、传感器、无线模块,二进制协议才是主流。
3.2 CubeMX配置与代码落地
STM32CubeMX里需要配置两个核心外设:
第一,USART1设为异步模式,波特率根据HC-05设置。如果用HC-05默认出厂参数,就是38400,8位数据,无校验,1位停止位。如果你在蓝牙模块上改过AT指令,记得保持一致。使能USART1全局中断,否则收不到数据。
第二,定时器输出PWM。我用TIM2的CH1和CH2,分别输出到PA0和PA1,控制左右电机的速度。配置时把PWM频率设为1kHz左右——太低了电机会发出明显的"嗡嗡"声,太高了驱动板上的H桥可能响应不过来。占空比初值设0,后续在代码里调整。
生成代码之后,在main.c里开启接收中断:
uint8_t rx_data; uint8_t uart_rx_buf[8]; uint8_t uart_rx_len = 0; // 在main函数里启动第一个字节的接收 HAL_UART_Receive_IT(&huart1, &rx_data, 1); void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uart_rx_buf[uart_rx_len++] = rx_data; if (uart_rx_len >= 8) { uart_rx_len = 0; // 简单保护,防溢出 } // 重新开启接收 HAL_UART_Receive_IT(&huart1, &rx_data, 1); } }这段代码的思路是每次只接收一个字节,收到后进回调函数,把数据塞进缓冲区,再立刻开启下一次接收。为什么不用HAL_UART_Receive_IT(&huart1, buf, len)一次收满一帧?因为串口数据不是一口气全到的,蓝牙传输本身有延迟,一帧里的字节可能间隔几十毫秒,你设一个超时判断反而更麻烦。单字节接收配合主循环里的状态机解析,是最稳的组合。
3.3 串口收码与动作执行的逻辑
真正解析帧的代码我放在主循环里,用状态机来做,这个代码基本是所有串口项目的模板,值得背下来:
typedef enum { FRAME_IDLE, FRAME_HEADER, FRAME_TYPE, FRAME_DATA1, FRAME_DATA2, FRAME_CHECK } FrameState; FrameState state = FRAME_IDLE; uint8_t frame_type = 0, frame_data1 = 0, frame_data2 = 0, frame_check = 0; uint8_t calc_check = 0; void parse_uart_data(void) { for (int i = 0; i < uart_rx_len; i++) { uint8_t byte = uart_rx_buf[i]; switch (state) { case FRAME_IDLE: if (byte == 0xAA) state = FRAME_HEADER; break; case FRAME_HEADER: frame_type = byte; calc_check = 0xAA ^ byte; state = FRAME_TYPE; break; case FRAME_TYPE: frame_data1 = byte; calc_check ^= byte; state = FRAME_DATA1; break; case FRAME_DATA1: frame_data2 = byte; calc_check ^= byte; state = FRAME_DATA2; break; case FRAME_DATA2: frame_check = byte; state = FRAME_CHECK; break; case FRAME_CHECK: if (calc_check == frame_check) { execute_command(frame_type, frame_data1, frame_data2); } state = FRAME_IDLE; break; } } uart_rx_len = 0; }这个状态机的核心思想是:一个一个字节喂进去,按帧格式推进状态,最后校验通过才执行动作。这样一来,即使蓝牙传来一长串数字,或者中间混入干扰字节,只要状态不对,会退回到IDLE重新等待帧头,不会误动作。
execute_command里写:
void execute_command(uint8_t type, uint8_t d1, uint8_t d2) { int speed = d1; // 0-100 uint16_t pwm_value = speed * 999 / 100; // ARR=999,占空比0-999 switch (type) { case 0x01: // 前进 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_RESET); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, pwm_value); break; case 0x02: // 后退 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_SET); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, pwm_value); break; // 左转右转类似,只是左右轮PWM值不一样 } }PWM调速这里有个关键细节:__HAL_TIM_SET_COMPARE直接改比较寄存器,是调速最常用的手段。但如果你在中断里修改PWM占空比,记得别在高优先级中断里频繁调用,否则可能造成占空比毛刺。实测下来,在小车这个场景里无所谓,但养成好习惯没坏处。
4. 常见问题与排查技巧实录
这部分是真正值钱的地方。HC-05蓝牙模块连接不上、串口乱码、小车跑偏、电机抖动,每个问题我都踩过,把排查路径整理成速查表,按顺序查,基本都能解决。
4.1 HC05蓝牙模块连接不上
这是被问到最多的问题,没有之一。排查顺序一定要对:
第一,看电源灯。HC-05上电后,如果LED快速闪烁,说明模块正常工作但未连接。如果LED常亮,说明已经被某个设备连上了。如果你之前手机配对过,但这次打开APP搜不到,先关掉手机蓝牙再重新打开,Android系统经常把蓝牙模块"记住"但又不自动重连。
第二,看波特率。我特别强调一遍:HC-05默认波特率是38400,市场上很多山寨模块被刷成9600。你在STM32代码里设的波特率必须和模块一致。判断方法很简单:先用USB转TTL模块把HC-05接到电脑串口助手,直接发AT指令,如果返回OK,再发AT+UART?查一下当前波特率。如果连AT指令都没反应,多半是进入了AT模式,需要按住模块上的按键再上电,此时波特率是38400。
第三,看配对密码。HC-05默认配对密码是1234,但如果之前被配置过,密码可能变了。用AT指令AT+PSWD?查询重置。另外手机第一次配对时系统会弹密码输入框,很多新手以为那一步是模块出了问题,其实不是。
第四,很多人在网上买模块时,商家给的资料里写着"默认波特率9600""默认名称HC-05",但实际到手参数千奇百怪。我一般到手第一件事就是全用AT指令重置一遍:AT+ORGL恢复出厂,然后AT+UART=38400,0,0设成38400。这一步能省掉后面一大堆排查时间。
4.2 串口乱码与电机抖动
串口乱码的原因,排在第一位的是波特率不匹配,这个排查起来最简单。排在第二位的是ST-Link或USB转TTL干扰——有些劣质调试器的地线有干扰,会让RX线上叠加上噪声。我用过一个55块钱的ST-Link,调试时串口总出一两个错字节,后来换了正版ST-Link,问题直接消失。
电机抖动则是供电问题。用USB口给单片机供电时,电机一转,电流拉高,板子电压瞬间跌到3V以下,单片机内部的Flash读取都可能出错,表现为随机的PWM占空比毛刺。解决办法在前面接线部分讲过:电池单独给驱动板供电,共地,驱动板的5V输出再给单片机。如果手头只有一块低压差稳压板,可以用LM2596模块把7.4V电池电压稳到5V给单片机,这样电机大电流也不影响逻辑部分。
还有一个小技巧:电机两个电极之间要并联一个104陶瓷电容,或者直接把电机上自带的去耦电容留着。电机电刷换向会产生大量尖峰干扰,透过驱动板反馈到5V电源轨上,轻则串口乱码重则单片机死机。我给每个电机都并了电容之后,整个系统的稳定性明显提升了一个档次。
4.3 红外通信的小经验
既然标题里提到了红外通信,我也补几句。红外遥控小车一般用NEC协议,38kHz载波,遥控器按键按下时发送一串脉冲序列。STM32读取红外接收头输出,用定时器输入捕获测量高低电平持续时间,解析出地址码和按键码。
在STM32蓝牙遥控小车上叠加红外通信,一个更实际的做法是"冗余控制":蓝牙断了,还能用红外遥控让它停下来。红外接收头集成在GPIO上,解析逻辑完全独立,只在需要时启用。不过要提醒一点:红外接收头对阳光敏感,室内灯光也会干扰,特别是现在教室和家里的LED灯,PWM调光频率如果落在红外接收头的响应范围内,会直接把解码逻辑冲乱。如果发现小车面对灯光乱跑,大概率不是代码问题,而是环境光干扰,想继续调试就先拉上窗帘或者换到背光位置。
5. 这个项目还能往哪些方向扩展
做完一台能跑动的蓝牙小车,你会发现核心能力其实已经沉淀下来了:串口通信的收发与解析、PWM控制执行器、中断处理。这三件事可以迁移到非常多项目里。
5.1 从蓝牙到WiFi/局域网控制
手机蓝牙遥控距离只有10米左右,把蓝牙模块换成ESP8266或ESP32,小车就变成了WiFi控制。ESP32和STM32之间仍然可以用串口通信,只是数据来源从蓝牙变成了TCP/UDP。这时候前面写的状态机解析代码一行都不用改,只需要新开一个USART口,把ESP32串口接上来就行。我在一个升级版小车里就是这样做的,上位机用Python写了一个简易UDP控制界面,把之前的帧格式原封不动地搬过去,三百行代码就完成了远程遥控。
WiFi方案还能解决一个蓝牙的痛点:蓝牙串口APP大多是一次只能连一个设备,而WiFi可以同时多端控制。另外ESP32本身也能做信号强度测距,用RSSI粗略判断手机朝向,做一个自动跟随的小车也完全可行。
5.2 协议设计思维迁移
玩过这个小车后你会发现,串口通信协议的设计套路到处都是:帧头、长度、命令、数据、校验。这个套路后面接GPS模块、接单片机之间通信、接机器人的控制总线,都是同一个套路。
比如很多人问的Unity串口通信,也就是Unity上位机通过串口控制下位机:Unity里先打开SerialPort类,配置串口号和波特率,然后按你定义的二进制帧格式发送指令。小车里用的0xAA帧头+异或校验,在Unity里就用byte[]数组发出去,下位机的状态机原样解析。我做的一个流水灯展示项目就是这么干的,Unity界面里点击按钮,Windows把串口数据发到STM32,STM32解析后控制LED灯带。所以在这个小车上花时间把协议写规范了,后面做任何上位机联动都能直接复用,特别值。
6. 几个实操心得
最后说几个我在反复调试中积累的小经验,不写总结性的话,就分享点实在的。
我实际做这类项目的流程是:先把串口通信调通,再用串口助手发指令让小车动,最后才接蓝牙。很多人跳过第二步,直接蓝牙模块上电、手机连上、发指令,出了问题根本分不清是蓝牙的问题还是电机控制的问题,排查时一团浆糊。串口通信是整个链路的地基,地基稳了,蓝牙只是换个数据来源而已。
控制小车直行时,左右电机的PWM值很少能完全一致——两个电机哪怕型号相同,内阻和机械摩擦也有细微差异。我测试下来,一般会有5%到10%的速度偏差,表现为小车总是往一边偏。解决办法是把PWM调速值做成可配置参数,在校准模式里通过手机发指令微调左轮和右轮的偏移量,而不是在代码里写死。
当时还遇到过一个匪夷所思的Bug:手机蓝牙一连接,小车就原地往前冲一下。后来发现是蓝牙串口APP在建立连接时,会向模块发送一行初始化AT指令或者字符,被STM32当成了命令解析。解决方法是把控制指令全部加上帧头和校验,同时在状态机里对非法数据直接丢弃。如果只发裸字符,这类问题几乎无法彻底消除。
这就是串口通信的魅力:表面上看只是几根线、几个寄存器,实际上每一层都藏着值得琢磨的细节。把这台蓝牙遥控小车做完、调稳,你对STM32的外设认知、协议设计能力和排错手感都会提高一大截,再去做复杂的项目,会觉得轻松很多。