Code-Profiling Code mit schlechter Performance oder unerreichbaren Code aufspüren

Von Aaron Bauch * |

Anbieter zum Thema

Gerade in Embedded-Systemen ist es angesichts meist knapper Ressourcen essentiell, dass Codeelemente mit ineffizienter Arbeitsleistung oder solche, die effektiv von der Anwendung nicht erreicht werden können, gar nicht erst ins fertige Produkt gelangen. Durch den Einsatz von Tools wie Code-Profilern und Performance-Analysatoren kann ein effizienterer und zuverlässigerer Code erstellt werden.

Ressourcenoptimierung ist einer der größten Herausforderungen in der Entwicklung von Embedded-Systemen. Die Verarbeitungsleistung der MCUs und MPUs in Embedded-Systemen ist geringer als die in Desktops, Smartphones und Servern, meist steht nur begrenzter Speicherplatz bereit. Embedded-Anwendungen benötigen also die volle Kontrolle über das Systemverhalten, um mit den verfügbaren Ressourcen die beste Reaktion und Performance zu erzielen.
Ressourcenoptimierung ist einer der größten Herausforderungen in der Entwicklung von Embedded-Systemen. Die Verarbeitungsleistung der MCUs und MPUs in Embedded-Systemen ist geringer als die in Desktops, Smartphones und Servern, meist steht nur begrenzter Speicherplatz bereit. Embedded-Anwendungen benötigen also die volle Kontrolle über das Systemverhalten, um mit den verfügbaren Ressourcen die beste Reaktion und Performance zu erzielen.
(Bild: gemeinfrei / Pixabay)

Jede Anwendung ist einzigartig, und jedes Produkt hat seine individuellen technischen Anforderungen und Spezifikationen. Je nach Anwendung gibt es im Allgemeinen eine maximale Zeit, die für die Verarbeitung von Informationen und die Reaktion auf Eingaben zur Verfügung steht. Hieraus definieren sich die Echtzeitsysteme.

Professionelle Compiler sind sehr leistungsfähig und können einen hinsichtlich Geschwindigkeit und Leistung optimierten Code oder einen möglichst kleinen Code erzeugen. Die Optimierungen werden jedoch oft auf den gesamten Quellcode angewendet – und das ist nicht unbedingt die beste Lösung. Eine Embedded-Anwendung ist oft eine Kombination aus Anwendungscode, Middleware und Echtzeitbetriebssystem (real time operating system, RTOS) sowie Board Support Package (BSP)- oder Hardware Abstraction Layer (HAL)-Treibern.

Es empfiehlt sich, für die verschiedenen Komponenten unterschiedliche Optimierungsstrategien zu wählen. Die BSP- oder HAL-Treiber, die im Allgemeinen als Bibliotheken von den Chip-Herstellern bereitgestellt werden, können wahrscheinlich in Bezug auf die Größe und die Anwendungs- und RTOS-Komponenten auf Geschwindigkeit optimiert werden und erzielen so das beste Ergebnis. Dies kann in einigen Fällen funktionieren, aber angesichts der begrenzten Ressourcen von Embedded-Systemen müssen die verschiedenen Module oder Funktionen feinabgestimmt werden, damit der erzeugte Code in den verfügbaren Speicher passt.

Entwickler in Multicore-Umgebungen haben auch die Möglichkeit, die Last der Anwendung auf die verschiedenen Kerne zu verteilen, um die beste Performance zu erzielen. Wahrscheinlich sind Anpassungen erforderlich, und dies führt zu der Notwendigkeit, die Leistung der Anwendung zu messen und zu analysieren.

Benchmarks sind ein beliebtes Mittel, um die Leistung eines bestimmten Kerns zu messen und festzustellen, wie sich der erzeugte Code auf die Effizienz auswirkt. Die beliebtesten Benchmarks für Embedded-Hardware sind Coremark und Dhrystone, bei denen es sich hauptsächlich um C-Code dreht. Diese enthalten Implementierungen verschiedener Algorithmen, darunter Listenverarbeitung (Suchen und Sortieren), Matrixmanipulation (allgemeine Matrixoperationen) und Zustandsmaschinenoperationen (Feststellung, ob ein Input-Stream gültige Zahlen enthält).

In vielen Anwendungen kann es jedoch etwas komplizierter sein zu definieren, was eine ausreichende Leistung ist oder ob eine Anwendung tatsächlich eine schlechte Performance hat. Um die gewünschte oder spezifizierte Leistung zu erreichen, muss der tatsächliche Zeitverbrauch eines Codeabschnitts sehr genau gemessen werden. Ermöglicht wird dies durch einen Debugger, der Trace und die Möglichkeit der Protokollierung von Datenzugriffen unterstützt.

Gründe für die Verwendung von Trace

Trace ist eine kontinuierlich gesammelte Sequenz von ausgeführten Anweisungen für einen ausgewählten Teil der Anwendung. Trace kann für jede einzelne Anweisung gesammelt werden, z. B. über die Embedded Trace Macrocell oder bei Arm-Cores durch eine diskrete Ereignisverfolgung über SWO (Serial Wire Output Trace).

Bild 1. Gesammelte Sequenz von ausgeführten Maschinenbefehlen
Bild 1. Gesammelte Sequenz von ausgeführten Maschinenbefehlen
(Bild: IAR Systems)

Vollständige Befehls-Trace-Daten (Full Instruction Trace Data) werden vor allem zur Lokalisierung von Programmierfehlern verwendet, die unregelmäßige Merkmale aufweisen und sporadisch auftreten. Mit Hilfe von Trace-Daten können Embedded-Entwickler den Programmablauf bis zu einem bestimmten Zustand, z. B. einem Anwendungsabsturz, untersuchen und die Trace-Daten verwenden, um den Ursprung des Problems zu lokalisieren. Die Daten können aber auch genaue Informationen über die Performance der Anwendung für jede ausgeführte Routine und jede Codezeile mit zyklusgenauer Genauigkeit liefern. Bild 1 zeigt eine Sequenz von ausgeführten Maschinenbefehlen, die mit Hilfe von Full Instruction Trace gesammelt wurden.

Die Trace-Informationen können auch als Aufrufdiagramm in einer Zeitleiste angezeigt werden. Dies hilft den Entwicklern bei der Analyse der Leistung einer Live-Anwendung anhand der Aufrufdiagrammdaten.

Bild 2: Beispiel für den Abruf zeitlicher Informationen aus der Zeitleiste
Bild 2: Beispiel für den Abruf zeitlicher Informationen aus der Zeitleiste
(Bild: IAR Systems)

Bild 2 stellt ein Beispiel für den Abruf zeitlicher Informationen für die Auswahl oder die Funktionen dar, wobei die Start- und Endzeiten in einem vereinfachten Ansatz als Zykluszählung und Zeit einschließlich der absoluten Startzeit, der Stoppzeit und der Differenz zwischen den beiden angezeigt werden.

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

Ereignisdiagramm und Datenprotokollierung mit instrumentiertem Code

Ereignismeldungen können erzeugt werden, wenn die Ausführung bestimmte Positionen im Anwendungscode passiert. Um die Position im Quellcode seiner Anwendung festzulegen, an der eine Ereignismeldung erzeugt werden soll, muss der Entwickler vordefinierte Präprozessormakros verwenden, die in den meisten Entwicklungstools für Arm verfügbar sind, die die Arm CoreSight-Funktionen unterstützen. In IAR Embedded Workbench für Arm sind beispielsweise die Instrumentierungs-Makros in der Header-Datei arm_itm.h definiert, und die Makroaufrufe müssen in den Quellcode der Anwendung eingefügt werden:

#include <arm_itm.h>void func(void){ITM_EVENT8_WITH_PC(1,25);// Code whose time you want to measure...// end codeITM_EVENT32_WITH_PC(2, __get_PSP());}

Die erste Zeile sendet ein Ereignis mit dem Wert 25 an Kanal 1. Die zweite Zeile sendet ein Ereignis mit dem aktuellen Wert des Stack-Pointers an Kanal 2, was bedeutet, dass der Debugger den Stack-Pointer an einer vordefinierten Codeposition anzeigen kann. Wenn diese Codezeilen während der Programmausführung abgearbeitet werden, werden Ereignisse erzeugt und visualisiert, und somit für eine weitere Analyse verfügbar gemacht.

Bild 3: Mit instrumentiertem Code erzeugte Ereignisse in der Zeitleiste.
Bild 3: Mit instrumentiertem Code erzeugte Ereignisse in der Zeitleiste.
(Bild: IAR Systems)

Bild 3 zeigt die Ereignisse, die erzeugt werden, wenn die Ausführung bestimmte Positionen im Anwendungscode passiert. Dies ist bei der Arbeit mit einem RTOS äußerst hilfreich, da es dem Entwickler dabei hilft, die Aufgabenwechsel während der Ausführung der Anwendung zu analysieren. Es ist aber auch sehr hilfreich, um die Zeit zu messen, die bestimmte Teile oder Funktionen der Anwendung benötigen. Hierbei gilt zu beachten, dass es bei hohen Datenraten mit mehreren Meldungsquellen zu Datenüberlaufproblemen kommen kann. Das liegt daran, dass der SWO zwar eine beträchtliche Bandbreite im Bereich von 10 Megabit pro Sekunde hat, aber für Operationen mit hoher Bandbreite, wie z. B. die Verfolgung vollständiger Befehlsströme oder Hochgeschwindigkeits-Interrupt- und Daten-Sampling-Ereignisse, nicht ausreichend ist.

Gründe für die Verwendung des Profilers

Bild 4: Profiler mit Funktionsaufrufen.
Bild 4: Profiler mit Funktionsaufrufen.
(Bild: IAR Systems)

Profiling kann Embedded-Entwicklern dabei helfen, die Funktionen in ihrem Quellcode zu finden, die bei der Ausführung die meiste Zeit beanspruchen. Dabei gilt es, sich bei der Optimierung des Codes auf diese Funktionen konzentrieren. Profiling kann bei der Feinabstimmung des Codes auf einer sehr detaillierten Ebene helfen, insbesondere bei Assembler-Quellcode. Profiling kann auch dabei helfen, zu verstehen, wo ein kompilierter C/C++-Quellcode Zeit aufwendet, und vielleicht einen Einblick geben, wie dieser für eine bessere Performance umgeschrieben werden kann (siehe Bild 4). Ein solcher Profiler vorfolgt den Programmfluss und erkennt Funktionseingänge und -ausgänge:

  • Bei der Funktion InitFib ist Flat Time 231 (Zyklen) die in der Funktion selbst verbrachte Zeit.
  • Für die Funktion InitFib ist Acc Time 487 (Zyklen) die in der Funktion selbst verbrachte Zeit, einschließlich aller Aufrufe von InitFib.
  • Bei der Funktion InitFib/GetFib ist Acc Time 256 (Zyklen) die Zeit, die innerhalb der Funktion GetFib verbracht wird (aber nur, wenn sie von InitFib aus aufgerufen wird), einschließlich aller Aufrufe der Funktion GetFib.
  • Weiter unten in den Daten finden sich separat die Funktion GetFib und alle ihre Unterfunktionen (in diesem Fall keine).

Es ist klar, dass die Funktion PutFib mit 3174 ausgeführten Zyklen das größte Potenzial für Leistungsoptimierungen aufweist. Ein erster Schritt könnte darin bestehen, PutFib in kleinere Module aufzuteilen. Höhere Geschwindigkeitsoptimierungsstufen könnten ebenfalls helfen.

Performance Monitoring Unit (PMU)

High-End-Arm-Prozessoren auf der Basis von Cortex-A und Cortex-R enthalten eine Performance Monitor Unit (PMU), die nützliche Informationen über die Leistung liefert, z. B. Ereignis- und Zykluszählungen. Der Zugriff auf die PMU-Daten erfolgt über das CP-Register (Co-Prozessor). Um vom Code aus auf die Co-Prozessoren zuzugreifen, werden die speziellen Anweisungen MCR (Move from Register to Co-processor) und MRC (Move from Co-processor to Register) verwendet.

Bild 5: Beispiel für die Leistungsüberwachungsregister.
Bild 5: Beispiel für die Leistungsüberwachungsregister.
(Bild: IAR Systems)

Ein Debugger mit einem Viewer ermöglicht die Überwachung von Ereigniszählern oder CPU-Zyklen durch die PMU. In Bild 5 ist ein Beispiel für die Leistungsüberwachungsregister zu sehen. Kennt der Entwickler die CPU-Taktzykluszeit, kann die tatsächlich verstrichene Zeit leicht berechnet werden.

Zu beachten ist, dass für die Verwendung der Leistungsüberwachung im Hardware-Debugger-System eine Debug Probe (Tastkopf) nötig ist, die über einen Debug-Access-Port (DAP) mit der PMU verbunden werden kann, und dass das Ziel über Memory-Mapped-Register verfügen muss. Sind diese Voraussetzungen nicht erfüllt, können die Werte der Ereigniszähler nur gelesen werden, wenn die Ausführung der Anwendung gestoppt ist.

Unerreichbarer Code

Unerreichbarer Code ist ein Teil des Quellcodes eines Programms, der niemals ausgeführt werden kann, weil es keinen Kontrollflusspfad gibt, um den Code von irgendwo im Rest des Programms aus zu erreichen.

Unerreichbarer Code wird manchmal auch als „toter“ Code bezeichnet, obwohl sich toter Code auch auf Programmzeilen beziehen kann, die zwar ausgeführt werden, aber keine Auswirkungen auf die Ausgabe eines Programms haben. Unerreichbarer Code wird im Allgemeinen aus mehreren Gründen als unerwünscht angesehen:

  • beansprucht unnötig Programmspeicher
  • kann zu einer unnötigen Nutzung des CPU-Befehlscaches führen
  • beansprucht Zeit- und Arbeitsaufwand für das Testen, Warten und Dokumentieren eines Codes, der nie verwendet wird.
  • Ein optimierender Compiler kann ihn einfach eliminieren, was während des Debuggings verwirrend sein kann, wenn in diesem Bereich Haltepunkte gesetzt werden.

Unerreichbaren Code gibt es aus vielen Gründen, wie z. B.:

  • Programmierfehler in komplexen bedingten Verzweigungen
  • unvollständige Tests von neuem oder geändertem Code
  • veralteter Code
  • unerreichbarer Code, den ein Programmierer nicht löschen wollte, weil er mit erreichbarem Code vermengt war
  • potenziell erreichbarer Code, der im aktuellen Anwendungsfall nie benötigt wird
  • Code, der nur zum Debuggen verwendet wird

Unerreichbarer oder nicht verwendeter Code sollte niemals Teil eines Release-Builds der Anwendung sein, es sei denn, es gibt einen triftigen Grund, wie z. B. Code, der Fehler oder Ausnahmen behandelt. Um die Normen für funktionale Sicherheit zu erfüllen, muss außerdem die vollständige Abdeckung durch umfangreiche Tests nachgewiesen werden, was ein Hindernis darstellen kann.

Die Codeabdeckungsfunktion hilft bei der Überprüfung, ob alle Teile der Anwendung ausgeführt wurden. Sie hilft auch dabei, Teile des Codes zu identifizieren, die nicht erreichbar sind.

Gründe für die Verwendung von Codeabdeckungsfunktion

Bild 6: Typischer Analysebericht der Codeabdeckungsfunktion.
Bild 6: Typischer Analysebericht der Codeabdeckungsfunktion.
(Bild: IAR Systems)

Die Codeabdeckungsfunktion ist nützlich beim Entwurf von Testverfahren, um zu überprüfen, ob alle Teile des Codes ausgeführt und somit zumindest mit einem Ausführungspfad geprüft wurden. Sie hilft auch, Teile des Codes zu identifizieren, die nicht erreichbar sind. Bild 6 bildet eine typische Anzeige des Status der aktuellen Codeabdeckungsanalyse ab. Für jedes Programm, Modul und jede Funktion zeigt die Analyse den Prozentsatz des Codes an, der seit dem Einschalten der Codeabdeckungsfunktion bis zu dem Punkt ausgeführt wurde, an dem die Anwendung angehalten wurde. Darüber hinaus werden alle nicht ausgeführten Anweisungen aufgelistet.

Nur die Anweisung, die den eingefügten Funktionsaufruf enthält, wird als ausgeführt markiert. Eine Anweisung gilt als ausgeführt, wenn alle ihre Anweisungen ausgeführt wurden. Wenn eine Anweisung ausgeführt wurde, wird standardmäßig der Prozentsatz entsprechend erhöht und die Anzeige aktualisiert.

Fazit

Mit der Nutzung umfassender Debugger-Funktionen kann ein effizienterer und zuverlässigerer Code erstellt werden. Die Beseitigung von unerreichbarem Code kann die Zuverlässigkeit eines Programms verbessern. Durch den Einsatz von Codeabdeckungstechniken, mit denen sichergestellt wird, dass der gesamte Code, einschließlich des Fehlerbehandlungscodes, ausgeführt und getestet wird, lässt sich außerdem gewährleisten, dass ein System sich auch beim Auftreten von Fehlern wie erwartet verhält.

Durch den Einsatz von Tools wie Code-Profilern und Performance-Analysatoren zur Ermittlung der „Hot Spots“, an denen eine Anwendung die meiste Zeit aufwendet, erfahren Embedded-Entwickler, auf welche Funktionen sie sich konzentrieren müssen, um die Performance ihrer Anwendung zu optimieren und die beste Performance bei vertretbarem Aufwand erzielen können.

Dadurch kann nicht nur sichergestellt werden, dass das System die Echtzeitanforderungen erfüllt, sondern es kann auch ein sehr effektiver Weg sein, um den Gesamtenergiebedarf des Systems zu senken. Es lohnt sich langfristig also, zunächst zu prüfen, ob eine Anwendung wie erwartet funktioniert und im nächsten Schritt den Code zu optimieren: Für eine Verbesserung der Performance und zur Eliminierung von nicht benötigtem Code. (sg)

* Aaron Bauch ist Senior Field Application Engineer bei IAR Systems.

(ID:47932342)