Skip to content

Dokumentation

Jonathan edited this page Apr 30, 2021 · 22 revisions

Dokumentation PicSim

In dieser Dokumentation werden zunächst Hinweise zur Verwendung des Simulators gegeben, danach werden Simulatoren im Allgemeinen vorgestellt und schließlich geht es um die Besonderheiten und Funktionen dieses PIC-Simulators.

Hinweise zur Verwendung des Simulators

  • Der Simulator unterstützt nur LST-Dateien in UTF-8 Format.
  • LST-Dateien müssen richtig formatiert sein. Der Simulator setzt dies voraus.
  • Offiziell läuft der PicSim sowohl auf Windows als auch Android. Die Apk- und Exe-Datei befinden sich im Release-Tab des Repositories. Tatsächlich ließe sich der Simulator auch für macOS, Linux und iOS kompilieren lassen. Mit diesem Hintergedanken wurde der Simulator entwickelt. Dies wurde aber bisher noch nicht getestet.

Allgemein

Grundsätzliches zu einem Simulator

Ein Simulator versucht die Realität nachzubilden. Da die Realität allerdings sehr komplex ist, muss diese abstrahiert werden. Dazu werden Modelle gesucht, welche bestimmte Spezifikationen besitzen, die wichtig für den Anwendungszweck der Simulation sind. Für den PIC-Simulator ist das Ziel schon vorgegeben. Es soll ein PIC simuliert werden.
Simulationen können unterteilt werden in Simulationen mit oder ohne der Verwendung von Informationstechnik. Eine Simulationen ohne Informationstechnik ist z.B. der Auto-Crashtest, bei welchem ein Verkehrsunfall simuliert wird. Weil keine Menschen dabei gefährdet werden sollen, werden dafür Crashtest-Dummys verwendet. Da die Realität bei einem Crashtest sehr vereinfacht wird, ist es leichter möglich verschiedene Autos beim Crash zu vergleichen. Simulationen mit Informationstechnologien sind besser bekannt als Computersimulationen. Dazu zählen z.B. Fahrsimulatoren, Flugsimulatoren usw. Es gibt verschiedene Gründe eine Simulationen zu entwickeln. Oft wären Test am realen System zu teuer oder ethisch nicht vertretbar. Es kann aber auch sein, dass das reale System noch gar nicht existiert (z.B. Strömungsmodelle bei einem neuen Flugzeug) oder zu komplex oder noch zu unverstanden ist (z.B. Urknall) um es an einem realen System zu testen.

Vor- und Nachteile einer Simulation

Vorteile einer Simulation sind hauptsächlich die oben genannten Gründe (Grundsätzliches zu einem Simulator). Für den PIC-Simulator sind nur zwei Gründe wirklich relevant: Aufwand und Kosten. Durch eine Simulation werden immer gleiche Bedingungen erzeugt. Dadurch lässt sich das Programmieren mit einem PIC einfacher lernen. Denn es wird keiner Hardware außer einem Computer vorausgesetzt. Es gibt keine Hardwarefehler und der ausgeführte Programmcode lässt sich einfacher überwachen.
Da ein Simulator das Verhalten eines Systems nur vereinfacht darstellt, kann ein Simulator nicht das gesamte System repräsentieren. In diesem Fall wäre ein Emulator wesentlich besser geeignet. Dieser emuliert sowohl Software als auch Hardware.

Realisierung

Konzept

Der Simulator soll dem realen PIC sehr ähnlich. Deswegen werden Daten möglichst in Bit Schreibweise geschrieben sein. Da Dart allerdings keine Bits bzw. Bytes als Datentypen kennt, werden alle Daten intern als Strings gespeichert. Weil Strings auch in Dart einfache Char-Arrays sind, können Bit-Befehle wie bcf oder bsf sehr einfach implementiert werden. Der Simulator arbeitet auch wie der PIC nur mit den Instructions. Außerdem wird der ProgramCounter (PC) im Simulator mit einem Integer-Wert dargestellt. Dadurch sind Zugriffe auf das Array des Programmcodes einfacher realisierbar. Wie beim realen Vorbild setzt sich dieser aus PCL und PCLATH zusammen.
Der Simulator besteht aus zwei Anzeigen: einer Startseite bei der ein Programm ausgewählt und geladen werden kann und einer weiteren Seite, die den eigentlichen Simulator ausmacht. Die Logik des Simulators ist weitesgehend getrennt von der Benutzeroberfläche. Dadurch ließe sich der Simulator mit geringem Aufwand in einer Kommandozeile oder mit einer anderen Benutzeroberfläche starten.

Struktur

Nachdem die LST Datei ausgewählt und in den PicSim geladen wurde, zeigt die GUI durch einen Highlighter den nächsten Befehl an der abgearbeitet wird. Im Hintergrund wurden bis dahin, wie im Ablaufdiagramm zu sehen ist, die Instructions in das Programm-Array geladen, die Variablen initalisiert und das Programm wartet auf die Nutzereingabe. Sobald der Nutzer den Start-Button klickt startet das Programm mit dem ersten Befehl, analysiert die Instruction, arbeitet diesen Befehl ab, der Programm Counter wird angepasst, der Highlighter springt zum entsprechenden Befehl und der nächste Befehl wird ausgeführt. Sollte jedoch der Stop Button geklickt worden sein wartet das Programm bis zum nächsten Start, bevor der nächste Befehl ausgeführt wird. Beim Step wartet das Programm vor jedem Befehl auf die nächste Eingabe.

GUI
GUI
Ablaufdiagramm
Ablaufdiagramm

Um einen PIC zu simulieren müssen dessen Komponenten softwaremäßig nachgebildet werden. Hierfür wird der Hauptspeicher mit seinen Regisstern als String Array abgespeichert. In jeder "Speicherzelle" steht ein String mit acht-Zeichen. Initialisiert werden diese Speicherzellen mit acht Nullen. Alle Speicherzellen können angeklickt und manuell in Hex- oder Binär-Schreibweise gesetzt werden. Der Wert wird zur einfacheren Lesbarkeit immer als Hex angezeigt. Intern wird jedoch ein acht-Zeichen String verwendet.

GUI-Top_Speicher
RAM-Speicher

Das Programm wird aus der LST-Datei ausgelesen. Dabei wird die Instruction extrahiert und als Program-Array abgespeichert. Auf diese Speicherstellen zeigt der Programmcounter (PC). Dieser wird als Int-Wert realisiert welcher auf den auszuführenden Befehl zeigt. Nach jedem duchgeführten Befehl wird dieser hochgezählt. Sprungbefehle geben den absuluten Wert an an den gesprungen werden soll. Der Stack wurde wie das Programm als Array mit dazugehörigem Stack-Pointer realisiert. Dies ermöglicht, dass bei einem Überlauf über den Inhalt von acht wieder das erste Element überschrieben werden kann. Der Stack ist mit null initalisiert und wird bei jedem CALL weiter gefüllt und bei jedem RETURN wieder geleert. Der Stack wird in der GUI als Hex-Zahl angegeben, wie in der folgenden Abbildung zu sehen ist, intern werden die Zahlen jedoch als acht Zeichen langer String gespeichert.

GUI-Top_Stack
Stack

Um die Runtime zu ermitteln wird unsere globale Runtime Variable (int) bei jedem Befehl um die jeweilige Anzahl Cyles hochgezählt die eine Instruction benötigt. Je nach eingestellter Quarzfrequenz wird die absolute Zeit ermittelt und in der GUI angezeigt. Das FSR-Register, das W-Register, der PCL, das PCLATH und das Status-Register werden auch in der GUI ausgegeben. Alle sowohl als acht-Zeichen-String als auch zur einfacheren Lesbarkeit als Hex-Zahl. Die Anzeige ist in der folgenden Abbildung farblich markiert. Das wReg (W-Register) ist nicht Teil des Storages sondern wird als eigene String-Variable realisiert. Der PCL besteht aus den unteren acht Bits des ProgramCounters. Für die GUI wird dazu der Integer-Wert in einen String mit Binärzahlen konvertiert. Danach wird der String auf acht Stellen normalisiert und schließlich angezeigt. Das FSR ist Teil des Storage und steht dort an Stelle vier. Es wird als Speicher für die indirekte Adressierung verwendet Das PCLATH ist ebenso Teil des Storage, es steht an Stelle zehn. Verwendet wird es um die Programm-Page umzuschalten. Alle Register können manuell, durch anklicken, mit einer Hex- oder Binär-Zahl gesetzt werden.

Ebenso ist in der Abbildung die einstellbare Quarzfrequenz, die Runtime sowie der Button Runtime Reset zu sehen. Die Runtime wird intern als Int-Variable bei jedem Befehl hochgezählt. Die Variable wird pro Cycle um eins inkrementiert. Die Cycle-Anzahl wird dann mithilfe der Quarzfrequenz in die tatsächliche Laufzeit umgerechnet und ausgegeben. Der Button Reset Runtime setzt den Wert wieder zurück auf null.

GUI-Top_wReg+Runtime
W-Register und Laufzeitmessung

Das Tris-Register (RA-RE) stellt die In/Out-Schnittsstellen dar. Dieses Register ist Teil des Storages an Stelle fünf und sechs. Hier werden die Pins auf Input oder Output gesetzt. Die Werte können auch manuell durch klicken auf das entsprechende Bit eingestelt werden.

GUI-Top_Tris
Tris-Register

Klassendiagramm:

class-diagram
Klassendiagramm

Wie in diesem Diagramm zu erkennen ist, werden bei diesem Simulator Logik und UI strikt getrennt. Die Datei main.dart beinhaltet die Homepage auf der eine Datei für den Simulator ausgewählt werden kann. Die Klassen dieser Datei erben alle vom Framework von Flutter, welches die UI rendert. Dasselbe gilt auch für die UI welche in der simscreen.dart-Datei deklariert wird. Die Logik erstreckt sich über zwei Klassen: den InstructionCycler und den InstructionRecognizer. Im InstructionRecognizer werden alle Befehle definiert, die der Simulator simulieren kann. Von außen aufgerufen wird nur die recognize()-Funktion. Diese nimmt eine Instruction und den aktuellen ProgramCounter entgegen und liefert nachdem der Befehl ausgeführt wurde den neuen ProgrammCounter zurück.
Der InstructionCycler arbeitet etwas abstrahierter und besitzt die Funktionen start(), stop() und step(), welche von der Benutzeroberfläche aufgerufen werden. Damit wird der Programmablauf gesteuert. Außerdem wird in dieser Klasse auch auf Interrupts (interrupt()) geprüft. Auch der Timer0 (timer0()) wird hier inkrementiert.

Dart als Programmiersprache für den Simulator

Da die Programmiersprache für den PIC-Simulator frei wählbar war, entschieden wir uns relativ schnell für die Sprache Dart. Dart bietet eine sehr einfache Syntax, die vergleichbar mit Typescript und Kotlin ist. Ein weiterer Grund der für Dart spricht ist das UI-Framework Flutter. Mit Flutter lassen sich sehr einfach ansprechende User Interfaces entwickeln. Da Flutter und Dart außerdem auf vielen verschiedenen Geräten laufen können, lag es nahe auch die Crossplatform-Möglichkeiten zu nutzen.

Einige Beispiele

Ablauf_SUBLW
SUBLW

Wie in der Abbildung zu sehen ist, besitzt unsere Funktion/Befehl SUBLW zwei Inputs. Einmal der Index welcher nur für das return benötigt wird und die Instruction. Diese setzt sich aus dem Befehlcode und einem Bin-Literal zusammen. Dieses Literal wird Zahl1 genannt. Die Zahl2 wird aus dem W-Register geholt und umgewandelt in ein 2er-Komplement mit vier und eins mit acht Stellen. Durch die Addition des Komplements8 mit der Zahl1 erhalten wir unser Ergebnis. Dasselbe passiert auch mit der auf vier Stellen normalisierten Zahl1 und dem Komplement4. Nun werden die nötigen Flags gessetzt. Dazu wird für das Z-Bit das Ergebnis auf 0 geprüft, für das C-Bit der String des Ergebnisses in binär auf eine Länge von größer acht getestet und für das DC-Bit geprüft, ob die Länge des binär Strings des Ergebnis4 länger als 4 ist. Anschließend wird das Ergebnis auf acht Stellen normalisiert, im wReg gespeichert und der Index inkrementiert zurückgegeben.\

Ablauf_SUBWF
SUBWF

Die Befehle vom Typ "WF" sind alle ähnlich aufgebaut. Beispielhaft wird in der Abbildung der SUBWF-Befehl gezeigt. Zuerst wird aus der Instruction die Adresse gefiltert. Dies wird von der Funktion catchAdress übernommen. Wird die Bank 1 verwendet, also wenn das RP0-Flag auf 1 steht, wird zu der aus der Instruction extrahierten Adresse ein Wert von 128 addiert. Ist die Adresse 0 wird die indirekte Addresierung verwendet. Das bedeutet, dass die Adresse dem Inhalt des FSR-Registers entspricht. Nach der Adresse wird das aktuelle W-Register als oldW zwischengespeichert. Um keinen Code doppelt schreiben zu müssen, wird die Funktion SUBLW verwendet. Dieser Funktion wird der Index und die angepasste Instruction übergeben. In der Instruction für SUBLW wird dem Literal (Letzten 8 Stellen des Strings)der Wert des Inhalts der Speicherstelle storage.value[adress] zugewiesen.
Das Ergebnis von SUBLW steht num im wReg. In der Funktion wf() wird nun das d-Bit aus der Instruction analysiert. Ist es 1 müsste das Ergebnis im Storage stehen, es wird als dem storage.value[adress] der Wert des wReg zugewiesen und das wReg wird mit dem zwischengespeicherten oldW überschrieben. Ist das Ergebnis 0 steht das Ergebnis schon im wReg, ansonsten wird es ersetzt.\

Ablauf_CALL
CALL

Bei Sprungbefehlen, wie hier Beispielhaft der Call Befehl, wird as Return Wert eine Index zurück gegeben. Dieser Index setzt sich aus dem 3 und 4 Bit des PCLath und der Adresse aus der intruction zusammen. Zusätzlich muss noch der aktuelle Index+1 auf dem Stack gespeichert werden und der StackPointer inkrementiert werden. Falls der StackPointer > 7 beginnt er wieder bei 0, da der Stack nur 8 Speicherstellen besitzt.
Der Return Befehl funktioniert genau umgekehrt. Die Außnahme bildet der Goto-Befehl, hier wird kein Wert auf den Stack gelegt. Die Befehle der Art des Btfss sind eine Unterart der Sprungbefehle. Hier wird der nächste Befehl übersprungen wenn eine Bit gesetzt oder nicht gesetz wurde. Btfss z.B. prüft ob ein Bit (Instruction Stellen 4 bis 6) eines registers (catchAddress) einer 1 entspricht. Bei ja wird der nächste Befehl übersprungen (Index+2) sonst wird der nächste Befehl ausgeführt (Index++).\

Realisierung der Flags

Die Flags zum Steuern des PICs werden in drei Registern gespeichert, dem Status-Register an Stelle drei im Storage, dem Option-Register an Stelle 128 und dem Intcom-Register an Stelle 11. Somit sind die Flags wie jedes Register ein String mit 8-Zeichen, wobei jedes einzelne Flag einer Stelle des Strings entspricht. In der GUI werden diese wie unten zu sehen aufgelistet. Dabei kann jeder Wert einzeln durch klicken invertiert werden.
Die Funktionen der einzelnen Flags sind hier nachlesbar (ab S 15).\

GUI-Top_Flags
Flags

Timer0

Der Timer0, das spezielle Zählregister des PICs, ist auch im PicSim integriert. Anders als die normale Laufzeitzählung kann dieses Register nicht einfach pro Cycle inkrementiert werden. Damit der Timer zuverlässig funktioniert, wird eine Routine bei jedem Befehl ausgeführt. Die Routine lässt sich in drei Teile unterteilen: die Berechnung des Prescalers, das Inkremtieren des Timers und das Überprüfen auf ein Overflow.
Die Funktion für das berechnen des Prescalers des Timers ist trivial. Die Bits PS2, PS1 und PS0 werden in einen Integer-Wert konvertiert und um eins inkrementiert. Dies geschieht in Zeile drei des Listings. Danach wird die Zahl zwei mit sich selbst psa-Mal multipliziert. Zum Schluss wird noch überprüft, ob sich der Prescaler überhaupt verändert. Dies wird über eine Variable gelöst, die den alten Prescaler-Wert zwischenspeichert.
Um den Prescaler für den Watchdog zu berechnen, dürfte zum anfänglichen psa-Wert nicht die eins hinzuaddiert werden. Ansonsten ist die Berechnung identisch.

int calculatePSA() {
    if (storage.value[129][4] == "1") return 1;
    num psa = int.parse(storage.value[129].substring(5), radix: 2) + 1;
    psa = pow(2, psa);
    if (oldPSA != psa) {
      oldPSA = psa.toInt();
      psaCounter = 1;
    }
    return psa.toInt();
  }

void timer0() {
  int timerValue = int.parse(storage.value[1], radix: 2);
  int i = 0;
  if (storage.value[129][2] == "0") {
    var psa = calculatePSA();
    if (psa <= psaCounter) {
      storage.value[1] = recognizer.normalize(8, timerValue + (psaCounter ~/ psa));
      psaCounter = psaCounter - (psa - 1);
    } else {
      psaCounter++;
    }
    print(psaCounter);
    // check for timer0 overflow
    if (storage.value[1] == "00000000") {
      if (storage.value[3][recognizer.statustoBit("RP0")] == "0") {
        i = 11;
      } else {
        i = 139;
      }
      storage.value[i] = storage.value[i].substring(0, 2) + "1" + storage.value[i].substring(3);
     }
   }
}

Immer wenn der Prescaler-Wert kleiner oder gleich dem psaCounter wird, kann das Timer0-Register um eins erhöht werden. Danach wird der psaCounter zurückgesetzt. Allerdings nicht auf den Wert eins, sondern auf die Differenz von psaCounter und psa-1. Dadurch werden Befehle mit zwei Cycles berücksichtigt.
Zuletzt wird noch auf ein Timer0-Überlauf geprüft. Wird einer erkannt, wird das entsprechende Bit (T0IE) im Interrupt-Register gesetzt.

Interrupts

Bisher wurde nur das Timer-Interrupt in den Simulator implementiert. Die Basis ist aber für alle fehlenden Interrupts gegeben. Bei jedem Befehl den der Simulator ausführt wird immer auch überprüft, ob ein Intterupt im Interrupt-Register ausgelöst wurde. Falls dieser Fall eintritt, springt der Simulator an die vordefinierte Stelle (Stelle: 4) im Programmcode. Von dort aus wird dann die ISR (Interrupt-Routine) ausgeführt.

bool interrupt() {
 if (storage.value[11][0] == "1" && storage.value[11][2] == "1" &&
 storage.value[11][5] == "1") {
 	recognizer.call(int.parse(storage.value[10] + storage.value[2], radix: 2), "00000000000100");

 	// set ISR-address
 	storage.value[10] = "00000000";
 	storage.value[2] = "00000100";

 	return true;
 }

return false;

} 

Beim Interrupt wird ein Sprung ausgeführt. Dafür wird der Programcounter auf 4 gesetzt. Denn dort startet die Intterupt Service Routine. Bevor der Sprung ausgeführt wird, muss zuerst überprüft werden, ob überhaupt ein Interrupt ausgelöst werden soll. Dies geschieht in der if-Abfrage. Dafür wird geschaut, ob im Interrupt-Register (Index: 11) die Bits für ein Timer-Interrupt (GIE, T01E, T01F) gesetzt sind.

Zusammenfassung

Der Simulator beherrscht die meisten Befehle und Funktionen die ein echter PIC beherrscht. Und die dazugehörige Benutzeroberfläche ermöglicht eine einfache Bedienung für das Debugging und das Ausführen von Programmen. Natürlich fehlen noch einige Funktionen, wie z.B. der Watchdog, die restlichen Funktionen des TRIS-Registers und die restlichen Interrupts. Diese könnten mit geringem Aufwand jeweils hinzugefügt werden. Beispielsweise existiert schon das Grundgerüst für Interrupts, da der Timer0-Interrupt schon implementiert ist. So ist das auch mit dem Watchdog und dem Prescaler. Die Prescaler-Funktion hierfür existiert bereits und kann dafür wiederverwendet werden.

Fazit

Die Entwicklung des Simulators hat u.A. zum Verständnis des PICs beigetragen. Dieses tiefergehende Verständnis hätte auch in einer Klausur weiterhelfen können.
Die Designentscheidung Dart und Flutter zu verwenden war richtig. So konnten die Kenntnisse mit der Programmiersprache und dem UI-Framework stark vergrößert werden. Dies kann in folgenden Projekten weiterhelfen. Durch Flutter war es auch möglich direkt von Beginn an eine funktionierende Benutzeroberfläche zu entwickeln. So fiel das Debugging der implementierten Funktionen einfacher aus als Anfangs erwartet, weil Fehler nicht immer über die Ausgabe einer Konsole gesucht werden mussten.

Literatur