Programmieren in C/C++: Toolchain/Entwicklungsumgebung

Toolchain

Bearbeiten

Die C-Toolchain besteht im Wesentlichen aus dem Compiler, den Linker und dem Loader:

  • Compiler zur Übersetzung der einzelnen C-Dateien in Objekt-Dateien
  • Linker (OS-Funktionalität) zum Binden mehrerer Objekt-Dateien zu einer (ausführbaren) Executable-Datei
  • Loader (OS-Funktionalität) welche die Executable-Datei vom sekundären Speichermedium in den Speicher des Prozess lädt und das Programm durch einen Sprung zu main() zur Ausführung bringt (inkl. Bereitstellung der Übergabeparameter)

→ Die C-Toolchain und das Betriebssystem sind folglich aufeinander abgestimmt

 
Darstellung der C/C++ Toolchain bestehend aus Compiler, Linker und Loader

Die Koordination der Tools kann per Hand/Manuell, per Make oder über eine integrierte Entwicklungsumgebung (Eclipse, Visual Studio, XCODE, IntelliJ IDEA, …) erfolgen.

Compiler

Bearbeiten

C-Dateien werden einzeln compiliert. Jeder Compilerdurchlauf erzeugt eine Objektdatei. Vor der eigentlichen C-Compilierung wird erst der C-Präprozessor ausgeführt, welcher unter anderen die Include-Anweisungen durch den Inhalt der dazugehörigen Datei ersetzt. Die vom Compiler erzeugte Objekt-Datei ist einzeln nicht ausführbar, da:

  • Funktionen aufgerufen werden, welche in anderen C-Dateien definiert sind
  • Auf globale Variablen zugegriffen werden, die in anderen C-Dateien definiert sind
  • Ggf. keine main-Funktion enthalten ist, sondern nur Funktionen und globale Variablen enthalten sind, welche von anderen C-Datei genutzt werden
  • Library-Funktionen verwendet werden (z.B. printf()), die nicht Bestandteil des C-Projektes sind

Compiler-Varianten

Bearbeiten

Für die Sprache C sind diverse Compiler verfügbar:

  • Intel-Compiler (ICC)
  • Microsoft Compiler (MSVC)
  • GCC Compiler (GCC)
  • Clang Compiler Frontend (CLANG) mit Compiler-System LLVM

Unter Linux ist der GCC-Compiler der Standardcompiler. Alle C-Programme und das Betriebssystem selbst wurden mit diesem Compiler erstellt. Sofern dieser nicht defaultmäßig auf dem System installiert ist, kann er mit wenigen Handgriffen schnell nachinstalliert werden. Auf Windows-Systemen gibt es keinen Standardcompiler. Oft wird hier der Microsoft Compiler genutzt. Soll besonders schneller Code für Intel Prozessoren (und nicht AMD) erzeugt werden, empfiehlt sich der Intel-Compiler. Unter macOS wird sowohl sowohl CLANG/LLVM und GCC eingesetzt.

Compiler-Parameter

Bearbeiten

Neben der Angabe der zu übersetzenden C-Datei können beim Aufruf des Compilers weitere, optionale Parameter gesetzt werden, über welchen der Compiler konfiguriert wird. Am Beispiel des GCC-Compilers sind u.A. folgende Schalter vorhanden:

  • Optimierungsqualität setzen (z.B. -Ox mit x=0 → keine Optimierung x=1 → einfache Optimierung x=2 → bessere Optimierung x=3 → höchste Optimierung x=size → Optimierung hinsichtlich Programmgröße)
  • Makros (Präprozessoranweisung) setzen (z.B -Dmakro=7)
  • Umfang der Fehlerprüfung (Warnings) und Umgang mit diesen setzen (-Wx mit x=all → erhöhter Warning Level x=extra → zusätzlicher Warning Level, die nicht in -Wall enthalten sind x=error → Warnings als Fehler ansehen)
  • einzubindende Librarys setzen (-lx mit x=c → zum Einbinden der Standard-C-Library (defaultmäßig aktiviert) x=m → zum Einbinden der Math-Library)
  • Suchpfade für Header Dateien setzen (-Idir mit dir=Verzeichnis in dem ergänzend nach Header-Dateien gesucht wird)
  • Suchpfade für Library Dateien setzen (-Ldir mit dir=Verzeichnis, in dem ergänzend nach Library-Dateien gesucht wird)
  • Instrumentierungskommandos (Erzeugung von zusätzlichen Maschinencode zur erweiterten Fehlerprüfung) (z.B. -fsanitize=x x=address zum Instrumentieren des Executable, so dass einige illegale Speicherzugriffe erkannt werden)

OBJ-Datei

Bearbeiten

Eine Objektdatei speichert die vom Compiler (eigentlich die vom Assembler) übersetzten Anweisungen in einer strukturierten Form. Die Maschinenbefehle werden in der Text-Section gespeichert. Globale und statisch lokale Variablen, welche außerhalb eines Funktionskontextes existieren, werden entweder in der Data-Section (bei initialisierten Variablen) oder in der Bss-Section (bei nicht initialisierte Variablen) gespeichert. Erstere Section beinhaltet die Initialisierungswerte der Variablen. Zweitere Section wird mit Programmstart mit 0 gefüllt, so dass alle nicht initialisierten globalen Variablen den Wert 0 beinhalten.
Globale Konstanten werden in der rodata-Section gespeichert. Dies ermöglicht es dem Loader, die Zugriffsrechte auf diesen Speicherbereich auf Read-Only zu setzen.
Alle Variablen und Funktionsnamen werden mit ihren zugeordneten Speicheradressen in der Symboltabelle gespeichert.
Die absolute/tatsächliche Speicheradresse wird erst beim Erstellen der Executable erzeugt. Die Sectionen, welche die bis dahin relativen/verschiebbaren Adressen beinhaltet werden hierbei in Segmente (beinhaltet absolute Speicheradressen) gewandelt.
Typische Dateiformat für Objekt-Dateien sind    Executable and Linking Format (ELF) und    Common Object File Format (COFF).

Die einzelnen Objekt-Dateien werden vom Linker zu einer Objektdatei zusammengefügt, d.h. alle Funktionen und globale Variablen werden in einer Datei zusammengebunden, so dass bisher 'externe' Funktionen und 'externe' globale Variablen für alle im Zugriff stehen. Auch Libraries werden 'eingebunden', so dass z.B. printf()/sin() ebenfalls im Zugriff stehen.
Die Ausgabedatei eines Linkerdurchlaufes kann wahlweise eine Objekt-, eine Library- oder eine Executable-Datei sein. Erstes bedeutet, dass mit einem Linker Aufrufe nicht alle Objekt-Dateien auf einmal gebunden werden müssen, sondern dies in einen mehrstufigen Prozess (z.B. Ordnerweise, Klassenweise) erfolgen kann. Mit dem letzten Linker Aufruf im mehrstufigen Prozess wird entschieden, ob eine Library (enthält keine main()-Funktion) oder eine Executable (bei welche alle globalen Variablen, alle statisch lokalen Variablen und alle Funktionen eine absolute Speicheradresse zugewiesen bekommen) erzeugt werden soll.

Mit dem Start der Anwendung wird der   Loader aufgerufen (bestehend aus der OS-Funktion exec() (Posix)) , welcher die aktuelle laufende Anwendung durch die zu startende Anwendung ersetzt. Hierbei werden die Inhalte der Segmente aus der Objekt-Datei in die dazugehörigen Speicheradressen des Prozesses geladen. Im Anschluss wird main() (resp. C-Startup, welche dann Main startet) aufrufen. Die Übergabeparameter von exec() werden an Main() weitergereicht.

Im Wikipedia Artikel   make wird die Aufgabe von make wie folgt zusammengefasst: "make ist ein Build-Management-Tool, das Kommandos in Abhängigkeit von Bedingungen ausführt." In C/C++-Projekten wird es genutzt, die einzelnen Compiler und die anschließenden Linkeraufrufe in einem Tool zentral zu organisieren. Mit jedem Aufruf von make prüft make, in welcher C-/H-Dateien Änderungen vorgenommen wurden (C-/H-Datei neueren Datums als die dazugehörige OBJ-Datei, resp. OBJ-Dateien neueren Datums als die Executable) und übersetzt nur die geänderten Dateien. Ergänzend werden im Makefile auch Projektkonfigurationen vorgenommen (ähnlich .classpath File in Java), über welches das Projekt konfiguriert wird (Debug-Mode, Optimierung, …)" Die Projektbeschreibung ist in der Datei 'makefile' enthalten, welche bei Start von Make ohne expliziter Angabe einer Projektbeschreibung 'ausgeführt' wird.

  >>make         #startet Make, welches die Datei makefile einliest und ausführt
  >>make -f xyz  #startet make, welches die Datei xyz einliest und ausführt

Mit dem Aufruf vom Make können Ziele(Targets) definiert werden, welche behandelt werden sollen. Typische Ziele sind:

>>make all       #Prüft das gesamte Projekt auf Änderungen und erstellt die Executable
>>make clean     #Löscht alle Zwischenergebnisse (also alle Objekt-Dateien)

Entwicklungsumgebung

Bearbeiten

Ein integrierte Entwicklungsumgebung IDE beinhaltet Softwarekomponenten, die ein einfaches Programmieren, die Ausführung und den Test unter einer Oberfläche ermöglichen. Zur Vermeidung von Medienbrüchen bieten IDE's eine eigene Verwaltungsoberfläche/-sprache an, aus welcher die Anweisungen für den Compiler, Linker, Debugger, Make usw. abgeleitet werden. Zur einfachen und schnellen Softwareentwicklung sind weitere Hilfstools wie Autovervollständigung oder Lösungsvorschläge für Compilerfehlermeldungen vorhanden. Letztere Hilfstools stellen nach Meinung des Autors einen Nachteil dar, da ergänzend zum syntaktischen Fehler oftmals auch prinzipielle Fehler vorhanden sind.

Installation / Start beispielhafter Toolchains

Bearbeiten

In dieser Vorlesung wird der GCC-Compiler als Grundlage genutzt. Damit die Fehlermeldung des Compilers gelesen werden, wird empfohlen wahlweise den Compiler per Hand zu starten oder eine Online Version des Compilers zu nutzen. Von der Nutzung einer IDE wird abgeraten. Für Betriebssystemaufrufe wird der POSIX-Standard vorausgesetzt, wie er bspw. von LINUX, dem Windows Subsystem für Linux WSL, oder macOS unterstützt wird.

Händische Installation der Toolchain

Bearbeiten

Im Normalfall sollte unter Linux die benötigte Toolchain installiert sein. Bei einigen Distributionen muss jedoch die Toolchain händisch installiert werden. Dazu geben sie folgende Befehle in ein Terminalfenster ein:

>>sudo apt install gcc
>>sudo apt install make

Die Erstellung des Source-Codes kann mit einem beliebigen Editor (z.B. gedit, nano) erfolgen.

Windows 10/11 64-Bit Nutzer

Bearbeiten

Sowohl der GCC-Compiler als auch POSIX konforme OS-Aufrufe werden von Windows von Haus aus nicht unterstützt. Mit Windows Subsystem für Linux (WSL) wird ein Linux System emuliert, welche beide Voraussetzungen erfüllt:

  • Installation von WSL
Entsprechend nachfolgender Anleitung: How to install Linux on Windows with WSL
  • Upgrade + Vervollständigung
Starten einer bash (bspw. durch Eingabe von 'bash' in der Suchzeile)
>>sudo apt-get update
>>sudo apt-get upgrade
>>sudo apt install gcc
>>sudo apt install make
  • Datenaustausch zwischen Windows und WSL
Für den einfachen Datenaustausch zwischen Windows und WSL empfiehlt sich, im Windows Dateisystem ein Arbeitsverzeichnis anzulegen. Das Windows Dateisystemen ist ab dem Pfad '/mnt/c/...' unter Linux gemountet:
>>cd /mnt/c/_Projekte/AufgabeX
  • Die Erstellung des Source-Codes kann mit einem beliebigen Windows-Editor (z.B. notepad++) erfolgen.

macOS basiert auf darwin (ein freies UNIX Betriebssystem). Defaultmäßig wird macOS ohne Compiler ausgeliefert. Die Installation eines Compilers sollte identisch zur Installation unter Linux sein.

Händischer Start des Compilers

Bearbeiten

Beim GCC-Compiler übernimmt der Aufruf von gcc gleichermaßen den Compiler- und den Linker-Aufruf. Eine Übersetzung wird wie folgt gestartet:

>>gcc datei1.c datei2.c datei3.c 

Diese Anweisung startet zunächst für jede C-Datei den Compiler. Im Anschluss werden alle erzeugten Objekt-Files mit einem weiteren Linkeraufruf zusammengefügt. Als Executable wird die Datei a.out erzeugt. Mit dem Compiler-Parameter -o kann ein alternative Name für die Executable gesetzt werden:

>>gcc datei1.c datei2.c datei3.c -o prog

Der Aufruf kann beliebig um Compiler-Parameter ergänzt werden:

>>gcc datei1.c datei2.c datei3.c -o prog -fsanitize=address -Wall

Nach fehlerfreien Compilerdurchlauf wird die Executeable wie folgt gestartet:

>>./a.out   #Bei Aufruf von gcc ohne -o
>>./prog    #Bei Aufruf von gcc mit -o prog

Wichtige Terminal/bash-Befehle: Siehe auch: Ubuntuusers/Befehlsübersicht

>>cd  xxx	   #Change Directory, zum Wechseln des Arbeitsverzeichnisses
>>pwd	       #zur Ausgabe des aktuellen Verzeichnisses
>>ls	       #zur Auflistung des Inhaltes des aktuellen Verzeichnisses
>>ls -la	   #zur tabellarischen Auflistung des Inhaltes des aktuellen Verzeichnisses
>>ll	       #in vielen Installationen ist dies ein Alias auf 'ls -la'
>>cp xxx yyyy  #zum Kopieren einer Datei 'xxx' in die neue Datei 'yyy'
>>mv xxx yyy   #zum Verschieben/Umbenennen von Dateien
>>mkdir xxx	   #zum Anlegen eines Unterverzeichnisses
>>rm xxx 	   #zum Löschen einer Datei
>>rmdir xxx	   #zum Löschen eines Unterverzeichnisses (dieses muss jedoch leer sein)
>>cat xxx	   #Eigentlich ein Befehl zum Zusammenfügen mehrerer Dateien.
               #Da ohne explizite Angabe einer Zieldatei die Ausgabe auf der 
               #Standardausgabe erfolgt, kann mit diesem Befehl der Inhalt 
               #einer Datei angezeigt werden.
>>less xxx	   #scrollfähige Anzeige einer Datei (Beenden mit Taste 'q')
>>more xxx	   #Wie less, aber ohne die Fähigkeit, rückwärts zu scrollen
>>vi main.c	   #ein einfacher, aber universeller Texteditor
>>nano main.c  #ein etwas komfortabler Texteditor

Nutzung des Online-Compilers Compiler Explorer

Bearbeiten

Der Compiler Explorer ist eine komfortable Online-Entwicklungsumgebung, welche diverse Programmiersprachen, diverse Compiler unterstützt, eine komfortable Ausführungsinstanz für die Executable bietet und den erzeugten Assemblercode darstellen kann.

https://www.godbolt.org

Für den einfachen Start soll hier der prinzipielle Umgang mit dem Compilerexplorer erläutert werden:

  • Eingabe des Source-Codes
 
Beschreibung der Editor Eingabefelder vom Compiler Explorer

Die Eingabe des Source-Codes ist selbsterklärend. Rechts oben in der Eingabebox sollte C als Programmiersprache gewählt werden. Der Compilevorgang startet automatisch, wenn für ca. 1 Sekunde keine Änderung mehr am Source-Code vorgenommen wird.
Beim ersten Start des Compilerexplorers wird defaultmäßig der compilierte Assemblercode dargestellt. Der besseren Übersicht sollte dieses Fenster geschlossen werden.
Über 'Add New-Execution only ' wird die Executable zur Ausführung gebracht. Wer den erzeugten Assemblercode oder Zwischenergebnisse des Compilers analysieren möchte, wählt Add New-Compiler.

  • Executor
 
Beschreibung der Executor Eingabefelder vom Compiler Explorer

Sobald der Haken im grünen Kreis erscheint, ist der Compilevorgang und die Programmausführung abgeschlossen. Änderungen bei *2) und *3) führen direkt zu einem Neustart des Programms. Mit Änderung von *1) oder Wahl eines anderen Compilers wird der Compilevorgang ergänzend neu gestartet. Über die 'Menü'-Zeile können einzelne Fenster ein- und ausgeblendet werden. Es wird empfohlen, alle Fenster einzublenden!

*1) Hier können dem Compiler-Parameter übergeben werden. Es empfiehlt sich '-fsanitize=Address -Wall -Werror' vorzugeben, so dass eine Speicherüberwachung in den Source-Code integriert wird und eine erweiterte Fehlerprüfung stattfindet.
*2) Hier können der Executable Parameter übergeben werden, die dann in argc und argv von main() abrufbar sind. Entspricht dem händischen Start des Programms über './a.out para1 para2'
*3) Hier wird die Standardeingabe nachgebildet. Allerdings wartet der Executor nicht auf eine aktive Eingabe durch den Nutzer, sondern gibt mit jeden Aufruf von scanf() oder fgets() die Inhalte der einzelnen Zeilen aus diesem Fenster zurück. D.h. der erste Aufruf von fgets() liefert hier 'set 1 eins' und der zweite Aufruf 'set 2 zwei' zurück.
*4) Sofern mit 'Compiler Output' aktiviert, werden hier alle Compiler-Warnings angezeigt. Wichtig: Warnings sollten als Fehler angesehen werden und dementsprechend im Source-Code behoben werden
*5) Hier wird der Rückgabewert von 'int main()' angezeigt. Sollte das Programm sich in einer Endlosschleife befinden (und damit die Laufzeit von ca. 10 Sekunden überschreiten), so wird das Programm nach ca. 10 Sekunden vom Compiler Explorer beendet und ein Returnwert von 143 angezeigt. Dem Wert 'Programm returned: xxx' sollte einer besonderen Aufmerksamkeit zugedacht sein.
*6) In diesem Fenster wird die Standardausgabe dargestellt, also alles, was sie bspw. mit printf() ausgeben.
*7) In diesem Fenster wird die Standardfehlerausgabe dargestellt, alles, was sie bspw. Mit fprintf(stderr, ...) Oder perror(...) Ausgeben. Die Nutzung der Standardfehlerausgabe empfiehlt sich insbesondere, wenn Laufzeitfehler vorhanden sind. Aufgrund dessen, dass die Standardausgabe nicht gepuffert ist, werden alle Ausgaben direkt zur Anzeige gebracht.

Fehlersuche von Laufzeitfehlern

Bearbeiten

Ein fehlerfrei übersetzter Code heißt nicht, dass das erzeugte Programm fehlerfrei ist. Ein erfolgreicher Compilerdurchlauf bedeutet nur, dass alle C-Anweisungen in entsprechende Maschinenbefehle umgesetzt werden konnten. Fehler können auch zur Laufzeit (siehe Kap. Einführung/Literatur-Laufzeitfehler) eines Programmes auftreten.

Laufzeitfehler machen sich wahlweise durch Programmabsturz oder durch ein fehlerhaftes Verhalten des Programmes bemerkbar.
Bei einem Programmabsturz beendet das Betriebssystem die Fortführung des Programmes z.B. aufgrund eines fehlerhaften Speicherzugriffes (Segmentation Fault). Beim Compiler-Explorer begrenzt der Executor die Laufzeit auf ca. 10 Sekunden und beendet dann ebenfalls das Programm. Beide 'Programmabstürze' sind dadurch gekennzeichnet, dass die main() Funktion sich nicht selbst beendet und damit der Return-Wert nicht zur Ausführung gebracht wird. Beim Compiler Explorer empfiehlt sich, den Return-Wert (und den Grünen Hacken) im Auge zu behalten.
Ein fehlerhaftes Verhalten des Programms macht sich bspw. dadurch bemerkbar, dass ein Variable einen anderen Wert beinhaltet, als erwartet:

int lauf = 4711;
int var  = 2;
int *ptr=&var;
*--ptr=13;
printf("%d\n",lauf);  //Erwartet 4711, Ausgabe 13

Ungeachtet der Art des Auftretens des Fehlers liegt der Fehlergrund zumeist nicht an der Stelle, an der sich der Fehler bemerkbar macht.
Das Mittel der Wahl zur Fehlersuche sind die in Kap. Einführung/Literatur-Laufzeitfehler dargestellten Methoden, also ein Codereview durchzuführen. Ergänzend können printf()-Anweisungen helfen, den Fehler einzugrenzen. Dabei sollten beachtet werden, dass printf()-Anweisungen nicht gepuffert sind und wahlweise von einer fflush(stdout) gefolgt werden sollte oder durch fprintf(stderr,..) erfolgen sollten.

Hinweise:

  • Ein Softwaretest sollte nicht nur die Gutfälle abdecken, sondern insbesondere die Schlechtfälle berücksichtigen. Dabei sollte der Kreativität freien Lauf gelassen werden. Der Anwender der späteren Software nutzt sicherlich nicht immer den von Ihnen vorgesehenen Weg, sondern nutzt alle Wege, die nicht explizit deaktiviert sind (unwissend oder auch bewusst).
  • Werden Laufzeitfehler nicht behoben, so sind dies häufig Einfallstore für Viren. Die 25 gefährlichsten Softwarefehler werden von Common Weakness Enumeration (CWE) unter https://cwe.mitre.org/top25/archive/2021/2021_cwe_top25.html aufgelistet. Auf einer der obersten Plätze sind Arrayzugriffe außerhalb des reservierten Bereiches zu finden.
Das Open Web Application Security Project (OWASP) führt ebenfalls eine Liste der Sicherheitsbedrohungen im Web: https://owasp.org/Top10/