Au chapitre C4‑I , on a vu que la décomposition en fonctions d'un programme était la base de la programmation procédurale. Mais lorsque un programme dépasse quelques centaines de ligne (ce qui est très peu), sa conception devient déjà mal aisée s'il est codé sur un seul fichier. En effet, même avec les possibilités avancées de recherche des meilleurs éditeurs de code, faire des allers‑retours entre diverses parties éloignées du fichier devient fastidieux, chronophage

C'est pourquoi les langages C et C++ sont conçus pour permettre de répartir sur plusieurs fichiers le code source d'un programme. De cette manière, même avec un simple éditeur de code, les accès rapides aux différentes fonctions d'un programme sont facilités : via le panneau de navigation et/ou les onglets affichés dans la fenêtre d'édition, il suffit d'ouvrir le fichier dans lequel une fonction est codée pour la trouver rapidement, dès lors que ce fichier reste de taille raisonnable. Cela suppose bien entendu de savoir dans quel fichier est codée la fonction – et c'est tout l'art de la répartition du code.

Plus généralement, la programmation multi‑fichiers présente d'autres intérêts déterminants.

  • Lors de la mise au point du programme, on peut faire en sorte que seuls les fichiers modifiés soient recompilés, ce qui procure un gain de temps d'autant plus de important que le programme est grand. Les IDE modernes comme VS Code sont capables de mettre en œuvre une compilation intelligente en examinant les dates de modification des fichiers objets pour ne pas avoir à recompiler ceux qui demeurent inchangés depuis la dernière compilation.
  • À terme, dans une perspective de réutilisation, certains fichiers peuvent être compilés pour former des modules de bibliothèque. Stockés dans un répertoire connu du système, les fichiers objets de ces modules sont alors directement disponibles pour l'édition de liens (cf. chap. C4‑IV ) de tout programme qui ferait appel à ces modules.

Mais la programmation multi‑fichiers n'est pas toujours simple à mettre en œuvre. En plus d'une connaissance suffisante du système d'exploitation de la machine et de l'environnement de programmation, elle requiert un savoir‑faire technique en codage, notamment pour la répartition du code sur les différents fichiers. De plus, pour maîtriser l'interdépendance des modules et faire en sorte que la compilation se déroule sans erreurs, il faut :

  • comprendre où placer les déclarations des types, variables, prototypes des fonctions et leurs définitions ;
  • et par précaution, toujours coder une pseudo‑constante et une directive de compilation conditionnelle afin de prévenir toute possibilité de double inclusion d'un fichier (cf. chap. C4‑III ).

L'objectif de ce chapitre est donc d'apporter les connaissances de base indispensables pour pouvoir ensuite acquérir ce savoir‑faire dans la pratique. On abordera dans l'ordre les parties suivantes :

  • On commencera par le cas général de compilation native en langage C (les mêmes principes valent en C++). On illustrera l'étude avec l'exemple concret d'un programme académique de calendrier s'exécutant sur un terminal de commandes en ligne, compilé avec GCC sous Linux.
  • On verra également comment les commandes de compilation et de génération du code exécutable peuvent être facilement prises en charge par des tâches sous VS Code.
  • On poursuivra avec cas plus spécifique de compilation croisée d'un programme Arduino, pour lequel on traitera l'exemple concret un programme académique de clignotement d'une led à fréquences multiples. Même si ce programme est encore une fois élaboré sur un poste de travail à système Linux, le processus est assez similaire avec ce que l'on peut observer sous Windows.

Cas général d'un programme en langage C/C++

Sans entrer dans le cas beaucoup plus complexe des gros logiciels, on se propose de décrire le principe général de répartition sur plusieurs fichiers du code source d'un programme.

Pour fixer les idées, on imagine d'abord un exemple générique nommé simpleProgram. Habituellement, on crée un répertoire de projet homonyme – c'est‑à‑dire portant le même nom que le programme, même si ce n'est pas une contrainte impérative – dans lequel seront stockés tous les fichiers sources (cf. chap. C2‑I ).

Structure modulaire d'un code source en langage C

En langage C, une méthode générale de répartition du code source sur plusieurs fichiers est basée sur une structure en modules, avec typiquement :

  • un module principal où est codé la fonction main ;
  • des modules auxiliaires pour répartir les autres fonctions.

En règle générale, chaque module est constitué de 2 fichiers source, de préférence homonymes pour expliciter l'appartenance au même module :

  • un fichier d'en‑tête d'extension .h (pour header en anglais) ;
  • un fichier d'implémentation d'extension .c.

Tous les fichiers sources d'un programme sont liés entre eux par diverses directives d'inclusion, typiquement comme représenté sur la figure ci‑dessus et détaillées plus loin. En particulier, chaque fichier d'implémentation comporte une directive d'inclusion de son fichier d'en‑tête associé pour former une unité de compilation.

Il peut également exister d'autres liaisons, notamment celles relatives aux variables globales, via le mot‑clef extern (cf. infra ).

Toutefois, il importe de souligner que la méthode générale présentée ci‑dessus peut se décliner avec diverses variantes :

  • Si le fichier d'en‑tête principal se limite à quelques directives d'inclusion, alors il peut être fusionné avec le fichier d'implémentation principal, conformément à la figure ci‑contre.
  • En effet, ce ne sont pas quelques lignes de code supplémentaires en préambule du fichier principal d'implémentation qui vont diminuer significativement la commodité d'accès à la fonction main.
    En revanche, si le codage du programme nécessite la déclaration de types qui sont utilisés dans d'autres modules, alors le recours à un fichier d'en‑tête principal est incontournable.
  • Par ailleurs, lorsque les fichiers d'en‑tête sont peu volumineux, il est envisageable de les fusionner en un seul : celui du module principal. Chaque module auxiliaire est alors constitué de son seul fichier d'implémentation, qui commence par une directive d'inclusion de l'unique fichier d'en‑tête, conformément à la figure ci‑dessous.
  • Mais dans ce cas, il n'est pas possible de réutiliser individuellement chaque module dans un autre projet sans l'encombrer avec des éléments de code inutiles.
  • Enfin, lorsque le programme comporte un module volumineux, il est possible de répartir son code sur plusieurs fichiers d'implémentation tout en conservant un seul fichier d'en‑tête, conformément à la figure ci‑contre.
  • Encore une fois, le but est de travailler sur des fichiers de taille raisonnable pour ne pas perdre de temps à les parcourir.

Comme expliqué au chap. C2‑I, le nommage main.c du fichier principal d'implémentation est une convention, pas une obligation.

Spécificités en langage C++

En langage C++, on adopte la même méthode générale de répartition du code source d'un programme sur plusieurs fichiers, avec seulement quelques spécificités pour les extensions de fichiers. Typiquement, on adopte :

  • l'extension .hh pour les fichiers d'en‑tête ;
  • l'extension .cpp pour les fichiers d'implémentation.

Ces aspects ont déjà été présentés plus en détail au chap. C4‑IV .

Principes de la répartition en fichiers

La structuration d'un programme en plusieurs modules est une tâche complexe, qui en général débouche sur diverses solutions. Dès qu'un programme devient volumineux, on aboutit à des conflits de classification inévitables. Typiquement se pose la question : dans quel module coder telle ou telle fonction ?

En général, au moins deux approches sont possibles.

  • Au regard des spécifications auquel le programme doit répondre, on structure le code en grandes problématiques fonctionnelles : typiquement, le dialogue homme‑machine, les calculs techniques (le code dit « métier »), les commandes des actionneurs, les acquisitions par capteurs, les communications diverses, etc.
  • Mais du point de vue des techniques d'implémentation, on est plutôt amené à structurer le code avec une logique d'objets : chaque module spécifique met en œuvre des variables d'une ou plusieurs classes spécifiques qui définissent toutes les fonctions pour manipuler ces objets.

Et bien entendu, ces deux approches peuvent être employées conjointement à différents niveaux de raffinement du code : ainsi, il est usuel d'adopter une logique d'objets dans chaque grande problématique fonctionnelle.

Les fichiers d'en‑tête

En principe, un fichier d'en‑tête – usuellement nommé avec l'extension .h en C ou .hpp en C++ – sert à coder, séparément du fichier d'implémentation, les déclarations publiques de types, de constantes et de fonctions (prototypes seuls) utilisées dans un module.

Le fichier peut également contenir des directives d'inclusion de fichiers, de définitions (pseudo‑constantes, pseudo‑fonctions…), de compilation conditionnelle, etc.

Ne contenant pas de code source algorithmique, ce fichier peut en principe être divulgué à d'éventuels partenaires de développement sans compromettre des aspects potentiellement confidentiels. C'est pourquoi on parle de fichier d'interface W.

Les déclarations codées dans un fichier d'en‑tête sont dites « publiques » dans la mesure où elles sont exploitables dans n'importe quel fichier source qui inclut ce fichier d'en‑tête par une directive #include.

A contrario, toutes les déclarations qui sont codées dans un fichier d'implémentation sont a priori exploitables seulement dans ce dernier. On peut donc considérer qu'elles sont « privées » (on verra toutefois que cette limitation peut aisément être contournée dans le cas des variables).

En règle générale, un fichier d'en‑tête n'est pas employé pour coder des instructions programmant des actions (même si cette pratique existe), ni des déclarations de variables globales (au risque sinon d'erreurs de compilation).

Considérons notre exemple générique d'un programme dont le fichier d'en‑tête principal est nommé simpleProgram.h. Comme expliqué au chapitre C4‑III , il doit avoir son contenu déclaratif en totalité protégé contre les doubles inclusions par une directive de compilation conditionnelle de la forme :

#ifndef SIMPLE_PROGRAM_H_INCLUDED
#define SIMPLE_PROGRAM_H_INCLUDED

// code protected against double inclusions

#endif

Après cette directive de protection, le fichier d'en‑tête commence par des directives d'inclusion de tous les fichiers d'en‑tête des modules du programme (sauf lui‑même, bien entendu), ainsi que des fichiers d'en‑tête de bibliothèques. Par exemple, on peut avoir des directives de la forme suivante :

#include "module1.h"
#include "module2.h"
#include "module3.h"

#include <stdio.h>
// ...

Par ailleurs, il est possible que certains fichiers d'en‑tête auxiliaire .h commencent par la directive d'inclusion : 
#include "simpleProgram.h"
afin de pouvoir exploiter les déclarations codées dans le fichier simpleProgram.h. Et comme le fichier d'en‑tête principal inclut lui‑même les fichiers d'en‑tête des modules auxiliaires, la protection contre les doubles inclusions empêche que ses propres déclarations lui soient incluses en retour.

Les fichiers d'implémentation

Un fichier d'implémentation, usuellement d'extension .c ou .cpp en C++, est un fichier dans lequel on code essentiellement la définition des fonctions déclarées dans le fichier d'en‑tête qui lui est associé.

Souvent, on parle spécifiquement de « fichier source » car c'est là que l'on trouve l'essentiel du code (les algorithmes mis en œuvre). Néanmoins, il s'agit d'un abus de langage, car en termes de compilation, un fichier d'en‑tête est aussi un fichier source – par opposition à un fichier objet ou un fichier exécutable (cf. chap. C4‑IV .

Dans le fichier d'implémentation principal – typiquement nommé avec l'extension main.c (ou .cpp) – on trouve en général :

  • la directive d'inclusion du fichier d'en‑tête principal du programme ;
  • la déclaration des variables globales du programme ;
  • la définition de la fonction principale main qui, idéalement, devrait ne pas dépasser une page de moniteur pour que son algorithme soit simple à visualiser dans son ensemble. Dans cet objectif, la fonction main fait appel à de nombreuses fonctions de haut niveau définies dans les fichiers d'implémentation des différents modules auxiliaires.

Par ailleurs, chaque fichier d'implémentation auxiliaire comprend :

  • la directive d'inclusion de son fichier d'en‑tête associé .h, en principe de même nom ;
  • éventuellement des déclarations de données globales utilisées spécifiquement dans le fichier d'implémentation, chacune étant :
    • précédée du mot‑clef extern, si cette donnée est déjà déclarée dans un autre fichier d'implémentation ;
    • précédée du mot‑clef static, si cette donnée doit rester locale au fichier d'implémentation (autrement dit que le codeur veut en interdire l'usage dans les autres fichiers par une déclaration avec le mot‑clef extern).
  • la définition de toutes les fonctions dont les prototypes sont déclarés dans le fichier d'en‑tête associé au fichier d'implémentation.

Organisation du répertoire de projet

On rappelle que pour les petits programmes (cf. chap. C1‑II  :

  • le développement peut très bien être mené à l'aide d'éditeur de code (Sublime Text ou autre…) ;
  • la compilation – au sens large, c'est‑à‑dire avec l'assemblage et l'édition de liens – étant effectuée dans un terminal de commandes en ligne.

Dans un tel cas, l'organisation des fichiers source est totalement libre dès lors que leurs chemins d'accès respectifs sont saisis dans la commande de compilation. Toutefois, il est évidemment plus simple – et cela est vivement recommandé – de placer a minima tous les fichiers du programme dans ce qu'on appelle un répertoire de projet (cf. chap. C2‑I ).

De plus, même si le programme comporte peu de fichiers source, il est rapidement malcommode de ranger au même niveau tous les fichiers, quels que soient leurs types respectives. La bonne pratique la plus courante consiste à les placer dans des sous‑répertoires spécifiques, nommées usuellement comme ci‑dessous :

  • include pour les fichiers d'en‑tête ;
  • src pour les fichiers sources d'implémentation ;
  • obj pour les fichiers objets ;
  • lib pour les bibliothèques (cf. chap. C4‑VI ) ;
  • bin pour le ou les fichiers exécutables.

Bien entendu, cette organisation nécessite d'adapter les commandes de compilation et d'édition de liens, ce que l'on va voir infra.

Par ailleurs, si le programme est d'usage fréquent sur le poste de travail, alors il est plus commode de placer le fichier exécutable – ou à défaut un lien symbolique vers ce dernier – dans un répertoire connu du système d'exploitation, par exemple, sous Linux :
/home/user/.local/bin

Commande de génération du code exécutable

Dans l'hypothèse d'un programme en langage C réparti sur plusieurs modules comme exposé supra , la commande de génération du code exécutable doit impérativement cibler tous les fichiers d'implémentation à compiler pour produire le code exécutable.

Quant aux fichiers d'en‑tête, ils n'ont pas besoin d'être ciblés puisqu'ils sont inclus dans le code source par des directives au préprocesseur. Seul le chemin d'accès à leur répertoire doit être indiqué au compilateur.

Avec GCC, dans le cas d'une compilation native et sans considération d'éventuelles options (avertissements, etc.), la commande de génération du code exécutable prend donc simplement la forme suivante :

gcc -Iinclude src/fichier_1.c src/fichier_2.c -o bin/fichier exécutable

où l'option -Iinclude ajoute le répertoire local include à la liste de ceux susceptibles de contenir des fichiers d'en‑tête (cf. chap. C4‑IV ).

Avec l'usage d'un joker * (wildcard – cf. chap. S1‑III ), cette commande peut se coder encore plus rapidement :

gcc -Iinclude src/*.c -o bin/fichier exécutable

puisqu'a priori, tous les fichiers d'implémentation dans le sous‑répertoire src sont à compiler.

Conservation des fichiers objets

On rappelle (cf. chap. C4‑IV ) que par défaut, les fichiers objets sont considérés comme intermédiaires par la commande gcc et donc ne sont pas conservés après la production du fichier exécutable.

Or justement, conserver les fichiers objets de chaque module permet d'éviter d'avoir à tout recompiler à chaque modification de tel ou tel fichier source.

Pour cela, plusieurs solutions sont possibles, notamment les deux suivantes :

  • On peut compiler individuellement les fichiers sources d'implémentation à l'aide de l'option -c pour produire à chaque fois un fichier objet, puis produire le fichier exécutable par édition de liens de tous les fichiers objets préalablement créés. C'est que qu'accomplit la succession de commandes ci‑dessous :
  •   
    gcc -c -Iinclude src/fichier_1.c -o obj/fichier_1.o
    gcc -c -Iinclude src/ fichier_n.c -o obj/fichier_n.o
    gcc obj/*.o -o bin/fichier exécutable
  • On peut aussi procéder globalement en utilisant l'option -save-temps (cf. chap. C4‑IV ), sachant que de plus :
    • l'option -dumpbase permet d'imposer un préfixe aux fichiers intermédiaires différent de celui du nom du fichier exécutable – et éventuellement vide ;
    • l'option -dumpdir permet d'imposer un répertoire de destination pour les fichiers intermédiaires différent de celui du fichier exécutable ;
    • une commande supplémentaire rm *.i && rm *.o permet de supprimer les fichiers intermédiaires autres que les fichiers de code objet.
    En définitive, on peut s'en sortir avec une seule commande de la forme de celle ci‑dessous :
      
    gcc -save-temps -Iinclude src/*.c -o bin/fichier exécutable -dumpbase '' -dumpdir obj/ && rm obj/*.i obj/*.s

Après une première compilation, il est alors possible de recompiler individuellement chaque fichier d'implémentation qui a été modifié. Ensuite, pour actualiser le fichier exécutable, il suffit d'effectuer l'édition de liens de tous les fichiers objets. Dans le cas d'un gros programme, on peut ainsi économiser du temps de production de façon considérable.

Avec l'IDE VS Code

Rappel. On a découvert au chapitre C1‑II  les principes de base de l'utilisation de VS code pour la programmation en langages C/C++ lorsque le code source est formé d'un seul fichier source :

  • Lorsque le fichier source est actif dans l'éditeur de code, la génération du code exécutable peut être déclenchée par le raccourci CtrlshiftB (comme build.
  • Cette commande est définie dans le fichier tasks.json automatiquement créé par l'application dans le sous‑répertoire .vscode, lui‑même créé dans le répertoire de projet. Et ce fichier est modifiable à volonté pour adapter la commande de compilation aux besoins spécifiques du projet (invocation de telle ou telle option, etc.) .

Dans le cas d'un programme multi‑fichiers, l'application VS code n'est toutefois pas capable de créer automatiquement une commande de génération conforme aux spécificités du programme. Il revient donc au codeur d'apporter des modifications nécessaires dans le fichier tasks.json du projet.

Compte tenu des solutions élaborées précédemment, et en adoptant une approche générique, c'est‑à‑dire valable quel que soit le nom du répertoire de projet. Pour cela, il est utile de définir un espace de travail (workspace ‑ cf. chap. C1‑II ) associé au répertoire de projet

  • On configure la tâche de génération par défaut (de type « cppbuild », qui est associée au raccourci CtrlShiftB) avec la commande qui met en œuvre une compilation complète des fichiers sources (afin qu'elle soit toujours opérationnelle quelles que soient les modifications effectuées dans tel ou tel fichier) et qui effectue l'édition de liens pour produire le fichier exécutable, et qui conserve les fichiers objets.
  • On code deux nouvelle tâches :
    • l'une pour effectuer uniquement la compilation du fichier source actif (à laquelle on peut par exemple associer le raccourci AltB) – typiquement, le fichier dans lequel on vient de coder des modifications ;
    • l'autre pour effectuer l'édition de liens de tous les fichiers objets (à laquelle on peut par exemple associer le raccourci CtrlShiftAltL).

C'est justement ce que fait le code JSON proposé ci‑dessous, en remplacement complet des solutions proposées au chapitre C1‑II  pour adapter le fichier tasks.json  :

{
  "tasks": [
    {
      "type": "cppbuild",
      "label": "C/C++: gcc générer l'exécutable",
      "command": "/usr/bin/gcc",
      "args": [
        "-fdiagnostics-color=always",
        "-Wall",
        "-Wextra",
        "-Werror",
        "-save-temps",
        "-I${workspaceFolder}/include",
        "${workspaceFolder}/src/*.c",
        "-o",
        "${workspaceFolder}/bin/${workspaceFolderBasename}",
        "-dumpbase",
        "''",
        "-dumpdir",
        "${workspaceFolder}/obj/",
        "&&",
        "rm",
        "${workspaceFolder}/obj/*.i",
        "${workspaceFolder}/obj/*.s"
      ],
      "options": {
        "cwd": "${workspaceFolder}"
      },
      "problemMatcher": [
        "$gcc"
      ],
      "group": {
        "kind": "build",
        "isDefault": true
      },
      "detail": "General pattern for a multi-file project"
    },
    {
      "type": "shell",
      "label": "Compile active C file",
      "command": "gcc -I${workspaceFolder}/include -Wall -Wextra -Werror -c ${file} -o ${workspaceFolder}/obj/${fileBasenameNoExtension}.o",
      "options": {
        "cwd": "${workspaceFolder}"
      },
      "problemMatcher": [
        "$gcc"
      ],
      "presentation": {
        "reveal": "always",
        "panel": "shared"
      },
      "group": {
        "kind": "build",
        "isDefault": false
      },
      "detail": "Specifically coded in tasks.json"
    },
    {
      "type": "shell",
      "label": "Link all object files",
      "command": "gcc ${workspaceFolder}/obj/*.o -o ${workspaceFolder}/bin/${workspaceFolderBasename}",
      "options": {
        "cwd": "${workspaceFolder}"
      },
      "problemMatcher": [
        "$gcc"
      ],
      "presentation": {
        "reveal": "always",
        "panel": "shared"
      },
      "group": {
        "kind": "build",
        "isDefault": false
      },
      "detail": "Specifically coded in tasks.json"
    },
    {
      "type": "shell",
      "label": "Run C/C++ File",
      "command": "${workspaceFolder}/bin/${workspaceFolderBasename}",
      "options": {
        "cwd": "${workspaceFolder}"
      },
      "presentation": {
        "reveal": "always",
        "panel": "shared"
      },
      "isBackground": true,
      "group": {
        "kind": "test",
        "isDefault": true
      },
      "detail": "Specifically coded in tasks.json"
    },
    {
      "type": "shell",
      "label": "C Compile and Run program",
      "command": "${workspaceFolder}/bin/${workspaceFolderBasename}",
      "options": {
        "cwd": "${workspaceFolder}"
      },
      "dependsOn": [
        "C/C++: gcc générer l'exécutable",
      ],
      "dependsOrder": "sequence",
      "presentation": {
        "reveal": "always",
        "panel": "shared"
      },
      "group": {
        "kind": "test",
        "isDefault": false
      },
      "detail": "Specifically coded in tasks.json"
    },
  ],
  "version": "2.0.0"
}

Remarques.

  • La première tâche étant de type cppbuild et non pas shell, elle ne peut pas être définie par une commande sur une seule ligne. Néanmoins, ses arguments peuvent servir à enchaîner d'autres commandes (cf. les lignes nº 24 à 26 qui invoque une commande rm pour supprimer des fichiers intermédiaires).
  • Parmi les options de compilation, on a bien évidemment invoqué celles concernant les avertissements (-Wall -Wextra -Werror – cf. chap. C1‑II ), qui ont été passées sous silence dans la présentation générale de la commande de compilation multi‑fichiers pour ne pas alourdir inutilement l'exposé.

Quant aux raccourcis claviers, ils sont définis par le code JSON ci‑dessous, à insérer dans le fichier keybindings.json de l'utilisateur de VS Code :

  {
    "key": "alt+b",
    "command": "workbench.action.tasks.runTask",
    "args": "Compile active C file",
    "when": "editorLangId == 'c' || editorLangId == 'cpp'"
  },
  {
    "key": "ctrl+shift+alt+l",
    "command": "workbench.action.tasks.runTask",
    "args": "Link all object files",
    "when": "editorLangId == 'c' || editorLangId == 'cpp'"
  }

Exemple de programme multi‑fichiers en langage C

Description du programme

Contexte

Sous Linux, la commande cal ou ncal W permet d'afficher la page de calendrier d'un mois donné, avec possiblement la numérotation des semaines, comme le montre un exemple d'exécution ci‑dessous pour le mois de janvier 1900.

ncal -w -b 1 1900
      Janvier 1900  w| lu ma me je ve sa di  1|  1  2  3  4  5  6  7  2|  8  9 10 11 12 13 14  3| 15 16 17 18 19 20 21  4| 22 23 24 25 26 27 28  5| 29 30 31

Cahier des charge

On se propose de coder un programme similaire d'affichage de calendrier où, en boucle, l'utilisateur peut cibler une date donnée, de telle sorte que dans la page de calendrier affiché, le numéro du jour saisi par l'utilisateur apparaîtra précédé du symbole >.

Remarques.

  • Pour des considérations académiques, le but étant de mettre en œuvre une programmation multi‑fichiers intéressante, on choisit ne pas recourir au module time de la bibliothèque standard du langage C.
  • On limite la saisie des dates de sorte qu'elles ne soient pas antérieures à 1583 (sachant que l'instauration du calendrier Grégorien date de 1582 en France – cf. chap. R2‑VI ).

Structure du programme

Le code source du programme est structuré en 5 fichiers répartis sur 3 modules, comme le montre la figure ci‑dessous :

  • Le module principal est constitué seulement du fichier d'implémentation main.c.
  • Un premier module auxiliaire regroupe toutes les fonctions d'affichage du calendrier, constitué :
    • d'un fichier d'en‑tête calDisplay.h
    • et d'un fichier d'implémentation calDisplay.c.
  • Un deuxième module auxiliaire regroupe toutes les fonctions de calcul du calendrier, constitué :
    • d'un fichier d'en‑tête timeTools.h
    • et d'un fichier d'implémentation timeTools.c.
    Ce deuxième module auxiliaire est une dépendance du premier, via une directive d'inclusion de timeTools.h codée dans calDisplay.h.

Pour faciliter l'expérimentation, le code source du programme est téléchargeable sous la forme d'un fichier d'archive .zip au lien suivant .

Il peut être testé dans l'environnement de programmation en ligne OnlineGDB, en cliquant sur le bouton New File (cf. la capture d'écran ci‑contre) pour ajouter des fichiers supplémentaires.

Le module principal

Le code source du fichier principal d'implémentation main.c, qui ne contient que quelques directives d'inclusion et la fonction principale main (point d'entrée du programme) est donné ci‑dessous :

/* SIMPLE CALENDAR PROGRAM
 *   display a full date with names of day and month
 *   and the calendar of the current month (6 lines of 7 days)
 *   with week numbers heading each line
 */

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

#include "calDisplay.h"


int main(void)
{
  printf(">>>>>>> Simple Calendar <<<<<<<\n\n");
  while (1) {
    int day = 0, month = 0, year = 0;

    printf("Type a date with format day/month/year\n");
    printf("(year ≥ %d, day = 0 to quit)\n", MINIMAL_YEAR);
    while (1) {
      printf("   > ");
      scanf(" %02d/%02d/%d", &day, &month, &year);
      if (day == 0) {
        printf("\nGoodbye.\n");
        exit(0);
      }
      if (isDateOutOfRange(day, month, year)) {
        printf("Date is out of range or wrong!\n");
      }
      else {
        break;
      }
    }
    printFullMonth(day, month, year);
  }
}

Au regard de ce code, on peut formuler les observations suivantes :

  • Il n'y a pas de partie déclarative à proprement parler, mais seulement quelques directives d'inclusion qu'il n'est pas utile de déporter dans un fichier d'en‑tête.
  • L'essentiel de la fonction main consiste à gérer les saisies de l'utilisateur.
  • La complexité cachée du code est déportée dans deux fonctions auxiliaires codées dans le module calDisplay :
    • isDateOutOfRange, appelée à la ligne nº 28, pour vérifier la correction de la date saisie, c'est‑à‑dire sa compatibilité avec la limite spécifiée supra sa conformité à l'arithmétique calendaire ;
    • elle est déclarée dans le fichier d'en‑tête timeTools.h ;
    • printFullMonth, appelée à la ligne nº 35, qui réalise l'affichage demandé ;
    • elle est déclarée dans le fichier d'en‑tête calDisplay.h.

Le module d'affichage

Le module d'affichage calDisplay regroupe, comme son nom l'indique, toutes les fonctions permettant l'affichage dans le terminal d'exécution de la page de calendrier dans laquelle s'inscrit une date donnée.

Fichier d'en‑tête

Le code fichier d'en‑tête calDisplay.h du module d'affichage est le suivant :

/* SIMPLE CALENDAR PROGRAM
 *   calendar display functions
 *   (printing text in the running terminal)
 */

#ifndef CALENDAR_DISPLAY_H_INCLUDED
#define CALENDAR_DISPLAY_H_INCLUDED

#include <stdio.h>

#include "timeTools.h"


/* All function prototypes */
void printShortNameOfDay(int wday);
void printFullNameOfDay(int wday);
void printFullNameOfMonth(int month);
void printFullDate(int wday, int day, int month, int year);
void printDaysHeader(void);
void printFullMonth(int day, int month, int year);

#endif // CALENDAR_DISPLAY_H_INCLUDED

Au regard de ce code, on peut formuler les observations suivantes :

  • Par précaution d'usage, le fichier est protégé contre les inclusions multiples par une directive (cf. chap. C4‑III ).
  • La partie déclarative se limite à celle des prototypes des fonctions, qui sont toutes des fonctions d'affichage dans le terminal d'exécution.

Fichier d'implémentation

Le code fichier d'implémentation caldisplay.c du module d'affichage est le suivant :

/* SIMPLE CALENDAR PROGRAM
 *   calendar display functions
 *   (printing text in the running terminal)
 */

#include "calDisplay.h"


void printShortNameOfDay(int wday)
{
  switch (wday) { 
    case MON : printf("Mon"); break;
    case TUE : printf("Tue"); break;
    case WED : printf("Wed"); break;
    case THU : printf("Thu"); break;
    case FRI : printf("Fri"); break;
    case SAT : printf("Sat"); break;
    case SUN : printf("Sun"); break;
  } 
}


void printFullNameOfDay(int wday)
{
  switch (wday) { 
    case MON : printf("Monday");    break;
    case TUE : printf("Tuesday");   break;
    case WED : printf("Wednesday"); break;
    case THU : printf("Thursday");  break;
    case FRI : printf("Friday");    break;
    case SAT : printf("Saturday");  break;
    case SUN : printf("Sunday");    break;
  } 
}


void printFullNameOfMonth(int month)
{
  switch (month) { 
    case JAN : printf("January");   break;
    case FEB : printf("February");  break;
    case MAR : printf("March");     break;
    case APR : printf("April");     break;
    case MAY : printf("May");       break;
    case JUN : printf("June");      break;
    case JUL : printf("July");      break;
    case AUG : printf("August");    break;
    case SEP : printf("September"); break;
    case OCT : printf("October");   break;
    case NOV : printf("November");  break;
    case DEC : printf("December");  break;
  } 
}


void printFullDate(int wday, int day, int month, int year)
{
  printf("\n   ");
  printFullNameOfDay(wday);
  printf(", ");
  printFullNameOfMonth(month);
  printf(" %d, %d\n\n", day, year);
}


void printDaysHeader(void)
{
  printf("Wk|");
  for (int wd = MON; wd <= SUN; wd++) {
    printf(" ");
    printShortNameOfDay(wd);
  }
}


void printFullMonth(int day, int month, int year)
{
  int wday = dayOfWeek(day, month, year);
  printFullDate(wday, day, month, year);

  int d = previousMonday(month, year); // looping day to be printed
  int w = weekInYear(1, month, year);  // week of d
  int m = (d == 1) ? month : previousMonth(month); // month of d
  int y = w == lastWeekInYear(year  - 1) ? year - 1 : year;

  // print an array of 6 lines of 7 days from Monday to Sunday
  printDaysHeader();
  for (int count = 0; count < 6 * 7; count++) {
    if (count % 7 == 0) {    // linebreak at each end of week
      printf("\n%2d| ", w);    // print the week number before monday
      w = nextWeek(w, y);      // increment the current week number
      if (w == 1) {
        y++;                   // increment the current year if first week
      }
    }
    if (d == day && m == month) {
      printf(">%2d ", d);    // mark the day of the date with '>'
    }
    else {
      printf("%3d ", d);     // all others days are normally printed
    }
    d = d % nbOfDaysInMonth(m, year) + 1; // increment the current day
    if (d == 1) {
      m = nextMonth(m);      // increment the current month if first day
    }
  }
  printf("\n\n");
}

Au regard de ce code, on peut formuler les observations suivantes :

  • Le fichier ne comporte aucune partie déclarative qui lui soit propre.
  • Les quatre premières fonctions sont des routines d'affichage d'éléments de date (nom des mois et des jours, date complète).
  • Les deux autres sont réellement des fonctions spécifiques aux besoins du programme principal :
    • printDaysHeader affiche la première ligne du calendrier (noms des jours de la semaine formant les têtes de colonnes du tableau).
    • printFullMonth affiche le reste du tableau. C'est la seule fonction qui soit algorithmiquement un peu complexe. Elle est basée sur une boucle répétitive à 42 itérations puisque le tableau est constitué de 6 lignes à 7 colonnes.
    • Un saut de ligne est effectué toutes les 7 itérations (donc en fin de semaine), avec affichage du numéro de semaine en début de ligne suivante (cf. la ligne nº 90).
      La gestion des numéros de semaines nécessite la précaution de tenir compte d'un éventuel changement d'année (s'il s'agit d'un mois de décembre ou de janvier (cf. les lignes nº 84 & 92).
      Le numéro du jour courant est incrémenté en arithmétique cyclique compte tenu du nombre de jours du mois courant (cf. la ligne nº 102).

Le module de calcul

Le module de calcul timeTools regroupe toutes les fonctions de calcul spécifiques à l'arithmétique calendaire, c'est‑à‑dire qui permettent par exemple de calculer le nombre de jours dans un mois donnée, le nombre de jours dans une année, etc.

Fichier d'en‑tête

Le code fichier d'en‑tête timeTools.h du module de calcul est le suivant :

/* SIMPLE CALENDAR PROGRAM
 *   time calculation functions
 *   following the calendar arithmetic
 */

#ifndef TIME_TOOLS_H_INCLUDED
#define TIME_TOOLS_H_INCLUDED

#include <stdbool.h>

enum DayOfWeek {MON = 1, TUE, WED, THU, FRI, SAT, SUN};
enum Month {JAN = 1, FEB, MAR, APR, MAY, JUN, JUL, AUG, SEP, OCT, NOV, DEC};

extern int const MINIMAL_YEAR;

/* All function prototypes */
bool isLeapYear(int year);
int  nbOfDaysInMonth(int month, int year);
int  nbOfDaysInYear(int year);
int  dayInYear(int day, int month, int year);
int  lastWeekInYear(int year);
int  weekInYear(int day, int month, int year);
int  previousWeek(int week, int year);
int  nextWeek(int week, int year);
int  dayOfWeek(int day, int month, int year);
int  previousMonth(int month);
int  nextMonth(int month);
int  previousMonday(int month, int year);
bool isDateOutOfRange(int day, int month, int year);

#endif

Au regard de ce code, on peut formuler les observations suivantes :

  • Par précaution d'usage, le fichier est protégé contre les inclusions multiples par une directive (cf. chap. C4‑III ).
  • La partie déclarative comporte :
    • des déclarations de types énumérés de constantes associées aux numéros des jours et des mois, pour une bonne lisibilité du code ;
    • la déclaration de la constante MINIMAL_YEAR qui limite l'année saisie par l'utilisateur ; il s'agit d'une donnée externe afin qu'elle soit utilisable dans tout fichier faisant l'inclusion de ce module (sa déclaration complète est codée dans le fichier d'implémentation timeTools.c) ;
    • les prototypes des fonctions.

Fichier d'implémentation

Le code fichier d'implémentation timeTools.c du module de calcul est le suivant :

/* SIMPLE CALENDAR PROGRAM
 *   time calculation functions
 *   following the calendar arithmetic
 */

#include "timeTools.h"

const int MINIMAL_YEAR = 1583; // Gregorian calendar adopted in 1582
const int EPOCH_0_YEAR = 1900; // Reference date to calculate week numbers


/* no comment (classic formula) */
bool isLeapYear(int year)
{
  return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
}


/* no comment (everybody knows this) */
int nbOfDaysInMonth(int month, int year)
{
  switch (month) {
    case FEB : 
      return (isLeapYear(year)) ? 29 : 28;
    case APR : case JUN : case SEP : case NOV :
      return 30;
    case JAN : case MAR : case MAY : case JUL ... AUG : case OCT : case DEC :
      return 31;
    default :
      return 0;  
  }
}


/* no comment (everybody knows this) */
int nbOfDaysInYear(int year)
{
  return isLeapYear(year) ? 366 : 365;
}


/* returns the position number of the day in a year (1 -> 365 or 366) */
int dayInYear(int day, int month, int year)
{
  int nbOfDays = 0;
  for (int m = JAN; m < month; m++) {
    nbOfDays += nbOfDaysInMonth(m, year);
  }
  return nbOfDays + day;
}


/* returns the number of the last week in a year (52 or 53)
   according to ISO 8601 */
int lastWeekInYear(int year) {
  // special cases when the year has 53 weeks
  if (dayOfWeek(1, JAN, year) == THU || (isLeapYear(year) && dayOfWeek(1, JAN, year) == WED)) {
    return 53;
  }
  // general case
  else {
    return 52;
  }
}


/* returns the position number of the week in a year (1 -> 52 or 53)
   according to ISO 8601 */
int weekInYear(int day, int month, int year)
{
  int dayInThisYear = dayInYear(day, month, year);
  int wdayOfJan1    = dayOfWeek(1, JAN, year);
  // based on the nb of days elapsed in the year
  //   1) offsetting the week day of January 1st
  //   2) divide by 7 (week length)
  int rawWeekNb     = (wdayOfJan1 + dayInThisYear - 2) / 7;
  // and here we apply the ISO 8601 standard
  if (wdayOfJan1 < FRI) { // 1st of January is yealier than Friday
    rawWeekNb++;          // partial first week is week number 1
  }
  // else partial first week is counted as the last one of last year
  else if (dayInThisYear <= 8 - wdayOfJan1) { 
    return lastWeekInYear(year - 1);
  }
  // in any case, the week number does not exceed the last week number
  return rawWeekNb <= lastWeekInYear(year) ? rawWeekNb : 1;
}


/* number of the previous week, cyclically (1 -> 52 or 53) */
int previousWeek(int week, int year)
{
  return week >= 1 ? lastWeekInYear(year - 1) : week - 1;
}


/* number of the previous week, cyclically (1 -> 52 or 53) */
int nextWeek(int week, int year)
{
  return week >= lastWeekInYear(year) ? 1 : week + 1;
}


/* number of days ellapsed since/missing before 01/01/1900 */
int nbOfDaysSinceEpoch0(int day, int month, int year)
{
  int nbOfDays = 0;
  if (year >= EPOCH_0_YEAR) {
    for (int y = EPOCH_0_YEAR; y < year; y++) { // elapsed years since 1900
      nbOfDays += nbOfDaysInYear(y);
    }
    // count of the 
    nbOfDays += dayInYear(day, month, year);   // elapsed day this year
  }
  else { // idem but with a negative value
    for (int y = EPOCH_0_YEAR - 1; y >= year; y--) { // years before 1900
      nbOfDays -= nbOfDaysInYear(y);                 // this year included
    }
    nbOfDays += dayInYear(day, month, year);         // elapsed days decounted
  }
  return nbOfDays;
}


/* day of week of a date (1 = Monday -> 7 = Sunday) */
int dayOfWeek(int day, int month, int year)
// for the record, EPOCH 0 (01/01/1900) is a Monday (useful reference date)
// proceeding by arithmetic modulo 7 plus 1 from EPOCH 0
{
  if (year > EPOCH_0_YEAR) { // after 1900
    return 1 + (nbOfDaysSinceEpoch0(day, month, year) - 1) % 7; 
  }
  else {                    // before 1900
    return 7 + nbOfDaysSinceEpoch0(day, month, year) % 7; // complementary
  }
}


/* number of the previous month, cyclically (1 -> 12) */
int previousMonth(int month)
{
  return month == JAN ? DEC : month - 1;
}


/* number of the next month, cyclically (1 -> 12) */
int nextMonth(int month)
{
  return month == DEC ? JAN : month + 1;
}


/* returns the day-in-month number of the previous monday of a given month and year */
int previousMonday(int month, int year)
{
  int wdayOf1 = dayOfWeek(1, month, year);  // week day of the first day of the month
  // if wdayOf1 == MONDAY then no change: previousMonday = 1; 
  // else, previousMonday = last day of previous month (31/30/29/28) - (wdayOf1 - 2)
  return (wdayOf1 == MON) ? 1 : nbOfDaysInMonth(previousMonth(month), year) - (wdayOf1 - 2);
}


/* date may be out of range if wrongly typed by the user */
bool isDateOutOfRange(int day, int month, int year)
{
  if (day   < 1   || day   > nbOfDaysInMonth(month, year) ||
      month < MON || month > DEC                          ||
      year < MINIMAL_YEAR) {
    return true;
  }
  return false;
}

Au regard de ce code, on peut formuler les observations suivantes :

  • On trouve une partie déclarative spécifique, avec :
    • la déclaration complète de la constante MINIMAL_YEAR, qui a été déclarée externe dans le fichier timeTools.h – cf. supra  ;
    • la déclaration de la constante EPOCH_0_YEAR en raison de sa particularité remarquable pour le calcul des jours de la semaine – le 1er janvier 1900 tombe un lundi.
  • La complexité de certains calculs (numéro des semaines, jour de la semaine) est résolue par la définition de nombreuses routines :
    • nbOfDaysInMonth qui détermine le nombre de jours dans un mois ;
    • nbOfDaysInYear qui détermine le nombre de jours dans une année ;
    • etc.
  • Le calcul le plus compliqué est certainement celui du numéro de la semaine dans l'année d'une date donnée, réalisé par la fonction weekInYear.
  • Il est basé sur le principe retenu par la norme ISO 8601 (cf. chap. R2‑VI ) concernant le cas d'une semaine à cheval sur deux années :
    • Cette semaine appartient à l'année dans laquelle elle comporte la majorité de ses jours (il ne peut pas y avoir égalité puisqu'une semaine dure 7 jours).
    • Il faut donc que le jour de l'an tombe au plus tard un jeudi pour qu'une semaine soit considérée comme faisant partie de la nouvelle année (sinon, elle est considérée comme la dernière semaine de l'année précédente).

Production et analyse du code exécutable

Pour produire le fichier exécutable du programme de calendrier tout en gardant les fichiers objets, on peut bien entendu procéder à l'aide de la commande unique proposée supra  :

gcc -save-temps *.c -o bin/calendar -dumpbase '' -dumpdir obj/ && rm obj/*.i obj/*.s

On obtient alors 3 fichiers objets dans le répertoire obj :

ls -l obj
total 32 -rw-rw-r-- 1 fg fg 6880 mars 8 23:23 calDisplay.o -rw-rw-r-- 1 fg fg 2488 mars 8 23:23 main.o -rw-rw-r-- 1 fg fg 4480 mars 8 23:23 timeTools.o

et le fichier exécutable dans le répertoire bin :

ls -l bin
total 24 -rwxrwxr-x 1 fg fg 17056 mars 8 23:23 calendar

Parce qu'il est produit par édition de liens dynamiques (choix par défaut, cf. chap. C4‑IV ), cet exécutable est à peine plus volumineux que la somme des fichiers objets dont il est issu. Et il utilise toutes les fonctions qui y sont codées. On peut le vérifier grâce à la commande nm (name mangling – cf. chap. C4‑IV ) combinée avec un filtrage approprié (commande grep – cf. chap. S1‑IV ) :

nm -U bin/calendar | grep ' T ' | grep -v ' _'
000000000000196e T dayInYear 0000000000001bc2 T dayOfWeek 0000000000001cf2 T isDateOutOfRange 000000000000183e T isLeapYear 00000000000019bb T lastWeekInYear 000000000000173e T main 00000000000018b3 T nbOfDaysInMonth 0000000000001943 T nbOfDaysInYear 0000000000001b23 T nbOfDaysSinceEpoch0 0000000000001c7c T nextMonth 0000000000001af3 T nextWeek 0000000000001c9c T previousMonday 0000000000001c5c T previousMonth 0000000000001ac4 T previousWeek 000000000000154d T printDaysHeader 00000000000014da T printFullDate 0000000000001598 T printFullMonth 00000000000012a5 T printFullNameOfDay 0000000000001381 T printFullNameOfMonth 00000000000011c9 T printShortNameOfDay 0000000000001a18 T weekInYear
nm -U obj/calDisplay.o | grep ' T '
0000000000000384 T printDaysHeader 0000000000000311 T printFullDate 00000000000003cf T printFullMonth 00000000000000dc T printFullNameOfDay 00000000000001b8 T printFullNameOfMonth 0000000000000000 T printShortNameOfDay
nm -U obj/timeTools.o | grep ' T '
0000000000000130 T dayInYear 0000000000000386 T dayOfWeek 00000000000004b6 T isDateOutOfRange 0000000000000000 T isLeapYear 000000000000017d T lastWeekInYear 0000000000000075 T nbOfDaysInMonth 0000000000000105 T nbOfDaysInYear 00000000000002e7 T nbOfDaysSinceEpoch0 0000000000000440 T nextMonth 00000000000002b7 T nextWeek 0000000000000460 T previousMonday 0000000000000420 T previousMonth 0000000000000288 T previousWeek 00000000000001da T weekInYear

Ici :

  • l'option -U permet de lister seulement les objets définis ;
  • le filtrage d'inclusion de la chaîne grep ' T ' permet de lister seulement les objets appartement à la section .text c'est‑à‑dire les fonctions (cf. chap. C4‑II ) ;
  • le filtrage d'exclusion de la chaîne grep -v ' _' permet d'exclure de la liste les fonctions de la bibliothèque CRT comme _init (cf. chap. C4‑IV ).

Scénario d'exécution

Juste pour fixer les idées, on donne ci‑dessous un scénario d'exécution du programme dans un terminal en saisissant successivement deux dates, l'une avant 1900 et l'autre après 1900. (Rappelons en effet que l'année 1900 est une référence commode pour le calcul des jours de la semaine – cf. supra ).

De plus, les dates sont choisies spécialement pour mettre en évidence les changements d'années, qui sont particuliers pour le calcul des numéros de semaine – cf. supra.

./calendar
>>>>>>> Simple Calendar <<<<<<< Type a date with format day/month/year (year ≥ 1583, day = 0 to quit)    > 3/1/1892    Sunday, January 3, 1892 Wk| Mon Tue Wed Thu Fri Sat Sun 53|  28  29  30  31   1   2 > 3  1|   4   5   6   7   8   9  10  2|  11  12  13  14  15  16  17  3|  18  19  20  21  22  23  24  4|  25  26  27  28  29  30  31  5|   1   2   3   4   5   6   7 Type a date with format day/month/year (year ≥ 1583, day = 0 to quit)    > 16/12/1928    Sunday, December 16, 1928 Wk| Mon Tue Wed Thu Fri Sat Sun 48|  26  27  28  29  30   1   2 49|   3   4   5   6   7   8   9 50|  10  11  12  13  14  15 >16 51|  17  18  19  20  21  22  23 52|  24  25  26  27  28  29  30  1|  31   1   2   3   4   5   6 Type a date with format day/month/year (year ≥ 1583, day = 0 to quit)    > 0 Goodbye.

On peut vérifier que ces sorties sont conformes à celles de la commande ncal sous Linux :

ncal -w -b 1 1892
      Janvier 1892  w| lu ma me je ve sa di 53|              1  2  3  1|  4  5  6  7  8  9 10  2| 11 12 13 14 15 16 17  3| 18 19 20 21 22 23 24  4| 25 26 27 28 29 30 31
ncal -w -b 12 1928
      Décembre 1928  w| lu ma me je ve sa di 48|                 1  2 49|  3  4  5  6  7  8  9 50| 10 11 12 13 14 15 16 51| 17 18 19 20 21 22 23 52| 24 25 26 27 28 29 30  1| 31

Par ailleurs, on peut remarquer que :

  • le 1er janvier 1882 est bien repéré comme appartenant à la semaine nº 53 de l'année 1881 puisqu'il tombe un vendredi (cette semaine comporte 4 jours en 1881 contre seulement 3 en 1882).
  • le 31 décembre 1927 est bien repéré comme appartenant à la semaine nº 1 de l'année 1928 puisqu'il tombe un lundi (cette semaine comporte seulement 1 jour en 1927 contre 6 en 1928).

Cas général d'un programme Arduino

Par rapport au contexte de la programmation généraliste en C/C++, la production d'un programme Arduino pour carte à microcontrôleur présente des points communs mais aussi certaines spécificités en matière de programmation multi‑fichiers. Il est indispensable de les connaître et les comprendre pour garantir une bonne mise en œuvre.

Par ailleurs, rappelons que s'il n'est malheureusement pas possible de mettre en œuvre une programmation multi‑fichiers dans l'environnement de simulation Tinkercad, cela est en revanche faisable avec Wokwi, même avec l'offre gratuite (cf. chap. C1‑III ).

Organisation des fichiers dans l'environnement Arduino

En programmation des cartes Arduino et compatibles, on peut suivre le même principe de répartition sur plusieurs fichiers du code source que dans le contexte d'une programmation généraliste, avec une structuration en modules (cf. supra ).

Néanmoins, certaines contraintes de nommage des fichiers s'imposent. En particulier, rappelons qu'il est impératif de respecter la contrainte d'homonymie du fichier principal avec le répertoire de projet (cf. chap. C2‑I ).

De plus, le répertoire de projet doit contenir tous les fichiers sources du programme, à l'exception des fichiers de bibliothèque Arduino qui peuvent être rangés dans des répertoires externes (et à condition qu'ils soient ciblés par l'IDE).

Ces fichiers sources peuvent porter diverses extensions possibles, notamment .ino, .cpp, .c, .h. Mais attention ! le choix de ces extensions n'est pas indifférent : l'IDE se base dessus pour effectuer des traitements différentiés sur les fichiers sources selon leur extension, comme on va le voir dans la section suivante.

De plus, lors de l'ouverture d'un fichier .ino, l'IDE Arduino ouvre simultanément tous les fichiers de code source (.ino, .cpp, .h, etc.) placés dans le répertoire de projet. Chaque fichier apparaît dans un onglet séparé – en anglais, tab.

Et on trouve tout à droite dans la barre supérieure de la fenêtre principale de l'IDE un bouton de gestion des onglets, qui permet de créer/renommer/effacer/sélectionner un fichier source sans avoir à faire ces manipulations dans l'explorateur de fichier du système d'exploitation du poste de travail.

Traitements spécifiques opérés par l'IDE Arduino

Comme expliqué aux chap. C2‑I  et C4‑I , pour rendre la programmation des cartes à microcontrôleur accessible à des non‑spécialistes, avant toute compilation, l'IDE Arduino effectue des traitements préliminaires spécifiques sur le code source, un peu à la manière d'un préprocesseur.

Ces traitements sont opérés en arrière‑plan (avant le processus de compilation), ils sont transparents pour l'utilisateur de l'IDE. Ils sont spécifiquement adaptés à chaque famille de carte Arduino.

L'IDE commence par créer un répertoire temporaire de compilation dans l'espace réservé à cet effet par le système d'exploitation du poste de travail (sous Linux, typiquement /tmp/arduino_build_xxxxxx/xxxxxx est un nouveau numéro généré automatiquement). Ce n'est donc pas le répertoire de projet.

Dans un sous‑répertoire nommé sketch du répertoire temporaire de compilation :

  • Le fichier source principal de compilation est créé par copie directe du fichier « standard » main.cpp A (réf. pour les cartes à cœur AVR).
  • Pour mémoire, c'est dans ce fichier qu'est codée (par les développeurs d'Arduino) la fonction main qui appelle les fonctions setup et loop (cf. chap. C2‑I ).
  • Un fichier d'implémentation auxiliaire d'extension .cpp est construit par concaténation – c'est‑à‑dire mise bout‑à‑bout – du contenu tous les fichiers sources d'extension .ino placés dans le répertoire de projet :
    • en commençant par le fichier principal du projet, celui identifié par homonymie avec le répertoire de projet, où l'utilisateur a codé les fonction setup et  loop ;
    • puis en procédant par ordre alphabétique pour les autres fichiers (mais pas les éventuels fichiers ayant une autre extension comme .cpp ou .h).
    Dans ce fichier auxiliaire, divers traitements sont effectués :
    • ajout en première ligne la directive #include <Arduino.h> ;
    • génération des prototypes de toutes les fonctions définies dans ces fichiers.
  • Enfin, tous les autres fichiers sources (.h, .cpp etc.) présents dans le répertoire de projet sont incorporés mais sans y effectuer des modifications significatives (seules des directives de marquage de lignes #line sont ajoutées).

C'est seulement après tout cela, lorsque le répertoire de compilation est prêt, qu'une chaîne de compilation GCC est invoquée en C++ avec une commande complexe (cf. infra ).

Consignes pour la programmation multi‑fichiers Arduino

Compte tenu des traitements spécifiques qui l'on vient de décrire, en plus de la méthode générale de répartition du code source proposée supra , on peut formuler les remarques suivantes lorsque l'on code un programme Arduino :

  • La répartition sur plusieurs fichiers d'extension .ino peut faciliter le travail du codeur mais n'apporte aucun gain en termes de temps de compilation puisque tous ces fichiers sont finalement concaténés en un seul et ne forme donc qu'une seule unité de compilation.
  • Il est donc préférable de surtout privilégier la répartition du code source sur plusieurs fichiers d'extension .cpp pour former plusieurs unités de compilation et ainsi gagner du temps lors de la mise au point du programme (les unités non modifiées n'ayant pas besoin d'être recompilées).
  • Attention : si ces fichiers .cpp emploient des fonctions Arduino (par exemple, pinMode ou autre), alors il est indispensable de coder une directive #include <Arduino.h> au début du fichier ou dans son fichier d'en‑tête associé.

Il importe aussi de prendre conscience qu'avec l'IDE Arduino, les fichiers objets et le fichier exécutable produits par la chaîne de compilation sont stockés dans un répertoire temporaire. A priori, ce répertoire est conservé tant que le projet est ouvert, mais il n'a pas vocation à être pérennisé dans la durée.

Ainsi, à chaque ouverture d'un projet qui était auparavant fermé, la première compilation est a priori plus longue que les suivantes puisqu'elle doit reprendre à zéro la production de tous les fichiers objets. C'est seulement ensuite que l'on bénéficie d'un gain de temps grâce à la structuration du code en plusieurs unités de compilation.

Il s'agit d'un programme « académique » qui met en œuvre juste une led et un bouton-poussoir, comme avec le montage du TP n° 1 du chapitre C2 . Nommé multiSpeedBlink, ce programme commande le clignotement de la led avec une demi-période réglable : partant d'une valeur donnée (2 s), à chaque appui sur le bouton, la période est divisée par 2, et au bout de 8 appuis, la led s'éteint. Le programme est réparti sur 8 fichiers formant 4 modules :

  1. le module principal,
  2. un module d'entrées‑sorties booléennes,
  3. un module de changement de la fréquence de clignotement,
  4. un module de détection des fronts montants sur le bouton-poussoir.

Module principal

Fichier d'en‑tête principal multiSpeedBlink.h

Dans une directive de compilation conditionnelle de protection contre les doubles expansions, il contient :

  • les directives d'inclusion des trois fichiers d'en‑tête des autres modules du programme ;
  • les directives de définition des pseudo‑constantes (numéros des broches utilisées) et les déclarations des constantes du programmes pour le réglage de la période de clignotement.
#ifndef MULTI_SPEED_BLINK_H_INCLUDED
#define MULTI_SPEED_BLINK_H_INCLUDED
 
#include "logicalSignals.h"
#include "pinManagement.h"
#include "blinkSpeedManagement.h"
 
#define LED_PIN    2
#define BUTTON_PIN 4

const uint32_t BASE_BLINK_HALF_PERIOD = 2000000; // in microseconds (= 2 s)
const uint16_t MAX_BLINK_SPEED        = 256;     // max divisor of the base period
 
#endif // MULTI_SPEED_BLINK_H_INCLUDED

Fichier d'implémentation principal multiSpeedBlink.ino

Il contient :

  • la directive d'inclusion du fichier d'en‑tête principal ;
  • la déclaration des variables globales, l'une étant du type structuré LogicalSignal défini dans le fichier LogicalSignals.h ;
  • la définition des fonctions setup et loop qui n'appellent que des fonction de haut niveau, définies dans d'autres fichiers.
#include "multiSpeedBlink.h"
 
// global variables
LogicalSignal buttonSignal = {BUTTON_PIN, 0b00}; // from LogicalSignals.h
uint16_t blinkSpeed = 1;                         // divisor of the base period
 


void setup()
{
  configDigitalOutput(LED_PIN, LOW);
  configDigitalInput(BUTTON_PIN, INPUT);
}
 

void loop()
{
  updateSignal(buttonSignal);
  if (risingEdge(buttonSignal)) {
    switchToNextBlinkSpeed(blinkSpeed);
  }
  blinkDigitalOutput(LED_PIN, blinkSpeed);
}

Remarque : ce module principe ne met directement en œuvre aucune fonction de bas niveau ; il est donc a priori portable sur n'importe quel type de carte compatible avec l'environnement Arduino.

Module d'entrées‑sorties booléennes

Fichier d'en‑tête pinManagement.h

Il contient :

  • la directive d'inclusion du fichier d'en‑tête Arduino.h car le fichier d'implémentation associé utilise des fonctions Arduino ;
  • la directive d'inclusion du fichier d'en‑tête principal du programme ;
  • le prototype des trois fonctions définies dans le fichier d'implémentation associé pinManagement.cpp.
#include <Arduino.h>
#include "multiSpeedBlink.h"

void configDigitalOutput(uint8_t pin, bool level);
void configDigitalInput(uint8_t pin, byte mode);
void blinkDigitalOutput(uint8_t pin, uint16_t speed);

Fichier d'implémentation pinManagement.cpp

Il contient :

  • la directive d'inclusion du fichier d'en‑tête associé ;
  • la définition des trois fonctions déclarées dans le fichier d'en‑tête associé ; ce sont des procédures qui opèrent des actions de bas niveau sur les broches de la cartes (déclarer une broche en sortie, en entrée, mettre en œuvre le clignotement sur une broche à une vitesse donnée).
#include "pinManagement.h"

void configDigitalOutput(uint8_t pin, bool level)
{
  pinMode(pin, OUTPUT);
  digitalWrite(pin, level);
}


void configDigitalInput(uint8_t pin, byte mode)
{
  pinMode(pin, mode);
}


void blinkDigitalOutput(uint8_t pin, uint16_t speed)
{
  static unsigned long previousMicros = micros();
  if (speed == 0) {
    digitalWrite(pin, LOW);
    previousMicros = micros();
  }
  else {
    uint32_t blinkHalfPeriod = BASE_BLINK_HALF_PERIOD / speed;
    if (micros() - previousMicros >= blinkHalfPeriod) {
      previousMicros += blinkHalfPeriod;
      digitalWrite(pin, !digitalRead(pin));
    }
  }
}

Remarque. La fonction initDigitalInput peut sembler inutile dans la mesure où elle ne fait qu'appeler la fonction pinMode. Elle n'a pour but que de déporter du module principal une fonction de bas niveau qui serait éventuellement implémentée autrement dans un autre environnement matériel. Elle pourrait également apparaître plus pertinente pour un programme mettant en œuvre plusieurs entrées booléennes (en factorisant le code).

Module de changement de la fréquence de clignotement

Fichier d'en‑tête blinkSpeedManagement.h

Il contient :

  • la directive d'inclusion du fichier d'en‑tête principal du programme ;
  • le prototype de la fonction définie dans le fichier d'implémentation associé blinkSpeedManagement.cpp.
#include "multiSpeedBlink.h"

void switchToNextBlinkSpeed(uint16_t & blinkSpeed);

Fichier d'implémentation blinkSpeedManagement.cpp

Il contient :

  • la directive d'inclusion du fichier d'en‑tête associé ;
  • la déclaration de la variable externe de vitesse (déclarée dans le fichier d'implémentation principal) ;
  • la définition de la fonction déclarée dans le fichier d'en‑tête associé ; c'est une procédure qui modifie la variable globale de vitesse conformément à la spécification ;
#include "blinkSpeedManagement.h"

void switchToNextBlinkSpeed(uint16_t & blinkSpeed)
{
  if (blinkSpeed == 0) {
    blinkSpeed = 1;
  }
  else {
    blinkSpeed = (blinkSpeed * 2) % MAX_BLINK_SPEED;
  }
}

Remarque. Il peut sembler fastidieux de créer un module pour une seule fonction, mais cela découle du fait que le programme est simple. Du point de vue fonctionnel, il semblerait peu cohérent de placer cette fonction dans l'un des trois autres modules.

Module de détection de fronts montants sur le signal du bouton

Fichier d'en‑tête LogicalSignals.h

Il contient :

  • la directive d'inclusion du fichier d'en‑tête Arduino.h car le fichier d'implémentation associé utilise des fonctions Arduino ;
  • la déclaration du type LogicalSignal qui regroupe dans un type structuré un numéro de broche du port numérique et un octet pour stocker sur ses deux premiers bits la valeur courante et la valeur précédente du niveau logique sur la broche (les types structurés sont abordés au chap. C5‑V ) ;
  • le prototype des deux fonctions définies dans le fichier d'implémentation associé LogicalSignals.cpp.
#include <Arduino.h>

typedef struct {
  uint8_t pin;  // pin number to be declared as INPUT our INPUT_PULLUP
  byte levels;  // current level on bit 0, previous level on bit 1
} LogicalSignal;

void updateSignal(LogicalSignal & signal); // to be called once in loop function
bool risingEdge (LogicalSignal signal);

Fichier d'implémentation LogicalSignals.cpp

Il contient :

  • la directive d'inclusion du fichier d'en‑tête associé ;
  • la définition des deux fonctions déclarées dans le fichier d'en‑tête associé.
#include "LogicalSignals.h"

void updateSignal(LogicalSignal & signal) {
  signal.levels <<= 1;        // record previous level on bit rank 1
  // copy current level on bit rank 0 and clear all bits ranging over rank 1
  signal.levels = ((digitalRead(signal.pin) | signal.levels) & 0b11);
}

bool risingEdge (LogicalSignal signal) {
  return (signal.levels == 0b01);
}

Remarque. Contrairement aux autres modules, celui‑ci ne requiert pas la directive #include "multiSpeedBlink.h" car aucune de ces fonctions ne fait appel à un élément spécifique du programme. Il peut être complété pour former une bibliothèque de fonctions lecture de niveaux et de fronts sur un signal d'entrée booléenne.

Processus de compilation d'un projet Arduino