Uma análise detalhada das mudanças dinâmicas da pilha na memória quando a função da linguagem C é chamada (imagem colorida) Como as variáveis locais são colocadas na pilha e fora da pilha

Declaração de direitos autorais: este artigo é o artigo original do blogueiro e não pode ser reproduzido sem a permissão do blogueiro. Bem-vindo a entrar em contato comigo qq2488890051 https://blog.csdn.net/kangkanglhb88008/article/details/89739105
Primeiro entenda o seguinte conhecimento e processo:

* Os códigos de instrução do programa de computador do sistema Von Neumann são carregados do disco rígido na memória com antecedência para serem executados (se for o código de instrução do computador da arquitetura Harvard executado diretamente na memória externa, você pode ver meu artigo para detalhes, Computador Von Nuo A diferença entre a arquitetura Iman e a arquitetura Harvard e o padrão de avaliação de desempenho do processador), esses códigos de instrução são armazenados na memória do segmento de código do processo, e os códigos de instrução na mesma função são armazenados em ordem de endereço (determinada pelo compilador) ( Ou seja, desde que o endereço de instrução + 1 possa obter automaticamente o endereço da próxima instrução), então, quando ocorre uma chamada de função, ela entra no segmento de código de endereço contínuo de outra função, portanto, quando a função é chamada, ela deve ser colocada na pilha com antecedência. Salve o endereço de uma instrução após esta função.

* A definição da parte superior e inferior da pilha não é definida de acordo com a altura do endereço, mas é definida de acordo com a posição da pilha e da pilha. O lugar da pilha e da pilha é chamado de topo da pilha (embora o crescimento da pilha no sistema operacional Windows seja alto Endereço para endereço baixo), a pilha é a última a entrar, a primeira a sair e a função chamada é colocada na pilha, então, quando a função retorna, ela também é reciclada primeiro, ou seja, o espaço recuperado é revertido camada por camada

* Pilha é uma pilha, mas costuma ser chamada de pilha. Pilha é uma pilha. Não nomeie aleatoriamente. Sobre como a pilha na memória aloca espaço e qual é a diferença, você pode ver meu artigo, Explicação detalhada da alocação de armazenamento de programas de computador e Visão geral do processo de chamada de função da linguagem C

* O programa inteiro mantém uma pilha (se estiver executando um sistema operacional, pode haver vários processos, então haverá várias pilhas independentes e, por exemplo, um programa bare-metal de chip único tem apenas uma pilha). Esta pilha está mudando dinamicamente, e as variáveis Alocação e liberação têm tudo a ver com o movimento dinâmico do ponteiro no topo da pilha. Se as variáveis ​​na pilha que precisam ser liberadas precisarem ser salvas e continuar a ser usadas, use o método pop, e a pilha será salva em um determinado registrador de cpu ao mesmo tempo, como EAX , Este registro pode ser usado para salvar temporariamente o valor da variável ou o valor de retorno da função final

* A cpu tem vários registros, que são usados ​​principalmente para alguns valores temporários da função ativa atual (quando uma função está em execução, eu a chamo de função ativa aqui) e pode alterar dinamicamente o conteúdo do registro e da função ativa atual Interação ou algo assim, mas estamos mais preocupados com quatro, EAX, ESP, EBP, EIP, a explicação é a seguinte

 

* A função de atividade irá empurrar as variáveis ​​locais e os valores em alguns registros na pilha (não o endereço do registro na pilha, porque o endereço do registro é definido como ebx e assim por diante através da macro, ou seja, o endereço do registro foi tornado público, sobre isso Para obter detalhes, consulte minha explicação detalhada neste artigo, a estrutura do microprocessador incorporado e a explicação do processo desde a inicialização até o início da execução do programa), porque toda a cpu tem apenas esse conjunto de registros, mas a chamada de função pode ter muitas camadas, portanto, a chamada de função atual Após a próxima função, a função ativa se torna a próxima função. Neste momento, o valor deste conjunto de registros é primeiro colocado na pilha, ou seja, salvo e, em seguida, usado para apoiar a operação da nova função ativa. Quando a nova função ativa Após o término, o valor que acabou de ser salvo na pilha será reatribuído a este conjunto de registradores, para que o estado de execução antes da chamada seja restaurado.

* O programa possui apenas uma pilha, mas pode ser chamada hierarquicamente, e cada função terá uma pilha parcial nesta pilha total, também chamada de stack frame

Por exemplo, atualmente na função principal main, a pilha é a seguinte:

 

As variáveis ​​locais aqui são definidas na função principal, e o que ebx, esi e edi fazem especificamente, por que estão empilhados (definitivamente algumas informações de registro), não entendo, contanto que você saiba que cada função ativa colocará esses três Basta colocar o valor do registro na pilha. O valor do registro EBP armazena o endereço inferior do quadro de pilha de função ativa atual, enquanto o ESP armazena o endereço superior do quadro de pilha atual. O endereço da próxima instrução executada pela cpu é lido diretamente do registrador EIP. O registrador EIP é usado para armazenar o endereço da instrução a ser executada a cada vez, portanto, devemos preencher manualmente o endereço da próxima instrução antes de cada execução. Entre, ou seja, após a chamada de função que será vista mais tarde, retire o endereço que foi colocado na pilha antes da pilha. (Se o endereço da próxima instrução for pop, esta instrução de montagem também deve preencher o endereço no registrador EIP ao mesmo tempo)

 

// O código de montagem do processo de instrução executado na função principal. A primeira coluna representa o endereço da instrução. Eu removi o código de instrução irrelevante
011C1540 push ebp // Empurre a pilha e salve o ebp (esta é a função que chamou a função principal O endereço inferior da pilha do frame da pilha, não sei quem chamou a função principal, deve ser o sistema operacional), observe que a operação push implica esp-4
011C1541 mov ebp, esp // passa o valor de esp para ebp, Defina o ebp atual
011C1543 sub esp, 0F0h // Abra espaço para a função, o intervalo é (ebp, ebp-0xF0)
011C1549 push ebx
011C154A push esi
011C154B push edi
011C154C lea edi, [ebp-0F0h] // Defina edi para ebp- 0xF0 As próximas instruções não precisam olhar para
011C1552 mov ecx, 3Ch // O número de dwords no espaço de função, 0xF0 >> 2 = 0x3C
011C1557 mov eax, 0CCCCCCCCh
011C155C rep stos dword ptr es: [edi]
// O propósito da instrução rep É repetir a instrução acima. O valor de ECX é o número de vezes de repetição.
// A função da instrução STOS é copiar o valor em eax para o endereço apontado por ES: EDI, e depois EDI + 4
// Aqui é para começar a chamar print_out (0, 2)
013D155E push 2 // O segundo parâmetro real é colocado na pilha
013D1560 push 0 // O primeiro parâmetro real é colocado na pilha
013D1562 chamar print_out (13D10FAh) // O endereço de retorno é colocado na pilha, neste caso 013D1567, e então chamar a função
print_out 013D1567 add esp, 8 // Dois parâmetros reais são retirados da pilha
// Observe que no comando de chamada, a operação implícita é colocar O endereço de uma instrução é colocado na pilha, que é o chamado endereço de retorno.
// Quando a função chamada é executada para a instrução de retorno, ela está pronta para encerrar a função. O processo de retorno é
013D141C mov eax, 1 // O valor de retorno é passado para eax
013D1421 pop edi
013D1422 pop esi
013D1423 pop ebx // Registrar
popping 013D1424 add esp, 0D0h // Os 3 comandos a seguir chamam __RTC_CheckEsp de VS, verifique o estouro de pilha
013D142A cmp ebp, esp
013D142C chame @ ILT + 315 (__ RTC_CheckEsp1140D11h) (13
013D1431 mov esp, ebp // passa o valor de ebp para esp, ou seja, restaura o valor de esp antes da chamada
013D1433 pop ebp // pop ebp, restaura o valor de ebp
013D1434 ret // escreve o endereço de retorno em EIP, que é equivalente a pop EIP
Agora, outra função print_out é chamada na função principal, e suas alterações de pilha são as seguintes:

 

Podemos ver que a chamada hierárquica da função é na verdade o empilhamento repetido do conteúdo de diferentes funções ativas (da mesma forma). Se a função print_out chamar outra função, é o mesmo que adicionar outro frame de pilha.

Agora vamos analisar o processo e a sequência desse empilhamento:

A função principal também é chamada por alguma outra função. Aqui não vamos persegui-la, porque a pilha cresce para um endereço inferior. Podemos ver que o processo de execução da função principal (ou seja, a função ativa atual) é primeiro definido em principal As variáveis ​​locais são colocadas na pilha, seguidas pelo conteúdo dos três registradores. Neste momento, continue a executar e descubra que a função prin_out é chamada. Neste momento, dois espaços de 4 bytes serão abertos na pilha (porque apenas encontrados Dois parâmetros formais do tipo int), que é a declaração de duas variáveis ​​na linguagem C, e ao mesmo tempo preenche esses dois espaços com 0 e 2, o que completa a declaração e inicialização dos parâmetros da função (porque ainda é No quadro da pilha da função principal, podemos ver que a declaração e atribuição dos parâmetros formais da função chamada são feitas na função de chamada, não no espaço alocado pela própria função chamada), que é o parâmetro real visto acima 1, 2 existe na pilha e, em seguida, antes de entrar na função print_out, a função principal tem que salvar o próximo endereço de instrução da função print_out (ou seja, o endereço de retorno na figura acima) na pilha (este processo é chamado montagem print_out A instrução será concluída automaticamente. Na verdade, o endereço da próxima instrução é a operação de recuperar o espaço ocupado pelos dois parâmetros reais recém-alocados, ou seja, o endereço da instrução add esp, 8. Não se apresse, irei analisar em detalhes mais tarde. Isso), porque depois que a função print_out é finalizada, a função principal sabe como continuar. (Ponto de interrogação: o endereço da próxima instrução desta função print_out não pode ser a função print_out e informá-la para a função principal quando a execução estiver quase completa? Claro que não, porque a função print_out em si não sabe quem é a próxima instrução, e pode ser diferente. Para chamadas de função, a função externa (o chamador) não tem conhecimento disso). Quando o endereço de retorno também é colocado na pilha, você pode inserir a função print_out.

                     

Depois de inserir a função print_out, é da mesma maneira que ao inserir a função principal. Primeiro, o endereço inferior da pilha do chamador (função principal) é colocado na pilha.

O endereço inferior do frame da pilha da função principal, ou seja, o endereço da unidade de memória apontada pela seta vermelha na figura, é o valor de ebp (principal) na pilha (a finalidade é que após a chamada da função print_out ser concluída, a função principal se torna a função ativa novamente, principal O quadro de pilha se torna o quadro de pilha atual. Preencha o valor do endereço do registro EBP para que o EBP possa apontar rapidamente para a posição correta, ou seja, a seta vermelha. Neste momento, o ESP deve, é claro, apontar para a posição de edi, que é o quadro de pilha da função principal. A posição superior da pilha é agora. Olhando desta forma, ela é restaurada para a aparência da pilha quando a função print_out não foi chamada. Esta é a imagem certa acima, perfeita, tão perfeita), e então você pode entrar na função print_out.

Em seguida, aloque o espaço total exigido pelas variáveis ​​locais para a função ativa atual (função print_out) (a alocação de 8 aqui não é necessariamente precisa, porque os valores nos três registros de ebx, esi e edi também serão colocados na pilha, que deve ser de 20 bytes) , Mas por uma questão de simplicidade, não é tão rigoroso, mas o princípio está correto), em seguida, empurre as variáveis ​​locais da pilha, os valores nos três registradores de ebx, esi e edi, e execute o processo de operação correspondente, uma vez que você encontre a instrução de retorno, Neste momento, a função print_out sabe que sua execução está para terminar, então ela começa a recuperar o stack frame desta função, basta salvar o valor de retorno no registrador EXA (há um valor de retorno, se não houver valor de retorno, a função Se for do tipo void, não há necessidade de salvar o valor de retorno no registrador EXA), pois as variáveis ​​locais e os valores nos três registradores ebx, esi e edi são valores sem sentido, basta jogá-los fora, ou seja, colocar o registrador esp O conteúdo é atribuído diretamente ao valor do endereço no registro ebp, ou seja, esp e ponto ebp para a mesma unidade de memória. Nesse momento, o topo da pilha torna-se ebp (principal) e a memória da pilha é recuperada, conforme mostrado na figura a seguir. O código de montagem correspondente é mov esp, ebp,

 

Neste momento, preencha o registro ebp no endereço inferior da pilha do quadro da pilha de função principal que foi colocado na pilha antecipadamente, ou seja, ebp (principal) é retirado da pilha e atribuído ao registrador ebp ao mesmo tempo

Ou seja: pop ebp // pop ebp, e atribui este valor de endereço ao registrador ebp ao mesmo tempo, ou seja, restaura o valor de ebp, ou seja, ebp aponta para a parte inferior do quadro de pilha da função principal, conforme mostrado abaixo


Neste momento, o frame de pilha da função print_out foi recuperado.Neste momento, o frame de pilha da função principal foi alcançado, mas o segmento de código de instrução da função principal não foi alcançado.

Em seguida, na função print_out vem a instrução ret, ou seja, o endereço de retorno (armazenado no frame da pilha principal) é escrito no EIP, que é equivalente a pop EIP, conforme mostrado abaixo


Neste ponto, a função print_out é completamente executada e retorna para a seção de instrução da função principal. Obviamente, a próxima instrução é continuar a recuperar os dois espaços variáveis ​​alocados para os parâmetros formais da função print_out na função principal (a função principal foi originalmente chamada O processo de atribuição de função de parâmetros formais também é uma instrução pertencente à função principal), ou seja, a seguinte instrução

add esp, 8 // Dois parâmetros reais são retirados da pilha, ou seja, o espaço dos dois parâmetros reais é recuperado, conforme mostrado abaixo


Ou seja, a instrução após a instrução para chamar a função print_out na função principal é a instrução add esp, 8 (o compilador pode saber a relação entre essas duas instruções, então isso não é dinâmico), então será chamado no início O endereço de retorno que é colocado na pilha na função print_out é o endereço da instrução add esp, 8, que é o endereço da instrução que recupera o espaço do parâmetro real. E quanto ao endereço da próxima instrução, porque dissemos o mesmo no início O código de instrução de uma função é armazenado em um espaço de endereço contínuo, então você só precisa adicionar o endereço da instrução add esp, 8 a + 1 para obter o endereço da próxima instrução a ser executada.

Desta forma, toda a chamada da função print_out é completada, e o quadro da pilha da função principal é restaurado ao estado original quando a função print_out não foi chamada.Como mostrado na figura acima, perfeito, completo.

 

A seguir, vamos dar uma olhada em um exemplo. Com a base de análise acima, a seguinte pode ser facilmente analisada da mesma maneira. O código de instrução de montagem interno é claro e claro, e todo o processo é claro e claro.

 

 

/ ------------------------------------------------- -------------------------------------------------- ---------------- /


Agora, vamos resumir o processo de mudança da pilha quando a função é chamada:

1. O chamador abre o espaço necessário para os parâmetros formais da função chamada em seu próprio stack frame

2. O valor do endereço que deve ser executado após o término da chamada da função push, ou seja, o endereço de retorno, que é na verdade o endereço da instrução que recupera o espaço aberto para os parâmetros formais na primeira etapa

3. Insira a função chamada, empurre o endereço inferior da pilha do frame da pilha da função de chamada

4. Depois de alocar espaço para variáveis ​​locais no quadro de pilha atual da nova função, coloque as variáveis ​​locais na pilha

5. A função chamada encontra a instrução return, indicando que a função está prestes a terminar e começa a reivindicar o espaço do frame da pilha:

        1) Se houver um valor de retorno, atribua o valor de retorno a EAX; caso contrário, ignore esta etapa.

        2) Recupere o espaço variável local, ou seja, esp aponta para o topo da estrutura da pilha da função de chamada

        3) O endereço inferior da pilha do quadro da pilha da função principal salvo com antecedência é atribuído ao registrador ebp, de modo que o ebp aponta para o fundo da pilha do quadro da pilha da função principal

        4) Preencha o endereço de retorno no registrador EIP, e então ele apontará para o endereço de instrução dos dois espaços de parâmetros formais que a função principal originalmente abriu para a função chamada

        5) Recuperação do espaço formal de parâmetros

Isso restaura o quadro de pilha da função principal e retorna ao quadro de pilha quando essa função não foi chamada.

 

Algumas conclusões podem ser tiradas do acima, uma função é na verdade um conceito dinâmico, sua existência só é refletida na memória, ou seja, seu frame de pilha correspondente, quando seu frame de pilha é reciclado, a função termina Acima.

 

Finalmente, vamos discutir esse problema: acabamos de ver que a função chamada passa o valor de retorno através dos registradores eax e edx da CPU e, em seguida, a função de chamada precisa apenas ler os valores desses dois registradores para obter a função chamada. O valor de retorno, mas os dois registros eax e edx são ambos de 32 bits, o que significa que um total de 8 bytes de dados pode ser retornado. Para tipos básicos de dados (como char, int, float, double (ocupando 8 bytes), ponteiros Tipo) não é problema, mas se quisermos retornar dados de um tipo de estrutura e o tamanho total dos membros exceder 8 bytes (o método comum é passar o ponteiro da estrutura. Mas como método permitido pela linguagem, é necessário esclarecer o compilador Como conseguir dessa forma), qual é o princípio?

Resposta: O mesmo programa compilado por nosso compilador geralmente suporta a geração de duas versões do código de destino, a versão de depuração e a versão de lançamento. Os resultados da compilação da versão de depuração geralmente são usados ​​para o depurador. A otimização do código é inferior e o desenvolvedor é melhor restaurado. A estrutura do programa de origem escrita em linguagem C. A versão de lançamento refere-se à versão de lançamento, ou seja, o software é lançado para uso na prateleira. O compilador otimiza o código em alto grau e exclui código inútil e status inacessível. (Se você estiver interessado em entender a otimização de código, consulte o livro de princípios de compilação) , Não é fácil depurar, mas a eficiência da operação é maior. Na verdade, os princípios dos dois são basicamente os mesmos. Explicaremos aqui brevemente a versão de depuração e a versão de lançamento, respectivamente.

No primeiro caso, o processo de retorno de uma estrutura que não ultrapasse 8 bytes: conforme mostrado na figura a seguir:

 

Resumindo:

  (1.1) Use edx: eax para passar o valor de retorno. O chamador não precisa passar o endereço do valor de retorno para a função add na pilha. Ou seja, o processo é o mesmo que o retorno de variáveis ​​básicas do tipo de dados.

  (2.2) A versão de depuração gera um valor de retorno do objeto temporário no chamador (este não é o caso da versão de lançamento. O espaço de memória na caixa vermelha na figura acima não existirá, mas o valor do registro é copiado diretamente para a variável t da função principal , Portanto, a versão de lançamento é mais eficiente) e, em seguida, copie o objeto temporário para o endereço da variável t especificada por main. baixa eficiencia. Podemos ver que o objeto temporário está no stack frame da função principal, o que significa que a função principal analisa seu tamanho de tipo de valor de retorno antes de chamar a função add, e então aloca espaço. Quando a chamada é concluída, o objeto temporário O valor de (conteúdo do valor de retorno) é copiado para a variável atribuída t à esquerda.Neste momento, o objeto temporário completou sua missão, e a função principal recupera o espaço do objeto temporário.

 

No segundo caso, o processo de retorno de uma estrutura superior a 8 bytes: conforme mostrado na figura a seguir:

 

Resumindo:

  (1) Quando a estrutura excede 8 bytes, ela não pode ser passada por EDX: EAX. Neste momento, o chamador mantém uma estrutura para preencher o valor de retorno em seu próprio quadro de pilha, e seu endereço é colocado na pilha depois que os parâmetros reais são colocados na pilha. Para cima, conforme mostrado na seta azul acima. A função chamada add irá definir o valor de retorno para este endereço de acordo com este endereço, a seta vermelha.

  (2) Na função principal, a versão de depuração tem mais um objeto temporário do que a versão de lançamento, que é ineficiente. Na versão de lançamento, existem apenas valores de retorno e variáveis ​​temporárias t (o objeto temporário na caixa vermelha na figura não existe), o que é um pouco mais eficiente do que depurar. Mas os dois modelos são basicamente os mesmos. Você ainda tem que copiar o conteúdo do espaço no valor de retorno para o espaço da variável de atribuição t especificada à esquerda (referindo-se a t na função principal) e, em seguida, recuperar o espaço correspondente ao valor de retorno. A eficiência geral Ele ainda é menor do que o ponteiro de estrutura (porque o ponteiro ocupa apenas 4 bytes, ele pode ser retornado diretamente através do registrador eax e, em seguida, atribuído ao ponteiro t), por isso é recomendado usar o ponteiro para retornar ao retornar dados de tipo de estrutura na linguagem C , O código é executado com mais eficiência.

  (3) Para os dois experimentos acima, a otimização da versão de lançamento é relativamente forte, a atribuição de t na função principal está incompleta, porque o compilador pensa que alguns membros não são usados ​​(como a atribuição dos dois membros de tb e tc, ou seja, código inútil ), portanto, não há necessidade de copiar, desde que o código seja equivalente (para conhecimentos específicos, consulte o capítulo de otimização de código dos princípios de compilação do livro).

O código de montagem correspondente aos dois experimentos acima não foi postado aqui. A função de otimização do compilador não é onipotente. Depois de conhecer o processo subjacente, seremos capazes de escrever código no futuro e escrever um código de maior qualidade e mais eficiente.

Bem-vindo a seguir meu blog. Quando eu tiver tempo, vou escrever alguns artigos científicos fáceis de entender sobre teoria básica da computação. Por um lado, pode ser usado para registrar meu próprio processo de aprendizagem e, por outro lado, posso compartilhá-lo com outras pessoas para que mais pessoas possam entender Como os computadores funcionam em todos os lugares da vida hoje.

 

Artigo de referência:

Pilha de função de chamada de função https://www.cnblogs.com/rain-lei/p/3622057.html

Alocação de memória em tempo de execução após o programa ser compilado https://www.cnblogs.com/guochaoxxl/p/6977712.html

A diferença entre heap e stack https://www.cnblogs.com/yechanglv/p/6941993.html

Sobre a função que retorna a estrutura https://www.cnblogs.com/hoodlum1980/archive/2012/07/18/2598185.html
---------------------
autor: biao2488890051
fonte: CSDN
original: https: //blog.csdn.net/kangkanglhb88008/article/details/89739105
copyright: Este artigo é um artigo original de um blogueiro, reproduzido, anexe o link Bowen!

Acho que você gosta

Origin blog.csdn.net/qq_25814297/article/details/108462206
Recomendado
Clasificación