Backup Clearclic
Ancienne intro
Au début des années 70, les telecoms américains ont le projet de créer un système d’exploitation multi-tâches. La création d’un tel système est le domaine de recherche des auteurs du langage C. Ils travaillent d’abord pour le projet Multics, mais celui-ci, trop ambitieux et complexe est abandonné. Fort de leur échec, ils vont concevoir une version simplifiée : Unix, et se donnent une année pour le mettre en œuvre.
Parallèlement, depuis les années 60 les fabricants d’ordinateurs se multiplient et le langage utilisé pour fabriquer leur partie logicielle : système d’exploitation, compilateur et utilitaires ; est l’assembleur. Chaque constructeur emploie des instructions en assembleur différentes et chaque génération comprend de nouvelles instructions, les programmeurs doivent donc fournir un effort important pour rédiger la partie logicielle de chaque ordinateur. Dans ce contexte le BCPL se propose, grâce à une syntaxe de “haut niveau” néanmoins adaptée à la programmation système d’être compilé vers l’assembleur de chaque machine, et d’avoir du code réutilisable — en ne gardant en assembleur que les parties les plus spécifiques à chaque machine. Le gain de temps est immense et le BCPL est d’abord considéré pour rédiger UNIX.
Mais il n’existait pas de compilateur pour le BCPL sur la machine à disposition de nos chercheurs, et le compilateur qu’ils ont écrit n’a implémenté que les parties du langage indispensables à leur tâche. Ce langage simplifié fût appelé le B. Cette machine avait des mots de 20 bits, ce qui était suffisant pour contenir à la fois n’importe quel jeu de caractères et des nombres entiers, mais avec l’adoption de l’ASCII qui est un jeu de caractères 7 bits, la taille du mot devint fixée à 8 bits (pour permettre des caractères ASCII étendus), mais avec 8 bits on ne pouvait plus coder que des nombres entre 0 et 255 (ou -128 et 127 signés) et pour le calcul scientifique ce n’était pas assez, pour remédier à cela on utilisera plusieurs mots pour coder les entiers, pour travailler sur des valeurs entières répartis sur plusieurs mots, l’absence de typage du B posait problème, donc le C lui ajouta le type char en sus du type int, ainsi que la possibilité de les rendre signés ou non. Plus tard viendront des variantes short et long du type int.
Le C a été créé pour fabriquer le cœur et les programmes de base du système d’exploitation Unix. Il voit le jour en 1973 au sein du laboratoire de recherche et développement de Bell. ainsi que les programmes utilitaires s’exécutant dessus. C’est donc un langage système qu’on retrouve au cœur de tous les systèmes d’exploitation majeurs actuels (Windows, Linux…) car ceux-ci descendent d’UNIX. Il a remplacé l’assembleur qui était jusque là utilisé pour écrire du code système, et a permis des gains de productivité considérables, grâce à sa portabilité. De nos jours il est toujours le langage utilisé dans la quasi totalité des systèmes d’exploitation.
Outre les compatibles PC, il est populaire au sein des systèmes embarqués, même si le C++ y tend à le remplacer de nos jours.
Il permet également d’écrire des programmes “hébergés” au sein de ces systèmes, c’est à dire des programmes exécutables (.exe) les plus célèbres et utilisés étants git, python…
C’est un langage très léger et “sans fioritures”, si bien qu’on le retrouve dans la plupart des librairies assurant des fonctions de base en informatique : lire un fichier jpeg, décompresser une archive zip… Ces librairies peuvent facilement être réutilisées dans d’autres langages compilés (C++, Fortran…), le contraire n’étant pas vrai.
On l’emploie également comme langage intermédiaire d’autres langages plus évolués. C’est le cas de Haskell par exemple : le code Haskell est compilé en C, puis le code C est compilé en code machine. D’autres exemples sont le Cython, ou le C++ à ses débuts.
Avant-propos
Ce livre va exposer le C tel qu’il est utilisé pour écrire des programmes exécutables sur PC, l’accent étant mis sur Windows. Les fonctionnalités du C utiles surtout à l’embarqué seront également exposés mais nous ne nous attarderont pas dessus. La programmation système ne sera globalement pas abordée.
Il suppose une connaissance d’un langage de programmation adapté pour les débutants: python, javascript, PHP, et ne s’attardera pas sur les concepts de base tels que les sauts conditionnels, les boucles. De nombreux parallèles seront faits avec ces langages, ainsi qu’avec le C++ dont les différences avec le C seront détaillées.
Plusieurs projets seront réalisés le long de l’ouvrage en tant que fil rouge. Un jeu de bataille navale. Puis une interface graphique pour faciliter le jeu basée sur OpenGL. Puis une version multijoueur faisant usage de multithreading et pouvant héberger des centaines de parties de bataille navale. Les contraintes du temps réel ne seront pas abordées.
L’accent sera mis sur des programmes optimisés pour Windows, puis des solutions pour écrire du code inter-système, moins rapide mais plus portable. Nous utiliserons à cette fin Cmake et présenterons les bases de ce logiciel et son langage de script.
Fil rouge
- Un pong en réseau en temps réel.
- Utilisation de la libuv pour faire de l’asynchrone. https://vimeo.com/24713213 🌍⤴
- Une application serveur en GUI, nuklear https://github.com/vurtun/nuklear 🌍⤴
- Des clients en OpenGL
Prérequis
Il ne s’agit pas d’un livre d’apprentissage de la programmation. Il présuppose que le lecteur sache à quoi sert une instruction “if
”, “while
”, à quoi correspond \n
dans "hello world\n"
, etc.
- Visual code
- Git
- Cpu-Z
- Une suite d’outils de compilation (MSVC)
Idées pédagogiques
Il existe de nombreuses innovations pédagogiques dans la vie réelle telles que l’école mutuelle1, la classe inversée, mais qu’en est-il du domaine de l’internet? Sommes-nous cantonnés au cours magistral. Rendre les étudiants actifs. L’aspect péjoratif du cours magistral. Qui n’est pas centrée sur le magistrat, mais sur la participation. On remplace les QCM par des questions ouvertes. L’étudiant participe, exemple beekast.com, wooclap. Les exercices consistent en des choses qu’on ne peut faire qu’en C. Fixer l’objectif pédagogique du cours exposé. Le transfert de compétence. La motivation de chaque étudiant cependant peut être différente. Si vous n’avez pas de question, je n’ai pas de cours. On donne l’illusion du choix : “qu’est ce que vous devez apprendre pour résoudre le problème ?”. Au final on impose un cheminement. On ne sanctionne et on n’évalue pas, mais c’est l’étudiant qui s’auto évalue. Ce n’est pas seulement un cours just-in-time. Ni une classe inversée.
Vos besoins peuvent être transversaux. Nécessiter des besoins dans le domaine de l’informatique, des mathématiques, qui sortent de l’exposé. Le projet reste l’épine dorsale du trimestre. Il est facile de dire que au lieu que ce soit moi qui vous apprenne à programmer en C, ce soit vous qui m’appreniez à programmer en C. On ne veut pas tomber dans un extrémisme.
En classe un professeur motivé, qui est intéressé. Un cours pertinent, qui ne passe pas des heures à expliquer des choses hors sujet. Un cours bien structuré, auquel le professeur a bien réfléchi, ou bien un cours improvisé, où c’est à l’élève de faire des efforts. Préférez vous une application immédiate de ce que vous venez d’apprendre ou un projet hebdomadaire, ou global. Etes vous prêts à cumuler. Travailler sur des problèmes concrets de la vie réelle, vous augmenterait il votre motivation, sur des problèmes rencontrés par les professionnels, ou fondamentaux.
Déterminer votre motivation. Motivations personnelle ? Trouver un travail, apprendre de nouvelles choses, raisons familiales. Motivation sociale ? Prestige. Motivation de la classe? Un professeur qui vous félicite, vous encourage. Voir les autres réussir, ou bien réussir où les autres ne réussissent pas. Etre dans le haut du classement. Avoir besoin des autres pour vous motiver. être le meilleur. Ou au contraire vous avez toujours détesté la classe et vous comparer aux autres. Sont-ce les notes ou l’auto évaluation suffit, et dans ce cas comment comptez-vous prouver que vous avez les compétences visées ? Le diplôme? Trouver du travail? Apprendre de nouvelles choses? Vous jauger? Mettre vos limites à l’épreuve. Donner des privilèges aux personnes les plus motivées?
Appuyez-vous sur votre motivation initiale pour créer un projet motivant. ne personne qui a déjà fait des dizaines de morpions ne va pas être intéressé par un énième, une personne qui n’aime pas le foot ne sera peut être pas intéressé par programmer un jeu de foot. Un projet qui vise l’affectif, la motivation personnelle. Demander trop, programmer un système d’exploitation par exemple, peut aussi décourager. Trouver un projet qui soit le juste milieu, suffisamment motivant, qui s’inscrive dans la motivation personnelle de l’étudiant.
Présentation du C
Un programme C est constitué de fonctions qui s’appellent les unes les autres au cours de l’exécution du programme. Une fonction particulière s’appelle main
et sert à amorcer le programme.
Il est impossible d’avoir du code qui s’exécute en dehors d’une fonction, comme dans dans les constexpr du C++ lors de la compilation, ou simplement dans le document qui s’exécute comme en python ou js.
Des structures de contrôle permettent de diriger l’exécution d’un programme, il est également possible d’ordonner de manière impérative un saut avec goto
. Un appel de fonction ou de longjmp()
provoque également au saut, mais a pour effet de sauvegarder l’état de la pile actuelle et la création d’un nouveau contexte de pile.
Il s’agit d’un langage compilé dont les principaux compilateurs sont:
- MSVC pour Windows
- GCC pour GNU/Linux
- Clang pour la LLVM (Windows et Linux)
Nous utiliserons tous ces compilateurs en même temps pour nous assurer que notre code est bien portable, ceci au moyen de Cmake.
Machine à empiller
Pour qu’une machine puisse supporter le langage C elle doit lui fournir une machine à empiller des variables. Contrairement aux calculatrice et autres machine à calculer simples qui ne fournissent qu’un nombre limité de variables et leur donnent des noms numériques, un ordinateur qui supporte le C doit pouvoir fournir autant de variables que le programmeur lui demande, et le fait avec une machine à empiller, chaque variable sera placée l’une après l’autre dans la pile, jusqu’à sa complétion (stack overflow) que le langage lui meme ne sait pas gerer, c’est au systeme qui fournit la pile de le faire.
Une des nouveautés du C, concomitante à l’adoption du standart ASCII pour représenter les charctères textuels sur 7 bits, est qu’une variable peut etre typée, pour contenir soit une lettre sur 7 bits, soit un nombre sur beaucoup plus de bits (jusque 128 aujourd’hui), on empile donc des variables de taille différente et qui représentent des concepts qui sont soit des lettres, soit des nombres entiers strictement positifs, soit des nombres qui peuvent être positif et négatif, soit des nombres à virgule.
Les manières de traduire la représentation binaire des objets vers des valeirs concrètes et assez libre (Ebdic, Ascii, complement a 1, 2, signe et megnitude), mais aujourd’hui on considere les standard de facto.
Un tel objet dans la pile possède donc une valeur qui dépend de son type.
Concomitance
Mais pouvoir empiler des variables ne garantit que de pouvoir en avoir autant que la memoire le permet, cela ne dit pas dans quelle direction on empile, si on rajoute du bourrage ou de l’espacement entre chaque, etc.
Pour représenter du texte on aimerait avoir une garantie de restitution binaire fidèle, de meme pour une serie de mesures statistiques. Pour cela le C fournit les tableaux.
Ceux ci garantissent qu’il n’y a aucun espace entre valeurs concomitantes, et qu’elles seront restituées dans l’ordre où elles sont entrées. Pour discriminer un élement d’un tableau on utilisera un pointeur vers celui ci et un index qui se base sur ce pointeur, le C fournit une syntaxe qui simplifie grandement le procéde.
Finalité du C
Dans les années 70 les ordinateurs n’occupaient plus une pièce entière, mais une armoire à rangement, c’étaient des mini-ordinateurs. Ils devenaient de plus répandus et de plus en plus de constructeurs en fabriquaient, à chaque nouvel ordinateur de chaque constructeur il fallait écrire un système d’exploitation, en assembleur, généralement en partant de zéro. Le BCPL était un langage conçu pour répondre à ce besoin d’écrire des systèmes d’exploitation et son usage commençait à se répandre. Il fournissant une syntaxe de plus haut niveau que l’assembleur et compilait lui même en instructions machines aussi rapides que du code en assembleur, réduisant grandement la quantité de code qu’il fallait écrire tout en assurant une certaine portabilité : certaines portion de code ne nécessitaient pas d’être réécrites pour chaque marque et version d’un d’ordinateur, et seules les parties vraiment spécifiques à chaque machine devaient être écrites en assembleur. Le gain de temps était immense.
L’équipe à l’origine du C travaillait dans la recherche et développement en système d’exploitation, car tout était encore à inventer à cette époque, leur concept était assez révolutionnaire et s’appelait UNIX. Pour l’implémenter le plus rapidement possible ils ont d’abord considéré le BCPL, mais se sont aperçus qu’implémenter un compilateur pour la machine qu’ils avaient à disposition demanderait du temps, donc ils ont juste implémenté une version simplifié, qui s’est appelé le B. Mais ce langage était trop simplifié, et l’absence de typage a posé des problèmes de portabilité lorsqu’il a fallu porter leur prototype d’UNIX sur une autre machine. Le C a enrichi le B d’un système de typage et a ainsi résolu ce problème.
Donc c’est un langage conçu pour écrire des systèmes d’exploitation en assurant une portabilité, c’est à dire un réemploi de code maximal. C’est aussi un langage généraliste, si bien qu’on l’a rapidement utilisé à toutes les sauces, pour écrire tous les programmes métier du système d’exploitation d’UNIX entre autres.
UNIX a été tellement populaire qu’on lui a reproché de mettre fin au foisonnement en matière de recherche & développement des systèmes d’exploitation : quasiment tous les OS se sont basés sur UNIX après sa sortie, et par percolation, tous les logiciels fonctionnant dessus. Même aujourd’hui, MacOs, Linux, Windows, Android, sont basés sur UNIX est sont codés principalement en C, et il demeure l’unique langage système plus de quarante ans après sa création, et quelques évolutions pour tenir compte de l’évolution des microprocesseurs.
Quel C allons-nous utiliser
Il existe une myriade de variantes du C en usage actuellement. L’ANSI C, qui date de 89 est la base de la plupart d’entre elles et la version la plus portable. De nombreux compilateurs supportent également une partie des nouvelles fonctionnalités du C99, et le C11 est également en partie supporté par des compilateurs du monde UNIX principalement. A titre anecdotique le C2X est actuellement en développement et avant l’ANSI C existait le K&R C qui n’est plus du tout utilisé.
L’ANSI C est le plus universel, celui qui sera compatible avec tous les compilateurs, on le privilégie lorsqu’on écrit une libraire d’utilité générale, qu’on veut rendre accessible à tout le monde. Le C99 est partiellement supporté par Microsoft et son compilateur MSVC. Le C11 n’est supporté que par Linux et pas dans son ensemble, on l’utilisera si on veut se limiter à Linux. Il existe un compilateur GCC pour Windows mais ses performances ne sont pas égales au compilateur natif de Windows.
Dans ce cours nous privilégierons le sous-ensemble du C99 supporté par MSVC : celui-ci a une portabilité comparable à l’ANSI C aujourd’hui car totalement supporté par les compilateurs de chaque OS principal. On évoquera les différences avec entre le C99 et l’ANSI C, et les fonctionnalités apportées par le C11 et le C20 qu’on peut espérer pouvoir utiliser dans le futur si Microsoft les intègre dans sa suite de construction logicielle.
Cmake
Types de base
Voila les types de base présents dans différents langages et leur équivalence en C.
Comparatif
Javascript | Python | C |
---|---|---|
string | str/unicode | char[], “…” |
number | int/float | int/float/double |
object | type/dict | struct |
Array | list | [] |
bool | Boolean | bool/_Bool |
null | None | NULL/(void*)0 |
undefined | None | NULL/(void*)0 |
void | void |
Spécificités du C
Certains éléments sont spécifiques au C et ne se trouvent pas ou peu dans d’autres langages populaires.
Fonction ne peuvent être imbriquées
Les fonctions ne peuvent exister qu’au niveau du fichier, et non à l’intérieur d’un bloc. Il n’y a donc point de fonctions dans des fonctions. Il n’est également pas possible de déclarer des expressions lambda.
Notation mathématique
Le C emprunte aux mathématiques certaines notations, mais ne respectent pas toutes leurs règles. Par exemple les crochets {}
servent à regrouper plusieurs instructions dans un même ensemble, mais l’absence d’ordonnancement et l’unicité de chaque élément de l’ensemble ne sont pas pris en compte. Le python par exemple permet d’écrire 2 < 4 == 2 * 2 <= 8
comme cela est valide en écriture mathématique. En C un test booléen revoie soit 0 ou 1, l’expression ne s’évaluera pas de la mème manière, pour retenir les résultats intermédiaires il faudrait écrire. (2 < 4) && (4 == 2 * 2) && (2 * 2 <= 8)
.
Le C utilise la notation mathématique des ensembles pour décrire des données groupées, c’est à dire int[] a = {1, 2, 3}
mais ne garantit pas l’unicité des éléments, et garantit leur ordonnancement. En mathématiques l’unicité des éléments est garantie et l’ordonancement n’a pas d’importante, ainsi en python, la propriété mathématique d’unicité des ensembles est respectée : toto = {1, 2, 3, 1}
donnera toto = {1, 2, 3}
, et l’ordonnancement est déterminé ou non selon les versions de python. En C L’ordre est important et l’unicité ignorée : int[] a = {1, 2, 3, 1}
donnera {1, 2, 3, 1}
en mémoire. La structure de données qui correspond aux tableaux du C est la liste. Ainsi toto = [1, 2, 3, 1]
aura le même comportement que int[] toto = {1, 2, 3, 1}
.
Guillemets doubles et “guillemets simples”
Les guillemets doubles et simples (apostrophes) peuvent être utilisés de manière interchangeable dans beaucoup de langages évolués. En C seuls les guillemets doubles sont acceptés. Les guillemets simples sont utilisés pour les caractères seuls.
Controle
Les if
, while peuvent etre familiers, certaines structures peuvent l etre moins.
- do while
- goto
- continue
- switch
- for
- ++ / –
Structures, preprocesseur
Chacunes constituent un para langage. On peut tout faire sans structure en tordant suffisamment des tableaux.
Les structures permettent des parametres nommés optionnels mis à zero. Des api evolutives.
Elles outrepassent la limite de ne pas pouvoir retournes des valeurs separees depuis une fonction et font du C un langage ambitieux.
Le preprocesseur quand lui permet la metaprogrammarion, les arguments optionnels…
Fonctionnalités avancées
Beaucoup d’éléments intégrés nativements dans d’autres langages sont fournis par des librairies tierces en C.
On peut penser a malloc() pour new, longjmp() pour yield, pow() pour **…
Il n’y a pas de this, l’addresse de l’objet doit être fournie aux fonctions travaillants avec elles.
Nouveautés du c99
Beuacoup de langages inspirés du C se sont basés sur la version ANSI du C, et les nouveautés de C99 demeurent spécifiques au C et ne se trouvent pas dans d’autres langages.
Le c99 apporte des éléments syntaxiques absents des langages qui se sont inspirés du C ANSI, et sont donc assez uniques au C “moderne”.
Les structures imbriquées, les structures imbriquées anonymes (c11), les structures expressions return (struct toto){1, 2}
Les initialisateurs de champs qui permettent une souplesse. struct toto tata = {.foo=1, .bar=2}
, char toto[] = {[50]=42}
Les VLA.
Annexe K.
Équivalences entre expressions et instructions
Beaucoup d’instructions possèdent une expression correspondante, c’est à dire une valeur assignable.
- if
- On peut utiliser une expression ternaire
- blocs d’instruction {}
- expression virgule
- switch
- équivalent rudimentaire via accès tableau.
"Abc"[N]
Tous les éléments statiques n’ont toutefois pas d’équivalent assignable, par exemple point de lambdas ou de fermetures comme on le voit souvent.
Rust est C# ont des switch assignables. Les langages fonctionnels vont plus loin et rendent assignable la plupart des éléments des langages structurés.
Si on écrit du code tel que celui-ci while (len--)
La longueur initiale est perdue à la sortie de la boucle, sauf si on en a fait une copie. On peut imaginer d’une boucle-expression qu’elle retourne son nombre d’itérations, mais on peut également vouloir d’elle sa dernière valeur produite si valeurs produites il y a eu.
L’emploi privilégié des structures de controle donnent au C un lustre impératif, même si des éléments d’un langage fonctionnel existent.
Nouvelle dimension
Si vous venez d’un langage de haut niveau, on a certainement essayé de vous présenter la programmation sous une forme abstraite, voire parfaite, qui ignore la machine. Vous édictez votre programme sous forme de loi et nulle machine ne sera censée l’ignorer et l’exécutera sans broncher. C’est vous qui créez vos programmes, et ce sont les machines qui se plient à vous. Lorsqu’on s’approche des langages de bas niveau, il devient important de comprendre comment fonctionne un ordinateur. Qu’est ce qu’un ordinateur peut faire et comment exploiter ces possibilités avec le langage que nous allons étudier.
Par exemple la partie de l’ordinateur qui travaille à additionner des nombres entiers n’est pas la même que celle qui additionne les nombres à virgule, que ces nombres à virgule ont de nombreuses particularités.
Il existe un emplacement de la mémoire vive qui ne peut jamais être référencé, NULL.
Si on compare un programme à un cylindre à musique, le travail du programmeur se résume à mettre les encoches au bon endroit pour que l’appareil execute la musique qu’on lui a demandé. On lui garantit un sens de rotation, une séquence (les notes ne seront pas jouées au hasard). On comprend qu’un programme s’exécute dans un cadre bien délimité et dans un environnement dont il ne doit pas se soucier. Si cela est vrai pour le python, dans le C l’environnement peut changer. Par exemple un xylophone ne jouera qu’une ligne surt deux, la ligne impaire servant à moduler la note. Dans ce cas un cylindre crée pour un type de xylophone ne fonctionnera plus dans la nouvelle génération. Un programme C écrit pour Linux peut ne pas fonctionnert sous Windows ou MAcintosh. En C il faut dopnc avoir des connaissances transversales. Connaitre les différents composants de la machine (mémoire, processeur…), les utilitaire du système d’exploitation (ouvrir une invite de commandes), les accesseurs vers la machine (POSIX…).
Que ça soit d’exécuter de la musique ou d’imprimer un livre avec une machine, ou le machin de souffler sur sa main pour imprimer sa silhouette, ces activités cherchent à rendre plus facile une tâche quelconque. Quelle tâche la programmation cherche-elle à faciliter ? Celui du calcul ? Un programme qui se contente d’afficher un nombre sans calculer est un programme. Un programme qui se contente de ne faire que des calculs sans les afficher l’est également, et on ne saurait savoir ce qu’il fait sans outil externe.
Les programmes de type C s’exécutent très rapidement, théoriquement à capacité maximale de la machine. Il devient possible et il peut être tentant de vouloir dialoguer directement avec elle. Par exemple sachant qu’elle exécute les additions plus rapidement que les multiplications, écrire a + a + a, a lieu de a * 3. Si l’addition s’exécute rapidement, la mise en mémoire (d’autant plus qu’elle est manuelle) ou la récupération est quelques magnitudes plus lente. Le changement de thread est une magnitude plus lente. L’interaction avec un disque dur, indépendamment de ses performances encore plus lente.
il faut rapidement être conscient des optimisations que le compilateur peut faire et exprimer clairement l’intention de son programme. Ce souci est au cœur même du langage avec la construction (for(;;)
).
Ne pas mettre d’accès disque dur dans une boucle vitale.
Problème foncièrement séquentiel : Remplacer toutes les lettres d’un mot par une table de remplacement. Si on fait les lettres unes par unes les remplacements précédents peuvent encore être considérés.
Aspiration universelle
Le C est un langage qui se veut généraliste, c’est à dire non cloîtré à un environnement d’exécution particulier. On peut donc l’exécuter dans toutes sortes de machines électroniques, robots, ordinateurs, calculatrices, embarqués…
Il est cependant conçu pour la programmation système et les programmes qui fonctionnent sous un système de type UNIX et excelle dans ces deux cas de figure, au détriment d’autres cas.
A ce titre il est extrêmement laxiste et préjuge très rarement de la sémantique d’un code source. Par exemple le C++ interdit les opérations arithmétiques sur des booléens, le C l’autorise, avec parfois des résultats surprenants. Par exemple le programme suivant produit affiche toto: 1, toto: 1 en 2019.
#include<stdio.h>
int main(void) {
bool toto = 0;
--toto; /* overflow ? oui pour -- */
printf("toto: %d\\n", toto);
toto = false;
++toto;
++toto; /* overflow ? non pour ++ */
printf("toto: %d\\n", toto);
return 0;
}
Marques de conception
Le C est cependant marqué par l’environnement dans lequel il a été créé. Une machine qui contient de la mémoire, et qui exécute les instructions séquentiellement, sans parallélisme. Une machine qui ne dispose que d’un seul processeur séquentiel et d’une seule unité de mémoire. Rien dans le langage ne permet d’exécuter expressément des instructions en parallèle. Il n’est pas possible de cibler les différents niveaux de mémoire cache, tout au mieux peut on cibler les registres du processeur.
La machine virtuelle du C correspond a la première machine à laquelle il a été destiné. Une unité de calcul, une seule couche de mémoire en un seul tenant.
Les appareils ont évolué et contiennent pres de 200 unités de calcul qui peuvent fonctionner en parallèle et plusieurs niveaux de cache. Les outils proposés par le C sont spartiates. Pour le parallelisme le C propose restrict
, volatile
; pour la mémoire rien si ce n’est register
.
Le C qu’on va utiliser
Nous écrirons du C qui s’exécutera dans un système d’exploitation dérivé d’UNIX, (Windows, Linux)… Lorsque nous lui demanderons d’exécuter notre programme, il recherchera une fonction appelée main
et lui passera la main. En outre l’OS fournia deux arguments, le premier étant le nombre d’arguments, le deuxième un tableau de ces arguments. Etant un langage neanmoins généraliste, les informations spécifiques à l’OS ne sont pas fournis, comme le PID du programme, il faudra utiliser des solutions spécifiques à chaque OS pour connaître cette information.
Fonctionnalités que nous n’utiliserons pas
Au fur et à mesure des besoins des fonctionnalités ont été rajoutées au C, peu ont été standardisées, parmi celles ci beaucoup sont deconseillés dans des programmes portables :
- Unions
- VLA
- setjmp/lngjmp
- fonctions récursives
- offsetoff
- Malloc
- fonctions securisees - annexe K
- Multithreading, fork
- register/inline/restrict
Nous les évoquerons seulement. On essayera de respecter autant que possible les recommendations.
Nous évoquerons les extensions les plus marquantes du C. Fonctions imbriquées de GCC, notation binaire 0b01101, typeof.
VLA
Les VLA enrtrent dans le cas de l’allocation dynamique et peuvent depasser la capacité mémoire allouée au programme ou sur la machine.
Malloc
Dans les applications les plus critiques (avions) la probabilité de dépleter la memoire n’est pas acceptable.
Recursion
Les implementations ne sont tenues d’implementer le short tail recursion, auquel cas un nombre trop important de recursions peut depasser la limite memoire et planter de maniere imprevisible.
offsetoff/alignas
Peut empecher des optimisations, tres specifique au materiel.
setjmp/longjmp
Rarement implémenté, et implementations varient.
Annexe K
Voulue par Microsoft et decriees, non portables, bien que recommandees. Dans un contexte tres securisé, intégrer une tierce librairie si besoin.
multithreading / fork / signal / atomic
Pas de solution standard et portable. Faire quand même?
register, etc
Microptimisations que le compilateur sait mieux faire que nous.
Locations memoire
Objets scalaired, bitfields non zero. Utiliser atomic
3.14
Sizeof
Sizeof donne la taille d’un objet. Il est valide à la compilation, mais également sur les VLA. Il fonctionn sur un nom de type entre parenthèses (ce n’est pas un cast, juste une particularité de sizeof)
int toto[sizeof(int)] /* declare un tableau de 4 entiers, valable au niveau du file scope. */
Son opérande n’est pas évalué, ainsi sizeof printf("test\n");
ne doit rien afficher. Si l’opérande est un VLA, alors il est évalué.
Contrôle de saut
Un programme évolué peut nécessiter de sauter à une autre ligne que celle qui se trouve immédiatement après.
Goto
Risqué avec les VLA.
Utilisé pour gérer le réclamation de mémoire no empilée. Exemple https://github.com/P-H-C/phc-winner-argon2/blob/master/src/argon2.c#L283 🌍⤴ Ou bien de plusieurs boucles empilées, il n’est pas possible break N, comme en java ou PHP.
Le C qui s’exécute en dehors des fonction
On a vu qu’un programme C est constitué de fonctions qui s’appellent entre elles, et qu’il n’est pas possible d’écrire du code C en dehors de ces fonctions. C’est vrai dans une certaine mesure. On peut écrire du C en dehors des fonctions à condition que celui-ci puisse s’exécuter à la compilation. Tout le code situé en dehors des fonctions est exécuté au démarrage du programme, avant le passage à la fonction main(), même si ce code est situé après la fonction mais, et se trouve dans un fichier différent de la fonction main(). Ce code se limite à l’attribution de variables statiques, et à des calculs sur des valeurs entières littérales qui sont effectués par le compilateur. Voici à peu près la limite de ce que l’on peut écrire dans la portion statique d’un programme C :
#include<stdio.h>
int a = 2 + 2;
int b = 1 << 8;
int c = sizeof(int) + 4;
int main(void) {
printf("a: %d - b: %d - c: %d\n", a, b, c);
return 0;
}
Les valeurs de a, b et c peuvent être calculées à la compilation, même si les parenthèses de sizeof()
peuvent laisser croire à un appel de fonction, il s’agit d’une fonction intrinsèque. Il n’est pas toujours clair de savoir quelles fonctions sont intrinsèques et quelles génèrent du vrai code en C; Par exemple la fonction pow() de la librairie math.h est quasiment tout le temps transformé en une instruction utilisant le cœur arithmétique du processeur mais il n’y a pas de garantie à cela, donc on ne peut pas écrire int a = pow(2, 4);
dans du code statique.
Mais attention
#include<stdio.h>
int a = 2 + 2;
int b = 1 << a;
int c = sizeof(b) + 4;
int main(void) {
printf("a: %d - b: %d - c: %d\\n", a, b, c);
return 0;
}
est incorrect. Les objets a, b et c n’existent qu’à l’exécution du programme, non à sa compilation et ne peuvent être utilisées par le compilateur. Pour utiliser des “variables” dans des expressions situées en dehors des fonctions, il faut utiliser des #define
ou des enum
: elles créent toutes deux des valeurs symboliques n’existent pas en mémoire lors de l’exécution du programme. Ce code-ci est correct :
#include<stdio.h>
enum {
a = 2 + 2,
b = 1 << a,
c = sizeof(b) + 4
}
int main(void) {
printf("a: %d - b: %d - c: %d\\n", a, b, c);
return 0;
}
Ce que peut faire un compilateur en C est assez limité, le C++ lève certaines limitations avec les constexpr
et les templates
. Les constexpr sont proposés à l’intégration de la norme C2X.
Remarque sur static
Les variables déclarées static
sont également initialisées au démarrage du programme, avant le passage à la fonction main(), mais n’obéissent pas au mêmes règles que les variables statiques situées en dehors des fonctions.
Les variables déclarées à l’intérieur de fonctions sont également initialisées au démarrage du programme, avant le passage à la fonction main(), c’est à dire que non seulement de survivre à la terminaison de la fonction, elles existent avant le premier appel à celle-ci. Bien qu’elles semblent ne pas appartenir à la fonction, leur nom sont bel et bien circoncises à celle-ci: on peut donc avoir un variable statique en dehors d’une fonction et une variable du même nom au sein d’une fonction, de meme deux variables statiques avec le même nom dans deux fonctions différentes. On ne peut pas accéder à une variable statique au sein d’une fonction depuis l’extérieur de celle-ci, mais le contraire est possible (on parle d’accès à une variable globale).
L’initialisation des variables statiques ne se fait qu’une seule fois dans toute la vie d’un programme, ainsi le C rend implicite leur initialisation à 0. Autrement dit une variable non statique (variable normale déclarée au sein d’une fonction) non initialisée aura une valeur indéterminée, une variable statique non initialisée sera automatiquement initialisée à 0.
Vérifier
static toto = 1;
int toto() {
static toto = 2;
{
static toto = 4;
}
}
int toto() {
static toto = 2;
static tata = toto + 2;
}
Préprocesseur
Le préprocesseur ajoute des pouvoirs au C.
Valeurs constantes
Avant le C++ et ses const
, le préprocesseur permettait de déclarer des valeurs constantes.
Metaprogrammation
Macros
Fonctions a nombre d’arguments variables (variadiques)
Avec l’ellipse …
Commentaires
Permettent des meta langages comme la génération de documentation, la validation sémantique (Frama-C). Ne remplacent pas un code clair et des variable et des noms de fonctions explicites, des fonctions courtes et dont l’utilité apparait clairement.
Tableaux
Ce qui différencie un ordinateur d’une autre machine électronique telle que l’Enigma ou un flipper, et qu’elle répond à la définition d’une machine de Turing, dont un des attributs principaux est d’avoir une bande mémoire où l’on peut se déplacer pour lire et écrire à l’envie.
Une calculatrice possède par exemple des variables nommées A, B, C, etc. jusque Z, mais ces variables ne sont pas ordonnées entre elles, c’est à dire que rien n’oblige la variable A à être avant la variable B. On peut également faire l’analogie avec les registres d’un processeur, le registre EAX n’est pas avant le registre EDX, ce sont des locations mémoires séparées.
Cette modalité nous limite rapidement pour peu que nous voulions davantage de locations mémoire que la machine peut nous fournir. La solution trouvée et d’utiliser une bande mémoire où chaque emplacement possède une ordonnée. Le C peut utiliser la mémoire vive pour y empiler autant de variables qu’il veut, c’est le compilateur qui garde en mémoire à quelle ordonnée se trouve telle ou telle valeur, en traduisant les noms symboliques en ordonnées.
Les différents objets créés peuvent l’êtres soit dans la pile dynamique, soit dans le tas, soit en pile statique, de manière aléatoire, il n’existe pas de procédé fiable qui permette d’aller d’un objet à un autre. Même dans la pile il n’existe pas de garantie sur la direction d’empilement ou la présence ou non de bourrage ou de métadonnées entre chaque variable.
Pour se garantir en mémoire une succession arithmétiquement juste (c’est à dire que l’adresse + 1 est bien après celle qui se trouve à adresse - 1, et elle même se trouve immédiatement après celle qui se trouve à l’adresse - 2) de valeurs continues on utilise un tableau.
Pour déclarer un tableau de 10 entiers, on précise la taille en crochets après le symbole de l’objet déclaré.
int mon_tableau[10];
Notons que contrairement aux entiers vus jusqu’ici, l’entier 10 a ici une fonction cardinale : elle précise la borne de l’ensemble des entiers [0 à 10[ qui sont valides. Mais elle introduit également une propriété ordinale : il sous entend l’existence de 10 valeurs successives 0, 1, 2, 3, 4, 5, 6, 7, 8 et 9 qui sont ordonnées entre elles de manière arithmétique (2 suivant immédiatement 1, et ainsi de suite). Une valeur telle que
int mon_entier = 10;
A une valeur purement cardinale et ne sous entend pas l’existence d’autres valeurs.
L’entier utilisé pour déclarer la taille du tableau ne peut pas valoir 0, ou être inférieur à 0, et sa taille maximale est définie par le système, on le considère inconnu et il faut donc éviter de déclarer des tableaux de trop grande taille pour éviter que le système n’interrompe votre programme (plantage). Sur Linux on considère 6Mio comme limite. C’est à dire que créer un programme qui définit un objet de cette taille char tableau[6000000]
est suffisant pour que le programme s’arrête sans aucun recours.
Si on veut des objets de très grande taille il convient de les déclarer dans la pile statique : statuc int mon_tableau[1000000]
ou dans le tas : int * mon_tableau = malloc(sizeof(int) * 1000000);
.
Exercice : essayer de faire fonctionner un programme qui initialise un tableau de 10, 100, etc cases. Le programme finit-il par planter ? Estimez la taille de la pile alouée par le systeme.
L’ensemble des valeurs entieres qui sont asressables est denombrable (non infini), ordonné, et par nature uniques. On peut se représenter une fonction qui a chaque entier associe une valeur. L’attrait de ceci est qu’à partie d’une adresse il est facile de trouver sa valeur, la réciproque n’est pas vraie (si deux valeurs sont identiques à quelle adresse la faire correspondre ?) On pourrait imaginer qu a chaque valeur on lui associe une etiquette temporelle, et qu’a chaque adresse on lui associe la date a laquelle elle aété modifiée : cela leverait ses ambiguites au detriment de plus de memoire.
Les valeurs sont en apparence erratiques, hormis les compteurs ou les valeurs representant du texte.
L’ordonnancement des valeurs adressables est mis à profit par les tableaux. Ils créent un domaine valide où on peut lire et écrire un pointeur et lui appliquer des opérations arithmétiques.
Par arithmétique on veit dire qu’il est possible d’ajoiter a un pointeur pour que celui ci aille lire dans une audre addresse : pae exemple *(ptr + 2) qui va lire deux cases après la case pointée. Une écriture racourci de l’expression est ptr[2], attention il ne s’agit pas du meme type d’entiers que ceux utilisés a la déclaration, 0 etant valide. Sachant que ptr[0] peut s’écrire *(ptr + 0) et se simplifie en *ptr, on retrouve l’une ou l’autre notation dans le langage C selon qu’on veuille lire à l’adresse courante ou concommitante.
Structures
Les structures permettent de faire des choses qu’il n’est pas possible de faire avec le C de base, et rajoutent une couche supérieure de pouvoirs à ce langage.
Outre passer la limite du char
La plus petite valeur que peut générer le C, est le char, c’est à dire un mot du processeur, généralement composé de 8 bits, il n’est pas possible d’écrire ailleurs que à partir d’un taquet, ou d’avoir des valeurs qui soient autre chose que des multiples de 2 (1, 2, 4, 8 mots), autrement qu’avec des décalages de bits, mais c’est peu pratique. Les structures permettent facilement d’écrire et manipuler chaque bit individuellement, au moyen des bitfields.
Retourner plusieurs valeurs d’une fonction
En C classique pour raisonner de plusieurs valeurs depuis une fonction, il faut utiliser des pointeurs. Avec une structure il est facile de passer et retourner plusieurs valeurs depuis une fonction. Même si la structure est créée dans la pile de la fonction appelée, elle survit à la destruction de la fonction et de sa pile, on dit qu’elle est enfermée dans la fonction appelante. C’est une fonctionnalité non imaginée par les créateurs du C, les structures devraient se comporter sur la pile comme les tableaux, mais implémentée dans tous les premiers compilateurs, si bien que c’est devenu un comportement standard et attendu du C.
Arguments optionnels et nommés
Dans l’appel de fonction classique du C, les valeurs passées par arguments ne sont pas apparentes immédiatement, par exemple activer_led(10, 3, 5.5, "hla")
, il n’est pas immédiatement appararent à quoi correspond la valeur 10, 3, 5.5… et comment elles sont utilisées par la fonction. De même si on change la signature de la fonction et qu’on inverse les deux premières valeurs, il ne faut pas oublier dans chaque appel de cette fonction inverser les deux arguments, même si les deux valeurs sont int
et ne provoquent pas d’erreur de compilation en cas d’oubli et l’appel de fonction peut sembler faussement correct.
Si le préprocesseur permet un nombre d’arguments variable, les structures depuis le c99 autorisent les arguments nommés, et un ordre indifférent dans ces arguments, avec une souplesse qui fait penser aux langages les plus modernes tels que le python. ma_fonction((struct toto){.angle = 15, .virage = 10})
Avec cette méthode l’utilité de chaque valeur est immédiatement apparente, l’ordre n’a pas d’importance et résiste aux restructurations de la fonction et à ses changements d’api, et un changement majeur, comme celui du nom d’un des champs, non respecté par un des appels de cette fonction est immédiatement capturé à la compilation.
Limitation sur l’endianness des bits et des mots
Malgré tous les pouvoirs et le contrôle permis par les structures, il n’est pas possible de contrôler l’endianness des valeurs contenues. En pratique cela n’est utile que lors de la communication avec une autre machine, et la librairie spécialisée de dialogue réseau contient des fonctions à cette fin, la famille htonl()
, htons()
, ntohl()
, ntohs()
Les tableaux de taille non spécifiée
Les variable length arrays, couplées à malloc
.
Introspection
Nombre d’éléments
sizeof(mastruct) / alignof(mastruct)
Self
&element - offsetof(element)
Autres fonctionnalités centrales apportées par les librairies
sdlib
malloc, mémoire dynamique. les VLA essayent d’intégrer ce concept dans le cœur de C. atexit. réclamation simple de ressources sensibles, beaucoup moins avancées que le RRID du C++.
limits.h
Permet l’introspection.
assert
Vérification au runtime de la validité sémantique de certaines valeurs.
static_assert
Vérification à la compilation de la validité sémantique de certaines valeurs numériques, ainsi que du comportement attendu par la machine (endianess, taille de sizeof(int), existence de certaines fonctionnalités optionelles du C via leurs macros, etc). Apporté par le C11.
stdbool
Permet de vrais booléens. Le C a toujours cosidéré les valeurs booléennes comme étant fondamentalement différentes des autres valeurs entières. Ainsi dans le langage B, on pouvait écrire a === b
, et a =!= b
à la place de a = a == b
et a = a != b
, le C n’autorise pas cette conversion d’une valeur de a entière vers une valeur booléenne. Il faut cependant plusieurs décennies avant de voir entrer le type _Bool dans le langage.
Outre qu’il soit une valeur entière, il permet plusieurs choses. Par exemple considérons une union :
union {
long a;
int b;
bool c;
} toto;
Si on lui assigne une valeur de type long dont les bits de poids faibles sont à 0, toto.a = 10000000
, un test booleen sur toto.b ne considèrera pas les bits de poids fort et vaudra 0, un test booleen sur toto.c considèrera la plus grande valeur et vaudra 1.
Une conversion d’une valeur flottante vers un int sera tronquée à 0, par exemple int a = 0.001
, a vaudra 0, mais bool b = 0000.1
, b vaudra 1.
Dans un bitfield si un champ est défini comme bool, lui donner une valeur plus grande que 1 produira une erreur de compilation.
struct {
bool toto : 2; /* erreur */
}
Enfin les booléens ne se comportent pas comme les entiers lors d’un dépassement de capacité.
bool toto = false;
--toto;
printf("toto: %d\\n", toto); /* affiche 1 */
toto = false;
++toto;
++toto;
printf("toto: %d\\n", toto); /* affiche */
Dans le deuxième printf, le dépassement de capacité n’entraine pas une remise à zéro.
Un entier converti vers une taille plus petite, de long int à int par exemple sera tronqué, mais une conversion vers un booléen ne tronque pas tous les bits de poids fort, un entier non égal à zéro sera toujours égal à 1 une fois converti en booléen.
setjmp
Permet des vrais générateurs, incorporés généralement par le mot-clé yield
dans les autres langages (C#, javascript, python). On peut simuler ce comportement avec des variables déclarées en static.
atomic, thread
Valeurs atomiques et thread safe.
Fonctionnalités implicites non explicitement gérées par le C
Parallélisme
Si le threading permet de faire des programmes non bloquants, il ne permettent pas un vrai parallélisme et l’utilisation de plusieurs processeurs en parallèle.
La parallélisation via les instructions MIPS est automatique et l’auteur en C ne peut que suggérer, via restrict
.
Limitation du C gérées par des meta langagaes
Sémantique des valeurs
Assert est static_assert assurent certaines valeurs, mais au niveau de chaque appel de fonction rien n’existe en C.
Deux int
sont corrects meme si leur sens ne l’est pas. par exemple jour_de_la_semaine(int jour, int heure, int seconde)
, on ne peut pas spécifier à la compilation que le premier int n’est valide qu’entre 0 et 365, le deuxième int seulement entre 0 et 24, et le troiseme seulement entre 0 et 6, sauf au runtime et par des assert. En typescript, on pourrait écrire
type jour = 0 | 1 | 2 | 3 …
En C il faut utiliser un méta langage comme Frama-C, mais cela dépasse le limite de ce cours.
Réclamation de resources
Il existe atexit en C, mais si on veut libérer beaucoup de ressources avant la sortie d’un programme, par exemple dans un jeu vidéo où l’on veut changer de niveau, on ne va pas sortir du programme puis le redémarrer pour charger le niveau suivant. A cette fin Le C++ permet le RAII, ou RRID, et est mieux adapté pour ce qui est de jeux vidéo, ou des programmes permettant plusieurs sessions d’utilisation de ressources (GUI).
Faire un getter
union connection {
int connected;
struct socket {
size_t ptr * = NULL;
};
}
Comment fonctionne _Bool dans ce cas.
Faire des interfaces
int toto(int, char[], long) {
puts("toto");
}
int tata(int, char[], float) {
puts("tata");
}
int interface(int, char[], long) [10] ;
interfaces[0] = toto; /* ok */
interfaces[1] = tata; /* fail */
Coroutine avec variables statiques
Une fonction peut travailler sur un objet externe à elle qui conserve l’état du traitement.
Des variables statiques peuvent egalement conserver l’étay du traitement.
Une vraie réentrée dans une fonction peut être obtenue avec setjmp et lngjmp mais leur usage est déconseillé dans des programmes portables.
Difference bool et int (conversions implicites)
Il n’existait pas dans le langage C, jusqu’à la norme de 99, de type booléen. On utilisait à cette fin le type entier où dans les tests booléens, 0 vaut faux et toute valeur est vraie.
Toute valeur booléenne non nulle est égale à une autre valeur booléenne non nulle. C’est à dire
#include <stdio.h>
int main(void)
{
puts(2 == 4 ? "vrai" : "faux"); /* affiche "faux" */
puts((_Bool)2 == (_Bool)4 ? "vrai" : "faux"); /* affiche "vrai", à partir du c99. */
/* En ANSI C, le comportement attendu peut être obtenu avec && */
puts(2 && 4 ? "vrai" : "faux"); /* affiche "vrai" dans tous les cas. */
return 0;
}
Fonction polymorphique
Une fonction qui trouve le maximum dans des types divers on utiliserait une foncition generique, en on peut definir la taille du saut en
- amont max_tableau(addresse debut, addresse fin, taille_de saut)
- a l’interieur meme de la fonction max_tableau(addresse debut, addresse fin) -> on determine la taille de saut a l’interieur de la fonction
- en utilisant _Generic et une macro
Espaces de noms
Le C a 4 espaces de noms. Le bloc La fonction Les structures, unions et enums. Les membres desdites structures et unions.
Ce dernier espace a été ajouté dans le c99, ce qui fait qu’on peut écrire
struct toto {
int toto;
}
Ou dans l’ANSI C il fallait trouver un nom différent car ceux ci etaient dans le meme espace de noms.
struct toto_s {
int tt_toto;
}
Les pointeurs
Un pointeur est une variable qui contient une adresse d’une autre variable. On peut par exemple la faire pointer sur un élément quelconque d’un tableau puis la faire se déplacer le long de celle ci pour parcourir tous les éléments de celui ci. On peut en réalité faire pointer un pointeur sur n’importe quel endroit de la mémoire, ce qui les rend tout aussi puissants que dangereux.
Un pointeur a une taille fixée par le système d’exploitation, un système 16 bits créera des pointeurs de 16 bits (type équivalent de short int
) et ne pourra adresser que 2^16, soient 65 octets, un système d’exploitation 32 bits créera des pointeurs de 4 octets (type équivalent à long int
) et pourra adresser 2^32 octets, soient plus de 4 milliards d’octets, soit 4 giga-octets de RAM. Aujourd’hui la norme est d’allouer 64 bits pour chaque pointeur, ce qui est suffisant pour adresser plus de 16 000 téraoctets de mémoire vive.
Un pointeur peut être typé ou non. Lorsqu’il est typé il est possible de le modifier via une arithmétique particulière. Si il n’est pas typé il faut alors le transtyper à chaque fois qu’on veut s’en servir.
L’exemple suivant déclare une variable, puis un pointeur qui pointe sur celle ci puis un pointeur non typé.
int base = 10;
int * pointeur = &base;
void * non_type = (void*)0;
On voit qu’un pointeur se distingue d’une variable normale par la présence d’une étoile à gauche de son nom. On voit également que & est un opérateur qui produit l’adresse d’une variable, on peut également dire que l’opérateur & créé un pointeur vers son opérande.
La premiere chose que je fais quand je lis un nouveau cours ou livre sur le C est de lire le chapitre sur les pointeurs. C’est un des rares aspects du C qui lui est propre, dont les langages qui s’en sont inspirés n’ont pas adopté. se distingue des autres langages et qui demande une expertise particulière à celui qui veut l’utiliser. Tous les langages qui se sont inspirés du C se sont accordés sur le fait que les pointeurs sont une mauvaise idée et qu’il fallait une autre manière de les gérer.
sur cet aspect que le C se distingue des autres langages, y compris de ceux qui s’en sont inspirés. L’argument du C++ ou du Java par exemple est de ne pas avoir de pointeurs par exemple.
Quand je lis un cours sur le langage C, la première chose que je fais est de lire le chapitre sur les pointeurs, c’est l’aspect le plus unique du langage C, et un des plus décriés : tous les langages qui se sont inspirés du C se vantent de ne pas avoir de pointeurs, ou de proposer une meilleure façon de traiter les problèmes qui sont traités avec les pointeurs en C.
- Quel est l’intérêt d’un pointeur ?
- Peut-on faire sans ? Passage de structures par valeur
- Comment déclarer un pointeur ?
- Comment un pointeur apparait-il spontanément ?
- Comment déréférencer un pointer ? Quels risques de déréférencer une adresse invalide ?
- Pointeurs sur des tableaux, structures, fonctions
- Choses que l’on ne peut pointer ? register, inline, objets qui n’existent pas au runtime
Passerelle entre le programme et la mémoire
Le C fournit une passerelle vers la mémoire de travail de l’ordinateur. Par cela on entend la mémoire à accès rapide. Il n’y a pas vraiment de précision sur ce que doit être cette mémoire mais il est dans l’intérêt du constructeur de l’ordinateur qu’elle soit rapide. De la mémoire la plus lente citons la mémoire cloud, la mémoire du disque dur, d’une mémoire flash type clé USB, la mémoire vive, les multiples mémoire cache entre le disque dur et le processeur, enfin les registres du processeur. A coté de cela, les cartes matérielles (carte graphique, carte son, etc.) possèdent également une mémoire.
Pour abstraire cette variété de mémoires le C requiert une machine virtuelle pour fonctionner, c’est au compilateur de faire que le programme s’exécute le plus rapidement possible selon le souhait du programmeur.
Généralités
Il n’y a pas un seul type de pointeur, dans le C, mais deux. Le premier concerne un objet que l’on créé pour référencer (on dit “pointer sur” dans le C) un autre objet, soit une variable (valeur scalaire, tableau, élément d’un tableau, structure, élément d’une structure), soit une fonction. La syntaxe pour créér un ponteur est la suivante :
int * pointeur;
Notez l’étoile qui signifie que “pointeur” pointe sur un endroit de la mémoire qui doit être interprété comme un entier. Les pointeur héritent de la garantie du typage du C. Nous verrons plus tard qu’un pointeur spécial, le pointeur sur void (void *
) peut outre-passer cette contrainte et pointer sur n’importe quel type d’objet, pour peu qu’il reste dans la même catégorie (un pointeur sur fonction doit continuer à pointer sur une fonction).
Pour décider sur quel objet pointer, on utilise l’opérateur &
address-of avant l’objet.
int mon_entier = 10;
int mon_tableau[10] = {};
struct ma_structure {
int un_entier;
char un_caractere;
} ma_structure = {
12, 'a'
}
int * mon_pointeur;
mon_pointeur = &mon_entier;
mon_pointeur = &mon_tableau[5];
mon_pointeur = &ma_structure.un_entier;
A quoi sert de pointer sur une autre variable si on a déjà cette variable : autant utiliser la variable d’origine. Ils peuvent donner un sens différent à certains emplacements de la mémoire. Par exemple dans un tableau d’entier, un pointeur peut s’appeler int * plus grand_entier
, et pointer sur l’endroit du tableau qui pointe sur la plus grande valeur.
int tableau[10] = {1, 2, 3, 4, 3, 6, 7, 1, 13, 40};
int * plus_grand_entier;
int plus_grande_valeur = 0;
for (int i = 0; i < 10; i++) {
if (tableau[i] > plus_grande_valeur) {
plus_grand_entier = &tableau[i];
plus_grande_valeur = tableau[i];
}
}
A la fin de cet algorithme, on peut utiliser le pointeur plus_grand_entier
pour connaitre immédiatement l’endroit qui contient la plus grande valeur du tableau, plutôt qu’avoir à le recalculer chaque fois. Le pointeur sert donc à donner de la sémantique à certains endroits de la mémoire du programme.
L’autre intérêt de ce type de pointeur est que c’est une variable et peut donc varier, pointer sur différentes choses au cours du programme. Par exemple un pointeur de fonction, peut pointer sur différents fonctions selon ce que l’on veut que notre programme exécute. Le pointeur sert ici à posséder une interface unique pour appeler différents endroits du programme.
Ces types de pointeurs occupent un emplacement de la mémoire qui leur est dédié, en sus des autres objets du programmes, est sont traités comme n’importe quel objet.
Le deuxième type de pointeur est celui que le C génère automatiquement, dans certains contextes. Lorsque le C invoque une fonction, les paramètres travaillent sur une copie des objets qui lui sont fournis. Ils sont donc dupliqués. Maintenant imaginons que nous voulons passer un tableau à une fonction, cela voudra dire que pour un tableau de 10 éléments, à chaque invocations de la fonction, ce tableau doit être recopié, pareil pour un tableau de 100 éléments, et ainsi de suite. Pour une fonction qu’on appelle souvent, cela engendre des centaines de copies et un ralentissement exponentiel du programme, pour dix itérations, un tableau qui contient 1 élément de plus, fera que le programme s’exécutera dix fois plus lentement. Pour éviter des copies d’objets coûteuses.
Un pointeur contient une adresse de la mémoire de travail du programme. On considère que cette mémoire se trouve principalement dans la mémoire vive, dans les registres du processeur et dans les caches intermédiaires (L1, L2, L3…). Si depuis le C99 on peut préciser qu’on souhaite travailler avec les registres du processeur avec le mot-clé register
, il n’est pas possible de cibler la mémoire cache, d’y lire ou d’y écrire quoi que ce soit.
La configuration des registres processeurs (16, 32, 126 bits…), et la configuration de la mémoire vive (une barrette, deux barrettes…) varient grandement entre les compatibles PC et il n’est pas possible de cibler un registre précis (AX, EAX…), ni de cibler une barrette. Plutôt que d’avoir des pointeurs qui prennent en compte les particularités de chaque machine, par exemple struct pointeur {int numero_barrette; int offset; int taille_objet;}
, le C, à des fins de portabilité, abstrait la mémoire en une plage de valeurs entières positives, qui débute à 0, et se termine à la déplétion de la mémoire, sans interruptions. On peut imaginer qu’avec ce système il soit donc possible d’avoir un objet qui commence sur une barrette, et se poursuit sur une autre, cela est invisible pour le programmeur.
Attention, même si les césures matérielles sont invisibles, la configuration en mémoire des objets est prévisible : il n’y a pas de fragmentation ou de placement à des endroits aléatoires de la mémoire ; les objets sont empilés les uns à la suite des autres, avec du bourrage éventuel, dans un ordre indéfini, afin de permettre des accès rapides en lecture et en écriture. Dans les tableaux que nous verrons plus loin, chaque objet de la mémoire sera contigu avec ses voisins, sans bourrage ni espacement ; dans les structures les différents éléments seront placés en mémoire dans l’ordre où ils sont déclarés, avec du bourrage (padding) éventuel. L’inconvénient de cela est qu’il n’est pas possible d’accroître un objet après sa création, c’est à dire d’ajouter des éléments à un tableau, des champs à une structure… Pour avoir des tableaux croissants avec de la marge autour qui permette un accroissement en cours de fonctionnement du programme, il faut utiliser malloc
, que nous verrons plus tard. Rien dans le C ne permet de rajouter des champs à une structure après sa déclaration toutefois.
Valeur entière
Une adresse mémoire en C est donc représentée comme une valeur entière valant entre zéro et une grande valeur, et un pointeur qui représente cette adresse contient une de ces valeurs. A ce titre le C autorise la conversion d’un pointeur vers un entier non signé, et d’un entier non signé vers un pointeur. C’est surtout utile dans l’embarqué où les adresses des différents constituants avec lesquels que le programme veut dialoguer sont connus. Ansi dans les exemples ci-dessous on initialise des pointeurs avec des valeurs entières connus, fournis par la fabriquant de la machine.
#define addresse_photorecepteur 10
#define addresse_klaxon 30
volatile int * photorecepteur1 = addresse_photorecepteur1;
volatile int * klaxon = addresse_photorecepteur;
int main() {
printf("Valeur photorecepteur, %d", *photorecepteur);
printf("Attention aux oreilles, on active le klaxon");
klaxon = 1;
}
Notez l’astérisque entre le nom du pointeur et le type de la valeur pointée. Elle sert à indiquer que photorecepteur1
est un pointeur.
Address of
On peut prendre d’addresse d’un objet en mémoire.
Hormis dans la programmation système avec du matériel dont on connait la configuration mémoire (embarqué), il est peu probable que la valeur d’un pointeur ait de l’importance et qu’on veuille connaitre sa valeur entière ou lui en assigner une. Dans les programmes hébergés (exécutables) nous n’utiliserons que des pointeurs sur des objets créés auparavant par le programme. A cette fin le C permet de récupérer l’adresse de n’importe quel objet (tant qu’il n’est pas déclaré comme variable register
ou qu’elle soit une fonction déclarée inline
) avec l’opérateur &
(address of) qu’on accole avant le nom de l’objet dont on veut connaitre l’adresse.
Voici un programme qui récupère les adresses de divers objets et en affiche la valeur :
int main() {
int toto = 42;
int * pointeur_sur_toto = &toto;
}
Dans ce programme, plutôt que d’initialiser le pointeur avec une valeur entière arbitraire, nous avons utilisé l’adresse de la variable toto obtenue avec l’opérateur &.
D’autres objets ont également des adresses que l’on peut réemployer :
void une_fonction() {
return;
}
int toto_statique = 42;
int main() {
int toto = 42;
printf("Addresse fonction: %p\n", &une_fonction);
printf("Addresse statique: %p\n", &toto_statique);
printf("Addresse chaine: %p\n", &"Une chaine de caractères");
printf("Addresse toto: %p\n", &toto);
}
Les variables de préprocesseur ne sont pas considérés comme des objets et n’ont pas d’adresse, de même que pour les éléments d’une énumération ou que l’énumération elle même.
Différence entre address of
d’un membre et offsetof()
Certains compilateur produisent un warning lorsqu’on veut prendre l’adresse d’un membre d’une structure non paquée, dont l’alignement n’est pas spécifiée. De même utiliser offsetoff empêche certaines optimisations. Jouer avec des membres d’une structure n’est généralement pas conseillé et produit des programmes sous performants.
Durée de vie des pointeurs
Les pointeurs existent dans 3 états: valides, nuls et indeterminés.
Un pointeur assigné la valeur zero est nul, par conséquent un test bolléen sur un pointeur nul ne se vérifiera jamais, quelle que soit l’implémentation.
Un pointeur valide pointe sur un objet qui est dans sa durée de vie (lifetime). Un objet valide possède une addresse fournie par l’environnement où s’exécute le programme.
On utilise la syntaxe suivante
int a;
int *b = &a;
Un pointeur indéterminé ne réference pas un objet valide.
int *a; /* indéterminé */
int *b = 0; /* pointeur nul */
{
int c;
b = &c; /* b devient un pointeur valide après l'exécution de cette expression d'assignement */
}
/* b devient un pointeur indéterminé après l'exécution de la sortie du bloc */
Attention: un programme déréférencant un pointeur indéterminé a un comportement indéterminé.
Leur contenu est opaque et varie selon les architectures et il ne faut pas s’en soucier. Le C abstrait les implémentations mémoire en une plage de valeurs entières qui réference une addresse virtualle d’une plage qui s’étend sur toute la mémoire vive. On peut par conséquent les convertir vers une valeur entière (cast en int) et vice versa. C’est une des facette de la portabilité du C.
Le programmeur n’a pas à se soucier quel composant de la memoire vive cibler, ou si des données chevauchent plusieurs composants ou si son programme est exécuté dans une machine virtuelle. Il n’est pas possible de cibler les memoires cache du processeur (L1, L2…), on peut cependant travailler avec les registres du processeur, avec le mot clé register
, par exemple register int a;
, mais il n’est pas possible d’avoir de pointeur dessus, c’est leur seul interet (éviter d’avoir des références volantes et potentiellement invalides) car il n’est pas garanti que des valeur declarées register soient effectivement stockées dans le registres du processeur et que le programme soit plus rapide.
Astuces:
- Toujours initialiser ses pointeurs.
- Eviter de pointer sur des valeurs d’une portée et d’une durée de vie differente des leurs.
- Si on ne peut l’éviter, leur assigner explicitement la valeur 0, ou NULL, si stdlib a été incluse, pour éviter une source de comportement indéterminé.
Un pointeur peut prendre l’addresse d’une variable scalaire, d’un tableau (et d’une chaîne de caractères), d’une structure, d’une fonction, mais pas d’une variable de type register.
int scalaire;
int \*pointeur\_scalaire = &scalaire;
\*pointeur\_scalaire = 10;
int tableau\[10\];
int \*pointeur\_tableau = tableau; /\* implicite \*/
\*(pointeur\_tableau + 4) = 10;
struct toto {
int tata;
} titi;
struct toto \*pointeur\_struct = &titi;
(\*pointeur\_struct).tata = 10;
int toto(int val) {
return val \* 2;
}
int (\* pointeur\_fonction)(int) = toto;
(\*pointeur\_fonction)(5) /\* 10 \*/
Pointeur générique
void *
sert de pointeur générique. Jusqu’en C90, char *
était utilisé à cette fin.
On peut fournir à un pointeur void l’addresse de tout type d’objet sans avoir à le cast, et prendre l’addresse d’un pointeur de type void at l’attribuer à tout type d’objet sans avoir à le cast. Même si il est générique, il ne pointe pas sur n’importe quoi, void *
, void ***
ne sont pas synonymes.
Pointeur NULL
C’est un pointeur crée automatiquement dans tous les programmes en C, sans intervention de l’utilisateur, comme singleton. Sa valeur est opaque mais quand on l’utilise comme opérande dans une fonction de comparaison sa valeur est zero et booleennement fausse.
Exercice
Faites un programme qui affiche la valeur de plusieurs pointeurs sur
- des fonctions internes au fichier en cours,
- des fonctions situées dans des fichiers externes (stdio)
- des fonctions dites intrinsèques de la librairie math.h (comme fmod)
- des objets créés dans diverses fonctions
- des objets crées à l’exterieur (static et extern)
- des objets static créés à l’intérieur d’une fonction ou pointant sur un objet extern
- des chaines de caractères
- des objets créés via malloc
Estimez les deltas et la configuration générale des divers objets manipulés. Lancez deux instances de ce programme, les fonctions partagées de la stdio sont elles situées au même endroit pour chaque instance ? Estimez la quantité de mémoire totale utilisée par votre programme. Augmenter la taille des objets statiques augmente t-il la taille du programme ?
Definir un tableau
Int[] = { 1, [50] = 1, [100] = 1};
On ne peut pas faire ca
char a[10];
a = "Hello";
Fonction prototype
La fonction main ne necessite pas de prototype.
6.9.1.13
Passer un bitfield en argument de fonction
Fun(1, &{.foo=1,.bar=1})
Quand mettre extern et static
gdb et valgrind
Include variable
- include pp-tokens
Rendre iso646 plus explicite
- define MULTICHAR <iso646.h>
- include MULTICHAR
Solidifier son code
#define LONG 5
#define CAT(a, b) a##b
#define P10(exp) (int)CAT(1e,exp)
foo = P10(LONG) /\* 100000 \*/
Verifier MAX_SIZE
Multiprocessing
Avec python
A quel moment les implementation ne respectent pas le standart
GCC permet d’initialiser des entiers avec la notation de caractère simple. Par exemple int toto = 'toto'
. Sachant qu’un caractère tient sur un seul octet et un entier en contient 4, le compilateur l’accepte.
Gcc permet de declarer des fonctions a l’intérieur d’autres, plutot un namespace que lambda.
Differences avec le c++
c++
if (int a = 2)
c
seulement une expression.
Malloc peut retourner n’importe quoi.
Les constexpr countournent les limitations des evaluations compile time.
Difference avec le python
Do/While, en C, continue
saute bien au bloc de controle. L emulation du do while du python ne le fait pas.
Les blocs while, try admettent un bloc else en python.
Les regles de portee sont tres differentes.
Les fonctions peuvent etre imbriquées.
Hashtable (dans initiations?)
Utiliser TOUTES les librairies standart
Maitriser pleinement printf et scanf
Restrict, register
Premier favorise le parralelisme, second cible la memoire de travail. En principe non utiles.
Register empeche de prendre l’addresse d’une variable, assurant l’unicité de son propriétaire, de la même façon que const assure qu’on ne modifiera pas la variable. Peut servir de garde-fou.
Un bloc if/for, etc a une portée à lui
Si bien que
int truc() { return 2; }
int a = 1;
if(int a = truc())
a vaudra 1 ou 2 selon qu’on soit en c90 ou c 99
Et le bloc eventuel a l’intérieur du if a également une portée à lui
Les blocs if peuvent etre imbriqués, contrairement aux blocs fonction. Les struct peuvent etre dans des blocs, mais pas de declarations de fonctions.
Le statement qui correspond au if a une portée de bloc, même si ce n’est pas un compound statement.
if(1) int a;
/* a est hors portée et on ne peut pas le référencer */
Pour acceder a une portée supérieure, on utilise extern.
On ne peut pas declarer une variable dans un statement if, contrairement a for
if(int a = 1) /* non valide */
for(int a = 0;..) /* valide */
break
Break ne sort pas du bloc, mais du bloc implicite de l’instruction à laquelle un break peut s’appliquer, dans le cas de blocs enchevêtrés, de switch avec blocs, etc. Imaginer ce que fait continue
, et break
dans un bloc switch englobé par une boucle while
programme imperatif
Statements if, for… Leurs pendants fonctionnels ?:, la recursion basique (prototypes).., les foncteurs.
if(nbJoueur <= 4) {
nbJoueur = 4;
} else if(nbJoueur <= 8) {
nbJoueur = 8;
} else if(nbJoueur <= 12) {
nbJoueur = 12;
} else if(nbJouer <= 16) {
nbJoueur = 16;
} else if(nbJoueur <= 20) {
nbJoueur = 20;
} else if(nbJoueur <= 24) {
nbJoueur = 24;
} else if(nbJouer <= 28) {
nbJoueur = 28;
} else if(nbJoueur <= 32) {
nbJoueur = 32;
} else if(nbJoueur <= 36) {
nbJoueur = 36;
} else if(nbJouer <= 40) {
nbJoueur = 40;
} else if(nbJoueur <= 44) {
nbJoueur = 44;
} else if(nbJoueur <= 48) {
nbJoueur = 48;
} else if(nbJouer <= 52) {
nbJoueur = 52;
}
Contre
clamp = val => val - val % 4;
Flux
En python les variables ne disparaissent pas à la sortie de leur bloc.
En python les fonctions retournent automatiquement, en C c’est un comportement indefini lorsque la fonction doit retourner une valeur, en renpy, le flux du programme continue.
Dans quelle condition la derniere ligne s’exécute?
function valid_gauth_payload(payload: TokenPayload): boolean {
if (!'https://accounts.google.com').includes(payload.iss) {
console.warn('iss invalid');
} else if (payload.exp \* 1000 < Date.now()) {
console.warn('token expired');
} else if (payload.email\_verified === false) {
console.warn('email has not been verified');
} else {
return true;
}
return false;
}
Comportement non explicite
un deuxieme argument vide dans for est remplacé par true __func__ defini pour chaque fonction.
préprocesseur
Compatibilité avec le K&R C
En 2021, un compilateur peut compiler du code C écrit il y a 40 ans. Pour détecter cet ancien C, la directive __STDC__ n’est pas définie.
Dans ce vieux C, les protoypes de fonction peuvent ne pas contenir d’argument, seul dans l’implémentation on trouve le type.
Les objets avaient 8 caractère significatifs, si bien que ma_fonction1
et ma_fonction2
désignaient le même endroit en mémoire.
La manière de définir des fonctions variadiques telles que printf serait considéré aujourd’hui comme un comportement non défini.
Exercices
Quelle est la valeur de !0
Faire une fonction qui prenne un tableau de tableaux de type type/contenu, ainsi qu’une longueur de texte totale. Tronquer le texte à partir du moment où la limite et franchie, et vider tous les textes de type texte suivants.