Lassen Sie den Assembler "z80asm" eine Anweisung an einer bekannten Speicheradresse platzieren

Dec 30 2020

Ich schreibe ein sehr einfaches Betriebssystem für meinen Homebrew Z80-Computer. Als absoluter Anfänger in Assemblersprache gelang es mir, einen funktionierenden "OS plus Memory Monitor" zu erhalten, der Speicherinhalt anzeigen und Bytes in den RAM laden kann. Dabei habe ich einige "Systemroutinen" geschrieben, um einige E / A-Geräte miteinander zu verbinden. Zum Beispiel habe ich eine "Printc" -Routine, die ein Byte liest und das entsprechende ASCII-Zeichen auf dem Bildschirm zeichnet.

Dies funktioniert mit dem vom Assembler erstellten Code, da der Assembler entscheidet, wo das erste Byte der Routine abgelegt werden soll, und diese Adresse verwendet, wenn er auf einen jp-Befehl mit derselben Bezeichnung stößt.

Jetzt möchte ich die Printc-Routine von einem dynamisch geladenen Programm aus aufrufen. Ich kann dank des -lFlags, das eine Ausgabe mit folgenden Ergebnissen erzeugt , feststellen, wo der Assembler das erste Byte der Routine im ROM abgelegt hat.

...
Print:    equ $043a Printc: equ $043e
Readc:    equ $0442 Readline: equ $0446
...

Ich kann jetzt ein Programm wie dieses schreiben:

ld a, 0x50     ; ASCII code for P
call 0x043e    ; Calls Printc

Dieses Programm druckt den Buchstaben P erfolgreich: Ich habe meine Printc-Routine unter Verwendung ihrer Speicheradresse aufgerufen.

Dies ist in Ordnung, solange ich keinen Assembler-Code vor der Printc-Deklaration in meinem "Betriebssystem" ändere. In diesem Fall wird das Printc-Etikett einer anderen Adresse zugewiesen und mein vorhandenes Programm funktioniert nicht mehr.

Was ist die kanonische Lösung für diese Art von Problem? Das einzige, was mir in den Sinn kommt, ist, vor jedem Import am Anfang meines Assembler-Codes eine "Sprungtabelle" mit der Liste der Systemaufrufe zu erstellen, in der Hoffnung, dass sie jedes Mal dieselbe Adresse erhalten. Etwas wie:

...
; System routines
Sys_Print:
call Print
ret
Sys_Printc:
call Printc
ret
.... and so on

Aber das scheint ziemlich hackisch ... Ist es möglich, den z80asmAssembler anzuweisen, die erste Anweisung der Routine an einer von mir festgelegten Speicheradresse zu platzieren?

Antworten

10 Raffzahn Dec 30 2020 at 04:31

Was ist die kanonische Lösung für diese Art von Problem?

Es gibt keine kanonische Lösung, aber viele Varianten, die alle als brauchbar angesehen werden können.

Das einzige, was mir in den Sinn kommt, ist, am Anfang eine "Sprungtabelle" zu erstellen

Welches ist eine perfekte gute. Außer, normalerweise würde man Sprünge anstelle von Aufrufen verwenden, um die Codelänge zu reduzieren, die Ausführung zu beschleunigen und die Stapellast zu reduzieren.


JUMP_TABLE:
PRINT    JP  _I_PRINT    ; First Function
READC    JP  _I_READC    ; Second Function
...

Aber das scheint ziemlich hackisch ...

Nein, viele 8080- und Z80-Systeme funktionieren so.

Der wichtigste Schritt ist, dass sich alle Einstiegspunkte an einem einzigen definierten Ort und in einer bestimmten Reihenfolge befinden.

Ist es möglich, den z80asm-Assembler anzuweisen, den ersten Befehl der Routine an einer von mir festgelegten Speicheradresse zu platzieren?

Verwenden Sie eine ORG, um sie an die gewünschte Adresse zu setzen (* 1). Aber das wäre hackisch oder zumindest nicht sehr vorausschauend. Eine solche Sprungtabelle an einer definierten Adresse zu haben, ist ein guter Anfang. Natürlich nimmt es etwas Platz ein. Drei Bytes pro Eintrag, aber nur zwei sind die Adresse. Wäre es nicht besser, einfach eine Adresstabelle zu erstellen? Mögen:

SYS_TABLE:
         DW    _I_PRINT    ; First Function
         DW    _I_READC    ; Second Function

Das Aufrufen einer Funktion wäre wie

         LD    HL, (SYS_TABLE+0)   ; Load Address of First Function - PRINT
         JP    (HL)                ; Do it

Dies kann einfach mit einer Art Funktionsauswahl kombiniert werden:

SYS_ENTRY:
         PUSH  HL
         LD    H,0
         LD    L,A
         ADD   HL,HL
         ADD   HL,SYS_TABLE
         JP    (HL)

Jetzt kann sogar die Sprungtabelle nach Bedarf im ROM (oder RAM) verschoben werden.

Wenn Sie es aufrufen, verwenden Sie eine Funktionsnummer - wie es viele Betriebssysteme getan haben - und geben Sie einfach die Funktionsnummer in A ein und rufen Sie den Standard-Systemeinstiegspunkt (SYS_ENTRY) auf.

         LD    A,0   ; Print
         CALL  SYS_ENTRY

Natürlich wird es besser lesbar, wenn das Betriebssystem eine Reihe von Gleichungen für die Funktionsnummern bereitstellt :)

Bisher muss das geladene Programm noch entweder die Tabellenadresse (SYS_TABLE) oder den Einstiegspunkt für den Selektor (SYS_ENTRY) kennen. Die nächste Abstraktionsebene verschiebt ihre Adresse an einen definierten Ort, wie z. B. 0100h, am besten in Form eines JP. Daher ruft jedes Benutzerprogramm immer diese feste Adresse (0100h) auf, unabhängig davon, ob sich Ihr Betriebssystem im ROM oder RAM befindet oder wo auch immer.

Und ja, wenn Ihnen das bekannt vorkommt, ist es so, wie CP / M Systemaufrufe genauso behandelt wie MS-DOS.

Apropos MS-DOS: Es bietet eine zusätzliche (und allgemein bekanntere) Möglichkeit, eine Betriebssystemfunktion aufzurufen, sogenannte Software-Interrupts, wie das bekannte INT 21h. Und es gibt etwas ganz Ähnliches, das der Z80 (und der 8080 zuvor) bietet: Ein Satz von acht verschiedenen ReSTart-Vektoren (0/8/16 / ...). Neustart 0 ist zum Zurücksetzen reserviert, alle anderen können verwendet werden. Warum also nicht die zweite (RST 8h) für Ihr Betriebssystem verwenden? Funktionsaufrufe würden dann so aussehen:

         LD    A,0   ; Print
         RST   8h

Jetzt ist der Benutzerprogrammcode so weit wie möglich von der Betriebssystemstruktur und dem Speicherlayout getrennt - ohne dass ein Umsetzer oder irgendetwas anderes erforderlich ist. Das Beste daran ist, dass mit ein wenig Fummelei der gesamte Selektor in die 8 verfügbaren Bytes passt, was eine optimale Codierung ermöglicht.


Ein kleiner Vorschlag:

Wenn Sie sich für eines dieser Modelle entscheiden, stellen Sie sicher, dass die erste Funktion (0) Ihres Betriebssystems ein Aufruf ist, der Informationen zum Betriebssystem bereitstellt, damit Programme die Kompatibilität überprüfen können. Es sollten mindestens zwei Grundwerte zurückgegeben werden:

  • ABI-Versionsnummer
  • Maximal unterstützte Funktionsnummer.

Die ABI- Versionsnummer kann mit einer Versionsnummer identisch sein oder muss, muss dies aber nicht. Sie muss bei jeder API-Änderung erhöht werden. Zusammen mit der maximal unterstützten Funktionsnummer können diese Informationen von einem Benutzerprogramm verwendet werden, um bei Inkompatibilität ordnungsgemäß zu beenden - anstatt auf halbem Weg abzustürzen. Für Luxus kann die Funktion auch einen Zeiger auf a zurückgeben

  • Struktur mit weiteren Informationen zum Betriebssystem wie
    • lesbarer Name / Version
    • Adressen verschiedener Quellen
    • "spezielle" Einstiegspunkte
    • Maschineninformationen wie RAM-Größe
    • verfügbare Schnittstellen usw.

Ich sage nur ...


* 1 - Und nein, anders als manche vielleicht annehmen, sollte ORG niemals Polsterung oder ähnliches alleine hinzufügen. Assembler, die dies tun, sind eine schlechte Wahl. Org sollte nur die Adressenebene ändern, nicht definieren, was in einem Bereich "übersprungen" ist. Wenn Sie dies tun, kann dies zu potenziellen Fehlern führen - zumindest sobald eine fortgeschrittene ORG-Nutzung abgeschlossen ist -, glauben Sie mir, ORG ist ein sehr vielseitiges Werkzeug für komplexe Strukturen.

Darüber hinaus führt das Füllen von "leeren" Bereichen mit etwas Auffüllen dazu, dass dieses Auffüllen Teil des Programms ist und nicht unberührter Speicher, wodurch ein Hauptwerkzeug für spätere Patches wegfällt: nicht initialisierter EPROM-Speicherplatz. Wenn Sie diese Bereiche einfach nicht definieren und nicht laden, bleiben sie im gelöschten Zustand (alle im Fall von EPROM) und können später programmiert werden - zum Beispiel, um Code während des Debuggens zu speichern oder um einen Hotfix ohne den zu implementieren Notwendigkeit der Programmierung neuer Geräte.

Undefinierter Speicher sollte also genau das sein, undefiniert. Und deshalb werden selbst die frühesten Assembler-Ausgabe- / Ladeformate (z. B. Motorola SREC oder Intel HEX ) für die Programmbereitstellung von der ROM-Herstellung bis hin zu Benutzerprogrammen verwendet, um Bereiche auszulassen.

Lange Rede, kurzer Sinn: Wenn man es füllen lassen will, muss es schnell erledigt werden. z80asm macht es richtig.

12 WillHartung Dec 30 2020 at 04:55

Das Problem mit Z80ASM besteht insbesondere darin, dass es die Assembly-Eingabe verwendet und eine statische Binärdatei ausspuckt. Das ist gut und schlecht.

In "normalen" Systemen liegt die Adresszuweisung unweigerlich in der Verantwortung des Linkers und nicht des Assemblers. Assembler sind jedoch so einfach, dass viele diesen Aspekt des Erstellungszyklus überspringen.

Da Z80ASM anstelle von "Objekt" -Dateien wörtliche Binärbilder ausspuckt, benötigt es keinen Linker. Aber es lässt Sie auch nicht unbedingt das tun, was Sie tun möchten.

Betrachten Sie die allgegenwärtige ORG-Richtlinie.

ORG teilt dem Assembler mit, wie die Startadresse (Ursprung - also ORG) für den bevorstehenden Assembler-Code lautet.

Dies bedeutet, wenn Sie dies tun:

    ORG 0x100
L1: jp L1

Der Assembler setzt den JP-Befehl zu JUMP unter der Adresse 0x100 (L1) zusammen.

ABER, wenn es die Binärdatei ausspuckt, wird die Datei nur 3 Bytes sein. Die Sprunganweisung, gefolgt von 0x100 im Binärformat. Es gibt nichts in dieser Datei, was irgendetwas aussagt, dass sie bei 0x100 geladen werden muss, um "zu funktionieren". Diese Informationen fehlen.

Wenn Sie tun:

    ORG 0x100
L1: jp L2

    ORG 0x200
L2: jp L1

Dadurch wird eine Datei mit einer Länge von 6 Byte erstellt. Diese beiden JP-Anweisungen werden direkt nacheinander abgelegt. Das einzige, was die ORG-Anweisung tut, ist zu sagen, wie die Etiketten lauten sollen. Dies ist nicht das, was Sie erwarten würden.

Das einfache Hinzufügen eines ORG zu Ihrer Datei führt also nicht zu dem, was Sie tun möchten, es sei denn, Sie haben eine alternative Methode zum Laden des Codes an der Stelle, an der sich Ihr Code befinden soll.

Die einzige Möglichkeit, dies mit sofort einsatzbereitem Z80ASM zu tun, besteht darin, Ihre Ausgabedatei mit Byteblöcken und Leerzeichen aufzufüllen, die die Binärdatei auffüllen, um Ihren Code an der richtigen Stelle zu platzieren.

Normalerweise erledigt der Linker dies für Sie. Die Aufgabe des Linkers besteht darin, Ihre unterschiedlichen Codeteile zu übernehmen und ein resultierendes Binärbild zu erstellen. Das alles erledigt es für Sie.

Auf meinem Assembler, der keinen Linker verwendete, wurde ein Intel HEX-Dateiformat erstellt, das die tatsächliche Adresse für jeden Datenblock enthält.

Für das vorherige Beispiel hätte es also zwei Datensätze erstellt. Einer ist für 0x100 bestimmt, der andere für 0x200, und dann würde das Hex-Ladeprogramm die Dinge an den richtigen Ort bringen. Dies ist eine weitere Alternative, aber Z80ASM scheint dies auch nicht zu unterstützen.

So.

Z80ASM ist großartig, wenn Sie ROM-Images erstellen, die beispielsweise willkürlich bei 0x1000 beginnen. Sie würden das ORG, eine resultierende Binärdatei erhalten und die gesamte eingebrannte Datei in ein EPROM herunterladen. Dafür ist es perfekt.

Aber für das, was Sie tun möchten, müssen Sie Ihren Code auffüllen, um Ihre Routinen an die richtigen Stellen zu verschieben, oder ein anderes Lader-Schema entwickeln, um dies für Sie zu manifestieren.

5 GeorgePhillips Dec 30 2020 at 03:16

Die orgRichtlinie sollte genau das tun, was Sie verlangen. Z80asm ist jedoch in seinem Ausgabeformat etwas simpel. Stattdessen können Sie dsRoutinen an bestimmten Adressen platzieren:

        ds     0x1000
printc:
        ...
        ret

        ds     0x1100-$
readc:
        ...
        ret

Dies wird immer printcauf 0x1000 und readcauf 0x1100 gesetzt. Es gibt viele Nachteile. Sollte es printcgrößer als 0x100 werden, wird das Programm nicht zusammengesetzt und Sie müssen auf printcirgendeine Weise auseinander brechen und den zusätzlichen Code woanders ablegen. Aus diesem und anderen Gründen ist eine Sprungtabelle an einem festen Speicherort einfacher zu verwalten und flexibler:

           ds    0x100
v_printc:  jp    printc
v_readc:   jp    readc
           ...

Eine andere Technik besteht darin, einen einzelnen Einstiegspunkt zu verwenden und die Funktion anhand eines Wertes im ARegister auszuwählen . Dies ist zumindest etwas langsamer, bedeutet jedoch, dass nur ein einziger Einstiegspunkt beibehalten werden muss, wenn sich das Betriebssystem ändert.

Und anstatt einen CALLEinstiegspunkt zu RSTerstellen, platzieren Sie ihn an einem der speziellen Speicherorte (0, 8, 0x10, 0x18, 0x20, 0x28, 0x30, 0x38), an denen Sie ihn RST 0x18als Einzelbyte-Aufruf an den Speicherort 0x18 verwenden können. Normalerweise RST 0und RST 0x38werden vermieden, da sie der Pwoer-On-Einstiegspunkt bzw. der Interrupt-Handler-Standort für Modell 1 sind.