Sugiro olhar para tudo o que está por trás de linhas simples de objetos de inicialização, métodos de chamada e passagem de parâmetros. Bem, é claro, usar essas informações na prática está subtraindo a pilha do método de chamada.
Isenção de responsabilidade
Antes de começar a história, recomendo fortemente que você leia o primeiro post sobre o
StructLayout , porque existe um exemplo que será usado neste artigo.
Todo o código por trás do nível superior é apresentado no modo de
depuração , é ele quem mostra a base conceitual. Além disso, todos os itens acima são considerados para uma plataforma de 32 bits. A otimização do JIT é um tópico separado e importante que não será considerado aqui.
Gostaria também de advertir que este artigo não contém material que deve ser usado em projetos reais.
Comece com a teoria
Qualquer código acaba se tornando um conjunto de comandos da máquina. O mais compreensível é sua representação na forma de instruções em linguagem Assembly que correspondem diretamente a uma (ou várias) instruções da máquina.
Antes de passar para um exemplo simples, sugiro que você se familiarize com o que é uma pilha de software.
A pilha de software é principalmente uma parte da memória usada como regra para armazenar vários tipos de dados (como regra, eles podem ser chamados de
dados temporários ). Também vale lembrar que a pilha cresce em direção a endereços mais baixos. Ou seja, quanto mais tarde o objeto for empurrado para a pilha, menor será o seu endereço.
Agora, vejamos o próximo trecho de código na linguagem Assembler (omiti algumas das chamadas inerentes ao modo de depuração).
C #:
public class StubClass { public static int StubMethod(int fromEcx, int fromEdx, int fromStack) { int local = 5; return local + fromEcx + fromEdx + fromStack; } public static void CallingMethod() { int local1 = 7, local2 = 8, local3 = 9; int result = StubMethod(local1, local2, local3); } }
Asm:
StubClass.StubMethod(Int32, Int32, Int32) 1: push ebp 2: mov ebp, esp 3: sub esp, 0x10 4: mov [ebp-0x4], ecx 5: mov [ebp-0x8], edx 6: xor edx, edx 7: mov [ebp-0xc], edx 8: xor edx, edx 9: mov [ebp-0x10], edx 10: nop 11: mov dword [ebp-0xc], 0x5 12: mov eax, [ebp-0xc] 13: add eax, [ebp-0x4] 14: add eax, [ebp-0x8] 15: add eax, [ebp+0x8] 16: mov [ebp-0x10], eax 17: mov eax, [ebp-0x10] 18: mov esp, ebp 19: pop ebp 20: ret 0x4 StubClass.CallingMethod() 1: push ebp 2: mov ebp, esp 3: sub esp, 0x14 4: xor eax, eax 5: mov [ebp-0x14], eax 6: xor edx, edx 7: mov [ebp-0xc], edx 8: xor edx, edx 9: mov [ebp-0x8], edx 10: xor edx, edx 11: mov [ebp-0x4], edx 12: xor edx, edx 13: mov [ebp-0x10], edx 14: nop 15: mov dword [ebp-0x4], 0x7 16: mov dword [ebp-0x8], 0x8 17: mov dword [ebp-0xc], 0x9 18: push dword [ebp-0xc] 19: mov ecx, [ebp-0x4] 20: mov edx, [ebp-0x8] 21: call StubClass.StubMethod(Int32, Int32, Int32) 22: mov [ebp-0x14], eax 23: mov eax, [ebp-0x14] 24: mov [ebp-0x10], eax 25: nop 26: mov esp, ebp 27: pop ebp 28: ret
A primeira coisa que você deve prestar atenção é nos registros e operações
EBP e
ESP com eles.
Um equívoco entre meus amigos é que o registro
EBP está de alguma forma relacionado a um ponteiro para o topo da pilha. Devo dizer que não é assim.
O registro
ESP é responsável pelo ponteiro para o topo da pilha. Assim, com cada
comando PUSH (coloca o valor no topo da pilha), o valor desse registro é decrementado (a pilha cresce em direção a endereços mais baixos) e, a cada operação
POP , é incrementada. O comando
CALL também envia o endereço de retorno para a pilha, diminuindo também o valor do registro
ESP . De fato, a alteração do registro
ESP não é realizada apenas quando essas instruções são executadas (por exemplo, quando chamadas de interrupção são executadas, o mesmo acontece quando as instruções
CALL são executadas).
Considere o StubMethod.
Na primeira linha, o conteúdo do registro
EBP é salvo (pressionado na pilha). Antes de retornar da função, esse valor será restaurado.
A segunda linha armazena o valor atual da parte superior do endereço da pilha (o valor do registro
ESP é inserido no
EBP ). Nesse caso, o registro
EBP é um tipo de zero no contexto da chamada atual. O endereçamento é realizado em relação a ele. Em seguida, movemos o topo da pilha para quantas posições precisarmos para armazenar variáveis e parâmetros locais (terceira linha). Algo como alocar memória para todas as necessidades locais.
Todos os itens acima são chamados de função de prólogo.
Depois disso, o acesso às variáveis na pilha ocorre através do
EBP armazenado, o que indica o local onde as variáveis desse método específico começam.
A seguir, é apresentada a inicialização das variáveis locais.
Lembrete sobre a
chamada rápida : o .net nativo usa a
convenção de chamada da chamada
rápida .
O contrato rege a localização e a ordem dos parâmetros passados para a função.
Com a
chamada rápida, o primeiro e o segundo parâmetros são passados pelos registradores
ECX e
EDX , respectivamente, e os parâmetros subsequentes são passados pela pilha.
Para métodos não estáticos, o primeiro parâmetro está implícito e contém o endereço do objeto no qual o método é chamado (endereça isso).
Nas linhas 4 e 5, os parâmetros que foram transmitidos pelos registradores (os 2 primeiros) são armazenados na pilha.
A seguir, é necessário limpar o espaço da pilha para variáveis locais e inicializar variáveis locais.
Vale lembrar que o resultado da função está no registro
EAX .
Nas linhas 12-16, as variáveis necessárias são adicionadas. Chamo sua atenção para a linha 15. Há uma chamada para o endereço, mais do que o início da pilha, ou seja, para a pilha do método anterior. Antes de chamar, o método de chamada envia o parâmetro para o topo da pilha. Aqui nós lemos. O resultado da adição é recuperado do registro
EAX e empurrado para a pilha. Como esse é o valor de retorno do StubMethod, ele é colocado novamente no
EAX . Obviamente, esses conjuntos absurdos de instruções são inerentes apenas ao modo de depuração, mas mostram como nosso código parece exatamente sem um otimizador inteligente que faça a maior parte do trabalho.
As linhas 18 e 19 restauram o
EBP anterior (o método de chamada) e o ponteiro para o topo da pilha (no momento em que o método foi chamado).
A última linha retorna. Sobre o valor 0x4 vou dizer um pouco menor.
Essa sequência de comandos é chamada de epílogo da função.
Agora vamos dar uma olhada no CallingMethod. Vamos direto para a linha 18. Aqui colocamos o terceiro parâmetro no topo da pilha. Observe que fazemos isso usando a instrução
PUSH , ou seja, o valor do
ESP é diminuído. Os outros 2 parâmetros são colocados nos registradores (
chamada rápida ). A seguir, é chamada o método StubMethod. Agora, lembre-se da instrução
RET 0x4 . A seguinte pergunta é possível aqui: o que é 0x4? Como mencionei acima, colocamos os parâmetros da função chamada na pilha. Mas agora não precisamos deles. 0x4 indica que o byte precisa ser limpo da pilha após a chamada da função. Como havia um parâmetro, você precisa limpar 4 bytes.
Aqui está uma imagem de pilha de amostra:

Portanto, se nos virarmos e vermos o que está no verso da pilha imediatamente após chamar o método, a primeira coisa que veremos é o
EBP empurrado para a pilha (na verdade, isso aconteceu na primeira linha do método atual). Em seguida, haverá um endereço de retorno que indica onde a execução continuará (usada pela instrução
RET ). E através desses campos, veremos os próprios parâmetros da função atual (a partir do 3º, os parâmetros são transmitidos pelos registros anteriores). E por trás deles está a pilha do próprio método de chamada!
Os primeiro e segundo campos mencionados explicam o deslocamento em + 0x8 ao se referir aos parâmetros.
Assim, os parâmetros devem estar no topo da pilha em uma ordem estritamente definida quando a função é chamada. Portanto, antes de chamar o método, cada parâmetro é enviado para a pilha.
Mas e se você não pressioná-los e a função ainda os aceitar?
Um pequeno exemplo
Portanto, todos os fatos declarados acima me fizeram ter um desejo irresistível de ler a pilha de um método que chamará minha função. O pensamento de que, literalmente, em uma posição do terceiro argumento (será o mais próximo da pilha do método de chamada), são os dados preciosos que eu quero tanto obter, não me deixaram dormir.
Portanto, para ler a pilha do método de chamada, preciso ir um pouco além dos parâmetros.
Ao se referir a parâmetros, o cálculo do endereço de um parâmetro é baseado apenas no fato de o método de chamada colocar todos eles na pilha.
Mas a passagem implícita pelo parâmetro
EDX (quem se importa - o
artigo anterior ) sugere que podemos superar o compilador em alguns casos.
A ferramenta que eu fiz isso é chamada StructLayoutAttribute (recursos no
primeiro artigo ). // Algum dia vou aprender algo diferente desse atributo, prometo.
Usamos a mesma técnica favorita com tipos de referência.
Ao mesmo tempo, se os métodos sobrepostos tiverem um número diferente de parâmetros, obtemos que o compilador não enviará os que precisamos para a pilha (como o imaginário, porque não sabe quais).
No entanto, o método realmente chamado (com o mesmo deslocamento de outro tipo) aborda os endereços positivos relativos à sua pilha, ou seja, aqueles em que planeja encontrar os parâmetros.
Mas lá ele não os encontra e começa a ler a pilha do método de chamada.
Código Spoiler using System; using System.Runtime.InteropServices; namespace Magic { public class StubClass { public StubClass(int id) { Id = id; } public int Id; } [StructLayout(LayoutKind.Explicit)] public class CustomStructWithLayout { [FieldOffset(0)] public Test1 Test1; [FieldOffset(0)] public Test2 Test2; } public class Test1 { public virtual void Useless(int skipFastcall1, int skipFastcall2, StubClass adressOnStack) { adressOnStack.Id = 189; } } public class Test2 { public virtual int Useless() { return 888; } } class Program { static void Main() { Test2 objectWithLayout = new CustomStructWithLayout { Test2 = new Test2(), Test1 = new Test1() }.Test2; StubClass adressOnStack = new StubClass(3); objectWithLayout.Useless(); Console.WriteLine($"MAGIC - {adressOnStack.Id}"); // MAGIC - 189 } } }
Eu não vou dar o código da linguagem assembler, está tudo bem claro lá, mas se você tiver perguntas, tentarei respondê-las nos comentários
Entendo perfeitamente que esse exemplo não pode ser usado na prática, mas, na minha opinião, pode ser muito útil para entender o esquema geral do trabalho.