pc-serial-loader: Datenübertragung via Nullmodem mal anders

Hintergrund

Neuer alter Rechner, neue Herausforderungen: nach dem Kauf eines IBM Personal Computer XTs befand ich mich in der Situation einen Rechner zu besitzen, der mit keinem meiner anderen Rechner "sprechen" konnte. Der XT hat ein 5,25"-Floppy-Laufwerk mit 360 KB Kapazität, also Double-Density-Medien. Diese sind auf Grund ihrer größeren Spurbreite nicht mit High-Density-Laufwerken (1,2 MB) kompatibel. Daraus ergaben sich eine Reihe von Optionen:

Ich habe mich (natürlich) für den aufwendigsten, aber interessantesten Weg entschieden: die Datenübertragung via Nullmodem, aber mal anders. Ich hätte natürlich über den ersten Weg initial mein zjlink auf den XT bringen können, aber ich habe mal so getan als hätte ich gar keine Wechseldatenträger zur Verfügung (was zeitweise auch so war, siehe die Geschichte zu meinem IBM PC XT). Gesucht war also ein Weg, der mit den Mitteln auskam, die auf dem PC bereits verfügbar waren. Neben ein paar Benchmarks und Diagnose-Programmen sowie einer Textverarbeitung war das im Wesentlichen ein MS-DOS 3.30 und ein Windows 1.0.

Das MS-DOS war leider unvollständig und hatte kein BASIC dabei, das wäre sonst ein Heimspiel gewesen. Dafür aber war DEBUG.COM dabei, sodass ich kurz darauf ein erstes "Hello World"-Programm in Assembler laufen hatte. Im späteren Verlauf der Entwicklung ergab sich jedoch eine interessante Idee, die sogar mit den in den Befehlsinterpreter COMMAND.COM eingebauten Mitteln auskommt.

Die Idee grob umrissen

pc-serial-loader setzt auf ein mehrstufiges Konzept, das ich in ähnlicher Weise bei der Software Amiga Explorer gesehen hatte:

  1. Der Initial Loader wird irgendwie auf das Zielsystem gebracht und gestartet
  2. Über die Nullmodem-Verbindung wird eine mächtigere zweite Stufe nachgeladen
  3. Ein Client-Programm auf dem Hostsystem dient als Benutzerschnittstelle

Der Initial Loader ist in Assembler geschrieben und umfasst gerade mal 22 Instruktionen. Zu Beginn hatte ich diesen per DEBUG.COM auf dem Zielsystem erstellt und übersetzt (wenn man das überhaupt so nennen kann). Später habe ich ihn auf meinem Windows-PC erstellt und über die Tastatur am Ziel-PC eingegeben. Dazu gibt es den schönen Trick aus der DOS-Steinzeit, die ALT-Taste zu drücken, gedrückt zu halten, einen Zeichencode über den Nummernblock einzugeben, und die ALT-Taste wieder loszulassen. Damit lassen sich alle 256 möglichen Werte, die ein Byte annehmen kann, als Zeichen darstellen (mit kleinen Einschränkungen, doch dazu später mehr). Diese Zeichen kann man direkt in eine Datei speichern und schon ist das Programm auf dem Zielsystem.

Die zweite Stufe nenne ich Toolbox, da sie universelle Grundfunktionen beinhaltet, die zur Umsetzung komplexerer Funktionen genutzt werden können. Als eine solche Grundfunktion betrachte ich z.B. das Öffnen einer Datei oder das Lesen eines Datenblocks. Eine komplexere Funktion wäre das Übertragen einer Datei vom Zielsystem auf das Hostsystem. Die Toolbox ist ebenfalls in Assembler geschrieben, aber da sie nicht mühevoll eingetippt werden muss, sondern in einem Rutsch via Nullmodem übertragen werden kann, besteht hier nicht der gleiche Zwang, sich möglichst kurz zu fassen. Assembler bot sich nicht zuletzt deshalb an, weil einige der benötigten Funktionen ohnehin nur als BIOS- oder DOS-Interrupt zur Verfügung stehen (z.B. einzelne Sektoren von Disketten lesen/schreiben).

Als Client kommt ein Windows-Programm mit grafischer Benutzeroberfläche zum Einsatz. Dieses habe ich in C geschrieben, unter direkter Verwendung der Win32-API. Das entstandene Programm ist mit ca. 60 KB ziemlich klein und hat keine weiteren Abhängigkeiten, sodaß es unverändert unter allen Windows-Versionen seit Windows 95 lauffähig sein sollte. Das Client-Programm ermöglicht zum einen das Lesen und Schreiben von Disk-Images unter Verwendung der Floppy-Laufwerke des Zielsystems, zum anderen die Übertragung von Dateien in beide Richtungen. Damit besteht sowohl die Möglichkeit, bereits auf dem Zielsystem vorhandene Daten und Programme zu sichern, als auch aus anderen Quellen stammende Software auf das Zielsystem zu bringen.

Der Initial Loader

Als Assembler-Programm mit nur 22 Instruktionen kann der Initial Loader nicht viel. Er besteht im Wesentlichen aus der Konfiguration des COM-Ports und einer Endlosschleife für Empfang und Ausführung der zweiten Stufe.

Zustandsmaschine des Initial Loaders

Die Funktion 2 des BIOS-Interrupts 14h wartet zunächst blockierend auf eingehende Daten. Wenn für ca. 1 Sekunde keine Daten eintreffen, dann kehrt der Aufruf zuück und zeigt an, dass nichts empfangen wurde. Der Initial Loader nutzt dies, indem er zu Beginn solange im Zustand idle verbleibt, bis ein erstes gültiges Byte empfangen wurde. Danach wechselt er in den Zustand receive und schreibt das empfangene Byte an eine statisch konfigurierte Adresse. Danach wird versucht das nächste Byte zu empfangen. Solange gültige Bytes empfangen werden, verbleibt der Loader im Zustand receive und schreibt die eingehenden Daten an fortlaufende Speicheradressen. Sobald der Datenstrom aufhört und die BIOS-Funktion per Timeout zurückkehrt, wechselt der Loader in den Zustand execute und ruft den empfangenen Code mit einer CALL-Instruktion auf. Wenn das aufgerufene Programm zurückkehrt, wird der Rückgabewert im Register AL geprüft. Ist er 0, so beginnt der Vorgang durch den Wechsel in den Zustand start von neuem. Bei einem Rückgabewert ungleich 0 wird der Loader verlassen, wobei der Rückgabewert des empfangenen Programms auch als Exit-Code des Loaders an das Betriebssystem weitergegeben wird.

Durch dieses sehr einfache Protokoll benötigt der Loader keinerlei Kenntnis über die Länge und die Art der Daten, die er erhält. Es muss lediglich sichergestellt sein, dass die zweite Stufe an einem Stück übertragen wird, also ohne Unterbrechungen oder Stockungen die zeitlich in die Größenordnung der Timeout-Zeit kommen.

Tatsächlich ruft der Loader sogar nicht einfach nur das Programm auf, sondern legt zuvor den Index des verwendeten COM-Ports (z.B. 0 für COM1) als Wort auf den Stack. Das geladene Programm kann somit die bereits konfigurierte Verbindung nutzen, und sollte man den COM-Port oder die Baudrate einmal ändern wollen, so muss dies nur im Code des Loaders geschehen. Dort ist es dafür umso umständlicher: sowohl die Baudrate als auch die Einstellungen für Anzahl Datenbits, Stopbits und Parität sind in einem einzigen 8-Bit-Wert kodiert.

7 6 5 4 3 2 1 0
Baudrate Parity Stop bits Data bits
Details: https://stanislavs.org/helppc/int_14-0.html

Die Eingabe des Initial Loaders über die Tastatur erfordert noch ein paar weitere Kniffe. Der Index des COM-Ports 1 ist wie erwähnt der Wert 0. Dieser wird im Register DX erwartet, d.h. irgendwo im Code muss die Instruktion MOV DX, 0 auftauchen. Dies erzeugt den Maschinencode BA 00 00, d.h. er beinhaltet zwei Null-Bytes. Bei der Eingabe des Loader-Codes über die Tastatur via ALT+nnn gibt es jedoch ein paar Zeichencodes, die problematisch sind, und dazu gehört neben der 9 (Tabulator) und der 27 (Escape) auch die 0. Um dieses Problem zu umschiffen müssen Instruktionen, die solche ungünstigen Codes erzeugen, durch Instruktionen mit gleicher Wirkung aber anderen Codes ersetzt werden. Am Beispiel des COM-Port-Index verwendet mein Loader z.B. XOR DX, DX, was dem Maschinencode 31 D2 entspricht und damit problemlos darstellbar ist. Das gleiche Problem ergibt sich auch mit der Speicheradresse, an die der empfangene Code gespeichert wird: glatte Adressen wie DS:0400 erzeugen ebenfalls Null-Bytes. Daher wird bei mir das Programm an die Adresse DS:0402 geladen.

Wenn der Code breinigt ist, dann kann er über folgendes Konstrukt genutzt werden:

C:\>echo [Zeichenfolge] > initld.com

Damit wird die Zeichenfolge auf die Konsole ausgegeben (echo), aber die Ausgabe in eine Datei umgeleitet. Diese beinhaltet danach die Zeichenfolge (und damit das Programm), gefolgt von den zwei Bytes 0D 0A. Diese sind auf den Zeilenumbruch (CR LF bzw. "\r\n") zurückzuführen, die echo automatisch anhängt. So etwas möchte man normalerweise nicht im Programmcode haben, aber in diesem Fall ist das egal, da es hinten anhängt und somit nach der Instruktion folgt, die das Ausführen des Programms beendet und zu DOS zurückkehrt, d.h. das 0D 0A wird niemals ausgeführt. Glück gehabt. :-)

Die Toolbox

Die Toolbox wird als Binärdatei übertragen und auf dem Zielsystem ausgeführt. Sie verwendet ein deutlich komplexeres Protokoll, das eine Kommunikation in beide Richtungen erlaubt. Jeder Übertragung wird eine Längenangabe in Bytes vorangestellt, sodass der Empfänger genau weiß, wie viel Bytes er noch lesen muss. Außerdem ermöglicht dies, unbekannte Befehle zu überspringen, ohne dass die Kommunikation danach gestört ist.

Jeder Befehl ist durch einen Code identifizierbar, der zur Auswahl einer spezifischen Bearbeitungsfunktion genutzt wird. Alle darauf folgenden Bytes sind spezifisch für den jeweiligen Befehl und können weitere Parameter beinhalten. In Version 1 des Kommunikationsprotokolls werden folgende Befehle unterstützt:

Befehl Code Nutzdaten in Request Nutzdaten in Response
Exit 0 Exitcode ---
GetVersion 1 --- Versionsnummer der Toolbox
GetDriveInfo 2 Laufwerkindex Statuscode, Laufwerktyp, Anzahl Spuren, Anzahl Seiten, Anzahl Sektoren
ReadSector 3 Laufwerkindex, Spur, Seite, Sektor Statuscode, 512 Byte Daten
WriteSector 4 Laufwerkindex, Spur, Seite, Sektor, 512 Byte Daten Statuscode
OpenFile 5 Zugriffsmodus (lesen/schreiben), Dateiname Statuscode, Datei-Handle
GetFileSize 6 Datei-Handle Statuscode, Dateigröße
ReadFile 7 Datei-Handle Statuscode, Anzahl gelesener Bytes, bis 512 Byte Daten
WriteFile 8 Datei-Handle, Anzahl zu schreibender Bytes, bis 512 Byte Daten Statuscode, Anzahl geschriebener Bytes
CloseFile 9 Datei-Handle Statuscode

Die Toolbox-Binärdatei ist ca. 1,2 KB groß, was mich persönlich mal wieder staunen lässt, wie kompakt Assembler-Programme sind. (Und ich bin wahrscheinlich noch nicht mal besonders gut in Assembler, da kann man bestimmt noch was rausquetschen.)

Der Client

Anders als bei zjlink wollte ich diesmal eine grafische Benutzeroberfläche haben. Ich schätze, dass selbst unter Berücksichtigung der ganzen Grundlagenforschung für Loader und Toolbox trotzdem mehr als die Hälfte der Arbeit in das Client-Programm geflossen ist. Dies liegt nicht einmal so sehr daran, dass der Weg über die Win32-API in C etwas steinig ist, sondern einfach daran, dass sich viele Fragen zu Gestaltung und Bedienphilosophie ergeben, die bei einem Kommandozeilen-Tool einfach nicht relevant sind. Zum Beispiel die flüssige Bedienbarkeit eines Cancel-Buttons bei gleichzeitiger Darstellung eines Fortschrittbalkens während langwieriger Vorgänge -- denn dies bedeutet Multi-Threading. Außerdem wollte ich diesmal völlig auf static-Variablen verzichten und jeder Dialog-Instanz eine eigene Instanz-Struktur mit den Laufzeitinformationen mitgeben, was tatsächlich ziemlich hübsch geworden ist.

Hauptdialog des Client-Programms

Das Client-Programm besteht im Wesentlichen aus einem Dialogfenster mit Menüleiste und diversen Unterdialogen zum Abfragen von Parametern und Dateinamen.

Hinter den Menüpunkten verbergen sich die im Laufe einer Sitzung weniger oft benötigten Funktionen für das Öffnen und Schließen der Verbindung, Download und Beeenden der Toolbox, etc. sowie ein Einstellungsdialog für diverse Timings und andere experimentelle Dinge, die evtl. bei anderen Zielsystemen nützlich sein könnten.

Der Refresh-Button aktualisiert die Liste der Laufwerke, wobei diese durch das Abfragen der Laufwerkinformationen für die Indizes 0 bis 3 entsteht. Mein IBM PC XT mit nur einem Laufwerk meldet hierbei für jeden Laufwerk-Index die gleichen Informationen. Ich habe noch nicht ausprobiert, was auf anderen Systemen passiert, aber eigentlich sollte Index 0 = A:, Index 1 = B: usw. gelten.

Die Betätigung der Buttons Read image... und Write image... öffnet jeweils einen Standard-Dialog zum Auswählen einer Image-Datei und führt darauf folgend den Transfer von bzw. zu dem in der Dropdown-Combobox ausgewählten Laufwerk aus. Der Transfer eines Images mit 360 KB dauert etwa 7 - 8 Minuten und ist damit schon ziemlich nah an dem Maximum, das man bei 9600 Baud erwarten kann:

Datei für Empfangen von Dateien

9600 Baud = 9600 Bit/s.
Bei 1 Startbit, 8 Datenbits und 1 Stopbit: 9600 Bit/s / 10 Bit/Byte = 960 Byte/s.
360 * 1024 Byte / 960 Byte/s = 384 s (= ca. 6,4 Minuten).

Der Button List files... ist im Moment eigentlich nur dort, damit die Buttons Receive... und Send... auf der jeweils gleichen Höhe wie Read/Write image sind. Vielleicht wird es in einer späteren Version aber tatsächlich mal die Möglichkeit geben, Dateien auf der Target-Seite aufzulisten. Bis dahin sehen die Dialoge, die bei einem Klick auf Receive... und Send... erscheinen, etwas unhandlich aus.

Die Datei im lokalen Dateisystem kann jeweils über einen "Öffnen"- bzw. "Speichern unter"-Dialog gewählt werden, die Datei im entfernten Dateisystem muss von Hand eingetippt werden. Dafür werden jedoch relative und absolute Pfade unterstützt, d.h. man kommt unabhängig vom aktuellen Arbeitsverzeichnis des Loaders an alle Dateien (auch auf anderen Laufwerken!).
Bevor ich mich aber zu sehr mit fremden Federn schmücke: das ist ein Beiprodukt der Verwendung der Funktionen 3Ch und 3Dh des DOS-Interrupts 21h. Hätten die das nicht hergegeben, dann hätte ich mich wahrscheinlich mit dem aktuellen Arbeitsverzeichnis begnügt.

Weitere Dokumentation

Ich habe es tatsächlich sehr genossen eine readme.txt mit etwas ASCII-Art und einer Zeilenlänge von 80 Zeichen zu erstellen. Solche Dateien haben eine seltsame Faszination auf mich und erinnern mich an so gruselige Dinge wie BIOS-Updates und Patches für Spiele aus den späten 90ern, bei denen nach der Installation häufig solche Textdateien im Windows-eigenen Notepad und dem hässlichen System-Font aufgeploppt sind. Jetzt habe ich auch mal so etwas gemacht; Eintrag kann von der wollte-ich-schon-immer-mal-tun-Liste gestrichen werden. ;-)

Downloads

Datum Version Datei Beschreibung
2020-05-30 1.0 pc-serial-loader-bin-1.0.zip Binaries für MS-DOS und Win32
pc-serial-loader-src-1.0.zip Quellcode

Zurück zur Hauptseite