STM32 L298N 电机驱动代码落地:PWM配置、接线与排错

发布时间:2026/10/21 21:21:14
STM32 L298N 电机驱动代码落地:PWM配置、接线与排错
简介STM32搭配L298N驱动直流电机是嵌入式控制中非常典型的入门项目常用于智能车、小型机器人等场景。这份代码包以按键控制电机转速与方向为任务提供了一套从GPIO初始化、外部中断响应到定时器PWM调速的完整示例工程适合初学STM32或需要快速搭建电机控制原型的开发者参考。压缩包共143个文件以C源码、H头文件为主辅以Keil工程配置文件、可烧录的HEX文件以及编译中间文件整体约2.62MBMDK环境下打开即可编译运行。示例代码演示了L298N使能与输入引脚的不同组合如何决定电机正反转同时通过修改定时器比较值来改变PWM占空比从而实现无级调速相关驱动模块拆分清晰便于对照原理图理解每个寄存器配置和中断处理逻辑。目前已有5739人学习下载对于想梳理外设调用流程、掌握中断与PWM协作机制或者直接复用这套代码的读者来说都是很有价值的参考资料。1. STM32 L298N 代码包怎么落地先看接线、再谈调试搜 STM32 L298N 代码的人多半不是缺原理是缺一份能直接烧进去跑的工程。这套资源就是一个完整的 STM32 驱动 L298N 双 H 桥的代码包里面包含定时器 PWM 初始化、方向控制函数、加减速接口和接线说明。适合三种人刚做完 CubeMX 点灯、准备上小车底盘的初学者手里有 L298N 板子但想快速验证电机的人以及被 PWM 频率、共地、跳线帽这些细节卡住的老手。我按这个工程包实际用到的思路把硬件接线、PSC/ARR 计算、控制接口和排错过程拆成下面六章你可以顺着章节搭线也可以直接跳到第 5 章对着问题查。先说一句结论L298N 本身没有协议你给它的只是三样东西——两个方向电平、一个 PWM 占空比、一个共地参考这三样少一样电机都转不对。2. 接线与引脚规划一路电机占用哪三个 STM32 引脚L298N 模块是双 H 桥内部是两个独立的 H 桥电路可以同时驱动两台直流电机。每一路电机需要三个控制信号两个方向引脚 IN1/IN2一个使能引脚 ENA 或 ENB。对 STM32 来说方向引脚是普通 GPIO 推挽输出使能引脚必须接到带定时器复用功能的引脚上才能输出 PWM。注意一个容易被忽略的事实L298N 的输入高电平门槛大约在 2.3VSTM32 的 3.3V GPIO 可以直接驱动不用额外加电平转换芯片。这个结论让接线表变得很简单。2.1 引脚分配表PWM 引脚、方向引脚与地我一般把 PA6/PA7 留给两路电机的 PWM因为这两个引脚是 TIM3 的通道 1 和通道 2在 F103 全系列上都有重映射没什么争议。方向引脚随便挑普通 GPIOPB8/PB9 和 PB10/PB11 不与常用调试口冲突。下面是推荐接线表功能STM32 引脚L298N 端子说明电机 A 使能PA6ENATIM3_CH1PWM 占空比控制速度电机 A 方向PB8IN1GPIO 推挽输出与 IN2 组合定方向电机 A 方向PB9IN2GPIO 推挽输出电机 B 使能PA7ENBTIM3_CH2电机 B 方向PB10IN3GPIO 推挽输出电机 B 方向PB11IN4GPIO 推挽输出公共地GNDGNDSTM32 与 L298N 必须共地这张表的核心是把 PWM 引脚放在定时器通道上而不是随便找 GPIO。如果你在 CubeMX 里把 PA6 选成 TIM3_CH1生成代码后定时器的复用功能会自动配好如果手动建工程就需要在 GPIO 初始化里手动把 PA6/PA7 设为复用推挽模式。方向引脚则完全不需要定时器资源普通推挽输出就够因为方向切换对时间精度没有要求。为什么默认把 TIM4 留出来而不是把 PWM 放在 TIM4 的 PB6/PB7 上因为后面做闭环调速时编码器最好挂在 TIM4 的编码器模式上PWM 和编码器抢占同一个定时器会让引脚冲突代码包里的注释也明确提醒了这一点。如果你只有一路电机且确定不做闭环PWM 放 TIM4 也能跑但最好一开始就按上面的表规划。2.2 跳线帽和电源先说清 7805 的使能条件模块上的 ENA/ENB 跳线帽是新手第一个坑。出厂时跳线帽默认套在 5V 和使能引脚之间等于把 enable 焊死在 5V这时 PWM 信号进来会被忽略电机永远是全速转。很多人改代码改了一个下午最后发现是跳线帽没拔。拿到板子的第一步把 ENA、ENB 两个跳线帽拔掉把 STM32 的 PWM 输出线接到拔掉跳线帽后露出的使能引脚上。电源方面L298N 模块的 12V 端子就是电机主电源输入建议和 STM32 的供电完全分开电机用电池或独立适配器STM32 用 USB 或稳压模块供电两者只连一根 GND。模块板载的 7805 稳压器可以从电机电源取电生成 5V 逻辑电但它的输入电压范围有限一般建议电机电压在 7V 到 12V 之间时才允许使用板载稳压输出。如果电机电压超过 12V必须把 5V 输出使能跳线帽拔掉否则 7805 会严重发热甚至烧毁此时逻辑电要从外部单独接到 L298N 的 5V 输入端子。提示L298N 板上的 ENA/ENB 跳线帽拿到板子第一步就拔掉除非你确认要全速直驱。地线连接常常被当成玄学但本质是电流回路。PWM 和方向电平都是 STM32 以自己的 GND 为参考输出的L298N 内部逻辑也必须以同一个 GND 为参考才能正确解读这些电平。如果两边的地不连信号电压会漂移轻则电机抖动重则烧毁 IO。连接时用粗一点的杜邦线或直接焊线电机大电流造成的压降会在地线上产生噪声线越细越容易出问题。还需要提一下电流能力。L298N 每桥允许 2A 峰值电流但连续电流超过 1.5A 后芯片温度会明显上升必须加散热片。如果你带的电机额定电流超过这个范围建议换大功率驱动板代码包的接口设计对这一点留了替换空间。3. 定时器 PWM 配置PSC 和 ARR 的参数推导与典型值定时器配置对新手是个黑匣子同样的代码改频率只要动两个数但不知道这两个数怎么来的就有种全凭玄学的感觉。其实只有一条公式PWM 频率 定时器时钟 / ((PSC1) x (ARR1))。以 STM32F103 为例APB1 上的定时器时钟是 72MHzCubeMX 里把 PSC 设 7、ARR 设 899算出来就是 10kHz误差为 0。这个公式你一定要自己推一遍因为你换一个主频不同的芯片时别人给的配置就失效了。3.1 PWM 频率与占空比分辨率为什么不要用 1kHz网上大量教程用 1kHz PWMPSC71、ARR999好算但不好用。直流电机用 1kHz 有两个毛病低速时电流断续得厉害电机会发出一顿一顿的顿挫感PWM 的开关噪声落在人耳最敏感的频段听起来像蚊子在耳边飞。10kHz 到 20kHz 是个平衡点。L298N 内部是 BJT 达林顿结构不是 MOSFET开关损耗随频率上升但 20kHz 以下这个损耗完全可接受所以把频率提到 20kHz 并不会把驱动芯片烧掉。占空比分辨率由 ARR 决定占空比最小步进是 1/(ARR1)。ARR899 时分辨率约 0.11%对普通直流电机已经足够。有人喜欢把 ARR 拉到 9999 追求极致平滑但这样 PWM 周期变成 10ms调速响应变慢做闭环时积分调节的反应也会拖后腿。常见的组合见下表都基于 72MHz 定时器时钟目标频率PSCARR分辨率1kHz719990.1%10kHz78990.11%20kHz38990.11%我一般默认用 10kHz兼顾噪声和开关损耗如果项目对声音敏感直接抄 20kHz 那一行只改两个寄存器值代码逻辑不用动。3.2 CubeMX 初始化代码TIM3 双通道 PWM 生成CubeMX 里把 PA6 设为 TIM3_CH1、PA7 设为 TIM3_CH2生成代码后主要逻辑固化在 MX_TIM3_Init 函数里。手动建工程的同学可以对照下面这段void MX_TIM3_Init(void) { TIM_OC_InitTypeDef sConfigOC {0}; htim3.Instance TIM3; htim3.Init.Prescaler 7; // 72MHz / (71) 9MHz 计数器时钟 htim3.Init.CounterMode TIM_COUNTERMODE_UP; htim3.Init.Period 899; // 9MHz / 900 10kHz htim3.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_PWM_Init(htim3); sConfigOC.OCMode TIM_OCMODE_PWM1; sConfigOC.Pulse 0; // 初始比较值占空比 0% sConfigOC.OCPolarity TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(htim3, sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_ConfigChannel(htim3, sConfigOC, TIM_CHANNEL_2); }这个函数里几个参数要理解清楚Prescaler 决定计数器从 72MHz 降到多快Period 决定数多少个数溢出一次两者相乘的倒数就是 PWM 频率。Pulse 是输出比较值PWM1 模式下计数器值小于 Pulse 时输出有效电平所以 Pulse0 就是恒低电平占空比为 0。AutoReloadPreload 开启后运行时修改 Pulse 不会立刻生效而是等计数器溢出进入下一次更新事件才统一加载这个特性保证调速过程不会出现一个周期宽一个周期窄的撕裂脉宽。代码生成后别忘了在 main 函数里对两个通道调用 HAL_TIM_PWM_Start。这是新手漏得最多的一个函数定时器初始化了但输出通道没有使能引脚上永远测不到波形。调用方式如下HAL_TIM_PWM_Start(htim3, TIM_CHANNEL_1); HAL_TIM_PWM_Start(htim3, TIM_CHANNEL_2);之后调速只需要写比较值寄存器__HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, duty)duty 取值范围 0 到 899。这个宏是这套代码里被调用频率最高的底层操作方向引脚负责向左还是向右Pulse 负责转多快两者互不干扰。3.3 初始化顺序GPIO 复用、速度等级和上电默认电平定时器配置之前引脚必须在 GPIO 层面设置成正确的模式。方向引脚和 PWM 引脚是两类完全不同的初始化GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOC_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); __HAL_RCC_TIM3_CLK_ENABLE(); // 方向引脚PB8/PB9/PB10/PB11上电默认拉低防止电机乱转 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10 | GPIO_PIN_11, GPIO_PIN_RESET); GPIO_InitStruct.Pin GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10 | GPIO_PIN_11; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOB, GPIO_InitStruct); // PWM 引脚PA6/PA7必须复用推挽 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(GPIOA, GPIO_InitStruct);PWM 引脚设为复用推挽后控制权交给定时器方向引脚保持普通推挽输出。GPIO Speed 等级要注意PWM 引脚选 HIGH 是为了让脉冲上升沿足够陡避免边缘拖沓导致驱动芯片误判方向引脚就选 LOW减少电平切换时的电磁干扰。初始化顺序上我习惯先配 GPIO 再初始化定时器原因是如果定时器先把波形输出了而 GPIO 复用还没设好引脚电平处于不确定状态电机可能在系统刚上电时抽动一下。方向引脚初始拉低也很关键它保证上电瞬间 IN1/IN2 为 00电机处于自由滑行状态而不是突然刹车抱死。4. 控制代码落地方向枚举、占空比接口、刹车与死区补偿定时器配置好之后应用层不应该直接操作寄存器。代码包里的电机控制模块把操作封装成两层底层是 GPIO 电平和定时器比较值上层是方向枚举和占空比数值。这样应用层只关心「向左转 30% 速度」不用记住 PB8 是高还是低。4.1 接口设计MotorId、MotorDir 与 SetDuty 的分层先定义两个枚举和一个初始化函数typedef enum { MOTOR_LEFT, MOTOR_RIGHT } MotorId_t; typedef enum { MOTOR_FORWARD, MOTOR_REVERSE, MOTOR_BRAKE, MOTOR_FREE } MotorDir_t; void Motor_Init(void); void Motor_SetDir(MotorId_t id, MotorDir_t dir); void Motor_SetDuty(MotorId_t id, uint16_t duty); // duty: 0 ~ ARRMotor_SetDir 的实现核心是一个开关判断。以左电机为例PB8 接 IN1PB9 接 IN2void Motor_SetDir(MotorId_t id, MotorDir_t dir) { if (id MOTOR_LEFT) { switch (dir) { case MOTOR_FORWARD: HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_SET); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_RESET); break; case MOTOR_REVERSE: HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_SET); break; case MOTOR_BRAKE: HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8 | GPIO_PIN_9, GPIO_PIN_SET); break; case MOTOR_FREE: HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8 | GPIO_PIN_9, GPIO_PIN_RESET); break; } } /* 右电机同理换成 PB10/PB11 */ }这里要理解四种方向的电学含义IN11、IN20 时 H 桥正向导通IN10、IN21 反向导通IN1IN21 时两个下桥臂关断、两个上桥臂导通电机两端被短路这就是刹车也叫再生制动IN1IN20 时所有开关断开电机自由滑行。自由滑行不是刹车负载大的时候滑行会造成反向冲击所以需要停车时应该用 BRAKE 而不是 FREE。有个常见误用是直接把 FREE 当停止来用电机带着惯性转好几圈才停下来这在底盘项目中会让定位精度直接报废。速度写入用下面这个宏它其实就是改定时器的输出比较寄存器#define MOTOR_SET_DUTY(id, duty) __HAL_TIM_SET_COMPARE(htim3, \ ((id) MOTOR_LEFT ? TIM_CHANNEL_1 : TIM_CHANNEL_2), (duty))duty 范围必须限制在 0 到 ARR 之间超过 ARR 的脉冲会让输出一直保持高电平等效于全速但这不是正常用法。为什么不在 SetDuty 里直接限制因为宏定义在编译期替换没有边界检查所以实际工程里应用层会把速度先做一次 clamp再调这个宏。代码包在 motor.h 里留了 SPEED_CLAMP 宏就是为了强制做这层保护。4.2 换向刹车与最小死区补偿保护 L298N 也保护齿轮箱换向是电机控制里最容易翻车的地方。从正转直接切反转电机因惯性还在向前转反电动势和母线电压叠加会在 H 桥上产生很大的电流尖峰轻则驱动板发出刺耳的咔声重则把续流二极管打穿。正确的做法是状态机方向不变时直接调占空比方向改变时先归零占空比再刹车一个固定时长最后切到新方向。代码包里的换向函数如下void Motor_ChangeDir(MotorId_t id, MotorDir_t newDir) { Motor_SetDuty(id, 0); // 占空比归零断开动力 Motor_SetDir(id, MOTOR_BRAKE); // 刹车让反电动势通过 H 桥短路泄放 HAL_Delay(30); // 重载时改到 80ms Motor_SetDir(id, newDir); // 再输出新方向 }这个 30ms 延时是经验值普通小车底盘齿轮间隙小30ms 足够带减速箱的重载结构延时要加长到 80ms否则切换瞬间齿轮箱还会被反扭力砸一下。你有示波器的话可以观察电流波形换向时电流尖峰明显下降说明刹车在起作用。第二个问题是死区。直流电机在占空比很小的时候转不动超过某个阈值后突然起步表现在小车上是低速时一跳一跳。这不是 PWM 坏了而是电机启动需要克服静摩擦和电枢电阻存在一个最小启动占空比。实测下来 10kHz PWM 下普通小型直流电机的最低启动点大约在 12% 到 15% 占空比。解决方式是给最小占空比做钳位速度请求即使低于阈值实际输出的占空比也保持在阈值附近让电机保持转动。#define MOTOR_MIN_DUTY 120 // 约 13%ARR899 时 void Motor_SetSpeed(MotorId_t id, int16_t speed) // speed: -899 ~ 899 { MotorDir_t dir (speed 0) ? MOTOR_FORWARD : MOTOR_REVERSE; uint16_t duty (speed 0) ? (uint16_t)speed : (uint16_t)(-speed); if (speed 0) { Motor_SetDir(id, MOTOR_BRAKE); return; } if (duty MOTOR_MIN_DUTY) duty MOTOR_MIN_DUTY; if (duty 899) duty 899; Motor_SetDir(id, dir); Motor_SetDuty(id, duty); }这段代码把方向和速度合并成一个带符号数接口应用层不用分别调两个函数。注意 speed 为 0 时是刹车而不是自由滑行这和大多数人的直觉相反但更符合工程需求请求停车时电机应该停住而不是靠惯性继续溜。如果项目需要堵转保护可以在上层加一个定时器2 秒内速度没有到目标值就切断输出这个钩子在代码包里已经留好位置。5. 避坑排查五个 L298N 驱动翻车现场的复现与修复这一章是代码包排错文档里最常被翻的一页。我把常见问题合并成五条每一条都是真实踩过的坑按现象、原因、解决的顺序写方便你对着查。5.1 问题一PWM 引脚测不到波形现象烧录后电机完全无反应示波器探到 PA6显示一直是低电平占空比怎么改都没变化。原因有两种一是没调用 HAL_TIM_PWM_Start定时器在跑但输出通道没使能二是 CubeMX 里 PA6 被配成了普通 GPIO 输出没有选 TIM3_CH1 的复用功能引脚根本不归定时器管。解决确认 main 函数里有HAL_TIM_PWM_Start(htim3, TIM_CHANNEL_1)检查 GPIO 初始化里 PA6 是不是 GPIO_MODE_AF_PP。如果两个都对但测不到波形把初始化代码打断点看 htim3.State 是否为 HAL_TIM_STATE_BUSY这个状态卡住多半是前面某处调过 HAL_TIM_PWM_Stop 没恢复。另外示波器探头别夹到 GND 之前先确认线和模块共地了没共地时波形是悬空噪声不是 PWM。5.2 问题二占空比改了转速不变现象代码里 SetDuty(100) 和 SetDuty(800) 转速一样快电机一直是全速速度不受控。原因L298N 板上的 ENA 跳线帽没拔。跳线帽把使能引脚直接拉到 5V等于告诉芯片「不用管 PWM永远输出」你给的占空比信号被完全忽略。这个问题我用血泪经验提醒所有读者它不报错、不发热表现为纯粹的不可调速。解决拔掉 ENA 和 ENB 的跳线帽把 PWM 线接到拔掉跳线帽后的使能引脚上。判断是不是这个原因的方法很简单用万用表量使能引脚对地电压如果始终是 5V就是跳线帽还在。5.3 问题三电机一转 MCU 复位现象跑车、带负载、或者踩一脚油门STM32 突然重启串口打印断掉屏幕闪一下。原因电机电流大时地线上的压降会把 MCU 的参考地拉偏。如果 STM32 和 L298N 之间只有一根又细又长的杜邦线作为 GND电机启动瞬间几十安培的冲击电流会在这个地线上产生电位差MCU 逻辑电平错乱直接触发复位。电源共用也会放大这个问题电机启动时把板级 5V 拉低MCU 供电跌出工作范围。解决电机电源和逻辑电源彻底分开电机用独立电池STM32 用 USB 或稳压模块供电STM32 与 L298N 之间用尽可能短的粗导线共地最好直接在 L298N 端子旁边汇合。还有一个低成本措施在电机正负两端并一个 104 陶瓷电容和一个 47uF 电解电容吸收换向尖峰这个措施能治不少「一启动就复位」的毛病。5.4 问题四电机电压升到 12V 以上板子反而罢工现象原来 9V 供电一切正常换成 18V 电池组后模块完全没反应电机一动不动。原因L298N 模块板载的 7805 稳压器输入电压超过安全范围。模块出厂时 5V 输出跳线帽默认套着7805 从 12V 端子取电生成 5V 逻辑电电压一路升到 18V7805 严重过压不是保护就是直接烧毁逻辑电路断电导致 PWM 和方向信号都失效。解决电机电压高于 12V 时必须先拔掉 5V 输出跳线帽禁止板载稳压器从主电源取电然后给逻辑供电端子单独接一个 5V 电压。如果发现 7805 周边电容鼓包或者芯片烫手大概率已经损坏需要换模块或飞线绕过稳压部分。这个坑很容易被误判成代码问题因为逻辑电一没所有控制信号都像石沉大海。5.5 问题五低速顿挫和换向火花现象低速占空比在 10% 左右时电机一顿一顿倒车切前进时模块附近有放电声响齿轮箱发出咔嚓声。原因低速顿挫有两个来源一是 PWM 频率太低电流断续严重二是占空比落在启动死区内。换向火花和咔嚓声是因为没有刹车过渡电机反电动势叠加在母线上冲击 H 桥和齿轮间隙。解决把 PWM 频率提到 10kHz 以上按第 3 章的公式重算 PSC 和 ARR占空比用最小启动值钳位不要低于 12%。换向必须走 Motor_ChangeDir 的刹车流程先归零占空比、刹车 30ms、再切方向。重载机构把这个延时长到 80ms。如果换向火花还是明显检查电机端子上的续流电容别把 12V 输入端和电机端接反。6. 进阶技巧编码器闭环 PI 调速与整机验证开环 PWM 控制能用但遇到负载变化就露馅小车爬坡掉速、履带过障碍卡顿占空比没变转速却掉了。这时要从开环升级成闭环编码器反馈是关键。STM32F103 的 TIM4 支持编码器模式硬件自动对 AB 相脉冲进行四倍频和判向比在外部中断里数脉冲省太多 CPU。6.1 编码器接线与 TI12 模式配置编码器 A 相接 PB6B 相接 PB7这两个引脚恰好是 TIM4 的通道 1 和通道 2。把定时器配置成编码器模式后CNT 寄存器会自动跟着电机转角增减TIM_Encoder_InitTypeDef sEncoderConfig {0}; htim4.Instance TIM4; htim4.Init.Period 0xFFFF; htim4.Init.CounterMode TIM_COUNTERMODE_UP; htim4.Init.Prescaler 0; HAL_TIM_Encoder_Init(htim4, sEncoderConfig); sEncoderConfig.EncoderMode TIM_ENCODERMODE_TI12; sEncoderConfig.IC1Polarity TIM_ICPOLARITY_RISING; sEncoderConfig.IC2Polarity TIM_ICPOLARITY_FALLING; sEncoderConfig.IC1Filter 10; sEncoderConfig.IC2Filter 10; HAL_TIM_Encoder_Start(htim4, TIM_CHANNEL_ALL);IC 滤波器设为 10 可以滤掉换向时的毛刺这是代码包在实测后补上的参数。CNT 是 16 位有符号叠加读出来直接做差值就是方向带上符号的速度。因为这个功能占用了 TIM4所以第 2 章的接线方案里 PWM 一定要放在 TIM3 上两条线不能互相挤占这也是我坚持那套引脚分配的原因。6.2 简化 PI 参数与整机验证速度环我用增量式 PI10ms 周期读一次编码器差值把目标速度和实际速度的误差送进 PI输出直接作为 PWM 占空比int16_t Motor_SpeedPI(int16_t target, int16_t current) { static int16_t lastError 0; static int32_t integral 0; int16_t err target - current; integral err; integral CLAMP(integral, -3000, 3000); int32_t out 15 * err integral / 2; // Kp15, Ki0.5 out CLAMP(out, -899, 899); lastError err; return (int16_t)out; }Kp 取 15 是因为满量程 899误差 50 时输出约 750接近全占空比响应够快又不至于一上来就饱和。Ki 用 integral/2 而不是常规的 Ki*integral是为了避免浮点运算在 Cortex-M3 上拖慢中断。积分限幅必须做否则遇到堵转积分项会一路飙到几千解除堵转后需要很长时间才能收敛也就是所谓的积分饱和。验证方法很简单给定一个 500 的目标速度用串口以 100Hz 打印实际速度看曲线能否在 200ms 内进入 ±5% 误差带。如果超调太大把 Kp 降到 8如果稳态误差一直消不掉把 Ki 从 0.5 提到 1.0。带负载后重新整定不要把空载参数直接用在负载工况里。从那以后我每次拿到 L298N 工程都强制自己先走一遍检查清单跳线帽拔了没、共地线够不够粗、HAL_TIM_PWM_Start 调了没、换向走没走刹车流程。这套 STM32 L298N 代码就是把这些检查固化成了代码层级你现在照着这个流程搭一套至少省掉一个通宵的调试时间。希望帮到你。本文还有配套的精品资源点击获取