Das Titelbild dieses Blogbeitrags ist von Gerd Altmann von Pixabay
Wie hoch ist der Overhead, der durch den millis()-
Interrupt entsteht? Und können wir den vermeiden?
millis()
und micros()
sind die beiden Funktionen, die die Anzahl der Millisekunden und Mikrosekunden seit dem Start eines Arduino-Programms zurückgeben. Mit diesen Funktionen kann man Zeitintervalle zwischen zwei Ereignissen messen. Man muss aber etwas Vorsicht walten lassen, da die internen Zähler nach einer gewissen Zeit überlaufen. Da die Zeitzähler vorzeichenlose 32-Bit-Zahlen verwenden, geht micros()
nach etwa 70 Minuten von UINT32_MAX
auf null, millis()
tut dies nach 49 Tagen. Es ist aber trotzdem möglich, die Länge von Zeitintervallen zu bestimmen, wenn sie nicht zu lang sind.
In einer idealen Welt würden diese Zeitnahmefunktionen keine Kosten verursachen. In der realen Welt tun sie dies jedoch. Die Funktionsaufrufe kosten einige Zeit und darüber hinaus verursacht die Zeitmessung im Hintergrund, die von der TIM0_OVF
Interrupt Service Routine (ISR) durchgeführt wird, einige Kosten.
Wie viel Zeit benötigt die Interrupt-Routine, der die Millisekunden zählt? Da kein anderer Code ausgeführt werden kann, während eine Interrupt-Routine aktiv ist, reagieren zeitkritische Funktionen (z. B. Bit-Banging-Funktionen) sehr empfindlich darauf. Wenn man den Quellcode des Arduino-Cores studiert, findet man in der wiring.c
den folgenden Code:
#if defined(TIM0_OVF_vect) ISR(TIM0_OVF_vect) #else ISR(TIMER0_OVF_vect) #endif { // copy these to local variables so they can be stored in registers // (volatile variables must be read from memory on every access) unsigned long m = timer0_millis; unsigned char f = timer0_fract; m += MILLIS_INC; f += FRACT_INC; if (f >= FRACT_MAX) { f -= FRACT_MAX; m += 1; } timer0_fract = f; timer0_millis = m; timer0_overflow_count++; }
Man kann nun versuchen, die Laufzeit dieser ISR zu bestimmen, indem man entweder den Assembler-Code analysiert und Befehlszyklen zählt oder man misst das Timing empirisch. Ich habe beides gemacht.
Zuerst habe ich zwei Port-Manipulationsanweisungen (wie PORTC |= 0x01
und PORTC &= ~0x01
) am Anfang und am Ende der Routine eingefügt und die Zeit mit einem Logikanalysator gemessen. Das Ergebnis war, dass man Blips der Länge 3,13 μs sehen konnte. Dann suchte ich mit dem Triggermechanismus nach längeren Intervallen und stellte fest, dass die Routine manchmal 3,44 μs benötigt. Aber es gibt keine längeren Ausführungszeiten. Das sieht durchaus plausibel aus, denn manchmal wird der if-Zweig ausgeführt, was etwas mehr Zeit braucht.
Ist das die gesamte Zeit, die verbraucht wird? Definitiv nicht! Wenn man sich den generierten Assembler-Code ansieht, ist da Code, der vor dem ersten Port-Manipulationsbefehl ausgeführt wird und Code nach dem letzten Port-Manipulationsbefehl. Es ist hauptsächlich eine Reihe von Push-Anweisungen
vorher (um Register vorübergehend zu speichern) und eine Reihe von Pop-Anweisungen
danach. Bevor wir darauf eingehen, lassen Sie uns die Zyklen zwischen den beiden Portmanipulationsanweisungen zählen, die ich eingefügt hatte.
0000067a <__vector_16>: __vector_16(): wiring.c:47 { 67a: 1f 92 push r1 [2] 67c: 0f 92 push r0 [2] 67e: 0f b6 in r0, 0x3f [1] 680: 0f 92 push r0 [2] 682: 11 24 eor r1, r1 [1] 684: 2f 93 push r18 [2] 686: 3f 93 push r19 [2] 688: 8f 93 push r24 [2] 68a: 9f 93 push r25 [2] 68c: af 93 push r26 [2] 68e: bf 93 push r27 [2] wiring.c:48 PORTC |= 0x01; 690: 40 9a sbi 0x08, 0 [2] wiring.c:51 unsigned long m = timer0_millis; 692: 80 91 1e 01 lds r24, 0x011E ; <timer0_millis> [2] 696: 90 91 1f 01 lds r25, 0x011F ; <timer0_millis+0x1> [2] 69a: a0 91 20 01 lds r26, 0x0120 ; <timer0_millis+0x2> [2] 69e: b0 91 21 01 lds r27, 0x0121 ; <timer0_millis+0x3> [2] /wiring.c:52 unsigned char f = timer0_fract; 6a2: 30 91 14 01 lds r19, 0x0114 ; <timer0_fract> [2] wiring.c:55 f += FRACT_INC; 6a6: 23 e0 ldi r18, 0x03 ; 3 [1] 6a8: 23 0f add r18, r19 [1] wiring.c:56 if (f >= FRACT_MAX) { 6aa: 2d 37 cpi r18, 0x7D ; 125 [1] 6ac: 60 f5 brcc .+88 ; 0x706 <__vector_16+0x8c> [wc: 2] wiring.c:54 m += MILLIS_INC; 6ae: 01 96 adiw r24, 0x01 ; 1 6b0: a1 1d adc r26, r1 6b2: b1 1d adc r27, r1 wiring.c:61 timer0_fract = f; 6b4: 20 93 14 01 sts 0x0114, r18 ; <timer0_fract> [2] wiring.c:62 timer0_millis = m; 6b8: 80 93 1e 01 sts 0x011E, r24 ; <timer0_millis> [2] 6bc: 90 93 1f 01 sts 0x011F, r25 ; <timer0_millis+0x1> [2] 6c0: a0 93 20 01 sts 0x0120, r26 ; <timer0_millis+0x2> [2] 6c4: b0 93 21 01 sts 0x0121, r27 ; <timer0_millis+0x3> [2] wiring.c:63 timer0_overflow_count++; 6c8: 80 91 15 01 lds r24, 0x0115 ; <timer0_overflow_count> [2] 6cc: 90 91 16 01 lds r25, 0x0116 ; <timer0_overflow_count+0x1> [2] 6d0: a0 91 17 01 lds r26, 0x0117 ; <timer0_overflow_count+0x2> [2] 6d4: b0 91 18 01 lds r27, 0x0118 ; <timer0_overflow_count+0x3> [2] 6d8: 01 96 adiw r24, 0x01 ; 1 [2] 6da: a1 1d adc r26, r1 [1] 6dc: b1 1d adc r27, r1 [1] 6de: 80 93 15 01 sts 0x0115, r24 ; <timer0_overflow_count> [2] 6e2: 90 93 16 01 sts 0x0116, r25 ; <timer0_overflow_count+0x1> [2] 6e6: a0 93 17 01 sts 0x0117, r26 ; <timer0_overflow_count+0x2> [2] 6ea: b0 93 18 01 sts 0x0118, r27 ; <timer0_overflow_count+0x3> [2] wiring.c:64 PORTC &= ~0x01; 6ee: 40 98 cbi 0x08, 0 [2] wiring.c:66 } 6f0: bf 91 pop r27 [2] 6f2: af 91 pop r26 [2] 6f4: 9f 91 pop r25 [2] 6f6: 8f 91 pop r24 [2] 6f8: 3f 91 pop r19 [2] 6fa: 2f 91 pop r18 [2] 6fc: 0f 90 pop r0 [2] 6fe: 0f be out 0x3f, r0 [1] 700: 0f 90 pop r0 [2] 702: 1f 90 pop r1 [2] 704: 18 95 reti [4] wiring.c:57 f -= FRACT_MAX; 706: 26 e8 ldi r18, 0x86 ; 134 [1] 708: 23 0f add r18, r19 [1] wiring.c:58 m += 1; 70a: 02 96 adiw r24, 0x02 ; 2 [2] 70c: a1 1d adc r26, r1 [1] 70e: b1 1d adc r27, r1 [1] 710: d1 cf rjmp .-94 ; 0x6b4 <__vector_16+0x3a> [2]
Im schlimmsten Fall (wenn der if-Zweig ausgeführt wird) erhält man 53 Ausführungszyklen. Man muss außerdem 2 Zyklen der cbi
-Anweisung hinzufügen (Löschen des Portbits), wodurch es 55 werden. Da wir 16 Zyklen pro μs haben (bei einem 16 MHz MCU-Takt), führt dies zu 55/16 = 3,4375 μs, was nahe genug an meiner Messung ist. Man sollte im Blick behalten, dass später zwei dieser Zyklen subtrahiert werden können, da sie durch die Messung verursacht werden.
Was ist also mit dem Prolog und dem Epilog? Addiert man diese Zyklen zusammen, führt dies zu weiteren 43 Zyklen. Und das ist immer noch nicht alles. Das Datenblatt für den ATmega328 gibt unter der Überschrift Interrupt Response Time an:
The interrupt execution response for all the enabled AVR interrupts is four clock cycles minimum. After four clock cycles the program vector address for the actual interrupt handling routine is executed. During this four clock cycle period, the Program Counter is pushed onto the Stack. The vector is normally a jump to the interrupt routine, and this jump takes three clock cycles. If an interrupt occurs during execution of a multi-cycle instruction, this instruction is completed before the interrupt is served.
ATmega48A/48PA/88A/88PA/168A/168PA/328/328PA Datenblatt
Mit anderen Worten, wir haben weitere 4 + 3-Zyklen und falls eine 4-Zyklus-Anweisung ausgeführt wird, wenn der Interrupt auftritt, muss man möglicherweise weitere 3 Zyklen warten, bevor der Interrupt bedient wird. Mit anderen Worten, wir haben im schlimmsten Fall 10 Zyklen, bevor die Interrupt-Service-Routine ausgeführt wird. Addiert man das alles zusammen, ergibt sich 53 + 43 + 10 = 106 Zyklen, was bei Verwendung eines 16-MHz-Taktes 6,625 μs entspricht.
Dies ist eine sehr kleine Zeitspanne im Vergleich zur Geschichte der Menschheit. Aber es ist ein sehr großer Zeitabschnitt, wenn zeitkritische Operationen ausgeführt werden. Wenn wir beispielsweise Daten seriell über eine UART-Leitung mit 115200 bps empfangen möchten, benötigt jedes Bit 8,68 μs. Wenn der Millis-Interrupt läuft, kann da durchaus ein Bit verloren gehen.
Was könnte also in diesem Fall eine Lösung sein? Wenn das Timing sehr eng ist, ist es immer eine Option, den Millis-Interrupt zu deaktivieren: TIMSK0 = 0
. Wenn man dies tut, können millis()
und micros()
sowie die assoziierten Funktionen delay()
und delayMicroseconds()
nicht mehr verwendet werden.
Wenn man dennoch eine Zeitmessung durchführen will und Genauigkeit keine Rolle spielt, kann man den Watchdog-Timer-Interrupt zur Zeitmessung verwenden. Natürlich muss man auch hier eine ISR schreiben, aber diese kann sehr kurz sein, indem man einfache eine Variable inkrementiert. Wenn Genauigkeit von Belang ist, kann man stattdessen eine Hardwarelösung (z. B. einen RTC) verwenden.
Wenn man trotz deaktiviertem Millis-Interrupt eine delay-Routine verwenden möchte, kann man die in util/delay.h
Funktionen nutzen. _delay_ms
und _delay_us
ähneln delay
und delayMicroseconds
. Sie erwarten jedoch Float-Argumente und die Argumente müssen Konstanten sein.
Schreibe einen Kommentar