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
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=71ARR=19999PWM 频率 = 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/180×2000+500 500 0.5ms
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 为什么 Anglefloat

角度可能带小数(比如 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_EnableTIM_Cmd
舵机抖动、无力 供电电压被拉垮 换独立 5V 电源,共地
舵机来回乱转 信号有干扰 线别太长,远离电机线;必要时加滤波
转到边界"咔咔"响 角度给超了范围 确认 Angle 在 0~180
换了个舵机角度不对 舵机脉宽范围不同 重新标定公式(见下)

不同舵机的脉宽范围可能不同(有的 0.5~~2.5ms,有的 1~~2ms,有的 0~270°)。用前先查舵机说明书,改 Servo.c 里的 2000500 两个常数即可。这就是为什么标定代码要集中放在 Servo.c 一个地方——改一处,全工程生效。


十、动手扩展练习(强烈建议至少做一个)

  1. 按键 2 减小角度:main 里加 else if (KeyNum == 2) Angle -= 30;,并处理下限。
  2. 自动往复"扫舵机":用定时器中断,每 100ms 让角度在 0~180 来回摆动(把定时器笔记和这篇合起来)。
  3. 电位器控制:用 ADC 读电位器电压 → 映射到 0~180° → Servo_SetAngle。等你学完 ADC 再来做。
  4. 串口控制:用串口发一个角度数字,舵机转到对应角度。
  5. 改角度步进:把 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 → 封装成好用的模块"的完整流程。