
前言:先說為什麼你該把 delay() 戒掉
大部分人寫 MCU 的第一支程式都是 delay(1000) 讓 LED 每秒閃一次。問題是那一秒裡 CPU 什麼都不能做 —— 按鍵不理你、序列埠不回應、通訊直接斷。
硬體定時器就是來解決這件事的:它是一個由硬體時鐘驅動的計數器,時間到了自己發中斷或輸出波形,CPU 從頭到尾不用等。
它有三個讓人離不開的特性:
- 精準:由專屬硬體電路驅動,不受 CPU 負載影響
- 無阻塞:計數期間 CPU 可以做任何事
- 多功能:同一個計數器可同時產生中斷、PWM、捕捉外部訊號
核心只有三個暫存器

| 暫存器 | 全名 | 功能 |
|---|---|---|
| CNT | Counter | 當前計數值,每個時鐘週期遞增或遞減 |
| ARR | Auto-Reload Register | 計數上限(或下限),溢位時自動重載 |
| PSC | Prescaler | 對時鐘源進行除頻,控制計數速度 |
就這三個:PSC 決定「多快數」、ARR 決定「數到多少」、CNT 就是現在數到哪。所有定時器的花招都是從這三個數字長出來的。
把公式算一次就有感覺了

這三個結果值得記住:改 PSC 就是改解析度,改 ARR 就是改週期。原文的範例只要兩行設定就做出 100 Hz 中斷,所以之後看到「1 kHz PWM」或「20 kHz 取樣」時,你心裡可以直接換算成 PSC/ARR 要填多少。
五種工作模式:同一個計數器,換不同的用法

先看最單純的「計時」。STM32 用 TIM6 做 10 ms 週期中斷是標準起手式:
// STM32 HAL — 基本定時器中斷(TIM6, 10ms)
TIM_HandleTypeDef htim6;
void MX_TIM6_Init(void)
{
htim6.Instance = TIM6;
htim6.Init.Prescaler = 71; // 72 MHz / 72 = 1 MHz
htim6.Init.CounterMode = TIM_COUNTERMODE_UP;
htim6.Init.Period = 9999; // 10000 ticks = 10ms
htim6.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;
HAL_TIM_Base_Init(&htim6);
HAL_TIM_Base_Start_IT(&htim6); // 啟動並啟用中斷
}
// 中斷回呼 — 每 10ms 執行一次
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM6) {
toggle_led(); // 或讀取感測器、更新狀態機
}
}
進階定時器還能做「中央對齊」:計數器先往上數到底、再往下數回來,產生的波形左右對稱,諧波比邊緣對齊低。做馬達控制時這個模式很常被指定:
// STM32 — 中央對齊模式設定
htim2.Init.CounterMode = TIM_COUNTERMODE_CENTERALIGNED1;
// Center-aligned 1: 計數向下時設定比較旗標
// Center-aligned 2: 計數向上時設定比較旗標
// Center-aligned 3: 向上和向下都設定比較旗標
PWM 輸出:定時器最常被用到的功能
PWM 的原理就是拿 CNT 去跟 CCR 比:CNT 小於 CCR 輸出高、大於就輸出低,兩者的比值就是佔空比。換句話說,改 CCR 就是改亮度或轉速,而且改完下一個週期立刻生效。
// STM32 — TIM2 CH1 (PA0) PWM 輸出, 1kHz, 50% duty
TIM_OC_InitTypeDef sConfigOC = {0};
void MX_TIM2_PWM_Init(void)
{
htim2.Instance = TIM2;
htim2.Init.Prescaler = 71; // 72 MHz → 1 MHz
htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
htim2.Init.Period = 999; // 1 MHz / 1000 = 1 kHz
HAL_TIM_PWM_Init(&htim2);
sConfigOC.OCMode = TIM_OCMODE_PWM1;
sConfigOC.Pulse = 500; // CCRx = 500 → 50% duty
sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
sConfigOC.OCFastMode = TIM_OCFAST_DISABLE;
HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1);
HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);
}
// 動態調整 Duty Cycle
void set_pwm_duty(TIM_HandleTypeDef *htim, uint32_t channel, uint16_t duty)
{
__HAL_TIM_SET_COMPARE(htim, channel, duty);
}
ESP32 不用碰暫存器,Arduino 環境一行 ledcWrite 就打發:
// ESP32 Arduino — LEDC PWM 輸出
// ESP32 內建 LEDC 控制器,專為 PWM 設計
const int ledPin = 2; // GPIO2 (內建 LED)
const int freq = 5000; // 5 kHz
const int resolution = 8; // 8-bit (0~255)
void setup() {
ledcSetup(0, freq, resolution); // channel 0
ledcAttachPin(ledPin, 0); // 綁定 GPIO
ledcWrite(0, 128); // 50% duty (128/255)
}
void loop() {
// 呼吸燈效果
for (int duty = 0; duty <= 255; duty++) { ledcWrite(0, duty); delay(5); } for (int duty = 255; duty >= 0; duty--) {
ledcWrite(0, duty);
delay(5);
}
}
如果你要的是「精確週期中斷」而不是 PWM,ESP32 要用 Timer Group:
// ESP32 IDF — Timer Group 0, 1ms 週期中斷
#include "esp_timer.h"
#include "driver/gptimer.h"
static bool IRAM_ATTR timer_isr_callback(gptimer_handle_t timer,
const gptimer_alarm_event_data_t *edata, void *user_ctx)
{
// 此處在 ISR 中執行,保持簡短
static int counter = 0;
counter++;
if (counter >= 1000) { // 1 秒到
gpio_set_level(GPIO_NUM_2, !gpio_get_level(GPIO_NUM_2));
counter = 0;
}
return true; // 喚醒
}
void app_main(void)
{
gptimer_handle_t gptimer = NULL;
gptimer_config_t timer_config = {
.clk_src = GPTIMER_CLK_SRC_DEFAULT,
.direction = GPTIMER_COUNT_UP,
.resolution_hz = 1000000, // 1 MHz (1 μs/tick)
};
gptimer_new_timer(&timer_config, &gptimer);
gptimer_alarm_config_t alarm_config = {
.alarm_count = 1000, // 1000 ticks = 1 ms
.reload_count = 0, // 重載到 0(向上計數)
.flags.auto_reload_on_alarm = true,
};
gptimer_set_alarm_action(gptimer, &alarm_config);
gptimer_event_callbacks_t cbs = {
.on_alarm = timer_isr_callback,
};
gptimer_register_event_callbacks(gptimer, &cbs, NULL);
gptimer_enable(gptimer);
gptimer_start(gptimer);
}
但 ESP32 的 LEDC 有一個必須先知道的取捨:

這張表是很多人踩過的坑:你要 20-bit 的超細亮度,頻率就上不去;要跑 40 MHz,解析度就只能給 8-bit。解析度與頻率是同一塊硬體資源的兩面,選之前先想清楚哪一個對你更重要。
輸入捕捉:讓硬體幫你量時間
反過來,如果你要「量」外面的訊號,用輸入捕捉:外部邊緣一來,硬體就把當下的 CNT 值存進 CCR,你不用在軟體裡忙著讀腳位。

// STM32 — TIM2 CH1 輸入捕捉,測量外部訊號頻率
volatile uint16_t cap1 = 0, cap2 = 0;
volatile uint8_t cap_ready = 0;
void MX_TIM2_Capture_Init(void)
{
TIM_IC_InitTypeDef sConfigIC = {0};
htim2.Init.Prescaler = 71; // 1 MHz
htim2.Init.Period = 0xFFFF;
HAL_TIM_IC_Init(&htim2);
sConfigIC.ICPolarity = TIM_INPUTCHANNELPOLARITY_RISING;
sConfigIC.ICSelection = TIM_ICSELECTION_DIRECTTI;
sConfigIC.ICPrescaler = TIM_ICPSC_DIV1;
sConfigIC.ICFilter = 0; // 無濾波
HAL_TIM_IC_ConfigChannel(&htim2, &sConfigIC, TIM_CHANNEL_1);
HAL_TIM_IC_Start_IT(&htim2, TIM_CHANNEL_1);
}
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM2 && htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1) {
static uint16_t last = 0;
uint16_t now = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1);
if (now > last) {
uint16_t period = now - last;
uint32_t freq = 1000000 / period; // 1 MHz 時基
printf("Freq: %lu Hz\n", freq);
}
last = now;
}
}
ESP32 沒有直接的輸入捕捉,但有更省事的 PCNT(脈衝計數器),讀編碼器或測脈衝數幾乎不用寫邏輯:
// ESP32 — PCNT 脈衝計數,適用於編碼器與感測器脈衝
#include "driver/pulse_cnt.h"
void app_main(void)
{
pcnt_unit_handle_t pcnt_unit = NULL;
pcnt_unit_config_t unit_config = {
.high_limit = 1000,
.low_limit = -1000,
};
pcnt_new_unit(&unit_config, &pcnt_unit);
pcnt_glitch_filter_config_t filter = {
.max_glitch_ns = 1000, // 1 μs 雜訊濾波
};
pcnt_unit_set_glitch_filter(pcnt_unit, &filter);
pcnt_chan_config_t chan_config = {
.edge_gpio_num = 4, // GPIO4 脈衝輸入
.level_gpio_num = -1, // 不使用方向腳
};
pcnt_channel_handle_t channel = NULL;
pcnt_new_channel(pcnt_unit, &chan_config, &channel);
pcnt_channel_set_edge_action(channel, PCNT_CHANNEL_EDGE_ACTION_INCREASE,
PCNT_CHANNEL_EDGE_ACTION_HOLD);
pcnt_unit_enable(pcnt_unit);
pcnt_unit_start(pcnt_unit);
int pulse_count = 0;
while (1) {
pcnt_unit_get_count(pcnt_unit, &pulse_count);
printf("Pulse count: %d\n", pulse_count);
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
STM32 與 ESP32 的定時器設計差在哪
兩邊的設計哲學很不一樣,看這張表最快:
| 項目 | STM32 TIM (基本/通用/進階) | ESP32 Timer Group | ESP32 LEDC |
|---|---|---|---|
| 計數位元 | 16-bit / 32-bit | 64-bit | 20-bit |
| 計數方向 | 向上/向下/中央對齊 | 向上/向下 | 僅向上 |
| 通道數 | 4 通道 / 定時器 | 1 組警報 | 8 通道 |
| PWM 解析度 | 16-bit (0~65535) | — | 20-bit (0~1,048,575) |
| 互補 PWM + 死區 | ✔(進制定時器) | — | — |
| 編碼器支援 | ✔(通用定時器) | — | — |
| 硬體自動漸變 | — | — | ✔(Fade) |
| Break 輸入(急停) | ✔(進制定時器) | — | — |
| DMA 觸發 | ✔ | ✔ | ✔ |
一句話總結:STM32 是「一個定時器什麼都能做」,ESP32 是「不同事情交給不同週邊」。
STM32 的通用與進階定時器有互補 PWM + 死區、編碼器模式、Break 輸入(急停)、DMA 觸發,幾乎是為了馬達與電源控制設計的;ESP32 則把 PWM(LEDC)、週期中斷(Timer Group)、脈衝計數(PCNT)拆成三個獨立週邊,各司其職但單一週邊的彈性較小。
進階:編碼器模式
做馬達控制時,你一定會遇到「到底轉了多少」。硬體編碼器模式讓 CNT 直接跟著 A/B 相位加減:

// STM32 — TIM3 編碼器模式 (PA6=TI1, PA7=TI2)
TIM_Encoder_InitTypeDef sEncoder = {0};
void MX_TIM3_Encoder_Init(void)
{
htim3.Instance = TIM3;
htim3.Init.Prescaler = 0;
htim3.Init.CounterMode = TIM_COUNTERMODE_UP;
htim3.Init.Period = 0xFFFF;
sEncoder.EncoderMode = TIM_ENCODERMODE_TI12; // 同時採樣 A+B
sEncoder.IC1Polarity = TIM_ICPOLARITY_RISING;
sEncoder.IC1Selection = TIM_ICSELECTION_DIRECTTI;
sEncoder.IC1Prescaler = TIM_ICPSC_DIV1;
sEncoder.IC1Filter = 10;
sEncoder.IC2Polarity = TIM_ICPOLARITY_RISING;
sEncoder.IC2Selection = TIM_ICSELECTION_DIRECTTI;
sEncoder.IC2Prescaler = TIM_ICPSC_DIV1;
sEncoder.IC2Filter = 10;
HAL_TIM_Encoder_Init(&htim3, &sEncoder);
HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL);
}
// 讀取編碼器位置
int16_t get_encoder_pos(void)
{
return (int16_t)__HAL_TIM_GET_COUNTER(&htim3);
}
讀 CNT 就是位置,讀兩次差值就是轉速 —— 零軟體成本。這件事如果自己用中斷做,CPU 會被吃掉一大塊。
實戰:超音波測距(TIM 輸入捕捉 + GPIO 觸發)
HC-SR04 是輸入捕捉最經典的練習:拉觸發腳、聽回音腳的高電位持續多久,再換算成距離。
// STM32 — HC-SR04 超音波測距
// 使用 TIM2 CH1 PWM 輸出 40kHz (Trigger 10μs pulse)
// 使用 TIM3 CH1 輸入捕捉測量 Echo 脈衝寬度
float measure_distance(void)
{
// 發送 10μs Trigger 脈衝
HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_SET);
delay_us(10);
HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_RESET);
// 等待輸入捕捉中斷獲取 Echo 寬度
// 距離 (cm) = pulse_width_us / 58
uint32_t pulse_us = get_captured_pulse_us();
return pulse_us / 58.0;
}
這個專案之所以經典,是因為它同時用到定時器的兩種能力:精確產生觸發脈衝,以及精確量測回來的高電位時間。用 delay 做當然也會動,但距離一遠就開始飄。
五個常見陷阱

其中最容易被忽略的是 PSC 與 ARR 的「寫入生效時機」:值寫進去了,行為卻沒變,通常是因為硬體要等一次更新事件(UEV)才把新值載入。如果你直接操作暫存器,就得自己等這個事件。
總結
硬體定時器看起來是一堆暫存器,但收斂起來只有四個重點:
- 三個數字決定一切:PSC 管速度、ARR 管週期、CNT 是現值
- 同一個計數器可以兼職:中斷、PWM、輸入捕捉都能掛上去
- 解析度與頻率是同一塊資源:想要細就快不了,選之前先想清楚
- 能交給硬體就別留給軟體:編碼器、捕捉、死區都是硬體免費幫你做的事
把這四點記住,之後不管是調馬達、做訊號產生器、還是寫一個「不卡住」的排程器,你都會知道該從哪個暫存器開始改。
文章評論