La mise en place d'un moteur de base de données léger en C donne aux développeurs un contrôle direct sur la mémoire, les performances et le stockage. Cet article passe par la conception et la mise en place d'un simple moteur de base de données intégré, couvrant les structures de données, les opérations CRUD, l'indexation et les stratégies de persistance pour les environnements encombrés de ressources.

Exigences de base pour un moteur de base de données embarqué

Un moteur de base de données intégré doit fonctionner dans des limites étroites sur RAM, flash et vitesse de traitement. Les exigences typiques comprennent le comportement déterministe, l'empreinte de code minimale et aucune dépendance externe. Le moteur doit supporter les opérations de base : insérer, récupérer, mettre à jour, supprimer et rechercher.

Choisir la bonne structure de données est la première décision de conception. Les tableaux sont simples mais limités par des tailles statiques. Les listes liées permettent une croissance dynamique mais ajoutent des pointeurs en hauteur. Pour des performances équilibrées, une approche hybride utilisant des bassins de disques de taille fixe avec une liste libre peut bien fonctionner. Les principes de conception de SQLite offrent des indications utiles même pour des moteurs beaucoup plus simples.

Conception du calque de stockage des enregistrements

La couche de stockage gère la façon dont les enregistrements sont disposés en mémoire ou sur disque. Un modèle commun est de traiter chaque enregistrement comme une structure de longueur fixe pour simplifier l'arithmétique du pointeur et permettre l'indexation directe.

Registres de longueur fixe avec un répertoire

Définir un nombre maximal d'enregistrements (p. ex. ) et attribuer un tableau statique. Une piste bitmap ou une piste de liste libre qui est utilisée. Lorsqu'un enregistrement est supprimé, sa fente retourne au pool. Cette approche évite l'attribution dynamique et garantit un temps d'attribution O(1).

#define MAX_RECORDS 256
typedef struct {
 int id;
 char name[32];
 float value;
 int active; // 1 if slot in use
} Record;

Record pool[MAX_RECORDS];

Pour le stockage persistant, le pool peut être sauvegardé par un fichier ou une région de flash. Au démarrage, le moteur lit le pool de la mémoire non volatile en RAM, et à l'arrêt (ou périodiquement) il l'écrit en retour.

Vérifications de l'intégrité des données

Ajouter un champ de somme de contrôle simple à chaque enregistrement pour détecter la corruption. CRC-32 est un bon choix pour les systèmes embarqués, en conciliant complexité et force de détection des erreurs.

Mise en œuvre des opérations de base du CRUD

Avec le pool d'enregistrement défini, implémenter des fonctions pour insérer, trouver, mettre à jour et supprimer des enregistrements. Les opérations de recherche sont souvent le goulot d'étranglement de performance, donc une recherche linéaire naïve est acceptable seulement pour les petites bases de données (quelques centaines d'enregistrements).

Insérez avec la gestion de la liste libre

Maintenez une liste libre d'indices. Sur insert, pop un index de la liste libre, remplissez l'enregistrement, et marquez-le actif. La liste libre elle-même peut être une simple pile à l'aide d'un tableau d'entiers.

int free_list[MAX_RECORDS];
int free_count = MAX_RECORDS;
for (int i = 0; i < MAX_RECORDS; i++) free_list[i] = i;

int db_insert(int id, const char* name, float value) {
 if (free_count == 0) return -1; // no space
 int idx = free_list[--free_count];
 pool[idx].id = id;
 strncpy(pool[idx].name, name, sizeof(pool[idx].name)-1);
 pool[idx].value = value;
 pool[idx].active = 1;
 return idx;
}

Recherche et mise à jour

Une simple recherche par le pool, ne vérifiant que les enregistrements actifs. Pour les mises à jour, localisez l'enregistrement, modifiez les champs et revérifiez la liste libre si l'enregistrement est supprimé.

int db_find_by_id(int id) {
 for (int i = 0; i < MAX_RECORDS; i++) {
 if (pool[i].active && pool[i].id == id) return i;
 }
 return -1;
}

void db_update(int idx, float new_value) {
 if (idx >= 0 && idx < MAX_RECORDS && pool[idx].active)
 pool[idx].value = new_value;
}

Concepts avancés : Indexation et persistance

L'ajout d'un simple index, comme un tableau trié de pointeurs de clés ou un arbre de recherche binaire, améliore le temps de récupération. Pour les systèmes embarqués, un tableau statique trié sur la clé avec recherche binaire est souvent suffisant si les insertions sont peu fréquentes.

Index trié avec recherche binaire

Maintenez un tableau parallèle d'indices d'enregistrement triés par la clé de recherche (par exemple ID). Lors de l'insertion d'un nouvel enregistrement, insérez son index dans le tableau trié en utilisant une insertion binaire. Ensuite, la recherche devient O(log n) via la recherche binaire.

Stockage persistant en utilisant le fichier E/S

Sur les microcontrôleurs sans système de fichiers, les écritures de mémoire flash brutes sont courantes. Sur les systèmes embarqués basés sur Linux, POSIX standard // fonctionne bien. Utilisez un format de fichier simple : écrivez un en-tête (numéro magique, version, nombre d'enregistrement) suivi du tableau de pool brut. Pour la résilience des crashs, un journal d'écriture (WAL) peut aider, mais pour les moteurs de base, une seule écriture atomique (qui s'intègre dans une page flash) suffit. FreeRTOS+FAT est une option de système de fichiers léger pour les systèmes profondément intégrés.

void db_save(const char* filename) {
 FILE* fp = fopen(filename, "wb");
 if (!fp) return;
 fwrite(pool, sizeof(pool), 1, fp);
 fclose(fp);
}

void db_load(const char* filename) {
 FILE* fp = fopen(filename, "rb");
 if (!fp) return;
 fread(pool, sizeof(pool), 1, fp);
 fclose(fp);
 // Rebuild free-list from pool
 free_count = 0;
 for (int i = 0; i < MAX_RECORDS; i++) {
 if (!pool[i].active) free_list[free_count++] = i;
 }
}

Manipulation des contraintes et des compromis

Les moteurs de base de données embarqués font face à un compromis constant entre les fonctionnalités et l'utilisation des ressources. Le choix des fonctionnalités à inclure dépend de l'application:

  • Conformité de l'ACID – Habituellement pas nécessaire.
  • Indexing – Ajoute le coût d'insertion mais accélère les lectures. Pour les charges de travail lourdes en écriture, sautez les index.
  • Concurrence – La plupart des systèmes embarqués utilisent un seul thread. Tirez profit des mutexes si vous utilisez un RTOS.
  • Impression mémoire – L'allocation statique est plus sûre que dynamique . Utilisez des constantes pour les tailles de tampon.
  • Perte de puissance – Pour le stockage flash, évitez les écritures fréquentes de petite taille.

Exemple pratique : Une base de données de enregistreur de température

Considérez un capteur de température IoT qui enregistre les relevés toutes les minutes et les stocke localement pendant 24 heures. Le moteur de base de données doit gérer 1440 enregistrements (un par minute). Chaque enregistrement peut contenir un horodatage (Uneix epoch), une température (float) et un ID de capteur. L'utilisation du pool fixe avec 256 emplacements est trop petite; ici, nous avons besoin . Avec 28 octets par enregistrement (4+4+4+4+4 pour les frais généraux), le pool utilise environ 40 KB, possible sur de nombreux microcontrôleurs avec 128 KB RAM.

Le moteur peut stocker les données de façon à les tamponner : lorsque le pool est plein, le plus ancien enregistrement est écrasé. Implémentez un pointeur "head" pour la prochaine fente d'écriture et une "tail" pour le plus ancien enregistrement actif. Ceci évite la logique de liste libre et fournit O(1) insert. La recherche peut être optimisée avec une recherche binaire sur les horodatages si les enregistrements sont stockés dans l'ordre chronologique.

typedef struct {
 uint32_t timestamp;
 float temp_c;
 uint8_t sensor_id;
 uint8_t active; // not needed if using ring buffer
} TemperatureRecord;

#define MAX_LOGS 1440
TemperatureRecord logs[MAX_LOGS];
uint16_t head = 0; // next write position
uint16_t count = 0; // number of valid records

Insérer une lecture : écrire à , incrément modulo , incrément (encastré à ). Rechercher un chronomètre spécifique : si compte == MAX LOGS, le journal est une séquence contiguë de la tête à la tête-1 (wrap). Utilisez la recherche binaire après avoir calculé le démarrage virtuel. Ce modèle est extrêmement léger et largement utilisé dans les systèmes de télémétrie. Les bases du tampon de ring fournissent des options d'implémentation supplémentaires.

Essais et optimisations sur le matériel cible

Toujours tester le moteur de base de données sur le matériel embarqué réel. Les émulateurs manquent les contraintes de temps, en particulier pour les cycles d'écriture flash et les scénarios de perte de puissance. Surveiller l'utilisation de RAM avec un outil de profilage et vérifier les cas de bord: stockage complet, données corrompues, réinitialiser mi-écriture.

  • Nivelage d'usure par décharge – Si vous écrivez à EEPROM ou NOR flash, limitez le total des écritures à quelques centaines de milliers. Un tampon circulaire avec une couche de nivellement prolonge la durée de vie.
  • Safe-fail[ – Utilisez une marque de commit : écrivez un octet de drapeau après un lot complet d'enregistrements. Au redémarrage, vérifiez le drapeau; si vous manquez, jetez le dernier lot et retournez à l'état précédent.
  • Mémoires de mise en commun[ – Évitez les récursions profondes. Gardez l'appel de fonction empile peu profond. Utilisez des tampons statiques pour le fichier E/S.

Conclusion

En mettant l'accent sur des structures de données simples comme les pools d'enregistrements fixes et les tampons à anneaux, les développeurs réalisent des opérations CRUD efficaces avec des frais généraux minimes. L'ajout d'indexations et de persistances de base permet de transformer un tableau simple en datastore local fiable. Les techniques décrites ici s'étendent des petits enregistreurs de capteurs aux systèmes plus sophistiqués, et elles fournissent une base pour comprendre comment les bases de données intégrées plus grandes comme SQLite ou Berkeley DB fonctionnent sous le capot.