
前言: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 = 2 就是 20%、CCR = 5 就是 50%、CCR = 8 就是 80%。頻率完全不變,只有高電位的比例在動,這就是 PWM 的全部。
頻率選錯,一定會出問題

這張表建議存下來。它說明了一件事:PWM 沒有「標準頻率」,只有「這個負載能接受的頻率」。伺服馬達死守 50 Hz,LED 卻要 1 kHz 以上,兩者用同一套硬體、不同的參數。
Timer 是怎麼把 PWM 生出來的
原理比想像中單純:Timer 的計數器 CNT 從 0 數到 ARR,同時拿 CNT 去跟 CCR 比較 —— CNT 小於 CCR 就輸出高、大於就輸出低,數到頂自動歸零重來。

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?三個選項先分清楚

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 接起來,就是一個完整的閉環速度控制:

// 系統架構
// 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 完全沒輸出?照這個順序查

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

馬達那一項還有個安全細節: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
}
文章評論