1. 从零攒一台遥控车:整体思路与技术选型拆解
拿到STM32F103C8T6最小系统板的时候,很多人第一反应是点灯,点完灯就不知道干嘛了。我当初也是这个状态,直到逼着自己用一块板子做出一台能跑起来的遥控车,才真正把GPIO、PWM、UART、定时器、状态机这几块知识串成了一条线。这篇就把我做的第一台蓝牙遥控车从选型到跑通的完整过程摊开讲,适合刚摸到单片机、想找一个综合项目练手的同学,也适合做过51、想换到32位平台练手的老鸟。整车的核心链路其实就一句话:手机蓝牙发指令,UART收进来,状态机判断该干什么,PWM调电机转速,GPIO控方向,定时器决定节奏。关键词就是STM32F103C8T6、GPIO八种工作模式、PWM调速、UART串口、状态机这几样,搞透了这几样,后面做平衡车、循迹车都是同一套底子。
1.1 为什么偏偏选STM32F103C8T6这块板
市面上能驱动小车的MCU一抓一大把,为什么我第一台车还是用C8T6?主要是三个原因。第一是资源刚好够用还不浪费:72MHz的Cortex-M3内核、64KB Flash、20KB SRAM,控制两个直流电机这种任务对它来说就是杀鸡用牛刀,但胜在外设齐全,3个USART、3个通用定时器加1个高级定时器,PWM通道数量足够,不用为了凑路数去外挂芯片。第二是资料环境成熟,Keil和CubeMX的配置模板遍地都是,标准库和HAL库任选,新手踩坑了容易搜到答案。第三是成本,一块最小系统板加国产替代型号的价格已经压到很低,做坏了不心疼,适合反复练手。
需要说明的是,我下面给的引脚分配、定时器选择都是基于常见做法总结出来的一套稳妥方案,不是唯一解,你完全可以按自己手头的模块调整。但思路和坑点是通用的。
1.2 把这台车拆成四个可独立验证的模块
新手最容易犯的错,是一上来就把所有模块焊在一起然后通电,结果不知道哪儿出了问题。我的做法是把它拆成四块,每块单独跑通再接下一块。第一块是电源与最小系统板本身能正常运行,串口能打印信息;第二块是电机驱动能单独用代码控制正反转和调速;第三块是蓝牙能收到手机发的字符;第四块才是状态机把这些东西串起来。这么拆的好处是,任何一步出问题,故障范围都被圈得很小,排查起来快。
这四块的依赖顺序也很关键:先让串口通,因为串口是你后面调试所有东西的眼睛;再让电机动,因为电机涉及供电和PWM,是最容易翻车的地方;最后做蓝牙和状态机,因为这两个是纯软件逻辑,硬件没问题的情况下基本一次就能通。
2. 硬件清单与最小系统板上手要点
动手之前先把料备齐,避免焊到一半缺东西。下面是这台车用到的完整清单,价格都是大概范围,供参考。
| 器件 | 型号/规格 | 数量 | 用途与说明 |
|---|---|---|---|
| 主控板 | STM32F103C8T6最小系统板 | 1 | 核心,注意买带3.3V稳压和复位按键的 |
| 下载器 | ST-Link V2 或 DAPLink | 1 | 烧录和调试,SWD接口 |
| 电机驱动 | L298N 或 TB6612FNG | 1 | 双路H桥,驱动两个直流电机 |
| 蓝牙模块 | HC-05 或 HC-06 | 1 | 与手机通信,串口透传 |
| 直流电机 | 减速电机 3-6V | 2 | 带轮子 |
| 车体 | 亚克力底盘+轮子 | 1套 | 两轮差速结构 |
| 电池 | 2S锂电7.4V 或 4节AA 6V | 1 | 给电机和板子供电 |
| 杜邦线 | 母对母、公对母 | 若干 | 连接用 |
2.1 电源与共地,这一步错了后面全白干
供电是整个项目里最容易被忽视、又最容易出问题的地方。L298N这类驱动模块的电机供电和逻辑供电是分开的,电机那一路电压波动大、电流大,绝对不能和MCU共用一根细细的杜邦线。我的接法是:电池先接到电机驱动的电源输入,再由驱动板的5V输出给最小系统板供电,这样电机的大电流走的是粗线,MCU拿到的是相对干净的一路。
这里有个必须强调的点:STM32和电机驱动之间一定要共地。我就吃过这个亏,第一版接线时忘了把驱动板的地和单片机的地连起来,结果蓝牙能收到数据、程序也跑得好好的,电机就是纹丝不动,查了半天以为是定时器没配好,最后发现是地没连。所以接线检查清单里,共地永远排第一条。
注意:如果电机和MCU用两套独立电池,也必须把两个系统的地连接到一起,否则PWM信号没有参考电平,驱动会误动作甚至完全没反应。
2.2 GPIO的八种工作模式到底怎么选
STM32的GPIO有八种工作模式,新手看到这个列表基本是懵的,我用一句话帮你区分:输入侧分浮空、上拉、下拉、模拟四种,输出侧分开漏、推挽、复用开漏、复用推挽四种。核心判断逻辑就三条:这个引脚是输入还是输出?需不需要内部上下拉?是不是复用给外设用了?
具体到这台车:连接电机驱动的方向控制引脚,用推挽输出(GPIO_Mode_Out_PP),因为它要输出稳定的高低电平来定方向,推挽能主动拉高拉低,驱动能力强;连接PWM的引脚,必须用复用推挽输出(GPIO_Mode_AF_PP),因为此时引脚归定时器管,不再受GPIO输出寄存器控制,配错了PWM就没波形;读按键或读限位开关的输入引脚,用上拉输入(GPIO_Mode_IPU),这样按键不按的时候默认是高电平,按下接地变低,省一个外部电阻;只有用到ADC采样电池电压这类模拟量时才用模拟输入(GPIO_Mode_AIN)。
| 引脚用途 | 推荐模式 | 原因 |
|---|---|---|
| 电机方向控制 | 推挽输出 | 需要稳定驱动高低电平 |
| PWM输出 | 复用推挽输出 | 引脚交给定时器外设 |
| 按键/限位输入 | 上拉输入 | 省外部电阻,默认电平明确 |
| ADC采样 | 模拟输入 | 关闭数字电路,避免干扰 |
很多人问开漏输出什么时候用,简单说,当你要和别的芯片共用一根信号线、或者需要电平转换(比如3.3V器件接5V总线)才用开漏配上拉。这台车用不上,但知道这个边界,以后接I2C器件(I2C本身就是开漏总线)就不会慌。
2.3 电机驱动模块的选型与坑
L298N和TB6612我都用过,给你一个实在的对比。L298N便宜、耐操、带5V输出能顺便给板子供电,缺点是压降大、发热猛、效率低,电机供电7.4V经过它可能只剩5V多,而且空载都能烫手。TB6612效率高、发热小、支持更高PWM频率、体积小,缺点是价格略贵、需要单独给逻辑供电。新手第一台车我推荐先用L298N,便宜大碗,坏了不心疼,等你要做更精密的控制再换TB6612。
L298N的接线逻辑要搞清楚:每个电机有两个控制引脚(IN1、IN2)和一个使能引脚(ENA或ENB)。方向由IN1/IN2的高低组合决定,速度由使能脚的PWM占空比决定。如果使能脚直接用跳帽接到高电平,电机就是全速转,不受PWM控制,这也是新手常见困惑——为什么我改了占空比电机速度不变,就是因为跳帽没拔。
3. 核心外设驱动实现:GPIO、定时器、PWM与UART
硬件接好之后进入软件部分。我建议用CubeMX先生成初始化框架,把引脚和时钟配好,再在生成的工程里填业务逻辑,这样能省下大量查寄存器的时间。但初始化背后的原理你还是得懂,不然出问题根本不知道该改哪。
3.1 定时器与PWM:频率和占空比是怎么算出来的
PWM本质是定时器在不停地数数,数到一个比较值就翻转电平。要理解它,你只要记住两个寄存器:ARR(自动重装载值)决定数到多少归零,也就是周期;CCR(捕获比较值)决定数到多少翻转,也就是占空比。PWM频率的公式是:频率 = 定时器时钟 / ((PSC+1) × (ARR+1)),其中PSC是预分频系数。
STM32F103的TIM3挂在APB1上,虽然APB1时钟是36MHz,但定时器时钟会被自动倍频到72MHz,这点很多人搞错。假设我要20kHz的PWM给电机调速,代入公式反推:72,000,000 / 20,000 = 3600,所以我让(PSC+1)×(ARR+1)=3600。取PSC=0、ARR=3599,占空比分辨率就是1/3600,已经足够精细;或者取PSC=71、ARR=49,同样得到20kHz。我一般直接用PSC=71、ARR=999,得到72M/(72×1000)=1kHz。1kHz人耳能听到轻微啸叫,如果介意就换成20kHz,超出听觉范围,电机就安静了。
为什么电机PWM频率不能太低也不能太高?太低(比如几十Hz)电机会有明显抖动和噪音,效率也差;太高则驱动芯片开关损耗大、发热增加,L298N实测超过20kHz就开始明显烫。所以5到20kHz是直流电机驱动的甜点区,你按这个范围调就行。
关键代码长这样(HAL库风格,标准库同理):
// TIM3 PWM初始化,20kHz,通道1-4 void Motor_PWM_Init(void) { // PSC=0, ARR=3599 -> 72M/(1*3600)=20kHz htim3.Instance = TIM3; htim3.Init.Prescaler = 0; htim3.Init.Period = 3599; htim3.Init.CounterMode = TIM_COUNTERMODE_UP; HAL_TIM_PWM_Init(&htim3); // 四个通道都配成PWM1模式 TIM_OC_InitTypeDef oc; oc.OCMode = TIM_OCMODE_PWM1; oc.Pulse = 0; // 初始占空比为0,电机不转 oc.OCPolarity = TIM_OCPOLARITY_HIGH; HAL_TIM_PWM_ConfigChannel(&htim3, &oc, TIM_CHANNEL_1); HAL_TIM_PWM_ConfigChannel(&htim3, &oc, TIM_CHANNEL_2); HAL_TIM_PWM_ConfigChannel(&htim3, &oc, TIM_CHANNEL_3); HAL_TIM_PWM_ConfigChannel(&htim3, &oc, TIM_CHANNEL_4); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_3); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_4); }改速度就是用__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, duty),duty从0到3599对应0到100%占空比。
这里埋着一个经典大坑:如果你用的是TIM1这个高级定时器输出PWM,光配好通道是不够的,还必须调用__HAL_TIM_MOE_ENABLE(&htim1)开启主输出使能,否则引脚一点波形都没有。我当年查这个问题查了一晚上,以为是硬件接触不良,结果就是少这一句。所以新手尽量先用TIM2/3/4这种通用定时器,避开高级定时器的额外配置。
3.2 UART串口:给车子装上一双耳朵
UART是这台车和手机通信的通道。串口的配置就四个参数要对上:波特率、数据位、停止位、校验位。蓝牙模块默认一般是9600波特率、8位数据、1位停止、无校验,你单片机和它必须完全一致,差一点收到的就是乱码。STM32F103C8T6有3个USART,我习惯用USART1,引脚是PA9(TX)和PA10(RX)。
接线的时候记住一个原则:TX接RX,RX接TX,交叉连接。单片机的PA9接蓝牙的RX,单片机的PA10接蓝牙的TX。有人图省事想接成同名对同名,那肯定通不了,因为两边都是发送端对着发送端,谁也收不到。
接收蓝牙数据我推荐用中断方式,而不是在main里死循环轮询。原因很简单,遥控车需要实时响应,如果主循环里有延时或者在处理别的任务,轮询就会漏掉数据。用中断接收,每来一个字节就进一次中断把它存下来,主循环只管取。中断回调的核心逻辑:
uint8_t rx_data; // 接收缓冲 volatile uint8_t cmd = 0; // 最新指令 // 在初始化后开启中断接收 HAL_UART_Receive_IT(&huart1, &rx_data, 1); // 每收到一个字节触发一次 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { cmd = rx_data; // 保存指令 // 重新挂起接收,等待下一个字节 HAL_UART_Receive_IT(&huart1, &rx_data, 1); } }那个在回调里重新调用HAL_UART_Receive_IT的动作特别关键,不重新挂起的话只能收到一个字节,之后就没反应了。这也是新手高频问题之一。
3.3 指令协议设计:越简单越不容易崩
第一版遥控车,指令协议千万别搞复杂。我直接用单字符指令:前进发'F',后退发'B',左转发'L',右转发'R',停止发'S'。手机端装个蓝牙串口助手APP,设置几个按钮对应这几个字符就行。每个字符用一个字节传,没有帧头帧尾,也没有校验,简单粗暴但足够可靠,因为蓝牙透传本身有链路层纠错,短距离下丢包率极低。
如果你非要加可靠性,那也应该是在这一版跑通之后再加。我见过新手一上来就设计多字节协议、加CRC校验、加心跳包,结果协议逻辑本身的bug比要解决的问题还多,最后卡在协议解析上动不了。先把车跑起来,协议优化是第二版的事。
4. 状态机设计:让遥控车的行为可预测
一堆if-else也能让车动起来,但车一复杂,代码就会烂成一团。状态机是这台车真正值得学的部分,它把车的每一种行为抽象成一个状态,用指令去触发状态之间的转移,逻辑一下子就清晰了。
4.1 为什么用状态机而不是一堆if-else
想象一下你要处理前进、后退、左转、右转、停止,还要处理急停、速度档位切换。用if-else写,判断会层层嵌套,改一个功能可能牵动十几个地方,过两天自己都看不懂。状态机的思路是把注意力从"怎么判断"转移到"现在处于什么状态"和"什么条件让它切到下一个状态"。车的每一种行为都是独立的状态,互不干扰,加一个新状态不影响老状态,可维护性完全不是一个量级。
这个思路在工业控制和嵌入式里非常常见,按键扫描用它防抖、通信协议解析用它认帧头,都是同一套逻辑。你把这台车的状态机写明白了,以后遇到类似问题脑子里会自动冒出状态图。
4.2 状态定义与转移表
我给这台车定义了六个状态:IDLE(待机)、FORWARD(前进)、BACKWARD(后退)、LEFT(左转)、RIGHT(右转)、STOP(刹车)。转移条件就是蓝牙收到的那个指令字符。设计状态机时,我习惯先画一张转移表,把"当前状态 + 输入 = 下一个状态"写清楚,之后再对着表写代码,基本不会漏。
| 当前状态 | 收到'F' | 收到'B' | 收到'L' | 收到'R' | 收到'S' |
|---|---|---|---|---|---|
| IDLE | FORWARD | BACKWARD | LEFT | RIGHT | IDLE |
| FORWARD | FORWARD | BACKWARD | LEFT | RIGHT | STOP |
| BACKWARD | FORWARD | BACKWARD | LEFT | RIGHT | STOP |
| LEFT | FORWARD | BACKWARD | LEFT | RIGHT | STOP |
| RIGHT | FORWARD | BACKWARD | LEFT | RIGHT | STOP |
| STOP | FORWARD | BACKWARD | LEFT | RIGHT | STOP |
这张表一眼就能看出逻辑是否完整,也能看出有没有遗漏的组合。比如你会发现任何状态下收到'S'都进STOP,这就是一种安全设计——刹车优先级最高,随时能停。
4.3 代码落地与主循环结构
状态机的代码通常写成两层:一层是状态转移函数,负责根据输入更新当前状态;另一层是状态执行函数,负责根据当前状态去设置电机方向和PWM占空比。这么分开的好处是,转移逻辑和执行逻辑互不缠绕,改调速只动执行层,改响应规则只动转移层。
typedef enum { ST_IDLE, ST_FORWARD, ST_BACKWARD, ST_LEFT, ST_RIGHT, ST_STOP } CarState; CarState state = ST_IDLE; // 第一层:根据指令做状态转移 void State_Transition(uint8_t cmd) { switch (cmd) { case 'F': state = ST_FORWARD; break; case 'B': state = ST_BACKWARD; break; case 'L': state = ST_LEFT; break; case 'R': state = ST_RIGHT; break; case 'S': state = ST_STOP; break; default: break; // 未知指令忽略,不影响当前状态 } } // 第二层:根据状态执行动作 void State_Execute(void) { switch (state) { case ST_FORWARD: Motor_SetDir(LEFT_MOTOR, 1); Motor_SetDir(RIGHT_MOTOR, 1); Motor_SetSpeed(LEFT_MOTOR, 3000); // 占空比约83% Motor_SetSpeed(RIGHT_MOTOR, 3000); break; case ST_BACKWARD: Motor_SetDir(LEFT_MOTOR, 0); Motor_SetDir(RIGHT_MOTOR, 0); Motor_SetSpeed(LEFT_MOTOR, 2500); // 后退稍慢,更稳 Motor_SetSpeed(RIGHT_MOTOR, 2500); break; case ST_LEFT: // 差速转向:左轮慢,右轮快 Motor_SetDir(LEFT_MOTOR, 1); Motor_SetDir(RIGHT_MOTOR, 1); Motor_SetSpeed(LEFT_MOTOR, 1000); Motor_SetSpeed(RIGHT_MOTOR, 3500); break; case ST_RIGHT: Motor_SetDir(LEFT_MOTOR, 1); Motor_SetDir(RIGHT_MOTOR, 1); Motor_SetSpeed(LEFT_MOTOR, 3500); Motor_SetSpeed(RIGHT_MOTOR, 1000); break; case ST_STOP: Motor_SetSpeed(LEFT_MOTOR, 0); Motor_SetSpeed(RIGHT_MOTOR, 0); break; case ST_IDLE: default: Motor_SetSpeed(LEFT_MOTOR, 0); Motor_SetSpeed(RIGHT_MOTOR, 0); break; } }主循环就变得极其干净:判断有没有新指令,有就做转移,然后无条件执行一次当前状态的动作。
while (1) { if (cmd != 0) { State_Transition(cmd); cmd = 0; // 消费掉指令 } State_Execute(); HAL_Delay(10); // 10ms一个控制周期 }这里能看到状态机的一个隐含好处:就算没有新指令,车也会持续执行当前状态,比如前进时你松手不发指令,车会一直前进,直到你发'S'。这符合直觉。如果你希望松手就停,那就在转移逻辑里加一个超时判断,比如超过500ms没收到指令就自动切到STOP,这就是状态机方便扩展的地方。
5. 联调实录与常见问题排查
理论和代码都齐了,真正把车跑起来的过程才是长经验的时候。下面这些坑,全是我自己踩过或者身边人踩过的高频问题,整理成表方便你对照排查。
5.1 高频问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 电机完全不转 | 驱动地和MCU地没连 | 万用表量两端是否等电位 |
| 电机全速转不受控 | 使能脚跳帽没拔,或PWM引脚配错模式 | 检查AF_PP复用配置 |
| PWM没波形 | 用了TIM1忘了开MOE,或引脚复用没开 | 补MOE使能,查时钟使能 |
| 蓝牙收不到数据 | 波特率不匹配、TX/RX没交叉、没重新挂起接收中断 | 逐项核对 |
| 收到数据是乱码 | 波特率或时钟配置错误 | 确认系统时钟和波特率 |
| 车一动就复位 | 电机电流拉低供电压,MCU欠压复位 | 电机和MCU分开供电、加大电容 |
| 左右转方向反了 | 电机接线或方向逻辑反 | 交换电机两根线或改逻辑 |
| 串口偶尔丢数据 | 轮询方式或中断里处理太慢 | 改用中断接收,缩短回调耗时 |
5.2 几个从实践中抠出来的避坑心得
第一个心得关于供电。电机启动瞬间电流会飙到堵转电流,可能把共用的电源拉低,导致MCU瞬间欠压复位,表现就是"车一动就重启"。解决办法是在电机供电两端并一个大电解电容(几百微法),给突变电流提供缓冲,同时尽量让MCU那一路走稳压后供电。
第二个心得关于调试顺序。蓝牙一边连着手机一边连着板子调试很麻烦,因为串口被占用了,你没法同时用电脑看打印。我的做法是先在电脑上用串口助手模拟手机,把指令发过去验证整车逻辑,确认没问题再把蓝牙接上,这样调试效率高一个数量级。
第三个心得关于PWM和方向的配合。有些驱动在电机旋转过程中直接切换方向(比如前进中突然发后退),会有冲击电流,长期这么搞容易伤驱动。更好的做法是建议程序里加一个短暂停止再反向,哪怕只停50毫秒,过渡会平顺很多。这个细节文档里很少写,但实测真的有用。
第四个心得是给状态机留退路。我最早写的时候没处理未知指令,手机APP万一发个别的字符进来,状态判断缺省分支没写,行为就不可预期。后来我给每个switch都补上default分支,未知指令一律忽略,车保持当前状态,稳得多。这种防御性写法在嵌入式里应该成为肌肉记忆。
5.3 把车跑稳之后还能怎么继续折腾
第一台车能按指令跑起来,说明GPIO、PWM、UART、定时器、状态机这五样你已经串起来了,这就是嵌入式入门最硬的一块骨头。接下来你可以往几个方向延伸:加编码器测速做闭环,把差速转向做成精确的弧线;加超声波或红外避障,让状态机多一个自动避障状态;把状态机升级成带优先级和超时处理的更完整版本,甚至后面上RTOS学任务调度。这些都是在同一套底层上做加法,底子打得越扎实,后面学得越快。
我个人在带新人时最看重的一点,就是你能不能把"为什么这么设计"讲清楚,而不只是"照抄能跑"。这台车的每个参数、每个模式选择、每条状态转移,背后都有它的理由,你能把这些讲明白,就已经超过大多数只会抄例程的人了。