DIY Bytecode Interpreter


Virtuelle Maschinen von Programmiersprachen sind in den letzten Jahrzehnten sehr verbreitet geworden. Seit der Präsentation von Java Virtual Machine in der zweiten Hälfte der 90er Jahre ist ziemlich viel Zeit vergangen, und man kann mit Sicherheit sagen, dass Bytecode-Interpreter nicht die Zukunft, sondern die Gegenwart sind.


Aber diese Technik ist meiner Meinung nach fast universell und das Verständnis der Grundprinzipien der Dolmetscherentwicklung ist nicht nur für den Schöpfer des nächsten Herausforderers für den Titel "Sprache des Jahres" nach TIOBE nützlich, sondern für jeden Programmierer im Allgemeinen.


Mit einem Wort, wenn Sie erfahren möchten, wie unsere bevorzugten Programmiersprachen Zahlen addieren, worüber Entwickler virtueller Maschinen immer noch streiten und wie Zeichenfolgen und reguläre Ausdrücke schmerzlos abgeglichen werden können, frage ich nach cat.


Teil eins, Einführung (aktuell)
Zweiter Teil, Optimierung
Dritter Teil, Angewandt


Hintergrund


Eines der selbstgeschriebenen Systeme der Business Intelligence-Abteilung unseres Unternehmens verfügt über eine Schnittstelle in Form einer einfachen Abfragesprache. In der ersten Version des Systems wurde diese Sprache im laufenden Betrieb ohne Kompilierung direkt aus der Eingabezeile mit der Anforderung interpretiert. Die zweite Version des Parsers funktioniert bereits mit Zwischenbytecode, wodurch Sie die Abfragesprache von ihrer Ausführung trennen und den Code erheblich vereinfachen können.


Während ich an der zweiten Version des Systems arbeitete, hatte ich einen Urlaub, in dem ich jeden Tag ein oder zwei Stunden lang von Familienangelegenheiten abgelenkt wurde, um Materialien über die Architektur und Leistung von Bytecode-Dolmetschern zu studieren. Ich beschloss, die daraus resultierenden Notizen und Beispiele von Dolmetschern als eine Reihe von Artikeln mit Habrs Lesern zu teilen.


Die erste von ihnen präsentiert fünf kleine (bis zu Hunderte Zeilen einfachen C-Code) virtuelle Maschinen, von denen jede einen bestimmten Aspekt der Entwicklung solcher Interpreter enthüllt.


Wohin gingen die Bytecodes in Programmiersprachen?


Es wurden sehr viele virtuelle Maschinen erfunden, die verschiedensten virtuellen Befehlssätze der letzten Jahrzehnte. Wikipedia behauptet, dass die ersten Programmiersprachen bereits in den 60er Jahren des letzten Jahrhunderts zu verschiedenen vereinfachten Zwischendarstellungen kompiliert wurden. Einige dieser ersten Bytecodes wurden in Maschinencodes konvertiert und von realen Prozessoren ausgeführt, während andere von virtuellen Prozessoren im laufenden Betrieb interpretiert wurden.


Die Popularität virtueller Befehlssätze als Zwischendarstellung von Code hat drei Gründe:


  1. Bytecode-Programme können problemlos auf neue Plattformen portiert werden.
  2. Bytecode-Interpreter sind schneller als Interpreter des Syntaxbaums des Codes.
  3. Sie können eine einfache virtuelle Maschine in nur wenigen Stunden entwickeln.

Lassen Sie uns einige einfache virtuelle C-Maschinen erstellen und anhand dieser Beispiele die wichtigsten technischen Aspekte der Implementierung virtueller Maschinen hervorheben.


Vollständige Beispielcodes sind auf GitHub verfügbar. Beispiele können mit jedem relativ frischen GCC zusammengestellt werden:


gcc interpreter-basic-switch.c -o interpreter ./interpreter 

Alle Beispiele haben die gleiche Struktur: Zuerst kommt der Code der virtuellen Maschine selbst, dann die Hauptfunktion mit Zusicherungen, die die Funktionsweise des Codes überprüfen. Ich habe versucht, die Opcodes und Schlüsselstellen der Dolmetscher klar zu kommentieren. Ich hoffe, dass dieser Artikel auch für Leute verständlich ist, die nicht täglich in C schreiben.


Der weltweit einfachste Bytecode-Interpreter


Wie gesagt, der einfachste Dolmetscher ist sehr einfach zu erstellen. Kommentare befinden sich direkt hinter der Auflistung, aber beginnen wir direkt mit dem Code:


 struct { uint8_t *ip; uint64_t accumulator; } vm; typedef enum { /* increment the register */ OP_INC, /* decrement the register */ OP_DEC, /* stop execution */ OP_DONE } opcode; typedef enum interpret_result { SUCCESS, ERROR_UNKNOWN_OPCODE, } interpret_result; void vm_reset(void) { puts("Reset vm state"); vm = (typeof(vm)) { NULL }; } interpret_result vm_interpret(uint8_t *bytecode) { vm_reset(); puts("Start interpreting"); vm.ip = bytecode; for (;;) { uint8_t instruction = *vm.ip++; switch (instruction) { case OP_INC: { vm.accumulator++; break; } case OP_DEC: { vm.accumulator--; break; } case OP_DONE: { return SUCCESS; } default: return ERROR_UNKNOWN_OPCODE; } } return SUCCESS; } 

Es gibt weniger als hundert Zeilen, aber alle charakteristischen Attribute einer virtuellen Maschine werden dargestellt. Die Maschine hat ein einzelnes Register ( vm.accumulator ), drei Operationen (Registerinkrement, Registerdekrement und Abschluss der Programmausführung) und einen Zeiger auf den aktuellen Befehl ( vm.ip ).


Jede Operation (dt. Operationscode oder Opcode ) wird mit einem Byte codiert, und die Planung wird unter Verwendung des üblichen switch in der Funktion vm_interpret . Die Zweige im switch enthalten die Logik der Operationen, dh sie ändern den Status des Registers oder beenden die Ausführung des Programms.


Die Operationen werden als Array von Bytes - ein Bytecode (englischer Bytecode ) - an die Funktion vm_interpret übertragen und nacheinander ausgeführt, bis die Operation zum OP_DONE virtuellen Maschine ( OP_DONE ) OP_DONE .


Ein Schlüsselaspekt einer virtuellen Maschine ist die Semantik, dh die Menge der Operationen, die auf ihr möglich sind. In diesem Fall gibt es nur zwei Operationen, die den Wert eines einzelnen Registers ändern.


Einige Forscher ( Abstraktions- und Optimierungstechniken für virtuelle Maschinen, 2009) schlagen vor, virtuelle Maschinen entsprechend der Nähe der Semantik der virtuellen Maschine zur Semantik der physischen Maschine, auf der der Bytecode ausgeführt wird, in übergeordnete und untergeordnete Ebenen zu unterteilen .


Im Extremfall kann der Bytecode von virtuellen Maschinen auf niedriger Ebene den Maschinencode der physischen Maschine mit simuliertem RAM, einem vollständigen Satz von Registern, Anweisungen zum Arbeiten mit dem Stapel usw. vollständig wiederholen. Die virtuelle Maschine von Bochs wiederholt beispielsweise den Befehlssatz der x86-Architektur.


Und umgekehrt: Der Betrieb von virtuellen Maschinen auf hoher Ebene spiegelt die Semantik einer speziellen Programmiersprache wider, die in Bytecode kompiliert wurde. Arbeiten Sie beispielsweise mit SQLite, Gawk und zahlreichen Versionen von Prolog.


Zwischenpositionen werden von Dolmetschern für allgemeine Programmiersprachen mit Elementen sowohl auf hohem als auch auf niedrigem Niveau besetzt. Die beliebteste Java Virtual Machine verfügt sowohl über einfache Anweisungen zum Arbeiten mit dem Stack als auch über eine integrierte Unterstützung für die objektorientierte Programmierung mit automatischer Speicherzuweisung.


Der obige Code ist eher die primitivste virtuelle Maschine auf niedriger Ebene: Jeder virtuelle Befehl ist ein Wrapper über einen oder zwei physische Befehle, das virtuelle Register stimmt vollständig mit einem Register des "Eisen" -Prozessors überein.


Bytecode-Anweisungsargumente


Wir können sagen, dass das einzige Register in unserem Beispiel für eine virtuelle Maschine sowohl ein Argument als auch der Rückgabewert aller ausgeführten Anweisungen ist. Es kann jedoch nützlich sein, Argumente in Anweisungen zu übergeben. Eine Möglichkeit besteht darin, sie direkt in den Bytecode einzufügen.


Wir werden das Beispiel erweitern, indem wir Anweisungen (OP_ADDI, OP_SUBI) einführen, die ein Argument in Form eines Bytes unmittelbar nach dem Opcode annehmen:


 struct { uint8_t *ip; uint64_t accumulator; } vm; typedef enum { /* increment the register */ OP_INC, /* decrement the register */ OP_DEC, /* add the immediate argument to the register */ OP_ADDI, /* subtract the immediate argument from the register */ OP_SUBI, /* stop execution */ OP_DONE } opcode; typedef enum interpret_result { SUCCESS, ERROR_UNKNOWN_OPCODE, } interpret_result; void vm_reset(void) { puts("Reset vm state"); vm = (typeof(vm)) { NULL }; } interpret_result vm_interpret(uint8_t *bytecode) { vm_reset(); puts("Start interpreting"); vm.ip = bytecode; for (;;) { uint8_t instruction = *vm.ip++; switch (instruction) { case OP_INC: { vm.accumulator++; break; } case OP_DEC: { vm.accumulator--; break; } case OP_ADDI: { /* get the argument */ uint8_t arg = *vm.ip++; vm.accumulator += arg; break; } case OP_SUBI: { /* get the argument */ uint8_t arg = *vm.ip++; vm.accumulator -= arg; break; } case OP_DONE: { return SUCCESS; } default: return ERROR_UNKNOWN_OPCODE; } } return SUCCESS; } 

Neue Anweisungen (siehe die Funktion vm_interpret ) lesen ihr Argument aus dem Bytecode und fügen es dem Register hinzu / subtrahieren es vom Register.


Ein solches Argument wird als sofortiges Argument bezeichnet , da es sich direkt im Opcode-Array befindet. Die Hauptbeschränkung in unserer Implementierung besteht darin, dass das Argument ein einzelnes Byte ist und nur 256 Werte annehmen kann.


In unserer virtuellen Maschine spielt der Bereich möglicher Befehlsargumentwerte keine große Rolle. Wenn die virtuelle Maschine jedoch als Interpreter der realen Sprache verwendet wird, ist es sinnvoll, den Bytecode zu komplizieren, indem eine vom Array der Opcodes und Anweisungen getrennte Konstantentabelle mit einem direkten Argument hinzugefügt wird, das der Adresse des realen Arguments in der Konstantentabelle entspricht.


Stapelmaschine


Anweisungen in unserer einfachen virtuellen Maschine arbeiten immer mit einem Register und können in keiner Weise Daten untereinander übertragen. Außerdem kann das Argument für die Anweisung nur unmittelbar sein, und die Additions- oder Multiplikationsoperation benötigt beispielsweise zwei Argumente.


Einfach ausgedrückt, wir haben keine Möglichkeit, komplexe Ausdrücke zu bewerten. Um dieses Problem zu lösen, wird eine gestapelte Maschine benötigt, dh eine virtuelle Maschine mit einem integrierten Stapel:


 #define STACK_MAX 256 struct { uint8_t *ip; /* Fixed-size stack */ uint64_t stack[STACK_MAX]; uint64_t *stack_top; /* A single register containing the result */ uint64_t result; } vm; typedef enum { /* push the immediate argument onto the stack */ OP_PUSHI, /* pop 2 values from the stack, add and push the result onto the stack */ OP_ADD, /* pop 2 values from the stack, subtract and push the result onto the stack */ OP_SUB, /* pop 2 values from the stack, divide and push the result onto the stack */ OP_DIV, /* pop 2 values from the stack, multiply and push the result onto the stack */ OP_MUL, /* pop the top of the stack and set it as execution result */ OP_POP_RES, /* stop execution */ OP_DONE, } opcode; typedef enum interpret_result { SUCCESS, ERROR_DIVISION_BY_ZERO, ERROR_UNKNOWN_OPCODE, } interpret_result; void vm_reset(void) { puts("Reset vm state"); vm = (typeof(vm)) { NULL }; vm.stack_top = vm.stack; } void vm_stack_push(uint64_t value) { *vm.stack_top = value; vm.stack_top++; } uint64_t vm_stack_pop(void) { vm.stack_top--; return *vm.stack_top; } interpret_result vm_interpret(uint8_t *bytecode) { vm_reset(); puts("Start interpreting"); vm.ip = bytecode; for (;;) { uint8_t instruction = *vm.ip++; switch (instruction) { case OP_PUSHI: { /* get the argument, push it onto stack */ uint8_t arg = *vm.ip++; vm_stack_push(arg); break; } case OP_ADD: { /* Pop 2 values, add 'em, push the result back to the stack */ uint64_t arg_right = vm_stack_pop(); uint64_t arg_left = vm_stack_pop(); uint64_t res = arg_left + arg_right; vm_stack_push(res); break; } case OP_SUB: { /* Pop 2 values, subtract 'em, push the result back to the stack */ uint64_t arg_right = vm_stack_pop(); uint64_t arg_left = vm_stack_pop(); uint64_t res = arg_left - arg_right; vm_stack_push(res); break; } case OP_DIV: { /* Pop 2 values, divide 'em, push the result back to the stack */ uint64_t arg_right = vm_stack_pop(); /* Don't forget to handle the div by zero error */ if (arg_right == 0) return ERROR_DIVISION_BY_ZERO; uint64_t arg_left = vm_stack_pop(); uint64_t res = arg_left / arg_right; vm_stack_push(res); break; } case OP_MUL: { /* Pop 2 values, multiply 'em, push the result back to the stack */ uint64_t arg_right = vm_stack_pop(); uint64_t arg_left = vm_stack_pop(); uint64_t res = arg_left * arg_right; vm_stack_push(res); break; } case OP_POP_RES: { /* Pop the top of the stack, set it as a result value */ uint64_t res = vm_stack_pop(); vm.result = res; break; } case OP_DONE: { return SUCCESS; } default: return ERROR_UNKNOWN_OPCODE; } } return SUCCESS; } 

In diesem Beispiel gibt es mehr Operationen, und fast alle funktionieren nur mit dem Stapel. OP_PUSHI schiebt sein unmittelbares Argument auf den Stapel. Die Anweisungen OP_ADD, OP_SUB, OP_DIV, OP_MUL werden aus einem Wertestapel entfernt, das Ergebnis berechnet und auf den Stapel zurückgeschoben. OP_POP_RES entfernt den Wert aus dem Stapel und legt ihn im Ergebnisregister ab, das für die Ergebnisse der virtuellen Maschine vorgesehen ist.


Für die Divisionsoperation (OP_DIV) wird ein Fehler durch Division durch Null abgefangen, der die virtuelle Maschine stoppt.


Die Fähigkeiten einer solchen Maschine sind viel breiter als die der vorherigen mit einem einzigen Register und ermöglichen beispielsweise die Berechnung komplexer arithmetischer Ausdrücke. Ein weiterer (und wichtiger!) Vorteil ist die einfache Kompilierung von Programmiersprachen in den Bytecode der Stapelmaschine.


Maschine registrieren


Aufgrund ihrer Einfachheit werden gestapelte virtuelle Maschinen unter Entwicklern von Programmiersprachen am häufigsten verwendet. Dieselben JVMs und Python-VMs verwenden genau diese.


Solche Maschinen haben jedoch Nachteile: Sie müssen spezielle Anweisungen für die Arbeit mit dem Stapel hinzufügen. Bei der Berechnung von Ausdrücken durchlaufen alle Argumente wiederholt eine einzelne Datenstruktur. Im Stapelcode werden unweigerlich viele zusätzliche Anweisungen angezeigt.


In der Zwischenzeit verursacht die Ausführung jedes zusätzlichen Befehls die Kosten für die Planung, dh das Decodieren des Opcodes und das Umschalten auf den Hauptteil der Befehle.


Eine Alternative zu gestapelten Maschinen ist das Registrieren virtueller Maschinen. Sie haben einen komplexeren Bytecode: Die Anzahl der Registerargumente und die Nummer des Registerergebnisses werden in jedem Befehl explizit codiert. Dementsprechend wird anstelle eines Stapels ein erweiterter Satz von Registern als Speicher für Zwischenwerte verwendet.


 #define REGISTER_NUM 16 struct { uint16_t *ip; /* Register array */ uint64_t reg[REGISTER_NUM]; /* A single register containing the result */ uint64_t result; } vm; typedef enum { /* Load an immediate value into r0 */ OP_LOADI, /* Add values in r0,r1 registers and put them into r2 */ OP_ADD, /* Subtract values in r0,r1 registers and put them into r2 */ OP_SUB, /* Divide values in r0,r1 registers and put them into r2 */ OP_DIV, /* Multiply values in r0,r1 registers and put them into r2 */ OP_MUL, /* Move a value from r0 register into the result register */ OP_MOV_RES, /* stop execution */ OP_DONE, } opcode; typedef enum interpret_result { SUCCESS, ERROR_DIVISION_BY_ZERO, ERROR_UNKNOWN_OPCODE, } interpret_result; void vm_reset(void) { puts("Reset vm state"); vm = (typeof(vm)) { NULL }; } void decode(uint16_t instruction, uint8_t *op, uint8_t *reg0, uint8_t *reg1, uint8_t *reg2, uint8_t *imm) { *op = (instruction & 0xF000) >> 12; *reg0 = (instruction & 0x0F00) >> 8; *reg1 = (instruction & 0x00F0) >> 4; *reg2 = (instruction & 0x000F); *imm = (instruction & 0x00FF); } interpret_result vm_interpret(uint16_t *bytecode) { vm_reset(); puts("Start interpreting"); vm.ip = bytecode; uint8_t op, r0, r1, r2, immediate; for (;;) { /* fetch the instruction */ uint16_t instruction = *vm.ip++; /* decode it */ decode(instruction, &op, &r0, &r1, &r2, &immediate); /* dispatch */ switch (op) { case OP_LOADI: { vm.reg[r0] = immediate; break; } case OP_ADD: { vm.reg[r2] = vm.reg[r0] + vm.reg[r1]; break; } case OP_SUB: { vm.reg[r2] = vm.reg[r0] - vm.reg[r1]; break; } case OP_DIV: { /* Don't forget to handle the div by zero error */ if (vm.reg[r1] == 0) return ERROR_DIVISION_BY_ZERO; vm.reg[r2] = vm.reg[r0] / vm.reg[r1]; break; } case OP_MUL: { vm.reg[r2] = vm.reg[r0] * vm.reg[r1]; break; } case OP_MOV_RES: { vm.result = vm.reg[r0]; break; } case OP_DONE: { return SUCCESS; } default: return ERROR_UNKNOWN_OPCODE; } } return SUCCESS; } 

Das Beispiel zeigt eine Registermaschine mit 16 Registern. Anweisungen belegen jeweils 16 Bit und werden auf drei Arten codiert:


  1. 4 Bit pro Opcode + 4 Bit pro Registername + 8 Bit pro Argument.
  2. 4 Bits pro Opcode + dreimal 4 Bits pro Registernamen.
  3. 4 Bits pro Opcode + 4 Bits pro Einzelregistername + 8 nicht verwendete Bits.

Unsere kleine virtuelle Maschine hat nur sehr wenige Operationen, sodass vier Bits (oder 16 mögliche Operationen) pro Opcode völlig ausreichen. Die Operation bestimmt, was genau die verbleibenden Bits des Befehls darstellen.


Die erste Art der Codierung (4 + 4 + 8) wird benötigt, um Daten mit der Operation OP_LOADI in Register zu laden. Der zweite Typ (4 + 4 + 4 + 4) wird für arithmetische Operationen verwendet, die wissen sollten, wo ein Argumentpaar verwendet und wo das Berechnungsergebnis hinzugefügt werden muss. Und schließlich wird die letzte Form (4 + 4 + 8 unnötige Bits) für Anweisungen mit einem einzelnen Register als Argument verwendet, in unserem Fall OP_MOV_RES.


Zum Codieren und Decodieren von Anweisungen benötigen wir jetzt eine spezielle Logik ( decode ). Andererseits wird die Logik von Anweisungen dank der expliziten Angabe der Position der Argumente einfacher - Operationen mit dem Stapel verschwinden.


Hauptmerkmale: Im Bytecode von Registermaschinen gibt es weniger Anweisungen, einzelne Anweisungen sind breiter, das Kompilieren in einen solchen Bytecode ist schwieriger - der Compiler muss entscheiden, wie die verfügbaren Register verwendet werden sollen.


Es ist zu beachten, dass es in der Praxis in virtuellen Registermaschinen normalerweise einen Stapel gibt, in dem beispielsweise Funktionsargumente platziert werden. Register werden verwendet, um einzelne Ausdrücke zu berechnen. Selbst wenn es keinen expliziten Stapel gibt, wird ein Array zum Erstellen des Stapels verwendet, das dieselbe Rolle spielt wie RAM in physischen Maschinen.


Maschinen stapeln und registrieren, Vergleich


Es gibt eine interessante Studie ( Showdown für virtuelle Maschinen: Stapel versus Register , 2008), die einen großen Einfluss auf alle nachfolgenden Entwicklungen auf dem Gebiet der virtuellen Maschinen für Programmiersprachen hatte. Die Autoren haben eine Methode zur direkten Übersetzung vom Stapelcode einer Standard-JVM in einen Registercode vorgeschlagen und die Leistung verglichen.


Die Methode ist nicht trivial: Der Code wird zuerst übersetzt und dann auf ziemlich komplizierte Weise optimiert. Ein anschließender Vergleich der Leistung desselben Programms zeigte jedoch, dass die zusätzlichen Prozessorzyklen, die für das Decodieren von Befehlen aufgewendet werden, durch eine Verringerung der Gesamtzahl von Befehlen vollständig kompensiert werden. Kurz gesagt, im Allgemeinen war die Registermaschine effizienter als die Stapelmaschine.


Wie bereits oben erwähnt, hat diese Effizienz einen durchaus greifbaren Preis: Der Compiler muss die Register selbst zuordnen, und ein erweiterter Optimierer ist zusätzlich wünschenswert.


Die Debatte darüber, welche Architektur besser ist, ist noch nicht vorbei. Wenn wir über Java-Compiler sprechen, wurde der Dalvik VM-Bytecode registriert, der bis vor kurzem auf jedem Android-Gerät funktioniert hat. Der Titel JVM hat jedoch einen Stapel von Anweisungen beibehalten. Die virtuelle Lua-Maschine verwendet eine Registermaschine, die Python-VM ist jedoch weiterhin stapelbar. Usw.


Bytecode in Interpreten für reguläre Ausdrücke


Um uns von virtuellen Maschinen auf niedriger Ebene abzulenken, schauen wir uns einen speziellen Interpreter an, der Zeichenfolgen auf Übereinstimmung mit regulären Ausdrücken überprüft:


 typedef enum { /* match a single char to an immediate argument from the string and advance ip and cp, or * abort*/ OP_CHAR, /* jump to and match either left expression or the right one, abort if nothing matches*/ OP_OR, /* do an absolute jump to an offset in the immediate argument */ OP_JUMP, /* stop execution and report a successful match */ OP_MATCH, } opcode; typedef enum match_result { MATCH_OK, MATCH_FAIL, MATCH_ERROR, } match_result; match_result vm_match_recur(uint8_t *bytecode, uint8_t *ip, char *sp) { for (;;) { uint8_t instruction = *ip++; switch (instruction) { case OP_CHAR:{ char cur_c = *sp; char arg_c = (char)*ip ; /* no match? FAILed to match */ if (arg_c != cur_c) return MATCH_FAIL; /* advance both current instruction and character pointers */ ip++; sp++; continue; } case OP_JUMP:{ /* read the offset and jump to the instruction */ uint8_t offset = *ip; ip = bytecode + offset; continue; } case OP_OR:{ /* get both branch offsets */ uint8_t left_offset = *ip++; uint8_t right_offset = *ip; /* check if following the first offset get a match */ uint8_t *left_ip = bytecode + left_offset; if (vm_match_recur(bytecode, left_ip, sp) == MATCH_OK) return MATCH_OK; /* no match? Check the second branch */ ip = bytecode + right_offset; continue; } case OP_MATCH:{ /* success */ return MATCH_OK; } } return MATCH_ERROR; } } match_result vm_match(uint8_t *bytecode, char *str) { printf("Start matching a string: %s\n", str); return vm_match_recur(bytecode, bytecode, str); } 

Die Hauptanweisung ist OP_CHAR. Sie nimmt ihr unmittelbares Argument und vergleicht es mit dem aktuellen Zeichen in der Zeichenfolge ( char *sp ). Bei Übereinstimmung der erwarteten und aktuellen Zeichen in der Zeile erfolgt der Übergang zum nächsten Befehl und zum nächsten Zeichen.


Die Maschine versteht auch die Sprungoperation (OP_JUMP), die ein einzelnes sofortiges Argument akzeptiert. Das Argument bedeutet den absoluten Versatz im Bytecode, von dem aus die Berechnung fortgesetzt werden soll.


Die letzte wichtige Operation ist OP_OR. Sie nimmt zwei Offsets und versucht, den Code zuerst auf den ersten und im Fehlerfall auf den zweiten anzuwenden. Sie tut dies mit einem rekursiven Aufruf, dh die Anweisung macht einen Spaziergang in die Tiefe des Baumes aller möglichen Varianten des regulären Ausdrucks.


Überraschenderweise reichen vier Opcodes und siebzig Codezeilen aus, um reguläre Ausdrücke wie "abc", "a? Bc", "(ab | bc) d", "a * bc" auszudrücken. Diese virtuelle Maschine hat nicht einmal einen expliziten Status, da alles, was Sie benötigen - Zeiger auf den Anfang des Befehlsstroms, den aktuellen Befehl und das aktuelle Zeichen - als Argumente an die rekursive Funktion übergeben wird.


Wenn Sie an den Details der Arbeit von Engines-Engines interessiert sind, können Sie zunächst eine Reihe von Artikeln von Russ Cox lesen, dem Autor der Reglement- Engines von Google RE2 .


Zusammenfassung


Fassen wir zusammen.


Für allgemeine Programmiersprachen werden in der Regel zwei Architekturen verwendet: Stack und Register.


Im Stapelmodell ist der Stapel die Hauptdatenstruktur und Methode zum Übergeben von Argumenten zwischen Anweisungen. Im Registermodell wird ein Satz von Registern zum Berechnen von Ausdrücken verwendet, aber ein expliziter oder impliziter Stapel wird weiterhin zum Speichern von Funktionsargumenten verwendet.


Das Vorhandensein eines expliziten Stapels und eines Satzes von Registern bringt solche Maschinen näher an niedrige und sogar physische. Die Fülle von Anweisungen auf niedriger Ebene in einem solchen Bytecode bedeutet, dass ein erheblicher Ressourcenaufwand des physischen Prozessors auf die Decodierung und Planung virtueller Anweisungen entfällt.


Auf der anderen Seite spielen Anweisungen auf hoher Ebene in beliebten virtuellen Maschinen eine große Rolle. In Java sind dies beispielsweise Anweisungen für polymorphe Funktionsaufrufe, Objektzuweisung und Speicherbereinigung.


Rein übergeordnete virtuelle Maschinen - zum Beispiel Sprachbytecode-Interpreter mit entwickelter Semantik und weit entfernt von Eisen - verbringen die meiste Zeit nicht im Dispatcher oder Decoder, sondern in den Anweisungen und sind dementsprechend relativ effizient.


Praktische Empfehlungen:


  1. Wenn Sie einen Bytecode ausführen müssen und dies in angemessener Zeit tun müssen, versuchen Sie, mit Anweisungen zu arbeiten, die Ihrer Aufgabe am nächsten kommen. Je höher die semantische Ebene, desto besser. Dies reduziert die Planungskosten und vereinfacht die Codegenerierung.
  2. Wenn Sie mehr Flexibilität und heterogene Semantik benötigen, sollten Sie zumindest versuchen, den gemeinsamen Nenner im Bytecode hervorzuheben, damit die resultierenden Anweisungen auf einem bedingt durchschnittlichen Niveau liegen.
  3. Wenn es in Zukunft erforderlich sein kann, Ausdrücke zu berechnen und eine gestapelte Maschine zu erstellen, verringert dies die Kopfschmerzen beim Kompilieren von Bytecode.
  4. Wenn keine Ausdrücke erwartet werden, erstellen Sie eine einfache Registermaschine, die die Kosten des Stapels vermeidet und die Anweisungen selbst vereinfacht.

In den folgenden Artikeln werde ich praktische Implementierungen von virtuellen Maschinen in gängigen Programmiersprachen diskutieren und erklären, warum die Business Intelligence Badoo-Abteilung einen Bytecode benötigte.

Source: https://habr.com/ru/post/de425325/


All Articles