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!