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.
- 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 rua | Na memória |
|---|---|
| Uma casa | Um 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 casa | O endereço do byte. |
| Uma família que precisa de 4 casas | Uma variável int, que ocupa 4 bytes vizinhos. |
| O endereço da família | O 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:
charsempre 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
boolsó 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
0a9e depois dea(10) af(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:
- 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.
- As duas variáveis moram coladas. Na primeira execução,
idadecomeça em…85cealturaem…860. A diferença é 4 (em hexadecimal, depois decvemd,e,f,60), exatamente o tamanho de umint.
| Casa (endereço) | …85c | …85d | …85e | …85f | …860 | …861 | … | …867 | …868 |
|---|---|---|---|---|---|---|---|---|---|
| Quem mora | idade (4 bytes) = 29 | altura (8 bytes) = 1.65 | outra coisa | ||||||
&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 " << ¬as[0] << "\n";
std::cout << "notas[1] mora em " << ¬as[1] << "\n";
std::cout << "notas[2] mora em " << ¬as[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ão | Endereços (na Ana) | O que mora lá |
|---|---|---|
| Pilha ↓ cresce para baixo | altos, 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 cima | mais 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. |
| Globais | perto do heap | Variáveis criadas fora de qualquer função. |
| Código | os mais baixos do programa | As instruções do próprio programa. |
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áveis | Endereço |
|---|---|---|
main | a = 1 | 0x7ffd4c532854 |
primeira | b = 2 | 0x7ffd4c532834 |
segunda (o topo da pilha) | c = 3 | 0x7ffd4c532814 |
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 usa | Variáveis locais e parâmetros | std::vector, std::string, new, std::make_unique |
| Quem reserva e devolve | O compilador, sozinho, na entrada e na saída de cada bloco | O programa, quando pede e quando devolve |
| Tamanho | Pequeno: 8 MB por padrão no Linux | Quase toda a RAM livre |
| Velocidade | Muito rápida | Mais lenta (é preciso achar um espaço livre) |
| Tamanho da variável | Precisa ser conhecido ao compilar | Pode ser decidido durante a execução |
| Erro típico | Estouro 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:
| Tipo | Exemplo | Nasce | Morre |
|---|---|---|---|
| Automático (local, na pilha) | int x = 5; dentro de uma função ou bloco | Na linha da declaração | No } que fecha o bloco onde ela foi criada |
| Estático (global) | int visitas = 0; fora das funções | Antes do main começar | Quando o programa termina |
| Dinâmico (no heap) | os itens de um std::vector, um new int | Quando o programa pede | Quando 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
$ ./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.
$ 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.
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.
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 termo | O que é |
|---|---|
| byte | A menor casa da memória com endereço próprio (8 bits, valores de 0 a 255). |
| endereço | O 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 variavel | Quantos bytes a variável ocupa. Num array, o total de todos os itens. |
&x | O 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. |
| ASLR | Proteção que sorteia os endereços a cada execução. |
ulimit -s | Mostra 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:
2. O endereço
Complete para mostrar o endereço da variável preco:
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).
sizeofdiz quantos bytes um tipo ou variável ocupa:char1,int4,double8 (no Linux de 64 bits).&xmostra o endereço dex, 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.