Andreas Rozek
[ Impressum ]   [ Datenschutzerklärung ]   [ Kontakt ]       deutsche Fassung   [ english version ]

Assembler-Simulator

Zu den ursprünglichsten Formen der Computer-Programmierung gehört die Verwendung eines Assemblers.

Marco Schweighauser hat einen sehr schönen Simulator für einen einfachen 8-Bit Prozessor mit integriertem Assembler geschrieben, der einen guten Einblick in die Welt der maschinennahen Programmierung ermöglicht.

Falls Sie die Vorlesung "Grundlagen der Informatik" an der HFT Stuttgart hören, finden Sie hier neben den dort bereits erläuterten Beispielen weitere Programme zur Vertiefung der Thematik und für eigene Experimente.

Eine Anleitung erklärt Ihnen die Bedienung der Seiten in diesem Web-Auftritt, in den Einstellungen können Sie deren Verhalten anpassen.

Der Simulator im Überblick

Der Simulator kann online genutzt werden, eine Installation ist nicht erforderlich. Alle wichtigen Informationen zu dem System finden Sie auf Github.

Die Bedienoberfläche umfasst

  • einen großen Editor für das auszuführende Programm
    Nota bene: vergessen Sie im Anschluss an eine Änderung nicht, das Programm erneut zu assemblieren - erst dadurch wird Ihre Änderung auch wirksam!
  • einen Ausgabebereich ("Konsole") für bis zu 24 Zeichen,
  • eine Anzeige für die vier Mehrzweck-Register, den "Instruction Pointer" und den "Stack Pointer" sowie die drei Flags ("Zero", "Carry", "Failure") des simulierten Prozessors,
  • eine Anzeige für die 256 Bytes des simulierten Speichers und schließlich
  • eine Liste der im Assembler-Programm definierten "Labels", mitsamt der konkreten Adresse und des aktuellen Inhaltes.

Explizite Eingaben gestattet der Simulator nicht, alle Daten müssen Teil des Assembler-Programmes sein.

Abb. 1: Ansicht des 8-Bit Assembler-Simulators
Abb. 1: Ansicht des 8-Bit Assembler-Simulators

Die Anzeige von Register- und Speicher-Inhalten kann wahlweise hexadezimal oder dezimal erfolgen, außerdem können erkannte Befehle in der Speicheranzeige farblich markiert werden.

Falls ein Register eine Adresse beinhaltet, kann die referenzierte Speicherzelle auf Wunsch farblich markiert werden, so wie es für "Instruction Pointer" und "Stack Pointer" standardmäßig bereits geschieht.

Die Konsole wird (im Sinne einer "memory-mapped IO") auf einen Bereich im Hauptspeicher abgebildet, die betreffenden Zellen sind grau hinterlegt.

Das erste Programm

Direkt nach dem Laden der zugehörigen Web-Seite enthält der Editor des Assembler-Simulators bereits ein "Hello World!"-Programm.

Drücken Sie auf Assemble, um dieses Programm zu übersetzen (und achten Sie darauf, wie der Speicher mit den Daten und Instruktionen aus dem Programm gefüllt wird).

Anschließend klicken Sie auf die grüne Run-Taste und sehen Sie dem Prozessor dabei zu, wie der Ausgabebereich mit dem Text "Hello World!" gefüllt wird - danach bleibt der Prozessor stehen.

Nota bene: Falls Sie ein bereits gelaufenes Programm noch einmal starten möchten, müssen Sie den Prozessor zunächst mithilfe der Reset-Taste zurücksetzen.

Die ersten eigenen Programme

Der Simulator wäre nur halb so schön, könnte man nicht selbst aktiv werden und eigene Programme laufen lassen.

Die folgenden Beispiele sind dafür gedacht, mit möglichst wenig Befehlen sichtbare Ausgaben zu produzieren.

  • ein einzelner Stern auf der Konsole
    das erste Beispiel schreibt lediglich den ASCII-Code für einen Stern (*) in den Speicherbereich, der für die Konsole gedacht ist.
    Leeren Sie den Editor und kopieren Sie den Quelltext für dieses Beispiel hinein, klicken Sie auf Assemble, danach auf Run und beobachten Sie den Ausgabebereich
    ; write a single asterisk onto the console

    MOV [0xE8],'*'
    HLT
  • zwei Sterne auf der Konsole
    die Anzeigefelder der Konsole belegen aufeinanderfolge Speicherzellen. Möchte man also einen zweiten Stern anzeigen, so muss der ASCII-Code dafür einfach in die auf 0xE8 folgende Speicherzelle geschrieben werden
    ; write two asterisks onto the console

    MOV [0xE8],'*'
    MOV [0xE9],'*'
    HLT
  • zwei Sterne auf der Konsole (zweite Variante)
    man muss die Adressen der Ausgabefelder nicht explizit angeben, man kann sie auch berechnen lassen und mithilfe einer "indirekten Adressierung" auf die Zellen zugreifen
    ; write two asterisks onto the console

    MOV A,0xE8
    MOV [A],'*'

    INC A
    MOV [A],'*'
    HLT
  • "Mein Gott, es ist voller Sterne"
    interessant ist die Adressberechnung bei der Verwendung von Schleifen - auf diese Weise kann man leicht die gesamte Konsole mit Sternen füllen.
    Da der Speicherbereich für die Ausgabe bis zur Adresse 0xFF reicht, erkennt man das Ende z.B. an einem Bereichsüberlauf nach dem Inkrementieren der Ausgabeadresse.
    ; fill the console with asterisks

    MOV A,0xE8
    Loop:
    MOV [A],'*'
    INC A
    JNC Loop ; incrementing 0xFF will set carry
    HLT
  • ...und falls man einen Fehler macht?
    syntaktische (und auch den einen oder anderen semantischen) Fehler kann der Assembler selbst erkennen. Im allgemeinen hilft aber nur ein gezieltes Ausprobieren (d.h. "Testen"), um die Korrektheit eines Programmes zu zeigen.
    Zu den am schwierigsten zu findenden Fehlern gehört das versehentliche (bisweilen auch gezielte) Überschreiben des Programmes selbst. Probieren Sie aus was passiert, wenn Sie im vorherigen Programm anstelle des bedingten Sprunges einen unbedingten setzen...
    ; fill the console with asterisks

    MOV A,0xE8
    Loop:
    MOV [A],'*'
    INC A
    JMP Loop

Lernen Sie den Prozessor kennen

Für die Assembler-Programmierung ist es wichtig, das Verhalten des Prozessors sowie die Auswirkungen der einzelnen Befehle auf die Flags zu kennen.

Die folgenden Beispiele mögen deshalb vielleicht trivial wirken (sie sind es ja auch), tragen aber dennoch zum Verständnis des Systems bei. Werfen Sie deshalb am Ende jedes der folgenden Programme einen Blick auf die Register-Inhalte und Flag-Zustände!

  • MOV lässt Flags unangetastet
    zu den wichtigsten Eigenschaften des MOV-Befehles gehört das Beibehalten aller Flag- Zustände: kaum ein Assembler-Programm könnte funktionieren, wenn sich ein Prozessor in dieser Hinsicht anders verhalten würde.
    Im Gegenzug bedeutet dies aber auch, dass das simple Laden einer 0 in ein Register nicht zum Setzen des "Zero Flag" führt - man muss den Prozessor schon mit einem CMP reg,0 explizit zu einem Test zwingen
    ; MOV does not set the zero flag

    MOV A,0
    HLT
    ; CMP triggers an explicit test

    MOV A,0
    CMP A,0
    HLT
  • INC und DEC verändern Flags - aber Vorsicht!
    im Allgemeinen benötigt man entweder CMP oder einen arithmetischen bzw. logischen Befehl, um die Flags zu aktualisieren - aber nicht immer verhält sich der Prozessor wie erwartet:
    • Inkrementieren des Wertes 255
      setzt das Carry und löscht das Zero Flag, obwohl das verwendete Register 0 zeigt - aus semantischer Sicht ist dieses Verhalten aber absolut korrekt

      ; check flags (Z = 0 - sic!, C = 1)

      MOV A,255
      INC A
      HLT
    • Dekrementieren einer 1
      setzt das Zero Flag, so wie es auch zu erwarten war

      ; check flags (Z = 1, C = 0)

      MOV A,1
      DEC A
      HLT
    • Dekrementieren einer 0
      setzt das Carry Flag, welches jetzt die Funktion eines "Borrow" übernimmt

      ; check flags (Z = 0, C = 1)

      MOV A,0
      DEC A
      HLT
  • NOT ist fehlerhaft
    ebenso wie "echte" Prozessoren, beinhaltet auch der Simulator ein paar Fehler:
    • NOT setzt stets das Carry Flag
      und zwar unabhängig vom zu invertierenden Wert - ein solches Verhalten ist unerwartet.

      Allerdings kann diese Eigenheit durchaus auch praktisch genutzt werden: wenn man das Carry Flag (z.B. im Rahmen einer 16-Bit-Arithmetik) explizit setzen möchte, genügen zwei NOT-Befehle hintereinander (auf ein beliebiges, aber dasselbe Register angewendet), und das Carry Flag ist gesetzt, der Register-Inhalt jedoch unverändert

      ; check flags (Z = 0, C = 1 - why?)

      MOV A,0x0F
      NOT A
      NOT A
      HLT
    • NOT invertiert 0xFF falsch
      leider liefert NOT, auf 0xFF angewandt, nicht den Wert 0x00, sondern 0x100 (!) - also einen vollkommen ungültigen Wert - eine zweifache Invertierung liefert zwar wieder das ursprgl. 0xFF (wodurch der NOT-Befehl weiterhin zum Setzen des Carry Flag taugt), für eine einfache Invertierung ist der Befehl jedoch ungeeignet!

      ; NOT is broken

      MOV A,0xFF
      NOT A ; watch register: contains 100!
      HLT
    • XOR anstelle von NOT
      Abhilfe (für die Invertierung, nicht aber für das Setzen des Carry Flag) bietet in diesem Fall der XOR-Befehl: die XOR-Verknüpfung eines beliebigen Registers mit dem konstanten Wert 0xFF bewirkt de facto eine Invertierung des Registerinhaltes

      ; use XOR (instead of NOT) for negation

      MOV A,0xFF
      XOR A,0xFF
      HLT
  • arithmetische Befehle
    die arithmetischen Befehle verhalten sich wie erwartet - probieren Sie sie aus!
    ; check flags (Z = 0 - sic!, C = 1) like INC

    MOV A,0xFF
    ADD A,1
    HLT
    ; check flags (Z = 1, C = 0) like DEC

    MOV A,1
    SUB A,1
    HLT
    ; check flags (Z = 0, C = 1) like DEC

    MOV A,0
    SUB A,1
    HLT
    ; check flags (Z = 1, C = 0)

    MOV A,0
    MUL 1
    HLT
    ; check flags (Z = 0, C = 1)

    MOV A,255
    MUL 2
    HLT
    ; check flags (Z = 1, C = 0)

    MOV A,0
    DIV 1
    HLT

    Testen Sie durchaus auch einmal die Division durch 0
    ; check flags (do you see it?)

    MOV A,0
    DIV 0
    HLT
  • logische Befehle
    die logischen Befehle bergen ebenfalls keine Überraschungen
    ; check flags (Z = 1, C = 0)

    MOV A,0xFF
    AND A,0x00
    HLT
    ; check flags (Z = 0, C = 0)

    MOV A,0xFF
    AND A,0x55
    HLT
    ; check flags (Z = 1, C = 0)

    MOV A,0x00
    OR A,0x00
    HLT
    ; check flags (Z = 0, C = 0)

    MOV A,0x00
    OR A,0x55
    HLT
    ; check flags (Z = 1, C = 0)

    MOV A,0x55
    XOR A,0x55
    HLT
    ; check flags (Z = 0, C = 0)

    MOV A,0x55
    XOR A,0x00
    HLT
  • Stack-Operationen
    sehr angenehm am Simulator ist auch die Unterstützung eines Stapelspeichers ("Stack") und die Art und Weise, wie der Stack in der Speicheranzeige dargestellt wird.
    Lassen Sie die folgenden Beispiele laufen und sehen Sie sich den jeweiligen Stack-Zustand an (z.B. die Richtung, in der der Stack wächst, und was nach dem Abräumen des Stack im Speicher verbleibt)!
    ; watch the stack!

    PUSH 1
    PUSH 2
    PUSH 3
    HLT
    ; watch the stack!

    PUSH 1
    PUSH 2
    PUSH 3
    POP A
    POP B
    HLT
    ; watch the stack!

    POP A ; causes a runtime error
    HLT
    ; watch the stack!

    CALL Subroutine
    HLT ; good style to protect routines

    Subroutine:
    HLT
    ; watch the stack!

    CALL Sub_1
    HLT ; good style to protect routines

    Sub_1:
    CALL Sub_2
    HLT

    Sub_2:
    CALL Sub_3
    RET

    Sub_3:
    RET

16-Bit Arithmetik mit dem 8-Bit-Prozessor

Die folgenden Beispiele beschäftigen sich nicht mehr mit dem Prozessor selbst, sondern lösen arithmetische Aufgaben. Nutzen Sie die Programme, um Ihre Kenntnisse im Umgang mit binären Zahlen zu vertiefen!

Mangels anderer Eingabemöglichkeiten müssen die zu bearbeitenden Zahlen direkt in das jeweilige Programm eingegeben werden - durch Füllen der beteiligten Register (A...D) mit den höher- bzw. niederwertigen Bytes der 16-Bit großen Operanden.

Auch die Ausgabe des (bzw. der) Ergebnisse erfolgt der Einfachheit halber (zumeist) über Register. Werfen Sie deshalb am Ende jeder Berechnung einen Blick auf die Registeranzeige im Simulator.

Ein- und Ausgabe erfolgen in der "big endian"-Reihenfolge: zuerst kommt das höherwertige, anschließend das niederwertige Byte (MSB vor LSB): A = MSB, B = LSB, C = MSB, D = LSB.

  • Vergleich zweier 16-Bit-Zahlen
    Das einfachste Beispiel vergleicht zwei 16-Bit große Zahlen miteinander.
    Tragen Sie die erste Zahl in die Register A und B, die zweite in die Register C und D ein. Nach Durchlaufen des Programmes zeigt die Konsole die Beziehung zwischen den beiden Registerpaaren AB und CD an: < bedeutet, dass AB kleiner als CD ist, = zeigt die Gleichheit der beiden Zahlen an, und > erscheint, falls AB größer als CD ist.
    ; compare two 16-bit values

    MOV A,0x12 ; compare AB with CD
    MOV B,0x34
    MOV C,0x12
    MOV D,0x34

    CMP A,C
    JB below
    JE equal_MSB
    JA above
    HLT

    equal_MSB:
    CMP B,D
    JB below
    JE equal
    JA above
    HLT

    below:
    MOV [0xE8],'<'
    HLT

    equal:
    MOV [0xE8],'='
    HLT

    above:
    MOV [0xE8],'>'
    HLT
  • Inkrementieren und Dekrementieren einer 16-Bit-Zahl
    Die einfachsten Rechenoperationen sind das Erhöhen bzw. Erniedrigen einer Zahl um 1.
    Tragen Sie den gewünschten Operanden in die Register A und B ein, assemblieren Sie und lassen Sie das Programm laufen. Am Ende steht in dem Registerpaar auch das Rechenergebnis.
    Testen Sie insbesondere auch folgende Operanden: 0x0000, 0x00FF und 0xFFFF - achten Sie auch auf den zustand des Carry Flag am Ende der Berechnung!
    ; increment a 16-bit value (see registers)

    MOV A,0x12 ; increment AB
    MOV B,0x34

    INC B
    JNC exit

    INC A
    exit:
    HLT
    ; decrement a 16-bit value (see registers)

    MOV A,0x12 ; decrement AB
    MOV B,0x34

    DEC B
    JNC exit

    DEC A
    exit:
    HLT
  • Bilden des 2er-Komplements für eine 16-Bit-Zahl
    Von der Inkrementierung zur Bildung eines 2er-Komplements ist es nicht mehr weit.
    Tragen Sie den gewünschten Operanden in die Register A und B ein, assemblieren Sie und lassen Sie das Programm laufen. Am Ende steht im Registerpaar AB auch das Rechenergebnis.
    Testen Sie insbesondere auch folgende Operanden: 0x0000, 0x0001 und 0xFFFF!
    ; 16-bit 2th complement (see registers)

    MOV A,0x12
    MOV B,0x34

    XOR A,0xFF ; instead of NOT A
    XOR B,0xFF ; instead of NOT B

    INC B
    JNC exit

    INC A
    exit:
    HLT
  • Addieren und Subtrahieren zweier 16-Bit-Zahlen
    Das Addieren und Subtrahieren zweier 16-Bit breiter Zahlen ist schon ein wenig aufwändiger - insbesondere kommt hier auch zum erstenmal der "Trick" für das explizite Setzen des Carry Flag zum Einsatz.
    Tragen Sie die gewünschten Summanden in die Registerpaare AB bzw. CD ein, assemblieren Sie und lassen Sie das Programm laufen. Am Ende steht im Registerpaar AB auch das Rechenergebnis.
    Achten Sie bei der Wahl der Zahlen für Ihre Tests insbesondere auch auf die Problemfälle des Verfahrens - nämlich die Überläufe nach der Verarbeitung der beiden LSBs sowie den endgültigen Überlauf nach Verarbeitung der MSBs!
    ; add two 16-bit values (see registers,flags)

    MOV A,0x12 ; compute AB + CD
    MOV B,0x34
    MOV C,0x12
    MOV D,0x34

    ADD B,D
    JC LSB_carry

    ADD A,C
    JMP exit ; carry flag is properly set here

    LSB_carry:
    INC A
    JC MSB_carry

    ADD A,C
    JMP exit ; carry flag is properly set here

    MSB_carry:
    ADD A,C ; will clear the carry flag
    NOT A ; trick to explicitly set carry flag
    NOT A

    exit:
    HLT
    ; subtract two 16-bit values (see regs,flags)

    MOV A,0x12 ; compute AB - CD
    MOV B,0x34
    MOV C,0x12
    MOV D,0x34

    SUB B,D
    JC LSB_carry

    SUB A,C
    JMP exit ; carry flag is properly set here

    LSB_carry:
    DEC A
    JC MSB_carry

    SUB A,C
    JMP exit ; carry flag is properly set here

    MSB_carry:
    SUB A,C ; will clear the carry flag
    NOT A ; trick to explicitly set carry flag
    NOT A

    exit:
    HLT
  • Verschieben einer 16-Bit-Zahl um eine Stelle nach rechts
    Die einfachste Form der Division einer Dualzahl besteht in dem Verschieben aller Bits um eine Stelle nach rechts - entsprechend einer Division durch 2.
    Tragen Sie die zu verschiebende Zahl in die Register A und B ein, assemblieren Sie und lassen Sie das Programm laufen. Am Ende steht im Registerpaar AB auch das Ergebnis.
    Testen Sie insbesondere auch folgende Operanden: 0x0000, 0x0001, 0x0100 und 0xFFFF!
    ; shift 16-bit value right (see regs,flags)

    MOV A,0x12 ; compute AB >> 1
    MOV B,0x34

    MOV C,A ; since rightmost bit of A is lost
    SHL C,7

    SHR A,1
    SHR B,1
    ADD B,C ; considers rightmost bit of A

    exit:
    HLT
  • Verschieben einer 16-Bit-Zahl um eine Stelle nach links
    Die einfachste Form der Multiplikation einer Dualzahl besteht in dem Verschieben aller Bits um eine Stelle nach links - entsprechend einer Multiplikation mit 2.
    Tragen Sie die zu verschiebende Zahl in die Register A und B ein, assemblieren Sie und lassen Sie das Programm laufen.
    Am Ende steht im Registerpaar AB auch das Ergebnis. Testen Sie insbesondere auch folgende Operanden: 0x0000, 0x0080, 0x8000 und 0xFFFF - achten Sie auch auf den Zustand des Carry Flag am Ende jedes Durchlaufes!
    ; shift 16-bit value left (see regs,flags)

    MOV A,0x12 ; compute AB << 1
    MOV B,0x34

    SHL B,1
    JC LSB_carry

    SHL A,1
    JMP exit ; carry flag is properly set here

    LSB_carry:
    SHL A,1
    JC MSB_carry

    ADD A,1 ; considers carry from B << 1
    JMP exit ; carry flag is properly set here

    MSB_carry:
    ADD A,1 ; considers carry from B << 1
    NOT A ; trick to explicitly set carry flag
    NOT A

    exit:
    HLT
  • Multiplizieren zweier 8-Bit-Zahlen zu einem 16-Bit-breiten Ergebnis
    Die Multiplikation zweier 8-Bit breiter Zahlen ist das erste etwas anspruchsvollere Beispiel in dieser Zusammenstellung.
    Tragen Sie die gewünschten Operanden in die Register A und B ein, assemblieren Sie und lassen Sie das Programm laufen. Am Ende steht im Registerpaar CD das Ergebnis der Berechnung.
    Testen Sie insbesondere auch die Multiplikation mit 0 oder 1 sowie Zahlen, deren Produkt noch bzw. nicht mehr in ein einzelnes Byte passt!
    ; multiply two 8-Bit values (see regs, flags)

    MOV A,0x12 ; compute A*B
    MOV B,0x34

    MOV C,0 ; will store MSB of result
    MOV D,0 ; LSB

    ; find left-most bit of B

    PUSH A ; we need this register ourself

    MOV A,8 ; counter
    bit_loop:
    SHL B,1
    JC upper_bit_found

    DEC A
    JNZ bit_loop
    JMP exit ; B seems to be 0

    upper_bit_found:
    MOV D,[SP+1] ; start actual multiplication

    multiplication_loop:
    DEC A ; proceed to next bit
    JZ exit

    SHL C,1 ; don't care about carry
    SHL D,1
    JNC no_bit_transfer

    ADD C,1 ; MSB of D transferred to C

    no_bit_transfer:
    SHL B,1
    JNC upper_bit_not_set

    ADD D,[SP+1] ; add former content of A
    JNC upper_bit_not_set; no overflow detected

    INC C ; considers carry of addition

    upper_bit_not_set:
    JMP multiplication_loop

    exit:
    INC SP ; throw backup of A away
    HLT
  • Division einer 16-Bit-breiten Zahl durch eine 8-Bit-Zahl
    Die 16-Bit-Division ist das anspruchsvollste Beispiel auf dieser Seite.
    Tragen Sie den Dividenden in das Registerpaar AB und den Divisor in das Register C ein, assemblieren Sie und lassen Sie das Programm laufen. Am Ende steht im Registerpaar CD das Ergebnis der (Ganzzahl-)Division und im Registerpaar AB der verbleibende Divisionsrest.
    Testen Sie insbesondere auch die Division durch 0, 1 oder eine 2er-Potenz!
    ; divide a 16-Bit value by an 8-Bit one (see regs, flags)

    MOV A,0x12 ; compute AB / C
    MOV B,0x34
    MOV C,0x56 ; divisor

    CMP C,0
    JNE division
    HLT ; error: division by zero

    ; how many iterations do we need?

    division:
    MOV D,9 ; that's the default

    normalization_loop:
    CMP C,0x80 ; test if upper bit is set
    JAE start_division

    INC D
    SHL C,1 ; C is not 0, thus has bit(s) set
    JMP normalization_loop

    start_division: ; we need more "registers"!
    PUSH 0 ; auxiliary "register", call it "I"
    PUSH 0 ; LSB of result, call it "H"
    PUSH 0 ; MSB of result, call it "G"
    PUSH 0 ; call it "F"
    PUSH C ; call it "E" now

    division_loop:
    CMP A,[SP+1] ; AB >= EF?
    JB skip
    JA subtract

    CMP B,[SP+2]
    JB skip

    subtract: ; compute AB-EF
    SUB B,[SP+2]
    JNC no_borrow

    DEC A
    no_borrow:
    SUB A,[SP+1]

    MOV C,[SP+4]
    OR C,0x01 ; set LSB in result
    MOV [SP+4],C

    skip:
    DEC D ; proceed to next step
    JZ exit ; finish, if no more steps needed

    ; shift EF right

    MOV C,[SP+1] ; rightmost bit of E is lost
    SHL C,7
    MOV [SP+5],C ; store in "I"

    MOV C,[SP+1]
    SHR C,1
    MOV [SP+1],C

    MOV C,[SP+2]
    SHR C,1
    ADD C,[SP+5] ; considers rightmost bit of E
    MOV [SP+2],C

    ; shift GH left

    MOV C,[SP+4]
    SHL C,1
    MOV [SP+4],C

    MOV C,[SP+3]
    JNC no_carry

    SHL C,1
    ADD C,1
    JMP continue

    no_carry:
    SHL C,1

    continue:
    MOV [SP+3],C
    JMP division_loop

    exit: ; AB is remainder, load CD with result
    MOV C,[SP+3]
    MOV D,[SP+4]

    ADD SP,5 ; throw auxiliary "registers" away
    HLT

Diese Web-Seite verwendet die folgenden Drittanbieter-Bibliotheken oder -Materialien bzw. StackOverflow-Antworten:

Der Autor dankt den Entwicklern und Autoren der genannten Beiträge für ihre Mühe und die Bereitschaft, ihre Werke der Allgemeinheit zur Verfügung zu stellen.