W świecie robotyki i elektroniki, gdzie mikrokontrolery jak Arduino muszą obsłużyć wiele zadań jednocześnie – od odczytu czujników po sterowanie silnikami i komunikację – funkcja millis() staje się kluczowym narzędziem. Zamiast blokować procesor za pomocą delay(), millis() umożliwia wielozadaniowość, pozwalając na wykonywanie operacji w regularnych odstępach czasu bez przerywania głównej pętli programu.

Dlaczego delay() psuje twój kod?

delay() zatrzymuje cały program na określony czas, więc w trakcie opóźnienia mikrokontroler nie wykona żadnych innych zadań. Jeśli dioda miga co sekundę za pomocą delay(1000), mikrokontroler stoi bezczynnie – nie odczyta przycisku, nie zaktualizuje wyświetlacza ani nie obsłuży komunikacji. W projektach robotycznych oznacza to utratę reakcji na otoczenie i ryzyko awarii.

millis() działa inaczej: zwraca liczbę milisekund upłynionych od startu Arduino, korzystając z wbudowanego timera sprzętowego. Procesor nie czeka – po prostu sprawdza, czy minął odpowiedni czas, i kontynuuje pracę. To fundament kodu nieblokującego, idealny dla robotyki, gdzie liczy się każda milisekunda.

Jak działa millis() w praktyce?

Zacznijmy od prostego przykładu: miganie wbudowaną diodą LED bez blokowania:

Podstawowy kod z millis()

#define LED_BUILTIN 13 // Wbudowana dioda na Arduino Uno

unsigned long poprzedniCzas = 0;
const unsigned long interwal = 1000; // 1 sekunda

void setup() {
pinMode(LED_BUILTIN, OUTPUT);
}

void loop() {
unsigned long aktualnyCzas = millis();

if (aktualnyCzas - poprzedniCzas >= interwal) {
poprzedniCzas = aktualnyCzas; // Reset timera
digitalWrite(LED_BUILTIN, !digitalRead(LED_BUILTIN)); // Odwróć stan diody
}

// Tutaj możesz dodać inne zadania!
}

W każdej iteracji loop() sprawdzamy, czy upłynął interwał. Jeśli tak, zmieniamy stan diody i resetujemy licznik. Reszta programu działa płynnie i responsywnie.

Wielozadaniowość – dwie diody, różne tempa

Wyobraź sobie robota z dwoma sygnalizatorami: jedna dioda miga wolno (co 700 ms), druga szybko (co 150 ms). Z delay() to niemożliwe – jedna blokowałaby drugą. Z millis() – proste:

#define LED1 13
#define LED2 12

#define LED1_DELAY 700
#define LED2_DELAY 150

unsigned long nextChange1 = 0;
unsigned long nextChange2 = 0;

int stanLED1 = LOW;
int stanLED2 = LOW;

void setup() {
pinMode(LED1, OUTPUT);
pinMode(LED2, OUTPUT);
nextChange1 = millis() + LED1_DELAY;
nextChange2 = millis() + LED2_DELAY;
}

void loop() {
unsigned long teraz = millis();

if (teraz >= nextChange1) {
stanLED1 = !stanLED1;
digitalWrite(LED1, stanLED1);
nextChange1 = teraz + LED1_DELAY;
}

if (teraz >= nextChange2) {
stanLED2 = !stanLED2;
digitalWrite(LED2, stanLED2);
nextChange2 = teraz + LED2_DELAY;
}

// Odczyt czujnika, sterowanie silnikiem – wszystko działa!
}

Każdy timer jest niezależny, a skalowalność pozwala dodać dziesiątki zadań bez blokowania programu.

Problem z przepełnieniem licznika

millis() zwraca unsigned long, który po ok. 49 dniach (2^32 ms) przepełnia się i wraca do 0. Bez poprawnych porównań timery mogą się „rozsynchronizować”.

Rozwiązanie – używaj arytmetyki modularnej i porównuj różnice czasu. Dla podejścia z nextChange zabezpieczenie wygląda tak:

if ((long)(teraz - nextChange1) >= 0) {
// Zabezpieczenie działa również po przepełnieniu
// Zmiana stanu
}

Warunek w postaci różnicy czasu działa poprawnie także po przepełnieniu licznika.

Zaawansowane zastosowania w robotyce i elektronice

Debouncing przycisków

W robotach przyciski generują drgania styków (bounce) trwające 5–50 ms. Zamiast delay(), użyj millis() do filtrowania:

const int przycisk = 2;
unsigned long ostatniDebounce = 0;
const unsigned long debounceDelay = 50;

int ostatniStan = HIGH;
int stanPrzycisku = HIGH;

void loop() {
int odczyt = digitalRead(przycisk);

if (odczyt != ostatniStan) {
ostatniDebounce = millis();
}

if (millis() - ostatniDebounce > debounceDelay) {
if (odczyt != stanPrzycisku) {
stanPrzycisku = odczyt;
if (stanPrzycisku == LOW) {
// Akcja po naciśnięciu!
}
}
}

ostatniStan = odczyt;
}

Takie podejście zapewnia responsywną, stabilną obsługę w czasie rzeczywistym.

Sterowanie silnikami i czujnikami

W robocie mobilnym harmonogram może wyglądać tak:

  • odczyt czujnika ultradźwiękowego HC-SR04 co 100 ms,
  • aktualizacja PWM silnika co 20 ms,
  • miganie diodą status co 500 ms.

Wszystko bez blokad – procesor w tym czasie może też obsługiwać np. Bluetooth.

micros() dla precyzji

Dla zadań wymagających mikrosekund (np. szybkie sterowanie serwem) użyj micros() – działa analogicznie do millis(), ale w µs, z przepełnieniem po ~70 minutach. Wybieraj micros() tam, gdzie liczy się submilisekundowa dokładność.

Porównanie delay() i millis()

Najważniejsze różnice przedstawia tabela:

Aspekt delay() millis()
Blokowanie Tak, zatrzymuje cały program Nie, kod nieblokujący
Wielozadaniowość Niemożliwa Pełna, wiele niezależnych timerów
Precyzja Dokładna, ale marnuje zasoby CPU Wysoka, wykorzystuje timer sprzętowy
Przepełnienie Nie dotyczy Obsługiwane arytmetyką bez znaku
Zastosowania Proste testy Robotyka, IoT, złożone projekty

millis() wygrywa w 99% przypadków.

Najczęstsze błędy i wskazówki

Zanim zaczniesz, pamiętaj o kilku praktycznych zasadach:

  • Nie resetuj poprzedniCzas na 0 – zawsze używaj poprzedniCzas = aktualnyCzas;
  • Używaj const unsigned long dla interwałów – zwiększa czytelność i zapobiega błędom arytmetycznym;
  • Testuj na ESP32 – posiada FreeRTOS i zadania, a millis() działa identycznie;
  • Biblioteki – przy większych projektach rozważ Timer lub TaskScheduler, które bazują na millis().

Podsumowanie korzyści dla robotyków

W projektach jak robot-sumo czy inteligentny dom, millis() pozwala na płynną wielozadaniowość: silniki jadą, sensory skanują, LED-y sygnalizują – bez opóźnień. To krok od prostych szkiców do profesjonalnego oprogramowania wbudowanego. Ćwicz na płytkach Arduino Uno, Nano czy ESP32 – efekty zaskoczą!

Eksperymentuj, buduj i dziel się projektami w komentarzach!