Intérpretes de Bytecode DIY


Máquinas virtuais de linguagens de programação se tornaram muito difundidas nas últimas décadas. Muito tempo se passou desde a apresentação da Java Virtual Machine na segunda metade dos anos 90, e é seguro dizer que os intérpretes de código de bytes não são o futuro, mas o presente.


Mas essa técnica, na minha opinião, é quase universal, e o entendimento dos princípios básicos do desenvolvimento de intérpretes é útil não apenas para o criador do próximo desafiante do título "Linguagem do Ano" de acordo com o TIOBE , mas para qualquer programador em geral.


Em uma palavra, se você estiver interessado em aprender como nossas linguagens de programação favoritas somam números, sobre o que os desenvolvedores de máquinas virtuais ainda estão discutindo e sobre como combinar strings e expressões regulares sem problemas, peço cat.


Parte um, introdutória (atual)
Parte Dois, Otimizando
Parte Três, Aplicada


Antecedentes


Um dos sistemas auto-escritos do departamento de Business Intelligence da nossa empresa possui uma interface na forma de uma linguagem de consulta simples. Na primeira versão do sistema, esse idioma foi interpretado em tempo real, sem compilação, diretamente da linha de entrada com a solicitação. A segunda versão do analisador já funcionará com bytecode intermediário, o que permitirá que você separe o idioma da consulta da execução deles e simplifique bastante o código.


No processo de trabalhar na segunda versão do sistema, tive férias durante as quais, durante uma ou duas horas todos os dias, me distraí dos assuntos da família para estudar materiais sobre a arquitetura e o desempenho dos intérpretes de bytecode. Decidi compartilhar as notas e exemplos de intérpretes resultantes com os leitores de Habr como uma série de artigos.


O primeiro deles apresenta cinco máquinas virtuais pequenas (até centenas de linhas de código C simples), cada uma das quais revela um certo aspecto do desenvolvimento de tais intérpretes.


Para onde foram os códigos de bytes nas linguagens de programação?


Muitas máquinas virtuais, os mais diversos conjuntos de instruções virtuais nas últimas décadas, foram inventados. A Wikipedia afirma que as primeiras linguagens de programação começaram a se compilar em várias representações intermediárias simplificadas nos anos 60 do século passado. Alguns desses códigos de primeiro byte foram convertidos em códigos de máquina e executados por processadores reais, enquanto outros foram interpretados em tempo real por processadores virtuais.


A popularidade dos conjuntos de instruções virtuais como uma representação intermediária do código se deve a três razões:


  1. Os programas de bytecode são facilmente portados para novas plataformas.
  2. Os intérpretes de bytecode são mais rápidos que os intérpretes da árvore de código da sintaxe.
  3. Você pode desenvolver uma máquina virtual simples em apenas algumas horas.

Vamos criar algumas máquinas virtuais C simples e usar esses exemplos para destacar os principais aspectos técnicos da implementação de máquinas virtuais.


Códigos de amostra completos estão disponíveis no GitHub . Exemplos podem ser compilados com qualquer GCC relativamente recente:


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

Todos os exemplos têm a mesma estrutura: primeiro vem o código da própria máquina virtual, depois a função principal com asserções que verificam a operação do código. Tentei comentar claramente os códigos de operação e os principais locais dos intérpretes. Espero que este artigo seja compreensível até para as pessoas que não escrevem em C diariamente.


O interpretador de bytecodes mais fácil do mundo


Como eu disse, o intérprete mais simples é muito fácil de fazer. Os comentários estão logo atrás da lista, mas vamos começar diretamente com o 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; } 

Existem menos de cem linhas, mas todos os atributos característicos de uma máquina virtual são representados. A máquina possui um único registro ( vm.accumulator ), três operações (incremento do registro, decremento do registro e conclusão da execução do programa) e um ponteiro para a instrução atual ( vm.ip ).


Cada operação (por exemplo, código de operação ou opcode ) é codificada com um byte e o agendamento é realizado usando a switch usual na função vm_interpret . As ramificações no switch contêm a lógica das operações, ou seja, elas alteram o estado do registro ou encerram a execução do programa.


As operações são transferidas para a função vm_interpret na forma de uma matriz de bytes - um bytecode (Eng. Bytecode ) - e são executadas sequencialmente até que a operação de OP_DONE máquina virtual ( OP_DONE ) seja OP_DONE .


Um aspecto fundamental de uma máquina virtual é a semântica, ou seja, o conjunto de operações que são possíveis nela. Nesse caso, existem apenas duas operações e elas alteram o valor de um único registro.


Alguns pesquisadores ( Técnicas de Abstração e Otimização de Máquinas Virtuais , 2009) propõem a divisão de máquinas virtuais em máquinas de alto e baixo nível, de acordo com a proximidade da semântica da máquina virtual com a semântica da máquina física na qual o bytecode será executado.


No caso extremo, o bytecode de máquinas virtuais de baixo nível pode repetir completamente o código da máquina física com RAM simulada, um conjunto completo de registros, instruções para trabalhar com a pilha e assim por diante. A máquina virtual Bochs , por exemplo, repete o conjunto de instruções da arquitetura x86.


E vice-versa: as operações de máquinas virtuais de alto nível refletem de perto a semântica de uma linguagem de programação especializada compilada no bytecode. Então trabalhe, por exemplo, SQLite, Gawk e várias versões do Prolog.


As posições intermediárias são ocupadas por intérpretes de linguagens de programação de uso geral, com elementos de níveis alto e baixo. A Java Virtual Machine mais popular possui instruções de baixo nível para trabalhar com a pilha e suporte interno para programação orientada a objetos com alocação automática de memória.


É mais provável que o código acima seja a mais primitiva das máquinas virtuais de baixo nível: cada instrução virtual é um invólucro com uma ou duas instruções físicas, o registro virtual é totalmente consistente com um registro do processador "iron".


Argumentos de instrução de bytecode


Podemos dizer que o único registro em nosso exemplo de máquina virtual é um argumento e o valor de retorno de todas as instruções executadas. No entanto, podemos achar útil passar argumentos nas instruções. Uma maneira é colocá-los diretamente no bytecode.


Expandiremos o exemplo introduzindo instruções (OP_ADDI, OP_SUBI) que recebem um argumento na forma de um byte imediatamente após o código de operação:


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

Novas instruções (consulte a função vm_interpret ) leem seu argumento do bytecode e o adicionam ao registro / subtraem-no do registro.


Esse argumento é chamado de argumento imediato , porque está localizado diretamente na matriz opcode. A principal limitação em nossa implementação é que o argumento é um byte único e pode levar apenas 256 valores.


Em nossa máquina virtual, o intervalo de possíveis valores de argumentos de instruções não desempenha um grande papel. Mas se a máquina virtual for usada como intérprete da linguagem real, faz sentido complicar o bytecode adicionando uma tabela de constantes separada da matriz de opcodes e instruções com um argumento direto correspondente ao endereço desse argumento na tabela de constantes.


Máquina de empilhar


As instruções em nossa máquina virtual simples sempre funcionam com um registro e não podem transmitir dados entre si de forma alguma. Além disso, o argumento para a instrução só pode ser imediato e, digamos, a operação de adição ou multiplicação leva dois argumentos.


Simplificando, não temos como avaliar expressões complexas. Para resolver esse problema, é necessária uma máquina empilhada, ou seja, uma máquina virtual com uma pilha 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; } 

Neste exemplo, já existem mais operações e quase todas funcionam apenas com a pilha. OP_PUSHI coloca seu argumento imediato na pilha. As instruções OP_ADD, OP_SUB, OP_DIV, OP_MUL são exibidas de uma pilha de valores, calculam o resultado e empurram-no de volta para a pilha. OP_POP_RES remove o valor da pilha e o coloca no registro de resultados, destinado aos resultados da máquina virtual.


Para a operação de divisão (OP_DIV), um erro de divisão por zero é capturado, o que para a máquina virtual.


As capacidades dessa máquina são muito mais amplas do que a anterior com um único registro e permitem, por exemplo, calcular expressões aritméticas complexas. Outra vantagem (e importante!) É a simplicidade de compilar linguagens de programação no código de bytes da máquina de empilhamento.


Registrar máquina


Devido à sua simplicidade, as máquinas virtuais empilhadas são mais amplamente usadas entre os desenvolvedores de linguagens de programação; as mesmas VMs JVMs e Python as usam exatamente.


No entanto, essas máquinas têm desvantagens: elas precisam adicionar instruções especiais para trabalhar com a pilha; ao calcular expressões, todos os argumentos passam repetidamente por uma única estrutura de dados; muitas instruções extras aparecerão inevitavelmente no código da pilha.


Enquanto isso, a execução de cada instrução extra implica o custo da programação, isto é, decodificar o código de operação e mudar para o corpo das instruções.


Uma alternativa para máquinas empilhadas é registrar máquinas virtuais. Eles têm um bytecode mais complexo: o número de argumentos do registro e o número do resultado do registro são codificados explicitamente em cada instrução. Portanto, em vez de uma pilha, um conjunto estendido de registros é usado como armazenamento de valores intermediários.


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

O exemplo mostra uma máquina de registro com 16 registros. As instruções ocupam 16 bits cada e são codificadas de três maneiras:


  1. 4 bits por código de operação + 4 bits por nome de registro + 8 bits por argumento.
  2. 4 bits por código de operação + três vezes 4 bits por nomes de registro.
  3. 4 bits por código de operação + 4 bits por nome de registro único + 8 bits não utilizados.

Nossa pequena máquina virtual possui muito poucas operações, portanto, quatro bits (ou 16 operações possíveis) por código de operação são suficientes. A operação determina o que exatamente os bits restantes da instrução representam.


O primeiro tipo de codificação (4 + 4 + 8) é necessário para carregar dados nos registradores com a operação OP_LOADI. O segundo tipo (4 + 4 + 4 + 4) é usado para operações aritméticas, que devem saber onde levar um par de argumentos e onde adicionar o resultado do cálculo. E finalmente, a última forma (4 + 4 + 8 bits desnecessários) é usada para instruções com um único registro como argumento, no nosso caso é OP_MOV_RES.


Para codificar e decodificar instruções, agora precisamos de lógica especial (função de decode ). Por outro lado, a lógica das instruções, graças à indicação explícita da localização dos argumentos, fica mais fácil - as operações com a pilha desaparecem.


Recursos principais: no bytecode das máquinas de registro, há menos instruções, instruções individuais são mais amplas, a compilação nesse bytecode é mais difícil - o compilador precisa decidir como usar os registros disponíveis.


Deve-se notar que, na prática, no registro de máquinas virtuais, geralmente há uma pilha em que, por exemplo, argumentos de função são colocados; registradores são usados ​​para calcular expressões individuais. Mesmo se não houver pilha explícita, uma matriz é usada para construir a pilha, desempenhando o mesmo papel que a RAM em máquinas físicas.


Máquinas de empilhar e registrar, comparação


Há um estudo interessante ( confrontação de máquinas virtuais: Stack versus registradores , 2008) que teve uma grande influência em todos os desenvolvimentos subsequentes no campo de máquinas virtuais para linguagens de programação. Seus autores propuseram um método de tradução direta do código de pilha de uma JVM padrão para um código de registro e compararam o desempenho.


O método não é trivial: o código é primeiro traduzido e depois otimizado de uma maneira bastante complicada. Porém, uma comparação subsequente do desempenho do mesmo programa mostrou que os ciclos adicionais do processador gastos em instruções de decodificação são totalmente compensados ​​por uma diminuição no número total de instruções. Em geral, em resumo, a máquina registradora era mais eficiente que a pilha.


Como já mencionado acima, essa eficiência tem um preço bastante tangível: o compilador deve alocar os próprios registros e um otimizador avançado é adicionalmente desejável.


O debate sobre qual arquitetura é melhor ainda não acabou. Se falamos de compiladores Java, o bytecode da Dalvik VM, que até recentemente funcionava em todos os dispositivos Android, era registrado; mas o título JVM manteve uma pilha de instruções. A máquina virtual Lua usa uma máquina de registro, mas a VM Python ainda é empilhável. E assim por diante


Bytecode em intérpretes de expressão regular


Por fim, para nos distrair das máquinas virtuais de baixo nível, vejamos um intérprete especializado que verifica as seqüências de caracteres quanto à correspondência de expressões 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); } 

A instrução principal é OP_CHAR. Ela pega seu argumento imediato e o compara com o caractere atual na string ( char *sp ). Em caso de coincidência dos caracteres esperados e atuais na linha, faça a transição para a próxima instrução e o próximo caractere.


A máquina também entende a operação de salto (OP_JUMP), que usa um único argumento imediato. O argumento significa o deslocamento absoluto no bytecode, de onde continuar o cálculo.


A última operação importante é OP_OR. Ela pega duas compensações, tentando aplicar o código primeiro na primeira delas e, em caso de erro, na segunda. Ela faz isso com uma chamada recursiva, ou seja, a instrução faz uma caminhada na profundidade da árvore de todas as variantes possíveis da expressão regular.


Surpreendentemente, quatro opcodes e setenta linhas de código são suficientes para expressar expressões regulares como "abc", "a? Bc", "(ab | bc) d", "a * bc". Essa máquina virtual nem sequer tem um estado explícito, pois tudo o que você precisa - ponteiros para o início do fluxo de instruções, a instrução atual e o caractere atual - é passado como argumento para a função recursiva.


Se você estiver interessado nos detalhes do trabalho dos mecanismos de expressão regular, primeiro leia uma série de artigos de Russ Cox, autor do mecanismo de expressão regular do Google RE2 .


Sumário


Vamos resumir.


Para linguagens de programação de uso geral, como regra, duas arquiteturas são usadas: empilhar e registrar.


No modelo de pilha, a estrutura de dados principal e o método de transmissão de argumentos entre instruções é a pilha. No modelo de registro, um conjunto de registros é usado para calcular expressões, mas uma pilha explícita ou implícita ainda é usada para armazenar argumentos de função.


A presença de uma pilha explícita e de um conjunto de registros aproxima essas máquinas das de baixo nível e até físicas. A abundância de instruções de baixo nível nesse código de bytes significa que um gasto significativo de recursos do processador físico recai na decodificação e programação de instruções virtuais.


Por outro lado, as instruções de alto nível desempenham um papel importante nas máquinas virtuais populares. Em Java, por exemplo, estas são instruções para chamadas de funções polimórficas, alocação de objetos e coleta de lixo.


Máquinas virtuais puramente de alto nível - por exemplo, intérpretes de códigos de bytes de linguagens com semântica desenvolvida e distante do ferro - passam a maior parte do tempo não no expedidor ou decodificador, mas no corpo das instruções e, portanto, são relativamente eficientes.


Recomendações práticas:


  1. Se você precisar executar qualquer bytecode e fazê-lo em um período de tempo razoável, tente operar com as instruções mais próximas da sua tarefa; quanto maior o nível semântico, melhor. Isso reduzirá os custos de agendamento e simplificará a geração de código.
  2. Se você precisar de mais flexibilidade e semântica heterogênea, tente pelo menos destacar o denominador comum no código de bytes para que as instruções resultantes estejam em um nível médio condicional.
  3. Se, no futuro, for necessário calcular qualquer expressão, criar uma máquina empilhada, isso reduzirá a dor de cabeça ao compilar o código de bytes.
  4. Se expressões não forem esperadas, crie uma máquina de registro trivial, que evitará o custo da pilha e simplificará as instruções.

Nos artigos a seguir, discutirei implementações práticas de máquinas virtuais em linguagens de programação populares e explicarei por que o departamento de Business Intelligence Badoo precisava de um bytecode.

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


All Articles