0x6A Logbook

0x6A Logbook
Shi6a的筆記本
  1. 首頁
  2. 程式開發
  3. 正文

硬體定時器(Timer)完整教學:從計數原理到 STM32/ESP32 PWM 與輸入捕捉實作

2026 年 5 月 31 日 536點熱度 0人點贊 0條評論

硬體定時器完整教學封面

前言:先說為什麼你該把 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 有一個必須先知道的取捨:

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、輸入捕捉都能掛上去
  • 解析度與頻率是同一塊資源:想要細就快不了,選之前先想清楚
  • 能交給硬體就別留給軟體:編碼器、捕捉、死區都是硬體免費幫你做的事

把這四點記住,之後不管是調馬達、做訊號產生器、還是寫一個「不卡住」的排程器,你都會知道該從哪個暫存器開始改。

標籤: ESP32 工業通訊
最後更新: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