20 Endereçamento de memória e ponteiros nos programas
Este capítulo aborda alguns detalhes sobre como os diversos elementos se relacionam à memória do dispositivo onde um programa é executado. Esse tema pode parecer desconexo do conteúdo de todos os capítulos anteriores, mas os conceitos descritos aqui são muito importantes para capítulos seguintes. Para capítulos anteriores este material complementa informações já apresentadas de forma mais superficial. Nos capítulos seguintes este conteúdo será relevante, pois são tratados mecanismos para modificar, dentro de funções, variáveis declaradas em outros escopos (Capítulo 21, Seção 22.2) e também meios para requerer dinamicamente espaços para armazenamento de dados (?sec-alocacao-dinamica-de-memoria).
Nesta parte do texto apenas os conceitos de armazenamento e uso da memória são abordados. As aplicações desses recursos para resolver problemas práticos da linguagem são abordados em outros lugares.
20.1 Endereçamento de memória
Quando uma variável é declarada, um espaço na memória é reservado para guardar seu valor. Por exemplo, ao se criar uma variável i
do tipo int
, alguns bytes da memória precisam ser reservados para guardar o valor da variável.
int i; // criação de uma variável inteira
Ao se fazer uma atribuição, como i = 10
, os bytes da variável i
são modificados para representar o valor inteiro 10. Se uma chamada printf("%d", i)
é feita, os bytes da memória reservados para i
são consultados e convertidos para um texto (valor decimal, %d
) e apresentado na tela.
= 10; // os bytes de i são modificados para representar o valor inteiro 10
i ("%d", i); // os bytes de i são consultados e convertidos para "10" printf
Até este momento, os bytes reservados para a variável i
foram irrelevantes. O compilador, apenas tendo o nome da variável (identificador i
), sabe onde e quantos são os bytes usados e como os valores devem ser representados. Para usar a memória para os dados, basta usar seu identificador e todo o resto é gerenciado automaticamente. E isso é ótimo para o programador, tanto que essa necessidade pelos detalhes nunca apareceu.
Para ilustrar esses detalhes ocultos, segue um programa que apresenta mais informações sobre as variáveis do programa.
/*
* Apresentação simples de endereços de memória
* Assegura: apresentação do valor de variáveis e suas localizações na
* memória de execução do programa
*/
#include <stdio.h>
int main(void) {
int i = 100;
("i = %d e está no endereço %p e tem %zu bytes.\n", i, (void *)&i,
printfsizeof i);
double d = -17.2;
("d = %g e está no endereço %p e tem %zu bytes.\n", d, (void *)&d,
printfsizeof d);
return 0;
}
i = 100 e está no endereço 0x7ffe51267d3c e tem 4 bytes. d = -17.2 e está no endereço 0x7ffe51267d30 e tem 8 bytes.
Não há novidades na atribuição de valores tanto à variável i
quanto d
, nem na apresentação de seus valores com o printf
. O que este programa introduz é o operador &
, o qual significa “endereço de”. Assim, &i
é o endereço de memória da variável i
, da mesma forma que &d
corresponde ao endereço de d
. O modificador de tipo (cast) (void *)
serve apenas para indicar que o endereço é genérico e desprovido de tipo. Ao longo do texto esse assunto voltará a ser tratado. O operador sizeof
também já foi utilizado e indica quantos bytes cada variável usa.
Quando um programa é colocado em execução, o sistema operacional cria um processo e compartilha com o programa o uso do processador e também uma porção da memória principal. A memória do programa vista por ele como um bloco contínuo de bytes, cada com seu endereço. É usual que endereços de memória sejam apresentados em valores hexadecimais (formato %p
do printf
).
Por exemplo, 7FFE51267D3C16 (endereço de i
no programa) corresponde ao valor decimal 140.730.259.897.660, mas esse valor, por si só, não é relevante. Dado que a variável está no endereço 7FFE51267D3C16 e possui quatro bytes, os endereços 7FFE51267D3C16, 7FFE51267D3D16, 7FFE51267D3E16 e 7FFE51267D3F16 são usados pela variável. Um raciocínio similar se aplica aos oito bytes da variável d
.
Para os objetivos desta seção, é apenas relevante saber, que cada variável está em algum lugar e que o compilador sabe seu endereço. Desse modo, atribuições triviais como d = -17.2
podem ser feitas, pois o compilador sabe o tipo (double
), a quantidade de bytes que serão usados (sizeof d
) e quais são esses bytes (os oito bytes começando em 7FFE51267D3016).
20.2 Armazenamento de endereços
Endereços de memória podem ser guardados em variáveis, as quais recebem genericamente o nome de ponteiros. Quando um ponteiro guarda um endereço, diz-se que ele guarda uma referência àquele endereço e, portanto, ao seu conteúdo.
/*
* Armazenamento de endereços de memória
* Assegura: apresentação do endereço de uma variável
*/
#include <stdio.h>
int main(void) {
double d = 1.125;
double *endereco_de_d = &d;
("A variável d usa %zu bytes começando em %p.\n", sizeof d,
printf(void *)endereco_de_d);
return 0;
}
A variável d usa 8 bytes começando em 0x7fff1420adc0.
Este programa cria uma variável chamada endereco_de_d
, à qual é atribuído o valor &d
(que é o endereço de d
). O tipo de uma variável que guarda endereços deve ser sempre um ponteiro e sua declaração usa o *
para indicar isso.
double *endereco_de_d; // variável que guarda um endereço
Entrando em mais detalhes, a variável é declarada com o tipo double *
e isso significa que a variável guarda o endereço de algo que ela sabe que é um double
. Na prática, uma declaração de ponteiro como a usada significa que o valor armazenado será o endereço primeiro dos oito bytes que estão guardando um valor do tipo double
.
Os ponteiros são criados com tipos associados à referência que vão armazenar e, assim, o compilador têm o controle do que é apontado. Seguem alguns exemplos adicionais de declarações.
int *pi; // endereço de um inteiro
char *pc; // endereço de um char
unsigned long int *puli; // endereço de um unsigned long int
20.3 Ponteiros nulos
Não custa lembrar que uma variável do tipo ponteiro é como qualquer outra variável e, para ser usada, precisa ter um valor válido atribuído a ela. O programa que segue mostra o conteúdo de um ponteiro para double
que não foi iniciado e, portanto, contém lixo.
/*
* Uso de um ponteiro sem valor atribuído
* Assegura: apresentação do endereço de uma variável
*/
#include <stdio.h>
int main(void) {
double *ponteiro_para_double;
("%p.\n", (void *)ponteiro_para_double); // lixo
printf
return 0;
}
main.c: In function ‘main’:
main.c:9:5: warning: ‘ponteiro_para_double’ is used uninitialized
[-Wuninitialized]
9 | printf("%p.\n", (void *)ponteiro_para_double); // lixo
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
main.c:8:13: note: ‘ponteiro_para_double’ was declared here
8 | double *ponteiro_para_double; | ^~~~~~~~~~~~~~~~~~~~
0x7f469a754ad0.
Porém, há uma diferença entre ter uma variável com valor inválido (nada foi atribuído a ela) e ter uma variável que “não aponta para nada”. Em C, o valor NULL
é usado para indicar explicitamente que uma variável não é referência para um endereço real.
/*
* Uso de um ponteiro sem valor atribuído
* Assegura: apresentação do endereço de uma variável
*/
#include <stdio.h>
int main(void) {
double *ponteiro_para_double = NULL; // endereço explicitamente inválido
("%p.\n", (void *)ponteiro_para_double);
printf
return 0;
}
(nil).
Na computação em geral, termos como null, nil ou nulo são usados para se referir a um endereço sabidamente inválido. Em C esse valor é expresso por NULL
e permite comparações, como if (p != NULL)
, por exemplo.
Para recordar essa situação é possível citar o acesso a arquivos. Uma variável para guardar um arquivo lógico é um ponteiro do tipo FILE *
, ou seja, guarda uma referência (endereço) de um objeto do tipo FILE
. Quando uma chamada à função fopen
não consegue acessar o arquivo, ela retorna NULL
, Em outras palavras, fopen
retorna o endereço de algo válido em caso de sucesso ou o endereço especial NULL
para indicar que o endereço não pode ser usado. Todas as demais funções (fprint
, fgets
, fclose
) apenas usam o endereço válido quando são chamadas.
20.4 Manipulação da memória com uso de ponteiros
Uma aplicação importante de ponteiros é a possibilidade de, tendo em mãos um endereço, modificar o que há naquele local. Assim, se um ponteiro contém o endereço de um inteiro, é viável ver e alterar o valor apontado.
Um exemplo inicial simples é apresentado na sequência, ilustrando como o ponteiro pode ser usado para acessar uma posição de memória.
/*
* Uso de ponteiro para ter acesso a um valor armazenado na memória
* Assegura: Apresentação do valor de duas variáveis usando um ponteiro
*/
#include <stdio.h>
int main(void) {
int n1 = 75;
int n2 = -3;
int *ponteiro;
// Uso do ponteiro com n1
= &n1; // guarda em ponteiro a referência para n1
ponteiro ("Valor apontado: %d.\n", *ponteiro);
printf
// Uso do ponteiro com n2
= &n2; // altera a referência para n2
ponteiro ("Valor apontado: %d.\n", *ponteiro);
printf
return 0;
}
Valor apontado: 75. Valor apontado: -3.
Neste programa são criadas duas variáveis int
: n1
e n2
. À primeira é atribuído o valor 75 e à segunda, -3. Uma variável ponteiro
é criada para guardar o endereço de um valor int
e o endereço de n1
é armazenado, conforme destacado na sequência.
int *ponteiro; // criação de uma variável ponteiro
= &n1; // armazenamento do endereço de n1 ponteiro
Nesse momento, com ponteiro
contendo o endereço de n1
, a expressão *ponteiro
se refere ao valor inteiro guardado nesse endereço. Em outras palavras, ponteiro
aponta para n1
e, em consequência, *ponteiro
dá o valor apontando, que é o valor de n1
. Assim, o printf
usa essa valor para “espiar” em n1
.
O programa então dá instruções para que ponteiro
aponte para n2
para, em seguida, usar *ponteiro
para acessar seu valor, conforme destaque seguinte.
= &n2; // altera a referência para n2
ponteiro ("Valor apontado: %d.\n", *ponteiro); printf
É importante notar que, nesse programa, ponteiro
é do tipo int *
e guarda endereços de elementos inteiros e, por sua vez, *ponteiro
é do tipo int
, pois olha o conteúdo apontado naquele endereço. A Tabela 20.1 mostra alguns casos e os tipos associados à notação sem e com o operador *
.
*
.
Declaração | Tipos associados | Exemplos |
---|---|---|
char *pc |
|
|
int *pi |
|
|
double *pd |
|
|
unsigned int *pui |
|
|
De forma similar ao se obter o conteúdo referenciado por um ponteiro, também é possível modificar o valor apontado. Segue um programa para exemplificar essa situação.
/*
* Modificação de um valor inteiro com uso de um ponteiro
* Assegura: Apresentação do valor do inteiro antes e depois da modificação
*/
#include <stdio.h>
int main(void) {
int valor_inteiro = 123;
("Valor da variável: %d.\n", valor_inteiro);
printf
int *ponteiro = &valor_inteiro;
*ponteiro = 98765;
("Valor da variável: %d.\n", valor_inteiro);
printf
return 0;
}
Valor da variável: 123. Valor da variável: 98765.
A variável valor_inteiro
é declarada, tem o valor 123 atribuído a ela e esse valor é apresentado. Então a variável ponteiro
é criada e o endereço de valor_inteiro
é armazenado nela. A modificação de valor é feita pelo comando destacado.
*ponteiro = 98765; // modifica o valor do endereço guardado em ponteiro
Essa instrução, basicamente, diz “coloque o valor 98765 no endereço armazenado em ponteiro
”. Nesse caso, como ponteiro
aponta para valor_inteiro
, os bytes dessa última variável serão alterados. O resultado é que, em última instância, valor_inteiro
tem seu conteúdo atualizado.
Desse modo, *ponteiro
pode ser tanto usado para obter o valor apontado quanto para modificá-lo.
int i, j, *pi; // i e j inteiros, pi ponteiro para inteiro
= 10;
i = &i;
pi
= *pi; // copia o valor 10 (de i) para j
j *pi = 1; // coloca o valor 1 em i
20.5 Os ponteiros têm tipos
Os ponteiros são sempre declarados usando um tipo (char
, int
, double
etc.) e especificando um asterisco antes do identificador, conforme os exemplos que seguem.
int *pi; // ponteiro para int
char *pc; // ponteiro para char
long double *pld; // ponteiro para long double
unsigned char *puc; // ponteiro para um unsigned char
Os tipos são importantes para o compilador lidar com as diversas operações. Assim, atribuições usando *pi
do lado esquerdo do operador de atribuição tratarão uma atribuição para inteiro; ao se escrever *pi + *pld
, as promoções de tipo serão feitas segundo as regras; ou para usar o printf
para mostrar *puc
deve ser usado o formato %u
, pois seu tipo é unsigned char
.
Segue um exemplo em que há mistura de tipos e, dada a mistura, os resultados divergem dos esperados.
/*
* Uso do tipo incorreto para um ponteiro
* Assegura: Apresentação do valor do inteiro antes e depois da modificação
*/
#include <stdio.h>
int main(void) {
float f = -1.1;
int *p = (int *)&f; // usa int* para apontar para float
*p = 1000; // altera o conteúdo de d
("d = %g.\n", f);
printf
return 0;
}
d = 1.4013e-42.
Como as representações de float
e int
são diferentes, a variável f
tenta extrair um valor real a partir de um conjunto de bits que, na realidade, representa um inteiro. A conclusão é simples: não funciona.
20.6 Ponteiros para cadeias de caracteres
Em C, uma cadeia de caracteres é uma sequência de bytes contínuos na memória (os caracteres) seguida por um byte nulo \0
. É dessa forma, por exemplo, que a função printf
, quando usa o formato %s
, interpreta a memória e decide o que apresentar na tela.
Como está introduzido na Capítulo 16, há cadeias de caracteres constantes e também em variáveis. Em particular, a Seção 16.3 mostra como usar ponteiros para referenciar as constantes literais existentes em um programa.
Da mesma forma que é possível ter uma variável do tipo char *
apontando para uma constante, também é comum que esse ponteiro seja usado para apontar para uma variável.
Para os ponteiros lidarem com cadeias de caracteres há dois pontos principais: o ponteiro mantém o endereço do primeiro byte da cadeia e o fim da cadeia é indicado pelo terminador \0
.
O programa seguinte mostra o uso de ponteiros tanto para constantes quanto para variáveis.
/*
* Ponteiros para cadeias de caracteres
* Assegura: apresentação de algumas cadeias de caracteres
*/
#include <stdio.h>
int main(void) {
char *texto_ponteiro = "Texto constante"; // ponteiro para constante
("Constante: %s.\n", texto_ponteiro);
printf
char texto_variavel[100] = "Texto de iniciação da variável"; // variável
("Variável: %s.\n", texto_variavel);
printf
char *outro_ponteiro;
= texto_ponteiro; // também aponta para a constante
outro_ponteiro ("Constante de novo: %s.\n", outro_ponteiro);
printf
= texto_variavel; // aponta para a variável
outro_ponteiro ("Variável via ponteiro: %s.\n", outro_ponteiro);
printf
return 0;
}
Constante: Texto constante.
Variável: Texto de iniciação da variável.
Constante de novo: Texto constante. Variável via ponteiro: Texto de iniciação da variável.
A variável texto_ponteiro
é um ponteiro e contém o endereço do primeiro byte da constante "Texto constante"
. Por sua vez, texto_variavel
já é um espaço para uma cadeia de até 99 bytes, ao qual também é copiado um valor inicial.
Uma terceira variável outro_ponteiro
é usada, em um primeiro momento, para apontar para a constante, o que é feito copiando-se o endereço armazenado em texto_ponteiro
.
= texto_ponteiro; // copia a referência para outro_ponteiro outro_ponteiro
Essa mesma variável é usada para, em um segundo momento, apontar para o primeiro byte da variável texto_variavel
.
= texto_variavel; // aponta para a variável outro_ponteiro
Aqui, é importante observar que existe uma variável literal denominada texto_variavel
e o uso deste identificador não se refere ao texto armazenado nela, mas ao seu endereço. Na prática, texto_variavel
equivale a &texto_variavel[0]
, ou seja, ao endereço do primeiro byte da variável. Esta é uma das razões pelas quais a atribuição direta a variáveis textuais em C não funciona, como exemplificado na sequência.
char nome[100] = "Cervantes" // variável
char outro_nome[100];
= nome; // não funciona, pois 'nome' é um endereço e não
outro_nome // o texto "Cervantes"
20.7 Exemplos
Nesta seção são apresentados alguns programas relativamente simples e genéricos que usam ponteiros, tendo como objetivo reforçar os conceitos e indicar alguns de seus usos.
20.7.1 Selecionando o menor valor
O exemplo seguinte usa um ponteiro para modificar o valor de uma entre duas variáveis. A variável a ser modificada é sempre a de valor mínimo.
/*
* Duplicando o valor mínimo com uso de ponteiro
* Requer: dois valores reais quaisquer
* Assegura: apresentação do dobro do valor mínimo e do valor máximo original
*/
#include <stdio.h>
int main(void) {
// Entrada
("Digite dois valores reais: ");
printfchar entrada[160];
(entrada, sizeof entrada, stdin);
fgetsdouble valor1, valor2;
(entrada, "%lf%lf", &valor1, &valor2);
sscanf
// Duplicação do valor mínimo usando um ponteiro
double *ponteiro_minimo;
if (valor1 < valor2)
= &valor1;
ponteiro_minimo else
= &valor2;
ponteiro_minimo *ponteiro_minimo = *ponteiro_minimo * 2; // dobra o valor mínimo
// Apresentação do resultado
("valor1 = %g e valor2 = %g.\n", valor1, valor2);
printf
return 0;
}
10.7 18.2
Digite dois valores reais: valor1 = 21.4 e valor2 = 18.2.
Com valor1
e valor2
lidos, a variável ponteiro_minimo
pode apontar tanto para a primeira quanto para a segunda, a depender de seus valores.
A estrutura if
usada poderia ser substituída por uma atribuição com o condicional ternário:
= (valor1 < valor2) ? &valor1 : &valor2; ponteiro_minimo
20.7.2 Vários ponteiros para um mesmo local
Assim como nada impede que duas ou mais variáveis double
tenham um mesmo valor, também é possível que vários ponteiros armazenem o mesmo endereço. Nesse caso, diz-se que vários ponteiros apontam para o mesmo local.
O programa seguinte define uma variável c
do tipo char
e usa três ponteiros para referenciá-la.
/*
* Uso de vários ponteiros para um mesmo local
* Assegura: apresentação dos valores apontados dadas algumas modificações
*/
#include <stdio.h>
int main(void) {
char c = 'A';
char *p1, *p2, *p3;
= p2 = p3 = &c; // todos os ponteiros apontam para c
p1 ("c = %c; *p1 = %c; *p2 = %c; *p3 = %c.\n", c, *p1, *p2, *p3);
printf
= 'B';
c ("c = %c; *p1 = %c; *p2 = %c; *p3 = %c.\n", c, *p1, *p2, *p3);
printf
*p1 = 'C';
("c = %c; *p1 = %c; *p2 = %c; *p3 = %c.\n", c, *p1, *p2, *p3);
printf
*p2 = 'D';
("c = %c; *p1 = %c; *p2 = %c; *p3 = %c.\n", c, *p1, *p2, *p3);
printf
*p3 = 'E';
("c = %c; *p1 = %c; *p2 = %c; *p3 = %c.\n", c, *p1, *p2, *p3);
printf
return 0;
}
c = A; *p1 = A; *p2 = A; *p3 = A.
c = B; *p1 = B; *p2 = B; *p3 = B.
c = C; *p1 = C; *p2 = C; *p3 = C.
c = D; *p1 = D; *p2 = D; *p3 = D. c = E; *p1 = E; *p2 = E; *p3 = E.
Como todas as variáveis (c
, *p1
, *p2
e *p3
) estão se referenciando ao mesmo char
na memória, esse caractere pode ser modificado por qualquer uma delas.
20.7.3 Copiando referências
Ao se atribuir o endereço de uma variável a um ponteiro, o valor armazenado é o endereço de memória do primeiro byte dessa variável. Se houver outra variável do tipo ponteiro, esse endereço pode ser copiado para ela com uma atribuição simples. Dessa forma, como no exemplo da Seção 20.7.2, o resultado é que se tem mais de um ponteiro referenciando uma mesma posição.
/*
* Cópia de referência entre ponteiros
* Assegura: apresentação dos valores apontados dadas algumas modificações
*/
#include <stdio.h>
int main(void) {
char c = 'A';
char *p1 = &c;
char *p2 = p1;
("c = %c; *p1 = %c; *p2 = %c.\n", c, *p1, *p2);
printf
= 'X';
c ("c = %c; *p1 = %c; *p2 = %c.\n", c, *p1, *p2);
printf
return 0;
}
c = A; *p1 = A; *p2 = A. c = X; *p1 = X; *p2 = X.
Neste programa, p1
aponta para c
ao receber &c
. Para p2
é atribuído o valor de p1
, o qual, nesse momento, é igual ao &c
.