Propongo mirar las partes internas que están detrás de las líneas simples de inicialización de los objetos, los métodos de llamada y los parámetros de paso. Y, por supuesto, utilizaremos esta información en la práctica: restaremos la pila del método de llamada.
Descargo de responsabilidad
Antes de continuar con la historia, le recomiendo que lea la primera publicación sobre
StructLayout , hay un ejemplo que se utilizará en este artículo.
Todo el código detrás del de alto nivel se presenta para el modo de
depuración , ya que muestra la base conceptual. La optimización JIT es un gran tema separado que no se tratará aquí.
También me gustaría advertir que este artículo no contiene material que deba usarse en proyectos reales.
Primero - teoría
Cualquier código eventualmente se convierte en un conjunto de comandos de máquina. Lo más comprensible es su representación en forma de instrucciones en lenguaje ensamblador que corresponden directamente a una (o varias) instrucciones de máquina.
Antes de pasar a un ejemplo simple, propongo familiarizarse con la pila.
La pila es principalmente una porción de memoria que se usa, por regla general, para almacenar varios tipos de datos (generalmente se les puede llamar
datos temporales ). También vale la pena recordar que la pila crece hacia direcciones más pequeñas. Es decir, cuanto más tarde se coloque un objeto en la pila, menos dirección tendrá.
Ahora echemos un vistazo al siguiente fragmento de código en lenguaje ensamblador (he omitido algunas de las llamadas que son inherentes al modo de depuración).
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
Lo primero que debe notar es el
EBP y los registros
ESP y las operaciones con ellos.
Una idea errónea de que el registro
EBP está relacionado de alguna manera con el puntero a la parte superior de la pila es común entre mis amigos. Debo decir que no lo es.
El registro
ESP es responsable de apuntar a la parte superior de la pila. De manera correspondiente, con cada instrucción
PUSH (poniendo un valor en la parte superior de la pila) se disminuye el valor del registro
ESP (la pila crece hacia direcciones más pequeñas), y con cada instrucción
POP se incrementa. Además, el comando
CALL empuja la dirección de retorno en la pila, lo que disminuye el valor del registro
ESP . De hecho, el cambio del registro
ESP se realiza no solo cuando se ejecutan estas instrucciones (por ejemplo, cuando se realizan llamadas de interrupción, lo mismo sucede con las instrucciones
CALL ).
Considerará
StubMethod () .
En la primera línea, se guarda el contenido del registro
EBP (se coloca en una pila). Antes de regresar de una función, este valor será restaurado.
La segunda línea almacena el valor actual de la dirección de la parte superior de la pila (el valor del registro
ESP se mueve a
EBP ). A continuación, movemos la parte superior de la pila a tantas posiciones como sea necesario para almacenar variables y parámetros locales (tercera fila). Algo así como la asignación de memoria para todas las necesidades locales:
marco de pila . Al mismo tiempo, el registro
EBP es un punto de partida en el contexto de la llamada actual. El direccionamiento se basa en este valor.
Todo lo anterior se llama
el prólogo de la función .
Después de eso, se accede a las variables en la pila a través del registro
EBP almacenado, que apunta en el lugar donde comienzan las variables de este método. Luego viene la inicialización de variables locales.
Recordatorio de
llamada rápida : en .net, se utiliza la convención de llamada de llamada
rápida .
La convención de llamada gobierna la ubicación y el orden de los parámetros pasados a la función.
Los parámetros primero y segundo se pasan a través de los registros
ECX y
EDX , respectivamente, los parámetros posteriores se transmiten a través de la pila. (Esto es para sistemas de 32 bits, como siempre. En sistemas de 64 bits, cuatro parámetros pasaron a través de registros (
RCX ,
RDX ,
R8 ,
R9 ))
Para los métodos no estáticos, el primer parámetro es implícito y contiene la dirección de la instancia en la que se llama el método (esta dirección).
En las líneas 4 y 5, los parámetros que se pasaron a través de los registros (los primeros 2) se almacenan en la pila.
Lo siguiente es limpiar el espacio en la pila para las variables locales (
marco de la pila ) e inicializar las variables locales.
Cabe mencionar que el resultado de la función está en el registro
EAX .
En las líneas 12-16, se produce la adición de las variables deseadas. Llamo su atención a la línea 15. Hay un valor de acceso por la dirección que es mayor que el comienzo de la pila, es decir, a la pila del método anterior. Antes de llamar, la persona que llama empuja un parámetro a la parte superior de la pila. Aquí lo leemos. El resultado de la adición se obtiene del registro
EAX y se coloca en la pila. Como este es el valor de retorno de
StubMethod () , se vuelve a colocar en
EAX . Por supuesto, estos conjuntos de instrucciones absurdas son inherentes solo en el modo de depuración, pero muestran exactamente cómo se ve nuestro código sin un optimizador inteligente que hace la mayor parte del trabajo.
En las líneas 18 y 19, se restauran tanto el
EBP anterior (método de llamada) como el puntero a la parte superior de la pila (en el momento en que se llama al método). La última línea es el retorno de la función. Sobre el valor 0x4 lo contaré un poco más tarde.
Tal secuencia de comandos se llama epílogo de función.
Ahora echemos un vistazo a
CallingMethod () . Vayamos directamente a la línea 18. Aquí colocamos el tercer parámetro en la parte superior de la pila. Tenga en cuenta que hacemos esto utilizando la instrucción
PUSH , es decir, el valor
ESP se reduce. Los otros 2 parámetros se colocan en registros (
llamada rápida ). Luego viene la llamada al método
StubMethod () . Ahora recordemos la instrucción
RET 0x4 . Aquí es posible la siguiente pregunta: ¿qué es 0x4? Como mencioné anteriormente, hemos introducido los parámetros de la función llamada en la pila. Pero ahora no los necesitamos. 0x4 indica cuántos bytes deben borrarse de la pila después de la llamada a la función. Como el parámetro era uno, debe borrar 4 bytes.
Aquí hay una imagen aproximada de la pila:

Por lo tanto, si nos damos la vuelta y vemos qué hay en la pila justo después de la llamada al método, lo primero que veremos es
EBP , que se introdujo en la pila (de hecho, esto sucedió en la primera línea del método actual). Lo siguiente será la dirección del remitente. Determina el lugar, allí para reanudar la ejecución después de que nuestra función haya finalizado (utilizada por
RET ). Y justo después de estos campos veremos los parámetros de la función actual (a partir del 3er, los dos primeros parámetros se pasan a través de los registros). ¡Y detrás de ellos se esconde la pila del método de llamada!
Los campos primero y segundo mencionados anteriormente (
EBP y dirección de retorno) explican el desplazamiento en + 0x8 cuando accedemos a los parámetros.
En consecuencia, los parámetros deben estar en la parte superior de la pila en un orden estrictamente definido antes de la llamada a la función. Por lo tanto, antes de llamar al método, cada parámetro se inserta en la pila.
Pero, ¿qué pasa si no empujan y la función aún los tomará?
Pequeño ejemplo
Por lo tanto, todos los hechos anteriores me han provocado un deseo abrumador de leer la pila del método que llamará mi método. La idea de que solo estoy en una posición desde el tercer argumento (será la más cercana a la pila del método de llamada) son los datos apreciados que quiero recibir tanto, no me dejaron dormir.
Por lo tanto, para leer la pila del método de llamada, necesito subir un poco más allá de los parámetros.
Cuando se hace referencia a parámetros, el cálculo de la dirección de un parámetro particular se basa solo en el hecho de que la persona que llama los ha empujado a todos a la pila.
Pero el paso implícito a través del parámetro
EDX (quién está interesado -
artículo anterior ) me hace pensar que podemos ser más astutos que el compilador en algunos casos.
La herramienta que solía hacer esto se llama StructLayoutAttribute (todas las características están en
el primer artículo ). // Algún día aprenderé un poco más que solo este atributo, lo prometo
Utilizamos el mismo método favorito con tipos de referencia superpuestos.
Al mismo tiempo, si los métodos superpuestos tienen un número diferente de parámetros, el compilador no inserta los necesarios en la pila (al menos porque no sabe cuáles).
Sin embargo, el método que realmente se llama (con el mismo desplazamiento de un tipo diferente), se convierte en direcciones positivas en relación con su pila, es decir, aquellas en las que planea encontrar los parámetros.
Pero nadie pasa parámetros y el método comienza a leer la pila del método de llamada. Y la dirección del objeto (con la propiedad Id, que se usa en
WriteLine () ) está en el lugar, donde se espera el tercer parámetro.
El código está en el 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 } } }
No daré el código del lenguaje ensamblador, todo está bastante claro allí, pero si hay alguna pregunta, intentaré responderla en los comentarios
Entiendo perfectamente que este ejemplo no se puede usar en la práctica, pero en mi opinión, puede ser muy útil para comprender el esquema general de trabajo.