Introdução: FreeRTOS em sistemas com lwIP
A combinação do FreeRTOS com a pilha de rede lwIP (Lightweight IP) é hoje uma das arquiteturas mais comuns em sistemas embarcados conectados, especialmente em microcontroladores Cortex-M (STM32, NXP, RP2040 com stack externa, ESP32 quando abstraído), onde há restrição severa de memória, tempo real e consumo energético. O FreeRTOS fornece o modelo concorrente determinístico (tarefas, prioridades, sincronização), enquanto o lwIP entrega uma implementação enxuta da pilha TCP/IP, compatível com IPv4, IPv6, TCP, UDP, ICMP, ARP, DHCP, DNS e HTTP, entre outros protocolos.
O ponto crítico — e frequentemente negligenciado — é que o lwIP não é apenas uma biblioteca de rede, mas sim um framework cooperativo que precisa ser corretamente integrado ao modelo de execução do RTOS. Dependendo da configuração (RAW API, Netconn API ou BSD Sockets API), o lwIP pode operar de forma event-driven, thread-safe ou thread-aware, exigindo decisões arquiteturais bem fundamentadas. Uma escolha inadequada costuma resultar em deadlocks, stack overflow, perda de pacotes, latências imprevisíveis ou até hard faults difíceis de diagnosticar.
Em sistemas com FreeRTOS, o lwIP normalmente é encapsulado em uma ou mais tarefas dedicadas, além de callbacks executados a partir de interrupções (ETH IRQ, DMA RX/TX) ou timers de software. Isso cria um ambiente híbrido onde código de interrupção, tarefas e a pilha TCP/IP precisam cooperar com rigor: uso correto de semáforos, mutexes, mailboxes (queues), buffers e prioridades. O entendimento dessa interação é essencial para construir aplicações robustas como servidores HTTP, MQTT brokers/clients, WebSockets, OTA, Modbus/TCP, Profinet sobre TCP/IP, entre outras.
Nesta série de artigos, este material assume um cenário típico e realista:
- FreeRTOS rodando como sistema operacional principal
- lwIP configurado com TCP/IP thread dedicada
- Interface Ethernet (MAC + PHY) ou driver de rede equivalente
- Comunicação entre tarefas da aplicação e a pilha de rede
- Ênfase em boas práticas, arquitetura correta e exemplos práticos em C
Nas próximas seções, vamos avançar progressivamente, começando pela arquitetura interna do lwIP quando integrado ao FreeRTOS, passando pelas formas de integração (RAW, Netconn e Sockets), até chegar a exemplos completos como servidor HTTP, cliente TCP e sincronização entre tarefas da aplicação e a pilha de rede.
Arquitetura do lwIP em sistemas com FreeRTOS
Quando o lwIP é integrado a um sistema com FreeRTOS, ele deixa de ser apenas uma pilha TCP/IP “passiva” e passa a fazer parte ativa do modelo concorrente do sistema. O ponto central dessa arquitetura é a chamada TCP/IP thread (normalmente criada por tcpip_init()), que se torna o contexto exclusivo e soberano para quase todas as operações internas do lwIP. Isso significa que estruturas críticas como PCB (Protocol Control Blocks), buffers pbuf, timers TCP e estados de conexão não são thread-safe por padrão e devem ser manipulados apenas dentro desse contexto controlado.
Na prática, o lwIP organiza seu funcionamento em três grandes domínios de execução: interrupções de hardware, TCP/IP thread e tarefas da aplicação. As interrupções normalmente vêm do driver Ethernet (IRQ do MAC ou DMA RX/TX), cujo papel é mínimo: sinalizar que há pacotes recebidos ou buffers liberados, evitando qualquer processamento pesado. Essas interrupções acordam a TCP/IP thread por meio de semaphores ou mailboxes, garantindo que todo o processamento de protocolos ocorra fora do contexto de ISR, preservando determinismo e estabilidade do sistema.
A TCP/IP thread funciona como um dispatcher de eventos de rede. Ela processa pacotes recebidos, executa timers periódicos (TCP retransmission, ARP aging, DHCP renewal), gerencia conexões e chama callbacks registrados pela aplicação. É aqui que reside uma armadilha comum: callbacks do lwIP não executam no contexto da task da aplicação, mas sim dentro da TCP/IP thread. Isso exige extremo cuidado ao acessar recursos compartilhados, como filas do FreeRTOS, buffers globais ou drivers de periféricos, sob pena de criar condições de corrida difíceis de rastrear.
As tarefas da aplicação, por sua vez, nunca devem acessar diretamente estruturas internas do lwIP, exceto quando se utiliza APIs explicitamente thread-safe (como Netconn ou Sockets). Quando a arquitetura é bem desenhada, as tasks da aplicação se comunicam com a TCP/IP thread usando mensagens, filas ou chamadas assíncronas, mantendo uma separação clara entre lógica de negócio e infraestrutura de rede. Esse desacoplamento é um dos fatores-chave para escalabilidade e manutenibilidade do firmware.
Do ponto de vista conceitual, pense no lwIP como um ator central que serializa todo o tráfego de rede, enquanto o FreeRTOS fornece o orquestrador de concorrência ao redor dele. Quando esse modelo é respeitado, o sistema se comporta de forma previsível mesmo sob carga elevada de rede. Quando é violado — por exemplo, chamando funções RAW do lwIP a partir de tasks arbitrárias — os sintomas surgem como bugs intermitentes, travamentos aleatórios ou degradação severa de desempenho.
Na próxima seção, vamos entrar em um dos pontos mais críticos e confusos para quem trabalha com lwIP:
as três APIs disponíveis (RAW API, Netconn API e BSD Sockets API), explicando quando usar cada uma, suas vantagens, limitações e impactos diretos na arquitetura FreeRTOS.
As APIs do lwIP no contexto do FreeRTOS: RAW, Netconn e Sockets
Um dos pontos que mais geram confusão — e erros arquiteturais — no uso do lwIP com FreeRTOS é a existência de três APIs distintas, cada uma com modelos mentais, custos e implicações completamente diferentes. A escolha da API não é apenas uma questão de preferência sintática; ela define como as tasks interagem com a pilha TCP/IP, quais garantias de thread-safety existem e qual será o impacto em latência, uso de memória e complexidade do sistema.
A RAW API é a forma mais direta e eficiente de usar o lwIP. Ela é totalmente event-driven, baseada em callbacks, e opera exclusivamente no contexto da TCP/IP thread. Não existe bloqueio, não existem semáforos implícitos, nem cópia adicional de buffers. Cada evento de rede — conexão estabelecida, dados recebidos, erro ou fechamento — dispara uma função de callback registrada previamente. Isso torna a RAW API ideal para sistemas com restrições severas de RAM e tempo real rígido, mas ao custo de maior complexidade cognitiva. Em sistemas FreeRTOS, a RAW API não deve ser chamada diretamente a partir de tasks da aplicação; qualquer interação precisa ser mediada por mensagens ou funções como tcpip_callback().
Já a Netconn API atua como uma camada intermediária entre a RAW API e o modelo tradicional de threads. Ela encapsula a lógica baseada em callbacks dentro de uma API bloqueante e thread-safe, usando mailboxes e semaphores internos. Para o desenvolvedor FreeRTOS, isso significa poder escrever código sequencial — netconn_accept(), netconn_recv(), netconn_write() — dentro de uma task, sem violar as regras internas do lwIP. O custo disso é um pequeno aumento de consumo de memória e latência, além de menos controle fino sobre eventos de baixo nível. Em projetos industriais, a Netconn API costuma ser um equilíbrio muito saudável entre robustez e simplicidade.
A BSD Sockets API, por sua vez, é a mais familiar para quem vem do mundo Linux ou POSIX. Ela oferece funções como socket(), bind(), listen(), accept(), recv() e send(), com semântica muito próxima à de sistemas operacionais completos. Internamente, ela é construída sobre a Netconn API, herdando suas características e custos. Em FreeRTOS, a Sockets API facilita a portabilidade de código legado e acelera o desenvolvimento inicial, mas frequentemente induz a arquiteturas pobres, com tasks bloqueantes demais, pilha grande e baixa previsibilidade temporal se não for bem planejada.
A decisão correta normalmente segue este raciocínio: se o sistema é altamente restrito e orientado a eventos, use RAW API; se precisa de clareza, robustez e integração limpa com FreeRTOS, use Netconn; se o foco é portabilidade e velocidade de desenvolvimento, a Sockets API pode ser aceitável — desde que o impacto em recursos seja cuidadosamente controlado. Em sistemas críticos, misturar APIs sem critério é uma receita certa para problemas difíceis de depurar.
Na próxima seção, vamos aprofundar exatamente como o FreeRTOS e o lwIP se comunicam internamente, explorando mailboxes, semaphores, timers e a função tcpip_init(), com diagramas conceituais e exemplos práticos em C.
Integração interna entre FreeRTOS e lwIP: mailboxes, semáforos e tcpip_init()
A espinha dorsal da integração entre FreeRTOS e lwIP é o mecanismo de mensagens assíncronas que garante que todo o processamento da pilha TCP/IP ocorra em um único contexto controlado. Esse contexto é criado pela chamada a tcpip_init(), responsável por inicializar o lwIP e criar a TCP/IP thread, além de suas estruturas de sincronização internas. Entender exatamente o que acontece aqui é essencial para evitar violações de thread-safety e erros sutis de concorrência.
Quando tcpip_init() é chamada, o lwIP cria internamente uma task do FreeRTOS (normalmente chamada de tcpip_thread) e associa a ela uma mailbox principal. Essa mailbox funciona como uma fila de mensagens do RTOS, por onde chegam eventos como pacotes recebidos, timers expirados ou callbacks solicitados por outras tasks. O modelo é deliberadamente serial: a TCP/IP thread processa uma mensagem por vez, garantindo que as estruturas internas do lwIP nunca sejam acessadas concorrentemente.
As interrupções de rede (por exemplo, RX DMA do Ethernet MAC) não processam protocolos diretamente. Em vez disso, elas apenas notificam o sistema — geralmente liberando um semáforo ou enviando um ponteiro de buffer para uma mailbox. O driver Ethernet, então, acorda a TCP/IP thread, que passa a processar os pacotes no contexto correto. Esse desenho é fundamental para manter latência previsível e evitar execução de código pesado em ISR, algo especialmente crítico em sistemas com FreeRTOS.
Para permitir que tasks da aplicação interajam com o lwIP sem violar esse modelo, o lwIP fornece funções como tcpip_callback() e tcpip_try_callback(). Essas funções permitem que uma task qualquer solicite a execução de uma função dentro da TCP/IP thread, de forma assíncrona. Esse padrão é a base de arquiteturas seguras quando se utiliza a RAW API, pois impede acessos diretos às estruturas internas da pilha a partir de múltiplos contextos.
A Netconn e a Sockets API se apoiam nesse mesmo mecanismo, mas encapsulam toda essa complexidade. Quando uma task chama netconn_recv(), por exemplo, o que acontece por baixo dos panos é um diálogo entre mailboxes internas, semáforos e a TCP/IP thread. A task da aplicação fica bloqueada de forma controlada, enquanto o lwIP continua operando normalmente. Isso explica por que essas APIs são thread-safe, mas também por que consomem mais RAM e stack.
Um erro clássico em projetos é tentar “otimizar” esse fluxo acessando diretamente buffers, chamando funções RAW a partir de tasks comuns ou usando mutexes do FreeRTOS para proteger estruturas do lwIP. Essa abordagem não funciona e quebra premissas internas do stack. O modelo correto não é proteger o lwIP com mutexes, mas respeitar o confinamento de contexto imposto pela TCP/IP thread.
Na próxima seção, vamos aplicar esses conceitos de forma concreta, construindo um exemplo prático de inicialização do lwIP com FreeRTOS, incluindo criação de tarefas, configuração do driver Ethernet e verificação do fluxo de dados.
Exemplo prático: inicialização do lwIP em um sistema FreeRTOS
Nesta seção vamos sair do plano conceitual e entrar no código real, mostrando como um sistema típico FreeRTOS + lwIP é inicializado. O objetivo aqui não é apenas “fazer funcionar”, mas entender por que cada passo existe, qual o contexto de execução envolvido e quais são os erros clássicos que devem ser evitados.
Assumiremos um cenário bastante comum em projetos industriais:
- FreeRTOS já inicializado
- Interface Ethernet com driver próprio (MAC + PHY)
- lwIP configurado para uso com TCP/IP thread dedicada
- Uso futuro de Netconn ou Sockets API
5.1 Ordem correta de inicialização
A ordem de inicialização é crítica. Um erro frequente é criar tasks de aplicação que usam rede antes do lwIP estar totalmente operacional.
A sequência correta, em alto nível, é:
- Inicializar hardware básico (clock, GPIO, PHY, MAC)
- Inicializar o lwIP (
tcpip_init) - Configurar interface de rede (
netif) - Subir a interface (
netif_set_up) - Só então criar tasks da aplicação que usam rede
- Iniciar o scheduler do FreeRTOS
Essa ordem garante que nenhuma task tente acessar a pilha TCP/IP fora do contexto correto.
5.2 Inicializando o lwIP (tcpip_init)
O primeiro ponto de integração direta entre FreeRTOS e lwIP é a chamada a tcpip_init():
#include "lwip/tcpip.h"
static void lwip_init_done(void *arg)
{
/* Callback chamado quando a TCP/IP thread está pronta */
(void)arg;
}
void lwip_stack_init(void)
{
tcpip_init(lwip_init_done, NULL);
}
O que acontece aqui, de fato:
- O lwIP cria internamente a TCP/IP thread como uma task do FreeRTOS
- São criadas mailboxes internas para troca de mensagens
- Timers internos do TCP/IP são registrados
- O callback
lwip_init_done()é executado no contexto da TCP/IP thread, não na task chamadora
Esse detalhe é importante: qualquer código executado nesse callback já está em um contexto seguro para chamadas RAW, caso necessário.
5.3 Configuração da interface de rede (netif)
Após a inicialização do lwIP, precisamos registrar a interface de rede:
#include "lwip/netif.h"
#include "lwip/ip_addr.h"
#include "lwip/dhcp.h"
static struct netif netif_eth;
void netif_config(void)
{
ip4_addr_t ipaddr;
ip4_addr_t netmask;
ip4_addr_t gw;
IP4_ADDR(&ipaddr, 0, 0, 0, 0); /* DHCP */
IP4_ADDR(&netmask, 0, 0, 0, 0);
IP4_ADDR(&gw, 0, 0, 0, 0);
netif_add(&netif_eth,
&ipaddr,
&netmask,
&gw,
NULL,
ethernetif_init,
tcpip_input);
netif_set_default(&netif_eth);
netif_set_up(&netif_eth);
dhcp_start(&netif_eth);
}
Aqui aparecem alguns pontos arquiteturais importantes:
ethernetif_inité o driver de baixo nível, responsável por integrar MAC/PHY ao lwIPtcpip_inputgarante que pacotes recebidos sejam entregues à TCP/IP thread, e não processados no contexto errado- O uso de DHCP é opcional, mas comum em sistemas conectados
Essa função normalmente é chamada após tcpip_init() e antes da criação das tasks de aplicação.
5.4 Criação das tasks da aplicação
Somente depois que a pilha está pronta é que criamos tasks que usam rede:
void network_task(void *argument)
{
/* A partir daqui, é seguro usar Netconn ou Sockets API */
for (;;)
{
/* Lógica de rede */
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void app_start(void)
{
xTaskCreate(network_task,
"NetTask",
1024,
NULL,
tskIDLE_PRIORITY + 2,
NULL);
}
Note que:
- A task não interage diretamente com estruturas RAW
- O tamanho da stack já precisa considerar buffers de rede
- A prioridade deve ser pensada em conjunto com a TCP/IP thread
Um erro comum é dar prioridade muito baixa para a TCP/IP thread e alta para tasks de aplicação, o que resulta em timeouts, perda de pacotes e conexões instáveis.
5.5 Erros clássicos nesta fase
Alguns problemas recorrentes que surgem exatamente nesse ponto:
- Criar sockets antes do
netif_set_up - Chamar funções RAW a partir de tasks comuns
- Executar processamento pesado no driver Ethernet (ISR)
- Stack insuficiente para tasks de rede
- Prioridade inadequada da TCP/IP thread
Todos esses erros levam a sintomas difíceis de diagnosticar, como travamentos aleatórios ou falhas intermitentes de comunicação.
Na próxima seção, vamos construir um exemplo completo de servidor TCP/HTTP, mostrando como uma task FreeRTOS usa a Netconn ou Sockets API corretamente, com explicação detalhada de cada chamada.
Exemplo completo: servidor TCP usando Sockets API em uma task FreeRTOS
Aqui vamos montar um servidor TCP simples (estilo “echo server” + base para HTTP) rodando em uma task do FreeRTOS usando a BSD Sockets API do lwIP. A ideia é te dar um esqueleto real que funciona em 80% dos firmwares conectados: uma task aceita conexões, recebe dados, responde e fecha. Em seguida, vou apontar onde normalmente você evolui para HTTP, MQTT, Modbus/TCP etc.
6.1 Requisitos e premissas
Para usar Sockets no lwIP você normalmente precisa ter (no lwipopts.h):
LWIP_SOCKET=1LWIP_NETCONN=1(sockets geralmente dependem dela)LWIP_TCP=1LWIP_DNS=1(se quiser resolver nomes)LWIP_DHCP=1(se usar DHCP)
E lembrar do modelo: a task de sockets é sua, mas o lwIP continua processando rede na TCP/IP thread.
6.2 Código do servidor TCP (com comentários didáticos)
#include "FreeRTOS.h"
#include "task.h"
#include "lwip/sockets.h"
#include "lwip/inet.h"
#include <string.h>
#include <stdio.h>
/* Ajuste conforme sua aplicação */
#define SERVER_PORT 5000
#define RX_BUF_SIZE 1024
#define LISTEN_BACKLOG 4
static void tcp_server_task(void *arg)
{
(void)arg;
int listen_fd = -1;
int client_fd = -1;
struct sockaddr_in addr;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
/* Buffer local: cuidado com stack! */
char rx_buf[RX_BUF_SIZE];
/* 1) Criar socket TCP */
listen_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (listen_fd < 0) {
/* Em sistemas embarcados: logue e reinicie a task ou sinalize falha */
vTaskDelete(NULL);
}
/* 2) Preencher estrutura de endereço */
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(SERVER_PORT);
addr.sin_addr.s_addr = htonl(INADDR_ANY); /* escuta em todas interfaces */
/* 3) Bind: amarra o socket à porta */
if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
closesocket(listen_fd);
vTaskDelete(NULL);
}
/* 4) Listen: coloca em modo servidor */
if (listen(listen_fd, LISTEN_BACKLOG) < 0) {
closesocket(listen_fd);
vTaskDelete(NULL);
}
for (;;)
{
/* 5) Accept: bloqueia esperando cliente */
client_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len);
if (client_fd < 0) {
/* Se accept falhar, normalmente você continua tentando */
vTaskDelay(pdMS_TO_TICKS(100));
continue;
}
/* 6) Loop de recepção */
for (;;)
{
int n = recv(client_fd, rx_buf, sizeof(rx_buf) - 1, 0);
if (n <= 0) {
/* n == 0 -> cliente fechou; n < 0 -> erro */
break;
}
rx_buf[n] = '\0';
/* 7) Aqui entra a lógica da aplicação.
Para um echo server: devolve os bytes recebidos. */
send(client_fd, rx_buf, n, 0);
/* Exemplo: se quiser encerrar ao receber "quit" */
if (strncmp(rx_buf, "quit", 4) == 0) {
break;
}
}
/* 8) Fecha conexão do cliente */
closesocket(client_fd);
client_fd = -1;
}
}
/* Função de arranque */
void start_tcp_server(void)
{
/* Stack precisa ser dimensionada: sockets + buffer local */
xTaskCreate(tcp_server_task,
"TCPServer",
2048, /* ajuste real conforme seu MCU/heap */
NULL,
tskIDLE_PRIORITY + 2, /* prioridade média */
NULL);
}
6.3 Pontos críticos (onde a maioria erra)
A primeira armadilha é stack. Repare que rx_buf[1024] está na stack da task. Em Cortex-M, 2 KB de stack pode ficar apertado se você começar a montar respostas HTTP grandes, fazer parsing pesado ou usar printf. Uma prática robusta é mover buffers grandes para heap/estático e deixar stack para variáveis pequenas.
A segunda armadilha é bloqueio infinito. accept() e recv() bloqueiam. Isso pode ser OK em um servidor dedicado, mas se você precisa de shutdown limpo, watchdog cooperativo ou multiplexar atividades, você vai querer timeouts via setsockopt() com SO_RCVTIMEO e SO_SNDTIMEO, ou usar select() para multiplexar.
A terceira armadilha é “fazer tudo nessa task”. Em firmware sério, o servidor de sockets vira um front-end que entrega payloads para outras tasks via queues (produtor/consumidor), mantendo o servidor leve e previsível. Caso contrário, você mistura rede + lógica de negócio + acesso a periféricos no mesmo contexto e cria latência ruim e travamentos em cascata.
6.4 Melhorando com timeouts (essencial para robustez)
Um exemplo direto para evitar travar em recv():
struct timeval tv;
tv.tv_sec = 5;
tv.tv_usec = 0;
setsockopt(client_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
Com isso, recv() retorna erro após 5 s sem dados, e você pode encerrar a conexão ou apenas continuar.
Na próxima seção, vamos fazer exatamente o que transforma esse exemplo em arquitetura “de produção”:
- Task de sockets como Gatekeeper
- Entrega de dados para uma fila FreeRTOS
- Um ou mais consumidores processando requisições
- Resposta retornando ao cliente de forma segura
Arquitetura de produção: servidor de sockets como Gatekeeper + filas FreeRTOS (produtor/consumidor)
Quando você coloca lwIP + FreeRTOS em um produto real, a pergunta não é “como abrir um socket”, mas sim: como garantir previsibilidade, isolamento de falhas e escalabilidade quando há múltiplas conexões, parsing de protocolo, acesso a periféricos e regras de negócio rodando ao mesmo tempo. O padrão mais sólido aqui é tratar a task de rede como um Gatekeeper (porteiro): ela faz o mínimo necessário (I/O de rede e framing básico), empacota mensagens e entrega para a aplicação via Queue. As tasks de aplicação, por sua vez, consomem essas mensagens e devolvem respostas por um caminho igualmente controlado.
A razão técnica é simples: a pilha de rede precisa de tempo de CPU para manter conexões (ACKs, retransmissões, janelas TCP), e isso pode ser prejudicado se você faz parsing pesado, acesso a flash, escrita em SD, controle de motores ou logs complexos dentro da mesma task que está segurando o socket. Além disso, erros na lógica de negócio não podem derrubar a infraestrutura de rede. Com Gatekeeper + filas, você desacopla os domínios: rede continua estável, aplicação pode reiniciar tasks de processamento, e o sistema ganha uma arquitetura “defensiva”.
A seguir, vamos montar um exemplo funcional e didático. Ele não é “o menor possível”: ele é “o mais correto possível” para firmware.
7.1 Estruturas de mensagem e filas
Vamos definir duas filas:
rxQueue: mensagens recebidas da rede para a aplicaçãotxQueue: respostas da aplicação para serem transmitidas pela task de rede
Cada mensagem precisa carregar, no mínimo:
- identificador da conexão (socket do cliente ou um handle abstrato)
- payload (bytes recebidos)
- tamanho
- metadados (opcional: IP/porta, timestamp etc.)
#include "FreeRTOS.h"
#include "queue.h"
#include <stdint.h>
#define MAX_PAYLOAD 512
typedef struct
{
int client_fd; /* Identifica a conexão */
uint16_t len;
uint8_t payload[MAX_PAYLOAD]; /* Mensagem "copiada" para trânsito seguro */
} NetMessage_t;
static QueueHandle_t rxQueue;
static QueueHandle_t txQueue;
void net_queues_init(void)
{
rxQueue = xQueueCreate(8, sizeof(NetMessage_t));
txQueue = xQueueCreate(8, sizeof(NetMessage_t));
}
Observação crítica: aqui estamos usando cópia de payload. Em sistemas mais exigentes, você pode migrar para zero-copy com pbuf (RAW API) ou pool de buffers. Mas começar com cópia é geralmente a forma mais estável de construir algo robusto e debugar rápido.
7.2 Task Gatekeeper: recebe do socket e entrega na rxQueue
A task de rede agora vira uma “central de I/O”: aceita conexões, recebe dados e empacota mensagens para a fila.
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "lwip/sockets.h"
#include "lwip/inet.h"
#include <string.h>
#define SERVER_PORT 5000
static void socket_gatekeeper_task(void *arg)
{
(void)arg;
int listen_fd;
struct sockaddr_in addr;
listen_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (listen_fd < 0) vTaskDelete(NULL);
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(SERVER_PORT);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
closesocket(listen_fd);
vTaskDelete(NULL);
}
if (listen(listen_fd, 4) < 0) {
closesocket(listen_fd);
vTaskDelete(NULL);
}
for (;;)
{
int client_fd = accept(listen_fd, NULL, NULL);
if (client_fd < 0) {
vTaskDelay(pdMS_TO_TICKS(50));
continue;
}
/* Exemplo simples: atende 1 cliente por vez.
Em produção você pode criar uma task por cliente OU usar select(). */
for (;;)
{
NetMessage_t msg;
int n = recv(client_fd, msg.payload, MAX_PAYLOAD, 0);
if (n <= 0) break;
msg.client_fd = client_fd;
msg.len = (uint16_t)n;
/* Entrega para aplicação: se a fila estiver cheia, você precisa decidir política:
- descartar
- bloquear por tempo limitado
- desconectar cliente
*/
if (xQueueSend(rxQueue, &msg, pdMS_TO_TICKS(20)) != pdPASS) {
/* Política simples: descarta e segue */
}
/* Antes de receber mais, veja se há resposta pronta para este cliente */
NetMessage_t out;
if (xQueueReceive(txQueue, &out, 0) == pdPASS) {
if (out.client_fd == client_fd && out.len > 0) {
send(client_fd, out.payload, out.len, 0);
} else {
/* Se veio resposta de outro cliente, em produção você
rotearia corretamente (mapa de conexões, etc.) */
}
}
}
closesocket(client_fd);
}
}
void start_socket_gatekeeper(void)
{
xTaskCreate(socket_gatekeeper_task,
"NetGate",
2048,
NULL,
tskIDLE_PRIORITY + 3, /* Um pouco acima da aplicação */
NULL);
}
Por que esse desenho é bom? Porque o Gatekeeper mantém I/O simples e previsível: ele não interpreta protocolo nem acessa hardware. Ele apenas “empurra bytes”.
Crítica (importante): este exemplo atende um cliente por vez. Para múltiplos clientes, as duas opções típicas são:
- uma task por conexão (mais simples, mas consome RAM/stack)
select()com um loop único multiplexando sockets (mais eficiente, mais complexo)
Na próxima seção eu te mostro os dois modelos, mas antes precisamos fechar o ciclo com o consumidor.
7.3 Task consumidora: processa mensagem e devolve resposta
Agora a lógica de aplicação recebe mensagens da rxQueue, interpreta e devolve uma resposta pela txQueue.
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include <string.h>
static void app_worker_task(void *arg)
{
(void)arg;
for (;;)
{
NetMessage_t in;
if (xQueueReceive(rxQueue, &in, portMAX_DELAY) == pdPASS)
{
NetMessage_t out;
memset(&out, 0, sizeof(out));
out.client_fd = in.client_fd;
/* Exemplo de "protocolo": comandos ASCII */
if (in.len >= 4 && memcmp(in.payload, "ping", 4) == 0) {
const char *resp = "pong\n";
out.len = (uint16_t)strlen(resp);
memcpy(out.payload, resp, out.len);
} else {
const char *resp = "unknown\n";
out.len = (uint16_t)strlen(resp);
memcpy(out.payload, resp, out.len);
}
/* Envia resposta para o Gatekeeper transmitir */
(void)xQueueSend(txQueue, &out, pdMS_TO_TICKS(50));
}
}
}
void start_app_worker(void)
{
xTaskCreate(app_worker_task,
"AppWorker",
2048,
NULL,
tskIDLE_PRIORITY + 2,
NULL);
}
Aqui você ganhou algo decisivo: se o parsing for pesado, você pode criar vários workers consumidores (ou uma pool) sem mexer na task de rede. E se um worker travar, você pode reiniciá-lo sem derrubar a rede (principalmente se tiver Task Monitor/Watchdog como você já vem tratando na série).
7.4 Ajustes essenciais para “produção”
O exemplo acima é a forma didática mais direta, mas em firmware real você geralmente aplica melhorias:
- Roteamento de respostas por conexão: em vez de uma
txQueueúnica, você cria uma fila por cliente (ou um mapa de conexões). - Política de backpressure: se
rxQueueenche, você reduz leitura do socket, aplicarecvcom timeout, ou desconecta cliente. - Framing: TCP é stream, não pacote. Seu “comando” pode vir partido em múltiplos
recv(). Então você implementa framing (por delimitador\n, por tamanho fixo, ou por header com length). - Stack e heap: payload copiado custa RAM; quando necessário, migra para pool de buffers.
Na próxima seção, vamos atacar exatamente o que separa “demo” de “produto”:
TCP é stream — então precisamos de framing e parser robusto, além de timeouts e manuseio de múltiplos clientes.
TCP é stream: framing robusto + múltiplos clientes (task por conexão vs select())
Se tem um erro conceitual que derruba sistemas FreeRTOS + lwIP “do nada”, é tratar recv() como se cada chamada retornasse uma mensagem completa. TCP não entrega mensagens — entrega um fluxo (stream). Isso significa que um comando ping\n pode chegar como p, depois ing\n, ou pode chegar junto com outro comando colado, ou pode vir com bytes extras. Se você não implementar framing, sua aplicação vai funcionar em bancada e falhar sob carga, ruído de rede, latência variável ou retransmissões.
A solução arquitetural é definir um protocolo com framing claro. Os dois modelos mais comuns em firmware são: (1) delimitador (por exemplo \n) e (2) length-prefix (header com tamanho). Vou te mostrar os dois, e em seguida mostro como isso muda quando você tem múltiplos clientes: ou você cria uma task por conexão (simples, mais RAM) ou usa select() (eficiente, mais complexo).
8.1 Framing por delimitador (\n)
Esse é o mais simples: você acumula bytes num buffer até achar \n. Cada linha vira um comando. É ótimo para console TCP, debug remoto, protocolos ASCII e comandos humanos. O cuidado é evitar overflow: se o cliente mandar uma linha enorme sem \n, você precisa cortar, descartar ou desconectar.
Exemplo de acumulador por conexão:
#include <string.h>
#include <stdint.h>
#define LINE_BUF_SZ 256
typedef struct
{
uint8_t buf[LINE_BUF_SZ];
uint16_t used;
} LineFramer_t;
/**
* @brief Alimenta o framer com bytes recebidos e extrai 0..N linhas completas.
* Retorna 1 quando extraiu uma linha, 0 quando ainda não tem linha.
*/
static int framer_try_get_line(LineFramer_t *f, const uint8_t *data, uint16_t len,
uint8_t *out_line, uint16_t *out_len)
{
/* Copia com proteção */
uint16_t space = (LINE_BUF_SZ - 1) - f->used;
if (len > space) {
/* Política simples: reset (em produção: desconectar cliente) */
f->used = 0;
return 0;
}
memcpy(&f->buf[f->used], data, len);
f->used += len;
f->buf[f->used] = 0;
/* Procura delimitador */
uint8_t *nl = (uint8_t*)memchr(f->buf, '\n', f->used);
if (!nl) return 0;
uint16_t line_len = (uint16_t)(nl - f->buf) + 1; /* inclui '\n' */
memcpy(out_line, f->buf, line_len);
*out_len = line_len;
/* Remove a linha do buffer (shift) */
uint16_t remaining = f->used - line_len;
memmove(f->buf, &f->buf[line_len], remaining);
f->used = remaining;
return 1;
}
Uso típico no loop do socket: você chama recv(), joga os bytes no framer e enquanto ele conseguir extrair linhas você envia para a fila (rxQueue).
8.2 Framing por header com tamanho (length-prefix)
Esse é o padrão de protocolos binários robustos. Você define, por exemplo:
- 2 bytes:
len(big-endian) lenbytes: payload
Vantagem: não depende de delimitador e funciona bem com binário. Desvantagem: você precisa validar tamanho, lidar com endianess e limites.
Estrutura de parsing por estados:
#include <stdint.h>
#include <string.h>
#define MAX_FRAME 512
typedef enum { ST_LEN1, ST_LEN2, ST_PAYLOAD } ParseState_t;
typedef struct
{
ParseState_t st;
uint16_t expected;
uint16_t got;
uint8_t payload[MAX_FRAME];
} LenFramer_t;
static void lenframer_init(LenFramer_t *f)
{
f->st = ST_LEN1;
f->expected = 0;
f->got = 0;
}
/**
* @brief Consome bytes e extrai frames completos. Retorna 1 quando extrai um frame.
*/
static int lenframer_feed(LenFramer_t *f, const uint8_t *data, uint16_t len,
uint8_t *out_payload, uint16_t *out_len)
{
for (uint16_t i = 0; i < len; i++)
{
uint8_t b = data[i];
switch (f->st)
{
case ST_LEN1:
f->expected = ((uint16_t)b) << 8;
f->st = ST_LEN2;
break;
case ST_LEN2:
f->expected |= b;
if (f->expected == 0 || f->expected > MAX_FRAME) {
/* Frame inválido: reset (em produção: desconectar) */
lenframer_init(f);
break;
}
f->got = 0;
f->st = ST_PAYLOAD;
break;
case ST_PAYLOAD:
f->payload[f->got++] = b;
if (f->got >= f->expected) {
memcpy(out_payload, f->payload, f->expected);
*out_len = f->expected;
lenframer_init(f);
return 1; /* extraiu 1 frame */
}
break;
}
}
return 0;
}
Esse modelo é extremamente estável em ambiente real porque mantém o protocolo bem definido.
8.3 Múltiplos clientes: duas arquiteturas
Agora vem o ponto de engenharia de firmware: como atender N conexões sem destruir RAM e sem perder previsibilidade.
Opção A — Uma task por conexão (modelo simples)
- Ao aceitar um cliente, você cria uma task dedicada para ele.
- Cada task tem seu framer + buffer + loop
recv(). - Fácil de entender e depurar.
- Custo: cada task consome stack e TCB; em MCU pequeno isso vira limite rápido.
Padrão típico:
static void client_task(void *arg)
{
int client_fd = (int)(intptr_t)arg;
LineFramer_t fr;
fr.used = 0;
for (;;)
{
uint8_t tmp[128];
int n = recv(client_fd, tmp, sizeof(tmp), 0);
if (n <= 0) break;
uint8_t line[LINE_BUF_SZ];
uint16_t line_len;
while (framer_try_get_line(&fr, tmp, (uint16_t)n, line, &line_len)) {
/* aqui você empacota e manda para rxQueue */
}
}
closesocket(client_fd);
vTaskDelete(NULL);
}
Opção B — select() (modelo eficiente)
- Uma única task monitora vários sockets ao mesmo tempo.
- Melhor uso de RAM (uma task só), bom para dezenas de conexões.
- Custo: mais complexo; exige tabela de conexões e estados por cliente.
Se seu firmware precisa escalar, select() costuma ser o caminho. Se precisa ser simples e você terá poucos clientes, task por conexão é perfeito.
8.4 Um detalhe que muita gente ignora: prioridade e latência
Não adianta ter framing perfeito se a task que atende sockets fica “no fim da fila” e perde janela TCP. Em geral, uma prática saudável é:
- TCP/IP thread: prioridade média-alta (depende do port e do vendor)
- Gatekeeper de sockets: prioridade logo abaixo da TCP/IP thread
- Workers de aplicação: prioridade média
- Logs/telemetria: prioridade baixa
Isso evita um cenário em que o processamento de aplicação “engole” a CPU e a rede degrada.
Na próxima seção, vamos fechar a arquitetura com os componentes que tornam isso resiliente em produto:
- timeouts completos (recv/send/connect/accept)
- keepalive TCP
- watchdog e task monitor aplicados ao domínio de rede
- política de backpressure quando filas enchem
- e um exemplo “quase HTTP” com parser mínimo
Robustez em rede: timeouts, keepalive, backpressure e integração com Task Monitor/Watchdog
Até aqui você já tem uma arquitetura correta. Agora entramos no que diferencia firmware de laboratório de firmware de campo: resiliência. Em sistemas FreeRTOS + lwIP, a maior parte das falhas em produção não vem de bugs óbvios, mas de bloqueios silenciosos, clientes mal-comportados, perda intermitente de link, filas saturadas e tarefas que parecem vivas, mas não fazem progresso. Esta seção trata exatamente desses pontos — com decisões práticas de engenharia.
9.1 Timeouts em sockets: nunca confie em bloqueios infinitos
Chamadas como accept(), recv() e send() bloqueiam por padrão. Em firmware, isso é perigoso: se um cliente trava no meio de uma transmissão, sua task pode ficar presa indefinidamente, impedindo shutdown limpo, watchdog cooperativo e diagnóstico.
A regra é simples: todo socket deve ter timeout.
static void socket_set_timeouts(int fd, int sec)
{
struct timeval tv;
tv.tv_sec = sec;
tv.tv_usec = 0;
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
}
Use isso logo após accept():
int client_fd = accept(listen_fd, NULL, NULL);
if (client_fd >= 0) {
socket_set_timeouts(client_fd, 5); /* 5s é um bom ponto inicial */
}
Com isso:
recv()retorna< 0após timeout- você pode encerrar a conexão, reavaliar estado ou alimentar watchdog
- evita tarefas “zumbis”
9.2 TCP Keepalive: detectando clientes mortos
Em redes reais, cliente pode cair sem fechar socket (queda de Wi-Fi, cabo puxado, crash). O TCP keepalive permite detectar isso.
int enable = 1;
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &enable, sizeof(enable));
int idle = 10; /* segundos ociosos antes do probe */
int interval = 5; /* intervalo entre probes */
int count = 3; /* probes antes de declarar morto */
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count));
Nem todos os ports do lwIP suportam todas essas opções, mas quando disponíveis, reduzem drasticamente conexões zumbis.
9.3 Backpressure: o que fazer quando as filas enchem
Esse ponto é crítico e raramente tratado em exemplos. Se sua rxQueue enche, o sistema está dizendo que não consegue processar dados na mesma taxa em que recebe. Ignorar isso leva a latência crescente e colapso.
Existem quatro políticas clássicas:
- Descartar mensagens novas
Simples, mas pode quebrar protocolo. - Bloquear o Gatekeeper por tempo limitado
if (xQueueSend(rxQueue, &msg, pdMS_TO_TICKS(50)) != pdPASS) { /* timeout: decide política */ } - Parar de ler do socket temporariamente
Com timeout emrecv(), você simplesmente não chamarecvpor um ciclo. - Desconectar cliente agressivo (muito comum em produto)
Se um cliente gera backpressure constante, ele é o problema.
Em firmware sério, a política 4 costuma ser a mais segura.
9.4 Integração com Task Monitor: detectar “tarefas vivas porém travadas”
Uma task bloqueada em recv() não parece travada para o scheduler — ela está em estado Blocked. Por isso, Task Monitor precisa ir além de “task rodando ou não”: precisa verificar progresso lógico.
Modelo simples: heartbeat por domínio.
volatile uint32_t net_heartbeat = 0;
void socket_gatekeeper_task(void *arg)
{
for (;;)
{
net_heartbeat++;
/* accept / recv / processamento */
}
}
O Task Monitor verifica:
static uint32_t last_net_hb = 0;
void task_monitor(void)
{
if (net_heartbeat == last_net_hb) {
/* rede não progrediu */
/* ação: reiniciar task, reiniciar interface, logar */
}
last_net_hb = net_heartbeat;
}
Isso detecta:
- deadlocks
- starvation
- loops bloqueados por erro lógico
Muito mais eficaz que apenas checar se a task existe.
9.5 Watchdog cooperativo aplicado à rede
Nunca alimente o watchdog dentro da task de sockets diretamente. Isso mascara falhas. O correto é:
- task de rede atualiza estado/heartbeat
- task de supervisão decide se o sistema está saudável
- só ela alimenta o watchdog
Exemplo conceitual:
void supervisor_task(void *arg)
{
for (;;)
{
if (net_ok && app_ok && sensors_ok) {
feed_watchdog();
}
vTaskDelay(pdMS_TO_TICKS(500));
}
}
Assim, falhas de rede resetam o sistema, em vez de ficarem ocultas.
9.6 Mini-HTTP: aplicação prática dos conceitos
Para fechar, um exemplo conceitual de servidor estilo HTTP minimalista:
- framing por
\r\n\r\n - timeout curto
- parsing mínimo
- resposta fixa
if (strstr(request, "GET /status")) {
const char *resp =
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/plain\r\n"
"Connection: close\r\n"
"\r\n"
"OK\n";
send(fd, resp, strlen(resp), 0);
}
Em firmware, HTTP raramente precisa ser completo. Muitas vezes é só um protocolo humano para debug, status e configuração inicial. Quanto mais simples, mais confiável.