Reforço de Algoritmos e Programação - Instituto de Informática (UFRGS) - Prof. Dennis Giovani Balreira



Aula 9 - Ponteiros e Passagem por Referência

Na Aula 8, vimos que argumentos são sempre copiados para os parâmetros de uma função — inclusive quando o argumento é o endereço de uma variável. Nesta aula, vamos entender exatamente o que é esse "endereço", como manipulá-lo através de ponteiros, e como isso nos permite fazer uma função alterar de verdade uma variável de quem a chamou.


1. Motivação: por que precisamos de ponteiros?

Vimos, na Aula 8, o seguinte problema: a função trocar(int x, int y) não conseguia trocar os valores de duas variáveis da main(), porque x e y recebiam apenas cópias dos valores de a e b. Alterar as cópias não tem nenhum efeito sobre as variáveis originais.

Precisamos, então, de uma forma de dizer a uma função: "não me dê uma cópia do valor — me diga onde, na memória, esse valor está guardado, para que eu possa ir lá e alterá-lo diretamente." É exatamente isso que um ponteiro permite fazer: um ponteiro é uma variável que armazena, como seu valor, o endereço de memória de uma outra variável.

Por que isso importa? Ponteiros são a base de praticamente tudo o que veremos daqui para frente: passar vetores e structs grandes para funções sem copiá-los inteiros, alterar variáveis de dentro de uma função, percorrer vetores de forma alternativa, e futuramente alocar memória dinamicamente e construir estruturas de dados como listas encadeadas.

2. Revisão: variáveis na memória

Lembre-se, desde a Aula 1: toda variável, ao ser declarada, ocupa um espaço na memória principal do computador (a memória RAM). É lá que ficam guardadas as variáveis de um programa em execução, enquanto o processador (CPU) é quem executa as instruções do programa — lendo e escrevendo valores na RAM conforme o programa manda. Cada posição da RAM tem um endereço, guarda um valor, e esse valor é interpretado de acordo com o tipo da variável.

Endereços de memória normalmente são representados em hexadecimal (com o prefixo 0x). Para não misturar essa conversão de base com os conceitos de ponteiros que veremos agora, continuamos usando aqui a notação decimal simplificada da Aula 1: a letra E seguida de um número, como E1000 ("endereço 1000").

Podemos imaginar a memória como uma grande tabela, em que cada linha é uma "caixinha" com um identificador (o nome da variável), um endereço, um tipo, e o valor guardado ali:

Endereço Identificador (ID) Tipo Valor
E1000 num int 42

Até aqui, nada novo. A novidade é: o endereço de uma variável também é um valor, e esse valor pode, ele mesmo, ser guardado em uma outra variável — um ponteiro.


3. Três formas de acessar uma variável

Dado o identificador (ID) de uma variável, existem três formas de acessá-la, cada uma "lendo" um campo diferente da tabela de memória:

Forma O que acessa Vale para
ID O campo Valor da variável — o conteúdo da "caixinha". Qualquer variável.
&ID O campo Endereço da variável — onde a "caixinha" está localizada na memória. Qualquer variável.
*ID O "valor do valor": usa o Valor de ID como se fosse um Endereço, e busca o que está guardado naquele endereço. Apenas ponteiros — só faz sentido se o valor guardado em ID for, ele próprio, um endereço válido.

Vamos ver essas três formas em ação com um exemplo completo, acompanhando a tabela de memória a cada linha do programa.

#include <stdio.h>

int main() {
    int num = 42;      // (1) declara "num"
    int *p = &num;   // (2) declara "p" e guarda nele o ENDEREÇO de num

    printf("num  = %d\n", num);   // campo Valor de num  -> 42
    printf("&num = %p\n", &num);  // campo Endereço de num -> E1000 (exemplo)
    printf("p    = %p\n", p);     // campo Valor de p    -> E1000 (mesmo endereço!)
    printf("&p   = %p\n", &p);   // campo Endereço de p   -> E2000 (exemplo)
    printf("*p   = %d\n", *p);    // "valor do valor de p" -> vai até E1000 e lê 42

    return 0;
}
Como imprimir um endereço de verdade. Para imprimir um ponteiro (ou o endereço de qualquer variável, com &), usamos o especificador %p no printf, como nos exemplos acima (printf("%p", &num);, printf("%p", p);). Diferente da notação E1000 que usamos aqui apenas para fins didáticos, o %p imprime o endereço real da variável em memória — e ele sempre imprime esse endereço em hexadecimal (com prefixo 0x), como 0x7ffe1234abcd. Se você rodar esses exemplos em um computador de verdade, é isso que verá na tela, não os endereços E... deste material.

Depois da linha int *p = &num;, a tabela de memória (com endereços fictícios, apenas para fins didáticos) fica assim:

Endereço ID Tipo Valor
E1000 num int 42
... ... ... ...
E2000 p int * E1000

As reticências entre as duas linhas representam o fato de que, entre os endereços E1000 e E2000, existe muito mais coisa na memória — outras variáveis, dados do próprio programa, ou espaço não utilizado. Os endereços aqui são apenas exemplos didáticos; na prática, duas variáveis quaisquer podem estar bem mais próximas ou bem mais distantes uma da outra do que sugerem estes números.

Repare que p é uma variável como qualquer outra — também tem seu próprio endereço (E2000) — mas o valor guardado dentro dela não é um número "comum", e sim o endereço de uma outra variável (E1000, o endereço de num). Por isso p é chamado de ponteiro: ele "aponta" para onde num está.

  • num → 42 (campo Valor da linha de num)
  • &numE1000 (campo Endereço da linha de num)
  • pE1000 (campo Valor da linha de p — que por acaso é um endereço)
  • &pE2000 (campo Endereço da linha de p)
  • *p → 42 ("valor do valor": pega o valor de p [E1000], vai até a linha cujo endereço é E1000, e lê o Valor de lá — que é o de num)

4. Declarando e usando ponteiros

A declaração de um ponteiro utiliza o operador * junto ao tipo:

Sintaxe genérica:

tipo *nome_do_ponteiro;

Exemplos:

int *p;      // ponteiro para int
float *pf;   // ponteiro para float
char *pc;    // ponteiro para char

O tipo do ponteiro deve corresponder ao tipo da variável para a qual ele apontará — um int * só deveria apontar para um int, e assim por diante. Isso importa porque o tipo é o que diz ao compilador quantos bytes ler ao desreferenciar o ponteiro (*p), e quanto "andar" na memória em operações de aritmética de ponteiros (Seção 6).

O tamanho do ponteiro não depende do tipo apontado. Todo ponteiro guarda apenas um endereço — e, em um sistema de 64 bits, um endereço sempre ocupa o mesmo tamanho, não importa para que tipo o ponteiro aponte:
sizeof(char *)    // 8 (em um sistema de 64 bits)
sizeof(int *)     // 8
sizeof(float *)   // 8
sizeof(double *)  // 8
sizeof(FILE *)     // 8 (Aula 11)
Todos ocupam o mesmo tamanho porque todos armazenam apenas um endereço — nenhum deles guarda, ele mesmo, o dado apontado. O que muda, de tipo para tipo, é o tamanho do objeto apontado, e não o do ponteiro:
sizeof(char)     // 1
sizeof(int)      // geralmente 4
sizeof(double)   // geralmente 8
É exatamente essa diferença que sustenta a aritmética de ponteiros (Seção 6):
int *p;
p + 1
O ponteiro p continua ocupando 8 bytes (em um sistema de 64 bits) — isso nunca muda. Mas, ao calcular p + 1, o compilador não soma 1 byte ao endereço: ele usa o tipo apontado (int) para saber que deve avançar sizeof(int) bytes (normalmente 4) de uma vez, "pulando" para o próximo int, e não para o próximo byte.
Atenção: o * tem dois papéis diferentes. É comum confundir os dois usos do asterisco:
  • Em int *p; (na declaração), o * não significa "desreferenciar" — ele apenas indica que p é um ponteiro para int. É parte do tipo da variável.
  • Em *p (fora da declaração, como em *p = 20; ou printf("%d", *p);), o * é o operador de desreferenciação: "vá até o endereço guardado em p e acesse o valor de lá".
Além disso, não confunda *x (operador unário de desreferenciação, com um único operando) com x * y (operador binário de multiplicação, com dois operandos) — é o mesmo símbolo *, mas com significados completamente diferentes dependendo de quantos operandos ele recebe.

Uma das aplicações mais importantes de ponteiros é modificar o valor de uma variável através do ponteiro que aponta para ela, usando *p do lado esquerdo de uma atribuição:

#include <stdio.h>

int main() {
    int num = 10;
    int *p = &num;

    printf("Antes: %d\n", num);   // 10

    *p = 20; // vai até o endereço guardado em p (E1000) e escreve 20 lá

    printf("Depois: %d\n", num);  // 20 (num mudou, mesmo sem mexer em "num" diretamente!)

    return 0;
}
Ponteiros não inicializados e NULL. Um ponteiro recém-declarado, sem inicialização, guarda "lixo de memória" — um endereço qualquer, inválido. Desreferenciar esse ponteiro (*p) é um erro grave (pode travar o programa ou corromper memória). Se um ponteiro ainda não deve apontar para nada, inicialize-o com NULL, e sempre verifique if (p != NULL) antes de desreferenciá-lo.

5. Passagem por referência: ponteiros em funções

Agora podemos resolver, de verdade, o problema da Aula 8: fazer uma função alterar uma variável de quem a chamou. A ideia é simples: em vez de passar a variável como argumento (o que copia seu valor), passamos o endereço dela (que também é copiado — lembre-se, tudo é copiado — mas agora a cópia é do endereço, não do valor original).

#include <stdio.h>

// Protótipo
void incrementar(int *n);

int main() {
    int num = 10;

    printf("Antes: %d\n", num);   // 10

    incrementar(&num); // passamos o ENDEREÇO de num, não o valor

    printf("Depois: %d\n", num);  // 11 (agora sim, num mudou de verdade!)

    return 0;
}

// Implementação
void incrementar(int *n) {
    *n = *n + 1; // desreferencia n: altera o valor de "num", lá na main()
}

Compare com a versão da Aula 8, que não conseguia alterar a variável original:

Passagem por valor (Aula 8) Passagem por referência (com ponteiro)
void incrementar(int n) void incrementar(int *n)
incrementar(num); incrementar(&num);
n recebe uma cópia do valor de num. Alterar n não afeta num. n recebe uma cópia do endereço de num. Alterar *n altera num de verdade.
Atenção: continua sendo "passagem por valor". Como vimos na Aula 8, tudo em C é passado por valor — inclusive endereços. A diferença é o que é copiado: um valor "comum" (que não permite alterar o original) ou um endereço (que permite, indiretamente, alterar o original através dele). Por isso dizemos que C simula uma passagem por referência usando ponteiros — tecnicamente, ainda é uma cópia (do endereço) sendo passada.

6. Aritmética de ponteiros

Ponteiros suportam operações aritméticas (soma e subtração) que permitem "navegar" pela memória, elemento a elemento — isso é especialmente útil para percorrer vetores.

#include <stdio.h>

int main() {
    int vetor[5] = {10, 20, 30, 40, 50};
    int *p = vetor; // p aponta para o primeiro elemento (vetor[0])

    printf("%d\n", *p);        // 10 (vetor[0])

    p++;                     // avança p para o PRÓXIMO elemento
    printf("%d\n", *p);        // 20 (vetor[1])

    p = p + 2;                // avança mais 2 elementos
    printf("%d\n", *p);        // 40 (vetor[3])

    return 0;
}

O ponto-chave: quando fazemos p++, o ponteiro não avança simplesmente 1 byte. Ele avança o tamanho do tipo apontado. Como p é um int *, e um int normalmente ocupa 4 bytes, p++ avança 4 bytes de uma vez — o suficiente para "pular" para o próximo int do vetor:

Endereço ID / posição Tipo Valor
E1000 vetor[0] int 10
E1004 vetor[1] int 20
E1008 vetor[2] int 30
E1012 vetor[3] int 40

Ou seja: p começa valendo E1000; após p++, passa a valer E1004 (não E1001!); após p = p + 2 (a partir de E1004), passa a valer E1012. É por isso que o tipo do ponteiro importa tanto: ele diz ao compilador de quantos em quantos bytes "pular".

Cuidado: isso só é seguro dentro de um mesmo vetor. A aritmética de ponteiros "andar um elemento à frente" só tem garantia de funcionar quando as posições pertencem ao mesmo vetor (como vetor[0], vetor[1], vetor[2], que o compilador garante serem contíguas). Duas variáveis comuns, declaradas uma após a outra, não têm essa garantia — o compilador pode alocá-las em qualquer ordem, com qualquer espaçamento entre elas (ou até reordená-las, otimizar uma delas para ficar só em um registrador, etc.):
#include <stdio.h>

int main() {
    int a = 10;
    int b = 20;

    printf("%d\n", *(&a + 1));

    return 0;
}
Embora a e b tenham sido declaradas em sequência, não há garantia de que b esteja logo depois de a na memória — o resultado de *(&a + 1) não necessariamente será 20. Esse código tem comportamento indefinido (pode até "parecer funcionar" às vezes, por coincidência, mas não é algo em que se possa confiar). Só use aritmética de ponteiros para navegar dentro de um vetor de fato.

7. Vetores e strings em funções

Em C, o nome de um vetor, usado sozinho, já é um ponteiro para seu primeiro elemento. Explicitamente:

v == &v[0]

Ou seja, dado um vetor v: o próprio nome do vetor usado como valor (v) e o endereço explícito do seu primeiro elemento (&v[0]) apontam para o mesmo endereço. É por isso que podemos atribuir int *p = vetor; diretamente, sem precisar escrever &vetor[0].

E o &v (endereço do vetor inteiro)? Tecnicamente, v (um vetor) não é a mesma coisa que um ponteiro: o tipo de v é "vetor de N inteiros" (int[N]), não int *. Na grande maioria das expressões, porém, o compilador converte automaticamente um vetor para um ponteiro ao seu primeiro elemento — é por isso que v "funciona como" int * quase sempre.

&v é diferente: seu tipo é "ponteiro para vetor de N inteiros" (int (*)[N]), e não int *. Ele aponta para o mesmo endereço na memória que v e &v[0], mas com um tipo diferente — por isso, para comparar ou atribuir &v a um int *, seria necessário um cast explícito, como em (int *)&v. Considere, por exemplo:
int v[10];

v      // tipo int *          (convertido automaticamente)
&v[0]  // tipo int *
&v     // tipo int (*)[10]     (ponteiro para o vetor inteiro)
Os três apontam para o mesmo local de memória — mas apenas os dois primeiros têm o tipo int * diretamente. Este é um detalhe mais avançado; para o dia a dia da disciplina, basta lembrar que v == &v[0].

Da mesma forma, para qualquer posição i dentro do vetor, acessar por colchetes ou por aritmética de ponteiros dá exatamente no mesmo resultado:

v[i] == *(v + i)
#include <stdio.h>

int main() {
    int vetor[5] = {10, 20, 30, 40, 50};
    int *p = vetor;

    printf("%d\n", *p);       // 10 -> acessa vetor[0]
    printf("%d\n", *(p + 1)); // 20 -> acessa vetor[1]
    printf("%d\n", p[1]);     // 20 -> equivalente: p[1] == *(p + 1)

    return 0;
}

Isso explica por que, quando passamos um vetor para uma função, na prática estamos sempre passando um ponteiro para seu primeiro elemento — nunca uma cópia do vetor inteiro. Por isso uma função pode alterar diretamente o conteúdo de um vetor recebido como parâmetro, mesmo sem usarmos & explicitamente na chamada:

#include <stdio.h>

// Protótipo — "int v[]" e "int *v" são equivalentes aqui
void dobrarTodos(int v[], int tamanho);

int main() {
    int numeros[4] = {1, 2, 3, 4};
    int i;

    dobrarTodos(numeros, 4); // não precisa de &: o próprio "numeros" já é um endereço

    for (i = 0; i < 4; i++) {
        printf("%d ", numeros[i]); // 2 4 6 8 (o vetor original foi alterado!)
    }
    printf("\n");

    return 0;
}

// Implementação
void dobrarTodos(int v[], int tamanho) {
    int i;
    for (i = 0; i < tamanho; i++) {
        v[i] = v[i] * 2; // altera direto o vetor de quem chamou
    }
}

O mesmo raciocínio vale para strings: como vimos na Aula 5, uma string em C é apenas um vetor de char terminado em '\0'. Ao passar uma string para uma função, também estamos passando um ponteiro para seu primeiro caractere — por isso funções como strcpy, strcat e as suas próprias funções que recebem char texto[] conseguem alterar a string original.

Boas práticas com ponteiros.
  • Sempre inicialize ponteiros (com um endereço válido ou com NULL), e verifique != NULL antes de desreferenciar.
  • Respeite a correspondência de tipos: um int * deve apontar para um int, não para outro tipo.
  • Use ponteiros com propósito: quando precisar que uma função altere a variável original, ou quando estiver lidando com vetores/strings (que já são ponteiros por natureza). Evite ponteiros onde uma variável comum resolveria o problema.

Resumo

  • Ponteiro: variável cujo valor é o endereço de memória de outra variável. Declarado como tipo *nome;.
  • Três formas de acessar uma variável a partir do seu ID: ID (campo Valor), &ID (campo Endereço), *ID ("valor do valor" — só para ponteiros).
  • & (endereço-de) obtém o endereço de uma variável; * (desreferenciação) acessa/altera o valor no endereço apontado.
  • Passagem por referência: passar o endereço (&variavel) para um parâmetro-ponteiro permite que a função altere a variável original — mesmo sendo, tecnicamente, ainda uma cópia (do endereço).
  • Aritmética de ponteiros: p++ ou p + n avançam de acordo com o tamanho do tipo apontado, não byte a byte.
  • O nome de um vetor já é um ponteiro para seu primeiro elemento — por isso vetores e strings, ao serem passados para funções, permitem que a função altere o conteúdo original diretamente.

Exercícios

Nível fácil

  1. Escreva um programa que declare uma variável int idade = 25; e um ponteiro int *p que aponte para ela. Imprima, usando printf, o valor de idade, o endereço de idade, o valor de p e o valor de *p, comentando o que cada saída representa.
  2. Escreva uma função void dobrar(int *n) que dobre, por referência, o valor apontado por n. Chame-a a partir da main(), passando o endereço de uma variável, e imprima o valor antes e depois da chamada.
  3. Escreva um programa que declare um vetor int notas[4] = {7, 8, 5, 9}; e um ponteiro int *p = notas;. Usando apenas o ponteiro p (com *p, *(p+1), etc., sem usar colchetes), imprima os quatro valores do vetor.

Nível médio

  1. Escreva uma função void trocar(int *x, int *y) que troque, de verdade, os valores de duas variáveis, usando ponteiros. Chame-a a partir da main() com duas variáveis a e b, e mostre que a troca funciona (diferente do que ocorria na Aula 8).
  2. Escreva uma função void somarUm(int v[], int tamanho) que some 1 a cada elemento de um vetor de inteiros. Chame-a a partir de uma main() que declare um vetor, imprima-o antes e depois da chamada, e confirme que o vetor original foi alterado.
  3. Explique, sem executar o código, qual será a saída do programa abaixo, e por quê:
    #include <stdio.h>
    
    int main() {
        int valores[3] = {100, 200, 300};
        int *p = valores;
    
        p = p + 2;
        *p = 999;
    
        printf("%d %d %d\n", valores[0], valores[1], valores[2]);
    
        return 0;
    }
    

Nível difícil

  1. Escreva uma função int somaVetor(int *v, int tamanho) que calcule a soma dos elementos de um vetor, acessando os elementos apenas por aritmética de ponteiros (usando *(v + i), sem usar a notação v[i]). Utilize-a em um programa completo que leia um vetor do usuário e imprima a soma.
  2. O programa abaixo tenta usar um ponteiro sem inicializá-lo corretamente. Identifique o erro, explique por que ele é perigoso (mesmo que "pareça funcionar" ao rodar), e reescreva o programa corrigindo o problema:
    #include <stdio.h>
    
    int main() {
        int *p;
        *p = 10;
        printf("%d\n", *p);
        return 0;
    }
    
  3. Explique, sem executar o código, qual será a saída do programa abaixo (assuma int com 4 bytes e arquitetura little-endian, em que o byte menos significativo fica no primeiro endereço). O programa usa um ponteiro char * para "enxergar" um vetor de inteiros byte a byte, e altera diretamente o primeiro byte do primeiro elemento:
    #include <stdio.h>
    
    int main() {
        int valores[2] = {256, 500};
        char *bp = (char *) valores; // bp "enxerga" a mesma memória, byte a byte
    
        bp[0] = 1; // altera só o primeiro BYTE do primeiro int
    
        printf("%d %d\n", valores[0], valores[1]);
    
        return 0;
    }
    
    Explique também por que, se em vez de char *bp usássemos int *bp, o resultado da mesma operação (bp[0] = 1;) seria bem diferente.
  4. O programa abaixo usa gets() (função insegura, sem verificação de tamanho) para ler um nome em um vetor pequeno demais. Explique o que acontece com as variáveis idade e saldo se o usuário digitar um nome mais longo do que o vetor nome comporta, mesmo o programa nunca atribuindo diretamente a essas variáveis depois da leitura. Em seguida, reescreva o programa usando fgets de forma seaura (como visto na Aula 5):
    #include <stdio.h>
    
    int main() {
        char nome[8];
        int idade = 20;
        float saldo = 100.0;
    
        printf("Digite seu nome: ");
        gets(nome); // PERIGO: não verifica o tamanho do que foi digitado!
    
        printf("Nome: %s, idade: %d, saldo: %.2f\n", nome, idade, saldo);
    
        return 0;
    }
    

Sugestões de Respostas dos Exercícios

Exercício 1
#include <stdio.h>

int main() {
    int idade = 25;
    int *p = &idade;

    printf("idade  = %d\n", idade);  // valor de idade
    printf("&idade = %p\n", &idade); // endereço de idade
    printf("p      = %p\n", p);     // valor de p (que é o endereço de idade)
    printf("*p     = %d\n", *p);    // "valor do valor de p": vai até o endereço e lê 25

    return 0;
}

Exercício 2
#include <stdio.h>

void dobrar(int *n);

int main() {
    int valor = 5;

    printf("Antes: %d\n", valor);

    dobrar(&valor);

    printf("Depois: %d\n", valor);

    return 0;
}

void dobrar(int *n) {
    *n = *n * 2;
}

Exercício 3
#include <stdio.h>

int main() {
    int notas[4] = {7, 8, 5, 9};
    int *p = notas;

    printf("%d %d %d %d\n", *p, *(p + 1), *(p + 2), *(p + 3));

    return 0;
}

Exercício 4
#include <stdio.h>

void trocar(int *x, int *y);

int main() {
    int a = 3, b = 8;

    printf("Antes: a = %d, b = %d\n", a, b);

    trocar(&a, &b);

    printf("Depois: a = %d, b = %d\n", a, b);

    return 0;
}

void trocar(int *x, int *y) {
    int temp = *x;
    *x = *y;
    *y = temp;
}

Agora a troca funciona: x e y continuam sendo cópias — mas cópias de endereços. Ao desreferenciá-los (*x, *y), a função acessa e altera diretamente as posições de memória de a e b, na main().


Exercício 5
#include <stdio.h>

void somarUm(int v[], int tamanho);

int main() {
    int numeros[4] = {10, 20, 30, 40};
    int i;

    printf("Antes: ");
    for (i = 0; i < 4; i++) {
        printf("%d ", numeros[i]);
    }
    printf("\n");

    somarUm(numeros, 4);

    printf("Depois: ");
    for (i = 0; i < 4; i++) {
        printf("%d ", numeros[i]);
    }
    printf("\n");

    return 0;
}

void somarUm(int v[], int tamanho) {
    int i;
    for (i = 0; i < tamanho; i++) {
        v[i] = v[i] + 1;
    }
}

Exercício 6

A saída será 100 200 999. O vetor valores ocupa três posições consecutivas na memória (por exemplo, E1000, E1004 e E1008, considerando int de 4 bytes). p começa apontando para valores[0] (E1000). A instrução p = p + 2 avança 2 elementos (não 2 bytes!), levando p a apontar para valores[2] (E1008). Em seguida, *p = 999; altera exatamente essa posição — ou seja, valores[2] — deixando valores[0] e valores[1] inalterados.


Exercício 7
#include <stdio.h>

int somaVetor(int *v, int tamanho);

int main() {
    int valores[5];
    int i;

    printf("Digite 5 numeros:\n");
    for (i = 0; i < 5; i++) {
        scanf("%d", &valores[i]);
    }

    printf("Soma: %d\n", somaVetor(valores, 5));

    return 0;
}

int somaVetor(int *v, int tamanho) {
    int soma = 0;
    int i;

    for (i = 0; i < tamanho; i++) {
        soma = soma + *(v + i);
    }

    return soma;
}

Exercício 8

O erro é usar o ponteiro p sem inicializá-lo: int *p; declara p, mas seu valor inicial é indeterminado ("lixo de memória") — pode ser qualquer endereço, inclusive um endereço inválido ou de uma região protegida do sistema. A instrução *p = 10; tenta escrever no endereço guardado em p, que não aponta para lugar nenhum conhecido; isso é comportamento indefinido — o programa pode travar imediatamente (o mais provável), pode corromper outra parte da memória silenciosamente, ou pode, por coincidência, "parecer funcionar" em um teste e falhar em outro computador ou execução.

#include <stdio.h>

int main() {
    int valor;        // variável de verdade, para onde p vai apontar
    int *p = &valor; // p inicializado com um endereço válido

    *p = 10;
    printf("%d\n", *p);

    return 0;
}

Exercício 9

A saída será 1 500. O vetor valores ocupa 8 bytes na memória (dois int de 4 bytes cada). O ponteiro bp é do tipo char *, então cada posição bp[i] enxerga um único byte dessa mesma região de memória — diferente de um int *, que enxergaria 4 bytes de cada vez. Em uma arquitetura little-endian, o valor 256 (que em binário é 00000001 00000000 em 2 bytes, completando 4 bytes com zeros à esquerda) é armazenado com o byte menos significativo (0x00) no primeiro endereço. A instrução bp[0] = 1; substitui exatamente esse primeiro byte por 1, sem tocar nos outros 3 bytes do inteiro — o valor de valores[0] passa de 256 para 1 (256 já tinha o byte menos significativo zerado; ao trocá-lo por 1, o número final passa a ser só 1). O segundo elemento, valores[1], nem é tocado, e permanece 500.

Se usássemos int *bp em vez de char *bp, a instrução bp[0] = 1; escreveria 4 bytes de uma vez (o tamanho de um int) na posição de valores[0], substituindo o inteiro inteiro por 1 — não apenas um byte dele. O comportamento de "enxergar" a memória byte a byte só existe porque o ponteiro é char *; o tipo do ponteiro sempre determina de quantos em quantos bytes ele lê e escreve.


Exercício 10

O vetor nome tem espaço para apenas 8 caracteres na memória. A função gets() lê o que o usuário digitar sem nunca verificar se cabe nesse espaço — se o usuário digitar, por exemplo, 20 caracteres, os 12 caracteres que "sobram" são escritos além do fim do vetor nome, sobrescrevendo o que estiver logo depois dele na memória (nesse caso, possivelmente as variáveis idade e saldo, ou até outras informações internas do programa, dependendo de como o compilador organizou a memória). Isso acontece mesmo sem que o programa nunca tenha escrito idade = ... ou saldo = ... depois da leitura — a própria gets(), por dentro, está escrevendo byte a byte na memória, exatamente como fizemos manualmente no Exercício 9, só que sem controle nenhum sobre até onde vai. Esse tipo de falha é chamado de buffer overflow, e é uma das causas mais clássicas de bugs graves e falhas de segurança em programas C. É exatamente por isso que gets() foi removida do padrão da linguagem.

#include <stdio.h>
#include <string.h>

int main() {
    char nome[8];
    int idade = 20;
    float saldo = 100.0;

    printf("Digite seu nome: ");
    fgets(nome, sizeof(nome), stdin); // respeita o tamanho de "nome"
    nome[strcspn(nome, "\n")] = '\0'; // remove o '\n' deixado pelo fgets

    printf("Nome: %s, idade: %d, saldo: %.2f\n", nome, idade, saldo);

    return 0;
}