AVR-Interrupt-Serviceroutine wird nicht so schnell wie erwartet ausgeführt (Befehlsaufwand?)

8

Ich entwickle einen kleinen Logikanalysator mit 7 Eingängen. Mein Zielgerät ist ein ATmega168mit einer Taktrate von 20 MHz. Um logische Änderungen zu erkennen, verwende ich Pinwechsel-Interrupts. Jetzt versuche ich herauszufinden, mit welcher niedrigsten Abtastrate ich diese Pin-Änderungen feststellen kann. Ich habe einen Wert von mindestens 5,6 µs (178,5 kHz) bestimmt. Jedes Signal unterhalb dieser Rate kann ich nicht richtig erfassen.

Mein Code ist in C (avr-gcc) geschrieben. Meine Routine sieht aus wie:

ISR()
{
    pinc = PINC; // char
    timestamp_ll = TCNT1L; // char
    timestamp_lh = TCNT1H; // char
    timestamp_h = timerh; // 2 byte integer
    stack_counter++;
}

Meine erfasste Signaländerung befindet sich unter pinc. Um es zu lokalisieren, habe ich einen 4 Byte langen Zeitstempelwert.

Im Datenblatt habe ich gelesen, dass die Interrupt-Serviceroutine 5 Takte benötigt, um einzuspringen, und 5 Takte, um zur Hauptprozedur zurückzukehren. Ich gehe davon aus, dass jeder Befehl in meinem ISR()1 Takt benötigt, um ausgeführt zu werden. Insgesamt sollte es also einen Overhead von 5 + 5 + 5 = 15Uhren geben. Die Dauer eines Takts sollte der Taktrate von 20 MHz entsprechen 1/20000000 = 0.00000005 = 50 ns. Der Gesamtaufwand in Sekunden sollte dann sein : 15 * 50 ns = 750 ns = 0.75 µs. Jetzt verstehe ich nicht, warum ich nichts unter 5,6 µs erfassen kann. Kann jemand erklären, was los ist?

arminb
quelle
Vielleicht 5 Uhren, um den ISR-Code zu versenden, der das Speichern und Wiederherstellen von Epilog / Prolog im Kontext enthält, den Sie in der C-Quelle nicht sehen. Was macht die Hardware auch, wenn der Interrupt ausgelöst wird? Ist es in einem Schlafzustand. (Ich kenne AVR nicht, aber im Allgemeinen kann die Unterbrechung der Verarbeitung bestimmter Zustände länger dauern.)
Kaz
@arminb Siehe auch diese Frage für weitere Ideen, wie externe Ereignisse genauer erfasst werden können. Auch [diese Appnote] (www.atmel.com/Images/doc2505.pdf) könnte von Interesse sein.
Angelatlarge

Antworten:

10

Es gibt einige Probleme:

  • Nicht alle AVR-Befehle benötigen 1 Uhr, um ausgeführt zu werden. Wenn Sie sich die Rückseite des Datenblattes ansehen, wird die Anzahl der Takte angegeben, die für die Ausführung jedes Befehls erforderlich sind. So ist beispielsweise ANDein Ein-Takt-Befehl, MUL(Multiplizieren) dauert zwei Takte, während LPM(Programmierspeicher laden) drei und CALL4 ist. In Bezug auf die Befehlsausführung hängt dies also wirklich vom Befehl ab.
  • 5 Uhren zum Einspringen und 5 Uhren zum Zurückkehren können irreführend sein. Wenn Sie sich Ihren zerlegten Code ansehen, werden Sie feststellen, dass RETIder Compiler zusätzlich zu den Sprüngen und Anweisungen alle möglichen anderen Codes hinzufügt, was ebenfalls einige Zeit in Anspruch nimmt. Beispielsweise benötigen Sie möglicherweise lokale Variablen, die auf dem Stapel erstellt und entfernt werden müssen usw. Um zu sehen, was tatsächlich vor sich geht, sollten Sie sich die Demontage ansehen.
  • Denken Sie zum Schluss daran, dass Ihre Interrupts während Ihrer ISR-Routine nicht ausgelöst werden. Dies bedeutet, dass Sie von Ihrem Logikanalysator nicht die gewünschte Leistung erzielen können, es sei denn, Sie wissen, dass sich Ihre Signalpegel in Intervallen ändern, die länger dauern als für die Wartung Ihres Interrupts. Wenn Sie die Zeit berechnet haben, die Ihr ISR für die Ausführung benötigt, erhalten Sie eine Obergrenze dafür, wie schnell Sie ein Signal erfassen können . Wenn Sie zwei Signale erfassen müssen, geraten Sie in Schwierigkeiten. Um diesbezüglich zu detailliert zu sein, betrachten Sie das folgende Szenario:

Geben Sie hier die Bildbeschreibung ein

Wenn xes Zeit ist, Ihren Interrupt zu warten, wird Signal B niemals erfasst.


Wenn wir Ihren ISR-Code in eine ISR-Routine (die ich verwendet habe ISR(PCINT0_vect)) einfügen, alle Variablen deklarieren volatileund für ATmega168P kompilieren, sieht der zerlegte Code wie folgt aus (weitere Informationen finden Sie in der Antwort von @ jipple), bevor wir zum Code gelangen das "macht etwas" ; Mit anderen Worten, der Prolog zu Ihrem ISR lautet wie folgt:

  37                    .loc 1 71 0
  38                    .cfi_startproc
  39 0000 1F92              push r1
  40                .LCFI0:
  41                    .cfi_def_cfa_offset 3
  42                    .cfi_offset 1, -2
  43 0002 0F92              push r0
  44                .LCFI1:
  45                    .cfi_def_cfa_offset 4
  46                    .cfi_offset 0, -3
  47 0004 0FB6              in r0,__SREG__
  48 0006 0F92              push r0
  49 0008 1124              clr __zero_reg__
  50 000a 8F93              push r24
  51                .LCFI2:
  52                    .cfi_def_cfa_offset 5
  53                    .cfi_offset 24, -4
  54 000c 9F93              push r25
  55                .LCFI3:
  56                    .cfi_def_cfa_offset 6
  57                    .cfi_offset 25, -5
  58                /* prologue: Signal */
  59                /* frame size = 0 */
  60                /* stack size = 5 */
  61                .L__stack_usage = 5

Also , PUSHx 5, inx 1, clrx 1. Nicht so schlecht wie die 32-Bit-Vars von Jipple, aber immer noch nichts.

Einiges davon ist notwendig (erweitern Sie die Diskussion in den Kommentaren). Da die ISR-Routine jederzeit auftreten kann, muss sie natürlich die verwendeten Register vorab speichern, es sei denn, Sie wissen, dass kein Code, in dem ein Interrupt auftreten kann, dasselbe Register wie Ihre Interrupt-Routine verwendet. Zum Beispiel die folgende Zeile im zerlegten ISR:

push r24

Ist da, weil alles durchgeht r24: Ihr pincwird dort geladen, bevor es in den Speicher geht usw. Also müssen Sie das zuerst haben. __SREG__wird geladen r0und dann geschoben: Wenn dies durchgehen r24könnte, könnten Sie sich selbst sparen aPUSH


Einige mögliche Lösungen:

  • Verwenden Sie eine enge Abfrageschleife, wie von Kaz in den Kommentaren vorgeschlagen. Dies wird wahrscheinlich die schnellste Lösung sein, unabhängig davon, ob Sie die Schleife in C oder in Assembly schreiben.
  • Schreiben Sie Ihren ISR in Assembly: Auf diese Weise können Sie die Registernutzung so optimieren, dass die geringste Anzahl von Registern während des ISR gespeichert werden muss.
  • Deklarieren Sie Ihre ISR-Routinen ISR_NAKED , obwohl sich herausstellt, dass dies eher eine Lösung für roten Hering ist. Wenn Sie ISR-Routinen deklarieren ISR_NAKED, generiert gcc keinen Prolog- / Epilog-Code, und Sie sind dafür verantwortlich, alle von Ihrem Code geänderten Register zu speichern und aufzurufen reti(Rückkehr von einem Interrupt). Leider gibt es keine Möglichkeit, Register in avr-gcc C direkt zu verwenden (natürlich können Sie dies in Assembly tun). Sie können jedoch Variablen an bestimmte Register mit den Schlüsselwörtern register+ binden asm, wie folgt : register uint8_t counter asm("r3");. Wenn Sie dies tun, wissen Sie für den ISR, welche Register Sie im ISR verwenden. Das Problem ist dann, dass es keine Möglichkeit gibt, pushund zu generierenpopzum Speichern der verwendeten Register ohne Inline-Assembly (vgl. Punkt 1). Um sicherzustellen, dass weniger Register gespeichert werden müssen, können Sie auch alle Nicht-ISR-Variablen an bestimmte Register binden. Es tritt jedoch kein Problem auf, dass gcc Register zum Mischen von Daten zum und vom Speicher verwendet. Dies bedeutet, dass Sie nicht wissen, welche Register Ihr Hauptcode verwendet, es sei denn, Sie sehen sich die Demontage an. Wenn Sie also überlegen ISR_NAKED, können Sie den ISR genauso gut in Assembly schreiben.
Angelatlarge
quelle
Danke, also macht mein C-Code den riesigen Overhead? Wäre es schneller, wenn ich es in Assembler schreibe? Bei der zweiten Sache war mir das bewusst.
Arminb
@arminb: Ich weiß nicht genug, um diese Frage zu beantworten. Ich würde davon ausgehen, dass der Compiler einigermaßen intelligent ist und das tut, was er aus einem bestimmten Grund tut. Ich bin mir jedoch sicher, dass Sie, wenn Sie einige Zeit mit der Montage verbracht haben, ein paar weitere Taktzyklen aus Ihrer ISR-Routine herausholen könnten.
Angelatlarge
1
Ich denke, wenn Sie die schnellste Antwort wünschen, vermeiden Sie im Allgemeinen Interrupts und fragen die Pins in einer engen Schleife ab.
Kaz
1
Mit Blick auf bestimmte Ziele ist es möglich, den Code mithilfe von Assembler zu optimieren. Zum Beispiel beginnt der Compiler damit, alle verwendeten Register auf den Stapel zu schieben, und beginnt dann mit der Ausführung der eigentlichen Routine. Wenn Sie zeitkritische Dinge haben, können Sie einen Teil des Pushs zurückschieben und zeitkritische Dinge nach vorne ziehen. Ja, Sie können mit Assembler optimieren, aber der Compiler an sich ist auch ziemlich schlau. Ich verwende den kompilierten Code gerne als Ausgangspunkt und ändere ihn manuell für meine spezifischen Anforderungen.
Jippie
1
Wirklich schöne Antwort. Ich werde hinzufügen, dass der Compiler alle Arten von Registerspeicherung und -wiederherstellung hinzufügt, um den Anforderungen der meisten Benutzer zu entsprechen. Es ist möglich, Ihren eigenen Bare-Bones-Interrupt-Handler zu schreiben - wenn Sie nicht all diesen Aufwand benötigen. Einige Compiler bieten möglicherweise sogar die Möglichkeit, einen "schnellen" Interrupt zu erstellen, wodurch ein Großteil der "Buchhaltung" dem Programmierer überlassen bleibt. Ich würde nicht unbedingt direkt in eine enge Schleife ohne ISR gehen, wenn ich meinen Zeitplan nicht einhalten könnte. Zuerst würde ich eine schnellere uC in Betracht ziehen und dann herausfinden, ob ich eine Art Kleberhardware wie einen Riegel und eine RTC verwenden könnte.
Scott Seidman
2

Es gibt eine Menge PUSH'ing- und POP'ing-Register, die gestapelt werden müssen, bevor Ihr tatsächlicher ISR startet, dh zusätzlich zu den 5 Taktzyklen, die Sie erwähnen. Sehen Sie sich die Demontage des generierten Codes an.

Abhängig von der von Ihnen verwendeten Toolchain wird die Baugruppe, die uns auflistet, auf verschiedene Arten ausgegeben. Ich arbeite an der Linux-Befehlszeile und dies ist der Befehl, den ich verwende (es erfordert die .elf-Datei als Eingabe):

avr-objdump -C -d $(src).elf

Schauen Sie sich ein Code-Sniplet an, das ich kürzlich für ein ATtiny verwendet habe. So sieht der C-Code aus:

ISR( INT0_vect ) {
        uint8_t myTIFR  = TIFR;
        uint8_t myTCNT1 = TCNT1;

Und das ist der generierte Assembler-Code dafür:

00000056 <INT0_vect>:
  56:   1f 92           push    r1
  58:   0f 92           push    r0
  5a:   0f b6           in      r0, SREG        ; 0x3f
  5c:   0f 92           push    r0
  5e:   11 24           eor     r1, r1
  60:   2f 93           push    r18
  62:   3f 93           push    r19
  64:   4f 93           push    r20
  66:   8f 93           push    r24
  68:   9f 93           push    r25
  6a:   af 93           push    r26
  6c:   bf 93           push    r27
  6e:   48 b7           in      r20, TIFR       ; uint8_t myTIFR  = TIFR;
  70:   2f b5           in      r18, TCNT1      ; uint8_t myTCNT1 = TCNT1;

Um ehrlich zu sein, verwendet meine C-Routine einige weitere Variablen, die all diese Push'es und Pops verursachen, aber Sie haben die Idee.

Das Laden einer 32-Bit-Variablen sieht folgendermaßen aus:

  ec:   80 91 78 00     lds     r24, 0x0078
  f0:   90 91 79 00     lds     r25, 0x0079
  f4:   a0 91 7a 00     lds     r26, 0x007A
  f8:   b0 91 7b 00     lds     r27, 0x007B

Das Erhöhen einer 32-Bit-Variablen um 1 sieht folgendermaßen aus:

  5e:   11 24           eor     r1, r1
  d6:   01 96           adiw    r24, 0x01       ; 1
  d8:   a1 1d           adc     r26, r1
  da:   b1 1d           adc     r27, r1

Das Speichern einer 32-Bit-Variablen sieht folgendermaßen aus:

  dc:   80 93 78 00     sts     0x0078, r24
  e0:   90 93 79 00     sts     0x0079, r25
  e4:   a0 93 7a 00     sts     0x007A, r26
  e8:   b0 93 7b 00     sts     0x007B, r27

Dann müssen Sie natürlich die alten Werte einfügen, sobald Sie den ISR verlassen:

 126:   bf 91           pop     r27
 128:   af 91           pop     r26
 12a:   9f 91           pop     r25
 12c:   8f 91           pop     r24
 12e:   4f 91           pop     r20
 130:   3f 91           pop     r19
 132:   2f 91           pop     r18
 134:   0f 90           pop     r0
 136:   0f be           out     SREG, r0        ; 0x3f
 138:   0f 90           pop     r0
 13a:   1f 90           pop     r1
 13c:   18 95           reti

Gemäß der Anweisungszusammenfassung im Datenblatt sind die meisten Anweisungen Einzelzyklen, aber PUSH und POP sind Doppelzyklen. Sie haben die Idee, woher die Verzögerung kommt?

Jippie
quelle
Danke für deine Antwort! Jetzt weiß ich, was passiert. Besonders danke für den Befehl avr-objdump -C -d $(src).elf!
Arminb
Nehmen Sie sich einen Moment Zeit, um die avr-objdumpausspuckenden Montageanweisungen zu verstehen. Sie werden im Datenblatt unter Anweisungszusammenfassung kurz erläutert. Meiner Meinung nach ist es empfehlenswert, sich mit den Mnemonics vertraut zu machen, da dies beim Debuggen Ihres C-Codes sehr hilfreich sein kann.
Jippie
Tatsächlich ist das Zerlegen nützlich, um es als Teil Ihrer Standardeinstellung zu verwenden. MakefileWenn Sie also Ihr Projekt erstellen, wird es auch automatisch zerlegt, sodass Sie nicht darüber nachdenken oder sich daran erinnern müssen, wie es manuell durchgeführt wird.
Angelatlarge