WEB3DEV

Cover image for Mergulhos profundos no EVM: O Caminho para o Shadowy Super Coder - Parte 2
Fatima Lima
Fatima Lima

Posted on • Atualizado em

Mergulhos profundos no EVM: O Caminho para o Shadowy Super Coder - Parte 2

Vamos fazer uma viagem pela memória

Esta é a segunda parte de uma série de artigos que irão mergulhar profundamente no EVM e construir o conhecimento básico necessário para se tornar uma um “Shadowy Super Coder”. Este artigo se baseará no conhecimento adquirido na Parte 1, portanto, se você ainda não leu, eu encorajo que você o faça.

Na parte 1, exploramos como o EVM sabe qual bytecode executar dependendo de qual função de contrato for chamada. Isso nos ajudou a construir uma compreensão da call stack (pilha de chamadas), calldata, assinaturas de funções e as instruções de opcode EVM.

Na parte 2, nós faremos uma viagem pela “memória” e forneceremos uma revisão abrangente do que é contrato de memória e como isso funciona por baixo do capô do EVM.

Uma viagem pela memória

Como você deve se lembrar, na Parte 1 nós demos uma olhada no padrão de contrato 1_Storage.sol do remix.

Image description

Em seguida, geramos o código de byte e ampliamos a parte relacionada à função de seleção. Neste artigo, iremos focar nos 5 primeiros bytes do contrato de execução de bytecode.


6080604052

Enter fullscreen mode Exit fullscreen mode

60 80                       =   PUSH1 0x80

60 40                       =   PUSH1 0x40

52                          =   MSTORE 

Enter fullscreen mode Exit fullscreen mode

Esses 5 bytes representam a inicialização do “free memory pointer” (ponteiro de memória livre). Para entender completamente o que isso significa e o que esses bytes fazem, devemos primeiro construir sua compreensão das estruturas de dados que governam a memória de contrato.

Estrutura de dados da memória

A memória de contrato é um array (vetor) de bytes simples, onde os dados podem ser armazenados em blocos de 32 bytes (256 bits) ou 1 byte (8 bits) e lidos em blocos de 32 bytes (256 bits). A imagem abaixo ilustra essa estrutura junto com a funcionalidade de leitura/gravação do contrato de memória.

Image description

fonte: https://takenobu-hs.github.io/downloads/ethereum_evm_illustrated.pdf

Essa funcionalidade é determinada pelos 3 opcodes que operam na memória:

  • MSTORE (x, y) - Armazena um valor de 32 bytes (256 bits) em “y” começando no local de memória “x”.
  • MLOAD (x) - Carrega 32 bytes (256 bits) começando no local de memória “x” na call stack.
  • MSTORE8 x, y) - Armazena um valor de 1 byte ( 8 bits) em “y” no local de memória “x” (o byte menos significativo do valor da stack (pilha) de 32 bytes).

Você pode pensar na localização da memória simplesmente como o índice do array onde começa escrever/ler os dados. Se você quiser escrever/ler mais de 1 byte de dados, simplesmente continue escrevendo ou lendo a partir do próximo índice de array.

Playground EVM

Este playground EVM ajudará a solidificar a sua compreensão do que esses 3 opcodes fazem e como funcionam os locais de memória. Clique em Run (executar) e na seta enrolada no canto superior direito para pular pelos opcodes e ver como a stack e a memória são alteradas. (Há comentários em cima dos opcodes para descrever o que cada seção faz).

Image description

Enquanto caminhamos pelo playground EVM acima, você deve ter percebido algumas ocorrências estranhas. Primeiro, quando escrevemos um único byte 0x22 usando MLOAD8 no local de memória 32 (0x20), a memória mudou de:

Image description

Para:

Image description

Você pode se perguntar, o que houve comeste monte de zeros adicionais se nós adicionamos apenas um byte?

Expansão de memória

Quando seu contrato está gravado na memória, você deve pagar pelo número de bytes gravados. Se você está gravando em uma área da memória que ainda não foi escrita antes, existe um custo de expansão de memória adicional para usá-la pela primeira vez.

A memória é expandida em incrementos de 32 bytes (256 bits), quando gravado em um espaço de memória inalterado.

Os custos de expansão de memória escalam linearmente para os primeiros 724 bytes e quadraticamente depois disso.

Acima de nossa memória havia 32 bytes antes de escrevermos 1 byte no local 32. Neste ponto, começamos escrevendo na memória virgem, como resultado, a memória expandiu por outro incremento de 32 bytes para 64 bytes.

Observe que todos os locais na memória são bem definidos inicialmente como zero, e é por isso que nós vemos 2200000000000000000000000000000000000000000000000000000000000000 adicionados em nossa memória.

Relembre-se de que Memória é um Array de Bytes

A segunda coisa que você deve ter percebido que ocorreu quando nós executamos uma MLOAD do local de memória 33 (0x21). Nós retornamos o seguinte valor para a call stack:

3300000000000000000000000000000000000000000000000000000000000000

Conseguimos iniciar nossa leitura a partir de um fator diferente de 32.

Relembre-se, a memória é uma array de bytes, o que significa que podemos iniciar nossa leitura (ou nossas escritas) de um local de memória qualquer. Nós não estamos limitados a múltiplos de 32. A memória é linear e pode ser endereçada no nível de byte.

A memória só pode ser recém-criada em uma função. Podem ser tipos complexos recém-instanciados como array/struct (por exemplo, via new int[...]) ou copiados de uma variável referenciada de armazenamento.

Agora que temos um embasamento das estruturas de dados, vamos retornar para o ponteiro de memória livre.

Ponteiro de Memória Livre

O ponteiro de memória livre é um simples ponteiro para o local onde a memória livre começa. Ele garante que smart contracts acompanhem quais locais de memória foram escritos e quais não foram.

Isso protege contra um contrato que possa sobrescrever um local de memória que foi alocado para outra variável.

Quando uma variável é escrita na memória, o contrato fará primeiro referência ao ponteiro de memória livre para determinar onde os dados devem ser armazenados.

Em seguida, ele atualizará o ponteiro de memória livre observando quantos novos dados devem ser gravados no novo local de memória. Uma simples adição desses 2 valores vai gerar o local onde a nova memória livre começará.


freeMemoryPointer + dataSizeBytes = newFreeMemoryPointer 

Enter fullscreen mode Exit fullscreen mode

Bytecode

Como foi mencionado anteriormente, o ponteiro de memória livre é definido no início do bytecode em tempo de execução por meio destas 5 opcodes.


60 80                       =   PUSH1 0x80

60 40                       =   PUSH1 0x40

52                          =   MSTORE 

Enter fullscreen mode Exit fullscreen mode

Eles efetivamente afirmam que o ponteiro de memória livre está localizado na memória no byte 0x40 (64 em decimal) e tem um valor de 0x80 (128 em decimal). As perguntas imediatas que você pode ter são por que os valores acima 0x40 e 0x80 são usados. A resposta para isso pode ser encontrada na seguinte afirmação.

O layout de memória do Solidity reserva quatro slots de 32 >bytes:

  • 0x00 - 0x3f (64 bytes): espaço de rascunho
  • 0x40 - 0x5f (32 bytes): ponteiro de memória livre
  • 0x60 - 0x7f (32 bytes): slot zero

Podemos ver que 0x40 é a localização predefinida pelo Solidity para o ponteiro de memória livre. O valor 0x80 é apenas o primeiro byte de memória disponível para gravação após os 4 slots de 32 bytes reservados.

Analisaremos rapidamente o que cada seção reservada faz:

  • Espaços de rascunho, podem ser usados entre as instruções, ou seja, dentro do Assembly inline ou para métodos de hashing.
  • Ponteiro de memória livre, representa o tamanho de memória atualmente alocado, localização inicial da memória livre, inicialmente 0x80.
  • O slot zero, é usado como um valor inicial para arrays de memória dinâmica e nunca deve ser gravado.

Memória em um contrato real

Para consolidar o que aprendemos até agora, veremos como a memória e o ponteiro de memória livre são atualizados dentro de um código solidity, real.

Eu criei um contrato MemoryLane e intencionalmente o mantive extremamente simples. Ele contém uma única função que meramente define 2 arrays de comprimentos 5 e 2, e então atribui a b[0] um valor de 1. Apesar da simplicidade, muita coisa acontece quando essas 3 linhas de código são executadas.

Image description

Para visualizar os detalhes de como esse código solidity é executado no EVM, ele pode ser copiado em uma remix IDE. Depois de copiado, você pode compilar o código, implantá-lo, executar a função memoryLane() e então entrar no modo de depuração para percorrer os opcodes (consulte aqui para obter instruções sobre como fazer isso). Eu extraí uma versão simplificada em um Playground EVM e vou executá-la abaixo.

A versão simplificada organiza os opcodes sequencialmente removendo quaisquer JUMP’s e qualquer código que não seja relevante para manipulação de memória. Comentários foram adicionados ao código para fornecer contexto ao que está sendo feito. O código é dividido em 6 seções distintas nas quais nos aprofundaremos.

Eu não posso enfatizar o suficiente como é importante usar o playground e percorrer os opcodes você mesmo. Isso vai melhorar muito o seu aprendizado. Agora vamos nos aprofundar nas 6 seções.

Inicialização do Ponteiro de Memória Livre ( EVM Playground - Linhas 1-15)

Primeiro, temos a “inicialização do ponteiro de memória livre” que discutimos acima. Um valor de 0x80 (128 em decimal) é colocado na stack. Este é o valor do ponteiro de memória livre e é determinado pelo layout de memória do Solidity. Nesta fase, não temos nada na memória.

Image description

Em seguida, empurramos o local do ponteiro de memória livre 0x40 (64 em decimal) novamente determinado pelo layout de memória do Solidity.

Image description

Finalmente, chamamos MSTORE que retira o primeiro item da _stack _0x40 para determinar onde escrever na memória e o segundo valor 0x80 como o que escrever.

Isso nos deixa com uma stack vazia, mas agora preenchemos alguma memória. Esta representação de memória está em hexadecimal onde cada caractere representa 4 bits.

Temos 192 caracteres hexadecimais na memória, o que significa que temos 96 bytes (1 byte = 8 bits = 2 caracteres hexadecimais).

Se retomarmos o layout de memória do Solidity, seríamos informados de que os primeiros 64 bytes seriam alocados como espaço de rascunho e os próximos 32 seriam para o ponteiro de memória livre.

É exatamente isso que temos abaixo.

Image description

Variável de Alocação de Memória “a” e Atualização do Ponteiro de Memória Livre (_Playground _EVM - Linhas 16-34)

Para as seções restantes, vamos pular para o estado final de cada seção e fornecer uma visão geral de alto nível do que aconteceu com rapidez. As etapas de opcode individuais podem ser vistas através do EVM playground.

A próxima memória é alocada para a variável “a” (_bytes_32[5]) e o ponteiro de memória livre é atualizado.

O compilador determinará quanto espaço é necessário por meio do tamanho da array e do tamanho padrão do elemento do array.

Lembre-se de que elementos em arrays de memória no Solidity sempre ocupam múltiplos de 32 bytes (isso é verdade para bytes1[], mas não para bytes e string (cadeia de caracteres)).

O tamanho do array multiplicado por 32 bytes nos diz quanta memória precisamos alocar.

Nesse caso, esse cálculo 5 * 32 resulta em 160 ou 0xa0 em hexadecimal. Podemos ver isso sendo empurrado para a stack e adicionado ao ponteiro de memória livre atual 0x80 (128 em decimal) para obter o novo valor do ponteiro de memória livre.

Isso retorna 0x120 (288 em decimal) que podemos ver que foi gravado no local do ponteiro de memória livre.

A call stack mantém a localização de memória da variável “a” na stack 0x80 para que possa referenciá-la posteriormente, se necessário. 0xffff representa um local JUMP e pode ser ignorado, pois não é relevante para manipulação de memória.

Image description

Variável de Inicialização de Memória “a” (_Playground _EVM- Linhas 35-95)

Agora que a memória foi alocada e o ponteiro de memória livre atualizado, precisamos inicializar o espaço de memória para a variável “a”. Como a variável é apenas declarada e não atribuída, ela será inicializada com o valor zero.

Para fazer isso, o EVM usa CALLDATACOPY que recebe 3 variáveis.

  • memoryOffset (para qual local de memória copiar os dados)
  • calldataOffset (byte offset (byte deslocado) no calldata para copiar)
  • size (tamanho do byte para copiar)

No nosso caso, o memoryOffset é o local de memória para a variável “a” (0x80). O calldataOffset é o tamanho real do nosso calldata, pois não queremos copiar nenhum dos_ calldata_, queremos inicializar a memória com o valor zero. Finalmente, o size é 0xa0 ou 160 bytes, pois esse é o tamanho da variável.

Podemos ver que nossa memória foi expandida para 288 bytes (isso inclui o slot zero) e a stack novamente mantém o local de memória da variável e um local JUMP na call stack.

Image description

Variável de Alocação de Memória “b” e Atualização do Ponteiro de Memória Livre (Playground EVM - Linhas 96-112)

Isso é o mesmo que a alocação de memória e atualização do ponteiro de memória livre para a variável “a”, exceto que desta vez é para “bytes 32[2] memória b”.

Observe que o ponteiro de memória livre foi atualizado na memória para 0x160 e agora temos o local de memória para a variável “b” (0x120) na stack.

Image description

Variável de Inicialização de Memória “b” (Playground EVM - Linhas 113-162)

O mesmo que a inicialização de memória da variável “a”.

Observe que a memória aumentou para 352 bytes. A stack ainda mantém os locais de memória para as 2 variáveis.

Image description

Atribuir Valor a b0

Finalmente, podemos atribuir um valor ao array “b” no índice 0. O código afirma que b[0] deve ter um valor de 1.

Esse valor é enviado para a stack 0x01. Um deslocamento de bit para a esquerda ocorre em seguida, no entanto, a entrada para o deslocamento de bit é 0, o que significa que nosso valor não muda.

Em seguida, a posição do índice do array a ser gravada em 0x00 é enviada para a stack e é feita uma verificação para averiguar se esse valor é menor que o comprimento do array 0x02. Se não for, a execução salta para uma parte diferente do bytecode que trata desse estado de erro.

Os opcodes MUL (multiplicar) e ADD são usados para determinar onde na memória o valor precisa ser escrito, para que corresponda ao índice correto de array.


0x20 (32 em decimal) * 0x00 (0 em decimal) = 0x00

Enter fullscreen mode Exit fullscreen mode

Lembre-se de que arrays de memória são elementos de 32 bytes, portanto, esse valor representa o local inicial de um índice do array. Levando em conta o que estamos escrevendo no índice 0, não temos deslocamento.


0x00 + 0x120 = 0x120 (288 em decimal)

Enter fullscreen mode Exit fullscreen mode

O ADD é usado para adicionar este valor de deslocamento ao local de memória para a variável “b”. Dado que nosso deslocamento foi de 0, escreveremos nossos dados diretamente no local de memória atribuído.

Finalmente, um MSTORE armazena o valor 0x01 neste local de memória 0x120.

A imagem abaixo mostra o estado do sistema ao final da execução da função. Todos os itens da stack foram retirados.

Observe que, na verdade, no remix, existem alguns itens restantes na stack, um local JUMP e a assinatura da função, no entanto, eles não são relevantes para a manipulação de memória e, portanto, foram omitidos no playground EVM.

Nossa memória foi atualizada para incluir a atribuição b[0] = 1, na terceira última linha de nossa memória um valor 0 se transformou em 1.

Você pode verificar se o valor está no local correto da memória, b[0] deve ocupar os locais 0x120 - 0x13f (bytes 289 - 320).

Image description

Aí está 🎉 , era muita informação para assimilar, mas agora temos uma sólida compreensão de como funciona a memória de contrato. Isso nos será bastante útil na próxima vez que precisarmos escrever algum código solidity.

Quando você estiver passando por alguns opcodes de contrato e vir certos locais de memória que continuam aparecendo (0x40), agora você saberá exatamente o que eles significam.

Até a próxima vez.

noxx

Twitter @noxx3xxon

Este artigo foi escrito por Noxx e traduzido por Fernando Gueller. O original pode ser lido aqui.

Latest comments (0)