简介:围绕STM32F103C8T6与L298N双H桥驱动模块控制两台直流电机正反转及停止的入门实践资料,面向刚接触STM32 GPIO输出、按键扫描与电机驱动的嵌入式初学者,可用于课程实验、小车底盘调试或毕业设计前期验证。压缩包共1个PDF文件,大小约6.97MB,以图文说明和代码清单组织,重点讲清L298N的OUT1~OUT4、IN1~IN4与PA2~PA5的对应接线、外部电源与共地处理,以及5V端口不宜直接供电等易错点。代码部分覆盖GPIO推挽输出初始化、按键键值判断、两台电机的正转、反转和停止控制,并给出电源管理、中断响应、编码器反馈与错误处理等优化方向。已有939人学习,适合按图接线后对照代码逐段理解控制逻辑,并据此扩展调速与状态检测功能。
1. 只有五个源文件的双电机例程,恰好够把 H 桥讲明白
手上有一块 STM32F103C8T6 最小系统板、一块 L298N、两个 TT 直流电机,想按键控制正反转,搜到的多半是整包智能小车工程:几十个.c、一堆用不上的外设、编译报错先卡半天。这份 STM32 软件例程走的是另一条路——C8T6 只干一件事,三个按键分别对应两个直流电机的正转、反转、停止,代码就motor.c/motor.h、key.c/key.h、main.c五个文件。它不教循迹和避障,却把 L298N 的 IN1~IN4 跟 GPIO 之间的电平关系、共地为什么必须接、停止为啥要两个输入同时拉高这几个最容易含糊过去的点,用最短路径摆在你面前。适合刚装好开发环境、第一次让 MCU 去驱动功率器件的人,也适合从老工程里抠电机模块、不想被框架绑住的人。
2. 接线先立规矩:L298N 四个输入脚与共地的那根线
硬件接错,代码写得再漂亮也是白搭。这一章把引脚选择和电源拓扑拆开讲,因为后面所有调试现象都能回溯到这两件事上。
2.1 为什么 IN1~IN4 落在 PA2~PA5 而不是随手挑四个脚
L298N 是双 H 桥,每个桥配两个逻辑输入,四个输入凑成一组最省事的做法就是落在同一个 GPIO 端口上。原始代码选 PA2、PA3、PA4、PA5,好处很实在:RCC_APB2PeriphClockCmd只开一次 GPIOA 的时钟,GPIO_InitTypeDef结构体只赋一次值,四个脚一次GPIO_Init全部搞定。少写一行时钟使能,就少一个「忘了开时钟、引脚纹丝不动」的翻车点,这种错误在调试器里看不出来,只能靠经验。
| STM32 引脚 | L298N 端子 | 控制的桥臂 | 备注 |
|---|---|---|---|
| PA2 | IN1 | 电机 A 左半桥 | 与 USART2_TX 复用 |
| PA3 | IN2 | 电机 A 右半桥 | 与 USART2_RX 复用 |
| PA4 | IN3 | 电机 B 左半桥 | 可作 SPI1_NSS |
| PA5 | IN4 | 电机 B 右半桥 | 可作 SPI1_SCK |
这张表最后两列是选型的边界。PA2/PA3 同时是 USART2 的收发脚,如果你打算用串口打日志看按键值,这两根线就不能再给 L298N;PA4/PA5 挂着 SPI1,接了 SPI 屏或 Flash 也一样冲突。实际项目里我一般把电机四根线挪到 PB6~PB9 或者 PB12~PB15,把 PA2/PA3 留给串口,调试阶段能省下大量猜测时间。
另一个常被担心的问题是电平匹配。L298N 的逻辑输入高电平门限在手册里标到 2.3V 起,STM32 推挽输出高电平接近 3.3V,直接对接完全够用,不需要加电平转换。反倒要注意别选到 PA13、PA14、PA15、PB3、PB4 这几个脚,它们默认被 SWD 调试口和 JTAG 占用,选上了会表现为「程序确实烧进去了,但引脚电平不动」,查起来相当费劲。
2.2 12V、GND、5V:三个端子里最容易被误读的是 5V
L298N 模块上有三个电源相关端子:12V(有些板子标 VS 或 VCC)、中间的 GND、以及 5V。板上带一颗三端稳压,配合一个跳线帽决定 5V 从哪来,这个跳线帽的状态直接决定模块能不能正常工作。
跳线帽短接时,12V 端子的输入经板载稳压变成 5V,供 L298N 内部逻辑使用,同时 5V 端子对外输出 5V,可以顺手给 MCU 供电(电流不大,带最小系统板可以,带一堆外设就别指望了)。跳线帽拔掉时,板载稳压断开,必须从 5V 端子外部灌入 5V,否则内部逻辑不上电,H 桥根本不响应输入信号。
原始资源里那句「5V 端口不接,不然很难转动」,说的其实是跳线帽还在的情况下又从外部灌了 5V。两个 5V 源顶在一起,逻辑电平被抬到不该有的高度,H 桥工作异常,电机就转得又慢又没劲。正确做法是二选一:要么保留跳线帽、5V 端子悬空,要么拔掉跳线帽、外部供 5V。
共地是另一条硬规矩。L298N 中间的 GND 必须引一条线到 STM32 的 GND,因为 IN1~IN4 的高低电平是以 L298N 自己的地为参考的,两边地不连,信号就是浮空状态,表现从「完全不动」到「时动时不动」都有。这条线同时也是电机大电流回流路径的一部分,线径别用太细的杜邦线。
供电拓扑按手头器件分三种,差别不小:
| 拓扑 | 12V 端子 | 5V 端子 | 跳线帽 | 适用场景 |
|---|---|---|---|---|
| 单电源升压 | 接升压板输出 | 悬空 | 保留 | 只有 5V 电源,小功率电机 |
| 双电源 | 接独立电池组 | 悬空 | 保留 | 推荐,电机与逻辑隔离 |
| 外部 5V 供逻辑 | 接电机电源 | 接 5V 源 | 拔掉 | 12V 输入异常或需要精确控制逻辑电压 |
升压板那条路要特别算功率。TT 马达空载电流 0.15A 左右,堵转能到 0.8A,两个电机同时堵转接近 1.6A,再加上 L298N 自身损耗,标称 2A 的升压板基本是在极限上跑,启动瞬间电压一塌,MCU 跟着复位。这是后面排查章节里反复出现的现象源头。
2.3 上电前的一张检查表
接线完成别急着插 USB,按下面顺序走一遍,能挡掉八成低级问题:
- 全部断电,万用表蜂鸣档测 L298N 的 GND 与 MCU 的 GND,必须导通。
- 测 12V 端子对 GND 的电阻,不能是 0 或几欧,短路说明电源接反或板子坏了。
- 测 OUT1 对 OUT2、OUT3 对 OUT4,同样不能短路。
- 确认跳线帽状态和你选的供电拓扑一致。
- 先不接电机,只上电,量 5V 端子应该有稳定的 5V。
- 烧一个只翻转 GPIO 的测试程序,按键切换时量 PA2/PA3 电平是否按预期翻转。
- 以上都对,再接电机,第一次上电用手轻捏电机轴感受转向和力矩。
第 6 步是关键分水岭。电平不对就往代码和引脚配置上查,电平对了但电机不动就往电源和接线查,这两条岔路早点分开,能省掉大量来回折腾。
3. 把真值表翻译成 GPIO:motor.c 里的三个函数与一组宏
硬件搭好之后再看代码,会发现整个电机控制的核心其实只有一张四行的真值表,剩下都是工程包装。
3.1 H 桥真值表:为什么停止要两个输入都拉高
L298N 每个桥臂由上下两个功率管组成,两个逻辑输入分别控制左右半桥,组合出四种状态:
| IN1 | IN2 | 导通的管子 | 电机状态 | 停止方式 |
|---|---|---|---|---|
| 0 | 1 | 左下 + 右上 | 正转 | — |
| 1 | 0 | 左上 + 右下 | 反转 | — |
| 1 | 1 | 两个下管 | 停止 | 刹车(绕组短接) |
| 0 | 0 | 全部关断 | 停止 | 滑行(自由停车) |
原始代码的Motor_1_STOP()写的是IN1(High); IN2(High);,取的是第三行,也就是刹车。这时候电机绕组被下端两个管子短接,转动产生的反电动势在绕组内形成电流,动能迅速变成热耗散掉,停得干脆利落。第四行的 00 是自由停车,绕组开路,转子靠摩擦和负载慢慢停下来。
两种停法没有对错,看场景选。做定位停、需要快速响应下一次启动,选 11 刹车;做省电待机、不想让电机突然一顿,选 00 滑行。用分立 MOS 自己搭 H 桥的话,11 这个状态要格外小心,必须自己加死区,否则上下管直通就是炸管;L298N 内部有互锁逻辑,直接给 11 不会出问题,这也是模块化方案对新手友好的地方。
3.2 宏封装与它的两个副作用
motor.h里那组IN1(a)到IN4(a)的宏,本质是把「根据参数置位或复位某个引脚」压缩成一行调用:
#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)参数a传High时置位,传Low时复位。这样Motor_1_PRun()里两行IN1(Low); IN2(High);就把真值表第一行落到了寄存器上,读起来跟真值表一一对应,这是它讨巧的地方。
问题出在宏没有用do { ... } while(0)包裹。假设你这么写:
if (speed > 0) IN1(High); else IN1(Low);预处理展开之后,else会跟宏内部的if (a)配对,而不是跟外层的if (speed > 0)配对,这就是经典的悬挂 else 问题,编译不报错,运行行为完全不对。第二种情况是参数带副作用,比如IN1(GPIO_ReadInputDataBit(...)),虽然宏里只求值一次,但可读性已经很差。
我的习惯是直接换成内联函数,编译出来指令一样,还能在调试器里下断点:
/* 用 static inline 替代宏:类型安全、可下断点、无悬挂 else 风险 */ static inline void IN1(uint8_t level) { if (level) GPIO_SetBits(GPIOA, GPIO_Pin_2); else GPIO_ResetBits(GPIOA, GPIO_Pin_2); }static inline在头文件里定义,每个编译单元各生成一份,不会有多重定义问题,F103 这种资源紧张的芯片也不用担心函数调用开销。
3.3 Motor_12_Config:时钟、模式、初值三件事
初始化函数只有六行,但每行都有讲究:
void Motor_12_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; /* GPIOA 挂在 APB2 总线上,F103 所有 GPIO 都在 APB2 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); /* 四个 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; /* 压摆率,不是时钟频率 */ GPIO_Init(GPIOA, &GPIO_InitStructure); /* 上电默认全低,对应真值表第四行:自由停车 */ GPIO_ResetBits(GPIOA, GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5); }GPIO_Speed_50MHz是最容易被误解的参数。它选的不是「这个脚跑 50MHz」,而是输出驱动电路的压摆率档位,F103 有 2MHz、10MHz、50MHz 三档。档位越高边沿越陡,开关损耗小但 EMI 略大。驱动 L298N 这种输入电容不大的负载,2MHz 就绰绰有余,原始代码用 50MHz 不会错,只是没必要。
最后一行把四个脚全部拉低,对应真值表第四行的自由停车。如果写成全部拉高,上电瞬间就是刹车状态,电机不动但绕组被短接,长时间通电会发热。更糟的写法是四个脚初值不一致,比如两个高两个低,上电瞬间电机会突然抽一下,这在带机械限位的机构上可能直接撞坏东西。初值统一拉低是最稳妥的选择。
3.4 按键扫描与主循环的调度
key.c里的KEY_Scan是一份很典型的阻塞式扫描代码,值不值得抄另说,先看懂它的三个机制:
u8 KEY_Scan(u8 mode) { static u8 key_up = 1; /* 松手标志,静态变量跨调用保持 */ if (mode) key_up = 1; /* mode=1 时支持连按 */ if (key_up && (KEY0 == 0 || KEY1 == 0 || WK_UP == 1)) { delay_ms(10); /* 软件去抖,10ms 是经验值 */ 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; }第一个机制是static u8 key_up,靠静态变量在两次调用之间记住「上一次是不是已经处理过按下」,实现松手检测。没有它,按住不放时主循环每 10ms 就会返回一次键值。
第二个机制是 10ms 延时去抖。机械按键触点闭合瞬间会有几毫秒的抖动,不滤掉的话一次按下可能被识别成好几次。
第三个机制是优先级,if-else链决定了 KEY0 优先于 KEY1,KEY1 优先于 WK_UP。同时按下两个键时只有优先级高的那个生效,做组合键的话这套逻辑要重写。
主循环那边:
while (1) { key = KEY_Scan(0); /* mode=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); /* 无按键时降低空转频率 */ } }case里的delay_ms(1)其实没什么意义,因为下一次循环进KEY_Scan还会再耗 10ms,删掉不影响行为。真正值得留意的是这套结构的局限性:KEY_Scan内部有 10ms 阻塞延时,主循环无按键时又阻塞 10ms,CPU 大部分时间都在空转。要加 PWM 调速、要接编码器测速、要做多任务,这套阻塞式扫描就得换掉,改成定时器中断里扫键加状态机,或者干脆上 RTOS。原始代码的定位是「跑通」,不是「跑好」,这个边界心里要有数。
4. 从空工程到按键响应:五步把这份代码跑起来
代码看懂了,接下来是把它塞进一个能编译、能下载、能验证的工程里。这一步的坑比写代码多。
4.1 工程骨架:五个文件放哪、启动文件怎么选
按下面的目录摆放,编译器才找得到头文件:
MotorDemo/ ├── CORE/ │ ├── core_cm3.c │ ├── core_cm3.h │ └── startup_stm32f10x_md.s # 中容量启动文件,64KB Flash ├── HARDWARE/ │ ├── MOTOR/ │ │ ├── motor.c │ │ └── motor.h │ └── KEY/ │ ├── key.c │ └── key.h ├── STM32F10x_FWLib/ # 标准外设库 │ ├── inc/ │ └── src/ ├── USER/ │ ├── main.c │ ├── stm32f10x_conf.h │ └── stm32f10x_it.c └── SYSTEM/ ├── delay.c ├── delay.h ├── sys.c └── sys.h # key.h 依赖它启动文件必须选startup_stm32f10x_md.s。STM32F103C8T6 是 64KB Flash、20KB RAM 的中容量型号,对应md;选hd(大容量,256KB 以上)或ld(小容量,16~32KB),中断向量表长度和栈初始化都会对不上,典型现象是程序下载后不进入main,或者一进中断就 HardFault。
KEY目录下的key.h引用了sys.h,这是某类配套库工程里放位带操作和系统定义的头文件。手上没有这套库的话,把key.h里那几行位带宏换成标准的GPIO_ReadInputDataBit就行,功能完全等价。
4.2 编译与下载:三个必须打开的宏
在 IDE 的预定义宏里必须有这两项,否则标准库编译不过或者行为不对:
# Keil MDK:Options for Target -> C/C++ -> Define STM32F10X_MD, USE_STDPERIPH_DRIVER # 命令行工具链等价写法 arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb \ -DSTM32F10X_MD -DUSE_STDPERIPH_DRIVER \ -I./CORE -I./STM32F10x_FWLib/inc -I./USER \ -I./HARDWARE/MOTOR -I./HARDWARE/KEY -I./SYSTEM \ -Os -ffunction-sections -fdata-sections \ main.c motor.c key.c delay.c sys.c \ STM32F10x_FWLib/src/*.c \ startup_stm32f10x_md.s \ -T stm32f103c8t6.ld -o MotorDemo.elfSTM32F10X_MD决定标准库里的寄存器定义和中断号,USE_STDPERIPH_DRIVER让stm32f10x.h去包含stm32f10x_conf.h。少任何一个,报错都是几十行起步,而且报的位置跟真实原因差很远。
烧录用 SWD 四根线(3.3V、GND、SWDIO、SWCLK),OpenOCD 的命令是这样的:
openocd -f interface/stlink.cfg -f target/stm32f1x.cfg \ -c "program MotorDemo.elf verify reset exit"如果提示找不到目标,先查 SWDIO/SWCLK 有没有接反,再查目标板是不是被低功耗模式锁住了——真锁住了就用connect under reset方式重新连。
4.3 第一次上电:不接电机先量电平
这一步别省。接上电机再测,转速看不清、万用表读数一直在跳,判断不了是代码问题还是功率问题。
烧录完成后不接电机,万用表直流电压档,黑表笔接 GND,红表笔依次点 PA2、PA3,按不同按键记录读数:
| 按键 | PA2 (IN1) | PA3 (IN2) | 电机 A 预期 |
|---|---|---|---|
| WK_UP | 0V | 3.3V | 正转 |
| KEY1 | 3.3V | 0V | 反转 |
| KEY0 | 3.3V | 3.3V | 刹车 |
| 无按键 | 0V | 0V | 自由停车 |
实测和这张表对不上,问题一定在软件或接线,跟电机、电源都没关系,别往那边浪费时间。四行全部正确,再往下走。
4.4 接上电机后的三个观察点
接电机上电,按正转键,看三件事:转向对不对、转速是否稳定、L298N 散热片温度。
转向反了先别改代码,把 OUT1 和 OUT2 两根线对调最快,也避免两个电机的逻辑方向不一致。转速不稳、伴随周期性顿挫,多半是电源功率不够,回头查 2.2 节那张供电拓扑表。散热片烫手但要区分程度,L298N 是双极型工艺,饱和压降 1.4~2V,2A 电流下自身功耗 3~4W,摸着烫属于正常,烫到不能碰才是散热不够或者电流超标。
5. 避坑与排查:C8T6 上没有 GPIOE 这件事,得先说清楚
原始工程跑在一块 100 脚以上的大容量板子上,直接搬到 C8T6 会撞上一连串问题。这一章按「现象 → 原因 → 解决」把最常见的五个记录下来。
5.1 编译零错误,但按键全都没反应
现象:编译通过,烧录成功,按键按下去电机纹丝不动,调试器里看KEY_Scan永远返回 0。
原因:key.c里开的是RCC_APB2Periph_GPIOE,初始化的是GPIOE,key.h里KEY0、KEY1读的也是GPIOE_Pin_4和GPIOE_Pin_3。STM32F103C8T6 是 48 脚封装,端口只到 GPIOA/GPIOB/GPIOC,根本没有 GPIOE。标准库头文件里RCC_APB2Periph_GPIOE这个宏是存在的,编译不报错,但寄存器写进去没有任何硬件响应,读回来恒为 0。
解决:把按键挪到实际存在的端口上,比如 PB0、PB1 接两个独立按键,PA0 留给 WK_UP。三处要同步改:RCC_APB2PeriphClockCmd的端口宏、GPIO_Init的第一个参数、key.h里读引脚的宏定义。
顺带还有个小坑:key.c里连着写了两行GPIO_InitStructure.GPIO_Pin = ...,第二行把第一行覆盖掉了,Pin_3|Pin_4实际没生效,只初始化了Pin_5。改成GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5才是本意。
还有一个更隐蔽的现象:如果只改了端口没改key.h的读脚宏,会出现「WK_UP 能用、另外两个键死活没反应」——因为 PA0 在 C8T6 上确实存在,而 GPIOE 不存在。
5.2 上电十秒内怎么按都没反应
现象:烧录后立刻按键,完全没响应,心里一凉以为烧失败了;过十秒左右突然一切正常。
原因:main.c里Motor_12_Config()之后有一句delay_ms(10000),而且它在KEY_Init()前面。这十秒里按键 GPIO 根本还没配置成输入,KEY_Scan也没开始跑。
解决:改成delay_ms(100)或者直接删掉。如果当初加这句是为了等电源稳定,那也应该放在最前面、且不超过 200ms。调试阶段遇到「过一会儿自己好了」的现象,第一反应就该去main里找有没有长延时。
5.3 电机只嗡嗡响,或者一转 MCU 就复位
现象 A:单独给 L298N 供电,按键时 MCU 侧指示灯有反应,电机偶尔抽动一下但不转。
原因:L298N 的 GND 和 MCU 的 GND 没连。IN 信号以 L298N 自己的地为参考,两边地不共,电平就是浮的,逻辑判断随机。
解决:L298N 中间的 GND 引一条线到 MCU 的 GND。注意这条线同时承担电机电流回流,别用最细的那种杜邦线。
现象 B:空载转得挺欢,电机一落地或者一加负载就停,同时 MCU 跟着复位重启。
原因:电源功率不够。升压板输出电流撑不住电机启动瞬间的冲击,母线电压被拉垮,MCU 的 3.3V 跟着跌到欠压复位门限以下,触发 BOR。
解决:电机和 MCU 分开供电、只共地;或者在电机母线上并 470µF~1000µF 电解电容吸收启动冲击。根本上还是换一个电流余量足够的电源。
现象 C:电机转得又慢又没劲,用手一捏就停,L298N 发烫。
原因:跳线帽还在的情况下又从 5V 端子外接了 5V 电源,两个 5V 源顶在一起。
解决:二选一。要么保留跳线帽、5V 端子悬空,要么拔掉跳线帽、只从外部供 5V。
5.4 转向反了、按键连发、芯片烫手
现象 A:按正转键,电机往反方向跑。
原因:OUT1/OUT2 接反,或者电机两根线接反。
解决:对调 OUT1 和 OUT2 两根线。不建议改代码里的电平逻辑,因为两个电机如果各自改一遍,后面看代码的人会分不清到底哪个是「正转」。
现象 B:按一次正转,电机一直转,必须再按停止才停。
原因:这不是 bug,是点动与自锁的区别。KEY_Scan返回的是「按下事件」,但 GPIO 电平一旦置好就会一直保持,所以电机会持续转。原始代码的设计就是「按一下启动、按一下停止」。
解决:想要「按住转、松手停」,得在读键之后判断松开状态,或者在主循环里持续检测按键电平,松开就调Motor_x_STOP()。
现象 C:跑几分钟 L298N 散热片烫得摸不得。
原因:双极型 H 桥的固有压降,1.4~2V 乘以电流就是纯热耗。12V 供电时电机实际只拿到 10V 左右,这也是为什么很多人觉得「12V 电源带 12V 电机却转不快」。
解决:加散热片、降低占空比、缩短连续运行时间。真要长时间大电流跑,换 MOS 管方案的驱动模块,压降能降到 0.1V 量级。
6. 把 ENA/ENB 从跳线帽上拔下来:PWM 调速与软启动
原始代码只能全速正反转,因为 ENA、ENB 两个使能端被跳线帽短接到 5V,永远处于使能状态。想要调速,第一步是拔掉这两个跳线帽,把使能端接到能输出 PWM 的定时器通道上。
6.1 引脚重选与定时器配置
选 PB6、PB7 对应 TIM4_CH1、TIM4_CH2 比较稳妥,避开 PA0(被 WK_UP 占用)和 PA2/PA3(留给串口)。初始化代码如下:
void Motor_PWM_Init(u16 arr, u16 psc) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE); /* TIM4 在 APB1 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); /* PB6/PB7 配置为复用推挽,PWM 由定时器直接驱动引脚 */ 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(GPIOB, &GPIO_InitStructure); /* arr 决定频率,psc 决定计数时钟,72MHz/(psc+1)/(arr+1) = PWM 频率 */ TIM_TimeBaseStructure.TIM_Period = arr; TIM_TimeBaseStructure.TIM_Prescaler = psc; TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM4, &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(TIM4, &TIM_OCInitStructure); /* CH1 -> PB6 */ TIM_OC2Init(TIM4, &TIM_OCInitStructure); /* CH2 -> PB7 */ TIM_OC1PreloadConfig(TIM4, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM4, TIM_OCPreload_Enable); TIM_Cmd(TIM4, ENABLE); } /* 运行时改占空比,0~100 映射到 0~arr */ void Motor_SetSpeed(u8 ch, u8 percent) { u16 pulse = (u16)((u32)percent * (TIM4->ARR + 1) / 100); if (ch == 1) TIM_SetCompare1(TIM4, pulse); else TIM_SetCompare2(TIM4, pulse); }频率怎么定:
| PWM 频率 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 1~3kHz | 开关损耗低 | 落在人耳可闻区间,电机有明显啸叫 | 不推荐 |
| 8~12kHz | 超出听觉敏感区,转矩平滑 | 开关损耗可接受 | 常用 |
| 20kHz 以上 | 完全静音 | L298N 开关损耗上升,发热加剧 | 小电流场景 |
72MHz 主频下取psc=71、arr=99,得到 10kHz,正好落在中间那档。
6.2 从占空比到实际转速:验证方法
改完 PWM 别只看电机转不转,用示波器量 OUT1 对 OUT2 的差分波形,看到的是幅值等于母线电压、占空比可调的方波。万用表直流档量 OUT1 对 GND 得到的是平均值,占空比 50% 时读数约为母线电压的一半,这个数值可以直接换算成电机的等效驱动电压。
软启动值得加。直接把占空比从 0 跳到 80%,启动电流冲击能达到额定值的五到七倍,升压板很容易被拉垮。用十步斜坡,每步延时 20ms,从 0 爬到目标值,启动会平顺很多。
最后说个自己的教训。早些年调这类双电机板子,我总爱先把电机接上再看效果,结果每次出问题都要在「代码、接线、电源」三件事之间来回猜。后来养成习惯:任何一块新板子第一次上电,都强制先空载量引脚电平、再空载量 OUT 端电压、最后才接电机,三道都过了才进功率环节。这个顺序麻烦一点,但省下的时间远多于多花的那几分钟。希望帮到你。
本文还有配套的精品资源,点击获取