Timer phần cứng trên ESP32: ngắt định kỳ chính xác tới micro giây, API mới của gói esp32 3.x
Timer phần cứng ESP32 hoạt động thế nào, API timerBegin, timerAlarm của gói esp32 3.x, quy tắc viết hàm ngắt, thí nghiệm so ngắt timer 500 µs với vTaskDelay(1) khi CPU bận.
Nhiều việc cần chạy đúng nhịp tới từng micro giây: lấy mẫu âm thanh, tạo xung cho động cơ bước, quét LED ma trận, đo tốc độ. vTaskDelay() chỉ chính xác tới một tick (1 ms) và còn bị lệch khi task khác chiếm CPU. ESP32 có 4 timer phần cứng 64 bit: bộ đếm chạy bằng mạch điện, tới hạn thì gọi ngắt, không phụ thuộc chương trình đang làm gì. Bài này giải thích timer hoạt động thế nào, cách dùng API timer của gói esp32 3.x (khác hẳn bản 2.x), các quy tắc viết hàm ngắt, và một thí nghiệm so độ chính xác của ngắt timer với vTaskDelay(1).
1. Timer phần cứng hoạt động thế nào

Mỗi timer gồm ba phần: bộ chia hạ xung nhịp 80 MHz xuống tần số mong muốn, bộ đếm 64 bit tăng 1 mỗi xung đã chia, và bộ so sánh phát ngắt khi bộ đếm bằng giá trị đặt trước (alarm). Với chế độ tự nạp lại, bộ đếm quay về 0 ngay lúc đó, nên các ngắt cách nhau đúng bằng nhau mà không cần phần mềm can thiệp.
Gói esp32 3.x chỉ còn ba hàm chính:
timerBegin(1000000): lấy một timer còn trống, đặt bộ chia để đếm 1 MHz (mỗi đơn vị 1 µs). Trả về con trỏhw_timer_t*, hoặcNULLnếu hết timer.timerAttachInterrupt(dongHo, ngatTimer): gắn hàm ngắt.timerAlarm(dongHo, 500, true, 0): báo khi đếm tới 500, tự nạp lại, số lần lặp 0 là lặp mãi.
Các bài hướng dẫn cũ dùng timerBegin(0, 80, true), timerAlarmWrite(), timerAlarmEnable(): đó là API của gói esp32 2.x, không còn biên dịch được trên bản 3.x. Gặp lỗi "too many arguments to function timerBegin" là do vậy.
2. Quy tắc viết hàm ngắt
- Đánh dấu
IRAM_ATTRđể hàm nằm trong RAM nội, chạy được cả khi bộ nhớ flash đang bận (ví dụ lúc ghi Preferences). - Làm thật ít: ghi chân, đọc bộ đếm, cập nhật vài biến. Không gọi
Serial,delay(), cấp phát bộ nhớ, không chờ mutex. - Tránh tính số thực (float) trong ngắt; đổi đơn vị khi in ra ở task.
- Biến dùng chung giữa ngắt và task bảo vệ bằng
portENTER_CRITICAL_ISR(trong ngắt) vàportENTER_CRITICAL(trong task) với cùng mộtportMUX_TYPE. - Muốn báo cho task làm việc dài, dùng hàm
…FromISR:vTaskNotifyGiveFromISR()trong bài là cách nhẹ nhất để đánh thức đúng một task.
Hàm ngắt trong bài ghi thẳng vào thanh ghi GPIO_OUT_W1TS_REG / GPIO_OUT_W1TC_REG (đặt lên 1 / xóa về 0 các chân theo mặt nạ bit) thay cho digitalWrite(): nhanh hơn và chắc chắn nằm trong RAM. Thời điểm mỗi lần ngắt được đo bằng bộ đếm xung nhịp CPU (esp_cpu_get_cycle_count(), 240 xung mỗi µs ở 240 MHz), đủ mịn để thấy sai lệch dưới 1 µs.
3. Thí nghiệm: ngắt timer và vTaskDelay(1)
Chương trình chạy đồng thời trên nhân 1:
- Ngắt timer mỗi 500 µs, đảo mức GPIO25 (tạo xung vuông 1 kHz) và ghi lại khoảng cách giữa hai lần ngắt.
- Task nhịp (ưu tiên 2) lặp
vTaskDelay(1)và cũng ghi khoảng cách giữa hai lần chạy. - Task nặng (ưu tiên 3) mỗi 200 ms giữ CPU 3 ms bằng
delayMicroseconds(3000), giả lập một việc xử lý nặng. - Task báo cáo được ngắt đánh thức mỗi 2000 lần ngắt (1 giây), chép và đặt lại số liệu trong một đoạn khóa rồi in ra.
// PYWORLD.VN - Timer phần cứng trên ESP32: ngắt đúng mỗi 500 µs, tạo xung 1 kHz ở GPIO25, đo độ lệch chu kỳ bằng bộ đếm xung nhịp CPU
// So sánh với một task dùng vTaskDelay(1) khi có task ưu tiên cao chiếm CPU 3 ms mỗi 200 ms. Đo xung ở GPIO25 bằng máy hiện sóng hoặc logic analyzer
// Không cần thư viện ngoài (API timer của gói esp32 3.x)
#include "esp_cpu.h"
#include "soc/gpio_reg.h"
const int CHAN_XUNG = 25;
const uint32_t TAN_SO_TIMER = 1000000; // timer đếm 1 MHz: mỗi đơn vị 1 µs
const uint64_t CHU_KY_US = 500; // ngắt mỗi 500 µs (2000 lần mỗi giây)
hw_timer_t *dongHo;
TaskHandle_t taskBaoCao;
portMUX_TYPE khoa = portMUX_INITIALIZER_UNLOCKED;
struct ThongKe { // độ dài chu kỳ đo bằng số xung nhịp CPU (240 MHz: 240 xung = 1 µs)
uint32_t dem, nhoNhat, lonNhat, lanTruoc;
};
ThongKe tkNgat = {0, UINT32_MAX, 0, 0}, tkTask = {0, UINT32_MAX, 0, 0};
bool mucCao = false;
void IRAM_ATTR ghiNhan(ThongKe &tk, uint32_t bayGio) {
if (tk.lanTruoc) {
uint32_t d = bayGio - tk.lanTruoc;
if (d < tk.nhoNhat) tk.nhoNhat = d;
if (d > tk.lonNhat) tk.lonNhat = d;
}
tk.lanTruoc = bayGio;
tk.dem++;
}
// Hàm ngắt: ngắn, nằm trong IRAM, không gọi Serial hay delay.
void IRAM_ATTR ngatTimer() {
uint32_t bayGio = esp_cpu_get_cycle_count();
mucCao = !mucCao;
REG_WRITE(mucCao ? GPIO_OUT_W1TS_REG : GPIO_OUT_W1TC_REG, BIT(CHAN_XUNG)); // ghi thẳng thanh ghi: nhanh, an toàn trong ngắt
portENTER_CRITICAL_ISR(&khoa);
ghiNhan(tkNgat, bayGio);
bool duMotGiay = tkNgat.dem >= 1000000 / CHU_KY_US;
portEXIT_CRITICAL_ISR(&khoa);
if (duMotGiay) {
BaseType_t canDoi = pdFALSE;
vTaskNotifyGiveFromISR(taskBaoCao, &canDoi); // báo cho task báo cáo, nhẹ hơn semaphore
portYIELD_FROM_ISR(canDoi);
}
}
// Task so sánh: cố chạy mỗi 1 ms bằng vTaskDelay(1).
void taskNhip1ms(void *thamSo) {
for (;;) {
vTaskDelay(1);
uint32_t bayGio = esp_cpu_get_cycle_count();
portENTER_CRITICAL(&khoa);
ghiNhan(tkTask, bayGio);
portEXIT_CRITICAL(&khoa);
}
}
// Task ưu tiên cao làm việc nặng 3 ms mỗi 200 ms (giả lập xử lý dữ liệu, ghi màn hình...).
void taskNang(void *thamSo) {
for (;;) {
vTaskDelay(pdMS_TO_TICKS(200));
delayMicroseconds(3000); // giữ CPU 3 ms, không nhường
}
}
void taskInBaoCao(void *thamSo) {
float xungMoiUs = getCpuFrequencyMhz();
for (;;) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
ThongKe a, b;
portENTER_CRITICAL(&khoa); // chép rồi đặt lại trong cùng một đoạn khóa
a = tkNgat;
b = tkTask;
tkNgat = {0, UINT32_MAX, 0, tkNgat.lanTruoc};
tkTask = {0, UINT32_MAX, 0, tkTask.lanTruoc};
portEXIT_CRITICAL(&khoa);
Serial.printf("Ngat timer: %lu lan, chu ky %.1f - %.1f us | vTaskDelay(1): %lu lan, chu ky %.1f - %.1f us\n",
(unsigned long)a.dem, a.nhoNhat / xungMoiUs, a.lonNhat / xungMoiUs,
(unsigned long)b.dem, b.nhoNhat / xungMoiUs, b.lonNhat / xungMoiUs);
}
}
void setup() {
Serial.begin(115200);
pinMode(CHAN_XUNG, OUTPUT);
xTaskCreatePinnedToCore(taskInBaoCao, "bao cao", 4096, NULL, 1, &taskBaoCao, 1);
xTaskCreatePinnedToCore(taskNhip1ms, "nhip 1ms", 2048, NULL, 2, NULL, 1);
xTaskCreatePinnedToCore(taskNang, "nang", 2048, NULL, 3, NULL, 1);
dongHo = timerBegin(TAN_SO_TIMER); // chọn một timer phần cứng còn trống, đếm 1 MHz
timerAttachInterrupt(dongHo, ngatTimer);
timerAlarm(dongHo, CHU_KY_US, true, 0); // báo khi đếm tới 500, tự nạp lại, lặp mãi
Serial.printf("Timer %lu Hz, ngat moi %llu us; xung %llu Hz o GPIO%d\n", (unsigned long)timerGetFrequency(dongHo), CHU_KY_US,
1000000ULL / (2 * CHU_KY_US), CHAN_XUNG);
}
void loop() {
vTaskDelay(portMAX_DELAY);
}
4. Kết quả mong đợi


Ngắt timer luôn đủ 2000 lần mỗi giây và chỉ lệch vài phần mười micro giây (do lệnh đang chạy dở khi ngắt tới). Task vTaskDelay(1) mất khoảng 15 lần mỗi giây và có chu kỳ dài tới 4 ms mỗi khi task nặng chạy. Đo xung ở GPIO25 bằng máy hiện sóng hoặc logic analyzer sẽ thấy tần số 1 kHz rất ổn định.
Ngắt timer vẫn có thể bị trễ: khi một ngắt khác ưu tiên cao hơn đang chạy, hoặc khi chương trình tắt ngắt quá lâu (đoạn khóa portENTER_CRITICAL dài). Vì vậy đoạn khóa cũng phải thật ngắn.
5. Khi nào dùng cách nào
| Việc | Nên dùng |
|---|---|
| Đọc cảm biến mỗi giây, gửi dữ liệu mỗi phút | vTaskDelay / vTaskDelayUntil trong task |
| Việc cần nhịp đều dưới 1 ms hoặc sai số dưới vài µs | Ngắt timer phần cứng |
| Xung PWM tần số cố định cho LED, động cơ | Khối LEDC (không cần ngắt) |
| Hẹn giờ phần mềm vài ms trở lên, không cần chính xác tuyệt đối | esp_timer hoặc software timer của FreeRTOS |
| Lấy mẫu ADC tốc độ cao liên tục | ADC chế độ liên tục (DMA) thay vì đọc trong ngắt |
Không gọi analogRead() trong hàm ngắt: trình điều khiển ADC dùng khóa, có thể làm treo hoặc khởi động lại chip. Cách đúng là ngắt báo cho task đọc, hoặc dùng chế độ đọc liên tục của ADC.
6. Xử lý lỗi thường gặp
| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
Lỗi biên dịch ở timerBegin(0, 80, true) | Mã viết cho gói esp32 2.x | Đổi sang API 3.x như trong bài |
| Khởi động lại, báo "Cache disabled but cached memory region accessed" | Hàm ngắt (hoặc hàm nó gọi) không nằm trong IRAM | Thêm IRAM_ATTR; không gọi hàm thư viện lạ trong ngắt |
| "Guru Meditation Error: Coprocessor exception" trong ngắt | Tính số thực trong hàm ngắt | Chỉ dùng số nguyên trong ngắt |
| Khởi động lại khi in Serial trong ngắt | Serial dùng khóa, không được gọi từ ngắt | Báo cho task in |
| Số liệu in ra lúc đúng lúc sai | Task đọc dở trong khi ngắt đang ghi | Dùng portENTER_CRITICAL khi chép số liệu |
| Chip treo khi chu kỳ quá ngắn (vài µs) | Ngắt chiếm hết CPU | Tăng chu kỳ; việc cần tần số cao dùng LEDC, RMT, I2S |
7. Xem thêm
- Ngắt GPIO trên ESP32: ngắt theo sự kiện ở chân, cùng quy tắc viết hàm ngắt.
- PWM trên ESP32: khi chỉ cần một xung đều, LEDC làm việc đó không cần ngắt.
- Các bài FreeRTOS (task; queue, semaphore, mutex) trong cùng loạt ESP32 nâng cao.
Xem thêm