Soft-PWM

Aus der Mikrocontroller.net Artikelsammlung, mit Beiträgen verschiedener Autoren (siehe Versionsgeschichte)
Wechseln zu: Navigation, Suche

Mit PWM lassen sich vielfältige Aufgaben lösen, wie beispielsweise die Leistungssteuerung von Motoren, Helligkeitssteuerung von LEDs, Digital-Analog Wandlung und vieles mehr. Die meisten Mikrocontroller haben ein oder mehrere PWM-Module eingebaut, womit ohne CPU-Belastung PWM-Signale generiert werden können. Jedoch kommt es bisweilen vor, dass die Anzahl der verfügbaren PWM-Kanäle nicht ausreicht. Dann muss eine Softwarelösung gefunden werden, bei der die CPU die PWM-Generierung vornimmt, genannt Soft-PWM.

Einfacher Lösungsansatz

Ein sehr einfacher Lösungsansatz findet sich im AVR-Tutorial: PWM. Hier wird schon ein Timer benutzt, um in regelmäßigen Abständen die PWM-Generierung durchzuführen. Damit verbleibt noch Rechenzeit für andere Aufgaben, außerdem wird die Programmierung wesentlich vereinfacht. Allerdings ist das Beispiel in ASM, hier soll das ganze in C gemacht werden.

Es soll nun eine 8 Bit PWM mit 100 Hz (PWM-Zyklus 10ms) und acht Kanälen generiert werden. Der verwendete Controller ist ein AVR vom Typ ATmega32, welcher mit dem internen RC-Oszillator auf 8 MHz getaktet wird. Das Programm kann jedoch problemlos an so ziemlich jeden anderen AVR angepasst werden. Die Programme wurden mit dem Optimierungsgrad -Os compiliert.

Erster Versuch

Das Programm ist recht kurz und übersichtlich. Im Hauptprogramm wird der Timer 1 initialisiert und der Output Compare 1A als variabler Timer verwendet, wobei die Output Compare Funktion nicht mit dem IO-Pin verbunden ist. Im Interrupt, welcher regelmäßig aufgerufen wird, werden nun in einer Schleife alle acht Kanäle geprüft. Alle Kanäle werden auf HIGH gesetzt, welche eine PWM-Einstellung größer als der aktuelle Zykluszähler haben. Sinnvollerweise werden erst alle Kanäle geprüft und das Ergebnis zwischengespeichert, am Ende erfolgt nur ein Zugriff auf den Port.

/*
    Eine 8-kanalige PWM mit einfachem Lösungsansatz

    ATmega32 @ 8 MHz

*/

// Defines an den Controller und die Anwendung anpassen

#define F_CPU 8000000L                  // Systemtakt in Hz
#define F_PWM 100                       // PWM-Frequenz in Hz
#define PWM_STEPS 255                   // PWM-Schritte pro Zyklus(1..255)
#define PWM_PORT PORTD                  // Port für PWM
#define PWM_DDR DDRD                    // Datenrichtungsregister für PWM

// ab hier nichts ändern, wird alles berechnet

#define T_PWM (F_CPU/(F_PWM*PWM_STEPS)) // Systemtakte pro PWM-Takt

#if (T_PWM<(152+5))
    #error T_PWM zu klein, F_CPU muss vergrössert werden oder F_PWM oder PWM_STEPS verkleinert werden
#endif

#if PWM_STEPS > 255
    #error PWM_STEPS zu gross
#endif

// includes

#include <stdint.h>
#include <string.h>
#include <avr/io.h>
#include <avr/interrupt.h>

// globale Variablen

volatile uint8_t pwm_setting[8];                    // Einstellungen für die einzelnen PWM-Kanäle

// Timer 1 Output COMPARE A Interrupt

ISR(TIMER1_COMPA_vect) {
    static uint8_t pwm_cnt=0;
    uint8_t tmp=0, i=0, j=1;

    OCR1A += (uint16_t)T_PWM;

    for (; i<8; i++) {    
    	if (pwm_setting[i] > pwm_cnt) tmp |= j;
            j<<=1;
	}
    PWM_PORT = tmp;                         // PWMs aktualisieren
    if (pwm_cnt==(uint8_t)(PWM_STEPS-1))
        pwm_cnt=0;
    else
        pwm_cnt++;
}

int main(void) {

    // PWM einstellen
    
    PWM_DDR = 0xFF;         // Port als Ausgang
    
    // Timer 1 OCRA1, als variablem Timer nutzen

    TCCR1B = 1;             // Timer läuft mit vollem Systemtakt
    TIMSK |= (1<<OCIE1A);   // Interrupt freischalten

    sei();                  // Interrupts global einschalten

/*********************************************************************/
// nur zum Testen, im Anwendungsfall löschen

    volatile uint8_t tmp;
const uint8_t t1[8]={27, 40, 3, 17, 150, 99, 5, 9};
const uint8_t t2[8]={27, 40, 3, 0, 150, 99, 5, 9};
const uint8_t t3[8]={27, 40, 3, 17, 3, 99, 3, 0};
const uint8_t t4[8]={0, 0, 0, 0, 0, 0, 0, 0};
const uint8_t t5[8]={0, 0, 0, 0, 0, 0, 0, 9};
const uint8_t t6[8]={33, 33, 33, 33, 33, 33, 33, 33};

// Messung der Interruptdauer
    tmp =0;
    tmp =0;
    tmp =0;

// Debug 

    memcpy(pwm_setting, t1, 8);
    
    memcpy(pwm_setting, t2, 8);

    memcpy(pwm_setting, t3, 8);

    memcpy(pwm_setting, t4, 8);

    memcpy(pwm_setting, t5, 8);
    
    memcpy(pwm_setting, t6, 8);

/*********************************************************************/
    while (1)
    {
    }

    return 0;
}

Im AVR-Studio kann man den Code simulieren. Wichtig ist hier vor allem die Ausführungszeit des Interrupts. Bei 100 Hz PWM-Frequenz und 256 Schritten pro PWM-Zyklus wird diese Funktion immerhin 25600 mal pro Sekunde aufgerufen (PWM-Takt 25,6 kHz), bei 8MHz Taktfrequenz stehen damit maximal 312 Takte zur Verfügung. Glücklicherweise ist die Funktion relativ kurz und der GCC leistet gute Arbeit. Der Interrupt benötigt hier 152 Takte, es verbleiben also jeweils 160 Takte zur Bearbeitung anderer Aufgaben. Das entspricht einer CPU-Belastung von ~49%. Das Programm benötigt 284 Byte Programmspeicher. Nicht schlecht für den Anfang.

Zweiter Versuch

Wo gibt es in diesem Programm noch Optimierungsmöglichkeiten? Nur im Interrupt, denn das ganze Programm besteht ja praktisch nur aus der Interruptroutine. Betrachten wir die Schleifen genauer müssen wir feststellen, dass die Indizierung von pwm_setting[] etwas Rechenzeit benötigt. Ebenso die Schiebeoperation von tmp, auch wenn das nur acht mal ein Takt ist. Wir können jetzt per Hand das machen, was der Compiler auch manchmal macht. Die Rede ist vom Loop-Unrolling. Dabei wird die Schleife durch mehrere diskrete Befehle ersetzt (entrollt). Der Vorteil dabei ist, dass die Befehle zur Berechnung und Prüfung der Zählvariable entfallen, außerdem können ggf. Werte im Voraus berechnet werden. Als Ergebnis hat man zwar ein etwas größeres Programm, doch das wird schneller ausgeführt! Außerdem orientiert sich diese Version mehr am Original der Assemblerversion. Dadurch wird sie zusätzlich ein wenig kürzer und schneller.

/*
    Eine 8-kanalige PWM mit verbessertem Lösungsansatz

    ATmega32 @ 8 MHz

*/

// Defines an den Controller und die Anwendung anpassen

#define F_CPU 8000000L                  // Systemtakt in Hz
#define F_PWM 100                       // PWM-Frequenz in Hz
#define PWM_STEPS 256                   // PWM-Schritte pro Zyklus(1..256)
#define PWM_PORT PORTD                  // Port für PWM
#define PWM_DDR DDRD                    // Datenrichtungsregister für PWM

// ab hier nichts ändern, wird alles berechnet

#define T_PWM (F_CPU/(F_PWM*PWM_STEPS)) // Systemtakte pro PWM-Takt

#if (T_PWM<(93+5))
    #error T_PWM zu klein, F_CPU muss vergrössert werden oder F_PWM oder PWM_STEPS verkleinert werden
#endif

// includes

#include <stdint.h>
#include <string.h>
#include <avr/io.h>
#include <avr/interrupt.h>

// globale Variablen

volatile uint8_t pwm_setting[8];                    // Einstellungen für die einzelnen PWM-Kanäle

// Timer 1 Output COMPARE A Interrupt

ISR(TIMER1_COMPA_vect) {
    static uint8_t pwm_cnt=0;
    uint8_t tmp=0;

    OCR1A += (uint16_t)T_PWM;
        
    if (pwm_setting[0] > pwm_cnt) tmp |= (1<<0);
    if (pwm_setting[1] > pwm_cnt) tmp |= (1<<1);
    if (pwm_setting[2] > pwm_cnt) tmp |= (1<<2);
    if (pwm_setting[3] > pwm_cnt) tmp |= (1<<3);
    if (pwm_setting[4] > pwm_cnt) tmp |= (1<<4);
    if (pwm_setting[5] > pwm_cnt) tmp |= (1<<5);
    if (pwm_setting[6] > pwm_cnt) tmp |= (1<<6);
    if (pwm_setting[7] > pwm_cnt) tmp |= (1<<7);
    PWM_PORT = tmp;                         // PWMs aktualisieren
    if (pwm_cnt==(uint8_t)(PWM_STEPS-1))
        pwm_cnt=0;
    else
        pwm_cnt++;
}

int main(void) {

    // PWM einstellen
    
    PWM_DDR = 0xFF;         // Port als Ausgang
    
    // Timer 1 OCRA1, als variablem Timer nutzen

    TCCR1B = 1;             // Timer läuft mit vollem Systemtakt
    TIMSK |= (1<<OCIE1A);   // Interrupt freischalten

    sei();                  // Interrupts global einschalten

/*********************************************************************/
// nur zum Testen, im Anwendungsfall löschen

    volatile uint8_t tmp;
const uint8_t t1[8]={27, 40, 3, 17, 150, 99, 5, 9};
const uint8_t t2[8]={27, 40, 3, 0, 150, 99, 5, 9};
const uint8_t t3[8]={27, 40, 3, 17, 3, 99, 3, 0};
const uint8_t t4[8]={0, 0, 0, 0, 0, 0, 0, 0};
const uint8_t t5[8]={0, 0, 0, 0, 0, 0, 0, 9};
const uint8_t t6[8]={33, 33, 33, 33, 33, 33, 33, 33};

// Messung der Interruptdauer
    tmp =0;
    tmp =0;
    tmp =0;

// Debug 

    memcpy(pwm_setting, t1, 8);
    
    memcpy(pwm_setting, t2, 8);

    memcpy(pwm_setting, t3, 8);

    memcpy(pwm_setting, t4, 8);

    memcpy(pwm_setting, t5, 8);
    
    memcpy(pwm_setting, t6, 8);

/*********************************************************************/

    return 0;
}

Mit dieser Interruptroutine werden nur noch 93 Takte benötigt, die CPU-Belastung verringert sich auf ~30%. Nicht schlecht. Der Programmcode steigt auf 324 Byte, aber das ist im Angesicht der Leistungsverbesserung zu verschmerzen. Weiter verringern kann man die CPU-Belastung durch eine niedrigere PWM-Frequenz oder eine geringere Anzahl Stufen der PWM. Wenn man beispielsweise mit 64 (6 Bit) statt 256 (8 Bit) Stufen auskommt verringert sich die Belastung um den Faktor 4.

Intelligenter Lösungsansatz

Wenn auch eine CPU-Last von 30% recht akzeptabel erscheint, so hat man doch noch irgendwie das Gefühl, dass es noch besser geht. Aber wie? Mein Mathematikprofessor pflegte in so einem Fall die Anwendung der „Methode des scharfen Blicks“ ™. Was passiert eigentlich während eines gesamten PWM-Zykluses?

  • Zu Beginn werden alle IO-Pins gesetzt, deren PWM-Einstellung nicht Null ist.
  • Die jeweiligen IO-Pins werden gelöscht, wenn der PWM-Zähler mit der PWM-Einstellung übereinstimmt

Ja klar, aber da wir nur acht Kanäle haben, gibt es maximal 8 Zeitpunkte, an denen ein Pin gelöscht werden muss. Wenn mehrere Kanäle die gleiche PWM-Einstellung haben sind es sogar noch weniger. Alle anderen Interrupts verursachen keinerlei Änderung der IO-Pins und verbrauchen eigentlich nur sinnlos Rechenzeit. Ein Skandal!

Was ist also zu tun? Wir wissen nun, dass es maximal 9 Ereignisse pro PWM-Zyklus gibt. Was ist damit zu machen?

  • Die PWM-Kanäle müssen in aufsteigender Folge sortiert werden.
  • Wenn mehrere PWM-Kanäle den gleichen PWM-Wert haben müssen sie zusammengefaßt werden
  • Die Zeitdifferenzen zwischen den einzelnen Ereignissen müssen berechnet werden

Das ist eigentlich schon alles. Praktisch ist das mit einigen Kniffligkeiten verbunden, aber die sind lösbar. Am Ende steht eine Interruptroutine, welche maximal 9 mal pro PWM-Zyklus aufgerufen wird und die jeweiligen IO-Pins setzt bzw. löscht. Eine normale Funktion wird benutzt, um die PWM-Einstellungen der acht Kanäle in die notwendigen Informationen für die Interruptroutine umzuwandeln. Das hat unter anderem den Vorteil, dass nur dann zusätzlich Rechenzeit benötigt wird, wenn sich die PWM-Einstellungen ändern.

/*
    Eine 8-kanalige PWM mit intelligentem Lösungsansatz

    ATmega32 @ 8 MHz

*/

// Defines an den Controller und die Anwendung anpassen

#define F_CPU         8000000L           // Systemtakt in Hz
#define F_PWM         100L               // PWM-Frequenz in Hz
#define PWM_PRESCALER 8                  // Vorteiler für den Timer
#define PWM_STEPS     256                // PWM-Schritte pro Zyklus(1..256)
#define PWM_PORT      PORTB              // Port für PWM
#define PWM_DDR       DDRB               // Datenrichtungsregister für PWM
#define PWM_CHANNELS  8                  // Anzahl der PWM-Kanäle

// ab hier nichts ändern, wird alles berechnet

#define T_PWM (F_CPU/(PWM_PRESCALER*F_PWM*PWM_STEPS)) // Systemtakte pro PWM-Takt
//#define T_PWM 1   //TEST

#if ((T_PWM*PWM_PRESCALER)<(111+5))
    #error T_PWM zu klein, F_CPU muss vergrössert werden oder F_PWM oder PWM_STEPS verkleinert werden
#endif

#if ((T_PWM*PWM_STEPS)>65535)
    #error Periodendauer der PWM zu gross! F_PWM oder PWM_PRESCALER erhöhen.   
#endif
// includes

#include <stdint.h>
#include <string.h>
#include <avr/io.h>
#include <avr/interrupt.h>

// globale Variablen

uint16_t pwm_timing[PWM_CHANNELS+1];          // Zeitdifferenzen der PWM Werte
uint16_t pwm_timing_tmp[PWM_CHANNELS+1];      

uint8_t  pwm_mask[PWM_CHANNELS+1];            // Bitmaske für PWM Bits, welche gelöscht werden sollen
uint8_t  pwm_mask_tmp[PWM_CHANNELS+1];        // ändern uint16_t oder uint32_t für mehr Kanäle

uint8_t  pwm_setting[PWM_CHANNELS];           // Einstellungen für die einzelnen PWM-Kanäle
uint8_t  pwm_setting_tmp[PWM_CHANNELS+1];     // Einstellungen der PWM Werte, sortiert
                                              // ändern auf uint16_t für mehr als 8 Bit Auflösung  

volatile uint8_t pwm_cnt_max=1;               // Zählergrenze, Initialisierung mit 1 ist wichtig!
volatile uint8_t pwm_sync;                    // Update jetzt möglich

// Pointer für wechselseitigen Datenzugriff

uint16_t *isr_ptr_time  = pwm_timing;
uint16_t *main_ptr_time = pwm_timing_tmp;

uint8_t *isr_ptr_mask  = pwm_mask;              // Bitmasken fuer PWM-Kanäle
uint8_t *main_ptr_mask = pwm_mask_tmp;          // ändern uint16_t oder uint32_t für mehr Kanäle

// Zeiger austauschen
// das muss in einem Unterprogramm erfolgen,
// um eine Zwischenspeicherung durch den Compiler zu verhindern

void tausche_zeiger(void) {
    uint16_t *tmp_ptr16;
    uint8_t *tmp_ptr8;                          // ändern uint16_t oder uint32_t für mehr Kanäle

    tmp_ptr16 = isr_ptr_time;
    isr_ptr_time = main_ptr_time;
    main_ptr_time = tmp_ptr16;
    tmp_ptr8 = isr_ptr_mask;
    isr_ptr_mask = main_ptr_mask;
    main_ptr_mask = tmp_ptr8;
}

// PWM Update, berechnet aus den PWM Einstellungen
// die neuen Werte für die Interruptroutine

void pwm_update(void) {
    
    uint8_t i, j, k;
    uint8_t m1, m2, tmp_mask;                   // ändern uint16_t oder uint32_t für mehr Kanäle    
    uint8_t min, tmp_set;                       // ändern auf uint16_t für mehr als 8 Bit Auflösung

    // PWM Maske für Start berechnen
    // gleichzeitig die Bitmasken generieren und PWM Werte kopieren

    m1 = 1;
    m2 = 0;
    for(i=1; i<=(PWM_CHANNELS); i++) {
        main_ptr_mask[i]=~m1;                       // Maske zum Löschen der PWM Ausgänge
        pwm_setting_tmp[i] = pwm_setting[i-1];
        if (pwm_setting_tmp[i]!=0) m2 |= m1;        // Maske zum setzen der IOs am PWM Start
        m1 <<= 1;
    }
    main_ptr_mask[0]=m2;                            // PWM Start Daten 

    // PWM settings sortieren; Einfügesortieren

    for(i=1; i<=PWM_CHANNELS; i++) {
        min=PWM_STEPS-1;
        k=i;
        for(j=i; j<=PWM_CHANNELS; j++) {
            if (pwm_setting_tmp[j]<min) {
                k=j;                                // Index und PWM-setting merken
                min = pwm_setting_tmp[j];
            }
        }
        if (k!=i) {
            // ermitteltes Minimum mit aktueller Sortiertstelle tauschen
            tmp_set = pwm_setting_tmp[k];
            pwm_setting_tmp[k] = pwm_setting_tmp[i];
            pwm_setting_tmp[i] = tmp_set;
            tmp_mask = main_ptr_mask[k];
            main_ptr_mask[k] = main_ptr_mask[i];
            main_ptr_mask[i] = tmp_mask;
        }
    }

    // Gleiche PWM-Werte vereinigen, ebenso den PWM-Wert 0 löschen falls vorhanden

    k=PWM_CHANNELS;             // PWM_CHANNELS Datensätze
    i=1;                        // Startindex

    while(k>i) {
        while ( ((pwm_setting_tmp[i]==pwm_setting_tmp[i+1]) || (pwm_setting_tmp[i]==0))  && (k>i) ) {

            // aufeinanderfolgende Werte sind gleich und können vereinigt werden
            // oder PWM Wert ist Null
            if (pwm_setting_tmp[i]!=0)
                main_ptr_mask[i+1] &= main_ptr_mask[i];        // Masken vereinigen

            // Datensatz entfernen,
            // Nachfolger alle eine Stufe hochschieben
            for(j=i; j<k; j++) {
                pwm_setting_tmp[j] = pwm_setting_tmp[j+1];
                main_ptr_mask[j] = main_ptr_mask[j+1];
            }
            k--;
        }
        i++;
    }
    
    // letzten Datensatz extra behandeln
    // Vergleich mit dem Nachfolger nicht möglich, nur löschen
    // gilt nur im Sonderfall, wenn alle Kanäle 0 sind
    if (pwm_setting_tmp[i]==0) k--;

    // Zeitdifferenzen berechnen
    
    if (k==0) { // Sonderfall, wenn alle Kanäle 0 sind
        main_ptr_time[0]=(uint16_t)T_PWM*PWM_STEPS/2;
        main_ptr_time[1]=(uint16_t)T_PWM*PWM_STEPS/2;
        k=1;
    }
    else {
        i=k;
        main_ptr_time[i]=(uint16_t)T_PWM*(PWM_STEPS-pwm_setting_tmp[i]);
        tmp_set=pwm_setting_tmp[i];
        i--;
        for (; i>0; i--) {
            main_ptr_time[i]=(uint16_t)T_PWM*(tmp_set-pwm_setting_tmp[i]);
            tmp_set=pwm_setting_tmp[i];
        }
        main_ptr_time[0]=(uint16_t)T_PWM*tmp_set;
    }

    // auf Sync warten

    pwm_sync=0;             // Sync wird im Interrupt gesetzt
    while(pwm_sync==0);

    // Zeiger tauschen
    cli();
    tausche_zeiger();
    pwm_cnt_max = k;
    sei();
}

// Timer 1 Output COMPARE A Interrupt

ISR(TIMER1_COMPA_vect) {
    static uint8_t pwm_cnt;                     // ändern auf uint16_t für mehr als 8 Bit Auflösung
    uint8_t tmp;                                // ändern uint16_t oder uint32_t für mehr Kanäle

    OCR1A += isr_ptr_time[pwm_cnt];
    tmp    = isr_ptr_mask[pwm_cnt];
    
    if (pwm_cnt == 0) {
        PWM_PORT = tmp;                         // Ports setzen zu Begin der PWM
                                                // zusätzliche PWM-Ports hier setzen
        pwm_cnt++;
    }
    else {
        PWM_PORT &= tmp;                        // Ports löschen
                                                // zusätzliche PWM-Ports hier setzen
        if (pwm_cnt == pwm_cnt_max) {
            pwm_sync = 1;                       // Update jetzt möglich
            pwm_cnt  = 0;
        }
        else pwm_cnt++;
    }
}

int main(void) {

    // PWM Port einstellen
    
    PWM_DDR = 0xFF;         // Port als Ausgang
    // zusätzliche PWM-Ports hier setzen
    
    // Timer 1 OCRA1, als variablen Timer nutzen

    TCCR1B = 2;             // Timer läuft mit Prescaler 8
    TIMSK |= (1<<OCIE1A);   // Interrupt freischalten

    sei();                  // Interrupts global einschalten


/******************************************************************/
// nur zum testen, in der Anwendung entfernen
/*
// Test values
volatile uint8_t tmp;
const uint8_t t1[8]={255, 40, 3, 17, 150, 99, 5, 9};
const uint8_t t2[8]={27, 40, 3, 0, 150, 99, 5, 9};
const uint8_t t3[8]={27, 40, 3, 17, 3, 99, 3, 0};
const uint8_t t4[8]={0, 0, 0, 0, 0, 0, 0, 0};
const uint8_t t5[8]={9, 1, 1, 1, 1, 1, 1, 1};
const uint8_t t6[8]={33, 33, 33, 33, 33, 33, 33, 33};
const uint8_t t7[8]={0, 0, 0, 0, 0, 0, 0, 88};


// Messung der Interruptdauer
    tmp =1;
    tmp =2;
    tmp =3;

// Debug 

    memcpy(pwm_setting, t1, 8);
    pwm_update();

    memcpy(pwm_setting, t2, 8);
    pwm_update();

    memcpy(pwm_setting, t3, 8);
    pwm_update();

    memcpy(pwm_setting, t4, 8);
    pwm_update();

    memcpy(pwm_setting, t5, 8);
    pwm_update();
    
    memcpy(pwm_setting, t6, 8);
    pwm_update();
    
    memcpy(pwm_setting, t7, 8);
    pwm_update();
*/
/******************************************************************/

    while(1);
    return 0;
}

Das Programm ist schon um einiges länger (968 Byte). Die Interruptroutine benötigt maximal 111 Takte und wird zwischen 2 bis 9 mal pro PWM-Zyklus aufgerufen. Zweimal, wenn alle PWM-Einstellungen gleich sind, 9 mal, wenn alle PWM-Einstellungen verschieden sind. Damit werden zwischen 222 bis 999 Takte benötigt, pro PWM-Zyklus, nicht pro PWM-Takt! Das entspricht einer CPU-Belastung von 0,3..1,2%! Standing Ovations! Die Funktion pwm_update() benötigt ca. 1500 bis 1800 Takte, das ist geringfügig abhängig von den PWM-Einstellungen, je nach dem ob die Daten schon sortiert sind und ob PWM-Werte mehrfach vorkommen. Bei einer Updaterate von 100 Hz (mehr ist physikalisch sinnlos) entspricht das einer CPU-Belastung von 2,3%, praktisch wird es wahrscheinlich weniger sein. Taktet man den AVR mit vollen 16 MHz halbiert sich die CPU-Belastung noch einmal. Beachtet werden sollte hier die Datenübergabe von der Funktion pwm_update() zur Interruptroutine. Hier werden jeweils zwei Zeiger verwendet, um auf Arrays zu zeigen. In zwei Arrays werden durch die Funktion die Berechnungen der neuen Daten vorgenommen. In den beiden anderen Arrays stehen die aktuellen Daten, mit welchen die ISR arbeitet. Um am Ende der Berechung ein relativ aufwändiges Kopieren der Daten zu vermeiden werden einfach die Zeiger vertauscht. Das ist wesentlich schneller als das Kopieren der Arrays! Im englischen spricht man hier von double buffering, also doppelter Pufferung. Dieses Prinzip wird oft angewendet. Würde man allerdings einfach am Ende die Zeiger tauschen käme es zu einem Crash! Der Interrupt kann jederzeit aktiv werden. Wenn dann die Zeiger nur halb kopiert sind greift die Interruptroutine auf zerstückelte Daten zu und macht Müll. Ebenso würde es zu Fehlfunktionen kommen, wenn während es PWM-Zyklus neue Daten in die Arrays kopiert werden. Das muß verhindert werden. Und zwar dadurch, dass über eine Variable eine Synchronisation durchgeführt wird. Diese wird am Ende des PWM-Zyklus gesetzt und signalisiert, dass neue Daten für den nächsten Zyklus kopiert werden können. Deshalb muss die Funktion pwm_update ggf. bis zu 1 vollen PWM-Zyklus warten, bis die Zeiger getauscht werden können. Wichtig ist dabei, dass die Variable pwm_sync, welche sowohl in der Funktion als auch im Interrupt geschrieben wird, als volatile deklariert wird. Denn sonst würde die Sequenz

pwm_sync=0;             // Sync wird im Interrupt gesetzt
while(pwm_sync==0);

zum Stehenbleiben der CPU führen, weil der Compiler erkennt, dass die Variable nie ungleich Null sein kann und damit die Schleife endlos ausgeführt wird. Der Compiler kann prinzipbedingt nicht automatisch erkennen, dass die Variable im Interrupt auf 1 gesetzt wird.

Bei dem schon recht hohen Prozessortakt von 8MHz und der relativ niedrigen PWM Frequenz von 100 Hz haben wir allerdings ein kleines Problem. Wenn beispielsweise nur ein Kanal den PWM-Wert 10 hat, alle anderen aber den Wert Null, dann passiert folgendes. Zum Begin eines PWM-Zyklus wird der eine Kanal aktiviert. Jetzt wird per Timer für 10xT_PWM = 3120 Takte gewartet. Jetzt wird dieser Kanal wieder gelöscht. Bis zum Begin des nächsten PWM-Zyklus muss jedoch noch (256-10)*T_PWM = 76752 Takte gewartet werden. Doch diese Zahl passt nicht mehr in eine 16 Bit Variable! Und damit kann sie auch nicht mit dem Timer verwendet werden. Der Ausweg heisst Vorteiler (engl. Prescaler). Damit kann der Timer langsamer getaktet werden und somit wird die gleiche Wartezeit mit weniger Timertakten erzielt. Zu beachten ist, dass die Einstellung im #define PWM_PRESCALER mit der realen Einstellung in TCCR1B übereinstimmen muss.

Eine Einschränkung gilt allerdings für alle Soft-PWMs. Die PWM-Frequenz muss niedrig genug sein, damit sich die Interrupts nicht überschneiden. D.h. der Wert T_PWM muß immer größer sein als die Anzahl Takte der Interruptroutine. Das wird im Quelltext mit Hilfe von #if . . #endif geprüft. Die +5 Takte sind eine Reserve. Dazu muß aber die Optimierung -Os eingeschaltet sein, sonst stimmen die Zahlen nicht!

Achtung
Wenn man für eine PWM aussschliesslich 8 Bit breite Datentypen verwendet, dann steht für den Parameter für die Pulsbreite nur der Bereich 0..255 zur Verfügung. Da bei einer vollständigen PWM mit N Schritten aber N+1 mögliche Fälle auftreten können (0/N bis N/0), ist mit dem hier gezeigten Code eine solche PWM nur für N ? 255 realisierbar.

Als Grenze kann mit diesem Programm bei 16 MHz eine 10-Bit PWM in Software generiert werden, was schon sehr respektabel ist und weiches, langsames LED-Fading mit vielen LEDs ermöglicht. Dazu muss allerdings das Programm an wenigen Stellen angepasst werden, im Speziellen müssen verschiedene Variablen und Arrays von uint8_t auf uint16_t umgestellt werden. Das Gleiche gilt für mehr PWM-Kanäle, praktisch wurden schon bis zu 32 Kanäle realisiert. Der Quelltext ist an den Stellen kommentiert.

Maximale PWM-Auflösung bei einer LED-Matrix

Bei der Ansteuerung einer LED-Matrix – als Richtwert 10 (Spalten) x 20 (Zeilen) = 200 LEDs – kommt man bei den vorangegangenen Lösungsansätzen zu dem Problem, dass die Ausführungszeit einer Interruptserviceroutine (ISR) einfch zu lange dauert. Zudem sind für eine genügend „softe“ Steuerung bei kleinen Helligkeiten deutlich mehr als 8 Bit PWM-Auflösung erforderlich.

Die Lösung besteht darin, die kurzen PWM-Zeiten durch eine Sequenz von zeitlich berechneten Ausgabebefehlen zu generieren und die langen wie gehabt durch eine Zählervergleichs-ISR.

Man macht sich dabei die Eigenschaft typischer Mikrocontroller zu Nutze, dass die Latenzzeit für den Interruptaufruf nur um wenige Takte schwankt (= Jitter): Beim AVR sind es maximal 3 Takte, wenn ein RET-Befehl (4 Takte) abgearbeitet wird. Phasen mit gesperrten Interrupts oder konkurrierende ISRs sollte man vermeiden. Eventuelle ISRs sollten sei als ersten Befehl (am besten noch vor dem Sprung zur eigentlichen ISR) enthalten, dann wäre der Jitter ≥ 5 (4 Takte ISR-Aufruf, 1 Takt sei, ≥ 1 Takt gesperrte Interrupts für den Befehl danach). Steht die Hauptschleife im sleep, ist der Jitter Null.

Der nachfolgende Programmrumpf hat folgende Eigenschaften auf einem ATmega16 mit 16 MHz Quarzfrequenz:

  • Binärcode- oder „Bit-Winkel“-Modulation (bit angle modulation, BAM, komischer Begriff, siehe unten), keine klassische PWM, mit dem Nachteil häufigerer Umladevorgänge an den Gates der MOSFETs
  • LED-Matrix 9 Spalten (hier: Katoden) x 18 Zeilen (Anoden)
  • Minimale PWM-Pulslänge: 62,5 ns = 1 Takt
  • PWM-Tiefe: 13 Bit (wirklich!)
  • Maximaler Jitter: Je nach unterbrochenem Befehl 0-3 Takte, bei Interruptsperrzeiten mehr, betrifft nur Helligkeiten > 25, fällt also kaum auf
  • Bildwiederholfrequenz: > 200 Hz
  • Für die ISR sind die Register R2,R3,R4,R5,R6,R7=CurKatode reserviert; diese ließen sich unter zusätzlichem Zeitaufwand „wegpushen“.
  • CPU-Last: ≈ 25 %, konstant, unabhängig von der Beleuchtungssituation

Die kurzen PWM-Zeiten werden derart verschachtelt auf die 3 Ports ausgegeben, dass immer genug Zeit zum Nachladen von Registern zwischendurch vorhanden ist. Anschaulich in der Kommentarspalte.

(Das +-Zeichen unterteilt längliche PWM-Phasen in 8 Takte. Jede Zeile steht für 1 CPU-Takt. Der * in der Registerspalte bedeutet: Register mit gültigem Wert geladen.)

Das Vorhalten der auszugebenden Werte erfolgt in der Datenstruktur abt im RAM („Anoden-Bit-Tabelle“); das Zeigerregister Y läuft da durch. Ein paar Werte werden bereits im voraus in den Registern R2 … R6 vorgehalten, das erspart Ladebefehle am Anfang der ISR. Das ist normalerweise kein Problem, da sich im gcc die Register „wegreservieren“ lassen. Aber die Standardbibliothek kann einen Strich durch die Rechnung machen: Die Funktion rand() nimmt R2 … R15 in Beschlag und sperrt zudem Interrupts. Hier hilft nur:

  • Verzicht auf entsprechende Standardbibliotheksfunktionen, Listfile auf Verwendung von R2 usw. durchsuchen
  • Ändern der ISR (Wegpushen der Register kostet natürlich mehr Rechenleistung)
  • Neucompilierung der Standardbibliothek

Wer wagemutig ist, lässt auch das Sichern von YH:YL weg. (Dies war so im ersten Ansatz realisiert.) Denn die meisten C-Programme brauchen es nicht. Auch hier muss man im Listfile kontrollieren.

Code-Ausschnitt der Interruptserviceroutinen

TIMER1_CAPT_vect:
// Verschachtelte Längen		PORTB	PORTD	PORTC  R23456
	push	YL		//	4096	4096	4096	*****	Y retten (wird bisweilen von AVRGCC gebraucht)
				//	|	|	|		2-Takt-Befehl
	push	YH		//	|	|	|
				//	|	|	|
	ldi	YL,lo8(abt)	//	|	|	|
	ldi	YH,hi8(abt)	//	|	|	|
	out	PORTB,ONES	//	-	|	|		Anoden AUS
	out	PORTD,ONES	//	-	-	|		Anoden AUS
	out	PORTC,r2	//	-	-	-	-****	Anoden AUS, Katode umschalten
	out	PORTA,r3	//	-	-	-	--***	Katode umschalten
	out	PORTB,r4	//	32	-	-	---**	Anoden EIN
	out	PORTD,r5	//	|	16	-	----*	Anoden EIN
	out	PORTC,r6	//	|	|	8	-----	Anoden EIN (Katode unverändert)
	sei			//	|	|	|		Für [http://www.obdev.at/vusb V-USB]
	ldd	r2,Y+0*3+2	//	|	|	|	*----	Register nachladen
				//	|	|	|		2-Takt-Befehl
	ldd	r3,Y+1*3+2	//	|	|	|	**---	Prinzipiell könnte man …
				//	|	|	|		… auch LDS verwenden, für mehr Kode …
	ldd	r4,Y+4*3+2	//	+	|	|	***--	… könnte man noch ein paar …
				//	|	+	|		… Takte zum Retten von Y einsparen.
	out	PORTC,r2	//	|	|	1	-**--
	out	PORTC,r3	//	|	|	2	--*--
	nop			//	|	|	|
	out	PORTC,r4	//	|	|	16	-----
	nop			//	|	|	|
	ldd	r2,Y+5*3+1	//	|	|	|	*----
				//	+	|	|
	out	PORTD,r2	//	|	32	|	-----
	nop			//	|	|	|
	ldd	r2,Y+2*3+2	//	|	|	|	*----
				//	|	|	|
	ldd	r3,Y+5*3+2	//	|	|	+	**---
				//	|	|	|
	ldd	r4,Y+0*3+0	//	|	|	|	***--
				//	+	|	|
	ldd	r5,Y+2*3+0	//	|	+	|	****-
				//	|	|	|
	ldd	r6,Y+2*3+1	//	|	|	|	*****
				//	|	|	|
	out	PORTC,r2	//	|	|	4	-****
	ldd	r2,Y+1*3+0	//	|	|	|	*****
				//	|	|	|
	out	PORTB,r2	//	2	|	|	-****
	out	PORTC,r3	//	|	+	32	--***
	out	PORTB,r4	//	1	|	|	---**
	out	PORTB,r5	//	4	|	|	----*
	nop			//	|	|	|
	ldd	r2,Y+3*3+0	//	|	|	|	*---*
				//	|	|	|
	out	PORTB,r2	//	8	|	|	----*
	nop			//	|	|	|
	ldd	r2,Y+4*3+0	//	|	+	+	*---*
				//	|	|	|
	ldd	r3,Y+0*3+1	//	|	|	|	**--*
				//	|	|	|
	ldd	r4,Y+1*3+1	//	|	|	|	***-*
				//	|	|	|
	out	PORTB,r2	//	16	|	|	-**-*
	cli			//	|	|	|
	out	PORTD,r3	//	|	1	+	--*-*
	out	PORTD,r4	//	|	2	|	----*
	sei			//	|	|	|
	out	PORTD,r6	//	|	4	|	-----
	ldd	r2,Y+3*3+1	//	|	|	|	*----
				//	|	|	|
	ldi	YL,abt+6*3	//	+	|	|
	out	PORTD,r2	//	|	8	|	-----
	ld	r2,Y+		//	|	|	+	*----
				//	|	|	|
	ld	r3,Y+		//	|	|	|	**---
				//	|	|	|
	ld	r4,Y+		//	|	|	|	***--
				//	|	|	|
	out	PORTB,r2	//	64	|	|	-**--
	out	PORTD,r3	//	|	64	|	--*--
	out	PORTC,r4	//	|	|	64	-----
	movw	r2,YL		//	|	|	|		Am Ende muss R3:R2 auf abt+7*3 zeigen …
	pop	YH		//	|	|	|		… damit die nachfolgende ISR funktioniert.
				//	|	|	|		Stattdessen 2 Bytes RAM zu opfern würde …
	pop	YL		//	|	|	|		… die nachfolgende ISR um 14 Takte …
				//	|	|	|		… verlängern (R2 und R3 wären zu retten).
	reti			//	|	|	|

Bei zwei statt drei Anoden-Ausgabeports, wenn bis zu 16 Ausgabeleitungen genügen, kommt man mit einer programmgesteuerten maximalen Pulsweite von 16 (24) aus. Das verringert die Quelltextlänge.

Die langen PWM-Zeiten werden wie gewohnt in einer ISR ausgegeben; die Besonderheit hier ist nur, dass der nächste Unterbrechungszeitpunkt gleich mit aus der Tabelle bzt (Bitzeit-Tabelle) im festen Offset ausgelesen wird. Das erspart das Vorhalten eines zweiten Zeigerregisters. Das jeweils dritte Byte in bzt ist ungenutzt.

TIMER1_COMPA_vect:
	push	YL		//	|	|	|
				//	|	|	|
	push	YH		//	|	|	|
				//	|	|	|
	movw	YL,r2		//	|	|	|
	ld	r2,Y+		//	|	|	|
	ld	r3,Y+		//	|	|	|
	ld	r4,Y+		//	|	|	|
	out	PORTB,r2	//	128+	|	|
	out	PORTD,r3	//	|	128+	|
	out	PORTC,r4	//	|	|	128+
	ldd	r2,Y+(bzt-(abt+8*3)) //	|	|	|
	ldd	r3,Y+(bzt-(abt+8*3)+1)//|	|	|
	out	OCR1AH,r3	//	|	|	|	Hier sollte es nicht passieren …
	out	OCR1AL,r2	//	|	|	|	… dass OCR1A ≤ TCNT1 gerät!
	movw	r2,YL		//	|	|	|	Sonst bleiben die LEDs stehen …
	pop	YH		//	|	|	|	… bis zum nächsten Katodenwechsel.
				//	|	|	|	Dazu muss die Interruptsperrzeit …
	pop	YL		//	|	|	|	… kurz genug sein, was mit V-USB nicht …
				//	|	|	|	… zu machen ist. Da flackert's eben!
	reti			//gesamt: 35 Takte

Beide ISRs verändern keine Flags!

Das Rundherum

Die Werte, die vom Y-Zeiger gelesen werden, beinhalten die bitmatrix-gestürzten PWM-Werte. Die Berechnung dieser Tabelle erfolgt genau dann, wenn jeweils das höchstwertige Bit mit 4096 CPU-Takten ausgegeben wird. Das ist ein großer Vorteil der Bitcode-Modulation: Man hat immer eine größere Lücke mit fester Zeit für Berechnungen frei. Dieser Quelltextabschnitt sowie die gesamte Initialisierung ist recht umfangreich, deshalb hier der Verweis auf das Testprojekt.

Die stetige Neuberechnung mitsamt Entlogarithmierung aus einer 8-Bit-Helligkeits-Tabelle (also ein char lht[162]) erspart Race-Conditions beim Zugriff auf Mehr-Byte-Werte. Auch eine globale Helligkeitseinstellung erfolgt in der ISR; die Veränderung der Speisespannung hätte zwar den gleichen Effekt, würde aber durch die geringere Flussspannung von roten LEDs eine Rotverschiebung bewirken.

Anwendung

Der Programm-Anwender kann ganz einfach in das Byte-Array lht Helligkeitswerte „hineinpoken“, die ISR kümmert sich um den Rest. Natürlich muss die PWM-Ausgabe initialisiert werden (siehe Link oben). Sonst nichts.

Von Vorteil ist ein globaler Helligkeitssteller (Byte-Wert), mit dem die ISR die lht-Werte vormultipliziert. Denn insbesondere bei der Steuerung von RGB-LEDs würde die Entlogarithmierung nach der Gesamthelligkeitseinstellung zu Farbverfälschungen führen.

Prinzipiell ließe sich lht auch 16-bittrig organisieren, um direkten Zugriff auf die PWM-Werte zu haben. Da der Schreibzugriff atomar gestaltet werden muss, um Flackern zu vermeiden, würde das erheblichen Aufwand und unschöne Interruptsperrzeiten einbringen. Eine 16-bit-lht sollte daher besser einem 16-Bit-Controller vorbehalten bleiben.

Noch mehr LEDs

Bei einer exorbitant großen LED-Matrix (könnte man schon fast Bildschirm nennen) mit bspw. 100 Spalten und 30 Zeilen (= 3000 RGB-LEDs!) kommt man nicht umhin,

  • die Maximalhelligkeit der LEDs zu reduzieren (weil maximales Tastverhältniss 1:100) oder dafür geeignete LEDs auszuwählen
  • die Bildfrequenz zu reduzieren (Verdoppeln der Spaltenleitungen halbiert die Bildwiederholfrequenz)
  • die Auflösung zu reduzieren (1 Bit weniger verdoppelt die verfügbare Bildwiederholfrequenz)
  • die CPU-Taktfrequenz zu erhöhen (erhöht die Bildwiederholfrequenz proportional)

Zur Spaltensteuerung kommen nur Schieberegister in Frage, etwa 74HC164, als Treiber entsprechend stromstarke und widerstandsarme Leistungs-MOSFETs. Als Controller bieten sich ARM-basierte Typen oder AVR32 an, deren zeitkritischer Firmware-Teil ebenfalls in Assembler zu programmieren ist und im RAM (nicht im lahmen Flash) ablaufen muss.

Es gibt auch RGB-LEDs mit eingebauter PWM-Steuerung und SPI-Businterface zu kaufen. Diese kann man zu einer langen Kette hintereinanderschalten.

Zusammenfassung

Durch kritische Analyse ist es möglich, eine Software-PWM drastisch zu verbessern und die CPU-Belastung auf ein verschwindend geringes Maß zu reduzieren. Zudem zeigen diese Beispiele ein oft vorkommendes Muster auf, welches gemeinhin als 'Time for Space' (Zeit für Platz) bezeichnet wird. Man meint damit, dass es oft möglich ist, dramatische Einsparungen in der Laufzeit zu erreichen, wenn man gewillt ist dafür Speicherplatz zu opfern.

Version Programmspeicher [Byte] CPU-Belastung [%]
1 284 49
2 324 30
3 968 0,3..1,2
13-bit-PWM ≈ 1500 ≈ 25

Siehe auch

Weblinks