¿Cómo funciona exactamente el callstack?


Estoy tratando de obtener una comprensión más profunda de cómo funcionan las operaciones de bajo nivel de los lenguajes de programación y especialmente cómo interactúan con el sistema operativo/CPU. Probablemente he leído todas las respuestas en cada hilo relacionado con stack/heap aquí en Stack Overflow, y todas son brillantes. Pero todavía hay una cosa que no entendí completamente todavía.

Considere esta función en pseudo código que tiende a ser código Rust válido; -)

fn foo() {
    let a = 1;
    let b = 2;
    let c = 3;
    let d = 4;

    // line X

    doSomething(a, b);
    doAnotherThing(c, d);
}

Así es como asumo que la pila debe verse como en la línea X:

Stack

a +-------------+
  | 1           | 
b +-------------+     
  | 2           |  
c +-------------+
  | 3           | 
d +-------------+     
  | 4           | 
  +-------------+ 

Ahora, todo lo que he leído sobre cómo funciona la pila es que obedece estrictamente las reglas de LIFO (último en entrar, primero en salir). Al igual que un tipo de datos de pila en.NET, Java o cualquier otro lenguaje de programación.

Pero si ese es el caso, entonces ¿qué sucede después de la línea X? Porque, obviamente, lo siguiente que necesitamos es trabajar con a y b, pero eso significaría que el OS/CPU (?) tiene que salir d y c primero para volver a a y b. Pero entonces sería dispara a sí mismo en el pie, porque necesita c y d en la siguiente línea.

Entonces, me pregunto qué exactamente sucede detrás de las escenas?

Otra pregunta relacionada. Consideremos que pasamos una referencia a una de las otras funciones como esta:

fn foo() {
    let a = 1;
    let b = 2;
    let c = 3;
    let d = 4;

    // line X

    doSomething(&a, &b);
    doAnotherThing(c, d);
}

Por cómo entiendo las cosas, esto significaría que los parámetros en doSomething están esencialmente apuntando a la misma dirección de memoria como a y b en foo. Pero de nuevo esto significa que no hay pop arriba de la pila hasta llegar a a y b pasando.

Esos dos casos me hacen pensar que no he comprendido completamente cómo exactamente funciona la pila y cómo sigue estrictamente las reglas LIFO.

Author: legends2k, 2014-06-01

6 answers

La pila de llamadas también podría llamarse pila de marcos.
Las cosas que son apiladas 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 estallan junto con esos marcos en la llamada función prólogo y epílogo , respectivamente.

Dentro del marco el orden de las variables es completamente no especificado; Compiladores "reordenar" las posiciones de las variables locales dentro de un frame apropiadamente para optimizar su alineación para que el procesador pueda buscarlas lo más rápido posible. El hecho crucial es que el desplazamiento de las variables en relación con alguna dirección fija es constante a lo largo de la vida útil del marco - por lo que basta con tomar una dirección de anclaje, por ejemplo, la dirección del marco en sí, y trabajar con desplazamientos de esa dirección a las variables. Tal dirección de anclaje está realmente contenida en el llamado base o puntero de marco que se almacena en el registro EBP. Las compensaciones, por otro lado, se conocen claramente en tiempo de compilación y, por lo tanto, se codifican en el código máquina.

Este gráfico de Wikipedia muestra cómo se estructura la pila de llamadas típica1:

Imagen de una pila

Añadir el desplazamiento de una variable que queremos acceder a la dirección contenida en el puntero del marco y obtenemos la dirección de nuestro variable. Dicho en breve, el código simplemente accede a ellos directamente a través de compensaciones constantes de 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. La función prólogo consiste en las tres primeras operaciones:

  • El puntero base es empujado a la pila.
  • El puntero de pila se guarda en la base pointer
  • El puntero de pila se resta para hacer espacio para las variables locales.

Luego cin se mueve al registro EDI2 y se llama get; El valor devuelto está en EAX.

Hasta ahora 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 justo después del puntero base: Es decir -1(%rbp), el desplazamiento del puntero base es -1. Este byte es nuestra variable c. El desplazamiento es negativo porque la pila crece hacia abajo en x86. La siguiente operación 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 devuelto de main se almacena en EAX: 0. Esto se debe a la declaración implícita return. También puede ver xorl rax rax en lugar de movl.
  • salga y regrese al sitio de la llamada. leave es abreviar este epílogo e implícitamente
    • Reemplaza el puntero de pila con el puntero base y
    • Abre el puntero base.

Después de esta operación y ret se han realizado, el marco ha sido efectivamente popped, aunque la persona que llama todavía tiene que limpiar los argumentos como estamos utilizando la convención de llamadas cdecl. Otras convenciones, por ejemplo, stdcall, requieren que el destinatario ordene, por ejemplo, pasando la cantidad de bytes a ret.

Omisión del puntero de marco

También es posible no usar desplazamientos desde el puntero base/marco, sino desde el puntero de pila (ESB). Esto hace que el registro EBP que de otra manera contendría el valor del puntero de marco esté disponible para uso arbitrario, pero puede hacer que la depuración sea imposible en algunas máquinas, y estará desactivado implícitamente para algunas funciones. Es particularmente útil cuando se compila para procesadores con pocos registros, incluyendo x86.

Esta optimización se conoce como FPO (omisión del puntero de marco) y se establece por -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 es todavía posible, ya que no tiene ningún costo aparte de eso. Para más información, ver aquí y aquí.


1 Como se señaló en los comentarios, el puntero del marco es presumiblemente destinado a apuntar a la dirección después de la dirección del remitente.

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 bajo de RAX. Usé los nombres de los registros de 32 bits para mayor claridad.

 109
Author: Columbo,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-05-23 11:47:01

Porque obviamente, lo siguiente que necesitamos es trabajar con a y b, pero eso significaría que el sistema operativo/CPU (?) tiene que salir d y c primero para volver a a y b. Pero luego se dispararía en el pie porque necesita c y d en la siguiente línea.

En resumen:

No hay necesidad de pop los argumentos. Los argumentos pasados por caller foo a function doSomething y las variables locales en doSomething todos pueden ser referenciados como un desplazamiento desde el puntero base.
Así,

  • Cuando se realiza una llamada a una función, los argumentos de la función se empujan en la pila. Estos argumentos son referenciados por el puntero base.
  • Cuando la función regresa a su llamador, los argumentos de la función que regresa se extraen de la pila usando el método LIFO.

En detalle:

La regla es que cada llamada a una función resulta en la creación de una pila frame (siendo el mínimo la dirección a la que volver). Por lo tanto, si funcA llama a funcB y funcB llama a funcC, se configuran tres marcos de pila uno encima del otro. Cuando una función regresa, su marco se vuelve inválido. Una función de buen comportamiento actúa solo en su propio marco de pila y no traspasa el de otra. En otras palabras, el POPing se realiza en el marco de pila en la parte superior (al regresar de la función).

introduzca la descripción de la imagen aquí

La pila en su pregunta es configuración por persona que llama foo. Cuando se llama a doSomething y doAnotherThing, entonces configuran su propia pila. La figura puede ayudarle a entender esto:

introduzca la descripción de la imagen aquí

Tenga en cuenta que, para acceder a los argumentos, el cuerpo de la función tendrá que desplazarse hacia abajo (direcciones más altas) desde la ubicación donde se almacena la dirección de retorno, y para acceder a las variables locales, el cuerpo de la función tendrá que desplazarse hacia arriba por la pila (direcciones más bajas) almacenado. De hecho, el código típico generado por el compilador para la función hará exactamente esto. El compilador dedica un registro llamado EBP para esto (Puntero Base). Otro nombre para el mismo es puntero de marco. El compilador típicamente, como lo primero para el cuerpo de la función, empuja el valor EBP actual a la pila y establece el EBP al ESP actual. Esto significa que, una vez hecho esto, en cualquier parte del código de la función, el argumento 1 es EBP + 8 de distancia (4 bytes para cada EBP de la persona que llama y el retorno dirección), el argumento 2 es EBP + 12 (decimal) de distancia, las variables locales son EBP-4n de distancia.

.
.
.
[ebp - 4]  (1st local variable)
[ebp]      (old ebp value)
[ebp + 4]  (return address)
[ebp + 8]  (1st argument)
[ebp + 12] (2nd argument)
[ebp + 16] (3rd function argument) 

Eche un vistazo al siguiente código C para la formación del marco de pila de la función:

void MyFunction(int x, int y, int z)
{
     int a, int b, int c;
     ...
}

Cuando la persona que llama lo llama

MyFunction(10, 5, 2);  

Se generará el siguiente código

^
| call _MyFunction  ; Equivalent to: 
|                   ; push eip + 2
|                   ; jmp _MyFunction
| push 2            ; Push first argument  
| push 5            ; Push second argument  
| push 10           ; Push third argument  

Y el código de ensamblado para la función será (configurado por el llamante antes de regresar)

^
| _MyFunction:
|  sub esp, 12 ; sizeof(a) + sizeof(b) + sizeof(c)
|  ;x = [ebp + 8], y = [ebp + 12], z = [ebp + 16]
|  ;a = [ebp - 4] = [esp + 8], b = [ebp - 8] = [esp + 4], c = [ebp - 12] =   [esp]
|  mov ebp, esp
|  push ebp

Referencias:

 25
Author: haccks,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-05-23 12:09:39

Como otros señalaron, no hay necesidad de pop parámetros, hasta que salgan de alcance.

Voy a pegar un ejemplo de "Punteros y Memoria" de Nick Parlante. Creo que la situación es un poco más simple de lo que imaginaste.

Aquí está el código:

void X() 
{
  int a = 1;
  int b = 2;

  // T1
  Y(a);

  // T3
  Y(b);

  // T5
}

void Y(int p) 
{
  int q;
  q = p + 2;
  // T2 (first time through), T4 (second time through)
}

Los puntos en el tiempo T1, T2, etc. están marcados en el código y el estado de la memoria en ese momento se muestra en el dibujo:

introduzca la descripción de la imagen aquí

 18
Author: ,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2014-06-01 17:04:07

Diferentes procesadores y lenguajes utilizan unos pocos diseños de pila diferentes. Dos patrones tradicionales tanto en el 8x86 como en el 68000 se llaman la convención de llamada Pascal y la convención de llamada C; cada convención se maneja de la misma manera en ambos procesadores, excepto por los nombres de los registros. Cada uno utiliza dos registros para administrar la pila y las variables asociadas, llamados puntero de pila (SP o A7) y puntero de marco (BP o A6).

Cuando se llama a la subrutina usando cualquiera convención, los parámetros se empujan en la pila antes de llamar a la rutina. El código de la rutina luego empuja el valor actual del puntero de marco a la pila, copia el valor actual del puntero de pila al puntero de marco, y resta del puntero de pila el número de bytes utilizados por las variables locales [si las hay]. Una vez hecho esto, incluso si se introducen datos adicionales en la pila, todas las variables locales se almacenarán en variables con un desplazamiento negativo constante desde el puntero de pila, y todos los parámetros que fueron empujados en la pila por el llamador se puede acceder a un desplazamiento positivo constante desde el puntero de marco.

La diferencia entre las dos convenciones radica en la forma en que manejan una salida de subrutina. En la convención C, la función de retorno copia el puntero de marco al puntero de pila [restaurándolo al valor que tenía justo después de que se empujara el puntero de marco antiguo], muestra el valor del puntero de marco antiguo y realiza un retorno. Cualquier parámetros que el llamante había empujado en la pila antes de que la llamada permaneciera allí. En la convención Pascal, después de hacer estallar el viejo puntero de marco, el procesador hace estallar la dirección de retorno de la función, añade al puntero de la pila el número de bytes de parámetros empujados por el llamante, y luego va a la dirección de retorno popped. En el 68000 original fue necesario usar una secuencia de 3 instrucciones para eliminar los parámetros del llamante; el 8x86 y todos los procesadores 680x0 después del original incluyeron un " ret N" [o equivalente a 680x0] instrucción que añadiría N al puntero de pila al realizar un retorno.

La convención Pascal tiene la ventaja de guardar un poco de código en el lado del llamante, ya que el llamante no tiene que actualizar el puntero de la pila después de una llamada a la función. Requiere, sin embargo, que la función llamada sepa exactamente cuántos bytes de parámetros el llamante va a poner en la pila. No se puede empujar el número adecuado de parámetros en la pila antes de llamar una función que utiliza la convención Pascal está casi garantizada para causar un accidente. Esto se compensa, sin embargo, por el hecho de que un poco de código adicional dentro de cada método llamado guardará código en los lugares donde se llama al método. Por esa razón, la mayoría de las rutinas originales de Macintosh Toolbox usaban la convención de llamadas Pascal.

La convención de llamada C tiene la ventaja de permitir que las rutinas acepten un número variable de parámetros, y ser robustas incluso si una rutina no usa todos los parámetros que se pasan (el llamante sabrá cuántos bytes de parámetros envió, y por lo tanto será capaz de limpiarlos). Además, no es necesario realizar la limpieza de la pila después de cada llamada a la función. Si una rutina llama a cuatro funciones en secuencia, cada una de las cuales usa cuatro bytes de parámetros, puede instead en lugar de usar un ADD SP,4 después de cada llamada, usar un ADD SP,16 después de la última llamada para limpiar los parámetros de las cuatro llamadas.

Hoy en día el llamado descrito las convenciones se consideran algo anticuadas. Dado que los compiladores se han vuelto más eficientes en el uso de registros, es común que los métodos acepten algunos parámetros en los registros en lugar de requerir que todos los parámetros sean empujados a la pila; si un método puede usar registros para contener todos los parámetros y variables locales, no hay necesidad de usar un puntero de marco, y por lo tanto no hay necesidad de guardar y restaurar el antiguo. Aún así, a veces es necesario usar las convenciones de llamadas más antiguas al llamar bibliotecas que fueron enlazadas para usarlas.

 7
Author: supercat,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2014-06-02 15:51:38

Ya hay algunas respuestas realmente buenas aquí. Sin embargo, si todavía está preocupado por el comportamiento LIFO de la pila, piense en ella como una pila de marcos, en lugar de una pila de variables. Lo que quiero sugerir es que, aunque una función puede acceder a variables que no están en la parte superior de la pila, todavía solo está operando en el elemento en la parte superior de la pila: un solo marco de pila.

Por supuesto, hay excepciones a esto. Las variables locales de toda la llamada cadena todavía están asignados y disponibles. Pero no se accederá directamente a ellos. En su lugar, se pasan por referencia (o por puntero, que en realidad solo es diferente semánticamente). En este caso se puede acceder a una variable local de un marco de pila mucho más abajo. Pero incluso en este caso, la función actualmente en ejecución sigue operando solo con sus propios datos locales. Está accediendo a una referencia almacenada en su propio marco de pila, que puede ser una referencia a algo en el montón, en estático memoria, o más abajo en la pila.

Esta es la parte de la abstracción de la pila que hace que las funciones sean invocables en cualquier orden, y permite la recursión. El marco de la pila superior es el único objeto al que el código accede directamente. Cualquier otra cosa se accede indirectamente (a través de un puntero que vive en el marco de la pila superior).

Podría ser instructivo mirar el ensamblado de su pequeño programa, especialmente si compila sin optimización. Creo que verás que toda la memoria el acceso en su función ocurre a través de un desplazamiento desde el puntero del marco de pila, que es la forma en que el código para la función será escrito por el compilador. En el caso de un paso por referencia, verá instrucciones de acceso indirecto a la memoria a través de un puntero que se almacena en algún desplazamiento desde el puntero de marco de pila.

 4
Author: Jeremy West,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2014-06-02 11:29:40

La pila de llamadas no es en realidad una estructura de datos de pila. Detrás de las escenas, las computadoras que utilizamos son implementaciones de la arquitectura de la máquina de acceso aleatorio. Por lo tanto, a y b se puede acceder directamente.

Entre bastidores, la máquina hace:

  • get "a" es igual a leer el valor del cuarto elemento debajo de stack top.
  • get " b " es igual a leer el valor del tercer elemento debajo de la pila superior.

Http://en.wikipedia.org/wiki/Random-access_machine

 4
Author: hdante,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2014-06-04 21:03:20