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;
}
}
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
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
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
A função interna acessa os argumentos passados para ela executando:
-
dup6
para acessarb
-
dup8
para acessara
\
-dup8
(e nãodup7
) é usado uma vez que a pilha cresceu em um desde odup6
.
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:
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 PUSH
ed (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;
}
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, 0
no 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);
}
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'}
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'}
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;
}
// ...
}
Existem muitos manipuladores FunctionType
. Estamos interessados em FunctionType::Kind::Internal
para 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;
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);
}
}
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
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());
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);
}
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çoaddUints
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
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 <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)