Philipps – Universität Marburg
Fachbereich
Mathematik und Informatik
Implementierung eines DLX-Simulators in
der funktionalen Sprache Haskell
Informatikpraktikum
im Grundstudium SS 2000
Gruppe
1.1 – „Ausführung/Pipelining“
Teilnehmer
:
Martin
Kaletsch, Olaf Siefart, Adam Sosnowski, Yan Sun, Nils Weskamp
Betreuer
:
Steffen
Priebe
Abgegeben
am 10.07.2000
Grundlage
dieser Arbeit ist das Buch „Computer Architechture – A quantitative approach“
von Hennessy/Patterson
Abstract
In
dem Buch von [Hennessy/Patterson] wird mit DLX ein einfaches Modell einer
RISC-Architektur vorgestellt. Das Ziel unseres Projektes war es, einen CPU –
Simulator für DLX in der funktionalen Sprache Haskell zu erstellen. Die
Verwendung des funktionalen Programmierkonzeptes ermöglichte uns die Erstellung
einer „ausführbaren Spezifikation“, d.h. die Phasen für Spezifikation und
Entwicklung konnten parallel durchgeführt werden.
Die
Aufgabe unserer Gruppe bestand insbesondere darin, die eigentliche CPU mit
Pipelining und Strategien zur Vermeidung von Hazards zu modellieren.
Inhaltsverzeichnis
1. Einführung
2. Grundlagen
3. Hauptteil
3.1.1 InstructionFetch
3.1.2 InstructionDecode
3.1.3 Execute
3.1.4 MemoryAccess
3.1.5 WriteBack
3.2 Pipelineverwaltung, Hazards
3.5 Schnittstellen
6. Literatur
Die drastischen Steigerungen in der
Verarbeitungsgeschwindigkeit moderner Prozessoren sind nicht allein auf
gesteigerte Taktraten zurückzuführen, sondern ließen sich nur durch erhebliche
Veränderungen in der Architektur der CPUs realisieren. Früher ging es vor allem
darum, einen möglichst umfangreichen Befehlssatz (CISC) zur Verfügung zu
stellen [der durch die Nutzung von Microcode realisiert wurde] und die
Verarbeitungsgeschwindigkeit der einzelnen Befehle so niedrig wie möglich zu
halten. Bei der heute üblichen RISC – Technologie ist das Ziel ein möglichst
einfacher, direkt als Hardware realisierter Befehlssatz.
Hinzu kommt meist noch die Technik des Pipelining,
zu deutsch Fließbandverarbeitung. Dazu wird zunächst die Ausführung eines
Befehles in mehrere Phasen aufgeteilt, die jeweils möglichst unabhängig
voneinander ablaufen. Es wird damit ermöglicht, mehrere Befehle überlappend,
also „versetzt parallel“, auszuführen, was zu einer weiteren, deutlichen
Geschwindigkeitssteigerung, aber auch zu Problemen führt. Probleme treten immer
dann auf, wenn ein Befehl auf das Ergebnis eines kurz vorhergehenden Befehls
zugreift, der aufgrund des Pipelinings noch nicht vollständig verarbeitet
wurde. Solche Situationen bezeichnet man auch als Hazards, deren Behandlung ein
Hauptproblem bei der Entwicklung von RISC-Prozessoren und der entsprechenden
Software darstellt.
Die erheblich gesteigerte
Verarbeitungsgeschwindigkeit von z.B. einem Befehl pro Taktzyklus stellt jedoch
ein großes Problem beim Zugriff auf externe Systeme wie etwa den
Arbeitsspeicher dar. Hier ist es notwendig, den gesteigerten Anforderungen z.B.
durch geeignete Cache-Strategien zu begegnen.
Die gleichen Probleme treten natürlich auch
bei der Realisierung eines Simulators für eine solche CPU auf und müssen
entsprechend behandelt werden. Wir haben uns entschlossen, gleich mehrere
Strategien zur Vermeidung von Hazards zu realisieren, um deren unterschiedliche
Effizienz besser vergleichen zu können.
Das folgende, zweite Kapitel legt kurz die
Grundlagen der verwendeten funktionalen Programmiersprache Haskell und die
Konsequenzen für die Realisierung unseres Projektes dar.
Im dritten Kapitel werden unsere
Designentscheidungen, die Moduleinteilungen und schließlich die Realisierung
genauer beschrieben.
2.
Grundlagen
Bei der Entwicklung unseres
DLX-Simulators wurde die funktionale Sprache Haskell eingesetzt, insbesondere
wurde der Interpreter Hugs98 benutzt. Da
sich die funktionale Programmierung grundsätzlich erheblich von der imperativen
unterscheidet, haben wir uns entschlossen, an dieser Stelle eine kurze
Einführung in Haskell einzufügen. Für weitergehende Informationen verweisen wir
auf das Literaturverzeichnis.
Funktionale Sprachen sind
nicht aus der Abstraktion von imperativen Maschinensprachen entstanden, sondern
beruhen auf einem soliden mathematischen Fundament, welches aus dem -Kalkül entstanden ist. Aufgrund des hohen
Abstraktionsniveaus ist die Entwicklung in funktionalen Sprachen sehr schnell,
die Programme sind oft kürzer und verständlicher als ihre imperativen Pendants.
Ein funktionales Programm besteht aus einer Reihe von Funktionen im
mathematischen Sinne, welche einen oder mehrere Parameter auf einen oder
mehrere Funktionswerte abbilden.
Beispiel : fibo
:: Int -> Int
fibo n | n == 0 = 1
| n == 1 = 1
| otherwise = (fibo (n-1)) + (fibo
(n-2))
Die Notation in Haskell lehnt sich sehr stark an die mathematische Schreibweise von Funktionsdefinitionen an.
Bei der Entwicklung von funktionalen Programmen steht im Vordergrund, was eine Funktion realisieren soll. Wie diese Berechnung dann tatsächlich umgesetzt wird, liegt nicht in der Verantwortung des Programmierers. Dies legt auch den Begriff der „ausführbaren Spezifikation“ nahe, da ein Haskell-Programm letztlich nur eine formale Spezifikation darstellt. Aufgrund dieser besonderen Eigenschaften konnte in unserem Projekt auf eine separate Spezifikations- und Designphase verzichtet werden.
In funktionalen Sprachen steht
als einzige Kontrollstruktur die Rekursion zur Verfügung, die Verwendung von
Schleifen ist nicht vorgesehen. Ebenso können nicht -wie in der imperativen
Sprachfamilie üblich- Variablen als Bezeichnungen für Speicherzellen genutzt
werden. Die Konsequenzen dieser Eigenschaften sind weitreichend. So sind die
funktionalen Sprachen grundsätzlich frei von Seiteneffekten, für Ausdrücke gilt
die sogenannte „referenzielle Transparenz“, d.h., das Auswertungsergebnis eines
Ausdrucks hängt nicht von Ort und Zeit seiner Auswertung ab. Dies eröffnet
erhebliche Möglichkeiten für die Paralellverarbeitung von funktionalen
Programmen, schafft allerdings auch Probleme, wenn etwa Daten innerhalb eines
Programmablaufes persistent gehalten werden sollen. Da Daten grundsätzlich
nicht in Form von Variablen verwaltet werden können, sind oft sehr
unübersichtliche Funktionsdefinitionen notwendig.
Darüber hinaus besitzt Haskell
ein sehr mächtiges Typkonzept, das jede Form von Typfehler bereits zum
Zeitpunkt der Übersetzung eines Programmes abfängt. Dies kommt natürlich einer
arbeitsteiligen Entwicklung sehr entgegen, da inkompatible
Funktionsdefinitionen bereits zum Zeitpunkt der Typüberprüfung bemerkt werden.
Die grundlegenden Definitionen wurden in
unserem Modul "Datentypen.hs" festgelegt :
Dokumentation zum Modul
Datentypen:
In diesem Modul werden die grundlegenden
Datentypen für die Befehlsverarbeitung in der Pipeline festgelegt und die
einzelnen möglichen Instruktionen für den DLX festgelegt. Hierbei definierten
wir zuerst folgende grundlegende Datentypen: Byte (8 Bit), Halbwort (16 Bit) und Wort (32 Bit), die jeweils durch
einen Int-Wert repräsentiert werden.
Es folgen die DLX-Definitionen:
Reg (Register), Imm (Immediate (16 Bit)), Dis
(Displacement (16 Bit)), Label (Sprungmarke), TrapNr (Code für
Betriebssystemaufruf), die auch durch Int-Werte repräsentiert werden.
Nach der Definiton dieser dem DLX
zugrundeliegenden Datentypen begannen wir unsere eigene Datenstruktur zu
entwickeln.
Die einzelnen Register R0,...,R31
verwalten wir als Liste ([R0,...,R31]) von Int-Werten.
Die internen Register Program Counter, New
Program Counter, Instruction Register, A-Register, B-Register,
Immediate-Register, ALU-Output Register, Condition-Register, Load Memory Data
Register werden ebenfalls als Liste ([PC, NPC, IR, A-Reg, B-Reg, Imm-Reg,
Alu-Out, Cond-Reg, LMD-Reg]) von Int-Werten
verwaltet.
Zu jedem Befehl, der in der Pipeline steht,
wird verwaltet:
(Instruktion, interne Register, Resourcen)
Hierbei steht der eigentliche Befehl in der
Instruktion, danach eine Version der internen Register und anschließend die
Resourcen (Register,..) die sie benötigen. Da die Pipeline aus 5 Befehlen
besteht, realisierten wir sie als Liste ([(Instruktion, interne Register,
Resourcen)]). So entstand unsere endgültige Datenstruktur:
CPU = (Register, [(Instruktion, interne
Register, Resourcen)])
Als erstes Ziel hatten wir uns gesetzt, einen
Simulator zu schreiben, der Befehle sequentiell abarbeiten kann. Hierzu
beschlossen wir die einzelnen Phasen InstruktionFetch, InstruktionDecode,
Execute, MemoryAccess und WriteBack in einzelne Module aufzuteilen, die dann
von den einzelnen Teilnehmern programmiert werden konnten. Dabei sollten die Hauptfunktionen
der einzelnen Module die folgende Signatur besitzen:
(CPU,
Memory) ® (CPU, Memory)
So konnte gesichert werden, das wir später
bei der Hintereinanderausführung der einzelnen Hauptfunktionen keine Probleme
mit den Datentypen bekamen.
Zusätzlich entwarfen wir ein Modul, das einen
gehelfsmäßigen Speicher simulierte, da zu dieser Zeit von den Gruppen 3 und 4
noch nichts zu Verfügung gestellt werden konnte (was von uns auch nicht
erwartet wurde), wir die Load/Store Befehle aber schon implementieren wollten.
Hierzu stellten wir den Speicher einfach als Liste von Instruktionen (Programm)
dar, in der die Adresse modulo 4 der entsprechenden Listenposition entsprach,
da wir später bei der Umstellung auf die Speichermodule der Gruppe 3 und 4
keine Probleme mit den Adressen bekommen wollten, zumal dort der Speicher in
Bytes verwaltet wird.
Die Datenstruktur Pipeline bestand zu dieser
Zeit einfach aus einem einzigen Befehl, und die internen Register hatten den
gleichen Stand wie die normalen.
Jede der fünf Pipelinephasen wurde in einem
eigenen Modul behandelt.
Dokumentation zum Modul
InstructionFetch:
Im Modul InstructionFetch wird im obersten
Pipelinebefehl von der Adresse, auf die der PC zeigt, die nächste Instruktion
(als Int-Wert) gelesen und ins IR geschrieben. Zusätzlich wird der NPC auf PC+4
gesetzt.
Dokumentation zum Modul InstructionDecode:
Um
eine effiziente Verarbeitung der Instruktionen zu ermöglichen, besitzen die
RISC CPUs Maschinenbefehle einheitlicher Länge, im Gegensatz zu herkömmlichen
Architekturen, bei denen zur Dekodierung des Befehls unterschiedlich viele
Speicherzugriffe nötig sind.
Bei
der DLX Architektur hat jeder Maschinenbefehl eine Länge von 32 Bit und kann in
einem Zugriff aus dem Speicher gelesen werden, da der Speicherbus 32 Bit breit
ist,.
Um
eine schnelle Dekodierung und Ausführung der Befehle zu ermöglichen, gibt es
nur drei verschiedene Formate.
Dabei haben die verschiedenen Abschnitte des
Befehles folgende Bedeutung:
• Bits 0...5 : Opcode, identifiziert den
Befehl.
• Bits 6...10: Quellregister 1 (rs1)(in
diesem Fall einziges Quellregister)
• Bits 11...15: Zielregister (rd)
• Bits 16...31: konstanter 16 Bit Wert
(imm)
Alle Befehle der Art rd ¬ rs1 op
Imm werden in diesem Format kodiert.
Bei bedingten Sprüngen gibt rs1 das Condition-Register an, Imm eine eine PC-relative Sprungadresse.
Bei ’jump register’ und ’jump and link register’
gibt rs1 das Zielregister an.
Bei
Speicherzugriffen gibt rs1 + Imm (in
diesem Zusammenhang wird Imm auch als “Displacement“ bezeichnet) die
Speicheradresse an, deren Wert nach rd gelesen bzw. an die der Wert von rd
geschrieben wird.
Dabei haben die verschiedenen Abschnitte des
Befehles folgende Bedeutung:
• Bits 0...5: Opcode = 0
• Bits 6...10: Quellregister 1 (rs1)
• Bits 11...15: Quellregister 2 (rs2)
• Bits 16...20: Zielregister (rd)
• Bits 26...31: Art der Operation (func)
Befehle
der Art rd <- rs1 func rs2 werden
in diesem Format kodiert.
Dabei haben die verschiedenen Abschnitte des
Befehles folgende Bedeutung:
• Bits 0...5: Opcode
• Bits 6...31: PC-relative 26 Bit Adresse
Die
Befehle J und JAL werden in diesem Format kodiert.
Die
arithmetischen Befehle der DLX CPU existieren in zwei Varianten, signed und
unsigned (z.B. ADD und ADDU). Diese Varianten führen die gleichen Operationen
aus; bei der signed Variante werden die Operanden als Zweierkomplementzahlen
interpretiert, bei unsigned als vorzeichenlose Binärzahlen.
Folgende
Befehle wurden implementiert:
Opcode |
Bezeichnung |
Operanden |
Bemerkungen |
1 |
LB |
rd, dis (rs1) |
Reg[rd] <-Mem[Reg[rs1]+dis] |
2 |
LBU |
rd,
dis (rs1) |
wie oben, jedoch mit |
3 |
SB |
dis
(rs1), rd |
Mem[Reg[rs1]+dis]<-Reg[rd] |
4 |
LH |
rd,
dis (rs1) |
Reg[rd] <-Mem[Reg[rs1]+dis] |
5 |
LHU |
rd,
dis (rs1) |
wie oben, jedoch mit |
6 |
SH |
dis (rs1), rd |
Mem[Reg[rs1]+dis]<-Reg[rd] |
7 |
LW |
rd,
dis (rs1) |
Reg[rd] <-Mem[Reg[rs1]+dis] |
8 |
SW |
dis (rs1), rd |
Mem[Reg[rs1]+dis]<-Reg[rd] |
9 |
ADDI |
rd,
rs1, Imm |
Reg[rd]
<- Reg[rs1] + Imm |
10 |
ADDUI |
rd,
rs1, Imm |
|
11 |
SUBI |
rd,
rs1, Imm |
Reg[rd] <- Reg[rs1] - Imm |
12 |
SUBUI |
rd,
rs1, Imm |
|
13 |
ANDI |
rd, rs1, Imm |
Reg[rd] <- Reg[rs1] & Imm |
14 |
ORI |
rd,
rs1, Imm |
Reg[rd] <- Reg[rs1] | Imm |
15 |
XORI |
rd,
rs1, Imm |
Reg[rd] <- [rs1] XOR Imm |
16 |
SLLI |
rd, rs1, Imm |
Reg[rd] <- Reg[rs1] << Imm |
17 |
SRLI |
rd, rs1, Imm |
Reg[rd] <- Reg[rs1] >> Imm |
18 |
SLTI |
rd, rs,1 Imm |
Reg[rd] <- 1 wenn Reg[rs] < Imm |
19 |
SRAI |
rd, rs1, Imm |
Reg[rd] <- Reg[rs1] >>> Imm |
20 |
SGTI |
rd, rs1, Imm |
Reg[rd] <- 1 wenn Reg[rs] > Imm |
21 |
SLE |
rd, rs1, Imm |
Reg[rd] <- 1 wenn Reg[rs] <= Imm |
22 |
SGEI |
rd, rs1, Imm |
Reg[rd] <- 1 wenn Reg[rs] >= Imm |
23 |
SEQI |
rd, rs1, Imm |
Reg[rd] <- 1 wenn Reg[rs] = Imm |
24 |
SNEI |
rd, rs1, Imm |
Reg[rd] <- 1 wenn Reg[rs] <> Imm |
25 |
BEQZ |
rs1, Imm |
Springe nach NPC+Imm wenn REg[rs1] = 0 |
26 |
BNEZ |
rs1,
Imm |
Springe nach NPC+Imm, wenn Reg[rs1]
<> 0 |
27 |
JAL |
Offset |
Wird verlegt!! |
28 |
JR |
rs1 |
Sprung an die Adresse, deren Wert in
Reg[rs1] |
29 |
JALR |
rs1 |
Springe an die Adresse, deren Wert in
Reg[rs1] |
30 |
LHI |
rd, Imm |
Lade Imm in die oberen 16 Bit von Reg[rd] |
Sämtliche R-Typ Befehle haben den Opcode
0 (Bits 0...5), die auf den Registern auszuführende Operation wird durch
den in den Bits 26...31 gespeicherten func
Wert bestimmt.
func |
Bezeichnung |
Operanden |
Bemerkungen |
9 |
ADD |
rd, rs1, rs2 |
Reg[Rd]
<- Reg[rs1] + Reg[rs2] |
10 |
ADDU |
rd,
rs1, rs2 |
|
11 |
SUB |
rd,
rs1, rs2 |
Reg[Rd]
<- Reg[rs1] - Reg[rs2] |
12 |
SUBU |
rd,
rs1, rs2 |
|
13 |
AND |
rd,
rs1, rs2 |
Reg[Rd]
<- Reg[rs1] & Reg[rs2] |
14 |
OR |
rd,
rs1, rs2 |
Reg[Rd]
<- Reg[rs1] | Reg[rs2] |
15 |
XOR |
rd,
rs1, rs2 |
Reg[Rd]
<- Reg[rs1] XOR Reg[rs2] |
16 |
SLL |
rd,
rs1, rs2 |
Reg[rd]
<- Reg[rs1] << Reg[rs2] |
17 |
SRL |
rd,
rs1, rs2 |
Reg[rd]
<- Reg[rs1] >> Reg[rs2] |
18
|
SLT |
rd,
rs1, rs2 |
Reg[rd]
<- 1 wenn Reg[rs1] < Reg[rs2] |
19 |
SRA |
rd, rs1,
rs2 |
Reg[rd] <- Reg[rs1] >>>
Reg[rs2] |
20 |
SGT |
rd, rs1, rs2 |
Reg[rd] <- 1 wenn Reg[rs1] > Reg[rs2] |
21 |
SLE |
rd,
rs1, rs2 |
Reg[rd] <- 1 wenn Reg[rs1] <=
Reg[rs2] |
22 |
SGE |
rd, rs1, rs2 |
Reg[rd] <- 1 wenn Reg[rs1] >=
Reg[rs2] |
23 |
SEQ |
rd,
rs1, rs2 |
Reg[rd] <- 1 wenn Reg[rs1] = Reg[rs2] |
24 |
SNE |
rd,
rs1, rs2 |
Reg[rd] <- 1 wenn Reg[rs1] <>
Reg[rs2] |
Opcode |
Bezeichnung |
Operanden |
Bemerkungen |
61 |
JAL |
offset |
Springt nach NPC + offset |
62 |
J |
offset |
Springt nach NPC + offset |
63 |
TRAP |
offset |
TRAP 0 beendet das laufende Programm |
NOP:
Aus historischen Gründen wird dieser Befehl sowohl durch den Opcode 32 als auch
durch opcode = func = 0 kodiert.
Dieser
Befehl führt keine Operationen durch und er verwendet keine Argumente.
In
unserer Version der DLX CPU wird dieser Befehl hauptsächlich intern verwendet,
um Wartezyklen bei der Pipelineverarbeitung zu realisieren. Der Programmierer
muß selbst keine NOP Befehle in sein Programm integrieren, da alle Formen von
Hazards intern abgefangen werden.
instructionDecode :: (CPU, Memory) -> (CPU, Memory)
zur
Verfügung. Die Funktion führt auf dem übergebenen CPU Zustand die Pipelinephase
InstructionDecode aus; zurückgegeben wird der dadurch geänderte CPU Zustand.
Der ebenfalls übergebene Speicher wird unverändert zurückgegeben.
Dem
DXL-Modell entsprechend werden folgende Funktionen durchgeführt:
• Die Inhalte der durch die Bits 6...10 und
11...15 im Befehlswort im IR bezeichneten Register werden in die internen
Register A und B geschrieben.
• Die Bits 16...31 des Befehlswortes in IR
werden in das Imm (Immediate) Register geschrieben, dabei findet eine
Vorzeichenerweiterung statt, d.h. die obersten 16 Bit des Imm Registers werden
auf den Wert von Bit 16 des Befehlswortes gesetzt.
Handelt es sich um einen J-Typ Befehl, werden die Bits 6...31 mit
Vorzeichenerweiterung ins Imm eingetragen.
Zusätzlich
zu den durch das DLX Modell festgelegten Operationen der Pipelinephase wandelt
die Funktion noch das im Instruktionsregister der aktuellen Pipelinephase
stehende 32-Bit Befehlswort in das interne Befehlsformat (Instruktion) um und
speichert es im ersten Element des Drei-Tupels, das die aktuelle Pipelinephase
beschreibt.
Weiterhin
wird in die zur Hazardvermeidung verwendeten Ressourcenliste eingetragen,
welche Register jeweils vom dekodierten Befehl gelesen bzw. geschrieben werden.
In das erste Element der Liste wird die Nummer des Zielregisters, in das zweite
und dritte die Nummern der beiden Quellregister eingetragen. Wird eine der drei
Resoucen vom dekodierten Befehl nicht genutzt, so wird der entsprechende
Eintrag auf -1 gesetzt. Die weiteren Elemente der Resourcenliste bleiben
unverändert.
Dokumentation zum Modul
Execute:
Das Modul "Execute" implementiert die dritte Phase der Pipeline - die Execution-Phase. In dieser Phase werden die Instruktionen, die in den beiden Phasen davor aus dem Speicher geholt und dekodiert wurden, ausgeführt bzw. zur Ausführung vorbereitet.
Es
wird dazu auf die dekodierten Operanden zugegriffen, die vom vorausgehenden
Modul "InstructionDecode" in den internen Registern "A",
"B" und "Imm" bereitgestellt werden. Je nach
Instruktionstyp werden die internen Register "ALU-Output" und
"Cond" gesetzt.
Es
gibt vier grundlegende Instruktionstypen: Speicherzugriff,
Register-Register ALU-Operation, Register-Immediate ALU-Operation und (bedingter) Sprung. Alle Befehle können
eindeutig einem dieser Typen zugeordnet werden. Der Befehl selbst wird aus der
Datenstruktur "CPU" entnommen. Dort steht er in dekodierter Form als
Instruktion vom Typ "Instruktion".
Das
Modul "Execute" besteht im Grunde aus der folgenden Funktion:
execute ::
(CPU,Memory) -> (CPU,Memory)
Wie
zu sehen ist, erhält die Funktion - wie alle anderen Pipeline-Phasen auch -
einen CPU-Zustand als Eingabe, verändert diesen und gibt den neuen CPU-Zustand
an die nächste Phase weiter. Die nachfolgende Codezeile aus dem Modul
"Execute" zeigt anhand des einfachsten Befehls, wie dies konkret
aussieht:
execute
((x,[a,b,(NOP,iRegs,y),c,d]),mem) =
x,[a,b,(NOP,iRegs,y),c,d]),mem)
In
diesem speziellen Fall ist die Eingabe gleich der Ausgabe, da der Befehl
"NOP" nichts macht. Weiter unten sind einige andere Befehle
aufgeführt, die zeigen, wie der CPU-Zustand verändert wird. An dem ersten
Beispiel ist aber bereits zu sehen, welche Komponenten der Datenstruktur "CPU"
für die Funktion "execute" von Bedeutung sind.
"x"
kennzeichnet die Liste der 32 Allzweckregister und "mem" den
Arbeitsspeicher. Da in der Execute-Phase sowohl Allzweck-Register als auch der
Speicher nicht verändert wird - dies geschieht erst später in den Phasen
"MemoryAccess" und "WriteBack" - sind diese Komponenten für
die Funktion unerheblich. Das gleiche gilt für die Bezeichner "a",
"b","c","d", welche für die anderen vier Phasen
von Bedeutung sind.
Analog
zur Pipelinestufe wird in diesem Modul nur auf das dritte Element der Liste
[(Instruktion,IntRegisters,Resource)]
zugegriffen.
Hierbei sind auch nur die ersten beiden Komponenten des 3-Tupels von Bedeutung.
Die letzte Komponente wird in der vorhergehenden Phase verändert. In der ersten
Komponente steht die dekodierte Instruktion, in der zweiten die internen
Register ("Latch").
Die
Funktion "execute" wird mittels Pattern-Matching aufgerufen, d.h. je
nachdem, welcher Befehl in der ersten Komponente steht, wird die richtige
Version der Funktion "execute" aufgerufen und ausgeführt.
Wie
vorhin erwähnt, kann man alle (in dieser DLX-CPU implementierten) Befehlen in
vier Gattungen aufteilen. Im folgenden wird auf diese vier Instruktionstypen
eingegangen und gezeigt, in welcher Weise sie die internen Register "ALU-Output"
und "Cond" verändern.
1. Speicherzugriffe
Bei
Instruktionen vom Typ Speicherzugriff (auch "Load/Store"-Befehle) wird
die Speicheradresse, in die geschrieben bzw. aus der gelesen werden soll,
berechnet und in ALU-Output abgelegt. Erst in einer der nachfolgenden
Pipeline-Phasen erfolgt dann der eigentliche Zugriff auf diese Adresse.
Die
Adresse setzt sich aus einem Offset und dem Inhalt eines Registers zusammen.
Betrachten wir beispielsweise folgenden Befehl: "LB R1,
80(R2)"
Hier
wird ein Byte aus der Speicherzelle, die durch das Displacement 80(R2) adressiert wird, gelesen und im Register R1
abgelegt. Bei allen Speicherzugriffsbefehlen wird mit dem Displacement
adressiert. Die Zahl 80 ist im obigen Beispiel der Offset. Er wird in der Phase
"InstructionDecode" im internen Register "Imm" abgelegt.
Der Inhalt des Registers R2 liegt im internen Register "A" bereit. In
der Execution-Phase werden nun die beiden Werte addiert und im internen
Register "ALU-Output" gespeichert.
Diagramm: ALU-Output
¬ A + Imm
Der
zugehörige Funktionsaufruf ist:
execute
((x,[a,b,(LB op1 op2,iRegs,y),c,d]),mem) =
((x,[a,b,(LB op1
op2,ueberschreibe aluOutPos
((nth
iRegs aPos) + (nth iRegs immPos)) iRegs,y),c,d]),mem)
Zuerst
werden mit "nth iRegs
aPos" und
"nth iRegs
immPos"
die internen Register "A" und "Imm" aus der Liste
"iRegs" der internen Register ausgelesen und dann addiert. Danach
wird in der gleichen Liste mit der Hilfsfunktion "ueberschreibe" das
Ergebnis der Addition in das interne Register "ALU-Output" (Position
"aluOutPos") geschrieben, und der neue CPU-Zustand wird
zurückgegeben.
Das
ist im Prinzip die Vorgehensweise der Funktion "execute" bei allen
Befehlen. Demzufolge kann bei den nachfolgenden Beispielen auf eine nochmalige
Erklärung der Code-Zeilen verzichtet werden.
2. Register-Register
ALU-Operationen
Befehle
diese Instruktionstyps gehören zu den Befehlen, die in der Execute-Phase
wirklich ausgeführt werden, d.h., es wird nicht wie bei den
"Load/Store"-Befehlen bzw. den Sprüngen in der Execute-Phase nur
etwas vorbereitet, was erst später tatsächlich eintrifft (wie der
Speicherzugriff bzw. die Verzweigung). Zu den Reg.-Reg. ALU-Operationen gehören
die arithmetischen, die logischen, die Shift- und die Set-Befehle. Je nach Art des Befehls
wird im ALU-Output das Ergebnis der Operation abgelegt.
Diagramm:
ALU-Output ¬ A op B
Beispiele:
Arithmetischer Befehl "ADD
R1, R2, R3",
schreibt in R1 die Summe aus R2 und R3. Der Wert des ersten Quelloperanden (R2) liegt im internen Register
"A", der des zweiten (R3) in "B". Zugehörige Funktion:
execute ((x,[a,b,(ADD op1 op2 op3,iRegs,y),c,d]),mem)
= ((x,[a,b,(ADD
op1 op2 op3,ueberschreibe aluOutPos ((nth iRegs aPos) + (nth iRegs bPos))
iRegs,y),c,d]),mem)
Logischer Befehl "AND R1, R2, R3", schreibt in R1 mit
Hilfe der Funktion "bAND" das Ergebnis aus der bitweisen
AND-Verknüpfung von R2 und R3.
Zugehörige
Funktion:
execute
((x,[a,b,(AND op1 op2 op3,iRegs,y),c,d]),mem)
= ((x,[a,b,(AND op1 op2
op3,ueberschreibe aluOutPos
(bAND
(nth iRegs aPos) (nth iRegs bPos)) iRegs,y),c,d]),mem)
3. Register-Immediate
ALU-Operationen
Zu
jedem Befehl vom Typ Register-Register
ALU-Operation gehört ein Pendant vom Typ Register-Immediate ALU-Operation. Im Unterschied zum ersten Typ
kommt bei diesen Befehlen eine Zahl, ein sogenanntes Immediate, als Operand
vor.
Diagramm:
ALU-Output ¬ A op Imm
Beispiele:
Shift-Befehl "SLLI R10, R20, #2", schreibt mit Hilfe
der Funktion "<<" den um 2 Stellen nach links geshifteten Wert
aus R20 in R10 (äquivalent zur Multiplikation mit 4). Zugehörige
Funktion:
--
ShiftLeftLogicalImmediate
execute ((x,[a,b,(SLLI
op1 op2 imm,iRegs,y),c,d]),mem)
= ((x,[a,b,(SLLI op1 op2 imm,ueberschreibe
aluOutPos ((nth iRegs
aPos) << (nth iRegs immPos)) iRegs,y),c,d]),mem)
Set-Befehl "SLTI R11, R12, #100", schreibt in R11 eine
1, wenn der Wert von R12 kleiner ist als 100, ansonsten eine 0. Zugehörige
Funktion:
execute
((x,[a,b,(SLTI op1 op2 imm,iRegs,y),c,d]),mem)
= ((x,[a,b,(SLTI op1 op2
imm,ueberschreibe aluOutPos ergebnis
iRegs,y),c,d]),mem) where ergebnis = if (nth iRegs aPos)
< (nth
iRegs immPos) then 1 else 0
4. Sprünge
Bei
allen Sprüngen außer "JR Reg" und "JALR Reg" wird der Sprung-Offset relativ zum
internen Register "NPC" ("NewProgrammCounter") berechnet.
Bei den Befehlen "JR Reg" und "JALR Reg" wird direkt zu der Adresse im Register
"Reg" verzweigt. In beiden
Fällen wird die endgültige Sprung-Adresse in "ALU-Output"
geschrieben. Die eigentliche Verzweigung erfolgt in einer anderen
Pipeline-Phase.
Die
Verzweigung erfolgt aber nur, wenn im internen Register "Cond" eine 1
steht. Bei den unbedingten Sprüngen
"J Name", "JR Reg", "JAL Name" und "JALR
Reg" wird
dagegen das "Cond"-Register in jedem Fall mit einer 1 beschrieben.
Bei den beiden bedingten Sprüngen "BEQZ Reg,Name" und "BNEZ Reg,Name" wird zuerst eine
Bedingung überprüft und dann "Cond" je nach Ergebnis mit einer 1 oder
einer 0 beschrieben.
Diagramm:
ALU-Output ¬ NPC + Imm ; Cond ¬ A op 0
Beispiele:
Bedingter Sprung "BEQZ R21,Loop", verzweigt zu Adresse
"Loop", wenn in R21 eine 0
steht. Der Operand "Loop" ist ein
16-Bit-Offset, der in "Imm" bereitliegt. Er wird zu dem Wert in
"NPC" addiert und anschließend in "ALU-Output" gespeichert.
In "A" liegt der Wert vom ersten Operanden (R21). Er wird mit 0
verglichen und entsprechend wird dann "Cond" gesetzt. Zugehörige
Funktion:
execute
((x,[a,b,(BEQZ op name,iRegs,y),c,d]),mem)
= ((x,[a,b,(BEQZ op name,ueberschreibe
condPos ergebnis
(ueberschreibe aluOutPos ((nth iRegs
npcPos) + (nth iRegs immPos))
iRegs),y),c,d]),mem)
where
ergebnis = if ((nth iRegs aPos)==0) then 1 else 0
Unbedingter Sprung "JR R17", hier wird direkt zur Adresse im
Register R17 verzweigt. Dazu muß in das "Cond"-Register eine 1
geschrieben werden. In "ALU-Output" wird der Wert des Operanden (R17)
geschrieben. Dieser wurde nach "A" dekodiert. Zugehörige Funktion:
execute
((x,[a,b,(JR op,iRegs,y),c,d]),mem)
= ((x,[a,b,(JR op,ueberschreibe condPos
1
(ueberschreibe aluOutPos (nth iRegs aPos)
iRegs),y),c,d]),mem)
Bei
den Sprüngen "JAL
Reg"
"JALR Reg" wird die
Rücksprungadresse nicht in R31geschieben, weil...
Dokumentation zum Modul
MemoryAccess:
Im Modul MemoryAccess werden die
Speicherzugriffe realisiert. Wenn im Programm ein Load- oder Storebefehl
auftaucht, wird entweder der Inhalt der entsprechenden Speicherzelle in das
interne LMD-Register des jeweiligen Latches geschrieben oder der Inhalt aus dem
internen B-Register in den Speicher geschrieben. Dabei werden gegebenfalls
auftretende Verzögerungen (etwa durch einen Cache-Miss) berücksichtigt.
Dokumentation zum Modul
WriteBack:
Im Modul WriteBack wird zunächst zwischen 4 Instruktionstypen
unterschieden:
a)
Register-Register ALU
Operationen (z.B. ADD, SUB, etc.)
b)
Register-Immediate ALU Operationen (z.B. ADDI, SUBI, etc.)
c)
Load-Befehle (z.B. LB,
LW, etc.)
d)
Sprungbefehle JAL und
JALR
e)
Sonstige Instruktionen
In den Fällen a) und b) wird der Inhalt vom ALU-Output Register und im
Fall c) der Inhalt vom LMD-Register in die Zielregister geschrieben. Im Fall d)
wird der Inhalt vom ALU-Output-Register in das Register R31 geschrieben. In
sonstigen Fällen bleibt der CPU-Zustand unverändert.
Nachdem jeder sein Modul
fertiggestellt hatte, schalteten wir sie hintereinander und erhielten ein
Programm, welches in der Lage war, einen Befehl sequentiell abzuarbeiten. Der
nächste Schritt war nun die Entwicklung einer Pipelinestruktur, die es uns
ermöglichen sollte, mehrere Befehle gleichzeitig, aber jeweils um einen Takt
versetzt, abzuarbeiten. Hier entschieden wir, daß jedes unserer Module immer
nur auf einen Befehl zugreifen sollte, der an einer bestimmten Stelle der
Pipeline steht, so daß die 5 Module "gleichzeitig" auf den 5
verschieden Befehlen in der Pipeline ausgeführt werden sollten und die Befehle
dann anschließend um eine Position weiterverschoben wurden. Diese
Pipelineverwaltung wurde dann wieder in einem Modul gekapselt.
3.2
Pipelineverwaltung, Hazards
Die
parallele Verarbeitung der Instruktionen in der Pipeline ist eine der
wichtigsten Gründe für den Geschwindigkeitsgewinn der RISC Architektur
gegenüber herkömmlichen Prozessoren. Durch die Pipeline kann theoretische pro
Taktzyklus ein Befehl ausgeführt werden.
Bei
der praktischen Realisierung ist dies allerdings nur der Idealfall. Unter
verschiedenen Bedingungen können die Befehle nicht wie gewünscht abgearbeitet
werden. Diese Situationen nennt man Hazards.
Es
werden drei Arten von Hazards unterschieden:
• 1. Strukturhazards treten auf, wenn zwei
Instruktionen gleichzeitig auf dieselbe Hardware zugreifen, z.B., wenn ein
Befehl in der InstructionDecode Phase ein Register liest, während ein anderer
in der WriteBack Phase in dieses Register schreibt.
• 2. Datenhazards treten auf, wenn ein
Befehl auf Daten zugreift, die das Ergebnis eines vorhergehenden Befehls sind,
aber durch die Pipelineverarbeitung noch nicht zur Verfügung stehen, z.B. wenn
ein Befehl im InstructionDecode ein Register liest, das aber Zielregister eines
vorhergehenden Befehls ist, der in diesem Takt noch vor der WriteBack Phase
steht.
• 3. Kontrollhazards treten auf, wenn bei
der Verarbeitung von bedingten Sprungbefehlen erst dann entschieden werden
kann, ob ein Sprung genommen wird oder nicht, wenn bereits weitere Befehle in
die Pipeline geladen wurden, so daß die Pipelineverarbeitung unter Umständen
für mehrere Takten verzögert wird.
Es
gibt verschiedene Methoden, bei dem Entwurf einer CPU mit Hazards umzugehen,
deren Effizienz variiert. Es besteht
natürlich auch die Möglichkeit, es dem Assemblerprogrammierer oder
Compiler zu überlassen, das Entstehen von Hazards durch geschickte Anordnung
der Befehle selbst zu verhindern,. Dies vereinfacht den Entwurf der CPU
natürlich deutlich und kann, wenn es vernünftig durchgeführt wird, eine sehr
effiziente Lösung sein.
Beim
Entwurf unseres Modells haben wir uns dafür entschieden, sämtliche Formen von
Hazards abzufangen so, daß der Programmierer sich darum nicht kümmern muß.
Wir
haben verschiedene Methoden der Hazardvermeidung implementiert, die der
Benutzer auswählen kann, um die Effizienz der verschiedenen Strategien bei der
Abarbeitung von Programmen selbst zu überprüfen.
Dokumentation zum Modul
Pipeline:
Im Modul Pipeline findet die eigentliche
Pipelineverwaltung und Hazardbehandlung statt.
Hierbei wird in einer Pipelinephase immer ein
InstruktionFetch aud dem ersten, InstruktionDecode auf dem zweiten, Execute auf
dem dritten, MemoryAccess auf dem vierten und Writeback auf dem fünften Befehl
auf der Pipeline gleichzeitig ausgeführt (Pseudoparallel sozusagen, da echte
Parallelität in Haskell nicht realisierbar ist)[1].
Grundlegende Funktion in diesem Modul ist die
Einschub-Funktion
einschub :: Int ® (CPU,Memory) ® (CPU,Memory)
Mit ihrer Hilfe wird ein einer beliebigen
Stelle i (0 £ i £ 4) in der Pipeline ein Nop
eingeschoben, daß dann später von dem neuen Befehl überschrieben wird und
gleichzeitig wird der fünfte Befehl entfernt, die Pipeline wird somit ganz oder
zumindestens partiell weitergeschoben. Das partielle Weiterschieben dient der
Hazard-Behandlung, so kann der Befehl, der durch einen Hazard aufgehalten wird,
an der entsprechenden Stelle in der Pipeline gehalten werden, während die
unteren Befehle weiter abgearbeitet werden.
Auf dieser Funktion baut dann die eigentlich
Pipeline-Funktion
pipeline :: (CPU,Memory) ® (CPU,Memory)
auf. Diese entscheidet nach Auswertung eines
Hazard-Prädikates, wie sie die Pipeline weiterschieben muß, ob ganz oder
partiell. Hierbei wird durch die hierarchische Struktur gesichert, das Hazards,
die weiter unten in der Pipeline auftreten zuerst abgehandelt werden.
Die Pipeline wurde auf diesen Grundlagen
erstmal implementiert und funktionierte auch, obwohl sie relativ ineffektiv
war. Aus diesem Grund wurden folgende Oprtimierungen
programmiert und eingebaut :
1. Die Ausführung der
absoluten Sprünge schon in der InstruktionFetch-Phase
2. Die Ausführung der
bedingten Sprünge in die InstruktionDecode-Phase
3. Data-Forwarding
In
einer Pipeline stellt die Verarbeitung von bedingten und unbedingten Sprüngen
ein erhebliches Problem dar, da ein Sprung eben die gesamte Pipeline verändert.
Grundsätzlich ist es zwar möglich, Sprünge genau wie alle anderen Befehle zu
verarbeiten, dies erfordert jedoch, daß bei jedem Sprung ein Teil der Pipeline
gelöscht wird. Damit entstünde nach jedem Sprung eine „Lücke“ von mehreren
Phasen in der Pipeline, die sich natürlich auf die Ausführungsgeschwindigkeit
auswirkt. Um nun dieses Problem zu lösen, haben wir uns entschieden, die
bedingten und unbedingten Sprünge gesondert zu behandeln.
Die
Verarbeitung der unbedingten Sprünge wurde in die Phase „Instruction Fetch“
verlagert. Nachdem ein Befehl aus dem Arbeitsspeicher gelesen wurde, wird nun
zusätzlich der Opcode extrahiert und festgestellt, ob dieser einen absoluten
Sprung[2]
anzeigt. Wenn dies der Fall ist, so wird bereits an dieser Stelle der Sprung
ausgeführt, der nächste Befehl kann ohne Unterbrechung in die Pipeline
eingefügt werden.
Bei
den bedingten Sprüngen ergaben sich zusätzliche Probleme. Ob ein solcher Sprung
tatsächlich ausgeführt wird, hängt von den Inhalten einzelner Register ab,
diese könnten jedoch von vorhergehenden Befehlen verändert werden. Um derartige
Datenhazards abfangen zu können, werden die bedingten Sprünge in unserer
Implementierung in der Phase „Instruktion Decode“ verarbeitet, dort werden auch
die Datenhazards der übrigen arithmetisch-logischen Kommandos beseitigt. Wenn
ein bedingter Sprung auf ein Register zugreift, welches von einem anderen, in
der Pipeline befindlichen Befehl verändert wird, dann wird die Ausführung
dieses Sprunges verzögert, bis es zu keinen Komplikationen mehr kommen kann.
Nachteil dieser Lösung ist, daß aufgrund der Verlagerung in die 2. Pipelinephase
bei bedingten Sprüngen eine „Lücke“ von einem Zyklus in der Pipeline auftritt.
Dies ist jedoch gegenüber der ursprünglichen Lücke von 3 (!) Befehlen eine
erhebliche Ersparnis.
Zu 1 : Die Verlagerung der absoluten Sprünge
in die InstructionFetch-Phase wird durch die Funktionen
isJump :: Int ® Bool
handleJumps :: (CPU, Memory) ® (CPU, Memory)
realisiert. Grund für die Verlagerung war die
Tatsache, das vorher ein absoluter Sprung immer erst in der Execute-Phase
ausgeführt wurde. Daraus folgte, das die beiden Befehle über ihm nochmal neu
geladen werden mußten und zusätzlich zwei Pipelinephasen verschwendet wurden.
Durch die Verlagerung konnte dieses Manko behoben werden. Die Funktion isJump
ist dabei als Prädikat zu sehen, das feststellt, ob ein absoluter Sprung
vorliegt. Sie wird von der Funktion handleJumps benutzt, die dann je
nach Aussage des Prädikats entscheidet, ob sie einen Sprung ausführen muß oder
nicht, nachdem im InstruktionFetch der Befehl aus dem Speicher geladen worden
ist.
Zu 2 : Die Verlagerung der bedingten Sprünge
in die InstructionDecode-Phase wird durch die Funktionen
isCondJump :: Instruktion ® Bool
handleCondJumps :: (CPU, Memory) ® (CPU, Memory)
Zu 3.
Im allgemeinen können mit Hilfe des Data-Forwarding Datenhazards
vermieden werden, außer wenn die Datenabhängigkeit zwischen zwei nachbarten
Befehlen liegt, wobei der vordere ein Loadbefehl ist, da die benötigte Daten
erst nach der Phase “MemoryAccess” im LMD vorhanden sein werden. (vgl. Kapitel
3.4.)
In unserer CPU beachten wir aber zwei Sonderheiten:
1)
Wir simulieren keine
echte gleichzeitige Durchführung der 5 Pipeline-Phasen in einem Takt. In der
Simulation laufen die 5 Phasen in der Folge WriteBack -> MemoryAccess ->
Execute -> InstruktionDecode -> InstruktionFetch ab. Das bedeutet z.B.,
daß bei der Durchführung der Phase Execute” die Phase “MemoryAccess” schon
fertig ist, also im Zustand, der eigentlich erst im nächsten Takt auftritt.
Diesen “Simulationscharakter” haben wir so ausgenutzt, daß wir hier eine
Behandlung der eigentlich nicht vom Data-Forwarding vermeidbare Datenhazard
sparen können.
2)
Um eine effizientere
Behandlung der bedingten Sprünge zu erhalten, haben wir sie an die Stelle
direkt nach der “InstruktionDecode” vorgeschoben. Daher brauchen wir eine
zusätzliche Forwarding-Behandlung vor dieser Sprung-Behandlung vor dem
“Execute”. Im Fall, daß die Quellregister des Sprungsbefehls und das
Zielregister des nächsten Befehls identisch sind, tritt ein Datenhazards auf,
der nicht vom “Data-Forwarding” vermieden werden kann. In diesem Fall müssen
wir einen 1-Takt-Stall bezahlen, also ein NOP einfügen. Im Programm werden
solche Fälle durch das Prädikat “isHazard” abgefangen und die Funktion
“einschube 2” (???) implementiert das Einfügen der NOP.
“Data-Forwarding” ist eine Hardware-Technik, die den Datenfluß zwischen
den Latches der verschiedener Phasen ermöglicht. Somit werden Datenhazards
vermieden und damit ein effizienteres Pipelineverhalten erzielt. Die Kernidee
ist, im Fall eines Datenhazards die Daten direkt aus einem internen Register
(LMD bei Load-Befehlen, ALU-Output bei anderen) zu holen, anstatt zu warten,
bis die benötigten Daten in Allzweck-Registern vorliegen.
Im Modul “Dataforward” haben wir das Forwarding folgendermaßen
implementiert:
·
Vor der Durchführung von
“Execute”, “MemoryAccess” und “WriteBack” wird jeweils geprüft, ob eine
Datenabhängigkeit vorhanden ist (ein Quellregister der durchzuführenden
Instruktion ist ein Zielregister einer vorherigen Instruktion).
·
Liegt eine Datenabhängigkeit
vor, so wird zunächst unterschieden, ob die benötigte Daten von einem
Loadbefehl erzeugt werden. Ist dies der Fall, so werden die Daten aus dem
LMD-Register, ansonsten aus dem ALU-Output-Register jeweils an die erwünschte
Stelle (Register A bzw. B) geschrieben. Im Programm dient die Funktion
“forward” für diesen Datentransport.
·
Im Fall einer mehrfachen
Datenabhängigkeit (ein Quellregister der durchzuführenden Instruktion gleicht
dem Zielregister mehrerer vorhergehenden Instruktionen) werden die aktuellsten
Daten “geforwardet”.
Leider müssen wir bemerken, daß das “Data-Forwarding” nicht alle
Datahazards vermeiden kann. So kann zum Beispiel der Fall auftreten, daß die Datenabhängigkeit zwischen zwei
direkt aufeinanderfolgenden Befehlen liegt,
wobei der erste ein Loadbefehl ist. Da der Speicherzugriff immer in der
Phase “MemoryAccess” stattfindet, stehen die zu holenden Daten erst nach der 4.
Pipeline-Phase im LMD zur Verfügung. Also sind die Daten erst nach
“MemoryAccess” “forward”-bereit. Wenn diese Daten von dem nächsten Befehl (in
der Phase “Execute”) benötigt werden, so muß dieser nächste Befehl einen Takt
warten. lücklicherweise können wir bei der Simulation die tatsächlich
sequentielle Durchführung der verschiedener Phasen ausnutzen und uns damit von
der Behandlung solcher Fälle befreien.
Der ursprünglich für interne Zwecke
entwickelte, vorläufige Speicher wurde, sobald dieser fertiggestellt war, durch
die Arbeitsspeichermodule der Gruppe 4 ersetzt.Wir verweisen auf deren
Dokumentation für weitere Informationen.
Für die Gruppen, die für die
Benutzeroberfläche zuständig sind, stellen wir im wesentlichen die folgende
Funktion zur Verfügung :
pipelinezyklus :: Int -> Int -> (CPU,Memory, Int) ->
(CPU,Memory, Int)
Diese Funktion simuliert einen, auf Wunsch
auch mehrere, Prozessortakt(e) einer DLX-CPU. Der erste Parameter legt fest,
welche Strategie zur Hazard-Vermeidug angewandt werden soll. Hier sind Werte
zwischen 0 und 3 zulässig, ein Wechsel der Strategie im laufenden Betrieb ist
nicht empfohlen. Der zweite Parameter gibt an, wie viele Taktzyklen auf einmal
ausgeführt werden sollen. Besonders bei umfangreichen Programmen kann sich dies
überaus positiv auf die Verarbeitungsgeschwindigkeit auswirken. Um eine
korrekte Beendigung des Programmes zu ermögliche, stellen wir zusätzlich ein
Prädikat
isEnd :: (CPU,Memory, Int) -> Bool
zur Verfügung. Dieses stellt fest, ob der
aktuelle Befehl im zweiten Latch ein TRAP 0, und in diesem Fall kann
das Programm beendet werden.
Die Funktion
initpipeline
:: [(Instruktion,IntRegisters,Resource)]
stellt eine "leere" Pipeline zur
Verfügung und dient dazu, die Programmausführung überhaupt zu starten.
Wir zitieren hier an dieser Stelle die
README-Datei zum DLX-Assembler von Adam Sosnowski, dem für die Entwicklung an
dieser Stelle herzlich gedankt sei :
Der
DLX-Assembler DLXasm DLXasm liest Quellcode in
Mnemonic-Schreibweise ein und übersetzt diesen in für den DLX-Simulator
lesbare Instruktionen. Diese Instruktionen werden in Form von 32-Bit
Integerwerten in einer Haskell-Liste gespeichert. Ausgegeben wird also eine
hs-Datei, die im DLX-Simulator direkt als Speicherinhalt eingelesen und weiterverarbeitet
werden kann. Dazu wird es im fertigen DLX-Simulator eine Eingabemöglichkeit
geben, mit der man ein mit DLXasm assembliertes Programm einlesen und
auszuführen kann. Nachfolgendes Beispiel zeigt eine Ausgabe von DLXasm. Es ist die
assemblierte Version des Beispiel-Programms "bubble.as". Der
Quelltext dazu befindet sich weiter unten.
ADDI R1, R2, #123 LHI R4, #-1000 LB R30, 48(R0) SW -80(R20), R21 NOP JAL -1234 BNEZ R15, 20
Immediates werden mit
einer vorausgehenden Raute # gekennzeichnet und Register mit einem R.
Displacements erfordern die bekannte Klammerung.
·
Es
dürfen Sprungmarken definiert werden. Sie müssen als erstes Wort in der Zeile
stehen und mit einem Doppelpunkt ":" enden. Das erste
Zeichen muß ein Buchstabe sein, danach dürfen auch Ziffern vorkommen, also
z.B. "Loop2Begin:". Soll ein Sprungmarkenname ('Label') als Operand
innerhalb eine Sprungbefehls vorkommen, dann muß der Doppelpunkt weggelassen
werden. ·
Es
können - wie aus x86-Assemblern bekannt - mit der Assembleranweisung "dw" (DefineWord)
Speicherzellen mit 32-Bit-Integerwerten vordefiniert werden. DLXasm ist case-insensitive,
d.h. es wird nicht zwischen Groß- und Kleinschreibung unterschieden. Dies
gilt allerdings nicht für Sprungmarken! So sind z.B. "loop" und "Loop" zwei verschiedene Labels. Es folgt das Beispiel-Programm "bubble.as". Es
veranschaulicht gut, welchen Quellcode der Assembler
verarbeiten kann.
LW R1, 8(R0) ;lade Word aus der Speicherzelle hinter dem nächsten Befehl NOP DW 4711 ;auf diese Speicherzelle wird zugegriffen Last not least verfügt DLXasm über eine umfassende
Fehlerbehandlung. Je nach Fehlerart wird eine Meldung mit Zeilennummer
ausgegeben. Der Befehl TRAP wird auch
kodiert, ist aber (zumindest bei unserer DLX-CPU) nicht im Befehlssatz
enthalten. Er wird von uns als "Programmende"-Befehl verwendet bzw.
für eine kontrollierte Ausführung benutzt. DLXasm [-o ziel.hs] quell.as
Bei Aufruf ohne Parameter wird diese Meldung ausgegen. Wird keine Ausgabedatei angegeben, erzeugt DLXasm standardmäßig als Ausgabe eine Datei "out.hs", in der dann der assemblierte Code liegt. Ein Aufruf der Art DLXasm datei.as
würde dies bewirken. Es kann aber auch explizit eine Ausgabedatei angegeben werden: DLXasm -o datei.hs datei.as
Nach dem Assemblieren liegt das Programm in für den
DLX-Simulator verständlicher Form im Verzeichnis. Außerdem wird eine Datei
"DLXasmPass2.as" erzeugt. In dieser Datei befindet sich der
ursprüngliche Quellcode, so wie er nach der zweiten Assemblierungsphase
aussieht. D.h. sämtliche Labels wurden durch Sprung-Offsets ersetzt. Diese
Datei dient ausschließlich zur Fehlersuche. Wer den Assembler selbst unter Linux compilieren möchte, muß folgendes eingeben: flex -i DLXasm.l yacc DLXasm.y (oder: bison -y DLXasm.y) gcc -o DLXasm y.tab.c -lfl
Diese Arbeit auch automatisch von dem Bash-Script
"Make" ausgeführt werden. (Nicht vergessen, das Execute-Bit
für Make zu setzen.) WICHTIG:
Sollte unter Linux das Compilieren des Assemblers nicht klappen oder wenn der
Assembler unerklärliche Ausgaben bzw. Fehlermeldungen erzeugt, ist zu
überprüfen, ob nicht vielleicht eine Quelldatei im DOS-Format (also mit
CR/LF) vorliegt bzw. im UNIX-Format (nur CR ?), falls unter MS-Windows
compiliert wird. - DLXasm : Ich habe diese Version unter Suse 6.5 compiliert, sollte somit auch unter Suse 6.5 laufen - DLXasm.exe : Version für MS-Windows - DLXasm.l : flex-Quelldatei - DLXasm.y : yacc-Quelldatei - Make : Bash-Script zur automatischen Compilierung von DLXasm unter Linux - Make.bat : Batch-Script - " - unter MS-Windows - DLX-Befehle : Eine Auflistung der Formate und Opcodes der DLX-Befehle, quasi die "Spezifikation" - bubble.as : Beispiel-Programm: BubbleSort mit gezieltem Abbruch - bubble.hs : assemblierte Version - fibo.as : Beispiel-Programm: Berechnet Fibonacci-Zahl - fibo.hs : assemblierte Version
Über alle
Änderungen/persönlichen Anpassungen am Quellcode - sofern sie erfolgen -
bitte ich, mich zu benachrichtigen.
|
5.
Schlußbemerkung und Fazit
Die Verwendung der funktionalen Sprache
Haskell in einem Software-Projekt größeren Umfanges hat die besonderen
Eigenschaften dieser Programmiersprache und die damit verbundenen Arbeitsweisen
deutlich hervortreten lassen. Das Schlagwort "Ausführbare
Spezifikation" macht deutlich, daß es und möglich war, ohne größere
Vorarbeiten direkt mit Design und Entwicklung des CPU-Simulators zu beginnen.
Damit konnte gegenüber einer Entwicklung in einer imperativen
Programmiersprache ein erheblicher Zeitgewinn verzeichnet werden. Gleichzeitig
erfordert aber die Nutzung des funktionalen Programmierkonzeptes eine
erhebliche Umstellung im Aufbau der Programme.
6. Literatur
[Hennessy/Patterson] Hennessy/Patterson : Computer Architecture – A
quantitative
approach,
2nd ed., Morgan-Kauffman, 1998
[Info IIIa] Seeger
: Skript zur Vorlesung Informatik IIIa
WS
1999/2000, Philipps - Universität Marburg
[Info IIIb] Loogen
: Skript zur Vorlesung Informatik IIIb
WS
1999/2000, Philipps – Universität Marburg
[Thompson] Thompson : Haskell
– The Craft of Functional Programming
Addison-Wesley,
1995
[Hudak/Peterson/Fasel] Hudak/Peterson/Fasel : A Gentle
Introduction to Haskell
1999
[1] Selbstverständlich würde die Benutzung der parallelen funktionalen Sprache EDEN eine echte Parallelverarbeitung ermöglichen.
[2] Die Einteilung in bedingte und unbedingte Sprünge wurde von uns anders vorgenommen, als die evtl. intuitiv zu erwarten wäre : Nur J und JAL sind absolute Sprünge. Da die Befehl JR und JALR auf Register zugreifen, kann es bei deren Abarbeitung zu Datenhazards kommen. Um dieses Problem zu umgehen, wurden diese Instruktionen zu bedingten Sprüngen erklärt und ihre Behandlung in die nachfolgende Phase verlagert.