¿Cómo funciona exactamente la pila de llamadas?

103

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 pila / montón aquí en Stack Overflow, y todas son brillantes. Pero todavía hay una cosa que aún no entendí completamente.

Considere esta función en pseudocódigo que tiende a ser un 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 supongo que la pila se verá 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 a las reglas 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, ¿qué sucede después de la línea X? Porque obviamente, lo siguiente que necesitamos es trabajar con ay b, pero eso significaría que el sistema operativo / CPU (?) Tiene que salir dy cprimero volver a ay b. Pero luego se dispararía en el pie, porque necesita cy den la siguiente línea.

Entonces, me pregunto qué sucede exactamente detrás de escena.

Otra pregunta relacionada. Considere 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 doSomethingesencialmente apuntan a la misma dirección de memoria como ay ben foo. Pero, de nuevo, esto significa que no hay una ventana emergente en la pila hasta que lleguemos ayb ocurra.

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

Christoph
fuente
14
LIFO solo importa para reservar espacio en la pila. Siempre puede acceder a cualquier variable que esté al menos en su marco de pila (declarada dentro de la función) incluso si está bajo muchas otras variables
VoidStar
2
En otras palabras, LIFOsignifica que puede agregar o eliminar elementos solo al final de la pila, y siempre puede leer / cambiar cualquier elemento.
HolyBlackCat
12
¿Por qué no desmonta una función simple después de compilar con -O0 y mira las instrucciones generadas? Es bonito, bueno, instructivo ;-). Verá que el código hace un buen uso de la parte R de la RAM; accede a las direcciones directamente a voluntad. Puede pensar en un nombre de variable como un desplazamiento de un registro de direcciones (el puntero de pila). Como dijeron los demás, la pila es solo LIFO con respecto al apilamiento (bueno para recursividad, etc.). No es LIFO con respecto al acceso. El acceso es completamente aleatorio.
Peter - Reincorpora a Monica
6
Puede crear su propia estructura de datos de pila usando una matriz y simplemente almacenando el índice del elemento superior, incrementándolo cuando empuja, disminuyéndolo cuando hace estallar. Si hiciera esto, aún podría acceder a cualquier elemento individual en la matriz en cualquier momento sin presionarlo ni abrirlo, como siempre puede hacerlo con las matrices. Aproximadamente lo mismo está sucediendo aquí.
Crowman
3
Básicamente, el nombre de pila / montón es desafortunado. Se parecen poco a apilar y amontonar en la terminología de las estructuras de datos, por lo que llamarlos de la misma manera es muy confuso.
Siyuan Ren

Respuestas:

117

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 :

Imagen de una pila

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 cinse mueve al registro EDI 2 y getse 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 cen EAX: EAX se mueve a ESI, coutse mueve a EDI y luego se llama al operador de inserción con couty csiendo los argumentos.

Finalmente,

  • El valor de retorno de mainse almacena en EAX: 0. Eso se debe a la returndeclaración implícita . También puede ver en xorl rax raxlugar de movl.
  • salir y volver al sitio de la llamada. leaveabrevia 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 retse 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-pointeren GCC y -Oyen 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.

Columbo
fuente
1
Gran respuesta. El problema con abordar los datos por compensaciones fue el bit que faltaba para mí :)
Christoph
1
Creo que hay un pequeño error en el dibujo. El puntero del marco debería estar en el otro lado de la dirección de retorno. Dejar una función generalmente se hace de la siguiente manera: mover el puntero de la pila al puntero del marco, sacar el puntero del marco de la persona que llama de la pila, regresar (es decir
sacar el
Kasperd tiene toda la razón. O no usa el puntero del marco en absoluto (optimización válida y particularmente para arquitecturas sin registros como x86 extremadamente útil) o lo usa y almacena el anterior en la pila, generalmente justo después de la dirección de retorno. La forma en que se configura y elimina el marco depende en gran medida de la arquitectura y la ABI. Hay bastantes arquitecturas (hola Itanium) donde todo es ... más interesante (¡y hay cosas como listas de argumentos de tamaño variable!)
Voo
3
@Christoph Creo que estás abordando esto desde un punto de vista conceptual. Aquí hay un comentario que, con suerte, aclarará esto: el RTS, o RunTime Stack, es un poco diferente de otras pilas, ya que es una "pila sucia"; en realidad, no hay nada que le impida ver un valor que no es ' t en la parte superior. Observe que en el diagrama, la "Dirección de retorno" para el método verde, que es necesaria para el método azul. está después de los parámetros. ¿Cómo obtiene el método azul el valor de retorno, después de que se abrió el marco anterior? Bueno, es una pila sucia, así que puede meter la mano y agarrarla.
Riking
1
En realidad, el puntero de marco no es necesario porque siempre se pueden usar desplazamientos del puntero de pila. GCC que apunta a arquitecturas x64 de forma predeterminada usa un puntero de pila y se libera rbppara hacer otro trabajo.
Siyuan Ren
27

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

En breve:

No es necesario hacer estallar los argumentos. Los argumentos pasados ​​por la persona fooque llama a la función doSomethingy las variables locales en se doSomething pueden hacer referencia como un desplazamiento del puntero base .
Entonces,

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

En detalle:

La regla es que cada llamada de función da como resultado la creación de un marco de pila (siendo el mínimo la dirección a la que volver). Entonces, si hay funcAllamadas funcBy funcBllamadas funcC, se configuran tres marcos de pila uno encima del otro. Cuando una función regresa, su marco deja de ser válido . Una función con buen comportamiento actúa solo en su propio marco de pila y no traspasa el de otra. En otras palabras, el POP se realiza en el marco de la pila en la parte superior (al regresar de la función).

ingrese la descripción de la imagen aquí

La pila de su pregunta la configura la persona que llama foo. Cuando doSomethingy doAnotherThingpara los llamados, entonces configurar su propia pila. La figura puede ayudarlo a comprender esto:

ingrese la descripción de la imagen aquí

Tenga en cuenta que, para acceder a los argumentos, el cuerpo de la función tendrá que recorrer hacia abajo (direcciones superiores) 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 deberá recorrer la pila (direcciones inferiores ) relativo a la ubicación donde se almacena la dirección de retorno. 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 (Base Pointer). Otro nombre para el mismo es puntero de marco. El compilador normalmente, como lo primero para el cuerpo de la función, empuja el valor actual de EBP a la pila y establece el EBP en el ESP actual. Esto significa que, una vez hecho esto, en cualquier parte del código de función, el argumento 1 es EBP + 8 de distancia (4 bytes para cada EBP de la persona que llama y la dirección de retorno), el argumento 2 es EBP + 12 (decimal) de distancia, variables locales están 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 ensamblaje para la función será (configurado por el destinatario 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:

haccks
fuente
1
Gracias por su respuesta. Además, los enlaces son realmente geniales y me ayudan a arrojar más luz sobre la interminable pregunta de cómo funcionan realmente las computadoras :)
Christoph
¿Qué quiere decir con "empuja el valor actual de EBP a la pila" y también el puntero de la pila se almacena en el registro o que también ocupa una posición en la pila ... estoy un poco confundido
Suraj Jain
¿Y no debería ser * [ebp + 8] no [ebp + 8].?
Suraj Jain
@Suraj Jain; ¿Sabes qué es EBPy ESP?
haccks
esp es un puntero de pila y ebp es un puntero de base. Si tengo algún conocimiento perdido, por favor corríjalo.
Suraj Jain
19

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

Pegaré algún ejemplo de "Pointers and Memory" de Nick Parlante. Creo que la situación es un poco más simple de lo que imaginaba.

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:

ingrese la descripción de la imagen aquí


fuente
2
Gran explicación visual. Busqué en Google y encontré el documento aquí: cslibrary.stanford.edu/102/PointersAndMemory.pdf ¡Documento realmente útil!
Christoph
7

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

Al llamar a una subrutina usando cualquiera de las convenciones, los parámetros se insertan en la pila antes de llamar a la rutina. El código de la rutina luego empuja el valor actual del puntero del marco a la pila, copia el valor actual del puntero de la pila al puntero del marco y resta del puntero de la pila el número de bytes usados ​​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 la pila, y se puede acceder a todos los parámetros que el llamador empujó en la pila en un desplazamiento positivo constante desde el puntero del marco.

La diferencia entre las dos convenciones radica en la forma en que manejan una salida de subrutina. En la convención de C, la función de retorno copia el puntero del marco al puntero de la pila [restaurándolo al valor que tenía justo después de que se empujó el puntero del marco anterior], saca el valor del puntero del marco anterior y realiza un retorno. Cualquier parámetro que la persona que llama haya introducido en la pila antes de la llamada permanecerá allí. En la convención de Pascal, después de sacar el puntero del marco antiguo, el procesador muestra la dirección de retorno de la función, agrega al puntero de la pila el número de bytes de parámetros empujados por la persona que llama y luego va a la dirección de retorno emergente. En el 68000 original era necesario utilizar una secuencia de 3 instrucciones para eliminar los parámetros de la persona que llama; los procesadores 8x86 y todos los 680x0 posteriores al original incluían una "ret N"

La convención de Pascal tiene la ventaja de guardar un poco de código en el lado de la persona que llama, ya que la persona que llama no tiene que actualizar el puntero de la pila después de una llamada a una función. Sin embargo, requiere que la función llamada sepa exactamente cuántos bytes de parámetros va a colocar la persona que llama en la pila. Es casi seguro que no enviar el número adecuado de parámetros a la pila antes de llamar a una función que usa la convención de Pascal provocará un bloqueo. Sin embargo, esto se compensa 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 la caja de herramientas de Macintosh usaban la convención de llamadas de Pascal.

La convención de llamadas de 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 (la persona que llama sabrá cuántos bytes de los parámetros presionó, y así podrá limpiarlos). Además, no es necesario realizar una limpieza de la pila después de cada llamada a una función. Si una rutina llama a cuatro funciones en secuencia, cada una de las cuales usó cuatro bytes de parámetros, puede, en lugar de usar un ADD SP,4después de cada llamada, usar una ADD SP,16después de la última llamada para limpiar los parámetros de las cuatro llamadas.

Hoy en día, las convenciones de llamadas descritas 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 se inserten en la pila; si un método puede usar registros para contener todos los parámetros y variables locales, no es necesario usar un puntero de marco y, por lo tanto, no es necesario guardar y restaurar el anterior. Aún así, a veces es necesario usar las convenciones de llamadas más antiguas al llamar a las bibliotecas que estaban vinculadas para usarlas.

Super gato
fuente
1
¡Guauu! ¿Puedo tomar prestado tu cerebro durante una semana más o menos? ¡Necesito extraer algunas cosas esenciales! ¡Gran respuesta!
Christoph
¿Dónde se almacenan el marco y el puntero de pila en la propia pila o en cualquier otro lugar?
Suraj Jain
@SurajJain: Normalmente, cada copia guardada del puntero del marco se almacenará con un desplazamiento fijo en relación con el nuevo valor del puntero del marco.
supergato
Señor, tengo esta duda desde hace mucho tiempo. Si en mi función escribo si (g==4)entonces int d = 3y gtomo entrada usando scanfdespués de eso, defino otra variable int h = 5. Ahora, ¿cómo da el compilador ahora d = 3espacio en la pila? ¿Cómo se hace el desplazamiento? Porque si gno es así 4, entonces no habría memoria para d en la pila y simplemente se le daría un desplazamiento a hy si g == 4entonces el desplazamiento será primero para gy luego para h. ¿Cómo lo hace el compilador en tiempo de compilación? No conoce nuestra entrada parag
Suraj Jain
@SurajJain: Las primeras versiones de C requerían que todas las variables automáticas dentro de una función aparezcan antes de cualquier declaración ejecutable. Relajando ligeramente esa compilación complicada, pero un enfoque es generar código al comienzo de una función que resta de SP el valor de una etiqueta declarada hacia adelante. Dentro de la función, el compilador puede, en cada punto del código, realizar un seguimiento de cuántos bytes de locales todavía están dentro del alcance, y también rastrear el número máximo de bytes de locales que alguna vez están dentro del alcance. Al final de la función, puede proporcionar el valor de la anterior ...
supercat
5

Ya hay algunas respuestas realmente buenas aquí. Sin embargo, si todavía le preocupa el comportamiento LIFO de la pila, considérelo 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 está operando solo 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 cadena de llamadas todavía están asignadas y disponibles. Pero no se accederá directamente a ellos. En cambio, 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 que se está ejecutando actualmente 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 la memoria estática o más abajo en la pila.

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

Puede resultar instructivo observar el ensamblaje de su pequeño programa, especialmente si lo compila sin optimización. Creo que verá que todo el acceso a la memoria en su función ocurre a través de un desplazamiento del puntero del marco de pila, que es la forma en que el compilador escribirá el código de la función. En el caso de un pase por referencia, verá instrucciones de acceso a memoria indirectas a través de un puntero que se almacena en algún desplazamiento del puntero del marco de pila.

Jeremy West
fuente
4

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

Detrás de escena, la máquina hace:

  • obtener "a" es igual a leer el valor del cuarto elemento debajo de la parte superior de la pila.
  • obtener "b" es igual a leer el valor del tercer elemento debajo de la parte superior de la pila.

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

hdante
fuente
1

Aquí hay un diagrama que creé para la pila de llamadas de C. Es más precisa y contemporánea que las versiones de imágenes de Google.

ingrese la descripción de la imagen aquí

Y correspondiente a la estructura exacta del diagrama anterior, aquí hay una depuración de notepad.exe x64 en Windows 7.

ingrese la descripción de la imagen aquí

Las direcciones bajas y las direcciones altas se intercambian, por lo que la pila asciende en este diagrama. El rojo indica el marco exactamente como en el primer diagrama (que usó rojo y negro, pero el negro ahora ha sido reutilizado); el negro es el espacio del hogar; el azul es la dirección de retorno, que es un desplazamiento de la función de llamada a la instrucción posterior a la llamada; naranja es la alineación y rosa es donde apunta el puntero de instrucción justo después de la llamada y antes de la primera instrucción. El valor de retorno de espacio de inicio + es el marco más pequeño permitido en Windows y, como se debe mantener la alineación rsp de 16 bytes justo al comienzo de la función llamada, esto siempre incluye una alineación de 8 bytes también.BaseThreadInitThunk y así.

Los marcos de función rojos describen lo que la función del destinatario lógicamente 'posee' + lee / modifica (puede modificar un parámetro pasado en la pila que era demasiado grande para pasar en un registro en -Ofast). Las líneas verdes delimitan el espacio que la función se asigna desde el principio hasta el final de la función.

Lewis Kelsey
fuente
RDI y otros argumentos de registro solo se derraman en la pila si compila en modo de depuración, y no hay garantía de que una compilación elija ese orden. Además, ¿por qué no se muestran los argumentos de pila en la parte superior del diagrama para la llamada de función más antigua? No hay una demarcación clara en su diagrama entre qué marco "posee" qué datos. (Un destinatario es dueño de sus argumentos de pila). Omitir los argumentos de la pila de la parte superior del diagrama hace que sea aún más difícil ver que los "parámetros que no se pueden pasar en los registros" están siempre justo encima de la dirección de retorno de cada función.
Peter Cordes
La salida de @PeterCordes goldbolt asm muestra clang y gcc callees empujando un parámetro pasado en un registro a la pila como comportamiento predeterminado, por lo que tiene una dirección. En gcc, el uso de registerdetrás del parámetro optimiza esto, pero pensaría que se optimizaría de todos modos, ya que la dirección nunca se toma dentro de la función. Arreglaré el marco superior; es cierto que debería haber puesto los puntos suspensivos en un marco en blanco separado. 'una llamada es propietaria de sus argumentos de pila', ¿qué incluye los que la persona que llama empuja si no se pueden pasar en los registros?
Lewis Kelsey
Sí, si compila con la optimización desactivada, el destinatario de la llamada lo derramará en alguna parte. Pero a diferencia de la posición de los argumentos de pila (y posiblemente el RBP guardado), no hay nada estandarizado sobre dónde. Re: callee posee sus argumentos de pila: sí, las funciones pueden modificar sus argumentos entrantes. Los registros que se derraman no son argumentos de pila. Los compiladores a veces hacen esto, pero IIRC a menudo desperdician espacio en la pila al usar espacio debajo de la dirección de retorno, incluso si nunca vuelven a leer el argumento. Si una persona que llama quiere hacer otra llamada con los mismos argumentos, para estar seguro, debe almacenar otra copia antes de repetir elcall
Peter Cordes
@PeterCordes Bueno, hice que los argumentos fueran parte de la pila de llamadas porque estaba demarcando los marcos de la pila en función de dónde apunta rbp. Algunos diagramas muestran esto como parte de la pila de llamadas (como lo hace el primer diagrama de esta pregunta) y otros lo muestran como parte de la pila de personas que llaman, pero tal vez tenga sentido hacerlos parte de la pila de llamadas como el alcance del parámetro no es accesible para la persona que llama en un código de nivel superior. Sí, parece registery las constoptimizaciones solo marcan la diferencia en -O0.
Lewis Kelsey
@PeterCordes Lo cambié. Sin embargo
Lewis Kelsey