ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F103C8T6+L298N电机驱动:GPIO正反转与PWM调速

STM32F103C8T6+L298N电机驱动:GPIO正反转与PWM调速

简介:围绕STM32F103微控制器与L298N双H桥驱动模块的入门实践资料,面向刚接触嵌入式GPIO输出、按键扫描与电机驱动的学生和自学者,用于完成直流电机正反转及停止控制这一典型入门实验。正文以PDF形式呈现,先列出器件清单与硬件接线对应关系(IN1至IN4分别接PA2至PA5,OUT1至OUT4接两台电机,共地连接、12V供电以及升压板使用时的注意事项),再给出main.c、motor.c、motor.h、key.c的完整代码与逐段说明,并附有电源管理、中断响应优化、编码器测速扩展等改进思路。压缩包共1个PDF文件,约6.97MB,篇幅紧凑,便于打印后对照实验。目前已有939人学习下载,可帮助读者快速搭起硬件连线、理解推挽输出与按键扫描逻辑,并在此基础上自行拓展闭环控制等功能。

1. 从一块 5V 电源说起:STM32F103C8T6 配 L298N 的第一次上电

手边只有一块 5V 开发板供电、L298N 的 12V 端子空着,电机要么纹丝不动,要么闷响一声就停——做过这套组合的人大概都见过这一幕。这份资源给的是最小可跑通的一套:STM32F103C8T6 用四个 GPIO 直连 L298N 的 IN1~IN4,驱动两个直流电机做正转、反转、停止三个动作,三个按键做输入,代码拆成 motor.c、motor.h、key.c、key.h、main.c,挂进任意一个带标准外设库的 stm32 工程就能编译。它适合刚把 GPIO 点灯跑完、想要一个有明确输入输出闭环的练手项目的人,也适合手上堆着旧电机和驱动板、想快速验证接线对不对的熟手。后面几章把接线、驱动代码、按键扫描和翻车点一层层拆开,包括原工程在 C8T6 上跑不通的那处引脚冲突。

2. 硬件接线:四个 IN 引脚、三根地线和一块升压板的落位

2.1 L298N 的两个 H 桥与它的三个供电端子

L298N 是一颗双 H 桥功率驱动芯片,内部集成两个完整的 H 桥,每个 H 桥由四只达林顿功率管构成,靠两个逻辑输入控制对角管的通断,从而改变输出端的极性。做成模块之后,这些引脚被引到两侧排针上,按功能分成三组:电源组是 12V、GND、5V 三个端子;控制组是 IN1~IN4 加 ENA、ENB;输出组是 OUT1~OUT4。理解这三组的分工,比背接线图有用得多。

先说 12V 端子。它标的是 12V,实际是电机母线输入 VS,模块允许的输入范围比字面宽,但真正决定电机能不能转的是电流而不是电压标称。L298N 内部是达林顿结构,饱和压降不小,输出到电机的电压通常比 VS 低 1.4~2V。这就意味着:想让电机实际拿到 12V,VS 得给到 13.5V 往上;反过来,如果电机铭牌写的是 6V 额定,直接怼 12V 进去就是长期过压,短时间内转得欢,久了对碳刷和绕组都不友好。选电源时按「电机额定电压 + 2V」倒推 VS,比按标称值硬套靠谱。

再说 GND。模块中间那个 GND 是整块板子的参考地,电机电流和逻辑电流最后都要从这里回流。很多人只把电源负极接上去,忘了把 STM32 的 GND 也并过来,结果就是单片机输出高电平、L298N 却「看不见」,电机一动不动——这不是代码问题,是两边没有共同参考点。

最后说 5V 端子。它是模块板载稳压器的输出端,作用是给逻辑电路供电,同时也能对外输出 5V。当你用跳线帽把 ENA、ENB 短接到 5V 时,H 桥就处于常使能状态,只用 IN1~IN4 控制方向。但如果你打算用外部 5V 给模块逻辑供电,就千万别再让这个端子和板载稳压输出顶在一起。原文里「5V 端口不接,不然很难转动」说的就是这件事:外部 5V 和板载 5V 打架,逻辑电平被抬高,H 桥进入异常状态,表现出来就是电机带不动。

ENA、ENB 两个使能端值得单独提一句。模块出厂时一般用跳线帽把它们短接到 5V,等于占空比 100%。想后面做 PWM 调速,就得先把这两颗跳线帽拔掉,否则外部 PWM 信号和 5V 直接短接,轻则调速无效,重则把单片机引脚拉坏。

2.2 从 OUT 到 IN:一张接线表和两次通断测量

先把接线固定下来,再谈代码。这套资源里 STM32 侧用的是 PA2、PA3、PA4、PA5 四个脚,对应 L298N 的 IN1~IN4;输出侧 OUT1/OUT2 接第一个电机,OUT3/OUT4 接第二个电机。整理成表是这样:

L298N 端子接到哪里注意事项
12V外部电源正极(升压板输出或独立电池组)不要接到开发板的 5V 排针上
GND电源负极,同时引一条线到 STM32 的 GND两条线都要接,缺一不可
5V悬空外部已供电时接上会与板载稳压冲突
IN1PA23.3V 推挽输出,直接连
IN2PA3同上
IN3PA4同上
IN4PA5同上
ENA / ENB保留跳线帽短接到 5V后面做 PWM 时再拔
OUT1 / OUT2电机 A 的两个端子转向反了就互换这两个
OUT3 / OUT4电机 B 的两个端子同上

表里最后两行的备注不是客套话。直流电机的转向只取决于绕组两端电压的极性,而极性又只取决于 OUT1/OUT2 谁高谁低。当你发现按键按下去电机转的方向和预期相反,最省事的做法不是去改代码里的正反转函数,而是把电机那两根线对调——代码是逻辑,接线是物理,两边改一处就够,改两处等于互相抵消,最后排查起来更乱。

接下来说两次通断测量,这是上电前唯一值得花五分钟做的事。第一次,断开所有电源,用万用表蜂鸣档量 L298N 的 GND 和 STM32 开发板的 GND 排针,应该响;不响说明共地线没接好或者接在了别的网络。第二次,量 OUT1 和 OUT2 之间的电阻,正常应该读到几欧到几十欧(电机绕组直流电阻),如果读到无穷大,说明电机线没插紧或者端子螺丝没压到铜丝;如果读到接近 0,那多半是 OUT1、OUT2 之间有短路,上电就会触发电源保护。

这两次测量之所以重要,是因为 L298N 模块本身不带完善的保护。它有过流保护,但响应慢,电机堵转那一下的冲击电流足以先把模块上的续流二极管烤热。上电前用万用表排掉短路,比上电后闻焦味强。

2.3 共地、上电顺序与 3.3V 能不能驱动 L298N

共地这件事前面提了两次,这里把它说透。STM32 输出高电平是相对自己的 GND 而言的 3.3V,L298N 判断高低电平是相对自己的 GND 而言的。两块板子的 GND 一旦不连,各自的「0V」就不是同一个电位,可能出现 STM32 输出 3.3V、L298N 那边测出来只有 1V 出头的情况,逻辑被判成低电平,电机自然不动。所以那根共地线不是可选项,是信号通路的一部分。

3.3V 能不能驱动 L298N 的输入端,这是另一个高频疑问。L298N 的输入是 TTL 电平兼容结构,数据手册给出的高电平门限典型值在 2.3V 左右,低电平门限在 1.5V 以下。STM32F103C8T6 的推挽输出在 3.3V 供电下,输出高电平接近 VDD,驱动这个门限绰绰有余。真正需要注意的是上电瞬间:PA2~PA5 在复位后默认是浮空输入,电平不确定,如果这时电机电源已经上电,H 桥可能收到一个随机电平,电机会抽一下。解决办法是在初始化函数里先写 GPIO_ResetBits 把四个脚拉低,再配置成输出模式,顺序别反。

上电顺序我一般按这个来:先接好所有地线和信号线,确认无误;给 STM32 上电,观察到程序跑起来(比如板载 LED 有反应);最后给电机电源上电。断电时反过来,先断电机电源,再断控制电源。这样做的理由是,只要控制侧先建立稳定的电平,H 桥就不会在电源建立过程中收到毛刺信号。反过来的顺序在某些电源上会导致电机上电瞬间自转一下,虽然不致命,但每次上电都抖一下,调参的时候很干扰判断。

最后说升压板。原文作者手头只有 5V,用升压板把 5V 抬到 12V 给 L298N 供电。这个方案能跑通,但有两个物理限制:一是升压板的输出电流能力,常见的小型升压模块标称 2A,实际持续输出 1A 左右就开始发热,两个电机同时启动的冲击电流很容易顶到它的限流点,表现就是电机转一下就停、升压板指示灯闪烁;二是 5V 输入的来源如果是开发板的 USB 口,那 USB 口本身也就 500mA 到 1A 的水平,整条链路都是瓶颈。真要用升压方案,给升压板的输入端并一颗 470uF 以上的电解电容,能明显缓解启动瞬间的电压跌落。

3. 驱动层代码:GPIO 推挽初始化与正反转真值表的对应

3.1 时钟、模式、速度:三个初始化的取舍

STM32 的任何一个 GPIO 要工作,第一步都是开时钟。PA2~PA5 挂在 GPIOA 上,而 GPIOA 的外设时钟挂在 APB2 总线上,所以使能语句是RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE)。这一步漏了,后面所有配置都写不进去,现象是引脚电平永远是 0,用示波器量也量不到变化——这类问题在初学者里出现频率很高,因为代码能编译通过,运行时却毫无反应。

第二个参数是输出模式。这里必须选GPIO_Mode_Out_PP,也就是通用推挽输出。推挽结构的含义是上管和下管都在,输出高时上管导通把引脚拉到 VDD,输出低时下管导通把引脚拉到 GND,两种状态都有驱动能力。如果误选成开漏输出GPIO_Mode_Out_OD,输出高电平时引脚是悬空的,必须外接上拉电阻才能得到高电平,直接连 L298N 的输入端就等于什么都没接,电机只会朝一个方向转或者干脆不转。

第三个参数是输出速度GPIO_Speed_50MHz。这个参数控制的是引脚翻转时的上升沿和下降沿斜率,速度越高边沿越陡。对电机控制这种几十赫兹到几十千赫兹的开关频率来说,2MHz 完全够用,选 50MHz 并不会让电机转得更快,只会让边沿更陡、高频谐波更多。不过在纯 GPIO 开关控制的场景下,这个差别小到可以忽略,按原工程的写法保持 50MHz 也没问题;等到后面上 PWM 调速时,才需要认真考虑这个参数对 EMI 的影响。

初始化最后一步是把四个引脚全部拉低。GPIO_ResetBits(GPIOA, GPIO_Pin_2|GPIO_Pin_3|GPIO_Pin_4|GPIO_Pin_5)这一句的作用是给 H 桥一个确定的初始状态:IN1~IN4 全低,两个电机都处于停止。少了这一句,引脚电平取决于复位后的默认值和上一次运行的残留,上电瞬间可能抽一下。

3.2 宏封装 IN1~IN4 与 do-while 的必要性

原工程用宏来封装单个引脚的高低电平操作,这个思路是对的:电机控制函数里如果每次都写GPIO_SetBits(GPIOA, GPIO_Pin_2),代码会长得没法看,而且引脚一改就要全文替换。宏把「引脚号」这个易变的东西收在一处,后面要换引脚只改宏定义就行。

但原工程的宏写法有个隐患:

#define IN1(a) if (a) \ GPIO_SetBits(GPIOA,GPIO_Pin_2);\ else \ GPIO_ResetBits(GPIOA,GPIO_Pin_2)

这种展开方式在单独使用时没问题,一旦放进if (x) IN1(1); else ...这样的上下文里,宏内部的 else 会和外面的 else 配对,编译器报错或者逻辑走偏。稳妥的写法是套一层 do-while(0):

/* motor.h —— 引脚宏封装 */ #ifndef __MOTOR_H #define __MOTOR_H #include "stm32f10x.h" #include "stm32f10x_gpio.h" #define HIGH 1 #define LOW 0 /* do{...}while(0) 保证宏在多语句上下文中被当成单条语句 */ #define IN1(a) do { if (a) GPIO_SetBits(GPIOA, GPIO_Pin_2); \ else GPIO_ResetBits(GPIOA, GPIO_Pin_2); } while (0) #define IN2(a) do { if (a) GPIO_SetBits(GPIOA, GPIO_Pin_3); \ else GPIO_ResetBits(GPIOA, GPIO_Pin_3); } while (0) #define IN3(a) do { if (a) GPIO_SetBits(GPIOA, GPIO_Pin_4); \ else GPIO_ResetBits(GPIOA, GPIO_Pin_4); } while (0) #define IN4(a) do { if (a) GPIO_SetBits(GPIOA, GPIO_Pin_5); \ else GPIO_ResetBits(GPIOA, GPIO_Pin_5); } while (0) void Motor_12_Config(void); void Motor_1_STOP(void); void Motor_1_PRun(void); void Motor_1_NRun(void); void Motor_2_STOP(void); void Motor_2_PRun(void); void Motor_2_NRun(void); #endif

do { ... } while (0)这个写法看起来绕,实际作用是让整个宏在语法上表现为一条语句:末尾的 while(0) 保证循环体只执行一次,而分号由调用方补上。这样无论宏被放在 if、else、for 还是裸语句位置,展开后都不会出现悬空的分号或错配的 else。这是 C 语言里处理多语句宏的标准做法,写驱动层时值得养成习惯。

参数方面,HIGH和LOW只是可读性别名,对应 1 和 0,编译后没有额外开销。真正需要留意的是这四个宏硬编码了 GPIOA,如果哪天要把 IN3、IN4 挪到 PB 口,就得改宏体而不是改调用处。想做得更通用,可以把端口和引脚号做成两个参数,但对这个规模的项目来说属于过度设计。

3.3 正转、反转、停止的三组电平组合

H 桥的控制逻辑可以浓缩成一张真值表。以电机 A 为例,IN1 和 IN2 两个输入决定 OUT1 和 OUT2 的极性:

动作IN1IN2OUT1OUT2电机状态
正转01低高电流从 OUT2 流向 OUT1
反转10高低电流从 OUT1 流向 OUT2
停止11高高两端等电位,绕组短接制动
停止00低低同上,也是短接制动

表里最后两行都是停止,区别在于哪一对管子导通。IN1=IN2=1 时两个上管导通,电机两端都被拉到高电平;IN1=IN2=0 时两个下管导通,两端都被拉到低电平。两种状态下电机绕组都是被短接的,产生的是制动转矩而不是自由滑行。如果确实需要电机自由滑行(比如靠惯性走一段),正确做法是把 ENA 拉低让 H 桥整体关断,只把 IN1、IN2 置成相同电平是做不到的。

第二路电机 B 完全对称,IN3 对应 IN1、IN4 对应 IN2,这里不重复列表,代码里按同样规律写就行。实际接线时如果发现电机 A 按「正转」键转的方向和电机 B 相反,不用改代码,把 B 的 OUT3、OUT4 两根线对调即可。

有一点容易被忽略:切换方向时不要从「正转」直接跳到「反转」。H 桥在极性翻转的瞬间,电机绕组是感性负载,电流不能突变,会产生一个反向电动势叠加在电源上,轻则电源电压瞬间跌落导致单片机复位,重则击穿功率管。稳妥的做法是在两个方向之间插一段停止状态,停留几十毫秒让绕组电流衰减下去。原工程的按键逻辑里没有这一步,属于可以接受的简化,但如果你把电机换成功率更大的型号,这个空档就必须补上。

3.4 motor.c 完整实现与主循环调用

把上面这些拼起来,驱动文件是这样:

/* motor.c —— 两路直流电机的初始化与控制 */ #include "motor.h" #include "stm32f10x_rcc.h" #include "delay.h" /* 初始化 PA2~PA5 为推挽输出,并先拉低 */ void Motor_12_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; /* GPIOA 挂在 APB2 上,这步漏了后面全白搭 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; /* 推挽,能拉能灌 */ GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); /* 先给 H 桥一个确定的停止状态,避免上电抽动 */ GPIO_ResetBits(GPIOA, GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5); } /* 以下六个函数对应真值表,命名里的 P/N 表示正转/反转 */ void Motor_1_STOP(void) { IN1(HIGH); IN2(HIGH); } void Motor_1_PRun(void) { IN1(LOW); IN2(HIGH); } void Motor_1_NRun(void) { IN1(HIGH); IN2(LOW); } void Motor_2_STOP(void) { IN3(HIGH); IN4(HIGH); } void Motor_2_PRun(void) { IN3(LOW); IN4(HIGH); } void Motor_2_NRun(void) { IN3(HIGH); IN4(LOW); }

代码里的逻辑很直白,值得说的是几个设计取舍。第一,初始化函数不返回任何值,也不做参数校验,因为这个规模的驱动没必要;但如果你的项目里电机数量会变,把「引脚」和「电机编号」做成结构体数组会更省事。第二,六个控制函数各自只改两个引脚,不做任何延时,延时留给调用方决定——驱动层保持纯粹,上层才能自由组合出「正转 2 秒后停止」这类时序。第三,STOP用的是两脚同高,也就是短接制动;如果你的应用需要滑行停止,把它改成两脚同低也不会改变结果,真正要滑行还是得动 ENA。

主程序按资源里的写法,用一个无限循环读键值再分派:

/* main.c —— 按键控制两路电机 */ #include "stm32f10x.h" #include "motor.h" #include "delay.h" #include "key.h" int main(void) { u8 key = 0; Motor_12_Config(); /* 先初始化电机引脚,给出确定的停止态 */ delay_init(); /* 延时函数初始化 */ KEY_Init(); /* 按键引脚初始化 */ while (1) { key = KEY_Scan(0); /* 0 表示不支持连按 */ if (key) { switch (key) { case WKUP_PRES: /* 正转 */ Motor_1_PRun(); Motor_2_PRun(); break; case KEY1_PRES: /* 反转 */ Motor_1_NRun(); Motor_2_NRun(); break; case KEY0_PRES: /* 停止 */ Motor_1_STOP(); Motor_2_STOP(); break; default: break; } } else { delay_ms(10); /* 无按键时空转,降低 CPU 占用 */ } } }

switch里三个分支的动作是同时作用于两个电机的,这一点符合「小车直行、后退、停车」的直觉。如果你要做的是差速转向,就把两个电机的调用拆开,比如左转时Motor_1_PRun()配Motor_2_STOP(),右转反过来。default分支看起来多余,但按键扫描函数返回的是 u8,万一以后加了新键值忘了加 case,有个 default 至少不会静默吞掉。

delay_ms(10)放在 else 里的用意是省电和降低总线占用,但这个写法有个副作用:按键按下时循环转得飞快,松开后每 10ms 才扫一次。对人工按键来说没问题,但如果以后要接红外遥控或者串口指令,这个节拍就得换成定时器中断。

4. 按键层改造:消抖、优先级与 C8T6 上没有 PE 口这件事

4.1 KEY_Scan 的静态标志位和 10ms 消抖

机械按键在按下和松开的瞬间,触点会弹跳若干次,持续时间通常在 5ms 到 20ms 之间。如果程序在这段时间里连续读引脚,会读出一串高低交替的值,表现出来就是「按一下,电机启动了又停、停了又启」。消抖的本质就是绕开这段时间,常见做法有两种:硬件上并电容,软件上延时后再读。

这套代码用的是软件消抖,核心是一个静态标志位:

/* key.c —— 按键扫描,支持单次触发 */ #include "key.h" #include "delay.h" void KEY_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; /* 按键用 PB0、PB1(上拉输入)和 PA0(下拉输入) */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; /* 上拉:默认高,按下为低 */ GPIO_Init(GPIOB, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPD; /* 下拉:默认低,按下为高 */ GPIO_Init(GPIOA, &GPIO_InitStructure); } /* mode = 0 单次触发;mode = 1 支持连按 */ u8 KEY_Scan(u8 mode) { static u8 key_up = 1; /* 按键松开标志,静态变量跨调用保持 */ if (mode) key_up = 1; /* 连按模式下强制允许触发 */ if (key_up && (KEY0 == 0 || KEY1 == 0 || WK_UP == 1)) { delay_ms(10); /* 消抖延时 */ key_up = 0; /* 锁定,直到全部松开才解锁 */ if (KEY0 == 0) return KEY0_PRES; else if (KEY1 == 0) return KEY1_PRES; else if (WK_UP == 1) return WKUP_PRES; } else if (KEY0 == 1 && KEY1 == 1 && WK_UP == 0) { key_up = 1; /* 全部松开,重新武装 */ } return 0; /* 无按键 */ }

这段代码的关键在key_up这个静态变量。它记录的是「上一次扫描时按键是否处于松开状态」,只有松开的按键才允许产生一次新的键值。按下时先延时 10ms 跳过抖动窗口,再把 key_up 清零,此后不管按多久,函数都返回 0,直到检测到三个按键全部松开才重新置 1。

mode参数决定要不要保留这个锁定。传 0 是单次触发,适合「按一下启动、再按一下停止」的场景;传 1 时每次调用都会把 key_up 置 1,等于取消了锁定,按住不放会连续返回键值,适合需要长按连续触发的场景,但用在电机控制上会导致电机反复执行启停。

宏定义在 key.h 里,负责把引脚电平翻译成可读的键值:

/* key.h —— 按键宏与键值定义 */ #ifndef __KEY_H #define __KEY_H #include "stm32f10x.h" #include "stm32f10x_gpio.h" /* 直接读 GPIO 输入寄存器,不依赖位带头文件 */ #define KEY0 GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_0) #define KEY1 GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1) #define WK_UP GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) #define KEY0_PRES 1 #define KEY1_PRES 2 #define WKUP_PRES 3 void KEY_Init(void); u8 KEY_Scan(u8 mode); #endif

这里刻意没有用#include "sys.h"。原工程里引入这个头文件是为了使用位带操作的宏,但 sys.h 属于某个特定开发板的配套库,换一套工程环境就找不到文件,编译直接报错。改用标准外设库的GPIO_ReadInputDataBit之后,只要有 stm32f10x_gpio.c 就能编过,移植成本低很多。位带操作确实快,省掉一次函数调用的开销,但按键扫描本来就跑在毫秒级,这点开销完全看不出来。

4.2 键值到电机动作的 switch 映射

键值映射这块没什么玄机,但有一个优先级问题值得说。KEY_Scan 内部的判断顺序是KEY0 > KEY1 > WK_UP,这个顺序决定了同时按下多个键时谁赢。如果有人手误同时按了 KEY0 和 KEY1,程序只会返回 KEY0_PRES,也就是执行停止。这个行为本身是合理的——停止的优先级高于启动——但它是有意为之还是碰巧,取决于写代码时有没有想过。想改优先级,调换 if-else 链的顺序就行。

另一个细节是键值和动作的对应。原工程里 WKUP 对应正转、KEY1 对应反转、KEY0 对应停止。WK_UP 这个键在多数开发板上是独立按键,位置离另外两个较远,正转放这里减少了误触;停止用 KEY0(通常是靠边的那个)也方便紧急时刻拍下去。如果你的板子布局不一样,改 switch 的 case 就行,不用动扫描函数。

真正需要小心的是「停止」和「反转」之间的时序。假设电机正在正转,用户直接按反转键,代码会立刻把 IN1 从 0 拉到 1、IN2 从 1 拉到 0,中间没有任何空档。前面说过,感性负载的电流不能突变,这一下会在电源上产生一个不小的尖峰。稳妥的做法是在 switch 里加一层状态判断,检测到方向切换时先调 STOP,延时 50ms,再调新方向:

/* 带方向切换保护的按键处理片段 */ static u8 last_dir = 0; /* 0 停止,1 正转,2 反转 */ u8 new_dir = 0; if (key == WKUP_PRES) new_dir = 1; else if (key == KEY1_PRES) new_dir = 2; else if (key == KEY0_PRES) new_dir = 0; /* 方向反转时先刹车再换向 */ if ((last_dir == 1 && new_dir == 2) || (last_dir == 2 && new_dir == 1)) { Motor_1_STOP(); Motor_2_STOP(); delay_ms(50); /* 等绕组电流衰减 */ } if (new_dir == 1) { Motor_1_PRun(); Motor_2_PRun(); } else if (new_dir == 2) { Motor_1_NRun(); Motor_2_NRun(); } else { Motor_1_STOP(); Motor_2_STOP(); } last_dir = new_dir;

这段代码多了十几行,换来的是电源电压不再被反复拉低。用 5V 升压方案供电时,这 50ms 空档几乎是必需品——升压板的动态响应跟不上突变负载,省掉它会看到单片机时不时复位。

4.3 把 PE3/PE4 换到 PB0/PB1 的重映射

这里要处理原工程里最硬的一处冲突。标题写的是 STM32F103C8T6,但正文里的芯片型号写成了 zet6,而 key.c 里用的是 GPIOE 的 Pin_3 和 Pin_4。这两件事放在一起就矛盾了:C8T6 是 48 脚封装,可用的 IO 只有 PA0~PA15、PB0~PB15、PC13~PC15,压根没有 PE 口。把带 GPIOE 的代码烧进 C8T6,编译可能过得去(因为寄存器地址是全局定义的),运行时对 GPIOE 的写操作不会产生任何效果,按键自然完全没反应。

这个问题在新手手里特别容易卡住,因为现象是「电机能转,按键没反应」,看起来像按键代码写错了,实际上是用错了芯片的引脚图。判断方法很简单:查一下手上芯片的封装,48 脚就是 C8T6,没有 E 口;如果是 144 脚的 ZET6,那 GPIOE 是存在的。两者代码不能直接互抄。

改造方案是把按键挪到 C8T6 上真实存在的引脚。我一般用 PB0 和 PB1 做两个普通按键,配成上拉输入;PA0 保留作 WK_UP,配成下拉输入——PA0 在 C8T6 上确实存在,而且很多开发板把它接到了独立按键上。改完之后 key.c 里的初始化就是 4.1 节展示的那份。要注意 PB0、PB1 与 PA2~PA5 不冲突,改动不影响电机引脚。

选上拉还是下拉,取决于按键的硬件接法。按键一端接引脚、另一端接 GND,就用上拉输入,默认读到 1,按下读到 0;按键一端接引脚、另一端接 VCC,就用下拉输入,默认读到 0,按下读到 1。接反了的现象是「不按时电平就是按下状态」,程序一上电就执行动作。原工程里 KEY0、KEY1 用 IPU、WK_UP 用 IPD,说明前两个键接 GND、后一个键接 VCC,这是开发板出厂时的硬件决定,不是可以随便改的软件选项。

另外原工程 key.c 里有两行连续的GPIO_InitStructure.GPIO_Pin赋值,前一行写了GPIO_Pin_4|GPIO_Pin_3,后一行又覆盖成GPIO_Pin_5。这是明显的笔误,结构体成员的第二次赋值会把第一次覆盖掉,结果是只初始化了一个引脚。改造时把两行合并成一行、或者分成两次GPIO_Init调用,别把这个 bug 一起抄过去。

4.4 去掉 delay_ms(10000) 与主循环节拍

原工程 main.c 里在初始化之后有一句delay_ms(10000),也就是上电后先干等十秒。作者的解释是等电源稳定或者等某个模块就绪,但对这套电路来说没必要,反而让人以为程序没跑起来。我第一次跑的时候盯着板子看了十秒才反应过来,血泪经验就是先把这个延时删掉或者改成 100ms。

主循环的节拍也值得调一下。现在的结构是无按键时delay_ms(10),有按键时不延时、连续跑。这意味着按键按下的瞬间 CPU 是满速空转的,虽然对功能没影响,但把延时挪到循环末尾统一执行会更均匀:

while (1) { key = KEY_Scan(0); if (key) { /* 动作分派 */ } delay_ms(10); /* 无论有无按键,统一 10ms 节拍 */ }

这样改的另一个好处是按键扫描的间隔固定,消抖那 10ms 的窗口不会因为循环体长短变化而漂移。等以后要接定时器,把这个delay_ms(10)换成定时器中断置标志位、主循环查询标志,整个程序就自然过渡到了非阻塞结构。

5. 排查现场:电机不转、只抖不转、单边转的五种典型

5.1 上电类的三个现场:不转、抖、复位

现象:上电后电机纹丝不动,用手摸 L298N 模块也不热,单片机程序看起来在跑。

原因通常是电源侧。第一种可能,12V 端子没接或者接的电压不够,H 桥没有母线电压,输出端自然是死的。第二种可能,共地线漏了,STM32 输出的电平 L298N 判不出来。第三种可能,ENA/ENB 的跳线帽被拔掉了但代码里没有 PWM 输出,H 桥整体关断。

解决按顺序走:先拿万用表量 L298N 的 12V 端子和 GND 之间有没有电压,没有就查电源和升压板;有电压再量 STM32 的 GND 和 L298N 的 GND 是不是通的,不通就补一根线;最后检查 ENA/ENB 的跳线帽在不在位。三步走完,绝大多数「完全不转」都能定位。

现象:电机动一下或者发出嗡嗡声,然后停住,模块发烫。

原因是电流上不去。升压板的输出能力不够,或者电机堵转,或者电源线太细。L298N 内部有压降,如果 VS 只有 5V 出头,输出到电机可能只剩 3V 多,不足以克服电机的启动转矩,于是电机堵在那里,电流全部转化成热。

解决:换更粗更短的电源线;给升压板输入端并一颗 470uF 以上的电解电容吸收启动冲击;如果电机是 12V 额定,老老实实上独立电源,别指望 5V 升压能带两个电机同时启动。

现象:一按按键单片机就复位,或者程序跑飞。

原因是电机启动或换向时的电流冲击把电源电压拉低了,触发单片机的欠压复位。这条链路里最脆弱的是升压板:它的输入是 5V,输出是 12V,负载突变时反馈环路来不及响应,输出电压会掉一下。

解决:控制侧和电机侧分开供电,至少让单片机的 5V 来自 USB 而电机侧来自独立电池;在 L298N 的 12V 和 GND 之间并一颗 100uF 电解加一颗 0.1uF 瓷片;换向时加 50ms 空档(4.2 节那段代码)。

5.2 控制类的两个现场:方向反了、按键失灵

现象:按正转键,电机转了,但方向和预期相反。

原因只有一个:电机绕组接到 OUT1/OUT2 的极性反了。H 桥本身没有「正转」的概念,它只知道让 OUT1 高还是 OUT2 高。

解决:把电机的两根线对调,或者把 motor.c 里Motor_1_PRun和Motor_1_NRun的两个电平互换。两种改法效果一样,但建议只改一处——接线反了改接线,代码反了改代码,两边都改会互相抵消,下次排查时就分不清到底是哪边的问题。如果两个电机方向不一致,分别对调各自的 OUT 线即可。

现象:按键完全没反应,但电机上电后自己会转一下。

这个现象指向两个可能。一是按键引脚在 C8T6 上不存在,也就是 4.3 节说的 PE3/PE4 问题;二是按键的上拉/下拉配置和硬件接法不匹配,导致程序读到的电平恒定不变。

解决:先确认芯片封装和可用 IO,把 PE 口换到 PB 或 PA;再用万用表量按键未按下和按下时引脚对地的电压,上拉配置下未按应该是 3.3V、按下是 0V,如果量出来一直是 0V 或者一直是 3.3V,说明按键硬件没接到位或者配置反了。

还有一种隐蔽情况:按键按下有反应,但按一次电机启动后就再也停不下来。这多半是key_up静态变量的解锁条件没满足——三个按键里有一个的电平一直停在「按下」状态(比如 WK_UP 悬空被拉高),导致KEY0==1 && KEY1==1 && WK_UP==0永远不成立,key_up 一直是 0,后续键值全被吞掉。用万用表把三个脚都量一遍,找出那个「一直按着」的脚。

5.3 编译与引脚类的现场

现象:编译报错,提示找不到 sys.h。

原因:sys.h是某套特定开发板配套库里的头文件,提供位带操作的宏,不是 STM32 标准外设库的一部分。换一套工程环境就没有这个文件。

解决:把 key.h 里的#include "sys.h"删掉,按键读取改用GPIO_ReadInputDataBit。如果确实想用位带,得把对应的 sys.c、sys.h 一起加进工程,但那会增加不必要的移植负担。就按键扫描这点频率来说,标准库函数完全够用。

现象:代码编译通过、烧录成功,但所有引脚都没有输出。

原因:GPIO 的时钟没开。RCC_APB2PeriphClockCmd这一句漏掉或者写错了外设名,后面的所有配置都是往一个没上电的寄存器里写。

解决:检查RCC_APB2PeriphClockCmd的参数是不是RCC_APB2Periph_GPIOA(电机)和RCC_APB2Periph_GPIOB(按键)。注意 GPIO 挂在 APB2 而不是 APB1,写成RCC_APB1Periph_GPIOA编译不报错,运行时无效,属于典型的黑匣子型 bug。用调试器在初始化之后读一下 GPIOA 的 CRL 寄存器,如果全是复位默认值,基本就是时钟没开。

6. 往上走一步:用 TIM3 的 PWM 做调速和换向死区

6.1 把 ENA/ENB 从跳线帽上摘下来

GPIO 只能给出「全速」和「停止」两种状态,想让小车慢下来或者做平滑加减速,就得把 ENA、ENB 从 5V 跳线帽上拔下来,改接 PWM。C8T6 上 TIM3 的 CH1、CH2 正好落在 PA6、PA7,与已经在用的 PA2~PA5 不冲突,接线只需两根杜邦线,改动最小。

思路是让 IN1~IN4 继续负责方向,PWM 只负责速度:IN 决定电流往哪个方向流,ENA 的占空比决定这个电流平均有多大。这样原来的方向控制函数一行不用改,只在外面套一层调速。

/* 初始化 TIM3_CH1(PA6) / CH2(PA7) 输出 PWM,频率约 1kHz */ void Motor_PWM_Init(u16 arr, u16 psc) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); /* TIM3 在 APB1 */ /* PA6/PA7 必须配成复用推挽,普通推挽出不来波形 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period = arr; /* 自动重装值,决定分辨率 */ TIM_TimeBaseStructure.TIM_Prescaler = psc; /* 预分频,决定频率 */ TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 0; /* 上电占空比为 0 */ TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM3, &TIM_OCInitStructure); /* CH1 -> PA6 -> ENA */ TIM_OC2Init(TIM3, &TIM_OCInitStructure); /* CH2 -> PA7 -> ENB */ TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_Cmd(TIM3, ENABLE); }

arr和psc两个参数决定 PWM 的频率和分辨率。系统时钟 72MHz,取 psc=71 得到 1MHz 计数时钟,再取 arr=999,PWM 频率就是 1kHz,占空比可以分 1000 档。1kHz 对直流电机来说略低,电机可能会有轻微啸叫,常用范围是 5kHz 到 20kHz:频率高了超出人耳范围、电流更平滑,但开关损耗也上升。小功率电机取 10kHz 左右比较平衡,此时 psc=71、arr=99,分辨率降到 100 档,调速够用。

6.2 占空比与换向时序的写法

设置速度只需要改比较寄存器的值,注意 TIM3 是 16 位定时器,CCR 的取值不能超过 arr:

/* duty: 0~100,对应 0%~100% 占空比 */ void Motor_SetSpeed(u8 duty) { u16 ccr; if (duty > 100) duty = 100; /* 限幅,防止越界 */ ccr = (u16)((100 - duty) * 10); /* arr=999 时满量程 1000 */ TIM_SetCompare1(TIM3, ccr); TIM_SetCompare2(TIM3, ccr); }

100 - duty这个减法是配合 PWM1 模式的极性:PWM1 模式下 CCR 越大高电平越长,而 L298N 的 ENA 是高电平使能,所以占空比越大电机越快,这里的换算要根据实际极性验证一遍。我见过有人直接ccr = duty * 10写下去,结果速度反着走,按「加速」反而变慢,查了半天以为是硬件问题。

换向时的死区靠软件补。正确的顺序是:先把占空比降到 0,延时 20ms 以上等绕组电流衰减,再切 IN 引脚的方向,最后把占空比缓缓升上去。切换方向时如果占空比还挂在 60%,H 桥在极性翻转的瞬间相当于把电机绕组直接短路在电源

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进