La pila de llamadas también podría denominarse pila de tramas.
Las cosas que se apilan después del principio LIFO no son las variables locales sino los marcos de pila completos ("llamadas") de las funciones que se llaman . Las variables locales se empujan y hacen estallar junto con esos fotogramas en la llamada función prólogo y epílogo , respectivamente.
Dentro del marco, el orden de las variables no está especificado en absoluto; Los compiladores "reordenan" las posiciones de las variables locales dentro de un marco de manera adecuada para optimizar su alineación, de modo que el procesador pueda recuperarlas lo más rápido posible. El hecho crucial es que el desplazamiento de las variables relativas a alguna dirección fija es constante durante toda la vida útil del marco , por lo que es suficiente tomar una dirección de anclaje, digamos, la dirección del propio marco, y trabajar con los desplazamientos de esa dirección para las variables. Dicha dirección de anclaje está realmente contenida en el llamado puntero base o marcoque se almacena en el registro EBP. Las compensaciones, por otro lado, se conocen claramente en el momento de la compilación y, por lo tanto, están codificadas en el código de máquina.
Este gráfico de Wikipedia muestra cómo se estructura la pila de llamadas típica como 1 :
Agregamos el desplazamiento de una variable a la que queremos acceder a la dirección contenida en el puntero del marco y obtenemos la dirección de nuestra variable. Dicho brevemente, el código simplemente accede a ellos directamente a través de constantes desplazamientos en tiempo de compilación desde el puntero base; Es aritmética de puntero simple.
Ejemplo
#include <iostream>
int main()
{
char c = std::cin.get();
std::cout << c;
}
gcc.godbolt.org nos da
main:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl std::cin, %edi
call std::basic_istream<char, std::char_traits<char> >::get()
movb %al, -1(%rbp)
movsbl -1(%rbp), %eax
movl %eax, %esi
movl std::cout, %edi
call [... the insertion operator for char, long thing... ]
movl $0, %eax
leave
ret
.. para main
. Dividí el código en tres subsecciones. El prólogo de la función consta de las tres primeras operaciones:
- El puntero de la base se inserta en la pila.
- El puntero de pila se guarda en el puntero base
- El puntero de la pila se resta para dejar espacio para las variables locales.
Luego cin
se mueve al registro EDI 2 y get
se llama; El valor de retorno está en EAX.
Hasta aquí todo bien. Ahora sucede lo interesante:
El byte de orden bajo de EAX, designado por el registro de 8 bits AL, se toma y se almacena en el byte inmediatamente después del puntero base : es decir -1(%rbp)
, el desplazamiento del puntero base es -1
. Este byte es nuestra variablec
. El desplazamiento es negativo porque la pila crece hacia abajo en x86. La siguiente operación se almacena c
en EAX: EAX se mueve a ESI, cout
se mueve a EDI y luego se llama al operador de inserción con cout
y c
siendo los argumentos.
Finalmente,
- El valor de retorno de
main
se almacena en EAX: 0. Eso se debe a la return
declaración implícita . También puede ver en xorl rax rax
lugar de movl
.
- salir y volver al sitio de la llamada.
leave
abrevia este epílogo e implícitamente
- Reemplaza el puntero de la pila con el puntero base y
- Saca el puntero de la base.
Después de esta operación y ret
se ha realizado, el marco se ha eliminado de manera efectiva, aunque el llamador aún tiene que limpiar los argumentos ya que estamos usando la convención de llamada cdecl. Otras convenciones, por ejemplo, stdcall, requieren que el destinatario de la llamada lo ordene, por ejemplo, pasando la cantidad de bytes a ret
.
Omisión del puntero de cuadro
También es posible no utilizar compensaciones desde el puntero base / marco, sino desde el puntero de pila (ESB). Esto hace que el registro EBP, que de otro modo contendría el valor del puntero del marco, esté disponible para uso arbitrario, pero puede hacer que la depuración sea imposible en algunas máquinas y se desactivará implícitamente para algunas funciones . Es particularmente útil cuando se compila para procesadores con pocos registros, incluido x86.
Esta optimización se conoce como FPO (omisión de puntero de trama) y se establece -fomit-frame-pointer
en GCC y -Oy
en Clang; tenga en cuenta que se activa implícitamente por cada nivel de optimización> 0 si y solo si la depuración aún es posible, ya que no tiene ningún costo aparte de eso. Para obtener más información, consulte aquí y aquí .
1 Como se señaló en los comentarios, el puntero del marco presumiblemente está destinado a apuntar a la dirección después de la dirección de retorno.
2 Tenga en cuenta que los registros que comienzan con R son las contrapartes de 64 bits de los que comienzan con E. EAX designa los cuatro bytes de orden inferior de RAX. Usé los nombres de los registros de 32 bits para mayor claridad.