Introdução a Zig - Parte 5

Memória

Enzo Soares

Publicado em 24 de Março de 2025 às 20:29

Saudações!

O tema de hoje é alocação de memória em linguagens de programação e mais especificamente em Zig. É um tema bem complexo e eu só vou poder arranhar a superfície dele, mas espero poder dar uma base e entendimento a você, leitor.

A medida em que um programa é executado em um computador, ele vai precisando de quantidades variáveis de memória para funcionar. Em linguagens de baixo nível, como Zig, esse gerenciamento é feito diretamente pelo programador. Em outras linguagens, há ferramentas que lidam com esse aspecto. Essa memória é armazenada em duas estruturas distintas: a stack e o heap.

Stack

O stack, que significa pilha, é uma estrutura de memória no modelo FIFO (First in, first out). Ela é gerida pelo compilador e nós temos acesso limitado a ela. É a forma mais organizada, segura e rápida de alocar memória, mas o programador não tem controle dela em tempo de execução, apenas em tempo de compilação.

pub fn main() void {
    var first: u8 = 1;
    var second: u8 = 2;

    std.debug.print("O resultado é {d}", .{first+second});
}

Quando o compilador vai construir o executável do código acima, ele sabe com antecedência exatamente quanta memória será utilizada, já que todas as alocações são estáticas. Mas como dados são armazenados dentro da stack?

Ao entrar na função main, o compilador coloca na stack de execução os dados na seguinte ordem, chamados em conjunto de stack frame:

  1. Os argumentos da função. Nesse caso, nada.
  2. O endereço de memória para o qual a execução tem que voltar após finalizar a execução de main.
  3. As variáveis locais.

Caso uma segunda função seja chamada dentro da main, uma nova stack frame referente à nova função é posta na última posição da stack. Quando essa segunda função retorna um valor, a execução de código volta para a posição marcada no ponto 2 e as informações guardadas no seu stack frame são marcadas para exclusão.

Por exemplo, observe o seguinte código:

const std = @import("std");

fn sum(num1: u8, num2: u8) u8 {
    return num1 + num2;
}

pub fn main() void {
    const first: u8 = 1;
    const second: u8 = 2;

    const result = sum(first, second);
    std.debug.print("O resultado é {d}", .{result});
}

Em primeiro momento, a stack pode ser representada como:

--- Stack ---

  • main -
  1. Parâmetros (void)
  2. O endereço de retorno da função
  3. first e second

A partir da linha const result = sum(first, second);, a stack se encontra assim:

--- Stack ---

  • main -
  1. Parâmetros (void)
  2. O endereço de retorno da função
  3. first e second

    • sum -
  4. Parâmetros (num1 e num2)

  5. A linha const result = sum(first, second);
  6. void

Quando sum retornar, a stack frame dela é liberada e a stack retorna para o estado:

--- Stack --- - main -

  1. Parâmetros (void)
  2. O endereço de retorno da função
  3. first e second

Quando main finalizar, a stack está vazia e o código finalizou sua execução. Observe que, ao sair de um stack frame, todas as informações ali guardadas são excluídas; ou melhor, marcadas para exclusão.

Stack Overflow

Stack Overflow, que significa vazamento da pilha, é um problema que ocorre quando a pilha não tem mais espaço para suportar as stack frames subsequentes.

pub fn main() void {
    main();
}

No exemplo acima, a stack cresce sem freios, até que todo o espaço alocado para ela acabe. Nesse ponto, ocorre a Stack Overflow.

Heap

Enquanto a stack é organizada e segue regras bem definidas, o heap, que significa monte, é o velho oeste. Você pode imaginar ele como um dicionário de dados: existem vários ponteiros, que são as posições em memória, e cada um aponta para um valor, que é o dado em si. O heap está sob controle do programador e nós interagimos com ele através das chamadas alloc e free. A primeira reserva um espaço de memória e retorna um ponteiro para esse espaço, enquanto a segunda recebe um ponteiro como argumento e libera seu valor para ser usada por outrem. Observe o seguinte exemplo:

const std = @import("std");

fn allocate_data(allocator: std.mem.Allocator, size: u16) ![]u16 {
    const allocated_data = try allocator.alloc(u16, size);
    return allocated_data;
}

pub fn main() !void {
    _ = try allocate_data(std.heap.page_allocator, 20);
}

Na função allocate data, a função alloc reserva na memória uma quantidade de bits igual ao tamanho do primeiro argumento vezes o segundo argumento. No exemplo, a função reservou 20 * 16 = 320 bits de memória no heap, ou 40 bytes. allocated_data recebe então um ponteiro para a posição do primeiro item do vetor, que tem tamanho de 2 bytes. Para acessar o décimo item do vetor, é só uma questão de adicionar 10 x 2 (tamanho em bytes do tipo u16) ao endereço inicial.

Vazamento de memória

Como os ponteiros do heap não estão vinculados à stack frame e devem ser liberados manualmente com free, ou pode ocorrer o chamado Vazamento de Memória, que é o acúmulo de memória não utilizada. Observe o exemplo:

const std = @import("std");

fn allocate_data(allocator: std.mem.Allocator, size: u8) ![]u8 {
    const another_allocated_data = try allocator.alloc(u8, size);
    //fazer alguma coisa com another_allocated_data


    const allocated_data = try allocator.alloc(u8, size);
    return allocated_data;
}

pub fn main() !void {
    const data = try allocate_data(std.heap.page_allocator, 20);
    std.heap.page_allocator.free(data);
}

another_allocated_data é uma referência de memória a outros 320 bits. No entanto, ao sair do stack frame de allocate_data, os 320 bits não são perdidos, mas o ponteiro sim. Agora temos um espaço em memória que não conseguimos acessar, um vazamento de memória.

No próximo blog, falaremos sobre como lidar com vazamentos de memória com as ferramentas próprias do Zig. Até!