Område 3 · lektion 13
Tid utan delay
I
guiden om
digital ingång stod att
delay(20) inte var den slutgiltiga lösningen och att den
riktiga hör hemma här. Det här är den. Den handlar om ett enda uttryck –
millis() - forra – som är rätt av ett skäl de flesta
handledningar på nätet inte förklarar, och några av dem får fel.
Mål
- Du kan förklara vad
delay()faktiskt stoppar, och känna igen symtomen på ett bygge som blockerar sig självt. - Du kan skriva mönstret med tidsstämpel och intervall, och läsa knappar i samma loop som något annat pågår.
- Du kan räkna ut när
millis()viker runt, och förklara varför subtraktionen överlever det medan additionen inte gör det. - Du kan ersätta avstudsningen från förra området med en variant som inte stoppar resten av bygget.
Genomgång
Vad delay() faktiskt gör
delay(500) ser ut som en paus. Det är det inte. Det är en
tom slinga: kortet står och kollar sin egen klocka om och om igen tills
500 millisekunder har gått, och gör ingenting annat under tiden. Ingen
digitalRead() körs. Ingen utgång ändras. Ingen rad i din
loop() nås.
Hur mycket är det som inte händer? Kortet går på 16 MHz, alltså 16
miljoner klockcykler i sekunden. Ett varv i en enkel loop()
som läser en pinne och sätter en utgång tar några mikrosekunder – räkna
med 5 µs. Under en enda delay(500) hade loopen alltså hunnit
varvas
500 ms / 5 µs = 0,5 s / 0,000005 s = 100 000 varv
Hundratusen tillfällen att läsa en knapp, kastade bort. Och det är just knappen som drabbas: trycker du i ungefär en tiondels sekund, och koden bara tittar en gång per halvsekund, är chansen att den ser trycket
100 ms / 500 ms = 0,2 = 20 %
Fyra tryck av fem försvinner. Det är inte ett kontaktfel, inte en trasig
knapp och inte kontaktstuds. Det är delay().
- Varje streck är ett loopvarv, alltså ett tillfälle då koden hinner läsa knappen.
- Det skuggade fältet på den övre axeln är tiden inuti
delay(). Där finns inga streck, eftersom inga varv körs. - Trycket i den övre axeln hamnar mitt i fältet och är borta när kortet vaknar. Knappen var nere och uppe igen medan koden stod stilla.
- Den nedre axeln har streck hela vägen. Loopen går vidare, tiden mäts i stället för att väntas ut, och samma tryck hamnar på ett varv som faktiskt körs.
Kortet är inte helt dött under delay()
Avbrotten fortsätter gå i bakgrunden. Det är därför millisekundräknaren
tickar vidare även inuti en delay(), och det är därför tecken
som kommer in på serieporten oftast inte tappas bort – de samlas i en
mottagningsbuffert som rymmer 64 byte, och en tillräckligt lång
delay() hinner fylla den. Men din kod – allt
du skrivit i loop() – står stilla. Det är den skillnaden som
spelar roll i ett bygge.
millis(): en räknare som aldrig stannar
millis() lämnar antalet millisekunder sedan kortet
startade. Den räknar av sig själv, i bakgrunden, oavsett vad din kod
håller på med. Anropet tar mikrosekunder och stoppar ingenting.
unsigned long nu = millis();
Serial.println(nu); // t.ex. 12480, alltså 12,48 sekunder sedan start
Typen är unsigned long och ingenting annat. Det är fyra
byte, alltså 32 bitar, och den kan inte vara negativ. Både variabeln du
lägger värdet i och variabeln du sparar en tidpunkt i måste ha den typen –
hela resten av den här guiden vilar på det.
Mönstret: en tidsstämpel och ett intervall
Idén är att inte vänta utan att komma ihåg när det senast hände och fråga varje varv om det gått tillräckligt länge sedan dess.
const int LAMPA_PIN = 8;
const unsigned long INTERVALL = 500; // ms mellan varje omslag
unsigned long forra = 0; // när det senast hände
bool lampanLyser = false;
void loop() {
unsigned long nu = millis();
if (nu - forra >= INTERVALL) {
forra = nu; // stämpla om, direkt
lampanLyser = !lampanLyser;
digitalWrite(LAMPA_PIN, lampanLyser);
}
// Här nedanför får allt annat plats, och det körs varje varv.
}
Tre rader att lägga på minnet. forra står
utanför funktionerna, annars nollställs den varje varv
och intervallet löper aldrig ut. forra = nu; sker
först i blocket, så att nästa intervall räknas från rätt
punkt. Och villkoret är en subtraktion, inte en
addition – vilket är nästa avsnitt.
Räknaren viker runt efter 49,7 dygn
En unsigned long är 32 bitar. Med samma räkning som i den
första guiden i området rymmer den
2³² = 4 294 967 296 olika värden, alltså 0 … 4 294 967 295
Värdena är millisekunder. Räkna om till dygn:
4 294 967 296 ms / 1000 = 4 294 967,296 s
4 294 967,296 s / 60 = 71 582,79 min
71 582,79 min / 60 = 1 193,05 h
1 193,05 h / 24 = 49,71 dygn
Efter ungefär 49 dygn och 17 timmar är räknaren full. Nästa millisekund blir den inte 4 294 967 296 – det talet ryms inte – utan 0, och så börjar den om. Det kallas att den viker runt.
Ett bygge som står påslaget i en och en halv månad är ovanligt i en skolsal och fullständigt normalt i en maskin. Och felet är inte att räknaren viker runt. Felet är att den ena av de två varianterna nedan tål det och den andra inte gör det.
Varför subtraktionen är säker och additionen inte är det
De två varianterna ser lika rimliga ut:
if (nu - forra >= INTERVALL) // säker
if (nu >= forra + INTERVALL) // trasig
Grunden till skillnaden är en regel i C++ som är lätt att missa: räkning med heltal utan tecken viker runt med flit. Det är inte ett fel, inte något odefinierat – språket lovar att varje resultat räknas modulo 2³², alltså att det som blir för stort räknas om från början. Subtraktionen som skulle bli negativ blir i stället ett stort positivt tal. Och det talet är precis rätt.
Det syns bäst i en mindre räknare. Låt oss låtsas att räknaren bara är 8
bitar, alltså 2⁸ = 256 värden, 0 till 255. Sätt
INTERVALL till 20 och antag att sist det hände var vid 250 –
alltså strax innan varvet tar slut.
| Räknaren nu | Verklig tid sedan 250 | nu − 250 modulo 256 |
Säker: ≥ 20 |
Trasig: nu ≥ 250 + 20 |
|---|---|---|---|---|
| 252 | 2 | 2 | nej, rätt | 252 ≥ 14 → ja, 18 steg för tidigt |
| 254 | 4 | 4 | nej, rätt | 254 ≥ 14 → ja, för tidigt igen |
| 0 | 6 | 6 | nej, rätt | 0 ≥ 14 → nej |
| 14 | 20 | 20 | ja, exakt rätt | 14 ≥ 14 → ja |
Två rader i tabellen behöver läsas långsamt.
Kolumnen med subtraktionen. När räknaren står på 14 har
den passerat 255 och börjat om. Räknat rakt av är 14 − 250
lika med −236, ett negativt tal som inte finns i en
unsigned-typ. Modulo 256 blir det i stället
14 − 250 + 256 = 20
vilket är exakt den verkliga tiden: från 250 upp till 255 är fem steg, över kanten till 0 är ett steg, och från 0 till 14 är fjorton. 5 + 1 + 14 = 20. Rundningen i räknaren och rundningen i subtraktionen tar ut varandra. Det är hela hemligheten, och det gäller lika mycket för 2³² som för 256.
Kolumnen med additionen. 250 + 20 = 270,
som inte heller ryms. Modulo 256 blir det
270 − 256 = 14
Målvärdet har alltså vikt runt och blivit ett litet tal – men räknaren
har inte vikt runt än, den står fortfarande på 252. Och
jämförelsen ≥ viker inte runt: den ställer två tal mot
varandra rakt av. 252 är större än 14, så villkoret slår till direkt, 18
steg för tidigt. Sedan stämplas forra om till 252, nytt mål
blir 272 modulo 256 = 16, och 253 är större än det också. Villkoret är
sant varje varv ända tills räknaren vikt runt.
Översatt till kortet: den trasiga varianten fungerar felfritt i 49 dygn och 17 timmar, spårar sedan ur i ungefär en halv sekund – ett blink som går i loopens hastighet i stället för i halvsekunder – och fungerar därefter felfritt i 49 dygn till. Det är därför felet överlever i så många färdiga projekt: nästan ingen har sitt bygge påslaget så länge, och den som har det tror att det var ett strömglapp.
Regeln, i en mening
Flytta aldrig över intervallet till andra sidan
jämförelsen. Skriv nu - forra >= INTERVALL och
låt både nu och forra vara
unsigned long. Då är differensen alltid den verkliga
tidsskillnaden, oavsett var i sitt varv räknaren råkar befinna sig.
Blinka och läsa knapp samtidigt
Här är hela poängen med området, i en sketch. Lampan blinkar i sin egen
takt. Knappen läses varje varv, räknas med flankdetektering, och avstudsas
med tid i stället för med delay().
const int KNAPP_PIN = 2;
const int LAMPA_PIN = 8;
const unsigned long BLINKINTERVALL = 500; // ms mellan omslagen
const unsigned long STUDSTID = 20; // ms som studsen får hålla på
unsigned long forraBlinket = 0;
unsigned long forraTrycket = 0;
bool lampanLyser = false;
int forraLaget = HIGH;
int antalTryck = 0;
void setup() {
pinMode(KNAPP_PIN, INPUT_PULLUP);
pinMode(LAMPA_PIN, OUTPUT);
Serial.begin(9600);
}
void loop() {
unsigned long nu = millis();
// 1. Blinket sköter sig självt och stoppar ingenting.
if (nu - forraBlinket >= BLINKINTERVALL) {
forraBlinket = nu;
lampanLyser = !lampanLyser;
digitalWrite(LAMPA_PIN, lampanLyser);
}
// 2. Knappen läses varje varv, alltså tusentals gånger i sekunden.
int laget = digitalRead(KNAPP_PIN); // LOW betyder nedtryckt
if (forraLaget == HIGH && laget == LOW
&& nu - forraTrycket >= STUDSTID) {
forraTrycket = nu;
antalTryck = antalTryck + 1;
Serial.println(antalTryck);
}
forraLaget = laget;
}
Jämför med avstudsningen
i förra området. Där stod
delay(20) mitt i flankdetekteringen och stoppade allt i tjugo
millisekunder. Här är samma tjugo millisekunder ett villkor: ett
nytt tryck godtas inte förrän det gått minst så länge sedan det förra.
Studsarna sållas bort på samma sätt, men ingenting står stilla under
tiden, och blinket märker inte att en knapp trycktes.
När delay() ändå duger
Det här är inte ett förbud. delay() är rätt verktyg när
ingenting annat behöver hända:
- I
setup(), när något ska hinna starta innan resten börjar. - I ett litet testprogram som bara ska visa att en komponent lever.
- När pausen är kortare än en millisekund och mätbart oviktig.
Regeln är enkel: så fort loop() har mer än en sak att göra
hör delay() inte hemma där. I bygge 2 har loop()
minst tre saker att göra, och kravlistan säger därför rakt ut att den ska
vara fri från delay().
I industrin och i vardagen
I industrin: en PLC arbetar i en fast rundgång som heter cykel eller scan. Den läser alla ingångar till ett minne, kör igenom hela programmet en gång, skriver alla utgångar, och börjar om. En typisk cykeltid ligger runt 5 ms, alltså
1 s / 5 ms = 1 / 0,005 = 200 cykler i sekunden
I det arbetssättet finns inget delay() att skriva. Vill man
att en ventil ska stå öppen i tre sekunder startar man en
timer och låter cykeln fortsätta – exakt samma idé som
tidsstämpeln ovan, med samma skäl. En blockerande väntan hade fryst alla
maskinens andra utgångar samtidigt, inklusive dem som håller något
säkert. Styrenheten har dessutom en watchdog som
övervakar cykeltiden: tar en cykel längre än den inställda gränsen antar
den att programmet hängt sig och går till ett säkert läge. En blockerande
väntan skulle alltså inte bara vara olämplig – den skulle fälla
maskinen.
I vardagen: blinkersen i en bil ska enligt reglerna blinka omkring 90 gånger i minuten. Det ger en blinkcykel på
60 s / 90 = 0,67 s per cykel, alltså cirka 333 ms tänd och 333 ms släckt
Under de 333 millisekunderna fortsätter bilen att göra allt annat: läsa bromspedalen, tända bromsljuset, läsa den andra blinkersspaken, hålla igång instrumenten. Styrenheten som sköter karossfunktionerna använder samma sorts tidsstämpel som du just skrivit – den kommer ihåg när lampan senast slog om och jämför mot nu. Hade den i stället väntat ut tiden hade bromsljuset släpat en tredjedels sekund efter foten, varje gång blinkersen var på. Det är samma fel som ditt bygge gör när knappen missas, med den skillnaden att ingen skriver om ditt bygge i en olycksrapport.
Prova själv
- Skriv ett program som skriver ut
millis()en gång i sekunden med tidsstämpelmönstret. Kontrollera i seriemonitorn att talen ökar med ungefär 1 000 varje rad. - Bygg det blockerande felet med flit: en lampa som blinkar med
delay(500)och en knapp som ska räknas. Tryck tio korta tryck och se hur många som räknas. Anteckna siffran. - Skriv om samma bygge med
millis()enligt exemplet ovan. Tryck tio korta tryck igen. Jämför med din anteckning. - Lägg till en andra lampa som blinkar med ett annat
intervall, till exempel 130 ms. Den behöver en egen
unsigned longför sin tidsstämpel. Två blink och en knapp, i samma loop, utan att något stör något annat. - Byt typen på en av tidsstämplarna från
unsigned longtillintoch låt bygget gå i en dryg halvminut. Vid 32 767 ms tar enintslut – notera vad som händer, och byt sedan tillbaka. - Skriv om villkoret till den trasiga formen
nu >= forra + INTERVALL. Bygget fungerar. Skriv en mening om varför du ändå inte kan upptäcka felet genom att prova, och hur länge du hade behövt låta kortet stå på.
Kontrollfrågor
Efter hur lång tid viker millis() runt, och vad
händer då?
Efter 2³² millisekunder, alltså 4 294 967 296 ms. Det blir
4 294 967,296 s, som är 71 582,79 min, som är 1 193,05 h, som är
49,71 dygn – ungefär 49 dygn och 17 timmar. Då börjar räknaren om på 0.
Skriver du nu - forra >= INTERVALL märks det inte alls,
eftersom subtraktionen ger rätt svar även över kanten.
Varför fungerar nu - forra när nu är
mindre än forra?
Därför att räkning med heltal utan tecken är definierad modulo 2³² i C++. Ett resultat som skulle blivit negativt räknas om från toppen och blir ett stort positivt tal – och just det talet är den verkliga tidsskillnaden, eftersom räknaren själv har gjort samma hopp åt andra hållet. De två rundningarna tar ut varandra.
Vad är egentligen fel med
if (nu >= forra + INTERVALL)?
Summan forra + INTERVALL viker runt och blir ett litet
tal, medan nu ännu inte vikt runt och alltså är ett mycket
stort tal. Jämförelsen >= jobbar inte modulo någonting –
den ställer talen mot varandra rakt av – så villkoret blir sant direkt
och sedan sant varje varv, ända tills räknaren också vikt runt.
Följden är ungefär en halv sekunds galenskap vart femtionde dygn.
Du har deklarerat int forra = 0; i stället för
unsigned long. När märks det?
Efter drygt en halv minut. En int på ett Uno-kort tar
slut vid 32 767, och 32 767 ms är 32,8 sekunder. Efter det innehåller
forra ett stympat och ofta negativt värde, och
differensen blir ett meningslöst tal. Symtomet är ett bygge som
fungerar under provkörningen på lektionen och inte gör det hemma, där
det får stå på längre.
Räknar millis() vidare inuti en
delay()?
Ja. Räknaren drivs av ett avbrott som går oavsett vad din kod gör, så
tiden är korrekt när du kommer ut ur pausen. Det som står stilla är din
loop() – ingen pinne läses, ingen utgång ändras. Det är
därför problemet inte är tidmätningen utan allt annat som inte
blir gjort.
Varför ska forra = nu; stå först i blocket och inte
sist?
Det spelar sällan roll i praktiken, men det är lättare att läsa och
omöjligt att glömma. Står omstämplingen efter en lång rad kod är risken
att en tidig return eller ett nytt villkor hoppar över den,
och då blir intervallet aldrig klart – villkoret är i stället sant varje
varv, för alltid.
Vanliga fel
- Tidsstämpeln deklareras inuti
loop(). Den sätts till 0 varje varv, differensen blir alltid dagensmillis(), och villkoret slår till varje varv. Symtomet är en lampa som ser konstant tänd ut fast koden säger 500 ms. - Fel typ på tidsstämpeln.
inttar slut efter 32,8 sekunder,longkan bli negativ. Det ska ståunsigned longpå både stämpeln och den avlästa tiden. - Intervallet flyttas till högersidan.
nu >= forra + INTERVALLfungerar i alla tester du hinner göra och går sönder efter 49,7 dygn. Ett fel du inte kan prova dig fram till måste läsas fram. delay()ligger kvar någonstans iloop(). Ofta i avstudsningen från förra området, eller i enSerial.println()-felsökning som aldrig togs bort. Sök efter ordet innan du felsöker vidare.- Två saker delar på en tidsstämpel. Blinket och
avstudsningen skriver till samma
forra, och båda beter sig oförutsägbart. Varje sak som har sin egen takt behöver sin egen variabel. - Servot skrivs varje varv i stället för vid ändring.
Nu när loopen går tusentals varv i sekunden anropas
write()lika ofta. Det fungerar, men servot surrar och drar ström i onödan. Skriv bara när värdet faktiskt bytt. - Omstämplingen glöms helt. Utan
forra = nu;är villkoret sant från första gången och sedan för alltid.
Vidare
Kapitlet Tid och millis() i
språkreferensen är den här guidens uppslagsdel, med mönstret, typerna och
rundningen samlade på ett ställe.
Typer och variabler förklarar
varför en int tar slut vid 32 767 och en
unsigned long vid drygt fyra miljarder.
Nästa guide är Tillstånd. Nu när bygget kan mäta tid utan att stanna behöver det också minnas var i ett förlopp det befinner sig – annars vet det inte vad tiden som gått betyder.