简介:面向STM32与L298N电机驱动联调场景,这份示例代码提供按键调节直流电机转速和方向的基础工程,适合嵌入式初学者、电子设计竞赛参与者以及正在学习PWM与外部中断的开发者。压缩包内共143个文件,以C源码文件和头文件为主,并包含Keil工程配置、编译中间文件与hex烧录文件,整体2.62MB,目录结构按外设模块组织,便于对照源码定位初始化、定时器与中断相关代码。示例工程覆盖GPIO模式配置、定时器PWM输出、按键中断服务函数以及L298N使能端和输入端的电平组合逻辑,同时展示了直流电机正转、反转与停止的控制流程,可以直接编译下载到开发板验证调速和换向效果。目前已有5735人学习下载,对于希望快速跑通STM32电机控制例程的开发者而言,这套代码既适合课堂实验,也适合课后扩展,对照例程逐步修改,可以更清楚地理解寄存器操作、PWM占空比调节与硬件协同工作的细节,有效减少自行摸索的时间。 一块L298N电机驱动模块、一块STM32最小系统板、两个直流减速电机,这几乎是所有入坑智能小车的人第一套标准配置。搜STM32 L298N代码,你能找到上百篇博客,可真正照着接线、烧录、上电之后,我发现很多帖子只贴了一段能动的代码,却把最关键的接线细节和调试思路一句带过。这篇文章会从L298N到底帮你干了什么说起,把电源规划、PWM引脚选择、HAL库代码写法、以及电机不转时该怎么一步步定位问题全部捋一遍。不管是刚拿到板子的新手,还是想优化小车控制逻辑的人,这套思路都适用——它解决的不只是“让电机转起来”,而是让你以后遇到任何电机驱动问题,都能自己找到根因。
1. 先搞明白L298N到底在替你做什么
1.1 H桥原理:为什么GPIO不能直接驱动电机
STM32的GPIO拉电流和灌电流能力大约在20mA级别,用来点个LED很轻松,但直流减速电机的空载电流动辄几百毫安,堵转时甚至能到一两安培。电流不够只是问题之一,麻烦的是电机转向需要反向电流,而GPIO只能输出相对于GND的正向电压。L298N内部的两个H桥,正好一次解决这三件事:电压电流放大、方向切换、续流保护。
所谓H桥,就是四个功率开关管排成“H”字形,电机接在中间一横上。控制对角两个开关管同时导通,电流正向流过电机;换另一对对角导通,电流反向流过电机。通过IN1、IN2两个引脚控制这四个开关的组合,就能得到正转、反转、刹车三种状态。下面是电机A的逻辑真值表,电机B的IN3、IN4同理:
| IN1 | IN2 | 电机状态 |
|---|---|---|
| 1(高) | 0(低) | 正转 |
| 0(低) | 1(高) | 反转 |
| 0(低) | 0(低) | 停转(惰性滑行) |
| 1(高) | 1(高) | 刹车(强力制动) |
很多人不知道IN1和IN2同为高电平时,L298N内部会把电机两端短接,相当于用电机自身的反电动势强行制动。这个特性在需要急停的场合比直接断电好用得多,我做循迹小车时就让急停逻辑用这个状态,比单纯给0停得更干脆。
1.2 模块上那两组跳线帽的隐藏含义
L298N成品模块上有两个关键跳线,很多人上来就忽略了。第一个是ENA、ENB使能跳线:跳线帽插上时,对应通道一直被内部逻辑拉高使能,电机无法用PWM调速,只能全速正转或反转;只有拔掉跳线帽,从STM32的PWM引脚引线到ENA引脚,才能写入占空比实现调速。
第二个是5V使能跳线,它控制板载5V稳压器是否给逻辑电路供电。插上时,从VS输入的主电源经过板载LDO降压到5V,既给L298N内部逻辑用,也可以从5V端子引出给外部单片机供电。不插时,需要在5V/VSS端子额外接一个5V电源。
这里有个经典误区操作:有人为了省事,把两个跳线帽都留着,结果发现PWM调速完全没反应。原因就是ENA跳线把使能引脚锁死在有效电平,PWM信号根本没进到驱动芯片内部。所以一旦决定用PWM调速,第一步就是拔掉ENA、ENB的跳线帽。这个小动作,能省掉后续一小时的排查。
2. 接线之前必须搞清楚的三件事
2.1 共地:大多数玄学故障的真正源头
GPIO输出的电平,是相对于STM32板上GND的电压差。L298N判断输入引脚是高还是低,也是相对于自己GND的电压差。如果两边GND没有连在一起,单片机的3.3V信号在L298N看来就是一个悬浮的、无法比较的电平,电机自然没法正常动作。
我接手过不少“电机不转”的求助案例,里面有一大半最后都查到共地上。所以接线顺序我建议固定为:先把STM32的GND、L298N的GND、电机电源负极三者短接,再谈信号线。只要共地接对,后面即使有问题也容易定位。另外,L298N的输入高电平阈值大约是2.3V,STM32的3.3V逻辑电平理论上能触发,但噪声余量不大,所以信号线尽量短一些,不要和电机动力线绑在一起走。
2.2 PWM引脚不是随便挑的:先看定时器通道
STM32的PWM不是“任意GPIO”都能输出的,而是挂在定时器通道上的复用功能。用CubeMX配置时,想让某个引脚输出PWM,必须先找到该引脚对应的定时器通道。拿最常见的STM32F103C8T6来说,一个比较省心的方案是用TIM2的CH1、CH2输出两路PWM,对应引脚PA0、PA1,方向控制IN1~IN4接PB0~PB3这几个普通GPIO。
| 定时器 | 通道 | GPIO引脚 | 备注 |
|---|---|---|---|
| TIM2 | CH1 / CH2 | PA0 / PA1 | 最常用,引脚好找 |
| TIM2 | CH3 / CH4 | PA2 / PA3 | 同上,可做备用通道 |
| TIM3 | CH1 / CH2 | PA6 / PA7 | 注意可能被ADC或其他外设占用 |
| TIM3 | CH3 / CH4 | PB0 / PB1 | 我常用这一组做方向引脚 |
| TIM4 | CH1 / CH2 | PB6 / PB7 | I2C复用引脚,需确认没做其他用途 |
建议在飞线之前,先拿CubeMX把引脚规划好,免得等代码写完了才发现ENA接到了PA15这种没有定时器通道的引脚上,只能飞线重接或者改代码,白白浪费时间。
2.3 电源分配:电池、驱动板、单片机三者怎么喂
L298N的VS端接电机主电源,建议电压范围6~12V。电池电压选多少,直接决定电机实际能拿到多少电压:L298N内部饱和压降通常有1.5~2V,用7.4V锂电池时,电机端实际最多也就5.5~6V,所以很多小车用两节18650(标称7.4V)驱动6V额定电机是合理的。
逻辑供电有两个方案:插上5V跳线由板载LDO从VS降压供电,或者不插跳线外部给5V。我自己的习惯是,小功率实验(普通TT马达、电源电流2A以内)可以用板载5V顺便给STM32板子的5V引脚供电;一旦电机功率上来,板载LDO会热得烫手,这时就得把逻辑供电独立出来。
另外一个几乎必踩的坑:如果用同一个电源同时给电机和单片机供电,电机启停瞬间的电流冲击会让电压骤降,轻则单片机复位,重则程序卡死。解决办法是电机侧单独供电,或者至少在电机电源两端并上470uF以上电解电容加0.1uF陶瓷电容,吸收瞬态冲击。
3. 代码实现:从GPIO翻转到PWM调速
3.1 定时器初始化:PWM频率是怎么算出来的
我用HAL库,以STM32F103C8T6、系统时钟72MHz为例。先看初始化代码:
static void MX_TIM2_Init(void) { TIM_OC_InitTypeDef sConfigOC = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 72 - 1; // 72MHz / 72 = 1MHz 计数频率 htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 1000 - 1; // 1MHz / 1000 = 1kHz PWM htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_PWM_Init(&htim2); sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 0; // 初始占空比0 sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_2); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_2); }这里的计算不复杂:72MHz经过Prescaler的72分频得到1MHz计数频率,计数器每计满1000个数溢出一次,输出PWM就是1kHz。把Period设成1000还有一个隐藏好处——占空比数值和百分比直接对应,__HAL_TIM_SET_COMPARE传500就代表50%占空比,写控制逻辑时完全不用来回换算。
为什么用1kHz而不用更高的20kHz?L298N是线性驱动芯片,开关频率太高会增加开关损耗、发热变大;频率太低电机会发出明显“滋滋”声且运转不平顺。1kHz是我做直流电机驱动常用的折中值,如果你在意的场合对噪音敏感,可以试试5kHz左右,但要确认芯片温度在可接受范围。
3.2 方向控制与调速解耦:写一个清爽的电机函数
电机控制直观的写法,是把方向和速度分开传参。下面这个函数把IN引脚状态和PWM占空比封装在一起:
#define MOTOR_A_IN1 GPIO_PIN_0 #define MOTOR_A_IN2 GPIO_PIN_1 #define MOTOR_A_EN TIM_CHANNEL_1 void MotorA_Set(int speed) // speed范围: -1000 ~ 1000 { if (speed > 0) { HAL_GPIO_WritePin(GPIOB, MOTOR_A_IN1, GPIO_PIN_SET); HAL_GPIO_WritePin(GPIOB, MOTOR_A_IN2, GPIO_PIN_RESET); } else if (speed < 0) { HAL_GPIO_WritePin(GPIOB, MOTOR_A_IN1, GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOB, MOTOR_A_IN2, GPIO_PIN_SET); } else { HAL_GPIO_WritePin(GPIOB, MOTOR_A_IN1, GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOB, MOTOR_A_IN2, GPIO_PIN_RESET); } int duty = speed > 0 ? speed : -speed; __HAL_TIM_SET_COMPARE(&htim2, MOTOR_A_EN, duty); }正数正转、负数反转、0停止,三个状态一个函数搞定。主程序里调用MotorA_Set(600)就是60%占空比正转,非常直观。如果有两个电机,复制粘贴改成TIM2的CH2和IN3、IN4即可,但要注意GPIO引脚别搞混。电机B的完整函数写法与A几乎一样,就是把通道换成TIM_CHANNEL_2,IN引脚换成PB2、PB3。
3.3 最小可运行Demo:先证明链路是通的
第一次上电,别急着写循迹、蓝牙那些复杂逻辑,先让电机按固定模式正转、反转、停止循环,验证整个链路:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_TIM2_Init(); while (1) { MotorA_Set(800); MotorB_Set(800); HAL_Delay(2000); MotorA_Set(-800); MotorB_Set(-800); HAL_Delay(2000); MotorA_Set(0); MotorB_Set(0); HAL_Delay(1000); } }如果这个Demo跑通了,说明GPIO、PWM、接线、电源全部正常,之后的复杂功能都是在这个骨架上叠加。如果跑不通,别急着改代码,进入下一章的排查链路,按顺序查一遍。
4. 实测中的常见坑与排查链路
4.1 电机完全不转:按顺序验证四个环节
遇到完全不转,我的排查顺序是电源→方向→使能→PWM,别跳步。
第一步看L298N板上的电源指示灯有没有亮。不亮,先查主电源是否接反、电压是否正常。第二步用万用表量IN1对GND的电压,程序里让电机正转时,IN1对地应该有3.3V左右;如果量到0V,说明GPIO没输出高电平,检查引脚配置是不是写错了、代码有没有真的跑进去。第三步量ENA引脚,如果一直为低,说明PWM没Start或者引脚选错。第四步做排除法:直接给IN1接3.3V、IN2接GND,如果电机能转,问题就锁定在单片机侧,和驱动模块无关。
还有个容易被忽略的现象:空载时电机转得正常,放到地上带负载就不转了。这种情况基本不是逻辑问题,而是电源带不动。两个电机同时启动时的电流可能到2A以上,电池容量不够或者导线太细,电压瞬间被拉垮,电机自然无力。
4.2 只有一侧电机转:优先怀疑未被启动的通道
这种问题通常不是硬件坏了,而是代码配置不对称。最常见的是初始化时只对CH1调用了HAL_TIM_PWM_Start,CH2忘了Start,于是右侧电机只有方向信号没有PWM,表现为完全不动或者速度不受控。检查方法很简单:把两个电机的接线对调,如果故障跟着电机走,说明电机或接线有问题;如果故障还停留在原来的通道,说明是驱动电路或代码问题。
另外一个容易踩的点是CubeMX里只勾了一个通道也生成了配置,另一个通道完全没初始化。所以跑Demo前,瞄一眼生成的MX_TIM2_Init里是不是两个Channel都有HAL_TIM_PWM_ConfigChannel和HAL_TIM_PWM_Start。
4.3 一加速就复位:电源跌落问题,不是代码问题
最刺激的现象是:占空比一拉高,STM32立刻重启。我第一次遇到时查了半天代码,后来才发现是电源问题。当时给STM32供电的5V和电机驱动逻辑共用了一条电源通路,电机大电流一拉,电压瞬时跌到单片机复位阈值以下,表现出来就是“一跑就重启”。
三个解决方案可以组合使用:给STM32独立供电;在电机电源两端并联470uF~1000uF电解电容和0.1uF陶瓷电容;在代码里做斜坡加速而不是直接给满占空比。三个都做,基本能彻底解决这个顽疾。
4.4 烧录报错别慌:排查顺序和电机无关
这里顺带说一个做小车时很常见、但和驱动本身没关系的问题——程序烧不进去,出现类似error: no stm32 target found的报错。我遇到过几次,最后定位到Boot0引脚悬空受到外部干扰。解决办法:把Boot0用10k电阻拉低,重新上电,然后用ST-Link的connect under reset方式烧录——在点击下载的瞬间按住复位键、等连接建立后再松开。
做小车经常要反复烧录,如果每次都要拔线才能下载,说明下载接线或Boot配置有问题。把这个问题一次性解决,能省掉大量调试时间。
5. 从“能转”到“能跑”:把代码扩展成一辆可操控的小车
5.1 差速转向:一个函数搞定各种走法
L298N两个通道天然支持差速控制。想让小车直行就左右同速;想原地左转就左轮反转、右轮正转;想让左转半径大一点,左轮慢速正转、右轮快速正转即可。把运动模型抽象成左右轮速度之后,控制代码会非常简洁:
void Car_Set(int leftSpeed, int rightSpeed) { MotorA_Set(leftSpeed); MotorB_Set(rightSpeed); } Car_Set(800, 800); // 直行 Car_Set(-800, 800); // 原地左转 Car_Set(300, 800); // 左转弯,弧线较大这个函数后续接蓝牙遥控、循迹传感器、PID调速时都不用再改,因为底层已经把电机控制封装好了。我后来做带编码器的闭环小车时,只改MotorA_Set内部的占空比计算逻辑,对外接口完全不变。
5.2 斜坡加速:让起步柔和一点
直接给满占空比,电机瞬间电流是正常运行的几倍,对电池和齿轮箱都不友好。我习惯加一段软件斜坡,让占空比在几十毫秒内逐步增加:
void MotorA_RampTo(int target, int step) { int current = __HAL_TIM_GET_COMPARE(&htim2, MOTOR_A_EN); while (current != target) { if (target > current) current += step; else current -= step; if (current > 1000) current = 1000; if (current < 0) current = 0; __HAL_TIM_SET_COMPARE(&htim2, MOTOR_A_EN, current); HAL_Delay(10); } }用法不复杂:先调用MotorA_Set(800)把方向设好,再调用MotorA_RampTo(800, 50)让占空比从当前值以每10ms增加50的速度升上去。实测下来,同样一组电池,斜坡启动比直接满占空比启动的电压跌落明显更小,车子的起步姿态也稳很多,不会一蹿一蹿的。
最后分享一个我自己的调试习惯:所有电机项目都会保留一个固定占空比测试模式,上电后不接按键、不接串口,电机先按某个固定速度转几秒。这个模式看着简陋,却能帮你排除掉一大半干扰因素。做小车做得越多,越觉得L298N这套方案真正难的从来不是那几行驱动代码,而是你愿不愿意把电源、接线、初始化、现象验证这些基础环节一个个仔细过一遍。把这套“分模块验证”的思路记住,比抄一百段代码都管用。
本文还有配套的精品资源,点击获取