
简介面向嵌入式单片机学习者与开发者这份基于STM32F103的L298N直流减速电机驱动例程提供了可直接编译运行的KEIL标准库工程覆盖PWM调速、电机正反转及硬件接线定义等关键环节能帮助读者快速掌握电机驱动类项目的开发思路。资源包共128个文件以C源文件、头文件、编译生成的object与HEX烧录文件为主同时包含keil工程配置文件、链接脚本及一键重置编译的bat脚本整体仅1.52MB结构清晰、便于下载后直接导入开发环境。目前已有203人浏览学习。程序经过实战检验代码中对传感器和模块接线均有注释若更换为STM32F103其他型号只需调整KEIL芯片型号与Flash容量配套说明还区分了J-Link与ST-Link的下载选项并预留技术交流联系方式遇到硬件差异时可自行微调代码适配适合刚接触STM32或需要快速完成直流电机驱动模块的开发人员参考复用。1. 为什么一个“驱动例程”能卡住大半新手先搞清楚它在解决什么很多人第一次打开“嵌入式单片机实战开发例程-STM32F103实现L298N控制直流减速电机驱动程序.rar”时以为难点在那一堆 IN1、IN2、ENA 的代码逻辑结果照着接完线、烧完程序电机要么纹丝不动要么一转就把单片机拉复位。这个例程真正要解决的不是“写一段 PWM”而是把 L298N 双 H 桥的使能逻辑、STM32F103 的定时器 PWM 映射、电源共地这三件事串成一条能复现的链路。它能让你在半小时内让带编码器的直流减速电机听话地转动并按你给的占空比指令调速适合做小车底盘、机械臂关节和毕设运动控制的同学。先别急着抄代码把接线和电源看明白后面才不会翻车。2. L298N 驱动直流减速电机的原理与硬件接线三组线别接错2.1 双 H 桥与使能/方向引脚L298N 为什么需要 ENA 和 IN1~IN4L298N 是双路 H 桥驱动芯片内部是两个全桥电路每一路桥由四个开关管组成。以电机 A 为例IN1 和 IN2 的电平组合决定电机两端电压方向IN11、IN20 时电机正转反过来就是反转IN1IN2 时电机进入刹车状态。ENA 是这一路桥的使能端只有 ENA 为高电平时IN1/IN2 的组合才真正作用到输出如果把 ENA 接上 PWM 信号就能通过占空比控制电机的平均电压从而调速。直流减速电机就是直流电机加减速箱的组合特点是转速低、扭矩大很适合小车和机械臂这类负载。把 H 桥的开关管用 PWM 高频通断电机两端得到的是一串宽度可调的方波电压平均电压决定转速这就是“PWM 调速”。这里要特别提一下续流二极管电机是感性负载PWM 关断瞬间会产生反向电动势如果没有续流回路尖峰电压可能打坏 H 桥。市面上常见的 L298N 驱动模块大多板载了续流二极管和滤波电容所以买模块直接做基本没问题如果是自己画板子续流二极管是必加的否则 L298N 芯片会莫名其妙地烧掉。还有一个供电细节容易踩模块上的 5V 输出端子能不能给 STM32 供电我一般不这么做。L298N 的逻辑电源 VSS 负责给内部逻辑电路供电模块上那个 5V 输出是从一颗小功率稳压器引出来的负载能力很弱带不动 STM32F103 最小系统的全部功耗更别说再挂蓝牙、OLED 这类外设。正确做法是 STM32 系统板用独立的 5VUSB 口或单独稳压板供电电机电源按电机额定电压选常见 9V/12V两块板只共地、不共供电。这条原则我会在避坑章里再展开。2.2 引脚分配与接线表电机线、电源线、逻辑线三组线我会把整套驱动例程的引脚分成三组对照着接不容易乱。以常见的 STM32F103C8T6 最小系统板为例我用 PA6/PA7 接电机编码器PB0/PB1 接方向控制PA8 输出 PWM。为什么选 PA8因为 PA8 是 TIM1_CH1 的复用引脚TIM1 挂在 APB2 总线上时钟最高能到 72MHz做电机 PWM 输出很干净。当然你也可以用 TIM3 的 PA6/PA7 输出 PWM但那样会占用编码器输入通道所以 PWM 和编码器各占一个定时器更合理。这组接线关系列成表照着接就行功能STM32 引脚L298N 模块说明电机 A 方向PB0IN1与 IN2 互补控制正反转电机 A 方向PB1IN2与 IN1 互补电机 A 使能 PWMPA8ENA10kHz PWM占空比调速电机 A 输出-OUT1/OUT2接电机两根线可互换电机电源正-VS按电机额定电压常见 9V/12V逻辑电源-VSS接 5V也可由模块内部稳压供电公共地GNDGND必须和 STM32 的 GND 相连编码器 A 相PA6-接电机霍尔编码器的 A 相输出编码器 B 相PA7-接电机霍尔编码器的 B 相输出注意STM32 的 GPIO 输出高电平是 3.3V而 L298N 的输入阈值在 VSS5V 时大约 2.0V所以 3.3V 高电平通常能直接驱动 IN 引脚。但前提是信号线别拉太长、电源纹波别太大否则会出现“时不时不识别”的怪毛病。我一般直接把 IN 接 MCU 引脚但会控制线长在 10cm 以内。OUT1/OUT2 到电机的两根线不分正负接反了只是转向反代码里把方向定义反过来就行不用改线。编码器信号线则要注意别和电机功率线捆在一起走这是后面 5.4 节要讲的干扰问题。2.3 编码器信号与定时器编码器模式为什么 AB 相要接 CH1/CH2带霍尔编码器的直流减速电机一般输出 A、B 两路相位差 90° 的方波。STM32F103 的通用定时器支持编码器模式把 A 相接定时器的 CH1TI1B 相接 CH2TI2配置成“TI1 和 TI2 都计数”硬件就能自动把两路信号 4 倍频还能通过相位判断正反转。这就是大家常搜到的“stm32f103 cubemx 定时器3”的典型用法定时器 3 的 CH1/CH2 正好在 PA6/PA7所以很多例程把编码器接在这两个引脚上。一个容易忽略的外设边界STM32F103C8T6 只有 TIM1、TIM2、TIM3、TIM4没有 TIM5。如果你拿到的是 C8T6 最小系统板却照着 RCT6 的例程把编码器接到 TIM5 的引脚上代码编译能过但外设根本不存在计数器纹丝不动。建议把 STM32F103 系列参考手册RM0008下载下来当字典用哪个封装有哪些定时器、哪些 IO 有复用功能一查就能确认省得走弯路。2.4 “5V 转 3.3V”是前后级的关键边界“stm32f103 5v转3.3v电路”在这个方案里确实绕不开L298N 的逻辑是 5VSTM32F103 是 3.3V 逻辑。虽然 IN 引脚直接接 3.3V 高电平能工作但编码器这边更需要注意——很多霍尔编码器是 5V 供电输出高电平也是 5V而 F103 的普通 IO 并不是 5V 容忍直接接进 PA6/PA7 有烧引脚的风险。稳妥做法是选 3.3V 供电也能工作的霍尔编码器型号或者在编码器输出到 MCU 之间串一个电阻分压把高电平压到 3.3V 以内。这一条是很多小车翻车的地方别只盯着 L298N 那一半电路看。3. 用 CubeMX 配置定时器和 PWM让电机先转起来的最小工程3.1 Keil 工程搭建时钟树、GPIO、定时器三处配置工具链方面我习惯用 STM32CubeMX 生成 HAL 库工程再用 Keil MDK 编译下载。跟着江科大笔记入门的同学很多用标准外设库两种风格代码不同但寄存器配置思路一致。这里以 HAL 库为例因为 CubeMX 的可视化配置不容易漏开时钟对新手更友好。CubeMX 里的关键配置就三处。第一RCC 里 HSE 选择 Crystal/Ceramic Resonator时钟树把 SYSCLK 配到 72MHz第二GPIO 里 PB0、PB1 设为 Output Push-Pull速度 HighPA8 设为 AF_PP复用推挽因为 TIM1_CH1 的复用功能就在 PA8第三TIM1 的 Channel1 选 PWM Generation CH1Prescaler 和 Period 按下一节算Pulse 初值设 0。有个常踩的配置坑PWM 通道的 GPIO 必须设置成复用推挽AF_PP如果按普通推挽输出配置引脚电平能拉高拉低但定时器波形根本出不来。我见过不少同学在这里卡半小时最后发现就是端口复用没开。生成工程后在 main.c 里确认 HAL_TIM_PWM_Start(htim1, TIM_CHANNEL_1) 被调用PWM 才算真正输出。3.2 PWM 频率计算为何是 PSC71、ARR99占空比控制靠的就是定时器计数。TIM1 挂在 APB2 上时钟 72MHz。要让 PWM 频率等于 10kHz分频系数就是 7200。取 PSC71也就是对 72MHz 做 72 分频得到 1MHz再取 ARR99计数器从 0 数到 99共 100 个计数PWM 频率就是 1MHz / 100 10kHz占空比调节精度 1%。CubeMX 生成的时基参数长这样htim1.Init.Prescaler 71; // 72MHz / (711) 1MHz htim1.Init.Period 99; // 1MHz / (991) 10kHz htim1.Init.CounterMode TIM_COUNTERMODE_UP; htim1.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim1.Init.RepetitionCounter 0; sConfigOC.OCMode TIM_OCMODE_PWM1; // PWM1CNT CCR 时输出有效电平 sConfigOC.Pulse 0; // 初始占空比 0% sConfigOC.OCPolarity TIM_OCPOLARITY_HIGH; sConfigOC.OCIdleState TIM_OCIDLESTATE_RESET;PWM1 模式下CCR 寄存器的值决定占空比。Pulse50 时计数器前 50 个计数值输出高电平占空比就是 50%。所以调速的本质是改 TIM1 的 CCR 值后面我会封装一个函数一行代码就能完成。PWM 频率不是越高越好。直流减速电机是感性负载频率太高时电感感抗变大电流还没来得及建立就被切断力矩反而下降频率太低几百 Hz电机会发出明显啸叫甚至一顿一顿。我一般从 10kHz 起调电机会有轻微“嗡”声但不刺耳。如果你发现电机在某个频率下噪声特别大升到 16kHz 或降到 8kHz 再试不同电机的电感量不同这个参数值得花几分钟扫一遍。3.3 初始化与方向控制函数换向顺序是个关键细节PWM 和 GPIO 初始化完成后我习惯封装两个函数一个设置方向并给占空比一个单独更新占空比。代码骨架如下#define MOTOR_IN1_ON() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET) #define MOTOR_IN2_OFF() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_RESET) void Motor_Forward(uint16_t ccr) { // 方向拉到正转 MOTOR_IN1_ON(); MOTOR_IN2_OFF(); __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, ccr); // 更新 PWM 占空比 } void Motor_Backward(uint16_t ccr) { MOTOR_IN1_OFF(); MOTOR_IN2_ON(); __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, ccr); } void Motor_Brake(void) { // 两个方向引脚同时输出相同电平L298N 内部下桥臂导通刹车 MOTOR_IN1_ON(); MOTOR_IN2_ON(); __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, 0); }注意三个细节。第一__HAL_TIM_SET_COMPARE 只是写 CCR 寄存器不会重新启动定时器比先 Stop 再 Start 平滑得多不会产生占空比跳变。第二换向时如果当前占空比很大直接改 IN1/IN2 会让电机两端电压瞬间反接电流冲击很大轻则“咯噔”一声重则损伤 H 桥。正确做法是先调低 PWM 或先刹车再换向再恢复目标占空比。第三刹车不等于立刻停直流电机有惯性真要让小车急停后面要配合编码器做反向制动单纯靠 IN1IN2 只能实现“短接制动”。3.4 软启动与占空比斜坡从 0 匀加速避免起步“蹿车”减速电机的摩擦负载有一个特点占空比低于某个阈值比如 25%时电机根本不转一旦越过启动阈值转速又会突然跳起来。如果代码里直接把占空比从 0 设到 80%电机相当于被猛踹一下起步抖动很大。我一般在初始化后用斜坡函数每 10ms 把占空比加 2%从 0 爬到目标值void Motor_RampTo(uint16_t target_ccr) { uint16_t current 0; while (current target_ccr) { current 2; // 每 10ms 加 2约 0.5s 到 100 if (current target_ccr) current target_ccr; __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, current); HAL_Delay(10); } }这个斜坡速度完全看负载底盘重、摩擦大就把步长调小负载轻步长可以大一点。如果斜坡爬了半天电机还在“嗡嗡”不转大概率是起始占空比太低把 current 初始值改成 15 或 20 更实际。这一步能让电流缓慢上升既保护减速齿轮也让后面的闭环控制有一个稳定的起点。4. 从“能转”到“能控”编码器测速与 PID 调速闭环4.1 用定时器 3 配置正交编码器模式先解决“电机到底转多快”的问题。带霍尔编码器的直流减速电机通常输出 A、B 两路方波A、B 相位差 90°正转时 A 领先 B反转时 B 领先 A。把这组信号接进定时器的编码器模式硬件会自动判断相位并计数不需要在中断里手工数脉冲这是 STM32F103 比较省心的外设之一。CubeMX 里把 TIM3 的 Channel1、Channel2 都设为 Encoder ModeCombined mode 选“TI1 and TI2”。这样 A/B 两路信号的四个边沿都参与计数也就是 4 倍频。比如某电机编码器是 11 线每转输出 11 个脉冲4 倍频后电机轴每圈 44 个计数再乘上减速比 30输出轴转一圈就是 1320 个计数。这个数值后面会用在测速公式里。代码层面的配置大致如下TIM_Encoder_InitTypeDef sEncoder; sEncoder.EncoderMode TIM_ENCODERMODE_TI12; // 双沿计数4 倍频 sEncoder.IC1Polarity TIM_ICPOLARITY_RISING; sEncoder.IC1Selection TIM_ICSELECTION_DIRECTTI; sEncoder.IC2Polarity TIM_ICPOLARITY_RISING; sEncoder.IC2Selection TIM_ICSELECTION_DIRECTTI; htim3.Init.Prescaler 0; // 编码器模式不分频避免丢计数 htim3.Init.Period 0xFFFF; // 16 位自由循环计数 htim3.Init.CounterMode TIM_COUNTERMODE_UP;注意编码器模式是硬件计数器不是 PWM所以 PSC 和 ARR 的含义跟上一章完全不同。编码器模式下 PSC 保持 0否则会丢失计数精度ARR 给 0xFFFF 是为了让 16 位计数器自由循环配合软件做差分。很多初学者在这里把定时器当 PWM 配把 ARR 设成 99结果编码器每转一圈就回绕一次测速根本没法算。4.2 测速函数16 位回绕与 RPM 计算编码器的 CNT 是 16 位最大 65535。转速快时相邻两次采样之间 CNT 可能从 65000 跳回 100直接做减法会得到负数转速突然变成负值。正确做法是把差值强制转成 int16_t利用有符号数回绕特性得到正确的增量static int16_t last_cnt 0; int16_t Encoder_GetDeltaCnt(void) { int16_t cnt (int16_t)__HAL_TIM_GET_COUNTER(htim3); int16_t delta cnt - last_cnt; // 有符号差值自动处理 65535 回绕 last_cnt cnt; return delta; } int16_t Encoder_GetRPM(void) { int16_t delta Encoder_GetDeltaCnt(); // 每 10ms 采样一次输出轴每转一圈计数值 1320 // 转速(rpm) delta / 1320 * (1000ms / 10ms) * 60s // delta * 4.545 return (int16_t)(delta * 45 / 10); // 整数运算等价于 delta * 4.5 }这里我用整数运算而不是浮点。MCU 做浮点运算偏慢对速度环这种每 10ms 算一次的控制完全可以用定点数。把 delta 乘 45 再除 10等于乘 4.5如果你的减速比不是 30 或者编码器线数不是 11把系数按实际参数替换就行。测速方向由 delta 的正负直接反映正数正转、负数反转不需要额外读取方向标志。测速周期决定控制带宽。周期太短比如 1ms噪声大周期太长比如 50ms反应慢。我一般用 10ms既能满足速度环 20Hz 以上的响应又不会让码盘计数太少导致分辨率不足。低速时如果每 10ms 只有 1~2 个计数速度值会一跳一跳这时可以改成每 100ms 累计再求平均代价是响应变慢。这也是调车时常遇到的“速度显示抖动”问题的根源。4.3 位置式 PI 调速限幅、积分饱和与死区补偿拿到 RPM 以后就可以做速度闭环。目标转速减去反馈转速得到误差误差驱动 PI 控制器输出一个占空比。速度环我不加微分项因为微分会把测速噪声放大调试起来很痛苦除非做位置控制否则 PI 足够。下面这个函数是最简可用的位置式 PItypedef struct { float Kp; float Ki; float integral; int16_t out_min; int16_t out_max; } Pid_TypeDef; int16_t Pid_SpeedCalc(Pid_TypeDef *pid, int16_t target, int16_t feedback) { int16_t err target - feedback; // 积分项按采样周期 10ms 累加 pid-integral (float)err * 0.01f; if (pid-integral 500.0f) pid-integral 500.0f; if (pid-integral -500.0f) pid-integral -500.0f; float out pid-Kp * (float)err pid-Ki * pid-integral; if (out pid-out_max) out pid-out_max; if (out pid-out_min) out pid-out_min; // 死区补偿小占空比下电机不动把输出抬到最低转动力矩 if (out 0 out 20) out 20; if (out 0 out -20) out -20; return (int16_t)out; }这个函数里的关键点有三个。第一积分限幅必须有否则只要误差一直存在积分项会无限涨大电机以最大占空比一直堵转这叫积分饱和。第二死区补偿是因为直流减速电机有启动电压PI 输出 5% 占空比时电机根本不转误差永远消不掉系统表现为低速震荡直接把小于 20% 的输出修正到 20%让电机保持最低转速再由 PI 去修误差。第三out_min/out_max 限制在 ±100 以内对应占空比 0~100%。调参我一般这样走先把 Ki 设为 0只用 Kp 让电机在目标转速附近跑起来观察是否有稳态误差然后从 Kp2、Ki0.5 起步逐渐加 Ki 到系统出现轻微震荡再回退 30%。这个流程对大多数市面常见的带编码器直流减速电机都适用。注意 target 和 feedback 都带符号当目标转速是负值时误差和输出自然会跟随方向不需要额外判断。5. 避坑这些坑我一个一个踩过按现象、原因、解决给你列好5.1 电机一转单片机就复位或花屏现象程序烧进去电机转不到一秒STM32F103 最小系统板自动重启或者 USB 串口断开严重时接了 OLED 会花成一团。原因电机启动瞬间电流高达几安培电源电压被瞬间拉低到单片机复位阈值以下。很多同学给 L298N 的 VS 和 STM32 的 5V 用同一个电源适配器电机一动5V 也被拖垮。解决给 L298N 单独供电VS 接电机额定电压9V 或 12VSTM32 用另一路 5VUSB 口或独立稳压板都行两路电源的 GND 必须相连但正极不要并联。如果是电池供电在电机电源端并接 470µF 到 2200µF 的电解电容能明显缓解瞬态跌落。这是我把“共地不共电”放在接线章第一句的原因也是新手最容易翻车的第一站。5.2 电机高频啸叫转起来扭矩却软绵绵现象PWM 一输出电机发出尖锐的“吱吱”声用手捏轮子明显感觉扭力不足转速还忽快忽慢。原因PWM 频率设置不合理。频率太高时电机电感阻碍电流建立平均电流小力矩不够频率太低时电流波动大机械噪声明显。解决检查 PSC 和 ARR 的实际值先把频率设到 10kHz 附近再试。有些电机在 10kHz 下仍然刺耳就试 16kHz 或 8kHz。手头有示波器或逻辑分析仪的话直接看 PWM 波形频率最直观。这个坑往往不是代码逻辑问题是参数问题调参思路比换芯片优先级更高。5.3 信号线都接对了电机却“偶尔转偶尔不转”现象按道理 IN1/IN2/ENA 全接对了但电机只在碰一下线头时转一下或者换个杜邦线就正常再多晃两下又失灵。原因最常见的是共地缺失——L298N 的 GND 与 STM32 的 GND 没有连在一起信号的高电平没有参考地芯片根本判断不了逻辑 1 还是 0。其次是信号线过长、电机干扰串入3.3V 高电平在阈值边缘晃动。解决先确认共地这是排查此类玄学问题的第一步再把信号线缩短或换成双绞线。我早期调试时漏接 GND整整折腾了一个晚上后来养成习惯接线完毕第一件事是用万用表量一遍 GND 连通性而不是急着烧程序。5.4 测速反馈突然变负值或变成一个极大值现象电机匀速转着串口打印的 RPM 每 10ms 跳一次有时突然变负数或跳到四位数但实际转速并没有变化。原因编码器 CNT 是 16 位相邻采样跨越 65535 边界时普通减法溢出成巨大差值或者 A/B 相极性接反硬件计数方向与软件预期相反或者电机反电动势串进编码器信号产生毛刺脉冲被多计。解决测速差值用 int16_t 类型强制转换解决回绕问题A/B 相顺序不对时把两路输入对调或改 IC 极性编码器信号线尽量远离电机功率线必要时加一个 1kΩ 电阻加 100nF 电容的 RC 低通滤掉尖峰。另外别忘了确认减速比和编码器线数在测速公式里是否对应你手上那个电机型号很多翻车其实是参数没对上。5.5 下载器或串口驱动过不了 Windows 的签名校验现象把 ST-LINK 或 USB 转串口模块插到 Win10/Win11 电脑上系统提示“Windows 无法验证此设备所需的驱动程序的数字签名”设备管理器里设备带黄色感叹号Keil 下载时找不到 ST-LINK用 CubeProgrammer 串口下载也一样失败。原因部分兼容驱动未通过微软新签名认证或者系统开启了强制驱动签名验证。这个问题与 C 代码无关属于环境问题但卡在这上面会让你误以为程序没烧进去。解决临时禁用驱动签名验证操作路径是“高级启动 → 疑难解答 → 高级选项 → 启动设置 → 重启 → 按键禁用驱动强制签名”更省心的做法是去芯片厂商官网下载带微软认证的新版驱动手动安装。排查顺序永远是先确认驱动、再确认下载器最后才怀疑代码。6. 验证这套驱动例程是否合格串口标定转速-占空比曲线驱动写完、电机转起来之后先别急着上车跑做一次最基本的标定。标定目的很简单搞清这个电机在当前供电电压下的死区占空比和线性区间这两个数据直接决定 PID 参数能不能调顺。我的操作是写一个测试程序用串口命令或按键把占空比从 10% 步进到 90%每个档位停留 2 秒同时通过串口打印当前占空比和测速函数算出的 RPM。打印格式用 CSV 样式方便直接粘到表格工具里void Motor_CalibrationDemo(void) { printf(duty,rpm\r\n); for (uint16_t duty 10; duty 90; duty 10) { Motor_RampTo(duty); // 斜坡起步避免冲击 HAL_Delay(2000); // 等转速稳定 int16_t rpm Encoder_GetRPM(); printf(%d,%d\r\n, duty, rpm); } Motor_Brake(); }printf 从串口输出需要重定向 fputcHAL 库工程里常见做法是int fputc(int ch, FILE *f)重定向到 USART1这个网上例子很多照着配一下即可。跑完这个测试你会得到一张表占空比低于某个值时 RPM 基本为 0那是死区之后 RPM 大致随占空比线性上升那是工作区间到了高占空比转速还会因反电动势趋于饱和。把这组数据留下来后面调 PID 的 Kp 和死区补偿值就有依据了不用靠猜。对毕设或比赛来说这张标定表本身就是一份很好的过程文档。我自己的习惯是把这组标定数据贴在工程目录的说明文件里每次换电池、换轮径、换负载时重新跑一遍。因为这套例程能不能从“能转”升级到“能用”不在于代码写得多漂亮而在于你知不知道手上这台电机的真实脾气。最早我做小车为了省事跳过标定结果 PID 参数调了两天没调稳后来发现是死区补偿设太低、积分项一直在空转回头把标定补上半小时就调完了。希望帮到你。本文还有配套的精品资源点击获取