TIM4四通道PWM驱动智能小车:从PWM初始化到运动控制代码

STM32笔记专栏 智能小车专栏

🎯 适用平台:STM32F103C8T6(STM32F10x系列通用)
🎯 开发环境:Keil5 + 标准库 V3.5.0
🎯 核心外设:TIM4(CH1~CH4)+ GPIOB(Pin6~Pin9)
一、前言
大家好!从今天开始我们就来讲解关于stm32的智能小车,这篇文章就来聊聊怎么用STM32的TIM4四通道PWM来控制智能小车的基本运动。
如果你做过小车,一定遇到过这个问题:给电机直接通高电平,电机"嗡"的一下全速转,小车直接冲出去,根本没法控制快慢。而PWM(脉冲宽度调制)就是解决这个问题的关键——通过调节占空比,让电机获得不同的平均电压,从而实现调速。
STM32F103C8T6的TIM4有4个独立的PWM通道(CH1~CH4),正好对应小车的四个轮子(左前、左后、右前、右后),简直是给小车量身定做的!
📌 本文要解决的问题:
① TIM4四通道PWM怎么初始化?ARR和PSC怎么算?
② 四个通道怎么分别控制四个电机?
③ 前进、后退、左转、右转、原地旋转的代码逻辑怎么写?
④ 实际接线中有哪些坑要注意?
二、硬件接线与原理图说明
在讲代码之前,先搞清楚引脚对应关系,不然后面代码看懂了,线接错了小车照样不动。

| 功能 | TIM通道 | GPIO引脚 | 对应电机 |
|---|---|---|---|
| 左前轮PWM | TIM4_CH1 | PB6 | 左前轮 |
| 左后轮PWM | TIM4_CH2 | PB7 | 左后轮 |
| 右前轮PWM | TIM4_CH3 | PB8 | 右前轮 |
| 右后轮PWM | TIM4_CH4 | PB9 | 右后轮 |
我的理解:TIM4挂在APB1总线上,最大时钟是72MHz,所以PSC和ARR的计算都要基于72MHz来。PB6~PB9这四个引脚默认是普通GPIO,必须配置成复用推挽输出(AF_PP),否则TIM4的PWM信号走不到引脚上,电机驱动板收不到信号。
三、知识点讲解
3.1 PWM频率的计算:20kHz是怎么来的?
在PWM_Init()中,我们看到这样两行:
TIM_TimeBaseStructure.TIM_Period = 100 - 1; // ARR = 99
TIM_TimeBaseStructure.TIM_Prescaler = 36 - 1; // PSC = 35
PWM频率的计算公式是:
fPWM=fCLK/(PSC+1)/(ARR+1)=20kHz
为什么选20kHz? 因为人耳能听到的声音范围大概是20Hz~20kHz,如果PWM频率太低(比如1kHz),电机会发出刺耳的"吱吱"声,这就是电磁噪音。把频率拉到20kHz以上,就超出了人耳听力范围,电机运行起来就"静音"了。
⚠️有些电机驱动板(比如L298N)的开关频率不能太高,如果超过驱动芯片的响应速度,PWM波形会失真。建议先用示波器或逻辑分析仪看一下PB6~PB9输出的波形是否规整。
3.2 TIM4四通道PWM初始化详解(PWM.c)
下面是完整的PWM.c代码,每一行我都加了详细解析,建议对照着看:
#include "stm32f10x.h" // 包含STM32F10x标准外设库头文件,里面定义了所有寄存器和库函数
/**
* @brief TIM4四通道PWM初始化函数
* @note 配置TIM4的CH1~CH4输出PWM,对应GPIOB_Pin6~Pin9
* PWM频率 = 72MHz / (36) / (100) = 20kHz
*/
void PWM_Init(void)
{
GPIO_InitTypeDef GPIO_InitStructure; // 定义GPIO初始化结构体,用于配置PB6~PB9
TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; // 定义定时器时基初始化结构体,配置ARR、PSC等
TIM_OCInitTypeDef TIM_OCInitStructure; // 定义定时器输出比较初始化结构体,配置PWM模式
/* ========== 第一步:开启时钟 ========== */
// 使能TIM4定时器时钟,TIM4挂在APB1总线上,必须用RCC_APB1PeriphClockCmd
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE);
// 使能GPIOB端口时钟,PWM输出引脚PB6~PB9属于GPIOB,用APB2总线时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
/* ========== 第二步:配置GPIO为复用推挽输出 ========== */
// 同时选中PB6、PB7、PB8、PB9四个引脚,用按位或"|"连接
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7 | GPIO_Pin_8 | GPIO_Pin_9;
// 配置为复用推挽输出模式(AF_PP),让TIM4的PWM信号能从引脚输出
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
// GPIO输出速度设为50MHz,PWM是高频信号,速度不能太低
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
// 调用GPIO_Init函数,把上面的配置写入GPIOB的寄存器
GPIO_Init(GPIOB, &GPIO_InitStructure);
/* ========== 第三步:配置定时器时基参数 ========== */
// 设置自动重装载值ARR = 99,计数器从0数到99,一共100个计数周期
TIM_TimeBaseStructure.TIM_Period = 100 - 1;
// 设置预分频系数PSC = 35,72MHz / 36 = 2MHz,定时器每0.5us计数一次
TIM_TimeBaseStructure.TIM_Prescaler = 36 - 1;
// 时钟分频因子设为0,表示不再对定时器时钟进行二次分频
TIM_TimeBaseStructure.TIM_ClockDivision = 0;
// 选择向上计数模式,计数器从0开始递增,数到ARR后溢出归零
TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
// 调用TIM_TimeBaseInit,把时基参数写入TIM4的寄存器
TIM_TimeBaseInit(TIM4, &TIM_TimeBaseStructure);
/* ========== 第四步:配置TIM4_CH1的PWM输出比较模式 ========== */
// 选择PWM模式1:向上计数时,CNT < CCR输出有效电平(高电平),CNT >= CCR输出无效电平(低电平)
TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1;
// 使能比较输出,把PWM信号真正输出到对应的GPIO引脚上
TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable;
// 设置初始比较值CCR = 0,初始占空比 = CCR/(ARR+1) = 0/100 = 0%,电机初始不转
TIM_OCInitStructure.TIM_Pulse = 0;
// 设置输出极性为高电平有效,即CNT < CCR时输出高电平
TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High;
// 初始化TIM4的通道1(CH1),对应PB6,控制左前轮
TIM_OC1Init(TIM4, &TIM_OCInitStructure);
/* ========== 第五步:配置TIM4_CH2的PWM输出比较模式 ========== */
// 同样配置为PWM模式1
TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1;
// 使能通道2的比较输出
TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable;
// 初始占空比设为0
TIM_OCInitStructure.TIM_Pulse = 0;
// 高电平有效
TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High;
// 初始化TIM4的通道2(CH2),对应PB7,控制左后轮
TIM_OC2Init(TIM4, &TIM_OCInitStructure);
/* ========== 第六步:配置TIM4_CH3的PWM输出比较模式 ========== */
// PWM模式1
TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1;
// 比较输出使能
TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable;
// 初始占空比0
TIM_OCInitStructure.TIM_Pulse = 0;
// 高电平有效
TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High;
// 初始化TIM4的通道3(CH3),对应PB8,控制右前轮
TIM_OC3Init(TIM4, &TIM_OCInitStructure);
/* ========== 第七步:配置TIM4_CH4的PWM输出比较模式 ========== */
// PWM模式1
TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1;
// 比较输出使能
TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable;
// 初始占空比0
TIM_OCInitStructure.TIM_Pulse = 0;
// 高电平有效
TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High;
// 初始化TIM4的通道4(CH4),对应PB9,控制右后轮
TIM_OC4Init(TIM4, &TIM_OCInitStructure);
/* ========== 第八步:使能预装载寄存器(重要!) ========== */
// 使能TIM4_CH1的CCR预装载,修改CCR值时不会立即生效,而是等下一个更新事件(溢出)时才生效
// 这样可以避免PWM占空比在计数中途跳变,防止电机抖动
TIM_OC1PreloadConfig(TIM4, TIM_OCPreload_Enable);
// 使能TIM4_CH2的CCR预装载
TIM_OC2PreloadConfig(TIM4, TIM_OCPreload_Enable);
// 使能TIM4_CH3的CCR预装载
TIM_OC3PreloadConfig(TIM4, TIM_OCPreload_Enable);
// 使能TIM4_CH4的CCR预装载
TIM_OC4PreloadConfig(TIM4, TIM_OCPreload_Enable);
// 使能ARR自动重装载寄存器预装载,修改ARR时也是下一个周期生效
TIM_ARRPreloadConfig(TIM4, ENABLE);
/* ========== 第九步:启动定时器 ========== */
// 使能TIM4定时器计数器,CNT开始从0向上计数,PWM波形正式输出
TIM_Cmd(TIM4, ENABLE);
}
我的理解:这里最关键的一步是预装载寄存器的使能。如果没有TIM_OCxPreloadConfig,你调用TIM_SetComparex改占空比的时候,CCR会立即生效,如果此时计数器刚好数到一半,PWM波形就会"截断"一半,电机就会抖一下。开了预装载之后,新的CCR值会先存进预装载寄存器,等计数器溢出(更新事件)时才会真正生效,这样占空比切换就是"无缝"的。
3.3 小车运动控制代码详解(ROBOT.c)
PWM初始化好了,引脚也能输出波形了,接下来要解决的是四个轮子怎么配合才能让小车动起来。
#include "stm32f10x.h" // 包含STM32F10x标准外设库头文件
#include "PWM.h" // 包含PWM初始化头文件,声明PWM_Init函数
#include "Delay.h" // 包含延时函数头文件,用于运动持续时间控制
/**
* @brief 机器人初始化函数
* @note 调用PWM_Init完成TIM4四通道PWM的初始化,为后续电机驱动做准备
*/
void robot_Init(void)
{
// 调用PWM初始化函数,配置TIM4的CH1~CH4输出PWM,四路PWM全部就绪
PWM_Init();
}
/**
* @brief 设置四路PWM占空比,直接控制四个电机的转速
* @param left1_speed : 左前轮PWM占空比(对应TIM4_CH1 / PB6)
* @param left2_speed : 左后轮PWM占空比(对应TIM4_CH2 / PB7)
* @param right1_speed : 右前轮PWM占空比(对应TIM4_CH3 / PB8)
* @param right2_speed : 右后轮PWM占空比(对应TIM4_CH4 / PB9)
* @note 占空比范围0~100,与之前PWM_Init中ARR=99对应
* CCR值直接填0~100,占空比 = CCR / 100 × 100%
*/
void robot_speed(uint8_t left1_speed, uint8_t left2_speed, uint8_t right1_speed, uint8_t right2_speed)
{
// 设置TIM4通道1的比较值CCR,控制左前轮转速,占空比 = left1_speed %
TIM_SetCompare1(TIM4, left1_speed);
// 设置TIM4通道2的比较值CCR,控制左后轮转速
TIM_SetCompare2(TIM4, left2_speed);
// 设置TIM4通道3的比较值CCR,控制右前轮转速
TIM_SetCompare3(TIM4, right1_speed);
// 设置TIM4通道4的比较值CCR,控制右后轮转速
TIM_SetCompare4(TIM4, right2_speed);
}
我的理解:robot_speed这个函数是小车运动的"原子操作",所有运动函数最终都是调它。这里有个细节——占空比和CCR值的关系。因为ARR=99,所以计数周期是100,CCR填50就是50%占空比。但注意TIM_SetComparex的参数范围是0~65535(16位),虽然ARR只有99,但库函数本身不会限制你传入的值,如果传了150,CCR=150 > ARR=99,那PWM就会一直输出高电平(100%占空比)。所以上层函数一定要做好限幅保护!
3.3.1 前进与刹车
/**
* @brief 机器人前进函数
* @param speed : 前进速度,范围0~100
* @param time : 前进持续时间,单位毫秒(ms)
* @note 左轮正转(speed),右轮正转(speed),小车直线前进
* 这里假设电机驱动方式:speed给PWM=正转,0=停止
*/
void makerobo_run(int8_t speed, uint16_t time)
{
// 判断传入的速度是否超过上限100,防止占空比计算越界
if (speed > 100)
{
// 超出上限则强制限制为100,保护电机和驱动板
speed = 100;
}
// 判断速度是否小于下限0,负数占空比没有意义
if (speed < 0)
{
// 负数速度置为0,防止异常输出
speed = 0;
}
// 设置四路PWM:左前轮speed,左后轮0,右前轮speed,右后轮0
// 这里根据实际接线,左右各只有一个主驱动轮在转(或两轮都转,取决于你的机械结构)
robot_speed(speed, 0, speed, 0);
// 延时指定时间,保持当前运动状态,小车持续前进
Delay_ms(time);
// 四路PWM全部置0,电机停止转动,小车停下
robot_speed(0, 0, 0, 0);
}
/**
* @brief 机器人刹车函数(急停)
* @param time : 刹车保持时间,单位毫秒(ms)
* @note 直接将四路PWM全部置0,所有电机停止转动
*/
void makerobo_brake(uint16_t time)
{
// 四路PWM全部输出0,所有电机立即停止,实现"急停"效果
robot_speed(0, 0, 0, 0);
// 延时指定时间,保持停止状态
Delay_ms(time);
}
我的理解:makerobo_run里的速度限幅非常重要!如果没有if (speed > 100)和if (speed < 0)这两道防线,万一主函数里传了个150或者-20进去,TIM_SetComparex会直接写150到CCR寄存器。虽然ARR=99,CCR=150也不会损坏芯片,但PWM会恒为高电平,电机全速运转不受控,小车可能直接冲出去撞墙。
⚠️ 这里speed用的是
int8_t(有符号8位),范围是-128~127。如果用户传了-128,经过if (speed < 0) speed = 0;处理后会变成0,不会出错。但如果以后改成uint8_t,负数传入时会发生隐式类型转换,-1会变成255,那就会出大问题!所以参数类型选择要慎重。
3.3.2 差速转向:原地左转与原地右转
/**
* @brief 机器人左转函数(原地左转,以右轮为轴)
* @param speed : 转弯速度,范围0~100
* @param time : 转弯持续时间,单位毫秒(ms)
* @note 左轮停止(0),右轮正转(speed),小车以左侧为轴向右前方转弯
* 实际效果取决于电机安装方向和驱动板接线
*/
void makerobo_Left(int8_t speed, uint16_t time)
{
// 速度上限保护,超过100则限制为100
if (speed > 100)
{
speed = 100;
}
// 速度下限保护,负数则置0
if (speed < 0)
{
speed = 0;
}
// 左轮全部停止(0),右前轮以speed转动,右后轮停止(0)
// 右轮转动、左轮静止,小车绕左侧轴向左转弯
robot_speed(0, 0, speed, 0);
// 延时保持转弯动作
Delay_ms(time);
// 停止所有电机,结束转弯
robot_speed(0, 0, 0, 0);
}
/**
* @brief 机器人原地左转函数(差速左转,左轮后退右轮前进)
* @param speed : 旋转速度,范围0~100
* @param time : 旋转持续时间,单位毫秒(ms)
* @note 左轮反转(speed),右轮正转(speed),两侧轮子反向转动
* 小车以中心为轴原地逆时针旋转(从上方看)
*/
void makerobo_Spin_Left(int8_t speed, uint16_t time)
{
// 速度上限保护
if (speed > 100)
{
speed = 100;
}
// 速度下限保护
if (speed < 0)
{
speed = 0;
}
// 左后轮speed(对应反转通道),右前轮speed(对应正转通道)
// 左右轮反向转动,产生原地旋转力矩,小车绕中心逆时针旋转
robot_speed(0, speed, speed, 0);
// 延时保持旋转
Delay_ms(time);
// 停止所有电机
robot_speed(0, 0, 0, 0);
}
/**
* @brief 机器人右转函数(原地右转,以左轮为轴)
* @param speed : 转弯速度,范围0~100
* @param time : 转弯持续时间,单位毫秒(ms)
* @note 左轮正转(speed),右轮停止(0),小车以右侧为轴向左前方转弯
*/
void makerobo_Right(int8_t speed, uint16_t time)
{
// 速度上限保护
if (speed > 100)
{
speed = 100;
}
// 速度下限保护
if (speed < 0)
{
speed = 0;
}
// 左前轮以speed转动,其余三轮停止
// 左轮转动、右轮静止,小车绕右侧轴向右转弯
robot_speed(speed, 0, 0, 0);
// 延时保持转弯
Delay_ms(time);
// 停止所有电机
robot_speed(0, 0, 0, 0);
}
/**
* @brief 机器人原地右转函数(差速右转,左轮前进右轮后退)
* @param speed : 旋转速度,范围0~100
* @param time : 旋转持续时间,单位毫秒(ms)
* @note 左轮正转(speed),右轮反转(speed),两侧轮子反向转动
* 小车以中心为轴原地顺时针旋转(从上方看)
*/
void makerobo_Spin_Right(int8_t speed, uint16_t time)
{
// 速度上限保护
if (speed > 100)
{
speed = 100;
}
// 速度下限保护
if (speed < 0)
{
speed = 0;
}
// 左前轮speed(正转),右后轮speed(反转)
// 左右轮反向,产生原地旋转力矩,小车绕中心顺时针旋转
robot_speed(speed, 0, 0, speed);
// 延时保持旋转
Delay_ms(time);
// 停止所有电机
robot_speed(0, 0, 0, 0);
}
我的理解:这里要注意一个关键问题——"正转"和"反转"只是PWM占空比,真正决定轮子往哪个方向转的是电机驱动板的接线! 如果你的电机线接反了,给PWM本来想让轮子正转,结果它反着转,那makerobo_run就会变成后退,makerobo_Left会变成右转。所以第一次调试时,一定要先单独测试每个轮子,确认哪个PWM通道对应哪个轮子,以及PWM占空比增加时轮子是正转还是反转。
3.3.3 后退控制
/**
* @brief 机器人后退函数
* @param speed : 后退速度,范围0~100
* @param time : 后退持续时间,单位毫秒(ms)
* @note 左轮反转(speed),右轮反转(speed),小车直线后退
*/
void makerobo_back(int8_t speed, uint16_t time)
{
// 速度上限保护
if (speed > 100)
{
speed = 100;
}
// 速度下限保护
if (speed < 0)
{
speed = 0;
}
// 左后轮speed,右后轮speed
// 这里对应轮子的"反转"通道,通过电机驱动板的IN1/IN2逻辑实现后退
robot_speed(0, speed, 0, speed);
// 延时保持后退
Delay_ms(time);
// 停止所有电机
robot_speed(0, 0, 0, 0);
}
我的理解:makerobo_back的实现很有意思——它并没有用"负占空比"这种概念,而是通过选择不同的电机驱动通道来实现方向控制。这说明在电机驱动板那一层,已经用两个GPIO(或驱动芯片的IN1/IN2)控制了电机的正反转,而PWM只是控制转速。STM32这边的PWM始终输出正占空比,方向由驱动板的另一路逻辑决定。这种设计很清晰,把"速度控制"和"方向控制"解耦了。
完整工程代码可见:基于STM32的智能小车: 基于STM32F103C8T6开发的智能小车代码教程
四、个人理解 / 心得
-
关于ARR=99的选择:我一开始想ARR设成999,这样占空比分辨率就是0.1%,调速更精细。但后来发现STM32的
TIM_SetComparex传的是uint16_t,ARR=999也没问题。不过考虑到小车电机本身精度不高,0~100级调速完全够用,而且ARR=99时PSC=35,计算起来特别整,就采用了这个配置。 -
四个轮子不一定都要转:从代码里能看到,
makerobo_run只驱动了左前轮和右前轮(robot_speed(speed, 0, speed, 0)),左后轮和右后轮是0。这是因为我的小车是两轮差速驱动,后面两个轮子是万向轮(从动轮),不需要电机。如果你的小车是四轮驱动,把0改成speed就行,代码结构不用大改。 -
Delay_ms的阻塞问题:现在的运动函数都是阻塞式的——执行
Delay_ms(time)时,CPU干不了别的事。如果后面要加红外循迹、超声波避障,这种阻塞方式就不行了,得改成非阻塞的状态机或者用定时器中断。不过作为基础运动控制的入门代码,阻塞式最简单直观,适合先理解原理。
五、踩坑记录
| 踩坑现象 | 原因分析 | 解决方法 |
|---|---|---|
| 电机不转,PWM引脚用万用表量有电压但电机没反应 | GPIO配置成了GPIO_Mode_Out_PP(普通推挽),而不是GPIO_Mode_AF_PP(复用推挽) | 改成GPIO_Mode_AF_PP,让TIM4的PWM信号能输出到引脚 |
| 小车应该前进,结果原地打转 | 左右电机接线反了,或者左右轮子的PWM通道对应错了 | 单独测试每个通道,用robot_speed(50,0,0,0)逐个确认哪个轮子在转 |
| 修改占空比后电机抖动明显 | 没有使能CCR预装载寄存器,占空比在中途跳变 | 加上TIM_OCxPreloadConfig(TIM4, TIM_OCPreload_Enable) |
makerobo_back执行后小车反而前进 | 电机驱动板的DIR方向控制线接反了 | 交换电机两根线,或者在代码里调换对应的PWM通道 |
六、总结表格
| 项目 | 说明 |
|---|---|
| 定时器 | TIM4(APB1总线,72MHz) |
| PWM通道 | CH1~CH4,对应PB6~PB9 |
| PWM频率 | 20kHz(PSC=35,ARR=99) |
| 占空比范围 | 0~100(CCR值直接对应百分比) |
| GPIO模式 | 复用推挽输出(AF_PP) |
| 关键配置 | 必须使能CCR预装载和ARR预装载,防止PWM跳变 |
| 速度保护 | 每个运动函数都带0~100限幅,防止越界 |
| 适用驱动板 | L298N、TB6612、DRV8833等支持PWM调速的电机驱动 |
| 验证方式 | ①示波器/逻辑分析仪看PB6~PB9波形;②万用表测平均电压;③实际小车运行测试 |
七、验证方式与测试建议
-
第一步:静态测试
下载程序后,用示波器或逻辑分析仪接PB6,应该能看到20kHz的PWM波形。初始占空比为0%,波形应该是一条低电平直线。 -
第二步:单轮测试
在main函数里调用robot_speed(50, 0, 0, 0),看左前轮是否以约50%占空比转动。逐个测试四个轮子,确认接线无误。
-
第三步:运动测试
依次调用makerobo_run、makerobo_back、makerobo_Left等函数,观察小车运动方向是否与预期一致。如果方向反了,调整电机线或修改代码中的通道对应关系。 -
第四步:边界测试
传入speed=150和speed=-10,验证限幅保护是否生效,电机不应出现异常高速或反转。
写这篇文章的时候,我把江科大的视频又回顾了一遍,特别是TIM定时器输出比较那一章。说实话,PWM这部分内容虽然代码量不大,但ARR、PSC、CCR、预装载寄存器这几个概念第一次接触的时候确实容易搞混。
如果你也在做智能小车,建议不要一次性把所有功能都堆上去,先把PWM调通、让轮子能转,再一步步加上运动控制。基础打扎实了,后面加红外循迹、超声波避障、蓝牙遥控都会轻松很多。
如果这篇文章对你有帮助,欢迎点赞 👍 收藏 ⭐ !有任何问题欢迎在评论区交流,一起学习进步!

更多推荐



所有评论(0)