WEB3DEV

Cover image for Por dentro do solc Parte 1: Convenções de chamada
Diogo Jorge
Diogo Jorge

Posted on

Por dentro do solc Parte 1: Convenções de chamada

Vamos explorar recursos e peculiaridades de convenções de chamada solc.

Este post é o primeiro de uma série de três sobre solc, o compilador Solidity. Nesta série, apresentamos aos leitores os recursos do compilador que carecem de documentação e fornecemos exemplos para destacar seu efeito no bytecode EVM gerado.

Esta primeira postagem se concentra nas convenções de chamada de função e revisaremos alguns componentes solc que traduzem o Solidity em bytecode.

Passando argumentos para funções internas

Para contratos compilados pelo solc, os primeiros quatro bytes de calldata em chamadas para funções externas denotam a função a ser executada e o restante serve como argumentos codificados em ABI para o callee (destinatário), que os decodifica antes de executar sua lógica.

As funções externas contêm chamadas para funções internas. De fato, na maioria dos contratos existem mais funções internas/privadas do que públicas. Então, como os parâmetros são passados ​​para as funções internas? Na maioria das linguagens Assembly (x86, arm, etc.), os parâmetros são realmente passados ​​por meio de registradores especiais, mas a EVM é uma máquina baseada em stack (pilha) que não possui registradores.

Vamos ilustrar com um exemplo simples:

// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.13;

contract StuffDoer {
    function doStuff(uint256 a, uint256 b, uint256 c, uint256 d) public returns (bool) {
        return _doStuff(a,b,c,d);
    }
    function _doStuff(uint256 a, uint256 b, uint256 c, uint256 d) internal returns (bool) {
        uint256 x;
        uint256 z;
        unchecked {
            x = a + b;
            z = c + d;
        }
        return true;
    }
}
Enter fullscreen mode Exit fullscreen mode

Ao chamar doStuff, também executamos a função interna _doStuff. Os parâmetros para _doStuff são passados ​​da função do chamador doStuff.

Vamos compilar este código para assembly da EVM:

~/.solc-select/artifacts/solc-0.8.13 — asm ~/Desktop/StuffDoer.sol
Enter fullscreen mode Exit fullscreen mode

Ao examinar este código simples com anotações em solc, podemos observar tanto a convenção de chamada quanto como o callee acessa os argumentos passados ​​para ele:

/* Empurrar endereço de retorno _doStuff(a,b,c,d) */
  push tag_11
    /* a */
  dup6
    /* b */
  dup6
    /* c */
  dup6
    /* d */
  dup6
    /* Empurrar _doStuff */
  push tag_12
    /* "Chamar" _doStuff(a,b,c,d) */
  jump // in
tag_11:
    /* Retornar _doStuff(a,b,c,d) */
  swap1
  pop
    /* Função doStuff(uint256 a, uint256 b, uint256 c, uint256 d) publica retorna (bool) {... */
  swap5
  swap4
  pop
  pop
  pop
  pop
  jump // out
    /* Função _doStuff(uint256 a, uint256 b, uint256 c, uint256 d) interna retorna (bool) {... */
tag_12:
    /* bool */
  push 0x00
    /* uint256 x */
  dup1
    /* uint256 z */
  push 0x00
    /* b */
  dup6
    /* a */
  dup8
    /* a + b */
  add
    /* x = a + b */
  swap2
  pop
    /* d */
  dup4
    /* c */
  dup6
    /* c + d */
  add
    /* z = c + d */
  swap1
  pop
    /* verdadeiro */
  0x01
    /* retorno verdadeiro */
  swap3
  pop
  pop
  pop
    /* function _doStuff(uint256 a, uint256 b, uint256 c, uint256 d) internal returns (bool) {... */
  swap5
  swap4
  pop
  pop
  pop
  pop
  jump // out
Enter fullscreen mode Exit fullscreen mode

Começando no topo, há quatro operações dup6 que colocam os parâmetros da função interna na pilha.

tag_11 contém o bloco de código que será executado quando a função interna retornar.

tag_12 é mais interessante porque é o ponto de entrada para _doStuff().

Lá podemos ver três 0x00's empurrados para a pilha (dois diretamente com push e um com um dup1):

  • A primeira é para o valor de retorno booleano da função.
  • O segundo e o terceiro inicializam os parametros x e y, respectivamente.

Neste ponto, a parte superior da pilha (ou seja, local para esta função) tem a seguinte aparência:

1: 0x00 // <- head
2: 0x00
3: 0x00
4: d
5: c
6: b
7: a
8: return address
Enter fullscreen mode Exit fullscreen mode

A função interna acessa os argumentos passados ​​para ela executando:

  • dup6 para acessar b
  • dup8 para acessar a

    \
    -dup8 (e não dup7) é usado uma vez que a pilha cresceu em um desde o dup6.

dup4 e dup6 são usados ​​mais tarde para d e c, respectivamente.

Vejamos o Gráfico de Fluxo de Controle (CFG) deste contrato em torno do bloco básico que representa _doStuff . Se você é novo em CFGs, observe que um bloco básico é uma sequência de instruções consecutivas em um programa – não tem nada a ver com um bloco da blockchain. No nosso caso, essas instruções são opcodes EVM:

Image description

A função _doStuff da CFG do contrato

Se fôssemos emular a pilha a partir do início deste bloco básico, encontraríamos acessos de índice "negativos" à pilha, às vezes chamados de "subfluxo de pilha". Na parte da CFG acima, isso inclui todas as quatro operações DUP6, e há mais em blocos subsequentes. Esses índices referem-se a itens que foram PUSHed (empurrados) para a pilha em um bloco básico anterior/recebido. Esta é a função interna acessando seus argumentos.

ExpressionCompiler

O código C++ que gera esse assembly é a classe ExpressionCompiler no módulo libsolidity. Ela compila uma Árvore Sintática Abstrata (AST) do Solidity, cuja raiz é uma Expression (expressão), em bytecode EVM.

O ExpressionCompiler irá “visitar” os nós da AST e tentar gerar bytecode EVM para cada nó a ser incluído no contrato compilado. A função visit é sobrecarregada para cada Expression. Aqui está parte da função visit para uma Expressão do tipo FunctionCall:

bool ExpressionCompiler::visit(FunctionCall const& _functionCall)
{
    auto functionCallKind = *_functionCall.annotation().kind;

    CompilerContext::LocationSetter locationSetter(m_context, _functionCall);
    if (functionCallKind == FunctionCallKind::TypeConversion)
    {
        solAssert(_functionCall.arguments().size() == 1, "");
        solAssert(_functionCall.names().empty(), "");
        auto const& expression = *_functionCall.arguments().front();
        auto const& targetType = *_functionCall.annotation().type;
        if (auto const* typeType = dynamic_cast<TypeType const*>(expression.annotation().type))
            if (auto const* addressType = dynamic_cast<AddressType const*>(&targetType))
            {
                auto const* contractType = dynamic_cast<ContractType const*>(typeType->actualType());
                solAssert(
                    contractType &&
                    contractType->contractDefinition().isLibrary() &&
                    addressType->stateMutability() == StateMutability::NonPayable,
                    ""
                );
                m_context.appendLibraryAddress(contractType->contractDefinition().fullyQualifiedName());
                return false;
            }
        acceptAndConvert(expression, targetType);
        return false;
    }
Enter fullscreen mode Exit fullscreen mode

Fonte

Este é, na verdade, um manipulador que gera código para uma expressãoTypeConversion, que em si é uma função. Por exemplo, a expressão do Solidity address(0) na verdade nada mais é do que uma função que recebe um argumento, 0no nosso caso, e retorna sua representação como um tipo address. O compilador acomoda isso anexando o bytecode EVM para a conversão, como vemos em acceptAndConvert, após determinar o targetType:

void ExpressionCompiler::acceptAndConvert(Expression const& _expression, Type const& _type, bool _cleanupNeeded)
{
    _expression.accept(*this);
    utils().convertType(*_expression.annotation().type, _type, _cleanupNeeded);
}
Enter fullscreen mode Exit fullscreen mode

Esta função chama a função CompilerUtils::convertType que anexa o bytecode EVM para conversão.

Este é apenas um manipulador para muitas FunctionCall‘s. Queremos descobrir o que está sendo anexado para nossa chamada _doStuff. Podemos fazer isso compilando nosso código-fonte para AST.

Existem várias maneiras de converter o código-fonte do Solidity em AST. Recomendamos que você use o solc oficial para obter sua representação AST, pois é sempre garantido estar atualizado com os recursos mais recentes da linguagem e é testado na prática constantemente para garantir a validade da saída.

No entanto, como nosso contrato é muito pequeno e estamos interessados ​​apenas em pequenas partes do AST, podemos usar uma ferramenta Python simples chamada python-solidity-parser. Aqui estão as partes relevantes da saída:

{'body':
 {'statements':
  [{'arguments':
   [{'name': 'a',
         'type': 'Identifier'},
        {'name': 'b',
         'type': 'Identifier'},
        {'name': 'c',
         'type': 'Identifier'},
        {'name': 'd',
         'type': 'Identifier'}],
    'expression': {'name': '_doStuff',
                   'type': 'Identifier'},
    'names': [],
    'type': 'FunctionCall'}],
  'type': 'Block'},
 'isConstructor': False,
 'isFallback': False,
 'isReceive': False,
 'modifiers': [],
 'name': 'doStuff',
 'parameters' : { ... }, # uma lista de parametros 
 'returnParameters' : { ... }, # uma lista de parametros de retorno
 'stateMutability': None,
 'type': 'FunctionDefinition',
 'visibility': 'public'}
Enter fullscreen mode Exit fullscreen mode

A parte do nó statements contém arguments para a função, bem como um item expression que é do tipo FunctionCall. Esta é a chamada para _doStuff de dentro do doStuff externo, expresso em AST.

Também podemos olhar para outras partes do AST que representam a definição de _doStuff:

{'body': {'statements': [ ... ], # todos statements dentro do _doStuff
  'type': 'Block'},
'isConstructor': False,
'isFallback': False,
'isReceive': False,
'modifiers': [],
'name': '_doStuff',
'parameters': {},
'returnParameters': {},
'stateMutability': None,
'type': 'FunctionDefinition',
'visibility': 'internal'}
Enter fullscreen mode Exit fullscreen mode

Podemos ver que o tipo é FunctionDefinition e visibility é internal . Isso significa que precisamos encontrar o manipulador em ExpressionCompiler que cuida de chamadas para funções internas:

{
    FunctionType const& function = *functionType;
    if (function.hasBoundFirstArgument())
        solAssert(
            function.kind() == FunctionType::Kind::DelegateCall ||
            function.kind() == FunctionType::Kind::Internal ||
            function.kind() == FunctionType::Kind::ArrayPush ||
            function.kind() == FunctionType::Kind::ArrayPop,
        "");
    switch (function.kind())
    {
    case FunctionType::Kind::Declaration:
        solAssert(false, "Tentativa de gerar código para chamar uma definição de função.");
        break;
    case FunctionType::Kind::Internal:
    {
        // Convenção de chamada: o chamador envia o endereço de retorno e os argumentos
        // Callee remove-os e empurra os valores de retorno

        evmasm::AssemblyItem returnLabel = m_context.pushNewTag();
        for (unsigned i = 0; i < arguments.size(); ++i)
            acceptAndConvert(*arguments[i], *function.parameterTypes()[i]);
        _functionCall.expression().accept(*this);

        unsigned parameterSize = CompilerUtils::sizeOnStack(function.parameterTypes());
        if (function.hasBoundFirstArgument())
        {
            // stack: arg2, ..., argn, label, arg1
            unsigned depth = parameterSize + 1;
            utils().moveIntoStack(depth, function.selfType()->sizeOnStack());
            parameterSize += function.selfType()->sizeOnStack();
        }

        if (m_context.runtimeContext())
            // Temos um contexto de tempo de execução, então precisamos da parte de criação.
            utils().rightShiftNumberOnStack(32);
        else
            // Extrai o tempo de execução.
            m_context << ((u256(1) << 32) - 1) << Instruction::AND;

        m_context.appendJump(evmasm::AssemblyItem::JumpType::IntoFunction);
        m_context << returnLabel;

        unsigned returnParametersSize = CompilerUtils::sizeOnStack(function.returnParameterTypes());
        // callee adiciona parâmetros de retorno, mas remove argumentos e rótulo de retorno
        m_context.adjustStackOffset(static_cast<int>(returnParametersSize - parameterSize) - 1);
        break;
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Fonte

Existem muitos manipuladores FunctionType. Estamos interessados ​​em FunctionType::Kind::Internalpara encontrar o bytecode que será gerado para nossa chamada. Lá vemos que o compilador enviará a tag de retorno primeiro, assim como vimos no código assembly, seguido pelos argumentos para o callee.

Usando X para Y

Também vemos alguns casos estranhos em que a pilha deve parecer diferente (pelo menos pelos comentários):

/// verdadeiro se a função for chamada como arg1.fun(arg2, ..., argn).
/// Isso é obtido por meio da diretiva "using for".
bool hasBoundFirstArgument = false;
Enter fullscreen mode Exit fullscreen mode

A palavra-chave de Solidity using X for Y permite que os desenvolvedores anexem as funções de uma biblioteca x para qualquer tipoy no contexto do contrato em que essas palavras-chave são usadas. Vamos tentar isso em nosso contrato:

// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.13;

library Math{
    function addUints(uint256 arg1 , uint256 arg2, uint256 arg3, uint256 arg4) internal pure returns(uint) {
        return arg1 + arg2 + arg3 + arg4;
    }
}

contract StuffDoer {
    using Math for uint256;

    function doStuff(uint256 a, uint256 b, uint256 c, uint256 d) external returns(uint) {
        return c.addUints(a, b, d);
    }
}
Enter fullscreen mode Exit fullscreen mode

Vemos as implicações do padrão using X for Y no código assembly:

tag_7:
    /* Empurrar uint para resultado de função externa */
  0x00
    /* Empurrar etiqueta de retorno */
  tag_11
    /*  a */
  dup6
    /*  b */
  dup6
    /*  d */
  dup5
    /* c */
  dup7
    /* Empurrar endereço de c.aaddUints */
  tag_12
  swap1
    /* Retornar etiqueta */
  swap4
  swap3
  swap2
  swap1
  0xffffffff
  and
  jump // in
Enter fullscreen mode Exit fullscreen mode

Como o primeiro argumento da função é “delimitado”, ele aparece na pilha antes do rótulo de retorno, não na ordem da assinatura do callee. d é passado antes dele, mesmo que apareça como o último argumento para addUints.

Os swaps que vemos no final são o resultado da tentativa do compilador de executar:

utils().moveIntoStack(depth, function.selfType()->sizeOnStack());
Enter fullscreen mode Exit fullscreen mode

Isso aciona uma rotação da pilha para mover o argumento c além de todos os outros argumentos que são passados ​​para addUints:

void CompilerUtils::moveIntoStack(unsigned _stackDepth, unsigned _itemSize)
{
    if (_stackDepth <= _itemSize)
        for (unsigned i = 0; i < _stackDepth; ++i)
            rotateStackDown(_stackDepth + _itemSize);
    else
        for (unsigned i = 0; i < _itemSize; ++i)
            rotateStackUp(_stackDepth + _itemSize);
}

void CompilerUtils::rotateStackUp(unsigned _items)
{
    assertThrow(
        _items - 1 <= 16,
        StackTooDeepError,
        util::stackTooDeepString
    );
    for (unsigned i = 1; i < _items; ++i)
        m_context << swapInstruction(_items - i);
}

void CompilerUtils::rotateStackDown(unsigned _items)
{
    assertThrow(
        _items - 1 <= 16,
        StackTooDeepError,
        util::stackTooDeepString
    );
    for (unsigned i = 1; i < _items; ++i)
        m_context << swapInstruction(i);
}
Enter fullscreen mode Exit fullscreen mode

Como podemos ver, várias instruções push são adicionadas para esse propósito.

Podemos demonstrar isso em evm.codes com as seguintes atribuições:

  • 0x42424242 - etiqueta de retorno
  • 0x43434343—endereço addUints
  • 1 —a
  • 2—b
  • 3 —c
  • 4 —d

Aqui está a pilha no final da execução (logo antes de chamar addUints):

1: 0x43434343 // <- head
2: 4
3: 2
4: 1
5: 3
6: 0x42424242
Enter fullscreen mode Exit fullscreen mode

Com certeza,3 (c) está logo antes da etiqueta de retorno.

Conclusão

Nesta postagem, abordamos as convenções de chamada e exploramos como o compilador transforma o código-fonte original em AST para gerar o bytecode EVM que será implantado ou executado na cadeia.

Também estabelecemos como funções internal são chamadas. Isso é útil se você lê muitobytecode EVM compilado pelo solc e está procurando padrões que indiquem o que um bloco específico de código faz, ou se você está descompilando contratos e escrevendo ferramentas que tentam determinar como pode ser o código Solidity associado a algum bytecode.

Claro, essa é apenas uma prévia: A função visit sozinha tem mais de 800 linhas de código!

Além das chamadas internas, ela também lida com chamadas para pré-compilações, criações de objetos (new &lt;Type>), e muito mais! Certamente vale a pena explorar mais se você estiver interessado em compilação.

Definitivamente, encorajamos os leitores a dar uma olhada e aprimorar sua compreensão do código gerado pelo compilador.

Obrigada a Yoav Weiss e os membros da equipe smlXL que ofereceram conselhos e feedback para esta postagem.

Responda a esta postagem se tiver alguma dúvida e Tweete para nós se você tem um tópico que gostaria que abordássemos.

Estamos contratando!

Este artigo foi escrito por Tal e traduzido por Diogo Jorge. O artigo original pode ser encontrado [aqui]

Latest comments (0)