ESP32 定时器完全入门:millis 非阻塞、Ticker 回调与硬件定时器微秒级精度

前置知识:第一个程序已经用过 millis() 做过去抖。本文系统梳理 ESP32 上「周期性执行」的完整武器库——从最简单的 delay 到微秒级硬件定时器,并给出选型决策树。


一、为什么 delay() 是「新手陷阱」

// ❌ 反面教材:三个任务互相拖累
void loop() {
  readSensor();      // 10ms
  delay(1000);       // ← 阻塞 1 秒!
  updateDisplay();   // 期间按键、网络消息全部无人响应
}

delay() 期间 CPU 原地空转:按键读不到(除非上中断)、Wi-Fi 消息堆积、看门狗逼近超时。任何超过 50ms 的 delay 都是架构问题。


二、第 1 层:millis() 非阻塞范式(90% 场景够用)

核心思想:不等待,只检查——记录每个任务的上次执行时间,到点就跑:

uint32_t tSensor = 0, tDisplay = 0, tReport = 0;

void loop() {
  uint32_t now = millis();

  if (now - tSensor >= 100)  { tSensor = now;  readSensor(); }    // 10Hz
  if (now - tDisplay >= 1000) { tDisplay = now; updateDisplay(); } // 1Hz
  if (now - tReport >= 60000) { tReport = now; reportToServer(); } // 1 分钟

  handleButton();   // 随时响应,永不阻塞
}

三个细节:

  1. 必须用 now - t >= 间隔 而非 now >= t + 间隔——前者在 millis() 约 49.7 天溢出回绕时依然正确,后者会死锁
  2. micros()(微秒版)约 71.6 分钟回绕,同理用差值判断
  3. 间隔粒度受 loop 执行速度限制(毫秒级),要微秒精度请用第四层

三、第 2 层:Ticker——轻量周期回调

不想把任务判断摊在 loop 里?Ticker 把「到点执行」封装成回调:

#include <Ticker.h>

Ticker timerSensor, timerHeartbeat;

void readSensorTask() { /* 读传感器 */ }
void heartbeatTask()  { /* 心跳上报 */ }

void setup() {
  timerSensor.attach(0.1, readSensorTask);    // 每 100ms
  timerHeartbeat.attach(30, heartbeatTask);   // 每 30s
}

注意事项:

  • Ticker 回调运行在定时器中断上下文,遵守 ISR 铁律:短小、不 delay、不 print
  • 简单写法 attach();attach_ms() 用毫秒单位
  • 重活范式:Ticker 回调置标志位,loop 消费
volatile bool flagSensor = false;

void onTimer() { flagSensor = true; }   // 回调只留信号

void loop() {
  if (flagSensor) {
    flagSensor = false;
    readSensor();                       // 重活在这里
  }
}

四、第 3 层:硬件定时器——微秒级精确与 3.x 新 API

需要精确定时(如 20kHz 采样、精确秒脉冲、软实时控制)时上硬件定时器。经典 ESP32 有 4 组 64 位定时器,arduino-esp32 3.x API:

hw_timer_t *timer = nullptr;
volatile bool tickFlag = false;

void IRAM_ATTR onTimer() {     // 本质是中断:IRAM_ATTR 照加
  tickFlag = true;
}

void setup() {
  timer = timerBegin(1000000);           // 1MHz 时钟(1 tick = 1μs)
  timerAttachInterrupt(timer, &onTimer);
  timerAlarm(timer, 20000, true, 0);     // 20000 tick = 20ms 周期,自动重装
}

void loop() {
  if (tickFlag) {
    tickFlag = false;
    sampleAdc();                         // 精确 50Hz 采样
  }
}

与旧版(timerBegin(num, divider, countUp))差异较大,抄旧教程报错时注意版本分水岭(同 LEDC 的情况)。


五、四层武器选型决策树

你的周期性任务?
├── 精度要求 ≤ 毫秒级,任务可以摊在主循环
│   └── ① millis() 非阻塞范式(首选,最简单可控)
├── 想要「到点自动调用」的代码结构
│   └── ② Ticker 回调(置标志位 + loop 消费)
├── 需要微秒级精确周期(采样、软实时)
│   └── ③ 硬件定时器 timerBegin
└── 深度睡眠中被周期唤醒(μA 级功耗)
    └── ④ RTC 定时器(timerWake 深睡方案,见后续深睡专文)

六、看门狗:定时器的「反面」守护者

与「定时做事」相对的是「必须定期喂狗」:ESP32 的任务看门狗(TWDT)监控关键任务是否卡死。loop 长时间被阻塞(>约 5 秒不喂狗)会触发复位并打印:

Task watchdog got triggered. The following tasks did not reset the watchdog in time

排错三问:

  1. loop 里是否有长 delay() / 死循环计算?→ 改非阻塞
  2. 是否在 ISR/Ticker 回调里做了重活?→ 移回 loop
  3. 确属长计算任务?→ 手动加 yield() 分段喂狗

生产代码思路:看门狗是最后保险,不要用禁用看门狗来「解决」报错。


七、避坑速查表(收藏级)

症状/场景根因与对策
任务间隔越跑越偏用 now - t 累计基准(回绕安全),别用 delay 拼接
49.7 天后任务停摆now > t + interval 写法回绕死锁,改差值判断
Ticker 回调里 print 死机回调是中断上下文,移到 loop
硬件定时器报 timerBegin 参数不对2.x/3.x API 不同,3.x 是 timerBegin(1000000)
采样频率漂移millis 粒度不够,上硬件定时器
Task watchdog got triggeredloop 阻塞超时,改非阻塞或 yield()
想深睡定时唤醒RTC timer + deep sleep(后续专文)

八、延伸阅读

  • 🧭 前作:中断与去抖——事件驱动的另一半
  • 🧭 系列:ADC × 定时器 = 精确周期采样
  • ⏭️ 下一篇:UART/I2C/SPI 三大总线(即将上线)

版权声明:本文采用 CC BY-SA 4.0 协议授权。转载与二创请注明出处:ESPDIY 创客工坊 - espdiy.com

ESP32 Timers Complete Guide: Non-blocking millis, Ticker Callbacks and Hardware Timers

Prerequisite: Your First Program already used millis() for debouncing. This guide covers the complete toolbox for “run this periodically” — from plain delay to microsecond hardware timers, ending with a selection decision tree.


1. Why delay() is a beginner trap

// ❌ Anti-pattern: three tasks dragging each other down
void loop() {
  readSensor();      // 10ms
  delay(1000);       // ← blocked for a full second!
  updateDisplay();   // buttons and network messages ignored meanwhile
}

During delay() the CPU spins idle: buttons unread (unless on interrupts), Wi-Fi messages piling up, watchdog creeping toward timeout. Any delay beyond ~50ms is an architecture problem.


2. Layer 1: the millis() non-blocking pattern (enough for 90% of cases)

Core idea: don’t wait — check. Remember each task’s last run time and fire when due:

uint32_t tSensor = 0, tDisplay = 0, tReport = 0;

void loop() {
  uint32_t now = millis();

  if (now - tSensor >= 100)  { tSensor = now;  readSensor(); }     // 10Hz
  if (now - tDisplay >= 1000) { tDisplay = now; updateDisplay(); } // 1Hz
  if (now - tReport >= 60000) { tReport = now; reportToServer(); } // once a minute

  handleButton();   // always responsive, never blocked
}

Three details:

  1. Always now - t >= interval, never now >= t + interval — the former survives the ~49.7-day millis() wraparound; the latter deadlocks on it
  2. micros() (microsecond version) wraps in ~71.6 minutes — same subtraction rule
  3. Granularity is bounded by loop speed (millisecond class); for microseconds use layer 3

3. Layer 2: Ticker — lightweight periodic callbacks

Prefer tasks not sprawled across loop? Ticker wraps “run when due” as a callback:

#include <Ticker.h>

Ticker timerSensor, timerHeartbeat;

void readSensorTask() { /* read sensors */ }
void heartbeatTask()  { /* heartbeat report */ }

void setup() {
  timerSensor.attach(0.1, readSensorTask);    // every 100ms
  timerHeartbeat.attach(30, heartbeatTask);   // every 30s
}

Caveats:

  • Ticker callbacks run in the timer interrupt context — obey the ISR rules: short, no delay, no printing
  • attach() takes seconds; attach_ms() takes milliseconds
  • Heavy-work pattern: the callback sets a flag, loop consumes it
volatile bool flagSensor = false;

void onTimer() { flagSensor = true; }   // callback only signals

void loop() {
  if (flagSensor) {
    flagSensor = false;
    readSensor();                       // heavy work here
  }
}

4. Layer 3: hardware timers — microsecond precision, 3.x API

When you need precise timing (20kHz sampling, exact second pulses, soft real-time), use hardware timers. The classic ESP32 has four 64-bit timer groups; arduino-esp32 3.x API:

hw_timer_t *timer = nullptr;
volatile bool tickFlag = false;

void IRAM_ATTR onTimer() {     // still an interrupt: IRAM_ATTR applies
  tickFlag = true;
}

void setup() {
  timer = timerBegin(1000000);           // 1MHz clock (1 tick = 1µs)
  timerAttachInterrupt(timer, &onTimer);
  timerAlarm(timer, 20000, true, 0);     // 20000 ticks = 20ms period, auto-reload
}

void loop() {
  if (tickFlag) {
    tickFlag = false;
    sampleAdc();                         // precise 50Hz sampling
  }
}

The legacy API (timerBegin(num, divider, countUp)) differs a lot — when an old tutorial errors out, check the version watershed (same story as LEDC).


5. The four-layer selection tree

Your periodic task?
├── Millisecond precision is fine; tasks can live in the main loop
│   └── ① millis() non-blocking pattern (first choice — simplest, controllable)
├── You want "call me when due" structure
│   └── ② Ticker callback (set flag + consume in loop)
├── Microsecond-precise periods (sampling, soft real-time)
│   └── ③ Hardware timer via timerBegin
└── Periodic wake from deep sleep (µA power)
    └── ④ RTC timer (timerWake deep-sleep scheme — dedicated article coming)

6. The watchdog: the timer’s opposite

The counterpart of “do something on schedule” is “must feed on schedule”: the ESP32’s task watchdog (TWDT) watches for stuck tasks. A loop blocked too long (~5s unfed) triggers a reset printing:

Task watchdog got triggered. The following tasks did not reset the watchdog in time

Three debugging questions:

  1. Long delay() / tight computation in loop? → go non-blocking
  2. Heavy work inside an ISR/Ticker callback? → move it to loop
  3. Genuinely long computation? → insert yield() segments to feed the dog

Production mindset: the watchdog is your last line of defense — never “fix” the error by disabling it.


7. Pitfall cheat sheet (bookmark this)

Symptom / scenarioCause and fix
Intervals drift over timeAccumulate from now - t baselines (wrap-safe); don’t chain delays
Tasks stop after 49.7 daysnow > t + interval deadlocks on wrap — use subtraction
print in a Ticker callback freezesCallback is interrupt context — move to loop
timerBegin argument errors2.x/3.x APIs differ; 3.x is timerBegin(1000000)
Sampling frequency wandersmillis granularity insufficient — hardware timer
Task watchdog got triggeredLoop blocked too long — go non-blocking or yield()
Periodic deep-sleep wakeRTC timer + deep sleep (upcoming article)

8. Further reading

  • 🧭 Previous: Interrupts & Debouncing — the event-driven other half
  • 🧭 Series: ADC × timers = precise periodic sampling
  • ⏭️ Next: UART/I2C/SPI — the three buses (coming soon)

License: CC BY-SA 4.0. Repost & remix with attribution: ESPDIY Maker Lab - espdiy.com