Control Systems and Automation
Implementing a Basic Database Engine in C for Embedded Systems
Table of Contents
Sistemas incorporados muitas vezes requerem gerenciamento de dados local sem a sobrecarga de servidores de banco de dados completos. A implementação de um mecanismo de banco de dados leve em C fornece aos desenvolvedores controle direto sobre memória, desempenho e armazenamento. Este artigo caminha através do projeto e implementação de um motor de banco de dados simples incorporado, cobrindo estruturas de dados, operações CRUD, indexação e estratégias de persistência para ambientes restritos a recursos.
Requisitos essenciais para um motor de banco de dados incorporado
Um motor de banco de dados incorporado deve operar dentro de limites apertados na RAM, flash e velocidade de processamento. Os requisitos típicos incluem comportamento determinístico, pegada de código mínima, e nenhuma dependência externa. O motor deve suportar operações básicas: inserir, recuperar, atualizar, excluir e pesquisar. Muitos bancos de dados incorporados também precisam sobreviver à perda de energia e armazenar dados em memória não volátil, como EEPROM, flash SPI, ou cartões SD.
Escolher a estrutura de dados correta é a primeira decisão de projeto. As linhas são simples, mas limitadas por tamanhos estáticos. Listas ligadas permitem o crescimento dinâmico, mas adicionam o ponteiro acima. Para um desempenho equilibrado, uma abordagem híbrida usando conjuntos de registros de tamanho fixo com uma lista livre pode funcionar bem. Os princípios de design do SQLite oferecem insights úteis, mesmo para motores muito mais simples.
Projetando a Camada de Armazenamento de Registros
A camada de armazenamento gerencia como os registros são dispostos na memória ou no disco. Um padrão comum é tratar cada registro como uma estrutura de comprimento fixo para simplificar a aritmética do ponteiro e permitir a indexação direta. Registros de comprimento variável complicam a fragmentação e requerem um gerenciador de memória.
Registros de comprimento fixo com um conjunto de registros
Defina um número máximo de registros (por exemplo, [FLT: 0]]) e aloque um array estático. Um bitmap ou faixas de lista livre que são usadas. Quando um registro é excluído, seu slot retorna ao pool. Esta abordagem evita alocação dinâmica e garante o tempo de alocação 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];
Para armazenamento persistente, o pool pode ser suportado por um arquivo ou uma região de flash. No arranque, o motor lê o pool de memória não volátil para RAM, e ao desligar (ou periodicamente) ele o escreve de volta.
Verificação da integridade dos dados
Adicione um campo de soma simples a cada registro para detectar corrupção. CRC-32 é uma boa escolha para sistemas incorporados, balanceando complexidade com força de detecção de erros.
Implementação de Operações Básicas CRUD
Com o conjunto de registros definido, implemente funções para inserir, encontrar, atualizar e excluir registros. Operações de pesquisa são muitas vezes o gargalo de desempenho, então uma busca linear ingênua é aceitável apenas para bancos de dados pequenos (algumas centenas de registros).
Inserir com o Gerenciamento de Lista Livre
Manter uma lista livre de índices. Ao inserir, pop um índice da lista livre, preencha o registro e marque- o ativo. A lista livre em si pode ser uma pilha simples usando uma matriz de inteiros.
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;
}
Procurar e Atualizar
Uma simples pesquisa itera-se sobre o pool, verificando apenas registros ativos. Para atualizações, localize o registro, modifique campos e, opcionalmente, verifique novamente a lista livre se o registro for excluído.
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;
}
Conceitos Avançados: Indexação e persistência
À medida que o número de registros cresce, a pesquisa linear se torna cara. Adicionar um índice simples, como uma matriz ordenada de ponteiros de chaves ou uma árvore de pesquisa binária, melhora o tempo de recuperação. Para sistemas embarcados, um array estático ordenado na chave com pesquisa binária é frequentemente suficiente se inserções são pouco frequentes.
Índice Ordenado com Pesquisa Bíblica
Manter uma matriz paralela de índices de registro ordenados pela chave de pesquisa (por exemplo, ID). Ao inserir um novo registro, insira seu índice no array ordenado usando uma inserção binária. Em seguida, a pesquisa torna- se O( log n) via pesquisa binária. As deleções requerem mudar o array de índice, mas para bancos de dados pequenos isso é aceitável.
Armazenamento Persistente Usando o Arquivo I/O
Em microcontroladores sem sistema de arquivos, as gravações de memória flash são comuns. Em sistemas embarcados baseados em Linux, o POSIX padrão // funciona bem. Use um formato de arquivo simples: escreva um cabeçalho (número mágico, versão, contagem de registros) seguido pelo array de piscinas em bruto. Para a resiliência de quebras, um registro de gravação (WAL) pode ajudar, mas para motores básicos uma gravação atômica (que se encaixa em uma página flash) é suficiente. FreeRTOS+FAT[] é uma opção leve do sistema de arquivos para sistemas profundamente incorporados.
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;
}
}
Restrições e restrições de tratamento
Os motores incorporados de banco de dados enfrentam uma constante troca entre recursos e uso de recursos. Escolher quais recursos incluir depende da aplicação:
- Compliance ACID – Normalmente não é necessário. As gravações atômicas simples são suficientes para a maioria dos registros de dados de sensores.
- Indexing – Adiciona custo de inserção, mas acelera leituras. Para cargas de trabalho pesadas, skip indexs.
- Concorrencia – A maioria dos sistemas incorporados executam uma única thread. Aproveite os mutexes se usarem um RTOS.
- Pegada de memória – Alocação estática é mais segura do que a dinâmica . Use constantes para tamanhos de buffer.
- Perda de energia – Para armazenamento flash, evite escrever pequenas vezes. Atualizações em lote e use um esquema de duplo buffer.
Exemplo prático: Um banco de dados de registradores de temperatura
Considere um sensor de temperatura IoT que grava leituras a cada minuto e as armazena localmente por 24 horas. O motor de banco de dados deve lidar com 1440 registros (um por minuto). Cada registro pode conter uma data (temporização Unix), uma temperatura (float) e um ID do sensor. Usando o pool de registro fixo com 256 slots é muito pequeno; aqui precisamos . Com 28 bytes por registro (4+4+4+4 para sobrecarga), o pool usa cerca de 40 KB, viável em muitos microcontroladores com 128 KB de RAM.
O motor pode armazenar dados de uma forma de ring-buffer: quando o pool estiver cheio, o registro mais antigo é substituído. Implemente um ponteiro "head" para o próximo slot de gravação e um "tail" para o registro ativo mais antigo. Isto evita a lógica da lista livre e fornece o inserto O(1). A pesquisa pode ser otimizada com uma pesquisa binária em timestamps se os registros forem armazenados em ordem cronológica.
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
Inserindo uma leitura: escreva para , incremento ] módulo , incremento (capado em ). Procurando por um temporizador específico: se contar == MAX LOGS, o log é uma sequência contígua da cabeça à cabeça-1 (wrap). Use a pesquisa binária após computar o início virtual. Este padrão é extremamente leve e amplamente utilizado em sistemas de telemetria. Os princípios básicos do buffer de ring fornecem opções adicionais de implementação.
Teste e otimização no hardware alvo
Teste sempre o motor de banco de dados em hardware incorporado. Os emuladores falham as restrições de tempo, especialmente para ciclos de gravação em flash e cenários de perda de energia. Monitore o uso da RAM com uma ferramenta de perfil e verifique os casos de borda: armazenamento completo, dados corrompidos, repor a gravação média. Um arnês de teste simples executa milhares de inserções aleatórias, pesquisas e exclusão, comparando com um modelo dourado.
- Flash desgaste nivelamento – Se escrever para EEPROM ou NOR flash, limite total escreve para algumas centenas de milhares. Um buffer circular com uma camada desgaste-nivelamento prolonga a vida útil.
- Power-fail safe – Use uma marca de commit: escreva um byte de bandeira após um lote completo de registros. Ao reiniciar, verifique a bandeira; se faltar, descarte o último lote e retorne ao estado anterior.
- ]Poolagem de memória – Evite recursão profunda. Mantenha as pilhas de chamadas de função rasas. Use buffers estáticos para E/S de arquivo.
Conclusão
Construir um mecanismo básico de banco de dados em C para sistemas incorporados é uma abordagem prática para gerenciar dados em dispositivos limitados por recursos. Ao focar em estruturas de dados simples como conjuntos de registros fixos e buffers de anéis, os desenvolvedores conseguem operações de CRUD eficientes com uma sobrecarga mínima. Adicionando indexação opcional e persistência básica transforma um array simples em uma loja de dados local confiável. As técnicas aqui descritas vão de pequenos registradores de sensores para sistemas mais sofisticados, e eles fornecem uma base para entender como bancos de dados incorporados maiores como SQLite ou Berkeley DB operam sob o capô.