0x6A Logbook

0x6A Logbook
Shi6a的筆記本
  1. 首頁
  2. 未分類
  3. 正文

PWM 脈衝寬度調變完整教學:從原理到 STM32/ESP32 實作

2026 年 5 月 28 日 786點熱度 0人點贊 0條評論

PWM 脈衝寬度調變完整教學封面

前言:PWM 是數位電路「假裝類比」的老招

MCU 只會輸出高或低,但我們要的是「一半的亮度」或「每分鐘 100 轉」。PWM(Pulse Width Modulation)就是那個經典解法:不去改變電壓,而是改變「開多久、關多久」的比例。

對 LED 來說,快到人眼跟不上就變成調光;對馬達來說,快到電流來不及變化就變成調速。而最棒的是:這段波形由硬體 Timer 產生,CPU 幾乎不用管事。

三個核心參數:頻率、週期、工作週期

參數 說明 單位 範例
頻率 (Frequency) 每秒週期數 Hz 1 kHz = 1000 次/秒
週期 (Period) 一個完整週期的時間 秒 T = 1/f = 1ms
工作週期 (Duty Cycle) 高電位時間佔週期的比例 % 50% = 一半時間 ON

它們的關係只有兩行:

T_PWM = 1 / f_PWM
T_ON = Duty × T_PWM
V_avg = Duty × V_CC

再用一個例子把「改 CCR 就是改亮度」講清楚。假設 ARR = 9,一個週期被切成 10 格:

不同 CCR 對應的佔空比波形

CCR = 2 就是 20%、CCR = 5 就是 50%、CCR = 8 就是 80%。頻率完全不變,只有高電位的比例在動,這就是 PWM 的全部。

頻率選錯,一定會出問題

不同用途的 PWM 建議頻率

這張表建議存下來。它說明了一件事:PWM 沒有「標準頻率」,只有「這個負載能接受的頻率」。伺服馬達死守 50 Hz,LED 卻要 1 kHz 以上,兩者用同一套硬體、不同的參數。

Timer 是怎麼把 PWM 生出來的

原理比想像中單純:Timer 的計數器 CNT 從 0 數到 ARR,同時拿 CNT 去跟 CCR 比較 —— CNT 小於 CCR 就輸出高、大於就輸出低,數到頂自動歸零重來。

PWM 的三個公式與計算範例

PWM_Freq = Timer_Clock / (PSC + 1) / (ARR + 1)
Duty = (CCR + 1) / (ARR + 1) × 100%
解析度 = 1 / (ARR + 1) × 100%
PSC = 83  →  Timer Clock = 84MHz / 84 = 1 MHz
ARR = 999 →  PWM Freq = 1MHz / 1000 = 1 kHz
CCR = 500 →  Duty = 501 / 1000 ≈ 50%

原文的範例值得抄在手邊:STM32F4 的 Timer 時脈 84 MHz,想要 1 kHz、50% 的話就是 PSC = 83、ARR = 999、CCR = 500。三個數字一次到位,之後改頻率只需要動 PSC 與 ARR。

要用哪一種 PWM?三個選項先分清楚

三種 PWM 產生方式

STM32 實作:HAL + Timer

STM32 的做法是設定 TIM 的通道為 PWM 模式,啟動之後硬體就自己跑:

#include "stm32f4xx_hal.h"

TIM_HandleTypeDef htim2;

void MX_TIM2_Init(void)
{
    TIM_OC_InitTypeDef sConfigOC = {0};

    htim2.Instance = TIM2;
    htim2.Init.Prescaler = 83;          // 84 MHz / 84 = 1 MHz
    htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
    htim2.Init.Period = 999;            // 1 MHz / 1000 = 1 kHz
    htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
    htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;
    HAL_TIM_PWM_Init(&htim2);

    sConfigOC.OCMode = TIM_OCMODE_PWM1;
    sConfigOC.Pulse = 500;              // 50% duty
    sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
    sConfigOC.OCFastMode = TIM_OCFAST_DISABLE;
    HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1);
}

void HAL_TIM_PWM_MspInit(TIM_HandleTypeDef *htim)
{
    GPIO_InitTypeDef gpio = {0};
    __HAL_RCC_TIM2_CLK_ENABLE();
    __HAL_RCC_GPIOA_CLK_ENABLE();

    gpio.Mode = GPIO_MODE_AF_PP;
    gpio.Pull = GPIO_NOPULL;
    gpio.Speed = GPIO_SPEED_FREQ_LOW;
    gpio.Alternate = GPIO_AF1_TIM2;
    gpio.Pin = GPIO_PIN_0;
    HAL_GPIO_Init(GPIOA, &gpio);
}

void start_pwm(void)
{
    HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);
}

實務上你會把它包成一個「設定轉速」的函式,讓上層只傳百分比:

void set_duty(uint8_t percent)
{
    if (percent > 100) percent = 100;
    uint32_t ccr = (uint32_t)percent * 999 / 100;
    __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, ccr);
}

// 呼吸燈效果
void breathe_effect(void)
{
    for (int i = 0; i <= 100; i++) { set_duty(i); HAL_Delay(10); } for (int i = 100; i >= 0; i--)
    {
        set_duty(i);
        HAL_Delay(10);
    }
}

如果要做 H-Bridge 馬達控制,記得挑進階定時器(例如 TIM1),它才有互補輸出與死區控制:

// TIM1 進階定時器支援互補輸出,適合 H-Bridge 馬達控制
// CH1 = PA8, CH1N = PB13 (互補), CH2 = PA9, CH2N = PB14

sConfigOC.OCMode = TIM_OCMODE_PWM1;
sConfigOC.Pulse = 500;
sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
sConfigOC.OCNPolarity = TIM_OCNPOLARITY_HIGH;
sConfigOC.OCFastMode = TIM_OCFAST_DISABLE;
sConfigOC.OCIdleState = TIM_OCIDLESTATE_SET;
sConfigOC.OCNIdleState = TIM_OCNIDLESTATE_RESET;
HAL_TIM_PWM_ConfigChannel(&htim1, &sConfigOC, TIM_CHANNEL_1);

// Dead Time 設定 (BDTR 暫存器)
htim1.Init.DeadTime = 10;  // 100ns dead time (取決於 Timer clock)
HAL_TIMEx_PWMN_Start(&htim1, TIM_CHANNEL_1);  // 啟動互補輸出

ESP32 實作:LEDC 到 MCPWM

Arduino 環境最省事,LEDC 直接給通道與解析度:

#include <driver/ledc.h>

#define LEDC_CH    LEDC_CHANNEL_0
#define LEDC_TIMER LEDC_TIMER_0
#define LEDC_PIN   2  // GPIO2 (內建 LED)
#define LEDC_FREQ  5000  // 5 kHz
#define LEDC_RES   10     // 10-bit 解析度 (0~1023)

void setup()
{
    ledcSetup(LEDC_CH, LEDC_FREQ, LEDC_RES);
    ledcAttachPin(LEDC_PIN, LEDC_CH);

    // 呼吸燈
    for (int duty = 0; duty <= 1023; duty++) { ledcWrite(LEDC_CH, duty); delay(2); } for (int duty = 1023; duty >= 0; duty--)
    {
        ledcWrite(LEDC_CH, duty);
        delay(2);
    }
}

void loop()
{
    // 外部控制
    int pot = analogRead(34);  // 電位器 0~4095
    int duty = map(pot, 0, 4095, 0, 1023);
    ledcWrite(LEDC_CH, duty);
    delay(20);
}

用 IDF 的話設定步驟多一點,但可以精細控制時脈來源與解析度:

#include "driver/ledc.h"
#include "esp_err.h"

#define LEDC_GPIO    GPIO_NUM_2
#define LEDC_FREQ    5000
#define LEDC_RES     LEDC_TIMER_10_BIT

void pwm_init(void)
{
    ledc_timer_config_t timer_conf = {
        .speed_mode      = LEDC_HIGH_SPEED_MODE,
        .timer_num       = LEDC_TIMER_0,
        .duty_resolution = LEDC_RES,
        .freq_hz         = LEDC_FREQ,
        .clk_cfg         = LEDC_AUTO_CLK,
    };
    ledc_timer_config(&timer_conf);

    ledc_channel_config_t ch_conf = {
        .gpio_num   = LEDC_GPIO,
        .speed_mode = LEDC_HIGH_SPEED_MODE,
        .channel    = LEDC_CHANNEL_0,
        .timer_sel  = LEDC_TIMER_0,
        .duty       = 0,
        .hpoint     = 0,
    };
    ledc_channel_config(&ch_conf);
}

void set_pwm_fade(uint32_t target_duty, int time_ms)
{
    ledc_set_fade_with_time(LEDC_HIGH_SPEED_MODE,
        LEDC_CHANNEL_0, target_duty, time_ms);
    ledc_fade_start(LEDC_HIGH_SPEED_MODE,
        LEDC_CHANNEL_0, LEDC_FADE_NO_WAIT);
}

要控制伺服馬達,50 Hz 是硬性規格,直接用現成函式庫最不容易出錯:

#include <ESP32Servo.h>

Servo myServo;
#define SERVO_PIN 13

void setup()
{
    myServo.attach(SERVO_PIN, 500, 2500);  // min=500μs, max=2500μs
}

void loop()
{
    myServo.write(0);    // 0°
    delay(1000);
    myServo.write(90);   // 90°
    delay(1000);
    myServo.write(180);  // 180°
    delay(1000);
}

而真的要做 FOC 或 H-Bridge 時,就該換上 MCPWM —— 它內建死區與互補輸出,是 LEDC 做不到的:

#include "driver/mcpwm_prelude.h"
#include "driver/mcpwm_timer.h"
#include "driver/mcpwm_oper.h"
#include "driver/mcpwm_cmpr.h"
#include "driver/mcpwm_gen.h"

void servo_init(void)
{
    mcpwm_timer_handle_t timer;
    mcpwm_oper_handle_t oper;
    mcpwm_cmpr_handle_t cmpr;
    mcpwm_gen_handle_t gen;

    mcpwm_timer_config_t timer_cfg = {
        .group_id = 0,
        .clk_src  = MCPWM_TIMER_CLK_SRC_DEFAULT,
        .resolution_hz = 1 * 1000 * 1000,  // 1 MHz
        .count_mode    = MCPWM_TIMER_COUNT_MODE_UP,
        .period_ticks  = 20000,  // 20ms = 50 Hz
    };
    mcpwm_new_timer(&timer_cfg, &timer);

    mcpwm_operator_config_t oper_cfg = {.group_id = 0};
    mcpwm_new_operator(&oper_cfg, &oper);
    mcpwm_operator_connect_timer(oper, timer);

    mcpwm_comparator_config_t cmpr_cfg = {.flags.update_cmp_on_tez = true};
    mcpwm_new_comparator(oper, &cmpr_cfg, &cmpr);

    mcpwm_generator_config_t gen_cfg = {.gen_gpio_num = SERVO_PIN};
    mcpwm_new_generator(oper, &gen_cfg, &gen);

    mcpwm_generator_set_action_on_timer_event(gen,
        MCPWM_GEN_TIMER_EVENT_ACTION(MCPWM_TIMER_DIRECTION_UP,
            MCPWM_TIMER_EVENT_EMPTY, MCPWM_GEN_ACTION_HIGH));
    mcpwm_generator_set_action_on_compare_event(gen,
        MCPWM_GEN_COMPARE_EVENT_ACTION(MCPWM_TIMER_DIRECTION_UP,
            cmpr, MCPWM_GEN_ACTION_LOW));

    mcpwm_timer_enable(timer);
    mcpwm_timer_start_stop(timer, MCPWM_TIMER_START_NO_STOP);

    mcpwm_comparator_set_compare_value(cmpr, 1500);  // 90° = 1.5ms
}

實戰:DC 馬達的 PID 速度控制

把 PWM、編碼器與 PID 接起來,就是一個完整的閉環速度控制:

DC 馬達 PID 閉環迴路

// 系統架構
// PID_Task → set_duty() → PWM → Motor Driver(DRV8833) → DC Motor
//                                                          │
//                      PID_Task ← calc_rpm() ← Encoder ←──┘

volatile int32_t encoder_count = 0;
int target_rpm = 100;  // 目標轉速

void encoder_isr(void)
{
    encoder_count += (digitalRead(ENC_B) == HIGH) ? 1 : -1;
}

float calc_rpm(int dt_ms)
{
    const float pulses_per_rev = 20 * 4;  // 20 PPR × 4 倍頻
    float rpm = (float)encoder_count / pulses_per_rev * 60000.0 / dt_ms;
    encoder_count = 0;
    return rpm;
}

void pid_task(void *pv)
{
    float kp = 1.5, ki = 0.2, kd = 0.05;
    float integral = 0, prev_error = 0;

    while (1)
    {
        float rpm = calc_rpm(50);  // 50ms 一次
        float error = target_rpm - rpm;
        integral += error * 0.05;
        float derivative = (error - prev_error) / 0.05;
        float output = kp * error + ki * integral + kd * derivative;

        output = constrain(output, 0, 1023);
        ledcWrite(MOTOR_CH, (uint32_t)output);

        prev_error = error;
        vTaskDelay(pdMS_TO_TICKS(50));
    }
}

這段程式最有意思的地方是「倍頻」:編碼器標 20 PPR,但因為 A/B 兩相的上升與下降緣都拿來計數,實際解析度變成 80 脈衝/圈。解析度越高,PID 算出來的速度就越平滑。

PWM 完全沒輸出?照這個順序查

PWM 無輸出的檢查清單

其中最容易被忽略的是第二項:設定完卻忘了呼叫啟動函式。HAL 的生態裡「初始化」與「啟動」是兩個獨立步驟,少了後面那一步什麼都不會發生。

現場最常見的兩個問題

馬達噪音與 LED 閃爍的處理

馬達那一項還有個安全細節:H-Bridge 的上下臂絕對不能同時導通,所以死區時間(Dead Time)的設定不是選項,而是必須。這也是為什麼做馬達時該用有硬體死區的 MCPWM 或進階定時器。

總結

PWM 的知識密度不高,但細節很多。四個重點收好:

  • 三個參數各有分工:PSC 管速度、ARR 管頻率與解析度、CCR 管佔空比
  • 頻率要配合負載:LED 要 kHz、馬達 50~500 Hz、伺服 50 Hz
  • 能交給硬體就別用軟體:Timer 產生的 PWM 不佔 CPU,時序也穩
  • 馬達一定要處理死區:不然燒的不是程式,是驅動板

把這四點弄對,之後你要調光、調速、做音訊還是做電源,都只是換一組頻率與解析度的事。

#include "driver/mcpwm_prelude.h"
#include "driver/mcpwm_timer.h"
#include "driver/mcpwm_oper.h"
#include "driver/mcpwm_cmpr.h"
#include "driver/mcpwm_gen.h"

void servo_init(void)
{
    mcpwm_timer_handle_t timer;
    mcpwm_oper_handle_t oper;
    mcpwm_cmpr_handle_t cmpr;
    mcpwm_gen_handle_t gen;

    mcpwm_timer_config_t timer_cfg = {
        .group_id = 0,
        .clk_src  = MCPWM_TIMER_CLK_SRC_DEFAULT,
        .resolution_hz = 1 * 1000 * 1000,  // 1 MHz
        .count_mode    = MCPWM_TIMER_COUNT_MODE_UP,
        .period_ticks  = 20000,  // 20ms = 50 Hz
    };
    mcpwm_new_timer(&timer_cfg, &timer);

    mcpwm_operator_config_t oper_cfg = {.group_id = 0};
    mcpwm_new_operator(&oper_cfg, &oper);
    mcpwm_operator_connect_timer(oper, timer);

    mcpwm_comparator_config_t cmpr_cfg = {.flags.update_cmp_on_tez = true};
    mcpwm_new_comparator(oper, &cmpr_cfg, &cmpr);

    mcpwm_generator_config_t gen_cfg = {.gen_gpio_num = SERVO_PIN};
    mcpwm_new_generator(oper, &gen_cfg, &gen);

    mcpwm_generator_set_action_on_timer_event(gen,
        MCPWM_GEN_TIMER_EVENT_ACTION(MCPWM_TIMER_DIRECTION_UP,
            MCPWM_TIMER_EVENT_EMPTY, MCPWM_GEN_ACTION_HIGH));
    mcpwm_generator_set_action_on_compare_event(gen,
        MCPWM_GEN_COMPARE_EVENT_ACTION(MCPWM_TIMER_DIRECTION_UP,
            cmpr, MCPWM_GEN_ACTION_LOW));

    mcpwm_timer_enable(timer);
    mcpwm_timer_start_stop(timer, MCPWM_TIMER_START_NO_STOP);

    mcpwm_comparator_set_compare_value(cmpr, 1500);  // 90° = 1.5ms
}
標籤: RS485 工業通訊
最後更新:2026 年 9 月 30 日

shi6a

這個人很懶,什麼都沒留下

點贊
< 上一篇
下一篇 >

文章評論

razz evil exclaim smile redface biggrin eek confused idea lol mad twisted rolleyes wink cool arrow neutral cry mrgreen drooling persevering
取消回覆

COPYRIGHT © 2026 0x6A Logbook. ALL RIGHTS RESERVED.

Theme Kratos Made By Seaton Jiang