Experience Embedded

Professionelle Schulungen, Beratung und Projektunterstützung

Hardwarenahe Softwareentwicklung

Autor: Christian Siemers, Technische Universität Clausthal, Institut für Prozess- und Produktionstechnik

Beitrag - Embedded Software Engineering Kongress 2015

 

Einleitung

Ein Thema wie Hardwarenahe Programmierung (in einer Hochsprache) sollte es eigentlich gar nicht geben, denn Hochsprache impliziert Hardwareunabhängigkeit – und nicht ein spezifisches Eingehen auf die Eigenheiten selbiger. Dennoch ist dieses Thema aus der Praxis aus folgenden Gründen nicht wegzudenken:

  • Ressourcenbeschränkungen (gerade in eingebetteten Systemen)
  • Umständliche Konfiguration von Peripherieelementen (diese ist eher auf Bit- und Byte-Ebene zu sehen)
  • Extreme Anpassung der Software auf Hardwaregegebenheiten, z.B. bei spezialisierter Hardware oder fehlenden Hardwarekomponenten
  • Echtzeitprogrammierung bei sehr knappen Rechenzeiten

 

Bei hardwarenaher Programmierung handelt es sich oftmals auch um eine Systementwicklung mit Elementen aus dem Bereich Hardware-/Software-Co-Design.

Ressourcenbeschränkungen am Beispiel der diskreten Fouriertransformation

Fouriertransformation (FT):
Die Fouriertransformation ist die Analyse eines (meist zeit-) konti­nuierlichen Signals auf die beinhalteten Frequenzen. Hierbei geht man zunächst von periodischen Signalen aus, die sich also nach einer bestimmten Zeit wiederholen. Diese Wiederholungsfrequenz ergibt dann die so genannte Grundfrequenz, das Frequenzspektrum ist diskret.

Der Übergang von periodischen auf aperiodische Signalformen ergibt dann den Übergang vom diskreten auf das kontinuierliche Spektrum. Umgekehrt kann man die ursprüngliche Signalform g(x) durch phasen- und amplitudenkorrektes Überlagern der im Spektrum vorhandenen Frequenzen wieder erzeugen (Fourier-Synthese). Hier die Formeln zur Berechnung in der reellen Schreibweise, wobei angenommen wird, dass g(x) eine reellwertige Funktion ist (siehe Abbildung 1a, PDF).

Diskrete Fouriertransformation (DFT):
In Rechnern ist die "Zeitachse" niemals kontinuierlich, sondern immer diskret. Dies liegt u.a. daran, dass selbst die AD-Wandler niemals zeitkontinuierlich, sondern immer nur zeitdiskret von der analogen in die digitale Welt übertragen können. AD-Wandler diskretisieren sowohl Daten- als auch Zeitwerte.

Damit werden die Berechnungsintegrale der allgemeinen Fouriertransformation zu Berechnungs­summen, die vergleichsweise einfach in Form von Algorithmen implementiert werden können [1]. T bezeichnet in den Gleichungen (Bild 1b, siehe PDF) die Periode des Signals, Dt den zeitlichen Abstand zweier aufeinanderfolgender Messpunkte.

Die DFT wird genutzt, wenn die Zahl der Messpunkte pro Periode nicht vorherbestimmt oder ungleich einer Zweierpotenz ist. Bei Zweierpotenzen wie 512 oder 1024 jedoch bietet sich die Implementierung als Fast Fourier Transformation (FFT) an. Die Komplexität der DFT liegt bei O(N²), die der FFT bei O(N*log(N)).

DFT-Algorithmus mit Fließkommazahlen

Die Formeln in Bild 1 b) (s. PDF) können vergleichsweise leicht in einem Algorithmus aufgenommen werden, z.B. in C:

void vComputeDFT( unsigned int uiNumOfPoints, int *iValue )

{

   unsigned int k, m;

   double dCoeffAtemp, dCoeffBtemp;

 

   for( k = 0; k < NUM_OF_COEFFICIENTS; k++ )

   {

      dCoeffAtemp = 0.L;

      dCoeffBtemp = 0.L;

 

      for( m = 0; m < uiNumOfPoints; m++ )

      {

         dCoeffAtemp += (double)*(iValue+m) *
                      cos(2.L * PI * m * k / uiNumOfPoints);

         dCoeffBtemp += (double)*(iValue+m) *
                      sin(2.L * PI * m * k / uiNumOfPoints);

      }

 

      dCoeffA[k] = dCoeffAtemp / (double)uiNumOfPoints;

      dCoeffB[k] = dCoeffBtemp / (double)uiNumOfPoints;

   }

}

 

Dieses Programm beinhaltet mit NUM_OF_COEFFICIENTS eine Konstante, die anzeigt, wie viele Koeffizienten a und b aus Bild 1 (siehe PDF) berechnet werden sollen; diese Konstante kann natürlich auch leicht als Variable formuliert werden. Was man aber sehr leicht beurteilen kann: Hier sind eine Menge an Additionen und Multiplikationen enthalten, von der Berechnung der Sinus- und Cosinuswerte ganz abgesehen. Dieser Algorithmus auf einem Mikroprozessor oder DSP implementiert, der keine Floating-Point-Unit aufweist, kann schon überraschend große Laufzeiten aufweisen, da das Floating-Point-Format recht umständlich emuliert werden muss.

Umsetzung in ein Festkommaformat

Es liegt also nahe, die Umsetzung in ein Festkommaformat durchzuführen und dann mit erhöhter Performance zu rechnen. Eine weitere Verbesserung ist z.B. geringerer Platzbedarf, was aber ggf. zweitrangig ist. Die grundsätzliche Formel zur Umsetzung ist (siehe Gleichung 1, PDF)

wobei mit Δ der so genannte Offset (Versatz des Nullpunkts) und mit Q zunächst ein linearer Skalierungsfaktor bezeichnet wird. Diese Abbildung betrifft aber zunächst nur das Mapping einer physikalischen (Prozess-) Größe auf eine analoge (!) Größe. Interpretiert man die Formel (1) jedoch so, dass Q eine Quantisierungsgröße darstellt, und dass die Abbildung in Quanten erfolgt, dann erhalten wir die gewünschte Abbildung von physikalischen Größen in Darstellungen in Computer-verwendbaren Datenwerten.

Gleichung (2, siehe PDF) wird gerne noch dahingehend vereinfacht, dass man den Offset Δ zu Null setzt. Hierdurch werden arithmetische Operationen wie Addition, Subtraktion usw. erheblich vereinfacht.

So schön einfach die Formel (2) auch erscheint, sie birgt noch einen Pferdefuß in sich, denn sie gilt nur für einen eingeschränkten Wertebereich von xphys. Diese Größe repräsentiert ja die physikalische Außenwelt bzw. eine Messgröße, und da der Zahlenbereich aller Darstellungen im Rechner beschränkt ist, ist auch die Messgröße in einem zulässigen Intervall zu halten.

Mehr noch, wenn man xphys durch xfloat ersetzt – genau dies soll hier ja geschehen, es soll die Fließkomma- durch eine Fixkomma-Darstellung ersetzt werden –, dann gelten für beide Zahlenformate Intervallgrenzen für die Zulässigkeit, aber eben unterschiedliche. Dies muss bei der Umsetzung unbedingt berücksichtigt werden.

Das Qm.n-Format

Das verwendbare Festkommaformat muss man sich als Abwandlung des bekannten Integer-Formats denken, bei dem der Abstand zwischen zwei benachbarten darstellbaren Werten eben nicht 1, sondern z.B. 0,5, 0,25 oder 0,1 ist. Üblicherweise nimmt man in der digitalen Welt auch hierfür Zweier-Potenzen, und das resultierende Format wird als Qn.m bezeichnet.

Der Index n steht hierbei für die Anzahl der Bits vor dem Komma, m für die Anzahl danach. Ferner wird wie im Integer-Fall zwischen vorzeichenlosen und vorzeichenbehafteten Darstellungen unterschieden, wobei dies nicht in der Formatbezeichnung sichtbar wird.

Rechnen mit Qn.m unterscheidet sich nicht sonderlich vom Rechnen mit Integerzahlen, solange das Komma bei den Operanden immer an der gleichen Stelle steht. Sind die Darstellungen der Operanden allerdings unterschiedlich, d.h., stimmen n und m für die jeweiligen Operanden untereinander nicht mehr überein, dann ist Gehirnarbeit und Bitschieberei angesagt.

Sinus und Cosinus

Und es wartet noch eine Aufgabe im Sourcecode (Listing 2), die für eine Übersetzung gelöst werden muss: Die transzendenten Funktionen Sinus und Cosinus. Diese Funktionen sind üblicherweise für Fließkommawerte in Bibliotheken vorhanden, aber genau dieser Datentyp soll hier nicht verwendet werden.

#define PI              3.14159265358979L

#define LIMIT           2048

 

#define FRACTION_BITS   30

/* This results in a Q1.30 format with sign  */

#define LIMIT_TEST      960

void main()

{

   int k;

   long int lResult;

   double dfResult;

 

   for( k = 0; k < LIMIT; k++ )

   {

      dfResult = sin( 2.L * PI * (double)k / (double)LIMIT );

      lResult = (long int) (dfResult * (1 << FRACTION_BITS));

      printf( „%lf  %08x “, dfResult, lResult );

   }

}

 

Die Lösung besteht z.B. darin, die Sinus- und Cosinuswerte vor der Laufzeit zu berechnen und in einem Datenbereich im Qn.m-Format (hier z.B. vorzeichenbehaftetes Q1.30) zu speichern (siehe Listing 2). Um dann zur Laufzeit präzise Werte zu allen Eingangsparameter verfügbar zu haben, kann man sich aus der Grundtabelle eine zweite Tabelle (oder direkt die Werte) durch lineare Interpolation erstellen. Dieses Verfahren ist sehr schnell, auch für einfache Prozessoren.

... und nun die DFT

Mit den gerade diskutierten Ansätzen zur hardwarenahen Softwareentwicklung wird nun die Transformation des Algorithmus aus Listing 1 in eine Version, die lediglich Integer-Wert nutzt, durchgeführt. Folgende Details sind dabei wichtig:

  • Alle Rechnungen werden als Integerberechnungen durchgeführt, kein Casting!
  • Eine (Muster-)Sinuskurve wird in Form von 513 (= 512 + 1) Werten im signed-Q1.30-Format gespeichert, und zwar nur das erste Viertel (weil sich daraus alle anderen ergeben). Die vollständige Kurve hat dann 2048 Werte.
  • Der Zugriff auf diese Wertetabelle ist durch Zugriffsfunktionen wie i32GetCosineValue( uint16 ui16Index ) gekapselt. Dies ist eine Anleihe aus der objektorientierten Programm­entwicklung.
  • Für die DFT sind eine Sinus- und eine Cosinustabelle notwendig, deren Periode exakt gleich der Anzahl der Messpunkte ist. Diese wird zu Beginn berechnet (durch lineare Interpolation), und anschließend werden die jeweiligen Werte von dort mithilfe der Funktionen (i32GetSine­TableValue() und i32GetCosineTableValue()gelesen.

 

Die zentrale Routine zur Berechnung der DFT lautet nun:

void vComputeDFT( uint16 ui16NumOfPoints, int16 *i16Value )

{

   uint16 k, m, ui16Index;

   int32 i32CoeffATemp, i32CoeffBTemp;

 

   for( k = 0; k < NUM_OF_COEFFICIENTS; k++ )

   {

      i32CoeffATemp = 0;

      i32CoeffBTemp = 0;

 

      for( m = 0, ui16Index = 0; m < ui16NumOfPoints; )

      {

         i32CoeffATemp += *(i16Value+m) *
                            i32CosineTable[ui16Index];

         i32CoeffBTemp += *(i16Value+m) * i32SineTable[ui16Index];

         m++;

         ui16Index = (ui16Index + k) % ui16NumOfPoints;

      }

 

      i32CoeffA[k] = i32CoeffATemp;

      i32CoeffB[k] = i32CoeffBTemp;

   }

}

 

Als Testfunktion wird ein Rechteckt mit 5 Perioden auf 1000 Punkte gewählt, da hier das Ergebnis bekannt ist: Wenn die Grundwelle eine Höhe von 1 aufweist, dann sind alle geraden Oberwellen 0, und alle ungeraden Oberwellen (Index k) weisen eine Amplitude von 1/k auf. Bild 2 zeigt das Ergebnis für die in Floating Point realisierte Variante, Bild 3 (siehe PDF) für die im Festkommaformat.

Die Bilder weisen unübersehbare Unterschiede auf, obwohl sie den gleichen Algorithmus imple­mentieren. Zudem sieht man zumindest qualitativ, dass Bild 2 (siehe PDF) ein korrektes Ergebnis anzeigt, da das Ergebnis mit der Theorie übereinstimmt. Hierzu gleich eine Anmerkung: Will man die wirkliche Korrektheit der Ergebnisse prüfen, muss man zu einer logarithmische Darstellung wechseln und die Differenz der berechneten und der theoretischen Werte logarithmisch betrachten. Je nach Anzahl der signifikanten Bits ergibt dies einen Wert, der unter dem erreichbaren Signal/Rausch-Abstand liegen muss.

Zurück zur Auswertung: Der Grund für das Scheitern der DFT-Berechnung im Festkommaformat sind Datenüberläufe. Die Berechnung verwendet Sinus- und Cosinuswerte von 16 bit Breite. Diese werden in dem Beispiel mit 13 bit Werten (dem gewählten Format für das Rechteck, 12 bit Wert + Vorzeichen) multipliziert, dies ergibt 29 signifikante Bits, und dann 1000fach aufsummiert, was zu 10 weiteren Bits führt (10 = log2(1024)). Insgesamt können die Ergebnisse also 39 Bit Breite aufweisen, da wundert es nicht, wenn es für 32 bit Variablen überlaufen kann.

Die Lösung des Problems

Man kann nun an 2 verschiedenen Schrauben justieren, um das Problem des Datenüberlaufs zu lösen: geringere Datenbreiten, also ein Verlust an Genauigkeit, oder größere Datentypen, z.B. also 64 bit. Der erste Weg bietet sich an, wenn die Genauigkeit, die erzielbar wäre, gar nicht gefordert ist; der zweite, wenn der Genauigkeitsverlust nicht akzeptabel ist. Die Aufteilung auf zwei Variablen, also die Emulation höherer Datenbreiten durch softwaremäßige Kopplung bleibt ebenfalls als durchaus performante Lösung. Bild 4 (siehe PDF) zeigt das Ergebnis der Fouriertransformation für einen korrekten, die entstehenden Datenbreiten berücksichtigenden Algorithmus im Festkommaformat.

Die Performance ist dabei schon beeindruckend. Während ein DSP mit einer intrinsischen Datenbreite von 16 Bit ohne Fließkommaeinheit für eine 1000-Punkte-DFT immerhin 7 Sekunden benötigte, schlugen für die Festkommavariante nur 700 ms und nach Optimierung (Abbildung der Divisions­befehle auf sukzessive Subtraktion) nur 120 ms zu Buche.

Als Fazit bleibt, dass beim Übergang auf Festkommadarstellung ein einfaches Codieren des Algo­rithmus oft nicht zielführend ist, da insbesondere bei arithmetischen Operationen mit Datenüberläufen zu rechnen ist. Es ist dringend zu empfehlen, sich mit den möglichen Datenwerten, auch den Zwischenergebnissen, zu befassen.

Zugriff auf Hardware mit besonderen Eigenschaften

Ein ganz anderes Thema sind Lese- und Schreibzugriffe auf Bereiche, die sich im Programm wie RAM-Speicher verhalten (Lese- und Schreizugriffe), diesen aber nur durch eine besondere Funktionalität emulieren. Wenn dann der Compiler oder der Treiber diesen Zugriff noch verdeckt, kann es schon zu unerwünschten Wechselwirkungen kommen.

Variablen im EEPROM

Das Thema "Variablen im ROM" sieht nach einem Widerspruch in sich aus, stellt doch ein ROM einen Festspeicher, ein Read-Only Memory dar. Seit der Einführung von EEPROM, also einem elektrisch löschbarem, wiederbeschreibbarem ROM, ist das jedoch kein Widerspruch mehr, und einige Compiler unterstützen das sogar, indem mithilfe des (nicht-Standard-) Schlüsselwortes eeprom dem Compiler/Linker angezeigt wird, die Werte auch im EEPROM abzulegen.

Angenommen, ein Satz von 16 Daten soll in das EEPROM eines Mikrocontrollers geschrieben werden, weil die Werte bei Abschalten der Spannung gespeichert sein sollen. Dies können z.B. Konfigurationswerte sein, die bei jedem Neustart geladen werden.

Das Schreiben in das EEPROM wird wie erwähnt von entsprechenden C-Compilern mithilfe von Laufzeitbibliotheken unterstützt, aus Sicht der Anwendungsprogrammierung ist dies eine einfache Sache:

eeprom int eepIBasicConfig[16];

int k, iConfig[16];

for( k = 0; k < 16; k++ )

eepIBasicConfig[k] = iConfig[k];

 

Das Gefährliche an dieser Codesequenz ist, dass hinter der scheinbar einfachen Wertzuweisung an eepIBasicConfig[] eine ganze Laufzeitroutine mit blockierender Kommunikation steckt, was so nicht ersichtlich ist. Meistens wird der erste Wert sofort geschrieben (weil in der Hardware ein Pufferplatz vorhanden ist), ab dem zweiten Wert wartet man dann auf die Fertigstellung des Schreibens des Vorgängerwerts – was durchaus einige Millisekunden dauern kann.

Im Klartext: Ein derartiges Schreiben in den Speicher, im RAM zügig durchführbar, kann hier zu überraschend langen Laufzeiten führen. Ähnliche Effekte ergeben sich, wenn man auf Peripherie über ein Netzwerk- oder Bussystem (z.B. I²C bzw. TWI in kleinen Mikrocontrollersystemen) zugreift, das durchaus durch andere Teilnehmer blockiert sein kann, auch hier können lange, sogar unbestimmte Wartezeiten entstehen.

Die Kunst der nicht-blockierenden Kommunikation

Die Kunst der nicht-blockierenden Kommunikation will es nun, dass man nur hineinschreibt, wenn dies sofort möglich ist (weil der Puffer oder das Netzwerk frei ist, die notwendigen Ressourcen also verfügbar sind), ansonsten sich merkt, dass noch etwas zu schreiben/lesen ist, und zur weiteren Programmausführung zurückkehrt. Hierbei muss dann gewährleistet sein, dass das Schreiben etwa durch zyklisches Design irgendwann fertig gestellt wird, und dass es keine Seiteneffekte gibt.

Ideal ist es hier, wenn man im Rahmen eines kleinen Betriebssystems einen Thread starten kann, der genau die gewünschte Aktion startet und sich mit dem gleichen Parameter aufruft, falls die Aktion nicht durchführbar war. Wenn der erneute Aufruf später wieder aktiviert wird, wird erneut geprüft, ob die Aktion durchzuführen ist, bis alles fertiggestellt ist bzw. anderweitig abgebrochen wird.

Dies bedeutet allerdings einen etwas höheren Initialaufwand. Wie es ohne Betriebssystem gemacht wird, steht beispielsweise in [1], ansonsten kann man solche nicht-blockierende Kommunikation auch in Interrupt-Service-Routinen, durch einen Timer aufgerufen, unterbringen.

Fazit

Es sollte hier exemplarisch gezeigt werden, was eine hardwarenahe Softwareentwicklung bedeutet und welche Konsequenzen das für die Entwicklung hat. Die Software verliert hierbei natürlich ihren Anspruch, Hardware-unabhängig und damit portierbar zu sein. Dies ist eine häufig implizit vorhandene extrafunktionale Anforderung.

Andererseits werden andere, ebenfalls extrafunktionale Anforderungen erfüllbar(er), so dass es sich lohnt, über einige Anpassungen nachzudenken.

In jedem Fall bedeutet hardwarenahe Softwareentwicklung, dass man sich mit der Hardware und ihren Eigenschaften und/oder den Eigenschaften von Datentypen befassen muss, sowohl, um diese auszunutzen, als auch, um nicht zu scheitern.

Literatur

[1]  Christian Siemers, Embedded Systems Engineering Handbuch.
http://www.elektronikpraxis.vogel.de/themen/embeddedsoftwareengineering/management/articles/340538/

 

Beitrag als PDF downloaden

 


Implementierung - unsere Trainings & Coachings

Wollen Sie sich auf den aktuellen Stand der Technik bringen?

Dann informieren Sie sich hier zu Schulungen/ Seminaren/ Trainings/ Workshops und individuellen Coachings von MircoConsult zum Thema Implementierung /Embedded- und Echtzeit-Softwareentwicklung.

 

Training & Coaching zu den weiteren Themen unseren Portfolios finden Sie hier.


Implementierung - Fachwissen

Wertvolles Fachwissen zum Thema Implementierung/ Embedded- und Echtzeit-Softwareentwicklung steht hier für Sie zum kostenfreien Download bereit.

Zu den Fachinformationen

 
Fachwissen zu weiteren Themen unseren Portfolios finden Sie hier.