Este tutorial presenta el diseño de memoria de Solana BPF (sBPF) y las funciones de los registros de su máquina virtual. Demostraremos las convenciones sobre cómo los programas leen y escriben datos desde la memoria hacia los registros dentro de la VM de sBPF.
Diseño de memoria de Solana BPF
La memoria de la VM de sBPF está dividida en 5 regiones distintas. Cada una de las cinco regiones tiene un propósito específico. Cualquier intento de acceder a la memoria fuera de estas regiones, o de violar los permisos de una región, como escribir en datos de solo lectura, desencadena un error de violación de acceso. Mostraremos esto más adelante.
Antes de describir cada región, queremos enfatizar que mientras la EVM lee su bytecode directamente del código del contrato, la SVM carga su bytecode en la memoria antes de ejecutarlo.
Las direcciones a continuación son el inicio de cada región de memoria y están definidas en el código fuente de Solana como constantes u64. Los nombres como MM_BYTECODE_START, MM_RODATA_START, etc., son las definiciones de constantes en Rust de la dirección de inicio de cada región. Aquí MM significa “memory map” mientras que RO significa “read-only”. Solana reserva 4GiB para cada una de estas regiones de memoria para prevenir colisiones de direcciones entre regiones, pero asigna el tamaño que cada región realmente necesita.
0x000000000:MM_RODATA_START— 4 GiB para los datos ELF de solo lectura (constantes, datos estáticos)0x100000000:MM_BYTECODE_START— 4 GiB para la región del bytecode del programa0x200000000:MM_STACK_START— 4 GiB para la pila de ejecución (stack)0x300000000:MM_HEAP_START— 4 GiB reservados para la región de memoria dinámica (heap)0x400000000:MM_INPUT_START— 4 GiB para los datos de entrada serializados (program id, cuentas y datos de instrucción) en la transacción actual. Esto es poblado por el entorno de ejecución (runtime) al inicio del programa.
El diseño de memoria de Solana BPF se puede visualizar como se muestra a continuación:

El cliente de Solana define las constantes como se muestra en el siguiente fragmento:
pub const MM_RODATA_START: u64 = 0;
pub const MM_BYTECODE_START: u64 = MM_REGION_SIZE; // = MM_REGION_SIZE * 1
pub const MM_STACK_START: u64 = MM_REGION_SIZE * 2;
pub const MM_HEAP_START: u64 = MM_REGION_SIZE * 3;
pub const MM_INPUT_START: u64 = MM_REGION_SIZE * 4;
MM_REGION_SIZE define el tamaño de un bloque de memoria virtual, calculado como 1 << VIRTUAL_ADDRESS_BITS. VIRTUAL_ADDRESS_BITS se define como 32 en el código fuente de sBPF, esto significa que dentro de cada región, hay 2^32 direcciones de bytes diferentes, lo que da a cada región 4GiB de espacio direccionable.
Los multiplicadores (* 2, * 3, * 4) avanzan la dirección de inicio de cada región en múltiplos de MM_REGION_SIZE.

Para usar datos en memoria, primero debemos cargarlos en un registro. La VM asigna a cada registro un rol específico, que se espera que los desarrolladores sigan por convención. La siguiente sección describe estos roles con ejemplos mínimos.
Cómo la VM de Solana BPF asigna roles a los registros
La VM de sBPF tiene 12 registros, nombrados de r0 a r11. Los registros r0–r10 están expuestos a los programas, mientras que r11 contiene el contador de programa (program counter) y no es legible ni escribible por los programas de Solana.
r0 contiene los valores de retorno, r1–r5 son registros de argumentos, r6–r9 son registros de propósito general también conocidos como registros callee-saved (usados para almacenar valores temporales a través de las llamadas), y r10 es el registro del puntero de marco (frame pointer) para la pila de llamadas actual.
Antes de examinar cada registro, configuremos un entorno para observar cómo cambian los valores de los registros durante la ejecución.
Configuración del experimento con registros
Crea una nueva carpeta llamada register-experiment. Abre una terminal dentro de esta carpeta y ejecuta el comando solana-test-validator. Esto iniciará un clúster local de Solana y creará un directorio test-ledger dentro de register-experiment.
Una vez que el validador local esté en ejecución:
- Crea una carpeta llamada
srcen el directorioregister-experiment. Esta carpeta contendrá nuestro programa en ensamblador y un archivo de rastreo (trace) que muestra cómo cambian los estados de los registros después de que se ejecuta cada instrucción. - Crea un archivo
src/inputs.asmpara nuestro código en ensamblador.
Tu directorio debería verse así:
register-experiment
├── src
└── inputs.asm
Usaremos la herramienta agave-ledger-tool (que viene con tu instalación de Solana) para ejecutar nuestro código ensamblador y crear rastreos de registros.
Usa el comando a continuación para ejecutar los siguientes ejemplos. Se ejecuta contra nuestro test ledger local, ejecuta el programa en ensamblador con un límite de 200,000 unidades de cómputo y genera un archivo de rastreo que muestra cómo cambian los valores de los registros durante la ejecución.
agave-ledger-tool program run src/inputs.asm --limit 200000 --trace src/trace.txt --ledger test-ledger
Nota: En algunas arquitecturas, como Apple Silicon, ejecutar este comando puede desencadenar un error JitNotCompiled. Para resolver esto, agrega la bandera --mode interpreter para forzar la interpretación en lugar de la compilación JIT.
Ahora que nuestra configuración está completa, demostraremos cómo se usa cada registro, desde r0 hasta r11, durante la ejecución. Nuestras demostraciones codifican de forma rígida (hardcode) valores en los registros para aislar su comportamiento. Mostraremos cómo leer de la memoria y cargar valores en los registros en el próximo artículo.
Registro r0
Los programas comunican el éxito o el fracaso al entorno de ejecución escribiendo en r0. La VM lee este valor cuando finaliza la ejecución. Los posibles resultados son:
- Una ejecución exitosa devuelve
0. - Un error controlado devuelve un código de error distinto de cero (nosotros escribimos el código de error personalizado).
- Si ocurre un pánico (panic), el programa termina antes de llegar a la instrucción de salida (exit), por lo que el entorno de ejecución ignora
r0.
Vamos a demostrar cómo los programas comunican el éxito o el fracaso usando r0 basándonos en los tres posibles resultados enumerados anteriormente.
1/3 Un ejemplo que muestra que una ejecución exitosa devuelve 0
Escribe una instrucción exit simple en src/inputs.asm:
exit
Ejecútalo con el comando agave-ledger-tool, obtendrás una salida exitosa que devuelve 0:

El siguiente rastreo se creará en trace.txt mostrando que r0 es 0000000000000000 (la primera columna en el rastreo es r0, la segunda es r1 …):
Frame 0
0 [0000000000000000, 0000000400000000, ...] 0: exit
La demostración anterior muestra que r0 contiene 0 si una función se devuelve con éxito.
2/3 Un ejemplo para mostrar que una falla controlada devuelve un valor distinto de cero.
Podemos escribir en r0 en caso de una falla controlada. Si por alguna razón queremos devolver un código de error personalizado como 600 (0x0000000000000258), el programa sigue saliendo limpiamente, pero r0 contiene el código de error en lugar de cero:
mov r0, 600 ; Set custom error code
exit
Los comentarios en los ejemplos de código en ensamblador pueden causar un error de análisis al copiarlos directamente, porque agave-ledger-tool arroja errores si hay comentarios. Elimina el comentario ; Set custom error code si esto sucede.
Ejecuta el código anterior usando agave-ledger-tool, obtendrás esta salida:

El rastreo muestra que r0 comenzó en 0 y su estado actual es 0000000000000258 en formato hexadecimal, lo cual es 600 en decimal:
Frame 0
0 [0000000000000000, 0000000400000000, 0000000000000000, 0000000000000000, 0000000000000000, 0000000000000000, 0000000000000000, 0000000000000000, 0000000000000000, 0000000000000000, 0000000200001000] 0: lddw r0, 0x258
1 [0000000000000258, 0000000400000000, 0000000000000000, 0000000000000000, 0000000000000000, 0000000000000000, 0000000000000000, 0000000000000000, 0000000000000000, 0000000000000000, 0000000200001000] 2: exit
3/3 Un ejemplo para mostrar que un pánico (panic) o trampa (trap) finaliza la ejecución y no escribe en r0
Un pánico o trampa termina la ejecución y se trata como irrecuperable. No almacena el resultado en r0, por lo que cualquier valor guardado allí previamente es ignorado.
El siguiente código demuestra un intento de escribir 42 en la región de memoria de solo lectura.
lddw r1, 0x000000000 ;Load MM_RODATA_START address into r1
stdw [r1 + 0], 42 ;Attempt to store 42 into the MM_RODATA_START
exit
En el código anterior:
lddwcarga un valor inmediato de 64 bits en un registro. Lo usamos para cargar la dirección de inicio de la región de solo lectura (0x000000000) enr1.stdwalmacena un valor inmediato de 64 bits directamente en la memoria. Intentamos escribir 42 en la región de memoria de solo lectura usando esta instrucción, lo que desencadena un error de violación de acceso cuando la ejecutamos.

En el rastreo, notarás que este termina en la línea 1 (la línea stxdw) porque estamos intentando una operación de memoria inválida, lo que provocó una trampa (trap). El programa nunca llegó a la instrucción exit.
Frame 0
0 [0000000000000000, 0000000400000000, ...] 0: lddw r1, 0x100000000
1 [0000000000000000, 0000000100000000, ...] 2: stxdw [r1+0x0], r2
Hasta ahora, hemos cubierto cómo se comporta r0 en diferentes escenarios y cómo lo usa la VM. En la capa de Rust, el valor de retorno aparece como Ok(u64) o Err(...), pero en la VM es simplemente un único código de estado u64 almacenado y leído desde r0.
La siguiente tabla muestra qué sucede en cada escenario y el valor que contiene r0 en dicho escenario:
| Situación | Qué sucede | Valor en r0 |
|---|---|---|
| Retorno normal | El programa finaliza y retorna al cargador (loader) | 0 |
| Error controlado (Anchor o manejo manual de errores) | El programa establece un código de error y retorna | Código de error distinto de cero (ej. 2, 600, 1, 5, etc.) |
| Fallo de aserción (assertion) o aborto | El programa finaliza | Se ignora r0 porque el programa no llega a la instrucción exit |
Registro r1
Al inicio del programa, r1 contiene MM_INPUT_START (0x400000000), que apunta al principio de los parámetros de entrada serializados en la memoria.
Supongamos que tenemos una función que toma dos argumentos, a y b:
fn add_constant(a: u64, b: u64)
Cuando el programa comienza su ejecución, el entorno de ejecución almacena los valores serializados de a y b en la ubicación de memoria MM_INPUT_START. En esta etapa, el registro r1 apunta a MM_INPUT_START (es decir, r1 contiene la dirección de MM_INPUT_START).
Durante la ejecución, el rol de r1 cambia a un registro de argumento y su contenido puede ser sobrescrito por el programa.
Registros r1-r5
Si las funciones de tu programa necesitan argumentos durante la ejecución, el entorno de ejecución espera que tu programa almacene esos argumentos desde r1 hasta r5. Si una función necesita más de cinco argumentos, debes almacenar los valores extra en la pila (stack) y pasar un puntero a ellos usando uno de estos registros.
Cuando una función retorna, los valores en los registros de argumentos se consideran sucios (dirty). La siguiente función que se ejecute puede sobrescribir libremente de r1 a r5.
Vamos a demostrar el paso de argumentos llamando a una función add_numbers que toma dos argumentos.
fn add_numbers(a: u64, b: u64) -> u64 {
a + b
}
Para mayor simplicidad, traducimos manualmente el código anterior a ensamblador sBPF y omitimos el compilador. El fragmento se muestra a continuación. Pasemos 10 como primer argumento y 25 como el segundo, sumémoslos en el cuerpo de la función y devolvamos el resultado en r0:
mov r1, 10 ; Simulate passing a=10 as first argument
mov r2, 25 ; Simulate passing b=25 as second argument
add64 r1, r2 ; Function body: add first and second argument
mov r3, r1 ; Copy the result to r3
exit ; exit successfuly with 0 in r0
Ejecuta agave-ledger-tool y revisa el archivo trace.txt. Verás que el resultado es 23 (35 en decimal):
Los siguientes registros son r6-r9, pero dependen de r10 (el puntero de pila). Vamos a explicar primero r10, y luego volveremos a r6-r9.
El registro del puntero de pila (r10)
r10 es el puntero del marco de pila (stack frame pointer). Contiene una dirección virtual que apunta dentro de la región actual de la pila.
Cuando comienza tu programa, el entorno de ejecución inicializa r10 en MM_STACK_START + stack_frame_size, donde MM_STACK_START es 0x200000000 mientras que stack_frame_size = 4096 (1000 en hex) bytes (4KiB) — en los rastreos, verás que r10 se inicializa como 0x200001000. Esto coloca a r10 en la parte superior del marco de la pila con 4096 bytes de espacio utilizable por debajo de él.

La pila crece hacia abajo, lo que significa que asignas espacio en la pila en direcciones de memoria progresivamente más bajas. Dado que r10 = MM_STACK_START + stack_frame_size, leer o escribir en cualquier ubicación de la pila sigue esta fórmula:
[r10 - offset] = MM_STACK_START + stack_frame_size - offset
donde offset es el número de bytes de espacio en la pila consumidos. Eso significa que [r10 - 8] se resuelve a MM_STACK_START + stack_frame_size - 8, estás referenciando a una ubicación 8 bytes por debajo de donde apunta r10.
Así que r10 en sí nunca cambia de valor a medida que se ejecuta tu programa. Para usar más espacio en la pila, simplemente usas desplazamientos (offsets) más grandes como [r10 - 16], [r10 - 24], y así sucesivamente. Cada llamada a función está limitada a 4KiB (4096 bytes) de espacio utilizable.
Digamos que necesitas pasar datos en la pila a una función. Eliges una ubicación en el marco de la pila en algún desplazamiento negativo respecto a r10, escribes tus datos allí, luego pones la dirección de esa ubicación (por ejemplo, r10 - 8) en uno de los registros de argumentos (desde r1 hasta r5) antes de realizar la llamada.
Por ejemplo, si necesitas un búfer de 64 bytes como primer argumento:
- Puedes almacenar datos en la región de memoria de
[r10 - 64]a[r10 - 1] - Mueves la dirección de inicio
r10 - 64haciar1

- Luego realizas tu llamada a la función
Demostraremos cómo usar el puntero de pila r10 cuando cubramos “Registros 6-9”.
Restricciones de escritura en r10
No puedes escribir en el registro r10. Escribir en r10 con mov devuelve un error:
mov r10, 999 # Error: cannot write to r10
exit

Intentar escribir en r10 con add r10, -64 no genera un error, pero la VM lo ignora silenciosamente y reescribe la instrucción a una instrucción OR a nivel de bits de 64 bits (or64 r0, 0) que no hace nada:
add r10, -64
exit
Si ejecutamos el código anterior y miramos el archivo de rastreo, veremos que r10 permanece sin cambios:
Registros Callee-saved (r6-r9)
En Solana BPF, los registros r6-r9 son ‘callee-saved’ (también llamados ‘preservados’). Esto significa que si una función (el llamado o callee) modifica estos registros, debe restaurarlos a sus valores originales antes de regresar a quien la llamó (el caller).
Por ejemplo, si la función A almacena un valor en r6 y llama a la función B, y la función B quiere usar r6 para su propio cálculo, la función B debe preservar lo que había en r6 antes de usarlo. La función B hace esto copiando r6 a la pila, usando r6 para su propio cálculo, y luego copiando el valor original de regreso desde la pila a r6 antes de retornar. Cuando la función B finaliza, la función A aún mantiene su valor original en r6.
El siguiente código demuestra la convención de preservación de datos entre dos funciones:
- La función A (CALLER) almacena 999 en
r6 - La función A llama a la función B (
function_b), esperando que se preserver6 - La función B copia
r6(999) en la pila en[r10 - 8] - La función B usa
r6para su propio cálculo: carga 42 y suma 10 (dando como resultado 52) - La función B restaura
r6desde la pila nuevamente a 999 antes de retornar - Cuando la función B finaliza, la función A todavía mantiene 999 en
r6 - La función A mueve
r6ar2para usar el valor preservado
; === Function A: CALLER ===
mov r6, 999 ; Store value we want preserved across call
call function_b ; function_b MUST preserve r6 per convention
mov r2, r6 ; r6 still contains 999 (preserved by callee)
exit
; === Function B: CALLEE ===
function_b:
; Save callee-saved registers we'll modify
stxdw [r10 - 8], r6 ; copy caller's r6 value to stack
; FUNCTION BODY: We can freely use r6 now
mov r6, 42 ; Temporary use of r6
add64 r6, 10 ; r6 = 52 (will be discarded)
; Restore callee-saved registers
ldxdw r6, [r10 - 8] ; Restore original r6 value (999)
exit
La animación a continuación ilustra estos pasos:
Ejecutar el código anterior produce el siguiente rastreo:
Frame 0
0 [..., 0000000400000000, 0000000000000000, ..., ..., ..., 0000000000000000, ..., ..., ..., 0000000200001000] 0: mov64 r6, 999
1 [..., 0000000400000000, 0000000000000000, ..., ..., ..., 00000000000003E7, ..., ..., ..., 0000000200001000] 1: call function_b
2 [..., 0000000400000000, 0000000000000000, ..., ..., ..., 00000000000003E7, ..., ..., ..., 0000000200003000] 4: stxdw [r10-0x8], r6
3 [..., 0000000400000000, 0000000000000000, ..., ..., ..., 00000000000003E7, ..., ..., ..., 0000000200003000] 5: mov64 r6, 42
4 [..., 0000000400000000, 0000000000000000, ..., ..., ..., 000000000000002A, ..., ..., ..., 0000000200003000] 6: add64 r6, 10
5 [..., 0000000400000000, 0000000000000000, ..., ..., ..., 0000000000000034, ..., ..., ..., 0000000200003000] 7: ldxdw r6, [r10-0x8]
6 [..., 0000000400000000, 0000000000000000, ..., ..., ..., 00000000000003E7, ..., ..., ..., 0000000200003000] 8: exit
7 [..., 0000000400000000, 0000000000000000, ..., ..., ..., 00000000000003E7, ..., ..., ..., 0000000200001000] 2: mov64 r2, r6
8 [..., 0000000400000000, 00000000000003E7, ..., ..., ..., 00000000000003E7, ..., ..., ..., 0000000200001000] 3: exit
Observaciones clave del rastreo:
- Línea 1:
r6contiene0x3E7(999) antes de la llamada - Línea 2: La instrucción
callhace la transición afunction_by actualizar10para apuntar a un nuevo marco de pila en0x200003000 - Línea 4: Después de
mov64 r6, 42,r6contiene0x2A(42) - Línea 5: Después de
add64 r6, 10,r6contiene0x34(52) - Línea 6: Después de
ldxdw r6, [r10-0x8],r6es restaurado a0x3E7(999) - Línea 7: Después de regresar al caller,
r10vuelve a0x200001000yr6aún contiene0x3E7(999) - Línea 8: Después de
mov64 r2, r6, tantor2comor6contienen0x3E7(999)
La animación a continuación explica los pasos anteriores:
En la siguiente parte de este artículo, demostraremos más a fondo cómo leer y escribir las entradas de instrucción en la memoria escribiendo programas simples en ensamblador puro que inspeccionan directamente el contenido de la memoria.
Este artículo es parte de una serie de tutoriales sobre desarrollo en Solana