
简介这是一份面向STM32入门者与电机控制初学者的实操资料围绕STM32F103C8T6通过L298N驱动两个直流电机实现正转、反转和停止展开适合课程设计、实验课及小型智能小车底盘调试参考。压缩包共1个PDF文件约6.97MB以图文讲解和代码节选为主可按硬件连接、主程序、驱动模块和按键扫描对照阅读。内容给出L298N与STM32的OUT1OUT4、IN1IN4接法说明PA2PA5控制信号及共地、供电注意事项附main.c、motor.c、motor.h、key.c等代码覆盖GPIO初始化、延时、按键扫描及Motor_12_Config、正反转与停止函数。列出电源管理、中断响应、编码器反馈和错误处理等优化方向帮助读者理解双电机控制流程并排查接线与转动异常。已有939人学习。1. 桌面上两块电机一个模块十分钟内让它转起来桌面上摊着一块 STM32F103C8T6 最小系统板、一个红色 L298N 双 H 桥模块、两个带减速箱的直流电机没有小车底盘也没有机械臂唯一的目标是按下板子上的按键让两个轮子正转、反转、停下。这套资源解决的就是这个最原始的起点问题用最少的硬件和最短的代码把「MCU 输出电平」和「电机转动」之间的因果链打通。适合刚学完 GPIO 点灯、想上手执行器的嵌入式新手也适合要带学生做第一个运动控制 Demo 的开发者。它不追求速度闭环也不做 PID但把 L298N 的 IN1~IN4 真值表、按键扫描、GPIO 推挽输出这几件事串成了一条能跑通的完整链路后面加 PWM 调速、加编码器反馈都是从这条链路往上叠。2. 硬件接线拆解四根控制线如何落到 PA2 到 PA5先别急着写代码接线接错了后面所有调试都是白费。这套方案里 STM32 和 L298N 之间的信号线只有 6 根但电源部分有三个容易翻车的接口值得单独拆开看。2.1 电源侧12V 输入、GND、5V 输出这三个口不能混L298N 模块上通常有一排三位端子标着12V、GND、5V。很多人第一眼会把它当成「给模块供三种电压」实际上它的逻辑是12V和GND是电机的功率输入也叫 VS实际能接受 5~35V 左右5V是模块板载稳压器的输出端用来给单片机供 5V 逻辑电或者干脆空着不用。这里的坑在于跳线帽。模块上 12V 端子旁边一般有个短接帽短接时板载稳压器工作5V 端子对外输出 5V当你的电机电源超过 12V 左右时必须把跳线帽拔掉否则稳压器承受不住会发热甚至烧掉。原方案里作者用的是 5V 升压到 12V 的板子给 L298N 供电也就是说电机侧的输入电压其实不高这种情况下跳线帽保留、5V 端子悬空是安全的。作者在原文里特意标了一句「5V 端口不接不然很难转动」这句话是对的但原因不是玄学如果 5V 端子和外部 5V 电源同时接上板载稳压器和外部电源会互相打架逻辑电平被拉偏L298N 内部 H 桥的开关管进入线性区而不是饱和区输出力矩自然就没了。血泪经验是——同一个电源域里只能有一个供电方。共地则是另一条硬规矩。STM32 的 GND 和 L298N 的 GND 必须连在一起而且推荐从 L298N 中间的 GND 端子引两根线一根回单片机 GND一根回电源负极。原因很直白IN1~IN4 是电压信号信号是相对于「地」这个基准来判定的两边地不共PA2 输出 3.3V 在 L298N 眼里可能是 1.8V 也可能是 5V纯看两块板子各自的浮地电位飘到哪里表现出来就是「有时转有时不转」。L298N 端子接到哪里备注12VVS电机电源正极电压越高转速越快注意跳线帽GND中间单片机 GND 电源负极必须共地两条线分开走5V悬空或给单片机供电二选一别和外部 5V 同时接OUT1 / OUT2电机 A 的两根线转向反了就互换OUT3 / OUT4电机 B 的两根线同上IN1~IN4PA2 / PA3 / PA4 / PA5顺序别乱代码是对应的ENA / ENB保留跳线帽默认全速后续做调速要拔掉接 PWM2.2 控制侧IN1~IN4 与 PA2~PA5 的固定映射四根控制线的对应关系是硬编码在代码里的接错一根就会出现「按正转结果一个轮子正转一个轮子倒转」的诡异现象。这套方案定的是 IN1→PA2、IN2→PA3、IN3→PA4、IN4→PA5前两根管电机 1后两根管电机 2。接线的物理顺序建议按「从 L298N 到板子」来排即先把模块上的 IN1 找出来插到 PA2再依次推下去而不是先看板子引脚再找模块。因为 L298N 模块的排针顺序各家略有差异有的把 ENA 放在最左边有的放在最右边按模块为准不容易错。电机侧的 OUT1/OUT2 接一个电机OUT3/OUT4 接另一个。这里有个不需要改代码的调节手段如果通电后发现某个轮子的转向和预期相反不用去改Motor_1_PRun()里的电平直接把该电机的两根线对调即可。原因是直流电机的转向由流过绕组的电流方向决定交换 OUT1/OUT2 等价于把电流方向反过来代码层面完全不用动。2.3 上电顺序与快速验线正确的上电顺序是先接好所有信号线和电机线确认共地再给单片机供电最后给 L298N 的电机电源上电。反过来的话L298N 先上电、而 IN 引脚此时处于浮空状态输入电平不确定H 桥可能瞬间导通电机「啪」地抖一下。这种抖动对空载电机无害但如果装在小车上可能会撞到东西。想在上代码前先验证接线是否正确有一个不用写程序的土办法把 L298N 的 IN1 用杜邦线直接接到 5VIN2 接 GND此时电机 1 应该朝一个方向转反过来接就朝另一个方向转。这一步能转说明电源、共地、电机线全部没问题剩下的锅只可能在 MCU 那一侧。我一般会把这个动作叫做「给硬件先做一次体检」能省掉后面大量的返工。提示如果 L298N 上电后模块发烫但电机不转立刻断电。多半是 OUT1/OUT2 短路或者电机堵转继续通电会烧 H 桥。3. 代码骨架motor.h 的宏、motor.c 的真值表和 main.c 的轮询硬件通了之后软件部分其实只有三块引脚定义、电平控制函数、主循环调度。这套代码把前两块压得很紧一个头文件用宏封装了全部 GPIO 操作另一个源文件把「正转、反转、停止」翻译成 IN 引脚的高低组合。3.1 motor.h用带参宏把 GPIO 操作压成一行头文件里最值得看的是那四个带参宏。原文的写法是条件分支直接写在宏体里#ifndef __MOTOR1_H #define __MOTOR1_H #include stm32f10x.h #include stm32f10x_gpio.h #define High 1 #define Low 0 /* 原文写法宏体直接是 if-else */ #define IN1(a) if (a) \ GPIO_SetBits(GPIOA, GPIO_Pin_2);\ else \ GPIO_ResetBits(GPIOA, GPIO_Pin_2) #define IN2(a) if (a) \ GPIO_SetBits(GPIOA, GPIO_Pin_3);\ else \ GPIO_ResetBits(GPIOA, GPIO_Pin_3) #define IN3(a) if (a) \ GPIO_SetBits(GPIOA, GPIO_Pin_4);\ else \ GPIO_ResetBits(GPIOA, GPIO_Pin_4) #define IN4(a) if (a) \ GPIO_SetBits(GPIOA, GPIO_Pin_5);\ else \ GPIO_ResetBits(GPIOA, GPIO_Pin_5) 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(High)这种可读性极强的调用写业务代码时脑子里想的是电平不是GPIO_SetBits。参数a只要非 0 就置高为 0 就拉低High和Low两个宏只是给 1 和 0 起了名字。但这个宏有一个会让人调半天的陷阱后面避坑章节会展开讲宏体里带着裸的if...else当它被放进另一个if的花括号外时else会跟外层if配对。稳妥写法是套一层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)do-while(0)在编译后会被优化掉不产生任何额外指令纯粹是语法上的封装壳。3.2 motor.c初始化与六种电平组合源文件里的初始化函数负责两件事开 GPIOA 时钟把 PA2~PA5 配成推挽输出并拉低。#include motor.h #include stm32f10x.h #include stm32f10x_rcc.h #include delay.h void Motor_12_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; /* 1. 开时钟GPIOA 挂在 APB2 上不开时钟写寄存器无效 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); /* 2. 四个 IN 引脚一起配置省一次函数调用 */ 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; /* 驱动 L298N 足够 */ GPIO_Init(GPIOA, GPIO_InitStructure); /* 3. 初始电平全部拉低避免上电瞬间乱转 */ GPIO_ResetBits(GPIOA, GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5); }GPIO_Mode_Out_PP选推挽而不是开漏是因为 L298N 的输入端内部是高阻抗的 CMOS 输入需要 MCU 主动输出高和低两个电平开漏只能输出低和「悬空」高电平得靠外部上拉多一层不确定性。GPIO_Speed_50MHz是翻转速率不是输出电压电机控制这种毫秒级动作其实用最低档也够但统一设成 50MHz 不容易在别处踩坑。三个动作函数把电平组合翻译成转动状态逻辑很朴素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); }对应的真值表以电机 1 为例IN1IN2H 桥状态电机表现01左低右高正转10左高右低反转11两端同高刹车绕组被短接停得快00两端同低自由滑行靠惯性慢慢停这里有一个语义上的小瑕疵值得指出初始化函数把四个引脚全拉低了也就是0 0状态电机是「自由滑行」而Motor_1_STOP()用的是1 1是「刹车」。两者都叫停止但物理行为不一样。空载时看不出差别装上轮子做小车就能明显感觉到掉电滑行会溜车刹车会立刻停住。想让停车更干脆就把初始化也改成拉高。3.3 main.c一个 while(1) 里塞下三种动作主程序的调度非常直接没有中断、没有状态机就是「扫键 → 查表 → 调函数」。#include stm32f10x.h #include stm32f10x_gpio.h #include stm32f10x_rcc.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); /* 原文留的上电等待10 秒 */ KEY_Init(); /* 按键初始化 */ while (1) { key KEY_Scan(0); /* 0 不支持连按 */ if (key) { switch (key) { case WKUP_PRES: /* 正转 */ Motor_1_PRun(); Motor_2_PRun(); delay_ms(1); break; case KEY1_PRES: /* 反转 */ Motor_1_NRun(); Motor_2_NRun(); delay_ms(1); break; case KEY0_PRES: /* 停止 */ Motor_1_STOP(); Motor_2_STOP(); delay_ms(1); break; } } else { delay_ms(10); /* 无键按下时让出 CPU */ } } }vu8是volatile unsigned char的别名来自开发板配套的公共头文件这里其实没必要加 volatile因为key只在主循环里读写没有任何中断去改它。KEY_Scan(0)的参数 0 表示「不支持连按」按住不放只会触发一次传 1 则会持续返回键值适合做「按住加速」这类功能。三个 case 里各自跟了一句delay_ms(1)作用是让电平稳定一下再进入下一轮扫描属于保守写法。真正让 CPU 不做空转的是else分支里的delay_ms(10)它把轮询周期压到 10ms 左右既不会漏按也不会白烧电。整个结构的问题也很明显delay_ms(10000)让板子上电后要发呆 10 秒才响应按键第一次上电的人很容易以为程序没烧进去。这个延时的来历大概率是调试期间为了等电源稳定或者避开某个上电抖动留下的正式使用时删掉或者改成 100ms 都行。4. 按键扫描与去抖KEY_Scan 的静态标志和优先级按键部分看着不起眼但它是这套代码里唯一带「状态」的逻辑key_up这个静态变量用得对不对直接决定按一下是触发一次还是触发十几次。4.1 KEY_Init上拉、下拉、以及被覆盖的那一行初始化函数要做的是给三个按键选对输入模式。机械按键一端接 IO、一端接 GND那么 IO 必须上拉按下时读到 0松开时读到 1如果按键一端接 VCC那 IO 要下拉按下读 1。void KEY_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; /* 同时开 GPIOA 和 GPIOE 的时钟 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOE, ENABLE); /* 原文这两行第二行把第一行覆盖掉了 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_4 | GPIO_Pin_3; /* KEY0-KEY1 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_5; /* 只剩 PE5 */ GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; /* 上拉输入 */ GPIO_Init(GPIOE, GPIO_InitStructure); /* WK_UP 接 PA0按下接 VCC所以配下拉 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPD; GPIO_Init(GPIOA, GPIO_InitStructure); }GPIO_Mode_IPU是内部上拉输入GPIO_Mode_IPD是内部下拉输入省掉外部电阻。这段代码里有两个隐患一个是明显的笔误——连续两次给GPIO_Pin赋值第二行把Pin_3 | Pin_4冲掉了实际只有 PE5 被配成上拉输入另一个是更致命的GPIOE在 48 脚的 C8T6 上根本不存在只有 100 脚以上的大容量型号才引出 Port E。这块后面单独讲。4.2 KEY_Scan一个静态变量管住连发扫描函数的核心是static u8 key_up它记录「上一次扫描时按键是不是已经松开了」。u8 KEY_Scan(u8 mode) { static u8 key_up 1; /* 1 上一次是松开状态 */ if (mode) key_up 1; /* 连按模式每次都强制允许触发 */ if (key_up (KEY0 0 || KEY1 0 || WK_UP 1)) { delay_ms(10); /* 软件去抖跳过机械抖动窗口 */ 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允不允许触发允许的话再判断有没有键按下有键按下就延时 10ms 躲过抖动把key_up清 0然后按优先级返回键值下一次进来时key_up已经是 0即使按键还按着也不会重复返回直到三个键全松开才恢复。mode参数是个「后悔药开关」传 1 时每次都把key_up重置为 1等于绕过单次触发限制按住就会连发。做电机控制一般传 0因为连发会让电机状态在正转反转之间疯狂横跳。返回值的优先级是 KEY0 KEY1 WK_UP这个顺序由if-else if链决定。同时按下两个键时只有排在前面的生效实际使用中基本遇不到但如果你的项目要支持组合键这个函数就得改成先收集所有键的状态再判断。按键电平的读取封装在 key.h 里全是GPIO_ReadInputDataBit的宏#define KEY0 GPIO_ReadInputDataBit(GPIOE, GPIO_Pin_4) #define KEY1 GPIO_ReadInputDataBit(GPIOE, GPIO_Pin_3) #define WK_UP GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) #define KEY0_PRES 1 #define KEY1_PRES 2 #define WKUP_PRES 3把GPIO_ReadInputDataBit包成宏的好处是业务代码里写if (KEY0 0)就够了读起来像在读一个变量。代价是每次读都真的走一遍函数调用没有做缓存好在按键扫描频率只有 100Hz 左右完全无所谓。4.3 从按键到动作为什么不用外部中断有人会问电机控制为什么不用 EXTI 外部中断按键一按下立刻响应不是更快吗在这个场景下确实没必要原因有三点。第一机械按键的抖动在中断里更难处理。中断服务函数里不能长时间delay_ms(10)只能靠定时器做状态机去抖代码量立刻翻几倍。第二电机控制的响应时间以毫秒计10ms 的轮询周期人根本感觉不到延迟。第三中断里调Motor_x_PRun()这种带 GPIO 写的函数如果主循环同时也在写同一组引脚会出现电平竞争虽然不是致命问题但调试时会看到莫名其妙的毛刺。我一般的判断标准是输入事件频率低于 100Hz、且处理逻辑里带延时或阻塞操作就走轮询高于这个频率或者要做精确时间戳才上中断。按键明显属于前者。5. 避坑与排查五个让电机不听话的真实原因这套代码不长但复现时踩坑的概率不低因为硬件链路上每一环都可能出问题而现象往往都表现为「电机不动」很难一眼定位。下面五条是我按排查顺序整理的从最容易被忽略的开始。5.1 上电后按键毫无反应等十秒才好现象程序烧进去、复位、按键电机一动不动以为烧录失败反复重烧过一会儿随手再按一下突然就好了。原因main()里在KEY_Init()之前有一句delay_ms(10000)也就是 10 秒的裸延时。这期间程序卡在延时循环里根本没进主循环按键当然无效。这个延时大概率是调试阶段为了等升压板输出稳定留下的交付时忘了删。解决直接把delay_ms(10000)改成delay_ms(100)或者干脆删掉。如果真的需要等电源稳定100ms 已经非常充裕电机电源的软启动一般在几十毫秒内完成。改完之后记得重新编译下载别只改了没烧。5.2 按键接在 PE 口C8T6 上根本不存在这个端口现象按 KEY0、KEY1 没有任何反应只有 WK_UPPA0好用用万用表量 PE3、PE4一直是高阻态怎么按都不变。原因KEY_Init()里操作的是GPIOEkey.h里读的也是GPIOE, GPIO_Pin_3/4。但 STM32F103C8T6 是 48 脚封装引出的端口只有 PA0~PA15、PB0~PB15、PC13~PC15Port E 在物理上就不存在。这段代码是从 100 脚以上的大容量型号工程里搬过来的搬的时候只改了电机部分按键部分原封不动留着了。附带一句GPIO_InitStructure.GPIO_Pin被连续赋值两次Pin_3 | Pin_4也被Pin_5覆盖了就算 Port E 存在KEY0/KEY1 也没被正确初始化。解决把两个按键挪到 C8T6 上确实存在的引脚。PB0、PB1 是好选择不和其他外设冲突也不占用 PA2~PA5 的电机控制线。改法是三处同步改/* key.h读取端改成 GPIOB */ #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) /* PA0 保留 */ /* key.c时钟和引脚一起改注意去掉被覆盖的那一行 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1; /* 只写一次 */ GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOB, GPIO_InitStructure);硬件上把两个按键的一端接 PB0/PB1另一端接 GND 即可内部上拉已经打开了。改完之后用KEY_Scan里那句KEY0 0判断就正常了——上拉输入松开读 1、按下读 0。5.3 电机只嗡嗡响不转或者一个轮子转一个不转现象按下正转能听到电机里有交流声转子微微抖动但转不起来或者左边轮子正常右边纹丝不动。原因按概率从高到低排第一是电源功率不够。L298N 驱动两个 130 电机空载就要几百毫安堵转瞬间能到 2A 以上如果用的是 5V 升压到 12V 的小板子输出电流常常只有 1A 左右一带载电压就塌H 桥输出不足。第二是共地没做好L298N 的 GND 只连了电源负极没连 MCU 的 GND逻辑电平参考点飘了。第三是电机线虚接杜邦线插在 OUT 端子上看着插进去了实际只碰到塑料壳。解决先用万用表量 L298N 的 12V 和 GND 之间电压空载和带载各量一次如果带载时电压掉到 6V 以下就是电源的问题换独立电源或者并联大电容。然后确认 MCU 的 GND 和 L298N 的 GND 之间电阻接近 0不接近就补一根线。最后把不转的那个电机的两根线拔下来直接怼到 5V 电源上看转不转能转说明电机没问题问题在模块或线序。注意L298N 是双极型工艺内部有约 1.4V 的压降输出到电机的电压永远比输入低一截。用 12V 供电时电机实际只能拿到 10V 出头这是芯片特性不是接线问题。5.4 IN1 宏放进 if-else 里else 跑到了别的地方现象代码里写成if (flag) IN1(High); else xxx;编译能过但运行结果和预期完全相反xxx该执行的时候没执行。原因IN1宏展开后是if (a) GPIO_SetBits(...); else GPIO_ResetBits(...)里面自带一个else。当你把它放在外层if后面、且没加大括号时展开结果变成if (flag) if (a) ...; else ...; else xxx;第一个else被内层if吃掉了外层的else就变成了孤儿编译器要么报错要么按就近原则乱配。这就是典型的悬垂 else。解决两个层面都可以动手。写调用时永远给外层if加大括号这样内层宏的else被包在块里不会外溢或者按 3.1 节给出的方式把宏体改成do { ... } while(0)从根上消除这个问题。我现在的习惯是两条都做宏写成do-while调用时加大括号双保险。5.5 上电瞬间电机自己抖一下之后才正常现象每次给板子上电电机都会「哒」地转个几度再停下程序里明明没有这个动作。原因上电瞬间PA2~PA5 在Motor_12_Config()执行之前处于复位默认状态也就是浮空输入。浮空引脚的电压不确定可能被 L298N 输入端的漏电流拉到中间电平H 桥短暂导通。等你初始化把引脚拉低抖动就结束了。解决硬件上加 10kΩ 下拉电阻把四个 IN 引脚在 MCU 未初始化时钉在低电平。软件上则是在Motor_12_Config()里先写GPIO_ResetBits再配模式或者把初始化提到main()的最前面尽量缩短浮空窗口。这个抖动对空载电机无害但装成小车后上电就往前窜一下容易撞东西还是值得处理。6. 二次开发把硬编码电平换成调速与状态机原代码是「全速 三个动作」的最小可用版本往实用方向走最值得改的两处是速度控制和动作管理。第一处改动是把 L298N 的 ENA/ENB 从跳线帽状态改成由 STM32 的 PWM 驱动。板上 PA6、PA7 分别是 TIM3 的 CH1 和 CH2正好空着把它们接到 ENA/ENB 上拔掉模块上的短接帽就能用一个定时器同时调两路速度。/* TIM3_CH1 - PA6 - ENATIM3_CH2 - PA7 - ENBPWM 频率 1kHz */ 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); 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 TIM_CKD_DIV1; 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_OCPolarity TIM_OCPolarity_High; TIM_OCInitStructure.TIM_Pulse 0; /* 初始占空比 0 */ TIM_OC1Init(TIM3, TIM_OCInitStructure); TIM_OC2Init(TIM3, TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM3, ENABLE); TIM_Cmd(TIM3, ENABLE); }调用时传Motor_PWM_Init(999, 71)72MHz 除以 (711) 得到 1MHz 计数频率再除以 (9991) 得到 1kHz 的 PWM。频率别再往下压800Hz 以下电机会有明显的啸叫和抖动也别拉太高L298N 的开关速度跟不上高频下损耗变大、模块发烫。1kHz 到 5kHz 是比较稳的区间。第二处改动是终止「每个 case 直接调函数」的写法换成状态机。原代码的问题在于状态是隐含在最后一次调用的函数里的主循环没有变量记录「现在到底在干什么」想加个「正转 2 秒后自动停」就得在 case 里塞延时把整个循环堵死。typedef enum { MOTOR_STOP 0, MOTOR_FWD, MOTOR_REV, MOTOR_BRAKE } motor_state_t; static motor_state_t s_state MOTOR_STOP; /* 上层只改目标状态不再直接操作引脚 */ void Motor_SetState(motor_state_t st) { switch (st) { case MOTOR_FWD: IN1(Low); IN2(High); IN3(Low); IN4(High); break; case MOTOR_REV: IN1(High); IN2(Low); IN3(High); IN4(Low); break; case MOTOR_BRAKE: IN1(High); IN2(High); IN3(High); IN4(High); break; default: IN1(Low); IN2(Low); IN3(Low); IN4(Low); break; } s_state st; } motor_state_t Motor_GetState(void) { return s_state; }有了状态变量就可以在while(1)里做「非阻塞定时」记录进入当前状态的时刻用delay_ms的替代品基于 SysTick 计数算差值超时就自动切换。这样按键响应和定时逻辑在同一个循环里并存不需要额外的定时器中断。改造清单整理成表按收益从高到低排改造点改动文件收益风险ENA/ENB 接 PWM 调速motor.c、main.c从全速变成 0~100% 可调L298N 需拔跳线帽接错会烧引脚状态机motor.c、motor.h可回溯当前状态支持超时逻辑状态切换要防止正反转硬切按键移植到 PB0/PB1key.c、key.h修复 C8T6 上按键失效硬件要重新接线加串口打印新增 usart.c能在电脑上看状态和速度占用 PA9/PA10编码器测速新增 encoder.c具备闭环基础需要外部中断或定时器捕获最后提醒一个我踩过的坑正转和反转之间不要直接切。H 桥两端瞬间反接会产生很大的反向电流L298N 虽然内部有一定保护但频繁硬切会让模块烫得离谱电机的减速箱齿轮也吃不消。正确做法是先切到MOTOR_STOP滑行或MOTOR_BRAKE刹车等 100ms 左右或者用编码器确认转速降到接近 0再切到反向。从那以后我每次改动电机方向逻辑都会在代码里强制走一遍「停 → 等 → 反向」的流程再也不图省事直接赋值了。希望这套接线和代码能帮你把第一个轮子顺利转起来剩下的调速、闭环、避障都可以在这条链路上继续往上长。本文还有配套的精品资源点击获取