Analisando alocações de memória em Rust utilizando GNU Debugger
João Victor Ignacio
Posted on March 30, 2021
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;
}
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:
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.
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.
Agora sim. A função stack_only termina e neste instante, seu stack_frame é liberado, o que deixa nosso Stack assim:
Por fim, a função main se encerra e seu stack_frame também é liberado:
É 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);
}
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:
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:
Ao executarmos a função stack_and_heap, vamos ter um stack_frame para ela também. Até aqui, nada demais:
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.
Nosso programa poderia ter vários outros objetos alocados no Heap, e ele ficaria mais ou menos assim:
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:
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:
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.
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")
}
Com nosso exemplo compilado, posso iniciá-lo junto ao gdb da seguinte forma:
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.
Com essa alteração, o seu display do gdb deve ter ficado mais ou menos com a seguinte aparência:
Agora, podemos por exemplo visualizar nosso código para analisarmos onde podemos colocar alguns breakpoints. Para isso, podemos utilizar o seguinte comando:
E nossa visualização no gdb será a seguinte:
Para colocarmos um breakpoint em um ponto específico, podemos utilizar o comando:
Visando já alguns pontos interessantes, vamos colocar nas 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:
Agora, sem mais delongas, vamos executar nosso código utilizando um comando simples, de uma letra só:
E agora, nosso código deve ter se iniciado e travado no nosso primeiro breakpoint, na linha 4:
Como estamos na função main, significa que já temos um stack_frame. Podemos observá-lo utilizando o comando backtrace:
Como sabemos que temos apenas 1 stack_frame, então vamos utilizar o comando passando esse valor. O que nos forneceria o seguinte resultado:
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:
Se executarmos esse comando agora, este será o output:
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:
Que nos dá o seguinte resultado:
Se agora tentarmos utilizar o comando para analisarmos as alocações de variáveis:
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:
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:
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:
Podemos inclusive observar como o stack_frame da função stack_only está de fato vindo antes do stack_frame
Posted on March 30, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.