Obwohl der typische Arduino-Nutzer vermutlich kein Interesse an Assembler-Programmierung hat, kann man in einigen Situationen nicht darauf verzichten. Werfen wir einen Blick auf diese Situationen und schauen, was man tun kann.

Warum Assembler-Programmierung?

Der typische Anwendungsfall für die Assemblerprogrammierung ist, wenn strikte Zeitvorgaben eingehalten werden müssen und/oder der Speicherplatz beschränkt ist. Ein Beispiel dafür ist eine schnelle (und leichtgewichtige) Bit-Banging I2C-Bibliothek, die auch auf MCUs ohne I2C-Hardware läuft. Peter Fleury hat eine solche Bibliothek für AVR-MCUs geschrieben. Ich habe seinen Code genommen, ihn etwas erweitert und in eine Arduino-Bibliothek umgewandelt, indem ich GCC-Inline-Assembler-Kodierung verwendet habe. Ich habe auch eine plattformunabhängige C++-Version dieser Bibliothek implementiert. Diese C++-Bibliothek benötigt doppelt so viel Flash-Speicher, und die maximale Kommunikationsgeschwindigkeit ist etwa 6 Mal langsamer.

Assembler-Programmierung hilft aber nicht nur, wenn es schnell gehen soll, sondern auch, wenn man präzise Kontrolle über das Timing haben möchte. Da man die genaue Anzahl der Zyklen kennt, die jeder Maschinenbefehl benötigt, kann man z. B. einen Software-UART implementieren, der ähnlich gut ist wie Hardware-UARTs.

Ein weiteres Beispiel ist der Logikanalysator, der auf einem Arduino UNO implementiert ist, wie in meinem vorherigen Beitrag beschrieben. Das Herzstück ist eine Erfassungsschleife, die die Port-Eingänge mit der vorgegebenen Abtastrate ausliest. Algorithmisch ist dies trivial. Die Herausforderung besteht darin, dies zeitlich präzise mit einer hohen Abtastrate zu tun. Ich habe Inline-Assemblercode geschrieben, der Timer1 für das Timing der Abtastung verwendet, was bis zu einer Abtastfrequenz von 1 MHz funktioniert. Für 2 MHz musste ich eine abgespeckte Version verwenden, die wir uns später ansehen werden.

Ein letztes Beispiel ist eine zeit- und platzsparende Implementierung des Arduino-Cores, die stark auf Inline-Assembler-Programmierung setzt.

Ich behaupte nicht, dass die Assembler-Programmierung besser ist als die Verwendung von C/C++ (obwohl man einige Bytes im Flash-Speicher einsparen könnte, aber das ist normalerweise nicht entscheidend). Im Gegenteil, ich selbst versuche, die Assembler-Kodierung auf ein Minimum zu beschränken! Sie ist mühsam und fehleranfällig. Und Assembler-Code ist schwer zu verstehen und zu debuggen.

Meine Strategie bei engen Zeitvorgaben ist, den zeitkritischen Teil zuerst in C++ zu programmieren. Danach schaue ich mir an, was der Compiler erzeugt, und prüfe, ob die Zeitvorgaben eingehalten werden können. Und erst dann fange ich an, darüber nachzudenken, wie ich es besser machen kann, d.h. mit weniger Zyklen – falls überhaupt nötig.

Wie kommt man nun an den Code, den der Compiler erzeugt? Es gibt viele verschiedene Möglichkeiten. Die einfachste ist vermutlich, die Datei platform.local.txt zu erzeugen, wie ich es in meinem Beitrag zum Debuggen beschrieben habe. Mit dieser Änderung erhält man eine ELF-Datei, wenn man Kompilierte Binärdatei exportieren im Sketch-Menü auswählt. Mit dem Programm avr-objdump, das Teil des Arduino-Pakets ist, oder das man als Teil der AVR-GCC-Toolchain herunterladen kann, kann man dann ein Listing erstellen, das den generierten Maschinencode enthält:

> avr-objdump --disassemble --source --line-numbers --demangle --section=.text ELF-file > LST-file

Wenn man im Arduino-Kontext Assembler-Programmierung betreiben will, muss man sich zuerst einmal mit den Maschinenbefehlen vertraut machen. Dies werde ich als Nächstes behandeln. Außerdem muss man lernen, wie Assembler-Code in ein C++-Programm eingebettet werden kann, worauf ich danach eingehen werde. Abschließende präsentiere ich dann ein Beispiel, nämlich die bereits erwähnte Erfassungsschleife des Logikanalysators.

RISC-Befehlssatz

Die AVR MCUs verwenden einen RISC-Befehlssatz. Das bedeutet, dass alle Befehle hoch spezialisiert (nur logische Operationen oder nur Speichertransfer) und optimiert sind, sodass die meisten Befehle nur einen oder zwei Taktzyklen benötigen. Das AVR Instruction Set Manual ist eine gute Referenz und enthält alle Details, die man wissen muss. Anstatt jetzt jede einzelne Anweisung durchzugehen, werde ich nur das große Bild präsentieren und die Konzepte vorstellen, die man kennen muss, um zu verstehen, wie man Assembler programmiert.

Die AVR MCUs verwenden eine modifizierte Harvard-Architektur, was bedeutet, dass Programmspeicher und Datenspeicher in zwei verschiedenen Adressräumen liegen. Darüber hinaus gibt es den EEPROM-Datenspeicher, auf den man aber nur über spezielle E/A-Operationen zugreifen kann.

Der Datenspeicher besteht aus 32 8-Bit-Allzweckregistern mit den Bezeichnungen r0-r31, 64 E/A-Registern für den Zugriff auf Steuerregister und andere E/A-Funktionen, bis zu 160 erweiterten E/A-Registern (je nach MCU-Typ), gefolgt von internem SRAM, das im Falle des ATmega328P aus 2048 Bytes besteht.

Datenspeicher (Microchip Developer Help)

Auf alle Datenspeicherzellen kann mit Lade- und Speicherbefehlen unter Verwendung der in der obigen Abbildung rechts dargestellten Adressen zugegriffen werden. Auf die 64 E/A-Register kann über den E/A-Bus mit in– und out-Befehlen zugegriffen werden, wobei eine E/A-Adresse verwendet wird, die um 0x20 niedriger ist als die reguläre Speicheradresse. Solche E/A-Befehle benötigen nur einen Zyklus anstelle der zwei Zyklen, die für allgemeine Lade- und Speicherbefehle erforderlich sind. Und in den ersten 32 der E/A-Register können einzelne Bits mit einer Instruktion, die nur zwei Zyklen benötigt, gesetzt und gelöscht werden. Dies würde im SRAM mindestens 3 Instruktionen und 5 Zyklen erfordern.

Während alle Register 8-Bit-Register sind, bilden die 6 oberen Register drei Paare von 16-Bit-Registern, die für indirekte Adressierung (s.u.) verwendet werden können. r26/r27 wird auch X-Register genannt, r28/29 Y-Register und r30/r31 Z-Register. AVR MCUs sind Little-Endian-Systeme, was bedeutet, dass das Byte mit der niedrigsten Adresse das niederwertigste Byte enthält. Wenn wir also die Zahl 0x0F12 im Z-Register speichern wollen, würde r30 0x12 und r31 0x0F enthalten.

Adressierungsmodi

Befehle können nur bestimmte Teile des Adressraums adressieren und dies auf unterschiedliche Weise tun. Diese verschiedenen Möglichkeiten werden als Adressierungsmodi bezeichnet. Wir werden alle diese Modi durchgehen und Beispiele für ihre Verwendung anführen.

  • Direkte Registeradressierung, einzelnes Register: Der Operand ist in einem Register enthalten, und das Ergebnis der Anwendung des Befehls wird im selben Register gespeichert. Beispiel: Der Befehl neg r0, der den Inhalt des Registers r0 durch dessen Zweierkomplement ersetzt.
  • Direkte Registeradressierung, zwei Register: Die Operanden sind in zwei Registern enthalten. Wenn der Befehl ein Ergebnis hat, wird es im ersten Register gespeichert. Beispiel: Der Befehl sub r1, r2, der den Inhalt von r2 von r1 subtrahiert und das Ergebnis in r1 ablegt. Ein anderes Beispiel ist cp r1, r2, bei dem die gleiche Operation durchgeführt wird, aber kein Ergebnis gespeichert wird. Es wird lediglich das Statusregister aktualisiert, das eine Reihe verschiedener Flags enthält, z. B. das Null-Flag und das Carry-Flag.
  • Unmittelbarer Wert: Ein Operand ist ein unmittelbarer Wert, der Teil des Befehls ist. Der andere Operand ist ein Register (oder Registerpaar). Je nach Anweisung sind unterschiedliche Wertebereiche zulässig. Beispiele: Der Befehl adiw r30, 5, der den Wert 5 (0-63 ist erlaubt) zum Registerpaar r30/r31 addiert, und der Befehl ori r0, 0xFE, der die boolesche Oder-Funktion auf dem Registern r0 und dem Wert 0xFE ausführt und das Ergebnis in r0 speichert.
  • E/A-Direktadressierung: Ein Byte aus einem Register wird aus einem E/A-Register gelesen oder in dieses geschrieben (E/A-Registeradressen reichen von 0x00 bis 0x3F). Beispiele: Der Befehl in r0, 0x05, der ein Byte von dem E/A-Register 0x05 in das Register r0 lädt, und out 0x06, r1, der den Inhalt des Registers r1 in das E/A-Register 0x06 schreibt.
  • Direkte Datenadressierung: Ein Operand ist ein Byte im Datenspeicher, das durch eine im Befehl angegebene 16-Bit-Adresse adressiert wird. Beispiele sind: Die Befehle lds r2, 0x05FF und sts 0x0020, r0, die das Byte, das in dem durch 0x05FF adressierten Byte enthalten ist, in r2 laden bzw. den Inhalt von r0 in die durch die Adresse 0x0020 bezeichnete Speicherzelle speichern.
  • Indirekte Datenadressierung: Ein Operand ist ein Byte im Datenspeicher, der durch das X-, Y- oder Z-Register adressiert wird. Beispiel: st X, r5, speichert den Inhalt von r5 in das durch die durch das X-Register adressierte Speicherzelle.
  • Indirekte Datenadressierung mit Verschiebung: Ein Operand ist ein Byte im Datenspeicher, das durch das Y- oder Z-Register und eine konstante Verschiebung von 0 bis 63 adressiert wird. Beispiel: ldd r0, Y+5, lädt von der Adresse, auf die das Y-Register zeigt, plus 5 in das Register r0.
  • Indirekte Datenadressierung mit Pre-Dekrementierung oder Post-Inkrementierung: Ein Operand ist ein Byte im Datenspeicher, das durch das X-, Y- oder Z-Register adressiert wird, und das Adressregister wird entweder vor dem Zugriff dekrementiert oder nach dem Zugriff inkrementiert. Beispiele: ld r0, X+, das r0 mit dem Byte lädt, auf das das X-Register zeigt, das anschließend inkrementiert wird, und st -Y, r1, das das im Register r1 enthaltene Byte an der Stelle speichert, die adressiert wird, nachdem das Y-Register dekrementiert wurde.
  • Konstante Adressierung des Programmspeichers: Dieser Modus ist nur mit den Programmanweisungen lpm, elpm und spm verwendbar. Diese Befehle laden Bytes aus dem Programmspeicher oder schreiben Worte in den Programmspeicher. In beiden Fällen wird die Adresse durch das Z-Register angegeben.
  • Programmspeicheradressierung mit Post-Inkrement: Ähnlich wie der vorherige Modus, aber mit Post-Inkrementierung des Z-Registers.
  • Direkte Adressierung des Programmspeichers: Die Programmausführung wird an der unmittelbaren Adresse im Befehlswort fortgesetzt. Beispiel: Die Instruktion call 0x0555 ruft das Unterprogramm an der Programmadresse 0x0555 (die keine Byte-Adresse, sondern eine Wortadresse ist!) auf. Man beachte, dass man als Programmierer eine symbolische Bezeichnung für die Adresse des Unterprogramms angibt, sodass man sich nicht um den spezifischen numerischen Wert kümmern muss.
  • Indirekte Adressierung des Programmspeichers: Die Programmausführung wird an der Adresse fortgesetzt, auf die das Z-Register zeigt (wiederum eine Wortadresse, keine Byte-Adresse). Beispiel: der Befehl ijmp.
  • Relative Adressierung des Programmspeichers: Die Programmausführung wird an der Adresse fortgesetzt, die relativ zum aktuellen Programmzähler durch eine Verschiebung (wiederum eine Wortadresse) angegeben wird. Auch hier gibt der Programmierer einfach einen symbolischen Wert ein. Beispiel: rjmp back, was bedeutet, dass die Programmausführung an der mit back bezeichneten Programmstelle fortgesetzt werden soll. Die Instruktionen für diesen Adressierungsmodus haben eine Länge von 2 Bytes, während die direkte Adressierung 3 Bytes erfordert.

Instruktionstypen

Man unterscheidet Instruktionstypen danach, was die Instruktionen bewirken. Einige verschieben Daten, andere führen arithmetische Berechnungen durch. Die AVR-Befehle können in die folgenden Typen von Befehlen unterteilt werden:

  • Arithmetische und logische Instruktionen: Dies sind Befehle, die logische und arithmetische Operationen durchführen. Fast alle von ihnen arbeiten auf zwei Registern, einige von ihnen nehmen einen unmittelbaren Wert als Operanden. Sie wirken sich alle auf das Statusregister aus, z. B. werden Null- und Carry-Flags gesetzt. Letzteres ist besonders wichtig, wenn man arithmetische Operationen mit mehreren Bytes durchführt. Wenn wir z.B. die Zwei-Byte-Ganzzahl in den Registern r2/r3 zu der Zwei-Byte-Ganzzahl in r4/r5 addieren wollen (zur Erinnerung: LSB in den Registern mit der niedrigeren Adresse), könnte dies wie folgt geschehen:
  add r4, r2 ; add low byte in r2 to r4 without carry
  adc r5, r3 ; add high byte in r3 to r5 with carry-flag
  • Verzweigungsbefehle: Verzweigungsbefehle sind alle Befehle, die den linearen Ablauf der Ausführung ändern. Dazu gehören zum Beispiel der oben erwähnte Aufrufbefehl call und auch der ret-Befehl, der von einem Unterprogrammaufruf zurückkehrt. Diese beiden Befehle sind nicht an Bedingungen geknüpft. Es gibt auch eine große Anzahl von bedingten Verzweigungsbefehlen, wie z. B. breq (branch if equal), der nur dann zur angegebenen Programmadresse springt, wenn das Null-Flag gesetzt ist.
  • Datenübertragungsbefehle: Datenübertragungsbefehle verschieben Daten, z. B. vom Datenspeicher zu Registern (ld) und umgekehrt (st). Dann gibt es noch zwei Anweisungen, die Daten zwischen Registern verschieben (mov und movw), und Anweisungen, die den Stack manipulieren (push und pop). Und natürlich gibt es die oben erwähnten Befehle, die aus E/A-Registern lesen (in) und in sie schreiben (out). Alle diese Befehle ändern keine Flags im Statusregister.
  • Anweisungen zur Bitmanipulation: Dies sind Befehle, die Bits in einem der allgemeinen Register, in den E/A-Registern oder im Statusregister manipulieren. Ein Beispiel für die erste Art von Befehlen ist rol, der den Inhalt eines Registers nach links verschiebt, wobei das Carry-Flag in das niedrigst wertige Bit und das höchstwertige Bit in das Carry-Flag verschoben wird. Ein Beispiel für die zweite Art von Befehlen ist sbi, der ein bestimmtes Bit in einem E/A-Register auf eins setzt. Schließlich ist ein Beispiel für die dritte Art von Befehlen clc, der das Carry-Flag löscht.
  • MCU-Steuerbefehle: Die einzige Anweisung aus dieser Gruppe, die normalerweise interessant ist, ist nop, der Befehl, der nichts tut und dafür einen Taktzyklus benötigt. Dieser Befehl wird oft verwendet, um eine Schleife zu timen, z.B. bei Bit-Banging-Implementationen.
  • Wort-Befehle: Orthogonal zur obigen Klassifizierung kann man die Befehle nach der Anzahl der Bytes einteilen, die bearbeitet werden. Es gibt eigentlich nur drei Befehle, die auf Wörtern und nicht auf Bytes operieren. Diese sind movw, um ein Wort von einem Registerpaar in ein anderes zu verschieben, adiw, um einen unmittelbaren Wert (0-63) zu einem Wort in einem der vier oberen Registerpaare zu addieren, und sbiw, um einen unmittelbaren Wert von einem Registerpaar zu subtrahieren.

Inline-Assembler

Wenn man verstanden hat, wie man Assembler-Anweisungen verwenden kann, möchte man natürlich ein Assembler-Programm schreiben. Dies kann man tun, indem man ein solches Programm in eine Datei mit einer S-Extension schreibt und sie dann an avr-gcc übergibt. Leider unterstützt die Arduino IDE dies nicht. Es gibt aber die Möglichkeit, Inline-Assembler-Code in C/C++-Code einzufügen, wie im folgenden einfachen Beispiel, in dem Interrupts aktiviert werden:

asm volatile("sei");

Man beachte, dass bei der asm-Anweisung die volatile-Qualifikation benutzt wurde. Dies sollte man tun, um zu vermeiden, dass der Compiler den Assemblercode wegoptimiert oder aus einer Schleife herausschiebt (streng genommen ist dies nur notwendig, wenn es einen Ausgabeoperanden gibt, der anschließend im C++-Code nicht verwendet wird, aber es schadet sowieso nie).

Versuchen wir nun, den Eingangsport PINA zu lesen, und den Wert in die C-Variable invalue vom Typ byte zu speichern. Man könnte versucht sein, eine asm-Anweisung wie die folgende zu verwenden:

asm volatile("in invalue, PINA");

Das funktioniert aber aus mehreren Gründen nicht. Zunächst einmal muss der erste Operand einer in-Anweisung ein Register sein, keine C-Variable. Wir müssen also eine Verbindung zwischen einem Register, das der Compiler wählt, und der Variablen invalue herstellen. Zweitens können wir eine Kompilierzeitkonstante wie PINA nicht innerhalb einer Zeichenkette verwenden, da der C++-Präprozessor innerhalb einer Zeichenkette nichts ändert. Um beide Probleme zu lösen, enthält die asm-Anweisung normalerweise mehr als nur den Assemblercode. Die allgemeine Form der asm-Anweisung ist:

asm volatile(<code> : <output operands> : <input operands> : <clobbers>)

Der <code>-Abschnitt ist einfach eine Zeichenkette, die den Assembler-Code enthält, wobei die Anweisungen durch ein Zeilenumbruch-Symbol getrennt werden müssen. Um das vom Compiler erzeugte Listing schöner zu gestalten, sollte man auch einen Tabulator hinzufügen. Da in C und C++ String-Konstanten, die aufeinander folgen und nur durch Leerzeichen getrennt sind, als eine String-Konstante betrachtet werden, kann man jede Anweisung in eine Zeile schreiben:

"LABEL: nop ; this is a comment \n\t"
"rjmp LABEL ; jump to LABEL \n\t"

Der <output operands>-Abschnitt ist eine durch Kommata getrennte Liste von Beschreibungen, wie die Ausgaben an den C/C++-Kontext zurückgegeben werden sollen. Eine solche Beschreibung besteht aus einem Constraint und einem C-Ausdruck in Klammern. Im Falle eines Ausgabeoperanden muss es sich um einen L-Wert handeln, d. h. um etwas, das auf der linken Seite eines Zuweisungsoperators stehen könnte.

In ähnlicher Weise gibt der <input operands>-Abschnitt an, woher die Eingaben für die asm-Anweisung kommen. Der <clobbers>-Abschnitt schließlich ist eine durch Kommata getrennte Liste von Registern, die im Inline-Code verwendet werden und die deshalb vor der Ausführung der asm-Anweisung gespeichert und danach wiederhergestellt werden müssen. Unser Beispiel von oben könnte wie folgt geschrieben werden:

asm volatile("in %0, %1" : "=r" (invalue) : "I" (_SFR_IO_ADDR(PINA)));

Die Notation %0 und %1 bezieht sich auf die erste bzw. zweite Operandenspezifikation. Die erste Operandenspezifikation im Ausgabeteil ist "=r" (invalue), was bedeutet, dass innerhalb der asm-Anweisung %0 für ein Register steht, und dass nach Beendigung der asm-Anweisung der Registerinhalt in der C-Variablen invalue gespeichert werden soll. Die Angabe "I" (_SFR_IO_ADDR(PINA)) bedeutet, dass %1 durch eine Konstante ersetzt werden soll und diese Konstante die E/A-Adresse von PINA ist (d.h. es wird 0x20 von der Speicheradresse des Eingangsports A subtrahiert).

Für eine Ausgabespezifikation können die folgenden Constraints benutzt werden:

  • "=r" bedeutet, dass ein Register verwendet werden soll und der Wert schreibgeschützt ist,
  • "+r" bedeutet, dass ein Register verwendet werden soll und der Wert am Anfang geladen und am Ende gespeichert werden soll,
  • "=&r" bedeutet, dass ein Register verwendet werden soll und dass dies exklusiv reserviert ist.

Für Eingabespezifikationen sind viele weitere Constraints möglich. Wir haben bereits gesehen, dass "I" für eine positive 6-Bit-Ganzzahl steht. Es gibt z.B. auch "M" für eine 8-Bit positive Integer-Konstante, ⁣ "r" für ein Register, "e" für eines der drei Adressregister, usw. Hier ist eine Tabelle mit allen möglichen Constraints.

Constraint Used forRange of
allowed values
Possible registers to be
allocated by compiler
asimple upper registerr16-r23
bbase pointer register pairsY, Z
dupper registerr16-r31
epointer register pairX, Y, Z
llower registerr0-r15
qstack pointerSPL:SPH
rany registerr0-r31
ttemporary register__temp_reg__
wspecial upper register pairr24, r26, r28, r30
xpointer register XX
ypointer register YY
zpointer register ZZ
Gfloating point constant0.0
I6-bit positive integer0-63
J6-bit negative integer-63-0
Kinteger constant2
Linteger constant0
M8-bit integer constant0-255
n16-bit integer constant0-65535
Ninteger constant-1
Ointeger constant8, 16, 24
Pinteger constant1
Qmemory address based
on Y or Z pointer with
displacement
Rinteger constant-6-5

Eine interessante Eigenschaft des Inline-Assemblers ist, dass man nicht nur Ein-Byte-Werte und -Variablen, sondern auch Zwei- und Vier-Byte-Werte und -Variablen verarbeiten kann. Wenn man zum Beispiel die Bytes einer Zwei-Byte-Variablen val vertauschen will, kann man dies wie folgt tun:

asm volatile("mov r5, %A0\n\t"
             "mov %A0, %B0\n\t"
             "mov %B0, r5\n\t"
             : "+r" (val)
             : 
             : "r5")

Dabei steht %An für das erste (niederwertige) Byte-Register des Operanden %n und %Bn für das zweite Byte. Im Falle von Vier-Byte-Werten würde man auch %Cn und %Dn verwenden. Die Variable val ist hier Ein- und Ausgabe-Variable, was durch "+r" gekennzeichnet ist. Weiterhin wird r5 als „clobbered register“ bezeichnet, d.h. der Compiler muss dafür sorgen, dass der zuvor in r5 gespeicherte Werte wiederhergestellt wird. Eigentlich hätte man anstelle von r5 auch das temporäre Register r0 verwenden können, das immer verfügbar ist. Anstelle von r0 sollte man jedoch temp_reg schreiben, um den Code unabhängig von Änderungen in der Registerzuweisung des Compilers zu machen.

Das AVR-GCC Inline Assembler Cookbook gibt ein umfassendes Bild davon, wie man Inline-Assembler-Code schreibt, ist aber an einigen Stellen sehr kompakt geschrieben. Wünscht man sich eher eine Einführung im Stil eines Tutorials, dann ist das Arduino Inline Assembly Tutorial wahrscheinlich das Richtige. Es gibt auch eine Einführung in die AVR-Assembler-Programmierung im Allgemeinen. Inzwischen hat der Autor auch ein E-Book veröffentlicht, das auf seinem Blog basiert. Abschließend möchte ich auch noch auf ein Tutorial von wek bei AVRFREAKS hinweisen, das einige mögliche Missverständnisse und Fallstricke anspricht.

Beispiel

Um die Inline-Assembler-Codierung zu demonstrieren, möchte ich das Beispiel der 2-MHz-Erfassungsschleife für den Arduino UNO-Logikanalysator (vereinfacht) vorstellen:

byte trigger, trigger_value;
byte logicdata[1024];

// This function provides sampling for 2 MHz with no pre-trigger data
void acquire2MHz() {
  byte inp;
  unsigned int index = 0;
  
  cli(); // disable interrupts
  do {
    inp = PINB; // read sample
  } while ((trigger_values ^ inp ) &amp; trigger); // as long as no trigger
  logicdata[0] = inp; // keep first trigger sample 
  
  // keep sampling for 1023 samples after trigger
  for (unsigned int i = 1 ; i &lt; 1024; i++) {
    logicdata[i] = PINB;
  }
  sei(); // enable interrupts again
}

Wenn wir uns nun ansehen, was der Compiler daraus macht, sieht das schon recht effizient aus:

void acquire2MHz() {
  byte inp;
  
  cli();
 11e:	f8 94       	cli

  do {
    inp = PINB;
 120:	93 b1       	in	r25, 0x03	; 3

  } while ((trigger_values ^ inp ) & trigger); 
 122:	80 91 05 05 	lds	r24, 0x0505	; 0x800505 <trigger_values>
 126:	89 27       	eor	r24, r25
 128:	20 91 04 05 	lds	r18, 0x0504	; 0x800504 <trigger>
 12c:	82 23       	and	r24, r18
 12e:	c1 f7       	brne	.-16     	; 0x120
  logicdata[0] = inp;
 130:	90 93 04 01 	sts	0x0104, r25	; 0x800104 <logicdata>

  for (unsigned int i = 1 ; i < 1024; i++) {
 134:	81 e0       	ldi	r24, 0x01	; 1
 136:	90 e0       	ldi	r25, 0x00	; 0
 138:	81 15       	cp	r24, r1         ; [1]
 13a:	24 e0       	ldi	r18, 0x04	; 4 [1]
 13c:	92 07       	cpc	r25, r18        ; [1] 
 13e:	38 f4       	brcc	.+14     	; 0x14e [1 if false]

    logicdata[i] = PINB;
 140:	23 b1       	in	r18, 0x03	; 3 [1]
 142:	fc 01       	movw	r30, r24        ; [1]
 144:	ec 5f       	subi	r30, 0xFC	; 252 [1]
 146:	fe 4f       	sbci	r31, 0xFE	; 254 [1]
 148:	20 83       	st	Z, r18          ; [1]

  for (unsigned int i = 1 ; i < 1024; i++) {
 14a:	01 96       	adiw	r24, 0x01	; 1 [1]
 14c:	f5 cf       	rjmp	.-22     	; 0x138 [2]
  }

  sei();
 14e:	78 94       	sei

}
 150:	08 95       	ret

Der interessante Teil ist die for-Schleife zwischen 138 und 14c. Ich habe das Listing mit den Taktzyklen in eckigen Klammern kommentiert. Wenn man sie zusammenzählt, erhält man ein Ergebnis von 12 Taktzyklen. Wenn man aber eine Abtastrate von 2 MHz haben will, passt das nicht. Dann sollte jede Schleifeniteration nur 8 Taktzyklen betragen. Hier haben wir also einen Fall, in dem Inline-Assembler definitiv ein Muss ist. Die endgültige Erfassungsprozedur sieht wie folgt aus:

void acquire2MHz() {
  byte * ptr = &amp;logicdata[0];
  int delayCount = 1023;

  cli();
  asm volatile(
    "TRIGLOOP: in __tmp_reg__, %[CHANPINaddr]; read input [1]\n\t"
    "st %a[LOGICDATAaddr], __tmp_reg__  ; store input [2]\n\t"
    "eor __tmp_reg__, %[TRIGVAL]        ; inp = inp XOR trig_val [1]\n\t"
    "and __tmp_reg__, %[TRIGMASK]       ; inp = inp AND trigger [1]\n\t"
    "brne TRIGLOOP                      ; wait for trigger [1 if false]\n\t"
    "adiw %A[LOGICDATAaddr], 1          ; increment pointer [2]\n\t"
    ";This makes it 8 cycles! Now the sampling loop:\n\t"
    "SAMPLOOP: in __tmp_reg__, %[CHANPINaddr]; read input data [1]\n\t"
    "nop                                 ; cycle padding [1]\n\t"
    "st %a[LOGICDATAaddr]+, __tmp_reg__  ; store input &amp; post incr. [2]\n\t"
    "sbiw %A[DELAYCOUNT], 1              ; decrement delayCount [2]\n\t"
    "brne SAMPLOOP                       ; continue until end [2]\n\t"
    ";sum: 8 cycles\n\t"
    : 
    : [LOGICDATAaddr] "e" (ptr),	
      [CHANPINaddr] "I" (_SFR_IO_ADDR(PINB)),
      [DELAYCOUNT] "e" (delayCount), 
      [TRIGMASK] "r" (trigger),
      [TRIGVAL] "r" (trigger_values));
  sei();
}

Jetzt werden nur noch 8 Taktzyklen in der Schleife verwendet, und außerdem liegt das auslösende Sample ebenfalls nur 8 Zyklen vor dem Beginn der allgemeinen Erfassung. Im Vergleich zu dem, was wir bisher an Programmkonstrukten eingeführt hatten, gibt es jedoch eine Reihe von Erweiterungen. Erstens habe ich symbolische Referenzen (z.B. %[CHANPINaddr]), anstelle der Positionsreferenz (z.B. %1) für Ausgangs- und Eingangsoperanden verwendet. Zweitens habe ich die Notation %a[ref] verwendet, in der Anweisung st %a[LOGICDATAaddr]+, tmp_reg. Das bedeutet, dass das jeweilige Indexregister verwendet werden soll, d. h. X, Y oder Z.

Alles in allem zeigt dieses Beispiel, dass man mit Inline-Assembler die Grenzen des Timings auf einer AVR MCU ziemlich weit ausreizen kann. Aus dem Code geht aber auch klar hervor, dass es unmöglich wäre, mit einer AVR MCU, die mit 16 MHz getaktet ist, eine Abtastrate von 4 oder 5 Ms/Sekunde mit einer Schleife zu erreichen. Dies kann man nur durch Loop-Unrolling erreichen.