Intérpretes de bytecode de bricolaje


Las máquinas virtuales de lenguajes de programación se han generalizado en las últimas décadas. Ha pasado mucho tiempo desde la presentación de Java Virtual Machine en la segunda mitad de los 90, y es seguro decir que los intérpretes de código de bytes no son el futuro, sino el presente.


Pero esta técnica, en mi opinión, es casi universal, y comprender los principios básicos del desarrollo del intérprete es útil no solo para el creador del próximo retador para el título "Idioma del año" según TIOBE , sino para cualquier programador en general.


En una palabra, si está interesado en aprender cómo nuestros lenguajes de programación favoritos suman números, sobre qué discuten todavía los desarrolladores de máquinas virtuales y cómo combinar cadenas y expresiones regulares sin problemas, le pido un gato.


Primera parte, introductoria (actual)
Segunda parte, optimización
Tercera parte, aplicada


Antecedentes


Uno de los sistemas autoescritos del departamento de Business Intelligence de nuestra empresa tiene una interfaz en forma de lenguaje de consulta simple. En la primera versión del sistema, este lenguaje fue interpretado sobre la marcha, sin compilación, directamente desde la línea de entrada con la solicitud. La segunda versión del analizador ya funcionará con bytecode intermedio, lo que le permitirá separar el lenguaje de consulta de su ejecución y simplificar en gran medida el código.


En el proceso de trabajar en la segunda versión del sistema, tuve unas vacaciones durante las cuales durante una o dos horas todos los días me distraía de los asuntos familiares para estudiar materiales sobre la arquitectura y el desempeño de los intérpretes de bytecode. Decidí compartir las notas resultantes y ejemplos de intérpretes con los lectores de Habr como una serie de artículos.


El primero de ellos presenta cinco máquinas virtuales pequeñas (hasta cientos de líneas de código C simple), cada una de las cuales revela un cierto aspecto del desarrollo de tales intérpretes.


¿A dónde fueron los códigos de bytes en los lenguajes de programación?


Se han inventado una gran cantidad de máquinas virtuales, los conjuntos de instrucciones virtuales más diversos en las últimas décadas. Wikipedia afirma que los primeros lenguajes de programación comenzaron a compilarse en varias representaciones intermedias simplificadas en los años 60 del siglo pasado. Algunos de estos primeros códigos de bytes se convirtieron en códigos de máquina y se ejecutaron mediante procesadores reales, mientras que otros fueron interpretados sobre la marcha por procesadores virtuales.


La popularidad de los conjuntos de instrucciones virtuales como una representación intermedia del código se debe a tres razones:


  1. Los programas de código de bytes se portan fácilmente a nuevas plataformas.
  2. Los intérpretes de código de bytes son más rápidos que los intérpretes del árbol de código de sintaxis.
  3. Puede desarrollar una máquina virtual simple en solo un par de horas.

Hagamos algunas máquinas virtuales C simples y usemos estos ejemplos para resaltar los principales aspectos técnicos de la implementación de máquinas virtuales.


Los códigos de muestra completos están disponibles en GitHub . Se pueden compilar ejemplos con cualquier GCC relativamente nuevo:


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

Todos los ejemplos tienen la misma estructura: primero viene el código de la máquina virtual en sí, luego la función principal con aserciones que verifican el funcionamiento del código. Traté de comentar claramente los códigos de operación y los lugares clave de los intérpretes. Espero que este artículo sea comprensible incluso para las personas que no escriben en C a diario.


El intérprete de bytecode más fácil del mundo.


Como dije, el intérprete más simple es muy fácil de hacer. Los comentarios están justo detrás de la lista, pero comencemos directamente con el código:


 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; } 

Hay menos de cien líneas, pero todos los atributos característicos de una máquina virtual están representados. La máquina tiene un único registro ( vm.accumulator ), tres operaciones (incremento del registro, disminución del registro y finalización de la ejecución del programa) y un puntero a la instrucción actual ( vm.ip ).


Cada operación (ing. Código de operación o código de operación ) se codifica con un byte, y la programación se realiza utilizando el switch habitual en la función vm_interpret . Las ramas en el switch contienen la lógica de las operaciones, es decir, cambian el estado del registro o completan la ejecución del programa.


Las operaciones se transfieren a la función vm_interpret en forma de una matriz de bytes, un bytecode (Eng. Bytecode ), y se ejecutan secuencialmente hasta que se encuentra la operación de OP_DONE máquina virtual ( OP_DONE ) OP_DONE .


Un aspecto clave de una máquina virtual es la semántica, es decir, el conjunto de operaciones que son posibles en ella. En este caso, solo hay dos operaciones y cambian el valor de un único registro.


Algunos investigadores ( Técnicas de abstracción y optimización de máquinas virtuales, 2009) proponen dividir las máquinas virtuales en máquinas de alto y bajo nivel de acuerdo con la proximidad de la semántica de la máquina virtual a la semántica de la máquina física en la que se ejecutará el código de bytes.


En el caso extremo, el código de bytes de las máquinas virtuales de bajo nivel puede repetir completamente el código de la máquina física con RAM simulada, un conjunto completo de registros, instrucciones para trabajar con la pila, etc. La máquina virtual de Bochs , por ejemplo, repite el conjunto de instrucciones de arquitectura x86.


Y viceversa: las operaciones de máquinas virtuales de alto nivel reflejan de cerca la semántica de un lenguaje de programación especializado compilado en bytecode. Así que trabaje, por ejemplo, SQLite, Gawk y numerosas versiones de Prolog.


Los puestos intermedios están ocupados por intérpretes de lenguajes de programación de propósito general que tienen elementos de niveles altos y bajos. La máquina virtual Java más popular tiene instrucciones de bajo nivel para trabajar con la pila y soporte incorporado para programación orientada a objetos con asignación automática de memoria.


Es más probable que el código anterior sea el más primitivo de las máquinas virtuales de bajo nivel: cada instrucción virtual es una envoltura sobre una o dos instrucciones físicas, el registro virtual es totalmente coherente con un registro del procesador "de hierro".


Argumentos de instrucciones de código de bytes


Podemos decir que el único registro en nuestro ejemplo de máquina virtual es tanto un argumento como el valor de retorno de todas las instrucciones ejecutadas. Sin embargo, podemos encontrar útil pasar argumentos en las instrucciones. Una forma es ponerlos directamente en bytecode.


Expandiremos el ejemplo introduciendo instrucciones (OP_ADDI, OP_SUBI) que toman un argumento en forma de un byte inmediatamente después del código de operación:


 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; } 

Las nuevas instrucciones (vea la función vm_interpret ) leen su argumento del vm_interpret y lo agregan al registro / lo restan del registro.


Tal argumento se llama argumento inmediato , porque está ubicado directamente en la matriz de código de operación. La principal limitación en nuestra implementación es que el argumento es un solo byte y solo puede tomar 256 valores.


En nuestra máquina virtual, el rango de posibles valores de argumentos de instrucción no juega un papel importante. Pero si la máquina virtual se utilizará como intérprete del lenguaje real, entonces tiene sentido complicar el código de bytes agregando una tabla de constantes separada de la matriz de códigos de operación e instrucciones con un argumento directo correspondiente a la dirección de este argumento en la tabla de constantes.


Máquina apiladora


Las instrucciones en nuestra máquina virtual simple siempre funcionan con un registro y no pueden transmitirse datos entre sí de ninguna manera. Además, el argumento de la instrucción solo puede ser inmediato y, por ejemplo, la operación de suma o multiplicación toma dos argumentos.


En pocas palabras, no tenemos forma de evaluar expresiones complejas. Para resolver este problema, se necesita una máquina apilada, es decir, una máquina virtual con una pila integrada:


 #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; } 

En este ejemplo, ya hay más operaciones, y casi todas funcionan solo con la pila. OP_PUSHI empuja su argumento inmediato a la pila. Las instrucciones OP_ADD, OP_SUB, OP_DIV, OP_MUL se extraen de una pila de valores, calculan el resultado y lo devuelven a la pila. OP_POP_RES elimina el valor de la pila y lo coloca en el registro de resultados, destinado a los resultados de la máquina virtual.


Para la operación de división (OP_DIV), se detecta un error de división por cero, que detiene la máquina virtual.


Las capacidades de una máquina de este tipo son mucho más amplias que la anterior con un solo registro y permiten, por ejemplo, calcular expresiones aritméticas complejas. Otra ventaja (¡e importante!) Es la simplicidad de compilar lenguajes de programación en el código de bytes de la máquina de pila.


Máquina de registro


Debido a su simplicidad, las máquinas virtuales apiladas son las más utilizadas entre los desarrolladores de lenguajes de programación; las mismas máquinas virtuales JVM y Python las usan exactamente.


Sin embargo, tales máquinas tienen inconvenientes: tienen que agregar instrucciones especiales para trabajar con la pila, al calcular expresiones, todos los argumentos pasan repetidamente a través de una sola estructura de datos, inevitablemente aparecerán muchas instrucciones adicionales en el código de la pila.


Mientras tanto, la ejecución de cada instrucción adicional implica el costo de la programación, es decir, decodificar el código de operación y cambiar al cuerpo de las instrucciones.


Una alternativa a las máquinas apiladas es registrar máquinas virtuales. Tienen un código de bytes más complejo: el número de argumentos de registro y el número del resultado de registro están codificados explícitamente en cada instrucción. En consecuencia, en lugar de una pila, se utiliza un conjunto extendido de registros como almacenamiento de valores intermedios.


 #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; } 

El ejemplo muestra una máquina de registro con 16 registros. Las instrucciones ocupan 16 bits cada una y se codifican de tres maneras:


  1. 4 bits por código de operación + 4 bits por nombre de registro + 8 bits por argumento.
  2. 4 bits por código de operación + tres veces 4 bits por nombre de registro.
  3. 4 bits por código de operación + 4 bits por nombre de registro único + 8 bits sin usar.

Nuestra pequeña máquina virtual tiene muy pocas operaciones, por lo que cuatro bits (o 16 operaciones posibles) por código de operación son suficientes. La operación determina qué representan exactamente los bits restantes de la instrucción.


Se necesita el primer tipo de codificación (4 + 4 + 8) para cargar datos en registros con la operación OP_LOADI. El segundo tipo (4 + 4 + 4 + 4) se usa para operaciones aritméticas, que deben saber dónde tomar un par de argumentos y dónde agregar el resultado del cálculo. Y finalmente, la última forma (4 + 4 + 8 bits innecesarios) se usa para instrucciones con un único registro como argumento, en nuestro caso es OP_MOV_RES.


Para codificar y decodificar instrucciones, ahora necesitamos una lógica especial (función de decode ). Por otro lado, la lógica de las instrucciones, gracias a la indicación explícita de la ubicación de los argumentos, se vuelve más fácil: las operaciones con la pila desaparecen.


Características clave: en el código de bytes de las máquinas de registro hay menos instrucciones, las instrucciones individuales son más amplias, la compilación en dicho código de bytes es más difícil: el compilador tiene que decidir cómo usar los registros disponibles.


Cabe señalar que, en la práctica, en las máquinas virtuales de registro, generalmente hay una pila donde, por ejemplo, se colocan argumentos de función; Los registros se utilizan para calcular expresiones individuales. Incluso si no hay una pila explícita, se usa una matriz para construir la pila, desempeñando el mismo papel que la RAM en las máquinas físicas.


Apilar y registrar máquinas, comparación


Hay un estudio interesante ( Showdown de máquinas virtuales: Stack versus registros , 2008) que ha tenido una gran influencia en todos los desarrollos posteriores en el campo de las máquinas virtuales para lenguajes de programación. Sus autores han propuesto un método de traducción directa del código de pila de una JVM estándar a un código de registro y compararon el rendimiento.


El método no es trivial: el código primero se traduce y luego se optimiza de una manera bastante complicada. Pero una comparación posterior del rendimiento del mismo programa mostró que los ciclos de procesador adicionales dedicados a las instrucciones de decodificación se compensan completamente con una disminución en el número total de instrucciones. En general, en resumen, la máquina de registro fue más eficiente que la pila.


Como ya se mencionó anteriormente, esta eficiencia tiene un precio bastante tangible: el compilador debe asignar los registros por sí mismo y un optimizador avanzado también es deseable.


El debate sobre qué arquitectura es mejor aún no ha terminado. Si hablamos de compiladores de Java, entonces se registró el bytecode Dalvik VM, que hasta hace poco funcionaba en todos los dispositivos Android. pero el título JVM ha retenido una pila de instrucciones. La máquina virtual Lua usa una máquina de registro, pero la máquina virtual Python todavía es apilable. Y así sucesivamente.


Bytecode en intérpretes de expresiones regulares


Finalmente, para distraernos de las máquinas virtuales de bajo nivel, echemos un vistazo a un intérprete especializado que verifica las cadenas para la coincidencia de expresiones regulares:


 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); } 

La instrucción principal es OP_CHAR. Ella toma su argumento inmediato y lo compara con el carácter actual en la cadena ( char *sp ). En caso de coincidencia de los caracteres esperados y actuales en la línea, se produce la transición a la siguiente instrucción y al siguiente carácter.


La máquina también comprende la operación de salto (OP_JUMP), que toma un solo argumento inmediato. El argumento significa el desplazamiento absoluto en el código de bytes, desde donde continuar el cálculo.


La última operación importante es OP_OR. Ella toma dos compensaciones, tratando de aplicar el código primero en el primero de ellos, luego, en caso de error, el segundo. Ella hace esto con una llamada recursiva, es decir, la instrucción hace un recorrido en la profundidad del árbol de todas las variantes posibles de la expresión regular.


Sorprendentemente, cuatro códigos de operación y setenta líneas de código son suficientes para expresar expresiones regulares como "abc", "a? Bc", "(ab | bc) d", "a * bc". Esta máquina virtual ni siquiera tiene un estado explícito, ya que todo lo que necesita (punteros al comienzo del flujo de instrucciones, la instrucción actual y el carácter actual) se pasa como argumentos a la función recursiva.


Si está interesado en los detalles del trabajo de los motores de expresión regular, primero puede leer una serie de artículos de Russ Cox, el autor del motor de expresión regular de Google RE2 .


Resumen


Resumamos


Para los lenguajes de programación de uso general, por regla general, se utilizan dos arquitecturas: apilar y registrar.


En el modelo de pila, la estructura de datos principal y el método de pasar argumentos entre instrucciones es la pila. En el modelo de registro, se usa un conjunto de registros para calcular expresiones, pero una pila explícita o implícita todavía se usa para almacenar argumentos de función.


La presencia de una pila explícita y un conjunto de registros acerca tales máquinas a las de bajo nivel e incluso físicas. La abundancia de instrucciones de bajo nivel en dicho código de bytes significa que un gasto significativo de recursos del procesador físico recae en la decodificación y programación de instrucciones virtuales.


Por otro lado, las instrucciones de alto nivel juegan un papel importante en las máquinas virtuales populares. En Java, por ejemplo, estas son instrucciones para llamadas a funciones polimórficas, asignación de objetos y recolección de basura.


Máquinas virtuales puramente de alto nivel, por ejemplo, intérpretes de códigos de bytes de idiomas con semántica desarrollada y lejos de ser de hierro, la mayor parte del tiempo se pasa no en el despachador o decodificador, sino en los cuerpos de instrucciones y, en consecuencia, son relativamente eficientes.


Recomendaciones prácticas


  1. Si necesita ejecutar cualquier código de bytes y hacerlo en un período de tiempo razonable, intente operar con las instrucciones más cercanas a su tarea; cuanto mayor sea el nivel semántico, mejor. Esto reducirá los costos de programación y simplificará la generación de código.
  2. Si necesita más flexibilidad y una semántica heterogénea, al menos debe intentar resaltar el denominador común en el código de bytes para que las instrucciones resultantes estén en un nivel condicionalmente promedio.
  3. Si en el futuro puede ser necesario calcular alguna expresión, haga una máquina apilada, esto reducirá el dolor de cabeza al compilar código de bytes.
  4. Si no se esperan expresiones, haga una máquina de registro trivial, lo que evitará el costo de la pila y simplificará las instrucciones.

En los siguientes artículos, analizaré implementaciones prácticas de máquinas virtuales en lenguajes de programación populares y explicaré por qué el departamento de Business Intelligence Badoo necesitaba un código de bytes.

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


All Articles