STM32 PWM 驱动舵机 教学笔记
基于示例工程:
D:\下载\STM32入门教程资料\程序源码\STM32Project-有注释版\6-4 PWM驱动舵机
硬件:STM32F103C8 + 标准外设库 V3.5.0(Keil 工程)
前置:已学完你的《STM32_中断定时器PWM学习笔记》。本篇就是把 PWM 用在一个真实外设——舵机上,是一次"拿学过的知识做产品功能"的实战。
一、舵机是什么?为什么用 PWM 控制它?
1.1 舵机(Servo)是什么
舵机是一种可以转到指定角度并保持住的电机。常见的有:
- SG90 9g(本项目用的,玩具级、便宜)
- MG995 / MG996R(金属齿轮,力大)
- 数字舵机、总线舵机(更高级)
它内部长这样(不用记,有个印象):
┌─────────────┐
电源 + 地 + 信号 │ 控制电路 │
───────────────► │ 电位器反馈 │──► 电机 ──► 减速齿轮 ──► 输出轴(摇臂)
└─────────────┘
控制电路不断读取输出轴的当前位置(电位器反馈),和你要的目标角度比,有误差就转,转到位就停——这是一个闭环系统。你不需要懂内部细节,只需要知道:给它一条符合要求的 PWM 波,它就把轴转到对应角度。
1.2 舵机到底要什么样的 PWM?
SG90 的要求是固定的:
频率 50Hz(周期 20ms),高电平脉宽 0.5ms ~ 2.5ms,对应 0° ~ 180°
| 脉宽 | 角度 |
|---|---|
| 0.5 ms | 0° |
| 1.0 ms | 45° |
| 1.5 ms | 90°(中位) |
| 2.0 ms | 135° |
| 2.5 ms | 180° |
关键点:舵机看的是"脉宽"(高电平持续多久),频率必须是 50Hz。 同一个 20ms 周期里,高电平占 0.5ms 就是 0°,占 2.5ms 就是 180°。
对照你学过的知识:这就是 PWM 的"占空比"——只不过舵机只用了占空比里很窄的一段(见 5.3)。这就是为什么控制舵机和给 LED 调光用的是同一套 PWM 硬件,参数却完全不同。
1.3 换个角度理解:PWM 在这里"骗"的是舵机控制电路
回想你的《前置基础》:PWM 能"骗"过人眼(LED 调光)。对舵机,PWM 是在"骗"它的控制电路——控制电路把脉宽当成"目标角度指令"。同样的 PWM 硬件,输出不同的脉宽,驱动不同的外设。
二、接线与硬件准备
SG90 舵机一般引出三根线:
| 线色(常见) | 功能 | 接到哪 |
|---|---|---|
| 红 | VCC 电源 | 5V(舵机用 5V,不能接 3.3V!) |
| 棕 / 黑 | GND 地 | GND(必须和 STM32 共地) |
| 橙 / 黄 | 信号线 | PA1(本项目 PWM 输出脚) |
⚠️ 供电是舵机最容易出问题的地方:
- 舵机转动瞬间电流可达几百 mA,不能直接从 STM32 的 3.3V 引脚取电。
- 正确做法:舵机 VCC 接 外部 5V 电源,GND 与 STM32 的 GND 共地,信号线接 PA1。
- 开发板上有 5V 输出引脚,但舵机电流大时可能把板子电压拉垮,建议用独立电源或用开发板 5V(视板子供电能力而定)。
本项目信号脚是 PA1 = TIM2_CH2(下面会讲为什么 PA1 能输出 PWM)。
三、工程结构与分层思想(先看懂骨架)
6-4 PWM驱动舵机/
├── User/ main.c, stm32f10x_it.c ...
├── Hardware/ OLED, Key, Servo, PWM, LED ← 硬件驱动模块
├── System/ Delay
├── Library/ 标准外设库
├── Start/ 启动文件、内核文件
├── Objects/ 编译产物
└── Listings/ 编译产物
调用关系(重要,记住这个分层):
main.c
│ 只用"语义化"的函数
▼
Servo.c(舵机驱动) Servo_SetAngle(角度)
│ 只调 PWM 模块
▼
PWM.c(PWM 驱动) PWM_SetCompare2(CCR值)
│ 调标准库
▼
标准外设库 TIM_SetCompare2(...) → 寄存器
为什么这样分层?
| 好处 | 说明 |
|---|---|
| 语义清晰 | main 里 Servo_SetAngle(90) 一看就懂;如果直接写 TIM_SetCompare2(TIM2, 1500),没人知道 1500 是啥 |
| 可复用 | PWM 模块以后还能拿去驱动 LED、蜂鸣器;舵机模块是独立的 |
| 可移植 | 换芯片/换引脚,只改 PWM.c 或 Servo.c,main 不用动 |
| 好调试 | 哪一层出问题就在哪一层查 |
这是嵌入式开发最重要的习惯之一:把硬件细节封装起来,上层用"人类语言"调用。你以后写任何外设,都应该按这个思路分层。
四、PWM 初始化逐行精讲(PWM.c)
这是整个工程的核心。我们一行一行拆。
4.1 开启时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); // 开 TIM2 时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 开 GPIOA 时钟
- TIM2 在 APB1 总线上(所以用
RCC_APB1PeriphClockCmd)。 - GPIOA 在 APB2 总线上(用
RCC_APB2PeriphClockCmd)。 - 回忆你的笔记:用任何外设前必须先开时钟,忘了就什么都没反应。
4.2 配置 GPIO:PA1 为复用推挽输出
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; // 复用推挽
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
- 为什么要
AF_PP(复用推挽)? 因为 PA1 这个脚现在交给 TIM2 控制,不再由 GPIO 自己控制。凡是被外设(定时器、串口、SPI…)控制的输出脚,都要配"复用模式"。 - 对比:你自己手动
GPIO_SetBits/ResetBits控制 LED 时用Out_PP(普通推挽)。 - PA1 为什么能输出 PWM? 因为 PA1 的"复用功能"之一就是 TIM2_CH2(定时器2的通道2)。每个引脚能复用成什么功能,查数据手册的"引脚复用功能表"。
4.3 选择时钟源
TIM_InternalClockConfig(TIM2); // 选择内部时钟(不调也是默认内部时钟,可省略)
- TIM2 用内部时钟(来自系统时钟链)。这一行可写可不写,写上更明确。
4.4 时基单元配置(决定 PWM 频率)
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1; // 滤波器时钟不分频
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up; // 向上计数
TIM_TimeBaseInitStructure.TIM_Period = 20000 - 1; // ARR = 19999
TIM_TimeBaseInitStructure.TIM_Prescaler = 72 - 1; // PSC = 71
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0; // 高级定时器才用
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseInitStructure);
这里的两个数字是全局的精华,下面第 5 节专门推导。 先记住结论:PSC=71、ARR=19999 → PWM 频率 = 50Hz,周期 = 20ms。
4.5 输出比较通道配置(决定占空比 / 脉宽)
TIM_OCStructInit(&TIM_OCInitStructure); // 先给结构体填默认值
TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; // PWM 模式1
TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; // 高电平有效
TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; // 使能输出(易忘!)
TIM_OCInitStructure.TIM_Pulse = 0; // 初始 CCR = 0
TIM_OC2Init(TIM2, &TIM_OCInitStructure); // 配置通道2
TIM_OC2Init里的"2"是通道号:我们用的是 TIM2 的通道 2(PA1),所以是OC2。如果信号脚接 PA0(通道1)就要改成TIM_OC1Init。TIM_OCMode_PWM1:回忆 PWM 笔记——计数 CNT < CCR 时输出高,CNT ≥ CCR 时输出低。脉宽就是 CCR 个计数节拍。TIM_Pulse = 0:初始 CCR 为 0,相当于一直输出低电平(舵机先不转)。真正的脉宽由后面Servo_SetAngle动态设置。TIM_OCStructInit:防止结构体里有成员没赋值导致初值不确定,这是个好习惯。
4.6 使能定时器
TIM_Cmd(TIM2, ENABLE); // 启动 TIM2,PWM 开始输出
- 这一步最容易被漏。没有它,前面全白配。
五、关键计算:为什么是 PSC=71、ARR=19999?
这是本篇最重要的一节,把数字算明白,你就真正懂了。
5.1 套公式
PWM 频率 = 定时器时钟 ÷ (PSC + 1) ÷ (ARR + 1)
本项目定时器时钟 = 72 MHz(标准 72MHz 系统时钟下,TIM2 在 APB1,APB1 分频为 2 时定时器时钟 = 72MHz,详见你的定时器笔记)。
定时器时钟 = 72 MHz
PSC + 1 = 72 → 计数时钟 = 72MHz / 72 = 1 MHz(每 tick = 1μs)
ARR + 1 = 20000 → PWM 频率 = 1MHz / 20000 = 50 Hz
50 Hz 的周期 = 1 / 50 Hz = 20 ms,正好是舵机要求的频率!
5.2 为什么"刚好"要这样拆?
舵机要求周期 20ms。我们有两个旋钮:
| 参数 | 作用 | 本项目取值 | 效果 |
|---|---|---|---|
| PSC | 把 72MHz 分到"好算的计数频率" | 71 → 1MHz | 每 tick = 1μs,好换算 |
| ARR | 决定周期(20ms = 20000 tick) | 19999 → 20000 tick | 周期 = 20ms |
关键妙处:计数时钟 = 1MHz,意味着 CCR 的数值直接就是"脉宽的微秒数"!
CCR = 500 → 高电平 500μs = 0.5ms
CCR = 1500 → 高电平 1500μs = 1.5ms
CCR = 2500 → 高电平 2500μs = 2.5ms
这让你在写 Servo_SetAngle 时能直接用"毫秒/微秒"思考,不用来回换算。选 PSC=71 是为了让"1 tick = 1μs"这个换算关系成立。
5.3 舵机占空比只有一段窄范围
回忆 PWM 笔记:占空比 = CCR / (ARR + 1)。本项目:
0° → CCR=500 → 占空比 = 500/20000 = 2.5%
180°→ CCR=2500 → 占空比 = 2500/20000 = 12.5%
舵机只工作在 2.5% ~ 12.5% 占空比这一段,而 LED 调光可以用 0~100%。这就是为什么"同样是 PWM,参数却差很多"——先看外设要求,再算参数。
5.4 对照:LED 呼吸灯 vs 舵机
| LED 呼吸灯 | 舵机 | |
|---|---|---|
| 频率 | 1 kHz 以上(避免人眼闪烁) | 固定 50 Hz(舵机规定) |
| 占空比范围 | 0 ~ 100%(调亮度) | 2.5% ~ 12.5%(0°~180°) |
| 关注点 | 占空比=平均电压 | 脉宽=角度指令 |
六、Servo_SetAngle 角度换算(Servo.c)
这是把"人类角度"翻译成"舵机脉宽"的地方,也是一行魔法公式:
void Servo_SetAngle(float Angle)
{
PWM_SetCompare2(Angle / 180 * 2000 + 500); // 角度 → CCR
}
6.1 公式推导(重点)
目标:把 0° ~ 180° 线性映射到 CCR = 500 ~ 2500(也就是 0.5ms ~ 2.5ms)。
CCR = Angle / 180 * 2000 + 500
└────────┬────────┘ └┬┘
角度占 180° 的比例 基础脉宽(0.5ms=500)
× 总脉宽跨度(2.5-0.5=2ms=2000)
代入验证:
| Angle | 计算 | CCR | 脉宽 | 角度 |
|---|---|---|---|---|
| 0° | 0/180×2000+500 | 500 | 0.5ms | 0° |
| 45° | 45/180×2000+500 | 1000 | 1.0ms | 45° |
| 90° | 90/180×2000+500 | 1500 | 1.5ms | 90° |
| 180° | 180/180×2000+500 | 2500 | 2.5ms | 180° |
公式拆成两段理解:+500 是"0° 对应的基础脉宽",Angle/180×2000 是"在 0.5ms 基础上,每多 1° 多加多少"。180° 总共跨 2000μs,所以每度 = 2000/180 ≈ 11.1μs。
6.2 为什么 Angle 是 float
角度可能带小数(比如 90.5°),用 float 保留精度。公式里的除法也是浮点除法,最后结果会自动转成整数传给 PWM_SetCompare2(它收 uint16_t)。
6.3 舵机模块存在的意义
如果没有 Servo 模块,main 里得写:
TIM_SetCompare2(TIM2, (uint16_t)(Angle / 180 * 2000 + 500)); // 又长又难懂
有了 Servo 封装,main 里只写 Servo_SetAngle(Angle)。这就是分层带来的可读性。
七、main.c 主程序逻辑
uint8_t KeyNum; // 键码
float Angle; // 当前角度
int main(void)
{
OLED_Init(); // OLED 初始化
Servo_Init(); // 舵机初始化(内部就是 PWM_Init)
Key_Init(); // 按键初始化
OLED_ShowString(1, 1, "Angle:"); // 第一行显示 "Angle:"
while (1)
{
KeyNum = Key_GetNum(); // 读按键
if (KeyNum == 1) // 按键1按下
{
Angle += 30; // 角度 +30°
if (Angle > 180) // 超过 180°
Angle = 0; // 归零
}
Servo_SetAngle(Angle); // 让舵机转到 Angle
OLED_ShowNum(1, 7, Angle, 3); // 在 OLED 上显示角度(3位)
}
}
流程逻辑:主循环无限转——读按键 → 有按键就改角度 → 把角度发给舵机 → 显示。按下一次按键 1,舵机转 30°;到 180° 后再按回 0°。
可以改进的小地方(后面练习用):
Servo_SetAngle每次都调用,即使角度没变也重复设值。可以只在KeyNum != 0时才设置。KeyNum判断只处理了 1,按键 2(PB11)被忽略了。
八、配套模块简单带过(Key / OLED)
8.1 按键(Key.c)——阻塞式扫描
if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1) == 0) // PB1 为低 = 按下
{
Delay_ms(20); // 消抖
while (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1) == 0); // 等松手
Delay_ms(20); // 再消抖
KeyNum = 1;
}
- PB1、PB11 配成上拉输入(
GPIO_Mode_IPU),按下接地 → 读到 0。 - 消抖:机械按键按下瞬间会弹跳,加 20ms 延时等电平稳定(数电里的"抖动"问题)。
- 阻塞式:
while(等待松手)会卡住主循环。简单教学够用,但如果你以后要用中断,就不能这么写。
8.2 OLED(OLED.c)——显示调试信息
OLED_ShowString(1,1,"Angle:") 和 OLED_ShowNum(1,7,Angle,3) 在屏幕第 1 行显示角度数值。OLED 的作用就是"让你看见程序在干什么",和串口打印一样,是最重要的调试手段之一。
九、常见问题与调试
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 舵机完全不动 | 供电不足 / 没共地 | 舵机 VCC 接 5V,GND 和 STM32 共地 |
| 舵机完全不动 | 信号脚接错 | 确认 PWM 输出在 PA1(TIM2_CH2) |
| 舵机完全不动 | 忘了使能输出 / 没启动定时器 | 查 TIM_OutputState_Enable 和 TIM_Cmd |
| 舵机抖动、无力 | 供电电压被拉垮 | 换独立 5V 电源,共地 |
| 舵机来回乱转 | 信号有干扰 | 线别太长,远离电机线;必要时加滤波 |
| 转到边界"咔咔"响 | 角度给超了范围 | 确认 Angle 在 0~180 |
| 换了个舵机角度不对 | 舵机脉宽范围不同 | 重新标定公式(见下) |
不同舵机的脉宽范围可能不同(有的 0.5~~2.5ms,有的 1~~2ms,有的 0~270°)。用前先查舵机说明书,改 Servo.c 里的 2000 和 500 两个常数即可。这就是为什么标定代码要集中放在 Servo.c 一个地方——改一处,全工程生效。
十、动手扩展练习(强烈建议至少做一个)
- 按键 2 减小角度:main 里加
else if (KeyNum == 2) Angle -= 30;,并处理下限。 - 自动往复"扫舵机":用定时器中断,每 100ms 让角度在 0~180 来回摆动(把定时器笔记和这篇合起来)。
- 电位器控制:用 ADC 读电位器电压 → 映射到 0~180° →
Servo_SetAngle。等你学完 ADC 再来做。 - 串口控制:用串口发一个角度数字,舵机转到对应角度。
- 改角度步进:把 30° 改成 10° 或 45°,观察舵机转动的差异。
附录:本工程用到的标准库函数
| 函数 | 作用 |
|---|---|
TIM_InternalClockConfig(TIM2) |
选择内部时钟 |
TIM_TimeBaseInit(TIM2, &tb) |
配置时基(PSC / ARR / 计数模式) |
TIM_OCStructInit(&oc) |
给输出比较结构体填默认值 |
TIM_OC2Init(TIM2, &oc) |
配置 TIM2 通道2 为 PWM 输出 |
TIM_SetCompare2(TIM2, val) |
运行时改通道2 的 CCR(改脉宽) |
TIM_Cmd(TIM2, ENABLE) |
启动定时器 |
GPIO_ReadInputDataBit(GPIOB, Pin) |
读某个引脚的电平 |
本篇一句话总结:舵机是"吃脉宽"的外设——固定 50Hz、0.5~~2.5ms 对应 0~~180°。用定时器输出 PWM 时,
PSC=71把计数时钟变成 1MHz(1 tick=1μs),ARR=19999把周期定成 20ms,于是CCR 数值 = 脉宽微秒数,Servo_SetAngle一行公式Angle/180*2000+500就把角度变成了脉宽。整篇就是"外设要求 → 反推 PSC/ARR/CCR → 封装成好用的模块"的完整流程。