
简介围绕STM32F103微控制器与L298N双H桥驱动模块的入门实践资料面向刚接触嵌入式GPIO输出、按键扫描与电机驱动的学生和自学者用于完成直流电机正反转及停止控制这一典型入门实验。正文以PDF形式呈现先列出器件清单与硬件接线对应关系IN1至IN4分别接PA2至PA5OUT1至OUT4接两台电机共地连接、12V供电以及升压板使用时的注意事项再给出main.c、motor.c、motor.h、key.c的完整代码与逐段说明并附有电源管理、中断响应优化、编码器测速扩展等改进思路。压缩包共1个PDF文件约6.97MB篇幅紧凑便于打印后对照实验。目前已有939人学习下载可帮助读者快速搭起硬件连线、理解推挽输出与按键扫描逻辑并在此基础上自行拓展闭环控制等功能。1. 从一块 5V 电源说起STM32F103C8T6 配 L298N 的第一次上电手边只有一块 5V 开发板供电、L298N 的 12V 端子空着电机要么纹丝不动要么闷响一声就停——做过这套组合的人大概都见过这一幕。这份资源给的是最小可跑通的一套STM32F103C8T6 用四个 GPIO 直连 L298N 的 IN1~IN4驱动两个直流电机做正转、反转、停止三个动作三个按键做输入代码拆成 motor.c、motor.h、key.c、key.h、main.c挂进任意一个带标准外设库的 stm32 工程就能编译。它适合刚把 GPIO 点灯跑完、想要一个有明确输入输出闭环的练手项目的人也适合手上堆着旧电机和驱动板、想快速验证接线对不对的熟手。后面几章把接线、驱动代码、按键扫描和翻车点一层层拆开包括原工程在 C8T6 上跑不通的那处引脚冲突。2. 硬件接线四个 IN 引脚、三根地线和一块升压板的落位2.1 L298N 的两个 H 桥与它的三个供电端子L298N 是一颗双 H 桥功率驱动芯片内部集成两个完整的 H 桥每个 H 桥由四只达林顿功率管构成靠两个逻辑输入控制对角管的通断从而改变输出端的极性。做成模块之后这些引脚被引到两侧排针上按功能分成三组电源组是 12V、GND、5V 三个端子控制组是 IN1~IN4 加 ENA、ENB输出组是 OUT1~OUT4。理解这三组的分工比背接线图有用得多。先说 12V 端子。它标的是 12V实际是电机母线输入 VS模块允许的输入范围比字面宽但真正决定电机能不能转的是电流而不是电压标称。L298N 内部是达林顿结构饱和压降不小输出到电机的电压通常比 VS 低 1.4~2V。这就意味着想让电机实际拿到 12VVS 得给到 13.5V 往上反过来如果电机铭牌写的是 6V 额定直接怼 12V 进去就是长期过压短时间内转得欢久了对碳刷和绕组都不友好。选电源时按「电机额定电压 2V」倒推 VS比按标称值硬套靠谱。再说 GND。模块中间那个 GND 是整块板子的参考地电机电流和逻辑电流最后都要从这里回流。很多人只把电源负极接上去忘了把 STM32 的 GND 也并过来结果就是单片机输出高电平、L298N 却「看不见」电机一动不动——这不是代码问题是两边没有共同参考点。最后说 5V 端子。它是模块板载稳压器的输出端作用是给逻辑电路供电同时也能对外输出 5V。当你用跳线帽把 ENA、ENB 短接到 5V 时H 桥就处于常使能状态只用 IN1~IN4 控制方向。但如果你打算用外部 5V 给模块逻辑供电就千万别再让这个端子和板载稳压输出顶在一起。原文里「5V 端口不接不然很难转动」说的就是这件事外部 5V 和板载 5V 打架逻辑电平被抬高H 桥进入异常状态表现出来就是电机带不动。ENA、ENB 两个使能端值得单独提一句。模块出厂时一般用跳线帽把它们短接到 5V等于占空比 100%。想后面做 PWM 调速就得先把这两颗跳线帽拔掉否则外部 PWM 信号和 5V 直接短接轻则调速无效重则把单片机引脚拉坏。2.2 从 OUT 到 IN一张接线表和两次通断测量先把接线固定下来再谈代码。这套资源里 STM32 侧用的是 PA2、PA3、PA4、PA5 四个脚对应 L298N 的 IN1~IN4输出侧 OUT1/OUT2 接第一个电机OUT3/OUT4 接第二个电机。整理成表是这样L298N 端子接到哪里注意事项12V外部电源正极升压板输出或独立电池组不要接到开发板的 5V 排针上GND电源负极同时引一条线到 STM32 的 GND两条线都要接缺一不可5V悬空外部已供电时接上会与板载稳压冲突IN1PA23.3V 推挽输出直接连IN2PA3同上IN3PA4同上IN4PA5同上ENA / ENB保留跳线帽短接到 5V后面做 PWM 时再拔OUT1 / OUT2电机 A 的两个端子转向反了就互换这两个OUT3 / OUT4电机 B 的两个端子同上表里最后两行的备注不是客套话。直流电机的转向只取决于绕组两端电压的极性而极性又只取决于 OUT1/OUT2 谁高谁低。当你发现按键按下去电机转的方向和预期相反最省事的做法不是去改代码里的正反转函数而是把电机那两根线对调——代码是逻辑接线是物理两边改一处就够改两处等于互相抵消最后排查起来更乱。接下来说两次通断测量这是上电前唯一值得花五分钟做的事。第一次断开所有电源用万用表蜂鸣档量 L298N 的 GND 和 STM32 开发板的 GND 排针应该响不响说明共地线没接好或者接在了别的网络。第二次量 OUT1 和 OUT2 之间的电阻正常应该读到几欧到几十欧电机绕组直流电阻如果读到无穷大说明电机线没插紧或者端子螺丝没压到铜丝如果读到接近 0那多半是 OUT1、OUT2 之间有短路上电就会触发电源保护。这两次测量之所以重要是因为 L298N 模块本身不带完善的保护。它有过流保护但响应慢电机堵转那一下的冲击电流足以先把模块上的续流二极管烤热。上电前用万用表排掉短路比上电后闻焦味强。2.3 共地、上电顺序与 3.3V 能不能驱动 L298N共地这件事前面提了两次这里把它说透。STM32 输出高电平是相对自己的 GND 而言的 3.3VL298N 判断高低电平是相对自己的 GND 而言的。两块板子的 GND 一旦不连各自的「0V」就不是同一个电位可能出现 STM32 输出 3.3V、L298N 那边测出来只有 1V 出头的情况逻辑被判成低电平电机自然不动。所以那根共地线不是可选项是信号通路的一部分。3.3V 能不能驱动 L298N 的输入端这是另一个高频疑问。L298N 的输入是 TTL 电平兼容结构数据手册给出的高电平门限典型值在 2.3V 左右低电平门限在 1.5V 以下。STM32F103C8T6 的推挽输出在 3.3V 供电下输出高电平接近 VDD驱动这个门限绰绰有余。真正需要注意的是上电瞬间PA2~PA5 在复位后默认是浮空输入电平不确定如果这时电机电源已经上电H 桥可能收到一个随机电平电机会抽一下。解决办法是在初始化函数里先写 GPIO_ResetBits 把四个脚拉低再配置成输出模式顺序别反。上电顺序我一般按这个来先接好所有地线和信号线确认无误给 STM32 上电观察到程序跑起来比如板载 LED 有反应最后给电机电源上电。断电时反过来先断电机电源再断控制电源。这样做的理由是只要控制侧先建立稳定的电平H 桥就不会在电源建立过程中收到毛刺信号。反过来的顺序在某些电源上会导致电机上电瞬间自转一下虽然不致命但每次上电都抖一下调参的时候很干扰判断。最后说升压板。原文作者手头只有 5V用升压板把 5V 抬到 12V 给 L298N 供电。这个方案能跑通但有两个物理限制一是升压板的输出电流能力常见的小型升压模块标称 2A实际持续输出 1A 左右就开始发热两个电机同时启动的冲击电流很容易顶到它的限流点表现就是电机转一下就停、升压板指示灯闪烁二是 5V 输入的来源如果是开发板的 USB 口那 USB 口本身也就 500mA 到 1A 的水平整条链路都是瓶颈。真要用升压方案给升压板的输入端并一颗 470uF 以上的电解电容能明显缓解启动瞬间的电压跌落。3. 驱动层代码GPIO 推挽初始化与正反转真值表的对应3.1 时钟、模式、速度三个初始化的取舍STM32 的任何一个 GPIO 要工作第一步都是开时钟。PA2~PA5 挂在 GPIOA 上而 GPIOA 的外设时钟挂在 APB2 总线上所以使能语句是RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE)。这一步漏了后面所有配置都写不进去现象是引脚电平永远是 0用示波器量也量不到变化——这类问题在初学者里出现频率很高因为代码能编译通过运行时却毫无反应。第二个参数是输出模式。这里必须选GPIO_Mode_Out_PP也就是通用推挽输出。推挽结构的含义是上管和下管都在输出高时上管导通把引脚拉到 VDD输出低时下管导通把引脚拉到 GND两种状态都有驱动能力。如果误选成开漏输出GPIO_Mode_Out_OD输出高电平时引脚是悬空的必须外接上拉电阻才能得到高电平直接连 L298N 的输入端就等于什么都没接电机只会朝一个方向转或者干脆不转。第三个参数是输出速度GPIO_Speed_50MHz。这个参数控制的是引脚翻转时的上升沿和下降沿斜率速度越高边沿越陡。对电机控制这种几十赫兹到几十千赫兹的开关频率来说2MHz 完全够用选 50MHz 并不会让电机转得更快只会让边沿更陡、高频谐波更多。不过在纯 GPIO 开关控制的场景下这个差别小到可以忽略按原工程的写法保持 50MHz 也没问题等到后面上 PWM 调速时才需要认真考虑这个参数对 EMI 的影响。初始化最后一步是把四个引脚全部拉低。GPIO_ResetBits(GPIOA, GPIO_Pin_2|GPIO_Pin_3|GPIO_Pin_4|GPIO_Pin_5)这一句的作用是给 H 桥一个确定的初始状态IN1~IN4 全低两个电机都处于停止。少了这一句引脚电平取决于复位后的默认值和上一次运行的残留上电瞬间可能抽一下。3.2 宏封装 IN1~IN4 与 do-while 的必要性原工程用宏来封装单个引脚的高低电平操作这个思路是对的电机控制函数里如果每次都写GPIO_SetBits(GPIOA, GPIO_Pin_2)代码会长得没法看而且引脚一改就要全文替换。宏把「引脚号」这个易变的东西收在一处后面要换引脚只改宏定义就行。但原工程的宏写法有个隐患#define IN1(a) if (a) \ GPIO_SetBits(GPIOA,GPIO_Pin_2);\ else \ GPIO_ResetBits(GPIOA,GPIO_Pin_2)这种展开方式在单独使用时没问题一旦放进if (x) IN1(1); else ...这样的上下文里宏内部的 else 会和外面的 else 配对编译器报错或者逻辑走偏。稳妥的写法是套一层 do-while(0)/* motor.h —— 引脚宏封装 */ #ifndef __MOTOR_H #define __MOTOR_H #include stm32f10x.h #include stm32f10x_gpio.h #define HIGH 1 #define LOW 0 /* do{...}while(0) 保证宏在多语句上下文中被当成单条语句 */ #define IN1(a) do { if (a) GPIO_SetBits(GPIOA, GPIO_Pin_2); \ else GPIO_ResetBits(GPIOA, GPIO_Pin_2); } while (0) #define IN2(a) do { if (a) GPIO_SetBits(GPIOA, GPIO_Pin_3); \ else GPIO_ResetBits(GPIOA, GPIO_Pin_3); } while (0) #define IN3(a) do { if (a) GPIO_SetBits(GPIOA, GPIO_Pin_4); \ else GPIO_ResetBits(GPIOA, GPIO_Pin_4); } while (0) #define IN4(a) do { if (a) GPIO_SetBits(GPIOA, GPIO_Pin_5); \ else GPIO_ResetBits(GPIOA, GPIO_Pin_5); } while (0) 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); #endifdo { ... } while (0)这个写法看起来绕实际作用是让整个宏在语法上表现为一条语句末尾的 while(0) 保证循环体只执行一次而分号由调用方补上。这样无论宏被放在 if、else、for 还是裸语句位置展开后都不会出现悬空的分号或错配的 else。这是 C 语言里处理多语句宏的标准做法写驱动层时值得养成习惯。参数方面HIGH和LOW只是可读性别名对应 1 和 0编译后没有额外开销。真正需要留意的是这四个宏硬编码了 GPIOA如果哪天要把 IN3、IN4 挪到 PB 口就得改宏体而不是改调用处。想做得更通用可以把端口和引脚号做成两个参数但对这个规模的项目来说属于过度设计。3.3 正转、反转、停止的三组电平组合H 桥的控制逻辑可以浓缩成一张真值表。以电机 A 为例IN1 和 IN2 两个输入决定 OUT1 和 OUT2 的极性动作IN1IN2OUT1OUT2电机状态正转01低高电流从 OUT2 流向 OUT1反转10高低电流从 OUT1 流向 OUT2停止11高高两端等电位绕组短接制动停止00低低同上也是短接制动表里最后两行都是停止区别在于哪一对管子导通。IN1IN21 时两个上管导通电机两端都被拉到高电平IN1IN20 时两个下管导通两端都被拉到低电平。两种状态下电机绕组都是被短接的产生的是制动转矩而不是自由滑行。如果确实需要电机自由滑行比如靠惯性走一段正确做法是把 ENA 拉低让 H 桥整体关断只把 IN1、IN2 置成相同电平是做不到的。第二路电机 B 完全对称IN3 对应 IN1、IN4 对应 IN2这里不重复列表代码里按同样规律写就行。实际接线时如果发现电机 A 按「正转」键转的方向和电机 B 相反不用改代码把 B 的 OUT3、OUT4 两根线对调即可。有一点容易被忽略切换方向时不要从「正转」直接跳到「反转」。H 桥在极性翻转的瞬间电机绕组是感性负载电流不能突变会产生一个反向电动势叠加在电源上轻则电源电压瞬间跌落导致单片机复位重则击穿功率管。稳妥的做法是在两个方向之间插一段停止状态停留几十毫秒让绕组电流衰减下去。原工程的按键逻辑里没有这一步属于可以接受的简化但如果你把电机换成功率更大的型号这个空档就必须补上。3.4 motor.c 完整实现与主循环调用把上面这些拼起来驱动文件是这样/* motor.c —— 两路直流电机的初始化与控制 */ #include motor.h #include stm32f10x_rcc.h #include delay.h /* 初始化 PA2~PA5 为推挽输出并先拉低 */ void Motor_12_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; /* GPIOA 挂在 APB2 上这步漏了后面全白搭 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); 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; GPIO_Init(GPIOA, GPIO_InitStructure); /* 先给 H 桥一个确定的停止状态避免上电抽动 */ GPIO_ResetBits(GPIOA, GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5); } /* 以下六个函数对应真值表命名里的 P/N 表示正转/反转 */ 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); }代码里的逻辑很直白值得说的是几个设计取舍。第一初始化函数不返回任何值也不做参数校验因为这个规模的驱动没必要但如果你的项目里电机数量会变把「引脚」和「电机编号」做成结构体数组会更省事。第二六个控制函数各自只改两个引脚不做任何延时延时留给调用方决定——驱动层保持纯粹上层才能自由组合出「正转 2 秒后停止」这类时序。第三STOP用的是两脚同高也就是短接制动如果你的应用需要滑行停止把它改成两脚同低也不会改变结果真正要滑行还是得动 ENA。主程序按资源里的写法用一个无限循环读键值再分派/* main.c —— 按键控制两路电机 */ #include stm32f10x.h #include motor.h #include delay.h #include key.h int main(void) { u8 key 0; Motor_12_Config(); /* 先初始化电机引脚给出确定的停止态 */ delay_init(); /* 延时函数初始化 */ KEY_Init(); /* 按键引脚初始化 */ while (1) { key KEY_Scan(0); /* 0 表示不支持连按 */ if (key) { switch (key) { case WKUP_PRES: /* 正转 */ Motor_1_PRun(); Motor_2_PRun(); break; case KEY1_PRES: /* 反转 */ Motor_1_NRun(); Motor_2_NRun(); break; case KEY0_PRES: /* 停止 */ Motor_1_STOP(); Motor_2_STOP(); break; default: break; } } else { delay_ms(10); /* 无按键时空转降低 CPU 占用 */ } } }switch里三个分支的动作是同时作用于两个电机的这一点符合「小车直行、后退、停车」的直觉。如果你要做的是差速转向就把两个电机的调用拆开比如左转时Motor_1_PRun()配Motor_2_STOP()右转反过来。default分支看起来多余但按键扫描函数返回的是 u8万一以后加了新键值忘了加 case有个 default 至少不会静默吞掉。delay_ms(10)放在 else 里的用意是省电和降低总线占用但这个写法有个副作用按键按下时循环转得飞快松开后每 10ms 才扫一次。对人工按键来说没问题但如果以后要接红外遥控或者串口指令这个节拍就得换成定时器中断。4. 按键层改造消抖、优先级与 C8T6 上没有 PE 口这件事4.1 KEY_Scan 的静态标志位和 10ms 消抖机械按键在按下和松开的瞬间触点会弹跳若干次持续时间通常在 5ms 到 20ms 之间。如果程序在这段时间里连续读引脚会读出一串高低交替的值表现出来就是「按一下电机启动了又停、停了又启」。消抖的本质就是绕开这段时间常见做法有两种硬件上并电容软件上延时后再读。这套代码用的是软件消抖核心是一个静态标志位/* key.c —— 按键扫描支持单次触发 */ #include key.h #include delay.h void KEY_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; /* 按键用 PB0、PB1上拉输入和 PA0下拉输入 */ 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); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPD; /* 下拉默认低按下为高 */ GPIO_Init(GPIOA, GPIO_InitStructure); } /* mode 0 单次触发mode 1 支持连按 */ u8 KEY_Scan(u8 mode) { static u8 key_up 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直到检测到三个按键全部松开才重新置 1。mode参数决定要不要保留这个锁定。传 0 是单次触发适合「按一下启动、再按一下停止」的场景传 1 时每次调用都会把 key_up 置 1等于取消了锁定按住不放会连续返回键值适合需要长按连续触发的场景但用在电机控制上会导致电机反复执行启停。宏定义在 key.h 里负责把引脚电平翻译成可读的键值/* key.h —— 按键宏与键值定义 */ #ifndef __KEY_H #define __KEY_H #include stm32f10x.h #include stm32f10x_gpio.h /* 直接读 GPIO 输入寄存器不依赖位带头文件 */ #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) #define KEY0_PRES 1 #define KEY1_PRES 2 #define WKUP_PRES 3 void KEY_Init(void); u8 KEY_Scan(u8 mode); #endif这里刻意没有用#include sys.h。原工程里引入这个头文件是为了使用位带操作的宏但 sys.h 属于某个特定开发板的配套库换一套工程环境就找不到文件编译直接报错。改用标准外设库的GPIO_ReadInputDataBit之后只要有 stm32f10x_gpio.c 就能编过移植成本低很多。位带操作确实快省掉一次函数调用的开销但按键扫描本来就跑在毫秒级这点开销完全看不出来。4.2 键值到电机动作的 switch 映射键值映射这块没什么玄机但有一个优先级问题值得说。KEY_Scan 内部的判断顺序是KEY0 KEY1 WK_UP这个顺序决定了同时按下多个键时谁赢。如果有人手误同时按了 KEY0 和 KEY1程序只会返回 KEY0_PRES也就是执行停止。这个行为本身是合理的——停止的优先级高于启动——但它是有意为之还是碰巧取决于写代码时有没有想过。想改优先级调换 if-else 链的顺序就行。另一个细节是键值和动作的对应。原工程里 WKUP 对应正转、KEY1 对应反转、KEY0 对应停止。WK_UP 这个键在多数开发板上是独立按键位置离另外两个较远正转放这里减少了误触停止用 KEY0通常是靠边的那个也方便紧急时刻拍下去。如果你的板子布局不一样改 switch 的 case 就行不用动扫描函数。真正需要小心的是「停止」和「反转」之间的时序。假设电机正在正转用户直接按反转键代码会立刻把 IN1 从 0 拉到 1、IN2 从 1 拉到 0中间没有任何空档。前面说过感性负载的电流不能突变这一下会在电源上产生一个不小的尖峰。稳妥的做法是在 switch 里加一层状态判断检测到方向切换时先调 STOP延时 50ms再调新方向/* 带方向切换保护的按键处理片段 */ static u8 last_dir 0; /* 0 停止1 正转2 反转 */ u8 new_dir 0; if (key WKUP_PRES) new_dir 1; else if (key KEY1_PRES) new_dir 2; else if (key KEY0_PRES) new_dir 0; /* 方向反转时先刹车再换向 */ if ((last_dir 1 new_dir 2) || (last_dir 2 new_dir 1)) { Motor_1_STOP(); Motor_2_STOP(); delay_ms(50); /* 等绕组电流衰减 */ } if (new_dir 1) { Motor_1_PRun(); Motor_2_PRun(); } else if (new_dir 2) { Motor_1_NRun(); Motor_2_NRun(); } else { Motor_1_STOP(); Motor_2_STOP(); } last_dir new_dir;这段代码多了十几行换来的是电源电压不再被反复拉低。用 5V 升压方案供电时这 50ms 空档几乎是必需品——升压板的动态响应跟不上突变负载省掉它会看到单片机时不时复位。4.3 把 PE3/PE4 换到 PB0/PB1 的重映射这里要处理原工程里最硬的一处冲突。标题写的是 STM32F103C8T6但正文里的芯片型号写成了 zet6而 key.c 里用的是 GPIOE 的 Pin_3 和 Pin_4。这两件事放在一起就矛盾了C8T6 是 48 脚封装可用的 IO 只有 PA0~PA15、PB0~PB15、PC13~PC15压根没有 PE 口。把带 GPIOE 的代码烧进 C8T6编译可能过得去因为寄存器地址是全局定义的运行时对 GPIOE 的写操作不会产生任何效果按键自然完全没反应。这个问题在新手手里特别容易卡住因为现象是「电机能转按键没反应」看起来像按键代码写错了实际上是用错了芯片的引脚图。判断方法很简单查一下手上芯片的封装48 脚就是 C8T6没有 E 口如果是 144 脚的 ZET6那 GPIOE 是存在的。两者代码不能直接互抄。改造方案是把按键挪到 C8T6 上真实存在的引脚。我一般用 PB0 和 PB1 做两个普通按键配成上拉输入PA0 保留作 WK_UP配成下拉输入——PA0 在 C8T6 上确实存在而且很多开发板把它接到了独立按键上。改完之后 key.c 里的初始化就是 4.1 节展示的那份。要注意 PB0、PB1 与 PA2~PA5 不冲突改动不影响电机引脚。选上拉还是下拉取决于按键的硬件接法。按键一端接引脚、另一端接 GND就用上拉输入默认读到 1按下读到 0按键一端接引脚、另一端接 VCC就用下拉输入默认读到 0按下读到 1。接反了的现象是「不按时电平就是按下状态」程序一上电就执行动作。原工程里 KEY0、KEY1 用 IPU、WK_UP 用 IPD说明前两个键接 GND、后一个键接 VCC这是开发板出厂时的硬件决定不是可以随便改的软件选项。另外原工程 key.c 里有两行连续的GPIO_InitStructure.GPIO_Pin赋值前一行写了GPIO_Pin_4|GPIO_Pin_3后一行又覆盖成GPIO_Pin_5。这是明显的笔误结构体成员的第二次赋值会把第一次覆盖掉结果是只初始化了一个引脚。改造时把两行合并成一行、或者分成两次GPIO_Init调用别把这个 bug 一起抄过去。4.4 去掉 delay_ms(10000) 与主循环节拍原工程 main.c 里在初始化之后有一句delay_ms(10000)也就是上电后先干等十秒。作者的解释是等电源稳定或者等某个模块就绪但对这套电路来说没必要反而让人以为程序没跑起来。我第一次跑的时候盯着板子看了十秒才反应过来血泪经验就是先把这个延时删掉或者改成 100ms。主循环的节拍也值得调一下。现在的结构是无按键时delay_ms(10)有按键时不延时、连续跑。这意味着按键按下的瞬间 CPU 是满速空转的虽然对功能没影响但把延时挪到循环末尾统一执行会更均匀while (1) { key KEY_Scan(0); if (key) { /* 动作分派 */ } delay_ms(10); /* 无论有无按键统一 10ms 节拍 */ }这样改的另一个好处是按键扫描的间隔固定消抖那 10ms 的窗口不会因为循环体长短变化而漂移。等以后要接定时器把这个delay_ms(10)换成定时器中断置标志位、主循环查询标志整个程序就自然过渡到了非阻塞结构。5. 排查现场电机不转、只抖不转、单边转的五种典型5.1 上电类的三个现场不转、抖、复位现象上电后电机纹丝不动用手摸 L298N 模块也不热单片机程序看起来在跑。原因通常是电源侧。第一种可能12V 端子没接或者接的电压不够H 桥没有母线电压输出端自然是死的。第二种可能共地线漏了STM32 输出的电平 L298N 判不出来。第三种可能ENA/ENB 的跳线帽被拔掉了但代码里没有 PWM 输出H 桥整体关断。解决按顺序走先拿万用表量 L298N 的 12V 端子和 GND 之间有没有电压没有就查电源和升压板有电压再量 STM32 的 GND 和 L298N 的 GND 是不是通的不通就补一根线最后检查 ENA/ENB 的跳线帽在不在位。三步走完绝大多数「完全不转」都能定位。现象电机动一下或者发出嗡嗡声然后停住模块发烫。原因是电流上不去。升压板的输出能力不够或者电机堵转或者电源线太细。L298N 内部有压降如果 VS 只有 5V 出头输出到电机可能只剩 3V 多不足以克服电机的启动转矩于是电机堵在那里电流全部转化成热。解决换更粗更短的电源线给升压板输入端并一颗 470uF 以上的电解电容吸收启动冲击如果电机是 12V 额定老老实实上独立电源别指望 5V 升压能带两个电机同时启动。现象一按按键单片机就复位或者程序跑飞。原因是电机启动或换向时的电流冲击把电源电压拉低了触发单片机的欠压复位。这条链路里最脆弱的是升压板它的输入是 5V输出是 12V负载突变时反馈环路来不及响应输出电压会掉一下。解决控制侧和电机侧分开供电至少让单片机的 5V 来自 USB 而电机侧来自独立电池在 L298N 的 12V 和 GND 之间并一颗 100uF 电解加一颗 0.1uF 瓷片换向时加 50ms 空档4.2 节那段代码。5.2 控制类的两个现场方向反了、按键失灵现象按正转键电机转了但方向和预期相反。原因只有一个电机绕组接到 OUT1/OUT2 的极性反了。H 桥本身没有「正转」的概念它只知道让 OUT1 高还是 OUT2 高。解决把电机的两根线对调或者把 motor.c 里Motor_1_PRun和Motor_1_NRun的两个电平互换。两种改法效果一样但建议只改一处——接线反了改接线代码反了改代码两边都改会互相抵消下次排查时就分不清到底是哪边的问题。如果两个电机方向不一致分别对调各自的 OUT 线即可。现象按键完全没反应但电机上电后自己会转一下。这个现象指向两个可能。一是按键引脚在 C8T6 上不存在也就是 4.3 节说的 PE3/PE4 问题二是按键的上拉/下拉配置和硬件接法不匹配导致程序读到的电平恒定不变。解决先确认芯片封装和可用 IO把 PE 口换到 PB 或 PA再用万用表量按键未按下和按下时引脚对地的电压上拉配置下未按应该是 3.3V、按下是 0V如果量出来一直是 0V 或者一直是 3.3V说明按键硬件没接到位或者配置反了。还有一种隐蔽情况按键按下有反应但按一次电机启动后就再也停不下来。这多半是key_up静态变量的解锁条件没满足——三个按键里有一个的电平一直停在「按下」状态比如 WK_UP 悬空被拉高导致KEY01 KEY11 WK_UP0永远不成立key_up 一直是 0后续键值全被吞掉。用万用表把三个脚都量一遍找出那个「一直按着」的脚。5.3 编译与引脚类的现场现象编译报错提示找不到 sys.h。原因sys.h是某套特定开发板配套库里的头文件提供位带操作的宏不是 STM32 标准外设库的一部分。换一套工程环境就没有这个文件。解决把 key.h 里的#include sys.h删掉按键读取改用GPIO_ReadInputDataBit。如果确实想用位带得把对应的 sys.c、sys.h 一起加进工程但那会增加不必要的移植负担。就按键扫描这点频率来说标准库函数完全够用。现象代码编译通过、烧录成功但所有引脚都没有输出。原因GPIO 的时钟没开。RCC_APB2PeriphClockCmd这一句漏掉或者写错了外设名后面的所有配置都是往一个没上电的寄存器里写。解决检查RCC_APB2PeriphClockCmd的参数是不是RCC_APB2Periph_GPIOA电机和RCC_APB2Periph_GPIOB按键。注意 GPIO 挂在 APB2 而不是 APB1写成RCC_APB1Periph_GPIOA编译不报错运行时无效属于典型的黑匣子型 bug。用调试器在初始化之后读一下 GPIOA 的 CRL 寄存器如果全是复位默认值基本就是时钟没开。6. 往上走一步用 TIM3 的 PWM 做调速和换向死区6.1 把 ENA/ENB 从跳线帽上摘下来GPIO 只能给出「全速」和「停止」两种状态想让小车慢下来或者做平滑加减速就得把 ENA、ENB 从 5V 跳线帽上拔下来改接 PWM。C8T6 上 TIM3 的 CH1、CH2 正好落在 PA6、PA7与已经在用的 PA2~PA5 不冲突接线只需两根杜邦线改动最小。思路是让 IN1~IN4 继续负责方向PWM 只负责速度IN 决定电流往哪个方向流ENA 的占空比决定这个电流平均有多大。这样原来的方向控制函数一行不用改只在外面套一层调速。/* 初始化 TIM3_CH1(PA6) / CH2(PA7) 输出 PWM频率约 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, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); /* TIM3 在 APB1 */ /* PA6/PA7 必须配成复用推挽普通推挽出不来波形 */ 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 0; 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_Pulse 0; /* 上电占空比为 0 */ TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM3, TIM_OCInitStructure); /* CH1 - PA6 - ENA */ TIM_OC2Init(TIM3, TIM_OCInitStructure); /* CH2 - PA7 - ENB */ TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_Cmd(TIM3, ENABLE); }arr和psc两个参数决定 PWM 的频率和分辨率。系统时钟 72MHz取 psc71 得到 1MHz 计数时钟再取 arr999PWM 频率就是 1kHz占空比可以分 1000 档。1kHz 对直流电机来说略低电机可能会有轻微啸叫常用范围是 5kHz 到 20kHz频率高了超出人耳范围、电流更平滑但开关损耗也上升。小功率电机取 10kHz 左右比较平衡此时 psc71、arr99分辨率降到 100 档调速够用。6.2 占空比与换向时序的写法设置速度只需要改比较寄存器的值注意 TIM3 是 16 位定时器CCR 的取值不能超过 arr/* duty: 0~100对应 0%~100% 占空比 */ void Motor_SetSpeed(u8 duty) { u16 ccr; if (duty 100) duty 100; /* 限幅防止越界 */ ccr (u16)((100 - duty) * 10); /* arr999 时满量程 1000 */ TIM_SetCompare1(TIM3, ccr); TIM_SetCompare2(TIM3, ccr); }100 - duty这个减法是配合 PWM1 模式的极性PWM1 模式下 CCR 越大高电平越长而 L298N 的 ENA 是高电平使能所以占空比越大电机越快这里的换算要根据实际极性验证一遍。我见过有人直接ccr duty * 10写下去结果速度反着走按「加速」反而变慢查了半天以为是硬件问题。换向时的死区靠软件补。正确的顺序是先把占空比降到 0延时 20ms 以上等绕组电流衰减再切 IN 引脚的方向最后把占空比缓缓升上去。切换方向时如果占空比还挂在 60%H 桥在极性翻转的瞬间相当于把电机绕组直接短路在电源本文还有配套的精品资源点击获取