AVR-Tutorial: Uhr

Aus der Mikrocontroller.net Artikelsammlung, mit Beiträgen verschiedener Autoren (siehe Versionsgeschichte)
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

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 schließlich 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 1.024 zählt der Timer also mit 4.000.000 / 1.024 = 3.906,25 Pulsen pro Sekunde. Der Timer muss einmal bis 256 zählen, bis er einen Überlauf auslöst. Es ereignen sich also 3.906,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 anschließend zur Anzeige gebracht. Dazu werden die in einem vorhergehenden Kapitel entwickelten LCD-Funktionen benutzt.

Das erste Programm

.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) ; Stackpointer initialisieren
        out     SPH, temp1
        ldi     temp1, LOW(RAMEND)
        out     SPL, temp1

        ldi temp1, 0xFF             ; Port D = Ausgang
        out DDRD, temp1 

        rcall   lcd_init
        rcall   lcd_clear

        ldi     temp1, (1<<CS02) | (1<<CS00) ; Teiler 1024
        out     TCCR0, temp1

        ldi     temp1, 1<<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               ; temp1 sichern
        in      temp1, sreg         ; SREG sichern

        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, sonst -> Ausgabe.

                                    ; Überlauf
        clr     Stunden             ; Stunden rücksetzen

Ausgabe:
        ldi     flag, 1             ; Flag setzen, LCD updaten

end_isr:
        out     sreg, temp1         ; SREG wiederherstellen
        pop     temp1               ; temp1 wiederherstellen
        reti                        ; Das war's. Interrupt ist fertig.

; Eine Zahl aus dem Register temp1 ausgeben

lcd_number:
        push    temp2               ; Register temp2 sichern,
                                    ; wird für Zwischenergebnisse 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 (https://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 wiederherstellen
        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/100 s 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 das 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 Setzen 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 organisieren, dass bestimmte Dinge nur dann getan 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

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 4.000.000 / 1.024 / 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:

[math]\displaystyle{ F_r = \left(\frac{15}{15{,}258789} - 1\right) \cdot 100\,\% = -1{,}69\,\% }[/math]

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

[math]\displaystyle{ F_r = \left(\frac{61}{61{,}035156} - 1\right) \cdot 100\,\% = -0{,}0575\,\% }[/math].

Mit diesem Schema ist der Fehler beträchtlich gesunken. Nur noch 0,06 %. Bei dieser Rate muss die Uhr immerhin etwas länger als ½ Stunde 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 1.024 eingesetzt. Was passiert bei einem anderen Vorteiler? Nehmen wir mal einen Vorteiler von 64. Das heißt, es müssen (4.000.000 / 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

[math]\displaystyle{ F_r = \left(\frac{244}{244{,}140625} - 1\right) \cdot 100\,\% = -0{,}0576\,\% }[/math].

Nicht schlecht. Nur durch Verändern von zwei Zahlenwerten im Programm (Teilerfaktor und Anzahl der Overflow-Interrupts bis zu einer Sekunde) 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

Worin liegt denn das eigentliche Problem, mit dem die Uhr zu kämpfen hat? Es 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 = 262.144 Takten operiert werden kann, während im letzten Fall immerhin schon eine Granulierung von 64 · 256 = 16.384 Takten erreicht wird. Aber offensichtlich ist das nicht genau genug. Bei 4 MHz entsprechen 262.144 Takte bereits einem Zeitraum von 65,5 ms, während 16.384 Takte einem Zeitbedarf von 4,096 ms entsprechen. Beide Zahlen teilen aber 1.000 ms nicht ganzzahlig, Nachkommareste fallen unter den Tisch und daraus summiert sich der Fehler auf. Angestrebt wird ein Timer, der seinen „Overflow“ so erreicht, dass sich ein ganzzahliger Teiler von einer Sekunde einstellt. Dann gibt es keinen Rest. 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 möglich, allerdings nur beim Timer 1. 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 39.999 vorbelegt. Dadurch vergehen exakt 40.000 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 40.000 / 4.000.000 = 0,01. Bei einem möglichen Umbau der Uhr zu einer Stoppuhr könnte sich die Hundertstelsekunde 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) ; Stackpointer initialisieren
        out     SPH, temp1
        ldi     temp1, LOW(RAMEND)
        out     SPL, temp1

        ldi temp1, 0xFF             ; Port D = Ausgang
        out DDRD, 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  ; Interrupt bei Timer Compare Match
        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               ; temp1 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, sonst -> Ausgabe.

                                    ; Überlauf
        clr     Stunden             ; Stunden rücksetzen

Ausgabe:
        ldi     flag, 1             ; Flag setzen, LCD updaten

end_isr:
        out     sreg, temp1         ; SREG wiederherstellen
        pop     temp1               ; temp1 wiederherstellen
        reti                        ; Das war's. Interrupt ist fertig.

; Eine Zahl aus dem Register temp1 ausgeben

lcd_number:
        push    temp2               ; Register temp2 sichern,
                                    ; wird für Zwischenergebnisse 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 wiederherstellen
        ret

In der Interrupt-Routine wird 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 somit 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 4 MHz hat, so stimmt das nicht exakt. Auch Quarze haben Fertigungstoleranzen und verändern ihre Frequenz mit der Temperatur. Typisch liegt die Fertigungstoleranz bei ±100 ppm = 0,01 % („ppm“ = parts per million, Millionstel Teile), die Temperaturdrift zwischen −40 und 85 °C liegt je nach Typ in der selben Größenordnung. Das bedeutet, dass die Uhr pro Monat um bis zu 268 Sekunden (≈ 4½ 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 Tagesschau). 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.