rechner- netze und -organisation

Transcription

rechner- netze und -organisation
 COMPUTATION AND COMMUNICATION RECHNER‐ NETZE UND ‐ORGANISATION DIE SCHNITTSTELLE ZWISCHEN SOFTWARE UND HARDWARE DER TEXT ZUR LEHRVERANSTALTUNG IM SOMMERSEMESTER 2010 Version 4. Mai 2010 Michael Hutter Karl C. Posch Teile dieses Textes wurden mit freundlicher Erlaubnis von Norman Matloff von der University of California at Davis aus dessen Skriptum übernommen. Diese Teile wurden explizit gekennzeichnet. Für die Verwendung des im Teil A besprochenen „Visual X‐Toy“ bedanken wir uns bei Kevin Wayne vom Department of Computer Science an der Princeton University. 1
INHALT Prolog Teil A: TOY 1.
2.
3.
4.
5.
Representation von Information: Zahlen, Buchstaben, Daten und Anweisungen Von‐Neumann‐Maschine und Instruction Set Architecture Maschinenprogramme, Assemblersprache und Linking Boot Code Security, Buffer‐Overflow Teil B: x86 6. Einführung in Linux‐Assemblersprache 7. Einführung in Intel‐Maschinensprache Teil C: Rechnernetze 8. Local‐Area Networks 9. Wide‐Area Networks 10. Personal‐Area Networks 11. Telekommunikation Teil D: Hardware, Stack und I/O 12. Hardware von unten: Logikfunktionen, Speicher, Endliche Automaten 13. Hardware von oben: CPU, Speicher, I/O‐Geräte, Systembus, CPU‐Aufbau, Fetch‐Execute, Betriebssystem, Pipelining, Cache 14. Unterprogramme 15. Input/Output ÜBUNG 
1. Aufgabe zum Teil A: basierend auf TOY 
2. Aufgabe zum Teil B: basierend auf x86 
3. Aufgabe zur ersten Hälfte von Teil C: basierend auf LAN und WAN 2 Rechnernetze und –Organisation Prolog
PROLOG Dieser Text begleitet die Lehrveranstaltung „Rechnernetze und –Organisation“ an der Technischen Universität Graz. Diese Lehrveranstaltung wird für Studierende der Studienrichtung „Bachelorstudium Softwareentwicklung‐Wirtschaft“ im 2. Semester angeboten. Wir haben uns bemüht, den Inhalt so zu gestalten, dass er ohne spezielle Voraussetzungen im 2. Semester des Bachelorstudiums verstanden werden kann. Die Kenntnis einer ersten Programmiersprache ist jedoch hilfreich. Das Thema der Lehrveranstaltung besteht aus zwei Teilen: „Computation“ und „Communication“. Beide Themen liegen im Zentrum der rasanten Entwicklung der Informationstechnologie. Erst das intensive Wechselspiel dieser beiden Komponenten bedingt den Erfolg der komplexesten Maschine, die die Menschen jemals gebaut haben: das Internet. Wir siedeln die Betrachtung des Themas rund um das sogenannte „Software‐Hardware‐Interface“ an. Die typische Frage, welche sich dabei beim Thema „Computation“ stellt, ist folgende: Welche Sprache versteht eine „Rechenmaschine“, wie erzeugt man Programme in dieser Maschinensprache und woraus bestehen solche Maschinen? Beim zweiten Thema „Communication“ konzentrieren wir uns ebenfalls auf den Übergang zwischen Sprache, dem Internet‐Protokoll, und der dazu gehörenden Hardware. Rund um diese beiden Kernbereiche „Maschinensprache“ und „Internet‐Protokoll“ (Network Layer) betrachten wir angrenzende Abstraktionsebenen. Diese sind im Bild dargestellt. Das Verständnis des Zusammenspiels von Hardware und Software soll das Fundament für höhere Abstraktionsebenen schaffen, welche viele andere Lehrveranstaltungen des Bacherlorstudiums Softwareentwicklung‐Wirtschaft zum Inhalt haben. Das Manuskript ist nicht vollständig. An mehreren Stellen verweisen wir auf andere Texte und Materialien. Dass diese meist in englischer Sprache vorliegen, soll die Studierenden anhalten, die Wichtigkeit dieser Sprache 3
4 Rechnernetze und –Organisation in ihrem Studium zu begreifen. Manche Textteile sind nur in Zusammenhang mit dem Vortrag im Hörsaal verständlich. Ein Selbststudium mit dem Manuskript alleine empfehlen wir deshalb nicht. Einige Teile des Textes sind Übersetzungen eines Manuskripts von Norman Matloff von der University of California, Davis. Wir bedanken uns bei Norman Matloff für die Erlaubnis, seine Texte in der Lehrveranstaltung zu verwenden. Großer Dank gebührt auch Kevin Wayne vom Department of Computer Science an der Princeton University für die Erlaubnis, das „Visual X‐Toy“ in der Lehrveranstaltung zu verwenden. Die Lehrveranstaltung Rechnernetze und –Organisation besteht aus einem Vorlesungteil und einem Übungsteil. Wenngleich formal getrennt, raten wir den Studierenden dringend, an beiden Teilen gleichzeitig zu arbeiten. Erst damit ergibt sich das gewünschte umfassende Verständnis des Themas. Alle Materialien für die Lehrveranstaltung gibt es auf dem Web unter http://www.iaik.tugraz.at/content/teaching/bachelor_courses/rechnernetze_und_organisation/. Michael Hutter und Karl C. Posch, Mai 2010
Ein C‐Beispiel und dessen Verarbeitung durch GCC
EIN C‐BEISPIEL UND DESSEN VERARBEITUNG DURCH GCC Bevor es eigentlich losgeht, wollen wir ein kleines C‐Programm ansehen und uns einige typische Fragen stellen, welche uns in dieser Lehrveranstaltung beschäftigen werden. Ein kleines Beispiel: // Datei rno_1.c
//
// Dieses Programm zählt die 4 Zahlen des Feldes x zusammen
// und speichert das Ergebnis in der Variablen sum
#include <stdio.h>
int x[4] = {1, 5, 2, 18};
int sum = 0;
int main()
int
int
int
{
counter = 4;
local_sum = 0;
*pointer = x;
do {
local_sum += *pointer;
pointer++;
counter--;
} while (counter != 0);
sum = local_sum;
printf("Sum is %d\n", sum);
return 0;
}
Das gleiche Beispiel ohne printf‐Zuweisungen (Datei rno_1_2.c): // rno_1_2.c
// gleich wie rno_1_1.c, jedoch dieses Mal ohne printf
int x[4] = {1, 5, 2, 18};
int sum = 0;
void main() {
int counter = 4;
int local_sum = 0;
int *pointer = x;
do {
local_sum += *pointer;
pointer++;
counter--;
} while (counter != 0);
sum = local_sum;
}
Mit gcc –S rno_1_2.c entsteht daraus die Datei rno_1_2.s. Diese sieht je nach darunter liegendem Betriebssystem und je nach verwendetem Compiler etwas anders aus. Doch die Datei sieht in etwa so aus: 5
6 Rechnernetze und –Organisation .data
_x:
.long
.long
.long
.long
1
5
2
18
.bss
_sum:
.globl _main
_main:
pushl
movl
subl
andl
movl
addl
addl
shrl
sall
movl
movl
call
call
%ebp
%esp, %ebp
$24, %esp
$-16, %esp
$0, %eax
$15, %eax
$15, %eax
$4, %eax
$4, %eax
%eax, -16(%ebp)
-16(%ebp), %eax
__alloca
___main
movl
movl
movl
$4, -4(%ebp)
$0, -8(%ebp)
$_x, -12(%ebp)
movl
movl
leal
addl
leal
addl
leal
decl
cmpl
je
jmp
-12(%ebp), %eax
(%eax), %edx
-8(%ebp), %eax
%edx, (%eax)
-12(%ebp), %eax
$4, (%eax)
-4(%ebp), %eax
(%eax)
$0, -4(%ebp)
L3
L2
L2:
L3:
movl
-8(%ebp), %eax
movl
%eax, _sum
leave
ret
Fragen: o
o
Was bedeutet das? Wie kann die Maschine das verstehen? Wenn wir den Assembler‐Code zum Beispiel unter Windows mit msys mit dem Kommando as –a rno_1_2.s > rno_1_2_listing in ein Listing assemblieren, dann kommt Folgendes raus: Ein C‐Beispiel und dessen Verarbeitung durch GCC
GAS LISTING rno_1_2.s
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
24
25
26
27
28
29
30
31
31
32
32
33
33
34
34
35
35
36
37
38
39
40
41
42
43
44
45
46
47
48
0000
0004
0008
000c
01000000
05000000
02000000
12000000
0000 00000000
0004 00000000
0000
0001
0003
0006
0009
000e
0011
0014
0017
001a
001d
0020
0025
002a
0031
0038
55
89E5
83EC18
83E4F0
B8000000
00
83C00F
83C00F
C1E804
C1E004
8945F0
8B45F0
E8000000
00
E8000000
00
C745FC04
000000
C745F800
000000
C745F400
000000
page 1
.file
.globl _x
.data
.align
_x:
.long
.long
.long
.long
.globl _sum
.bss
.align
_sum:
.space
.def
.text
.globl _main
.def
_main:
pushl
movl
subl
andl
movl
"rno_1_2.c"
4
1
5
2
18
4
4
___main; .scl 2; .type
_main; .scl
2;
%ebp
%esp, %ebp
$24, %esp
$-16, %esp
$0, %eax
addl
addl
shrl
sall
movl
movl
call
$15, %eax
$15, %eax
$4, %eax
$4, %eax
%eax, -16(%ebp)
-16(%ebp), %eax
__alloca
call
___main
movl
$4, -4(%ebp)
movl
$0, -8(%ebp)
movl
$_x, -12(%ebp)
movl
movl
leal
addl
leal
addl
leal
decl
cmpl
je
jmp
-12(%ebp), %eax
(%eax), %edx
-8(%ebp), %eax
%edx, (%eax)
-12(%ebp), %eax
$4, (%eax)
-4(%ebp), %eax
(%eax)
$0, -4(%ebp)
L3
L2
L2:
003f
0042
0044
0047
0049
004c
004f
0052
0054
0058
005a
8B45F4
8B10
8D45F8
0110
8D45F4
830004
8D45FC
FF08
837DFC00
7402
EBE3
L3:
32;
.endef
.type 32;
.endef
7
8 Rechnernetze und –Organisation 49
50
50
51
52
52
52
005c 8B45F8
005f A3000000
00
0064 C9
0065 C3909090
90909090
909090
movl
movl
-8(%ebp), %eax
%eax, _sum
leave
ret
DEFINED SYMBOLS
*ABS*:00000000 rno_1_2.c
.data:00000000 _x
.bss:00000000 _sum
.text:00000000 _main
matloff_p6_shorter.s:5
matloff_p6_shorter.s:13
matloff_p6_shorter.s:19
UNDEFINED SYMBOLS
___main
__alloca
TYPISCHE FRAGEN 



Wie muss man diesen Code lesen? Wie entsteht eine Object‐Datei daraus? Was ist eine Object‐Datei? Wie entsteht eine exekutierbare Datei daraus? Wie bringt man die exekutierbare Datei zur Exekution? BEGINNEN WIR EINFACH MIT EINEM SPIELZEUG‐COMPUTER Betrachten wir ein einfaches Beispiel (Datei rno_1_TOY_1.asm):
; start with data section
x0
DW
1
x1
DW
5
x2
DW
2
x3
DW
18
sum
DW
0
; start of code
lda
lda
lda
lda
top
ldi
add
add
sub
bp
R1,
R2,
R3,
R5,
R4,
R2,
R3,
R1,
R1,
4
0
x0
1
R3
R2,
R3,
R1,
top
done
R2,
sum
st
hlt
R4
R5
R5
Wir übersetzen den Code mit dem TOY‐Assembler: toyasm < rno_1_TOY_1.asm > rno_1_TOY_1.toy
Wie geht es weiter?
Danach laden wir das erzeugte Maschinenprogramm in den TOY‐Simulator. Jetzt können wir das Programm in TOY ausführen. Was läuft da jetzt genau ab? WIE GEHT ES WEITER? 






Darstellung von Information: Zahlen etc. Speicher: Organisation Befehle: Instruktion Set Architecture Ein einfachster Computer: TOY C, Assemblersprache, Maschinensprache Assemblieren und Linken Booten UND DANACH? 




X86: Assemblersprache und Maschinensprache Wie kommunizieren Maschinen? Netzwerke zwischen Rechnern Wieviel muss man von der Hardware wissen? Wie organisieren wir Code und Daten? Input/Output‐Mechanismen 9
10 Rechnernetze und –Organisation Teil A: TOY
TEIL A: TOY Karl C Posch, Feb 2010 PROGRAMMSKIZZE FÜR DIE ERSTEN 3 WOCHEN: 


3. März 2010 10. März 2010 17. März 2010 1.
2.
3.
4.
5.
Representation von Information: Zahlen, Buchstaben, Daten und Anweisungen Von‐Neumann‐Maschine und Instruction Set Architecture Maschinenprogramme, Assemblersprache und Linking Boot Code Security, Buffer‐Overflow INHALT MATERIALIEN: 



TOY‐Simulator (www.cs.princeton.edu/introcs/xtoy) “Notes on Toy” (www.cs.princeton.edu/introcs/50machine/toy.pdf) TOY‐Assembler, TOY‐Linker (http://www.csie.ntu.edu.tw/~cyy/courses/assembly/08fall/lectures/) Matloff: Information Representation and Storage, Kapitel 1‐4. AUFWAND VORLESUNG + ÜBUNG: 17,5 STUNDEN: 


3 * 1,5 Stunden im Hörsaal 3 Stunden Lesen 10 Stunden Übungsaufwand 11
12 Rechnernetze und –Organisation 1 REPRESENTATION VON INFORMATION: ZAHLEN, BYTES, WÖRTER, ADRESSEN Der nachfolgende Text ist zum Teil dem Text von Matloff („Information Representation and Storage“ Kap. 1‐4) entnommen. Ein Computer kann verschiedene Typen von Information speichern. Eine höhere Programmiersprache (High Level Language, HLL) hat typischerweise mehrere Datentypen. In C++ finden wir etwa int, float, oder char. Die Hardware des Computers selbst kann mit Datentypen jedoch nichts anfangen. Die Hardware kennt nur Nullen und Einsen. Damit ergibt sich die Frage, wie diese abstrakten Datentypen von C oder C++ oder auch von anderen höheren Programmiersprachen in Form von Einsen und Nullen gespeichert werden. Wie sieht etwa eine char‐Variable „unter der Motorhaube“ aus? Eine ähnliche Frage stellt sich, wenn wir über die Representation eines Programms mit Hilfe von Nullen und Einsen nachdenken. Wie sehen also die sogenannte „Maschinensprache“ bzw. die Instruktionen dieser Maschinensprache aus. Ein Programm auf Maschinensprach‐Ebene wird ja mit Hilfe eines Compilers und/oder Assemblers übersetzt. In diesem Kapitel wollen wir uns dies ansehen. Wir möchten also wissen, wie und wo die verschiedenen Teile eines Programmes (Code und Daten) im Hauptspeicher eines Computers gespeichert werden. 1.1 ZAHLENDARSTELLUNGEN (Matloff’s Information Representation and Storage, Kapitel 2) 1.1.2 BINÄRE ZAHLEN Wir nennen die Nullen und Einsen im Computer „Bits“. Dieser Begriff kommt von „Binary Digits“. Wir können diese Bits auch als Ziffern eines binären Zahlensystems begreifen. Doch nicht alle Bits in einem Computer stellen Zahlen dar. Wie wir Einsen und Nullen in der Hardware speichern, werden wir im Kapitel über Hardware besprechen. Was wir jedoch festhalten wollen, ist, dass jedwede Information im Computer aus einer Folge von Bits besteht. Es ist üblich, die Bits in einer Folge von Bits von rechts beginnend mit 0 zu nummerieren. Die Bitfolge „1101“ hat also ein Bit 0 mit dem Wert 1, ein Bit 1 mit dem Wert 0 usw. Wenn wir es mit einer Folge von n Bits zu tun haben, welche eine Zahl darstellen, dann nennen wir das (n‐1)‐Bit das höchstwertige Bit („Most Significant Bit“, MSB). Dies kommt aus den Konventionen unseres Dezimalsystems. Wir sprechen bei Bits auch vom „Setzen“ („Set“) und „Löschen“ („Clear“) eines Bits. Ein Bit ist „gesetzt“, wenn es den Wert 1 hat, und „gelöscht“, wenn es den Wert 0 hat. Eine Folge von 8 Bit nennen wir üblicherweise Byte. Speicher werden sehr oft in Anzahl von Bytes angegeben. Manches Mal kommt auch der Begriff „Nibble“ vor. Damit bezeichnet man eine Folge von 4 Bits. 1 Representation von Information: Zahlen, Bytes, Wörter, Adressen
1.1.3 HEX‐NOTATION Es ist üblich, eine Folge von Bits in einer Art „Kurzschrift“ darzustellen. Dazu hat sich die hexadezimale Schreibweise eingebürgert. Wir gruppieren jeweils 4 Bits und geben ihnen einen Namen aus der Menge der Symbole 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E und F. Die Bitfolge 1001110010101110 könnten wir mit 4‐er‐Gruppen so darstellen: 1001_1100_1010_1110. Jetzt interpretieren wir jede 4‐er‐Gruppe als Binärzahl und schreiben 9CAE. Damit es klar ist, dass wir es mit einer Hexadezimalzahl (kurz auch Hex‐Zahl) zu tun haben, fügen wir ein 0x am Anfang ein: 0x9cae. So ist es ja auch in der Sprache C üblich. Was bedeutet obige Bitfolge? Ist es eine Zahl? Eine positive Zahl? Eine negative Zahl? Eine Buchstrabenfolge? Ein Computerbefehl? Dies geht erst aus dem Kontext der Verwendung dieses Bitstrings hervor. Wenn wir die Bitfolge als sogenannte vorzeichenlose Zahl (unsigned integer) betrachten, dann lässt sich der Zahlenwert so ausrechnen: 9*163 + 12*162 + 10*161 + 14*160 = 40110
Der Begriff „Hexa“ kommt aus dem Griechischen und bedeutet „Sechs“. Der Begriff „Dezimal“ kommt aus dem Lateinischen und bedeutet „Zehn“. Komischerweise hat es sich eingebürgert, diese beiden Begriffe zusammen für „Sechzehn“ zu verwenden. Du solltest im Rahmen dieser Lehrveranstaltung kein Problem haben, zwischen dem dezimalen Zahlensystem und dem Hex‐System hin und her zu rechnen. Lerne es. 1.1.4 AUF MASCHINENEBENE GIBT ES KEINE HEX‐ZAHLEN Damit es zu keinen Missverständnissen kommt: In der Computer‐Hardware selbst findet man keine Hex‐Zahlen. Ein Satz wie „Dieser Computer speichert die Zahlen in Hex‐Format“ oder „In Unix werden die Zahlen in Oktalformat gespeichert“ ist sinnlos. Auf Hardware‐Ebene gibt es nur Bits. Wir verwenden das Hex‐Format lediglich als abkürzende Schreibweise, da wir uns als Menschen damit leichter tun als mit den allzu langen Bitfolgen. 1.2 ORGANISATION IM HAUPTSPEICHER Wenn wir ein Computerprogramm ausführen, befindet sich das Programm sowie die dazugehörigen Daten im Hauptspeicher. Das stimmt zwar nicht ganz genau so in jedem Fall, doch der Einfachheit halber wollen wir dies vorerst so betrachten. Wie sieht dieser Hauptspeicher aus? In der Folge werden wir den Hauptspeicher manches Mal auch kurz als Speicher bezeichnen. 1.2.1 BYTES, WÖRTER UND ADRESSEN Wir wollen uns den Speicher als eine lange Bitfolge vorstellen. Jeweils 8 Bits zusammen genommen betrachten wir als Byte. Jedem Byte geben wir eine Identifikationsnummer, die sogenannte Adresse. Damit können wir im Speicher vom Byte 0 (= Byte mit der Adresse 0), Byte 1, Byte 2 usw. sprechen. Auf jeder Maschine fasst man eine bestimmte Anzahl von Bytes als Wort zusammen. Jede Maschine hat also eine bestimmte Wortgröße. Meistens wird diese Wortgröße durch den Addierer innerhalb der CPU bestimmt. Bis vor Kurzem war die Wortgröße meist 32 Bit, also 4 Byte. Mittlerweile finden wir viele Computer mit einer 13
14 Rechnernetze und –Organisation Wortgröße von 64 Bit, also 8 Byte. In dieser Lehrveranstaltung werden wir durchgehend mit einer Wortgröße von 32 Bits arbeiten. Früher hatten Intel‐CPUs Wortgrößen von nur 16 Bit. Alle Intel‐CPUs können aus Kompatibilitätsgründen noch immer diese Wortgrößen verarbeiten. 1.2.2 WORTADRESSEN Wir sehen jetzt also, dass bei einer 32‐Bit‐Maschine jeweils 4 Bytes im Speicher ein Wort ergeben. Byte 0 bis Byte 3 das 1. Wort, Byte 4 bis Byte 7 das 2. Wort und so weiter. Anders herum ausgedrückt: Die 4 Bytes mit den Adressen 1 bis 4 ergeben kein Wort. Die Wortgrenzen sind fest. Bei Unix‐Systemen würde die Hardware einen sogenannten „Bus‐Error“ anzeigen, wenn ein Programm auf ein Wort zugreifen möchte, welches nicht „aligned“ ist. Genauso wie wir bei einem Byte ein höchstwertiges Bit und ein niederwertigstes Bit haben, müssen wir bei Wörtern die Byte‐Reihenfolge festlegen. Folgendes Beispiel nehmen wir dazu: Die Bitfolge bestehend aus 32 Bit stelle die Zahl 25 dar: 00000000000000000000000000011001
Der einfacheren Lesbarkeit halber, füge ich Unterstreichungszeichen ein, damit wir die Bytes besser sehen: 0000_0000__0000_0000__0000_0000__0001_1001
Wir haben also in Hexadezimalschreibweise die Bytes 0x00, 0x00, 0x00 und 0x19. Jeder dieser 4 Bytes hat eine Adresse im Hauptspeicher. Und wir können auch von einer Wortadresse sprechen. Dabei wollen wir die Wortadresse als die kleinste Adresse der vier Byte‐Adressen nehmen. Die Bytes mit den Adressen 4 bis 7 stellen also zusammen das Wort mit der Adresse 4 dar. 1.2.3 „LITTLE ENDIAN“ UND „BIG ENDIAN“ Es stellt sich die Frage, in welcher Reihenfolge die 4 Bytes einer 32‐Bit‐Zahl im Speicher angeordnet sind. In anderen Worten: Auf welcher Adresse liegt das niederwertigste Byte? Bei der Intel‐Familie verwendet man die sogenannte „Little‐Endian“‐Art. Damit meint man, dass das niederwertigste Byte auch auf der niedrigsten Adresse liegt. Nehmen wir zum Beispiel das Wort mit der Adresse 204. Auf einer 32‐Bit‐Maschine besteht dieses Wort also aus den Byte‐Adressen 204, 205, 206 und 207. Auf einer Intel‐Maschine liegt das niederwertigste Byte mit dem Wert 0x19 (aus obigem Beispiel) auf der Adresse 204. Es gibt auch Maschinen, welche die Bytes in umgekehrter Reihenfolge, also in „Big‐Endian“‐Manier speichern. SPARC‐Chips machen das zum Beispiel. Auch die Java‐Virtual‐Maschine verwendet Big‐Endian. Manche Chips wie etwas MIPS oder PowerPC geben dem Betriebssystem die Möglichkeit, eine aus beiden Varianten auszuwählen. Auch am Internet haben wir das gleiche Problem. Das Internet verwendet die Big‐Endian‐
Reihenfolge. Mit folgendem kleinen Programm kannst du herausfinden, welche Byte‐Reihenfolge auf der darunter liegenden Hardware verwendet wird: int Endian() // return 1 if the machine is little-endian, else 0
{
int X;
char *PC;
X = 1;
PC = (char *) &X;
return *PC;
} 1 Representation von Information: Zahlen, Bytes, Wörter, Adressen
Wie wir noch sehen werden, verwenden Compiler für den Datentyp int, also Integer, typischerweise 1 Wort. Für den Datentyp char reicht 1 Byte. Im obigen kleinen Programm – du findest es unter dem Dateinamen Endian.c bei den Beipieldateien ‐‐ wird also die Integer‐Zahl X 4 Bytes in Anspruch nehmen. Die charVariable PC wir auch genau 1 Byte zeigen. Nehmen wir an, dass X auf Adresse 4000 zu liegen kommt. Dann ist &X also 4000. PC wird sodann auch auf die Adresse 4000 zeigen. Das Wort mit der Adresse 4000 besteht ja aus den Bytes mit den Adressen 4000, 4001, 4002 und 4003. Da wir die Variable X mit dem Wert 1 initialisiert haben, wird eines dieser 4 Bytes den „Einser“ drin haben. Alle anderen haben nur Nullen. Wenn der „Einser“ im Byte mit der Adresse 4000 liegt, dann ist es eine Little‐Endian‐Maschine. Beim return‐Befehl zeigt PC auf das Byte mit der Adresse 4000. Damit hat PC also den Wert 1 oder 0, je nach „Endian‐ness“. Innerhalb eines Bytes selbst haben wir kein Problem mit der „Endian‐ness“. Da ist das niederwertigste Bit „rechts“ – wo immer das ist. (Du kannst ja den CPU‐Chip drehen ) Für einen C‐Compiler besteht jedoch das Problem mit der „Endian‐ness“. Stelle dir das C‐Kommando int Z = 0x12345678;
vor. Dabei sei die Adresse von Z, also @Z gleich 240. Compiler packen typischerweise Integer‐Variablen in ein Wort. Damit landet Z also auf den Adressen 240, 241, 242 und 243 – egal ob auf einer Big‐Endian‐Maschine oder auf einer Little‐Endian‐Maschine. Auf einer Little‐Endian‐Maschine wird das Byte 0x78 auf der Adresse 240 abgelegt. Auf einer Big‐Endian‐
Maschine hingegen auf Adresse 243. Auch bei der C‐Funktion printf() mit dem Format‐String „%x“ gibt es Unterschiede zwischen Big‐Endian und Little‐Endian. Das Byte mit der höchsten Adresse wird bei Little‐Endian‐Maschinen zuerst „gedruckt“. Bei Big‐
Endian‐Maschinen hingegen wird das Byte mit der Adresse 240 zuerst „gedruckt“. 1.2.4 ANDERE THEMEN Viele Maschinen können nur mit Wörtern umgehen, welche „aligned“ sind. Damit meint man, dass bei einer 32‐Bit‐Maschine etwa Wörter nur mit Adressen, welche ein ganzzahliges Vielfaches von 4 sind, beginnen dürfen. Die Bytes 568 bis 571 formen also das Wort mit der Adresse 568. Aber die Bytes mit den Adressen 569 bis 572 sind kein Wort. Wir sehen also, dass die Wortadresse identisch ist mit der niedrigsten Byteadresse. Da entsteht jetzt ein Problem: Wie können wir unterscheiden, ob wir das Wort mit der Adresse 52 oder das Byte mit der Adresse 52 meinen? Die Antwort besteht darin, dass manche Maschineninstruktionen mit Wörtern arbeiten, andere mit Bytes und manche mit beiden Typen. Bei letzteren gibt es dann ein Unterscheidungsmerkmal beim Namen der Instruktion selbst. [Ab hier geht der Originaltext von Matloff noch mehr ins Detail. Bitte dort weiter lesen. Seite 8.] Es ist heutzutage üblich, Maschinen zu bauen, deren Adressgröße gleich der Wortgröße ist. Wir können damit auch Adressen in Wörtern speichern. Wie wir sehen werden, kommt dies sehr oft vor. 15
16 Rechnernetze und –Organisation 1.3 WIE WIRD INFORMATION ALS FOLGE VON BITS DARGESTELLT Leseaufgabe 1: Lies dazu den Originaltext von Matloff: „Information Representation and Storage“, Kapitel 4: „Representing Information as Bit Strings“. 






Zahlen, Zahlensysteme, Konvertierung zwischen Zahlensystemen, negative Zahlen mit 2‐er‐Komplementdarstellung Gleitkommazahlendarstellung: Mantisse und Exponent, IEEE‐Standard ASCII‐Darstellung: Auch Buchstaben sind Zahlen Darstellung von Maschineninstruktionen: Alles lässt sich als Zahl darstellen Lese‐Aufgabe 2: http://www.cs.princeton.edu/introcs/51data/ 






Verständnis unseres Stellenwert‐Zahlensystems Konvertierung zwischen Zahlensystemen Addition und Multiplikation in anderen Zahlensystemen Darstellung von negativen Zahlen in Zweierkomplementdarstellung Operationen auf Bit‐Ebene: Not, And, Or, Xor, Left shift, Right shift, Unsigned right shift. Big Endian, little endian Overflow 2 2 TOY 2.1 EINFÜHRUNG IN TOY 













TOY‐Instruktionen zeigen. Ein paar zuerst. Nur als Beispiel. In TOY rechts oben als Pseudo‐Code‐Darstellung. TOY auf Sim‐Mode umschalten. Hauptspeicher erklären. Speicher ist eine „lange Reihe“ von „Bytes“. Jedes Byte hat eine Identifikationsnummer, die Adresse TOY spricht jeweils 2 Bytes gemeinsam als „Word“ an. „Word size“ ist also 2. X86: 32 Bit oder 64 Bit: Word Size ist 4 oder 8. TOY: Look. Binär. Der leichteren Lesbarkeit halber auch im Memory‐Fenster in Hex‐Notation dargestellt. TOY‐Registers. Program counter. Anfangswert. Core dump. Input: Load with Switches. Output: Look. Running the machine: Zeige mit Load immediate (z.B. 7103), wie man im Sim‐Mode einen Befehl ausprobiert. Dann „kleines Programm“: lda
lda
add


2.2 







R1,
R2,
R3,
1
2
R1,
R2
7101
7202
1312
Von Neumann Maschine kurz. Lese‐Aufgabe: http://www.cs.princeton.edu/introcs/52toy/ INSTRUCTION SET ARCHITECTURE Der Befehlssatz von TOY. Bild zeigen (Powerpoint‐Datei toy.ppt). Das Instruktionsformat: Op‐Code, Destination, Source1, Source2 Load‐Store‐Architektur Load: Load immediate, Load direct, Load indirect. Branch Jump Jump & link (kurze) Lese‐Aufgabe: http://www.cs.princeton.edu/introcs/53isa/ TOY
17
18 Rechnernetze und –Organisation 3 MASCHINENPROGRAMME, ASSEMBLERSPRACHE UND LINKING 3.1 TOY‐PROGRAMMIERUNG 


Umschalten auf TOY‐Edit‐Mode Einfache Programme. Zeigen wie man Programm in den Simulator lädt. Zuerst zwei Zahlen aus Hauptspeicher addieren und zurückspeichern. C‐Programm dazu als Illustration zeigen: //rno_1_TOY_2.c
#include <stdio.h>
int a = 3;
int b = 4;
int c = 0;
void main()
{
c = a + b;
printf("c=%d\n", c);
}

Zeile für Zeile „umschreiben“ und daraus die Datei rno_1_TOY_2.toy machen: 00:
01:
02:
10:
11:
12:
13:


Subtraktion zeigen. Einfach statt 1CAB 2CAB schreiben. 2er‐Komplement Resultat FFFF zeigen. Erklären. Warum ist das ‐1? Standard‐Input, Standard‐Output. Zwei Zahlen zusammen zählen. (rno_1_TOY_3.toy) 10:
11:
12:
13:

8AFF
8BFF
1CAB
9CFF
Mehrere Zahlen aus Standard‐Input (mit Null terminiert) zusammenzählen. Eingehen auf das Problem des Endes einer Liste. Mit Null terminieren, wie in C. Oder mit Zähler davor. „Open Example Program“ in TOY. Programm sum laden. Ausprobieren. Erklären. 10: 7C00
RC <- 0000
11:
12:
13:
14:
15:
16:
read RA
if (RA == 0) pc <- 15
RC <- RC + RA
pc <- 11
write RC
halt
0003
0004
0000
8A00
8B01
1CAB
9C02
8AFF
CA15
1CCA
C011
9CFF
0000
sum = 0
while (true) {
read a
if (a == 0) break
sum = sum + a
}
write sum
3
Maschinenprogramme, Assemblersprache und Linking
Eventuell zurück zu C: Obiges Beispiel in C: //rno_1_TOY_4.c
#include <stdio.h>
int sum = 0;
int a;
void main()
{
while(1) {
scanf("%d",
if (a == 0)
sum = sum +
}
printf("sum = %d",
}
&a);
break;
a;
sum);

3.2 

Lese‐Aufgabe: http://www.cs.princeton.edu/introcs/54programming/ DER TOY‐SIMULATOR Wozu Simulatoren?
Der TOY‐Simulator in Java /*************************************************************************
* Compilation: javac TOY.java StdIn.java In.java
* Execution:
java TOY file.toy
*
* We use variables of type int to store the TOY registers, main
* memory, and program counter even though in TOY these quantities
* are 16 and 8 bit integers. Java does not have an 8-bit unsigned
* type. The type short in Java does represent 16-bit 2's complement
* integers, but using it requires alot of casting. Instead, we are
* careful to treat all of the variable as if they were the appropriate
* type so that the behavior truly models the TOY machine.
*
* Probably need to cast with right shift to simulate signed short.
*
*************************************************************************/
import java.util.regex.Pattern;
import java.util.regex.Matcher;
public class TOY {
// return a 4 digit hex string corresponding to n
public static String toHex(int n) {
String symbols = "0123456789ABCDEF";
int base
= 16;
// print in hex
int digits = 4;
// print out 4 hex digits (for 16-bit integers)
String s = "";
for (int i = 0; i < digits; i++) {
int d = n % base;
s = symbols.charAt(d) + s;
n = n / base;
}
return s;
}
// return an integer corresponding to the 4 digit hex string
19
20 Rechnernetze und –Organisation public static int fromHex(String s) {
return Integer.parseInt(s, 16) & 0xFFFF;
}
// display an array of hex integers, 16 per line
public static void show(int[] a) {
for (int i = 0; i < a.length; i++) {
System.out.print(toHex(a[i]) + " ");
if (i % 16 == 15) System.out.println();
}
System.out.println();
}
// the TOY simulator
public static void main(String[] args) {
int pc
= 0x10;
int[] R
= new int[16];
int[] mem = new int[256];
// program counter
// registers
// main memory
/****************************************************************
* Read in memory location and instruction.
* A valid input line consists of 2 hex digits followed by a
* colon, followed by any number of spaces, followed by 4
* hex digits. The rest of the line is ignored.
****************************************************************/
// read the TOY program directly from the file
String filename = args[0];
In in = new In(filename);
String regexp = "([0-9A-Fa-f]{2}):[ \t]*([0-9A-Fa-f]{4}).*";
Pattern pattern = Pattern.compile(regexp);
String line;
while ((line = in.readLine()) != null) {
Matcher matcher = pattern.matcher(line);
if (matcher.find()) {
int addr = fromHex(matcher.group(1));
int inst = fromHex(matcher.group(2));
mem[addr] = inst;
}
}
/*****************************************************
* Print it back out.
*****************************************************/
System.out.println("Before");
System.out.println("---------------------------------------------------");
show(R);
show(mem);
while(true) {
// Fetch and parse
int inst = mem[pc++];
int op
= (inst >> 12)
int d
= (inst >> 8)
int s
= (inst >> 4)
int t
= (inst >> 0)
int addr = (inst >> 0)
& 15;
& 15;
& 15;
& 15;
& 255;
// fetch
// get
// get
// get
// get
// get
next instruction
opcode (bits 12-15)
dest
(bits 8-11)
s
(bits 4- 7)
t
(bits 0- 3)
addr
(bits 0- 7)
// halt
if (op == 0) break;
// stdin
if ((addr == 255 && op == 8) || (R[t] == 255 && op == 10))
3
Maschinenprogramme, Assemblersprache und Linking
mem[255] = fromHex(StdIn.readString());
// Execute
switch (op) {
case 1: R[d] = R[s] + R[t];
case 2: R[d] = R[s] - R[t];
case 3: R[d] = R[s] & R[t];
case 4: R[d] = R[s] ^ R[t];
case 5: R[d] = R[s] << R[t];
case 6: R[d] = (short) R[s] >> R[t];
case 7: R[d] = addr;
case 8: R[d] = mem[addr];
case 9: mem[addr] = R[d];
case 10: R[d] = mem[R[t] & 255];
case 11: mem[R[t] & 255] = R[d];
case 12: if ((short) R[d] == 0) pc = addr;
case 13: if ((short) R[d] > 0) pc = addr;
break;
break;
break;
break;
break;
break;
break;
break;
break;
break;
break;
break;
break;
//
//
//
//
//
//
//
//
//
//
//
//
//
add
subtract
bitwise and
bitwise xor
shift left
shift right
load address
load
store
load indirect
store indirect
branch if zero
branch if
break;
break;
// jump indirect
// jump and link
positive
case 14: pc = R[d];
case 15: R[d] = pc; pc = addr;
}
// stdout
if ((addr == 255 && op == 9) || (R[t] == 255 && op == 11))
System.out.println(toHex(mem[255]));
R[0] = 0;
R[d] = R[d] & 0xFFFF;
pc = pc & 255;
//
//
//
//
ensure R0 is always 0
don't let R[d] overflow a 16-bit integer
(needed???)
don't let pc overflow an 8-bit integer
}
/*****************************************************
* Print out final contents of TOY.
*****************************************************/
System.out.println("After");
System.out.println("---------------------------------------------------");
show(R);
show(mem);
}
} 


3.3 
Lese‐Aufgabe http://www.cs.princeton.edu/introcs/55simulator/ Eventuell: http://www.cs.princeton.edu/introcs/lectures/5toy1.pdf Eventuell: http://www.cs.princeton.edu/introcs/lectures/5toy2.pdf TOY‐ASSEMBLERSPRACHE Wozu Assemblersprache? Vergleiche: o C‐ähnlich: R1 = 1;
R2 = 2;
R3 = R1 + R2;
o
Assemblersprache: lda R1, 1
lda R2, 2
add R3, R1, R2
21
22 Rechnernetze und –Organisation o
Registertransfersprache: R[1] <- 1
R[2] <- 2
R[3] <- R[1] + R[2]
o
TOY‐Maschinensprache: 0x7101
0x7202
0x1312

TOY‐Instruktionsformat: Jeweils 4 Bit für: o Opcode o Destination o Source1 (s) o Source2 (t) 
Nochmals der TOY‐Instruktionssatz. 0
1
2
3
4
5
6
7
8
9
A
B
C
D
E
F







RD,
RD,
RD,
RD,
RD,
RD,
RD,
RD,
RD,
RD,
RD,
RD,
RD,
RD
RD,
RS, ST
RS, ST
RS, ST
RS, ST
RS, ST
RS, ST
imm
addr
addr
RT
RT
addr
addr
addr
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
addition
subtraction
logical AND
logical XOR
shift left
shift right
load immediate
load direkt
store
load indirect
store indirect
branch on zero
branch on positive
jump
jump and link
Comments begin with semicolons. Only support decimal literals or hexadecimal literals. Hexadecimal literals must begin with 0x Two assembly directives for data definition: o DUP for declaring array o DW for defining a number o
hlt
add
sub
and
xor
shl
shr
lda
ld
st
ldi
sti
bz
bp
jr
jl
Examples: A
B
DUP
DW
5
10
; define an array of five words
; declare a variable B, initialized with 10
Labels must start with a letter. It can contain letters and numbers. No colons after labels. It is case‐insensitive. Program will start from the first instruction that the assembler meets Use of Assembler: toyasm < reverse.asm > reverse.toy 3
A
DUP
32
lda
lda
lda
R1, 1
RA, A
RC, 0
read
ld
bz
add
sti
add
bz
RD,
RD,
R2,
RD,
RC,
R0,
exit
jl
hlt
RF, printr
Maschinenprogramme, Assemblersprache und Linking
0xFF
exit
RA, RC
R2
RC, R1
read
; print reverse
; starting address must be placed at RA
; number of elements must be placed at RC
printr sub
RC, RC, R1
add
R2, RA, RC
ldi
RD, R2
st
RD, 0xFF
bp
RC, printr
bz
RC, printr
return jr
RF


To generate an object file: toyasm -o < mul.asm > mul.obj
To link several object files together to make an executable file: toylink multest.obj mul.obj stack.obj > multest.toy
Program will start from the first instruction of the first object file. 3.4 BEISPIEL FÜR VERWENDUNG DES TOY‐ASSEMBLERS Multiplikation programmieren (multiply.asm): durch n‐malige Addition. Zeigen von Loops. A
B
C
DW
DW
DW
3
9
0
ld
ld
ld
lda
RA,
RB,
RC,
R1,
A
B
C
1
loop
bz
add
sub
bz
RA,
RC,
RA,
R0,
exit
RC, RB
RA, R1
loop
exit
st
hlt
RC, 0xFF
;
;
;
;
if (RA == 0) goto exit
RC = RC + RB
RA = RA - 1
goto loop
23
24 Rechnernetze und –Organisation Alternativer Multiplikationsalgorithmus: (multiply-fast.asm): mit Shift‐Operation. A
B
C
SIXTEEN
loop
end
DW
DW
DW
DW
3
9
0
16
ld
ld
ld
lda
ld
RA,
RB,
RC,
R1,
R2,
A
B
C
1
SIXTEEN
sub
shl
shr
and
bz
add
bp
R2,
R3,
R4,
R4,
R4,
RC,
R2,
R2, R1
RA, R2
RB, R2
R4, R1
end
RC, R3
loop
st
hlt
RC, 0xFF
; R2 is i
Vergleich der beiden obigen Methoden. Die erste Variante läuft linear mit der Größe des Multiplikators. Die zweite Variante in logarithmischer Laufzeit. Unterprogrammaufruf: (Datei reverse.asm): A
DUP
32
lda
lda
lda
R1, 1
RA, A
RC, 0
read
ld
bz
add
sti
add
bz
RD,
RD,
R2,
RD,
RC,
R0,
exit
jl
hlt
RF, printr
0xFF
exit
RA, RC
R2
RC, R1
read
; print reverse
; starting address must be placed at RA
; number of elements must be placed at RC
printr sub
RC, RC, R1
add
R2, RA, RC
ldi
RD, R2
st
RD, 0xFF
bp
RC, printr
bz
RC, printr
return jr
RF

Eventuell als Draufgabe: Insertion Sort (insertion-sort.toy) 3
Maschinenprogramme, Assemblersprache und Linking
3.5 LINKING 
Verteilen des Multiplikations‐Codes von oben auf 2 Dateien. Stack: Operationen PUSH und POP. Zuerst Pseudo‐Code: Achtung, Fehler im Assemblercode “stacktest.asm”. push A
push B
pop RF
write RF
pop RF
write RF

In TOY‐Assemblercode sieht das so aus: A
B
C
D
DW
DW
DW
DW
1
2
3
4
main
ld
jl
RF, A
RE, push
ld
jl
RF, B
RE, push
jl
st
RE, pop
RF, 0xFF
jl
st
RE, pop
RF, 0xFF
hlt
Push und Pop als Unterprogramme in der Datei stack.asm: STK_TOP
DW
0xFF
; these procedures will use R8, R9
; return address is assumed to be in RE, instead of RF
; push RF into stack
push
lda
R8,
ld
R9,
sub
R9,
st
R9,
sti
RF,
jr
RE
1
STK_TOP
R9, R8
STK_TOP
R9
; pop and return [top] to RF
pop
lda
R8, 0xFF
ld
R9, STK_TOP
sub
R8, R8, R9
bz
R8, popexit
ldi
RF, R9
25
26 Rechnernetze und –Organisation popexit
lda
add
st
jr
R8, 1
R9, R9, R8
R9, STK_TOP
RE
; the size of the stack, the result is in R9
stksize
lda
R8, 0xFF
ld
R9, STK_TOP
sub
R9, R8, R9
jr
RE

Linken: Testen des Stack: toyasm –o stacktest.asm
toyasm –o stack.asm
toylink stacktest.obj stack.obj > stacktest.toy


WICHTIG: Das „Hauptprogramm“, in diesem Fall also stacktest, muss dem Linker zuerst angeboten werden! Hausaufgabe: Multiplikation unter Verwendung von Stack: (Dateien multest.asm, stack.asm) Datei multest.asm: Multipliziere A*B*C durch zweimaligen Aufruf des Unterprogramms mul3, welches seinerseits die Unterprogramme push und pop braucht. Letztere sind nicht in dieser Datei definiert und müssen dazu gelinkt werden. A
B
C
DW
DW
DW
3
4
5
; calculate A*B*C
main
ld
RA,
ld
RB,
ld
RC,
jl
RF,
st
RD,
hlt
A
B
C
mul3
0xFF
; RD=RA*RB*RC
; return address is in RF
mul3
jl
RE, push
; push return address
lda
add
jl
RD, 0
RD, RC, R0
RF, mul
; RD=0
; RD=RC
; RC=RA*RB
add
add
jl
RA, RC, R0
RB, RD, R0
RF, mul
; RA=RC=RA*RB
; RB=RD=RC
add
RD, RC, R0
; move result to RD
jl
jr
RE, pop
RF
; pop return address and return
3
Maschinenprogramme, Assemblersprache und Linking

Studium des Inhalts der Dateien. Was braucht der Linker um seine Arbeit ausführen zu können? Besprechung des Inhalts der beiden folgenden Objekt‐Dateien.
Inhalt der Datei stack.obj: // size 35
(von Adresse 0 bis Adresse 34 = 0x22)
// export 5
(Exportiere nachfolgende 5 Stück)
// STK_TOP 0x00
(Adresse von STK_TOP)
// push 0x10
(Adresse von push)
// pop 0x16
(Adresse von pop)
// popexit 0x1E
(Adresse von poxexit)
// stksize 0x1F
(Adresse von stksize)
// literal 4 16 22 27 31
(4 Stück Literale auf Adressen 16, 22, 27 und 31)
// lines 20
(es folgen noch 20 Zeilen)
00: 00FF
10: 7801
11: 8900
12: 2998
13: 9900
14: BF09
15: EE00
16: 78FF
17: 8900
18: 2889
19: C81E
1A: AF09
1B: 7801
1C: 1998
1D: 9900
1E: EE00
1F: 78FF
20: 8900
21: 2989
22: EE00
// import 0
(Keine Imports)

Inhalt der Datei stacktest.obj
// size 27
(von Adresse 0 bis Adresse 26 = 0x1A)
// export 5
(Exportiere folgende 5 Symbole)
// A 0x000
(Adresse von Label A)
// B 0x01
(Adresse von Label B)
// C 0x02
(Adresse von Label C)
// D 0x03
(Adresse von Label D)
// main 0x10
(Adresse von Label main)
// literal 7 0 1 2 3 21 23 25 (7 Literale auf den Adressen 0,1,2,3,21,23 und 25)
// lines 15
(es kommen 15 Zeilen)
00: 0001
...
1A: 0000
// import 2
// push 2 0x11 0x13
// pop 3 0x14 0x16 0x18
27
28 Rechnernetze und –Organisation 4 BOOT CODE 
Im Assemblercode sieht das Boot‐Programm folgendermaßen aus: A
B
DW
DUP
0xCAFE
223
start
lda
ld
ld
sub
bz
st
hlt
ld
add
ld
sub
bz
ld
sti
add
jl
jr
hlt
R1,
R2,
R3,
R3,
R3,
R3,
0x1
A
0xFF
R3, R2
go
0xFF
R4,
R5,
R6,
R6,
R6,
R7,
R7,
R5,
RF,
R4
0xFF
R4, R0
0xFF
R6, R1
exit
0xFF
R5
R5, R1
loop
go
loop
exit
// leave space for 223 words,
// i.e. start at location 224
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
//
R1 holds constant 1
load „CAFE“
read first word
check for „CAFE“
branch to „go“ if „CAFE“ found
otherwise write error value to stdout
stop here: stdin does not start with CAFE
read second word: start address in R4
temp copy of start address
read 3rd word: amount of words to load
loop: decrement R6
goto exit if all words have been read
read a word
store in appropriate memory location
increment code address
go to loop
jump to program's start address
this line should not be reached


„Booten“: Programm wird über STDIN ab Adresse 0x10 geladen. Nach dem Laden wird die Programmkontrolle auf die Startadresse übergeben. Das ladbare Format sieht dabei folgendermaßen aus: CAFE 1. Wort: 2. Wort: Startadresse 3. Wort: Anzahl der noch zu ladenden Speicherwörter Beispiel: Summe von 3 Zahlen: sum3_kc_alternativ.asm ; hier beginnen die Instruktionen:
start ld
RC, A
ld
RA, B
add
RC, RC, RA
ld
RA, C
add
RC, RC, RA
st
RC, D
end
hlt
; Definiere 4 symbolische Variablen und deren Anfangswerte:
A
DW
4
B
DW
5
C
DW
6
D
DW
0

Maschinencode des obigen Beispiels: 10:
11:
12:
13:
14:
8C17
8A18
1CCA
8A19
1CCA
4 15:
16:
17:
18:
19:
1A:

9C1A
0000
0004
0005
0006
0000
Exekutierbare und mit Bootcode ladbare Datei des obigen Beispiels: CAFE
0010
000B
8C17
8A18
1CCA
8A19
1CCA
9C1A
0000
0004
0005
0006
0000

Boot code
(Magic Word ist 0xCAFE)
(Startadresse ist 0x10)
(Anzahl der noch zu ladenden Wörter ist 11 = 0xB)
Wie könnten wir wieder zurück kommen? Hinweis auf Stack. Vielleicht mit explizitem Beispiel, wie das geht. 29
30 Rechnernetze und –Organisation 5 UNTERSCHIED DATEN UND CODE: SECURITY‐PROBLEMATIK, BUFFER‐OVERFLOW 
//
//
//
//
//
//
//
//
Buffer Overflow: Crazy8.toy
Input:
Output:
Remarks:
A list of up to 16 positive integers terminated by a 0000
The positive integers in reverse order
The data is stored starting at memory location 00.
If you enter more than 16 integers, you will overwrite
the program itself. To see the crazy 8 virus, enter
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
(16 1's)
8888 8810 98FF C011
-----------------------------------------------------------------------------
10: 7101
11: 7A00
12: 7B00
R[1] <- 0001
R[A] <- 0000
R[B] <- 0000
// Read in
13: 8CFF
14: CC19
15: 16AB
16: BC06
17: 1BB1
18: C013
sequence of positive integers
read R[C]
while (1) {
if (R[C] == 0) goto 19
if ((read R[C]) == 0) break
R[6] <- R[A] + R[B]
a + n
mem[R[6]] <- R[C]
a[n] = c
R[B] <- R[B] + R[1]
n++
goto 13
}
// Print out results in reverse order
19: CB20
if (R[B] == 0) goto 20
1A: 16AB
R[6] <- R[A] + R[B]
1B: 2661
R[6] <- R[6] - R[1]
1C: AC06
R[C] <- mem[R[6]]
1D: 9CFF
write R[C]
1E: 2BB1
R[B] <- R[B] - R[1]
1F: C019
goto 19
20: 0000
halt
R[1] always 1
memory address of array a[]
# elements in array = n
while (n != 0) {
a + n
a + n - 1
c = a[n-1]
print c
n-}
Wenn man obiges Programm laufen lässt und bei STDIN mehr als 16 Werte eingibt , dann wird der Code überschrieben. Man kann selbst „Codeinjektion“ machen. Sehen wir uns die (fast identische) Datei (crazy8_kc.asm) zuerst in Assemblercode an: ;Input:
A list of up to 16 positive integers terminated by a 0000
;Output:
The positive integers in reverse order
;Remarks: The data is stored starting at memory location 00.
;
If you enter more than 16 integers, you will overwrite
;
the program itself. To see the crazy 8 virus, enter
;
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
(16 1's)
;
8888 8810 98FF C011
; ------------------------------------------------------------------------ld R1, one ;R[1] always 1
lda RA, 0
; memory address of array a[]
lda RB, 0
;elements in array = n
; Read in sequence of positive integers
start ld RC, 0xFF
; while (1) {
bz RC, print
;
if ((read R[C]) == 0) break
add R6, RA, RB
;
a + n
sti RC, R6
;
a[n] = c
add RB, RB, R1
;
n++
bz R0, start ;
goto 13
; }
;Print out results in reverse order
5 print bz
add
sub
ldi
st
sub
bz
end
hlt
RB,
R6,
R6,
RC,
RC,
RB,
R0,
; Data
one
DW
1
end
RA, RB
R6, R1
R6
0xFF
RB, R1
print
Unterschied Daten und Code: Security‐Problematik, Buffer‐Overflow
; while (n != 0) {
;
a + n
;
a + n - 1
;
c = a[n-1]
;
print c
;
n-;
goto 19
; }
; int one = 1;
Hier die Datei, mit welcher das Programm über STDIN „gefüttert“ werden sollte: Zuerst 16 Mal eine Konstante, z.B. „1“. Und danach der Code, welchen wir selbst laufen lassen möchten. 1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
8888
8810
98FF
C011
31
32 Rechnernetze und –Organisation Teil B: X86
TEIL B: X86 Karl C Posch, Jan 2010 PROGRAMMSKIZZE FÜR DIE ZWEITEN 3 WOCHEN: 


24. März 2010 21. April 2010 28. April 2010 6.
7.
Einführung in Linux‐Assemblersprache Einführung in Intel‐Maschinensprache INHALT TEXTMATERIALIEN: 



Matloff: Introduction to Linux Intel Assembly Language (Optional) Matloff: More on Intel Arithmetic and Logic Operations Matloff: Introduction to Intel Machine Language (and More on Linking) Compiler, Assembler, Linker, and Loader: A Brief Story: http://www.tenouk.com/ModuleW.html FÜR DIE PROGRAMMIERUNG: 
Ein Texteditor. 
gcc, as, gdb. 
SSH zu Pluto oder ein eigenes Linux. Für Windows‐Menschen: Debian‐Linux in einer Virtual Box. AUFWAND VORLESUNG + ÜBUNG: 22,5 STUNDEN: 


3 * 1,5 Stunden im Hörsaal 3 Stunden Lesen 15 Stunden Übungsaufwand 33
34 Rechnernetze und –Organisation 6 EINFÜHRUNG IN LINUX‐ASSEMBLERSPRACHE Nach Mattloff: Introduction to Linux Intel Assembly Language 6.1 EINLEITUNG 




Programme exekutieren auf CPUs. Beispiele für CPUs: TOY, Pentium, PowerPC, MIPS, und viele andere. Programme bestehen aus Daten und Code. Daten und Code befinden sich während der Exekution im Hauptspeicher (RAM). Programme werden meist in „Hochsprache“ geschrieben und dann in Maschinensprache übersetzt. Mit Compiler und/oder Assembler.  Die CPU „holt“ die Maschinen‐Instruktionen (eine nach der anderen) vom Hauptspeicher und exekutiert diese. Dazu holt diese auch Daten vom Speicher oder speichert eventuell neu erzeugte Datenwerte im Speicher ab.  Die Verbindung zwischen CPU und Hauptspeicher nennen wir „Bus“. Einfache Vorstellung: viele Drähte.  Innerhalb der CPU gibt es „Register“, in welchen Daten zwischengespeichert werden können. Ein Register ist einem Speicherwort im Hauptspeicher sehr ähnlich. Der Zugriff auf ein CPU‐Register ist jedoch um ein Vielfaches schneller als der Zugriff auf ein Datenwort im Hauptspeicher.  Wenn wir in Assemblersprache programmieren, versuchen wir deshalb so viele Variablen wie möglich in den CPU‐Registern zu halten. Ein Compiler versucht dies auch. Und was kommt jetzt?  Wir sollten zuerst kurz über Computer, CPU‐Architekturen und über die Intel‐Architektur reden.  Danach sollten wir genauer auf Assemblersprachen und verschiedene Assembler eingehen.  Wir schauen uns dann das Einstiegsbeispiel (4 Zahlen addieren) nochmals an. Jedoch auf eine etwas andere Weise.  Im Anschluss denken wir über Register und über den Hauptspeicher nach.  Adressierungsmodi sollten wir auch verstehen.  Wie entsteht dann eine exekutierbare Datei: Dazu müssen wir über „Linken“ sprechen.  Wir werden dann ein Programm „exekutieren“. Mit GDB.  Schließlich sprechen wir noch über Debugging, Adressierungsmodi, GCC, Linker, und eventuell über Inline‐Assembly.  Am Ende schauen wir uns die Arbeit eines Assemblers genauer an. 6
6.2 Einführung in Linux‐Assemblersprache
CPU‐ARCHITEKTUR UND INTEL‐ARCHITEKTUR 


CPU‐Architektur: Damit meinen wir den Instruktionssatz und die Register einer bestimmten Maschine. Unterschied Maschinensprache und „anderen Programmiersprachen“: Maschinensprache ist für einen bestimmten CPU‐Typ einzigartig. Kann auf einem anderen CPU‐Typ nicht laufen, sondern bedeutet für diesen andern CPU‐Typ „Nonsense“. Intel‐Architektur: o Intel‐CPUs können in verschiedenen Modi laufen. Linux verwendet den „protected, flat 32‐bit mode“. Oder auch den „64‐bit mode“. o Von nun an sprechen wir nur vom „protected, flat 32‐bit mode“ o Intel‐Instruktionen haben verschiedene Längen. o Die Register heißen:  EAX  EBX  ECX  EDX  ESI  EDI  EBP  ESP o Im Programm‐Code verwenden wir Kleinbuchstaben mit % davor. Z.B. %eax o Alle Register haben 32 Bit. (Auf die Register EAX, EBX, ECX und EDX kann man auch in kleineren Portionen zugreifen). o Die unteren 16 Bit von EAX nennt man AX. o Die unteren 8 Bit von AX nennt man AL, die oberen AH. o EPB und ESP sind für den allgemeinen Gebrauch nicht verwendbar. o Bei Programmen mit Unterprogrammaufruf sollte man auch ESP nicht verwenden. Warum? Davon sprechen wir später. o Finger weg von EBP ebenfalls. o Es gibt ein weiteres Register namens EIP (extended instruction pointer). Dieses können wir auch nicht verwenden. Es „zeigt“ immer auf die nächste zu holende Instruktion. o Und dann gibt es noch das Register EFLAGS. Seine Verwendung ist ebenfalls „reserviert“. Wir sprechen davon weiter unten. 6.3 ASSEMBLERSPRACHE 
Maschinensprache besteht aus langen Strings aus 1 und 0. Da die Programmierung auf dieser Ebene für Menschen zu umständlich war, hat man die Assemblersprache erfunden. 
Assemblersprache besteht aus Abkürzungen für spezielle Bit‐Strings. Zum Beispiel: Der Befehl 01100110100111000011 bedeutet, dass der Inhalt des AX‐Registers in das BX‐Register kopiert werden soll. Einfacher lesbar ist „mov %ax, %bx“. 
Aufpassen: mov steht für „move“. Und eigentlich werden die Inhalte kopiert. 
Ein Assembler übersetzt Assemblersprache in Maschinensprache. 35
36 Rechnernetze und –Organisation 
In Linux bzw. Unix ist es üblich, Dateien in Assemblersprache mit dem Suffix „.s“ zu benennen. Ein Assembler kriegt als Input eine „.s“‐Datei und macht daraus eine Objekt‐Datei, welche man üblicherweise mit dem Suffix „.o“ versieht. 
In der Windows‐Welt werden „.asm“ und „.obj“ stattdessen verwendet. 
Ein Assembler arbeitet also ähnlich wie ein Compiler. Stimmt zwar im Prinzip, doch ist diese Aussage auch irreführend: Der Assembler hat keine Freiheitsgrade beim Übersetzen, während ein Compiler diese sehr wohl hat. 
Beispiel: Sehen wir uns die C‐Anweisung z = x+y an. Diese kann von verschiedenen Compilern verschieden in Assemblersprache bzw. Maschinensprache übersetzt werden. 6.4 VERSCHIEDENE ASSEMBLER 
In dieser Lehrveranstaltung verwenden wir den GNU Assembler AS. Dieser versteht die sogenannte „AT&T‐Syntax“. Es gibt auch einen recht bekannten anderen Assembler, den NASM. Dieser verwendet die „Intel‐Syntax“. 
Beispiel: mov %ax, %bx AT&T‐Syntax: mov bx, ax Intel‐Syntax: In beiden Fällen meinen wir den String 01100110100111000011 bzw. wollen den Inhalt des Registers AX nach BX kopieren. 6.5 BEISPIELPROGRAMM: WIR WOLLEN 4 ZAHLEN ZUSAMMENZÄHLEN 
Wir betrachten nochmals das Beispiel, wo wir 4 Zahlen zusammengezählt haben: 
Du findest diese Variante unter „Total.s“ oder unter „Add4.s“. Entweder auf dem RNO‐Web oder auch unter http://heather.cs.ucdavis.edu/~matloff/50/Add4.s Es ist eine Textdatei, welche mit einem Texteditor geschrieben wurde. # introductory example; finds the sum of the elements of an array
# assembly/linking Instructions:
# as -a --gstabs -o total.o Total.s
# ld -o total total.o
.data
# start of data segment
x:
.long
.long
.long
.long
1
5
2
18
6
Einführung in Linux‐Assemblersprache
sum:
.long 0
.text
# start of code segment
.globl _start
_start:
movl $4, %eax
# EAX will serve as a counter for
# the number words left to be summed
movl $0, %ebx # EBX will store the sum
movl $x, %ecx # ECX will point to the current
# element to be summed
top: addl (%ecx), %ebx
addl $4, %ecx # move pointer to next element
decl %eax # decrement counter
jnz top # if counter not 0, then loop again
done: movl %ebx, sum # done, store result in "sum"

Wir analysieren obiges Beispiel im Detail. 
Die Quelldatei (source file) in Assemblersprache ist unterteilt in zwei Sektionen: data und text. Es gibt auch andere, über welche wir später sprechen werden. Vorerst also nur diese beiden: Die Sektion data enthält die Daten und die Sektion text enthält die Maschinen‐Instruktionen. Die Daten bestehen aus den Variablen, ganz gleich wie bei C oder C++‐Programmen üblich. 
Zuerst haben wir nach ein paar Zeilen mit Kommentar (beginnend mit #) eine sogenannte Direktive (oder auch Pseudoinstruktion): .data
# start of data segment

Diese Pseudoinstruktion ist ein Kommando für den Assembler selbst – also nicht gedacht dafür, in Maschinensprache übersetzt zu werden. Also so etwas wie eine „Randnotiz“ für den Assembler, was er tun soll. Und nochmals: Ab dem Zeichen # ist der Rest der Zeile lediglich Kommentar. 
Dann kommt: x:
.long
.long
.long
.long
1
5
2
18

Dies sagt dem Assembler, dass er in seiner Output‐Datei („total.o“ oder „add4.o“) eine Notiz einfügen soll, dass das Betriebssystem, welches das übersetzte Programm später laden wird einen Speicherbereich reservieren soll, in welchem 4 „long“ Platz haben. 
Diese 4 „long“ werden zudem auch auf die Initialwerte 1, 5, 2 und 18 (dezimal) gesetzt. 
„long“ heißt in unserem Zusammenhang einen Speicherbereich von 32 Bit. „long“ ist ein historisches Wort und war früher mal 16 Bit groß. In der Intel‐Syntax heißt dies „double“. 
Wir sagen mit obigem Code dem Assembler auch, dass wir diesem Speicherbereich von 4 mal „long“ den symbolischen Namen x geben wollen und ihn in der Folge als x ansprechen wollen. 
Wir sagen auch: x ist ein Label für dieses Wort. Oder auch anders: x ist lediglich ein Name für die Adresse des ersten Wortes im Array und nicht für alle 4 Werte des Arrays! 37
38 Rechnernetze und –Organisation 
Damit verstehst du jetzt vielleicht auch, warum wir in der Sprache C das auch so haben: Ein Array‐
Name ist gleichzeitig auch ein Pointer auf das erste Element des Arrays. 
Gleich nach den 4 „long“ kommt ein weiteres „long“‐Word, welchem wir das Label sum geben. 
Übrigens: Wenn wir mit x ein Array von – sagen wir – 100 Wörtern definieren wollten, dann könnten wir alternativ dem Assembler dies auch so mitteilen: x:
.rept 1000
.long
8
.endr

Die Direktive .rept sagt dem Assembler, dass die nachfolgenden Zeile bis ausschließlich .endr genügend oft (in diesem Fall also 100 Mal) wiederholt werden sollen. 
Wie könnten wir einen Array von „char“ anlegen? Dies geht mit der Direktive .space: y:

.space 6
# reserve 6 bytes of space
Wenn wir diesen Speicherplatz mit einem String initialisieren wollten, dann könnten wir dies so machen: y:
.string “hello“

Dieser String nimmt 6 Bytes in Anspruch, da er mit einem Null‐Byte abgeschlossen wird. Die Entwickler des Assemblers haben also diese Direktive konsistent mit der Sprache C gestaltet. Es ist wichtig hier anzumerken, dass dies nicht notwendigerweise so gemacht werden musste. Null‐Bytes am Ende eines Strings sind nicht „gottgewollt“. 
Die nächste Direktive in unserem Beispiel signalisiert, dass jetzt die Sektion text beginnt, also der Programm‐Code. Die ersten beiden Zeilen des Codes schauen so aus: _start:
movl $4, %eax

Wir beginnen mit einem neuen Label: _start. Es ist dies der „Entry Point“. In anderen Worten: An dieser Adresse im Speicher beginnt der Code und die CPU soll hier mit dem Ausführen des Codes beginnen. Im speziellen Fall haben wir hier eine movl‐Instruktion. 
Das Label „_start“ wurde von uns nicht zufällig gewählt – wie alle anderen. Der Unix‐Linker geht davon aus, dass der Code im Allgemeinen („default“) beim Label _start beginnt. 
Die vorangehende Direktive .globl ist übrigens auch für den Linker notwendig. Mehr dazu später bei den Unterprogrammaufrufen im Teil D. 
Die Instruktion movl kopiert die Konstante 4 in das Register EAX. (Wir verwenden hier die Gegenwartsform; trotzdem solltest du immer wissen, dass im Moment gar nichts passiert. Wir besprechen lediglich eine Datei. Erst bei der Exekution der übersetzten Version dieser Datei in einer CPU passiert alles wirklich. Eigentlich sollten wir also immer sagen: „Wenn wir diese Anweisung später in einer CPU exekutieren, dann würde diese Instruktion …“ 
Wir sehen also, dass der Assembler as Konstanten mit dem $‐Zeichen versteht. Der Buchstabe „l“ bei movl bedeutet „long“, also 32 Bit. 6

Einführung in Linux‐Assemblersprache
In der Intel‐Syntax würde der gleiche Befehl so aussehen: mov eax, 4




Der Befehl movl gehört zur Familie der MOV‐Instruktionen. Wir werden etwas später etwa den movb‐
Befehl kennen lernen, mit welchem wir ein Byte (statt einem Wort) kopieren können. Wir werden für eine Befehlsfamilie Großbuchstaben verwenden. Hier also MOV. Alle Integer‐Konstanten sind zur Basis 10, wenn nichts anderes definiert wird. Will man eine Zahl als Hexadezimalzahl definieren, dann verwenden wir wie in C die „0x“‐Notation. Die zweite Instruktion ist ziemlich gleich wie die erste. Doch bei der dritten Instruktion gibt es eine Neuigkeit: movl $x, %ecx

Vorher haben wir mit $4 die Konstante 4 gemeint. Das gleiche gilt jetzt für $x. Die Konstante hier ist die Adresse von x. Obige Instruktion kopiert also die Adresse von x in das ECX‐Register. ECX stellt also einen Pointer dar. 
In einer späteren Instruktion werden wir zum Wert im Register ECX den Wert 4 hinzu addieren: addl $4, %ecx

Indem wir den Wert in ECX um 4 erhöhen, zeigen wir im Speicher genau um ein Wort weiter. Damit können wir also Wort für Wort durch den Speicher gehen und in der Folge also alle 4 Speicherworte aufaddieren. 
Achtung: x alleine hat eine ganz andere Bedeutung als $x. Mit dem Befehl movl x, %ecx
würden wir den Inhalt der Speicherstelle x (und der drei folgenden Bytes) in das Register ECX kopieren 
Aufpassen: Bei der Intel‐Syntax (die wir hier nicht verwenden) ist dies ganz anders: x würde die Adresse von x bedeuten und [x] würde das Datenwort auf dieser Adresse bezeichnen. 
In der nächsten Zeile beginnt eine Schleife: top: addl (%ecx), $ebx

Mit dem Label „top“ zeigen wir, dass hier der Beginn (= top) der Loop ist. Die Instruktion nimmt das Datenwort an der Adresse, welche in ECX liegt, und addiert dieses zum Inhalt des Register EBX. In EBX haben wir also die Zwischensumme. 
Zum Schluss werden wir diese Zwischensumme, welche dann die Endsumme darstellt, auf die Speicheradresse mit dem Label sum übertragen. Warum machen wir dies nicht jetzt schon? Der Grund dafür liegt darin, dass Speicherzugriffe viel langsamer sind als Registerzugriffe. Register liegen ja innerhalb der CPU, während die Speicherworte außerhalb der CPU sind. Wir wollen hier also unnötige Speicherzugriffe vermeiden. Deshalb akkumulieren wir die Summe vorerst im Register EBX und Schreiben die Summer erst zum Schluss in den Speicher. Dies unter der Voraussetzung, dass wir den Summenwert später im Speicher brauchen, sonst könnten wir uns dies ersparen. 
Wenn wir uns über die Geschwindigkeit von Speicherzugriffen keine Sorgen machen würden, dann könnten wir direkt die Variable sum verwenden, wie im folgenden Beispiel‐Code gezeigt wird: 39
40 Rechnernetze und –Organisation top:

movl $sum, %edx # use edx as a pointer to sum
movl $0, %ebx
addl (%ecx), %ebx
# old sum is still in %ebx
movl
%ebx, (%edx)
Achtung: Wir dürfen auf keinen Fall folgenden Code schreiben: top:
movl $sum, %edx
addl (%ecx), (%edx)

Dies deshalb, da es letzere Instruktion in der Intel‐Architektur nicht gibt. Wie viele andere CPU‐
Architekturen lässt auch die Intel‐Architektur nicht zu, beide Operanden einer Instruktion im Hauptspeicher zu haben. (Es gibt jedoch eine Anzahl von Ausnahmen. Dazu jedoch vielleicht später). Dieser Zwang wird uns also von der Hardware auferlegt, und nicht vom Assembler. 
Das untere Ende der Schleife geht so: decl %eax
jnz top

Die DEC‐Instruktion (hier also decl) zieht in der AT&T‐Syntax (welche wir hier verwenden) 1 vom Inhalt von EAX ab. Mit dieser Instruktion – zusammen mit der nachfolgenden JNZ‐Instruktion – stoßen wir das erste Mal auf die Bedeutung des Registers EFLAGS in der CPU. 
Bei fast jeder arithmetischen Instruktion wird innerhalb der CPU auch das Resultat dieser Rechnung „angesehen“. Wenn das Resultat 0 ist, wird das Zero‐Flag (ZF) im EFLAGS‐Register auf 1 gesetzt. 1 heißt hier also „Ja“ und 0 bedeutet „Nein“. In ähnlicher Weise wird das Vorzeichen‐Flag (Sign Flag) auf 1 oder 0 gesetzt, je nachdem ob das Ergebnis der Subtraktion negativ war oder nicht. 
Die meisten arithmetischen Operationen haben einen Einfluss auf EFLAGS. Nicht jedoch die MOV‐
Operation. Schau im Intel‐CPU‐Manual nach, welche Instruktionen das EFLAGS‐Register beeinflussen (auf der Intel‐Webseite, auf der RNO‐Webseite, oder unter http://heather.cs.ucdavis.edu/~matloff/50/IntelManual.PDF.) 
Was machen wir jetzt also mit dem Zero‐Flag? Die JNZ‐Instruktion („Jump if not zero“) besagt, dass wir als Nächstes zum Label top springen, falls das Ergebnis der vorigen Instruktion nicht 0 ergeben hat. 
Es gibt auch eine „komplementäre“ Instruktion, JZ: „Jump on zero“. 
Als Ergebnis sehen wir, dass die Schleife vier Mal durchlaufen wird – bis eben der Wert im Register EAX den Wert 0 erreicht hat. 
Danach verlassen wir die Schleife; dies bedeutet, dass wir die Instruktion nach dem Befehl JNZ als Nächstes ausführen. 
Wir haben hier einen Rückwärtssprung; zu einer „kleineren“ Adresse. Es gibt auch Vorwärtssprünge. Bei „if‐then‐else“‐Situationen kommen diese gewöhnlich vor. Denk darüber nach. 
Manches Mal verwenden wir für „Jump“ auch das Wort „Branch“ (Verzweigung) als Synonym. Deshalb beginnen in anderen Architekturen diese Befehle oft mit dem Buchstaben B.

Das Register EFLAGS besitzt 32 Bit, nummeriert von 31 bis 0. Hier einige wichtige davon: 6
o
o
o
o
o
Overflow Flag Interrupt Enable Sign Flag Zero Flag Carry Flag Einführung in Linux‐Assemblersprache
Bit 11 Bit 9 Bit 7 Bit 6 Bit 0 
Es gibt zum Beispiel Instruktionen wie JC (“jump if carry”), JNC (“jump if no carry”) und so weiter. 
Schließlich finden wir im Code das Label done. Das Wort done wurde vom Schreiber des Code frei erfunden; er/sie hätte genauso jedes andere Wort dafür nehmen können. Dem Assembler ist dies egal. Man hätte hier sogar gänzlich ohne Label arbeiten können, da dieses Label ja nirgends sonst im Code aufscheint. Es steht hier lediglich zum Zwecke des Debuggens, wie wir später sehen werden. 
Es gibt neben den DEC‐Instruktionen auch INC‐Instruktionen zum Imkrementieren von Registerinhalten. 
Bei unseren 2‐Operanden‐Instruktionen nennen wir die beiden Operanden „Source“ und „Destination“. Der Operand, welcher verändert wird, ist der Destinations‐Operand. Zum Beispiel: movl %eax, %ebx

EAX ist der Source‐Operand und EBX der Destination‐Operand. 
Wichtig: Die Labels x, sum, _start, top und done sind nur für uns Menschen wichtig. Sie kommen im assemblierten Maschinencode nicht mehr vor. Das Gleiche können wir auch von den Variablennamen in einer C‐ oder C++‐Datei sagen. Nach der Kompilation und der Assemblierung sind diese im Maschinencode nicht mehr sichtbar. Als menschliche „Programmierer“ verwenden wir Variablennamen und Labels lediglich um auf bestimmte Speicherstellen (deren exakte Adresse ja noch nicht festgelegt wurde) hinweisen zu können. In der vom Assembler produzierten Maschinensprache kommen nur mehr numerische Adressen vor. 
Die Instruktion „jnz top“ beinhaltet das Label top, aber der tatsächliche Maschinensprach‐Befehl, welche aus diesem Befehl hervorgeht, ist 0111010111111000. Später bei unserer Betrachtung der Maschinensprache werden wir sehen, dass die ersten 8 Bit, 01110101, den Befehl JNZ bezeichnen, und die zweiten 8 Bit, 11111000, besagen, dass das Sprungziel 8 Bytes vorher liegt. Das Label top kommt also nicht mehr vor. 
Es gibt auf Maschinensprach‐Ebene auch keine Datentypen. Beispielsweise gibt es keinen Unterschied zwischen den beiden nachfolgenden Beispielen: z:
.long 0
w:
.byte 0
und z:
.rept 5
.byte 0
.endr

Beide obigen Beispiele reservieren 5 Bytes mit Nullen im Datensegment im Speicher. Vermeide, die erste Variante als ein Art „Deklaration“ einer Integer‐Variablen mit Namen z und einer zweiten 41
42 Rechnernetze und –Organisation Variablen mit Namen w zu verstehen. Lediglich der Programmierer interpretiert diesen Code auf diese Weise, doch die beiden Versionen produzieren den gleichen Objekt‐Code. 
Im obigen Beispiel, wo wir 4 Variablen zusammen gezählt haben, haben wir den gesamten Speicher statisch alloziert. Das heißt, wir haben den Speicher zur Assemblierungszeit reserviert. Na ja, genau gesprochen erst dann, wenn das Programm tatsächlich in einer CPU läuft; doch zur Assemblier‐Zeit wurde die Verwendung des Speichers schon festgelegt. 
Wir haben also 4 Wörter in der Sektion .data alloziert. Zudem wurden eine bestimmte Anzahl von Bytes in der Sektion .text fixiert. Die genaue Anzahl werden wir später bei der Besprechung des Maschinencodes sehen. 
Jedes C‐ oder C++‐Programm wird kompiliert und das Resultat ist ein Maschinenprogramm. Wie geht das also mit der dynamischen Allokation? 
Nehmen wir an, dass in einem C‐Programm malloc() oder in einem C++‐Programm new() aufgerufen wird. Wenn wir ein Programm exekutieren, dessen Quellcode in C oder C++ geschrieben wurde, dann wird ein bestimmter Speicherbereich, der sogenannte Heap, „aufgesetzt“, also reserviert. Jeder Aufruf von malloc() gibt einen Pointer auf einen Speicherbereich am Heap zurück. malloc() selbst verwendet intern eine Datenstruktur, mit Hilfe derer benutzte und unbenutzte Speicherbereiche am Heap verwaltet werden. Wenn später die Funktion free() aufgerufen wird, werden diese Speicherbereiche wieder als „frei“ markiert. 
Mit anderen Worten: Der Speicherbereich für den Heap wurde bereits beim Laden des Programms alloziert; also statisch. Wir haben diesen Speicherbereich lediglich mit den Funktionen malloc() bzw. free() verwendete und freie Teile markiert. Das Vorhandensein von dynamischem Speicher ist also eine Illusion. 
Als Assembler‐Programmierer könnte man die C‐Bibliothek mit dem Programm malloc() zu seinem Programm „dazu‐linken“, um denselben Effekt zu erzeugen. 6.6 REGISTER UND SPEICHER 
Register sind in der CPU, Speicher außerhalb. Deshalb ist der Zugriff auf Register viel schneller als der Hauptspeicherzugriff. 
Deshalb sollte man bei der Programmierung mit Assemblersprache versuchen, die Speicherverwendung zu Gunsten der Registerverwendung zu minimieren. Dies vor allem deshalb, da Assemblerprogrammierung typischerweise genau deshalb eingesetzt wird, um Geschwindigkeitsoptimierungen vorzunehmen. 
Alle CPU‐Architekturen haben jedoch immer eine ziemlich begrenzte Anzahl von Registern. 6
6.7 Einführung in Linux‐Assemblersprache
EIN WEITERES BEISPIEL 
Im nun folgenden Beispiel betrachten wir das Problem „Sortieren“. Mit dem Beispiel wollen wir lediglich mehr über das Programmieren auf Assemblersprachebene lernen. Die algorithmischen Aspekte wie Effizienz etc. lassen wir außer Acht. 
Eines der wesentlichen neuen Elemente hier ist die Verwendung von Unterprogrammen. Dies ist ähnlich der Verwendung von Funktionen in C bzw. C++. Jede C‐ oder C++‐Funktion wird vom Compiler in einen Unterprogrammaufruf übersetzt. In diesem Beispiel haben wir die Unterprogramme
init(), findmin() und swap(). 1 # sample program; does a (not very efficient) sort of the array x, using
2 # the algorithm (expressed in pseudo-C code notation):
3
4 # for each element x[i]
5 #
find the smallest element x[j] among x[i+1], x[i+2], ...
6 #
if new min found, swap x[i] and x[j]
7
8 .equ xlength, 7 # number of elements to be sorted
9
10 .data
11 x:
12
.long 1
13
.long 5
14
.long 2
15
.long 18
16
.long 25
17
.long 22
18
.long 4
19
20 .text
21
# register usage in "main()":
22
# EAX points to next place in sorted array to be determined,
23
# i.e. "x[i]"
24
# ECX is "x[i]"
25
# EBX is our loop counter (number of remaining iterations)
26
# ESI points to the smallest element found via findmin
27
# EDI contains the value of that element
28 .globl _start
29 _start:
30
call init # initialize needed registers
31 top:
32
movl (%eax), %ecx
33
call findmin
34
# need to swap?
35
cmpl %ecx, %edi
36
jge nexti
37
call swap
38 nexti:
39
decl %ebx
40
jz done
41
addl $4, %eax
42
jmp top
43
44 done: movl %eax, %eax # dummy, just for running in debugger
45
46 init:
47
# initialize EAX to point to "x[0]"
48
movl $x, %eax
49
# we will have xlength-1 iterations
50
movl $xlength, %ebx
51
decl %ebx
52
ret
43
44 Rechnernetze und –Organisation 53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
findmin:
# does the operation described in our pseudocode above:
# find the smallest element x[j], j = i+1, i+2, ...
# register usage:
# EDX points to the current element to be compared, i.e. "x[j]"
# EBP serves as our loop counter (number of remaining iterations)
# EDI contains the smallest value found so far
# ESI contains the address of the smallest value found so far
# haven’t started yet, so set min found so far to "infinity" (taken
# here to be 999999; for simplicity, assume all elements will be
# <= 999999)
movl $999999, %edi
# start EDX at "x[i+1]"
movl %eax, %edx
addl $4, %edx
# initialize our loop counter (nice coincidence: number of
# iterations here = number of iterations remaining in "main()")
movl %ebx, %ebp
# start of loop
findminloop:
# is this "x[j]" smaller than the smallest we’ve seen so far?
cmpl (%edx), %edi # compute destination - source, set EFLAGS
js nextj
# we’ve found a new minimum, so update EDI and ESI
movl (%edx), %edi
movl %edx, %esi
nextj: # do next value of "j" in the loop in the pseudocode
# if done with loop, leave it
decl %ebp
jz donefindmin
# point EDX to the new "x[j]"
addl $4, %edx
jmp findminloop
donefindmin:
ret
swap:
# copy "x[j]" to "x[i]"
movl %edi, (%eax)
# copy "x[i]" to "x[j]"
movl %ecx, (%esi)
ret

Wir finden eine Reihe neuer Instruktionen im obigen Beispiel. Auch eine neue Pseudo‐Instruktion ist dabei: .equ xlength, 7

Mit equ teilen wir dem Assembler mit, an jeder Stelle, wo er xlength findet, dies durch 7 zu ersetzen. equ hat also ziemlich genau die gleiche Bedeutung wie #define in C bzw. C++. Die Pseudo‐
Instruktion equ reserviert also keinen Speicher. 
Die erste Instrukton der Sektion .text ist ein Unterprogrammaufruf: call init

Die Instruktion call springt zum Label init und merkt sich dabei die Rücksprungadresse auf dem sogenannten Stack. Wir werden den Stack im Detail später beim Kapitel „Unterprogramme“ besprechen. Es reicht, vorerst zu wissen, dass wir zum Label init springen, welches sich ein Stück weiter unten im Quellcode befindet: init:
movl $x, %eax
movl $xlength, %ebx
6
Einführung in Linux‐Assemblersprache
decl %ebx
ret

Die erste Instruktion nach call ist also movl $x, %eax

Ein paar Instruktionen weiter befindet sich eine RET‐Instruktion. RET steht für „return“ und macht den „Rücksprung“: Wir springen unmittelbar zur Instruktion nach derer, welche den Sprung zu init ausgelöst hat. Wie bereits erwähnt, hat sich die CPU diese Rücksprungadresse ja am sogenannten Stack „gemerkt“. RET springt also zur Instruktion, welche genau nach dem dazu passenden CALL steht. In unserem Beispielcode oben geht es also weiter bei: top:
movl (%eax), %ecx

Wie gesagt, den genauen Mechanismus werden wir später beim Kapitel „Unterprogramme“ kennen lernen. Doch das Prinzip der Aufteilung eines Programms in Unterprogrammaufrufe ist so fundamental, dass wir es bereits hier zu Beginn besprechen. Damit ist man in der Lage, auch auf Assembler‐Ebene eine Top‐Down‐Vorgangsweise bei der Programmentwicklung zu verwenden. 
Der CPU ist das alles ziemlich „egal“. Sie ist dumm. Nehmen wir etwa an, dass wir zufällig eine RET‐
Instruktion irgendwo in unserem Code eingefügt hätten, wo wir eigentlich eine solche nicht vorgesehen hätten. Sobald die CPU auf diese RET‐Instruktion stößt, würde die CPU diese ausführen. Die CPU „weiß“ nicht, dass diese dort nicht hingehört hätte. Sie „weiß“ nicht, dass sie sich ja gerade inmitten eines Unterprogramms befindet. 
Gehe nicht davon aus, dass auf dem Stack ja keine Rücksprungadresse zu finden wäre. Am Stack befindet sich immer „etwas“, auch wenn es lediglich Müll ist. Die CPU würde bei einem falsch eingefügten RET‐Befehl dann halt zu einem Nonsense‐Punkt springen. Typischerweise verursacht ein solcher Sprung in das „Nirwana“ einen Segmentation Fault. Der wichtige Aspekt hier ist, dass sich die CPU jedoch nicht dagegen wehrt diese RET‐Instruktion auszuführen. 
Aus dem gleichen Grund kann auch der Assembler eine solche an falscher Stelle im Programmcode stehende RET‐Instruktion nicht erkennen. 
Nebenbei bemerkt: Das Label _start ist ähnlich der Funktion main() in C bzw. C++. 
Die Routine init macht, was ihr Name bereits ausdrückt: Register werden auf entsprechende Werte gesetzt. 
Als Nächstes finden wir einen weiteren Unterprogrammaufruf, dieses Mal heißt das Unterprogramm findmin(). Wie im Pseudo‐Code dargestellt, findet diese Routine das kleinste Element im verbleibenden Teil des zu sortierenden Feldes. Wir gehen vorerst auf die Details, wie dies gemacht wird, nicht ein. Wir sollten Code nicht nur top‐down entwickeln, sondern auch so lesen. 
Danach überprüfen wir im Code, ob wir eine Vertauschung (swap) vornehmen müssen oder nicht. Wenn ja, machen „wir“ das. 
Eine weitere neue Instruktion ist CMP. Dies steht für „Compare“, also „Vergleiche“. Wir finden diese z.B. im Unterprogramm findmin(). Wie auch im Pseudo‐Code dargestellt, subtrahiert diese Instruktion den Inhalt des Quellregisters vom Inhalt des Zielregisters. Die Differenz wird nirgendwo gespeichert, aber das Register EFLAGS wird entsprechend modifiziert. 45
46 Rechnernetze und –Organisation 
Es sollte nicht verwundern, dass es neben der Instruktion ADD auch eine Instruktion für die Subtraktion gibt: subl %ebx, %eax

Obige Instruktion subtrahiert den Inhalt von EBX vom Inhalt von EAX und speichert die Differenz im Register EAX. Diese Instruktion macht also ziemlich das gleiche wie CMP, außer dass CMP das Ergebnis nirgendwo speichert. Wir verwenden CMP also lediglich zur Manipulation der Flags. 
Nach der CMP‐Instruktion kommt JS. Dies steht für „jump if the sign flag is 1“. Wir springen also im Falle dass die zuletzt ausgeführte arithmetische Instruktion ein „signed“ Ergebnis geliefert hat; also ein negatives Ergebnis. Die komplementäre Funktion JNS springt, wenn das Ergebnis „nicht negativ“ war. 
Mit anderen Worten: Die Kombination der beiden Befehle CMP und JS bewirkt, dass wir zum Label nextj springen, falls der Inhalt des Registers EDI kleiner war als der Inhalt der Speicherstelle, auf welche das Register EDX zeigt. In diesem Falle haben wir kein neues Minimum gefunden und wir setzen mit der nächsten Instruktion fort. 
Auf Zeile 42 finden wir eine neue Instruktion: jmp top


Dieser JMP‐Befehl (Jump) ist „ohne Bedingung“. Betrachten wir nun den Kommentar für main(): #
#
#
#
#
#
#
register usage in "main()":
EAX points to next place in sorted array to be determined,
i.e. "x[i]"
ECX is "x[i]"
EBX is our loop counter (number of remaining iterations)
ESI points to the smallest element found via findmin
EDI contains the value of that element

Der Kommentar wurde geschrieben, bevor der Assemblercode gemacht wurde. Mit Hilfe des Kommentars gelang es dem Autor des Codes, Fehler zu vermeiden. 
Beachte auch, dass wir bei diesem Beispiel auf keinen Fall das Register ESP verwenden dürften, da dieses durch die Instruktionen CALL und RET geblockt ist. Die genauen Details dazu später beim Kapitel „Unterprogramme“. 6.8 ADRESSIERUNGMODI 
Sehen wir uns folgende 3 Beispielbefehle an: movl $9, %ebx
movl $9, (%ebx)
movl $9, x

Wir sehen hier drei verschiedene Möglichkeiten, Operanden für die MOV‐Instruktion zu definieren. Im ersten Beispiel kopieren wir die Konstante 9 in ein Register. Im zweiten und dritten Beispiel kopieren wir die Konstanten 9 auf verschiedene Speicherstellen. Dabei verwenden wir im 2. Beispiel den Inhalt 6
Einführung in Linux‐Assemblersprache
eines Registers als Pointer; damit meinen wir, dass der im Register EBX gespeicherte Wert die Adresse darstellt, auf die wir die Konstante 9 kopieren wollen. Im dritten Beispiel geben wir die Adresse, auf die wir kopieren wollen, direkt an. Hier mit der symbolischen Adresse x. Wichtig dabei ist, dass die Zieladresse in der Maschineninstruktion direkt kodiert vorkommt. 
Die Art und Weise, auf die ein Operand in einer Instruktion spezifiziert wird, nennen wir Adressierungsmodus für diesen Operanden. Oben haben wir es also mit drei verschiedenen Adressierungsmodi für den zweiten Operanden der Instruktion zu tun. 
Schauen wir z.B. folgende Instruktion an: movl $9, %ebx

Betrachten wir dabei zuerst den Ziel‐Operanden, also zweiten. Da der Operand in einem Register landet, sprechen wir von Register‐Modus. Wir sagen auch, dass der Quell‐Operand im Immediate‐
Modus festgelegt ist. Damit meinen wir, dass der Operand (also die Zahl 9) unmittelbar in der Instruktion vorkommt. Ganz offensichtlich wird das werden, wenn wir uns später den daraus resultierenden Maschinenbefehl ansehen werden. 
Wie ist das bei der folgenden Instruktion? movl $9, (%ebx)

Hier liegt der Zieloperand im Speicher, und zwar auf der Adresse, welche durch den Inhalt von EBX festgelegt ist. Dieser Adressierungsmodus heißt „indirekter Adressierungsmodus“. 
Hingegen ist im folgenden Beispiel der Zieloperand, besser gesagt: die Adresse des Zielopranden direkt in der Instruktion festgelegt. Deshalb auch „direkte Adressierung“. movl $9, x

Wir haben oben also „direkt“ festgelegt, wo im Speicher sich der Operand befindet. 
Später werden wir noch einige andere Adressierungsmodi kennen lernen. 6.9 ASSEMBLIEREN UND LINKEN: SO ENTSTEHT EINE AUSFÜHRBARE DATEI 
Sehen wir uns zuerst die Syntax des Assembleraufrufs in einer Kommandozeile an. Um einen Quellcode in AT&T‐Syntax, welcher z.B. in der Datei „x.s“ gespeichert ist, zu assemblieren, geben wir folgende Zeile ein: as –a -–gstabs –o x.o x.s

Die Option –o spezifiziert den Namen der Ausgabedatei des Assemblers. Dies ist die Objektdatei. Mit Objektdatei meinen wir also die Datei, welche die vom Assembler produzierten Maschinenbefehle beinhält. Wir „sagen“ dem Assembler also: „Bitte nenne die Objektdatei x.o“. 
Wenn wir mehr als eine Quelldatei in einem Aufruf assemblieren wollen, dann muss man aufpassen. Sehen wir uns folgendes Beispiel an: 47
48 Rechnernetze und –Organisation as –a –-gstabs –o z.o x.s y.s

Nehmen wir an, wir hätten in obiger Zeile unachtsamerweise z.o vergessen. In diesem Fall würde der Assembler die Objektdatei „beinhart“ x.s nennen. Der ursprüngliche Inhalt von x.s würde überschrieben werden. Also aufpassen. 
Die Option –a hält fest, dass der Assembler auf dem Standardausgabegerät, bei uns wäre das wohl der Bildschirm, den Quellcode samt erzeugtem Maschinencode und den Offsets der Sektionen ausgeben soll. Assemblercode und Maschinencode werden dabei nebeneinander angezeigt, damit man die beiden leichter lesen kann. Unter den Offsets verstehen wir, wie wir gleich sehen werden, Adressen. 
Die Option –-gstabs (aufpassen: es sind zwei Minus‐Zeichen in der Kommandozeile!) sagt dem Assembler, dass er in die Objektdatei auch die sogenannte Symboltabelle reingeben soll. Die Symboltabelle beinhaltet eine Liste aller Symbole und aller Labels samt zugehörigen Adressen in einer Form, welche durch einen Debugger verstanden wird. Als Debugger könnten wir z.B. GDB oder DDD verwenden. Die Option –-gstabs entspricht also der Option –g beim GCC. 
Obiges gilt für Linux. Wenn wir andere Betriebssysteme verwenden, ist das alles ein bisschen anders. Der Microsoft‐Assembler MASM hat ähnliche Kommandozeilen‐Optionen; es werden aber – wie nicht anders zu erwarten – andere Namen mit leicht verschiedener Funktionalität verwendet. 
Nebenbei: NASM gibt es sowohl für Unix also auch für MS Windows. Damit können wir AS sogar unter Windows verwenden, da es ein Teil der GCC‐Distribution Cygwin ist, welches unter MS Windows läuft. 
Kommen wir nun zum „Linking“: Wir haben also den Assembler‐Quellcode in der Datei „x.s“ in die Objektdatei „x.o“ assembliert. Danach könnten wir den Linker so aufrufen: ld –o x x.o

Der Linker LD nimmt die Objektdatei mit dem Objekt‐Code und produziert daraus die exekutierbare Datei mit Namen x. Mehr zu diesem Prozess werden wir später kennen lernen. 
Mit Hilfe von Makefiles können wir die Assemblierung und das Linken automatisieren – ähnlich wie wir dies für C‐ oder C++‐Dateien machen. Makefiles haben ja überhaupt nichts mit C bzw. C++ zu tun, sondern zeigen nur Abhängigkeiten auf, und definieren, wie man die abhängigen Dateien erzeugt. 
Beispiel aus einem Makefile: x: y
<TAB> z

Im obigen Beispiel wird festgestellt, dass die Datei x von der Datei y abhängt. Wenn wir x machen wollen, oder nach Veränderung von x nochmals machen wollen, dann müssen wir z ausführen. Im Falle unser Quelldatei x.s oben würde der Makefile also folgendermaßen aussehen: x: x.o
<TAB> ld –o x x.o
x.o: x.s
<TAB> as -–gstabs –o x.o x.s
6
Einführung in Linux‐Assemblersprache
6.10 WIE KÖNNEN WIR DIESE BEISPIELPROGRAMME AUSFÜHREN? 
„Normale“ Programmexekution funktioniert hier nicht! 
Gehen wir davon aus, dass unser erstes Beispielprogramm „Summe von 4 Zahlen“ in der Quelldatei „total.s“ (bzw. „Add4.s“) gespeichert wäre. Nach Assemblierung und Linking hätten wir dann ‐‐ sagen wir ‐‐ die ausführbare Datei „tot“. Wir können nicht einfach auf der Unix‐Kommandozeile Folgendes eingeben: % tot

Das Programm würde mit einem “Segmentation Fault” auf die Nase fallen. Probiere es aus! Warum ist das so? 
Das grundlegende Problem ist, dass nach der Ausführung des Programms die CPU versuchen würde, die nach dem Ende des Programms nachfolgende Speicherstelle auszuführen. Diese Instruktion gibt es ja nicht, bzw. es liegt auf dieser Speicherstelle „keine vernünftige“ Instruktion. Die CPU weiß das aber nicht. Alles was die CPU kann ist, eine nach der anderen Instruktion zu exekutieren. Wenn die CPU also am Ende unseres Codes versucht, die dort nachfolgende Information im Speicher als Instruktion zu interpretieren und diese auszuführen, dann wird Müll „ausgeführt“. Wir könnten diese Situation also als „über den Rand der Erde hinaus gehen“ interpretieren. 
In Linux ist das dann so: Der Linker wird die Sektion .data direkt nach der Sektion .text in den Speicher legen, so ziemlich unmittelbar nach dem Ende. Mit „ziemlich unmittelbar“ meinen wir, dass die Sektion .data an einer Wortgrenze (also auf einer Adresse, welche ein ganzzahliges Vielfaches von 4 ist) beginnen muss, der vorige Code jedoch nicht unbedingt genau vorher aufgehört haben muss. Der Platz dazwischen ist „Padding“: Er wird mit Nullen aufgefüllt. In unserem Beispiel tot wären das 3 Bytes mit Nullen. Der Müll, welcher in diesem Fall exekutiert wird, sind also unsere eigenen Daten mit ein paar Nullen davor. 
Wenn wir C‐ oder C++‐Dateien kompilieren, dann passiert das nicht. Der Compiler fügt nämlich einen System‐Call am Ende ein. Ein System‐Call ist eine Funktion, welche das Betriebssystem zur Verfügung stellt. Bei Linux ist das die Funktion exit(). Möglicherweise kennst du den Aufruf von exit() auch aus deiner C‐Programmierpraxis. Mit dieser Funktion wird eine saubere Übergabe von unserem Programm auf das Betriebssystem ermöglicht. Das Betriebssystem reagiert darauf, indem es auf der Kommandozeile den bekannten Prompt ausgibt. 
Wir hätten in unserem Beispielprogramm ebenfalls einen Aufruf der Funktion exit() einfügen können. Wir werden das später an geeigneter Stelle so machen. 
Wir können aus ähnlichen Gründen vorerst auch keine Inputs oder Outputs verarbeiten. Auch dazu müssten wir System‐Calls aus dem Betriebssystem aufrufen. 
Wenn also unser Programm oben „auf die Nase fällt“, dann wissen wir im Moment überhaupt nicht, ob dies deswegen ist, weil die CPU über das Programmende hinaus exekutieren möchte, oder ob wir schon vorher einen groben Schnitzer gemacht haben. 
Deshalb können wir in unserer kleinen Welt, welche wir vorerst betrachten, nur mit Hilfe eines Debuggers arbeiten. Dazu stehen typischerweise GDB oder DDD zur Verfügung. Damit können wir die Exekution unseres Codes kurz vor dem Ende stoppen und das Resultat ansehen. 49
50 Rechnernetze und –Organisation 
Mit einem Debugger können wir Breakpoints setzen. Wir können auch im Single‐Step‐Modus Instruktion für Instruktion durch das Programm gehen. Damit besitzen wir eine Möglichkeit, nicht über das Ende unseres Codes hinaus in einen Segmentation Fault „zu laufen“ indem wir vor dem Ende des Codes einen Breakpoint setzen. 
Wir können mit Hilfe eines Debuggers zudem auch Registerinhalte und Speicherinhalte inspizieren und damit das Programm auf seine richtige Funktionsweise testen. In unserem ersten Programm, wo wir 4 Zahlen aufsummiert haben, war die Summe im Register EBX. Wir würden also den Inhalt des Registers EBX auf richtigen Inhalt überprüfen. 
Schauen wir uns dies mit Hilfe des Debuggers DDD an: Wir würden das Programm nach Assemblieren und Linken folgendermaßen starten: % ddd tot

Die Quellcode‐Datei Total.s sollte im DDD Source Window erscheinen. 
Jetzt müssen wir vermeiden, dass das Programm über sein Ende hinaus läuft. Dazu setzen wir einen Breakpoint auf das Label done indem wir auf die Zeile, in der done steht, klicken, und danach auf das rote Stoppzeichen oben im Fenster klicken. Damit halten wir das Programm in dieser Zeile an. 
Als Nächstes starten wir das Programm indem wir auf Run klicken. Der Programmdurchlauf stoppt beim Label done. 
Wir können jetzt den Inhalt des Registers EBX überprüfen. Dazu klicken wir auf Status und danach Registers. Sollte hingegen in einem anderen Programm das Resultat der Berechnung im Speicher liegen, dann können wir auch den Speicherinhalt inspizieren. Dazu drücken wir Data, dann Memory. Ein neues Fenster („Examine Memory“) erscheint und wir können angeben, welche Daten uns interessieren. Dazu müssen wir angeben, wie viele Datenworte uns interessieren (im leeren Feld links) Im zweiten Feld, in welchem „octal“ steht, sollten wir das Datenformat angeben, Das dritte Feld beschreibt die Größe der Datenworte, also Byte etc. Und schließlich müssen wir im letzten Feld die Adresse des ersten Datums angeben. Wenn wir lediglich das Resultat des Programmdurchlaufs überprüfen wollen, dann wählen wir Print (und nicht Display). Print zeigt das Datum nur einmal, während Display das Datum kontinuierlich anzeigt. Wenn wir etwa wie beim Beispiel aus Kapitel 6 alle sieben Feldelemente überprüfen wollten, dann würden wir das Fenster „Examine Memory“ ausfüllen und „Print 7 Decimal Words“ beginnend ab der Startadresse &x eingeben. o
o
o
o


Wenn wir dem Debugger anweisen, Daten in Wortgröße anzuzeigen, dann wird das höchstwertige Byte zuerst angezeigt. Innerhalb eines Bytes wird das höchstwertige Bit „zuerst“ angezeigt. 
Wie ginge das mit dem Debugger GDB? In manchen Fällen ist es einfacher, GDB direkt zu verwenden. Zum Beispiel bei der Verwendung von telnet oder ssh. GDB wird folgendermaßen gestartet: gdb tot

Als Nächstes setzen wir den Breakpoint: (gdb) b done
Breakpoint 1 at 0x804808b: file total.s, line 28.
6

Mit “r” (welches für “run” steht) können wir das Programm laufen lassen. Es sollte beim Breakpoint done stehen bleiben. Um jetzt den Inhalt des Registers EBX zu überprüfen, verwenden wir das GDB‐
Kommando „info registers“: (gdb) info registers ebx
ebx
0x1a

Einführung in Linux‐Assemblersprache
26
Um den Inhalt einer Speicherstelle zu überprüfen, verwenden wir das Kommando x (steht für eXamine). Für das Beispiel aus Kapitel 6 etwa: x/7w &x

Damit überprüfen wir 7 Wörter beginnend von der Adresse x. Die Daten werden in Hexadezimalformat ausgegeben. In manchen Fällen, z.B. wenn wir mit Code arbeiten, welcher aus C kompiliert wurde, gibt GDB einzelne Bytes aus. Dann sollten wir statt „x/7w“ das Kommando „x/7x“ eingeben. 
Es gibt auch andere Möglichkeiten, wie z.B. die folgende: x/12c &x

Damit würden wir anweisen, 12 Bytes beginnend ab der Adresse von x auszugeben. 6.11 WIE SOLL MAN ASSEMBLERPROGRAMME DEBUGGEN? 6.11.1 VERWENDE IMMER EIN DEBUGGING TOOL 
Meiner Erfahrung nach machen Studierende den größten Fehler indem sie keine Debugging‐
Werkzeuge verwenden. Sie schießen sich, wie man auf Englisch sagt, in den Fuß. Sie lernen zwar in Lehrveranstaltungen mit Debuggern umzugehen, glauben jedoch, dies nur für die Prüfung lernen zu müssen. Nachfolgend „debuggen“ sie ihre Programme mit printf‐Anweisungen (oder cout in C++); eine langsame und anstrengende Art zu debuggen. 
Für C‐ bzw. C++‐Programmierung auf Unix‐Maschinen gibt es viele Debugging‐Werkzeuge. Das gebräuchlichste davon ist GDB. Viele verwenden GDB indirekt, indem sie DDD als ein grafisches Interface zu GDB verwenden. 6.11.2 ALLGEMEINE REGELN 
Das Prinzip der „Confirmation“ (Bestärkung) ist das zentrale Konzept beim Debuggen. Wir müssen durch unser Programm Schritt für Schritt durchgehen und bestätigen, dass die Register und die verschiedenen Variablen im Speicher die Werte besitzen, welche wir haben möchten. Wir müssen auch alle Jumps aktiv überprüfen und nachsehen, ob diese genommen werden, wenn wir glauben, dass diese stattfinden sollten. Gelegentlich werden wir vergeblich auf diese Bestätigung warten und damit einen Hinweis auf einen Fehler haben. Gleichzeitig sind wir dem Ort des Fehlers ebenfalls sehr nahe. 
Wir sollten unsere Programme nicht nur top‐down „schreiben“, sondern auch auf die gleiche Art und Weise „debuggen“. Sehen wir uns folgendes Code‐Schnipsel an: 51
52 Rechnernetze und –Organisation movl $12, %eax
call xyz
addl %ebx, %ecx

Wenn wir im Single‐Step‐Modus mit Hilfe eines Debuggers durch dieses Programm gehen und auf die Zeile mit der Call‐Anweisung treffen, dann haben wir die Möglichkeit, in das Unterprogramm mit dem Debugger‐Kommando step einzusteigen oder auch (mit dem Next‐Kommando) darüber hinweg zu gehen. Letzteres sollte zuerst gemacht werden. Bei der nächsten Instruktion addl angekommen, sollte man das Ergebnis des Aufrufs des Unterprogramms xyz überprüfen. Wenn das Ergebnis stimmen sollte, hat man sich viel Zeit erspart, durch das Unterprogramm Schritt für Schritt durchzugehen. Wenn das Ergebnis hingegen falsch sein sollte, dann haben wir gleichzeitig auch die Fehlerquelle eingegrenzt. Bei einem zweiten Debug‐Durchlauf sollten wir dann das Unterprogramm xyz genauer ansehen. 6.11.3 SPEZIELLE TIPPS FÜR ASSEMBLERPROGRAMME 
Du musst auf jeden Fall wissen, wo deine Daten sind. Als Erstes sollte man zu Beginn einer Debug‐
Session also die Adressen aller Labels aus der Sektion .data aufschreiben. Diese Adressen sollte man dann beim Debuggen verwenden. 
Betrachten wir das Programm aus Kapitel 6, wo wir das Label x hatten. Zuerst müssen wir also nachsehen, wo x ist: (gdb) p/x &x

In DDD können wir das Gleiche mit einem Kommando aus dem Konsolenfenster machen. Die Kenntnis der Adresse einer Variablen ist für einen Debug‐Prozess extrem nützlich. Sehen wir nochmals das Programm aus Kapitel 6 als Beispiel an: In der Zeile mit dem Label top verwenden wir das Register EAX als Pointer auf unsere gegenwärtige Adresse x. Für eine Konfirmation brauchen wir also die Adresse von x. 
Segmentation Faults: Viele Fehler auf Assembler‐Ebene führen zu Segmentation Faults. Diese entstehen dadurch, dass sehr oft durch die Verwendung eines falschen Adressierungsmodus oder eines falschen Registers ein grober Fehler entstanden ist. Beispielsweise: movl $39, %edx

Dieser Befehl kopiert die Zahl 39 in das Register EDX. Nehmen wir an, dass wir statt dessen unabsichtlicherweise folgenden Befehl geschrieben hätten: movl $39, (%edx)

Jetzt würde also die Zahl 39 auf eine Speicherstelle kopiert, auf welche der Inhalt des Registers EDX zeigt. Wenn EDX zum Beispiel den Wert 12 enthielte, dann würde die CPU versuchen, auf die Speicheradresse 12 zuzugreifen. Diese ist höchstwahrscheinlich nicht im Speicherbereich, welcher für unser Programm alloziert wurde; deshalb auch der Segmentation Fault. 
Wenn wir das Programm im Debugger laufen lassen, wird dieser uns genau mitteilen, bei welcher Instruktion der Segmentation Fault passiert ist. Diese Information hat enormen Wert, denn sie zeigt uns punktgenau, wo der Fehler liegt. Der Debugger sagt uns zwar nicht, dass wir den falschen Adressierungsmodus verwendet haben; dies muss man gegebenenfalls selbst heraus finden. 6

Einführung in Linux‐Assemblersprache
Stelle dir beispielsweise folgenden Fall vor: Wir hätten indirekte Adressierung verwenden sollen, haben aber leider statt dem Register EXD das Register ECX „erwischt“. Also statt movl $39, (%edx)

Haben wir fälschlicherweise Folgendes geschrieben: movl $39, (%ecx)

Nehmen wir zudem an, dass im Register EDX das Datum 0x10402226 beträgt, im Register ECX jedoch der Wert 12 liegt. Weiter sollte die Adresse 0x10402226 zu unserem Programmbereich gehören (also vom Betriebssystem für unser Programm alloziert sein), die Adresse 12 jedoch nicht. Gleich wie weiter oben im vorigen Beispiel, würden wir auch hier wieder einen Segmentation Fault produzieren. Der Debugger sagt uns zwar nicht, welchen Fehler wir gemacht haben, doch zumindest wo genau der Fehler liegt. 6.11.4 DIE VERWENDUNG VON DDD FÜR DAS DEBUGGEN VON ASSEMBLERPROGRAMMEN 
Um Registerinhalte inspizieren zu können, klicke auf „Status“ und dann auf „Registers“. Aller Registerinhalte werden angezeigt. Auch das Register EFLAGS ist dabei. Das Carry‐Bit ist das Bit 0, also das niederwertigste Bit. Andere wichtige Bits sind das Zero Flag (Bit 6), das Sign Flag (Bit 7) und das Overflow Flag (Bit 11). 
Um zum Beispiel ein Feld von Daten, sagen wir x, anzuzeigen, klickt man zuerst auf „Data“, dann auf „Memory“. Danach muss man angeben, wie viele „Zellen“ man anzeigen will und welche Art von Zellen man haben will – ganze Wörter, einzelne Bytes, etc. – und schließlich, ab welcher Adresse diese Zellen liegen, also zum Beispiel &z. Man wird auch gefragt, ob man diese Zellen im Print‐Modus oder im Display‐Modus angezeigt haben will. Mit „Print“ werden die Werte nur einmal angezeigt, mit „Display“ laufend, sodass man Änderungen der Werte sehen kann. 
Breakpoints kann man setzen oder löschen, indem man das Stopp‐Zeichen klickt. Man kann dabei keinen Breakpoint auf die erste Instruktion eines Programmes legen. 
Man kann durch seinen Code Zeile für Zeile gehen. Bei Assembler‐Programmen sollte man jedoch Stepi statt Next oder Step verwenden. Das „i“ bei Stepi steht für Instruktion. 
Wenn man die Quellcode‐Datei verändert und danach neu assembliert und durch den Linker laufen lässt, dann lädt DDD diese veränderten Dateien nicht automatisch. Um die neuen Dateien in den Debugger zu laden, klickt man auf File, dann auf Open Program, und dann wählt man die exekutierbare Datei aus. 53
54 Rechnernetze und –Organisation 6.11.5 DIE VERWENDUNG VON GDB BEIM DEBUGGEN VON ASSEMBLERPROGRAMMEN 
Wir gehen hier davon aus, dass du GDB schon vom Prinzip her kennst. Zwei neue Kommandos solltest du lernen: o
Um alle Registerinhalte anzusehen: info registers
o
Mit dem Kommando „p“ (Print) kann man einzelne Registerinhalte ansehen: p/x $pc
p/x $esp
p/x $eax
o
Um Speicherinhalte anzusehen, verwendet man das Kommando „x“ (eXamine). Wenn man z.B. einen Speicherbereich mit dem Label z gekennzeichnet hat und die ersten 4 Wort ansehen möchte, dann erreicht man das damit: x/4w &z o
Man kann auch indirekt zugreifen. Beispielsweise würde folgender Befehl die 4 Wörter im Speicher anzeigen, auf deren Beginn der Inhalt von Register EBX zeigt: x/4w $ebx o
Wie schon oben bei DDD erwähnt: Verwende den Modus Stepi um Schritt für Schritt durch den Code zu gehen: (gdb)stepi o
Es geht auch mit: (gdb) si

Im Gegensatz zu DDD wird bei GDB das exekutierbare Programm nach einer Veränderung der Quellcode‐Datei und deren Rekompilierung automatisch neu geladen. 
Ein offensichtlicher Nachteil von GDB ist, dass man viel tippen muss. Dieser Nachteil kann durch das Kommando „define“ jedoch sehr gemildert werden. Mit „define“ kann man Abkürzungen einführen. Folgendes Beispiel zeigt, wie wir den Schreibaufwand vermindern um den Inhalt von Register EAX anzeigen zu können: (gdb) define pa
Type commands for definition of „pa“.
End wit h a line saying just “end”.
>p/x $eax
>end

Von nun an kann man mit pa in dieser GDB‐Session den Inhalt von Register EAX ausgeben. 
Will man diese Abkürzungen für jede Session zur Verfügung haben, können wir diese in die Datei .gdbinit stellen. Diese Datei muss im gleichen Verzeichnis wie das Programm selbst sein. 
Verwende die Online‐Hilfe von GDB um mehr über GDB zu erfahren. Du brauchst beim GDB‐Prompt lediglich „help“ einzugeben. 6
Einführung in Linux‐Assemblersprache

Andere Möglichkeiten bietet der TUI‐Modus, welcher in neueren Versionen von GDB oft eingebaut ist. Starte GDB mit dem Schalter –tui. Wenn du dann in GDB bist, kannst du den TUI‐Modus mit ctrl-x a ein‐ oder ausschalten. 
Im TUI‐Modus muss man zuerst das List‐Kommande („l“) eingeben. Danach ein oder zwei Mal die Enter‐Taste drücken. Dann erscheint das Fenster mit dem Quellcode. Als Nächstes würde man wohl einen Breakpoint setzen und wie in GDB üblich das Run‐Kommando („r“) ausführen. 
Breakpoints erkennt man an den Asterisk‐Zeichen. Die soeben laufende Instruktion ist mit dem Zeichen „>“ gekennzeichnet. 
Mit folgendem Kommando kann man auch ein Fenster mit den Registerinhalten erscheinen lassen: (gdb) layout reg 
Auf diese Art und Weise können wir den Quellcode und die Registerinhalte gleichzeitig betrachten. TUI stellt die Register, welche soeben ihre Werte verändern“ mit „Highlight“ dar. 
Der TUI‐Modus läuft innerhalb von GDB. Man kann deshalb auch alle sonstigen GDB‐Kommandos eingeben. 6.12 NOCH EIN PAAR OPERANDEN‐GRÖSSEN 
Wir hatten weiter oben folgende Instruktion: addl (%ecx), %ebx

Wie würde dieser Befehl aussehen, wenn wir es mit Daten von 16‐Bit zu tun hätten? Wir würden möglicherweise statt .long die Pseudoinstruktion .word in der Sektion .data verwenden. Obige Instruktion würde dann wohl eher so aussehen: addw (%ecx), %bx

„w“ steht dabei für „Word“ und deutet auf ältere Intel‐Chips hin, wo lediglich 16‐Bit‐Register zur Verfügung standen. 
Die Änderungen sind also recht einfach zu verstehen. Doch eine „Nicht‐Änderung“ ist bemerkenswert: Warum haben wir noch immer ECX und nicht CX? Die Antwort: Wir haben es noch immer mit 32‐Bit‐
Adressen zu tun. Deshalb ECX. 
Es gibt auch entsprechende 8‐Bit‐Befehle. Statt .long würden wir dann .byte nehmen. Und movl würde durch movb ersetzt werden. „%ah“ und „%al“ würden das High‐order‐Byte und das Low‐Order‐
Byte des Registers AX bezeichnen usw. 
Will man bedingte Sprünge, welche auf 16‐Bit oder auf 8‐Bit arbeiten, dann kann man cmpw oder cmpb verwenden. 
Wenn der Zieloperand für eine Byte‐Instruktion Wortgröße hat, dann erlaubt uns die CPU, das Source‐
Byte zu einem Wort zu erweitern. Beispielsweise: movb $-1, %eax
55
56 Rechnernetze und –Organisation 
Hier wird das Byte ‐1, also 11111111 genommen und auf das Wort ‐1 erweitert: 11111111111111111111111111111111. Dieses wird dann im Register EAX gespeichert. Es wird also das Vorzeichenbit erweitert. Wir nennen das auch „Sign Extension“. 
Wenn man dies macht, gibt der Assembler normalerweise eine Warnung aus. Der Assembler gibt einen Fehler aus, wenn man Folgendes machen möchte: movb %eax, %ebx

Der Quelloperand hat hier die Größe eines Wortes. Damit hat der Assembler keine andere Wahl als uns einen Fehler zu signalisieren. Es gibt in der Intel‐Architektur keinen Maschinenbefehl dieser Art. 6.13 EIN PAAR ANDERE ADRESSIERUNGSMODI 
Nachfolgend finden sich Beispiele für verschiedene Adressierungsmodi. Die ersten 4 haben wir bereits früher gesehen, die restlichen sind neu. Das Label „x“ befindet sich in der Sektion .data. decl
decl
decl
movl
%ebx
(%ebx)
x
$8888, %ebx
#
#
#
#
register mode
indirect mode
direct mode
source operand is in immediate mode
decl
decl
decl
decl
x(%ebx)
8(%ebx)
(%eax,%ebx,4)
x(%eax,%ebx,4)
#
#
#
#
indexed mode
based mode (same as indexed)
scale-factor (my own name for it)
based scale-factor (my own name for it)

Sehen wir uns den “indexed mode” als Erstes an. Der Ausdruck x(%ebx) bedeutet, dass der Operand sich c(EBX) Bytes nach x befindet. Mit c(EBX) meinen wir den Inhalt (content) des Registers EBX. Der Name „Index“ kommt also daher, dass EBX die Rolle eines Index in einem Vektor (Feld, Array) übernimmt. 
Sehen wir uns beispielsweise folgenden C‐Code an: char x[100];
…
x[12] = ´g´;
Wir haben es hier mit einem Array vom Typ char zu tun. Jedes Array‐Element besetzt also 1 Byte. Deshalb ist x[12] 12 Speicherstellen nach x zu finden. Der C‐Compiler könnte obigen C‐Code also folgendermaßen übersetzen: movl $12, %ebx
movb $´g´, x(%ebx)

EBX erfüllt hier also die Rolle eines Index. Deshalb auch der Name „indexed adressing mode“. Wir nennen EBX hier das Indexregister. Auf Intel‐Maschinen kann fast jedes Register als Indexregister verwendet werden. 6

Einführung in Linux‐Assemblersprache
Obige Methode des Indizierens funktioniert jedoch nicht für Integer‐Variablen. Integer besetzen ja jeweils 4 Bytes. Wir brauchen also im Falle von Integer eine zusätzliche Instruktion. Der C‐Code int x[100], i;
…
x[i] = 8888;
// nehmen wir an, dass diese Variablen global sind
würde vermutlich folgendermaßen übersetzt werden: movl i, %ebx
imull $4, %ebx
movl $8888, x(%ebx)
(imull ist eine Multiplikations‐Instruktion.) Deshalb hat Intel einen anderen, allgemeineren Modus, welcher oben „scale‐factor mode“ genannt wurde. Im Scale‐Factor‐Modus kommen die drei Variablen (register1, register2, scale_factor) vor; daraus berechnet sich die Operanden‐Adresse mit register1 + scale_factor*register2. Damit können wir die imull‐Instruktion vermeiden: movl
movl
movl
x, %eax
i, %ebx
$8888, (%eax, %ebx, 4)

Wir müssen oben noch immer einen Extra‐Schritt mit dem Setzen von EAX machen. Wenn wir jedoch eine Menge von Zugriffen auf das Feld x vorhaben, dann brauchen wir EAX nur einmal zu setzen. Eigentlich muss auch hier noch immer innerhalb der Maschine eine Multiplikation mit 4 durchgeführt werden. Dies geschieht jetzt in der dritten MOVL‐Instruktion.

Übrigens: Nehmen wir an, wir möchten folgende C‐Anweisung kompilieren: x[12] = 8888;

In diesem Fall könnten wir einfach mit dem bekannten Direkt‐Addressierungs‐Modus auskommen: movl $8888, x+48

Im obigen Fall haben wir es ja mit einer fixen Adresse (=12) zu tun. Deshalb wissen wir auch, dass diese 48 Bytes nach x vorkommt. Und x ist ja fix hier.

Was ist jetzt „Based Mode“?

Indexed Mode und Based Mode sind eigentlich das Gleiche. Wir betrachten die beiden Modi jedoch auf verschiedene Weise, da wir diese typsicherweise in verschiedenem Zusammenhang verwenden. In beiden Fällen ist die Syntax constant(register)
57
58 Rechnernetze und –Organisation 
Die Adresse des Operanden ist dann „constant + register‐Inhalt“. Wenn also constant z.B. 5000 wäre und der Inhalt des angegebenen Registers 240, dann würde der Operand auf Speicherstelle 5240 sein.

Beachte, dass im Falle von x(%ebx)
das x eine Konstante ist. Denn x steht hier ja als „Adresse von x“.

Es ist auch möglich, den Ausdruck (x+200)(%ebx)
zu verwenden. Man meint hier „EBX Bytes nach x+200“ – oder noch deutlicher: „EBX + 200 Bytes nach x“.

Wir tendieren jedoch dazu, Based Mode etwas verschieden von Indexed Mode zu betrachten. Mit x(%ebx)
meinen wir „EBX Bytes nach x“, während wir mit 8(%exb)
folgende Bedeutung haben: „8 Bytes nach dem Platz im Speicher, auf den EBX zeigt“. Der erste Fall kommt typischerweise im Zusammenhang mit Arrays vor. Der zweite Fall eher in Zusammenhang mit Stacks.

Über Stacks werden wir später noch im Detail sprechen. Beim Kapitel über Unterprogramme. Was du jedoch jetzt schon wissen solltest: Lokale Variablen werden am Stack gespeichert. Eine bestimmte Variable könnte also z.B. 8 Bytes vom Beginn des Stacks gespeichert sein. Du wirst auch lernen, dass das ESP‐Register auf den Beginn des Stacks zeigt. Damit ist die lokale Variable tatsächlich „8 Bytes nach ESP“. Damit erkennt man den Zusammenhang mit dem Based Mode. 6.14 GCC/LINKER‐OPERATIONEN 6.14.1 GCC ALS MANAGER DES KOMPILIERUNGSPROZESSES 
Nehmen wir an, dass unser C‐Programm aus den Quelldateien x.c und y.c bestünde. Wir würden diese beiden Dateien so kompilieren: gcc –g x.c y.c

GCC ist im Grunde ein „Wrapper“‐Programm und dient als Manager des Kompilierungsprozesses. GCC kompiliert nicht selbst, sondern startet andere Programme, welche die tatsächliche Arbeit durchführen. Sehen wir uns das im Detail an. 6
6.14.1.1 
Einführung in Linux‐Assemblersprache
DER C‐PREPROZESSOR Zuerst wird von GCC der C‐Preprozessor CPP aufgerufen. Dieser verarbeitet #define, #inlcude und andere ähnliche Anweisungen. Zum Beispiel: %cat y.c
// y.c
#define ZZZZ 7
#include “w.h”
main()
{
int x,y,z;
scanf(“%d%d”,&x,%y);
x *= ZZZZ;
y += ZZZZ;
z = bigger(x,y);
printf(“%d\n”,z);
}
%cat w.h
// w.h
#define bigger(a,b) (a > b) ? (a) : (b)
%
#
#
#
#
cpp y.c
1 “y.c”
1 “<built-in>”
1 “<command line>”
1 “y.c”
# 1 “w.h” 1
# 6 “y.c” 2
main()
{
int x,y,z;
scanf(“%d%d”,&x,%y);
x *= 7;
y += 7;
z = (x < y) ? (x) : (y);
printf(“%d\n”,z);
}

Das Ergebnis des Preprozessors kann also jetzt in der nächsten Verarbeitungsstufe verwendet werden. 6.14.1.2 DER TATSÄCHLICHE COMPILER CC1 UND DER ASSEMBLER AS 
Im Anschluss an den C‐Preprozessor startet GCC ein weiteres Programm. Dieses trägt den Namen cc1 und erledigt die tatsächliche Übersetzung des C‐Codes in Assembler‐Sprache. Die erzeugte Datei heißt dann x.s. 
Danach startet GCC den Assembler AS (mit dem Dateinamen as). AS übersetzt die Datei x.s in echte Maschinensprache, also in Nullen und Einsen. Die erzeugte Datei heißt x.o und wir nennen diese üblicherweise Objekt‐Datei (object file). 59
60 Rechnernetze und –Organisation 
GCC macht danach das Gleiche mit der Datei y.c und produziert y.o. 
Schließlich wirft GCC das Linker‐Programm LD (mit dem Dateinamen ld) an. Dieses verbindet (linking) die beiden Objektdateien x.o und y.o und erzeugt die Datei a.out. Danach werden die Zwischenprodukte, also die Dateien mit den Endungen .s und .o gelöscht. 6.14.2 DER LINKER 6.14.2.1 
WELCHE INFORMATION WIRD DURCH DEN LINKER VERBUNDEN? Unser Beispiel im vorigen Abschnitt bestand aus den beiden Quelldateien x.c und y.c. Wir wissen bereits, dass das Kompilier‐Kommando gcc –g x.c y.c
als Zwischenprodukt zwei Objekt‐Dateien x.o und y.o erzeugt. Das Linker‐Programm LD macht aus diesen beiden Dateien die ausführbare Datei a.out. 
Was macht der Linker genau? Nehmen wir an, dass die Funktion main() in der Datei x.c eine Funktion f() in y.c aufruft. Wenn der Compiler diesen Funktionsaufruf f() in x.c erkennt, „sagt“ er: „Aha, es gibt offensichtlich keine Funktionsbeschreibung für f() in der Datei x.c. Ich kann diesen Funktionsaufruf (= Call) nicht übersetzen, da ich die Adresse von f() nicht kenne.“ Deshalb macht der Compiler in der Datei x.o eine Notiz für den Linker mit folgendem Inhalt: „Lieber Linker! Wenn du die Datei x.o mit anderen Objekt‐Dateien verbindest, dann musst du herausfinden, wo die Funktion f() definiert ist. Du kannst dann die Übersetzung des Aufrufs von f() in x.c fertig machen. Alternativ dazu könnte es auch sein, dass unser Kommandozeilen‐Aufruf von GCC Bibliotheken beinhaltet. Doch dazu später. 
In ähnlicher Weise könnte in x.c auch eine globale Variable mit Namen z vorkommen, auf welche sich der Code in der Datei y.c bezieht. Der Compiler würde dann in y.o eine Notiz für den Linker hinterlassen. Und so weiter. 
Wir sehen also, dass der Linker dafür zuständig ist, obige Querverweise zwischen Dateien aufzulösen bevor die Objekt‐Dateien zu einer einzigen großen exekutierbaren Datei a.out zusammengefügt werden. 
Es ist egal, ob der ursprüngliche Quellcode in C oder C++, oder in Assemblersprache vorliegt. Der Linker verbindet alle Objekt‐Dateien. 
Obwohl typischerweise der Linker LD von GCC aufgerufen wird, können wir LD auch direkt aufrufen. Im Falle der Übersetzung von Assemblersprache ist das sogar üblich. Wir können jedoch auch GCC zur Übersetzung von Quelldateien in Assemblersprache verwenden. GCC erkennt an der Datei‐
Erweiterung .s, dass es sich um diesen Fall handelt und wird deshalb den Assembler AS aufrufen. 6
6.14.2.2 Einführung in Linux‐Assemblersprache
HEADER IN EXEKUTIERBAREN DATEIEN 
Auch wenn wir lediglich eine Datei als Quellcode haben – im Gegensatz zum obigen Beispiel mit zwei Dateien – ist es notwendig, den Linker aufzurufen, um eine exekutierbare Datei zu erzeugen. Dafür gibt es eine Reihe von Gründen. 
Zum Einen wird bei C‐Programmen eine Reihe von Bibliotheken verwendet, welche im Quellcode nicht offensichtlich aufscheinen. Wenn man das Programm ldd über eine exekutierbare Datei, welche aus C‐Quellcode erzeugt wurde, laufen lässt, dann erkennt man, dass die C‐Bibliothek libc.so verwendet wird. Diese Bibliothek ist notwendig, damit die Ausführung des Programms überhaupt starten kann. Denn in dieser Bibliothek ist das Label _start definiert. Zudem wird in dieser Bibliothek der Stack für das Programm „aufgesetzt“. Schließlich beinhaltet diese Bibliothek eine Reihe von Basis‐Funktionen, wie etwa printf() und scanf(). 
Darüber hinaus enthalten exekutierbare Dateien nicht nur den tatsächlichen Maschinencode, sondern auch eine „Header“. Dies ist eine Art „Einleitung“ zu Beginn der Datei. Dieser Header definiert, auf welche Speicheradressen die einzelnen Sektionen geladen werden sollen, wie groß diese Sektionen sind, und auf welcher Adresse die Ausführung des Programms begonnen werden soll. Und Ähnliches mehr. Der Linker berechnet diese Daten und erzeugt diesen Header entsprechend. 
Es gibt mehrere Standardformate für diese Header. In Linux wird das sogenannte ELF‐Format verwendet. Solltest du neugierig sein: mit dem Linux‐Kommando readelf kannst du herausfinden, was im Header einer exekutierbaren Datei steht. Wenn du dabei die Option –s verwendest, dann siehst du die endgültigen Adressen der Labels, welche der Linker berechnet hat. 
Du könntest auch das Kommando nm verwenden. Dieses Kommando ist allgemeiner als readelf und funktioniert nicht nur für ELF‐Dateien; zudem kriegt man damit auch Symbolinformationen. 
Auch in Objekt‐Dateien gibt es Header. Mit dem Linux‐Kommando objdump kannst du dir diese ansehen. 6.14.2.3 BIBLIOTHEKEN 
Eine Bibliothek ist ein Konglomerat aus mehreren Objekt‐Dateien, welche gemeinsam an einem bestimmten praktischen „Ort“ gesammelt werden. Bibliotheken können entweder statisch oder dynamisch sein. In Unix haben Bibliotheksdateien die Endung .a (statisch) oder .so (dynamisch). Danach folgt eventuell eine Versionsnummer. 
In Windows nennt man die dynamischen Bibliotheksdateien DLL‐Dateien. („dynamically linked library“) 
In Unix starten die Namen dieser Bibliotheksdateien normalerweise mit „lib“. Also zum Beispiel libc.so.6. Dies wäre etwa die Version 6 der C‐Bibliothek. 
Wenn man Code einer statischen Bibliothek in seinem Programm benötigt, dann wird der Linker diesen Code zusammen mit dem „eigenen“ Maschinencode in die exekutierbare Datei packen. Wenn man eine dynamische Bibliothek verwendet, dann wird der Linker in den Maschinencode der exekutierbaren Datei lediglich eine Notiz einfügen, welche die benötigten Bibliotheken festhält. Dieser Bibliothekscode wird dann zur „Runtime“, also wenn das exekutierbare Programm zur Exekution in den Hauptspeicher geladen wird, ebenfalls in den Hauptspeicher geladen. Erst kurz vor der Ausführung dieses Programms wird dann die endgültige „Verlinkung“ vorgenommen. Dynamische 61
62 Rechnernetze und –Organisation Bibliotheken sparen also Speicherplatz, sowohl auf der Harddisk als auch im Hauptspeicher. Im Hauptspeicher deshalb, da wir ja nur eine einzige Kopie im Hauptspeicher sein muss, selbst wenn diese von mehreren Programmen verwendet wird. Man nimmt jedoch den kleinen Nachteil in Kauf, dass sich der Programmstart ein wenig verzögert. 
Nachfolgend sehen wir uns an, wie man Bibliotheken macht bzw. diese verwendet. 
Eine statische Bibliothek kann man mit dem Kommando ar aus einer Gruppe von mehreren Objekt‐
Dateien erzeugen. Danach muss man noch (manches Mal) das Kommando ranlib ausführen. Also zum Beispiel so: %
%
%
%

gcc –c x.c
gcc –c y.c
ar lib8888.a x.o y.o
ranlib lib8888.a
Wenn später jemand den Inhalt einer statischen Bibliothek herausfinden möchte, kann er das Kommando ar mit der Option t zusammen mit einer .a‐Datei ausführen: % ar t lib8888.a

Will man hingegen eine dynamische Bibliothek erzeugen, dann muss man die Option –fPIC bei der Erzeugung der Objekt‐Dateien verwenden. Schließlich muss man die Bibliothek unter Verwendung der GCC‐Option -shared kompilieren: % gcc –g –c –fPIC x.c
% gcc – g –c –fPIC y.c
% gcc –shared –o lib8888.so x.o y.o

In Systemen, welche ELF verwenden, kann man mit dem Kommando readelf den Inhalt einer dynamischen Bibliothek checken: % readelf –s ib8888.so 
Will man eine Bibliothek zu einem Programm dazu linken, dann verwendet man in GCC die Option –l für statische Bibliotheken. Hier ein Beispiel: Wir haben etwa ein C‐Programm w.c, in welchem wir die Funktion sqrt() aufrufen. Diese Funktion befindet sich in der Mathematik‐Bibliothek libm.so. In diesem Fall könnten wir so vorgehen: % gcc –g w.c –lm

Die Notation „-lxxx“ bedeutet, dass die Bibliothek mit dem Namen libxxx.a oder libxxx.so „dazugelinkt“ werden soll. 
Noch eine Anmerkung: Es könnte sein, dass die benötigte Bibliothek sich nicht in einem der Default‐
Verzeichnisse /usr/lib, /lib bzw. in denjenigen Verzeichnissen, welche in /etc/ld.so.cache
erwähnt werden, befindet. Wenn sich etwas eine Bibliothek libqrs.so im Verzeichnis /a/b/c
befindet, dann müsste man GCC so starten: % gcc –g w.c –lqrs – L/a/b/c 6

Einführung in Linux‐Assemblersprache
Das alles geht so im Falle von statischen Bibliotheken. Bei dynamischen Bibliotheken wird der Code der Bibliothek jedoch nicht zur Kompilierzeit gebraucht. In der zu erzeugenden exekutierbaren Datei wird stattdessen der Name der Bibliothek verzeichnet – nicht deren Ort. Mit anderen Worten: Die Angabe –L/a/b/c im Beispiel oben wird vom Linker verwendet, lediglich die Existenz der Datei zu verifizieren. Es gibt mehrere Varianten, wie man dem Linker auch andere Verzeichnisse zum Nachsehen geben kann. Eine Möglichkeit besteht darin, die Unix‐Umgebungsvariable LD_LIBRARY_PATH zu verwenden: % setenv LD_LIBRARY_PATH /a/b/c

Um herauszufinden, welche dynamischen Bibliotheken von einer exekutierbaren Datei gebraucht werden, kann man das Kommando ldd verwenden: % ldd a.out 
Es gibt noch eine Reihe weiterer Details, welche wir hier nicht besprechen wollen. Solltest du aber mehr wissen wollen, dann suche am Web nach einem „Tutorial“ zu „shared libraries“. 6.15 INLINE ASSEMBLY CODE FÜR C++ 
In der Sprache C++ gibt es das Konstrukt asm, mit welchem man inmitten von normalem C++‐
Quellcode Code in Assemblersprache einbetten kann. 
Damit besteht die Möglichkeit, sich Zugang zu schnellen Verarbeitungsmöglichkeiten der Hardware zu verschaffen. Wenn man zum Beispiel die schnelle String‐Kopier‐Funktion MOVS verwenden möchte, könnte man diese Assembler‐Instruktion in einem eigenen Assembler‐Unterprogramm in einer eigenen Assembler‐Quellcodedatei verwenden und diese Datei zum restlichen C++‐Quellcode „dazu‐
linken“. Damit würde man jedoch sich den „zeitraubenden“ Call‐Return‐Mechanismus eines Unterprogramms einhandeln. Stattdessen könnte man den Code mit dem MOVS‐Befehl direkt im C++‐
Quellcode einbetten. Sehen wir uns ein kleines, sehr simplifiziertes Beispiel an: // file a.c
int x;
main()
{
scanf(" “%d“,&x);
__asm__(“pushl x“);
}

Dann übersetzen wir diese Datei in Assemblersprache: gcc –S a.c

Die daraus entstehende Datei a.s wird folgendermaßen aussehen: .file "a.c"
.section .rodata
.LC0:
.string "%d"
.text
.globl main
.type main, @function
main:
leal
4(%esp), %ecx
63
64 Rechnernetze und –Organisation andl
pushl
pushl
movl
pushl
subl
movl
movl
call
$-16, %esp
-4(%ecx)
%ebp
%esp, %ebp
%ecx
$20, %esp
$x, 4(%esp)
$.LC0, (%esp)
scanf
#APP
pushl x
#NO_APP
addl
$20, %esp
popl
%ecx
popl
%ebp
leal
-4(%ecx), %esp
ret
.size main, .-main
.comm x,4,4
.ident “GCC: (GNU) 4.1.2 20080704 (Red Hat 4.1.2-46)“
.section
.note.GNU-stack,””,@progbits

Unser Assemblercode‐Einschub ist mit den beiden Begrenzern #APP und #NO_APP eingeschlossen. Und dazwischen befindet sich unser „pushl x“. 
Wenn du mehr darüber wissen willst, dann schau im Web unter „Inline Assembly Tutorial“ nach. http://www.opennet.ru/base/dev/gccasm.txt.html 6.16 „LINUX INTEL ASSEMBLY LANGUAGE“: WARUM „INTEL“ UND WARUM „LINUX“? 
Warum sprechen wir von „Linux Intel Assemblersprache“? Warum steckt Linux und Intel drin? 
Der Zusatz „Intel“ ist einfach zu erklären. Jeder CPU‐Typ hat einen anderen Instruktionssatz und Registersatz. Maschinencode, welcher auf einer Intel‐CPU läuft, kann z.B. nicht auf einem PowerPC laufen. Auch die umgekehrte Variante funktioniert nicht. 
Der Zusatz „Linux“ ist etwas subtiler: Nehmen wir an, dass wir eine C‐Quellcode‐Datei mit Namen y.c hätten. Diese kompilieren wir jetzt auf ein und demselben PC, einmal unter Linux und einmal unter Windows. Im ersten Fall bekommen wir eine Datei mit Namen y und im zweiten Fall eine Datei mit Namen y.exe. Beide Dateien enthalten Intel‐Instruktionen. Aber je nach Betriebssystem stecken verschiedene Konventionen für die Verwendung von Betriebssystem‐Funktionen drin. Diese sind abhängig vom verwendeten Betriebssystem. 6.17 WIE KANN MAN KOMPILIERTEN ASSEMBLER‐CODE ANSEHEN? 
Oft möchte man den Assembler‐Code ansehen, welcher von GCC aus C‐Code erzeugt wird. Bei GCC verwendet man dazu die Option –S. Hier ein Beispiel: gcc –S y.c In diesem Fall wird die Datei y.s erzeugt und danach stoppt GCC und erzeugt keine weiteren „Produkte“ (Objekt‐Datei oder exekutierbare Datei). Man könnte jetzt auf diese Datei y.s den Assembler as anwenden und y.o erzeugen. Dann kann man ld mit y.o laufen lassen und damit a.out erzeugen. Dabei muss man jedoch noch die entsprechenden C‐Bibliotheken „dazu‐linken“. 6
Einführung in Linux‐Assemblersprache
Einfacher wäre folgendes: gcc y.o
Da würde GCC selbst heraus finden, welche Bibliotheken im Link‐Prozess dazugebunden werden müssen. 6.18 NÜTZLICHE LINKS AM WEB 








http://linuxassembly.org http://www.gnu.org/manual/gas‐2.9.1/html_mono/as.html Intel2gas: Konvertierprogramm von Intel‐Syntax zur AT&T‐Syntax und umgekehrt: http://www.niksula.sc.hut.fi/~mtiihone/intel2gas http://www.penguin.cz/~literakl/intel/intel.html ftp://download.intel.com/design/Pentium4/manuals/24547108.pdf http://nasm.2y.net/ ALD‐Debugger: http://ellipse.mcs.drexel.edu/ald.html http://heather.cs.ucdavis.edu/~matloff/debug.html Unix‐Tutorial: http://heather.cs.ucdavis.edu/~matloff/unix.html 65
66 Rechnernetze und –Organisation 7 EINFÜHRUNG IN INTEL‐MASCHINENSPRACHE (nach Mattloff: Introduction to Intel Machine Language (and More On Linking) 7.1 EINLEITUNG 
In diesem Kapitel sprechen wir über Intel‐Maschinensprache. Als Voraussetzung solltest einigermaßen mit der Intel‐Assemblersprache (in der AT&T‐Syntax) vertraut sein. 7.2 ASSEMBLERSPRACHE UND MASCHINENSPRACHE 
Assemblersprache ist in Wirklichkeit identisch mit Maschinensprache. Der einzige Unterschied liegt darin, dass eine Instruktion in Maschinensprache in Nullen und Einsen dargestellt wird, während in Assemblersprache der gleiche Sachverhalt in für den Menschen verständlicher Weise dargestellt wird. 
Nehmen wir als Beispiel die Operation „move“, welche den Inhalt eines Registers in ein anderes Register kopiert. [Aufpassen: „move“ ist eigentlich für die Kopieroperation eine vollkommen falsche Bezeichnung.] Das Bitmuster, welches einer CPU dies signalisiert – wir nennen dieses auch Op‐Code – ist in diesem Fall 1000100111. Die CPU‐Hardware ist so gebaut, dass bei Erkennen dieses Musters bei den ersten 10 Bit einer Instruktion eine Register‐Register‐Kopieroperation ausgelöst wird. 
Was meinen wir hier mit „die ersten 10 Bit einer Instruktion“? Eine Intel‐Instruktion kann mehrere Bytes lang sein. Wenn wir vom „ersten“ Byte einer Instruktion sprechen, dann meinen wir dasjenige Byte, das an der niedrigsten Speicheradresse liegt. Wenn wir vom ersten Bit eines Bytes sprechen, dann meinen wir das höchstwertige Bit in diesem Byte. 
Da wir Menschen uns schwer tun, Zeichenketten bestehend aus Nullen und Einsen zu lesen und zu interpretieren, sehen wir eine Alternative vor. Immer wenn wir „movl“ schreiben, meinen wir eigentlich 1000100111. Und wir verwenden einen „Diener“, welcher diese Übersetzung vornimmt. Die Buchstabenfolge „movl“ ist für uns Menschen wesentlich einfacher zu merken. Spricht man zudem Englisch, wird es fast trivial zu erkennen, was gemeint ist. 
Die CPU‐Hardware ist zudem so gebaut, dass sie „weiß“, dass die Instruktion, welche mit dem Bitmuster 1000100111 beginnt, eine Länge von 2 Bytes hat. Die Hardware‐Schaltung „weiß“ auch, dass die restlichen 6 Bits der Instruktion die Information darstellen, welche beiden Register bei dieser Move‐Instruktion verwendet werden sollen. Das 11., 12. und 13. Bit benennen das Quellregister und das 14., 15. Und 16. Bit das Zielregister. Dabei gibt es folgende Übereinkunft: EAX 000 EBX 011 ECX 001 EDX 010 7
Einführung in Intel‐Maschinensprache
(Es gibt wohl noch ein paar andere Register, doch wir wollen uns hier beispielhaft auf diese vier beschränken.) 
Unser „Diener“ sollte also folgende Übereinkunft kennen: Immer wenn wir %ebx schreiben, soll er dies in das Bitmuster „001“ übersetzen. Dieser „Diener“ ist unser Assembler as. Wir verwenden einen normalen Text‐Editor, um unsere Abkürzungen in einer Text‐Datei festzuhalten. Der Assembler übersetzt diese Text‐Datei sodann in eine Folge von Nullen und Einsen. 
Wichtig ist jedoch hier festzuhalten, dass der „Programmierer“ trotz der Übersetzungsprozesses die volle Kontrolle über die Folge der erzeugten Instruktionen samt deren Operanden behält. Dies ist bei der Verwendung eines Compilers, welcher etwas C‐Quellcode in Assemblercode übersetzt, nicht der Fall. 
Wenn wir also zum Beispiel die Zeichenfolge movl %eax, %ebx in eine Textdatei x.s eingeben, dann wissen wir, dass der Assembler diese Zeile in den Maschinenbefehl 1000100111000011 übersetzen wird. In Hexadezimalnotation wäre das dann 0x89c3. 
Wenn wir hingegen die C‐Anweisung y = x + 3; mit Hilfe eines Compilers übersetzen, dann haben wir keine Kontrolle darüber, was der Compiler daraus macht. Tatsächlich würden verschiedene Compiler auch verschiedene Maschinenbefehle verwenden. 
Der Assembler würde im obigen Beispiel den Maschinencode 1000100111000011 in der Objektdatei x.o speichern. 
In diesem Kapitel sprechen wir über Intel‐Maschinensprache. Als Voraussetzung solltest einigermaßen mit der Intel‐Assemblersprache (in der AT&T‐Syntax) vertraut sein. 7.3 BEISPIEL 7.3.1 DER CODE 
Betrachten wir die Assembler‐Quelldatei Total.s (bzw. Add4.s). Wir lassen diese durch den Assembler as mit der Option –a laufen. Damit erzeugt der Assembler eine Datei, in welcher er uns den produzierten Maschinencode Zeile für Zeile zusammen mit dem Assembler‐Code zeigt. Das sieht so aus: GAS LISTING Total.s page 1
1
2
3
4
5
# finds the sum of the elements of an array
# assembly/linking Instructions:
67
68 Rechnernetze und –Organisation 6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
24
25
26
26
27
27
28
29
30
31
32
33
33
34
# as -a --gstabs -o total.o Total.s
# ld -o total total.o
.data # start of data section
0000
0004
0008
000c
x:
01000000
05000000
02000000
12000000
.long
.long
.long
.long
0010
sum:
00000000
.long 0
1
5
2
18
.text # start of code section
.globl _start
_start:
B8040000
movl $4, %eax
# EAX will serve as a counter for
0005
00
000a
00
BB000000
movl $0, %ebx
# number of words left
# EBX will store the sum
B9000000
movl $x, %ecx
# ECX will point to the current
000f
0011
0014
0015
0017
0000
001d
0319
top:
addl (%ecx), %ebx
83C104
addl $4, %ecx
48
decl %eax
75F8
jnz top
891D1000 done: movl %ebx, sum
0000
00
# element to be summed
#
#
#
#
move pointer to next element
decrement counter
if counter not 0, loop again
done, store result in "sum"
8D7600
DEFINED SYMBOLS
Total.s:11
Total.s:17
Total.s:23
Total.s:29
Total.s:33
.data:00000000
.data:00000010
.text:00000000
.text:0000000f
.text:00000017
x
sum
_start
top
done
NO UNDEFINED SYMBOLS
7.3.2 FEEDBACK VOM ASSEMBLER 
Jede Zeile obiger Datei hat folgende Anordnung: Zeilennummer



Offset
Speicherinhalt
Assemblercode
Unter „Offset“ verstehen wir die relative Adresse, also die Distanz zum Beginn der jeweiligen Sektion (.data, .text, usw.) Der Assembler sagt uns also: Das habe ich mit der Assemblercodezeile gemacht. Nochmals zum „Offset“: Sehen wir uns dazu Zeile 18 an. Dort steht, dass das Wort, welches wir in unserem Assemblerquellcode mit dem Label sum bezeichnet haben, auf die relative Adresse 0x10 kommen wird. Also 16 Bytes nach dem Beginn der Sektion .data. 7
Einführung in Intel‐Maschinensprache
7.3.3 EIN PAAR INSTRUKTIONS‐FORMATE 




Der Speicherinhalt in obiger Datei zeigt uns die Bytes, welche der Assembler aus dem Quellcode produziert hat. Die Bytes werden in aufsteigender Reihenfolge (!) Byte für Byte dargestellt. Beachte den Effekt des Formats „Little Endian“. Zum Beispiel in Zeile 13. Diese Zeile besagt, dass unser Assemblercode‐Anweisung .long 5 in folgende Sequenz von Bytes übersetzt wurde: 05 00 00 00. Verstehst du das? Die Zahl ist 0x00000005. Das niederwertigste Byte dieser Zahl ist also 0x05 und kommt auf die niedrigste Adresse. Deswegen wird dieses Byte auch zuerst gezeigt. Die tatsächliche Adresse dieses Worts wird erst durch den Linker ld ermittelt. Der Linker wählt einen Platz im Speicher, wo er die Sektion .data hinlegt. Damit werden auch alle Adressen definiert. Wenn die Sektion zum Beispiel auf Adresse 0x2020 beginnen sollte, dann würde das Datum von Zeile 15 oben auf die Adresse 0x2020 + 0x0c, also auf Adresse 0x202c kommen. Beachte, dass die zugewiesenen Adressen für die Daten‐Elemente der Sektion .data in der gleichen Reihenfolge wie in unserem Quell‐Code aufscheinen. Sehen wir uns nun noch ein paar andere Formate aus der Intel‐Maschinensprache an. Zum Beispiel Zeile 24 oben: Die dort vorkommende Instruktion ist eine „Immediate‐to‐Register‐Move‐
Instruktion“. Mit „Immediate“ meint man, dass der Quell‐Operand als Konstante in der Instruktion selbst vorkommt. Die Konstante ist hier 4 und diese belegt im Speicher 4 Bytes dieser 5‐Byte‐
Instruktion. 24 0000
24 00
25


B8040000
movl $4, %eax # EAX will serve as a counter for
# number of words left
Die ersten 5 Bits kodieren diese Instruktion: 10111. Die nächsten drei Bits bestimmen das Zielregister – in diesem Fall ist das 000. Damit entsteht das erste Byte dieser Instruktion, also 10111000 bzw. 0xB8. Diesem Byte folgt die Konstante 04000000. Achtung: Auf Grund des Formats „Little‐Endian“ kommt das niederwertigste Byte zuerst. Wir können das Format dieser Immediate‐to‐Register‐Move‐Instruktion so beschreiben: 10111DDDIMM4



Dabei ist 10111 der Op‐Code, DDD steht für das Zielregister („Destination“) und IMM4 bezeichnet die Immediate‐Konstante aus 4 Bytes. Auf ähnliche Weise können wir die weiter oben beschriebene Register‐to‐Register‐Move‐Instruktion so beschreiben: 1000100111SSSDDD. Beachte Zeile 24: Der Offset beginnt dort wieder mit 0, da wir nun in einer neuen Sektion (.text) sind. Die Zeilen 26 und 27 sind ähnlich, doch bei der letzteren gibt es eine Spezialität: Die Immediate‐
Konstante ist dort $x, also die Adresse von x: 26
26
27
27
0005
00
000a
00
BB000000
movl $0, %ebx
# EBX will store the sum
B9000000
movl $x, %ecx
# ECX will point to the current
69
70 Rechnernetze und –Organisation 

Alles was der Assembler zu diesem Zeitpunkt weiß, ist, dass später, wenn der Linker seine Arbeit verrichtet haben wird, x genau 0 Bytes nach dem Beginn der Sektion .data zu liegen kommen wird. Wenn der Loader ld also die Sektion .data beispielsweise auf die Adresse 0x2000 legt, dann wird x genau dort liegen. Der Assembler sollte also in diesem Fall 0x2000 eintragen, doch dieser Wert ist zum Zeitpunkt der Assemblierung noch nicht fixiert. Deshalb fügt der Assembler hier lediglich den temporären Dummy‐Offset 0 ein und überlässt den Eintrag des tatsächlichen Offsets dem Linker, welcher zu einem späteren Zeitpunkt in Aktion tritt. In Zeile 29 sehen wir eine „Indirekt‐to‐Register‐Add‐Instruktion“: 29 000f



addl (%ecx), %ebx
%ecx, (%ebx),
dann wäre das Format folgendes: 0000000100SSSDDD Die Wahl der Op‐Codes wurde von den Hardware‐Entwurfsingenieuren festgelegt. Typischerweise haben diese folgende Überlegungen bei dieser Wahl vorgenommen: Instruktionen, welche typischerweise öfter vorkommen, sollten kürzere Codes haben, damit man Speicherplatz spart. Und zweitens sollte durch eine geschickte Wahl der Op‐Codes die Hardware vereinfacht werden. In Zeile 31 sehen wir eine „Register‐Dekrement‐Instruktion“: 31 0014

top:
Deren Op‐Code ist 0000001100 gefolgt von drei Bits für das Zielregister und danach drei Bits für das Quellregister. Dabei wird das Quellregister im indirekten Adressiermodus verwendet. Wir können diese Instruktion so darstellen: 0000001100DDDSSS Wenn es sich um eine „Register‐to‐Indirekt‐Add‐Instruktion“ handeln würde, also zum Beispiel addl

0319
48
decl %eax
# decrement counter
Wir könnten ein kleines Experiment durchführen und darin %eax durch %ebx ersetzen und die Auswirkung auf den Maschinencode studieren: Dann würden wir das Format dieser Instruktion erkennen und die 3 letzten Bits dieser Instruktion als Code für das Zielregister ermitteln: 01001DDD 7.3.4 JUMP‐INSTRUKTIONEN: FORMAT UND AUSWIRKUNG 
Sehen wir uns als Nächstes Zeile 32 an: 32 0015


75F8
jnz top
# if counter not 0, loop again
JNZ ist eine “Jump‐If‐Zero‐Flag‐Not‐Set‐Instruktion“ und hat das Format 01110101IMM1. Wir haben also den Op‐Code 01110101 und danach eine 1‐Byte lange Immediate‐Konstante. Diese Konstante ist vorzeichenbehaftet („signed“) und definiert die Zieladresse des Sprunges an – sofern das 7







Einführung in Intel‐Maschinensprache
Zero‐Flag nicht gesetzt ist. Diese vorzeichenbehaftete Konstante definiert den relativen Abstand zur Zieladresse. Was ist damit gemeint? Jeder Computer hat ein Register, dessen generischer Name meist Program Counter (PC) heißt. Der Inhalt dieses Registers bestimmt die Adresse der nächsten auszuführenden Instruktion. Man kann auch sagen: PC zeigt auf die nächste Instruktion. Nehmen wir an, dass die Sektion .text in unserem Beispiel auf der Adresse 0x300ba starten würde. Dann würde die Instruktion in Zeile 32 auf der Adresse 0x300ba + 0x15 = 0x300cf zu finden sein. Sobald eine Instruktion in die Maschine geholt wurde – man nennt das die Fetch‐Phase – wird der Inhalt des Registers PC „erhöht“, sodass der Inhalt danach auf die nächste Instruktion zeigt. Beim Beispiel der Instruktion von Zeile 32 ginge das so: PC hatte den Wert 0x300cf; die CPU holt sich die Instruktion von dieser Adresse und erkennt aus dem Op‐Code 01110101, dass es sich um ein JNZ‐
Instruktion handelt, welche 2 Bytes lang ist. Deshalb inkrementiert die CPU den Inhalt des Registers PC um 2 Bytes, damit dieser Wert auf die nachfolgende Instruktion in Zeile 33 zeigt; diese Instruktion liegt auf Adresse 0x300cf + 2 = 0x300d1. Wenn jetzt die Instruktion JNZ ausgeführt wird, wird der Wert des Zero‐Flag überprüft. Wenn das Zero‐Flag nicht gesetzt ist, dann wird die CPU den Immediate‐Wert der Instruktion zum Wert im Register PC hinzuzählen. Dabei wird der 1‐Byte‐Wert vorzeichenbehaftet (in 2er‐
Komlementdarstellung) interpretiert. Der Immediate‐Wert ist 0xf8 und entspricht dem vorzeichenbehafteten Dezimalwert ‐8. Dadurch wird der Inhalt des Registers PC also um 8 erniedrigt: 0x300d1 – 8 = 0x300c9. In anderen Worten: „Wir“ springen zur Zeile, welche wir in unserem ursprünglichen Assemblercode mit dem Label top bezeichnet haben. So wollten wir das ja haben. Der Assembler weiß „natürlich“, dass die CPU den Immediate‐Wert zum laufenden Wert im Register PC addieren wird – und berechnet diese Adressdistanz entsprechend. In anderen Worten: Der Assembler berechnet den Immediate‐Wert aus der Distanz zwischen der Instruktion, welche JNZ nachfolgt und der Zieladresse. Damit sehen wir auch, dass dem Assembler die tatsächlichen Adressen, welche zur Ausführzeit verwendet werden, egal sein können. Es ist hier also anders als im Fall der Instruktion auf Zeile 27, wo der Assembler diese „Runtime‐Adresse“ gebraucht hätte. IMM1 besitzt lediglich 8 Bits. Damit lassen sich Werte von ‐128 bis +127 darstellen. Sollte die relative Sprungdistanz über dieses Intervall hinausgehen, dann muss der Assembler eine alternative JNZ‐
Instruktion verwenden: Deren Format ist 0000111110000101IMM4. Man sieht, dass die Hardware, welche JNZ realisiert, eigentlich ganz einfach ist. Man checkt den Wert des Zero‐Flags, und falls dieser Wert 1 ist, wird das zweite Byte der Instruktion zum Register EIP (so heißt der Program Counter) als 2er‐Komplement hinzugezählt. Das ist alles. 7.3.5 ANDERES 

Der Assembler geht die Quelldatei .s Zeile für Zeile durch und übersetzt dabei jede Zeile in den entsprechenden Maschinencode. Zum Zeitpunkt, wo der Assembler also auf die Zeile mit der JNZ‐
Instruktion kommt, kennt er die Position des Labels top schon. Der Assembler kennt also in diesem Fall die relative Distanz innerhalb der Sektion .text und kann deshalb die Immediate‐Konstante für die Instruktion JNZ ermitteln. Doch wie wäre es, wenn das Label top erst nach der Zeile mit der Instruktion JNZ stünde? In diesem Fall würde der Assembler die Adressoffset‐Information noch nicht haben und deshalb auch den Maschinencode für JNZ nicht fertigstellen können. Stattdessen macht sich der Assembler „eine Notiz“ und er würde nach Beendigung des ersten Durchlaufs durch die Quelldatei nochmals zu dieser Zeile 71
72 Rechnernetze und –Organisation 

zurückkommen und diese „fertig machen“. Assembler gehen also typsicherweise 2 Mal durch den Quellcode. Man nennt sie deshalb auch „Two‐Pass Assembler“. Wenn, wie oben gesehen, die Maschinen die Subtraktion 0x300d1 – 8 durchführen soll, muss in der Hardware zuerst eine sogenannte Sign‐Extension gemacht werden. Mit dieser Vorzeichenerweiterung wird die 8‐Bit‐Version der Zahl ‐8, also 0xF8, auf 32 Bit erweitert, also auf den Wert 0xFFFFFFF8. Technisch gesprochen führt die CPU also die Addition 0x000300D1 + 0xFFFFFFF8 aus und kommt damit auf das Ergebnis 0x300C9. Hier noch die Formate von ein paar anderen Jump‐Instruktionen: JZ
JS
JNS
JMP

7.4 01110100IMM1
01111000IMM1
01111001IMM1
11101011IMM1 (unbedingter Sprung) Der Intel‐Instruktionssatz besteht aus Hunderten Instruktionen und deren Format kann sehr komplex sein. Der Einfachheit halber hören wir hier auf. Beachte jedoch, dass bereits bei den wenigen Instruktionsformaten, welche wir hier besprochen haben, eine große Variation in der Instruktionslänge besteht. Einige Instruktionen sind lediglich 1 Byte lang, einige haben 2 Bytes, und so weiter bis zu Längen von 6 Bytes. Diese Variation steht im Gegensatz zu RISC CPUs wie z.B. MIPS, bei welcher alle Instruktionen 4 Bytes lang sind. RISC‐Architekturen versuchen das Konzept „Einheitlichkeit“ zu forcieren; damit versucht man, eine einfache Hardware zu erzeugen. ASSEMBLIERUNG IST NUR EIN MECHANISCHER PROZESS 
Wie bereits vorher besprochen, agiert der Assembler wie ein „Gehilfe“, welcher mechanisch eine Übersetzung von Englisch‐ähnlichen Codes in Nullen und Einsen vornimmt. Um dies ganz klar auszudrücken, wollen wir im folgenden Beispiel unsere „eigenen“ Maschinenbefehle in Hex‐Format inmitten herkömmlichen Assembler‐Codes einfügen. Dazu wollen wir die Instruktion decl %eax gleich direkt in seiner Übersetzung in Maschinensprache (0x48) einfügen: ... # same code as before, but not shown here
movl $x, %ecx
top:
addl (%ecx), %ebx
addl $4, %ecx
.byte 0x48 # we write "directly" in machine language
jnz top
done: movl %ebx, sum

Mit der Direktive .byte sagen wird dem Assembler, dass er nach der Übersetzung des Befehl addl $4, %ecx in Maschinensprache in den Speicher das Byte 0x48 einfügen soll. Eigentlich hätte das der Assembler bei Vorliegen der Instruktion decl %eax sowieso gemacht. Wir erhalten also das identische Resultat. Probier das einmal selbst aus! 7.5 DU KÖNNTEST AUCH SELBST EINEN ASSEMBLER SCHREIBEN! 
Eine übliche Aufgabe in einer Lehrveranstaltung über Assemblersprache ist, selbst einen einfachen Assembler zu programmieren. Selbst wenn du das in dieser Lehrveranstaltung nicht machen musst, 7
Einführung in Intel‐Maschinensprache
wäre es eine gute Übung, darüber nachzudenken, wie du das anstellen würdest. Nimm dir zum Beispiel die paar Instruktionen, welche wir hier besprochen haben, vor. Deine Aufgabe könnte es sein, ein Programm, z.B. in der Sprache C, zu schreiben, welches Assembler‐Code in Maschinen‐Code übersetzt. 7.6 SEHEN WIR UNS DAS ENDPRODUKT AN 
Der Linker wählt Adressen für die einzelnen Sektionen aus. Welche er so wählt, können wir zum Beispiel mit dem Programm readelf herausfinden. Für unser exekutierbares Programm in der Datei tot (bzw add4) ginge das so: %readelf –s tot

Der wesentliche Ausschnitt aus dem Output dieses Programmaufrufs ist folgender: 9:
10:
11:
12:
13:


08049094
080490a4
08048083
0804808b
08048074
0
0
0
0
0
NOTYPE
NOTYPE
NOTYPE
NOTYPE
NOTYPE
LOCAL
LOCAL
LOCAL
LOCAL
GLOBAL
DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT
2
2
1
1
1
x
sum
top
done
_start
Das Programm ld hat also die Sektion .data auf den Speicherbereich beginnend mit der Startadresse 0x08049094 gelegt. Wir sollten genau bleiben: Sobald unser Programm tot zur Exekution in den Speicher kommt, also zur Run‐Time, werden diese Adressen verwendet. Auf ähnliche Weise ermitteln wir, dass die Sektion .text auf der Adresse 0x08048074 beginnt. Wir können GDB verwenden, um diese Adressen zur Laufzeit „zu verifizieren“: % gdb tot
(gdb) p/x &x
$1 = 0x8049094
(gdb) p/x &_start
$2 = 0x8048074

Wir können auch nachsehen, dass der Linker den temporären Maschinencode, welchen der Assembler aus unserem Assembler‐Quellcode movl $x, %ecx
gemacht hat, geeignet verändert hat. Der Assembler hatte ja das Problem, dass er die Adresse der Variablen x noch nicht kannte, da die Startadresse der Sektion .data ja erst später bei der Bearbeitung durch den Linker festgelegt wurde. Deshalb hat der Assembler in der Objekt‐Datei .o ja lediglich eine Notiz hinterlassen, in der „er den Linker bittet“, die richtige Adresse einzufügen. Sehen wir nach, ob dies auch geschehen ist: (gdb) b 24
Breakpoint 1 at 0x804807e: file tot.s, line 24.
(gdb) r
Starting program: /root/50/tot
Breakpoint 1, _start () at tot.s:24
24 movl $x, %ecx # ECX will point to the current
Current language: auto; currently asm
(gdb) disassemble
Dump of assembler code for function _start:
73
74 Rechnernetze und –Organisation 0x08048074 <_start+0>: mov $0x4,%eax
0x08048079 <_start+5>: mov $0x0,%ebx
0x0804807e <_start+10>: mov $0x8049094,%ecx
End of assembler dump.
(gdb) x/5b 0x0804807e
0x804807e <_start+10>: 0xb9 0x94 0x90 0x04 0x08

Wir sehen, dass der Maschinencode nun tatsächlich die echte Adresse von x beinhaltet. Teil C: Rechnernetze
TEIL C: RECHNERNETZE M Hutter, März 2010 PROGRAMMSKIZZE FÜR DEN DRITTEN BLOCK „RECHNERNETZE UND ‐ORGANISATION“: 
LAN 
WAN 
PAN 
Telekom 8.
9.
10.
11.
Lokale Netzwerke (LANs) Weitverkehrnetze (WANs) und das Internet Persönliche Netzwerke (PANs) Telekommunikationsnetzwerke INHALT MATERIALIEN: 



Netzwerkanalyse mit dem Programm Wireshark: http://www.wireshark.org/download.html Matloff: Overview of Computer Networks IBM Redbooks, TCP/IP Tutorial and Technical Overview, 2006, http://www.redbooks.ibm.com/abstracts/gg243376.html Larry L. Peterson and Bruce S. Davie, Computer Networks ‐ A Systems Approach, 3rd Edition, 2003. AUFWAND VORLESUNG + ÜBUNG: 30 STUNDEN 


4* 1,5 Stunden im Hörsaal 4 Stunden Lesen Übungsaufwand: 20 Stunden 75
76 In den folgenden 4 Kapiteln beschäftigen wir uns mit Rechnernetzen. Netzwerke sind im Allgemeinen Systeme, die mehrere autonome Objekte miteinander verbinden. Im Bereich der Computerwissenschaften bezeichnet man den Verbund mehrerer Rechner oder Rechnergruppen als Rechnernetzwerk. Das Verbinden der Rechner hat im Wesentlichen zwei Vorteile: 1) Die Rechner können gemeinsame Ressourcen teilen (wie z.B. ein gemeinsamer Drucker oder ein gemeinsamer Internetzugang). 2) Die Rechner können untereinander kommunizieren und Informationen austauschen (z.B. verteilte Datenbanken, E‐Mails, …). Im Folgenden werden Rechnernetze hinsichtlich derer geographischen Ausdehnung eingeteilt. Lokale Netzwerke (auf Englisch „Local Area Networks“ – kurz LANs genannt) verbinden Rechner bis zu einigen hundert Metern. Wir werden auf LANs in Kapitel 8 näher eingehen. Weitverkehrnetze (sogenannte „Wide Area Networks“ – kurz WANs) erstrecken sich typischerweise auf mehr als einen Kilometer. Das Internet gehört zum Beispiel zu der Gruppe der WANs und erstreckt sich über die gesamte Welt. In Kapitel 9 gehen wir auf WANs genauer ein und beschreiben, wie das Internet aufgebaut ist und wie es funktioniert. Neben LANs und WANs gibt es auch noch persönliche Netzwerke (auf Englisch: „personal area networks“ – kurz PANs), welche Rechner nur auf einige Meter miteinander verbinden. Typische persönliche Netzwerke werden mit USB, Firewire, sowie auch mit Bluetooth und WLAN gebildet. 8
LOKALE NETZWERKE (LAN) 8.1
NETZWERKTOPOLOGIEN Rechner können in unterschiedlichen Strukturen miteinander verbunden werden. Dabei unterscheiden sich die Strukturen nicht nur in der Art, wie die Rechner verbunden sind, sondern sie unterscheiden sich auch in der Performance des Netzwerkes und geben Aufschluss über die Ausfallsicherheit des Rechnernetzes. STERNTOPOLOGIE Bei einem sternförmigen Rechnernetz werden mehrere Rechner mit einem zentralen Verteiler verbunden. Die Endgeräte sind dabei untereinander nicht verbunden und können über den Verteiler kommunizieren. Vorteile:  Einfache Struktur  Leichte Erweiterbarkeit  Wenn ein Endgerät ausfällt, hat es keine Auswirkung auf andere Endgeräte Nachteile:  Wenn der Verteiler (z.B. ein Hub oder Switch) ausfällt, „steht“ das gesamte Netzwerk. Lokale Netzwerke (LAN)
BUSTOPOLOGIE Bei einer Bustopologie werden alle Endgeräte über ein gemeinsames Netzwerkkabel verbunden. Es gibt keinen zentralen Knoten und alle Endgeräte erhalten den gesamten Datenverkehr. Ein typisches Bussystem stellt ein Ethernet‐Netzwerk mit Koaxialkabeln dar. Dabei werden die Koaxialkabel mit sogenannten „T‐Stücken“ mit dem Hauptkabel verbunden. Das Hauptkabel muss mit einem „Terminator“ (ein Widerstand mit 50 Ohm) abgeschlossen werden. Vorteile:  Einfache Struktur, leichte Erweiterbarkeit.  Wenn ein Endgerät ausfällt, hat es keine Auswirkung auf andere Endgeräte. Nachteile:  Wenn das Hauptkabel an einer Stelle defekt ist, „steht“ das restliche Netzwerk.  Es kann immer nur ein Endgerät am Bus Daten senden (langsam).  Der gesamte Datenstrom kann an allen Endgeräten ausgelesen werden (Sicherheitsproblem). RINGTOPOLOGIE Werden Rechner in einer Ringtopologie miteinander verbunden, dann ist ein Rechner mit zwei weiteren Nachbarrechnern verbunden, sodass ein Ring entsteht. Die Nachricht für ein bestimmtes Endgerät wird von allen Teilnehmern weitergeleitet bis diese das Ziel erreicht hat. Ein Vorteil dieser Vernetzungsart ist, dass größere Distanzen überbrückt werden können, da jedes Endgerät als elektrischer „Repeater“ dienen kann, der das Signal vor dem Weiterleiten wieder verstärken kann. Vorteile:  Größere Entfernungen (> 1 km) sind möglich, da Endgeräte als „Repeater“ dienen können.  Es kommen keine Kollisionen vor, da immer nur zwei Teilnehmer in eine Richtung (z.B. Uhrzeigersinn) im Netzwerk miteinander kommunizieren  garantierte Bandbreite. Nachteile:  Ist ein Endgerät defekt, ist das gesamte Netzwerk lahm. Moderne Endgeräte haben daher einen Schutzmechanismus eingebaut wie in einem Lichtwellenleiter‐Ring, z.B. Fibre Distributed Data Interface (FDDI) oder Token‐Ring (siehe Standard IEEE 802.5).  Endgeräte können den Datenstrom von anderen Teilnehmern mithören (Sicherheitsproblem)  Teuere Komponenten und lange Verkabelungen. ANDERE TOPOLOGIEN Es gibt noch viele andere Netzwerktopologien, die in diesem Skriptum aus Platzgründen nicht weiter beschrieben werden, wie zum Beispiel: 


die Linientopologie (daisy chain) die Baumtopologie die Mesh‐Topologie (vermaschtes Netzwerk) 77
78 

die Zelltopologie … 8.2
NETZWERKKABEL UND –STECKER Es gibt verschiedene Kabel und Stecker um Rechner miteinander zu verbinden. Eine sehr weit verbreitete Kabelart für lokale Netzwerke ist das CAT‐5‐Kabel, welches auch Twisted‐Pair‐Kabel genannt wird. Es gibt aber auch Netzwerke, die mit Koaxialkabeln oder mit Lichtwellenleitern (Glasfaser) aufgebaut sind. Im Folgenden werden diese drei gängigen Kabeltypen und deren Stecker genauer beschrieben. DAS CAT‐5‐KABEL Das sogenannte Kategorie‐5‐Kabel (oder kurz CAT‐5‐Kabel) wird vor allem in lokalen Netzwerken (LANs) verwendet. Es besteht im Wesentlichen aus nicht‐abgeschirmten (unshielded) verdrillten Adernpaaren (twisted pair). Die Verdrillung der Adernpaare vermindert das Auftreten von elektromagnetischen Interferenzen (Übersprechen) mit anderen Adern oder Kabeln. Am Anfang und am Ende dieses Unshielded‐Twisted‐Pair‐
Kabels (oft daher auch als UTP‐Kabel bezeichnet) ist ein RJ‐45‐Stecker montiert. Die Zuordnung der Adernpaare im RJ‐45‐Stecker ist in den Standards EIA/TIA‐568A und EIA/TIA‐568B spezifiziert. Der EIA/TIA‐568A‐Standard definiert 8 Adern, die unterschiedliche Farben aufweisen. Der einzige Unterschied zwischen den beiden Standards liegt in der Tatsache, dass zwei Adernpaare vertauscht sind, nämlich die Adernpaare für Senden (grün) und Empfangen (orange). Verwendet man in einem Netzwerk nun ein CAT‐5‐Kabel mit zwei EIA/TIA‐
568A konformen RJ‐45 Steckern, so spricht man von einem Geradkabel oder auch einem Patch‐Kabel. Diese sind die häufigsten LAN‐Netzwerkkabeln heutzutage. Verwendet man hingegen ein CAT‐5‐Kabel mit einem RJ‐
45‐Stecker nach EIA/TIA‐568A auf der einen Seite und einem RJ‐45‐Stecker nach EIA/TIA‐568B auf der anderen Seite, so spricht man von einem Kreuzkabel (Crossover‐Kabel). Vorteile:  Geringe Kosten  Voll‐Duplex‐Übertragung ist möglich, da 2 Adern für Senden und Empfangen Nachteile:  Empfindlich gegenüber elektrischen Störfeldern (Übersprechen, Interferenzen)  Geringe Reichweite (< 100m) DAS KOAXIALKABEL Das Koaxialkabel ist eine ältere Netzwerkkabelart, die im Gegensatz zu einem CAT‐5‐Kabel nur aus einem dicken Kupferdraht besteht. Der Kupferdraht ist von einem Isolationsmantel umschlossen und besitzt eine äußere Abschirmung. Häufig werden Koaxialkabel in einer Bus‐Netzwerktopologie eingesetzt, wo einzelne Rechner mithilfe von Verbindungsstücken (sogenannten „T‐Stücken“) mit einem Hauptkoaxialkabel verbunden werden. An den Enden des Koaxialkabels befinden sich Bayonet‐Neill‐Concelman‐Stecker (BNC, benannt nach den Entwicklern Paul Neill und Carl Concelman). Für den Abschluss des busförmigen Koaxialnetzwerkes wird ein 50‐Ohm‐Abschlusswiderstand („Terminator“) benötigt (für Videoanwendungen wird ein 75‐Ohm‐Abschluss verwendet). Vorteile:  Geringe Kosten  Einfacher Anschluss von Endgeräten  Durch Schirmung sind größere Reichweiten möglich (< 500m) Lokale Netzwerke (LAN)
Nachteile:  Nur Halb‐Duplex‐Übertragung möglich, da nur 1 Leitung zur Verfügung steht  Langsame Datenübertragung (einige MBit/s) DAS GLASFASERKABEL Glasfasernetzwerke übertragen Information mithilfe von Licht (Photonen) anstatt mit Elektronen wie bei CAT‐
5‐Kabeln und Koaxialkabeln. Dabei unterscheidet man zwischen Monomode‐ und Multimode‐Glasfaserkabel. Ein Monomode‐Kabel („single‐mode“) besitzt einen sehr kleinen Wellenleiter‐Kern (die sogenannte Faser), welcher typischerweise einen Durchmesser zwischen 3 und 10 µm besitzt. Durch die Lichtwellenfaser wird nun Laserlicht eingekoppelt, das aufgrund des Durchmessers nur einen einzigen Weg durch das Kabel nehmen kann. Vorteile:  Hoher Datendurchsatz (einige Tera‐Bits pro Sekunde)  Hohe Distanzen sind möglich (> 10 km)  Geringer Signalverlust, keine Beeinflussung von elektrischen Feldern Nachteile:  Hohe Herstellungskosten  Empfindlich gegenüber mechanische Belastung Das Multimode‐Kabel hingegen besitzt einen weit größeren Faserkerndurchmesser von 50‐400 µm (Stufenfasern haben einen typischen Kerndurchmesser von 100‐400 µm, Gradientenfasern besitzen einen Kerndurchmesser von 50 µm, 62,5 µm, 85 µm, oder 100 µm). Das Licht kann dabei mehrere Wege durch das Glasfaserkabel nehmen und weist dadurch auch unterschiedliche Wellenlaufzeiten auf. Das Multimode‐Kabel ist daher nur für kurze Distanzen geeignet. Vorteile:  Billiger; anstatt von Laserlicht werden Leuchtdioden (LEDs) eingesetzt Nachteile:  Geringerer Datendurchsatz (einige Giga‐Bits pro Sekunde)  Geringere Distanzen möglich (einige 1000 m) 8.3
BANDBREITE UND LATENZZEIT Die Netzwerk‐Performance wird typischerweise in zwei Einheiten angegeben: Bandbreite und Latenzzeit. Im Allgemeinen bezeichnet man als Bandbreite einen definierten Frequenzbereich in dem Signale in einem Nachrichtenkanal übertragen werden können. Das Telefonnetz hat zum Beispiel eine typische Bandbreite B von 3100 Hz. Die niedrigste Frequenz, die übertragen werden kann, ist f1 = 300 Hz, die höchste Frequenz f2 = 3400 Hz. Zwischen 300 und 3400 Hz können also Sprachsignale ohne Abschwächung übertragen werden. Eine korrekte Definition der Bandbreite wäre also B = f2 ‐ f1, in welchem Signale mit einer Frequenz zwischen f1 und f2 übertragen werden können. f1 und f2 nennt man untere und obere Grenzfrequenz. In der Netzwerktechnik ist die Bandbreite nicht zu verwechseln mit der Datenübertragungsrate eines Netzwerkes. Die Datenübertragungsrate bezeichnet die Anzahl der Bits, die pro Sekunde übertragen werden können. Man hört z.B. oft: „Die Bandbreite meiner DSL‐Verbindung hat 10 Mbit/s“. Die Bandbreite wird in Hertz angegeben, die Datenübertragungsrate jedoch in Bit pro Sekunde. In der Literatur wird die Bandbreite oft fälschlicherweise der Datenübertragungsrate gleichgesetzt. Auf den genauen Zusammenhang zwischen Bandbreite und Datenübertragungsrate wird in diesem Skriptum nicht näher eingegangen, jedoch wäre hier für den interessierten Leser auf das Gesetz von Nyquist und Shannon‐Hartley hinzuweisen. 79
80 Ein weiteres Maß in der Netzwerktechnik ist die Latenzzeit. Sie gibt die Zeit an, die eine Nachricht benötigt, um vom Sender zum Empfänger zu gelangen. Die Latenzzeit wird typischerweise in Millisekunden angegeben. Die Gründe für die Nachrichtenverzögerung liegen in drei unterschiedlichen Komponenten: 1) Propagation delay: Nachrichtensignale können nicht schneller als mit Lichtgeschwindigkeit übertragen werden. Typische Verzögerungswerte für Signale sind die folgenden: 3*108 m/s (Vakuum), 2,3*108 m/s (Kabel), 2*108 m/s (Glasfaserkabel). 2) Transmission delay: Die Verzögerung hängt von der Bandbreite der Netzwerkverbindung sowie der Größe der Nachricht, die übertragen werden soll, ab. Nachrichten können früher oder später beim Empfänger eintreffen. 3) Queuing delay: Netzwerkkomponenten wie Hubs, Switches, und Router benötigen eine gewisse Zeit, das Paket zu verarbeiten und weiterzuleiten. Die Zeit, welche eine Nachricht benötigt, um vom Sender zum Empfänger und wieder zurück zum Sender zu gelangen, wird mit Paketumlaufzeit (auf Englisch: round‐trip time, kurz: RTT) bezeichnet. Die RTT ist speziell in der Netzwerkanalyse hilfreich, womit man die Latenzzeiten von Netzwerken überprüfen kann. 8.4
SIGNALKODIERUNG Wie bei einem Rechner repräsentiert ein Netzwerksignal Daten in Form von „0“ und „1“. Dabei gibt es unterschiedliche Methoden, wie man die Daten auf der Leitung kodieren kann. Bei der sogenannten „Non‐
Return‐To‐Zero“‐Methode zum Beispiel entspricht die binäre Zahl „0“ einer geringen Spannung (z.B. 0 Volt), die Zahl „1“ einer höheren Spannung (z.B. 5 Volt). Der genaue Spannungspegel ist dabei nicht festgelegt und ist je nach Art der Anwendung unterschiedlich. Bei der seriellen Schnittstelle nach der Norm EIA‐232 entspricht z.B. die logische „0“ einer negativen Spannung (‐12 Volt) und die logische „1“ einer positiven Spannung (+12 Volt). Im Folgenden besprechen wir drei wesentliche Kodierungsmethoden, die in vielen Rechnernetzen Verwendung finden. Bitte beachten Sie, dass es bei modernen Hochgeschwindigkeitsnetzwerken jedoch Weiterentwicklungen dieser Kodierungsmethoden gibt, welche hier aber nicht näher beschrieben werden. NON‐RETURN‐TO‐ZERO (NRZ) Bei der Non‐Return‐To‐Zero‐Kodierungsmethode (NRZ) werden wie bei der seriellen Schnittstelle nach EIA‐232 zwei unterschiedliche Spannungspegel herangezogen um Nachrichten binär zu kodieren. Eine logische „0“ entspricht einer geringen Spannung, eine logische „1“ einer höheren Spannung. Vorteile:  Sehr einfach Nachteile:  Die Datenübertragung erfolgt asynchron (Synchronisationsproblem), d.h. der Empfänger benötigt zur Datendekodierung ein separates Taktsignal, das entweder mit der Leitung mitgeliefert wird oder es werden einzelne Bits in das Nutzsignal eingefügt, damit lange Folgen von gleichen Bits unterbunden werden. Letzteres wird als Bitstuffing bezeichnet und kommt z.B. bei USB zum Einsatz. Lokale Netzwerke (LAN)
NON‐RETURN‐TO‐ZERO‐INVERTED (NRZI) Bei dieser Methode erfolgt bei einer logischen „1“ ein Spannungsübergang. Bei „0“ nicht. Dadurch erreicht man, dass bei einer langen Folge von einer logischen „1“ der Takt rückgewonnen werden kann, da die Spannung jedes Mal wechselt. Das Problem der Synchronisation bei einer Folge von einer „0“ ist jedoch nicht gelöst. Vorteile:  Bei einer lagen Folge von logischen „1“ kann der Takt rückgewonnen werden. Nachteile:  Synchronisationsproblem wie bei NRZ bei logischen „0“ Folgen. MANCHESTER‐KODIERUNG Die Manchester‐Kodierung kodiert eine logische „0“ mit einem Low‐to‐High‐Spannungsübergang. Eine logische „1“ wird mit einem High‐to‐Low‐Übergang kodiert. Dadurch erreicht man einen Spannungswechsel für jedes übertragene Bit. Damit ist diese Variante langsamer als wie die NRZ‐ oder NRZI‐Methode, löst jedoch das Problem der Sender‐Empfänger‐Synchronisation, da der Takt einfach aus den Spannungsübergängen rückgewonnen werden kann. Oft wird für eine korrekte Synchronisation für den Empfänger eine bestimmte Bitfolge als Präambel gesendet, bevor die eigentlichen Nutzdaten übertragen werden. Der Empfänger synchronisiert sein Taktsignal (Clock) mithilfe der Präambel‐Bitfolge und empfängt danach die Daten. Vorteile:  Taktrückgewinnung möglich (keine Synchronisationsprobleme)  Erkennung von Fehlern, wenn längere Zeit kein Spannungswechsel erfolgt ist Nachteile:  Für jedes Datenbit werden zwei Spannungspegel verwendet, daher benötigt man die doppelte Datenübertragungsrate. 81
82 8.5
DIE NETZWERKKARTE Das Kodieren der Netzwerkdaten übernimmt typischerweise die Netzwerkkarte, die im Rechner eingebaut ist. Sie ist zuständig für das Senden und Empfangen von Netzwerkdaten und leitet die Daten an das entsprechende Betriebssystem bzw. indirekt an die entsprechende Anwendung weiter. Eine Netzwerkkarte (auf Englisch: Network Interface Card, oder kurz NIC genannt) besteht aus mehreren Komponenten. Neben den Anschlüssen von Übertragungsmedien (z.B. Ethernetkabel mit RJ‐45‐Stecker) und LED‐Anzeigen für den aktiven Netzwerkverkehr besitzt die Netzwerkkarte mehrere Hardwarekomponenten zum Übertragen und Verarbeiten noch Netzwerksignalen. Diese Komponenten werden im sogenannten Physical Layer (kurz: PHY) der Netzwerkkarte implementiert. Der PHY bildet das Interface zum Übertragungsmedium und ist zuständig für die Aufbereitung und Verstärkung der elektrischen Signale, für die Modulation, die Kodierung und Dekodierung, die Synchronisation, das Multiplexing, das Switching, etc… Der Physical Layer ist mit dem Media Access Controller (MAC) verbunden. Dieser ist für die Adressierung und das Mehrfachzugriffsverfahren (wird in Kapitel 8.6 genauer beschrieben) zuständig. Um Geräte im Netzwerk adressieren zu können, besitzt jede Netzwerkkarte eine eindeutige Media‐Access‐Control‐Adresse (oft auch als MAC‐Adresse bekannt). Diese 48‐Bit große Adresse ist fix im Speicher der Netzwerkkarte gespeichert. Die „Registration Authority” des „Institute of Electrical and Electronics Engineers“ (IEEE) regelt die Vergabe der MAC‐Adressen weltweit. Mit den 48 Bit stehen 248 oder 281 474 976 710 656 mögliche MAC‐Adressen zur Verfügung. Neben Physical Layer und Media Access Controller besitzt die Netzwerkkarte einen Sende‐Buffer (First‐In‐First‐
Out Buffer, TX FIFO) und einen Empfangs‐Buffer (RX FIFO). Diese FIFO‐Speicher verbinden den Media Access Controller über ein Interface (wie zum Beispiel PCI) mit dem Rechner. Der Rechner speichert die Netzwerknachrichten in den Sende‐Buffer, um sie über das Netzwerk zu übertragen. Werden Nachrichten empfangen, so liest der Rechner die Daten aus dem Empfangs‐Buffer aus. Den gesamten Ablauf und die Kontrolle der einzelnen Komponenten übernimmt typischerweise eine Kontrolleinheit, zum Beispiel ein Microcontroller oder ein Field Programmable Gate Array (FPGA). Weiters stellt der Hersteller der Netzwerkkarte Treibersoftware zur Verfügung, damit das Betriebsystem mit der Netzwerkkarten‐Hardware kommunizieren kann. 8.6
WAS PASSIERT EIGENTLICH BEI KOLLISIONEN IM ETHERNET? Das ursprüngliche Ethernet (10 Mb/s) benützte als Übertragungsmedium Koaxialkabel. Die Rechner im Ethernet‐Netzwerk wurden dabei in einer busförmigen Struktur miteinander verbunden. Das Koaxialkabel bot jedoch nur eine gemeinsame Leitung für das Senden und das Empfangen von Netzwerkdaten (halb‐duplex). Aus diesem Grund konnte es passieren, dass es zu Kollisionen von Netzwerkdaten kam, wenn zwei oder mehrere Endgeräte gleichzeitig Daten über das Netzwerk senden wollten. Netzwerkkarten implementierten daher einen Schutzmechanismus, um einen gemeinsamen Zugriff auf die Netzwerk‐Ressourcen zu ermöglichen. Der Media Access Controller war für den Mehrfachzugriff zuständig und regelte den Netzwerkverkehr, damit die gesendeten Daten erfolgreich empfangen werden konnten. Ein weitverbreitetes Mehrfachzugriffsprotokoll ist Lokale Netzwerke (LAN)
das „Carrier‐Sense‐Multiple‐Access‐with‐Collision‐Detection“‐Protokoll (CDMA/CD). Wie der Name des Protokolls sagt, hat es drei unterschiedliche Aufgaben: 1) „Carrier Sense“: Das Übertragungsmedium (Nachrichtenträger) wird überprüft, ob gerade Daten gesendet werden oder nicht. 2) „Multiple Access“: Mehrere Endgeräte können auf die Netzwerkkarte zugreifen. 3) „Collision Detection“: Falls mehrere Endgeräte gleichzeitig senden, können Kollisionen erkannt werden. Der genaue Ablauf des CDMA/CD Protokolls ist wie folgt: 1) Das Endgerät, das Daten senden möchte, prüft zuerst das Netzwerkkabel auf eine laufende Übertragung („Carrier Sense“). Erst wenn die Leitung frei ist und kein anderes Endgerät sendet, geht es zu Schritt 2. 2) Das Endgerät sendet die Daten. Gleichzeitig liest es auch die Netzwerkdaten aus und überprüft ob die gesendeten Daten nicht durch das Senden eines anderen Endgerätes gestört wurden. Falls die Übertragung gestört wurde, wird das Senden abgebrochen und es wird ein Störsignal an alle Teilnehmer des Netzwerkes gesendet. 3) Es wird eine zufällig gewählte Zeit abgewartet, dann wird das Senden wiederholt (beginne wieder bei Schritt 1). Ist die Leitung belegt, wird der Übertragungsversuch um 1 erhöht. Ist die Anzahl der maximalen Übertragungsversuche überschritten, wird die Übertragung abgebrochen (gehe zu Schritt 4). 4) Die Übertragung wird abgebrochen. Es wird eine Fehlermeldung an die entsprechende Anwendung geschickt. 5) Ende des Protokolls. Neben CDMA/CD gibt es noch weitere Mehrfachzugriffsverfahren, wie zum Beispiel der Token Bus (spezifiziert im Standard IEEE 802.4), Token Ring (IEEE 802.5) oder Token Passing bei FDDI. Für kabellose Netzwerke werden auch das CSDM/CA (Standard IEEE 802.11) oder zum Beispiel das Slotted‐ALOHA‐Protokoll verwendet. Heutzutage sind voll‐duplex‐fähige Netzwerke üblich, wie zum Beispiel bei Fast‐Ethernet (spezifiziert in IEEE 802.3x). Statt nur einem Übertragungsleitungspaar gibt es zwei Leitungspaare für das Senden und das Empfangen von Daten. Daher wird auch auf das CSMA/CD‐Verfahren verzichtet. Stattdessen ist eine sogenannte Flusskontrolle („flow control“) notwendig, um den Datenverkehr zu regeln. Werden Netzwerkdaten eines Endgerätes zu schnell gesendet, kann es zu einer „Überflutung“ am Empfänger kommen, welcher die Daten dann verwerfen muss. Der Empfänger kann jedoch ein PAUSE‐Signal (im Wesentlichen ein Ethernet‐Block mit dem Typenfeld 0x0001, siehe Ethernet‐Block‐Beschreibung in 8.7) an den Sender schicken, um die Übertragung für eine gewisse Zeit zu unterbrechen. 8.7
DER ETHERNET‐BLOCK Netzwerkdaten werden in Form von Blöcken (auf Englisch: frames) gesendet. Ein Block ist eine logische Struktur bestehend aus einzelnen Bits, welche den Nachrichtenverkehr strukturiert. Innerhalb eines Blocks befinden sich sogenannte Pakete (auf Englisch: packets), welche die eigentlichen Nachrichtendaten beinhalten. Der Ethernet‐Block (ethernet frame) ist nach IEEE 802.3 folgendermaßen strukturiert: 

Präambel: Die Präambel besteht aus 7 Bytes mit der fixen Bitfolge „10101010…“. Die alternierenden Bits dienen zur Phasen‐Synchronisation der Netzwerkkarten. Start‐Frame‐Delimiter (kurz SFD): Das SFD‐Byte kennzeichnet den Ethernet‐Blockanfang. Das Byte ist fixiert auf „10101011“. Die letzten 2 Bits sind also auf „1“ gesetzt. 83
84 




Ziel‐MAC‐Adresse: Gibt die MAC‐Adresse des Ziel‐Endgerätes an. Dies kann auch eine Multicast‐ oder auch Broadcast‐Adresse sein. Quell‐MAC‐Adresse: Gibt die MAC‐Adresse des Quellrechners an, also des Rechners, von dem die Nachricht versendet wird. Typ: Das Typ‐Feld (oder oft auch EtherType genannt) gibt den Typ des Protokolls an, das mit dem Ethernet‐Block im Datenfeld übertragen wird. Es gibt unterschiedliche Protokolltypen. Das Internet‐
Protokoll (IPv4) hat z.B. den Wert 0x0800. Das Address‐Resolution‐Protokoll (ARP) hat z.B. den Wert 0x0806. Daten: Im Datenfeld werden die eigentlichen Nutzdaten gespeichert. Maximal können pro Ethernet‐
Block 1500 Bytes gekapselt werden. CRC: CRC steht für Cyclic Redundancy Check (zyklische Redundanzprüfung) und wird verwendet, um Übertragungsfehler des Ethernet‐Blockes zu erkennen. Der Sender berechnet einen 32‐Bit‐CRC über den Block (beginnend von der Ziel‐MAC‐Adresse bis zum Ende der Nutzdaten) und fügt ihn am Ende des Blockes an. Der Empfänger berechnet dieselbe Prüfsumme und vergleicht die CRC‐Werte. Damit können Übertragungsfehler mit einer gewissen Wahrscheinlichkeit erkannt (jedoch nicht korrigiert) werden. 8.8
DER HUB Das erste Netzwerkgerät, das wir näher betrachten, ist ein sogenannter Hub. Ein Hub wird verwendet, um Rechner in einem lokalen Netzwerk zu verbinden. Der Hub besitzt prinzipiell mehrere Anschlüsse, die miteinander verbunden sind. Nachrichten, die empfangen werden, werden elektrisch verarbeitet (das heißt: gefiltert und verstärkt) und an alle Anschlüsse (auf Englisch: ports) weitergeleitet. Er ist sozusagen ein „elektrischer (Multiport‐)Repeater“ und gibt Signale einfach an alle Anschlüsse weiter, ohne dabei die Signale zu dekodieren und den Inhalt bzw. die Ethernet‐Daten zu analysieren. Er speichert keine Daten und weiß auch nichts über MAC‐ oder IP‐Adressen. Physikalisch gesehen verbindet ein Hub die Rechner in einer sternförmigen Netzwerktopologie, logisch gesehen jedoch agiert er wie ein Bus, da jedes Endgerät alle Nachrichten empfängt. Vorteile:  Einfach, billig Nachteile:  Signalfehler werden einfach weitergeleitet ohne erkannt zu werden.  Hoher Nachrichtenverkehr und daher höhere Kollisionswahrscheinlichkeit (Verringerung der Datenübertragungsrate) Lokale Netzwerke (LAN)
8.9
DER SWITCH Ein Switch ist intelligenter als ein Hub, da er nicht nur die Netzwerksignale verstärkt und weiterleitet, sondern auch die Signale dekodiert und die MAC‐Adresse der empfangenen Ethernet‐Blöcke lesen kann. Dadurch dass er die MAC‐
Adresse des Zielrechners kennt, kann er den empfangenen Block einfach an den Anschluss weiterleiten, an dem der Zielrechner angeschlossen ist. Der Switch besitzt daher auch einen Speicher und einen Prozessor, der die Signale dekodieren und verarbeiten kann. Um zu wissen, an welchen Anschluss er die Ethernet‐Blöcke weiterleiten muss, hält der Switch eine Tabelle im Speicher (auf Englisch: Source Address Table, kurz: SAT), die die MAC‐Adresse und die Anschlussnummer (port number) speichert. Ist zu einem empfangenen Block kein Eintrag vorhanden, sendet er den Block an alle Anschlüsse weiter. Der Switch „lernt“ die MAC‐Adressen der angeschlossenen Geräte indem er den Nachrichtenverkehr analysiert. Vorteile:  Sendet Blöcke nur an die jeweiligen Teilnehmer (weniger Netzwerkverkehr, höhere Datenübertragungsraten)  Keine Datenkollisionen durch die parallele Verarbeitung der Daten  Physikalische Anschlüsse können oft zu einem logischen Anschluss gebündelt werden um die Bandbreite zu erhöhen. Nachteile:  Höhere Latenzzeiten, da die Netzwerkblöcke erst verarbeitet werden müssen. 8.10 WIE GELANGEN DATEN VON EINEM ENDGERÄT ZUM ANDEREN? Stellen Sie sich ein LAN mit mehreren Rechnern vor, die mit einem Switch sternförmig verbunden sind. Wie funktioniert nun der Datentransfer, wenn ein Endgerät einem anderen Endgerät Daten senden möchte? Wie wir bereits erfahren haben, werden die Daten in sogenannte Ethernet‐Blöcke gekapselt und über das Netzwerk übertragen. Der Ethernet‐Block benötigt jedoch (unter anderem) die MAC‐Adresse des Zielrechners, um die Daten zu verschicken. Woher kennt der Rechner nun die MAC‐Adressen der anderen Endgeräte? MAC‐Adressen können mithilfe des Address Resolution Protocols (ARP) ermittelt werden. Der Rechner, der Daten senden möchte, versendet zuerst eine ARP‐Anfrage ‐‐‐ ein Ethernet‐Block mit der Broadcast‐MAC‐
Adresse FF:FF:FF:FF:FF:FF:FF, das ein IP‐Paket enthält, welches die Ziel‐IP‐Adresse beinhaltet ‐‐‐ an den Switch, der die Anfrage an alle Teilnehmer des Netzwerkes weiterleitet. Derjenige Rechner, dessen IP‐Adresse mit der Ziel‐IP‐Adresse der ARP‐Anfrage übereinstimmt, antwortet daraufhin mit seiner MAC‐Adresse. 85
86 8.11 DAS ISO/OSI‐SCHICHTENMODELL Das Open‐Systems‐Interconnection‐Schichtenmodell (OSI‐Schichtenmodell) wurde von der Internationalen Organisation für Normung (ISO) spezifiziert und dient in der Netzwerktechnik als begrifflicher Rahmen für die Aufgaben von Netzwerkprotokollen und von Übertragungssystemen. Da die Kommunikation zwischen Endgeräten nicht trivial ist und es Fragen und Probleme auf unterschiedlichen Komplexitätsebenen geben kann, werden die Kommunikationsaufgaben in sieben Schichten aufgeteilt. Jede Schicht ist dabei für eine bestimmte Aufgabe zuständig. 1) Schicht 7 – Anwendungsschicht: Dies ist die oberste OSI‐Schicht und stellt die Anwendung dar, welche einen Netzwerkzugriff startet. 2) Schicht 6 – Darstellungsschicht: Diese Schicht ist dafür zuständig, dass die systemabhängigen Anwendungsdaten in eine systemunabhängige Darstellung umgewandelt werden. 3) Schicht 5 – Kommunikationsschicht: Schicht 5 ist zuständig für die Organisation und Kontrolle von (synchronisierten) Verbindungen. 4) Schicht 4 – Transportschicht: In der Transportschicht werden die Daten in Segmente aufgeteilt. Empfangene Pakete werden den Anwendungen entsprechend zugeordnet. 5) Schicht 3 – Vermittlungsschicht: Steuert das Schalten der Verbindungen und die Weitervermittlung von Paketen. Das Routen von Paketen passiert in dieser Schicht. 6) Schicht 2 – Sicherungsschicht: Die Schicht 2 ist zuständig für eine zuverlässige und fehlerfreie Übertragung. Pakete werden in (Ethernet‐)Blöcke gekapselt. 7) Schicht 1 – Bitübertragungsschicht: Die unterste Schicht ist die Bitübertragungsschicht. Sie regelt die elektrischen oder mechanischen (optischen) Anforderungen. Das ISO/OSI‐Schichtenmodell wird in der Literatur oft verwendet um Netzwerkprotokolle und Techniken auf einfache Weise zu erklären. Oft ist es jedoch nicht so praxisnah wie andere existierende Modelle wie zum Beispiel das TCP/IP‐Modell (oft auch als TCP/IP Stack oder DoD‐Modell bezeichnet). OSI‐Schicht TCP/IP‐Modell
Protokolle Netzwerkgeräte
Anwendungsorientiert Anwendung HTTP, FTP, SMTP, Telnet Gateway 7 Anwendung 6 Darstellung 5 Kommunikation 4 Transport Transport
TCP, UDP 3 Vermittlung Internet
IP, ICMP Router
2 Sicherung MAC, ARP Switch
Ethernet, Token Ring, FDDI,… Hub, Repeater 1 Orientierung
Bitübertragung Transportorientiert Netzzugang Weitverkehrnetze (WAN) und das Internet
9
WEITVERKEHRNETZE (WAN) UND DAS INTERNET In diesem Kapitel beschäftigen wir uns näher mit Weitverkehrsnetzen (auf Englisch: wide area network, WAN) und dem Internet. Im Gegensatz zu lokalen Netzwerken, die sich in der geographischen Ausbreitung auf einige hundert Meter beschränken, erstrecken sich Weitverkehrsnetze auf sehr große Distanzen (oft auch über Ländergrenzen und Kontinente hinweg). WANs verbinden einzelne Rechner oder mehrere Rechner in einem LAN zu einem großen Rechnernetzwerk, das typischerweise von einer oder mehreren Organisationen bzw. Institutionen verwaltet wird. Diese Netzwerke sind meist gegen öffentlichen Zugriff geschützt und können oft nur von organisationsinternen Personen verwendet werden. Nicht‐öffentliche Rechnernetze, welche oft bei Firmen zum Einsatz kommen und ähnliche Dienste (Services) wie das Internet (z.B. Emails, firmeninterne Webseiten,…) bieten, werden auch häufig als Intranet bezeichnet. Ein Intranet hat jedoch im Gegensatz zu WANs nichts mit der räumlichen Ausdehnung zu tun und kann sich auch nur über einige hundert Meter erstrecken. Das Internet ist ein weltweites Rechnernetzwerk, das öffentlich zugänglich ist und unterschiedliche Dienste anbietet, wie zum Beispiel E‐Mails, Webseiten (World Wide Web, kurz: WWW), Datenaustausch (File Transfer Protocol, kurz: FTP), Telefonie (Voice over IP), etc. Das Internet ist aus einem WAN entstanden, das in den USA im Jahr 1969 vom dortigen Verteidigungsministerium eingerichtet wurde. Das sogenannte Advanced Research Project Agency Network (kurz: ARPANET) vernetzte damals Rechner von Universitäten und anderen Forschungseinrichtungen. Ziel war es damals, die Rechnerkapazitäten gemeinsam nutzen und Informationen auszutauschen zu können. Es wurden in der Folge immer mehr Rechner vernetzt und aus dem ARPANET entstand der Vorläufer des heutigen Internet. Die ersten Dienste waren E‐Mail und einfacher Datentransfer. Erst in den 90er‐Jahren wurde das Hyptertext Transfer Protocol (kurz: HTTP) wie auch die Auszeichnungssprache „Hypertext Markup Language“ (kurz HTML) entworfen, welche die Basis für das heutige World Wide Web (WWW) darstellt. Für die heute verfügbaren Internetdienste gibt es zahlreiche Protokolle und Standards, welche von der Internet Engineering Task Force (kurz: IETF) in üblicherweise öffentlich zugänglichen Dokumenten, den sogenannten Request for Comments (RFCs), beschrieben sind. Das Protokoll HTTP (Version 1.1) ist z.B. in RFC 2616 beschrieben. Als Nächstes wollen wir uns die wichtigsten Komponenten, Techniken, Dienste und Protokolle von Weitverkehrsnetze und dem Internet näher anschauen. 9.1
NETZWERK‐ADRESSIERUNG Wie in Kapitel 8 bereits beschrieben wurde, verwenden Netzwerkgeräte in einem lokalen Netzwerk (also z.B. Hubs oder Switches) eine weltweit eindeutige MAC‐Adresse, um alle Geräte im Netzwerk eindeutig identifizieren zu können. Die MAC‐Adresse besteht aus 6 Bytes (48 Bits), wobei die ersten 3 Bytes von der IEEE zugewiesen werden und die letzten 3 Bytes vom Gerätehersteller fixiert werden. Speziell Switches verwenden diese MAC‐Adresse, um empfangene Ethernet‐Blöcke zu bestimmten Netzwerkgeräten zu schicken. Dabei verändern sie die Blöcke nicht (die MAC‐Adresse wird nur ausgelesen und wird nicht verändert) und sind daher für alle Netzwerkteilnehmer transparente Netzwerkkomponenten. Werden mehrere (lokale) Netzwerke miteinander verbunden, entstehen sogenannte Subnetze. Das Internet besteht aus einer Vielzahl von Subnetzen, die miteinander verbunden sind. Damit Rechner von einem Subnetz in ein anderes Subnetz Daten schicken können, wird eine eindeutige Adressierung der Geräte benötigt. MAC‐
Adressen können dazu nicht verwendet werden, da sie keine Netzwerkinformationen beinhalten. Im Internet verwendet man daher sogenannte Internet‐Protokoll‐Adressen (IP‐Adressen) sowie die dazugehörige Subnetzmaske. Eine IP‐Adresse besteht bei Version IPv4 aus 4 Bytes (also 32 Bits) und ist in eine Netzwerkadresse und einer Hostadresse unterteilt. Die Subnetzmaske gibt dabei an, aus wievielen Bits der IP‐
Adresse die Netzwerkadresse besteht. Im folgenden Beispiel wäre ein Rechner mit der IP‐Adresse 192.168.0.1 und der dazugehörigen Subnetzmaske 255.255.255.0 eindeutig adressierbar. Die Subnetzmaske zeigt an, dass 87
88 die Netzwerkadresse aus 3 Bytes (24 Bits) besteht und das vierte Byte der IP‐Adresse die Hostadresse angibt. Demnach kann man im Subnetz insgesamt 254 (=2Anzahl Bits der Hostadresse‐2) verschiedene Netzwerkgeräte adressieren. Die Adresse 192.168.0.0 bezeichnet das Netzwerk selbst, die Adresse 192.168.0.255 wird als Broadcast‐Adresse verwendet. Wählt man als Subnetz z.B. 255.255.0.0, so würde die Netzwerkadresse 2 Bytes ausmachen (also 192.168) und die Hostadresse hätte dann 16 Bit bzw. 2 Bytes (0.1). Die Bitanzahl der Subnetzmaske wird auch häufig nach mit einem Querstrich („Slash“, „/“) an die IP‐Adresse angehängt, also z.B. 192.168.0.1/24 oder 192.168.0.1/16. Je nachdem wie die Subnetzmaske definiert ist, unterscheidet man mehrere Klassen von IP‐Adressen: Klasse A, Klasse B und Klasse C. Klasse A IP‐Adresse Subnetzmaske Klasse B IP‐Adresse Subnetzmaske Klasse C IP‐Adresse Subnetzmaske Byte 1 Byte 2
Byte 3
Byte 4 1‐127 255
24‐bit Hostadressen
.0
.0
.0 128‐191 255
wird zugewiesen
.255
16‐bit Hostadressen .0
.0 192‐223 255
wird zugewiesen
.255
.255
8‐bit Hostadressen
.0 Neben zugewiesenen IP‐Adressen gibt es auch private IP‐Adressen, z.B. 10.0.0.0‐10.255.255.255 (private IP‐
Adressen in einem Klasse‐A‐Netz), 172.16.0.0‐172.31.255.255 (16 Klasse‐B‐Netze) und 192.168.0.0‐
192.168.255.255 (256 Klasse‐C‐Netze). Die Adressierung von Rechnern in einem Netzwerk ist der Adressierung von herkömmlicher Post ähnlich. Für jede Stadt (Subnetzwerk) gibt es eine Postleitzahl (Netzwerkadresse). Analog entspricht dann die Hausadresse der Hostadresse. Damit kann ein Poststück (Ethernet‐Blöcke, welche die IP‐Pakete beinhalteten) eindeutig zu einem Haushalt (Endgerät) zugestellt werden. Postämter, welche die Pakete dann an andere Postämter weiterleiten („routen“) oder an den entsprechenden Haushalt zuteilen, könnte man dann als sogenannte „Router“ bezeichnen. Diese werden im nächsten Kapitel beschrieben. 9.2
DOMAIN NAME SYSTEM (DNS) Eines der wichtigsten Dienste im Internet ist das Domain Name System (kurz DNS). Rechner im Internet werden ja mithilfe von Menschen lesbaren Namen adressiert. In einem Internet‐Browser gibt man z.B. den Namen der Web‐Site www.tugraz.at oder portal.tugraz.at ein. Da Rechner im Internet jedoch mit IP‐Adressen arbeiten, müssen diese Namen in IP‐Adressen umgewandelt werden. Diese sogenannte Namensauflösung erledigt das Domain Name System. Weitverkehrnetze (WAN) und das Internet
Der Name einer Web‐Site besteht typischerweise aus einem Hostnamen, einem Domainnamen und dem Top‐
Level‐Domainnamen. Zusammengesetzt ergeben Sie den sogenannten qualifizierten Domainnamen (fully qualified domain name, kurz: FQDN). Der Hostname definiert einen bestimmten Rechner in einer Domäne, z.B. www oder portal. Der Domainname muss registriert werden, um auf der Welt eindeutig zu sein. Die Verwaltung aller Domainnamen übernimmt die Internet Corporation for Assigned Names and Numbers (kurz ICANN). Dieser wäre in unserem Beispiel tugraz. Der Top‐Level‐Domainname (TLD) wäre .at (es gibt aber auch viele andere TLD für unterschiedliche Zwecke z.B. .com, .org, .gov;, und auch für unterschiedliche Länder z.B. .de, .co.uk, …). Im Internet gibt es tausende Rechner (Server), die für diese Namensauflösung zuständig sind. Diese Rechner sind hierarchisch aufgebaut und verwalten den Namensraum im Internet. An der Spitze stehen Root‐DNS‐
Server, die herausfinden, wie der Top‐Level‐Domainname lautet. Er leitet die Anfrage dann dem entsprechenden TLD‐Server weiter. Dieser hält eine Liste aller Domainnamen dieses TLD und leitet die Anfrage an den jeweiligen Name‐Server weiter. Der Name‐Server kennt nun alle Web‐Server, die für diesen Domainnamen zugeordnet wurden, und leitet die Anfrage an den Web‐Server weiter, der seine IP‐Adresse an den Client zurücksendet. 9.3
DER ROUTER Eine der wichtigsten Hardwarekomponenten des Internets sind Router. Sie bilden den Kern des Internets und sorgen für die reibungslose Übertragung und Weiterleitung von Daten über unterschiedliche Netzwerke. Ein Router besitzt wie ein Hub oder ein Switch mehrere Anschlüsse zu einem Übertragungsmedium. Ähnlich wie bei einem Switch analysiert er die empfangenen Netzwerkdaten und leitet diese an die Anschlüsse weiter. Im Gegensatz zum Switch betrachtet der Router jedoch nicht nur die MAC‐
Adresse des empfangenen Ethernet‐Blockes, sondern er „entpackt“ das darin enthaltene IP‐Paket und nutzt die darin enthaltene IP‐Adresse, um die Ethernet‐Blöcke (in andere Netzwerke) weiterzuleiten. Der wesentliche Unterschied zwischen einem Switch und einem Router besteht darin, dass Router in der Lage sind, unterschiedliche Netzwerke miteinander physikalisch sowie auch logisch zu verbinden (logische Adressierung durch IP‐Adressen). Ein Switch kann nur in einem Subnetz verwendet werden, da er nur mit MAC‐Adressen arbeitet und die MAC‐Adresse keine Netzwerkinformationen beinhaltet. Der Router hingegen arbeitet mit IP‐
Adressen (auf Schicht 3 im ISO/OSI‐Modell), welche ein Netzwerk mithilfe der Netzwerkadresse und einen bestimmten Rechner mithilfe der Hostadresse eindeutig identifizieren können. Sie besitzen eine eigene IP‐
89
90 Adresse und eine eigene MAC‐Adresse. Werden Ethernet‐Blöcke von einem Router empfangen, so ersetzt der Router die Quell‐ und Ziel‐MAC‐Adresse im Ethernet‐Block bevor er die Daten weitersendet. Dies machen Hubs und Switches nicht. Vorteile:  Kann unterschiedliche Netzwerke miteinander verbinden (durch logische IP‐Adressierung, unterschiedliche Netzwerkschnittstellen wie z.B. Ethernet, Token Ring, ATM, DSL, Wireless LAN,…)  Bietet mehr Sicherheit durch eingebaute Hardware‐ oder Software‐Firewall, Intrusion‐Detection‐
Mechanismen, etc.  Gateway‐Funktion: Mehrere Rechner im Netzwerk können über den Router in ein anderes Netzwerk zugreifen (z.B. das Internet). Nachteile:  Höherer Konfigurationsaufwand  Eventuell teurer (speziell Backbone‐Router bzw. Hochgeschwindigkeitsrouter) 9.4
IP‐ROUTING Um Daten von einem Netzwerk (z.B. einem LAN) in ein anderes Netzwerk (z.B. in das Internet) weiterleiten zu können, bedient sich der Router einer Tabelle, die er in seinem internen Speicher hält. Diese sogenannte Routing‐Tabelle speichert, welches Netzwerk über welchen Router‐Anschluss erreichbar ist. Ein Eintrag in der Routing‐Tabelle beinhaltet typischerweise die Netzwerkadresse des Zielnetzwerkes, die Subnetzmaske, eine Gateway‐Adresse (oder auch next hop address genannt, welche die nächste Netzwerkadresse angibt, an die die Daten gesendet werden sollen), die Netzwerkschnittstelle (z.B. Fast Ethernet), die Länge der Route (oft als Anzahl der hops oder auch als Metric bezeichnet) sowie Informationen, wie die Route in die Tabelle eingetragen wurde. Für alle Netzwerk‐Schnittstellen des Routers wird automatisch ein Routing‐Eintrag gemacht. Diese nennt man dann direkt verbundene Routen, welche in der Tabelle oft mit C gekennzeichnet sind. Werden Routen in die Tabelle per Hand eingetragen, so nennt man diese statische Routen. Diese werden oft mit S gekennzeichnet. Werden Routen über sogenannte Routing‐Protokolle empfangen (z.B. durch das Routing Information Protocol, kurz RIP), so wird dies mit einer jeweiligen Abkürzung gekennzeichnet (z.B. R bei RIP). Mithilfe der Routing‐Tabelle kann der Router nun Daten von einem Netzwerk zu einem anderen Netzwerk weiterleiten. In den letzten Abschnitten haben wir nun erfahren, dass Nachrichten in modernen Rechnernetzen mithilfe von Ethernet‐Blöcken an andere Rechner im Netzwerk verschickt werden können. Ethernet‐Blöcke beinhalten als Daten sogenannte Pakete. In den nächsten drei Abschnitten werden wir uns drei wichtige Pakettypen genauer Weitverkehrnetze (WAN) und das Internet
ansehen, die alle auf dem Internet‐Protokoll (IP) basieren, nämlich ICMP, UDP und TCP. Aber zuerst schauen wir uns das Internet‐Protokoll genauer an. 9.5
DAS INTERNET‐PROTOKOLL (IP) Das Internet‐Protokoll besitzt ein IP‐Header‐Datenfeld und ein Nutzdatenfeld. Der sogenannte IP‐Header liefert Informationen über die Quelle (Quell‐IP‐Adresse), über das Ziel (Ziel‐IP‐Adresse) und auch weitere Informationen für die Weiterleitung der Daten (z.B. die Länge des Nutzdatenfeldes, die Lebensdauer des inkludierten Paketes, etc.). Werden Daten in einem IP‐Protokoll gekapselt und sind diese zu groß für die Übertragung, so können die Daten beim Senden in mehrere Fragmente unterteilt werden und beim Empfangen wieder zusammengesetzt werden (IP Fragmentierung). Es gibt jedoch weder eine Garantie, dass die Pakete in der richtigen Reihenfolge ankommen noch dass diese auf dem Weg verändert wurden. IP ist kein verlässliches Protokoll. Die folgende Liste beschreibt die einzelnen Felder des IP‐Headers im Detail: 













Version: Gibt die Version des verwendeten IP‐Protokolls an, z.B. IPv4 oder IPv6. IHL: Die IP Header Length (kurz: IHL) gibt die Länge des IP‐Headers an (die angegebene Länge ist ein Vielfaches von 32 Bits) Diensttyp/Service: Dieses Feld ist 8 Bit breit und kann verwendet werden um IP‐Pakete zu priorisieren (Quality of Service). Länge: Gibt die gesamte Länge des Paketes in Bytes an. Die maximale Paketlänge ist 64 kB. Identifikation: Dieses Feld wird zusammen mit den Feldern Flags und Offset verwendet, um IP‐Pakete, welche zuvor zerlegt (fragmentiert) worden sind, wieder zusammenzusetzen. Flags: Gibt an, ob das Paket zerlegt werden darf oder nicht (don’t fragment bit), ob es das letzte Fragment ist oder ob noch weitere Fragmente folgen. Offset: Dieses Feld gibt den Offset der Fragmente an. Lebensdauer: Gibt die Lebensdauer des Paketes in Anzahl der Hops an. Jeder Router im Netzwerk verringert diesen Wert um 1. Ist der Wert 0, so wird das Paket verworfen. Dies verhindert, dass Pakete unendlich lang weitergeleitet werden. Protokoll: Dieses Feld zeigt an, welcher Pakettyp in den Nutzdaten gesendet wird. Ist z.B. ein ICMP‐
Paket inkludiert, so hat dieses Feld den Wert 1, bei TCP den Wert 6 und bei UDP den Wert 17. Prüfsumme: Die 16‐Bit‐Prüfsumme wird vom Empfänger verwendet um zu überprüfen, ob Übertragungsfehler aufgetreten sind. Der Empfänger berechnet einen CRC nur über den IP‐Header und vergleicht den Wert dann mit dem Wert im Prüfsummenfeld. Dieser Wert muss von jedem Router verifiziert und neu berechnet werden, da ja jeder Router das Lebensdauerfeld dekrementiert und sich dadurch die Prüfsumme ändert. Quell‐IP‐Adresse: Gibt die 32‐Bit‐Quell‐IP‐Adresse des Senders an. Ziel‐IP‐Adresse: Gibt die 32‐Bit‐Ziel‐IP‐Adresse des Empfängers an. Optionen (optional): Optionen sind optional, müssen aber ein Vielfaches von 32 Bit sein. Daten: Das Datenfeld kapselt weitere Pakete, wie z.B. ICMP, UDP oder TCP. IP‐Header Paket Bit 0‐7 Bit 8‐15
Bit 16‐23
Bit 24‐31
Version IHL Diensttyp/Service
Länge Identifikation
Flags
Offset Lebensdauer (Time‐
Protokoll Prüfsumme (CRC) To‐Live‐Feld, TTL) Quell‐IP‐Adresse
Ziel‐IP‐Adresse
Optionen (optional)
Daten
91
92 9.6
DAS ICMP‐PAKET Ein oft verwendetes IP‐Paket ist das Internet‐Control‐Message‐Protocol‐Paket (kurz ICMP‐Paket). In der Praxis wird es häufig verwendet, um Information und Fehlermeldungen zwischen Rechnern auszutauschen. Router versenden zum Beispiel oft ICMP‐Pakete, um auf etwaige Übertragungsfehler hinzuweisen, z.B. dass ein Router ausgefallen ist oder dass ein Paket nicht zugestellt werden konnte (TTL ist abgelaufen etc…). Der ICMP‐Header besteht aus einem Nachrichtentyp‐Feld, einem Code‐Feld, einer Prüfsumme und einem optionalen Datenfeld. Programme wie ping oder traceroute verwenden ICMP, um das Netzwerk analysieren zu können. IP‐Header ICMP‐Paket Bit 0‐7 Bit 8‐15
Bit 16‐23
Bit 24‐31
Version IHL Diensttyp/Service
Länge Identifikation
Flags
Offset Lebensdauer (Time‐
Protokoll Prüfsumme (CRC) To‐Live‐Feld, TTL) Quell‐IP‐Adresse
Ziel‐IP‐Adresse
Optionen (optional)
Nachrichtentyp Code
Prüfsumme Daten
9.7
DAS UDP‐PAKET Das User‐Datagram‐Protocol‐Paket (kurz: UDP‐Paket) wird häufig verwendet, um Daten wie Sprache oder Filme schnell über ein Netzwerk zu übertragen. Ziel bei diesen (unkritischen) Übertragungen ist es, die Daten mit relativ wenig Overhead zu übertragen. Dabei ist es nicht so wichtig, dass einzelne Pakete auch wirklich ankommen. Fehlende Pakete werden z.B. bei Film‐ oder bei der Sprachübertragung interpoliert, so dass der Anwender das Fehlen einzelner Datenbits oft selten erkennt. UDP ist ein unzuverlässiges Protokoll und nicht verbindungsorientiert und macht im Schnitt ca. 20 % des Internetverkehrs aus. Um die Daten einem bestimmten Programm am Zielrechner zuweisen zu können, werden vom Rechner jedem Programm eine sogenannte Portnummer zugewiesen. Die Portnummer wird dann im UDP‐Paket inkludiert, sodass der Empfänger die Daten dann auch an das entsprechende Programm weiterreichen kann. Es gibt insgesamt 65536 Portnummern (16 Bit); viele davon sind fix für bestimmte Dienste bzw. Programme vergeben; das sind z.B. die Portnummern 0 bis 1023 (die sogenannten well‐known‐ports) oder die Portnummern 1024 bis 49151 (registered ports). Andere Portnummern können frei gewählt werden. Dazu gehören z.B. die Ports 49152 bis 65535 (die sogenannten dynamic ports). Beispiele für Portnummern für bekannte Dienste sind z.B. Port 80 für den HTTP‐Dienst (Webbrowser), Port 21 für den FTP‐Dienst, oder Port 23 für den Telenet‐Dienst, etc. Weitverkehrnetze (WAN) und das Internet
IP‐Header UDP‐Paket Bit 0‐7 Bit 8‐15
Bit 16‐23
Bit 24‐31
Version IHL Diensttyp/Service
Länge Identifikation
Flags
Offset Lebensdauer (Time‐
Protokoll Prüfsumme (CRC) To‐Live‐Feld, TTL) Quell‐IP‐Adresse
Ziel‐IP‐Adresse
Optionen (optional)
Quell‐Port
Ziel‐Port Länge
Prüfsumme Daten
9.8
DAS TCP‐PAKET Das Transmission‐Control‐Protocol‐Paket (TPC‐Paket) ist im Gegensatz zum UDP‐Paket verbindungsorientiert und zuverlässig. TCP stellt also eine Verbindung zwischen zwei Endgeräten her und erlaubt einen sicheren Austausch von Nachrichten durch Paketverlust‐Erkennung und automatischer Neuübertragung. Es basiert auf dem IP‐Protokoll (daher auch oft als TCP/IP bezeichnet) und ist eines der weitverbreitetsten Übertragungsprotokolle für Rechnernetze. Prinzipiell benötigen Programme auf Endgeräten, welche eine TCP‐Verbindung mit einem anderen Endgerät herstellen möchten, eine IP‐Adresse des Zielrechners sowie eine Portnummer für das bestimmte Programm (Dienst) auf diesem Zielrechner. Mithilfe der IP‐Adresse und der Portnummer können Programme Daten austauschen. Die Schnittstelle zwischen dem Programm und dem Rechnernetz bezeichnet man auch als sogenannten Socket. Ein Socket bietet eine bidirektionale Verbindung zweier Anwendungen über ein Rechnernetz. WIE VERBINDEN SICH RECHNER NUN ÜBER TCP? Möchten zwei Rechner eine TCP‐Verbindung aufbauen, um Daten zu übertragen (z.B. eine Webseite in einem Browser anzeigen lassen), dann wird zuerst eine Host‐zu‐Host‐Verbindung aufgebaut, die Daten werden sodann übertragen und anschließend wird die Verbindung wieder abgebaut. 1) Der Verbindungsaufbau: Der Quellrechner (Client), der die Verbindung zu dem Zielrechner (Server) aufbauen möchte, sendet zuerst ein SYN‐Paket an den Server. Das SYN‐Paket ist im Wesentlichen ein TCP‐Paket mit einem gesetzten SYN‐Flag und einer zufälligen Sequenznummer x. Das TCP‐Paket wird in einem IP‐Paket gekapselt, welches wiederum in einem Ethernet‐Block gekapselt wird. Nach Erhalt des SYN‐Paketes inkrementiert der Server die Sequenznummer x zu x+1, generiert selbst eine zufällige Sequenznummer y und sendet beide in einem sogenannten SYN/ACK‐Packet (Synchronize/Acknowledge) an den Client zurück. Der Client bestätigt den Erhalt des Paketes durch ein ACK‐Paket mit Sequenznummer x+1 und y+1. Diesen Vorgang bezeichnet man oft als Drei‐Wege‐
Handshake. 93
94 2) Die Datenübertragung: Daten können nun übertragen werden. Für jedes übertragene Datenbyte wird nun die Sequenznummer erhöht. Dies ermöglicht dem Empfänger, die empfangenen TCP‐Pakete wieder in die richtige Reihenfolge anzuordnen. Pakete in Netzwerken können unterschiedlichste Wege durch das Rechnernetz oder Internet nehmen, wodurch es zu unterschiedlichen Laufzeiten der Pakete kommen kann. Dadurch können die Pakete in unterschiedlicher Reihenfolge beim Empfänger ankommen, obwohl sie in der richtigen Reihenfolge versendete wurden. Für jedes empfangene Paket sendet der Empfänger ein Bestätigungs‐Paket (ACK‐Paket inklusive Sequenznummer des Paketes). Wird innerhalb einer gewissen Zeitspanne (timeout) kein ACK‐Paket beim Sender empfangen, so sendet dieser die Pakete erneut. 3) Der Verbindungsabbau: Der Verbindungsabbau verläuft ähnlich wie beim Verbindungsaufbau. Statt den SYN‐Paketen werden FIN‐Pakete und FIN/ACK‐Pakete versendet. DIE STRUKTUR EINES TCP‐PAKETS Das TCP‐Paket besitzt die folgenden Felder: 










Quell‐Port: Gibt die Portnummer der Anwendung auf dem Quell‐Rechner an. Ziel‐Port: Gibt die Portnummer der Anwendung auf dem Ziel‐Rechner an. Sequenznummer: Dieses Feld ist 32 Bit breit und dient zum Verbindungsaufbau und der Sortierung der einzelnen TCP‐Segmente. Quittierungsnummer: Gibt die Sequenznummer an, die der Empfänger erwartet TCP‐Header‐Länge: Gibt die Länge des TCP‐Headers (ohne Nutzdaten) an Flags: Es gibt unterschiedliche Flags: SYN, ACK, FIN, RST, PSH, URG. Fenster: Das Fenster‐Feld (Window) gibt an, wie viele Bytes übertragen werden dürfen, ohne eine Bestätigung des Empfängers zu erwarten. Diese Technik entlastet das Netzwerk und wird oft als sliding window bezeichnet. Prüfsumme: Die Prüfsumme dient zur Erkennung von Übertragungsfehlern. Es wird ein 16‐Bit‐CRC über den Header und die Daten berechnet. Dringlichkeit: Dieses Feld gibt die genaue Position des ersten Bytes der Nutzdaten an. Gilt nur wenn das URG‐Flag gesetzt ist. Optionen: Enthält Zusatzinformationen, die hier nicht weiter erläutert werden. Daten: Enthält die Daten des TCP‐Paketes. Ein TCP‐Segment hat bei Ethernet‐LAN typischerweise maximal 1460 Bytes (ein Ethernet‐Block kann maximal 1500 Bytes übertragen und der IP‐Header und auch der TCP‐Header benötigen jeweils 20 Bytes). Weitverkehrnetze (WAN) und das Internet
IP‐Header TCP‐Paket Bit 0‐7 Bit 8‐15
Bit 16‐23
Bit 24‐31
Version IHL Diensttyp/Service
Länge Identifikation
Flags
Offset Lebensdauer (Time‐
Protokoll Prüfsumme (CRC) To‐Live‐Feld, TTL) Quell‐IP‐Adresse
Ziel‐IP‐Adresse
Optionen (optional)
Quell‐Port
Ziel‐Port Sequenznummer
Quittierungsnummer
TCP‐Header‐Länge Flags
Fenster Prüfsumme
Dringlichkeit Optionen
Daten
95
96 10 PERSONAL AREA NETWORKS (PANS) 11 TELEKOMMUNIKATIONSNETZE Teil D: Hardware, Stack und I/O
TEIL D: HARDWARE, STACK UND I/O Karl C Posch, April 2010 PROGRAMM FÜR DEN VIERTEN BLOCK „RECHNERNETZE UND ‐ORGANISATION“: Vermutliche Termine: 26. Mai 2010 (Hardware von unten mit Logisim) 9. Juni 2010 (Hardware von oben) 16. Juni 2010 (Unterprogrammaufrufe und Stack) 23. Juni 2010 (I/O) 30. Juni 2010 (Zusammenfassung) INHALT: 12. Hardware von unten: Logikfunktionen, Speicher, Endliche Automaten 13. Hardware von oben: CPU, Speicher, I/O‐Geräte, Systembus, CPU‐Aufbau, Fetch‐Execute, Betriebssystem, Pipelining, Cache 14. Unterprogramme 15. Input/Output TEXTMATERIALIEN: 


Matloff: Major Components of Computer Engines Matloff: Subroutines on Intel CPUs Matloff: Overview of Input/Output Mechanisms FÜR DIE PROGRAMMIERUNG: 



Das Programm Logisim (http://ozark.hendrix.edu/~burch/logisim/) samt den Beispielschaltungen Ein Texteditor. gcc, as, gdb. SSH zu Pluto oder ein eigenes Linux. Für Windows‐Menschen: Debian‐Linux in einer Virtual Box. AUFWAND VORLESUNG + ÜBUNG: 42,5 STUNDEN 



5 * 1,5 Stunden im Hörsaal 5 Stunden Lesen Experimentieren mit Hardware‐Simulator: 5 Stunden Vorbereitung zur Vorlesungsprüfung: 25 Stunden 97
98 Rechnernetze und –Organisation 12 HARDWARE VON UNTEN Wir verwenden in diesem Kapitel das Programm Logisim, mit welchem wir einfache Hardwareschaltungen ausprobieren können. Das Programm ist ein Java‐Programm und läuft unter allen gängigen Plattformen. Du findest Logisim auf der RNO‐Webseite oder unter http://ozark.hendrix.edu/~burch/logisim/. 12.1 KOMBINATORISCHE SCHALTUNGEN 12.1.1 GRAFISCHE DARSTELLUNG VON SCHALTUNGEN UND DEREN SIMULATION Wir beginnen ganz einfach. Wir können digitale Schaltungen als sogenannte „Schematics“ darstellen. Dies ist eine schematische, also bildhafte Darstellung einer elektrischen Schaltung. Im Bild unten siehst du rechts das Symbol für eine Lampe. Sie hat eigentlich zwei elektrische Anschlüsse. Doch nur ein Anschluss wird gezeigt und ist mit einem elektrischen leitenden Draht mit „1“ verbunden. Stelle dir unter „1“ den Plus‐Pol einer Batterie vor. Der zweite Anschluss, welcher nicht dargestellt wird, ist mit „0“ verbunden. Stelle dir dabei den Minus‐Pol der Batterie vor. Man spricht dabei auch vom „Masse‐Anschluss“ oder „Erde“. Die Batterie oder eine andere Gleichspannungsversorgung liefert also die Energie für die Schaltung. Die Batterie selbst ist in der schematischen Darstellung nicht dargestellt. Das Symbol „1“ auf der linken Seite symbolisiert also eine Verbindung mit dem Plus‐Pol der Batterie. Und dazwischen haben wir einen „Draht“, also eine elektrisch leitende Verbindung zwischen den beiden Symbolen. Obige Darstellung ist also eine Abstraktion der unten dargestellten physikalische Schaltung bestehend aus Batterie, Lampe und elektrisch leitenden Verbindungen. Vermutlich kannst du dir vorstellen, was passiert, wenn man die beiden Anschlüsse einer Lampe mit dem Plus‐ und dem Minus‐Pol einer Batterie verbindet: Die Lampe leuchtet. Auf dem Draht bewegen sich nämlich Elektronen und stellen damit einen elektrischen Strom dar, welcher die Lampe zum Leuchten bringt. Und die Bewegung der Elektronen wird durch die elektrische Spannung in der Batterie zwischen den beiden Polen verursacht. Hardware von unten
Lade die Datei rno_Teil_D.circ mit File->Open in den Simulator. Dann wählst du auf der linken Seite den „Circuit“ mit dem Namen rno1. Du solltest jetzt obiges Bild sehen. Noch passiert nicht recht viel: Die Lampe leuchtet. Probiere selbst, diese Schaltung „zusammen zu löten“. Starte mit File->New und hole die beiden Symbole in das Fenster. Du findest die Lampe links unter Input/Output unter dem Namen LED. Unter Gates findest du die Konstante „1“. Möglicherweise musst du im linken Fenster unten die Konstante erst „einstellen“: Data
Bits muss auf 1 gesetzt sein und Value auf 0x1. Danach verbindest du die beiden Symbole mit einem „Draht“. „Drähte“ findest du links oben unter Base im Wiring Tool. Klicke dieses und du kannst elektrische Verbindungsdrähte zeichnen. 99
100 Rechnernetze und –Organisation Probiere aus, aus der „1“ eine „0“ zu machen. Du musst lediglich im Edit-Modus den 1‐er anklicken und danach links unten den Value auf 0x0 setzen. Die Lampe geht aus. Sehen wir uns an, wie wir das „eleganter“ machen können. 12.1.2 EIN‐ UND AUSSCHALTEN VON LAMPEN Sehen wir uns als Nächstes die Schaltung (= Circuit) rno2 an. Statt der Verbindung zum Plus‐Pol der Batterie haben wir jetzt die schematische Darstellung eines Schalters. Starte die Simulation indem du auf den Finger links oben klickst. Klicke dann auf den Schalter und die Lampe leuchtet. Klicke nochmals und die Lampe ist wieder aus. Du kannst zwischen Edit‐Modus und Simulations‐Modus wechseln. Dazu gibt es links oben „den Finger“ und „den Cursor“. Im Edit‐Modus kannst du Elemente deiner Schaltung auswählen und Parameter dieses Elements links unten verändern. Im folgenden Bild habe ich dem Schalter das Label mein-schalter gegeben. Und die Lampe heißt L. Hardware von unten
Wir sehen in dieser Abstraktion des Stromkreises eine Reihe von wichtigen Aspekten nicht mehr. Die Batterie und auch die Verbindung des Schalters mit dem Pluspol und dem Minuspol der Batterie werden nicht dargestellt. Mit dem Mittel der Abstraktion können wir jedoch andere Aspekte betonen, die uns mehr interessieren. Doch du solltest nicht vergessen, dass obiges schematisches Bild eigentlich für folgende physikalische Situation steht: Wir sagen beim Schalter „Ein‐Aus“, können jedoch auch genauso gut von „Eins‐Null“, „1‐0“ oder „Wahr‐Falsch“ sprechen. Wir wollen in der Folge die mathematische Form „1“ und „0“ bevorzugen. Im schematischen Bild können wir zudem schon von einem Eingang und einem Ausgang sprechen. Der Schalter links stellt den Eingang dar, die Lampe rechts den Ausgang. Wir können auch von Ursache und Wirkung sprechen. Die Lampe, also der Ausgang der Schaltung ist auf „1“ wenn der Eingang auf „1“ ist. Und die Lampe ist auf „0“, wenn der Schalter auf „0“ ist. Einen dritten Fall gibt es in unserer „digitalen“ Welt nicht – oder genauer formuliert: Wir wollen hier nur eine digitale Abstraktion betrachten. Wir können in dieser Abstraktion den Schalter nicht auf „halb“ einschalten und damit die Lampe auf „halb hell“ bringen. Und die Batterie geht in unserer Abstraktion nie aus. 12.1.3 WAHRHEITSTAFELN, SIGNALE UND ZEITDIAGRAMME Mathematisch betrachtet können wir obigen Sachverhalt tabellarisch darstellen. Wir nennen das Wahrheitstafel: mein_schalter
0
L
0
1
1
Links befindet sich die Eingabespalte. Diese stellt die Ursache dar. Rechts die Ausgabespalte mit der Wirkung. Wir können auch sagen, dass L eine Funktion f von der Eingangsvariablen mein_schalter ist: L = f(mein_schalter)
Wie viele verschiedene Funktionen dieser einfachen Art gibt es? Oder präziser formuliert: Wie viele verschiedene Wahrheitstafeln mit 1 Eingangsvariablen gibt es? Die Antwort ist: Es gibt genau 4 Möglichkeiten. Hier sind sie. Nennen wir den Eingang in1 und den Ausgang out1. 101
102 Rechnernetze und –Organisation Die Lampe ist entweder immer aus, unabhängig vom Eingang. Oder die Lampe leuchtet, wenn der Schalter „ein“ ist. Oder sie leuchtet genau dann nicht, wenn der Schalter „ein“ ist und umgekehrt. Schließlich könnten wir uns eine Abbildung zwischen Eingang und Ausgang vorstellen, wo unabhängig vom Eingangswert die Lampe immer leuchtet. 12.1.4 GATTER, INVERTER Sehen wir uns die dritte Wahrheitstafel der obigen 4 an. Diese ist von „technischem Interesse“. Wir nennen die technische Realisierung dieser Funktion einen „Inverter“. Klicke auf den Circuit rno3. Das dreieckige Symbol mit dem Kreis dran ist das schematische Symbol für den Inverter. Schalte die Simulation ein – du weißt schon: der Finger links oben ‐‐ und klicke auf den Schalter, welcher jetzt den Namen „in“ trägt. Das Resultat dieser Simulation sollte dich angesichts der Wahrheitstafel nicht mehr überraschen. Wie du siehst, stellt der Simulator in Farbe dar, welches „Signal“ gerade auf einem Leitungsstück ist. Hellgrün bedeute „1“ und dunkelgrün steht für „0“. Wie macht der Inverter aus „keiner elektrischen Spannung“ eine „elektrische Spannung“ und umgekehrt? Die beste Antwort hier wäre wohl: „Der kann das eben“. Denn die Frage führt ins Bodenlose. Denn wenn ich dir jetzt erklären würde, dass wir den Inverter aus der Zusammenschaltung zweier MOS‐Transistoren bauen können, dann willst du sicher wissen, wie man MOS‐Transistoren baut. Doch wie baut man MOS‐Transistoren? Wenn wir dann nach mehreren Frage‐Antwort‐Runden tief in der Quantenphysik gelandet wären, dann käme der Punkt, wo es (vorläufig) keine naturwissenschaftlichen Antworten mehr gibt. Wenn wir hingegen postulieren, dass man mit zwei MOS‐Transistoren einen Inverter bauen kann und uns mit dieser Antwort zufrieden geben, dann können wir auf dieser Basis so ziemlich alle Antworten bis zum Bau einer komplexen CPU geben. In diesem Kapitel wollen wir einen kleinen Crash‐Kurs dazu absolvieren. Ich schlage vor, du liest den Text langsam durch uns „spielst“ im Simulator Logisim mit den besprochenen Schaltungen. Wichtig ist, dass du erkennst, dass im obigen Bild folgende Bestandteile vorhanden sind: Ein Schalter, der mit dem Eingang des Inverters mit einer elektrischen Leitung verbunden ist. Der zweite (nicht gezeigte) Anschluss des Schalters ist entweder mit dem Plus‐Pol („Schalterstellung auf 1“) oder dem Minus‐Pol („Schalterstellung auf 0“) der Batterie verbunden. Dazu hatten wir weiter oben ja schon eine „physikalische Darstellung“. Diese Schalterstellung können wir mit unserem Simulator durch Doppelklicken im Simulationsmodus umschalten. Dann gibt es den Inverter. Das ist das Dreieck zusammen mit dem Kreis. Der Ausgang des Inverters ist „über ein Stück Draht“ mit einer Lampe verbunden. Diese Lampe hat einen zweiten Anschluss, welcher mit dem Minus‐
Pol der Batterie verbunden ist. Hardware von unten
12.1.5 TRANSISTOREN UND MOS‐TECHNOLOGIE Trotz der oben erwähnten Bodenlosigkeit: Ein kleines Stück des Weges „nach unten“ wollen wir uns ansehen. Es gibt 2 Typen sogenannter MOS‐Transistoren. Den sogenannten p‐MOS‐Transistor und den n‐MOS‐Transistor. MOS steht dabei für „Metal Oxide Silicon“. Das ist eine historische Bezeichnung und hat keine besondere Bedeutung für uns hier. Diese MOS‐Transistoren können als elektronische Schalter verwendet werden. Ein Schalter lässt Strom von einem Anschlusspunkt zum anderen Anschlusspunkt durch oder sperrt den Strom. Beim MOS‐Transistor heißen diese beiden Anschlüsse üblicherweise Source (S) und Drain (D). In welchem der beiden „Schalter‐Zustände“ sich ein MOS‐Transistor befindet, hängt von der elektrischen Spannung am dritten Anschluss, dem sogenannten Gate (G) ab. Die elektrische Spannung am Gate beeinflusst also den Schalter. In einer digitalen Abstraktion kennen wir nur zwei Spannungs‐„Pegel“: Spannung und keine Spannung. Bei einer 1‐Volt‐Batterie wäre das eben „1 Volt“ und „0 Volt“ – oder „1“ und „0“ eben. Für die Realisierung eines Inverters brauchen wir genau 1 p‐MOS‐Transistor und 1 n‐MOS‐Transistor. Unser Simulator Logisim kann auf Transistorebene nicht mehr simulieren. Bei Interesse könntest du jedoch den „anderen“ Simulator „DSCH3“ dazu verwenden. Dieser wird am Web unter http://www.microwind.net angeboten. Installiere den Simulator DSCH3 unter Windows, starte den Simulator und lade die Datei rno4.sch. Doch das ist nicht wichtig. Es reicht, wenn du einfach hier weiter liest. Du siehst im unteren Bild „das Innere“ eines Inverters: Oben der p‐MOS‐Transistor und unten der n‐MOS‐
Transistor. In der Mitte sind beide „zusammen gelötet“. Der p‐MOS‐Transistor ist zudem oben mit dem Plus‐Pol der Batterie verbunden und der n‐MOS‐Transistor mit dem Minus‐Pol der Batterie. Starte die DSCH3‐Simulation durch Drücken des grünen Dreiecks. Du siehst, dass der Eingang in1 „aus“ ist. In diesem Fall stellt der p‐Transistor eine Verbindung der Lampe mit „Plus“ her. Deswegen leuchtet die Lampe. Die Verbindung über den n‐Transistor ist hingegen unterbrochen. Drücke den Eingangsschalter in1 auf „ein“ (= 1). Jetzt dreht sich die Situation um: Der p‐Transistor unterbricht und der n‐Transistor stellt eine Verbindung der Lampe mit „Masse“, also mit 0 her. Die Lampe ist „aus“ (= 0). So geht das. Übrigens: Bei beiden Transistoren stehen die Werte für W und für L dabei. „W“ steht dabei für „width“, also Breite, und „L“ für „length“, also Länge. Diese Details kommen aus einer noch tieferen Betrachtungsweise. Es reicht für uns zu wissen, dass es zwei Transistoren sind. 103
104 Rechnernetze und –Organisation Auch alle anderen in der Folge besprochenen Gatter etc. lassen sich durch die Zusammenschaltung von ein paar n‐MOS‐ und p‐MOS‐Transistoren bauen und auf ähnliche Weise vom Prinzip her erklären. Solltest du keinen Zugang zu DSCH3 haben oder obige Übung nicht ausprobiert haben, dann macht das nichts. Dieses Wissen ist für das Verständnis der nachfolgenden Teile nicht notwendig. Doch ein letztes Mal will ich dir ein „genaueres“ Bild zeigen. In diesem Bild siehst du, dass der Inverter nur funktioniert, wenn er ebenfalls „an die Batterie angeschlossen“ ist. Diese Batterieanschlüsse sind bei jedem Gatter notwendig, werden bei unserer schematischen Darstellung jedoch nie gezeigt. 12.1.6 UND, ODER Vermutlich kennst du bereits andere sogenannte „Logikfunktionen“. Beispielsweise die UND‐Funktion (AND) oder die ODER‐Funktion (OR). Sehen wir uns die UND‐Funktion mit 2 Eingängen als Beispiel an. Lade dazu – jetzt sind wir wieder beim Simulator Logisim – den Circuit rno5 in den Simulator. Jetzt haben wir 2 Eingangsschalter mit den Namen in1 und in2. Es gibt jetzt 4 Möglichkeiten, wie diese Schalter stehen können. Diese 4 Möglichkeiten kannst du in der Simulation ausprobieren. Die Wahrheitstafel dieser 2‐Input‐UND‐Funktion sieht so aus: in2
0
in1
0
out1
0
0
1
0
1
1
0
1
0
1
Nur dann, wenn beide Eingänge auf 1 sind, ist auch der Ausgang auf 1. Sonst 0. Hardware von unten
Bevor wir weitermachen, ein allgemeiner Kommentar zu Wahrheitstafeln: Es ist an sich egal, in welcher Reihenfolge man alle Möglichkeiten für die Eingangsvariablen aufschreibt. Doch Wahrheitstafeln sind leichter lesbar, wenn man sich an eine fix vorgegebene Reihenfolge hält. Bei uns ist es üblich, oben mit lauter Nullen zu beginnen und unten mit lauter Einsen aufzuhören. Dazwischen sortieren wir die Kombinationen so, dass sich eine aufsteigende Reihenfolge von Zahlen ergibt, wenn man die Eingangskombinationen als Binärzahl interpretiert. Bei 2 Variablen also 00, 01, 10 und schließlich 11. Bei 3 Eingangsvariablen beginnt man mit 000, danach 001, 010, 011, 100, 101, 110 und schließlich 111. 12.1.7 ANDERE POPULÄRE GATTER Manche populäre Logikfunktionen haben Symbole. Lade die Schaltung base und probiere andere populäre Logikfunktionen aus. Experimentiere mit allen und ermittle die Wahrheitstafeln aller. 12.1.8 ANDERE LOGIKFUNKTIONEN Sehen wir uns jetzt rno6 an. In dieser Schaltung siehst du, wie du auch „komplexere“ Logik‐Funktionen im Simulator „bauen“ kannst. Man kann etwa bei den Gattern auch mehr als 2 Eingänge einstellen. Du siehst im Bild unten etwa ein 3‐Eingang‐UND‐Gatter. Die Einstellungen kannst du bei ausgewähltem Symbol links unten vornehmen. Ich gehe davon aus, dass du die Funktionsweise einer UND‐Funktion mit 3 Eingängen verstehst. Wenn nicht: Einfach alle 8 Möglichkeiten ausprobieren. Die zweite dargestellte Logikfunktion ist ein 3‐faches XOR‐Gatter. Diese macht Folgendes: Immer wenn eine ungerade Anzahl der Eingangsvariablen a, b und c auf 1 ist, liefert sie 1. Sonst 0. Die Wahrheitstafel sieht so aus: 105
106 Rechnernetze und –Organisation a
0
0
b
0
0
s
0
1
0
1
1
0
1
1
0
0
1
1
1
0
1
0
0
1
1
1
Probiere die Simulation mit allen 8 Möglichkeiten für die Eingangsvariablenkombination aus. So viele gibt es bei 3 Eingangsvariablen. Du solltest auch verstehen, warum es jetzt 8 sind. Bei 1 Eingangsvariablen gab es 2 Möglichkeiten, bei 2 Eingangsvariablen waren es 4, und jetzt bei 3 Eingangsvariablen halten wir bei 8 Möglichkeiten. Weil wir bei diesen Funktionen Eingangsvariablen „kombinieren“ können, spricht man von „kombinatorischer Logik“ und von „kombinatorischen Funktionen“. Weiter oben haben wir nachgedacht, wie viele kombinatorische Funktionen es mit 1 Eingangsvariablen gibt. Wir kamen auf die Zahl 4 und haben alle 4 möglichen Wahrheitstafeln nebeneinander aufgezeichnet. Wie viele verschiedenen kombinatorischen Funktionen mit 2 Eingangsvariablen gibt es? Denke nach. Wenn deine Antwort nicht 16 sein sollte, dann liegst du falsch. Verstehst du warum? Wie viele mögliche kombinatorische Funktionen mit 3 Eingangsvariablen gibt es dann? Wie viele mit n Eingangsvariablen? 12.1.9 ARITHMETIK: ADDIEREN Als Nächstes wollen wir uns ansehen, wie wir mit Logikfunktionen arithmetische Operationen wie etwa die Addition ausführen können. Beginnen wir mit der Addition von 2 Ziffern, a und b. Da a und b ja nur entweder 0 oder 1 sein können, gibt es insgesamt nur 4 Fälle, welche wir betrachten müssen:  0 + 0 = 0  0 + 1 = 1  1 + 0 = 1  1 + 1 = 2 In binärer Schreibweise brauchen wir für die Zahl 2 zwei Bits: „10“. Damit können wir die 4 Fälle so schreiben:  0 + 0 = 00  0 + 1 = 01  1 + 0 = 01  1 + 1 = 10 Die 2 Ausgangsbits nennt man üblicherweise „Carry“ (= Übertrag) und „Sum“. Wir sehen, dass das Carry‐Bit nur dann „gesetzt“ ist, also den Wert 1 annimmt, wenn sowohl der Eingang a als auch der Eingang b auf 1 sind. Das entspricht der UND‐Verknüpfung von a und b; also carry = a & b
Hardware von unten
Das Summen‐Bit hat den Wert 1, wenn entweder a oder b, aber nicht beide gleichzeitig den Wert 1 haben. Diese Funktion heißt „Exklusiv‐Oder“ (XOR). In der Sprache C ginge das so: sum = a ^ b
Aus arithmetischer Sicht berechnet die XOR‐Funktion (a + b) mod 2. Schau dir den „Circuit“ rno7 im Simulator an. Starte eine Simulation und probiere alle 4 Fälle aus. Man nennt obige Schaltung üblicherweise einen „Halbaddierer“. Die Wahrheitstafel haben wir eigentlich schon oben dargestellt, doch der Vollständigkeit halber nochmals: a
0
b
0
carry
0
sum
0
0
1
1
0
0
0
1
1
1
1
1
0
Diese Wahrheitstafel stellt eine Logikfunktion mit 2 Eingängen (a und b) und 2 Ausgängen (carry und sum) dar. Es handelt sich hier um eine gemeinsame Darstellung von 2 einzelnen Wahrheitstafeln bzw. Logikfunktionen. Beachte, dass die beiden Ausgangswerte carry und sum im Stellenwertsystem nicht gleichwertig sind. Das Signal carry ist eine Stelle weiter links als das Signal sum. Deshalb habe ich carry auch eine Spalte links von der sum‐Spalte angeordnet. So ist es in unserem Stellenwertsystem ja üblich, oder? Apropos „Signal“: Wenn wir in einer Schaltung eine Variable haben, welche sich über die Zeitdauer verändern kann, dann nennen wir diese Variable ein Signal. Wie sieht die Schaltung aus, welche drei Ziffern x, y und z zusammenzählen kann? Man nennt diese Schaltung üblicherweise einen Volladdierer. Folgende 8 Fälle gibt es zu beachten: 







0 + 0 + 0 = 00 (= 0) 0 + 0 + 1 = 01 (= 1) 0 + 1 + 0 = 01 (= 1) 0 + 1 + 1 = 10 (= 2) 1 + 0 + 0 = 01 (= 1) 1 + 0 + 1 = 10 (= 2) 1 + 1 + 0 = 10 (= 2) 1 + 1 + 1 = 11 (= 3) Auch hier brauchen wir 2 Bits am Ausgang und nennen diese wieder carry und sum. Betrachten wir auch dieses Mal wieder die Wahrheitstafel, welche sich ganz einfach aus obigen 8 Fällen ergibt: 107
108 Rechnernetze und –Organisation x
0
0
y
0
0
z
0
1
carry
0
0
sum
0
1
0
1
0
0
1
0
1
1
0
1
0
1
0
0
1
1
1
0
1
1
0
1
1
0
0
1
1
1
1
1
Was sehen wir „beim genauen Hinsehen“? Das Ausgangssignal carry ist „offensichtlich“ immer 1 wenn zumindest 2 der drei Eingangsvariablen auf 1 sind. Dies lässt sich „logisch“ so ausdrücken: carry = (a & b)|(a & c)|(b & c)
Das Summenbit sum hat immer dann den Wert 1, wenn eine ungerade Anzahl von Einsen bei den drei Eingangsvariablen vorkommt. Dies lässt sich so ausdrücken: sum = a ^ b ^ c
Ehrlich gesagt, diese beiden letzten Feststellungen sind erst nach einiger Erfahrung möglich. Doch warte mal ab. Wir kommen gleich zu einer viel einfacheren Sichtweise. Vorerst lade einmal die Schaltung (= Circuit) rno8 und siehe dir diese an. Sieht schon ziemlich unübersichtlich aus, oder? Ich nenne das immer „Gatterfriedhof“ und die Drähte darin einen „Drahtverhau“. Doch lasse dich nicht von diesem „Gatterfriedhof“ schrecken. Es steckt lediglich die obige Wahrheitstafel dahinter. Probiere selbst alle 8 Fälle aus und überzeuge dich. Hardware von unten
12.1.10 LOGIKFUNKTIONEN ALS LOOK‐UP‐TABLES Ganz schön viel zu zeichnen, oder? Und dann noch die vielen Fehlermöglichkeiten, welche sich beim Zeichnen einschleichen. Deshalb verrate ich dir hier den Trick: Ich habe diese Schaltung gar nicht gezeichnet, sondern zeichnen lassen. Die Wahrheitstafel weiter oben ist alles was du dafür brauchst. Und diese ist ja einfach: Man muss ja nur bis drei zählen können. Wir sehen uns dazu die Schaltung rno9 an. Wie habe ich diese Schaltung erzeugt. Ja, „erzeugt“ und nicht „gezeichnet“. In Logisim kann man die Umwandlung einer Wahrheitstafel in einen „Gatterfriedhof“ (wie oben gezeigt) so vornehmen: Beginne mit dem Definieren der Ein‐ und Ausgänge deiner Schaltung: Danach musst du Project->Analyze Circuit im Simulator wählen. In der Abteilung Inputs solltest du dann die Eingangsvariablen a, b und c sehen. Bei den Outputs die Variablen carry und sum. Bei Table, also bei der Wahrheitstafel kriegst du dann eine entsprechende Tabelle zum Ausfüllen. Klicke auf die „x“‐en und definiere damit, ob du dort „1“ oder „0“ haben möchtest. Wie gesagt: Beim Volladdierer musst da dabei nur bis drei zählen können. Du siehst die Wahrheitstafel des Volladdierers im nächsten Bild. Als Nächstes kannst du dir unter Expression den Logik‐Ausdruck ansehen, wenn du willst: 109
110 Rechnernetze und –Organisation Schließlich drückst du Build Circuit und kriegst die ganze Schaltung. Vorher wirst du noch gefragt, welche Gatter du verwenden möchtest. Du kannst dabei einfach beide Kästchen leer lassen. Das ist mehr so „historisches Zeugs“ und nicht mehr wirklich interessant für uns hier. Doch wenn du viel Zeit hast, dann solltest du die Variante mit beiden Kästchen angeklickt ausprobieren. Du erkennst daraus, dass man jede Logikfunktion aus genügend vielen 2‐fach‐NAND‐Gattern bauen kann. Du siehst die so entstandene Schaltung unten. Man kann also eine bestimmt Logikfunktion mit genau 1 Wahrheitstafel definieren. Aber es gibt unendlich viele Realisierungen dieser Funktion mit Gattern. Hardware von unten
Es ist also so, dass eine Logikfunktion (= Wahrheitstafel) so etwas wie eine „Look‐Up‐Table“ ist. Man sieht nach, welche Eingangskombination vorliegt, und daraus resultiert auch schon der Ausgangswert. Im Computer‐Jargon sagen wir dazu auch oftmals ROM, also „Read‐Only Memory“. Wir werden später als Realisierung einer Wahrheitstafel so ein ROM verwenden. Die Eingänge sind die Adress‐Bits und die Ausgänge der Logikfunktion sind die Daten‐Bits des ROM. Welche „Implementierung“ einer Wahrheitstafel (= Logikfunktion) ist den die beste? Das hängt bei einer technischen Realisierung von einer Reihe von Parametern ab. Es könnte sein, dass man die Schaltung sucht, welche den kleinsten Platzbedarf hat. Oder die höchste Geschwindigkeit bei der Berechnung des richtigen Ausgangs zu einer neuen Eingangskombination. Es könnte auch sein, dass wir die Schaltung suchen, welche den geringsten Energiebedarf hat und so die Batterie am längsten leben lässt. Wie oft musst du eigentlich die Batterie in deinem Mobiltelefon aufladen? Früher einmal war es üblich, Studenten die sogenannte Karnaugh‐
Tafel‐Minimierung von logischen Funktionen üben zu lassen. Ich bevorzuge hier, all diese Details wegzulassen. Es geht hier um die logische Sichtweise. Und aus logischer Sicht sind alle oben vorgestellten Varianten gleich. Solltest du hingegen jemals in die Lage kommen, tatsächlich eine kombinatorische Funktion „optimieren“ zu müssen, dann wird diese Optimierung vermutlich in einem sogenannten Hardware‐Synthesizer eingebaut sein. In einer einfachen Variante ist die Optimierung ja auch schon im Analyze Circuit eingebaut. Ganz ähnlich wie auch beim Kompilieren von C‐Quellcode in Maschinensprache. Der erzeugte Code wird auch innerhalb des Compilers optimiert. Entweder auf Geschwindigkeit oder auf Code‐Größe. Übrigens: Der Volladdierer mit den 2‐fach‐NAND‐Gattern oben lässt sich auch als Logikfunktion darstellen: sum = ~(~(~(~(~(~(~(~(~(a a) ~(b b)) ~(~(a a) ~(b b))) c) ~(~(~(~(a a) b) ~(~(a a) b)) ~(c
c))) ~(~(~(~(~(a a) ~(b b)) ~(~(a a) ~(b b))) c) ~(~(~(~(a a) b) ~(~(a a) b)) ~(c c)))) ~(0
0)) ~(~(~(~(~(~(~(a a) ~(b b)) ~(~(a a) ~(b b))) c) ~(~(~(~(a a) b) ~(~(a a) b)) ~(c c)))
~(~(~(~(~(a a) ~(b b)) ~(~(a a) ~(b b))) c) ~(~(~(~(a a) b) ~(~(a a) b)) ~(c c)))) ~(0 0)))
~(~(~(~(~(a ~(b b)) ~(a ~(b b))) ~(c c)) ~(~(~(a b) ~(a b)) c)) ~(~(~(~(a ~(b b)) ~(a ~(b b)))
~(c c)) ~(~(~(a b) ~(a b)) c))))
carry = ~(~(~(~(b c) ~(a c)) ~(~(b c) ~(a c))) ~(a b))
Auch diese Formeln habe ich aus dem Logisim entnommen. 111
112 Rechnernetze und –Organisation 12.1.11 INKREMENTIEREN Betrachten wir als ein neues Beispiel für eine kombinatorische Schaltung die Funktion „3‐Bit‐Inkrementierer“. Dies soll eine Funktion mit 3 Eingangssignalen sein. Der Ausgang dieser Funktion soll ebenfalls 3 Bit haben und immer die um 1 erhöhte Binärzahl darstellen. Es gibt wiederum genau 8 Fälle zu beachten:  Aus 0 wird 1  Aus 1 wird 2  Aus 2 wird 3  Aus 3 wird 4  Aus 4 wird 5  Aus 5 wird 6  Aus 6 wird 7  Aus 7 wird 0 (denn 8 geht mit 3 Bit ja nicht) Wir können diese 8 Sachverhalte auch in einer Wahrheitstafel darstellen: in2 0 in1
0
in0
0
out2
0
out1
0
out0
1
0 0
1
0
1
0
0 0 1
1
0
1
0
1
1
0
1
0
1 1 0
0
0
1
1
1
0
1
1
0
1 1
0
1
1
1
1 1
1
0
0
0
In ähnlicher Weise wie zuvor gebe ich im Simulator Logisim zuerst die drei Eingangsvariablen in2, in1 und in0 sowie die Ausgangsvariablen out2, out1 und out0 ein. Ich starte mit der Schaltung rno10. Diese habe ich später in 3-bit incrementer umbenannt. Dann wähle ich Project->Analyze Circuit aus und kriege bei „Table“ eine Wahrheitstafel zum Ausfüllen: Hardware von unten
Durch Klicken auf die „x“‐en kannst du „1“‐en und „0“‐en eingeben: Aus 1 mach 2, aus 2 mach 3, usw. Falls du Zeit hast, kannst du auch die Expressions und Minimized studieren. Doch das ist nicht so wichtig hier. Es reicht, wenn du einfach auf Build Circuit klickst und danach auf „OK“ und „Ja“. Bei mir ist folgende Schaltung rausgekommen. 113
114 Rechnernetze und –Organisation Sicherheitshalber (oder auch nur interessehalber) solltest du alle 8 Fälle durchprobieren und überprüfen, ob die Schaltung mit der Spezifikation (= Wahrheitstafel) übereinstimmt. Man weiß ja nie. Jetzt möchte ich dir noch einen Trick zeigen: Statt der drei Eingangsschalter i2, in1 und in0 möchte ich als Alternative einen 3‐Bit‐Schalter verwenden. Und statt der drei Ausgangsleitungen auch einen kombinierten 3‐
Bit‐Ausgang. Diese vermeintlich lediglich kosmetische Operation wird uns später helfen, Schaltungen kompakter darzustellen. Du findest diese Modifikation in der Schaltung rno11. Ich habe die Schaltung aus rno10 zuerst mit Copy und Paste nach rno11 gebracht. Das geht mit Project->Add Circuit. Dann habe ich ein neuen Input‐Pin hinzugefügt und bei dessen Attributen, welche links unten zu finden sind, die Data
Bits auf den Wert 3 gesetzt. Als Label habe ich in verwendet. Dann wähle ich im Menu Base den „Splitter“ aus. Dieser kann aus einer 3‐Bit‐Leitung drei 1‐Bit‐Leitungen machen. Dazu muss man jedoch auch dessen Attribute richtig setzen: Fan-Out = 3, Bit Width In = 3, Facing = East. Diesen füge ich jetzt an den zuvor eingefügten 3‐Input‐Eingang an. Bei mir sieht das Zwischenresultat jetzt so aus: Hardware von unten
Jetzt müssen wir die drei Anschlüsse noch mit der Schaltung verbinden. Wichtig dabei ist, dass wir die richtige Reihenfolge verwenden. Oben ist das Least‐Significant Bit. Man erkennt das, indem man den Cursor auf das obere Ende hält. Nach kurzem Warten erscheint Bit 0 from Combined End. Dieses muss mit dem in0‐
Eingang verbunden werden. Und so weiter. Wir können die Schaltung jetzt bereits wieder testen und sehen, ob wir den Splitter richtig angeschlossen haben. Aus 3 mache 4. Scheint zu passen: Im nächsten Schritt nehmen wir nochmals den „Splitter“ und drehen ihn um. Dies macht man dadurch, dass man das Attribut Facing auf den Wert „West“ setzt. Jetzt ist es also ein „Un‐Splitter“. Damit können wir 3 Ausgangsleitungen zu einem 3‐Bit‐Ausgang zusammen fügen. Dann brauchen wir noch Einen 3‐Bit‐Ausgang. Dazu nehmen wir wie üblich einen 1‐Bit‐Ausgang und verändern dessen Attribut Data
Bits auf 3. Als Label nehmen wir out. Jetzt müssen wir noch die drei Un‐Splitter‐Eingänge mit den 3 Ausgängen der kombinatorischen Schaltung zusammen schließen. Dabei muss man wieder auf die Reihenfolge achten. Oben ist wieder das Least‐Significant Bit. 115
116 Rechnernetze und –Organisation Ich hätte die drei ursprünglich verwendeten Output‐Bits jetzt zwar löschen können, doch was soll’s. Ist ja eh nur zum Probieren hier. Und du kannst das Alles eh selbst weiter machen. In der Schaltung rno12 haben wir die gleiche Logikfunktion wie zuvor. Doch jetzt habe ich am Ausgang einen 4‐
Bit‐Splitter eingefügt und dessen höchstwertiges Bit mit der Konstante 0 verbunden. Solche „Konstanten“ findest du in der Bibliothek unter Gates. Sodann habe ich eine 7‐Segment‐Hex‐Anzeige angefügt. Diese braucht 4 Bits am Dateneingang, deswegen auch der 4‐Bit‐UnSplitter. Dieser Schritt ist zwar ebenfalls nur Kosmetik, sollte dir jedoch zeigen, was man alles so machen kann. 12.1.12 MULTIPLEXER Eine wichtige Logikfunktion müssen wir noch betrachten: den Multiplexer. Er entspricht dem Software‐
Konstrukt „if‐then‐else“. In seiner einfachsten Ausführung, dem 2‐zu‐1‐Multiplexer, kann man ihn so darstellen: if (sel == 0) then
q = d0;
else
q = d1;
Hardware von unten
Als Wahrheitstafel formuliert sieht das dann folgendermaßen aus: sel d1 d0 q
0
0
0
0
0
0
1
1
0
0
1
1
0
1
0
1
1
0
0
0
1
1
0
1
1
0
0
1
1
1
1
1
Zugegebenermaßen ist die Software‐Beschreibung einfacher zu interpretieren als die Wahrheitstafel. Die Wahrheitstafel drückt jedoch genau denselben Sachverhalt aus. Weil Multiplexer sehr oft in Schaltungen vorkommen, verwenden wir ein eigenes schematische Symbol. Lade die Schaltung rno13 und probiere den Multiplexer aus. Wir haben dabei den Multiplexer aus der Abteilung Plexers genommen. Wir haben die Attribute auf die einfachsten Werte gesetzt: Select Bits = 1. Damit entsteht ein 2‐zu‐1‐Multiplexer. Und dann noch Data Bits ebenfalls gleich 1. Damit entsteht ein sogenannter 1‐fach 2‐zu‐1‐MUX. Neben dem hier gezeigten einfachsten Multiplexer (MUX), dem 2‐zu‐1‐Multiplexer, gibt es auch noch „größere“. So zu Beispiel den 4‐zu‐1‐MUX, den 8‐zu‐1‐MUX usw. Meistens 2‐hoch‐n zu 1. Beim 4‐zu‐1‐MUX braucht man zwei Auswahl‐Leitungen zum Auswählen des „selektierten“ Eingangs. Du kannst diese Multiplexer in Logisim ebenfalls erzeugen. Du musst nur das Attribut Select Bits entsprechend wählen. Mit Select
Bits = 3 kriegst du einen 8‐zu‐1‐Multiplexer. Probiere das aus. 12.1.13 ZUSAMMENFASSUNG: KOMBINATORISCHE FUNKTIONEN Damit haben wir in aller Schnelle die sogenannte „kombinatorische Logik“ besprochen. Alle Funktionen dieser kombinatorischen Logikwelt sind „Abbildungen“. In der einfachsten Form wollen wir dabei jede Eingangssituation auf eine definierte Ausgangssituation abbilden. Für technische Realisierungen dieser Funktionen verwenden wir heutzutage in Mikrochips immer Transistoren und elektrisch leitendende Verbindungen zwischen diesen Transistoren. Die Transistoren werden wie Schalter verwendet. Mit der Mikrochiptechnologie haben wir in den letzten Jahrzehnten eine Möglichkeit geschaffen, solche Transistoren auf Siliziumbasis in sehr kleiner Form herzustellen. Auf Mikrochips können wir deshalb viele Millionen, ja sogar Milliarden solcher Schalter samt deren elektrischen Verbindungen zu sehr kleinen Preisen erzeugen. 117
118 Rechnernetze und –Organisation 12.2 RÜCKKOPPLUNG, SPEICHERN UND ENDLICHE AUTOMATEN Wir wollen jetzt ein „neues Element“ in unsere Betrachtung einführen: die Rückkopplung. Was passiert, wenn wir den Ausgang einer kombinatorischen Schaltung auf deren Eingang „rückkoppeln“, also den Ausgang mit dem Eingang verbinden. Zwei prinzipielle Effekte entstehen dabei: Entweder die Schaltung beginnt zu oszillieren oder die Schaltung beginnt, Information zu speichern. Beide Effekte sind nützlich. Oszillatoren brauchen wir in unseren Schaltungen, um die Zeit in Perioden zu zerteilen. In unserem Simulator gibt es einen digitalen Oszillator fertig in der Bibliothek. Wir werden diesen also einfach verwenden. Der Oszillator, wir sagen auch Taktgenerator dazu, oszilliert mit einer bestimmten Frequenz zwischen den beiden logischen Werten 0 und 1. Das Taktsignal nennen wir „Clock“ (clk). 12.2.1 LATCH Die Entstehung des Speichereffektes wollen wir hier genauer betrachten. Wie gesagt: Geeignete Rückkopplung führt dazu. Lade die Schaltung rno14. In dieser Schaltung findest du einen Multiplexer mit (sogenannter „positiver“) Rückkopplung: Der Ausgang wird zurück an den oberen Eingang geführt. Wir nennen diese Schaltung ein „Latch“. „Latch“ bedeutet „Einfangen“. Sehen wir uns an, wie dieses „Einfangen“ des Eingangsbits funktioniert. Wenn du die Simulation einschaltest, dann erscheint der Ausgang q in blau. Auch die angeschlossenen Leitungen werden in blauer Farbe dargestellt. Damit drückt der Simulator aus, dass er nicht weiß, ob am Ausgang 0 oder 1 sein könnte. So ist das mit Speicherelementen immer. Dies ist deshalb der Fall, da der Schalter en auf 0 ist. Der Multiplexer soll also an seinem Ausgang den Wert, den er an seinem oberen Eingang (i0) sieht, legen. Doch welcher Wert, 0 oder 1, dies nach dem Einschalten ist, kann der Simulator derzeit offensichtlich noch nicht heraus finden. Setze den Schalter en auf 1. Jetzt wandelt sich das Blau des Ausgangs in ein Grün. Der Ausgang ist also auf 0. Warum? Weil der untere Eingang (i1) des Multiplexers ebenfalls auf 0 liegt. Schalte den Eingangsschalter d – dieser ist ja mit i1 verbunden. Aha! Was immer am Eingang d liegt erscheint am Ausgang q. Wir nennen dies den „transparenten“ Fall des Latch. Setze also den Eingang d jetzt auf 1 (wobei en auch noch immer auf 1 sein soll). Die Lampe ist auf 1. Jetzt schalte den Eingang en auf 0. Damit versetzt du das Latch in den „opaquen“ Zustand. Wenn du danach den Hardware von unten
Eingang d auf 0 legst, bleibt der Ausgang q jedoch „gespeichert“. Die Rückkopplung ist ja jetzt wirksam. Das 1‐
Bit ist sozusagen eingefangen (= „gelatcht“). Auf ähnliche Art kannst du auch ein 0‐Bit am Eingang d „latchen“. Der wichtige Moment – also der Moment, wo dieses „Einfangen“ passiert, ist der Zeitpunkt, wo das Signal en von 1 auf 0 geht. Da geht das Latch vom transparenten Zustand in den opaquen (= undurchlässigen) Zustand über. Cool, oder? 12.2.2 FLIPFLOPS Eine Überlegung fehlt jetzt noch. Damit möchte ich dir zeigen, wie man ein sogenanntes „D‐Flipflop“ baut. Dieses „D‐Flipflop“ funktioniert wie ein 1‐Bit‐Fotoapparat. Mit einem „Klick“ können wir das Bit am Eingang „fotografieren“ und am Ausgang danach ansehen. Dieser „Fotoapparat“ kann jedoch lediglich das zuletzt fotografierte Bit speichern. Wie baut man einen solches Flipflop? Man schaltet zwei Latches in Kette und betreibt diese „zeitversetzt“: Wenn das eine im transparenten Modus ist, soll das andere opaque sein, und umgekehrt. Du siehst diese Schaltung in rno15. Mit einem Inverter erzeugen wir die zueinander „gegengleiche“ Operation der beiden Latches. Diese Schaltung kann etwas Phänomenales: Wann immer der Taktgenerator sein Bit von 0 auf 1 setzt, klickt der Fotoapparat sozusagen – und das Bit am Eingang d erscheint am Ausgang q – bis zum nächsten Klick. Simuliere die Schaltung. Du siehst, wie die beiden Latches jeweils gegengleich transparent und opaque sind. Interessant ist auch, dass vor der ersten steigenden Taktflanke, also vor dem ersten „Klick“, kein Foto am Ausgang zu sehen ist – der Ausgang erscheint in blauer Farbe mit einem x. Dies ist im Bild oben auch dargestellt. In anderen Worten: Der Simulator kann vor der ersten steigenden Taktflanke keine Aussage treffen. 119
120 Rechnernetze und –Organisation Deshalb Blau als ein Symbol für „unbekannter Wert“. Ein wirkliches Flipflop würde nach dem Einschalten jedoch schon einen Wert am Ausgang haben – wir können lediglich nicht vorhersagen, welcher Wert dies sein wird. Der Simulator zeigt uns dieses Problem mit dem Anfangswert mit der Farbe Blau an. Das Problem mit dem Initialwert haben wir immer wieder. Du kennst es vermutlich vom Programmieren her. Uninitialisierte Werte können ganz schön lästig sein. Deshalb bevorzugen wir auch oft Flipflops mit einem sogenannten „asynchronen Rücksetzeingang“. Über diesen können wir gleich nach Einschalten den gespeicherten Wert auf 0 initialisieren. Manches Mal sagen wir zu diesem Flipflop auch „Master‐Slave‐Flipflop“, weil die beiden Latches in seinem Inneren sich so wie Master und Slave verhalten. Naja. Meinetwegen. In seiner Langform heißt dieses Flipflop also „positiv‐flankengetriggertes Master‐Slave‐D‐Typ‐Flipflop“. Ich nenne es fortan nur Flipflop. Und wenn wir mehrere Flipflops zusammen als Einheit betrachten, dann nenne ich es auch oft „Register“. Das Attribut „positiv“ bezieht sich hier auch die „positive Flanke“ des Taktsignals: Wenn das Taktsignal clk von 0 auf 1 geht, dann „klickt“ es. Beim Bau von Flipflops entsteht neben dem Ausgang q auch meist der invertierte Ausgang „q quer“. Aus historischen Gründen wird dieser Ausgang meist mit gezeichnet. Übrigens zum Thema „Historisches“: Es gibt in der Literatur und in vielen Köpfen noch immer jede Menge anderes relativ „unnützes“ Wissen zum Thema Flipflop. Ist nicht mehr wichtig. Weder hier noch überhaupt. Dazu gehören RS‐, JK‐ oder T‐Flipflops. Wie gesagt: Alles unwichtig. Was mir jedoch wichtig erscheint, ist ein Phänomen, welches wir „Emergenz“ nennen. Das Flipflop „erkennt“ die positive Taktflanke, obwohl keines seiner Bestandteile etwas von Flanke „weiß“. Wir können in der Schaltung keinen „Flankendetektor“ finden. Die Gesamtheit der beiden Latches plus Inverter „weiß“, wann eine positive Taktflanke „ist“ und „fotografiert“ bei deren Auftreten den Eingang d. Die Eigenschaft, „positive Flanken zu erkennen“ emergiert also aus der Gesamtheit dieser Schaltung. Interessant ist auch, dass diese Schaltung keine Ahnung von „negativen Flanken“ hat. Emergenzen findet man immer wieder bei natürlichen und artifiziellen Systemen und gehören meiner Meinung nach zu den interessantesten Erscheinungen. 12.2.3 DAS PROBLEM MIT DEM ANFANGSZUSTAND Im unteren Teil der Schaltung rno15 siehst du ein D‐Flipflop mit Reset‐Eingang. Das Flipflop wurde aus dem Bibliothekskasten (Abteilung Memory) des Simulationsprogrammes genommen. Dieses Flipflop hat zusätzlich zu den Eingängen clk und d noch drei weitere. Davon interessieren uns vorerst die beiden mit 1 und 0 bezeichneten Eingänge auf der Südseite des Symbols. Der 0‐Eingang ist ein sogenannter „asynchroner Rücksetz‐Eingang“. Mit diesem kann man den gespeicherten Wert im Flipflop unabhängig vom Taktsignal, also asynchron, auf 0 setzen. Damit kann man zum Beispiel den Anfangszustand von Flipflops definieren. Hardware von unten
In ähnlicher Weise könntest du den 1‐Eingang dafür verwenden, den gespeicherten Wert im Flipflop asynchron auf 1 zu setzen. Nebenbei: Der mit en bezeichnete Eingang erlaubt es, positive Taktflanken zu „ignorieren“. Dazu werden wir später noch kommen. 12.2.4 ENDLICHE AUTOMATEN Alle digitalen Schaltungen bestehen nur – nochmals: nur – aus kombinatorischen Funktionen und aus Flipflops. Sonst nichts. Naja, und dann noch einem Oszillator. Nachfolgend will ich dir an Hand einiger Beispiele zeigen, dass dies wirklich der Fall ist. Dabei wirst du das Konzept der „Maschine“ kennen lernen. Wir sagen auch „Automat“ dazu. Oder genauer: „endlicher Automat“ und auf Englisch: „Finite State Machine“ (FSM). Das Attribut „endlich“ kommt daher, dass alle real existierenden und real möglichen Maschinen mit den endlich vielen Bauteilen unseres Kosmos auskommen müssen und deshalb „endlich“ groß sind. Aus Sicht der theoretischen Informatik ist dies sehr wichtig. Denn denken können wir uns auch unendlich große Maschinen. 12.2.5 ZÄHLER Als ersten Automat sehen wir uns einen Zähler an. Denke etwa an eine Uhr. Die zählt auch. Oder an den Kilometerzähler in einem Fahrzeug. Unser Zähler soll von 0 aufwärts bis 7 zählen. Und dann wieder mit 0 beginnen. Mit Binärzahlen also 000, 001, 010, 011, 100, 101, 110, 111 und dann wieder 000. Die Geschwindigkeit des Zählens hängt vom Taktgenerator ab. Wie bei einer altmodischen Pendeluhr: vom Pendel. Was brauchen wir dazu? Drei Flipflops zum Speichern der Zahl, welche soeben „dran“ ist. Dann einen Inkrementierer – jetzt weißt du, wofür wir diesen schon weiter oben besprochen haben. Also ein Element, welches aus einer Zahl die nachfolgende Zahl ermitteln kann. Und ein paar Lampen, sodass wir den Zählerstand anzeigen können. Schließlich noch ein Pendel, also einen Taktgenerator. Die Schaltung rno16 hat das alles drin. Doch bevor du verstehst, wie ich diese Schaltung erzeugt habe, sollten wir nochmals zur Schaltung rno11 zurück gehen. In rno10 hatten wir ja den Inkrementierer. Diesen Modul nenne ich inc (steht für „increment“) und werde ihn danach in rno16 für den Zähler verwenden. Klicke dazu rno10 links und definiere das Attribut „Label“ von rno10 mit inc. Das geht links unten. Schließlich setze ich das Attribut Label Facing noch auf den Wert „North“. Vielleicht wäre es auch noch sinnvoll, das Attribut Circuit Name links unten auf 3-bit incrementer zu setzen. Probiere das aus. Jetzt verändert sich links rno10 zu 3-bit incrementer. 121
122 Rechnernetze und –Organisation In meiner neuen Schaltung rno16 kann ich jetzt einen 3-bit incrementer auswählen und auf der Zeichenfläche platzieren. In Analogie zu C++ sollten wir wohl „instanzieren“ sagen. Du siehst jetzt den von dir generierten Bibliotheksmodul inc mit seinen Anschlüssen. Die blaue Farbe der Anschlüsse zeigt, dass es sich um 1‐Bit‐Anschlüsse handelt, deren Wert vorerst noch unbekannt ist. [Mit der Farbe Schwarz stellt der Simulator Mehr‐Bit‐Anschlüsse dar.] Wenn du mit dem Maus‐Zeiger auf einen Anschluss zeigst, dann kriegt dieser einen grünen Kreis und kurz danach erscheint auch der Name des Anschlusses. [Wenn das bei dir nicht gleich geht: Vorher außerhalb des Symbols einmal klicken, dann geht es.] Jetzt fehlen noch 3 Flipflops. Diese findet man links unter Memory. Ich instanziere 3 Stück davon. Ich hätte auch meine selbst gemachten Flipflops von zuvor verwenden können. Doch diese haben ein Problem, welches ich jetzt nicht behandeln möchte: Sie haben keinen asynchronen Rücksetzeingang. Deshalb verwende ich gleich die vom Simulator mitgelieferten D‐Flipflops aus der Abteilung Memory. Diese haben den asynchronen Rücksetzeingang eingebaut. Zudem sind diese so „voreingestellt“, dass sie bei der Simulation so funktionieren, als wäre dieser Rücksetzeingang schon „angelötet“ und kurz zu Beginn der Simulation „aktiviert“ worden. Die Flipflops sind also bei Instanzierung schon auf 0 initialisiert. Du siehst dies im nachstehenden Bild daran, dass bei jedem Flipflop schon eine 0 „drin“ ist. Danach hänge ich den Taktgenerator an die Takteingänge der 3 Flipflops. Der gespeicherte 3‐Bit‐Wert in unserem 3‐Bit Register (dreg2, dreg1 und dreg0) wird jetzt mit dem Eingang des Moduls inc verbunden. Dann verbinden wir den Ausgang des Moduls inc mit dem 3‐Bit‐D‐Eingang des Registers. Hardware von unten
Jetzt kann unser 3‐Bit‐Fotoapparat beim Klick das „vorher gemachte“ Bild, welches im Modul inc „um 1 erhöht“ wurde, bei der steigenden Flanke des Taktsignals clk „fotografieren“ und als dann „neues“ 3‐Bit‐Bild an seinen 3 Q‐Ausgängen anbieten. Probiere das aus. Du musst lediglich manuell die Rolle des Oszillators übernehmen und das Eingangssignal clk klicken. Immer wenn sich das Signal clk von 0 auf 1 ändert, wird das in den Flipflops gespeicherte Bitmuster „um 1 erhöht“. Natürlich sind es lediglich 3 einzelne Bits, welche wir als 3‐Bit Zahl interpretieren. Wir könnten noch eine „schönere“ Anzeige hinzufügen. Wir nehmen dazu ein Hex Digit Display aus der Abteilung Input/Output. Dieses Element erwartet einen 4‐Bit‐Eingang. Wir haben jedoch nur 3 1‐Bit‐
Ausgänge. Wir brauchen also wie schon früher gezeigt einen 4‐Bit‐Un‐Splitter (Splitter mit Orientierung „West“). Mit diesem können wir aus 4 einzelnen Leitungen ein 4‐Bit‐Leitungsbündel machen. Wichtig ist hier, dass du verstehst, dass dieser Un‐Splitter nur zur vereinfachten Darstellung im Simulator notwendig ist. Er hat in der wirklichen Schaltungswelt keine Relevanz. Wir schließen die 3 Bits an und das 4. Bit setzen wir konstant auf 0. Du findest die Konstante bei den Gates ganz oben. Auch auf die Reihenfolge der Leitungen musst du aufpassen. Der Un‐Splitter hat das niederwertigste Bit ganz oben. Schließlich verbinden wir noch das Ost‐Ende des Un‐Splitters – dieser stellt jetzt ein Bündel von 4 Drähten mit einem schwarzen Strich dar – mit dem Display. Damit ist der 3‐Bit‐Zähler fertig. Jetzt könntest du noch den Takteingang statt mit einem Eingangsschalter mit einem echten Taktgenerator versehen. Du findest den Taktgenerator in der Abteilung Base. Um diesen „zum Leben zu erwecken“, musst du noch Simulate->Ticks Enabled einschalten. Und schon zählt der Zähler. Wenn du jetzt nicht begeistert bist, dann kann ich dir nicht helfen. Wir sprechen hier von einem synchronen Automaten: Alle Speicherelemente des Automaten werden synchron mit dem gleichen Signal getaktet. Das Signal clk teilt die Zeit in Zeitperioden ein. Jede Periode beginnt mit einer fallende Taktflanke und dauert bis zur nächsten fallenden Taktflanke. In jeder Periode besitzt unser Automat einen Zustand. Dieser wird durch die in den 3 Flipflops gespeicherten Werte dargestellt. Unser Automat besitzt 8 Zustände. Er läuft auch alle diese Zustände durch. In jedem Zustand wird durch eine kombinatorische Funktion bestimmt, welcher der nächste Zustand sein wird. Diese kombinatorische Funktion ist bei uns der Modul inc. 123
124 Rechnernetze und –Organisation 12.2.6 ZUSTANDSDIAGRAMME Wir können das Verhalten des Automaten auch mit einem Zustandsdiagramm darstellen. Jeder Zustand wird durch ein Rechteck dargestellt. Die Pfeile zwischen den Rechtecken drücken den Übergang zwischen Zuständen aus, welcher genau zur fallenden Flanke des Taktsignals „passiert“. Neben dem Rechteck gibt es einen Namen für den Zustand. Ich habe der Einfachheit halber die Namen „000“ bis „111“ vergeben. [Ich hätte jedoch genauso gut jeden anderen Namen geben können.] In das Rechteck schreiben wir hinein, welchen Output der Automat in jedem Zustand haben soll. Bei diesem einfachen Automaten ist der Output „identisch“ mit dem Zustand. Wir werden beim nächsten Beispiel sehen, dass dies nicht immer so ist. Wesentlich ist auch der Anfangszustand. Dieser soll bei uns immer der zuoberst Gezeichnete sein. In diesem Fall also der Zustand mit dem Namen „000“. Als wesentlich für den Automaten sehen wir also vorerst folgende Situation:  Es gibt einen Anfangszustand  Jeder Nachfolgezustand ist über eine kombinatorische Funktion aus dem gegenwärtigen Zustand berechenbar: next_state = f(state) Wir werden in der Folge noch ein paar weitere wesentliche Eigenschaften von Automaten kennen lernen. Hardware von unten
12.2.7 BEISPIEL „LAUFLICHT“ Als Nächstes wollen wir unseren Automaten erweitern, sodass er 5 Lampen in einer Lauflicht‐ähnlichen Situation der Reihe nach aufleuchten lässt. Am besten ist es, du ladest die Schaltung rno17 und startest die Simulation. Sieh dir die Lampen L0 bis L4 an: Sie leuchten von links nach rechts laufend und dann wieder zurück. Was ist neu hier? Wir haben den Automaten mit einer sogenannten Output‐Logikfunktion mit Namen „o log“ erweitert. Diese Output‐Logikfunktion verwendet den Zustand des Automaten (also die Ausgänge der Flipflops) als Eingang seinerseits und ermittelt daraus die gewünschte Situation am Ausgang ‐‐ bei uns also das „Lauflicht“. Sehen wir uns an, wie man diese Logikfunktion aus der Aufgabenstellung entwickeln kann. Wir möchten also die 5 Lampen der Reihe nach aufleuchten lassen: L0, L1, L2, L3, L4 und dann wieder zurück, also L3, L2, L1. Jetzt soll die ganze Sequenz wieder von vorne beginnen: Also L0, L1 usw. Insgesamt haben wir es also mit einer Wiederholung der Sequenz L0, L1, L2, L3, L4, L3, L2, L1 zu tun. Wir können deshalb unseren Zähler von vorhin nehmen. Dieser Zähler hat ja genau 8 Zustände. Diese 8 Zustände bilden wir jetzt auf die Sequenz der leuchtenden Lampen ab. Das können wir mit folgender Wahrheitstafel darstellen: q2 0 q1
0
q0
0
L0
1
L1
0
L2
0
L3
0
L4
0
0 0 0
1
1
0
0
0
1
0
0
1
0
0
0
0
0 1
1
0
0
0
1
0
1 1 0
0
0
1
0
0
0
0
0
0
0
1
1
0
1 1 1
1
0
1
0
0
0
1
1
0
0
0
0
0
Wir wollen diese Wahrheitstafel mit Hilfe der Simulator‐Funktion Project->Analyze Circuit implementieren. Dazu starten wir eine neue Schaltung. Ich habe diese rno17a genannt. In dieser tragen wir Eingänge und Ausgänge unserer Logikfunktion ein: 125
126 Rechnernetze und –Organisation Jetzt öffnen wir das Fenster Projekt->Analyze Circuit und darin die Abteilung Table. In dieser WahrheitStafel tragen wir jetzt die Einsen und Nullen wie oben vorgegeben ein. Der Rest ist eigentlich fad. Wir drücken auf Build Circuit und ein paarmal auf „OK“ und kriegen unten stehenden „Gatterfriedhof“. Wir können, wenn wir unsicher sind, die 8 Eingangskombinationen ausprobieren und die Ausgänge überprüfen, ob diese mit der Vorgabe aus der Wahrheitstafel übereinstimmen. Schließlich können wir dieser Schaltung auch einen Namen geben. Das machen wir, indem wir links unten das Attribut Label auf „o log“ (mir ist nichts Besseres eingefallen) geben. Hardware von unten
Schließlich fügen wir diesen Modul im Schaltkreis rno17 ein, verbinden alles und erhalten die Schaltung wie schon weiter oben gezeigt. Wie hat sich jetzt das Zustandsdiagramm unseres Automaten verändert? Die Zustandsabfolge ist die gleiche wie im Beispiel zuvor. Lediglich den Ausgang des Automaten haben wir jetzt anders gestaltet. Und mit Ausgang meine ich jetzt die 5 Lampen. Das Zustandsdiagramm des Automaten sieht so aus: 127
128 Rechnernetze und –Organisation Die Namen der Zustände sind nach wie vor 000 bis 111. In die Rechtecke, welche die Zustände darstellen, habe ich den „aktiven“ Ausgang, also die leuchtende Lampe, eingetragen. Alle „passiven“, also ausgeschalteten Ausgänge kommen nicht vor. Wir hätten die Situation auch etwas expliziter darstellen können. So könnten wir etwa im Zustand 000 die Ausgänge auch so definieren: L0 = 1; L1 = 0; L2 = 0; L3 = 0; L4 = 0; Das Wesentliche unseres Automaten ist also: ‐ Die Zustände und deren Abfolge ‐ Die Ausgänge als Funktion des jeweiligen Zustands Was wir noch nicht besprochen haben, sind Eingänge von Automaten. Damit meinen wir Eingänge, welche die Abfolge der Zustände beeinflussen können. Wir meinen hier nicht den Takteingang und den Reset‐Eingang (der bei uns auf Grund der verwendeten Flipflops nicht explizit in der Simulation aufscheint). Der Takteingang ist für jeden Automaten notwendig. Und der Reset‐Eingang kommt auch bei vielen vor. 12.2.8 LAUFLICHT MIT START/STOPP‐FUNKTION Wir möchten z.B. unseren Lauflicht‐Automaten mit einem Eingangsschalter „einfrieren“, also anhalten können. Und wir wollen dies nicht durch Anhalten des Taktes realisieren. Dann könnten wir folgendermaßen vorgehen: Als Nachfolgezustand nehmen wir nicht den um 1 erhöhten Wert sondern „den um 0 erhöhten Wert“. Wir speichern also als Nachfolgezustand den gegenwärtigen Zustand ab. Mit drei 2‐zu‐1‐Multiplexern können wir zwischen diesen beiden Situationen umschalten. Lade die Schaltung rno18. Dort findest du den Automaten samt dem neuen Schalter enable. Solange das Eingangssignal enable den Wert 0 hat, ändert der Automat seinen Zustand nicht. Oder genauer gesagt: der Nachfolgezustand ist jeweils gleich dem Vorgänger. Wenn jedoch das Eingangssignal enable den Wert 1 hat, dann schalten die 3 2‐zu‐1‐Multiplexer den Wert vom Modul inc durch. Wie verändert sich durch diesen Eingang enable (= en) jetzt das Zustandsdiagramm? Hardware von unten
Und jetzt noch ein paar Kosmetika. Du kennst ja bereits den Splitter bzw. Un‐Splitter von vorhin. In Logisim ist es möglich, mehrere (üblicherweise zusammen gehörende) Leitungen zu einem einzigen Bündel zusammen zu fassen. Dies wollen wir jetzt mit der obigen Schaltung machen. Wir verändern also nicht die Schaltung an und für sich, sondern nur deren Darstellung im Simulator bzw. am Bildschirm. Als erstes werden wir die drei D‐Flipflops zu einem einzigen 3‐Bit‐Flipflop zusammen fassen. Üblicherweise nennt man solche Mehr‐Bit‐Flipflops „Register“. Du findest Register in der Bibliothek links in der Abteilung Memory. Um ein 3‐Bit‐Register zu kriegen, muss man das Attribut Data Bits auf den Wert 3 setzen. Zusätzlich können wir auch die drei zusammen gehörenden 2‐zu‐1‐Multiplexer zusammen fassen. Man spricht dann von einem „2‐zu‐1‐Multiplexer mit einer Datenbreite von 3 Bit“ oder einem „3‐fach‐2‐zu‐1‐MUX“. Siehe dir die Attribute des 2‐zu‐1‐Multiplexers an. Mit dem Attribut Data Bits kannst du die Datenbreite einstellen. Den Modul inc haben wir ja bereits weiter oben selbst gemacht. Dort habe ich dir bei rno11 gezeigt, wie man Eingänge und Ausgänge mit mehreren Bits darstellen kann. Bleibt noch unsere Output‐Logik „o log“ für das Lauflicht. Hier könnten wir zwar die drei Eingänge ebenfalls zusammen fassen, doch ich schlage vor, dass wir hier einfach einen Splitter verwenden. Der „Drahtverhau“ auf der Ostseite des Splitters entsteht leider dadurch, dass der Splitter die Bits in (aus Sicht der Output‐Logik) verkehrter Reihenfolge liefert. Siehe dir das Ergebnis im Bild unten an. Die Schaltung sieht jetzt sehr aufgeräumt aus. Doch sie ist gleichzeitig wohl auch schwieriger zu verstehen, wenn man nicht schon „vorbelastet“ ist. 129
130 Rechnernetze und –Organisation Ein Trick, wie man die Übersicht bewahren kann, liegt im Hinzufügen von „Messinstrumenten“. Im nachfolgenden Bild habe ich sogenannte „Probes“ an die Leitungen angehängt. Du findest Probes in der Bibliotheksabteilung Base. 12.2.9 MOORE‐ UND MEALY‐AUTOMATEN Der obige Automat hat jetzt alle typischen Elemente eines synchronen Automaten. Man nennt solche Automaten auch Moore‐Automaten. Was sind die typischen Elemente? Fassen wir zusammen: ‐ Der Automat hat Flipflops, welche jeweils seinen Zustand speichern. Bei n Flipflops gibt es maximal 2n Zustände. Es müssen jedoch nicht alle möglichen Zustände „vorkommen“. Diese Flipflops wollen wir mit Hilfe eines Reset‐Signals kurz nach Einschalten des Automaten auf den definierten Wert 0 initialisieren. [Dieses Rücksetzen wird bei den von uns verwendeten Flipflops und Registern des Simulators bereits automatisch zu Beginn der Simulation vorgenommen]. ‐ Der Zeitpunkt des Zustandswechsels wird durch die „aktive“ Taktflanke, bei uns ist das die Änderung des Taktsignals von 0 auf 1, verursacht. ‐
In jedem Zustand wird mit Hilfe einer kombinatorischen Logikfunktion – die sogenannte „Next‐State‐Logik“ – der Nachfolgezustand ermittelt. Dieser wird dann bei der aktiven Taktflanke als Zustand gespeichert. Diese Next‐State‐Logik f() ermittelt aus dem gegenwärtigen Zustand und aus den Eingängen den nächsten Zustand: next_state = f(state, input)
Hardware von unten
‐
Die Ausgänge des Moore‐Automaten werden über eine kombinatorische Funktion des Zustandes des Automaten ermittelt. Wir nennen diese Funktion die „Output‐Logik“ g(): output = g(state)
Das generelle Strukturbild eines jeden Moore‐Automaten sieht also so aus: Ich betone: Jeder Moore‐Automat sieht so aus. Und jede noch so komplexe synchrone Schaltung kann man im Prinzip als Moore‐Automat realisieren. Auch deinen Computer. Neben dem Moore‐Automaten gibt es auch noch den Mealy‐Automaten. Beim Mealy‐Automaten gibt es zusätzlich noch einen Einfluss des Einganges auf den Ausgang: output = g(state, in) 12.2.10 JETZT BIST DU DRAN: AMPELSTEUERUNG Du solltest jetzt in der Lage sein, dir irgendeine andere Lampensteuerung als Moore‐Automat vorstellen zu können. Die Steuerung einer Verkehrsampel zum Beispiel. Du beginnst mit dem Entwurf des Zustandsdiagramms und ermittelst daraus die beiden kombinatorischen Funktionen „Next‐State‐Logik“ und „Output‐Logik“. Die Anzahl der Zustände bestimmt die Anzahl der notwendigen Flipflops. Eine österreichische Ampel hat etwa folgende Ausgangsfolge: ‐ grün ‐ grün blinkend ‐ gelb ‐ rot ‐ rot‐gelb 131
132 Rechnernetze und –Organisation ‐ (und dann wieder grün) Wir könnten diese Ausgangsfolge etwa mit 16 Zuständen darstellen. Ich nenne die Zustände entsprechend der Hex‐Zahlen 0,1,2,…,E,F. Aus Übersichtlichkeitsgründen habe ich die Nullen in der Wahrheitstafel weggelassen. Zudem stehen die Hex‐Ziffern für jeweils 4 Bits. Die Grünphase dauert 4 Taktzyklen, die Rotphase ebenfalls. Zustand Rot Gelb
Grün
0
1
1
1
2
3
1
1
4
5
6
1
7
1
8
9
1
1
A
B
1
1
C
1
D
E
1
1
1
F
1
1
Was brauchen wir? 4 Flipflops bzw. ein 4‐Bit‐Register. Einen 4‐Bit‐Inkrementierer als „Next‐State‐Logik“. Damit können wir schon einen Zähler bauen, der die Sequenz 0 bis F zählt. Oder noch besser: Du verwendest gleich einen 4‐Bit‐Zähler aus der Bibliothek. Und dann fehlt noch die Output‐Logik. Die Wahrheitstafel dazu siehst du oben. Probiere das mal selbst aus. Übrigens: Du kannst du die Farben der Lampen (LED bei Input/Output) verändern. Du musst lediglich das Attribut Color entsprechend setzen. Für die Mama oder den kleinen Bruder bzw. die kleine Schwester. Das Bild oben zeigt die Schaltung: Ein 4‐Bit‐Zähler zählt von 0 bis F. Die 4 Ausgänge des Zählers sind an einer Implementierung der Output‐Logik angeschlossen. Diese Output‐Logik habe ich mir von Logisim aus der Wahrheitstafel „ausrechnen lassen“. Ich habe im Bild oben den Zustand „E“ festgehalten. Der Zähler steht also auf hexadezimal „E“. Und wie in der Tabelle festgehalten, sind am Ausgang die Lampen „Rot“ und „Gelb“ eingeschaltet. Hardware von unten
12.3
AUF GEHT‘S IN RICHTUNG CPU Du findest die Schaltungen zu diesem Teil in der Datei rno_Teil_D_ram44.circ. 12.3.1 RAM UND SO WEITER Sehen wir uns als Nächstes den Aufbau eines RAM („Random Access Memory“) an. Dabei wollen wir auch wieder ganz einfach beginnen. Mit zwei Latches und einem Inverter, welche zusammen ein D‐Flipflop darstellen. Du findest diese Schaltung unter dem Namen flipflop. Du solltest dich jetzt daran erinnern, warum wir einen Multiplexer samt Rückkopplung Latch nennen. Und wie so ein Latch funktioniert. Darüber haben wir ja schon weiter oben ausführlich gesprochen. Zur Erinnerung: Unsere D‐Flipflops funktionieren wie ein 1‐Bit‐Fotoapparat. Wenn der Takt „klickt“, in unserem Fall also das Taktsignal von 0 auf 1 geht, dann wird der momentane Wert am D‐Eingang „fotografiert“ und erscheint bis zur nächsten steigenden Taktflanke am Ausgang q. Wir wollen jetzt basierend auf diesem D‐Flipflop ein sogenanntes 4‐mal‐4‐Bit‐RAM aufbauen. Im TOY‐
Computer haben wir auch RAMs: Der Register‐File mit den 16‐Bit‐Registern R0, R1, R2,…, RE, RF ist zum Beispiel einem 16‐mal‐16‐Bit‐RAM ziemlich ähnlich. (Wenn wir davon absehen, dass bei TOY das Register R0 eigentlich kein „echtes“ Register ist, da es ja immer nur den Wert 0 liefert). Der Hauptspeicher von TOY ist ein 256‐mal‐
16‐Bit‐RAM, wenn man davon absieht, dass die Speicherstelle mit der Hex‐Adresse FF „anders“ funktioniert. Kommen wir zurück zum 4‐mal‐4‐Bit‐RAM. Wir wollen also 4 Speicherworte zu je 4 Bit haben. Jedes Speicherwort hat eine Adresse. Wir kommen hier mit den Adressen 0, 1, 2 und 3 aus. Binär betrachtet also 00, 01, 10 und 11. Als Besonderheit wollen wir bei unserem RAM sowohl Schreibe‐Adressen (wr_addr) als auch davon getrennte Lese‐Adressen (rd_addr) vorsehen. Damit können wir ein Wort lesen, während in ein möglicherweise anderes RAM‐Wort ein neuer Wert geschrieben wird. Unser RAM ist ein getaktetes RAM. Es besitzt also auch einen Takteingang (clk) und reagiert – wie alle unsere Automaten – auf die steigende Taktflanke. 133
134 Rechnernetze und –Organisation 12.3.2 REGISTER MIT LADE‐EINGANG Sehen wir uns an, wie wir ein Bit unseres RAMs bauen können. Unser D‐Flipflop von oben hat noch ein Problem: Es fotografiert ja zu jeder fallenden Taktflanke. Wir brauchen aber ein Speicherelement, welches nur bei „ausgesuchten“ Taktflanken „fotografiert“. Dazu verwenden wir zusätzlich zum „nackten“ Flipflop von oben noch einen weiteren Multiplexer. Wir machen sozusagen einen kleinen Automaten mit den beiden Eingängen d und ld. Die Abkürzung ld steht für „load“. Du findest das Ganze in der Schaltung reg_w_load. Probiere die Schaltung aus. Nach Start der Simulation zeigt die Lampe die Farbe Blau. Damit sagt uns der Simulator, dass der im Flipflop gespeicherte Wert unbekannt ist. So ist das ja immer. Und wir haben hier keinen Reset‐Eingang. Brauchen wir auch nicht. Setze den Lade‐Eingang ld auf 1 und bei der nächsten steigenden Taktflanke wird der Wert am Eingang d im Flipflop gespeichert. Jetzt kannst du weitere Tests machen: Das Resultat dieser Tests sollte sein, dass du verstehst, dass das Flipflop nur dann bei einer steigenden Taktflanke den Wert am Eingang d „fotografiert“, wenn auch der Lade‐Eingang ld aktiviert ist. Ansonsten „fotografiert“ es sozusagen den bereits gespeicherten Wert. Von außen betrachtet sieht das dann so aus, als wäre kein neues Foto gemacht worden. Lies diesen Absatz sicherheitshalber nochmals. Bis du ihn verstehst. Mit dieser Schaltung haben wir also ein „komfortableres“ Flipflop gemacht. Es besitzt jetzt einen zusätzlichen Lade‐Eingang (ld). Beachte, dass es sich um einen kleinen Moore‐Automaten handelt. Der Multiplexer stellt die Next‐State‐Logik dar. Und die Output‐Logik ist die „Identitätsfunktion“. (Also was rein kommt, kommt 1‐zu‐1 raus). Um dies klar zu machen, „zeichne“ ich obige Schaltung nochmals unter Verwendung eines D‐Flipflops: Die rechten beiden Multiplexer in obiger Schaltung stellen ja zusammen mit dem Inverter ein D‐Flipflop dar. Du findest diese Schaltung unter dem Namen reg_w_load_1. Hardware von unten
12.3.3 EIN 4‐BIT‐REGISTER Nehmen wir als Nächstes 4 Stück von der obigen Schaltung und hängen die Lade‐Eingänge zusammen. Du findest diese Schaltung in der Datei reg4_w_ld. (Das „w“ im Dateinamen steht für „with“). Diese Schaltung ist ein 4‐Bit‐Register. Es hat einen 4‐Bit‐Dateneingang (d3, d2, d1, d0) und einen 4‐Bit‐Datenausgang (q3, q2, q1, q0). Wenn man den Lade‐Eingang ld aktiviert, dann wird der Dateneingang bei der nachfolgenden aktiven Taktflanke gespeichert und der gespeicherte Wert erscheint am Datenausgang. Im Bild siehst du, dass alle 4 Flipflops offensichtlich noch nie einen Wert geladen haben. Deshalb signalisiert der Simulator den Wert „x“ am Ausgang. Also „unbekannt“. Setze einen neuen Wert für {d3, d2, d1, d0}. Schalte den Eingang ld auf 1. Und verursache dann eine steigende Taktflanke bei clk. Du siehst, dass der D‐Wert jetzt am Ausgang erscheint. Für unser RAM brauchen wir nun 4 Stück obiger Register. Zusätzlich brauchen wir für jede der 4 Bit‐Positionen einen 4‐zu‐1‐Multiplexer. Mit diesem selektieren wir mit Hilfe der 2 Adressleitungen rd_addr das zu lesende Speicherwort und bringen dessen Inhalt zum Datenausgang. Doch gehen wir Schritt für Schritt vor: Zuerst bauen wir uns einen 4‐zu‐1‐Multipler. Und dann einen Adress‐
Dekoder. Und dann bauen wir das Ganze zusammen. 12.3.4 NOCHMALS MULTIPLEXER In dieser Abteilung möchte ich dir zeigen, wie man aus drei 2‐zu‐1‐Multiplexern einen 4‐zu‐1‐Multiplexer erzeugen kann. Du findest diese Schaltung unter mux4_to_1. 135
136 Rechnernetze und –Organisation Wie immer solltest du diese Schaltung ausprobieren. Der MUX hat 4 Dateneingänge (d0, d1, d2 und d3). Mit Hilfe der beiden Auswahl‐Eingänge sel1 und sel0 kannst du bestimmen, welcher Dateneingangswert am Ausgang des Multiplexers erscheint. Du könntest dir auch eine Wahrheitstafel dieser kombinatorischen Funktion aufstellen. Doch, ehrlich gesagt, mir wäre diese Wahrheitstafel schon zu groß. Der 4‐zu‐1‐MUX hat 6 Eingänge. Damit hat die Wahrheitstafel insgesamt 7 Spalten und 26 + 1 = 65 Zeilen. Da betrachte ich den Multiplexer schon eher so: if (sel1 == 1) {
if (sel0 == 1)
q = d3;
else
q = d2;
}
else {
if (sel0 == 1)
q = d1;
else
q = d0;
}
Warum haben wir diese Übung hier gemacht? Es gibt im Simulator ja schon 4‐zu‐1‐Multiplexer als Bibliothekselement zur Auswahl. Die Antwort ist leicht: Zum Üben. Zum Verstehen. Wir haben weiter oben ja auch D‐Flipflops gebaut, um deren Aufbau zu verstehen, obwohl es schon fertige Flipflops mit allem Komfort als Bibliothekselement im Simulator gibt. Wenn du alles über das Auto‐Bauen lernen möchtest, solltest du (eventuell in einem Automobil‐Simulator) ein Auto Stück für Stück zusammen setzen. Du darfst später jedoch auch ein fertiges Auto kaufen und fahren. Dies ist kein Widerspruch. 12.3.5 ADRESSDEKODIERUNG Für unser RAM brauchen wir jetzt noch eine neue kombinatorische Funktion, nämlich einen sogenannten „Dekoder“. Dieser ist notwendig, um eine Abbildung aus der 2‐Bit‐Adresse wr_addr auf die 4 Lade‐Eingänge unserer Register vorzunehmen. Man nennt diesen Teil im RAM deshalb auch Adressdekoder. Du findest die Schaltung in der Datei decoder. Hardware von unten
Die Wahrheitstafel dieses Dekoders sieht so aus: en a1 a0
0 0
0
0 0
1
q0
0
0
q1
0
0
q2
0
0
q3
0
0
0 0 1
1
0
1
0
0
0
0
0
0
0
0
1 0
0
1
0
0
0
1 1 0
1
1
0
0
0
1
0
0
1
0
0
1 1
1
0
0
0
1
Die obere Hälfte dieser Wahrheitstafel ist „fad“. Der Eingang en ist inaktiv, also 0. Damit sind auch alle Ausgänge auf 0. Interessanter wird die zweite Hälfte. Jetzt ist der Eingang en auf 1 und der Dekoder „macht sein Geschäft“. Er interpretiert die Adresse {a1, a0} und setzt den „dazu passenden“ Ausgang auf 1. Dies nennt man „dekodieren“. 12.3.6 DAS 4‐MAL‐4‐BIT‐RAM Damit können wir jetzt unser 4‐mal‐4‐Bit‐RAM „zusammenstöpseln“. Sehen wir uns zuerst das vereinfachte Bild auf der folgenden Seite an. Die Taktleitungen habe ich dabei weg gelassen, damit wir die Übersicht nicht verlieren. Links ist der Adressdekoder. Wenn das Signal write aktiv ist, wird je nach wr_addr der Lade‐Eingang einer Zeile mit 4 Flipflops „heiß“ gemacht. Ansonsten sind alle 4 Lade‐Eingänge inaktiv auf 0. Bei der nachfolgenden steigenden Flanke des Taktes wird in die aktivierte Speicher‐Zeile, also in das adressierte Datenwort der 4‐Bit‐
Wert din gespeichert. Wenn das Schreibesignal write hingegen auf 0 bleibt, wird bei der steigenden Taktflanke kein neuer Wert gespeichert. Der Inhalt der Flipflops bleibt gleich. [Du erinnerst dich: In „Wirklichkeit“ „fotografiert“ in diesem Fall jedes Flipflop seinen eigenen gespeicherten Wert.] Wir könnten auch sagen: Der Zustand des Automaten ändert sich in diesem Falle nicht. Wie geht das Lesen eines Datenwortes? Dies ist hier unabhängig vom Schreiben möglich. Je nachdem, welche Read‐Adresse rd_addr anliegt, erscheint eines der 4 Speicherworte mit jeweils 4 Bit über den Lese‐Multiplexer am Datenausgang dout. 137
138 Rechnernetze und –Organisation Hole jetzt die Schaltung ram4_by_4 auf den Bildschirm. Du findest – jedoch wie gesagt schon ziemlich unübersichtlich – alle besprochenen Elemente wieder. Starte die Simulation. Die Leitungen an den Ausgängen der Register sind blau. Dies deshalb, da der Simulator ja noch nicht weiß, welche Werte in den Flipflops gespeichert sind. Er signalisiert also „unbekannte Werte“. Das untenstehende Bild zeigt diese Situation. Hardware von unten
Schreiben wir an die Adresse das Datum 0. Dazu müssen wir lediglich das Write‐Signal write auf 1 setzen. Du siehst an der grünen Farbe des Load‐Signals für das oberste Register, dass dieses jetzt selektiert ist. Jetzt müssen wir eine steigende Flanke des Taktsignals generieren. Aha! Das oberste Register hat den Wert 0000 geladen. Das erkennt man an den dunkelgrünen Datenausgangsleitungen des obersten Registers reg4. Dieses Register hat ja die Adresse 0. Und nach einer steigenden Flanke vom Taktsignal müssen wir das Signal write wieder auf 0 setzen. Da die Lese‐Adresse {rd_addr1, rd_addr0} auch auf 0 liegt, sehen wir dieses Datenwort auch an den Ausgängen des RAM. Schreibe jetzt andere Daten. Und lese diese. Verstehst du alles? 12.3.7 IST EIN RAM EIN AUTOMAT? Betrachten wir obige Schaltung aus der „Automatenperspektive“. Der Automat hat 16 Flipflops. Damit kann er maximal 216 Zustände haben. In jedem Taktzyklus ist der Zustand definiert. Am Ausgang des Automaten finden wir die 4 Datenleitungen, über welche wir den Inhalt von Speicherworten lesen können. Die Output‐
Logikfunktion besteht aus den 4 Stück 4‐zu‐1‐Multiplexern. Im Prinzip lässt sich diese durch eine Wahrheitstafel darstellen: 18 Eingänge und 4 Ausgänge. Damit kriegen wir eine Wahrheitstafel mit 218 Zeilen. Das ist schon recht viel: 256 * 1024 Zeilen. Als Eingänge des Automaten sehen wir 4 Dateneingänge, 2 Read‐Adressleitungen, 2 Write‐Adressleitungen und das Write‐Kontrollsignal. Also 9 Stück. Was fällt alles in die Next‐State‐Logik? Da 139
140 Rechnernetze und –Organisation gibt es den Adressdekoder und die 16 Stück 2‐zu‐1‐Multiplexer, welche wir vor jedem Flipflop‐D‐Eingang finden. Dort sehen wir auch die Rückkopplung vom Ausgang der Flipflops zu den Eingängen. Da die beiden Read‐Adresseingänge direkt auf den Ausgang wirken, haben wir es mit einem Mealy‐Automaten zu tun. Du wirst mir vermutlich zustimmen, dass die Automatenperspektive eines RAM ziemlich umständlich ist. Die Sichtweise, dass man in einem RAM Speicherworte addressieren kann, diese ändern („be‐schreiben“) und auch lesen kann, ist da schon viel anschaulicher. Doch ich möchte dir hier auch zeigen, wie man obigen „Drahtverhau“ unter Verwendung von Leitungsbündel im Simulator übersichtlicher darstellen kann. Du findest diese Schaltung unter dem Namen ram4_by_4_kompakt. Dazu verwende ich folgende Bibliotheksmodule:  Für den Adressdekoder: einen 2‐zu‐4‐Dekoder und 4 UND‐Gatter,  für das Speicher‐Array: 4 Stück 4‐Bit‐Register  und für den Read‐Multiplexer: einen 4‐fachen 4‐zu‐1‐Multiplexer Diese Schaltung hat zudem die „nette“ Eigenheit, dass wir „in die Speicherworte hinein sehen“ können, da die verwendeten Register im Symbol ihren gespeicherten Wert anzeigen. Hast du dieses RAM erst einmal verstanden, dann wirst du in Zukunft wohl eher das Bibliothekssymbol aus der Abteilung Memory nehmen wollen. Dort kannst du einstellen, wie viele Datenbits ein Speicherwort haben soll und wie viele Adressleitungen du haben willst. Der Nachteil dieses RAM: Es hat kombinierte Adressleitungen für Lesen und Schreiben. Und du kannst nicht gleichzeitig Lesen und Schreiben. Du musst bei diesem Bibliotheks‐RAM das Attribut Data Interface auf „Separate load and store ports“ setzen. Die Schaltung im folgenden Bild heißt ram_4_by_4_library. Hardware von unten
Auch bei diesem RAM sehen wir dessen Inhalt. Auch das soeben adressierte Speicherwort ist optisch hervor gehoben. Neu ist, dass wir hier nur entweder Schreiben (write = 1) oder Lesen (write = 0) können. Experimentiere mit RAMs. Siehe dir größere an. Wie wäre es mit dem TOY‐RAM? Adressbreite ist 8 Bit. Datenbreite ist 16 Bit. 12.3.8 NOCHMALS ADDIERER: EIN 4‐BIT‐ADDIERER Die nachfolgenden Schaltungen findest du in der Datei rno_Teil_D_regfile_w_controller. Bevor wir zum Schluss unserer Hardware‐Geschichte kommen, möchte ich dir noch einen 4‐Bit‐Addierer zeigen. Diesen brauchen wir gleich nachher. Hole die Schaltung adder4 auf den Bildschirm. Die Schaltung innerhalb des Addierers sieht so aus: Probiere den Addierer aus. Du siehst 4 Volladdierer (fa sind Instanzierungen von full_adder). Oben ist der niederwertigste Volladdierer und unten der höchstwertige. Das Carry‐Signal wird jeweils von einem Volladdierer zum nächsten weiter gereicht. Addiere zum Beispiel 1 + 7. Diese Situation ist oben dargestellt. Sieh dir die Farben der Leitungen an. Wir addieren hier also 0001 + 0111. Am Stellenwert 0 (ganz oben) wird 1 + 1 addiert. Die Summe ist dann 0 und der Übertrag (Carry) gleich 1. An der nächsthöheren Stelle (zweiter Volladdierer von oben im Bild) werden die drei Bits 1 + 0 + 1 zusammen gezählt. Das ergibt Summe = 0 und Carry = 1. Und so weiter. 141
142 Rechnernetze und –Organisation 12.3.9 REGISTER‐FILE UND ARITHMETISCHE EINHEIT Zum Schluss unserer Hardware‐Geschichte möchte ich jetzt noch das 4‐mal‐4‐Bit‐RAM von vorhin mit dem oben besprochenen Addierer zusammen schalten. Wir werden jedoch eine neue Variante eines solchen RAM verwenden. Es hat 2 Lese‐Ausgänge. Damit können wir zwei RAM‐Worte gleichzeitig lesen. Du findest diese Schaltung unter dem Namen regfile4x4. Du siehst im Bild das 4‐mal‐4‐RAM mit 2 Lese‐Ausgängen. Vergleiche diese Schaltung mit dem einfachen 4‐mal‐
4‐Bit‐RAM von vorhin. Das Schreiben geht ganz gleich wie beim RAM zuvor. Lediglich für das Lesen haben wir jetzt 2 „Lese‐Ports“ (doutA und doutB) samt den dazu passenden Lese‐Adressen (read_addrA und read_addrB). Betrachten wir als Nächstes die Schaltung data_path. Die Ausgänge dieses Register‐File werden mit den beiden Eingängen des vorhin besprochenen 4‐Bit‐Addierers (add4) verbunden. Der Ausgang des Addierers ist über einen 4‐fachen 2‐zu‐1‐Multiplexer mit den Dateneingängen des Register‐File regfile verbunden. Die 3 mal 2 Bits der Adressleitungen für den Register‐File sowie die RAM‐Kontroll‐Leitung wr (= write) und die Auswahl‐Leitung für den 2‐zu‐1‐Multiplexer fassen wir zu einem 8‐Bit‐Kontrollwort control_word zusammen. Hardware von unten
Schalte die Simulation ein. Versuchen wir, durch „geschicktes Klavierspielen“ diesem „Klavier“ eine „Melodie“ zu entlocken. Na ja. Als erstes vielleicht lediglich einen „Ton“ und dann noch einen „Ton“ – wir wollen ja bescheiden bleiben. Technisch gesprochen haben wir es hier mit einem Datenpfad zu tun. Dieser Datenpfad, den wir in einer Analogie wie ein “Klavier“ betrachten, besteht aus  dem 4‐mal‐4‐Bit Register‐File regfile,  dem 4‐Bit Addierer add4  und einem 2‐zu‐1 Eingangs‐Multiplexer (MUX). Mit dem Eingangsmultiplexer können wir die Eingangsdaten din in den Register‐File kriegen. Man setzt dazu ld_din auf 1, setzt wr (= write) auf 1, wählt eine geeignete Schreibe‐Adresse (wr_addr) und wartet auf die steigende Taktflanke. Man könnte in Analogie zum Klavier das Kontrollwort als Tastatur mit 8 Tasten betrachten. Jede Taste – soll heißen: jedes Bit – auf dieser Tastatur bewirkt „etwas“ bei Eintreffen der steigenden Taktflanke. Wenn wir die richtigen „Akkorde“ auf dieser „Tastatur“ spielen, dann „spielen wir auf dem Datenpfad „eine Melodie“. Sehen wir uns die Struktur des Kontrolworts an:  Bit 7 (ganz links): ld_din  Bit 6: wr  Bit 5: wr_addr[1]  Bit 4: wr_addr[0]  Bit 3: rd_addrA[1]  Bit 2: rd_addrA[0]  Bit 1: rd_addrB[1]  Bit 0 (ganz rechts): rd_addrB[0] Der Akkord „11_00_00_00“ würde also (bei der nächsten steigenden Taktflanke) das anliegende Datenwort din auf die Adresse 00 im Register‐File schreiben. Probieren wir das einmal aus. Es müsste dann das Datenwort, sagen wir din = 0011 (= dezimal 3), am Lese‐Port A erscheinen. Dieses können wir unter dout beim Datenpfad ablesen. Also los: 143
144 Rechnernetze und –Organisation 

Setze din auf den Wert „0011“ (einfach die rechten beiden Bits mit dem Mauszeiger „flippen“). Setze control_word auf den Wert „11000000“ (ebenfalls einfach die Bits mit dem Mauszeiger „flippen“). Setze clk auf 1 (und dann wieder zurück auf 0). 
Bei mir sieht das Ergebnis jetzt so aus: Am Datenausgang dout wird ja das Port A abgegriffen. Dort sehen wir den Inhalt des im Register‐File gespeicherten Wortes an der Adresse 0. Es erscheint der Wert „0011“, also dezimal 3. Doch wir sehen noch etwas Interessantes: Am Ausgang des Addierers finden wir den 4‐Bit‐Wert „0110“ und zusätzlich das Carry‐Bit mit dem Wert 0. Also zusammen betrachtet den 5‐Bit‐Wert „00110“. Dies entspricht ja 3 + 3 = 6. Warum? Eigentlich eh klar: Unser angelegtes Kontrollwort legt fest, dass nicht nur am Lese‐Port A das Speicherwort der Adresse 0 rauskommen soll, sondern auch am Lese‐Port B. Damit liegt an beiden Ports der Wert „0011“. Du siehst das auch an den Probes. Wir könnten als Nächstes den Wert am Addierer‐Ausgang, also den Wert „0110“ auf die Speicherstelle mit Adresse 1 im Register‐File schreiben. Wie würde das Kontrollwort dazu aussehen? Kurzes Nachdenken ergibt: control_word = 01_01_00_00 Wir setzen also ld_din auf 0, wr auf 1, die Schreib‐Adresse wr_addr auf 01. Die beiden Lese‐Adressen rd_addrA und rd_addrB lassen wir auf 0. Und dann brauchen wir eine steigende Taktflanke. Also los! Das Ergebnis: Im Register‐File steht jetzt auf Adresse 1 der Wert 0100. Wir können diesen Wert von Adresse 01 am Port A ausgeben, indem wir einfach beim Kontroll‐Wort das entsprechende Bit setzen: „00000100“. Das Simulationsergebnis sieht jetzt so aus: Hardware von unten
Im obigen Bild sehen wir am Port A das Speicherwort mit der Adresse 00 und am Port B das Speicherwort mit der Adresse 01. Durch geeignete Wahl einer Folge von Kontroll‐Wörtern können wir also ein Programm auf diesem Datenpfad ablaufen lassen. 12.3.10 WIR HABEN EIN „KLAVIER“, DOCH WO IST DER „KLAVIERSPIELER“? Welche „Melodien“ oder, technisch gesprochen, welche Programme bestehend aus einer Folge von Kontrollwörtern könnten wir auf obigem Datenpfad spielen? Dazu wollen wir eine etwas leichter verständliche Sprache für die 8‐Bit‐Kontrollwörter erfinden. Was können wir denn alles machen? Wir können den Dateneingang din in eines der 4 vorhandenen Register laden. Nennen wir die 4 Register im Register‐File R0, R1, R2 und R3. Nun könnten wir vom „Laden eines Wertes“ in eines der Register sprechen. Zum Beispiel ld R0, 3
oder ausgesprochen: Lade das Register R0 mit dem Wert 3. Das haben wir im Simulationsbeispiel vorhin ja schon gemacht. Das Kontrollwort dazu war „11000000“ und der Dateneingang war „0011“. Als zweiten Befehl haben wir vorhin eine Addition durchgeführt: add R1, R0, R0
In Registertransfersprache lässt sich dies auch so darstellen: R1  R0 + R0 Diese Operation haben wir mit dem Kontrollwort „01010000“ erzeugt. Schließlich konnten wir mit dem Kontrollwort „00000100“ am Port A den Wert von Register R1 ausgeben. Diese Operation könnten wir so darstellen: 145
146 Rechnernetze und –Organisation out R1
Fassen wir nochmals zusammen: din
ld_din
wr
wr_addr rd_addrA
rd_addrB
Mikrocode ld R0, 3
0011 1
1
00
00
00 R1  R0 + R0
XXXX 0
1
01
00
00 out R1
XXXX 0
0
XX
01
XX Auf ähnliche Weise können wir weitere „Akkorde“ formulieren. Der Wert „X“ in obiger Tabelle bedeutet, dass dieses Bit keine Auswirkung hat und auf 0 oder auch auf 1 gesetzt werden kann. Ein „Akkord“ besteht jetzt also aus 12 Bits: 4 Bits von din und 8 Bits von control_word. Stell dir vor, dass du folgendes Mikroprogramm ausführen möchtest: ld R0, 1
goto 1 // Lade R0 mit 1 und gehe weiter zu Zeile 1 0: ld R1, 2
goto 2 // Lade R1 mit 2 und gehe weiter zu Zeile 2 1: R2  R0 + R1 goto 3 // Lade R2 mit dem Wert R1 + R2, gehe weiter zu Zeile 3 2: 3: R3  R2 + R0 goto 4 out R0
goto 5 // schalte R0 auf den Ausgang, weiter mit Zeile 5 4: out R1
goto 6 5: out R2
goto 7 6: out R3
goto 4 // schalte R3 auf den Ausgang, weiter mit Zeile 4 7: Mit der Spalte „goto“ wollen wir festhalten, welcher „Akkord“ – also welche Mikroinstruktion – als Nächstes auszuführen ist. Siehst du, was im Programm geschieht? Zuerst wird R0 mit dem Wert 1 geladen. Dann wird der Wert 2 in das Register R1 geladen. R3 kriegt sodann den Wert 3. Und R4 danach den Wert 5. Jetzt kommen die out‐Befehle: Es werden in der Folge die Registerwerte von R0, R1, R2 und R3 ausgegeben. Das heißt, dass diese am Ausgang dout erscheinen. Doch dann ist nicht Schluss. Das Ganze wiederholt sich, denn nach „out R3“ in Zeile 7 gehen wir zurück zur Zeile 4. Wir haben hier also eine Schleife, eine Endlosschleife. Am Ausgang erscheint also nach den ersten 4 Mikroinstruktionen die Endlosfolge 1, 2, 3, 4, 1, 2, 3, 4, 1 usw. Wir können nun jede Zeile Stück für Stück übersetzen: 0: 1: 2: 3: 4: 5: 6: 7: ld R0, 1 ld R1, 2 R2  R0 + R1 R3  R2 + R0 out R0 out R1 out R2 out R3, goto 1 goto 2 goto 3 goto 4 goto 5 goto 6 goto 7 goto 4 control_word = 11_00_00_00 control_word = 11_01_01_00 control_word = 01_10_00_01 control_word = 01_11_00_10 control_word = 00_00_00_00 control_word = 00_00_01_00 control_word = 00_00_10_00 control_word = 00_00_11_00 din = 0001 din = 0010 din = XXXX din = XXXX din = XXXX din = XXXX din = XXXX din = XXXX next = 001 next = 010 next = 011 next = 100 next = 101 next = 110 next = 111 next = 100 Die Werte mit X legen wir willkürlich mit 0 fest. Damit haben wir jetzt das „Notenblatt“ (= Programm) für den „Klavierspieler“ (= Kontroll‐Logik) vorliegen. Dieses „Notenblatt“ (= Programm) besteht aus 8 „Akkorden“ (= Mikrobefehlen bestehend aus control_word und din) mit zugehöriger Anweisung (next), in welcher Reihenfolge diese „Akkorde“ zu spielen sind. Hardware von unten
Unser Klavierspieler braucht jetzt nur mehr ein Register, welches in unserer Analogie „den Finger am Notenblatt“ darstellt, der auf den soeben gespielten „Akkord“ zeigt. Technisch gesprochen handelt es sich um einen Moore‐Automaten. In einem 3‐Bit‐Register wird der Zustand gespeichert. Das waren die Zeilen in unserem Programm vorhin. In der Next‐State‐Logik wird für jeden Zustand der nachfolgende errechnet. Abhängig vom Zustand wird in der „Output‐Logik für din“ der Wert für din festgelegt. Und schließlich haben wir noch einen zweiten Teil der Output‐Logik: In der „Output‐Logik für control_word“ wird in Abhängigkeit vom Zustand das Kontrollwort definiert. Wir können die Wahrheitstafeln für die drei Logikfunktionen aus obiger Beschreibung leicht ermitteln. Beginnen wir mit der Next‐State‐Logik:  Bei Zustand 0 bereite als nächsten Zustand den Zustand 1 vor. Also: Bei 0 mache 1.  Bei 1 mache 2,  …,  Bei 4 mache 5,  Bei 5 mache 6,  Bei 6 mache 7,  Bei 7 mache 4(!). Als Wahrheitstafel sieht dies so aus: Zustand
nächster Zustand
0
1
1
2
2
3
3
4
4
5
5
6
6
7
7
4
Jetzt zur Output‐Logik für din. Auch der 4‐Bit‐Wert am Dateneingang din des Datenpfades ist lediglich vom Zustand abhängig. So ist das ja bei Moore‐Automaten. 147
148 Rechnernetze und –Organisation Zustand
0
1
2
3
4
5
6
7
din
1
2
0
0
0
0
0
0
Schließlich ist auch das Kontroll‐Wort abhängig vom Zustand. Wir tragen es in der Wahrheitstafel in hexadezimaler Schreibweise ein: control_word
Zustand
0
0xC0
1
0xD4
2
0x61
3
0x72
4
0x00
5
0x04
6
0x08
7
0x0C
Jetzt haben wir alle Teile des Kontroll‐Automaten fixiert. Wir könnten jetzt, wie schon mehrmals in diesem Tutorium gemacht, die Logikfunktionen im Simulator ausgehend von den Wahrheitstafeln als „Gatterfriedhof“ berechnen lassen. Doch zur Abwechslung möchte ich es dieses Mal etwas anders machen. Ich möchte zur Implementierung der drei obigen Logikfunktionen ROMs verwenden. ROM steht für „Read Only Memory“. Ein ROM ist also eine Art Speicher, dessen Inhalt nur gelesen werden kann. Der Speicherinhalt liegt also schon beim Bau des ROMs fest. Wenn wir das ROM in einer Schaltung verwenden, dann legen wir eine Adresse am Eingang an und das ROM antwortet mit dem dazu gespeicherten Datum. Damit eignet sich ein ROM ideal für die Implementierung einer Wahrheitstafel. Wir können zum Beispiel die „Output‐Logik für control_word“ so interpretieren: Als Adresse legen wir den Zustand an und als Datum kommt das control_word raus. Das ROM muss also in unserem Fall eine 3‐Bit‐
Adresse akzeptieren. Damit brauchen wir ein ROM mit 8 Speicherwörtern zu je 8 Bit. Für die beiden anderen Logikfunktionen gilt Ähnliches. In Logisim findest du ROMs in der Abteilung Memory ganz unten. Wenn du ein ROM instanzierst, dann musst du die Attribute Address Bit Width und Data Bit Width festlegen. Schließlich musst du noch den Inhalt des ROMs definieren. Dazu gibt es das Attribut Contents. Probiere das aus. Die so gebaute Kontroll‐Logik findest du in der Schaltung controller. Du siehst darin das 3‐Bit‐Register in der Mitte. Rechts die beiden ROMs mit den dazu gehörigen Inhalten, welche den Wahrheitstafeln entsprechen. Und links die Next‐State‐Logik, welche ebenfalls mit einem ROM implementiert wurde. Schließlich gibt es noch einen 2‐zu‐1‐MUX. Mit diesem können wir „starten“. Wenn start auf 0 liegt, dann wird der Zustand auf 0 gesetzt. Wenn start jedoch auf 1 ist, dann „spielt“ die Kontroll‐Logik. Hardware von unten
Probiere das aus und beobachte, wie die Kontroll‐Logik zuerst die Zustände 0 bis 7 durchläuft und sodann die Zustände 4, 5, 6 und 7 in einer Endlosschleife durchläuft. Beobachte auch die Ausgänge. Jetzt müssen wir den „Klavierspieler“ nur noch ans „Klavier“ setzen. Sonst hören wir die „Musik“ nicht. Du findest die Schaltung unter dem Namen main. Du siehst im Bild den Datenpfad (= Klavier) mit Namen dp und die Kontroll‐Logik (= „Klarvierspieler“) cntl. Es gibt den Einschaltknopf start und einen Taktgenerator. Zur Simulation musst du den Taktgenerator eventuell unter Simulate->Ticks Enabled einschalten. Die restlichen Teile der Schaltung dienen nur zu unserer Beobachtung. „Lautsprecher“ sozusagen. Wir können damit die Funktion dieser Schaltung „unter Laborbedingungen“ überprüfen. Damit meine ich, dass wir mit Hilfe von „Probes“ die Werte din, control_word, state und dout mitverfolgen können. Wenn wir nun das Programm starten, dann erscheint nach ein paar Taktzyklen wie gewünscht die Folge 1, 2, 3, 4 in einer Endlosschleife am Ausgang. Probiere, ein anderes Programm aus. Du musst dazu lediglich die Inhalte der drei ROMs entsprechend definieren. 149
150 Rechnernetze und –Organisation 12.3.11 ZUSAMMENFASSUNG Im Prinzip haben wir jetzt alle Elemente kennengelernt, welche man in digitalen Schaltungen typischerweise findet. Auch der TOY‐Computer kann auf diese Weise gebaut werden. Und auch – vom Prinzip her – dein Computer. Beim Entwerfen einer komplexen Schaltung mit vielen Millionen und Milliarden Schaltelementen muss man die Komplexität ähnlich wie beim Software‐Entwurf mit dem Prinzip „Divide & Conquer“ in den Griff kriegen. Man zerteilt das Problem in kleinere Teilprobleme so lange, bis man diese lösen kann. Man verwendet beim Hardware‐Entwurf auch sogenannte Hardware‐Entwurfssprachen. Bekannte Beispiele sind Verilog, VHDL oder System‐C. Statt Bilder zu zeichnen, wie wir es hier gemacht haben, schreibt man dann Quell‐Code. Und diesen Quell‐Code kann man dann – gleich wie in der Software – kompilieren und exekutieren. Wir nennen dieses Exekutieren dann Simulation. Es gibt jedoch auch andere Compiler, sogenannte Hardware‐Synthesizer, mit welchen der Quell‐Code in eine Transistorschaltung und sogar in ein Chip‐Layout automatisch umgewandelt werden kann. Wenn dich das interessiert, dann musst du die Lehrveranstaltung „Rechnerorganisation“ und danach „VLSI‐Design“ besuchen. Dort kannst du das lernen. Was würde uns jetzt noch zum Verständnis des Aufbaus unseres TOY‐Computers fehlen? Wie das RAM und die Registerbank aussehen, wissen wir jetzt. Es ist ein Register‐File mit 16‐mal‐16 Bit. Du kannst dir statt des besprochenen 4‐Bit‐Addierers wohl auch eine 16‐Bit‐Arithmetikeinheit vorstellen, welche nicht nur addieren, sondern auch subtrahieren und alle anderen TOY‐Arithmetik‐ und Logik‐Operationen „kann“. Man nennt diese kombinatorischen Schaltungen „Arithmetic Logic Units“ (ALU). Der TOY‐Computer hat dann noch ein paar zusätzliche Register: Den Programmzähler – doch wie Zähler funktionieren hast du oben ja schon gesehen. Dann das Instruktionsregister. In diesem findet sich immer die vom RAM zuletzt gelesene Instruktion. Schließlich brauchen wir noch einen „Kontrollor“, also eine „Super‐Lauflichtsteuerung“, welche den „Fetch‐
Execute“‐Algoritmus abarbeitet. Fertig. 13 HARDWARE VON OBEN Den Text dazu findest in in Matloff’s Kapitel “Major Components of Computer Engines”. Teil D: Hardware, Stack und I/O
14 UNTERPROGRAMME Gutes Programmieren verwendet die Top‐Down‐Methode und bevorzugt modularen Entwurf. In C‐ähnlichen Sprachen kommen deshalb viele „Funktionen“ und „Methoden“ vor. Auf tieferen Ebenen (Assemblersprache und Maschinensprache) sprechen wir in diesem Zusammenhang von Unterprogrammen. Dieser Begriff wird auch in einigen Hochsprachen, wie FORTRAN oder Perl, verwendet. In diesem Kapitel wollen wir uns mit Unterprogrammaufrufen in der Intel‐Maschinensprache beschäftigen. Du solltest zum Verständnis dieses Abschnittes bereits Grundkenntnisse in der Intel‐32‐Bit‐Assemblersprache haben. Wir verwenden die AT&T‐Syntax und sehen uns das Thema basierend auf Linux an. 14.1 STACKS Bei den meisten CPU‐Typen werden Unterprogrammaufrufe unter Zuhilfenahme eines Stack (Stapel) gemacht. Jedes laufende Programm hat einen Speicherbereich, welcher als Stack verwendet wird. Die meisten CPUs haben auch ein spezielles Register mit dem Namen „Stack Pointer“ (SP, oder ESP bei 32‐Bit‐Maschinen). Dieses Register zeigt auf den „Top“ des Stack. Damit meinen wir, dass der Registerinhalt die Adresse des derzeit obersten Elements des Stack speichert. Der Speicherbereich, in welchem der Stack liegt, ist ein ganz normaler Teil des Speichers. Der Stack hat also keinen „Zaun“. Wohin auch immer das Register ESP zeigt, dort ist der „Top of Stack“. Und das ist auch so, wenn wir überhaupt keinen Stack verwenden, bzw. wenn dort einfach nur „Datenmüll“ gespeichert ist. Typischerweise wächst ein Stack von einer Basisadresse in Richtung Adresse 0, also hin zu kleineren Adressen. Wenn wir zum Stack ein Datenwort dazu geben – wir nennen diese Operation „Push“, dann wird der Inhalt des Registers ESP entsprechend dekrementiert und auf das Speicherwort (4 Bytes bei einer 32‐Bit‐Maschine) zu zeigen, welches um 1 niedriger liegt. Umgekehrt muss der Stack‐Pointer inkrementiert werden, wenn wir ein Datenwort „vom Stack nehmen“ – diese Operation nennen wir „Pop“. Aufpassen: Es wird lediglich der Zeiger, also der Inhalt des Registers ESP verändert. Der zugehörige Speicherinhalt am Stack wird nicht verändert. Es wird bei einer Pop‐Instruktion also nichts „genommen“, sondern lediglich ein Speicherwort kopiert und der Stack‐Pointer inkrementiert. Nehmen wir als Beispiel an, dass wir den Wert 35 auf den Stack „pushen“ wollen. Wir könnten dies also so machen: subl $4, %esp
movl $35, (%esp)
# expand the stack by one word
# put 35 in the word which is the new top-of-stack
Typische CPUs haben für diese Operation auch spezielle Instruktionen. Auf Intel‐Maschinen könnten wir Obiges auch mit folgender Instruktion erreichen: push $35
151
152 Rechnernetze und –Organisation Es gibt auch eine Instruktion mit dem Namen pushl. Damit bleibt der Instruktionssatz konsistent mit den movl‐Instruktionen. pushl ist lediglich ein anderer Name für push. Es gibt noch eine weitere Besonderheit hier anzumerken. Normalerweise haben Intel‐Maschinen keinen Befehl, mit welchem wir ein Speicherwort von einer Speicheradresse auf eine andere Speicheradresse kopieren können. Man muss immer durch ein Register hindurch. Lediglich push kann das. Wir können z.B. mit pushl w
das symbolische Speicherwort w im Datenbereich .text auf den Stack – also auf eine anderen Speicheradresse – kopieren. Wenn wir einen Wert vom Stack „poppen“ wollten, also den obersten Wert des Stacks in ein Register, z.B. ECX, kopieren wollten, dann würden wir folgenden Befehl nehmen: pop %ecx
Nochmals: Der Befehl pop ändert im Speicher (wo sich der Stack ja befindet) keine Inhalte. Das Datenwort bleibt dort. Die einzige Veränderung ist, dass der Inhalt des Registers ESP modifiziert wird, sodass der Speicherinhalt nicht mehr als Teil des Stack angesehen wird. Der Stack besteht also immer nur aus dem Speicherwort, auf welches der Inhalt des Registers ESP zeigt, und aus allen Speicherwörtern mit höheren Adressen. Jeder Wert, der vom Stack mit pop „geholt“ wird, bleibt im Speicher bis zu dem Zeitpunkt, wo er eventuell durch einen späteren Push‐Befehl überschrieben wird. 14.2 DIE INSTRUKTIONEN CALL UND RET Der Aufruf eines Unterprogramms geschieht mit der Instruktion CALL. Die Ausführung dieser Instruktion hat folgende Auswirkungen: 
Der gegenwärtige Wert des Registers PC (Program Counter, EIP) wir auf den Stack gelegt (Push). 
Die Startadresse des Unterprogramms wird in das Register PC geladen. Unterprogramme
Bei der ersten dieser beiden Aktionen sprechen wir vom „Pushen der Rückkehradresse auf den Stack“. Damit meinen wir, dass sich die Maschine die Adresse merkt, auf welche Adresse sie nach der Ausführung des Unterprogramms zurück kehren soll. Die beiden obigen Aktionen werden also von der Hardware ausgeführt. Sehen wir uns z.B. folgenden Code an: call abc
addl %eax, %ebx
Zuerst zeigt das Register PC auf die Instruktion CALL. Nachdem diese Instruktion in der sogenannten Fetch‐
Phase vom Speicher geholt wurde, wird der Wert im PC von der CPU inkrementiert, um sodann auf die im Code unmittelbar nachfolgende Instruktion zu zeigen; im obigen Fall also auf die Instruktion ADDL. Danach erkennt die CPU in der sogenannten Decode‐Phase, dass es sich um einen CALL‐Befehl handelt. Damit wird der Inhalt von PC auf den Stack kopiert („gepusht“). Wir sehen also, dass damit die tatsächliche Rückkehradresse am Stack liegt, nämlich die Adresse der Instruktion ADDL. Wie sieht das nun mit dem zweiten Punkt oben aus? Als Effekt sehen wir, dass die CPU „in das Unterprogramm springt“. Es wird ja als nächster Befehl die erste Instruktion des Unterprogramms ausgeführt. Als letzte Instruktion eines Unterprogramms muss immer der Befehl RET, also eine Return‐Anweisung stehen. Diese Instruktion holt mit pop die Rückkehradresse vom Stack und kopiert diesen Wert ins Register PC. Da dieser Wert ja vorhin auf den Stack „gepusht“ wurde, kriegen wir damit den richtigen Wert in den PC und können an der richtigen Stelle, nämlich mit der Instruktion, welche unmittelbar nach dem CALL‐Befehl liegt, die Ausführung fortsetzen. Im obigen Beispiel setzen wir also mit dem ADDL‐Befehl fort. Dabei muss man natürlich aufpassen, dass alle in der Zwischenzeit auf den Stack gelegte Werte wieder mittels pop vorher wieder runtergeholt worden sind. Doch mehr dazu etwas später. 14.3 ARGUMENTE Funktionen in der Sprache C haben normalerweise oft auch Argumente. Manches Mal nennt man diese auch „Parameter“. Diese Situation finden wir auch auf Assemblerebene vor. Die typische Art und Weise, wie diese Parameterübergabe zwischen aufrufenden Programm und aufgerufenem Programm stattfindet, führt ebenfalls über den Stack. Wir „pushen“ die Argumente ganz einfach vor dem CALL auf den Stack. Später, nach der Ausführung des Unterprogramms, „löschen“ wir diese Werte am Stack durch entsprechend viele Pop‐Befehle. Sehen wir uns wieder das Beispiel von vorhin an und nehmen wir an, dass das Unterprogramm abc zwei Integer‐Argumente erwartet. Diese haben im gegenständlichen Fall die Werte 3 und 12. Dann würde der Code so aussehen: push
push
call
pop
pop
addl
$12
$3
abc
%edx
# assumes EDX isn’t saving some other useful data at this time
%edx
%eax, %ebx
Das Unterprogramm abc würde auf diese Argumente über den Stack zugreifen. Nehmen wir an, dass das Unterprogramm abc die beiden Argumente addieren müsste und die Summe im Register ECX ablegen sollte. Das würde dann der entsprechende Code des Unterprogramms so aussehen: 153
154 Rechnernetze und –Organisation …
movl 4(%esp), %ecx
addl 8(%esp), %ecx
…
ret
Im Befehl movl liegt der erste Operand 4 Bytes nach der Adresse, auf welche das Register ESP zeigt. Also auf das Nachbarwort des „Top‐of‐Stack“‐Wortes. Dies wäre im obigen Beispiel also der Wert 3. Am „Top‐of‐Stack“ befindet sich ja die Rückkehradresse – diese wurde ja zuletzt „gepusht“. Der Befehl addl nimmt dann den Wert, welcher 8 Bytes nach der Adresse liegt, auf welche das Register ESP zeigt. Hier finden wir also den Wert 12. Der Stack sieht also zum Zeitpunkt der Ausführung der Instruktion movl so aus: 14.4 DER RICHTIGE ZUGRIFF AUF DEN STACK Doch hier haben wir noch ein paar Fragen zu klären. Was wäre gewesen, wenn das aufrufende Programm im Beispiel oben selbst im Register ECX wichtige Datenwerte gespeichert hätte? Wir müssen das Unterprogramm abc also so programmieren, dass es da keine Konflikte geben kann. Deshalb merken wir uns also zuerst den Inhalt des Registers ECX am Stack (wo sonst?) und holen diesen Wert später wieder zurück. Wir restaurieren ihn sozusagen. Der Beginn und das Ende des Programmcodes des Unterprogramms abc würde also so aussehen: push %ecx
…
movl 8(%esp), %ecx
addl 12(%esp), %ecx
…
# hier wird ECX verwendet, verändert, etc.
pop %ecx
ret
Stack nach der Instruktion “push %ecx” Unterprogramme
Pass genau auf: Wir mussten jetzt die Adress‐Offsets am Stack von vorhin 4 und 8 auf 8 und 12 erhöhen, denn wir haben jetzt ja ein Datenwort (also 4 Bytes) mehr am Stack. Nur der Programmierer bzw. die Programmiererin selbst kann auf diese Details aufpassen. Der Assembler und die Hardware können auf keinen Fall draufkommen, wenn der/die ProgrammiererIn diese Veränderung nicht vornehmen würde. In einem solchen Fall würde die Maschine ganz einfach auf die falschen Daten am Stack zugreifen – und der Assembler und auch die Hardware würden sich überhaupt nicht „beschweren“. 14.5 DAS AUFRÄUMEN AM STACK Kurz bevor wir ein Unterprogramm mit dem Befehl ret verlassen, müssen wir natürlich den Stack „zusammen räumen“; wir müssen also mit geeignet vielen pop‐Instruktionen den Stack‐Pointer auf den richtigen Wert zurückführen: 
Was immer wir vorher im Unterprogramm auf den Stack „gepusht“ haben, müssen wir wieder „poppen“. Ansonsten würde der Stack Stück für Stück nutzloserweise Richtung Adresse 0 wachsen und irgendwann dann eventuell andere Daten in diesem Speicherbereich überschreiben. 
Wir müssen auch den Inhalt des Registers ECX „restaurieren“, also auf seinen Wert vor dem Unterprogrammaufruf bringen. 
Wir müssen sicher stellen, dass die Return‐Instruktion auf die richtige Speicherstelle am Stack zugreift. Denke einmal nach, was passieren würde, wenn man beim Programmieren des Unterprogramms auf die Instruktion pop vor dem Befehl ret vergessen hätte. Beachte auch, dass wir nach der Instruktion CALL auch eine Stack‐Aufräum‐Aktion eingefügt haben pop %edx
pop %edx
Damit haben wir die beiden Argumente, welche wir vorher auf den Stack „gepusht“ haben, wieder „gepoppt“. Wir verwenden diese zwar nicht mehr, doch der Stack ist damit wieder in Ordnung. Oder genauer gesagt: Der Inhalt des Registers ESP zeigt wieder auf den Top‐of‐Stack so wie wir das ja wollen. Die pop‐Instrukton braucht eine Angabe, wohin wir diesen Wert kopieren. Im obigen Beispiel also in das Register EDX. EDX wird hier wie ein Abfalleimer verwendet, da wir die „gepoppten“ Werte ja nicht mehr verwenden. Alternativ könnten wir auch so vorgehen: addl $8, %esp
Dieser Befehl würde sogar noch schneller sein und zudem bräuchten wir uns über den Inhalt von EDX keine Sorgen machen. 155
156 Rechnernetze und –Organisation 14.6 GANZE BEISPIELE 14.6.1 ERSTES BEISPIEL Das folgende Beispiel entstand aus einer Modifikation eines anderen Beispiels, welches wir schon früher bei der Einführung in Assemblersprache verwendet haben: Wir addieren wiederum Elemente eines Arrays. Dieses Mal wollen wir jedoch die Initialisierung der Register mit Hilfe eines Unterprogramms ausführen. Zudem erlauben wir, dass das Zusammenzählen der Array‐Elemente auch in der Mitte des Arrays beginnen darf. Die Datei hat den Namen full.s. 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
.data
x:
n:
sum:
.long
.long
.long
.long
.long
.long
.long
1
5
2
18
8888
168
3
.long 0
.text
# EAX will contain the number of loop interations remaining
# EBX will contain the sum
# ECX will point to the current item to be summed
.globl _start
_start:
# push the arguments before call: place to start summing, and
# number of items to be summed
push $x+4
# here we specify to start summing at the 5
push n
# here we specify how many to sum
call init
addl $8, %esp
# clean up the stack
top:
addl (%ecx), %ebx
addl $4, %ecx
decl %eax
jnz top
done: movl %ebx, sum
init:
movl $0, %ebx
#pick up arguments from the stack
mov 8(%esp), %ecx
mov 4(%esp), %eax
ret
Der Assembler wird für diese Datei mit dem Namen full.s folgendermaßen aufgerufen: as –a –-gstabs –o full.o full.s
Unterprogramme
Der Assembler erzeugt daraus das folgende Listing: GAS LISTING full.s
1
2
3 0000 01000000
4 0004 05000000
5 0008 02000000
6 000c 12000000
7 0010 B8220000
8 0014 A8000000
9 0018 03000000
10
11 001c 00000000
12
13
14
15
16
17
18
19
20
21
22
23 0000 68040000
23
00
24 0005 FF351800
24
0000
25 000b E8110000
25
00
26 0010 83C408
27 0013 0319
28 0015 83C104
29 0018 48
30 0019 75F8
31 001b 891D1C00
31
0000
32
33
34 0021 BB000000
34
00
35
36 0026 8B4C2408
37 002a 8B442404
38 002e C390
page 1
.data
x:
n:
sum:
.text
# EAX will contain the number of loop interations remaining
# EBX will contain the sum
# ECX will pointto the current item to be summed
.globl _start
_start:
# push the arguments before call: place to start summing, and
# number of items to be summed
push $x+4
# here we specify to start summing at the 5
push n
# here we specify how many to sum
call init
addl $8, %esp # clean up the stack
addl (%ecx), %ebx
addl $4, %ecx
decl %eax
jnz top
done: movl %ebx, sum
top:
init:
NO UNDEFINED SYMBOLS
1
5
2
18
8888
168
3
.long 0
DEFINED SYMBOLS
full.s:2
full.s:9
full.s:10
full.s:20
full.s:33
full.s:27
full.s:31
.long
.long
.long
.long
.long
.long
.long
movl $0, %ebx
#pick up arguments from the stack
mov 8(%esp), %ecx
mov 4(%esp), %eax
ret
.data:00000000
.data:00000018
.data:0000001c
.text:00000000
.text:00000021
.text:00000013
.text:0000001b
x
n
sum
_start
init
top
done
157
158 Rechnernetze und –Organisation Wir sehen in Zeile 25 des Listings, dass die Instruktion CALL als E811000000 übersetzt wurde. Der Op‐Code ist dabei E8 und das Distanzfeld hat den Wert 11000000. Dieser Wert stellt die Distanz von der Instruktion nach dem CALL‐Befehl zum Beginn des Unterprogramms dar. Diese ist ja von Zeile 26 bis Zeile 34, also von der Adresse 0x0010 zur Adresse 0x0021. Die Differenz 0x0021 – 0x0010 ist 0x11. Da wir es bei Intel‐Maschinen mit einem Little‐Endian‐Format zu tun haben, finden wir 0x11 auch im ersten Byte des Speicherwortes: 0x11000000. Dieser Call ist ein sogenannter „Near‐Call“. Dieser Begriff ist historisch. Als bei früheren Intel‐CPUs lediglich auf 64Kilo‐Segmente im Speicher zugegriffen werden konnte, brauchte man diesen Near‐Call. Im Gegensatz zum „Far‐Call“, mit welchem man auch auf Stellen in einem anderen Segment springen konnte. Mit dem Speichermodell „Flat 32‐Bit Addressing“, welches hier verwendet wird, sind alle Calls „near“. Um noch besser verstehen zu lernen, wie der Stack beim Unterprogrammaufruf verwendet wird, spannen wir obiges Programm in GDB ein und sehen nach, wie der Stack während des Programmablaufs aussieht. Wir erzeugen das exekutierbare Programm zuerst wie üblich mit dem Linker: ld –o full full.o
Dann starten wir GDB: test@test Unterprogramme % gdb full
GNU gdb 6.8-debian
Copyright (C) 2008 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "i486-linux-gnu"...
(gdb) b 25
Breakpoint 1 at 0x804807f: file full.s, line 25.
(gdb) r
Starting program: /home/test/RNO/Teil_D/Unterprogramme/full
Breakpoint 1, _start () at full.s:25
25
call init
Current language: auto; currently asm
(gdb) p/x $eip
$1 = 0x804807f
(gdb) p/x $esp
$2 = 0xbf826548
(gdb) si
init () at full.s:34
34
movl $0, %ebx
(gdb) p/x $eip
$3 = 0x8048095
(gdb) p/x $esp
$4 = 0xbf826544
(gdb) x/3w $esp
0xbf826544: 0x08048084
0x00000003
0x080490a8
(gdb) p/x &x
$5 = 0x80490a4
(gdb)
Wir setzen einen Breakpoint vor dem CALL‐Befehl und sehen uns die Werte von PC und ESP an. Unterprogramme
Stack vor dem Call‐Befehl Wir sehen, dass der Wert des Registers PC (welchen wir in GDB mit $eip ansprechen) sich beim Call von 0x804807f auf 0x8048095 verändert. Der Call‐Befehl speichert auch die Rückkehradresse am Stack. Wir sehen dies daran, dass der Stack um ein Wort, also um 4 Bytes, erweitert wurde: ESP zeigte vor dem Call auf 0xbf826548 und nach dem Call auf 0xbf826544, also um 4 Bytes „darunter“. Stack nach dem Call‐Befehl Der Stack sollte also nach dem Call‐Befehl die Return‐Adresse ganz oben haben. Danach sollte der Wert von n liegen, und danach die Adresse von x plus 4. Die letzteren beiden Werte wurden ja vor dem Call auf den Stack „gepusht“. Wir können dies in GDB überprüfen: Mit x/3w $esp sehen wir uns die 3 Wörter am Stack an. Wir sehen zuerst 0x08048084 als Rücksprung‐Adresse. Vor dem Call hatten wir im Register PC ja den Wert 0x804807f. Der Call‐Befehl selbst braucht 5 Bytes. Dies sehen wir Listing oben. Der nachfolgende Befehl („addl“) steht also auf Adresse 0x804807f + 5 = 0x8048084. Und tatsächlich ist dieser Wert als Rücksprungadresse am Stack. Übrigens: Du solltest jetzt auch herausfinden können, auf welcher Adresse die Sektion .text beginnt. Schauen wir weiter in unserer GDB‐Session: (gdb) b done
Breakpoint 2 at 0x804808f: file full.s, line 31.
(gdb) c
Continuing.
Breakpoint 2, done () at full.s:31
31
done: movl %ebx, sum
(gdb) si
init () at full.s:34
34
movl $0, %ebx
(gdb) p sum
$1 = 25
(gdb) si
init () at full.s:36
159
160 Rechnernetze und –Organisation 36
mov 8(%esp), %ecx
(gdb) q
The program is running. Exit anyway? (y or n) y
Nach der Ausführung der Instruktion beim Label done sehen wir nach, ob der Wert an der symbolischen Adresse sum korrekt ermittelt wurde. Das machen wir mit p sum. An diesem Punkt hätte ich wohl stoppen müssen. Es ist ja die letzte Instruktion unseres Programms. Doch ich wollte ausprobieren, was denn passiert, wenn ich eine weitere Instruktion ausführen würde. Mit dem GBD‐
Befehl si habe ich dies „angeordnet“. Hat sich GDB dagegen gewehrt? Nein. Hat sich „das kleine Männchen im Computer“ dagegen gewehrt? Nein! Die Maschine hat keine Ahnung, dass auf der Adresse mit dem Label done der letzte Befehl steht. Sie macht einfach beim nächsten Speicherwort weiter, wenn man sie lässt, und interpretiert diesen Speicherinhalt als Befehl. Die CPU interpretiert das Bitmuster des nächsten Speicherwortes als MOV‐Befehl. In diesem Fall handelt es sich ja auch um einen solchen. Doch es ist der erste Befehl des Unterprogramms init(). Aber init() sollte zu diesem Zeitpunkt eigentlich nicht aufgerufen werden. Was man hier sieht ist, dass ein Unterprogramm nichts „Spezielles“ an sich hat. Es gibt keine „physikalischen“ Abgrenzungen zwischen Code‐Stücken wie zwischen dem aufrufenden Programm und dem aufgerufenen Unterprogramm init(). Es gibt „nur“ Bitmuster, welche gegebenenfalls als Instruktion interpretiert werden. 14.6.2 ZWEITES BEISPIEL Die Funktion des nachfolgenden Beispiels ist in den ersten Zeilen des Quellcodes in Kommentarform beschrieben. Lies den Code. Probiere den Code aus. Du solltest alles verstehen. #
#
#
#
#
#
the subroutine findfirst(v,w,b) finds
the first instance of a value v in a
block of w consecutive words of memory beginning at b, returning
either the index of the word where v was found (0, 1, 2, ...) or -1 if
v was not found; beginning with _start, we have a short test of the
subroutine
.data
x:
# data section
.long
.long
.long
.long
.long
1
5
3
168
8888
.text
# code section
.globl _start
# required
_start:
# required to use this label unless special action taken
# push the arguments on the stack, then make the call
push $x+4
# start search at the 5
push $168
# search for 168 (deliberately out of order)
push $4
# search 4 words
call findfirst
done:
movl %edi, %edi
# dummy instruction for breakpoint
findfirst:
# finds first instance of a specified value in a block of words
# EBX will contain the value to be searched for
Unterprogramme
# ECX will contain the number of words to be searched
# EAX will point to the current word to search
# return value (EAX) will be index of the word
# found (-1 if not found)
# fetch the arguments from the stack
movl 4(%esp), %ebx
movl 8(%esp), %ecx
movl 12(%esp), %eax
movl %eax, %edx # save block start location
top:
# top of loop; compare the current word to the search value
cmpl (%eax), %ebx
jz found
decl %ecx
# decrement counter of number of words left to search
jz notthere
# if counter has reached 0,
# the search value isn’t there
addl $4, %eax
# otherwise, go on to the next word
jmp top
found:
subl %edx, %eax
shrl $2, %eax
ret
notthere:
movl $-1, %eax
ret
# get offset from start of block
# divide by 4, to convert from byte offset to index
14.7 VERBINDUNG VON C/C++ MIT ASSEMBLERSPRACHE Das Programmieren in Assemblersprache geht sehr langsam und ist obendrein mühsam und zum Teil unübersichtlich. Deshalb versuchen wir, dies auch zu vermeiden und schreiben unsere Programme auf höherer Ebene wie z.B. C. Doch in manchen Fällen müssen wir Teile eines Programms in Assemblersprache programmieren. Dies kommt zum Beispiel vor, wenn man Teile in einem Programm hat, welche maschinenabhängig sind. Man kann zwar viele maschinenabhängigen Aspekte auch in C programmieren. So kann man z.B. die Wortgröße einer Maschine mit dem C‐Konstrukt sizeof() ermitteln. Doch nicht alle maschinenabhängigen Parameter sind auf diese Weise eruierbar. Es könnte jedoch auch sein, dass wir eine Geschwindigkeitsoptimierung vornehmen wollen, da Teile eines Programms die meiste Zeit konsumieren. Auch dann würde man eventuell diese „zeitkritischen“ Code‐Teile in Assembler direkt programmieren. Ein gutes Beispiel ist Linux. Der größte Anteil am Linux‐Code ist in C geschrieben – aus Gründen der Einfachheit und der Portierbarkeit des Codes über viele Maschinentypen hinweg. Kleine Teile des Linux‐Codes sind jedoch in Assemblersprache geschrieben, damit man auf bestimmt Hardware‐Ressourcen optimal zugreifen kann. Wenn man Linux auf einen neuen Hardware‐Typ portiert, dann müssen diese Assembler‐Teile für diese Maschine neu geschrieben werden. Glücklicherweise handelt es sich um recht wenig Code. Auf den ersten Blick erscheint es also „unnatürlich“, C‐Code und Assemblersprache zu mischen. Doch es werden eh beide Sprachen schließlich in Maschinensprache übersetzt. Wir verbinden also lediglich Maschinensprache mit Maschinensprache. 161
162 Rechnernetze und –Organisation 14.7.1 BEISPIEL Sehen wir uns ein Beispiel an, wo C‐Code mit einer Assemblercode‐Routine zusammenarbeitet. Zuerst der C‐
Code: 1 // TryAddOne.c, example of interfacing C to assembly language
2 // paired with AddOne.s, which contains the function addone()
3 // compile by assembling AddOne.s first, and then typing
4 //
5 // gcc -g -o tryaddone TryAddOne.c AddOne.o
6 //
7 // to link the two .o files into an executable file tryaddone
8 // (recall the gcc invokes ld)
9
10 int x;
11
12 main()
13
14 {
x = 7;
15
16
17
18 }
19
addone(&x);
printf("%d\n",x); // should print out 8
exit(1);
Ich habe die Funktion addone() in Assemblersprache geschrieben. Um dies machen zu können, müssen wir zuerst herausfinden, wie der C‐Compiler den Aufruf von addone() in Assembler‐ bzw. Maschinensprache übersetzt hat. Wir können dies herausfinden, indem wir den C‐Code mit der Option –S übersetzen. Damit erzeugen wir eine Assemblerversion des C‐Codes: .file "TryAddOne.c"
.section
.rodata
.LC0:
.string
"%d\n"
.text
.globl main
.type main, @function
main:
leal
4(%esp), %ecx
andl
$-16, %esp
pushl -4(%ecx)
pushl %ebp
movl
%esp, %ebp
pushl %ecx
subl
$20, %esp
movl
$7, x
movl
$x, (%esp)
call
addone
movl
x, %eax
movl
%eax, 4(%esp)
movl
$.LC0, (%esp)
call
printf
movl
$1, (%esp)
call
exit
.size main, .-main
.comm x,4,4
.ident "GCC: (Debian 4.3.2-1.1) 4.3.2"
.section
.note.GNU-stack,"",@progbits
Unterprogramme
Obwohl obige S‐Datei recht lang ist, ist lediglich ein Teil davon für die Beantwortung unserer Frage interessant: movl
call
$x, (%esp)
addone
Wir sehen, dass das Argument für das Unterprogramm addone() vor dem Unterprogrammaufruf auf den Stack gegeben wird. Der Compiler hätte auch folgenden Code erzeugen können: pushl $x
call addone
Obige Version findest du in der englischen Originalversion dieses Textes (von Matloff). Wir können also im Unterprogramm addone() das Argument vom Stack holen: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#
#
#
#
#
#
#
#
AddOne.s, example of interfacing C to assembly language
#
#
#
#
note that we do not have a .data section here, and normally would not;
but we could do so, and if we wanted that .data section to be visible
from the calling C program, we would have .globl lines for whatever
labels we have in that section
paired with TryAddOne.c, which calls addone
assemble by typing
as --gstabs -o AddOne.o AddOne.s
.text
# need .globl to make addone visible to ld
.globl addone
addone:
# will use EBX for temporary storage below, and since the calling
# module might have a value there, better save the latter on the stack
# and restore it when we leave
push %ebx
# at this point the old EBX is on the top of the stack, then the
# return address, then the argument, so the latter is at ESP+8
movl 8(%esp), %ebx
incl (%ebx) # increment; need the (), since the argument was an address
# restore value of EBX in the calling module
pop %ebx
ret
Im Kommentar des obigen Codes siehst du, dass wir in diesem Unterprogramm den Inhalt des Registers EBX zuerst sichern und später „restaurieren“ müssen. Das aufrufende Hauptprogramm main() verwendet dieses Register in unserem Fall zwar nicht, doch da wir eventuell zu einem späteren Zeitpunkt einen Aufruf von addone() aus einem anderen Programm machen möchten, ist es gut, gleich jetzt das Register EBX „sauber“ zu verwenden. Zu dem Zeitpunkt, wo wir zur Instruktion movl kommen, sieht der Stack also so aus: 163
164 Rechnernetze und –Organisation Die Instruktion movl muss also 8 Bytes „in den Stack hineingehen“, um das Argument zu finden. Dieses war ja die Adresse des Speicherwortes, welches inkrementiert werden soll. In der nachfolgenden Instruktion incl wird dieses Speicherwort durch indirekte Adressierung um 1 erhöht. Wir benötigen die indirekte Adressierung, da es sich beim Argument ja um die Adresse des zu erhöhenden Wertes handelt. Du solltest diesen Code jetzt unbedingt kompilieren, assemblieren, linken, und exekutieren um zu sehen, dass dies alles tatsächlich so funktioniert. TryAddOne.c und AddOne.s sind für sich alleine keine eigentlichen „Programme“. Sie sind lediglich Teile des Quellcodes eines Programmes, dessen ausführbare Datei bei uns tryaddone heißt. Es handelt sich also um einen ähnlichen Fall, wie er beim Kompilieren von zwei C‐Quelldateien in zwei Objektdateien (.o) entsteht, welche danach zu einer einzigen exekutierbaren Datei „gelinkt“ werden. 14.7.2 DAS AUFRÄUMEN AM STACK Sehen wir uns jetzt an, ob der Compiler im obigen Programm auch seine „Verantwortung“ für das Aufräumen am Stack nach dem Unterprogrammaufruf übernimmt. In der Datei TryAddOne.s sehen wir folgenden Code: subl
movl
movl
call
movl
movl
movl
call
movl
call
$20, %esp
$7, x
$x, (%esp)
addone
x, %eax
%eax, 4(%esp)
$.LC0, (%esp)
printf
$1, (%esp)
exit
Zuerst wird der Stack um 20 Bytes, also um 5 Wörter vergrößert. Danach wird der Wert 7 in das Speicherwort mit der symbolische Adresse x geschrieben. Die Adresse dieses Speicherworts wird sodann auf den Stack kopiert. Jetzt ist der Stack also um insgesamt 5 Wörter gewachsen, wovon nur das letzte für den Parameter verwendet wird. Als Nächstes finden wir die Call‐Instruktion. Dabei wird die Rückkehradresse am Stack gespeichert und beim Return wieder „gepoppt“. Nach der Call‐Instruktion wird der Wert der Adresse x über das Register EAX auf den Stack kopiert. Damit wird die Parameterübergabe für den Unterprogrammaufruf printf() vorbereitet. Platz dafür ist ja vorhanden, da vorher der Stack schon um 5 Wörter vergrößert wurde, ohne diese unmittelbar zu brauchen. Wie es scheint, haben die Leute von GCC den Compiler so gebaut, dass er bei Unterprogrammaufrufen recht „großzügig“ mit dem Stack umgeht. Dies vielleicht deshalb, da damit das ganze System ein bisschen sicherer Unterprogramme
wird – eine Rücksprungadresse kann dann nicht so leicht unabsichtlich (durch einen Fehler beim Programmieren) überschrieben werden. 14.7.3 SEKTIONEN Der Compiler hat mit der Direktive .comm einen Speicherplatz aufgesetzt, in welchem die globale Variable x gespeichert wird. .comm x,4,4
Es werden 4 Bytes reserviert. Und das „Alignment“ ist so, dass die Anfangsadresse dieses Speicherbereichs für x ein Vielfaches von 4 sein soll. Damit wurde dem Linker mitgeteilt, dass er später diesen Speicherbereich für x in die Sektion .bss legen soll. Diese Sektion .bss ist gleich wie die Sektion .data. Der Unterschied liegt darin, dass es sich in der Sektion .bss um uninitialisierte Werte handelt. Wir haben im C‐Quellcode die Variable x ja einfach mit int x;
deklariert. Hätten wir stattdessen int x = 28;
geschrieben, dann wäre x in der Sektion .data gelandet. Probier das aus und überprüfe es! Es gibt auch noch die Sektion .rodata (steht für „Read‐Only Data“). Du solltest das Kommando nm ausprobieren – mit der ausführbaren Datei von oben. Dieses Kommando nm liefert dir alle Symbole, also alle Namen von Funktionen und globalen Variablen). Diese sind mit T, D, B, R und so weiter gekennzeichnet. Diese Großbuchstaben stehen für die Sektionen .text, .data, .bss, .rodata usw. Zusätzlich siehst du auch die Adressen, welche der Linker diesen Variablen zugewiesen hat. In Linux beginnt die Stack‐Sektion auf der Adresse 0xBFFFFFFF und wächst Richtung Adresse 0. Der Heap, von welchem wir mit der C‐Funktion malloc() bzw. der C++‐Funktion new() dynamisch Speicher allozieren können, startet auf der Adresse 0xBFFFF000 und wächst in die entgegengesetzte Richtung, also in Richtung größer werdender Adressen. 14.7.4 MEHRERE ARGUMENTE Was wäre, wenn wir addone() mit mehreren Argumenten aufrufen würden? Aus dem Quellcode TryAddOne.c kann der C‐Compiler überhaupt nicht herausfinden, wie viele Argumente addone() tatsächlich hat. Die Funktion addone() ist ja in einer anderen Quellcodedatei definiert. Der Compiler hat also keine Ahnung, nicht einmal darüber, ob es sich dabei um eine C‐Datei oder um eine andere Sprache handelt. Der Compiler sieht zu diesem Zeitpunkt lediglich, wie viele Argumente bei diesem Aufruf verwendet werden. Sehen wir uns beispielsweise den Compiler‐Output für den Aufruf von printf() im vorigen Beispiel an. Wir sehen, dass die Argumente in verkehrter Reihenfolge auf den Stack „gepusht“ werden. Die C‐Anweisung printf("%d\n",x); 165
166 Rechnernetze und –Organisation wird so übersetzt: movl
movl
movl
x, %eax
%eax, 4(%esp)
$.LC0, (%esp)
Zuerst kommt also x auf den Stack und danach $.LC0 (dies ist die symbolische Adresse des Beginns des Format‐Strings "%d\n"). Wenn unser Unterprogramm addone() also 2 Argumente hätte, dann müssten wir den Code für das Unterprogramm entsprechend schreiben: Das erste Argument wäre näher dem „Top‐of‐Stack“ als das zweite. 14.7.5 RETURN‐WERTE EINER FUNKTION In C kann eine Funktion, also ein Unterprogramm auch Werte zurückliefern. Bisher sind wir davon ausgegangen, dass es sich um den „Nicht“‐Wert void handelt. Sollte ein Unterprogramm jedoch einen „echten“ Wert zurückgeben, dann gibt es die Konvention, dass dies über das Register EAX geschieht. Der C‐
Compiler GCC macht das so, indem er nach dem Call‐Befehl entsprechende Anweisungen im Assemblercode platziert. Offensichtlich geht das nur, wenn der Rückgabewert auch im Register EAX Platz hat. Das ist zum Beispiel bei int‐Werten, bei char‐Werteen oder auch bei einem Pointer der Fall. Wenn der Rückgabewert jedoch vom Typ long long ist, so müssen 8 Bytes zurückgegeben werden. Dann gibt es bei GCC die Konvention, dass dieser Wert über das Registerpaar EDX:EAX zurückgegeben wird. Auch bei der Instruktion IMUL wird das ja so gemacht. Im Falle eines Rückgabewertes vom Typ float ist es noch etwas komplizierter. Die Intel‐CPU hat ja eigene Register und einen eigenen Stack für Floating‐Point‐Operationen. [Details dazu gibt es in Matloffs Text zu „Arithmetic & Logic“.] 14.7.6 DER AUFRUF VON C‐ UND C‐BIBLIOTHEKSFUNKTIONEN AUS DEM ASSEMBLERCODE Die gleichen Grundsätze wie oben beschrieben gelten beim Aufruf einer C‐Funktion aus einem Assemblerprogramm. Wir müssen auch hier auf die Reihenfolge der Parameter aufpassen. Durch das Ausführen von gcc –S können wir das bei jeder C‐Funktion „ansehen“. Wie ist das, wenn wir ein C‐Bibliotheksfunktion wie etwa printf() aus einem Assemblerprogramm aufrufen möchten? Die Reihenfolge der Parameter kennen wir bereits, doch wie gehen wir beim Linken um? Am einfachsten ist es, wenn wir das Ganze gleich GCC machen lassen, denn GCC kann das automatisch. Sehen wir uns ein Beispiel dazu an. Wir verwenden wieder das erste Assembler‐Beispiel, wo wir die 4 Array‐Elemente addiert haben: .data
x:
.long 1
.long 5
.long 2
Unterprogramme
.long 18
sum:
.long 0
fmt:
.string “%d\n”
.text
.globl main
main:
movl $4, %eax
movl $0, %ebx
movl $x, %ecx
addl (%ecx), %ebx
addl $4, %ecx
decl %eax
jnz top
printsum:
pushl %ebx
pushl $fmt
call printf
done: movl %ebx, sum
#
#
#
#
#
EAX will serve as a counter
for the number of word left to be summed
EBX will store the sum
ECX will point to the current
element to be summed
top:
# move pointer to next element
# decrement counter
# if counter not 0, then loop again
Wir wollen die Summe mit Hilfe der C‐Bibliotheksfunktion printf() ausdrucken. Der Wert der Summe liegt im Register EBX. Der erste Parameter der C‐Funktion printf() ist ja bekannterweise der Format‐String. Damit meinen wir den Zeichen‐String, welcher das Format festlegt. Hier ist das also “%d\n“. Die folgenden Parameter stellen die Variablennamen dar, deren Werte ausgedruckt werden sollen. In unserem Beispiel ist dies lediglich einer, nämlich der Wert des Register EBX. Wir „pushen“ also zuerst den Wert des Registers EBX und danach den Format‐String, welcher bei uns den symbolischen Namen $fmt hat. Danach rufen wir das Unterprogramm printf() mit der Call‐Instruktion auf. Wir lassen das Ganze mit GCC übersetzen: gcc –g –o sum sum.s
Da wir GCC als Übersetzer verwenden, müssen wir uns über die einzubindenden C‐Bibliotheken keine Gedanken machen. Wir müssen uns jedoch an die Konvention halten, dass GCC als Hauptprogramm das Label main verlangt. Damit findet GCC den Startpunkt des Programmes – nämlich beim Label main. Wir dürfen also nicht wie in allen vorherigen Assemblerprogrammen das Label _start verwenden. GCC wird nämlich die sogenannte C‐Startup‐Bibliothek zu unserem Code dazu‐„linken“. Das Label _start ist aber in dieser Startup‐
Bibliothek. Wenn unser Programm hier – oder auch irgendein anderes C‐Programm mit der Exekution beginnt, dann startet es üblicherweise genau bei dem Label _start. Der Code beim Label _start macht dann einige „Initialisierungen“ und ruft dann das „Unterprogramm“ mit dem Label main auf. Deshalb müssen wir in obigem Beispiel also das Label main nehmen. Wir wissen nicht, welche Register die C‐Bibliotheksfunktion selbst verwendet. Es könnte ja sein, dass diese die Register EAX, ECX und/oder EDX verwendet. Die meisten C‐Funktionen haben zum Beispiel einen Return‐Wert – typischerweise ein Code, der über den Erfolg oder Misserfolg bei der Ausführung dieser C‐Funktion Auskunft gibt. Dieser Wert wird über das Register EAX zurückgegeben. Also aufpassen. 167
168 Rechnernetze und –Organisation 14.7.7 LOKALE VARIABLEN Der Compiler verwendet den Stack nicht nur für Argumente, sondern er speichert auch lokale Variablen dort. Sehen wir uns folgendes Beispiel (Datei sum.c) an: int sum(int *x, int n)
{
int i=0,s=0;
for (i = 0; i < n; i++)
s += x[i];
return s;
}
Lokale Variablen werden in umgekehrter Reihenfolge gespeichert: Die beiden Variablen i und s werden in benachbarten Speicherwörtern abgelegt. Zuerst wird i auf den Stack „gepusht“ und danach s. Damit liegt also i auf der höheren Adresse, da der Stack ja in Richtung Adresse 0 wächst. Schauen wir uns das etwas genauer an, indem wir den Code mit gcc –S übersetzen und uns dann das Resultat in Assemblercode ansehen. Verschiedene Versionen von GCC werden etwas verschiedene Assembler‐Codes erzeugen, doch der Code sieht in etwa so aus: ...
sum:
pushl
movl
subl
movl
movl
%ebp
%esp,
$8,
$0,
$0,
%ebp
%esp
-8(%ebp)
-4(%ebp)
Was sehen wir? Zuerst wird der Inhalt des Register EBP auf den Stack „gepusht“. Dann wird EBP mit dem Wert von ESP überschrieben. Diese „Standard‐Operation“ werden wir etwas später im nächsten Abschnitt genauer besprechen. Danach wird der Stack mit der Instruktion subl um 8 Bytes erweitert. Damit wird für die beiden lokalen Variablen am Stack „Platz hergerichtet“. Schließlich werden die beiden Speicherwörter am Stack mit 0 initialisiert. Wie bereits oben gesagt, machen hier nicht alle Versionen von GCC das Gleiche. Wenn man also C‐Code mit Assemblercode mischen möchte, sollte man immer das Ganze mit gcc –S genau ansehen. Die lokalen Variablen werden nicht wirklich im herkömmlichen Sinn auf den Stack „gepusht“ – es wird also nicht die Instruktion pushl verwendet. Es wird eigentlich am Stack nur „Platz geschaffen“. Später dann, wenn in einem anderen Code‐Stück des Unterprogramms sum (welches hier nicht gezeigt wird) die lokalen Variablen nicht mehr gebraucht werden, muss der Compiler kurz vor der Instruktion ret, also kurz vor dem Rücksprung die lokalen Variablen wieder vom Stack „poppen“. Ohne dieses „Weg‐Poppen“ würde die Maschine ja die erste lokale Variable als Rücksprungadresse interpretieren, welches ja Nonsens wäre. Unterprogramme
14.7.8 DIE VERWENDUNG DES REGISTER EBP 14.7.8.1 CALLING‐CONVENTSION VON GCC Wir haben weiter oben bereits festgehalten, dass wir das Register ESP nicht als allgemeines Register zum Speichern von Daten verwenden dürfen, sofern wir Unterprogrammaufrufe in unserem Programm haben. Mittlerweile sollte der Grund für diese Einschränkung klar sein. Es gibt noch eine Einschränkung: Wenn man Assembler‐Code mit C‐ oder C++‐Code zusammenbindet, dann sollte man auch das Register EBP nicht als Speicher für allgemeine Daten verwenden. Den Grund dafür besprechen wir jetzt. Sehen wir uns die ersten drei Instruktion der Implementierung von sum() an: pushl %ebp
movl
%esp, %ebp
subl
$8,
%esp
Dieser “Prolog” wird standardmäßig von C‐Compilern bei Intel‐Maschinen erzeugt. Der alte Wert des Registers EBP wird auf den Stack „gepusht“ und der laufende Wert des Registers ESP wird dann im Register EBP gespeichert. Schließlich wird der Stack durch Erniedrigung des Wertes von ESP erweitert, damit die lokalen Variablen am Stack „Platz kriegen“. Wenn man GCC verwendet, dann wird GCC dafür sorgen, dass das aufrufende Programm keine „lebenden“ Werte in den Registern EAX, ECX und/oder EDX hat. Deshalb braucht das aufgerufene Programm die Werte dieser Register vor der Verwendung dieser Register nicht sichern. Wenn jedoch das aufgerufene Unterprogramm die Register ESI, EDI oder EBX verwendet, dann muss das aufgerufene Programm diese Registerinhalte im Prolog selbst sichern. In der Computer‐Welt nennt man obige „Abmachungen“ die „Calling Conventions“. Es handelt sich dabei also um eine Menge von Regeln, welche man beim Programmierung von Unterprogrammen bei einer bestimmten Maschine und einem bestimmten Compiler einhalten muss. 14.7.8.2 DER STACK‐FRAME FÜR EINEN BESTIMMTEN UNTERPROGRAMMAUFRUF Unmittelbar nach der Exekution des Prologs irgendeiner C‐Funktion g() zeigt das Register EBP auf den Beginn des sogenannten Stack‐Frame. Das andere Ende des Stack‐Frame wird durch den Inhalt des Registers ESP definiert. Die Inhalte der Register EBP und ESP definieren also den Speicherbereich innerhalb des Stack, welcher als Rahmen (= Frame) für den gegenwärtigen Aufruf der Funktion g() dient. Sehen wir uns als 169
170 Rechnernetze und –Organisation Nächstes an, was in diesem Stack‐Frame alles drin ist. Dazu nehmen wir an, dass die Funktion g() von der Funktion f() aufgerufen worden ist. Wir sehen im Prolog‐Code, dass der Anfang des Stack‐Frame von g() eine Sicherung des Inhalts des Registers EBP vor dem Aufruf der Funktion g() hält. Wir haben hier also eine Kopie der Adresse des Beginns des Stack‐
Frames der aufrufenden Funktion f(). Der erste Wert des Stack‐Frames von g() ist also ein Pointer auf den Stack‐Frame von f(). Der Stack‐Frame von g() beinhaltet weiters auch die lokalen Variablen von g(). Wenn zudem die Funktion g() andere Registerwerte am Stack speichert (um die aufrufende Funktion f() „schadlos“ zu halten), dann sind diese auch Bestandteil des Stack‐Frames von g(). Während g() ausgeführt wird, zeigt der Inhalt des Registers ESP auf das Ende des Stack‐Frames. Wenn hingegen jetzt g() selbst ein Unterprogramm aufruft, sagen wir h(), dann kriegt h() seinen eigenen Stack‐
Frame. Dieser liegt dann auf noch niedrigeren Adressen als der Stack‐Frame von g(). Denn der Stack wächst ja in Richtung niedrigerer Adressen. Sehen wir uns an, wie der Stack‐Frame von g() aussieht kurz nachdem die Funktion g() aufgerufen wurde, und bevor der Prolog von h() ausgeführt wird: Nachdem der Prolog der Funktion h() ausgeführt wurde, wird sich ESP wieder ändern und dann das Ende des Stack‐Frames der Funktion h() definieren. Und EBP wird dann den Beginn des Stack‐Frames von h() fixieren. Wenn dann die Exekution von h() beendet wird, wird der Stack wieder „schrumpfen“. Der Wert in ESP wird also wieder erhöht werden und damit wieder das Ende des Stack‐Frames von g() festhalten. 14.7.8.3 DIE STACK‐FRAMES SIND VERKETTET Unterprogramme
Jeder Stack‐Frame hat also am Anfang einen Pointer zum Beginn des Stack‐Frames der aufrufenden Funktion. Dies führt dazu, dass die ersten Elemente der Stack‐Frames zusammen eine verkettete Liste darstellen. Wir können also den Stack mit Hilfe von Call‐Befehlen „tracen“. Der Wert im Register EBP zeigt auf den derzeitigen Rahmen („Frame“). Diesen können wir als Ausgangspunkt verwenden. In GDB ist dies mit dem „Backtrace“‐Kommando bt möglich. Sehen wir uns diesen Befehl im nächsten Beispiel an: void h(int *w)
{
int z;
*w = 13 * *w;
}
int g(int u)
{
int v;
h(&u);
v = u + 12;
}
main()
{
int x,y;
x = 5;
y = g(x);
}
Wir übersetzen und führen dieses Programm in GDB aus. (gdb) b h
Breakpoint 1 at 0x804837a: file bt.c, line 3.
(gdb) r
Starting program: /home/test/RNO/Teil_D/Unterprogramme/a.out
Breakpoint 1, h (w=0xbf998dbc) at bt.c:3
3
*w = 13 * *w;
(gdb) bt
#0
h (w=0xbf998dbc) at bt.c:3
#1
0x080483a3 in g (u=5) at bt.c:8
#2
0x080483d1 in main () at bt.c:16
Wir befinden uns jetzt in der Funktion h(), welche als Frame 0 (“#0“) oben angezeigt wird. Die Funktion h() wurde von der Adresse 0x08048360 in der Funktion g() aufgerufen. Die Funktion g() stellt den Frame #1 dar. Die Funktion g() wiederum wurde von der Adresse 0x0804839c in der Funktion main() aufgerufen. Die Funktion main findet sich unter dem Frame #2 oben. Mit GDB können wir vorübergehend den Kontext in einen anderen Frame wechseln. Zum Beispiel können wir in den Frame 1 wechseln und dort „herum schauen“: (gdb) f 1
#1
0x080483a3 in g (u=5) at bt.c:8
8
h(&u);
(gdb) p u
$1 = 5
(gdb) p v
$2 = -1208813047
171
172 Rechnernetze und –Organisation Du solltest verstehen, wie GDB, das ja auch nur ein Programm ist, Obiges machen kann. GDB inspiziert die Stack‐Frames der einzelnen Funktionen. Und GDB kommt von einem Frame zum nächsten über das erste Element jedes Frame, welcher ja einen Pointer zum Beginn des Stack‐Frames der aufrufenden Funktion darstellt. Mit dieser Struktur von Stack‐Frames stellt der Compiler sicher, dass wir immer eine Rücksprungadresse finden und dass wir auf die Argumente etc. zugreifen können. 14.7.8.4 DIE INSTRUKTIONEN ENTER UND LEAVE Weiter oben haben wir angemerkt, dass die aufrufende Funktion nach der Rückkehr von einem Unterprogramm den Stack „aufräumen“ muss. Damit meinen wir, dass die Argumente, welche vor dem Unterprogrammaufruf auf den Stack gelegt wurden, nachher wieder von diesem „entfernt“ werden müssen. Dies geschieht ja dadurch, dass der Wert im Register ESP entsprechend erhöht wird. Die Werte selbst am Stack werden ja zu diesem Zeitpunkt nicht gelöscht. Auch der Wert im Register EPB wird auf den Wert vor dem Prolog „restauriert“: movl %ebp, %esp
popl %ebp
Wir nennen diesen Teil den „Epilog“. Der Code im Prolog und im Epilog kommen bei Intel‐CPUs so oft vor, dass diese bereits in der Hardware als die Instruktionen ENTER und LEAVE eingebaut sind. Der Prolog (siehe weiter oben) besteht ja aus 3 Instruktionen, welche am Stack für 8 Bytes Platz für die lokalen Variablen geschaffen haben. Dieser Prolog kann auch mit der einzigen Instruktion enter 8, 0
programmiert werden. Dabei hat die 0 im Flat‐Speicher‐Modus (den wir durchgehend verwenden) keine Bedeutung. Der Epilog, welcher aus den obigen 2 Instruktionen besteht, kann mit dem Befehl leave
programmiert werden. GCC verwendet derzeit zwar LEAVE, aber nicht ENTER. Dies deshalb, da ENTER auf den meisten Maschinen heutzutage langsamer ist als die ursprüngliche Sequenz bestehend aus 3 Instruktionen. 14.7.8.5 BACKTRACING IN C Im nachfolgenden Beispiel wollen wir uns in C eine Funktion programmieren, welche ähnlich wie Backtrace in GDB funktioniert. Ein User‐Programm ruft getchain() auf. getchain() liefert die Liste aller Rückkehradressen aller aktiven Stack‐Frames. Zuerst muss man im User‐Programm die Funktion initbase() aufrufen. Damit wird die Adresse des Stack‐Frames von main() ermittelt und in der Variablen base gespeichert. Wir dürfen getchain() nicht im Hauptprogramm main() aufrufen. Der C‐Prototyp ist void getchain(int *chain, int *nchain)
Der Pointer *chain zeigt auf genügend Platz für das Array chain; auch die Variable nchain, welche die Länge des Arrays chain fixiert, wird von der aufrufenden Funktion bereitgestellt. Unterprogramme
Das folgende Bild des Stacks liegt dem Code zugrunde: .data
base: # address of main()‘s stack frame
.long 0
.text
.gobl initbase, getchain
initbase:
# initializes base
movl %ebp, base
ret
getchain:
# register usage:
# EAX will point to the current element of chain to be filled
# EBX will point to the frame currently being examined
push %eax
push %ebx
push %ecx
movl 16(%esp), %eax
# put in the return address from this subroutine, getchain()
# as the first element of the chain
movl 12(%esp), %ecx
movl %ecx, (%eax)
addl $4, %eax
# EBP still points to the caller’s frame, perfect since we don’t want
# to include the frame for this subroutine
movl %ebp, %ebx
top:
# if this is main()’s frame, then leave
cmpl %ebx, base
jz done
# get return address and add it to chain
movl 4(%ebx), %ecx
movl %ecx, (%eax)
addl $4, %eax
# go to next frame
movl (%ebx), %ebx
jmp top
done:
# find nchain, by subtracting start of chain from EAX and adjusting
subl 16(%esp), %eax
shrl $2, %eax
movl 20(%esp), %ebx
movl %eax, (%ebx)
pop %ecx
pop %ebx
pop %eax
173
174 Rechnernetze und –Organisation ret
Beispiele für den Aufruf obiger Funktion: int chain[5], nchain;
void h(int *w)
{
int z;
int chain[10], nchain;
z = *w + 28;
getchain(chain, &nchain)
*w = 13 * z;
}
int g(int u)
{
int v;
h(&u);
v = u + 12;
getchain(chain, &nchain);
return v;
}
main()
{
int x, y;
initbase();
x = 5;
y = g(x);
}
14.7.9 DIE INSTRUKTIONSFAMILIE LEA LEA steht für „Lade Effective Adresse“. LEA rechnet die Speicheradresse des ersten Operanden aus und speichert diese Adresse im zweiten Operanden. Beispiel: leal
-4(%ebp), %eax
Es wird dabei der Inhalt des Registers EBP genommen, der Wert 4 subtrahiert und das Ergebnis wir im Register EAX abgelegt. Wenn wir etwa in einer C‐Funktion eine einzige lokale Variable mit Namen z hätten – diese würde ja auf dem Stack liegen –, dann würde obige Instruktion deren Adresse &z ermitteln und im Register EAX speichern. Der Compiler verwendet dies sehr oft. 14.7.10 DIE FUNKTION MAIN() IST AUCH EINE FUNKTION; SIE HAT AUCH EINEN STACK‐FRAME Unterprogramme
Es sollte jetzt klar sein, dass die Funktion main() auch „nur“ eine Funktion ist. Auch main() hat seinen Stack‐
Frame. Die Funktion main() wird ja typischerweise so aufgerufen: int main(int argc, char **argv)
Daran erkennt man schon, dass main() eine Funktion sein muss. Der Wert, welcher von main() zurückgegeben wird, ist ein Integer. Typischerweise stellt diese Zahl dar, ob der Aufruf der Funktion erfolgreich oder nicht erfolgreich war. Sehen wir uns das etwas genauer an: Bei der Kompilation eines Programms wird vom Compiler – na ja, eigentlich ist es der Linker – in das Programm Code von C‐Bibliotheken dazu‐„gelinkt“, welcher beim Starten des Programmes verwendet wird. Wie schon früher bei unseren selbst geschriebenen Assembler‐Programmen gibt es das Label _start. Bei diesem Label beginnt die Ausführung des Programms. Der Code muss dann als Erstes den Stack vorbereiten. Es müssen auch die Argumente argc und argv auf den Stack „gepusht“ werden. Danach wird die Funktion main() aufgerufen. Vielleicht sollten wir hier anmerken, dass die beiden Variablen argc und argv überhaupt nicht so heißen müssen. Es ist lediglich üblich, diese so zu nennen. Wenn du genau nachdenkst, dann weißt du, dass die formalen Parameter einer C‐Funktion beliebig genannt werden können. Wenn die Funktion aufgerufen wird, schert sich die aufrufende Funktion – in unserem Fall hier der sogenannte Startup‐Code – überhaupt nicht um die Namen der formalen Parameter, mit welcher die aufgerufene Funktion (also main() hier) definiert wurde. Wenn wir also beginnen, die Funktion main() auszuführen, dann befinden sich am Stack die Rückkehradresse, danach argc, dann argv, und schließlich Platz für die lokalen Variablen der Funktion main(). Sehen wir uns das an einem Beispiel an (Datei argv.c): main(int argc, char** argv)
{
int
i;
printf(“%d %s\n”, argc, argv[1]);
}
Wir übersetzen obiges Programm mit gcc –S und erhalten folgenden Code (oder eine leicht veränderte Variante, je nach Compiler). Ich habe den Code mit Kommentaren versehen. Dabei gehen wir davon aus, dass die exekutierbare Datei a.out heißt und mit folgenden beiden Argumenten aufgerufen wird: %a.out abc def. Damit kriegt argc zur Laufzeit den Wert 3. Und argv zeigt auf ein Array bestehend aus 3 Pointer. Der erste zeigt auf einen String, welcher den Pfad plus den Dateinamen der soeben ausgeführten Datei darstellt. Der zweite Pointer zeigt auf den String “abc“ und der dritte Pointer auf den String “def“. Wie in C üblich, werden Strings mit dem Null‐Zeichen („\0“) abgeschlossen. Hier also der Assemblercode: .file "argv.c"
.section
.rodata
.LC0:
.string
"%d %s\n"
.text
.globl main
.type main, @function
main:
leal
4(%esp), %ecx
andl
$-16, %esp
# after call to main: ESP points to return address
# ECX = ESP+4: ECX holds pointer to argc
# set ESP to the next lower address which is
175
176 Rechnernetze und –Organisation pushl
pushl
movl
pushl
subl
movl
addl
movl
movl
-4(%ecx)
%ebp
%esp, %ebp
%ecx
$36, %esp
4(%ecx), %eax
$4, %eax
(%eax), %eax
%eax, 8(%esp)
movl
movl
(%ecx), %eax
%eax, 4(%esp)
movl
call
addl
popl
popl
$.LC0, (%esp)
printf
$36, %esp
%ecx
%ebp
leal
-4(%ecx), %esp
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
0 modulo 16
push ECX-4 on stack: is return value
push EBP: save caller’s base pointer
save ESP in EBP: set new base pointer
save value of ECX: points to argc
increase stack by 9 words
get address of **argv into EAX
now EAX points to argv[1]
now EAX points to first argument ("abc")
pointer to first argument ("abc"),
moved to two words below TOS
get argc (=3) to EAX
second parameter of printf one below TOS
(argc, 3)
address of 1st parameter of printf on TOS
after call: "pop" 9 words from stack
restore ecx: points now to argc on stack again
restore ebp: EBP has now caller's base pointer
again
restore esp: ESP points now to return address
again
ret
.size main, .-main
.ident "GCC: (GNU) 4.1.2 20080704 (Red Hat 4.1.2-46)"
.section
.note.GNU-stack,"",@progbits
Beim Eintritt in die Funktion main() befinden sich die Argumente argc und argv am Stack 4 Bytes bzw. 8 Bytes „unterhalb“ (also auf höheren Adressen) von ESP. Diese wurden dort vor dem Call‐Befehl, mit welchem die Funktion main() aufgerufen wurde, vom aufrufenden Programm auf den Stack „gepusht“. ESP
Rücksprungadresse zum aufrufenden Programm
argc
**argv
Mit Hilfe der Kommentare solltest du den erzeugten Assemblercode der Funktion main() verstehen. Du solltest den Code auch in GDB durchtesten. Dabei kannst du viel lernen. Der Prolog sieht so aus: Zu Beginn wird das Register ECX mit der Adresse von argc geladen: leal
4(%esp), %ecx
# ECX = ESP+4: ECX holds pointer to argc
Dann wird der Wert in ESP auf die nächstniedrigere Adresse, welche 0 modulo 8 ergibt, gesetzt. Zur Erinnerung: Die Zahl ‐16 schaut in Zweierkomplementdarstellung so aus: 0xfffffff0. Mit dem Befehl AND werden also die rechten 4 Bits des Wertes von ESP auf 0 gezwungen. andl
$-16, %esp
# set ESP to the next lower address which is 0 modulo 16
Unterprogramme
Damit beginnen wir den für diese Funktion wichtigen Teil des Stacks auf einer „runden” Adresse. Es kann also sein, dass jetzt ein paar Wörter am Stack „ausgelassen werden“. Jetzt wird „neu angefangen“: Zuerst wird die Return‐Adresse auf den Stack „gepusht“. Diese ist jetzt also 2 Mal am Stack: pushl -4(%ecx)
# push ECX-4 on stack: is return value
Dann wird EBP auf den Stack „gepusht“. Das kennen wir schon. Es ist dies die Adresse zum Beginn des Stack‐
Frames der aufrufenden Funktion. In diesem Fall also die Funktion, welche main() aufruft. Sodann kriegt das Register EBP den Wert von ESP. Wir haben also jetzt in EBP einen Pointer zum Beginn des Stack‐Frames der soeben ausgeführten Funktion main(): pushl %ebp
movl
%esp, %ebp
# push EBP: save caller’s base pointer
# save ESP in EBP: set new base pointer
Sodann wird der Wert von ECX, also die Adresse des Arguments argc, auf den Stack „gepusht“. Damit wird das Register ECX für andere Aufgaben “frei”. pushl %ecx
# save value of ECX: points to argc
Richtung Adresse 0
Damit ist die Präambel des Funktionsaufrufs von main() fertig. Der Stack sieht zu diesem Zeitpunkt also so aus: Danach kommt die Vorbereitung des Aufrufes der Funktion printf(). Zuerst wird der Stack um 9*4 Bytes, also um 9 Speicherworte erweitert. Damit wird Platz für die lokale Variable i sowie für „mehrere“ Argumente der Funktion printf() geschaffen. subl
$36, %esp
# increase stack by 9 words
In den nächsten 4 Befehlen wird ein Pointer auf den ersten Zeichenstring, welcher unserem Programm bei Programmstart übergeben wurde, ermittelt. Ein Stück weiter unten haben wir in GDB zum Beispiel den String „abc“ übergeben: movl
addl
movl
movl
4(%ecx), %eax
$4, %eax
(%eax), %eax
%eax, 8(%esp)
#
#
#
#
#
get address of **argv into EAX
now EAX points to argv[1]
now EAX points to first argument ("abc")
pointer to first argument ("abc"),
moved to two words below TOS
Wir sehen hier die doppelte Indirektion: Zuerst kommt **argv ins Register EAX. Das ist ja ein Pointer auf den Beginn eines Pointer‐Arrays. Dieses Array beginnt mit dem Pointer argv[0], danach kommt der Pointer auf argv[1] und so weiter. Indem wir also zum Register EAX den Wert 4 hinzuzählen, kriegen wir einen Pointer 177
178 Rechnernetze und –Organisation auf argv[1]. Diesen brauchen wir ja als drittes Argument für die Funktion printf(). Wir gehen im dritten Befehl oben diesem Pointer nach und holen uns damit die Startadresse des Strings „abc“. Im vierten Befehl wird dieser Pointer auf die Speicherstelle zwei Worte „unterhalb“ des Top‐of‐Stack kopiert. Als Nächstes wird der zweite Parameter für den Aufruf der Funktion printf() auf den Stack kopiert. Es ist dies argc. Diese Aktion läuft auch über das Register ECX, welcher ja schon seit Längerem auf das oberste Argument, welches der Funktion main() übergeben, wird zeigt. Der Parameter argc, der ja bei unserem beispielhaften Aufruf des Programms den Wert 3 hat, landet ein Wort „unter“ dem Top‐of‐Stack. movl
movl
(%ecx), %eax
%eax, 4(%esp)
# get argc (=3) to EAX
# second parameter of printf one below TOS(argc, 3)
Jetzt kommt noch ein Pointer auf den Zeichenstring, welcher das Format für die Funktion printf() festlegt, auf den Stack. Die symbolische Adresse dieses Strings heißt ja $.LC0. movl
$.LC0, (%esp)
# address of 1st parameter of printf on TOS
Richtung Adresse 0
9 Speicherworte
Der Stack sieht jetzt also so aus: Jetzt wir die Funktion printf() aufgerufen. Diese findet vereinbarungsgemäß die drei übergebenen Parameter. call
printf
#
Nach dem Return von printf() müssen wir den Stack in umgekehrter Reihenfolge wieder „abbauen”. Also zuerst die 9 Wörter „wegnehmen“, indem wir den Wert in ESP um 36 erhöhen. addl
$36, %esp
Dann ECX und EBP „restaurieren”: # after call: "pop" 9 words from stack
Unterprogramme
popl
popl
%ecx
%ebp
# restore ecx: points now to argc on stack again
# restore ebp: EBP has now caller's base pointer again
ECX hat jetzt den ursprünglichen Wert vom anfänglichen Wert in ESP plus 4 dazu. Siehe dazu die erste Instruktion von main(). Wir führen also ein „Restore“ des ESP‐Wertes durch und kehren dann zur aufrufenden Funktion zurück: leal
ret
-4(%ecx), %esp
# restore esp: ESP points now to return address again
Das war’s. Wie sieht das jetzt genau mit „char **argv“ aus? Am Stack liegt wie gesagt ein Pointer. Den habe ich links im nachfolgenden Bild dargestellt. Der Pointer zeigt auf den Beginn eines Arrays bestehend aus 3 Pointern. Dass es genau drei sind, wissen wir lediglich vom Argument argc. (!) Diese drei Pointer zeigen auf Strings. Probieren wir obigen C‐Code in GDB aus und überprüfen die Wirklichkeit mit unserer Analyse. Zuerst übersetzen wir den C‐Quellcode in Assemblercode, assemblieren dann mit der Option –-gstabs, damit wir Debug‐Informationen für GDB einbauen, linken und starten dann GDB im Quiet‐Mode (-q). Wir setzen sodann einen Breakpoint auf Zeile 9 im Assemblercode. In dieser Zeile befindet sich der erste Befehl des Programms main(). Wir starten den Programmlauf mit dem Run‐Kommando und zwei Argumenten: „r abc def“. %gcc –S argv.c
%as –-gstabs –o argv.o argv.s
%gcc –g argv.o
% gdb -q a.out
(gdb) b 9
Breakpoint 1 at 0x80483ae: file argv.s, line 12.
(gdb) r abc def
Starting program: /home/test/RNO/Teil_D/Unterprogramme/a.out abc def
Breakpoint 1, main () at argv.s:9
9
leal
4(%esp), %ecx
Current language: auto; currently asm
Siehe dir die Zeile an, die mit “Starting program” beginnt. Dort siehst du die drei Zeichen‐Strings, auf welche argv[0], argv[1] und argv[2] zeigen. 179
180 Rechnernetze und –Organisation GDB hält beim Breakpoint an. Dies ist vor der Ausführung des Befehls „pushl %ebp“. Sehen wir uns den Stack mal an. Auf dem Stack müssten ja jetzt die Rücksprungadresse, danach das Argument argc und danach das Argument argv zu finden sein. (gdb) x/3x $esp
0xbff88bcc: 0xb7e1a455
0x00000003
0xbff88c54
Sehr gut: Wir finden argc = 3. Und danach sollte wohl der Pointer auf das Array beginnend mit argv[0] sein. Probieren wir’s aus. Es sollten ja 3 Pointer sein: (gdb) x/3x 0xbff88c54
0xbff88c54: 0xbff8a64c
0xbff8a677
0xbff8a67b
(gdb) x/s 0xbff8a64c
0xbff8a64c: “/home/test/RNO/Teil_D/Unterprogramme/a.out”
Tatsächlich: Wir haben den ersten Parameter im Speicher gefunden. Sehen wir nach den beiden anderen: (gdb) x/s 0xbfdd6677
0xbfdd6677:
"abc"
(gdb) x/s 0xbfdd667b
0xbfdd667b:
"def"
(gdb)
Der Bibliothekscode für die Funktion main() kennt auch einen dritten Parameter. Dieser ist ein Pointer auf die Umgebungsvariablen des Programms. Diese stellen das Arbeitsverzeichnis, den Such‐Pfad, den Username usw. zur Verfügung. Will man main mit 3 Parametern aufrufen, dann geht das so: int main(int argc, char **argv, char**envp)
14.7.11 WICHTIG: ES GIBT IN DER HARDWARE KEINE DATENTYPEN In der Einführungslehrveranstaltung zu Programmieren lernt man – zu Recht! – die Bedeutung des „Scope“ von Variablen. Mit „Scope“ meinen wir, auf welche Variablen man in einem bestimmten Teil eines Programmes zugreifen kann und auf welche nicht. Es ist jedoch lediglich der Compiler, welcher festlegt, auf welche Variablen wir zugreifen können und auf welche nicht. Der Compiler stellt also diesen „Wächter“ dar. Die Hardware verbietet diesen Zugriff nicht. Es gibt also kein „Scope“ auf Hardware‐Ebene. Jede Instruktion, welche sich im Programm befindet, kann im Prinzip auf jedes Datum irgendwo im Speicher zugreifen. In C++ lernt man: „A private member of a class cannot be accessed from anywhere outside the class.“ Das ist falsch! Man sollte besser so sagen: „The compiler will refuse to compile any C++ code you write which attempts to access by name a private member of a class from anywhere outside the class.“ Also nochmals: Der Compiler passt auf, nicht die Hardware. Und es ist gut so, dass der Compiler das so macht. Mit „Scope“ können wir unseren Code und unsere Daten besser organisieren. Aus Hardware‐Sicht bedeutet dies jedoch nichts. Sehen wir uns dazu folgendes Beispiel an (Datei change_private_member.cpp): #include <iostream>
Unterprogramme
using namespace std
class c {
private:
int x;
public:
c();
// constructor
// printx() prints out x
void printx() { cout << x << endl; }
};
c::c()
{ x = 5; }
int main(int argc, char **argv[])
{
c ci;
ci.printx(); // prints 5
// now point p to ci, thus to the first word at ci, i.e. x
int *p = (int *) &ci;
*p = 29;
// change x to 29
ci.printx(); // prints 29
}
Wir sehen die Klasse c, welche die Variable x als „private member“ hat. Diese Variable x ist natürlich im Speicher. Wir können auf Variablen im Speicher zugreifen, indem wir einen Pointer auf die Adresse dieser Variablen setzen. Das haben wir im obigen Beispiel gemacht. Wir haben sodann sogar den Wert von x in einer Instanz der Klasse c mittels Code, welcher nicht innerhalb der Klasse c ist, verändert. Man kann das auch mit lokalen Variablen machen. Wir könnten sogar auf eine lokale Variable einer Funktion aus dem Code in einer anderen Funktion zugreifen. Das folgende Beispiel zeigt dies (Datei change_local_variable.c), wobei der Compiler zum Source‐Code dazu passen muss. Dieser letzte Satz ist wichtig! Bei mir ging das in MSYS zusammen mit MINGW. Auf Linux, GCC‐Version 4.3.2 unter Debian 4.3.2‐1.1 ging es so nicht. Da müsste man sich den Assembler‐Code genau ansehen, dann den C‐Quellcode entsprechend anpassen und dann ginge es wohl wieder. void g()
{
int i, *p;
p = &i;
p = p+1;
p = (int *)*p;
p = p – 1;
*p = 29;
}
//
//
//
//
p now points
p now equals
p now points
changes x to
to main()’s ESP
main()‘s EBP value
to main()’s x
29
main()
{
int x = 5;
// x is local to main()
g();
printf(“%d\n”, x); // prints out 29
}
14.7.12
WIE IST DAS MIT C+? 181
182 Rechnernetze und –Organisation Wenn das aufrufende Programm in C++ geschrieben wurde – statt wie oben in C –, dann muss man den Compiler informieren, dass die Assembler‐Routine in „C style“ gehalten ist. Das macht man, indem man folgende Zeile in den C++‐Quellcode einfügt: extern “C“ void addone(int *);
Das muss man machen, da die Assembler‐Routine bei der Verwendung von EBP etc. die C‐Konventionen verwendet. In C++ ist bei den Non‐Static‐Funktionen auch der this‐Pointer ein Argument. Die Konvention dabei ist, dass dieses Argument als Letztes auf den Stack „gepusht“ wird, also wie ein erstes Argument gehandhabt wird. 14.7.13 ZUSAMMENFASSUNG In einem früheren Abschnitt haben wir die Funktion sum behandelt. Wir wollen diese Funktion jetzt als Beispiel für die Zusammenfassung mehrerer Konzepte, welche wir besprochen haben, verwenden. Zuerst nochmals der C‐Quellcode: int sum(int *x, int n)
{
int i=0, s=0;
for (i = 0; i < n; i++)
s += x[i];
return s;
}
Der kompilierte Code sieht so aus: .file
.text
.align
.globl sum
.type
sum:
pushl
movl
subl
movl
movl
movl
.L2:
movl
cmpl
jl
jmp
.L5:
movl
leal
movl
movl
“sum.c”
2
sum,@function
%ebp
%esp, %ebp
$8(%esp)
$0,
-4(%ebp)
$0,
-8(%ebp)
$0,
-4(%ebp)
-4(%ebp), %eax
12(%ebp), %eax
.L5
.L3
-4(%ebp), %eax
0(,%eax,4), %edx
8(%ebp), %eax
(%eax,%edx), %edx
Unterprogramme
leal
addl
leal
incl
jmp
-8(%ebp), %eax
%edx, (%eax)
-4(%ebp), %eax
(%eax)
.L2
movl
leave
ret
-8(%ebp), %eax
.L3:
.Lfe1:
.size
sum,.Lfe1-sum
.ident “GCC: (GNU) 3.2 20020903 (Red Hat Linux 8.0 3.2-7)”
Wir sehen, dass der Compiler zuerst den Standard‐Prolog an den Beginn des Codes gesetzt hat: sum:
pushl
movl
subl
%ebp
%esp, %ebp
$8(%esp)
Dabei hat er für 2 lokale Variablen am Stack Platz reserviert. Danach werden diese lokalen Variablen mit 0 initialisiert: movl
movl
$0,
$0,
-4(%ebp)
-8(%ebp)
Unser Code setzt i sogar gleich 2 Mal auf 0. Der Compiler macht das auch, da wir vom Compiler keine Code‐
Optimierung verlangt haben. In der For‐Schleife wird dann i mit n verglichen: movl
cmpl
jl
jmp
-4(%ebp), %eax
12(%ebp), %eax
.L5
.L3
Im Inneren der For‐Schleife wird x[i] zur Summe sum addiert: .L5:
movl
leal
movl
movl
leal
addl
-4(%ebp), %eax
0(,%eax,4), %edx
8(%ebp), %eax
(%eax,%edx), %edx
-8(%ebp), %eax
%edx, (%eax)
Wir sehen, wie die Instruktion LEA verwendet wird. Zudem sehen wir einige komplexere Adressierungsmodi. Um zum Beginn der For‐Schleife wieder zu kommen, müssen wir zuerst i inkrementieren: leal
incl
jmp
-4(%ebp), %eax
(%eax)
.L2
183
184 Rechnernetze und –Organisation Wenn wir mit der Funktion fertig sind, dann muss der Summenwert sum ins Register EAX kopiert werden und danach der Stack aufgeräumt werden. Schließlich kommt der Rücksprung: movl
leave
ret
-8(%ebp), %eax
Wir sehen hier die Verwendung der Instruktion LEAVE. Was wäre, wenn wir die lokale Variable s als statisch deklariert hätten (Datei sum_static_s.c)? int sum(int *x, int n)
{
int i=0, static s=0;
for (i = 0; i < n; i++)
s += x[i];
return s;
}
Damit würden wir sagen, dass diese Variable s ja seinen Wert über mehrere Aufrufe der Funktion sum beibehält. Damit ist es nicht möglich, den Stack als Speicherplatz für s zu verwenden. Dort würde s zwischen verschiedenen Aufrufen der Funktion sum wohl überschrieben werden. Deshalb muss sie in der Sektion .data gespeichert werden. Unser Code initialisiert diese statische Variable s auf Null. Doch wie wir sehen werden, macht der übersetzte Code das nur einmal. Sehen wir uns den Code an: .file
.data
.align
.type
.size
“sum_static_s.c”
4
s.0,@object
s.0,4
s.0:
.long
.text
.align
.globl sum
.type
sum:
pushl
movl
subl
movl
movl
.L2:
movl
cmpl
jl
jmp
.L5:
movl
leal
movl
movl
addl
0
2
sum,@function
%ebp
%esp, %ebp
$4(%esp)
$0,
-4(%ebp)
$0,
-4(%ebp)
-4(%ebp), %eax
12(%ebp), %eax
.L5
.L3
-4(%ebp), %eax
0(,%eax,4), %edx
8(%ebp), %eax
(%eax,%edx), %eax
%eax, s.0
Unterprogramme
leal
incl
jmp
-4(%ebp), %eax
(%eax)
.L2
movl
leave
ret
s.0, %eax
.L3:
.Lfe1:
.size
sum,.Lfe1-sum
.ident “GCC: (GNU) 3.2 20020903 (Red Hat Linux 8.0 3.2-7)”
Wir sehen am folgenden Code‐Stück, dass unsere Variable s jetzt als Label s.0 in der Sektion .data abgelegt wurde: movl
leave
ret
s.0, %eax
Anmerkung: Auf der Maschine pluto.tugraz.at wird obige Datei „sum_static_s.c“ so übersetzt: .file "sum_static_s.c"
.local s.1284
.comm s.1284,4,4
.text
.globl sum
.type sum, @function
sum:
pushl %ebp
movl
%esp, %ebp
subl
$16, %esp
movl
$0, -4(%ebp)
movl
$0, -4(%ebp)
jmp
.L2
.L3:
movl
-4(%ebp), %eax
sall
$2, %eax
addl
8(%ebp), %eax
movl
(%eax), %edx
movl
s.1284, %eax
leal
(%edx,%eax), %eax
movl
%eax, s.1284
addl
$1, -4(%ebp)
.L2:
movl
-4(%ebp), %eax
cmpl
12(%ebp), %eax
jl
.L3
movl
s.1284, %eax
leave
ret
.size sum, .-sum
.ident "GCC: (GNU) 4.1.2 20080704 (Red Hat 4.1.2-46)"
.section
.note.GNU-stack,"",@progbits
14.8 UNTERPROGRAMMAUFRUFE SIND TEUER Auf den meisten CPUs bedeuten Unterprogrammaufrufe und die dazugehörenden Rücksprünge einen erheblichen „Overhead“. Es müssen nicht nur eigene Instruktionen eingeführt werden, sondern es ist vor allem 185
186 Rechnernetze und –Organisation der Zugriff auf den Stack, der ja im Speicher liegt, zeitintensiv. CPU‐Entwürfe werden sehr oft danach beurteilt, wie dies beschleunigt werden kann. Ein Ansatz liegt darin, für den Stack einen eigenen Cache bereit zu stellen. Da der Stack ja im Speicher angesiedelt ist, kann ja ein Teil des Stacks als Kopie im normalen Cache liegen. Wenn man für den Stack einen eigenen Cache bereit hält, dann würde sich die Cache‐Miss‐Rate bei Stack‐Zugriffen auf ein Minimum beschränken. Ein noch besserer Ansatz läge darin, die CPU so zu entwerfen, dass der obere Teil des Stacks, also der Teil, der soeben verwendet wird, innerhalb der CPU selbst angesiedelt ist. So haben das die Entwerfer der SPARC‐CPU (von Sun Microsystems) gemacht. Schon in den 60er‐ und 70er‐Jahren wurde das bei den Burroughs‐
Mainframe‐Maschinen so gemacht. Dieser Ansatz ist sogar noch besser als ein Spezial‐Cache, da jetzt die ganze Spezial‐Hardware für die Cache‐Hit&Miss‐Logik wegfällt. Damit wird der Chip nicht nur schneller, sondern eventuell sogar auch kleiner. Andererseits ist Silizium‐Platz auf dem CPU‐Chip sehr kostbar. Man muss also immer den sogenannten „Tradeoff“ zwischen Kosten und Nutzen beachten. 14.9 DEBUGGING ASSEMBLY LANGUAGE SUBROUTINES …lies die englische Version des Textes von Matloff. 14.10 MACROS …lies die englische Version des Textes von Matloff. 187
15 INPUT/OUTPUT Du findest den Text zu diesem Kapitel in Matloff’s Dokument “Overview of Input/Output Mechanisms” 15.1 EINLEITUNG 15.2 I/O‐PORTS 15.3 ZUGRIFF AUF I/O‐PORTS AUS EINEM PROGRAMM 15.3.1 VARIANTE A: I/O‐ADRESSBEREICH 15.3.2 VARIANTE B: MEMORY‐MAPPED‐I/O 15.4 WARTESCHLEIFEN 15.5 PC‐TASTATUREN 15.6 INTERRUPT‐GESTEUERTE I/O 15.6.1 ANALOGIE MIT TELEFON 15.6.2 WAS PASSIERT, WENN EIN INTERRUPT AUFTRITT? 15.6.3 ALTERNATIVEN 188 15.6.4 INTERRUPT‐SERVICE‐ROUTINEN: EIN KURZER BLICK DRAUF 15.6.5 I/O PROTECTION 15.6.6 WIE UNTERSCHEIDET MAN ZWISCHEN VERSCHIEDENEN GERÄTEN? 15.6.7 INTERRUPT‐PRIORITÄTEN 15.7 DIRECT MEMORY ACCESS 15.8 HARDDISKSTRUKTUREN 15.9 USB‐GERÄTE