Proponho olhar para os internos que estão por trás das linhas simples de inicialização dos objetos, métodos de chamada e passagem de parâmetros. E, é claro, usaremos essas informações na prática - subtrairemos a pilha do método de chamada.
Isenção de responsabilidade
Antes de prosseguir com a história, recomendo vivamente que leia o primeiro post sobre o
StructLayout ; existe um exemplo que será usado neste artigo.
Todo o código por trás do de alto nível é apresentado para o modo de
depuração , porque mostra a base conceitual. A otimização de JIT é um grande tópico separado que não será abordado aqui.
Gostaria também de advertir que este artigo não contém material que deve ser usado em projetos reais.
Primeira - teoria
Qualquer código eventualmente se torna 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, proponho me familiarizar com a pilha.
A pilha é principalmente um pedaço de memória usado, como regra, para armazenar vários tipos de dados (geralmente eles podem ser chamados de
dados temporais ). Também vale lembrar que a pilha cresce em direção a endereços menores. Ou seja, quanto mais tarde um objeto for colocado na pilha, menos endereço ele terá.
Agora, vamos dar uma olhada no próximo trecho de código na linguagem Assembly (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 a notar é o
EBP e o
ESP registram e operam com eles.
Um equívoco de que o registro
EBP esteja de alguma forma relacionado ao ponteiro para o topo da pilha é comum entre meus amigos. Devo dizer que não é.
O registro
ESP é responsável por apontar para o topo da pilha. Da mesma forma, com cada instrução
PUSH (colocando um valor no topo da pilha), o valor do registro
ESP é decrementado (a pilha cresce em direção a endereços menores) e, a cada instrução
POP , é incrementada. Além disso, o comando
CALL pressiona o endereço de retorno na pilha, diminuindo assim o valor do registro
ESP . De fato, a alteração do registro
ESP é realizada não apenas quando essas instruções são executadas (por exemplo, quando chamadas de interrupção são feitas, o mesmo ocorre com as instruções
CALL ).
Considerará
StubMethod () .
Na primeira linha, o conteúdo do registro
EBP é salvo (colocado em uma pilha). Antes de retornar de uma função, esse valor será restaurado.
A segunda linha armazena o valor atual do endereço da parte superior da pilha (o valor do registro
ESP é movido para
EBP ). Em seguida, movemos o topo da pilha para quantas posições precisarmos para armazenar variáveis e parâmetros locais (terceira linha). Algo como alocação de memória para todas as necessidades locais -
quadro de pilha . Ao mesmo tempo, o registro
EBP é um ponto de partida no contexto da chamada atual. O endereçamento é baseado nesse valor.
Todos os itens acima são chamados de
prólogo da função .
Depois disso, as variáveis na pilha são acessadas através do registro
EBP armazenado, que aponta para o local onde as variáveis deste método começam. Em seguida, vem a inicialização das variáveis locais.
Lembrete de chamada
rápida : em .net, a
convenção de chamada de chamada
rápida é usada.
A convenção de chamada controla o local e a ordem dos parâmetros passados para a função.
O primeiro e o segundo parâmetros são passados pelos registradores
ECX e
EDX , respectivamente, os parâmetros subsequentes são transmitidos pela pilha. (Isso é para sistemas de 32 bits, como sempre. Nos sistemas de 64 bits, quatro parâmetros passaram pelos registradores (
RCX ,
RDX ,
R8 ,
R9 ))
Para métodos não estáticos, o primeiro parâmetro está implícito e contém o endereço da instância na qual o método é chamado (este endereço).
Nas linhas 4 e 5, os parâmetros que foram passados pelos registradores (os 2 primeiros) são armazenados na pilha.
A seguir, é necessário limpar o espaço na pilha para variáveis locais (
quadro da pilha ) e inicializar variáveis locais.
Vale ressaltar que o resultado da função está no registro
EAX .
Nas linhas 12-16, ocorre a adição das variáveis desejadas. Chamo sua atenção para a linha 15. Há um valor de acesso pelo endereço que é maior que o início da pilha, ou seja, para a pilha do método anterior. Antes de ligar, o chamador envia um parâmetro para o topo da pilha. Aqui nós lemos. O resultado da adição é obtido no registro
EAX e colocado na 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 exatamente como nosso código se parece sem o otimizador inteligente que faz a maior parte do trabalho.
Nas linhas 18 e 19, o
EBP anterior (método de chamada) e o ponteiro para o topo da pilha são restaurados (no momento em que o método é chamado). A última linha é o retorno da função. Sobre o valor 0x4, falarei um pouco mais tarde.
Essa sequência de comandos é chamada epílogo de função.
Agora vamos dar uma olhada em
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 em registradores (
chamada rápida ). Em seguida, vem a chamada do método
StubMethod () . Agora vamos lembrar a instrução
RET 0x4 . Aqui a seguinte pergunta é possível: o que é 0x4? Como mencionei acima, colocamos os parâmetros da função chamada na pilha. Mas agora não precisamos deles. 0x4 indica quantos bytes precisam ser limpos da pilha após a chamada da função. Como o parâmetro era um, você precisa limpar 4 bytes.
Aqui está uma imagem aproximada da pilha:

Assim, se nos virarmos e vermos o que está na pilha logo após a chamada do método, a primeira coisa que veremos
EBP , que foi empurrada para a pilha (na verdade, isso aconteceu na primeira linha do método atual). A próxima coisa será o endereço de retorno. Ele determina o local para retomar a execução após a conclusão da nossa função (usada pelo
RET ). E logo após esses campos, veremos os parâmetros da função atual (a partir do terceiro, os dois primeiros parâmetros são passados pelos registradores). E atrás deles a pilha do método de chamada se esconde!
O primeiro e o segundo campos mencionados anteriormente (
EBP e endereço de retorno) explicam o deslocamento em + 0x8 quando acessamos parâmetros.
Correspondentemente, os parâmetros devem estar no topo da pilha em uma ordem estritamente definida antes da chamada da função. Portanto, antes de chamar o método, cada parâmetro é enviado para a pilha.
Mas e se eles não pressionarem, e a função ainda os levar?
Pequeno exemplo
Portanto, todos os fatos acima me causaram um desejo irresistível de ler a pilha do método que chamará meu método. A ideia de que estou apenas em uma posição do terceiro argumento (será o mais próximo da pilha do método de chamada) são os dados estimados que desejo receber tanto, 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 específico é baseado apenas no fato de que o chamador colocou todos eles na pilha.
Mas a passagem implícita pelo parâmetro
EDX (quem está interessado -
artigo anterior ) me faz pensar que podemos ser mais espertos que o compilador em alguns casos.
A ferramenta que eu costumava fazer isso é chamada StructLayoutAttribute (todos os recursos estão no
primeiro artigo ). // Um dia vou aprender um pouco mais do que apenas esse atributo, prometo
Usamos o mesmo método favorito com tipos de referência sobrepostos.
Ao mesmo tempo, se os métodos sobrepostos tiverem um número diferente de parâmetros, o compilador não enviará os necessários para a pilha (pelo menos porque não sabe quais).
No entanto, o método realmente chamado (com o mesmo deslocamento de um tipo diferente) se transforma em endereços positivos em relação à sua pilha, ou seja, aqueles em que planeja encontrar os parâmetros.
Mas ninguém passa parâmetros e o método começa a ler a pilha do método de chamada. E o endereço do objeto (com a propriedade Id, usada no
WriteLine () ) está no local, onde o terceiro parâmetro é esperado.
O código está no 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 } } }
Não vou dar o código da linguagem assembly, está tudo bem claro lá, mas se houver alguma dúvida, tentarei respondê-la 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.