Im ersten Teil dieser Einführung in den ATtiny ging es um einen ersten Überblick über den Aufbau eines Mikrocontrollers. Das kleine Kerlchen für gerade einmal 1€ enthält ja ein komplettes System mit CPU, RAM und Flash auf einem winzigen Chip. In der Abbildung oben ist die Belegung der Pins des ATtiny abhängig von der jeweiligen Konfiguration dargestellt. Ein Beinchen kann also einmal mit einem I/O-Port verbunden sein, mal mit dem SPI Modul, mal mit dem Analog-Digital-Coverter. Welche Funktion das ist, hängt von der Konfiguration der jeweiligen Register ab. In diesem Beitrag soll gezeigt werden, wie ein Programm für den ATtiny mit Hilfe von Assembler erstellt wird und wie eine dieser Funktionen – der I/O-Port PB3 (hellgrau) – für einen Timer-gesteuerten Blinker genutzt wird.
Der ATtiny ist übersichtlich genug, um schnell eine Menge über Mikrocontroller zu lernen. Viele Aspekte des 8-Bit Systems lassen sich auf andere Systeme übertragen und selbst die modernen CPUs und der RasPi funktionieren nach diesem Schema. Wenn man sich näher mit dem Zwerg beschäftigt, ist jedoch erstaunlich, wie weit man mit einem solch kleinem System kommen kann und dass verhältnsimäßig komplexe Anwendungen möglich sind. Der CPU-Takt von 20 MHz erlaubt immerhin einige Millionen Instruktionen pro Sekunde. Der extrem geringe Stromverbrauch ermöglicht zudem eine autarke Versorgung der Anwendung mit Batterien oder Solarbetrieb.
Maschinensprache
Man kann den ATtiny in C programmieren. Das ist sehr komfortable und erstaunlich, dass es ein Compiler schafft, den Code in den winzigen Chip zu quetschen. Aber eigentlich wird so mit Kanonen auf Spatzen geschossen. Um den Chip so effizient wie möglich zu nutzen, wird man eher Maschinennah programmieren. Dazu muss man verstehen, wie genau das System funktioniert.
Die CPU versteht nur Zahlen. Jede Zahl steht für ein bestimmte Funktion und löst teilweise recht komplexe elektronische Vorgänge in der CPU aus. Die Zahlen bekommt die CPU dank Instruction-Register häppchenweise aus dem Programmspeicher. Ein Programm für den ATtiny besteht lediglich aus einer langen Reihe von 2 Byte-Blöcken. Ein 2-Byte-Block wird als Word bezeichnet. Das folgende Programm ist gerade einmal 86 Byte groß und lässt eine LED an Pin 2 blinken. Dafür macht es sich schon recht viele Funktionen des ATtiny zu Nutze: Es initialisiert den Timer und immer wenn der Timer überläuft, schaltet es den Port PB3 an oder aus, zusätzlich setzt es die CPU in den Standby während es auf den Timer wartet. Nach Ablauf des Timer-Zyklus feuert es einen Interrupt, um die CPU aufzuwecken und PB3 zu schalten:
c00e 9518 9518 c030 e50f bf0d e002 bf0e e008 bb07 e008 bb08 b50c 6800 780f bd0c b507 7f0b bd07 b700 780f 6800 7f00 600f bf00 e400 bf09 e001 bd0e e400 bd0d 9478 b50c 770f bd0c b705 7e07 6200 bf05 9588 cffe e008 bb06
Ein Word aus der obigen Zahlenkollonne ist für die ATtiny-CPU wie eine Schaltanweisung für die verschiedenen Speichereinheiten. Die Abbildung aus dem Datenblatt des ATtiny oben zeigt, wie die verschiedenen Register über den Datenbus kommunizieren. Die Schaltanweisungen, die der Instruction Decoder aus dem Word liest, werden im Blockdiagramm oben als „Control Lines“ bezeichnet. Zum Beispiel wird oft eine Operation auf die General Purpose Register R0 bis R31 angewendet und das Ergebnis in eines der Register geschrieben. Wird zum Beispiel die Zahl 3073 (oder in Hexadezimaler Darstellung 0x0C01) in das Instruction Register geladen, addiert die Arithmetische- und Logikeinheit (ALU) die Inhalte der Register R0 und R1 und speichert das Ergebnis der Rechnung in R0. In fast allen Fällen werden bei solchen Operationen bestimmte Bits im Statusregister (SREG) gestellt. Ein weiterer wichtiger Speicherbereich ist der Arbeitsspeicher (SRAM) und der dort verwaltete Stack. Zudem werden die Hardware Register für die anderen Komponenten wie externe Ports und TIMER über Register angesprochen.
Das Datenblatt
Ich denke, am meisten lernt man, wenn man sich mit Hilfe des Datenblatt Schritt für Schritt ein eigenes Programm erarbeitet. Und da Hochsprachen wie C das meiste der Chip-Architektur hinter der Sprache und Bibliotheken verstecken, schlage ich vor, die ersten Versuche mit Assembler vorzunehmen. Rein theoretisch wäre es auch möglich, das Programm direkt in Maschinencode zu schreiben, denn im AVR Instruction Set Manual sind zu jedem Mnemonic und möglichen Operanden auch die Bitfolgen enthalten. Aber verliert man dann doch relativ schnell die Übersicht.
Assembler ist eigentlich ganz einfach: Zeile für Zeile wird ein Maschinebefehl mit Parametern aufgerufen, die entweder miteinander verknüpft werden (addieren, substrahieren) oder Änderungen in bestimmten Registern vornehmen. Es ist reine Fleißarbeit, die Parameter entsprechend vorzubereiten, da wichtig ist, wo sie gerade abgelegt wurden und wo das Ergebnis der Operation hingeschrieben wird. Also muss man sich den Befehlssatz des Prozessors ansehen und die Operationen genauer ansehen. All das verrät das Datenblatt.
Der Befehlssatz des ATtiny ist im Abschnitt „Instruction Set Summary“ des Datenblatts aufgelistet. Dabei wurden der Maschinencode jedoch schon in Assembler-Ausdrücke übersetzt, da der Maschinencode nicht direkt erstellt wird sondern eben über Assembler-Programme. Die Tabelle wird wie folgt gelesen: in der Spalte Mnemonic befinden sich die Instruktionsbezeichner, also die Befehle. Die Spalte Operands zeigt, welche und auf welche Weise die Operanden übergeben werden müssen. Description ist eine Erläuterung. In der Spalte Operation wird in Kurzform erklärt, was der Befehl mit den Operanden macht. Die komplette Beschreibung findet sich im AVR Instruction Set Manual.
Die Tabelle beginnt mit den Arithmetic and Logic Instructions. Der erste Befehl in diesem Abschnitt ist ADD. Rd und Rr sind Register aus dem General Purpose Register, also zwei der Register R0 bis R31. Das Ergebnis wird in das erste Register zurück geschrieben und überschreibt damit den ersten Summanden. Vorher muss jedoch dafür gesorgt werden, das sich in diesen Registern Werte zum addieren befinden. Das geschieht mit Datentransfer-Befehlen (Data Transfer Instructions), z.B. mit dem Befehl LDI, der eine Konstante in ein Register lädt. Der vollständige Aufruf für eine Berechnung lautet demnach:
LDI R16,100
LDI R17,20
ADD R16,R17
Nun befindet sich der Wert 120 im Register R16. Dieser Wert kann dann mit anderen Befehlen im Arbeitsspeicher, im Stack oder im Port einer Hardwarekomponente abgelegt werden. Das Datenblatt erklärt also, wie die Register, der Arbeitsspeicher oder die Hardware Ports manipuliert werden.
Allerdings steht nicht im Datenblatt, wie ein Programm eigentlich aufgebaut wird und wie es in den Programmspeicher kommt.
Der Programmaufbau
Ein Assembler-Programm folgt einem bestimmten Aufbau. Dazu zählt, dass die Chip-spezifischen Register-Bezeichnungen aus den Header-Dateien geladen werden, die Einsprungadressen für die Interrupts eingestellt werden und der Stack vorbereitet wird. Der folgende Code könnte als Vorlage für ein ATtiny-Assembler-Sourcecode sein:
;
; Template.asm
;
; Created: 14.04.2017 08:33:23
; Author :
; Header-Datei für den ATTtiny85 laden
.nolist
.include "tn85def.inc"
.list
; Dem Arbeitsregister einen Namen geben
.def rmp = R16
.cseg
; Macro für die Initiatlisierung des Stacks
.macro ISP
LDI @0, LOW(@1)
OUT SPL, @0
LDI @0, HIGH(@1)
OUT SPH, @0
.endmacro
; Einsprungadresse nach RESET
.org 0x0000
RJMP start
.org INT_VECTORS_SIZE
start:
ISP rmp, RAMEND ; initialisiere den Stack-Pointer
main:
RJMP main ; Endlosschleife
Das vollständige Assembler-Programm für den Timer-basierten Blinker – der obige Zahlenblock in Maschinensprache von 80 Byte – sieht dann so aus (Kommentare beginnen mit einem „;“):
;
; TimerBlink.asm
;
; Created: 14.04.2017 09:00:00
; Author : mnasa
;
.nolist ; Ausgabe ausschalten
.include "tn85def.inc" ; AVR Header-Datei laden
.list ; Ausgabe wieder anschalten
.def rmp = R16 ; ein Namen für ein Arbeitsregister
.cseg ; Start des Code-Segments
.macro ISP ; MACRO für die Initialisierung des Stackpointers
LDI @0, LOW(@1) ; unteres Byte von @1 in Register @0 laden
OUT SPL, @0 ; Register @0 in unteres Byte des Stackpointers
LDI @0, HIGH(@1) ; oberes Byte von @1 in Register @0 laden
OUT SPH, @0 ; Register @0 in oberes Byte des Stackpointers
.endmacro ; @0 und @1 sind erster und zweiter Parameter des Macros
.org 0x0000 ; Einsprungadresse nach RESET
RJMP start ; springe zum Label "start"
.org OC1Aaddr ; Einsprung nach Timer/Counter1 Compare Match 1A
RJMP oc1a_ISR ; Springe zum Label "oc1a_ISR"
.org INT_VECTORS_SIZE ; Hier hören die Interrupt-Einsprungadressen auf
.equ TIMERVAL = 0x40 ; Setze Konstante zwischen 0x00 und 0xFF (0x40 = 64 von 256)
start: ; Einsprung-Label
ISP rmp, RAMEND ; Initialisiere Stackpoiter mit Macro
LDI rmp, (1<<DDB3) ; baue Byte zum Setzen von DDB3 für output
OUT DDRB, rmp ; Setze DDB3 (Abschnitt 10.2.1 im Datenblatt)
LDI rmp, (1<<PB3) ; PB3 output HIGH
OUT PORTB, rmp ; Setze PB3 output auf HIGH (Abschn 10.2.1 im Datenblatt)
; Init Timer:
IN rmp, GTCCR ; Lese Register GTCCR ins Arbeitsregister
CBR rmp, (1<<PWM1B) | (1<<COM1B1) | (1<<COM1B0) ; Lösche Bits um
; Comparator B von Output Pin OC1B (= PB4) zu trennen
OUT GTCCR, rmp ; schreibe das Arbeitsregister nach GTCCR (Abschn 12.3.2 im Datenblatt)
IN rmp, PLLCSR ; Lese Register PLLCSR ins Arbeitsregister
CBR rmp, (1<<PCKE) ; Lösche Bits für externe Zeitgeber
OUT PLLCSR, rmp ; schreibe PLLCSR (Abschn 12.3.9 im Datenblatt)
IN rmp, TCCR1 ; Lese TCCR1 ins Arbeitsregister
CBR rmp, (1<<PWM1A) | (1<<COM1A1) | (1<<COM1A0) ; Lösche Bits, um
; Comparator A von Output Pin OC1A (= PB1) zu trennen
SBR rmp, (1<<CTC1) ; Setze Bit zum Zurücksetzen von Timer/Counter on Compare Match
ANDI rmp, 0xF0 ; Setze die letzen 4 Bit im Arbeitsregister auf 0
SBR rmp, (1<<CS13) | (1<<CS12) | (1<<CS11) | (1<<CS10) ; Setze Bits, um
; den Takt für den Timer einzustellen (Abschn 13.3.1 im Datenblatt)
; der Prescaler is dann CK/16384 = 0.5s bei 8 MHz CPU
OUT TCCR1, rmp ; schreibe Arbeitsregister nach TCCR1
; Set Timer Interrupt:
LDI rmp, (1<<OCIE1A) ; baue Byte für Timer/Counter1 Output Compare Interrupt
OUT TIMSK, rmp ; Schreibe Arbeitsregister nach TIMSK (Abschn 13.3.6 im Datenblatt)
LDI rmp, 0x01 ; schreibe 1 ins Arbeitsregister
OUT OCR1A, rmp ; Setze Timer/Counter1 Compare Register A auf 1
LDI rmp, TIMERVAL ; Lade TIMERVAL ins Arbeitsregister
OUT OCR1C, rmp ; Setze Time/Counter1 Compare Register C auf TIMERVAL
SEI ; Aktiviere Interrupts
IN rmp, GTCCR ; Lade Register GTCCR ins Arbeitsregister
CBR rmp, (1<<TSM) ; Setze Bit für Start von Timer0 (Abschn 11.9.1 im Datenblatt)
OUT GTCCR, rmp ; schreibe Arbeitsregister nach Register GTCCR
IN rmp, MCUCR ; Lade Register MCUCR ins Arbeitsregister
CBR rmp, (1<<SM1) | (1<<SM0) ; Lösche die Bits für andere SLEEP Modes
; und stelle so SLEEP MODE auf Idle (Abschn 7.5.1 im Datenblatt)
SBR rmp, (1<<SE) ; setze Bit fürs Aktiveren des SLEEP MODE
OUT MCUCR, rmp ; schreibe Arbeitsregister ins Register MCUCR
main: ; Label für Hauptprogramm
SLEEP ; CPU Schlafen legen bis von Interrupt geweckt und
; Interrupt-Routine ausgeführt wurde
RJMP main ; Zurück in den SLEEP MODE
oc1a_ISR: ; Einsprung-Label für OC1A Interrupt
LDI rmp, (1<<PINB3) ; Baue Byte mit einer 1 an Bit für PINB3
OUT PINB, rmp ; Schreibe Arbeitsregister ins Register PINB
RETI ; kehre aus Interrupt zum Programm zurück
Im nächsten Teil wird gezeigt, wie das Programm kompiliert wird und wie es in den ATtiny kommt.
Sehr schöne Seite mit überwiegend gut nachvollziehbaren Beschreibungen. Das dreizeilige Beispiel ist allerdings etwas unglücklich, da 100 + 200 = 300 > 255. Die Aussage „Nun befindet sich der Wert 300 im Register R16.“ stimmt also nicht. Das letzte Register ist auch nicht R32, sondern R31, es sind halt insgesamt 32 Register. Weiter oben war es richtig.
Richtig! Beispiel war komplett daneben. Wird korrigiert, Danke!