(He hecho una idea general de todo el código en esta respuesta en caso de que quieras jugar con él)
Solo hice las cosas más básicas en ASM durante mi curso CS101 en 2003. Y nunca había "entendido" realmente cómo funcionan ASM y Stack hasta que me di cuenta de que todo es básicamente como programar en C o C ++ ... pero sin variables, parámetros y funciones locales. Probablemente no suene fácil todavía :) Déjame mostrarte (para asm x86 con sintaxis Intel ).
1. ¿Qué es la pila?
La pila suele ser una porción contigua de memoria asignada para cada subproceso antes de que comiencen. Puedes guardar allí lo que quieras. En términos de C ++ ( fragmento de código n. ° 1 ):
const int STACK_CAPACITY = 1000;
thread_local int stack[STACK_CAPACITY];
2. Parte superior e inferior de la pila
En principio, podría almacenar valores en celdas aleatorias de la stack
matriz ( fragmento # 2.1 ):
stack[333] = 123;
stack[517] = 456;
stack[555] = stack[333] + stack[517];
Pero imagínese lo difícil que sería recordar qué células de stack
ya están en uso y cuáles son "gratuitas". Es por eso que almacenamos nuevos valores en la pila uno al lado del otro.
Una cosa extraña acerca de la pila de asm (x86) es que agrega cosas allí comenzando con el último índice y se mueve a índices inferiores: pila [999], luego pila [998] y así sucesivamente ( fragmento # 2.2 ):
stack[999] = 123;
stack[998] = 456;
stack[997] = stack[999] + stack[998];
Y aún así (precaución, ahora estarás confundido) el nombre "oficial" de stack[999]
está al final de la pila .
La última celda utilizada ( stack[997]
en el ejemplo anterior) se denomina parte superior de la pila (consulte Dónde está la parte superior de la pila en x86 ).
3. Puntero de pila (SP)
Para el propósito de esta discusión, supongamos que los registros de CPU se representan como variables globales (consulte Registros de propósito general ).
int AX, BX, SP, BP, ...;
int main(){...}
Hay un registro de CPU (SP) especial que rastrea la parte superior de la pila. SP es un puntero (contiene una dirección de memoria como 0xAAAABBCC). Pero para los propósitos de esta publicación, lo usaré como un índice de matriz (0, 1, 2, ...).
Cuando se inicia un hilo, SP == STACK_CAPACITY
el programa y el sistema operativo lo modifican según sea necesario. La regla es que no puede escribir en las celdas de la pila más allá de la parte superior de la pila y cualquier índice menor que SP no es válido y no es seguro (debido a las interrupciones del sistema ), por lo que
primero disminuye SP y luego escribe un valor en la celda recién asignada.
Cuando desee insertar varios valores en la pila en una fila, puede reservar espacio para todos ellos por adelantado ( fragmento # 3 ):
SP -= 3;
stack[999] = 12;
stack[998] = 34;
stack[997] = stack[999] + stack[998];
Nota. Ahora puede ver por qué la asignación en la pila es tan rápida: es solo una disminución de un solo registro.
4. Variables locales
Echemos un vistazo a esta función simplista ( fragmento # 4.1 ):
int triple(int a) {
int result = a * 3;
return result;
}
y reescribirlo sin usar la variable local ( fragmento # 4.2 ):
int triple_noLocals(int a) {
SP -= 1;
stack[SP] = a * 3;
return stack[SP];
}
y vea cómo se llama ( fragmento # 4.3 ):
someVar = triple_noLocals(11);
SP += 1;
5. Push / pop
La adición de un nuevo elemento en la parte superior de la pila es una operación tan frecuente que las CPU tienen una instrucción especial para eso push
. Lo implementaremos así ( fragmento 5.1 ):
void push(int value) {
--SP;
stack[SP] = value;
}
Del mismo modo, tomando el elemento superior de la pila ( fragmento 5.2 ):
void pop(int& result) {
result = stack[SP];
++SP;
}
El patrón de uso común para push / pop está ahorrando algo de valor temporalmente. Digamos, tenemos algo útil en variable myVar
y por alguna razón necesitamos hacer cálculos que lo sobrescriban ( fragmento 5.3 ):
int myVar = ...;
push(myVar);
myVar += 10;
...
pop(myVar);
6. Parámetros de función
Ahora pasemos los parámetros usando la pila ( fragmento # 6 ):
int triple_noL_noParams() {
SP -= 1;
stack[SP] = stack[SP + 1] * 3;
return stack[SP];
}
int main(){
push(11);
assert(triple(11) == triple_noL_noParams());
SP += 2;
}
7. return
declaración
Devolvemos el valor en el registro AX ( fragmento # 7 ):
void triple_noL_noP_noReturn() {
SP -= 1;
stack[SP] = stack[SP + 1] * 3;
AX = stack[SP];
SP += 1;
}
void main(){
...
push(AX);
push(11);
triple_noL_noP_noReturn();
assert(triple(11) == AX);
SP += 1;
pop(AX);
...
}
8. Apilar el puntero de base (BP) (también conocido como puntero de marco ) y marco de pila
Tomemos una función más "avanzada" y la reescribamos en nuestro C ++ tipo asm ( fragmento # 8.1 ):
int myAlgo(int a, int b) {
int t1 = a * 3;
int t2 = b * 3;
return t1 - t2;
}
void myAlgo_noLPR() {
SP -= 2;
stack[SP + 1] = stack[SP + 2] * 3;
stack[SP] = stack[SP + 3] * 3;
AX = stack[SP + 1] - stack[SP];
SP += 2;
}
int main(){
push(AX);
push(22);
push(11);
myAlgo_noLPR();
assert(myAlgo(11, 22) == AX);
SP += 2;
pop(AX);
}
Ahora imagine que decidimos introducir una nueva variable local para almacenar el resultado allí antes de regresar, como lo hacemos en tripple
(fragmento # 4.1). El cuerpo de la función será ( fragmento # 8.2 ):
SP -= 3;
stack[SP + 2] = stack[SP + 3] * 3;
stack[SP + 1] = stack[SP + 4] * 3;
stack[SP] = stack[SP + 2] - stack[SP + 1];
AX = stack[SP];
SP += 3;
Verá, tuvimos que actualizar cada una de las referencias a los parámetros de función y las variables locales. Para evitar eso, necesitamos un índice de anclaje, que no cambia cuando la pila crece.
Crearemos el ancla justo después de la entrada de la función (antes de asignar espacio para los locales) guardando el tope actual (valor de SP) en el registro BP. Fragmento n.º 8.3 :
void myAlgo_noLPR_withAnchor() {
push(BP);
BP = SP;
SP -= 2;
stack[BP - 1] = stack[BP + 1] * 3;
stack[BP - 2] = stack[BP + 2] * 3;
AX = stack[BP - 1] - stack[BP - 2];
SP = BP;
pop(BP);
}
El segmento de la pila, que pertenece y tiene el control total de la función se llama marco de pila de la función . myAlgo_noLPR_withAnchor
El marco de pila de Eg es stack[996 .. 994]
(ambos idexes inclusive).
El marco comienza en el BP de la función (después de que lo hayamos actualizado dentro de la función) y dura hasta el siguiente marco de pila. Por tanto, los parámetros de la pila son parte del marco de pila de la persona que llama (consulte la nota 8a).
Notas:
8a. Wikipedia dice lo contrario sobre los parámetros, pero aquí me adhiero al manual del desarrollador de software de Intel , ver vol. 1, sección 6.2.4.1 Puntero de base de marco apilado y Figura 6-2 en la sección 6.3.2 Operación de LLAMADA y RET lejanos . Los parámetros de la función y el marco de pila son parte del registro de activación de la función (consulte El gen en los perílogos de la función ).
8b. las compensaciones positivas de BP apuntan a parámetros de función y las compensaciones negativas apuntan a variables locales. Eso es bastante útil para depurar
8c. stack[BP]
almacena la dirección del marco de pila anterior,stack[stack[BP]]
almacena el marco de pila anterior y así sucesivamente. Siguiendo esta cadena, puede descubrir marcos de todas las funciones en el programa, que aún no regresaron. Así es como los depuradores muestran que llama a la pila
8d. las primeras 3 instrucciones de myAlgo_noLPR_withAnchor
, donde configuramos el marco (guardar BP antiguo, actualizar BP, reservar espacio para locales) se llaman función prólogo
9. Convenciones de llamadas
En el fragmento 8.1, hemos enviado los parámetros myAlgo
de derecha a izquierda y devolvimos el resultado en AX
. También podríamos pasar los params de izquierda a derecha y regresar BX
. O pase los parámetros en BX y CX y regrese en AX. Obviamente, caller ( main()
) y la función llamada deben estar de acuerdo en dónde y en qué orden se almacenan todas estas cosas.
La convención de llamada es un conjunto de reglas sobre cómo se pasan los parámetros y se devuelve el resultado.
En el código anterior, usamos la convención de llamada cdecl :
- Los parámetros se pasan a la pila, con el primer argumento en la dirección más baja de la pila en el momento de la llamada (pulsado el último <...>). La persona que llama es responsable de quitar los parámetros de la pila después de la llamada.
- el valor de retorno se coloca en AX
- EBP y ESP deben ser preservados por la persona que llama (
myAlgo_noLPR_withAnchor
función en nuestro caso), de modo que la persona que llama ( main
función) puede confiar en que esos registros no han sido cambiados por una llamada.
- Todos los demás registros (EAX, <...>) pueden ser modificados libremente por el destinatario; si una persona que llama desea conservar un valor antes y después de la llamada a la función, debe guardar el valor en otro lugar (hacemos esto con AX)
(Fuente: ejemplo "cdecl de 32 bits" de la documentación de Stack Overflow; copyright 2016 de icktoofay y Peter Cordes ; con licencia CC BY-SA 3.0. Se puede encontrar un archivo del contenido completo de la documentación de Stack Overflow en archive.org, en el que este ejemplo está indexado por ID de tema 3261 y ID de ejemplo 11196.)
10. Llamadas a funciones
Ahora la parte más interesante. Al igual que los datos, el código ejecutable también se almacena en la memoria (sin ninguna relación con la memoria para la pila) y cada instrucción tiene una dirección.
Cuando no se le ordena lo contrario, la CPU ejecuta las instrucciones una tras otra, en el orden en que se almacenan en la memoria. Pero podemos ordenarle a la CPU que "salte" a otra ubicación en la memoria y ejecute instrucciones desde allí. En asm puede ser cualquier dirección, y en lenguajes de más alto nivel como C ++ solo puede saltar a direcciones marcadas con etiquetas ( hay soluciones pero no son bonitas, por decir lo menos).
Tomemos esta función ( fragmento # 10.1 ):
int myAlgo_withCalls(int a, int b) {
int t1 = triple(a);
int t2 = triple(b);
return t1 - t2;
}
Y en lugar de llamar a tripple
C ++, haga lo siguiente:
- copia
tripple
el código al principio del myAlgo
cuerpo
- en la
myAlgo
entrada saltar sobre tripple
el código congoto
- cuando necesitemos ejecutar
tripple
el código, guarde en la dirección de pila de la línea de código justo después de la tripple
llamada, para que podamos volver aquí más tarde y continuar la ejecución ( PUSH_ADDRESS
macro a continuación)
- saltar a la dirección de la primera línea (
tripple
función) y ejecutarla hasta el final (3. y 4. juntos son CALL
macro)
- al final de
tripple
(después de haber limpiado los locales), toma la dirección de retorno de la parte superior de la pila y salta allí ( RET
macro)
Debido a que no existe una manera fácil de saltar a una dirección de código particular en C ++, usaremos etiquetas para marcar los lugares de los saltos. No entraré en detalles sobre cómo funcionan las macros a continuación, solo créanme que hacen lo que digo que hacen ( fragmento # 10.2 ):
#define PUSH_ADDRESS(labelName) { \
void* tmpPointer; \
__asm{ mov [tmpPointer], offset labelName } \
push(reinterpret_cast<int>(tmpPointer)); \
}
#define TOKENPASTE(x, y) x ## y
#define TOKENPASTE2(x, y) TOKENPASTE(x, y)
#define LABEL_NAME(num) TOKENPASTE2(lbl_, num)
#define CALL_IMPL(funcLabelName, callId) \
PUSH_ADDRESS(LABEL_NAME(callId)); \
goto funcLabelName; \
LABEL_NAME(callId) :
#define CALL(funcLabelName) CALL_IMPL(funcLabelName, __LINE__)
#define RET() { \
int tmpInt; \
pop(tmpInt); \
void* tmpPointer = reinterpret_cast<void*>(tmpInt); \
__asm{ jmp tmpPointer } \
}
void myAlgo_asm() {
goto my_algo_start;
triple_label:
push(BP);
BP = SP;
SP -= 1;
stack[BP - 1] = stack[BP + 2] * 3;
AX = stack[BP - 1];
SP = BP;
pop(BP);
RET();
my_algo_start:
push(BP);
BP = SP;
SP -= 2;
push(AX);
push(stack[BP + 2]);
CALL(triple_label);
stack[BP - 1] = AX;
SP -= 1;
pop(AX);
push(AX);
push(stack[BP + 3]);
CALL(triple_label);
stack[BP - 2] = AX;
SP -= 1;
pop(AX);
AX = stack[BP - 1] - stack[BP - 2];
SP = BP;
pop(BP);
}
int main() {
push(AX);
push(22);
push(11);
push(7777);
myAlgo_asm();
assert(myAlgo_withCalls(11, 22) == AX);
SP += 1;
SP += 2;
pop(AX);
}
Notas:
10a. debido a que la dirección de retorno se almacena en la pila, en principio podemos cambiarla. Así es como funciona el ataque de aplastamiento de pilas
10b. las últimas 3 instrucciones al "final" de triple_label
(limpiar locales, restaurar BP antiguo, regresar) se denominan epílogo de la función
11. Montaje
Ahora veamos el asm real myAlgo_withCalls
. Para hacer eso en Visual Studio:
- establecer la plataforma de compilación en x86 ( no x86_64)
- tipo de compilación: depuración
- establecer un punto de interrupción en algún lugar dentro de myAlgo_withCalls
- ejecutar, y cuando la ejecución se detenga en el punto de interrupción, presione Ctrl + Alt + D
Una diferencia con nuestro C ++ tipo asm es que la pila de asm opera en bytes en lugar de ints. Entonces, para reservar espacio para uno int
, SP se reducirá en 4 bytes.
Aquí vamos ( fragmento # 11.1 , los números de línea en los comentarios son de la esencia ):
; 114: int myAlgo_withCalls(int a, int b) {
push ebp ; create stack frame
mov ebp,esp
; return address at (ebp + 4), `a` at (ebp + 8), `b` at (ebp + 12)
sub esp,0D8h ; reserve space for locals. Compiler can reserve more bytes then needed. 0D8h is hexadecimal == 216 decimal
push ebx ; cdecl requires to save all these registers
push esi
push edi
; fill all the space for local variables (from (ebp-0D8h) to (ebp)) with value 0CCCCCCCCh repeated 36h times (36h * 4 == 0D8h)
; see https://stackoverflow.com/q/3818856/264047
; I guess that's for ease of debugging, so that stack is filled with recognizable values
; 0CCCCCCCCh in binary is 110011001100...
lea edi,[ebp-0D8h]
mov ecx,36h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
; 115: int t1 = triple(a);
mov eax,dword ptr [ebp+8] ; push parameter `a` on the stack
push eax
call triple (01A13E8h)
add esp,4 ; clean up param
mov dword ptr [ebp-8],eax ; copy result from eax to `t1`
; 116: int t2 = triple(b);
mov eax,dword ptr [ebp+0Ch] ; push `b` (0Ch == 12)
push eax
call triple (01A13E8h)
add esp,4
mov dword ptr [ebp-14h],eax ; t2 = eax
mov eax,dword ptr [ebp-8] ; calculate and store result in eax
sub eax,dword ptr [ebp-14h]
pop edi ; restore registers
pop esi
pop ebx
add esp,0D8h ; check we didn't mess up esp or ebp. this is only for debug builds
cmp ebp,esp
call __RTC_CheckEsp (01A116Dh)
mov esp,ebp ; destroy frame
pop ebp
ret
Y asm para tripple
( fragmento # 11.2 ):
push ebp
mov ebp,esp
sub esp,0CCh
push ebx
push esi
push edi
lea edi,[ebp-0CCh]
mov ecx,33h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
imul eax,dword ptr [ebp+8],3
mov dword ptr [ebp-8],eax
mov eax,dword ptr [ebp-8]
pop edi
pop esi
pop ebx
mov esp,ebp
pop ebp
ret
Espero que, después de leer esta publicación, el ensamblaje no se vea tan críptico como antes :)
Aquí hay enlaces del cuerpo de la publicación y algunas lecturas adicionales:
- Eli Bendersky , donde la parte superior de la pila está en x86 : superior / inferior, push / pop, SP, marco de pila, convenciones de llamada
- Eli Bendersky , diseño de marco de pila en x86-64 - args pasando en x64, marco de pila, zona roja
- University of Mariland, Understanding the Stack : una introducción muy bien escrita a los conceptos de stack. (Es para MIPS (no x86) y en sintaxis GAS, pero esto es insignificante para el tema). Consulte otras notas sobre la programación MIPS ISA si está interesado.
- x86 Asm wikibook, registros de propósito general
- wikibook de desmontaje x86, The Stack
- wikilibro de desmontaje x86, funciones y marcos de pila
- Manuales para desarrolladores de software de Intel : esperaba que fuera realmente duro, pero sorprendentemente es bastante fácil de leer (aunque la cantidad de información es abrumadora)
- Jonathan de Boyne Pollard, The gen on function perilogues - prólogo / epílogo, marco de pila / registro de activación, zona roja