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

Sơ đồ khối: xung APB 80 MHz qua bộ chia 80 thành 1 MHz vào bộ đếm 64 bit, bộ so sánh kiểm tra bằng 500 thì gọi ngắt ngatTimer, đường nét đứt tự nạp lại về bộ đếm; bên dưới là ba lời gọi timerBegin, timerAttachInterrupt, timerAlarm
Xung nhịp qua bộ chia vào bộ đếm; đếm tới giá trị đặt trước thì gọi ngắt và đếm lại (hình minh họa).

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ặc NULL nế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ột portMUX_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

Giản đồ 17 ms: xung vuông GPIO25 đều, các vạch ngắt timer cách đều 500 µs, một khối task nặng giữ CPU 3 ms, các vạch của task vTaskDelay(1) mỗi 1 ms nhưng có khoảng trống 4 ms trùng với lúc task nặng chạy
17 ms quanh lúc task ưu tiên cao giữ CPU: ngắt vẫn đều, task bị dồn nhịp (mô phỏng).
Serial Monitor: dòng timer 1000000 Hz ngắt mỗi 500 us, xung 1000 Hz ở GPIO25; các dòng ngắt timer 2000 lần chu kỳ 499,7 đến 500,3 us, vTaskDelay(1) 985 lần chu kỳ 995 đến 4005 us
Mỗi giây một dòng: số lần và chu kỳ ngắn nhất, dài nhất của hai cách (số liệu mô phỏng).

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ệcNên dùng
Đọc cảm biến mỗi giây, gửi dữ liệu mỗi phútvTaskDelay / vTaskDelayUntil trong task
Việc cần nhịp đều dưới 1 ms hoặc sai số dưới vài µsNgắ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 đốiesp_timer hoặc software timer của FreeRTOS
Lấy mẫu ADC tốc độ cao liên tụcADC 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ượngNguyên nhân thường gặpCá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 IRAMThêm IRAM_ATTR; không gọi hàm thư viện lạ trong ngắt
"Guru Meditation Error: Coprocessor exception" trong ngắtTính số thực trong hàm ngắtChỉ dùng số nguyên trong ngắt
Khởi động lại khi in Serial trong ngắtSerial dùng khóa, không được gọi từ ngắtBáo cho task in
Số liệu in ra lúc đúng lúc saiTask đọc dở trong khi ngắt đang ghiDù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 CPUTă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.

Tin tức liên quan

Mạch sạc pin 2S, 3S, 4S từ Type-C 5 V: chọn 1A, 2A, 4A, đấu dây và vì sao vẫn cần mạch bảo vệ BMS
Mạch sạc pin 2S, 3S, 4S từ Type-C 5 V: chọn 1A, 2A, 4A, đấu dây và vì sao vẫn cần mạch bảo vệ BMS

Mạch sạc tăng áp từ 5 V lên 8,4, 12,6, 16,8 V cho khối pin 2S, 3S, 4S: chân và cầu hàn, dòng sạc thực tế, nguồn cần dùng, đấu dây với mạch bảo vệ BMS và an toàn khi sạc.

Breadboard, dây cắm và hàn linh kiện: lắp mạch thử đúng cách cho người mới
Breadboard, dây cắm và hàn linh kiện: lắp mạch thử đúng cách cho người mới

Cách dùng breadboard: bên trong nối thế nào, cắm linh kiện và IC đúng cách, dây cắm, cấp nguồn và gắn ESP32; cách hàn linh kiện: dụng cụ, nhiệt độ, năm bước hàn, mối hàn tốt và xấu, an toàn.

Cảm biến tiệm cận điện dung: cấu tạo, nguyên lý, cách chế tạo và tự làm cảm biến mức nước với ESP32
Cảm biến tiệm cận điện dung: cấu tạo, nguyên lý, cách chế tạo và tự làm cảm biến mức nước với ESP32

Cảm biến tiệm cận điện dung hoạt động thế nào: điện cực, điện cực bù, điện trường viền, hằng số điện môi, báo mức xuyên thành bồn, cách chế tạo và tự làm cảm biến mức nước bằng chân cảm ứng ESP32.

Lập trình rèn cho trẻ cách giải quyết vấn đề có hệ thống: hiểu đề, chia nhỏ, lập kế hoạch, gỡ lỗi
Lập trình rèn cho trẻ cách giải quyết vấn đề có hệ thống: hiểu đề, chia nhỏ, lập kế hoạch, gỡ lỗi

Vì sao học lập trình rèn cho trẻ cách giải quyết vấn đề có hệ thống: bốn bước của Pólya, chia nhỏ, lập kế hoạch, gỡ lỗi từng bước, nghiên cứu liên quan và hoạt động tại nhà.

Vali đựng máy ảnh, ống kính chống sốc: chọn cỡ theo bộ máy và cách xếp xốp
Vali đựng máy ảnh, ống kính chống sốc: chọn cỡ theo bộ máy và cách xếp xốp

Chọn vali đựng máy ảnh, ống kính: liệt kê những gì bộ máy cần chỗ, xếp xốp, đặt ống kính đứng hay nằm, gợi ý cỡ vali chống nước theo bộ máy, chống ẩm và mang lên xe, máy bay.

Lực kế chuẩn theo ISO 376: trình tự hiệu chuẩn, chỉ tiêu tái lập, lặp lại, nội suy và phân cấp
Lực kế chuẩn theo ISO 376: trình tự hiệu chuẩn, chỉ tiêu tái lập, lặp lại, nội suy và phân cấp

Hiệu chuẩn lực kế chuẩn theo ISO 376: chuẩn bị, trình tự sáu loạt đo xoay 0°, 120°, 240°, công thức b, b', fc, v, bảng tiêu chí cấp 00, 0,5, 1, 2 và ví dụ lực kế 100 kN.

Vẽ đa giác và hoa văn bằng lập trình: học góc trong môn Toán với Scratch
Vẽ đa giác và hoa văn bằng lập trình: học góc trong môn Toán với Scratch

Dùng nhóm lệnh Bút vẽ của Scratch để vẽ đa giác đều, ngôi sao, hoa văn, xoắn ốc; hiểu vì sao xoay góc ngoài 360 : n, góc trong, và liên hệ với môn Toán ở tiểu học.

HTTPS trên ESP32: nhúng chứng chỉ gốc đúng cách và vì sao setInsecure() nguy hiểm
HTTPS trên ESP32: nhúng chứng chỉ gốc đúng cách và vì sao setInsecure() nguy hiểm

HTTPS bảo vệ được gì, chuỗi tin cậy của chứng chỉ, nhúng chứng chỉ gốc ISRG Root X1 vào ESP32, vì sao setInsecure() mở đường cho tấn công xen giữa, chương trình thử ba trường hợp.


Bình luận
Đã thêm vào giỏ hàng