Eine Ganganzeige selbst bauen, Teil 2, Software
Dieser Teil der DIY-Serie hat den Fokus auf Software. Alle Beispiele basieren auf einen Atmel ATmega328 in klassischer C-Toolchain-Umgebung. Mit kleinen Anpassungen bei den Registernamen ist alles auch auf den anderen ATmega-Geschwistern (z.B. ATmega128) lauffähig.
Das Prinzip der Frequenzmessung
Zur Frequenzbestimmung wird die Zeit zwischen 2 ansteigenden Flanken des Eingangssignals gemessen.
Das Eingangssignal muss dafür auf einen Interrupt-fähigen Eingang gelegt werden (INT0 oder INT1). Die zugehörigen
Interrupt-Register werden für das Erkennen einer ansteigenden Flanke gesetzt.
In der Interrupt-Routine wird der Zählerstand eines freilaufenden Oszillators gespeichert. Der Zählerstand
ist die Anzahl der abgelaufenen Taktimpulsen. Der Kehrwert entspricht der gesuchten Signalfrequenz.
Vorbereitung der Register
Counter/Timer0 (8-Bit) wird als freilaufender Zähler mit Overflow-Interrupt konfiguriert. Die Clock-Rate beträgt
125kHz. Der Overflow-Counter ist eine künstliche Vergrösserung des Zählbereichs. Der 8-Bit Zahlenbereich wäre für
den gewählten Takt und die zu erwartenden Frequenzen etwas zu klein.
void timer0_init(void)
{ // 8-Bit Normal operation, Zähler für RPM-Intervall, Overflow-Interrupt aktiv
// Normal operation, clock internal prescaler 011 (= Faktor 64)
// Oszillatorfrequenz 8MHz, prescaler 64 -> Counter-Clock = 125kHz
// 8Bit-Counter Overflow nach 2ms (488 Hz)
TCCR0B = 0b00000011;
TCNT0 = 0; // Zählerstand auf 0 setzen
TIMSK0 |= (1 << TOIE0); // Overflow-Interrupt0 enabled
}
Die Befehle in der Overflow-Interrupt-Routine sind trivial, es wird nur eine Variable inkrementiert.
Der Overflow-Counter ist als uint16_t deklariert. In der Gesamtsumme ergibt sich damit ein maximaler Zählbereich
von 24 Bit (= 134 Sekunden). Das ist für alle praktischen Fälle ausreichend.
uint16_t Timer0_overflow; // Überlaufvariable, global deklariert
ISR(TIMER0_OVF_vect)
{ // Overflow 8 Bit Timer
if (Timer0_overflow < 0xFFFE) Timer0_overflow++;
}
Der externe Interrupt wird konfiguriert, auf Rising-Edge gesetzt und aktiviert.
void Interrupt0_init(void)
{ // External Interrupt0 aktivieren, RPM
EICRA |= (1 << ISC00)|(1 << ISC01);// Rising edge generates interrupt
EIMSK |= (1 << INT0); // Interrupt 0 set to active
}
Die wichtige Interrupt0-Routine. Sie wird bei einem Interrupt angesprungen. Hier wird der aktuelle Zählerstand
von Counter0 ausgelesen. Hinzu werden die aufgetretenen Überläufe multipliziert. Das Endergebnis findet sich
in der Variable "Ergebnis_RPM". Das Counter0-Zählregister wird nach dem Lesen zurückgesetzt.
uint32_t Ergebnis_RPM; // Messwert des Drehzahlsignals, global deklariert
ISR(INT0_vect)
{ // Interrupt Eingang 0, RPM, 8-Bit Counter0 Werte übernehmen
uint32_t dummy2; // Hilfsvariable
Ergebnis_RPM = TCNT0; // untere 8 Bit aus Counter lesen
TCNT0 = 0; // Counter zurücksetzen
dummy2 = Timer0_overflow; // 16 Bit vom Überlauf lesen
Timer0_overflow=0; // Überlauf zurücksetzen
dummy2 = (dummy2 << 8); // Überlauf um 8 Bit schieben, Platz machen für TCNT0
Ergebnis_RPM += dummy2; // Ergebnis Zusammenbauen
}
Alle vorhergehenden Programmblöcke müssen analog auch noch für die Geschwindigkeitsmessung erstellt werden. Diesmal mit/für:
- Counter/Timer2 (8-Bit)
- uint16_t Timer2_overflow
- INT1
- uint32_t Ergebnis_Speed
Die Interrupt-Routinen sind zeitkritisch. Sie müssen beendet sein bevor ein neuer Interrupt auftreten kann.
Für die Drehzahl wurde eine Frequenz von bis zu 4800 Hz angenommen (siehe Teil 1). Das entspricht 208 us oder
1666 Prozessortakten. Das klingt erstmal sehr bequem. Allerdings ist das Beispiel nicht vollständig, es fehlt
noch etwas "Beiwerk". Eventuell müssen noch Mittelwerte berechnet und diverse Bereichsüberprüfungen durchgeführt
werden.
Noch entscheidender ist die Response-Zeit für den zweiten (asynchron dazu) laufende Interrupt!
Je länger die Interruptbearbeitung dauert desto wahrscheinlicher ist ein verzögerter Start in der jeweils
anderen. Und damit ein falsch ausgelesener Zählerstand. Zum Glück ist die Statistik an dieser Stelle nicht
kritisch und eine Überlappung relativ selten.
Die Gangberechung
Sobald die Interrupt-Routinen ihre Frequenz-Ergebnisse geliefert haben kann die eigentliche Gangberechung beginnen.
Genaugenommen wurden die Periodendauern gemessen, also die Kehrwerte der Frequenzen. Aber das spielt
keine Rolle weil nur das Verhältnis der beiden Werte zueinander wichtig ist. Es wird daher zuerst der Quotient
aus beiden Messwerten bestimmt:
Quotient = (float)Ergebnis_RPM / (float)Ergebnis_Speed.
Dieser Quotientenwert ist für jeden Gang einmalig und konstant und unabhängig von der Drehzahl weil er ein
direktes Abbild der mechanischen Übersetzung ist. Als Beispiel die Werte für eine Yamaha FZS1000:
- Gang 1: 0,367
- Gang 2: 0,498
- Gang 3: 0,611
- Gang 4: 0,688
- Gang 5: 0,764
- Gang 6: 0,822
Die Messergebnisse wurden bewusst vorab in einen "Float" umgewandelt. Eine Integer-Division würde
wegen Rundungen zu hohe Abweichungen hervorrufen.
Der berechnete Quotient wird aus diversen Gründen nicht exakt mit dem Tabellenwert übereinstimmen.
Es muss daher noch ein Vergleich auf minimale Abweichung folgen. Der Tabellenplatz mit der kleinsten
Abweichung ist der gesuchte Gang :)
Q_MinPosition = 0; // Array-Position mit minimaler Differenz zum Messwert, default
Q_MinValue = 65000; // zugehöriger Differenzwert, default
lauf = 1; // Laufindex
while(lauf < 7) { // alle Quotienten der Tabelle durchlaufen
Q_Delta = (abs(Q_Gangtabelle[lauf]-Q_Messung)); // Differenzen zw Messung und Ziel-Werten berechnen
if (Q_Delta < Q_MinValue) { // Differenz kleiner als alter Wert?
Q_MinValue = Q_Delta; // kleinste Abweichung merken
Q_MinPosition = lauf; // Array-Index der kleinsten Abweichung merken
}
lauf++;
}
Der gesuchte Gang ist der finale Wert in Q_MinPosition. Die Entscheidungsschwelle liegt jeweils in der
Mitte zwischen zwei Q-Werten. Beispiel Gang 1 und Gang 2:
Q_[1] = 0,367
Q_[2] = 0,498
Mittelwert = 0,4325 = 0,367 + (0,498-0,367)/2
Um grobe Messfehler zu erkennen und um die Auswertung robuster zu machen kann man weitere Grenzwerte
(bzw "Pseudo"-Gangwerte) unterhalb des 1ten und oberhalb des 6ten Gangs hinzufügen. Die Werte sind geschätzt
und extrapoliert aus den nächstliegenden Abständen. Im Quotienten-Array werden sie einfach angehängt. Der
Algorithmus findet sie und setzt ein Fehler-Flag.
Q_[7] = 0,236 = 0,367 - (0,498 - 0,367); Fehler unterhalb 1ten Gang
Q_[8] = 0,880 = 0,822 + (0,822 - 0,764); Fehler oberhalb 6ter Gang
Sonstige Berechnungen und Feinheiten
Eine wichtige Neben-Funktion ist es zu erkennen ob die Interrupt-Impulse ausbleiben. Dafür kann der noch ungenutzte Counter/Timer1 eingesetzt werden. Er überprüft regelmässig ob die Overflow-Zähler im normalen Bereich liegen. Sobald die Zähler unplausibel hohe Werte enthalten wird ein Fehlerflag gesetzt.
void timer1_init(void)
{ // 16-Bit, OutputCompareRegister aktiv, testet auf ausbleibende Interrupt der Zähleingänge
TCCR1A = 0b00000000; // CTC Mode 4 clear on OCR1A
TCCR1B = 0b00001011; // CTC Mode 4 clear on OCR1A, clock internal prescaler
// Main-Clock = 8MHz; timer1-Clock = 125KHz (= 8MHz / 64)
OCR1AH = 0x30;
OCR1AL = 0x00; // OCR-Interrupt = 125kHz/12288 = 10Hz -> 0,1 Sekunden Raster
TCNT1H = 0;
TCNT1L = 0;
TIMSK1 |= (1 << OCIE1A); // Output Compare A Match Interrupt enabled
}
Das Fehler-Flag wird gesetzt sobald einer der Overflow-Counter einen Wert von 1000 überschreitet. Das bedeutet
es wurde während der letzten 2 Sekunden kein Interrupt ausgelösst. Das entsprechende Signal wird damit
als nicht vorhanden angenommen und es kann kein Gang-Wert berechnet werden.
ISR(TIMER1_COMPA_vect)
{ // Output compare, Fehlerflag setzen wenn keine Speed oder RPM-Signal vorhanden sind
SignalFailure = ((Timer0_overflow > 1000)||(Timer2_overflow > 1000));
}
Die Messergebnisse aus den Frequenzmessungen ändern sich permanent und sind typischerweise mit Störungen behaftet.
Um die Gangerkennung stabiler und robuster zu machen ist es daher angebracht mit gleitenden Mittelwerten zu arbeiten.
Einige Variablen sollten daher als Array deklariert werden und der Reihe nach (im Kreis) befüllt werden. Zu extensive
Mittelungen sind aber nicht hilfreich weil das dynamische Verhalten stark verschlechtert wird..
uint32_t Ergebnis_RPM[4]; // Messergebnisse des Drehzahlsignals, global deklariert
uint32_t Ergebnis_Speed[4]; // Messergebnisse des Geschwindigkeitssignals, global deklariert
float Quotient[8]; // Ergebnisse Quotientenberechung
Main
In der Hauptschleife des Programm passiert die meiste Zeit nichts. Es wird auf neue Ergebnisse von den beiden
Interrupts (INT0 und INT1) gewartet. Um das zu erkennen setzt man sich am besten ein paar Flags in die Interrupts.
Sobald zwei frische Ergebnisse vorhanden sind kann die Gangberechnung gestartet werden. Im Anschluss muss
der neue Gang "nur noch angezeigt werden".
Das Grundgerüst der Software ist damit erstellt. Es fehlen allerdings noch jede Menge Feinheiten um die Messungen
robuster zu machen. Und es wird nochmals deutlich umfangreicher wenn die Software universeller und für beliebige
Motorräder einsetzbar werden soll.