attachInterrupt trên Arduino: đếm xung, đo tần số, lưu ý volatile

Dùng attachInterrupt trên Arduino: chân ngắt, volatile, vì sao biến nhiều byte phải đọc khi tắt ngắt qua mã máy thật, đếm xung và đo chu kỳ, quy tắc trong hàm ngắt, lỗi hay gặp.

attachInterrupt trên Arduino gọi một hàm ngay khi có cạnh tín hiệu ở chân chỉ định, kể cả khi loop() đang bận. Đây là cách đếm xung encoder, cảm biến lưu lượng, đo tần số. Lỗi với ngắt rất khó tìm vì chỉ hiện thỉnh thoảng. Bài này chỉ cách dùng, phân tích lỗi đọc biến nhiều byte bằng mã máy thật do trình biên dịch sinh ra, và so sánh hai cách đo tần số.

1. Cách dùng

attachInterrupt(digitalPinToInterrupt(2), tenHam, RISING);
Bảng chân ngắt: Uno và Nano chân 2, 3; Mega sáu chân; ESP32, ESP8266 đa số chân
Dùng hàm digitalPinToInterrupt để code chạy được trên nhiều mạch.

Hàm ngắt không có tham số, không trả về giá trị. Trên ESP32 và ESP8266, hàm nên có IRAM_ATTR để nằm trong RAM, không phụ thuộc bộ nhớ flash. Cách dùng ngắt trên ESP32 có các điểm riêng; trong bài này dùng Arduino Nano.

2. Lưu ý volatile và đọc atomic

volatile báo trình biên dịch rằng biến có thể đổi bất cứ lúc nào, nên phải đọc lại từ RAM. Nhưng volatile chưa đủ. ATmega328 là chip 8 bit: biến 16 bit được đọc bằng hai lệnh.

Hai lệnh lds đọc byte thấp rồi byte cao; nếu ngắt chen giữa khi biến đổi từ 0x00FF sang 0x0100 thì kết quả sai 0x01FF
Lệnh lấy từ mã máy thật của sketch biên dịch cho Nano.

Nếu ngắt tăng biến ngay giữa hai lệnh đúng lúc byte thấp tràn, kết quả sai đúng 256. Script dưới đây mô phỏng tình huống này: xung 10 kHz, đọc biến 100.000 lần mỗi giây trong 60 giây.

# PYWORLD.VN - Mô phỏng biến 16 bit trên AVR 8 bit: đọc hai byte, ngắt chen giữa làm sai số 256; tính sai số đo tần số bằng đếm xung và đo chu kỳ
# PYWORLD.VN - Mô phỏng đúng thứ tự lệnh lds của trình biên dịch: đọc byte thấp trước, byte cao sau; kiểm tra lại bằng công thức xác suất
# PYWORLD.VN - Chạy: python mo_phong_ngat.py          (chỉ cần Python, không cần thư viện ngoài)
import random

F_CPU = 16e6
CUA_SO_CHU_KY = 2             # hai chu kỳ giữa hai lệnh lds


def doc_khong_an_toan(ngat_hz, doc_moi_giay, giay, co_khoa=False, hat=3):
    """Ngắt đến ngẫu nhiên: đếm số lần đọc ra giá trị sai."""
    r = random.Random(hat)
    tong_doc = int(doc_moi_giay * giay)
    p_chen = ngat_hz * CUA_SO_CHU_KY / F_CPU
    sai = 0
    dem = 0
    buoc = ngat_hz / doc_moi_giay
    tich = 0.0
    for _ in range(tong_doc):
        tich += buoc
        them = int(tich)
        tich -= them
        dem = (dem + them) & 0xFFFF
        if co_khoa:
            continue
        if r.random() < p_chen and (dem & 0xFF) == 0xFF:
            sai += 1                   # byte thấp 0xFF cũ + byte cao mới: lệch 256
    return sai, tong_doc


def sai_so_dem(f, t=1.0):
    return 1 / t


def sai_so_chu_ky(f, do_phan_giai_us=4.0):
    return f * f * do_phan_giai_us * 1e-6


if __name__ == "__main__":
    print("Dem tang o 10 kHz, doc 100.000 lan moi giay, 60 giay:")
    s, n = doc_khong_an_toan(10000, 100000, 60)
    p = 10000 * CUA_SO_CHU_KY / F_CPU / 256
    print(f"  Khong khoa ngat: {s} lan sai tren {n}; ly thuyet {p * n:.0f}")
    s, n = doc_khong_an_toan(10000, 100000, 60, co_khoa=True)
    print(f"  Co noInterrupts: {s} lan sai tren {n}")
    print("Sai so do tan so, Hz")
    for f in (10, 100, 500, 1000, 10000):
        print(f"  {f:>6} Hz: dem 1 s {sai_so_dem(f):6.2f}, do chu ky {sai_so_chu_ky(f):8.4f}")
    print("Diem giao:", round((1 / 1 / (4e-6)) ** 0.5), "Hz")
    print("Thoi gian vao ngat attachInterrupt khoang 5 us -> gioi han khoang", round(1 / 7e-6 / 1000), "kHz")

Kết quả: 22 lần đọc sai trên 6 triệu lần, lý thuyết 29 lần, nghĩa là cứ vài giây lại có một số nhảy. Quá ít để thấy khi thử nhanh, nhưng đủ để làm sai một bộ đếm chạy cả ngày. Với noInterrupts() thì không có lần nào. Bộ đếm 32 bit, số thực, chuỗi đều cần làm như vậy: sao chép ra biến cục bộ khi tắt ngắt, rồi dùng bản sao.

Hai lệnh noInterrupts(), interrupts() phải bao quanh càng ngắn càng tốt, vì trong lúc đó các ngắt khác đang chờ.

3. Chương trình đếm xung, đo tần số

Nối D9 sang D2 để tự thử; tone() phát 1 kHz trên D9. Cắm đế ra chân Nano để đấu tín hiệu thật.

// PYWORLD.VN - attachInterrupt trên Arduino Nano: đếm xung D2, đo tần số bằng hai cách, đọc biến dùng chung an toàn
// PYWORLD.VN - Nối D9 sang D2 để tự thử: tone() phát xung trên D9. Tín hiệu ngoài phải 0–5 V và chung GND
// PYWORLD.VN - Tần số thấp nên đo chu kỳ, tần số cao nên đếm xung; chương trình chọn theo mốc 500 Hz

const int CHAN_XUNG = 2, CHAN_PHAT = 9;
const uint32_t CHU_KY_IN_MS = 1000;

volatile uint32_t soXung = 0;
volatile uint32_t lucCanhTruoc = 0;
volatile uint32_t chuKyUs = 0;

void khiCoCanh() {
  const uint32_t t = micros();
  soXung = soXung + 1;
  chuKyUs = t - lucCanhTruoc;
  lucCanhTruoc = t;
}

void layDuLieu(uint32_t &xung, uint32_t &chuKy) {
  noInterrupts();                         // biến 32 bit: 4 byte, phải đọc liền một mạch
  xung = soXung;
  chuKy = chuKyUs;
  interrupts();
}

void setup() {
  Serial.begin(115200);
  pinMode(CHAN_XUNG, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(CHAN_XUNG), khiCoCanh, RISING);
  tone(CHAN_PHAT, 1000);                  // phát thử 1 kHz, bỏ dòng này khi đo tín hiệu thật
}

void loop() {
  static uint32_t lanIn = 0, xungTruoc = 0;
  if (millis() - lanIn < CHU_KY_IN_MS) {
    return;
  }
  const uint32_t bayGio = millis();
  const uint32_t dt = bayGio - lanIn;
  lanIn = bayGio;

  uint32_t xung, chuKy;
  layDuLieu(xung, chuKy);
  const float tanSoDem = (xung - xungTruoc) * 1000.0 / dt;
  xungTruoc = xung;
  const float tanSoChuKy = chuKy ? 1e6 / chuKy : 0;

  Serial.print("Tong xung ");
  Serial.print(xung);
  Serial.print(", dem xung ");
  Serial.print(tanSoDem, 1);
  Serial.print(" Hz, do chu ky ");
  Serial.print(tanSoChuKy, 1);
  Serial.print(" Hz, chon ");
  Serial.println(tanSoDem > 500 ? tanSoDem : tanSoChuKy, 1);
}

4. Đếm xung hay đo chu kỳ

Đồ thị log sai số: đếm xung cố định 1 Hz, đo chu kỳ tăng theo bình phương tần số, giao nhau ở 500 Hz
micros() trên Nano 16 MHz có độ phân giải 4 µs.
  • Đếm xung trong T giây: sai ±1 xung, tức ±1/T Hz, không đổi theo tần số. Hợp với tần số cao.
  • Đo chu kỳ: sai theo độ phân giải đồng hồ, đổi ra tần số là f² × 4 µs. Ở 10 Hz chỉ sai 0,0004 Hz; ở 10 kHz sai tới 400 Hz.
  • Điểm giao ở √(1 / (T × 4 µs)) = 500 Hz. Chương trình dùng mốc này để chọn cách đo.

Bộ đếm phần cứng Timer1 (chân D5) hay Input Capture (D8) đo được cao hơn và không tốn CPU cho mỗi xung. Với attachInterrupt, mỗi lần vào ngắt tốn vài micro giây, nên giới hạn cỡ vài chục đến hơn 100 kHz, còn phụ thuộc nội dung hàm.

5. Quy tắc trong hàm ngắt

Bảng quy tắc: không Serial, không delay, millis không tăng, giữ ngắn, dùng volatile, tắt ngắt khi chép biến nhiều byte
Hàm ngắt chỉ ghi nhận sự kiện, phần xử lý để trong loop.

6. Lỗi thường gặp

Bảng lỗi: không đếm, đếm nhiều do dội phím, biến không đổi, số nhảy vọt, đếm thiếu ở tần số cao, đếm khi không có xung
Với nút nhấn, ngắt thường không cần thiết; đọc trong loop đơn giản hơn.

7. Xem thêm

  • Bài cùng cụm về máy trạng thái trong code Arduino, nạp bootloader bằng Arduino ISP.

Tin tức liên quan

Điều khiển relay từ xa bằng ESP-NOW: remote ESP32 một nút nhấn có phản hồi trạng thái
Điều khiển relay từ xa bằng ESP-NOW: remote ESP32 một nút nhấn có phản hồi trạng thái

Hướng dẫn làm bộ điều khiển relay từ xa bằng hai mạch ESP32 và ESP-NOW hai chiều: remote gửi lệnh, mạch relay báo lại trạng thái, lọc theo địa chỉ MAC và bật mã hóa.

Giám sát điện trong nhà bằng ESP32 và nhiều PZEM-004T: từng nhánh, quá tải, quên tắt, công suất nền
Giám sát điện trong nhà bằng ESP32 và nhiều PZEM-004T: từng nhánh, quá tải, quên tắt, công suất nền

Dùng ESP32 đọc nhiều PZEM-004T chung một đường Modbus để giám sát điện từng nhánh: cảnh báo quá tải, điện áp thấp, bình nóng lạnh quên tắt, điện năng từng ngày, công suất nền.

Vẽ đồ thị dữ liệu ESP32 trên ThingSpeak: theo dõi pin 18650, ngủ sâu và ước tính thời gian chạy pin
Vẽ đồ thị dữ liệu ESP32 trên ThingSpeak: theo dõi pin 18650, ngủ sâu và ước tính thời gian chạy pin

ESP32 chạy pin 18650 thức dậy mỗi 10 phút, đo điện áp pin, gửi lên ThingSpeak rồi ngủ sâu; cách tạo kênh, ước tính thời gian chạy pin và giảm dòng lúc ngủ.

Tưới cây tự động với ESP32: cảm biến độ ẩm đất, relay và bơm mini, ngưỡng có trễ
Tưới cây tự động với ESP32: cảm biến độ ẩm đất, relay và bơm mini, ngưỡng có trễ

Làm hệ thống tưới cây tự động với ESP32: cảm biến độ ẩm đất điện dung, hiệu chỉnh khô ướt, relay điều khiển bơm mini, ngưỡng có trễ, giới hạn thời gian bơm và thí nghiệm STEM.

ESP32 điều khiển động cơ không chổi than qua ESC: xung 1000–2000 µs, hiệu chuẩn dải ga, thử an toàn
ESP32 điều khiển động cơ không chổi than qua ESC: xung 1000–2000 µs, hiệu chuẩn dải ga, thử an toàn

Dùng ESP32 phát xung ga 1000–2000 µs bằng LEDC để điều khiển ESC và động cơ không chổi than: nối dây với pin LiPo, hiệu chuẩn dải ga, tăng ga từ từ, khóa động cơ và an toàn khi thử.

Đưa ESP32 và relay 4 kênh vào Home Assistant bằng MQTT discovery, không viết YAML
Đưa ESP32 và relay 4 kênh vào Home Assistant bằng MQTT discovery, không viết YAML

ESP32 tự gửi tin cấu hình MQTT discovery, Home Assistant tạo 4 công tắc relay và cảm biến Wi-Fi; Mosquitto, tài khoản, di chúc, retain và bảng xử lý lỗi.

DHT11 và DHT22 với ESP32: đọc nhiệt độ, độ ẩm không cần thư viện, giao thức 40 bit và điểm sương
DHT11 và DHT22 với ESP32: đọc nhiệt độ, độ ẩm không cần thư viện, giao thức 40 bit và điểm sương

So sánh DHT11 và DHT22, nối dây với ESP32, giải thích giao thức một dây 40 bit và checksum, chương trình đọc nhiệt độ, độ ẩm không cần thư viện, kèm điểm sương và bảng lỗi.

RFID RC522 với ESP32: đọc mã thẻ không cần thư viện, lưu thẻ hợp lệ, mở khóa bằng relay
RFID RC522 với ESP32: đọc mã thẻ không cần thư viện, lưu thẻ hợp lệ, mở khóa bằng relay

Nối RC522 với ESP32 qua SPI, hiểu REQA, chống va chạm, SELECT, HLTA, đọc UID 4 và 7 byte không cần thư viện, lưu thẻ bằng Preferences, đóng relay khi quẹt đúng thẻ.


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