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(); // 随时响应,永不阻塞
}
三个细节:
- 必须用
now - t >= 间隔而非now >= t + 间隔——前者在 millis() 约 49.7 天溢出回绕时依然正确,后者会死锁 micros()(微秒版)约 71.6 分钟回绕,同理用差值判断- 间隔粒度受 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
排错三问:
- loop 里是否有长
delay()/ 死循环计算?→ 改非阻塞 - 是否在 ISR/Ticker 回调里做了重活?→ 移回 loop
- 确属长计算任务?→ 手动加
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 triggered | loop 阻塞超时,改非阻塞或 yield() |
| 想深睡定时唤醒 | RTC timer + deep sleep(后续专文) |
八、延伸阅读
版权声明:本文采用 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:
- Always
now - t >= interval, nevernow >= t + interval— the former survives the ~49.7-day millis() wraparound; the latter deadlocks on it micros()(microsecond version) wraps in ~71.6 minutes — same subtraction rule- 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:
- Long
delay()/ tight computation in loop? → go non-blocking - Heavy work inside an ISR/Ticker callback? → move it to loop
- 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 / scenario | Cause and fix |
|---|---|
| Intervals drift over time | Accumulate from now - t baselines (wrap-safe); don’t chain delays |
| Tasks stop after 49.7 days | now > t + interval deadlocks on wrap — use subtraction |
| print in a Ticker callback freezes | Callback is interrupt context — move to loop |
timerBegin argument errors | 2.x/3.x APIs differ; 3.x is timerBegin(1000000) |
| Sampling frequency wanders | millis granularity insufficient — hardware timer |
Task watchdog got triggered | Loop blocked too long — go non-blocking or yield() |
| Periodic deep-sleep wake | RTC 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