Ambient Light im Eigenbau

7. Firmware der MCU

Nachdem wir uns also für eine Software-PWM entschieden haben, müssen wir uns zu guter Letzt um die Realisierung in der Firmware der MCU kümmern. Vorwegschicken möchte ich, dass mir der Artikel Soft-PWM auf www.mikrocontroller.net entscheidende Anregungen für den nun vorliegenden, an Optimalität grenzenden Code gegeben hat. Zum Entwickeln habe ich AVR-Studio und zum Kompilieren WinAVR verwendet.

7.1 Übersicht, Tasks

Zunächst eine Übersicht über die einzelnen Tasks auf der MCU. Im Prinzip haben wir drei Komponenten:

  1. Hauptprogramm
  2. Abarbeiten der Daten der USART-Schnittstelle (Interrupt)
  3. PWM-Signale erzeugen (Interrupt)

Nachdem wir an der MCU nur Tasten im Polling-Betrieb abfragen, nicht aber USART oder Timer-Tasks, habe ich bereits vorweggenommen, dass diese beiden Tasks im Interrupt-Betrieb laufen werden, also nur dann ausgeführt werden, wenn auch wirklich etwas zu tun ist (z.B. wenn der USART ein Byte fertig empfangen hat).

Im Hauptprogramm dagegen wird bis auf die einmalige Initialisierung der Register zunächst einmal nichts getan, wir werden uns das später aber noch genauer ansehen.

7.2 Konvertierung der seriellen Daten / Gammakorrektur

Zunächst muss die Software die Daten vom PC entgegennehmen, und, wie wir schon wissen, irgendwie „gamma“-korrigieren. Der PC liefert pro Kanal 8 Bit (256 Helligkeitsstufen). Würden wir nun einfach eine PWM mit nur 256 Stufen implementieren und einfach die Werte des PC 1:1 auf die Anzahl der Schritte der PWM „mappen“, so wäre ein linearer Helligkeitsanstieg auf Seiten des PC am Controller mit hefitgen Spüngen bei den ersten 50 Werten verbunden, danach aber sähen wir keinen großen Unterschied mehr. Mit Linearität hat das offenbar nichts zu tun.

Wir wissen also, dass die Lösung in mehr Schritten pro PWM-Zyklus, ergo weniger langen Einzelschritten besteht. Aufgrund der Hardware-Limitierungen von 14 Mhz können wir 1024 Schritte bei 75 Hz gerade noch so verarbeiten. Für das folgende gilt also eine PWM-Auflösung von 10bit, 1024 Schritte.

Der erste Schritt besteht darin, die Daten des PC auf die mehr möglichen Werte der PWM umzusetzen, einfacher ausgedrückt: Für jedes der 256 möglichen Bytes vom PC ist ein Mapping auf einen der 1024 Schritte zu finden; nach Lektüre der Gamma-Korrektur kennen wir auch die Formel, um den einzelnen Schritt auszurechnen:

Formel 1:
v(i) = \text{round}( (\frac{i}{\text{schritteEingang}})^{\text{Gamma}} * \text{schrittePWM})

Mit unseren Werten:
v(i) = \text{round}( (\frac{i}{255})^{2{,}2} * {1023})

Wir rechnen diese Tabelle aus und können sie als unsigned int Array im Flash-Speicher ablegen. Von dort aus läßt sie sich ganz einfach in C aufrufen:

Gamma-Tabelle und Aufruf in C
const unsigned int pwmGammaValues22[256] = {
  0, 0, 0, 0, 0, 0, 0, 0, 1, 1,
  1, 1, 1, 2, 2, 2, 3, 3, 3, 4,
  4, 5, 5, 6, 6, 7, 7, 8, 8, 9,
  10, 11, 11, 12, 13, 14, 15, 15, 16, 17,
  18, 19, 20, 21, 22, 23, 25, 26, 27, 28,
  29, 31, 32, 33, 35, 36, 38, 39, 41, 42,
  44, 45, 47, 48, 50, 52, 54, 55, 57, 59,
  61, 63, 65, 67, 69, 71, 73, 75, 77, 79,
  81, 84, 86, 88, 90, 93, 95, 98, 100, 103,
  105, 108, 110, 113, 116, 118, 121, 124, 127, 129,
  132, 135, 138, 141, 144, 147, 150, 153, 156, 160,
  163, 166, 169, 173, 176, 179, 183, 186, 190, 193,
  197, 200, 204, 208, 211, 215, 219, 223, 226, 230,
  234, 238, 242, 246, 250, 254, 259, 263, 267, 271,
  275, 280, 284, 288, 293, 297, 302, 306, 311, 316,
  320, 325, 330, 334, 339, 344, 349, 354, 359, 364,
  369, 374, 379, 384, 389, 394, 400, 405, 410, 416,
  421, 426, 432, 437, 443, 449, 454, 460, 466, 471,
  477, 483, 489, 495, 501, 507, 513, 519, 525, 531,
  537, 543, 550, 556, 562, 568, 575, 581, 588, 594,
  601, 607, 614, 621, 627, 634, 641, 648, 655, 662,
  669, 676, 683, 690, 697, 704, 711, 718, 726, 733,
  740, 748, 755, 763, 770, 778, 785, 793, 801, 808,
  816, 824, 832, 840, 847, 855, 863, 871, 879, 888,
  896, 904, 912, 920, 929, 937, 946, 954, 962, 971,
  980, 988, 997, 1005, 1014, 1023
};

// [...]
neuer_PWM_Wert_fuer_Kanal = pwmGammaValues22[USART_Byte];

7.3 Prinzip von Software PWM – Teil 1

Die MCU muss dazu gebracht werden, die Port-Pins im richtigen Moment ein- und auszuschalten. Nun können wir uns bereits ein erstes Konzept überlegen:

Alle Pins werden am Anfang des PWM-Zyklus eingeschaltet und bei Erreichen der ensprechenden Helligkeit des Kanals (0-1023) wieder ausgeschaltet. Es ist also ein Timer zu programmieren, der bei 75Hz PWM-Frequenz nach folgender Formel:

t_{\text{PWM}} = \frac{1}{\text{PWM-Frequenz} * \text{PWM-Aufloesung}} = \frac{1}{75 \tfrac{1}{s} * 1024} = 1{,}30208 * 10^{-5} s \cong 13{,}02 {\,\mu s}

genau alle ~13 µs einen Interrupt generiert. In der Interrupt-Service-Routine (ISR) wird jeder Kanal getestet, ob er schon ausgeschaltet werden soll, und das entsprechende Port-Bit auf Null gesetzt. Ein Pseudo-C-Code könnte also so aussehen:

Pseudo C Code für Soft-PWM
//include Section
#include "avr/mega16.h"

const unsigned int pwmGammaValues22[256] = { /* Siehe oben */ };
unsigned int kanalHelligkeitsWerte[9];
static unsigned int loopCounter;
static unsigned char byteCounter;

//ISR
void TimerISR() {
  unsigned char i;
  if (loopCounter==0) { enableAllPwmPortPins(); }
  for (i=0;i<9;i++) {
    if (kanalHelligkeitsWerte[i]==loopCounter) {
      disablePortPinForChannel(i);
    }
  }
  if (loopCounter==1023) { loopCounter=0; }
}

void UsartISR() {
  if (byteCounter==9) { byteCounter = 0; }
  kanalHelligkeitsWerte[byteCounter] = pwmGammaValues22[USART_Byte];
  byteCounter++;
}

// Main program
void main() {
  //Setze Register-Werte für USARt, Timer, Watchdog, usw.
  initMCU();

  // leeres Hauptprogramm
  while (1) { }
}

Diese Lösung hat mehrere gravierende Nachteile:

  • Die meisten der 1024 Interrupts pro PWM-Zyklus sind überflüssig, weil kein Kanal geändert werden soll (wir haben ja nur maximal neun Änderungen pro Zyklus, da neun Kanäle). Die MCU läuft somit mindestens 1014 mal pro Zyklus und 1014*75 = 76050 mal pro Sekunde in eine sinnlose ISR. Im Grunde würde das nichts machen, wenn nur der eine Task des PWM-Erzeugens notwendig wäre; leider müssen wir aber noch mit der Außenwelt über USART kommunizieren, sonst würden wir keine neuen Farben vom PC bekommen.
  • Kommt mitten im Frame der PWM (z.B. bei Schritt 512) ein Update für einen Kanal, so wird der neue Wert direkt übernommen; äußerst unschönes Farbenflackern ist das Ergebnis. Hardware-PWM (z.B. MCU Timer) haben deswegen eine sogenannte doppelte Pufferung („double buffering“): Der neue Wert wird erst zum Anfang des nächsten PWM-Zyklus übernommen; der Arbeitswert kann dazwischen nicht geändert werden.
  • Die TimerISR erledigt bei jedem Aufruf die Prüfung der zu schaltenden Kanäle. Extreme Verschwendung von CPU-Zeit: Diese Information ist für jeden PWM-Zyklus ja von Anfang an bekannt.
  • Synchronisation: Wenn nur ein einziges Byte irgendwann vom USART verlorengeht, schiebt sich der vom PC gesendete Wert auf einen falschen Farbwert im Controller; gesendet wurde etwa R1R2R3… aber dargestellt würde wegen eines fehlenden R1-Bytes R2R3G1… falsche Farben sind das Resultat.

Zeit für ein Redesign, denn wir können das besser…

7.4 Prinzip von Software PWM – Teil 2

Danke an dieser Stelle noch einmal an den hervorragenden Artikel Soft-PWM bei mikrocontroller.net, er hat mir einige Denkanstöße gegeben. Schauen wir uns an, wo wir Einsparungen vornehmen können.

Im wesentlichen sind das zwei Dinge:

  1. Wir kennen durch die Daten der Kanäle die genauen Zeitpunkte, an denen eine Interaktion mit den Port-Pins notwendig wird, sprich, Pins ausgeschaltet werden müssen. Maximal sind das bei neun Kanälen 10 Zeitpunkte, wenn eben alle Kanäle unterschiedliche Helligkeiten besitzen (der erste Zeitpunkt zum Einschalten aller Kanäle).
  2. Außerdem wissen wir damit bereits, welche Kanäle es zu einem bestimmten Zeitpunkt betrifft. Gleiche Helligkeiten können wir somit in einen Schritt zusammenfassen.

Es ergeben sich drei Aufgaben, die wir immer nach Empfang eines kompletten Frame (alle Kanäle) vom PC erledigen müssen:

  1. Für jeden Schritt / Zeitpunkt ist eine Bitmaske zu erstellen, die einfach 1:1 auf die betreffenden PORT-Register geschrieben werden kann: In Schritt 0 z.B. würde sie wohl [1111 1111] lauten, wenn alle Kanäle eingeschaltet werden (hier nur 8 berücksichtigt). Im ersten Schritt dann sei Kanal zwei auszuschalten, die Bitmaske würde hier also [1111 1011] lauten (Big Endian, 0-indiziert). Wir erstellen also maximal 10 Bitmasken, eine für jeden Schritt, genannt „Bitmaske0“-„Bitmaske9“.
  2. Außerdem müssen wir noch ausrechnen, wann genau im PWM-Zyklus jede dieser Bitmasken auszugeben ist; z.B. wird die Bitmaske0 immer am Anfang ausgegeben, während obige Bitmaske 1 bei Schritt 31 (von 1023) des PWM-Zyklus ausgegeben werden soll. Das entspricht einer geringen Helligkeit von Kanal 2.
  3. Die Bitmasken und entsprechenden Zeitwerte sind in Arrays abzulegen, denn dann kann der Timer eine einfache Ausgabe vornehmen: Er gibt die Startbitmaske aus mit allen Kanälen != 0, schaltet also die benötigten Pins ein. Sodann lädt er in sein Register den Timer-Wert der nächsten Bitmaske und beendet die Ausführung. Genau zum nächsten Schritt der PWM wird die Timer-ISR wieder aktiv, gibt die nächste Bitmaske aus und stellt den Timer auf die den folgenden Schritt ein.

So ist sichergestellt, dass der Timer nur zu exakt vorherberechneten Momenten aktiv wird und die CPU sich in der restlichen Zeit um andere Tasks (USART) kümmern kann. Damit ist das Prinzip der „optimalen“ Software-PWM schon beschrieben.

Links noch eine Grafik zur Verdeutlichung: Wir sehen einen vollständigen PWM-Zyklus mit (zur Vereinfachung) 32 Schritten. Die neun Kanäle haben bis auf zwei (violett) unterschiedliche Werte; die entsprechende Tabelle ist rechts dargestellt. Wichtig ist hier zu beachten, dass die Zeiten bereits in Differenzen ausgedrückt sind; der Timer muss ja immer nur die Zwischenzeit bis zur nächsten Aktion bekommen.

7.5 Double Buffering

Eng verbunden mit vorigen Ausführungen ist die Implementierung eines Double-Bufferings. Kurz zur Erinnerung: Wir müssen die Werte, die vom USART empfangen werden, zunächst in ein Array zwischenspeichern, damit der aktuelle PWM-Zyklus nicht gestört, sondern mit seinen momentanen Werten beendet wird. Es kommt allerdings eine weitere Schwierigkeit hinzu. Sehen wir uns das Timing an:

Ein PWM-Zyklus dauert, wie oben berechnet, 13,33ms. Wenn der PC jetzt nicht exakt mit der gleichen Frequenz sendet, sondern z.b. nur mit 50Hz oder 60Hz (je nach Bildschirm-Wiederholrate etwa), so wird es früher oder später passieren, dass der PWM ein neues Frame benötigt, während der USART gerade im Moment empfängt und nocht nicht fertig ist. Ein falscher Farbwert für den Zyklus (und ein sichtbares Flackern) wäre die Folge. Das gilt es unbedingt zu vermeiden.

Die Lösung erscheint naheliegend, wir müssen auch dem USART einen eigenen Pufferspeicher spendieren und bekommen damit drei Arrays:

  1. Das Arbeits-Array des USART (nur schreibend)
  2. Das Arbeits-Array des PWM-Timers (nur lesend)
  3. Ein Zwischenspeicher, der jeweils wechselseitig mit den Daten von USART- und PWM-Array vertauscht wird (wird nicht gelesen oder beschrieben)

Beim Programmieren achten wir natürlich darauf, nicht die gesamten Arrays zu kopieren, sondern nur die Pointer auf die Arrays zu vertauschen:

Array-Tausch mit Pointern
// Heer die eigentlichen Arrays
unsigned int pwmTimerValues1[PWMChannels+1]; //USART
unsigned int pwmTimerValues2[PWMChannels+1]; //PWM
unsigned int pwmTimerValues3[PWMChannels+1]; //MAIN

// Pointer auf die Arrays oben
unsigned int *pToPTV1 = pwmTimerValues1; //Pointer to USART
unsigned int *pToPTV2 = pwmTimerValues2; //Pointer to PWM
unsigned int *pToPTV3 = pwmTimerValues3; //Pointer to MAIN

// Pointer zum Arbeiten mit den Arrays
unsigned int *pToCurrentUsartArray = pToPTV1; //Vorbelegung
unsigned int *pToCurrentPwmArray   = pToPTV2; //Vorbelegung
unsigned int *pToCurrentMainArray  = pToPTV3; //Vorbelegung

// [...]
void swapUsartToMain() {
  unsigned int *tempPointer; //Zwischenspeicher
  tempPointer = pToCurrentMainArray;
  pToCurrentMainArray = pToCurrentUsartArray;
  pToCurrentUsartArray = tempPointer;
}

void swapMainToPwm() {
  unsigned int *tempPointer;
  tempPointer = pToCurrentPwmArray;
  pToCurrentPwmArray = pToCurrentMainArray;
  pToCurrentMainArray = tempPointer;
}

Der Ablauf gestaltet sich nun wie links im Bild dargestellt. Sobald ein PWM-Zyklus beendet ist, holt er sich die neuen Werte aus dem MAIN-Puffer ab (Zeigertausch) und startet den nächsten Zyklus. Ähnlich beim USART: Sobald ein Frame (also 9 vollständige Bytes) empfangen wurde, wird der Zeiger mit dem vom MAIN-Puffer getauscht und so dem nächsten PWM-Zyklus zur Verfügung gestellt.

Selbstverständlich kann es nun, wie auch im Bild dargestellt, sein, dass der PWM-Zyklus früher fertig ist, oder der USART ein Byte verloren hat und nicht rechtzeitig zum Tauschen der Zeiger kommt. Das macht aber nichts, denn jetzt würde der PWM-Zyklus wieder den vorigen Wert nehmen und mit den Daten weitermachen. Es ist optisch viel besser, wenn ein Frame zweimal dargestellt wird (da die Farbwerte bei aufeinanderfolgenden Frames im Allgemeinen stark korrelieren, Abtast-Theorem locker erfüllt), als wenn ein PWM-Zyklus mittendrin mit neuen Werten versorgt würde.

Eine Winzigkeit müssen wir noch beachten: Da die USART-ISR auf das Array schreibt, müssen, solange die Pointer getauscht werden, Interrupts maskiert werden; sonst könnte Chaos ausbrechen, wenn die Zeiger durcheinandergeraten. Theoretisch könnte dadurch ein PWM-Zyklus einen äußerst unschönen weißen Blitz erzeugen (weil ja alle eingeschalteten Kanäle für diesen Zyklus nicht mehr ausgeschaltet werden und R+G+B=Weiß ergibt), in der Praxis kommt es ungefähr 1x pro alle zwei Stunden vor. Das ist aber zu verschmerzen, denke ich. Mit Hardware-PWM würde es nicht passieren…

7.6 Synchronisation

Nun müssen wir uns noch überlegen, wie wir eine Synchronisation zwischen PC und Controller herstellen; denn es kommt oft vor (z.B. StandBy oder Einschalten des PC), dass der PC ungewollt Daten sendet oder auf der Strecke PC->MCU mal ein Byte verlorengeht. Wir legen dazu zunächst eine Datenrate fest, ich habe 19 200 Baud/s gewählt, im Falle von RS232 entspricht ein Baud/s genau einem Bit/s.

Berechnen wir also, wie lange das Senden eines Frames (9 Bytes) dauert, wobei wir die Signallaufzeit vernachlässigen:

t_{\text{Frame}}=\text{Anzahl Bits} * \frac{1}{\text{Datenrate}\,\tfrac{\text{Bit}}{s}} = 9\,\text{Bit} * \frac{1}{19200\,\tfrac{\text{Bit}}{s}} = 0,00046875\,s \cong 0{,}47\,ms

Wir können also sicher sagen, dass nach spätestens einer Millisekunde der gesamte Frame übertragen sein muss. Testen wir, ob das bei unserer gewünschten Aktualisierungsrate von 75 Hz überhaupt möglich ist:

t_{\text{Periode}}=\frac{1}{\text{Aktualisierungsrate }\,\tfrac{\text{1}}{s}} = \frac{1}{75\,\tfrac{\text{1}}{s}} = 0{,}013333\,s \cong 13\,ms

Wir sehen, dass Datenrate und Aktualisierungsfrequenz gut gewählt sind und zusammenpassen. Aufgrund obiger Annahme können wir uns ein Konzept für einen zweiten Timer überlegen.

  1. Der Timer soll nach seinem Start ungefähr eine Millisekunden laufen und dann einen Interrupt erzeugen.
  2. Er muss immer dann gestartet (Enabled und Counter-Reset) werden, wenn am USART ein Byte vollständig empfangen wurde. So wird der Timer während des Empfangs der 9 Byte immer wieder zurückgesetzt, bis auf das letzte Byte. Dort wird er nicht mehr zurückgesetzt und läuft in seine ISR.
  3. In der ISR des Timers ist der Index des Empfangskanals auf null zu setzen. Wenn also beispielsweise nur 7 statt 9 Bytes empfangen wurden, so wird nach dem letzten Byte der Timer überlaufen und der Kanalindex wieder auf 0 zurückgesetzt. Das nächste Frame ist deswegen wieder exakt synchronisiert.

Sehen wir uns zur Veranschaulichung die Zeitleiste an:

  1. 0ms: Beginn des ersten Frames, Timer startet
  2. 0,47ms: alle 9 Bytes empfangen, Timer wird letztmalig zurückgesetzt
  3. 1,47ms: Timer läuft über und setzt Kanalindex auf 0
  4. 13,3ms: Beginn nächster Frame, diesmal nur 7 Bytes (Zeit += 0,47ms*(7/9)).
  5. 13,67ms: 7 Bytes empfangen, Timer wird letztmalig zurückgesetzt
  6. 14,67ms: Timer läuft über und setzt Kanalindex auf 0
  7. usw.

Damit ist eine einwandfreie Synchronisation bis ca. 500Hz (1/0,002ms) gewährleistet.

7.7 Quelltext

Hier der vollständige und kommentierte Quelltext. Bittet beachtet, dass die Datei für ATmega16 und 14,7456 MHz geschrieben ist. Wenn ihr andere Werte verwendet, bitte die Werte oben in der Datei anpassen. Das Channel-Mapping ist auch für meinen Controller und meine Kabelbelegung angepaßt, ihr könnt es für eure Bedürfnisse anders einrichten.

Ambient Light Firmware für ATmega16 (GPLv3)

Bislang keine Kommentare vorhanden.

Einen Kommentar hinterlassen