DEV Community

loading...
Cover image for Analisando alocações de memória em Rust utilizando GNU Debugger

Analisando alocações de memória em Rust utilizando GNU Debugger

ignaciojvig profile image João Victor Ignacio ・9 min read

Olá pessoal, como vão vocês?

Neste artigo, vou mostrar pra vocês um exemplo de como podemos analisar as alocações de memória que são feitas por baixo dos panos em um programa em Rust. Para conseguirmos analisar essas alocações, vamos utilizar de ferramenta o GNU Debugger. Portanto, para seguir adiante, certifiquem-se de que tanto o Rust quanto o GNU Debugger estejam ambos devidamente instalados.

Para darmos início, é necessário antes abordarmos dois conceitos que são fundamentais para as análises que vamos fazer em diante- e mais do que isso, fundamentais para uma escrita de um código minimamente otimizado: a Stack e o Heap.

A Stack é uma região especial da memória de processamento que armazena as variáveis criadas por cada função. A memória necessária para realizar esse armazenamento, para cada função, é chamado de stack frame. Para cada função chamada, um novo stack frame específico a ela é alocado no topo da nossa Stack, e a função pode acessar apenas a sua própria stack frame. Este comportamento é justamente o que define o escopo das funções. Ao trabalharmos com a Stack, o tamanho de cada variável deve ser específicado em tempo de compilação, ou seja, se precisarmos por exemplo trabalhar com um array, utilizando a Stack, este array deve possuir um tamanho exato de quantos elementos vai comportar. Quanto a execução de um programa termina de passar por uma função o stack frame dela é liberado, ou seja, não precisamos nos preocupar em desalocar manualmente.

Para termos um relance um pouco mais objetivo de como o Stack se comporta, vejamos o exemplo abaixo. Ainda não utilizaremos o GNU Debugger, deixemos ele para o nosso exemplo mais completo.

fn main() {
  let a = 2;
  stack_only(a);
}

fn stack_only(_b: i32) {
  let _c = 3;
}
Enter fullscreen mode Exit fullscreen mode

O programa em si é muito simples, certo? Ele possui uma função main, onde ele começa declarando uma variável a que recebe o valor 2. Em seguida, ele chama a função stack_only e passa de parâmetro para ela a variável a. A função stack_only tem em sua assinatura que ela espera um parâmetro, que ela denomina de __b_ do tipo i32, e declara em seu escopo uma variável __c_ que recebe o valor 3.

Vamos agora então imaginar o Stack:
Representação do Stack vazio

Naturalmente, a Stack vai se iniciar vazia, assim como nossa imagem acima representa. E então, seguindo os procedimentos que também já mencionamos, nossa função main declara uma variável, logo, ela deve possuir um stack_frame que comporte essa mesma.

Representação do Stack com o Stack Frame da função main

Em seguida, antes da função main terminar (logo, nada de desalocações do stack_frame dela ainda), uma outra função é chamada: stack_only.
Essa função naturalmente também vai precisar do seu stack_frame, e diferente da main, ela precisará comportar espaço para duas váriáveis: a variável b que é especificada em sua assinatura e recebe o valor da variável a, 2, e a variável c declarada em seu escopo que recebe o valor 3.
Representação do Stack com o Stack Frame da função main e com o Stack Frame da função stack_only

Agora sim. A função stack_only termina e neste instante, seu stack_frame é liberado, o que deixa nosso Stack assim:
Representação do Stack com o Stack Frame da função main após o Stack Frame da função stack_only ter sido desalocado

Por fim, a função main se encerra e seu stack_frame também é liberado:
Representação do Stack vazio, após o Stack Frame da função Main ter sido desalocado

É importante ressaltarmos que o Stack possui um tamanho limitado que é determinado, dentre outros fatores, pela arquitetura do processador, sistema operacional, compilador...
Logo, se utilizarmos por exemplo uma recursão infinita, nossa Stack vai eventualmente ser completamente preenchida e na tentativa de alocarmos mais um stack_frame vamos nos deparar com a famigerada Stack Overflow Exception.

Simples, certo? As coisas vão mudar um pouco no momento em que introduzirmos um outro conteito: o Heap.

O Heap se difere da Stack por ser uma região da memória de processamento que não é automaticamente gerenciada para nós, logo, nós temos que manualmente alocar memória nela e tão importante quanto, manualmente desalocar a memória dela.
Esquecer de desalocar memórias alocadas no Heap é uma das principais causas de memory leaks em aplicações.
Diferente da Stack que possui seu tamanho definido em tempo de compilação, o Heap não possui um limite específico, mas sim delimitado apenas pela quantidade de recursos físicos que uma máquina conseguirá prover.

Observemos o exemplo a seguir, analisando apenas as interações com o Heap:

fn main() {
  let a = 2;
  stack_only(a);
  stack_and_heap()
}

fn stack_only(_b: i32) {
  let _c = 3;
}

fn stack_and_heap() {
  let _d = Box::new(4);
}
Enter fullscreen mode Exit fullscreen mode

Diferente da representação do Stack, o Heap não é uma pilha e muito menos irá 'enpilhar' algo. Podemos visualizá-lo como um espaço onde as alocações irão ocupar blocos cujo tamanho varia conforme o necessário para alocar o desejado. Ele começa vazio, conforme a representação abaixo:

Representação do Heap e da Stack ambos vazios

Até que a nossa função stack_and_heap seja executada, nosso Stack nesse exemplo será identico ao exemplo anterior. Portanto, vamos partir do momento em que o stack_frame da função stack_only é liberado:

Representação do Heap e da Stack, mas a Stack após a função stack_frame ter se encerrado

Ao executarmos a função stack_and_heap, vamos ter um stack_frame para ela também. Até aqui, nada demais:

Representação do Heap e da Stack, mas a Stack após a função stack_frame ter se encerrado e agora com um Stack Frame para a função stack_and_heap

Agora as coisas ficam um pouco diferentes. Em Rust o Box é um Smart Pointer que irá, vide nosso exemplo, alocar o valor 4 no Heap e automaticamente alocar assim que essa função for encerrada. A varíavel d, ao invés de simplesmente receber o valor 4, diferente das atribuições de variáveis que fizemos até então, receberá o endereço do valor que foi alocado no Heap, que nada mais é do que o ponteiro que leva a ele.

Representação do Heap e da Stack, com o Heap contento o valor 4 alocado nele e o Stack com um ponteiro para este

Nosso programa poderia ter vários outros objetos alocados no Heap, e ele ficaria mais ou menos assim:

Representação do Heap e da Stack, com o Heap contento o valor 4 alocado nele e o Stack com um ponteiro para este e vários outros objetos alocados simulando outras alocações

Voltando agora para o nosso exemplo, o stack_frame da função stack_and_heap será liberado e o valor 4 será desalocado do Heap, nos deixando no seguinte caso:

Representação do Heap e da Stack, com o stack frame da função stack_and_heap removido e o valor 4 desalocado do Heap

Vale ressaltar: o valor 4 está sendo desalocado justamente graças ao Box, que é um Smart Pointer. Se estivéssemos utilizando um raw pointer teríamos que fazer essa desalocação de forma manual ou teríamos um memory leak como falamos anteriormente, e essa seria a nossa representação:

Representação do Heap e da Stack, com o stack frame da função stack_and_heap removido mas o valor 4 não desalocado do Heap

O programa se encerraria e a memória anteriormente alocada no Heap persistiria e o que iria acontecer com ela passa a depender muito do Sistema Operacional e da arquitetura por trás. Em computadores com Windows ou distribuições UNIX, o Sistema Operacional irá simplesmente reinvidicar a memória para si e utilizá-la em outro processo por exemplo, mas isso pode não ser verdade para aplicações embarcadas.

Os próximos passos do nosso programa são exatamente os mesmos do exemplo anterior e agora consistem de liberar o stack_frame da função main, basicamente nos levando de volta para o cenário inicial.

Representação do Heap e da Stack ambos vazios

Sem mais delongas e devidamente aquecidos vamos agora para o GNU Debugger!

Vamos utilizar o código do nosso último exemplo com pequenas alterações, especificamente para nos ajudar a colocar nossos breakpoints.

use log::info;

fn main() {
  let a = 2;
  stack_only(a);
  stack_and_heap()
}

fn stack_only(_b: i32) {
  let _c = 3;
  info!("Debugging stack_only")
}

fn stack_and_heap() {
  let _d = Box::new(4);
  info!("Debugging stack_and_heap")
}
Enter fullscreen mode Exit fullscreen mode

Com nosso exemplo compilado, posso iniciá-lo junto ao gdb da seguinte forma:

Executando o GNU Debugger com nosso exemplo compilado

Para uma melhor visualização e interação ao gdb, vou fazer uma pequena alteração com um comando que a própria interface do CLI dele nos dispõe.

Alterando o Layout do gdb

Com essa alteração, o seu display do gdb deve ter ficado mais ou menos com a seguinte aparência:

Aparencia do Layout do gdb após a alteração

Agora, podemos por exemplo visualizar nosso código para analisarmos onde podemos colocar alguns breakpoints. Para isso, podemos utilizar o seguinte comando:
Listando o código exemplo

E nossa visualização no gdb será a seguinte:
Visualização do Código no gdb

Para colocarmos um breakpoint em um ponto específico, podemos utilizar o comando:

Comando para colocar um Breakpoint no código

Visando já alguns pontos interessantes, vamos colocar nas linhas 4, 10 e 15
Breakpoints para as linhas 4, 10 e 15

Nosso código listado no GDB deve estar agora com as linhas que colocamos os breakpoints devidamente marcadas, como no exemplo abaixo:
Código no gdb com os breakpoints

Agora, sem mais delongas, vamos executar nosso código utilizando um comando simples, de uma letra só:
Executando código

E agora, nosso código deve ter se iniciado e travado no nosso primeiro breakpoint, na linha 4:
Código executando com Breakpoint na Linha 4

Como estamos na função main, significa que já temos um stack_frame. Podemos observá-lo utilizando o comando backtrace:
Comando para observar como estão os StackFrames

Como sabemos que temos apenas 1 stack_frame, então vamos utilizar o comando passando esse valor. O que nos forneceria o seguinte resultado:
Observando Stack Frame da função main

Contudo, até então, não existe nenhuma variável alocada dentro do stack_frame, pois a execução do nosso programa para logo antes de executar a linha em que posicionamos um breakpoint. Podemos verificar se houve alguma alocação de variável utilizando o comando:
Comando para verificar alocações de variáveis

Se executarmos esse comando agora, este será o output:
Verificando alocação de memória antes do nosso breakpoint

Para darmos continuidade, vamos apenas passar pela alocação da variável que nosso breakpoint está segurando, antes de entrarmos nas funções, para conseguirmos observar a alocação. Para isso, podemos utilizar o comando next:
Comando para passar uma linha na execução dos comandos após um breakpoint

Que nos dá o seguinte resultado:
Passado o breakpoint utilizando o comando next

Se agora tentarmos utilizar o comando para analisarmos as alocações de variáveis:
Alocação da variável 'a' recebendo o valor 2

Podemos observar agora que, neste _stack_frame, existe a variável a que recebeu o valor 2.
Agora, é interessante que 'entremos' dentro da função stack_only para observamos como o que vimos até então pode mudar. Para isso, podemos utilizar o comando step:
Comando para entrarmos em uma função pelo debugger

Se tivéssemos utilizado o comando next, a função stack_only teria sido executada e o debugger iria para a função stack_and_heap.
Se o comando step foi utilizado corretamente, seu gdb deve estar assim:
gdb após a execução do comando step

Como estamos em outra função, temos outro _stack_frame. Como sabemos que temos dois até então, podemos executar o comando backtrace passando o número 2:
Analisando agora o stack com dois stack frames

Podemos inclusive observar como o stack_frame da função stack_only está de fato vindo antes do stack_frame

Discussion (0)

pic
Editor guide