Como funciona exatamente a pilha de chamadas?

103

Estou tentando obter uma compreensão mais profunda de como as operações de baixo nível das linguagens de programação funcionam e, especialmente, como elas interagem com o SO / CPU. Provavelmente li todas as respostas em todos os tópicos relacionados a pilha / heap aqui no Stack Overflow, e todas são brilhantes. Mas ainda há uma coisa que eu ainda não entendi totalmente.

Considere esta função em pseudo código que tende a ser um 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);
}

É assim que presumo que a pilha se pareça na linha X:

Stack

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

Agora, tudo que li sobre como a pilha funciona é que ela obedece estritamente às regras LIFO (último a entrar, primeiro a sair). Assim como um tipo de dados de pilha em .NET, Java ou qualquer outra linguagem de programação.

Mas se for esse o caso, o que acontece depois da linha X? Porque, obviamente, a próxima coisa que precisamos é trabalhar com ae b, mas isso significaria que o SO / CPU (?) Tem que sair de cprimeiro voltar para aeb . Mas aí ia dar um tiro no pé, porque precisa ce dna próxima linha.

Então, eu me pergunto o que exatamente acontece nos bastidores?

Outra questão relacionada. Considere que passamos uma referência a uma das outras funções como esta:

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

    // line X

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

Pelo que entendi, isso significaria que os parâmetros em doSomethingestão essencialmente apontando para o mesmo endereço de memória como ae bem foo. Mas, novamente, isso significa que não há pop-up na pilha até chegarmos aa eb acontecer.

Esses dois casos me fazem pensar que não entendi totalmente como exatamente a pilha funciona e como segue estritamente as regras LIFO .

Christoph
fonte
14
LIFO só importa para reservar espaço na pilha. Você sempre pode acessar qualquer variável que esteja pelo menos em sua estrutura de pilha (declarada dentro da função), mesmo se estiver sob muitas outras variáveis
VoidStar 01 de
2
Em outras palavras, LIFOsignifica que você pode adicionar ou remover elementos apenas no final da pilha, e você sempre pode ler / alterar qualquer elemento.
HolyBlackCat
12
Por que você não desmonta uma função simples após compilar com -O0 e olha as instruções geradas? É muito, bem, instrutivo ;-). Você descobrirá que o código faz bom uso da parte R da RAM; ele acessa endereços diretamente à vontade. Você pode pensar em um nome de variável como um deslocamento para um registrador de endereço (o ponteiro da pilha). Como os outros disseram, a pilha é apenas UEPS em relação ao empilhamento (boa para recursão etc.). Não é UEPS com relação a acessá-lo. O acesso é totalmente aleatório.
Peter - Reintegrar Monica em
6
Você pode fazer sua própria estrutura de dados de pilha usando um array e apenas armazenando o índice do elemento superior, incrementando-o quando você empurra, diminuindo-o quando você abre. Se você fizesse isso, ainda seria capaz de acessar qualquer elemento individual no array a qualquer momento sem empurrá-lo ou removê-lo, assim como você sempre pode fazer com arrays. Aproximadamente a mesma coisa está acontecendo aqui.
Crowman 01 de
3
Basicamente, a nomenclatura de pilha / heap é lamentável. Eles têm pouca semelhança com empilhar e empilhar na terminologia das estruturas de dados, portanto, chamá-los da mesma forma é muito confuso.
Siyuan Ren

Respostas:

117

A pilha de chamadas também pode ser chamada de pilha de quadros.
As coisas que são empilhadas após o princípio LIFO não são as variáveis ​​locais, mas todos os quadros de pilha ("chamadas") das funções que estão sendo chamadas . As variáveis ​​locais são enviadas e colocadas juntas com esses quadros no chamado prólogo e epílogo da função , respectivamente.

Dentro do quadro, a ordem das variáveis ​​é completamente não especificada; Os compiladores "reordenam" as posições das variáveis ​​locais dentro de um quadro de maneira apropriada para otimizar seu alinhamento, de forma que o processador possa buscá-las o mais rápido possível. O fato crucial é que o deslocamento das variáveis ​​em relação a algum endereço fixo é constante ao longo da vida útil do quadro - portanto, é suficiente pegar um endereço de âncora, digamos, o endereço do próprio quadro e trabalhar com deslocamentos desse endereço para as variáveis. Tal endereço de âncora está realmente contido na chamada base ou ponteiro de quadroarmazenado no registro EBP. Os offsets, por outro lado, são claramente conhecidos em tempo de compilação e, portanto, são codificados no código de máquina.

Este gráfico da Wikipedia mostra como a pilha de chamadas típica é estruturada como 1 :

Imagem de uma pilha

Adicione o deslocamento de uma variável que queremos acessar ao endereço contido no ponteiro do quadro e obteremos o endereço de nossa variável. Resumindo, o código apenas os acessa diretamente por meio de deslocamentos de tempo de compilação constantes do ponteiro base; É simples aritmética de ponteiro.

Exemplo

#include <iostream>

int main()
{
    char c = std::cin.get();
    std::cout << c;
}

gcc.godbolt.org nos dá

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. Eu dividi o código em três subseções. O prólogo da função consiste nas três primeiras operações:

  • O ponteiro da base é colocado na pilha.
  • O ponteiro da pilha é salvo no ponteiro base
  • O ponteiro da pilha é subtraído para abrir espaço para variáveis ​​locais.

Em seguida, ciné movido para o registrador EDI 2 e geté chamado; O valor de retorno está em EAX.

Por enquanto, tudo bem. Agora o interessante acontece:

O byte de ordem inferior de EAX, designado pelo registrador de 8 bits AL, é obtido e armazenado no byte logo após o ponteiro de base : Ou seja -1(%rbp), o deslocamento do ponteiro de base é -1. Este byte é nossa variávelc . O deslocamento é negativo porque a pilha cresce para baixo em x86. A próxima operação armazena cem EAX: EAX é movido para ESI, couté movido para EDI e então o operador de inserção é chamado com coute csendo os argumentos.

Finalmente,

  • O valor de retorno de mainé armazenado em EAX: 0. Isso se deve à returndeclaração implícita . Você também pode ver em xorl rax raxvez de movl.
  • sair e voltar ao site da chamada. leaveestá abreviando este epílogo e implicitamente
    • Substitui o ponteiro da pilha pelo ponteiro da base e
    • Abre o ponteiro da base.

Após esta operação e retter sido executada, o quadro foi efetivamente exibido, embora o chamador ainda tenha que limpar os argumentos, pois estamos usando a convenção de chamada cdecl. Outras convenções, por exemplo, stdcall, exigem que o receptor faça a limpeza, por exemplo, passando a quantidade de bytes para ret.

Omissão do Frame Pointer

Também é possível não usar deslocamentos do ponteiro de base / quadro, mas sim do ponteiro de pilha (ESB). Isso torna o registrador EBP que, de outra forma, conteria o valor do ponteiro do quadro disponível para uso arbitrário - mas pode tornar a depuração impossível em algumas máquinas e será implicitamente desativado para algumas funções . É particularmente útil ao compilar para processadores com apenas alguns registros, incluindo x86.

Essa otimização é conhecida como FPO (omissão de ponteiro de quadro) e definida por -fomit-frame-pointerno GCC e -Oyno Clang; observe que ele é acionado implicitamente por cada nível de otimização> 0 se e somente se a depuração ainda for possível, uma vez que não tem nenhum custo além disso. Para mais informações, clique aqui e aqui .


1 Conforme apontado nos comentários, o ponteiro do frame deve apontar para o endereço após o endereço do remetente.

2 Observe que os registros que começam com R são as contrapartes de 64 bits daqueles que começam com E. EAX designa os quatro bytes de ordem inferior de RAX. Usei os nomes dos registradores de 32 bits para maior clareza.

Columbo
fonte
1
Ótima resposta. O problema com o endereçamento de dados por deslocamentos era a parte que faltava para mim :)
Christoph
1
Acho que há um pequeno erro no desenho. O ponteiro do quadro deve estar do outro lado do endereço do remetente. Abandonar uma função normalmente é feito da seguinte maneira: mover o ponteiro da pilha para o ponteiro do quadro, retirar o ponteiro do quadro do chamador da pilha, retornar (ou seja
retirar o
Kasperd está absolutamente certo. Você não usa o ponteiro do frame de forma alguma (otimização válida e particularmente para arquiteturas sem registro, como o x86 extremamente útil) ou você o usa e armazena o anterior na pilha - geralmente logo após o endereço de retorno. Como o quadro é configurado e removido depende muito da arquitetura e da ABI. Existem algumas arquiteturas (olá Itanium) onde a coisa toda é ... mais interessante (e há coisas como listas de argumentos de tamanho variável!)
Voo
3
@Christoph Acho que você está abordando isso de um ponto de vista conceitual. Aqui está um comentário que esperamos esclarecer isso - O RTS, ou Pilha de tempo de execução, é um pouco diferente de outras pilhas, pois é uma "pilha suja" - não há realmente nada que o impeça de olhar para um valor que não é t no topo. Observe que no diagrama, o "Endereço de Retorno" para o método verde - que é necessário para o método azul! está após os parâmetros. Como o método blue obtém o valor de retorno, depois que o quadro anterior foi exibido? Bem, é uma pilha suja, então pode simplesmente alcançá-la e agarrá-la.
Riking
1
O ponteiro do quadro não é realmente necessário porque sempre se pode usar deslocamentos do ponteiro da pilha. O GCC visando arquiteturas x64 por padrão usa o ponteiro de pilha e libera rbppara fazer outro trabalho.
Siyuan Ren
27

Porque, obviamente, a próxima coisa de que precisamos é trabalhar com aeb, mas isso significaria que o SO / CPU (?) Tem que sair de d e c primeiro para voltar a a e b. Mas então ele atiraria no próprio pé porque precisa de ced na próxima linha.

Em resumo:

Não há necessidade de apresentar argumentos. Os argumentos passados ​​pelo chamador foopara a função doSomethinge as variáveis ​​locais em doSomething podem ser referenciados como um deslocamento do ponteiro base .
Assim,

  • Quando uma chamada de função é feita, os argumentos da função são colocados na pilha. Esses argumentos são posteriormente referenciados por ponteiro de base.
  • Quando a função retorna ao seu chamador, os argumentos da função retornada são retirados da pilha usando o método LIFO.

Em detalhe:

A regra é que cada chamada de função resulta na criação de um quadro de pilha (com o mínimo sendo o endereço para o qual retornar). Portanto, se funcAchamadas funcBe funcBchamadas funcC, três frames de pilha são configurados um em cima do outro. Quando uma função retorna, seu quadro se torna inválido . Uma função bem comportada atua apenas em seu próprio quadro de pilha e não invade o de outra. Em outras palavras, o POPing é executado para o frame da pilha no topo (ao retornar da função).

insira a descrição da imagem aqui

A pilha em sua pergunta é configurada pelo chamador foo. Quando doSomethinge doAnotherThingsão chamados, eles configuram sua própria pilha. A figura pode ajudá-lo a entender isso:

insira a descrição da imagem aqui

Observe que, para acessar os argumentos, o corpo da função terá que percorrer para baixo (endereços superiores) a partir do local onde o endereço de retorno está armazenado, e para acessar as variáveis ​​locais, o corpo da função terá que percorrer a pilha (endereços inferiores ) em relação ao local onde o endereço de retorno está armazenado. Na verdade, o código gerado pelo compilador típico para a função fará exatamente isso. O compilador dedica um registro chamado EBP para isso (Base Pointer). Outro nome para o mesmo é ponteiro de quadro. O compilador normalmente, como a primeira coisa para o corpo da função, envia o valor EBP atual para a pilha e define o EBP para o ESP atual. Isso significa que, uma vez feito isso, em qualquer parte do código da função, o argumento 1 está longe de EBP + 8 (4 bytes para cada EBP do chamador e o endereço de retorno), o argumento 2 está longe de EBP + 12 (decimal), variáveis ​​locais estão a EBP-4n de distância.

.
.
.
[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) 

Dê uma olhada no seguinte código C para a formação do frame da pilha da função:

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

Quando o chamador ligar

MyFunction(10, 5, 2);  

o seguinte código será gerado

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

e o código de montagem para a função será (configurado pelo receptor antes de retornar)

^
| _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
 

Referências:

haccks
fonte
1
Obrigado pela sua resposta. Além disso, os links são muito legais e me ajudam a lançar mais luz sobre a questão interminável de como os computadores realmente funcionam :)
Christoph
O que você quer dizer com "coloca o valor EBP atual na pilha" e também o ponteiro da pilha é armazenado no registro ou que também ocupa uma posição na pilha ... estou um pouco confuso
Suraj Jain
E não deveria ser * [ebp + 8] e não [ebp + 8].?
Suraj Jain
@Suraj Jain; Você sabe o que é EBPe ESP?
haccks
esp é o ponteiro da pilha e ebp é o ponteiro da base. Se eu tiver algum conhecimento incorreto, por favor, corrija-o.
Suraj Jain
19

Como outros observaram, não há necessidade de alterar os parâmetros, até que saiam do escopo.

Vou colar um exemplo de "Ponteiros e memória" de Nick Parlante. Acho que a situação é um pouco mais simples do que você imaginou.

Aqui está o 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)
}

Os pontos no tempo T1, T2, etc. são marcados no código e o estado da memória naquele momento é mostrado no desenho:

insira a descrição da imagem aqui


fonte
2
Ótima explicação visual. Pesquisei no Google e encontrei o papel aqui: cslibrary.stanford.edu/102/PointersAndMemory.pdf Papel muito útil!
Christoph
7

Processadores e linguagens diferentes usam alguns designs de pilha diferentes. Dois padrões tradicionais no 8x86 e no 68000 são chamados de convenção de chamada Pascal e convenção de chamada C; cada convenção é tratada da mesma maneira em ambos os processadores, exceto pelos nomes dos registradores. Cada um usa dois registradores para gerenciar a pilha e as variáveis ​​associadas, chamados stack pointer (SP ou A7) e frame pointer (BP ou A6).

Ao chamar a sub-rotina usando qualquer uma das convenções, quaisquer parâmetros são colocados na pilha antes de chamar a rotina. O código da rotina então empurra o valor atual do ponteiro do quadro para a pilha, copia o valor atual do ponteiro da pilha para o ponteiro do quadro e subtrai do ponteiro da pilha o número de bytes usados ​​pelas variáveis ​​locais [se houver]. Uma vez feito isso, mesmo se dados adicionais forem colocados na pilha, todas as variáveis ​​locais serão armazenadas em variáveis ​​com um deslocamento negativo constante do ponteiro da pilha, e todos os parâmetros que foram colocados na pilha pelo chamador podem ser acessados ​​em um deslocamento positivo constante do ponteiro do quadro.

A diferença entre as duas convenções está na maneira como tratam uma saída da sub-rotina. Na convenção C, a função de retorno copia o ponteiro do quadro para o ponteiro da pilha [restaurando-o para o valor que tinha logo após o ponteiro do quadro antigo ser pressionado], exibe o valor do ponteiro do quadro antigo e executa um retorno. Todos os parâmetros que o chamador colocou na pilha antes da chamada permanecerão lá. Na convenção Pascal, após exibir o ponteiro do quadro antigo, o processador exibe o endereço de retorno da função, adiciona ao ponteiro da pilha o número de bytes dos parâmetros enviados pelo chamador e, a seguir, vai para o endereço de retorno exibido. No 68000 original, era necessário usar uma sequência de 3 instruções para remover os parâmetros do chamador; o 8x86 e todos os processadores 680x0 após o original incluíam um "ret N"

A convenção Pascal tem a vantagem de salvar um pouco de código no lado do chamador, pois o chamador não precisa atualizar o ponteiro da pilha após uma chamada de função. Requer, entretanto, que a função chamada saiba exatamente quantos bytes de parâmetros o chamador irá colocar na pilha. Falhar em colocar o número apropriado de parâmetros na pilha antes de chamar uma função que usa a convenção Pascal quase certamente causará um travamento. Isso é compensado, entretanto, pelo fato de que um pequeno código extra dentro de cada método chamado salvará o código nos locais onde o método é chamado. Por esse motivo, a maioria das rotinas originais da caixa de ferramentas do Macintosh usava a convenção de chamada Pascal.

A convenção de chamada C tem a vantagem de permitir que as rotinas aceitem um número variável de parâmetros, e ser robusta mesmo se uma rotina não usar todos os parâmetros que são passados ​​(o chamador saberá quantos bytes de parâmetros ela empurrou, e assim será capaz de limpá-los). Além disso, não é necessário realizar a limpeza da pilha após cada chamada de função. Se uma rotina chamar quatro funções em sequência, cada uma das quais usando quatro bytes de parâmetros, ela pode - em vez de usar um ADD SP,4após cada chamada, usar um ADD SP,16após a última chamada para limpar os parâmetros de todas as quatro chamadas.

Hoje em dia, as convenções de chamada descritas são consideradas um tanto antiquadas. Visto que os compiladores se tornaram mais eficientes no uso de registradores, é comum ter métodos que aceitem alguns parâmetros em registradores em vez de exigir que todos os parâmetros sejam colocados na pilha; se um método pode usar registradores para manter todos os parâmetros e variáveis ​​locais, não há necessidade de usar um ponteiro de quadro e, portanto, não há necessidade de salvar e restaurar o antigo. Ainda assim, às vezes é necessário usar as convenções de chamada mais antigas ao chamar bibliotecas que foram vinculadas para usá-las.

supergato
fonte
1
Uau! Posso pegar seu cérebro emprestado por uma semana ou mais. Precisa extrair algumas coisas essenciais! Ótima resposta!
Christoph
Onde fica o frame e o ponteiro da pilha armazenados na própria pilha ou em qualquer outro lugar?
Suraj Jain
@SurajJain: Normalmente, cada cópia salva do ponteiro do quadro será armazenada em um deslocamento fixo em relação ao novo valor do ponteiro do quadro.
supercat
Senhor, estou com essa dúvida há muito tempo. Se na minha função eu escrevo if (g==4)then int d = 3e gtomo a entrada usando scanfdepois disso, defino outra variável int h = 5. Agora, como o compilador dá d = 3espaço na pilha. Como o deslocamento é feito, porque se gnão for 4, então não haveria memória para d na pilha e simplesmente o deslocamento seria fornecido para he se o g == 4deslocamento seria primeiro para ge depois para h. Como o compilador faz isso em tempo de compilação, ele não conhece nossa entrada parag
Suraj Jain
@SurajJain: As primeiras versões de C exigiam que todas as variáveis ​​automáticas dentro de uma função aparecessem antes de qualquer instrução executável. Relaxando um pouco essa compilação complicada, mas uma abordagem é gerar código no início de uma função que subtraia de SP o valor de um rótulo declarado para frente. Na função, o compilador pode, em cada ponto do código, controlar quantos bytes de valor local ainda estão no escopo e também controlar o número máximo de bytes de valor local que já estão no escopo. No final da função, ele pode fornecer o valor para o anterior ...
supercat
5

Já existem algumas respostas muito boas aqui. No entanto, se você ainda está preocupado com o comportamento LIFO da pilha, pense nela como uma pilha de quadros, em vez de uma pilha de variáveis. O que pretendo sugerir é que, embora uma função possa acessar variáveis ​​que não estão no topo da pilha, ela ainda está operando apenas no item no topo da pilha: um único quadro de pilha.

Claro, existem exceções a isso. As variáveis ​​locais de toda a cadeia de chamadas ainda estão alocadas e disponíveis. Mas eles não serão acessados ​​diretamente. Em vez disso, eles são passados ​​por referência (ou por ponteiro, o que é realmente diferente apenas semanticamente). Nesse caso, uma variável local de um quadro de pilha muito mais abaixo pode ser acessada. Mas, mesmo neste caso, a função atualmente em execução ainda está operando apenas em seus próprios dados locais. Ele está acessando uma referência armazenada em seu próprio quadro de pilha, que pode ser uma referência a algo no heap, na memória estática ou mais abaixo na pilha.

Essa é a parte da abstração da pilha que torna as funções chamadas em qualquer ordem e permite a recursão. O quadro da pilha superior é o único objeto acessado diretamente pelo código. Qualquer outra coisa é acessada indiretamente (por meio de um ponteiro que fica no quadro da pilha superior).

Pode ser instrutivo observar a montagem de seu pequeno programa, especialmente se você compilar sem otimização. Acho que você verá que todo o acesso à memória em sua função acontece por meio de um deslocamento do ponteiro do frame da pilha, que é como o código da função será escrito pelo compilador. No caso de uma passagem por referência, você veria instruções de acesso indireto à memória por meio de um ponteiro armazenado em algum deslocamento do ponteiro do frame da pilha.

Jeremy West
fonte
4

A pilha de chamadas não é realmente uma estrutura de dados de pilha. Nos bastidores, os computadores que usamos são implementações da arquitetura de máquina de acesso aleatório. Portanto, a e b podem ser acessados ​​diretamente.

Nos bastidores, a máquina:

  • obter "a" é igual à leitura do valor do quarto elemento abaixo do topo da pilha.
  • obter "b" é igual a ler o valor do terceiro elemento abaixo do topo da pilha.

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

hdante
fonte
1

Aqui está um diagrama que criei para a pilha de chamadas de C. É mais preciso e contemporâneo do que as versões da imagem do Google

insira a descrição da imagem aqui

E correspondendo à estrutura exata do diagrama acima, aqui está uma depuração de notepad.exe x64 no Windows 7.

insira a descrição da imagem aqui

Os endereços inferiores e os endereços superiores são trocados de forma que a pilha esteja subindo neste diagrama. Vermelho indica o quadro exatamente como no primeiro diagrama (que usava vermelho e preto, mas o preto agora foi reaproveitado); preto é o espaço doméstico; azul é o endereço de retorno, que é um deslocamento da função do chamador para a instrução após a chamada; laranja é o alinhamento e rosa é para onde o ponteiro da instrução está apontando logo após a chamada e antes da primeira instrução. O valor homeSpace + return é o menor quadro permitido no windows e como o alinhamento rsp de 16 bytes logo no início da função chamada deve ser mantido, isso sempre inclui um alinhamento de 8 bytes também.BaseThreadInitThunk e assim por diante.

Os quadros de função vermelhos delineiam o que a função do receptor logicamente 'possui' + lê / modifica (pode modificar um parâmetro passado na pilha que era muito grande para passar em um registro em -Ofast). As linhas verdes demarcam o espaço que a função aloca-se do início ao fim da função.

Lewis Kelsey
fonte
RDI e outros argumentos de registro só são transferidos para a pilha se você compilar no modo de depuração e não há garantia de que uma compilação selecione essa ordem. Além disso, por que os argumentos da pilha não são mostrados no topo do diagrama para a chamada de função mais antiga? Não há uma demarcação clara em seu diagrama entre qual quadro "possui" quais dados. (Um callee possui seus argumentos de pilha). Omitir os argumentos da pilha do topo do diagrama torna ainda mais difícil ver que "parâmetros que não podem ser passados ​​em registradores" estão sempre logo acima do endereço de retorno de cada função.
Peter Cordes,
A saída de @PeterCordes goldbolt asm mostra clang e gcc callees enviando um parâmetro passado em um registro para a pilha como comportamento padrão, portanto, ele tem um endereço. No gcc, usar registerbehind o parâmetro otimiza isso, mas você pensaria que isso seria otimizado de qualquer maneira, visto que o endereço nunca é obtido dentro da função. Vou consertar a moldura superior; admito que deveria ter colocado a elipse em um quadro em branco separado. 'um callee possui seus argumentos de pilha', incluindo aqueles que o chamador envia se eles não podem ser passados ​​nos registradores?
Lewis Kelsey
Sim, se você compilar com a otimização desabilitada, o receptor vai derramar em algum lugar. Mas, ao contrário da posição dos argumentos da pilha (e indiscutivelmente salvos-RBP), nada é padronizado sobre onde. Re: callee possui seus argumentos de pilha: sim, as funções têm permissão para modificar seus argumentos de entrada. Os argumentos de registro que ele derrama não são argumentos de pilha. Compiladores às vezes fazem isso, mas o IIRC geralmente desperdiça espaço de pilha usando o espaço abaixo do endereço de retorno, mesmo se nunca relerem o argumento. Se um chamador quiser fazer outra chamada com o mesmo argumento, por segurança, ele terá que armazenar outra cópia antes de repetir ocall
Peter Cordes
@PeterCordes Bem, tornei os argumentos parte da pilha do chamador porque estava demarcando os quadros de pilha com base em onde rbp aponta. Alguns diagramas mostram isso como parte da pilha do receptor (como o primeiro diagrama nesta questão) e alguns mostram como parte da pilha do chamador, mas talvez faça sentido torná-los parte da pilha do receptor, visto como o escopo do parâmetro não está acessível ao chamador em código de nível superior. Sim, parece registere as constotimizações só fazem diferença em -O0.
Lewis Kelsey
@PeterCordes eu mudei. Eu posso mudar novamente
Lewis Kelsey