☰
STM32+L298N双轮小车电机正反转控制实战
2026/10/28 14:58:23 网站建设 项目流程

简介:这份STM32F103C8T6控制L298N驱动直流电机的学习资料,面向嵌入式入门者与需要完成电机正反转实验的读者,重点解决双直流电机启停、正反转控制及L298N与单片机接线问题。压缩包内为1个PDF文件,约6.97MB,集中呈现硬件连接说明、GPIO初始化、按键扫描、电机正反转与停止控制代码,以及电源供电和调试注意事项。内容按部件、接线、代码与优化建议展开,读者可据此理解IN1~IN4电平组合与电机动作的对应关系,并参考主程序、电机驱动和按键处理代码搭建自己的控制框架。资料中还提到5V供电、升压板使用、共地连接和方向调整等实践细节,便于排查电机不转或转向异常。已有939人学习下载,适合作为课程实验、毕业设计前期验证和STM32外设练习的参考。

1. 一块板子两个轮子:L298N 这套驱动代码能跑出什么效果

很多人第一次做双轮小车,最后卡住的地方不是代码写不出来,而是接线那十分钟。这套基于 STM32F103C8T6 的例程,做的事情其实非常克制:四个 GPIO 推挽输出接到 L298N 的 IN1~IN4,两个按键负责发指令,一个按键让两个直流电机正转,一个按键让它们反转,第三个按键让它们停。没有编码器,没有 PWM,没有串口协议,就是最纯粹的方向控制闭环里最小的那一环。它能让你在半小时内看到轮子转起来、换向、刹停,从而确认「主控活着、驱动活着、电源扛得住」这三件事。适合刚上手 STM32 标准库、想拿电机练手又不想一上来就被 FOC 和 PID 劝退的人;也适合手上已经有一块 C8T6 最小系统板和一块 L298N 模块,想找一份能直接编译下载的参考代码的人。下文所有引脚、逻辑电平、代码结构都来自这份工程,我按自己复现时踩过的顺序重排了一遍。

2. 硬件链路拆解:从 PA2~PA5 到 IN1~IN4 的接线与共地

在代码之前先把线接对,否则后面所有调试都是在给错误的物理连接写注释。L298N 是一颗双 H 桥,逻辑侧要的是电平信号,功率侧要的是电机电压,这两套电源必须在 GND 上汇合,否则 STM32 输出的 3.3V 高电平对 L298N 来说没有参考点,芯片会「听不懂」。这一章把接线拆成三块:信号线、功率线、以及验证方法。

2.1 引脚映射表与接线顺序

先看原作者给的映射关系,我用一张表固定下来,接线时对着这张表一根根插,不要凭记忆。

L298N 端子STM32F103C8T6 引脚作用备注
IN1PA2电机 A 方向位 1推挽输出,3.3V 电平
IN2PA3电机 A 方向位 2推挽输出
IN3PA4电机 B 方向位 1推挽输出
IN4PA5电机 B 方向位 2推挽输出
OUT1 / OUT2电机 A 两根线功率输出方向反了就对调这两根
OUT3 / OUT4电机 B 两根线功率输出同上
ENA板载跳线帽短接电机 A 使能调速时才需要接 PWM
ENB板载跳线帽短接电机 B 使能同上
+12V外部电源正极电机功率输入常见 7~12V
GND外部电源负极 + STM32 的 GND公共地必须接,且要接牢
+5V悬空板载稳压输出见 2.2 的说明

接线顺序建议是:先断所有电,接 GND,再接 IN1~IN4,再接电机线,最后接电源。反过来接的话,插 IN 线时手指很容易蹭到 +12V 端子和 GND 之间的间距,运气不好就是一股青烟。电机线不分正负,接反了只是转向相反,不会烧,这一点可以放心。

2.2 电源与共地:最容易翻车的一环

原始记录里那句「5v 端口不接,不然很难转动」是整份材料里最值钱的一句话,值得单独解释。L298N 模块上那个标着 +5V 的端子,在跳线帽(通常标为 5V-EN 或者 U5V)短接时是板载 7805 稳压器的输出,用来给单片机供电;拔掉跳线帽后,它就变成外部 5V 输入,给 L298N 自己的内部逻辑供电。很多人把它当成「给模块供电」的入口,直接拿 5V 电源怼上去,结果就是 L298N 内部逻辑和电机共用一路被拉垮的 5V,电机一启动电压瞬间跌到 3V 出头,表现出来的现象就是轮子只抖不转、或者转得极慢,同时单片机还可能复位。

我在复现时的做法是:把跳线帽保留,+5V 端子完全不接,电机功率走 +12V 和 GND,STM32 用另一路 5V(USB 或者最小系统板自己的稳压)供电,两路电源的负极在 L298N 的 GND 端子上汇合。这一根地线是所有问题的分水岭——不接,L298N 收不到有效电平;接虚了,电机一转程序就跑飞。

原文提到手上只有 5V 电源、用了升压板升到 12V,这是可行的应急方案,但要注意升压板的输出电流能力。L298N 驱动两个空载小电机,静态电流也有几十毫安,堵转时单路能到 1A 以上,一块常见的廉价升压模块往往标称 2A 实际连续输出也就 1A 出头。空载测试没问题,一装上轮子落地就重启,八成是这里。条件允许的话,直接用 12V 电池盒或者 12V 适配器,比升压方案稳得多。

2.3 用万用表验证接线的三步法

接完线别急着上电烧程序,花两分钟做三步验证,能省掉后面半小时的怀疑人生。

第一步,断电状态下用万用表蜂鸣档量 STM32 的 GND 和 L298N 的 GND,应该导通。量 +5V 端子和 GND,不应该导通(如果导通说明跳线帽拔了但外部 5V 又接上了,短路)。

第二步,只给 STM32 上电(L298N 的 +12V 先不接),用万用表直流电压档量 PA2~PA5 对 GND 的电压,程序里初始化把这些引脚拉低,正常应该都是 0V 左右。这一步同时验证了主控在工作、引脚没被复用功能占走。

第三步,给 L298N 上 +12V,量 +5V 端子对 GND,如果是 5V 左右,说明板载稳压正常、跳线帽在位;如果测出来是 0V 或者接近 12V,说明跳线帽状态不对,先别接电机。

提示:L298N 内部是双极型晶体管 H 桥,导通压降典型值在 1.4V~2V 之间,12V 进去电机实际只能拿到 10V 出头,这是芯片的物理特性,不是接线问题。想要更高效率就得换 MOS 方案的驱动模块,但这套代码的逻辑层不用改。

3. 核心代码拆解:motor.h 四个宏把电平翻译成转向

这套工程的组织方式是很典型的标准库模板:main.c 管流程,motor.c/motor.h 管执行器,key.c/key.h 管输入,delay 提供毫秒延时。分开看每个文件都不复杂,但合在一起有几个细节值得说清楚——尤其是 motor.h 里那四个宏,它是整份代码里唯一一处「把硬件操作包装成语义」的地方。

3.1 工程文件划分

移植时最省事的做法是找一个能编译通过的空模板工程,然后把新增文件塞进去。目录结构大概是这样:

Project/ ├── CORE/ # 内核文件,模板自带,不用动 ├── STM32F10X_FWLib/ # 标准外设库源码 ├── SYSTEM/ │ ├── delay/ # 毫秒/微秒延时 │ └── sys/ # 位带操作等基础宏 ├── HARDWARE/ │ ├── motor/ # motor.c + motor.h,本次核心 │ └── key/ # key.c + key.h ├── USER/ │ ├── main.c │ ├── stm32f10x_conf.h # 头文件开关 │ └── stm32f10x_it.c # 中断入口 └── OBJ/ # 编译输出

需要新增到工程里的只有 motor.c 和 key.c 两个源文件,其余都是模板原有内容。这一步的坑在启动文件和器件宏上,留到第 4 章讲。

3.2 motor.h:宏定义把寄存器操作藏起来

先看头文件,它定义了电平别名和四个引脚操作宏:

#ifndef __MOTOR_H #define __MOTOR_H #include "stm32f10x.h" #include "stm32f10x_gpio.h" #define High 1 #define Low 0 /* 每个宏内部用 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) #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

这里的逻辑说明:宏IN1(a)接收参数 a,a 为真(非 0)时把这个引脚置高,为假时置低。参数a用的是第 2 章表里定义好的High/Low,也就是 1 和 0。之所以用宏而不是函数,是因为这四次调用在电机动作函数里是热路径,宏展开可以省掉一次函数调用的压栈开销——虽然在 72MHz 的 F1 上这点开销根本感知不到,但它带来的写法好处是代码读起来像「设置 IN1 为高」,比直接写GPIO_SetBits(GPIOA, GPIO_Pin_2)直观。

参数说明:GPIOA是端口基址,GPIO_Pin_2是引脚掩码,两者组合定位到具体引脚的 BSRR/BRR 寄存器。GPIO_SetBits写 BSRR 的低 16 位,GPIO_ResetBits写 BSRR 的高 16 位,都是单周期原子操作,不会出现读-改-写的中间态,这一点比GPIO_WriteBit更适合电机控制这种对时序有要求的场景。

原文的宏没有用do{}while(0)包裹,是直接if(a) ... \ else ...。单独调用没问题,但如果哪天写成if(cond) IN1(High); else ...,展开后 else 会跟宏内部那个 if 配对,编译器不报错,行为却完全错了。这是个老生常谈的坑,我在自己的版本里补上了——这类改动不影响功能,纯粹是给以后改代码的自己留条后路。

3.3 motor.c:初始化与六个动作函数

初始化函数只做三件事:开时钟、配引脚、拉低。

#include "motor.h" #include "stm32f10x_rcc.h" void Motor_12_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; /* PA2~PA5 挂在 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); /* 初始化后先全部拉低,避免上电瞬间电机乱转 */ GPIO_ResetBits(GPIOA, GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5); }

参数说明:GPIO_Mode_Out_PP是推挽输出,能主动输出高电平和低电平,驱动 L298N 的逻辑输入必须用这个;如果误配成开漏(GPIO_Mode_Out_OD),高电平就是高阻态,靠外部上拉拉高,L298N 输入端如果没有上拉电阻,就会一直读到低电平,表现出来的现象是「怎么按都只往一个方向转」。GPIO_Speed_50MHz是输出翻转速率,对电机方向控制来说 2MHz 完全够用,50MHz 带来的额外边沿陡度在长排线上反而容易串扰到相邻引脚——如果接线比较长,降到GPIO_Speed_2MHz更稳。

然后是六个动作函数,两两成对:

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); }

这里的逻辑说明要对照 L298N 的真值表看,单个 H 桥的两路输入组合一共四种状态:

IN1IN2电机 A 状态电气含义
00自由滑行停止两个下桥臂导通,电机两端被短接到地
01正转(或反转,取决于电机线序)左上 + 右下导通
10反转(与上行相反)右上 + 左下导通
11刹车停止两个上桥臂导通,电机两端被短接到电源

注意Motor_x_STOP()用的是IN1(High); IN2(High);,也就是真值表最后一行——这是「刹车」而不是「滑行」。刹车状态下电机两端被短接,反电动势形成回路,会产生一个阻碍转动的制动力矩,轮子会很快停下;如果改成IN1(Low); IN2(Low);就是滑行,轮子靠惯性自由转动慢慢停。做小车的话,刹车手感更干脆,但频繁刹车对 H 桥的冲击电流更大;滑行更省电、更温和。这个选择没有对错,取决于你想让小车的停車动作看起来是什么样。原作者选了刹车,我复现时保留了,因为两个小轮子惯性不大,刹车那一下的顿挫感反而更像「听话」。

3.4 main.c:主循环里的按键状态机

主函数的结构很直白:初始化、等一会、扫按键、分派动作。

#include "stm32f10x.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); /* 上电等待,见下方说明 */ KEY_Init(); /* 按键 GPIO 初始化 */ 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 占用 */ } } }

逻辑说明:KEY_Scan(0)的参数 0 表示不支持连续按,也就是按住不放只会返回一次键值,必须松开再按才会触发第二次。这个模式适合「按一下切换一个状态」的场景,正是这里需要的——按一下正转,电机就一直转,直到你按停止。如果传 1,按住正转键会每个扫描周期都返回一次键值,虽然结果一样(都是调Motor_1_PRun),但白耗 CPU。

参数说明:vu8是volatile unsigned char,volatile告诉编译器这个变量可能在中断或硬件中被改,不要优化掉。这里其实没有中断参与,加 volatile 属于习惯性写法,无害。

那句delay_ms(10000)我建议直接改掉。原意大概是上电后留出十秒缓冲,方便观察或者避开电源不稳的瞬间,但实际体验是:烧录完复位,板子毫无反应十秒钟,第一次上手的人百分百会怀疑程序没跑起来。我一般改成delay_ms(500),够电源稳定了。如果确实需要长延时(比如等某个外设上电),也应该是放在外设初始化里,而不是堵在主函数开头。

另外switch里原本每个 case 后面还有一个delay_ms(1),这个延时没有任何作用——动作函数是即时生效的,延时只会让按键响应慢 1ms。删掉。

3.5 key.c:软件消抖与优先级

按键部分用的是最经典的「检测 + 延时 + 再检测」消抖:

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); /* 机械抖动一般 5~20ms,取 10ms */ 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是静态变量,第一次调用时初始化为 1,之后每次调用都保留上次的值。按下瞬间检测到电平变化,先延时 10ms 躲过抖动,再确认一次,然后把key_up清零——这样在松手之前,即使电平还在跳,也不会重复返回键值。松手判断在else if里,三个键全部回到松开态才把标志置回 1。

参数说明:mode为 0 时是单次触发,为 1 时每次进来先把key_up置 1,等于跳过「松手才允许下次」的限制,变成按住就连发。KEY0/KEY1是上拉输入,按下读到 0;WK_UP是下拉输入,按下读到 1。这两个极性是反的,所以判断条件里一个是== 0一个是== 1,抄代码的时候这里最容易看漏。

注意这个函数里有一个隐藏的优先级:KEY0 > KEY1 > WK_UP。三个键同时按下时,只有 KEY0 会被返回。日常用不会同时按,但如果后面要加组合键功能,这个顺序就得重新设计。

还有一点,delay_ms(10)是在扫描函数内部阻塞执行的,意味着按键按下时会占用 10ms 的 CPU。这套代码主循环里没别的事,无所谓;一旦加了串口接收或者 PWM 输出,就得换成状态机 + 定时器扫描的非阻塞写法,第 6 章会给改造方案。

4. 工程搭建与烧录:从空工程到电机转起来

代码看懂了,接下来是让它真的跑在自己的板子上。这一步的坑不在 C 语言,而在工程配置——尤其是芯片型号、启动文件、宏定义这三者必须对齐,错一个就是一堆看不懂的链接错误或者运行异常。

4.1 时钟、启动文件、器件型号选择

STM32F103C8T6 属于中容量(Medium Density)产品线,Flash 64KB,RAM 20KB,48 脚,只有 GPIOA/GPIOB/GPIOC(其中 PC13~PC15 可用)。工程里必须体现这三点:

第一,Keil 的 Device 选型要选STM32F103C8,不要选成STM32F103ZE或者STM32F103RC。选错了编译能过,但下载时可能报「Cannot access target」或者下载进去跑飞。

第二,启动文件要用startup_stm32f10x_md.s(md = medium density)。如果模板是从大容量工程(比如 ZET6 的例程)拷来的,启动文件是startup_stm32f10x_hd.s,链接时中断向量表长度对不上,表现是中断进不去、或者一上电就 HardFault。

第三,C/C++ 选项卡里的全局宏定义要有STM32F10X_MD, USE_STDPERIPH_DRIVER。第一个决定标准库按哪个容量编译外设寄存器映射,第二个决定是否启用stm32f10x_conf.h里的外设头文件包含。少写STM32F10X_MD,编译会直接报一堆寄存器未定义。

这三条我列成清单,每次新建工程对着勾一遍:

[ ] Device : STM32F103C8 [ ] Startup : startup_stm32f10x_md.s [ ] Define : STM32F10X_MD, USE_STDPERIPH_DRIVER [ ] Include Paths : CORE, STM32F10X_FWLib/inc, USER, SYSTEM/delay, HARDWARE/motor, HARDWARE/key [ ] Output : 勾选 Create HEX File(用串口 ISP 下载时需要) [ ] Debug : ST-Link / J-Link / DAP 按实际选择,Port 选 SW

附带说一句,这套 stm32 软件是纯标准库工程,不需要装任何额外的插件或者包管理器,Keil 装好器件支持包(Device Family Pack)就能编译,对网络环境没有要求,离线也能干活。

4.2 编译与下载

把 motor.c、key.c 加进工程分组,把对应的头文件路径加进 Include Paths,然后按 F7 编译。正常情况下应该是 0 Error 0 Warning。如果出现undefined symbol Motor_12_Config,说明 motor.c 没加进工程或者路径不对;如果出现cannot open source input file "stm32f10x_gpio.h",说明标准库的 inc 目录没加进 Include Paths。

编译通过后,下载方式看手上有什么工具:

# 用 ST-Link 命令行工具烧录(需先装 ST-LINK Utility 或 CubeProgrammer) STM32_Programmer_CLI -c port=SWD -w OBJ/Project.hex -v -rst # 如果用 DAP-Link + OpenOCD openocd -f interface/cmsis-dap.cfg -f target/stm32f1x.cfg \ -c "program OBJ/Project.hex verify reset exit"

参数说明:-w指定要写的文件,-v表示写入后校验,-rst表示写完自动复位运行。用 OpenOCD 的话,interface/cmsis-dap.cfg是调试器配置,target/stm32f1x.cfg是芯片配置,两者都不能省。

如果手上只有串口,那就用 HEX 文件 + 串口 ISP 工具,注意 BOOT0 跳线要先拨到 1(从系统存储器启动),下载完再拨回 0,否则程序不运行。这个拨码开关忘记拨回来是新手最常见的「下载成功但不跑」的原因。

4.3 上电顺序与首次验证

第一次通电我建议按这个顺序,每一步都观察现象,出问题能立刻定位到是哪一步:

第一步,只插 USB 给 STM32 供电,不接 L298N 的 +12V。此时程序应该已经跑起来了(如果你把delay_ms(10000)改小了),用万用表量 PA2~PA5,默认都应该是低电平。

第二步,按一下正转键,量 PA2 应该变低(本来就是低)、PA3 变高(3.3V)。再按反转键,PA2 变高、PA3 变低。这一步确认了按键扫描和 GPIO 输出都是通的,跟电机没关系。

第三步,给 L298N 接上 +12V,把电机接上。再按正转,轮子应该转。如果此时单片机复位(表现为程序重新从头跑、或者按键失灵几秒),立刻断电,回到 2.2 节检查电源。

第四步,测试停止。按下停止键,轮子应该迅速停住而不是缓慢滑行——如果是缓慢滑行,说明Motor_x_STOP()里的电平写错了,回去对着真值表看。

注意:测试时不要把轮子悬空太久。空载转速高、电流小,很多电源问题在空载下看不出来,落地带载才会暴露。真要验证电源够不够,就用手轻捏轮子施加阻力,看单片机是否复位。

5. 避坑与排查:按键没反应、电机只嗡嗡响、一按就复位

这一章是我复现这套代码时真实踩过的五条,每条按「现象 → 原因 → 解决」写,对着查能省掉大量时间。

5.1 现象:编译下载都正常,但按键怎么按都没反应

原因:这份代码的按键接在 GPIOE 上(KEY0是 PE4,KEY1是 PE3),而 STM32F103C8T6 是 48 脚封装,物理上根本没有 GPIOE。工程能编译通过是因为标准库的头文件对所有 F103 型号都定义了 GPIOE 的寄存器宏,编译器不会报错,但代码执行到RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOE, ENABLE)时,这个时钟位在 C8T6 上不存在,写进去没有效果;后面读 PE4 的输入数据寄存器,读回来永远是 0,于是KEY0 == 0恒成立,KEY_Scan会一直返回KEY0_PRES(停止),表现就是「按什么键都只是停」。

解决:把按键改到 C8T6 实际存在的引脚上。我一般这么改:KEY0 用 PB0,KEY1 用 PB1,都配上拉输入、按下为低;WK_UP 保留 PA0,配下拉输入、按下为高。key.h 里的宏跟着改:

/* 适配 C8T6:去掉 GPIOE,改用 PB0/PB1 + PA0 */ #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)

key.c 里的时钟使能也要从RCC_APB2Periph_GPIOE改成RCC_APB2Periph_GPIOB。如果手头的板子上 PB0/PB1 没有焊按键,就得外接两个轻触开关,一端接引脚、一端接 GND,靠内部上拉。

5.2 现象:电机不转,只发出「嗡嗡」声或者轻微抖动

原因:L298N 的 +5V 端子和跳线帽状态错了。跳线帽短接时 +5V 是输出端,此时如果再往这个端子上灌 5V,两路电源打架,L298N 内部逻辑电压被拉低,H 桥驱动能力不足,电机只能抖。另一种情况是跳线帽被拔掉了但没接外部 5V,L298N 逻辑部分没供电,同样不转。

解决:检查跳线帽。用外部 12V 供电机、STM32 自己独立供电的场景下,跳线帽保持短接,+5V 端子悬空不接。用万用表量 +5V 对 GND,正常应该是稳定的 5V 左右;如果测出来只有 2~3V 或者随电机抖动,那就是被拉垮了,立刻断开 +5V 端子。

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

原因:电机启动瞬间的浪涌电流把共用电源拉低到单片机的复位阈值(约 2.0V)以下。根源通常是两路电源共地不良、或者升压板/电池的输出能力不够。

解决:分三步。第一,确保 STM32 的 GND 和 L298N 的 GND 用一根短而粗的线连接,不要用杜邦线串一长串。第二,电机电源和单片机电源分开走,只共地不共正。第三,在 L298N 的 +12V 和 GND 之间并一个 100μF 以上的电解电容,吸收启动瞬间的电流尖峰。这三步做完还有复位,那就是电源本身功率不够,换 12V 电池盒。

5.4 现象:上电后要等十秒才响应按键,以为程序死了

原因:main 函数开头那句delay_ms(10000)。

解决:改成delay_ms(500)或者直接删掉。如果确实需要等待某个外设稳定,用状态机 + 定时器实现,不要堵在主循环前面。

5.5 现象:两个电机转向不一致,或者跟预期相反

原因:电机线的接法没有统一。直流电机两根线不分正负,同一个方向的电流,接反了就是反转。另外两个电机如果是从不同批次买的,内部磁钢方向也可能是反的。

解决:先分别单独测试每个电机。给Motor_1_PRun(),看电机 A 转向;再给Motor_2_PRun(),看电机 B 转向。如果 B 和 A 反向,把 B 的两根线(OUT3/OUT4)对调。不要靠改代码里的电平来纠正,那样以后每换一个电机都得重新改代码。硬件的归硬件,软件的归软件。

6. 二次开发:把点动正反转改成 PWM 调速和非阻塞状态机

这套代码能用,但它的定位是「验证链路通不通」,离能用还有两段距离:一是速度不可调,二是按键扫描是阻塞的。这两点在二次开发里几乎一定会被要求改掉,下面是我实际改过的两个方向。

6.1 加 PWM 调速:把 ENA/ENB 从跳线帽接到定时器

L298N 的 ENA/ENB 是使能端,跳线帽短接时是常使能(全速)。要调速,必须先拔掉跳线帽,把这两根针接到能输出 PWM 的引脚上。C8T6 上 PA6/PA7 正好是 TIM3 的 CH1/CH2,而且跟已经占用的 PA2~PA5 不冲突,是现成的选择。

/* TIM3_CH1(PA6) -> ENA, TIM3_CH2(PA7) -> ENB, 72MHz / 72 = 1MHz 计数时钟, 1000 个计数 = 1kHz PWM */ 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); /* PA6/PA7 必须配成复用推挽,普通推挽输出不了 PWM */ 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 */ TIM_OC2Init(TIM3, &TIM_OCInitStructure); /* CH2 -> PA7 */ TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM3, ENABLE); TIM_Cmd(TIM3, ENABLE); }

参数说明:arr是自动重装值,psc是预分频。调用时传Motor_PWM_Init(999, 71),计数时钟 = 72MHz / (71+1) = 1MHz,PWM 周期 = 1000 个计数 = 1ms,也就是 1kHz。这个频率对 L298N 来说比较合适——太低(低于 100Hz)电机会有可闻的啸叫和抖动,太高(高于 20kHz)L298N 的开关损耗会明显上升、芯片发热。1kHz~5kHz 是比较稳的区间。

有了 PWM 之后,把原来的动作函数改成带速度参数的形式,速度用有符号数表示,正负代表方向:

/* speed: -1000 ~ 1000,正为前进,负为后退,0 为停止 */ void Motor_SetSpeed(s8 id, s16 speed) { if(speed > 0) { if(id == 1) { IN1(Low); IN2(High); TIM_SetCompare1(TIM3, speed); } else { IN3(Low); IN4(High); TIM_SetCompare2(TIM3, speed); } } else if(speed < 0) { if(id == 1) { IN1(High); IN2(Low); TIM_SetCompare1(TIM3, -speed); } else { IN3(High); IN4(Low); TIM_SetCompare2(TIM3, -speed); } } else { if(id == 1) { IN1(High); IN2(High); TIM_SetCompare1(TIM3, 0); } else { IN3(High); IN4(High); TIM_SetCompare2(TIM3, 0); } } }

6.2 按键改成非阻塞扫描,把延时从主循环里赶出去

原来的KEY_Scan里有一个delay_ms(10),这 10ms 里 CPU 什么都干不了。加了 PWM 之后其实影响不大(PWM 是硬件外设,不占 CPU),但如果再加串口接收、OLED 刷新,这个阻塞就会变成肉眼可见的卡顿。改法是:定时器每 5ms 中断一次,在中断里采样按键电平,用一个状态机做消抖,主循环只读结果。

验证改造是否成功的办法很朴素:按键按下的同时用示波器量 PWM 输出,看占空比切换是否干净、有没有丢帧;没有示波器就接个串口,每 100ms 打印一次当前速度和按键状态,看串口输出是否连续、有没有掉数据。改造这种东西,能观测就能调试。

最后说个我自己的习惯。每次改完电机相关的代码,我不会直接上整车测试,而是先在桌上把两个轮子悬空,依次按正转、反转、停止、再正转,看四个动作是否都符合预期,再看电流表读数有没有异常尖峰。因为电机这东西翻车成本太高——一次接线错误可能烧掉的不只是 L298N,还有那块主控。从那以后我每次动电机线之前都强制走一遍「断电 → 量通断 → 再上电」的流程,慢是慢了点,但没再烧过板子。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询