La notion d'adresse (par Hugues Saulnier)
École de technologie supérieure Aut 2003
INF145 cours # 2 (partie adresses et pointeurs) Hugues Saulnier
La mémoire
La mémoire symbolique d’une machine est un vecteur d’octets. Chaque octet possède un numéro dans l’intervalle [0, quantité de mémoire].
Une adresse
En C, une adresse est une valeur qui possède une position mémoire et un type. C’est la conjonction d’un numéro d’octet et d’un contenu de type T. L’adresse possède le type T*. ATTENTION : En C, une adresse N’EST PAS l’équivalent d’une position dans la mémoire.
L’opérateur de déréférencement * en préfixe à une adresse permet d’accéder au contenu de cette adresse (à une exception près). Ce qui peut être par exemple :
1. Une addresse addr de type double* ou char* qui est l’adresse d’une valeur. L’expression *addr permet d’atteindre son contenu. De plus, l’expression sizeof(*addr) est correcte et donne la taille en octets du contenu.
2. Une adresse addr de type int(*)(int, int) qui est l’adresse d’une fonction. L’expression addr(2, 5) ou (*addr)(2, 5) permet d’exécuter la fonction. L’utilisation de l’opérateur * est alors superflue. Dans ce cas, l’expression sizeof(*addr) ne s’applique pas puisque le prototype d’une fonction ne suffit pas à connaître le nombre d’octets de son code.
3. Une adresse de type void* où seule la position mémoire est importante. Le besoin d’adresses génériques -- peu importe le contenu -- a fait naître ce type spécial. On les utilise pour la comparaison (memcmp) ou la copie (memcpy) de blocs d’octets de toutes tailles et pour obtenir (malloc) ou libérer (free) de la mémoire en allocation programmée. L’opérateur * ne s’applique pas à ces adresses.
La taille en octets des adresses dépend du modèle de compilation choisi. Avec la taille des mémoires actuelles, toutes les adresses sont normalement gardées sur quatre octets. En ingénierie, la programmation sur de toutes petites plate-formes se fait toujours avec des adresses plus petites.
Comment obtenir des adresses dans un programme
1. Avec l’opérateur & : L’expression obtenue de l’opérateur & en préfixe à une variable de type T est évaluée comme une adresse de type T*. Veuillez noter que la classe d’allocation register et l’opérateur & sont incompatibles.
2. À l’aide du nom d’un tableau : Le nom d’un tableau contenant des objets de type T est automatiquement évalué comme une adresse de type T* correspondant à l’adresse du premier élément du tableau.
3. À l’aide du nom d’une fonction : Le nom d’une fonction donne l’adresse du premier octet de son code. Le type de l’adresse est associé au prototype du contenu.
4. Par allocation programmée : Les fonctions classiques – malloc, calloc et realloc – permettent d’obtenir du système d’exploitation des blocs d’octets de la taille désirée. Ces blocs n’ont pas de classe d’allocation (« storage class »), leur maintien est tout à la charge du programmeur. Elles retournent l’adresse du premier octet du bloc réservé dynamiquement par l’instruction.
5. Par transtypage : Le transtypage d’une adresse correcte donne une adresse différente. La position n’est pas changée mais le contenu est différent. La forme la plus correcte devrait se faire en transitant par void*. Peu importe le type de l’adresse addr, l’expression (T1*)(void*) addr sera de type T1*. Le C ne donne pas de limites à ces transtypages et le programmeur en est totalement responsable.
6. Par adressage absolu : Un entier peut être considéré comme une adresse. Par exemple, l’expression (int*)(void *) 1600 permet d’avoir accès à un entier à l’octet numéro 1600 en mémoire. Cette pratique n’est rencontrée qu’en programmation système et elle est absolument NON PORTABLE!
7. Par arithmétique des adresses : À l’aide des opérateurs + et – que l’on applique sur une adresse, on obtient une nouvelle adresse. La prochaine section décrit l’arithmétique des adresses.
L’arithmétique des adresses
Les opérations permises sur une adresse tiennent compte de la taille de son contenu. Ainsi, on ne pourra pas effectuer d’opération sur une adresse dont le contenu n’admet pas l’opérateur sizeof -- adresses de fonctions et adresses génériques (void*) --.
1. La somme d’une adresse et d’un entier donne une adresse : Avec une adresse addr de type T* et un entier positif n, (addr + n) est une adresse de type T* et sa position est avancée de n * sizeof(T) octets dans la mémoire par rapport à addr.
2. La différence d’une adresse et d’un entier donne une adresse : Avec une adresse addr de type T* et un entier positif n, (addr - n) est une adresse de type T* et sa position est reculée de n * sizeof(T) octets dans la mémoire par rapport à addr.
3. La différence de deux adresses du même type T* donne un entier : La valeur obtenue correspond au nombre d’objets de type T entre les deux adresses.
L’usage portable de l’arithmétique des adresses n’a de sens que sur des adresses à l’intérieur du même tableau.
Les pointeurs
Une variable de type pointeur contient une adresse. Pour être conservée dans votre programme, une adresse doit être assignée à un pointeur du même type. Donc, pour un type quelconque T, un pointeur de type T* est apte à garder une adresse dont le contenu est un T. Ainsi, avec un pointeur T * ptr (où T est différent de void), *ptr donne accès au contenu de l’adresse pointé par ptr.
Lorsqu’un pointeur est déclaré, celui-ci n’est pas initialisé automatiquement à une adresse correcte. L’utilisation d’un pointeur non initialisé est une erreur grave et malheureusement fréquente en C. Le compilateur ne prend aucune responsabilité sur l’utilisation du contenu d’un pointeur. Pour aider le programmeur, la norme ANSI offre le symbole NULL – équivalent à la valeur 0 -- assignable à tout type de pointeur pour indiquer qu’il n’adresse rien.
Le passage par adresse
La programmation structurée moderne encourage l’emploi de plusieurs fonctions et l’absence de variables globales. Cette pratique amène l’utilisation massive de la paramétrisation.
En C, tout passage en paramètre se fait par copie. Cette pratique est efficace pour les types de base. Cependant, pour une struct dont la taille est parfois imposante, le gaspillage de temps et de mémoire rend cette méthode inacceptable. Voilà pourquoi les struct seront toujours passées à l’aide d’un pointeur constant. Cette pratique ne nécessite la copie que de quelques octets et empêche de modifier les données transmises (tout comme un passage par valeur). Voici un exemple :
typedef struct
{
/* Les différents champs souhaités. */
} T;
void afficher_struct(const T *);
Si la fonction doit modifier la struct reçue, on retirera alors le mot const de l’en-tête. Voici un exemple :
typedef struct
{
/* Les différents champs souhaités. */
} T;
void initialiser_struct(T *);