Gravando logs no SD Card com uma tarefa dedicada

Em um sistema embarcado com FreeRTOS, uma boa prática é evitar que qualquer parte do firmware grave diretamente no cartão SD. Embora seja tecnicamente possível abrir um arquivo e escrever nele a partir de várias tarefas, isso costuma tornar o sistema mais frágil. O ideal é concentrar o acesso ao sistema de arquivos em uma tarefa especializada, que recebe mensagens de outras partes do sistema e grava no cartão de forma organizada.
Essa abordagem cria uma separação clara de responsabilidades. As tarefas de sensores, comunicação, controle ou inferência não precisam conhecer FATFS, nomes de arquivos, montagem do cartão ou detalhes de escrita. Elas apenas enviam mensagens para uma fila. A tarefa de armazenamento recebe essas mensagens e decide quando abrir o arquivo, gravar, sincronizar e fechar.
Nesse modelo, o cartão SD fica protegido naturalmente, porque apenas uma tarefa escreve nele. Ainda podemos usar mutex, principalmente se outras funções do sistema também precisarem ler arquivos de configuração, mas a escrita contínua de logs fica centralizada.
Vamos definir primeiro uma estrutura simples para representar uma mensagem de log:
#ifndef STORAGE_LOG_H
#define STORAGE_LOG_H
#include <stdint.h>
#define LOG_MESSAGE_SIZE 96
typedef struct
{
uint32_t timestamp_ms;
char message[LOG_MESSAGE_SIZE];
} StorageLogMessage_t;
#endif
Aqui usamos um timestamp_ms para registrar o instante aproximado do evento e um campo textual com tamanho fixo. Em sistemas embarcados, estruturas com tamanho fixo são mais previsíveis, evitam alocação dinâmica e facilitam o uso com filas do FreeRTOS.
Agora podemos declarar a fila global:
#include "cmsis_os.h"
#include "storage_log.h"
osMessageQueueId_t logQueueHandle;
void App_CreateResources(void)
{
const osMessageQueueAttr_t logQueue_attributes = {
.name = "logQueue"
};
logQueueHandle = osMessageQueueNew(
16,
sizeof(StorageLogMessage_t),
&logQueue_attributes
);
}
A fila acima guarda até 16 mensagens. Esse valor deve ser escolhido conforme a taxa de geração de logs e a velocidade de escrita no SD Card. Se o sistema gera logs muito rapidamente, a fila pode encher. Nesse caso, é preciso decidir se o firmware deve descartar mensagens antigas, bloquear a tarefa produtora ou registrar apenas eventos críticos.
Uma tarefa qualquer pode enviar logs assim:
#include "cmsis_os.h"
#include "storage_log.h"
#include <stdio.h>
#include <string.h>
extern osMessageQueueId_t logQueueHandle;
void SensorTask(void *argument)
{
uint32_t counter = 0;
for (;;)
{
StorageLogMessage_t log;
log.timestamp_ms = osKernelGetTickCount();
snprintf(
log.message,
sizeof(log.message),
"Amostra do sensor: %lu",
(unsigned long)counter
);
osMessageQueuePut(
logQueueHandle,
&log,
0,
0
);
counter++;
osDelay(1000);
}
}
Essa tarefa não sabe onde o log será salvo. Ela apenas produz uma mensagem e envia para a fila. Isso melhora bastante a organização do firmware, porque o armazenamento deixa de ficar espalhado pelo código.
Agora vem a tarefa de armazenamento:
#include "ff.h"
#include "fatfs.h"
#include "cmsis_os.h"
#include "storage_log.h"
#include <stdio.h>
#include <string.h>
extern FATFS SDFatFS;
extern char SDPath[4];
extern osMessageQueueId_t logQueueHandle;
void StorageTask(void *argument)
{
FRESULT result;
FIL file;
StorageLogMessage_t log;
UINT bytesWritten;
result = f_mount(&SDFatFS, SDPath, 1);
if (result != FR_OK)
{
for (;;)
{
osDelay(1000);
}
}
result = f_open(&file, "system_log.csv", FA_OPEN_APPEND | FA_WRITE);
if (result != FR_OK)
{
f_mount(NULL, SDPath, 0);
for (;;)
{
osDelay(1000);
}
}
const char *header = "timestamp_ms,message\r\n";
if (f_size(&file) == 0)
{
f_write(
&file,
header,
strlen(header),
&bytesWritten
);
f_sync(&file);
}
for (;;)
{
if (osMessageQueueGet(logQueueHandle, &log, NULL, osWaitForever) == osOK)
{
char line[160];
int len = snprintf(
line,
sizeof(line),
"%lu,%s\r\n",
(unsigned long)log.timestamp_ms,
log.message
);
if (len > 0 && len < sizeof(line))
{
result = f_write(
&file,
line,
(UINT)len,
&bytesWritten
);
if (result == FR_OK && bytesWritten == (UINT)len)
{
f_sync(&file);
}
}
}
}
}
Esse código monta o cartão, abre o arquivo system_log.csv em modo de acréscimo e passa a esperar mensagens na fila. Quando uma mensagem chega, ela é formatada como uma linha CSV e gravada no cartão. O uso de FA_OPEN_APPEND permite preservar logs anteriores, acrescentando novas linhas no final do arquivo.
O arquivo gerado ficaria assim:
timestamp_ms,message
1000,Amostra do sensor: 0
2000,Amostra do sensor: 1
3000,Amostra do sensor: 2
Esse formato é simples, mas muito útil. Ele pode ser lido em um editor de texto, importado em uma planilha ou processado por scripts em Python. Em projetos de aquisição de dados, diagnóstico de falhas, telemetria ou manutenção preditiva, salvar dados em CSV facilita bastante a etapa de análise posterior.
Um ponto importante é o uso de f_sync. Ele reduz o risco de perda de dados, mas também aumenta o número de operações físicas no cartão. Em sistemas que geram muitos logs por segundo, chamar f_sync a cada linha pode prejudicar o desempenho e aumentar o desgaste da mídia. Uma estratégia melhor pode ser sincronizar a cada grupo de mensagens, a cada intervalo de tempo ou antes de um desligamento controlado.
Uma versão mais econômica poderia usar um contador:
uint32_t syncCounter = 0;
if (result == FR_OK && bytesWritten == (UINT)len)
{
syncCounter++;
if (syncCounter >= 10)
{
f_sync(&file);
syncCounter = 0;
}
}
Nesse caso, o firmware sincroniza o arquivo a cada 10 linhas gravadas. Há um compromisso técnico: se a energia cair, algumas últimas linhas podem ser perdidas, mas o desempenho melhora. Essa decisão depende da criticidade do log.
Também é possível dividir os logs por arquivo, por exemplo:
log_0001.csv
log_0002.csv
log_0003.csv
Ou por tipo de evento:
sensor.csv
error.csv
network.csv
inference.csv
No STM32N6, essa abordagem é especialmente útil porque o microcontrolador pode executar tarefas mais sofisticadas, como aquisição de sinais, pré-processamento, comunicação e inferência embarcada. O cartão SD pode funcionar como memória de massa para registrar resultados, armazenar configurações e coletar dados para análise posterior.
A ideia principal desta seção é que o sistema de arquivos deve ser tratado como um recurso compartilhado e relativamente lento. Em vez de permitir que qualquer parte do firmware escreva no SD Card, criamos uma tarefa dedicada de armazenamento, isolando a complexidade e tornando o sistema mais previsível.