Arbeitsblatt #4 Einführung in die Systemprogrammierung SS 2014 14. Mai 2014 In dieser und den beiden folgenden Übungen betrachten wir die Grundkonzepte der Programmiersprache C. Dazu erstellen wir C-Programme, die wir danach übersetzen und ausführen. Die Tutoren können Ihnen bei der Auswahl eines geeigneten Editors helfen. Ein vom Benutzer angegebenes C-Programm wird also zunächst in ein Assemblerprogramm kompiliert, und von dort aus in Maschinensprache übersetzt. Tatsächlich bleiben diese Details jedoch meist verborgen. So können Sie ein C-Programm programm.c auf der Kommandozeile sehr direkt in ein ausführbares Programm programm übersetzten: gcc --std=c11 programm.c -o programm Sofern beim Übersetzen keine Fehler auftreten, wird der C-Übersetzer gcc das ausführbare Programm programm erzeugen. Eventuelle Fehler werden mit Zeilennummer und Erklärung angegeben. Die Angabe von --std=c11 weist den Übersetzer darauf hin, den C-Standard C11 zu verwenden, statt des älteren C89, das meist noch die Standardeinstellung ist. Dies vereinfacht einige Konstrukte. Um diesen Parameter nicht jedes Mal angeben zu müssen, können Sie der Shell sagen, daß sie ein neues Kommando gcc11 anbieten soll: alias gcc11=gcc --std=c11 Nun können Sie gcc11 statt gcc --std=c11 verwenden: gcc11 programm.c -o programm Beachten Sie dabei, daß diese Einstellung nur für ihre aktuelle Kommando-Shell gilt. Falls sie das Alias immer automatisch einrichten möchten, können Sie es an die Datei /.bashrc anhängen, so daß es automatisch beim Start Ihrer Shell ausgeführt wird1 . Wir verwenden zwei Operationen, die durch die Präprozessoranweisung # include <stdio .h> importiert werden können: • printf(), das sich wie in Übung #01 verhält und das wir hier zum Ausgeben von Zeichenketten und Zahlen verwenden. printf nimmt einen oder mehrere Parameter, wobei der erste Parameter immer eine Zeichenkette sein muß. Diese erste Zeichenkette kann Formatierungszeichen beinhalten, wie z.B. %d oder %s. Mit Ausnahme des Formatierungszeichens %%, das immer ein Prozentsymbol ausgibt, bezieht sich das nte Formatierungssymbol der Zeichenkette immer auf den (n + 1)ten Parameter und fügt diesen an der betreffenden Stelle in der Zeichenkette ein. Dabei gilt: – %d erwartet als zugehörigen Parameter eine int-Zahl und fügt diese als Dezimalzahl ein. – %s erwartet als zugehörigen Parameter eine Zeichenkette (ein char-array) und fügt diese direkt ein. printf ignoriert Formatierungszeichen in dieser eingefügten Zeichenkette. 1 Diese Angaben, wie die meisten Angaben bezüglich der Shell, gehen davon aus, daß Sie die bash verwenden, die verbreitetste Shell. Für andere Shells wie die tccsh gelten analoge aber andere Regeln. 1 Beispiel: printf("%d%% von %d sind %d\n", 20, 45, 9); gibt 20% von 45 sind 9“ aus, ” gefolgt von einem Zeilenumbruch. • scanf(), das das Gegenstück zu printf zum Einlesen von Zahlen ist. Wir benötigen es hier nur in der Form scanf("%d", &i), in der es eine einzelne Zahl einliest und in die int-Variable ‘i’ schreibt (die genaue Bedeutung des &-Präfixoperators behandeln wir in der nächsten Vorlesung). 1 Hallo, Welt! Testen Sie den C-Übersetzer, indem Sie ein ‘Hallo-Welt’-Programm schreiben. Das Programm sieht aus wie folgt: # include <stdio .h> int main(int argc , char ** argv) { printf ("Hallo , Welt !\n"); return 0; } Beachten Sie, daß es sich geringfügig von der in der Vorlesung vorgestellten Fassung unterscheidet: • #include <stdio.h> macht die Funktion ‘printf’ verfügbar2 • return 0 springt vom Hauptprogramm mit dem Ergebnis 0 zurück. Dieser Wert wird von der Kommando-Shell aufgefangen und kann dort verwendet werden, um zu prüfen, ob das Programm erfolgreich terminiert hat oder nicht: 0 bedeutet ‘erfolgreich’, 1 bedeutet ‘mit Fehler’3 . 2 Schleifen und Eingaben [9.1–9.3] Schreiben Sie ein Programm, das in einer Schleife zwei Zahlen einliest und deren Summe ausgibt. Die Schleife soll terminieren, sobald das Ergebnis der Berechnung 0 ist. Wählen Sie eine angemessene Schleifenform. a. Schreiben und testen Sie Ihr Programm. b. Was passiert, wenn Sie die Zahl 3000000000 (drei Milliarden) mit Ihrem Programm mit sich selbst addieren? Versuchen Sie, das Ergebnis zu erklären. 3 Arrays [9.1] Übersetzen Sie den folgenden Programmcode: # include <stdio .h> int main () { char * daten [2] = {" Hallo ", "Welt!"}; printf ("%s\n", daten [0]); return 0; } a. Führen Sie das Programm wie angegeben aus. 2 Strenggenommen wird dadurch nur der Typ von printf und einigen anderen Funktionen/Strukturen definiert; wir werden dieses Detail später genauer untersuchen. 3 Der C-Standard definiert einige Konstanten für diesen Zweck, die in der Praxis statt 0 oder 1 verwendet werden sollten. 2 f e d c b a 9 8 7 6 5 4 3 2 1 Rezessiv Dominant langstielig saftig hell dunkel oval fleischig klein gross nachtleuchtend braun wuerzig sauer suess fest weich gerippt Bit 0 Abbildung 1: Genom der simulierten Tomatenpflanze und Bit-Kodierung b. Ändern Sie daten[0] auf daten[-1]. Was passiert nun, wenn Sie das Programm ausführen? c. Ändern Sie auf daten[2]. Was passiert nun, wenn Sie das Programm ausführen? d. Was passiert, wenn Sie in C die Grenzen eines Datenfeldes überschreiten? 4 Funktionen und Bitoperationen [9.1–9.3, 9.8] In der Systemprogrammierung ist es oft wichtig, Speicherplatz zu sparen: Wenn Sie auf einem eingebetteten System arbeiten, haben Sie inhärent wenig Platz zur Verfügung, und wenn Sie an einem Betriebssystemkern, einer Systembibliothek, oder einem Laufzeitsystem arbeiten, kann ein verschwendetes Wort im Speicher sich schnell vervielfachen und ggf. durch zusätzliche Auslastung der Caches zu Systemverlangsamungen führen. Auf dem Rest dieses Arbeitsblattes werden wir daher zur Übung ein Beispielprogramm entwickeln, das die Verwendung von Bitoperationen demonstriert. Diese Operationen können verwendet werden, um Daten kompakt im Speicher zu repräsentieren. Wir entwickeln dazu eine Tomatenpflanzensimulation, in der wir eine (stark vereinfachte und biologisch nicht realistische) genetische Struktur von Tomatenpflanzen mit verschiedenen Eigenschaften simulieren. Wir nehmen an, daß Tomaten nur eine Art von Chromosom verwenden, wobei jedes Chromosom exakt 16 Gene beinhaltet. Wir gehen davon aus, daß jedes Gen entweder ‘ja’ oder ‘nein’ ausdrückt und daher mit einem einzigen Bit simuliert werden kann. Wenn wir davon ausgehen, daß Tomaten zwei Elternteile haben können, die jeweils ein Chromosom beisteuern, benötigen wir also 32 Bit, um das komplette Genom einer Tomatenpflanze zu simulieren: Je 16 Bit für jeden Elternteil. Da int-Werte auf modernen Rechnern mindestens 32 Bit kodieren, können wir also das gesamte Erbgut einer Tomatenpflanze in einem int kodieren. Abbildung 1 beschreibt die 16 Eigenschaften und ihre Bit-Kodierung. a. Ausgabe: Schreiben Sie eine Funktion void ausgabe(int gene), die die genetischen Informationen der Tomatenpflanze ausgibt. Dabei sollen in einer Zeile alle Eigenschaften, getrennt durch je ein Leerzeichen, ausgegeben werden, gefolgt von einem Zeilenumbruch. Verwenden Sie dazu eine Schleife. Beispiel: 0x0103 soll zu folgender Ausgabe führen: langstielig saftig wuerzig Die Datei http://web.sepl.cs.uni-frankfurt.de/2014-ss/b-sysp/4\_4.c beinhaltet die nötigen Header-Deklarationen, sowie ein Datenfeld gene, das Zeichenketten für die 16 Eigenschaften beinhaltet. Sie können mit gene[0] bis gene[15] auf diese Eigenschaften zugreifen. 3 b. Ausgabe des zweiten Elternteils: Wir wollen nun die genetischen Informationen beider Elternteile in einem 32-Bit-Wort speichern. So ist z.B. 0x80012002 eine Pflanze, die von einem Elternteil die Eigenschaften langstielig“ und sauer“, und vom anderen Elternteil die Eigenschaften fest“ ” ” ” und saftig“ geerbt hat. Wie müssen Sie Ihre Funktion aufrufen, wenn Sie beide Elternteile ” kodiert haben, z.B. int g = 0 x80012002 ; und nun beide ausgeben möchten? c. Phänotyp berechnen: Der Phänotyp eines Lebewesens beschreibt dessen Aussehen, im Gegensatz zum Genotyp, den wir bisher betrachtet haben. Um den Phänotyp zu berechnen, müssen wir folgende Schritte durchführen: (i) Kombinieren der Erbgutinformationen beider Eltern. Hierbei müssen wir zwischen dominanten und rezessiven Eigenschaften unterscheiden: • Dominante Eigenschaften sind im Phänotyp ersichtlich genau dann wenn sie in mindestens einem der beiden Sätze an Erbinformationen auftauchen. • Rezessive Eigenschaften sind im Phänotyp ersichtlich genau dann wenn sie in beiden der Sätze an Erbinformationen auftauchen. Die Abbildung führt auf, welche Eigenschaften rezessiv und welche dominant sind. Versuchen Sie, diese effizient mit Bitoperationen zu berechnen. (ii) Entfernen von gegensätzlichen Eigenschaften: Die Eigenschaftspaare süß“/ sauer“, sowie ” ” groß“/ klein“, fest“/ weich“, und hell“/ dunkel“ schließen sich gegenseitig aus. Wenn ” ” ” ” ” ” beide Eigenschaften im Phänotyp enthalten sind, entfernen wir sie beide; eine Tomate, die z.B. sowohl groß“ als auch klein“ ist, unterscheidet sich nicht sichtbar von einer Tomate ” ” ohne beide Eigenschaften. Schreiben Sie eine Funktion int phaenotyp(int genotyp), die den Phänotyp berechnet. Berechnen Sie mit dieser Funktion die Phänotypen der folgenden Pflanzen und geben Sie sie aus: • 0xff00e301 (langstielig, wuerzig, braun, fest) • 0x010c7608 (fleischig, oval) d. Kreuzen: Wir wollen nun zwei Tomatenpflanzen kreuzen. Dazu führen wir Meiose durch. Wir verwenden in unserer Simulation einen vereinfachten Prozeß: • Für jedes Gen (aus den 32) wählen wir zufällig ein Gen von einem oder von dem anderen Elternteil. • Anschließend drehen wir genau eines der 32 Bits um, um einen Transkriptionsfehler zu simulieren. Verwenden Sie die in 4 4.c enthaltene Funktion int zufall int(), um eine Folge von 32 pseudozufälligen Bits (in einem int kodiert) zu erhalten. Um eine Zufallszahl zwischen 0 und 31 zu erhalten, können Sie die Funktion int rand() aus der Standardbibliothek verwenden. Diese liefert eine nichtnegative Pseudozufallszahl, die allerdings sehr hoch sein kann; Sie können die &bzw. %-Operatoren verwenden, um daraus eine Zahl zwischen 0 und 31 zu extrahieren. Implementieren Sie die Funktion int meiose(int elternteil1, int elternteil2) und stellen Sie sicher, daß die erzeugten Ergebnisse plausibel sind. Anmerkung zu Pseudozufallszahlen: Der Pseudozufallszahlengenerator rand() wird bei jedem Programmstart gleich initialisiert, so daß er deterministisch identische Ergebnisse liefert4 . 4 Wenn Sie nichtdeterministische Pseudozufallszahlen wünschen, können Sie die Header-datei time.h mit einbinden. Danach können Sie den Generator auf einer Zahl initialisieren, die sich jede Sekunde ändert, indem Sie zu Beginn Ihres Programmes den Aufruf srand(time(NULL)) durchführen. 4