简介针对STM32与L298N电机控制应用场景代码包提供可直接运行的Keil工程代码适合正在学习嵌入式电机驱动、需要用按键调速调向的开发者。压缩包共143个文件以h头文件、c源文件、o目标文件为主另有hex烧录文件、uvprojx工程配置、map映射文件等可清晰查看模块划分重新编译并下载到开发板验证。资源整体仅2.62MB目前已有5739人学习下载内容覆盖GPIO输入输出初始化、定时器PWM占空比调节、按键中断处理以及L298N输入引脚控制信号切换能帮读者系统理解电机正反转与调速原理。示例工程中还包含标准外设库驱动和完整用户逻辑便于对照代码梳理从按键检测到电机响应的完整流程也可直接作为基础框架复用到实际项目中。对于需要快速完成课堂设计或入门电机驱动的读者尤其有参考价值。1. 为什么这份 STM32 L298N 代码能让你少踩三天坑第一次拿 L298N 驱动直流电机我照着网图把 IN1~IN4 接上电机上电就全速转PWM 怎么调都没反应。后来才明白正反转和调速是两回事IN 脚决定方向ENA 脚的 PWM 才决定速度。这份 STM32 L298N 代码把这两个维度拆开做成了独立函数传方向值和速度值就能让电机按预期转。工程基于 STM32F103C8T6HAL 库例程结构把定时器 PWM 输出、方向 GPIO 组合、四象限调速从头到尾封装好。无论是两驱小车、智能车底盘还是机械臂关节都能直接套这套驱动逻辑。适合正在学电机控制、被 L298N 接线折磨过的同学照着接线下载就能跑通还能顺着注释改出自己的驱动模块。2. 硬件接线先分清 L298N 的 12V、5V 与 GND再谈代码2.1 L298N 端口速览为什么它要接三路电源L298N 是双 H 桥驱动板不是逻辑芯片正常工作时需要同时喂三路东西电机电源、逻辑电源和参考地。板上最粗的接线柱是电机电源常见标法 VCC/VMS/12V接电池组给电机供电旁边的 GND 是整个系统共地再往边上是 5V 输出脚许多板子带稳压器能从电机电源降出 5V 给外部逻辑电路用。这个 5V 脚也是最容易翻车的点。如果你从 12V 主电源供电且板上稳压器能输出 5V那你可以把 5V 接到 STM32 的 5V 引脚但板子发热会明显因为电流经过了线性稳压。更稳的做法是电机用独立电池逻辑部分用 MCU 板载 USB 或 3.3V 系统供电然后把 MCU 的 GND 和 L298N 的 GND 牢牢连在一起。三条线接错一条现象都类似指示灯亮、电机不转、单片机会莫名复位。2.2 一个能跑的最小接线表STM32F103C8T6 引脚分配我在这份工程里用的引脚是一个很保守的组合定时器 TIM3 的 CH1/CH2 输出 PWM给到 L298N 的 ENA/ENB 脚方向脚用普通 GPIO。这一点很关键——很多人刚上手会把 PWM 接到 IN 脚把使能脚悬空结果电机要么不转要么全速转。功能STM32 引脚定时器通道L298N 接口说明左电机 PWMPA6TIM3_CH1ENA占空比控制左电机速度左方向 APB0-IN1与 IN2 组合决定正反转左方向 BPB1-IN2与 IN1 组合决定正反转右电机 PWMPA7TIM3_CH2ENB占空比控制右电机速度右方向 APB2-IN3与 IN4 组合决定正反转右方向 BPB10-IN4与 IN3 组合决定正反转有两点要说明。PB2 在不少最小系统板上被拉成 BOOT1代码里必须把它复用为普通 GPIO 输出否则可能出现方向脚不受控。如果还不放心可以把 IN3 换到 PB11PB10/PB11 是 USART3 的默认复用引脚在小车上没用到串口三时完全没冲突。接线顺序建议是这样的先在掉电状态下接好电机线再把 STM32 的 PA6、PA7、PB0、PB1、PB2、PB10 对应连到 L298N 的 ENA、ENB、IN1、IN2、IN3、IN4最后接共地线。先接信号线再接电源的好处是避免在 MCU 未初始化时L298N 的逻辑侧先上电造成 I/O 口电平冲突这也算是我之前换过三块板子换出来的血泪经验。2.3 电源走线与共地细节电机启动瞬间电流大如果 STM32 和电机共用一个电源很容易在启动瞬间压降超过 MCU 复位阈值。常见做法是给 L298N 单独接 7.4V 锂电池STM32 用 USB 或 DC-DC 隔离供电然后只在一点把两块板的 GND 连起来。共地是 H 桥驱动的底线不共地时 IN 脚电平没有参考电机转不转完全看运气。如果实在只能共用一个电源我一般会在 STM32 的电源入口多加两个电容一个 100uF 电解电容扛瞬时压降一个 0.1uF 陶瓷电容滤高频噪声。电机线最好绞起来越短越好避免长线引入电感噪声干扰到定时器计数那些“转速偶尔跳一下”的玄学问题有一半是这个原因。3. 核心代码拆解一个最小闭环从 GPIO 到电机转起来的完整链路3.1 定时器 PWM 初始化让 ENA/ENB 真正“使能”L298N 的 ENA/ENB 是使能脚给它 PWM 才能调速。这份代码里用 TIM3 产生两路 PWM频率选在 1kHz。计算方式很简单STM32F103 内部主频 72MHz预分频 PSC 设为 71得到 1MHz 计数时钟自动重装值 ARR 设为 999于是 PWM 频率就是 1MHz / 1000 1kHz占空比 CCR 的范围则落在 0~1000。void MX_TIM3_Init(void) { TIM_OC_InitTypeDef sConfigOC {0}; htim3.Instance TIM3; htim3.Init.Prescaler 71; // 72MHz / (711) 1MHz htim3.Init.CounterMode TIM_COUNTERMODE_UP; htim3.Init.Period 999; // 1MHz / (9991) 1kHz htim3.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_PWM_Init(htim3); sConfigOC.OCMode TIM_OCMODE_PWM1; sConfigOC.Pulse 0; // 初始占空比 0防止上电即转 sConfigOC.OCPolarity TIM_OCPOLARITY_HIGH; HAL_TIM_PWM_ConfigChannel(htim3, sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_ConfigChannel(htim3, sConfigOC, TIM_CHANNEL_2); }这段代码的作用是让 TIM3 的两个通道以 PWM1 模式工作Pulse 0意味着上电瞬间输出低电平L298N 的输出端没有驱动电机不会偷跑。这一点很重要很多例程只初始化了定时器却忘了先把 CCR 置零导致电机一上电先冲一下底盘如果没固定就直接飞出去了。1kHz 对 L298N 来说频率偏低但稳定肉眼观察没有明显啸叫实测 500Hz 以下电机噪声大20kHz 以上开关损耗明显1kHz 是个省心区间。3.2 方向控制函数IN1/IN2 的组合逻辑有了 PWM 输出接下来要让电机“知道”往哪转。L298N 的 H 桥由 IN1、IN2 两个脚结合起来控制逻辑表如下状态IN1IN2输出效果正转10电机正转反转01电机反转刹车11输出端短路电机急停停止00输出端悬空电机自由滑行我把这套逻辑封装成了一个入口函数上层业务只关心要哪个电机、以多大的速度、是否刹车不需要知道引脚具体是哪个。// motor.c 中的调速函数 void motor_set_speed(uint8_t motor_id, int16_t speed, uint8_t brake) { uint16_t duty 0; if (brake MOTOR_BRAKE) { set_in(motor_id, 1, 1); // 两个 IN 脚全拉高短路制动 set_pwm(motor_id, 0); // 同时把 PWM 占空比清 0 return; } if (speed 100) speed 100; // 限幅避免超量程 if (speed -100) speed -100; if (speed 0) { // 正转IN11IN20 set_in(motor_id, 1, 0); duty (uint16_t)speed; } else { // 反转IN10IN21 set_in(motor_id, 0, 1); duty (uint16_t)(-speed); } set_pwm(motor_id, duty * 10); // 把 0~100 映射到 0~1000 }逻辑说明speed的符号决定方向绝对值决定占空比比例brake信号优先于速度一旦刹车就走短路制动不再输出 PWM。duty * 10是因为外部传参按 0~100 的百分比来理解更符合直觉而定时器 CCR 需要的是 0~1000 的值。配套的两个底层函数set_in和set_pwm也很直白。set_in切换两个 GPIO 的电平set_pwm直接修改定时器比较寄存器不需要停掉定时器CCR 是硬件影子寄存器下一周期自动生效所以换向过程非常快。// 以左电机为例的 set_in / set_pwm 实现 static void set_in(uint8_t motor_id, uint8_t a, uint8_t b) { if (motor_id MOTOR_LEFT) { HAL_GPIO_WritePin(IN1_GPIO_Port, IN1_Pin, a ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(IN2_GPIO_Port, IN2_Pin, b ? GPIO_PIN_SET : GPIO_PIN_RESET); } else { HAL_GPIO_WritePin(IN3_GPIO_Port, IN3_Pin, a ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(IN4_GPIO_Port, IN4_Pin, b ? GPIO_PIN_SET : GPIO_PIN_RESET); } } static void set_pwm(uint8_t motor_id, uint16_t duty) { if (motor_id MOTOR_LEFT) { __HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, duty); } else { __HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_2, duty); } }这里有个细节set_in用了大写宏IN1_GPIO_Port是 HAL 库根据 CubeMX 配置生成出来的改引脚时只需要重新生成gpio.c不用动motor.c。这也是我把驱动拆分出来的原因机械结构怎么变逻辑层始终稳定。3.3 主循环里的控制逻辑刹车、换向与渐加速工程里附带了一个演示 demo在main.c的主循环前顺序执行用来验证桥路和 MCU 配置是否都正确。测试流程是原地静止、左转、右转、刹车、慢速启动。int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_TIM3_Init(); motor_init(); motor_set_speed(MOTOR_LEFT, 50, 0); // 左电机正转 50% motor_set_speed(MOTOR_RIGHT, 50, 0); // 右电机正转 50% HAL_Delay(2000); motor_set_speed(MOTOR_LEFT, -50, 0); // 左电机反转原地转向 motor_set_speed(MOTOR_RIGHT, -50, 0); HAL_Delay(2000); motor_set_speed(MOTOR_LEFT, 0, MOTOR_BRAKE); // 刹车 motor_set_speed(MOTOR_RIGHT, 0, MOTOR_BRAKE); HAL_Delay(500); for (uint8_t duty 0; duty 100; duty 5) { motor_set_speed(MOTOR_LEFT, duty, 0); // 渐加速 motor_set_speed(MOTOR_RIGHT, duty, 0); HAL_Delay(100); } while (1) { } }跑这个 demo 时重点观察两件事第一刹车时电机是不是立即停下如果还能滑一段距离说明 IN1/IN2 没有同时拉高或者你连到的其实不是同一个电桥的输出。第二50% 和 100% 的转速差异是否明显如果 L298N 的使能跳线帽还插着PWM 会被旁路电机永远是全速转那就需要把跳线帽调整到和 PWM 输入共存的位置不同板子的跳线布局不一样以盖子丝印为准。4. 避坑指南L298N 驱动板最常翻车的 5 个问题以下问题是我在实际调试中遇到的每条都按“现象、原因、解决”的顺序写排查时按这个顺序看一般十分钟能定位。4.1 现象板上电指示灯亮但电机咔咔响就是不转原因STM32 与 L298N 之间没有共地。H 桥的逻辑电平是以 GND 为参考的MCU 输出 3.3V 高电平在 L298N 眼里这个高电平是“悬空”的阈值判断混乱于是出现电磁阀一样的高速开关电机发出咔咔声却无力。解决把 STM32 的 GND 和 L298N 的 GND 用一根杜邦线连起来然后再加一根粗一点的线接到电源负极。共地之后如果还响检查 IN 脚本是否被其他模块占用。4.2 现象刚上电时电机不受控制地“突突”一下原因MCU 引脚在初始化前是高阻态L298N 的 IN 脚内部有下拉电阻但使能脚 ENA/ENB 通过板载跳线帽接到了 5V一上电 H 桥就开了此时 IN 脚的电平还没定义导致随机转向。解决代码层面把定时器 CCR 初始值设为 0前面我已经做了同时在上电后第一时间调用motor_init()把所有 IN 脚输出为 0。硬件层面如果不需要让使能脚悬空可以直接把跳线帽换成排线连接 PWM 输出避免默认上拉。顺序是先烧录、再上电、最后接电机能避免八成这类小惊吓。4.3 现象PWM 占空比越高转速反而往下掉原因电源容量不足。L298N 是线性 H 桥输出端压降大电机拉到高占空比时电流猛增电池电压瞬间被拉低MCU 和 L298N 的逻辑电路也跟着掉电相当于芯片在“供血不足”下工作转速自然下降。解决换更大容量的电池或在电源输出端并联一个 470uF 电容。我试过在 7.4V 锂电池上并联一组超级电容启动压降从 0.8V 降到 0.2V整个系统稳定很多。如果一高占空比就复位优先怀疑的是复位电路掉电而不是代码逻辑。4.4 现象电机只能正转反转时不动或抖动原因L298N 同侧电机的两个 IN 脚接反了。正转时 IN11、IN20如果杜邦线接成 IN1 实际管的是 IN2那正转逻辑实际驱动的是“反转”另一条逻辑则让两个脚同为 1 或同为 0变成刹车或停止。解决用万用表量一下 L298N 丝印上的引脚标号和杜邦线颜色最好按位置关系固定接线避免交叉。软件上也可以在set_in里加一个断言把同种电机两个脚的电平打印出来快速判断哪个脚没有翻转。4.5 现象电机突然自己加速程序里没改任何东西原因大概率是 PWM 引脚与调试工具共用了定时器通道例如用 SWD 调试时SCK/PA14 或 PA13 被配置到同一组功能调试器访问内存时把 CCR 寄存器动了造成偶发占空比跳变。另一个常见点是 PB2 作为 BOOT1 被外部拉高导致电机方向脚波动。解决把调试口和 PWM 口分开PA6/PA7 天然避开 SWD 引脚是最稳妥的选择。方向脚不要选 PA13/PA14/PA15那是调试端口。如果你已经用了 PB2确认代码里把它复用为普通输出并且外部没有下拉电阻把电平带偏。5. 工程结构剖析驱动层与业务层是如何拆开的5.1 资源包目录与文件职责解压这份工程包之后会看到一类标准 HAL 库项目结构。我把核心注意力放在两处Hardware目录是电机驱动二次开发的主要改点User/Src/main.c是功能演示入口。stm32-l298n/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ └── stm32f1xx_hal_conf.h │ └── Src/ │ ├── main.c │ ├── gpio.c │ └── stm32f1xx_it.c ├── Drivers/ │ ├── CMSIS/ │ └── STM32F1xx_HAL_Driver/ ├── Hardware/ │ ├── motor.c │ └── motor.h ├── MDK-ARM/ │ ├── project.uvprojx │ └── project.uvoptx └── Readme.mdHardware/motor.c是整个项目里最核心的驱动文件内部实现 PWM 启动、方向控制、刹车控制三个功能。Core/Src/gpio.c负责引脚模式配置把所有方向脚设置为推挽输出上拉速度拉到高速。Core/Src/main.c只保留系统时钟初始化和 demo 流程不让业务逻辑污染驱动层。我刻意把Hardware从Core里拆出去是因为小车项目的迭代速度通常很快今天用 STM32F103明天可能换 F407或者改成用普通 GPIO 模拟 PWM 的切板调试。把驱动层独立出来迁移时只需要改motor.c内部实现接口保持稳定上层和其他模块无需翻动。5.2 接口层设计motor.h 的对外函数.h头文件是驱动层对上层唯一的契约只暴露必要的函数和数据宏。我在工程里保留了最精简的接口避免新人被十几个函数吓住。#ifndef __MOTOR_H #define __MOTOR_H #include stdint.h #define MOTOR_LEFT 0 #define MOTOR_RIGHT 1 #define MOTOR_BRAKE 1 #define MOTOR_FREE 0 void motor_init(void); void motor_set_speed(uint8_t motor_id, int16_t speed, uint8_t brake); void motor_set_all(int16_t left_speed, int16_t right_speed, uint8_t brake); #endif说明一下每个接口的定位。motor_init启动定时器 PWM 并把所有方向脚设为 0必须在任何调速调用之前执行motor_set_speed是单电机调速入口motor_set_all是一次性设置双电机这个函数在底盘控制里很有价值——很多业务场景是“左轮 50、右轮 30”如果分两次调用中间会有一个极短的使能间隔高速运行时能感觉到轻微摆动。数据流大概是这样的上层函数比如按键遥控、PID 计算先算出目标速度然后调用motor_set_speed函数内部根据速度符号确定 IN1/IN2 电平再把占空比写入__HAL_TIM_SET_COMPARE定时器硬件自动输出到 L298N 的 ENA/ENB 脚最终驱动 H 桥。整条链路都是同步的没有中断回调也不涉及消息队列对初学者来说逻辑非常直白调试时打断点看到的每一步都和预期一致。5.3 改动引脚时要注意的联动位置想换引脚很多人只改了gpio.c里的初始化结果motor.c里用的宏没更新编译报错甚至编译过了但引脚打架。这里有两个联动位置必须同步。第一处是motor.c中的接口宏比如IN1_GPIO_Port、IN1_Pin这些宏由 CubeMX 根据项目配置生成如果你重新生成了工程它们会被自动更新如果是手写工程则需要自己维护。第二处是定时器通道映射如果 PWM 从 TIM3_CH1 换到 TIM4_CH1motor.c里的htim3要换成htim4同时配置定时器 init 的 GPIO 复用功能。// 如果改用 PB6/PB7 输出 PWM引脚初始化示例 GPIO_InitStruct.Pin GPIO_PIN_6 | GPIO_PIN_7; GPIO_InitStruct.Mode GPIO_MODE_AF_PP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, GPIO_InitStruct);这是因为 STM32 的定时器 PWM 输出必须使用复用功能引脚普通推挽输出是输出不了 PWM 波形的。这也是常见的“我代码明明写了 CCR 为什么不输出”的原因。6. 二次开发从开环调速到闭环 PID 的速度环改造拿到这套代码最值得做的下一步改造是加编码器让电机从“开环给占空比”变成“按目标速度稳定”这一步做完不管是跑直线还是走迷宫都是质的提升。先在硬件上给电机装霍尔编码器利用 STM32 的 TIM2 编码器模式读取 A/B 相脉冲。读到的计数值需要定时采样算速度我用了一个 10ms 的定时器中断每次进入中断读一次计数差然后调用 PID 更新。// 速度环更新示例10ms 执行一次 void speed_loop_update(void) { int16_t real_speed encoder_count_delta(); // 当前周期脉冲数 int16_t err target_speed - real_speed; integral err; if (integral 500) integral 500; // 积分限幅 if (integral -500) integral -500; output 0.8f * err 0.2f * integral; // 实际项目建议写成增量式 last_output output; if (output 100) output 100; if (output -100) output -100; motor_set_speed(MOTOR_LEFT, output, 0); }这个片段重点强调的是“速度采集要稳”。我用的是带符号的脉冲差值避免连续两次采样之间计数器溢出导致方向判断错误。PID 参数可以先设 Kp 为 0.8、Ki 为 0.2、Kd 先置 0跑出来如果电机高频震荡就把 Kp 降到 0.4如果响应慢且稳态误差偏大则缓慢加 Ki。调参时不要同时动两个参数每次只改一个否则你根本分不清是哪个参数在起作用。改造完闭环以后我每次给小车换底盘或换轮子都会重新标定一次速度环先打印目标速度和实测速度的波形确认稳态误差再跑一段 50% 目标速度的时间关系观察积分项是否收敛。从那以后每次接完新电机我都强制自己先跑一遍开环功能测试再合入速度环这个习惯帮我拦下了至少三次接线错误。希望帮到你。本文还有配套的精品资源点击获取