Programmieren in C/C++/ Druckversion

Druckversion des Buches Programmieren in C/C++
  • Dieses Buch umfasst derzeit etwa ? DIN-A4-Seiten einschließlich Bilder.
  • Wenn Sie dieses Buch drucken oder die Druckvorschau Ihres Browsers verwenden, ist diese Notiz nicht sichtbar.
  • Zum Drucken klicken Sie in der linken Menüleiste im Abschnitt „Drucken/exportieren“ auf Als PDF herunterladen.
  • Mehr Informationen über Druckversionen siehe Hilfe:Fertigstellen/ PDF-Versionen.
  • Hinweise:
    • Für einen reinen Text-Ausdruck kann man die Bilder-Darstellung im Browser deaktivieren:
      • Internet-Explorer: Extras > Internetoptionen > Erweitert > Bilder anzeigen (Häkchen entfernen und mit OK bestätigen)
      • Mozilla Firefox: Extras > Einstellungen > Inhalt > Grafiken laden (Häkchen entfernen und mit OK bestätigen)
      • Opera: Ansicht > Bilder > Keine Bilder
    • Texte, die in Klappboxen stehen, werden nicht immer ausgedruckt (abhängig von der Definition). Auf jeden Fall müssen sie ausgeklappt sein, wenn sie gedruckt werden sollen.
    • Die Funktion „Als PDF herunterladen“ kann zu Darstellungsfehlern führen.

Dieser Text ist sowohl unter der „Creative Commons Attribution/Share-Alike“-Lizenz 3.0 als auch GFDL lizenziert.

Eine deutschsprachige Beschreibung für Autoren und Weiternutzer findet man in den Nutzungsbedingungen der Wikimedia Foundation.

Programmieren in C/C++


Prof. Dr.-ing. Detlef Justen
Wikibooks

Wie ist dieses Buch entstanden

Bearbeiten

Dieses Buch stammt aus der Vorlesung 'Programmieren in C' und dient dort als Skript für diese Vorlesung. Die Motivationen für die Veröffentlichung des Skripts in Wikibooks sind:

  • Bisher kein vernünftiges Lehrbuch für die Programmiersprache C gefunden. Viele Bücher basieren vorrangig auf praktischen Beispielen und vernachlässigen syntaktische Aspekte
  • Bereitstellung des Wissens für Personen außerhalb der Vorlesung
  • Interaktivität: Das heißt vorerst nicht, dass hier jeder mitschreiben soll. Vielmehr geht es mir um Rückmeldung bzgl. Verbesserungsvorschläge, Ergänzungsvorschläge,... über Diskussionsbeiträge

Für wen ist dieses Buch

Bearbeiten

Die Zielgruppe für dieses Buch sind Personen mit Programmierkenntnissen. Es wird vorausgesetzt, dass der Leser weiß, was eine IF-Bedingung ist und wann eine FOR-Schleife anzuwenden ist. Schwerpunkt dieses Buches ist der Syntax der Sprache C/C++, die 'Interpretationen' daraus und die praktische Anwendung hiervon. Der Syntax von C/C++ kennt nur wenige Ausnahmeregeln, so dass quasi alles an allen Stellen erlaubt ist. Für unerfahrene Nutzer dieser Sprache führt dies oftmals zu Frust und der Aussagen wie 'Scheiß Compiler' / 'Scheiß Sprache'. In der Tat liegt das Problem vor der Tastatur. Moderne Programmiersprachen haben aus dem offenem Syntax von C/C++ gelernt und den Syntax entsprechend eingeschränkt. D.h. bspw. den Komma-Operator nur in FOR-Schleifen erlaubt und Zeiger durch Referenzen ersetzt. Damit wurden diverse Fehlerquellen unterbunden, aber gleichermaßen die Flexibilität der Sprache reduziert. Bei korrekter Anwendung der Syntaxen kann auch mit C/C++ saubere, sichere und vor allem schnelle Programme geschrieben werden.

Im Vergleich zu anderen Sprachen entspricht C (mit den Worten eines geschätzten Kollegen gesagt) einem 'senkrechtstartenden Düsenjäger'. Wenn der Pilot (resp. der Programmierer) das Werkzeug nicht beherrscht, führt es schnell zum Crash ('Scheiß Compiler'/'Scheiß Sprache'). Wenn er es beherrscht, kann er die Vorteile beider Welten nutzen.

Compiler ist dein Freund und Helfer

Bearbeiten

Fehlermeldungen des Compilers werden nur ungern gelesen (derweil sie sich kryptisch anhören). Anstatt die Fehlermeldung zu lesen und zu verstehen, sehe ich oftmals folgenden Umgang mit Fehlermeldungen:

  • Nutzung der Lösungsvorschläge einer integrierten Entwicklungsumgebung
  • Vermeidung der Fehlermeldung durch Umstellung des Codes:
Mit Compilerfehler Umgehung der Fehlermeldung
int *foo(void) {
  int arr[10];
  ...
  return arr;
  //Error, aufgrund der Rückgabe
  //einer Adresse einer lokalen
  //Variablen
}
int *foo(void) {
  int arr[10];
  int *ptr;
  ...
  ptr=arr;
  //Adresse wird zuvor in eine
  //lokale Variable kopiert, so
  //dass der Compiler nicht prüfen
  //kann, woher die Adresse stammt
  return ptr;
}
  • Googlen nach Lösungsvorschlägen und Nutzung dieser (wobei die Autoren der Lösungsvorschläge den Fehler oftmals ebenfalls nicht verstanden haben und somit nur ein Workaround vorschlagen)
  • Ignorieren von Warnings (eigentlicher Fehler rächt sich dann zumeist später)
void foo(void) {	
  int *ptr;
  *ptr=4711;  //Warning, ptr is uninitialized
}

In der Tat ist die Fehlermeldung kryptisch. Der Grund für eine Fehlermeldung ist, dass der Source-Code von der Spezifikation abweicht und der Compiler entsprechend keinen Assemblercode generieren kann. Die Fehlermeldung beschreibt somit die Syntaxverletzung. Sie ist eine Hilfestellung an den Programmierer, was genau falsch ist. Als Programmierer sollte man sich bei Fehlermeldungen überlegen, was wollte man mit dem Ausdruck erreichen und was hat man in der Tat beschrieben. Also nicht einfach den Fehler korrigieren, sondern die Fehlermeldung als Anregung verstehen, den Code zu überdenken.

In den letzten Jahren sind die Compiler so intelligent geworden, dass diese bei vielen typischen Programmiererfehlern eine Warning ausgeben. Sie meckern, wenn bspw. eine Zuweisung in einer IF-Bedingung steht oder einer IF-Anweisung kein Block folgt (beides ist syntaktisch korrekt, sind aber gleichermaßen auch typische Zeichen für mögliche Fehler). Defaultmäßig sind einige diese Warnings deaktiviert. Da sie helfen können, mögliche Fehler zu vermeiden empfiehlt es sich, den Warning Level auf den höchsten Mode zu setzen (Compilerschalter: -Wall).

Programmieren lernt man nur durch Programmieren

Bearbeiten

Lesen ist ein wichtiger Aspekt des Lernens, ausprobieren ein Weiterer. Beide ergänzen sich prima. In diesem Sinne sind im Buch viele Code-Beispiele vorhanden, ohne und zum Teil mit bewussten Fehlern.
Damit die notwendigen Include Anweisungen und ggf. Funktionsrümpfe den Beispielcode nicht unnötig aufblähen, wurde ein LUA Script entworfen (Danke an meinen Mitarbeiter), welches den im (verborgenen) im Buch enthaltenen Code in einen Compiler Explorer Link konvertiert. Über 'Öffnen im Compiler Explorer' können alle Beispiele ohne Abtippen direkt ausprobiert werden. Änderungen im Compilerexplorer sind lokale Änderungen und für den 'Rest' nicht sichtbar.
Über das LUA Script kann nicht nur Code vorgegebenen werden, sondern auch Compilerschalter und mehr gesetzt werden. Die Dokumentation zum Lua Code ist hier zu finden: Lua Doku. Der Lua Code selbst ist hier zu finden: Lua Code.

Das Augenmerk des Buches liegt derzeit bei der Programmiersprache C. Viele grundlegende Konzepte sind in C und C++ identisch. Da C++ eine eigene Sprache ist, sind einige syntaktischen Abweichungen vorhanden. Im derzeitigen Stand des Buches bedeutet die Ergänzung C++ im Titel des Buches, diese syntaktischen Abweichungen zu beschreiben. Zukünftig ist geplant, die ergänzenden Konzepte wie Objektorientierung, Templates, Namensraum, ... zu ergänzen.

Compiler

Bearbeiten

Die C-Spezifikation ist die Grundlage für alle Compilerhersteller. Insofern sind diese austauschbar. Die Spezifikation regelt das allgemeine Verhalten der Sprache, wie der Compiler dieses umsetzt, obliegt ihm. Über Compiler spezifische Schalter kann dem Compiler detailliertere Anweisungen für die Umsetzung gegeben werden (bei GCC über u.A. über __attribute__((attribute-list)), ab C++11 über [ [attribute-list] ]. An vielen Stellen gibt die C-Spezifikation ein undefiniertes Verhalten vor (z.B. bei der Rückgabe einer Adresse einer lokalen Variablen). Das Verhalten vom Compiler an diesen Stellen ist compilerabhängig. Einige fügen zusätzlichen Code ein (um z.B. zur Laufzeit einen Laufzeitfehler zu provozieren (Rückgabe der Adresse 0 im obigen Beispiel)), einige tätigen gar nichts. Ergänzend zur C-Spezifikation ergänzen einige Compiler die Spezifikation um zusätzliche Features. GCC beispielsweise ermöglicht es, in einer SWITCH-Anweisung ein CASE über einen Wertebereich zu beschreiben (Case 1 ... 3:).

Auch wenn die Sprache C sauber spezifiziert ist, wird man früher oder später compilerspezifische Eigenschaften nutzen und sich damit an einen Compiler binden. Im Falle dieses Buches ist es der GCC Compiler aus der GNU Compiler Collection.

Sonstiges

Bearbeiten

Die C-Spezikation ist an einigen Stellen nur sehr schwer zu vermitteln. In diesem Sinne wird an einigen Stellen im Buch von C Spezifikation abgewichen. Dies soll der besseren Lesbarkeit/Verständnis dienen.

Einführung/Literatur

Bearbeiten


Literatur

Bearbeiten

Kurze Literaturaufzählung, nach Relevanz sortiert:

Spezifikation der Standardbibliotheksfunktionen

Bearbeiten

Die Spezifikation der Standard Header Dateien und der Standard Library Funktionen sind Bestandteil der C-Spezifikation [C11 7.x] und beanspruchen ~⅓ der Seiten. POSIX (Portable Operating System Interface) ist eine für UNIX entwickelte standardisierte Programmierschnittstelle u.A. für Betriebssystemaufrufe. Es beinhaltet und erweitert aber auch die Beschreibung der Standard-C Header Dateien und der Standard-C Library Funktionen. Da die POSIX Beschreibungen in den Manpages von UNIX und verwandten Betriebssystemen enthalten sind, empfehlen sich diese als alternative/ergänzende Beschreibung. Neben der

  • detaillierten Funktionsbeschreibungen,
  • der Parameterbeschreibung,
  • der Beschreibung des Rückgabewertes
  • der Fehlerarten
  • und der zur Nutzung zu inkludierenden Header-Dateien

enthalten einige dieser Spezifikationen ergänzend Anwendungsbeispiele.

In Unix basierten Systemen ist die Spezifikation Bestandteil der Distribution und kann über die Kommandozeile wie folgt aufgerufen werden (Voraussetzung, gcc ist installiert):

>>man strcpy
>>man printf.3

Alternativ sind diese im Netz verfügbar. Hier empfiehlt es sich, das Wort 'man' der Suche voranzustellen, so dass in den ersten Suchergebnissen direkt ein Link auf die Manpages enthalten ist. Wenn nur der Funktionsname als Suchbegriff eingegeben wird, wird als Suchergebnis zumeist Anwenderinterpretationen geliefert, die oftmals nur die 'halbe' Wahrheit darstellen oder z.T. falsch sind.

Sprachenvergleich

Bearbeiten

Die Programmiersprache C gibt es schon seit 50 Jahren. Nach seiner 'Hoch'-Zeit in den 90er Jahren ist diese Sprache zwar nicht mehr führend, aber immer noch unter den Top 5 Sprachen zu finden:

1. Python
2. Java
3. C
4. C++
5. R
6. JavaScript
7. C#
8. Matlab
9. Swift
10. Go
1. 16,36% Python
2. 16,26% C
3. 12,91% C++
4. 12,21% Java
5. 5,73% C#
6. 4,64% Visual Basic
7. 2,87% Java Script
8. 2,50% SQL
9. 1,60% Assembly Language
10. 1,37% PHP

Gründe für die gute Positionierung:

  • hohe Ausführungsgeschwindigkeit des compilierten Programms
  • kein zusätzlicher Interpreter zur Ausführung notwendig (Compilersprache)
  • Systemprogrammierung (Betriebssystem, Gerätetreiber sind zumeist in C/C++ geschrieben)
  • eingebetteten Systemen sind in C/C++ geschrieben
  • (Java) Interpreter und diverse Java 'Libraries' sind in C geschrieben
  • Python Librarys sind in C/C++ geschrieben
  • C Compiler sind quasi für alle Betriebssysteme und embedded Systeme verfügbar (auch aufgrund der kleinen Library)
  • Energieeffizeint (siehe Heise - Grünes Programmieren in C und Rust)

Hinweis:

C-Dialekte

Bearbeiten

1972 wurde C von Brian Kernighan und Dennis Ritchie mit dem Ziel entwickelt, Unix für div. Rechnersysteme (und damit nicht mehr in Assembler geschrieben) verfügbar zu machen. Dazu wurde die Vorgängersprache B unter anderem um Datentypen (einhergehend mit der Bereitstellung dieser durch die   PDP-11, einem quasi Standardcomputer der 70er Jahre) und um die klare Trennung von Integer- und Pointer-Variablen erweitert (siehe Bell-Labs).

Aufbauend auf dieser Grundlage wurde C stetig weiterentwickelt. (siehe   Varianten der Programmiersprache C):

  • K&R C (1978)
Kein offizieller Standard, jedoch wurde mit dem Buch von Kerningham & Ritchie 1978 (siehe Literatur) die Sprache erstmals zusammenhängend beschrieben, so dass das zugrundeliegende Buch als quasi Standard gilt.
Besonderheiten (anhand eines Beispielcodes)
func(str)
char * str;    /* 1)  */
{              /* 2) Zunächst Definitionsbereich */
int a;         /* 3) */
    b;         /* 4)  */
               /* 2) Ab hier dann Anweisungsbereich */
b=a+1;
               /* 2) Hier nun keine weitere Variablendefinition erlaubt */
func(4,5,6);   /* 5) */
...
//	1) Datentypdefinition bei Funktionsparameter außerhalb der ()-Klammern
//	2) am Anfang des Blockscopes ist der Definitionsbereich, in welchen
//   die Variablen definiert werden. Dieser endet mit der ersten Anweisung. 
//   Ab der ersten Anweisung können keine weiteren Variablen definiert werden.
//	3) Nur wenige Datentypen vorhanden
//	4) Implizites Int, d.h. Variablen/Funktionen ohne vorangestellten 
//   Datentyp sind automatisch vom Datentyp int.
//	5) kein Funktionsparametercheck, d.h. eine Funktion konnte mit anderen
//   Parameter aufgerufen werden, als in der Tat benötigt wurden (u.A. 
//   aufgrund dieser Tatsache wurde das Tool lint entwickelt, welches 
//   eine Prüfung vornimmt)
  • ANSI-C oder C89/90
Erste offizielle Spezifikation der Sprache.
Compiler-Schalter zum Aktivieren dieser Version: -ansi oder -std=c90
Besonderheiten:
  • Einführung weiterer Datentypen: short, long
  • Funktionsprototypen mit dem sich daraus ergebenden Funktionsparametercheck
  • Einführung weiterer Schlüsselwörter: const, volatile, unsigned
  • Einführung des Präprozessors
  • Datentypdefinition von Funktionsparameter nun in den ()-Klammern
  • uvm.
  • C99
Viele Optimierungen, die sich u.A. im Zuge mit der Einführung von C++ ergeben haben.
Compiler-Schalter zum Aktivieren dieser Version: -std=c99
Besonderheiten:
  • for-Schleife erzeugt einen eigenen Block, so dass innerhalb der for-Anweisung eine nur im Anweisungsbereich der for-Schleife lokale Variable definiert werden kann (for(int lauf=0;...)
  • Einführung von Zeilenkommentare (enden automatisch am Zeilenende) '//'
  • Frei platzierbare Definitionen, d.h. Variablen-Definitionen können nun auch zwischen Anweisungen stehen
  • VLA Variable Length Array; Array, dessen Dimension erst zur Laufzeit festgelegt wird
  • Inline Funktionen
  • Designated Initializers (z.B. struct point p={.x=1,.y=2}; )
  • Compound Literals (z.B. var=(struct xyz){1,2,3}; )
  • Neue Datentypen: long long / float _complex / double _complex /…
  • Verbot des impliziten int, d.h. Compiler gibt mindestens eine Warning, wenn z.B. eine Funktion ohne Rückgabewert definiert wurde, bzw. der Datentyp eines Übergabeparameters nicht gesetzt wurde
  • C11 (Default bei gcc)
Wenige Änderung zu C99. Enthält im wesentlichen Features, welche Compilerhersteller aufgrund von Anwenderforderungen vorab im Compiler implementiert hatten
Compiler-Schalter zum Aktivieren dieser Version: -std=c11
Besonderheiten
  • Unterstützung von Multi-Threading in Form durch Bereitstellung von "thread local storage class specifier" (Sichtbarkeit/Gültitgkeit von Variablen auf Thread Basis) und Bereitstellung von Funktionen zur Verwaltung von Threads, Mutexen, condition variable und atomic Operationen
  • Anonymous Structures und Unions (sinnvoll z.B. für einfacheren Strukturierung von Datentypen)
  • Entfernung der 'unsicheren' gets() Funktion
  • und einige weitere
  • C17
Enthält im Wesentlichen nur Fehlerkorrekturen von C11 und keine neuen Features
Compiler-Schalter zum Aktivieren dieser Version: -std=c17
  • C23
In Entwicklung. Voraussichtliche Veröffentlichung 2024
Änderungen: siehe bspw. C23 implications for C libraries
Besonderheiten
  • Unterstützung des Unicode Zeichensatzes
  • Testfunktionen für Addition/Subtraktion/Multiplikation auf Integerüberlauf
  • nullptr als Ersatz für NULL

In diesem Skript wird vorrangig der Standard C11 genutzt. Die Ergänzungen, welche C17 und spätere Versionen mitbringen, sind nach Ansicht des Autors nur Features. Am grundlegenden Syntax wurden hier nur geringfügige Änderungen vorgenommen.

Hinweise:

  • Ergänzend zu diesen Standards erweitern einige Compilerherstellen den Standard um weitere Features. Sofern diese (oftmals nützliches) Features genutzt werden, baut man sich Compiler-Abhängigkeiten in seinen Code ein
  • Viele etablierten Programme/Libraries nutzen C89/90 als Standard. So basieren bspw. cURL, SQLite, libxml2 auf diesen Standard. Auch der Linux Kernel bis Version 5.18 (Mai 2022) basiert auf C89
  • Selbst der K&R Standard ist in einigen Librarys noch enthalten (bspw. in der atanh() Funktion in libm von newlib)

C Besonderheiten

Bearbeiten

C-Syntax

Bearbeiten

Java, JavaScript, PHP uvm. orientieren sich am Syntax der Programmiersprache C. Eine Einführung in den allgemeinen Syntax entfällt somit. Einzig werden hier einige Besonderheiten dargestellt:

  • Im C-Syntax gibt es nur wenige Ausnahmeregeln, so dass quasi alles an allen Stellen erlaubt ist:
//Aufruf einer Funktion im Funktionsparameterbereich
int main(int argc,char *argv[]) {
  if( ({int var=1; var>argc;}))  //Hier wird eine Variable
                                 //innerhalb des IF-Konstruktes angelegt!
  switch(argc) {
        int abc;               //Variable außerhalb des Case-Bereiches
     case 1:
        int var1=8;
        hallo:                //Sprungziel innerhalb von Switch
     case 2:
        var1*=2;              //Vorsicht, in diesem Case-Zweig ist var1 nicht
        break;                //initialisieirt
    (void)abc;                //Zur Unterbildung der Compilerwarning        
  }
  else
    goto hallo;      //Sprung zu einer Stelle innerhalb der Switch-Anweisung
 
  for(int lauf1=7,lauf2=8;lauf2++,lauf1--;printf("-"));  
  return 0;
}
  • Die C-Spezifikation ist so ausgelegt, dass der C-Code in kompakten/schnellen Maschinencode übersetzt werden kann und die Prozessoreigenschaften optimal genutzt werden. Dementsprechend sind in der C-Spezfikation diverse 'undefined/unspecified Behavior' vorhanden. Beispiele sind:
  • Bitbreite des Datentyp integer ist vom Prozessor und Compiler abhängig
  • Verhalten bei Integerüberlauf/unterlauf ist von der Prozessorarchitektur abhängig
  • Auswertereihenfolge der Übergabeparameter beim Funktionsaufruf ist nicht definiert
Bei Nichtbeachtung kann dies zu nicht portablen (auf andere Compiler oder andere Prozessoren) oder gar fehlerhaften Programmen führen (siehe auch: Undefined Behavior in C and C++)
  • Einige Operatoren haben in Abhängigkeit der Verwendung unterschiedliche Bedeutungen
'*' -> Ganzzahl Multiplikation, wenn beide Operanden vom Datentyp Ganzzahl sind
'*' -> Gleitpunktzahl Multiplikation, wenn eine der beiden Operanden vom Datentyp Gleitpunktzahl und der andere von Ganzzahl ist
'*' -> Datentyp Zeiger anlegen, wenn der linke Operand ein Datentyp ist
'*' -> Dereferenzierung, wenn der rechte Operand vom Datentyp Zeiger ist
  • Ausführbarer Code in der Parameterliste von Funktionen
int main(int argc,char *argv[printf("%*c",argc,'a')])

Folglich sind viele Anweisungen vom Syntax korrekt (so dass der Compiler keine Fehler ausgibt):

  • führen andere Aktionen aus, als gedacht
  • werden zur Verschleierung des Source-Codes genutzt (Unleserlicher Code)
  • werden von Profi-Programmierer angewendet, um kleinere/schnellere Programme zu schreiben

C-Fallstricke

Bearbeiten

Das Ziel der Spezifikation von C war eine universelle/schnelle Programmiersprache (als Ersatz für Assembler und den komplexen Sprachen wie Algol und Fortran). Aus Geschwindigkeitsaspekten sind einige Anweisungen von der zugrundeliegenden Rechnerarchitektur abhängig, so dass C nicht portabel ist. Ebenso werden aus Geschwindigkeitsgründen keine unbedingt notwendigen Überprüfungen zur Laufzeit durchgeführt:

  • Die Datentypen sind nicht klar definiert (Datentyp int ist 16- oder 32-Bit breit, abhängig von der Rechenbreite der CPU)
  • C kann den gesamten vom Prozessor adressierbaren Speicherbereich für Variablen und Programm nutzen, d.h. Breite des Datentyps Zeigers ist von der Rechenbreite der CPU abhängig
  • Keine Prüfung der Indices bei Arrayzugriffen
char arr[10];
arr[1]='a';
arr[10]='z';   //Korrekt
arr[-1]='x';   //Korrekt
  • Keine Prüfung, ob bei der Dereferenzierung von Pointer diese auf eine gültige Speicheradresse zeigen
int  vari=3;
int *ptr=&vari;
vari=*++ptr;      //Zugriff auf falsche Variable
ptr=(int *)100;   //Initialisierung des Pointers mit absoluter Adresse
  • Implizite Typkonvertierung bei Ganzzahl und Gleitkommazahlen
char  varc;
short vars;
int   varii;
float varf=sin(varc*vars)+varii;
//entspricht:
//float varf=(float)(sin((double)((int)varc*(int)Vars))+(double)varii);
  • C stellt dynamischen Speicher (Heap) zur Verfügung. Der Anwender ist sowohl für die Reservierung als auch für die Freigabe des zuvor reservierten Speichers zuständig

Laufzeitfehler

Bearbeiten

Neben Syntax-Fehler (die durch den Compiler zur Compilezeit ausgegeben werden) gibt es insb. in C bei unsachgemäßer Programmierung viele Laufzeitfehler. Diese führen:

  • zum Programmabsturz während der Laufzeit
  • zu einem merkwürdigen Verhalten während der Ausführung des Programms (Variable hat auf einmal einen anderen Wert, als erwartet)

Dies liegt unter anderen an folgenden Sachverhalten:

  • Keine Prüfung der Indices bei Arrayzugriffen
  • Keine Prüfung, ob bei der Dereferenzierung von Pointer dieser auf eine gültige Speicheradresse zeigt.
  • Keine Prüfung, ob bei der Allokation von lokalen Variablen genügend Speicher (Stack) zur Verfügung steht.
  • Zahlenüberlauf bei Integer-Arithmetik ist nicht definiert (siehe 'Integer-Arithmetik')
         int a=2000000000;
unsigned int b=1000000000;
         int c=a+b;        //c=-1294967296
  • Division durch 0, welche nicht durch eine Exception abgefangen wird
  • C-Spezifikation an einige Stellen ein undefiniertes Verhalten beschreibt, also dem Compilerhersteller die Umsetzung der Anweisung in Code frei lässt
int lauf=5;
lauf=++lauf * ++lauf + ++lauf * ++lauf;
//GCC-Compiler -> lauf=130
//Clang-Compiler -> lauf=114

Die ersten beiden Fehler werden als Pufferüberlauf/BufferOverflow, die fehlende Stackprüfung als Stapelüberlauf/StackOverflow bezeichnet.

Laufzeitfehler sind für den ungeübten Programmierer schnell erstellt, jedoch nur schwer zu finden. Vorsorge (also die Analyse des Codes) ist hier die bessere Variante als Nachsorge (den Fehler erst beim Auftreten zu suchen). Insbesondere bei Nutzung von Pointern sollte der Source-Code vorm Übersetzen auf bspw. folgende Sachverhalten geprüft werden:

  • wurde der Pointer vor der Dereferenzierung mit einer gültigen Speicheradresse initialisiert
  • ist der Speicherbereich, auf den der Pointer zeigt, gültig
  • beinhaltet der Zeiger den Wert NULL, welcher als Fehlerfall genutzt wird
  • zeigt der Zeiger nach einer Änderung (z.B. durch Zeigerarithmetik) weiterhin auf eine gültige Adresse

Ähnliche Sachverhalte gilt es bei Nutzung von Arrays zu prüfen

  • liegt der Indice innerhalb der Array Grenzen
  • ist sichergestellt, dass bei Übergabe eines Arrays an eine Funktion (sowohl selbstgeschrieben Funktion als auch Libraryfunktionen) diese nicht über die Grenzen des Arrays auf das Array zugreift

Ergänzend zu oben aufgeführten empfehlenswerten Codereviews empfehlen sich folgende Ansätze/Hilfsmittel zum finden/vermeiden von Laufzeitfehlern:

  • Bewusste Nutzung des C Syntax. So können mit dem Schlüsselwort 'const' die Zeiger in ihrer Verwendung eingeschränkt werden (wird leider nur selten genutzt)
  • Erweiterte Syntaxprüfung des Compiler nutzen, z.B. durch den Compiler-Schalter: '-Wall -Werror'
  • Nutzung externer Codeanalysetools (wie z.B. lint) welche eine tiefergehende Codeanalyse als der Compiler durchführen und bei welcher einige C Funktionalitäten 'deaktiviert' werden können
  • Nutzung des   Code sanitizer (-fsanitize=address), welcher im erzeugten Maschinencode zusätzliche Testfunktionen einbaut und Canarie zwischen den einzelnen Variablen legt, so dass Bufferüberläufe und 'Use-after-Free' Zugriffe durch Prüfung der Canarie erkannt werden
  • Nutzung von   Valgrind, welcher den auszuführenden Maschinencode in einen anderen Zwischencode übersetzt und diesen unter seiner Kontrolle in einer virtuellen Maschine ausführt (Verwendung von Valgrind siehe bspw.: Valgrind memcheck: Different Ways to lose your memory)
  • Nutzung des _FORTIFY_SOURCE Makros (sofern durch GNU-C Library bereitgestellt) zur Vermeidung von Bufferoverflows (siehe auch # GCC's new fortification level: The gains and costs)

Hinweis:

  • Ausgaben auf die Standardausgabe (bspw. über printf()) sind gepuffert, d.h. sie werden nicht unmittelbar zur Anzeige gebracht. Stürzt das Programm aufgrund eines Laufzeitfehlers ab, so wird dieser Puffer nicht abschließend zur Anzeige gebracht. Im Falle von Laufzeitfehler empfiehlt sich das regelmäßige flushen des Puffers mittels 'fflush(stdout)' resp. der Nutzung der Standfehlerausgabe mittels 'fprintf(stderr,"Fehler")' oder 'perror("Fehler")'

Zusammenfassung

Bearbeiten

C wurde mit dem Ziel entwickelt, als höhere Programmiersprache zum Erstellen eines Betriebssystems zu dienen. Schnelle Programmausführung stand somit im Vordergrund. Die fehlenden Prüfungen bei bspw. Arrayzugriffen und Pointerdereferenzierung sind gewollt. C geht davon aus, dass der Programmierer weis, was er tut. Wenn der Programmierer bestimmte Sachverhalte während der Laufzeit nicht sicherstellen kann, so muss die notwendige Überprüfung durch zusätzlichen Code händisch sichergestellt werden!

Wie gesagt ist C ein offener Syntax, welcher nur wenige Einschränkungen kennt und den Syntax ohne Schnick-Schnack in Maschinencode umsetzt. Das heißt nicht, dass C eine schlechte Programmiersprache ist, sondern dass vorausgesetzt wird, dass der Programmierer weiß, was er tut:

  • Er kennt die Wertebereiche der unterschiedlichen Datentypen (Plattformabhängig) und kennt die impliziten Typkonvertierungsregeln, so dass keine Typverletzungen auftreten
  • Er kennt die Dimension des Arrays und stellt sicher, dass kein Zugriff auf ein Arrayelement außerhalb der Dimension erfolgt.
  • Wenn er Speicher im Heap reserviert, gibt er diesen nach Verwendung wieder frei.

In vielen neueren Sprachen wurde der Ansatz "Alles ist überall erlaubt / Der Programmierer weiß was er tut" eingeschränkt, indem:

  • der Syntax eingeschränkt wurde
  • zur Laufzeit Überprüfung stattfinden und ggf. eine Ausnahmebehandlung im Fehlerfall ausgeführt wird
  • eine komfortable Speicherverwaltung (Garbage Collector) integriert wurde


Die Programmiersprache C fordert vom Programmierer somit mehr Bewusstsein für die Sprache ab, als andere Programmiersprachen. Wer der Sprache C mächtig ist, wird sicherlich auch andere Sprachen bewusster nutzen!

Hinweis

  • Im Blog vom Tom Mewet sind einige wesentlichen Unterscheidungen von C zu anderen Sprachen dargestellt

Compiler Sprache

Bearbeiten

C ist eine Compiler-Sprache. Aus dem Source-Code wird durch die Toolchain, bestehend aus Compiler und Linker ein vom Prozessor direkt ausführbarer Maschinencode erzeugt.

Im Wikipedia Artikel   Compiler wird die Aufgabe eines Compilers wie folgt zusammengefasst: "Ein Compiler (auch Kompilierer; von englisch compile 'zusammentragen' bzw. lateinisch compilare 'aufhäufen') ist ein Computerprogramm, das Quellcodes einer bestimmten Programmiersprache in eine Form übersetzt, die von einem Computer (direkter) ausgeführt werden kann."

Beispiel eines Übersetzungsvorganges:
C-Code

int a=7,b=8;
a=a+b+7;

Assemblercode (etwas vereinfacht dargestellt)

X86-64 ARM 6502 Anmerkungen
MOV edx,DWORD PTR a[rip] 
MOV eax,DWORD PTR b[rip]
LDR r1,.LCPI0_0
LDR r0,[r1]
LDR r2,.LCP0_1
LDR r2,[r2]
LDA _a
LDX _a+1
JSR pushax
LDA _b
LDX _b+1
Lade Inhalt der Variablen in die Register
ADD eax,edx
ADD r0,r0,r2
JSR tosaddax
Addiere die Variablen
ADD eax,7
ADD r0,r0,#7
JSR incax7
Addiere die Konstante 7
MOV DWORD PTR a[rip],eax
STR r0,[r1]
STA _a
STX _a+1
Speichere Register zurück in die Variablen


Der vom Compiler erzeugte Maschinencode liegt letztendlich als Zahlencode im Speicher (hier nicht dargestellt) und wird vom Prozessor Befehlsweise eingelesen und direkt ausgeführt. Der Start des erzeugten Maschinencode, die Rückkehr zum Betriebssystem und der Aufruf von Library-Funktionen (z.B. printf) bedingen ergänzende Abhängigkeiten. Für jeden Prozessor und jedes Betriebssystem ist folglich ein gesonderter Compiler notwendig.

Arbeitsweise eines Compilers

Bearbeiten

Der Wikipedia Artikel   Compiler beschreibt die prinzipiellen Schritte bei der Übersetzung eines Quellcodes in einen Ziel Code wie folgt:

Syntaxprüfung
es wird geprüft, ob der Quellcode ein gültiges Programm darstellt, also der Syntax der Quellsprache entspricht. Festgestellte Fehler werden protokolliert. Ergebnis ist eine Zwischendarstellung des Quellcodes
Analyse und Optimierung
Die Zwischendarstellung wird analysiert und optimiert. Dieser Schritt variiert im Umfang je nach Compiler und Benutzereinstellung stark. Er reicht von einfacheren Effizienzoptimierungen bis hin zu Programmanalyse
Codeerzeugung
Die optimierte Zwischendarstellung wird in entsprechende Befehle der Zielsprache übersetzt. Hierbei können weitere, zielsprachenspezifische Optimierungen vorgenommen werden

Diese Schritte können sowohl unabhängig voneinander durchlaufen werden (Multi-pass-Compiler), als auch in Einem (Single-pass-Compiler). Bei Letzteren wird der Quellcode nur einmalig von vorne bis hinten analysiert und zugleich der Maschinencode erzeugt. Trifft der Compiler auf einen Variable/Funktion/Datentyp, welche zuvor nicht definiert wurde, kann der Compiler hierfür keinen Maschinencode erzeugen. Der Code darf somit keine Vorwärtsbezüge enthalten.
Multi-pass-Compiler durchlaufen den Code mehrmals, so dass in einem am Anfang liegenden Durchlauf zunächst der Code nach Definitionen durchsucht wird. Die eigentliche Codeerzeugung erfolgt dann in einem späteren Durchlauf.

Die Sprache C entstammt aus einer Zeit, indem Speicher ein knappes Gut war. Typische Hauptspeichergrößen waren 32kByte...128kByte, Festplattenspeicher ca. 5MByte. Die Spezifikation der Sprache ging aufgrund dieser knappen Ressourcen von einem Single-pass-Compiler als Werkzeug aus. Daraus ergibt sich, dass in C alle Variablen/Funktionen/Datentypen vor der ersten Nutzung definiert oder per Prototyp/(Forward)deklaration beschrieben werden müssen.

Compileroptimierungen

Bearbeiten

Neben der reinen Übersetzung unterstützen moderne Compiler div. Optimierungsmöglichkeiten wie z.B.:

  • Halten von Variablen in Register
  • Erkennung ungenutzter Variablen
  • Optimierung von Schleifen
  • Erkennung und Optimierung von 'doppelten' Anweisungen

Optimierungen versuchen, das erstellte Programm hinsichtlich der Anzahl der Maschinenbefehle (kleineren Code) oder der Ausführungsgeschwindigkeit der Befehle (schnelleren Code) zu optimieren, ändern aber nichts am Source-Code. Hohe Optimierungseinstellungen können aufgrund der Zwischenspeicherung von Variablen in Registern und der Optimierung der Ausführungsreihenfolge den erzeugten Maschinencode so entfremden, dass dieser im Debugger nicht mehr nachvollziehbar angezeigt werden kann.
Daher wird in C-Projekten zwischen den Debug-Mode und dem Release-Mode unterschieden. Im ersten sind viele Optimierungen ausgeschaltet und ggf. ergänzende printf() Ausgaben enthalten, so dass im Debugger die Codeabarbeitung verfolgt werden kann. Im Release-Mode wird der Compiler auf 'max' Optimierung gestellt. Ein Debuggen ist zwar auch hier möglich, aber der Zusammenhang zwischen Maschinencode und Source-Code nur schwer nachvollziehbar.

Die Optimierungsmöglichkeiten eines Compilers sind zwar gut, der Compiler kann aber nur das optimieren, was der Programmierer 'vorgibt'. Für einen optimalen Code ist folglich auch der Programmierer verantwortlich:

  • Stringvergleich dauert immer länger als ein Zahlenvergleich, d.h. bspw. keine Zustandsinformation im Datentyp String speichern (zustand="ON") sondern als Zahlenwert (zustand=1)
  • Mehrmalige Aufrufe einer Funktion mit identischen Übergabeparametern sind zu vermeiden
for(index=0; index<strlen(str);  index++)
//die Funktion strlen() wird bei jedem Schleifendurchlauf aufgerufen
  • Mathematische Ausdrücke, sofern möglich in Ganzzahlarithmetik durchführen
  • IF-Abfragen vermeiden (Sprünge in Maschinensprache gehören zu den langsamsten Maschinenbefehlen)
  • Schleifen von oben nach unten zählen lassen (erspart ein Vergleich und damit einen Sprung) oder alternativ zur Zählvariable einen Pointer nutzen
for(int lauf=0; lauf<10;lauf++)  //Ein Vergleich für < und
                                 //ein Vergleich für True/False notwendig
for(int lauf=10;lauf;lauf--)     //Nur Vergleich auf True/False notwendig
char str[]="hallo";
for(const char *ptr=str;*ptr;ptr++) //Vermeidung der Index-Variable
  • Durchsuchen eines zweidimensionalen Arrays in der inneren Schleife über den rechten Index und nicht den linken Index (Optimierung, so dass der Cache optimal genutzt wird)
Schlecht Gut
int zeile,spalte;
for(spalte=999;spalte;spalte--)
   for(zeile=999;zeile;zeile--)
      if(matr[zeile][spalte]>max)
           max=matr[zeile][spalte];
int zeile,spalte;
for(zeile=999;zeile;zeile--)
   for(spalte=999;spalte;spalte--)
      if(matr[zeile][spalte]>max)
           max=matr[zeile][spalte];

Zusammenspiel von C und Betriebssystem

Bearbeiten

C wurde als Sprache zum Schreiben eines Betriebssystems (Unix) entwickelt, zwecks einer effizienteren Programmierung und einer Portierbarkeit (beides hat dann Unix zum Durchbruch verholfen). Die Sprache ist somit eng mit dem Unix-Betriebssystem (und anderen Betriebssystemen, die entsprechende Eigenschaften adaptiert haben) verbunden. Dies ist unter anderen daran erkennbar, dass:

  • Alles ist eine Datei, d.h.
  • Standardein-, Standardaus- und Standardfehlerausgabe erfolgt über Lesen/Schreiben in eine Datei, welche mit dem Start des Programms geöffnet sind (resp. vom startenden Programm vererbt werden). printf(), scanf() und error() nutzen diese Dateien als Arbeitsgrundlage.
  • Ein Großteil der Interaktion mit dem Betriebssystem erfolgt über Dateizugriffe (Zugriff auf Geräte, Zugriff auf Prozessorauslastung, ...)
  • Start des Programms erfolgt über den Funktionsaufruf main(), welcher Übergabeparameter (als Array von Strings) übergeben werden und welche einen Fehlerstatus (als Integer) an den Aufrufer zurückgibt.
  • Die Nutzung von Environment-Variablen zum Datenaustausch zwischen Betriebssystem und Anwendungsprogramm (Länder-/Spracheinstellung, Systemkonfigurationen, ...)
  • Klare Trennung zwischen OS-Funktionalität und C-Funktionalität:
  • OS-Funktionen gehören nicht zur C-Standardbibliothek. D.h. OS-Funktionen werden direkt, ohne Nutzung von Wrapper, aufgerufen (z.B. wird zur Erzeugung eines Threads direkt die dazugehörige OS-Funktionalität pthread_create() genutzt)
  • Die Standard-C-Library dementsprechend nur wenige Funktionen bereitstellt
  • Libraries / DLL beruhen auf den CallingConvention von C (Regelung, wie bspw. die Übergabeparameter an Funktionen übergeben werden) und sind folglich rudimentär C-Funktionen
  • Linker (ein Programm zu binden mehrere Objekt-Dateien zu einer Objekt-Datei) ist eine Funktionalität des Betriebssystems (und nicht des Compilers). Das Ausgabeformat des Linkers ist wahlweise:
  • eine ausführbare Datei
  • eine statische Library (Archiv)
  • eine dynamische Library
d.h. dynamische Librarys können mit diversen Compiler-Sprache erstellt werden und auch von diversen Programmiersprachen (inkl. Interpretersprachen wie Python) eingebunden werden
  • Die Standardbibliotheksfunktionen von C sind in Librarys ausgelagert. printf() / strlen() sind bspw. in der Library libc.so und sin() / sqrt() in der Library libm.so enthalten. Werden aus einem Programm diese Funktionen genutzt, so müssen diese Librarys über Linkerschalter eingebunden werden (Ausnahme libc.so, welche per Default eingebunden wird)

Alles ist Speicher

Bearbeiten

Ein Rechnersystem besteht im Wesentlichen aus einem Prozessor/CPU (bestehend aus Arithmetische/Logischen Einheit (ALU), den Registern und dem Steuerwerk) und aus Speicher. Das Zusammenspiel beider Einheiten lässt sich wie folgt darstellen:

 
Interpretation des Speicherinhaltes als Variablenspeicher und Programmspeicher

Die Aufgabe der CPU besteht darin, die Maschinenbefehle aus dem Speicher zu laden (adressiert über den ProgrammCounter PC) und diese einen nach dem anderen auszuführen. Die zu verarbeitenden Daten (Variablen) sind ebenfalls im Speicher enthalten (angesprochen über die Speicheradresse z.B. 0xf0f, 0xf0d).

Ein Speicher ist eine Einheit zur Speicherung von Informationen. Im weitesten Sinne entspricht der Hauptspeicher (nicht Festplatte, resp. sekundärer Speicher) einem Karteikarten System:

  • Wobei auf einer Karteikarte nur 8-Bit/1-Byte Information gespeichert sind
  • Vorzeichenlose Zahlenwerte von 0…255
  • Vorzeichenbehaftete Zahlenwerte von -128…+127
  • ASCII Zeichen
  • Maschinenanweisungen für den CPU
  • Max. Anzahl der Karteikarten hängt von der Adressierungsbreite (zumeist identisch zur Rechenbreite) des Prozessors ab:
  • Bei einem 32-Bit System sind 232=4 294 967 295 Karteikarten vorhanden
  • Bei einem 16-Bit System sind 216=65 536 Karteikarten vorhanden
  • Bei einem 64-Bit System sind 264=18 446 744 073 709 551 616 =18 Exa vorhanden
  • Bindeglied zwischen Prozessor und Speicher ist das Bus-System, welches sich in einen Daten- und einen Adressbus unterteilen lässt. Der Adressbus dient dabei der Adressierung einer Speicherstelle und der Datenbus zum Datentransport in die Speicherstelle (Schreiben) oder aus der Speicherstelle (Lesen).
  • Es kann zu einem Zeitpunkt nur einer (zumeist die CPU) durch Vorgabe einer Adresse die Daten (Inhalt der Karteikarten) lesen oder schreiben
  • Beim Lesen wird der gesamte Inhalt der Karteikarte zurückgegeben
  • Beim Schreiben wird der gesamte Inhalt der Karteikarte neu beschrieben (Änderung einzelner Bits bedingen das vorherige Lesen des gesamten Inhaltes (Var=Var|0b0100)
  • Es gibt kein Löschen, die einzelnen Bits des Speichers sind entweder 0 oder 1
  • Inhalte des Speichers hängen von der Interpretation (des Programms) ab
  • Maschinenbefehle, welches die CPU ausführt
  • Variablen als 8-Bit/16-Bit/32-Bit/Ascii/Float/Strutur/…
  • Heap, zur Darstellung von zur Laufzeit benötigten Speicher
  • Stack, zur Darstellung von lokalen Variablen
  • Der Inhalt des Speichers kann
  • Flüchtig sein, d.h. er verliert nach PowerOff seinen Inhalt und ist nach PowerOn mit einem zufälligen Wert gefüllt
  • Nicht flüchtig sein, d.h. er behält auch ohne Spannungsversorgung seinen Inhalt
  • Nur lesbar sein (für Konstanten)
  • Schreib- und lesbar sein (für Variablen)
  • Nur ausführbar sein (für Funktionen)

Zusammengefasst kann gesagt werden: "Alles ist Speicher". Jede Variable belegt somit in Abhängigkeit des Datentyps Speicher, jede Funktion belegt in Abhängigkeit der dazugehörigen Anweisungen Speicher und selbst der Zugriff auf die Peripherie zur Interaktion mit der Umwelt erfolgt über Speicherzugriffe. Variablen / Funktionen / Peripherie werden folglich über Speicheradressen angesprochen, d.h. :

  • Ein Zugriff auf eine Variable erfolgt über deren (Start)Speicheradresse, wobei dann in Abhängigkeit des Datentyps beginnend ab der Speicheradresse ein oder mehrere Bytes gelesen werden
  • Ein Aufruf einer Funktion entspricht einem Sprung zu einer Speicheradresse, ab welcher der nächste Maschinenbefehl zur Ausführung gebracht werden soll

In C wird dieses Konzept direkt aufgegriffen, indem:

  • Die Speicheradresse in einer Zeiger(variablen) gespeichert werden kann
  • Über den Adressoperator zu jeder Variablen/Funktion die Speicheradresse ermittelt werden kann und z.B. in einer Zeigervariablen gespeichert werden kann
  • Über Dereferenzierung auf jede Speicheradresse lesend und schreibendend zugegriffen werden kann

Alle Programmiersprachen basieren auf diesem Prinzip. Die Sprache C gibt einen Teil der Verantwortung für den Umgang mit Speicher an den Programmierer ab. Andere Sprachen versuchen, diesen Sachverhalt möglichst vor dem Programmierer zu verstecken. Dies lässt sich am Beispiel der Symboltabelle erkennen. In C werden durch den Compiler/Linker alle Variablen/Funktionssymbole direkt durch die Speicheradresse ersetzt, so dass die Symboltabelle im ausführenden Code nicht mehr enthalten ist. Dynamischer Speicher wird vom Programmierer und nicht vom Compiler verwaltet. D.h. der Programmierer kann sich Speicher anfordern, die Verwaltung des Speichers (Zuordnung zu den Attributen/Inhalten) liegt jedoch in der Verantwortung des Programmiers.

Toolchain/Entwicklungsumgebung

Bearbeiten


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/

Grundlagen

Bearbeiten

Die Programmiersprache C/C++ beinhaltet mehrere Sprachen/ Syntaxen:

  • C-Syntax
  • Präprozessor-Syntax
  • Printf/Scanf Formatstring Syntax
  • Terminal Emulation
  • Compiler/Linker Anweisungen

Letztere beiden werden nur rudimentär in dieser Vorlesung/diesem Skript behandelt. Alle anderen sind Bestandteil dieser Vorlesung.

In diesem Kapitel sollen zunächst allgemeine Eigenschaften der Sprache, der grundlegende Syntax und die Kontrollstrukturen erklärt werden. Bei vielen Erklärungen sind Code-Beispiele vorhanden. Da man aus Fehlern am meisten lernt, sind zum Teil auch negative Beispiele enthalten. Für ein besseres Verständnis empfiehlt es sich, Code-Beispiele selbst nachzuvollziehen.
Sofern sich die Eigenschaften auf eine bestimmte C-/C++- oder Compiler-Version beziehen, wird dies gesondert vermerkt.

Zeichensatz

Bearbeiten

Der Syntax von C nutzt die unteren 128 Zeichen des ASCII Zeichensatzes. Da UTF-8 in den ersten 128 Zeichen deckungsgleich zu ASCII ist, kann auch dieser zur Erstellung des Source Codes genutzt werden. Zeichen außerhalb dieses gültigen Zeichensatzes können folglich nur in Strings oder Kommentaren vorkommen. Werden Zeichen außerhalb der unteren 128 Zeichen in Strings genutzt, so gilt Folgendes zu berücksichtigen:

  • Die String-Funktionalitäten der Standard-C Library gehen von dem regulären ASCII Zeichensatz aus, so dass z.B. die Suche, der Vergleich oder die Konvertierung fehlschlagen kann
  • Strings werden oftmals in Verbindung mit printf() genutzt, d.h. auf der Standardausgabe ausgegeben. Der verwendete Zeichensatz sollte hier identisch zum verwendeten Zeichensatz der Terminal-Emulation sein!

Kommentarzeichen

Bearbeiten

Kommentare dienen dazu, den Source Code mit zusätzlichen Hinweisen zu versehen, so dass die Intension der Anweisungen sichtbar wird. Kommentarzeichen werden durch den Compiler vor dem eigentlichen Übersetzungsdurchlauf entfernt und durch ein Leerzeichen ersetzt [C11 6.4.9]. Innerhalb von Strings wird nicht nach Kommentarzeichen gesucht.

Syntax: /* */

Blockkommentar zum Kommentieren eines Bereiches, auch über mehrere Zeilen hinweg. Dieses Kommentarzeichen kann nicht verschachtelt werden, d.h. '*/' beendet die Kommentierung, unabhängig von der Anzahl der zuvor geöffneten Kommentierungen!

Syntax: //

Zeilenkommentar zum Kommentieren bis zum Zeilenende (wurde in der Programmiersprache BCPL definiert, aber nicht von C, sondern erst von C++ übernommen).

Sollen größere Bereiche auskommentiert werden, so empfiehlt sich die Nutzung der Präprozessoranweisung #if 0 ... #endif. Beispiele:

/* Blockkommentar mit zwei öffnenden Klammern /*
   über mehrere Zeilen, nicht verschachtelt */
//Zeilenkommentar
/* Kleiner Bereich zum auskommentieren */
#if 0
Großer Bereich zum auskommentieren
#endif

Öffnen im Compiler Explorer

Namenskonventionen

Bearbeiten

Folgende Regeln gelten bzgl. der Benennung von Variablen und Funktionen:

  • Variablen und Funktionsnamen können aus Buchstaben, Zahlen und dem Unterstrich bestehen. Sie müssen mit einem Buchstaben oder einem Unterstrich beginnen
  • Seit C95 können weitere Zeichen aus dem Universal Coded Character Set (UCS) genutzt werden [C11 D.1]
  • C ist Case sensitiv, d.h. es wird zwischen Groß- und Kleinbuchstaben unterschieden
  • Schlüsselwörter können nicht für Variablen/Funktionsnamen/Datentypen genutzt werden

Namenskonventionen von Libraryfunktionen:

  • In C (und C++) sind Schlüsselwörter und Standardlibraryfunktionen zumeist in Kleinbuchstaben geschrieben. In der C-Standardlibrary werden oftmals verkürzte Ausdrücke (z.B. isalnum() (zum Testen ob ein Zeichen ein Buchstaben oder ein Digit ist) und in C++ der Unterstrich als Worttrenner (z.b. out_of_range) genutzt
  • Makros (siehe Kap. Präprozessor) werden per Konvention in GROSSBUCHSTABEN und ggf. mit Unterstrich als Worttrenner geschrieben.
  • Namen beginnend mit doppelten Unterstrich oder beginnend mit einem Unterstrich gefolgt von einem Großbuchstaben (z.B. __LINE__ _Reserved) sind für den Compiler und der Standard-C-Library vorbehalten und sollten im eigenen Programm nicht benutzt werden.

Zeilenfortsetzung

Bearbeiten

Mit dem Backslash Operator (gefolgt von einem Zeilenende) kann eine Zeile in der nächsten Zeile fortgesetzt werden. Der Compiler löscht das \-Zeichen mit anschließendem Zeilenende und ersetzt dies durch nichts. Dies ist insbesondere bei Strings und bei Makros von Interesse, die am Ende der Zeile abgeschlossen sein müssen.

Beispiele:

char str1[]="Strings müssen am Ende abgeschlossen sein
             so dass dies ein Fehler ist";
char str2[]="Dies\
       ist ein Test";  //Vorsicht, führende Leerzeichen vor 'ist' 
                       //bleiben erhalten!
 /*mehrzeiliges Makro*/                       
#define MAX(a,b)    (a>b?\
                     b:  \
                     a)
//Dies ist ein Zeilenkommentar \
welcher in dieser Zeile fortgesetzt wird
/\
* dies ist ein Blockkommentar*\
/
//hinter der Zeilenfortsetzung darf nur ein CR/LF folgen 
#define MAX2(a,b)    \  //so dass hier kein Kommentar folgen darf
            a>b?a:b

Öffnen im Compiler Explorer

Hinweis:

  • Innerhalb von Char-Literatoren und Strings wird '\' als Escape-Operator genutzt, welche das '\' und ein oder mehrere folgende Zeichen ersetzt (siehe Kap. Grundlagen:Literale/Konstanten). Daher darf hinter Backslash als Zeilenfortsetzungszeichen kein weiteres Zeichen folgen.

Gültigkeit/Sichtbarkeit von Variablen

Bearbeiten

Vorrangig in der objektorientierten Programmierung werden mit Namensräumen Objekte und deren Methoden/Attribute in einer Art Baumstruktur strukturiert. Dies ermöglicht eine eindeutige Ansprache von Variablen/Objekte, aber auch eine doppelte Verwendung von Methoden-/Attributnamen in unterschiedlichen Namensräumen. Ergänzend zu den Namensräumen kann mit public/private/proteced eine Zugriffsbeschränkung von Methoden/Attributen definiert werden.

Die Programmiersprache C unterstützt keinen Namensraum. Zugriffsmodifikatoren werden indirekt über Header-Dateien getätigt. Hinsichtlich der Gültigkeit/Sichtbarkeit unterscheidet C folgende Bereiche [C11 6.2.1]:

  • Funktionen (Function Scope)
  • Datei (File Scope)
  • Block (Block Scope)
  • Funktionsparameter in Prototypen (Function Prototype Scope)

Innerhalb eines Gültigkeitsbereiches dürfen Variablen-/Funktions-/Datentypnamen nicht doppelt genutzt werden.
In der Programmiersprache C++ sind weitere Scope wie z.B. Class Scope, Enumationation Scope und ergänzend das Konzept von Namensräumen vorhanden (Beschreibung folgt).

Funktionsweite Sichtbarkeit

Bearbeiten

Eine Label-Definition (als Sprungmarke für die goto-Anweisungen) erfolgt immer mit funktionsweiter Sichtbarkeit/Gültigkeit. Die genaue Beschreibung dieses Sichtbarkeitstyps erfolgt im Rahmen des Kapitels Grundlagen:Label+Goto.

Dateiweite Sichtbarkeit

Bearbeiten

Erfolgt eine Funktion-/Variablen-/Datentyp-Definition außerhalb eines Block-Scopes oder von Funktionsparameter, so sind diese innerhalb der gesamten Datei und bei Funktionen/Variablen ergänzend Projektweit (für alle Objektdateien) sichtbar/gültig (= global) (In der C-Spec unter dem Stichwort 'Linkage' beschrieben, siehe Kapitel DatentypeSpecifier:Internal/External Linkage). Alle globalen Funktionen/Variablen können von allen Dateien aus genutzt/zugegriffen werden (sofern sie zuvor deklariert wurden).
Beispiel:

Datei1.c Datei2.c
//Deklaration von func(),
//welche in Datei2.c definiert wird
extern void func(void);

//Definition der Variablen global
int global=0;
int main(void) {
  func();
  global++;
  return 0;
}
//Deklaration von global,
//welche in Datei1.c definiert wird
extern int global; 

//Definition der Funktion func()
void func(void) {
  global++;
}

Öffnen im Compiler Explorer

Wird das Schlüsselwort 'static' der Variablen/Funktionsdefinition vorangestellt, so wird die Sichtbarkeit/Gültigkeit auf Dateiweit eingeschränkt. Variablen/Funktionen können nur innerhalb der (Objekt-) Datei genutzt werden und sind für anderen (Objekt-)Dateien unsichtbar. (Siehe Kap. DatentypeSpecifier:Internal/External Linkage Internal/External Linkage)

Datei1.c Datei2.c
//Dateiweite Sichtbarkeit von var1
static int var1;
 
int main(void) {
  var1++;
  return 0;
}
//Dateiweite Sichtbarkeit von var1
static int var1;

void func(void) {
  static int var2; //Vorsicht, static 
  // hat hier eine andere Bedeutung
  var1++;
}

Öffnen im Compiler Explorer

Projektweite Gültigkeit bedeutet insbesondere, dass keine doppelten Benennung von Variablen/Funktionen/Datentypen innerhalb des gesamten Projektes erlaubt sind, d.h. alle Variablen/Funktionennamen über alle Dateien/Librarys eindeutig sein müssen.
Beispiel 1:

Datei1.c Datei.2
#include <stdio.h>
int a=1;
int main(int argc,char *argv[])
{
  printf("Hello World %d",a);
  void dummy(void);  //Deklaration von Dummy
  dummy();
  return 0;
}
#include <stdio.h>
int a=7;
void dummy(void)
{
  printf("Hello Again %d\n",a);
}

Öffnen im Compiler Explorer

Der Linker meldet beim Zusammenfügen der Objekt-Dateien, dass die Variable a bereits woanders definiert sei (multiple definition of `a';)!

Beispiel 2:

#include <stdio.h>   //fuer printf() size_t stderr
int printf=7;

Öffnen im Compiler Explorer

Hier meldet der Compiler eine Fehlermeldung, da das Symbol printf zu einen als Variable und zum anderen als Funktion (innerhalb der inkludierten Datei stdio.h beschrieben) genutzt wird ('printf' redeclared as different kind of symbol).

Blockweite Sichtbarkeit

Bearbeiten

Erfolgt eine Funktion-/Variablen-/Datentyp Definition innerhalb einer Funktion, als Funktionsparameter oder eines Blockes, so sind diese nur innerhalb des Blockes sichtbar/gültig (=Lokale Variable). Blöcke können verschachtelt sein, so dass das bei identischer Namensgebung innere Definitionen Vorrang haben. Ebenso haben Blockdefinitionen Vorrang vor Datei-/Projektdefinitionen (überdecken diese):

Datei1.c Datei2.c
int main(void) {
  int var2;    //var2 ist nur innerhalb
               //von main() sichtbar
  struct xyz   //Datentyp ist nur innerhalb
  {int x,y,z;};//von main()   
               //sichtbar/gültig
  extern void func(void); //Deklaration     
          //ist nur innerhalb von
          //main() sichtbar/gültig
    func();
}
 
void foo(void) {
 struct xyz var_xyz;  //Fehler, da
 //Datentypedefinition hier nicht mehr
 //gültig ist!

 func();  //Fehler, da Deklaration
 //hier nicht mehr gültig ist
}
#include <stdio.h>
void func(void) {
  int var2=1;  //var2 ist
  //nur innerhalb von 
  //func() sichtbar

  {
    int var2=2; 
    //var2 ist nur innerhalb 
    //dieses Blockes sichtbar
    printf("%d\n",var2);
  }
  printf("%d\n",var2);
}

Öffnen im Compiler Explorer

Sichtbarkeit von Funktionsparameter in Protypen

Bearbeiten

Soll eine Funktion aufgerufen werden, die zuvor nicht definiert wurde, so ist ein Prototyp/(Forward)deklaration der Funktion notwendig (siehe nachfolgendes Kapitel). Die Parameterliste dieses Prototyps hat seinen eigenen Gültigkeitsbereich.
Beispiel:

int a;   //a im File Scope
int func(int a, int b);  //a und b im Function Prototyp scope

Hinweis:

  • Bei Prototypen ist die Angabe von Variablenname nicht notwendig. Die alleinige Angabe des Datentyps ist ausreichend

Zuordnung der Gültigkeitsbereiche zu globalen/lokalen Variablen

Bearbeiten

Zum einfacheren Verständnis wird im Skript von folgenden Begrifflichkeiten gesprochen:

  • Globale Variablen-/Funktionen-/Datentypdefinitionen → Projektweite Sichtbarkeit/Gültigkeit
  • Statisch globale Variable/Funktionen → Dateiweite Sichtbarkeit/Gültigkeit
  • Lokale Variablen/Funktionen/Funktionsparameter/Datentypen → Nur innerhalb des zugehörigen Blockes sichtbare/gültig
  • Statisch lokale Variablen → Nur innerhalb der Funktion sichtbar/gültig, mit der Besonderheit, dass die Gültigkeit über der Laufzeit der Funktion gilt (siehe Kap. DatentypeSpecifier:Static Storage Class Specifier)

Speicherzuweisung

Bearbeiten

Für die einzelnen Gültigkeitsbereiche werden im Speicher (und auch in der Objekt-Datei) unterschiedliche Speicherbereiche (Segmente) vorgehalten, d.h. der Compiler teilt den Speicher in unterschiedliche Bereiche auf und weist den Variablen/Funktionen in Abhängigkeit der Gültigkeit/Sichtbarkeit unterschiedliche Speicherbereiche zu!

 
Zuordnung von Inhalten eines C-Programms zu Speicherbereichen (Segmenten)

Zur Ermöglichung von Rekursion werden lokale Variable auf dem Stack gehandelt. Mit Aufruf einer Funktion oder öffnen eines neuen Block-Scopes wird Speicher auf dem Stack reserviert, der bei Beenden des Scopes wieder freigegeben wird. Die Speicheradresse von lokalen Variablen ist somit nicht Fix, sondern hängt von der Aufrufhierarchie ab!

Globale und statisch globale Variablen werden nicht auf dem Stack gehalten, sondern bekommen einen 'festen' Speicherbereich (hier DATA/BSS/RODATA) während der gesamten Laufzeit des Programms zugewiesen. Die Adresse dieser Variablen sind somit konstant. Das genutzte Speichersegment von globalen Variablen hängen von der Art der Variablen ab. Initialisierte Variablen werden im DATA-Segment gesammelt, welcher mit dem Initialisierungswert der Variablen initialisiert wird. Nicht initialisierte globale Variablen landen im BSS-Segment, so dass diese mit Programmstart durch Füllen dieses Blockes mit 0 auf 0 gesetzt werden. Konstante globale Variablen werden im RODATA-Segment gehalten. Über das Betriebssystem kann dieser Speicherbereich auf nur Lesbar gesetzt werden.

Dynamische Speicheranforderungen, resp. Speicher, dessen Gültigkeit über die Laufzeit einer Funktion hinausgeht, wird im Heap gesammelt. Für die Verwaltung dieses Speichers ist der Programmierer zuständig.

Entsprechend globalen Variablen wird für Funktionen ebenfalls ein eigener Speicherbereich (TEXT Segment) vorgesehen. Im Gegensatz zu Variablen, wo im Speicher der dazugehörige Inhalt gespeichert ist, ist der Speicherinhalt von Funktionen die Maschinenanweisungen. Die Speicheradresse der Funktionen sind konstant.

Hinweis:

  • Die Speicheradresse von sowohl globalen und statisch globalen Variablen als auch von Funktionen ist konstant und ändert sich während der Laufzeit des Programms nicht. Die Adressvergabe dieser Variablen und Funktionen erfolgt somit nicht zur Laufzeit sondern durch die Toolchain (hier der Linker).
  • Dieses Konzept ist allgemeingültig, gilt nicht nur für die Programmiersprache C/C++

Definition/Deklaration(Prototyp)

Bearbeiten

Der C-Compiler ist ein Single-Pass Compiler (siehe Kap Einführung/Literatur:Arbeitsweise eines Compilers ), d.h. für den Übersetzungsvorgang wird der Quellcode einmalig eingelesen und von vorne nach hinten abgearbeitet. Dies bedingt, dass keine Vorwärtsbezüge im Quelltext enthalten sein dürfen:

  • keine Variable genutzt werden kann, die erst später definiert wird (da der Compiler in Abhängigkeit des Datentyps (den er dann noch nicht kennt) keine entsprechende Maschinensprachebefehle erzeugen kann)
  • keine Funktion aufgerufen werden kann, die erst später definiert wird (da der Compiler nicht weiß, welcher Datentyp die Übergabewerte haben und folglich keine Maschinensprachebefehle zur Konvertierung und Übergabe der Variablen erzeugen kann)
  • kein Datentyp genutzt werden kann (struct, union, enum, typedef), welcher erst später definiert wird (da der Compiler nicht weiß, wieviel Speicher er für die Variable reservieren muss)

Eine Lösungsvariante dieser Problematik ist, im Quellcode alle Variablen/Funktionen/Datentypen/Makros zu definieren, bevor diese erstmalig genutzt werden (Funktion main() steht dann folglich am Ende des Quellcodes):

void foo(void) {   //foo() wird vor dem ersten Aufruf definiert
  //Hier kein Zugriff auf var möglich!
  //Hier kein Zugriff auf Datentyp uchar möglich!
}
int var;            //var wird vor dem ersten Aufruf definiert
typedef unsigned char uchar; //Definition des Datentyps uchar
int main(void) {
  foo();     //da zuvor definiert kann diese Funktion hier aufgerufen werden
  var++;     //dito
}

Öffnen im Compiler Explorer

Sollen globale Funktionen/Variablen in Datei1.c genutzt werden, die in Datei2.c definiert sind, so schlägt diese Vorgehensweise fehl. Um auch dieses zu ermöglichen, bietet C die Möglichkeiten von Prototypen/Deklarationen an. Diese teilt dem Compiler mit, wie im späteren Ablauf die Variable, die Funktion definiert wird, bzw. dass es diesen Datentyp geben wird:

Datei1.c Datei2.c
//Prototyp für Variable var
extern int var; 

//Prototyp für Funktion foo
void foo(void);

int main(int argc, char *argv[]) {
  foo();  //Compiler kann aufgrund
  var++;  //der Prototypen diese
            //Aufruf in Maschinen-    
            //sprachebefehle umsetzen
}
//Definition der Variablen var
int var;

//Definition der Funktion foo
void foo(void)  {
}

Öffnen im Compiler Explorer

Innerhalb der C-Spezifikation werden für den Umgang mit dieser Problematik die Begriffe 'Declaration' und 'Definition' genutzt. Da diese Begriffe jedoch nicht klar abgegrenzt werden und auch für viele weitere Aspekte genutzt werden, sollen in diesem Skript in Anlehnung an den Wikipedia Artikel   Deklaration (Programmierung) (im Absatz zur Programmiersprache C/C++) die Begriffe Definition/Deklaration wie folgt genutzt werden:

Definition
entspricht der Belegung/Reservierung von Speicherplatz
  • Bei der Definition einer Variablen wird hiermit Speicherplatz entsprechend dem Datentyp der Variable belegt:
int var1;       //entsprechend der Größe des Datentyps int wird
                //Speicherplatz für die Variable var1 reserviert
int var2=4711;  //Dito, ergänzend wird dieser Speicherplatz mit dem 
                //Wert 4711 initialisiert
  • Bei einer Funktion wird Speicherplatz zur Aufnahme der Maschinenbefehle belegt:
void foo(void) {   //Die Anweisungen in den geschweiften Klammern 
                   //werden in Maschinenbefehle übersetzt. D.h. die
                   //Funktion foo() belegt Speicher, dessen Inhalt 
                   //die Maschinensprachebefehle sind
}
  • Ergänzend wird der Begriff Definition auch in Verbindung mit der Definition von neuen Datentypen genutzt!
Deklaration/Prototyp
ist die Mitteilung an den Compiler, wie diese Variable/Funktion an einer späteren Stelle im Source-Code oder in einer anderen Source-Datei definiert wird. Auf Grundlage dieser 'vorab' Information kann der Compiler die passenden Maschinensprachebefehle für den Zugriff/Aufruf auf die Variablen/Funktion einsetzen:
extern int var1;        //Deklaration von var1
extern void foo(void);  //Deklaration von foo()
       void foo(void);  //Deklaration von foo()

Insbesondere bei dem Umgang mit der Deklaration gilt es Folgendes zu berücksichtigen:

  • Deklarationen werden entweder:
  • am Anfang einer C-Datei geschrieben, so dass Funktionen/Variablen im Zugriff stehen, die erst später in der C-Datei definiert werden (entspricht dann der Private Anweisung in anderen Sprachen)
  • in Header-Dateien geschrieben, so dass diese Variable/Funktionen für alle sichtbar sind, welche diese Header-Datei einbinden (entspricht der Public Anweisung in anderen Sprachen)
  • Deklarationen von Funktionen werden in der C-Spec als (function) Prototyp bezeichnet und sind dadurch gekennzeichnet, dass diese mit einem Semikolon abgeschlossen sind. Der Specifier 'extern' ist hier optional und sagt eigentlich aus, dass diese Funktion external Linkage besitzt (siehe DatentypeSpecifier:Internal/External Linkage). Die Benennung der Parametervariablen sind gleichermaßen optional:
extern int foo(int   var1, float var2);  //Deklaration
extern int foo(int       , float     );  //Deklaration
       int foo(int   var1, float var2);  //Deklaration + external Linkage
       int foo(int       , float     );  //Deklaration + external Linkage
  • Für globale Variablen gibt es keine (Variablen) Prototype. Stattdessen muss zur Bekanntgabe des Datentyps der Specifier 'extern' vorangestellt werden, mit welchen ergänzend zum Ausdruck gebracht wird, das diese Variable external Linkage und damit globale Gültigkeit hat (siehe Kap DatentypeSpecifier:Extern - Storage Class Specifier). Da die Deklaration einzig zur Bestimmung der Zugriffsmethoden auf Maschinenspracheebene genutzt wird, können diese keine Initialisierungswerte enthalten:
extern int var1;
extern int var2=7; //KO Deklarationen können keine 
                   //Initialisierungswerte haben

Öffnen im Compiler Explorer

  • Deklarationen von statisch globalen, lokalen und statisch lokalen Variablen nicht möglich sind:
extern static int glob; //keine Deklaration von statisch globalen
                        //Variablen möglich
int main(int argc, char *argv[]) {
  extern int var;  //Deklaration bezieht sich auf eine globale
                   //Variable, die nicht definiert ist
  extern static int lok;
  printf("%d %d\n",var,lok);
  int var;
  static int lok;
}

Öffnen im Compiler Explorer

  • Deklarationen für identische Variablen/Funktionen können beliebig oft erfolgen, sofern der Datentyp identisch ist:
extern int var;
extern int var;           //OK, doppelt Deklaration mit identischen Datentyp
extern unsigned int var;  //KO, Doppelte Deklaration mit unterschiedlichen
                          //    Datentyp
  • Sofern eine Deklaration und eine Definition in der identischen Datei erfolgen überprüft der Compiler, ob Deklaration und Definition identisch sind. Es empfiehlt sich, die eigene Header-Datei (in welcher die Deklaration normalerweise enthalten ist) zu inkludieren:
Datei1.c Datei1.h
//Include der eigenen Header-Datei
#include "datei1.h"
int var1;   //Definition von var1
short var2; //Definition von var2

int var3;  //Private Variable
           //da kein Prototyp
           //im Header vorhanden
void func(void) {
}
extern int var1;  //Deklaration
extern char var2; //KO, da Deklaration
                  //von Definition 
                  //abweicht
void func(int);   //KO, da Deklaration
                  //von Definition
                  //abweicht

Öffnen im Compiler Explorer

  • Innerhalb einer Datei kann eine Variable identischen Datentyps mehrmals definiert werden (bei identischen Initialisierungswert). Diese Definitionen werden vom Compiler als eine Definition angesehen. Wenn jedoch eine identische Variable in unterschiedlichen Dateien definiert wird, so legt jede Datei eine eigene unabhängige Variable an, welche dann beim Zusammenführen der Objektdateien durch den Linker zu Fehler führt. Da Header-Datei zumeist in mehrere C-Dateien inkludiert werden, sind Definitionen in Header-Dateien 'verboten'.
  • Deklarationen innerhalb eines Block-Scopes oder eines Function Prototyp Scopes sind nur in diesen Bereich gültig.

Grundlegende Datentypen und Typsicherheit

Bearbeiten

Die Programmiersprache C kennt folgende grundlegende Datentypen (siehe Kap. Datentypen). Von jedem Datentyp kann ein Zeiger angelegt werden:

Datentyp Bsp. für Datentyp Bsp. für Zeiger auf Datentyp
Ganzzahl
          char  var;
           int  var;
     short int  var;
     long  int  var;
long long  int  var;
           char *ptr;
           int  *ptr;
     short int  *ptr;
     long  int  *ptr;
long long  int  *ptr;
Gleitkommazahl
     double var;
     float  var;
long double var;
     double *ptr;
     float  *ptr;
long double *ptr;
Funktionen
void func(void) {}
void (*pfunc)(void);
Arrays
int   arr[10];
float arr[3][3];
int   (*parr);
float (*parr)[3];
void (*pfunc[5])(void);
Strukturen/Unions
struct xyz {int x,y,z};
union abc  {int a,b,c};
struct xyz *ptr;
union abc  *ptr;
Aufzählungstyp
enum abc {A,B,C};
enum abc *ptr;


Boolescher Datentyp
_Bool var;
_Bool *ptr;
Komplexe Zahlen
_Complex var;
_Complex *ptr;

Mit der Defintion (dem Anlegen) einer Variablen eines Datentyps wird Speicher entsprechend der Größe des Datentyps reserviert. Optional kann dieser Speicher initialisiert werden. Über den Datentyp wird ausgesagt, wie der Inhalt des Speichers zu interpretieren ist:

char   var1;        //Speicherplatzreservierung von 1 Byte
                    //Inhalt der Speicherstelle wird als Zahl interpretiert
                    //(Hinweis: ASCII-Zeichen sind Zahlen)
double var2=47.11;  //Speicherplatzreservierung von 8 Byte
                    //Inhalt der Speicherstellen wird als Gleitkommazahl
                    //interpretiert (bestehend aus je einer Ganzzahl für
                    //Mantisse und Exponent)
short arr[10];      //Speicherplatzreservierung von 10*2 Byte
                    //Inhalt der 10 Speicherstellen werden als Ganzzahl
                    //interpretiert

Die Funktionalität der Operatoren hängt vom Datentyp der Operanden ab. Der Plus-Operator angewendet auf ganzzahlige Operanden führt eine Ganzzahl Addition aus, angewendet auf gleitkomma Operanden eine Gleitkomma Addition. Ist einer der Operanden ein Zeiger, so wird eine Zeigeraddition angewendet. Für andere Datentypen ist der Plus-Operator nicht definiert, so dass der Compiler einen Fehler wirft. Bei der Betrachtung eines Ausdruckes sollte dem genutzten Datentypen folglich eine besondere Aufmerksamkeit zuteilwerden:

int vari=47;
vari+12;           //(int) + (int) → Ganzzahladdition
float varf=12.3;
3.1415F+varf;      //(float) + (float) → Gleitkommaaddition
int *ptr=&vari;
ptr+3;             //(int *) + (int) → Zeigeraddition
int arr[3];
arr+3              //(int (*)) + (int) → Nicht definiert

Das Ergebnis einer Operation hat ebenfalls einen Datentyp. Eine Ganzzahl Addition gibt als Ergebnis einen Ganzahl Datentyp zurück, eine Gleitkomma Addtition einen Gleitkomma Datentyp, usw.:

int vari1=47;
int vari2=11;
vari1 + 12 + vari2;     //((int) + (int))→(int) + (int)
40 < vari1 < 50;        //((int) < (int))→(int) < (int)

Wie Operatoren haben auch Funktionen Operanden (in Form von Übergabeparameter). Das Ergebnis eines Funktionsaufrufes wird ebenfalls in Form eines Datentyps zurückgegeben:

double add(int par1,par2) {return par1+par2;}
int vari1=47;
int vari2=11;
vari1 + add(var2,12);   //(int) + ((int),(int))→(int)

Sind die Datentypen der Operanden nicht 'kompatibel', so 'passt' der Compiler die Datentypen im Falle von Ganzahl und Gleitkomadatentypen per impliziter Regel an (siehe Kap. Datentypen:Implizite Typumwandlung). Über explizite Typumwandlung (siehe Kap.Datentypen:Explizite Typumwandlung) kann ein Datentyp z.T. in einen anderen Datentyp gewandelt werden. In allen anderen Fällen ist C Typsicher, d.h. der Compiler gibt zumindest eine Warning aus, wenn die Datentypen nicht identisch sind und der Compiler keine implizite Regel anwenden kann.

Tipp:

  • Compiler gibt bei Fehlermeldung oftmals die erwarteten und die vorhandenen Datentyp an. Wenn solch eine Fehlermeldung kommt, sollten sie sich a) überlegen, was wollten sie syntaktisch geschrieben haben und b) was haben sie in der Tat geschrieben. Ein Google nach einer Lösung des Compilerproblems löst zumeist nicht das Problem, sondern umgeht es!

Hinweis:

  • Ein guter Programmierer sollte die Datentypen so wählen, dass:
  • die Datentypen zum Inhalt und zum Wertebereiches passen
  • keine Datentypkonvertierungen notwendig sind
andernfalls besteht die Gefahr, dass hiermit Laufzeitfehler einprogrammiert werden.
  • Wenn Datentypkonvertierungen notwendig sind, sollte die explizite Konvertierung genutzt werden

Abhängigkeiten bzgl. Rechnerarchitektur

Bearbeiten

Kenngrößen von Prozessoren sind unter anderem die Anzahl der Bits für den Datentransport und für Arithmetisch-/Logische-Operationen (→ Datenbreite) und die Anzahl der Bits für die Adressierung des Speichers (→ Adressierungsbreite). Die Datenbreite sagt dabei aus, welche max. Datenbreite ein Maschinenbefehl (zumeist in einem Taktzyklus) transportieren/berechnen kann. Werden programmtechnisch größere Datenbreiten benötigt, bedingt dies, dass hierzu mehrere Maschinenbefehle notwendig sind. Wenn kleinere Datenbreiten notwendig sind, so wird oftmals das Ergebnis abgeschnitten (sofern der Prozessor keine Befehle für den Umgang mit kleineren Datentyp beherrscht):

32-Bit Datentransport/Operation
bei 32-Bit Datenbreite
64-Bit Datentransport/Operation
bei 32-Bit Datenbreite
16-Bit Datentransport/Operation
bei 32-Bit Datenbreite
int32_t a32,b32;
int64_t a64,b64;
int16_t a16,b16;
// a32=b32;
mov  eax,b32
mov  a32,eax
// a64=b64;
mov  eax  ,b64
mov  edx  ,b64+4
mov  a64  ,eax
mov  a64+4,edx
//a16=b16; 
movz  eax,b16
mov   a16,ax
// a32=a32+b32;
mov  edx, a32
mov  eax, b32
add  eax, edx
mov  a32, eax
// a64=a64+b64;
mov  ecx  , a64
mov  ebx  , a64+4
mov  eax  , b64
mov  edx  , b64+4
add  eax  , ecx
adc  edx  , ebx
mov  a64  , eax
mov  a64+4, edx
// a16=16+b16;
movzx eax, a16
mov   edx, eax
movzx eax, b16
add   eax, edx
mov   a16, ax

Die Adressierungsbreite gibt an, mit welcher Bitbreite der Speicher adressiert werden kann:

  • Bei einem 32-Bit System können 232=4.294.967.295=4Gi Bytes/Speicherstellen adressiert werden
  • Bei einem 16-Bit System können 216=65.536=64Ki Bytes/Speicherstellen adressiert werden
  • Bei einem 64-Bit System können 264=18.446.744.073.709.551.616 =16Ei ~18Exa Bytes/Speicherstellen adressiert werden

Waren die Datenbreite und die Adressierungsbreite bei den ersten Prozessoren noch unterschiedlich, so sind diese heutzutage zumeist identisch. Bei einem 32-Bit Prozessor bedeutet dies, dass dieser max. 4Gi Bytes Speicherstellenstellen adressieren kann und Maschinenbefehle für den Datentransport und die Berechnung von 32-Bit Datenworten beinhaltet.

Die Datenbreite und auch die Adressierungsbereite haben direkten Einfluss auf die Programmiersprache:

  • Wird defaultmäßig mit größerer Datenbreite gerechnet, als der Prozessor 'von Hause' aus unterstützt, so werden die erzeugten Maschinenprogramme größer und langsamer (Umgedreht bedeutet dies jedoch nicht, dass die Maschinenprogramme kleiner und schneller werden)
  • Die max. Größe von Verbunddatentypen und Arrays ergibt sich aus der Adressierungsbreite des Prozessors

Die Intention der Programmiersprache C/C++ ist, die Prozessorarchitektur optimal zu nutzen. Dementsprechend sind diverse Eigenschaften der Sprache nicht fest spezifiziert, sondern von der Rechnerarchitektur abhängig.

Integer Datentyp

Bearbeiten

Intention von C/C++ ist, dass der Umgang mit dem Grunddatentyp 'int' möglichst direkt in einen Maschinensprachebefehl umgesetzt werden kann.
Bei einem 16-Bit Prozessor hat der Datentyp int daher eine Breite von 16-Bit und bei einem 32-Bit Prozessor eine Breite von 32-Bit (siehe Kap Datentypen:Ganzahl Datentypen). Über vorangestellte Qualifier kann dieser Grunddatentyp verkleinert/vergrößert werden.

Integer Arithmetik

Bearbeiten

Ganzzahlen haben nur einen begrenzten Wertebereich. Wird dieser bspw. durch eine Addition überschritten (d.h. das Rechenergebnis benötigt zur Darstellung mehr Stellen, als der Datentyp 'hergibt'), so gibt es zwei Vorgehensweisen:

  • Arithmetischer Überlauf (siehe   arithmetischer Überlauf): Die nicht speicherfähigen Stellen werden verworfen, so dass i.d.R. ein falsches Ergebnis erzeugt wird!
Beispiel:
unsigned char a=254,b=3,c;
c=a+b;   //c ist (254+3)%256=1
c=a*b;   //c ist (254*3)%256=250

Öffnen im Compiler Explorer

  • Sättigungsarithmetik (siehe   Sättigungsarithmetik): Alle Operationen laufen innerhalb eines festen Intervalls ab, so dass Überläufe und Unterläufe auf dieses Intervall begrenzt werden:
Beispiel:
unsigned char a=254,b=3,c;
c=a+b;  //c ist MAX((254+3),255)=255
c=a*b;  //c ist MAX((254*3),255)=255

Die Spezifikation von C macht bezüglich der zu nutzenden Methodik keine Vorgaben, so dass das Handling von der ALU des Prozessors abhängt. Aus Komplexitäts- und Geschwindigkeitsgründen ist dies zumeist der arithmetische Überlauf. Nur bei Spezialprozessoren (z.B. DSP Digital Signal Processor) kommt z.T. die Sättigungsarithmetik zum Einsatz.

Gleitkomma Datentyp und Arithmetik

Bearbeiten

Für den Umgang mit Gleitkommazahlen bieten einige Prozessoren spezielle Recheneinheiten (  Gleitkommaeinheit Floating Point Units FPU) an. Sowohl die Darstellung von Gleitkommazahlen als auch das Verhalten bei Überlauf/Unterlauf ist damit Herstellerabhängig. Um auch diese Einheiten effektiv nutzen zu können, ist die C/C++ Spezifikation in diesem Bereich ebenfalls sehr offen.

Max. Speicherbedarf von Variablen

Bearbeiten

Die maximale Größe von Arrays/Strukturen/Unions ist in C/C++ von der zugrundeliegenden Rechnerarchitektur abhängig:

char a[100000];  //Speicherbedarf=100.000*1Byte=100.000 Bytes
                 //Bei 16-Bit Architektur KO
                 //Ab  32-Bit Architektur OK
struct size_16 {
    char a,b,c,d,e,f,g,h,
         i,j,k,l,m,n,o,p;
};
struct size_256 {
    struct size_16 a,b,c,d,e,f,g,h,
                   i,j,k,l,m,n,o,p;
};
struct size_4096 {
    struct size_256 a,b,c,d,e,f,g,h,
                   i,j,k,l,m,n,o,p;
};
struct size_65536 {
    struct size_4096 a,b,c,d,e,f,g,h,
                     i,j,k,l,m,n,o,p;
};
struct size_65536 var65536; //Speicherbedarf=65536 Bytes
                            //Bei 16-Architektur KO
                            //Ab  32-Architektur OK

Öffnen im Compiler Explorer

Im Falle von globalen Variablen erzeugt der Compiler/Linker eine Fehlermeldung. Werden von diesen Datentypen Variablen als lokale Variablen angelegt, so erzeugt dies zur Laufzeit einen Stackoverflow.

Zeigervariable

Bearbeiten

Eine Zeigervariable ist eine Variable, die eine Speicheradresse speichert. Die Größe dieses Datentyps hängt von der Adressierungsbreite des Prozessors ab. Unabhängig von dem Datentyp, auf den die Zeigervariable zeigt, ergibt sich folgende Breite:

  • 4-Byte bei einer 32-Bit Rechnerarchitektur
  • 8-Byte bei einer 64-Bit Rechnerarchitektur
  • 2-Byte bei einer 16-Bit Rechnerarchitektur

Datentyp size_t

Bearbeiten

Die maximale Größe von Arrays/Strukturen/Unions ist in C von der zugrundeliegenden Rechnerarchitektur abhängig. Sie ergibt sich aus der Adressierungsbreite. Da die Datenbreite des Datentyps int zumeist kleiner als die Adressierungsbreite ist, existiert in C/C++ der vorzeichenlose Datentyp size_t.
Alle Funktionen, welche den Speicherbedarf übergeben bekommen, bzw. zurückgeben (bspw. strlen(), memcpy(), sizeof()) , nutzen den Datentyp size_t:

//Prototypen
size_t strlen(char *str); //Die max. Stringlänge ist durch den 
                          //adressierbaren Speicher vorgegeben
void *malloc(size_t size);
void *memcpy(void *dest, const void *src, size_t n);

Der Datentyp size_t ist in der Header-Datei stddef.h der Standard-C-Library per typedef gesetzt. Im Normalfall muss diese Header-Datei nicht explizit inkludiert werden, da bei Nutzung von z.B. strlen() diese bereits durch die Header-Datei string.h inkludiert wird:

typedef unsigned long long size_t;  //Bei einer 64-Bit Rechnerarchitektur

Hinweis:

  • Der Datentyp size_t ist immer ein vorzeichenloser Datentyp (es gibt keine negativen Speicheradressen).
  • Mit dem Datentype ssize_t wird eine vorzeichenbehaftete Variante zur Verfügung gestellt, welche hilfreich ist, wenn Speicheradresse subtrahiert werden sollen.
  • Soll der Wert vom Datentyp size_t mittels printf() ausgegeben werden, so ist der 'Length Modifier' z und der 'Conversion Specifier' u zu nutzen:
size_t var=strlen("String");
printf("%zu",var);

Öffnen im Compiler Explorer

Boolescher-Datentype/Operatoren

Bearbeiten

Ursprünglich gab es den booleschen Datentyp in der Programmiersprache C nicht. Mit C99 wurde zwar der Datentyp _Bool eingeführt, dies änderte jedoch nicht den Umgang mit Anweisungen und Operatoren, die einen booleschen Datentyp erwarten/zurückgeben!

An allen Stellen, an denen ein boolescher Datentyp erwartet wird (z.B. IF-Abfrage, bei booleschen Operationen) wird der Datentyp Ganzzahl, Gleitkommazahl oder Zeiger erwartet (= Scalar-Type) und ausgewertet. Der hierin gespeicherte Zahlenwert sagt aus, ob diese Zahl als True oder False zu interpretieren ist:

  • False, wenn Ganzzahl gleich 0 oder Gleitkommazahl gleich 0.0 oder Zeiger gleich NULL
  • True, wenn Ganzzahl ungleich 0 oder Gleitkommazahl ungleich 0.0 oder Zeiger ungleich NULL

Beispiel:

int    a=3;
double b=0.0;
int   *ptr=&a;
while(1);
if(a) {}
for(  ; b && ptr ; );

Öffnen im Compiler Explorer

Operatoren, welchen einen booleschen Wert zurückliefern (Vergleichs-Operatoren und Boolesche-Operatoren), liefern ein Integer-Datentyp zurück:

  • (int)0 für False
  • (int)1 für True

Beispiel:

int a=3;
int b= (a==3);  //b=1

Öffnen im Compiler Explorer

In den if()/for()/while() Anweisungen ist folglich kein explizites Konvertieren eines Ganzzahl-/Gleitkomma-/Zeigerwertes mittels des Vergleichs-Operators in einen Booleschen Wert notwendig:

int a;
double b;
if(a)         //entspricht if(a!=0)
if(!b)        //entspricht if(b==0.0)
while(1)      //entspricht while(True)
for(int lauf=1; lauf ; lauf--);

Umgedreht kann ein boolescher Wert oder auch das Ergebnis einer booleschen Operation direkt mit einer Ganzzahl- oder Gleitkommazahl verknüpft werden:

int a=3,b=2;
a=a+1 && 7 << 3  & a==3;
if( a==1  &  b==2)
if(3 < a < 9)    //Vorsicht, die Schreibweise lässt ein
                 //anderes Verhalten erwarten!

Öffnen im Compiler Explorer

Mit C99 wurde der Datentyp _Bool eingeführt. Dieser entspricht weiterhin einem Integerdatentyp mit der Besonderheit, dass beim Zuweisen eines Wertes dieses auf true oder false geprüft wird und Variablen dieses Datentyps nur die Werte 0 oder 1 annehmen können:

_Bool  var1=12;
printf("var1=%d\n",var1);  //→ 1
float  var2=13.2;
var1=var2;
printf("var1=%d\n",var1);  //→ 1
var1--;  //Post/Pre Inkrement/Dekrement in C++ nicht auf Datentyp bool! möglich
printf("var1=%s\n",var1?"true(1)":"false(0)");  //→ false

Öffnen im Compiler Explorer

Für den einfacheren Umgang mit dem 'neuen' Datentyp wurde in der Header-Datei stdbool.h die Makros true (als Austauschwert für die Ganzzahl 1) und false (als Austauschwert für die Ganzahl 0) definiert. Ergänzend wird hier der Datentyp bool als Alias auf _Bool gesetzt.

#include <stdbool.h>
bool foo(int par,int min,int max) {
if((par >= min) && (par <= max))
  return true;
else
  return false;
}

Öffnen im Compiler Explorer

Hinweis:

  • In C++ gibt es anstatt des Datentypes _Bool den Datentyp bool. Auch sind die beiden Konstanten true und false ohne zusätzliches Einbinden der Header-Datei vorhanden. Der weitere Umgang ist jedoch identisch zu C.

Literale/Konstanten

Bearbeiten

Zahlenwerte und Strings im Source-Code werden vom Compiler in binäre Zahlen umgerechnet. Über der Schreibweise/Syntax der Literale/Konstanten wird dem Compiler gesagt, in welchem Zahlenformat die Zahl auf Source-Code Ebene beschrieben ist und welchem Datentyp sie entspricht. Unabhängig wie die Konstanten im Source-Code beschrieben sind, werden die Zahlen intern als binäre Zahlen gespeichert. Beispiele, wie die Zahlen intern gespeichert werden:

int   var=17;   //0000  0000  0000  0000  0000  0000  0001  0001
short var=0x307;//                        0000  0011  0000  0111
char  var=017;  //                                    0000  1111
int   var=-1;   //1111  1111  1111  1111  1111  1111  1111  1111
float var=0.0;  //0000  0000  0000  0000  0000  0000  0000  0000
float var=1.0;  //0011  1111  1000  0000  0000  0000  0000  0000

Ganzzahl-Konstanten

Bearbeiten

Im Allgemeinen ordnet der Compiler einer numerischen, ganzzahligen Konstanten den kleinstmöglichen Datentyp beginnend vom Datentyp integer zu. Sobald der Wert der Konstanten den Wertebereich von integer übersteigt, wird der nächst größere passende Datentyp gewählt. Ergänzend gilt, dass alle Ganzzahl-Konstanten als vorzeichenbehaftete Ganzzahl-Konstanten angesehen werden!
Beispiele für integer Konstanten:

123    
+123
-123          → Formal wird das Minus als Operator angesehen
                und die positive Zahl 123 negiert
0x123         → Konstante ist in hexadezimaler Schreibweise notiert
0123          → Konstante ist in oktaler Schreibweise notiert
0b011         → Konstante ist in binärer Schreibweise notiert
      nicht offizieller C-Syntax, wird aber von vielen Compiler unterstützt
      ab C23-Spezifikation offiziell im C-Syntax enthalten!
6'432'108     → Tausendertrenner 
    Nur in C++ Spezifikation enthalten 
    ab C23-Spezifikation auch im C-Syntax enthalten.
5123123123    → Wertebereich größer als 2^31 so dass automatisch 
                zum nächst größeren Datentyp konvertiert wird

Mit den Suffixen L/l und LL/ll kann der Datentyp long und long long erzwungen werden:

12L           → Konstante wird in eine long Integer Konstante konvertiert
4711LL        → Konstante wird in eine Long Long Konstante konvertiert

Mit dem Suffix U/u kann die Konstante explizit auf unsigned gesetzt werden:

12U           → Konstante wird als vorzeichenlose Zahl gesepeichert
4711ULL       → Konstante wird in eine vorzeichenlose 
                 Long Long Konstante konvertiert

Char-Konstanten

Bearbeiten

In einfachen Anführungsstrichen geschriebene ASCII Zeichen werden entsprechend der    ASCII-Tabelle als Zahlenwert (Datentyp int) dargestellt:

'A' → Integerkonstante 65 
'a' → Integerkonstante 97
…

Über den   Escape-Operator (Zeichen Backslash '\') wird das/die dem ESCAPE Operator folgende Zeichen gesondert interpretiert. Die im Source-Code erhaltene Zeichenfolge wird im Computerprogramm als ein Zahlenwert (Datentyp int) gespeichert. Dies ist u.A. zur Darstellung von ' und " aber auch zur Darstellung der unteren 32 Zeichen der ASCII Tabelle notwendig (Siehe auch   Steuerzeichen)

ESCAPE   Integer-  → Bedeutung / (Terminal)funktionalität
Sequence konstante
'\0'    =  0 → 0 Character = Stringendezeichen
'\a'    =  7 → Bell (erzeugt auf dem Terminal einen Ton)
'\b'    =  8 → Backspace (Bewegt den Cursor um eine Position nach links)
'\t'    =  9 → Horizontal Tab (Bewegt den Curso auf die nächste horizontale
               Tabulatorposition, Abstand ist Terminalspezifisch)
'\n'    = 10 → NewLine (Zeilenvorschub)
'\v'    = 11 → Vertial Tab
'\r'    = 13 → Carriage Return (Wagenrücklauf, Bewegt den Cursor an den 
               Zeilenanfang)
'\"'    = 34 → Double Quote
'\''    = 39 → Single Quote
'\?'    = 63 → Question mark
'\\'    = 92 → Backslash
'\f'    = 12 → FormFeed (Erzeugt auf dem Terminal/Drucker 
                         einen Seitenvorschub)
'\ooo'  = 0ooo → Oktal, wobei o Digits von 0..7 sein müssen
'\xdd'  = 0xdd → Hexadezimal, wobei d Digits von 0..9 A..F sein müssen
'\udddd'       Unicode mit 4 Digits
'\Udddddddd'   Unicode mit 8 Digits

Beispiel

'A' == 65 == '\101' == '\x41'   == '\u0041'  == '\U00000041'

Öffnen im Compiler Explorer

Über Prefixe können alternative Zieldatentypen/Konvertierungen erzwungen werden:

u'b'      → unicode 16    (nur C++)
U'b'      → unicode 32    (nur C++)
L'b'      → wchar_t 

Hinweis

  • Die ASCII-Tabelle basiert auf einer 7-Bit Zeichenkodierung, so dass bspw. die Umlaute 'ä', 'ö' und 'ü' dort nicht kodiert sind. Sollen auch diese Zeichen genutzt werden, so empfiehlt sich der Datentyp wchar_t

Gleitkommakonstanten

Bearbeiten

Alle Gleitkommakonstanten sind ohne weitere Angaben vom Datentyp double!

555.12          
555.        → 555.0
.1          → 0.1
4e2         → 400.0
0x22.11p13  → Konstante in Hexadezimalen Schreibweise (ab C99)

Über Suffixe kann ein alternativer Datentyp erzwungen werden:

3.0F        → Datentyp float
3.0l        → Datentyp long double

Hinweis:

  • Aufgrund der unterschiedlichen Basis zwischen dem menschlichen Zehner-System und dem computerinternen Binär-System entstehen insbesondere bei den Nachkommastellen Rundungsfehler. Die Konstante 0.1f wird durch die Wandlung intern als 1.00000001490116119384765625e-1 dargestellt
  • Die C-Spec verlangt, dass die binäre Repräsentation von Gleitkommazahlen zur Laufzeit (z.B. durch die Funktion atof() strtod() scanf("%f")) identisch zur der zur Compilezeit ist. Beispiel:
if(3.14==atof("3.14"))   printf("muss erfüllt sein");
//3.14 wird vom Compiler in ein Binärwert gewandelt
//Der String "3.14" wird zur Laufzeit mittels atof in ein Binärwert gewandelt
//Beide Wandlungsergebnisse müssen identisch sein

Öffnen im Compiler Explorer

Konstanten Ausdrücke

Bearbeiten

Ergänzend zu den Zahlenwerten werden auch Ausdrücke durch den Compiler ausgewertet. Als Voraussetzung für die Ausdrücke gilt, dass alle Operatoren Literale/Konstante sein müssen, so dass der Compiler die Berechnung durchführen kann. Als Operatoren können alle im normalem Syntax erlaubten Operatoren (z.B.: +,-,*,/,&,&&,|,||,>>,<<,==,!=) genutzt werden.
Beispiel:

int var1=7*3+5;            wird vom Compiler durch 26 ersetzt
int var2=(4710+(8-1))&-8;  wird vom Compiler durch 4712 ersetzt
var1=4*4*var1+77*(77%3);   wird vom Compiler durch 16 * var1 + 154 ersetzt

Öffnen im Compiler Explorer

Hinweis:

  • Konform zur C-Spezifikation bieten einige Compiler weitere Operatoren aus der math.h Library an (z.B. sin(), cos(), ...). Bestehen die Aufrufparameter aus Konstanten, so ersetzt der Compiler den Ausdruck durch die 'Ergebnis'-Konstante:
int var3=sin(1.0)*10;   wird vom Compiler durch 8 ersetzt
  • Konstanten Ausdrücke werden durch den Präprozessor ausgewertet, so dass dieser die Ergebnisse ebenfalls nutzen kann:
#define BUF_SIZE 512
#if (BUF_SIZE & (BUF_SIZE -1)) != 0
#endif

String Konstanten

Bearbeiten

Stringkonstanten erzeugen ein Array von Characters. Mit dem abschließenden Hochkommata wird das Array um ein Stringendezeichen ergänzt:

"abc"        →  (char []) {'a','b','c','\0'}

Adjacent Strings Konstanten werden vom Compiler zu einer String-Kontante zusammengefasst, so dass nur ein Stringendezeichen vorhanden ist:

"xxx" "yyy"  →  (char []) {'x','x','x','y','y','y','\0'}

Über Prefixe können alternative Zieldatentypen/Konvertierungen erzwungen werden:

L"string"    →   (wchar_t[7]){L's',L't',L'r' …)

Über Postfixes können in C++ Objekte erzeugt werden:

"string"s    → erzeugt einen String vom Datentyp std::string
"string"sw   → Erzeugt einen String vom Datentyp std::string_view

String Konstanten werden Compilerintern über unnamed Variablen realisiert, welche als globale Variable angelegt werden und per default const sind:

printf("Wert=%d",4);
//entspricht
const char unnamed1[]="Wert=%d";
printf(unnamed1,4);

//String Konstanten sind per Default Konstant
char *str="hallo";  //Typkonflikt!, da "hallo" vom Datentyp const char[] ist
str[0]='H';         //Laufzeitfehler, da nun schreibender Zugriff auf im 
                    //Readonly Speicher angelegt Variable.

Öffnen im Compiler Explorer

Für doppelte String Konstanten wird nur einmal Speicher reserviert:

const char *str1="hallo";
const char *str2="hallo";
if(str1==str2) printf("Identische Startadressen");

Öffnen im Compiler Explorer

Initialisierungsliste / Compound Literal

Bearbeiten

Arrays, Strukturen und Unions bestehen zumeist aus mehreren Elementen. Zur Initialisierung solcher Variablen werden die Initialisierungswerte in geschweiften Klammern als Initialisierungsliste zusammengefasst.

Syntax: {Initialisierungsliste}

Für eine Initialisierungsliste wird kein Speicher vorgehalten. Vielmehr wird die Variable direkt mit dem Inhalt der Liste 'gefüllt'. Der Datentyp der Initialisierungsliste ergibt sich aus dem Datentyp der Variable:

	int arr[5] = { 1,2,3,4,5 };
	struct xyz { int x,y,z;};  //Definition einer Struktur
	struct xyz var={1,2,3};

Wird der Initialisierungsliste ein Datentyp in runden Klammern vorangestellt, so wird hiermit eine 'unnamend' Variable/Objekt, ein Compound Literal erstellt:

Syntax: (type) {Initialisierungsliste}

Compound Literale können überall dort genutzt werden, wo andernfalls vorab initialisierte Variablen des gleichen Typs eingesetzt würden. Bei einmaligen Gebrauch solcher Variablen erspart man sich hierüber die Definition der Variablen:

int *p=(int []){2,4};
//entspricht
int unnamed0[]={2,4};
int *p=unnamed0;
drawline((struct point){1,2},(struct point){2,3});
drawlinep( &(struct point){1,2},&(struct point){2,3});
//entsprechen
struct point unnamed1={1,2},unnamed2={2,3};
drawline(unnamed1,unnamed2);
drawlinep(&unnamed1,&unnamed2);

Öffnen im Compiler Explorer

Der Gültigkeitsbereich eines Compound Literals entspricht der von Variablen. Wird ein Compound Literal außerhalb eines Block-Sopes angelegt, so entspricht sie einer globalen Variablen, andernfalls einer lokalen Variable.

Hinweis:

  • Compound-Literal sind per Default keine Konstanten, können also zur Laufzeit geändert werden (siehe auch 7.8 Const Type Qualifier):
float *arr=(float []){1.1,2.2,3.3};  //Kein Typkonflikt
arr[0]=47.11;   //Änderung des 'Konstanten' arr[0] ist möglich

Öffnen im Compiler Explorer

Variableninitialisierung

Bearbeiten

Mit der Definition einer Variable kann diese optional initialisiert werden. Der tatsächliche Zeitpunkt der Variableninitialisierung (zur Laufzeit oder zur Compilezeit) und damit die Art des Initialisierungswertes / der Inhalt der Initialisierungsliste hängt von der Gültigkeit/Sichtbarkeit der Variablen ab (siehe Gültigkeit/Sichtbarkeit von Variablen).

Globale + Static lokale Variablen

Bearbeiten

Globale und statisch globale Variablen werden in einem während der Laufzeit des Programms festen Speicherbereich gehalten. Zum Startzeitpunkt des Programms ist der Speicherplatz solch einer Variable mit dem Initialisierungswert gefüllt, so das bei erstmaliger Nutzung dieser Variablen diese ihre Initialisierungswerte enthalten. Die Belegung des Speicherplatzes erfolgt durch den Compiler, so dass als Initialisierungswerte /Inhalt der Initialisierungsliste nur Konstanten oder Konstanten-Ausdrücke (siehe Literale/Konstanten) erlaubt sind:

int g=1;                          //Speicherstelle der Variable g wird 
                                  //mit 1 belegt
short array[3]={1,1+1, sin(0.0)}; //Speicherstellen des Arrays wird
                                  //mit 1, 2 und 0 belegt werden

Öffnen im Compiler Explorer

Nicht initialisierte globale und static lokale Variablen werden mit 0 initialisiert! D.h. die Speicherstellen dieser Variablen werden mit dem Wert 0 vorbelegt:

int f;          //f hat den Wert 0
double arr[10]; //Alle Elemente von arr werden mit 0.0 initialisiert!

Öffnen im Compiler Explorer

Lokale Variable

Bearbeiten

Der Speicherplatz für lokale Variable wird auf dem Stack zur Verfügung gestellt. Bei jeder Ausführung der Definition wird Speicher vom Stack nach dem   LIFO Prinzip reserviert und im Anschluss Maschinencode zur Initialisierung der Speicherstellen ausgeführt (sofern Variable einen Initialisierungswert hat). Zum besseren Verständnis kann man sich die Variableninitialisierung als zwei Anweisungen vorstellen: a) der Definition der Variablen und b) der Zuweisung des Initialisierungswerter zur Variablen:

int var=sin(b);
//entspricht
int var;     //a)Variablendefinition/Speicherplatzreservierung
var=sin(b);  //b) Initialisierung über separate Zuweisungs-Operation
             //d.h. es werden Maschinensprachebefehle erzeugt, welche 
             //den Inhalt der Variablen b holt, anschließend die sin()
             //Funktion aufruft und dessen Rückgabewert der Variablen
             //var zuweist.

Folglich können die Initialisierungswerte/Inhalte der Initialisierungsliste aus beliebigen Ausdrücken stehen und müssen keine Konstanten oder Konstanten Ausdrücke sein:

void foo(float f,float g) {
  float summe=f+g;
  float arr[3] = {f+f,g-f,sin(f)}; //Initialisierungswerte 
                                   //werden zur Laufzeit berechnet
}

Öffnen im Compiler Explorer

Aufgrund der Ausführung von Maschinencode bedingt die Initialisierung Rechenzeit.
Wird eine Variableninitialisierung mit einer switch oder goto Anweisung übersprungen, so wird zwar die Variable angelegt (Speicherplatz reserviert), jedoch die Maschinenbefehle zur Initialisierung dieser Variablen nicht ausgeführt (siehe auch Switch-Anweisung und Label + Goto). Die Variable enthält dann einen Zufallswert:

switch(value) {
  case 0:
    int lok=7;
    printf("%d",lok);  //OK, lok beinhaltet den Wert 7
    break;
default:
    printf("%d",lok);  //KO, Variable lok vorhanden, 
                       //aber nicht initialisiert. D.h. Ausgabe einer
    break;             //Zufallszahl
}

Öffnen im Compiler Explorer

Nicht initialisierte lokale Variablen werden nicht initialisiert, d.h. keine zusätzlichen Maschinenbefehle zum Initialisieren erzeugt. Der Variablenwert entspricht dann einem 'Zufallswert' oder korrekter gesagt dem Inhalt der Variablen, welcher zuvor der Speicherstelle zugewiesen war:

int foo(void) {
  int var_foo;    //Variable var_foo bekommt den identischen
  return var_foo; //Speicherbereich wie var_far zugwiesen
}
int far(void) {
  int var_far=7;
  return var_far;
}
printf("%d\n",foo());   //Zufallswert (0, da Stack mit 0 initialisiert ist)
printf("%d\n",far());   //Stack belegen
printf("%d\n",foo());   //Zufallswert (7, da Speicherstelle zuvor mit 7 belegt wurde

Öffnen im Compiler Explorer

Hinweis:

  • Bei erstmaligem Funktionsaufruf kann eine nicht initialisierten Variablen den Wert 0 beinhalten (Stackframe wird mit Programmstart auf 0 gesetzt). Beim nächsten Aufruf der Funktion ist zumeist die Speicherstelle der lokalen Variablen mit anderen Werten gefüllt worden, so dass nun ein anderer 'Zufallswert' als 'Initialisierungswert' enthalten ist.

In C++ ist ergänzend die Initialisierung einer Variablen über () möglich. Bei Objekten wird hierüber der zugehörige Konstruktor aufgerufen. Bei primitiven Datentypen entspricht der Wert in den Klammern dem Initialisierungswert:

int variable(10);       //Bei primitiven Datentype entspricht dies 
                        //int variable=10;
struct test {
  int a;
  test(int par) {a=par;}  //Konstruktor der Struktur test
};
struct test test(4);   //Konstruktor test(int par) wird aufgerufen
struct test test(4,5); //KO, kein passender Konstruktor vorhanden

Sonstiges

Bearbeiten

Folgende weitere Sachverhalten gilt es bzgl. Variableninitialisierung zu berücksichtigen:

  • Nach C-Spezifikation ist eine Initialisierung von Variablen nicht notwendig. Es liegt folglich kein Fehler vor, wenn auf eine nicht initialisierte Variable lesend zugegriffen wird. Ungeachtet der C-Spezifikation überprüft der Compiler mit dem Schalter '-Wall' diesen Sachverhalt und gibt eine Warning aus.
  • Deklarationen/Prototypen können keinen Initialisierungswert enthalten
extern int var=7;    //Fehlerhaft
  • Const und lokale Static Variablen sollten initialisiert werden, auch wenn C in diesem Fall keinen Fehler meldet!
const int konstante;  //Syntaktisch OK, Initialisierungswert fraglich!
static int statisch;

Anweisung (Statement) & Expression

Bearbeiten

Eine Anweisung (Statement) stellt entsprechend dem Wikipedia Artikel    Anweisung "ein in der Syntax einer Programmiersprache formulierte einzelne Vorschrift dar, die im Rahmen der Abarbeitung des Programms auszuführen ist". Entsprechend der C-Spezifikation [C11 6.8] kann eine Anweisung aus:

labled-statement: Eine Sprungmarke für die Switch- und die Goto-Anweisung
compound-statement: siehe nachfolgendes Kapitel
expression-statement: siehe nachfolgendes Kapitel
selection-statement: Eine if() oder switch() Anweisung
iteration-statement: Eine while(), do while() oder eine for() Anweisung
jump-statement: Eine goto, break, continue oder return Anweisung

Die einzelnen Statements können dabei aus weiteren Statements bestehen.

Ausdruck (Expression)

Bearbeiten

Entsprechend der C-Spezifikation [C11 6.5] ist ein Ausdruck eine Folge von Operatoren und Operanden, die die Berechnung eines Werts angibt, ein Objekt oder eine Funktion bezeichnet, Nebenwirkungen erzeugt oder eine Kombination davon ausführt. Die Wertberechnungen der Operanden eines Operators werden vor der Wertberechnung des Ergebnisses des Operators sequenziert (siehe auch   Expression).

Beispiele:

a=sqrt(a*a+b*b);
//Ausdruck mit Nebenwirkungen
int func(int par) { a++; return par*2;}
b=func(1)*func(2); 
//Ausdruck mit undefinierten Verhalten
a=++a + 1;

a=for(lauf=10;lauf>0;lauf--);  //for-Anweisung ist kein Ausdruck
                               //d.h. liefert keinen Wert zurück

Öffnen im Compiler Explorer

Discarded Value

Bearbeiten

Discarded Value sind Ausdrücke, dessen Ergebnisse nicht verwendet werden (also keiner Variablen zugewiesen werden). Ergebnisse von Ausdrücken, die nicht verwendet werden, führen nicht zu einer Fehlermeldung:

int a=7;
a;          //Variable a wird gelesen, der Wert jedoch verworfen
7;          //Konstante wird verworfen
a+7;        //Ergebnis wird Berechnet und verworfen
printf("hallo"); //Printf hat einen Rückgabewert, dessen Wert verworfen wird

Öffnen im Compiler Explorer

Hinweise:

  • Soll bewusst der Rückgabewert verworfen werden, so kann/sollte mit dem explizten Cast auf (void) der Rückgabewert explizit 'verworfen' werden. Dies ist u.A. hilfreich bei Variablen, die (noch) nicht benutzt werden und der Compiler die Warning "unused Variable" ausgibt:
(void)a;        //Zur Vermeidung von Compiler-Warnings "Unused Variable" 
(void)(a+7);    //hier eine explizte 'Ansage' der Verwerfung des Wertes
(void)func();

Öffnen im Compiler Explorer

  • Mit der Anweisung [[nodiscard]] (ab C++17, ab C23) wird der Compiler angewiesen, dass der Aufrufer einer Funktion mit Rückgabewert der Rückgabewert entgegenzunehmen ist. Wird der Rückgabewert verworfen, so gibt der Compiler eine Fehlermeldung aus:
	[[nodiscard]] int  Client(int socket);
	Client(5);     ////Fehlermeldung, da Rückgabewert nicht verwendet wird

Öffnen im Compiler Explorer

  • Java kennt Dicarded Values nicht. Ausnahme, es handelt sich um einen Funktionsaufruf, dessen Rückgabewert nicht genutzt wird.

Sequence Point

Bearbeiten

Ein Sequenzpunkt (u.A. das abschließende Semikolon am Ende der Anweisung) ist nach dem Wikipedia Artikel   Sequence Point definiert als der Punkt in der Ausführung eines Computerprogramms, an dem garantiert ist, dass alle Nebenwirkungen früherer Bewertungen durchgeführt wurden und noch keine Nebenwirkungen von nachfolgenden Bewertungen durchgeführt wurden.
Sequenzpunkte sind beispielsweise dann von Interesse, wenn dieselbe Variable innerhalb eines einzelnen Ausdrucks mehr als einmal geändert wird und die Ausführungsreihenfolge der Operationen nicht sauber spezifiziert ist.
Ein oft zitiertes Beispiel ist der C-Ausdruck 'i=i++;'. Der Sequence Point garantiert, dass alle Operationen (hier Zuweisung und Postinkrement) ausgeführt sind. Jedoch ist die Ausführungsreihenfolge dieser beiden Operationen nicht spezifiziert. In Abhängigkeit des Zeitpunktes der Ausführung des Postinkrementes (vor oder nach der Zuweisung) kann i unterschiedliche Werte annehmen. In C und C++ führt die Auswertung eines solchen Ausdrucks zu undefiniertem Verhalten. Andere Sprachen, wie z. B. C#, definieren den Vorrang des Zuweisungs- und des Inkrementoperators so, dass das Ergebnis des Ausdrucks i=i++ garantiert ist.
Öffnen im Compiler Explorer

Weitere Sequence Points:

  • Beim ?-Operator stellt sowohl das ? als auch der : ein Sequenz Point dar. Zunächst muss der linke Ausdruck vollständig bearbeitet werden, bevor einer der beiden Bedingungen bearbeitet wird
  • Bei der logischen UND / ODER Funktion stellen der Operator ein Sequenz Point dar. Erst wird der linke Operand vollständig ausgeführt und ausgewertet, bevor (ggf.) der rechte Operand ausgewertet wird (siehe Logische Verknüpfungen)
  • Beim Komma-Operator stellen der Komma ein Sequence-Point dar (siehe Komma-Operator)

• und weitere (siehe Appendix der C-Spec)

Operationen, bei denen die Ausführungsreihenfolge nicht spezifiziert ist:

  • Bei Funktionsaufrufen ist die Abarbeitungsreihenfolge der Parameter nicht spezifiziert, so dass bspw.:
//Aufrufruf einer Funktion, deren Parameter sich aus Funktionsaufrufen
//ergeben
(*pf[f1()]) (f2(), f3() + f4());   //pf -> Array von Funktionszeigern
//Die Funktionen f1,f2,f3 und f4 können in einer beliebigen Reihenfolge
//aufgerufen werden

Öffnen im Compiler Explorer

  • Per Postinkrement/-dekrement Operator sagt aus, dass für die eigentliche Operation der Wert der Variablen genutzt wird und im Anschluss die Variable erhöht / erniedrigt werden soll. Nach C-Spezifikation [C11 6.5.2.4] muss dies spätestens vor dem nächsten Sequence Point erfolgt sein. Der genaue Zeitpunkt innerhalb des Sequence Points ist jedoch nicht vorgegeben:
int a=7;
int b=a++ + a;   //Ergebnis Abhängig, ob das Postinkrement vor der Addition
                 //oder als letztes ausgeführt wird.
a=a++ + 1;      //dito

Öffnen im Compiler Explorer

Block / Compound-Statement

Bearbeiten

Ein Block oder Compound-Statement fasst Vereinbarungen und Anweisungen zu 'einer' Anweisung/Statement zusammen. Es kann überall dort eingesetzt werden, wo nur eine Anweisung erlaubt ist.

Syntax: {Declaration-or-statement-list/textsuperscriptOPT}

Ein Block wird nicht wie bei einer Anweisung mit einem Semikolon abgeschlossen (Das angehängte Semikolon wäre dann eine separate leere Anweisung!).
Ein Block hat blockweite Sichtbarkeit (siehe Blockweite Sichtbarkeit), so dass hier definierte Variablen nur hier gültig sind. Deklarationen sind ebenfalls nur in dem Block gültig.

for(..) Anweisung;   //For-Schleife führt nur diese eine Anweisung aus
for() {Block}       //For-Schleife führt Block (als eine Anweisung) aus

if(..) Anweisung; else Anweisung;  //IF-Anweisung führt nur 'eine'
                                   //Anweisung aus
if(..) {Block} else {Block}        //IF-Anweisung führt Block aus
if(  ) {Block}; else {Block};      //Semikolon ist eine separate
                                   //Leer-Anweisung, so dass else
                                   //nicht zur IF-Anweisung zugeordnet
                                   //werden kann (2 Anweisungen vor Else)
void foo(void) {
  int var1; 	
  {  //Block innerhalb von Block als Strukturierungswerkzeug
    int var2;
    struct xyz{int x,y,z};  //Defintion eines Datentyps im Block
    struct xyz var3;
  }
  struct xyz var4; //KO, Datentyp hier nicht mehr bekannt
}

Die GNU-C Reference (nicht C++) erlaubt es, Funktionen innerhalb von Blöcken (und damit auch innerhalb von Funktionen) zu definieren. Diese innere Funktion steht dann nur in diesem Block zur Verfügung und kann ergänzend auf Variablen des umgebenden Blockes zugreifen:

void foo(void) {
  {
    int label=1;
    //Funktion innerhalb einer Funktion
    void dummy(void){
        label++;  //Zugriff auf Variablen des umschließenden Blockes
    }
    dummy();      //Aufruf der nur in diesem Block gültigen Funktion
  }
  dummy();        //KO, Funktion hier nicht mehr gültig
}

Öffnen im Compiler Explorer

Hinweise:

  • Blöcke können/sollten als Strukturierungswerk genutzt werden. Dem Leser von Source Code kann auf dieser Weise vermittelt werden, dass diese Anweisungen 'zusammen' zu lesen sind.
  • Im Gegensatz zu Java, in welchem innerhalb eines Blockes kein Variablenname genutzt werden darf, die außerhalb des Blockes existiert, ist dies in C erlaubt. Die äußere Variable steht damit innerhalb des Blockes nicht mehr im Zugriff.
int a=3;
int main(void){
  int a=7;  //Globale Variable ist ab hier nicht mehr sichtbar/zugreifbar
  {
    int a=9;   //Innere Variable überdeckt äußere Variable
  }
}

Öffnen im Compiler Explorer

Embedded Statement

Bearbeiten

Embedded Statements sind eine GNU-C Erweiterung (siehe GNU-C-Manual 3.18). Da sie vom Autor als sehr nützlich angesehen wird und auch von diversen Compilern und auch in C++ unterstützt wird, wird diese hier beschrieben.

Ein Block fasst mehrere Anweisungen zu einer Anweisung zusammen, hat jedoch kein Rückgabewert. Ein Embedded-Statement entspricht einem Block mit der Ergänzung, dass dieser einen Rückgabewert hat, so dass ein Embedded-Statement überall dort eingesetzt werden kann, wo eine Expression erwartet wird.

Syntax: ({Declaration-or-statement-listOPT})

Der Rückgabewert wird nicht über eine return-Anweisung angegeben, sondern entstammt aus der letzten Anweisung innerhalb der Liste:

int var;
var={int par=7;}                 //KO Block hat keine Rückgabewert.
var=({int par=7; par++; par;});  //OK Embedded-Statement hat einen
                                 //Rückgabewert, der sich aus der
                                 //letzten Anweisung 'Par;' ergibt
                                 //Hier 8.

Öffnen im Compiler Explorer

Ein Embedded-Statement ähnelt folglich einem Funktionsaufruf mit dem Unterschied, dass a) in der Tat kein Funktionsaufruf stattfindet und b) der Rückgabewert nicht mit return, sondern über die letzte Anweisung zurückgegeben wird. Der Anwendungsfall ist dort zu finden, wo eine Expression erwartet wird und mehrere Anweisungen zum Ermitteln der Expression notwendig sind:

int var1=8;
int b=({int d=var1++;d>10?10:d;});
if(  ({int d=b;b=var1;b=d;}) > 1)

Öffnen im Compiler Explorer

Hinweise:

  • Ein Embedded-Statement ist insbesondere bei Nutzung von Makros (siehe Kap Präprozessor) hilfreich
  • Mittels eines Embedded-Statement kann eine anonyme Funktion/Lambdafunktion dargestellt werden:
void test(int (*fptr)(int),int arr[10]) {
  for(int lauf=0;lauf<10;lauf++)
    arr[lauf]=fptr(arr[lauf]);
}
int main(int argc, char *argv[]) {
  int arr[]={1,2,3,4,5,6,7,8,9,10};
  //Aufruf mit einer anonymen Funktion
  test( ({int func(int a) {return a%2?a:0;} ; func;})
         ,arr);
}

Öffnen im Compiler Explorer

Funktionen / Prozeduren

Bearbeiten

Funktionen/Prozeduren sind ein Strukturierungsmerkmal, so dass Teile der Funktionalität des Programmes wiederverwendbar sind. C/C++ unterscheidet nicht zwischen Funktionen (haben einen Rückgabewert, können damit in Ausdrücken verwendet werden) und Prozeduren (haben keine Rückgabewert, resp. Rückgabetyp void).
Mit jedem Aufruf einer Funktion wird für den zugehörigen Block ein eigener Stack-Kontext angelegt, so dass Funktionen sich selbst aufrufen können (Rekursion). Jeder Funktionsinstanz hat somit seinen eigenen lokalen Variablenbereich, teilen sich jedoch die globalen und die statisch lokalen Variablen.
Mit dem Aufruf einer Funktion können Parameter übergeben werden, wobei die Übergabeparameter vom Aufrufer (Caller) durch den Aufruf in die lokalen Variablen des Aufgerufenen (Callee) kopiert/zugewiesen werden (Call by Value). Wird ein Zeiger übergeben, hat der Callee über die Dereferenzierung des Zeigers die Möglichkeit, die Variablen des Caller zu verändern (Call by Reference) (siehe Kap. Zeiger:Zeiger bei Funktionsaufrufen).).

Hinweise:

  • Die Klammerung hinter dem Funktionsnamen (foo()) zeigt an, dass es sich um einen Funktionsaufruf (Verzweigung der Programmausführung zu der Startadresse der Funktion) handelt
void foo(void) {printf("foo called");};
foo();  //Klammern hinter dem Funktionsnamen bedeuten Aufruf der Funktion
  • Die Nutzung des Funktionsnamens ohne Klammerung (foo) gibt die Startadresse der Funktion zurück. Die Startadresse kann in einem Zeiger auf Funktionen gespeichert werden (siehe Kap. Zeiger:Zeiger auf Funktionen).
void foo(void) {printf("foo called");};
void (*fptr)(void)=foo;

Öffnen im Compiler Explorer

  • Deklaration/Definition vor dem ersten Aufruf der Funktion 'notwendig', damit Compiler die Übergabeparameter entsprechend bereitstellt und ggf. eine implizite Typkonvertierung durchführt (dito Rückgabewert). Die Angabe der Variablennamen können bei der Deklaration entfallen
  • Die fehlende Angabe der Übergabeparameter bedeutet in C (nicht C++), dass diese Funktion mit beliebigen Übergabeparametern aufgerufen werden kann und der Compiler keine Typprüfung durchführt:
int func() {...}
//kann wie folgt aufgerufen werden
func();
func(4);
func("hallo Welt",4.7);

Öffnen im Compiler Explorer

→ unbedingt vermeiden
Hat die Funktion keinen Übergabewert, so wird dies in C mit void gekennzeichnet
  • In C++ bedeutet die Schreibweise ohne Datentypbeschreibung, dass keine Parameter erwartet werden
  • In der Parameterliste von Funktionen dient in der GNU-C Spezifikation das Semikolon zum Trennen zwischen Deklaration von Übergabeparameter und den Übergabeparametern. Alle Übergabewerte links vom Semikolon sind (Forward)deklarationen:
void test1(int a; int b,int a) {
//      Forward;  Parameter
  printf("a=%d b=%d\n",a,b);
}
//Aufruf
test1(3,7);    //a=7 b=3
	
//Sinnvoll im Umgang mit Variable Length Arrays
void test2(int dim; const char str[dim], int dim);
//Aufruf
test2("hallo",6);

Öffnen im Compiler Explorer

Return-Anweisung

Bearbeiten

Mit der Return-Anweisung wird eine Funktion (vorzeitig) beendet. Der optionale Parameter dient der Angabe des Rückgabewertes der Funktion an den Aufrufer. Der Datentyp des Parameters ergibt sich aus dem Rückgabedatentyp der Funktion.

Syntax: return expressionOPT

Der Rückgabewert expresion sagt aus, dass hier nicht nur Konstanten oder Variablen stehen können, sondern beliebige Ausdrücke erlaubt sind. Der resultierende Datentyp des Ausdruckes muss letztendlich dem Rückgabedatentyp der Funktion entsprechen:

int foo(double a, double b) {
  return (int)(a*a+b*b);
}

Hinweise:

  • Bei nicht void-Funktionen ist eine return-Anweisung mit einer Expression entsprechend dem Datentyp der Funktion zwingend notwendig:
int func(void) {
  return 5.1;     //Datentyp wird zuvor int konvertiert
}
double a=func();  //int wird in double konvertiert

Öffnen im Compiler Explorer

  • Bei einer void-Funktion ist die return-Anweisung ohne Expression optional. Hier entspricht der schließende Block der Return-Anweisung
void func(char *str) {
  if(str==NULL)
    return;
  printf("%s",str);
}

Sollen mehr als ein Rückgabewert an den Aufrufer zurückgegeben werden so empfiehlt sich:

Rückgabewert aus Datentypsicht

Bearbeiten

Aus Datentypsicht kann der Funktionsname inkl. den Parameter(n) in einem Ausdruck mit dem Datentyp des Rückgabewertes der Funktion ausgetauscht werden:

int foo(void) {return 4711;}
int var;
var=1+foo();  //Aus Datentypsicht (int)=(int)+(int)

Daraus ergibt sich, dass bei Arrays/Strukturen/Unions direkt auf einzelne Komponenten zugegriffen werden kann, ohne diese zuvor einer Variablen zuzuweisen:

struct xyz{int x,y,z;};
struct xyz foo(int par) {
  struct xyz var;
  var.x=var.y=var.z=par;
  return var;
}
if(foo(12).x == 12) printf("Identisch\n");

Öffnen im Compiler Explorer

Fehlerrückgabe

Bearbeiten

Viele Funktionen können unter gewissen Umständen nicht korrekt ausgeführt werden. Z.B. aufgrund dessen, dass:

  • der benötigter Speicher vom Heap nicht bereitgestellt werden kann
  • Übergabeparameter außerhalb des zulässigen Bereiches liegen
  • angeforderte Ressource (Datei, Geräte, …) nicht vorhanden/verfügbar sind

Solche Fälle gilt es dem Aufrufer mitzuteilen, da in diesen Fällen die vom Aufrufer geforderte Funktionalität nicht erfüllt ist und der Aufrufer ggf. nicht weiterarbeiten kann. Zur Mitteilung des Fehlers sollte vorrangig der Rückgabewerte genutzt werden. (auch wenn in C++ mit Exception eine Alternative hierzu bereitsteht)

char *strdup(char *src) {
  char *dst;
  if((dst=malloc(strlen(src)+1))==NULL)
    return NULL;
  strcpy(dst,src);
  return dst;
}

Öffnen im Compiler Explorer

Die Standard-C Library-Funktionen geben in diesem Fall zumeist ein -1 (Rückgabewert Ganzzahl) oder einen NULL-Zeiger (Rückgabewert Zeiger) zurück. Der genaue Fehlergrund ist dann ergänzend in der globalen Variablen errno gespeichert. Mittels der Standard-C Funktion strerror() kann die Zahl in einen für den Anwender/Programmierer lesbaren String umgewandelt werden (die GNU-C Library Funktion printf() bietet ergänzend den Conversion Spezifier %m als alternative Ausgabemöglichkeit an):

#include <errno.h>
int var;
if(-1==scanf("%d",&var) )  printf("Es wurde keine Zahl erkannt\n");
int ret=open("/etc/ptmp",O_WRONLY|O_CREAT|O_EXCL,
                         S_IRUSR|S_IWUSR|S_IRGRP|S_IROTH);
if(-1==ret)  
  printf("open failed() with %s\n",strerror(errno));
if(-1==ret)
  printf("open failed() with %m (nur glibc)\n");

Öffnen im Compiler Explorer

Für eine sauber Programmierung sollte diese Methodik generell angewendet werden. Jede Funktion, welche Fehlschlagen kann, teilt dies per Rückgabewert dem Aufrufer mit. Der Rückgabewert einer jeden Funktion wird vom Aufrufer kontrolliert.

Main-Funktion

Bearbeiten

Mit Start der Anwendung übergibt das Betriebssystem die Kontrolle an die Anwendung und führt dort die Funktion main aus. Als Übergabe- und Rückgabeparameter sind folgende Varianten erlaubt:

int main(void) {  }
int main(int argc, char *argv[]) {  }

Mit dem Start der Anwendung kann, wie bei jedem Funktionsaufruf, Parameter an den startenden Prozess übergeben werden. Als Parameter können Strings übergeben werden, welche in Form eines Arrays von Strings übergeben werden. Im Parameter argc sind die Anzahl der Strings enthalten. Im Parameter argv[x] sind die einzelnen Strings enthalten. Sofern vorhanden ist im ersten String der Programmname/Aufrufname der Anwendung enthalten:

int main(int argc, char *argv[]) {
  //Variante 1: Darstellung des Inhaltes des Arrays von Strings
  for(int lauf=0;lauf < argc; lauf++)
    printf("%d.Parameter: '%s'\n",lauf,argv[lauf]);
  //Variante 2: Darstellung des Inhaltes des Arrays von Strings
  (void)argv;  //Wird hier nicht benötigt
  for(char **str=argv;*str!=NULL;str++)
    printf("Parameter: '%s'\n",*str);
  return EXIT_SUCCESS;
}

Öffnen im Compiler Explorer

Hinweis:

  • Im Compiler-Explorer können die Parameter im Executor unter 'Execution Arguments', getrennt mit Leerzeichen eingegeben werden.

Wird die Funktion main beendet, entweder über die return Anweisung oder den Aufruf der exit() Funktion, wird die Kontrolle zurück an den startenden Prozess (zumeist Shell) gegeben. Über den Rückgabewert wird im Allgemeinen dem Aufrufer der Fehlerstatus mitgeteilt (EXIT_SUCCESS oder 0 bedeutet fehlerfreie , EXIT_FAILURE oder ungleich 0 fehlerhafte Ausführung). Der Start der Anwendung kann als Funktionsaufruf angesehen werden, welchen einen Wert an den Aufrufer zurückliefert.

Hinweise:

  • Der Return-Wert der main-Funktion kann mit >>echo $? in der Bash abgefragt werden
  • Mittels exit(int status) kann das Programm an jeder Stelle innerhalb des Programms beendet werden. status entspricht dann der expression der return Anweisung von main

Inline Funktion

Bearbeiten

Wie der Name inline andeutet, ist die Intention von Inline, dass die aufgerufen Funktion Bestandteil der aufrufenden Funktion ist. Die aufgerufene Funktion soll somit im Kontext des Aufrufers ausgeführt werden, so dass kein eigener Stack bereitgestellt werden muss. Der Start der Funktion kann somit schneller erfolgen:

inline int foo(void) { }

Das Inline erfolgt durch den Compiler (und nicht durch den Linker). Damit das inline funktioniert, muss die zu inlinende Funktion vor dem Aufruf definiert sein, d.h. sie muss in derselben C-Datei enthalten sein [C11 6.7.4].

Hinweis:

  • Das 'inlinen' von Funktionen über die eigene Objektdatei hinweg erfolgt in C/C++ über Link Time Optimization (LTO). Hierzu fügt der Compiler den Zwischencode mit in die Objektdatei, so dass der Linker das 'inlinen' vollziehen kann (siehe    Interprocedural Optimization)


Variadic Function / Ellipsis Punctuator

Bearbeiten

Mit dem Ellipsis Puncturator '…' kann in Funktionsdefinitionen angegebenen werden, dass anstatt der … Anweisung, beliebig viele Parameter/Argumente übergeben werden können. Dies wird unter anderen in der printf() Anweisung genutzt:

int printf(const char *fmt, ... );

Die wie folgt aufgerufen werden kann:

printf("Hello World");   //es folgt kein Parameter
printf("%d",100);        //es folgt ein Parameter vom Datentyp int
printf("%f",1.1);        //es folgt ein Parameter vom Datentyp double
printf(conststr,1,2,3,"xyz")  //Ohne Worte

Öffnen im Compiler Explorer

Da der Compiler für die optionalen Parameter nicht den Zieldatentyp kennt, erlaubt die C-Spezifikation als Übergabedatentypen hier nur die Grunddatentypen signed int, unsigned int, long, long long, double und Zeiger. Entspricht der übergebene Parameter nicht einen dieser Datentypen, so wird er mittels implizit Cast entsprechend nachfolgender Tabelle gewandelt [C11 6.5.2.2 Default Argument Promotions]:

Ausgangstyp Umwandlungstyp
char, signed char, short int
unsigned char, unsigned short unsigned int
float double
Alle anderen Datentypen bleiben erhalten

Bspw.:

signed char varc=-1;
float  varf     =0.1;
printf("%d %f",varc, varf);  //varc wird beim Aufruf nach int konvertiert!
                             //varf wird beim Aufruf nach double konvertiert!

Öffnen im Compiler Explorer

Zum Umgang mit dem variadischen Parameter stellt C die Funktionen va_start(), va_arg(), va_copy() und va_end() bereit. Auf Grundlage des dazugehörigen Datentyps va_list können variadische Parameter auch an Unterfunktionen weitergereicht werden (bspw vprintf()). Dieses Thema bedingt jedoch genaue Kenntnisse vom Umgang mit dem Stack und ist nicht Bestandteil dieser Vorlesung.

Variable Length Array (VLA)

Bearbeiten

Im Normalfall ergibt sich die Anzahl der Elemente eines Arrays aus einer Konstanten, so dass der Compiler zur Compilezeit passend Speicher reserviert (siehe Kap. Array):

	int arr[4];    //Compiler reserviert für Variable arr 
	               //4*sizeof(int) Speicherplatz

Bei Variable Lenght Array wird die Arraydimension nicht über eine Konstante, sondern über eine Variable oder einen Ausdruck angegeben. D.h. die tatsächliche Dimension des Arrays ergibt sich erst zum Ausführungszeitpunkt der Definition:

void foo(size_t n) {
  int arr[n*2+1];   //Arraydimension ergibt sich zum Ausführungszeitpunkt
  n++;              //Nachträgliches Ändern von n ändert nicht 
                    //die Größe des Arrays!
}

Ein Variable Length Array kann nur als lokale Variable oder als Übergabeparameter definiert werden (wobei im letzteren Fall kein Speicher hiermit reserviert wird). Eine Initialisierung eines VLA's mit der Definition ist nicht möglich, d.h. nach der Definition beinhaltet das Array 'zufällige' Werte. Es empfiehlt sich daher, diese bspw. mit memset händisch zu initialisieren (siehe auch Kap Array:Initialisierung):

void foo(size_t n) {
  char str[n];
  memset(str,0,n*sizeof(char));  //Array mit 0 füllen

Öffnen im Compiler Explorer

Mittels sizeof kann weiterhin der Speicherbedarf ermittelt werden, wobei sizeof mit einem VLA als Argument kein Konstantenausdruck ist. Stattdessen wird die Größe zur Laufzeit ermittelt:

void foo(size_t n) {
  int arr[n*2+1];    //VLA
  size_t size;
  size=sizeof(arr);  //sizeof(arr) entspricht einem Funktionsaufruf
                     //welcher die Speichergröße des VLA's ermittelt

Öffnen im Compiler Explorer

VLA wurden erstmals in C mit C99 eingeführt. In C11 wurden VLA's als optional eingestuft und mit C23 zurück auf notwendig gesetzt. In C++ sind VLA's nicht definiert, werden jedoch vom GCC Compiler unterstützt. Als alternativen Datentyp wird hier std::array empfohlen.

Hinweis:

  • VLA sind nicht direkt in einem Switch-Case Block definierbar. Wird hier ein VLA benötigt, so muss hierzu ein Block erzeugt werden:
switch(value) {
  case 1:
    char arr1[value];  //Compilerfehler, da bei value!=1 kein Speicherplatz
                       //für das VLA reserviert wird
    break;
  case 2: {
      char arr2[value];   //OK
    }
    break;
}

Öffnen im Compiler Explorer

Operatoren

Bearbeiten

In der Programmiersprache C sind folgende Punctuators [C11 6.4.6] definiert:

	[ ]  ( )  { }  .  ->
	++   --    &   *  +   -   ~   !
	/     %   <<  >>  <   >  <=   >=  ==  !=  ^  |  &&  ||
	?     :    ;  ...
	=          *=       /=       %=   +=     -=     <<=     >>=    &=    ^=    |= 	,     #   ##
	<:   :>   <%   %>   %:   %:%:

Die Funktionalitäten sind identisch zu Java. Aufgrund des Datentyps Zeiger gibt es in C ergänzend den Punctuator -> (siehe Kap Datentypen:Struktur/Verbundtyp). Der Präprozessor bedingt die beiden Punctuatoren # und ## (siehe Kap Präprozessor). Die letzten 6 Punctuators <: :> <% %> %: %:%: werden zu [ ] { } # ## ersetzt. Sie sind für Computersysteme gedacht, welche keine ALTGR-Taste besitzen.
Java kennt ergänzend den >>> Operatoren, welcher in C über den normalen Schiebebefehl und den zugrundeliegenden Datentyp gesteuert wird.

Wie in Java sind einige Punctuators vom Kontext abhängig und haben damit unterschiedliche Funktionalitäten:

	++   → (Postfix) ++       vs.    ++(Präfix)
	--   → (Postfix) --       vs.    --(Präfix)
	+/-  → Vorzeichen         vs. Addition/Subtraktion
	()   → Funktionsaufruf()  vs. Befehlsbestandteil if()  vs. Priorisierung

Für den Umgang mit dem Datentyp Zeiger besitzen einige Punctuatoren ergänzende Funktionalitäten (siehe Kap Zeiger):

	& →  Bitweises UND: 
         Anwendung: Ganzahlausdruck & Ganzahlausdruck  
	& →  Adressoperator:   
	     Anwendung: &Variable
	* →  Multiplikation
	     Anwendung: Ganzahl-/Gleitkommaausdruck * Ganzahl-/Gleitkommaausdruck 
	* →  Datentyp Zeiger
	     Anwendung: Datentyp * Variablenname;
	* →  Dereferenzierung: 
	     Anwendung: * Zeiger

Punctuatoren, welche von Java abweichende / ergänzende Funktionalitäten haben, werden nachfolgend gesondert beschrieben.

Sind in einer Anweisung mehrere Operatoren enthalten, so hängt die Abarbeitungsreihenfolge von der Rangfolge/Priorität der Operatoren ab (siehe C-Programmierung:Liste der Operatoren nach Priorität).
Die Rangfolge ist identisch zu Java, wobei C mehr Operatoren kennt. Generell empfiehlt sich, lieber eine Klammer zu viel als zu wenig zusetzen. Klammern haben die höchste Priorität.

Zuweisungs-Operator

Bearbeiten

Die Zuweisung (=-Operator, assignment expression) weist dem L-Value den R-Value zu. Ergänzend dient der L-Value als Rückgabewert des Operators, so dass das Ergebnis der Zuweisung weiter genutzt werden kann. Die Abarbeitungsreihenfolge ist von rechts nach links (R-L Assoziativität). Wird der 'letzte' Rückgabewert nicht verwendet, so wird dieser verworfen (siehe Kap. Discarded Value).
Beispiel:

a=7;        //Zuweisung
a=b=7;      //Erste Zuweisung (b=7) hat einen Rückgabewert, welcher im
            //Anschluss a zugewiesen wird
if(a=7)     //Variablen a wird der Wert 7 zugewiesen. Der Rückgabewert
            //der Zuweisung wird im Anschluss auf True oder False geprüft
if(7=a)     //Besser, da aufgrund der fehlerhafter Zuweisung ein
            //Compilerfehler geworfen wird
x*=y=z      //entspricht  x=x*(y=z)
a=b=d+7;    //entspricht  a=(b=(d+7))
a=b==c;     //a bekommt das boolesche Ergebnis der Vergleichsoperation
            //zugewiesen

Öffnen im Compiler Explorer

Hinweise:

  • Auf beiden Seiten der Zuweisung muss der identische Datentyp stehen. Wenn andernfalls eine implizite Typkonvertierung nicht möglich ist, gibt der Compiler einen Fehler aus:
int a = 7.3;    //implizite Typkonvertierung
double b=a+3;   //Implizite Typkonvertierung der Intergeraddition nach double
float c=(float)a+(float)3;  //Explizite Typkonvertierung
char *d=a;      //Compilerfehler, da Integerwert nicht implizit in einen
                //Zeiger konvertiert werden kann

Öffnen im Compiler Explorer

  • Bei der Parameterübergabe beim Funktionsaufruf wird ebenfalls indirekt der Zuweisungsoperator angewendet. Die Parameter des Aufrufers werden per Zuweisung den lokalen Variablen der aufgerufenen Funktion zugewiesen
void func(int a, float b) {
  printf("a=%d b=%f",a,b);
}
int   x=4;
float y=5.0;
func(x,y);   //der Inhalt der Variablen x wird der lokalen Variable a 
             //(a=x) und der Inhalt der Variablen y der lokalen Variablen b
             //(b=y) zugewiesen.
func(y,x);   //Dito, mit der Ergänzung, dass eine implizite Typkonvertierung
			 //stattfindet (a=(int)y) und (b=(float)x).

Öffnen im Compiler Explorer

Komma-Operator

Bearbeiten

Der Komma-Operator erlaubt es, zwei Ausdrücke auszuführen, wo nur einer erlaubt wäre. Die Ergebnisse aller durch diesen Operator verknüpften Ausdrücke, außer dem letzten werden verworfen. Der letzte Ausdruck dient als Rückgabewert.

Folgendes gilt es bei der Nutzung des Komma-Operators zu beachten:

  • Der Komma-Operator wird von links nach rechts ausgewertet
  • Das Komma selbst stellt eine Sequence Point dar (siehe Kap Sequence Point)
  • An Stellen, wo das Komma zum Syntax gehört (z.B. Trennung der Übergabeparameter bei der Funktionsdefinition und beim Funktionsaufruf, Trennung von Variablen bei der Variablendefinition/-deklaration, Trennung der der enum Konstanten) muss der Ausdruck ergänzend geklammert werden, wenn der Komma-Operator gefordert ist
  • Der Komma-Operator hat von allen Operatoren die niedrigste Priorität/Rangfolge, so dass dieser Operator als letztes 'ausgewertet' wird

Beispiel:

int a=(5,3);       //5 wird verworfen, 3 dient als Rückgabewert, so dass
                   //a den Wert 3 erhält.
int b=5,3;         //KO, da Komma Bestandteil der Definition ist
int c=1,d=2,e=3; 
int f=(c+=2,a+a);  //c um zwei erhöht, a+a in f
int g= c+=2,a+a;   //KO, da Komma Bestandteil der Defintion ist
void func(int value){}
func(1);           //Funktion wird mit dem Wert 1 aufgerufen
func(1,2);         //KO, da Komma Bestandteil des Funktionsaufrufes ist
func((1,2));       //Funktion wird mit dem Wert 2 aufgerufen
int arr[12];
arr[3,4]=1;        //3 wird verworfen, 4 dient als Index für das Array

Öffnen im Compiler Explorer

'Sinnvolle' Anwendungsbeispiele:

  • Bei For-Schleifen, so dass mehrere Variablen initialisiert und mehrere Variablen mit jedem Schleifendurchlauf geändert werden können:
int sum,lauf;
int arr[]={5,4,3,2,1};
for(sum=100,lauf=5 ; lauf ; lauf--,sum+=arr[lauf]);

Öffnen im Compiler Explorer

  • Bei der Return-Anweisung, bei welcher zur Darstellung der Zusammengehörigkeit mehrere Aktionen ausgeführt werden sollen:
return errno=EINVAL,-1;
return printf("Fehler"),-1;

Öffnen im Compiler Explorer

  • An Stellen, wo nur eine Anweisung erlaubt ist (und man zu faul ist, einen Block zu öffnen)
if() y=2,z=3;

Öffnen im Compiler Explorer

Hinweis:

  • In vielen Ländern wird das Komma zur Darstellung von Gleitkommazahlen genutzt. Wird versehentlicher Weise dies in C/C++ übernommen, so führt dies zu einem unerwarteten Verhalten:
double var;
var=5.3;      //Variable wird mit 5.3 initialisiert
var=5,3;      //KO, da Komma Bestandteil der Defintion ist
var=(5,3);    //Variable wird mit 3 initialisiert

Öffnen im Compiler Explorer

?-Operator / Conditional-Operator

Bearbeiten

Operator mit 3 Operanden, welcher in Abhängigkeit des ersten Operanden den zweiten oder dritten Operanden zurückgibt.

Syntax: Logical-OR-expression ? expression : condition-expression

Entspricht der if()-Anweisung, wobei der ? Operator als solches einen Wert zurückgibt! Expression wird nur ausgewertet, wenn der erste Operand true ist. Andernfalls wird nur condition-expression ausgeführt. Der zurückgegebene Datentyp ergibt sich aus der Expression resp. Condition-expression. Beide sollten somit vom identischen Datentyp sein.

Folgendes gilt es bei der Nutzung des ?-Operators zu beachten

  • Der Datentyp Expression und condition-Expression muss identisch sein
int a=5;
a=a > 10 ? "String" : 4;   //KO, "String" ist vom Datentyp (char*) und 
                           //       4     ist vom Datentyp (int).

Öffnen im Compiler Explorer

int a=13;
int b=a>10 ? : 0;    //wenn a > 10 wird true, andernfalls false zurückgegeben

Öffnen im Compiler Explorer

  • Sollen innerhalb von condition-expression mehrere (durch Komma getrennte) Anweisungen enthalten sein, so sind diese zu Klammern! Andernfalls bedingt die niedrige Priorität des Komma-Operators ein anders Verhalten, als erwartet.
int a=7,max=5;
max=a>max ? a : max;         //Alles bestens

int var0,var1,var2;
var0=a>5 ? a++,5:6,a=1;      //das zweite Komma wird als separate Anweisung
                             //ausgeführt und kommt somit immer zur
                             //Ausführung
var1=(a>5 ? a++,5:6),a=1;    //durch Klammerung klare Abgrenzung
var2= a>5 ? a++,5 : (6,a=1); //durch Klammerung klare Abgrenzung

Öffnen im Compiler Explorer

Die Nutzung des ?-Operators empfiehlt sich an diversen Stellen:

  • Bei Makros
#define MAX(a,b) (a>b?a:b)

Öffnen im Compiler Explorer

  • Beim Fehlerhandling
char *str=strstr("hallo123","lox");
//Ausgabe von str nur, wenn Suche erfolgreich (str!=NULL) war
printf("%s",str!=NULL?str : "(not found)"  );

Öffnen im Compiler Explorer

  • Prüfen, ob eine Funktion erfolgreich ausgeführt wurde, bevor eine weitere Funktion ausgeführt wird
int ret;
ret=init1();       //im Fehlerfreien gibt init1() 0 zurück
ret=ret?:init2();  //Aufruf von init2() nur wenn ret==0 ist
                   //andernfalls wird ret zurückgegeben

Öffnen im Compiler Explorer

Logische Verknüpfungen

Bearbeiten

Alle logischen Operationen nutzen als Operanden Ganzzahlen ,Gleitkommazahlen oder Zeiger. Der Operand wird als false angesehen, wenn der Operand 0, 0.0 oder NULL ist. Andernfalls als true. Das Ergebnis ist immer vom Datentyp int und hat den Wert von 0 oder 1.

Syntax: Expression && Expression
Syntax: Expression || Expression
Syntax:  ! Expression

Bei der logischen UND und ODER Verknüpfung wird zuerst der linke Ausdruck ausgewertet (Operator ist ein Sequence Point). Der rechte Ausdruck wird nur ausgeführt, wenn:

  • bei der logischen UND Verknüpfung der linke Ausdruck TRUE ergab
  • bei der logische OR Verknüpfung der linke Ausdruck FALSE ergab

Andernfalls steht das Ergebnis schon mit der Auswertung des linken Operanden fest.
In der theoretischen Informatik wird dies 'Non-strict-evaluation' genannt, bei welcher der Rückgabewert feststeht, bevor alle Ausdrücke bearbeitet worden sind.

Beispiel für das Verhalten des Ausbleibens der Auswertung des zweiten Operators:

int a=0,b=0;
//a=-1; //Als alternativer Startwert
if(++a || ++a)
  b=1;

Öffnen im Compiler Explorer

Diese Funktionalität ist insbesondere beim Umgang mit Fehlern vorteilhaft:

  • Prüfen, ob eine Variable einen gültigen Wert hat, bevor sie weiterverwendet wird
char *str = strstr("hallo123","la");
if(str!=NULL  && strlen(str)  )
  printf("Len = %ld",strlen(str));

Öffnen im Compiler Explorer

  • Prüfen, ob eine Funktion erfolgreich ausgeführt wurde, bevor eine weitere Funktion aufgerufen wird:
int ret;
ret=init1();  //Bei erfolgreicher Initialisierung wird 1 zurückgegeben
ret=ret && init2();

Öffnen im Compiler Explorer

Bitweise Verknüpfungen

Bearbeiten

Alle bitweisen Operationen nutzen als Operanden Ganzzahlen. Bei vorzeichenbehafteten Zahlen ist das Ergebnis nach [C11 6.5] Implementierungsabhängig, wobei das Vorzeichenbit zumeist als eigenständiges Bit angesehen wird und bei UND/ODER/EXOR als normales BIT betrachtet wird.

Syntax: Ganzzahl & Ganzzahl → Bitweise UND Operation
Syntax: Ganzzahl | Ganzzahl → Bitweise ODER Operation
Syntax: Ganzzahl ^ Ganzzahl → Bitweise EXOR Operation
Syntax: ~ Ganzzahl → Bitweise Negierung
Syntax: Ganzzahl >> additive-expression
Syntax: Ganzzahl << additive-expression

Mittels des Schiebeoperators können Ganzzahlen um additive-expression bitweise nach Links << oder Rechts >> verschoben werden. Der resultierende Datentyp ergibt sich aus dem linken Operanden. Der rechte Operand muss positiv und kleiner gleich der Bitbreite des linken Operanden sein. Andernfalls ist das Ergebnis implementierungsspezifisch.

Nach [C11 6.5.7] ist das Ergebnis der Schiebeoperation implementierungsspezifisch, wenn der linke Operand eine negative vorzeichenbehaftete Zahl ist. In der Regel wendet der Compiler jedoch folgende Schiebeoperation an:

  • Logisches Schieben (zu füllende Bit-Stellen werden mit 0 aufgefüllt), wenn der linke Operand eine vorzeichenlose Zahl ist
  • Arithmetisches Schieben (zu füllende Bit-Stelle beim Rechtsschieben wird mit dem Vorzeichenbit aufgefüllt), wenn der linke Operand eine vorzeichenbehaftete Zahl ist. Hiermit wird das Vorzeichen beibehalten, so dass eine negative Zahl nach dem Schieben weiterhin negativ ist (In Java erfolgt dies durch den >>> - Operator)

Hinweis:

  • Ein Schieben um eine Stelle nach rechts entspricht einer Division durch 2
  • Ein Schieben um eine Stelle nach links entspricht einer Multiplikation mit 2

sizeof-Operator

Bearbeiten

Der sizeof-Operator dient zur Ermittlung des Speicherbedarfs (in Bytes) einer Variablen/eines Datentyps.

Syntax: sizeof (type-name)
Syntax: sizeof unary-expression

Als Argument für den Sizeof-Operator kann wahlweise ein Datentyp, eine Variable oder ein Ausdruck genutzt werden. Sizeof gilt als Konstantenausdruck, d.h. seine Berechnung erfolgt durch den Compiler, sodass:

  • sizeof zur Initialisierung von globalen Variablen genutzt werden
struct xyz{int x,y,z;};
size_t size=sizeof(struct xyz);

Öffnen im Compiler Explorer

  • bei einem Ausdruck dieser nicht ausgeführt wird, sondern nur der resultierende Datentyp bestimmt wird und dessen Größe zurückgegeben
short var=4711;
size_t size=sizeof var++;   //var=4711, size=2

Öffnen im Compiler Explorer

Der resultierende Datentyp des Rückgabewertes ist size_t.

Es empfiehlt sich, den sizeof-Operator mit Klammern zu nutzen, andernfalls wird im Falle eines Ausdruckes der Speicherbedarf des linken Operanden angegeben:

size_t size;
short var;
size=sizeof var+0;   //size=2, da sizeof auf var angewendet wird
size=sizeof(var+0);  //size=4, da sizoeof auf den resultierenden datentyp
                     //angewendet wird

Öffnen im Compiler Explorer

Hinweise:

  • sizeof angewendet auf char gibt immer den Wert 1 zurück.
  • sizeof ist nicht auf Operanden vom Typ Funktion, void, Felder ohne Größenangabe und Bitfelder anwendbar
  • wird sizeof auf ein VLA angewendet, so entspricht sizeof nicht mehr einen Konstantenausdruck, sondern einem Funktionsaufruf
void foo(int n) {
  int arr[n];
  size_t size=sizeof(arr);  //Speicherbedarf ist kein Konstantenausdruck
                            //sondern wird zur Laufzeit berechnet
  • Sizeof entspricht nicht strlen(), da sizeof den für die Variable reservierten Speicherbereich zurückgibt und nicht wie strlen() den Inhalt des Speicher hinsichtlich des Stringendezeichens auswertet.
char string[100]="Test";
printf("%zu\n",sizeof(string));  //100
printf("%zu\n",strlen(string));  //4

Öffnen im Compiler Explorer

Anweisungen

Bearbeiten

If-Anweisung

Bearbeiten

Syntax: if (expression) statement
Syntax: if (expression) statement else statement

Das Ergebnis des Ausdruckes (Ganzzahl, Gleitkommazahl oder Zeiger) wird auf ungleich 0, 0.0 oder NULL getestet. Trifft dies zu, so wird statement ausgeführt. Andernfalls wird das else statement ausgeführt, sofern vorhanden. Sollen mehrere Anweisungen ausgeführt werden, so sind diese mittels Block zusammenzufassen (diesen dann bitte nicht mit ; abschließen).

Beispiel:

if(a==1) 
if(a=1)    //Erst Zuweisung, dann prüfen der Rückgabe der Zuweisung
if(a)      //entspricht a!=0
if(printf("hallo")<0)  //prüfen, ob printf() fehlschlägt
if( ({int dummy=a;a=b;b=dummy;})==6)
if(a=7)    //Zuweisung!
if(7=a)    //KO, Eine Konstante kann kein L-Value sein
if(7==a)  
  {printf("a ist 7");};       //Semikolon hinter Blocksope ist eine zweite
else                          //Anweisung, so dass die ELSE Anweisung nicht
  {printf("Compilerfehler");};//zur IF-Anwesigung zugeordnet werden kann.

Öffnen im Compiler Explorer

Hinweis:

  • Bei der Anweisung if(a==7) finden aus syntaktischer Sicht zwei Aktionen statt. Zunächst wird geprüft, ob a gleich 7 ist. Dieser Vergleich liefert true(=1) oder false(=0) zurück. Die If-Anweisung prüft im Anschluss, ob das Argument true oder false ist.

For-Anweisung

Bearbeiten

Syntax: for(InitialisierungsteilOPT ; TestOPT ; FortsetzungOPT) Statement

Die For-Anweisung wird vom Compiler wie folgt umgesetzt:

   InitialisierungsteilOPT
   Goto Next
Goon:
    //Continue-Anweisung entspricht goto Continue
    //Break-Anweisung entspricht goto Break;
    Statement
Continue:
    FortsetzungOPT
Next:
    if(TestOPT) goto Goon
Break:

Öffnen im Compiler Explorer

Die einzelne Elemente haben folgende Bedeutung/Besonderheiten:

Initialisierungsteil
Zum Initialisieren von Schleifenvariablen, wird einmalig mit Beginn der FOR-Anweisung ausgeführt.
  • kann Leer sein, dann erfolgt keine Initialisierung
  • Ab C99: Hier kann eine lokale Variable definiert werden, welche nur innerhalb dieser For-Anweisung gültig ist
Testteil
Hierin wird vor jedem Schleifendurchlauf geprüft, ob die Schleifenbedingung erfüllt ist. Wenn Bedingung nicht erfüllt ist (Ganzzahl, Gleitkommazahl oder Zeiger gleich 0,0.0 oder NULL), wird die For-Schleife beendet.
  • Kann leer sein, dann wird dieser Teil als true angesehen
Fortsetzungsteil
Wird nach jeden Schleifendurchlauf ausgeführt. In der Regel sollte hier die Schleifenvariable aktualisiert werden
  • Kann leer sein
Statement
In der Schleife auszuführender Ausdruck
  • Sollen mehrere Ausdrücke ausgeführt werden, so sind diese in einem Block zusammen zu fassen.
Ergänzende Anweisungen innerhalb des Statements/Block
  • 'continue' zum Fortsetzen des Schleifendurchlaufes, d.h. Sprung zum Fortsetzungs-Teil
  • 'break', zum Beenden des Schleifendurchlaufes

Beispiele:

  • For-Schleife mit lokaler Schleifenvariable
char string[]="hallo";
for(size_t lauf=0; lauf < strlen(string); lauf++)
//Schleife ist Suboptimal, da in jedem Durchlauf die Funktion
//strlen() aufgerufen wird!

Öffnen im Compiler Explorer

  • Schleife mit Break-Anweisung
char dest[5];
char src[10]="HalloWelt";
for(size_t lauf=0; src[lauf]!=0; lauf++) {
  if(lauf >= sizeof(dest))
    break;
  dest[lauf]=src[lauf];
}

Öffnen im Compiler Explorer

  • Schleife, bei welcher die gesamte Aktion innerhalb der For-Anweisung ausgeführt wird
char dest[5];
char src[10]="HalloWelt";
for(size_t l=0;
    src[l]!=0 && l <sizeof(dest); 
    printf("l=%zu\n",l),dest[l]=src[l],l++);

Öffnen im Compiler Explorer

  • Schleifenvariable muss keine Zählvariable sein
int  zahl=0b100010;
int bits=0;
for(int mask=0b10000000;  mask!=0; mask>>=1 )
  bits+= zahl & mask ? 1 : 0;

Öffnen im Compiler Explorer

  • Zeiger als Schleifenvariable
char src[]="hallo";
char dst[strlen(src)+1];
char *s,*d;
for(s=src,d=dst; *s; )
  *d++=*s++;
*d=0;

Öffnen im Compiler Explorer

Hinweis:

  • Zur Geschwindigkeitsoptimierung sollten im Test- und Fortsetzungsteil keine Funktionsaufrufe mit Werten enthalten sein, die sich innerhalb der Schleife nicht ändern:
for(size_t lauf=0;lauf<strlen("hallo");lauf++)

Öffnen im Compiler Explorer

  • Zur Geschwindigkeitssteigerung sollte im Testteil auf 0 abgefragt werden, d.h. die Schleifenvariable dekrementiert werden
for(int lauf=700000;lauf;lauf--)

Öffnen im Compiler Explorer

  • Alternativ zu einer Laufvariablen empfiehlt sich die Nutzung von Pointer als Schleifenvariable;
for(char *ptr=str; *ptr;  ptr++);

While-Anweisung

Bearbeiten

Syntax: while(expression) Statement
Syntax: do Statement while(expression);

Die While-Anweisungen werden vom Compiler wie folgt umgesetzt:

while()-Anweisung do while()-Anweisung

Continue:
  if(!expression)
    goto Break
  //Break-Anweisung entspricht
  // goto Break;
  //Continue-Anweisung entspr.
  // goto Continue;
  Statement
  goto Continue
Break:

GoOn:
  //Break-Anweisung entspr.
  // goto Break
  //Continue-Anweisung ent.
  // goto Continue
  Statement
Continue:
  if(expression)
     goto GoOn
Break:

Öffnen im Compiler Explorer

Folgendes gilt es bei der Nutzung der While-Anweisung zu beachten:

  • Soll mehr als ein Statement innerhalb der While-Schleife ausgeführt werden, so sind diese in einem Block zu schreiben
  • Statement kann eine Leeranweisung (gekennzeichnet durch ein Semikolon) sein
while(1);
do ; while(1);

Öffnen im Compiler Explorer

  • Bei der do-While-Schleife ist das abschließende Semikolon Bestandteil des Befehls
  • expression muss eine Ganzzahl, Gleitkommazahl oder Zeiger zurückgeben, der bei False (0, 0.0 oder NULL) die Schleife beendet.

Beispiele:

  • Endlosschleife
while(1) {}
  • Schleife zum Durchlaufen eines String
char str[]="string";
char *ptr=str;
while(*ptr++);
size_t len=ptr-str-1;

Öffnen im Compiler Explorer

Switch-Anweisung

Bearbeiten

Syntax: switch(expression) statement
Syntax: { case constant-expression: statementopt default: statementopt}

Die Switch-Anweisung entspricht einer verschachtelten If-Anweisung, mit der Besonderheit, dass der auszuführende Code nicht im If-Zweig stattfindet, sondern dort ein goto enthalten ist:

Switch-Anweisung Compilerumsetzung
switch(a) {
   case 1:
     int a=7;
      //Code
	break;
   case 2:
     printf("%d",a);

   case 3:  {
     int a;
     //Code
      }
    case  4  ...  8:
    default:
      //Code
}

if(a==1)
  goto label1;
else if(a==2)
  goto label2;
else
  goto label_default;
//-------------------
label1:
   //Code
   goto label_break;
label2:
  //Code
label_default:
   goto label_break;

label_break:

Folgendes gilt es bei der Nutzung der Switch-Anweisung zu beachten:

  • Expression muss zu einer Ganzzahl ausgewertet werden (int, short, char, long, long long)
  • Constant-expression muss eine Integer-Konstante. Sie darf nicht doppelt im Block vorkommen. Der genaue Datentyp ergibt sich aus dem Datentyp der Expression
  • Ohne den folgenden Block kann nur ein case oder default Fall enthalten sein
int var=1;
switch(var) case 1: printf("hallo");

Öffnen im Compiler Explorer

  • Die geschweifte Klammer der Switch-Anweisung erzeugt einen Block, so dass hierin definierte Variablen innerhalb des gesamten Block zur Verfügung stehen (sind also nicht CASE abhängig). Die CASE Anweisungen sind nichts anderes als Sprungmarken, so dass der Initialisierungsteil der lokalen Variablen übersprungen / nicht ausgeführt werden kann:
switch(a) {
    int a=3;     //Variable in gesamten Block gültig
  case 1: 
    int b=3;  //Variabe in gesamten Block gültig
    printf("%d %d",a,b);  //a undefiniert, b=3
    break;
  case 2:
    printf("%d %d",a,b);  //a und b undefiniert
    break;
  case 3: {        //Block, so dass Variable
    int a=4711;    //nur in diesem CASE gültig ist
    break;
    }
}

Öffnen im Compiler Explorer

  • GNU-C (siehe Switch-Statement) erlaubt ergänzend zur Angabe einer Konstante die Angabe eines Wertebereiches.
switch(a) {
  case 1 ... 8:   //Leerzeichen zwischen den Konstanten zwingend notwendig
                  //andernfalls wird der Punkt zur Konstante zugehörig
                  //interpretiert (erzeugt dann eine Gleitpunktkonstante)

Öffnen im Compiler Explorer

  • Break ist optional. Wenn break weggelassen wird, geben Compiler ggf. eine Warning aus. Mittels der GCC-Compiler-Anweisung __attribute__((fallthrough)); kann diese Warning unterdrückt werden.
switch(a) {
  case 0:  //ggf. Compiler-Warning
  __attribute__((fallthrough)); case 1: 
  case 2:
    break;
}
  • Case - Anweisungen sind nichts anderes als Sprungmarken (Labels), so dass CASE-Anweisungen überall stehen können:
int prime(int value) {return value+1;}
void hallo(int var)  {
  switch(var) {
    default:
      if(prime(var))
        case 2: case 4:
        printf("%d",var);
      else {
        case 3: case 6:
        printf("%x",var);
      }
    case 7:
      printf("xyz");
  }
}

Öffnen im Compiler Explorer

  • Variable Length Arrays sind als Variablen im Block der Switch Anweisung nicht erlaubt. Ist dies notwendig, so sind diese in einen inneren Block anzulegen
switch(var) {
  case 1:
    int arr[var];  //KO
    break;
  case 2: {
    int arr[var];  //OK
   }

Öffnen im Compiler Explorer

Hinweis:

  • Eine Switch-Case Anweisung über Strings sind in C/C++ nicht möglich:
	switch(str) {
	  case "hallo":
	    return 1;
	  case "du da":
	    return 2;
	  case "xyz":
	    return 3;
	  default:
	   rReturn -1;
	}
Ein direkte Lösungsansatz ist die Nutzung von strcmp() zum Vergleichen von String. Da strcmp zeichenweise den String vergleicht, ist dieser Ansatz langsam. Alternativ bietet sich an, von allen String einen Hash-Wert zu berechnen und zunächst diesen Hash-Wert zu kontrollieren. Da der Hash-Wert nicht eindeutig ist, muss ergänzend ein strcmp() erfolgen:
char *str="hallo";
switch(HASH_LAUFZEIT(str)) {
  case HASH_COMPILEZEIT('d','u'):
    if(!strcmp(str,"du"))
       printf("Case \"du\":\n");
    break;
  case HASH_COMPILEZEIT('h','a','l','l','o'):
    if(!strcmp(str,"hallo"))
      printf("Case \"hallo\"\n");
    else if(!strcmp(str,"xyz"))
      printf("String='xyz'\n");
    break;
default:         
  return -1;
}

Öffnen im Compiler Explorer

Siehe:Hash Table in C

Label + Goto

Bearbeiten

Syntax: name:

Labels dienen zur Kennzeichnung einer Anweisung, so dass die Programmausführung an dieser Stelle fortgesetzt werden kann, resp. mit einer GOTO-Anweisung angesprungen werden kann. Ein Label ist durch den folgenden Doppelpunkt gekennzeichnet. Dem Label muss eine Anweisung folgen, welches im Falle einer folgenden Variablendefinition durch eine Leeranweisung (Semikolon) erzeugt werden kann.

Folgendes gilt es bei der Nutzung von Labels zu beachten:

  • Ein Label entspricht der Speicheradresse des der Label-Anweisung folgenden Anweisung
  • Bei nicht genutzten Labels (mit goto nicht angesprungene Labels) gibt der Compiler eine Warning aus. Diese kann beim GCC-Compiler mit __attribute__((unused)) unterdrückt werden
  • die Case- und die Default-Anweisung aus der Switch-Anweisung entspricht einem Label


Syntax: goto label;

Mit der Goto-Anweisung wird die Programmausführung an der mit label gekennzeichneten Stelle fortgesetzt.

Folgendes gilt es bei der Nutzung von goto zu beachten:

  • Mit einer Goto Anweisung kann an eine beliebige Steller innerhalb der identischen Funktion gesprungen werden. Es kann nicht zu einem Label einer anderen Funktion gesprungen werden (dies erfolgt über setjmp() und longjmp())
  • Mit der Goto Anweisung kann gleichermaßen in einen Block hinein, als auch herausgesprungen werden:
goto first_time;
for(;;) {
  if(a>10) 
    goto last_time:
  first_time:
  a++;
}
last_time: ;

Öffnen im Compiler Explorer

  • Wird mit der Goto Anweisung der Initialisierungsteil einer Variablen übersprungen, so ist zwar die Variable existent, beinhaltet aber nicht ihren Initialisierungswert

Das Label hat eine funktionsweite Sichtbarkeit (siehe Kap Gültigkeit/Sichtbarkeit von Variablen). Dies bedeutet, dass das:

  • Labels nur innerhalb einer Funktion gültig ist
  • Labels einen von Variablen/Funktionen/Datentypen unabhängigen Namensraum haben
  • Kein Prototyp für ein Label notwendig ist, wenn es bei Nutzung über goto noch nicht gesetzt worden ist, also nach vorne gesprungen wird

Beispiel:

goto label1;
if(a==3) {
  label2:   
    printf("True\n");
}
else
  label1:
{
  printf("False\n");
}
if(a==3) goto label2;

Öffnen im Compiler Explorer

Hinweis:

  • In Java gibt es das Schlüsselwort goto, welches aber ohne Funktion ist. Es gibt jedoch Labels, die mit "break label" angesprungen werden können.

Prinzipiell sind Goto-Anweisungen zu vermeiden. In einigen Anwendungsfällen erweist sich goto als nützlich:

  • Konstruktor mit mehreren Initialisierungsteilen
int main(int argc,char *argv[]) {
  enum {INIT_2,INIT_1,INIT_START} fortschritt=INIT_START;
  int *ptr1;
  int *ptr2;
  if(!(ptr1=malloc(10)))
    goto end;
  fortschritt=INIT_1;
  if(!(ptr2=malloc(20)))         
    goto end;
  fortschritt=INIT_2;
  //    ...
  end:
  switch(fortschritt) {
    case INIT_2: free(ptr2); __attribute__((fallthrough));
    case INIT_1: free(ptr1); __attribute__((fallthrough));
    case INIT_START: 
  }
  return (int)fortschritt;
}

Öffnen im Compiler Explorer

  • Abbruch einer verschachtelten Schleife
int arr[10][10];
size_t zeile;
size_t spalte;
for(zeile=0;zeile<10;zeile++)
  for(spalte=0;spalte<0;spalte++)
    if(arr[zeile][spalte]==0)
      goto label_end; 
label_end:
if((zeile==0) && (spalte==0))
  printf("Nichts gefunden");
else
  printf("Gefunden an %zu %zu",zeile,spalte);

Öffnen im Compiler Explorer

Sonstiges

Bearbeiten

??-Trigraphs

Bearbeiten

Zeichenfolge bestehend aus 3 Zeichen beginnend mit 2 Fragezeichen. Die beiden Fragezeichen entsprechen einem ESCAPE-Operator (siehe Kap Char-Konstanten), welcher das nachfolgende Zeichen durch ein anderes ASCII Zeichen ersetzt. Die Ersetzung findet auch innerhalb von Strings statt.
Trigraphs wurden früher genutzt, als die ALTGR Taste noch nicht üblich war.

??< →  {   ??> →   }   
??( →  [   ??) →   ]
??/ →  \   ??! →   |
??' →  ^   ??- →   ~
??= →  #

Bei neuer C Varianten muss zur Nutzung von Trigraphs der Compiler mittels -trigraphs aktiviert werden.

Datentypen

Bearbeiten

Die Programmiersprache C kennt nachfolgende grundlegende Datentypen und Zeiger auf diese Datentypen (siehe auch Grundlagen:Grundlegende Datentypen und Typsicherheit)

Datentyp Bsp. für Datentyp Bsp. für Zeiger auf Datentyp
Ganzzahl
          char  var;
           int  var;
     short int  var;
     long  int  var;
long long  int  var;
           char *ptr;
           int  *ptr;
     short int  *ptr;
     long  int  *ptr;
long long  int  *ptr;
Gleitkommazahl
     double var;
     float  var;
long double var;
     double *ptr;
     float  *ptr;
long double *ptr;
Funktionen
void func(void) {}
void (*pfunc)(void);
Arrays
int   arr[10];
float arr[3][3];
int   (*parr);
float (*parr)[3];
void (*pfunc[5])(void);
Strukturen/Unions
struct xyz {int x,y,z};
union abc  {int a,b,c};
struct xyz *ptr;
union abc  *ptr;
Aufzählungstyp
enum abc {A,B,C};
enum abc *ptr;
Boolescher Datentyp
_Bool var;
_Bool *ptr;
Komplexe Zahlen
_Complex var;
_Complex *ptr;

Bis auf die Datentypen Funktionen, Array und die Zeiger sollen die wesentlichen Aspekte dieser Datentypen im nachfolgenden beschrieben werden.

Der grundlegende Datentyp ist 'int'!

char var1=10,var2=20,var3;
var3=var1+var2; //Addition findet auf Basis des Datentyps int statt
  • Es gibt zwar den Booleschen Datentyp, dieser entspricht jedoch einem Ganzzahldatentyp und true und false entsprechen den Integerwerten 0 und 1
  • Der Aufzählungsdatentyp und seine Aufzählungselemente sind vom Datentyp 'int'
  • Bis einschl. C90 wurde bei der Definition von Variablen ohne Angabe des Datentyps, bei Definition einer Funktion ohne Angabe des Datentyps der Übergabeparameter, des Rückgabedatentyps der Datentyp implizit auf int gesetzt (implizit int)
static var2=2;
foo(par1,par2){
  auto var1;
  var1=par1+par2;
  return var1;
}

Öffnen im Compiler Explorer

Ganzzahl Datentypen

Bearbeiten

Grundlegend kennt die Programmiersprache C nur zwei Ganzzahldatentypen:

  • Datentyp: int
Vorzeichenbehafteter (signed) Datentyp, dessen Bitbreite von der Rechnerarchitektur und des Compilers abhängig ist
  • Datentyp char
Datentyp mit einer Mindestbreite von 8-Bit u.A. zur Speicherung von Zeichen typischerweise im ASCII Format. Die C-Spezifikation [C11 6.2.5 Types] lässt es dem Compiler frei, ob char als vorzeichenbehafteter oder vorzeichenloser Datentyp implementiert wird.
Ergänzend kann der Datentyp char auch zur Speicherung von ganzen Zahlen genutzt werden. In diesem Fall empfiehlt sich, diese explizit als signed und unsiged zu definieren. Wenn nur ASCII Zeichen gespeichert werden, so ist diese Angabe nicht nötig!

Optional können Qualifier dem Datentyp vorangestellt werden:

  • unsigned / signed zur Angabe, ob der Inhalt als vorzeichenlose Zahl oder als vorzeichenbehaftete Zahl zu interpretieren ist. Ohne Angabe sind Variablen vom Datentyp int vorzeichenbehaftet
  • short / long / long long (letzteres seit C99) als vorangestellten Qualifier zu int zur Vergrößerung/Verkleinerung der Bitbreite. Bei Angabe des Qualifiers ist das Schlüsselwort int optional. Die Beschreibung long long int und long long sind gleichbedeutend!

Als Bitbreite für die Datentypen ist im C-Standard nur ein Mindestbreite definiert. Die genaue Bitbreite hängt von der Rechenbreite des Systems und vom Compiler ab (siehe [C11 6.2.5 Types]):

Datentype Mindest
breite
Typisch bei 32-Bit
Architektur
Typisch bei 64-Bit
Architektur
printf Format
Anweisung
char 8 8 8 %c
signed char
unsigned char
8 8 8 %hhd
%hhu
short
short int
16 16 16 %hd / %hu
int 16 16/32 32 %d / %u
long
long int
32 32 32/64 %ld / %lu
long long
long long int
64 64 64 %lld / %llu

In der Header-Datei limits.h sind die tatsächlichen Grenzwerte der Datentypen gespeichert:

Konstante Beschreibung Typ. bei 64-Bit Architektur
CHAR_BIT Anzahl der Bits in einem Char 8
SCHAR_MIN min. Wert, den der Typ bei signed char aufnehmen kann -128
SCHAR_MAX max. Wert, den der Typ bei signed char aufnehmen kann 127
UCHAR_MAX max. Wert, den der Typ bei unsigned char aufnehmen kann 255
CHAR_MIN min. Wert, den der Typ char aufnehmen kann 0 oder SCHAR_MIN
CHAR_MAX max. Wert, den der Typ char aufnehmen kann SCHAR_MAX oder UCHAR_MAX
SHRT_MIN min. Wert, den der Typ short int annehmen kann -32.768
SHRT_MAX max. Wert, den der Typ short int annehmen kann 32.767
USHRT_MAX max. Wert, den der Typ unsigned short int annehmen kann 65.535
INT_MIN min. Wert, den der Typ int annehmen kann -2.147.483.648
INT_MAX max. Wert, den der Typ int annehmen kann 2.147.483.647
UINT_MAX max. Wert, den der Typ unsigned int aufnehmen kann 4.294.967.296
LONG_MIN min. Wert, den der Typ long int annehmen kann -2.147.483.648 oder
-9.223.372.036.854.775.808
LONG_MAX max. Wert, den der Typ long int annehmen kann 2.147.483.647 oder
9.223.372.036.854.775.807
ULONG_MAX max. Wert, den der Typ unsigned long int annehmen kann 4.294.967.296 oder
18.446.744.073.709.551.616
LLONG_MIN min. Wert, den der Typ long long int annehmen kann -9.223.372.036.854.775.808
LLONG_MAX max. Wert, den der Typ long long int annehmen kann 9.223.372.036.854.775.807
ULLONG_MAX max. Wert, den der Typ unsigned long long int annehmen kann 18.446.744.073.709.551.615

Aufgrund dessen, dass die Breite der Datentypen von der Rechnerarchitektur und vom Compiler abhängig ist, sind in der Header-Datei stdint.h Aliase für Datentypen enthalten, welche in ihrem Alias die tatsächliche Breite und das Vorzeichens beinhalten:

uint8_t    -->  unsigned  8-Bit
 int8_t    -->    signed  8-Bit
uint16_t   -->  unsigned 16-Bit
 int16_t   -->    signed 16-Bit
	...

Für C++ sehen diese Datentypen wie folgt aus:

std::intptr_t
std::int8_t    std::uint8_t
std::int16_t   std::uint16_t
std::int32_t   std::uint32_t
Std::int64_t   std::uint64_t

Zur Erzeugung von portablen/rechnerunabhängigen Programmen empfiehlt sich, diese Datentypen zu nutzen. Die Rechenbreite ist dann unabhängig vom Compiler und Betriebssystem.

Gleitkomma Datentypen

Bearbeiten

Gleitkommazahlen werden im Computer auf Basis folgender Schreibweise dargestellt (siehe auch:   Gleitkommazahl):

             Dezimalsystem                 Dualsystem
12,3456710 = 1234567 * 10^-5       0,110 = 1,1001100110011 * 2^-4
             -------      --               ---------------     --
             Mantisse  Exponent            Mantisse         Exponent
Mantisse
Vorzeichenlose ganze Zahl (ggf. mit einer festen Position des Dezimalpunktes=Festkommazahl)
Exponent
Vorzeichenbehaftete ganze Zahl, welche um einen Bias verschoben ist
Vorzeichen
Vorzeichen der (vorzeichenlosen) Mantisse

Zur Speicherung im Computer werden das Vorzeichen, die Mantisse und der Exponent getrennt im Binärformat gespeichert, für den Programmierer aber als eine (zusammenhängende) Zahl dargestellt:

 
Bitweise Darstellung einer Gleitkommazahl, aufgeteilt in Vorzeichen, Exponent und Mantisse

Gleitkommazahlen haben genauso wie ganze Zahlen einen beschränkten Wertebereich:

  • Die Anzahl der Bits der Mantisse bestimmen die Anzahl der Nachkommastellen:
Bei 23-Bit Mantisse hat das niederwertigste Bit die Wertigkeit:
2^-22=1/2^22=1/(2^10*2^10*2^2)=1/(1024*1024*4)1/(4.000.000)=0,000.000.25
 Es könnten also nur 7..8 dezimale Nachkommastellen gespeichert werden
  • Mit dem Exponent kann 'quasi' der kleinste und der größte Wertebereich angegeben werden:
Bei 8-Bit Exponent mit einem Bias von 127 liegt der Wertebereich des
Exponenten im Bereich von -126  +127 (Exponent -127 und +127 wird zur 
Darstellung weitere Zahlen benötigt).
Die kleinste darstellbare Zahl (unter Vernachlässigung der Nachkommastellen der Mantisse ist:
2^-126=1/2^1261,175*10^-38
Die größte darstellbare Zahl ist:
2^127         1,7*10^38

Öffnen im Compiler Explorer

Die genaue Darstellung (Anzahl der Bits für Mantisse und Exponent, Darstellung von NAN/INF, ….) ist in C nicht spezifiziert und somit compiler- und rechnerabhängig. Zumeist wird mittlerweile die   IEEE 754 Standardisierung genutzt (Norm wurde von Intel mit der Entwicklung der 8087 FPU entworfen).

Grunddatentypen und der Wertebereich (nach   IEEE 754)

Daten-
type
Speicher-
platz
Exponent Mantisse Größte Zahl Kleinste Zahl Genauigkeit printf Format Anweisung
float 4 Byte 8 bit 23 bit +-3,4*1038 1,2*10-38 6 Stellen ---
double 8 Byte 11 bit 52 bit +-1,7*10308 2,3*10-308 12 Stellen %f %e %g
long double 10 Byte ≥8 bit ≥ 63 bit +-1,1*104932 3,4*10-4932 18 Stellen %Lf %Le %Lg

Aufgrund des nicht standardisierten Formates sollte ein Gleitkommadatentyp nicht im Binärformat für den Datenaustausch (mit Netzwerk, über Dateien, ..) genutzt werden. Andere Rechnersysteme/Programmiersprachen würde aufgrund der anderen Interpretation andere Zahlenwerte aus den übertragenen/gespeicherten Binärdaten auslesen!

float var1;
write(file_hdl,&var1,sizeof var1);   //Schreiben des Binärwertes
                                     //in eine Datei
double var2;
send(socket_hdl,&var2,sizeof var2,0);//Senden des Binärwertes
                                     //über ein Netzwerk

Die Standard-C-Library bietet einige mathematische Funktionen wie sin() / cos() / tan() / atan2() / sqrt() / pow() / exp()/… an. Die Prototypen dieser Funktion sind in der Header-Datei math.h beschrieben, so dass diese bei Nutzung dieser Funktion zu inkludieren ist. Ergänzend ist die Shared Library libm.so über den Compilerschalter '-lm' einzubinden:

#include <math.h>
//Math-Library libm.so mittels Linker-Command '-lm' einbinden
int main(int argc,char *argv[]) {
  double var1=47.11;
  double var2=sqrt(var1);

Öffnen im Compiler Explorer

Die Funktionen basieren auf den Datentyp double, d.h. sowohl der Übergabewert als auch der Rückgabewert ist vom Datentyp double. Die Prototypen sehen wie folgt aus:

double sin(double);
double cos(double);
double tan(double);
double asin(double);
double acos(double);
double atan(double);          //Wertebereich der Rückgabe von -PI/2..+PI/2
double atan2(double,double);  //Wertebereich der Rückgabe von -Pi .. +PI
double sqrt(double);
double log(double);

Über den Suffix f oder l im Funktionsnamen kann der zugrundeliegende Datentyp auf float (Suffix f) oder long double (Suffix l) geändert werden:

float       sinf(float);
long double sinl(long double);

Ergänzend zu den Prototypen sind die gebräuchlichen Naturkonstanten in math.h definiert (siehe auch: math.h):

M_E         Value of e
M_LOG2E     Value of log_2 e
M_LOG10E    Value of log_10 e
M_LN2       Value of log_e 2
M_LN10      Value of log_e 10
M_PI        Value of π
M_PI_2      Value of π/2
M_PI_4      Value of π/4
M_1_PI      Value of 1/π
M_2_PI      Value of 2/π
M_2_SQRTPI  Value of 2/π
M_SQRT2     Value of 2
M_SQRT1_2   Value of 1/2

Gleitkommazahlen können nicht nur Zahlenwerte, sondern auch Sonderwerte annehmen. Diese Sonderwerte sind ebenfalls in math.h beschrieben (siehe auch: math.h):

INFINITY  A constant expression of type float representing
          positive or unsigned infinity, if available; else a
          positive constant of type float that overflows at
          translation time.
NAN       A constant expression of type float representing a
          quiet NaN. This macro is only defined if the
          implementation supports quiet NaNs for the float type.

Mit fesetround()/fegetround() kann gesetzt/gelesen werden, wie mit Ergebnissen umzugehen sind, die nicht exakt darstellbar sind. Mögliche Werte sind 'round to nearest' (default), 'round up', 'round down' und 'round toward zero'.

Komplexe Zahlen

Bearbeiten

Nach dem Wikipedia Artikel   komplexe Zahlen stellen komplexe Zahlen eine Erweiterung der reellen Zahlen dar. Ziel der Erweiterung ist es, algebraische Gleichungen wie x2+1=0 bzw. x2=-1 lösbar zu machen. ... Da die Quadrate aller reellen Zahlen größer oder gleich 0 sind, kann die Lösung der Gleichung x2=-1 keine reelle Zahl sein. Man braucht eine ganz neue Zahl, die man üblicherweise i nennt, mit der Eigenschaft i2=-1. Diese Zahl i wird als imaginäre Einheit bezeichnet. Komplexe Zahlen werden nun als Summe a+b*i definiert, wobei a und b reelle Zahlen sind und i die oben definierte imaginäre Einheit ist.
Der Datentyp für komplexe Zahlen (erst ab C99 enthalten) beinhaltet folglich zwei Gleitkommazahlen, eine für den reelen Teil und eine für den imaginären Teil. Entsprechend den Gleitkommazahlen steht der komplexe Datentyp ebenfalls in drei unterschiedlichen Genauigkeiten zur Verfügung:

Datentype Speicherplatz
float _Complex 2x4Byte
double _Complex
_Complex
2x8Byte
long double _Complex 2x10Byte

Wie auch beim Booleschen Datentyp ist in der Header-Datei complex.h für den einfacheren Umgang mit diesem Datentyp das Makro complex als Textersetzung für _Complex gesetzt.

Zur Darstellung von komplexen Konstanten wurde die Gleitkommakonstante um den Suffix i ergänzt. Durch Anhängen von i wird aus dem ansonsten rellen Teil der imaginäre Teil und damit aus der Gleitkommakonstante eine komplexe Gleitkommakonstante:

_Complex varc1=2i;
_Complex varc2=1+2i;
printf("%f %f\n",creal(varc1),cimag(varc1));
printf("%f %f\n",creal(varc2),cimag(varc2));

Öffnen im Compiler Explorer

Normale Gleitkommavariablen werden als reellen Teil einer komplexen Zahl angesehen. Bei Operationen von Gleitkommazahlen und komplexen Zahlen wird der imaginäre Teil der Gleitkommazahl auf 0 gesetzt. Beim Zuweisen einer komplexen Zahl an eine Gleitkommazahl wird nur der reele Teil 'gelesen'.
Soll eine Gleitkommavariablen zu einem imaginären Teil gewandelt werden, so muss diese bspw mit der Konstanten 1.0i multipliziert werden:

double vard1=2i;       //Vorsicht, es wird nur der reele Teil
printf("%f\n",vard1);  //der komplexen Zahl in vard1 gespeichert!
_Complex varc1=1+2i;
         vard1=1;
varc1=varc1+vard1;
printf("%f %f\n",creal(varc1),cimag(varc1));
varc1=varc1+vard1*1.0i;
printf("%f %f\n",creal(varc1),cimag(varc1));

Öffnen im Compiler Explorer

Die Standard-C-Library bietet diverse mathematische Funktionen wie csin(), ccos(), csqrt(), cpow() und ergänzend Umrechnungsfunktionen von Gleitkommazahlen in komplexe Zahlen (und umgedreht) wie creal(),cimag() ,cabs() für den Umgang mit komplexen Zahlen an:

double _Complex csin(double _Complex);
double _Complex csqrt(double _Complex);
double          creal(double _Complex);
double          cimag(double _Complex);

Zur Nutzung dieser Funktionen muss die Header-Datei complex.h inkludiert und ergänzend die Shared Library libm.so über den Compilerschalter '-lm' eingebunden werden.

#include <complex.h>
//Math-Library mittels Linker-Command '-lm' einbinden
int main(int argc, char *argv[]) {
  double _Complex xyz=6+2i;  //Reeler-Teil=6  Imaginärer Teil=2
  printf("Reeller Teil: %f Imaginärer Teil:%f",creal(xyz),cimag(xyz));

Öffnen im Compiler Explorer

Die Funktionen beruhen auf den Datentyp double _Complex, d.h. sowohl der Übergabewert als auch der Rückgabewert ist double _Complex resp. double (bei den Umrechnungsfunktionen). Über den Suffix f und l im Funktionsnamen kann der zugrundeliegende Datentyp geändert werden:

float       crealf(float _Complex z);
long double creall(long double _Complex z);

Hinweis:

  • Der Datentyp _Complex und die dazugehörigen mathematischen Funktionen sind in der C-Spezifikation als optional gekennzeichnet.

Generische mathematische Funktionen

Bearbeiten

In der tgmath.h Header Datei sind für die mathematischen Funktionen Makros enthalten, welche den Datentyp der Übergabeparameter bestimmen und basierend auf den Datentyp die dazugehörige mathematische Funktion aufrufen.

tgmath.h selbst inkludiert math.h und complex.h, so dass diese nicht weiter zu inkludieren sind.

Steht für den Übergabedatentyp keine passende Funktion zu Verfügung, so ist das Verhalten des Makros undefiniert (bspw. wenn ein komplexes Argument an eine Funktion übergeben wird, die nur für Gleitkommazahlen ausgelegt ist ceil(1+2i); )

Folgendes Regelwerk wird angewendet:

  • Ist einer der Übergabeparameter imaginär, so ist die ausgewählte Funktion in der Spezifikation der Funktion beschrieben
  • Ist einer der Übergabeparameter Komplexe, so wird die Komplexe Variante der Funktion aufgerufen, andernfalls die Gleitkomma Variante
  • Ist einer der Übergabeparameter vom Datentyp long double, so wird die long double Variante, bei integer und double die double Variante und andernfalls die float Variante aufgerufen

Beispiel:

#include <tgmath.h>
//Math-Library libm.so mittels Linker-Command '-lm' einbinden
long double foo(long double complex parlc, 
                long double         parld, 
                     float          parf, 
                     int            pari) {
  long double ret;
  ret =(long double) sqrt(parlc); //Aufruf von csqrtl()
  ret+=              sqrt(parld); //Aufruf von  sqrtl()
  ret+=(long double) sqrt(parf);  //Aufruf von  sqrtf()
  ret+=              sqrt(pari);  //Aufruf von  sqrt()
  return ret;
}

Öffnen im Compiler Explorer

Boolschescher Datentyp

Bearbeiten

Siehe Grundlagen: Boolescher-Datentype/Operatoren

void / unvollständiger Datentype

Bearbeiten

Der Datentyp void dient vorrangig dazu, nicht vorhandene Über- und Rückgabewerte von Funktionen anzuzeigen. Eine Variable vom Datentyp void kann nicht angelegt werden. Ein Zeiger vom Datentyp void ist möglich, kann aber nicht dereferenziert werden (siehe Kap. Zeiger:Void-Zeiger)

void func(void); //Void zur Darstellung der nicht vorhandenen Parameter und
                 //des nicht vorhandenen Rückgabewertes
void var;        //Eine Variable vom Datentyp void kann nicht angelegt werden
void *ptr;       //Void-Zeiger

Öffnen im Compiler Explorer

Hinweise:

  • sizeof(void) ist nach der C-Spezifikation nicht erlaubt. Dennoch geben viele Compiler hier den Wert 1 zurück!
  • Mit einem expliziten Cast auf den Datentyp void wird dem Compiler mitgeteilt, dass diese Variable in Gebrauch ist, der Wert in dieser Operation aber nicht genutzt wird. Hierüber kann die Compilerwarning 'unused Variable' unterbunden werden:
void func(int par1, int par2) {
  int var1=12;
  int var2=13;
  (void)par2;     //Angabe, dass diese Variable genutzt wird.
  (void)var2;     //Angabe, dass diese Variable genutzt wird.
  if(var1==par1) //var1 und par1 werden verwendet

Öffnen im Compiler Explorer

Datentypkonvertierung

Bearbeiten

Die Abarbeitungsreihenfolge der Operatoren wird durch die Prioritätenliste/Rangfolge (siehe C-Programmierung:Liste der Operatoren nach Priorität) vorgegeben. Operatoren mit höherer Priorität werden vor Operatoren mit niedriger Priorität ausgeführt:

//Der Ausdruck
c = sizeof(x) +  ++a / 3;  
//wird aufgrund der Prioritäten wie folgt ausgewertet:
c= (sizeof(x)) + ( (++a) / 3);

Öffnen im Compiler Explorer

Bei identischer Priorität ergibt sich die Abarbeitungsreihenfolge aus der Assoziativität (L-R oder R-L)

a=33 / 5 / 2;
//Wird aufgrund der Assoziativität wie folgt ausgewertet.
//a= (33 / 5) / 2;
//und damit zu 3 und nicht zu 16 (bei 33 / (5/2)) ausgewertet. 
a = b = c = d*2;  //→ a=(b=(c=(d*2)));
a = b = 1+c = d;  //→ a=(b=((1+c)=d)); //Compilerfehler, da 
                                       //1+c kein lvalue ist

Öffnen im Compiler Explorer

Für das Rechnen/Vergleichen müssen beide Operatoren vom identischen Datentyp sein! Sind diese nicht identisch, so müssen die Datentypen 'angeglichen' werden. Dies kann einerseits ‚automatisch‘ mittels 'implizierter' Typumwandlung oder ‚manuell‘ mittels ‚expliziter' Typumwandlung erfolgen.
Beim Zuweisungsoperator (inkl. Parameterzuweisung bei Funktionsaufrufen und Funktionsrückgabewerte) gilt dies ebenso, nur dass hier der Quelldatentyp an den Zieldatentyp angepasst werden muss. Die impliziten Regeln finden hier keine Anwendung.

Hinweis:

  • Empfehlenswert ist, die Datentypen der Variablen so zu wählen, dass der Compiler keine implizite Typumwandlung tätigt. Kann dies nicht vermieden werden, so sollte die explizite Typumwandlung genutzt werden (um sich einen möglichen Datenverlust bewusst zu machen).
  • Der Datentyp selbst sollte den möglichen Wertebereich der Variablen entsprechen und nicht unnötig groß gewählt werden.
Datentyp zur Speicherung der Stundenzeit: 
	Wertebereich: 0 ... 23  -> unsigned char
Datentyp zur Speicherung einer Jahreszahl:  
	Wertebereich: 2000 v.C. ... 4000 n.C -> signed short
Datentyp zur Speicherung einer Temperatur: 
	Wertebreich: -273,0°C ... 2000,0°C  -> float

Implizite Typumwandlung

Bearbeiten

Diese Regeln wurden so aufgestellt, dass dabei stets ein Datentyp in einen anderen Datentyp mit höherem Rang umgewandelt wird (Rangordnung: long double, double, float, long long, long, int) [C11 6.3.1.8] .

Nach [Harbison: S. 199]
Regel/
Priorität
If either operand
has Type
And the other operand
has Type
Converts both to
1 long double any real type long double
2 double any real type double
3 float any real type float
4 any unsigned type any unsigned type The unsigned type
with the greater rank
5 any signed type any signed type The signed type
with the greater rank
6 any unsigned type a signed type of greater rank that can
represent all vaues of the unsigned type
The signed type
7 any unsigned type a signed type of greater rank that cannot
represent all values ot the unsigned type
The unsigned version
the the signed type
8 any other type any other type No conversion

Ergänzend gilt das Regelwerk zu Integer Promotion [C11 6.3.1.1], welche Datentypen kleiner als signed int zu int und kleiner als unsigned int und unsigned int konvertiert (Compiler kann hiervon abweichen, wenn sichergestellt ist, dass kein Datenverlust eintritt).

Regel 6 und 7 sind der nicht klar definierten Bitbreite der ganzzahligen Datentypen geschuldet und versuchen, Konvertierungsverluste zu vermeiden. Sie lesen sich zunächst kryptisch, lassen sich aber einfach am folgenden Beispiel erklären:

Operand 1: unsigned int (hier 32-Bit)
Operand 2: long         (hier 32-Bit  / 64-Bit)
  • Im Fall, dass der Datentyp long 64-Bit breit ist, kann dieser problemlos die vorzeichenlose 32-Bit Zahl ohne Konvertierungsverluste repräsentieren, so dass der Zieltype signed long ist (Regel 6)
  • Im Fall, dass der Datentyp long 32-Bit breit ist, kann dieser mit seinen Wertebereich von -2.147.483.648 bis +2.147.483.647 nicht den vorzeichenlosen Zahlenbereich von 0…4.294.967.295 darstellen. In diesem Fall hat der unsigned Datentyp einen höheren Rang, so dass als Zieltyp unsigend long gewählt wird(Regel 7)

Regel 7 bedeutet die Gefahr eines Konvertierungsverlustes, dessen man sich bewusst sein sollte! Diese tritt insb. dann in Kraft, wenn ein Operand vom Typ 'unsigned long long' ist (64-Bit).

Für die Konvertierung von Pointer, Arrays, Strukturen, Unions zu anderen Datentypen, als sich selbst gilt Regel 8. In diesem Fall gibt der Compiler zumeist einen Error, in wenigen Ausnahmefällen eine Warning aus:

char *string;             //Pointer
int   arr[3];             //Array
struct {int x,y;} var1;   //Strukturen
union  {int x,y;} var2;   //Union
var1 = var2;    //Error Incompatible Types
string=arr;     //Error Incompatible Types
arr=var1;       //Error Assignment to expression with array type
                //(=incompatible Types)
string=var2;    //Error Incompatbiles Types

Öffnen im Compiler Explorer

Beispiele von impliziten Typumwandlungen:

char  varc=100;
short vars=100;
int   vari=100;
vars=varc + vars;
//Wird aufgrund der impliziten Regel wie folgt umgesetzt
vars=(short)((int)varc+(int)vars);

vari=5.0*(int)sin(vars);
//Wird aufgrund der impliziten Regel wie folgt umgesetzt
vari=(int)(5.0*(double)(int)sin((double)vars));
//Hinweis: da sin() nur Werte im Bereich -1…0…+1 zurückgibt kommen
//als Werte für a hier nur 5, 0 und -5 in Frage!

Öffnen im Compiler Explorer

Hinweis:

  • Im CompilerExplorer können sie sich die impliziten Typumwandlungen anzeigen lassen. Dazu gehen sie bitte wie folgt vor:
  • Im Source-Fenster mit '+Add new' ein Compiler Fenster öffnen
  • Im Compiler-Fenster mit '+Add new' ein 'GCC Tree/RTL' Fenster öffnen
  • Im GCC Tree/RTL-Fenster unter 'Select a pass…' 'original tree' auswählen


Um implizite Typumwandlung besser erkennen zu können, wird oftmals bei Variablennamen die 'Ungarische Notation' angewendet (siehe   Ungarische Notation). Aufgrund der besonderen Namensgebung kann der Programmierer ohne großen Aufwand frühzeitig mögliche Typkonflikte erkennen. Der Variablenname setzt sich wie folgt zusammen:

{Präfix}{Datentyp}{Bezeichner}
Präfix: p->Pointer h->Handle i->index c->count f->flag rg->Array
Datentyp: ch->Character st->string w->word b->byte…
Bezeichner: Zum Binden der Variable an eine konkrete Aufgabe (keine Unterstriche).
Bezeichner ist optional, wenn aus Präfix und Datentyp die 'Aufgabe' der Variable direkt sichtbar ist

Beispiele:

char  rgchtemp[10];   //Array (Range) vom Datentyp character
int   ich;            //Index zum Adressieren eines Arrays vom Datentyp
                      //character

Explizite Typumwandlung

Bearbeiten

Programmierer erzwingt durch explizite Typumwandlung (auch CASTen genannt) eine Umwandlung eines Datentyps in einen anderen. Dazu wird der Zieldatentyp in runden Klammern vor Quelldatentype geschrieben:

short a=4;
double b=(double)(a+1);   //Das Ergebnis von (a+1) wird nach double gecastet
                          //(Addition erfolgt auf Basis des Datentyps.
                          //integer)
double c=(double)a+1;     //a wird nach double gecastet, so dass nachfolgende
                          //Addition auf Basis von double basiert.

Öffnen im Compiler Explorer

In der Rangfolge der Operatoren steht der Cast-Operator unterhalb von bspw. Funktionsaufrufen, Arrayzugriffen aber auch der Dereferenzierung:

Priorität Symbol Assoziativität Bedeutung
15 ... ... ...
14

++/-- (Präfix)
+/- (Vorzeichen)
!/~
&
*
(TYP)
...

R-L

Präfix-Inkrement/Dekrement
Vorzeichen
Logisches/Bitweises Nicht
Adresse
Zeigerdereferenzierung
Typumwandlung (Explizites Cast)
...

13
  • / %
L-R Multiplikation/Division/Modulo
12 ... ... ...

Im Zweifel gilt auch hier, den zu wandelnden Typ ergänzend zu klammern.

Hinweis:

  • Bedenke, dass die explizite Typumwandlung so aufgestellt sein sollte, dass der Zieldatentyp dem notwendigen Datentyp entspricht. Andernfalls wendet der Compiler ergänzend eine implizierte Typumwandlung an:
int    a;
double b=4.7;
short  c1= (long)(  (float)a+b );
//b ist vom Typ double, so dass a nach dem Cast auf float 
//   implizit vom Compiler auf double gecastet wird
//c ist vom Typ short, so dass das Ergebnis der Addtion nach dem expliziten 
//Cast auf long auf short gecastet wird.
//Nach Anwendung der impliziten Cast sieht der Ausdruck wie folgt aus:
short c2=(short)(long)( (double)(float)a+4.7);

Öffnen im Compiler Explorer

Mittels expliziter Typumwandlung können Ganzzahl nach Gleitkommazahlen (und andersherum) und Zeiger in einen anderen Zeiger und auf andere Datentypen gewandelt werden. Eine Konvertierung von Arrays, Strukturen, Unions zu anderen Datentypen als sich selbst ist weiterhin nicht möglich:

char *string;                 //Pointer
int arr[3];                   //Array
struct stru {int x,y;} var1;  //Strukturen
union  unio {int x,y;} var2;  //Union

var1 = (struct stru) var2;    //Error Conversion to non-scalar type
string=(char *)arr;           //OK
arr=(int *)var1;              //Error Assignment to expression with array type
string=(char *)var2;          //Error cannot convert to pointer type

Öffnen im Compiler Explorer

Bei den möglichen Konvertierungen sollte Folgendes berücksichtigt werden:

  • Vorzeichenlose GanzzahlVorzeichenlose Ganzzahl: Wenn der Zieldatentyp größer ist, wird die Zahl durch führende '0' erweitert. Wenn der Zieldatentyp kleiner ist, werde die zu vielen Bitstellen abgeschnitten/verworfen (ggf. Datenverlust).
  • Vorzeichenbehaftete GanzzahlVorzeichenbehaftete Ganzzahl: Wenn der Zieldatentyp größer ist, wird vorzeichenrichtig erweitertet (d.h. das Auffüllen erfolgt auf Basis des Vorzeichens). Wenn der Zieldatentyp kleiner ist, werden auch hier die zu vielen Bits abgeschnitten/verworfen (Vorsicht: Negative Zahlen können dabei in positive Zahlen gewandelt werden)
  • GleitkommazahlGleitkommazahl: Hier wird vereinfacht ausgedrückt sowohl die Mantisse als auch der Exponent einzeln kopiert. Wenn die Zielmantisse kleiner ist, gehen 'Nachkommastellen' verloren. Wenn der Quellexponent einen größeren Wert beinhaltet, als der Zielexponent 'aufnehmen' kann, so wird die Zahl auf Unendlich gesetzt. Umgedreht, wenn der Quellexponent kleiner ist, als der Zielexponent 'aufnehmen' kann, so wird die Zahl auf 0 gesetzt
  • GanzzahlGleitkommazahl: Hier wird, vereinfacht ausgedrückt, die Ganzzahl in die Mantisse kopiert. Um Datenverluste zu vermeiden, sollte die Bitbreite der Zielmantisse größer gleich der Bitbreite der Ganzzahl sein
  • GleitkommazahlGanzzahl: Im Wesentlichen wird hier die Mantisse übernommen, so dass hier die Bitbreite des Zieldatentyps mindestens der Bitbreite der Mantisse sein sollte
  • ZeigerGanzzahl: Die Konvertierung ist möglich. Da bei 64-Bit Systemen der Zeiger 64-Bit und Integer 32-Bit breit ist, meckert ggf. der Compiler. Für solche Fälle existieren die Datentypen 'intptr_t' und 'uintptr_t', über welche sichergestellt ist, dass ein Zeiger in einen Ganzzahldatentyp gespeichert werden kann!
  • ZeigerGleitkommazahl: Diese Konvertierung ist nicht möglich!
  • Struktur/UnionGanzzahl/Gleitkommazahl: Diese Konvertierung ist nicht möglich. Es können nur einzelnen Strukturelemente konvertiert werden, sofern diese vom Typ Ganzzahl/Gleitkommazahl sind
  • ArrayGanzzahl/Gleitkommazahl: Der Arrayname entspricht einen Zeiger, so dass hier ein Zeiger in eine Ganzzahl oder umgedreht konvertiert wird (siehe Zeiger ↔ Ganzzahl)

In C++ gibt es weitere CAST-Operatoren, auf welche hier derzeit noch nicht weiter eingegangen wird!

  • Static_cast (Entspricht dem Expliziten Cast)
  • Const_cast
  • Dynamic_cast
  • Reinterpret_cast

Struktur/Verbundtyp

Bearbeiten

Nach dem Wikipedia Artikel   Verbund (Datentyp) ist ein Verbund (englisch object composition) ist ein Datentyp, der aus einem oder mehreren Datentypen zusammengesetzt wurde. Die Komponenten können wiederum Verbünde enthalten, wodurch auch komplexe Datenstrukturen definiert werden können.
Für jedes Strukturelement wird Speicher reserviert. Alle Strukturelemente liegen hintereinander im Speicher.

Syntax: struct StrukturnameOpt {Datentype Strukturelementname; … }OPT VariablenlisteOpt;

Ein Verbund entspricht einer Java Klasse mit dem Unterschied zu C (nicht C++) , dass ein Verbund keine Methoden beinhaltet und keine Zugriffsbeschränkung für die Attribute gesetzt werden können.

Der Syntax erlaubt es, gleichermaßen einen Datentyp (über Strukturname) zu definieren und Variablen von diesem Datentyp (über Variablenliste) anzulegen. Da beide Elemente optional sind, ergeben sich diverse Kombinationsmöglichkeiten:

  • Nur Strukturname → Definition eines neuen Datentyps
struct xyz1 {int x; int y,z;};   //Definition eines neuen Datentyps
struct xyz1 var1;                //Definition einer Variablen dieses
                                 //Datentyps
var1.x=7;                        //Zugriff auf ein Strukturelement

Öffnen im Compiler Explorer

C: Der Strukturname ist nur mit dem vorangestellten struct gültig. Der Strukturname stellt somit einen eigenen Namensraum, getrennt von dem Namensraum der Variablen/Funktionen, dar. Daher kann ein Strukturname identisch zu einem Variablennamen sein:
struct xyz2 {int x,y,z;};      //Definition des Datentyps 'struct xyz2'
struct xyz2 xyz2;              //Definition der Variablen 'xyz2' auf
                               //Basis des Datentyps 'struct xyz2'
xyz2 var2;                     //Compilerfehler, zur Nutzung des Datentyps
                               //'struct xyz2' muss struct vorangestellt 
                               //werden!

Öffnen im Compiler Explorer

C++: Der Strukturname ist sowohl mit als auch ohne dem vorangestellten struct gültig. Auch hier stellt der Strukturname einen eigenen Namensraum dar, der jedoch eine niedrigere Priorität als der der Variablen hat:
struct xyz3 {int x,y,z;};
xyz3 xyz3={1,1,1};        //'xyz3' beschreibt hier den Datentypen
xyz3.x=1;                 //und nach Definition einer Variable die Variable!
xyz3 var2;                //Fehler, xyz beschreibt hier die Variable

Öffnen im Compiler Explorer

  • Nur Variablenliste → Definition einer/mehrerer Variablen von einem 'unnamed' Datentyp
struct {
  int x,y,z;
} xyz,arr[3],*ptr=&xyz;  //Definition von Variablen des Datentyps 
                         //struct {...}. Da dieser Datentyp nicht benannt
                         //wurde, kann im späteren keine weitere Variable
                         //von diesem Datentyp angelegt werden.
                         
xyz.x    =8;             //Nutzung der Variablen
arr[1].y =9;
ptr->x   = arr[1].y;

Öffnen im Compiler Explorer

Angewendet wird die Schreibweise gerne, wenn eine Struktur innerhalb einer Struktur definiert wird und die innere Struktur zur besseren Strukturierung dient:
struct aussen {         //Äußere Struktur
  struct {              //Innere Struktur
    int a,b,c;
  } anwendung1;
  struct {              //Innere Struktur
    int x,y,z;
  } anwendung2;
};
struct aussen var;

var.anwendung1.a=10;    //Nutzung der Variablen
var.anwendung2.y=12;

Öffnen im Compiler Explorer

  • Strukturname und Variablenliste → Definition eines Datentyps und Definition von Variablen. D.h. es können im Nachhinein weitere Variablen von diesem Datentyp angelegt und die hier angelegten Variablen genutzt werden.
struct xyz{                 //Definition eines Datentyps
  int x,y,z;
} var1,*ptr1;               //und Definition von Variablen dieses Datentyps
struct xyz  var2,*ptr2;     //Definition weiterer Variablen dieses Datentyps

var1.x=var2.y;              //Nutzung der Variablen

Öffnen im Compiler Explorer

  • Kein Strukturname und keine Variablenliste → "Anonymous Struct", d.h. Definition eines 'unnamed' Datentyps, von der ergänzend keine Variable angelegt wird. Anonymus Structs sind nur innerhalb von Strukturen erlaubt/einsetzbar. Hier dienen sie der besseren Strukturierung und ersparen dem Programmierer die Benennung des normalerweise notwendigen Strukturelementes:
struct außen {
  int a;
  struct {
    int b,c,d;
  } anwendung1;   //'Normale' innere Struktur
  struct {
    int x,y,z;
  };               //Anonyme Struct als innere Struktur
} var;

var.a=4711;        //Nutzung der Variablen
var.anwendung1.b=1;//Bei Zugriff auf anonyme Struktur ist die Benennung
                   //des Strukturelementes notwendig
var.x=1;           //'Einfacherer' Zugriff auf inneres Element bei 
                   //'anonymous struct'

Öffnen im Compiler Explorer

Hinweise:

Strukturelement

Bearbeiten

Innerhalb der Struktur können beliebige Strukturelemente von beliebigen Datentypen angelegt werden. Einzige Voraussetzung, der Datentyp des anzulegenden Strukturelementes muss zuvor definiert worden sein. Ergänzend kann der neue Datentyp auch in der Struktur selbst definiert werden, sofern mit der Definition des Datentyps auch eine Variable angelegt wird:

struct abc {
  int a,b,c;
};                   //Definition des Datentyps struct abc
struct xyz {         //Definition des Datentyps struct xyz
  int x,y,z;         //Strukturelement vom Type int
  struct abc abc;    //Strukturelement vom Type struct abc
  struct def {       //Definition eines neuen Datentyps innerhalb
    int d,e,f;       //der Struktur bei gleichzeitigen Anlegen zweier
  } def,geh;         //Strukturelementen von diesem neuen Datentyp
  struct uvw {
    int u,v,w;       //Warning, reine Datentypdefinition innerhalb
  };                 //einer Struktur nicht möglich
};
struct def abc;      //Innere Datentypbeschreibung auch
                     //außerhalb der Struktur nutzbar

Öffnen im Compiler Explorer

Zugriff auf Strukturelemente

Bearbeiten

Die Programmiersprache C unterscheidet zwei 'Zugriffsarten' auf die Strukturelemente, abhängig vom zugrundeliegenden Datentyp:

  • Handelt es sich beim zugrundeliegenden Datentyp um eine (struct)Variablen, so erfolgt der Zugriff über den Punkt-Operator .:
struct {
  int a,b,c;
} abc;
abc.a=4711;     //abc ist eine Variable einer Struktur
abc.b=abc.c;

Öffnen im Compiler Explorer

  • Handelt es sich beim zugrundeliegenden Datentyp um einen Zeiger auf eine (struct)Variable, so erfolgt der Zugriff über den Zeiger-Operator ->:
struct xyz {
  int x,y,z;
};
struct xyz  var1;  //Variable von Datentyp struct xyz;
struct xyz *ptr1;  //Zeiger auf eine Struktur vom Datentyp struct xyz
ptr1=&var1;        //Initialisierung des Zeigers 
                   //(mit der Adresse der Variablen var1)
var1.x=  7;        //Zugriff auf das Strukturelement x über die Variable var1
ptr1->x=7;         //Zugriff auf das Strukturelement x über den Zeiger ptr1

(*ptr1).x=7;       //Der Zeiger-Operator entspricht dem Zugriff auf ein
                   //dereferenziertes Strukturelement

Öffnen im Compiler Explorer

Werden innerhalb von Strukturen weitere Strukturen und Zeiger auf Strukturen angelegt, so müssen diese Regeln auf jedes innere Strukturelement einzeln angewendet werden:

struct abc {          //Äußere Struktur
 int a,b,c;
 struct xyz {
   int x,y,z;
  } xyz;              //Inneres Strukturelement xyz vom Datentyp struct xyz
  struct {
    char str[10];
  } strstr;           //Inneres Strukturelement strstr vom Datentyp 
                      //struct strstr
  struct xyz *ptr;    //Inneres Strukturelement ptr vom Datentyp Zeiger 
                      //auf struct xyz
} var2,*ptr;
	
var2.a=8;              //var2 vom Datentyp 'struct abc'. Zugriff über '.'
var2.xyz.x=1;          //xyz vom Datentyp 'struct xyz'. Zugriff über '.'
var2.strstr.str[0]='a';//strstr vom anonymes Struct. Zugriff über '.'

ptr=&var2;            //ptr mit einer Adresse initialisieren
var2.ptr = &var2.xyz; //Strukturelement ptr mit einer Adresse initialisieren

ptr->a='a';           //ptr vom Datentyp 'Zeiger auf struct abc'
                      //Zugriff über '->' 
var2.ptr->x=3;        //ptr vom Datentyp 'Zeiger auf struct xyz'.
                      //Zugriff über '->'
ptr->xyz.x=3;
ptr->ptr->x=3;

Öffnen im Compiler Explorer

Initialisierung von Strukturen

Bearbeiten

Mit dem Anlegen einer Strukturvariablen kann diese auch initialisiert werden. Eine 'Vorbelegung' der Strukturelemente bei der Definition der Struktur ist in C nicht möglich.
Die Initialisierung erfolgt über eine Initialisierungsliste (siehe Kap Grundlagen:Initialisierungsliste / Compound Literal). Innerhalb der Initialisierungsliste stehen die Initialisierungswerte entweder in der Reihenfolge der Datentypdefinition oder werden explizit mit dem ‘.‘ Operator angesprochen (designated initializers). Werden einzelne Strukturelemente ausgelassen, so werden diese mit 0 initialisiert:

struct abc{
  int a,b;
  int c=3;    //KO: Kein Initialisierungswert von Strukturelementen möglich
  char str[10];
};
struct abc v1={1,2,"hallo"}; //Alle Strukturelemente werden initialisiert
struct abc v2={ 1,2 };       //Initialisierung der Strukturlemente a und b
                             //Rest wird mit 0 initialisiert
struct abc v3={ .b=3};  //Initialisierung einzelner/designated Strukturele.
                        //Rest wird mit 0 initialisiert 
struct abc v4={.a=strlen(v1.str)}; //Initialisierungswert ergibt
                                    //sich erst zur Laufzeit.
                                    //Nur als lokale Variable möglich!
struct abc v5={.b=1,.a=2,.b=12}; //Nur C: Reihenfolge und Doppelbenennung
                                 //der Strukturelemente egal/möglich
struct abc v6={};  //Alle Strukturelemente mit 0 initialisieren

Öffnen im Compiler Explorer

Eine Initialisierung einer Struktur über nachfolgende Art bewirkt etwas anderes, als erwartet:

struct abc { int a,b,c; };
struct abc var={var.b=12,var.c=3};

Öffnen im Compiler Explorer

Innerhalb der Initialisierungsliste wird über 'var.b=12' und 'var.c=3' die zu diesem Zeitpunkt noch nicht initialisierte Variable var die Elemente b und c initialisiert. Mit dem Werten der Initialisierungsliste (hier {12,3}) wird nachfolgend die Variable erneut initialisiert und 12 dem Strukturelement a und 3 dem Strukturelement b zugewiesen. Abhängig vom Compiler ist das Strukturelement c entweder 3 oder 0.

Bei verschachtelten Strukturen muss entsprechend obiger Aussage für jede innere Struktur eine eigene Initialisierungsliste erstellt werden. Die inneren Initialisierungslisten können entfallen, was jedoch nicht empfohlen wird und ergänzend vom Compiler bei '-Wall' als Warning angemerkt wird:

struct abc {
  int a,b;
  struct xy {
    int x,y;
  } xy;
  struct xy arr[2];
};
struct abc v1={     //Initialisierung über Reihenfolge
  1,2,
  {3,4},
  { {5,6},{7,8}}
};
struct abc v2={     //Initialisierung über 'designated initializers'
  .arr={{.x=5,.y=6},{7,8}},
  .xy={3,4},
  .a=1,.b=2
};
struct abc v3={     //Fehlende innere Initialisierungsliste
  1,2,
  3,4,
  5,6,7,8
};

Öffnen im Compiler Explorer

Zuweisen/Kopieren von Strukturen

Bearbeiten

Strukturen werden über den Namen als 'ganzes' angesprochen, so dass eine Zuweisung als 'ganzes' möglich ist. Bei bspw. einer 12-Byte großen Struktur werden bei Nutzung der Variable die gesamten 12-Byte gelesen/geändert!

struct xyz {int x,y,z;} var1,var2;
var1=var2;           //12-Bytes kopiert
//--> entspricht memcpy(&var1,&var2,sizeof(xyz));

Die Zuweisung als 'ganzes' gilt auch bei der Parameterübergabe und -rückgabe von Funktionen:

struct xyz {int x,y,z;};
//Funktionsdefinition
struct xyz function(struct xyz par) { 	
  return (struct xyz){par.z,par.y,par.x};
}
//Funktionsaufruf
struct xyz var=function((struct xyz){1,2,3});
//Mit dem Aufruf der Funktion werden 12-Bytes in die Variable par kopiert
//Mit dem Ende der Funktion werden 12-Bytes in die Variable var kopiert

Bedenke, dass bei großen Strukturen das Kopieren Rechenzeit benötigt und folglich vermieden werden sollte! Alternativ sollten Zeiger auf Strukturen über- und zurückgeben werden.

Über Compound Literal (siehe Kap. Grundlagen:Initialisierungsliste / Compound Literal) kann einer Strukturvariablen als 'ganzes' einen neuen Wert zugewiesen werden:

struct abc {int a,b,c;};
var1={.a=7};                 //KO, Initialisierungsliste kann hier nicht
                             //angewendet werden
var1=(struct abc){.a=7};     //Wertezuweisung über Compound Literal möglich

Formel gesehen entspricht das Compound Literal einer 'unnamed Variable', die als erstes angelegt und initialisiert wird. Der Inhalt dieser Variablen wird im Anschluss der eigentlichen Variable zugewiesen!

Prototyp / Deklaration einer Struktur

Bearbeiten

Soll der Datentyp einer Struktur genutzt werden, der erst an weiter hinten liegenden Stellen im Programm definiert wird, so ist wie bei Variablen/Funktionen ein Prototyp/Deklaration notwendig:

struct abc;                //Prototyp der Struktur abc
...
struct abc { int a,b,c;};  //Definition der Struktur abc

Die Deklaration sagt einzig aus, dass die Struktur später definiert wird, beinhaltet aber nicht die Strukturelemente. Folglich kann auf Basis dieses Prototyps keine Variable definiert werden, sondern einzig ein Zeiger auf solche eine Struktur:

struct xyz;           //Deklaration / Prototyp des Datentyps
                      //    d.h. keine Benennung der Strukturelemente
struct xyz var1;      //KO, es kann auf Basis der Deklaration keine
                      //    Variable angelegt werden
struct xyz *pttr1;    //OK, von einer Strukturdeklaration kann ein Zeiger
                      //    angelegt werden!

Öffnen im Compiler Explorer

Anwendung:

  • Struktur, welche auf sich selbst verweist (Verkettete Liste)
struct vl {            //Entspricht gleichermaßen einem Prototyp, so dass
                       //innerhalb dieser Struktur dieser Datentyp zum
                       //Anlegen eines Strukturelementes genutzt werden kann
    struct vl  *next;  //Zeiger auf das nächste Element
    char daten[100];
};

Öffnen im Compiler Explorer

  • Entsprechend der objektorientierten Programmierung, wenn alle Attribute der Klasse private sind:
class.h
struct class;  //Prototyp für Struktur
               //so dass Nutzer dieser 
//Struktur ein Zeiger auf diese anlegen,
//aber nicht auf die Strukturelemente
//zugreifen können.

//Prototyp der public Methoden
void konstruktor(struct class ** me);
main.c class.c
#include "class.h"

int main(int argc, char*argv[])
{
  struct class *obj1;
  konstruktor(&obj1);
  ..
  //Kein Zugriff auf 
  //Strukturelemente möglich
  return 0;
}
#include "class.h"
struct class {
  int attr1;
  int attr2;
};
void konstruktor(struct class ** me)
{
  struct class *this;
  this=(struct class *)
        malloc(sizeof(struct class));
  this->attr1=10;
  *me=this;
}

Öffnen im Compiler Explorer

Vergleichen von Strukturen

Bearbeiten

Ein Vergleich von Strukturen über den direkten Weg ist nicht möglich. Vielmehr müssen die Strukturelemente händisch verglichen werden:

struct xyz {int inx,y,z;} var1,var2;
if(var1 == var2)  //KO, ein Vergleich von Strukturvariablen ist nicht möglich

//Manueller Vergleich über den Vergleich der Strukturelemente
if(var1.x == var2.x && var1.y == var2.y && var1.z == var2.z)

Öffnen im Compiler Explorer

Hinweis

  • Bei Rechnerarchitekturen mit nicht ausgerichteter Speicherausrichtung (Alignment) kann ergänzend ein Vergleich über memcmp() erfolgen

Speicherplatzbedarf einer Struktur

Bearbeiten

Für jedes Strukturelement wird Speicher entsprechend der Größe des Datentyps reserviert. Der Speicherplatzbedarf der Gesamtstruktur ergibt sich aus der Summe der Strukturelemente:

struct xyz{
  int x;   //Strukturelement belegt 4 Byte Speicher
  int y;   //Strukturelement belegt 4 Byte Speicher
  int z;   //Strukturelement belegt 4 Byte Speicher
}var1;   //Speicherplatz der Struktur = 4+4+4 = 12 Byte
sizeof(var1)       //ergibt 12
sizeof(struct xyz) //ergibt 12

Öffnen im Compiler Explorer

Die Ermittlung der tatsächlichen Speichergröße einer Struktur erfolgt über den sizeof-Operator:

struct xyz{int x,y,z;} a;
sizeof(a);                    //=12 OK
sizeof(a.x);                  //=4  OK
sizeof(struct xyz);           //=12 OK
sizeof(struct xyz.x);         //    KO
//Ist dies dennoch notwendig, so kann dies über den Umweg eines Zeigers
//erfolgen:
sizeof(((struct xyz *)0)->x); //=4  OK

Öffnen im Compiler Explorer

Hinweis:

  • Bei Rechnerarchitekturen mit ausgerichteter Speicherausrichtung (Alignment) (siehe   Speicherausrichtung) kann der tatsächliche Speicherbedarf größer sein! Hier fügt der Compiler ggf. zwischen den Strukturelemente Füllbytes (Padding Bytes) ein
  • Die Reihenfolge der Strukturelemente wird vom Compiler nicht geändert. D.h. die Zuordnung der Speicherstellen zu den Strukturelementen erfolgt in der Definitionsreihenfolge

Interne Organisation

Bearbeiten

Compilerintern werden die Strukturelemente über einen Offset dargestellt. Das erste Strukturelement bekommt dabei immer den Offset 0 zugewiesen. Das nachfolgende Element bekommt als Offset die Größe des Datentyps des vorherigen Elementes zugewiesen. Usw..
Der Compiler setzt den Zugriff auf ein Strukturelement einer (Struktur)Variablen so um, dass er zunächst die Startadresse der Variablen holt und zu dieser den Offset des Strukturelementes addiert. Die Anzahl der zu lesenden/schreiben Bytes ergibt sich aus dem Datentyp des Strukturelementes:

struct xyz {         //Das Anlegen der Variable var bedeutet,
  int x,y,z;         //12Byte Speicher zu reservieren.
} var1;              //(Beispielhaft soll var1 die Speicheradressen 
                     //0x100..0x10B belegen). Der Zugriff auf
                     //die Variable erfolgt immer über die Startadresse
var1.x=7;            //Zugriff auf Speicheradresse 0x100+0 (Offset)
var1.y=8;            //Zugriff auf Speicheradresse 0x100+4 (Offset)
var1.z=9;            //Zugriff auf Speicheradresse 0x100+8 (Offset)

Öffnen im Compiler Explorer


Der Offset eines Strukturelementes kann mit dem offsetof-Operator ermittelt werden:

Syntax: offsetof(type,Strukturelement)

Wie beim sizeof-Operator gilt der offsetof-Operator als Konstantenausdruck und der Rückgabedatentyp ist size_t. Zur Nutzung des Operators muss die Header-Datei 'stddef.h' inkludiert werden:

#include <stddef.h>  //Zur Nutzung des OffsetOf Operators
struct xyz {int x,y,z;} var1;
offsetof(struct xyz,z)  --> 8   
offsetof(var1,z)        --> KO: Es wird ein Datentyp und 
                                keine Variable erwartet

Öffnen im Compiler Explorer

Explizites Cast

Bearbeiten

Eine Struktur kann nicht in einen anderen Datentyp und damit auch nicht in einen anderen Strukturdatentyp mit identischen Strukturelementen konvertiert werden:

struct xyz {int x,y,z;} xyz;
struct abc {int a,b,c;} abc;
int var;

xyz=abc;                  //KO, Datentyp struct xyz != struct abc
xyz=(struct xyz)abc;      //KO, siehe zuvor

int neu1 = (int) xyz;     //KO
int neu2 = (int) xyz.x;   //OK (Das Strukturelement x ist vom Datentyp int)
struct xyz neu2 = (struct xyz)3;  //KO

Öffnen im Compiler Explorer

Incomplete Array

Bearbeiten

Das letzte Element einer Struktur kann ein Array ohne Angabe einer Dimensionsgröße sein (Incomplete Array/Flexible Array Member). Bei der Definition einer Variablen von solch einem Datentyp wird in der Tat kein Speicher für das letzte Element reserviert, so dass dies 'händisch' erfolgen muss. Auf das Array kann ungeachtet dessen unproblematisch zugegriffen werden:

struct vl {
  struct vl *next; 
  char data[];   //incomplete Array / flexible Array Member
};
struct vl a;      //Es wird nur Speicher für den next Zeiger reserviert
a.data[0]='7';    //Syntax OK, jedoch erzeugt dies ein Laufzeitfehler,
                  //da kein Speicherplatz für das Incomplete Array reserviert
                  //wurde

Öffnen im Compiler Explorer

Die Speicherplatzreservierung für das incomplete Array kann auf zwei verschiedene Arten erfolgen:

  • Speicherplatzreservierung für das Incomplete Array über Variableninitialisierung (nur GCC-C und nur im Falle einer globalen Variablen)
struct vl ele1 = {
  .next = NULL,
  .data = {32, 31, 30}  //Durch Initialisierung der einzelnen Array Elemente
};  //Vorsicht, sizeof(ele) gibt dennoch nur 4/8 zurück
struct vl ele2 = {
  .next = NULL,
  .data[3-1] = 0  //Durch Initialisierung des letzten Array Elementes
};  //Vorsicht, sizeof(ele) gibt dennoch nur 4/8 zurück

Öffnen im Compiler Explorer

  • Speicherplatzreservierung für das Incomplete Array über malloc
struct vl *b=malloc(sizeof(struct vl)+  //Größe der Struktur
                    sizeof(char[3]  )); //Größe des Arrays
b->data[1]='7';           //OK, da mit dem Anlegen von b Speicher für 2 
                          //data Element reserviert wurde

Öffnen im Compiler Explorer


Das Incomplete Array bietet sich überall dort an, wo Daten gespeichert werden sollen, deren Größe sich erst zur Laufzeit ergibt.

Anwendung

Bearbeiten

Die Nutzung des Datentyps struct empfiehlt sich an vielen Stellen:

  • Zusammengehörende Daten zu Kapseln (entsprechend der objektorientierten Programmierung
  • Verkette Listen
  • Aufgrund der Typsicherheit zur Darstellung von sicherheitskritischen Aufgaben

Siehe Übungsbereich!

Der wesentliche Syntax von Strukturen wurde in C++ übernommen. D.h.:

  • das abschließende Semikolon
  • der Zugriff auf die Strukturelemente
  • Speicherplatzreservierung
  • die Definition von Variablen mit der Definition des Datentyps
  • ...

Ergänzend wurde in C++ der Datentyp class eingeführt, der weitestgehend identisch zu struct ist. Geändert/Hinzugefügt wurden folgende Sachverhalte:

  • Classen/Strukturen können Methoden haben
  • Operatoren (Zuweisungsoperator, Addition, ...) können überladen werden
  • Alle Strukturelemente (und Methoden) einer struct sind per default public
  • Alle Attribute (und Methoden) einer Class sind per default private
  • Zur Nutzung des Datentyps ist das führende struct nicht notwendig (dies bedingt dann auch, dass der Strukturdatentyp kein separater Namensraum ist)
struct xyz {
  int x,y,z;
};
xyz var_xyz;
  • Initialisierungswerte für Strukturelemente und Klassenattribute bei der Datentypbeschreibung angegeben werden können:
struct xyz {
  int x=3;
  int y=1;
  int z;
};
xyz var={7};    //var.x=7   var.y=1  var.z=0

Öffnen im Compiler Explorer

  • In C++ kann eine Struktur ebenfalls über 'designated initializers' initialisiert werden. Jedoch muss die Reihenfolge der Initialisierungselement identisch zur Datentypdefinition sein. Auch ist keine Doppeltbenennung erlaubt:
struct xyz {
  int x,y,z;
};
xyz var1={.x=1, .y=2, .z=3};  //OK, Reihenfolge identisch
xyz var2={.y=1, .z=3};        //OK, Reihenfolge identisch
xyz var3={.y=1, .x=0};        //KO, Reihenfolge nicht identisch
xyz var4={.x=1, .x=2};        //KO, keine Doppeltbenennung möglich

Öffnen im Compiler Explorer

Ähnlich wie eine Struktur ist ein Union ein Datentyp, der aus einem oder mehreren Datentypen zusammengesetzt wird. Bei sog. Union beginnen jedoch alle Komponenten nicht wie bei Strukturen an nacheinander folgenden Speicheradressen, sondern an der identischen Speicheradresse, d.h. ihre Speicherbereiche überlappen sich ganz oder zumindest teilweise. Eine Union kann folglich zu einem Zeitpunkt nur ein Element enthalten. Der benötigte Speicherplatz ergibt sich aus der größten Komponente.

Syntax: union UnionnameOpt {Datentyp Variablenname; …}Opt VariablenlisteOpt;

Ein Schutz/Zugriffssteuerung der Unionelemente ist nicht vorhanden. Es kann in beliebiger Reihenfolge auf die einzelnen Elemente zugegriffen werden.

Die Anwendung ist identisch wie bei Strukturen, so dass im Folgenden nur die Abweichungen zu den Strukturen beschrieben werden.

Speicherplatzbedarf und interne Struktur einer Union

Bearbeiten

Die Größe der Union ergibt sich aus dem größtem Unionelement. Alle Unionelemente überlappen sich, so dass der Offset zum Basiselement 0 ist:

union abc {
  char  a;
  short b;
  int   c;
} abc;
printf("Größe:   %zu\n",sizeof  (union abc));   //-> 4
printf("Offset a:%zu\n",offsetof(union abc,a)); //-> 0
printf("Offset b:%zu\n",offsetof(union abc,b)); //-> 0
printf("Offset c:%zu\n",offsetof(union abc,c)); //-> 0

Öffnen im Compiler Explorer

Wird ein kleineres Unionelement belegt und im Anschluss ein größeres Unionelement gelesen, so ist der Inhalt der durch das kleinere Element nicht beschriebenen Speicherstellen undefiniert:

union abc {
  char a;
  short b;
  int   c;
} abc;
abc.a=0x11;
printf("%08x",abc.c);

Öffnen im Compiler Explorer

Initialisierung von Union

Bearbeiten

Bei einer Union kann nur ein Element initialisiert werden, d.h. die Initialisierungsliste kann nur einen Wert beinhalten. Ohne explizite Benennung des Unionelementes in der Initialisierungsliste wird das erste Element aus der Datentypbeschreibung initialisiert. Mit expliziter Benennung mittels 'designated initializers' können auch andere Elemente initialisiert werden:

union abc {
  char a;
  short b;
  int c;
} abc;
	 
abc=3;                 //KO keine Initialisierungsliste
abc=(union abc){3};    //OK Initialisierung des ersten Elementes a
abc=(union abc){.b=3}; //OK Initialisierung des Elementes b 	 
abc=(union abc){3,4};  //KO nur ein Initialisiuerngswert erlaubt

Öffnen im Compiler Explorer

Anwendung

Bearbeiten

Mittels des Datentyps union können diverse Anwendungsfälle abgedeckt werden:

  • Über Union lassen sich größere Datentypen in kleinere Datentypen unterteilen, um z.B. einzelne Bytes zu extrahieren:
union floatu {
  float        var;       //Float ist 4-Byte groß
  unsigned int hex;       //Integer ist 4-Byte groß
  char         byte[4];   //Ohne Worte
};
union floatu var={1.234};
printf("%f\n",var.var);   //Darstellung des Float-Wertes
printf("%x\n",var.hex);   //Darstellung des Float-Wertes 'BinäreZahl'
printf("%hhx %hhx %hhx %hhx\n",   //Darstellung der einzelnen Bytes
  var.byte[0],var.byte[1],var.byte[2],var.byte[3]);

union longlong {
  long long ll;       //Long Long ist 8-Byte groß
  int       i[2];     //Ohne Worte
  short     s[4];
  char      c[8];
};
union longlong var2={0x123456789ABCDEFULL};
printf("%llx\n",var2.ll); 
printf("%x %x\n",var2.i[0],var2.i[1]);
//Vorsicht: Die Aufteilung der Bytes ist von der Rechnerarchitektur
//(Endianes) und von der Ausrichtung durch den Compiler abhängig

Öffnen im Compiler Explorer

  • Ein weiterer Anwendungsfall ergibt sich, wenn in einer Struktur unterschiedliche Datensätze gespeichert werden sollen (siehe   Tagged Union):
struct set {
  char *index;
  char *value;
};
struct get {
  char *index;
  char *value;
};
struct cli{
  //Enum zur Darstellung des aktiven Unionelementes hilfreich
  enum {SETTER,GETTER} tag;
  union {                //Interpretation abhängig von tag
    struct set set;
    struct get get;
  } ;
};
struct cli var={.tag=SETTER, .set.index="hallo"};

if(var.tag==SETTER)
  printf("Set: %s",var.set.index);
else
  printf("Get: %s",var.get.index);

Öffnen im Compiler Explorer

  • Mittels eines Tagged Union kann komfortabel ein Binärbaum realisiert werden:
//Quelle: https://github.com/Hirrolot/datatype99
struct BinaryTree; //Prototyp
struct BinaryTreeNode {
  struct BinaryTree *lhs;
  int x;
  struct BinaryTree *rhs;
} BinaryTreeNode_t;
struct BinaryTree {
  enum { Leaf, Node } tag;
  union {
    int leaf;
    struct BinaryTreeNode node;
  } data;
};
int sum(const struct BinaryTree *tree) {
  switch (tree->tag) {
    case Leaf:
      return tree->data.leaf;
    case Node:
      return sum(tree->data.node.lhs) + 
                 tree->data.node.x + 
             sum(tree->data.node.rhs);
  }
  return -1; // Invalid input (no such variant).
}

Öffnen im Compiler Explorer

Die Eigenschaften des Datentyps union entsprechen den Eigenschaften des Datentyps struct in C++.

Enum/Aufzählungstyp

Bearbeiten

Nach dem Wikipedia Artikel   Aufzählungstyp ist ein Aufzählungstyp (englisch enumerated type) ein Datentyp für Variablen mit einer endlichen Wertemenge. Alle zulässigen Werte des Aufzählungstyps werden bei der Deklaration des Datentyps mit einem eindeutigen Namen (Identifikator) definiert, sie sind Symbole.

Syntax: enum enumnameOpt {definition-list[=expression]}Opt VariablenlisteOpt

Die Anwendung ist identisch wie bei Strukturen, so dass nachfolgend nur die Abweichungen zu den Strukturen beschrieben werden.

Datentyp von enum

Bearbeiten

In C entspricht der Datentyp Enum dem Datentyp int so dass Zuweisungen/Vergleiche mit Ganzzahlen möglich sind. Ganzzahloperationen funktionieren ebenso:

enum STATUS {OK,KO=5};  //Definition des Datentyps
enum STATUS status;     //Definition einer Variable dieses Datentyps
status=OK;
if(status == 5)        
status++;           
status=5.7;

Öffnen im Compiler Explorer

Enumelemente

Bearbeiten

Die Elemente der Definitionsliste sind Integerkonstanten. Diese können auch in Kombination mit anderen Datentypen genutzt werden. Auch gibt es keinen gesonderten Namensraum für Elemente der Definitionsliste. Sie 'konkurrieren' folglich mit allen Variablen- und Funktionsnamen:

enum mode {OK, KO};
enum {FIRST,SECOND,LAST};  //Anonymes Enum!
enum {EINS,ZWEI,LAST};     //KO, Symbolname LAST bereits vergeben
enum mode var1=OK;
var1=4711;           //OK, enum entspricht Datentyp int
int var2=KO;         //OK, EnumElement entspricht Datentyp int
int OK=KO;           //KO, Symbolname OK bereits für EnumElement vergeben
var1=FIRST;          //OK, var1 und FIRST sind zwar unterschiedlich
                     //Enumtypen,jedoch entspricht beides dem Datentyp int

Öffnen im Compiler Explorer

Der erste Enumelement der Definitionsliste bekommt den Wert 0 zugewiesen. Folgeelemente bekommen den Wert des Vorgängerelementes +1 zugewiesen. Enumelemente können mit einer Konstanten (und Konstantenausdruck) initialisiert werden:

enum {bill=10, john=bill+2, fred=john+1} ;
//Negative Werte sind möglich
enum {error=-3, warning, info, ok};   //warning=-2  info=-1 …
//Doppelte Wertzuweisung sind möglich
enum {Ostfalia=10,Wolfenbuettel=10};
//Hinter dem letzten Enumelement kann ein Komma folgen
enum {test=10,};

Öffnen im Compiler Explorer

Hinweis:

  • Es empfiehlt sich, die Enumelemente in GROSSBUCHSTABEN zu schreiben. Hiermit wird gekennzeichnet, dass es sich um Konstanten und nicht um Variablen/Funktionen handelt

Anwendung

Bearbeiten

Die Nutzung des Datentyps enum empfiehlt sich überall dort, wo eine Fallunterscheidung notwendig ist:

  • Statusrückgabe von Funktionen:
enum STATUS {OK,MEMORY_OVERFLOW, DIVISION_BY_ZERO};
enum STATUS funct() {
    return MEMORY_OVERFLOW;
}
  • Beschreibung der Zustände und der Ereignisse eines Zustandsautomates:
enum EREIGNIS {EREIGNIS1,EREIGNIS2,EREIGNIS3};
void zustandsautomat(enum EREIGNIS ereignis) {
  static enum {IDLE,OPERAND,OPERATOR,} zustand=IDLE;
  switch(zustand) {
    case IDLE:
    case OPERAND:
	  //Compiler meckert bei fehlendem Case über OPERATOR
  }
}

Öffnen im Compiler Explorer

Ergänzend zu den Abweichungen bei Structs sind hier weitere Unterscheidungsmerkmale vorhanden:

  • Enums stellen einen eigenen Datentyp dar, der nicht mit int kompatible ist. Das bedingt unter anderem, dass einige Operatoren wie z.B. ++ nicht mehr auf Variablen des Datentyps enum angewendet werden. Die Enumelemente als solches sind jedoch kompatibel zum Ganzzahldatentyp:
enum Color {RED,GREN,BLUE};
enum Color var1=RED;
var1=4;              //KO
var1=var1+1;         //KO
var1++;              //KO
int var2=RED;        //OK
if(RED==2)           //OK

Öffnen im Compiler Explorer

  • Auch enums können Methoden besitzen.
  • Über Operatorüberladung können z.B. nicht vorhandene Operatoren wie ++/-- ergänzt werden
  • Über unscoped/scoped Enumerations wird der 'Namensraum' gesteuert:
  • Unscoped Enumeration (Elemente der Definitionsliste stehen wie bei C im Zugriff)
Syntax: enum nameOpt : typeOPT {enumerator=constexp, …};
enum Color {RED,GREN,BLUE };
Color r = RED;

Öffnen im Compiler Explorer

  • Scoped enumeration (Elemente der Definitionsliste haben einen eigenen 'Namensraum')
Syntax: enum class|struct nameOpt : typeOPT {enumerator=constexp, …};
	enum class Color {RED,GREN=20,BLUE };
	Color r = Color::BLUE;

Öffnen im Compiler Explorer

Bitfelder

Bearbeiten

Nach dem Wikipediaartikel   Bitfeld bezeichnet in der Informationstechnik und Programmierung ein Bitfeld ein vorzeichenloses Ganzzahldatentyp, in dem einzelne Bits oder Gruppen von Bits aneinandergereiht werden. Es stellt eine Art Verbunddatentyp auf Bit-Ebene dar. Im Gegensatz dazu steht der primitive Datentyp, bei dem der Wert aus allen Stellen gemeinsam gebildet wird.

Der Syntax entspricht dem struct-Syntax, mit der Ergänzung, dass hinter den Strukturelementen noch die Bitbreite getrennt durch ein Doppelpunkt angegeben wird:

struct time {
  unsigned int hour:   5;   //0..23  Bits 0..4
  unsigned int minute: 6;   //0..59  Bits 5..10
  Unsigend int second: 6;   //0..59  Bits 11..16
} myTime;

Als mögliche Datentypen für die Strukturelemente stehen nur die Ganzzahldatentypen int, short, long , long long und char zur Verfügung. Der Datentyp eines Strukturelementes muss mindestens die Anzahl der Bits enthalten, wie diese über die Bitbreite gefordert wird. Von den Strukturelementen kann keine Adresse bestimmt werden! Auch der offsetf - Operator schlägt fehl.

Der Datentyp entspricht im Wesentlichen einem Ganzzahlzahldatentyp, so dass bei Zugriff auf eine Variable dieses Datentyps eine Ganzzahl gelesen/geschrieben wird. Bei Zugriff auf die einzelnen Strukturelemente werden die entsprechenden Bits dieser Ganzzahlvariablen maskiert, so dass nur die betroffenen Bits geändert werden:

struct test {
  unsigned char first:2;
  unsigned char second:4;
  unsigned char third:2;
};
struct test var={.first=1,.second=1,.third=1};
//entspricht
unsigned char var1=0b01000101;

var.second=1;
//entspricht:
var1=(var1&0b11000011) | (1<<2);

var.second++;
//entspricht
int second =(var1>>2)&0b00001111;
second++;
var1=(var1&0b11000011) | ((second&0b00001111)<<2);

Öffnen im Compiler Explorer

Anwendung

Bearbeiten
  • Komprimierte Speicherung der Uhrzeit (wie dies z.B. in Realtimeclocks erfolgt)
union {
  struct {             //Datentyp zum 'Bitweisen' Zugriff
    unsigned int hour:5;
    unsigned int minute:6;
    unsigned int second:6;
    };
  unsigned int hex;    //Datentyp zum Zugriff aller Elemente
} myTime;
myTime.hour   = 13;
myTime.minute = 37;
myTime.second = 59;
printf("Zeit: %02d:%02d:%02d\n", myTime.hour,myTime.minute,myTime.second );
//Alternativer/Händischer Zugriff auf die einzelnen Bits
printf("Es ist jetzt %02d:%02d:%02d Uhr\n",((myTime.hex>> 0)&0b011111),
                                           ((myTime.hex>> 5)&0b111111),
                                           ((myTime.hex>>11)&0b111111));

Öffnen im Compiler Explorer

  • Aufteilung von FloatingPoint Zahlen in seine Komponenten
union float_mes {
  float flo;
  struct ieee { //aus ieee754.h kopiert
#if __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__
    unsigned int negative:1;
    unsigned int exponent:8;
    unsigned int mantissa:23;
#endif /* Big endian.  */
#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
    unsigned int mantissa:23;
    unsigned int exponent:8;
    unsigned int negative:1;
#endif /* Little endian.  */
  } ieee;
  int hex;
};
//aus ieee754.h kopiert
#define IEEE754_FLOAT_BIAS	0x7f /* Added to exponent.  */
union float_mes test={0.1};
printf("float: %f\n",test.flo);
printf("hex:   %x\n",test.hex);
printf("bin:   ");
for(unsigned int flag=0x80000000; flag; flag=flag>>1)
  printf("%c%s",test.hex&flag?'1':'0',
         flag&0x80800000?":":(flag&1?"\n":""));
printf("bitfield: (%c)%x * 2^(%d-%d)\n",(test.ieee.negative==1?'-':'+'),
                                         test.ieee.mantissa,
                                         test.ieee.exponent,
                                         IEEE754_FLOAT_BIAS);

Öffnen im Compiler Explorer

Bitfelder existieren mit den bekannten Ergänzungen auch in C++.

Datatype-/Storage Class Specifier

Bearbeiten

Typedef Storage Class Specifier

Bearbeiten

Anweisung zum Erstellen eines Alias für einen Datentyp.

Syntax: typedef T type_ident[,type_ident];

T ist der zu ersetzende Datentyp. Type_ident ist der Alias, wobei auch mehrere mit Komma separierte Aliase gesetzt werden können. Der Syntax entspricht einer normalen Variablendefinition mit vorangestellten typedef. Aufgrund des vorangestellten wird nun keine Variablen angelegt, sondern der 'Variablenname' ist der Alias.
Auf Basis des Alias können in Anschluss beliebig viele Variablen definiert werden:

typedef unsigned long long ull;
ull var1;

typedef signed long     sl1,sl2,*psl;
sl1   var2;               //--> signed long var2;
psl   var3;               //--> signed long *var3;
	
struct xyz {
  ull x,y,z;
};
typedef struct xyz   xyz_t;
xyz_t  var4;              //--> struct xyz var4;
	
typedef struct abc { int a,b,c;} abc_t;
abc_t       var5;   //Definition über Alias
struct abc  var6;   //Definition über struct abc
	
typedef struct {int d,e,f;}  def_t;  //Unnamed Struct 
def_t       var7;
	
typedef int arr_alias[4];
arr_alias arr;

typedef void func_alias(void);  //Alias für eine Funktion 
func_alias *func;               //Zeiger auf Funtion

Öffnen im Compiler Explorer

Anwendungsfälle von typedef sind:

  • Source-Code unabhängig von der Rechnerarchitektur zu entwickeln:
//Bitbreite des Datentyps int ist von der Rechnerarchitektur abhängig
#if ARCHITEKTUR == 16
typedef unsigned int uint16_t;
#elif ARCHITEKTUR == 32
typedef unsigned short uint16_t;
#endif
//Diese Definition sind in der Header-Datei stdint.h enthalten!

Öffnen im Compiler Explorer

  • Weniger Tipparbeit:
//Vermeidung des führende struct/union/enum
typedef enum {OK,KO,var11}  STATUS_T;
STATUS_T  var1;

//Vermeidung der Angabe weiterer Specifier
typedef const volatile unsigned int cvuint16_t;
cvuint16_t var2=4711;

Öffnen im Compiler Explorer

  • Dem Datentyp über seinen Namen eine Bedeutung zuzuordnen:
typedef  char * cpointer;
typedef struct { int raeder; enum {ROT,GELB}farbe;} auto_t;
typedef  void (*fptr_t)(void);

Öffnen im Compiler Explorer

Hinweise:

  • Zur Verdeutlichung, dass es sich bei dem Alias nicht um eine Variable oder Funktion handelt, wird oftmals der Suffix '_t' an den Alias angehängt
  • Der Alias konkurriert mit Variablen und Funktionsnamen, kann also nicht doppelt benutzt werden
  • Wird ein Typedef innerhalb eines Blockes definiert, so ist dieser nur innerhalb des Blockes gültig

Internal/External Linkage

Bearbeiten

C-Projekte bestehen aus mehreren C-Dateien, welche beim Compilieren in Objektdateien gewandelt werden und über den Linker zu einer ausführbaren Datei zusammengebunden werden. Der Zugriff auf Inhalte 'externer' Objektdateien wird über die 'internal', 'external' und 'none Linkage' gesteuert, wobei none gleichzusetzen mit external Linkage ist. External Linkage bedeutet, dass solche Variablen/Funktionen im gesamten Projekt (bestehend aus div. Objektdateien und Libraries) in Zugriff steht und somit nur einmal existiert (= globale Variable/Funktion). Internal Linkage bedeutet, dass die Variable/Funktionen nur in der aktuellen Objekt-/C-Datei im Zugriff stehen.

External Linkage / Extern Storage Class Specifier

Bearbeiten

Mit dem Storage Class Specifier extern wird ausgesagt, dass die Variable/Funktion 'external linkage' hat (also projektweit im Zugriff steht) und an dieser Stelle kein Speicherplatz für die Variable/Funktion definiert wird. Sie entspricht folglich dem Funktions-Prototyp mit der expliziten Aussage, dass diese Variable/Funktion 'anderweitig' definiert ist.

Syntax: extern Datentyp Variablenname;
        extern Datentyp Funktionsname(Datentyp,Datentyp);

Beispiel:

datei1.c datei2.c
extern int var; //Zugriff auf
                //'externe' Variable
extern int func(int par); //Zugriff
          //auf 'externe' Funktionen

int main(int argc, char *argv[])
{
  extern int var2;  //Zugriff auf 
            //'externe' vVariable
  func(var+var2);
}
int var;  //Definition der
          //'externen' Variable
int var2=8; //Definition der 
            //'externen' Variable

int func(int par) //Definition der
{                 //'externen' Funkt.
    return(par*2);
}

Öffnen im Compiler Explorer

Das Weglassen des Schlüsselwortes extern entspricht der Definition einer Variable/Funktion, d.h. der Speicherreservierung. Die Linkage ist in diesem Fall ebenfalls extern. Die Extern Anweisung wird benötigt:

  • wenn auf Funktionen / Variablen zugegriffen werden sollen, die erst weiter hinten im Code definiert werden und projektweit im Zugriff stehen
  • wenn auf Funktionen / Variablen zugegriffen werden, die in anderen Dateien enthalten sind
  • wenn auf Funktionen / Variablen zugegriffen werden, die in Libraries enthalten sind
  • wenn auf Funktionen / Variablen zugegriffen werden, die mit einer anderen Sprache übersetzt wurden

Hinweise:

  • Wenn in zwei unterschiedlichen Dateien eines Projektes je eine initialisierte globale Variable mit identischen Namen angelegt wird, so gibt der Linker den Fehler "multiple definition" dieser Variablen aus
  • Wie bereits erwähnt wurde Unix und C parallel entwickelt und einer der neuen Features von Unix war die Bereitstellung eines Linkers. Der Extern Storage Class Specifier ist eine Anweisung an den Linker, an den Stellen der Nutzung der Variablen/Funktion in der eigenen Objektdatei die Adresse der eigentlichen Variablen/Funktion aus einer anderen Objektdatei einzutragen

Internal Linkage / Static Storage Class Specifier

Bearbeiten

Die genaue Bedeutung ist abhängig davon, ob es sich um eine globale Funktion/Variable oder einer lokalen Variable handelt.

Static globale Variable / Static Funktionen

Bearbeiten

Weist den Compiler an, dass diese Variable/Funktion nur innerhalb dieser C-Datei genutzt wird (internal Linkage) und für anderen C-/Objekt-Dateien nicht im Zugriff stehen. Static entspricht dem Zugriffsmodifikator 'public' aus der objektorientierten Programmierung mit dem Unterschied, das der Zugriff auf diese Variable/Funktion nicht innerhalb des Objektes, sondern innerhalb der Datei beschränkt ist.
Ergänzend dient static der besseren Lesbarkeit des Codes (Zugriff auf diese Variable/Funktion nur aus dieser Datei). Auch der Compiler kann den Zugriff auf solche Variablen/Funktionen optimieren und schnelleren/kompakteren Code erzeugen.

Im Sinne der C-Spezifikation steht der Static Storage Class Specifier dafür, die Variable auf internal Linkage zu setzen (diese nach außen zu anderen Objektdateien nicht bekannt zu machen). Extern im Gegenzug steht für external Linkage. Die Nutzung beider Anweisungen zusammen widersprechen sich folglich und führen zu Compilerfehler. Auch bedeutet static, dass hiermit Speicherplatz reserviert wird (entgegen der 'extern' Anweisung, die eher einem Prototyp entspricht).
Für eine statische lokale Variable kann kein Prototyp erzeugt werden. Solche Variablen müssen immer vor der ersten Verwendung definiert werden! Beispiele:

  • Nutzung von static Variablen/Funktionen innerhalb einer Datei
//Prototypen basierend auf den unten stehenden statische Variablen/Funktionen
static void func(void);         //OK 
extern void func(void);         //KO (Prototyp muss ebenfalls static sein)
extern static void func(void);  //KO (extern und static wiedersprechen sich)
void func(void);                //KO (Prototyp muss ebenfalls static sein)
extern static int var1;         //KO a)extern und static widersprechen sich
                                //   b)von einer static Variable kann kein
                                //      Prototyp angelegt werden!
		 
int main(int argc, char *argv[]) {
  func();
  var1=1;                     //KO, da kein Prototyp möglich ist
  return 0;
}
//Definition von statische Variablen/Funktionen
static int var1;             //Static Variable
static void func(void) {     //Static Funktion
  printf("var1=%d",var1);
}

Öffnen im Compiler Explorer

  • Nutzung von static Variablen/Funktionen innerhalb mehrerer Dateien
Static entspricht dem private Zugriffsmodifikatior, mit dem Unterschied dass diese Variablen/Funktionen nur innerhalb der Datei gelten und in mehreren Dateien unabhängig voneinander existieren können
datei1.c datei2.c
static int var=4711;
static void func(void) {
   printf("datei1.c var=%d\n",var);
}
static int var=0715;
static void func(void) {
   printf("datei2.c var=%o\n",var);
}

Öffnen im Compiler Explorer

Static lokale Variable

Bearbeiten

Diese Variablen behalten über der Laufzeit der Funktion ihre Gültigkeit, d.h. sie verlieren nicht ihren Inhalt bei Beendigung der Funktion, sondern behalten den Wert bei. Static lokale Variablen entsprechen folglich einer globalen Variablen, mit dem Unterschied, dass von außerhalb der Funktion keiner auf diese Variable zugreifen kann. Dementsprechend erfolgt die Initialisierung wie bei globalen Variablen über Speichervorbelegung (Initialisierungswert muss eine Konstanten sein) und nicht zur Laufzeit bei jeden 'Aufruf'. Nichtinitialisierte Variablen werden entsprechend mit 0 initialisiert:

Static Lokale Variablen entsprechen weitestgehend
void func(void) {
  int var=7;  //Initialisierung erfolgt mit
              //jedem Funktionsaufruf
  var++;      //7->8  7->8  7->8
  static int summe=8;  //Initialisierung
                       //erfolgt vor Aufruf
                       //der main-Funktion
  summe++;  //8->9  9->10  10->11
}
//Namensverschleierung der static
//Variablen, so dass kein anderer
//auf diese Variablen 'zugreifen' 
//kann.
int $a$1_xyz=0;
void func(void) {
  int var=7;
  var++;
  $a$1_xyz++;
}

Öffnen im Compiler Explorer

Hinweis:

  • Wird eine Funktion mit statisch lokalen Variablen von nebenläufigen Prozessen aufgerufen, so besteht ein Dateninkonsistenzproblem. Solche Funktionen sind folglich nicht Nebenläufigkeitsfest. In der Spezifikation sind diese als MT-UNSAFE (MultiThread-Unsafe) gekennzeichnet. strtok() beispielsweise speichert die aktuelle Position in einer statische Variablen, so das diese Funktion nicht gleichzeitig von nebenläufigen Prozessen aufgerufen werden darf.

Datentyp spezifische Operatoren

Bearbeiten

C war einer der ersten Sprachen, die Datentypen eingeführt hat. Mit der Einführung der Datentypen wurde jedoch keine Möglichkeit geschaffen, den Datentyp abzufragen, zu vergleichen oder gar in einen String zu wandeln. Der Datentyp dient einzig der Reservierung von passend Speicher und der Auswahl der passenden Maschinensprachebefehle.

Mit der Weiterentwicklung der Sprache, resp. auf den Compilererweiterungen wurde Datentyp spezifische Operatoren ergänzt.

Typeof Operator

Bearbeiten

Mittels des Typeof Operator kann der Datentyp einer Variablen ermittelt werden.

Syntax: typeof (T) //GNU-C und C23
        __typeof__(T) //GNU-C

T ist wahlweise eine Variable, ein Datentyp oder eine Expression. typeof entspricht einem Konstantenausdruck und wird vom Compiler durch den ermittelten Datentyp ersetzt. Der Rückgabewert ist der ermittelte Datentyp, der nicht in einer Variablen gespeichert oder mit einem anderen Datentyp verglichen werden kann. Typeof kann einzig dazu genutzt werden, weitere Variablen von T anzulegen (entspricht somit dem auto Datentyp bei anderen Programmiersprachen):

typedef unsigned int UI;
UI var1[19];
typeof( UI ) var2;
typeof( int) var3;
typeof(var1) var4;
  var4[3]=1;
typeof(var1[2]) var5;
  var5=7;

struct {                  //Unnamed Struct
  int x,y,z;
}xyz;
typeof(xyz) abc;          //Definition einer neuen Variable von
                          //einem unnamed struct.

if(typeof(var1) == int)   //KO, Ersetzung findet zur Compilezeit
  printf("True");         //statt und der Vergleichsoperator kann 
                          //nicht auf Datentypen angewendet werden
//Typeof einer Expression
char var6;
typeof(var6+1) var7;      //Es wird mit nichts kleineren gerechnet
                          //als int. Resultierender Datentyp ist int!
printf("sizeof(var6)=%zu sizeof(var7)=%zu\n",sizeof(var6),sizeof(var7));

Öffnen im Compiler Explorer

Anwendung:

  • Zur Darstellung von generischen Function-Like Makros (siehe Präprozessor:Function-Like Makros), so dass auf Basis von Typeof lokale Variablen vom Datentyp der 'übergebenen' Variablen erzeugt werden können:
#define SWAP1(a,b)   ({typeof(*a) dummy=*a; *a=*b; *b=dummy;})
#define SWAP2(a,b)   ({auto       dummy=*a; *a=*b; *b=dummy;})  //C++
int a=7;
int b=8;
SWAP1(&a,&b);

Öffnen im Compiler Explorer

Hinweis:

  • typeof war bis C23 eine GCC-Compilererweiterung. Mit C23 wurde diese Erweiterung in den Standard übernommen

_Generic Operator

Bearbeiten

Mit C11 wurde der _Generic Operator, bei dem ähnlich wie bei einer Switch-Anweisung anstatt über eine Ganzzahl über Datentypen selektiert werden kann, eingeführt. Wie auch der typeof Operator wird der _Generic Operator nicht zur Laufzeit, sondern zur Compilezeit ausgewertet.

Syntax: _Generic(controlling-expression, association-list)
wobei die association-list eine kommaseparierte Liste bestehend aus
type-name : expression
default : expression
ist

Die controlling-expression kann hierbei ein beliebiger Ausdruck sein, der einen Datentyp zurückgibt. Der Ausdruck selbst wird nicht ausgeführt! type-name kann ein beliebiger Datentyp sein. Sofern dieser identisch zum resultierenden Datentyp der controlling-expression ist, wird der gesamte _Generic-Operator ähnlich wie bei einem Objekt-Like Makro (siehe Präprozessor:Object-Like Makros) durch expression ersetzt.

default wird genutzt, wenn kein kompatibler Datentyp gefunden wird. Wird default nicht genutzt und es ist kein kompatibler Datentyp in der association-list enthalten, so gibt es einen Compilerfehler.

Einsatz findet der _Generic-Operator vorrangig in Verbindung mit Makros, so dass Generische Funktionen darstellbar sind:

#define sqrt(X) _Generic((X), \
              long double: sqrtl, \
                  default: sqrt,  \
                    float: sqrtf  \
              )(X)
long double x=sqrt((long double)par);  //sqrt wird durch sqrtl ersetzt
const int   y=sqrt((long long)par);    //sqrt wird durch sqrt ersetzt
      float z=sqrt((float)par);        //sqrt wird durch sqrtf ersetzt

Öffnen im Compiler Explorer

Hinweise:

  • Aufgrund der lvalue Konversion ist der kompatible Datentyp zu "abc" char * und nicht char[4]
  • Alle gültigen Datentypen sind erlaubt, somit auch Zeiger auf Funktion und void
  • Qualifiers wie const, volatile, restrictv und _Atomic werden nicht beachtet
  • Siehe auch Paul J. Lucas _Generic in C

Register Storage Class Specifier

Bearbeiten

Hinweis an den Compiler, dass diese lokale Variable 'häufig' genutzt wird und folglich der Compiler diese so optimieren soll, dass ein schneller Zugriff auf diese möglich ist.

Syntax: register Datentyp variablenname;

Der Storage Class Specifier kann nur auf lokale Variablen und auf Funktionsparameter angewendet werden. Hiermit wird der Compiler gebeten, die Variablen, sofern möglich, in einem Prozessorregister zu halten:

C-Programm x86 32-Bit Assemblerprogramm
         int var1=1;
register int var2=2;
         int var3=3;
if(var1==var2)
  printf("var1==var2");
  ...
  mov     DWORD PTR [ebp-12], 1 //var1
  mov     ebx, 2                //var2
  mov     DWORD PTR [ebp-16], 3 //var3
  cmp     DWORD PTR [ebp-12], ebx
  jne     .L2        //if(var1==var2)
  ...
.L2:

Öffnen im Compiler Explorer

Hinweise:

  • Moderne Compiler versuchen Variablen temporär im 'gecachten' Speicher zu legen oder teilen dem OS mit, dass die zugehörige Seite nicht ausgelagert werden darf. Folglich ist die Verwendung des Storage Class Specifier register nicht mehr nötig, bzw. wird sogar vom Compiler ignoriert
  • Ein Register hat keine Speicheradresse, so dass mit dem Adress-Operator keine Adresse ermittelt werden kann
  • Ein Debugger stellt beim Darstellen von Variablen den Speicherinhalt der Variablen dar. Aufgrund dessen, dass Compiler Variablen im gecachten Speicher temporär verwalten, kann der angezeigte Variableninhalt vom tatsächlichen Variableninhalt abweichen

Volatile Type Qualifier

Bearbeiten

Volatile 'informiert' den Compiler, dass die zugehörige Variable durch andere (nebenläufige Threads) manipuliert werden kann. Der Compiler schließt folglich diese Variable aus allen Optimierungsprozessen aus, so dass die Variable nicht über einen Sequence Point in Register zwischengespeichert werden darf. Stattdessen wird innerhalb eines Sequence Points bei einem Lesezugriff solch einer Variablen diese aus dem Speicher geladen resp. das Ergebnis zum Ende des Sequence Points zurückgeschrieben.

Syntax: volatile Datentyp variablenname;

Beispiel:

  • Nutzung einer 'normalen' cachebaren Variablen zum Datenaustausch zwischen Threads
Consumer-Thread Producer-Thread
int thread_run=0; //Cachebare Variable
void consumer(void) {
  //Warten, bis Producer 
  //Daten erzeugt hat!
  while(thread_run==0);
  //Consume Data
}
extern int thread_run;
void producer(void) {
  //Produce Data
  //Signalisiere Consumer, 
  //dass Daten bereitstehen
  thread_run=1;
}
x86 32-Bit Assemblerprogramm x86 32-Bit Assemblerprogramm
consumer:
  mov     eax, DWORD PTR thread_run
.L2:
  test    eax, eax
  je      .L2
  ret
producer:
  mov     DWORD PTR thread_run, 1
  ret

Öffnen im Compiler Explorer

Durch Compileroptimierung wird die Variable thread_run nur einmal aus dem Speicher gelesen und im jedem while-Zyklus der gecachte Inhalt (Inhalt der Variablen eax) genutzt. Änderungen in thread_run werden folglich nicht erkannt!
  • Nutzung einer volatile Variablen zum Datenaustausch zwischen Threads
Consumer-Thread Producer-Thread
volatile int thread_run=0; 
//None cacheable variable
void consumer(void) {
  //Warten, bis Producer 
  //Daten erzeugt hat!
  while(thread_run==0);
  //Consume Data
}
extern volatile int thread_run;
void producer(void) {
  //Produce Data
  //Signalisiere Consumer, 
  //dass Daten bereitstehen
  thread_run=1;
}
x86 32-Bit Assemblerprogramm x86 32-Bit Assemblerprogramm
consumer:
.L2:
  mov     eax, DWORD PTR thread_run
  test    eax, eax
  je      .L2
  ret
producer:
  mov     DWORD PTR thread_run, 1
  ret

Öffnen im Compiler Explorer

Volatile bedingt, dass der Inhalt der Variable thread_run bei jedem Schleifendurchlauf aus dem Speicher gelesen wird.

Anwendung:

  • wenn Variablen für den Datenaustausch zwischen Threads, zwischen Hauptprogramm und Signalhandler und zwischen dem Hauptprogramm und ISR genutzt werden. (Sorgt jedoch nicht dafür, dass die Daten konsistent sind! Diese müssen z.B. gesondert über Semaphoren oder _Atomic gesichert werden)
  • bei Hardwarezugriffen, da
  • ein Lesezugriff immer von der Peripherie und nicht aus einem Cache bedient wird:
ad1=*(uint32_t *)(0xFFFFF000);
ad2=*(uint32_t *)(0xFFFFF000);  //Hier erfolgt kein separater Lesezugriff
                                //Stattdessen optimiert der Compiler
                                //den zweiten Lesezugriff durch den 
                                //zuvor gelesen Wert weg!
  • Schreibzugriffe immer geschrieben werden und nicht wegoptimiert werden:
*(uint32_t *)(0xFFFFF004)=1;  //Compiler optimiert diesen Schreibzugriff weg
*(uint32_t *)(0xFFFFF004)=80; //und führt nur diesen aus!
Hardware/Peripherie kann wie ein nebenläufiger Thread angesehen werden!
  • zum 'Ausbremsen' einer For-Schleife, also zum aktiven Begrenzen der Ausführungsgeschwindigkeit eines Programmes:
for(volatile int delay=0;delay<100000;delay++)
   ...

Öffnen im Compiler Explorer

Auto Storage Class Specifier

Bearbeiten

In C ist der Storage Class Specifier auto ein Relikt aus der Vorgängersprache B, welche a) noch keine unterschiedlichen Datentypen kannte und b) lokale Variablen als solche explizit definiert werden mussten. Mit dem Schlüsselwort auto wurde dort ausgedrückt, dass es sich um eine lokale Variable (vom einzig vorhandenen Datentyp) handelt, resp. mit dem Schlüsselwort extern, dass es sich um eine globale Variable handelt.
In C (bis C17) wird mit auto (automatic storage Duration) dementsprechend ausgesagt, dass es sich um eine lokale Variable handelt. Wenn ergänzend kein Datentyp angegeben wird, wird der Datentyp der Variable implizit auf int gesetzt!

auto var;             //KO, Lokale Variable kann hier nicht angelegt werden
void foo(void){
  auto short a=1;     //Lokale Variable, Datentyp short
  auto b=1.0;         //Lokale Variable, Datentyp int (implicit int)
  printf("%zu %zu\n",sizeof(a),sizeof(b));
}

Öffnen im Compiler Explorer

Variablendefinitionen innerhalb von Funktion werden auch ohne dem Storage Class Specifier Auto auf automatic storage Duration gesetzt, so dass dieses Schlüsselwort eigentlich entfallen könnte. Da jedoch einige Librarys (z.B. OpenSSL) diesen Storage Class Specifier nutzen (siehe Type Inference for object definitions ), ist 'auto' weiterhin Bestandteil der Spezifikation.

Ab C23 und in C++ wird mit dem Schlüsselwort auto ausgedrückt, dass der Datentyp der Variablen sich aus dem Initialisierungswert ergibt:

void foo(void) {
  auto a=1;      //Datentyp int
  auto b=1.0;    //Datentyp double
  printf("%zu %zu\n",sizeof(a),sizeof(b));
}

Öffnen im Compiler Explorer

Im Gegensatz zu C++ hat das Schlüsselwort auto ab C23 diverse Einschränkungen (siehe auto in C23)

  • je Variabledefintion kann nur eine Variable definiert werden
auto a=1.0,b=2LL;   //KO

Öffnen im Compiler Explorer

  • es kann kein Zeiger hierüber definiert werden
short b;
auto *a=&b;

Öffnen im Compiler Explorer

  • auto kann nicht als Rückgabeparameter oder Übergabeparameter von Funktionen genutzt werden:
auto foo(auto par1, auto par2) {}

Öffnen im Compiler Explorer

Const Type Qualifier

Bearbeiten

Mit dem Type Qualifier const wird eine Variable auf nur lesbaren Zugriff gesetzt.

Syntax: Datentyp const Variablenname;
        const Datentyp Variablenname;

Die Wertzuweisung solcher Variablen erfolgt über die Initialisierung. Der Schreibschutz erfolgt im Wesentlichen zur Compilezeit, welcher den Compilevorgang abbricht, wenn eine Zuweisung an solche eine Variable erfolgt (eine Const Variable kann kein lvalue sein):

      int var0;    //ReadWrite
const int var1=7;  //ReadOnly
int const var2;    //wenn var2 ein globale Variable, dann 0, 
                   //andernfalls Zufallswert
var1++;            //KO 
var2=var0;         //KO Datentyp (const int)=(int)
var0=var1;         //OK Datentyp (int)=(const int)

Öffnen im Compiler Explorer

Zur Laufzeit erfolgt keine Kontrolle. Wird bspw. die Adresse solch einer Variablen in einem Zeigervariablen gespeichert und diese im Anschluss dereferenziert, so hängt das Laufzeitverhalten von diversen Faktoren ab:

const int var=7;
int *ptr=&var;
*ptr=4711;      //Schreibender Zugriff auf var. 
                //bei lokaler Variable führt dies zur Änderung der Konstanten
                //bei globaler Variable führt dies zum Programmabsturz

Öffnen im Compiler Explorer

Alle C-Compiler weisen in der Regel globalen und static lokalen Const-Variablen einer separaten Speicherklasse zu (rodata-Segment). In der Regel wird diese Speicherklasse bei:

  • Embedded Systemen in der Tat nur im Speicher mit reinen Lesezugriff gehalten (ROM, Flash). Ein indirekter Schreibzugriff auf solche Variable bewirkt keine Änderung. Das Programm wird normal fortgeführt
  • Systemen mit MMU (Windows, Linux, macOS) in einen Speicherbereich gehalten, der durch die MMU schreibgeschützt wird. Ein indirektes Schreiben auf diesen Bereich bewirkt somit ein Laufzeitfehler (Segmentation fault), verursacht durch die MMU

Const lokale Variablen werden wie normale lokale Variablen auf dem Stack gehalten, so dass hier kein zusätzlicher Schutz besteht. Ein indirektes Schreiben solch einer Variablen führt zur Änderung der Konstanten.

Anwendung

  • Variablen, die nicht geändert werden sollen
  • Variablenübergabe an Funktionen, zur Kennzeichnung, dass diese ReadOnly sind
void foo(const int par1) {
  par1=4711;  //KO

Hinweise:

  • Const Variable sollten initialisiert werden, da später kein schreibender Zugriff möglich ist. Der Compiler gibt leider bei nicht initialisierten Konstanten keinen Fehler aus!
  • Const als Rückgabewert einer Funktion ist möglich, aber wenig sinnvoll. Im Gegensatz zu C++, bei welcher mit constexptr gesagt wird, dass die ganze Funktion Konstant ist (und dadurch zur Compilezeit ausgerechnet werden kann) wird bei C einzig gesagt, dass der Rückgabewert konstant ist:
const als Funktionsrückgabewert bei C constexpr in C++
const int func(int var) {
  return var*3;
}
 
int main(int argc, char *argv[]) {
  int par=7;
  //Rückgabewert ist Konstant
  const int var = func(par+1);
  return 0;
}

Öffnen im Compiler Explorer

constexpr int func(int var) {
  return var*3;
}
int main(int argc, char *argv[]) {
  int par=7;
  //Normaler Funktionsaufruf
  int var = func(par+1);

  //Aufgrund des konstanten Übergabeparameters
  //ist die ganze Funktion konstant und wird
  //zur Compilezeit ausgerechnet
  int var2 = func(4711);
  //Compiler ersetzt Funktionsaufruf durch
  int var=3*4711;
  return 0;
}

Öffnen im Compiler Explorer

  • CONST Variablen erhöhen die Sicherheit eines Programmes. So sind bspw. in der Programmiersprache RUST alle Variablen const, resp. die Variable darf nur vom Owner geändert werden. Alle Borower haben nur lesenenden Zugriff
  • Alternativ zu const Variablen können in C/C++ konstante Werte über Makros dargestellt werden, welche einen kompakteren/schnelleren Code ermöglichen:
const int var=7;
#define MAKRO 7
if(var==7) ...
if(MAKRO==7) ...

_Atomic Type Qualifier

Bearbeiten

Threads sind nebenläufige Ausführungsstränge innerhalb eines Prozesses, welche sich alle globalen Variablen teilen. Die Zuteilung der Rechenzeit der einzelnen Threads erfolgt durch den Scheduler. Ein laufender Thread kann zu jedem Zeitpunkt die Rechenzeit entzogen/zugeteilt werden. Zu jedem Zeitpunkt bedeutet, dass der Entzug auf Maschinenspracheebene und nicht auf C-Befehlsbene stattfindet.
Ein Datenaustausch zwischen den Threads erfolgt typischerweise über globale Variablen. Wird ein Thread während des Zugriffs auf eine globale Variable angehalten, so besteht dies Gefahr von Dateninkonsistenz:

thread1 thread2
long long var=0x00000000FFFFFFFF;
void *thread1(void *par) {
  while(1) {
    //Do something
    //var++;
  }
}
extern long long var;
void *thread2(void *par) {
  long long copy;
  while(1) {
    copy=var;
    //Do something
  }
}
x86 32-Bit Assemblerprogramm x86 32-Bit Assemblerprogramm
mov     eax, DWORD PTR var
mov     edx, DWORD PTR var+4
add     eax, 1
adc     edx, 0
mov     DWORD PTR var, eax
//Threadwechsel hier
mov     DWORD PTR var+4, edx
mov     eax, DWORD PTR var
mov     edx, DWORD PTR var+4
mov     DWORD PTR [ebp-8], eax
mov     DWORD PTR [ebp-4], edx

Öffnen im Compiler Explorer

Findet ein Thread-Wechsel zwischen den beiden Schreibbefehlen mov DWORD PTR var...) statt, so würde die Variable var zum einen aus dem neuen niederwertigen 32-Bits (hier 0xFFFFFFFF+1=0x00000000) und dem alten höherwertigen 32-Bits (hier 0x00000000) bestehen.

Zur Lösung des Problems stehen diverse Möglichkeiten zur Verfügung:

  • Scheduling auf Hochsprachenebene (in C nicht gegeben)
  • Absicherung des Zugriffs auf diese Variable über Semaphoren (händisch)
  • Absicherung durch Sperrung des Interrupts (nicht sinnvoll)
  • Absicherung über atomare (nichttrennbare/-unterbrechbare) Maschinenbefehle

Über den Type Specifier _Atomic wird seit C11 der Compiler angewiesen, Schreibzugriffe auf solche Variablen über atomare Maschinenbefehle abzusichern.

Syntax: _Atomic(type-name) Variablenname
        _Atomic type-name Variablenname

Zugriffe auf solche Variablen werden als nicht unterbrechbare Einheit dargestellt, so dass sichergestellt ist, dass bei einem Zugriff immer der gesamte Wert gelesen/geschrieben wird:

_Atomic long long var=0x00000000FFFFFFFF;
void *thread1(void *par) {
  while(1) {
    //Do Something
    var++; //Befehls wird als atomare Befehl ausgeführt
  }
}

Öffnen im Compiler Explorer

Der Type Specifier ist optional und muss vom Compiler nicht unterstützt werden. Auch werden nicht alle Datentypen unterstützt. In der Header-Datei "stdatomic.h" sind diverse Makros als Alternative/Ergänzung enthalten.

_Thread_local Storage Class Specifier

Bearbeiten

Ergänzend zu lokalen (automatic Storage Duration) und globalen (static Storage Duration) Variablen wird mit Thread Local Storage eine für jeden erzeugten Thread eigenständige Variable erzeugt (Thread Storage Duration).

Syntax: __thread Datentyp Variablenname;      //GCC
        _Thread_local Datentyp Variablenname; //ab C11
        thread_local Datentype Variablenname; //ab C++11

Die Speicherplatzreservierung und Initialisierung solcher Variablen erfolgt mit dem Start des Threads.

Beispiel:

//Compilerschalter: -lpthread

__thread int tls=2;    //Thread Local Storage
//Alternative Weg für Thread Local Storage
struct data {
  pthread_t id;
  int       data;
} data[2];
static void *run(void *arg)
{
  struct data *this=arg;
  printf("data->data=%d",this->data++);
  printf("->%d\n",this->data);
  
  printf("tls=%d",tls++);
  printf("->%d\n",tls);
  ...
}
	
int main(int argc, char *argv[])
{
  for(size_t lauf=0;lauf<sizeof(data)/sizeof(data[0]);lauf++) {
    pthread_create(&data[lauf].id,NULL,run,(void *)&data[lauf]);
  }
  ...
}

Öffnen im Compiler Explorer

Die Variable errno, in welcher der genaue Fehlercode abgelegt wird, wird von der Laufzeitumgebung als Thread Local Variable angelegt, so dass jeder Thread seine eigene errno Variable besitzt.

Hinweis:

  • Ändert die zugrundeliegenden Adressierungsart, wie auf Variablen zugegriffen wird

Sonstiges

Bearbeiten

Storage Class Specifier (static, extern, auto, register, _Thread_local) definieren die 'Speicherklasse', in der der Speicher für Variablen reserviert werden soll. Infolgedessen sind diese Specifier nur bei der Definition von Variablen anwendbar. Eine Anwendung bei der reinen Datentypbeschreibung und bei Struktur-/Unionelemente ist nicht möglich:

static struct xyz { //KO, static kann nicht auf den Datentyp angewendet
                    // werden
  extern int x;     //KO, extern kann nicht auf ein Strukturelement
                    //angewendet werden
  int y;
};
void foo(void) {
  register struct cd{ //OK, register wirkt auf die Variablendefinition
    int c;
    int d;
  } var;
}

Öffnen im Compiler Explorer

Datatypespecifier (const, volatile, _Atomic) wirken sich auf den Zugriff aus, d.h. sie beschränken den Zugriff oder garantieren bestimmte Eigenschaften beim Zugriff (durch Nutzung anderer Maschinensprachebefehle). In diesem Sinne können Datatypespecifier sowohl auf Variablen, als auch auf Struktur-/Unionelemente angewendet werden:

struct xyz {
           int a;
  const    int b;  //Strukturelement ist nur lesbar
  volatile int c;  //Strukturelement wird beim Zugriff 
                   //nicht gecached
  _Atomic int d; 
  const volatile int e;
};
struct xyz xyz1;
xyz1.b=7;          //KO Strukturelement x ist nur lesbar
xyz1.c=7;          //OK
const struct xyz xyz2={1,2};
xyz2.x=7;         //KO
xyz2.y=7;         //KO

const struct ab{  //KO Datentyp kann nicht auf const gesetzt werden
  int a;
  int b;
};
volatile struct c{   //OK hier wirkt volatile auf die Variablendefinition
  int c;
  int d;
} hallo;

Öffnen im Compiler Explorer

Compound-Literal entsprechen anonymen Variablen. Per Default sind diese Variablen 'normale' Variablen, also nicht im Zugriff beschränkt. Über Type Qualifier kann der Zugriff auf diese Variablen eingeschränkt werden:

(const float []){1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6};
struct xyz{int x,y,z;};
(volatile struct xyz){.y=2};
(_Atomic int []) {1,2,3};

Öffnen im Compiler Explorer

Programmieren in C/C++: Zeiger

Programmieren in C/C++: Array

Präprozessor

Bearbeiten

Programmieren in C/C++: Präprozessor