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.

Views: 24