资讯动态

STM32F103C8T6蓝牙遥控车:GPIO/PWM/UART/状态机实战

发布时间:2026/9/18 18:08:48 来源:尧图企业网站定制
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 或 DAPLink1烧录和调试SWD接口电机驱动L298N 或 TB6612FNG1双路H桥驱动两个直流电机蓝牙模块HC-05 或 HC-061与手机通信串口透传直流电机减速电机 3-6V2带轮子车体亚克力底盘轮子1套两轮差速结构电池2S锂电7.4V 或 4节AA 6V1给电机和板子供电杜邦线母对母、公对母若干连接用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频率的公式是频率 定时器时钟 / ((PSC1) × (ARR1))其中PSC是预分频系数。STM32F103的TIM3挂在APB1上虽然APB1时钟是36MHz但定时器时钟会被自动倍频到72MHz这点很多人搞错。假设我要20kHz的PWM给电机调速代入公式反推72,000,000 / 20,000 3600所以我让(PSC1)×(ARR1)3600。取PSC0、ARR3599占空比分辨率就是1/3600已经足够精细或者取PSC71、ARR49同样得到20kHz。我一般直接用PSC71、ARR999得到72M/(72×1000)1kHz。1kHz人耳能听到轻微啸叫如果介意就换成20kHz超出听觉范围电机就安静了。为什么电机PWM频率不能太低也不能太高太低比如几十Hz电机会有明显抖动和噪音效率也差太高则驱动芯片开关损耗大、发热增加L298N实测超过20kHz就开始明显烫。所以5到20kHz是直流电机驱动的甜点区你按这个范围调就行。关键代码长这样HAL库风格标准库同理// TIM3 PWM初始化20kHz通道1-4 void Motor_PWM_Init(void) { // PSC0, ARR3599 - 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引脚是PA9TX和PA10RX。接线的时候记住一个原则TX接RXRX接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收到SIDLEFORWARDBACKWARDLEFTRIGHTIDLEFORWARDFORWARDBACKWARDLEFTRIGHTSTOPBACKWARDFORWARDBACKWARDLEFTRIGHTSTOPLEFTFORWARDBACKWARDLEFTRIGHTSTOPRIGHTFORWARDBACKWARDLEFTRIGHTSTOPSTOPFORWARDBACKWARDLEFTRIGHTSTOP这张表一眼就能看出逻辑是否完整也能看出有没有遗漏的组合。比如你会发现任何状态下收到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学任务调度。这些都是在同一套底层上做加法底子打得越扎实后面学得越快。我个人在带新人时最看重的一点就是你能不能把为什么这么设计讲清楚而不只是照抄能跑。这台车的每个参数、每个模式选择、每条状态转移背后都有它的理由你能把这些讲明白就已经超过大多数只会抄例程的人了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价