ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

STM32F103C8T6蓝牙遥控车:GPIO、PWM、UART与状态机实战 先说个真实的场景。很多人第一次拿到 STM32F103C8T6 最小系统板跟着教程点完 LED、跑完串口打印Hello然后就把板子放回盒子里了。原因不复杂——点灯和打印串口跟让一个东西动起来之间隔着一道很宽的沟要懂 GPIO 的八种工作模式怎么选要会算 TIM 定时器分频出 PWM要处理 UART 和蓝牙模块的对接还要把这些东西用状态机组织成一套能跑的逻辑。把这些串起来做一台蓝牙遥控车其实就是把 STM32 入门阶段几乎所有核心外设一次性用一遍。这篇内容围绕 STM32F103C8T6、GPIO、PWM、UART、状态机这几个关键词把一台手机能控制的遥控车从硬件选型、引脚分配到代码落地完整走一遍适合刚学完点灯、想找一个能拿得出手的完整项目练手的同学也适合电子类专业做课程设计、准备电赛入门题目的朋友。1. 先把这台遥控车的信号链路画清楚1.1 从手机到轮胎一条指令的完整旅程在动手焊线之前我习惯先把整条链路在纸上走一遍。遥控车这个东西看起来就是手机点一下、轮子转一下但拆开看一条指令其实要穿过好几层。手机上的蓝牙调试 App 按下前进这个动作被编码成一个或几个字节通过蓝牙射频发出去车上的蓝牙模块常见的是 HC-05、HC-06 这类串口透传模块收到之后不做任何加工直接从它的 TXD 引脚把字节吐出来电平是 3.3V 的 TTL 电平STM32 的 USART 外设接收到这一串电平变化在停止位到来时把字节存进数据寄存器并触发中断中断服务函数把这个字节丢进一个环形缓冲区然后立刻返回主循环从缓冲区里取出完整的一帧解析成一个动作枚举值状态机根据当前状态和这个动作决定左右两个轮子各自应该输出多少占空比的 PWM最后 TIM 定时器的比较寄存器被改写PA6、PA7 上的方波占空比发生变化电机驱动芯片把 3.3V 的逻辑信号放大成能驱动电机的电流轮子转起来。这条链路里任何一环断掉现象都是车不动。而且不同环节断掉排查手法完全不一样。所以我一直强调做这类项目不要一上来就写代码先把这个链路默写一遍你要能指出每个环节的输入是什么、输出是什么、用什么工具去验证。比如蓝牙模块收到数据但 MCU 没反应你就知道要拿示波器或者 USB 转串口模块去量模块的 TXD 引脚如果是 MCU 收到了数据但轮子不转那问题就在 PWM 或者电机驱动那一层。有了这条链路的认知后面遇到问题时你的排查方向会非常明确而不是盲目换线、换模块。1.2 STM32F103C8T6 的资源账本与选它的理由为什么是 STM32F103C8T6 而不是别的先看它的家底Cortex-M3 内核最高主频 72MHz64KB Flash20KB SRAMLQFP48 封装常见的最小系统板会把它所有可用的引脚都引出来。外设方面它有 3 个通用同步异步收发器USART1/2/3其中 USART1 挂 APB2另两个挂 APB1、2 个 SPI、2 个 I2C、1 个 CAN以及 4 个定时器——TIM1 是高级定时器TIM2、TIM3、TIM4 是通用定时器每个都能输出 4 路 PWM。对一台遥控车来说这个配置是刚好够用还留有余量。我们至少需要 2 路 PWM 来调速左右轮各一路如果前轮要加舵机转向那就是 3 路需要 1 个 USART 接蓝牙最好再留 1 个 USART 用于调试打印需要 4 个普通 GPIO 控制两个电机的正反转方向再加一个板载 LED 做状态指示。算下来大概占用 12 到 14 个引脚STM32F103C8T6 完全吃得下还有一堆引脚空着以后扩展用。还有几个很现实的理由。第一资料足够多ST 官方的参考手册、数据手册加上各种中文社区的资料遇到问题几乎都能搜到答案。第二价格低国产替代版本比如同封装同外设的兼容型号已经把这块板子压到很便宜的位置做实验烧坏了也不心疼。第三工具链成熟Keil MDK 和 IAR 都能用下载器用 ST-Link 或者 DAP-Link 都可以DAP-Link 性价比更高还支持 SWD 调试和串口输出二合一。第四它的引脚复用好理解TIM3 的四个通道固定映射在 PA6、PA7、PB0、PB1 上TIM2 在 PA0 到 PA3TIM4 在 PB6 到 PB9做引脚规划的时候基本不用查太多表。需要提醒一点最小系统板上通常会有一块 AMS1117-3.3 的稳压芯片它的输出能力有限别用它去给电机供电。它只负责给 MCU 和蓝牙模块这类小电流负载供电电机电流必须走独立的电池回路。2. 硬件层的取舍电机驱动、供电与引脚分配2.1 电机驱动模块选型L298N、TB6612 与 DRV8833 的差异MCU 的 GPIO 输出电流只有几十毫安直接接电机是不可能的中间必须有一个驱动。常见的三类模块我按实际用下来的感受排一下。L298N 是最经典的双 H 桥能同时驱动两个直流电机逻辑电平和电机电源分开接线一目了然。它的短板是内部是双极性晶体管结构导通压降大典型在 1.4V 到 2V 之间。这意味着如果电池是 7.4V电机实际拿到的可能只有 5.5V 左右而且这个压降会变成热量散掉模块会明显发烫大电流下甚至需要加散热片。另外它标注的 5V 输出可以给 MCU 供电但电流能力有限带蓝牙模块加 MCU 有点勉强。TB6612FNG 是我现在更推荐的选择。它用的是 MOS 管 H 桥导通电阻只有零点几欧压降小、效率高、发热也小得多。它的控制逻辑比 L298N 还简单每个电机三根线——一个 PWM 调速引脚加两个方向引脚。PWM 给高电平的时候方向由两个逻辑引脚决定PWM 给低电平的时候电机是刹车还是自由滑行取决于这两个引脚的状态组合。TB6612 还支持 10kHz 以上的 PWM 频率这样可以避开人耳能听到的电机啸叫。DRV8833 更小更省空间适合做小型化的车但它的持续电流能力比前两者弱一些带 130 电机还行带 370 电机就有点吃力了。综合下来入门做第一台车TB6612 是性价比和体验的平衡点如果手头只有 L298N也完全能跑起来只是记得给它留散热空间别用手长时间摸。2.2 供电架构与共地遥控车烧板子的高发区供电这一块我见过太多翻车案例必须单独说。推荐架构是这样的动力电池2 节 18650 串成 7.4V或者 4 节 AA 电池盒出来后分成两条路。一条路进电机驱动模块的电机电源输入端VM专门给电机用因为电机启动瞬间电流可能是额定值的数倍这条线上电压波动很大。另一条路经过一个降压模块比如 LM2596 或者 MP1584 这类可调降压降到 5V 或直接 3.3V给 MCU 和蓝牙模块供电。关键点在于降压模块的输入要尽量靠近电池或者至少和电机电源线分开走避免电机电流波动把 MCU 拉复位。然后是共地。所有模块的地必须连在一起——电池负极、电机驱动的地、降压模块的地、MCU 的地、蓝牙模块的地全部接到同一个节点上。这不是建议是硬性要求。很多人接完线发现电机转一下 MCU 就复位第一反应是电源不够其实往往是因为电机驱动的地和 MCU 的地之间隔了一段细线电机电流在这段线上产生了压降导致两块板的地参考电位不一样逻辑信号就乱了。解决办法就是单独拉一根粗一点的线把所有地接到一个点上。还有一个细节TB6612 的待机引脚 STBY 必须拉高否则芯片处于待机状态无论你怎么给 PWM 它都不输出。这个坑我第一次用的时候踩过——代码反复检查都没问题最后发现是 STBY 悬空了。把 STBY 接到 MCU 的一个 GPIO还能顺便实现软件急停。2.3 一份能直接照抄的引脚分配表下面这份分配表是我实际用下来觉得比较顺手的方案兼顾了 PWM 通道的硬件映射和布线方便。功能引脚外设/模式说明左电机 PWMPA6TIM3_CH1复用推挽10kHz 调速右电机 PWMPA7TIM3_CH2复用推挽10kHz 调速左电机方向1PB0普通推挽输出与 PWMA 配合定方向左电机方向2PB1普通推挽输出与 PWMA 配合定方向右电机方向1PB10普通推挽输出与 PWMB 配合定方向右电机方向2PB11普通推挽输出与 PWMB 配合定方向蓝牙 TXDPA10USART1_RX浮空/上拉输入接收模块发来的数据蓝牙 RXDPA9USART1_TX复用推挽发给模块的数据驱动使能 STBYPB12普通推挽输出高电平使能低电平急停状态指示 LEDPC13普通推挽输出最小系统板板载灯低电平点亮这个分配的好处是两个电机的 PWM 都落在 TIM3 上时基和中断只需要配置一份代码干净方向脚集中在 PB 口初始化的时候可以一次性配好USART1 是挂载在 APB2 上的波特率容错性比其他两个串口好一点配 115200 时误差更小。3. GPIO 八种工作模式什么时候该用哪一种3.1 从寄存器角度看推挽、开漏、上下拉STM32 的每个 GPIO 引脚都由一个 32 位的配置寄存器组来管理CRL 管低 8 位CRH 管高 8 位每个引脚占 4 个位其中 2 位是模式、2 位是配置。所谓八种模式就是这两组位的排列组合。不用死记记住它们的物理含义就行。输入方向有四种浮空输入、上拉输入、下拉输入、模拟输入。浮空输入就是引脚内部什么也不接电平完全由外部决定适合那些外部已经有确定驱动源的场景比如接一个外部已经带上下拉的信号。上拉和下拉输入是内部通过一个几十千欧的电阻把引脚拉到高电平或低电平适合接按键——按键另一端接地平时靠内部上拉保持高电平按下变成低电平省掉外部电阻。模拟输入则是把引脚的施密特触发器关掉让信号直接进 ADC这是 ADC 采样必配的模式配错了采出来的值就是乱跳的。输出方向也有四种开漏输出、推挽输出、开漏复用、推挽复用。推挽这个名字很形象内部有两个管子轮流导通一个负责拉高、一个负责拉低所以它能主动输出高电平和低电平驱动能力较强多用在普通 GPIO 输出和复用输出上。开漏则是只有下拉的管子它只能把引脚拉低要输出高电平必须靠外部上拉电阻。开漏的价值在于实现线与——两个开漏输出接在一起任何一个拉低整条线都变低这是 I2C 总线必须用开漏的原因。至于复用和普通的区别在于该引脚的控制权是给了片上外设比如 TIM 的 PWM 输出、USART 的 TX还是给了 GPIO 的数据寄存器。你要输出 PWM就必须配成复用推挽配成普通推挽的话TIM 的信号根本到不了引脚上。3.2 遥控车上的引脚模式逐条对照回到我们的车把上面的表逐条对应成模式的理由。PA6、PA7 输出 PWM配复用推挽速度选 50MHz。为什么是复用因为引脚的控制权在 TIM3 手里。为什么是推挽因为要让引脚能主动输出高低电平去驱动 TB6612 的逻辑输入。PB0、PB1、PB10、PB11 做方向控制配普通推挽输出速度选 2MHz 就够了。这些脚只是输出静态的高低电平不参与高速翻转低速档还能减小 EM 干扰。PA10 接蓝牙模块的 TXD配浮空输入或者上拉输入都行。这里有个实践细节如果蓝牙模块在没供电的时候挂在这个引脚上模块的输出脚相当于高阻浮空输入下这个引脚电平会飘可能被误读成一堆乱码数据。所以我一般配成上拉输入让它有个确定的默认状态。PA9 接模块的 RXD配复用推挽。注意这时候 PA9 的控制权交给了 USART1你再用 GPIO_SetBits 去操作它是无效的。PC13 驱动板载 LED 配普通推挽输出。注意 PC13 这个脚在 STM32F103 上有个限制——它是备份域相关的引脚驱动能力比其他脚弱一般只用来点小电流的 LED不适合驱动大负载也不要指望它能输出很快速的信号。一个容易被忽略的点GPIO 初始化之前一定要先开时钟。GPIOA 和 GPIOB 挂 APB2PC 口也是 APB2TIM3 挂 APB1USART1 挂 APB2AFIO 挂 APB2。漏开任何一个时钟对应的寄存器写进去都是无效的现象是代码完全正确但引脚毫无反应。这个坑几乎每个人都踩过一次。4. TIM 与 PWM轮子转速是怎么被算出来的4.1 时钟树到 PWM 频率的完整推导要把 PWM 算清楚必须先把时钟链路捋顺。STM32F103C8T6 用外部 8MHz 晶振作为 HSE经过 PLL 倍频 9 倍得到 72MHz 的系统时钟 SYSCLK。AHB 预分频器不分频HCLK 就是 72MHz。关键在于 APB 的预分频规则APB1 被配置为 2 分频所以 PCLK1 是 36MHzAPB2 不分频PCLK2 是 72MHz。但是有一个特殊规定——当 APB 预分频系数不为 1 时挂在它上面的定时器时钟会再乘以 2。所以 TIM2、TIM3、TIM4 虽然挂在 APB1 上它们的实际计数时钟是 36MHz 乘以 2也就是 72MHz。有了 72MHz 的定时器时钟PWM 频率的计算就是两步分频。第一步是预分频器 PSC计数时钟被除以PSC1。第二步是自动重装载寄存器 ARR计数器从 0 数到 ARR 为一个周期所以周期是ARR1个计数。合起来PWM 频率 72 000 000 / ((PSC 1) × (ARR 1))举个实际例子。我想给电机 10kHz 的 PWM那就在 PSC 和 ARR 之间找一组整数解。取 PSC 0也就是不分频那么 ARR 72 000 000 / 10 000 − 1 7199。计数器每 1/72 微秒加一数到 7199 溢出一次周期 100 微秒频率 10kHz同时 ARR 取值大意味着占空比的分辨率有 7200 档调速足够细腻。如果想给舵机 50Hz 的信号那就取 PSC 71把计数时钟降到 1MHz一个计数就是 1 微秒ARR 20 000 − 1 19999周期 20 毫秒正好是舵机标准周期。这时候 CCR 直接就是高电平的微秒数CCR 1500 就是 1.5ms 中位代码写起来非常直观。用途PSCARRPWM 频率计数分辨率电机调速0719910 kHz微秒级7200 档舵机711999950 Hz1 微秒20000 档低速电机低噪719991 kHz1 微秒1000 档4.2 占空比、转速与死区把参数调到不飞车占空比的设置方式很直接在 PWM1 模式下CCR 的值决定高电平持续的计数个数。ARR 7199 时CCR 从 0 到 7199 对应占空比从 0% 到 100%。但实际写代码的时候我不会让 CCR 直接取满。原因是电机在极低占空比下根本转不动有静摩擦需要克服启动扭矩在接近 100% 时又容易因为电流过大触发驱动芯片的保护。所以我会把有效调速区间映射到 0 到 7000 之间并且在状态机里预留一个启动补偿——起步瞬间给一个略高于静摩擦阈值的占空比转起来之后再回到用户设定的值。电机飞车这个词听起来吓人其实很多时候不是电机的问题是控制逻辑的问题。典型场景你在一个状态切换里把方向脚和 PWM 同时改了但改的过程中出现了一个短暂的状态——方向脚已经是正转而 PWM 还残留着上一状态的高占空比如果没有经过停止这一中间步骤H 桥就被要求瞬间反向电流冲击很大。正确做法是所有涉及方向的切换都必须先让 PWM 降到 0延时几毫秒改方向脚再缓慢提升 PWM。这几毫秒的延时换的是驱动芯片的寿命。还有一点是 PWM 频率和电机类型的关系。空心杯电机和小型减速电机1kHz 到 5kHz 就够频率太高反而因为开关损耗和电机电感导致力矩下降。普通 130、370 减速电机用 8kHz 到 16kHz 比较合适能避开听觉敏感频段减少啸叫。这里还有个现象值得注意有些电机在特定频率下会产生明显的共振嗡鸣这时候把频率稍微挪开一点比如从 10kHz 改成 12kHz就能解决不需要改电路。4.3 顺带把手上的舵机也接上如果想让车能转向而不是靠左右轮差速转圈可以加一个 SG90 这类小舵机做前轮转向。舵机的控制信号是 50Hz 的 PWM高电平宽度 0.5ms 到 2.5ms 对应 0 到 180 度中位 1.5ms。用前面算好的 TIM2PSC 71ARR 19999把 CCR 设置在 500 到 2500 之间就能直接控制角度。实际装配的时候机械结构要保证舵机在中位时前轮是正的否则你所有的角度控制都会有一个固定偏差。需要注意的是舵机和电机最好不要共用同一路电源。舵机在堵转瞬间电流能冲到 1A 以上会拉低整条 5V 轨让 MCU 复位。稳妥的做法是给舵机单独一路降压或者在舵机电源入口并一个几百微法的大电容做缓冲。5. UART 接蓝牙指令进 MCU 的正确姿势5.1 初始化参数与第一个乱码是怎么来的USART 初始化的几个参数——波特率、数据位 8、停止位 1、无校验也就是常说的 8N1。波特率我们选 115200理由是它足够快能承载更高频率的控制指令同时 USART1 挂在 72MHz 的 APB2 上115200 的分频误差很小。第一个乱码问题几乎所有人都遇到过。乱码的根源通常是三种。第一种是两边波特率不一致比如代码写 9600 而蓝牙模块默认是 9600 但你手机 App 设成了 115200这是最常见的原因。第二种是晶振配置错了比如你的板子实际焊的是 8MHz 晶振但代码里配成了 12MHz 的 HSE那么系统时钟就不是 72MHz所有串口波特率都会整体偏移误差大到一定程度数据就全乱了。第三种是电平不匹配如果你的蓝牙模块或者 USB 转串口模块输出的是 5V 电平而 STM32 的引脚是 3.3V 容忍的短时间可能能通信但长时间会损坏引脚甚至芯片。排查方法很机械先用 USB 转串口模块CH340 这类很常见单独接蓝牙模块用电脑串口助手直接给它发 AT 指令确认模块本身没问题、波特率是多少然后再把模块接到 MCU 上用 MCU 发固定字符串比如每秒钟发一次 test\r\n看串口助手上是不是稳定的、周期性的字符串。这个一分为二的排查法能快速定位问题在模块侧还是在 MCU 侧。5.2 中断 环形缓冲区别在中断里解析协议串口接收要不要用中断对于遥控车来说答案是必须用。如果你在 while 循环里轮询 USART 的接收标志位那么主循环每转一圈才检查一次一旦主循环里某个动作耗时比较长比如状态机里有延时就会丢字节。丢字节的表现是偶尔按了没反应或者指令错乱。用中断接收但中断服务函数里只做一件事把收到的字节塞进环形缓冲区然后退出。千万不要在中断里解析协议、更不要在中断里做电机控制因为串口中断的频率可能很高中断里耗时过长会导致主循环饿死而且如果在中断里调用了耗时函数下一个字节可能已经来了还没被取走又会丢数据。这是个原则问题中断只负责搬运解析和处理都交给主循环。环形缓冲区本身也不复杂就是一个固定长度的数组加两个索引——写索引在中断里增加读索引在主循环里增加两者相等表示空。数组长度取 2 的幂比如 128这样索引用位与运算就能回绕省掉取模操作。要判满的话要么牺牲一个字节的空间要么额外加一个计数变量。我一般用 128 字节足够了因为遥控指令一帧也就几个字节128 字节能缓下几十帧足够主循环从容处理。5.3 自定义一版够用的遥控协议协议不用搞复杂够用就行。我用的是一套单字节命令加可选参数的简单协议。命令字节含义参数F前进无B后退无L原地左转无R原地右转无S停止滑行无P急停刹车无1 到 9设置速度档位无数字本身即档位换行符帧结束标记无主循环从缓冲区里按行读取遇到换行符就认为一帧结束然后把帧里的第一个有效字节映射成动作枚举。速度档位用一个静态变量存着后续所有运动命令都用这个速度。这套协议的好处是手机端 App 随便用哪种按键发送字符串的模式都能对接不用额外开发 App——很多蓝牙串口调试 App 都支持自定义按键和发送内容。如果后续要扩展比如读取电池电压、上报当前状态可以在协议里加一个方向标志用不同的前缀区分上行和下行的用途但第一版不需要别过度设计。6. 状态机把车该干什么从 if-else 里救出来6.1 为什么一堆 if-else 最后一定会失控刚上手的时候控制逻辑通常是这样写的如果收到 F就把两个方向的引脚设成正转两个 PWM 设成速度值如果收到 S就把 PWM 设成 0。一开始没问题但很快你会发现要处理的情况越来越多收到 F 的时候如果当前正在后退需要先经过停止收到 P 的时候需要立刻刹车并且锁住一段时间如果超过一段时间没收到任何指令要自动停车防止失控原地左转的时候左右轮是反向转的跟前进时的方向设置完全不同。用 if-else 堆下去代码会变成一层套一层而且状态之间的耦合越来越重。改了左转的逻辑可能影响到后退加一个急停保护结果在某个状态分支里被漏掉了。这就是典型的意大利面条代码。状态机的价值在于它强制你把车有哪几种状态和在每种状态下收到某个命令该做什么这两件事分开定义前者是一个有限的枚举后者是一张二维表。一旦用表来描述逻辑就变得可以逐格检查不会漏。6.2 状态划分与状态转移表我把车抽象成六个状态停止、前进、后退、左转、右转、刹车。这里把停止和刹车分开是有讲究的。停止是滑行PWM 输出 0电机两端都悬空车靠惯性溜一段刹车是让 H 桥把电机两端短接电机绕组里产生反向电动势形成阻力能在很短距离内停下。实际使用中正常停下用滑行紧急情况用刹车手感差别很明显。转移表描述的是当前状态 收到命令 新状态。举一部分来说明。当前状态收到 F收到 B收到 L收到 R收到 S收到 P停止前进后退左转右转停止刹车前进保持先停后退左转右转停止刹车后退先停前进保持左转右转停止刹车左转前进后退保持右转停止刹车刹车前进后退左转右转停止保持注意表格里先停后退这一类格子它表示需要插入一个中间步骤。实际实现的时候不是真的分两次状态切换而是在执行动作前先做一次 PWM 归零加短延时这是对硬件的保护。6.3 表驱动实现与超时保护用 C 语言落地的时候最清晰的方式是每个状态一个执行函数再用一张函数指针数组把它们串起来。这样做的好处是新增状态只需要加一个函数和一个枚举值不用改动已有的逻辑。typedef enum { CAR_STOP 0, CAR_FORWARD, CAR_BACKWARD, CAR_LEFT, CAR_RIGHT, CAR_BRAKE, CAR_STATE_MAX } CarState_t; typedef enum { CMD_FORWARD 0, CMD_BACKWARD, CMD_LEFT, CMD_RIGHT, CMD_STOP, CMD_BRAKE, CMD_MAX } CarCmd_t;方向控制的基础函数先封好static void SetMotorDir(int16_t left, int16_t right) { /* left / right 取 -1 / 0 / 1 */ GPIO_WriteBit(GPIOB, GPIO_Pin_0, (left 0) ? Bit_SET : Bit_RESET); GPIO_WriteBit(GPIOB, GPIO_Pin_1, (left 0) ? Bit_SET : Bit_RESET); GPIO_WriteBit(GPIOB, GPIO_Pin_10, (right 0) ? Bit_SET : Bit_RESET); GPIO_WriteBit(GPIOB, GPIO_Pin_11, (right 0) ? Bit_SET : Bit_RESET); } /* 真正的PWM输出speed范围 0 ~ 7000 */ static void SetMotorPwm(uint16_t l, uint16_t r) { TIM_SetCompare1(TIM3, l); TIM_SetCompare2(TIM3, r); }换向的时候先把 PWM 归零等一小段static void SafeReverse(void) { SetMotorPwm(0, 0); Delay_ms(15); }然后在主循环里做状态分发void CarStateMachine(CarCmd_t cmd) { if (cmd CMD_MAX) return; switch (g_carState) { case CAR_STOP: if (cmd CMD_FORWARD) { SetMotorDir(1, 1); SetMotorPwm(g_speed, g_speed); g_carState CAR_FORWARD; } else if (cmd CMD_BACKWARD) { SetMotorDir(-1, -1); SetMotorPwm(g_speed, g_speed); g_carState CAR_BACKWARD; } else if (cmd CMD_LEFT) { SetMotorDir(-1, 1); SetMotorPwm(g_speed, g_speed); g_carState CAR_LEFT; } else if (cmd CMD_RIGHT) { SetMotorDir(1, -1); SetMotorPwm(g_speed, g_speed); g_carState CAR_RIGHT; } break; case CAR_FORWARD: if (cmd CMD_BACKWARD) { SafeReverse(); SetMotorDir(-1, -1); SetMotorPwm(g_speed, g_speed); g_carState CAR_BACKWARD; } else if (cmd CMD_STOP) { SetMotorPwm(0, 0); g_carState CAR_STOP; } else if (cmd CMD_BRAKE) { SetMotorDir(1, 1); SetMotorPwm(7200, 7200); /* 两端同高H桥短接刹车 */ g_carState CAR_BRAKE; } break; /* 其余状态同理此处从略 */ default: break; } }超时保护是这套逻辑里最不能省的一环。做法是在一个 1ms 的定时器中断里维护一个计数器主循环每收到一条有效指令就把计数器清零计数器超过 300 就认为失联强制回到停止状态并切断使能。别小看这个保护遥控距离超了、手机没电了、蓝牙断开这些情况下如果车还在以满速前进结果会很难看。/* 放在 1ms 的定时中断里 */ void TIM4_IRQHandler(void) { if (TIM_GetITStatus(TIM4, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM4, TIM_IT_Update); if (g_linkTimer 1000) g_linkTimer; if (g_linkTimer 300) { SetMotorPwm(0, 0); GPIO_ResetBits(GPIOB, GPIO_Pin_12); /* STBY 拉低彻底切断 */ g_carState CAR_STOP; } } }7. 联调现场三个最典型的故障与排查链路7.1 上电后电机纹丝不动现象是蓝牙连上了、软件里也能看到数据在收发但两个轮子完全不转板载 LED 还正常闪。排查从后往前推。第一步先不管 MCU直接用一根杜邦线把 TB6612 的 PWMA 接到一个已知的高电平上比如 3.3V同时把 STBY 也拉高看电机动不动。如果这样能转说明驱动和电机这一侧没问题问题在 MCU 输出。如果这样也不转那就是供电或者驱动接线的问题重点检查电机电源有没有接、TB6612 的 VM 有没有电、电机线是不是掉了一根。第二步如果驱动侧正常用万用表量 PA6 在车静止、发前进指令时的电压。按照 10kHz、50% 占空比算平均值应该在 1.65V 左右。如果量出来一直是 0那就是 PWM 没输出。这时候检查三件事TIM3 的时钟开了没有、PA6 是不是配成了复用推挽、TIM_Cmd 有没有使能。第三步如果 PWM 有输出但电机还是不转检查方向脚。TB6612 的 AIN1、AIN2 如果同时为高或同时为低电机处于刹车或者停止状态PWM 再大也不转。这是很多人第一次用 TB6612 会忽略的点——它不是简单的一个方向脚加一个 PWM而是两个方向脚的状态组合决定工作模式。还有一个隐蔽的坑STM32 的复位后默认 PA13、PA14、PA15、PB3、PB4 会被 JTAG 占用其中 PA15 和 PB3、PB4 是 JTAG 的复用功能。如果你恰好把引脚分配到了这几个上会发现配置了但没反应。解决办法是关闭 JTAG只保留 SWD 调试用 GPIO_PinRemapConfig 里的 SWJ_JTAGDisable。我在一次把方向脚分到 PB3 的时候被这个坑卡了半小时。7.2 蓝牙连上了但车不动/乱码蓝牙模块有个通病它的状态引脚逻辑和通信引脚是分开的。手机能连上模块只说明射频链路通了数据传输通不通是另一回事。判断的方法是看模块上的指示灯——很多模块在未连接时快闪已连接后常亮或者慢闪这个指示的是连接状态不代表数据通道正常。排查链路的第一环是模块到 MCU 的接线。一个经典错误是 TXD 和 RXD 接反了。串口通信要求模块的 TX 接 MCU 的 RX、模块的 RX 接 MCU 的 TX很多人凭直觉把 TX 对 TX 接结果当然不通。第二环是模块的波特率。HC-05 这类模块默认可能是 9600而代码里配的是 115200这个不匹配导致收到的全是乱码。用 USB 转串口工具发 AT 指令读一下模块的波特率然后统一到 115200。如果模块本身不支持 115200那就把代码改成 9600。第三环是 MCU 侧的接收中断有没有真正进来。最简单的验证方式是在 USART1 的中断里翻转板载 LED或者用另一个串口USART2把收到的字节原样打印出来看打印的是不是你手机发出的内容。这个串口回环打印的调试手段非常实用能一次性确认中断、寄存器配置、缓冲区写入是否都正常工作。实测提示如果用 Keil 的在线调试在中断服务函数里打断点看变量是最直接的。但要注意串口中断频率高的时候断点会导致大量数据丢失和硬件 FIFO 溢出所以断点调完要尽快清除别一边跑一边挂着断点。7.3 车一走就窜、方向反了、原地打转车能动了但一按前进就往右偏或者一按左转结果原地打转。这类问题基本分两类软件的方向映射错了或者硬件的电机装反了。方向反了最简单直接交换对应电机的两根线或者改代码里方向脚的高低电平定义。原地打转是左右轮的动力方向不对比如发左转时你希望左轮反转右轮正转结果硬件上两个轮子都正转车就会动但不转圈。这种情况改一边的映射就行。一按就窜是很典型的 PWM 参数问题。可能是速度值被误设成了最大值或者占空比没有经过启动补偿就直接拉满。也有可能是电池电压高的时候电机转速过快跟调试时用的低压不一样。解决方式是把速度档位做出真实的区间比如档位 1 对应占空比 30%档位 9 对应 90%而不是直接从 0 跳到一个固定值。最后还有一类问题来自机械层面。两个电机的传动比不一致、轮子有摩擦差异、重心偏一侧都会导致直线走偏。这类问题不能靠软件硬纠最好的方式是在代码里加一个左右轮的微调系数先做机械上的对称调整再用软校正补偿一点点不要指望软件能把一个机械上歪掉的车跑直。8. 我把这台车跑顺之后总结的几条经验8.1 调试顺序永远从单模块验证开始这台车涉及的模块太多了我后来总结出的调试顺序是固定的先点灯确认最小系统板、下载器、工具链都正常再串口打印确认时钟树和串口配置正确然后单独测蓝牙USB 转串口直连模块发 AT 指令接着单独测电机驱动手给电平让电机转最后才把 MCU 接上去跑联调。这个顺序的意义在于每一步只引入一个新变量。如果你把四个模块一次性接好再上电调试出了问题你根本不知道是哪一块的锅只能一个个拆下来重测反而更慢。按顺序来的话每一层都是在前一层已验证的基础上叠加出问题一定在新引入的那一层排查范围立刻缩小到一个模块。还有个小窍门在开发阶段把每个关键变量当前状态、收到的命令、当前速度档位、链路超时计数都用串口按固定周期打印出来做一个简单的黑匣子。一旦车的行为异常看打印记录就能还原出它当时在想什么比拿示波器一帧一帧抓波形快得多。等调试稳定了再把这些打印关掉因为串口打印本身会占用主循环时间可能影响实时性。8.2 后续能往上加什么这台车的基本盘跑通之后能往上加的东西很多而且每一样都能让你多学一块 STM32 的外设。加编码器测速的话就用 TIM 的编码器模式把左右轮的编码器接到 TIM 的 CH1、CH2 上直接读出计数值能做闭环 PID 调速让两个轮子在负载变化时保持同步。这里就要用上定时器的输入捕获或者编码器接口比单纯输出 PWM 又深了一层。加超声波避障的话用 TIM 做微秒级计时触发 Trig 脚后测量 Echo 高电平持续时间算出距离。这会用到定时器的输入捕获功能能顺便理解捕获和比较的区别。加 FreeRTOS 的话把状态机、串口解析、传感器读取拆成独立任务用队列传递命令用信号量保护共享资源。这样代码结构会更清晰但也要注意任务优先级和栈大小的分配别让串口任务饿死电机控制任务。加 OLED 显示的话用 I2C 或者 SPI 驱动把当前的运行状态、电池电压显示出来。这一步能让你踩到开漏输出、上拉电阻、地址冲突这些 I2C 的经典坑。我自己踩过最深的坑反而不是代码而是接线。有次车跑着跑着突然不受控查了半天代码最后发现是电机的线在车体震动下虚接接触瞬间电流突变把 MCU 复位了。所以从那次之后我所有的电机线和电源线都焊死并且用扎带固定杜邦线绝不上动力回路。这种教训写出来好像很朴素但真的会让你的调试过程少掉很多莫名其妙的灵异现象。这套东西跑通之后你会发现ST M32F103C8T6 的入门门槛其实不在芯片本身而在于要同时协调 GPIO、TIM、UART 三个外设和一套控制逻辑。等你把这台车完整调出来回头再看那些单独的点灯教程串口教程会觉得完全是另一个层次的东西。而这里面的调试思路——分层验证、链路定位、参数可观测——放到任何一个嵌入式项目里都是通用的。
返回列表