Zum Inhalt springen

Programmieren in C/C++: Einführung/Literatur

Aus Wikibooks

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

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.