O que acontece nos bastidores em C #: o básico de trabalhar com a pilha

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.

Source: https://habr.com/ru/post/pt427465/


All Articles