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 zurü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. ;-)

Änderungen in Version 1.1

Bei systematischen Tests mit unterschiedlichen Systemen (vielen Dank an Markus!) sind ein paar Fehler und Unzulänglichkeiten aufgefallen. Insbesondere war der Toolbox-Client nicht so plattformunabhängig wie beabsichtigt. Dies lag an diversen "Convenience-Funktionen" die über die Zeit Einzug in die Win32-API erhalten haben, wie etwa RegGetValue, das es erst seit Windows XP gibt. Nach ein bisschen Umbau ist das Programm nun tatsächlich unter Windows 95/98/NT4 lauffähig, wobei man unter Windows 95 entweder das Windows Desktop Update (bzw. Windows 95c aka. OSR2.5) benötigt, oder die Datei MSVCRT.DLL aus anderer Quelle beschaffen muss.

Drive Editor zum Definieren eigener Laufwerke

Funktional hat sich auch die eine oder andere Lücke aufgetan. So funktioniert beispielsweise die Funktion 8 von BIOS-Interrupts 13h nicht immer zuverlässig und liefert im dümmsten Fall gar keine Laufwerksinformationen, sodass die Funktionen "Read Image" und "Write Image" nicht zur Verfügung stehen. Um dies zu beheben habe ich den Drive Editor gebaut, mit dem man nun manuell Laufwerke definieren kann, sofern man deren Index (0 = A:, 1 = B:) und Geometrie kennt (siehe README.TXT). In diesem Atemzug habe ich auch den Button im Hauptfenster von "Refresh" in "Detect drives" umbenannt. Der Drive Editor ist über ein Menü namens "Tweaks" zugängig, in das auch die exotischeren Einstellmöglichkeiten des ehemaligen Tweaks-Dialogs verschoben wurden (Einträge "Delays and timeouts" und "Flow control").

Dialog zur Konfiguration eines Transfers mit Größenänderung

Die wohl bedeutendste Änderung ist jedoch die Resize-Funktion für FAT12-Images. Mit dieser Funktion ist es möglich, z.B. 360K-Images so auf ein 1.44M-Laufwerk zu schreiben, dass die entstehenden Disketten bootfähig und vollständig les- und schreibbar sind. Was vielleicht nach schlimmen Dateisystem-Voodoo klingt ist hinter den Kulissen tatsächlich relativ einfach. Man muss sich ein Disk-Image lediglich als eine Folge von Sektoren vorstellen und ein paar Einträge im BIOS Parameter Block anpassen, sodass die Abbildung auf Spur und Seite wieder stimmt. Als Beiprodukt ist ein Kommandozeilen-Tool abgefallen, mit dem Images auch konvertiert werden können ohne sie auf ein Target-Laufwerk zu schreiben (z.B. für den Einsatz in einer virtuellen Maschine).
Dieses Kommandozeilen-Tool habe ich auch für einen automatisierten Test genutzt, um sicherzustellen dass alle möglichen Kombinationen aus Image und Laufwerkgröße bestmöglich funktionieren. Dazu habe ich die entstandenen Disk-Images unter Linux via mount -o loop gemountet und inhaltlich mit den ursprünglichen Images verglichen. Doch was ist mit "bestmöglich" gemeint? Nun, die Abbildung in die eine Richtung ist relativ klar: kleines Image auf großem Laufwerk muss natürlich funktionieren. Aber mein Programm erlaubt auch die umgekehrte Richtung, großes Image auf kleinem Laufwerk. Natürlich muss das zu Datenverlust führen, aber dennoch sind die am Anfang des Images gelegenen Dateien benutzbar, was je nach Anwendungsfall vielleicht heißbegehrt sein könnte.
Natürlich gibt es auch die Option, ein Image ohne "intelligente Verfummelung" einfach 1:1 zu schreiben, solange bis das Ende der Diskette bzw. des Images erreicht ist. Dies könnte für andere Dateisysteme sinnvoll sein und sollte daher nicht verboten sein.

Änderungen in Version 1.2

Neu in Version 1.2 ist der Target Explorer. Dieser erlaubt das komfortable Browsen durch das Dateisystem auf dem Target, sowohl auf Festplatten-Laufwerken als auch auf Disketten-Laufwerken. Dabei werden alle Dateien und Unterverzeichnisse aufgelistet, zusammen mit zusätzlichen Informationen zu Größe, Datum und Uhrzeit der letzten Änderung, sowie den gesetzten Datei-Attributen (schreibgeschützt, versteckt, etc.).

Target Explorer zum Browsen durch das Target-Dateisystem

Verzeichnisse können bequem per Doppelklick gewechselt werden, oder bei bekanntem Zielpfad direkt durch die Eingabe in die Location Bar am oberen Rand. Durch Eingabe eines anderen Filters als *.* können auch eingeschränkte Listen erzeugt werden, z.B. zeigt C:\DOS\*.BAS bei meiner Installation von MS-DOS 5.0 nur die 4 Beispiel-Programme (GORILLA.BAS, MONEY.BAS, NIBBLES.BAS und REMLINE.BAS) an. Das ist insofern ganz interessant, als dass die Auflistung vieler Einträge eine gewisse Zeit benötigt (bei 9600 Baud ca. 3-4 Einträge pro Sekunde; das ganzes DOS-Verzeichnis braucht ca. 25 Sekunden). Da der Filter bereits auf dem Target wirkt, entsteht somit eine entsprechend kürzere Liste die abgefragt werden muss.

Durch einen Doppelklick auf eine Datei wird der bereits bekannte "Receive file from target"-Dialog geöffnet, vorausgefüllt mit der ausgewählten Datei. Für Übertragungen in die umgekehrte Richtung können einzelne oder mehrere Dateien per Drag & Drop auf das Target-Explorer-Fenster gezogen werden. Auch hier wird der bereits vorhandene "Send file to target"-Dialog geöffnet und vorausgefüllt.

Diese Nicht-Interaktivität der Dateiübertragung (manuelle Bestätigung der Angaben durch den Benutzer) ergibt sich aus einer kleinen Gemeinheit der Geschichte: unter MS-DOS sind bei Dateinamen maximal 8 Zeichen für den Namen und 3 Zeichen für die Dateinamen-Erweiterung erlaubt. Unter Windows ist diese Beschränkung seit Windows 95 bzw. NT4 aufgehoben. Somit ist nicht sichergestellt, dass alle Dateien in einem Windows-Dateisystem direkt auf ein MS-DOS-Dateisystem abgebildet werden können. Als mir dies im Rahmen der Target-Explorer-Umsetzung bewusst geworden ist, habe ich die Dialoge "Send file to target" und "Receive file from target" um eine entsprechende Prüfung des Remote-Pfads erweitert. Wollte man nun die Übertragung automatisch ablaufen lassen, müsste im Fall eines Namenskonflikts eine automatische Lösung gefunden werden. Der Windows-typische Umgang besteht in der Vergabe von Kurznamen (MSDN: 8.3 alias or short name), wie z.B. "PROGRA~1" anstelle von "Programme". Um allerdings sicherstellen zu können, dass hier kein weiterer Konflikt entsteht (weil z.B. "PROGRA~1" im Ziel bereits existiert, und die nächste freie Darstellung "PROGRA~2" wäre), ist noch mehr Intelligenz erforderlich -- und das erschien mir für den ersten Wurf zu aufwendig. Allerdings ziehe ich inzwischen für eine spätere Version in Erwägung, den Prozess etwas zu straffen, indem entweder die Dateinamen schon im Voraus geprüft werden, oder erst im "Notfall" eine Konflikt-Behebungs-Aufforderung angezeigt wird; damit werden all jene Nutzer belohnt, die bereits im Vorfeld auf eine kompatible Benennung der zu übertragenden Dateien geachtet haben.

Eine vollständige Liste der Änderungen ist in der Datei CHANGES zu finden.

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
2020-11-17 1.1 pc-serial-loader-bin-1.1.zip Binaries für MS-DOS und Win32
pc-serial-loader-src-1.1.zip Quellcode
2020-12-19 1.2 pc-serial-loader-bin-1.2.zip Binaries für MS-DOS und Win32
pc-serial-loader-src-1.2.zip Quellcode

Zurück zur Hauptseite