Como se analizó en el tutorial de unidades de cómputo, las unidades de cómputo consumidas por la llamada a un programa de Solana equivalen al número de instrucciones SBF (Solana Bytecode Format) ejecutadas más los costos de tiempo de ejecución de cualquier syscalls. Este artículo profundiza en el conjunto de instrucciones SBF y demuestra cómo analizar esas instrucciones utilizando trazas de ejecución y el agave-ledger-tool.
Del tutorial de compilación de Rust a SBF, sabemos que los programas de Solana se compilan a SBF (Solana Bytecode Format), el cual se ejecuta en la máquina virtual sBPF, una VM específica de Solana derivada de eBPF. Las instrucciones SBF se asemejan a los lenguajes ensambladores tradicionales como x86 o ARM y se ven así:
mov64 r0, 1 ; move 1 (64 bit padded) to register 0
mov64 r1, 2 ; move 2 (64 bit padded) to register 1
add64 r0, r1 ; add register 1 to register 0, store result in register 0
Requisitos previos
Este artículo asume haber completado:
- Tutorial de unidades de cómputo - Cómo funcionan las unidades de cómputo de Solana
- Tutorial de compilación de Rust a SBF - Para entender el pipeline de compilación de programas de Solana
- También se requiere familiaridad básica con conceptos de ensamblador (registros, direccionamiento de memoria, saltos).
En el tutorial de compilación de Rust a SBF, explicamos cómo los programas de Solana pasan por tres etapas principales: de Rust a LLVM IR, luego a bytecode SBF y finalmente a código nativo. Este artículo cubre la arquitectura de la VM de Solana y muestra cómo analizar el bytecode SBF en la práctica.
La arquitectura de la máquina virtual de Solana
La VM de Solana está basada en registros, a diferencia de la VM de Ethereum, que está basada en pila. En una VM basada en registros, las instrucciones operan sobre un conjunto fijo de ranuras de almacenamiento de tamaño fijo llamadas registros, y cada instrucción nombra explícitamente de qué registros lee y en cuáles escribe. En una VM basada en pila, las instrucciones operan implícitamente en la parte superior de una estructura de datos de pila, por lo que los operandos deben apilarse en la pila y desapilarse de ella para ser utilizados.
eBPF define 11 registros (R0–R10), todos de 64 bits de ancho. La VM sBPF de Solana implementa estos mismos 11 registros, pero internamente mantiene un duodécimo registro oculto, R11, utilizado para el seguimiento del contador de programa. Dado que R11 no puede ser leído ni escrito por los programas durante la ejecución, solo los 11 registros originales de eBPF son visibles para el código del programa.
Según la especificación de eBPF, los registros tienen los siguientes casos de uso:
R0: Este registro contiene el valor de retorno de las llamadas a funciones y el valor de salida del programa.R1-R5: Estos registros contienen los argumentos de las llamadas a funciones.R6-R9: Estos son registros guardados por la función invocada (callee-saved), lo que significa que deben preservarse entre llamadas a funciones.R10: Este es un puntero de marco de solo lectura que apunta al marco de pila actual.
R0-R5 son registros de trabajo (scratch registers) que las funciones pueden sobrescribir sin guardar. R6-R9 son registros callee-saved que deben preservarse entre llamadas a funciones. Esto significa que cuando una función foo() llama a bar(), cualquier valor que foo() necesite después de la llamada debe mantenerse en R6–R9. Si bar() necesita usar esos registros, guarda su contenido en su propio marco de pila al entrar y los restaura antes de regresar a foo(). Este proceso se llama spilling (guardar en la pila) y filling (restaurar desde la pila).
Conjunto de instrucciones SBF (Opcodes)
Como sabemos, SBF está basado en eBPF, por lo que utilizan el mismo conjunto de instrucciones. Todas las instrucciones (opcodes) que utiliza la VM de Solana están definidas aquí, y también puedes encontrar el conjunto completo con descripciones en la especificación de eBPF.
Estos opcodes incluyen:
Operaciones aritméticas y lógicas:
- Opcodes aritméticos:
add,sub,mul,div,mod,neg(negación),sdiv(división con signo) ysmod(módulo con signo). - Opcodes lógicos:
and,or,xor,lsh(desplazamiento a la izquierda),rsh(desplazamiento a la derecha),arsh(desplazamiento aritmético a la derecha). - Cada opcode tiene una variante de 64 bits (la predeterminada) y una variante de 32 bits.
- Cada opcode tiene dos formas: una que toma dos registros (destino y origen), y otra que toma un registro y un valor inmediato (una constante codificada de forma rígida en el bytecode del programa). Por ejemplo,
add64 r0, r1suma el registro r1 a r0, mientras queadd64 r0, 42suma la constante 42 a r0.
Movimiento de datos:
- También existe un opcode
movque copia valores entre registros o de valores inmediatos a registros.
Flujo de control:
La VM de Solana tiene opcodes para saltos incondicionales y condicionales.
jarealiza un salto incondicional. Mueve la ejecución a otro desplazamiento de instrucción sin verificar nada.- Luego tienes los saltos condicionales.
jeqsalta si dos valores son iguales.jnesalta si no son iguales.jltyjgtverifican si es menor o mayor que.jleyjgeverifican si es menor o igual, o mayor o igual que. - También hay versiones con signo.
jslt,jsgt,jsleyjsgemanejan las mismas comparaciones pero tratan a los operandos como enteros con signo. callmueve la ejecución a una parte etiquetada del bytecode (como una función compilada). Para las syscalls, los programas utilizan la instrucciónsyscall(nocall). La instrucción syscall va seguida de un identificador que especifica qué syscall invocar, por ejemplosol_log_osol_log_64_.exitregresa al llamador, o termina la ejecución del programa si la pila de llamadas está vacía.
Operaciones de memoria:
También hay opcodes para realizar operaciones de lectura y escritura en memoria.
ldxlee desde la memoria hacia un registro, ystxescribe desde un registro hacia la memoria.- Las instrucciones de carga y almacenamiento tienen variantes de tamaño con sufijos que muestran cuántos bytes lee o escribe cada versión. Así, para la carga tenemos:
ldxdw,ldxw,ldxh,ldxb. Por lo tanto,ldxdwcarga 8 bytes (palabra doble),ldxwcarga 4 bytes (una palabra),ldxhcarga 2 bytes (media palabra), yldxbcarga 1 byte. Las versiones de almacenamiento siguen el mismo patrón. Dado que todos los registros tienen un ancho de 64 bits, las operaciones más pequeñas (32 bits, 16 bits, 8 bits) escriben en los bits inferiores del registro y ponen a cero los bits superiores. - La carga toma dos operandos: un registro de destino y una dirección de memoria. Por ejemplo,
ldxdw r0, [r1+0x08]carga 8 bytes de la memoria en la direcciónr1+0x08en el registror0. La sintaxis[r1+0x08]significa: tomar la dirección almacenada enr1, agregar un desplazamiento de 0x08 bytes, y luego leer desde esa dirección final. - El almacenamiento también toma dos operandos: una dirección de memoria y un registro de origen. Por ejemplo,
stxdw [r1+0x08], r0almacena 8 bytes del registror0en la memoria en la direcciónr1+0x08. - Las direcciones de memoria se calculan como un registro base más un desplazamiento (ej.,
[r1+0x08]) como se ve arriba.
Próximos pasos
Ahora que entiendes la arquitectura de la VM sBPF, las convenciones de los registros y el conjunto de instrucciones, el siguiente artículo demuestra cómo analizar la ejecución del programa utilizando trazas y cómo calcular las unidades de cómputo a partir de la ejecución real del bytecode.
Este artículo es parte de una serie de tutoriales sobre el desarrollo en Solana