Configuration d’une application embarquée (1/2)

Publié par cpb
Mar 31 2013

Configuration Application EmbarquéCet article a été écrit en collaboration avec mon ami François Beaulier dont je vous recommande le blog. Nous avons réfléchi ensemble sur les possibilités qui s’offrent au développeur d’application embarquée pour enregistrer le paramétrage, la configuration, les préférences… bref tout ce qui constitue les données persistantes de son système.

Fichiers de configuration pour une application embarquée

La plupart des applications doivent lire des paramètres de fonctionnement et éventuellement en sauvegarder certains. Ces paramètres de configuration qui agissent sur le comportement même du programme, peuvent aussi être des données manipulées par l’utilisateur (par exemple les réglages d’un asservissement PID). Nous nous sommes intéressés aux possibilités qui nous sont offertes pour stocker ces données de configuration, plus particulièrement dans l’optique des systèmes embarqués – mais la plupart des remarques sont valables pour une application classique sur poste de travail.

Pour illustrer nos propos nous imaginerons le cas d’une application de gestion domotique très simple. Elle doit disposer d’une représentation de son environnement sous forme de pièces de la maison, de capteurs (température, humidité, détection de présence, de fumée…) et d’actionneurs (thermostat, lampe, radiateur, alarme, etc.). Plusieurs possibilités se présentent pour stocker ces informations, nous allons les examiner en détaillant les avantages et inconvénients de chacune.

Format brut binaire “sauvegarde de structures”

Il s’agit de la méthode la plus simple possible, qui consiste juste en une sérialisation brute d’une structure. C’est une solution adaptée aux modèles de données statiques simples, lorsque rien n’est alloué dynamiquement. C’est aussi un bon cas d’école car on collectionne ici à peu près tout ce qu’il ne faut surtout pas faire !

Considérons le code suivant, version statique de notre application domotique:

 

#define MAX_CAPTEURS_PAR_PIECE     16
#define MAX_ACTIONNEURS_PAR_PIECE  16
#define LG_NOM_PIECE               32
#define MAX_PIECES                 10
#define LG_NOM_SITE                32

struct s_capteur {
    enum capteur_t     type;
    int                identifiant;
};

struct s_actionneur {
    enum actionneur_t  type;
    int               identifiant;
};

struct s_piece{
    wchar_t              Nom[LG_NOM_PIECE];
    double               Volume;
    struct s_capteur     TabCapteurs[MAX_CAPTEURS_PAR_PIECE];
    struct s_actionneur  TabActionneurs[MAX_ACTIONNEURS_PAR_PIECE];
};

struct s_site {
    wchar_t         Nom[LG_NOM_SITE];
    wchar_t         Adresse[LG_NOM_SITE];
    struct s_piece  TabPieces[MAX_PIECES];
};

struct s_site site_config;

L’ensemble des données se trouvent dans la structure struct s_site. La lecture de la configuration se fait par (traitement des erreur omis !) :

FILE * fp = fopen("config.bin", "r");
fread(&site_config, sizeof(struct site_config), 1, fp);
fclose(fp);

Et l’écriture par :

FILE * fp = fopen("config.bin", "w");
fwrite(&site_config, sizeof(struct site_config), 1, fp);
fclose(fp);

Difficile de faire plus court ! Et pourtant vous ne rencontrerez probablement jamais une application qui utilise cette méthode. Tel quel les inconvénients sont en effet assez décourageants.

  • Fichier illisible et donc non vérifiable et non modifiable avec un éditeur de texte (on peut d’ailleurs regretter la propension de certains éditeurs d’applications propriétaires à employer le format binaire justement pour le rendre incompatible avec leurs concurrents).
  • Dépendance à l’alignement des structures, l’architecture, l’endianisme (ordre de stockage des octets d’un entier : poids faible ou poids fort en premier)…
  • Pas de gestion de version.
  • Pas de contrôle d’intégrité.

Certains de ces inconvénients peuvent toutefois être assez facilement levés.

  • Utiliser des types de données figés comme uint32_t au lieu de unsigned long.
  • Forcer le type d’alignement par des directives de compilation.
  • Ajouter un entête avec un “numéro magique” pour identifier le fichier et un numéro de version.
  • Écrire un CRC à la fin du fichier pour vérifier son intégrité.

Ce sera déjà mieux mais le format binaire reste un gros problème et la dépendance à l’endianisme une faiblesse importante. Un avantage néanmoins pour ce type de format se trouve dans sa rapidité de chargement et de sauvegarde ainsi que dans la compacité des fichiers de données.

En cas d’allocation dynamique de mémoire il faudra enregistrer le nombre d’éléments suivi par le tableau de structures à plat en mémoire. On procédera donc en deux temps pour un tableau (traitement des erreurs omis).

struct s_capteur * tableau_capteurs= NULL;
uint32_t nb_capteurs = 0;
FILE * fp = fopen("config.bin", "r");

/* Lecture */
fread(& nb_capteurs, sizeof(uint32_t), 1,fp);
if (nb_capteurs != 0){
    tableau_capteurs = calloc(nb_capteurs, sizeof(struct s_capteur));
    uint32_t nb = fread(tableau_capteurs, sizeof(struct s_capteur), nb_capteurs, fp);
}
if (nb != nb_capteurs)
    /* erreur */

/* Ecriture */
fwrite(& nb_capteurs, sizeof(int), 1,fp);
uint32_t nb = fwrite(tableau_capteurs, sizeof(struct s_capteur), nb_capteurs, fp);
if (nb != nb_capteurs)
    /* erreur */

En conclusion, cette méthode de sauvegarde binaire des structures de données doit être réservée aux phases de prototypage de l’application, lorsque les informations à enregistrer ne représentent pas encore une partie cruciale du projet et qu’on n’envisage pas de communication avec d’autres modules logiciels, ni de portage vers d’autres plates-formes.

Fichier texte spécifique “fprintf()/fscanf()”

Pour contourner les principaux inconvénients que nous avons relevés avec le format précédent (illisibilité, problèmes d’alignement et d’endianisme), il est possible de stocker les données dans un format textuel et de les relire dans le même ordre. Voyons un exemple correspondant aux mêmes données que précédemment (encore une fois, nous avons omis le traitement des erreurs).

int sauvegarder_piece(FILE * fp, struct s_piece * piece)
{
  int i;
  fprintf(fp, "%s\n", piece->Nom);
  fprintf(fp, "%lf\n", piece->Volume);
  fprintf(fp, "%d\n", piece->nb_capteurs);
  for (i = 0; i < piece->nb_capteurs; i ++) {
    fprintf(fp, "%d\n", piece->TabCapteurs[i].type);
    fprintf(fp, "%d\n", piece->TabCapteurs[i].identifiant);
  }
  fprintf(fp, "%d\n", piece->nb_actionneurs);
  for (i = 0; i < piece->nb_actionneurs; i ++) {
    fprintf(fp, "%d\n", piece->TabActionneurs[i].type);
    fprintf(fp, "%d\n", piece->TabActionneurs[i].identifiant);
  }
  return 0;
}

int charger_piece(FILE * fp, struct s_piece * piece)
{
  int i;
  if ((fscanf(fp, "%s", piece->Nom) != 1)
    || (fscanf(fp, "%lf", & (piece->Volume)) != 1)
    || (fscanf(fp, "%d", & (piece->nb_capteurs)) != 1))
      return -1;
  if ((nb_capteurs < 0) || (nb_capteurs >= MAX_CAPTEURS_PAR_PIECE))
    return -1;
  for (i = 0; i < piece->nb_capteurs; i ++) {
    if ((fscanf(fp,"%d", &(piece->TabCapteurs[i].type)) != 1)
     || (fscanf(fp,"%d", &(piece->TabCapteurs[i].identifiant))!=1))
      return -1;
  }
  if (fscanf(fp, "%d", & (piece->nb_actionneurs)) != 1)
    return -1;
  if ((piece->nb_actionneurs < 0)
   || (piece->nb_actionneurs >= MAX_ACTIONNEURS_PAR_PIECE))
    for (i = 0; i < piece->nb_actionneurs; i ++) {
      if ((fscanf(fp, "%d", &(piece->TabActionneurs[i].type)) != 1)
       || (fscanf(fp, "%d",
        &(piece->TabActionneurs[i].identifiant))!=1))
      return -1;
    }
  return 0;
}

On notera que l’utilisation de fscanf() telle que nous l’avons employée ici peut poser problème dans certains cas, et qu’il est souvent préférable de procéder en deux temps pour lire les valeurs, ainsi :

int lecture_entier (FILE * fp, int * resultat)
{
  char ligne[LONGUEUR_MAXI_LIGNE + 1];

  if ((fgets(ligne, LONGUEUR_MAXI_LIGNE, fp) == NULL)
   || (sscanf(ligne, "%d",resultat) != 1))
    return -1;
  return 0;
}

Le fichier devient modifiable à la main dans un éditeur de texte, mais c’est très fastidieux, car il est constitué d’une suite interminable de valeurs que l’on devra identifier à l’œil. Cette approche fonctionne bien pour sauvegarder et récupérer des chaînes de caractères et des valeurs entières. Pour les valeurs réelles, des problèmes de précision peuvent se poser (même enregistré avec plusieurs décimales après la virgule, 0.333333 ne sera pas relu comme équivalent à 1/3).

Nous avons résolu les problèmes d’alignement (des champs des structures par exemple), les problèmes d’endianisme (puisque la conversion de la valeur entière vers la représentation en mémoire se fait avant la sauvegarde et après la relecture), et gagné une certaine portabilité. Le fichier est d’une taille beaucoup plus conséquente que précédemment, mais il est possible de le compresser (dynamiquement ou après son enregistrement) et retrouver en principe le même ordre de grandeur qu’avec la représentation binaire.

L’analyse des données se faisant dynamiquement, le chargement de la configuration est plus longue qu’avec des données binaires. Il est nécessaire de faire appel à des fonctions telles que sscanf(), atoi(), strtol(), qui consomment du temps CPU. En outre ces routines renvoient des codes de retour indiquant la réussite ou l’échec des conversions. Vérifier ces codes de réussite et agir en conséquence alourdit à nouveau le code.

Il m’est arrivé dans le cadre d’une application embarquée d’avoir à charger un gros fichier représentant une cartographie aéroportuaire. Le temps de chargement sur les premiers prototypes devenant prohibitif, nous avons utilisé un profiler pour détecter qu’une grosse partie de ce temps était perdue dans le sscanf() et dans le code de vérification des bornes des valeurs numériques. Sachant que les fichiers étaient validés sur un poste indépendant avant d’être déployés sur les systèmes embarqués où ils ne pouvaient plus être modifiés, la répétition de ces opérations de vérification était inutile et nous pouvions alléger grandement notre programme en remplaçant les sscanf() par des atoi(). Le gain de temps fut alors suffisant pour que le chargement paraisse instantané.

Le gros inconvénient de ces formats est avant tout la difficulté à intervenir humainement dans les fichiers pour rechercher et modifier des informations. Il est impossible de vérifier ou d’éditer les données sans avoir sous les yeux une description précise du format du fichier. On peut améliorer un peu la lisibilité en y ajoutant des commentaires : pour ce faire, il faut que le programme soit capable d’ignorer certaines lignes (par exemple celles qui commencent par un caractère dièse # ou un point virgule ; ou encore un double slash //). Si le programme peut ajouter automatiquement les lignes de commentaires durant la sauvegarde, le fichier devient plus accessible pour un humain. Il serait néanmoins préférable que les informations soient documentées, par exemple en possédant des noms, ce qui nous conduit au format suivant.

Format “.ini” type Windows

Une approche courante dans les fichiers de configuration des applications Windows – et de certaines applications Unix – est de disposer d’une liste de paires clé-valeur, que l’on peut regrouper en différentes sections au sein du fichier. La syntaxe est simplissime et ne permet qu’un seul niveau d’imbrication:

; un commentaire commence par un point virgule
[section]
  cle1 = valeur1
  cle2 = valeur2

[autre section]
  cle1 = valeur1
  cle3 = valeur3

Il existe diverses bibliothèques open sources pour lire et d’écrire ce type de fichier facilement. La meilleure ressource que j’ai pu trouver sur le sujet me semble être la page suivante : http://blog.brush.co.nz/2009/02/inih/
On y trouvera plusieurs projets dont le code est adapté à l’embarqué notamment à l’utilisation sur un micro-contrôleur, par exemple pour lire des fichiers de configuration sur une SD-Card. Après une investigation rapide, pour avoir des fonctions de lecture et d’écriture je penche pour ce projet : http://www.compuphase.com/minini.htm. La bibliothèque tient en un seul fichier minIni.c et l’API possède l’intérêt d’offrir une double approche.

  • Lecture des valeurs une par une avec à chaque fois l’appel à une fonction qui va ouvrir le fichier, le parcourir en entier et retourner le résultat, ce qui est simple mais lent.
  • Parcours de tout le fichier avec appel à un handler pour chaque valeur trouvée (équivalent à l’API SAX pour le xml).

Dans le cas de données de configurations lues au démarrage de l’application, la seconde approche sera bien sûr la meilleure. Afin de pouvoir être facilement portée sur micro-contrôleur la bibliothèque utilise des macros pour l’interface avec le système de fichier, ainsi l’ouverture d’un fichier est fait avec un appel à ini_openread(filename,file) qui est définit comme ceci dans minGlue-stdio.h.

#define ini_openread(filename,file) ((*(file) = fopen((filename),"rb")) != NULL)

Si l’on est sur un système comme Linux avec une bibliothèque stdio opérationnelle, il suffit de copier minGlue-stdio.h en minGlue.h pour avoir toutes les fonctions définies à partir des fonctions de la libc. Sur une cible différente il faudra adapter minGlue.h en fonction de l’API de la stack filesystem utilisée. Le projet propose déja des fichiers minGlue-xxxx.h pour quelques stack connues comme Petit-FatFs.

Regardons l’API proposée pour parser un fichier .ini dans le cas de la méthode avec callback. On définit tout d’abord une fonction de callback dont le prototype est le suivant.

int Callback(const char *section, const char *key, const char *value, const void *userdata)

Ensuite on appelle cette autre fonction pour lancer le parsing.

int ini_browse(INI_CALLBACK Callback, const void *UserData, const mTCHAR *Filename);

C’est dans la callback qu’il faudra prendre en charge la conversion de texte vers le type de valeur cible, ainsi que la gestion des index des tableaux. Voyons comment notre exemple d’application domotique peut être sauvegardée dans un fichier ini.

[Site]
Nom=
Adresse=
NbrPieces=

[Piece]
Nom=
Volume=
NbrCapteurs=
NbrActionneurs=

[Capteur]
Type=
Identifiant=

[Capteur]
Type=
Identifiant=

[Actionneur]
Type=
Identifiant=

[Piece]
Nom=
Volume=
NbrCapteurs=
NbrActionneurs=

[Capteur]
Type=
Identifiant=

[Actionneur]
Type=
Identifiant=

Deux problème se posent :

  • Comment gérer les tableaux d’objets, comme les pièces ou les capteurs de notre exemple ?
  • Comment gérer les relations de composition au sens de la programmation orientée objet, c’est à dire définir quel capteur appartient à quelle pièce ?

Si chaque objet peut être facilement identifié de manière unique, on peut définir explicitement les liens et les index en ajoutant des champs spécifiques.

[Capteur]
Type=
Identifiant=
Piece=
Index=

Si le fichier respecte une certaine structure, comme par exemple que les capteurs d’une pièce sont disposés à la suite de sa déclaration, on pourrait déterminer l’index automatiquement. Mais cela crée un risque de confusion. Une autre approche consiste à numéroter les sections en y reportant directement les indices. Nous aurions ainsi le 3ème capteur de la 1ère pièce déclaré avec le nom de section “Capteur_3_1“. Le problème est alors que le décodage reste à notre charge, les bibliothèques ne nous aideront pas !

On comprend vite que l’aspect arborescent des structures de données classiques se prête très mal à une description dans ce type de fichier. On réservera donc le .ini à des applications élémentaires voire on l’évitera carrément car comme nous le verrons dans la deuxième partie de cet article.

Une réponse

  1. Laurent dit :

    Bonjour Christophe,
    je me rappelle avoir utiliser une librairie sympa pour la gestion de conf :
    libconfuse
    plutôt pas mal, permettant de gérer des fichiers de conf au format texte, avec quelques fonctionnalités avancées :
    – prise en charge de commentaires,
    – utilisation de variable de substitution,
    – arborescence possible du fichier de conf (pratique pour des tableaux ou des structures…)

URL de trackback pour cette page