Sugiero mirar todo lo que se encuentra detrás de líneas simples de objetos de inicialización, métodos de llamada y parámetros de paso. Bueno, por supuesto, usar esta información en la práctica es restar la pila del método de llamada.
Descargo de responsabilidad
Antes de comenzar la historia, le recomiendo que lea la primera publicación sobre
StructLayout , porque Hay un ejemplo que se utilizará en este artículo.
Todo el código detrás del nivel superior se presenta para el modo de
depuración , es él quien muestra la base conceptual. Además, todo lo anterior se considera para una plataforma de 32 bits. La optimización JIT es un tema separado y grande que no se considerará aquí.
También me gustaría advertir que este artículo no contiene material que deba usarse en proyectos reales.
Comienza con la teoría
Cualquier código finalmente 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, le sugiero que se familiarice con lo que es una pila de software.
La pila de software es principalmente una pieza de memoria que se usa, como regla, para almacenar varios tipos de datos (como regla, pueden llamarse
datos temporales ). También vale la pena recordar que la pila crece hacia direcciones más bajas. Es decir, cuanto más tarde se inserte el objeto en la pila, menos será su dirección.
Ahora veamos el siguiente fragmento de código en lenguaje ensamblador (omití 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 a lo que debe prestar atención son los registros
EBP y
ESP y las operaciones con ellos.
Una idea errónea entre mis amigos es que el registro
EBP está relacionado de alguna manera con un puntero a la parte superior de la pila. Debo decir que esto no es así.
El registro
ESP es responsable del puntero a la parte superior de la pila. En consecuencia, con cada
comando PUSH (coloca el valor en la parte superior de la pila), el valor de este registro disminuye (la pila crece hacia direcciones más bajas), y con cada operación
POP se incrementa. El comando
CALL también empuja la dirección de retorno a la pila, disminuyendo así el valor del registro
ESP también. De hecho, cambiar el registro
ESP no solo se realiza cuando se ejecutan estas instrucciones (por ejemplo, cuando se ejecutan llamadas de interrupción, lo mismo sucede cuando se ejecutan las instrucciones
CALL ).
Considere StubMethod.
En la primera línea, el contenido del registro
EBP se guarda (se inserta en la pila). Antes de regresar de la función, este valor será restaurado.
La segunda línea almacena el valor actual de la parte superior de la dirección de la pila (el valor del registro
ESP se ingresa en
EBP ). En este caso, el registro
EBP es un tipo de cero en el contexto de la llamada actual. El direccionamiento se realiza en relación con él. A continuación, movemos la parte superior de la pila a tantas posiciones como sea necesario para almacenar variables y parámetros locales (tercera línea). Algo así como asignar memoria para todas las necesidades locales.
Todo lo anterior se llama función de prólogo.
Después de eso, el acceso a las variables en la pila ocurre a través del
EBP almacenado, que indica el lugar donde comienzan las variables de este método en particular.
Lo siguiente es la inicialización de variables locales.
Recordatorio sobre
fastcall : el .net nativo utiliza la
convención de llamadas
fastcall .
El acuerdo rige la ubicación y el orden de los parámetros pasados a la función.
Con
fastcall, los parámetros primero y segundo se pasan a través de los registros
ECX y
EDX , respectivamente, y los parámetros posteriores se pasan a través de la pila.
Para los métodos no estáticos, el primer parámetro es implícito y contiene la dirección del objeto en el que se llama el método (abordar esto).
En las líneas 4 y 5, los parámetros que se transmitieron a través de los registros (los primeros 2) se almacenan en la pila.
Lo siguiente es limpiar el espacio de la pila para variables locales e inicializar variables locales.
Vale la pena recordar que el resultado de la función está en el registro
EAX .
En las líneas 12-16, se agregan las variables necesarias. Llamo su atención a la línea 15. Hay una llamada a la dirección, más que al comienzo de la pila, es decir, a la pila del método anterior. Antes de llamar, el método de llamada empuja el parámetro a la parte superior de la pila. Aquí lo leemos. El resultado de la adición se extrae del registro
EAX y se empuja a la pila. Dado que este es el valor de retorno de StubMethod, se coloca nuevamente en
EAX . Por supuesto, tales conjuntos absurdos de instrucciones son inherentes solo al modo de depuración, pero muestran cómo se ve nuestro código exactamente sin un optimizador inteligente que hace la mayor parte del trabajo.
Las líneas 18 y 19 restauran el
EBP anterior (el método de llamada) y el puntero a la parte superior de la pila (en el momento en que se llamó al método).
La última línea vuelve. Sobre el valor 0x4 lo contaré un poco más bajo.
Esta secuencia de comandos se llama epílogo de la 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 ). Lo siguiente es la llamada al método StubMethod. Ahora recuerde la instrucción
RET 0x4 . La siguiente pregunta es posible aquí: ¿qué es 0x4? Como mencioné anteriormente, empujamos los parámetros de la función llamada a la pila. Pero ahora no los necesitamos. 0x4 indica que el byte necesita ser borrado de la pila después de la llamada a la función. Como había un parámetro, debe borrar 4 bytes.
Aquí hay una imagen de pila de muestra:

Por lo tanto, si damos la vuelta y vemos lo que se encuentra en la parte posterior de la pila inmediatamente después de la llamada al método, lo primero que veremos es el
EBP empujado a la pila (de hecho, esto sucedió en la primera línea del método actual). A continuación, habrá una dirección de retorno que indica dónde continuará la ejecución (utilizada por la instrucción
RET ). Y a través de estos campos veremos los parámetros mismos de la función actual (a partir del 3, los parámetros se transmiten a través de los registros anteriores). ¡Y detrás de ellos está la pila del método de llamada en sí!
Los campos primero y segundo mencionados explican el desplazamiento en + 0x8 cuando se refieren a los parámetros.
En consecuencia, los parámetros deben estar en la parte superior de la pila en un orden estrictamente definido cuando se llama 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 los presiona y la función aún los aceptará?
Un pequeño ejemplo
Entonces, todos los hechos mencionados anteriormente me hicieron tener un deseo irresistible de leer la pila de un método que llamará a mi función. La idea de que, literalmente, en una posición del tercer argumento (será la más cercana a la pila del método de llamada) están los datos atesorados que deseo tanto obtener, no me dejó dormir.
Por lo tanto, para leer la pila del método de llamada, necesito ir 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 se basa solo en el hecho de que el método de llamada los empujó a todos a la pila.
Pero el paso implícito a través del parámetro
EDX (a quién le importa, el
último artículo ) sugiere que podemos burlar al compilador en algunos casos.
La herramienta que hice esto se llama StructLayoutAttribute (características en el
primer artículo ). // Algún día aprenderé algo diferente a este atributo, lo prometo.
Usamos la misma técnica favorita con tipos de referencia.
Al mismo tiempo, si los métodos superpuestos tienen un número diferente de parámetros, obtenemos que el compilador no empujará los que necesitamos a la pila (como el imaginario, porque no sabe cuáles).
Sin embargo, el método que en realidad se llama (con el mismo desplazamiento de otro tipo) aborda las direcciones más en relación con su pila, es decir, aquellas en las que planea encontrar los parámetros.
Pero allí no los encuentra y comienza a leer la pila del método de llamada.
Código de 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 voy a dar el código de idioma del ensamblador, todo está bastante claro allí, pero si tiene preguntas, intentaré responderlas 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.