Điều khiển nâng cao

Đa nhiệm với millis() - robot làm nhiều việc cùng lúc không cần delay

7 phút đọc05/07/2026HocLapTrinhRobot
Đa nhiệm với millis() - robot làm nhiều việc cùng lúc không cần delay

Loại bỏ delay() triệt để - mẫu millis chuẩn, struct Task tái sử dụng, non-blocking cho nút nhấn/nháy đèn/đọc cảm biến và bẫy tràn số unsigned long. Trong bài viết này, chúng ta sẽ cùng tìm hiểu chi tiết và ứng dụng thực tế trong robot.

delay() - kẻ đánh cắp thời gian thực

delay(1000) trông vô hại nhưng là lệnh đóng băng toàn bộ chương trình: trong 1 giây đó robot không đọc cảm biến, không nhận Bluetooth, không cập nhật PID — mù, điếc và liệt. Một robot "vừa nháy đèn báo hiệu vừa dò line vừa nghe lệnh dừng khẩn" là bất khả thi với delay, và khả thi tự nhiên với mẫu millis().

Bài này đưa mẫu millis từ mức "biết dùng" (đã chạm ở bài Cấu trúc chương trình Arduino trong mục Kiến thức) lên mức thành thạo: mẫu chuẩn, đóng gói tái sử dụng, các ca khó (nút nhấn, chuỗi hành động) và những cái bẫy có thật.

Mẫu chuẩn - bốn dòng thay đổi mọi thứ

cpp
unsigned long lastBlink = 0;
const unsigned long BLINK_INTERVAL = 500;

void loop() {
  if (millis() - lastBlink >= BLINK_INTERVAL) {
    lastBlink = millis();
    digitalWrite(LED, !digitalRead(LED));   // việc cần làm định kỳ
  }
  // loop trống trải - các việc khác cứ tự nhiên chen vào
}

Đọc đúng cơ chế: millis() đếm mili giây từ lúc bật nguồn, không bao giờ dừng. Thay vì "đứng chờ đủ 500 ms" (delay), ta ghé qua kiểm tra "đã đủ 500 ms chưa?" hàng chục nghìn lần mỗi giây — chưa đủ thì đi làm việc khác. Đây là hợp tác đa nhiệm (cooperative multitasking): các việc thay phiên nhau cực nhanh trên một nhân, với điều kiện không việc nào chiếm sân lâu.

Ba việc song song đúng nghĩa:

cpp
unsigned long tBlink = 0, tSensor = 0, tReport = 0;

void loop() {
  if (millis() - tBlink >= 500)  { tBlink = millis();  toggleLed(); }
  if (millis() - tSensor >= 50)  { tSensor = millis(); updateDistance(); }
  if (millis() - tReport >= 2000){ tReport = millis(); sendTelemetry(); }
  handleBluetooth();   // việc "liên tục" - chạy mỗi vòng
}

Đèn nháy 2 Hz, cảm biến 20 Hz, báo cáo 0.5 Hz, Bluetooth tức thời — bốn nhịp độc lập, không ai chặn ai.

Đóng gói thành Task - hết lặp code

Ba biến tXxx lặp mãi thành rác — gói vào struct dùng cả đời:

cpp
struct Task {
  unsigned long interval;
  unsigned long last = 0;
  bool due() {
    if (millis() - last >= interval) { last = millis(); return true; }
    return false;
  }
};

Task blink{500}, sensor{50}, report{2000};

void loop() {
  if (blink.due())  toggleLed();
  if (sensor.due()) updateDistance();
  if (report.due()) sendTelemetry();
  handleBluetooth();
}

Mười dòng struct này là "hệ điều hành mini" đủ cho 90% robot học đường — trước khi cần đến FreeRTOS thật (bài FreeRTOS trên ESP32 ở mục Kiến thức).

Ca khó 1 - nút nhấn: chống rung không delay

Cách cổ điển delay(50) chống rung phím giết đa nhiệm. Bản non-blocking:

cpp
struct Button {
  int pin;
  bool stable = HIGH, lastRead = HIGH;
  unsigned long lastChange = 0;

  // trả true ĐÚNG MỘT LẦN tại khoảnh khắc nhấn xuống
  bool pressed() {
    bool now = digitalRead(pin);
    if (now != lastRead) { lastChange = millis(); lastRead = now; }
    if (millis() - lastChange > 30 && now != stable) {
      stable = now;
      if (stable == LOW) return true;   // INPUT_PULLUP: nhấn = LOW
    }
    return false;
  }
};

Button modeBtn{15};

void loop() {
  if (modeBtn.pressed()) nextMode();   // đổi chế độ ngay cả khi robot đang bận
  // ... các task khác
}

Điểm tinh tế: pressed() phát hiện sườn nhấn (một sự kiện) chứ không phải mức đang nhấn — nhấn giữ 3 giây vẫn chỉ đổi chế độ một lần.

Ca khó 2 - chuỗi hành động: "lùi 300ms rồi rẽ 400ms" không delay

Chuỗi thao tác theo thời gian là lý do người ta quay lại với delay. Lời giải đúng: máy trạng thái mini + mốc thời gian (họ hàng trực tiếp của bài Máy trạng thái ở mục Kiến thức):

cpp
enum AvoidPhase { NONE, BACKING, TURNING };
AvoidPhase phase = NONE;
unsigned long phaseStart = 0;

void startAvoid() { phase = BACKING; phaseStart = millis(); }

void updateAvoid() {
  switch (phase) {
    case BACKING:
      drive(-150, -150);
      if (millis() - phaseStart > 300) { phase = TURNING; phaseStart = millis(); }
      break;
    case TURNING:
      drive(150, -150);
      if (millis() - phaseStart > 400) { phase = NONE; }
      break;
    case NONE: break;
  }
}

void loop() {
  if (obstacleDetected() && phase == NONE) startAvoid();
  updateAvoid();
  if (phase == NONE) runLineFollowing();   // việc chính chỉ chạy khi không né
  statusLed();                              // đèn vẫn nháy XUYÊN SUỐT cú né
}

So với bản delay: cú né vẫn mất 700 ms, nhưng trong 700 ms đó robot vẫn nháy đèn, vẫn nghe được lệnh dừng khẩn — khác biệt giữa demo và sản phẩm.

Bẫy có thật - đọc trước khi dẫm

  • Tràn số sau 49.7 ngày: millis() là unsigned long, quay về 0 sau ~49.7 ngày. Mẫu millis() - last >= interval miễn nhiễm nhờ số học không dấu — nhưng biến thể sai millis() >= last + interval thì dính. Dùng đúng phép trừ, không so tổng
  • Lưu millis() vào int: tràn sau 32 giây trên Uno — robot "hóa điên đúng nửa phút" kinh điển; luôn unsigned long
  • Task nặng phá nhịp cả hội: một updateDistance() mất 30 ms (pulseIn timeout dài) làm mọi task khác trễ theo — đa nhiệm hợp tác chỉ mượt khi từng việc ngắn; đo thời gian vòng lặp theo kỹ thuật bài Debug bằng Serial Monitor
  • Reset mốc bằng `last = millis()` vs `last += interval`: cách đầu trôi nhịp nhẹ khi task trễ (thường vô hại); cách sau giữ nhịp tuyệt đối nhưng "bắn bù" dồn dập nếu bị nghẽn lâu — robot học đường dùng cách đầu cho lành

Câu hỏi thường gặp

Có phải xóa delay() khỏi mọi chỗ không?

Không cực đoan: delay ngắn trong setup (chờ cảm biến khởi động), delayMicroseconds vài µs cho xung TRIG — vô hại. Nguyên tắc: delay bị cấm trong loop() của robot phản ứng thời gian thực; ngoài vùng đó nó vẫn là công cụ hợp lệ.

millis() và micros() chọn cái nào?

millis() cho mọi việc cấp robot (nhịp 10 ms trở lên). micros() khi cần nhịp dưới 1 ms — vòng PID cân bằng 200 Hz trở lên, đo độ rộng xung. micros() tràn nhanh hơn (~71.6 phút) nhưng phép trừ không dấu vẫn xử lý đúng.

Mẫu này khác gì thư viện Scheduler/TaskScheduler?

Bản chất giống hệt — các thư viện đó đóng gói mẫu millis kèm tiện ích (bật tắt task, số lần chạy). Học sinh nên tự viết struct Task một lần để hiểu ruột, sau đó dùng thư viện hay tự viết đều được; hiểu ruột giúp debug được khi thư viện hành xử lạ.

Khi nào mẫu millis không còn đủ?

Ba dấu hiệu: có việc nặng không chia nhỏ được (xử lý ảnh, TLS handshake) làm nghẽn nhịp các việc khác; cần nhịp cứng tuyệt đối cho vòng điều khiển trong khi WiFi thỉnh thoảng chiếm CPU; muốn tận dụng nhân thứ hai của ESP32. Cả ba trỏ về cùng một cửa: FreeRTOS — bài tiếp theo trong mục Kiến thức.

Tài nguyên tải về

Tải bản PDF của bài viết để in giáo án hoặc gửi trước cho học viên.

Tải PDF
Chia sẻ bài viết