AVR-Tutorial: Uhr

Wechseln zu: Navigation, Suche

Eine beliebte Übung für jeden Programmierer ist die Implementierung einer Uhr. Die meisten Uhren bestehen aus einem Taktgeber und einer Auswerte- und Anzeigevorrichtung. Wir wollen hier beides mittels eines Programmes in einem Mikrocontroller realisieren. Voraussetzung für diese Fallstudie ist das Verständnis der Kapitel über

Aufbau und Funktion[Bearbeiten]

Die Aufgabe des Taktgebers, der uns einen möglichst konstanten und genauen Takt liefert, übernimmt ein Timer. Der Timer ermöglicht einen einfachen Zugang zum Takt, den der AVR vom Quarz abgreift. Wie schon im Einführungskapitel über den Timer beschrieben, wird dazu ein Timer Overflow Interrupt installiert, und in diesem Interrupt wird die eigentliche Uhr hochgezählt. Die Uhr besteht aus vier Registern. Drei davon repräsentieren die Sekunden, Minuten und Stunden unserer Uhr. Nach jeweils einer Sekunde wird das Sekundenregister um eins erhöht. Sind 60 Sekunden vergangen, wird das Sekundenregister wieder auf Null gesetzt und gleichzeitig das Minutenregister um eins erhöht. Dies ist ein Überlauf. Nach 60 Minuten werden die Minuten wieder auf Null gesetzt und für diese vergangenen 60 Minuten eine Stunde aufaddiert. Nach 24 Stunden schliesslich werden die Stunden wieder auf Null gesetzt, ein ganzer Tag ist vergangen.

Aber wozu das vierte Register?

Der Mikrocontroller wird mit 4 MHz betrieben. Bei einem Teiler von 1024 zählt der Timer also mit 4000000 / 1024 = 3906,25 Pulsen pro Sekunde. Der Timer muss einmal bis 256 zählen, bis er einen Überlauf auslöst. Es ereignen sich also 3906,25 / 256 = 15,2587 Überläufe pro Sekunde. Die Aufgabe des vierten Registers ist es nun, diese 15 Überläufe zu zählen. Bei Auftreten des 15. ist eine Sekunde vergangen. Dies stimmt jedoch nicht exakt, denn die Division weist ja auch Nachkommastellen auf, hat einen Rest, der hier im Moment der Einfachheit halber ignoriert wird. In einem späteren Abschnitt wird darauf noch eingegangen werden.

Im Überlauf-Interrupt wird also diese Kette von Zählvorgängen auf den Sekunden, Minuten und Stunden durchgeführt und anschliessend zur Anzeige gebracht. Dazu werden die in einem vorhergehenden Kapitel entwickelten LCD Funktionen benutzt.

Das erste Programm[Bearbeiten]

 
.include "m8def.inc"
 
.def temp1 = r16
.def temp2 = r17
.def temp3 = r18
.def flag  = r19
 
.def SubCount = r21
.def Sekunden = r22
.def Minuten  = r23
.def Stunden  = r24
 
.org 0x0000
        rjmp    main                ; Reset Handler
.org OVF0addr
        rjmp    timer0_overflow     ; Timer Overflow Handler
 
.include "lcd-routines.asm"
 
main:
        ldi     temp1, HIGH(RAMEND)
        out     SPH, temp1
        ldi     temp1, LOW(RAMEND)  ; Stackpointer initialisieren
        out     SPL, temp1
 
        rcall   lcd_init
        rcall   lcd_clear
 
 
        ldi     temp1, (1<<CS02) | (1<<CS00)   ; Teiler 1024
        out     TCCR0, temp1
 
        ldi     temp1, 1<<TOIE0     ; TOIE0: Interrupt bei Timer Overflow
        out     TIMSK, temp1
 
        clr     Minuten             ; Die Uhr auf 0 setzen
        clr     Sekunden
        clr     Stunden
        clr     SubCount
        clr     Flag                ; Merker löschen
 
        sei
 
loop:
        cpi     flag,0
        breq    loop                ; Flag im Interrupt gesetzt?
        ldi     flag,0              ; flag löschen
 
        rcall   lcd_clear           ; das LCD löschen
        mov     temp1, Stunden      ; und die Stunden ausgeben
        rcall   lcd_number
        ldi     temp1, ':'          ; zwischen Stunden und Minuten einen ':'
        rcall   lcd_data
        mov     temp1, Minuten      ; dann die Minuten ausgeben
        rcall   lcd_number
        ldi     temp1, ':'          ; und noch ein ':'
        rcall   lcd_data
        mov     temp1, Sekunden     ; und die Sekunden
        rcall   lcd_number
 
        rjmp    loop
 
timer0_overflow:                    ; Timer 0 Overflow Handler
 
        push    temp1               ; temp 1 sichern
        in      temp1,sreg          ; SREG sichern
        push    temp1
 
        inc     SubCount            ; Wenn dies nicht der 15. Interrupt
        cpi     SubCount, 15        ; ist, dann passiert gar nichts
        brne    end_isr
 
                                    ; Überlauf
        clr     SubCount            ; SubCount rücksetzen
        inc     Sekunden            ; plus 1 Sekunde
        cpi     Sekunden, 60        ; sind 60 Sekunden vergangen?
        brne    Ausgabe             ; wenn nicht kann die Ausgabe schon
                                    ; gemacht werden
 
                                    ; Überlauf
        clr     Sekunden            ; Sekunden wieder auf 0 und dafür
        inc     Minuten             ; plus 1 Minute
        cpi     Minuten, 60         ; sind 60 Minuten vergangen ?
        brne    Ausgabe             ; wenn nicht, -> Ausgabe
 
                                    ; Überlauf
        clr     Minuten             ; Minuten zurücksetzen und dafür
        inc     Stunden             ; plus 1 Stunde
        cpi     Stunden, 24         ; nach 24 Stunden, die Stundenanzeige
        brne    Ausgabe             ; wieder zurücksetzen
 
                                    ; Überlauf
        clr     Stunden             ; Stunden rücksetzen
 
Ausgabe:
        ldi     flag,1              ; Flag setzen, LCD updaten
 
end_isr:
 
        pop     temp1
        out     sreg,temp1          ; sreg wieder herstellen
        pop     temp1
        reti                        ; das wars. Interrupt ist fertig
 
; Eine Zahl aus dem Register temp1 ausgeben
 
lcd_number:
        push    temp2               ; register sichern,
                                    ; wird für Zwsichenergebnisse gebraucht     
        ldi     temp2, '0'         
lcd_number_10:                
        subi    temp1, 10           ; abzählen wieviele Zehner in
        brcs    lcd_number_1        ; der Zahl enthalten sind
        inc     temp2
        rjmp    lcd_number_10
lcd_number_1:
        push    temp1               ; den Rest sichern (http://www.mikrocontroller.net/topic/172026)
        mov     temp1,temp2         ; 
        rcall   lcd_data            ; die Zehnerstelle ausgeben
        pop     temp1               ; den Rest wiederherstellen
        subi    temp1, -10          ; 10 wieder dazuzählen, da die
                                    ; vorhergehende Schleife 10 zuviel
                                    ; abgezogen hat
                                    ; das Subtrahieren von -10
                                    ; = Addition von +10 ist ein Trick
                                    ; da kein addi Befehl existiert
        ldi     temp2, '0'          ; die übrig gebliebenen Einer
        add     temp1, temp2        ; noch ausgeben
        rcall   lcd_data
 
        pop     temp2               ; Register wieder herstellen
        ret

In der ISR wird nur die Zeit in den Registern neu berechnet, die Ausgabe auf das LCD erfolgt in der Hauptschleife. Das ist notwendig, da die LCD-Ausgabe bisweilen sehr lange dauern kann. Wenn sie länger als ~2/15 Sekunden dauert werden Timerinterrupts "verschluckt" und unsere Uhr geht noch mehr falsch. Dadurch, dass aber die Ausgabe in der Hauptschleife durchgeführt wird, welche jederzeit durch einen Timerinterrupt unterbrochen werden kann, werden keine Timerinterrupts verschluckt. Das ist vor allem wichtig, wenn mit höheren Interruptfrequenzen gearbeitet wird, z. B. 1/100s im Beispiel weiter unten. Auch wenn in diesem einfachen Beispiel die Ausgabe bei weitem nicht 2/15 Sekunden dauert, sollte man sich diesen Programmierstil allgemein angewöhnen. Siehe auch Interrupt.

Eine weitere Besonderheit ist das Register flag (=r19). Dieses Register fungiert als Anzeiger, wie eine Flagge, daher auch der Name. In der ISR wird dieses Register auf 1 gesetzt. Die Hauptschleife, also alles zwischen loop und rjmp loop, prüft dieses Flag und nur dann, wenn das Flag auf 1 steht, wird die LCD Ausgabe gemacht und das Flag wieder auf 0 zurückgesetzt. Auf diese Art wird nur dann Rechenzeit für die LCD Ausgabe verbraucht, wenn dies tatsächlich notwendig ist. Die ISR teilt mit dem Flag der Hauptschleife mit, dass eine bestimmte Aufgabe, nämlich der Update der Anzeige gemacht werden muss und die Hauptschleife reagiert darauf bei nächster Gelegenheit, indem sie diese Aufgabe ausführt und setzt das Flag zurück. Solche Flags werden daher auch Job-Flags genannt, weil durch ihr setzten das Abarbeiten eines Jobs (einer Aufgabe) angestoßen wird. Auch hier gilt wieder: Im Grunde würde man in diesem speziellen Beispiel kein Job-Flag benötigen, weil es in der Hauptschleife nur einen einzigen möglichen Job, die Neuausgabe der Uhrzeit, gibt. Sobald aber Programme komplizierter werden und mehrere Jobs möglich sind, sind Job-Flags eine gute Möglichkeit, ein Programm so zu organsieren, dass bestimmte Dinge nur dann gemacht werden, wenn sie notwendig sind.

Im Moment gibt es keine Möglichkeit, die Uhr auf eine bestimmte Uhrzeit einzustellen. Um dies tun zu können, müssten noch zusätzlich Taster an den Mikrocontroller angeschlossen werden, mit deren Hilfe die Sekunden, Minuten und Stunden händisch vergrößert bzw. verkleinert werden können. Studieren Sie mal die Bedienungsanleitung einer käuflichen Digitaluhr und versuchen sie zu beschreiben, wie dieser Stellvorgang bei dieser Uhr vor sich geht. Sicherlich werden Sie daraus eine Idee entwickeln können, wie ein derartiges Stellen mit der hier vorgestellten Digitaluhr funktionieren könnte. Als Zwischenlösung kann man im Programm die Uhr beim Start anstelle von 00:00:00 z. B. auch auf 20:00:00 stellen und exakt mit dem Start der Tagesschau starten. Wobei der Start der Tagesschau verzögert bei uns ankommt, je nach Übertragung können das mehrere Sekunden sein.

Ganggenauigkeit[Bearbeiten]

Wird die Uhr mit einer gekauften Uhr verglichen, so stellt man schnell fest, dass sie ganz schön ungenau geht. Sie geht vor! Woran liegt das? Die Berechnung sieht so aus:

  • Frequenz des Quarzes: 4.0 MHz
  • Vorteiler des Timers: 1024
  • Überlauf alle 256 Timertakte

Daraus errechnet sich, dass in einer Sekunde 4000000 / 1024 / 256 = 15.258789 Overflow Interrupts auftreten. Im Programm wird aber bereits nach 15 Overflows eine Sekunde weitergeschaltet, daher geht die Uhr vor. Rechnen wir etwas:

F_r = (\frac {15}{15,258789}-1) \cdot 100% = -1,69%

So wie bisher läuft die Uhr also rund 1.7 % zu schnell. In einer Minute ist das immerhin etwas mehr als eine Sekunde. Im Grunde ist das ein ähnliches Problem wie mit unserer Jahreslänge. Ein Jahr dauert nicht exakt 365 Tage, sondern in etwa einen viertel Tag länger. Die Lösung, die im Kalender dafür gemacht wurde - der Schalttag -, könnte man fast direkt übernehmen. Nach 3 Stück 15er Overflow Sekunden folgt eine Sekunde für die 16 Overflows ablaufen müssen. Wie sieht die Rechnung bei einem 15, 15, 15, 16 Schema aus? Für 4 Sekunden werden exakt 15.258789 * 4 = 61,035156 Overflow Interrupts benötigt. Mit einem 15, 15, 15, 16 Schema werden in 4 Sekunden genau 61 Overflow Interrupts durchgeführt. Der relative Fehler beträgt dann

F_r = (\frac {61}{61,035156}-1) \cdot 100% = -0,0575%

Mit diesem Schema ist der Fehler beträchtlich gesunken. Nur noch 0.06%. Bei dieser Rate muss die Uhr immerhin etwas länger als 0,5 Stunden laufen, bis der Fehler auf eine Sekunde angewachsen ist. Das sind aber immer noch 48 Sekunden pro Tag bzw. 1488 Sekunden (=24,8 Minuten) pro Monat. So schlecht sind nicht mal billige mechanische Uhren!

Jetzt könnte man natürlich noch weiter gehen und immer kompliziertere "Schalt-Overflow"-Schemata austüfteln und damit die Genauigkeit näher an 100% bringen. Aber gibt es noch andere Möglichkeiten?

Im ersten Ansatz wurde ein Vorteiler von 1024 eingesetzt. Was passiert bei einem anderen Vorteiler? Nehmen wir mal einen Vorteiler von 64. Das heißt, es müssen ( 4000000 / 64 ) / 256 = 244.140625 Overflows auflaufen, bis 1 Sekunde vergangen ist. Wenn also 244 Overflows gezählt werden, dann beläuft sich der Fehler auf

F_r = (\frac {244}{244,140625}-1) \cdot 100% = -0,0576%

Nicht schlecht. Nur durch Verändern von 2 Zahlenwerten im Programm (Teilerfaktor und Anzahl der Overflow Interrupts bis zu einer Sekunden) kann die Genauigkeit gegenüber dem ursprünglichen Overflow-Schema beträchtlich gesteigert werden. Aber geht das noch besser? Ja das geht. Allerdings nicht mit dem Overflow Interrupt.

Der CTC-Modus des Timers[Bearbeiten]

Worin liegt denn das eigentliche Problem, mit dem die Uhr zu kämpfen hat? Das Problem liegt darin, dass jedesmal ein kompletter Timerzyklus bis zum Overflow abgewartet werden muss, um darauf zu reagieren. Da aber nur jeweils ganzzahlige Overflowzyklen abgezählt werden können, heißt das auch, dass im ersten Fall nur in Vielfachen von 1024 * 256 = 262144 Takten operiert werden kann, während im letzten Fall immerhin schon eine Granulierung von 64 * 256 = 16384 Takten erreicht wird. Aber offensichtlich ist das nicht genau genug. Bei 4 MHz entsprechen 262144 Takte bereits einem Zeitraum von 65,5ms, während 16384 Takte einem Zeitbedarf von 4,096ms entsprechen. Beide Zahlen teilen aber 1000ms nicht ganzzahlig. Und daraus entsteht der Fehler. Angestrebt wird ein Timer, der seinen Overflow so erreicht, dass sich ein ganzzahliger Teiler von 1 Sekunde einstellt. Dazu müsste man dem Timer aber vorschreiben können, bei welchem Zählerstand der Overflow erfolgen soll. Und genau dies ist im CTC-Modus, allerdings nur beim Timer 1, möglich. CTC bedeutet "Clear Timer on Compare match".

Timer 1, ein 16-Bit-Timer, wird mit einem Vorteiler von 1 betrieben. Dadurch wird erreicht, dass der Timer mit höchster Zeitauflösung arbeiten kann. Bei jedem Ticken des Systemtaktes von 4 MHz wird auch der Timer um 1 erhöht. Zusätzlich wird noch das WGM12-Bit bei der Konfiguration gesetzt. Dadurch wird der Timer in den CTC-Modus gesetzt. Dabei wird der Inhalt des Timers hardwaremäßig mit dem Inhalt des OCR1A-Registers verglichen. Stimmen beide überein, so wird der Timer auf 0 zurückgesetzt und im nächsten Taktzyklus ein OCIE1A-Interrupt ausgelöst. Dadurch ist es möglich, exakt die Anzahl an Taktzyklen festzulegen, die von einem Interrupt zum nächsten vergehen sollen. Das Compare Register OCR1A wird mit dem Wert 39999 vorbelegt. Dadurch vergehen exakt 40000 Taktzyklen von einem Compare-Interrupt zum nächsten. "Zufällig" ist dieser Wert so gewählt, dass bei einem Systemtakt von 4 MHz von einem Interrupt zum nächsten genau 1/100 Sekunde vergeht, denn 40000 / 4000000 = 0.01. Bei einem möglichen Umbau der Uhr zu einer Stoppuhr könnte sich das als nützlich erweisen. Im Interrupt wird das Hilfsregister SubCount bis 100 hochgezählt und nach 100 Interrupts kommt wieder die Sekundenweiterschaltung wie oben in Gang.

 
.include "m8def.inc"
 
.def temp1 = r16
.def temp2 = r17
.def temp3 = r18
.def Flag  = r19
 
.def SubCount = r21
.def Sekunden = r22
.def Minuten  = r23
.def Stunden  = r24
 
.org 0x0000
           rjmp    main             ; Reset Handler
.org OC1Aaddr
           rjmp    timer1_compare   ; Timer Compare Handler
 
.include "lcd-routines.asm"
 
main:
        ldi     temp1, HIGH(RAMEND)
        out     SPH, temp1
        ldi     temp1, LOW(RAMEND)  ; Stackpointer initialisieren
        out     SPL, temp1
 
        rcall   lcd_init
        rcall   lcd_clear
 
                                    ; Vergleichswert 
        ldi     temp1, high( 40000 - 1 )
        out     OCR1AH, temp1
        ldi     temp1, low( 40000 - 1 )
        out     OCR1AL, temp1
                                    ; CTC Modus einschalten
                                    ; Vorteiler auf 1
        ldi     temp1, ( 1 << WGM12 ) | ( 1 << CS10 )
        out     TCCR1B, temp1
 
        ldi     temp1, 1 << OCIE1A  ; OCIE1A: Interrupt bei Timer Compare
        out     TIMSK, temp1
 
        clr     Minuten             ; Die Uhr auf 0 setzen
        clr     Sekunden
        clr     Stunden
        clr     SubCount
        clr     Flag                ; Flag löschen
 
        sei
loop:
        cpi     flag,0
        breq    loop                ; Flag im Interrupt gesetzt?
        ldi     flag,0              ; Flag löschen
 
        rcall   lcd_clear           ; das LCD löschen
        mov     temp1, Stunden      ; und die Stunden ausgeben
        rcall   lcd_number
        ldi     temp1, ':'          ; zwischen Stunden und Minuten einen ':'
        rcall   lcd_data
        mov     temp1, Minuten      ; dann die Minuten ausgeben
        rcall   lcd_number
        ldi     temp1, ':'          ; und noch ein ':'
        rcall   lcd_data
        mov     temp1, Sekunden     ; und die Sekunden
        rcall   lcd_number
 
        rjmp    loop
 
timer1_compare:                     ; Timer 1 Output Compare Handler
 
        push    temp1               ; temp 1 sichern
        in      temp1,sreg          ; SREG sichern
 
        inc     SubCount            ; Wenn dies nicht der 100. Interrupt
        cpi     SubCount, 100       ; ist, dann passiert gar nichts
        brne    end_isr
 
                                    ; Überlauf
        clr     SubCount            ; SubCount rücksetzen
        inc     Sekunden            ; plus 1 Sekunde
        cpi     Sekunden, 60        ; sind 60 Sekunden vergangen?
        brne    Ausgabe             ; wenn nicht kann die Ausgabe schon
                                    ; gemacht werden
 
                                    ; Überlauf
        clr     Sekunden            ; Sekunden wieder auf 0 und dafür
        inc     Minuten             ; plus 1 Minute
        cpi     Minuten, 60         ; sind 60 Minuten vergangen ?
        brne    Ausgabe             ; wenn nicht, -> Ausgabe
 
                                    ; Überlauf
        clr     Minuten             ; Minuten zurücksetzen und dafür
        inc     Stunden             ; plus 1 Stunde
        cpi     Stunden, 24         ; nach 24 Stunden, die Stundenanzeige
        brne    Ausgabe             ; wieder zurücksetzen
 
                                    ; Überlauf
        clr     Stunden             ; Stunden rücksetzen
 
Ausgabe:
        ldi     flag,1              ; Flag setzen, LCD updaten
 
end_isr:
 
        out     sreg,temp1          ; sreg wieder herstellen
        pop     temp1
        reti                        ; das wars. Interrupt ist fertig
 
; Eine Zahl aus dem Register temp1 ausgeben
 
lcd_number:
        push    temp2               ; register sichern,
                                    ; wird für Zwsichenergebnisse gebraucht     
        ldi     temp2, '0'         
lcd_number_10:                
        subi    temp1, 10           ; abzählen wieviele Zehner in
        brcs    lcd_number_1        ; der Zahl enthalten sind
        inc     temp2
        rjmp    lcd_number_10
lcd_number_1:
        push    temp1               ; den Rest sichern (http://www.mikrocontroller.net/topic/172026)
        mov     temp1,temp2         ; 
        rcall   lcd_data            ; die Zehnerstelle ausgeben
        pop     temp1               ; den Rest wieder holen
        subi    temp1, -10          ; 10 wieder dazuzählen, da die
                                    ; vorhergehende Schleife 10 zuviel
                                    ; abgezogen hat
                                    ; das Subtrahieren von -10
                                    ; = Addition von +10 ist ein Trick
                                    ; da kein addi Befehl existiert
        ldi     temp2, '0'          ; die übrig gebliebenen Einer
        add     temp1, temp2        ; noch ausgeben
        rcall   lcd_data
 
        pop     temp2               ; Register wieder herstellen
        ret

In der Interrupt-Routine werden wieder, genauso wie vorher, die Anzahl der Interrupt-Aufrufe gezählt. Beim 100. Aufruf sind daher 40.000 * 100 = 4.000.000 Takte vergangen und da der Quarz mit 4.000.000 Schwingungen in der Sekunde arbeitet, ist daher eine Sekunde vergangen. Sie wird genauso wie vorher registriert und die Uhr entsprechend hochgezählt. Wird jetzt die Uhr mit einer kommerziellen verglichen, dann fällt nach einiger Zeit auf ... Sie geht immer noch falsch! Was ist jetzt die Ursache? Die Ursache liegt in einem Problem, das nicht direkt behebbar ist. Am Quarz! Auch wenn auf dem Quarz drauf steht, dass er eine Frequenz von 4MHz hat, so stimmt das nicht exakt. Auch Quarze haben Fertigungstoleranzen und verändern ihre Frequenz mit der Temperatur. Typisch liegt die Fertigungstoleranz bei +/- 100ppm = 0,01% (parts per million, Millionstel Teile), die Temperaturdrift zwischen -40 Grad und 85 Grad liegt je nach Typ in der selben Größenordnung. Das bedeutet, dass die Uhr pro Monat um bis zu 268 Sekunden (~4 1/2 Minuten) falsch gehen kann. Diese Einflüsse auf die Quarzfrequenz sind aber messbar und per Hardware oder Software behebbar. In Uhren kommen normalerweise genauer gefertigte Uhrenquarze zum Einsatz, die vom Uhrmacher auch noch auf die exakte Frequenz abgeglichen werden (mittels Kondensatoren und Frequenzzähler). Ein Profi verwendet einen sehr genauen Frequenzzähler, womit er innerhalb weniger Sekunden die Frequenz sehr genau messen kann. Als Hobbybastler kann man die Uhr eine zeitlang (Tage, Wochen) laufen lassen und die Abweichung feststellen (z. B. exakt 20:00 Uhr zum Start der Tagsschau). Aus dieser Abweichung lässt sich dann errechnen, wie schnell der Quarz wirklich schwingt. Und da dank CTC die Messperiode taktgenau eingestellt werden kann, ist es möglich, diesen Frequenzfehler auszugleichen. Der genaue Vorgang ist in dem Wikiartikel AVR - Die genaue Sekunde / RTC beschrieben.