C++ Como a Memória Funciona

Até agora você criou variáveis sem pensar onde elas ficam. Para entender ponteiros, new e ponteiros inteligentes (as próximas aulas), você precisa de um mapa da memória. Esta aula não tem nada de novo para decorar: é o mapa. Você vai ver que a memória é uma rua comprida de casinhas numeradas, que cada variável mora num endereço, e que existem dois "bairros" com regras bem diferentes: a pilha e o heap.

Você vai aprender
  • O que são bytes e endereços (o "número da casa" de cada byte).
  • Como perguntar o tamanho de um tipo ou variável com sizeof.
  • Como ver o endereço de uma variável com &x, e por que ele muda a cada execução.
  • A diferença entre a pilha (stack) e o monte (heap).
  • Quanto tempo vive cada variável, e o que acontece quando a pilha estoura.

Todos os programas foram compilados na máquina fictícia da Ana (usuária ana, computador notebook, um Linux de 64 bits), na pasta ~/cpp, com o comando padrão do guia, g++ -std=c++23 -Wall -Wextra arquivo.cpp -o programa (veja Compilar com g++).


A ideia: uma rua de casinhas numeradas

Imagine uma rua muito, muito comprida. Cada casa é minúscula e guarda um único número pequeno, de 0 a 255. Cada casa tem uma placa com o seu número, e não existem duas casas com o mesmo número. A memória do computador (a RAM) é exatamente isso:

Na ruaNa memória
Uma casaUm byte: o menor pedaço de memória que tem número próprio. Guarda um valor de 0 a 255 (8 bits, oito "zeros ou uns").
O número da casaO endereço do byte.
Uma família que precisa de 4 casasUma variável int, que ocupa 4 bytes vizinhos.
O endereço da famíliaO número da primeira casa que ela ocupa.

Quando você escreve int idade = 29;, o compilador reserva 4 casas seguidas, guarda o 29 nelas e anota: "idade mora a partir da casa tal". O nome idade só existe no seu código. Lá dentro do computador, só existem os números das casas.


1. Quantas casas cada tipo ocupa: sizeof

O operador sizeof ("size of", tamanho de) responde quantos bytes um tipo ou uma variável ocupa:

#include <iostream>
#include <string>

int main() {
    std::cout << "char:        " << sizeof(char) << " byte\n";
    std::cout << "bool:        " << sizeof(bool) << " byte\n";
    std::cout << "int:         " << sizeof(int) << " bytes\n";
    std::cout << "long long:   " << sizeof(long long) << " bytes\n";
    std::cout << "float:       " << sizeof(float) << " bytes\n";
    std::cout << "double:      " << sizeof(double) << " bytes\n";
    std::cout << "std::string: " << sizeof(std::string) << " bytes\n";
}
$ g++ -std=c++23 -Wall -Wextra tamanhos.cpp -o tamanhos
$ ./tamanhos
char:        1 byte
bool:        1 byte
int:         4 bytes
long long:   8 bytes
float:       4 bytes
double:      8 bytes
std::string: 32 bytes        « o "envelope" da string, não o texto (veja abaixo)

Alguns detalhes:

  • char sempre ocupa 1 byte, por definição. Os outros tamanhos dependem da máquina e do compilador. Os números acima são os de um Linux de 64 bits com o g++, que é o caso mais comum hoje. Veja a tabela completa em Tipos e Limites.
  • O bool só precisa de um bit (verdadeiro ou falso), mas ocupa 1 byte inteiro, porque o byte é a menor casa com endereço.
  • O sizeof(std::string) dá 32 mesmo que o texto tenha mil letras. Os 32 bytes são o "envelope" (onde está o texto, quantas letras tem). Texto grande fica guardado em outro lugar, o heap, que você vai ver daqui a pouco.

sizeof de uma variável

Você também pode perguntar o tamanho de uma variável. Com variável, os parênteses são opcionais (sizeof altura); com um tipo, são obrigatórios (sizeof(int)). Num array, sizeof devolve o tamanho de todos os itens juntos:

#include <iostream>

int main() {
    int idade = 29;
    double altura = 1.65;
    int notas[5] = {7, 8, 6, 9, 10};

    std::cout << "idade:  " << sizeof(idade) << " bytes\n";
    std::cout << "altura: " << sizeof altura << " bytes\n";
    std::cout << "notas:  " << sizeof(notas) << " bytes\n";
    std::cout << "itens:  " << sizeof(notas) / sizeof(notas[0]) << "\n";
}
$ g++ -std=c++23 -Wall -Wextra sizeof-var.cpp -o sizeof-var
$ ./sizeof-var
idade:  4 bytes
altura: 8 bytes
notas:  20 bytes             « 5 itens × 4 bytes
itens:  5                    « 20 bytes ÷ 4 bytes por item

A conta sizeof(notas) / sizeof(notas[0]) (tamanho total dividido pelo tamanho de um item) é um truque antigo para descobrir quantos itens um array tem. Ele tem uma armadilha séria, que aparece em Ponteiros e Arrays. Hoje, prefira std::size(notas), ou use logo um std::array ou std::vector, que sabem o próprio tamanho com .size().


2. Onde a variável mora: o endereço &x

Colocar & antes do nome de uma variável pergunta: "qual é o número da sua casa?". O resultado é o endereço dela:

#include <iostream>

int main() {
    int idade = 29;
    double altura = 1.65;

    std::cout << "idade  vale " << idade  << " e mora em " << &idade  << "\n";
    std::cout << "altura vale " << altura << " e mora em " << &altura << "\n";
}
$ g++ -std=c++23 -Wall -Wextra endereco.cpp -o endereco
$ ./endereco
idade  vale 29 e mora em 0x7ffe28cde85c
altura vale 1.65 e mora em 0x7ffe28cde860
$ ./endereco                  « o mesmo programa, de novo
idade  vale 29 e mora em 0x7fff6199f62c
altura vale 1.65 e mora em 0x7fff6199f630
&idade
O operador de endereço. Não confunda com o & da aula Referências: lá ele vinha depois de um tipo (int& r, "referência para int"); aqui ele vem antes de uma variável (&idade, "endereço de idade"). Mesmo símbolo, dois trabalhos.
0x
Avisa que o número está em hexadecimal (base 16). Endereços são números enormes, e o hexadecimal os escreve mais curto. Os "algarismos" vão de 0 a 9 e depois de a (10) a f (15).
7ffe28cde85c
O número da casa. Em decimal, seria 140 729 583 003 740. Você não precisa fazer essa conta: endereços servem para comparar e para o computador usar, não para ler.

Repare em duas coisas na saída:

  1. Os endereços mudaram de uma execução para outra. Isso é normal e proposital. O Linux sorteia, a cada execução, onde cada região de memória do programa vai começar. Essa proteção se chama ASLR (Address Space Layout Randomization, "embaralhamento do mapa de memória") e atrapalha quem tenta invadir um programa adivinhando onde estão as coisas. Por isso, quando você rodar estes exemplos, vai ver outros números. Nunca escreva um endereço fixo no seu código.
  2. As duas variáveis moram coladas. Na primeira execução, idade começa em …85c e altura em …860. A diferença é 4 (em hexadecimal, depois de c vem d, e, f, 60), exatamente o tamanho de um int.
Casa (endereço)…85c…85d…85e…85f…860…861……867…868
Quem moraidade (4 bytes) = 29altura (8 bytes) = 1.65outra coisa
A rua da primeira execução. &idade é o número da primeira casa da família idade; as outras três vêm logo em seguida.

O compilador escolhe a ordem e a posição das variáveis como achar melhor: às vezes deixa um espaço vazio entre elas, às vezes inverte a ordem. Nos arrays, porém, a regra é fixa: os itens moram sempre colados, um depois do outro:

#include <iostream>

int main() {
    int notas[3] = {7, 8, 6};

    std::cout << "notas[0] mora em " << &notas[0] << "\n";
    std::cout << "notas[1] mora em " << &notas[1] << "\n";
    std::cout << "notas[2] mora em " << &notas[2] << "\n";
}
$ g++ -std=c++23 -Wall -Wextra vizinhos.cpp -o vizinhos
$ ./vizinhos
notas[0] mora em 0x7ffc4ea0571c
notas[1] mora em 0x7ffc4ea05720     « 4 casas depois
notas[2] mora em 0x7ffc4ea05724     « mais 4 casas

Essa "rua sem buracos" é o que permite andar de item em item com ponteiros (aula Ponteiros e Arrays).

Experimente: rode e veja onde as variáveis moram. Rode de novo e compare. Depois crie uma variável char inicial = 'A'; e mostre sizeof(inicial). No editor do guia, os endereços terão outra cara (números bem menores): o programa roda em WebAssembly, dentro do navegador, que organiza a memória do seu jeito. A ideia é a mesma.

#include <iostream>

int main() {
    int idade = 29;
    double altura = 1.65;

    std::cout << "idade:  " << sizeof(idade)  << " bytes, mora em " << &idade  << "\n";
    std::cout << "altura: " << sizeof(altura) << " bytes, mora em " << &altura << "\n";
}

3. Dois bairros: a pilha e o heap

A memória de um programa em execução é dividida em regiões. Duas delas importam muito para você:

  • A pilha (em inglês, stack). Pense numa pilha de bandejas no restaurante. Cada função que começa a rodar pega uma bandeja e coloca em cima da pilha, com as variáveis locais dela. Quando a função termina, a bandeja dela sai de cima e é reaproveitada. Rápido, automático e organizado, mas pequeno.
  • O monte (em inglês, heap). Pense num depósito de aluguel. Você pede um espaço do tamanho que precisar, quando precisar, e ele fica seu até você devolver. Grande e flexível, mas alguém precisa lembrar de devolver.
RegiãoEndereços (na Ana)O que mora lá
Pilha ↓ cresce para baixoaltos, perto de 0x7ff…Variáveis locais e parâmetros de cada função em andamento.
(espaço livre)A pilha e o heap crescem um em direção ao outro.
Heap ↑ cresce para cimamais baixos, perto de 0x5… ou 0x6…O que é pedido durante a execução: os itens de um std::vector, o texto de uma std::string grande, o que se cria com new.
Globaisperto do heapVariáveis criadas fora de qualquer função.
Códigoos mais baixos do programaAs instruções do próprio programa.
O mapa simplificado de um programa no Linux de 64 bits. Os números exatos mudam a cada execução (ASLR), mas a arrumação é sempre essa.

A pilha em ação

Veja as bandejas se empilhando: main chama primeira, que chama segunda. Cada uma tem uma variável local:

#include <iostream>

void segunda() {
    int c = 3;
    std::cout << "c (em segunda) mora em " << &c << "\n";
}

void primeira() {
    int b = 2;
    std::cout << "b (em primeira) mora em " << &b << "\n";
    segunda();
}

int main() {
    int a = 1;
    std::cout << "a (em main)     mora em " << &a << "\n";
    primeira();
}
$ g++ -std=c++23 -Wall -Wextra pilha.cpp -o pilha
$ ./pilha
a (em main)     mora em 0x7ffd4c532854
b (em primeira) mora em 0x7ffd4c532834     « 0x20 (32) casas abaixo
c (em segunda) mora em 0x7ffd4c532814      « mais 32 casas abaixo

Cada função nova ganhou uma bandeja abaixo da anterior: no computador, a pilha cresce para os endereços menores. Desenhando de cima para baixo, como os endereços:

Bandeja (de quem)VariáveisEndereço
maina = 10x7ffd4c532854
primeirab = 20x7ffd4c532834
segunda (o topo da pilha)c = 30x7ffd4c532814
A pilha no momento em que segunda mostra o endereço de c. Quando segunda termina, a bandeja dela sai; depois sai a de primeira.

A bandeja é reaproveitada

Quando uma função termina, as variáveis locais dela deixam de existir. Se a mesma função for chamada de novo, ela ganha uma bandeja nova (muitas vezes no mesmo lugar) e as variáveis nascem de novo, do zero:

#include <iostream>

void contar() {
    int vezes = 0;
    vezes = vezes + 1;
    std::cout << "vezes = " << vezes << " (mora em " << &vezes << ")\n";
}

int main() {
    contar();
    contar();
    contar();
}
$ g++ -std=c++23 -Wall -Wextra reuso.cpp -o reuso
$ ./reuso
vezes = 1 (mora em 0x7ffc20a28944)
vezes = 1 (mora em 0x7ffc20a28944)       « mesma casa, mas a variável nasceu de novo
vezes = 1 (mora em 0x7ffc20a28944)

A vezes nunca chega a 2: cada chamada cria uma vezes nova, valendo 0. É por isso que, na aula Referências, devolver um apelido para uma variável local dava problema: quando a função termina, a bandeja dela vira terreno livre, e a próxima função pode escrever por cima.

O heap em ação

Você já usou o heap sem saber. Um std::vector tem duas partes: um "controle" pequeno, que mora na pilha como qualquer variável local, e os itens, que moram no heap. O .data() mostra onde começam os itens:

#include <iostream>
#include <vector>

int main() {
    int local = 10;
    std::vector<int> lista = {1, 2, 3};

    std::cout << "local          (pilha) mora em  " << &local << "\n";
    std::cout << "lista          (pilha) mora em  " << &lista << "\n";
    std::cout << "itens da lista (heap)  moram em " << lista.data() << "\n";
}
$ g++ -std=c++23 -Wall -Wextra heap.cpp -o heap
$ ./heap
local          (pilha) mora em  0x7fff449b82b4
lista          (pilha) mora em  0x7fff449b82c0
itens da lista (heap)  moram em 0x65010a34a020    « outro bairro, bem longe

Os dois primeiros endereços começam com 0x7fff… (pilha). O terceiro, 0x6501…, é de outro bairro: o heap. O vetor pediu ao heap espaço para 3 ints e, quando a lista deixar de existir, ele devolve esse espaço sozinho. Você não precisa fazer nada. Pedir e devolver espaço no heap "na mão" é assunto da aula Memória Dinâmica (new e delete), e fazer isso com segurança, da aula Ponteiros Inteligentes.

Pilha × heap

Pilha (stack)Heap (monte)
Quem usaVariáveis locais e parâmetrosstd::vector, std::string, new, std::make_unique
Quem reserva e devolveO compilador, sozinho, na entrada e na saída de cada blocoO programa, quando pede e quando devolve
TamanhoPequeno: 8 MB por padrão no LinuxQuase toda a RAM livre
VelocidadeMuito rápidaMais lenta (é preciso achar um espaço livre)
Tamanho da variávelPrecisa ser conhecido ao compilarPode ser decidido durante a execução
Erro típicoEstouro da pilha (stack overflow)Esquecer de devolver (vazamento) ou usar depois de devolver

4. Quanto tempo vive cada variável

O tempo de vida é o intervalo entre o momento em que a caixa é montada e o momento em que é desmontada. Em C++, existem três tipos principais:

TipoExemploNasceMorre
Automático (local, na pilha)int x = 5; dentro de uma função ou blocoNa linha da declaraçãoNo } que fecha o bloco onde ela foi criada
Estático (global)int visitas = 0; fora das funçõesAntes do main começarQuando o programa termina
Dinâmico (no heap)os itens de um std::vector, um new intQuando o programa pedeQuando o programa devolve

Morreu no }

Qualquer par de chaves { } forma um bloco, e a variável criada dentro dele morre no }. Depois disso, o nome nem existe mais:

#include <iostream>

int main() {
    {
        int temporaria = 5;
        std::cout << "Dentro: " << temporaria << "\n";
    }
    std::cout << "Fora: " << temporaria << "\n";
}
$ g++ -std=c++23 -Wall -Wextra escopo.cpp -o escopo
escopo.cpp: In function ‘int main()’:
escopo.cpp:8:30: error: ‘temporaria’ was not declared in this scope
    8 |     std::cout << "Fora: " << temporaria << "\n";
      |                              ^~~~~~~~~~

"temporaria não foi declarada neste escopo". O escopo (a região do código onde um nome vale) e o tempo de vida andam juntos para as variáveis locais. A aula Escopo explica as regras dos nomes.

A global vive o programa inteiro

#include <iostream>

int visitas = 0;              // global: vive o programa inteiro

void visitar() {
    int agora = 1;            // local: nasce e morre a cada chamada
    visitas = visitas + agora;
    std::cout << "visitas = " << visitas << "\n";
}

int main() {
    visitar();
    visitar();
    visitar();
    std::cout << "global visitas mora em " << &visitas << "\n";
}
$ g++ -std=c++23 -Wall -Wextra global.cpp -o global
$ ./global
visitas = 1
visitas = 2                  « a global guardou o valor entre as chamadas
visitas = 3
global visitas mora em 0x62832db57154    « longe da pilha (0x7ff…)

Compare com o reuso.cpp: lá, a local voltava sempre a 0. Variáveis globais são práticas, mas qualquer função pode mexer nelas, e isso torna o programa difícil de entender. Use pouco.


5. A pilha é pequena

No Linux, cada programa ganha, por padrão, uma pilha de 8 MB. Você pode conferir com ulimit -s, que mostra o tamanho em kilobytes:

$ ulimit -s
8192                         « 8192 KB = 8 MB

Parece muito, mas um array local grande passa disso fácil. Três milhões de int são 12 MB:

#include <iostream>

int main() {
    int numeros[3'000'000];      // 3 milhões de int na pilha: 12 MB
    numeros[0] = 1;
    std::cout << "Coube! " << numeros[0] << "\n";
}
$ g++ -std=c++23 -Wall -Wextra estouro.cpp -o estouro
$ ./estouro
Falha de segmentação     (imagem do núcleo gravada) ./estouro
$ echo $?
139

Compilou sem nenhum aviso, e o programa morreu antes de mostrar qualquer coisa. Isso é um estouro da pilha (stack overflow, o nome do famoso site de perguntas vem daí): o programa tentou usar casas além do fim do bairro. O sistema percebe e mata o programa com uma falha de segmentação (em inglês, Segmentation fault); o código 139 é a marca disso. Os apóstrofos em 3'000'000 são só separadores de milhar, permitidos desde o C++14.

Conserto: coisas grandes vão para o heap. O jeito mais simples é um std::vector:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> numeros(3'000'000);   // os itens vão para o heap
    numeros[0] = 1;
    std::cout << "Coube! " << numeros[0] << " (" << numeros.size() << " itens)\n";
}
$ g++ -std=c++23 -Wall -Wextra estouro-ok.cpp -o estouro-ok
$ ./estouro-ok
Coube! 1 (3000000 itens)

O outro jeito clássico de estourar a pilha é uma função que chama a si mesma sem parar: cada chamada empilha mais uma bandeja. Veja em Recursão.


Quando dá errado

"Falha de segmentação" sem mensagem nenhuma do programa
$ ./estouro
Falha de segmentação     (imagem do núcleo gravada) ./estouro
$ echo $?
139

O que aconteceu: o programa mexeu numa memória que não podia. Nesta aula, a causa foi um array local grande demais para a pilha. Outras causas comuns aparecem nas próximas aulas: ponteiro nulo (nullptr), ponteiro para memória já devolvida (new e delete) e referência pendurada.

Como resolver: se você tem um array local enorme, troque por std::vector. Se não sabe onde está o problema, compile com -g e use o GDB, ou um sanitizer, que dizem a linha.

"was not declared in this scope" para uma variável que existe
$ g++ -std=c++23 -Wall -Wextra escopo.cpp -o escopo
escopo.cpp: In function ‘int main()’:
escopo.cpp:8:30: error: ‘temporaria’ was not declared in this scope
    8 |     std::cout << "Fora: " << temporaria << "\n";
      |                              ^~~~~~~~~~

O que aconteceu: a variável existe, mas morreu no } antes da linha que a usa.

Como resolver: declare a variável antes do bloco, no nível em que ela vai ser usada.

O endereço de um char aparece como texto
#include <iostream>

int main() {
    char letra = 'A';
    std::cout << "Endereço? " << &letra << "\n";
    std::cout << "Endereço: " << static_cast<void*>(&letra) << "\n";
}
$ g++ -std=c++23 -Wall -Wextra letra.cpp -o letra
$ ./letra
Endereço? A
Endereço: 0x7ffcbe2cb217

O que aconteceu: para o std::cout, o endereço de um char é o começo de um texto no estilo antigo do C, e ele mostra as letras a partir dali até encontrar um byte zero. Desta vez saiu só A, por sorte; poderia vir lixo depois do A.

Como resolver: para ver o número da casa de um char, converta para "endereço genérico" com static_cast<void*>(&letra) (veja Conversão de Tipos). Com int, double e os outros tipos, isso não é preciso.

"Os meus endereços são diferentes dos da aula"

O que aconteceu: nada de errado. Por causa do ASLR, os endereços mudam a cada execução, em qualquer computador. No editor do guia, eles têm até outro formato, porque o programa roda em WebAssembly.

Como resolver: não há o que resolver. Compare endereços do mesmo programa, na mesma execução (a diferença entre eles), nunca o número em si.


Referência rápida

Código ou termoO que é
byteA menor casa da memória com endereço próprio (8 bits, valores de 0 a 255).
endereçoO número de um byte. O endereço de uma variável é o da primeira casa que ela ocupa.
sizeof(tipo)Quantos bytes o tipo ocupa. sizeof(int) → 4 no Linux de 64 bits.
sizeof variavelQuantos bytes a variável ocupa. Num array, o total de todos os itens.
&xO endereço de x, mostrado em hexadecimal (0x…).
static_cast<void*>(&c)Mostra o endereço de um char em vez do texto.
pilha (stack)Região das variáveis locais. Automática, rápida, pequena (8 MB).
heap (monte)Região do que é pedido durante a execução. Grande; precisa ser devolvida.
ASLRProteção que sorteia os endereços a cada execução.
ulimit -sMostra o tamanho máximo da pilha, em KB (no terminal, não no C++).

Exercícios

1. Tamanho de um tipo

Complete para mostrar quantos bytes um double ocupa:

std::cout << (double);

2. O endereço

Complete para mostrar o endereço da variável preco:

std::cout << ;

3. Você rodou o mesmo programa duas vezes e o endereço de idade mudou. Por quê?

4. Onde moram os itens de um std::vector<int> criado dentro do main?

5. Uma função tem int contador = 0; contador++; e é chamada 3 vezes. Quanto vale contador no fim da terceira chamada?

Missão final: quantos produtos?

Missão: a lojinha da Ana guarda 6 preços num array de double. Sem contar à mão e sem escrever o número 6 no std::cout, use sizeof para mostrar quantos bytes o array ocupa e quantos itens ele tem:

Bytes: 48
Itens: 6
#include <iostream>

int main() {
    double precos[6] = {4.5, 3.0, 12.9, 7.25, 1.99, 5.0};

    // mostre "Bytes: " e o tamanho total do array
    // mostre "Itens: " e o total dividido pelo tamanho de um item
}
#include <iostream>

int main() {
    double precos[6] = {4.5, 3.0, 12.9, 7.25, 1.99, 5.0};

    std::cout << "Bytes: " << sizeof(precos) << "\n";
    std::cout << "Itens: " << sizeof(precos) / sizeof(precos[0]) << "\n";
}

Resumo

  • A memória é uma rua de bytes; cada byte tem um endereço (o número da casa).
  • sizeof diz quantos bytes um tipo ou variável ocupa: char 1, int 4, double 8 (no Linux de 64 bits).
  • &x mostra o endereço de x, em hexadecimal. Ele muda a cada execução (ASLR); isso é normal.
  • Variáveis locais moram na pilha: cada função ganha uma bandeja, que sai quando ela termina. A pilha é rápida, mas pequena (8 MB).
  • O heap guarda o que é pedido durante a execução (itens de std::vector, std::string, new) e precisa ser devolvido. Os containers devolvem sozinhos.
  • Locais morrem no }; globais vivem o programa inteiro; o que está no heap vive até ser devolvido.
  • Próxima aula: Ponteiros, variáveis que guardam um endereço.