Control Systems and Automation
Implementing a Basic Database Engine in C for Embedded Systems
Table of Contents
Eingebettete Systeme erfordern oft lokales Datenmanagement ohne den Overhead von vollständigen Datenbankservern. Die Implementierung einer leichtgewichtigen Datenbank-Engine in C gibt Entwicklern direkte Kontrolle über Speicher, Leistung und Speicher. Dieser Artikel geht durch das Design und die Implementierung einer einfachen eingebetteten Datenbank-Engine, die Datenstrukturen, CRUD-Operationen, Indexierung und Persistenzstrategien für ressourcenbeschränkte Umgebungen abdeckt.
Grundvoraussetzungen für eine Embedded Database Engine
Eine Embedded-Datenbank-Engine muss innerhalb enger Grenzen von RAM, Flash und Verarbeitungsgeschwindigkeit arbeiten. Typische Anforderungen sind deterministisches Verhalten, minimaler Code-Fußabdruck und keine externen Abhängigkeiten. Die Engine sollte grundlegende Operationen unterstützen: Einfügen, Abrufen, Aktualisieren, Löschen und Suchen. Viele eingebettete Datenbanken müssen auch den Stromverlust überleben und Daten auf nichtflüchtigen Speichern wie EEPROM, SPI Flash oder SD-Karten speichern.
Die Wahl der richtigen Datenstruktur ist die erste Designentscheidung. Arrays sind einfach, aber durch statische Größen begrenzt. Verknüpfte Listen ermöglichen dynamisches Wachstum, fügen jedoch einen Pointer-Overhead hinzu. Für eine ausgewogene Leistung kann ein hybrider Ansatz mit feststehenden Datensatzpools mit einer freien Liste gut funktionieren. SQLite's Designprinzipien bieten nützliche Einblicke auch für viel einfachere Engines.
Design der Record Storage Layer
Die Speicherschicht verwaltet, wie Datensätze im Speicher oder auf der Festplatte angelegt werden. Ein gängiges Muster ist, jeden Datensatz als Struktur mit fester Länge zu behandeln, um die Zeigerarithmetik zu vereinfachen und eine direkte Indexierung zu ermöglichen.
Feste Datensätze mit einem Rekordpool
Eine maximale Anzahl von Datensätzen (z. B. ) definieren und ein statisches Array zuweisen. Eine Bitmap oder eine freie Liste verfolgt, welche Slots verwendet werden. Wenn ein Datensatz gelöscht wird, kehrt sein Slot zum Pool zurück. Dieser Ansatz vermeidet dynamische Zuweisung und garantiert O(1) Zuweisungszeit.
#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];
Für persistente Speicherung kann der Pool durch eine Datei oder einen Flash-Bereich gesichert werden. Beim Start liest die Engine den Pool aus einem nichtflüchtigen Speicher in den RAM und schreibt ihn beim Herunterfahren (oder periodisch) zurück.
Datenintegritätsprüfungen
Fügen Sie jedem Datensatz ein einfaches Prüfsummenfeld hinzu, um Korruption zu erkennen. CRC-32 ist eine gute Wahl für eingebettete Systeme, die Komplexität mit der Stärke der Fehlererkennung ausgleichen.
Implementierung von Basic CRUD Operationen
Mit dem Datensatzpool definiert, implementieren Funktionen zum Einfügen, Finden, Aktualisieren und Löschen von Datensätzen. Suchoperationen sind oft der Leistungsengpass, so dass eine naive lineare Suche nur für kleine Datenbanken (einige hundert Datensätze) akzeptabel ist.
Einfügen mit Free-List Management
Wenn man einen Index aus der freien Liste einfügt, den Datensatz ausfüllt und aktiv markiert, kann die freie Liste selbst ein einfacher Stapel sein, der ein Array von ganzen Zahlen verwendet.
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;
}
Suche und Update
Eine einfache Suche wird über den Pool wiederholt, wobei nur aktive Datensätze überprüft werden.
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;
}
Fortgeschrittene Konzepte: Indexierung und Persistenz
Wenn die Anzahl der Datensätze wächst, wird die lineare Suche teuer. Das Hinzufügen eines einfachen Indexes - wie ein sortiertes Array von Schlüsselzeigern oder ein binärer Suchbaum - verbessert die Abrufzeit. Bei eingebetteten Systemen reicht ein statisches Array, das mit binärer Suche auf dem Schlüssel sortiert wird, oft aus, wenn Einsätze selten sind.
Sortierter Index mit binärer Suche
Wenn ein neuer Datensatz mit einem binären Eintrag in das sortierte Feld eingefügt wird, wird die Suche über die binäre Suche zu O(log n) . Löschungen erfordern eine Verschiebung des Indexfeldes, für kleine Datenbanken ist dies jedoch akzeptabel.
Persistente Speicherung mit Datei-I/O
Auf Mikrocontrollern ohne Dateisystem sind Roh-Flash-Speicherschreibvorgänge üblich. Auf Linux-basierten eingebetteten Systemen funktioniert Standard-POSIX // gut. Verwenden Sie ein einfaches Dateiformat: Schreiben Sie einen Header (magische Nummer, Version, Datensatzzahl) gefolgt von der Rohpool-Anordnung. Für die Crash-Resilienz kann ein Write-Ahead-Log (WAL) helfen, aber für grundlegende Engines reicht ein einziger Atomschreibvorgang (der in eine Flash-Seite passt). FreeRTOS + FAT ist eine leichte Dateisystemoption für tief eingebettete Systeme.
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;
}
}
Umgang mit Einschränkungen und Trade-offs
Embedded-Datenbank-Engines stehen vor einem ständigen Kompromiss zwischen Features und Ressourcenverbrauch.
- ACID-Compliance – Normalerweise nicht erforderlich. Einfache Atomschreibvorgänge reichen für die meisten Sensordatenprotokollierung aus.
- Indexing – Addiert die Einfügekosten, beschleunigt aber Lesevorgänge.
- Konkurrenz – Die meisten eingebetteten Systeme laufen mit einem einzigen Thread.
- Memory Footprint – Statische Allokation ist sicherer als dynamische ] Verwenden Sie Konstanten für Puffergrößen.
- Stromverlust – Vermeiden Sie für Flash-Speicherung häufige kleine Schreibvorgänge. Batch-Updates und verwenden Sie ein Doppelpufferschema.
Praktisches Beispiel: Eine Temperaturlogger-Datenbank
Betrachten wir einen IoT-Temperatursensor, der Messwerte jede Minute aufzeichnet und sie 24 Stunden lang lokal speichert. Die Datenbank-Engine muss 1440 Datensätze verarbeiten (einen pro Minute). Jeder Datensatz könnte einen Zeitstempel (Unix-Epoche), eine Temperatur (Float) und eine Sensor-ID enthalten. Die Verwendung des Fixed-Record-Pools mit 256 Slots ist zu klein; hier benötigen wir . Mit 28 Bytes pro Datensatz (4+4+4+4 für Overhead) verbraucht der Pool etwa 40 KB, was auf vielen Mikrocontrollern mit 128 KB RAM möglich ist.
Die Engine kann Daten in Ringpuffer-Manier speichern: Wenn der Pool voll ist, wird der älteste Datensatz überschrieben. Implementieren Sie einen "Kopf"-Zeiger für den nächsten Schreibschlitz und einen "Schwanz" für den ältesten aktiven Datensatz. Dies vermeidet die Freilistenlogik und liefert O(1)-Einsatz. Die Suche kann mit einer binären Suche nach Zeitstempeln optimiert werden, wenn die Datensätze in chronologischer Reihenfolge gespeichert werden.
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
Einfügen einer Lesung: schreiben Sie an , Inkrement modulo (begrenzt auf ). Suchen Sie nach einem bestimmten Zeitstempel: Wenn Zählen == MAX LOGS ist das Protokoll eine zusammenhängende Sequenz von Kopf nach Kopf-1 (Wrap). Verwenden Sie die binäre Suche nach dem Berechnen des virtuellen Starts. Dieses Muster ist extrem leicht und wird in Telemetriesystemen weit verbreitet. Ringpuffergrundlagen bieten zusätzliche Implementierungsoptionen.
Testen und Optimieren auf Target Hardware
Testen Sie die Datenbank-Engine immer auf tatsächlich eingebetteter Hardware. Emulatoren verpassen Zeitbeschränkungen, insbesondere für Flash-Schreibzyklen und Stromverlustszenarien. Überwachen Sie die RAM-Auslastung mit einem Profiling-Tool und überprüfen Sie Edge-Fälle: voller Speicher, beschädigte Daten, Reset mitten im Schreiben. Ein einfaches Test-Geschirr führt Tausende von zufälligen Einsätzen aus, sucht und löscht, während Sie mit einem goldenen Modell vergleichen.
- Flash Wear Leveling – Wenn Sie in EEPROM oder NOR Flash schreiben, schreibt die Grenze total auf einige hunderttausend. Ein kreisförmiger Puffer mit einer Wear Leveling-Schicht verlängert die Lebensdauer.
- Power-fail safe – Verwenden Sie ein Commit-Zeichen: Schreiben Sie ein Flag-Byte nach einem vollständigen Stapel von Datensätzen.
- Memory Pooling – Vermeiden Sie tiefe Rekursionen. Halten Sie die Funktion Call Stacks flach. Verwenden Sie statische Puffer für Datei-I/O.
Schlussfolgerung
Der Aufbau einer Basisdatenbank-Engine in C für eingebettete Systeme ist ein praktischer Ansatz zur Verwaltung von Daten in ressourcenbegrenzten Geräten. Durch die Konzentration auf einfache Datenstrukturen wie Pools mit festen Datensätzen und Ringpuffern erreichen Entwickler effiziente CRUD-Operationen mit minimalem Overhead. Durch das Hinzufügen von optionaler Indexierung und grundlegender Persistenz wird ein einfaches Array zu einem zuverlässigen lokalen Datenspeicher. Die hier beschriebenen Techniken reichen von kleinen Sensorloggern bis hin zu anspruchsvolleren Systemen und bieten eine Grundlage für das Verständnis, wie größere eingebettete Datenbanken wie SQLite oder Berkeley DB unter der Haube funktionieren.