Systèmes informatiques
Fermer ×

Le langage C

Historique

Le langage C a été créé dans les années 1970 par B.W. Kernighan et K. Thomson qui est l'auteur du langage B. Ce langage a fait l'objet d'un livre intitulé "The C Programming Language" écrit par B.W. Kernighan et D.M. Ritchie qui disposent de plusieurs versions en Anglais et éditions en Français.

Le langage a fait également l'objet de plusieurs normalisations de 1988 à nos jours. Ces normes sont essentiellement :

Dans certains cas, il peut s'avérer nécessaire de préciser la norme au compilateur, comme avec le compilateur gnu gcc avec l'option -std= suivi de la norme.

Outils et développement

Avertissement : tous les exemples de programmes C ont été compilés avec gcc et make et testés sous linux.

Les outils

Il existe une grande diversité d'outils de développement pour le langage C, cela va du compilateur à l'IDE qui intégère un éditeur de texte, un gestionnaire de projet, le compilateur et d'autres outils d'aide au développement.

Parmi ces compilateurs, on trouve les logiciels libres :

Parmi les IDE, on trouve les environnements :

Il faut également ajouter l'aide sur les fonctions, accessibles avec la commande man sous linux, ou bien en ligne avec, par exemple, le site man7.

Développement d'un programme

Les étapes de la conception d'un programme

Un compilateur transforme un code source (C) en un langage objet compréhensible par le micro-processeur. Pour tous les exemples de ce chapitre, on utilisera le compilateur GNU gcc sous Linux et l'éditeur de texte Geany qui est un logiciel libre disponible pour tous les systèmes d'exploitation. Il est fourni avec les distributions linux et téléchargeable pour le système Windows.

Les différentes étapes de développement sont principalement :

  • La compilation qui crée le fichier objet d'extension .o à partir du fichier source C, Il y a un fichier objet pour chaque fichier source. Un fichier d'extension .h est un fichier qui contient les prototypes des fonctions disponibles dans le fichier .c correspondant, les définitions des structures de données de l'application, les définitions des constantes. Un fichier d'extension .h ne contient pas de code qui puisse générer du code objet.
  • L'édition de liens, qui établit le lien entre les différents fichiers objets, mais également avec les fichiers librairies statiques d'extension .a ou bien dynamique d'extension .so afin de produire le programme exécutable.. Ces librairies contiennent les codes objets des fonctions comme les fonctions mathématiques, d'échanges avec la sortie standard et l'entrée standard, ... . La version statique inclut tous les codes objets dans le programme exécutable, la version dynamique implique que le système qui utilise ce programme dispose des mêmes librairies dynamiques ou partagées.
  • Enfin toutes ces tâches de compilation et d'édition de lien peuvent être automatisées dans un fichier nommé Makefile ou makefile qui est appelé par la commande make.

La commande make

Les principales options de la commande make

Le fichier Makefile

La syntaxe générale d'une ligne de commande dans le fichier est de la forme :

cible: dependances
[tablulation]commande

cible correspond à ce qui doit être réalisé, par défaut c'est la cible all qui est utilisée, ce qui signifie que cette cible doit toujours être présente.

dependances est la liste des cibles nécessaires à l'exécution des commandes relatives à cette cible

[tabulation] représente une tabulation pour décaler la commande, cette tabulation est obligatoire et ne peut pas être remplacée par des espaces.

Un fichier Makefile, est une suite de cibles et de commandes associées à cette cible.

Cibles et dépendances génériques :

%.o: %.c

correspond à chaque fichier.o en fonction du fichier .c de même nom

Les variables sont définies par une chaîne de caractères minuscules ou majuscules sans accent ni ponctuation. L'affectation se fait avec le symbole = suivi de la valeur. L'utilisation se fait avec le nom entre parenthèses précédé du symbole $ (ex : $(NOM)).

Il existe des variables prédéfinies

Création d'une liste des fichiers sources

SRC=$(willcard *.c)

Etablir une liste des fichiers objets à partir de la liste des fichiers sources

OBJ=$(wilcard .c=.o)

Exemple pour un seul fichier source

CC=gcc
PROG=hello
OBJ=$(PROG).o
SRC=$(PROG).c
CFLAGS=-Wall
LDFLAGS=

all: $(EXEC)

$(EXEC): $(OBJS)
	$(CC) $(LDFLAGS) -o $(PROG) $^

$(OBJ):
	$(CC) $(CFLAGS) -c -o $@ $(SRC)

clean:
	rm -f $(OBJ) $(PROG)
				

Ce fichier va créer le fichier exécutable hello en fonction du fichier source hello.c, en passant par la création du fichier hello.o

L'option de compilation -Wall permet d'afficher toutes les alertes (warning) en plus des erreurs, ce qui est vivement conseillé, car un warning peut se transformer en erreur lors de l'exécution.

Exemple pour plusieurs fichiers sources

CC=gcc
CFLAGS= -Wall 
LDFLAGS=-lm
EXEC=hello
SRC= $(wildcard *.c)
OBJS= $(SRC:.c=.o)

all: $(EXEC)

$(EXEC): $(OBJS)
	$(CC) -o $(EXEC) $^  $(LDFLAGS)

%.o: %.c
	$(CC) $(CFLAGS) -c -o $@ $<

clean:
	rm *.o $(EXEC)
				

Ce fichier va créer le fichier exécutable hello à partir de tous les fichiers sources .c présents dans le répertoire.

L'option d'édition de liens -lm permet d'utiliser les librairies partagées de calcul mathématique.

Exemple de programme

/* hello.c */
#include <stdio.h>

int main(int argc, char **argv) {
	printf("Bonjour le monde\n");
	return EXIT_SUCCESS;
}
				

Le texte compris entre /* et */ est du commentaire qui n'est pas compilé. On peut également utiliser un commentaire ligne (valable pour une seule ligne) en commençant par //

Le fichier de prototypes stdio.h contient entre autres la déclaration de la fonction printf.

La fonction main(argc,argv) doit toujours être présente une seule fois dans un projet C. Cette fonction est appelée au démarrage du programme. Le paramètre argc correspond au nombre de paramètres présents sur la ligne de commande incluant le nom du programme. Le paramètre argv est un tableau de chaînes de caractères qui contiennent chaque valeur de la ligne de commande, argv[0] correspond au nom du programme.

La valeur de retour EXIT_SUCCESS fournit la valeur de retour au système d'exploitation qui correspond au fait qu'il n'y a pas d'erreur, en général 0.

Voir le code objet du programme

Contenu du fichier hello.o obtenu avec la commande objdump -d hello.o, il fournit le code assembleur de la fonction main.

hello.o:     format de fichier elf64-x86-64

Déassemblage de la section .text :

0000000000000000 
: 0: f3 0f 1e fa endbr64 4: 55 push %rbp 5: 48 89 e5 mov %rsp,%rbp 8: 48 83 ec 10 sub $0x10,%rsp c: 89 7d fc mov %edi,-0x4(%rbp) f: 48 89 75 f0 mov %rsi,-0x10(%rbp) 13: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 1a 1a: e8 00 00 00 00 callq 1f 1f: b8 00 00 00 00 mov $0x0,%eax 24: c9 leaveq 25: c3 retq

La ligne 1a représente l'appel à la fonction printf. Ce code sera complété à l'édition de lien pour construire le programme exécutable.

Edition de liens dynamique et statique

L'exemple ci-dessus utilise une librairie dynamique, le programme a donc besoin des librairies partagées lors de l'exécution. La liste des librairies partagées nécessaires peut être obtenue avec la commande ldd.

ldd hello
	linux-vdso.so.1 (0x00007ffe2b1d5000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f9f858b9000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f9f85add000)
		

Ce programme a besoin de la libraire libc.so qui contient le code de printf, la librairie linux-vdso.so qui contient les liens vers les espaces mémoires dédiés au programme, la librairie ld-linux-x86-64.so qui est le chargeur de librairie.

Le programme hello a une taille d'environ 16ko, car il ne contient pas le code de la fonction printf.

Si on ajoute l'option -static aux options de l'édition de liens (LDFLAGS), on crée un fichier qui inclut le code de la fonction printf. Sa taille est maintenant d'environ 86ko, et la commande ldd précise qu'il ne s'agit pas d'un exécutable dynamique

Syntaxe et structure du langage

Les types de données

En C, on distingue les entiers signés et non signés, les réels en simple, double et quadruple précision. Le booléen est un entier qui représente la valeur fausse lorsqu'il est à 0 et la valeur vraie lorsqu'il est différent de 0.

Il existe également un type non typé : void

Les entiers

Les entiers signés

TypeLimites
char-128 à +127
short-32768 à +32767
int1-2147483648 à 2147483647
long-263 à 263-1

Les entiers non signés

TypeLimites
unsigned char0 à 255
unsigned short0 à 65535
unsigned int10 à 4294967295
unsigned long0 à 264-1

1 : pour les types int et unsigned int, la taille dépend du processeur, elle peut être de 16 ou 32 bits suivant le processeur.

Afin d'éviter le problème de la taille de ces types de données, il existe de nouvelles définitions de données déclarées dans le fichier stdint.h qu'il faudra inclure.

Les entiers signés

TypeLimites
int8_t-128 à +127
int16_t-32768 à +32768
int32_t1-2147483648 à 2147483647
int64_t-263 à 263-1

Les entiers non signés

TypeLimites
uint8_t0 à 255
uint16_t0 à 65535
uint32_t10 à 4294967295
uint64_t0 à 264-1

Les réels

Les nombres réels respectent la norme IEEE 754 comme cela est expliqué dans ce chapitre sur la calculatrice.

TypeLimitesPlus petite valeurPrécision relative
float≈±3.4×1038≈±2-126≈10-6
double≈±1.8×10308≈±2-1022≈10-15
long double≈±1.19×104932≈±2-16382≈10-33

Les variables

Déclaration

La déclaration d'une variable se fait avec le type suivi du nom de la variable et se termine par une point-virgule. Il est possible de déclarer plusieurs variables de même type en les séparant par des virgules.

type nomvariable;
type nomvariable1,nomvariable2;
		

Exemple de déclarations de variables :

int nb1,nb2,nb3;
float nb4;
		

Une variable peut être initialisée lors de la déclaration en utilisant le symbole =, comme par exemple

int a= 3;
float reel = 2.5;
		

Affectation

L'affectation consiste à transmettre le résultat d'une expression à une variable, il utilise le symbole = qui n'est pas une égalité au sens mathématique. L'affectation se termine également par un point-virgule.

resultat = expression de calcul;	
		

Exemple de calcul :

nb3 = nb1 + nb2; 
		

On réalise l'addition de nb1 avec nb2, puis on affecte la somme à la variable resultat. Ces variables doivent être déclarées au préalable.

L'affectation peut être mise en cascade :

a = b = 5;
		

La valeur 5 est transférée à b puis à a. Après cette ligne les deux variables ont la même valeur 5.

Conversion entre types différents

La conversion implicite de type fonctionne de la plus faible précision vers la plus forte :

Dans ce cas il n'y a pas de perte de précision

Dans une affectation, le type du résultat du calcul de l'expression ne dépend pas du tout du type de la variable situé à gauche du symbole =. L'expression est évaluée en effectuant les conversions de type implicites de la plus faible précision vers la plus élevée. Ensuite la conversion de type est effectuée entre le type du résultat et le type de la variable qui contient le résultat. Exemple :

float r;
int x,p;
r = x / p;
		

On aura une division entière avec le résultat entier converti en réel, ce qui donne une valeur entière au format réel.

Dans certains cas, on peut forcer la conversion de type en précédant la variable du nouveau type entre parenthèses, on parle de transtypage ou "cast".

On modifie l'exemple précédent afin d'obtenir une division réelle.

float r;
int x,p;
r = (double)x / p;
		

x est convertit en réel double précision, la division est réelle, le résultat réel est affecté à la variable r.

Il faut faire attention au placement de l'opérateur cast.

float r;
int x,p;
r = (double)(x / p);
		

Dans ce cas on effectue une division entière qui donne une résultat entier qui est ensuite converti en réel.

Voir un exemple de problème de transtypage d'une entier 32 bits vers un réel simple précision

La conversion d'entier vers réel peut induire des pertes de précision, comme par exemple lors de la conversion d'un entier 32 bits vers un réel simple précision. Cela se produit lorsque l'entier N exprimé sur 32 bits est supérieur à 224, comme par exemple, avec N=268499633 qui prend la valeur réelle : R=268499648.00000. En utilisant la représentation mémoire des entiers et réels, on a :

313029282726252423222120191817161514131211109876543210
N 00010000000000001111101010110001
sed7ed6ed5ed4ed3ed2ed1ed0a1a2a3a4a5a6a7a8a9a10a11a12a13a14a15a16a17a18a19a20a21a22a23
R 01001101100000000000011111010110

La valeur réelle est (1.00023913383483886718750) × 228=268499648.000000. L'erreur relative est environ 5.587e-08

Voir un deuxième exemple de problème de transtypage entre un réel et un entier

Cas du réel en double précision

double reel = 8.62;
printf("%lf\n",reel);
int entier = (int)(reel *100) ;
printf("%d\n",entier);
					

L'affichage est

8.620000
861
					

Cas du réel en simple précision

float reel = 8.62;
printf("%f\n",reel);
int entier = (int)(reel *100) ;
printf("%d\n",entier);
					

L'affichage est

8.620000
862
					

La représentation binaire de 8.62 utilise une infinité de bits, elle donc obligatoirement arrondie. On a une erreur de calcul en double précision mais pas en simple précision, cela vient du fait qu'en double précision la valeur réelle enregistrée n'est pas arrondie sur le même nombre de bits.
Cette erreur n'apparaît pas en seulement en C mais dans tous les langages qui utilisent la double précision ainsi que les logiciels de calcul comme octave et scilab.

Notation pour les constantes

Par défaut, une valeur entière est écrite en base 10, pour écrire une valeur en hexadécimal, il faut utiliser le préfixe 0x, pour affecter un entier avec le code ascii d'un caractère, il suffit d'utiliser le caractère entre simples apostrophes comme par exemple 'a'.

Portée des variables

Dans un fichier C les variables sont généralement déclarées à l'intérieur des fonctions, ce sont des variables locales qui ne sont connues de la fonction et stockées dans la pile du programme.

Quelques fois elles peuvent déclarées en début du fichier en dehors de toute fonction, dans ce cas ce sont des variables globales accessibles et modifiables par toutes les fonctions. Cela doit rester un cas particulier.

Les opérateurs de base

On a bien sûr les opérateurs arithmétiques : +,-,*,/,%. Le type de division dépend des opérandes, si les deux opérandes sont de type entier, la division est entière (euclidienne), si une des opérandes est réelle, la division est réelle. Le symbole % représente le modulo ou reste de la division entière.

Il existe également des opérateurs simplifiés comme += , -=, *=, /= qui effectue l'opération entre la variable qui contient le résultat et une vautre variable ou valeur.

Il existe également des opérateurs de post-incrémentation en suffixant la variable des symboles ++, pré-incrémentation en préfixant la variable des symboles ++, post-décrémentation en suffixant la variable des symboles -- et enfin la pré-décrémentation en préfixant la variable des symboles --. La post-incrémentation permet d'utiliser la valeur de la variable puis de lui ajouter 1, la pré-incrémentation permet d'ajouter 1 à la variable avant d'utiliser sa valeur. Il est est de même pour la post-décrémentation et la pré-décrémentation avec la soustraction de 1.

Le C fournit également des opérateurs qui permettent de traiter les bits des entiers : | pour l'opération OU logique bit par bit, & pour l'opération ET bit par bit, ~ pour l'inversion de chaque bit de l'entier, ou encore << pour décaler les bits d'un entier à gauche (bit de poids faible vers bit de poids fort), >> pour décaler à droite les bits d'une entier (bits de poids fort vers bit de poids faible).

Il fournit également des opérateurs booléens qui traite les entiers du point de vue booléen : || pour l'opération OU, && pour l'opération ET, ou encore ! pour l'inversion booléenne.

Opération logique booléenne et bit par bit

Lors d'écriture d'expressions booléennes, il ne faut pas oublier la signification des opérateurs bit par bit et booléen, car le résultat n'est pas le même.

L'entier a vaut 38 en base 10, et l'entier b 72. Le résultat de l'opération booléenne && est 1.

Du point de vue booléen a et b sont différents de 0, donc vrai, l'opération booléenne VRAI et VRAI donne VRAI qui vaut 1.

L'entier a vaut 38 en base 10, et l'entier b 72. Le résultat de l'opération booléenne & est 0.

L'opération bit par bit & effectue un ET logique entre chaque bit des entiers a et b, ce qui donne le résultat 0.

Le résultat n'est pas correct du point de vue booléen car il donne FAUX pour VRAI et VRAI

En conséquence il faut être très vigilant sur l'utilisation des opérateurs logiques, car cela pourrait entraîner des erreurs importantes.

Les opérateurs de comparaisons

Le résultat des opérations de comparaisons est un booléen qui vaut 0 (faux) ou 1 (vrai)

Les opérateurs sont :

Il est possible de combiner des expressions de comparaisons avec les opérateurs booléens || &&, à condition d'encadrer chaque expression de comparaison avec des parenthèses.

Les structures de contrôle

Les structures de contrôles permettent d'effectuer un traitement en fonction du résultat d'une expression booléenne

L'alternative

if (expression booleenne) {
	// traitement si expression booleenne VRAIE
}
else {
	// traitement si expression booleenne FAUSSE
}
				

Il est possible d'imbriquer ces structures

if (premiere expression booleenne) {
	// traitement si premiere expression booleenne VRAIE
}
else if (deuxième expression boolenne) {
	// traitement si premiere expression boolenne FAUSSE 
	// et deuxieme expression booleenne VRAIE
}
else {
	// traitement si premiere expression boolenne FAUSSE 
	// et deuxieme expression booleenne FAUSSE
}
				
switch (variable) {
	case val1 : // traitement pour variable=val1
				break;
	case val2 : // traitement pour variable=val2
				break;
	// ...
	case valn : // traitement pour variable=valn
				break;
	default : 	// traitement pour tout autre valeur
}
				

Très important : variable ne peut être qu'un entier, car cette structure effectue uniquement une comparaison d'égalité.

C'est la commande break qui permet de sortir de cette structure, si elle est omise, le cas suivant sera traité.

Les répétitions

for(initialisation;expression booléenne;calcul) {
	// bloc instructions ;
}
				
  • Le bloc instructions est répété tant que l’expression booléenne est vraie.
  • L'initialisation consiste à initialiser les variables de l'expression booléenne
  • le calcul permet de modifier une ou plusieurs variables de l'expression booléenne

Il peut y avoir plusieurs initialisations séparées par des virgules, et plusieurs calculs séparés par des virgules

Exemple avec la somme des 10 premiers entiers :

int s=0;
for(int n=1;n<=10;n+=1) {
	s += n ;
}
		
while (expression booleenne) {
	// bloc instructions
}
				
  • Le bloc instruction est exécuté tant que l’expression booléenne est vraie.
  • L’expression booléenne est vérifiée avant d’exécuter le bloc instruction, ce qui impose que la valeur de cette expression booléenne doit être initialisée avant d’exécuter cette boucle.
  • Le bloc instruction peut ne pas être exécuté
do  {
	// bloc instructions
} while (expression booleenne); 
				
  • La boucle est exécutée tant que expression booléenne est vraie.
  • L’expression booléenne est vérifiée après avoir exécuter le bloc instruction, ce qui signifie que la boucle est exécutée au moins une fois.

L'entrée et la sortie standard

L'entrée et la sortie standard correspondent respectivement au clavier et à l'écran.

L'affichage de message et de résultat sur la sortie standard fait appel à la fonction printf

La syntaxe est :

printf(chaineformat,liste variables);
				

avec

  • chaineformat qui est une chaîne de caractères qui contient du texte ainsi que des symboles pour formater l'affichage des variables
    • %s pour une variable de type chaîne de caractères
    • %d pour un entier signé, un l peut précéder le d pour un entier long
    • %u pour un entier non signé, l'utilisation du l est également possible
    • %f pour un réel simple précision, un l permet d'afficher un réel en double précision
  • liste variables est une liste de variables séparées par des virgules. L'ordre des variables est important et respecte l'ordre des symboles de formatage de la chaîne de format.

La lecture de données depuis l'entrée standard fait appel à la fonction scanf

La syntaxe est :

scanf(format,adresse variable);
				

avec

  • format est un symbole de formatage sans texte. Les symboles de formatage sont identiques à ceux utilisés dans la fonction printf
  • adresse variable est le nom de la variable préfixée du symbole &
Voir un exemple de programme de calculatrice simplifiée

Code source C

#include <stdlib.h>
#include <stdio.h>

int main(int argc, char **argv) {
	
	double a,b, resultat ;
	char operateur, operation[5] ;
	
	scanf("%lf",&a);
	scanf("%s",operation);
	scanf("%lf",&b);
	operateur = operation[0] ;
	switch (operateur) {
		case '+' : resultat = a + b;
			   break;
		case '-' : resultat = a - b;
			   break;
		case '*' : resultat = a * b;
			   break;
		case '/' : resultat = a / b;
			   break;
	}
	printf("%lf\n",resultat);
	
	return EXIT_SUCCESS;
}
					

Ce programme "calculatrice" permet d'effectuer une opération arithmétique sur deux nombres réels. On a donc trois variables réelles a,b,resultat ainsi qu'une variable qui contient le caractère représentant l'opération. La variable opération est une chaîne de 4 caractères. Ce type de données sera présenté dans les paragraphes qui suivent. La ligne : operateur=operation[0] permet de transférer le premier caractère de la chaîne operation dans la variable operateur.

Les trois lignes scanf permettent de saisir les valeurs a,b et l'opération à partir de l'entrée standard. Les variables a et b sont précédées de & pour signifier qu'il s'agit de l'adresse de la variable. Ce n'est pas le cas pour la variable operation qui est une chaîne où operation représente l'adresse.

La structure switch permet de choisir l'opération à effectuer en fonction de la valeur du caractère qui représente l'opération.

Enfin printf affiche le résultat de l'opération sur la sortie standard.

Ce programme peut être testé en ligne de commande, à condition d'être dans le répertoire du programme, avec la syntaxe suivante :

echo "3 + 5" | ./calculatrice
8.000000
					

Si on lance le programme ./calculatrice seul, il faudra saisir au clavier la valeur de a, l'opération puis la valeur de b.

Voir un exemple de programme de test de la calculatrice

Généralement, quelques tests manuels ne sont généralement pas suffisant pour permettre d'affirmer que le programme fonctionne parfaitement. Même si cela peut être suffisant dans le cas présent, on va montrer comment ou pourrait effectuer un test plus complet de ce programme à l'aide d'un script shell et de la calculatrice bc.

#!/bin/sh

EQ=0
N=100
LISTE=$(seq 1 $N)
operateur="/"
for i in $LISTE
do
	INTA=$(shuf -i 1-100000 -n 1)
	INTB=$(shuf -i 1-100000 -n 1)
	a=$( echo "scale=3;$INTA / 1000" | bc -l)
	b=$( echo "scale=3;$INTB / 1000" | bc -l)
	RBC=$(echo "scale=6;$a $operateur $b" | bc -l )
	RCAL=$(echo "$a $operateur $b" | ./calculatrice )
	EQ=$( echo "scale=20;$EQ + ($RBC - $RCAL)^2" | bc -l)
done
EQM=$( echo "scale=20;sqrt($EQ / $N)" | bc -l)
printf "EQM=%e\n" $EQM
					

Ce script va effectuer 100 divisions avec des valeurs aléatoires, calculer l'erreur quadratique moyenne sur l'ensemble de ces valeurs.

La commande shuf permet de générer un entier aléatoire dans l'intervalle 1-100000. Ensuite on utilise la calculatrice bc pour construire des réels en divisant par 1000 ces entiers.

On réalise la division avec la calculatrice pour fournir la valeur dans la variable RBC, puis avec le programme calculatrice pour fournir la valeur dans la variable RCAL

On utilise la calculatrice bc pour effectuer la somme cumulée de la différence des deux résultats élevée au carré. Le résultat final est dans la variable EQ

Après la boucle, on utilise encore la calculatrice bc pour effectuer la racine carrée de la moyenne.

Enfin on affiche le résultat avec la commande shell printf.

Exemple de résultat :

EQM=7.211103e-07
					

Ce résultat ne précise pas si l'erreur vient de la calculatrice bc ou bien du programme calculatrice. Il permet simplement de montrer que le programme calculatrice fournit des résultats acceptables.

Les pointeurs, tableaux, structures et unions

Les pointeurs

Un pointeur est une variable qui contient l'adresse mémoire d'une variable.

La syntaxe pour la déclaration est

type *ptr_variable;
				

type est le type de la donnée, ptr_variable est la variable de type pointeur représenté par l'opérateur *. Le préfixe ptr_ n'est pas obligatoire, c'est plutôt une convention pour désigner une variable de type pointeur.

  • ptr_var représente l'adresse de la variable qui contient la donnée.
  • *ptr_var représente la valeur de la donnée.
  • &ptr_var représente l'adresse du pointeur, c'est à dire l'adresse mémoire qui contient l'adresse de la variable.
Voir un exemple de représentation mémoire d'un pointeur

Exemple avec la déclaration

int entier;
char *ptr_octet = &entier;
					

Si l'adresse mémoire de l'entier est 0x00000010 et l'adresse mémoire du pointeur est 0x00000000, la deuxième ligne permet d'affecter le pointeur ptr_octet avec l'adresse mémoire de l'entier.

  • *ptr_octet représente le premier octet de l'entier
  • *(ptr_octet+1) représente le deuxième octet de l'entier
  • *(ptr_octet+2) représente le troisième octet de l'entier
  • *(ptr_octet+3) représente le quatrième octet de l'entier

Dans l'exemple présenté, l'octet de poids fort de l'adresse de la variable est stockée à l'adresse la plus basse du pointeur. On a donc un format little endian.

Avec ce format, la valeur de l'entier est donc 0x23424511

Pour comprendre les formats little et big endian, on peut se référer à la représentation mémoire des entiers et réels

On peut trouver un autre exemple d'utilisation des pointeurs pour effectuer des calculs avec cet exemple de calcul rapide de l'inverse de la racine carrée.

Les tableaux

Les tableaux à une dimension

Un tableau est un ensemble de données de même type accessibles individuellement en utilisant un indice qui commence à 0.

La syntaxe pour la déclaration est

type tab_variable[taille];
		

type est le type de l'élément. tab_variable est le nom de la variable tableau, et utilisé seul, sans crochets, correspond à l'adresse mémoire du début du tableau. Comme pour le pointeur, le préfixe tab_ n'est qu'une convention d'écriture. taille entre crochets définit le nombre maximum d'éléments réservés pour ce tableau. Il ne sera pas possible de modifier la taille du tableau dans la suite du programme.

La fonction sizeof(tab_variable) retourne la taille du tableau en octets.

Voir un exemple de représentation mémoire d'un tableau à une dimension

L'image présente l'implémentation mémoire du tableau déclaré avec la ligne suivante :

short tab_entier[3] = { 0x10, 0x20, 0x15 };
// ou
short tab_entier[] = { 0x10, 0x20, 0x15 };
					

Il s'agit d'un tableau de 3 entiers de 16 bits où chaque élément est initialisé. Dans le cas d'un tableau initialisé, il est possible de ne pas préciser la taille, et de laisser les crochets vides. La valeur de l'indice permet d'accéder à l'élément du tableau avec la syntaxe : tab_entier[indice]. Le nom de la variable tab_entier correspond à l'adresse du début du tableau en mémoire.

Les entiers sont représentés au format little endian.

Les tableaux à deux dimensions

Un tableau à deux dimensions est un tableau où l'élément est un tableau, c'est un tableau de tableaux.

La syntaxe pour la déclaration est

type tab_variable[taille_tableau][taille_tableau_element];
		

type est le type de l'élément. tab_variable est le nom de la variable tableau, et utilisé seul, sans crochets, correspond à l'adresse mémoire du début du tableau. Comme pour le pointeur, le préfixe tab_ n'est qu'une convention d'écriture. taille_tableau correspond à la taille du tableau, taille_tableau_element correspond à la taille de l'élément tableau.

Voir un exemple de représentation mémoire d'un tableau à deux dimensions

L'image présente l'implémentation mémoire du tableau déclaré avec la ligne suivante :

short tab[2][2] = { { 0x10, 0x20} , { 0x12, 0x45}  };
					

Contrairement au tableau une dimension, les tailles doivent toujours être précisées.

L'accès à un élément se fait avec la syntaxe tab[i][j] avec i l'indice du tableau et j l'indice dans l'élément tableau, comme par exemple, l'élément tab[1][0] qui contient l'entier 16 bits 0x12 (format little endian).

Le processeur calcule l'adresse mémoire de l'entier en prenant en compte les indices ainsi que la taille en octets du type de l'élément. Dans le cas présent, on 1×2×2+0=4 avec i=1 , j=0 avec deux octets par entier et deux éléments pour chaque élément tableau.

Tableaux et pointeurs

Il est très facile d'utiliser les notations pointeurs avec des tableaux et des notations tableaux avec des pointeurs car un tableau est une adresse mémoire au même titre qu'un pointeur. La différence réside dans le fait que l'adresse du pointeur est modifiable, contrairement à l'adresse du tableau qui est fixe, mais également dans la place mémoire occupée, un tableau réserve une taille mémoire qui est le produit des deux dimensions par la taille de l'élément, un pointeur ne réserve que la taille de l'adresse mémoire. C'est pour cela qu'il faut bien utiliser ces deux types, car même si le programme compile, cela ne signifie pas qu'il va toujours fonctionner correctement.

Le tableau à deux dimensions

tab_int[0] , tab_int[1] et tab_int[2] représentent les adresses mémoires des tableaux qui représentent les lignes. Ce qui fait que tab_int et tab_int[0] représentent la même adresse mémoire.

Ce tableau est facilement utilisable avec les notations pointeurs, en prenant l'exemple :

int tab_int[3][4] = { {1,2,3,4} , {5,6,7,8}  , {9,10,11,12} };
				

L'occupation mémoire de ce tableau, donné par la fonction sizeof(tab_int) est de 48 octets, 12 valeurs de 4 octets (32 bits).

on peut remarquer que les adresses tab_int , *tab_int et tab_int[0] représentent la même adresse. tab_int[1] représente l'adresse de la deuxième ligne, tab_int[2] représente l'adresse de la troisième ligne,

Les notations suivantes permettent d'accéder à tab_int[1][2]

  • *(*tab_int+4*1+2) : on se déplace d'une ligne de 4 entiers + deux colonnes dans la deuxième ligne
  • *(tab_int[1] + 2) : on se déplace de deux colonnes sur la deuxième ligne, puis on accède au contenu
  • (*tab_int+4)[2] : on prend un pointeur vers le tableau de la deuxième ligne, puis l'élément deux de ce tableau
  • *(*(tab_int+1)+2) : on prend l'adresse de la deuxième ligne à laquelle on ajoute deux colonnes, pour ensuite accéder au contenu
  • (*(tab_int+1))[2] : on prend un pointeur vers un tableau situé à l'adresse de la deuxième ligne, pour, ensuite , accéder au deuxième élément

Le tableau de pointeurs

Ce tableau est facilement utilisable avec les notations pointeurs, en prenant l'exemple :

int tab_int[3][4] = { {1,2,3,4} , {5,6,7,8}  , {9,10,11,12} };
int *ptr_int[3];
int **ptr2_int = (int**)ptr_int;
ptr_int[0] = (int *)tab_int ;
ptr_int[1] = (int *)(tab_int+1) ;
ptr_int[2] = (int *)(tab_int+2) ;
				

L'occupation mémoire de tab_int, donné par la fonction sizeof(tab_int) est de 48 octets, 12 valeurs de 4 octets.

L'occupation mémoire de ptr_int, donné par la fonction sizeof(ptr_int) est de 24 octets, 3 adresses de 8 octets.

L'occupation mémoire de ptr2_int, donné par la fonction sizeof(ptr2_int) est de 8 octets, 1 adresse de 8 octets.

*ptr_int[3] est un tableau de pointeurs, où chaque élément contient l'adresse d'une ligne du tableau. L'opérateur cast permet de passer du type pointeur depuis un tableau de 4 valeurs vers un pointeur. **ptr2_int est un pointeur vers un pointeur, qui est compatible avec un tableau de pointeurs.

Les notations suivantes permettent d'accéder à tab_int[1][2]

  • ptr_int[1][2] : avec un tableau de pointeurs vers des tableaux, on peut utiliser la notation tableau.
  • *(ptr_int[1]+2) : on prend l'adresse de la deuxième ligne, à laquelle on ajoute 2 colonnes pour accéder au contenu
  • *(*(ptr_int+1)+2) : on calcule l'adresse de la deuxième ligne, puis on ajoute 2 à cette adresse, pour accéder au contenu.
  • ptr2_int[1][2] : avec un pointeur de pointeur, on peut également utiliser la notation tableau, car il est compatible avec le tableau de pointeurs

Le pointeur vers un tableau

int tab_int[3][4] = { {1,2,3,4} , {5,6,7,8}  , {9,10,11,12} };
int (*ptr_tab)[4];
ptr_tab = tab + 1;
				

L'occupation mémoire de tab_int, donné par la fonction sizeof(tab_int) est de 48 octets, 12 valeurs de 4 octets.

L'occupation mémoire de ptr_tab, donné par la fonction sizeof(ptr_tab) est de 8 octets, 1 adresse de 8 octets.

(*ptr_tab)[4] est un pointeur vers un tableau de 4 entiers. L'adresse de ce pointeur est affectée avec la deuxième ligne du tableau.

Les notations suivantes permettent d'accéder à tab_int[1][2]

  • (*ptr_tab)[2] : Ce tableau est à la même adresse que la deuxième ligne du tableau, on accède donc à la deuxième colonne
  • *((*ptr_tab)+2) : On accède au contenu de l'adresse mémoire de la deuxième ligne + deux colonnes.

On peut remarquer qu'il est très facile d'écrire du code en mélangeant notations tableau et pointeur. Mais, même après avoir corrigé tous les messages d'alertes (warning obtenus avec l'option -Wall qui prend ici tout son sens) du compilateur, il est possible que le programme ne fonctionne pas correctement ou encore qu'il provoque une erreur système d'accès à la mémoire. Il est donc conseillé d'utiliser les notations appropriées les plus simples.

Les chaînes de caractères

Une chaîne de caractères est un tableau à une dimensions d'entiers 8 bits (char) qui contiennent les codes ASCII des caractères. La dernière valeur est 0, C'est une convention utilisée en C pour indiquer la fin de la chaîne. Cette convention est utilisée par les fonctions de traitement de chaînes. On déclare une chaîne de caractères comme un tableau, en ajoutant 1 à la taille qui contiendra le 0 de fin de chaîne.

Le fichier de prototypes <string.h> contient les fonctions de traitement des chaînes de caractères. Lors de la déclaration des chaînes de caractères, il faut prévoir la taille maximale du résultat du traitement. Toutes ces fonctions utilisent des notations pointeurs, cela ne signifie pas qu'il faut transmettre des variables de type pointeur, mais les adresses des chaînes de caractères (adresse du tableau).

Quelques fonctions :

Ces fonctions ne traitent que les chaînes de caractères encodés en ASCII, et ne fonctionne pas avec les autres encodages comme UTF-8.

Les structures

Une structure est un ensemble de données de types différents qui sont accessibles individuellement par l'intermédiaire d'une variable appelée champ.

Pour déclarer une structure, il est conseillé de déclarer un nouveau type avec typedef et le mot clé struct.

typedef	struct s_structure {
	type1 nom1;
	type1 nom2;
	// ...
} Tstructure;

Tstructure nomvariablestructure;
		

Tstructure est le nouveau type qui permet de déclarer des variables de ce type.

L'accès à un élément de la structure s'effectue en utilisant le nom de la variable de la structure suivie d'un point et du nom du champ :

nomvariable.nom1
		
Voir un exemple de représentation mémoire d'une structure
typedef struct s_nombre {
	char octets[4];
	float reel;
} T_nombre;

T_nombre nombre;	

nombre.reel = −11.25; 
nombre.octet[0]=0x10; 
nombre.octet[1]=0x20;
nombre.octet[2]=0x30; 
nombre.octet[3]=0x40;
					

L'occupation mémoire de T_nombre, donné par la fonction sizeof(T_nombre) est de 8 octets, 4 octets + un réel simple précision de 4 octets.

Pour la valeur des octets du réel, il faut se référer au chapitre sur la calculatrice.

Gestion des alignements des données en mémoire

En général les données sont alignées sur des adresses mémoires paires, ce qui peut laisser des adresses mémoires inutilisées. Ce n'est jamais un problème sauf exception qui peut être due à la ressource mémoire ou plus vraisemblablement à la compatibilité avec d'autres programmes lors d'échange de données.

Les alignements peuvent être gérés avec les options de compilation de gcc

mais également dans le code source avec l'attribut __attribute__((packed)).

Voir un exemple de représentation mémoire d'une structure avec et sans alignement
typedef struct s_nombre {
	char octets[3];
	float reel;
} T_nombre;

T_nombre nombre;				
					

L'occupation mémoire de T_nombre, donné par la fonction sizeof(T_nombre) est de 8 octets, 3 octets + une adresse non utilisée + un réel simple précision de 4 octets. Ceci est du à l'alignement sur une adresse paire.

Le champ reel est aligné sur une adresse paire, ce qui laisse une adresse mémoire non utilisée.

typedef struct s_nombre {
	char octets[3];
	float reel;
} __attribute__((packed)) Tnombre;

T_nombre nombre;				
					

L'occupation mémoire de T_nombre, donné par la fonction sizeof(T_nombre) est de 7 octets, 3 octets + un réel simple précision de 4 octets. Ceci est du à l'alignement sur une adresse paire.

L'option annule l'alignement sur une adresse paire, il n'y a plus d'adresse mémoire inutilisée.

Les unions

Une union est une structure de données qui permet d'accéder à une variable avec des types différents, dans une union tous les champs sont à la même adresse mémoire.

Pour déclarer une union, il est conseillé de déclarer un nouveau type avec typedef et le mot clé union.

typedef	union u_union {
	type1 nom1;
	type1 nom2;
	// ...
} Tunion;

Tunion nomvariableunion;		
		

Tunion est le nouveau type qui permet de déclarer des variables de ce type

Voir un exemple de représentation mémoire d'une union
typedef union s_nombre {
	char octet[4];
	float reel;
} T_nombre;
		
nombre.reel = −11.25; 
					

L'occupation mémoire de T_nombre, donné par la fonction sizeof(T_nombre) est de 4 octets, 4 octets ou bien un réel simple précision de 4 octets.

Les fonctions

Un programme C ne se limite jamais à la seule fonction main. Un programme se décompose en un ensemble de fonctions qui traitent chacune une partie des données.

Chaque fonction échange des données avec les autres fonctions. Le sens des échanges définit le type de la fonction.

  • Fonction sans paramètres : pas d'échange avec la fonction qui l'a appelée.
  • Fonction avec paramètres en entrée seule ou paramètres par valeur : la fonction utilise les données transmises par la fonction qui l'a appelée. C'est la cas des fonctions fct2 et fct4
  • Fonction avec paramètres en entrée ou en sortie ou paramètres par adresse : la fonction utilise et modifie les données transmises par la fonction qui l'a appelée. C'est le cas des fonctions fct3 et fct5. L'utilisation de paramètres par adresse est également utilisée pour les paramètres en sortie seule, comme pour la fonction fct1.
  • Dans le cas où il u a un seul paramètre de sortie, on peut utiliser la valeur de retour de la fonction.

Les fonctions sans paramètres

Ces fonctions sont utilisées pour l'affichage de message. Il est possible d'utiliser des variables globales, mais cela doit être exceptionnel.

La déclaration du prototype se fait en général dans un fichier de prototype .h

void nom_fonction();
		

Le codage de la fonction se fait dans un fichier c (.c) qui inclut le fichier .h correspondant.

void nom_fonction() {
	// declaration variables locales
	// code de la fonction
}
		

Lorsque la fonction est codée et utilisée dans le même fichier source, la déclaration de prototype est inutile, si le code de cette fonction se situe avant le code qu'il l'utilise. La déclaration de prototype est nécessaire sir la fonction est codée après son utilisation.

Les fonctions avec paramètres par valeur

Ce type de fonction utilise les paramètres transmis, peut éventuellement les modifier, mais cette modification ne sera pas transmise à la fonction qui l'a appelée.

La déclaration du prototype se fait sous la forme

void nom_fonction(liste type parametres);
		

Le codage de la fonction se fait dans un fichier c (.c)

void nom_fonction(liste parametres) {
	// declaration variables locales
	// code de la fonction
}
		

La liste des paramètres est une liste de types suivi du nom du paramètre et séparés par une virgule

L'appel de cette fonction se fait avec les noms des paramètres

Les fonctions avec paramètres par adresse

Ce type de fonction peut utiliser des paramètres et les modifiés, la modification sera transmise à la fonction qui l'a appelée. C'est pour cela que ce n'est pas la valeur du paramètre qui est transmise mais son adresse mémoire, d'où le nom de paramètre par adresse.

La déclaration du prototype se fait sous la forme

void nom_fonction(liste type pointeur);
		

Les paramètres sont cette-fois-ci des pointeurs, qui correspondent au contenu des adresses transmises.

Le codage de la fonction se fait dans un fichier c (.c)

void nom_fonction(liste pointeurs) {
	// declaration variables locales
	// code de la fonction
}
		

La liste des pointeurs est une liste de types pointeurs suivis du nom et séparés par une virgule.

L'appel de cette fonction se fait avec les adresses des paramètres : nom préfixé avec le symbole &.

Voir un exemple de transmission par adresse, l'échange de variables
#include <stdlib.h>
#include <stdio.h>

void echange(int *a,int *b) {
	int tmp ;
	tmp = *a;
	*a = *b;
	*b = tmp;
}

int main(int argc, char **argv) {
	int x=2,y=3;
	printf("%d %d\n",x,y);
	echange(&x,&y);
	printf("%d %d\n",x,y);
	return EXIT_SUCCESS;
}
					

La fonction echange permet d'échanger les contenus des paramètres a et b. La méthode est simple, on sauvegarde la première valeur, transfert la deuxième dans la première, et enfin affecte le contenu de la valeur sauvegardée dans la deuxième.

La fonction principale main qui est une fonction de test, échange les contenus de x et y en utilisant la fonction echange..

La fonction utilise des pointeurs qui permettent de traiter le contenu des adresses mémoires des variables x et y.

Lors de l'appel de la fonction, on transmet les adresses des variables x et y en les préfixant du symbole &.

Le résultat de l'affichage est :

2 3
3 2
					

La valeur de retour d'une fonction

La type de la valeur de la fonction est précisé en remplaçant void par le type de cette valeur de retour.

La déclaration du prototype se fait sous la forme

type nom_fonction(liste type parametres);
		

Le codage de la fonction se fait dans un fichier c (.c)

type nom_fonction(liste parametres) {
	type valeur_retour;
	// declaration variables locales
	// code de la fonction
	return valeur_retour;
}
		

La valeur de retour doit être déclarée dans le code de la fonction. La fonction se termine avec le mot clé return suivi du nom de la valeur de retour.

La valeur de retour ne doit jamais retourner une adresse de variable locale, car les variables locales sont stockées dans la pile, ne sont plus accessibles en dehors de la fonction.

Voir un exemple d'utilisation de la valeur de retour avec la fonction factorielle

Méthode itérative, code source C

#include <stdlib.h>
#include <stdio.h>

unsigned int factorielle(unsigned int n) {
	unsigned int fact=1;
	for(unsigned int i=1;i<=n;i+=1) {
		fact *= i ;
	}
	return fact;
}

int main(int argc, char **argv) {
	unsigned int n=0;
	unsigned int fn;
	if (argc != 2) {
		fprintf(stderr,"Syntaxe %s n\n",argv[0]);
		return EXIT_FAILURE;
	}	
	n=strtol(argv[1],NULL,10);
	fn=factorielle(n);
	printf("%u!= %u \n",n,fn);
	return EXIT_SUCCESS;
}
					

On utilise la définition de la factorielle, soit n!=n×(n-1)×(n-2)×1 au travers d'une boucle qui effectue le produit cumulé.

Les types des entiers est non signé, car la factorielle est définie pour n positif ou nul.

Il faut faire attention au dépassement dans le calcul avec, par exemple, le calcul de 13!=6 227 020 800

./factorielle 13
13!= 1932053504
					

Le résultat est faux : 12!=479 001 600 qui reste inférieur à 232-1=4 294 967 295 alors que 13!=6 227 020 800 est supérieur à 232-1. On a donc, lors de la dernière multiplication un dépassement qui entraîne une erreur de calcul.

La solution est d'utiliser un entier 64 bits, mais on aura le même problème avec la limite à 264-1. Quant à l'utilisation de réel, on aura un problème de précision sur les grands nombres.

Pour un entier codé sur n bits, le calcul de la factorielle donne la valeur modulo 2n, dans le cas présent, on a bien 1932053504=13! modulo 232

La valeur de n est transmis en ligne de commande sous la forme d'une chaîne de caractères, la fonction strtol(char *source,char **fin,int base) convertit la chaîne en entier long en utilisant la base 10. Le paramètre **fin correspond à l'adresse d'un pointeur qui contient la chaîne qui n'a pas pu être convertie en cas d'erreur. La valeur NULL indique de ne pas utiliser ce paramètre.

fonction récursive, code source C

unsigned int factorielle(unsigned int n) {
	unsigned int fact=1;
	if (n == 0) {
		return 1 ;
	}
	fact = n*factorielle(n-1);
	return fact;
}
					

Une fonction récursive, est une fonction qui s'appelle elle-même avec d'autres valeurs de paramètres. Une fonction récursive doit inclure une condition d'arrêt qui permet d'arrêter l'appel récursif.

Dans le cas présent on utilise la propriété de la factorielle, soit n!=n×(n-1)! avec comme condition d'arrêt 0!=1.

La fonction retourne donc 1 si n=0, sinon elle calcule n×(n-1)!

On a le même problème de dépassement dans les calculs que ceux déjà cités avec la solution itérative.

Voir un exemple d'utilisation de la valeur de retour avec la fonction puissance entière

Méthode classique

#include <stdlib.h>
#include <stdio.h>

double puissance(double x,unsigned int n) {
	double z=1;
	for(unsigned int i=1;i<=n;i+=1) {
		z *= x ;
	}
	return z;
}

int main(int argc, char **argv) {
	double x=0;
	unsigned int p=1;
	double puiss;
	if (argc != 3) {
		fprintf(stderr,"Syntaxe %s n p\n",argv[0]);
		return EXIT_FAILURE;
	}	
	x=strtod(argv[1],NULL);
	p=strtol(argv[2],NULL,10);
	puiss=puissance(x,p);
	printf("%lf^%u= %lf \n",x,p,puiss);
	return EXIT_SUCCESS;
}
					

La méthode dite classique utilise la définition de la puissance entière qui est an=a×a×a×...×a en réalisant n produit de a.

On a donc une boucle qui répète n fois le produit du dernier résultat obtenu par a, après avoir initialisé le résultat à 1.

On n'a pas le problème de dépassement de la factorielle car la valeur a est réelle. on a toujours une puissance maximale liée au nombre de bits de la puissance n. Par contre on est lié à la précision relative du réel double précision dont la mantisse est exprimée sur 52 bits. On aura cette fois-ci un résultat tronqué, lorsque celui-ci ne pourra plus être représenté sur 52 bits. On aura donc une erreur de précision pour tout résultat supérieur à 252.

On va également d'intéresser au temps de calcul de la fonction puissance, le nombre d'itération de la boucle est lié à la puissance n, on a donc un temps de calcul proportionnel à n.

Algorithme itératif rapide

double puissance(double x,unsigned int n) {
	double z=1;
	while (n != 0) {
		if (n & 1) {
			z *= x ;
		}
		n>>= 1;
		x *= x;
	}
	return z ;
}
					

Cet algorithme itératif rapide est basé sur la représentation binaire de l'exposant et inspiré de l'exponentiation rapide et de l'exponentiation modulaire. On parle également d'arithmétique distribuée.

On exprime n en fonction de la somme des valeurs des bits et des puissances de 2 :

x n = x k = 0 p a k 2 k = k = 0 p x a k 2 k = x a p 2 p k = 0 p - 1 x a k 2 k

On a transformé la somme en produit qui donne l'équation de récurrence pour k>0:

z k = ( x a k 2 k ) z k - 1 = ( ( x 2 k ) a k ) z k - 1 = ( ( x 2 k - 1 .2 ) a k ) z k - 1 = ( ( ( x 2 k - 1 ) 2 ) a k ) z k - 1

Cette équation de récurrence est l'algorithme de la fonction C du calcul de la puissance.

Cette-fois-ci la boucle dépend de la valeur du bit de poids le plus élevé à 1, ce qui fait que le temps de calcul est proportionnel à log2(n)

On a une fonction puissance entière optimisée en temps de calcul avec les mêmes contraintes de précision que la méthode classique.

L'allocation dynamique

Allocation de la mémoire

void *calloc(size_t nmemb, size_t size);
void *malloc(size_t size);
			

Il est possible de réallouer la mémoire pour augmenter la taille de la mémoire disponible

void *realloc(void *ptr, size_t size);

libération de la mémoire

free(void *ptr);

Application aux tableaux dynamiques

Tableau dynamique à une dimension

Exemple de tableau dynamique de nombres réels.

Les fonctions de gestions du tableau sont codées dans tab1ddyn.c et définies dans tab1ddyn.h

Fichier de prototypes tab1ddyn.h

#ifndef __TAB1DDYN_H
#define __TAB1DDYN_H

double *creertab(int);
void liberertab(double *);

#endif
				

Fichier des codes des fonctions tab1ddyn.c

#include <stdlib.h>
#include "tab1ddyn.h"

double *creertab(int taille) {
	return calloc(taille,sizeof(double));
}

void liberertab(double *ptab) {
	free((void *)ptab);
}
				

La fonction calloc est utilisée pour créer réserver une zone mémoire de réels, dont le nombre est défini par le paramètre taille.

La fonction free est utilisée pour libérer la mémoire. Il ne faut jamais oublier de libérer la mémoire.

La variable tableau est un pointeur vers un réel. Cette variable peut être utilisée comme un tableau à une dimension.

Ces fonctions sont testées avec le programme suivant qui affecte des valeurs puis affiche le contenu du tableau avant de libérer la mémoire allouée en fin de programme.

Dans un programme le nombre de libération (free) doit être égal au nombre d'allocation (malloc,calloc).

#include <stdio.h>
#include <stdlib.h>
#include "tab1ddyn.h"

int main(int argc, char **argv) {
	double *ptr_tab1d; 
	ptr_tab1d = creertab(4);
	for(int i=0;i<4;i+=1) {
		ptr_tab1d[i] = i;
	}
	for(int i=0;i<4;i+=1) {
		printf("tab[%d] = %f\n",i,ptr_tab1d[i]);
	}
	liberertab(ptr_tab1d);
	return EXIT_SUCCESS;
}
				

Tableau dynamique à deux dimensions

C'est un tableau de pointeurs qui utilise un pointeur de pointeur qui est donc accessible avec deux indices comme un tableau à deux dimensions.

Dans le cas d'un tableau à deux dimensions de réels, la variable tableau est déclarée

double **ptr_tab;					
				

L'accès à un élément du tableau d'indices i et j se fait avec la syntaxe

ptr_tab[i][j]
Voir un exemple de gestion d'un tableau dynamique à deux dimensions de réels

Fichier tab2ddyn.h

#ifndef __TAB1DDYN_H
#define __TAB1DDYN_H

void **creertab(int ,int ,int);
void liberertab(void **,int);

#endif
					

Fichier tab2ddyn.c

#include <stdlib.h>
#include "tab2ddyn.h"

void **creertab(int nligne,int ncolonne,int tailleelement) {
	char **ptabdyn;
	ptabdyn = (char **)malloc(nligne);
	for(int i=0;i<nligne;i+=1) {
		char *pligne = (char *)calloc(ncolonne,tailleelement);
		ptabdyn[i] = pligne; 
	}
	return (void**)ptabdyn;
}

void liberertab(void **ptabdyn,int nligne) {
	for(int i=0;i<nligne;i+=1) {
		free(ptabdyn[i]) ;
	}	
	free(ptabdyn);
}
					

Pour la création, on commence par réserver la mémoire pour le tableau de pointeurs de taille nligne, puis pour chaque ligne on réserve la mémoire de taille ncolonne.

pour la libération, il faut d'abord libérer la mémoire allouée pour chaque ligne, puis libérer la mémoire pour le tableau de pointeur. Il faut bien évidement transmettre le nombre de lignes pour la libération.

Test des fonctions de créations et libérations

#include <stdio.h>
#include <stdlib.h>
#include "tab2ddyn.h"

int main(int argc, char **argv) {
	double **ptabdyn; 
	int nligne = 3;
	int ncolonne = 4;
	ptabdyn=(double **)creertab(nligne,ncolonne,sizeof(double));
	for(int i=0;i<ligne;i+=1) {
		for(int j=0;j<ncolonne;j+=1) {
			ptabdyn[i][j] = i+j+1;
		}
	}
	for(int i=0;i<nligne;i+=1) {
		for(int j=0;j<ncolonne;j+=1) {
			printf("p[%d][%d] = %f\n",i,j,ptabdyn[i][j]);
		}
	}
	liberertab((void **)ptabdyn,nligne);
	return EXIT_SUCCESS;
}
					

Après avoir utiliser le tableau dynamique, il faut pas oublier de libérer la mémoire soit à la fin de l'utilisation, soit en fin de programme.

La gestion des fichiers

Il existe deux catégories de fonctions pour accéder aux fichiers, les fonctions dites de haut niveaux qui sont fopen, fclose, fprintf, fscanf, fwrite, fread, feof et les fonctions dites de bas niveau qui sont open, close, read, write.

En dehors des descripteurs déjà ouverts, la méthode est toujours la méme :

  1. Ouvrir le fichier en précisant le mode d'accès : lecture seule, écriture seule, lecture/écriture. L'ouverture permet de créer un lien entre le fichier et le fichier logique
  2. Echanger les données entre le programme et le fichier ouvert : lecture, écriture
  3. Fermer le fichier : supprimer le lien entre le fichier et le fichier logique, cette opération vide les mémoires tampons (buffer). Cette étape ne doit jamais être oubliée pour ne pas perdre de données.

Les fonctions de haut niveau

Elles utilisent une structure de données, nommée FILE qui contient les informations pour la gestion des échanges de données avec le fichier, qui est également appelé descripteur. Il existe des descripteurs ouverts :

Ouverture (fopen)

fd=fopen(nomfichbier,mode);

Fermeture (fclose)

flose(fd);

fd : pointeur retourné par la fonction fopen

Lecture (fread) et écriture (fwrite)

Les données sont lues ou écrites sans aucune conversion, identiques au contenu du buffer.

n=fread(buffer,nboctets,nbpaquets,fd);
  • buffer : tableau d'octets qui contient les données lues
  • nboctets : nombre d'octets par paquet à lire
  • nbpaquets : nombre de paquets d'octets à lire
  • fd : pointeur retourné par la fonction fopen
  • n : valeur de retour qui contient le nombre de paquets effectivement lus.
n=fwrite(buffer,nboctets,nbpaquets,fd);
  • buffer : tableau d'octets qui contient les données à écrire
  • nboctets : nombre d'octets par paquet à écrire
  • nbpaquets : nombre de paquets d'octets à écrire
  • fd : pointeur retourné par la fonction fopen
  • n : valeur de retour qui contient le nombre de paquets effectivement écrits.

Lecture (fscanf) et écriture (fprintf) avec conversion

Les données lues ou écrites sous la forme de chaînes de caractères dans le fichier.

fscanf(fd,chaineformat,liste adresses variables);
  • fd : pointeur retourné par la fonction fopen
  • chaineformat : chaîne qui précise le format de la donnée, identique à scanf
  • liste adresses variables : liste des adresses des variables
fprintf(fd,chaineformat,liste variables);
  • fd : pointeur retourné par la fonction fopen
  • chaineformat : chaîne qui précise le format de la donnée, identique à printf
  • liste variables : liste des variables

Indication de fin de fichier (feof)

fin=feof(fd);

Les fonctions de gestion de texte

On trouve les fonctions fgetc, fgets pour la lecture de caractère ou de chaîne et fputc, fputs pour l'écriture de caractère ou de chaîne.

caractere=fgetc(fd);
  • fd : pointeur retourné par la fonction fopen
  • n : valeur de retour qui contient le caractère lu, une valeur négative en cas d'erreur
n=fputc(caractere,fd);
  • fd : pointeur retourné par la fonction fopen
  • caractere : caractère à écrire
  • n : valeur de retour qui contient le caractère écrit, une valeur négative en cas d'erreur
p=fgets(buffer,nboctets,fd);
  • fd : pointeur retourné par la fonction fopen
  • buffer : tableau d'octets qui contient les données lues
  • nboctets : nombre d'octets par paquet à lire
  • p : pointeur qui contient l'adresse de buffer, ou pointeur NULL en cas d'erreur
n=fputs(chaine,fd);
  • fd : pointeur retourné par la fonction fopen
  • chaine : chaîne de caractères à écrire
  • n : valeur de retour qui est positive, une valeur négative en cas d'erreur
Voir un exemple d'affichage sur la sortie standard d'un fichier texte
#include <stdlib.h>
#include <stdio.h>

int main(int argc, char **argv) {
	
	FILE* fd;
	char ligne[81] ;
	if (argc !=2) {
		fprintf(stderr,"syntaxe %s fichier\n",argv[0]);
		return EXIT_FAILURE;
	}
	fd = fopen(argv[1],"r");
	while (!feof(fd)) {
		fgets(ligne,80,fd);
		printf("%s",ligne);
	}
	fclose(fd);
	
	return EXIT_SUCCESS;
}
					

Le nom du fichier texte est transmis en ligne de commande.

Pour une chaîne de 80 caractères maximum, on réserve 81 caractères, il ne faut pas oublier le 0 de fin de chaîne.

On ouvre le fichier en lecture seule

Ensuite on lit le fichier tant que la fin de fichier n'est pas atteinte.

Pour lire un fichier texte, on utilise la fonction fgets qui permet de lire le contenu du fichier texte ligne par ligne avec un maximum de 80 caractères. Le caractère de fin de ligne est également lu, sauf si on a une ligne de plus de 80 caractères.

Chaque ligne est affichée sur la sortie standard. Il ne faut pas oublier la fermeture du fichier.

Remarque : on a choisi fgets à la place de fscanf, Le lecteur pourra essayer ce programme en remplaçant la ligne qui contient fgets, par

fscanf(fd,"%s",ligne);
Voir un exemple d'affichage sur la sortie standard d'un fichier de réels
#include <stdlib.h>
#include <stdio.h>

int main(int argc, char **argv) {
	
	FILE* fd;
	double reel;
	int n=0;
	if (argc !=2) {
		fprintf(stderr,"syntaxe %s fichier\n",argv[0]);
		return EXIT_FAILURE;
	}
	fd = fopen(argv[1],"r");
	while (!feof(fd)) {
		n += fread(&reel,sizeof(double),1,fd);
		printf("%lf\n",reel);
	}
	printf("lu %d réels\n",n);
	fclose(fd);
	
	return EXIT_SUCCESS;
}
					

Ici on utilise la lecture de données de type octets, on fait une lecture réel par réel, en demandant un paquet de la taille d'un réel. La fonction fread retourne le nombre de paquets lu. Elle retourne 1 si le réel a été lu. Ce qui fait que n contient le nombre de réels lus.

Affichage du contenu du fichier des réels avec la commande hexdump -e '/8 "%f\n"'

0,100372
0,570332
0,086103
0,257617
0,950846
0,999225
0,995475
0,888895
0,036791
					

Le programme C affiche

0.100372
0.570332
0.086103
0.257617
0.950846
0.999225
0.995475
0.888895
0.036791
0.036791
lu 9 réels
					

Les fonctions de bas niveau

Elles utilisent un numéro de descripteur (handle) qui correspond à l'indice du périphérique dans la table des descripteurs. Les descripteurs ouverts, dont les constantes sont définies dans le fichier de prototype unistd.h, sont :

Pour utiliser ces fonctions, il faut inclure les fichiers fcntl.h et unistd.h.

Ouverture (open)

fd = open(nomfichier, mode,type);
fd = open(nomfichier, mode);

Fermeture (close)

close(fd)

fd : pointeur retourné par la fonction open

Lecture (read) et écriture (write)

n=read(fd,buffer,nb);
  • fd : entier retourné par la fonction open
  • buffer : tableau d'octets qui contient les données lues
  • nb : nombre d'octet à lire
  • n : valeur de retour qui contient le nombre de paquets effectivement lus, -1 en cas d'erreur
n=write(fd,buffer,nb);
  • fd : entier retourné par la fonction open
  • buffer : tableau d'octets qui contient les données à écrire
  • nb : nombre d'octets à écrire
  • n : valeur de retour qui contient le nombre de paquets effectivement écrits, -1 en cas d'erreur
Voir un exemple d'affichage sur la sortie standard d'un fichier texte
#include <stdlib.h>
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main(int argc, char **argv) {
	int fd;
	char buffer[80] ;
	if (argc !=2) {
		fprintf(stderr,"syntaxe %s fichier\n",argv[0]);
		return EXIT_FAILURE;
	}
	fd = open(argv[1],O_RDONLY);
	int n=read(fd,buffer,80);
	while (n > 0) {
		write(STDOUT_FILENO,buffer,n);
		n=read(fd,buffer,80);
	}
	close(fd);
	return EXIT_SUCCESS;
}
					

On utilise open sans le paramètre de type de fichier, car on est en mode lecture.

Dans ce cas, il n'y a pas de lecture ligne par ligne, on ne traite pas le fichier en fichier texte, mais en fichier d'octets. C'est pourquoi, on n'utilise pas, non plus, l'écriture formatée d'une chaîne de caractères sur la sortie standard, car le buffer ne contient pas de caractère 0 de fin de chaîne. On utilise la fonction d'écriture dans un fichier en utilisant le numéro de la sortie standard représenté par la constante STDOUT_FILENO.

Il n'y a pas de fonction de fin de fichier, on utilise donc la valeur de retour de la fonction read, qui est négative lorsque l'on essaie de lire des octets, alors qu'il n'y en a plus de disponible, c'est le cas en fin de fichier.

Voir un exemple d'affichage sur la sortie standard d'un fichier de réels
#include <stdlib.h>
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main(int argc, char **argv) {
	int fd;
	double reel;
	int nr=0;
	if (argc !=2) {
		fprintf(stderr,"syntaxe %s fichier\n",argv[0]);
		return EXIT_FAILURE;
	}
	fd = open(argv[1],O_RDONLY);
	int n=read(fd,&reel,sizeof(double));
	while (n > 0) {
		nr += 1;
		if (n == sizeof(double)) {
			printf("%lf\n",reel);
		}
		n=read(fd,&reel,sizeof(double));
	}
	printf("lu %d octets\n",nr);
	close(fd);
	return EXIT_SUCCESS;
}
					

Le buffer de lecture est un nombre réel, le fichier contient un nombre d'octets multiple de 8, qui est la taille en octets du réel double précision.

On transmets donc l'adresse du réel à la fonction read suivi de la taille en octets du réel en utilisant la fonction sizeof(reel). Si le fichier est un fichier de réels correct la valeur de retour n est toujours égale à la taille d'un réel en octet.

Au cas ou il y aurait une erreur de taille du fichier, on écrit le réel uniquement si le nombre d'octets lu correspond à la taille du réel.

Les processus et threads

Processus parent et processus enfant

Un programme qui est un processus peut créer un ou plusieurs processus nommés processus enfants. Lors de cette création l'environnement du processus parent est transmis au processus enfant. Le processus enfant a son propre contexte d'exécution.

La création d'un processus enfant se fait avec la fonction définie dans le fichier unistd.h

pid=fork()

Cette fonction retourne le pid du processus enfant dans le processus parent et 0 dans le processus enfant, et -1 en cas de problème.

Après la création du processus enfant, on a deux processus qui fonctionnent en parallèle.

Le processus parent doit se terminer avant tous les processus enfants. Pour cela, on peut utiliser des fonctions qui attendent la fin des processus enfants dans le processus parent. Pour cela il faut utiliser la fonction définie dans le fichier /sys/wait.h

waitpid(pidenfant,statut,options);
Voir un exemple de création de processus enfant avec fork
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main(int argc, char **argv) {
	int status;
	pid_t pid = fork();
	if (pid > 0) {
		printf("ici le parent : pid enfant = %d\n", pid);
		sleep(2);
		printf("Le parent attend son enfant\n");
		waitpid(pid, &status, 0);
		printf("Fin du processus parent\n");
	}
	else if (pid == 0) {
		printf("Je suis l'enfant\n");
		sleep(5);
		printf("Fin du processus enfant\n");
	}
	else {
		fprintf(stderr,"probleme\n");
	}	
	return EXIT_SUCCESS;
}
					

Ce programme crée un processus enfant, le processus parent attend la fin du processus enfant avant de se terminer.

Pour faciliter le rythme de l'affichage, on utilise la fonction

sleep(t)

t : temps en secondes

Cette fonction endort le processus en cours pendant t secondes.

La vidéo ci-dessous montre le fonctionnement du programme processus en affichant l'évolution des processus parent et enfant ainsi que leurs pid.

La commande ps, associée à la commande watch permet d'afficher les processus courants.

Les threads

Principe de fonctionnement

Un thread est un souvent appelé un processus léger car il partage le contexte d'exécution, ce qui fait que la commutation de threads est plus rapide. De plus il n'y a pas de hiérarchie parent/enfant avec les threads.

Les fonctions disponibles pour la gestion des threads sont définies dans le fichier de prototype pthread.h, et l'édition de lien se fait en ajoutant la librairie avec l'option -lpthread. Chaque thread est exécuté par une fonction associée.

La création et le démarrage du thread se fait avec la fonction

erreur=pthread_create(adresse_idthread,adresse_attributs,adresse fonction,adresse argument fonction);

Le programme principal doit attendre que tous les threads soient terminés avant de se terminer, cela se fait avec la fonction

pthread_join(idthread, adressevaleurretour);

avec adressevaleurretour qui est l'adresse de la variable qui contient la valeur de retour de la fonction transmise par pthread_exit.

Voir un exemple de thread
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>

void *fonction(void *ptr_data) {
	static int r;
	pthread_t threadid = pthread_self();
	for(int i=0;i<5;i+=1) {	
		printf("fonction %lx : %d, %d\n",threadid,i,*(int*)ptr_data);
		usleep(1);
	}
	r=*(int*)ptr_data;
	pthread_exit(&r);
}

int main(int nbarg,char *args[]) {
	pthread_t  threadmain, thread;
	int data,*retour_thread,err;
	threadmain = pthread_self();
	err=pthread_create(&thread,NULL,fonction,(void*)&data);
	if (err) {
		fprintf(stderr,"Erreur création thread\n");
		return EXIT_FAILURE;
	}
	for(int i=0;i<5;i+=1) {
		data=rand()*100.0/RAND_MAX;
		printf("fonction main %lx : %d, %d\n",threadmain,i,data); 
		usleep(1); 
	}
	pthread_join(thread,(void*)&retour_thread);
	printf("retour fonction %d\n",*retour_thread);
	return EXIT_SUCCESS;
}
					

La fonction affiche l'entier qui est transmis en argument qui est du type void*. Il faut donc utiliser un transtypage pour utiliser ce paramètre comme entier.

La valeur de retour de la fonction pthread_exit est déclarée static afin qu'elle reste visible de la fonction main.

La fonction pthread_self() retourne l'identifiant du thread qui est fourni lors de la création du thread avec pthread_create.

La fonction usleep permet, comme sleep, d'endormir le thread pendant un temps donné en µs. Cette fonction affiche toutes les us la valeur de la donnée partagée avec le programme principal, 5 fois de suite avant de se terminer.

Dans la fonction principale, la fonction pthread_self() retourne l'identifiant du thread qui contient la fonction main. Cela montre que la fonction main est exécutée sur un thread.

La création se fait en associant la fonction, ainsi que l'entier data. Si la création du thread se fait sans erreur, la fonction principale génère 5 valeurs aléatoires tous les us.

Enfin on appelle la fonction de synchronisation du thread afin d'attendre la fin du thread avant de terminer le programme après avoir afficher la valeur de retour de la fonction.

Exemple d'exécution du programme

fonction main 7f331745d740 : 0, 84
fonction      7f331745c700 : 0, 84
fonction main 7f331745d740 : 1, 39
fonction main 7f331745d740 : 2, 78
fonction      7f331745c700 : 1, 78
fonction main 7f331745d740 : 3, 79
fonction      7f331745c700 : 2, 79
fonction main 7f331745d740 : 4, 91
fonction      7f331745c700 : 3, 91
fonction      7f331745c700 : 4, 91
retour fonction 91
					

Chaque exécution donne un affichage différent.

Exclusion mutuelle

Lors de l'exécution d'un programme avec thread, on peut observer, que l'ordre d'exécution des threads n'est pas toujours le même. On peut également observer lors de l'échange de données entre threads, il peut y avoir des pertes d'information car un thread peut être interrompu avant d'avoir terminé un traitement, ce qui produit des résultats erronés et inattendus.

Pour résoudre ce problème, on doit interdire que le traitement soit interrompu par un autre thread, on parle d'exclusion mutuelle en ajoutant un verrou qui fait qu'une portion de code ne peut pas être interrompue.

L'initialisation du verrou se fait avec la fonction

pthread_mutex_init(adresse_verrou,adresse_attribut_exclusion)

Le verrouillage du code se fait après la fonction

pthread_mutex_lock(adresse_verrou);

Le déverrouillage se fait après la fonction

pthread_mutex_unlock(adresse_verrou);
Voir un exemple de thread avec et sans exclusion mutuelle
#include <stdlib.h>
#include <stdio.h>
#include <pthread.h>

pthread_mutex_t verrou; 
//#define AVECVERROU

void *fonction(void *ptr_data) {
	static int r; 
	int i;
	int *ptr_compteur=(int*)ptr_data;
	pthread_t threadfonction = pthread_self();
	for(i=0;i<10000;i+=1) {
#ifdef AVECVERROU		
		pthread_mutex_lock(&verrou);
#endif
		*ptr_compteur += 1;
#ifdef AVECVERROU	
		pthread_mutex_unlock(&verrou);
#endif
	}
#ifdef AVECVERROU		
		pthread_mutex_lock(&verrou);
#endif
	printf("thread %lx fin : %d\n",threadfonction,*ptr_compteur);	
#ifdef AVECVERROU	
		pthread_mutex_unlock(&verrou);
#endif
	r=*ptr_compteur;
	pthread_exit(&r);
}


int main(int nbarg,char *args[]) {
	pthread_t  thread1,thread2;
	int *retour_thread,err;
	int compteur=0;
#ifdef AVECVERROU		
	pthread_mutex_init(&verrou, NULL);
#endif
	err=pthread_create(&thread1,NULL,fonction,(void*)&compteur);
	if (err) {
		fprintf(stderr,"Erreur creation thread1\n");
		return EXIT_FAILURE;
	}
	printf("Premier thread : %lx\n",thread1);
	err=pthread_create(&thread2,NULL,fonction,(void*)&compteur);
	if (err) {
		fprintf(stderr,"Erreur creation thread2\n");
		return EXIT_FAILURE;
	}
	printf("Deuxième thread : %lx\n",thread2);
	pthread_join(thread1,NULL);
	pthread_join(thread2,(void*)&retour_thread);
	printf("retour fonction  %d\n",*retour_thread);
	printf("compteur = %d\n",compteur);
#ifdef AVECVERROU	
	pthread_mutex_destroy(&verrou);
#endif
	return EXIT_SUCCESS;
}
					

Ce code utilise la compilation conditionnelle afin de pouvoir le tester avec et sans verrou. Si la ligne #define AVECVERROU est commentée le programme fonctionne sans exclusion mutuelle, si cette ligne n'est pas commentée, le programme fonctionne avec l'exclusion mutuelle.

La fonction incrémente un compteur qui correspond à la donnée partagée avec la fonction principale. Lorsque la valeur maximale est atteinte la fonction affiche la valeur du compteur sur la sortie standard.

La fonction principale crée deux thread qui implémente cette fonction. On obtient donc un double comptage du compteur, ce qui fait qu'à la fin du programme, le compteur doit avoir une valeur qui est le double de la valeur de fin de la boucle.

Exemple d'exécution sans exclusion mutuelle

Premier thread : 7fba5b812700
Deuxième thread : 7fba5b011700
thread 7fba5b812700 fin : 11112
thread 7fba5b011700 fin : 14770
retour fonction  14770
compteur = 14770

La valeur finale n'est pas la valeur attendue. Cela vient du fait que l'incrémentation du compteur est interrompue avant de se terminer.

Prenons un exemple de fonctionnement des deux threads, en sachant que la ligne compteur += 1 correspond à trois instructions processeurs qui sont :

registre <- compteur
registre <- registre + 1
compteur <- registre

avec compteur qui représente le stockage en mémoire de la valeur du compteur

On exécute ces instructions sur les deux threads en prenant, par exemple, compteur=100

Thread1Thread2
InstructionregistreInstructionregistre
registre <- compteur100
registre <- compteur100
registre <- registre + 1101
compteur <- registre101
registre <- registre + 1101
compteur <- registre101

A la fin de ceux instructions d'incrémentation du compteur, sa valeur est de 1 au lieu de 2.

Exemple d'exécution avec exclusion mutuelle

Premier thread : 7fa2c7bd2700
Deuxième thread : 7fa2c73d1700
thread 7fa2c73d1700 fin : 19154
thread 7fa2c7bd2700 fin : 20000
retour fonction  20000
compteur = 20000

La valeur finale est bien le double de la valeur de fin de boucle. L'exclusion mutuelle fait que l'incrémentation du compteur n'est pas interrompue, donc toutes les incrémentations sont exécutées correctement. La valeur affichée par le thread qui se termine en premier, dépend de la séquence d'exécution des thread, et est donc différente à chaque appel de ce programme.

Un exemple d'application : le calcul Matriciel

Le calcul matriciel est réduit aux opérations d'addition et de multiplication de matrices de taille quelconque. Un matrice est enregistrée dans un tableau dynamique à deux dimensions.

Ces fonctions sont codées dans le fichier matrice.c et déclarées ainsi que la structure de données dans le fichier matrice.h

La structure de données qui représente la matrice contient : la taille de la matrice ainsi qu'un pointeur vers le tableau à deux dimensions qui contient les valeurs de la matrice.

Fichier matrice.h

#ifndef __MATRICE_H
#define __MATRICE_H

typedef struct s_matrice {
	int m;
	int n;
	double **contenu;
} Tmatrice;

typedef Tmatrice *Tmatrice_ptr;

Tmatrice_ptr creermatrice(int ,int ,Tmatrice_ptr );
Tmatrice_ptr matalea(Tmatrice_ptr);  
Tmatrice_ptr matadd(Tmatrice_ptr,Tmatrice_ptr);
Tmatrice_ptr matmul(Tmatrice_ptr,Tmatrice_ptr);
void liberemat(Tmatrice_ptr);

#endif
				

On utilise typedef pour créer la structure de donnée Tmatrice ainsi que le pointeur vers cette structure Tmatrice_ptr.

On crée une fonction creermatrice pour la création du tableau dynamique sans initialiser le contenu, et bien sûr une fonction de libération de la mémoire occupée par une matrice : liberemat. Pour effectuer les tests, on va créer une fonction de remplissage d'une matrice avec des valeurs aléatoires, ainsi que les fonctions d'additions et de multiplication.

La fonction de création de la matrice utilise la fonction de création d'un tableau à deux dimensions, déjà étudié auparavant.

La fonction de remplissage de valeurs aléatoires utilise la fonction rand, avec une graine initialisée à partir du temps système time() de façon a ne pas voir toujours les mêmes valeurs aléatoires au démarrage du programme. Les valeurs aléatoires sont comprises entre -5 et +5, c'est un choix totalement arbitraire.

La fonction d'addition crée la matrice résultat, après avoir vérifier que les tailles des matrices sont identiques.

La fonction de multiplication vérifie la condition nécessaire pour effectuer le produit matriciel, qui doit respecter le fait que le nombre de colonnes de la première matrice doit être égal au nombre de lignes de la deuxième.

fichier matrice.c

#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <time.h>
#include "matrice.h"

double **creertab(int nligne,int ncolonne) {
	double **ptr_tab = (double **)calloc(nligne,sizeof(double *));
	for(int i=0;i&lyt;nligne;i+=1) {
		double *pligne = (double *)calloc(ncolonne,sizeof(double));
		ptr_tab[i] = pligne;
	}
	return ptr_tab;	
}

Tmatrice_ptr creermatrice(int m,int n,Tmatrice_ptr matrice) {
	matrice->m = m;
	matrice->n = n;
	matrice->contenu =creertab(matrice->m,matrice->n);
	return matrice;
}

Tmatrice_ptr matalea(Tmatrice_ptr matrice) {
	time_t temps = time(NULL);
	srand(temps);
	for(int i=0;i<matrice->m;i+=1) {
		for(int j=0;jn;j+=1) {
		  matrice->contenu[i][j] = ((double)rand()/RAND_MAX)*10.0-5.0;
		}
	}
	return matrice;
}

Tmatrice_ptr matadd(Tmatrice_ptr A,Tmatrice_ptr B) {
	if ((A->m != B->m) || (A->n != B->n)) {
		return NULL;
	}
	Tmatrice_ptr R = (Tmatrice_ptr)malloc(sizeof(Tmatrice));
	creermatrice(A->m,A->n,R);
	for(int i=0;i<A->m;i+=1) { 
		for(int j=0;j<A->n;j+=1) { 
			R->contenu[i][j] = A->contenu[i][j] + B->contenu[i][j] ;
		}	
	}
	return R;
}

Tmatrice_ptr matmul(Tmatrice_ptr A,Tmatrice_ptr B) {
	if (A->n != B->m) {
		return NULL;
	}
	Tmatrice_ptr R = (Tmatrice_ptr)malloc(sizeof(Tmatrice));
	creermatrice(A->m,B->n,R);
	for(int i=0;i<A->m;i+=1) {
		for(int j=0;j<B->n;j+=1) {
			double scalaire=0;
			for(int k=0;k<A->n;k+=1) {
				scalaire += A->contenu[i][k] * B->contenu[k][j];
			}
			R->contenu[i][j] = scalaire;
		}
	}	
	return R;
}

void liberemat(Tmatrice_ptr matrice) {
	for(int i=0;i<matrice->m;i+=1) {
		free(matrice->contenu[i]) ;
	}	
	free(matrice->contenu);
}
				
Voir le code source du programme de test de la multiplication matricielle

Fichier de test

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <math.h>
#include "matrice.h"

#define NDEFAUT 1
#define TAILLE_STRMATRICE 500

char *mat2octave(Tmatrice matrice,char*nom,char *charoct) {
	sprintf(charoct,"%s=[",nom);
	for(int i=0;i<matrice.m;i+=1) {
		for(int j=0;j<matrice.n;j+=1) {
			char svaleur[TAILLE_STRMATRICE] ;
			sprintf(svaleur,"%e ",matrice.contenu[i][j]);
			strcat(charoct,svaleur);
		}
		if (i < (matrice.m -1)) {
			strcat(charoct,";");
		}
	}	
	strcat(charoct,"]");
	return charoct;
}

int main(int argc, char **argv) {
	int status;
	Tmatrice A,B;
	Tmatrice_ptr C;
	char sA[TAILLE_STRMATRICE], sB[TAILLE_STRMATRICE] , sC[TAILLE_STRMATRICE];
	char soctavecmd[3*TAILLE_STRMATRICE+50];
	int pipefd[2];
	pid_t pid;
	char buffer[100];
	int Nmax = NDEFAUT;
	double sommeerreur=0;
	if (argc == 2) {
		Nmax = strtol(argv[1],NULL,0);
	}
	creermatrice(3,4,&A);
	creermatrice(4,3,&B);
	for(int i=1;i<=Nmax;i+=1) {
		printf("Essai n°%d\r",i);
		fflush(stdout);
		matalea(&A);
		matalea(&B);
		C=matmul(&A,&B);
		mat2octave(A,"A",sA);
		mat2octave(B,"B",sB);
		mat2octave(*C,"C",sC);
		sprintf(soctavecmd,"%s;%s;%s;R=A * B;E=max(max((C-R).^2))",sA,sB,sC);
		liberemat(C);
		int res=pipe(pipefd);
		if (res < 0) {
			fprintf(stderr,"erreur pipe\n");
			return EXIT_FAILURE;
		}
		pid = fork();
		if (pid > 0) { 
			waitpid(pid, &status, 0); 
			close(pipefd[1]);
			int n=read(pipefd[0],buffer,1000);
			close(pipefd[0]);
			buffer[n]=0;
			char *p=strchr(buffer,'=');
			double erreuroctave = strtod(++p,NULL);
			sommeerreur += erreuroctave;
		}
		if (pid == 0) { 
			int err=dup2(pipefd[1],STDOUT_FILENO);
			if (err == -1) {
				return EXIT_FAILURE;
			}
			execl("/usr/bin/octave-cli", "octave-cli","--eval",soctavecmd, NULL);
			return EXIT_SUCCESS;
		}
	}
	double erreurquadratiquemoyenne = sqrt(sommeerreur/Nmax);
	printf("\nerreur quadratique moyenne %e\n",erreurquadratiquemoyenne);
	liberemat(&A);
	liberemat(&B);
	return EXIT_SUCCESS;
}
					

Méthode

Le programme de test va effectuer une suite de multiplications de matrices, transférer les matrices ainsi que le résultat au logiciel octave. Ce dernier va effectuer le calcul et comparer le résultat avec le calcul effectué par le programme C, en calculant l'erreur quadratique maximale entre les deux matrices résultats. Le programme C va utiliser ce résultat pour calculer l'erreur quadratique moyenne de cette suite de multiplications.

Implémentation

Pour chaque jeu de matrice, après avoir effectuer le calcul en C, le programme génère une chaîne de commande pour octave, exécute le programme octave dans un processus enfant. Ce dernier transmet le résultat au processus parent en utilisant un tube, après avoir rediriger la sortie standard vers l'entrée du tube.

Le programme effectue la somme cumulée des erreurs, pour enfin calculer l'erreur quadratique moyenne et afficher le résultat.

Codage

La fonction mat2octave crée une chaîne de caractère pour octave de déclaration de la matrice à partir du tableau. Cette chaîne est de la forme :

A=[ 1 2 3 4;4 5 6 7;8 9 10 11]

Exemple de chaîne de commande octave :

A=[ 1 2 3 4;4 5 6 7;8 9 10 11];
B=[ 1 2 1;4 5 6;12 10 11,7 8 6];
C=A*B;
E=max(max((C-R).^2))

La dernière ligne ne se termine pas par un point-virgule, ce qui fait que c'est le résultat de cette dernière qui sera envoyé sur la sortie standard, comme par exemple :

E = 5.564243e-6

Le programme crée un tube avec la fonction pipe qui contient un flux d'entrée (pipefd[1]) ou écriture et un flux de sortie ou lecture (pipefd[0]).

Dans le processus enfant, la sortie standard est redirigée vers l'entrée du tube avec la fonction dup2 avant d'exécuter le programme octave avec la fonction execl. Le résultat fournit par le logiciel octave sera donc écrit dans le tube et disponible dans le programme parent

Dans le processus parent, après avoir attendu la fin du processus enfant, le programme ferme le flux d'entrée, lit le contenu du flux de sortie dans un buffer, ferme le flux de sortie.

Le buffer contient la ligne retournée par octave, qui n'est pas au format chaîne de caractères. On ajoute donc un 0 en fin de buffer, pour que celui-ci soit exploitable par les fonctions de traitement de chaînes de caractères. La fonction strchr retourne un pointeur sur le caractère égal. Il reste à convertir la chaîne qui commence après le caractère égal en réel.