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);

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.

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ỳ

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

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

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.
Xem thêm