Grundlagen

Video zum Kapitel

Link zu YouTube

Warum zum Teufel C?

C ist – nach heutigen Maßstäben – eine uralte Programmiersprache, die für den Benutzer unkomfortabel ist und ihn wenig davor beschützt Fehler zu machen. Viele Dinge, die man in Java ein einigen Zeilen erledigen kann, benötigen in C umfangreiche Konstrukte und bereiten viel Aufwand, bis sie einwandfrei funktionieren. C ist ein Dinosaurier unter den Programmiersprachen. Die Produktivität in C ist meistens deutlich geringer, als in modernen Skriptsprachen wie Ruby oder Python.

Warum sollte man sich heute noch mit C beschäftigen? Oder noch kürzer „warum zum Teufel C?“.

The good

  • C ist gleichzeitig Highlevel- und Lowlevel-Sprache
  • C bietet bessere Kontrolle über die Hardware / das Betriebssystem
  • C liefert sehr gute Performance
  • C ist für Echtzeitprogrammierung geeignet
  • C erlaubt die Programmierung von Betriebssystemen (Linux, Windows, …)
  • C hilft die Hardware zu verstehen (Embedded Systems)
  • C läuft auf jeder Hardware, vom Großrechner zum Kühlschrank
  • C wird von Tools zum Reverse-Engineering verwendet

C ist auf keinen Fall irrelevant und wird heute noch in vielen Bereichen eingesetzt. Durch die Nähe zur Maschine und die explizite Speicherverwaltung kann man in C Dinge programmieren, die in anderen Sprachen nicht zu bewältigen wären, z. B. Betriebssysteme oder Echtzeitanwendungen. Eine Echtzeitanwendung, z. B. die Steuerung eines Airbags, wäre in Java undenkbar, da Java gelegentlich Pausen für die Garbage Collection einlegt und damit kein vorhersehbares Zeitverhalten hat. Ebenso benötigt man für die Entwicklung eines Betriebssystems eine Sprache, die direkten Zugriff auf die Hardware erlaubt – eine Java VM wäre hier auf jeden Fall im Weg. Dasselbe gilt beim Vergleich von C mit Skriptsprachen, wie z. B. Python. Während man in Python sehr produktiv programmieren kann, ist die Geschwindigkeit der Programme deutlich geringer. Aus diesem Grund enthalten die Bibliotheken für wissenschaftliches Rechnen in Python auch optimierte, in C programmierte Teile, um die Probleme effizient lösen zu können.

Wenn man sich mit fortgeschrittenen Themen wie Reverse Engineering beschäftigt, also der Frage, wie man ein vorliegendes Programm im Maschinencode analysieren kann, ist C ein wichtiges Hilfsmittel. Wegen seiner Nähe zur Hardware kann man Maschinenprogramme einfach als C-Code darstellen, sodass man beim Reverse Engineering nicht die Assembler-Instruktionen lesen muss, sondern sich mit dem daraus erzeugten C-Code beschäftigen kann.

The bad

  • C verzeiht keine Fehler
  • C hat eine manuelle Speicherverwaltung
  • C hat keine eingebaute Fehlerbehandlung
  • C benötigt umfangreicheren Code als in anderen Sprachen
  • C hat keine goße Standardbibliothek

Das Alter von C und die Hardwarenähe haben aber auch Auswirkungen. So muss man in C sehr viele Dinge manuell machen, die man in anderen Programmiersprachen geschenkt bekommt. Bei Speicherverwaltung und Fehlerbehandlung bietet C nur sehr wenig Unterstützung und man muss beim Programmieren höllisch aufpassen, um keine Fehler zu machen, die zu einem harten Absturz des Programmes führen.

C-Programme benötigen oft deutlich mehr Code, um dasselbe Ziel zu erreichen als Sprachen wie Python oder Ruby. Das liegt auch daran, dass es in C wenig vorgefertigte Bibliotheken gibt, aus denen man Funktionalitäten nutzen kann. Zwar hat C eine Standardbibliothek, wie auch Java, aber diese ist zu einer Zeit standardisiert worden, als es viele Konzepte noch gar nicht gab, z. B. XML, HTTP etc. Diese muss man deswegen heute über externe Bibliotheken nachrüsten.

Geschichte von C

1960er Jahre: Viele neue Programmiersprachen

  • Assembler für Betriebssysteme und zeitkritischen Code
  • COBOL für Geschäftsanwendungen
  • FORTRAN für wissenschaftliche Berechnungen
  • Lisp, Simula für Informatik-Forschung (AI)
  • PL/I als Sprache der 2. Generation

Die Geschichte von C beginnt zu einer Zeit, als die Entwicklung der Computer noch in den Kinderschuhen steckte.

Nachdem zu Beginn alle Programme direkt in der Maschinensprache des jeweiligen Prozessors geschrieben wurde, entstanden in den 1960 Jahren neue Hochsprachen, welche die Programmierung und die Übertragung von Programmen auf neue Hardware vereinfachen sollten. Die neu entwickelten Sprachen konzentrierten sich jeweils auf spezifische Bereiche: COBOL für Geschäftsanwendungen, FORTRAN für mathematische Berechnungen und Lisp für die KI-Forschung – Lisp ist tatsächlich eine der ältesten Programmiersprachen überhaupt, mit einer ersten Spezifikation aus dem Jahr 1958.

Hello World in FORTRAN
program hello
  ! This is a comment line; it is ignored by the compiler
  print *, 'Hello, World!'
end program hello
Hello World in COBOL
IDENTIFICATION DIVISION.
PROGRAM-ID. IDSAMPLE.
ENVIRONMENT DIVISION.
PROCEDURE DIVISION.
    DISPLAY 'HELLO WORLD'.
    STOP RUN.

Eine der wenigen Sprachen der damaligen Zeit, die für einen breiteren Anwendungsbereich vorgesehen wurde, war PL/I, die 1964 das Licht der Welt erblickte.

Hello World in PL/I
Hello2: proc options(main);
     put list ('Hello, world!');
end Hello2;

Ebenso rudimentär wie bei den Programmiersprachen war die Lage bei den Betriebssystemen: Diese wurden ebenfalls in Assembler geschrieben und liefen ausschließlich auf der Hardware, für die sie Entwickelt wurden.

  • Dominante Betriebssysteme: OS/360 (Großrechner) und Multics (in PL/I geschrieben)
    (Multiplexed Information and Computer Services)
  • Bell Labs wollte eigenes Betriebssystem (als Ersatz für Multics)
    Unics (Uniplexed Information and Computing Service)
  • Erste Version von Unics (dann Unix) wurde in Assembler geschrieben
    ⇒ Portierung (Übertragung) auf neue Hardware bei Assembler sehr aufwändig
  • Ken Thompson entwickelte B auf Basis von BCPL auf der PDP-7 (16 kB RAM)
    → einfacher als PL/I und BCPL
  • Dennis Ritchie entwickelt auf Basis von B die Sprache C (1969–1973)
Ken Thompson (links) und Dennis Ritchie (rechts)
Ken Thompson (links) und Dennis Ritchie (rechts)

Aus dem Bedürfnis heraus, einen Nachfolger für Multics zu entwickeln, begannen Ken Thompson und Dennis Ritchie um 1965 mit der Entwicklung von Unix, damals noch „Unics“ genannt. Ein Ziel der beiden war, das Betriebssystem möglichst einfach auf neue Hardware portieren zu können, wobei kleinere Minicomputer wie die PDP/7 oder PDP/11 unterstützt werden sollten. Die verfügbaren Programmiersprachen waren dafür aber entweder zu hardwarenah (Assembler) und damit nicht portierbar oder zu schwergewichtig (PL/I), weswegen Thompson mit B) eine neue Sprache entwarf.

Beispielprogramm in B
main()
{
    auto c;
    auto d;
    d=0;
    while(1)
    {
        c=getchar();
        d=d+c;
        putchar(c);
    }
}

Dennis Ritchie entwickelte B dann weiter zur Sprache C, wie wir sie heute kennen. Er und Thompson verwendeten C, um das Betriebssystem Unix zu entwickeln. Die ersten Versionen von Unix waren noch in Assembler geschrieben, ab 1973 (Version 4 Unix) dann in C.

Bei der Entwicklung von C bekam Ritchie Unterstützung von Brian Kernighan, mit dem zusammen er auch das Standardwerk „The C Programming Language“ zur C-Programmierung schrieb.

Von C zu Java

  • C: Dennis Ritchie (1969–1973)
    • Sprache zur Systemprogrammierung
    • macht das Betriebssystem hardwareunabhängig
    • ursprünglich nicht für Anwendungen gedacht (→ PL/I oder Fortran)
  • C++: Bjarne Stroustrup (Bell Labs), 1980er
    → objektorientierte Erweiterung zu C
  • Java: James Gosling in den 1990ern
    • für eingebettete Systeme gedacht
    • objektorientiert (ähnlich C++ aber deutlich vereinfacht)
    • Syntax von C übernommen

Nachdem C in den 1970er Jahren entwickelt wurde, wurde es in den 1980er Jahren von Bjarne Stroustrup um Objektorientierung erweitert, woraus dann C++ entstand.

#include <iostream>

int main(int argc, const char * argv[])
{
    std::cout << "Hello, world!\n";
}

Parallel zu C++ wurde von Tom Love und Brad Cox mit Objective-C eine andere objektorientierte Version von C entworfen, die sich bei Apple lange Zeit als Programmiersprache für macOS-Anwendungen gehalten hat, inzwischen aber von Swift abgelöst wurde.

Hello World in Objective-C
// FILE: hello.m
#import <Foundation/Foundation.h>
int main (int argc, const char * argv[])
{
    /* my first program in Objective-C */
    NSLog(@"Hello, World! \n");
    return 0;
}

Die Syntax der Sprache C diente als Vorbild für viele weitere Sprachen, die heute noch in Gebrauch sind. Neben Java und C# finden sich viele Ideen von C ebenfalls in Go, PHP und weiteren Sprachen wieder.

C vs. Java

Da Java häufig als erste Programmiersprache gelehrt wird, ist es sinnvoll einen Vergleich zwischen Java und C zu ziehen.

  • Java ist eine objektorientierte Sprache von Mitte der 1990er Jahre
  • C ist eine prozedurale Sprache aus den frühen 1970er Jahren
  • Vorteile von C
    • direkter Zugriff auf das Betriebssystem (System-Calls)
    • hohe Performance
  • Nachteile von C
    • Sprache ist portabel, Schnittstellen zum Betriebssystem nicht
    • wenig Unterstützung bei der Programmierung
    • Memory-Leaks und Abstürze drohen
    • Präprozessor ist obskur (und die Fehler noch mehr)

Java ist mehr als 20 Jahre jünger als C und mit einem anderen Ziel entwickelt worden: Während C maschinen- und hardwarenah für die Betriebssystementwicklung entworfen wurde, hatte Java das Ziel möglichst maschinenunabhängig zu sein und sollte für die Entwicklung von Anwendungen – damals für Set-Top-Boxen – dienen. Außerdem ist Java konsequent objektorientiert, während C rein prozedural ist.

Die Entwickler von Java habe sich dafür entschieden, die Syntax von Java an C anzulehnen, um Vertrautes zu verwenden. Die Semantik von Java orientiert sich aber viel mehr an Smalltalk als an C. Java-Code sieht auf den ersten Blick C-Code ähnlich, funktioniert aber häufig anders.

Folgende Tabelle gibt einen Überblick über die Unterschiede zwischen den beiden Sprachen.

Java C
objektorientiert prozedural
strikt getypt getypt, kann aber übersteuert werden
Klassen und Pakete für Namespaces flacher Namensraum
keine Makros Makros Kern der Sprache
automatische Speicherverwaltung manuelle Speicherverwaltung
keine Pointer Pointer
Funktionsaufruf by-value Funktionsaufruf by-value
Ausnahmebehandlung Manuelle Fehlerbehandlung
Arrays kennen ihre Länge Arrays kennen ihre Länge nicht
String als Datentyp Ausschließlich char-Arrays
Viele Bibliotheken Bibliotheken vom Betriebssystem

Aus der Tabelle sind vier Aspekte besonders herauszuheben, die Umsteigern von Java auf C häufig Schwierigkeiten bereiten:

  • Speicherverwaltung: In C muss der Entwickler die gesamte Speicherverwaltung übernehmen. Sowohl das Reservieren, als auch das Freigeben von Speicher muss explizit erfolgen.
  • Pointer: Pointer sind in C ein wichtiger Datentyp und können über die sogenannte Pointerarithmetik manipuliert werden. Ein sicherer Umgang mit Pointern ist unerlässlich, um C-Programme schreiben zu können.
  • Strings: C kennt keine Strings. Zeichenketten werden als Arrays von Zeichen (char[]) realisiert, deren Ende durch ein Nullbyte (0x00) gekennzeichnet ist.
  • Länge von Arrays: Ein C-Array kennt seine Länge nicht und es ist Aufgabe des Entwicklers, die Länge zu verwalten und an Funktionen, die Arrays verwalten zu übergeben.

Java-Programm

  • Sammlung von Klassen
  • Programm läuft in einer Java-VM (Java virtuelle Maschine)
  • main-Methode einer Klasse ist Einstiegspunkt
  • Java VM lädt weitere Klassen bei Bedarf aus JAR-Archiven nach

C-Programm

  • Sammlung von Funktionen
  • Programm läuft direkt auf dem Prozessor (physikalischer Maschine)
  • main-Funktion ist der Einstiegspunkt
  • alle Funktionen sind zu einem Executable (z. B. .exe) zusammengefasst
  • Dynamische Libraries (.dll, .so) enthalten gemeinsam genutzte Funktionen

Da C nicht objektorientiert ist, bestehen C-Programme nicht aus Klassen, sondern sind eine Sammlung von Funktionen, die sich gegenseitig aufrufen. Alle Funktionen eines C-Programms werden vom Linker (siehe unten) in ein sogenanntes Executable zusammengefasst. Gemeinsam genutzte Funktionen werden in dynamischen Libraries abgelegt, die unter Windows die Dateiendung .dll und unter Linux/Unix die Dateiendung .so bekommen.

Der Namensraum eines C-Programms ist flach, d. h. alle Funktionen teilen sich denselben Namensraum und es dürfen keine zwei Funktionen in einem Programm denselben Namen haben.

Das erste C-Programm

Im Folgenden soll ein erstes C-Programm Schritt für Schritt analysiert werden. Hierzu dient das berühmte „Hello World“-Beispiel.

HelloWorld.java
public class Hello {
    public static void main(String[] args) {
        /* Greet the world */
        System.out.printf("%s", "Hello, World!\n");
        System.exit(0);
    }
}
hello_world.c
#include <stdio.h>

int main(int argc, char** argv) {
    /* Greet the World */
    printf("%s\n", "Hello, World!");
    return 0;
}
  • #include <stdio.h>
    • Zeilen mit # werden vom Präprozessor ausgewertet
    • lädt eine Header-Datei (→ import in Java)
  • int main(int argc, char** argv) {
    • deklariert und definiert eine Funktion mit dem Namen main
    • Rückgabetyp der Funktion ist int
    • zwei Parametern argc und argv
      argc ist die Anzahl der Argumente
      argv ist ein Array von Strings mit den Kommandozeilenargumenten

Die Präprozessordirektiven werden mit # eingeleitet und sind eine Besonderheit von C-Programmen. Auf den Präprozessor wird später noch detailliert eingegangen. Hier sei allerdings bereits erwähnt, dass ein C-Programm in zwei Schritten kompiliert wird: Zuerst verarbeitet der Präprozessor den Quelltext und erzeugt Dateien, die dann vom C-Compiler verarbeitet werden. Dies bedeutet, dass der C-Compiler die Präprozessordirektiven gar nicht versteht, sondern dafür ein eigenes Werkzeug zuständig ist. Dieses Vorgehen kann man nur historisch aus den Beschränkungen der damaligen Hardware nachvollziehen, da der Speicher sehr knapp war und man den Compiler möglichst klein halten wollte.

Die zwei Sterne bei char **argv bedeuten, dass es sich um einen Pointer auf einen Pointer handelt, der auf ein Zeichen zeigt. Das ergibt auf den ersten Blick wenig Sinn, denn an der entsprechenden Stelle wird in Java ein Array von Strings übergeben. In C findet sich hier ebenfalls ein Array von Strings, die aber, da es keine Strings gibt, Arrays von Zeichen sind. Wir haben es somit mit einem Array von Arrays von Zeichen zu tun. In Java könnte man sich das dann als char[][] vorstellen. Der letzte Stein zum Verständnis der ** ist, dass ein Array in C als Pointer auf das erste Element verstanden werden kann, z. B. sind int a[] und int* a identisch. Der Zugriff kann entweder über den Index erfolgen a[3] oder aber über den Pointer *(a+3). Damit ist das Geheimnis von **argv für das Erste gelüftet. Details zu Arrays und Pointern werden später noch diskutiert.

Es bleibt noch die Frage, warum C eine Variable int argc hat, Java aber nicht? Der Grund ist, dass in C die Länge eines Arrays nicht bekannt ist und deshalb der main-Funktion mitgegeben werden muss, wie viele Parameter argv enthält. Für die Länge der Strings brauchen wir keine Längenangaben, weil diese Nullterminiert sind, also am Ende ein 0x00-Byte haben.

  • /* Greet the World */
    • Kommentar
    • Achtung: // ist kein Kommentarzeichen in ANSI-C (erst seit C99 und in C++)
  • printf("%s\n", "Hello, World!");
    • gibt einen String aus (→ printf in Java)
    • %s ist der Platzhalter, der durch Hello, World! ersetzt wird
    • \n fügt einen Zeilenvorschub ein
  • return 0;
    • beendet das Programm und gibt 0 als Exit-Code an das Betriebssystem zurück
      (→ System.exit(0) in Java)

Da fast alle C-Compiler C++-Compiler sind, akzeptieren sie // als Kommentarzeichen. Trotzdem ist // kein gültiger C-Kommentar. Da es immer passieren kann, dass ein C-Programm von einem Compiler übersetzt wird, der kein C++ beherrscht, sollte man auf // als Zeilenkommentar verzichten, insbesondere wenn man ältere Compiler antreffen könnte. In C99-Standard wurde // als Kommentarzeichen für Zeilenkommentare endlich auch in C aufgenommen.

Die Verwendung von Format-Strings ist bei C und Java nahezu identisch, was kein Wunder ist, da es Java einfach von C übernommen hat.

Am Ende übergibt jedes Programm einen Integer-Wert an das Betriebssystem um Fehler anzeigen zu können. Per Konvention bedeutet 0, das kein Fehler im Programm aufgetreten ist.

In Java ist es unüblich, das Programm mit System.exit zu beenden, insbesondere, wenn man keinen Fehlercode setzen will. Anders als das return 0; in C beendet nämlich System.exit(0); den Prozess abrupt und bietet der Java-VM keine Möglichkeit mehr, aufzuräumen. Deswegen vermeidet man die Verwendung in Java üblicherweise.

Noch etwas eleganter kann man einen vordefinierten Rückgabewert für die main-Funktion aus der C-Standardbibliothek (#include <stdlib.h> nutzen. Diese definiert EXIT_SUCCESS für den Erfolgsfall und EXIT_FAILURE für den Fehlerfall.

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char** argv) {
    /* Greet the World */
    printf("%s", "Hello, World!\n");
    return EXIT_SUCCESS;
}

Copyright © 2025 Thomas Smits