Pular para conteúdo

Semana 10 — Estrutura de memória e ponteiros (parte 1)

Nesta semana

Unidade 3 · Seg 12/10 · Qua 14/10 · Sex 16/10 · ⚠️ feriado: 12/10 (N. Sra. Aparecida)

Chegamos ao assunto mais desafiador — e talvez o mais poderoso — de toda a disciplina: memória e ponteiros. Se em algum momento do curso você vai precisar ir devagar, respirar fundo e reler com calma, é agora. A boa notícia: nada aqui é "mágica". É tudo consequência lógica de uma ideia simples que você já conhece desde a Semana 01 — a de que variáveis vivem na memória. Vamos, finalmente, abrir essa caixa e olhar dentro dela.

Como a segunda-feira (12/10) é feriado, use esse dia para uma primeira leitura tranquila. Não tenha pressa: o investimento que você fizer nesta semana e na próxima (ponteiros parte 2) rende juros no resto do curso — e no resto da sua vida como programador(a). Muita gente "trava" em ponteiros por pular etapas. Você não vai pular: vamos construir tudo do zero, com desenhos e analogias.

🎯 Objetivos de aprendizagem

Ao final desta semana, você deverá ser capaz de:

  • explicar como a memória de um programa é organizada em bytes endereçados e conhecer, em alto nível, as regiões código, globais/estáticas, heap e pilha (stack);
  • usar o operador & ("endereço de") e imprimir endereços com %p — e entender, enfim, por que o scanf sempre pediu &;
  • explicar o que é um ponteiro (uma variável que guarda um endereço) e declarar ponteiros com a sintaxe tipo *p;
  • atribuir o endereço de uma variável a um ponteiro (int *p = &x;);
  • usar o operador de desreferência * para ler e alterar o valor apontado, e distinguir o * da declaração do * da desreferência;
  • entender o ponteiro nulo NULL, por que sempre inicializar ponteiros e por que nunca desreferenciar NULL (o famoso segmentation fault);
  • ler e prever, sem erro, expressões que misturam &x, p, *p e &p.

📖 Segunda — Estudo do conteúdo

1. Retomando: variáveis vivem na memória

Lá na Semana 01 usamos a analogia da caixa etiquetada: uma variável é um espaço nomeado na memória RAM que guarda um valor. A etiqueta é o nome (idade), o conteúdo é o valor (20) e o tamanho/formato da caixa vem do tipo (int).

int idade = 20;     // uma "caixa" chamada idade, contendo o valor 20

Dissemos, também, que quando o programa roda ele é carregado do disco para a RAM, e as variáveis passam a existir ali durante a execução. Só ficou faltando uma pergunta: onde, exatamente, na memória? É essa pergunta que abre a Unidade 3 — e a resposta é a chave de tudo o que vem a seguir. Guarde a analogia da caixa: vamos agora descobrir que cada caixa tem também um número de casa (um endereço).

2. Como a memória é organizada: bytes numerados

A memória RAM pode ser imaginada como um prédio enorme de caixas de correio, todas iguais, enfileiradas. Cada caixinha guarda 1 byte (8 bits) e tem um número único — o seu endereço. Os endereços são inteiros que começam em 0 e vão crescendo, um por byte, até o fim da memória.

flowchart LR
    subgraph mem["Memória: bytes numerados sequencialmente"]
    direction LR
    B0["endereco 1000<br/>1 byte"] --- B1["1001<br/>1 byte"] --- B2["1002<br/>1 byte"] --- B3["1003<br/>1 byte"] --- B4["1004<br/>1 byte"]
    end

Uma variável int idade = 20; não ocupa só 1 byte: um int ocupa 4 bytes (veja a tabela de tipos da Semana 01). Então idade ocupa 4 caixinhas consecutivas — por exemplo, os endereços 1000, 1001, 1002 e 1003. O endereço da variável é, por convenção, o primeiro desses bytes (o 1000). O tipo (int) é o que informa ao computador "leia 4 bytes a partir daqui e interprete como um número inteiro".

Curiosidade 💡 — cada byte tem endereço, mas lemos em blocos

A memória é endereçada por byte: todo byte, individualmente, tem um número. Mas quase nunca trabalhamos com 1 byte isolado — trabalhamos com variáveis, que ocupam vários bytes conforme o tipo. É o tipo que diz quantos bytes formam o valor e como interpretá-los. Por isso o mesmo endereço "1000" pode ser o começo de um int (4 bytes) ou de um char (1 byte): o endereço aponta o começo; o tipo diz o tamanho.

2.1 As regiões de memória de um programa

Quando o sistema operacional coloca seu programa para rodar, ele reserva um espaço de memória e o organiza em regiões com finalidades diferentes. Não precisamos decorar tudo, mas conhecer o mapa ajuda muito a entender ponteiros:

Região O que guarda Tempo de vida
Código (text) As instruções do programa (o executável em si). Toda a execução (só leitura).
Globais / estáticas Variáveis globais e static (Semana 04). Toda a execução.
Heap Memória pedida manualmente pelo programa (Semana 11: malloc). Até você liberar (free).
Pilha (stack) Variáveis locais e parâmetros de cada função. Nasce e morre com a chamada da função.
flowchart TB
    subgraph prog["Memoria de um programa em execucao"]
    direction TB
    S["PILHA (stack)<br/>variaveis locais, parametros<br/>cresce para baixo ⬇"]
    G1[" "]
    H["HEAP<br/>memoria dinamica (malloc)<br/>cresce para cima ⬆"]
    D["GLOBAIS / ESTATICAS<br/>globais e static"]
    T["CODIGO (text)<br/>as instrucoes do programa"]
    S --- G1 --- H --- D --- T
    end

A pilha e o heap crescem "um em direção ao outro" a partir das pontas opostas do espaço livre — por isso o desenho os coloca nas extremidades.

2.2 A pilha: reencontrando a "pilha de chamadas" da Semana 04

Você já conhece a pilha! Na Semana 04, na curiosidade sobre a pilha de chamadas (call stack), dissemos: "a cada chamada de função, um quadro (stack frame) é empilhado com os parâmetros e as variáveis locais daquela chamada; quando a função retorna, o quadro é desempilhado". É exatamente esta região.

É por isso que uma variável local "morre" quando a função termina: o quadro dela é desempilhado, e aquele espaço fica livre para a próxima chamada. E é por isso que cada chamada tem suas próprias cópias das locais. A stack é a memória rápida e automática; o heap (Semana 11) é a memória que você controla à mão.

Por que estudar o mapa da memória?

Ponteiros são, no fundo, endereços dessas regiões. Muitos bugs difíceis (e o temido segmentation fault) acontecem quando um ponteiro aponta para um lugar errado ou inválido da memória. Entender o mapa é entender por que o programa quebra — e como consertar.

3. O operador &: o "endereço de"

Como descobrir onde uma variável mora? Com o operador & ("endereço de"), colocado antes do nome da variável. &idade significa, literalmente, "o endereço da variável idade".

E aqui vem uma revelação: é o mesmíssimo & do scanf! Desde a Semana 01 você escreve scanf("%d", &idade); — e a gente pediu para você aceitar o & "como uma regra, por enquanto". Chegou a hora de entender. O scanf precisa saber em que endereço depositar o valor lido; &idade entrega esse endereço. Simples assim.

Podemos imprimir um endereço com o especificador %p (de pointer):

enderecos.c
#include <stdio.h>

int main(void) {
    int idade = 20;
    double altura = 1.75;

    printf("valor de idade:   %d\n", idade);
    printf("endereco de idade: %p\n", (void *) &idade);   // %p com cast para (void *)
    printf("endereco de altura: %p\n", (void *) &altura);

    return 0;
}

Uma saída possível (os números mudam a cada execução!):

valor de idade:   20
endereco de idade: 0x7ffc9a3b4c5c
endereco de altura: 0x7ffc9a3b4c50

Os endereços aparecem em hexadecimal (começando com 0x) — é só a forma tradicional de escrever esses números grandes. Não decore o valor: o que importa é entender que &idade é "o número da caixa de correio" onde idade está guardada.

Curiosidade 💡 — por que o scanf sempre pediu &? (agora faz sentido)

Uma função em C recebe os argumentos por valor (Semana 04): ela ganha uma cópia. Se o scanf recebesse apenas a cópia do valor de idade, ele não teria como alterar a idade original de quem chamou. A solução? Passar o endereço — assim o scanf vai diretamente naquela caixa da memória e deposita ali o número digitado. O & do scanf é, portanto, o seu primeiro contato com passagem por referência — tema central da Semana 11. Você usava ponteiros sem saber!

O (void *) no %p

Para imprimir um endereço com %p de forma correta e portável, converta-o para (void *): printf("%p", (void *) &idade);. É um detalhe técnico; se você esquecer, o compilador com -Wall costuma avisar. Aceite o padrão por ora.

4. O que é um ponteiro

Se um endereço é apenas um número (o número da caixa), nada nos impede de guardar esse número em uma variável. Uma variável que guarda o endereço de outra variável é o que chamamos de ponteiro.

Um ponteiro é uma variável cujo conteúdo é um endereço de memória.

Pense assim: se idade é uma casa, um ponteiro é um papelzinho onde você anotou o endereço da casa. O papel não é a casa — é só a informação de onde ela fica. E, tendo o endereço, você consegue chegar até a casa e mexer no que há dentro.

4.1 Declarando um ponteiro

Declaramos um ponteiro colocando um asterisco * entre o tipo e o nome:

int *p;      // p e um ponteiro para int  (guarda o endereco de um int)
double *q;   // q e um ponteiro para double
char *c;     // c e um ponteiro para char

Leia int *p; como: "p é um ponteiro para int" — ou seja, p vai guardar o endereço de alguma variável do tipo int. O tipo apontado (int) importa: ele diz ao ponteiro quantos bytes ler e como interpretá-los quando formos até o endereço.

flowchart LR
    P["p (ponteiro)<br/>┌──────────┐<br/>│  1000    │<br/>└──────────┘<br/>guarda o endereco"] -->|aponta para| X["x (int)<br/>endereco 1000<br/>┌────┐<br/>│ 10 │<br/>└────┘<br/>guarda o valor"]

Onde colocar o *? int *p, int* p ou int * p?

As três formas compilam e são equivalentes. Mas há uma pegadinha ao declarar vários ponteiros na mesma linha: o * vale apenas para o nome logo à sua direita.

int* a, b;     // CUIDADO: 'a' e ponteiro, mas 'b' e um int comum!
int *a, *b;    // certo: os dois sao ponteiros (o * antes de cada nome)
Por isso, neste material, escrevemos o * colado no nome (int *p): deixa claro que o * "pertence" àquela variável específica.

5. Atribuindo um endereço a um ponteiro

Um ponteiro recém-declarado ainda não aponta para nada útil. Para fazê-lo apontar para uma variável, atribuímos a ela o endereço dessa variável (com o &):

int x = 10;      // x e um int comum, com valor 10
int *p = &x;     // p passa a guardar o ENDERECO de x  ->  "p aponta para x"

Repare na coerência dos tipos: x é int, então &x é "endereço de int", e p é declarado como int * ("ponteiro para int"). Tudo encaixa. Se você tentasse double *q = &x; (ponteiro para double recebendo endereço de int), o compilador reclamaria.

flowchart LR
    subgraph antes["Depois de: int x = 10;  int *p = &x;"]
    direction LR
    PP["p<br/>┌──────┐<br/>│ &x   │<br/>└──────┘"] -->|aponta para| XX["x<br/>┌────┐<br/>│ 10 │<br/>└────┘"]
    end

6. O operador *: desreferência ("conteúdo de")

Ter o endereço é ótimo, mas o que a gente quer de verdade é chegar até a variável e usar o valor que está lá. Para isso existe o operador de desreferência, o asterisco * colocado antes de um ponteiro. *p significa "o conteúdo do endereço guardado em p" — ou seja, o valor da variável apontada.

desreferencia.c
#include <stdio.h>

int main(void) {
    int x = 10;
    int *p = &x;         // p aponta para x

    printf("%d\n", x);   // 10  (o valor de x, direto)
    printf("%d\n", *p);  // 10  (o valor APONTADO por p -> tambem x)

    *p = 20;             // altera o CONTEUDO apontado por p... que e o x!
    printf("%d\n", x);   // 20  (x mudou, mesmo sem mencionar x!)

    return 0;
}

A linha *p = 20; é o coração do capítulo. Ela não muda p (o endereço continua o mesmo); ela vai até o endereço guardado em p e escreve 20 lá dentro. Como esse endereço é o de x, quem muda é o próprio x. Foi assim que o scanf sempre mexeu nas suas variáveis!

6.1 O * da declaração ≠ o * da desreferência

Este é, disparado, o ponto que mais confunde. O mesmo símbolo * faz coisas diferentes dependendo de onde aparece:

Onde aparece O que o * significa Exemplo
Na declaração "esta variável é um ponteiro" int *p;
Em uma expressão (uso) "desreferenciar: o valor apontado" *p = 20;
int *p = &x;   // AQUI o * faz parte da DECLARACAO: "p e um ponteiro"
*p = 20;       // AQUI o * e DESREFERENCIA: "escreva 20 no que p aponta"

Uma dica para não se perder: se há um tipo logo antes (int *p), o * é de declaração; se o * aparece "solto" numa expressão (*p, y = *p), é desreferência.

Erro clássico: confundir p com *p

  • p é o endereço (para onde o ponteiro aponta).
  • *p é o valor que está naquele endereço.

Escrever p = 20; faz p "apontar para o endereço 20" (quase sempre um lugar inválido — desastre à vista). O que você queria era *p = 20; (escrever 20 no destino). Leia sempre *p como "o conteúdo de".

7. O ponteiro nulo NULL

E se um ponteiro ainda não aponta para nada? Assim como uma variável int não inicializada contém lixo (Semana 01), um ponteiro não inicializado contém um endereço qualquer, imprevisível — e usá-lo é perigosíssimo. A convenção da linguagem é inicializar esses ponteiros com NULL, um valor especial que significa "não aponto para lugar nenhum".

#include <stdio.h>   // NULL vem definido aqui (e em outros cabecalhos)

int *p = NULL;       // p nao aponta para nada, de forma explicita e segura

if (p != NULL) {
    printf("%d\n", *p);   // so desreferencia se houver algo valido
} else {
    printf("ponteiro nulo: nao ha para onde ir.\n");
}

Duas regras de ouro:

  1. Sempre inicialize um ponteiro — com um endereço válido (&x) ou com NULL.
  2. Nunca desreferencie NULL (nem um ponteiro inválido). *p quando p é NULL quebra o programa.

Curiosidade 💡 — NULL e o famoso segmentation fault

Desreferenciar um ponteiro nulo ou inválido (*p com p == NULL, por exemplo) é uma das causas número um de travamentos em C. O sistema operacional detecta a tentativa de acessar uma região de memória proibida e mata o programa na hora, com a mensagem Segmentation fault (ou "falha de segmentação"). Se você vir isso, a primeira suspeita deve ser: "estou usando um ponteiro que não aponta para um lugar válido?". NULL existe justamente para você testar antes (if (p != NULL)) e evitar o desastre.

8. Quadro-resumo: &x, p, *p, &p

Junte tudo. Considere:

int x = 10;
int *p = &x;
Escrevo... Leio como... Vale...
x "o valor de x" 10
&x "o endereço de x" ex.: 0x7ffc...c5c
p "o endereço guardado em p" o mesmo de &x (p aponta para x)
*p "o valor apontado por p" (conteúdo) 10 (é o próprio x)
&p "o endereço do próprio ponteiro p" outro endereço (onde p mora)

Sim: o ponteiro também é uma variável e, portanto, também tem o seu próprio endereço (&p). Um ponteiro que guarda o endereço de outro ponteiro existe (é o int **) e será útil mais adiante — por ora, basta saber que &p faz sentido.

flowchart LR
    subgraph vis["&x, p, *p, &p"]
    direction LR
    PBOX["p<br/>(mora em &p)<br/>┌──────┐<br/>│ &x   │<br/>└──────┘"] -->|p aponta| XBOX["x<br/>(mora em &x)<br/>┌────┐<br/>│ 10 │  &lt;- *p<br/>└────┘"]
    end

Curiosidade 💡 — todo ponteiro tem o mesmo tamanho

Um int ocupa 4 bytes, um double ocupa 8, um char ocupa 1... e um ponteiro? Todos os ponteiros têm o mesmo tamanho, não importa o tipo apontado — porque todos guardam a mesma coisa: um endereço. Em um sistema 64 bits, um endereço ocupa 8 bytes; em sistemas de 32 bits, 4 bytes. Então sizeof(int *), sizeof(double *) e sizeof(char *) dão o mesmo resultado (tipicamente 8 na sua máquina). Faz sentido: o "papelzinho com o endereço" tem o mesmo tamanho, quer a casa seja um barraco ou um palácio.

9. Erros comuns desta semana (resumão)

Checklist de armadilhas

  • Confundir p (o endereço) com *p (o valor apontado).
  • Confundir o * da declaração (int *p) com o * da desreferência (*p = ...).
  • Declarar int* a, b; achando que b também é ponteiro (não é!).
  • Usar um ponteiro não inicializado (aponta para lixo) — inicialize com &x ou NULL.
  • Desreferenciar NULL ou ponteiro inválido → Segmentation fault.
  • Esquecer o & ao guardar o endereço de uma variável (p = x; em vez de p = &x;).
  • Tipos incompatíveis (double *q = &x; com x sendo int).
  • Imprimir endereço sem %p (ou sem o (void *)).

👥 Quarta — Encontro

Chegue ao encontro tendo estudado o material acima e tendo executado os exemplos — este é o assunto em que "ver de longe" menos funciona. Você precisa ter desenhado as caixas e as setas. Roteiro previsto:

  1. Revisão relâmpago: memória como bytes endereçados; regiões (stack × heap); o & e o *; ponteiro como "variável que guarda endereço".
  2. Desenho ao vivo: vamos executar um programa passo a passo, desenhando na tela cada variável, seu endereço e o que cada ponteiro aponta — o "modelo de caixas e setas".
  3. Galeria de erros: provocamos de propósito o *p num ponteiro NULL, a confusão p × *p e o int* a, b; para você aprender a reconhecer os sintomas (inclusive o segmentation fault).

Problema-guia do encontro — "a espiã da memória": um programa que declara algumas variáveis, cria ponteiros para elas, imprime endereços e valores, e depois altera as variáveis através dos ponteiros, confirmando as mudanças. É o "olá, mundo" dos ponteiros.

espia.c
#include <stdio.h>

int main(void) {
    int a = 5;
    int b = 42;

    int *p = &a;    // p aponta para a
    int *q = &b;    // q aponta para b

    // 1) Onde cada variavel mora e o que guarda:
    printf("a = %d  (mora em %p)\n", a, (void *) &a);
    printf("b = %d  (mora em %p)\n", b, (void *) &b);

    // 2) O que os ponteiros guardam e o que apontam:
    printf("p guarda %p  ->  *p = %d\n", (void *) p, *p);
    printf("q guarda %p  ->  *q = %d\n", (void *) q, *q);

    // 3) Alterando 'a' SEM mencionar 'a', so pelo ponteiro:
    *p = 100;
    printf("depois de *p = 100:  a = %d\n", a);   // a virou 100

    // 4) Fazendo p apontar para b (mudamos o ENDERECO em p, nao o valor):
    p = &b;
    printf("agora *p = %d (p aponta para b)\n", *p);  // 42

    return 0;
}

Perguntas para pensar antes do encontro: por que a mudou na etapa 3 sem escrevermos a = ...? Na etapa 4, o que muda quando fazemos p = &b — o valor de b ou o endereço guardado em p? Qual seria a diferença se tivéssemos escrito *p = b;?


✍️ Sexta — Estudo dirigido

Reserve este tempo para desenhar e executar. Em ponteiros, a técnica que mais ajuda é a mais simples: pegue papel e lápis e desenhe as caixas e as setas antes de rodar.

Exercícios (Beecrowd) — uma observação honesta

Ponteiros raramente aparecem como um tema "isolado" no Beecrowd — eles são uma ferramenta, e sua força aparece quando combinados com funções (Semana 11), vetores (Semana 07) e strings (Semana 08). Por isso, a sugestão desta semana é revisitar exercícios que você já resolveu e reescrevê-los usando ponteiros onde fizer sentido (por exemplo, uma função que preenche uma variável via ponteiro). O foco de verdade, hoje, são os desafios do zero logo abaixo.

Problema (já visto) Como revisitar com ponteiros
1001 Extremamente Básico leia os dois valores e imprima o endereço de cada variável com %p.
1006 Média 1 escreva uma função void media(double a, double b, double *r) que grava a média em *r.
1116 Dividindo Valores pratique uma função que devolve resultado via ponteiro (prévia da Semana 11).

(Não se preocupe se a versão "com ponteiros" parecer mais trabalhosa agora — o objetivo é praticar o mecanismo, não "resolver mais rápido". A vantagem real chega na Semana 11.)

Desafios para escrever do zero

  1. Retrato da memória: declare um int, um double e um char. Imprima, para cada um, o valor e o endereço (%p com (void *)). Rode duas vezes e observe se os endereços mudam entre as execuções. Por que mudam?
  2. Mexendo por ponteiro: declare int x = 10; e int *p = &x;. Imprima x e *p (devem ser iguais). Faça *p = 20;, imprima x de novo e confirme que mudou. Explique, por escrito, por quê.
  3. A função que zera: escreva void zera(int *p) que faz *p = 0;. Na main, crie int n = 77;, chame zera(&n); e confirme que n virou 0. (Este é o embrião da passagem por referência — guarde-o para a Semana 11.)
  4. Prevendo a saída: sem rodar, escreva no papel a saída do trecho abaixo; depois rode e confira.
    int a = 3, b = 7;
    int *p = &a;
    *p = *p + 5;      // linha 1
    p = &b;           // linha 2
    *p = a;           // linha 3
    printf("a=%d b=%d\n", a, b);
    
    (Dica: acompanhe, linha a linha, para onde p aponta e qual caixa cada *p altera.)

Para investigar (curiosidade prática)

  • Tamanho dos ponteiros: imprima sizeof(int *), sizeof(double *), sizeof(char *) e também sizeof(int) e sizeof(double), todos com %zu. O que você nota sobre o tamanho dos ponteiros comparados entre si? E comparados com os tipos apontados? Relacione com a curiosidade da seção 8.
  • O NULL, com cuidado: escreva int *p = NULL; e tente printf("%d\n", *p);. Salve tudo antes — o programa vai quebrar com segmentation fault. Observe a mensagem, então corrija protegendo com if (p != NULL). Entender o crash uma vez, de propósito, vale mais do que dez avisos.

✅ Checklist de autoavaliação

Marque com sinceridade — se algum item não estiver ✅, volte à seção correspondente. Em ponteiros, "mais ou menos entendi" costuma virar bug depois; seja rigoroso(a).

  • Explico que a memória é uma sequência de bytes, cada um com seu endereço.
  • Sei nomear e situar as regiões código, globais/estáticas, heap e pilha, e ligo a pilha à pilha de chamadas da Semana 04.
  • Uso o & para obter o endereço e o %p para imprimi-lo — e explico por que o scanf sempre pediu &.
  • Defino, com minhas palavras, o que é um ponteiro e declaro com tipo *p.
  • Faço um ponteiro apontar para uma variável (int *p = &x;).
  • Uso *p para ler e alterar o valor apontado, e sei que *p = 20; muda x.
  • Não confundo o * da declaração com o * da desreferência, nem p com *p.
  • Inicializo ponteiros (com &x ou NULL) e nunca desreferencio NULL.
  • Sei dizer, de cabeça, o que são &x, p, *p e &p.

🔗 Referências e para se aprofundar

Antes de seguir para a Semana 11

Você acabou de aprender o vocabulário dos ponteiros: endereço (&), ponteiro (tipo *p) e desreferência (*p). Isso ainda parece "para quê tudo isso?" — e é natural. A resposta vem na Semana 11: com ponteiros, uma função finalmente poderá alterar a variável de quem a chamou (a passagem por referência), resolvendo aquela limitação da Semana 04 (a função só devolvia um valor com return). Aquele & do scanf, que te acompanha desde a primeira aula, vai, enfim, revelar todo o seu poder. Reserve tempo para digitar os exemplos desta semana: quem constrói a base agora atravessa a Unidade 3 com tranquilidade.