Este artículo examina la arquitectura de almacenamiento de los Smart Contracts de Ethereum. Explica cómo se mantienen las variables en el almacenamiento de la EVM y cómo leer y escribir en las ranuras de almacenamiento usando assembly de bajo nivel (Yul).
Esta información es un requisito previo para entender cómo funcionan los proxies en Solidity y cómo optimizar el gas en los smart contracts.
Autoría
Este artículo fue coescrito por Aymeric Taylor (LinkedIn, Twitter), un pasante de investigación en RareSkills.
Arquitectura de almacenamiento de los Smart Contracts
Las variables en un smart contract almacenan su valor en dos ubicaciones principales: almacenamiento (storage) y bytecode.

Bytecode
El bytecode almacena información inmutable. Esto incluye los valores de los tipos de variables immutable y constant,
contract ImmutableVariables{
uint256 constant myConstant = 100;
uint256 immutable myImmutable;
}
así como el código fuente compilado (El código fuente es todo el texto a continuación).
contract ImmutableVariables {
uint256 constant myConstant = 100;
uint256 immutable myImmutable;
constructor(uint256 _myImmutable) {
myImmutable = _myImmutable;
}
function doubleX() public pure returns (uint256) {
uint256 x = 20;
return x * 2;
}
}
En la función doubleX() anterior, el valor de una variable local codificada (“hardcoded”) como uint256 x = 20 también se almacenará en el bytecode.
Dado que este artículo se centra en cubrir el aspecto del almacenamiento, no discutiremos el bytecode en detalle.
Almacenamiento
El almacenamiento guarda información mutable. Las variables que almacenan su valor en el almacenamiento se llaman variables de estado (state variables) o variables de almacenamiento (storage variables).

Su valor persiste en el almacenamiento indefinidamente, hasta que transacciones posteriores las modifiquen o el contrato se autodestruya.
Las variables de almacenamiento son variables de todos los tipos que se declaran dentro del ámbito global de un contrato (excepto las variables inmutables y constantes).
contract StorageVariables{
uint256 x;
address owner;
mapping(address => uint256) balance;
// and more...
}
Cuando interactuamos con una variable de almacenamiento, bajo el capó, en realidad estamos leyendo y escribiendo desde el almacenamiento, específicamente en la ranura de almacenamiento (storage slot) donde la variable guarda su valor.
Ranuras de almacenamiento
El almacenamiento de un smart contract está organizado en ranuras de almacenamiento. Cada ranura tiene una capacidad de almacenamiento fija de 256 bits o 32 bytes ().

Las ranuras de almacenamiento se indexan de a . Estos números actúan como un identificador único para localizar ranuras individuales.
El compilador de Solidity asigna espacio de almacenamiento a las variables de almacenamiento de manera secuencial y determinista, basándose en su orden de declaración dentro del contrato.
Considera el siguiente contrato, que contiene dos variables de almacenamiento: uint256 x y uint256 y.
contract StorageVariables {
uint256 public x; // first declared storage variable
uint256 public y; // second declared storage variable
}
Dado que x se declara primero e y se declara segundo, a x se le asigna la primera ranura de almacenamiento, la ranura 0, y a y se le asigna la segunda ranura de almacenamiento, la ranura 1. Por lo tanto, x retendrá su valor en la ranura 0, e y en la ranura 1.

Al ser consultadas, x e y leerán consistentemente los valores almacenados en sus respectivas ranuras de almacenamiento. Una variable no puede cambiar su ranura de almacenamiento una vez que el contrato ha sido desplegado en la blockchain.
Si el valor de x e y no se inicializa, su valor por defecto es cero. Todas las variables de almacenamiento tienen un valor por defecto de cero hasta que se establecen explícitamente.
contract StorageVariables {
uint256 public x; // Uninitialized storage variable
function return_uninitialized_X() public view returns (uint256) {
return x; // returns zero
}
}
Para establecer el valor de x en 20, podemos llamar a la función set_x(20).
function set_x(uint256 value) external {
x = value;
}
Esta transacción desencadena un cambio de estado en la ranura 0, actualizando su estado de 0 a 20.

Esencialmente, todos los cambios de estado realizados en un smart contract corresponden a cambios dentro de estas ranuras de almacenamiento.
Dentro de las ranuras de almacenamiento: datos de 256 bits
Las ranuras de almacenamiento individuales almacenan datos en formato de 256 bits; guardan la representación en bits del valor de una variable de almacenamiento.
En nuestro ejemplo anterior, uint256 x almacena su valor en la ranura 0. Una variable uint256 tiene un tamaño de 256 bits / 32 bytes, por lo tanto, utilizará los 256 bits de espacio de almacenamiento dentro de la ranura 0 para guardar su valor.
- Antes de llamar a
set_x(20), la ranura 0 estaba en su estado por defecto (todo ceros)

Todos los ceros verdes vistos en la imagen de arriba corresponden a los bits que se utilizan para almacenar el valor de x.
- Después de llamar a
set_x(20), el estado de la ranura 0 se cambió a la representación en bits del uint256 20.

Leer el contenido de una ranura de almacenamiento en formato crudo de 256 bits es menos legible para un humano, por lo tanto, los desarrolladores de Solidity generalmente lo leen en formato hexadecimal.
256 bits crudos: 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
Formato hexadecimal:
0x0000000000000000000000000000000000000000000000000000000000000014
Los 256 bits de unos y ceros se pueden reducir a solo 64 números hexadecimales. 1 carácter hexadecimal representa 4 bits. 2 caracteres hexadecimales representan 1 byte. El hexadecimal 0x14 se traduce de igual manera al número decimal 20. 0x14 (hex) = 10100 (binario) = 20 (decimal). Convertidor de binario a hexadecimal.
Demostraremos cómo obtener el valor de una ranura de almacenamiento en formato hexadecimal o en el tipo bytes32 utilizando assembly en una próxima sección.
Tipos de datos primitivos y complejos
A lo largo de este artículo, nuestros ejemplos solo girarán en torno a tipos de datos primitivos como enteros sin signo (uint), enteros (int), direcciones (address) y booleanos (bool).
contract PrimitiveTypes {
uint256 a;
int256 b;
address owner;
bool isTrue;
}
Estas variables ocupan como máximo una ranura de almacenamiento.
Los tipos de datos complejos como structs (struct{}), arrays (array[]), mapeos (mapping(address => uint256)), cadenas de texto (string) y bytes (bytes32) tienen una asignación de ranuras de almacenamiento más complicada. Requieren un artículo separado para discutirse a fondo.
Empaquetado de almacenamiento
Hasta ahora, hemos lidiado de manera conveniente con variables uint256, las cuales abarcan los 32 bytes completos de una ranura de almacenamiento. Otros tipos de datos primitivos, como uint8, uint32, uint128, address y bool, son de menor tamaño y utilizan menos espacio de almacenamiento. Pueden empaquetarse juntos dentro de la misma ranura de almacenamiento.
Como nota al margen, cualquier múltiplo de 8 hasta 256 es un uint válido, y bytes1, bytes2, y todos los tamaños fijos de bytes bytes1, bytes2, … hasta bytes32 son tipos de datos válidos.
La siguiente tabla ilustra el tamaño de almacenamiento de algunos tipos de datos primitivos.
| Tipo | Tamaño |
|---|---|
bool |
1 byte |
uint8 |
1 byte |
uint32 |
4 bytes |
uint128 |
16 bytes |
address |
20 bytes |
uint256 |
32 bytes |
Por ejemplo, una variable de almacenamiento de tipo address requerirá 20 bytes de espacio de almacenamiento para guardar su valor, como se ilustra en la tabla anterior.
contract AddressVariable{
address owner = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4;
}
En el contrato anterior, owner utilizará 20 bytes de los 32 bytes disponibles en la ranura 0 para almacenar su valor.

Solidity empaqueta las variables en las ranuras de almacenamiento comenzando desde el byte menos significativo (el byte más a la derecha) y avanza hacia la izquierda.
Podemos verificar esto leyendo la representación bytes32 de la ranura:

Como se muestra en el diagrama anterior, el valor de owner, 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4, se almacena comenzando desde el byte más a la derecha o el byte menos significativo. Los 12 bytes restantes en la ranura 0 serán espacio de almacenamiento no utilizado que otra variable podrá ocupar.
Cuando se declaran en secuencia, las variables de menor tamaño viven en la misma ranura de almacenamiento si su tamaño total es menor que 256 bits o 32 bytes.
Digamos que declaramos una segunda y una tercera variable de almacenamiento de tipo bool (1 byte) y uint32 (4 bytes), sus valores se almacenarán dentro de la misma ranura de almacenamiento que owner, la ranura 0, en el espacio de almacenamiento no utilizado.
contract AddressVariable {
address owner = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4;
// new
bool Boolean = true;
uint32 thirdvar = 5_000_000;
}
Boolean, la segunda variable de almacenamiento declarada, almacenará su valor en el primer byte a la izquierda de la secuencia de bytes de owner, o bien, en el byte menos significativo del espacio de almacenamiento no utilizado. Recuerda, Solidity empaqueta las variables de derecha a izquierda.

uint32 thirdVar, la tercera variable de almacenamiento, almacenará su valor a la izquierda de la secuencia de bytes de Boolean.

Si introdujéramos una cuarta variable de almacenamiento, address admin, su valor se almacenaría en la siguiente ranura de almacenamiento, la ranura 1.
contract AddressVariable {
address owner = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4;
bool Boolean = true;
uint32 thirdVar = 5_000_000;
// new
address admin = 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2;
}

Esto se debe a que el valor de admin en su totalidad no cabe en el espacio de almacenamiento no utilizado de la ranura 0. Quedan 7 bytes de espacio de almacenamiento pero se necesitan 20 bytes consecutivos. Por lo tanto, en lugar de dividir los datos de admin entre la ranura 0 y la ranura 1 (7 bytes en la ranura 0 y 13 bytes en la ranura 1), el valor de admin se almacenará en una nueva ranura de almacenamiento, la ranura 1.
Si el valor de una variable no puede caber completamente en el espacio restante de la ranura de almacenamiento actual, se almacenará en la siguiente ranura disponible.
Declarar variables más pequeñas juntas
uint16 public a;
uint256 public x; // uint256 in the middle
uint32 public b;
En esta disposición, uint16 a y uint32 b no se empaquetarán juntas.
En su lugar, a se almacenará en la ranura 0, x en la ranura 1, y b en la ranura 2, consumiendo tres ranuras de almacenamiento. La asignación de las ranuras de almacenamiento se vería como en el diagrama a continuación:

Una mejor práctica es reordenar las declaraciones para permitir que los tipos de datos más pequeños se empaqueten juntos.
uint256 public x;
// packed together
uint16 public a;
uint32 public b;
Esta configuración permite que a y b compartan una ranura de almacenamiento, optimizando así el espacio de almacenamiento.

Ahora que hemos entendido la teoría detrás de cómo se mantienen las variables primitivas en el almacenamiento, finalmente estamos listos para aprender cómo manipularlas en assembly, usando Yul.
Manipulación de ranuras de almacenamiento en Assembly (YUL)
El assembly de bajo nivel (Yul) otorga un mayor grado de libertad al realizar operaciones relacionadas con el almacenamiento. Nos permite leer y escribir directamente en ranuras de almacenamiento individuales y acceder a las propiedades de una variable de almacenamiento.
Hay dos opcodes relacionados con el almacenamiento en Yul: sload() y sstore().
sload()lee el valor almacenado por una ranura de almacenamiento específica.sstore()actualiza el valor de una ranura de almacenamiento específica con un nuevo valor.
Otras dos palabras clave importantes en Yul son .slot y .offset.
.slotdevuelve la ubicación dentro de las ranuras de almacenamiento..offsetdevuelve el desplazamiento en bytes de la variable. (Esto se discutirá en la Parte 2)
La palabra clave .slot
El contrato a continuación contiene tres variables de almacenamiento uint256.
contract StorageManipulation {
uint256 x;
uint256 y;
uint256 z;
}
Deberías poder deducir que x, y y z almacenan sus valores en la ranura 0, ranura 1 y ranura 2, respectivamente. Podemos probar esto accediendo a la propiedad de la variable de almacenamiento usando la palabra clave .slot.
.slot nos dice en qué ranura de almacenamiento una variable guarda su valor.
Por ejemplo, para consultar la ranura de almacenamiento de x, añade .slot al nombre de la variable: x.slot en assembly.
function getSlotX() external pure returns (uint256 slot) {
assembly {// yul
slot := x.slot // returns slot location of x
}
}
x.slot devuelve un valor de 0, que corresponde a la ranura de almacenamiento donde x almacena su estado—ranura 0.

y.slot devolverá 1, que corresponde a la ranura de almacenamiento de y— ranura 1.

z.slot devolverá 2, que corresponde a la ranura de almacenamiento de z— ranura 1.

Leer el valor de las variables directamente desde su ranura de almacenamiento: sload()
Yul nos permite leer el valor almacenado por ranuras de almacenamiento individuales. El opcode sload(slot) se utiliza para este propósito. Requiere una entrada, slot, el identificador de la ranura de almacenamiento y devuelve los 256 bits de datos enteros almacenados en la ubicación de la ranura especificada.
El identificador de la ranura puede ser la palabra clave .slot (sload(x.slot)), una variable local (sload(localvar)) o un número predefinido (sload(1)).
Aquí hay algunos ejemplos de cómo usar el opcode sload():
contract ReadStorage {
uint256 public x = 11;
uint256 public y = 22;
uint256 public z = 33;
function readSlotX() external view returns (uint256 value) {
assembly {
value := sload(x.slot)
}
}
function sloadOpcode(uint256 slotNumber)
external
view
returns (uint256 value)
{
assembly {
value := sload(slotNumber)
}
}
}
La función readSlotX() recupera los datos de 256 bits almacenados en x.slot (ranura 0) y los devuelve en formato uint256, que equivale a 11.
function readSlotX() external view returns (uint256 value) {
assembly {
value := sload(x.slot)
}
}
sload(0)lee de la ranura 0, que almacena el valor de 11.sload(1)lee de la ranura 1, que almacena el valor de 22.sload(2)lee de la ranura 2, que almacena el valor de 33.sload(3)lee de la ranura 3, que no almacena nada, todavía está en su estado por defecto.
La siguiente animación visualiza cómo funciona el opcode sload.
La función sloadOpcode(slotNumber) nos permite leer el valor de cualquier ranura de almacenamiento arbitraria. Luego devuelve el valor en formato uint256.
function sloadOpcode(uint256 slotNumber)
external
view
returns (uint256 value)
{
assembly {
value := sload(slotNumber)
}
}
Notablemente, sload() no realiza una verificación de tipos.
En Solidity, no podemos devolver una variable uint256 en formato bool ya que incurrirá en un error de tipo.
function returnX() public view returns (bool ret) {
// type error
ret = x;
}
Pero si el mismo conjunto de operaciones se realiza en Yul, el código seguirá compilándose.
function readSlotX_bool() external view returns(bool value) {
// return in bool
assembly{
value:= sload(x.slot) // will compile
}
}
Discutiremos por qué esto es posible en detalle en la Parte 2. Para darte una idea general, en assembly, cada variable es tratada esencialmente como un tipo bytes32. Fuera del ámbito de assembly, la variable reanudará su tipo original y formateará los datos en consecuencia.
Como resultado, podemos utilizar esta propiedad para examinar el valor de una ranura de almacenamiento en formato bytes32.
contract ReadSlotsRaw {
uint256 public x = 20;
function readSlotX_bool() external view returns (bytes32 value) {
assembly {
value := sload(x.slot) // will compile
}
}
}

Escribir en una ranura de almacenamiento usando el opcode sstore()
Yul nos da acceso directo para modificar el valor de una ranura de almacenamiento utilizando el opcode sstore().
sstore(slot, value) almacena un valor de 32 bytes de longitud directamente en una ranura de almacenamiento. El opcode toma dos parámetros, slot y value:
slot: Esta es la ranura de almacenamiento objetivo en la que estamos escribiendo.value: El valor de 32 bytes que se almacenará en la ranura de almacenamiento especificada. Si el valor es de menos de 32 bytes, se rellenará con ceros a la izquierda.
sstore(slot, value) sobrescribe toda la ranura de almacenamiento con un nuevo valor.
El contrato a continuación demuestra cómo usar sstore(); lo usamos para cambiar los valores de x e y:
contract WriteStorage {
uint256 public x = 11;
uint256 public y = 22;
address public owner;
constructor(address _owner) {
owner = _owner;
}
// sstore() function
function sstore_x(uint256 newval) public {
assembly {
sstore(x.slot, newval)
}
}
// normal function
function set_x(uint256 newval) public {
x = newval;
}
}
sstore_x(newVal) actualiza directamente el valor almacenado en la ranura de almacenamiento a la que hace referencia x, cambiando efectivamente el valor de x. La siguiente animación visualiza lo que sucede cuando llamamos al opcode sstore_x(88).
Tanto sstore_x(newVal) como set_x() realizan la misma función: actualizan el valor de x con un nuevo valor.
La función a continuación, sstoreArbitrarySlot(slot, newVal), es capaz de cambiar el valor de cualquier ranura de almacenamiento, por lo tanto, se aconseja no poner esto nunca en producción.
function sstoreArbitrarySlot(uint256 slot, uint256 newVal) public {
assembly {
sstore(slot, newVal)
}
}
Al llamar a sstoreArbitratySlot(1 , 48), cambiará el valor de y de 22 a 48. Dado que y mantiene su valor en la ranura de almacenamiento 1, sobrescribe el valor de 22 en la ranura 1 y lo cambia a 48.
sstore() tampoco realiza verificación de tipos.
Normalmente, cuando intentamos asignar un tipo address a un tipo uint256, devolvería un error de tipo y el contrato no compilaría:
address public owner;
function TypeError(uint256 value) external {
owner = value; // ERROR: Type uint256 is not implicitly convertible to expected type address.
}
ERROR: Type uint256 is not implicitly convertible to expected type address.
Este error no se activará con sstore() ya que no realiza una verificación de tipos.
contract WriteStorage {
address public owner;
function sstoreOpcode(uint256 value) public {
assembly {
sstore(owner.slot, value)
}
}
}
Manipular variables empaquetadas en almacenamiento en Yul Parte 2
sstore y sload operan en longitudes de 32 bytes. Esto es conveniente al tratar con el tipo uint256 ya que los 32 bytes completos leídos o escritos corresponden directamente a la variable uint256. Sin embargo, la situación se vuelve más compleja cuando se trata de variables que están empaquetadas dentro de la misma ranura de almacenamiento. Su secuencia de bytes ocupa solo una porción de los 32 bytes y en assembly, no tenemos un opcode para modificar o leer directamente desde su secuencia de bytes en el almacenamiento.
En la Parte 2, cubriremos la manipulación de variables empaquetadas en el almacenamiento en Yul utilizando técnicas de manipulación de bits y enmascaramiento de bits.
Publicado originalmente el 15 de julio de 2024