c assembly code-generation jit self-modifying

Escribir un compilador JIT en ensamblado



assembly code-generation (2)

He escrito una máquina virtual en C que tiene un rendimiento decente para una VM que no es JIT, pero quiero aprender algo nuevo y mejorar el rendimiento. Mi implementación actual simplemente usa un interruptor para traducir de bytecode VM a las instrucciones, que se compilan en una tabla de salto. Como dije, un rendimiento decente para lo que es, pero he topado con una barrera que solo se puede superar con un compilador JIT.

Ya hace mucho tiempo hice una pregunta similar sobre el código de auto modificación, pero me di cuenta de que no hacía la pregunta correcta.

Así que mi objetivo es escribir un compilador JIT para esta máquina virtual C, y quiero hacerlo en ensamblaje x86. (Estoy usando NASM como mi ensamblador) No estoy muy seguro de cómo hacerlo. Me siento cómodo con el ensamblaje, y he revisado algunos ejemplos de códigos de modificación automática, pero todavía no he descubierto cómo hacer la generación de código.

Mi bloque principal hasta ahora está copiando instrucciones a un pedazo de memoria ejecutable, con mis argumentos. Soy consciente de que puedo etiquetar una determinada línea en NASM y copiar toda la línea desde esa dirección con los argumentos estáticos, pero eso no es muy dinámico y no funciona para un compilador JIT. Necesito poder interpretar las instrucciones de bytecode, copiarlas en la memoria ejecutable, interpretar el primer argumento, copiarlo en la memoria, luego interpretar el segundo argumento y copiarlo en la memoria.

Me han informado sobre varias bibliotecas que facilitarían esta tarea, como el rayo GNU e incluso LLVM. Sin embargo, me gustaría escribir esto con la mano primero, para entender cómo funciona, antes de usar recursos externos.

¿Hay algún recurso o ejemplo que esta comunidad pueda proporcionar para ayudarme a comenzar esta tarea? Un ejemplo simple que muestra dos o tres instrucciones como "agregar" y "mover" que se utilizan para generar código ejecutable, con argumentos, dinámicamente, en la memoria, haría maravillas.


No recomendaría escribir un JIT en conjunto. Hay buenos argumentos para escribir los bits ejecutados con mayor frecuencia del intérprete en el ensamblaje. Para ver un ejemplo de cómo se ve, vea este comentario de Mike Pall , el autor de LuaJIT.

En cuanto al JIT, hay muchos niveles diferentes con complejidad variable:

  1. Compile un bloque básico (una secuencia de instrucciones sin ramificación) simplemente copiando el código del intérprete. Por ejemplo, las implementaciones de algunas instrucciones de bytecode (basadas en registros) podrían verse así:

    ; ebp points to virtual register 0 on the stack instr_ADD: <decode instruction> mov eax, [ebp + ecx * 4] ; load first operand from stack add eax, [ebp + edx * 4] ; add second operand from stack mov [ebp + ebx * 4], eax ; write back result <dispatch next instruction> instr_SUB: ... ; similar

    Entonces, dada la secuencia de instrucciones ADD R3, R1, R2 , SUB R3, R3, R4 un JIT simple podría copiar las partes relevantes de la implementación de los intérpretes en un nuevo fragmento de código máquina:

    mov ecx, 1 mov edx, 2 mov ebx, 3 mov eax, [ebp + ecx * 4] ; load first operand from stack add eax, [ebp + edx * 4] ; add second operand from stack mov [ebp + ebx * 4], eax ; write back result mov ecx, 3 mov edx, 4 mov ebx, 3 mov eax, [ebp + ecx * 4] ; load first operand from stack sub eax, [ebp + edx * 4] ; add second operand from stack mov [ebp + ebx * 4], eax ; write back result

    Esto simplemente copia el código relevante, por lo que necesitamos inicializar los registros utilizados en consecuencia. Una mejor solución sería traducir esto directamente a las instrucciones de máquina mov eax, [ebp + 4] , pero ahora ya tiene que codificar manualmente las instrucciones solicitadas.

    Esta técnica elimina los gastos generales de interpretación, pero de lo contrario no mejora mucho la eficiencia. Si el código se ejecuta solo una o dos veces, puede que no valga la pena traducirlo primero a código de máquina (que requiere enjuagar al menos partes de la I-caché).

  2. Mientras que algunos JIT utilizan la técnica anterior en lugar de un intérprete, entonces emplean un mecanismo de optimización más complicado para el código que se ejecuta con frecuencia. Esto implica traducir el bytecode ejecutado en una representación intermedia (IR) en la que se realizan optimizaciones adicionales.

    Dependiendo del idioma de origen y el tipo de JIT, esto puede ser muy complejo (por lo que muchos JIT delegan esta tarea a LLVM). Un JAT basado en un método necesita tratar con la unión de gráficos de control de flujo, por lo que usan el formulario SSA y ejecutan varios análisis sobre eso (por ejemplo, Hotspot).

    Un JIT de seguimiento (como LuaJIT 2) solo compila el código de línea recta, lo que facilita la implementación de muchas cosas, pero debe tener mucho cuidado al elegir trazas y cómo unir múltiples trazas juntas de manera eficiente. Gal y Franz describen un método en este documento (PDF) . Para otro método, vea el código fuente de LuaJIT. Ambos JIT están escritos en C (o quizás en C ++).


Te sugiero que consultes el proyecto http://code.google.com/p/asmjit/ . Al usar el marco que proporciona, puede ahorrar mucha energía. Si quieres escribir todo a mano, solo lee la fuente y reescríbelo tú mismo, creo que no es muy difícil.