STM32F103C8T6+L298N直流电机正反转控制入门
简介:这是一份面向STM32入门者与电机控制初学者的实操资料,围绕STM32F103C8T6通过L298N驱动两个直流电机实现正转、反转和停止展开,适合课程设计、实验课及小型智能小车底盘调试参考。压缩包共1个PDF文件,约6.97MB,以图文讲解和代码节选为主,可按硬件连接、主程序、驱动模块和按键扫描对照阅读。内容给出L298N与STM32的OUT1~OUT4、IN1~IN4接法,说明PA2~PA5控制信号及共地、供电注意事项,附main.c、motor.c、motor.h、key.c等代码,覆盖GPIO初始化、延时、按键扫描及Motor_12_Config、正反转与停止函数。列出电源管理、中断响应、编码器反馈和错误处理等优化方向,帮助读者理解双电机控制流程并排查接线与转动异常。已有939人学习。
1. 桌面上两块电机一个模块,十分钟内让它转起来
桌面上摊着一块 STM32F103C8T6 最小系统板、一个红色 L298N 双 H 桥模块、两个带减速箱的直流电机,没有小车底盘也没有机械臂,唯一的目标是按下板子上的按键,让两个轮子正转、反转、停下。这套资源解决的就是这个最原始的起点问题:用最少的硬件和最短的代码,把「MCU 输出电平」和「电机转动」之间的因果链打通。适合刚学完 GPIO 点灯、想上手执行器的嵌入式新手,也适合要带学生做第一个运动控制 Demo 的开发者。它不追求速度闭环,也不做 PID,但把 L298N 的 IN1~IN4 真值表、按键扫描、GPIO 推挽输出这几件事串成了一条能跑通的完整链路,后面加 PWM 调速、加编码器反馈,都是从这条链路往上叠。
2. 硬件接线拆解:四根控制线如何落到 PA2 到 PA5
先别急着写代码,接线接错了后面所有调试都是白费。这套方案里 STM32 和 L298N 之间的信号线只有 6 根,但电源部分有三个容易翻车的接口,值得单独拆开看。
2.1 电源侧:12V 输入、GND、5V 输出这三个口不能混
L298N 模块上通常有一排三位端子,标着12V、GND、5V。很多人第一眼会把它当成「给模块供三种电压」,实际上它的逻辑是:12V和GND是电机的功率输入(也叫 VS,实际能接受 5~35V 左右),5V是模块板载稳压器的输出端,用来给单片机供 5V 逻辑电,或者干脆空着不用。
这里的坑在于跳线帽。模块上 12V 端子旁边一般有个短接帽,短接时板载稳压器工作,5V 端子对外输出 5V;当你的电机电源超过 12V 左右时,必须把跳线帽拔掉,否则稳压器承受不住会发热甚至烧掉。原方案里作者用的是 5V 升压到 12V 的板子给 L298N 供电,也就是说电机侧的输入电压其实不高,这种情况下跳线帽保留、5V 端子悬空是安全的。
作者在原文里特意标了一句「5V 端口不接,不然很难转动」,这句话是对的,但原因不是玄学:如果 5V 端子和外部 5V 电源同时接上,板载稳压器和外部电源会互相打架,逻辑电平被拉偏,L298N 内部 H 桥的开关管进入线性区而不是饱和区,输出力矩自然就没了。血泪经验是——同一个电源域里只能有一个供电方。
共地则是另一条硬规矩。STM32 的 GND 和 L298N 的 GND 必须连在一起,而且推荐从 L298N 中间的 GND 端子引两根线,一根回单片机 GND,一根回电源负极。原因很直白:IN1~IN4 是电压信号,信号是相对于「地」这个基准来判定的,两边地不共,PA2 输出 3.3V 在 L298N 眼里可能是 1.8V 也可能是 5V,纯看两块板子各自的浮地电位飘到哪里,表现出来就是「有时转有时不转」。
| L298N 端子 | 接到哪里 | 备注 |
|---|---|---|
| 12V(VS) | 电机电源正极 | 电压越高转速越快,注意跳线帽 |
| GND(中间) | 单片机 GND + 电源负极 | 必须共地,两条线分开走 |
| 5V | 悬空或给单片机供电,二选一 | 别和外部 5V 同时接 |
| OUT1 / OUT2 | 电机 A 的两根线 | 转向反了就互换 |
| OUT3 / OUT4 | 电机 B 的两根线 | 同上 |
| IN1~IN4 | PA2 / PA3 / PA4 / PA5 | 顺序别乱,代码是对应的 |
| ENA / ENB | 保留跳线帽(默认全速) | 后续做调速要拔掉接 PWM |
2.2 控制侧:IN1~IN4 与 PA2~PA5 的固定映射
四根控制线的对应关系是硬编码在代码里的,接错一根就会出现「按正转结果一个轮子正转一个轮子倒转」的诡异现象。这套方案定的是 IN1→PA2、IN2→PA3、IN3→PA4、IN4→PA5,前两根管电机 1,后两根管电机 2。
接线的物理顺序建议按「从 L298N 到板子」来排,即先把模块上的 IN1 找出来,插到 PA2,再依次推下去,而不是先看板子引脚再找模块。因为 L298N 模块的排针顺序各家略有差异,有的把 ENA 放在最左边,有的放在最右边,按模块为准不容易错。
电机侧的 OUT1/OUT2 接一个电机,OUT3/OUT4 接另一个。这里有个不需要改代码的调节手段:如果通电后发现某个轮子的转向和预期相反,不用去改Motor_1_PRun()里的电平,直接把该电机的两根线对调即可。原因是直流电机的转向由流过绕组的电流方向决定,交换 OUT1/OUT2 等价于把电流方向反过来,代码层面完全不用动。
2.3 上电顺序与快速验线
正确的上电顺序是:先接好所有信号线和电机线,确认共地,再给单片机供电,最后给 L298N 的电机电源上电。反过来的话,L298N 先上电、而 IN 引脚此时处于浮空状态,输入电平不确定,H 桥可能瞬间导通,电机「啪」地抖一下。这种抖动对空载电机无害,但如果装在小车上,可能会撞到东西。
想在上代码前先验证接线是否正确,有一个不用写程序的土办法:把 L298N 的 IN1 用杜邦线直接接到 5V,IN2 接 GND,此时电机 1 应该朝一个方向转;反过来接就朝另一个方向转。这一步能转,说明电源、共地、电机线全部没问题,剩下的锅只可能在 MCU 那一侧。我一般会把这个动作叫做「给硬件先做一次体检」,能省掉后面大量的返工。
提示:如果 L298N 上电后模块发烫但电机不转,立刻断电。多半是 OUT1/OUT2 短路或者电机堵转,继续通电会烧 H 桥。
3. 代码骨架:motor.h 的宏、motor.c 的真值表和 main.c 的轮询
硬件通了之后,软件部分其实只有三块:引脚定义、电平控制函数、主循环调度。这套代码把前两块压得很紧,一个头文件用宏封装了全部 GPIO 操作,另一个源文件把「正转、反转、停止」翻译成 IN 引脚的高低组合。
3.1 motor.h:用带参宏把 GPIO 操作压成一行
头文件里最值得看的是那四个带参宏。原文的写法是条件分支直接写在宏体里:
#ifndef __MOTOR1_H #define __MOTOR1_H #include "stm32f10x.h" #include "stm32f10x_gpio.h" #define High 1 #define Low 0 /* 原文写法:宏体直接是 if-else */ #define IN1(a) if (a) \ GPIO_SetBits(GPIOA, GPIO_Pin_2);\ else \ GPIO_ResetBits(GPIOA, GPIO_Pin_2) #define IN2(a) if (a) \ GPIO_SetBits(GPIOA, GPIO_Pin_3);\ else \ GPIO_ResetBits(GPIOA, GPIO_Pin_3) #define IN3(a) if (a) \ GPIO_SetBits(GPIOA, GPIO_Pin_4);\ else \ GPIO_ResetBits(GPIOA, GPIO_Pin_4) #define IN4(a) if (a) \ GPIO_SetBits(GPIOA, GPIO_Pin_5);\ else \ GPIO_ResetBits(GPIOA, GPIO_Pin_5) 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这套宏的意图是把「设高/设低」这个动作浓缩成IN1(High)这种可读性极强的调用,写业务代码时脑子里想的是电平,不是GPIO_SetBits。参数a只要非 0 就置高,为 0 就拉低,High和Low两个宏只是给 1 和 0 起了名字。
但这个宏有一个会让人调半天的陷阱,后面避坑章节会展开讲:宏体里带着裸的if...else,当它被放进另一个if的花括号外时,else会跟外层if配对。稳妥写法是套一层do { ... } while(0):
/* 我后来改成这样,在任何 if-else 结构里都不会串味 */ #define IN1(a) do { \ if (a) GPIO_SetBits(GPIOA, GPIO_Pin_2); \ else GPIO_ResetBits(GPIOA, GPIO_Pin_2); \ } while (0)do-while(0)在编译后会被优化掉,不产生任何额外指令,纯粹是语法上的封装壳。
3.2 motor.c:初始化与六种电平组合
源文件里的初始化函数负责两件事:开 GPIOA 时钟,把 PA2~PA5 配成推挽输出并拉低。
#include "motor.h" #include "stm32f10x.h" #include "stm32f10x_rcc.h" #include "delay.h" void Motor_12_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; /* 1. 开时钟:GPIOA 挂在 APB2 上,不开时钟写寄存器无效 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); /* 2. 四个 IN 引脚一起配置,省一次函数调用 */ 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; /* 驱动 L298N 足够 */ GPIO_Init(GPIOA, &GPIO_InitStructure); /* 3. 初始电平全部拉低,避免上电瞬间乱转 */ GPIO_ResetBits(GPIOA, GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5); }GPIO_Mode_Out_PP选推挽而不是开漏,是因为 L298N 的输入端内部是高阻抗的 CMOS 输入,需要 MCU 主动输出高和低两个电平;开漏只能输出低和「悬空」,高电平得靠外部上拉,多一层不确定性。GPIO_Speed_50MHz是翻转速率,不是输出电压,电机控制这种毫秒级动作其实用最低档也够,但统一设成 50MHz 不容易在别处踩坑。
三个动作函数把电平组合翻译成转动状态,逻辑很朴素:
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); }对应的真值表(以电机 1 为例):
| IN1 | IN2 | H 桥状态 | 电机表现 |
|---|---|---|---|
| 0 | 1 | 左低右高 | 正转 |
| 1 | 0 | 左高右低 | 反转 |
| 1 | 1 | 两端同高 | 刹车,绕组被短接,停得快 |
| 0 | 0 | 两端同低 | 自由滑行,靠惯性慢慢停 |
这里有一个语义上的小瑕疵值得指出:初始化函数把四个引脚全拉低了,也就是0 0状态,电机是「自由滑行」;而Motor_1_STOP()用的是1 1,是「刹车」。两者都叫停止,但物理行为不一样。空载时看不出差别,装上轮子做小车就能明显感觉到:掉电滑行会溜车,刹车会立刻停住。想让停车更干脆,就把初始化也改成拉高。
3.3 main.c:一个 while(1) 里塞下三种动作
主程序的调度非常直接,没有中断、没有状态机,就是「扫键 → 查表 → 调函数」。
#include "stm32f10x.h" #include "stm32f10x_gpio.h" #include "stm32f10x_rcc.h" #include "motor.h" #include "delay.h" #include "key.h" int main(void) { vu8 key = 0; Motor_12_Config(); /* 电机引脚初始化 */ delay_init(); /* 延时函数初始化,SysTick 打底 */ delay_ms(10000); /* 原文留的上电等待,10 秒 */ KEY_Init(); /* 按键初始化 */ while (1) { key = KEY_Scan(0); /* 0 = 不支持连按 */ if (key) { switch (key) { case WKUP_PRES: /* 正转 */ Motor_1_PRun(); Motor_2_PRun(); delay_ms(1); break; case KEY1_PRES: /* 反转 */ Motor_1_NRun(); Motor_2_NRun(); delay_ms(1); break; case KEY0_PRES: /* 停止 */ Motor_1_STOP(); Motor_2_STOP(); delay_ms(1); break; } } else { delay_ms(10); /* 无键按下时让出 CPU */ } } }vu8是volatile unsigned char的别名,来自开发板配套的公共头文件,这里其实没必要加 volatile,因为key只在主循环里读写,没有任何中断去改它。KEY_Scan(0)的参数 0 表示「不支持连按」,按住不放只会触发一次;传 1 则会持续返回键值,适合做「按住加速」这类功能。
三个 case 里各自跟了一句delay_ms(1),作用是让电平稳定一下再进入下一轮扫描,属于保守写法。真正让 CPU 不做空转的是else分支里的delay_ms(10),它把轮询周期压到 10ms 左右,既不会漏按也不会白烧电。
整个结构的问题也很明显:delay_ms(10000)让板子上电后要发呆 10 秒才响应按键,第一次上电的人很容易以为程序没烧进去。这个延时的来历大概率是调试期间为了等电源稳定或者避开某个上电抖动留下的,正式使用时删掉或者改成 100ms 都行。
4. 按键扫描与去抖:KEY_Scan 的静态标志和优先级
按键部分看着不起眼,但它是这套代码里唯一带「状态」的逻辑,key_up这个静态变量用得对不对,直接决定按一下是触发一次还是触发十几次。
4.1 KEY_Init:上拉、下拉、以及被覆盖的那一行
初始化函数要做的是给三个按键选对输入模式。机械按键一端接 IO、一端接 GND,那么 IO 必须上拉,按下时读到 0,松开时读到 1;如果按键一端接 VCC,那 IO 要下拉,按下读 1。
void KEY_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; /* 同时开 GPIOA 和 GPIOE 的时钟 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOE, ENABLE); /* 原文这两行:第二行把第一行覆盖掉了 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_3; /* KEY0-KEY1 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5; /* 只剩 PE5 */ GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; /* 上拉输入 */ GPIO_Init(GPIOE, &GPIO_InitStructure); /* WK_UP 接 PA0,按下接 VCC,所以配下拉 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPD; GPIO_Init(GPIOA, &GPIO_InitStructure); }GPIO_Mode_IPU是内部上拉输入,GPIO_Mode_IPD是内部下拉输入,省掉外部电阻。这段代码里有两个隐患,一个是明显的笔误——连续两次给GPIO_Pin赋值,第二行把Pin_3 | Pin_4冲掉了,实际只有 PE5 被配成上拉输入;另一个是更致命的,GPIOE在 48 脚的 C8T6 上根本不存在,只有 100 脚以上的大容量型号才引出 Port E。这块后面单独讲。
4.2 KEY_Scan:一个静态变量管住连发
扫描函数的核心是static u8 key_up,它记录「上一次扫描时按键是不是已经松开了」。
u8 KEY_Scan(u8 mode) { static u8 key_up = 1; /* 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,然后按优先级返回键值;下一次进来时key_up已经是 0,即使按键还按着也不会重复返回,直到三个键全松开才恢复。
mode参数是个「后悔药开关」:传 1 时每次都把key_up重置为 1,等于绕过单次触发限制,按住就会连发。做电机控制一般传 0,因为连发会让电机状态在正转反转之间疯狂横跳。
返回值的优先级是 KEY0 > KEY1 > WK_UP,这个顺序由if-else if链决定。同时按下两个键时只有排在前面的生效,实际使用中基本遇不到,但如果你的项目要支持组合键,这个函数就得改成先收集所有键的状态再判断。
按键电平的读取封装在 key.h 里,全是GPIO_ReadInputDataBit的宏:
#define KEY0 GPIO_ReadInputDataBit(GPIOE, GPIO_Pin_4) #define KEY1 GPIO_ReadInputDataBit(GPIOE, GPIO_Pin_3) #define WK_UP GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) #define KEY0_PRES 1 #define KEY1_PRES 2 #define WKUP_PRES 3把GPIO_ReadInputDataBit包成宏的好处是业务代码里写if (KEY0 == 0)就够了,读起来像在读一个变量。代价是每次读都真的走一遍函数调用,没有做缓存,好在按键扫描频率只有 100Hz 左右,完全无所谓。
4.3 从按键到动作:为什么不用外部中断
有人会问,电机控制为什么不用 EXTI 外部中断,按键一按下立刻响应,不是更快吗?在这个场景下确实没必要,原因有三点。
第一,机械按键的抖动在中断里更难处理。中断服务函数里不能长时间delay_ms(10),只能靠定时器做状态机去抖,代码量立刻翻几倍。第二,电机控制的响应时间以毫秒计,10ms 的轮询周期人根本感觉不到延迟。第三,中断里调Motor_x_PRun()这种带 GPIO 写的函数,如果主循环同时也在写同一组引脚,会出现电平竞争,虽然不是致命问题,但调试时会看到莫名其妙的毛刺。
我一般的判断标准是:输入事件频率低于 100Hz、且处理逻辑里带延时或阻塞操作,就走轮询;高于这个频率或者要做精确时间戳,才上中断。按键明显属于前者。
5. 避坑与排查:五个让电机不听话的真实原因
这套代码不长,但复现时踩坑的概率不低,因为硬件链路上每一环都可能出问题,而现象往往都表现为「电机不动」,很难一眼定位。下面五条是我按排查顺序整理的,从最容易被忽略的开始。
5.1 上电后按键毫无反应,等十秒才好
现象:程序烧进去、复位、按键,电机一动不动,以为烧录失败,反复重烧;过一会儿随手再按一下,突然就好了。
原因:main()里在KEY_Init()之前有一句delay_ms(10000),也就是 10 秒的裸延时。这期间程序卡在延时循环里,根本没进主循环,按键当然无效。这个延时大概率是调试阶段为了等升压板输出稳定留下的,交付时忘了删。
解决:直接把delay_ms(10000)改成delay_ms(100),或者干脆删掉。如果真的需要等电源稳定,100ms 已经非常充裕,电机电源的软启动一般在几十毫秒内完成。改完之后记得重新编译下载,别只改了没烧。
5.2 按键接在 PE 口,C8T6 上根本不存在这个端口
现象:按 KEY0、KEY1 没有任何反应,只有 WK_UP(PA0)好用;用万用表量 PE3、PE4,一直是高阻态,怎么按都不变。
原因:KEY_Init()里操作的是GPIOE,key.h里读的也是GPIOE, GPIO_Pin_3/4。但 STM32F103C8T6 是 48 脚封装,引出的端口只有 PA0~PA15、PB0~PB15、PC13~PC15,Port E 在物理上就不存在。这段代码是从 100 脚以上的大容量型号工程里搬过来的,搬的时候只改了电机部分,按键部分原封不动留着了。附带一句,GPIO_InitStructure.GPIO_Pin被连续赋值两次,Pin_3 | Pin_4也被Pin_5覆盖了,就算 Port E 存在,KEY0/KEY1 也没被正确初始化。
解决:把两个按键挪到 C8T6 上确实存在的引脚。PB0、PB1 是好选择,不和其他外设冲突,也不占用 PA2~PA5 的电机控制线。改法是三处同步改:
/* key.h:读取端改成 GPIOB */ #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) /* PA0 保留 */ /* key.c:时钟和引脚一起改,注意去掉被覆盖的那一行 */ 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);硬件上把两个按键的一端接 PB0/PB1,另一端接 GND 即可,内部上拉已经打开了。改完之后用KEY_Scan里那句KEY0 == 0判断就正常了——上拉输入松开读 1、按下读 0。
5.3 电机只嗡嗡响不转,或者一个轮子转一个不转
现象:按下正转,能听到电机里有交流声,转子微微抖动但转不起来;或者左边轮子正常,右边纹丝不动。
原因:按概率从高到低排,第一是电源功率不够。L298N 驱动两个 130 电机空载就要几百毫安,堵转瞬间能到 2A 以上,如果用的是 5V 升压到 12V 的小板子,输出电流常常只有 1A 左右,一带载电压就塌,H 桥输出不足。第二是共地没做好,L298N 的 GND 只连了电源负极没连 MCU 的 GND,逻辑电平参考点飘了。第三是电机线虚接,杜邦线插在 OUT 端子上看着插进去了,实际只碰到塑料壳。
解决:先用万用表量 L298N 的 12V 和 GND 之间电压,空载和带载各量一次,如果带载时电压掉到 6V 以下,就是电源的问题,换独立电源或者并联大电容。然后确认 MCU 的 GND 和 L298N 的 GND 之间电阻接近 0,不接近就补一根线。最后把不转的那个电机的两根线拔下来,直接怼到 5V 电源上看转不转,能转说明电机没问题,问题在模块或线序。
注意:L298N 是双极型工艺,内部有约 1.4V 的压降,输出到电机的电压永远比输入低一截。用 12V 供电时电机实际只能拿到 10V 出头,这是芯片特性,不是接线问题。
5.4 IN1 宏放进 if-else 里,else 跑到了别的地方
现象:代码里写成if (flag) IN1(High); else xxx;,编译能过,但运行结果和预期完全相反,xxx该执行的时候没执行。
原因:IN1宏展开后是if (a) GPIO_SetBits(...); else GPIO_ResetBits(...),里面自带一个else。当你把它放在外层if后面、且没加大括号时,展开结果变成if (flag) if (a) ...; else ...; else xxx;,第一个else被内层if吃掉了,外层的else就变成了孤儿,编译器要么报错要么按就近原则乱配。这就是典型的悬垂 else。
解决:两个层面都可以动手。写调用时永远给外层if加大括号,这样内层宏的else被包在块里不会外溢;或者按 3.1 节给出的方式,把宏体改成do { ... } while(0),从根上消除这个问题。我现在的习惯是两条都做,宏写成do-while,调用时加大括号,双保险。
5.5 上电瞬间电机自己抖一下,之后才正常
现象:每次给板子上电,电机都会「哒」地转个几度再停下,程序里明明没有这个动作。
原因:上电瞬间,PA2~PA5 在Motor_12_Config()执行之前处于复位默认状态,也就是浮空输入。浮空引脚的电压不确定,可能被 L298N 输入端的漏电流拉到中间电平,H 桥短暂导通。等你初始化把引脚拉低,抖动就结束了。
解决:硬件上加 10kΩ 下拉电阻,把四个 IN 引脚在 MCU 未初始化时钉在低电平。软件上则是在Motor_12_Config()里先写GPIO_ResetBits再配模式,或者把初始化提到main()的最前面,尽量缩短浮空窗口。这个抖动对空载电机无害,但装成小车后上电就往前窜一下,容易撞东西,还是值得处理。
6. 二次开发:把硬编码电平换成调速与状态机
原代码是「全速 + 三个动作」的最小可用版本,往实用方向走,最值得改的两处是速度控制和动作管理。第一处改动是把 L298N 的 ENA/ENB 从跳线帽状态改成由 STM32 的 PWM 驱动。板上 PA6、PA7 分别是 TIM3 的 CH1 和 CH2,正好空着,把它们接到 ENA/ENB 上,拔掉模块上的短接帽,就能用一个定时器同时调两路速度。
/* TIM3_CH1 -> PA6 -> ENA,TIM3_CH2 -> PA7 -> ENB,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 | RCC_APB2Periph_AFIO, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); 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 = TIM_CKD_DIV1; 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_OCPolarity = TIM_OCPolarity_High; TIM_OCInitStructure.TIM_Pulse = 0; /* 初始占空比 0 */ TIM_OC1Init(TIM3, &TIM_OCInitStructure); TIM_OC2Init(TIM3, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM3, ENABLE); TIM_Cmd(TIM3, ENABLE); }调用时传Motor_PWM_Init(999, 71):72MHz 除以 (71+1) 得到 1MHz 计数频率,再除以 (999+1) 得到 1kHz 的 PWM。频率别再往下压,800Hz 以下电机会有明显的啸叫和抖动;也别拉太高,L298N 的开关速度跟不上,高频下损耗变大、模块发烫。1kHz 到 5kHz 是比较稳的区间。
第二处改动是终止「每个 case 直接调函数」的写法,换成状态机。原代码的问题在于状态是隐含在最后一次调用的函数里的,主循环没有变量记录「现在到底在干什么」,想加个「正转 2 秒后自动停」就得在 case 里塞延时,把整个循环堵死。
typedef enum { MOTOR_STOP = 0, MOTOR_FWD, MOTOR_REV, MOTOR_BRAKE } motor_state_t; static motor_state_t s_state = MOTOR_STOP; /* 上层只改目标状态,不再直接操作引脚 */ void Motor_SetState(motor_state_t st) { switch (st) { case MOTOR_FWD: IN1(Low); IN2(High); IN3(Low); IN4(High); break; case MOTOR_REV: IN1(High); IN2(Low); IN3(High); IN4(Low); break; case MOTOR_BRAKE: IN1(High); IN2(High); IN3(High); IN4(High); break; default: IN1(Low); IN2(Low); IN3(Low); IN4(Low); break; } s_state = st; } motor_state_t Motor_GetState(void) { return s_state; }有了状态变量,就可以在while(1)里做「非阻塞定时」:记录进入当前状态的时刻,用delay_ms的替代品(基于 SysTick 计数)算差值,超时就自动切换。这样按键响应和定时逻辑在同一个循环里并存,不需要额外的定时器中断。
改造清单整理成表,按收益从高到低排:
| 改造点 | 改动文件 | 收益 | 风险 |
|---|---|---|---|
| ENA/ENB 接 PWM 调速 | motor.c、main.c | 从全速变成 0~100% 可调 | L298N 需拔跳线帽,接错会烧 |
| 引脚状态机 | motor.c、motor.h | 可回溯当前状态,支持超时逻辑 | 状态切换要防止正反转硬切 |
| 按键移植到 PB0/PB1 | key.c、key.h | 修复 C8T6 上按键失效 | 硬件要重新接线 |
| 加串口打印 | 新增 usart.c | 能在电脑上看状态和速度 | 占用 PA9/PA10 |
| 编码器测速 | 新增 encoder.c | 具备闭环基础 | 需要外部中断或定时器捕获 |
最后提醒一个我踩过的坑:正转和反转之间不要直接切。H 桥两端瞬间反接会产生很大的反向电流,L298N 虽然内部有一定保护,但频繁硬切会让模块烫得离谱,电机的减速箱齿轮也吃不消。正确做法是先切到MOTOR_STOP滑行或MOTOR_BRAKE刹车,等 100ms 左右(或者用编码器确认转速降到接近 0)再切到反向。从那以后我每次改动电机方向逻辑,都会在代码里强制走一遍「停 → 等 → 反向」的流程,再也不图省事直接赋值了。
希望这套接线和代码能帮你把第一个轮子顺利转起来,剩下的调速、闭环、避障,都可以在这条链路上继续往上长。
本文还有配套的精品资源,点击获取