Architektur 10 Techniken für kompakten Code in RISC-V-basierten Anwendungen

Von Rafael Taubinger, Senior Product Marketing Manager, IAR Systems

Anbieter zum Thema

In diesem Artikel werden wir uns mit 10 Techniken befassen, die dazu beitragen können, kompakten Code in Anwendungen zu erstellen, die auf der RISC-V-Architektur basieren. Dieser Artikel richtet sich an Entwickler, die mit RISC-V-basierten Anwendungen arbeiten und ihren Code optimieren möchten.

Prototyp eines RISC-V Mikroprozessors aus dem Jahr 2013
Prototyp eines RISC-V Mikroprozessors aus dem Jahr 2013
(Bild: / CC0)

Embedded-Entwickler sind immer bestrebt, den Code in ihren Projekten so weit als möglich zu optimieren. Für manche bedeutet das, den am schnellsten auszuführende Code zu erstellen. Für andere, so viel Code oder Funktionalität wie möglich in ihren Prozessor zu integrieren. Was können Entwickler RISC-V-basierter Anwendungen tun, damit der Compiler einen optimalen kompakten Code erzeugt?

Welche Kernfunktionen stehen zur Verfügung?

Der Basis-Befehlssatz RV32I kann die in Tabelle 1 aufgeführten Standarderweiterungen enthalten:

Darüber hinaus gibt es viel mehr Erweiterungen. Die vollständige Liste der RISC-V-ISA-Basis und -Erweiterungen finden sich bei Wikipedia. Die meisten Erweiterungen sind ratifiziert oder eingefroren, aber es gibt neue, an denen derzeit gearbeitet wird. Ein Beispiel für unterstützte Erweiterungen in verschiedenen Kernen zeigt Bild 1.

Bild 1: Beispiel für die Unterstützung von Standard-Erweiterungen
Bild 1: Beispiel für die Unterstützung von Standard-Erweiterungen
(Bild: IAR Systems)

Der generische RISC-V 32-Bit-Prozessor (RV32) unterstützt beispielsweise M (Integer Multiplication), F (Single Floating Point), D (Double Floating Point) und C (Compressed Instructions). C reduziert die statische und dynamische Codegröße durch Hinzufügen von kurzen 16-Bit-Befehlen für Operationen. Das führt zu einer durchschnittlichen Reduzierung der Codegröße um 25 bis 30 Prozent und trägt dazu bei, die Stromaufnahme und die Speichernutzung zu verringern. Darüber hinaus wurde der RV32E-Basisbefehlssatz (Embedded) entwickelt, um einen noch kleineren Basiskern für Embedded-Mikrocontroller mit 16 Registern bereitzustellen.

Entwicklern steht es frei, ihre eigenen Erweiterungen für spezielle Anforderungen zu implementieren, z. B. für maschinelles Lernen (ML), Anwendungen mit geringem Energiebedarf oder optimierte SoC für Messgeräte und Motorsteuerungen. Der Zweck der Standarderweiterungen oder benutzerdefinierten Erweiterungen ist es, eine schnellere Reaktionszeit von Berechnungen und Verarbeitungen zu erreichen, die in der Hardware durchgeführt werden und meist nur einen oder wenige Zyklen benötigen.

Warum professionelle Tools für RISC-V?

Bild 2: Compiler-Optimierungen mit verschiedenen Transformationen.
Bild 2: Compiler-Optimierungen mit verschiedenen Transformationen.
(Bild: IAR Systems)

Mit dem Wachstum von RISC-V steigt auch der Bedarf für professionelle Tools, die die Kernfunktionen und Erweiterungen der Befehlsarchitektur voll ausschöpfen. Ein gut konzipierter und optimierter SoC sollte auch den bestmöglich optimierten Code ausführen, damit Unternehmen schnell ihre Innovationen realisieren, differenzierende Produkte herstellen und Profit daraus ziehen können.

Wenn es um die Codedichte geht, zählt jedes Byte, das eingespart werden kann. Professionelle Tools helfen dabei, die Anwendung so zu optimieren, dass sie den Anforderungen am besten entspricht. Durch die Optimierung lassen sich Kosten sparen, weil Prozessoren mit kleinerem Speicher verwendet werden können, oder der Wert der Anwendung steigern, weil das Produkt mit neuen Funktionen ausgestattet wird.

Ein professioneller Compiler für RISC-V kann im Vergleich zu anderen Werkzeugen durchschnittlich einen 7 bis 10 Prozent kleineren Code erzeugen.

Compilerfreundlichen Code schreiben für noch mehr Optimierungen

Ein optimierender Compiler versucht, einen kleinen und schnellen Code zu erzeugen, indem er die richtigen Anweisungen in der besten Reihenfolge für die Ausführung auswählt. Dies geschieht durch die wiederholte Anwendung einer Reihe von Transformationen auf das Quellprogramm. Die meisten Optimierungen folgen mathematischen oder logischen Regeln, die auf einer soliden theoretischen Grundlage beruhen. Andere Transformationen beruhen auf Heuristiken, wobei die Erfahrung gezeigt hat, dass einige Transformationen oft zu gutem Code führen oder Möglichkeiten für weitere Optimierungen eröffnen.

Die Art und Weise, wie Quellcode geschrieben wird, kann also bestimmen, ob eine Optimierung auf ein Programm angewendet werden kann oder nicht. Manchmal können kleine Änderungen im Quellcode einen erheblichen Einfluss darauf haben, wie effizient der Compiler den Code erzeugt.

Versucht der Entwickler seinen Code in so wenigen Zeilen wie möglich zu schreiben und verwendet ?:-Ausdrücke, Postincrements und Kommaausdrücke, um viele Seiteneffekte in einen einzigen Ausdruck zu packen, wird der Compiler keinen effizienteren Code erzeugen. Am besten ist es also, den Code in einem Stil zu schreiben, der leicht zu lesen ist.

Jetzt Newsletter abonnieren

Verpassen Sie nicht unsere besten Inhalte

Mit Klick auf „Newsletter abonnieren“ erkläre ich mich mit der Verarbeitung und Nutzung meiner Daten gemäß Einwilligungserklärung (bitte aufklappen für Details) einverstanden und akzeptiere die Nutzungsbedingungen. Weitere Informationen finde ich in unserer Datenschutzerklärung.

Aufklappen für Details zu Ihrer Einwilligung

Entwickler können dem Compiler helfen, bessere Entscheidungen zu treffen, indem sie folgende 10 Techniken beim Erstellen des Quellcodes beachten:

1.) Jede Funktion nur einmal aufrufen. Ein Compiler hat generell Schwierigkeiten, gemeinsame Unterausdrücke zu untersuchen, da die Unterausdrücke Nebeneffekte haben können, von denen der Compiler nicht a priori weiß, ob sie notwendig sind. Daher wird der Compiler dieselbe Funktion mehrfach aufrufen, wenn er dazu angewiesen wird – das verschwendet Codeplatz und erhöht den Ausführungsaufwand. Es ist besser, die Funktion einer Variablen zuzuweisen (die höchstwahrscheinlich in einem Register gespeichert wird) und Operationen durchzuführen, während sie sich in einem leicht zugänglichen Register befindet (Bild 3).

Bild 3: Ein guter Code ruft jede Funktion nur einmal auf.
Bild 3: Ein guter Code ruft jede Funktion nur einmal auf.
(Bild: IAR Systems)

2.) Übergabe per Referenz statt per Kopie. Übergibt man einen Zeiger auf ein Primitiv statt direkt auf das Primitiv selbst, erspart sich der Compiler den Overhead des Kopierens dieses Primitivs irgendwo im RAM oder in einem Register. Das kann bei einem großen Array eine ganze Menge an Ausführungszeit sparen. Denn durch die Übergabe per Kopie wird der Compiler gezwungen, dem Primitiv Code zum Kopieren des Inhalts einzufügen.

3.) Richtige Datengröße verwenden. Einige MCUs wie der 8051 oder AVR sind 8-Bit-Mikrocontroller, andere wie der MSP430 sind 16-Bit-Mikrocontroller und es gibt einige 32-Bit-Mikrocontroller wie Arm oder RISC-V. Nutzt der Entwickler eine unpassende Datengröße für den Mikrocontroller, muss der Compiler zusätzlichen Overhead erzeugen, um die darin enthaltenen Daten zu interpretieren. So muss z. B. eine 32-Bit-MCU Verschiebe-, Maskierungs- und Vorzeichenerweiterungsoperationen durchführen, um auf den Wert zu kommen, den sie zur Durchführung ihrer Operation benötigt. Es ist daher am besten, die natürliche Größe der MCU für den jeweiligen Datentypen zu verwenden. Ausgenommen sind Anwendungen, wo es einen triftigen Grund gibt, z. B. bei I/O-Operationen, für die eine genaue Anzahl von Bits benötigt wird, oder bei größeren Typen (wie z. B. einem Zeichen-Array), die zu viel Speicher benötigen.

4.) Vorzeichen (signedness) angemessen einsetzen. Die Vorzeichenabhängigkeit einer Variablen kann sich auf den vom Compiler erzeugten Code auswirken. Zum Beispiel wird die Division durch eine negative Zahl anders behandelt (nach den Regeln der Sprache C) als die Division durch eine positive Zahl. Wird also eine vorzeichenbehaftete Zahl verwendet, die in der Anwendung niemals negativ sein wird, kann dies zu einer zusätzlichen Test- und Sprungbedingung im Code führen, die sowohl Codeplatz verschwendet als auch die Ausführungszeit verlängert. Wenn der Zweck einer Variablen darin besteht, Bitmanipulationen vorzunehmen, sollte sie außerdem vorzeichenlos sein, da es sonst zu unbeabsichtigten Folgen beim Verschieben und Maskieren kommen kann.

5.) Casts richtig einsetzen. C führt oft implizite Casts durch (z. B. zwischen Floats und Integers und zwischen Ints und Longs), und das bleibt nicht folgenlos: Casts von einem kleineren Typ zu einem größeren Typ verwenden vorzeichenerweiternde Operationen, während Casts zu und von einem Float dazu führen, dass die Fließkommabibliothek benötigt wird (was den Code dramatisch vergrößern kann). Natürlich sollten explizite Casts so weit wie möglich vermieden werden, um diesen zusätzlichen Overhead zu verhindern. Dieses Problem lässt sich leicht erkennen, wenn ein Desktop-Programmierer, der daran gewöhnt ist, Ints und Funktionszeiger austauschbar zu verwenden, den Sprung zur Embedded-Programmierung macht:

Bild 4: Ein Negativbeispiel für die Vermischung von Funktionszeigern und ganzen Zahlen.
Bild 4: Ein Negativbeispiel für die Vermischung von Funktionszeigern und ganzen Zahlen.
(Bild: IAR Systems)

6.) Funktionsprototypen verwenden. Ist kein Prototyp vorhanden, schreiben die C-Sprachregeln vor, dass alle Argumente in eine Ganzzahl umgewandelt werden müssen, und das kann – wie bereits erwähnt – zu einem unnötigen Overhead in einer Laufzeitbibliothek führen.

7.) Globale Variablen in temporäre Variablen einlesen. Wird innerhalb einer Funktion mehrmals auf eine globale Variable zugegriffen, sollte diese in eine lokale temporäre Variable eingelesen werden. Andernfalls muss die Variable bei jedem Zugriff aus dem Speicher gelesen werden. Durch das Einlesen in eine lokale temporäre Variable wird der Compiler dem Wert wahrscheinlich ein Register zuweisen, so dass er effizienter mit ihm arbeiten kann.

Bild 5. Einlesen einer globalen Variable in eine lokale temporäre Variable.
Bild 5. Einlesen einer globalen Variable in eine lokale temporäre Variable.
(Bild: IAR Systems)

8.) Auf Inline-Assembler verzichten. Die Verwendung von Inline-Assemblern wirkt sich sehr nachteilig auf den Optimierer aus. Da der Optimierer nichts über den Codeblock weiß, kann er ihn nicht optimieren. Außerdem kann er keine Befehlsplanung für den handgeschriebenen Block durchführen, da er nicht weiß, was der Code tut (dies kann besonders bei DSPs nachteilig sein). Außerdem muss der Entwickler den handgeschriebenen Code jedes Mal überprüfen, um sicherzustellen, dass er korrekt in den optimierten C-Code eingefügt wird, damit er keine unbeabsichtigten Nebeneffekte erzeugt. Die Portabilität von Inline-Assembler ist sehr schlecht, so dass dieser neu geschrieben werden muss (und seine Auswirkungen verstanden werden müssen), wenn er auf eine neue Architektur übertragen werden soll. Müssen doch mal Inline-Assembler verwendet werden, sollten diese in Assembler-Dateien aufgeteilt und vom Quellcode getrennt werden.

9.) “Cleveren” Code meiden. Manche Entwickler glauben irrtümlicherweise, dass der Code durch das Schreiben weniger Quelltextzeilen und die geschickte Verwendung von C-Konstruktionen kleiner oder schneller wird (d. h. sie übernehmen die Arbeit des Compilers). Tatsächlich ist das Ergebnis aber ein schwer lesbarer Code, der nur von dem verstanden werden kann, der ihn ursprünglich geschrieben hat, und der schwer zu kompilieren ist. Schreibt der Entwickler seinen Code klar und einfach, wird dieser besser lesbar und der Compiler kann besser entscheiden, wie er den Code optimiert. Z.B. angenommen, das niedrigste Bit einer Variablen b soll gesetzt werden, wenn die niedrigsten 21 Bits einer anderen Variablen gesetzt sind. Der „clevere“ Code verwendet den !-Operator in C, der Null zurückgibt, wenn das Argument nicht Null ist („wahr“ ist in C jeder Wert außer Null), und Eins, wenn das Argument Null ist. Die einfache Lösung lässt sich leicht in eine bedingte Anweisung, gefolgt von einer Anweisung zum Setzen von Bits, kompilieren, da die Operation zum Bitsetzen offensichtlich ist und die Maskierung wahrscheinlich effizienter ist als die Verschiebung. Im Idealfall sollten beide Lösungen denselben Code erzeugen. Der „intelligente“ Code kann jedoch zu mehr Code führen, da er zwei !-Operationen ausführt, von denen jede in eine Bedingung kompiliert werden kann.

Bild 6. Ein einfacher Code ist oft effizienter als ein „cleverer“ Code.
Bild 6. Ein einfacher Code ist oft effizienter als ein „cleverer“ Code.
(Bild: IAR Systems)

Ein weiteres Beispiel ist die Verwendung von bedingten Werten in Berechnungen. Der „clevere“ Code führt zu einem größeren Maschinencode, da der erzeugte Code den gleichen Test wie der einfache Code enthält und eine temporäre Variable hinzufügt, die die Eins oder Null enthält und die zu str addiert werden soll. Der einfache Code kann ein simples Inkrement anstelle einer vollständigen Addition verwenden und erfordert nicht die Erzeugung von Zwischenergebnissen.

Bild 7. Weniger Berechnungen mit einem einfachen Code.
Bild 7. Weniger Berechnungen mit einem einfachen Code.
(Bild: IAR Systems)

10.) Klare Reihenfolge in der Struktur. Ist eine Struktur so angelegt, dass man von einem Element der Struktur zum nächsten schreiten kann, anstatt herumzuspringen, kann der Compiler die Vorteile von Inkrementoperationen nutzen, um auf das nächste Element der Struktur zuzugreifen, anstatt zu versuchen, dessen Offset aus dem Strukturzeiger zu berechnen. Bei einer statisch zugewiesenen Struktur lässt sich auf diese Weise kein Code einsparen, da die Adressen a priori berechnet werden, aber dies in den meisten Anwendungen dynamisch durchgeführt wird.

Fazit

Embedded-Compiler haben sich in den letzten dreißig Jahren stark weiterentwickelt, insbesondere im Hinblick auf ihre Optimierungsmöglichkeiten. Moderne Compiler verwenden viele verschiedene Techniken, um einen sehr schlanken und effizienten Code zu erzeugen. Damit können sich die Entwickler darauf konzentrieren, ihren Quellcode klar, logisch und prägnant zu schreiben und dabei ein Optimum an Effizienz in ihrer Software zu erreichen. Ein Compiler ist ein erstaunlich komplexes Stück Software, das zu großen Optimierungen fähig ist – und mit ein paar einfachen Techniken kann man ihm zu noch größerer Effizienz verhelfen.

Was ist RISC-V?

RISC-V (Reduced Instruction Set Computer) ist eine offene, kostenlose und modulare ISA (Instruction Set Architecture), die es ermöglicht, benutzerdefinierte Prozessoren und Systeme zu entwickeln. Es wurde von einer Gruppe von Wissenschaftlern und Ingenieuren an der Universität von Kalifornien, Berkeley entwickelt. RISC-V hat eine einfache und klare Architektur, die es ermöglicht, leistungsfähige und energieeffiziente Prozessoren zu entwerfen. Es unterstützt sowohl 32-Bit- als auch 64-Bit-Prozessoren und bietet Erweiterungen für verschiedene Anwendungen wie z.B Machine Learning, Sicherheit und Kryptografie. RISC-V ermöglicht es, Prozessoren und Systeme zu entwickeln, die speziell für bestimmte Anwendungen optimiert sind und die Hardwarekosten reduzieren.

 (mbf)

(ID:49018178)